gh-stack 0.19.9 → 0.19.10

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/README.md CHANGED
@@ -731,6 +731,14 @@ you at `doctor`.
731
731
 
732
732
  Before any destructive operation (restack, sync, merge, delete), gh-stack saves a snapshot of all branch HEADs as an append-only file under `snapshots/`, retained per-stack. Run `gh-stack undo` to restore.
733
733
 
734
+ ### Busy Repositories (git locks)
735
+
736
+ Git doesn't wait for a lock: a checkout or rebase that finds `index.lock` held fails at once. The usual holder is a background `git status` (an editor, a watcher, another agent session), which takes the lock for a few milliseconds. So every git step gh-stack runs that takes a lock (checkout, rebase, reset, commit, branch and tag writes, branch config) waits and retries: after 0.1, 0.2, 0.4, 0.8, 1.6 and 2 seconds, about 5 seconds in all. A rebase that a lock interrupts midway is continued, not reported as a conflict.
737
+
738
+ If the lock is still held after that, gh-stack names it and stops with exit 1, never "Rebase Conflict". Nothing conflicted, and the step changed nothing: once the lock is gone, run the command again, or `gh-stack restack --resume` for an interrupted restack or sync. A lock left by a crashed git process stays until you remove it.
739
+
740
+ gh-stack also runs its own git calls with `GIT_OPTIONAL_LOCKS=0`, so its reads never take the lock.
741
+
734
742
  ## Example Workflow
735
743
 
736
744
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.9",
3
+ "version": "0.19.10",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -277,7 +277,7 @@ export default async function join(args: string[]): Promise<void> {
277
277
  await verifyResult(plan);
278
278
 
279
279
  if (startBranch && startBranch !== (await git.currentBranch().catch(() => null))) {
280
- await $`git checkout ${startBranch}`.nothrow().quiet();
280
+ await git.tryCheckout(startBranch);
281
281
  }
282
282
 
283
283
  const endMeta = await freshMeta();
@@ -226,8 +226,11 @@ export default async function submit(args: string[]): Promise<void> {
226
226
  await runSubmit(args, run, jsonMode);
227
227
  } catch (err) {
228
228
  // A ConfigError (bad config file or env value) is an expected stop too: in
229
- // --json mode it becomes the document's error, like any refusal.
230
- const expected = err instanceof SubmitAbort || err instanceof ConfigError;
229
+ // --json mode it becomes the document's error, like any refusal. So is a
230
+ // git lock that outlasted the retries; outside --json, index.ts explains it.
231
+ if (!jsonMode && err instanceof git.GitLockError) throw err;
232
+ const expected =
233
+ err instanceof SubmitAbort || err instanceof ConfigError || err instanceof git.GitLockError;
231
234
  if (!jsonMode && !expected) throw err;
232
235
  if (!expected) console.error(err);
233
236
  error = (err as Error).message;
@@ -6,7 +6,6 @@ import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
6
6
  import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
7
7
  import { clearRestackState, loadRestackState } from "../lib/metadata.ts";
8
8
  import { confirmAction } from "../lib/ui.ts";
9
- import { $ } from "bun";
10
9
 
11
10
  export default async function undo(args: string[]): Promise<void> {
12
11
  if (args.includes("--help")) {
@@ -60,9 +59,9 @@ a destructive operation (restack, merge, sync, remove).
60
59
  for (const [branch, sha] of Object.entries(snapshot.branches)) {
61
60
  try {
62
61
  if (branch === activeBranch) {
63
- await $`git reset --hard ${sha}`.quiet();
62
+ await git.runGitOrThrow(["reset", "--hard", sha]);
64
63
  } else {
65
- await $`git branch -f ${branch} ${sha}`.quiet();
64
+ await git.runGitOrThrow(["branch", "-f", branch, sha]);
66
65
  }
67
66
  p.log.success(`Restored ${pc.yellow(branch)} → ${sha.slice(0, 8)}`);
68
67
  } catch {
package/src/index.ts CHANGED
@@ -19,6 +19,12 @@ const colorOff =
19
19
  process.env.GH_STACK_PLAIN === "1";
20
20
  if (colorOff) process.env.NO_COLOR = "1";
21
21
 
22
+ // gh-stack's own read-only git calls (status, diff) shouldn't take index.lock
23
+ // to save a refreshed stat cache: that only contends with our own writes and
24
+ // with a gh-stack running in another worktree (#69). Bun's shell reads
25
+ // process.env at each call, so this reaches every git we run.
26
+ process.env.GIT_OPTIONAL_LOCKS ??= "0";
27
+
22
28
  const { ensureGitRepo } = await import("./lib/safety.ts");
23
29
  const { parseCliArgs } = await import("./lib/cli.ts");
24
30
  const { setAutoYes } = await import("./lib/ui.ts");
@@ -64,6 +70,7 @@ const commandArgs = parsed.commandArgs;
64
70
  // A config problem (malformed TOML, a non-boolean env value) is reported as
65
71
  // one clean line, whichever command hit it.
66
72
  const { ConfigError } = await import("./lib/config.ts");
73
+ const { GitLockError } = await import("./lib/git.ts");
67
74
  try {
68
75
  switch (command) {
69
76
  case "log":
@@ -183,11 +190,39 @@ try {
183
190
  process.exit(1);
184
191
  }
185
192
  } catch (err) {
193
+ if (err instanceof GitLockError) await reportHeldLock(err);
186
194
  if (!(err instanceof ConfigError)) throw err;
187
195
  console.error(`gh-stack: ${err.message}`);
188
196
  process.exit(1);
189
197
  }
190
198
 
199
+ /**
200
+ * Another git process held a lock past every retry (#69). Say so plainly:
201
+ * nothing conflicted, and the step runs again once the lock is gone.
202
+ */
203
+ async function reportHeldLock(err: InstanceType<typeof GitLockError>): Promise<never> {
204
+ const { findPendingRun } = await import("./lib/run-log.ts");
205
+ const pending = await findPendingRun().catch(() => null);
206
+ const lines = [
207
+ `gh-stack: another git process holds ${err.lockPath}, and still did after waiting ${(err.waitedMs / 1000).toFixed(1)}s.`,
208
+ ` Nothing conflicted: ${err.command} couldn't take the lock, so it changed nothing.`,
209
+ ];
210
+ if (err.rebaseInProgress) {
211
+ lines.push(
212
+ ` The rebase is paused, not conflicted. Once the lock is gone:`,
213
+ ` git rebase --continue`,
214
+ );
215
+ }
216
+ lines.push(
217
+ pending
218
+ ? ` Once the lock is gone, pick the run up where it stopped:\n gh-stack restack --resume`
219
+ : ` Once the lock is gone, run the command again.`,
220
+ ` If no other git process is running, the lock is stale; remove it:\n rm ${err.lockPath}`,
221
+ );
222
+ console.error(lines.join("\n"));
223
+ process.exit(1);
224
+ }
225
+
191
226
  function printHelp() {
192
227
  console.log(`
193
228
  gh-stack v${VERSION} — Stacked PR manager for squash-merge workflows
@@ -27,6 +27,7 @@
27
27
  import { $ } from "bun";
28
28
  import { existsSync, readFileSync } from "node:fs";
29
29
  import * as path from "node:path";
30
+ import { runGit } from "./git.ts";
30
31
 
31
32
  export interface BranchMembership {
32
33
  stack?: string;
@@ -68,12 +69,16 @@ async function setOrUnset(branch: string, key: string, value: string | undefined
68
69
  await unsetKey(branch, key);
69
70
  return;
70
71
  }
71
- await $`git config ${configKey(branch, key)} ${value}`.quiet().nothrow();
72
+ // The repo's config is shared by every worktree, so another gh-stack (or any
73
+ // `git config` write) can hold config.lock for a moment: runGit waits it out
74
+ // instead of silently dropping the write.
75
+ await runGit(["config", configKey(branch, key), value]);
72
76
  }
73
77
 
74
78
  async function unsetKey(branch: string, key: string): Promise<void> {
75
- // `--unset` exits 5 when the key is already absent — harmless, so nothrow.
76
- await $`git config --unset ${configKey(branch, key)}`.quiet().nothrow();
79
+ // `--unset` exits 5 when the key is already absent — harmless, so the exit
80
+ // code is ignored.
81
+ await runGit(["config", "--unset", configKey(branch, key)]);
77
82
  }
78
83
 
79
84
  /**
package/src/lib/git.ts CHANGED
@@ -2,10 +2,168 @@
2
2
  import { $ } from "bun";
3
3
  import { createHash } from "node:crypto";
4
4
  import { mkdir } from "node:fs/promises";
5
+ import pc from "picocolors";
5
6
 
6
7
  export const STACK_SYNC_TAG_PREFIX = "stack-sync-base";
7
8
  export const STACK_SYNC_TAG_GLOB = `${STACK_SYNC_TAG_PREFIX}-*`;
8
9
 
10
+ // ── Lock contention (#69) ──────────────────────────────────────────────────
11
+ //
12
+ // Git doesn't wait for a lock: a checkout, rebase or config write that finds
13
+ // `index.lock` (or `config.lock`, a ref lock) already present fails at once.
14
+ // The usual holder is a background reader (an editor's or agent's `git
15
+ // status`) that grabs index.lock for a few milliseconds to save a refreshed
16
+ // stat cache, so the lock is gone almost immediately. Every gh-stack git call
17
+ // that takes a lock goes through runGit, which waits and retries instead.
18
+
19
+ /** git's message for a lock file that already exists (lockfile.c). */
20
+ const LOCK_HELD = /Unable to create '([^']+\.lock)': File exists/;
21
+ /**
22
+ * `git config` words it differently, naming the config file rather than its
23
+ * lock (and it already waits about a second on its own before failing).
24
+ */
25
+ const CONFIG_LOCK_HELD = /could not lock config file (.+): File exists/;
26
+
27
+ /** Waits between attempts: quick at first, then backing off (~5 s in all). */
28
+ const DEFAULT_LOCK_RETRY_DELAYS_MS = [100, 200, 400, 800, 1600, 2000];
29
+ let lockRetryDelaysMs = DEFAULT_LOCK_RETRY_DELAYS_MS;
30
+
31
+ /** Shorten the retry schedule in tests; `null` restores the default. */
32
+ export function setLockRetryDelays(delays: number[] | null): void {
33
+ lockRetryDelaysMs = delays ?? DEFAULT_LOCK_RETRY_DELAYS_MS;
34
+ }
35
+
36
+ /** The lock file another process holds, if git's output says one blocked it. */
37
+ export function heldLock(output: string): string | null {
38
+ const lock = LOCK_HELD.exec(output)?.[1];
39
+ if (lock) return lock;
40
+ const config = CONFIG_LOCK_HELD.exec(output)?.[1];
41
+ return config ? `${config}.lock` : null;
42
+ }
43
+
44
+ /**
45
+ * A lock was still held after every retry. Nothing conflicted: the step can
46
+ * simply run again once the other git process lets go.
47
+ */
48
+ export class GitLockError extends Error {
49
+ constructor(
50
+ readonly lockPath: string,
51
+ readonly command: string,
52
+ /** The lock interrupted a rebase midway; it's paused, not conflicted. */
53
+ readonly rebaseInProgress: boolean,
54
+ /** How long gh-stack waited for it before giving up. */
55
+ readonly waitedMs: number,
56
+ ) {
57
+ super(`another git process holds ${lockPath} (${command})`);
58
+ this.name = "GitLockError";
59
+ }
60
+ }
61
+
62
+ export interface GitResult {
63
+ exitCode: number;
64
+ stdout: string;
65
+ stderr: string;
66
+ }
67
+
68
+ /**
69
+ * Run `git <args>`, retrying while another process holds a lock it needs.
70
+ * Retrying is safe because git takes the lock before changing anything: the
71
+ * failed attempt left the repo as it was. Throws GitLockError when retries
72
+ * run out; any other failure is returned for the caller to judge.
73
+ *
74
+ * `echo` replays git's output to the terminal (rebase progress and conflict
75
+ * messages), leaving out the attempts a lock blocked.
76
+ */
77
+ export async function runGit(args: string[], opts: { echo?: boolean } = {}): Promise<GitResult> {
78
+ return retryLocks(args, opts.echo ?? false, async () => ({ next: args }));
79
+ }
80
+
81
+ /** Run `git <args>` with lock retries, throwing if it fails for another reason. */
82
+ export async function runGitOrThrow(args: string[], opts: { echo?: boolean } = {}): Promise<void> {
83
+ const { exitCode, stderr } = await runGit(args, opts);
84
+ if (exitCode !== 0) {
85
+ throw new Error(`git ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim()}`);
86
+ }
87
+ }
88
+
89
+ interface Retry {
90
+ next: string[];
91
+ /** The retry resumes a rebase that a lock paused midway. */
92
+ resumesRebase?: boolean;
93
+ }
94
+
95
+ /**
96
+ * A rebase, retried around lock contention. A lock held when the rebase
97
+ * starts fails it before anything happens ("could not detach HEAD"), so it
98
+ * runs again as is. A lock that turns up midway makes git reschedule the
99
+ * pick it couldn't apply and pause, with nothing conflicted: `git rebase
100
+ * --continue` picks it up. Returns false only for a real conflict.
101
+ * (Exported for tests; commands use rebaseOnto and rebase.)
102
+ */
103
+ export async function runRebase(args: string[]): Promise<boolean> {
104
+ const result = await retryLocks(args, true, async () => {
105
+ if (!(await isRebaseInProgress())) return { next: args };
106
+ // A conflict leaves unmerged paths; a lock-paused rebase doesn't.
107
+ if (await hasUnmergedPaths()) return null;
108
+ return { next: ["rebase", "--continue"], resumesRebase: true };
109
+ });
110
+ return result.exitCode === 0;
111
+ }
112
+
113
+ /**
114
+ * The retry loop. When an attempt fails because a lock is held, `onLocked`
115
+ * returns the command to retry with (or null to stop and return the failure).
116
+ */
117
+ async function retryLocks(
118
+ args: string[],
119
+ echo: boolean,
120
+ onLocked: () => Promise<Retry | null>,
121
+ ): Promise<GitResult> {
122
+ let retry: Retry = { next: args };
123
+ let announced = false;
124
+ let waitedMs = 0;
125
+ for (let attempt = 0; ; attempt++) {
126
+ let shell = $`git ${retry.next}`.quiet().nothrow();
127
+ // Resuming a lock-paused pick reuses the commit's message; never stop to
128
+ // open an editor for it.
129
+ if (retry.resumesRebase) shell = shell.env({ ...process.env, GIT_EDITOR: "true" });
130
+ const raw = await shell;
131
+ const result = {
132
+ exitCode: raw.exitCode,
133
+ stdout: raw.stdout.toString(),
134
+ stderr: raw.stderr.toString(),
135
+ };
136
+
137
+ const lock = result.exitCode === 0 ? null : heldLock(result.stderr);
138
+ const next = lock ? await onLocked() : null;
139
+ if (!lock || !next) {
140
+ if (echo) {
141
+ process.stdout.write(result.stdout);
142
+ process.stderr.write(result.stderr);
143
+ }
144
+ return result;
145
+ }
146
+
147
+ const delay = lockRetryDelaysMs[attempt];
148
+ if (delay === undefined) {
149
+ throw new GitLockError(lock, `git ${args.join(" ")}`, next.resumesRebase ?? false, waitedMs);
150
+ }
151
+ if (!announced) {
152
+ console.error(pc.dim(` Another git process holds ${lock}; waiting for it to finish…`));
153
+ announced = true;
154
+ }
155
+ await Bun.sleep(delay);
156
+ waitedMs += delay;
157
+ retry = next;
158
+ }
159
+ }
160
+
161
+ /** Whether the index has unmerged (conflicted) entries. */
162
+ async function hasUnmergedPaths(): Promise<boolean> {
163
+ const out = await $`git diff --name-only --diff-filter=U`.quiet().nothrow().text();
164
+ return out.trim() !== "";
165
+ }
166
+
9
167
  /**
10
168
  * Get the current branch name.
11
169
  * Throws if in detached HEAD state.
@@ -61,9 +219,13 @@ export async function repoRoot(): Promise<string> {
61
219
  * Refreshes the index first to avoid false positives from stale stat info.
62
220
  */
63
221
  export async function isCleanWorkingTree(): Promise<boolean> {
222
+ // Refresh index to avoid false positives from stat changes (timestamps, etc.).
223
+ // Outside the try: a lock that never frees must surface, not read as dirty.
224
+ // No -q: it also hides the lock message, and a refresh the lock blocked
225
+ // leaves stale stat info that makes a clean tree look dirty. The output
226
+ // ("needs update" lines) is captured and dropped.
227
+ await runGit(["update-index", "--refresh"]);
64
228
  try {
65
- // Refresh index to avoid false positives from stat changes (timestamps, etc.)
66
- await $`git update-index -q --refresh`.quiet().nothrow();
67
229
  await $`git diff-index --quiet HEAD --`.quiet();
68
230
  return true;
69
231
  } catch {
@@ -96,13 +258,13 @@ export async function isRebaseInProgress(): Promise<boolean> {
96
258
  }
97
259
 
98
260
  /**
99
- * Checkout a branch. Throws a raw ShellError on failure — only use this where a
100
- * failure is unexpected (e.g. after ensureCleanWorkingTree). For user-facing
101
- * branch switching, prefer tryCheckout so a dirty-tree refusal can be rendered
102
- * as a clean message instead of a stack trace.
261
+ * Checkout a branch. Throws on failure — only use this where a failure is
262
+ * unexpected (e.g. after ensureCleanWorkingTree). For user-facing branch
263
+ * switching, prefer tryCheckout so a dirty-tree refusal can be rendered as a
264
+ * clean message instead of a stack trace.
103
265
  */
104
266
  export async function checkout(branch: string): Promise<void> {
105
- await $`git checkout ${branch}`.quiet();
267
+ await runGitOrThrow(["checkout", branch]);
106
268
  }
107
269
 
108
270
  /**
@@ -111,15 +273,15 @@ export async function checkout(branch: string): Promise<void> {
111
273
  * message and the offending file list when a dirty tree blocks the switch).
112
274
  */
113
275
  export async function tryCheckout(branch: string): Promise<{ ok: boolean; stderr: string }> {
114
- const { exitCode, stderr } = await $`git checkout ${branch}`.quiet().nothrow();
115
- return { ok: exitCode === 0, stderr: stderr.toString() };
276
+ const { exitCode, stderr } = await runGit(["checkout", branch]);
277
+ return { ok: exitCode === 0, stderr };
116
278
  }
117
279
 
118
280
  /**
119
281
  * Create and checkout a new branch from the current HEAD.
120
282
  */
121
283
  export async function createBranch(name: string): Promise<void> {
122
- await $`git checkout -b ${name}`;
284
+ await runGitOrThrow(["checkout", "-b", name], { echo: true });
123
285
  }
124
286
 
125
287
  /**
@@ -324,7 +486,7 @@ export async function localBranchExists(branch: string): Promise<boolean> {
324
486
  * the branch didn't exist or the delete failed.
325
487
  */
326
488
  export async function deleteLocalBranch(branch: string): Promise<boolean> {
327
- const { exitCode } = await $`git branch -D ${branch}`.nothrow().quiet();
489
+ const { exitCode } = await runGit(["branch", "-D", branch]);
328
490
  return exitCode === 0;
329
491
  }
330
492
 
@@ -334,7 +496,7 @@ export async function deleteLocalBranch(branch: string): Promise<boolean> {
334
496
  * Returns false if the rename failed (e.g. the new name already exists).
335
497
  */
336
498
  export async function renameBranch(oldName: string, newName: string): Promise<boolean> {
337
- const { exitCode } = await $`git branch -m ${oldName} ${newName}`.nothrow().quiet();
499
+ const { exitCode } = await runGit(["branch", "-m", oldName, newName]);
338
500
  return exitCode === 0;
339
501
  }
340
502
 
@@ -350,7 +512,7 @@ export async function deleteRemoteBranch(branch: string): Promise<boolean> {
350
512
  * Create a temporary tag at a specific commit.
351
513
  */
352
514
  export async function createTag(name: string, commit: string): Promise<void> {
353
- await $`git tag -f ${name} ${commit}`.quiet();
515
+ await runGitOrThrow(["tag", "-f", name, commit]);
354
516
  }
355
517
 
356
518
  /**
@@ -361,7 +523,7 @@ export async function deleteTagsMatching(pattern: string): Promise<void> {
361
523
  const result = await $`git tag -l ${pattern}`.text();
362
524
  const tags = result.trim().split("\n").filter(Boolean);
363
525
  for (const tag of tags) {
364
- await $`git tag -d ${tag}`.quiet();
526
+ await runGitOrThrow(["tag", "-d", tag]);
365
527
  }
366
528
  } catch {
367
529
  // No tags to delete — that's fine
@@ -386,8 +548,7 @@ export async function rebaseOnto(
386
548
  oldBase: string,
387
549
  branch: string,
388
550
  ): Promise<boolean> {
389
- const { exitCode } = await $`git rebase --onto ${newBase} ${oldBase} ${branch}`.nothrow();
390
- return exitCode === 0;
551
+ return runRebase(["rebase", "--onto", newBase, oldBase, branch]);
391
552
  }
392
553
 
393
554
  /**
@@ -395,7 +556,7 @@ export async function rebaseOnto(
395
556
  * Returns true if successful. Caller must have verified the tree is clean.
396
557
  */
397
558
  export async function resetHard(ref: string): Promise<boolean> {
398
- const { exitCode } = await $`git reset --hard ${ref}`.nothrow().quiet();
559
+ const { exitCode } = await runGit(["reset", "--hard", ref]);
399
560
  return exitCode === 0;
400
561
  }
401
562
 
@@ -404,8 +565,7 @@ export async function resetHard(ref: string): Promise<boolean> {
404
565
  * Returns true if successful, false if conflicts occurred.
405
566
  */
406
567
  export async function rebase(target: string): Promise<boolean> {
407
- const { exitCode } = await $`git rebase ${target}`.nothrow();
408
- return exitCode === 0;
568
+ return runRebase(["rebase", target]);
409
569
  }
410
570
 
411
571
  /**
@@ -413,7 +573,7 @@ export async function rebase(target: string): Promise<boolean> {
413
573
  * Returns true if successful.
414
574
  */
415
575
  export async function mergeSquash(branch: string): Promise<boolean> {
416
- const { exitCode } = await $`git merge --squash ${branch}`.nothrow();
576
+ const { exitCode } = await runGit(["merge", "--squash", branch], { echo: true });
417
577
  return exitCode === 0;
418
578
  }
419
579
 
@@ -421,7 +581,7 @@ export async function mergeSquash(branch: string): Promise<boolean> {
421
581
  * Commit with a message.
422
582
  */
423
583
  export async function commit(message: string): Promise<void> {
424
- await $`git commit -m ${message}`;
584
+ await runGitOrThrow(["commit", "-m", message], { echo: true });
425
585
  }
426
586
 
427
587
  /**
@@ -303,6 +303,13 @@ it (its PR would be diffed against the parent's old history); the run lists it
303
303
  as held and exits 1 until `--resume` gets the parent pushed. `merge` and `join`
304
304
  refuse to run while a restack/sync is pending.
305
305
 
306
+ **"Another git process holds …index.lock"**: some other process (often a
307
+ background `git status`) held a git lock. gh-stack waits and retries for about 5
308
+ seconds first, so seeing this means the lock outlasted that. It is not a
309
+ conflict and the step changed nothing: run the same command again, or
310
+ `gh-stack restack --resume` if a restack/sync was interrupted. If no git process
311
+ is running, the lock is stale; remove the file it names.
312
+
306
313
  **Why did the push fail?** gh-stack prints the last lines of `git push`'s
307
314
  output, which includes a pre-push hook's own messages, and saves all of it to
308
315
  `.git/.gh-stack/push-failure.log`. Fix the cause (a failing check, say), then