diff --git a/src/services/Git.ts b/src/services/Git.ts index cbb2e84..9c9fdb0 100644 --- a/src/services/Git.ts +++ b/src/services/Git.ts @@ -7,8 +7,16 @@ import { BranchRef, branchRef, ExecError } from "../domain/model.ts"; import * as Proc from "../platform/proc.ts"; import { StackConfig } from "./Config.ts"; +export interface Worktree { + readonly path: string; + readonly head: string | null; + readonly branch: string | null; + readonly dirty: ReadonlyArray; +} + export interface Interface { readonly dirty: () => Effect.Effect, ExecError>; + readonly worktrees: () => Effect.Effect, ExecError>; readonly fetch: () => Effect.Effect; readonly remotes: () => Effect.Effect< ReadonlyArray<{ readonly name: string; readonly url: string }>, @@ -51,14 +59,89 @@ export const live = Layer.effect( const cfg = yield* StackConfig; const proc = yield* Proc.Service; - const run = Effect.fn("Git.run")(function* ( + const runAt = Effect.fn("Git.runAt")(function* ( + cwd: string, tool: string, args: ReadonlyArray, ok: ReadonlyArray = [0], ) { - return yield* proc.exec(cfg.root, tool, args, ok); + return yield* proc.exec(cwd, tool, args, ok); + }); + const run = Effect.fn("Git.run")( + (tool: string, args: ReadonlyArray, ok: ReadonlyArray = [0]) => + runAt(cfg.root, tool, args, ok), + ); + + const dirtyAt = Effect.fn("Git.dirtyAt")((path: string) => + runAt(path, "git", ["status", "--short"]).pipe( + Effect.map((out) => out.split("\n").filter(Boolean)), + ), + ); + + const worktrees = Effect.fn("Git.worktrees")(function* () { + const out = yield* run("git", ["worktree", "list", "--porcelain", "-z"]); + const records: Array<{ + path: string; + head: string | null; + branch: string | null; + prunable: boolean; + }> = []; + let current: { + path: string; + head: string | null; + branch: string | null; + prunable: boolean; + } | null = null; + for (const field of out.split("\0").filter(Boolean)) { + if (field.startsWith("worktree ")) { + if (current) records.push(current); + current = { + path: field.slice("worktree ".length), + head: null, + branch: null, + prunable: false, + }; + continue; + } + if (!current) continue; + if (field.startsWith("HEAD ")) current.head = field.slice("HEAD ".length); + else if (field.startsWith("branch refs/heads/")) + current.branch = field.slice("branch refs/heads/".length); + else if (field === "detached") current.branch = null; + else if (field === "prunable" || field.startsWith("prunable ")) current.prunable = true; + } + if (current) records.push(current); + + return yield* Effect.forEach( + records.filter((record) => !record.prunable), + (record) => + dirtyAt(record.path).pipe( + Effect.map( + (dirty): Worktree => ({ + path: record.path, + head: record.head, + branch: record.branch, + dirty, + }), + ), + ), + { concurrency: "unbounded" }, + ); }); + const checkedOutDirtyError = (branch: string, worktree: Worktree) => + new ExecError( + "git", + ["replay", branch], + 1, + [ + `${branch} is checked out at ${worktree.path} with local changes:`, + ...worktree.dirty.map((line) => ` ${line}`), + "", + `Commit, stash, or clean that worktree before repairing ${branch}.`, + ].join("\n"), + ); + const refs = Effect.fn("Git.refs")(function* () { const out = yield* run("git", [ "for-each-ref", @@ -75,9 +158,7 @@ export const live = Layer.effect( .map(([name, head]) => branchRef({ name, head })); }); - const dirty = Effect.fn("Git.dirty")(() => - run("git", ["status", "--short"]).pipe(Effect.map((out) => out.split("\n").filter(Boolean))), - ); + const dirty = Effect.fn("Git.dirty")(() => dirtyAt(cfg.root)); const current = Effect.fn("Git.current")(() => run("git", ["branch", "--show-current"])); const remote = Effect.fn("Git.remote")(() => @@ -145,27 +226,40 @@ export const live = Layer.effect( parent: string, commits: ReadonlyArray, ) { - const current = yield* run("git", ["branch", "--show-current"]); + const owner = (yield* worktrees()).find((worktree) => worktree.branch === branch) ?? null; + if (owner && owner.dirty.length > 0) { + return yield* Effect.fail(checkedOutDirtyError(branch, owner)); + } + + const root = owner?.path ?? cfg.root; + const current = yield* runAt(root, "git", ["branch", "--show-current"]); const now = yield* Clock.currentTimeMillis; const temp = `stack/replay-${now}-${branch.replaceAll("/", "-")}`; - const abortCherryPick = run("git", ["cherry-pick", "--abort"], [0, 1, 128]).pipe( + const abortCherryPick = runAt(root, "git", ["cherry-pick", "--abort"], [0, 1, 128]).pipe( Effect.asVoid, Effect.orDie, ); - const deleteTemp = run("git", ["branch", "-D", temp], [0, 1]).pipe( + const deleteTemp = runAt(root, "git", ["branch", "-D", temp], [0, 1]).pipe( Effect.asVoid, Effect.orDie, ); const restoreCurrent = current - ? run("git", ["checkout", current]).pipe(Effect.asVoid, Effect.orDie) + ? runAt(root, "git", ["checkout", current]).pipe(Effect.asVoid, Effect.orDie) : Effect.void; yield* Effect.gen(function* () { - yield* run("git", ["checkout", "-B", temp, parent]).pipe(Effect.asVoid); + yield* runAt(root, "git", ["checkout", "-B", temp, parent]).pipe(Effect.asVoid); if (commits.length > 0) { - yield* run("git", ["cherry-pick", "--empty=drop", ...commits]).pipe(Effect.asVoid); + yield* runAt(root, "git", ["cherry-pick", "--empty=drop", ...commits]).pipe( + Effect.asVoid, + ); + } + if (owner) { + yield* runAt(root, "git", ["checkout", branch]).pipe(Effect.asVoid); + yield* runAt(root, "git", ["reset", "--hard", temp]).pipe(Effect.asVoid); + } else { + yield* runAt(root, "git", ["branch", "-f", branch, temp]).pipe(Effect.asVoid); } - yield* run("git", ["branch", "-f", branch, temp]).pipe(Effect.asVoid); }).pipe( Effect.ensuring( abortCherryPick.pipe(Effect.ensuring(restoreCurrent.pipe(Effect.ensuring(deleteTemp)))), @@ -175,9 +269,23 @@ export const live = Layer.effect( const backup = Effect.fn("Git.backup")((branch: string, name: string) => run("git", ["branch", "-f", name, branch]).pipe(Effect.asVoid), ); - const drop = Effect.fn("Git.drop")((branch: string) => - run("git", ["branch", "-D", branch], [0, 1]).pipe(Effect.asVoid), - ); + const drop = Effect.fn("Git.drop")(function* (branch: string) { + const owner = + (yield* worktrees()).find( + (worktree) => worktree.branch === branch && worktree.path !== cfg.root, + ) ?? null; + if (owner) { + return yield* Effect.fail( + new ExecError( + "git", + ["branch", "-D", branch], + 1, + `${branch} is checked out at ${owner.path}; detach or remove that worktree before deleting the local branch.`, + ), + ); + } + return yield* run("git", ["branch", "-D", branch], [0, 1]).pipe(Effect.asVoid); + }); const restore = Effect.fn("Git.restore")((branch: string, name: string) => run("git", ["branch", "-f", branch, name]).pipe(Effect.asVoid), ); @@ -193,6 +301,7 @@ export const live = Layer.effect( fetch, remotes, dirty, + worktrees, refs, current, remote, @@ -221,6 +330,7 @@ export const test = (opts: { Service.of({ fetch: () => Effect.void, dirty: () => Effect.succeed([]), + worktrees: () => Effect.succeed([]), refs: () => Effect.succeed(opts.refs ?? []), remotes: () => Effect.succeed([]), current: () => Effect.succeed(opts.current ?? ""), diff --git a/src/services/Stack.ts b/src/services/Stack.ts index 323e787..193bc38 100644 --- a/src/services/Stack.ts +++ b/src/services/Stack.ts @@ -104,6 +104,31 @@ ${note}`; }), ); + const ensureRepairableWorktrees = Effect.fn("Stack.ensureRepairableWorktrees")(function* ( + branches: ReadonlyArray, + ) { + const wanted = new Set(branches); + if (wanted.size === 0) return; + const dirty = (yield* git.worktrees()).filter( + (item) => item.branch && wanted.has(item.branch) && item.dirty.length > 0, + ); + if (dirty.length === 0) return; + return yield* Effect.fail( + new StackOperationError( + [ + "Cannot repair checked-out dirty worktree branches:", + "", + ...dirty.flatMap((item) => [ + ` ${item.branch} -> ${item.path}`, + ...item.dirty.map((line) => ` ${line}`), + ]), + "", + "Commit, stash, or clean those worktrees, then rerun the command.", + ].join("\n"), + ), + ); + }); + const trunk = (name: string) => cfg.trunks.some((item) => item === name); const step = (message: string) => progress.emit({ _tag: "Step", message }); const wait = (message: string) => progress.emit({ _tag: "Wait", message }); @@ -798,6 +823,45 @@ ${note}`; } }; + const plannedRepairBranches = Effect.fn("Stack.repairStack.plannedRepairBranches")( + function* () { + const branches = new Set(); + const plannedMoved = new Set(); + const plannedTips = new Map(); + + for (const link of [...state.links].sort( + (a, b) => graph.rank(String(a.branch)) - graph.rank(String(b.branch)), + )) { + if (!live.has(String(link.branch))) continue; + + const parent = resolve(String(link.parent)); + if (!parent) continue; + + const onto = trunk(parent) ? `origin/${parent}` : parent; + if (!plannedTips.has(onto)) { + const tip = yield* git.head(onto); + plannedTips.set(onto, Option.isSome(tip) ? tip.value : null); + } + const want = plannedTips.get(onto) ?? heads.get(parent) ?? null; + const have = yield* git.base(link.branch, onto); + const drift = + replayAnchors.has(String(link.branch)) || + parent !== link.parent || + plannedMoved.has(parent) || + (want && (Option.isNone(have) || have.value !== want)); + + if (drift) { + branches.add(String(link.branch)); + plannedMoved.add(String(link.branch)); + } + } + + return branches; + }, + ); + + if (apply) yield* ensureRepairableWorktrees([...(yield* plannedRepairBranches())]); + for (const link of [...state.links].sort( (a, b) => graph.rank(String(a.branch)) - graph.rank(String(b.branch)), )) { @@ -1665,6 +1729,23 @@ ${note}`; }) : item; }); + const repairPulls = yield* changesForLinks( + scopedState.links.filter((item) => item.branch !== target), + plannedPulls, + ); + const plannedRepair = yield* repairStack( + scopedState, + refs.filter((item) => item.name !== target), + repairPulls, + { apply: false }, + ); + if (active) { + yield* ensureRepairableWorktrees( + plannedRepair.actions.flatMap((item) => + item._tag === "Rebase" ? [String(item.branch)] : [], + ), + ); + } const actions = [ ...(current === target ? [`${active ? "" : "would "}switch to ${root}`] : []), ...(hasLocalTarget ? [`${active ? "" : "would "}backup ${target} -> ${name}`] : []), @@ -1744,18 +1825,8 @@ ${note}`; return yield* repairAfterMerge(); } - const repairPulls = yield* changesForLinks( - scopedState.links.filter((item) => item.branch !== target), - plannedPulls, - ); - const repair = yield* repairStack( - scopedState, - refs.filter((item) => item.name !== target), - repairPulls, - { apply: false }, - ); const tail = next ? `next root: ${next}` : "next root: none"; - return [...actions, ...repair.lines, tail]; + return [...actions, ...plannedRepair.lines, tail]; }), ); diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 4ee9e16..3c77e16 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -73,6 +73,7 @@ const gitAndCodeHost = (service: Partial) => new ExecError(tool, args, 1, "unused test service"); const defaults: Git.Interface & CodeHost.Interface = { dirty: () => Effect.succeed([]), + worktrees: () => Effect.succeed([]), fetch: () => Effect.void, remotes: () => Effect.succeed([]), refs: () => Effect.succeed([]), @@ -1172,9 +1173,10 @@ describe("Git", () => { const error = yield* Effect.flip(git.replay("stack-b", "dev", ["b1"])); expect(error).toBeInstanceOf(ExecError); - const temp = calls[1]?.[3]; + const temp = calls[2]?.[3]; expect(temp).toBe("stack/replay-1700000000000-stack-b"); expect(calls).toEqual([ + ["git", "worktree", "list", "--porcelain", "-z"], ["git", "branch", "--show-current"], ["git", "checkout", "-B", temp, "dev"], ["git", "cherry-pick", "--empty=drop", "b1"], @@ -1185,6 +1187,130 @@ describe("Git", () => { }).pipe(Effect.provide(Git.live.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc)))); }); + it.effect( + "replay updates a checked-out branch from its owning clean worktree", + () => + Effect.gen(function* () { + const root = yield* tempDir(); + const repo = join(root, "repo"); + const sibling = join(root, "stack-b-worktree"); + + yield* mkdirp(repo); + yield* shell(repo, "git", ["init", "-b", "dev"]); + yield* shell(repo, "git", ["config", "user.email", "stack@example.com"]); + yield* shell(repo, "git", ["config", "user.name", "Stack Test"]); + yield* commitFile(repo, "base.txt", "base\n", "base"); + + yield* shell(repo, "git", ["checkout", "-b", "stack-b"]); + yield* commitFile(repo, "b.txt", "b1\n", "b1"); + const stackBCommit = yield* shell(repo, "git", ["rev-parse", "stack-b"]); + + yield* shell(repo, "git", ["checkout", "dev"]); + yield* commitFile(repo, "dev2.txt", "dev2\n", "dev2"); + const devTip = yield* shell(repo, "git", ["rev-parse", "dev"]); + yield* shell(repo, "git", ["worktree", "add", sibling, "stack-b"]); + + const cfgLayer = StackConfig.layer({ root: repo, trunks: ["dev"] }).pipe( + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const git = yield* Git.Service; + yield* git.replay("stack-b", "dev", [stackBCommit]); + }).pipe(Effect.provide(Git.live.pipe(Layer.provide(cfgLayer)))); + + expect(yield* shell(sibling, "git", ["branch", "--show-current"])).toBe("stack-b"); + expect(yield* shell(repo, "git", ["merge-base", "stack-b", "dev"])).toBe(devTip); + expect(yield* shell(sibling, "git", ["status", "--short"])).toBe(""); + }).pipe(Effect.provide(platform)), + 15_000, + ); + + it.effect( + "replay refuses a dirty owning worktree before moving the branch", + () => + Effect.gen(function* () { + const root = yield* tempDir(); + const repo = join(root, "repo"); + const sibling = join(root, "stack-b-worktree"); + + yield* mkdirp(repo); + yield* shell(repo, "git", ["init", "-b", "dev"]); + yield* shell(repo, "git", ["config", "user.email", "stack@example.com"]); + yield* shell(repo, "git", ["config", "user.name", "Stack Test"]); + yield* commitFile(repo, "base.txt", "base\n", "base"); + + yield* shell(repo, "git", ["checkout", "-b", "stack-b"]); + yield* commitFile(repo, "b.txt", "b1\n", "b1"); + const original = yield* shell(repo, "git", ["rev-parse", "stack-b"]); + + yield* shell(repo, "git", ["checkout", "dev"]); + yield* commitFile(repo, "dev2.txt", "dev2\n", "dev2"); + yield* shell(repo, "git", ["worktree", "add", sibling, "stack-b"]); + yield* put(join(sibling, "dirty.txt"), "dirty\n"); + + const cfgLayer = StackConfig.layer({ root: repo, trunks: ["dev"] }).pipe( + Layer.provide(NodeServices.layer), + ); + + const error = yield* Effect.gen(function* () { + const git = yield* Git.Service; + return yield* Effect.flip(git.replay("stack-b", "dev", [original])); + }).pipe(Effect.provide(Git.live.pipe(Layer.provide(cfgLayer)))); + + expect(error).toBeInstanceOf(ExecError); + expect(error.stderr).toContain("stack-b is checked out at "); + expect(error.stderr).toContain("stack-b-worktree"); + expect(error.stderr).toContain("?? dirty.txt"); + expect(yield* shell(repo, "git", ["rev-parse", "stack-b"])).toBe(original); + }).pipe(Effect.provide(platform)), + 15_000, + ); + + it.effect("worktrees ignores prunable records before dirty checks", () => { + const calls: Array<{ cwd: string; args: ReadonlyArray }> = []; + const proc = Layer.succeed( + Proc.Service, + Proc.Service.of({ + exec: (cwd, tool, args) => + Effect.gen(function* () { + calls.push({ cwd, args: [tool, ...args] }); + if (args[0] === "worktree") { + return [ + "worktree /tmp/stack", + "HEAD a", + "branch refs/heads/dev", + "worktree /tmp/missing-stack-b", + "HEAD b", + "branch refs/heads/stack-b", + "prunable gitdir file points to non-existent location", + "", + ].join("\0"); + } + if (cwd === "/tmp/missing-stack-b") { + return yield* Effect.fail(new ExecError(tool, args, 1, "ENOENT")); + } + return ""; + }), + }), + ); + + return Effect.gen(function* () { + const git = yield* Git.Service; + const worktrees = yield* git.worktrees(); + + expect(worktrees).toEqual([ + { + path: "/tmp/stack", + head: "a", + branch: "dev", + dirty: [], + }, + ]); + expect(calls.map((call) => call.cwd)).not.toContain("/tmp/missing-stack-b"); + }).pipe(Effect.provide(Git.live.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc)))); + }); + it.effect("push preserves origin tracking and supports fork remotes", () => { const calls: Array> = []; const proc = Layer.succeed( @@ -3565,6 +3691,138 @@ describe("Stack", () => { }).pipe(Effect.provide(platform)), ); + it.effect( + "sync repairs a branch from its owning clean worktree", + () => + Effect.gen(function* () { + const scenario = yield* realStack({ + current: "stack-b", + branches: [ + { + name: "stack-b", + parent: "dev", + number: 2, + commits: [{ file: "b.txt", body: "b1\n", message: "b1" }], + }, + { + name: "stack-c", + parent: "stack-b", + number: 3, + commits: [{ file: "c.txt", body: "c\n", message: "c" }], + }, + ], + }); + const sibling = join(scenario.repo, "..", "stack-c-worktree"); + + yield* scenario.git(["worktree", "add", sibling, "stack-c"]); + yield* commitFile(scenario.repo, "b2.txt", "b2\n", "b2"); + yield* scenario.git(["push", "origin", "stack-b"]); + + const items = yield* Effect.gen(function* () { + const stack = yield* Stack; + return yield* stack.sync({ apply: true }); + }).pipe(Effect.provide(scenario.layer)); + + expect(items).toContain(" └─ ✓ stack-c #3 rebased onto stack-b"); + expect(yield* shell(sibling, "git", ["branch", "--show-current"])).toBe("stack-c"); + expect(yield* scenario.git(["merge-base", "stack-c", "stack-b"])).toBe( + yield* scenario.git(["rev-parse", "stack-b"]), + ); + expect(yield* shell(sibling, "git", ["status", "--short"])).toBe(""); + }).pipe(Effect.provide(platform)), + 15_000, + ); + + it.effect( + "sync refuses a dirty checked-out branch before backing it up", + () => + Effect.gen(function* () { + const scenario = yield* realStack({ + current: "stack-b", + branches: [ + { + name: "stack-b", + parent: "dev", + number: 2, + commits: [{ file: "b.txt", body: "b1\n", message: "b1" }], + }, + { + name: "stack-c", + parent: "stack-b", + number: 3, + commits: [{ file: "c.txt", body: "c\n", message: "c" }], + }, + ], + }); + const sibling = join(scenario.repo, "..", "stack-c-worktree"); + + yield* scenario.git(["worktree", "add", sibling, "stack-c"]); + yield* put(join(sibling, "dirty.txt"), "dirty\n"); + yield* commitFile(scenario.repo, "b2.txt", "b2\n", "b2"); + yield* scenario.git(["push", "origin", "stack-b"]); + + const error = yield* Effect.gen(function* () { + const stack = yield* Stack; + return yield* Effect.flip(stack.sync({ apply: true })); + }).pipe(Effect.provide(scenario.layer)); + + expect(error).toBeInstanceOf(StackOperationError); + expect(error.message).toContain("Cannot repair checked-out dirty worktree branches"); + expect(error.message).toContain("stack-c -> "); + expect(error.message).toContain("stack-c-worktree"); + expect(error.message).toContain("?? dirty.txt"); + expect( + yield* scenario.git(["for-each-ref", "--format=%(refname:short)", "refs/heads/backup"]), + ).not.toContain("stack-c"); + }).pipe(Effect.provide(platform)), + 15_000, + ); + + it.effect( + "sync refuses a dirty descendant worktree before backing up earlier repairs", + () => + Effect.gen(function* () { + const scenario = yield* realStack({ + current: "dev", + branches: [ + { + name: "stack-b", + parent: "dev", + number: 2, + commits: [{ file: "b.txt", body: "b1\n", message: "b1" }], + }, + { + name: "stack-c", + parent: "stack-b", + number: 3, + commits: [{ file: "c.txt", body: "c\n", message: "c" }], + }, + ], + }); + const sibling = join(scenario.repo, "..", "stack-c-worktree"); + + yield* scenario.git(["worktree", "add", sibling, "stack-c"]); + yield* scenario.git(["checkout", "dev"]); + yield* commitFile(scenario.repo, "dev2.txt", "dev2\n", "dev2"); + yield* scenario.git(["push", "origin", "dev"]); + yield* put(join(sibling, "dirty.txt"), "dirty\n"); + + const error = yield* Effect.gen(function* () { + const stack = yield* Stack; + return yield* Effect.flip(stack.sync({ apply: true })); + }).pipe(Effect.provide(scenario.layer)); + + expect(error).toBeInstanceOf(StackOperationError); + expect(error.message).toContain("Cannot repair checked-out dirty worktree branches"); + expect(error.message).toContain("stack-c -> "); + expect(error.message).toContain("stack-c-worktree"); + expect( + yield* scenario.git(["for-each-ref", "--format=%(refname:short)", "refs/heads/backup"]), + ).not.toContain("stack-b"); + }).pipe(Effect.provide(platform)), + 15_000, + ); + it.effect("sync rebases a deep stack when PR 2 is refactored", () => Effect.gen(function* () { const scenario = yield* realStack({ @@ -3823,6 +4081,50 @@ describe("Stack", () => { }).pipe(Effect.provide(test.layer)); }); + it.effect( + "land apply refuses a dirty descendant worktree before merging the root", + () => + Effect.gen(function* () { + const scenario = yield* realStack({ + current: "stack-a", + branches: [ + { + name: "stack-a", + parent: "dev", + number: 1, + commits: [{ file: "a.txt", body: "a\n", message: "a" }], + }, + { + name: "stack-b", + parent: "stack-a", + number: 2, + commits: [{ file: "b.txt", body: "b\n", message: "b" }], + }, + ], + }); + const sibling = join(scenario.repo, "..", "stack-b-worktree"); + + yield* scenario.git(["worktree", "add", sibling, "stack-b"]); + yield* put(join(sibling, "dirty.txt"), "dirty\n"); + + const error = yield* Effect.gen(function* () { + const stack = yield* Stack; + return yield* Effect.flip(stack.land("stack-a", { apply: true })); + }).pipe(Effect.provide(scenario.layer)); + + expect(error).toBeInstanceOf(StackOperationError); + expect(error.message).toContain("Cannot repair checked-out dirty worktree branches"); + expect(error.message).toContain("stack-b -> "); + expect(error.message).toContain("stack-b-worktree"); + expect(error.message).toContain("?? dirty.txt"); + expect(scenario.log).not.toContain("merge 1"); + expect( + yield* scenario.git(["for-each-ref", "--format=%(refname:short)", "refs/heads/backup"]), + ).not.toContain("stack-a"); + }).pipe(Effect.provide(platform)), + 15_000, + ); + it.effect( "land repairs descendants in a real git repository", () =>