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 +53 -0
- awgit/bodies.py +323 -0
- awgit/bridge.py +117 -0
- awgit/capture.py +441 -0
- awgit/cli.py +488 -0
- awgit/data_root.py +30 -0
- awgit/diff.py +82 -0
- awgit/hooks/chain.sh +38 -0
- awgit/hooks/post-commit.d/vcs-capture +38 -0
- awgit/hooks/pre-commit.d/vcs-lease-check +15 -0
- awgit/identity.py +80 -0
- awgit/leases.py +264 -0
- awgit/ledger.py +80 -0
- awgit/mcp.py +94 -0
- awgit/merge.py +331 -0
- awgit/nodeid.py +210 -0
- awgit/oplog.py +221 -0
- awgit/parser.py +186 -0
- awgit/schema.py +190 -0
- awgit/sync.py +180 -0
- awgit-0.1.0.dist-info/METADATA +117 -0
- awgit-0.1.0.dist-info/RECORD +26 -0
- awgit-0.1.0.dist-info/WHEEL +5 -0
- awgit-0.1.0.dist-info/entry_points.txt +2 -0
- awgit-0.1.0.dist-info/licenses/LICENSE +202 -0
- awgit-0.1.0.dist-info/top_level.txt +1 -0
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
|