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.
@@ -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()
@@ -0,0 +1,5 @@
1
+ """Dashboard client for ctrlrelay."""
2
+
3
+ from ctrlrelay.dashboard.client import DashboardClient, EventPayload, HeartbeatPayload
4
+
5
+ __all__ = ["DashboardClient", "EventPayload", "HeartbeatPayload"]