From 5f92fdccc755101d7e5032a7adf9589df9ffdc89 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 27 Aug 2026 22:04:16 -0400 Subject: [PATCH] fix(stack): publish ahead parents safely and support older gh Journal remote-only parent publications independently from local branch backups, use explicit push leases, and retain cumulative recovery checkpoints across keep-going stacks. Read GitHub PR details through REST rather than version-dependent gh pr view fields. Add real-Git regression coverage and patch changesets. --- .changeset/quiet-pr-details.md | 5 + .changeset/safe-parent-publication.md | 7 + CONTEXT.md | 1 + README.md | 7 + skills/stack/SKILL.md | 7 + src/domain/model.ts | 15 +- src/services/Git.ts | 57 ++- src/services/Stack.ts | 133 ++++++- src/services/code-host/GitHub.ts | 48 +-- tests/gitRemote.test.ts | 228 +++++++++++ tests/parentPush.test.ts | 544 ++++++++++++++++++++++++++ tests/stack.test.ts | 124 +++++- 12 files changed, 1125 insertions(+), 51 deletions(-) create mode 100644 .changeset/quiet-pr-details.md create mode 100644 .changeset/safe-parent-publication.md create mode 100644 tests/gitRemote.test.ts create mode 100644 tests/parentPush.test.ts diff --git a/.changeset/quiet-pr-details.md b/.changeset/quiet-pr-details.md new file mode 100644 index 0000000..5c7f7d1 --- /dev/null +++ b/.changeset/quiet-pr-details.md @@ -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. diff --git a/.changeset/safe-parent-publication.md b/.changeset/safe-parent-publication.md new file mode 100644 index 0000000..e9cca9e --- /dev/null +++ b/.changeset/safe-parent-publication.md @@ -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. diff --git a/CONTEXT.md b/CONTEXT.md index aba3625..a2c1d0e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/README.md b/README.md index bc49b8c..a19e865 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/skills/stack/SKILL.md b/skills/stack/SKILL.md index 8e49b9d..6536d5f 100644 --- a/skills/stack/SKILL.md +++ b/skills/stack/SKILL.md @@ -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 diff --git a/src/domain/model.ts b/src/domain/model.ts index 0b490a9..893ab86 100644 --- a/src/domain/model.ts +++ b/src/domain/model.ts @@ -81,12 +81,21 @@ export class UndoEntry extends Schema.Class("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 {} + export class UndoState extends Schema.Class("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")({ @@ -397,11 +406,13 @@ export const undoState = ( state: StackState, entries: ReadonlyArray, actions: ReadonlyArray, + remoteUpdates: ReadonlyArray = [], ) => 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) } : {}), }); diff --git a/src/services/Git.ts b/src/services/Git.ts index de0f939..c13cf40 100644 --- a/src/services/Git.ts +++ b/src/services/Git.ts @@ -14,6 +14,13 @@ export interface Worktree { readonly dirty: ReadonlyArray; } +export interface PushRef { + readonly branch: string; + readonly remote: string; + readonly head: string | null; + readonly expected: string | null; +} + export interface Interface { readonly dirty: () => Effect.Effect, ExecError>; readonly worktrees: () => Effect.Effect, ExecError>; @@ -27,6 +34,10 @@ export interface Interface { readonly remote: () => Effect.Effect, ExecError>; readonly switch: (branch: string) => Effect.Effect; readonly head: (name: string) => Effect.Effect, ExecError>; + readonly remoteHead: ( + branch: string, + remote: string, + ) => Effect.Effect, ExecError>; readonly base: ( branch: string, parent: string, @@ -51,6 +62,7 @@ export interface Interface { readonly drop: (branch: string) => Effect.Effect; readonly restore: (branch: string, name: string) => Effect.Effect; readonly push: (branch: string, remote?: string) => Effect.Effect; + readonly pushRef: (update: PushRef) => Effect.Effect; } export class Service extends Context.Service()("@stack/Git") {} @@ -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())), ), ); + 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(); @@ -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, @@ -348,6 +398,7 @@ export const live = Layer.effect( remote, switch: switch_, head, + remoteHead, base, commits, novel, @@ -358,6 +409,7 @@ export const live = Layer.effect( drop, restore, push, + pushRef, }); }), ); @@ -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([]), @@ -399,6 +453,7 @@ export const test = (opts: { drop: () => Effect.void, restore: () => Effect.void, push: () => Effect.void, + pushRef: () => Effect.void, }), ); diff --git a/src/services/Stack.ts b/src/services/Stack.ts index e5e2dc0..762eadd 100644 --- a/src/services/Stack.ts +++ b/src/services/Stack.ts @@ -15,6 +15,7 @@ import { PullMeta, pullRef, PullRef, + type RemoteUpdate, stackLink, StackLink, StackOperationError, @@ -319,7 +320,7 @@ ${note}`; if (backups > 0 && opts.mode === "apply") summary.push(`Backups created: ${backups}`); if (summary.length > 0) lines.push("", ...summary); if (opts.mode === "dry-run") lines.push("", "Apply:", " stack sync --apply"); - else if (!opts.failed && (backups > 0 || updatedPrs.size > 0)) { + else if (!opts.failed && (backups > 0 || updatedPrs.size > 0 || pushed.size > 0)) { lines.push("", "Undo:", " stack undo --apply"); } return lines; @@ -705,6 +706,11 @@ ${note}`; state: ReturnType, ) => Effect.Effect; readonly preserveUndo?: boolean; + readonly publishParents?: boolean; + readonly childBases?: ReadonlySet; + readonly writeUndo?: ( + run: ReturnType, + ) => Effect.Effect; }, ) => Effect.gen(function* () { @@ -743,12 +749,13 @@ ${note}`; ), ); } - const childBases = new Set(pulls.map((pull) => String(pull.base))); + const childBases = opts.childBases ?? new Set(pulls.map((pull) => String(pull.base))); let remoteByRepository: Map | null = null; const tips = new Map(); const prior = new Map(); const moved = new Set(); const entries: Array = Array.from(opts.initialEntries ?? []); + const remoteUpdates: Array = []; const next: Array = []; let journal = apply && (initialActions.length > 0 || entries.length > 0); @@ -806,7 +813,7 @@ ${note}`; const checkpoint = Effect.fn("Stack.repairStack.checkpoint")(() => apply - ? store.writeUndo( + ? (opts.writeUndo ?? store.writeUndo)( undoState( stamp, journalState, @@ -816,6 +823,7 @@ ${note}`; reference, requestLabel, ), + remoteUpdates, ), ) : Effect.void, @@ -877,7 +885,8 @@ ${note}`; for (const link of [...state.links].sort( (a, b) => graph.rank(String(a.branch)) - graph.rank(String(b.branch)), )) { - if (!live.has(String(link.branch))) { + const local = live.get(String(link.branch)); + if (!local) { if (!prs.has(String(link.branch))) continue; next.push(link); continue; @@ -1000,6 +1009,55 @@ ${note}`; moved.add(link.branch); } + if (opts.publishParents && !drift && childBases.has(String(link.branch))) { + if (trunk(link.branch)) { + return yield* new StackOperationError( + `cannot publish trunk branch: ${link.branch}`, + ); + } + const after = local.head; + const updates: Array = []; + for (const remote of yield* pushRemotes(String(link.branch), headRepository, num)) { + const before = Option.getOrNull(yield* git.remoteHead(link.branch, remote)); + if (before === after) continue; + if (before !== null) { + const known = yield* git.head(`${before}^{commit}`); + const common = Option.isSome(known) + ? yield* git.base(after, before) + : Option.none(); + if (Option.isNone(common) || common.value !== before) { + return yield* new StackOperationError( + `cannot publish ${link.branch} to ${remote}: remote tip is not an ancestor of the local tip; fetch and reconcile it first`, + ); + } + } + updates.push({ branch: link.branch, remote, before, after }); + } + if (updates.length > 0) { + actions.push({ + _tag: "Push", + mode, + branch: String(link.branch), + remotes: updates.map((update) => update.remote), + }); + if (apply) { + // A checkpoint may precede only some pushes; undo also accepts the before tip. + remoteUpdates.push(...updates); + journal = true; + yield* checkpoint(); + for (const update of updates) { + yield* step(`push ${update.branch} to ${update.remote}`); + yield* git.pushRef({ + branch: update.branch, + remote: update.remote, + head: update.after, + expected: update.before, + }); + } + } + } + } + const now = prs.get(link.branch) ?? null; if (now && now.base !== parent) { const retarget = { @@ -1146,6 +1204,7 @@ ${note}`; journalState, entries, StackResult.renderAll([...journalActions, ...actions], reference, requestLabel), + remoteUpdates, ) : null, lines: @@ -1209,11 +1268,13 @@ ${note}`; : yield* resolveScope(current, false); const initialActions = [...reconciled.actions, ...plan.map(StackResult.track)]; + const childBases = new Set(pulls.map((pull) => String(pull.base))); const syncScoped = Effect.fn("Stack.sync.scoped")( ( target: { readonly root: string; readonly branches: ReadonlySet } | null, preserveUndo = false, + writeUndo?: (run: ReturnType) => Effect.Effect, ) => Effect.gen(function* () { const scoped = target ? filterState(planned, target.branches) : planned; @@ -1236,6 +1297,9 @@ ${note}`; initialActions: scopedInitial, ...(writeState ? { writeState } : {}), preserveUndo, + publishParents: true, + childBases, + ...(writeUndo ? { writeUndo } : {}), }); const changedOpenPulls = repair.actions.some( (action) => action._tag === "RetargetPull" || action._tag === "CreatePull", @@ -1278,6 +1342,7 @@ ${note}`; const failed = new Array<{ root: string; error: string }>(); const sections = new Array(); const aggregateEntries = new Array(); + const aggregateRemoteUpdates = new Array(); const aggregateActions = new Array(); let aggregateAt: string | null = null; @@ -1286,11 +1351,26 @@ ${note}`; aggregateAt ??= String(run.at); aggregateEntries.push(...run.entries); aggregateActions.push(...run.actions); + aggregateRemoteUpdates.push(...(run.remoteUpdates ?? [])); }; for (const root of roots) { + let checkpointed: ReturnType | null = null; const result = yield* Effect.result( - syncScoped({ root, branches: scopedBranches(planned, root) }, true), + syncScoped({ root, branches: scopedBranches(planned, root) }, true, (run) => + Effect.gen(function* () { + yield* store.writeUndo( + undoState( + aggregateAt ?? run.at, + state, + [...aggregateEntries, ...run.entries], + [...aggregateActions, ...run.actions], + [...aggregateRemoteUpdates, ...(run.remoteUpdates ?? [])], + ), + ); + checkpointed = run; + }), + ), ); if (Result.isSuccess(result)) { succeeded.push(root); @@ -1298,13 +1378,19 @@ ${note}`; rememberUndo(result.success.undo); sections.push(...result.success.lines); } else { - rememberUndo(yield* store.readUndo()); + rememberUndo(checkpointed); failed.push({ root, error: String(result.failure) }); } - if (!dryRun && aggregateAt && aggregateEntries.length > 0) { + if (!dryRun && aggregateAt && Result.isSuccess(result) && result.success.undo) { yield* store.writeUndo( - undoState(aggregateAt, state, aggregateEntries, aggregateActions), + undoState( + aggregateAt, + state, + aggregateEntries, + aggregateActions, + aggregateRemoteUpdates, + ), ); } } @@ -1897,6 +1983,23 @@ ${note}`; run.entries.flatMap((item) => (item.backup ? [String(item.branch)] : [])), ); + const remoteUpdates: Array = []; + for (const update of run.remoteUpdates ?? []) { + if (trunks.has(update.branch)) { + return yield* new StackOperationError( + `cannot restore trunk branch: ${update.branch}`, + ); + } + const current = Option.getOrNull(yield* git.remoteHead(update.branch, update.remote)); + if (current === update.before) continue; + if (current !== update.after) { + return yield* new StackOperationError( + `cannot undo ${update.remote}/${update.branch}: remote tip changed since sync; refusing to overwrite it`, + ); + } + remoteUpdates.push(update); + } + if (restore.has(current)) { actions.push(`${mode}switch to ${trunk}`); if (apply) yield* git.switch(trunk); @@ -1920,6 +2023,20 @@ ${note}`; } } + for (const update of remoteUpdates) { + actions.push( + `${mode}${update.before === null ? "delete" : "restore"} remote ${update.remote}/${update.branch}`, + ); + if (apply) { + yield* git.pushRef({ + branch: update.branch, + remote: update.remote, + head: update.before, + expected: update.after, + }); + } + } + for (const item of run.entries) { if (item.created) { actions.push(`${mode}close ${reference(Number(item.created))}`); diff --git a/src/services/code-host/GitHub.ts b/src/services/code-host/GitHub.ts index 3209bc0..97a3834 100644 --- a/src/services/code-host/GitHub.ts +++ b/src/services/code-host/GitHub.ts @@ -16,22 +16,6 @@ import { StackConfig } from "../Config.ts"; import { CodeHost } from "../CodeHost.ts"; import { CodeHostMemory } from "./Memory.ts"; -class PullView extends Schema.Class("PullView")({ - number: Schema.Number, - title: Schema.String, - body: Schema.String, - headRefName: Schema.String, - headRepository: Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String })), - baseRefName: Schema.String, - url: Schema.String, - isDraft: Schema.Boolean, - labels: Schema.Array( - Schema.Struct({ - name: Schema.String, - }), - ), -}) {} - class PullWatch extends Schema.Class("PullWatch")({ state: Schema.String, mergedAt: Schema.NullOr(Schema.String), @@ -51,6 +35,13 @@ class PullListData extends Schema.Class("PullListData")({ const PullListJson = Schema.Array(Schema.Array(PullListData)); +const PullDetails = Schema.Struct({ + ...PullListData.fields, + body: Schema.NullOr(Schema.String), + labels: Schema.Array(Schema.Struct({ name: Schema.String })), +}); +interface PullDetails extends Schema.Schema.Type {} + const extractJson = (out: string): string => { const start = out.search(/[[{]/); if (start === -1) return out; @@ -67,9 +58,9 @@ const decodePullList = (args: ReadonlyArray, out: string) => catch: (err) => new CodeHostDecodeError("gh", args, out, String(err)), }); -const decodePullView = (args: ReadonlyArray, out: string) => +const decodePullDetails = (args: ReadonlyArray, out: string) => Effect.try({ - try: () => Schema.decodeUnknownSync(PullView)(JSON.parse(extractJson(out))), + try: () => Schema.decodeUnknownSync(PullDetails)(JSON.parse(extractJson(out))), catch: (err) => new CodeHostDecodeError("gh", args, out, String(err)), }); @@ -93,16 +84,11 @@ const listRef = (row: PullListData) => draft: row.draft, }); -const meta = (row: PullView) => +const meta = (row: PullDetails) => pullMeta({ - number: row.number, + ...listRef(row), title: row.title, - body: row.body, - head: row.headRefName, - headRepository: row.headRepository?.nameWithOwner.toLowerCase() ?? null, - base: row.baseRefName, - url: row.url, - draft: row.isDraft, + body: row.body ?? "", state: "OPEN", labels: row.labels.map((item) => new PullLabel({ name: item.name })), }); @@ -146,16 +132,10 @@ export const layer = Layer.effect( }); const change = Effect.fn("CodeHost.github.change")((pr: number) => { - const args = [ - "pr", - "view", - `${pr}`, - "--json", - "number,title,body,headRefName,headRepository,baseRefName,url,isDraft,labels", - ]; + const args = ["api", `repos/{owner}/{repo}/pulls/${pr}`]; return run(args).pipe( Effect.catchIf(missingPull, () => Effect.fail(new CodeHostChangeNotFoundError(pr))), - Effect.flatMap((out) => decodePullView(args, out)), + Effect.flatMap((out) => decodePullDetails(args, out)), Effect.map(meta), ); }); diff --git a/tests/gitRemote.test.ts b/tests/gitRemote.test.ts new file mode 100644 index 0000000..a9d02fb --- /dev/null +++ b/tests/gitRemote.test.ts @@ -0,0 +1,228 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer, Option } from "effect"; +import * as FileSystem from "effect/FileSystem"; +import { ExecError } from "../src/domain/model.ts"; +import * as Proc from "../src/platform/proc.ts"; +import { StackConfig } from "../src/services/Config.ts"; +import { Git } from "../src/services/Git.ts"; + +const platform = Proc.live.pipe(Layer.provideMerge(NodeServices.layer)); + +const scenario = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const proc = yield* Proc.Service; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-git-remote-" }); + const repo = `${root}/repo`; + const origin = `${root}/origin.git`; + const other = `${root}/other.git`; + yield* fs.makeDirectory(repo); + const command = (args: ReadonlyArray) => proc.exec(repo, "git", args); + yield* command(["init", "--bare", origin]); + yield* command(["init", "--bare", other]); + yield* command(["init", "-b", "topic"]); + yield* command(["config", "user.name", "Stack Test"]); + yield* command(["config", "user.email", "stack@example.com"]); + yield* command(["config", "commit.gpgsign", "false"]); + yield* command(["commit", "--allow-empty", "-m", "base"]); + const base = yield* command(["rev-parse", "HEAD"]); + yield* command(["push", origin, `${base}:refs/heads/topic`]); + yield* command(["push", other, `${base}:refs/heads/topic`]); + yield* command(["remote", "add", "origin", origin]); + yield* command(["fetch", "origin"]); + yield* command(["commit", "--allow-empty", "-m", "ahead"]); + const ahead = yield* command(["rev-parse", "HEAD"]); + yield* command(["commit", "--allow-empty", "-m", "later"]); + const later = yield* command(["rev-parse", "HEAD"]); + const layer = Git.live.pipe(Layer.provide(StackConfig.layer({ root: repo }))); + const snapshot = () => + Effect.all({ + refs: command(["for-each-ref", "--format=%(refname) %(objectname)"]), + head: fs.readFileString(`${repo}/.git/HEAD`), + config: fs.readFileString(`${repo}/.git/config`), + fetchHead: fs.readFileString(`${repo}/.git/FETCH_HEAD`), + }); + return { command, origin, other, base, ahead, later, layer, snapshot }; +}); + +it.effect( + "pushRef publishes a literal ahead OID and restores the remote without moving local refs", + () => + Effect.gen(function* () { + const s = yield* scenario; + const before = yield* s.snapshot(); + yield* Effect.gen(function* () { + const git = yield* Git.Service; + expect(yield* git.remoteHead("topic", "origin")).toEqual(Option.some(s.base)); + yield* git.pushRef({ branch: "topic", remote: "origin", head: s.ahead, expected: s.base }); + expect(yield* git.remoteHead("topic", "origin")).toEqual(Option.some(s.ahead)); + expect(yield* git.head("topic")).toEqual(Option.some(s.later)); + expect(yield* s.snapshot()).toEqual(before); + + yield* git.pushRef({ branch: "topic", remote: "origin", head: s.base, expected: s.ahead }); + expect(yield* git.remoteHead("topic", "origin")).toEqual(Option.some(s.base)); + expect(yield* s.snapshot()).toEqual(before); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("pushRef does not publish annotated tags when push.followTags is enabled", () => + Effect.gen(function* () { + const s = yield* scenario; + yield* s.command(["config", "push.followTags", "true"]); + yield* s.command(["tag", "--no-sign", "-a", "unpublished", "-m", "unpublished", s.ahead]); + const before = yield* s.snapshot(); + yield* Effect.gen(function* () { + const git = yield* Git.Service; + yield* git.pushRef({ branch: "topic", remote: "origin", head: s.ahead, expected: s.base }); + expect( + yield* s.command([ + "--git-dir", + s.origin, + "for-each-ref", + "--format=%(refname) %(objectname)", + ]), + ).toBe(`refs/heads/topic ${s.ahead}`); + expect(yield* s.command(["ls-remote", "--tags", s.origin])).toBe(""); + expect(yield* s.snapshot()).toEqual(before); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("pushRef rejects a stale explicit lease rather than refreshing it", () => + Effect.gen(function* () { + const s = yield* scenario; + yield* Effect.gen(function* () { + const git = yield* Git.Service; + const expected = Option.getOrNull(yield* git.remoteHead("topic", "origin")); + yield* s.command(["push", s.origin, `${s.ahead}:refs/heads/topic`]); + const before = yield* s.snapshot(); + + const error = yield* Effect.flip( + git.pushRef({ branch: "topic", remote: "origin", head: s.later, expected }), + ); + expect(error).toBeInstanceOf(ExecError); + expect(error.stderr).toContain("stale info"); + expect(yield* git.remoteHead("topic", "origin")).toEqual(Option.some(s.ahead)); + expect(yield* s.snapshot()).toEqual(before); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("pushRef deletes only with a matching lease and preserves the local branch", () => + Effect.gen(function* () { + const s = yield* scenario; + const before = yield* s.snapshot(); + yield* Effect.gen(function* () { + const git = yield* Git.Service; + const error = yield* Effect.flip( + git.pushRef({ branch: "topic", remote: "origin", head: null, expected: s.ahead }), + ); + expect(error.stderr).toContain("stale info"); + expect(yield* git.remoteHead("topic", "origin")).toEqual(Option.some(s.base)); + + yield* git.pushRef({ branch: "topic", remote: "origin", head: null, expected: s.base }); + expect(yield* git.remoteHead("topic", "origin")).toEqual(Option.none()); + expect(yield* git.head("topic")).toEqual(Option.some(s.later)); + expect(yield* s.snapshot()).toEqual(before); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("missing refs return none and an empty lease only creates an absent remote ref", () => + Effect.gen(function* () { + const s = yield* scenario; + const before = yield* s.snapshot(); + yield* Effect.gen(function* () { + const git = yield* Git.Service; + expect(yield* git.head("missing")).toEqual(Option.none()); + expect(yield* git.head("refs/heads/missing")).toEqual(Option.none()); + expect(yield* git.head("origin/missing")).toEqual(Option.none()); + expect(yield* git.remoteHead("missing", "origin")).toEqual(Option.none()); + expect(yield* s.snapshot()).toEqual(before); + + yield* git.pushRef({ branch: "missing", remote: "origin", head: s.ahead, expected: null }); + expect(yield* git.remoteHead("missing", "origin")).toEqual(Option.some(s.ahead)); + const error = yield* Effect.flip( + git.pushRef({ branch: "missing", remote: "origin", head: s.later, expected: null }), + ); + expect(error.stderr).toContain("stale info"); + expect(yield* git.remoteHead("missing", "origin")).toEqual(Option.some(s.ahead)); + expect(yield* s.snapshot()).toEqual(before); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect( + "remoteHead reads the fresh push URL without fetching and pushRef writes that destination", + () => + Effect.gen(function* () { + const s = yield* scenario; + yield* s.command(["remote", "set-url", "--push", "origin", s.other]); + const remoteOnly = yield* s.command([ + "-c", + "user.name=Stack Test", + "-c", + "user.email=stack@example.com", + "--git-dir", + s.other, + "commit-tree", + `${s.base}^{tree}`, + "-p", + s.base, + "-m", + "remote-only", + ]); + yield* s.command(["--git-dir", s.other, "update-ref", "refs/heads/topic", remoteOnly]); + const before = yield* s.snapshot(); + + yield* Effect.gen(function* () { + const git = yield* Git.Service; + expect(yield* git.remoteHead("topic", "origin")).toEqual(Option.some(remoteOnly)); + expect(yield* git.head(`${remoteOnly}^{commit}`)).toEqual(Option.none()); + expect(yield* git.head("origin/topic")).toEqual(Option.some(s.base)); + expect(yield* s.snapshot()).toEqual(before); + + yield* git.pushRef({ + branch: "topic", + remote: "origin", + head: s.ahead, + expected: remoteOnly, + }); + expect(yield* git.remoteHead("topic", "origin")).toEqual(Option.some(s.ahead)); + expect(yield* s.command(["--git-dir", s.origin, "rev-parse", "topic"])).toBe(s.base); + expect(yield* s.command(["--git-dir", s.other, "rev-parse", "topic"])).toBe(s.ahead); + expect(yield* s.snapshot()).toEqual(before); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect( + "remoteHead and pushRef reject multiple push destinations before touching either repo", + () => + Effect.gen(function* () { + const s = yield* scenario; + yield* s.command(["config", "--add", "remote.origin.pushurl", s.origin]); + yield* s.command(["config", "--add", "remote.origin.pushurl", s.other]); + yield* Effect.gen(function* () { + const git = yield* Git.Service; + for (const explicitPushUrls of [true, false]) { + if (!explicitPushUrls) { + yield* s.command(["config", "--unset-all", "remote.origin.pushurl"]); + yield* s.command(["config", "--add", "remote.origin.url", s.other]); + } + const before = yield* s.snapshot(); + const readError = yield* Effect.flip(git.remoteHead("topic", "origin")); + const pushError = yield* Effect.flip( + git.pushRef({ branch: "topic", remote: "origin", head: s.ahead, expected: s.base }), + ); + expect(readError).toBeInstanceOf(ExecError); + expect(readError.stderr).toContain("exactly one push URL"); + expect(pushError.stderr).toContain("exactly one push URL"); + expect(yield* s.command(["--git-dir", s.origin, "rev-parse", "topic"])).toBe(s.base); + expect(yield* s.command(["--git-dir", s.other, "rev-parse", "topic"])).toBe(s.base); + expect(yield* s.snapshot()).toEqual(before); + } + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); diff --git a/tests/parentPush.test.ts b/tests/parentPush.test.ts new file mode 100644 index 0000000..31b6973 --- /dev/null +++ b/tests/parentPush.test.ts @@ -0,0 +1,544 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer, Option, Schema } from "effect"; +import * as FileSystem from "effect/FileSystem"; +import { + ExecError, + pullRef, + stackLink, + stackState, + StateError, + UndoState, +} from "../src/domain/model.ts"; +import * as Proc from "../src/platform/proc.ts"; +import { StackConfig } from "../src/services/Config.ts"; +import { CodeHostGitHub } from "../src/services/code-host/GitHub.ts"; +import { CodeHost } from "../src/services/CodeHost.ts"; +import { Git } from "../src/services/Git.ts"; +import * as Progress from "../src/services/Progress.ts"; +import { Stack } from "../src/services/Stack.ts"; +import { Store, type StoreService } from "../src/services/Store.ts"; + +const platform = Proc.live.pipe(Layer.provideMerge(NodeServices.layer)); + +const scenario = ( + opts: { + fork?: boolean; + second?: boolean; + remoteChild?: boolean; + service?: (git: Git.Interface) => Partial; + store?: (store: StoreService) => Partial; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const proc = yield* Proc.Service; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-parent-push-" }); + const repo = `${root}/repo`; + yield* fs.makeDirectory(repo); + const git = (args: ReadonlyArray) => proc.exec(repo, "git", args); + yield* git(["init", "--bare", `${root}/origin.git`]); + yield* git(["init", "-b", "dev"]); + yield* git(["config", "user.name", "Stack Test"]); + yield* git(["config", "user.email", "stack@example.com"]); + yield* git(["config", "commit.gpgsign", "false"]); + yield* git(["remote", "add", "origin", `${root}/origin.git`]); + const commit = (file: string) => + Effect.gen(function* () { + yield* fs.writeFileString(`${repo}/${file}`, `${file}\n`); + yield* git(["add", file]); + yield* git(["commit", "-m", file]); + }); + yield* commit("base"); + const trunk = yield* git(["rev-parse", "HEAD"]); + yield* git(["checkout", "-b", "parent"]); + yield* commit("parent1"); + const parent = yield* git(["rev-parse", "HEAD"]); + yield* git(["checkout", "-b", "child"]); + yield* commit("child"); + const child = yield* git(["rev-parse", "HEAD"]); + yield* git(["push", "origin", "dev", "parent", "child"]); + yield* git(["checkout", "parent"]); + yield* commit("parent2"); + const tip = yield* git(["rev-parse", "HEAD"]); + if (opts.remoteChild) yield* git(["branch", "-D", "child"]); + if (opts.second) { + yield* git(["branch", "other-parent", tip]); + yield* git(["branch", "other-child", child]); + yield* git([ + "push", + "origin", + `${parent}:refs/heads/other-parent`, + `${child}:refs/heads/other-child`, + ]); + } + const cfg = StackConfig.layer({ root: repo, trunks: ["dev"] }).pipe( + Layer.provide(NodeServices.layer), + ); + if (opts.fork) { + yield* git(["init", "--bare", `${root}/fork.git`]); + yield* git(["remote", "add", "fork", `${root}/fork.git`]); + yield* git(["push", "fork", `${trunk}:refs/heads/parent`]); + } + const adapter = yield* Git.Service.asEffect().pipe( + Effect.provide(Git.live.pipe(Layer.provide(cfg))), + ); + const state = stackState([ + stackLink({ branch: "parent", parent: "dev", anchor: trunk, pr: 1 }), + ...(opts.remoteChild + ? [] + : [stackLink({ branch: "child", parent: "parent", anchor: parent, pr: 2 })]), + ...(opts.second + ? [ + stackLink({ branch: "other-parent", parent: "dev", anchor: trunk, pr: 3 }), + stackLink({ branch: "other-child", parent: "other-parent", anchor: parent, pr: 4 }), + ] + : []), + ]); + const store = yield* Store.asEffect().pipe(Effect.provide(Store.memory(state))); + const host = yield* CodeHost.Service.asEffect().pipe( + Effect.provide( + CodeHostGitHub.memory({ + pulls: [ + pullRef({ + number: 1, + head: "parent", + base: "dev", + url: "u1", + draft: false, + headRepository: opts.fork ? "contributor/project" : null, + }), + pullRef({ number: 2, head: "child", base: "parent", url: "u2", draft: false }), + ...(opts.second + ? [ + pullRef({ + number: 3, + head: "other-parent", + base: "dev", + url: "u3", + draft: false, + }), + pullRef({ + number: 4, + head: "other-child", + base: "other-parent", + url: "u4", + draft: false, + }), + ] + : []), + ], + }), + ), + ); + const layer = Stack.layer.pipe( + Layer.provideMerge(Progress.noop), + Layer.provideMerge(Layer.succeed(Git.Service, { ...adapter, ...opts.service?.(adapter) })), + Layer.provideMerge(cfg), + Layer.provideMerge( + Layer.succeed(CodeHost.Service, { + ...host, + repository: (url) => + url.endsWith("/fork.git") ? "contributor/project" : "upstream/project", + }), + ), + Layer.provideMerge(Layer.succeed(Store, { ...store, ...opts.store?.(store) })), + ); + return { git, layer, child, tip, parent, trunk, commit, root, repo, adapter, store, state }; + }); + +it.effect("scoped sync publishes an ahead parent along with its repaired child", () => + Effect.gen(function* () { + const s = yield* scenario(); + const lines = yield* Effect.gen(function* () { + const stack = yield* Stack; + return yield* stack.sync({ apply: true, branch: "child" }); + }).pipe(Effect.provide(s.layer)); + expect(yield* s.git(["rev-parse", "origin/child"])).not.toBe(s.child); + expect(yield* s.git(["merge-base", "origin/child", "parent"])).toBe(s.tip); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.tip); + expect( + yield* s.git(["--git-dir", `${s.root}/origin.git`, "merge-base", "child", "parent"]), + ).toBe(s.tip); + expect(lines.join("\n")).toContain("parent #1 pushed"); + }).pipe(Effect.provide(platform)), +); + +it.effect("undo restores the remote parent without rolling back its existing local commits", () => + Effect.gen(function* () { + const s = yield* scenario(); + yield* Effect.gen(function* () { + const stack = yield* Stack; + const store = yield* Store; + yield* stack.sync({ apply: true, branch: "child" }); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.tip); + const journal = yield* store.readUndo(); + expect(journal?.version).toBe(2); + expect(journal).toMatchObject({ + remoteUpdates: [{ branch: "parent", remote: "origin", before: s.parent, after: s.tip }], + }); + expect(Schema.decodeUnknownSync(UndoState)(JSON.parse(JSON.stringify(journal)))).toEqual( + journal, + ); + yield* stack.undo(true); + expect(yield* s.git(["rev-parse", "parent"])).toBe(s.tip); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + expect(yield* s.git(["rev-parse", "child"])).toBe(s.child); + expect(yield* s.git(["rev-parse", "origin/child"])).toBe(s.child); + expect(yield* store.readUndo()).toBeNull(); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect( + "preview includes parent publication without fetching or mutating refs or the journal", + () => + Effect.gen(function* () { + const s = yield* scenario({ + service: () => ({ + fetch: () => Effect.fail(new ExecError("git", ["fetch"], 1, "preview must not fetch")), + }), + }); + const refs = yield* s.git(["show-ref"]); + const lines = yield* Effect.gen(function* () { + const stack = yield* Stack; + return yield* stack.sync({ branch: "child" }); + }).pipe(Effect.provide(s.layer)); + expect(lines.join("\n")).toContain("parent #1 would push"); + expect(yield* s.git(["show-ref"])).toBe(refs); + expect(yield* s.store.read()).toEqual(s.state); + expect(yield* s.store.readUndo()).toBeNull(); + }).pipe(Effect.provide(platform)), +); + +it.effect("publication and undo preserve different fork and origin parent tips", () => + Effect.gen(function* () { + const s = yield* scenario({ fork: true }); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true, branch: "child" }); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "fork"))).toBe(s.tip); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.tip); + expect((yield* s.store.readUndo())?.remoteUpdates).toEqual([ + { branch: "parent", remote: "fork", before: s.trunk, after: s.tip }, + { branch: "parent", remote: "origin", before: s.parent, after: s.tip }, + ]); + yield* stack.undo(true); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "fork"))).toBe(s.trunk); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + expect(yield* s.git(["rev-parse", "parent"])).toBe(s.tip); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("undo removes a newly published remote parent without deleting the local branch", () => + Effect.gen(function* () { + const s = yield* scenario(); + yield* s.git(["push", "origin", ":refs/heads/parent"]); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true, branch: "child" }); + expect((yield* s.store.readUndo())?.remoteUpdates?.[0]?.before).toBeNull(); + yield* stack.undo(true); + expect(Option.isNone(yield* s.adapter.remoteHead("parent", "origin"))).toBe(true); + expect(yield* s.git(["rev-parse", "parent"])).toBe(s.tip); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("refuses a diverged remote parent before changing its child", () => + Effect.gen(function* () { + const s = yield* scenario(); + yield* s.git(["checkout", "dev"]); + yield* s.commit("remote-only"); + const remote = yield* s.git(["rev-parse", "HEAD"]); + yield* s.git(["push", "--force", "origin", "HEAD:refs/heads/parent"]); + yield* s.git(["checkout", "parent"]); + yield* Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* Effect.flip(stack.sync({ apply: true, branch: "child" })); + expect(error.message).toContain("remote tip is not an ancestor"); + expect(yield* s.git(["rev-parse", "child"])).toBe(s.child); + expect(Option.getOrNull(yield* s.adapter.remoteHead("child", "origin"))).toBe(s.child); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(remote); + expect(yield* s.store.readUndo()).toBeNull(); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("undo refuses remote parent changes before restoring any local branches", () => + Effect.gen(function* () { + const s = yield* scenario(); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true, branch: "child" }); + const child = yield* s.git(["rev-parse", "child"]); + yield* s.commit("later-parent"); + yield* s.git(["push", "origin", "parent"]); + const refs = yield* s.git(["show-ref"]); + const error = yield* Effect.flip(stack.undo(true)); + expect(error.message).toContain("remote tip changed since sync"); + expect(yield* s.git(["show-ref"])).toBe(refs); + expect(yield* s.git(["rev-parse", "child"])).toBe(child); + expect(yield* s.store.readUndo()).not.toBeNull(); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("checkpoint failure prevents parent publication and descendant repair", () => + Effect.gen(function* () { + const s = yield* scenario({ + store: () => ({ + writeUndo: () => Effect.fail(new StateError("undo.json", "write", "test failure")), + }), + }); + yield* Effect.gen(function* () { + const stack = yield* Stack; + expect((yield* Effect.flip(stack.sync({ apply: true, branch: "child" })))._tag).toBe( + "StateError", + ); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + expect(yield* s.git(["rev-parse", "child"])).toBe(s.child); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("undo accepts a checkpointed parent push that never happened", () => + Effect.gen(function* () { + const s = yield* scenario({ + service: () => ({ + pushRef: () => Effect.fail(new ExecError("git", ["push"], 1, "test failure")), + }), + }); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* Effect.flip(stack.sync({ apply: true, branch: "child" })); + expect((yield* s.store.readUndo())?.remoteUpdates).toHaveLength(1); + yield* stack.undo(true); + expect(yield* s.store.readUndo()).toBeNull(); + expect(yield* s.git(["rev-parse", "parent"])).toBe(s.tip); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it("still decodes version 1 undo journals", () => { + const journal = { + version: 1, + at: "before-upgrade", + state: { version: 1, links: [] }, + entries: [], + actions: [], + }; + expect(Schema.decodeUnknownSync(UndoState)(journal)).toEqual(journal); +}); + +it.effect("journals and undoes publication even when no descendant needs repair", () => + Effect.gen(function* () { + const s = yield* scenario(); + yield* s.git(["rebase", "parent", "child"]); + yield* s.git(["push", "--force-with-lease", "origin", "child"]); + const child = yield* s.git(["rev-parse", "child"]); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true, branch: "child" }); + const journal = yield* s.store.readUndo(); + expect(journal?.entries).toEqual([]); + expect(journal?.remoteUpdates).toHaveLength(1); + yield* stack.undo(true); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + expect(Option.getOrNull(yield* s.adapter.remoteHead("child", "origin"))).toBe(child); + expect(yield* s.git(["rev-parse", "parent"])).toBe(s.tip); + expect(yield* s.git(["rev-parse", "child"])).toBe(child); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("sync does not republish a parent whose remote is already current", () => + Effect.gen(function* () { + let pushes = 0; + const s = yield* scenario({ + service: (git) => ({ + pushRef: (update) => + Effect.gen(function* () { + pushes += 1; + yield* git.pushRef(update); + }), + }), + }); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true, branch: "child" }); + expect(pushes).toBe(1); + yield* stack.sync({ apply: true, branch: "child" }); + expect(pushes).toBe(1); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("scoped parent publication leaves another stack and trunk unchanged", () => + Effect.gen(function* () { + const s = yield* scenario({ second: true }); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true, branch: "child" }); + expect(Option.getOrNull(yield* s.adapter.remoteHead("other-parent", "origin"))).toBe( + s.parent, + ); + expect(Option.getOrNull(yield* s.adapter.remoteHead("other-child", "origin"))).toBe(s.child); + expect(Option.getOrNull(yield* s.adapter.remoteHead("dev", "origin"))).toBe(s.trunk); + expect(yield* s.git(["rev-parse", "other-child"])).toBe(s.child); + expect((yield* s.store.readUndo())?.remoteUpdates?.map((update) => update.branch)).toEqual([ + "parent", + ]); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("keep-going retains earlier publications in every later checkpoint and undo", () => + Effect.gen(function* () { + const checkpoints: Array = []; + const s = yield* scenario({ + second: true, + store: (store) => ({ + writeUndo: (run) => + Effect.gen(function* () { + checkpoints.push(run); + yield* store.writeUndo(run); + }), + }), + service: (git) => ({ + pushRef: (update) => + update.branch === "parent" + ? Effect.fail(new ExecError("git", ["push"], 1, "test failure")) + : git.pushRef(update), + }), + }); + yield* s.git(["checkout", "dev"]); + yield* Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* Effect.flip(stack.sync({ apply: true, continueOnFailure: true })); + expect(error.message).toContain("1 stack synced, 1 stack failed"); + const later = checkpoints.filter((run) => + run.remoteUpdates?.some((update) => update.branch === "parent"), + ); + expect(later.length).toBeGreaterThan(0); + for (const run of later) { + expect(run.remoteUpdates?.map((update) => update.branch)).toEqual([ + "other-parent", + "parent", + ]); + } + yield* stack.undo(true); + expect(Option.getOrNull(yield* s.adapter.remoteHead("other-parent", "origin"))).toBe( + s.parent, + ); + expect(Option.getOrNull(yield* s.adapter.remoteHead("other-child", "origin"))).toBe(s.child); + expect(yield* s.git(["rev-parse", "other-parent"])).toBe(s.tip); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("undo can resume after restoring only one remote", () => + Effect.gen(function* () { + let undoing = false; + let failed = false; + const s = yield* scenario({ + fork: true, + service: (git) => ({ + pushRef: (update) => { + if (undoing && update.remote === "origin" && !failed) { + failed = true; + return Effect.fail(new ExecError("git", ["push"], 1, "test interruption")); + } + return git.pushRef(update); + }, + }), + }); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true, branch: "child" }); + undoing = true; + yield* Effect.flip(stack.undo(true)); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "fork"))).toBe(s.trunk); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.tip); + expect(yield* s.store.readUndo()).not.toBeNull(); + yield* stack.undo(true); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + expect(yield* s.git(["rev-parse", "parent"])).toBe(s.tip); + expect(yield* s.store.readUndo()).toBeNull(); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("publishes a tracked parent whose open child is only on the remote", () => + Effect.gen(function* () { + const s = yield* scenario({ remoteChild: true }); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.sync({ apply: true, branch: "parent" }); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.tip); + expect(Option.getOrNull(yield* s.adapter.remoteHead("child", "origin"))).toBe(s.child); + expect(Option.isNone(yield* s.adapter.head("child"))).toBe(true); + yield* stack.undo(true); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("unknown push-destination commits give a reconciliation error without mutation", () => + Effect.gen(function* () { + const s = yield* scenario({ fork: true }); + const unknown = yield* s.git([ + "-c", + "user.name=Stack Test", + "-c", + "user.email=stack@example.com", + "--git-dir", + `${s.root}/fork.git`, + "commit-tree", + `${s.trunk}^{tree}`, + "-p", + s.trunk, + "-m", + "only on fork", + ]); + yield* s.git(["--git-dir", `${s.root}/fork.git`, "update-ref", "refs/heads/parent", unknown]); + expect(Option.isNone(yield* s.adapter.head(`${unknown}^{commit}`))).toBe(true); + yield* Effect.gen(function* () { + const stack = yield* Stack; + const error = yield* Effect.flip(stack.sync({ apply: true, branch: "child" })); + expect(error.message).toContain("fetch and reconcile it first"); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "fork"))).toBe(unknown); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + expect(yield* s.git(["rev-parse", "child"])).toBe(s.child); + expect(yield* s.store.readUndo()).toBeNull(); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); + +it.effect("undo restores the first publication when the second destination fails", () => + Effect.gen(function* () { + const s = yield* scenario({ + fork: true, + service: (git) => ({ + pushRef: (update) => + update.remote === "origin" + ? Effect.fail(new ExecError("git", ["push"], 1, "test failure")) + : git.pushRef(update), + }), + }); + yield* Effect.gen(function* () { + const stack = yield* Stack; + yield* Effect.flip(stack.sync({ apply: true, branch: "child" })); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "fork"))).toBe(s.tip); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "origin"))).toBe(s.parent); + expect((yield* s.store.readUndo())?.remoteUpdates).toHaveLength(2); + yield* stack.undo(true); + expect(Option.getOrNull(yield* s.adapter.remoteHead("parent", "fork"))).toBe(s.trunk); + expect(yield* s.git(["rev-parse", "parent"])).toBe(s.tip); + expect(yield* s.git(["rev-parse", "child"])).toBe(s.child); + expect(yield* s.store.readUndo()).toBeNull(); + }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(platform)), +); diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 2fc0e4f..88eb329 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -87,6 +87,8 @@ const gitAndCodeHost = (service: Partial) => remote: () => Effect.succeed(Option.none()), switch: () => Effect.void, head: () => Effect.succeed(Option.none()), + remoteHead: () => Effect.succeed(Option.none()), + pushRef: () => Effect.void, base: () => Effect.succeed(Option.none()), commits: () => Effect.succeed([]), novel: (_parent, _branch, commits) => Effect.succeed(commits), @@ -144,6 +146,7 @@ const stackTestLayer = (opts: { changes: () => Effect.succeed(pulls), current: () => Effect.succeed(opts.current ?? ""), head: (name) => Effect.succeed(Option.fromNullishOr(refsHead(opts.refs, name))), + remoteHead: (name) => Effect.succeed(Option.fromNullishOr(refsHead(opts.refs, name))), base: (branch, parent) => Effect.succeed(Option.fromNullishOr(opts.bases?.[`${branch}:${parent}`])), change: (number) => { @@ -1685,7 +1688,59 @@ describe("GitHub", () => { ); }); - it.effect("tolerates non-JSON warnings emitted before gh pr view output", () => { + it.effect("reads fork identity without depending on gh pr view's version-specific fields", () => { + const calls: Array> = []; + const proc = Layer.succeed( + Proc.Service, + Proc.Service.of({ + exec: (_cwd, tool, args) => + Effect.sync(() => { + calls.push([tool, ...args]); + return JSON.stringify( + args[0] === "api" + ? { + number: 42, + title: "fork change", + body: null, + head: { ref: "feature", repo: { full_name: "Contributor/Project" } }, + base: { ref: "main" }, + html_url: "https://github.com/upstream/project/pull/42", + draft: true, + labels: [{ name: "bug" }], + } + : { + number: 42, + title: "fork change", + body: "", + headRefName: "feature", + headRepository: { id: "repo-id", name: "Project" }, + headRepositoryOwner: { login: "Contributor" }, + baseRefName: "main", + url: "https://github.com/upstream/project/pull/42", + isDraft: true, + labels: [{ name: "bug" }], + }, + ); + }), + }), + ); + + return Effect.gen(function* () { + const github = yield* CodeHost.Service; + const meta = yield* github.change(42); + expect(meta.headRepository).toBe("contributor/project"); + expect(meta.head).toBe("feature"); + expect(meta.base).toBe("main"); + expect(meta.body).toBe(""); + expect(meta.draft).toBe(true); + expect(meta.labels.map((label) => label.name)).toEqual(["bug"]); + expect(calls).toEqual([["gh", "api", "repos/{owner}/{repo}/pulls/42"]]); + }).pipe( + Effect.provide(CodeHostGitHub.layer.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc))), + ); + }); + + it.effect("tolerates non-JSON warnings emitted before GitHub PR detail output", () => { const proc = Layer.succeed( Proc.Service, Proc.Service.of({ @@ -1697,11 +1752,10 @@ describe("GitHub", () => { number: 1, title: "one", body: "body", - headRefName: "one", - headRepository: { nameWithOwner: "owner/project" }, - baseRefName: "main", - url: "u1", - isDraft: false, + head: { ref: "one", repo: { full_name: "owner/project" } }, + base: { ref: "main" }, + html_url: "u1", + draft: false, labels: [], }), ].join("\n"), @@ -1714,6 +1768,64 @@ describe("GitHub", () => { const meta = yield* github.change(1); expect(Number(meta.number)).toBe(1); expect(meta.title).toBe("one"); + expect(meta.body).toBe("body"); + expect(meta.headRepository).toBe("owner/project"); + }).pipe( + Effect.provide(CodeHostGitHub.layer.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc))), + ); + }); + + for (const repo of [null, {}]) { + it.effect( + `GitHub PR details ${repo === null ? "preserve deleted repositories" : "reject missing repository identity"}`, + () => { + const proc = Layer.succeed( + Proc.Service, + Proc.Service.of({ + exec: () => + Effect.succeed( + JSON.stringify({ + number: 1, + title: "one", + body: "body", + head: { ref: "one", repo }, + base: { ref: "main" }, + html_url: "u1", + draft: false, + labels: [], + }), + ), + }), + ); + + return Effect.gen(function* () { + const github = yield* CodeHost.Service; + if (repo === null) { + expect((yield* github.change(1)).headRepository).toBeNull(); + } else { + expect((yield* Effect.flip(github.change(1)))._tag).toBe("CodeHostDecodeError"); + } + }).pipe( + Effect.provide( + CodeHostGitHub.layer.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc)), + ), + ); + }, + ); + } + + it.effect("normalizes missing GitHub PR details", () => { + const proc = Layer.succeed( + Proc.Service, + Proc.Service.of({ + exec: (_cwd, tool, args) => + Effect.fail(new ExecError(tool, args, 1, "HTTP 404: Not Found")), + }), + ); + + return Effect.gen(function* () { + const github = yield* CodeHost.Service; + expect((yield* Effect.flip(github.change(1)))._tag).toBe("CodeHostChangeNotFoundError"); }).pipe( Effect.provide(CodeHostGitHub.layer.pipe(Layer.provideMerge(cfg), Layer.provideMerge(proc))), );