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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# @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

- 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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kitlangton/stack",
"version": "0.4.0",
"version": "0.4.2",
"description": "Squash-safe stacked PR/MR repair CLI for GitHub and GitLab",
"keywords": [
"cli",
Expand Down
16 changes: 10 additions & 6 deletions skills/stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:links:start -->

### [Stack](https://github.com/kitlangton/stack)

1. #101
2. #102
3. **#103** 👈 current
- #101 `stack-a`
- #102 `stack-b`
- **#103** 👈 current `stack-c`
<!-- stack:links:end -->
```

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

Expand All @@ -109,6 +111,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.
Expand Down
32 changes: 31 additions & 1 deletion src/domain/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,35 @@ export class StackOperationError extends Schema.TaggedErrorClass<StackOperationE
}
}

export class ReplayConflictError extends Schema.TaggedErrorClass<ReplayConflictError>()(
"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<string>,
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>()(
"CodeHostDecodeError",
{
Expand Down Expand Up @@ -276,7 +305,8 @@ export type StackError =
| BranchError
| MergeBaseError
| DirtyWorktreeError
| StackOperationError;
| StackOperationError
| ReplayConflictError;

export const stackState = (links: ReadonlyArray<StackLink>) =>
new StackState({ version, links: Array.from(links) });
Expand Down
4 changes: 2 additions & 2 deletions src/repairExecution.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,7 +11,7 @@ interface Dependencies {

export interface ApplyRebaseBranchDependencies extends Dependencies {
readonly git: Pick<Git, "backup" | "replay" | "push">;
readonly onReplayFailure: (error: ExecError) => StackError;
readonly onReplayFailure: (error: ExecError | ReplayConflictError) => StackError;
}

export const applyRebaseBranch = Effect.fn("RepairExecution.applyRebaseBranch")(function* (
Expand Down
49 changes: 47 additions & 2 deletions src/services/Git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -44,7 +44,9 @@ export interface Interface {
branch: string,
parent: string,
commits: ReadonlyArray<string>,
) => Effect.Effect<void, ExecError>;
) => Effect.Effect<void, ExecError | ReplayConflictError>;
readonly unmergedPaths: () => Effect.Effect<ReadonlyArray<string>, 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 +144,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 @@ -221,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,
Expand Down Expand Up @@ -252,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<string>)),
);
return yield* Effect.fail(
new ReplayConflictError(branch, parent, paths, err.stderr),
);
}),
),
);
}
if (owner) {
Expand All @@ -269,6 +299,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 +352,8 @@ export const live = Layer.effect(
commits,
novel,
replay,
unmergedPaths,
release,
backup,
drop,
restore,
Expand Down Expand Up @@ -350,6 +393,8 @@ export const test = (opts: {
commits: () => Effect.succeed([]),
novel: (_parent, _branch, commits) => Effect.succeed(commits),
replay: () => Effect.void,
unmergedPaths: () => Effect.succeed([] as ReadonlyArray<string>),
release: () => Effect.void,
backup: () => Effect.void,
drop: () => Effect.void,
restore: () => Effect.void,
Expand Down
38 changes: 32 additions & 6 deletions src/services/Stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand All @@ -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,
Comment on lines +178 to +182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Both branches of this ternary produce identical output. The check can be collapsed into one condition.

Suggested change
err._tag === "ExecError" && err.stderr
? ` ${err.stderr}`
: err._tag === "ReplayConflictError" && err.stderr
? ` ${err.stderr}`
: null,
(err._tag === "ExecError" || err._tag === "ReplayConflictError") && err.stderr
? ` ${err.stderr}`
: null,

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Cursor Cloud Agents Fix in Claude Code Fix in Cursor

]
.filter((line): line is string => line !== null)
.join("\n"),
Expand Down Expand Up @@ -786,6 +793,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);
Expand Down Expand Up @@ -930,7 +942,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* () {
Expand Down Expand Up @@ -1403,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,
Expand Down Expand Up @@ -1636,6 +1652,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 +1762,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 +1842,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
18 changes: 15 additions & 3 deletions src/services/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ export class Store extends Context.Service<Store, StoreService>()("@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.`,
),
});
});

Expand All @@ -49,9 +54,16 @@ export class Store extends Context.Service<Store, StoreService>()("@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")(() =>
Expand Down
Loading