awgit 0.1.0__tar.gz → 0.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-0.1.0 → awgit-0.3.0}/PKG-INFO +4 -2
- {awgit-0.1.0 → awgit-0.3.0}/README.md +1 -1
- {awgit-0.1.0 → awgit-0.3.0}/awgit/capture.py +42 -1
- {awgit-0.1.0 → awgit-0.3.0}/awgit/cli.py +85 -7
- awgit-0.3.0/awgit/graph.py +133 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/leases.py +49 -5
- awgit-0.3.0/awgit/repowise_parser.py +107 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/PKG-INFO +4 -2
- {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/SOURCES.txt +2 -0
- awgit-0.3.0/awgit.egg-info/requires.txt +4 -0
- {awgit-0.1.0 → awgit-0.3.0}/pyproject.toml +9 -1
- awgit-0.1.0/awgit.egg-info/requires.txt +0 -1
- {awgit-0.1.0 → awgit-0.3.0}/LICENSE +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/__init__.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/bodies.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/bridge.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/data_root.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/diff.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/hooks/chain.sh +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/hooks/post-commit.d/vcs-capture +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/identity.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/ledger.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/mcp.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/merge.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/nodeid.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/oplog.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/parser.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/schema.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit/sync.py +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/dependency_links.txt +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/entry_points.txt +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/top_level.txt +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/setup.cfg +0 -0
- {awgit-0.1.0 → awgit-0.3.0}/tests/test_awgit_standalone.py +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: awgit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
Requires-Python: >=3.10
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Requires-Dist: httpx>=0.25.0
|
|
10
|
+
Provides-Extra: multilang
|
|
11
|
+
Requires-Dist: repowise>=0.31.0; extra == "multilang"
|
|
10
12
|
Dynamic: license-file
|
|
11
13
|
|
|
12
14
|
# awgit — Aither World-Graph git
|
|
@@ -52,7 +54,7 @@ what changed, never the snapshot.
|
|
|
52
54
|
## Install
|
|
53
55
|
|
|
54
56
|
```bash
|
|
55
|
-
pip install awgit
|
|
57
|
+
pip install awgit
|
|
56
58
|
# or from source:
|
|
57
59
|
git clone https://github.com/aitherium/awgit && cd awgit
|
|
58
60
|
pip install -e .
|
|
@@ -128,10 +128,23 @@ def resolve_actor(
|
|
|
128
128
|
"""
|
|
129
129
|
github = _github_identity(data_root)
|
|
130
130
|
env_actor = os.environ.get("AITHER_ACTOR")
|
|
131
|
+
# The SESSION, before the GitHub login. Every agent on this box shares one
|
|
132
|
+
# GitHub identity, so resolving to `github` collapsed every session into one
|
|
133
|
+
# actor: measured 2026-08-09, an op-log of 188 ops across 189 files had
|
|
134
|
+
# exactly ONE actor, which makes "two actors touched this node" impossible to
|
|
135
|
+
# express and the collision view structurally blind. The schema already
|
|
136
|
+
# separates CLAIMED from VERIFIED — `verified_actor` still records the
|
|
137
|
+
# GitHub identity below — so the claimed actor is free to be the session
|
|
138
|
+
# that actually made the edit, which is what attribution is for. Matches
|
|
139
|
+
# the lease gate's `_actor()`, and the two disagreeing is what made leases
|
|
140
|
+
# discriminate per session while captures did not.
|
|
141
|
+
session = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
131
142
|
if actor_arg:
|
|
132
143
|
actor, source = actor_arg, "arg"
|
|
133
144
|
elif env_actor:
|
|
134
145
|
actor, source = env_actor, "env"
|
|
146
|
+
elif session:
|
|
147
|
+
actor, source = f"claude:{session}", "session"
|
|
135
148
|
elif github:
|
|
136
149
|
actor, source = github, "github"
|
|
137
150
|
else:
|
|
@@ -154,6 +167,29 @@ def _ancestor_shas(repo: Path, sha: str) -> List[str]:
|
|
|
154
167
|
def _node_records(src: Optional[bytes], rel_path: str) -> List[Dict[str, Any]]:
|
|
155
168
|
if src is None:
|
|
156
169
|
return []
|
|
170
|
+
if not rel_path.endswith(".py"):
|
|
171
|
+
# NON-PYTHON: identity comes from repowise, which parses 75 extensions
|
|
172
|
+
# across 20+ languages and emits ids of the form `path::qualified_name`
|
|
173
|
+
# — verified stable across a symbol MOVING and its body changing, which
|
|
174
|
+
# is the property node-level merge rests on. Without this every
|
|
175
|
+
# .ts/.tsx/.go/.cs file was invisible to awgit: of the 20,451 files
|
|
176
|
+
# repowise has indexed in this repo, 12,600+ are not Python.
|
|
177
|
+
from awgit.repowise_parser import parse_symbols
|
|
178
|
+
|
|
179
|
+
text = src.decode("utf-8", errors="ignore").split("\n")
|
|
180
|
+
out: List[Dict[str, Any]] = []
|
|
181
|
+
for sym in parse_symbols(src, rel_path):
|
|
182
|
+
start, end = sym.get("start_line") or 0, sym.get("end_line") or 0
|
|
183
|
+
body = "\n".join(text[start - 1:end]) if start and end else ""
|
|
184
|
+
out.append({
|
|
185
|
+
"name": sym["symbol"] or sym["node_id"],
|
|
186
|
+
"path": rel_path,
|
|
187
|
+
"type": sym.get("kind") or "symbol",
|
|
188
|
+
"signature": "",
|
|
189
|
+
"body": body,
|
|
190
|
+
})
|
|
191
|
+
return out
|
|
192
|
+
|
|
157
193
|
# lazy: CodeGraph's import chain is ~15s (AitherConfig auto-tune etc.); only
|
|
158
194
|
# pay it when actually parsing (capture/diff/merge runtime), never for
|
|
159
195
|
# `vcs lease`/`status` which import this module but never parse.
|
|
@@ -401,8 +437,13 @@ def capture_ops(
|
|
|
401
437
|
manager = StableNodeIDManager(path=data / "nodes.json", persist=True)
|
|
402
438
|
store = BodyStore(data_root=data)
|
|
403
439
|
node_changes: List[NodeChange] = []
|
|
440
|
+
from awgit.repowise_parser import language_for
|
|
441
|
+
|
|
404
442
|
for rel in files:
|
|
405
|
-
|
|
443
|
+
# Python natively; anything else only when repowise can parse it,
|
|
444
|
+
# so a file type nobody can give node identity to is skipped rather
|
|
445
|
+
# than recorded as an empty change.
|
|
446
|
+
if not rel.endswith(".py") and not language_for(rel):
|
|
406
447
|
continue
|
|
407
448
|
old_src = git_blob(repo, parent, rel)
|
|
408
449
|
new_src = git_blob(repo, git_sha, rel)
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import argparse
|
|
17
17
|
import os
|
|
18
|
+
import platform
|
|
18
19
|
import subprocess
|
|
19
20
|
import sys
|
|
20
21
|
from pathlib import Path
|
|
@@ -28,11 +29,38 @@ from awgit.oplog import OpLog
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def _actor(args: argparse.Namespace) -> str:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
"""Who is committing. MUST differ per concurrent agent, or leases are useless.
|
|
33
|
+
|
|
34
|
+
Explicit forms win, then one is DERIVED. Deriving is the point: with only
|
|
35
|
+
``AITHER_ACTOR`` this returned "unknown", and ``lease-check`` rejects an
|
|
36
|
+
unknown actor — so turning enforcement on would have blocked every commit on
|
|
37
|
+
the box until every session remembered to export a variable. A guard that can
|
|
38
|
+
only be switched on by breaking the machine never gets switched on, and it
|
|
39
|
+
never was: measured 2026-08-09, ``awgit lease list`` had never returned a
|
|
40
|
+
single lease while a concurrent session overwrote one file four times, the
|
|
41
|
+
last overwrite silently reverting a fix already committed to origin.
|
|
42
|
+
|
|
43
|
+
``CLAUDE_CODE_SESSION_ID`` is the right derivation: the harness sets it per
|
|
44
|
+
session, it is stable for that session's life, and it is inherited by the git
|
|
45
|
+
hook's subprocess (verified). A per-PID value would NOT do — every ``awgit``
|
|
46
|
+
invocation is a new process, so a lease taken by one command would not match
|
|
47
|
+
the next.
|
|
48
|
+
|
|
49
|
+
``user@host`` is the last resort, for humans and other tooling. It is
|
|
50
|
+
deliberately not what agents get: every session here shares one OS account
|
|
51
|
+
and one GitHub identity, so a shared actor makes session A's lease cover
|
|
52
|
+
session B's commit — all of the friction, none of the protection.
|
|
53
|
+
"""
|
|
54
|
+
explicit = getattr(args, "actor", None) or os.environ.get("AITHER_ACTOR")
|
|
55
|
+
if explicit:
|
|
56
|
+
return explicit
|
|
57
|
+
session = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
58
|
+
if session:
|
|
59
|
+
return f"claude:{session}"
|
|
60
|
+
user = os.environ.get("USERNAME") or os.environ.get("USER")
|
|
61
|
+
if user:
|
|
62
|
+
return f"{user}@{platform.node() or 'localhost'}"
|
|
63
|
+
return "unknown"
|
|
36
64
|
|
|
37
65
|
|
|
38
66
|
def _cmd_capture(args: argparse.Namespace) -> int:
|
|
@@ -119,9 +147,26 @@ def _cmd_lease(args: argparse.Namespace) -> int:
|
|
|
119
147
|
cmd = args.lease_cmd
|
|
120
148
|
who = _actor(args)
|
|
121
149
|
if cmd == "acquire":
|
|
150
|
+
targets = list(args.targets or [])
|
|
151
|
+
if getattr(args, "staged", False):
|
|
152
|
+
# Lease exactly what the gate will check. Without this, widening
|
|
153
|
+
# coverage beyond .py (D-1880) would have made a routine commit a
|
|
154
|
+
# per-file chore, and a gate that heavy gets routed around instead
|
|
155
|
+
# of satisfied.
|
|
156
|
+
from awgit.leases import is_guarded
|
|
157
|
+
|
|
158
|
+
repo = Path(os.environ.get("VCS_REPO_ROOT", os.getcwd()))
|
|
159
|
+
targets += [f for f in _staged_files(repo) if is_guarded(f)]
|
|
160
|
+
targets = sorted(set(targets))
|
|
161
|
+
if not targets:
|
|
162
|
+
print("vcs: nothing staged that the gate guards — no leases needed")
|
|
163
|
+
return 0
|
|
164
|
+
if not targets:
|
|
165
|
+
print("vcs: acquire needs targets (or --staged)", file=sys.stderr)
|
|
166
|
+
return 1
|
|
122
167
|
try:
|
|
123
168
|
leases = registry.acquire(
|
|
124
|
-
who,
|
|
169
|
+
who, targets, ttl_sec=args.ttl, reason=args.reason
|
|
125
170
|
)
|
|
126
171
|
except LeaseConflictError as exc:
|
|
127
172
|
print(f"vcs: {exc}", file=sys.stderr)
|
|
@@ -178,6 +223,25 @@ def _cmd_lease_check(args: argparse.Namespace) -> int:
|
|
|
178
223
|
return 0
|
|
179
224
|
|
|
180
225
|
|
|
226
|
+
|
|
227
|
+
def _cmd_graph(args: argparse.Namespace) -> int:
|
|
228
|
+
from awgit.graph import build, to_json, to_mermaid
|
|
229
|
+
|
|
230
|
+
g = build(since=args.since, actor=args.actor)
|
|
231
|
+
text = to_json(g) if args.format == "json" else to_mermaid(g)
|
|
232
|
+
if args.out:
|
|
233
|
+
try:
|
|
234
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
235
|
+
except OSError as exc:
|
|
236
|
+
print(f"vcs: cannot write {args.out}: {exc}", file=sys.stderr)
|
|
237
|
+
return 2
|
|
238
|
+
print(f"vcs: wrote {args.format} graph to {args.out} "
|
|
239
|
+
f"({g['ops']} ops, {len(g['collisions'])} collision(s))")
|
|
240
|
+
else:
|
|
241
|
+
print(text)
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
181
245
|
def _cmd_bodies(args: argparse.Namespace) -> int:
|
|
182
246
|
from awgit.bodies import BodyStore
|
|
183
247
|
|
|
@@ -360,6 +424,15 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
360
424
|
p_diff.add_argument("b", help="target sha")
|
|
361
425
|
|
|
362
426
|
sub.add_parser("status", help="op-log status")
|
|
427
|
+
p_graph = sub.add_parser(
|
|
428
|
+
"graph", help="render the op-log as a graph (mermaid or json)")
|
|
429
|
+
p_graph.add_argument("--format", choices=("mermaid", "json"),
|
|
430
|
+
default="mermaid")
|
|
431
|
+
p_graph.add_argument("--since", default=None,
|
|
432
|
+
help="ISO timestamp — only ops at or after it")
|
|
433
|
+
p_graph.add_argument("--actor", default=None, help="restrict to one actor")
|
|
434
|
+
p_graph.add_argument("--out", default=None,
|
|
435
|
+
help="write to a file instead of stdout")
|
|
363
436
|
|
|
364
437
|
p_mp = sub.add_parser("merge-preview", help="node-level merge preview of two shas")
|
|
365
438
|
p_mp.add_argument("a", help="base-side sha")
|
|
@@ -376,7 +449,10 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
376
449
|
p_lease = sub.add_parser("lease", help="lease registry operations")
|
|
377
450
|
lsub = p_lease.add_subparsers(dest="lease_cmd", required=True)
|
|
378
451
|
p_la = lsub.add_parser("acquire", help="acquire leases for targets")
|
|
379
|
-
p_la.add_argument("targets", nargs="
|
|
452
|
+
p_la.add_argument("targets", nargs="*")
|
|
453
|
+
p_la.add_argument("--staged", action="store_true",
|
|
454
|
+
help="also lease every STAGED file the gate guards "
|
|
455
|
+
"(the one-command way to satisfy the pre-commit gate)")
|
|
380
456
|
p_la.add_argument("--ttl", type=int, default=300)
|
|
381
457
|
p_la.add_argument("--reason", default="")
|
|
382
458
|
p_la.add_argument("--actor", default=None)
|
|
@@ -470,6 +546,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
470
546
|
return _cmd_lease(args)
|
|
471
547
|
if args.cmd == "lease-check":
|
|
472
548
|
return _cmd_lease_check(args)
|
|
549
|
+
if args.cmd == "graph":
|
|
550
|
+
return _cmd_graph(args)
|
|
473
551
|
if args.cmd == "bodies":
|
|
474
552
|
return _cmd_bodies(args)
|
|
475
553
|
if args.cmd == "dedupe":
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Render the op-log as a GRAPH — awgit's own data, drawn.
|
|
2
|
+
|
|
3
|
+
awgit is already a graph and was only ever printed as lines. Every edit op is
|
|
4
|
+
an edge: an ACTOR touched a NODE (a function or class with a stable id) that
|
|
5
|
+
lives in a FILE, at a commit. Rendering that answers, at a glance, the question
|
|
6
|
+
the whole lease plane exists for — *who is working where, and where are two
|
|
7
|
+
actors standing on the same node* — instead of making someone reconstruct it
|
|
8
|
+
from `awgit ledger` output.
|
|
9
|
+
|
|
10
|
+
Two shapes, deliberately:
|
|
11
|
+
|
|
12
|
+
- ``mermaid`` for humans: files as subgraphs, code nodes inside them, actors as
|
|
13
|
+
rounded nodes with edges into what they touched. A node TWO OR MORE actors
|
|
14
|
+
touched is drawn as a collision, because that is the only part of the picture
|
|
15
|
+
that means anything is wrong.
|
|
16
|
+
- ``json`` for machines: plain nodes/edges so the platform's graph plane can
|
|
17
|
+
ingest it. awgit's graph is one graph among many — code, services, memory —
|
|
18
|
+
and this is the seam that lets it be a subgraph of that larger graph rather
|
|
19
|
+
than a private format nothing else can read.
|
|
20
|
+
|
|
21
|
+
Deliberately NO layout, colours-by-actor or clustering heuristics: this renders
|
|
22
|
+
what the op-log says. If the picture is wrong, the op-log is wrong, and that is
|
|
23
|
+
worth seeing rather than smoothing over.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from awgit.oplog import OpLog
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _short(text: str, keep: int = 28) -> str:
|
|
35
|
+
text = text or "?"
|
|
36
|
+
return text if len(text) <= keep else text[:keep - 1] + "…"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build(data_root=None, since: Optional[str] = None, actor: Optional[str] = None) -> dict:
|
|
40
|
+
"""Collect the op-log into {nodes, edges, collisions}. Pure data, no rendering."""
|
|
41
|
+
log = OpLog(data_root=data_root)
|
|
42
|
+
ops = log.ops_since(since) if since else log.all_ops()
|
|
43
|
+
if actor:
|
|
44
|
+
ops = [o for o in ops if getattr(o, "actor", None) == actor]
|
|
45
|
+
|
|
46
|
+
files: dict = defaultdict(set) # path -> {(node_id, symbol)}
|
|
47
|
+
touched: dict = defaultdict(set) # (path, node_id) -> {actor}
|
|
48
|
+
actors: set = set()
|
|
49
|
+
for op in ops:
|
|
50
|
+
who = getattr(op, "actor", None) or "unknown"
|
|
51
|
+
actors.add(who)
|
|
52
|
+
for ch in (getattr(op, "node_changes", None) or []):
|
|
53
|
+
nid = getattr(ch, "node_id", None)
|
|
54
|
+
if not nid:
|
|
55
|
+
continue
|
|
56
|
+
path = getattr(ch, "path", None) or "?"
|
|
57
|
+
files[path].add((nid, getattr(ch, "symbol", None) or nid[:12]))
|
|
58
|
+
touched[(path, nid)].add(who)
|
|
59
|
+
|
|
60
|
+
collisions = sorted(k for k, v in touched.items() if len(v) > 1)
|
|
61
|
+
return {
|
|
62
|
+
"ops": len(ops),
|
|
63
|
+
"actors": sorted(actors),
|
|
64
|
+
"files": {p: sorted(v) for p, v in files.items()},
|
|
65
|
+
"touched": {f"{p}::{n}": sorted(v) for (p, n), v in touched.items()},
|
|
66
|
+
"collisions": [{"path": p, "node_id": n, "actors": sorted(touched[(p, n)])}
|
|
67
|
+
for p, n in collisions],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def to_mermaid(g: dict) -> str:
|
|
72
|
+
"""A mermaid `graph LR` of actors -> nodes, grouped by file."""
|
|
73
|
+
out = ["graph LR"]
|
|
74
|
+
if not g["files"]:
|
|
75
|
+
# An empty graph must SAY it is empty. A bare `graph LR` renders as a
|
|
76
|
+
# blank box, which reads as "nothing is wrong" rather than "no data".
|
|
77
|
+
out.append(' empty["op-log is empty — no captures yet"]')
|
|
78
|
+
return "\n".join(out)
|
|
79
|
+
|
|
80
|
+
ids: dict = {}
|
|
81
|
+
for fi, (path, nodes) in enumerate(sorted(g["files"].items())):
|
|
82
|
+
out.append(f' subgraph F{fi}["{_short(path, 40)}"]')
|
|
83
|
+
for ni, (nid, symbol) in enumerate(nodes):
|
|
84
|
+
key = f"N{fi}_{ni}"
|
|
85
|
+
ids[(path, nid)] = key
|
|
86
|
+
hits = g["touched"].get(f"{path}::{nid}", [])
|
|
87
|
+
marker = "💥 " if len(hits) > 1 else ""
|
|
88
|
+
out.append(f' {key}["{marker}{_short(symbol)}"]')
|
|
89
|
+
out.append(" end")
|
|
90
|
+
|
|
91
|
+
for ai, who in enumerate(g["actors"]):
|
|
92
|
+
out.append(f' A{ai}("{_short(who, 34)}")')
|
|
93
|
+
|
|
94
|
+
seen = set()
|
|
95
|
+
for key, whos in g["touched"].items():
|
|
96
|
+
path, _, nid = key.rpartition("::")
|
|
97
|
+
target = ids.get((path, nid))
|
|
98
|
+
if not target:
|
|
99
|
+
continue
|
|
100
|
+
for who in whos:
|
|
101
|
+
ai = g["actors"].index(who)
|
|
102
|
+
edge = (ai, target)
|
|
103
|
+
if edge in seen:
|
|
104
|
+
continue
|
|
105
|
+
seen.add(edge)
|
|
106
|
+
out.append(f" A{ai} --> {target}")
|
|
107
|
+
|
|
108
|
+
for c in g["collisions"]:
|
|
109
|
+
target = ids.get((c["path"], c["node_id"]))
|
|
110
|
+
if target:
|
|
111
|
+
out.append(f" style {target} fill:#a33,stroke:#f66,color:#fff")
|
|
112
|
+
return "\n".join(out)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def to_json(g: dict) -> str:
|
|
116
|
+
"""Node/edge form for ingestion into the platform's graph-of-graphs."""
|
|
117
|
+
nodes = [{"id": f"actor:{a}", "kind": "actor", "label": a} for a in g["actors"]]
|
|
118
|
+
edges = []
|
|
119
|
+
for path, entries in g["files"].items():
|
|
120
|
+
nodes.append({"id": f"file:{path}", "kind": "file", "label": path})
|
|
121
|
+
for nid, symbol in entries:
|
|
122
|
+
nodes.append({"id": f"node:{path}::{nid}", "kind": "code_node",
|
|
123
|
+
"label": symbol, "path": path})
|
|
124
|
+
edges.append({"src": f"node:{path}::{nid}", "dst": f"file:{path}",
|
|
125
|
+
"rel": "in_file"})
|
|
126
|
+
for key, whos in g["touched"].items():
|
|
127
|
+
for who in whos:
|
|
128
|
+
edges.append({"src": f"actor:{who}", "dst": f"node:{key}",
|
|
129
|
+
"rel": "touched"})
|
|
130
|
+
return json.dumps({
|
|
131
|
+
"graph": "awgit.oplog", "ops": g["ops"],
|
|
132
|
+
"nodes": nodes, "edges": edges, "collisions": g["collisions"],
|
|
133
|
+
}, indent=2)
|
|
@@ -243,16 +243,60 @@ class LeaseRegistry:
|
|
|
243
243
|
return [lz for lz in self._leases.values() if lz.actor == actor]
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
# Files worth guarding: the ones two agents plausibly edit at the same time.
|
|
247
|
+
# Widened from ".py only" on 2026-08-09 (D-1880). The old scope let a `.yml`
|
|
248
|
+
# commit print "lease-check OK" while checking NOTHING, so compose files,
|
|
249
|
+
# workflows, skills and .mcp.json were exactly as clobberable as before the
|
|
250
|
+
# gate existed — and that same day edits to docker-compose.aitheros.yml and
|
|
251
|
+
# concurrent-safe-git/SKILL.md had to be hand-restored after a concurrent
|
|
252
|
+
# overwrite, while the .py the gate did cover was overwritten four times.
|
|
253
|
+
GUARDED_SUFFIXES = (
|
|
254
|
+
".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", # source
|
|
255
|
+
".yml", ".yaml", ".toml", ".json", ".ini", ".cfg", # config
|
|
256
|
+
".sh", ".bash", ".ps1", ".psm1", # scripts
|
|
257
|
+
".md", # rules, skills, docs
|
|
258
|
+
".sql", ".proto", ".env",
|
|
259
|
+
)
|
|
260
|
+
# Bulk content nobody races on. Guarding these would make a routine content
|
|
261
|
+
# commit need dozens of leases, and a gate that heavy gets ROUTED AROUND rather
|
|
262
|
+
# than satisfied — the exact failure that produced this repo's per-file-ignores.
|
|
263
|
+
UNGUARDED_PREFIXES = (
|
|
264
|
+
"AitherOS/apps/AitherVeil/content/", # blog posts, one author at a time
|
|
265
|
+
"AitherOS/Library/", # generated training/runtime data
|
|
266
|
+
".DELIVERABLES/", # published artifacts
|
|
267
|
+
".RESEARCH/INTAKE/", # dossiers
|
|
268
|
+
"TECH_DEBT_ARCHIVE", # frozen
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def is_guarded(path: str) -> bool:
|
|
273
|
+
"""Does the lease gate care about this path?
|
|
274
|
+
|
|
275
|
+
Extension-based with a bulk-content escape, rather than "everything": the
|
|
276
|
+
trade is deliberate. Covering every path means a 50-file commit needs 50
|
|
277
|
+
leases; covering none is where this started.
|
|
278
|
+
"""
|
|
279
|
+
p = (path or "").replace("\\", "/").strip()
|
|
280
|
+
if not p:
|
|
281
|
+
return False
|
|
282
|
+
if p.startswith(UNGUARDED_PREFIXES):
|
|
283
|
+
return False
|
|
284
|
+
return p.endswith(GUARDED_SUFFIXES)
|
|
285
|
+
|
|
286
|
+
|
|
246
287
|
def coverage_gap(
|
|
247
288
|
changed_files: List[str],
|
|
248
289
|
actor: str,
|
|
249
290
|
data_root: Optional[Path] = None,
|
|
250
291
|
) -> List[str]:
|
|
251
|
-
"""Changed
|
|
292
|
+
"""Changed GUARDED files not covered by the actor's active path-leases.
|
|
293
|
+
|
|
294
|
+
The pre-commit gate's primitive: a commit touching a guarded file with no
|
|
295
|
+
active path-lease is an unleased edit — rejected when leases are enforced
|
|
296
|
+
(VCS_LEASES_ENFORCE=1), recorded as ``leased=false`` otherwise.
|
|
252
297
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
are enforced (VCS_LEASES_ENFORCE=1), recorded as ``leased=false`` otherwise.
|
|
298
|
+
``awgit lease acquire --staged`` takes leases on exactly this set, so
|
|
299
|
+
complying is one command rather than a per-file chore.
|
|
256
300
|
"""
|
|
257
301
|
registry = LeaseRegistry(data_root=data_root)
|
|
258
302
|
registry.sweep_expired()
|
|
@@ -261,4 +305,4 @@ def coverage_gap(
|
|
|
261
305
|
for lz in registry.active_leases()
|
|
262
306
|
if lz.actor == actor and lz.kind == "path"
|
|
263
307
|
}
|
|
264
|
-
return [f for f in changed_files if f
|
|
308
|
+
return [f for f in changed_files if is_guarded(f) and f not in covered]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Multi-language node identity for awgit, borrowed from repowise.
|
|
2
|
+
|
|
3
|
+
WHY (2026-08-09). awgit's node model is Python-only: ``parse_source_bytes``
|
|
4
|
+
routes to CPython's ``ast``, so every `.ts`, `.tsx`, `.go`, `.cs` file is
|
|
5
|
+
invisible to the diff, the merge engine and the graph. Measured on this repo,
|
|
6
|
+
that is not a corner case — repowise's index holds 20,451 files, of which
|
|
7
|
+
**7,824 are Python and 12,600+ are not** (2,859 `.tsx`, 2,163 `.ts`, 1,423
|
|
8
|
+
yaml, 1,015 json, …). The lease gate was widened to guard those files, but a
|
|
9
|
+
lease is all awgit could offer them: no node identity, nothing to merge,
|
|
10
|
+
nothing to draw.
|
|
11
|
+
|
|
12
|
+
Rather than grow a second tree-sitter stack inside awgit, this adapts the one
|
|
13
|
+
the platform already runs. repowise (PyPI, third-party) parses **75 extensions
|
|
14
|
+
across 20+ languages** and — the fact that decides it — emits symbol ids of the
|
|
15
|
+
form ``path::qualified_name``, which are STABLE. Verified before writing any of
|
|
16
|
+
this: a TypeScript function that MOVED position and had its BODY changed kept
|
|
17
|
+
the id ``probe.ts::alpha`` across both edits. Stability under movement is the
|
|
18
|
+
whole basis of node-level merge; without it this would be worthless.
|
|
19
|
+
|
|
20
|
+
Deliberately a THIN adapter:
|
|
21
|
+
* Python still goes to CodeGraph. It is the semantics awgit was built on, its
|
|
22
|
+
node ids are already in the op-log, and switching would orphan every op
|
|
23
|
+
recorded so far.
|
|
24
|
+
* repowise is OPTIONAL. It is a third-party dependency and the standalone
|
|
25
|
+
package must not hard-require it — `available()` is false when it is absent
|
|
26
|
+
and callers fall back, rather than awgit failing to import.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
_PARSER = None
|
|
33
|
+
_UNAVAILABLE: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def available() -> Tuple[bool, str]:
|
|
37
|
+
"""(usable, why-not). Never raises: absence is a fallback, not a failure."""
|
|
38
|
+
global _PARSER, _UNAVAILABLE
|
|
39
|
+
if _PARSER is not None:
|
|
40
|
+
return True, ""
|
|
41
|
+
if _UNAVAILABLE is not None:
|
|
42
|
+
return False, _UNAVAILABLE
|
|
43
|
+
try:
|
|
44
|
+
from repowise.core.ingestion import ASTParser # noqa: PLC0415
|
|
45
|
+
_PARSER = ASTParser()
|
|
46
|
+
return True, ""
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
_UNAVAILABLE = f"repowise unavailable ({type(exc).__name__})"
|
|
49
|
+
return False, _UNAVAILABLE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def language_for(path: str) -> Optional[str]:
|
|
53
|
+
"""repowise's language for a path, or None when it does not handle it."""
|
|
54
|
+
ok, _ = available()
|
|
55
|
+
if not ok:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
from repowise.core.ingestion import EXTENSION_TO_LANGUAGE # noqa: PLC0415
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
_, _, ext = (path or "").rpartition(".")
|
|
62
|
+
return EXTENSION_TO_LANGUAGE.get("." + ext.lower()) if ext else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_symbols(content: bytes, path: str) -> List[dict]:
|
|
66
|
+
"""Symbols in `content` as awgit-shaped dicts. [] when it cannot parse.
|
|
67
|
+
|
|
68
|
+
Returns node_id/symbol/kind/start_line/end_line/language. The node_id is
|
|
69
|
+
repowise's own ``path::qualified_name``, NOT a hash of the body — that is
|
|
70
|
+
precisely why it survives a move, and why it can be compared across two
|
|
71
|
+
commits the way awgit's Python node ids are.
|
|
72
|
+
"""
|
|
73
|
+
ok, _ = available()
|
|
74
|
+
if not ok:
|
|
75
|
+
return []
|
|
76
|
+
lang = language_for(path)
|
|
77
|
+
if not lang:
|
|
78
|
+
return []
|
|
79
|
+
try:
|
|
80
|
+
from repowise.core.ingestion import FileInfo # noqa: PLC0415
|
|
81
|
+
|
|
82
|
+
info = FileInfo(
|
|
83
|
+
path=path, abs_path=path, language=lang, size_bytes=len(content),
|
|
84
|
+
git_hash="", last_modified=0.0, is_test=False, is_config=False,
|
|
85
|
+
is_api_contract=False, is_entry_point=False,
|
|
86
|
+
)
|
|
87
|
+
parsed = _PARSER.parse_file(info, content)
|
|
88
|
+
except Exception:
|
|
89
|
+
# A file repowise chokes on must degrade to "no symbols", never take
|
|
90
|
+
# the capture down: awgit records what it can see, and a parser crash
|
|
91
|
+
# on one file is not a reason to lose the whole commit's op.
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
out = []
|
|
95
|
+
for sym in (getattr(parsed, "symbols", None) or []):
|
|
96
|
+
nid = getattr(sym, "id", None)
|
|
97
|
+
if not nid:
|
|
98
|
+
continue
|
|
99
|
+
out.append({
|
|
100
|
+
"node_id": str(nid),
|
|
101
|
+
"symbol": getattr(sym, "qualified_name", None) or getattr(sym, "name", ""),
|
|
102
|
+
"kind": str(getattr(sym, "kind", "") or ""),
|
|
103
|
+
"start_line": getattr(sym, "start_line", 0),
|
|
104
|
+
"end_line": getattr(sym, "end_line", 0),
|
|
105
|
+
"language": lang,
|
|
106
|
+
})
|
|
107
|
+
return out
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: awgit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
Requires-Python: >=3.10
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Requires-Dist: httpx>=0.25.0
|
|
10
|
+
Provides-Extra: multilang
|
|
11
|
+
Requires-Dist: repowise>=0.31.0; extra == "multilang"
|
|
10
12
|
Dynamic: license-file
|
|
11
13
|
|
|
12
14
|
# awgit — Aither World-Graph git
|
|
@@ -52,7 +54,7 @@ what changed, never the snapshot.
|
|
|
52
54
|
## Install
|
|
53
55
|
|
|
54
56
|
```bash
|
|
55
|
-
pip install awgit
|
|
57
|
+
pip install awgit
|
|
56
58
|
# or from source:
|
|
57
59
|
git clone https://github.com/aitherium/awgit && cd awgit
|
|
58
60
|
pip install -e .
|
|
@@ -8,6 +8,7 @@ awgit/capture.py
|
|
|
8
8
|
awgit/cli.py
|
|
9
9
|
awgit/data_root.py
|
|
10
10
|
awgit/diff.py
|
|
11
|
+
awgit/graph.py
|
|
11
12
|
awgit/identity.py
|
|
12
13
|
awgit/leases.py
|
|
13
14
|
awgit/ledger.py
|
|
@@ -16,6 +17,7 @@ awgit/merge.py
|
|
|
16
17
|
awgit/nodeid.py
|
|
17
18
|
awgit/oplog.py
|
|
18
19
|
awgit/parser.py
|
|
20
|
+
awgit/repowise_parser.py
|
|
19
21
|
awgit/schema.py
|
|
20
22
|
awgit/sync.py
|
|
21
23
|
awgit.egg-info/PKG-INFO
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "awgit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.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"
|
|
@@ -13,6 +13,14 @@ dependencies = [
|
|
|
13
13
|
"httpx>=0.25.0",
|
|
14
14
|
]
|
|
15
15
|
|
|
16
|
+
# OPTIONAL on purpose. Python node identity is native (CPython's ast); repowise
|
|
17
|
+
# adds identity for the other 20+ languages it parses. Making it a hard
|
|
18
|
+
# dependency would drag a large third-party parser into every install of a tool
|
|
19
|
+
# whose core does not need it — and awgit must still import, and still guard
|
|
20
|
+
# Python, when it is absent (see awgit/repowise_parser.available()).
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
multilang = ["repowise>=0.31.0"]
|
|
23
|
+
|
|
16
24
|
[project.scripts]
|
|
17
25
|
awgit = "awgit.cli:main"
|
|
18
26
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
httpx>=0.25.0
|
|
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
|