ctrlrelay 0.10.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.
Files changed (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1183 @@
1
+ """PersonalizationManager — clone, sync, wire symlinks for the
2
+ operator's cross-machine personalization repo.
3
+
4
+ Lifecycle:
5
+ init — clone the repo (or refuse if checkout already populated),
6
+ check out the per-machine working branch, wire symlinks.
7
+ wire_symlinks — (re-)apply the ``paths`` config; idempotent.
8
+ status — print working-tree state + per-symlink correctness.
9
+ push — stage allowlisted paths, commit on per-machine branch,
10
+ fetch, rebase onto ``main_branch``. Clean rebase → push
11
+ branch + FF-push ``main_branch``. Conflict → abort rebase
12
+ and surface the conflict files (Telegram escalation comes
13
+ in Slice 2).
14
+ pull — fetch, rebase local working branch onto origin/<main>,
15
+ FF local main, re-wire symlinks (config-as-code may have
16
+ changed under us).
17
+
18
+ Design choices that are easy to miss in review:
19
+
20
+ * The personalization checkout is a regular ``git clone``, NOT a
21
+ ctrlrelay worktree-from-bare. Worktree-per-session semantics fight
22
+ the requirement that Claude writes flow straight to the working tree
23
+ in real time.
24
+ * Per-machine branches (``personalization/<node_id>``) are the
25
+ conflict-avoidance mechanism. Each machine pushes its own branch and
26
+ then attempts a fast-forward of ``main``. Two machines pushing
27
+ simultaneously: one wins the FF, the other's next push starts with a
28
+ rebase onto the new ``main`` and tries again — never force-pushes,
29
+ never silently drops the other side's deltas.
30
+ * Symlinks point INTO the checkout, not the other way around. ``git
31
+ status`` is therefore the source of truth for "what changed since
32
+ last push" — no filesystem watcher needed.
33
+ * ``git`` is invoked synchronously via ``subprocess.run``. The rest of
34
+ the codebase is asyncio but the CLI commands using this manager are
35
+ Typer (sync), so threading an event loop through is overhead with no
36
+ payoff for a tool that runs ``git`` a handful of times per command.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import os
42
+ import re
43
+ import shutil
44
+ import subprocess
45
+ from collections.abc import Iterable
46
+ from dataclasses import dataclass
47
+ from pathlib import Path
48
+
49
+ from ctrlrelay.core.config import Config, Personalization, PersonalizationPath
50
+ from ctrlrelay.gh_protocol import detect_git_protocol, github_clone_url
51
+ from ctrlrelay.personalization.paths import (
52
+ TemplateContext,
53
+ project_slug,
54
+ resolve_template,
55
+ )
56
+
57
+
58
+ class PersonalizationError(Exception):
59
+ """Raised on any unrecoverable personalization-sync failure."""
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class SymlinkPlan:
64
+ """One concrete (resolved) source → target wiring decision.
65
+
66
+ Produced by ``PersonalizationManager._plan_symlinks`` from the
67
+ user's ``PersonalizationPath`` config + the available
68
+ ``RepoConfig`` rows. ``apply`` turns the plan into a real
69
+ filesystem mutation (idempotent).
70
+ """
71
+
72
+ source: Path # absolute path inside the checkout
73
+ target: Path # absolute path on disk where the symlink lives
74
+ is_dir: bool # whether source/target denote a directory (per template)
75
+ repo_name: str | None # populated for project_scoped entries
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class SymlinkResult:
80
+ """What ``apply`` actually did, for status/diagnostic display."""
81
+
82
+ plan: SymlinkPlan
83
+ action: str # "created" | "already-correct" | "replaced-stale-symlink"
84
+ # | "skipped-source-missing" | "skipped-real-file-at-target"
85
+ detail: str = ""
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class PushResult:
90
+ success: bool
91
+ summary: str
92
+ conflict_files: tuple[str, ...] = ()
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class PullResult:
97
+ success: bool
98
+ summary: str
99
+ conflict_files: tuple[str, ...] = ()
100
+
101
+
102
+ class PersonalizationManager:
103
+ """Orchestrates a personalization-sync repo on the local machine."""
104
+
105
+ def __init__(self, config: Config) -> None:
106
+ if config.personalization is None:
107
+ raise PersonalizationError(
108
+ "personalization is not configured in orchestrator.yaml"
109
+ )
110
+ self.config = config
111
+ self.cfg: Personalization = config.personalization
112
+ self.checkout_path: Path = self.cfg.checkout_path
113
+ # ``Config.personalization_branch`` is the single source of
114
+ # truth for the per-machine branch (handles default-from-top-
115
+ # level-node_id). Memoize so subsequent git invocations don't
116
+ # re-derive.
117
+ branch = config.personalization_branch()
118
+ if branch is None:
119
+ # Defensive — shouldn't happen given the personalization
120
+ # check above, but explicit beats AttributeError later.
121
+ raise PersonalizationError("personalization_branch is unset")
122
+ self.working_branch: str = branch
123
+ self.main_branch: str = self.cfg.main_branch
124
+ # Respect the operator's configured git protocol (gh's
125
+ # git_protocol setting) rather than assuming HTTPS — an
126
+ # operator set up for SSH-only GitHub auth would otherwise
127
+ # hit "could not read Username" on the clone below (#137).
128
+ self.repo_url: str = github_clone_url(
129
+ self.cfg.repo, protocol=detect_git_protocol()
130
+ )
131
+
132
+ # ----- top-level commands ------------------------------------------------
133
+
134
+ def init(self, *, adopt: bool = True) -> str:
135
+ """Clone the personalization repo into ``checkout_path`` and
136
+ wire symlinks. Refuses to overwrite an existing non-empty
137
+ directory; if the checkout already looks like a clone of the
138
+ right repo, falls through to ``wire_symlinks`` so a re-run is
139
+ idempotent.
140
+
141
+ ``adopt=True`` (the default since Slice 2) moves pre-existing
142
+ real files/dirs at target paths into the personalization
143
+ checkout and wires the symlink, eliminating the manual
144
+ adoption dance Slice 1 required. ``adopt=False`` preserves
145
+ the conservative Slice 1 behavior of skipping such targets
146
+ with a "back up and remove" instruction.
147
+
148
+ Returns a human-readable summary.
149
+ """
150
+ if self.checkout_path.exists():
151
+ # An empty directory is fine — ``git clone <url> <empty-dir>``
152
+ # works. Treat as not-yet-cloned and fall through.
153
+ is_empty_dir = (
154
+ self.checkout_path.is_dir()
155
+ and not any(self.checkout_path.iterdir())
156
+ )
157
+ if not is_empty_dir:
158
+ if not self._is_existing_checkout_ours():
159
+ raise PersonalizationError(
160
+ f"checkout_path {self.checkout_path} already exists "
161
+ f"and is not a clone of github.com:{self.cfg.repo}; "
162
+ "back it up or remove it before running init"
163
+ )
164
+ # Same repo already there — converge to the right
165
+ # branch and re-wire. Useful when ``init`` is re-run
166
+ # after a config change. ``_bootstrap_main_if_empty``
167
+ # also runs here because an existing clone of an
168
+ # empty remote still has unborn HEAD; without
169
+ # bootstrap, ``_ensure_working_branch`` would fail
170
+ # rev-parsing HEAD or origin/<main> (Codex pass 10
171
+ # caught this).
172
+ self._bootstrap_main_if_empty()
173
+ self._ensure_working_branch()
174
+ results = self.wire_symlinks(adopt=adopt)
175
+ return self._format_init_summary(results, cloned=False)
176
+
177
+ self.checkout_path.parent.mkdir(parents=True, exist_ok=True)
178
+ self._git_global("clone", self.repo_url, str(self.checkout_path))
179
+ self._bootstrap_main_if_empty()
180
+ self._ensure_working_branch()
181
+ results = self.wire_symlinks(adopt=adopt)
182
+ return self._format_init_summary(results, cloned=True)
183
+
184
+ def _bootstrap_main_if_empty(self) -> None:
185
+ """Create an initial commit on ``main_branch`` ONLY if the
186
+ remote has zero refs.
187
+
188
+ A brand-new GitHub repo (no commits, no default branch) clones
189
+ with an "unborn HEAD" and ``git ls-remote origin`` is empty —
190
+ bootstrap is safe and required. But if the remote has commits
191
+ under a DIFFERENT branch (typical when an existing repo uses
192
+ ``master`` and ``main_branch`` is misconfigured), we'd
193
+ otherwise silently create a parallel ``main_branch`` and push
194
+ it, which is rarely what the user wants. Treat that case as a
195
+ configuration error and surface it loudly with a hint.
196
+
197
+ Note: a missing local ``refs/remotes/origin/<main>`` is NOT
198
+ proof the remote lacks the branch — a ``--single-branch`` or
199
+ stale clone may not have fetched it. Always consult
200
+ ``ls-remote`` for the authoritative answer (Codex pass 11
201
+ caught this).
202
+ """
203
+ # Authoritative check against the remote: does it have
204
+ # refs/heads/<main_branch>?
205
+ ls = self._git_capturing("ls-remote", "origin", check=False)
206
+ if ls.returncode != 0:
207
+ # Network / auth failure: surface, don't silently bootstrap.
208
+ raise PersonalizationError(
209
+ f"`git ls-remote origin` failed: "
210
+ f"{ls.stderr.strip() or ls.stdout.strip()}"
211
+ )
212
+
213
+ # Parse ``<sha>\trefs/...`` lines.
214
+ remote_refs: list[str] = []
215
+ for line in ls.stdout.strip().splitlines():
216
+ parts = line.split("\t", 1)
217
+ if len(parts) == 2:
218
+ remote_refs.append(parts[1])
219
+
220
+ wanted_ref = f"refs/heads/{self.main_branch}"
221
+ if wanted_ref in remote_refs:
222
+ # Remote has main_branch. Make sure our local remote-
223
+ # tracking ref reflects it (single-branch clones may not
224
+ # have it without an explicit fetch) so subsequent
225
+ # operations can use ``origin/<main_branch>``.
226
+ self._git_capturing(
227
+ "fetch", "origin",
228
+ f"+{wanted_ref}:refs/remotes/origin/{self.main_branch}",
229
+ check=False,
230
+ )
231
+ return
232
+
233
+ if remote_refs:
234
+ actual = "\n".join(" " + r for r in remote_refs)
235
+ raise PersonalizationError(
236
+ f"remote {self.cfg.repo} has no branch named "
237
+ f"'{self.main_branch}', but the repo is not empty. "
238
+ "Set personalization.main_branch in orchestrator.yaml "
239
+ "to one of the existing remote refs (or rename the "
240
+ "remote branch). Existing refs:\n" + actual
241
+ )
242
+
243
+ # Truly empty remote — bootstrap.
244
+ self._ensure_local_identity()
245
+ readme = self.checkout_path / "README.md"
246
+ if not readme.exists():
247
+ readme.write_text(
248
+ "# personalization\n\n"
249
+ "Managed by ctrlrelay personalization sync. "
250
+ "See `ctrlrelay personalization --help`.\n"
251
+ )
252
+ self._git("checkout", "-b", self.main_branch)
253
+ self._git("add", "README.md")
254
+ self._git(
255
+ "commit", "-m",
256
+ "personalization: bootstrap empty repo",
257
+ )
258
+ self._git("push", "-u", "origin", self.main_branch)
259
+
260
+ def _ensure_local_identity(self) -> None:
261
+ """Ensure ``user.email`` and ``user.name`` are set on this
262
+ checkout so ``git commit`` doesn't fail with "Author identity
263
+ unknown".
264
+
265
+ Only writes when the value is missing — never overrides an
266
+ existing setting (operator's global ``~/.gitconfig`` values
267
+ come through to the cloned repo on default git settings, and
268
+ we don't want to silently shadow them). Used by both
269
+ bootstrap and the regular ``push`` commit path so a fresh
270
+ machine without global identity can still complete the first
271
+ sync.
272
+ """
273
+ if not self._git_capturing(
274
+ "config", "--get", "user.email", check=False
275
+ ).stdout.strip():
276
+ self._git("config", "user.email", "ctrlrelay@local")
277
+ if not self._git_capturing(
278
+ "config", "--get", "user.name", check=False
279
+ ).stdout.strip():
280
+ self._git("config", "user.name", "ctrlrelay")
281
+
282
+ def status(self) -> str:
283
+ """Return a human-readable summary of working-tree state +
284
+ symlink correctness. Read-only; does not fetch from origin.
285
+
286
+ Safe to run before ``init`` — the same path checks ``init``
287
+ uses to decide whether to clone (missing dir / empty dir /
288
+ non-checkout) all return a friendly "run init" message
289
+ instead of raising. Codex pass 14 caught the gap where an
290
+ existing non-checkout (e.g. empty dir or stray files) would
291
+ fall through to ``git rev-parse`` and traceback.
292
+ """
293
+ if not self.checkout_path.exists():
294
+ return (
295
+ f"checkout_path {self.checkout_path} does not exist; "
296
+ "run `ctrlrelay personalization init`"
297
+ )
298
+ if not (self.checkout_path / ".git").exists():
299
+ return (
300
+ f"checkout_path {self.checkout_path} exists but is not a "
301
+ "git checkout yet; run `ctrlrelay personalization init`"
302
+ )
303
+ # Unborn HEAD (clone of an empty repo, or interrupted init
304
+ # right after ``git clone``): ``rev-parse HEAD`` and
305
+ # ``rev-parse --abbrev-ref HEAD`` would both error. Detect
306
+ # and return the same friendly message instead of letting
307
+ # the error escape (Codex pass 19).
308
+ unborn = self._git_capturing(
309
+ "rev-parse", "--verify", "--quiet", "HEAD",
310
+ check=False,
311
+ )
312
+ if unborn.returncode != 0:
313
+ return (
314
+ f"checkout at {self.checkout_path} has unborn HEAD (no "
315
+ "commits yet); run `ctrlrelay personalization init` to "
316
+ "bootstrap"
317
+ )
318
+
319
+ lines: list[str] = []
320
+ lines.append(f"checkout: {self.checkout_path}")
321
+ lines.append(f"repo: {self.cfg.repo}")
322
+ lines.append(f"branch: {self.working_branch} (main: {self.main_branch})")
323
+
324
+ current = self._current_branch()
325
+ if current != self.working_branch:
326
+ lines.append(
327
+ f" ⚠ HEAD is on {current!r}, not {self.working_branch!r}"
328
+ )
329
+
330
+ porcelain = self._git("status", "--porcelain").strip()
331
+ if porcelain:
332
+ lines.append("dirty working tree:")
333
+ for entry in porcelain.splitlines():
334
+ lines.append(f" {entry}")
335
+ else:
336
+ lines.append("working tree clean")
337
+
338
+ plans = list(self._plan_symlinks())
339
+ if plans:
340
+ lines.append(f"symlinks ({len(plans)} entries):")
341
+ for plan in plans:
342
+ state = self._inspect_symlink(plan)
343
+ lines.append(f" [{state}] {plan.target} -> {plan.source}")
344
+ else:
345
+ lines.append("no symlinks configured")
346
+
347
+ return "\n".join(lines)
348
+
349
+ def wire_symlinks(self, *, adopt: bool = True) -> list[SymlinkResult]:
350
+ """Apply the ``paths`` config to the filesystem. Idempotent.
351
+
352
+ For each plan: create missing symlinks, replace pointing-
353
+ elsewhere symlinks, skip entries whose source doesn't exist
354
+ in the checkout yet, and (when ``adopt=True``, the default)
355
+ adopt pre-existing target files/dirs by moving them into the
356
+ checkout. ``adopt=False`` reverts to Slice 1 behavior:
357
+ refuse to clobber a real file/dir at the target.
358
+ """
359
+ results: list[SymlinkResult] = []
360
+ for plan in self._plan_symlinks():
361
+ results.append(self._apply_symlink(plan, adopt=adopt))
362
+ return results
363
+
364
+ # Bounded retries for the rebase + branch-push + FF-push cycle on
365
+ # FF rejection. Codex review caught the earlier silent-success-on-
366
+ # FF-rejection bug: if origin/main moved between our fetch and our
367
+ # FF-push, the per-machine branch lands on the remote but main
368
+ # stays at the racing machine's commit. Retrying refetches the new
369
+ # main, rebases on top, and tries again. Cap is small because the
370
+ # fleet should be ~3-5 machines and a hot loop here usually means
371
+ # a deeper problem (auth, branch protection, network) that won't
372
+ # fix itself.
373
+ _PUSH_MAX_ATTEMPTS = 3
374
+
375
+ def push(self, message: str | None = None) -> PushResult:
376
+ """Commit working-tree changes on the per-machine branch, then
377
+ rebase onto ``origin/<main>`` and push. On rebase conflict,
378
+ abort the rebase and return a ``PushResult`` listing the
379
+ conflict files; the working tree is left in its pre-rebase
380
+ state so the operator can resolve manually.
381
+
382
+ On a successful per-branch push but rejected FF of main
383
+ (concurrent push from another node), retries the cycle up to
384
+ ``_PUSH_MAX_ATTEMPTS`` times. Returns ``success=False`` if the
385
+ cycle never lands main — silently reporting success would
386
+ leave this node's commit reachable only via
387
+ ``origin/personalization/<node>``, never on main, which other
388
+ machines wouldn't see.
389
+ """
390
+ self._require_checkout()
391
+ self._ensure_working_branch()
392
+
393
+ # Reset the index to HEAD before staging. This unstages any
394
+ # ad-hoc ``git add`` the operator (or an interrupted previous
395
+ # run) left in the index, ensuring:
396
+ # 1. our commit's pathspec scope matches the actual diff,
397
+ # 2. ``git rebase`` doesn't refuse with "Your index contains
398
+ # uncommitted changes". Working-tree changes are
399
+ # preserved (mixed reset is index-only); the operator's
400
+ # out-of-allowlist files stay on disk and can be staged/
401
+ # committed by them manually after push completes. This
402
+ # pairs with the pathspec-scoped commit (Codex pass 12)
403
+ # to make the allowlist a hard guarantee.
404
+ self._git("reset", "--mixed")
405
+
406
+ # Stage and commit once up-front; the commit object stays the
407
+ # same across retries, only the rebase base shifts.
408
+ configured_pathspecs = self._stage_configured_paths()
409
+ if configured_pathspecs:
410
+ # ``git commit`` errors with "Author identity unknown" if
411
+ # neither global nor local user.email/user.name is set.
412
+ # Bootstrap already does this for the empty-repo path;
413
+ # call it here too so a fresh machine cloning a non-empty
414
+ # personalization repo can complete its first sync.
415
+ self._ensure_local_identity()
416
+ commit_msg = message or "personalization: sync from {}".format(
417
+ self.working_branch
418
+ )
419
+ # ``-- <pathspecs>`` constrains the commit to ONLY the
420
+ # allowlisted paths. Anything an operator manually staged
421
+ # outside the allowlist (Codex pass 12 finding — e.g.
422
+ # interrupted run, ad-hoc ``git add``) stays in the
423
+ # index but does NOT enter the personalization repo's
424
+ # history.
425
+ try:
426
+ self._git(
427
+ "commit", "-m", commit_msg, "--", *configured_pathspecs
428
+ )
429
+ except _GitError as e:
430
+ # ``git commit`` exits 1 with "nothing to commit" when
431
+ # the staged set turns out to be a no-op (e.g. only
432
+ # mode-bit churn). Treat as a no-op so push is
433
+ # idempotent.
434
+ if "nothing to commit" not in (e.stdout + e.stderr).lower():
435
+ raise
436
+
437
+ last_summary = ""
438
+ for attempt in range(1, self._PUSH_MAX_ATTEMPTS + 1):
439
+ try:
440
+ self._git("fetch", "origin", "--prune")
441
+ except _GitError as e:
442
+ return PushResult(
443
+ success=False,
444
+ summary=f"fetch failed: {e.stderr.strip() or e.stdout.strip()}",
445
+ )
446
+
447
+ rebase = self._git_capturing(
448
+ "rebase", f"origin/{self.main_branch}",
449
+ check=False,
450
+ )
451
+ if rebase.returncode != 0:
452
+ unmerged = self._git(
453
+ "diff", "--name-only", "--diff-filter=U"
454
+ ).strip()
455
+ self._git_capturing("rebase", "--abort", check=False)
456
+ files = tuple(unmerged.splitlines()) if unmerged else ()
457
+ return PushResult(
458
+ success=False,
459
+ summary=(
460
+ "rebase onto origin/{main} hit conflicts; aborted. "
461
+ "Resolve the listed files in the checkout, commit, "
462
+ "then re-run push."
463
+ ).format(main=self.main_branch),
464
+ conflict_files=files,
465
+ )
466
+
467
+ # Push the per-machine branch. Use ``--force-with-lease``
468
+ # whenever the local working branch has diverged from
469
+ # ``origin/<working_branch>`` (i.e., the rebase rewrote
470
+ # commits that origin already had). This is BROADER than
471
+ # just iteration > 1 within this call: if a previous
472
+ # invocation left ``origin/<working_branch>`` updated but
473
+ # ``origin/main`` not fast-forwarded (process interrupted,
474
+ # retry budget exhausted, etc.), the rebase at the top of
475
+ # this iteration rewrites the local commit and a plain
476
+ # push is non-FF on the per-machine branch. Codex review
477
+ # pass 9 caught this state-bridging case. Lease still
478
+ # protects against an unexpected concurrent update to the
479
+ # per-machine branch — per-machine branches are owned by
480
+ # exactly one node by design, so the lease almost always
481
+ # passes here, but if a stray update slips in we fail
482
+ # safely instead of clobbering it.
483
+ push_cmd: list[str] = ["push", "origin", self.working_branch]
484
+ if self._working_branch_diverged_from_origin():
485
+ push_cmd.insert(1, "--force-with-lease")
486
+ try:
487
+ self._git(*push_cmd)
488
+ except _GitError as e:
489
+ return PushResult(
490
+ success=False,
491
+ summary=(
492
+ f"push of {self.working_branch} failed: "
493
+ f"{e.stderr.strip() or e.stdout.strip()}"
494
+ ),
495
+ )
496
+
497
+ ff_push = self._git_capturing(
498
+ "push", "origin",
499
+ f"{self.working_branch}:{self.main_branch}",
500
+ check=False,
501
+ )
502
+ if ff_push.returncode == 0:
503
+ return PushResult(
504
+ success=True,
505
+ summary=(
506
+ f"pushed {self.working_branch} and fast-forwarded "
507
+ f"origin/{self.main_branch}"
508
+ + (
509
+ f" (after {attempt} attempts)"
510
+ if attempt > 1 else ""
511
+ )
512
+ ),
513
+ )
514
+ # FF rejected — another machine pushed to main between our
515
+ # fetch and our push. Loop and try again on top of the new
516
+ # tip. Capture the last error message in case we exhaust
517
+ # the retry budget.
518
+ last_summary = (
519
+ ff_push.stderr.strip() or ff_push.stdout.strip()
520
+ or "fast-forward of origin/main rejected"
521
+ )
522
+
523
+ return PushResult(
524
+ success=False,
525
+ summary=(
526
+ f"push of {self.working_branch} succeeded but origin/"
527
+ f"{self.main_branch} could not be fast-forwarded after "
528
+ f"{self._PUSH_MAX_ATTEMPTS} attempts ({last_summary}); "
529
+ "another node is racing pushes — re-run push to retry"
530
+ ),
531
+ )
532
+
533
+ def pull(self, *, adopt: bool = True) -> PullResult:
534
+ """Fetch and rebase the per-machine branch onto ``origin/<main>``.
535
+ FF-update local main if possible. Re-wire symlinks at the end
536
+ because the config-as-code shipped in the personalization repo
537
+ may have changed.
538
+
539
+ ``adopt`` is forwarded to :meth:`wire_symlinks`. The interactive
540
+ ``ctrlrelay personalization pull`` defaults to ``True`` (matches
541
+ ``init`` semantics: pre-existing real targets are moved into the
542
+ synced repo). The daemon-scheduled :meth:`auto_pull` passes
543
+ ``False`` so a background sync never silently moves operator
544
+ files.
545
+ """
546
+ self._require_checkout()
547
+ self._ensure_working_branch()
548
+
549
+ try:
550
+ self._git("fetch", "origin", "--prune")
551
+ except _GitError as e:
552
+ return PullResult(
553
+ success=False,
554
+ summary=f"fetch failed: {e.stderr.strip() or e.stdout.strip()}",
555
+ )
556
+
557
+ rebase = self._git_capturing(
558
+ "rebase", f"origin/{self.main_branch}",
559
+ check=False,
560
+ )
561
+ if rebase.returncode != 0:
562
+ unmerged = self._git("diff", "--name-only", "--diff-filter=U").strip()
563
+ self._git_capturing("rebase", "--abort", check=False)
564
+ files = tuple(unmerged.splitlines()) if unmerged else ()
565
+ return PullResult(
566
+ success=False,
567
+ summary=(
568
+ "rebase onto origin/{main} hit conflicts; aborted. "
569
+ "Resolve in the checkout, then re-run pull."
570
+ ).format(main=self.main_branch),
571
+ conflict_files=files,
572
+ )
573
+
574
+ # FF local main if it's a strict ancestor of origin/main.
575
+ ff = self._git_capturing(
576
+ "fetch", "origin",
577
+ f"{self.main_branch}:{self.main_branch}",
578
+ check=False,
579
+ )
580
+ # Re-wire (config may have changed; harmless if not).
581
+ self.wire_symlinks(adopt=adopt)
582
+
583
+ ff_note = "" if ff.returncode == 0 else (
584
+ f" (local {self.main_branch} not fast-forwarded — diverged "
585
+ "from origin)"
586
+ )
587
+ return PullResult(
588
+ success=True,
589
+ summary=(
590
+ f"pulled and rebased {self.working_branch} onto "
591
+ f"origin/{self.main_branch}{ff_note}"
592
+ ),
593
+ )
594
+
595
+ def auto_pull(self) -> PullResult:
596
+ """Pull variant for the daemon scheduler.
597
+
598
+ Differs from ``pull`` in two ways:
599
+
600
+ 1. Refuses to run when the working tree is dirty. The
601
+ operator may be mid-edit (writes go straight to the
602
+ personalization repo via the symlinks); rebasing under
603
+ them would lose the unsaved work, fail the rebase, or
604
+ both. Skip-on-dirty is the safe default.
605
+ 2. Refuses if no checkout exists yet, returning a benign
606
+ summary instead of raising — the daemon shouldn't crash
607
+ because an operator hasn't run ``init`` on this machine.
608
+
609
+ Conflicts during rebase still abort (same as ``pull``); the
610
+ daemon log records the conflict files. The re-wire phase runs
611
+ with ``adopt=False`` — a background sync must never silently
612
+ move operator files. Adoption stays an explicit init-time act.
613
+ """
614
+ if not (self.checkout_path / ".git").exists():
615
+ return PullResult(
616
+ success=True,
617
+ summary="auto-pull skipped: no checkout (run init first)",
618
+ )
619
+ # ``status --porcelain`` outputs nothing for a clean working
620
+ # tree; non-empty means there are uncommitted changes.
621
+ porcelain = self._git("status", "--porcelain").strip()
622
+ if porcelain:
623
+ return PullResult(
624
+ success=True,
625
+ summary=(
626
+ "auto-pull skipped: working tree dirty "
627
+ f"({len(porcelain.splitlines())} entries)"
628
+ ),
629
+ )
630
+ return self.pull(adopt=False)
631
+
632
+ # ----- internals: symlink planning + apply -------------------------------
633
+
634
+ def _plan_symlinks(self) -> Iterable[SymlinkPlan]:
635
+ """Yield resolved (source, target) plans from the config.
636
+
637
+ For non-project-scoped paths: one plan with the configured
638
+ source/target, resolved against ``${HOME}`` only.
639
+ For project-scoped paths: one plan per local repo whose
640
+ ``RepoConfig.local_path`` exists on disk. Repos that aren't
641
+ cloned on this machine are silently skipped (lazy wiring is a
642
+ feature — a memory entry for a project this machine doesn't
643
+ have is a no-op until the project arrives).
644
+ """
645
+ for entry in self.cfg.paths:
646
+ if entry.project_scoped:
647
+ yield from self._plan_project_scoped(entry)
648
+ else:
649
+ yield self._plan_global(entry)
650
+
651
+ def _plan_global(self, entry: PersonalizationPath) -> SymlinkPlan:
652
+ ctx = TemplateContext()
653
+ source = self.checkout_path / entry.source.lstrip("/")
654
+ target = resolve_template(entry.target, ctx)
655
+ return SymlinkPlan(
656
+ source=source,
657
+ target=target,
658
+ is_dir=entry.target.endswith("/"),
659
+ repo_name=None,
660
+ )
661
+
662
+ def _plan_project_scoped(
663
+ self, entry: PersonalizationPath
664
+ ) -> Iterable[SymlinkPlan]:
665
+ for repo in self.config.repos:
666
+ local = repo.local_path
667
+ if local is None or not local.exists():
668
+ continue
669
+ ctx = TemplateContext(
670
+ project=project_slug(repo.name),
671
+ project_local=local,
672
+ )
673
+ source_rel = resolve_template(entry.source, ctx)
674
+ # ``source`` is interpreted relative to the checkout root,
675
+ # but ``resolve_template`` returns an absolute Path on
676
+ # systems where the source happens to start with ``/``.
677
+ # Strip a leading ``/`` if present and re-anchor under the
678
+ # checkout. Any ``${HOME}``/``${PROJECT_LOCAL}`` etc. in
679
+ # the source field would be a config bug — we still defend
680
+ # against it by anchoring.
681
+ source = self.checkout_path / str(source_rel).lstrip("/")
682
+ target = resolve_template(entry.target, ctx)
683
+ yield SymlinkPlan(
684
+ source=source,
685
+ target=target,
686
+ is_dir=entry.target.endswith("/"),
687
+ repo_name=repo.name,
688
+ )
689
+
690
+ def _apply_symlink(
691
+ self, plan: SymlinkPlan, *, adopt: bool = True
692
+ ) -> SymlinkResult:
693
+ """Apply one symlink wiring decision.
694
+
695
+ With ``adopt=True`` (Slice 2 default), a target that is a real
696
+ file/dir AND a missing source triggers ADOPTION: the target is
697
+ moved into the checkout at the source location, then the
698
+ symlink is created. This eliminates the manual "move then
699
+ re-init" dance Slice 1 required.
700
+
701
+ Adoption is intentionally narrow: it only fires when the
702
+ source is missing. If BOTH source and target exist as real
703
+ content the manager refuses (``skipped-conflict-both-exist``)
704
+ — that needs operator judgment to reconcile.
705
+ """
706
+ source_exists = plan.source.exists()
707
+ target_is_real = plan.target.exists() and not plan.target.is_symlink()
708
+
709
+ if source_exists and target_is_real:
710
+ # Both populated — operator must reconcile manually.
711
+ return SymlinkResult(
712
+ plan=plan,
713
+ action="skipped-conflict-both-exist",
714
+ detail=(
715
+ "both the personalization repo's source and the on-disk "
716
+ "target have content; reconcile manually (move one into "
717
+ "the other or delete one) before re-running init"
718
+ ),
719
+ )
720
+
721
+ if not source_exists and target_is_real:
722
+ if not adopt:
723
+ return SymlinkResult(
724
+ plan=plan,
725
+ action="skipped-real-file-at-target",
726
+ detail=(
727
+ "adopt disabled (--no-adopt or wire(adopt=False)); "
728
+ "back up and remove the existing path before retrying"
729
+ ),
730
+ )
731
+ # Type sanity: refuse if target's on-disk shape doesn't
732
+ # match config's trailing-slash declaration. Better to
733
+ # surface the mismatch than to move a file into a slot
734
+ # the config says is a directory.
735
+ actual_is_dir = plan.target.is_dir()
736
+ if plan.is_dir != actual_is_dir:
737
+ expected = "directory" if plan.is_dir else "file"
738
+ actual = "directory" if actual_is_dir else "file"
739
+ return SymlinkResult(
740
+ plan=plan,
741
+ action="skipped-target-type-mismatch",
742
+ detail=(
743
+ f"config declares {expected} (trailing slash) but "
744
+ f"on-disk target {plan.target} is a {actual}; "
745
+ "fix the config or the target before retrying"
746
+ ),
747
+ )
748
+ # Adopt: move target into source location, then continue
749
+ # the wire path as if source had been there all along.
750
+ plan.source.parent.mkdir(parents=True, exist_ok=True)
751
+ shutil.move(str(plan.target), str(plan.source))
752
+ source_exists = True
753
+ adopted = True
754
+ else:
755
+ adopted = False
756
+
757
+ if not source_exists:
758
+ # Source missing AND no real-file target to adopt. Common
759
+ # for partial-repo state where another machine hasn't
760
+ # populated this entry yet. Skip without failing.
761
+ return SymlinkResult(plan=plan, action="skipped-source-missing")
762
+
763
+ # Source-side type check (existing Slice 1 invariant).
764
+ actual_is_dir = plan.source.is_dir()
765
+ if plan.is_dir != actual_is_dir:
766
+ expected = "directory" if plan.is_dir else "file"
767
+ actual = "directory" if actual_is_dir else "file"
768
+ return SymlinkResult(
769
+ plan=plan,
770
+ action="skipped-source-type-mismatch",
771
+ detail=(
772
+ f"config declares {expected} (target/source trailing "
773
+ f"slash) but on-disk source is a {actual}"
774
+ ),
775
+ )
776
+
777
+ plan.target.parent.mkdir(parents=True, exist_ok=True)
778
+
779
+ if plan.target.is_symlink():
780
+ current = plan.target.readlink()
781
+ if current == plan.source or self._same_resolved(current, plan.source):
782
+ return SymlinkResult(plan=plan, action="already-correct")
783
+ plan.target.unlink()
784
+ plan.target.symlink_to(plan.source)
785
+ return SymlinkResult(
786
+ plan=plan,
787
+ action="replaced-stale-symlink",
788
+ detail=f"was -> {current}",
789
+ )
790
+
791
+ plan.target.symlink_to(plan.source)
792
+ return SymlinkResult(
793
+ plan=plan,
794
+ action="adopted" if adopted else "created",
795
+ )
796
+
797
+ def _inspect_symlink(self, plan: SymlinkPlan) -> str:
798
+ """Read-only counterpart to ``_apply_symlink`` for ``status``."""
799
+ if not plan.source.exists():
800
+ return "source-missing"
801
+ if plan.is_dir != plan.source.is_dir():
802
+ expected = "directory" if plan.is_dir else "file"
803
+ actual = "directory" if plan.source.is_dir() else "file"
804
+ return f"source-type-mismatch (config={expected}, on-disk={actual})"
805
+ if plan.target.is_symlink():
806
+ current = plan.target.readlink()
807
+ if current == plan.source or self._same_resolved(current, plan.source):
808
+ return "ok"
809
+ return f"wrong-symlink->{current}"
810
+ if plan.target.exists():
811
+ return "real-file-blocking"
812
+ return "missing"
813
+
814
+ @staticmethod
815
+ def _same_resolved(a: Path, b: Path) -> bool:
816
+ try:
817
+ return a.resolve() == b.resolve()
818
+ except (OSError, RuntimeError):
819
+ return False
820
+
821
+ # ----- internals: git helpers --------------------------------------------
822
+
823
+ # Parses host + owner/repo out of any common GitHub remote URL:
824
+ # https://github.com/owner/repo(.git)?
825
+ # git@github.com:owner/repo(.git)?
826
+ # git://github.com/owner/repo(.git)?
827
+ # ssh://git@github.com/owner/repo(.git)?
828
+ # The host capture must be exactly ``github.com`` (case-
829
+ # insensitive). Owner/repo is captured separately and compared
830
+ # against ``cfg.repo``. Codex pass 20 caught that an earlier
831
+ # tail-only match accepted any host; a foreign clone with a
832
+ # matching tail (``https://evil.example/owner/repo.git``) would
833
+ # be wired against, leaking personalization data.
834
+ _ORIGIN_GITHUB_RE = re.compile(
835
+ r"^(?:"
836
+ r"https?://(?P<host_https>[^/]+)/"
837
+ r"|git@(?P<host_ssh>[^:]+):"
838
+ r"|git://(?P<host_git>[^/]+)/"
839
+ r"|ssh://(?:git@)?(?P<host_sshurl>[^/]+)/"
840
+ r")"
841
+ r"(?P<owner_repo>[^/:]+/[^/:]+?)"
842
+ r"(?:\.git)?/?\s*\Z",
843
+ re.IGNORECASE,
844
+ )
845
+
846
+ def _is_existing_checkout_ours(self) -> bool:
847
+ """Return True iff ``checkout_path`` is a clone of
848
+ ``github.com:<self.cfg.repo>``.
849
+
850
+ Tail-only matching (Codex pass 6 fix) was insufficient: an
851
+ existing checkout whose origin pointed at a non-github host
852
+ with the matching ``owner/repo`` tail would still be
853
+ accepted, and after the origin-URL reset (Codex pass 18 fix)
854
+ the foreign checkout's working tree / branch contents would
855
+ get wired into ``~/.claude/`` and friends. Now we require
856
+ the host to be ``github.com`` exactly. Anything else is
857
+ treated as not-ours and ``init`` refuses, telling the
858
+ operator to back up + remove the directory before retrying
859
+ (Codex pass 20 P1 fix).
860
+ """
861
+ if not (self.checkout_path / ".git").exists():
862
+ return False
863
+ try:
864
+ origin = self._git("remote", "get-url", "origin").strip()
865
+ except _GitError:
866
+ return False
867
+ match = self._ORIGIN_GITHUB_RE.match(origin)
868
+ if not match:
869
+ return False
870
+ host = (
871
+ match.group("host_https")
872
+ or match.group("host_ssh")
873
+ or match.group("host_git")
874
+ or match.group("host_sshurl")
875
+ or ""
876
+ )
877
+ if host.lower() != "github.com":
878
+ return False
879
+ return match.group("owner_repo").lower() == self.cfg.repo.lower()
880
+
881
+ def _ensure_working_branch(self) -> None:
882
+ """Make sure HEAD is on ``working_branch``, creating it from
883
+ ``main_branch`` if it doesn't exist on origin or locally.
884
+
885
+ Refreshes remote-tracking refs first via ``git fetch origin``
886
+ so an existing-but-stale checkout doesn't miss a per-node
887
+ branch that was created on origin after this clone last
888
+ fetched. Codex pass 16 caught this P1: without the fetch,
889
+ ``init`` on a stale clone would branch off main, then
890
+ ``push`` would later fetch and force-with-lease over commits
891
+ that only lived on the remote per-node branch.
892
+ """
893
+ current = self._current_branch()
894
+ if current == self.working_branch:
895
+ return
896
+ # Local branch already present? Just check it out — even if
897
+ # origin has a divergent copy we'll handle it on push (the
898
+ # rebase/lease logic is built for exactly that).
899
+ existing = self._git_capturing(
900
+ "show-ref", "--verify", "--quiet",
901
+ f"refs/heads/{self.working_branch}",
902
+ check=False,
903
+ )
904
+ if existing.returncode == 0:
905
+ self._git("checkout", self.working_branch)
906
+ return
907
+ # Refresh remote-tracking refs so the next show-ref reflects
908
+ # what origin actually has, not what we last knew. Best-
909
+ # effort — a fetch failure (offline, auth) shouldn't block
910
+ # branching off the local main; later push will surface the
911
+ # network/auth error properly.
912
+ self._git_capturing("fetch", "origin", "--prune", check=False)
913
+ existing_remote = self._git_capturing(
914
+ "show-ref", "--verify", "--quiet",
915
+ f"refs/remotes/origin/{self.working_branch}",
916
+ check=False,
917
+ )
918
+ if existing_remote.returncode == 0:
919
+ # Adopt the remote per-node branch instead of clobbering
920
+ # it later.
921
+ self._git(
922
+ "checkout", "-b", self.working_branch,
923
+ f"origin/{self.working_branch}",
924
+ )
925
+ return
926
+ # Brand new — branch off origin's main. ``origin/<main>``
927
+ # rather than the bare local name so a non-default
928
+ # ``main_branch`` (e.g. ``develop``) works on a fresh clone:
929
+ # ``git clone`` only checks out the repository's default
930
+ # branch locally, so ``git checkout -b ... develop`` would
931
+ # fail with "did not match any file(s) known to git". The
932
+ # remote ref is always present after ``fetch``/``clone``.
933
+ self._git(
934
+ "checkout", "-b", self.working_branch,
935
+ f"origin/{self.main_branch}",
936
+ )
937
+
938
+ def _current_branch(self) -> str:
939
+ return self._git("rev-parse", "--abbrev-ref", "HEAD").strip()
940
+
941
+ def _working_branch_diverged_from_origin(self) -> bool:
942
+ """Return True iff ``origin/<working_branch>`` exists and is
943
+ NOT an ancestor of the local ``working_branch``.
944
+
945
+ Used to decide whether the per-machine branch push needs
946
+ ``--force-with-lease``. Plain push works when:
947
+ - origin counterpart doesn't exist yet (first push), OR
948
+ - origin counterpart is an ancestor of local (FF possible).
949
+ Otherwise the rebase has rewritten commits that origin still
950
+ has on the per-machine branch and we need a leased force.
951
+ """
952
+ remote_exists = self._git_capturing(
953
+ "show-ref", "--verify", "--quiet",
954
+ f"refs/remotes/origin/{self.working_branch}",
955
+ check=False,
956
+ ).returncode == 0
957
+ if not remote_exists:
958
+ return False
959
+ # ``--is-ancestor A B`` exits 0 when A is ancestor of B (or
960
+ # equal). If origin is ancestor of local, FF push works.
961
+ is_ancestor = self._git_capturing(
962
+ "merge-base", "--is-ancestor",
963
+ f"origin/{self.working_branch}", self.working_branch,
964
+ check=False,
965
+ ).returncode == 0
966
+ return not is_ancestor
967
+
968
+ def _stage_configured_paths(self) -> list[str]:
969
+ """``git add -A`` each on-disk location of configured sources
970
+ and return the pathspecs that should bound the subsequent
971
+ ``git commit``.
972
+
973
+ The returned list is non-empty whenever the configured paths
974
+ produced ANY staged change — including tracked-file deletions
975
+ (Codex review pass 1 finding: filtering on ``Path.exists()``
976
+ would otherwise leave deletions out of the commit). The
977
+ caller MUST scope ``git commit`` to these pathspecs (Codex
978
+ pass 12 finding) so anything an operator pre-staged outside
979
+ the allowlist (manual ``git add``, interrupted run) does not
980
+ ride along into the personalization repo's history.
981
+
982
+ Tolerates ``pathspec did not match any files`` for paths that
983
+ are neither in the working tree nor tracked — typical for a
984
+ project_scoped entry whose source dir was never populated on
985
+ any machine.
986
+
987
+ Returns ``[]`` when nothing in the allowlist changed.
988
+ """
989
+ rels: list[str] = []
990
+ for entry in self.cfg.paths:
991
+ for plan in self._plan_for_entry(entry):
992
+ try:
993
+ rel = plan.source.relative_to(self.checkout_path)
994
+ except ValueError:
995
+ continue
996
+ rels.append(str(rel))
997
+ if not rels:
998
+ return []
999
+
1000
+ # Only paths that ``git add`` actually matched (path exists
1001
+ # in the working tree or is tracked) survive. The caller
1002
+ # passes the survivors to ``git commit -- <paths>``; commit
1003
+ # would fail with "pathspec did not match" if we left a
1004
+ # never-tracked, never-present path in the list (Codex
1005
+ # review pass 13 caught this — a normal partial-repo state
1006
+ # where one configured source is populated and another is
1007
+ # not blocked the whole push).
1008
+ matched: list[str] = []
1009
+ for rel in rels:
1010
+ result = self._git_capturing("add", "-A", "--", rel, check=False)
1011
+ if result.returncode == 0:
1012
+ matched.append(rel)
1013
+ continue
1014
+ if "did not match" in (result.stderr + result.stdout):
1015
+ # Benign — path is neither in the working tree nor
1016
+ # in the index. Skip it.
1017
+ continue
1018
+ raise _GitError(
1019
+ args=("add", "-A", "--", rel),
1020
+ returncode=result.returncode,
1021
+ stdout=result.stdout,
1022
+ stderr=result.stderr,
1023
+ )
1024
+
1025
+ if not matched:
1026
+ return []
1027
+
1028
+ # Only return the pathspecs if SOMETHING in the allowlist
1029
+ # actually has staged changes. Pre-staged files outside the
1030
+ # allowlist won't show up here because we restrict the diff
1031
+ # to the configured pathspecs.
1032
+ diff = self._git(
1033
+ "diff", "--cached", "--name-only", "--", *matched
1034
+ ).strip()
1035
+ return matched if diff else []
1036
+
1037
+ def _plan_for_entry(self, entry: PersonalizationPath) -> list[SymlinkPlan]:
1038
+ if entry.project_scoped:
1039
+ return list(self._plan_project_scoped(entry))
1040
+ return [self._plan_global(entry)]
1041
+
1042
+ def _require_checkout(self) -> None:
1043
+ if not (self.checkout_path / ".git").exists():
1044
+ raise PersonalizationError(
1045
+ f"no checkout at {self.checkout_path}; run "
1046
+ "`ctrlrelay personalization init` first"
1047
+ )
1048
+
1049
+ # ----- subprocess wrappers ------------------------------------------------
1050
+
1051
+ def _git(self, *args: str) -> str:
1052
+ """Run ``git <args>`` inside the checkout and return stdout.
1053
+
1054
+ Uses ``check=True`` semantics; failures raise ``_GitError``
1055
+ carrying captured stderr/stdout for the caller to inspect.
1056
+ """
1057
+ result = self._git_capturing(*args, check=True)
1058
+ return result.stdout
1059
+
1060
+ def _git_capturing(
1061
+ self, *args: str, check: bool = True
1062
+ ) -> subprocess.CompletedProcess:
1063
+ return _run_git(
1064
+ args,
1065
+ cwd=self.checkout_path,
1066
+ check=check,
1067
+ )
1068
+
1069
+ def _git_global(self, *args: str) -> str:
1070
+ """Run ``git <args>`` without a cwd inside the checkout (for
1071
+ operations like ``clone`` that target a path that doesn't
1072
+ exist yet).
1073
+ """
1074
+ result = _run_git(args, cwd=None, check=True)
1075
+ return result.stdout
1076
+
1077
+ # ----- formatting --------------------------------------------------------
1078
+
1079
+ def _format_init_summary(
1080
+ self, results: list[SymlinkResult], *, cloned: bool
1081
+ ) -> str:
1082
+ lines: list[str] = []
1083
+ if cloned:
1084
+ lines.append(f"cloned {self.cfg.repo} -> {self.checkout_path}")
1085
+ else:
1086
+ lines.append(
1087
+ f"checkout already at {self.checkout_path}; converged config"
1088
+ )
1089
+ lines.append(f"working branch: {self.working_branch}")
1090
+ if results:
1091
+ counts: dict[str, int] = {}
1092
+ for r in results:
1093
+ counts[r.action] = counts.get(r.action, 0) + 1
1094
+ for action, n in sorted(counts.items()):
1095
+ lines.append(f" {action}: {n}")
1096
+ else:
1097
+ lines.append(" (no symlinks configured)")
1098
+ return "\n".join(lines)
1099
+
1100
+
1101
+ # -----------------------------------------------------------------------------
1102
+ # Module-level helpers (kept outside the class so tests can patch them)
1103
+
1104
+
1105
+ class _GitError(PersonalizationError):
1106
+ """Raised when a git subprocess invoked by the manager exits non-zero.
1107
+
1108
+ Subclasses ``PersonalizationError`` so the CLI's ``PersonalizationError``
1109
+ handlers catch git failures (auth, network, unborn HEAD reads, etc.)
1110
+ and emit a clean error instead of a traceback. Codex pass 15 caught
1111
+ that bare ``Exception`` made these escape the CLI wrappers.
1112
+ """
1113
+
1114
+ def __init__(
1115
+ self, args: tuple[str, ...], returncode: int, stdout: str, stderr: str
1116
+ ):
1117
+ super().__init__(
1118
+ f"git {' '.join(args)} exited {returncode}: {stderr.strip() or stdout.strip()}"
1119
+ )
1120
+ self.args_run = args
1121
+ self.returncode = returncode
1122
+ self.stdout = stdout
1123
+ self.stderr = stderr
1124
+
1125
+
1126
+ def _run_git(
1127
+ args: tuple[str, ...] | list[str],
1128
+ *,
1129
+ cwd: Path | None,
1130
+ check: bool,
1131
+ ) -> subprocess.CompletedProcess:
1132
+ """Thin wrapper around ``subprocess.run(['git', ...])``.
1133
+
1134
+ Captures both stdout and stderr as text (utf-8 with replacement so
1135
+ a stray non-utf-8 byte from a remote can't blow up the orchestrator).
1136
+ Sets ``GIT_TERMINAL_PROMPT=0`` so an HTTP auth prompt against a
1137
+ private repo fails fast instead of hanging the daemon.
1138
+ """
1139
+ env = os.environ.copy()
1140
+ env.setdefault("GIT_TERMINAL_PROMPT", "0")
1141
+ # Treat all pathspecs as LITERAL filenames — never as git
1142
+ # pathspec magic (``:(top)``, ``:(glob)``, ``:!exclude``, etc.).
1143
+ # A config ``source: ":(top)secrets.txt"`` (or any other
1144
+ # magic prefix) would otherwise let ``git add -- <rel>`` reach
1145
+ # outside the intended allowlist (Codex pass 17 caught this).
1146
+ # Forced on the env so every subprocess invoked via ``_run_git``
1147
+ # gets the protection — manager-side validators already reject
1148
+ # ``:`` in sources but defense-in-depth here is cheap.
1149
+ env["GIT_LITERAL_PATHSPECS"] = "1"
1150
+ proc = subprocess.run(
1151
+ ["git", *args],
1152
+ cwd=str(cwd) if cwd else None,
1153
+ env=env,
1154
+ capture_output=True,
1155
+ text=True,
1156
+ encoding="utf-8",
1157
+ errors="replace",
1158
+ check=False,
1159
+ )
1160
+ if check and proc.returncode != 0:
1161
+ raise _GitError(
1162
+ args=tuple(args),
1163
+ returncode=proc.returncode,
1164
+ stdout=proc.stdout,
1165
+ stderr=proc.stderr,
1166
+ )
1167
+ return proc
1168
+
1169
+
1170
+ def remove_symlink(target: Path) -> None:
1171
+ """Best-effort symlink removal helper used by the CLI's
1172
+ ``unwire`` future command (Slice 2). Kept here so tests can hit it
1173
+ without spinning up a full manager.
1174
+ """
1175
+ if target.is_symlink():
1176
+ target.unlink()
1177
+ elif target.exists() and not target.is_symlink():
1178
+ # Real file/dir — refuse to delete; that's a manual operator
1179
+ # decision.
1180
+ raise PersonalizationError(
1181
+ f"refusing to delete real path at {target}; not a symlink"
1182
+ )
1183
+ # Missing is fine — nothing to remove.