gh-stack 0.17.2 → 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
 
@@ -346,6 +408,20 @@ export async function allLocalBranches(): Promise<string[]> {
346
408
  return result.trim().split("\n").filter(Boolean);
347
409
  }
348
410
 
411
+ /**
412
+ * Map every local branch to its tip SHA, in one call.
413
+ */
414
+ export async function localBranchTips(): Promise<Map<string, string>> {
415
+ const result =
416
+ await $`git for-each-ref --format=${"%(refname:short) %(objectname)"} refs/heads`.text();
417
+ const tips = new Map<string, string>();
418
+ for (const line of result.trim().split("\n").filter(Boolean)) {
419
+ const [name, sha] = line.split(" ");
420
+ if (name && sha) tips.set(name, sha);
421
+ }
422
+ return tips;
423
+ }
424
+
349
425
  /**
350
426
  * Determine the trunk branch name. Prefers "main", falls back to "master"
351
427
  * if only that exists locally. Defaults to "main" when neither is present.
package/src/lib/github.ts CHANGED
@@ -40,6 +40,26 @@ export async function getPrNumber(branch: string): Promise<number | null> {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * Find the open PR whose head is `branch`, for adopting a PR opened outside
45
+ * gh-stack. Unlike getPrNumber, returns null unless there is exactly one match
46
+ * — a fork with the same branch name must never be adopted by accident.
47
+ */
48
+ export async function findOpenPrForBranch(branch: string): Promise<number | null> {
49
+ try {
50
+ const out =
51
+ await $`gh pr list --head ${branch} --state open --json number --jq '.[].number'`.text();
52
+ const nums = out
53
+ .trim()
54
+ .split(/\s+/)
55
+ .filter(Boolean)
56
+ .map((n) => parseInt(n, 10));
57
+ return nums.length === 1 && !isNaN(nums[0]!) ? nums[0]! : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
43
63
  /**
44
64
  * Get PR info for a specific PR number.
45
65
  */
@@ -183,17 +203,75 @@ export async function getPrBody(prNumber: number): Promise<string | null> {
183
203
  * squash-merge lands on this branch. Compare `headRefOid` against the live ref
184
204
  * (`getBranchRefSha`) to detect that staleness — see `merge.ts`.
185
205
  */
186
- export async function getPrMergeState(prNumber: number): Promise<{
206
+ /** A PR's merge state, plus what merging it left behind. */
207
+ export interface PrMergeResult {
187
208
  state: string;
188
209
  mergeable: string;
189
210
  mergeStateStatus: string;
190
211
  headRefName: string;
212
+ /** The PR's head; for a merged PR, its head when it merged. */
191
213
  headRefOid: string;
192
- } | 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> {
193
221
  try {
194
- const result =
195
- await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid`.text();
196
- 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
+ }));
197
275
  } catch {
198
276
  return null;
199
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
@@ -7,17 +7,40 @@
7
7
  // - no ANSI colors (picocolors auto-disables via NO_COLOR; we also strip
8
8
  // any residual escapes defensively)
9
9
  //
10
- // Interactive prompts (text, confirm, select) are re-exported unchanged —
10
+ // Interactive prompts (text, confirm, select) are unchanged by plain mode —
11
11
  // plain mode is about output, not interactivity. Agents already pair --plain
12
- // with --yes/GH_STACK_YES so the interactive paths aren't reached.
12
+ // with --yes/GH_STACK_YES so the interactive paths aren't reached. If one IS
13
+ // reached with no TTY on stdin, nothing can ever answer it, so we fail fast
14
+ // instead of blocking forever (#33).
13
15
  import * as clack from "@clack/prompts";
14
16
 
15
17
  let _plain = false;
18
+ let stderrOnly = false;
16
19
 
17
20
  export function setPlain(enabled: boolean): void {
18
21
  _plain = enabled;
19
22
  }
20
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
+
21
44
  export function isPlain(): boolean {
22
45
  return _plain;
23
46
  }
@@ -27,16 +50,43 @@ function strip(s: string | undefined): string {
27
50
  return (s ?? "").replace(ANSI_RE, "");
28
51
  }
29
52
 
30
- // ── Interactive prompts (unchanged) ──
53
+ // ── Interactive prompts ──
31
54
  export const isCancel = clack.isCancel;
32
- export const text = clack.text;
33
- export const confirm = clack.confirm;
34
- export const select = clack.select;
55
+
56
+ /**
57
+ * Exit non-zero instead of prompting when stdin isn't a TTY. A prompt there
58
+ * can never be answered, and it would otherwise hang (holding the repo) past
59
+ * the caller's timeout. (#33)
60
+ */
61
+ function ensureInteractive(message: string): void {
62
+ if (process.stdin.isTTY) return;
63
+ console.error(
64
+ `gh-stack: refusing to prompt — stdin is not a TTY.\n` +
65
+ ` Prompt: ${strip(message)}\n` +
66
+ ` Pass --yes (or set GH_STACK_YES=1) to confirm automatically.`,
67
+ );
68
+ process.exit(1);
69
+ }
70
+
71
+ export const text: typeof clack.text = (opts) => {
72
+ ensureInteractive(opts.message);
73
+ return clack.text(opts);
74
+ };
75
+
76
+ export const confirm: typeof clack.confirm = (opts) => {
77
+ ensureInteractive(opts.message);
78
+ return clack.confirm(opts);
79
+ };
80
+
81
+ export const select: typeof clack.select = (opts) => {
82
+ ensureInteractive(opts.message);
83
+ return clack.select(opts);
84
+ };
35
85
 
36
86
  // ── Banner / error ──
37
87
  export function intro(message?: string): void {
38
88
  if (_plain) {
39
- if (message) console.log(strip(message));
89
+ if (message) say(strip(message));
40
90
  } else {
41
91
  clack.intro(message);
42
92
  }
@@ -44,7 +94,7 @@ export function intro(message?: string): void {
44
94
 
45
95
  export function outro(message?: string): void {
46
96
  if (_plain) {
47
- if (message) console.log(strip(message));
97
+ if (message) say(strip(message));
48
98
  } else {
49
99
  clack.outro(message);
50
100
  }
@@ -60,11 +110,11 @@ export function cancel(message?: string): void {
60
110
 
61
111
  // ── log.* ──
62
112
  export const log = {
63
- info: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.info(msg)),
64
- 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)),
65
115
  warn: (msg: string) => (_plain ? console.warn(strip(msg)) : clack.log.warn(msg)),
66
116
  error: (msg: string) => (_plain ? console.error(strip(msg)) : clack.log.error(msg)),
67
- step: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.step(msg)),
117
+ step: (msg: string) => (_plain ? say(strip(msg)) : clack.log.step(msg)),
68
118
  };
69
119
 
70
120
  // ── Spinner ──
@@ -78,13 +128,13 @@ export function spinner(): Spinner {
78
128
  if (!_plain) return clack.spinner() as Spinner;
79
129
  return {
80
130
  start: (msg) => {
81
- if (msg) console.log(strip(msg));
131
+ if (msg) say(strip(msg));
82
132
  },
83
133
  stop: (msg) => {
84
- if (msg) console.log(strip(msg));
134
+ if (msg) say(strip(msg));
85
135
  },
86
136
  message: (msg) => {
87
- if (msg) console.log(strip(msg));
137
+ if (msg) say(strip(msg));
88
138
  },
89
139
  };
90
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.