Skip to content

fix(worktree): prevent cleanup from wiping worktrees with uncommitted work#395

Merged
dimakis merged 7 commits into
mainfrom
session/2026-06-25-0f2e5bbfaeff
Jun 25, 2026
Merged

fix(worktree): prevent cleanup from wiping worktrees with uncommitted work#395
dimakis merged 7 commits into
mainfrom
session/2026-06-25-0f2e5bbfaeff

Conversation

@dimakis

@dimakis dimakis commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fix parseWorktreeAge regex: was matching [a-f0-9]{6} but generateWtId produces 12 hex chars — name-based age safety check was dead code, all worktrees fell back to mtime-only staleness
  • Add dirty check to cleanupSessionWorktrees: stopChat() removed secondary worktrees immediately without checking for uncommitted work. Now calls hasUncommittedWork() + rescueDirtyWorktree() before removal — if rescue fails, worktree is preserved on disk
  • Tests for both fixes (4 new test cases)

Root cause

Two bugs working together caused data loss:

  1. Regex mismatch meant the name-based age guard never fired for real worktrees
  2. Session close path (cleanupSessionWorktrees) bypassed the auto-rescue that the stale GC had

Test plan

  • parseWorktreeAge accepts 6–12 hex char suffixes
  • parseWorktreeAge rejects <6 and >12 hex chars
  • cleanupSessionWorktrees skips dirty secondary when rescue fails
  • cleanupSessionWorktrees removes dirty secondary after successful rescue
  • All existing worktree + chat tests pass

Follow-up

Telos e28ee6e9: persist full SessionRegistry to disk so startup cleanup never runs with empty registry (Bug 3).

🤖 Generated with Claude Code

… work

Two bugs caused worktree data loss:

1. parseWorktreeAge regex expected 6 hex chars but generateWtId produces 12,
   so the name-based age safety check was dead code — all worktrees fell back
   to mtime-only staleness detection.

2. cleanupSessionWorktrees (called by stopChat) removed secondary worktrees
   immediately without checking for uncommitted work. The stale GC had
   auto-rescue but this path bypassed it entirely.

Fixes: regex accepts 6–12 hex chars; cleanupSessionWorktrees now checks
hasUncommittedWork and attempts rescueDirtyWorktree before removing. If
rescue fails, the worktree is preserved on disk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 5 issue(s) (4 warning).

server/__tests__/chat.test.ts

Solid safety feature — dirty worktree detection and rescue prevents data loss. Main concerns: git add -A may leak secrets into rescue PRs, synchronous multi-step rescue can block the event loop for minutes, and the new functions lack direct unit tests despite the project's TDD requirement.

  • 🟡 bugs (L281): Misleading test comment says 'The mitzo entry should still be in the map (not cleared)' but the function's final block (session.worktreePaths.clear() + restore primary only) unconditionally clears all non-primary entries. The mitzo entry is NOT preserved in the final map state. The comment contradicts the actual code behavior. The core assertion (removeWorktreeMock not called) correctly verifies the worktree isn't deleted from disk, but the comment should say the worktree directory is preserved, not the map entry. [fixable]

server/__tests__/worktree.test.ts

Solid safety feature — dirty worktree detection and rescue prevents data loss. Main concerns: git add -A may leak secrets into rescue PRs, synchronous multi-step rescue can block the event loop for minutes, and the new functions lack direct unit tests despite the project's TDD requirement.

  • 🟡 missing_tests: No unit tests exist for hasUncommittedWork or rescueDirtyWorktree. These are non-trivial functions with important edge cases: hasUncommittedWork treats git failure as dirty (safety fallback), and rescueDirtyWorktree has a 4-step mutation sequence (stage → commit → push → PR) where partial failure leaves the repo in intermediate states. Both should have direct tests per the project's TDD requirement. [fixable]

server/worktree.ts

Solid safety feature — dirty worktree detection and rescue prevents data loss. Main concerns: git add -A may leak secrets into rescue PRs, synchronous multi-step rescue can block the event loop for minutes, and the new functions lack direct unit tests despite the project's TDD requirement.

  • 🟡 unsafe_assumptions (L459): rescueDirtyWorktree uses git add -A which stages all files in the worktree, including potentially sensitive files (.env, credentials, keys) that may exist in the worktree but are gitignored in the main repo. The rescue commit could inadvertently push secrets to a draft PR. Consider using git add -u (only tracked files) or checking .gitignore coverage before staging. [fixable]
  • 🔵 style (L443): Parameter is named sessionId but the caller passes wtId (worktree ID). These are distinct concepts in the codebase — sessionId is the SDK-assigned session ID (optional, resume-only), while wtId is the worktree identifier (always present, date+hex format). Renaming to wtId would match the call site and avoid confusion. [fixable]

server/chat.ts

Solid safety feature — dirty worktree detection and rescue prevents data loss. Main concerns: git add -A may leak secrets into rescue PRs, synchronous multi-step rescue can block the event loop for minutes, and the new functions lack direct unit tests despite the project's TDD requirement.

  • 🟡 unsafe_assumptions (L1307): rescueDirtyWorktree makes 4 sequential execFileSync calls (each with a 30s timeout), blocking the Node.js event loop for up to 120 seconds. This is called from stopChatwithSpan and from startChat's error handler. During this time, all other request handling, WebSocket messages, and session operations are blocked. Consider making the rescue async or deferring it to a background task. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 3 issue(s) (1 warning).

server/__tests__/chat.test.ts

The core implementation is sound — dirty worktrees are correctly preserved on disk when rescue fails. One test has a misleading comment with an assertion that doesn't verify what it claims (checks primary instead of mitzo), and the new worktree functions lack direct unit tests.

  • 🟡 bugs (L282): Test comment says 'The mitzo entry should still be in the map (not cleared)' but the assertion checks has('primary'), not has('mitzo'). The implementation's worktreePaths.clear() at line 1337 of chat.ts removes the mitzo entry — so has('mitzo') would be false, contradicting the comment. Either the comment is wrong (the map reference doesn't matter since the worktree stays on disk) and should be updated, or the implementation should preserve dirty entries in the map and the assertion should verify has('mitzo') is true. [fixable]
  • 🔵 style (L252): Both new tests ('skips dirty secondary when rescue fails' and 'removes dirty secondary after successful rescue') duplicate the boilerplate for importing, configuring mocks, and overriding Date.now. The existing tests in this describe block also do this. Consider extracting shared setup into the existing beforeEach/afterEach hooks to reduce per-test boilerplate. [fixable]

server/__tests__/worktree.test.ts

The core implementation is sound — dirty worktrees are correctly preserved on disk when rescue fails. One test has a misleading comment with an assertion that doesn't verify what it claims (checks primary instead of mitzo), and the new worktree functions lack direct unit tests.

  • 🔵 missing_tests: hasUncommittedWork and rescueDirtyWorktree are new exported functions with non-trivial logic (execFileSync calls, error handling, gh CLI interaction) but have no direct unit tests in worktree.test.ts. They are only tested indirectly via mocks in chat.test.ts. Given the project's TDD mandate, consider adding direct tests — at minimum for hasUncommittedWork returning null on clean repos and a string on dirty ones, and for rescueDirtyWorktree's error paths. [fixable]

// Dirty + rescue failed → worktree must NOT be removed
expect(removeWorktreeMock).not.toHaveBeenCalled();
// The mitzo entry should still be in the map (not cleared)
expect(session.worktreePaths.has('primary')).toBe(true);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: Test comment says 'The mitzo entry should still be in the map (not cleared)' but the assertion checks has('primary'), not has('mitzo'). The implementation's worktreePaths.clear() at line 1337 of chat.ts removes the mitzo entry — so has('mitzo') would be false, contradicting the comment. Either the comment is wrong (the map reference doesn't matter since the worktree stays on disk) and should be updated, or the implementation should preserve dirty entries in the map and the assertion should verify has('mitzo') is true. [fixable]

vi.restoreAllMocks();
});

it('skips dirty secondary when rescue fails', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: Both new tests ('skips dirty secondary when rescue fails' and 'removes dirty secondary after successful rescue') duplicate the boilerplate for importing, configuring mocks, and overriding Date.now. The existing tests in this describe block also do this. Consider extracting shared setup into the existing beforeEach/afterEach hooks to reduce per-test boilerplate. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 4 issue(s) (1 warning).

server/__tests__/chat.test.ts

Solid safety-first change — the dirty-worktree guard and rescue flow are well-structured with correct control flow. Findings are limited to test comment accuracy and coverage gaps for the underlying rescue functions.

  • 🔵 style (L281): Comment says "The mitzo entry should still be in the map (not cleared)" but this is inaccurate — cleanupSessionWorktrees always clears the map and re-adds only 'primary' (chat.ts:1336-1338). The worktree directory is preserved (removeWorktree not called), but the map entry is removed. Comment should say something like "Worktree directory preserved on disk (removeWorktree not called)". [fixable]
  • 🔵 missing_tests (L318): The 'removes dirty secondary after successful rescue' test verifies removeWorktree was called with the right args but does not assert that rescueDirtyWorktree was called with the expected (path, branch, wtId) arguments. Adding expect(rescueDirtyWorktreeMock).toHaveBeenCalledWith('/tools/mitzo/.claude/worktrees/abc', 'session/abc', 'abc') would catch regressions in the argument-wiring logic. [fixable]
  • 🔵 missing_tests (L282): The 'skips dirty secondary when rescue fails' test checks has('primary') but does not assert map size. The analogous existing test (line 246) asserts session.worktreePaths.size === 1. Adding the same assertion here would ensure no stale entries leak through. [fixable]

server/__tests__/worktree.test.ts

Solid safety-first change — the dirty-worktree guard and rescue flow are well-structured with correct control flow. Findings are limited to test comment accuracy and coverage gaps for the underlying rescue functions.

  • 🟡 missing_tests: hasUncommittedWork and rescueDirtyWorktree in worktree.ts have no dedicated unit tests — they are only exercised via mocks in chat.test.ts. Both shell out to git/gh with non-trivial error handling (e.g., hasUncommittedWork treats git failures as dirty). They would benefit from their own test suite in worktree.test.ts, similar to the existing createWorktree/cleanupStaleWorktrees tests that use temporary git repos.

Comment thread server/__tests__/chat.test.ts Outdated

// Dirty + rescue failed → worktree must NOT be removed
expect(removeWorktreeMock).not.toHaveBeenCalled();
// The mitzo entry should still be in the map (not cleared)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: Comment says "The mitzo entry should still be in the map (not cleared)" but this is inaccurate — cleanupSessionWorktrees always clears the map and re-adds only 'primary' (chat.ts:1336-1338). The worktree directory is preserved (removeWorktree not called), but the map entry is removed. Comment should say something like "Worktree directory preserved on disk (removeWorktree not called)". [fixable]

cleanupSessionWorktrees(session);

// Dirty but rescue succeeded → worktree should be removed
expect(removeWorktreeMock).toHaveBeenCalledWith('abc', '/tools/mitzo');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The 'removes dirty secondary after successful rescue' test verifies removeWorktree was called with the right args but does not assert that rescueDirtyWorktree was called with the expected (path, branch, wtId) arguments. Adding expect(rescueDirtyWorktreeMock).toHaveBeenCalledWith('/tools/mitzo/.claude/worktrees/abc', 'session/abc', 'abc') would catch regressions in the argument-wiring logic. [fixable]

// Dirty + rescue failed → worktree must NOT be removed
expect(removeWorktreeMock).not.toHaveBeenCalled();
// The mitzo entry should still be in the map (not cleared)
expect(session.worktreePaths.has('primary')).toBe(true);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The 'skips dirty secondary when rescue fails' test checks has('primary') but does not assert map size. The analogous existing test (line 246) asserts session.worktreePaths.size === 1. Adding the same assertion here would ensure no stale entries leak through. [fixable]

- Fix misleading test comment about map entry vs disk preservation
- Change git add -A to git add -u in rescueDirtyWorktree to avoid
  staging untracked files that may contain secrets (.env, credentials)
- Rename sessionId parameter to wtId in rescueDirtyWorktree to match
  call sites and avoid confusion with SDK sessionId
- Defer dirty worktree rescue to setTimeout(0) so cleanupSessionWorktrees
  doesn't block the event loop during stopChat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 3 issue(s) (1 warning).

server/__tests__/chat.test.ts

Solid defensive fix — the dirty-worktree rescue logic and parseWorktreeAge regex broadening are both correct. One misleading test comment should be fixed to match actual map-clearing behavior.

  • 🟡 bugs (L281): Misleading test comment: 'The mitzo entry should still be in the map (not cleared)' is factually wrong. After cleanupSessionWorktrees runs, lines 1336-1338 of chat.ts unconditionally clear the map and only restore the primary entry — so mitzo is always removed from the map. The test correctly asserts that removeWorktree is never called (preserving the on-disk worktree), but the assertion at line 282 only checks primary exists, not that mitzo persists. The comment should say something like 'worktree is preserved on disk (removeWorktree not called)'. [fixable]

server/__tests__/worktree.test.ts

Solid defensive fix — the dirty-worktree rescue logic and parseWorktreeAge regex broadening are both correct. One misleading test comment should be fixed to match actual map-clearing behavior.

  • 🔵 missing_tests: No unit tests for hasUncommittedWork, rescueDirtyWorktree, or getRepoRemote in worktree.test.ts. These functions involve external process calls (git, gh) with multiple branches (success, git failure treated as dirty, SSH vs HTTPS remote parsing, partial rescue failure after commit but before push). They are only tested indirectly via mocks in chat.test.ts. Given the project's TDD requirement, direct unit tests with execFileSync mocked would improve confidence — especially for rescueDirtyWorktree's partial-failure scenarios (e.g., commit succeeds but push fails, leaving staged changes).

server/worktree.ts

Solid defensive fix — the dirty-worktree rescue logic and parseWorktreeAge regex broadening are both correct. One misleading test comment should be fixed to match actual map-clearing behavior.

  • 🔵 unsafe_assumptions (L455): rescueDirtyWorktree stages all files via git add -A which could include sensitive files (.env, credentials) that were manually placed in the worktree. Since worktrees are Mitzo-managed and ephemeral this is likely acceptable, but the committed content will be pushed to a remote branch and a draft PR created — making any secrets in the worktree visible on GitHub. Consider whether a warning log when staging would be worthwhile.

@dimakis

dimakis commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 5 issue(s) (2 warning).

server/chat.ts

Solid data-loss prevention with good test coverage; main concern is a semantic mismatch between the dirty check (includes untracked files) and the staging strategy (-u ignores them), and the sync rescue blocking the event loop.

  • 🟡 bugs (L1310): hasUncommittedWork reports untracked files as dirty (via git status --porcelain which shows ?? entries), but rescueDirtyWorktree now uses git add -u which only stages tracked files. When the only dirty state is untracked files, git add -u stages nothing, git commit fails, and rescue returns {success: false} — the worktree is preserved (safe), but the log message 'rescue failed' is misleading since the real cause is a semantic mismatch between the dirty check and the staging strategy. Consider filtering out ?? lines in hasUncommittedWork or using git status --porcelain -uno to match the -u staging behavior, so untracked-only worktrees skip rescue entirely. [fixable]
  • 🟡 unsafe_assumptions (L1318): setTimeout(0) defers when the blocking starts but does NOT prevent event-loop blocking. rescueDirtyWorktree calls execFileSync up to 4 times (30s timeout each = ~2 min worst case). While the callback runs, all WebSocket handling and HTTP requests are blocked. With multiple dirty secondary repos this compounds. The comment on line 1312 ('Defer rescue to avoid blocking the event loop') is misleading — consider using async exec (execFile with promises) or spawning a child process. [fixable]

server/worktree.ts

Solid data-loss prevention with good test coverage; main concern is a semantic mismatch between the dirty check (includes untracked files) and the staging strategy (-u ignores them), and the sync rescue blocking the event loop.

  • 🔵 style (L374): Duplicate JSDoc block on hasUncommittedWork: lines 374-376 repeat the opening sentence of lines 377-381. The first block should be removed. [fixable]
  • 🔵 unsafe_assumptions (L456): Switching from git add -A to git add -u means newly created source files (agent-generated code, new modules) won't be included in rescue commits. .gitignore already excludes .env/secrets in most repos, so -A with gitignore would rescue more work. The current approach trades completeness for an extra layer of secret-staging protection — worth documenting that new files are intentionally excluded.

server/__tests__/chat.test.ts

Solid data-loss prevention with good test coverage; main concern is a semantic mismatch between the dirty check (includes untracked files) and the staging strategy (-u ignores them), and the sync rescue blocking the event loop.

  • 🔵 missing_tests (L252): No test for the case where hasUncommittedWork throws/returns the sentinel string (e.g., git status itself fails on a corrupted worktree). In that scenario, rescue is attempted on a likely-broken repo. The behavior is correct (rescue fails, worktree preserved), but a test would document this edge case. [fixable]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Centaur Review

Found 4 issue(s) (1 warning).

server/__tests__/rescue-worktree.test.ts

Solid safety improvement — dirty worktrees are rescued before cleanup, with correct fallback to preservation on failure. A few stale comments from the -A-u change and a minor test robustness gap around the repo-config TTL cache.

  • 🔵 style (L187): Stale comments still reference git add -A at lines 108, 187, and 203 — but the implementation was changed to git add -u. The PR updated the assertion comment at line 119 and the assertion itself at line 123, but missed these three setup-step comments. [fixable]

server/__tests__/chat.test.ts

Solid safety improvement — dirty worktrees are rescued before cleanup, with correct fallback to preservation on failure. A few stale comments from the -A-u change and a minor test robustness gap around the repo-config TTL cache.

  • 🟡 unsafe_assumptions (L252): New dirty-worktree tests use vi.useFakeTimers() but don't override Date.now to force getRepoConfig() cache expiry, unlike every other test in this describe block (e.g. lines 149-150). getRepoConfig() has a 5-second TTL cache; with fake timers, Date.now() freezes at approximately the current system time, and _cachedAt may still hold a value from a prior test that causes a stale cache hit. This happens to work today because the cached config also contains the mitzo key, but it's a test-ordering-dependent assumption that could cause flaky failures if configs diverge. [fixable]
  • 🔵 missing_tests: No test covers the case where hasUncommittedWork returns a git-failure sentinel (e.g. [git status failed: ...]). In production, this triggers rescue on a likely-broken worktree — rescue fails, worktree is preserved (safe). Adding a test would document this invariant and prevent regressions if the sentinel handling changes. [fixable]

server/chat.ts

Solid safety improvement — dirty worktrees are rescued before cleanup, with correct fallback to preservation on failure. A few stale comments from the -A-u change and a minor test robustness gap around the repo-config TTL cache.

  • 🔵 style (L1312): Comment says "Defer rescue to avoid blocking the event loop" but setTimeout(0) with synchronous execFileSync calls (up to 4 × 30s timeout) still blocks the event loop when the callback fires on the next tick. It avoids blocking the cleanup loop, which is the actual value. Consider rewording to "Defer rescue so the cleanup loop can finish first" to be more precise. [fixable]

…TTL, sentinel test

- Fix stale `git add -A` comments in rescue-worktree tests (now -u)
- Reword setTimeout comment to clarify it defers the cleanup loop, not the event loop
- Add vi.advanceTimersByTime(10_000) to bust getRepoConfig TTL cache in fake-timer tests
- Add test for git-failure sentinel path (hasUncommittedWork returns error string)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 4 issue(s) (2 warning).

server/chat.ts

Solid safety improvement — the rescue-before-delete logic and regex fix are correct. Main concern is a detection/rescue mismatch when dirty status comes solely from untracked files (safe but produces futile rescue attempts and confusing logs).

  • 🟡 bugs (L1310): Detection/rescue mismatch for untracked-only dirty worktrees. hasUncommittedWork uses git status --porcelain which reports untracked files (e.g. a new .env created during the session), but rescueDirtyWorktree uses git add -u which only stages tracked changes. If a worktree is dirty only from untracked files, hasUncommittedWork returns non-null, rescue is attempted, git add -u stages nothing, git commit fails → rescue returns { success: false } → worktree stays on disk. This is safe (no data loss), but the rescue attempt is futile and produces a confusing warning log. Consider either: (a) passing the dirty output to rescueDirtyWorktree so it can detect nothing-to-commit upfront and return early with a clearer message, or (b) filtering git status --porcelain to exclude ?? lines before deciding to rescue. [fixable]
  • 🟡 unsafe_assumptions (L1318): setTimeout(0) defers rescueDirtyWorktree which calls execFileSync up to 4 times (each with a 30s timeout). If multiple secondary repos are dirty, each gets its own setTimeout(0) callback — these fire sequentially on the event loop, potentially blocking it for N × 120s. With 3 dirty secondaries that's up to 6 minutes of event-loop blocking. Consider using the async execFile equivalent or worker_threads, or at minimum adding a log at the start of the callback so operators can correlate event-loop stalls. [fixable]
  • 🔵 style (L1314): The captured* variables (capturedRepoName, capturedPath, capturedWtId, capturedRepoPath) are unnecessary — for...of with const destructuring creates block-scoped bindings per iteration, so the closure already captures the correct values. Removing them would reduce 4 lines of noise. That said, this is a readability-vs-conciseness tradeoff and the explicit captures do communicate intent clearly. [fixable]

server/__tests__/chat.test.ts

Solid safety improvement — the rescue-before-delete logic and regex fix are correct. Main concern is a detection/rescue mismatch when dirty status comes solely from untracked files (safe but produces futile rescue attempts and confusing logs).

  • 🔵 missing_tests: No test covers the untracked-only dirty scenario: hasUncommittedWork returns non-null (e.g. ?? .env), but rescueDirtyWorktree fails because git add -u + git commit finds nothing to commit. This is the most likely real-world dirty state for secondary worktrees (agent creates a temp file). A test would document the expected behavior and prevent regressions if the git add -u strategy changes. [fixable]

Comment thread server/chat.ts
continue;
}
try {
const dirty = hasUncommittedWork(path);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: Detection/rescue mismatch for untracked-only dirty worktrees. hasUncommittedWork uses git status --porcelain which reports untracked files (e.g. a new .env created during the session), but rescueDirtyWorktree uses git add -u which only stages tracked changes. If a worktree is dirty only from untracked files, hasUncommittedWork returns non-null, rescue is attempted, git add -u stages nothing, git commit fails → rescue returns { success: false } → worktree stays on disk. This is safe (no data loss), but the rescue attempt is futile and produces a confusing warning log. Consider either: (a) passing the dirty output to rescueDirtyWorktree so it can detect nothing-to-commit upfront and return early with a clearer message, or (b) filtering git status --porcelain to exclude ?? lines before deciding to rescue. [fixable]

Comment thread server/chat.ts
const capturedPath = path;
const capturedWtId = wtId;
const capturedRepoPath = repoPath;
setTimeout(() => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: setTimeout(0) defers rescueDirtyWorktree which calls execFileSync up to 4 times (each with a 30s timeout). If multiple secondary repos are dirty, each gets its own setTimeout(0) callback — these fire sequentially on the event loop, potentially blocking it for N × 120s. With 3 dirty secondaries that's up to 6 minutes of event-loop blocking. Consider using the async execFile equivalent or worker_threads, or at minimum adding a log at the start of the callback so operators can correlate event-loop stalls. [fixable]

Comment thread server/chat.ts Outdated
if (dirty) {
// Defer rescue to avoid blocking the event loop — rescueDirtyWorktree
// makes up to 4 sync git/gh calls (~30s timeout each).
const capturedRepoName = repoName;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The captured* variables (capturedRepoName, capturedPath, capturedWtId, capturedRepoPath) are unnecessary — for...of with const destructuring creates block-scoped bindings per iteration, so the closure already captures the correct values. Removing them would reduce 4 lines of noise. That said, this is a readability-vs-conciseness tradeoff and the explicit captures do communicate intent clearly. [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 4 issue(s) (1 warning).

server/chat.ts

Solid safety improvement — the dirty-check + rescue flow is correct, git add -u is a good security hardening, and the three new test cases cover the key branches. Only minor suggestions around edge-case documentation and test completeness.

  • 🟡 unsafe_assumptions (L1316): The deferred setTimeout(0) rescue callback has no completion tracking. If cleanupSessionWorktrees is called during process shutdown (e.g. SIGTERM handler), the callback may never execute. The worktree directory survives on disk so stale GC will eventually find it, but a rescue opportunity is silently lost. Consider logging at the setTimeout scheduling site (not just inside the callback) so operators can correlate cleanup-start with rescue-start. [fixable]
  • 🔵 style (L1312): The explicit capturedRepoName, capturedPath, capturedWtId, capturedRepoPath variables are unnecessary — for...of with const destructuring already creates fresh bindings per iteration that closures capture correctly. The captures add 4 lines of noise. Either remove them or add a one-line comment explaining the defensive intent. [fixable]

server/worktree.ts

Solid safety improvement — the dirty-check + rescue flow is correct, git add -u is a good security hardening, and the three new test cases cover the key branches. Only minor suggestions around edge-case documentation and test completeness.

  • 🔵 bugs (L453): hasUncommittedWork detects untracked files (?? file in porcelain output), but git add -u will not stage them — so git commit will fail with 'nothing to commit', rescue returns failure, and the worktree is preserved. The outcome is safe (no data loss), but a worktree that is 'dirty' only because of untracked files will always fail rescue and never be cleaned up until stale GC. If this is intentional, the comment on hasUncommittedWork ("modified, staged, or untracked files") should note that untracked-only dirtiness intentionally skips rescue. [fixable]

server/__tests__/chat.test.ts

Solid safety improvement — the dirty-check + rescue flow is correct, git add -u is a good security hardening, and the three new test cases cover the key branches. Only minor suggestions around edge-case documentation and test completeness.

  • 🔵 missing_tests (L277): The 'skips dirty secondary when rescue fails' test asserts removeWorktreeMock was not called and primary survives, but doesn't assert the Map size. Adding expect(session.worktreePaths.size).toBe(1) would explicitly verify the secondary entry was removed from the tracking Map (separate from the on-disk worktree being preserved). [fixable]

Comment thread server/chat.ts Outdated
// makes up to 4 sync git/gh calls (~30s timeout each).
const capturedRepoName = repoName;
const capturedPath = path;
const capturedWtId = wtId;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: The deferred setTimeout(0) rescue callback has no completion tracking. If cleanupSessionWorktrees is called during process shutdown (e.g. SIGTERM handler), the callback may never execute. The worktree directory survives on disk so stale GC will eventually find it, but a rescue opportunity is silently lost. Consider logging at the setTimeout scheduling site (not just inside the callback) so operators can correlate cleanup-start with rescue-start. [fixable]

Comment thread server/chat.ts
try {
const dirty = hasUncommittedWork(path);
if (dirty) {
// Defer rescue so the cleanup loop can finish first — rescueDirtyWorktree

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The explicit capturedRepoName, capturedPath, capturedWtId, capturedRepoPath variables are unnecessary — for...of with const destructuring already creates fresh bindings per iteration that closures capture correctly. The captures add 4 lines of noise. Either remove them or add a one-line comment explaining the defensive intent. [fixable]

Comment thread server/worktree.ts
@@ -450,8 +451,9 @@ export function rescueDirtyWorktree(
}

try {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 bugs: hasUncommittedWork detects untracked files (?? file in porcelain output), but git add -u will not stage them — so git commit will fail with 'nothing to commit', rescue returns failure, and the worktree is preserved. The outcome is safe (no data loss), but a worktree that is 'dirty' only because of untracked files will always fail rescue and never be cleaned up until stale GC. If this is intentional, the comment on hasUncommittedWork ("modified, staged, or untracked files") should note that untracked-only dirtiness intentionally skips rescue. [fixable]

['primary', { path: '/repo/.claude/worktrees/abc', wtId: 'abc' }],
['mitzo', { path: '/tools/mitzo/.claude/worktrees/abc', wtId: 'abc' }],
]),
} as unknown as ManagedSession;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The 'skips dirty secondary when rescue fails' test asserts removeWorktreeMock was not called and primary survives, but doesn't assert the Map size. Adding expect(session.worktreePaths.size).toBe(1) would explicitly verify the secondary entry was removed from the tracking Map (separate from the on-disk worktree being preserved). [fixable]

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 5 issue(s) (2 warning).

server/worktree.ts

Solid data-safety improvement with good test coverage. The main concern is a semantic mismatch: hasUncommittedWork detects untracked files but git add -u can't rescue them — safe (worktree preserved) but confusing. The deferred-rescue approach works but creates orphaned worktrees on failure.

  • 🟡 bugs (L456): Semantic mismatch between detection and rescue: hasUncommittedWork detects untracked files (?? in porcelain output), but git add -u only stages tracked files. If a worktree is dirty only due to untracked files, rescue will attempt git commit with nothing staged — the commit fails, rescue reports failure, and the worktree is preserved. This is safe (no data loss), but the rescue error message will be confusing ("nothing to commit"). Consider either: (a) passing --no-untracked-files to git status --porcelain in hasUncommittedWork so detection matches what rescue can handle, or (b) documenting this as intentional in the rescue comment. [fixable]

server/__tests__/chat.test.ts

Solid data-safety improvement with good test coverage. The main concern is a semantic mismatch: hasUncommittedWork detects untracked files but git add -u can't rescue them — safe (worktree preserved) but confusing. The deferred-rescue approach works but creates orphaned worktrees on failure.

  • 🔵 missing_tests: No test for the case where hasUncommittedWork throws an exception (not a git-status-failure sentinel, but an actual JS exception — e.g. ENOENT if the worktree path was deleted between iteration and check). The try/catch at line 1309 of chat.ts wraps the entire block including the hasUncommittedWork call, so it would be caught, but the behavior (falling through to the outer catch, logging, then continuing) isn't explicitly tested. [fixable]

server/chat.ts

Solid data-safety improvement with good test coverage. The main concern is a semantic mismatch: hasUncommittedWork detects untracked files but git add -u can't rescue them — safe (worktree preserved) but confusing. The deferred-rescue approach works but creates orphaned worktrees on failure.

  • 🟡 unsafe_assumptions (L1318): The deferred rescue via setTimeout(0) means rescue runs after session.worktreePaths.clear() (line 1354). If rescue fails and the worktree is preserved on disk, there is no record of it in the session's worktreePaths map — the worktree becomes orphaned from the session's perspective. It will only be cleaned up by the stale GC (96h). This is probably acceptable given the alternative (blocking the cleanup loop for ~2min of sync git calls), but the orphan scenario should be documented or the stale GC should be mentioned in the rescue-failure log message. [fixable]
  • 🔵 style (L1314): The four const captured* variables exist because the loop vars would be stale inside the setTimeout callback. Since this is a for...of over a Map with const destructuring, the bindings are per-iteration and already stable — repoName, path, wtId are safe to close over directly. Only repoPath (derived from config.repos[repoName]) needs capture, and it's also block-scoped. The captures are harmless but unnecessary. [fixable]

server/__tests__/rescue-worktree.test.ts

Solid data-safety improvement with good test coverage. The main concern is a semantic mismatch: hasUncommittedWork detects untracked files but git add -u can't rescue them — safe (worktree preserved) but confusing. The deferred-rescue approach works but creates orphaned worktrees on failure.

  • 🔵 missing_tests: No test verifies rescue behavior when git add -u succeeds but stages nothing (untracked-only dirt). The commit step would then fail with "nothing to commit". This is the edge case created by changing -A to -u. [fixable]

Comment thread server/worktree.ts
execFileSync('git', ['-C', worktreePath, 'add', '-A'], gitOpts);
// 1. Stage tracked file changes only (-u avoids staging untracked files
// that may contain secrets like .env or credentials)
execFileSync('git', ['-C', worktreePath, 'add', '-u'], gitOpts);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: Semantic mismatch between detection and rescue: hasUncommittedWork detects untracked files (?? in porcelain output), but git add -u only stages tracked files. If a worktree is dirty only due to untracked files, rescue will attempt git commit with nothing staged — the commit fails, rescue reports failure, and the worktree is preserved. This is safe (no data loss), but the rescue error message will be confusing ("nothing to commit"). Consider either: (a) passing --no-untracked-files to git status --porcelain in hasUncommittedWork so detection matches what rescue can handle, or (b) documenting this as intentional in the rescue comment. [fixable]

Comment thread server/chat.ts
const capturedPath = path;
const capturedWtId = wtId;
const capturedRepoPath = repoPath;
setTimeout(() => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 unsafe_assumptions: The deferred rescue via setTimeout(0) means rescue runs after session.worktreePaths.clear() (line 1354). If rescue fails and the worktree is preserved on disk, there is no record of it in the session's worktreePaths map — the worktree becomes orphaned from the session's perspective. It will only be cleaned up by the stale GC (96h). This is probably acceptable given the alternative (blocking the cleanup loop for ~2min of sync git calls), but the orphan scenario should be documented or the stale GC should be mentioned in the rescue-failure log message. [fixable]

Comment thread server/chat.ts Outdated
if (dirty) {
// Defer rescue so the cleanup loop can finish first — rescueDirtyWorktree
// makes up to 4 sync git/gh calls (~30s timeout each).
const capturedRepoName = repoName;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The four const captured* variables exist because the loop vars would be stale inside the setTimeout callback. Since this is a for...of over a Map with const destructuring, the bindings are per-iteration and already stable — repoName, path, wtId are safe to close over directly. Only repoPath (derived from config.repos[repoName]) needs capture, and it's also block-scoped. The captures are harmless but unnecessary. [fixable]

…edge tests

- Document detection/rescue mismatch in rescueDirtyWorktree JSDoc
  (hasUncommittedWork detects untracked, git add -u only stages tracked)
- Remove unnecessary captured* variables — for...of const bindings are
  per-iteration and safe to close over directly
- Improve rescue-failure log to mention stale GC will handle orphans
- Add test: hasUncommittedWork throws exception (ENOENT) — caught gracefully
- Add test: untracked-only dirt causes commit failure (intentional safe path)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 2 issue(s).

server/__tests__/chat.test.ts

Well-structured data-loss prevention: git add -A-u plugs a secret-staging hole, dirty-worktree rescue is correctly deferred via setTimeout(0) with safe closures, and test coverage is thorough. Two minor suggestions around test robustness.

  • 🔵 style: New dirty-worktree tests manually reset mock state at the end of each test body (hasUncommittedWorkMock.mockReturnValue(null); vi.restoreAllMocks()). If a test fails mid-body, cleanup doesn't run and stale mock state leaks into the next test. Moving these resets into an afterEach inside the describe block would be more robust — the existing beforeEach already follows this pattern for removeWorktreeMock. [fixable]
  • 🔵 missing_tests: No test covers the case where multiple secondary worktrees are dirty simultaneously. The setTimeout(0) design means N callbacks fire after the loop completes and worktreePaths.clear() runs. The closures are correct (per-iteration const bindings), but a test with 2+ dirty secondaries (one rescue succeeds, one fails) would document this concurrent-callback behavior explicitly. [fixable]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

LGTM — no issues found.

…ch, add multi-secondary test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis dimakis merged commit 4a08eac into main Jun 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant