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,893 @@
|
|
|
1
|
+
"""Git worktree management for isolated sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Open-PR probe (issue #52) retries a handful of times before failing
|
|
16
|
+
# closed. Transient gh/network errors are common enough that one-shot
|
|
17
|
+
# failure would be noisy; but infinite retry would block retries when
|
|
18
|
+
# gh is genuinely down. Three quick attempts strike a balance.
|
|
19
|
+
_PR_PROBE_RETRY_ATTEMPTS = 3
|
|
20
|
+
_PR_PROBE_RETRY_SLEEP_SECONDS = 1.0
|
|
21
|
+
# Per-call gh timeout for the open-PR probe. This probe holds the repo
|
|
22
|
+
# lock, so inheriting GitHubCLI's 60s default would let a hung gh
|
|
23
|
+
# process block other sessions on the same repo for up to ~3 minutes
|
|
24
|
+
# (3 attempts × 60s) before failing closed. 10s is plenty for a
|
|
25
|
+
# single pr-list call with --head filter against GitHub.
|
|
26
|
+
_PR_PROBE_TIMEOUT_SECONDS = 10
|
|
27
|
+
|
|
28
|
+
# Network-bound git transfers (bare clone, fetch) get their own, much
|
|
29
|
+
# larger budget than the 120s that suits local plumbing. A 1.8 GB repo
|
|
30
|
+
# cannot clone or fetch its first time inside two minutes on any normal
|
|
31
|
+
# link, and the failure mode was silent: `asyncio.wait_for` raises a
|
|
32
|
+
# bare `TimeoutError` whose `str()` is empty, so the sweep reported
|
|
33
|
+
# "Error processing <repo>" with no cause attached and wrote neither a
|
|
34
|
+
# session row nor a log line. 1800s matches claude's own
|
|
35
|
+
# `default_timeout_seconds` — a transfer still running past that is
|
|
36
|
+
# genuinely stuck, not merely large.
|
|
37
|
+
_GIT_TRANSFER_TIMEOUT_SECONDS = 1800
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WorktreeError(Exception):
|
|
41
|
+
"""Raised when worktree operations fail."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StaleRecreatePartialFailureError(WorktreeError):
|
|
45
|
+
"""Raised when `create_worktree_with_new_branch` had committed to the
|
|
46
|
+
stale-merged delete+recreate path (destroying the old local ref)
|
|
47
|
+
and then the subsequent `git worktree add -b` failed partway. The
|
|
48
|
+
exception signals to the caller that any leftover ref on disk was
|
|
49
|
+
introduced by THIS session, so run_dev_issue's FAILED-path cleanup
|
|
50
|
+
must delete it regardless of the pre-call branch_existed_before
|
|
51
|
+
snapshot — the old ref is gone and anything present now is ours.
|
|
52
|
+
|
|
53
|
+
Scoped to the exception path so concurrent sessions can't clobber
|
|
54
|
+
each other's ownership state (instance-level flags on the shared
|
|
55
|
+
WorktreeManager were racy under concurrent run_dev_issue calls)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class WorktreeManager:
|
|
60
|
+
"""Manages git worktrees for session isolation."""
|
|
61
|
+
|
|
62
|
+
worktrees_dir: Path
|
|
63
|
+
bare_repos_dir: Path
|
|
64
|
+
timeout: int = 120
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
self.worktrees_dir = Path(self.worktrees_dir)
|
|
68
|
+
self.bare_repos_dir = Path(self.bare_repos_dir)
|
|
69
|
+
self.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self.bare_repos_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
async def _run_git(
|
|
73
|
+
self,
|
|
74
|
+
*args: str,
|
|
75
|
+
cwd: Path | None = None,
|
|
76
|
+
timeout: int | None = None,
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Run git command and return stdout. `timeout` overrides self.timeout
|
|
79
|
+
for one call — useful for cheap probes that shouldn't inherit the full
|
|
80
|
+
120s default when network is flaky."""
|
|
81
|
+
cmd = ["git", *args]
|
|
82
|
+
effective_timeout = timeout if timeout is not None else self.timeout
|
|
83
|
+
proc = await asyncio.create_subprocess_exec(
|
|
84
|
+
*cmd,
|
|
85
|
+
cwd=cwd,
|
|
86
|
+
stdout=asyncio.subprocess.PIPE,
|
|
87
|
+
stderr=asyncio.subprocess.PIPE,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
stdout, stderr = await asyncio.wait_for(
|
|
91
|
+
proc.communicate(),
|
|
92
|
+
timeout=effective_timeout,
|
|
93
|
+
)
|
|
94
|
+
except asyncio.TimeoutError:
|
|
95
|
+
try:
|
|
96
|
+
proc.kill()
|
|
97
|
+
await proc.wait()
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
# Re-raise the SAME type (callers filter on TimeoutError) but
|
|
101
|
+
# with a message attached. A bare `asyncio.TimeoutError()`
|
|
102
|
+
# stringifies to "", so every layer above rendered it as
|
|
103
|
+
# empty: the secops sweep reported "Error processing <repo>"
|
|
104
|
+
# with no cause, and the operator got a Telegram alert that
|
|
105
|
+
# named a repo and nothing else.
|
|
106
|
+
raise asyncio.TimeoutError(
|
|
107
|
+
f"git {' '.join(args)} timed out after "
|
|
108
|
+
f"{effective_timeout}s"
|
|
109
|
+
+ (f" (cwd={cwd})" if cwd is not None else "")
|
|
110
|
+
) from None
|
|
111
|
+
except asyncio.CancelledError:
|
|
112
|
+
# Scheduler/shutdown cancel: kill the child BEFORE re-raising
|
|
113
|
+
# so the git subprocess isn't left mutating the bare repo /
|
|
114
|
+
# worktree after the daemon exits. A restarted daemon would
|
|
115
|
+
# otherwise race with a stray `git worktree add` on the
|
|
116
|
+
# same repo. Shield the reaping so a second cancel between
|
|
117
|
+
# kill() and wait() doesn't leak the zombie.
|
|
118
|
+
if proc.returncode is None:
|
|
119
|
+
try:
|
|
120
|
+
proc.kill()
|
|
121
|
+
await asyncio.shield(proc.wait())
|
|
122
|
+
except (asyncio.CancelledError, Exception):
|
|
123
|
+
pass
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
if proc.returncode != 0:
|
|
127
|
+
raise WorktreeError(f"git failed: {stderr.decode().strip()}")
|
|
128
|
+
|
|
129
|
+
return stdout.decode()
|
|
130
|
+
|
|
131
|
+
def _get_bare_repo_path(self, repo: str) -> Path:
|
|
132
|
+
"""Get path to bare repo clone."""
|
|
133
|
+
repo_name = repo.replace("/", "-")
|
|
134
|
+
return self.bare_repos_dir / f"{repo_name}.git"
|
|
135
|
+
|
|
136
|
+
def _get_worktree_path(self, repo: str, session_id: str) -> Path:
|
|
137
|
+
"""Get path for a worktree."""
|
|
138
|
+
repo_name = repo.replace("/", "-")
|
|
139
|
+
return self.worktrees_dir / f"{repo_name}-{session_id}"
|
|
140
|
+
|
|
141
|
+
async def get_default_branch(self, repo: str) -> str:
|
|
142
|
+
"""Get the default branch for a repo from bare clone."""
|
|
143
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
144
|
+
output = await self._run_git("symbolic-ref", "HEAD", cwd=bare_path)
|
|
145
|
+
return output.strip().replace("refs/heads/", "")
|
|
146
|
+
|
|
147
|
+
async def create_worktree(
|
|
148
|
+
self,
|
|
149
|
+
repo: str,
|
|
150
|
+
session_id: str,
|
|
151
|
+
branch: str | None = None,
|
|
152
|
+
) -> Path:
|
|
153
|
+
"""Create a worktree for a session."""
|
|
154
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
155
|
+
worktree_path = self._get_worktree_path(repo, session_id)
|
|
156
|
+
|
|
157
|
+
if worktree_path.exists():
|
|
158
|
+
raise WorktreeError(f"Worktree already exists: {worktree_path}")
|
|
159
|
+
|
|
160
|
+
if branch is None:
|
|
161
|
+
branch = await self.get_default_branch(repo)
|
|
162
|
+
|
|
163
|
+
await self._run_git(
|
|
164
|
+
"worktree", "add",
|
|
165
|
+
str(worktree_path),
|
|
166
|
+
branch,
|
|
167
|
+
cwd=bare_path,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return worktree_path
|
|
171
|
+
|
|
172
|
+
async def create_worktree_with_new_branch(
|
|
173
|
+
self,
|
|
174
|
+
repo: str,
|
|
175
|
+
session_id: str,
|
|
176
|
+
new_branch: str,
|
|
177
|
+
base_branch: str | None = None,
|
|
178
|
+
github: GitHubCLI | None = None,
|
|
179
|
+
) -> tuple[Path, bool]:
|
|
180
|
+
"""Create a worktree for ``new_branch``.
|
|
181
|
+
|
|
182
|
+
If ``new_branch`` already exists in the bare repo — e.g. because a
|
|
183
|
+
previous session for the same issue ran out of fix attempts and left
|
|
184
|
+
its PR branch pushed to origin — reuse that branch so the retry can
|
|
185
|
+
iterate on the prior commits instead of hitting
|
|
186
|
+
``fatal: a branch named 'fix/issue-N' already exists`` from
|
|
187
|
+
``git worktree add -b``. Without this, any issue that exhausts the
|
|
188
|
+
verify-fix loop once gets permanently wedged until someone manually
|
|
189
|
+
deletes the branch.
|
|
190
|
+
|
|
191
|
+
When we have to fall back to a brand-new branch, it's cut from
|
|
192
|
+
``base_branch`` (default: the repo's default branch).
|
|
193
|
+
|
|
194
|
+
Returns ``(worktree_path, created_fresh)`` where ``created_fresh``
|
|
195
|
+
is True when THIS call either created a brand-new branch from
|
|
196
|
+
``base_branch`` or detected a stale fully-merged local branch,
|
|
197
|
+
deleted it, and recreated it. Callers use this flag to decide
|
|
198
|
+
whether cleanup on session failure should delete the branch
|
|
199
|
+
(issue #51): a pre-snapshotted ``branch_exists_locally`` check
|
|
200
|
+
goes stale the moment we delete+recreate, so the ownership
|
|
201
|
+
signal has to come from the function that performed the
|
|
202
|
+
mutation.
|
|
203
|
+
|
|
204
|
+
When ``github`` is provided and the branch already exists locally,
|
|
205
|
+
probe GitHub for an open PR with ``new_branch`` as head. If one
|
|
206
|
+
exists, refuse to reuse the branch — the reviewer's already-
|
|
207
|
+
reviewed work must not be hijacked by a fresh session, and
|
|
208
|
+
``gh pr create`` would later fail with "A pull request already
|
|
209
|
+
exists" (issue #52). Raises :class:`WorktreeError` with the PR
|
|
210
|
+
number and a concrete operator action. The probe runs BEFORE
|
|
211
|
+
any ref mutations so a concurrent PR is surfaced cleanly.
|
|
212
|
+
"""
|
|
213
|
+
# Per-call flag: tracks whether we committed to the stale-merged
|
|
214
|
+
# delete+recreate path. Must stay local to this coroutine call —
|
|
215
|
+
# placing it on self would race under concurrent sessions sharing
|
|
216
|
+
# the WorktreeManager instance (one session's start-of-call reset
|
|
217
|
+
# would clobber another's in-progress signal).
|
|
218
|
+
entered_stale_recreate = False
|
|
219
|
+
|
|
220
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
221
|
+
worktree_path = self._get_worktree_path(repo, session_id)
|
|
222
|
+
|
|
223
|
+
if worktree_path.exists():
|
|
224
|
+
raise WorktreeError(f"Worktree already exists: {worktree_path}")
|
|
225
|
+
|
|
226
|
+
if await self.branch_exists_locally(repo, new_branch):
|
|
227
|
+
# CRITICAL safety: never mutate or delete a branch that's still
|
|
228
|
+
# checked out by another linked worktree (e.g. a BLOCKED session
|
|
229
|
+
# that kept its worktree alive on purpose so it can be resumed
|
|
230
|
+
# when the operator replies). `git update-ref` / `update-ref -d`
|
|
231
|
+
# don't refuse in that case; they'd leave the live worktree's
|
|
232
|
+
# HEAD pointing at a stale or missing ref, corrupting the
|
|
233
|
+
# resumable state. Surface a clean error instead.
|
|
234
|
+
if await self._branch_is_checked_out_elsewhere(bare_path, new_branch):
|
|
235
|
+
raise WorktreeError(
|
|
236
|
+
f"Branch {new_branch!r} is already checked out in another "
|
|
237
|
+
f"worktree of {repo!r}. Resolve with `git worktree list` + "
|
|
238
|
+
"`git worktree remove` in the bare repo before retrying."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Issue #52: probe for an open same-repo PR BEFORE any ref
|
|
242
|
+
# mutation. Must run for BOTH on-remote and local-only
|
|
243
|
+
# branches — GitHub can keep a PR open after its head
|
|
244
|
+
# branch is deleted, and our next push would recreate
|
|
245
|
+
# origin/<branch>, re-attaching this session's commits to
|
|
246
|
+
# the reviewer-owned PR (codex round-7). Fail-closed on
|
|
247
|
+
# probe error is the correct trade-off: a flaky gh can
|
|
248
|
+
# delay recovery, but a missed probe silently hijacks a
|
|
249
|
+
# PR. See _refuse_if_branch_backs_open_pr.
|
|
250
|
+
if github is not None:
|
|
251
|
+
await self._refuse_if_branch_backs_open_pr(
|
|
252
|
+
github, repo, new_branch,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Remote presence has to be KNOWN to take either sync-to-origin
|
|
256
|
+
# or stale-merged branches. branch_exists_on_remote is
|
|
257
|
+
# fail-closed (returns True on timeout/auth error) which is
|
|
258
|
+
# good for "should I delete this branch" decisions but wrong
|
|
259
|
+
# here — a transient probe failure would skip the stale-merged
|
|
260
|
+
# recreate path and resurrect already-merged commits. Use the
|
|
261
|
+
# strict variant that raises on probe failure; on error we
|
|
262
|
+
# reuse the local ref unchanged, without mutations.
|
|
263
|
+
try:
|
|
264
|
+
on_remote = await self._branch_exists_on_remote_strict(
|
|
265
|
+
bare_path, new_branch,
|
|
266
|
+
)
|
|
267
|
+
except Exception:
|
|
268
|
+
await self._worktree_add_with_stale_cleanup(
|
|
269
|
+
bare_path, worktree_path, new_branch,
|
|
270
|
+
)
|
|
271
|
+
return worktree_path, False
|
|
272
|
+
|
|
273
|
+
if on_remote:
|
|
274
|
+
# Remote exists → sync local to remote head (preserving
|
|
275
|
+
# unpushed ahead-of-origin commits) and reuse.
|
|
276
|
+
await self._sync_reused_branch_to_origin(bare_path, new_branch)
|
|
277
|
+
await self._worktree_add_with_stale_cleanup(
|
|
278
|
+
bare_path, worktree_path, new_branch,
|
|
279
|
+
)
|
|
280
|
+
return worktree_path, False
|
|
281
|
+
|
|
282
|
+
# Local-only (confirmed). Distinguish between:
|
|
283
|
+
# (a) stale-merged: the prior PR was merged (any strategy) and
|
|
284
|
+
# the remote branch auto-deleted. The local ref's commits
|
|
285
|
+
# are all patch-equivalent to commits in the default
|
|
286
|
+
# branch. Reusing it would resurrect already-merged
|
|
287
|
+
# changes into the next PR — delete + create fresh.
|
|
288
|
+
# (b) recoverable unpushed work: the prior session made
|
|
289
|
+
# commits locally and died before push. Local has
|
|
290
|
+
# content not represented in the default branch — reuse
|
|
291
|
+
# so the operator's work isn't silently dropped.
|
|
292
|
+
if await self._branch_is_fully_merged(repo, new_branch):
|
|
293
|
+
# Commit to the delete+recreate path. From here on,
|
|
294
|
+
# any ref left on disk — whether the old one we just
|
|
295
|
+
# deleted, a partial one `worktree add -b` wrote
|
|
296
|
+
# before crashing, or the successfully recreated one
|
|
297
|
+
# — belongs to THIS session. Flip the local flag
|
|
298
|
+
# BEFORE the delete so even an exception in
|
|
299
|
+
# update-ref still surfaces StaleRecreatePartialFailureError
|
|
300
|
+
# to the caller.
|
|
301
|
+
entered_stale_recreate = True
|
|
302
|
+
try:
|
|
303
|
+
await self._run_git(
|
|
304
|
+
"update-ref", "-d", f"refs/heads/{new_branch}",
|
|
305
|
+
cwd=bare_path, timeout=10,
|
|
306
|
+
)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
# Fall through to the fresh-branch creation below. The
|
|
310
|
+
# delete+recreate path is the exact scenario issue #51
|
|
311
|
+
# covers: the branch that now exists was created by THIS
|
|
312
|
+
# session, so cleanup on failure must treat it as ours.
|
|
313
|
+
else:
|
|
314
|
+
await self._worktree_add_with_stale_cleanup(
|
|
315
|
+
bare_path, worktree_path, new_branch,
|
|
316
|
+
)
|
|
317
|
+
return worktree_path, False
|
|
318
|
+
|
|
319
|
+
if base_branch is None:
|
|
320
|
+
base_branch = await self.get_default_branch(repo)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
await self._run_git(
|
|
324
|
+
"worktree", "add",
|
|
325
|
+
"-b", new_branch,
|
|
326
|
+
str(worktree_path),
|
|
327
|
+
base_branch,
|
|
328
|
+
cwd=bare_path,
|
|
329
|
+
)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
# If we destroyed the old stale-merged ref on the way here,
|
|
332
|
+
# re-raise as StaleRecreatePartialFailureError so the caller
|
|
333
|
+
# knows any leftover ref belongs to this session. Without
|
|
334
|
+
# this signal, a pre-call branch_existed_before=True
|
|
335
|
+
# snapshot would misclassify the leftover as "not ours"
|
|
336
|
+
# and leak it into the next retry (issue #51).
|
|
337
|
+
if entered_stale_recreate:
|
|
338
|
+
raise StaleRecreatePartialFailureError(str(e)) from e
|
|
339
|
+
raise
|
|
340
|
+
return worktree_path, True
|
|
341
|
+
|
|
342
|
+
async def _refuse_if_branch_backs_open_pr(
|
|
343
|
+
self, github: GitHubCLI, repo: str, branch: str,
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Issue #52: raise WorktreeError if ``branch`` is the head of an
|
|
346
|
+
open PR on ``repo``. Probe is fail-closed (on repeated probe
|
|
347
|
+
error, we refuse reuse): the dev workflow pushes to the shared
|
|
348
|
+
branch BEFORE calling ``gh pr create``, so relying on the
|
|
349
|
+
later create to catch a PR-backed branch is unsafe — a
|
|
350
|
+
transient ``gh`` error would let the push hijack the existing
|
|
351
|
+
PR before any failure surfaces.
|
|
352
|
+
|
|
353
|
+
``gh pr list --head`` filters by branch name only, so PRs from
|
|
354
|
+
unrelated repos (external forks, same-owner forks like
|
|
355
|
+
``acme/repo-fork`` targeting ``acme/repo``) with the same
|
|
356
|
+
branch name would appear in the result and wrongly block
|
|
357
|
+
our reuse. Filter client-side on the FULL head repo identity
|
|
358
|
+
(owner + name) so only PRs whose head actually lives in the
|
|
359
|
+
repo we're about to push to can veto reuse.
|
|
360
|
+
"""
|
|
361
|
+
last_exc: Exception | None = None
|
|
362
|
+
prs: list | None = None
|
|
363
|
+
for attempt in range(_PR_PROBE_RETRY_ATTEMPTS):
|
|
364
|
+
try:
|
|
365
|
+
prs = await github.list_prs(
|
|
366
|
+
repo, state="open", head=branch,
|
|
367
|
+
timeout=_PR_PROBE_TIMEOUT_SECONDS,
|
|
368
|
+
)
|
|
369
|
+
last_exc = None
|
|
370
|
+
break
|
|
371
|
+
except Exception as e:
|
|
372
|
+
last_exc = e
|
|
373
|
+
if attempt < _PR_PROBE_RETRY_ATTEMPTS - 1:
|
|
374
|
+
await asyncio.sleep(_PR_PROBE_RETRY_SLEEP_SECONDS)
|
|
375
|
+
if prs is None:
|
|
376
|
+
raise WorktreeError(
|
|
377
|
+
f"Could not determine whether branch {branch!r} on "
|
|
378
|
+
f"{repo!r} backs an open PR (gh probe failed after "
|
|
379
|
+
f"{_PR_PROBE_RETRY_ATTEMPTS} attempts: "
|
|
380
|
+
f"{type(last_exc).__name__ if last_exc else 'unknown'}). "
|
|
381
|
+
"Refusing reuse to avoid hijacking an open PR. Retry "
|
|
382
|
+
"once gh is reachable, or delete the branch manually "
|
|
383
|
+
"if you're certain no PR is open."
|
|
384
|
+
) from last_exc
|
|
385
|
+
target_owner, _, target_name = repo.partition("/")
|
|
386
|
+
# GitHub owner/repo names are case-insensitive: config may say
|
|
387
|
+
# "Owner/Repo" while the API returns "owner/repo". Normalize
|
|
388
|
+
# both sides before comparing so a case mismatch doesn't let
|
|
389
|
+
# a colliding PR slip past the veto.
|
|
390
|
+
target_owner_lc = target_owner.lower()
|
|
391
|
+
target_name_lc = target_name.lower()
|
|
392
|
+
|
|
393
|
+
def _same_repo(pr: dict) -> bool:
|
|
394
|
+
owner = (pr.get("headRepositoryOwner") or {}).get("login") or ""
|
|
395
|
+
name = (pr.get("headRepository") or {}).get("name") or ""
|
|
396
|
+
return (
|
|
397
|
+
owner.lower() == target_owner_lc
|
|
398
|
+
and name.lower() == target_name_lc
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
same_repo_prs = [p for p in prs if _same_repo(p)]
|
|
402
|
+
if not same_repo_prs:
|
|
403
|
+
return
|
|
404
|
+
pr_number = same_repo_prs[0].get("number")
|
|
405
|
+
raise WorktreeError(
|
|
406
|
+
f"Branch {branch!r} already has open PR #{pr_number} on "
|
|
407
|
+
f"{repo!r} — close or merge it before retrying this issue."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
async def _worktree_add_with_stale_cleanup(
|
|
411
|
+
self, bare_path: Path, worktree_path: Path, branch: str,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""``git worktree add <path> <branch>`` with targeted recovery
|
|
414
|
+
if the add fails because a stale (prunable) admin entry still
|
|
415
|
+
claims the branch.
|
|
416
|
+
|
|
417
|
+
Try the add. If it succeeds, done. If it fails with "already
|
|
418
|
+
checked out" AND we've already established there's no LIVE
|
|
419
|
+
checkout of this branch (the caller checked
|
|
420
|
+
_branch_is_checked_out_elsewhere), we scope a `git worktree
|
|
421
|
+
prune` to this one stale entry and retry.
|
|
422
|
+
|
|
423
|
+
We deliberately do NOT prune up front: an unconditional prune
|
|
424
|
+
in a bare repo whose worktrees_dir is on a removable / network
|
|
425
|
+
path would destroy admin state for unrelated worktrees whose
|
|
426
|
+
paths are temporarily unavailable. Scoping to this specific
|
|
427
|
+
branch via a post-failure recovery keeps the blast radius
|
|
428
|
+
minimal.
|
|
429
|
+
"""
|
|
430
|
+
try:
|
|
431
|
+
await self._run_git(
|
|
432
|
+
"worktree", "add", str(worktree_path), branch,
|
|
433
|
+
cwd=bare_path,
|
|
434
|
+
)
|
|
435
|
+
return
|
|
436
|
+
except WorktreeError as e:
|
|
437
|
+
if "already checked out" not in str(e):
|
|
438
|
+
raise
|
|
439
|
+
# Find the stale admin entry for this branch and clear it
|
|
440
|
+
# WITHOUT running repo-wide `git worktree prune` — that would
|
|
441
|
+
# also discard admin state for unrelated prunable worktrees
|
|
442
|
+
# whose paths are temporarily unavailable (removable media,
|
|
443
|
+
# network mounts), making them unrecoverable when the path
|
|
444
|
+
# comes back. Instead, delete just the one admin directory.
|
|
445
|
+
stale_path = await self._find_stale_worktree_path(bare_path, branch)
|
|
446
|
+
if stale_path is None:
|
|
447
|
+
raise
|
|
448
|
+
# Only touch stale entries we OWN — under our managed
|
|
449
|
+
# worktrees_dir — AND only when our worktrees_dir is actually
|
|
450
|
+
# accessible on disk right now. A prunable stanza can also
|
|
451
|
+
# represent a worktree on a temporarily-unavailable path
|
|
452
|
+
# (network mount, removable drive); if the mount is down,
|
|
453
|
+
# every managed worktree under it appears prunable. Deleting
|
|
454
|
+
# their admin state would orphan real checkouts when the
|
|
455
|
+
# mount comes back.
|
|
456
|
+
#
|
|
457
|
+
# Safety gate: require worktrees_dir itself to exist and be
|
|
458
|
+
# a directory. If the whole mount is offline, bail.
|
|
459
|
+
if not self.worktrees_dir.is_dir():
|
|
460
|
+
raise e
|
|
461
|
+
try:
|
|
462
|
+
stale_resolved = Path(stale_path).resolve()
|
|
463
|
+
wt_root = self.worktrees_dir.resolve()
|
|
464
|
+
stale_resolved.relative_to(wt_root)
|
|
465
|
+
except (OSError, ValueError):
|
|
466
|
+
raise e # not under our dir — unsafe to recover
|
|
467
|
+
# Locate the admin dir via its canonical `gitdir` pointer,
|
|
468
|
+
# NOT by assuming the path basename matches. Git sanitizes
|
|
469
|
+
# names (`foo bar` → `foo-bar`) and disambiguates duplicates
|
|
470
|
+
# (`wt`, `wt1`, …), so Path(stale_path).name can point at the
|
|
471
|
+
# wrong admin dir or a live unrelated worktree's metadata.
|
|
472
|
+
# Each admin dir has a `gitdir` file that points at
|
|
473
|
+
# `<worktree-path>/.git` — that pointer is the source of truth.
|
|
474
|
+
admin_dir = self._resolve_admin_dir(bare_path, stale_path)
|
|
475
|
+
if admin_dir is not None and admin_dir.exists():
|
|
476
|
+
try:
|
|
477
|
+
shutil.rmtree(admin_dir)
|
|
478
|
+
except OSError:
|
|
479
|
+
pass
|
|
480
|
+
# Retry the add; if it fails again, let the error surface.
|
|
481
|
+
await self._run_git(
|
|
482
|
+
"worktree", "add", str(worktree_path), branch,
|
|
483
|
+
cwd=bare_path,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def _resolve_admin_dir(
|
|
487
|
+
self, bare_path: Path, worktree_dir: str,
|
|
488
|
+
) -> Path | None:
|
|
489
|
+
"""Find the bare repo's admin dir whose ``gitdir`` pointer file
|
|
490
|
+
resolves to ``<worktree_dir>/.git``. Returns None if no
|
|
491
|
+
matching entry exists.
|
|
492
|
+
|
|
493
|
+
Git may name admin dirs differently from the worktree path
|
|
494
|
+
basename (sanitization, collision-suffix), so the only robust
|
|
495
|
+
way to pair them is to inspect each admin's gitdir pointer.
|
|
496
|
+
"""
|
|
497
|
+
worktrees_root = bare_path / "worktrees"
|
|
498
|
+
if not worktrees_root.is_dir():
|
|
499
|
+
return None
|
|
500
|
+
target = str(Path(worktree_dir) / ".git")
|
|
501
|
+
try:
|
|
502
|
+
entries = list(worktrees_root.iterdir())
|
|
503
|
+
except OSError:
|
|
504
|
+
return None
|
|
505
|
+
for admin_dir in entries:
|
|
506
|
+
gitdir_file = admin_dir / "gitdir"
|
|
507
|
+
if not gitdir_file.exists():
|
|
508
|
+
continue
|
|
509
|
+
try:
|
|
510
|
+
pointer = gitdir_file.read_text().strip()
|
|
511
|
+
except OSError:
|
|
512
|
+
continue
|
|
513
|
+
if pointer == target:
|
|
514
|
+
return admin_dir
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
async def _find_stale_worktree_path(
|
|
518
|
+
self, bare_path: Path, branch: str,
|
|
519
|
+
) -> str | None:
|
|
520
|
+
"""If ``refs/heads/<branch>`` is claimed by a worktree entry marked
|
|
521
|
+
``prunable``, return the path of that entry. Otherwise None.
|
|
522
|
+
Used to confirm that a "already checked out" failure is safe to
|
|
523
|
+
recover from via prune (i.e. the stale entry IS for our branch
|
|
524
|
+
and not a real live checkout).
|
|
525
|
+
"""
|
|
526
|
+
try:
|
|
527
|
+
output = await self._run_git(
|
|
528
|
+
"worktree", "list", "--porcelain",
|
|
529
|
+
cwd=bare_path, timeout=10,
|
|
530
|
+
)
|
|
531
|
+
except Exception:
|
|
532
|
+
return None
|
|
533
|
+
target = f"refs/heads/{branch}"
|
|
534
|
+
current_path: str | None = None
|
|
535
|
+
current_branch: str | None = None
|
|
536
|
+
current_prunable = False
|
|
537
|
+
for raw in output.splitlines() + [""]:
|
|
538
|
+
line = raw.strip()
|
|
539
|
+
if not line:
|
|
540
|
+
if (
|
|
541
|
+
current_branch == target
|
|
542
|
+
and current_prunable
|
|
543
|
+
and current_path is not None
|
|
544
|
+
):
|
|
545
|
+
return current_path
|
|
546
|
+
current_path = None
|
|
547
|
+
current_branch = None
|
|
548
|
+
current_prunable = False
|
|
549
|
+
continue
|
|
550
|
+
if line.startswith("worktree "):
|
|
551
|
+
current_path = line[9:].strip()
|
|
552
|
+
elif line.startswith("branch "):
|
|
553
|
+
current_branch = line[7:].strip()
|
|
554
|
+
elif line.startswith("prunable"):
|
|
555
|
+
current_prunable = True
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
async def _sync_reused_branch_to_origin(
|
|
559
|
+
self, bare_path: Path, branch: str,
|
|
560
|
+
) -> None:
|
|
561
|
+
"""Fast-forward the local ``branch`` to origin's head using a
|
|
562
|
+
scratch ref so it never touches ``refs/heads/<branch>`` except
|
|
563
|
+
via a safe `update-ref` that's gated by a merge-base ancestor
|
|
564
|
+
check.
|
|
565
|
+
|
|
566
|
+
Bare clones from `git clone --bare` have fetch refspec
|
|
567
|
+
`+refs/heads/*:refs/heads/*`, so there are NO
|
|
568
|
+
`refs/remotes/origin/*` tracking refs AND an unscoped
|
|
569
|
+
`git fetch origin <branch>` would overwrite the live local ref
|
|
570
|
+
directly — destroying any unpushed local commits before we get
|
|
571
|
+
to compare. Routing through a scratch ref in
|
|
572
|
+
`refs/ctrlrelay/sync/*` avoids both problems.
|
|
573
|
+
|
|
574
|
+
Rules:
|
|
575
|
+
- If local is ancestor of the fetched remote tip → fast-forward.
|
|
576
|
+
- If local is ahead or diverged → leave local alone (ahead
|
|
577
|
+
contains unpushed work; forcing would destroy it).
|
|
578
|
+
|
|
579
|
+
All steps are best-effort: any failure (timeout, network,
|
|
580
|
+
permissions, bad ref) falls back to reusing the local ref
|
|
581
|
+
unchanged, so a flaky origin never aborts the retry.
|
|
582
|
+
"""
|
|
583
|
+
scratch_ref = f"refs/ctrlrelay/sync/{branch}"
|
|
584
|
+
fetched = False
|
|
585
|
+
try:
|
|
586
|
+
await self._run_git(
|
|
587
|
+
"fetch", "origin",
|
|
588
|
+
f"+refs/heads/{branch}:{scratch_ref}",
|
|
589
|
+
cwd=bare_path, timeout=30,
|
|
590
|
+
)
|
|
591
|
+
fetched = True
|
|
592
|
+
except Exception:
|
|
593
|
+
return
|
|
594
|
+
try:
|
|
595
|
+
# Distinguish three cases cleanly:
|
|
596
|
+
# a) local is ancestor of remote → local behind → fast-forward
|
|
597
|
+
# b) remote is ancestor of local → local ahead (unpushed work) → preserve
|
|
598
|
+
# c) neither → diverged → raise; caller surfaces as session failure
|
|
599
|
+
# so we don't silently reuse a branch that will fail on push.
|
|
600
|
+
local_behind = False
|
|
601
|
+
local_ahead = False
|
|
602
|
+
try:
|
|
603
|
+
await self._run_git(
|
|
604
|
+
"merge-base", "--is-ancestor", branch, scratch_ref,
|
|
605
|
+
cwd=bare_path, timeout=10,
|
|
606
|
+
)
|
|
607
|
+
local_behind = True
|
|
608
|
+
except WorktreeError:
|
|
609
|
+
pass
|
|
610
|
+
except Exception:
|
|
611
|
+
# Timeout or other — play safe and preserve local.
|
|
612
|
+
return
|
|
613
|
+
if not local_behind:
|
|
614
|
+
try:
|
|
615
|
+
await self._run_git(
|
|
616
|
+
"merge-base", "--is-ancestor", scratch_ref, branch,
|
|
617
|
+
cwd=bare_path, timeout=10,
|
|
618
|
+
)
|
|
619
|
+
local_ahead = True
|
|
620
|
+
except WorktreeError:
|
|
621
|
+
pass
|
|
622
|
+
except Exception:
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
if local_behind:
|
|
626
|
+
try:
|
|
627
|
+
await self._run_git(
|
|
628
|
+
"update-ref", f"refs/heads/{branch}", scratch_ref,
|
|
629
|
+
cwd=bare_path, timeout=10,
|
|
630
|
+
)
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
elif local_ahead:
|
|
634
|
+
# Preserve local — ahead means unpushed operator work.
|
|
635
|
+
pass
|
|
636
|
+
else:
|
|
637
|
+
# Diverged: local has commits origin doesn't, and origin has
|
|
638
|
+
# commits local doesn't. Reusing either side silently loses
|
|
639
|
+
# work or produces non-ff pushes. Surface the conflict so
|
|
640
|
+
# the session fails cleanly.
|
|
641
|
+
raise WorktreeError(
|
|
642
|
+
f"Local branch {branch!r} has diverged from "
|
|
643
|
+
f"origin/{branch}. Rebase or reset manually in the bare "
|
|
644
|
+
"repo before retrying."
|
|
645
|
+
)
|
|
646
|
+
finally:
|
|
647
|
+
if fetched:
|
|
648
|
+
try:
|
|
649
|
+
await self._run_git(
|
|
650
|
+
"update-ref", "-d", scratch_ref,
|
|
651
|
+
cwd=bare_path, timeout=10,
|
|
652
|
+
)
|
|
653
|
+
except Exception:
|
|
654
|
+
pass
|
|
655
|
+
|
|
656
|
+
async def _branch_exists_on_remote_strict(
|
|
657
|
+
self, bare_path: Path, branch: str,
|
|
658
|
+
) -> bool:
|
|
659
|
+
"""Strict variant of branch_exists_on_remote: True if origin
|
|
660
|
+
has the branch, False if it does not, RAISES on probe failure.
|
|
661
|
+
|
|
662
|
+
The public branch_exists_on_remote is fail-closed (returns True
|
|
663
|
+
on error) so callers making "safe to delete" decisions err
|
|
664
|
+
on preservation. That semantic is wrong for the reuse path,
|
|
665
|
+
where a transient error must NOT be interpreted as "remote
|
|
666
|
+
exists" — that would skip the stale-merged recreate branch and
|
|
667
|
+
resurrect already-merged commits into a new PR.
|
|
668
|
+
"""
|
|
669
|
+
output = await self._run_git(
|
|
670
|
+
"ls-remote", "--heads", "origin", branch,
|
|
671
|
+
cwd=bare_path, timeout=10,
|
|
672
|
+
)
|
|
673
|
+
return bool(output.strip())
|
|
674
|
+
|
|
675
|
+
async def _branch_is_checked_out_elsewhere(
|
|
676
|
+
self, bare_path: Path, branch: str,
|
|
677
|
+
) -> bool:
|
|
678
|
+
"""Return True if ``refs/heads/<branch>`` is currently checked out
|
|
679
|
+
by any LIVE linked worktree of this bare repo.
|
|
680
|
+
|
|
681
|
+
`git worktree list --porcelain` emits a blank-line-separated
|
|
682
|
+
stanza per worktree. A stanza with a ``prunable <reason>`` line
|
|
683
|
+
is a stale metadata entry — the worktree directory is gone, we
|
|
684
|
+
just haven't run ``git worktree prune`` yet. Such stanzas don't
|
|
685
|
+
represent a real checkout and MUST be ignored here, otherwise a
|
|
686
|
+
crash between ``shutil.rmtree()`` and ``worktree prune`` wedges
|
|
687
|
+
the branch for all future retries.
|
|
688
|
+
|
|
689
|
+
Conservative (fails closed): if the probe itself errors, returns
|
|
690
|
+
True — better to refuse mutation than to risk corrupting a live
|
|
691
|
+
worktree.
|
|
692
|
+
"""
|
|
693
|
+
try:
|
|
694
|
+
output = await self._run_git(
|
|
695
|
+
"worktree", "list", "--porcelain",
|
|
696
|
+
cwd=bare_path, timeout=10,
|
|
697
|
+
)
|
|
698
|
+
except Exception:
|
|
699
|
+
return True
|
|
700
|
+
|
|
701
|
+
target = f"refs/heads/{branch}"
|
|
702
|
+
current_branch: str | None = None
|
|
703
|
+
current_prunable = False
|
|
704
|
+
for raw in output.splitlines() + [""]: # trailing "" to flush last stanza
|
|
705
|
+
line = raw.strip()
|
|
706
|
+
if not line:
|
|
707
|
+
# End of stanza — check if this one matches and is live.
|
|
708
|
+
if (
|
|
709
|
+
current_branch == target
|
|
710
|
+
and not current_prunable
|
|
711
|
+
):
|
|
712
|
+
return True
|
|
713
|
+
current_branch = None
|
|
714
|
+
current_prunable = False
|
|
715
|
+
continue
|
|
716
|
+
if line.startswith("branch "):
|
|
717
|
+
current_branch = line[7:].strip()
|
|
718
|
+
elif line.startswith("prunable"):
|
|
719
|
+
current_prunable = True
|
|
720
|
+
return False
|
|
721
|
+
|
|
722
|
+
async def _branch_is_fully_merged(self, repo: str, branch: str) -> bool:
|
|
723
|
+
"""Return True if every commit reachable from ``branch`` is
|
|
724
|
+
patch-equivalent to something already in the default branch.
|
|
725
|
+
|
|
726
|
+
Uses ``git cherry <default> <branch>`` which compares commits
|
|
727
|
+
by patch-id (content), not by SHA — so this catches squash and
|
|
728
|
+
rebase merges too, not just regular merge commits.
|
|
729
|
+
|
|
730
|
+
Conservative: on any error (unknown default branch, cherry
|
|
731
|
+
failure) returns False so we preserve the local ref.
|
|
732
|
+
"""
|
|
733
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
734
|
+
try:
|
|
735
|
+
default = await self.get_default_branch(repo)
|
|
736
|
+
except Exception:
|
|
737
|
+
return False
|
|
738
|
+
try:
|
|
739
|
+
out = await self._run_git(
|
|
740
|
+
"cherry", default, branch,
|
|
741
|
+
cwd=bare_path, timeout=15,
|
|
742
|
+
)
|
|
743
|
+
except Exception:
|
|
744
|
+
return False
|
|
745
|
+
lines = [line for line in out.splitlines() if line.strip()]
|
|
746
|
+
if not lines:
|
|
747
|
+
# No commits in branch not in default — fully merged / stale.
|
|
748
|
+
return True
|
|
749
|
+
# Each line starts with "+" (unique) or "-" (patch-equivalent in upstream).
|
|
750
|
+
return all(line.startswith("-") for line in lines)
|
|
751
|
+
|
|
752
|
+
async def push_branch(self, worktree_path: Path, branch: str) -> None:
|
|
753
|
+
"""Push a branch to origin."""
|
|
754
|
+
await self._run_git("push", "-u", "origin", branch, cwd=worktree_path)
|
|
755
|
+
|
|
756
|
+
async def ensure_bare_repo(self, repo: str) -> Path:
|
|
757
|
+
"""Ensure bare repo exists, cloning if needed.
|
|
758
|
+
|
|
759
|
+
Uses an explicit refspec on fetch instead of relying on
|
|
760
|
+
``remote.origin.fetch`` config. Some bare clones in the wild
|
|
761
|
+
have the refspec missing or empty; ``git fetch --all`` is a
|
|
762
|
+
silent no-op in that case, so repeated calls never update
|
|
763
|
+
``refs/heads/*``. Worktrees created from that stale bare
|
|
764
|
+
then check out commits days or weeks behind origin —
|
|
765
|
+
exactly what we saw today when a task-pipeline run reported
|
|
766
|
+
test counts from a commit that was two weeks old.
|
|
767
|
+
|
|
768
|
+
Refspec: ``refs/heads/*:refs/heads/*`` (no leading ``+``).
|
|
769
|
+
Fast-forward-only on purpose — codex caught this on the
|
|
770
|
+
first pass: a force update would clobber the dev pipeline's
|
|
771
|
+
``create_worktree_with_new_branch`` reuse path, which keeps
|
|
772
|
+
local-ahead-of-origin commits when a prior session pushed
|
|
773
|
+
partial work. Non-force semantics give us the right
|
|
774
|
+
behavior in all three cases:
|
|
775
|
+
|
|
776
|
+
- Local branch BEHIND origin (default-branch case, the bug
|
|
777
|
+
we're fixing): fast-forward applies, local catches up.
|
|
778
|
+
- Local branch AHEAD of origin (unpushed dev work): fetch
|
|
779
|
+
refuses to rewind, local stays as-is, dev's reuse logic
|
|
780
|
+
gets to inspect divergence and decide.
|
|
781
|
+
- Local and origin equal: no-op.
|
|
782
|
+
|
|
783
|
+
``--prune`` drops refs that no longer exist on origin so
|
|
784
|
+
deleted branches don't linger forever.
|
|
785
|
+
"""
|
|
786
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
787
|
+
|
|
788
|
+
if bare_path.exists():
|
|
789
|
+
await self._run_git(
|
|
790
|
+
"fetch", "--prune", "origin",
|
|
791
|
+
"refs/heads/*:refs/heads/*",
|
|
792
|
+
cwd=bare_path,
|
|
793
|
+
timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
|
|
794
|
+
)
|
|
795
|
+
else:
|
|
796
|
+
await self._run_git(
|
|
797
|
+
"clone", "--bare",
|
|
798
|
+
f"https://github.com/{repo}.git",
|
|
799
|
+
str(bare_path),
|
|
800
|
+
timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
return bare_path
|
|
804
|
+
|
|
805
|
+
async def remove_worktree(self, repo: str, session_id: str) -> None:
|
|
806
|
+
"""Remove a worktree and clean up."""
|
|
807
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
808
|
+
worktree_path = self._get_worktree_path(repo, session_id)
|
|
809
|
+
|
|
810
|
+
if worktree_path.exists():
|
|
811
|
+
shutil.rmtree(worktree_path)
|
|
812
|
+
|
|
813
|
+
if bare_path.exists():
|
|
814
|
+
await self._run_git("worktree", "prune", cwd=bare_path)
|
|
815
|
+
|
|
816
|
+
async def delete_branch(self, repo: str, branch: str) -> None:
|
|
817
|
+
"""Delete a local branch in the bare repo. Best-effort; no-op if absent."""
|
|
818
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
819
|
+
if not bare_path.exists():
|
|
820
|
+
return
|
|
821
|
+
try:
|
|
822
|
+
await self._run_git("branch", "-D", branch, cwd=bare_path)
|
|
823
|
+
except WorktreeError:
|
|
824
|
+
pass
|
|
825
|
+
|
|
826
|
+
async def branch_exists_locally(self, repo: str, branch: str) -> bool:
|
|
827
|
+
"""Check if `branch` exists as a local ref in the bare repo."""
|
|
828
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
829
|
+
if not bare_path.exists():
|
|
830
|
+
return False
|
|
831
|
+
try:
|
|
832
|
+
await self._run_git(
|
|
833
|
+
"show-ref", "--verify", "--quiet", f"refs/heads/{branch}",
|
|
834
|
+
cwd=bare_path,
|
|
835
|
+
)
|
|
836
|
+
return True
|
|
837
|
+
except Exception:
|
|
838
|
+
return False
|
|
839
|
+
|
|
840
|
+
async def branch_exists_on_remote(self, repo: str, branch: str) -> bool:
|
|
841
|
+
"""Return True if `branch` exists on origin. Fail-closed: on any error
|
|
842
|
+
(WorktreeError, asyncio.TimeoutError from _run_git's wait_for, etc.)
|
|
843
|
+
returns True so callers err on the side of NOT deleting."""
|
|
844
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
845
|
+
if not bare_path.exists():
|
|
846
|
+
return True
|
|
847
|
+
try:
|
|
848
|
+
output = await self._run_git(
|
|
849
|
+
"ls-remote", "--heads", "origin", branch,
|
|
850
|
+
cwd=bare_path,
|
|
851
|
+
timeout=10,
|
|
852
|
+
)
|
|
853
|
+
return bool(output.strip())
|
|
854
|
+
except Exception:
|
|
855
|
+
return True
|
|
856
|
+
|
|
857
|
+
def _get_gitdir(self, worktree_path: Path) -> Path:
|
|
858
|
+
"""Get the real gitdir for a worktree.
|
|
859
|
+
|
|
860
|
+
In linked worktrees, .git is a file pointing to the actual gitdir.
|
|
861
|
+
"""
|
|
862
|
+
dot_git = worktree_path / ".git"
|
|
863
|
+
if dot_git.is_file():
|
|
864
|
+
content = dot_git.read_text().strip()
|
|
865
|
+
if content.startswith("gitdir: "):
|
|
866
|
+
return Path(content[8:])
|
|
867
|
+
return dot_git
|
|
868
|
+
|
|
869
|
+
def symlink_context(
|
|
870
|
+
self,
|
|
871
|
+
worktree_path: Path,
|
|
872
|
+
context_path: Path,
|
|
873
|
+
) -> None:
|
|
874
|
+
"""Symlink CLAUDE.md into worktree."""
|
|
875
|
+
target = worktree_path / "CLAUDE.md"
|
|
876
|
+
|
|
877
|
+
if target.exists() or target.is_symlink():
|
|
878
|
+
target.unlink()
|
|
879
|
+
|
|
880
|
+
target.symlink_to(context_path.resolve())
|
|
881
|
+
|
|
882
|
+
gitdir = self._get_gitdir(worktree_path)
|
|
883
|
+
exclude_file = gitdir / "info" / "exclude"
|
|
884
|
+
if exclude_file.exists():
|
|
885
|
+
content = exclude_file.read_text()
|
|
886
|
+
if "CLAUDE.md" not in content:
|
|
887
|
+
exclude_file.write_text(content.rstrip() + "\nCLAUDE.md\n")
|
|
888
|
+
|
|
889
|
+
def remove_context_symlink(self, worktree_path: Path) -> None:
|
|
890
|
+
"""Remove CLAUDE.md symlink before git operations."""
|
|
891
|
+
target = worktree_path / "CLAUDE.md"
|
|
892
|
+
if target.is_symlink():
|
|
893
|
+
target.unlink()
|