authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 00:07:09-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-19 00:14:39-08:00
log58a8d653a8f16d3c0e8d8ff86728fcf4d28e32fb
treeada5cd024dd76b73c6a3466a297d4b2868593cd7
parenteecb53498ad182f51ad1743280e8d22e5e1d116c

Force-remove dirty git worktrees during orphan cleanup

- add optional `force` to remove-worktree contract and server git args - pass `force: true` from sidebar orphaned worktree cleanup flow - add integration test covering dirty worktree removal behavior

5 files changed, 35 insertions(+), 3 deletions(-)

apps/server/src/git.test.ts+26
......@@ -617,6 +617,32 @@ describe("git integration", () => {
617617 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
618618 expect(existsSync(wtPath)).toBe(false);
619619 });
620
621 it("removeGitWorktree force removes a dirty worktree", async () => {
622 await using tmp = await makeTmpDir();
623 await initRepoWithCommit(tmp.path);
624
625 const wtPath = path.join(tmp.path, "wt-dirty-dir");
626 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
627 (b) => b.current,
628 )!.name;
629
630 await createGitWorktree({
631 cwd: tmp.path,
632 branch: currentBranch,
633 newBranch: "wt-dirty",
634 path: wtPath,
635 });
636 expect(existsSync(wtPath)).toBe(true);
637
638 await writeFile(path.join(wtPath, "README.md"), "dirty change\n");
639
640 await expect(removeGitWorktree({ cwd: tmp.path, path: wtPath })).rejects.toThrow();
641 expect(existsSync(wtPath)).toBe(true);
642
643 await removeGitWorktree({ cwd: tmp.path, path: wtPath, force: true });
644 expect(existsSync(wtPath)).toBe(false);
645 });
620646 });
621647
622648 // ── Full flow: local branch checkout ──
apps/server/src/git.ts+5-1
......@@ -546,7 +546,11 @@ export class GitCoreService {
546546 }
547547
548548 async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> {
549 const args = ["worktree", "remove", input.path] as const;
549 const args = ["worktree", "remove"];
550 if (input.force) {
551 args.push("--force");
552 }
553 args.push(input.path);
550554 try {
551555 await executeGit(input.cwd, args, {
552556 timeoutMs: 15_000,
apps/web/src/components/Sidebar.tsx+1
......@@ -331,6 +331,7 @@ export default function Sidebar() {
331331 await removeWorktreeMutation.mutateAsync({
332332 cwd: threadProject.cwd,
333333 path: orphanedWorktreePath,
334 force: true,
334335 });
335336 } catch (error) {
336337 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
apps/web/src/lib/gitReactQuery.ts+2-2
......@@ -173,11 +173,11 @@ export function gitRemoveWorktreeMutationOptions(input: {
173173 queryClient: QueryClient;
174174}) {
175175 return mutationOptions({
176 mutationFn: async ({ cwd, path }: { cwd: string; path: string }) => {
176 mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => {
177177 if (!input.api) {
178178 throw new Error("Git worktree removal is unavailable.");
179179 }
180 return input.api.git.removeWorktree({ cwd, path });
180 return input.api.git.removeWorktree({ cwd, path, force });
181181 },
182182 onSettled: async () => {
183183 await invalidateGitQueries(input.queryClient);
packages/contracts/src/git.ts+1
......@@ -16,6 +16,7 @@ export const gitCreateWorktreeInputSchema = z.object({
1616export const gitRemoveWorktreeInputSchema = z.object({
1717 cwd: z.string().min(1),
1818 path: z.string().min(1),
19 force: z.boolean().optional(),
1920});
2021
2122export const gitCreateBranchInputSchema = z.object({