Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 249 additions & 0 deletions server/__tests__/rescue-worktree.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('fs')>('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');
});
});
12 changes: 9 additions & 3 deletions server/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<descriptive title>" --body "<summary of changes>"\`
- 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
Expand Down
6 changes: 6 additions & 0 deletions server/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading