gh-stack 0.19.7 → 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 +17 -3
- package/package.json +1 -1
- package/src/commands/delete.ts +2 -1
- package/src/commands/doctor.ts +14 -28
- package/src/commands/merge.ts +24 -2
- package/src/lib/branch-config.ts +39 -0
- package/src/lib/git.ts +9 -0
- package/src/lib/metadata.ts +58 -13
- package/src/lib/using-gh-stack.md +2 -0
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
|
|
@@ -289,7 +290,9 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
|
|
|
289
290
|
via GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
|
|
290
291
|
into main. The whole stack lands on main as a single squash commit.
|
|
291
292
|
All merges happen on GitHub so PRs show as "Merged", Linear tickets
|
|
292
|
-
close automatically, and GitHub Actions fire normally.
|
|
293
|
+
close automatically, and GitHub Actions fire normally. Squash commit
|
|
294
|
+
subjects leave out the ` (N/M)` stack position that `submit` adds to
|
|
295
|
+
PR titles. Skips already-
|
|
293
296
|
merged PRs (safe to re-run). Waits for GitHub between merges, and
|
|
294
297
|
self-heals its spurious "Head branch is out of date" (a stale PR
|
|
295
298
|
head pointer after a child squash) by re-syncing and retrying. Before
|
|
@@ -496,7 +499,8 @@ config [--json]
|
|
|
496
499
|
doctor
|
|
497
500
|
Migrate old (v2) metadata to the v3 layout, split any stack left
|
|
498
501
|
with several root branches into one stack per root, reconcile git
|
|
499
|
-
branch config against the topology files
|
|
502
|
+
branch config against the topology files (leaving branches another
|
|
503
|
+
worktree is stacking alone), flag stacks whose base
|
|
500
504
|
stack appears already-merged into main, and report stacks that
|
|
501
505
|
still fail the checks other commands run before loading a stack.
|
|
502
506
|
Safe to run repeatedly.
|
|
@@ -650,7 +654,9 @@ Review/CI status is intentionally not shown here. Run `gh-stack update-prs`
|
|
|
650
654
|
### Metadata (v3)
|
|
651
655
|
|
|
652
656
|
Stack metadata lives under `.git/.gh-stack/` (never committed) — a folder of
|
|
653
|
-
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:
|
|
654
660
|
|
|
655
661
|
```
|
|
656
662
|
.git/.gh-stack/
|
|
@@ -685,8 +691,16 @@ branch.<name>.ghstack-stack <stack>
|
|
|
685
691
|
branch.<name>.ghstack-parent <parent-branch>
|
|
686
692
|
branch.<name>.ghstack-pr <number>
|
|
687
693
|
branch.<name>.ghstack-id <uuid> stable identity (survives rename)
|
|
694
|
+
branch.<name>.ghstack-gitdir <path> git dir of the worktree that owns it
|
|
688
695
|
```
|
|
689
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
|
+
|
|
690
704
|
The two representations cross-check each other; `gh-stack doctor` reconciles
|
|
691
705
|
any drift. Why this shape:
|
|
692
706
|
|
package/package.json
CHANGED
package/src/commands/delete.ts
CHANGED
|
@@ -50,7 +50,8 @@ OPTIONS
|
|
|
50
50
|
: ensureCurrentStack(meta);
|
|
51
51
|
await ensureValidStack(meta, stackName);
|
|
52
52
|
const stack = meta.stacks[stackName]!;
|
|
53
|
-
|
|
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
|
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
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 (
|
|
222
|
+
if (updated.length > 0 || cleared.length > 0) {
|
|
242
223
|
p.log.success(
|
|
243
|
-
`Reconciled branch config: ${
|
|
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();
|
package/src/commands/merge.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { Stack } from "../types.ts";
|
|
|
25
25
|
import { confirmAction, isAutoYes } from "../lib/ui.ts";
|
|
26
26
|
import { guardRemoteContent } from "../lib/remote-guard.ts";
|
|
27
27
|
import { ensureNoPendingRun } from "./restack.ts";
|
|
28
|
+
import { stripSeqSuffix } from "./update-prs.ts";
|
|
28
29
|
import {
|
|
29
30
|
planMerges,
|
|
30
31
|
advancePastMerged,
|
|
@@ -62,6 +63,24 @@ export function isTransientMergeError(stderr: string): boolean {
|
|
|
62
63
|
);
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The squash commit subject for a PR titled `title`, without the ` (N/M)`
|
|
68
|
+
* stack position `submit` adds to PR titles, which GitHub would otherwise put
|
|
69
|
+
* on the trunk's commit. Null when the title has no position, so GitHub's own
|
|
70
|
+
* "<title> (#N)" stands.
|
|
71
|
+
*/
|
|
72
|
+
export function squashSubject(title: string, prNumber: number): string | null {
|
|
73
|
+
const base = stripSeqSuffix(title);
|
|
74
|
+
return base === title.trimEnd() ? null : `${base} (#${prNumber})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `gh pr merge` args setting the squash subject (see squashSubject); empty to keep GitHub's. */
|
|
78
|
+
async function squashSubjectArgs(prNumber: number): Promise<string[]> {
|
|
79
|
+
const title = (await getPrInfo(prNumber))?.title;
|
|
80
|
+
const subject = title ? squashSubject(title, prNumber) : null;
|
|
81
|
+
return subject ? ["--subject", subject] : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* Squash-merge a PR, healing the two timing artifacts a top-down cascade
|
|
67
86
|
* provokes — neither of which is a real conflict:
|
|
@@ -87,8 +106,9 @@ async function squashMergeWithRetry(
|
|
|
87
106
|
s: ReturnType<typeof p.spinner>,
|
|
88
107
|
): Promise<{ ok: true } | { ok: false; stderr: string; genuine: boolean }> {
|
|
89
108
|
const maxAttempts = MERGE_RETRY_BACKOFF_MS.length + 1;
|
|
109
|
+
const subject = await squashSubjectArgs(prNumber);
|
|
90
110
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
91
|
-
const ghArgs = ["pr", "merge", String(prNumber), "--squash"];
|
|
111
|
+
const ghArgs = ["pr", "merge", String(prNumber), "--squash", ...subject];
|
|
92
112
|
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
93
113
|
|
|
94
114
|
const proc = Bun.spawn(["gh", ...ghArgs], { stdout: "pipe", stderr: "pipe" });
|
|
@@ -423,7 +443,8 @@ OPTIONS
|
|
|
423
443
|
: "Single branch stack — enabling auto-merge on GitHub.",
|
|
424
444
|
);
|
|
425
445
|
if (!dryRun) {
|
|
426
|
-
const
|
|
446
|
+
const subject = await squashSubjectArgs(basePr);
|
|
447
|
+
const auto = await $`gh pr merge ${basePr} --squash --auto ${subject}`.nothrow().quiet();
|
|
427
448
|
if (auto.exitCode === 0) {
|
|
428
449
|
p.log.success(`Auto-merge enabled for PR #${basePr}`);
|
|
429
450
|
} else {
|
|
@@ -712,6 +733,7 @@ OPTIONS
|
|
|
712
733
|
|
|
713
734
|
const enableAutoMerge = async (): Promise<{ ok: boolean; stderr: string }> => {
|
|
714
735
|
const ghArgs = ["pr", "merge", String(basePr), "--squash", "--auto"];
|
|
736
|
+
if (basePr != null) ghArgs.push(...(await squashSubjectArgs(basePr)));
|
|
715
737
|
if (deleteFlag) ghArgs.push("--delete-branch");
|
|
716
738
|
const proc = Bun.spawn(["gh", ...ghArgs], { stdout: "pipe", stderr: "pipe" });
|
|
717
739
|
const stderr = await new Response(proc.stderr).text();
|
package/src/lib/branch-config.ts
CHANGED
|
@@ -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
|
*/
|
package/src/lib/metadata.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
|
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(
|
|
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, {
|
|
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
|
|
354
|
-
if (
|
|
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
|