gh-stack 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/git.ts CHANGED
@@ -143,30 +143,92 @@ export async function isAncestor(ancestor: string, descendant: string): Promise<
143
143
  return exitCode === 0;
144
144
  }
145
145
 
146
- /**
147
- * Force-push a branch with lease protection.
148
- */
149
- export async function forcePushWithLease(branch?: string): Promise<boolean> {
150
- try {
151
- if (branch) {
152
- await $`git push --force-with-lease origin ${branch}`;
153
- } else {
154
- await $`git push --force-with-lease`;
155
- }
156
- return true;
157
- } catch {
158
- return false;
146
+ export type PushResult =
147
+ | { ok: true }
148
+ | {
149
+ ok: false;
150
+ /**
151
+ * `not-integrated`: origin's tip was fetched but isn't in the local
152
+ * branch's history (--force-if-includes refused). `stale-lease`: origin
153
+ * moved since the last fetch. `rejected`: anything else.
154
+ */
155
+ reason: "not-integrated" | "stale-lease" | "rejected";
156
+ stderr: string;
157
+ };
158
+
159
+ /**
160
+ * The one way gh-stack force-pushes a branch (restack, sync, merge's advance,
161
+ * submit, join). Sets upstream, and works whether or not the branch exists on
162
+ * origin yet.
163
+ *
164
+ * By default it's `--force-with-lease --force-if-includes`: the lease refuses
165
+ * if origin moved since the last fetch, and --force-if-includes refuses if the
166
+ * fetched tip was never brought into the local branch. That second check is
167
+ * #28's root cause (a collapse's squash commit fetched, never pulled, then
168
+ * overwritten), covered here for every command.
169
+ *
170
+ * `expect` gives the lease an explicit remote sha instead. Use it only for a
171
+ * tip whose content was checked first (see lib/remote-guard.ts): git makes
172
+ * --force-if-includes a no-op then, and the push still fails if origin moved
173
+ * off that sha.
174
+ */
175
+ export async function forcePushWithLease(
176
+ branch: string,
177
+ opts: { expect?: string } = {},
178
+ ): Promise<PushResult> {
179
+ const lease = opts.expect
180
+ ? `--force-with-lease=refs/heads/${branch}:${opts.expect}`
181
+ : "--force-with-lease";
182
+ const { exitCode, stderr } = await $`git push -u ${lease} --force-if-includes origin ${branch}`
183
+ .nothrow()
184
+ .quiet();
185
+ if (exitCode === 0) return { ok: true };
186
+ const text = stderr.toString();
187
+ const reason = text.includes("remote ref updated since checkout")
188
+ ? "not-integrated"
189
+ : text.includes("stale info")
190
+ ? "stale-lease"
191
+ : "rejected";
192
+ return { ok: false, reason, stderr: text };
193
+ }
194
+
195
+ /** Why a force-push was refused, and what to do, in gh-stack terms (plain lines). */
196
+ export function pushFailureHint(branch: string, result: PushResult): string[] {
197
+ if (result.ok) return [];
198
+ switch (result.reason) {
199
+ case "not-integrated":
200
+ return [
201
+ `origin/${branch} has commits you fetched but never brought into local ${branch}, so git refused to overwrite them (--force-if-includes).`,
202
+ `That's how a collapsed PR's squash commit gets lost (#28). See them: git log ${branch}..origin/${branch}`,
203
+ `Bring them in (git pull --rebase origin ${branch}, or cherry-pick), then retry.`,
204
+ `To drop them on purpose: gh-stack restack --overwrite-remote`,
205
+ ];
206
+ case "stale-lease":
207
+ return [
208
+ `origin/${branch} moved since your last fetch, so the lease refused.`,
209
+ `Look at what's there first: git fetch origin ${branch} && git log ${branch}..origin/${branch}`,
210
+ ];
211
+ default:
212
+ return [
213
+ result.stderr
214
+ .trim()
215
+ .split("\n")
216
+ .find((l) => l.includes("rejected") || l.startsWith("error")) ?? result.stderr.trim(),
217
+ ];
159
218
  }
160
219
  }
161
220
 
162
221
  /**
163
- * Push a stacked branch's ref to origin, setting upstream and using
164
- * force-with-lease. Works whether or not the remote branch already exists
165
- * (creates it on first push) — so restack can reconcile every ref, including
166
- * a branch that was never pushed before.
222
+ * Fetch commits from origin without moving any ref: no remote-tracking ref
223
+ * update (`--refmap=`) and no FETCH_HEAD. The tracking refs are what
224
+ * `--force-with-lease` and `--force-if-includes` compare against, so a check
225
+ * that needs origin's objects must never advance them.
167
226
  */
168
- export async function pushStackedBranch(branch: string): Promise<boolean> {
169
- const { exitCode } = await $`git push -u --force-with-lease origin ${branch}`.nothrow().quiet();
227
+ export async function fetchObjectsOnly(refs: string[]): Promise<boolean> {
228
+ if (refs.length === 0) return true;
229
+ const { exitCode } = await $`git fetch --quiet --no-write-fetch-head --refmap= origin ${refs}`
230
+ .nothrow()
231
+ .quiet();
170
232
  return exitCode === 0;
171
233
  }
172
234
 
@@ -264,10 +326,10 @@ export async function deleteTagsMatching(pattern: string): Promise<void> {
264
326
  }
265
327
 
266
328
  /**
267
- * Check if a tag exists.
329
+ * Whether a ref (branch, tag, remote-tracking ref, or any revision) resolves.
268
330
  */
269
- export async function tagExists(name: string): Promise<boolean> {
270
- const { exitCode } = await $`git rev-parse --verify ${name}`.nothrow().quiet();
331
+ export async function refExists(ref: string): Promise<boolean> {
332
+ const { exitCode } = await $`git rev-parse --verify --quiet ${ref}`.nothrow().quiet();
271
333
  return exitCode === 0;
272
334
  }
273
335
 
package/src/lib/github.ts CHANGED
@@ -203,17 +203,75 @@ export async function getPrBody(prNumber: number): Promise<string | null> {
203
203
  * squash-merge lands on this branch. Compare `headRefOid` against the live ref
204
204
  * (`getBranchRefSha`) to detect that staleness — see `merge.ts`.
205
205
  */
206
- export async function getPrMergeState(prNumber: number): Promise<{
206
+ /** A PR's merge state, plus what merging it left behind. */
207
+ export interface PrMergeResult {
207
208
  state: string;
208
209
  mergeable: string;
209
210
  mergeStateStatus: string;
210
211
  headRefName: string;
212
+ /** The PR's head; for a merged PR, its head when it merged. */
211
213
  headRefOid: string;
212
- } | null> {
214
+ /** The branch it targets (or merged into). */
215
+ baseRefName: string;
216
+ /** The commit merging it created on its base (for a squash merge, the squash commit). */
217
+ mergeCommit: string | null;
218
+ }
219
+
220
+ export async function getPrMergeState(prNumber: number): Promise<PrMergeResult | null> {
213
221
  try {
214
- const result =
215
- await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid`.text();
216
- return JSON.parse(result.trim());
222
+ const data = JSON.parse(
223
+ (
224
+ await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid,baseRefName,mergeCommit`.text()
225
+ ).trim(),
226
+ );
227
+ return { ...data, mergeCommit: data.mergeCommit?.oid ?? null };
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /** A commit as a patch (`git format-patch` style), from the GitHub API. Null if unavailable. */
234
+ export async function getCommitPatch(sha: string): Promise<string | null> {
235
+ const nwo = await getRepoNwo();
236
+ if (!nwo) return null;
237
+ const { stdout, exitCode } =
238
+ await $`gh api -H ${"Accept: application/vnd.github.patch"} repos/${nwo}/commits/${sha}`
239
+ .nothrow()
240
+ .quiet();
241
+ const text = stdout.toString();
242
+ return exitCode === 0 && text.trim() ? text : null;
243
+ }
244
+
245
+ /**
246
+ * The files a commit changed (path, status, blob sha after the commit), from
247
+ * the GitHub API. Null if unavailable, or if GitHub truncated the list (it
248
+ * returns at most 300 files): a partial list can't prove anything.
249
+ */
250
+ export async function getCommitFiles(
251
+ sha: string,
252
+ ): Promise<
253
+ { filename: string; status: string; sha: string | null; before?: string | null }[] | null
254
+ > {
255
+ const nwo = await getRepoNwo();
256
+ if (!nwo) return null;
257
+ const { stdout, exitCode } =
258
+ await $`gh api repos/${nwo}/commits/${sha} --jq ${"[.files[] | {filename, status, sha}]"}`
259
+ .nothrow()
260
+ .quiet();
261
+ if (exitCode !== 0) return null;
262
+ try {
263
+ const files: { filename: string; status: string; sha: string | null }[] = JSON.parse(
264
+ stdout.toString(),
265
+ );
266
+ if (!Array.isArray(files) || files.length === 0 || files.length >= 300) return null;
267
+ // The API gives no blob from before the commit, except that an added
268
+ // file had none; a removed file has no blob after it.
269
+ return files.map((f) => ({
270
+ filename: f.filename,
271
+ status: f.status,
272
+ sha: f.status === "removed" ? null : f.sha,
273
+ before: f.status === "added" ? null : undefined,
274
+ }));
217
275
  } catch {
218
276
  return null;
219
277
  }
@@ -11,6 +11,7 @@
11
11
  // • A stack can never silently vanish — on write, a stale stack file is moved
12
12
  // to `deleted/` (a recoverable tombstone), never just unlinked.
13
13
  import type { StackMetadata, Stack, Branch, RestackState } from "../types.ts";
14
+ import { RUN_STATE_VERSION } from "../types.ts";
14
15
  import { mkdir, readdir, rename, unlink, stat } from "node:fs/promises";
15
16
  import {
16
17
  ghStackDir,
@@ -605,7 +606,7 @@ export function getOrderedBranches(stack: Stack): string[] {
605
606
  */
606
607
  export function getChildren(stack: Stack, branchName: string): string[] {
607
608
  return Object.entries(stack.branches)
608
- .filter(([_, b]) => b.parent === branchName)
609
+ .filter(([, b]) => b.parent === branchName)
609
610
  .map(([name]) => name);
610
611
  }
611
612
 
@@ -655,23 +656,49 @@ export async function updateLastBranch(
655
656
  // ── Restack state (for --resume) ──
656
657
 
657
658
  /**
658
- * Save restack state for resume.
659
+ * Save a run's state (resume point + action log). Written after every step, so
660
+ * it goes through a temp file and a rename: a kill mid-write leaves the
661
+ * previous state, never a truncated file.
659
662
  */
660
663
  export async function saveRestackState(state: RestackState): Promise<void> {
661
664
  await ensureDirs();
662
- await Bun.write(await restackStateFile(), JSON.stringify(state, null, 2) + "\n");
665
+ const path = await restackStateFile();
666
+ const tmp = `${path}.tmp`;
667
+ await Bun.write(tmp, JSON.stringify(state, null, 2) + "\n");
668
+ await rename(tmp, path);
663
669
  }
664
670
 
665
671
  /**
666
- * Load restack state for resume.
667
- * Returns null if no state file exists.
672
+ * Load the saved run state. Returns null if there is none. A file that can't
673
+ * be parsed, or was written by another version of gh-stack, throws
674
+ * InvalidRunStateError: it is never guessed at or upgraded.
668
675
  */
669
676
  export async function loadRestackState(): Promise<RestackState | null> {
670
- const file = Bun.file(await restackStateFile());
677
+ const path = await restackStateFile();
678
+ const file = Bun.file(path);
671
679
  if (!(await file.exists())) {
672
680
  return null;
673
681
  }
674
- return file.json();
682
+ let raw: Partial<RestackState>;
683
+ try {
684
+ raw = await file.json();
685
+ } catch {
686
+ throw new InvalidRunStateError(path, "it isn't valid JSON");
687
+ }
688
+ if (raw?.version !== RUN_STATE_VERSION) {
689
+ throw new InvalidRunStateError(path, "it was written by an older gh-stack");
690
+ }
691
+ return raw as RestackState;
692
+ }
693
+
694
+ /** The saved run state exists but can't be used (unreadable, or another version's shape). */
695
+ export class InvalidRunStateError extends Error {
696
+ constructor(
697
+ readonly path: string,
698
+ readonly reason: string,
699
+ ) {
700
+ super(`The saved run state at ${path} can't be used: ${reason}.`);
701
+ }
675
702
  }
676
703
 
677
704
  /**
package/src/lib/output.ts CHANGED
@@ -15,11 +15,32 @@
15
15
  import * as clack from "@clack/prompts";
16
16
 
17
17
  let _plain = false;
18
+ let stderrOnly = false;
18
19
 
19
20
  export function setPlain(enabled: boolean): void {
20
21
  _plain = enabled;
21
22
  }
22
23
 
24
+ /**
25
+ * `--json` mode: plain output, all of it on stderr, so stdout carries only the
26
+ * JSON document the command writes itself.
27
+ */
28
+ export function setStderrOnly(): void {
29
+ _plain = true;
30
+ stderrOnly = true;
31
+ }
32
+
33
+ /** Plain-mode line: stdout normally, stderr in --json mode. */
34
+ function say(line: string): void {
35
+ if (stderrOnly) console.error(line);
36
+ else console.log(line);
37
+ }
38
+
39
+ /** A raw line of output (tree drawings, spacing), routed like the log calls. */
40
+ export function print(line = ""): void {
41
+ say(_plain ? strip(line) : line);
42
+ }
43
+
23
44
  export function isPlain(): boolean {
24
45
  return _plain;
25
46
  }
@@ -65,7 +86,7 @@ export const select: typeof clack.select = (opts) => {
65
86
  // ── Banner / error ──
66
87
  export function intro(message?: string): void {
67
88
  if (_plain) {
68
- if (message) console.log(strip(message));
89
+ if (message) say(strip(message));
69
90
  } else {
70
91
  clack.intro(message);
71
92
  }
@@ -73,7 +94,7 @@ export function intro(message?: string): void {
73
94
 
74
95
  export function outro(message?: string): void {
75
96
  if (_plain) {
76
- if (message) console.log(strip(message));
97
+ if (message) say(strip(message));
77
98
  } else {
78
99
  clack.outro(message);
79
100
  }
@@ -89,11 +110,11 @@ export function cancel(message?: string): void {
89
110
 
90
111
  // ── log.* ──
91
112
  export const log = {
92
- info: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.info(msg)),
93
- success: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.success(msg)),
113
+ info: (msg: string) => (_plain ? say(strip(msg)) : clack.log.info(msg)),
114
+ success: (msg: string) => (_plain ? say(strip(msg)) : clack.log.success(msg)),
94
115
  warn: (msg: string) => (_plain ? console.warn(strip(msg)) : clack.log.warn(msg)),
95
116
  error: (msg: string) => (_plain ? console.error(strip(msg)) : clack.log.error(msg)),
96
- step: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.step(msg)),
117
+ step: (msg: string) => (_plain ? say(strip(msg)) : clack.log.step(msg)),
97
118
  };
98
119
 
99
120
  // ── Spinner ──
@@ -107,13 +128,13 @@ export function spinner(): Spinner {
107
128
  if (!_plain) return clack.spinner() as Spinner;
108
129
  return {
109
130
  start: (msg) => {
110
- if (msg) console.log(strip(msg));
131
+ if (msg) say(strip(msg));
111
132
  },
112
133
  stop: (msg) => {
113
- if (msg) console.log(strip(msg));
134
+ if (msg) say(strip(msg));
114
135
  },
115
136
  message: (msg) => {
116
- if (msg) console.log(strip(msg));
137
+ if (msg) say(strip(msg));
117
138
  },
118
139
  };
119
140
  }
package/src/lib/paths.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // archived/<stack>.json merged/closed stacks
7
7
  // deleted/<stack>.json tombstones (recoverable)
8
8
  // snapshots/<ts>__<stack>.json append-only pre-operation branch tips
9
- // restack-state.json in-flight restack/sync resume state
9
+ // restack-state.json last restack/sync/join run: resume point + action log
10
10
  //
11
11
  // Everything lives inside `.git/` and is never committed — gh-stack is a
12
12
  // single-developer tool, so there's no need to share metadata across clones.
@@ -0,0 +1,281 @@
1
+ // Before a run rewrites branches, make sure origin doesn't hold content the
2
+ // local branches lack (#28).
3
+ //
4
+ // The incident: `merge --collapse` squash-merged PR2 into PR1's branch on
5
+ // GitHub. The local PR1 never pulled that squash commit, a `sync` rebased the
6
+ // local PR1 and force-pushed it, and the base PR silently lost every child's
7
+ // content. `--force-with-lease` doesn't protect against this once the
8
+ // remote-tracking ref has been fetched.
9
+ //
10
+ // The hard part is not crying wolf. After a `--no-push` restack, or a run
11
+ // interrupted before its pushes, origin legitimately holds the *pre-rewrite*
12
+ // versions of local commits. Those aren't new: local had them and rewrote them
13
+ // on purpose. So a commit on origin counts as new content only if
14
+ // 1. it was never in the local branch's history (its reflog, or a tip a
15
+ // snapshot recorded), and
16
+ // 2. it isn't patch-equivalent to a local commit.
17
+ import { $ } from "bun";
18
+ import pc from "picocolors";
19
+ import * as p from "./output.ts";
20
+ import * as git from "./git.ts";
21
+ import { isAutoYes } from "./ui.ts";
22
+ import type { StackMetadata } from "../types.ts";
23
+
24
+ /** A branch whose origin copy differs but couldn't be checked. */
25
+ export interface Unverified {
26
+ branch: string;
27
+ remoteSha: string;
28
+ reason: string;
29
+ }
30
+
31
+ export interface RemoteOnlyContent {
32
+ branch: string;
33
+ localSha: string;
34
+ remoteSha: string;
35
+ /** "sha subject", oldest first. */
36
+ commits: string[];
37
+ /** Local is simply behind origin (a fast-forward brings the commits in). */
38
+ behind: boolean;
39
+ }
40
+
41
+ /** How many reflog entries count as "versions local already had". */
42
+ const REFLOG_DEPTH = 300;
43
+
44
+ /**
45
+ * For each branch with a copy on origin that differs from local, the commits
46
+ * origin has that are new content (see the module comment). `cleared` maps the
47
+ * branches whose origin copy differs but holds nothing new to the origin tip
48
+ * that was checked. `unverified` lists branches whose origin copy couldn't be
49
+ * checked (not fetched, or git failed): never cleared, the caller fails closed.
50
+ */
51
+ export async function findRemoteOnlyContent(
52
+ meta: StackMetadata,
53
+ branches: string[],
54
+ ): Promise<{
55
+ found: RemoteOnlyContent[];
56
+ cleared: Map<string, string>;
57
+ unverified: Unverified[];
58
+ }> {
59
+ const found: RemoteOnlyContent[] = [];
60
+ const cleared = new Map<string, string>();
61
+ const unverified: Unverified[] = [];
62
+ const heads = await git.remoteHeads();
63
+ const trunk = await git.trunkBranch();
64
+
65
+ const targets: { branch: string; local: string; remote: string }[] = [];
66
+ for (const branch of new Set(branches)) {
67
+ if (branch === trunk || branch === "main" || branch === "master") continue;
68
+ const remote = heads.get(branch);
69
+ if (!remote || !(await git.localBranchExists(branch))) continue;
70
+ const local = await git.revParse(branch);
71
+ if (remote !== local) targets.push({ branch, local, remote });
72
+ }
73
+ if (targets.length === 0) return { found, cleared, unverified };
74
+
75
+ // Origin's trunk too: commits that came from it (GitHub's "Update branch"
76
+ // merges main into a PR branch) aren't the user's to lose.
77
+ const trunkTip = heads.get(trunk);
78
+ await git.fetchObjectsOnly([
79
+ ...targets.map((t) => `refs/heads/${t.branch}`),
80
+ ...(trunkTip ? [`refs/heads/${trunk}`] : []),
81
+ ]);
82
+
83
+ for (const { branch, local, remote } of targets) {
84
+ if (!(await git.refExists(`${remote}^{commit}`))) {
85
+ unverified.push({
86
+ branch,
87
+ remoteSha: remote,
88
+ reason: `origin/${branch} couldn't be fetched`,
89
+ });
90
+ continue;
91
+ }
92
+ if (await git.isAncestor(remote, local)) {
93
+ cleared.set(branch, remote); // local already has all of it
94
+ continue;
95
+ }
96
+
97
+ const known = [
98
+ local,
99
+ ...(await reflogTips(branch)),
100
+ ...snapshotTips(meta, branch),
101
+ ...(trunkTip ? [trunkTip] : []),
102
+ ];
103
+ const candidates = await revListLines([
104
+ "--ignore-missing",
105
+ "--reverse",
106
+ "--no-merges",
107
+ remote,
108
+ "--not",
109
+ ...new Set(known),
110
+ ]);
111
+ const unmatched = await revListLines([
112
+ "--cherry-pick",
113
+ "--right-only",
114
+ "--no-merges",
115
+ `${local}...${remote}`,
116
+ ]);
117
+ if (candidates === null || unmatched === null) {
118
+ // No verdict is not a clean verdict: this branch stays unchecked.
119
+ unverified.push({ branch, remoteSha: remote, reason: "git rev-list failed comparing it" });
120
+ continue;
121
+ }
122
+ const fresh = candidates.filter((sha) => unmatched.includes(sha));
123
+ if (fresh.length === 0) {
124
+ cleared.set(branch, remote);
125
+ continue;
126
+ }
127
+ found.push({
128
+ branch,
129
+ localSha: local,
130
+ remoteSha: remote,
131
+ commits: await oneLines(fresh),
132
+ behind: await git.isAncestor(local, remote),
133
+ });
134
+ }
135
+ return { found, cleared, unverified };
136
+ }
137
+
138
+ /** Earlier tips of `refs/heads/<branch>`, newest first. */
139
+ async function reflogTips(branch: string): Promise<string[]> {
140
+ const { stdout } = await $`git reflog show --format=%H refs/heads/${branch}`.nothrow().quiet();
141
+ return stdout.toString().split("\n").filter(Boolean).slice(0, REFLOG_DEPTH);
142
+ }
143
+
144
+ /** Tips of `branch` that snapshots recorded (the pre-rewrite versions). */
145
+ function snapshotTips(meta: StackMetadata, branch: string): string[] {
146
+ const tips = new Set<string>();
147
+ for (const snap of meta.snapshots ?? []) {
148
+ const sha = snap.branches[branch];
149
+ if (sha) tips.add(sha);
150
+ }
151
+ return [...tips];
152
+ }
153
+
154
+ /** `git rev-list` output, or null if it failed (never an empty "nothing found"). */
155
+ async function revListLines(args: string[]): Promise<string[] | null> {
156
+ const { stdout, exitCode } = await $`git rev-list ${args}`.nothrow().quiet();
157
+ if (exitCode !== 0) return null;
158
+ return stdout.toString().split("\n").filter(Boolean);
159
+ }
160
+
161
+ /** "abbrev subject" for each sha, in the given order, from one `git log`. */
162
+ async function oneLines(shas: string[]): Promise<string[]> {
163
+ const { stdout } = await $`git log --no-walk=unsorted --format=${"%h %s"} ${shas}`
164
+ .nothrow()
165
+ .quiet();
166
+ return stdout.toString().split("\n").filter(Boolean);
167
+ }
168
+
169
+ /**
170
+ * Stop a run before it rewrites a branch whose origin copy has new content.
171
+ * Interactive: ask (default no). Under --yes: refuse, since no flag the user
172
+ * gave said "drop commits from origin". `--overwrite-remote` proceeds. A dry
173
+ * run only reports.
174
+ */
175
+ export async function guardRemoteContent(
176
+ meta: StackMetadata,
177
+ branches: string[],
178
+ opts: { command: string; overwrite: boolean; dryRun: boolean },
179
+ ): Promise<Map<string, string>> {
180
+ const { found, cleared, unverified } = await findRemoteOnlyContent(meta, branches);
181
+ if (found.length === 0 && unverified.length === 0) return cleared;
182
+
183
+ if (unverified.length > 0) {
184
+ console.log();
185
+ p.log.error(
186
+ "Couldn't check these branches' copies on origin for commits the local branches lack:",
187
+ );
188
+ for (const u of unverified) {
189
+ console.log(
190
+ ` ${pc.yellow(u.branch)} ${pc.dim(`(origin ${u.remoteSha.slice(0, 8)}: ${u.reason})`)}`,
191
+ );
192
+ }
193
+ console.log();
194
+ console.log(
195
+ ` ${opts.command} would rebase or force-push them without knowing what's there. Look first:`,
196
+ );
197
+ for (const u of unverified) {
198
+ console.log(
199
+ ` ${pc.green(`git fetch origin ${u.branch} && git log ${u.branch}..origin/${u.branch}`)}`,
200
+ );
201
+ }
202
+ console.log(` Then re-run ${pc.green(`gh-stack ${opts.command}`)}.`);
203
+ console.log(
204
+ ` Or, to overwrite them regardless: ${pc.green(`gh-stack ${opts.command} --overwrite-remote`)}`,
205
+ );
206
+ }
207
+ if (found.length === 0) return decide(cleared, [], unverified, opts);
208
+
209
+ console.log();
210
+ p.log.error(
211
+ "Origin has commits that the local branches don't, and they aren't earlier versions of local commits:",
212
+ );
213
+ for (const f of found) {
214
+ console.log(
215
+ ` ${pc.yellow(f.branch)} ${pc.dim(`(origin ${f.remoteSha.slice(0, 8)}, local ${f.localSha.slice(0, 8)})`)}`,
216
+ );
217
+ for (const c of f.commits) console.log(` ${pc.red("+")} ${c}`);
218
+ }
219
+ console.log();
220
+ console.log(
221
+ ` ${opts.command} would rebase or force-push ${found.length === 1 ? "this branch" : "these branches"} without them, and they'd be lost from origin and the PR.`,
222
+ );
223
+ console.log(
224
+ " This is what happens after `merge --collapse` squashes a child PR into its parent on GitHub.",
225
+ );
226
+ console.log();
227
+ console.log(" Bring them into the local branch first:");
228
+ for (const f of found) {
229
+ const shas = f.commits.map((c) => c.split(" ")[0]).join(" ");
230
+ console.log(
231
+ ` ${pc.green(f.behind ? `git checkout ${f.branch} && git pull --ff-only origin ${f.branch}` : `git checkout ${f.branch} && git cherry-pick ${shas}`)}`,
232
+ );
233
+ }
234
+ console.log(` Then re-run ${pc.green(`gh-stack ${opts.command}`)}.`);
235
+ console.log(
236
+ ` Or, to drop them on purpose: ${pc.green(`gh-stack ${opts.command} --overwrite-remote`)}`,
237
+ );
238
+ return decide(cleared, found, unverified, opts);
239
+ }
240
+
241
+ /**
242
+ * What to do about branches with new or unchecked content on origin: report
243
+ * (dry run), proceed (--overwrite-remote, or an interactive yes), or stop
244
+ * (--yes, or no). Proceeding clears their checked tips for an explicit lease.
245
+ */
246
+ async function decide(
247
+ cleared: Map<string, string>,
248
+ found: RemoteOnlyContent[],
249
+ unverified: Unverified[],
250
+ opts: { overwrite: boolean; dryRun: boolean },
251
+ ): Promise<Map<string, string>> {
252
+ console.log();
253
+ if (opts.dryRun) {
254
+ p.log.warn("[DRY RUN] A real run would stop here.");
255
+ return cleared;
256
+ }
257
+ const overwrite = () => {
258
+ for (const f of found) cleared.set(f.branch, f.remoteSha);
259
+ for (const u of unverified) cleared.set(u.branch, u.remoteSha);
260
+ return cleared;
261
+ };
262
+ if (opts.overwrite) {
263
+ p.log.warn(
264
+ "--overwrite-remote: continuing. Origin's copies of those branches will be overwritten.",
265
+ );
266
+ return overwrite();
267
+ }
268
+ if (isAutoYes()) {
269
+ p.cancel("Stopped before rewriting anything (--yes never overwrites what it couldn't check).");
270
+ process.exit(1);
271
+ }
272
+ const ok = await p.confirm({
273
+ message: "Rewrite anyway, overwriting origin's copies of those branches?",
274
+ initialValue: false,
275
+ });
276
+ if (p.isCancel(ok) || !ok) {
277
+ p.cancel("Stopped before rewriting anything.");
278
+ process.exit(1);
279
+ }
280
+ return overwrite();
281
+ }