gh-stack 0.19.8 → 0.19.9

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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.8",
3
+ "version": "0.19.9",
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();
@@ -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,21 @@
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";
22
30
 
23
31
  export interface BranchMembership {
24
32
  stack?: string;
25
33
  parent?: string;
26
34
  pr?: number;
27
35
  id?: string;
36
+ gitdir?: string;
28
37
  }
29
38
 
30
39
  const KEY_PREFIX = "ghstack-";
@@ -32,6 +41,7 @@ const STACK_KEY = "ghstack-stack";
32
41
  const PARENT_KEY = "ghstack-parent";
33
42
  const PR_KEY = "ghstack-pr";
34
43
  const ID_KEY = "ghstack-id";
44
+ const GITDIR_KEY = "ghstack-gitdir";
35
45
 
36
46
  function configKey(branch: string, key: string): string {
37
47
  return `branch.${branch}.${key}`;
@@ -50,6 +60,7 @@ export async function setBranchMembership(
50
60
  await setOrUnset(branch, PARENT_KEY, membership.parent);
51
61
  await setOrUnset(branch, PR_KEY, membership.pr != null ? String(membership.pr) : undefined);
52
62
  await setOrUnset(branch, ID_KEY, membership.id);
63
+ await setOrUnset(branch, GITDIR_KEY, membership.gitdir);
53
64
  }
54
65
 
55
66
  async function setOrUnset(branch: string, key: string, value: string | undefined): Promise<void> {
@@ -83,6 +94,7 @@ export async function clearBranchMembership(branch: string): Promise<void> {
83
94
  await unsetKey(branch, PARENT_KEY);
84
95
  await unsetKey(branch, PR_KEY);
85
96
  await unsetKey(branch, ID_KEY);
97
+ await unsetKey(branch, GITDIR_KEY);
86
98
  }
87
99
 
88
100
  /**
@@ -121,6 +133,7 @@ export async function allBranchMemberships(): Promise<Map<string, BranchMembersh
121
133
  if (field === STACK_KEY) m.stack = value;
122
134
  else if (field === PARENT_KEY) m.parent = value;
123
135
  else if (field === ID_KEY) m.id = value;
136
+ else if (field === GITDIR_KEY) m.gitdir = value;
124
137
  else if (field === PR_KEY) {
125
138
  const n = Number(value);
126
139
  if (Number.isFinite(n)) m.pr = n;
@@ -130,3 +143,29 @@ export async function allBranchMemberships(): Promise<Map<string, BranchMembersh
130
143
 
131
144
  return out;
132
145
  }
146
+
147
+ /**
148
+ * True if `m` belongs to a worktree other than `mine` that still exists.
149
+ * Membership with no owner (written before owners were recorded) or with an
150
+ * owner that's gone is up for grabs.
151
+ */
152
+ export function ownedByOtherWorktree(m: BranchMembership | undefined, mine: string): boolean {
153
+ return !!m?.gitdir && m.gitdir !== mine && gitDirIsLive(m.gitdir);
154
+ }
155
+
156
+ /**
157
+ * A worktree's git dir is live while it exists and, for a linked worktree,
158
+ * while its checkout does too: `.git/worktrees/<name>/gitdir` names the
159
+ * checkout's `.git` file, which is what `git worktree prune` checks. The main
160
+ * checkout's git dir has no `gitdir` file.
161
+ */
162
+ function gitDirIsLive(dir: string): boolean {
163
+ if (!existsSync(dir)) return false;
164
+ const pointer = path.join(dir, "gitdir");
165
+ if (!existsSync(pointer)) return true;
166
+ try {
167
+ return existsSync(readFileSync(pointer, "utf8").trim());
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
package/src/lib/git.ts CHANGED
@@ -39,6 +39,15 @@ export async function gitDir(): Promise<string> {
39
39
  return result.trim();
40
40
  }
41
41
 
42
+ /**
43
+ * Absolute path to this worktree's git dir. `gitDir()` is relative (`.git`) in
44
+ * the main checkout, which can't identify a worktree from another one.
45
+ */
46
+ export async function absoluteGitDir(): Promise<string> {
47
+ const result = await $`git rev-parse --absolute-git-dir`.text();
48
+ return result.trim();
49
+ }
50
+
42
51
  /**
43
52
  * Get the absolute path to the repository's working-tree root.
44
53
  */
@@ -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