awgit 1.2.0__tar.gz → 1.3.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.
Files changed (71) hide show
  1. {awgit-1.2.0 → awgit-1.3.0}/PKG-INFO +64 -1
  2. {awgit-1.2.0 → awgit-1.3.0}/README.md +63 -0
  3. {awgit-1.2.0 → awgit-1.3.0}/awgit/cli.py +94 -73
  4. {awgit-1.2.0 → awgit-1.3.0}/awgit/evidence.py +0 -20
  5. {awgit-1.2.0 → awgit-1.3.0}/awgit/guard.py +117 -0
  6. {awgit-1.2.0 → awgit-1.3.0}/awgit/plugins.py +0 -18
  7. {awgit-1.2.0 → awgit-1.3.0}/awgit/worktree.py +1 -0
  8. {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/PKG-INFO +64 -1
  9. {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/SOURCES.txt +1 -0
  10. {awgit-1.2.0 → awgit-1.3.0}/pyproject.toml +1 -1
  11. awgit-1.3.0/tests/test_tabular.py +273 -0
  12. {awgit-1.2.0 → awgit-1.3.0}/LICENSE +0 -0
  13. {awgit-1.2.0 → awgit-1.3.0}/awgit/__init__.py +0 -0
  14. {awgit-1.2.0 → awgit-1.3.0}/awgit/absorb.py +0 -0
  15. {awgit-1.2.0 → awgit-1.3.0}/awgit/bodies.py +0 -0
  16. {awgit-1.2.0 → awgit-1.3.0}/awgit/bridge.py +0 -0
  17. {awgit-1.2.0 → awgit-1.3.0}/awgit/capture.py +0 -0
  18. {awgit-1.2.0 → awgit-1.3.0}/awgit/changeid.py +0 -0
  19. {awgit-1.2.0 → awgit-1.3.0}/awgit/code.py +0 -0
  20. {awgit-1.2.0 → awgit-1.3.0}/awgit/commands.py +0 -0
  21. {awgit-1.2.0 → awgit-1.3.0}/awgit/data_root.py +0 -0
  22. {awgit-1.2.0 → awgit-1.3.0}/awgit/diff.py +0 -0
  23. {awgit-1.2.0 → awgit-1.3.0}/awgit/git.py +0 -0
  24. {awgit-1.2.0 → awgit-1.3.0}/awgit/graph.py +0 -0
  25. {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/chain.sh +0 -0
  26. {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/post-commit.d/vcs-capture +0 -0
  27. {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
  28. {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/pre-commit.d/vcs-mass-delete-guard +0 -0
  29. {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/pre-push.d/ci-gate-parity +0 -0
  30. {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/prepare-commit-msg.d/awgit-change-id +0 -0
  31. {awgit-1.2.0 → awgit-1.3.0}/awgit/identity.py +0 -0
  32. {awgit-1.2.0 → awgit-1.3.0}/awgit/lazy.py +0 -0
  33. {awgit-1.2.0 → awgit-1.3.0}/awgit/lease_requests.py +0 -0
  34. {awgit-1.2.0 → awgit-1.3.0}/awgit/leases.py +0 -0
  35. {awgit-1.2.0 → awgit-1.3.0}/awgit/ledger.py +0 -0
  36. {awgit-1.2.0 → awgit-1.3.0}/awgit/mcp.py +0 -0
  37. {awgit-1.2.0 → awgit-1.3.0}/awgit/merge.py +0 -0
  38. {awgit-1.2.0 → awgit-1.3.0}/awgit/nodeid.py +0 -0
  39. {awgit-1.2.0 → awgit-1.3.0}/awgit/oplog.py +0 -0
  40. {awgit-1.2.0 → awgit-1.3.0}/awgit/outcomes.py +0 -0
  41. {awgit-1.2.0 → awgit-1.3.0}/awgit/owners.py +0 -0
  42. {awgit-1.2.0 → awgit-1.3.0}/awgit/parser.py +0 -0
  43. {awgit-1.2.0 → awgit-1.3.0}/awgit/prove.py +0 -0
  44. {awgit-1.2.0 → awgit-1.3.0}/awgit/push.py +0 -0
  45. {awgit-1.2.0 → awgit-1.3.0}/awgit/repowise_parser.py +0 -0
  46. {awgit-1.2.0 → awgit-1.3.0}/awgit/review.py +0 -0
  47. {awgit-1.2.0 → awgit-1.3.0}/awgit/schema.py +0 -0
  48. {awgit-1.2.0 → awgit-1.3.0}/awgit/stack.py +0 -0
  49. {awgit-1.2.0 → awgit-1.3.0}/awgit/staging.py +0 -0
  50. {awgit-1.2.0 → awgit-1.3.0}/awgit/staging_selftest.py +0 -0
  51. {awgit-1.2.0 → awgit-1.3.0}/awgit/sync.py +0 -0
  52. {awgit-1.2.0 → awgit-1.3.0}/awgit/tabular.py +0 -0
  53. {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/dependency_links.txt +0 -0
  54. {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/entry_points.txt +0 -0
  55. {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/requires.txt +0 -0
  56. {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/top_level.txt +0 -0
  57. {awgit-1.2.0 → awgit-1.3.0}/setup.cfg +0 -0
  58. {awgit-1.2.0 → awgit-1.3.0}/tests/test_absorb_routes_by_node.py +0 -0
  59. {awgit-1.2.0 → awgit-1.3.0}/tests/test_awgit_standalone.py +0 -0
  60. {awgit-1.2.0 → awgit-1.3.0}/tests/test_changeid_survives_history_rewrite.py +0 -0
  61. {awgit-1.2.0 → awgit-1.3.0}/tests/test_documented_commands_run.py +0 -0
  62. {awgit-1.2.0 → awgit-1.3.0}/tests/test_lazy_clone_is_verified.py +0 -0
  63. {awgit-1.2.0 → awgit-1.3.0}/tests/test_lease_release_accepts_paths.py +0 -0
  64. {awgit-1.2.0 → awgit-1.3.0}/tests/test_lease_requests.py +0 -0
  65. {awgit-1.2.0 → awgit-1.3.0}/tests/test_multilang_identity.py +0 -0
  66. {awgit-1.2.0 → awgit-1.3.0}/tests/test_outcome_tracking.py +0 -0
  67. {awgit-1.2.0 → awgit-1.3.0}/tests/test_owners_and_proof.py +0 -0
  68. {awgit-1.2.0 → awgit-1.3.0}/tests/test_push_is_the_pull_request.py +0 -0
  69. {awgit-1.2.0 → awgit-1.3.0}/tests/test_restack_repairs_orphans.py +0 -0
  70. {awgit-1.2.0 → awgit-1.3.0}/tests/test_review_threads_survive_moves.py +0 -0
  71. {awgit-1.2.0 → awgit-1.3.0}/tests/test_worktree_zombie_detection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awgit
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
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
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Aitherium/awgit
@@ -264,3 +264,66 @@ cd awgit && python -m pytest tests/ -q
264
264
  ## License
265
265
 
266
266
  Apache-2.0. Built by Aitherium — the git that scales to agents.
267
+
268
+ ---
269
+
270
+ <!-- aither-ecosystem:start GENERATED from the ecosystem registry. Edits here are overwritten; change the registry instead. -->
271
+
272
+ ## The aw family
273
+
274
+ Standalone tools that share one idea: **replace something you would otherwise have to _trust_ with something you can _check_.**
275
+
276
+ Each installs on its own, works offline, and needs no account.
277
+
278
+ | | instead of trusting | you check |
279
+ |---|---|---|
280
+ | [awdk](https://github.com/Aitherium/awdk) | a framework's idea of how your agents should run | one loop you can read, pointed at a backend you already pay for |
281
+ | [awskills](https://github.com/Aitherium/awskills) | that an agent knows your procedure | the procedure written down, versioned, and loadable by any agent |
282
+ | [awm](https://github.com/Aitherium/awm) | that memory stayed in its lane | tenant:user:project scopes, so a write cannot cross a boundary |
283
+ | [awnode](https://github.com/Aitherium/awnode) | a vendor's cloud with every prompt | a local gateway routing to backends you chose |
284
+ | [awgraph](https://github.com/Aitherium/awgraph) | that grep found everything | an AST + tree-sitter call graph an agent can traverse |
285
+ | **awgit** _(you are here)_ | that no one else is editing this file | a lease, refused at commit time if you do not hold it |
286
+ | [awseal](https://github.com/Aitherium/awseal) | that the artifact came from who you think | an Ed25519 seal — the key that verifies is not the key that forges |
287
+ | [awshare](https://github.com/Aitherium/awshare) | that the download is intact | content-addressed bundles, verified on fetch |
288
+ | [awnest](https://github.com/Aitherium/awnest) | that there is a person on the other end | a verdict with evidence, where "we could not tell" is not "yes" |
289
+ | [awnboard](https://github.com/Aitherium/awnboard) | a share link anyone who sees it can use | an invitation addressed to one person, for one gate, revocable |
290
+ | [awnix](https://github.com/Aitherium/awnix) | that the box is what you left it as | an immutable image you built, with atomic rollback |
291
+ | [awrecover](https://github.com/Aitherium/awrecover) | that the restore worked | a restore that fully lands or does not land at all |
292
+ | [awrelay](https://github.com/Aitherium/awrelay) | a SaaS in the middle of your agents | findings, alerts and coordination over your own transport |
293
+ | [awmail](https://github.com/Aitherium/awmail) | a mailbox somebody else can read | mail your agents send and receive over your own server |
294
+ | [awfind](https://github.com/Aitherium/awfind) | one vendor's idea of the web | results from whichever providers you configured |
295
+ | [awbrowse](https://github.com/Aitherium/awbrowse) | that the page said what you were told | the render, the DOM and the requests it made |
296
+ | [aitherkvcache](https://github.com/Aitherium/aitherkvcache) | a vendor's quantisation defaults | sub-byte KV cache kernels you can benchmark yourself |
297
+ | [AitherZero](https://github.com/Aitherium/AitherZero) | a pile of scripts nobody has numbered | numbered, discoverable automation with declarative playbooks |
298
+ | [AitherConnect](https://github.com/Aitherium/AitherConnect) | what a page tells your browser to do | a federated search and desktop bridge you host |
299
+
300
+ [**awnix**](https://github.com/Aitherium/awnix) is the ground floor — A Linux you can hand to an agent — immutable base, capabilities included.
301
+
302
+ ## The Aitherium ecosystem
303
+
304
+ Every repository here is public. Each publishes an `aither-manifest.json` beside its page, so any surface can read every sibling's — the network is browsable from any node in it.
305
+
306
+ | repo | what it is | pages |
307
+ |---|---|---|
308
+ | [awdk](https://github.com/Aitherium/awdk) | Build AI agent fleets — 3 lines, any backend, local or cloud | [docs](https://aitherium.github.io/awdk/) |
309
+ | [awskills](https://github.com/Aitherium/awskills) | Portable agent skills — self-contained procedures an agent loads on demand | [docs](https://aitherium.github.io/awskills/) |
310
+ | [awm](https://github.com/Aitherium/awm) | A portable, scoped agent memory | [docs](https://aitherium.github.io/awm/) |
311
+ | [awnode](https://github.com/Aitherium/awnode) | A lightweight local gateway — bridges your apps to the AI backends you chose | [docs](https://aitherium.github.io/awnode/) |
312
+ | [awrun](https://github.com/Aitherium/awrun) | A priority-aware queue and dispatcher for agentic runs and ad-hoc CI builds | [docs](https://aitherium.github.io/awrun/) |
313
+ | [awgraph](https://github.com/Aitherium/awgraph) | A semantic code graph for agents — AST + tree-sitter, call graphs | [docs](https://aitherium.github.io/awgraph/) |
314
+ | **awgit** _(you are here)_ | Semantic version control on top of git — edit-ops and leases | [docs](https://aitherium.github.io/awgit/) |
315
+ | [awseal](https://github.com/Aitherium/awseal) | Sign an artifact so a stranger can verify it | [docs](https://aitherium.github.io/awseal/) |
316
+ | [awshare](https://github.com/Aitherium/awshare) | Publish an artifact and fetch it back verified | [docs](https://aitherium.github.io/awshare/) |
317
+ | [awnest](https://github.com/Aitherium/awnest) | Prove there is a human before you let them into the nest | — |
318
+ | [awnboard](https://github.com/Aitherium/awnboard) | A front gate you can put in front of anything, and hand someone the key to | — |
319
+ | [awnix](https://github.com/Aitherium/awnix) | A Linux you can hand to an agent — immutable base, capabilities included | [docs](https://aitherium.github.io/awnix/) |
320
+ | [awrecover](https://github.com/Aitherium/awrecover) | Labelled snapshots with an all-or-nothing restore | [docs](https://aitherium.github.io/awrecover/) |
321
+ | [awrelay](https://github.com/Aitherium/awrelay) | Portable agent messaging — findings, alerts, coordination | [docs](https://aitherium.github.io/awrelay/) |
322
+ | [awmail](https://github.com/Aitherium/awmail) | Give an agent an email address — send, and actually receive | — |
323
+ | [awfind](https://github.com/Aitherium/awfind) | A portable search client — query, results, ranking | — |
324
+ | [awbrowse](https://github.com/Aitherium/awbrowse) | A portable browser client — navigate, console, network, DOM, screenshot | — |
325
+ | [aitherkvcache](https://github.com/Aitherium/aitherkvcache) | Near-optimal KV cache quantization for LLM inference — sub-byte compression | [docs](https://aitherium.github.io/aitherkvcache/) |
326
+ | [AitherZero](https://github.com/Aitherium/AitherZero) | PowerShell 7+ automation framework — numbered, self-describing scripts | [docs](https://aitherium.github.io/AitherZero/) |
327
+ | [AitherConnect](https://github.com/Aitherium/AitherConnect) | Browser extension — federated AI search, page context, and the Living OS overlay | — |
328
+
329
+ <!-- aither-ecosystem:end -->
@@ -247,3 +247,66 @@ cd awgit && python -m pytest tests/ -q
247
247
  ## License
248
248
 
249
249
  Apache-2.0. Built by Aitherium — the git that scales to agents.
250
+
251
+ ---
252
+
253
+ <!-- aither-ecosystem:start GENERATED from the ecosystem registry. Edits here are overwritten; change the registry instead. -->
254
+
255
+ ## The aw family
256
+
257
+ Standalone tools that share one idea: **replace something you would otherwise have to _trust_ with something you can _check_.**
258
+
259
+ Each installs on its own, works offline, and needs no account.
260
+
261
+ | | instead of trusting | you check |
262
+ |---|---|---|
263
+ | [awdk](https://github.com/Aitherium/awdk) | a framework's idea of how your agents should run | one loop you can read, pointed at a backend you already pay for |
264
+ | [awskills](https://github.com/Aitherium/awskills) | that an agent knows your procedure | the procedure written down, versioned, and loadable by any agent |
265
+ | [awm](https://github.com/Aitherium/awm) | that memory stayed in its lane | tenant:user:project scopes, so a write cannot cross a boundary |
266
+ | [awnode](https://github.com/Aitherium/awnode) | a vendor's cloud with every prompt | a local gateway routing to backends you chose |
267
+ | [awgraph](https://github.com/Aitherium/awgraph) | that grep found everything | an AST + tree-sitter call graph an agent can traverse |
268
+ | **awgit** _(you are here)_ | that no one else is editing this file | a lease, refused at commit time if you do not hold it |
269
+ | [awseal](https://github.com/Aitherium/awseal) | that the artifact came from who you think | an Ed25519 seal — the key that verifies is not the key that forges |
270
+ | [awshare](https://github.com/Aitherium/awshare) | that the download is intact | content-addressed bundles, verified on fetch |
271
+ | [awnest](https://github.com/Aitherium/awnest) | that there is a person on the other end | a verdict with evidence, where "we could not tell" is not "yes" |
272
+ | [awnboard](https://github.com/Aitherium/awnboard) | a share link anyone who sees it can use | an invitation addressed to one person, for one gate, revocable |
273
+ | [awnix](https://github.com/Aitherium/awnix) | that the box is what you left it as | an immutable image you built, with atomic rollback |
274
+ | [awrecover](https://github.com/Aitherium/awrecover) | that the restore worked | a restore that fully lands or does not land at all |
275
+ | [awrelay](https://github.com/Aitherium/awrelay) | a SaaS in the middle of your agents | findings, alerts and coordination over your own transport |
276
+ | [awmail](https://github.com/Aitherium/awmail) | a mailbox somebody else can read | mail your agents send and receive over your own server |
277
+ | [awfind](https://github.com/Aitherium/awfind) | one vendor's idea of the web | results from whichever providers you configured |
278
+ | [awbrowse](https://github.com/Aitherium/awbrowse) | that the page said what you were told | the render, the DOM and the requests it made |
279
+ | [aitherkvcache](https://github.com/Aitherium/aitherkvcache) | a vendor's quantisation defaults | sub-byte KV cache kernels you can benchmark yourself |
280
+ | [AitherZero](https://github.com/Aitherium/AitherZero) | a pile of scripts nobody has numbered | numbered, discoverable automation with declarative playbooks |
281
+ | [AitherConnect](https://github.com/Aitherium/AitherConnect) | what a page tells your browser to do | a federated search and desktop bridge you host |
282
+
283
+ [**awnix**](https://github.com/Aitherium/awnix) is the ground floor — A Linux you can hand to an agent — immutable base, capabilities included.
284
+
285
+ ## The Aitherium ecosystem
286
+
287
+ Every repository here is public. Each publishes an `aither-manifest.json` beside its page, so any surface can read every sibling's — the network is browsable from any node in it.
288
+
289
+ | repo | what it is | pages |
290
+ |---|---|---|
291
+ | [awdk](https://github.com/Aitherium/awdk) | Build AI agent fleets — 3 lines, any backend, local or cloud | [docs](https://aitherium.github.io/awdk/) |
292
+ | [awskills](https://github.com/Aitherium/awskills) | Portable agent skills — self-contained procedures an agent loads on demand | [docs](https://aitherium.github.io/awskills/) |
293
+ | [awm](https://github.com/Aitherium/awm) | A portable, scoped agent memory | [docs](https://aitherium.github.io/awm/) |
294
+ | [awnode](https://github.com/Aitherium/awnode) | A lightweight local gateway — bridges your apps to the AI backends you chose | [docs](https://aitherium.github.io/awnode/) |
295
+ | [awrun](https://github.com/Aitherium/awrun) | A priority-aware queue and dispatcher for agentic runs and ad-hoc CI builds | [docs](https://aitherium.github.io/awrun/) |
296
+ | [awgraph](https://github.com/Aitherium/awgraph) | A semantic code graph for agents — AST + tree-sitter, call graphs | [docs](https://aitherium.github.io/awgraph/) |
297
+ | **awgit** _(you are here)_ | Semantic version control on top of git — edit-ops and leases | [docs](https://aitherium.github.io/awgit/) |
298
+ | [awseal](https://github.com/Aitherium/awseal) | Sign an artifact so a stranger can verify it | [docs](https://aitherium.github.io/awseal/) |
299
+ | [awshare](https://github.com/Aitherium/awshare) | Publish an artifact and fetch it back verified | [docs](https://aitherium.github.io/awshare/) |
300
+ | [awnest](https://github.com/Aitherium/awnest) | Prove there is a human before you let them into the nest | — |
301
+ | [awnboard](https://github.com/Aitherium/awnboard) | A front gate you can put in front of anything, and hand someone the key to | — |
302
+ | [awnix](https://github.com/Aitherium/awnix) | A Linux you can hand to an agent — immutable base, capabilities included | [docs](https://aitherium.github.io/awnix/) |
303
+ | [awrecover](https://github.com/Aitherium/awrecover) | Labelled snapshots with an all-or-nothing restore | [docs](https://aitherium.github.io/awrecover/) |
304
+ | [awrelay](https://github.com/Aitherium/awrelay) | Portable agent messaging — findings, alerts, coordination | [docs](https://aitherium.github.io/awrelay/) |
305
+ | [awmail](https://github.com/Aitherium/awmail) | Give an agent an email address — send, and actually receive | — |
306
+ | [awfind](https://github.com/Aitherium/awfind) | A portable search client — query, results, ranking | — |
307
+ | [awbrowse](https://github.com/Aitherium/awbrowse) | A portable browser client — navigate, console, network, DOM, screenshot | — |
308
+ | [aitherkvcache](https://github.com/Aitherium/aitherkvcache) | Near-optimal KV cache quantization for LLM inference — sub-byte compression | [docs](https://aitherium.github.io/aitherkvcache/) |
309
+ | [AitherZero](https://github.com/Aitherium/AitherZero) | PowerShell 7+ automation framework — numbered, self-describing scripts | [docs](https://aitherium.github.io/AitherZero/) |
310
+ | [AitherConnect](https://github.com/Aitherium/AitherConnect) | Browser extension — federated AI search, page context, and the Living OS overlay | — |
311
+
312
+ <!-- aither-ecosystem:end -->
@@ -107,6 +107,48 @@ def _cmd_capture(args: argparse.Namespace) -> int:
107
107
  return 0
108
108
 
109
109
 
110
+ def _cmd_data(args: argparse.Namespace) -> int:
111
+ """Row-level diff of two tabular files (see awgit/tabular.py)."""
112
+ import json # function-local, matching the convention in this module
113
+
114
+ from . import tabular
115
+
116
+ if args.data_cmd != "diff": # pragma: no cover - argparse enforces this
117
+ print(f"awgit: unknown data subcommand {args.data_cmd!r}", file=sys.stderr)
118
+ return 2
119
+
120
+ try:
121
+ d = tabular.diff_files(args.old, args.new, args.key)
122
+ except tabular.UnreadableTableError as exc:
123
+ # Exit 2, never 0-with-empty-output: a table we could not read must not
124
+ # be reported as a table with no differences.
125
+ print(f"awgit: {exc}", file=sys.stderr)
126
+ return 2
127
+
128
+ if args.as_json:
129
+ print(json.dumps({
130
+ "summary": d.summary(),
131
+ "added": d.added,
132
+ "removed": d.removed,
133
+ "modified": [{"before": b, "after": a} for b, a in d.modified],
134
+ }, indent=2, default=str))
135
+ return 0
136
+
137
+ s = d.summary()
138
+ if d.keyless:
139
+ print("no --key given: content set-diff only; MODIFIED rows cannot be "
140
+ "distinguished from an add plus a remove.")
141
+ else:
142
+ print(f"keyed on: {', '.join(d.keys)}")
143
+ for col in s["columns_added"]:
144
+ print(f" + column {col}")
145
+ for col in s["columns_removed"]:
146
+ print(f" - column {col}")
147
+ print(f" {s['added']} added, {s['removed']} removed, "
148
+ f"{s['modified']} modified, {s['unchanged']} unchanged")
149
+ return 0
150
+
151
+
110
152
  def _cmd_diff(args: argparse.Namespace) -> int:
111
153
  try:
112
154
  changes = diff_git(args.a, args.b)
@@ -446,28 +488,6 @@ def staged_but_not_committed(repo: Path) -> List[str]:
446
488
  except OSError:
447
489
  return []
448
490
 
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
-
471
491
  # The paths AT RISK are the ones really staged — `_staged_files` would read
472
492
  # the temp index here, for the same GIT_INDEX_FILE reason as above.
473
493
  paths = [
@@ -574,66 +594,48 @@ def _cmd_stage_mine(args: argparse.Namespace) -> int:
574
594
  return 1 if failed else 0
575
595
 
576
596
 
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.
597
+ def _merge_hand_resolved(repo: Path, staged: List[str]) -> List[str]:
598
+ """During a merge, the files the COMMITTER actually decided.
585
599
 
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.
600
+ A merge commit legitimately stages everything the other lineage touched. On
601
+ this repo that is 1,266 files for a single recovery merge, and demanding a
602
+ lease on each is not a safety property -- it is a wall. Measured 2026-08-19:
603
+ `lease acquire --staged --adopt` granted zero, and acquiring them in batches
604
+ ran past six minutes without finishing, so a legitimate merge could not be
605
+ recorded at all.
590
606
 
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.
607
+ The gate exists to stop one session sweeping another's IN-FLIGHT edit. A file
608
+ taken verbatim from either parent is nobody's in-flight edit -- git chose it,
609
+ not the committer. Only a file whose staged blob differs from BOTH parents was
610
+ hand-resolved, and those are exactly the committer's own work, so those are
611
+ what still require a lease.
594
612
 
595
- Returns `staged` unchanged when this is not a merge.
613
+ Returns the staged paths when this is not a merge, so the normal path is
614
+ unchanged.
596
615
  """
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():
616
+ git_dir = subprocess.run(
617
+ ["git", "rev-parse", "--git-dir"], cwd=str(repo),
618
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
619
+ ).stdout.strip()
620
+ if not git_dir or not (Path(repo) / git_dir / "MERGE_HEAD").exists() and not Path(git_dir, "MERGE_HEAD").exists():
605
621
  return staged
606
622
 
607
- def blobs(rev: str) -> dict:
623
+ def _differs(ref: str) -> set:
608
624
  out = subprocess.run(
609
- ["git", "ls-tree", "-r", rev, "--", *staged], cwd=str(repo),
625
+ ["git", "diff", "--cached", "--name-only", ref], cwd=str(repo),
610
626
  capture_output=True, text=True, encoding="utf-8", errors="replace",
611
627
  ).stdout
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:
621
- return staged
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]
628
+ return {ln for ln in out.splitlines() if ln.strip()}
633
629
 
634
- authored = [p for p in staged
635
- if index.get(p) not in (ours.get(p), theirs.get(p))]
636
- return authored
630
+ ours, theirs = _differs("HEAD"), _differs("MERGE_HEAD")
631
+ if not ours and not theirs:
632
+ # Could not read either parent -> judge nothing away; fall back to the
633
+ # full staged set rather than exempting everything.
634
+ return staged
635
+ hand = sorted(set(staged) & ours & theirs)
636
+ print(f"vcs: merge in progress -- {len(staged)} staged, {len(hand)} hand-resolved; "
637
+ f"a lease is required on the hand-resolved files only")
638
+ return hand
637
639
 
638
640
 
639
641
  def _requests_store():
@@ -734,7 +736,7 @@ def _cmd_lease_check(args: argparse.Namespace) -> int:
734
736
  if who == "unknown":
735
737
  print("vcs: lease-check requires AITHER_ACTOR (or --actor)", file=sys.stderr)
736
738
  return 1
737
- gap = coverage_gap(merge_authored_files(repo, _staged_files(repo)), who)
739
+ gap = coverage_gap(_merge_hand_resolved(repo, _staged_files(repo)), who)
738
740
  if gap:
739
741
  print(
740
742
  "vcs: commit rejected — no active lease covering: " + ", ".join(gap),
@@ -1652,6 +1654,23 @@ def build_parser() -> argparse.ArgumentParser:
1652
1654
  p_diff.add_argument("b", help="target sha")
1653
1655
  p_diff.add_argument("--json", action="store_true", dest="as_json")
1654
1656
 
1657
+ # `data` is its own verb rather than an overload of `diff`: `awgit diff`
1658
+ # means NODE diff and keeps that meaning (the MCP handler, two skills, the
1659
+ # hooks and two blog posts all depend on its shape), so a tabular diff gets
1660
+ # its own noun instead of silently changing an existing contract.
1661
+ p_data = sub.add_parser("data", help="row-level operations on tabular files")
1662
+ data_sub = p_data.add_subparsers(dest="data_cmd", required=True)
1663
+ p_data_diff = data_sub.add_parser(
1664
+ "diff", help="row-level diff of two CSV/TSV/parquet files")
1665
+ p_data_diff.add_argument("old", help="baseline table")
1666
+ p_data_diff.add_argument("new", help="target table")
1667
+ p_data_diff.add_argument(
1668
+ "--key", action="append", default=[], metavar="COL",
1669
+ help="key column giving each row its identity; repeatable. Without one "
1670
+ "the diff falls back to a content set-diff and cannot report "
1671
+ "MODIFIED rows.")
1672
+ p_data_diff.add_argument("--json", action="store_true", dest="as_json")
1673
+
1655
1674
  p_status = sub.add_parser("status", help="op-log status")
1656
1675
  p_status.add_argument("--json", action="store_true", dest="as_json")
1657
1676
  p_graph = sub.add_parser(
@@ -2058,6 +2077,8 @@ def main(argv: Optional[List[str]] = None) -> int:
2058
2077
  return _cmd_capture(args)
2059
2078
  if args.cmd == "diff":
2060
2079
  return _cmd_diff(args)
2080
+ if args.cmd == "data":
2081
+ return _cmd_data(args)
2061
2082
  if args.cmd == "status":
2062
2083
  return _cmd_status(args)
2063
2084
  if args.cmd == "merge-preview":
@@ -83,16 +83,6 @@ 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
-
96
86
  return {
97
87
  "ops": total,
98
88
  "node_changes": nodes,
@@ -111,8 +101,6 @@ def gather(data_root=None, since: Optional[str] = None) -> dict:
111
101
  # The honesty flag. Every collision number below is meaningless without
112
102
  # it, and a reader who does not know that will over-read a zero.
113
103
  "can_detect_collisions": multi_actor,
114
- # Present only when a host registered the THOUGHTS hook.
115
- **({"reasoning": reasoning} if reasoning else {}),
116
104
  }
117
105
 
118
106
 
@@ -135,14 +123,6 @@ def render(ev: dict) -> str:
135
123
  f"{ev.get('confirmed_multi_agent_collisions', 0)} CONFIRMED "
136
124
  f"(two distinct agent sessions), "
137
125
  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}")
146
126
  if not ev["can_detect_collisions"]:
147
127
  out.append("")
148
128
  out.append(" NOTE: only ONE actor appears in this op-log, so a collision")
@@ -144,6 +144,123 @@ def check(actor: str, cwd: Optional[Path] = None) -> Verdict:
144
144
  return Verdict(True, basis="no other actor holds a lease in this worktree")
145
145
 
146
146
 
147
+ #: The branch a PUBLISHED artifact is supposed to come from. Overridable, because
148
+ #: a product with its own release branch is a real thing -- but it has a default,
149
+ #: because "which branch should this have been built from" is not a question the
150
+ #: person mid-deploy should have to answer.
151
+ DEFAULT_DEPLOY_REF = "origin/develop"
152
+
153
+
154
+ @dataclass
155
+ class ShipVerdict:
156
+ """Is this working tree fit to build a PUBLISHED artifact from?
157
+
158
+ Separate from `Verdict` on purpose: that one asks whether rewriting history
159
+ here would destroy a peer's work, this one asks whether shipping from here
160
+ would publish something old. Same tree, different danger.
161
+ """
162
+
163
+ ok: bool
164
+ behind: int = 0
165
+ ahead: int = 0
166
+ ref: str = DEFAULT_DEPLOY_REF
167
+ head: str = ""
168
+ reasons: List[str] = field(default_factory=list)
169
+ fix: List[str] = field(default_factory=list)
170
+ #: True when the question could not be ANSWERED (no such ref, not a repo).
171
+ #: Callers must treat this as a refusal, never as a pass -- an unanswerable
172
+ #: staleness check is exactly the state that let a stale build ship.
173
+ unknown: bool = False
174
+
175
+ def report(self) -> str:
176
+ head, *rest = self.reasons or ["unfit to publish from"]
177
+ lines = [f"awgit: refusing to publish — {head}"]
178
+ lines += [f"awgit: {r}" for r in rest]
179
+ lines += [f"awgit: {f}" for f in self.fix]
180
+ return "\n".join(lines)
181
+
182
+
183
+ def behind_deploy_ref(
184
+ cwd: Optional[Path] = None, ref: str = DEFAULT_DEPLOY_REF
185
+ ) -> ShipVerdict:
186
+ """How far the current checkout is behind the branch releases come from.
187
+
188
+ 🚨 WHY THIS EXISTS, measured 2026-08-20. Two customer-facing sites were
189
+ published from a feature branch **587 commits behind the deploy branch**. The build succeeded, every gate
190
+ passed, the export was byte-valid, and the sites came up looking right. What
191
+ shipped was a desktop carrying a bug that had been FIXED on develop earlier
192
+ that same day (the Aeon window could not be dragged, because its body was not
193
+ a containing block, so app content painted over the only drag handle).
194
+
195
+ Nothing in the pipeline could see it. A build does not know what it is missing;
196
+ `git status` is clean on a stale branch; and the artifact is *correct*, just
197
+ old. The only signal was the owner recognising a bug he had already had fixed.
198
+
199
+ Two hours of that session were also spent re-deriving fixes that already
200
+ existed on develop -- the same route exclusion, the same malformed blog post.
201
+ Staleness does not only ship old code, it burns the time spent rebuilding what
202
+ someone already built.
203
+
204
+ Answers from the LOCAL ref: this must be usable in a publish path that may have
205
+ no network, and a fetch that silently fails would turn "up to date" into a lie.
206
+ `stale_ref_age_days` reports how old the local ref is so a caller can insist on
207
+ a fetch when it matters, rather than this function pretending to know.
208
+ """
209
+ repo = repo_root(cwd)
210
+ if repo is None:
211
+ return ShipVerdict(
212
+ ok=False, unknown=True, ref=ref,
213
+ reasons=["not inside a git repository, so staleness cannot be judged"],
214
+ fix=["run this from the repo, or pass an explicit --allow-stale reason"],
215
+ )
216
+
217
+ head = _git(repo, "rev-parse", "--short", "HEAD").strip()
218
+ # `rev-list --count A..B` counts commits in B not in A.
219
+ raw = _git(repo, "rev-list", "--left-right", "--count", f"HEAD...{ref}").strip()
220
+ parts = raw.split()
221
+ if len(parts) != 2 or not all(x.isdigit() for x in parts):
222
+ return ShipVerdict(
223
+ ok=False, unknown=True, ref=ref, head=head,
224
+ reasons=[f"could not compare HEAD against {ref} (is it fetched?)"],
225
+ fix=[f"git fetch origin && retry, or pass --allow-stale with a reason"],
226
+ )
227
+ ahead, behind = int(parts[0]), int(parts[1])
228
+ if behind == 0:
229
+ return ShipVerdict(ok=True, behind=0, ahead=ahead, ref=ref, head=head)
230
+
231
+ return ShipVerdict(
232
+ ok=False, behind=behind, ahead=ahead, ref=ref, head=head,
233
+ reasons=[
234
+ f"HEAD ({head}) is {behind} commit(s) BEHIND {ref}",
235
+ "a build from here succeeds and ships code that was already fixed —"
236
+ " there is no other signal, because the artifact is correct, just old",
237
+ ],
238
+ fix=[
239
+ f"fix: git fetch origin && git merge {ref} (or rebuild from {ref})",
240
+ "override only with a stated reason: --allow-stale '<why>'",
241
+ ],
242
+ )
243
+
244
+
245
+ def stale_ref_age_days(cwd: Optional[Path] = None,
246
+ ref: str = DEFAULT_DEPLOY_REF) -> Optional[float]:
247
+ """Age in days of the LOCAL copy of `ref`, or None if it cannot be read.
248
+
249
+ `behind_deploy_ref` compares against whatever was last fetched. A ref fetched
250
+ a week ago can report "0 behind" and be badly wrong, so the age is the second
251
+ half of the answer and callers should surface it.
252
+ """
253
+ repo = repo_root(cwd)
254
+ if repo is None:
255
+ return None
256
+ out = _git(repo, "log", "-1", "--format=%ct", ref).strip()
257
+ if not out.isdigit():
258
+ return None
259
+ import time
260
+
261
+ return max(0.0, (time.time() - int(out)) / 86400.0)
262
+
263
+
147
264
  def require(actor: str, cwd: Optional[Path] = None) -> Optional[int]:
148
265
  """``None`` when a rewrite may proceed, else an exit code after reporting.
149
266
 
@@ -42,7 +42,6 @@ PARSER = "parser" # (bytes, str) -> graph with .chunks
42
42
  BLAST_RADIUS = "blast_radius" # (node_id, symbol, path) -> dict
43
43
  EXTEND_PARSER = "extend_parser" # (argparse subparsers action) -> None
44
44
  GATES = "gates" # (list[str] changed paths) -> dict
45
- THOUGHTS = "thoughts" # (list[str] actors) -> dict
46
45
 
47
46
  _hooks: Dict[str, Callable[..., Any]] = {}
48
47
  _multi: Dict[str, List[Callable[..., Any]]] = {}
@@ -142,23 +141,6 @@ def extend_parser(subparsers: Any) -> None:
142
141
  call_all(EXTEND_PARSER, subparsers)
143
142
 
144
143
 
145
- def thoughts(actors: List[str]) -> Dict[str, Any]:
146
- """Reasoning-trace enrichment for an evidence report; ``{}`` when absent.
147
-
148
- Standalone awgit records WHAT changed and WHO changed it. A host that also
149
- keeps the agents' reasoning can answer WHY, and that is the one question an
150
- op-log structurally cannot. Absent here means the evidence report simply
151
- does not mention reasoning — never a zero, which would assert that these
152
- agents did their work without any.
153
-
154
- The hook is handed only the actor labels already in the report; it must not
155
- be given anything that would make an evidence report depend on a host being
156
- present to be correct.
157
- """
158
- out = call(THOUGHTS, list(actors), default={})
159
- return out if isinstance(out, dict) else {}
160
-
161
-
162
144
  def self_test() -> int:
163
145
  """Prove the seam still behaves: default, override, and failure-is-soft."""
164
146
  failures = 0
@@ -86,6 +86,7 @@ def _is_zombie(path: Path, cwd: Optional[Path]) -> bool:
86
86
  that traps a later ``cd`` into it: any git command run there finds no
87
87
  local ``.git``, walks up, and silently resolves to whatever repo happens
88
88
  to contain it, which is why this is asserted rather than assumed.
89
+ to contain it.
89
90
  """
90
91
  if not path.is_dir() or (path / ".git").exists():
91
92
  return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awgit
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
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
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Aitherium/awgit
@@ -264,3 +264,66 @@ cd awgit && python -m pytest tests/ -q
264
264
  ## License
265
265
 
266
266
  Apache-2.0. Built by Aitherium — the git that scales to agents.
267
+
268
+ ---
269
+
270
+ <!-- aither-ecosystem:start GENERATED from the ecosystem registry. Edits here are overwritten; change the registry instead. -->
271
+
272
+ ## The aw family
273
+
274
+ Standalone tools that share one idea: **replace something you would otherwise have to _trust_ with something you can _check_.**
275
+
276
+ Each installs on its own, works offline, and needs no account.
277
+
278
+ | | instead of trusting | you check |
279
+ |---|---|---|
280
+ | [awdk](https://github.com/Aitherium/awdk) | a framework's idea of how your agents should run | one loop you can read, pointed at a backend you already pay for |
281
+ | [awskills](https://github.com/Aitherium/awskills) | that an agent knows your procedure | the procedure written down, versioned, and loadable by any agent |
282
+ | [awm](https://github.com/Aitherium/awm) | that memory stayed in its lane | tenant:user:project scopes, so a write cannot cross a boundary |
283
+ | [awnode](https://github.com/Aitherium/awnode) | a vendor's cloud with every prompt | a local gateway routing to backends you chose |
284
+ | [awgraph](https://github.com/Aitherium/awgraph) | that grep found everything | an AST + tree-sitter call graph an agent can traverse |
285
+ | **awgit** _(you are here)_ | that no one else is editing this file | a lease, refused at commit time if you do not hold it |
286
+ | [awseal](https://github.com/Aitherium/awseal) | that the artifact came from who you think | an Ed25519 seal — the key that verifies is not the key that forges |
287
+ | [awshare](https://github.com/Aitherium/awshare) | that the download is intact | content-addressed bundles, verified on fetch |
288
+ | [awnest](https://github.com/Aitherium/awnest) | that there is a person on the other end | a verdict with evidence, where "we could not tell" is not "yes" |
289
+ | [awnboard](https://github.com/Aitherium/awnboard) | a share link anyone who sees it can use | an invitation addressed to one person, for one gate, revocable |
290
+ | [awnix](https://github.com/Aitherium/awnix) | that the box is what you left it as | an immutable image you built, with atomic rollback |
291
+ | [awrecover](https://github.com/Aitherium/awrecover) | that the restore worked | a restore that fully lands or does not land at all |
292
+ | [awrelay](https://github.com/Aitherium/awrelay) | a SaaS in the middle of your agents | findings, alerts and coordination over your own transport |
293
+ | [awmail](https://github.com/Aitherium/awmail) | a mailbox somebody else can read | mail your agents send and receive over your own server |
294
+ | [awfind](https://github.com/Aitherium/awfind) | one vendor's idea of the web | results from whichever providers you configured |
295
+ | [awbrowse](https://github.com/Aitherium/awbrowse) | that the page said what you were told | the render, the DOM and the requests it made |
296
+ | [aitherkvcache](https://github.com/Aitherium/aitherkvcache) | a vendor's quantisation defaults | sub-byte KV cache kernels you can benchmark yourself |
297
+ | [AitherZero](https://github.com/Aitherium/AitherZero) | a pile of scripts nobody has numbered | numbered, discoverable automation with declarative playbooks |
298
+ | [AitherConnect](https://github.com/Aitherium/AitherConnect) | what a page tells your browser to do | a federated search and desktop bridge you host |
299
+
300
+ [**awnix**](https://github.com/Aitherium/awnix) is the ground floor — A Linux you can hand to an agent — immutable base, capabilities included.
301
+
302
+ ## The Aitherium ecosystem
303
+
304
+ Every repository here is public. Each publishes an `aither-manifest.json` beside its page, so any surface can read every sibling's — the network is browsable from any node in it.
305
+
306
+ | repo | what it is | pages |
307
+ |---|---|---|
308
+ | [awdk](https://github.com/Aitherium/awdk) | Build AI agent fleets — 3 lines, any backend, local or cloud | [docs](https://aitherium.github.io/awdk/) |
309
+ | [awskills](https://github.com/Aitherium/awskills) | Portable agent skills — self-contained procedures an agent loads on demand | [docs](https://aitherium.github.io/awskills/) |
310
+ | [awm](https://github.com/Aitherium/awm) | A portable, scoped agent memory | [docs](https://aitherium.github.io/awm/) |
311
+ | [awnode](https://github.com/Aitherium/awnode) | A lightweight local gateway — bridges your apps to the AI backends you chose | [docs](https://aitherium.github.io/awnode/) |
312
+ | [awrun](https://github.com/Aitherium/awrun) | A priority-aware queue and dispatcher for agentic runs and ad-hoc CI builds | [docs](https://aitherium.github.io/awrun/) |
313
+ | [awgraph](https://github.com/Aitherium/awgraph) | A semantic code graph for agents — AST + tree-sitter, call graphs | [docs](https://aitherium.github.io/awgraph/) |
314
+ | **awgit** _(you are here)_ | Semantic version control on top of git — edit-ops and leases | [docs](https://aitherium.github.io/awgit/) |
315
+ | [awseal](https://github.com/Aitherium/awseal) | Sign an artifact so a stranger can verify it | [docs](https://aitherium.github.io/awseal/) |
316
+ | [awshare](https://github.com/Aitherium/awshare) | Publish an artifact and fetch it back verified | [docs](https://aitherium.github.io/awshare/) |
317
+ | [awnest](https://github.com/Aitherium/awnest) | Prove there is a human before you let them into the nest | — |
318
+ | [awnboard](https://github.com/Aitherium/awnboard) | A front gate you can put in front of anything, and hand someone the key to | — |
319
+ | [awnix](https://github.com/Aitherium/awnix) | A Linux you can hand to an agent — immutable base, capabilities included | [docs](https://aitherium.github.io/awnix/) |
320
+ | [awrecover](https://github.com/Aitherium/awrecover) | Labelled snapshots with an all-or-nothing restore | [docs](https://aitherium.github.io/awrecover/) |
321
+ | [awrelay](https://github.com/Aitherium/awrelay) | Portable agent messaging — findings, alerts, coordination | [docs](https://aitherium.github.io/awrelay/) |
322
+ | [awmail](https://github.com/Aitherium/awmail) | Give an agent an email address — send, and actually receive | — |
323
+ | [awfind](https://github.com/Aitherium/awfind) | A portable search client — query, results, ranking | — |
324
+ | [awbrowse](https://github.com/Aitherium/awbrowse) | A portable browser client — navigate, console, network, DOM, screenshot | — |
325
+ | [aitherkvcache](https://github.com/Aitherium/aitherkvcache) | Near-optimal KV cache quantization for LLM inference — sub-byte compression | [docs](https://aitherium.github.io/aitherkvcache/) |
326
+ | [AitherZero](https://github.com/Aitherium/AitherZero) | PowerShell 7+ automation framework — numbered, self-describing scripts | [docs](https://aitherium.github.io/AitherZero/) |
327
+ | [AitherConnect](https://github.com/Aitherium/AitherConnect) | Browser extension — federated AI search, page context, and the Living OS overlay | — |
328
+
329
+ <!-- aither-ecosystem:end -->
@@ -65,4 +65,5 @@ tests/test_owners_and_proof.py
65
65
  tests/test_push_is_the_pull_request.py
66
66
  tests/test_restack_repairs_orphans.py
67
67
  tests/test_review_threads_survive_moves.py
68
+ tests/test_tabular.py
68
69
  tests/test_worktree_zombie_detection.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "awgit"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Aither World-Graph git — semantic version control on top of git: edit-ops keyed on stable node ids, content-addressed bodies, differential sync."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,273 @@
1
+ """Row-level tabular diff — the properties that make it worth having."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from awgit.tabular import (
10
+ Table,
11
+ UnreadableTableError,
12
+ diff_files,
13
+ diff_tables,
14
+ is_tabular,
15
+ row_content,
16
+ row_identity,
17
+ )
18
+
19
+
20
+ def _csv(tmp_path: Path, name: str, columns, rows) -> Path:
21
+ p = tmp_path / name
22
+ with open(p, "w", encoding="utf-8", newline="") as fh:
23
+ w = csv.DictWriter(fh, fieldnames=columns)
24
+ w.writeheader()
25
+ for r in rows:
26
+ w.writerow(r)
27
+ return p
28
+
29
+
30
+ def _t(columns, rows) -> Table:
31
+ return Table(columns=list(columns), rows=[dict(r) for r in rows])
32
+
33
+
34
+ # --- the encoding property everything else rests on -------------------------
35
+
36
+ def test_encoding_is_injective_across_field_boundaries():
37
+ """Two different rows must never share a content address.
38
+
39
+ The values below are chosen so that a naive `name + value` concatenation
40
+ produces the SAME byte string for both rows — the second row's value absorbs
41
+ the next column's name:
42
+
43
+ x="ay", y="b" -> "x" "ay" "y" "b" -> xayyb
44
+ x="a", y="yb" -> "x" "a" "y" "yb" -> xayyb
45
+
46
+ Oxen appends cell bytes into a buffer with no delimiter at all, so it is
47
+ vulnerable to this class; a real edit hashes as "unchanged". Length-prefixing
48
+ each name and value makes the encoding injective.
49
+
50
+ The first version of this test used ("ab","c") vs ("a","bc"), which does NOT
51
+ collide once column names are interleaved — it passed even with the encoder
52
+ deliberately broken. A mutation run caught it. An assertion that cannot fail
53
+ is worse than no assertion, because it reads as coverage.
54
+ """
55
+ cols = ["x", "y"]
56
+ a = row_content({"x": "ay", "y": "b"}, cols)
57
+ b = row_content({"x": "a", "y": "yb"}, cols)
58
+ assert a != b
59
+
60
+
61
+ def test_null_collides_with_nothing():
62
+ """A null must differ from an empty cell AND from the literal text "None".
63
+
64
+ The second half is the one that needs the sentinel. Without it, `None` falls
65
+ through to `str(None)` and hashes as the four characters "None" — so a data
66
+ cell whose value really is the string "None" (common in exported CSVs, and
67
+ in any column of Python reprs) becomes indistinguishable from a missing
68
+ value. A first version of this test asserted only `None != ""`, which passes
69
+ even with the sentinel removed; a mutation run caught that it proved nothing.
70
+ """
71
+ cols = ["x"]
72
+ null = row_content({"x": None}, cols)
73
+ assert null != row_content({"x": ""}, cols)
74
+ assert null != row_content({"x": "None"}, cols)
75
+
76
+
77
+ def test_content_is_order_stable_but_column_sensitive():
78
+ assert row_content({"a": 1, "b": 2}, ["a", "b"]) == row_content({"b": 2, "a": 1}, ["a", "b"])
79
+ assert row_content({"a": 1, "b": 2}, ["a", "b"]) != row_content({"a": 1, "b": 2}, ["b", "a"])
80
+
81
+
82
+ # --- identity ---------------------------------------------------------------
83
+
84
+ def test_identity_ignores_non_key_columns():
85
+ keys = ["id"]
86
+ assert row_identity({"id": "7", "v": "a"}, keys) == row_identity({"id": "7", "v": "b"}, keys)
87
+
88
+
89
+ def test_identity_is_none_without_keys():
90
+ """The Oxen tie-break: no keys means NO identity, not identity==content.
91
+
92
+ Collapsing the two would make every edit look like an unrelated add+remove
93
+ while still claiming to be a keyed diff.
94
+ """
95
+ assert row_identity({"a": 1}, []) is None
96
+
97
+
98
+ # --- the diff ---------------------------------------------------------------
99
+
100
+ def test_reordering_rows_is_not_a_change():
101
+ """The whole point. A line diff calls this a total rewrite."""
102
+ old = _t(["id", "v"], [{"id": "1", "v": "a"}, {"id": "2", "v": "b"}])
103
+ new = _t(["id", "v"], [{"id": "2", "v": "b"}, {"id": "1", "v": "a"}])
104
+ d = diff_tables(old, new, ["id"])
105
+ assert not d.changed
106
+ assert d.unchanged == 2
107
+
108
+
109
+ def test_modified_row_is_modified_not_add_plus_remove():
110
+ old = _t(["id", "v"], [{"id": "1", "v": "a"}])
111
+ new = _t(["id", "v"], [{"id": "1", "v": "b"}])
112
+ d = diff_tables(old, new, ["id"])
113
+ assert len(d.modified) == 1 and not d.added and not d.removed
114
+ before, after = d.modified[0]
115
+ assert before["v"] == "a" and after["v"] == "b"
116
+
117
+
118
+ def test_add_and_remove_are_detected():
119
+ old = _t(["id"], [{"id": "1"}, {"id": "2"}])
120
+ new = _t(["id"], [{"id": "2"}, {"id": "3"}])
121
+ d = diff_tables(old, new, ["id"])
122
+ assert [r["id"] for r in d.added] == ["3"]
123
+ assert [r["id"] for r in d.removed] == ["1"]
124
+ assert d.unchanged == 1
125
+
126
+
127
+ def test_column_change_is_reported_separately_from_rows():
128
+ """Adding a column must not make every row read as MODIFIED-for-no-reason...
129
+
130
+ ...but it IS a change to the rows that gain a value, so it shows in both
131
+ places: the schema delta names the column, and the affected rows appear as
132
+ modified. Silence in either place would be the bug.
133
+ """
134
+ old = _t(["id"], [{"id": "1"}])
135
+ new = _t(["id", "extra"], [{"id": "1", "extra": "x"}])
136
+ d = diff_tables(old, new, ["id"])
137
+ assert d.columns_added == ["extra"] and d.columns_removed == []
138
+ assert len(d.modified) == 1
139
+
140
+
141
+ def test_keyless_diff_is_honest_about_what_it_cannot_see():
142
+ """With no keys: content set diff, and `modified` stays EMPTY by design."""
143
+ old = _t(["v"], [{"v": "a"}])
144
+ new = _t(["v"], [{"v": "b"}])
145
+ d = diff_tables(old, new, [])
146
+ assert d.keyless is True
147
+ assert len(d.added) == 1 and len(d.removed) == 1
148
+ assert d.modified == []
149
+
150
+
151
+ def test_key_column_absent_from_both_sides_raises():
152
+ """Rather than reporting every row added+removed, which looks like data loss."""
153
+ old = _t(["a"], [{"a": "1"}])
154
+ new = _t(["a"], [{"a": "2"}])
155
+ with pytest.raises(UnreadableTableError):
156
+ diff_tables(old, new, ["nope"])
157
+
158
+
159
+ # --- file level -------------------------------------------------------------
160
+
161
+ def test_diff_files_roundtrip(tmp_path: Path):
162
+ old = _csv(tmp_path, "old.csv", ["id", "v"], [{"id": "1", "v": "a"},
163
+ {"id": "2", "v": "b"}])
164
+ new = _csv(tmp_path, "new.csv", ["id", "v"], [{"id": "2", "v": "B"},
165
+ {"id": "3", "v": "c"}])
166
+ d = diff_files(old, new, ["id"])
167
+ s = d.summary()
168
+ # id=1 gone -> removed; id=2 value changed -> modified; id=3 new -> added.
169
+ assert (s["added"], s["removed"], s["modified"], s["unchanged"]) == (1, 1, 1, 0)
170
+ assert d.added[0]["id"] == "3"
171
+ assert d.removed[0]["id"] == "1"
172
+ before, after = d.modified[0]
173
+ assert before["v"] == "b" and after["v"] == "B"
174
+
175
+
176
+ def test_headerless_file_raises_rather_than_reporting_zero_rows(tmp_path: Path):
177
+ p = tmp_path / "empty.csv"
178
+ p.write_text("", encoding="utf-8")
179
+ with pytest.raises(UnreadableTableError):
180
+ diff_files(p, p, ["id"])
181
+
182
+
183
+ def test_missing_file_raises(tmp_path: Path):
184
+ with pytest.raises(UnreadableTableError):
185
+ diff_files(tmp_path / "nope.csv", tmp_path / "nope.csv", [])
186
+
187
+
188
+ def test_tracking_type_split():
189
+ assert is_tabular("a/b/data.csv") and is_tabular("x.PARQUET") and is_tabular("t.tsv")
190
+ assert not is_tabular("module.py") and not is_tabular("photo.png")
191
+
192
+
193
+ # --- the guards that prove the guards ---------------------------------------
194
+ #
195
+ # Every assertion above is only worth what it CATCHES. Two of them were vacuous
196
+ # when first written — they passed with the code deliberately broken — and that
197
+ # was found by mutating the module by hand, once, in a throwaway script. A guard
198
+ # that ran once is not a guard, so the mutants live here and run on every suite.
199
+ #
200
+ # This is the same shape as dev/tests/test_event_loop_offload_contract.py, whose
201
+ # every claim carries a mutation reproducing the old defect.
202
+
203
+ _MUTANTS = {
204
+ # The encoding property. Reverting to Oxen's delimiter-free concatenation
205
+ # must break the injectivity test.
206
+ "naive concat (no length prefix)": (
207
+ 'return b"%d:%s=%d:%s;" % (len(n), n, len(v), v)',
208
+ "return n + v",
209
+ ),
210
+ # The NULL sentinel. Without it None falls through to str(None) == "None".
211
+ "null falls through to str()": (
212
+ "if value is None:",
213
+ "if False:",
214
+ ),
215
+ # Identity must read the KEY columns, not the whole row.
216
+ "identity ignores the key list": (
217
+ "return _digest((k, row.get(k)) for k in keys)",
218
+ "return _digest((c, row.get(c)) for c in sorted(row))",
219
+ ),
220
+ # No keys must mean NO identity, not a constant one.
221
+ "keyless fabricates an identity": (
222
+ "if not keys:\n return None",
223
+ "if not keys:\n return _digest([])",
224
+ ),
225
+ # A changed row must not be counted as unchanged.
226
+ "modified counted as unchanged": (
227
+ "out.modified.append((old_row, new_row))",
228
+ "out.unchanged += 1",
229
+ ),
230
+ }
231
+
232
+
233
+ @pytest.mark.parametrize("label", sorted(_MUTANTS))
234
+ def test_suite_catches_mutant(label: str, tmp_path: Path):
235
+ """Break the module one way; the rest of this file must go red.
236
+
237
+ Runs the suite in a SUBPROCESS against a mutated copy of the package, so the
238
+ already-imported module in this process is untouched and the mutation cannot
239
+ leak into another test.
240
+ """
241
+ import shutil
242
+ import subprocess
243
+ import sys
244
+
245
+ import awgit.tabular as mod
246
+
247
+ old, new = _MUTANTS[label]
248
+ original = Path(mod.__file__).read_text(encoding="utf-8")
249
+ assert old in original, (
250
+ f"mutation anchor for {label!r} no longer matches the source — the "
251
+ f"mutant silently became a no-op, which is how a mutation suite starts "
252
+ f"proving nothing"
253
+ )
254
+
255
+ pkg_root = Path(mod.__file__).resolve().parents[1]
256
+ sandbox = tmp_path / "sandbox"
257
+ shutil.copytree(pkg_root, sandbox / "awgit",
258
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
259
+ shutil.copy2(Path(__file__), sandbox / "test_tabular.py")
260
+ (sandbox / "awgit" / "tabular.py").write_text(
261
+ original.replace(old, new, 1), encoding="utf-8")
262
+
263
+ # -k excludes THIS test, or the subprocess would recurse into itself.
264
+ proc = subprocess.run(
265
+ [sys.executable, "-m", "pytest", "test_tabular.py", "-q",
266
+ "-k", "not catches_mutant", "-p", "no:cacheprovider"],
267
+ cwd=sandbox, capture_output=True, text=True,
268
+ encoding="utf-8", errors="replace", timeout=180,
269
+ )
270
+ assert proc.returncode != 0, (
271
+ f"MUTANT SURVIVED: {label}. The suite passed with the module broken this "
272
+ f"way, so whichever assertion covers it is vacuous.\n{proc.stdout[-1500:]}"
273
+ )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes