diff --git a/apps/server/src/git.test.ts b/apps/server/src/git.test.ts index bea0f3a9719488a074a2650932f491c1dd6ad8db..1e01c6d2623ede654bd0cc3c8de2ed234b1c21fe 100644 --- a/apps/server/src/git.test.ts +++ b/apps/server/src/git.test.ts @@ -617,6 +617,32 @@ describe("git integration", () => { await removeGitWorktree({ cwd: tmp.path, path: wtPath }); expect(existsSync(wtPath)).toBe(false); }); + + it("removeGitWorktree force removes a dirty worktree", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "wt-dirty-dir"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "wt-dirty", + path: wtPath, + }); + expect(existsSync(wtPath)).toBe(true); + + await writeFile(path.join(wtPath, "README.md"), "dirty change\n"); + + await expect(removeGitWorktree({ cwd: tmp.path, path: wtPath })).rejects.toThrow(); + expect(existsSync(wtPath)).toBe(true); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath, force: true }); + expect(existsSync(wtPath)).toBe(false); + }); }); // ── Full flow: local branch checkout ── diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index 874c4f9788c7fb0179525c917631cd074db0be20..a9a7aa062e55782292eab57bba45e65973403ed2 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -546,7 +546,11 @@ export class GitCoreService { } async removeWorktree(input: GitRemoveWorktreeInput): Promise { - const args = ["worktree", "remove", input.path] as const; + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); try { await executeGit(input.cwd, args, { timeoutMs: 15_000, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9f9b5f15bd57043c9dc459d866e306af493884a6..71a6e02ce87164ecbdfb4a91eaf977ce7455268a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -331,6 +331,7 @@ export default function Sidebar() { await removeWorktreeMutation.mutateAsync({ cwd: threadProject.cwd, path: orphanedWorktreePath, + force: true, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing worktree."; diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index cd39aed6419850a526542f632990690633bf3798..fc0c7160a9b436f38d12da6918cee49674de7036 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -173,11 +173,11 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient; }) { return mutationOptions({ - mutationFn: async ({ cwd, path }: { cwd: string; path: string }) => { + mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { if (!input.api) { throw new Error("Git worktree removal is unavailable."); } - return input.api.git.removeWorktree({ cwd, path }); + return input.api.git.removeWorktree({ cwd, path, force }); }, onSettled: async () => { await invalidateGitQueries(input.queryClient); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 5b275df1c706f38b2e185b2349d2f3f48779c967..fad26b834b16f297dd68bebf53cd3bc9d37bcc66 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -16,6 +16,7 @@ export const gitCreateWorktreeInputSchema = z.object({ export const gitRemoveWorktreeInputSchema = z.object({ cwd: z.string().min(1), path: z.string().min(1), + force: z.boolean().optional(), }); export const gitCreateBranchInputSchema = z.object({