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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- 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.
|