diff --git a/skills/stack/SKILL.md b/skills/stack/SKILL.md index f9359ac..f8e7770 100644 --- a/skills/stack/SKILL.md +++ b/skills/stack/SKILL.md @@ -193,6 +193,12 @@ Mutating merge workflows stream progress while they run. Expect live progress fo retargeting, backup, merge/auto-merge, waiting, and cleanup before the final summary. +Worktree cleanup has two separate cases. Descendant branches checked out in +clean sibling worktrees can be repaired from their owning worktree; dirty owners +fail before mutation. After a root lands, if the landed branch is checked out in +a clean sibling worktree, Stack detaches that worktree at `HEAD` before deleting +the local branch; a dirty owner fails before the hosted merge starts. + ## Understand Or Undo The Last Mutation ```bash diff --git a/src/services/Git.ts b/src/services/Git.ts index e3ee984..68a545a 100644 --- a/src/services/Git.ts +++ b/src/services/Git.ts @@ -45,6 +45,7 @@ export interface Interface { parent: string, commits: ReadonlyArray, ) => Effect.Effect; + readonly release: (branch: string) => Effect.Effect; readonly backup: (branch: string, name: string) => Effect.Effect; readonly drop: (branch: string) => Effect.Effect; readonly restore: (branch: string, name: string) => Effect.Effect; @@ -142,6 +143,19 @@ export const live = Layer.effect( ].join("\n"), ); + const releaseDirtyError = (branch: string, worktree: Worktree) => + new ExecError( + "git", + ["release", branch], + 1, + [ + `${branch} is checked out at ${worktree.path} with local changes:`, + ...worktree.dirty.map((line) => ` ${line}`), + "", + `Commit, stash, or clean that worktree before releasing ${branch}.`, + ].join("\n"), + ); + const refs = Effect.fn("Git.refs")(function* () { const out = yield* run("git", [ "for-each-ref", @@ -269,6 +283,17 @@ 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 release = Effect.fn("Git.release")(function* (branch: string) { + const owner = + (yield* worktrees()).find( + (worktree) => worktree.branch === branch && worktree.path !== cfg.root, + ) ?? null; + if (!owner) return; + if (owner.dirty.length > 0) { + return yield* Effect.fail(releaseDirtyError(branch, owner)); + } + return yield* runAt(owner.path, "git", ["checkout", "--detach", "HEAD"]).pipe(Effect.asVoid); + }); const drop = Effect.fn("Git.drop")(function* (branch: string) { const owner = (yield* worktrees()).find( @@ -311,6 +336,7 @@ export const live = Layer.effect( commits, novel, replay, + release, backup, drop, restore, @@ -350,6 +376,7 @@ export const test = (opts: { commits: () => Effect.succeed([]), novel: (_parent, _branch, commits) => Effect.succeed(commits), replay: () => Effect.void, + release: () => Effect.void, backup: () => Effect.void, drop: () => Effect.void, restore: () => Effect.void, diff --git a/src/services/Stack.ts b/src/services/Stack.ts index 4b65e8d..47c7cfb 100644 --- a/src/services/Stack.ts +++ b/src/services/Stack.ts @@ -1636,6 +1636,11 @@ ${note}`; const stamp = yield* timestamp(); const name = `backup/landed-${stamp}-${target}`; const hasLocalTarget = refs.some((item) => item.name === target); + const targetOwner = hasLocalTarget + ? ((yield* git.worktrees()).find( + (worktree) => worktree.branch === target && worktree.path !== cfg.root, + ) ?? null) + : null; const next = scopedState.links.find((item) => item.parent === target)?.branch ?? null; const landed = new Set([reference(Number(pr.number)), String(target)]); const preRetargets = (yield* Effect.forEach( @@ -1741,11 +1746,12 @@ ${note}`; { apply: false }, ); if (active) { - yield* ensureRepairableWorktrees( - plannedRepair.actions.flatMap((item) => + yield* ensureRepairableWorktrees([ + ...(targetOwner ? [target] : []), + ...plannedRepair.actions.flatMap((item) => item._tag === "Rebase" ? [String(item.branch)] : [], ), - ); + ]); } const actions = [ ...(current === target ? [`${active ? "" : "would "}switch to ${root}`] : []), @@ -1820,6 +1826,10 @@ ${note}`; } yield* beginPostMergeRepair(); if (hasLocalTarget) { + if (targetOwner) { + yield* step(`release ${target} worktree`); + yield* git.release(target); + } yield* step(`drop local ${target}`); yield* git.drop(target); } diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 93fd795..14a7756 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -90,6 +90,7 @@ const gitAndCodeHost = (service: Partial) => commits: () => Effect.succeed([]), novel: (_parent, _branch, commits) => Effect.succeed(commits), replay: () => Effect.void, + release: () => Effect.void, backup: () => Effect.void, drop: () => Effect.void, restore: () => Effect.void, @@ -830,7 +831,7 @@ const makeLand = ( dirty: ReadonlyArray = [], currentBranch = "stack-a", progress: Array | null = null, - codeHost: Partial = {}, + codeHost: Partial = {}, includeUnrelatedRoot = false, forkStackC = false, ) => { @@ -993,6 +994,7 @@ const makeLand = ( refs.set(branch, branchRef({ name: branch, head: `${branch}-2` })); bases.set(`${branch}:${parent}`, refs.get(parent)?.head ?? ""); }), + release: (branch: string) => Effect.sync(() => void seen.push(`release ${branch}`)), backup: (branch: string, name: string) => Effect.sync(() => void seen.push(`backup ${branch} ${name}`)), drop: (branch: string) => Effect.sync(() => void seen.push(`drop ${branch}`)), @@ -1409,6 +1411,75 @@ describe("Git", () => { 15_000, ); + it.effect( + "release detaches a clean owning 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 stackBTip = yield* shell(repo, "git", ["rev-parse", "stack-b"]); + yield* shell(repo, "git", ["checkout", "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.release("stack-b"); + }).pipe(Effect.provide(Git.live.pipe(Layer.provide(cfgLayer)))); + + expect(yield* shell(sibling, "git", ["branch", "--show-current"])).toBe(""); + expect(yield* shell(sibling, "git", ["rev-parse", "HEAD"])).toBe(stackBTip); + expect(yield* shell(repo, "git", ["rev-parse", "--verify", "stack-b"])).toBe(stackBTip); + }).pipe(Effect.provide(platform)), + 15_000, + ); + + it.effect( + "release no-ops when branch is not checked out elsewhere", + () => + Effect.gen(function* () { + const root = yield* tempDir(); + const repo = join(root, "repo"); + + 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 stackBTip = yield* shell(repo, "git", ["rev-parse", "stack-b"]); + yield* shell(repo, "git", ["checkout", "dev"]); + + const cfgLayer = StackConfig.layer({ root: repo, trunks: ["dev"] }).pipe( + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const git = yield* Git.Service; + yield* git.release("stack-b"); + }).pipe(Effect.provide(Git.live.pipe(Layer.provide(cfgLayer)))); + + expect(yield* shell(repo, "git", ["branch", "--show-current"])).toBe("dev"); + expect(yield* shell(repo, "git", ["rev-parse", "--verify", "stack-b"])).toBe(stackBTip); + }).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( @@ -3716,6 +3787,56 @@ describe("Stack", () => { }).pipe(Effect.provide(test.layer)); }); + it.effect("land apply releases a clean checked-out target before deleting it", () => { + const test = makeLand([], "dev", null, { + worktrees: () => + Effect.succeed([ + { + path: "/tmp/stack-a-worktree", + head: "stack-a-1", + branch: "stack-a", + dirty: [], + }, + ]), + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.land("stack-a", { apply: true }); + + const release = test.seen.indexOf("release stack-a"); + const drop = test.seen.indexOf("drop stack-a"); + expect(release).toBeGreaterThan(-1); + expect(drop).toBeGreaterThan(release); + expect(test.seen.indexOf("merge 4")).toBeLessThan(release); + }).pipe(Effect.provide(test.layer)); + }); + + it.effect("land auto releases a clean checked-out target before deleting it", () => { + const test = makeLand([], "dev", null, { + worktrees: () => + Effect.succeed([ + { + path: "/tmp/stack-a-worktree", + head: "stack-a-1", + branch: "stack-a", + dirty: [], + }, + ]), + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.land("stack-a", { auto: true }); + + const release = test.seen.indexOf("release stack-a"); + const drop = test.seen.indexOf("drop stack-a"); + expect(release).toBeGreaterThan(-1); + expect(drop).toBeGreaterThan(release); + expect(test.seen.indexOf("wait 4 merged")).toBeLessThan(release); + }).pipe(Effect.provide(test.layer)); + }); + it.effect("land auto can merge through a target PR", () => { const test = makeLand(); @@ -4461,6 +4582,56 @@ describe("Stack", () => { }).pipe(Effect.provide(test.layer)); }); + it.effect("land apply refuses a dirty target worktree before merging the root", () => { + const test = makeLand([], "dev", null, { + worktrees: () => + Effect.succeed([ + { + path: "/tmp/stack-a-worktree", + head: "stack-a-1", + branch: "stack-a", + dirty: ["?? dirty.txt"], + }, + ]), + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* Effect.flip(stack.land("stack-a", { apply: true })); + + expect(error).toBeInstanceOf(StackOperationError); + expect(error.message).toContain("Cannot repair checked-out dirty worktree branches"); + expect(error.message).toContain("stack-a -> /tmp/stack-a-worktree"); + expect(error.message).toContain("?? dirty.txt"); + expect(test.seen).toEqual([]); + }).pipe(Effect.provide(test.layer)); + }); + + it.effect("land auto refuses a dirty target worktree before merging the root", () => { + const test = makeLand([], "dev", null, { + worktrees: () => + Effect.succeed([ + { + path: "/tmp/stack-a-worktree", + head: "stack-a-1", + branch: "stack-a", + dirty: ["?? dirty.txt"], + }, + ]), + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* Effect.flip(stack.land("stack-a", { auto: true })); + + expect(error).toBeInstanceOf(StackOperationError); + expect(error.message).toContain("Cannot repair checked-out dirty worktree branches"); + expect(error.message).toContain("stack-a -> /tmp/stack-a-worktree"); + expect(error.message).toContain("?? dirty.txt"); + expect(test.seen).toEqual([]); + }).pipe(Effect.provide(test.layer)); + }); + it.effect( "land apply refuses a dirty descendant worktree before merging the root", () =>