| 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,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 | }); |
| 621 | 647 | ||
| 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 | } |
| 547 | 547 | ||
| 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({ |
| 16 | export const gitRemoveWorktreeInputSchema = z.object({ | 16 | export 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 | }); |
| 20 | 21 | ||
| 21 | export const gitCreateBranchInputSchema = z.object({ | 22 | export const gitCreateBranchInputSchema = z.object({ |