awgit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
awgit/cli.py ADDED
@@ -0,0 +1,488 @@
1
+ """CLI entrypoint for the semantic-VCS layer (`python -m lib.awgit.cli`).
2
+
3
+ Used by git hooks (capture, lease-check) and by agents (status, diff, lease).
4
+ Runs as its own sync process — no event loop is active, so the blocking file
5
+ locks in the stores are PQ010-compliant.
6
+
7
+ Actor attribution prefers an explicit ``--actor``, then ``AITHER_ACTOR``, then
8
+ the VERIFIED GitHub login (the Aitherium GitHub OAuth-app identity via ``gh``,
9
+ cached), then the commit author. Every op records ``actor_verified`` /
10
+ ``verified_actor`` so a self-asserted agent name is never mistaken for verified
11
+ identity — the verified half is the authoritative attribution.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import List, Optional
22
+
23
+ from awgit.capture import capture_ops
24
+ from awgit.diff import diff_git, render
25
+ from awgit.leases import LeaseConflictError, LeaseRegistry, coverage_gap
26
+ from awgit.merge import list_conflicts, merge_ops, resolve_conflict
27
+ from awgit.oplog import OpLog
28
+
29
+
30
+ def _actor(args: argparse.Namespace) -> str:
31
+ return (
32
+ getattr(args, "actor", None)
33
+ or os.environ.get("AITHER_ACTOR")
34
+ or "unknown"
35
+ )
36
+
37
+
38
+ def _cmd_capture(args: argparse.Namespace) -> int:
39
+ try:
40
+ op = capture_ops(
41
+ args.sha,
42
+ actor=args.actor,
43
+ data_root=Path(args.data_root) if args.data_root else None,
44
+ )
45
+ except ValueError as exc:
46
+ print(f"vcs: {exc}", file=sys.stderr)
47
+ return 2
48
+ if op is None:
49
+ print(f"vcs: no semantic changes for {args.sha}")
50
+ return 0
51
+ print(f"vcs: op {op.op_id} recorded ({op.summary})")
52
+ return 0
53
+
54
+
55
+ def _cmd_diff(args: argparse.Namespace) -> int:
56
+ try:
57
+ changes = diff_git(args.a, args.b)
58
+ except ValueError as exc:
59
+ print(f"vcs: {exc}", file=sys.stderr)
60
+ return 2
61
+ if not changes:
62
+ print("vcs: no semantic changes")
63
+ return 0
64
+ for line in render(changes):
65
+ print(line)
66
+ return 0
67
+
68
+
69
+ def _cmd_merge_preview(args: argparse.Namespace) -> int:
70
+ op_a = capture_ops(args.a, actor=args.actor)
71
+ op_b = capture_ops(args.b, actor=args.actor)
72
+ a_ops = [op_a] if op_a else []
73
+ b_ops = [op_b] if op_b else []
74
+ result = merge_ops(a_ops, b_ops)
75
+ print(f"vcs: merge status: {result.status}")
76
+ for note in result.notes:
77
+ print(f" {note}")
78
+ for c in result.conflicts:
79
+ print(f" CONFLICT {c.node_id} ({c.symbol}) — escalate for human review")
80
+ return 0 if result.status != "conflict" else 1
81
+
82
+
83
+ def _cmd_merge_conflicts(args: argparse.Namespace) -> int:
84
+ for c in list_conflicts():
85
+ print(f"{c.conflict_id} {c.node_id} {c.symbol} [{c.status}]")
86
+ return 0
87
+
88
+
89
+ def _cmd_resolve_conflict(args: argparse.Namespace) -> int:
90
+ if not args.body:
91
+ print("vcs: --body <file> required", file=sys.stderr)
92
+ return 2
93
+ try:
94
+ body = Path(args.body).read_text(encoding="utf-8")
95
+ except OSError as exc:
96
+ print(f"vcs: cannot read {args.body}: {exc}", file=sys.stderr)
97
+ return 2
98
+ resolver = args.resolver or os.environ.get("AITHER_ACTOR") or "unknown"
99
+ op = resolve_conflict(args.conflict_id, resolved_body=body, resolver=resolver)
100
+ if op is None:
101
+ print(f"vcs: conflict {args.conflict_id} not found", file=sys.stderr)
102
+ return 1
103
+ print(f"vcs: conflict resolved; synthetic op {op.op_id} recorded")
104
+ return 0
105
+
106
+
107
+ def _cmd_status(args: argparse.Namespace) -> int:
108
+ log = OpLog()
109
+ ops = log.all_ops()
110
+ last = max(ops, key=lambda o: o.ts) if ops else None
111
+ print(f"vcs: {len(ops)} ops in {log.path}")
112
+ if last is not None:
113
+ print(f" last: {last.ts} {last.actor} {last.summary}")
114
+ return 0
115
+
116
+
117
+ def _cmd_lease(args: argparse.Namespace) -> int:
118
+ registry = LeaseRegistry()
119
+ cmd = args.lease_cmd
120
+ who = _actor(args)
121
+ if cmd == "acquire":
122
+ try:
123
+ leases = registry.acquire(
124
+ who, args.targets, ttl_sec=args.ttl, reason=args.reason
125
+ )
126
+ except LeaseConflictError as exc:
127
+ print(f"vcs: {exc}", file=sys.stderr)
128
+ return 1
129
+ for lz in leases:
130
+ print(f"vcs: lease {lz.lease_id} {lz.target} until {lz.expires_ts}")
131
+ return 0
132
+ if cmd == "heartbeat":
133
+ print(f"vcs: heartbeat refreshed {registry.heartbeat(who, args.ids)} leases")
134
+ return 0
135
+ if cmd == "release":
136
+ print(f"vcs: released {registry.release(who, args.ids)} leases")
137
+ return 0
138
+ if cmd == "list":
139
+ for lz in sorted(registry.active_leases(), key=lambda x: x.target):
140
+ print(f"{lz.lease_id} {lz.status} {lz.actor} {lz.kind} {lz.target} "
141
+ f"until {lz.expires_ts}")
142
+ return 0
143
+ if cmd == "sweep":
144
+ print(f"vcs: swept {registry.sweep_expired()} expired leases")
145
+ return 0
146
+ return 2
147
+
148
+
149
+ def _staged_files(repo: Path) -> List[str]:
150
+ out = subprocess.run(
151
+ ["git", "diff", "--cached", "--name-only"],
152
+ cwd=str(repo),
153
+ capture_output=True,
154
+ text=True,
155
+ encoding="utf-8",
156
+ errors="replace",
157
+ ).stdout
158
+ return [ln for ln in out.splitlines() if ln.strip()]
159
+
160
+
161
+ def _cmd_lease_check(args: argparse.Namespace) -> int:
162
+ if os.environ.get("VCS_LEASES_ENFORCE", "0") != "1":
163
+ print("vcs: lease-check not enforced (VCS_LEASES_ENFORCE=0)")
164
+ return 0
165
+ repo = Path(os.environ.get("VCS_REPO_ROOT", os.getcwd()))
166
+ who = _actor(args)
167
+ if who == "unknown":
168
+ print("vcs: lease-check requires AITHER_ACTOR (or --actor)", file=sys.stderr)
169
+ return 1
170
+ gap = coverage_gap(_staged_files(repo), who)
171
+ if gap:
172
+ print(
173
+ "vcs: commit rejected — no active lease covering: " + ", ".join(gap),
174
+ file=sys.stderr,
175
+ )
176
+ return 1
177
+ print("vcs: lease-check OK")
178
+ return 0
179
+
180
+
181
+ def _cmd_bodies(args: argparse.Namespace) -> int:
182
+ from awgit.bodies import BodyStore
183
+
184
+ store = BodyStore()
185
+ if not args.get:
186
+ st = store.stats()
187
+ print(f"vcs: body store: {st['blobs']} blobs, {st['bytes']:,} bytes")
188
+ return 0
189
+ body = store.get(args.get)
190
+ if body is None:
191
+ print(f"vcs: no body for {args.get}", file=sys.stderr)
192
+ return 1
193
+ if args.out:
194
+ try:
195
+ Path(args.out).write_bytes(body)
196
+ except OSError as exc:
197
+ print(f"vcs: cannot write {args.out}: {exc}", file=sys.stderr)
198
+ return 2
199
+ print(f"vcs: wrote {len(body)} bytes to {args.out}")
200
+ else:
201
+ sys.stdout.write(body.decode("utf-8", errors="replace"))
202
+ return 0
203
+
204
+
205
+ def _cmd_dedupe(args: argparse.Namespace) -> int:
206
+ from awgit.bodies import BodyStore, dedupe_report, op_referenced_shas
207
+
208
+ if args.scan:
209
+ rep = dedupe_report([Path(p) for p in args.scan])
210
+ print(
211
+ f"vcs: dedupe across {len(args.scan)} trees: "
212
+ f"{rep['groups']} identical-content groups, "
213
+ f"{rep['duplicate_files']} duplicate files, "
214
+ f"{rep['wasted_bytes']:,} bytes duplicated"
215
+ )
216
+ return 0
217
+ if args.reclaim:
218
+ from awgit.bodies import reclaim
219
+
220
+ res = reclaim([Path(p) for p in args.reclaim], dry_run=not args.apply)
221
+ verb = "linked" if args.apply else "would link"
222
+ print(
223
+ f"vcs: reclaim {verb} {res['linked']} duplicate files across "
224
+ f"{res['groups']} groups ({res['reclaimed_bytes']:,} bytes)"
225
+ )
226
+ print(
227
+ f"vcs: skipped {res['skipped_tracked']} git-tracked files (never linked)"
228
+ )
229
+ if not args.apply:
230
+ print("vcs: dry-run — pass --apply to actually hard-link")
231
+ return 0
232
+ store = BodyStore()
233
+ st = store.stats()
234
+ if args.gc:
235
+ referenced = op_referenced_shas()
236
+ res = store.gc(referenced, dry_run=not args.apply)
237
+ verb = "removed" if args.apply else "would remove"
238
+ print(
239
+ f"vcs: gc {verb} {res['removed']} orphaned blobs "
240
+ f"({res['freed']:,} bytes)"
241
+ )
242
+ if not args.apply:
243
+ print("vcs: dry-run — pass --apply to actually delete")
244
+ return 0
245
+ referenced = op_referenced_shas()
246
+ orphaned = len(store.blob_shas() - referenced)
247
+ print(
248
+ f"vcs: body store: {st['blobs']} blobs, {st['bytes']:,} bytes, "
249
+ f"{len(referenced)} referenced by op-log, {orphaned} orphaned"
250
+ )
251
+ if orphaned:
252
+ print("vcs: run `awgit dedupe --gc` (dry-run) to see reclaimable space")
253
+ return 0
254
+
255
+
256
+ def _cmd_ledger(args: argparse.Namespace) -> int:
257
+ """Attribution view — who changed what, under a verified GitHub identity."""
258
+ from awgit.ledger import op_to_ledger_entry
259
+ from awgit.oplog import OpLog
260
+
261
+ ops = OpLog().all_ops()
262
+ if args.op:
263
+ ops = [o for o in ops if o.op_id == args.op]
264
+ elif args.sha:
265
+ ops = [o for o in ops if o.git_sha == args.sha]
266
+ if not ops:
267
+ print("vcs: ledger: no ops match", file=sys.stderr)
268
+ return 1
269
+ for op in ops:
270
+ entry = op_to_ledger_entry(op)
271
+ verified = (
272
+ f" (verified {entry.verified_actor})" if entry.actor_verified else ""
273
+ )
274
+ print(
275
+ f"{entry.ledger_ref} {entry.actor}{verified} "
276
+ f"{entry.git_sha[:10]} {entry.node_changes} node_changes {entry.ts}"
277
+ )
278
+ return 0
279
+
280
+
281
+ def _cmd_sync(args: argparse.Namespace) -> int:
282
+ import json as _json
283
+
284
+ from awgit.sync import export_delta, import_delta, sync_status
285
+
286
+ data_root = Path(args.data_root) if args.data_root else None
287
+ if args.export:
288
+ # export the ops a PEER is missing; default full-clone (known empty).
289
+ known = set(args.known) if args.known else set()
290
+ bundle = export_delta(
291
+ known, data_root=data_root, include_bodies=not args.meta_only
292
+ )
293
+ out = args.out or "awgit-delta.json"
294
+ try:
295
+ Path(out).write_text(_json.dumps(bundle), encoding="utf-8")
296
+ except OSError as exc:
297
+ print(f"vcs: cannot write {out}: {exc}", file=sys.stderr)
298
+ return 2
299
+ print(
300
+ f"vcs: exported {bundle['op_count']} ops, {bundle['body_count']} bodies -> {out}"
301
+ )
302
+ return 0
303
+ if args.import_file:
304
+ try:
305
+ bundle = _json.loads(Path(args.import_file).read_text(encoding="utf-8"))
306
+ except (OSError, ValueError) as exc:
307
+ print(f"vcs: cannot read bundle {args.import_file}: {exc}", file=sys.stderr)
308
+ return 2
309
+ res = import_delta(bundle, data_root=data_root)
310
+ print(
311
+ f"vcs: imported {res['imported_ops']} ops, {res['bodies']} bodies "
312
+ f"(skipped {res['skipped']}); applied {res['applied']}"
313
+ )
314
+ return 0
315
+ st = sync_status(data_root=data_root)
316
+ print(
317
+ f"vcs: sync {st['ops']} ops, {st['applied']} applied, {st['missing']} missing, "
318
+ f"{st['bodies']} bodies ({st['bytes']:,} bytes)"
319
+ )
320
+ return 0
321
+
322
+
323
+ def _cmd_hooks(args: argparse.Namespace) -> int:
324
+ """Install/uninstall the chained capture hooks (see bridge.install_hooks)."""
325
+ from awgit.bridge import install_hooks, uninstall_hooks
326
+
327
+ if args.action == "install":
328
+ installed = install_hooks(args.repo)
329
+ for p in installed:
330
+ print(f"vcs: installed hook {p}")
331
+ if not installed:
332
+ print("vcs: no hooks installed", file=sys.stderr)
333
+ return 2
334
+ return 0
335
+ if args.action == "uninstall":
336
+ for p in uninstall_hooks(args.repo):
337
+ print(f"vcs: restored hook {p}")
338
+ return 0
339
+ return 2
340
+
341
+
342
+ def main(argv: Optional[List[str]] = None) -> int:
343
+ parser = argparse.ArgumentParser(
344
+ prog="awgit",
345
+ description="awgit — Aither World-Graph git: semantic version control on top of git",
346
+ )
347
+ sub = parser.add_subparsers(dest="cmd", required=True)
348
+
349
+ p_capture = sub.add_parser("capture", help="capture a commit as an EditOp")
350
+ p_capture.add_argument("--sha", required=True)
351
+ p_capture.add_argument("--actor", default=None)
352
+ p_capture.add_argument(
353
+ "--data-root",
354
+ default=None,
355
+ help="vcs store directory (default: ~/.aither/awgit/data, or $VCS_DATA_ROOT)",
356
+ )
357
+
358
+ p_diff = sub.add_parser("diff", help="node-level diff between two shas")
359
+ p_diff.add_argument("a", help="base sha")
360
+ p_diff.add_argument("b", help="target sha")
361
+
362
+ sub.add_parser("status", help="op-log status")
363
+
364
+ p_mp = sub.add_parser("merge-preview", help="node-level merge preview of two shas")
365
+ p_mp.add_argument("a", help="base-side sha")
366
+ p_mp.add_argument("b", help="merge-side sha")
367
+ p_mp.add_argument("--actor", default=None)
368
+
369
+ sub.add_parser("merge-conflicts", help="list escalated merge conflicts")
370
+
371
+ p_rc = sub.add_parser("resolve-conflict", help="mark a conflict resolved (human)")
372
+ p_rc.add_argument("conflict_id")
373
+ p_rc.add_argument("--body", default=None, help="file with the resolved node body")
374
+ p_rc.add_argument("--resolver", default=None)
375
+
376
+ p_lease = sub.add_parser("lease", help="lease registry operations")
377
+ lsub = p_lease.add_subparsers(dest="lease_cmd", required=True)
378
+ p_la = lsub.add_parser("acquire", help="acquire leases for targets")
379
+ p_la.add_argument("targets", nargs="+")
380
+ p_la.add_argument("--ttl", type=int, default=300)
381
+ p_la.add_argument("--reason", default="")
382
+ p_la.add_argument("--actor", default=None)
383
+ p_lh = lsub.add_parser("heartbeat", help="refresh leases")
384
+ p_lh.add_argument("ids", nargs="+")
385
+ p_lh.add_argument("--actor", default=None)
386
+ p_lr = lsub.add_parser("release", help="release leases")
387
+ p_lr.add_argument("ids", nargs="+")
388
+ p_lr.add_argument("--actor", default=None)
389
+ lsub.add_parser("list", help="list active leases")
390
+ lsub.add_parser("sweep", help="sweep expired leases")
391
+
392
+ p_lc = sub.add_parser("lease-check", help="pre-commit lease gate")
393
+ p_lc.add_argument("--actor", default=None)
394
+
395
+ p_bodies = sub.add_parser(
396
+ "bodies", help="content-addressed body store (read a sha / stats)"
397
+ )
398
+ p_bodies.add_argument("--get", default=None, help="content address (sha) to read")
399
+ p_bodies.add_argument("--out", default=None, help="write the body to this file")
400
+
401
+ p_dedupe = sub.add_parser(
402
+ "dedupe", help="body store stats / gc / disk dedupe scan for duplicate files"
403
+ )
404
+ p_dedupe.add_argument(
405
+ "--scan", nargs="*", default=None, help="trees to hash for duplicate files"
406
+ )
407
+ p_dedupe.add_argument(
408
+ "--reclaim", nargs="*", default=None,
409
+ help="hard-link duplicate files under these trees (dry-run unless --apply)",
410
+ )
411
+ p_dedupe.add_argument(
412
+ "--gc", action="store_true",
413
+ help="remove body blobs the op-log does not reference (dry-run unless --apply)",
414
+ )
415
+ p_dedupe.add_argument(
416
+ "--apply", action="store_true",
417
+ help="with --reclaim/--gc: actually act (never touches git-tracked / referenced)",
418
+ )
419
+
420
+ p_ledger = sub.add_parser(
421
+ "ledger", help="op-log as attribution records (who changed what)"
422
+ )
423
+ p_ledger.add_argument("--op", default=None, help="op_id to show")
424
+ p_ledger.add_argument("--sha", default=None, help="git sha to show")
425
+
426
+ p_sync = sub.add_parser(
427
+ "sync", help="differential sync over the mesh (ops + bodies)"
428
+ )
429
+ p_sync.add_argument(
430
+ "--export", action="store_true", help="export the delta to a bundle file"
431
+ )
432
+ p_sync.add_argument("--out", default=None, help="bundle path for --export")
433
+ p_sync.add_argument(
434
+ "--known", nargs="*", default=None,
435
+ help="op_ids the peer already has (default: none = full clone)",
436
+ )
437
+ p_sync.add_argument(
438
+ "--meta-only", action="store_true",
439
+ help="export ops WITHOUT bodies (peers pull bodies on-demand by sha)",
440
+ )
441
+ p_sync.add_argument(
442
+ "--import", dest="import_file", default=None, help="bundle file to import"
443
+ )
444
+ p_sync.add_argument(
445
+ "--data-root", default=None, help="vcs store directory (default: Library/Data/vcs)"
446
+ )
447
+
448
+ p_hooks = sub.add_parser(
449
+ "hooks", help="install/uninstall the chained capture hooks"
450
+ )
451
+ p_hooks.add_argument("action", choices=["install", "uninstall"])
452
+ p_hooks.add_argument(
453
+ "--repo", default=None, help="repo root (default: current directory)"
454
+ )
455
+
456
+ args = parser.parse_args(argv)
457
+ if args.cmd == "capture":
458
+ return _cmd_capture(args)
459
+ if args.cmd == "diff":
460
+ return _cmd_diff(args)
461
+ if args.cmd == "status":
462
+ return _cmd_status(args)
463
+ if args.cmd == "merge-preview":
464
+ return _cmd_merge_preview(args)
465
+ if args.cmd == "merge-conflicts":
466
+ return _cmd_merge_conflicts(args)
467
+ if args.cmd == "resolve-conflict":
468
+ return _cmd_resolve_conflict(args)
469
+ if args.cmd == "lease":
470
+ return _cmd_lease(args)
471
+ if args.cmd == "lease-check":
472
+ return _cmd_lease_check(args)
473
+ if args.cmd == "bodies":
474
+ return _cmd_bodies(args)
475
+ if args.cmd == "dedupe":
476
+ return _cmd_dedupe(args)
477
+ if args.cmd == "ledger":
478
+ return _cmd_ledger(args)
479
+ if args.cmd == "sync":
480
+ return _cmd_sync(args)
481
+ if args.cmd == "hooks":
482
+ return _cmd_hooks(args)
483
+ parser.error(f"unknown command {args.cmd}")
484
+ return 2
485
+
486
+
487
+ if __name__ == "__main__":
488
+ sys.exit(main())
awgit/data_root.py ADDED
@@ -0,0 +1,30 @@
1
+ """VCS data-root resolution (standalone).
2
+
3
+ The op-log, node registry, body store, and lease stores must all live under
4
+ ONE root so a stable node id from any store resolves to the same
5
+ ``(name, path)`` coordinate space. Standalone awgit keeps the durable store
6
+ OUTSIDE the git tree — ``~/.aither/awgit/data`` by default, overridable with
7
+ ``VCS_DATA_ROOT`` (the same env the in-repo capture hooks use). A passed
8
+ ``root_path`` preserves the monorepo convention ``<root>/Library/Data/vcs``
9
+ for callers that want the store under a project root.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+
19
+ def vcs_data_root(root_path: Optional[str] = None) -> Path:
20
+ """Absolute path of the durable vcs store directory (created lazily).
21
+
22
+ Priority: ``VCS_DATA_ROOT`` env → ``root_path/Library/Data/vcs`` when a
23
+ project root is supplied → ``~/.aither/awgit/data``.
24
+ """
25
+ override = os.environ.get("VCS_DATA_ROOT")
26
+ if override:
27
+ return Path(override)
28
+ if root_path:
29
+ return Path(root_path) / "Library" / "Data" / "vcs"
30
+ return Path.home() / ".aither" / "awgit" / "data"
awgit/diff.py ADDED
@@ -0,0 +1,82 @@
1
+ """Semantic diff — 'what changed' at node granularity.
2
+
3
+ Renders the difference between two git shas (or two op-sets) as a list of
4
+ ``NodeChange`` records, replacing raw unified diffs for review. The primitive
5
+ reuses capture's blob→nodes pipeline, so the ids shown are the same stable
6
+ node ids the op-log stores — the graph is the query plane over node history.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional
13
+
14
+ from awgit.capture import (
15
+ default_repo,
16
+ diff_sources,
17
+ git_blob,
18
+ git_changed_files,
19
+ load_node_manager,
20
+ )
21
+ from awgit.data_root import vcs_data_root
22
+ from awgit.schema import EditOp, NodeChange
23
+
24
+
25
+ def diff_git(
26
+ a_sha: str,
27
+ b_sha: str,
28
+ *,
29
+ repo_path: Optional[str] = None,
30
+ root_path: Optional[str] = None,
31
+ data_root: Optional[Path] = None,
32
+ ) -> List[NodeChange]:
33
+ """Node-level diff between two git shas (Python files only)."""
34
+ repo = Path(repo_path or default_repo())
35
+ files = sorted(
36
+ set(git_changed_files(repo, a_sha)) | set(git_changed_files(repo, b_sha))
37
+ )
38
+ data = data_root or vcs_data_root(root_path=root_path)
39
+ manager = load_node_manager(data)
40
+ changes: List[NodeChange] = []
41
+ for rel in files:
42
+ if not rel.endswith(".py"):
43
+ continue
44
+ changes.extend(
45
+ diff_sources(
46
+ git_blob(repo, a_sha, rel),
47
+ git_blob(repo, b_sha, rel),
48
+ rel,
49
+ manager,
50
+ )
51
+ )
52
+ return changes
53
+
54
+
55
+ def diff_opsets(a_ops: List[EditOp], b_ops: List[EditOp]) -> Dict[str, List[str]]:
56
+ """Overlap summary of two op-sets — the merge engine's collision primitive."""
57
+ a = {nc.node_id for op in a_ops for nc in op.node_changes}
58
+ b = {nc.node_id for op in b_ops for nc in op.node_changes}
59
+ return {
60
+ "only_a": sorted(a - b),
61
+ "only_b": sorted(b - a),
62
+ "shared": sorted(a & b),
63
+ }
64
+
65
+
66
+ def render(changes: List[NodeChange]) -> List[str]:
67
+ """Human/agent-readable lines for a node-level diff."""
68
+ out: List[str] = []
69
+ for c in changes:
70
+ loc = c.path
71
+ if c.symbol:
72
+ loc = f"{c.path}:{c.symbol}"
73
+ if c.change_type == "body_rewrite":
74
+ detail = f"body {c.old_body_sha[:8] or '?'} -> {c.new_body_sha[:8] or '?'}"
75
+ elif c.change_type == "added":
76
+ detail = "added"
77
+ elif c.change_type == "deleted":
78
+ detail = "deleted"
79
+ else:
80
+ detail = c.change_type
81
+ out.append(f"[{c.change_type}] {loc} — {detail}")
82
+ return out
awgit/hooks/chain.sh ADDED
@@ -0,0 +1,38 @@
1
+ #!/bin/sh
2
+ # aither-vcs-chain — generated by install_hooks, do not edit by hand.
3
+ # Runs the pre-existing hook body (<hook>.org) in a SUBSHELL so an `exec`
4
+ # inside it (the original pre-commit ends in `exec python tools/secret_scan.py`)
5
+ # cannot terminate this chain, then runs the .d fragments.
6
+ #
7
+ # Exit-code contract: the FIRST non-zero exit among the .org body and the
8
+ # fragments is propagated. A pre-existing gate (the secret scanner) that fails
9
+ # must STILL fail the hook — a chain that swallowed it would silently disable
10
+ # it. Fragments always run, so a failure in one never silently skips another
11
+ # (the post-commit capture still runs even if the CD-autosync trigger failed).
12
+ # Both the .org and each fragment run in a SUBSHELL: an `exec` (the real
13
+ # pre-commit) or a bare `exit 0` (the lease-check guard) must never terminate
14
+ # the chain before the exit-code decision at the bottom.
15
+ set -u
16
+ HOOK_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
17
+ HOOK_NAME="$(basename -- "$0")"
18
+
19
+ ORG_RC=0
20
+ if [ -f "$HOOK_DIR/$HOOK_NAME.org" ]; then
21
+ ( . "$HOOK_DIR/$HOOK_NAME.org" "$@" )
22
+ ORG_RC=$?
23
+ fi
24
+
25
+ FRAG_RC=0
26
+ FRAG_DIR="$HOOK_DIR/$HOOK_NAME.d"
27
+ if [ -d "$FRAG_DIR" ]; then
28
+ for frag in "$FRAG_DIR"/*; do
29
+ [ -f "$frag" ] || continue
30
+ [ -x "$frag" ] || continue
31
+ ( . "$frag" "$@" ) || { FRAG_RC=$?; break; }
32
+ done
33
+ fi
34
+
35
+ if [ "$ORG_RC" -ne 0 ]; then
36
+ exit "$ORG_RC"
37
+ fi
38
+ exit "$FRAG_RC"
@@ -0,0 +1,38 @@
1
+ #!/bin/sh
2
+ # aither-vcs-fragment — capture the new commit as a semantic edit-op.
3
+ # Best-effort: the commit already happened, so a capture failure is logged to
4
+ # <git-dir>/vcs-capture.log and NEVER breaks the commit. Skip paths are LOUD,
5
+ # never silent: if the fragment cannot resolve HEAD or the awgit CLI it logs
6
+ # why — a silent skip is how a dead capture reads as "nothing to capture".
7
+ #
8
+ # GOTCHA: git invokes post-commit with NO arguments. The fragment must resolve
9
+ # the new commit from `git rev-parse --verify HEAD` — assuming `$1` is the sha
10
+ # made the automatic capture exit silently on every real commit while manual
11
+ # invocations (which passed the sha) worked fine.
12
+ set -u
13
+ GITDIR="$(git rev-parse --git-dir 2>/dev/null)" || GITDIR=".git"
14
+ LOG="$GITDIR/vcs-capture.log"
15
+ SHA="${1:-}"
16
+ if [ -z "$SHA" ]; then
17
+ SHA="$(git rev-parse --verify HEAD 2>/dev/null)"
18
+ fi
19
+ [ -n "$SHA" ] || {
20
+ echo "vcs: capture skipped — no sha and cannot resolve HEAD" >> "$LOG" 2>&1
21
+ exit 0
22
+ }
23
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
24
+ echo "vcs: capture skipped — not a git worktree (hook cwd: $PWD)" >> "$LOG" 2>&1
25
+ exit 0
26
+ }
27
+ if ! command -v awgit >/dev/null 2>&1 && ! command -v python >/dev/null 2>&1; then
28
+ echo "vcs: capture skipped — neither awgit nor python on PATH" >> "$LOG" 2>&1
29
+ exit 0
30
+ fi
31
+ {
32
+ if command -v awgit >/dev/null 2>&1; then
33
+ awgit capture --sha "$SHA"
34
+ else
35
+ python -m awgit.cli capture --sha "$SHA"
36
+ fi
37
+ } >> "$LOG" 2>&1 || true
38
+ exit 0
@@ -0,0 +1,15 @@
1
+ #!/bin/sh
2
+ # aither-vcs-fragment — lease gate. Enforced ONLY when VCS_LEASES_ENFORCE=1.
3
+ # Default OFF: agents that never lease can still commit; their ops are flagged
4
+ # leased=false.
5
+ set -u
6
+ [ "${VCS_LEASES_ENFORCE:-0}" = "1" ] || exit 0
7
+ if command -v awgit >/dev/null 2>&1; then
8
+ awgit lease-check
9
+ else
10
+ python -m awgit.cli lease-check
11
+ fi || {
12
+ echo "vcs: pre-commit lease gate rejected the commit" >&2
13
+ exit 1
14
+ }
15
+ exit 0