awgit 0.2.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.2.0 → awgit-0.3.0}/PKG-INFO +3 -1
  2. {awgit-0.2.0 → awgit-0.3.0}/awgit/capture.py +42 -1
  3. {awgit-0.2.0 → awgit-0.3.0}/awgit/cli.py +30 -0
  4. awgit-0.3.0/awgit/graph.py +133 -0
  5. awgit-0.3.0/awgit/repowise_parser.py +107 -0
  6. {awgit-0.2.0 → awgit-0.3.0}/awgit.egg-info/PKG-INFO +3 -1
  7. {awgit-0.2.0 → awgit-0.3.0}/awgit.egg-info/SOURCES.txt +2 -0
  8. awgit-0.3.0/awgit.egg-info/requires.txt +4 -0
  9. {awgit-0.2.0 → awgit-0.3.0}/pyproject.toml +9 -1
  10. awgit-0.2.0/awgit.egg-info/requires.txt +0 -1
  11. {awgit-0.2.0 → awgit-0.3.0}/LICENSE +0 -0
  12. {awgit-0.2.0 → awgit-0.3.0}/README.md +0 -0
  13. {awgit-0.2.0 → awgit-0.3.0}/awgit/__init__.py +0 -0
  14. {awgit-0.2.0 → awgit-0.3.0}/awgit/bodies.py +0 -0
  15. {awgit-0.2.0 → awgit-0.3.0}/awgit/bridge.py +0 -0
  16. {awgit-0.2.0 → awgit-0.3.0}/awgit/data_root.py +0 -0
  17. {awgit-0.2.0 → awgit-0.3.0}/awgit/diff.py +0 -0
  18. {awgit-0.2.0 → awgit-0.3.0}/awgit/hooks/chain.sh +0 -0
  19. {awgit-0.2.0 → awgit-0.3.0}/awgit/hooks/post-commit.d/vcs-capture +0 -0
  20. {awgit-0.2.0 → awgit-0.3.0}/awgit/hooks/pre-commit.d/vcs-lease-check +0 -0
  21. {awgit-0.2.0 → awgit-0.3.0}/awgit/identity.py +0 -0
  22. {awgit-0.2.0 → awgit-0.3.0}/awgit/leases.py +0 -0
  23. {awgit-0.2.0 → awgit-0.3.0}/awgit/ledger.py +0 -0
  24. {awgit-0.2.0 → awgit-0.3.0}/awgit/mcp.py +0 -0
  25. {awgit-0.2.0 → awgit-0.3.0}/awgit/merge.py +0 -0
  26. {awgit-0.2.0 → awgit-0.3.0}/awgit/nodeid.py +0 -0
  27. {awgit-0.2.0 → awgit-0.3.0}/awgit/oplog.py +0 -0
  28. {awgit-0.2.0 → awgit-0.3.0}/awgit/parser.py +0 -0
  29. {awgit-0.2.0 → awgit-0.3.0}/awgit/schema.py +0 -0
  30. {awgit-0.2.0 → awgit-0.3.0}/awgit/sync.py +0 -0
  31. {awgit-0.2.0 → awgit-0.3.0}/awgit.egg-info/dependency_links.txt +0 -0
  32. {awgit-0.2.0 → awgit-0.3.0}/awgit.egg-info/entry_points.txt +0 -0
  33. {awgit-0.2.0 → awgit-0.3.0}/awgit.egg-info/top_level.txt +0 -0
  34. {awgit-0.2.0 → awgit-0.3.0}/setup.cfg +0 -0
  35. {awgit-0.2.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.2.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
@@ -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)
@@ -223,6 +223,25 @@ def _cmd_lease_check(args: argparse.Namespace) -> int:
223
223
  return 0
224
224
 
225
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
+
226
245
  def _cmd_bodies(args: argparse.Namespace) -> int:
227
246
  from awgit.bodies import BodyStore
228
247
 
@@ -405,6 +424,15 @@ def main(argv: Optional[List[str]] = None) -> int:
405
424
  p_diff.add_argument("b", help="target sha")
406
425
 
407
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")
408
436
 
409
437
  p_mp = sub.add_parser("merge-preview", help="node-level merge preview of two shas")
410
438
  p_mp.add_argument("a", help="base-side sha")
@@ -518,6 +546,8 @@ def main(argv: Optional[List[str]] = None) -> int:
518
546
  return _cmd_lease(args)
519
547
  if args.cmd == "lease-check":
520
548
  return _cmd_lease_check(args)
549
+ if args.cmd == "graph":
550
+ return _cmd_graph(args)
521
551
  if args.cmd == "bodies":
522
552
  return _cmd_bodies(args)
523
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)
@@ -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.2.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
@@ -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.2.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