awgit 1.1.2__tar.gz → 1.2.0__tar.gz
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-1.1.2/README.md → awgit-1.2.0/PKG-INFO +17 -34
- awgit-1.1.2/awgit.egg-info/PKG-INFO → awgit-1.2.0/README.md +0 -49
- {awgit-1.1.2 → awgit-1.2.0}/awgit/cli.py +258 -97
- {awgit-1.1.2 → awgit-1.2.0}/awgit/evidence.py +20 -0
- awgit-1.2.0/awgit/lease_requests.py +171 -0
- awgit-1.2.0/awgit/outcomes.py +250 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/plugins.py +18 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/schema.py +92 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/worktree.py +1 -1
- {awgit-1.1.2 → awgit-1.2.0/awgit.egg-info}/PKG-INFO +5 -37
- {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/SOURCES.txt +5 -1
- {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/requires.txt +0 -3
- {awgit-1.1.2 → awgit-1.2.0}/pyproject.toml +11 -7
- awgit-1.2.0/tests/test_lease_release_accepts_paths.py +142 -0
- awgit-1.2.0/tests/test_lease_requests.py +156 -0
- awgit-1.2.0/tests/test_outcome_tracking.py +410 -0
- awgit-1.1.2/tests/test_tabular.py +0 -273
- {awgit-1.1.2 → awgit-1.2.0}/LICENSE +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/__init__.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/absorb.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/bodies.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/bridge.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/capture.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/changeid.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/code.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/commands.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/data_root.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/diff.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/git.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/graph.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/guard.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/chain.sh +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/post-commit.d/vcs-capture +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/pre-commit.d/vcs-mass-delete-guard +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/pre-push.d/ci-gate-parity +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/hooks/prepare-commit-msg.d/awgit-change-id +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/identity.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/lazy.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/leases.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/ledger.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/mcp.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/merge.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/nodeid.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/oplog.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/owners.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/parser.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/prove.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/push.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/repowise_parser.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/review.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/stack.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/staging.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/staging_selftest.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/sync.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit/tabular.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/dependency_links.txt +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/entry_points.txt +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/awgit.egg-info/top_level.txt +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/setup.cfg +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_absorb_routes_by_node.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_awgit_standalone.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_changeid_survives_history_rewrite.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_documented_commands_run.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_lazy_clone_is_verified.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_multilang_identity.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_owners_and_proof.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_push_is_the_pull_request.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_restack_repairs_orphans.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_review_threads_survive_moves.py +0 -0
- {awgit-1.1.2 → awgit-1.2.0}/tests/test_worktree_zombie_detection.py +0 -0
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: awgit
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Aither World-Graph git — semantic version control on top of git: edit-ops keyed on stable node ids, content-addressed bodies, differential sync.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/Aitherium/awgit
|
|
7
|
+
Project-URL: Documentation, https://aitherium.github.io/awgit/
|
|
8
|
+
Project-URL: Repository, https://github.com/Aitherium/awgit.git
|
|
9
|
+
Project-URL: Issues, https://github.com/Aitherium/awgit/issues
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: httpx>=0.25.0
|
|
14
|
+
Provides-Extra: multilang
|
|
15
|
+
Requires-Dist: repowise>=0.31.0; extra == "multilang"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
1
18
|
# awgit — the git that knows a function from a line
|
|
2
19
|
|
|
3
20
|
Git has no world model. It knows which lines moved, not what a function is — so
|
|
@@ -190,42 +207,8 @@ awgit ledger --sha <sha> # who changed what, under a verified identity
|
|
|
190
207
|
awgit evidence # the measurable claim, from your own op-log
|
|
191
208
|
awgit bodies --get <sha> # read a body from the content-addressed store
|
|
192
209
|
awgit dedupe --scan <trees> # quantify duplication; --reclaim to hard-link
|
|
193
|
-
awgit data diff <a> <b> --key id # ROW-level diff of CSV/TSV/parquet
|
|
194
210
|
```
|
|
195
211
|
|
|
196
|
-
### Data files get the same treatment as code
|
|
197
|
-
|
|
198
|
-
A line diff is useless on a table: sort it and every line "changed"; reorder two
|
|
199
|
-
rows and a review drowns. So a row gets the same pair a function gets — an
|
|
200
|
-
**identity** and a **content address**:
|
|
201
|
-
|
|
202
|
-
```
|
|
203
|
-
row identity = H(the --key columns) # which row is this?
|
|
204
|
-
row content = H(every column) # has it changed?
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
The diff is then set algebra on identity, so rows can be reordered freely and
|
|
208
|
-
nothing is reported:
|
|
209
|
-
|
|
210
|
-
```bash
|
|
211
|
-
awgit data diff old.csv new.csv --key id # 1 added, 1 removed, 1 modified
|
|
212
|
-
awgit data diff old.csv new.csv --key id --json # before/after per modified row
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
Without `--key` there is no identity, so it degrades to a content set-diff and
|
|
216
|
-
**says so** — every edit reads as an add plus a remove, and `modified` stays
|
|
217
|
-
empty rather than being guessed at. A key column that exists in neither table is
|
|
218
|
-
an error, not an empty result: silently keying on a missing column would report
|
|
219
|
-
every row as added *and* removed, which looks exactly like data loss.
|
|
220
|
-
|
|
221
|
-
CSV and TSV need nothing beyond the stdlib. Parquet needs the optional extra:
|
|
222
|
-
`pip install awgit[tabular]`.
|
|
223
|
-
|
|
224
|
-
*The two-hash row model is adapted from [Oxen](https://github.com/oxen-ai/Oxen)
|
|
225
|
-
(Apache-2.0). No Oxen code is vendored, and awgit's byte encoding deliberately
|
|
226
|
-
differs — it length-prefixes each field so that two different rows cannot share
|
|
227
|
-
a content address — so hashes are not comparable between the two tools.*
|
|
228
|
-
|
|
229
212
|
- **Merge** at node granularity: disjoint node sets merge clean by
|
|
230
213
|
construction, and a genuine collision escalates naming the exact function
|
|
231
214
|
(`awgit merge-preview`, `awgit merge-conflicts`, `awgit resolve-conflict`).
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: awgit
|
|
3
|
-
Version: 1.1.2
|
|
4
|
-
Summary: Aither World-Graph git — semantic version control on top of git: edit-ops keyed on stable node ids, content-addressed bodies, differential sync.
|
|
5
|
-
License: Apache-2.0
|
|
6
|
-
Requires-Python: >=3.10
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Requires-Dist: httpx>=0.25.0
|
|
10
|
-
Provides-Extra: multilang
|
|
11
|
-
Requires-Dist: repowise>=0.31.0; extra == "multilang"
|
|
12
|
-
Provides-Extra: tabular
|
|
13
|
-
Requires-Dist: pyarrow>=15.0.0; extra == "tabular"
|
|
14
|
-
Dynamic: license-file
|
|
15
|
-
|
|
16
1
|
# awgit — the git that knows a function from a line
|
|
17
2
|
|
|
18
3
|
Git has no world model. It knows which lines moved, not what a function is — so
|
|
@@ -205,42 +190,8 @@ awgit ledger --sha <sha> # who changed what, under a verified identity
|
|
|
205
190
|
awgit evidence # the measurable claim, from your own op-log
|
|
206
191
|
awgit bodies --get <sha> # read a body from the content-addressed store
|
|
207
192
|
awgit dedupe --scan <trees> # quantify duplication; --reclaim to hard-link
|
|
208
|
-
awgit data diff <a> <b> --key id # ROW-level diff of CSV/TSV/parquet
|
|
209
193
|
```
|
|
210
194
|
|
|
211
|
-
### Data files get the same treatment as code
|
|
212
|
-
|
|
213
|
-
A line diff is useless on a table: sort it and every line "changed"; reorder two
|
|
214
|
-
rows and a review drowns. So a row gets the same pair a function gets — an
|
|
215
|
-
**identity** and a **content address**:
|
|
216
|
-
|
|
217
|
-
```
|
|
218
|
-
row identity = H(the --key columns) # which row is this?
|
|
219
|
-
row content = H(every column) # has it changed?
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
The diff is then set algebra on identity, so rows can be reordered freely and
|
|
223
|
-
nothing is reported:
|
|
224
|
-
|
|
225
|
-
```bash
|
|
226
|
-
awgit data diff old.csv new.csv --key id # 1 added, 1 removed, 1 modified
|
|
227
|
-
awgit data diff old.csv new.csv --key id --json # before/after per modified row
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
Without `--key` there is no identity, so it degrades to a content set-diff and
|
|
231
|
-
**says so** — every edit reads as an add plus a remove, and `modified` stays
|
|
232
|
-
empty rather than being guessed at. A key column that exists in neither table is
|
|
233
|
-
an error, not an empty result: silently keying on a missing column would report
|
|
234
|
-
every row as added *and* removed, which looks exactly like data loss.
|
|
235
|
-
|
|
236
|
-
CSV and TSV need nothing beyond the stdlib. Parquet needs the optional extra:
|
|
237
|
-
`pip install awgit[tabular]`.
|
|
238
|
-
|
|
239
|
-
*The two-hash row model is adapted from [Oxen](https://github.com/oxen-ai/Oxen)
|
|
240
|
-
(Apache-2.0). No Oxen code is vendored, and awgit's byte encoding deliberately
|
|
241
|
-
differs — it length-prefixes each field so that two different rows cannot share
|
|
242
|
-
a content address — so hashes are not comparable between the two tools.*
|
|
243
|
-
|
|
244
195
|
- **Merge** at node granularity: disjoint node sets merge clean by
|
|
245
196
|
construction, and a genuine collision escalates naming the exact function
|
|
246
197
|
(`awgit merge-preview`, `awgit merge-conflicts`, `awgit resolve-conflict`).
|
|
@@ -91,48 +91,19 @@ def _cmd_capture(args: argparse.Namespace) -> int:
|
|
|
91
91
|
print(f"vcs: no semantic changes for {args.sha}")
|
|
92
92
|
return 0
|
|
93
93
|
print(f"vcs: op {op.op_id} recorded ({op.summary})")
|
|
94
|
-
return 0
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _cmd_data(args: argparse.Namespace) -> int:
|
|
98
|
-
"""Row-level diff of two tabular files (see awgit/tabular.py)."""
|
|
99
|
-
import json # function-local, matching the convention in this module
|
|
100
|
-
|
|
101
|
-
from . import tabular
|
|
102
|
-
|
|
103
|
-
if args.data_cmd != "diff": # pragma: no cover - argparse enforces this
|
|
104
|
-
print(f"awgit: unknown data subcommand {args.data_cmd!r}", file=sys.stderr)
|
|
105
|
-
return 2
|
|
106
|
-
|
|
107
|
-
try:
|
|
108
|
-
d = tabular.diff_files(args.old, args.new, args.key)
|
|
109
|
-
except tabular.UnreadableTableError as exc:
|
|
110
|
-
# Exit 2, never 0-with-empty-output: a table we could not read must not
|
|
111
|
-
# be reported as a table with no differences.
|
|
112
|
-
print(f"awgit: {exc}", file=sys.stderr)
|
|
113
|
-
return 2
|
|
114
94
|
|
|
115
|
-
if
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
95
|
+
# Record proof of verification if requested
|
|
96
|
+
if getattr(args, "prove", False):
|
|
97
|
+
try:
|
|
98
|
+
from awgit.outcomes import record_outcome
|
|
99
|
+
from awgit.prove import run_gates
|
|
100
|
+
data_root = Path(args.data_root) if args.data_root else None
|
|
101
|
+
gates = run_gates(op.file_paths)
|
|
102
|
+
outcome = record_outcome(args.sha, gates, data_root=data_root)
|
|
103
|
+
print(f"vcs: outcome {outcome.outcome_id} recorded ({outcome.verdict})")
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
print(f"vcs: proof recording failed (non-fatal): {exc}", file=sys.stderr)
|
|
123
106
|
|
|
124
|
-
s = d.summary()
|
|
125
|
-
if d.keyless:
|
|
126
|
-
print("no --key given: content set-diff only; MODIFIED rows cannot be "
|
|
127
|
-
"distinguished from an add plus a remove.")
|
|
128
|
-
else:
|
|
129
|
-
print(f"keyed on: {', '.join(d.keys)}")
|
|
130
|
-
for col in s["columns_added"]:
|
|
131
|
-
print(f" + column {col}")
|
|
132
|
-
for col in s["columns_removed"]:
|
|
133
|
-
print(f" - column {col}")
|
|
134
|
-
print(f" {s['added']} added, {s['removed']} removed, "
|
|
135
|
-
f"{s['modified']} modified, {s['unchanged']} unchanged")
|
|
136
107
|
return 0
|
|
137
108
|
|
|
138
109
|
|
|
@@ -232,10 +203,45 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
|
|
232
203
|
return 0
|
|
233
204
|
|
|
234
205
|
|
|
206
|
+
def _resolve_lease_args(registry, who, raw):
|
|
207
|
+
"""Map release/heartbeat arguments to lease ids, accepting leased PATHS too.
|
|
208
|
+
|
|
209
|
+
`acquire` takes paths and `release` takes ids, so passing the same string to
|
|
210
|
+
both is the natural mistake -- and it used to be a silent one, because an
|
|
211
|
+
unmatched id simply released nothing and still printed success.
|
|
212
|
+
|
|
213
|
+
Returns (ids, unresolved). Anything that is neither one of this actor's
|
|
214
|
+
active lease ids nor one of its leased targets comes back in `unresolved`,
|
|
215
|
+
so the caller can fail rather than report a count of zero.
|
|
216
|
+
"""
|
|
217
|
+
mine = [lz for lz in registry.active_leases() if lz.actor == who]
|
|
218
|
+
by_id = {lz.lease_id: lz.lease_id for lz in mine}
|
|
219
|
+
by_target = {}
|
|
220
|
+
for lz in mine:
|
|
221
|
+
# Last writer wins is fine: releasing any lease on that path is the
|
|
222
|
+
# intent, and a duplicate target for one actor is already a bug.
|
|
223
|
+
by_target[str(lz.target).replace("\\", "/").strip("/")] = lz.lease_id
|
|
224
|
+
ids, unresolved = [], []
|
|
225
|
+
for arg in raw or []:
|
|
226
|
+
if arg in by_id:
|
|
227
|
+
ids.append(arg)
|
|
228
|
+
continue
|
|
229
|
+
key = str(arg).replace("\\", "/").strip("/")
|
|
230
|
+
if key in by_target:
|
|
231
|
+
ids.append(by_target[key])
|
|
232
|
+
continue
|
|
233
|
+
unresolved.append(arg)
|
|
234
|
+
return ids, unresolved
|
|
235
|
+
|
|
236
|
+
|
|
235
237
|
def _cmd_lease(args: argparse.Namespace) -> int:
|
|
236
238
|
registry = LeaseRegistry()
|
|
237
239
|
cmd = args.lease_cmd
|
|
238
240
|
who = _actor(args)
|
|
241
|
+
if cmd == "contact":
|
|
242
|
+
return _cmd_lease_contact(args)
|
|
243
|
+
if cmd == "requests":
|
|
244
|
+
return _cmd_lease_requests(args)
|
|
239
245
|
if cmd == "acquire":
|
|
240
246
|
targets = list(args.targets or [])
|
|
241
247
|
if getattr(args, "staged", False):
|
|
@@ -290,7 +296,19 @@ def _cmd_lease(args: argparse.Namespace) -> int:
|
|
|
290
296
|
who, targets, ttl_sec=args.ttl, reason=args.reason
|
|
291
297
|
)
|
|
292
298
|
except LeaseConflictError as exc:
|
|
299
|
+
# Flush stdout FIRST. It is block-buffered when redirected to a file
|
|
300
|
+
# or a pipe and stderr is not, so this line otherwise lands
|
|
301
|
+
# INTERLEAVED in the middle of whatever stdout had buffered rather
|
|
302
|
+
# than at the end where anyone looks. Measured 2026-08-19:
|
|
303
|
+
# `lease acquire --staged --adopt` over 1776 files refused correctly
|
|
304
|
+
# on a real conflict, and the one line saying why was glued onto the
|
|
305
|
+
# middle of the adoption list at line 1657 of 1778 -- head and tail
|
|
306
|
+
# both missed it, and a correct refusal got reported as "awgit exits
|
|
307
|
+
# 1 and persists nothing". A diagnostic nobody can find is worse than
|
|
308
|
+
# none: it gets diagnosed as a different bug.
|
|
309
|
+
sys.stdout.flush()
|
|
293
310
|
print(f"vcs: {exc}", file=sys.stderr)
|
|
311
|
+
sys.stderr.flush()
|
|
294
312
|
return 1
|
|
295
313
|
# A lease over an ALREADY-DIRTY file captures a baseline that contains work
|
|
296
314
|
# which is not yours, and `stage-mine` computes (baseline -> worktree), so it
|
|
@@ -316,11 +334,28 @@ def _cmd_lease(args: argparse.Namespace) -> int:
|
|
|
316
334
|
"`git diff --stat -- <path>` must match the size of YOUR edit.",
|
|
317
335
|
file=sys.stderr)
|
|
318
336
|
return 0
|
|
319
|
-
if cmd
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
337
|
+
if cmd in ("heartbeat", "release"):
|
|
338
|
+
# `release`/`heartbeat` take lease IDS. Passing a PATH -- the same string
|
|
339
|
+
# `acquire` takes, and the obvious guess -- matched no id, so the registry
|
|
340
|
+
# returned 0 and this printed "released 0 leases" and exited 0 while the
|
|
341
|
+
# lease sat there in `lease list`. A command that reports success for
|
|
342
|
+
# having done nothing is the silent-no-op class in
|
|
343
|
+
# .claude/rules/security-review-patterns.md #5, and it cost a session on
|
|
344
|
+
# 2026-08-16: the release "succeeded", the lease stayed held, and the next
|
|
345
|
+
# edit was blocked by the caller's own lease.
|
|
346
|
+
# Resolve path-shaped arguments against this actor's active leases, and
|
|
347
|
+
# refuse anything that resolves to nothing rather than reporting 0.
|
|
348
|
+
ids, unresolved = _resolve_lease_args(registry, who, args.ids)
|
|
349
|
+
if unresolved:
|
|
350
|
+
print("vcs: no active lease of yours matches: " + ", ".join(unresolved),
|
|
351
|
+
file=sys.stderr)
|
|
352
|
+
print("vcs: pass a lease id or a leased path (`awgit lease list`)",
|
|
353
|
+
file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
if cmd == "heartbeat":
|
|
356
|
+
print(f"vcs: heartbeat refreshed {registry.heartbeat(who, ids)} leases")
|
|
357
|
+
else:
|
|
358
|
+
print(f"vcs: released {registry.release(who, ids)} leases")
|
|
324
359
|
return 0
|
|
325
360
|
if cmd == "list":
|
|
326
361
|
for lz in sorted(registry.active_leases(), key=lambda x: x.target):
|
|
@@ -411,6 +446,28 @@ def staged_but_not_committed(repo: Path) -> List[str]:
|
|
|
411
446
|
except OSError:
|
|
412
447
|
return []
|
|
413
448
|
|
|
449
|
+
# An index the OPERATOR supplied is not the sweep this rule is about — it is
|
|
450
|
+
# the documented DEFENCE against it (concurrent-safe-git rule 1a: seed a
|
|
451
|
+
# private index from HEAD so a peer's staging cannot reach your commit). It
|
|
452
|
+
# was being rejected by the very gate that recommends it, and the rejection
|
|
453
|
+
# message told the committer to do what they were already doing, so the only
|
|
454
|
+
# ways forward were --no-verify or the sweep. Measured 2026-08-15 on a commit
|
|
455
|
+
# whose alternative was shipping a peer's half-finished route-manifest
|
|
456
|
+
# refactor that DELETES three RBAC mappings — i.e. the gate was pushing
|
|
457
|
+
# toward the exact outcome it exists to prevent.
|
|
458
|
+
#
|
|
459
|
+
# The discriminator is WHERE the index lives, and it cannot be evaded:
|
|
460
|
+
# `git commit -- <pathspec>` and `git commit -a` build their temp index
|
|
461
|
+
# INSIDE the git dir (`next-index-<pid>.lock`, `index.lock`), and they do so
|
|
462
|
+
# even when GIT_INDEX_FILE is already set to something else — verified
|
|
463
|
+
# against a real throwaway repo, both forms, with and without a private
|
|
464
|
+
# index exported. So an index outside the git dir can only have come from
|
|
465
|
+
# the operator, and what they are committing is what they chose.
|
|
466
|
+
try:
|
|
467
|
+
Path(temp_index).resolve().relative_to(Path(git_dir).resolve())
|
|
468
|
+
except (ValueError, OSError):
|
|
469
|
+
return [] # operator-supplied private index — rule 1a, not a sweep
|
|
470
|
+
|
|
414
471
|
# The paths AT RISK are the ones really staged — `_staged_files` would read
|
|
415
472
|
# the temp index here, for the same GIT_INDEX_FILE reason as above.
|
|
416
473
|
paths = [
|
|
@@ -517,48 +574,155 @@ def _cmd_stage_mine(args: argparse.Namespace) -> int:
|
|
|
517
574
|
return 1 if failed else 0
|
|
518
575
|
|
|
519
576
|
|
|
520
|
-
def
|
|
521
|
-
"""During a
|
|
577
|
+
def merge_authored_files(repo: Path, staged: List[str]) -> List[str]:
|
|
578
|
+
"""During a MERGE, the files the committer actually authored.
|
|
579
|
+
|
|
580
|
+
A merge commit brings in every file the other side changed — already-committed
|
|
581
|
+
history that no lease could sensibly cover. The lease plane exists to stop one
|
|
582
|
+
session clobbering another's UNCOMMITTED work, and a merge cannot do that: git
|
|
583
|
+
refuses to merge over dirty files it would overwrite. So demanding a lease for
|
|
584
|
+
incoming history is asking for something that is neither possible nor useful.
|
|
522
585
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
ran past six minutes without finishing, so a legitimate merge could not be
|
|
528
|
-
recorded at all.
|
|
586
|
+
Measured 2026-08-11: merging origin/develop into a feature branch demanded
|
|
587
|
+
leases for ~250 files, and `lease acquire --staged --adopt` could only pick up
|
|
588
|
+
7 because is_guarded() filters the rest — leaving no way to complete a merge
|
|
589
|
+
except switching enforcement off, which is exactly how a gate stops being used.
|
|
529
590
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
hand-resolved, and those are exactly the committer's own work, so those are
|
|
534
|
-
what still require a lease.
|
|
591
|
+
What IS still guarded: the conflict RESOLUTIONS. A staged blob that matches
|
|
592
|
+
neither parent is text the committer wrote by hand, and that is a real edit on
|
|
593
|
+
a shared file. Everything taken verbatim from either side is inherited.
|
|
535
594
|
|
|
536
|
-
Returns
|
|
537
|
-
unchanged.
|
|
595
|
+
Returns `staged` unchanged when this is not a merge.
|
|
538
596
|
"""
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
597
|
+
try:
|
|
598
|
+
git_dir = subprocess.run(
|
|
599
|
+
["git", "rev-parse", "--absolute-git-dir"], cwd=str(repo),
|
|
600
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
601
|
+
).stdout.strip()
|
|
602
|
+
except OSError:
|
|
603
|
+
return staged
|
|
604
|
+
if not git_dir or not (Path(git_dir) / "MERGE_HEAD").is_file():
|
|
544
605
|
return staged
|
|
545
606
|
|
|
546
|
-
def
|
|
607
|
+
def blobs(rev: str) -> dict:
|
|
547
608
|
out = subprocess.run(
|
|
548
|
-
["git", "
|
|
609
|
+
["git", "ls-tree", "-r", rev, "--", *staged], cwd=str(repo),
|
|
549
610
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
550
611
|
).stdout
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
612
|
+
found = {}
|
|
613
|
+
for line in out.splitlines():
|
|
614
|
+
meta, _, path = line.partition(" ")
|
|
615
|
+
parts = meta.split()
|
|
616
|
+
if len(parts) >= 3 and path:
|
|
617
|
+
found[path] = parts[2]
|
|
618
|
+
return found
|
|
619
|
+
|
|
620
|
+
if not staged:
|
|
557
621
|
return staged
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
622
|
+
ours, theirs = blobs("HEAD"), blobs("MERGE_HEAD")
|
|
623
|
+
index = {}
|
|
624
|
+
out = subprocess.run(
|
|
625
|
+
["git", "ls-files", "--stage", "--", *staged], cwd=str(repo),
|
|
626
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
627
|
+
).stdout
|
|
628
|
+
for line in out.splitlines():
|
|
629
|
+
meta, _, path = line.partition(" ")
|
|
630
|
+
parts = meta.split()
|
|
631
|
+
if len(parts) >= 2 and path:
|
|
632
|
+
index[path] = parts[1]
|
|
633
|
+
|
|
634
|
+
authored = [p for p in staged
|
|
635
|
+
if index.get(p) not in (ours.get(p), theirs.get(p))]
|
|
636
|
+
return authored
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _requests_store():
|
|
640
|
+
"""(LeaseRequests, registry) or (None, None) if the plane is unreadable."""
|
|
641
|
+
try:
|
|
642
|
+
from .lease_requests import LeaseRequests
|
|
643
|
+
from .leases import LeaseRegistry, vcs_data_root
|
|
644
|
+
reg = LeaseRegistry()
|
|
645
|
+
return LeaseRequests(vcs_data_root()), reg
|
|
646
|
+
except Exception as exc: # noqa: BLE001 - never fatal
|
|
647
|
+
print(f"vcs: lease-request store unavailable ({exc})", file=sys.stderr)
|
|
648
|
+
return None, None
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _relay_notify(to_actor: str, target: str, message: str) -> bool:
|
|
652
|
+
"""Best-effort async ping. NEVER the only delivery path.
|
|
653
|
+
|
|
654
|
+
Returns False when it could not send, so the caller can say so rather than
|
|
655
|
+
implying the holder was reached. Relay is optional here on purpose: if the
|
|
656
|
+
only notification depended on a running service, this feature would be
|
|
657
|
+
unavailable in exactly the degraded conditions that produce lease pile-ups.
|
|
658
|
+
"""
|
|
659
|
+
try:
|
|
660
|
+
from awrelay.client import RelayClient # type: ignore
|
|
661
|
+
except ImportError:
|
|
662
|
+
return False
|
|
663
|
+
try:
|
|
664
|
+
who = to_actor.split(":")[-1][:8]
|
|
665
|
+
RelayClient().send(
|
|
666
|
+
channel="lease-negotiation",
|
|
667
|
+
text=f"@{who} please release `{target}` — {message or 'another '
|
|
668
|
+
'session is blocked on it'}")
|
|
669
|
+
return True
|
|
670
|
+
except Exception: # noqa: BLE001
|
|
671
|
+
return False
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _cmd_lease_contact(args) -> int:
|
|
675
|
+
"""Ask whoever holds a path's lease to let it go."""
|
|
676
|
+
store, reg = _requests_store()
|
|
677
|
+
if store is None or reg is None:
|
|
678
|
+
return 2
|
|
679
|
+
me = _actor(args)
|
|
680
|
+
target = args.path
|
|
681
|
+
|
|
682
|
+
holder = None
|
|
683
|
+
for lz in reg.active_leases():
|
|
684
|
+
if lz.target == target:
|
|
685
|
+
holder = lz
|
|
686
|
+
break
|
|
687
|
+
if holder is None:
|
|
688
|
+
print(f"vcs: no active lease on {target!r} — nothing to ask for. "
|
|
689
|
+
f"If a commit was refused, re-run `awgit lease acquire`.")
|
|
690
|
+
return 0
|
|
691
|
+
if holder.actor == me:
|
|
692
|
+
print(f"vcs: {target!r} is held by YOU ({me}); "
|
|
693
|
+
f"`awgit lease release {holder.lease_id}` frees it.")
|
|
694
|
+
return 0
|
|
695
|
+
|
|
696
|
+
store.add(target, holder.actor, me, getattr(args, "message", "") or "")
|
|
697
|
+
relayed = _relay_notify(holder.actor, target,
|
|
698
|
+
getattr(args, "message", "") or "")
|
|
699
|
+
print(f"vcs: asked {holder.actor} to release {target!r} "
|
|
700
|
+
f"(expires {holder.expires_ts}).")
|
|
701
|
+
print("vcs: they see it on their next `awgit lease list`"
|
|
702
|
+
+ (" and on relay #lease-negotiation." if relayed
|
|
703
|
+
else " (relay unavailable — the awgit path still delivers)."))
|
|
704
|
+
return 0
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _cmd_lease_requests(args) -> int:
|
|
708
|
+
"""What am I blocking, and what am I waiting on?"""
|
|
709
|
+
store, _ = _requests_store()
|
|
710
|
+
if store is None:
|
|
711
|
+
return 2
|
|
712
|
+
me = _actor(args)
|
|
713
|
+
from .lease_requests import format_pending
|
|
714
|
+
|
|
715
|
+
incoming = store.for_actor(me)
|
|
716
|
+
outgoing = store.by_actor(me)
|
|
717
|
+
if incoming:
|
|
718
|
+
print(format_pending(incoming))
|
|
719
|
+
else:
|
|
720
|
+
print("vcs: nobody is blocked on leases you hold.")
|
|
721
|
+
if outgoing:
|
|
722
|
+
print(f"vcs: you have asked for {len(outgoing)} lease(s):")
|
|
723
|
+
for r in outgoing[:10]:
|
|
724
|
+
print(f"vcs: {r.get('target')} <- {str(r.get('to_actor'))[:24]}")
|
|
725
|
+
return 0
|
|
562
726
|
|
|
563
727
|
|
|
564
728
|
def _cmd_lease_check(args: argparse.Namespace) -> int:
|
|
@@ -570,7 +734,7 @@ def _cmd_lease_check(args: argparse.Namespace) -> int:
|
|
|
570
734
|
if who == "unknown":
|
|
571
735
|
print("vcs: lease-check requires AITHER_ACTOR (or --actor)", file=sys.stderr)
|
|
572
736
|
return 1
|
|
573
|
-
gap = coverage_gap(
|
|
737
|
+
gap = coverage_gap(merge_authored_files(repo, _staged_files(repo)), who)
|
|
574
738
|
if gap:
|
|
575
739
|
print(
|
|
576
740
|
"vcs: commit rejected — no active lease covering: " + ", ".join(gap),
|
|
@@ -1477,29 +1641,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1477
1641
|
default=None,
|
|
1478
1642
|
help="vcs store directory (default: ~/.aither/awgit/data, or $VCS_DATA_ROOT)",
|
|
1479
1643
|
)
|
|
1644
|
+
p_capture.add_argument(
|
|
1645
|
+
"--prove",
|
|
1646
|
+
action="store_true",
|
|
1647
|
+
help="run gates and record the outcome after capture",
|
|
1648
|
+
)
|
|
1480
1649
|
|
|
1481
1650
|
p_diff = sub.add_parser("diff", help="node-level diff between two shas")
|
|
1482
1651
|
p_diff.add_argument("a", help="base sha")
|
|
1483
1652
|
p_diff.add_argument("b", help="target sha")
|
|
1484
1653
|
p_diff.add_argument("--json", action="store_true", dest="as_json")
|
|
1485
1654
|
|
|
1486
|
-
# `data` is its own verb rather than an overload of `diff`: `awgit diff`
|
|
1487
|
-
# means NODE diff and keeps that meaning (the MCP handler, two skills, the
|
|
1488
|
-
# hooks and two blog posts all depend on its shape), so a tabular diff gets
|
|
1489
|
-
# its own noun instead of silently changing an existing contract.
|
|
1490
|
-
p_data = sub.add_parser("data", help="row-level operations on tabular files")
|
|
1491
|
-
data_sub = p_data.add_subparsers(dest="data_cmd", required=True)
|
|
1492
|
-
p_data_diff = data_sub.add_parser(
|
|
1493
|
-
"diff", help="row-level diff of two CSV/TSV/parquet files")
|
|
1494
|
-
p_data_diff.add_argument("old", help="baseline table")
|
|
1495
|
-
p_data_diff.add_argument("new", help="target table")
|
|
1496
|
-
p_data_diff.add_argument(
|
|
1497
|
-
"--key", action="append", default=[], metavar="COL",
|
|
1498
|
-
help="key column giving each row its identity; repeatable. Without one "
|
|
1499
|
-
"the diff falls back to a content set-diff and cannot report "
|
|
1500
|
-
"MODIFIED rows.")
|
|
1501
|
-
p_data_diff.add_argument("--json", action="store_true", dest="as_json")
|
|
1502
|
-
|
|
1503
1655
|
p_status = sub.add_parser("status", help="op-log status")
|
|
1504
1656
|
p_status.add_argument("--json", action="store_true", dest="as_json")
|
|
1505
1657
|
p_graph = sub.add_parser(
|
|
@@ -1555,6 +1707,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1555
1707
|
p_ll = lsub.add_parser("list", help="list active leases")
|
|
1556
1708
|
p_ll.add_argument("--json", action="store_true", dest="as_json")
|
|
1557
1709
|
lsub.add_parser("sweep", help="sweep expired leases")
|
|
1710
|
+
p_lc = lsub.add_parser(
|
|
1711
|
+
"contact",
|
|
1712
|
+
help="ask whoever holds a path's lease to release it -- the missing "
|
|
1713
|
+
"half of 'talk to them or wait'")
|
|
1714
|
+
p_lc.add_argument("path")
|
|
1715
|
+
p_lc.add_argument("-m", "--message", default="", help="why you need it")
|
|
1716
|
+
p_lc.add_argument("--actor", default=None)
|
|
1717
|
+
p_lq = lsub.add_parser(
|
|
1718
|
+
"requests",
|
|
1719
|
+
help="who is blocked on YOUR leases, and what you are waiting on")
|
|
1720
|
+
p_lq.add_argument("--actor", default=None)
|
|
1558
1721
|
|
|
1559
1722
|
p_lc = sub.add_parser("lease-check", help="pre-commit lease gate")
|
|
1560
1723
|
p_lc.add_argument("--actor", default=None)
|
|
@@ -1895,8 +2058,6 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
1895
2058
|
return _cmd_capture(args)
|
|
1896
2059
|
if args.cmd == "diff":
|
|
1897
2060
|
return _cmd_diff(args)
|
|
1898
|
-
if args.cmd == "data":
|
|
1899
|
-
return _cmd_data(args)
|
|
1900
2061
|
if args.cmd == "status":
|
|
1901
2062
|
return _cmd_status(args)
|
|
1902
2063
|
if args.cmd == "merge-preview":
|
|
@@ -83,6 +83,16 @@ def gather(data_root=None, since: Optional[str] = None) -> dict:
|
|
|
83
83
|
ambiguous = [c for c in collisions if _agent_sessions(c["actors"]) < 2]
|
|
84
84
|
total = len(ops)
|
|
85
85
|
multi_actor = len(actors) > 1
|
|
86
|
+
|
|
87
|
+
# Optional host enrichment: the op-log says what changed and who changed it,
|
|
88
|
+
# never why. A host holding the agents' reasoning traces can answer that.
|
|
89
|
+
# Absent is the normal state and yields no key at all — an explicit zero
|
|
90
|
+
# would claim these agents worked without reasoning, which is a different
|
|
91
|
+
# and false statement.
|
|
92
|
+
from awgit import plugins as _plugins
|
|
93
|
+
|
|
94
|
+
reasoning = _plugins.thoughts(sorted(actors))
|
|
95
|
+
|
|
86
96
|
return {
|
|
87
97
|
"ops": total,
|
|
88
98
|
"node_changes": nodes,
|
|
@@ -101,6 +111,8 @@ def gather(data_root=None, since: Optional[str] = None) -> dict:
|
|
|
101
111
|
# The honesty flag. Every collision number below is meaningless without
|
|
102
112
|
# it, and a reader who does not know that will over-read a zero.
|
|
103
113
|
"can_detect_collisions": multi_actor,
|
|
114
|
+
# Present only when a host registered the THOUGHTS hook.
|
|
115
|
+
**({"reasoning": reasoning} if reasoning else {}),
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
|
|
@@ -123,6 +135,14 @@ def render(ev: dict) -> str:
|
|
|
123
135
|
f"{ev.get('confirmed_multi_agent_collisions', 0)} CONFIRMED "
|
|
124
136
|
f"(two distinct agent sessions), "
|
|
125
137
|
f"{ev.get('ambiguous_collisions', 0)} ambiguous")
|
|
138
|
+
reasoning = ev.get("reasoning")
|
|
139
|
+
if reasoning:
|
|
140
|
+
traces = reasoning.get("traces")
|
|
141
|
+
linked = reasoning.get("linked_actors")
|
|
142
|
+
detail = f"{traces} trace(s)" if traces is not None else "available"
|
|
143
|
+
if linked is not None:
|
|
144
|
+
detail += f" across {linked} actor(s)"
|
|
145
|
+
out.append(f" reasoning captured {detail}")
|
|
126
146
|
if not ev["can_detect_collisions"]:
|
|
127
147
|
out.append("")
|
|
128
148
|
out.append(" NOTE: only ONE actor appears in this op-log, so a collision")
|