Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions skills/stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/services/Git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface Interface {
parent: string,
commits: ReadonlyArray<string>,
) => Effect.Effect<void, ExecError>;
readonly release: (branch: string) => Effect.Effect<void, ExecError>;
readonly backup: (branch: string, name: string) => Effect.Effect<void, ExecError>;
readonly drop: (branch: string) => Effect.Effect<void, ExecError>;
readonly restore: (branch: string, name: string) => Effect.Effect<void, ExecError>;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -311,6 +336,7 @@ export const live = Layer.effect(
commits,
novel,
replay,
release,
backup,
drop,
restore,
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions src/services/Stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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}`] : []),
Expand Down Expand Up @@ -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);
}
Expand Down
173 changes: 172 additions & 1 deletion tests/stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const gitAndCodeHost = (service: Partial<Git.Interface & CodeHost.Interface>) =>
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,
Expand Down Expand Up @@ -830,7 +831,7 @@ const makeLand = (
dirty: ReadonlyArray<string> = [],
currentBranch = "stack-a",
progress: Array<Progress.ProgressEvent> | null = null,
codeHost: Partial<CodeHost.Interface> = {},
codeHost: Partial<Git.Interface & CodeHost.Interface> = {},
includeUnrelatedRoot = false,
forkStackC = false,
) => {
Expand Down Expand Up @@ -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}`)),
Expand Down Expand Up @@ -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<string> }> = [];
const proc = Layer.succeed(
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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",
() =>
Expand Down