awgit 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """awgit — Aither World-Graph git: semantic version control on top of git.
2
+
3
+ Rides ON TOP of git (git stays the byte-transport + history). Adds: edit-ops
4
+ keyed on stable graph node ids, a durable op-log, semantic diff, a
5
+ node-granularity merge engine, lease-based conflict prevention, content-addressed
6
+ bodies, verified-identity attribution, and differential sync.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from awgit.bodies import BodyStore, blob_sha, dedupe_report, reclaim, scan_tree
12
+ from awgit.capture import capture_ops
13
+ from awgit.data_root import vcs_data_root
14
+ from awgit.diff import diff_git, diff_opsets, render
15
+ from awgit.identity import attribution_id, github_email
16
+ from awgit.leases import LeaseRegistry
17
+ from awgit.ledger import LedgerEntry, mint_ledger_ref, op_to_ledger_entry
18
+ from awgit.merge import MergeResult, list_conflicts, merge_ops, resolve_conflict
19
+ from awgit.oplog import OpLog
20
+ from awgit.schema import SCHEMA_VERSION, EditOp, MergeConflict, NodeChange
21
+ from awgit.sync import export_delta, import_delta, sync_state, sync_status
22
+
23
+ __all__ = [
24
+ "BodyStore",
25
+ "EditOp",
26
+ "LedgerEntry",
27
+ "MergeConflict",
28
+ "MergeResult",
29
+ "NodeChange",
30
+ "SCHEMA_VERSION",
31
+ "OpLog",
32
+ "LeaseRegistry",
33
+ "attribution_id",
34
+ "blob_sha",
35
+ "capture_ops",
36
+ "dedupe_report",
37
+ "diff_git",
38
+ "diff_opsets",
39
+ "export_delta",
40
+ "github_email",
41
+ "import_delta",
42
+ "list_conflicts",
43
+ "merge_ops",
44
+ "mint_ledger_ref",
45
+ "op_to_ledger_entry",
46
+ "reclaim",
47
+ "render",
48
+ "resolve_conflict",
49
+ "scan_tree",
50
+ "sync_state",
51
+ "sync_status",
52
+ "vcs_data_root",
53
+ ]
awgit/bodies.py ADDED
@@ -0,0 +1,323 @@
1
+ """Content-addressed body store + disk dedupe index (M6).
2
+
3
+ The op-log records node bodies by their git-blob SHA — a content address — but
4
+ until this module the bytes themselves lived ONLY inside git objects: on this
5
+ box, the failing D: drive's ``.git/objects``. ``BodyStore`` materializes them
6
+ into ``<data_root>/bodies/<sha[:2]>/<sha>`` so any op can reconstruct a node
7
+ body WITHOUT git. Content addressing IS dedupe: identical bodies (across
8
+ commits, branches, worktrees, the D:/C:/staging copies) collapse to one blob.
9
+
10
+ Also provides the disk-level dedupe index: ``scan_tree`` hashes files and
11
+ reports identical-content groups — the "things get crazy with all the worktrees
12
+ on disk" problem, quantified. ``wasted_bytes`` is what a within-filesystem
13
+ hard-link would reclaim; the report is the Phase 6+ stepping stone to actually
14
+ reclaiming it.
15
+
16
+ Drive-failure resilience: unreadable files (0xC0000006 / ENODEV on this box's
17
+ failing D:) are skipped, never fatal — the same rule as ``vcs_replay_history``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import logging
24
+ import os
25
+ import subprocess
26
+ from pathlib import Path
27
+ from typing import Dict, List, Optional, Tuple
28
+
29
+ from awgit.data_root import vcs_data_root
30
+ from awgit.oplog import FileLock
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def blob_sha(content: bytes) -> str:
36
+ """Git-blob content address — matches ``git hash-object`` byte-for-byte."""
37
+ return hashlib.sha1(
38
+ b"blob " + str(len(content)).encode() + b"\0" + content
39
+ ).hexdigest()
40
+
41
+
42
+ class BodyStore:
43
+ """Content-addressed store of node bodies (deduped by sha).
44
+
45
+ ``put`` is idempotent: storing a body already present is a no-op (the same
46
+ content address returns the same sha and writes nothing). Writes are
47
+ atomic (temp file + ``os.replace``) and fsync'd, serialized by a per-store
48
+ lock so concurrent post-commit captures cannot tear a blob.
49
+ """
50
+
51
+ def __init__(self, data_root: Optional[Path] = None) -> None:
52
+ self.data_root = data_root or vcs_data_root()
53
+ self.root = self.data_root / "bodies"
54
+
55
+ def _path(self, sha: str) -> Path:
56
+ return self.root / sha[:2] / sha
57
+
58
+ def put(self, content: bytes) -> str:
59
+ """Store ``content``, returning its content address. Deduped."""
60
+ sha = blob_sha(content)
61
+ if self._path(sha).exists():
62
+ return sha
63
+ with FileLock(self.data_root / "bodies.lock"):
64
+ path = self._path(sha)
65
+ if path.exists():
66
+ return sha # another writer beat us to it
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ tmp = path.parent / f".{sha}.tmp"
69
+ with open(tmp, "wb") as f:
70
+ f.write(content)
71
+ f.flush()
72
+ os.fsync(f.fileno())
73
+ os.replace(tmp, path) # atomic on POSIX and Windows
74
+ return sha
75
+
76
+ def get(self, sha: str) -> Optional[bytes]:
77
+ path = self._path(sha)
78
+ if not path.exists():
79
+ return None
80
+ try:
81
+ return path.read_bytes()
82
+ except OSError:
83
+ return None # unreadable (failing drive) — caller falls back
84
+
85
+ def contains(self, sha: str) -> bool:
86
+ return self._path(sha).exists()
87
+
88
+ def stats(self) -> Dict[str, int]:
89
+ if not self.root.exists():
90
+ return {"blobs": 0, "bytes": 0}
91
+ blobs = [p for p in self.root.glob("*/*") if p.is_file()]
92
+ return {
93
+ "blobs": len(blobs),
94
+ "bytes": sum(p.stat().st_size for p in blobs),
95
+ }
96
+
97
+ def blob_shas(self) -> set:
98
+ """The set of content addresses currently stored."""
99
+ if not self.root.exists():
100
+ return set()
101
+ return {p.name for p in self.root.glob("*/*") if p.is_file()}
102
+
103
+ def gc(self, referenced: set, *, dry_run: bool = True) -> Dict[str, int]:
104
+ """Remove blobs NOT in ``referenced`` (orphans). Never touches a blob
105
+ the op-log references.
106
+
107
+ ``referenced`` is the set of body shas the durable op-log names. A blob
108
+ absent from it is an orphan — e.g. a capture process that crashed
109
+ between writing bodies and appending the op. ``dry_run`` (default)
110
+ reports what would be removed without deleting; pass ``dry_run=False``
111
+ to actually reclaim. Shard dirs are pruned when they empty.
112
+ """
113
+ if not self.root.exists():
114
+ return {"removed": 0, "freed": 0}
115
+ candidates = [
116
+ p for p in self.root.glob("*/*")
117
+ if p.is_file() and p.name not in referenced
118
+ ]
119
+ freed = 0
120
+ for p in candidates:
121
+ try:
122
+ freed += p.stat().st_size
123
+ except OSError:
124
+ continue
125
+ if not dry_run:
126
+ for p in candidates:
127
+ try:
128
+ p.unlink()
129
+ except OSError:
130
+ continue
131
+ for shard in list(self.root.iterdir()):
132
+ if shard.is_dir() and not any(shard.iterdir()):
133
+ try:
134
+ shard.rmdir()
135
+ except OSError:
136
+ logger.debug("vcs: could not prune empty shard %s", shard)
137
+ return {"removed": len(candidates), "freed": freed}
138
+
139
+
140
+ def op_referenced_shas(data_root: Optional[Path] = None) -> set:
141
+ """Every body sha the op-log references — the durable truth for GC."""
142
+ from awgit.oplog import OpLog # lazy: avoid a module-level cycle
143
+
144
+ ref: set = set()
145
+ for op in OpLog(data_root=data_root).all_ops():
146
+ for nc in op.node_changes:
147
+ for s in (nc.old_body_sha, nc.new_body_sha):
148
+ if s:
149
+ ref.add(s)
150
+ return ref
151
+
152
+
153
+ # ── disk dedupe index ─────────────────────────────────────────────────────
154
+
155
+ def scan_tree(
156
+ root: Path, include: Tuple[str, ...] = ()
157
+ ) -> Dict[str, List[str]]:
158
+ """Hash every file under ``root`` → ``{sha: [paths]}``.
159
+
160
+ ``include`` filters by suffix (e.g. ``(".py",)``); empty = ALL files. The
161
+ disk-dedupe purpose is all files (the space hogs are weights/artifacts, not
162
+ code) — ``include`` is for callers scoping to a language. Files sharing a
163
+ sha are byte-identical — the dedupe index. Unreadable files are skipped
164
+ (failing drive), never fatal.
165
+ """
166
+ index: Dict[str, List[str]] = {}
167
+ for path in root.rglob("*"):
168
+ if not path.is_file():
169
+ continue
170
+ if ".git" in path.parts:
171
+ continue # never hash/link git internals (worktrees, submodules)
172
+ if include and path.suffix not in include:
173
+ continue
174
+ try:
175
+ data = path.read_bytes()
176
+ except OSError:
177
+ continue
178
+ index.setdefault(blob_sha(data), []).append(str(path))
179
+ return index
180
+
181
+
182
+ def dedupe_report(paths: List[Path]) -> Dict[str, int]:
183
+ """Scan the given trees and report identical-content duplication.
184
+
185
+ Returns ``{groups, duplicate_files, wasted_bytes}`` — ``wasted_bytes`` is
186
+ the size of all but the first copy of each group (what a within-filesystem
187
+ hard-link / dedup would reclaim). Files identical across TWO trees (D: and
188
+ C:, say) can't be hard-linked (different filesystems) but the report still
189
+ names them — that is the duplication the owner flagged.
190
+ """
191
+ combined: Dict[str, List[str]] = {}
192
+ for root in paths:
193
+ if not root.exists():
194
+ continue
195
+ for sha, found in scan_tree(root).items():
196
+ combined.setdefault(sha, []).extend(found)
197
+ groups = {sha: p for sha, p in combined.items() if len(p) > 1}
198
+ duplicate_files = sum(len(p) - 1 for p in groups.values())
199
+ wasted = 0
200
+ for _sha, p in groups.items():
201
+ try:
202
+ wasted += (len(p) - 1) * Path(p[0]).stat().st_size
203
+ except OSError:
204
+ continue
205
+ return {"groups": len(groups), "duplicate_files": duplicate_files,
206
+ "wasted_bytes": wasted}
207
+
208
+
209
+ # ── reclaim: actually collapse the duplicates ─────────────────────────────
210
+
211
+ def _repo_root(path: Path) -> Optional[Path]:
212
+ """Nearest ancestor containing a `.git` entry, if any (bounded walk)."""
213
+ cur = path if path.is_dir() else path.parent
214
+ for _ in range(8):
215
+ if (cur / ".git").exists():
216
+ return cur
217
+ if cur.parent == cur:
218
+ break
219
+ cur = cur.parent
220
+ return None
221
+
222
+
223
+ def _tracked_abs_paths(roots: List[Path]) -> Dict[Path, set]:
224
+ """``{repo_root: {absolute tracked path}}`` — one ``git ls-files -z`` per root."""
225
+ out: Dict[Path, set] = {}
226
+ for root in roots:
227
+ try:
228
+ raw = subprocess.run(
229
+ ["git", "ls-files", "-z"], cwd=str(root),
230
+ capture_output=True, check=True,
231
+ ).stdout
232
+ except (OSError, subprocess.CalledProcessError):
233
+ continue
234
+ rels = raw.split(b"\0")
235
+ out[root] = {
236
+ str((root / r.decode("utf-8", "replace")).resolve())
237
+ for r in rels if r
238
+ }
239
+ return out
240
+
241
+
242
+ def reclaim(paths: List[Path], *, dry_run: bool = True) -> Dict[str, int]:
243
+ """Hard-link byte-identical duplicates to one copy per group.
244
+
245
+ This is the "actually reclaim the space" half of the dedupe report. Safety:
246
+
247
+ - links ONLY within a filesystem (hard links cannot cross ``st_dev`` — D:
248
+ and C: duplicates are reported by ``dedupe_report`` but never linked);
249
+ - git-TRACKED paths are never linked: an editor writing in place would
250
+ silently diverge two paths sharing an inode (the duplicate stops being
251
+ a copy the moment either is edited);
252
+ - ``dry_run`` (default) reports exactly what would be linked; pass
253
+ ``dry_run=False`` to act.
254
+
255
+ Linking is atomic: ``os.link`` to a temp name, then ``os.replace`` over the
256
+ duplicate — no window where the path is missing, and the content lives in
257
+ the canonical inode regardless (deleting canonical later keeps the
258
+ hard-linked copies intact).
259
+ """
260
+ candidates: List[Dict[str, object]] = []
261
+ for root in paths:
262
+ if not root.exists():
263
+ continue
264
+ for path in root.rglob("*"):
265
+ if not path.is_file():
266
+ continue
267
+ if ".git" in path.parts:
268
+ continue # never hash/link git internals (worktrees, submodules)
269
+ try:
270
+ st = path.stat()
271
+ data = path.read_bytes()
272
+ except OSError:
273
+ continue # unreadable (failing drive) — skip, never fatal
274
+ candidates.append({
275
+ "path": str(path.resolve()),
276
+ "sha": blob_sha(data),
277
+ "dev": st.st_dev,
278
+ "size": st.st_size,
279
+ })
280
+
281
+ repo_roots = {r for c in candidates if (r := _repo_root(Path(c["path"])))}
282
+ tracked = _tracked_abs_paths(list(repo_roots))
283
+
284
+ def is_tracked(p: str) -> bool:
285
+ return any(p in tset for tset in tracked.values())
286
+
287
+ groups: Dict[Tuple[str, int], List[Dict[str, object]]] = {}
288
+ skipped_tracked = 0
289
+ for c in candidates:
290
+ if is_tracked(str(c["path"])):
291
+ skipped_tracked += 1
292
+ continue
293
+ groups.setdefault((str(c["sha"]), int(c["dev"])), []).append(c)
294
+
295
+ linked = 0
296
+ reclaimed = 0
297
+ group_count = 0
298
+ for _key, members in groups.items():
299
+ if len(members) < 2:
300
+ continue
301
+ group_count += 1
302
+ canonical = str(members[0]["path"])
303
+ for dup in members[1:]:
304
+ dup_path = str(dup["path"])
305
+ if not dry_run:
306
+ tmp = dup_path + ".vcs-linktmp"
307
+ try:
308
+ os.link(canonical, tmp)
309
+ os.replace(tmp, dup_path)
310
+ except OSError:
311
+ try:
312
+ os.unlink(tmp)
313
+ except OSError:
314
+ logger.debug("vcs: could not clean up link temp %s", tmp)
315
+ continue # failed to link this one; keep going
316
+ linked += 1
317
+ reclaimed += int(dup["size"])
318
+ return {
319
+ "groups": group_count,
320
+ "linked": linked,
321
+ "reclaimed_bytes": reclaimed,
322
+ "skipped_tracked": skipped_tracked,
323
+ }
awgit/bridge.py ADDED
@@ -0,0 +1,117 @@
1
+ """Git-bridge integration: hook chaining, install/uninstall, autosync guard.
2
+
3
+ The layer must CHAIN with the existing custom hooks in ``.git/hooks`` (this repo
4
+ has live ``pre-commit``, ``post-commit``, ``post-merge``, ``pre-push``), never
5
+ overwrite them. ``install_hooks`` wraps each hook with ``chain.sh`` which
6
+ sources the pre-existing body (moved to ``<hook>.org``) then runs the ``.d``
7
+ fragments. No ``post-merge`` / ``pre-push`` hook is added — merge/push semantics
8
+ stay byte-identical.
9
+
10
+ Data lives OUTSIDE the git tree (``Library/Data/vcs``), so the D:→C: autosync
11
+ tree-copy and GitHub Actions never see it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import List, Optional
21
+
22
+ _HOOKS = ("pre-commit", "post-commit")
23
+ _FRAGMENTS = {
24
+ "pre-commit": ("vcs-lease-check",),
25
+ "post-commit": ("vcs-capture",),
26
+ }
27
+ _MARKER = "# aither-vcs-chain"
28
+
29
+
30
+ def _git_dir(repo_root: Path) -> Path:
31
+ out = subprocess.run(
32
+ ["git", "rev-parse", "--git-dir"],
33
+ cwd=str(repo_root),
34
+ capture_output=True,
35
+ text=True,
36
+ encoding="utf-8",
37
+ errors="replace",
38
+ check=True,
39
+ ).stdout.strip()
40
+ return Path(out) if Path(out).is_absolute() else repo_root / out
41
+
42
+
43
+ def _ensure_exec(path: Path) -> None:
44
+ if os.name != "nt":
45
+ path.chmod(path.stat().st_mode | 0o755)
46
+
47
+
48
+ def install_hooks(repo_root: Optional[str] = None) -> List[str]:
49
+ """Wrap existing hooks with the chaining shim (idempotent, reversible).
50
+
51
+ Never overwrites a live custom hook: the existing body moves to
52
+ ``<hook>.org`` and is sourced first by ``chain.sh``.
53
+ """
54
+ root = Path(repo_root or ".")
55
+ hooks_dir = _git_dir(root) / "hooks"
56
+ hooks_dir.mkdir(parents=True, exist_ok=True)
57
+ pkg_hooks = Path(__file__).parent / "hooks"
58
+ installed: List[str] = []
59
+ for hook in _HOOKS:
60
+ target = hooks_dir / hook
61
+ original = hooks_dir / f"{hook}.org"
62
+ if target.exists():
63
+ text = target.read_text(encoding="utf-8", errors="replace")
64
+ if _MARKER not in text and not original.exists():
65
+ shutil.move(str(target), str(original))
66
+ shutil.copyfile(pkg_hooks / "chain.sh", target)
67
+ _ensure_exec(target)
68
+ frag_dir = hooks_dir / f"{hook}.d"
69
+ frag_dir.mkdir(parents=True, exist_ok=True)
70
+ for frag_name in _FRAGMENTS[hook]:
71
+ src = pkg_hooks / f"{hook}.d" / frag_name
72
+ if src.exists():
73
+ dst = frag_dir / frag_name
74
+ shutil.copyfile(src, dst)
75
+ _ensure_exec(dst)
76
+ installed.append(str(target))
77
+ return installed
78
+
79
+
80
+ def uninstall_hooks(repo_root: Optional[str] = None) -> List[str]:
81
+ """Remove chain hooks and restore ``<hook>.org`` bodies (reversible)."""
82
+ root = Path(repo_root or ".")
83
+ hooks_dir = _git_dir(root) / "hooks"
84
+ removed: List[str] = []
85
+ for hook in _HOOKS:
86
+ target = hooks_dir / hook
87
+ original = hooks_dir / f"{hook}.org"
88
+ if target.exists():
89
+ text = target.read_text(encoding="utf-8", errors="replace")
90
+ if _MARKER in text:
91
+ target.unlink()
92
+ if original.exists():
93
+ shutil.move(str(original), str(target))
94
+ removed.append(str(target))
95
+ return removed
96
+
97
+
98
+ def verify_deploy_tree(repo_root: Optional[str] = None) -> bool:
99
+ """Tripwire for the D-267 class: unmerged paths in the working tree.
100
+
101
+ A cheap ``git status --porcelain`` check — unmerged markers mean a semantic
102
+ merge is half-staged and the D:→C: autosync must NOT copy that state.
103
+ """
104
+ root = Path(repo_root or ".")
105
+ out = subprocess.run(
106
+ ["git", "status", "--porcelain"],
107
+ cwd=str(root),
108
+ capture_output=True,
109
+ text=True,
110
+ encoding="utf-8",
111
+ errors="replace",
112
+ ).stdout
113
+ for line in out.splitlines():
114
+ xy = line[:2]
115
+ if xy[0] == "U" or xy in ("AA", "DD"):
116
+ return False
117
+ return True