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.
Files changed (35) hide show
  1. {awgit-0.1.0 → awgit-0.3.0}/PKG-INFO +4 -2
  2. {awgit-0.1.0 → awgit-0.3.0}/README.md +1 -1
  3. {awgit-0.1.0 → awgit-0.3.0}/awgit/capture.py +42 -1
  4. {awgit-0.1.0 → awgit-0.3.0}/awgit/cli.py +85 -7
  5. awgit-0.3.0/awgit/graph.py +133 -0
  6. {awgit-0.1.0 → awgit-0.3.0}/awgit/leases.py +49 -5
  7. awgit-0.3.0/awgit/repowise_parser.py +107 -0
  8. {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/PKG-INFO +4 -2
  9. {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/SOURCES.txt +2 -0
  10. awgit-0.3.0/awgit.egg-info/requires.txt +4 -0
  11. {awgit-0.1.0 → awgit-0.3.0}/pyproject.toml +9 -1
  12. awgit-0.1.0/awgit.egg-info/requires.txt +0 -1
  13. {awgit-0.1.0 → awgit-0.3.0}/LICENSE +0 -0
  14. {awgit-0.1.0 → awgit-0.3.0}/awgit/__init__.py +0 -0
  15. {awgit-0.1.0 → awgit-0.3.0}/awgit/bodies.py +0 -0
  16. {awgit-0.1.0 → awgit-0.3.0}/awgit/bridge.py +0 -0
  17. {awgit-0.1.0 → awgit-0.3.0}/awgit/data_root.py +0 -0
  18. {awgit-0.1.0 → awgit-0.3.0}/awgit/diff.py +0 -0
  19. {awgit-0.1.0 → awgit-0.3.0}/awgit/hooks/chain.sh +0 -0
  20. {awgit-0.1.0 → awgit-0.3.0}/awgit/hooks/post-commit.d/vcs-capture +0 -0
  21. {awgit-0.1.0 → awgit-0.3.0}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
  22. {awgit-0.1.0 → awgit-0.3.0}/awgit/identity.py +0 -0
  23. {awgit-0.1.0 → awgit-0.3.0}/awgit/ledger.py +0 -0
  24. {awgit-0.1.0 → awgit-0.3.0}/awgit/mcp.py +0 -0
  25. {awgit-0.1.0 → awgit-0.3.0}/awgit/merge.py +0 -0
  26. {awgit-0.1.0 → awgit-0.3.0}/awgit/nodeid.py +0 -0
  27. {awgit-0.1.0 → awgit-0.3.0}/awgit/oplog.py +0 -0
  28. {awgit-0.1.0 → awgit-0.3.0}/awgit/parser.py +0 -0
  29. {awgit-0.1.0 → awgit-0.3.0}/awgit/schema.py +0 -0
  30. {awgit-0.1.0 → awgit-0.3.0}/awgit/sync.py +0 -0
  31. {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/dependency_links.txt +0 -0
  32. {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/entry_points.txt +0 -0
  33. {awgit-0.1.0 → awgit-0.3.0}/awgit.egg-info/top_level.txt +0 -0
  34. {awgit-0.1.0 → awgit-0.3.0}/setup.cfg +0 -0
  35. {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.1.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 # from PyPI (when published)
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 .
@@ -41,7 +41,7 @@ what changed, never the snapshot.
41
41
  ## Install
42
42
 
43
43
  ```bash
44
- pip install awgit # from PyPI (when published)
44
+ pip install awgit
45
45
  # or from source:
46
46
  git clone https://github.com/aitherium/awgit && cd awgit
47
47
  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
- if not rel.endswith(".py"):
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
- return (
32
- getattr(args, "actor", None)
33
- or os.environ.get("AITHER_ACTOR")
34
- or "unknown"
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, args.targets, ttl_sec=args.ttl, reason=args.reason
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 Python files NOT covered by the actor's active path-leases.
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
- The pre-commit gate's primitive: a commit touching a Python file with no
254
- active path-lease is an unleased edit — a visible risk flag when leases
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.endswith(".py") and f not in covered]
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.1.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 # from PyPI (when published)
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
@@ -0,0 +1,4 @@
1
+ httpx>=0.25.0
2
+
3
+ [multilang]
4
+ repowise>=0.31.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "awgit"
7
- version = "0.1.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