Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/quiet-pr-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kitlangton/stack": patch
---

Read GitHub PR details through the REST API instead of version-dependent `gh pr view` JSON fields. This fixes decoding failures on older GitHub CLI versions while preserving fork repository identity and handling empty PR bodies and deleted repositories.
7 changes: 7 additions & 0 deletions .changeset/safe-parent-publication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@kitlangton/stack": patch
---

Publish locally ahead stack parents before repairing descendants during sync. Check the actual push destinations, require fast-forward ancestry, and use explicit leases so concurrent remote changes are not overwritten.

Journal remote-only updates separately so undo can restore different fork/origin tips, including previously absent refs, without discarding existing local parent commits. Keep-going checkpoints retain recovery data from earlier stacks. Journals with remote updates use version 2; existing version 1 journals remain readable.
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- **Stack block**: The generated markdown block in a change description that shows stack history and the current open path.
- **Repair**: The workflow that rehomes stack descendants after a squash merge, parent branch deletion, or parent branch rewrite.
- **Undo journal**: The saved snapshot of branch backups, change target branches, and stack metadata used by `stack undo`.
- **Remote-only publication**: Publishing an ahead parent without changing its local tip. The undo journal records each destination's before/after tips separately from local branch backups. Journals containing these records use version 2; version 1 journals remain readable.
- **Code host**: The hosted platform containing pull or merge requests. Currently GitHub (via `gh`) and GitLab (via `glab`). Known public hosts are selected from `origin`; enterprise hosts are configured with `git config stack.codeHost`.

## Architecture Notes
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,18 @@ then repair descendants automatically after the root lands.

- Infers stack links from PR/MR target branches.
- Records stack intent in `.git/stack/state.json`.
- Publishes ahead parent branches before repairing their descendants.
- Repairs descendants after parent branches move or land.
- Retargets PRs/MRs when needed.
- Refreshes stack blocks in descriptions.
- Saves `.git/stack/undo.json` before mutations.

For a parent that needs no local repair, sync checks its actual push destinations
and only publishes fast-forward changes, with explicit leases. Diverged or unknown
remote tips require fetching and reconciling first. Undo restores each destination's
previous tip without discarding commits that were already on the local parent;
it refuses to overwrite a remote tip changed since sync.

GitHub stack blocks use compact `#101` references. GitLab blocks use `!101`
references plus titles because bare GitLab MR links only show titles on hover.

Expand Down
7 changes: 7 additions & 0 deletions skills/stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ stack sync --apply # record links, repair, retarget, refresh stack blocks
That's the common loop. `stack sync` previews; `stack sync --apply` does the
work. Repeat after any parent branch changes or a squash merge lands.

Sync also publishes locally ahead parent branches before repairing descendants.
For parents that need no local repair, it checks the actual push destinations and
only publishes fast-forward changes with explicit leases. Fetch and reconcile
diverged or unknown remote tips before retrying. Undo restores remote-only
publication without rolling back commits that already existed on the local parent,
and refuses to overwrite unexpected remote changes.

## Commands

- `stack status` — show the current stack graph (hides backups, includes open
Expand Down
15 changes: 13 additions & 2 deletions src/domain/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,21 @@ export class UndoEntry extends Schema.Class<UndoEntry>("UndoEntry")({
pushRemotes: Schema.optional(Schema.Array(Schema.String)),
}) {}

export const RemoteUpdate = Schema.Struct({
branch: BranchName,
remote: Schema.String,
before: Schema.NullOr(Schema.String),
after: Schema.String,
});
export interface RemoteUpdate extends Schema.Schema.Type<typeof RemoteUpdate> {}

export class UndoState extends Schema.Class<UndoState>("UndoState")({
version: Schema.Literal(version),
version: Schema.Union([Schema.Literal(1), Schema.Literal(2)]),
at: Schema.String,
state: StackState,
entries: Schema.Array(UndoEntry),
actions: Schema.Array(Schema.String),
remoteUpdates: Schema.optionalKey(Schema.Array(RemoteUpdate)),
}) {}

export class StatusNode extends Schema.Class<StatusNode>("StatusNode")({
Expand Down Expand Up @@ -397,11 +406,13 @@ export const undoState = (
state: StackState,
entries: ReadonlyArray<UndoEntry>,
actions: ReadonlyArray<string>,
remoteUpdates: ReadonlyArray<RemoteUpdate> = [],
) =>
new UndoState({
version,
version: remoteUpdates.length > 0 ? 2 : version,
at,
state,
entries: Array.from(entries),
actions: Array.from(actions),
...(remoteUpdates.length > 0 ? { remoteUpdates: Array.from(remoteUpdates) } : {}),
});
57 changes: 56 additions & 1 deletion src/services/Git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export interface Worktree {
readonly dirty: ReadonlyArray<string>;
}

export interface PushRef {
readonly branch: string;
readonly remote: string;
readonly head: string | null;
readonly expected: string | null;
}

export interface Interface {
readonly dirty: () => Effect.Effect<ReadonlyArray<string>, ExecError>;
readonly worktrees: () => Effect.Effect<ReadonlyArray<Worktree>, ExecError>;
Expand All @@ -27,6 +34,10 @@ export interface Interface {
readonly remote: () => Effect.Effect<Option.Option<string>, ExecError>;
readonly switch: (branch: string) => Effect.Effect<void, ExecError>;
readonly head: (name: string) => Effect.Effect<Option.Option<string>, ExecError>;
readonly remoteHead: (
branch: string,
remote: string,
) => Effect.Effect<Option.Option<string>, ExecError>;
readonly base: (
branch: string,
parent: string,
Expand All @@ -51,6 +62,7 @@ export interface Interface {
readonly drop: (branch: string) => Effect.Effect<void, ExecError>;
readonly restore: (branch: string, name: string) => Effect.Effect<void, ExecError>;
readonly push: (branch: string, remote?: string) => Effect.Effect<void, ExecError>;
readonly pushRef: (update: PushRef) => Effect.Effect<void, ExecError>;
}

export class Service extends Context.Service<Service, Interface>()("@stack/Git") {}
Expand Down Expand Up @@ -201,10 +213,31 @@ export const live = Layer.effect(
),
);
const head = Effect.fn("Git.head")((name: string) =>
run("git", ["rev-parse", "--verify", name], [0, 1]).pipe(
run("git", ["rev-parse", "--verify", "--quiet", name], [0, 1]).pipe(
Effect.map((out) => (out ? Option.some(out) : Option.none<string>())),
),
);
const pushUrl = Effect.fn("Git.pushUrl")(function* (remote: string) {
const args = ["remote", "get-url", "--push", "--all", remote];
const urls = (yield* run("git", args)).split("\n").filter(Boolean);
const url = urls[0];
if (urls.length !== 1 || !url) {
return yield* Effect.fail(
new ExecError("git", args, 1, `${remote} must have exactly one push URL.`),
);
}
return url;
});
const remoteHead = Effect.fn("Git.remoteHead")(function* (branch: string, remote: string) {
const url = yield* pushUrl(remote);
const ref = `refs/heads/${branch}`;
const out = yield* run("git", ["ls-remote", "--heads", "--", url, ref]);
const head = out
.split("\n")
.map((line) => line.split("\t"))
.find((row) => row[1] === ref)?.[0];
return Option.fromNullishOr(head);
});
const base = Effect.fn("Git.base")(function* (branch: string, parent: string) {
const out = yield* run("git", ["merge-base", branch, parent], [0, 1]);
return out ? Option.some(out) : Option.none<string>();
Expand Down Expand Up @@ -338,6 +371,23 @@ export const live = Layer.effect(
Effect.asVoid,
),
);
const pushRef = Effect.fn("Git.pushRef")(function* ({
branch,
remote,
head,
expected,
}: PushRef) {
const url = yield* pushUrl(remote);
const ref = `refs/heads/${branch}`;
yield* run("git", [
"push",
"--no-follow-tags",
`--force-with-lease=${ref}:${expected ?? ""}`,
"--",
url,
`${head ?? ""}:${ref}`,
]);
});
return Service.of({
fetch,
remotes,
Expand All @@ -348,6 +398,7 @@ export const live = Layer.effect(
remote,
switch: switch_,
head,
remoteHead,
base,
commits,
novel,
Expand All @@ -358,6 +409,7 @@ export const live = Layer.effect(
drop,
restore,
push,
pushRef,
});
}),
);
Expand Down Expand Up @@ -388,6 +440,8 @@ export const test = (opts: {
: undefined),
),
),
remoteHead: (branch) =>
Effect.succeed(Option.fromNullishOr(opts.refs?.find((ref) => ref.name === branch)?.head)),
base: (branch: string, parent: string) =>
Effect.succeed(Option.fromNullishOr(opts.bases?.[`${branch}:${parent}`])),
commits: () => Effect.succeed([]),
Expand All @@ -399,6 +453,7 @@ export const test = (opts: {
drop: () => Effect.void,
restore: () => Effect.void,
push: () => Effect.void,
pushRef: () => Effect.void,
}),
);

Expand Down
Loading