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/__init__.py +53 -0
- awgit/bodies.py +323 -0
- awgit/bridge.py +117 -0
- awgit/capture.py +441 -0
- awgit/cli.py +488 -0
- awgit/data_root.py +30 -0
- awgit/diff.py +82 -0
- awgit/hooks/chain.sh +38 -0
- awgit/hooks/post-commit.d/vcs-capture +38 -0
- awgit/hooks/pre-commit.d/vcs-lease-check +15 -0
- awgit/identity.py +80 -0
- awgit/leases.py +264 -0
- awgit/ledger.py +80 -0
- awgit/mcp.py +94 -0
- awgit/merge.py +331 -0
- awgit/nodeid.py +210 -0
- awgit/oplog.py +221 -0
- awgit/parser.py +186 -0
- awgit/schema.py +190 -0
- awgit/sync.py +180 -0
- awgit-0.1.0.dist-info/METADATA +117 -0
- awgit-0.1.0.dist-info/RECORD +26 -0
- awgit-0.1.0.dist-info/WHEEL +5 -0
- awgit-0.1.0.dist-info/entry_points.txt +2 -0
- awgit-0.1.0.dist-info/licenses/LICENSE +202 -0
- awgit-0.1.0.dist-info/top_level.txt +1 -0
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
|