worktree-controller 1.30.4__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.
- worktree_controller/__init__.py +3 -0
- worktree_controller/activation_layout.py +483 -0
- worktree_controller/branch_retirement.py +1865 -0
- worktree_controller/canonical_refresh.py +150 -0
- worktree_controller/canonical_transition.py +324 -0
- worktree_controller/cleanup_attempts.py +442 -0
- worktree_controller/cleanup_evidence.py +75 -0
- worktree_controller/cleanup_recovery.py +912 -0
- worktree_controller/cli.py +1907 -0
- worktree_controller/controller.py +1974 -0
- worktree_controller/convergence.py +893 -0
- worktree_controller/delivered_closeout.py +928 -0
- worktree_controller/disposable.py +677 -0
- worktree_controller/environment.py +1038 -0
- worktree_controller/frozen_recovery.py +291 -0
- worktree_controller/git_backend.py +682 -0
- worktree_controller/governance.py +365 -0
- worktree_controller/hardlinks.py +294 -0
- worktree_controller/integration_cleanup.py +92 -0
- worktree_controller/integration_conflict.py +117 -0
- worktree_controller/integration_retry.py +526 -0
- worktree_controller/keyed_document_scope.py +162 -0
- worktree_controller/lifecycle.py +8992 -0
- worktree_controller/lifecycle_history.py +358 -0
- worktree_controller/lifecycle_observation.py +234 -0
- worktree_controller/metadata_codec.py +1694 -0
- worktree_controller/metadata_relocation.py +131 -0
- worktree_controller/models.py +546 -0
- worktree_controller/mount_diagnostics.py +324 -0
- worktree_controller/multiple_cleanup_recovery.py +730 -0
- worktree_controller/namespace_migration.py +1412 -0
- worktree_controller/native_session_recovery.py +171 -0
- worktree_controller/owner_actions.py +34 -0
- worktree_controller/policy.py +333 -0
- worktree_controller/preservation.py +983 -0
- worktree_controller/preserved_cleanup.py +849 -0
- worktree_controller/publication_execution.py +361 -0
- worktree_controller/reconciler.py +430 -0
- worktree_controller/recovery_guidance.py +205 -0
- worktree_controller/registration_backfill.py +166 -0
- worktree_controller/registration_prune.py +391 -0
- worktree_controller/rendering.py +179 -0
- worktree_controller/resource_admission.py +1736 -0
- worktree_controller/retirement_adapters.py +130 -0
- worktree_controller/retirement_execution.py +234 -0
- worktree_controller/semantics.py +459 -0
- worktree_controller/session.py +505 -0
- worktree_controller/session_admission.py +265 -0
- worktree_controller/source_cleanup.py +515 -0
- worktree_controller/source_preservation.py +692 -0
- worktree_controller/source_recovery.py +592 -0
- worktree_controller/store.py +1228 -0
- worktree_controller/tool_cli_contract.json +1791 -0
- worktree_controller/transaction_scope.py +876 -0
- worktree_controller/writer_lease.py +662 -0
- worktree_controller/writer_session.py +887 -0
- worktree_controller-1.30.4.dist-info/METADATA +3149 -0
- worktree_controller-1.30.4.dist-info/RECORD +61 -0
- worktree_controller-1.30.4.dist-info/WHEEL +4 -0
- worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
- worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""Narrow Git inventory and mutation primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
10
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .models import ControllerError, RepositoryInventory, WorktreeGitState
|
|
14
|
+
|
|
15
|
+
REMOTE_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
|
16
|
+
|
|
17
|
+
# One Git command may never hold the repository lock indefinitely: a slow
|
|
18
|
+
# remote, an authentication prompt or a wedged helper would block every
|
|
19
|
+
# Controller caller and the systemd reconcile window alike.
|
|
20
|
+
GIT_COMMAND_TIMEOUT_SECONDS = 120
|
|
21
|
+
|
|
22
|
+
# Per-worktree observations are independent read-only Git processes. Running a
|
|
23
|
+
# bounded number at once keeps Controller latency flat as checkouts accumulate.
|
|
24
|
+
WORKTREE_OBSERVATION_WORKERS = 8
|
|
25
|
+
|
|
26
|
+
# Variables that select a repository, object store, index or ref namespace other
|
|
27
|
+
# than the one named by ``-C``. A caller inside a Git hook (or any process that
|
|
28
|
+
# exported them) would otherwise make every observation describe another
|
|
29
|
+
# repository while the Controller attributes it to the requested one.
|
|
30
|
+
REPOSITORY_SELECTION_ENVIRONMENT = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"GIT_DIR",
|
|
33
|
+
"GIT_WORK_TREE",
|
|
34
|
+
"GIT_IMPLICIT_WORK_TREE",
|
|
35
|
+
"GIT_COMMON_DIR",
|
|
36
|
+
"GIT_INDEX_FILE",
|
|
37
|
+
"GIT_OBJECT_DIRECTORY",
|
|
38
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
39
|
+
"GIT_NAMESPACE",
|
|
40
|
+
"GIT_PREFIX",
|
|
41
|
+
"GIT_SHALLOW_FILE",
|
|
42
|
+
"GIT_GRAFT_FILE",
|
|
43
|
+
"GIT_REPLACE_REF_BASE",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def git_environment() -> dict[str, str]:
|
|
49
|
+
"""The caller's environment without inherited repository selection."""
|
|
50
|
+
environment = {
|
|
51
|
+
key: value
|
|
52
|
+
for key, value in os.environ.items()
|
|
53
|
+
if key not in REPOSITORY_SELECTION_ENVIRONMENT
|
|
54
|
+
}
|
|
55
|
+
environment["LC_ALL"] = "C"
|
|
56
|
+
return environment
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _git(
|
|
60
|
+
repo: Path,
|
|
61
|
+
*arguments: str,
|
|
62
|
+
check: bool = True,
|
|
63
|
+
readonly: bool = True,
|
|
64
|
+
timeout: float = GIT_COMMAND_TIMEOUT_SECONDS,
|
|
65
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
66
|
+
environment = git_environment()
|
|
67
|
+
environment["GIT_TERMINAL_PROMPT"] = "0"
|
|
68
|
+
environment["GIT_ASKPASS"] = "/bin/false"
|
|
69
|
+
environment["SSH_ASKPASS"] = "/bin/false"
|
|
70
|
+
# Authority, integration and retirement evidence must share the stored DAG.
|
|
71
|
+
# Repository-local replacement refs cannot redefine commit ancestry or trees.
|
|
72
|
+
environment["GIT_NO_REPLACE_OBJECTS"] = "1"
|
|
73
|
+
if readonly:
|
|
74
|
+
environment["GIT_OPTIONAL_LOCKS"] = "0"
|
|
75
|
+
command = ["git"]
|
|
76
|
+
if readonly:
|
|
77
|
+
command.append("--no-optional-locks")
|
|
78
|
+
command.extend(("-C", os.fspath(repo), *arguments))
|
|
79
|
+
try:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
command,
|
|
82
|
+
env=environment,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
check=False,
|
|
85
|
+
stdin=subprocess.DEVNULL,
|
|
86
|
+
timeout=timeout,
|
|
87
|
+
)
|
|
88
|
+
except subprocess.TimeoutExpired as exc:
|
|
89
|
+
raise ControllerError(
|
|
90
|
+
"GIT_TIMEOUT",
|
|
91
|
+
f"git {arguments[0] if arguments else ''} exceeded {timeout}s and was terminated",
|
|
92
|
+
) from exc
|
|
93
|
+
if check and result.returncode != 0:
|
|
94
|
+
message = result.stderr.decode("utf-8", "replace").strip() or "Git command failed"
|
|
95
|
+
raise ControllerError("GIT_ERROR", message)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _text(repo: Path, *arguments: str) -> str:
|
|
100
|
+
return _git(repo, *arguments).stdout.decode("utf-8", "strict").strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_repository(path: Path) -> tuple[Path, Path]:
|
|
104
|
+
requested = path.expanduser().resolve(strict=True)
|
|
105
|
+
root = Path(_text(requested, "rev-parse", "--show-toplevel")).resolve(strict=True)
|
|
106
|
+
common = Path(_text(root, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve(
|
|
107
|
+
strict=True
|
|
108
|
+
)
|
|
109
|
+
return root, common
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def commit_tree_oid(repo: Path, commit: str) -> str:
|
|
113
|
+
"""Resolve the immutable tree identity for an already validated commit OID."""
|
|
114
|
+
|
|
115
|
+
oid = _text(repo, "rev-parse", "--verify", f"{commit}^{{tree}}")
|
|
116
|
+
if len(oid) != 40 or any(character not in "0123456789abcdef" for character in oid):
|
|
117
|
+
raise ControllerError("GIT_PARSE_ERROR", "Git returned an invalid tree OID")
|
|
118
|
+
return oid
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_porcelain(payload: bytes) -> list[dict[str, str | bool]]:
|
|
122
|
+
records: list[dict[str, str | bool]] = []
|
|
123
|
+
current: dict[str, str | bool] = {}
|
|
124
|
+
for raw in payload.split(b"\0"):
|
|
125
|
+
if raw == b"":
|
|
126
|
+
if current:
|
|
127
|
+
records.append(current)
|
|
128
|
+
current = {}
|
|
129
|
+
continue
|
|
130
|
+
token = os.fsdecode(raw)
|
|
131
|
+
if token.startswith("worktree "):
|
|
132
|
+
if current:
|
|
133
|
+
records.append(current)
|
|
134
|
+
current = {"path": token.removeprefix("worktree ")}
|
|
135
|
+
elif token.startswith("HEAD "):
|
|
136
|
+
current["head"] = token.removeprefix("HEAD ")
|
|
137
|
+
elif token.startswith("branch "):
|
|
138
|
+
current["branch"] = token.removeprefix("branch ")
|
|
139
|
+
elif token == "detached":
|
|
140
|
+
current["detached"] = True
|
|
141
|
+
elif token.startswith("locked"):
|
|
142
|
+
current["locked"] = True
|
|
143
|
+
current["lock_reason"] = token.removeprefix("locked").strip()
|
|
144
|
+
elif token.startswith("prunable"):
|
|
145
|
+
current["prunable"] = True
|
|
146
|
+
current["prunable_reason"] = token.removeprefix("prunable").strip()
|
|
147
|
+
elif token == "bare":
|
|
148
|
+
current["bare"] = True
|
|
149
|
+
else:
|
|
150
|
+
raise ControllerError("GIT_PARSE_ERROR", f"unknown worktree porcelain field: {token}")
|
|
151
|
+
if current:
|
|
152
|
+
records.append(current)
|
|
153
|
+
if not records:
|
|
154
|
+
raise ControllerError("GIT_PARSE_ERROR", "Git returned no registered worktrees")
|
|
155
|
+
return records
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _linked_git_dir(common: Path, path: Path) -> Path:
|
|
159
|
+
if path.exists():
|
|
160
|
+
return Path(_text(path, "rev-parse", "--absolute-git-dir")).resolve(strict=True)
|
|
161
|
+
admin_root = common / "worktrees"
|
|
162
|
+
if admin_root.is_dir():
|
|
163
|
+
expected = os.fspath(path / ".git")
|
|
164
|
+
for candidate in sorted(admin_root.iterdir()):
|
|
165
|
+
pointer = candidate / "gitdir"
|
|
166
|
+
if not pointer.is_file():
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
target = pointer.read_text(encoding="utf-8").strip()
|
|
170
|
+
except (OSError, UnicodeError):
|
|
171
|
+
continue
|
|
172
|
+
if target == expected:
|
|
173
|
+
return candidate.resolve(strict=True)
|
|
174
|
+
raise ControllerError("GIT_DRIFT", f"cannot resolve linked worktree admin dir: {path}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def worktree_key(common: Path, git_dir: Path, canonical: bool) -> str:
|
|
178
|
+
identity = "canonical" if canonical else os.fspath(git_dir.relative_to(common))
|
|
179
|
+
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
|
180
|
+
return f"wt_{digest}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _status_counts(path: Path) -> tuple[bool, int, int, int]:
|
|
184
|
+
payload = _git(path, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
|
|
185
|
+
staged = 0
|
|
186
|
+
modified = 0
|
|
187
|
+
untracked = 0
|
|
188
|
+
entries = iter(payload.split(b"\0"))
|
|
189
|
+
for raw in entries:
|
|
190
|
+
if not raw:
|
|
191
|
+
continue
|
|
192
|
+
if raw.startswith(b"??"):
|
|
193
|
+
untracked += 1
|
|
194
|
+
continue
|
|
195
|
+
if len(raw) < 4 or raw[2:3] != b" ":
|
|
196
|
+
raise ControllerError("GIT_PARSE_ERROR", "invalid status porcelain record")
|
|
197
|
+
x = chr(raw[0])
|
|
198
|
+
y = chr(raw[1])
|
|
199
|
+
# Porcelain v1 -z emits destination NUL source NUL for renames/copies.
|
|
200
|
+
# The source is a path, even when its bytes look like a status row.
|
|
201
|
+
if (x in {"R", "C"} or y in {"R", "C"}) and not next(entries, b""):
|
|
202
|
+
raise ControllerError("GIT_PARSE_ERROR", "missing rename/copy source path")
|
|
203
|
+
if x not in {" ", "?", "!"}:
|
|
204
|
+
staged += 1
|
|
205
|
+
if y not in {" ", "?", "!"}:
|
|
206
|
+
modified += 1
|
|
207
|
+
return not payload, staged, modified, untracked
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _observe_each[T](function: Callable[[Path], T], paths: Sequence[Path]) -> list[Future[T]]:
|
|
211
|
+
"""Run independent per-worktree observations concurrently, in input order.
|
|
212
|
+
|
|
213
|
+
Every observation completes before this returns. Callers consume the results in
|
|
214
|
+
order, so `Future.result()` re-raises the same first failure a sequential loop would.
|
|
215
|
+
"""
|
|
216
|
+
if not paths:
|
|
217
|
+
return []
|
|
218
|
+
with ThreadPoolExecutor(max_workers=min(WORKTREE_OBSERVATION_WORKERS, len(paths))) as pool:
|
|
219
|
+
return [pool.submit(function, item) for item in paths]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def inventory(path: Path) -> RepositoryInventory:
|
|
223
|
+
requested_root, common = resolve_repository(path)
|
|
224
|
+
raw = _git(requested_root, "worktree", "list", "--porcelain", "-z").stdout
|
|
225
|
+
parsed = _parse_porcelain(raw)
|
|
226
|
+
# Git's main entry can name the common directory itself after
|
|
227
|
+
# --separate-git-dir. Resolve the canonical checkout from Git's explicit
|
|
228
|
+
# core.worktree mapping, or from the requested main checkout's Git identity.
|
|
229
|
+
if Path(str(parsed[0]["path"])).resolve() == common and not parsed[0].get("bare"):
|
|
230
|
+
configured = _git(requested_root, "config", "--path", "--get", "core.worktree", check=False)
|
|
231
|
+
if configured.returncode == 0:
|
|
232
|
+
canonical = Path(os.fsdecode(configured.stdout.rstrip(b"\n")))
|
|
233
|
+
if not canonical.is_absolute():
|
|
234
|
+
canonical = common / canonical
|
|
235
|
+
elif Path(_text(requested_root, "rev-parse", "--absolute-git-dir")).resolve() == common:
|
|
236
|
+
canonical = requested_root
|
|
237
|
+
else:
|
|
238
|
+
raise ControllerError(
|
|
239
|
+
"GIT_DRIFT", "separate Git directory requires a core.worktree canonical mapping"
|
|
240
|
+
)
|
|
241
|
+
canonical = canonical.resolve(strict=True)
|
|
242
|
+
if (
|
|
243
|
+
Path(_text(canonical, "rev-parse", "--absolute-git-dir")).resolve() != common
|
|
244
|
+
or Path(_text(canonical, "rev-parse", "--show-toplevel")).resolve() != canonical
|
|
245
|
+
):
|
|
246
|
+
raise ControllerError("GIT_DRIFT", "separate Git directory canonical mapping differs")
|
|
247
|
+
parsed[0]["path"] = os.fspath(canonical)
|
|
248
|
+
canonical_path = Path(str(parsed[0]["path"])).resolve(strict=True)
|
|
249
|
+
present = [
|
|
250
|
+
record.get("bare") is not True and Path(str(record["path"])).exists() for record in parsed
|
|
251
|
+
]
|
|
252
|
+
observed = iter(
|
|
253
|
+
_observe_each(
|
|
254
|
+
_status_counts,
|
|
255
|
+
[
|
|
256
|
+
Path(str(record["path"]))
|
|
257
|
+
for record, exists in zip(parsed, present, strict=True)
|
|
258
|
+
if exists
|
|
259
|
+
],
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
worktrees: list[WorktreeGitState] = []
|
|
263
|
+
for index, record in enumerate(parsed):
|
|
264
|
+
if record.get("bare") is True:
|
|
265
|
+
raise ControllerError("GIT_ERROR", "bare worktrees are unsupported")
|
|
266
|
+
item_path = Path(str(record["path"]))
|
|
267
|
+
canonical = index == 0
|
|
268
|
+
git_dir = common if canonical else _linked_git_dir(common, item_path)
|
|
269
|
+
prunable = bool(record.get("prunable", False))
|
|
270
|
+
if present[index]:
|
|
271
|
+
clean, staged, modified, untracked = next(observed).result()
|
|
272
|
+
elif prunable:
|
|
273
|
+
clean, staged, modified, untracked = False, 0, 0, 0
|
|
274
|
+
else:
|
|
275
|
+
raise ControllerError("GIT_DRIFT", f"registered worktree path is missing: {item_path}")
|
|
276
|
+
branch_ref = record.get("branch")
|
|
277
|
+
branch = None
|
|
278
|
+
if isinstance(branch_ref, str):
|
|
279
|
+
branch = branch_ref.removeprefix("refs/heads/")
|
|
280
|
+
worktrees.append(
|
|
281
|
+
WorktreeGitState(
|
|
282
|
+
path=item_path,
|
|
283
|
+
git_dir=git_dir,
|
|
284
|
+
key=worktree_key(common, git_dir, canonical),
|
|
285
|
+
canonical=canonical,
|
|
286
|
+
head=str(record.get("head", "")),
|
|
287
|
+
branch=branch,
|
|
288
|
+
detached=bool(record.get("detached", False)),
|
|
289
|
+
locked=bool(record.get("locked", False)),
|
|
290
|
+
lock_reason=str(record.get("lock_reason") or "") or None,
|
|
291
|
+
prunable=prunable,
|
|
292
|
+
prunable_reason=str(record.get("prunable_reason") or "") or None,
|
|
293
|
+
clean=clean,
|
|
294
|
+
staged=staged,
|
|
295
|
+
modified=modified,
|
|
296
|
+
untracked=untracked,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
return RepositoryInventory(
|
|
300
|
+
requested_path=requested_root,
|
|
301
|
+
canonical_path=canonical_path,
|
|
302
|
+
common_dir=common,
|
|
303
|
+
worktrees=tuple(worktrees),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def resolve_commit(repo: Path, value: str) -> str:
|
|
308
|
+
if not value:
|
|
309
|
+
raise ControllerError("INVALID_COMMIT", "commit value is empty")
|
|
310
|
+
result = _git(repo, "rev-parse", "--verify", f"{value}^{{commit}}", check=False)
|
|
311
|
+
if result.returncode != 0:
|
|
312
|
+
raise ControllerError("INVALID_COMMIT", f"commit is not resolvable: {value}")
|
|
313
|
+
oid = result.stdout.decode("ascii", "strict").strip()
|
|
314
|
+
if len(oid) != 40 or any(character not in "0123456789abcdef" for character in oid):
|
|
315
|
+
raise ControllerError("INVALID_COMMIT", "Git returned a non-SHA-1 commit identity")
|
|
316
|
+
return oid
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def validate_branch_name(repo: Path, branch: str) -> None:
|
|
320
|
+
if not branch or branch.startswith("-"):
|
|
321
|
+
raise ControllerError("INVALID_BRANCH", "branch name is empty or unsafe")
|
|
322
|
+
result = _git(repo, "check-ref-format", "--branch", branch, check=False)
|
|
323
|
+
if result.returncode != 0:
|
|
324
|
+
raise ControllerError("INVALID_BRANCH", f"invalid branch name: {branch}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def branch_exists(repo: Path, branch: str) -> bool:
|
|
328
|
+
return (
|
|
329
|
+
_git(
|
|
330
|
+
repo, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False
|
|
331
|
+
).returncode
|
|
332
|
+
== 0
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
|
|
337
|
+
ancestor_oid = resolve_commit(repo, ancestor)
|
|
338
|
+
descendant_oid = resolve_commit(repo, descendant)
|
|
339
|
+
return (
|
|
340
|
+
_git(
|
|
341
|
+
repo,
|
|
342
|
+
"merge-base",
|
|
343
|
+
"--is-ancestor",
|
|
344
|
+
ancestor_oid,
|
|
345
|
+
descendant_oid,
|
|
346
|
+
check=False,
|
|
347
|
+
).returncode
|
|
348
|
+
== 0
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def changed_paths(repo: Path, base: str, head: str) -> tuple[str, ...]:
|
|
353
|
+
base_oid = resolve_commit(repo, base)
|
|
354
|
+
head_oid = resolve_commit(repo, head)
|
|
355
|
+
payload = _git(
|
|
356
|
+
repo, "--no-replace-objects", "diff", "--name-only", "-z", base_oid, head_oid
|
|
357
|
+
).stdout
|
|
358
|
+
return tuple(sorted(os.fsdecode(item) for item in payload.split(b"\0") if item))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def trusted_publication_base(repo: Path, base: str, head: str, ref: str) -> str:
|
|
362
|
+
"""Advance only through one unmodified DAG and an observed forward-only formal ref.
|
|
363
|
+
|
|
364
|
+
A retained history suffix must include the frozen task base. History that no
|
|
365
|
+
longer covers that base, replacement graphs and rewritten refs cannot supply
|
|
366
|
+
attribution evidence. The frozen allocation base is never changed.
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
_root, common = resolve_repository(repo)
|
|
370
|
+
if _text(repo, "rev-parse", "--is-shallow-repository") != "false":
|
|
371
|
+
return base
|
|
372
|
+
if (common / "info" / "grafts").exists():
|
|
373
|
+
return base
|
|
374
|
+
if _text(repo, "for-each-ref", "--format=%(refname)", "refs/replace/"):
|
|
375
|
+
return base
|
|
376
|
+
if os.environ.get("GIT_REPLACE_REF_BASE") or os.environ.get("GIT_SHALLOW_FILE"):
|
|
377
|
+
return base
|
|
378
|
+
if _git(repo, "symbolic-ref", "--quiet", ref, check=False).returncode != 1:
|
|
379
|
+
return base
|
|
380
|
+
publication = resolve_commit(repo, ref)
|
|
381
|
+
history = (common / "logs" / ref).read_text(encoding="utf-8").splitlines()
|
|
382
|
+
if not history:
|
|
383
|
+
return base
|
|
384
|
+
|
|
385
|
+
def ancestor(left: str, right: str) -> bool:
|
|
386
|
+
return (
|
|
387
|
+
_git(
|
|
388
|
+
repo,
|
|
389
|
+
"--no-replace-objects",
|
|
390
|
+
"merge-base",
|
|
391
|
+
"--is-ancestor",
|
|
392
|
+
left,
|
|
393
|
+
right,
|
|
394
|
+
check=False,
|
|
395
|
+
).returncode
|
|
396
|
+
== 0
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
previous = None
|
|
400
|
+
begins_at_creation = False
|
|
401
|
+
observed_base = False
|
|
402
|
+
for line in history:
|
|
403
|
+
old, new = line.split(" ", 2)[:2]
|
|
404
|
+
if any(
|
|
405
|
+
len(oid) != 40 or any(c not in "0123456789abcdef" for c in oid)
|
|
406
|
+
for oid in (old, new)
|
|
407
|
+
):
|
|
408
|
+
return base
|
|
409
|
+
if previous is None:
|
|
410
|
+
begins_at_creation = old == "0" * 40
|
|
411
|
+
if previous is not None and old != previous:
|
|
412
|
+
return base
|
|
413
|
+
if old != "0" * 40 and not ancestor(old, new):
|
|
414
|
+
return base
|
|
415
|
+
observed_base = observed_base or base in (old, new)
|
|
416
|
+
previous = new
|
|
417
|
+
# Git may expire entries from before this task. Retain a positive formal
|
|
418
|
+
# ref observation of its exact base instead of requiring the ref's birth.
|
|
419
|
+
if not begins_at_creation and not observed_base:
|
|
420
|
+
return base
|
|
421
|
+
if previous != publication or not ancestor(base, publication) or not ancestor(base, head):
|
|
422
|
+
return base
|
|
423
|
+
result = _git(
|
|
424
|
+
repo, "--no-replace-objects", "merge-base", "--all", head, publication, check=False
|
|
425
|
+
)
|
|
426
|
+
candidates = result.stdout.decode("ascii").splitlines()
|
|
427
|
+
if result.returncode != 0 or len(candidates) != 1:
|
|
428
|
+
return base
|
|
429
|
+
candidate = candidates[0]
|
|
430
|
+
if not ancestor(base, candidate) or not ancestor(candidate, head):
|
|
431
|
+
return base
|
|
432
|
+
# The formal ref may have moved during these read-only checks.
|
|
433
|
+
if resolve_commit(repo, ref) != publication:
|
|
434
|
+
return base
|
|
435
|
+
return candidate
|
|
436
|
+
except (ControllerError, OSError, ValueError):
|
|
437
|
+
return base
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def commits_with_parents(repo: Path, base: str, head: str) -> tuple[tuple[str, ...], ...]:
|
|
441
|
+
base_oid = resolve_commit(repo, base)
|
|
442
|
+
head_oid = resolve_commit(repo, head)
|
|
443
|
+
payload = _text(repo, "rev-list", "--parents", f"{base_oid}..{head_oid}")
|
|
444
|
+
if not payload:
|
|
445
|
+
return ()
|
|
446
|
+
commits: list[tuple[str, ...]] = []
|
|
447
|
+
for line in payload.splitlines():
|
|
448
|
+
fields = tuple(line.split())
|
|
449
|
+
if not fields or any(
|
|
450
|
+
len(oid) != 40 or any(character not in "0123456789abcdef" for character in oid)
|
|
451
|
+
for oid in fields
|
|
452
|
+
):
|
|
453
|
+
raise ControllerError("GIT_PARSE_ERROR", "invalid commit topology")
|
|
454
|
+
commits.append(fields)
|
|
455
|
+
return tuple(commits)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def build_integration_candidate(
|
|
459
|
+
repo: Path,
|
|
460
|
+
base: str,
|
|
461
|
+
source_handoffs: tuple[str, ...],
|
|
462
|
+
*,
|
|
463
|
+
task_key: str,
|
|
464
|
+
) -> str:
|
|
465
|
+
"""Construct a candidate from immutable commits without touching a checkout or ref."""
|
|
466
|
+
|
|
467
|
+
candidate = resolve_commit(repo, base)
|
|
468
|
+
if not source_handoffs:
|
|
469
|
+
raise ControllerError("MISSING_INTEGRATION_SOURCE", "candidate requires frozen handoffs")
|
|
470
|
+
for source_value in source_handoffs:
|
|
471
|
+
source = resolve_commit(repo, source_value)
|
|
472
|
+
if is_ancestor(repo, source, candidate):
|
|
473
|
+
continue
|
|
474
|
+
if is_ancestor(repo, candidate, source):
|
|
475
|
+
candidate = source
|
|
476
|
+
continue
|
|
477
|
+
result = _git(
|
|
478
|
+
repo,
|
|
479
|
+
"merge-tree",
|
|
480
|
+
"--write-tree",
|
|
481
|
+
"--messages",
|
|
482
|
+
candidate,
|
|
483
|
+
source,
|
|
484
|
+
check=False,
|
|
485
|
+
readonly=False,
|
|
486
|
+
)
|
|
487
|
+
if result.returncode == 1:
|
|
488
|
+
raise ControllerError(
|
|
489
|
+
"INTEGRATION_CONFLICT",
|
|
490
|
+
f"frozen handoff {source} conflicts with the selected main base",
|
|
491
|
+
)
|
|
492
|
+
if result.returncode != 0:
|
|
493
|
+
raise ControllerError("GIT_ERROR", "git merge-tree failed for integration candidate")
|
|
494
|
+
lines = result.stdout.decode("utf-8", "replace").splitlines()
|
|
495
|
+
tree = lines[0].strip() if lines else ""
|
|
496
|
+
if len(tree) != 40 or any(character not in "0123456789abcdef" for character in tree):
|
|
497
|
+
raise ControllerError("GIT_PARSE_ERROR", "git merge-tree returned an invalid tree")
|
|
498
|
+
commit = _git(
|
|
499
|
+
repo,
|
|
500
|
+
"commit-tree",
|
|
501
|
+
tree,
|
|
502
|
+
"-p",
|
|
503
|
+
candidate,
|
|
504
|
+
"-p",
|
|
505
|
+
source,
|
|
506
|
+
"-m",
|
|
507
|
+
f"Integrate frozen handoff for {task_key}",
|
|
508
|
+
check=False,
|
|
509
|
+
readonly=False,
|
|
510
|
+
)
|
|
511
|
+
if commit.returncode != 0:
|
|
512
|
+
raise ControllerError("GIT_ERROR", "git commit-tree failed for integration candidate")
|
|
513
|
+
candidate = commit.stdout.decode("ascii", "strict").strip()
|
|
514
|
+
candidate = resolve_commit(repo, candidate)
|
|
515
|
+
return candidate
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def checkout_detached(repo: Path, commit: str) -> None:
|
|
519
|
+
oid = resolve_commit(repo, commit)
|
|
520
|
+
git_write(repo, ("checkout", "--detach", oid))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def update_branch_ref(repo: Path, branch: str, *, expected: str, new: str) -> None:
|
|
524
|
+
validate_branch_name(repo, branch)
|
|
525
|
+
expected_oid = resolve_commit(repo, expected)
|
|
526
|
+
new_oid = resolve_commit(repo, new)
|
|
527
|
+
git_write(repo, ("update-ref", f"refs/heads/{branch}", new_oid, expected_oid))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def checkout_branch(repo: Path, branch: str) -> None:
|
|
531
|
+
validate_branch_name(repo, branch)
|
|
532
|
+
git_write(repo, ("checkout", branch))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def fetch_remote_branch(repo: Path, remote: str, branch: str) -> str:
|
|
536
|
+
if REMOTE_NAME_PATTERN.fullmatch(remote) is None:
|
|
537
|
+
raise ControllerError("INVALID_REMOTE", f"remote name is unsafe: {remote}")
|
|
538
|
+
validate_branch_name(repo, branch)
|
|
539
|
+
if _git(repo, "remote", "get-url", remote, check=False).returncode != 0:
|
|
540
|
+
raise ControllerError("INVALID_REMOTE", f"remote is not configured: {remote}")
|
|
541
|
+
tracking_ref = f"refs/remotes/{remote}/{branch}"
|
|
542
|
+
result = _git(
|
|
543
|
+
repo,
|
|
544
|
+
"fetch",
|
|
545
|
+
"--no-tags",
|
|
546
|
+
"--no-write-fetch-head",
|
|
547
|
+
remote,
|
|
548
|
+
f"+refs/heads/{branch}:{tracking_ref}",
|
|
549
|
+
check=False,
|
|
550
|
+
readonly=False,
|
|
551
|
+
)
|
|
552
|
+
if result.returncode != 0:
|
|
553
|
+
raise ControllerError(
|
|
554
|
+
"PUBLICATION_FETCH_FAILED", "configured publication branch fetch failed"
|
|
555
|
+
)
|
|
556
|
+
return resolve_commit(repo, tracking_ref)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def path_is_ignored(repo: Path, relative_path: str) -> bool:
|
|
560
|
+
return _git(repo, "check-ignore", "--quiet", "--", relative_path, check=False).returncode == 0
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def tracked_paths(repo: Path, relative_path: str) -> tuple[str, ...]:
|
|
564
|
+
payload = _git(repo, "ls-files", "-z", "--", relative_path).stdout
|
|
565
|
+
return tuple(os.fsdecode(item) for item in payload.split(b"\0") if item)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def head_paths(repo: Path, relative_path: str) -> tuple[str, ...]:
|
|
569
|
+
"""Include committed paths even when their deletion has already been staged."""
|
|
570
|
+
payload = _git(repo, "ls-tree", "-r", "--name-only", "-z", "HEAD", "--", relative_path).stdout
|
|
571
|
+
return tuple(os.fsdecode(item) for item in payload.split(b"\0") if item)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def ignored_paths(path: Path) -> tuple[str, ...]:
|
|
575
|
+
payload = _git(
|
|
576
|
+
path,
|
|
577
|
+
"status",
|
|
578
|
+
"--porcelain=v1",
|
|
579
|
+
"-z",
|
|
580
|
+
"--ignored=matching",
|
|
581
|
+
"--untracked-files=all",
|
|
582
|
+
).stdout
|
|
583
|
+
ignored: list[str] = []
|
|
584
|
+
for raw in payload.split(b"\0"):
|
|
585
|
+
if not raw:
|
|
586
|
+
continue
|
|
587
|
+
if raw.startswith(b"!! "):
|
|
588
|
+
ignored.append(os.fsdecode(raw[3:]))
|
|
589
|
+
return tuple(sorted(ignored))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def git_state_fingerprint(repository: RepositoryInventory) -> str:
|
|
593
|
+
digest = hashlib.sha256()
|
|
594
|
+
digest.update(
|
|
595
|
+
_git(
|
|
596
|
+
repository.canonical_path,
|
|
597
|
+
"for-each-ref",
|
|
598
|
+
"--format=%(refname)%00%(objectname)%00%(symref)",
|
|
599
|
+
).stdout
|
|
600
|
+
)
|
|
601
|
+
digest.update(_git(repository.canonical_path, "worktree", "list", "--porcelain", "-z").stdout)
|
|
602
|
+
present = [item.path.exists() for item in repository.worktrees]
|
|
603
|
+
observed = iter(
|
|
604
|
+
_observe_each(
|
|
605
|
+
_index_and_status,
|
|
606
|
+
[
|
|
607
|
+
item.path
|
|
608
|
+
for item, exists in zip(repository.worktrees, present, strict=True)
|
|
609
|
+
if exists
|
|
610
|
+
],
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
for item, exists in zip(repository.worktrees, present, strict=True):
|
|
614
|
+
digest.update(os.fsencode(item.path))
|
|
615
|
+
digest.update(item.head.encode("ascii"))
|
|
616
|
+
digest.update((item.branch or "DETACHED").encode("utf-8"))
|
|
617
|
+
if not exists:
|
|
618
|
+
continue
|
|
619
|
+
index_bytes, status = next(observed).result()
|
|
620
|
+
digest.update(index_bytes)
|
|
621
|
+
digest.update(status)
|
|
622
|
+
return digest.hexdigest()
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _index_and_status(path: Path) -> tuple[bytes, bytes]:
|
|
626
|
+
index_path = Path(_text(path, "rev-parse", "--path-format=absolute", "--git-path", "index"))
|
|
627
|
+
index_bytes = index_path.read_bytes()
|
|
628
|
+
status = _git(path, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
|
|
629
|
+
return index_bytes, status
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def public_refs(repo: Path) -> dict[str, str]:
|
|
633
|
+
payload = _git(
|
|
634
|
+
repo,
|
|
635
|
+
"for-each-ref",
|
|
636
|
+
"--format=%(refname)%00%(objectname)",
|
|
637
|
+
"refs/heads",
|
|
638
|
+
"refs/tags",
|
|
639
|
+
"refs/remotes",
|
|
640
|
+
).stdout
|
|
641
|
+
refs: dict[str, str] = {}
|
|
642
|
+
for raw in payload.splitlines():
|
|
643
|
+
if not raw:
|
|
644
|
+
continue
|
|
645
|
+
try:
|
|
646
|
+
raw_name, raw_oid = raw.split(b"\0", 1)
|
|
647
|
+
name, oid = os.fsdecode(raw_name), raw_oid.decode("ascii", "strict")
|
|
648
|
+
except (UnicodeError, ValueError) as exc:
|
|
649
|
+
raise ControllerError("GIT_PARSE_ERROR", "invalid ref inventory") from exc
|
|
650
|
+
if not name or len(oid) != 40 or any(char not in "0123456789abcdef" for char in oid):
|
|
651
|
+
raise ControllerError("GIT_PARSE_ERROR", "invalid ref inventory")
|
|
652
|
+
refs[name] = oid
|
|
653
|
+
return refs
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def worktree_content_fingerprint(
|
|
657
|
+
repository: RepositoryInventory, *, exclude_keys: frozenset[str] = frozenset()
|
|
658
|
+
) -> str:
|
|
659
|
+
digest = hashlib.sha256()
|
|
660
|
+
for item in repository.worktrees:
|
|
661
|
+
if item.key in exclude_keys:
|
|
662
|
+
continue
|
|
663
|
+
digest.update(item.key.encode("ascii"))
|
|
664
|
+
digest.update(os.fsencode(item.path))
|
|
665
|
+
digest.update(item.head.encode("ascii"))
|
|
666
|
+
digest.update((item.branch or "DETACHED").encode("utf-8"))
|
|
667
|
+
digest.update(b"1" if item.locked else b"0")
|
|
668
|
+
digest.update(b"1" if item.prunable else b"0")
|
|
669
|
+
if not item.path.exists():
|
|
670
|
+
continue
|
|
671
|
+
index_path = Path(
|
|
672
|
+
_text(item.path, "rev-parse", "--path-format=absolute", "--git-path", "index")
|
|
673
|
+
)
|
|
674
|
+
digest.update(index_path.read_bytes())
|
|
675
|
+
digest.update(
|
|
676
|
+
_git(item.path, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
|
|
677
|
+
)
|
|
678
|
+
return digest.hexdigest()
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def git_write(repo: Path, arguments: Iterable[str]) -> subprocess.CompletedProcess[bytes]:
|
|
682
|
+
return _git(repo, *tuple(arguments), readonly=False)
|