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.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. 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)