awgit 1.1.2__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.1.2/awgit.egg-info → awgit-1.3.0}/PKG-INFO +68 -37
- awgit-1.1.2/PKG-INFO → awgit-1.3.0/README.md +63 -49
- {awgit-1.1.2 → awgit-1.3.0}/awgit/cli.py +187 -5
- {awgit-1.1.2 → awgit-1.3.0}/awgit/guard.py +117 -0
- awgit-1.3.0/awgit/lease_requests.py +171 -0
- awgit-1.3.0/awgit/outcomes.py +250 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/schema.py +92 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/worktree.py +1 -0
- awgit-1.1.2/README.md → awgit-1.3.0/awgit.egg-info/PKG-INFO +80 -34
- {awgit-1.1.2 → awgit-1.3.0}/awgit.egg-info/SOURCES.txt +5 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit.egg-info/requires.txt +0 -3
- {awgit-1.1.2 → awgit-1.3.0}/pyproject.toml +11 -7
- awgit-1.3.0/tests/test_lease_release_accepts_paths.py +142 -0
- awgit-1.3.0/tests/test_lease_requests.py +156 -0
- awgit-1.3.0/tests/test_outcome_tracking.py +410 -0
- {awgit-1.1.2 → awgit-1.3.0}/LICENSE +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/__init__.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/absorb.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/bodies.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/bridge.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/capture.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/changeid.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/code.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/commands.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/data_root.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/diff.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/evidence.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/git.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/graph.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/hooks/chain.sh +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/hooks/post-commit.d/vcs-capture +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/hooks/pre-commit.d/vcs-mass-delete-guard +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/hooks/pre-push.d/ci-gate-parity +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/hooks/prepare-commit-msg.d/awgit-change-id +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/identity.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/lazy.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/leases.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/ledger.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/mcp.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/merge.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/nodeid.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/oplog.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/owners.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/parser.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/plugins.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/prove.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/push.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/repowise_parser.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/review.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/stack.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/staging.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/staging_selftest.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/sync.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit/tabular.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit.egg-info/dependency_links.txt +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit.egg-info/entry_points.txt +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/awgit.egg-info/top_level.txt +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/setup.cfg +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_absorb_routes_by_node.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_awgit_standalone.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_changeid_survives_history_rewrite.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_documented_commands_run.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_lazy_clone_is_verified.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_multilang_identity.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_owners_and_proof.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_push_is_the_pull_request.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_restack_repairs_orphans.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_review_threads_survive_moves.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_tabular.py +0 -0
- {awgit-1.1.2 → awgit-1.3.0}/tests/test_worktree_zombie_detection.py +0 -0
|
@@ -1,16 +1,18 @@
|
|
|
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
|
+
Project-URL: Homepage, https://github.com/Aitherium/awgit
|
|
7
|
+
Project-URL: Documentation, https://aitherium.github.io/awgit/
|
|
8
|
+
Project-URL: Repository, https://github.com/Aitherium/awgit.git
|
|
9
|
+
Project-URL: Issues, https://github.com/Aitherium/awgit/issues
|
|
6
10
|
Requires-Python: >=3.10
|
|
7
11
|
Description-Content-Type: text/markdown
|
|
8
12
|
License-File: LICENSE
|
|
9
13
|
Requires-Dist: httpx>=0.25.0
|
|
10
14
|
Provides-Extra: multilang
|
|
11
15
|
Requires-Dist: repowise>=0.31.0; extra == "multilang"
|
|
12
|
-
Provides-Extra: tabular
|
|
13
|
-
Requires-Dist: pyarrow>=15.0.0; extra == "tabular"
|
|
14
16
|
Dynamic: license-file
|
|
15
17
|
|
|
16
18
|
# awgit — the git that knows a function from a line
|
|
@@ -205,42 +207,8 @@ awgit ledger --sha <sha> # who changed what, under a verified identity
|
|
|
205
207
|
awgit evidence # the measurable claim, from your own op-log
|
|
206
208
|
awgit bodies --get <sha> # read a body from the content-addressed store
|
|
207
209
|
awgit dedupe --scan <trees> # quantify duplication; --reclaim to hard-link
|
|
208
|
-
awgit data diff <a> <b> --key id # ROW-level diff of CSV/TSV/parquet
|
|
209
210
|
```
|
|
210
211
|
|
|
211
|
-
### Data files get the same treatment as code
|
|
212
|
-
|
|
213
|
-
A line diff is useless on a table: sort it and every line "changed"; reorder two
|
|
214
|
-
rows and a review drowns. So a row gets the same pair a function gets — an
|
|
215
|
-
**identity** and a **content address**:
|
|
216
|
-
|
|
217
|
-
```
|
|
218
|
-
row identity = H(the --key columns) # which row is this?
|
|
219
|
-
row content = H(every column) # has it changed?
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
The diff is then set algebra on identity, so rows can be reordered freely and
|
|
223
|
-
nothing is reported:
|
|
224
|
-
|
|
225
|
-
```bash
|
|
226
|
-
awgit data diff old.csv new.csv --key id # 1 added, 1 removed, 1 modified
|
|
227
|
-
awgit data diff old.csv new.csv --key id --json # before/after per modified row
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
Without `--key` there is no identity, so it degrades to a content set-diff and
|
|
231
|
-
**says so** — every edit reads as an add plus a remove, and `modified` stays
|
|
232
|
-
empty rather than being guessed at. A key column that exists in neither table is
|
|
233
|
-
an error, not an empty result: silently keying on a missing column would report
|
|
234
|
-
every row as added *and* removed, which looks exactly like data loss.
|
|
235
|
-
|
|
236
|
-
CSV and TSV need nothing beyond the stdlib. Parquet needs the optional extra:
|
|
237
|
-
`pip install awgit[tabular]`.
|
|
238
|
-
|
|
239
|
-
*The two-hash row model is adapted from [Oxen](https://github.com/oxen-ai/Oxen)
|
|
240
|
-
(Apache-2.0). No Oxen code is vendored, and awgit's byte encoding deliberately
|
|
241
|
-
differs — it length-prefixes each field so that two different rows cannot share
|
|
242
|
-
a content address — so hashes are not comparable between the two tools.*
|
|
243
|
-
|
|
244
212
|
- **Merge** at node granularity: disjoint node sets merge clean by
|
|
245
213
|
construction, and a genuine collision escalates naming the exact function
|
|
246
214
|
(`awgit merge-preview`, `awgit merge-conflicts`, `awgit resolve-conflict`).
|
|
@@ -296,3 +264,66 @@ cd awgit && python -m pytest tests/ -q
|
|
|
296
264
|
## License
|
|
297
265
|
|
|
298
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 -->
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: awgit
|
|
3
|
-
Version: 1.1.2
|
|
4
|
-
Summary: Aither World-Graph git — semantic version control on top of git: edit-ops keyed on stable node ids, content-addressed bodies, differential sync.
|
|
5
|
-
License: Apache-2.0
|
|
6
|
-
Requires-Python: >=3.10
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Requires-Dist: httpx>=0.25.0
|
|
10
|
-
Provides-Extra: multilang
|
|
11
|
-
Requires-Dist: repowise>=0.31.0; extra == "multilang"
|
|
12
|
-
Provides-Extra: tabular
|
|
13
|
-
Requires-Dist: pyarrow>=15.0.0; extra == "tabular"
|
|
14
|
-
Dynamic: license-file
|
|
15
|
-
|
|
16
1
|
# awgit — the git that knows a function from a line
|
|
17
2
|
|
|
18
3
|
Git has no world model. It knows which lines moved, not what a function is — so
|
|
@@ -205,42 +190,8 @@ awgit ledger --sha <sha> # who changed what, under a verified identity
|
|
|
205
190
|
awgit evidence # the measurable claim, from your own op-log
|
|
206
191
|
awgit bodies --get <sha> # read a body from the content-addressed store
|
|
207
192
|
awgit dedupe --scan <trees> # quantify duplication; --reclaim to hard-link
|
|
208
|
-
awgit data diff <a> <b> --key id # ROW-level diff of CSV/TSV/parquet
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
### Data files get the same treatment as code
|
|
212
|
-
|
|
213
|
-
A line diff is useless on a table: sort it and every line "changed"; reorder two
|
|
214
|
-
rows and a review drowns. So a row gets the same pair a function gets — an
|
|
215
|
-
**identity** and a **content address**:
|
|
216
|
-
|
|
217
|
-
```
|
|
218
|
-
row identity = H(the --key columns) # which row is this?
|
|
219
|
-
row content = H(every column) # has it changed?
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
The diff is then set algebra on identity, so rows can be reordered freely and
|
|
223
|
-
nothing is reported:
|
|
224
|
-
|
|
225
|
-
```bash
|
|
226
|
-
awgit data diff old.csv new.csv --key id # 1 added, 1 removed, 1 modified
|
|
227
|
-
awgit data diff old.csv new.csv --key id --json # before/after per modified row
|
|
228
193
|
```
|
|
229
194
|
|
|
230
|
-
Without `--key` there is no identity, so it degrades to a content set-diff and
|
|
231
|
-
**says so** — every edit reads as an add plus a remove, and `modified` stays
|
|
232
|
-
empty rather than being guessed at. A key column that exists in neither table is
|
|
233
|
-
an error, not an empty result: silently keying on a missing column would report
|
|
234
|
-
every row as added *and* removed, which looks exactly like data loss.
|
|
235
|
-
|
|
236
|
-
CSV and TSV need nothing beyond the stdlib. Parquet needs the optional extra:
|
|
237
|
-
`pip install awgit[tabular]`.
|
|
238
|
-
|
|
239
|
-
*The two-hash row model is adapted from [Oxen](https://github.com/oxen-ai/Oxen)
|
|
240
|
-
(Apache-2.0). No Oxen code is vendored, and awgit's byte encoding deliberately
|
|
241
|
-
differs — it length-prefixes each field so that two different rows cannot share
|
|
242
|
-
a content address — so hashes are not comparable between the two tools.*
|
|
243
|
-
|
|
244
195
|
- **Merge** at node granularity: disjoint node sets merge clean by
|
|
245
196
|
construction, and a genuine collision escalates naming the exact function
|
|
246
197
|
(`awgit merge-preview`, `awgit merge-conflicts`, `awgit resolve-conflict`).
|
|
@@ -296,3 +247,66 @@ cd awgit && python -m pytest tests/ -q
|
|
|
296
247
|
## License
|
|
297
248
|
|
|
298
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 -->
|
|
@@ -91,6 +91,19 @@ def _cmd_capture(args: argparse.Namespace) -> int:
|
|
|
91
91
|
print(f"vcs: no semantic changes for {args.sha}")
|
|
92
92
|
return 0
|
|
93
93
|
print(f"vcs: op {op.op_id} recorded ({op.summary})")
|
|
94
|
+
|
|
95
|
+
# Record proof of verification if requested
|
|
96
|
+
if getattr(args, "prove", False):
|
|
97
|
+
try:
|
|
98
|
+
from awgit.outcomes import record_outcome
|
|
99
|
+
from awgit.prove import run_gates
|
|
100
|
+
data_root = Path(args.data_root) if args.data_root else None
|
|
101
|
+
gates = run_gates(op.file_paths)
|
|
102
|
+
outcome = record_outcome(args.sha, gates, data_root=data_root)
|
|
103
|
+
print(f"vcs: outcome {outcome.outcome_id} recorded ({outcome.verdict})")
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
print(f"vcs: proof recording failed (non-fatal): {exc}", file=sys.stderr)
|
|
106
|
+
|
|
94
107
|
return 0
|
|
95
108
|
|
|
96
109
|
|
|
@@ -232,10 +245,45 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
|
|
232
245
|
return 0
|
|
233
246
|
|
|
234
247
|
|
|
248
|
+
def _resolve_lease_args(registry, who, raw):
|
|
249
|
+
"""Map release/heartbeat arguments to lease ids, accepting leased PATHS too.
|
|
250
|
+
|
|
251
|
+
`acquire` takes paths and `release` takes ids, so passing the same string to
|
|
252
|
+
both is the natural mistake -- and it used to be a silent one, because an
|
|
253
|
+
unmatched id simply released nothing and still printed success.
|
|
254
|
+
|
|
255
|
+
Returns (ids, unresolved). Anything that is neither one of this actor's
|
|
256
|
+
active lease ids nor one of its leased targets comes back in `unresolved`,
|
|
257
|
+
so the caller can fail rather than report a count of zero.
|
|
258
|
+
"""
|
|
259
|
+
mine = [lz for lz in registry.active_leases() if lz.actor == who]
|
|
260
|
+
by_id = {lz.lease_id: lz.lease_id for lz in mine}
|
|
261
|
+
by_target = {}
|
|
262
|
+
for lz in mine:
|
|
263
|
+
# Last writer wins is fine: releasing any lease on that path is the
|
|
264
|
+
# intent, and a duplicate target for one actor is already a bug.
|
|
265
|
+
by_target[str(lz.target).replace("\\", "/").strip("/")] = lz.lease_id
|
|
266
|
+
ids, unresolved = [], []
|
|
267
|
+
for arg in raw or []:
|
|
268
|
+
if arg in by_id:
|
|
269
|
+
ids.append(arg)
|
|
270
|
+
continue
|
|
271
|
+
key = str(arg).replace("\\", "/").strip("/")
|
|
272
|
+
if key in by_target:
|
|
273
|
+
ids.append(by_target[key])
|
|
274
|
+
continue
|
|
275
|
+
unresolved.append(arg)
|
|
276
|
+
return ids, unresolved
|
|
277
|
+
|
|
278
|
+
|
|
235
279
|
def _cmd_lease(args: argparse.Namespace) -> int:
|
|
236
280
|
registry = LeaseRegistry()
|
|
237
281
|
cmd = args.lease_cmd
|
|
238
282
|
who = _actor(args)
|
|
283
|
+
if cmd == "contact":
|
|
284
|
+
return _cmd_lease_contact(args)
|
|
285
|
+
if cmd == "requests":
|
|
286
|
+
return _cmd_lease_requests(args)
|
|
239
287
|
if cmd == "acquire":
|
|
240
288
|
targets = list(args.targets or [])
|
|
241
289
|
if getattr(args, "staged", False):
|
|
@@ -290,7 +338,19 @@ def _cmd_lease(args: argparse.Namespace) -> int:
|
|
|
290
338
|
who, targets, ttl_sec=args.ttl, reason=args.reason
|
|
291
339
|
)
|
|
292
340
|
except LeaseConflictError as exc:
|
|
341
|
+
# Flush stdout FIRST. It is block-buffered when redirected to a file
|
|
342
|
+
# or a pipe and stderr is not, so this line otherwise lands
|
|
343
|
+
# INTERLEAVED in the middle of whatever stdout had buffered rather
|
|
344
|
+
# than at the end where anyone looks. Measured 2026-08-19:
|
|
345
|
+
# `lease acquire --staged --adopt` over 1776 files refused correctly
|
|
346
|
+
# on a real conflict, and the one line saying why was glued onto the
|
|
347
|
+
# middle of the adoption list at line 1657 of 1778 -- head and tail
|
|
348
|
+
# both missed it, and a correct refusal got reported as "awgit exits
|
|
349
|
+
# 1 and persists nothing". A diagnostic nobody can find is worse than
|
|
350
|
+
# none: it gets diagnosed as a different bug.
|
|
351
|
+
sys.stdout.flush()
|
|
293
352
|
print(f"vcs: {exc}", file=sys.stderr)
|
|
353
|
+
sys.stderr.flush()
|
|
294
354
|
return 1
|
|
295
355
|
# A lease over an ALREADY-DIRTY file captures a baseline that contains work
|
|
296
356
|
# which is not yours, and `stage-mine` computes (baseline -> worktree), so it
|
|
@@ -316,11 +376,28 @@ def _cmd_lease(args: argparse.Namespace) -> int:
|
|
|
316
376
|
"`git diff --stat -- <path>` must match the size of YOUR edit.",
|
|
317
377
|
file=sys.stderr)
|
|
318
378
|
return 0
|
|
319
|
-
if cmd
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
379
|
+
if cmd in ("heartbeat", "release"):
|
|
380
|
+
# `release`/`heartbeat` take lease IDS. Passing a PATH -- the same string
|
|
381
|
+
# `acquire` takes, and the obvious guess -- matched no id, so the registry
|
|
382
|
+
# returned 0 and this printed "released 0 leases" and exited 0 while the
|
|
383
|
+
# lease sat there in `lease list`. A command that reports success for
|
|
384
|
+
# having done nothing is the silent-no-op class in
|
|
385
|
+
# .claude/rules/security-review-patterns.md #5, and it cost a session on
|
|
386
|
+
# 2026-08-16: the release "succeeded", the lease stayed held, and the next
|
|
387
|
+
# edit was blocked by the caller's own lease.
|
|
388
|
+
# Resolve path-shaped arguments against this actor's active leases, and
|
|
389
|
+
# refuse anything that resolves to nothing rather than reporting 0.
|
|
390
|
+
ids, unresolved = _resolve_lease_args(registry, who, args.ids)
|
|
391
|
+
if unresolved:
|
|
392
|
+
print("vcs: no active lease of yours matches: " + ", ".join(unresolved),
|
|
393
|
+
file=sys.stderr)
|
|
394
|
+
print("vcs: pass a lease id or a leased path (`awgit lease list`)",
|
|
395
|
+
file=sys.stderr)
|
|
396
|
+
return 1
|
|
397
|
+
if cmd == "heartbeat":
|
|
398
|
+
print(f"vcs: heartbeat refreshed {registry.heartbeat(who, ids)} leases")
|
|
399
|
+
else:
|
|
400
|
+
print(f"vcs: released {registry.release(who, ids)} leases")
|
|
324
401
|
return 0
|
|
325
402
|
if cmd == "list":
|
|
326
403
|
for lz in sorted(registry.active_leases(), key=lambda x: x.target):
|
|
@@ -561,6 +638,95 @@ def _merge_hand_resolved(repo: Path, staged: List[str]) -> List[str]:
|
|
|
561
638
|
return hand
|
|
562
639
|
|
|
563
640
|
|
|
641
|
+
def _requests_store():
|
|
642
|
+
"""(LeaseRequests, registry) or (None, None) if the plane is unreadable."""
|
|
643
|
+
try:
|
|
644
|
+
from .lease_requests import LeaseRequests
|
|
645
|
+
from .leases import LeaseRegistry, vcs_data_root
|
|
646
|
+
reg = LeaseRegistry()
|
|
647
|
+
return LeaseRequests(vcs_data_root()), reg
|
|
648
|
+
except Exception as exc: # noqa: BLE001 - never fatal
|
|
649
|
+
print(f"vcs: lease-request store unavailable ({exc})", file=sys.stderr)
|
|
650
|
+
return None, None
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _relay_notify(to_actor: str, target: str, message: str) -> bool:
|
|
654
|
+
"""Best-effort async ping. NEVER the only delivery path.
|
|
655
|
+
|
|
656
|
+
Returns False when it could not send, so the caller can say so rather than
|
|
657
|
+
implying the holder was reached. Relay is optional here on purpose: if the
|
|
658
|
+
only notification depended on a running service, this feature would be
|
|
659
|
+
unavailable in exactly the degraded conditions that produce lease pile-ups.
|
|
660
|
+
"""
|
|
661
|
+
try:
|
|
662
|
+
from awrelay.client import RelayClient # type: ignore
|
|
663
|
+
except ImportError:
|
|
664
|
+
return False
|
|
665
|
+
try:
|
|
666
|
+
who = to_actor.split(":")[-1][:8]
|
|
667
|
+
RelayClient().send(
|
|
668
|
+
channel="lease-negotiation",
|
|
669
|
+
text=f"@{who} please release `{target}` — {message or 'another '
|
|
670
|
+
'session is blocked on it'}")
|
|
671
|
+
return True
|
|
672
|
+
except Exception: # noqa: BLE001
|
|
673
|
+
return False
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _cmd_lease_contact(args) -> int:
|
|
677
|
+
"""Ask whoever holds a path's lease to let it go."""
|
|
678
|
+
store, reg = _requests_store()
|
|
679
|
+
if store is None or reg is None:
|
|
680
|
+
return 2
|
|
681
|
+
me = _actor(args)
|
|
682
|
+
target = args.path
|
|
683
|
+
|
|
684
|
+
holder = None
|
|
685
|
+
for lz in reg.active_leases():
|
|
686
|
+
if lz.target == target:
|
|
687
|
+
holder = lz
|
|
688
|
+
break
|
|
689
|
+
if holder is None:
|
|
690
|
+
print(f"vcs: no active lease on {target!r} — nothing to ask for. "
|
|
691
|
+
f"If a commit was refused, re-run `awgit lease acquire`.")
|
|
692
|
+
return 0
|
|
693
|
+
if holder.actor == me:
|
|
694
|
+
print(f"vcs: {target!r} is held by YOU ({me}); "
|
|
695
|
+
f"`awgit lease release {holder.lease_id}` frees it.")
|
|
696
|
+
return 0
|
|
697
|
+
|
|
698
|
+
store.add(target, holder.actor, me, getattr(args, "message", "") or "")
|
|
699
|
+
relayed = _relay_notify(holder.actor, target,
|
|
700
|
+
getattr(args, "message", "") or "")
|
|
701
|
+
print(f"vcs: asked {holder.actor} to release {target!r} "
|
|
702
|
+
f"(expires {holder.expires_ts}).")
|
|
703
|
+
print("vcs: they see it on their next `awgit lease list`"
|
|
704
|
+
+ (" and on relay #lease-negotiation." if relayed
|
|
705
|
+
else " (relay unavailable — the awgit path still delivers)."))
|
|
706
|
+
return 0
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _cmd_lease_requests(args) -> int:
|
|
710
|
+
"""What am I blocking, and what am I waiting on?"""
|
|
711
|
+
store, _ = _requests_store()
|
|
712
|
+
if store is None:
|
|
713
|
+
return 2
|
|
714
|
+
me = _actor(args)
|
|
715
|
+
from .lease_requests import format_pending
|
|
716
|
+
|
|
717
|
+
incoming = store.for_actor(me)
|
|
718
|
+
outgoing = store.by_actor(me)
|
|
719
|
+
if incoming:
|
|
720
|
+
print(format_pending(incoming))
|
|
721
|
+
else:
|
|
722
|
+
print("vcs: nobody is blocked on leases you hold.")
|
|
723
|
+
if outgoing:
|
|
724
|
+
print(f"vcs: you have asked for {len(outgoing)} lease(s):")
|
|
725
|
+
for r in outgoing[:10]:
|
|
726
|
+
print(f"vcs: {r.get('target')} <- {str(r.get('to_actor'))[:24]}")
|
|
727
|
+
return 0
|
|
728
|
+
|
|
729
|
+
|
|
564
730
|
def _cmd_lease_check(args: argparse.Namespace) -> int:
|
|
565
731
|
if os.environ.get("VCS_LEASES_ENFORCE", "0") != "1":
|
|
566
732
|
print("vcs: lease-check not enforced (VCS_LEASES_ENFORCE=0)")
|
|
@@ -1477,6 +1643,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1477
1643
|
default=None,
|
|
1478
1644
|
help="vcs store directory (default: ~/.aither/awgit/data, or $VCS_DATA_ROOT)",
|
|
1479
1645
|
)
|
|
1646
|
+
p_capture.add_argument(
|
|
1647
|
+
"--prove",
|
|
1648
|
+
action="store_true",
|
|
1649
|
+
help="run gates and record the outcome after capture",
|
|
1650
|
+
)
|
|
1480
1651
|
|
|
1481
1652
|
p_diff = sub.add_parser("diff", help="node-level diff between two shas")
|
|
1482
1653
|
p_diff.add_argument("a", help="base sha")
|
|
@@ -1555,6 +1726,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1555
1726
|
p_ll = lsub.add_parser("list", help="list active leases")
|
|
1556
1727
|
p_ll.add_argument("--json", action="store_true", dest="as_json")
|
|
1557
1728
|
lsub.add_parser("sweep", help="sweep expired leases")
|
|
1729
|
+
p_lc = lsub.add_parser(
|
|
1730
|
+
"contact",
|
|
1731
|
+
help="ask whoever holds a path's lease to release it -- the missing "
|
|
1732
|
+
"half of 'talk to them or wait'")
|
|
1733
|
+
p_lc.add_argument("path")
|
|
1734
|
+
p_lc.add_argument("-m", "--message", default="", help="why you need it")
|
|
1735
|
+
p_lc.add_argument("--actor", default=None)
|
|
1736
|
+
p_lq = lsub.add_parser(
|
|
1737
|
+
"requests",
|
|
1738
|
+
help="who is blocked on YOUR leases, and what you are waiting on")
|
|
1739
|
+
p_lq.add_argument("--actor", default=None)
|
|
1558
1740
|
|
|
1559
1741
|
p_lc = sub.add_parser("lease-check", help="pre-commit lease gate")
|
|
1560
1742
|
p_lc.add_argument("--actor", default=None)
|
|
@@ -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
|
|