feat(tui): delete working copies from move dialog (#31017)

This commit is contained in:
James Long 2026-06-05 16:47:10 -04:00 committed by GitHub
commit f591bf5f93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 337 additions and 117 deletions

View file

@ -30,6 +30,7 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
operation: Schema.Literals(["create", "remove", "list"]),
message: Schema.String,
directory: Schema.optional(AbsolutePath),
forceRequired: Schema.optional(Schema.Boolean),
cause: Schema.optional(Schema.Defect),
}) {}
@ -64,7 +65,11 @@ export interface Interface {
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: {
repo: Repo
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, WorktreeError>
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
}
@ -335,10 +340,12 @@ export const layer = Layer.effect(
),
)
if (result.exitCode === 0) return result.stdout.toString("utf8")
const message = result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed"
return yield* new WorktreeError({
operation,
directory: worktreeDirectory,
message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
message,
forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message),
})
})
@ -346,11 +353,15 @@ export const layer = Layer.effect(
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
})
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) {
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: {
repo: Repo
directory: AbsolutePath
force: boolean
}) {
yield* worktree(
"remove",
input.repo,
["worktree", "remove", "--force", input.directory],
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
input.directory,
input.repo.store,
)

View file

@ -19,10 +19,10 @@ export function makeStrategies(input: {
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
const found = yield* input.git.find(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
yield* input.git.worktreeRemove({ repo: found, directory })
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const found = yield* input.git.find(options.directory)
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const found = yield* input.git.find(directory)

View file

@ -34,6 +34,7 @@ export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: Project.ID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
export type RemoveInput = typeof RemoveInput.Type
@ -82,7 +83,10 @@ export interface Strategy {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
}
@ -209,7 +213,7 @@ export const layer = Layer.effect(
const copyDirectory = yield* canonical(input.directory)
const id = yield* detect({ directory: copyDirectory })
if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
yield* strategy(id).remove(copyDirectory)
yield* strategy(id).remove({ directory: copyDirectory, force: input.force })
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
})

View file

@ -99,7 +99,7 @@ describe("Git worktrees", () => {
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
expect(linked?.store).toBe(repo.store)
if (!linked) throw new Error("Linked worktree not found")
yield* git.worktreeRemove({ repo: linked, directory: worktree })
yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false })
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
}),
)

View file

@ -124,13 +124,48 @@ describe("ProjectCopy", () => {
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
it.live("requires force to remove a dirty git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
const created = yield* copy.create({
projectID: input.projectID,
strategy: "git_worktree",
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
const error = yield* copy
.remove({ projectID: input.projectID, directory: created.directory, force: false })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Git.WorktreeError)
if (error instanceof Git.WorktreeError) {
expect(error.operation).toBe("remove")
expect(error.forceRequired).toBe(true)
}
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" })
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
}),
)
it.live("adds a numeric suffix when a copy directory already exists", () =>
Effect.gen(function* () {
const input = yield* setup()
@ -160,7 +195,7 @@ describe("ProjectCopy", () => {
true,
)
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
}),
)