Skip to content

Commit 1ac0e33

Browse files
authored
feat(tui): delete working copies from move dialog (anomalyco#31017)
1 parent 31a8b15 commit 1ac0e33

19 files changed

Lines changed: 337 additions & 117 deletions

File tree

‎packages/core/src/git.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
3030
operation: Schema.Literals(["create", "remove", "list"]),
3131
message: Schema.String,
3232
directory: Schema.optional(AbsolutePath),
33+
forceRequired: Schema.optional(Schema.Boolean),
3334
cause: Schema.optional(Schema.Defect),
3435
}) {}
3536

@@ -64,7 +65,11 @@ export interface Interface {
6465
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
6566
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
6667
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
67-
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
68+
readonly worktreeRemove: (input: {
69+
repo: Repo
70+
directory: AbsolutePath
71+
force: boolean
72+
}) => Effect.Effect<void, WorktreeError>
6873
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
6974
}
7075

@@ -335,22 +340,28 @@ export const layer = Layer.effect(
335340
),
336341
)
337342
if (result.exitCode === 0) return result.stdout.toString("utf8")
343+
const message = result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed"
338344
return yield* new WorktreeError({
339345
operation,
340346
directory: worktreeDirectory,
341-
message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
347+
message,
348+
forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message),
342349
})
343350
})
344351

345352
const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
346353
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
347354
})
348355

349-
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) {
356+
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: {
357+
repo: Repo
358+
directory: AbsolutePath
359+
force: boolean
360+
}) {
350361
yield* worktree(
351362
"remove",
352363
input.repo,
353-
["worktree", "remove", "--force", input.directory],
364+
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
354365
input.directory,
355366
input.repo.store,
356367
)

‎packages/core/src/project/copy-strategies.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ export function makeStrategies(input: {
1919
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
2020
return { directory: yield* input.canonical(options.directory) }
2121
}),
22-
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
23-
const found = yield* input.git.find(directory)
24-
if (!found) return yield* new DirectoryUnavailableError({ directory })
25-
yield* input.git.worktreeRemove({ repo: found, directory })
22+
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
23+
const found = yield* input.git.find(options.directory)
24+
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
25+
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
2626
}),
2727
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
2828
const found = yield* input.git.find(directory)

‎packages/core/src/project/copy.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type CreateInput = typeof CreateInput.Type
3434
export const RemoveInput = Schema.Struct({
3535
projectID: Project.ID,
3636
directory: AbsolutePath,
37+
force: Schema.Boolean,
3738
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
3839
export type RemoveInput = typeof RemoveInput.Type
3940

@@ -82,7 +83,10 @@ export interface Strategy {
8283
sourceDirectory: AbsolutePath
8384
directory: AbsolutePath
8485
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
85-
readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
86+
readonly remove: (input: {
87+
directory: AbsolutePath
88+
force: boolean
89+
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
8690
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
8791
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
8892
}
@@ -209,7 +213,7 @@ export const layer = Layer.effect(
209213
const copyDirectory = yield* canonical(input.directory)
210214
const id = yield* detect({ directory: copyDirectory })
211215
if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
212-
yield* strategy(id).remove(copyDirectory)
216+
yield* strategy(id).remove({ directory: copyDirectory, force: input.force })
213217
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
214218
})
215219

‎packages/core/test/git.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe("Git worktrees", () => {
9999
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
100100
expect(linked?.store).toBe(repo.store)
101101
if (!linked) throw new Error("Linked worktree not found")
102-
yield* git.worktreeRemove({ repo: linked, directory: worktree })
102+
yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false })
103103
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
104104
}),
105105
)

‎packages/core/test/project-copy.test.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,48 @@ describe("ProjectCopy", () => {
124124
)
125125
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
126126

127-
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
127+
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
128128

129129
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
130130
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
131131
}),
132132
)
133133

134+
it.live("requires force to remove a dirty git worktree", () =>
135+
Effect.gen(function* () {
136+
const input = yield* setup()
137+
const copy = yield* ProjectCopy.Service
138+
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
139+
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
140+
yield* Effect.addFinalizer(() =>
141+
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
142+
)
143+
const created = yield* copy.create({
144+
projectID: input.projectID,
145+
strategy: "git_worktree",
146+
sourceDirectory: input.sourceDirectory,
147+
directory: parent,
148+
name: "copy",
149+
})
150+
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
151+
152+
const error = yield* copy
153+
.remove({ projectID: input.projectID, directory: created.directory, force: false })
154+
.pipe(Effect.flip)
155+
156+
expect(error).toBeInstanceOf(Git.WorktreeError)
157+
if (error instanceof Git.WorktreeError) {
158+
expect(error.operation).toBe("remove")
159+
expect(error.forceRequired).toBe(true)
160+
}
161+
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" })
162+
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
163+
164+
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
165+
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
166+
}),
167+
)
168+
134169
it.live("adds a numeric suffix when a copy directory already exists", () =>
135170
Effect.gen(function* () {
136171
const input = yield* setup()
@@ -160,7 +195,7 @@ describe("ProjectCopy", () => {
160195
true,
161196
)
162197

163-
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
198+
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
164199
}),
165200
)
166201

‎packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export function DialogModel(props: { providerID?: string }) {
157157
{
158158
command: "model.dialog.favorite",
159159
title: "Favorite",
160-
disabled: !connected(),
160+
hidden: !connected(),
161161
onTrigger: (option) => {
162162
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
163163
},

0 commit comments

Comments
 (0)