diff --git a/server/__tests__/rescue-worktree.test.ts b/server/__tests__/rescue-worktree.test.ts index a31b63df..c713f43e 100644 --- a/server/__tests__/rescue-worktree.test.ts +++ b/server/__tests__/rescue-worktree.test.ts @@ -107,6 +107,8 @@ describe('rescueDirtyWorktree', () => { mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); // Step 1: git add -u mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git diff --cached --name-only → meaningful file + mockExecFileSync.mockReturnValueOnce('src/feature.ts\n' as never); // Step 2: git commit mockExecFileSync.mockReturnValueOnce('' as never); // Step 3: git push @@ -186,6 +188,8 @@ describe('rescueDirtyWorktree', () => { mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); // Step 1: git add -u succeeds mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git diff --cached --name-only → meaningful file + mockExecFileSync.mockReturnValueOnce('src/feature.ts\n' as never); // Step 2: git commit fails mockExecFileSync.mockImplementationOnce(() => { throw new Error('nothing to commit'); @@ -202,6 +206,8 @@ describe('rescueDirtyWorktree', () => { mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); // Step 1: git add -u mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git diff --cached --name-only → meaningful file + mockExecFileSync.mockReturnValueOnce('src/feature.ts\n' as never); // Step 2: git commit mockExecFileSync.mockReturnValueOnce('' as never); // Step 3: git push fails @@ -220,6 +226,8 @@ describe('rescueDirtyWorktree', () => { mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); // Step 1: git add mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git diff --cached --name-only → meaningful file + mockExecFileSync.mockReturnValueOnce('src/feature.ts\n' as never); // Step 2: git commit mockExecFileSync.mockReturnValueOnce('' as never); // Step 3: git push @@ -252,14 +260,68 @@ describe('rescueDirtyWorktree', () => { mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); // Step 1: git add -u succeeds (but stages nothing — only untracked files) mockExecFileSync.mockReturnValueOnce('' as never); - // Step 2: git commit fails — nothing staged - mockExecFileSync.mockImplementationOnce(() => { - throw new Error('nothing to commit, working tree clean'); - }); + // Step 1b: git diff --cached --name-only → empty (nothing staged) + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git reset HEAD (unstage) + mockExecFileSync.mockReturnValueOnce('' as never); const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); expect(result.success).toBe(false); - expect(result.error).toContain('nothing to commit'); + expect(result.error).toContain('no meaningful changes'); + }); + + it('skips rescue when only noise files (.mitzo-session) are staged', () => { + // Step 0: getRepoRemote + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + // Step 1: git add -A + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git diff --cached --name-only → only .mitzo-session + mockExecFileSync.mockReturnValueOnce('.mitzo-session\n' as never); + // Step 1b: git reset HEAD (unstage) + mockExecFileSync.mockReturnValueOnce('' as never); + + const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); + + expect(result.success).toBe(false); + expect(result.error).toContain('no meaningful changes'); + // Should NOT have called commit, push, or pr create + expect(mockExecFileSync).toHaveBeenCalledTimes(4); // remote + add + diff + reset + }); + + it('proceeds with rescue when meaningful files are staged alongside noise files', () => { + // Step 0: getRepoRemote + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + // Step 1: git add -A + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git diff --cached --name-only → noise + real file + mockExecFileSync.mockReturnValueOnce('.mitzo-session\nsrc/feature.ts\n' as never); + // Step 2: git commit + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 3: git push + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 4: gh pr create + mockExecFileSync.mockReturnValueOnce('https://github.com/dimakis/mgmt/pull/99\n' as never); + + const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); + + expect(result.success).toBe(true); + expect(result.prUrl).toBe('https://github.com/dimakis/mgmt/pull/99'); + }); + + it('skips rescue when no files are staged', () => { + // Step 0: getRepoRemote + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + // Step 1: git add -A + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git diff --cached --name-only → empty + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 1b: git reset HEAD (unstage) + mockExecFileSync.mockReturnValueOnce('' as never); + + const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); + + expect(result.success).toBe(false); + expect(result.error).toContain('no meaningful changes'); }); }); diff --git a/server/worktree.ts b/server/worktree.ts index d95798e9..2c1c0701 100644 --- a/server/worktree.ts +++ b/server/worktree.ts @@ -28,6 +28,13 @@ import { createLogger } from './logger.js'; const log = createLogger('worktree'); +/** + * Files that are not meaningful work — rescue should skip if these are the only changes. + * Matched via exact path from `git diff --cached --name-only` (root-relative). + * Only root-level files are supported; subdirectory paths would need `subdir/file` entries. + */ +const RESCUE_NOISE_FILES = new Set(['.mitzo-session']); + /** * Detect the default branch of a repo (e.g. 'main' or 'master'). * Prefers origin/HEAD (remote truth) since session worktrees should branch @@ -463,6 +470,28 @@ export function rescueDirtyWorktree( return { success: false, error: err instanceof Error ? err.message : String(err) }; } + // 1b. Check if staged changes contain meaningful work (not just noise markers) + try { + const staged = execFileSync('git', ['-C', worktreePath, 'diff', '--cached', '--name-only'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: WORKTREE_GIT_TIMEOUT_MS, + }).trim(); + const files = staged ? staged.split('\n') : []; + if (files.length === 0 || files.every((f) => RESCUE_NOISE_FILES.has(f))) { + log.info('rescue skipped — only noise files changed', { wtId, files }); + // Unstage so the worktree can be cleaned up without leaving staged changes + try { + execFileSync('git', ['-C', worktreePath, 'reset', 'HEAD'], gitOpts); + } catch { + // Non-fatal — worktree is about to be removed anyway + } + return { success: false, error: 'no meaningful changes to rescue' }; + } + } catch { + // If we can't inspect staged files, proceed with the rescue anyway — safer than skipping + } + try { // 2. Commit execFileSync(