ctrlrelay 0.1.5__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 +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,673 @@
|
|
|
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
|
+
|
|
10
|
+
|
|
11
|
+
class WorktreeError(Exception):
|
|
12
|
+
"""Raised when worktree operations fail."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class WorktreeManager:
|
|
17
|
+
"""Manages git worktrees for session isolation."""
|
|
18
|
+
|
|
19
|
+
worktrees_dir: Path
|
|
20
|
+
bare_repos_dir: Path
|
|
21
|
+
timeout: int = 120
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
self.worktrees_dir = Path(self.worktrees_dir)
|
|
25
|
+
self.bare_repos_dir = Path(self.bare_repos_dir)
|
|
26
|
+
self.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
self.bare_repos_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
async def _run_git(
|
|
30
|
+
self,
|
|
31
|
+
*args: str,
|
|
32
|
+
cwd: Path | None = None,
|
|
33
|
+
timeout: int | None = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Run git command and return stdout. `timeout` overrides self.timeout
|
|
36
|
+
for one call — useful for cheap probes that shouldn't inherit the full
|
|
37
|
+
120s default when network is flaky."""
|
|
38
|
+
cmd = ["git", *args]
|
|
39
|
+
proc = await asyncio.create_subprocess_exec(
|
|
40
|
+
*cmd,
|
|
41
|
+
cwd=cwd,
|
|
42
|
+
stdout=asyncio.subprocess.PIPE,
|
|
43
|
+
stderr=asyncio.subprocess.PIPE,
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
stdout, stderr = await asyncio.wait_for(
|
|
47
|
+
proc.communicate(),
|
|
48
|
+
timeout=timeout if timeout is not None else self.timeout,
|
|
49
|
+
)
|
|
50
|
+
except asyncio.TimeoutError:
|
|
51
|
+
try:
|
|
52
|
+
proc.kill()
|
|
53
|
+
await proc.wait()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
raise
|
|
57
|
+
except asyncio.CancelledError:
|
|
58
|
+
# Scheduler/shutdown cancel: kill the child BEFORE re-raising
|
|
59
|
+
# so the git subprocess isn't left mutating the bare repo /
|
|
60
|
+
# worktree after the daemon exits. A restarted daemon would
|
|
61
|
+
# otherwise race with a stray `git worktree add` on the
|
|
62
|
+
# same repo. Shield the reaping so a second cancel between
|
|
63
|
+
# kill() and wait() doesn't leak the zombie.
|
|
64
|
+
if proc.returncode is None:
|
|
65
|
+
try:
|
|
66
|
+
proc.kill()
|
|
67
|
+
await asyncio.shield(proc.wait())
|
|
68
|
+
except (asyncio.CancelledError, Exception):
|
|
69
|
+
pass
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
if proc.returncode != 0:
|
|
73
|
+
raise WorktreeError(f"git failed: {stderr.decode().strip()}")
|
|
74
|
+
|
|
75
|
+
return stdout.decode()
|
|
76
|
+
|
|
77
|
+
def _get_bare_repo_path(self, repo: str) -> Path:
|
|
78
|
+
"""Get path to bare repo clone."""
|
|
79
|
+
repo_name = repo.replace("/", "-")
|
|
80
|
+
return self.bare_repos_dir / f"{repo_name}.git"
|
|
81
|
+
|
|
82
|
+
def _get_worktree_path(self, repo: str, session_id: str) -> Path:
|
|
83
|
+
"""Get path for a worktree."""
|
|
84
|
+
repo_name = repo.replace("/", "-")
|
|
85
|
+
return self.worktrees_dir / f"{repo_name}-{session_id}"
|
|
86
|
+
|
|
87
|
+
async def get_default_branch(self, repo: str) -> str:
|
|
88
|
+
"""Get the default branch for a repo from bare clone."""
|
|
89
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
90
|
+
output = await self._run_git("symbolic-ref", "HEAD", cwd=bare_path)
|
|
91
|
+
return output.strip().replace("refs/heads/", "")
|
|
92
|
+
|
|
93
|
+
async def create_worktree(
|
|
94
|
+
self,
|
|
95
|
+
repo: str,
|
|
96
|
+
session_id: str,
|
|
97
|
+
branch: str | None = None,
|
|
98
|
+
) -> Path:
|
|
99
|
+
"""Create a worktree for a session."""
|
|
100
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
101
|
+
worktree_path = self._get_worktree_path(repo, session_id)
|
|
102
|
+
|
|
103
|
+
if worktree_path.exists():
|
|
104
|
+
raise WorktreeError(f"Worktree already exists: {worktree_path}")
|
|
105
|
+
|
|
106
|
+
if branch is None:
|
|
107
|
+
branch = await self.get_default_branch(repo)
|
|
108
|
+
|
|
109
|
+
await self._run_git(
|
|
110
|
+
"worktree", "add",
|
|
111
|
+
str(worktree_path),
|
|
112
|
+
branch,
|
|
113
|
+
cwd=bare_path,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return worktree_path
|
|
117
|
+
|
|
118
|
+
async def create_worktree_with_new_branch(
|
|
119
|
+
self,
|
|
120
|
+
repo: str,
|
|
121
|
+
session_id: str,
|
|
122
|
+
new_branch: str,
|
|
123
|
+
base_branch: str | None = None,
|
|
124
|
+
) -> Path:
|
|
125
|
+
"""Create a worktree for ``new_branch``.
|
|
126
|
+
|
|
127
|
+
If ``new_branch`` already exists in the bare repo — e.g. because a
|
|
128
|
+
previous session for the same issue ran out of fix attempts and left
|
|
129
|
+
its PR branch pushed to origin — reuse that branch so the retry can
|
|
130
|
+
iterate on the prior commits instead of hitting
|
|
131
|
+
``fatal: a branch named 'fix/issue-N' already exists`` from
|
|
132
|
+
``git worktree add -b``. Without this, any issue that exhausts the
|
|
133
|
+
verify-fix loop once gets permanently wedged until someone manually
|
|
134
|
+
deletes the branch.
|
|
135
|
+
|
|
136
|
+
When we have to fall back to a brand-new branch, it's cut from
|
|
137
|
+
``base_branch`` (default: the repo's default branch).
|
|
138
|
+
"""
|
|
139
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
140
|
+
worktree_path = self._get_worktree_path(repo, session_id)
|
|
141
|
+
|
|
142
|
+
if worktree_path.exists():
|
|
143
|
+
raise WorktreeError(f"Worktree already exists: {worktree_path}")
|
|
144
|
+
|
|
145
|
+
if await self.branch_exists_locally(repo, new_branch):
|
|
146
|
+
# CRITICAL safety: never mutate or delete a branch that's still
|
|
147
|
+
# checked out by another linked worktree (e.g. a BLOCKED session
|
|
148
|
+
# that kept its worktree alive on purpose so it can be resumed
|
|
149
|
+
# when the operator replies). `git update-ref` / `update-ref -d`
|
|
150
|
+
# don't refuse in that case; they'd leave the live worktree's
|
|
151
|
+
# HEAD pointing at a stale or missing ref, corrupting the
|
|
152
|
+
# resumable state. Surface a clean error instead.
|
|
153
|
+
if await self._branch_is_checked_out_elsewhere(bare_path, new_branch):
|
|
154
|
+
raise WorktreeError(
|
|
155
|
+
f"Branch {new_branch!r} is already checked out in another "
|
|
156
|
+
f"worktree of {repo!r}. Resolve with `git worktree list` + "
|
|
157
|
+
"`git worktree remove` in the bare repo before retrying."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Remote presence has to be KNOWN to take either sync-to-origin
|
|
161
|
+
# or stale-merged branches. branch_exists_on_remote is
|
|
162
|
+
# fail-closed (returns True on timeout/auth error) which is
|
|
163
|
+
# good for "should I delete this branch" decisions but wrong
|
|
164
|
+
# here — a transient probe failure would skip the stale-merged
|
|
165
|
+
# recreate path and resurrect already-merged commits. Use the
|
|
166
|
+
# strict variant that raises on probe failure; on error we
|
|
167
|
+
# reuse the local ref unchanged, without mutations.
|
|
168
|
+
try:
|
|
169
|
+
on_remote = await self._branch_exists_on_remote_strict(
|
|
170
|
+
bare_path, new_branch,
|
|
171
|
+
)
|
|
172
|
+
except Exception:
|
|
173
|
+
await self._worktree_add_with_stale_cleanup(
|
|
174
|
+
bare_path, worktree_path, new_branch,
|
|
175
|
+
)
|
|
176
|
+
return worktree_path
|
|
177
|
+
|
|
178
|
+
if on_remote:
|
|
179
|
+
# Remote exists → sync local to remote head (preserving
|
|
180
|
+
# unpushed ahead-of-origin commits) and reuse.
|
|
181
|
+
await self._sync_reused_branch_to_origin(bare_path, new_branch)
|
|
182
|
+
await self._worktree_add_with_stale_cleanup(
|
|
183
|
+
bare_path, worktree_path, new_branch,
|
|
184
|
+
)
|
|
185
|
+
return worktree_path
|
|
186
|
+
|
|
187
|
+
# Local-only (confirmed). Distinguish between:
|
|
188
|
+
# (a) stale-merged: the prior PR was merged (any strategy) and
|
|
189
|
+
# the remote branch auto-deleted. The local ref's commits
|
|
190
|
+
# are all patch-equivalent to commits in the default
|
|
191
|
+
# branch. Reusing it would resurrect already-merged
|
|
192
|
+
# changes into the next PR — delete + create fresh.
|
|
193
|
+
# (b) recoverable unpushed work: the prior session made
|
|
194
|
+
# commits locally and died before push. Local has
|
|
195
|
+
# content not represented in the default branch — reuse
|
|
196
|
+
# so the operator's work isn't silently dropped.
|
|
197
|
+
if await self._branch_is_fully_merged(repo, new_branch):
|
|
198
|
+
try:
|
|
199
|
+
await self._run_git(
|
|
200
|
+
"update-ref", "-d", f"refs/heads/{new_branch}",
|
|
201
|
+
cwd=bare_path, timeout=10,
|
|
202
|
+
)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
# Fall through to the fresh-branch creation below.
|
|
206
|
+
else:
|
|
207
|
+
await self._worktree_add_with_stale_cleanup(
|
|
208
|
+
bare_path, worktree_path, new_branch,
|
|
209
|
+
)
|
|
210
|
+
return worktree_path
|
|
211
|
+
|
|
212
|
+
if base_branch is None:
|
|
213
|
+
base_branch = await self.get_default_branch(repo)
|
|
214
|
+
|
|
215
|
+
await self._run_git(
|
|
216
|
+
"worktree", "add",
|
|
217
|
+
"-b", new_branch,
|
|
218
|
+
str(worktree_path),
|
|
219
|
+
base_branch,
|
|
220
|
+
cwd=bare_path,
|
|
221
|
+
)
|
|
222
|
+
return worktree_path
|
|
223
|
+
|
|
224
|
+
async def _worktree_add_with_stale_cleanup(
|
|
225
|
+
self, bare_path: Path, worktree_path: Path, branch: str,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""``git worktree add <path> <branch>`` with targeted recovery
|
|
228
|
+
if the add fails because a stale (prunable) admin entry still
|
|
229
|
+
claims the branch.
|
|
230
|
+
|
|
231
|
+
Try the add. If it succeeds, done. If it fails with "already
|
|
232
|
+
checked out" AND we've already established there's no LIVE
|
|
233
|
+
checkout of this branch (the caller checked
|
|
234
|
+
_branch_is_checked_out_elsewhere), we scope a `git worktree
|
|
235
|
+
prune` to this one stale entry and retry.
|
|
236
|
+
|
|
237
|
+
We deliberately do NOT prune up front: an unconditional prune
|
|
238
|
+
in a bare repo whose worktrees_dir is on a removable / network
|
|
239
|
+
path would destroy admin state for unrelated worktrees whose
|
|
240
|
+
paths are temporarily unavailable. Scoping to this specific
|
|
241
|
+
branch via a post-failure recovery keeps the blast radius
|
|
242
|
+
minimal.
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
await self._run_git(
|
|
246
|
+
"worktree", "add", str(worktree_path), branch,
|
|
247
|
+
cwd=bare_path,
|
|
248
|
+
)
|
|
249
|
+
return
|
|
250
|
+
except WorktreeError as e:
|
|
251
|
+
if "already checked out" not in str(e):
|
|
252
|
+
raise
|
|
253
|
+
# Find the stale admin entry for this branch and clear it
|
|
254
|
+
# WITHOUT running repo-wide `git worktree prune` — that would
|
|
255
|
+
# also discard admin state for unrelated prunable worktrees
|
|
256
|
+
# whose paths are temporarily unavailable (removable media,
|
|
257
|
+
# network mounts), making them unrecoverable when the path
|
|
258
|
+
# comes back. Instead, delete just the one admin directory.
|
|
259
|
+
stale_path = await self._find_stale_worktree_path(bare_path, branch)
|
|
260
|
+
if stale_path is None:
|
|
261
|
+
raise
|
|
262
|
+
# Only touch stale entries we OWN — under our managed
|
|
263
|
+
# worktrees_dir — AND only when our worktrees_dir is actually
|
|
264
|
+
# accessible on disk right now. A prunable stanza can also
|
|
265
|
+
# represent a worktree on a temporarily-unavailable path
|
|
266
|
+
# (network mount, removable drive); if the mount is down,
|
|
267
|
+
# every managed worktree under it appears prunable. Deleting
|
|
268
|
+
# their admin state would orphan real checkouts when the
|
|
269
|
+
# mount comes back.
|
|
270
|
+
#
|
|
271
|
+
# Safety gate: require worktrees_dir itself to exist and be
|
|
272
|
+
# a directory. If the whole mount is offline, bail.
|
|
273
|
+
if not self.worktrees_dir.is_dir():
|
|
274
|
+
raise e
|
|
275
|
+
try:
|
|
276
|
+
stale_resolved = Path(stale_path).resolve()
|
|
277
|
+
wt_root = self.worktrees_dir.resolve()
|
|
278
|
+
stale_resolved.relative_to(wt_root)
|
|
279
|
+
except (OSError, ValueError):
|
|
280
|
+
raise e # not under our dir — unsafe to recover
|
|
281
|
+
# Locate the admin dir via its canonical `gitdir` pointer,
|
|
282
|
+
# NOT by assuming the path basename matches. Git sanitizes
|
|
283
|
+
# names (`foo bar` → `foo-bar`) and disambiguates duplicates
|
|
284
|
+
# (`wt`, `wt1`, …), so Path(stale_path).name can point at the
|
|
285
|
+
# wrong admin dir or a live unrelated worktree's metadata.
|
|
286
|
+
# Each admin dir has a `gitdir` file that points at
|
|
287
|
+
# `<worktree-path>/.git` — that pointer is the source of truth.
|
|
288
|
+
admin_dir = self._resolve_admin_dir(bare_path, stale_path)
|
|
289
|
+
if admin_dir is not None and admin_dir.exists():
|
|
290
|
+
try:
|
|
291
|
+
shutil.rmtree(admin_dir)
|
|
292
|
+
except OSError:
|
|
293
|
+
pass
|
|
294
|
+
# Retry the add; if it fails again, let the error surface.
|
|
295
|
+
await self._run_git(
|
|
296
|
+
"worktree", "add", str(worktree_path), branch,
|
|
297
|
+
cwd=bare_path,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def _resolve_admin_dir(
|
|
301
|
+
self, bare_path: Path, worktree_dir: str,
|
|
302
|
+
) -> Path | None:
|
|
303
|
+
"""Find the bare repo's admin dir whose ``gitdir`` pointer file
|
|
304
|
+
resolves to ``<worktree_dir>/.git``. Returns None if no
|
|
305
|
+
matching entry exists.
|
|
306
|
+
|
|
307
|
+
Git may name admin dirs differently from the worktree path
|
|
308
|
+
basename (sanitization, collision-suffix), so the only robust
|
|
309
|
+
way to pair them is to inspect each admin's gitdir pointer.
|
|
310
|
+
"""
|
|
311
|
+
worktrees_root = bare_path / "worktrees"
|
|
312
|
+
if not worktrees_root.is_dir():
|
|
313
|
+
return None
|
|
314
|
+
target = str(Path(worktree_dir) / ".git")
|
|
315
|
+
try:
|
|
316
|
+
entries = list(worktrees_root.iterdir())
|
|
317
|
+
except OSError:
|
|
318
|
+
return None
|
|
319
|
+
for admin_dir in entries:
|
|
320
|
+
gitdir_file = admin_dir / "gitdir"
|
|
321
|
+
if not gitdir_file.exists():
|
|
322
|
+
continue
|
|
323
|
+
try:
|
|
324
|
+
pointer = gitdir_file.read_text().strip()
|
|
325
|
+
except OSError:
|
|
326
|
+
continue
|
|
327
|
+
if pointer == target:
|
|
328
|
+
return admin_dir
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
async def _find_stale_worktree_path(
|
|
332
|
+
self, bare_path: Path, branch: str,
|
|
333
|
+
) -> str | None:
|
|
334
|
+
"""If ``refs/heads/<branch>`` is claimed by a worktree entry marked
|
|
335
|
+
``prunable``, return the path of that entry. Otherwise None.
|
|
336
|
+
Used to confirm that a "already checked out" failure is safe to
|
|
337
|
+
recover from via prune (i.e. the stale entry IS for our branch
|
|
338
|
+
and not a real live checkout).
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
output = await self._run_git(
|
|
342
|
+
"worktree", "list", "--porcelain",
|
|
343
|
+
cwd=bare_path, timeout=10,
|
|
344
|
+
)
|
|
345
|
+
except Exception:
|
|
346
|
+
return None
|
|
347
|
+
target = f"refs/heads/{branch}"
|
|
348
|
+
current_path: str | None = None
|
|
349
|
+
current_branch: str | None = None
|
|
350
|
+
current_prunable = False
|
|
351
|
+
for raw in output.splitlines() + [""]:
|
|
352
|
+
line = raw.strip()
|
|
353
|
+
if not line:
|
|
354
|
+
if (
|
|
355
|
+
current_branch == target
|
|
356
|
+
and current_prunable
|
|
357
|
+
and current_path is not None
|
|
358
|
+
):
|
|
359
|
+
return current_path
|
|
360
|
+
current_path = None
|
|
361
|
+
current_branch = None
|
|
362
|
+
current_prunable = False
|
|
363
|
+
continue
|
|
364
|
+
if line.startswith("worktree "):
|
|
365
|
+
current_path = line[9:].strip()
|
|
366
|
+
elif line.startswith("branch "):
|
|
367
|
+
current_branch = line[7:].strip()
|
|
368
|
+
elif line.startswith("prunable"):
|
|
369
|
+
current_prunable = True
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
async def _sync_reused_branch_to_origin(
|
|
373
|
+
self, bare_path: Path, branch: str,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Fast-forward the local ``branch`` to origin's head using a
|
|
376
|
+
scratch ref so it never touches ``refs/heads/<branch>`` except
|
|
377
|
+
via a safe `update-ref` that's gated by a merge-base ancestor
|
|
378
|
+
check.
|
|
379
|
+
|
|
380
|
+
Bare clones from `git clone --bare` have fetch refspec
|
|
381
|
+
`+refs/heads/*:refs/heads/*`, so there are NO
|
|
382
|
+
`refs/remotes/origin/*` tracking refs AND an unscoped
|
|
383
|
+
`git fetch origin <branch>` would overwrite the live local ref
|
|
384
|
+
directly — destroying any unpushed local commits before we get
|
|
385
|
+
to compare. Routing through a scratch ref in
|
|
386
|
+
`refs/ctrlrelay/sync/*` avoids both problems.
|
|
387
|
+
|
|
388
|
+
Rules:
|
|
389
|
+
- If local is ancestor of the fetched remote tip → fast-forward.
|
|
390
|
+
- If local is ahead or diverged → leave local alone (ahead
|
|
391
|
+
contains unpushed work; forcing would destroy it).
|
|
392
|
+
|
|
393
|
+
All steps are best-effort: any failure (timeout, network,
|
|
394
|
+
permissions, bad ref) falls back to reusing the local ref
|
|
395
|
+
unchanged, so a flaky origin never aborts the retry.
|
|
396
|
+
"""
|
|
397
|
+
scratch_ref = f"refs/ctrlrelay/sync/{branch}"
|
|
398
|
+
fetched = False
|
|
399
|
+
try:
|
|
400
|
+
await self._run_git(
|
|
401
|
+
"fetch", "origin",
|
|
402
|
+
f"+refs/heads/{branch}:{scratch_ref}",
|
|
403
|
+
cwd=bare_path, timeout=30,
|
|
404
|
+
)
|
|
405
|
+
fetched = True
|
|
406
|
+
except Exception:
|
|
407
|
+
return
|
|
408
|
+
try:
|
|
409
|
+
# Distinguish three cases cleanly:
|
|
410
|
+
# a) local is ancestor of remote → local behind → fast-forward
|
|
411
|
+
# b) remote is ancestor of local → local ahead (unpushed work) → preserve
|
|
412
|
+
# c) neither → diverged → raise; caller surfaces as session failure
|
|
413
|
+
# so we don't silently reuse a branch that will fail on push.
|
|
414
|
+
local_behind = False
|
|
415
|
+
local_ahead = False
|
|
416
|
+
try:
|
|
417
|
+
await self._run_git(
|
|
418
|
+
"merge-base", "--is-ancestor", branch, scratch_ref,
|
|
419
|
+
cwd=bare_path, timeout=10,
|
|
420
|
+
)
|
|
421
|
+
local_behind = True
|
|
422
|
+
except WorktreeError:
|
|
423
|
+
pass
|
|
424
|
+
except Exception:
|
|
425
|
+
# Timeout or other — play safe and preserve local.
|
|
426
|
+
return
|
|
427
|
+
if not local_behind:
|
|
428
|
+
try:
|
|
429
|
+
await self._run_git(
|
|
430
|
+
"merge-base", "--is-ancestor", scratch_ref, branch,
|
|
431
|
+
cwd=bare_path, timeout=10,
|
|
432
|
+
)
|
|
433
|
+
local_ahead = True
|
|
434
|
+
except WorktreeError:
|
|
435
|
+
pass
|
|
436
|
+
except Exception:
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
if local_behind:
|
|
440
|
+
try:
|
|
441
|
+
await self._run_git(
|
|
442
|
+
"update-ref", f"refs/heads/{branch}", scratch_ref,
|
|
443
|
+
cwd=bare_path, timeout=10,
|
|
444
|
+
)
|
|
445
|
+
except Exception:
|
|
446
|
+
pass
|
|
447
|
+
elif local_ahead:
|
|
448
|
+
# Preserve local — ahead means unpushed operator work.
|
|
449
|
+
pass
|
|
450
|
+
else:
|
|
451
|
+
# Diverged: local has commits origin doesn't, and origin has
|
|
452
|
+
# commits local doesn't. Reusing either side silently loses
|
|
453
|
+
# work or produces non-ff pushes. Surface the conflict so
|
|
454
|
+
# the session fails cleanly.
|
|
455
|
+
raise WorktreeError(
|
|
456
|
+
f"Local branch {branch!r} has diverged from "
|
|
457
|
+
f"origin/{branch}. Rebase or reset manually in the bare "
|
|
458
|
+
"repo before retrying."
|
|
459
|
+
)
|
|
460
|
+
finally:
|
|
461
|
+
if fetched:
|
|
462
|
+
try:
|
|
463
|
+
await self._run_git(
|
|
464
|
+
"update-ref", "-d", scratch_ref,
|
|
465
|
+
cwd=bare_path, timeout=10,
|
|
466
|
+
)
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
async def _branch_exists_on_remote_strict(
|
|
471
|
+
self, bare_path: Path, branch: str,
|
|
472
|
+
) -> bool:
|
|
473
|
+
"""Strict variant of branch_exists_on_remote: True if origin
|
|
474
|
+
has the branch, False if it does not, RAISES on probe failure.
|
|
475
|
+
|
|
476
|
+
The public branch_exists_on_remote is fail-closed (returns True
|
|
477
|
+
on error) so callers making "safe to delete" decisions err
|
|
478
|
+
on preservation. That semantic is wrong for the reuse path,
|
|
479
|
+
where a transient error must NOT be interpreted as "remote
|
|
480
|
+
exists" — that would skip the stale-merged recreate branch and
|
|
481
|
+
resurrect already-merged commits into a new PR.
|
|
482
|
+
"""
|
|
483
|
+
output = await self._run_git(
|
|
484
|
+
"ls-remote", "--heads", "origin", branch,
|
|
485
|
+
cwd=bare_path, timeout=10,
|
|
486
|
+
)
|
|
487
|
+
return bool(output.strip())
|
|
488
|
+
|
|
489
|
+
async def _branch_is_checked_out_elsewhere(
|
|
490
|
+
self, bare_path: Path, branch: str,
|
|
491
|
+
) -> bool:
|
|
492
|
+
"""Return True if ``refs/heads/<branch>`` is currently checked out
|
|
493
|
+
by any LIVE linked worktree of this bare repo.
|
|
494
|
+
|
|
495
|
+
`git worktree list --porcelain` emits a blank-line-separated
|
|
496
|
+
stanza per worktree. A stanza with a ``prunable <reason>`` line
|
|
497
|
+
is a stale metadata entry — the worktree directory is gone, we
|
|
498
|
+
just haven't run ``git worktree prune`` yet. Such stanzas don't
|
|
499
|
+
represent a real checkout and MUST be ignored here, otherwise a
|
|
500
|
+
crash between ``shutil.rmtree()`` and ``worktree prune`` wedges
|
|
501
|
+
the branch for all future retries.
|
|
502
|
+
|
|
503
|
+
Conservative (fails closed): if the probe itself errors, returns
|
|
504
|
+
True — better to refuse mutation than to risk corrupting a live
|
|
505
|
+
worktree.
|
|
506
|
+
"""
|
|
507
|
+
try:
|
|
508
|
+
output = await self._run_git(
|
|
509
|
+
"worktree", "list", "--porcelain",
|
|
510
|
+
cwd=bare_path, timeout=10,
|
|
511
|
+
)
|
|
512
|
+
except Exception:
|
|
513
|
+
return True
|
|
514
|
+
|
|
515
|
+
target = f"refs/heads/{branch}"
|
|
516
|
+
current_branch: str | None = None
|
|
517
|
+
current_prunable = False
|
|
518
|
+
for raw in output.splitlines() + [""]: # trailing "" to flush last stanza
|
|
519
|
+
line = raw.strip()
|
|
520
|
+
if not line:
|
|
521
|
+
# End of stanza — check if this one matches and is live.
|
|
522
|
+
if (
|
|
523
|
+
current_branch == target
|
|
524
|
+
and not current_prunable
|
|
525
|
+
):
|
|
526
|
+
return True
|
|
527
|
+
current_branch = None
|
|
528
|
+
current_prunable = False
|
|
529
|
+
continue
|
|
530
|
+
if line.startswith("branch "):
|
|
531
|
+
current_branch = line[7:].strip()
|
|
532
|
+
elif line.startswith("prunable"):
|
|
533
|
+
current_prunable = True
|
|
534
|
+
return False
|
|
535
|
+
|
|
536
|
+
async def _branch_is_fully_merged(self, repo: str, branch: str) -> bool:
|
|
537
|
+
"""Return True if every commit reachable from ``branch`` is
|
|
538
|
+
patch-equivalent to something already in the default branch.
|
|
539
|
+
|
|
540
|
+
Uses ``git cherry <default> <branch>`` which compares commits
|
|
541
|
+
by patch-id (content), not by SHA — so this catches squash and
|
|
542
|
+
rebase merges too, not just regular merge commits.
|
|
543
|
+
|
|
544
|
+
Conservative: on any error (unknown default branch, cherry
|
|
545
|
+
failure) returns False so we preserve the local ref.
|
|
546
|
+
"""
|
|
547
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
548
|
+
try:
|
|
549
|
+
default = await self.get_default_branch(repo)
|
|
550
|
+
except Exception:
|
|
551
|
+
return False
|
|
552
|
+
try:
|
|
553
|
+
out = await self._run_git(
|
|
554
|
+
"cherry", default, branch,
|
|
555
|
+
cwd=bare_path, timeout=15,
|
|
556
|
+
)
|
|
557
|
+
except Exception:
|
|
558
|
+
return False
|
|
559
|
+
lines = [line for line in out.splitlines() if line.strip()]
|
|
560
|
+
if not lines:
|
|
561
|
+
# No commits in branch not in default — fully merged / stale.
|
|
562
|
+
return True
|
|
563
|
+
# Each line starts with "+" (unique) or "-" (patch-equivalent in upstream).
|
|
564
|
+
return all(line.startswith("-") for line in lines)
|
|
565
|
+
|
|
566
|
+
async def push_branch(self, worktree_path: Path, branch: str) -> None:
|
|
567
|
+
"""Push a branch to origin."""
|
|
568
|
+
await self._run_git("push", "-u", "origin", branch, cwd=worktree_path)
|
|
569
|
+
|
|
570
|
+
async def ensure_bare_repo(self, repo: str) -> Path:
|
|
571
|
+
"""Ensure bare repo exists, cloning if needed."""
|
|
572
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
573
|
+
|
|
574
|
+
if bare_path.exists():
|
|
575
|
+
await self._run_git("fetch", "--all", cwd=bare_path)
|
|
576
|
+
else:
|
|
577
|
+
await self._run_git(
|
|
578
|
+
"clone", "--bare",
|
|
579
|
+
f"https://github.com/{repo}.git",
|
|
580
|
+
str(bare_path),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
return bare_path
|
|
584
|
+
|
|
585
|
+
async def remove_worktree(self, repo: str, session_id: str) -> None:
|
|
586
|
+
"""Remove a worktree and clean up."""
|
|
587
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
588
|
+
worktree_path = self._get_worktree_path(repo, session_id)
|
|
589
|
+
|
|
590
|
+
if worktree_path.exists():
|
|
591
|
+
shutil.rmtree(worktree_path)
|
|
592
|
+
|
|
593
|
+
if bare_path.exists():
|
|
594
|
+
await self._run_git("worktree", "prune", cwd=bare_path)
|
|
595
|
+
|
|
596
|
+
async def delete_branch(self, repo: str, branch: str) -> None:
|
|
597
|
+
"""Delete a local branch in the bare repo. Best-effort; no-op if absent."""
|
|
598
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
599
|
+
if not bare_path.exists():
|
|
600
|
+
return
|
|
601
|
+
try:
|
|
602
|
+
await self._run_git("branch", "-D", branch, cwd=bare_path)
|
|
603
|
+
except WorktreeError:
|
|
604
|
+
pass
|
|
605
|
+
|
|
606
|
+
async def branch_exists_locally(self, repo: str, branch: str) -> bool:
|
|
607
|
+
"""Check if `branch` exists as a local ref in the bare repo."""
|
|
608
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
609
|
+
if not bare_path.exists():
|
|
610
|
+
return False
|
|
611
|
+
try:
|
|
612
|
+
await self._run_git(
|
|
613
|
+
"show-ref", "--verify", "--quiet", f"refs/heads/{branch}",
|
|
614
|
+
cwd=bare_path,
|
|
615
|
+
)
|
|
616
|
+
return True
|
|
617
|
+
except Exception:
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
async def branch_exists_on_remote(self, repo: str, branch: str) -> bool:
|
|
621
|
+
"""Return True if `branch` exists on origin. Fail-closed: on any error
|
|
622
|
+
(WorktreeError, asyncio.TimeoutError from _run_git's wait_for, etc.)
|
|
623
|
+
returns True so callers err on the side of NOT deleting."""
|
|
624
|
+
bare_path = self._get_bare_repo_path(repo)
|
|
625
|
+
if not bare_path.exists():
|
|
626
|
+
return True
|
|
627
|
+
try:
|
|
628
|
+
output = await self._run_git(
|
|
629
|
+
"ls-remote", "--heads", "origin", branch,
|
|
630
|
+
cwd=bare_path,
|
|
631
|
+
timeout=10,
|
|
632
|
+
)
|
|
633
|
+
return bool(output.strip())
|
|
634
|
+
except Exception:
|
|
635
|
+
return True
|
|
636
|
+
|
|
637
|
+
def _get_gitdir(self, worktree_path: Path) -> Path:
|
|
638
|
+
"""Get the real gitdir for a worktree.
|
|
639
|
+
|
|
640
|
+
In linked worktrees, .git is a file pointing to the actual gitdir.
|
|
641
|
+
"""
|
|
642
|
+
dot_git = worktree_path / ".git"
|
|
643
|
+
if dot_git.is_file():
|
|
644
|
+
content = dot_git.read_text().strip()
|
|
645
|
+
if content.startswith("gitdir: "):
|
|
646
|
+
return Path(content[8:])
|
|
647
|
+
return dot_git
|
|
648
|
+
|
|
649
|
+
def symlink_context(
|
|
650
|
+
self,
|
|
651
|
+
worktree_path: Path,
|
|
652
|
+
context_path: Path,
|
|
653
|
+
) -> None:
|
|
654
|
+
"""Symlink CLAUDE.md into worktree."""
|
|
655
|
+
target = worktree_path / "CLAUDE.md"
|
|
656
|
+
|
|
657
|
+
if target.exists() or target.is_symlink():
|
|
658
|
+
target.unlink()
|
|
659
|
+
|
|
660
|
+
target.symlink_to(context_path.resolve())
|
|
661
|
+
|
|
662
|
+
gitdir = self._get_gitdir(worktree_path)
|
|
663
|
+
exclude_file = gitdir / "info" / "exclude"
|
|
664
|
+
if exclude_file.exists():
|
|
665
|
+
content = exclude_file.read_text()
|
|
666
|
+
if "CLAUDE.md" not in content:
|
|
667
|
+
exclude_file.write_text(content.rstrip() + "\nCLAUDE.md\n")
|
|
668
|
+
|
|
669
|
+
def remove_context_symlink(self, worktree_path: Path) -> None:
|
|
670
|
+
"""Remove CLAUDE.md symlink before git operations."""
|
|
671
|
+
target = worktree_path / "CLAUDE.md"
|
|
672
|
+
if target.is_symlink():
|
|
673
|
+
target.unlink()
|