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", () => {...@@ -617,6 +617,32 @@ describe("git integration", () => {
617 await removeGitWorktree({ cwd: tmp.path, path: wtPath });617 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
618 expect(existsSync(wtPath)).toBe(false);618 expect(existsSync(wtPath)).toBe(false);
619 });619 });
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 });
620 });646 });
621647
622 // ── Full flow: local branch checkout ──648 // ── Full flow: local branch checkout ──
apps/server/src/git.ts+5-1
...@@ -546,7 +546,11 @@ export class GitCoreService {...@@ -546,7 +546,11 @@ export class GitCoreService {
546 }546 }
547547
548 async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> {548 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);
550 try {554 try {
551 await executeGit(input.cwd, args, {555 await executeGit(input.cwd, args, {
552 timeoutMs: 15_000,556 timeoutMs: 15_000,
apps/web/src/components/Sidebar.tsx+1
...@@ -331,6 +331,7 @@ export default function Sidebar() {...@@ -331,6 +331,7 @@ export default function Sidebar() {
331 await removeWorktreeMutation.mutateAsync({331 await removeWorktreeMutation.mutateAsync({
332 cwd: threadProject.cwd,332 cwd: threadProject.cwd,
333 path: orphanedWorktreePath,333 path: orphanedWorktreePath,
334 force: true,
334 });335 });
335 } catch (error) {336 } catch (error) {
336 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";337 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: {...@@ -173,11 +173,11 @@ export function gitRemoveWorktreeMutationOptions(input: {
173 queryClient: QueryClient;173 queryClient: QueryClient;
174}) {174}) {
175 return mutationOptions({175 return mutationOptions({
176 mutationFn: async ({ cwd, path }: { cwd: string; path: string }) => {176 mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => {
177 if (!input.api) {177 if (!input.api) {
178 throw new Error("Git worktree removal is unavailable.");178 throw new Error("Git worktree removal is unavailable.");
179 }179 }
180 return input.api.git.removeWorktree({ cwd, path });180 return input.api.git.removeWorktree({ cwd, path, force });
181 },181 },
182 onSettled: async () => {182 onSettled: async () => {
183 await invalidateGitQueries(input.queryClient);183 await invalidateGitQueries(input.queryClient);
packages/contracts/src/git.ts+1
...@@ -16,6 +16,7 @@ export const gitCreateWorktreeInputSchema = z.object({...@@ -16,6 +16,7 @@ export const gitCreateWorktreeInputSchema = z.object({
16export const gitRemoveWorktreeInputSchema = z.object({16export const gitRemoveWorktreeInputSchema = z.object({
17 cwd: z.string().min(1),17 cwd: z.string().min(1),
18 path: z.string().min(1),18 path: z.string().min(1),
19 force: z.boolean().optional(),
19});20});
2021
21export const gitCreateBranchInputSchema = z.object({22export const gitCreateBranchInputSchema = z.object({