| author | |
| committer | |
| log | 58a8d653a8f16d3c0e8d8ff86728fcf4d28e32fb |
| tree | ada5cd024dd76b73c6a3466a297d4b2868593cd7 |
| parent | eecb53498ad182f51ad1743280e8d22e5e1d116c |
- 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 behavior5 files changed, 35 insertions(+), 3 deletions(-)
apps/server/src/git.test.ts+26| ... | ... | @@ -617,6 +617,32 @@ describe("git integration", () => { |
| 617 | 617 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); |
| 618 | 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 | }); |
| 621 | 647 | |
| 622 | 648 | // ── Full flow: local branch checkout ── |
apps/server/src/git.ts+5-1| ... | ... | @@ -546,7 +546,11 @@ export class GitCoreService { |
| 546 | 546 | } |
| 547 | 547 | |
| 548 | 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 | 554 | try { |
| 551 | 555 | await executeGit(input.cwd, args, { |
| 552 | 556 | timeoutMs: 15_000, |
apps/web/src/components/Sidebar.tsx+1| ... | ... | @@ -331,6 +331,7 @@ export default function Sidebar() { |
| 331 | 331 | await removeWorktreeMutation.mutateAsync({ |
| 332 | 332 | cwd: threadProject.cwd, |
| 333 | 333 | path: orphanedWorktreePath, |
| 334 | force: true, | |
| 334 | 335 | }); |
| 335 | 336 | } catch (error) { |
| 336 | 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 | 173 | queryClient: QueryClient; |
| 174 | 174 | }) { |
| 175 | 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 | 177 | if (!input.api) { |
| 178 | 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 | 182 | onSettled: async () => { |
| 183 | 183 | await invalidateGitQueries(input.queryClient); |
packages/contracts/src/git.ts+1| ... | ... | @@ -16,6 +16,7 @@ export const gitCreateWorktreeInputSchema = z.object({ |
| 16 | 16 | export const gitRemoveWorktreeInputSchema = z.object({ |
| 17 | 17 | cwd: z.string().min(1), |
| 18 | 18 | path: z.string().min(1), |
| 19 | force: z.boolean().optional(), | |
| 19 | 20 | }); |
| 20 | 21 | |
| 21 | 22 | export const gitCreateBranchInputSchema = z.object({ |