diff --git a/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java b/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java index c9b8d9d..443b8ee 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/FakeWorktrees.java @@ -32,6 +32,8 @@ public final class FakeWorktrees implements Worktrees { private volatile boolean dirty = false; private volatile String repoRoot = "/repo"; private volatile String prefix = "/worktrees"; + /** Worktree paths that currently exist, mirroring real {@code Files.exists} for the gone case. */ + private final Set worktreePaths = ConcurrentHashMap.newKeySet(); public FakeWorktrees withRepoRoot(String root) { this.repoRoot = root; @@ -76,7 +78,15 @@ public final class FakeWorktrees implements Worktrees { } // The branch already carries a unique nonce, so the derived path is distinct per acquire // without an extra counter — keep it a pure function of the branch the test can predict. - return prefix + "/" + branch.replace('/', '_'); + String path = prefix + "/" + branch.replace('/', '_'); + worktreePaths.add(path); + return path; + } + + /** Model an operator / {@code git worktree prune} removing the worktree before release. */ + public FakeWorktrees markGone(String worktreePath) { + worktreePaths.remove(worktreePath); + return this; } @Override @@ -86,6 +96,11 @@ public final class FakeWorktrees implements Worktrees { @Override public boolean hasUncommitted(String worktreePath) { + // A path that does not exist (never added, or marked gone) is reported clean, mirroring + // GitWorktrees' already-gone guard — never an error, so teardown still completes. + if (!worktreePaths.contains(worktreePath)) { + return false; + } return dirty; } diff --git a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java index 5fa720b..f5c7493 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -227,6 +228,34 @@ class WorktreeSessionManagerTest { } } + /** + * CB-576 review. A worktree that is already gone (operator cleanup, {@code git worktree prune}, + * an earlier half-completed release) must not break teardown. {@code hasUncommitted} reports the + * missing path clean, so release still runs {@code notifyReleased} (the CB-516 fast-fail for a + * blocked {@code bridge_send} caller) and {@code launcher.stop} (so the pane is not orphaned), + * and falls through to the already-gone-tolerant {@code remove}. + */ + @Test + void releaseStillStopsPaneAndNotifiesWhenWorktreeIsGone() { + FakeHerdr herdr = new FakeHerdr(); + FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt"); + SessionManager sessions = new SessionManager(workerService(herdr), worktrees); + MemberSession s = sessions.acquire("ltms-local", null, "/caller/proj", null, + new WorktreeRequest("cb-576g", null)); + AtomicReference releasedTerminal = new AtomicReference<>(); + sessions.onRelease(releasedTerminal::set); + + worktrees.markGone(s.worktree()); + sessions.release(s.paneId()); + + assertEquals(s.terminalId(), releasedTerminal.get(), + "notifyReleased must still fire when the worktree is already gone (CB-516)"); + assertTrue(herdr.called("pane.close"), + "the pane must still be stopped when the worktree is already gone"); + assertEquals(1, worktrees.removeCalls().size(), + "release still calls the already-gone-tolerant remove"); + } + @Test void drainAllPreservesWorktreeOfIdleSession() { FakeHerdr herdr = new FakeHerdr();