diff --git a/server/__tests__/rescue-worktree.test.ts b/server/__tests__/rescue-worktree.test.ts new file mode 100644 index 00000000..3e31f26a --- /dev/null +++ b/server/__tests__/rescue-worktree.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { execFileSync } from 'child_process'; + +// Mock child_process before importing the module under test. +// execFile must be a real callback-style function so promisify() works at module load. +vi.mock('child_process', () => ({ + execFileSync: vi.fn(), + execFile: vi.fn( + (_cmd: string, _args: string[], _opts: unknown, cb?: (...args: unknown[]) => void) => { + if (cb) cb(null, '', ''); + }, + ), +})); + +// Mock logger +vi.mock('../logger.js', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +// Mock constants +vi.mock('../constants.js', () => ({ + WORKTREE_BRANCH_PREFIX: 'session/', + WORKTREE_STALE_HOURS: 96, + WORKTREE_GIT_TIMEOUT_MS: 30_000, + WORKTREE_REMOVE_TIMEOUT_MS: 15_000, + WORKTREE_PRUNE_TIMEOUT_MS: 5_000, +})); + +// Mock fs — we only need existsSync for getRepoRemote's indirection +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + }; +}); + +import { getRepoRemote, rescueDirtyWorktree } from '../worktree.js'; + +const mockExecFileSync = vi.mocked(execFileSync); + +describe('getRepoRemote', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('parses SSH remote URL (git@github.com:user/repo.git)', () => { + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + + const result = getRepoRemote('/tmp/repo'); + expect(result).toBe('dimakis/mgmt'); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['-C', '/tmp/repo', 'remote', 'get-url', 'origin'], + expect.objectContaining({ encoding: 'utf-8' }), + ); + }); + + it('parses HTTPS remote URL (https://github.com/user/repo.git)', () => { + mockExecFileSync.mockReturnValueOnce('https://github.com/dimakis/mitzo.git\n' as never); + + const result = getRepoRemote('/tmp/repo'); + expect(result).toBe('dimakis/mitzo'); + }); + + it('parses HTTPS remote URL without .git suffix', () => { + mockExecFileSync.mockReturnValueOnce('https://github.com/dimakis/mitzo\n' as never); + + const result = getRepoRemote('/tmp/repo'); + expect(result).toBe('dimakis/mitzo'); + }); + + it('parses SSH remote URL without .git suffix', () => { + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mitzo\n' as never); + + const result = getRepoRemote('/tmp/repo'); + expect(result).toBe('dimakis/mitzo'); + }); + + it('returns null when git command fails', () => { + mockExecFileSync.mockImplementationOnce(() => { + throw new Error('fatal: No such remote'); + }); + + const result = getRepoRemote('/tmp/repo'); + expect(result).toBeNull(); + }); + + it('returns null for unrecognised URL format', () => { + mockExecFileSync.mockReturnValueOnce('/local/path/to/repo\n' as never); + + const result = getRepoRemote('/tmp/repo'); + expect(result).toBeNull(); + }); +}); + +describe('rescueDirtyWorktree', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('succeeds when all four steps work (add, commit, push, gh pr create)', () => { + // Step 0: getRepoRemote — git remote get-url origin + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + // Step 1: git add -A + mockExecFileSync.mockReturnValueOnce('' 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/42\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/42'); + + // Verify git add was called + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['-C', '/tmp/worktree', 'add', '-A'], + expect.objectContaining({ timeout: 30_000 }), + ); + + // Verify git commit was called + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + [ + '-C', + '/tmp/worktree', + 'commit', + '-m', + 'chore: rescue uncommitted work from session test-123', + ], + expect.objectContaining({ timeout: 30_000 }), + ); + + // Verify git push was called + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['-C', '/tmp/worktree', 'push', 'origin', 'session/test-123'], + expect.objectContaining({ timeout: 30_000 }), + ); + + // Verify gh pr create was called + expect(mockExecFileSync).toHaveBeenCalledWith( + 'gh', + [ + 'pr', + 'create', + '--draft', + '--title', + 'Rescued: test-123', + '--body', + expect.stringContaining('Auto-rescued uncommitted work'), + '--repo', + 'dimakis/mgmt', + '--head', + 'session/test-123', + ], + expect.objectContaining({ cwd: '/tmp/worktree', timeout: 30_000 }), + ); + }); + + it('returns failure when git add fails', () => { + // Step 0: getRepoRemote + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + // Step 1: git add -A fails + mockExecFileSync.mockImplementationOnce(() => { + throw new Error('git add failed'); + }); + + const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); + + expect(result.success).toBe(false); + expect(result.error).toContain('git add failed'); + expect(result.prUrl).toBeUndefined(); + }); + + it('returns failure when git commit fails', () => { + // Step 0: getRepoRemote + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + // Step 1: git add -A succeeds + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 2: git commit fails + mockExecFileSync.mockImplementationOnce(() => { + throw new Error('nothing to commit'); + }); + + const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); + + expect(result.success).toBe(false); + expect(result.error).toContain('nothing to commit'); + }); + + it('returns failure when git push fails', () => { + // Step 0: getRepoRemote + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + // Step 1: git add -A + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 2: git commit + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 3: git push fails + mockExecFileSync.mockImplementationOnce(() => { + throw new Error('remote rejected'); + }); + + const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); + + expect(result.success).toBe(false); + expect(result.error).toContain('remote rejected'); + }); + + it('returns failure when gh pr create fails', () => { + // Step 0: getRepoRemote + mockExecFileSync.mockReturnValueOnce('git@github.com:dimakis/mgmt.git\n' as never); + // Step 1: git add + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 2: git commit + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 3: git push + mockExecFileSync.mockReturnValueOnce('' as never); + // Step 4: gh pr create fails + mockExecFileSync.mockImplementationOnce(() => { + throw new Error('gh: not logged in'); + }); + + const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); + + expect(result.success).toBe(false); + expect(result.error).toContain('gh: not logged in'); + }); + + it('returns failure when remote cannot be resolved', () => { + // Step 0: getRepoRemote fails + mockExecFileSync.mockImplementationOnce(() => { + throw new Error('fatal: No such remote'); + }); + + const result = rescueDirtyWorktree('/tmp/worktree', 'session/test-123', 'test-123'); + + expect(result.success).toBe(false); + expect(result.error).toContain('resolve GitHub remote'); + }); +}); diff --git a/server/chat.ts b/server/chat.ts index ba282001..f9941f90 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -954,9 +954,15 @@ const CLOSEOUT_PROMPT = `This session is closing in 10 minutes due to inactivity Please perform session closeout: 1. If there is uncommitted work in any worktree, commit it now with a descriptive message -2. If there are memory-worthy observations, decisions, or patterns — write them to memory/Observations/ or memory/Decisions/ -3. Write a 2-3 sentence summary of what was accomplished and what remains unfinished — output it as your final chat message so it appears in the conversation history -4. Do not ask for confirmation — just do it`; +2. Push the branch and create a pull request: + - Use \`gh pr create --title "" --body ""\` + - If the work is incomplete or experimental, create a draft: \`gh pr create --draft ...\` + - If the work is solid and complete, create a regular PR + - Target the main branch of each repo + - If push or PR creation fails, continue with the remaining steps +3. If there are memory-worthy observations, decisions, or patterns — write them to memory/Observations/ or memory/Decisions/ +4. Write a 2-3 sentence summary of what was accomplished and what remains unfinished — output it as your final chat message so it appears in the conversation history +5. Do not ask for confirmation — just do it`; /** * Graceful session closeout. Called by the registry's closeout handler diff --git a/server/mcp-config.ts b/server/mcp-config.ts index 72603932..0efd37e7 100644 --- a/server/mcp-config.ts +++ b/server/mcp-config.ts @@ -61,6 +61,12 @@ export function getMcpConfigPaths(): string[] { paths.push(envPath); } + // Claude Code global config — same mcpServers format as Cursor + const claudePath = join(homedir(), '.claude.json'); + if (existsSync(claudePath)) { + paths.push(claudePath); + } + const cursorPath = join(homedir(), '.cursor', 'mcp.json'); if (existsSync(cursorPath)) { paths.push(cursorPath); diff --git a/server/worktree.ts b/server/worktree.ts index 71f4e077..786bdcc7 100644 --- a/server/worktree.ts +++ b/server/worktree.ts @@ -340,6 +340,121 @@ function hasUncommittedWork(worktreePath: string): string | null { } } +export interface RescueResult { + success: boolean; + prUrl?: string; + error?: string; +} + +/** + * Parse a git remote URL into a GitHub owner/repo slug. + * Handles both SSH (git@github.com:user/repo.git) and HTTPS + * (https://github.com/user/repo.git) formats. + * Returns null if the remote cannot be resolved or the URL format is unrecognised. + */ +export function getRepoRemote(worktreePath: string): string | null { + try { + const url = execFileSync('git', ['-C', worktreePath, 'remote', 'get-url', 'origin'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: WORKTREE_GIT_TIMEOUT_MS, + }).trim(); + + // SSH: git@github.com:user/repo.git + const sshMatch = url.match(/git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + + // HTTPS: https://github.com/user/repo.git + const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + + return null; + } catch { + return null; + } +} + +/** + * Rescue a dirty worktree by committing, pushing, and creating a draft PR. + * Each step is attempted in order; if any step fails the function returns + * immediately with { success: false, error }. + */ +export function rescueDirtyWorktree( + worktreePath: string, + branch: string, + sessionId: string, +): RescueResult { + const gitOpts = { stdio: ['pipe', 'pipe', 'pipe'] as 'pipe'[], timeout: WORKTREE_GIT_TIMEOUT_MS }; + + // Resolve the GitHub remote before doing any mutations + const repo = getRepoRemote(worktreePath); + if (!repo) { + return { success: false, error: 'Could not resolve GitHub remote for worktree' }; + } + + try { + // 1. Stage all changes + execFileSync('git', ['-C', worktreePath, 'add', '-A'], gitOpts); + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + + try { + // 2. Commit + execFileSync( + 'git', + [ + '-C', + worktreePath, + 'commit', + '-m', + `chore: rescue uncommitted work from session ${sessionId}`, + ], + gitOpts, + ); + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + + try { + // 3. Push + execFileSync('git', ['-C', worktreePath, 'push', 'origin', branch], gitOpts); + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + + try { + // 4. Create draft PR + const prBody = `Auto-rescued uncommitted work from session \`${sessionId}\`.\n\nThis PR was created automatically by Mitzo's worktree cleanup.`; + const prOutput = execFileSync( + 'gh', + [ + 'pr', + 'create', + '--draft', + '--title', + `Rescued: ${sessionId}`, + '--body', + prBody, + '--repo', + repo, + '--head', + branch, + ], + { + cwd: worktreePath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: WORKTREE_GIT_TIMEOUT_MS, + }, + ).trim(); + log.info('rescue PR created', { sessionId, prUrl: prOutput }); + return { success: true, prUrl: prOutput }; + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + /** * Post a proposal to the mgmt inbox about a stale worktree with uncommitted work. */ @@ -350,6 +465,7 @@ function postDirtyWorktreeToInbox( branch: string, dirtyFiles: string, inboxDir: string, + prUrl?: string, ): void { const ts = new Date().toISOString().replace(/[-:]/g, '').replace('T', '_').slice(0, 15); const filename = `${ts}_worktree_gc_${sessionId}.md`; @@ -367,32 +483,38 @@ function postDirtyWorktreeToInbox( .filter(Boolean) .join(', '); + const actionLine = prUrl + ? `**Action:** Auto-rescued — PR at ${prUrl}. Review and merge or close.` + : `**Action:** This worktree is ${WORKTREE_STALE_HOURS / 24}+ days old and has uncommitted changes. Rescue the work (commit/stash) or approve cleanup.`; + + const tags = prUrl ? '[worktree, auto-rescued]' : '[worktree, uncommitted-work, needs-rescue]'; + const content = `--- agent: worktree_gc timestamp: ${new Date().toISOString()} status: pending -tags: [worktree, uncommitted-work, needs-rescue] +tags: ${tags} --- -# Stale worktree has uncommitted work +# Stale worktree ${prUrl ? 'auto-rescued' : 'has uncommitted work'} **Session:** ${sessionId} **Repo:** ${repoName} **Branch:** ${branch} **Path:** ${worktreePath} -**Files:** ${summary} +**Files:** ${summary}${prUrl ? `\n**PR:** ${prUrl}` : ''} \`\`\` ${dirtyFiles} \`\`\` -**Action:** This worktree is ${WORKTREE_STALE_HOURS / 24}+ days old and has uncommitted changes. Rescue the work (commit/stash) or approve cleanup. +${actionLine} `; try { mkdirSync(inboxDir, { recursive: true }); writeFileSync(join(inboxDir, filename), content); - log.info('posted dirty worktree to inbox', { sessionId, repo: repoName }); + log.info('posted dirty worktree to inbox', { sessionId, repo: repoName, prUrl }); } catch (err: unknown) { log.warn('failed to post dirty worktree to inbox', { error: err instanceof Error ? err.message : 'unknown', @@ -446,6 +568,36 @@ export function cleanupStaleWorktrees( if (!alreadyNotified) { const branch = `${WORKTREE_BRANCH_PREFIX}${entry}`; const repoName = basename(baseRepo); + + // Attempt automatic rescue: commit, push, and create a draft PR + const rescue = rescueDirtyWorktree(fullPath, branch, entry); + if (rescue.success) { + log.info('auto-rescued dirty worktree', { + repo: baseRepo, + session: entry, + prUrl: rescue.prUrl, + }); + postDirtyWorktreeToInbox( + entry, + repoName, + fullPath, + branch, + dirty, + inboxDir, + rescue.prUrl, + ); + // Rescue succeeded — safe to clean up the worktree directory + removeWorktree(entry, baseRepo); + cleaned++; + continue; + } + + // Rescue failed — fall through to skip behavior + log.warn('auto-rescue failed, skipping dirty worktree', { + repo: baseRepo, + session: entry, + error: rescue.error, + }); postDirtyWorktreeToInbox(entry, repoName, fullPath, branch, dirty, inboxDir); } skipped++;