gh-stack 0.19.8 → 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
@@ -95,6 +95,7 @@ add [branch] [--parent <branch>]
95
95
  A PR that gh-stack already recorded for a branch in git config (in
96
96
  another worktree of the repo) is kept, so a stack submitted from a
97
97
  worktree can be tracked in the main checkout and merged from there.
98
+ The adding worktree takes the branch over from then on.
98
99
 
99
100
  --parent <branch>: stack it directly on <branch> (which it must
100
101
  already be based on), tracking <branch> first if needed. Use this
@@ -498,7 +499,8 @@ config [--json]
498
499
  doctor
499
500
  Migrate old (v2) metadata to the v3 layout, split any stack left
500
501
  with several root branches into one stack per root, reconcile git
501
- branch config against the topology files, flag stacks whose base
502
+ branch config against the topology files (leaving branches another
503
+ worktree is stacking alone), flag stacks whose base
502
504
  stack appears already-merged into main, and report stacks that
503
505
  still fail the checks other commands run before loading a stack.
504
506
  Safe to run repeatedly.
@@ -652,7 +654,9 @@ Review/CI status is intentionally not shown here. Run `gh-stack update-prs`
652
654
  ### Metadata (v3)
653
655
 
654
656
  Stack metadata lives under `.git/.gh-stack/` (never committed) — a folder of
655
- per-stack files plus git-native branch config, rather than a single JSON blob:
657
+ per-stack files plus git-native branch config, rather than a single JSON blob.
658
+ A linked worktree keeps its own folder in its own git dir
659
+ (`.git/worktrees/<name>/.gh-stack/`), so each worktree has its own stacks:
656
660
 
657
661
  ```
658
662
  .git/.gh-stack/
@@ -687,8 +691,16 @@ branch.<name>.ghstack-stack <stack>
687
691
  branch.<name>.ghstack-parent <parent-branch>
688
692
  branch.<name>.ghstack-pr <number>
689
693
  branch.<name>.ghstack-id <uuid> stable identity (survives rename)
694
+ branch.<name>.ghstack-gitdir <path> git dir of the worktree that owns it
690
695
  ```
691
696
 
697
+ Git config is shared by every worktree, so each branch records the worktree
698
+ that owns it. A write in one worktree never clears or overwrites a branch
699
+ another live worktree owns. `add` (or any command that starts tracking a
700
+ branch) takes a branch over, which is how a stack is handed from one worktree
701
+ to another. Once a worktree is removed, or its checkout is deleted, the next
702
+ write anywhere clears the config it left behind.
703
+
692
704
  The two representations cross-check each other; `gh-stack doctor` reconciles
693
705
  any drift. Why this shape:
694
706
 
@@ -719,6 +731,14 @@ you at `doctor`.
719
731
 
720
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.
721
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
+
722
742
  ## Example Workflow
723
743
 
724
744
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.8",
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",
@@ -50,7 +50,8 @@ OPTIONS
50
50
  : ensureCurrentStack(meta);
51
51
  await ensureValidStack(meta, stackName);
52
52
  const stack = meta.stacks[stackName]!;
53
- const currentBranch = await git.currentBranch();
53
+ // Detached HEAD is fine: `delete <branch>` doesn't need a current branch.
54
+ const currentBranch = (await git.currentBranch().catch(() => null)) ?? undefined;
54
55
 
55
56
  p.intro(pc.cyan("Delete Branch from Stack"));
56
57
 
@@ -17,7 +17,6 @@ import {
17
17
  import { validateStack } from "../lib/safety.ts";
18
18
  import { legacyMetadataPath, legacyRestackStatePath, restackStateFile } from "../lib/paths.ts";
19
19
  import { writeSnapshotFile } from "../lib/snapshot.ts";
20
- import { allBranchMemberships } from "../lib/branch-config.ts";
21
20
 
22
21
  const HELP = `
23
22
  gh-stack doctor — Migrate & repair stack metadata
@@ -28,9 +27,10 @@ USAGE
28
27
  Migrates old (v2) metadata to the v3 layout (per-stack files under
29
28
  .git/.gh-stack/ + git branch config), splits any stack left with several
30
29
  root branches into one stack per root, reconciles branch config against
31
- the topology files, flags stacks whose base stack looks already-merged
32
- into main, and reports stacks that still fail the checks other commands
33
- run before they load a stack.
30
+ the topology files (leaving branches another worktree is stacking
31
+ alone), flags stacks whose base stack looks already-merged into main,
32
+ and reports stacks that still fail the checks other commands run
33
+ before they load a stack.
34
34
 
35
35
  Safe to run repeatedly — it's idempotent.
36
36
  `;
@@ -204,11 +204,6 @@ function inferStack(
204
204
  // ── Reconciliation ──
205
205
 
206
206
  async function reconcile(meta: StackMetadata): Promise<void> {
207
- // Re-syncing config is what writeMetadata already does: set desired
208
- // membership, clear stale. Snapshot the before-state so we can report what
209
- // drifted, then apply.
210
- const before = await allBranchMemberships();
211
-
212
207
  // Report topology branches that no longer exist locally (can't auto-fix
213
208
  // safely — re-parenting children is a judgement call).
214
209
  const deadBranches: { stack: string; branch: string }[] = [];
@@ -220,31 +215,22 @@ async function reconcile(meta: StackMetadata): Promise<void> {
220
215
  }
221
216
  }
222
217
 
223
- // Build desired membership to diff against `before`.
224
- const desired = new Set<string>();
225
- let drift = 0;
226
- for (const [name, stack] of Object.entries(meta.stacks)) {
227
- for (const [branch, b] of Object.entries(stack.branches)) {
228
- desired.add(branch);
229
- const cur = before.get(branch);
230
- if (!cur || cur.stack !== name || cur.parent !== b.parent || cur.pr !== b.pr) drift++;
231
- }
232
- }
233
- let orphans = 0;
234
- for (const branch of before.keys()) {
235
- if (!desired.has(branch)) orphans++;
236
- }
237
-
238
- // Apply the reconciliation (idempotent).
239
- await writeMetadata(meta);
218
+ // Re-syncing config is what writeMetadata already does: set desired
219
+ // membership, clear stale, leave other worktrees' branches alone.
220
+ const { updated, cleared, foreign } = await writeMetadata(meta);
240
221
 
241
- if (drift > 0 || orphans > 0) {
222
+ if (updated.length > 0 || cleared.length > 0) {
242
223
  p.log.success(
243
- `Reconciled branch config: ${drift} updated, ${orphans} stale membership(s) cleared.`,
224
+ `Reconciled branch config: ${updated.length} updated, ${cleared.length} stale membership(s) cleared.`,
244
225
  );
245
226
  } else {
246
227
  p.log.info("Branch config already in sync with topology.");
247
228
  }
229
+ if (foreign.length > 0) {
230
+ p.log.info(
231
+ `Left ${foreign.length} branch(es) alone that another worktree is stacking: ${foreign.join(", ")}`,
232
+ );
233
+ }
248
234
 
249
235
  if (deadBranches.length > 0) {
250
236
  console.log();
@@ -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
@@ -7,6 +7,7 @@
7
7
  // branch.<name>.ghstack-parent <parent-branch>
8
8
  // branch.<name>.ghstack-pr <number>
9
9
  // branch.<name>.ghstack-id <uuid> (stable identity; survives rename)
10
+ // branch.<name>.ghstack-gitdir <path> (the git dir of the worktree that owns it)
10
11
  //
11
12
  // Why config and not JSON: git maintains the `branch.<name>` section for us.
12
13
  // • `git branch -m old new` → the section moves → rename tracking free
@@ -18,13 +19,22 @@
18
19
  // alphanumerics and `-` in config keys (underscores are rejected outright),
19
20
  // and it lowercases the variable portion on read — so `ghstack-stack` round
20
21
  // -trips exactly while a camelCase key would read back mangled.
22
+ //
23
+ // Git config is shared by every worktree of a repo, but each worktree keeps its
24
+ // own stack files (under its own git dir). `ghstack-gitdir` records which
25
+ // worktree wrote a branch's membership, so a write in one worktree leaves the
26
+ // branches another worktree is stacking alone (#64).
21
27
  import { $ } from "bun";
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import * as path from "node:path";
30
+ import { runGit } from "./git.ts";
22
31
 
23
32
  export interface BranchMembership {
24
33
  stack?: string;
25
34
  parent?: string;
26
35
  pr?: number;
27
36
  id?: string;
37
+ gitdir?: string;
28
38
  }
29
39
 
30
40
  const KEY_PREFIX = "ghstack-";
@@ -32,6 +42,7 @@ const STACK_KEY = "ghstack-stack";
32
42
  const PARENT_KEY = "ghstack-parent";
33
43
  const PR_KEY = "ghstack-pr";
34
44
  const ID_KEY = "ghstack-id";
45
+ const GITDIR_KEY = "ghstack-gitdir";
35
46
 
36
47
  function configKey(branch: string, key: string): string {
37
48
  return `branch.${branch}.${key}`;
@@ -50,6 +61,7 @@ export async function setBranchMembership(
50
61
  await setOrUnset(branch, PARENT_KEY, membership.parent);
51
62
  await setOrUnset(branch, PR_KEY, membership.pr != null ? String(membership.pr) : undefined);
52
63
  await setOrUnset(branch, ID_KEY, membership.id);
64
+ await setOrUnset(branch, GITDIR_KEY, membership.gitdir);
53
65
  }
54
66
 
55
67
  async function setOrUnset(branch: string, key: string, value: string | undefined): Promise<void> {
@@ -57,12 +69,16 @@ async function setOrUnset(branch: string, key: string, value: string | undefined
57
69
  await unsetKey(branch, key);
58
70
  return;
59
71
  }
60
- 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]);
61
76
  }
62
77
 
63
78
  async function unsetKey(branch: string, key: string): Promise<void> {
64
- // `--unset` exits 5 when the key is already absent — harmless, so nothrow.
65
- 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)]);
66
82
  }
67
83
 
68
84
  /**
@@ -83,6 +99,7 @@ export async function clearBranchMembership(branch: string): Promise<void> {
83
99
  await unsetKey(branch, PARENT_KEY);
84
100
  await unsetKey(branch, PR_KEY);
85
101
  await unsetKey(branch, ID_KEY);
102
+ await unsetKey(branch, GITDIR_KEY);
86
103
  }
87
104
 
88
105
  /**
@@ -121,6 +138,7 @@ export async function allBranchMemberships(): Promise<Map<string, BranchMembersh
121
138
  if (field === STACK_KEY) m.stack = value;
122
139
  else if (field === PARENT_KEY) m.parent = value;
123
140
  else if (field === ID_KEY) m.id = value;
141
+ else if (field === GITDIR_KEY) m.gitdir = value;
124
142
  else if (field === PR_KEY) {
125
143
  const n = Number(value);
126
144
  if (Number.isFinite(n)) m.pr = n;
@@ -130,3 +148,29 @@ export async function allBranchMemberships(): Promise<Map<string, BranchMembersh
130
148
 
131
149
  return out;
132
150
  }
151
+
152
+ /**
153
+ * True if `m` belongs to a worktree other than `mine` that still exists.
154
+ * Membership with no owner (written before owners were recorded) or with an
155
+ * owner that's gone is up for grabs.
156
+ */
157
+ export function ownedByOtherWorktree(m: BranchMembership | undefined, mine: string): boolean {
158
+ return !!m?.gitdir && m.gitdir !== mine && gitDirIsLive(m.gitdir);
159
+ }
160
+
161
+ /**
162
+ * A worktree's git dir is live while it exists and, for a linked worktree,
163
+ * while its checkout does too: `.git/worktrees/<name>/gitdir` names the
164
+ * checkout's `.git` file, which is what `git worktree prune` checks. The main
165
+ * checkout's git dir has no `gitdir` file.
166
+ */
167
+ function gitDirIsLive(dir: string): boolean {
168
+ if (!existsSync(dir)) return false;
169
+ const pointer = path.join(dir, "gitdir");
170
+ if (!existsSync(pointer)) return true;
171
+ try {
172
+ return existsSync(readFileSync(pointer, "utf8").trim());
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
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.
@@ -39,6 +197,15 @@ export async function gitDir(): Promise<string> {
39
197
  return result.trim();
40
198
  }
41
199
 
200
+ /**
201
+ * Absolute path to this worktree's git dir. `gitDir()` is relative (`.git`) in
202
+ * the main checkout, which can't identify a worktree from another one.
203
+ */
204
+ export async function absoluteGitDir(): Promise<string> {
205
+ const result = await $`git rev-parse --absolute-git-dir`.text();
206
+ return result.trim();
207
+ }
208
+
42
209
  /**
43
210
  * Get the absolute path to the repository's working-tree root.
44
211
  */
@@ -52,9 +219,13 @@ export async function repoRoot(): Promise<string> {
52
219
  * Refreshes the index first to avoid false positives from stale stat info.
53
220
  */
54
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"]);
55
228
  try {
56
- // Refresh index to avoid false positives from stat changes (timestamps, etc.)
57
- await $`git update-index -q --refresh`.quiet().nothrow();
58
229
  await $`git diff-index --quiet HEAD --`.quiet();
59
230
  return true;
60
231
  } catch {
@@ -87,13 +258,13 @@ export async function isRebaseInProgress(): Promise<boolean> {
87
258
  }
88
259
 
89
260
  /**
90
- * Checkout a branch. Throws a raw ShellError on failure — only use this where a
91
- * failure is unexpected (e.g. after ensureCleanWorkingTree). For user-facing
92
- * branch switching, prefer tryCheckout so a dirty-tree refusal can be rendered
93
- * 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.
94
265
  */
95
266
  export async function checkout(branch: string): Promise<void> {
96
- await $`git checkout ${branch}`.quiet();
267
+ await runGitOrThrow(["checkout", branch]);
97
268
  }
98
269
 
99
270
  /**
@@ -102,15 +273,15 @@ export async function checkout(branch: string): Promise<void> {
102
273
  * message and the offending file list when a dirty tree blocks the switch).
103
274
  */
104
275
  export async function tryCheckout(branch: string): Promise<{ ok: boolean; stderr: string }> {
105
- const { exitCode, stderr } = await $`git checkout ${branch}`.quiet().nothrow();
106
- return { ok: exitCode === 0, stderr: stderr.toString() };
276
+ const { exitCode, stderr } = await runGit(["checkout", branch]);
277
+ return { ok: exitCode === 0, stderr };
107
278
  }
108
279
 
109
280
  /**
110
281
  * Create and checkout a new branch from the current HEAD.
111
282
  */
112
283
  export async function createBranch(name: string): Promise<void> {
113
- await $`git checkout -b ${name}`;
284
+ await runGitOrThrow(["checkout", "-b", name], { echo: true });
114
285
  }
115
286
 
116
287
  /**
@@ -315,7 +486,7 @@ export async function localBranchExists(branch: string): Promise<boolean> {
315
486
  * the branch didn't exist or the delete failed.
316
487
  */
317
488
  export async function deleteLocalBranch(branch: string): Promise<boolean> {
318
- const { exitCode } = await $`git branch -D ${branch}`.nothrow().quiet();
489
+ const { exitCode } = await runGit(["branch", "-D", branch]);
319
490
  return exitCode === 0;
320
491
  }
321
492
 
@@ -325,7 +496,7 @@ export async function deleteLocalBranch(branch: string): Promise<boolean> {
325
496
  * Returns false if the rename failed (e.g. the new name already exists).
326
497
  */
327
498
  export async function renameBranch(oldName: string, newName: string): Promise<boolean> {
328
- const { exitCode } = await $`git branch -m ${oldName} ${newName}`.nothrow().quiet();
499
+ const { exitCode } = await runGit(["branch", "-m", oldName, newName]);
329
500
  return exitCode === 0;
330
501
  }
331
502
 
@@ -341,7 +512,7 @@ export async function deleteRemoteBranch(branch: string): Promise<boolean> {
341
512
  * Create a temporary tag at a specific commit.
342
513
  */
343
514
  export async function createTag(name: string, commit: string): Promise<void> {
344
- await $`git tag -f ${name} ${commit}`.quiet();
515
+ await runGitOrThrow(["tag", "-f", name, commit]);
345
516
  }
346
517
 
347
518
  /**
@@ -352,7 +523,7 @@ export async function deleteTagsMatching(pattern: string): Promise<void> {
352
523
  const result = await $`git tag -l ${pattern}`.text();
353
524
  const tags = result.trim().split("\n").filter(Boolean);
354
525
  for (const tag of tags) {
355
- await $`git tag -d ${tag}`.quiet();
526
+ await runGitOrThrow(["tag", "-d", tag]);
356
527
  }
357
528
  } catch {
358
529
  // No tags to delete — that's fine
@@ -377,8 +548,7 @@ export async function rebaseOnto(
377
548
  oldBase: string,
378
549
  branch: string,
379
550
  ): Promise<boolean> {
380
- const { exitCode } = await $`git rebase --onto ${newBase} ${oldBase} ${branch}`.nothrow();
381
- return exitCode === 0;
551
+ return runRebase(["rebase", "--onto", newBase, oldBase, branch]);
382
552
  }
383
553
 
384
554
  /**
@@ -386,7 +556,7 @@ export async function rebaseOnto(
386
556
  * Returns true if successful. Caller must have verified the tree is clean.
387
557
  */
388
558
  export async function resetHard(ref: string): Promise<boolean> {
389
- const { exitCode } = await $`git reset --hard ${ref}`.nothrow().quiet();
559
+ const { exitCode } = await runGit(["reset", "--hard", ref]);
390
560
  return exitCode === 0;
391
561
  }
392
562
 
@@ -395,8 +565,7 @@ export async function resetHard(ref: string): Promise<boolean> {
395
565
  * Returns true if successful, false if conflicts occurred.
396
566
  */
397
567
  export async function rebase(target: string): Promise<boolean> {
398
- const { exitCode } = await $`git rebase ${target}`.nothrow();
399
- return exitCode === 0;
568
+ return runRebase(["rebase", target]);
400
569
  }
401
570
 
402
571
  /**
@@ -404,7 +573,7 @@ export async function rebase(target: string): Promise<boolean> {
404
573
  * Returns true if successful.
405
574
  */
406
575
  export async function mergeSquash(branch: string): Promise<boolean> {
407
- const { exitCode } = await $`git merge --squash ${branch}`.nothrow();
576
+ const { exitCode } = await runGit(["merge", "--squash", branch], { echo: true });
408
577
  return exitCode === 0;
409
578
  }
410
579
 
@@ -412,7 +581,7 @@ export async function mergeSquash(branch: string): Promise<boolean> {
412
581
  * Commit with a message.
413
582
  */
414
583
  export async function commit(message: string): Promise<void> {
415
- await $`git commit -m ${message}`;
584
+ await runGitOrThrow(["commit", "-m", message], { echo: true });
416
585
  }
417
586
 
418
587
  /**
@@ -29,6 +29,7 @@ import {
29
29
  allBranchMemberships,
30
30
  setBranchMembership,
31
31
  clearBranchMembership,
32
+ ownedByOtherWorktree,
32
33
  type BranchMembership,
33
34
  } from "./branch-config.ts";
34
35
  import { loadSnapshots } from "./snapshot.ts";
@@ -256,7 +257,7 @@ function serializeStack(stack: Stack): string {
256
257
  * tombstone any stack file that's no longer present, and reconcile git branch
257
258
  * config to match active membership.
258
259
  */
259
- export async function writeMetadata(meta: StackMetadata): Promise<void> {
260
+ export async function writeMetadata(meta: StackMetadata): Promise<BranchConfigSync> {
260
261
  await ensureDirs();
261
262
 
262
263
  // 0. stamp a stable id on any active branch that lacks one, so a later
@@ -269,6 +270,13 @@ export async function writeMetadata(meta: StackMetadata): Promise<void> {
269
270
  const aDir = await activeDir();
270
271
  const arDir = await archivedDir();
271
272
 
273
+ // Branches this worktree tracked before this write. Any other active branch
274
+ // is joining it now, which claims its config from another worktree.
275
+ const tracked = new Set<string>();
276
+ for (const stack of Object.values(await readStackDir(aDir))) {
277
+ for (const name of Object.keys(stack.branches ?? {})) tracked.add(name);
278
+ }
279
+
272
280
  // 2. write active + archived stack files (write desired state BEFORE
273
281
  // removing anything — that's what makes "can't vanish" hold).
274
282
  for (const [name, stack] of Object.entries(meta.stacks)) {
@@ -286,7 +294,7 @@ export async function writeMetadata(meta: StackMetadata): Promise<void> {
286
294
  await reconcileStale(arDir, archivedNames, (name) => activeNames.has(name));
287
295
 
288
296
  // 4. mirror active membership into git config
289
- await syncBranchConfig(meta);
297
+ return syncBranchConfig(meta, tracked);
290
298
  }
291
299
 
292
300
  /**
@@ -321,38 +329,80 @@ async function reconcileStale(
321
329
  }
322
330
  }
323
331
 
332
+ /** What `syncBranchConfig` changed. */
333
+ export interface BranchConfigSync {
334
+ /** Branches whose membership was written. */
335
+ updated: string[];
336
+ /** Branches whose stale membership was removed. */
337
+ cleared: string[];
338
+ /** Branches left alone because another live worktree owns them. */
339
+ foreign: string[];
340
+ }
341
+
324
342
  /**
325
- * Reconcile git branch config so it exactly mirrors active-stack membership.
343
+ * Reconcile git branch config so it mirrors this worktree's active membership.
326
344
  * Only branches whose recorded membership differs are touched, to avoid
327
345
  * shelling out to `git config` for every branch on every write.
346
+ *
347
+ * Git config is shared by every worktree, so a branch another live worktree
348
+ * owns is left alone (#64): it isn't cleared when this worktree doesn't track
349
+ * it, and it isn't overwritten when both do. The exception is a branch joining
350
+ * this worktree's stacks in this write (`add`, `create`, adopting a chain),
351
+ * which is the handoff, so this worktree takes it over. `tracked` is what this
352
+ * worktree tracked before the write.
328
353
  */
329
- async function syncBranchConfig(meta: StackMetadata): Promise<void> {
354
+ async function syncBranchConfig(
355
+ meta: StackMetadata,
356
+ tracked: Set<string>,
357
+ ): Promise<BranchConfigSync> {
358
+ const mine = await git.absoluteGitDir();
330
359
  const desired = new Map<string, BranchMembership>();
331
360
  for (const [stackName, stack] of Object.entries(meta.stacks)) {
332
361
  for (const [branchName, b] of Object.entries(stack.branches)) {
333
- desired.set(branchName, { stack: stackName, parent: b.parent, pr: b.pr, id: b.id });
362
+ desired.set(branchName, {
363
+ stack: stackName,
364
+ parent: b.parent,
365
+ pr: b.pr,
366
+ id: b.id,
367
+ gitdir: mine,
368
+ });
334
369
  }
335
370
  }
336
371
 
337
372
  const existing = await allBranchMemberships();
373
+ const result: BranchConfigSync = { updated: [], cleared: [], foreign: [] };
338
374
 
339
375
  for (const [branch, m] of desired) {
340
376
  const cur = existing.get(branch);
377
+ if (tracked.has(branch) && ownedByOtherWorktree(cur, mine)) {
378
+ result.foreign.push(branch);
379
+ continue;
380
+ }
341
381
  if (
342
382
  !cur ||
343
383
  cur.stack !== m.stack ||
344
384
  cur.parent !== m.parent ||
345
385
  cur.pr !== m.pr ||
346
- cur.id !== m.id
386
+ cur.id !== m.id ||
387
+ cur.gitdir !== m.gitdir
347
388
  ) {
348
389
  await setBranchMembership(branch, m);
390
+ result.updated.push(branch);
349
391
  }
350
392
  }
351
393
 
352
394
  // Branches that still carry gh-stack config but are no longer active members.
353
- for (const branch of existing.keys()) {
354
- if (!desired.has(branch)) await clearBranchMembership(branch);
395
+ for (const [branch, m] of existing) {
396
+ if (desired.has(branch)) continue;
397
+ if (ownedByOtherWorktree(m, mine)) {
398
+ result.foreign.push(branch);
399
+ continue;
400
+ }
401
+ await clearBranchMembership(branch);
402
+ result.cleared.push(branch);
355
403
  }
404
+
405
+ return result;
356
406
  }
357
407
 
358
408
  /**
@@ -564,11 +614,6 @@ export async function removeBranchFromStack(
564
614
  }
565
615
  delete stack.branches[branchName];
566
616
 
567
- // The removed branch may still exist in git but is no longer a member —
568
- // drop its membership config explicitly (syncBranchConfig also would, but
569
- // be direct since the branch is gone from `meta`).
570
- await clearBranchMembership(branchName);
571
-
572
617
  if (stack.last_branch === branchName) {
573
618
  const remaining = Object.keys(stack.branches);
574
619
  stack.last_branch = remaining.length > 0 ? remaining[remaining.length - 1]! : null;
@@ -133,6 +133,8 @@ no-publish path.)
133
133
  but the PR numbers gh-stack records in git config are shared. So after
134
134
  submitting from a worktree, `gh-stack add <top-branch>` in the main checkout
135
135
  tracks the whole chain _with_ its PRs, and `sync`/`merge --base` work there.
136
+ Each branch's config records the worktree that owns it, so worktrees running
137
+ gh-stack side by side don't clear each other's; `add` takes a branch over.
136
138
 
137
139
  **Splice an existing branch/PR into a stack — `gh-stack join`.** For a branch
138
140
  that should have been stacked but was cut from main on its own (often with its
@@ -301,6 +303,13 @@ it (its PR would be diffed against the parent's old history); the run lists it
301
303
  as held and exits 1 until `--resume` gets the parent pushed. `merge` and `join`
302
304
  refuse to run while a restack/sync is pending.
303
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
+
304
313
  **Why did the push fail?** gh-stack prints the last lines of `git push`'s
305
314
  output, which includes a pre-push hook's own messages, and saves all of it to
306
315
  `.git/.gh-stack/push-failure.log`. Fix the cause (a failing check, say), then