From cab16bf54b762a9b37ceecfcb198ad94da9d7fef Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Tue, 30 Jun 2026 15:53:44 -0400 Subject: [PATCH 1/7] fix(stack): release checked-out landed branches (#30) When `stack merge --apply` or `stack merge --auto` lands a branch that is checked out in another worktree, Git refuses to delete it, leaving the stack half-landed locally. Add `Git.release(branch)` to detach a clean sibling worktree at HEAD before deleting the landed branch, and fail before hosted mutation when that worktree is dirty. --- src/services/Git.ts | 27 +++++++ src/services/Stack.ts | 16 +++- tests/stack.test.ts | 173 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 212 insertions(+), 4 deletions(-) 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", () => From 6180c053d423cf17c63d33d1613d32e9701f48e4 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Tue, 30 Jun 2026 15:56:39 -0400 Subject: [PATCH 2/7] fix(stack): recover stranded squash repair anchors (#31) If stack merge persists the landed state but aborts before descendant repair, a later stack sync --apply can lose the correct replay boundary. In squash-merge repos, the child then replays both the already-squashed parent commits and its own, producing avoidable conflicts. Use the persisted branch anchor as a fallback replay boundary when the child is already parented to trunk and the stored anchor matches a backup/landed-* head, so stranded descendants replay only their own commits. --- skills/stack/SKILL.md | 2 ++ src/services/Stack.ts | 11 ++++++++++- tests/stack.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/skills/stack/SKILL.md b/skills/stack/SKILL.md index 60c1e69..8e49b9d 100644 --- a/skills/stack/SKILL.md +++ b/skills/stack/SKILL.md @@ -109,6 +109,8 @@ GitHub uses `#123`; GitLab uses `!123 - Title`. code host and repairs after the root lands). - Never mutate trunk branches (`dev`, `main`, `master`, or any configured trunk). - Before rebasing, the tool creates a local backup branch. +- Clean sibling worktrees can own branches being repaired or cleaned up; dirty + sibling owners fail before mutation. - If a replay fails, the tool aborts the cherry-pick, restores the original branch, keeps backups and the undo journal, and tells you which branch to repair before running `stack sync --apply` again. diff --git a/src/services/Stack.ts b/src/services/Stack.ts index 47c7cfb..0bfb734 100644 --- a/src/services/Stack.ts +++ b/src/services/Stack.ts @@ -786,6 +786,11 @@ ${note}`; name.startsWith("backup/landed-") || name.startsWith("backup/stack-sync-"), ) .sort(); + const landedBackupHeads = new Set( + refs + .filter((ref) => ref.name.startsWith("backup/landed-")) + .map((ref) => String(ref.head)), + ); for (const name of backups) { for (const link of state.links) { if (name.endsWith(`-${link.branch}`)) prior.set(String(link.branch), name); @@ -930,7 +935,11 @@ ${note}`; headRepository, pr ? Number(pr.number) : link.pr ? Number(link.pr) : null, ); - const anchor = replayAnchors.get(String(link.branch)); + const landedAnchor = + trunk(parent) && landedBackupHeads.has(String(link.anchor)) + ? String(link.anchor) + : null; + const anchor = replayAnchors.get(String(link.branch)) ?? landedAnchor; const baseRef = anchor ? Option.some(anchor) : yield* git.base(link.branch, from); const commitsToReplay = Option.isSome(baseRef) ? yield* Effect.gen(function* () { diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 14a7756..3ab258e 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -3081,6 +3081,46 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); + it.effect("sync uses persisted child anchor after merge state was stranded", () => { + const seen: Array = []; + const pulls = [pr(2, "child", "dev")]; + const layer = stackTestLayer({ + current: "child", + refs: [ + ref("dev", "dev-squash"), + ref("child", "child-head"), + ref("backup/landed-1700000000000-parent", "parent-tip"), + ], + pulls, + bases: bases(["child", "dev", "dev-old"]), + state: stackState([ + stackLink({ branch: "child", parent: "dev", anchor: "parent-tip", pr: 2 }), + ]), + service: { + commits: (from: string, branch: string) => + Effect.succeed( + branch === "child" && from === "parent-tip" + ? ["child-only"] + : branch === "child" && from === "dev-old" + ? ["parent-1", "parent-2", "child-only"] + : [], + ), + novel: (_parent: string, _branch: string, commits: ReadonlyArray) => + Effect.succeed(commits), + replay: (branch: string, parent: string, commits: ReadonlyArray) => + Effect.sync(() => seen.push(`rebase ${branch} ${parent} ${commits.join(",")}`)), + }, + }); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true }); + + expect(seen).toContain("rebase child origin/dev child-only"); + expect(seen).not.toContain("rebase child origin/dev parent-1,parent-2,child-only"); + }).pipe(Effect.provide(layer)); + }); + it.effect("undo restores the last applied mutation", () => { const test = makeSync(); From 3b9322b69caf78368d9f38d1bc24ea70817d8290 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 30 Jun 2026 15:57:25 -0400 Subject: [PATCH 3/7] chore: backfill changesets for #30 and #31 (#33) --- .changeset/recover-stranded-anchors.md | 5 +++++ .changeset/release-landed-worktrees.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/recover-stranded-anchors.md create mode 100644 .changeset/release-landed-worktrees.md diff --git a/.changeset/recover-stranded-anchors.md b/.changeset/recover-stranded-anchors.md new file mode 100644 index 0000000..ef198f6 --- /dev/null +++ b/.changeset/recover-stranded-anchors.md @@ -0,0 +1,5 @@ +--- +"@kitlangton/stack": patch +--- + +Recover stranded squash repair anchors: if `stack merge` persists state but aborts before descendant repair, a later `stack sync --apply` now uses the persisted anchor when it matches a `backup/landed-*` ref, so stranded descendants replay only their own commits instead of re-replaying the already-squashed parent. diff --git a/.changeset/release-landed-worktrees.md b/.changeset/release-landed-worktrees.md new file mode 100644 index 0000000..678b2d5 --- /dev/null +++ b/.changeset/release-landed-worktrees.md @@ -0,0 +1,5 @@ +--- +"@kitlangton/stack": patch +--- + +Detach clean sibling worktrees that own a landed branch before deleting it during `stack merge --apply` and `stack merge --auto` cleanup. Fails before hosted mutation when the target worktree is dirty. From 749260c2a188468f631277d7db2a8b14b237c4dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:58:05 -0400 Subject: [PATCH 4/7] chore: version packages (#34) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/recover-stranded-anchors.md | 5 ----- .changeset/release-landed-worktrees.md | 5 ----- CHANGELOG.md | 7 +++++++ package.json | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) delete mode 100644 .changeset/recover-stranded-anchors.md delete mode 100644 .changeset/release-landed-worktrees.md diff --git a/.changeset/recover-stranded-anchors.md b/.changeset/recover-stranded-anchors.md deleted file mode 100644 index ef198f6..0000000 --- a/.changeset/recover-stranded-anchors.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kitlangton/stack": patch ---- - -Recover stranded squash repair anchors: if `stack merge` persists state but aborts before descendant repair, a later `stack sync --apply` now uses the persisted anchor when it matches a `backup/landed-*` ref, so stranded descendants replay only their own commits instead of re-replaying the already-squashed parent. diff --git a/.changeset/release-landed-worktrees.md b/.changeset/release-landed-worktrees.md deleted file mode 100644 index 678b2d5..0000000 --- a/.changeset/release-landed-worktrees.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kitlangton/stack": patch ---- - -Detach clean sibling worktrees that own a landed branch before deleting it during `stack merge --apply` and `stack merge --auto` cleanup. Fails before hosted mutation when the target worktree is dirty. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c85a1b..dc2484a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # @kitlangton/stack +## 0.4.1 + +### Patch Changes + +- 3b9322b: Recover stranded squash repair anchors: if `stack merge` persists state but aborts before descendant repair, a later `stack sync --apply` now uses the persisted anchor when it matches a `backup/landed-*` ref, so stranded descendants replay only their own commits instead of re-replaying the already-squashed parent. +- 3b9322b: Detach clean sibling worktrees that own a landed branch before deleting it during `stack merge --apply` and `stack merge --auto` cleanup. Fails before hosted mutation when the target worktree is dirty. + ## 0.4.0 ### Minor Changes diff --git a/package.json b/package.json index 959522b..734bf94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kitlangton/stack", - "version": "0.4.0", + "version": "0.4.1", "description": "Squash-safe stacked PR/MR repair CLI for GitHub and GitLab", "keywords": [ "cli", From ef789c840be8c4c3d4ef397788b15210f491d9d8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 30 Jun 2026 22:02:14 -0400 Subject: [PATCH 5/7] feat(stack): atomic state writes and cherry-pick conflict path surfacing (#35) Write state.json and undo.json atomically via tmp+rename so a crash mid-write cannot corrupt stack metadata. When a cherry-pick fails during repair, surface the conflicting file paths before aborting so the user knows which files need attention. Corrupt state files now include a recovery hint in the error message. Extracted from the worktree-stack-hardening branch. --- .changeset/durability-conflict-surfacing.md | 5 ++++ src/domain/model.ts | 32 ++++++++++++++++++++- src/repairExecution.ts | 4 +-- src/services/Git.ts | 22 ++++++++++++-- src/services/Stack.ts | 9 +++++- src/services/Store.ts | 18 ++++++++++-- tests/stack.test.ts | 5 +++- 7 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 .changeset/durability-conflict-surfacing.md diff --git a/.changeset/durability-conflict-surfacing.md b/.changeset/durability-conflict-surfacing.md new file mode 100644 index 0000000..54b0989 --- /dev/null +++ b/.changeset/durability-conflict-surfacing.md @@ -0,0 +1,5 @@ +--- +"@kitlangton/stack": patch +--- + +Write `state.json` and `undo.json` atomically via tmp+rename so a crash mid-write cannot corrupt stack metadata. When a cherry-pick fails during repair, surface the conflicting file paths before aborting so the user knows which files need attention. Corrupt state files now include a recovery hint in the error message. diff --git a/src/domain/model.ts b/src/domain/model.ts index c028ee2..0b490a9 100644 --- a/src/domain/model.ts +++ b/src/domain/model.ts @@ -207,6 +207,35 @@ export class StackOperationError extends Schema.TaggedErrorClass()( + "ReplayConflictError", + { + branch: Schema.String, + parent: Schema.String, + paths: Schema.Array(Schema.String), + stderr: Schema.String, + message: Schema.String, + }, +) { + constructor( + readonly branch: string, + readonly parent: string, + readonly paths: ReadonlyArray, + readonly stderr: string, + ) { + super({ + branch, + parent, + paths: Array.from(paths), + stderr, + message: + paths.length > 0 + ? `cherry-pick of ${branch} onto ${parent} failed in: ${paths.join(", ")}` + : `cherry-pick of ${branch} onto ${parent} failed`, + }); + } +} + export class CodeHostDecodeError extends Schema.TaggedErrorClass()( "CodeHostDecodeError", { @@ -276,7 +305,8 @@ export type StackError = | BranchError | MergeBaseError | DirtyWorktreeError - | StackOperationError; + | StackOperationError + | ReplayConflictError; export const stackState = (links: ReadonlyArray) => new StackState({ version, links: Array.from(links) }); diff --git a/src/repairExecution.ts b/src/repairExecution.ts index bf9845c..8afbf02 100644 --- a/src/repairExecution.ts +++ b/src/repairExecution.ts @@ -1,5 +1,5 @@ import * as Effect from "effect/Effect"; -import type { ExecError, StackError } from "./domain/model.ts"; +import type { ExecError, ReplayConflictError, StackError } from "./domain/model.ts"; import type { RebaseBranchPlan, RetargetPullPlan } from "./repairPlan.ts"; import type { Interface as Git } from "./services/Git.ts"; import * as StackResult from "./stackResult.ts"; @@ -11,7 +11,7 @@ interface Dependencies { export interface ApplyRebaseBranchDependencies extends Dependencies { readonly git: Pick; - readonly onReplayFailure: (error: ExecError) => StackError; + readonly onReplayFailure: (error: ExecError | ReplayConflictError) => StackError; } export const applyRebaseBranch = Effect.fn("RepairExecution.applyRebaseBranch")(function* ( diff --git a/src/services/Git.ts b/src/services/Git.ts index 68a545a..de0f939 100644 --- a/src/services/Git.ts +++ b/src/services/Git.ts @@ -3,7 +3,7 @@ import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { BranchRef, branchRef, ExecError } from "../domain/model.ts"; +import { BranchRef, branchRef, ExecError, ReplayConflictError } from "../domain/model.ts"; import * as Proc from "../platform/proc.ts"; import { StackConfig } from "./Config.ts"; @@ -44,7 +44,8 @@ export interface Interface { branch: string, parent: string, commits: ReadonlyArray, - ) => Effect.Effect; + ) => Effect.Effect; + readonly unmergedPaths: () => Effect.Effect, ExecError>; readonly release: (branch: string) => Effect.Effect; readonly backup: (branch: string, name: string) => Effect.Effect; readonly drop: (branch: string) => Effect.Effect; @@ -235,6 +236,11 @@ export const live = Layer.effect( }), ); }); + const unmergedPaths = Effect.fn("Git.unmergedPaths")(() => + run("git", ["diff", "--name-only", "--diff-filter=U"], [0, 1]).pipe( + Effect.map((out) => out.split("\n").filter(Boolean)), + ), + ); const replay = Effect.fn("Git.replay")(function* ( branch: string, parent: string, @@ -266,6 +272,16 @@ export const live = Layer.effect( if (commits.length > 0) { yield* runAt(root, "git", ["cherry-pick", "--empty=drop", ...commits]).pipe( Effect.asVoid, + Effect.catchTag("ExecError", (err) => + Effect.gen(function* () { + const paths = yield* unmergedPaths().pipe( + Effect.catch(() => Effect.succeed([] as ReadonlyArray)), + ); + return yield* Effect.fail( + new ReplayConflictError(branch, parent, paths, err.stderr), + ); + }), + ), ); } if (owner) { @@ -336,6 +352,7 @@ export const live = Layer.effect( commits, novel, replay, + unmergedPaths, release, backup, drop, @@ -376,6 +393,7 @@ export const test = (opts: { commits: () => Effect.succeed([]), novel: (_parent, _branch, commits) => Effect.succeed(commits), replay: () => Effect.void, + unmergedPaths: () => Effect.succeed([] as ReadonlyArray), release: () => Effect.void, backup: () => Effect.void, drop: () => Effect.void, diff --git a/src/services/Stack.ts b/src/services/Stack.ts index 0bfb734..e5e2dc0 100644 --- a/src/services/Stack.ts +++ b/src/services/Stack.ts @@ -158,6 +158,9 @@ ${note}`; "", "Failed:", ` ${rebase.branch} could not be replayed onto ${rebase.parent}`, + ...(err._tag === "ReplayConflictError" && err.paths.length > 0 + ? ["", "Conflicting paths:", ...err.paths.map((p) => ` ${p}`)] + : []), "", "Cleaned up:", ` backup created: ${rebase.backup}`, @@ -172,7 +175,11 @@ ${note}`; "", "Git error:", err instanceof Error ? ` ${err.message}` : ` ${String(err)}`, - err._tag === "ExecError" && err.stderr ? ` ${err.stderr}` : null, + err._tag === "ExecError" && err.stderr + ? ` ${err.stderr}` + : err._tag === "ReplayConflictError" && err.stderr + ? ` ${err.stderr}` + : null, ] .filter((line): line is string => line !== null) .join("\n"), diff --git a/src/services/Store.ts b/src/services/Store.ts index 343bb9a..e2590c8 100644 --- a/src/services/Store.ts +++ b/src/services/Store.ts @@ -39,7 +39,12 @@ export class Store extends Context.Service()("@stack/Store" return yield* Effect.try({ try: () => parse(raw), - catch: (err) => new StateError(file, "decode", String(err)), + catch: (err) => + new StateError( + file, + "decode", + `${err instanceof Error ? err.message : String(err)}\n\nThe file may be corrupt or from a future version. To recover, delete ${file} and rerun.`, + ), }); }); @@ -49,9 +54,16 @@ export class Store extends Context.Service()("@stack/Store" .makeDirectory(path.dirname(file), { recursive: true }) .pipe(Effect.mapError((err) => new StateError(file, "mkdir", String(err)))); + const tmp = `${file}.tmp`; + const body = `${JSON.stringify(encode(value), null, 2)}\n`; + + yield* fs + .writeFileString(tmp, body) + .pipe(Effect.mapError((err) => new StateError(tmp, "write", String(err)))); + yield* fs - .writeFileString(file, `${JSON.stringify(encode(value), null, 2)}\n`) - .pipe(Effect.mapError((err) => new StateError(file, "write", String(err)))); + .rename(tmp, file) + .pipe(Effect.mapError((err) => new StateError(file, "rename", String(err)))); }); const read = Effect.fn("Store.read")(() => diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 3ab258e..2fc0e4f 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -13,6 +13,7 @@ import { PullLabel, pullMeta, pullRef, + ReplayConflictError, stackLink, StackOperationError, stackState, @@ -90,6 +91,7 @@ const gitAndCodeHost = (service: Partial) => commits: () => Effect.succeed([]), novel: (_parent, _branch, commits) => Effect.succeed(commits), replay: () => Effect.void, + unmergedPaths: () => Effect.succeed([] as ReadonlyArray), release: () => Effect.void, backup: () => Effect.void, drop: () => Effect.void, @@ -1316,7 +1318,7 @@ describe("Git", () => { const error = yield* Effect.flip(git.replay("stack-b", "dev", ["b1"])); - expect(error).toBeInstanceOf(ExecError); + expect(error).toBeInstanceOf(ReplayConflictError); const temp = calls[2]?.[3]; expect(temp).toBe("stack/replay-1700000000000-stack-b"); expect(calls).toEqual([ @@ -1324,6 +1326,7 @@ describe("Git", () => { ["git", "branch", "--show-current"], ["git", "checkout", "-B", temp, "dev"], ["git", "cherry-pick", "--empty=drop", "b1"], + ["git", "diff", "--name-only", "--diff-filter=U"], ["git", "cherry-pick", "--abort"], ["git", "checkout", "stack-c"], ["git", "branch", "-D", temp], From bb2727b3f27ed26a7f4afb29ebe57cda8825c33e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:03:23 -0400 Subject: [PATCH 6/7] chore: version packages (#36) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/durability-conflict-surfacing.md | 5 ----- CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/durability-conflict-surfacing.md diff --git a/.changeset/durability-conflict-surfacing.md b/.changeset/durability-conflict-surfacing.md deleted file mode 100644 index 54b0989..0000000 --- a/.changeset/durability-conflict-surfacing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kitlangton/stack": patch ---- - -Write `state.json` and `undo.json` atomically via tmp+rename so a crash mid-write cannot corrupt stack metadata. When a cherry-pick fails during repair, surface the conflicting file paths before aborting so the user knows which files need attention. Corrupt state files now include a recovery hint in the error message. diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2484a..1faa12a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @kitlangton/stack +## 0.4.2 + +### Patch Changes + +- ef789c8: Write `state.json` and `undo.json` atomically via tmp+rename so a crash mid-write cannot corrupt stack metadata. When a cherry-pick fails during repair, surface the conflicting file paths before aborting so the user knows which files need attention. Corrupt state files now include a recovery hint in the error message. + ## 0.4.1 ### Patch Changes diff --git a/package.json b/package.json index 734bf94..3315593 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kitlangton/stack", - "version": "0.4.1", + "version": "0.4.2", "description": "Squash-safe stacked PR/MR repair CLI for GitHub and GitLab", "keywords": [ "cli", From 45297411c12720970a7c338d304538ca8ea1f468 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 3 Jul 2026 11:23:23 +0530 Subject: [PATCH 7/7] feat(stack): render stack blocks as nested topology --- README.md | 6 +- skills/stack/SKILL.md | 14 +++-- src/services/Stack.ts | 2 +- src/stackBlock.ts | 42 ++++++++++--- src/stackGraph.ts | 23 +++++++ tests/stack.test.ts | 142 +++++++++++++++++++++++++++--------------- 6 files changed, 161 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index bc49b8c..68d4967 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,10 @@ then repair descendants automatically after the root lands. - Refreshes stack blocks in descriptions. - Saves `.git/stack/undo.json` before mutations. -GitHub stack blocks use compact `#101` references. GitLab blocks use `!101` -references plus titles because bare GitLab MR links only show titles on hover. +Stack blocks render open changes as a nested list where indentation shows the +PR/MR target-branch topology. GitHub stack blocks use compact `#101` +references. GitLab blocks use `!101` references plus titles because bare GitLab +MR links only show titles on hover. If a repair fails, run: diff --git a/skills/stack/SKILL.md b/skills/stack/SKILL.md index 8e49b9d..36d76b6 100644 --- a/skills/stack/SKILL.md +++ b/skills/stack/SKILL.md @@ -87,19 +87,21 @@ work. Repeat after any parent branch changes or a squash merge lands. `stack sync --apply` and `stack merge --apply/--auto` refresh a deterministic block in each open change description: -```md +```text ### [Stack](https://github.com/kitlangton/stack) -1. #101 -2. #102 -3. **#103** 👈 current +- #101 `stack-a` + - #102 `stack-b` + - **#103** 👈 current `stack-c` ``` -Earlier entries are landed history. The current change is bold with `👈 current`. -GitHub uses `#123`; GitLab uses `!123 - Title`. +Earlier top-level entries are landed history. Open changes are rendered as a +nested list where indentation shows lineage and siblings share the same parent. +The current change is bold with `👈 current`. GitHub uses `#123`; GitLab uses +`!123 - Title`. ## Safety Rules diff --git a/src/services/Stack.ts b/src/services/Stack.ts index e5e2dc0..f0b4197 100644 --- a/src/services/Stack.ts +++ b/src/services/Stack.ts @@ -1419,7 +1419,7 @@ ${note}`; StackBlock.render({ pulls: selectedPulls, metas, - chain: graph.displayChainFor(String(pull.head)), + tree: graph.displayTreeFor(String(pull.head)), completed, branch: String(pull.head), previous: meta.body, diff --git a/src/stackBlock.ts b/src/stackBlock.ts index 21a14a6..86c08cf 100644 --- a/src/stackBlock.ts +++ b/src/stackBlock.ts @@ -1,4 +1,5 @@ import { PullMeta, PullRef } from "./domain/model.ts"; +import type { DisplayTree } from "./stackGraph.ts"; const start = ""; const end = ""; @@ -41,7 +42,7 @@ const completedLines = ( return prior .split("\n") .map((line) => line.trim()) - .filter((line) => line.startsWith("- [") || /^\d+\.\s+/.test(line)) + .filter((line) => line.startsWith("- ") || /^\d+\.\s+/.test(line)) .flatMap((line) => { const checked = line.startsWith("- [x]"); const numbered = /^\d+\.\s+/.test(line); @@ -57,9 +58,11 @@ const completedLines = ( } const cleaned = line .replace(/^- \[[ x]\]\s+/, "") + .replace(/^-\s+/, "") .replace(/^\d+\.\s+/, "") .replaceAll("**", "") .replace(/([#!]\d+)\s+`[^`]+`/g, "$1") + .replace(/\s+`[^`]+`/g, "") .replace(/\s*(?:←|👈) current$/, ""); const number = Number(pr?.slice(1)); const title = Number.isInteger(number) ? completedTitles.get(number) : undefined; @@ -67,6 +70,11 @@ const completedLines = ( }); }; +const walkTree = (tree: DisplayTree): ReadonlyArray => [ + tree.branch, + ...tree.children.flatMap((child) => walkTree(child)), +]; + export const references = (body: string) => { const prior = body.match(new RegExp(`${start}([\\s\\S]*?)${end}`))?.[1]; if (!prior) return []; @@ -78,7 +86,7 @@ export const references = (body: string) => { export const render = (opts: { readonly pulls: ReadonlyArray; readonly metas: ReadonlyMap; - readonly chain: ReadonlyArray; + readonly tree: DisplayTree | null; readonly completed?: ReadonlySet; readonly branch: string; readonly previous: string; @@ -91,17 +99,25 @@ export const render = (opts: { const showTitles = opts.showTitles ?? false; const heading = (opts.blockLink ?? true) ? linkedHeading : plainHeading; const prs = new Map(opts.pulls.map((pull) => [String(pull.head), pull])); - const chain = opts.chain; + const treeBranches = opts.tree ? walkTree(opts.tree) : []; const liveKeys = new Set( - chain.flatMap((branch) => { + [...opts.pulls.map((pull) => String(pull.head)), ...treeBranches].flatMap((branch) => { const pr = prs.get(branch) ?? opts.metas.get(branch) ?? null; return pr ? [branch, `#${pr.number}`, `!${pr.number}`] : [branch]; }), ); const line = (name: string) => { const head = format(name, prs, opts.metas, reference, showTitles); - if (name === opts.branch) return `**${head}** 👈 current`; - return head; + const label = name === opts.branch ? `**${head}** 👈 current` : head; + if (head === `\`${name}\``) return label; + return `${label} \`${name}\``; + }; + const treeLines = (tree: DisplayTree, depth = 0): ReadonlyArray => { + const prefix = `${" ".repeat(depth)}- `; + return [ + prefix + line(tree.branch), + ...tree.children.flatMap((child) => treeLines(child, depth + 1)), + ]; }; const items = [ ...completedLines( @@ -110,12 +126,18 @@ export const render = (opts: { opts.completed ?? new Set(), showTitles ? (opts.completedTitles ?? new Map()) : new Map(), ), - ...chain.map(line), + ...(opts.tree ? treeLines(opts.tree) : []), ]; - return [start, heading, "", ...items.map((item, index) => `${index + 1}. ${item}`), end].join( - "\n", - ); + return [ + start, + heading, + "", + ...items.map((entry) => + entry.startsWith("- ") || entry.startsWith(" ") ? entry : `- ${entry}`, + ), + end, + ].join("\n"); }; export const splice = (body: string, next: string) => { diff --git a/src/stackGraph.ts b/src/stackGraph.ts index 0dcd021..82edbaa 100644 --- a/src/stackGraph.ts +++ b/src/stackGraph.ts @@ -26,11 +26,17 @@ export interface StatusTree { readonly children: ReadonlyMap>; } +export interface DisplayTree { + readonly branch: string; + readonly children: ReadonlyArray; +} + export interface StackGraph { readonly report: StatusReport; readonly tree: StatusTree; readonly pathTo: (branch: string) => ReadonlyArray; readonly displayChainFor: (branch: string) => ReadonlyArray; + readonly displayTreeFor: (branch: string) => DisplayTree; readonly rank: (branch: string) => number; readonly rootOf: (branch: string) => string; readonly wouldCreateCycle: (branch: string, parent: string) => boolean; @@ -177,6 +183,7 @@ export const make = (input: StackGraphInput): StackGraph => { }; const explicitChildren = new Map>(); + const liveBranches = new Set(input.pulls.map((pull) => String(pull.head))); for (const link of input.state.links) { const parent = String(link.parent); const list = explicitChildren.get(parent) ?? []; @@ -203,6 +210,21 @@ export const make = (input: StackGraphInput): StackGraph => { return chain; }; + const displayTreeFor = (branch: string): DisplayTree => { + const root = rootOf(branch); + const build = (name: string, seen = new Set()): DisplayTree => { + if (seen.has(name)) return { branch: name, children: [] }; + const nextSeen = new Set(seen); + nextSeen.add(name); + const children = (explicitChildren.get(name) ?? []) + .filter((child) => liveBranches.has(child)) + .map((child) => build(child, nextSeen)); + return { branch: name, children }; + }; + + return build(root); + }; + const rank = (branch: string, seen = new Set()): number => { if (seen.has(branch)) return 0; const link = links.get(branch); @@ -229,6 +251,7 @@ export const make = (input: StackGraphInput): StackGraph => { tree, pathTo, displayChainFor, + displayTreeFor, rank, rootOf, wouldCreateCycle: (branch: string, parent: string) => diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 2fc0e4f..a9e1cfa 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -1288,6 +1288,13 @@ describe("StackGraph", () => { expect(graph.pathTo("stack-c")).toEqual(["stack-a", "stack-c"]); expect(graph.displayChainFor("stack-b")).toEqual(["stack-a", "stack-b"]); expect(graph.displayChainFor("stack-c")).toEqual(["stack-a", "stack-c"]); + expect(graph.displayTreeFor("stack-b")).toEqual({ + branch: "stack-a", + children: [ + { branch: "stack-b", children: [] }, + { branch: "stack-c", children: [] }, + ], + }); expect(graph.wouldCreateCycle("stack-a", "stack-b")).toBe(true); }); }); @@ -3187,7 +3194,7 @@ describe("Stack", () => { }).pipe(Effect.provide(test.layer)); }); - it.effect("links render the stack as chronological GitHub checkboxes", () => { + it.effect("links render the stack as nested GitHub bullets", () => { const test = makeSync(); return Effect.gen(function* () { @@ -3199,9 +3206,8 @@ describe("Stack", () => { expect(body).not.toContain("Earlier in Stack"); expect(body).not.toContain("Current / Remaining"); expect(body).not.toContain("\nMerged\n"); - expect(body).toContain("1. #4"); - expect(body).toContain("2. #5"); - expect(body).toContain("3. **#3** 👈 current"); + expect(body).toContain("- #5 `stack-b`"); + expect(body).toContain(" - **#3** 👈 current `stack-c`"); }).pipe(Effect.provide(test.layer)); }); @@ -3245,9 +3251,9 @@ describe("Stack", () => { yield* stack.links(true); const body = bodies.get(1) ?? ""; - expect(body).toContain("1. **#1** 👈 current"); - expect(body).toContain("2. #2"); - expect(body).toContain("3. #3"); + expect(body).toContain("- **#1** 👈 current `stack-a`"); + expect(body).toContain(" - #2 `stack-b`"); + expect(body).toContain(" - #3 `stack-c`"); }).pipe(Effect.provide(layer)); }); @@ -3264,14 +3270,13 @@ describe("Stack", () => { yield* stack.links(true); const body = test.bodies.get(3) ?? ""; - expect(body).toContain("1. !4 - stack-a"); - expect(body).toContain("2. !5 - fix+refactor(vcs): old title"); - expect(body).toContain("3. **!3 - stack-c** 👈 current"); + expect(body).toContain("- !5 - fix+refactor(vcs): old title `stack-b`"); + expect(body).toContain(" - **!3 - stack-c** 👈 current `stack-c`"); expect(body).not.toContain("#3"); }).pipe(Effect.provide(test.layer)); }); - it.effect("links render the current path through a forked stack", () => { + it.effect("links render sibling PRs in a forked stack", () => { const bodies = new Map(); const pulls = [ pullRef({ number: 1, head: "stack-a", base: "dev", url: "u1", draft: false }), @@ -3348,9 +3353,9 @@ describe("Stack", () => { const stack = yield* Stack; yield* stack.links(true); const body = bodies.get(2) ?? ""; - expect(body).toContain("1. #1"); - expect(body).toContain("2. **#2** 👈 current"); - expect(body).not.toContain("stack-c"); + expect(body).toContain("- #1 `stack-a`"); + expect(body).toContain(" - **#2** 👈 current `stack-b`"); + expect(body).toContain(" - #3 `stack-c`"); }).pipe(Effect.provide(layer)); }); @@ -3389,9 +3394,9 @@ describe("Stack", () => { yield* stack.links(true); const body = bodies.get(3) ?? ""; - expect(body).toContain("1. #1"); - expect(body).toContain("2. #2"); - expect(body).toContain("3. **#3** 👈 current"); + expect(body).toContain("- #1"); + expect(body).toContain("- #2 `stack-b`"); + expect(body).toContain(" - **#3** 👈 current `stack-c`"); }).pipe(Effect.provide(layer)); }); @@ -3425,10 +3430,10 @@ describe("Stack", () => { yield* stack.links(true); const body = bodies.get(4) ?? ""; - expect(body).toContain("1. #1"); + expect(body).toContain("- #1"); expect(body).not.toContain("#2"); expect(body).not.toContain("#3"); - expect(body).toContain("2. **#4** 👈 current"); + expect(body).toContain("- **#4** 👈 current `stack-b`"); }).pipe(Effect.provide(layer)); }); @@ -3462,10 +3467,10 @@ describe("Stack", () => { yield* stack.links(true); const body = bodies.get(4) ?? ""; - expect(body).toContain("1. #1"); - expect(body).toContain("2. #2"); - expect(body).toContain("3. #3"); - expect(body).toContain("4. **#4** 👈 current"); + expect(body).toContain("- #1"); + expect(body).toContain("- #2"); + expect(body).toContain("- #3"); + expect(body).toContain("- **#4** 👈 current `stack-b`"); }).pipe(Effect.provide(layer)); }); @@ -5201,18 +5206,27 @@ describe("StackBlock", () => { draft: false, }), ]; + const tree = { + branch: "feat/a", + children: [ + { + branch: "feat/b", + children: [{ branch: "feat/c", children: [] }], + }, + ], + }; - it("renders GitHub PR references with the # prefix by default", () => { + it("renders GitHub PR references as a nested topology", () => { const block = StackBlock.render({ pulls, metas: new Map(), - chain: ["feat/a", "feat/b", "feat/c"], + tree, branch: "feat/b", previous: "", }); - expect(block).toContain("1. #1"); - expect(block).toContain("2. **#2** 👈 current"); - expect(block).toContain("3. #3"); + expect(block).toContain("- #1 `feat/a`"); + expect(block).toContain(" - **#2** 👈 current `feat/b`"); + expect(block).toContain(" - #3 `feat/c`"); expect(block).not.toContain("Feature A"); }); @@ -5220,7 +5234,7 @@ describe("StackBlock", () => { const block = StackBlock.render({ pulls, metas: new Map(), - chain: ["feat/a", "feat/b", "feat/c"], + tree, branch: "feat/b", previous: "", }); @@ -5231,7 +5245,7 @@ describe("StackBlock", () => { const block = StackBlock.render({ pulls, metas: new Map(), - chain: ["feat/a", "feat/b", "feat/c"], + tree, branch: "feat/b", previous: "", blockLink: false, @@ -5239,21 +5253,21 @@ describe("StackBlock", () => { expect(block).toContain("### Stack"); expect(block).not.toContain("[Stack]"); expect(block).not.toContain("https://github.com/kitlangton/stack"); - expect(block).toContain("2. **#2** 👈 current"); + expect(block).toContain(" - **#2** 👈 current `feat/b`"); }); it("renders GitLab MR references using the code host reference formatter", () => { const block = StackBlock.render({ pulls, metas: new Map(), - chain: ["feat/a", "feat/b", "feat/c"], + tree, branch: "feat/c", previous: "", reference: (number) => `!${number}`, }); - expect(block).toContain("1. !1"); - expect(block).toContain("2. !2"); - expect(block).toContain("3. **!3** 👈 current"); + expect(block).toContain("- !1 `feat/a`"); + expect(block).toContain(" - !2 `feat/b`"); + expect(block).toContain(" - **!3** 👈 current `feat/c`"); expect(block).not.toContain("#1"); expect(block).not.toContain("Feature A"); }); @@ -5262,15 +5276,15 @@ describe("StackBlock", () => { const block = StackBlock.render({ pulls, metas: new Map(), - chain: ["feat/a", "feat/b", "feat/c"], + tree, branch: "feat/c", previous: "", reference: (number) => `!${number}`, showTitles: true, }); - expect(block).toContain("1. !1 - Feature A"); - expect(block).toContain("2. !2 - Feature B"); - expect(block).toContain("3. **!3 - Feature C** 👈 current"); + expect(block).toContain("- !1 - Feature A `feat/a`"); + expect(block).toContain(" - !2 - Feature B `feat/b`"); + expect(block).toContain(" - **!3 - Feature C** 👈 current `feat/c`"); }); it("can enrich completed GitLab history with MR titles", () => { @@ -5286,7 +5300,7 @@ describe("StackBlock", () => { const block = StackBlock.render({ pulls: [], metas: new Map(), - chain: [], + tree: null, branch: "feat/d", previous, reference: (number) => `!${number}`, @@ -5297,9 +5311,9 @@ describe("StackBlock", () => { [3, "Feature C"], ]), }); - expect(block).toContain("1. !1 - Feature A"); - expect(block).toContain("2. !2 - Already titled"); - expect(block).toContain("3. !3 - Feature C"); + expect(block).toContain("- !1 - Feature A"); + expect(block).toContain("- !2 - Already titled"); + expect(block).toContain("- !3 - Feature C"); }); it("parses both # and ! prefixed entries from a previous block", () => { @@ -5315,12 +5329,12 @@ describe("StackBlock", () => { const block = StackBlock.render({ pulls: [pulls[2]!], metas: new Map(), - chain: ["feat/c"], + tree: { branch: "feat/c", children: [] }, branch: "feat/c", previous, reference: (number) => `!${number}`, }); - expect(block).toContain("**!3** 👈 current"); + expect(block).toContain("- **!3** 👈 current `feat/c`"); }); it("does not duplicate live entries when prefix migrates between syncs", () => { @@ -5336,7 +5350,7 @@ describe("StackBlock", () => { const block = StackBlock.render({ pulls, metas: new Map(), - chain: ["feat/a", "feat/b", "feat/c"], + tree, branch: "feat/c", previous, reference: (number) => `!${number}`, @@ -5344,9 +5358,39 @@ describe("StackBlock", () => { expect(block).not.toContain("#1"); expect(block).not.toContain("#2"); expect(block).not.toContain("#3"); - expect(block).toContain("1. !1"); - expect(block).toContain("2. !2"); - expect(block).toContain("3. **!3** 👈 current"); + expect(block).toContain("- !1 `feat/a`"); + expect(block).toContain(" - !2 `feat/b`"); + expect(block).toContain(" - **!3** 👈 current `feat/c`"); + }); + + it("does not preserve open siblings from a stale flat block as history", () => { + const previous = `body before + + +### [Stack](https://github.com/kitlangton/stack) + +1. #1 +2. #3 +3. **#2** 👈 current +`; + const fork = { + branch: "feat/a", + children: [ + { branch: "feat/b", children: [] }, + { branch: "feat/c", children: [] }, + ], + }; + const block = StackBlock.render({ + pulls, + metas: new Map(), + tree: fork, + branch: "feat/b", + previous, + }); + + expect(block.match(/#3/g)).toHaveLength(1); + expect(block).toContain(" - #3 `feat/c`"); + expect(block).not.toContain("- #3\n"); }); });