Skip to content
Draft
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
18 changes: 13 additions & 5 deletions src/lib/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -311,8 +312,10 @@ export async function remove(identifier: string, force = false): Promise<RemoveR

if (!force) {
// Check for uncommitted changes (includes both tracked changes and untracked files)
if (await gitHasUncommittedChanges(worktreeDir, { includeUntracked: true })) {
throw new UncommittedChangesError(identifier);
const status = await gitGetWorkingDirectoryStatus(worktreeDir);
if (status.hasChanges) {
const statusSummary = formatWorkingDirectoryStatus(status);
throw new UncommittedChangesError(identifier, statusSummary);
}

// Check if branch is merged
Expand Down Expand Up @@ -371,8 +374,13 @@ export async function setup(targetDir?: string): Promise<SetupResult> {

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}`;
Expand Down
17 changes: 12 additions & 5 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
120 changes: 112 additions & 8 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,22 +329,126 @@ 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<WorkingDirectoryStatus> {
// 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
*/
export async function gitHasUncommittedChanges(
cwd?: string,
options?: { includeUntracked?: boolean }
): Promise<boolean> {
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;
}
Loading