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/capture.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Edit-op capture — turn a git commit into a semantic ``EditOp``.
|
|
2
|
+
|
|
3
|
+
Flow: changed files (git) → for each Python file parse the parent and commit
|
|
4
|
+
blobs via ``parse_source_bytes`` → diff the node sets keyed by stable node id
|
|
5
|
+
(``StableNodeIDManager``) → emit ``NodeChange`` records whose body hashes come
|
|
6
|
+
from the EXACT commit blobs, never the live index. That is the anti-staleness
|
|
7
|
+
guarantee: node *identity* is graph-derived, body *content* is commit-derived,
|
|
8
|
+
so an op stays self-consistent even if the index has moved on (D-1640 class).
|
|
9
|
+
|
|
10
|
+
Python-tree-first: non-``.py`` files are recorded in ``file_paths`` but skipped
|
|
11
|
+
at the node level in M1 (file-level fallback ops are a later refinement).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from awgit.bodies import BodyStore
|
|
27
|
+
from awgit.bodies import blob_sha as _blob_sha
|
|
28
|
+
from awgit.data_root import vcs_data_root
|
|
29
|
+
from awgit.ledger import mint_ledger_ref
|
|
30
|
+
from awgit.oplog import FileLock, OpLog
|
|
31
|
+
from awgit.schema import EditOp, NodeChange
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_repo() -> Path:
|
|
37
|
+
return Path(os.environ.get("VCS_REPO_ROOT", os.getcwd()))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _git(repo: Path, *args: str) -> bytes:
|
|
41
|
+
return subprocess.run(
|
|
42
|
+
["git", *args], cwd=str(repo), capture_output=True, check=True
|
|
43
|
+
).stdout
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def git_parent(repo: Path, sha: str) -> Optional[str]:
|
|
47
|
+
out = _git(repo, "rev-list", "--parents", "-n", "1", sha).decode().split()
|
|
48
|
+
return out[1] if len(out) > 1 else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def git_changed_files(repo: Path, sha: str) -> List[str]:
|
|
52
|
+
out = _git(repo, "show", "--format=", "--name-only", sha).decode()
|
|
53
|
+
return [ln for ln in out.splitlines() if ln.strip()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def git_blob(repo: Path, sha: str, rel_path: str) -> Optional[bytes]:
|
|
57
|
+
try:
|
|
58
|
+
return _git(repo, "show", f"{sha}:{rel_path}")
|
|
59
|
+
except subprocess.CalledProcessError:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── actor provenance ──────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
_GITHUB_TTL_SEC = 6 * 3600 # refresh the verified identity at most this often
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _git_author(repo: Path, sha: str) -> str:
|
|
69
|
+
"""The commit's real author (``Name <email>``) — independent provenance."""
|
|
70
|
+
try:
|
|
71
|
+
out = _git(
|
|
72
|
+
repo, "show", "-s", "--format=%an <%ae>", sha
|
|
73
|
+
).decode("utf-8", errors="replace").strip()
|
|
74
|
+
return out
|
|
75
|
+
except subprocess.CalledProcessError:
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _github_identity(data_root: Path) -> Optional[str]:
|
|
80
|
+
"""Verified GitHub login via ``gh`` (the Aitherium GitHub OAuth-app identity).
|
|
81
|
+
|
|
82
|
+
Best-effort and never load-bearing: returns None on any failure (no ``gh``
|
|
83
|
+
on PATH, offline, unauthenticated) so capture never depends on the network.
|
|
84
|
+
Cached in the vcs store for ``_GITHUB_TTL_SEC`` so per-commit hooks hit the
|
|
85
|
+
network at most once per window. Phase 6+ makes this identity (via the
|
|
86
|
+
Aitherium GitHub OAuth app for humans and the Aitherium GitHub app for the
|
|
87
|
+
agent fleet) the authoritative actor source; the schema already records it
|
|
88
|
+
via ``verified_actor`` / ``actor_verified``.
|
|
89
|
+
"""
|
|
90
|
+
cache = data_root / "identity.json"
|
|
91
|
+
if cache.exists():
|
|
92
|
+
try:
|
|
93
|
+
rec = json.loads(cache.read_text(encoding="utf-8"))
|
|
94
|
+
if time.time() - rec.get("ts", 0) < _GITHUB_TTL_SEC and rec.get("login"):
|
|
95
|
+
return rec["login"]
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
logger.debug("vcs: identity cache unreadable, re-resolving")
|
|
98
|
+
try:
|
|
99
|
+
out = subprocess.run(
|
|
100
|
+
["gh", "api", "user", "-q", ".login"],
|
|
101
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
102
|
+
timeout=5, check=True,
|
|
103
|
+
).stdout.strip()
|
|
104
|
+
except (OSError, subprocess.SubprocessError):
|
|
105
|
+
return None
|
|
106
|
+
if not out:
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
data_root.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
cache.write_text(
|
|
111
|
+
json.dumps({"ts": time.time(), "login": out}), encoding="utf-8"
|
|
112
|
+
)
|
|
113
|
+
except OSError:
|
|
114
|
+
logger.debug("vcs: identity cache write failed (best-effort)")
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_actor(
|
|
119
|
+
actor_arg: Optional[str], repo: Path, git_sha: str, data_root: Path
|
|
120
|
+
) -> Dict[str, object]:
|
|
121
|
+
"""Resolve the claimed actor + verified identity for an op.
|
|
122
|
+
|
|
123
|
+
Claimed-actor priority: explicit arg → ``AITHER_ACTOR`` env → verified
|
|
124
|
+
GitHub login → commit author. ``actor_verified``/``verified_actor`` record
|
|
125
|
+
the box's VERIFIED identity independently of the claimed actor, so a
|
|
126
|
+
session claiming ``AITHER_ACTOR=lyra`` on a box verified as ``wizzense``
|
|
127
|
+
records both — attribution stays honest without losing the agent name.
|
|
128
|
+
"""
|
|
129
|
+
github = _github_identity(data_root)
|
|
130
|
+
env_actor = os.environ.get("AITHER_ACTOR")
|
|
131
|
+
if actor_arg:
|
|
132
|
+
actor, source = actor_arg, "arg"
|
|
133
|
+
elif env_actor:
|
|
134
|
+
actor, source = env_actor, "env"
|
|
135
|
+
elif github:
|
|
136
|
+
actor, source = github, "github"
|
|
137
|
+
else:
|
|
138
|
+
author = _git_author(repo, git_sha)
|
|
139
|
+
actor, source = author or "unknown", "git-author" if author else "unknown"
|
|
140
|
+
return {
|
|
141
|
+
"actor": actor,
|
|
142
|
+
"actor_source": source,
|
|
143
|
+
"actor_verified": bool(github),
|
|
144
|
+
"verified_actor": github or "",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ancestor_shas(repo: Path, sha: str) -> List[str]:
|
|
149
|
+
"""First-parent chain of ``sha``, closest first, excluding ``sha``."""
|
|
150
|
+
out = _git(repo, "rev-list", "--first-parent", sha).decode().split()
|
|
151
|
+
return out[1:] # first entry is sha itself
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _node_records(src: Optional[bytes], rel_path: str) -> List[Dict[str, Any]]:
|
|
155
|
+
if src is None:
|
|
156
|
+
return []
|
|
157
|
+
# lazy: CodeGraph's import chain is ~15s (AitherConfig auto-tune etc.); only
|
|
158
|
+
# pay it when actually parsing (capture/diff/merge runtime), never for
|
|
159
|
+
# `vcs lease`/`status` which import this module but never parse.
|
|
160
|
+
from awgit.parser import parse_source_bytes
|
|
161
|
+
|
|
162
|
+
graph = parse_source_bytes(src, rel_path)
|
|
163
|
+
lines = src.decode("utf-8", errors="ignore").split("\n")
|
|
164
|
+
recs: List[Dict[str, Any]] = []
|
|
165
|
+
for chunk in graph.chunks:
|
|
166
|
+
body = ""
|
|
167
|
+
if chunk.end_line:
|
|
168
|
+
body = "\n".join(lines[chunk.start_line - 1: chunk.end_line])
|
|
169
|
+
recs.append({
|
|
170
|
+
"name": chunk.name,
|
|
171
|
+
"path": rel_path,
|
|
172
|
+
"type": chunk.chunk_type.value,
|
|
173
|
+
"signature": chunk.signature or "",
|
|
174
|
+
"body": body,
|
|
175
|
+
})
|
|
176
|
+
return recs
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _body_sha(rec: Dict[str, Any]) -> Optional[str]:
|
|
180
|
+
body = rec.get("body") or ""
|
|
181
|
+
return _blob_sha(body.encode("utf-8")) if body else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _diff_node_sets(
|
|
185
|
+
old: List[Dict[str, Any]],
|
|
186
|
+
new: List[Dict[str, Any]],
|
|
187
|
+
manager: StableNodeIDManager,
|
|
188
|
+
store: Optional[BodyStore] = None,
|
|
189
|
+
) -> List[NodeChange]:
|
|
190
|
+
"""Diff two node sets keyed by stable node id.
|
|
191
|
+
|
|
192
|
+
Rename detection pairs genuinely-ADDED names with genuinely-DELETED names
|
|
193
|
+
(same file, same type, similar body). A name present in BOTH sets is
|
|
194
|
+
unchanged or rewritten — NEVER a rename candidate — which is what keeps a
|
|
195
|
+
coincidentally-similar ADDED function from being misread as a rename (a
|
|
196
|
+
tiny function's body is structurally confusable at ~0.75-0.8 similarity).
|
|
197
|
+
The renamed node reuses the old stable id; the persisted legacy alias lets
|
|
198
|
+
a divergent branch still resolve the pre-rename symbol to it (the M5
|
|
199
|
+
rename gate).
|
|
200
|
+
"""
|
|
201
|
+
old_by: Dict[str, Dict[str, Any]] = {
|
|
202
|
+
manager.id_for(r["name"], r["path"], r["type"]): r for r in old
|
|
203
|
+
}
|
|
204
|
+
old_sid_by_name: Dict[str, str] = {}
|
|
205
|
+
for sid, r in old_by.items():
|
|
206
|
+
old_sid_by_name.setdefault(r["name"], sid)
|
|
207
|
+
|
|
208
|
+
old_names = {r["name"] for r in old}
|
|
209
|
+
new_names = {r["name"] for r in new}
|
|
210
|
+
added_cands = [r for r in new if r["name"] not in old_names]
|
|
211
|
+
deleted_cands = [r for r in old if r["name"] not in new_names]
|
|
212
|
+
rename_pairs: List[tuple] = []
|
|
213
|
+
used_old_names: set = set()
|
|
214
|
+
for nr in added_cands:
|
|
215
|
+
best_o, best_score = None, 0.0
|
|
216
|
+
for o in deleted_cands:
|
|
217
|
+
if o["name"] in used_old_names:
|
|
218
|
+
continue
|
|
219
|
+
if o["path"] != nr["path"] or o["type"] != nr["type"]:
|
|
220
|
+
continue
|
|
221
|
+
score = _similarity(o["body"], nr["body"])
|
|
222
|
+
if score > best_score:
|
|
223
|
+
best_o, best_score = o, score
|
|
224
|
+
if best_o is not None and best_score >= _RENAME_SIMILARITY:
|
|
225
|
+
used_old_names.add(best_o["name"])
|
|
226
|
+
rename_pairs.append((best_o, nr))
|
|
227
|
+
|
|
228
|
+
new_by: Dict[str, Dict[str, Any]] = {}
|
|
229
|
+
for o, nr in rename_pairs:
|
|
230
|
+
sid = old_sid_by_name[o["name"]]
|
|
231
|
+
manager.rename_node(sid, nr["name"])
|
|
232
|
+
new_by[sid] = nr
|
|
233
|
+
used_new_names = {nr["name"] for _, nr in rename_pairs}
|
|
234
|
+
for r in new:
|
|
235
|
+
if r["name"] in used_new_names:
|
|
236
|
+
continue
|
|
237
|
+
new_by[manager.id_for(r["name"], r["path"], r["type"])] = r
|
|
238
|
+
|
|
239
|
+
changes: List[NodeChange] = []
|
|
240
|
+
for o, nr in rename_pairs:
|
|
241
|
+
sid = old_sid_by_name[o["name"]]
|
|
242
|
+
changes.append(NodeChange(
|
|
243
|
+
node_id=sid,
|
|
244
|
+
change_type="renamed",
|
|
245
|
+
old_body_sha=_body_sha(o),
|
|
246
|
+
new_body_sha=_body_sha(nr),
|
|
247
|
+
symbol=nr["name"],
|
|
248
|
+
path=nr["path"],
|
|
249
|
+
renamed_from=o["name"],
|
|
250
|
+
))
|
|
251
|
+
renamed_sids = {old_sid_by_name[o["name"]] for o, _ in rename_pairs}
|
|
252
|
+
for sid in sorted(old_by.keys() & new_by.keys()):
|
|
253
|
+
if sid in renamed_sids:
|
|
254
|
+
continue # consumed by a rename above
|
|
255
|
+
o, n = old_by[sid], new_by[sid]
|
|
256
|
+
osha, nsha = _body_sha(o), _body_sha(n)
|
|
257
|
+
if osha == nsha:
|
|
258
|
+
continue
|
|
259
|
+
ctype = (
|
|
260
|
+
"signature_changed"
|
|
261
|
+
if o["signature"] != n["signature"]
|
|
262
|
+
else "body_rewrite"
|
|
263
|
+
)
|
|
264
|
+
changes.append(NodeChange(
|
|
265
|
+
node_id=sid,
|
|
266
|
+
change_type=ctype,
|
|
267
|
+
old_body_sha=osha,
|
|
268
|
+
new_body_sha=nsha,
|
|
269
|
+
symbol=n["name"],
|
|
270
|
+
path=n["path"],
|
|
271
|
+
))
|
|
272
|
+
for sid in sorted(new_by.keys() - old_by.keys()):
|
|
273
|
+
n = new_by[sid]
|
|
274
|
+
changes.append(NodeChange(
|
|
275
|
+
node_id=sid,
|
|
276
|
+
change_type="added",
|
|
277
|
+
new_body_sha=_body_sha(n),
|
|
278
|
+
symbol=n["name"],
|
|
279
|
+
path=n["path"],
|
|
280
|
+
))
|
|
281
|
+
for sid in sorted(old_by.keys() - new_by.keys()):
|
|
282
|
+
if sid in renamed_sids:
|
|
283
|
+
continue
|
|
284
|
+
o = old_by[sid]
|
|
285
|
+
changes.append(NodeChange(
|
|
286
|
+
node_id=sid,
|
|
287
|
+
change_type="deleted",
|
|
288
|
+
old_body_sha=_body_sha(o),
|
|
289
|
+
symbol=o["name"],
|
|
290
|
+
path=o["path"],
|
|
291
|
+
))
|
|
292
|
+
if store is not None:
|
|
293
|
+
# Materialize the bodies the op references so the op-log is
|
|
294
|
+
# self-contained — reconstructable WITHOUT git blobs (which live on the
|
|
295
|
+
# failing D: drive). Content addressing dedupes: identical bodies (across
|
|
296
|
+
# commits/branches/worktrees) collapse to one blob.
|
|
297
|
+
bodies: Dict[str, bytes] = {}
|
|
298
|
+
for r in list(old) + list(new):
|
|
299
|
+
body = r.get("body")
|
|
300
|
+
if body:
|
|
301
|
+
b = body.encode("utf-8")
|
|
302
|
+
bodies[_blob_sha(b)] = b
|
|
303
|
+
for nc in changes:
|
|
304
|
+
for sha in (nc.old_body_sha, nc.new_body_sha):
|
|
305
|
+
if sha and sha in bodies:
|
|
306
|
+
store.put(bodies[sha])
|
|
307
|
+
return changes
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_RENAME_SIMILARITY = 0.75
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _similarity(a: str, b: str) -> float:
|
|
314
|
+
if not a and not b:
|
|
315
|
+
return 1.0
|
|
316
|
+
if not a or not b:
|
|
317
|
+
return 0.0
|
|
318
|
+
return difflib.SequenceMatcher(None, a, b).ratio()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def diff_sources(
|
|
322
|
+
a_src: Optional[bytes],
|
|
323
|
+
b_src: Optional[bytes],
|
|
324
|
+
rel_path: str,
|
|
325
|
+
manager: StableNodeIDManager,
|
|
326
|
+
store: Optional[BodyStore] = None,
|
|
327
|
+
) -> List[NodeChange]:
|
|
328
|
+
"""Node-level diff of two source blobs (shared by capture and diff)."""
|
|
329
|
+
return _diff_node_sets(
|
|
330
|
+
_node_records(a_src, rel_path),
|
|
331
|
+
_node_records(b_src, rel_path),
|
|
332
|
+
manager,
|
|
333
|
+
store,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def load_node_manager(data_root: Path) -> "StableNodeIDManager":
|
|
338
|
+
"""Read-only stable-id manager over a persisted registry, if one exists.
|
|
339
|
+
|
|
340
|
+
``persist=False`` + explicit ``_load`` keeps a pure diff from WRITING the
|
|
341
|
+
registry — unseen symbols get ephemeral ids for the call without being
|
|
342
|
+
registered (a diff must not mutate the node universe).
|
|
343
|
+
"""
|
|
344
|
+
from awgit.nodeid import StableNodeIDManager
|
|
345
|
+
|
|
346
|
+
path = data_root / "nodes.json"
|
|
347
|
+
if path.exists():
|
|
348
|
+
mgr = StableNodeIDManager(path=path, persist=False)
|
|
349
|
+
mgr._load() # init skips the load when persist=False
|
|
350
|
+
return mgr
|
|
351
|
+
return StableNodeIDManager()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _make_summary(actor: str, changes: List[NodeChange]) -> str:
|
|
355
|
+
counts: Dict[str, int] = {}
|
|
356
|
+
for c in changes:
|
|
357
|
+
counts[c.change_type] = counts.get(c.change_type, 0) + 1
|
|
358
|
+
parts = [f"{counts[k]} {k}" for k in sorted(counts)]
|
|
359
|
+
who = actor or "unknown"
|
|
360
|
+
return f"{who}: {', '.join(parts)}" if parts else f"{who}: no semantic changes"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def capture_ops(
|
|
364
|
+
git_sha: str,
|
|
365
|
+
*,
|
|
366
|
+
actor: Optional[str] = None,
|
|
367
|
+
repo_path: Optional[str] = None,
|
|
368
|
+
root_path: Optional[str] = None,
|
|
369
|
+
data_root: Optional[Path] = None,
|
|
370
|
+
) -> Optional[EditOp]:
|
|
371
|
+
"""Capture ``git_sha`` as an ``EditOp`` (idempotent by commit).
|
|
372
|
+
|
|
373
|
+
Returns None when there is nothing to record (root commit, no Python
|
|
374
|
+
semantic changes) or the op already exists. Serialized by a per-store
|
|
375
|
+
file lock so concurrent post-commit hooks cannot tear the node registry
|
|
376
|
+
or double-log a commit.
|
|
377
|
+
"""
|
|
378
|
+
repo = Path(repo_path or default_repo())
|
|
379
|
+
data = data_root or vcs_data_root(root_path=root_path)
|
|
380
|
+
|
|
381
|
+
parent = git_parent(repo, git_sha)
|
|
382
|
+
if parent is None:
|
|
383
|
+
return None
|
|
384
|
+
files = git_changed_files(repo, git_sha)
|
|
385
|
+
if not files:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
# Provenance is resolved BEFORE the lock: the gh identity cache is a
|
|
389
|
+
# separate file, and the lock must never be held across a network call.
|
|
390
|
+
prov = resolve_actor(actor, repo, git_sha, data)
|
|
391
|
+
actor_name = str(prov["actor"])
|
|
392
|
+
|
|
393
|
+
data.mkdir(parents=True, exist_ok=True)
|
|
394
|
+
with FileLock(data / "capture.lock"):
|
|
395
|
+
from awgit.nodeid import StableNodeIDManager
|
|
396
|
+
|
|
397
|
+
oplog = OpLog(data_root=data)
|
|
398
|
+
if oplog.has_commit(git_sha):
|
|
399
|
+
return oplog.ops_for_commit(git_sha)[0]
|
|
400
|
+
|
|
401
|
+
manager = StableNodeIDManager(path=data / "nodes.json", persist=True)
|
|
402
|
+
store = BodyStore(data_root=data)
|
|
403
|
+
node_changes: List[NodeChange] = []
|
|
404
|
+
for rel in files:
|
|
405
|
+
if not rel.endswith(".py"):
|
|
406
|
+
continue
|
|
407
|
+
old_src = git_blob(repo, parent, rel)
|
|
408
|
+
new_src = git_blob(repo, git_sha, rel)
|
|
409
|
+
node_changes.extend(diff_sources(old_src, new_src, rel, manager, store))
|
|
410
|
+
if not node_changes:
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
414
|
+
sha_to_op = oplog.sha_index()
|
|
415
|
+
parent_ops = [
|
|
416
|
+
sha_to_op[a] for a in _ancestor_shas(repo, git_sha) if a in sha_to_op
|
|
417
|
+
]
|
|
418
|
+
op_id = os.urandom(16).hex()
|
|
419
|
+
op = EditOp(
|
|
420
|
+
op_id=op_id,
|
|
421
|
+
parent_ops=parent_ops,
|
|
422
|
+
actor=actor_name,
|
|
423
|
+
ts=ts,
|
|
424
|
+
git_sha=git_sha,
|
|
425
|
+
git_parent_sha=parent,
|
|
426
|
+
file_paths=files,
|
|
427
|
+
node_changes=node_changes,
|
|
428
|
+
summary=_make_summary(actor_name, node_changes),
|
|
429
|
+
leased=False,
|
|
430
|
+
actor_verified=bool(prov["actor_verified"]),
|
|
431
|
+
actor_source=str(prov["actor_source"]),
|
|
432
|
+
verified_actor=str(prov["verified_actor"]),
|
|
433
|
+
ledger_ref=mint_ledger_ref(op_id, git_sha),
|
|
434
|
+
)
|
|
435
|
+
oplog.append(op)
|
|
436
|
+
# a self-capture is by definition "applied" on this node — so
|
|
437
|
+
# sync_status shows it as covered, not missing.
|
|
438
|
+
from awgit.sync import mark_op_applied
|
|
439
|
+
|
|
440
|
+
mark_op_applied(op.op_id, data_root=data)
|
|
441
|
+
return op
|