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.
- {awgit-1.2.0 → awgit-1.3.0}/PKG-INFO +64 -1
- {awgit-1.2.0 → awgit-1.3.0}/README.md +63 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/cli.py +94 -73
- {awgit-1.2.0 → awgit-1.3.0}/awgit/evidence.py +0 -20
- {awgit-1.2.0 → awgit-1.3.0}/awgit/guard.py +117 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/plugins.py +0 -18
- {awgit-1.2.0 → awgit-1.3.0}/awgit/worktree.py +1 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/PKG-INFO +64 -1
- {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/SOURCES.txt +1 -0
- {awgit-1.2.0 → awgit-1.3.0}/pyproject.toml +1 -1
- awgit-1.3.0/tests/test_tabular.py +273 -0
- {awgit-1.2.0 → awgit-1.3.0}/LICENSE +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/__init__.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/absorb.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/bodies.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/bridge.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/capture.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/changeid.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/code.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/commands.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/data_root.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/diff.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/git.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/graph.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/chain.sh +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/post-commit.d/vcs-capture +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/pre-commit.d/vcs-mass-delete-guard +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/pre-push.d/ci-gate-parity +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/hooks/prepare-commit-msg.d/awgit-change-id +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/identity.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/lazy.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/lease_requests.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/leases.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/ledger.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/mcp.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/merge.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/nodeid.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/oplog.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/outcomes.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/owners.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/parser.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/prove.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/push.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/repowise_parser.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/review.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/schema.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/stack.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/staging.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/staging_selftest.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/sync.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit/tabular.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/dependency_links.txt +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/entry_points.txt +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/requires.txt +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/awgit.egg-info/top_level.txt +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/setup.cfg +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_absorb_routes_by_node.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_awgit_standalone.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_changeid_survives_history_rewrite.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_documented_commands_run.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_lazy_clone_is_verified.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_lease_release_accepts_paths.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_lease_requests.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_multilang_identity.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_outcome_tracking.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_owners_and_proof.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_push_is_the_pull_request.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_restack_repairs_orphans.py +0 -0
- {awgit-1.2.0 → awgit-1.3.0}/tests/test_review_threads_survive_moves.py +0 -0
- {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.
|
|
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
|
|
578
|
-
"""During a
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
613
|
+
Returns the staged paths when this is not a merge, so the normal path is
|
|
614
|
+
unchanged.
|
|
596
615
|
"""
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
623
|
+
def _differs(ref: str) -> set:
|
|
608
624
|
out = subprocess.run(
|
|
609
|
-
["git", "
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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(
|
|
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.
|
|
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 -->
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "awgit"
|
|
7
|
-
version = "1.
|
|
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
|
|
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
|