diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index 9cadb87..a5f2a2d 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -22,13 +22,14 @@ import { } from '@/utils/errors'; import { createDir, exists, getAllItems, move } from '@/utils/fs'; import { + formatWorkingDirectoryStatus, getGitRoot, gitAddWorktree, gitBranchExists, gitCreateBranch, gitFetchRemoteBranch, gitGetCurrentBranch, - gitHasUncommittedChanges, + gitGetWorkingDirectoryStatus, gitIsBranchMerged, gitIsWorktree, gitRemoteBranchExists, @@ -311,8 +312,10 @@ export async function remove(identifier: string, force = false): Promise { const currentBranch = await gitGetCurrentBranch(); - if (await gitHasUncommittedChanges()) { - throw new UncommittedChangesError(currentBranch); + const status = await gitGetWorkingDirectoryStatus(); + if (status.staged.length > 0 || status.unstaged.length > 0) { + const statusSummary = formatWorkingDirectoryStatus({ + ...status, + untracked: [], // Don't show untracked files for setup + }); + throw new UncommittedChangesError(currentBranch, statusSummary); } const tempDir = `.tmp-worktree-setup-${process.pid}`; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 431e74b..7f27728 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -44,12 +44,19 @@ export class UserCancelledError extends WorktreeError { } export class UncommittedChangesError extends GitError { - constructor(identifier: string) { - super( - `Worktree '${identifier}' has uncommitted changes. Commit or stash changes, or use --force to override.`, - 'git status --porcelain' - ); + public readonly statusSummary?: string; + + constructor(identifier: string, statusSummary?: string) { + const baseMessage = `Worktree '${identifier}' has uncommitted changes.`; + const actionMessage = 'Commit or stash changes, or use --force to override.'; + + const message = statusSummary + ? `${baseMessage}\n\n${statusSummary}\n\n${actionMessage}` + : `${baseMessage} ${actionMessage}`; + + super(message, 'git status --porcelain'); this.name = 'UncommittedChangesError'; + this.statusSummary = statusSummary; } } diff --git a/src/utils/git.ts b/src/utils/git.ts index 7783b33..5e45866 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -329,6 +329,114 @@ export async function gitIsBranchMerged( ); } +/** + * Detailed working directory status + */ +export interface WorkingDirectoryStatus { + staged: string[]; + unstaged: string[]; + untracked: string[]; + hasChanges: boolean; +} + +/** + * Get detailed working directory status + */ +export async function gitGetWorkingDirectoryStatus(cwd?: string): Promise { + // Use raw exec to preserve leading spaces (don't use execGit which trims) + const { error, data } = await tryCatch(async () => { + const proc = cwd + ? await $`git status --porcelain`.cwd(cwd).quiet() + : await $`git status --porcelain`.quiet(); + return proc.stdout.toString(); + }); + + if (error) { + throw new GitError('Could not check git status', 'git status --porcelain', { cause: error }); + } + + const staged: string[] = []; + const unstaged: string[] = []; + const untracked: string[] = []; + + const lines = data.split('\n').filter((line) => line.length > 0); + + for (const line of lines) { + // Porcelain format: XY filename (where XY is 2 chars, then space, then filename) + // Use regex to properly parse the format + const match = line.match(/^(.)(.) (.+)$/); + if (!match || !match[1] || !match[2] || !match[3]) continue; + + const indexStatus = match[1]; + const workTreeStatus = match[2]; + const filename = match[3]; + + // Untracked files + if (indexStatus === '?' && workTreeStatus === '?') { + untracked.push(filename); + continue; + } + + // Staged changes (index has changes) + if (indexStatus !== ' ' && indexStatus !== '?') { + staged.push(filename); + } + + // Unstaged changes (worktree has changes) + if (workTreeStatus !== ' ' && workTreeStatus !== '?') { + unstaged.push(filename); + } + } + + return { + staged, + unstaged, + untracked, + hasChanges: staged.length > 0 || unstaged.length > 0 || untracked.length > 0, + }; +} + +/** + * Format working directory status for display + */ +export function formatWorkingDirectoryStatus(status: WorkingDirectoryStatus): string { + const parts: string[] = []; + + if (status.staged.length > 0) { + parts.push(`Staged changes (${status.staged.length}):`); + for (const file of status.staged.slice(0, 5)) { + parts.push(` • ${file}`); + } + if (status.staged.length > 5) { + parts.push(` ... and ${status.staged.length - 5} more`); + } + } + + if (status.unstaged.length > 0) { + if (parts.length > 0) parts.push(''); + parts.push(`Unstaged changes (${status.unstaged.length}):`); + for (const file of status.unstaged.slice(0, 5)) { + parts.push(` • ${file}`); + } + if (status.unstaged.length > 5) { + parts.push(` ... and ${status.unstaged.length - 5} more`); + } + } + + if (status.untracked.length > 0) { + if (parts.length > 0) parts.push(''); + parts.push(`Untracked files (${status.untracked.length}):`); + for (const file of status.untracked.slice(0, 5)) { + parts.push(` • ${file}`); + } + if (status.untracked.length > 5) { + parts.push(` ... and ${status.untracked.length - 5} more`); + } + } + + return parts.join('\n'); +} + /** * Check for uncommitted changes */ @@ -336,15 +444,11 @@ export async function gitHasUncommittedChanges( cwd?: string, options?: { includeUntracked?: boolean } ): Promise { - const args = ['status', '--porcelain']; - if (!options?.includeUntracked) { - args.push('--untracked-files=no'); - } + const status = await gitGetWorkingDirectoryStatus(cwd); - const { error, data } = await tryCatch(execGit(args, cwd)); - if (error) { - throw new GitError('Could not check git status', `git ${args.join(' ')}`, { cause: error }); + if (options?.includeUntracked) { + return status.hasChanges; } - return data.stdout.trim().length > 0; + return status.staged.length > 0 || status.unstaged.length > 0; } diff --git a/tests/git.test.ts b/tests/git.test.ts index 1a3ec0a..0c70eb7 100644 --- a/tests/git.test.ts +++ b/tests/git.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { $ } from 'bun'; import { GitError } from '@/utils/errors'; import * as git from '@/utils/git'; @@ -66,3 +70,234 @@ describe('git utilities', () => { }); }); }); + +describe('gitGetWorkingDirectoryStatus', () => { + let testDir: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + testDir = await mkdtemp(join(tmpdir(), 'git-status-test-')); + process.chdir(testDir); + + await $`git init`.quiet(); + await $`git config user.email "test@example.com"`.quiet(); + await $`git config user.name "Test User"`.quiet(); + await $`git config commit.gpgsign false`.quiet(); + await writeFile('README.md', '# Test'); + await $`git add .`.quiet(); + await $`git commit -m "Initial commit"`.quiet(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(testDir, { recursive: true, force: true }); + }); + + test('returns empty status for clean repo', async () => { + const status = await git.gitGetWorkingDirectoryStatus(); + + expect(status.staged).toEqual([]); + expect(status.unstaged).toEqual([]); + expect(status.untracked).toEqual([]); + expect(status.hasChanges).toBe(false); + }); + + test('detects staged changes', async () => { + await writeFile('new.txt', 'content'); + await $`git add new.txt`.quiet(); + + const status = await git.gitGetWorkingDirectoryStatus(); + + expect(status.staged).toContain('new.txt'); + expect(status.unstaged).toEqual([]); + expect(status.untracked).toEqual([]); + expect(status.hasChanges).toBe(true); + }); + + test('detects unstaged changes', async () => { + await writeFile('README.md', 'modified content'); + + const status = await git.gitGetWorkingDirectoryStatus(); + + expect(status.staged).toEqual([]); + expect(status.unstaged).toContain('README.md'); + expect(status.untracked).toEqual([]); + expect(status.hasChanges).toBe(true); + }); + + test('detects untracked files', async () => { + await writeFile('untracked.txt', 'content'); + + const status = await git.gitGetWorkingDirectoryStatus(); + + expect(status.staged).toEqual([]); + expect(status.unstaged).toEqual([]); + expect(status.untracked).toContain('untracked.txt'); + expect(status.hasChanges).toBe(true); + }); + + test('detects mixed changes', async () => { + // Staged change + await writeFile('staged.txt', 'staged'); + await $`git add staged.txt`.quiet(); + + // Unstaged change + await writeFile('README.md', 'modified'); + + // Untracked file + await writeFile('untracked.txt', 'untracked'); + + const status = await git.gitGetWorkingDirectoryStatus(); + + expect(status.staged).toContain('staged.txt'); + expect(status.unstaged).toContain('README.md'); + expect(status.untracked).toContain('untracked.txt'); + expect(status.hasChanges).toBe(true); + }); + + test('detects file with both staged and unstaged changes', async () => { + // Stage a change + await writeFile('README.md', 'staged version'); + await $`git add README.md`.quiet(); + + // Make additional unstaged change + await writeFile('README.md', 'unstaged version'); + + const status = await git.gitGetWorkingDirectoryStatus(); + + expect(status.staged).toContain('README.md'); + expect(status.unstaged).toContain('README.md'); + expect(status.hasChanges).toBe(true); + }); +}); + +describe('formatWorkingDirectoryStatus', () => { + test('formats staged changes', () => { + const status: git.WorkingDirectoryStatus = { + staged: ['file1.ts', 'file2.ts'], + unstaged: [], + untracked: [], + hasChanges: true, + }; + + const output = git.formatWorkingDirectoryStatus(status); + + expect(output).toContain('Staged changes (2):'); + expect(output).toContain('file1.ts'); + expect(output).toContain('file2.ts'); + }); + + test('formats unstaged changes', () => { + const status: git.WorkingDirectoryStatus = { + staged: [], + unstaged: ['modified.ts'], + untracked: [], + hasChanges: true, + }; + + const output = git.formatWorkingDirectoryStatus(status); + + expect(output).toContain('Unstaged changes (1):'); + expect(output).toContain('modified.ts'); + }); + + test('formats untracked files', () => { + const status: git.WorkingDirectoryStatus = { + staged: [], + unstaged: [], + untracked: ['new.ts'], + hasChanges: true, + }; + + const output = git.formatWorkingDirectoryStatus(status); + + expect(output).toContain('Untracked files (1):'); + expect(output).toContain('new.ts'); + }); + + test('truncates long lists', () => { + const status: git.WorkingDirectoryStatus = { + staged: ['f1.ts', 'f2.ts', 'f3.ts', 'f4.ts', 'f5.ts', 'f6.ts', 'f7.ts'], + unstaged: [], + untracked: [], + hasChanges: true, + }; + + const output = git.formatWorkingDirectoryStatus(status); + + expect(output).toContain('Staged changes (7):'); + expect(output).toContain('... and 2 more'); + }); + + test('returns empty string for no changes', () => { + const status: git.WorkingDirectoryStatus = { + staged: [], + unstaged: [], + untracked: [], + hasChanges: false, + }; + + const output = git.formatWorkingDirectoryStatus(status); + + expect(output).toBe(''); + }); +}); + +describe('gitHasUncommittedChanges', () => { + let testDir: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + testDir = await mkdtemp(join(tmpdir(), 'git-changes-test-')); + process.chdir(testDir); + + await $`git init`.quiet(); + await $`git config user.email "test@example.com"`.quiet(); + await $`git config user.name "Test User"`.quiet(); + await $`git config commit.gpgsign false`.quiet(); + await writeFile('README.md', '# Test'); + await $`git add .`.quiet(); + await $`git commit -m "Initial commit"`.quiet(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(testDir, { recursive: true, force: true }); + }); + + test('returns false for clean repo', async () => { + const hasChanges = await git.gitHasUncommittedChanges(); + expect(hasChanges).toBe(false); + }); + + test('returns true for staged changes', async () => { + await writeFile('new.txt', 'content'); + await $`git add new.txt`.quiet(); + + const hasChanges = await git.gitHasUncommittedChanges(); + expect(hasChanges).toBe(true); + }); + + test('returns true for unstaged changes', async () => { + await writeFile('README.md', 'modified'); + + const hasChanges = await git.gitHasUncommittedChanges(); + expect(hasChanges).toBe(true); + }); + + test('ignores untracked files by default', async () => { + await writeFile('untracked.txt', 'content'); + + const hasChanges = await git.gitHasUncommittedChanges(); + expect(hasChanges).toBe(false); + }); + + test('includes untracked files when option is set', async () => { + await writeFile('untracked.txt', 'content'); + + const hasChanges = await git.gitHasUncommittedChanges(undefined, { includeUntracked: true }); + expect(hasChanges).toBe(true); + }); +}); diff --git a/tests/worktree.test.ts b/tests/worktree.test.ts index 8d16115..3de2943 100644 --- a/tests/worktree.test.ts +++ b/tests/worktree.test.ts @@ -36,6 +36,7 @@ beforeEach(async () => { await $`git add .`.quiet(); await $`git config user.email "test@example.com"`.quiet(); await $`git config user.name "Test User"`.quiet(); + await $`git config commit.gpgsign false`.quiet(); await $`git commit -m "Initial commit"`.quiet(); // Get the default branch name (master in CI, main locally) @@ -251,6 +252,92 @@ describe('worktree.remove() - safety checks', () => { await expect(worktree.remove('nonexistent')).rejects.toThrow(FileSystemError); await expect(worktree.remove('nonexistent')).rejects.toThrow('No such worktree directory'); }); + + test('throws UncommittedChangesError with only staged changes', async () => { + // Create merged branch with staged changes + await $`git checkout -b feature/staged`.quiet(); + await $`git checkout ${defaultBranch}`.quiet(); + await $`git merge feature/staged`.quiet(); + await $`git worktree add ../feature-staged feature/staged`.quiet(); + + // Add staged change (file is added to index but not committed) + const featurePath = join(testDir, 'feature-staged'); + await writeFile(join(featurePath, 'staged.txt'), 'staged content'); + await $`git -C ${featurePath} add staged.txt`.quiet(); + + const error = await worktree.remove('feature/staged').catch((e) => e); + expect(error).toBeInstanceOf(UncommittedChangesError); + expect(error.message).toContain('Staged changes'); + expect(error.message).toContain('staged.txt'); + }); + + test('throws UncommittedChangesError with only unstaged changes', async () => { + // Create merged branch with unstaged changes + await $`git checkout -b feature/unstaged`.quiet(); + await $`git checkout ${defaultBranch}`.quiet(); + await $`git merge feature/unstaged`.quiet(); + await $`git worktree add ../feature-unstaged feature/unstaged`.quiet(); + + // Modify existing file without staging + const featurePath = join(testDir, 'feature-unstaged'); + await writeFile(join(featurePath, 'README.md'), 'modified content'); + + const error = await worktree.remove('feature/unstaged').catch((e) => e); + expect(error).toBeInstanceOf(UncommittedChangesError); + expect(error.message).toContain('Unstaged changes'); + expect(error.message).toContain('README.md'); + }); + + test('throws UncommittedChangesError with only untracked files', async () => { + // Create merged branch with untracked file + await $`git checkout -b feature/untracked`.quiet(); + await $`git checkout ${defaultBranch}`.quiet(); + await $`git merge feature/untracked`.quiet(); + await $`git worktree add ../feature-untracked feature/untracked`.quiet(); + + // Add untracked file + const featurePath = join(testDir, 'feature-untracked'); + await writeFile(join(featurePath, 'untracked.txt'), 'untracked content'); + + const error = await worktree.remove('feature/untracked').catch((e) => e); + expect(error).toBeInstanceOf(UncommittedChangesError); + expect(error.message).toContain('Untracked files'); + expect(error.message).toContain('untracked.txt'); + }); + + test('throws UncommittedChangesError with mixed changes (staged + unstaged)', async () => { + // Create merged branch with mixed changes + await $`git checkout -b feature/mixed`.quiet(); + await $`git checkout ${defaultBranch}`.quiet(); + await $`git merge feature/mixed`.quiet(); + await $`git worktree add ../feature-mixed feature/mixed`.quiet(); + + const featurePath = join(testDir, 'feature-mixed'); + + // Add staged change + await writeFile(join(featurePath, 'staged.txt'), 'staged content'); + await $`git -C ${featurePath} add staged.txt`.quiet(); + + // Add unstaged change + await writeFile(join(featurePath, 'README.md'), 'modified content'); + + const error = await worktree.remove('feature/mixed').catch((e) => e); + expect(error).toBeInstanceOf(UncommittedChangesError); + expect(error.message).toContain('Staged changes'); + expect(error.message).toContain('Unstaged changes'); + }); + + test('allows removal of clean merged worktree', async () => { + // Create and merge branch + await $`git checkout -b feature/clean`.quiet(); + await $`git checkout ${defaultBranch}`.quiet(); + await $`git merge feature/clean`.quiet(); + await $`git worktree add ../feature-clean feature/clean`.quiet(); + + // Should succeed - branch is merged and has no changes + const result = await worktree.remove('feature/clean'); + expect(result.path).toContain('feature-clean'); + }); }); describe('worktree.setup()', () => {