gh-stack 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ // Git-native branch membership index.
2
+ //
3
+ // Each branch records which stack it belongs to (plus its parent and PR) in
4
+ // git's own config under the `branch.<name>` section:
5
+ //
6
+ // branch.<name>.ghstack-stack <stack>
7
+ // branch.<name>.ghstack-parent <parent-branch>
8
+ // branch.<name>.ghstack-pr <number>
9
+ //
10
+ // Why config and not JSON: git maintains the `branch.<name>` section for us.
11
+ // • `git branch -m old new` → the section moves → rename tracking free
12
+ // • `git branch -D name` → the section is deleted → no orphaned membership
13
+ //
14
+ // So this is a self-cleaning, rename-safe index that runs alongside the
15
+ // `.gh-stack/` topology files and lets `doctor` cross-check the two. We use
16
+ // dash-separated, all-lowercase variable names on purpose: git only permits
17
+ // alphanumerics and `-` in config keys (underscores are rejected outright),
18
+ // and it lowercases the variable portion on read — so `ghstack-stack` round
19
+ // -trips exactly while a camelCase key would read back mangled.
20
+ import { $ } from "bun";
21
+
22
+ export interface BranchMembership {
23
+ stack?: string;
24
+ parent?: string;
25
+ pr?: number;
26
+ }
27
+
28
+ const KEY_PREFIX = "ghstack-";
29
+ const STACK_KEY = "ghstack-stack";
30
+ const PARENT_KEY = "ghstack-parent";
31
+ const PR_KEY = "ghstack-pr";
32
+
33
+ function configKey(branch: string, key: string): string {
34
+ return `branch.${branch}.${key}`;
35
+ }
36
+
37
+ /**
38
+ * Set (or clear) the membership config for a single branch. Any field left
39
+ * undefined is removed, so callers can pass a full desired state and trust the
40
+ * branch ends up matching it exactly.
41
+ */
42
+ export async function setBranchMembership(
43
+ branch: string,
44
+ membership: BranchMembership,
45
+ ): Promise<void> {
46
+ await setOrUnset(branch, STACK_KEY, membership.stack);
47
+ await setOrUnset(branch, PARENT_KEY, membership.parent);
48
+ await setOrUnset(branch, PR_KEY, membership.pr != null ? String(membership.pr) : undefined);
49
+ }
50
+
51
+ async function setOrUnset(branch: string, key: string, value: string | undefined): Promise<void> {
52
+ if (value === undefined || value === "") {
53
+ await unsetKey(branch, key);
54
+ return;
55
+ }
56
+ await $`git config ${configKey(branch, key)} ${value}`.quiet().nothrow();
57
+ }
58
+
59
+ async function unsetKey(branch: string, key: string): Promise<void> {
60
+ // `--unset` exits 5 when the key is already absent — harmless, so nothrow.
61
+ await $`git config --unset ${configKey(branch, key)}`.quiet().nothrow();
62
+ }
63
+
64
+ /**
65
+ * Read one branch's membership. Returns an empty object if the branch has no
66
+ * gh-stack config.
67
+ */
68
+ export async function getBranchMembership(branch: string): Promise<BranchMembership> {
69
+ const all = await allBranchMemberships();
70
+ return all.get(branch) ?? {};
71
+ }
72
+
73
+ /**
74
+ * Remove all gh-stack membership keys from a branch (leaves the branch and its
75
+ * other config untouched).
76
+ */
77
+ export async function clearBranchMembership(branch: string): Promise<void> {
78
+ await unsetKey(branch, STACK_KEY);
79
+ await unsetKey(branch, PARENT_KEY);
80
+ await unsetKey(branch, PR_KEY);
81
+ }
82
+
83
+ /**
84
+ * Read every branch's gh-stack membership in one shot, keyed by branch name.
85
+ *
86
+ * Branch names may legally contain dots (`feat.x`), so we can't naively split
87
+ * the config key on `.`. We anchor on the known `.ghstack-<field>` suffix and
88
+ * treat everything between `branch.` and that suffix as the branch name.
89
+ */
90
+ export async function allBranchMemberships(): Promise<Map<string, BranchMembership>> {
91
+ const out = new Map<string, BranchMembership>();
92
+
93
+ const { stdout, exitCode } = await $`git config --get-regexp ${"^branch\\..*\\.ghstack-"}`
94
+ .quiet()
95
+ .nothrow();
96
+ // exit 1 simply means no matching keys — an empty repo, not an error.
97
+ if (exitCode !== 0) return out;
98
+
99
+ const text = stdout.toString().trim();
100
+ if (!text) return out;
101
+
102
+ for (const line of text.split("\n")) {
103
+ const sep = line.indexOf(" ");
104
+ if (sep === -1) continue;
105
+ const fullKey = line.slice(0, sep);
106
+ const value = line.slice(sep + 1);
107
+
108
+ // fullKey looks like: branch.<name>.ghstack-<field>
109
+ if (!fullKey.startsWith("branch.")) continue;
110
+ const dotField = fullKey.lastIndexOf(`.${KEY_PREFIX}`);
111
+ if (dotField === -1) continue;
112
+ const branch = fullKey.slice("branch.".length, dotField);
113
+ const field = fullKey.slice(dotField + 1); // ghstack-<field>
114
+
115
+ const m = out.get(branch) ?? {};
116
+ if (field === STACK_KEY) m.stack = value;
117
+ else if (field === PARENT_KEY) m.parent = value;
118
+ else if (field === PR_KEY) {
119
+ const n = Number(value);
120
+ if (Number.isFinite(n)) m.pr = n;
121
+ }
122
+ out.set(branch, m);
123
+ }
124
+
125
+ return out;
126
+ }
package/src/lib/chain.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // auto-create/register a stack from a bare set of local branches so the
5
5
  // end state is always stack registered + branches pushed + PRs created).
6
6
  import * as git from "./git.ts";
7
- import { findStackForBranch } from "./metadata.ts";
7
+ import { findStackForBranch, getOrderedBranches, stackBase } from "./metadata.ts";
8
8
  import type { StackMetadata, Branch } from "../types.ts";
9
9
 
10
10
  /**
@@ -55,7 +55,8 @@ export async function detectBranchChain(currentBranch: string, trunk: string): P
55
55
 
56
56
  export interface StackResolution {
57
57
  stackName: string;
58
- chain: string[]; // trunk→current order
58
+ base: string; // effective base of the resulting stack (for display)
59
+ chain: string[]; // resulting stack's branches, base→tip (for display)
59
60
  created: boolean; // true if a brand-new stack was created
60
61
  addedBranches: string[]; // branches newly registered into the stack
61
62
  }
@@ -67,74 +68,106 @@ export interface StackResolution {
67
68
  * effects).
68
69
  *
69
70
  * Behavior:
70
- * - Detects the chain trunk→current.
71
- * - If any branch in the chain already belongs to a stack, reconciles
72
- * into that existing stack (adds only the missing branches).
73
- * - Otherwise creates a new stack named after the current branch
74
- * (auto-suffixed if that name is taken).
75
- * - Registers missing branches with correct parent links (chain[0]'s
76
- * parent is trunk; each subsequent branch's parent is the prior one).
71
+ * - Detects the ancestry chain trunk→current.
72
+ * - Finds the NEAREST tracked ancestor of `branch`. If one exists, adopts
73
+ * the untracked tail above it into that ancestor's stack — stopping at
74
+ * that stack's boundary. This is what lets a stack with a non-main base
75
+ * (one stacked on top of another stack) keep accepting new branches: the
76
+ * walk never crosses into the parent stack, so it can't mistake a clean
77
+ * stacked-on-a-stack layout for an ambiguous multi-stack chain. (#11)
78
+ * - Otherwise creates a new stack rooted at `trunk`, named after the current
79
+ * branch (auto-suffixed if that name is taken). (#7)
77
80
  *
78
- * Throws if the chain spans more than one existing stack (ambiguous).
81
+ * Throws only if the untracked tail genuinely interleaves another stack's
82
+ * branches — which a clean stacked-on-a-stack layout never does.
79
83
  */
80
84
  export async function resolveOrCreateStack(
81
85
  meta: StackMetadata,
82
86
  branch: string,
83
87
  trunk: string,
84
88
  ): Promise<StackResolution> {
85
- const chain = await detectBranchChain(branch, trunk);
86
-
87
- // Which existing stacks (if any) already own branches in this chain?
88
- const owningStacks = new Set<string>();
89
- for (const b of chain) {
90
- const owner = findStackForBranch(meta, b);
91
- if (owner) owningStacks.add(owner);
89
+ const fullChain = await detectBranchChain(branch, trunk); // trunk→current
90
+
91
+ // Walk from just below `branch` downward; the first tracked ancestor is the
92
+ // attachment point. Everything above it (up to `branch`) is the untracked
93
+ // tail we adopt — by definition not tracked, since this is the *nearest*
94
+ // tracked ancestor.
95
+ let anchorIndex = -1;
96
+ let anchorStack: string | null = null;
97
+ for (let i = fullChain.length - 2; i >= 0; i--) {
98
+ const owner = findStackForBranch(meta, fullChain[i]!);
99
+ if (owner) {
100
+ anchorIndex = i;
101
+ anchorStack = owner;
102
+ break;
103
+ }
92
104
  }
93
105
 
94
- if (owningStacks.size > 1) {
95
- throw new Error(
96
- `Branches in this chain span multiple stacks (${[...owningStacks].join(
97
- ", ",
98
- )}). Resolve manually with gh-stack init / delete.`,
99
- );
100
- }
106
+ if (anchorStack) {
107
+ const stack = meta.stacks[anchorStack]!;
108
+ const tail = fullChain.slice(anchorIndex); // [anchor, ...untracked..., current]
109
+ const addedBranches: string[] = [];
110
+
111
+ for (let i = 1; i < tail.length; i++) {
112
+ const name = tail[i]!;
113
+ const parent = tail[i - 1]!;
114
+
115
+ // Safety net: a clean stacked-on-a-stack layout never trips this, but if
116
+ // the tail somehow already belongs to a different stack, it's genuinely
117
+ // ambiguous — refuse rather than silently move branches between stacks.
118
+ const existingOwner = findStackForBranch(meta, name);
119
+ if (existingOwner && existingOwner !== anchorStack) {
120
+ throw new Error(
121
+ `Branches in this chain span multiple stacks (${anchorStack}, ${existingOwner}). Resolve manually with gh-stack init / delete.`,
122
+ );
123
+ }
101
124
 
102
- let stackName: string;
103
- let created = false;
104
-
105
- if (owningStacks.size === 1) {
106
- // Reconcile into the existing stack
107
- stackName = [...owningStacks][0]!;
108
- } else {
109
- // Create a new stack named after the current branch (auto-suffix on clash)
110
- stackName = branch;
111
- let n = 2;
112
- while (meta.stacks[stackName]) {
113
- stackName = `${branch}-${n++}`;
125
+ if (stack.branches[name]) continue; // already tracked — leave as-is
126
+ stack.branches[name] = { parent };
127
+ stack.last_branch = name;
128
+ addedBranches.push(name);
114
129
  }
115
- meta.stacks[stackName] = {
116
- description: "",
117
- last_branch: null,
118
- branches: {},
130
+
131
+ return {
132
+ stackName: anchorStack,
133
+ base: stackBase(stack),
134
+ chain: getOrderedBranches(stack),
135
+ created: false,
136
+ addedBranches,
119
137
  };
120
- meta.current_stack = stackName;
121
- created = true;
122
138
  }
123
139
 
140
+ // No tracked ancestor — create a new stack rooted at trunk.
141
+ let stackName = branch;
142
+ let n = 2;
143
+ while (meta.stacks[stackName]) {
144
+ stackName = `${branch}-${n++}`;
145
+ }
146
+ meta.stacks[stackName] = {
147
+ description: "",
148
+ last_branch: null,
149
+ branches: {},
150
+ base: trunk,
151
+ };
152
+ meta.current_stack = stackName;
153
+
124
154
  const stack = meta.stacks[stackName]!;
125
155
  const addedBranches: string[] = [];
126
-
127
- // Register missing branches with correct parent links.
128
- for (let i = 0; i < chain.length; i++) {
129
- const name = chain[i]!;
130
- if (stack.branches[name]) continue; // already tracked — leave as-is
131
-
132
- const parent = i === 0 ? trunk : chain[i - 1]!;
156
+ for (let i = 0; i < fullChain.length; i++) {
157
+ const name = fullChain[i]!;
158
+ if (stack.branches[name]) continue;
159
+ const parent = i === 0 ? trunk : fullChain[i - 1]!;
133
160
  const branchData: Branch = { parent };
134
161
  stack.branches[name] = branchData;
135
162
  stack.last_branch = name;
136
163
  addedBranches.push(name);
137
164
  }
138
165
 
139
- return { stackName, chain, created, addedBranches };
166
+ return {
167
+ stackName,
168
+ base: trunk,
169
+ chain: getOrderedBranches(stack),
170
+ created: true,
171
+ addedBranches,
172
+ };
140
173
  }
package/src/lib/git.ts CHANGED
@@ -138,6 +138,17 @@ export async function forcePushWithLease(branch?: string): Promise<boolean> {
138
138
  }
139
139
  }
140
140
 
141
+ /**
142
+ * Push a stacked branch's ref to origin, setting upstream and using
143
+ * force-with-lease. Works whether or not the remote branch already exists
144
+ * (creates it on first push) — so restack can reconcile every ref, including
145
+ * a branch that was never pushed before.
146
+ */
147
+ export async function pushStackedBranch(branch: string): Promise<boolean> {
148
+ const { exitCode } = await $`git push -u --force-with-lease origin ${branch}`.nothrow().quiet();
149
+ return exitCode === 0;
150
+ }
151
+
141
152
  /**
142
153
  * Check if a branch exists on the remote.
143
154
  */
@@ -158,6 +169,24 @@ export async function localBranchExists(branch: string): Promise<boolean> {
158
169
  return exitCode === 0;
159
170
  }
160
171
 
172
+ /**
173
+ * Delete a local branch. Uses -D (force) since stacked branches are
174
+ * routinely rebased and won't appear "merged" to git. Returns false if
175
+ * the branch didn't exist or the delete failed.
176
+ */
177
+ export async function deleteLocalBranch(branch: string): Promise<boolean> {
178
+ const { exitCode } = await $`git branch -D ${branch}`.nothrow().quiet();
179
+ return exitCode === 0;
180
+ }
181
+
182
+ /**
183
+ * Delete a branch on the remote (origin). Returns false on failure.
184
+ */
185
+ export async function deleteRemoteBranch(branch: string): Promise<boolean> {
186
+ const { exitCode } = await $`git push origin --delete ${branch}`.nothrow().quiet();
187
+ return exitCode === 0;
188
+ }
189
+
161
190
  /**
162
191
  * Create a temporary tag at a specific commit.
163
192
  */
package/src/lib/github.ts CHANGED
@@ -2,6 +2,30 @@
2
2
  import { $ } from "bun";
3
3
  import type { PrInfo, PrStatus } from "../types.ts";
4
4
 
5
+ let cachedNwo: string | null | undefined; // undefined = not yet looked up
6
+
7
+ /**
8
+ * Get the repository's `owner/name` (once, cached for the process). Used to
9
+ * construct PR URLs locally — `https://github.com/<nwo>/pull/<n>` — so the
10
+ * stack visualization doesn't need a per-PR API call just for links.
11
+ * Returns null if it can't be resolved (links degrade to plain `#<n>`).
12
+ */
13
+ export async function getRepoNwo(): Promise<string | null> {
14
+ if (cachedNwo !== undefined) return cachedNwo;
15
+ try {
16
+ const out = (await $`gh repo view --json nameWithOwner --jq .nameWithOwner`.text()).trim();
17
+ cachedNwo = out.length > 0 ? out : null;
18
+ } catch {
19
+ cachedNwo = null;
20
+ }
21
+ return cachedNwo;
22
+ }
23
+
24
+ /** Build a PR URL from a cached nwo (or null if nwo is unknown). */
25
+ export function prUrlFor(nwo: string | null, prNumber: number): string | null {
26
+ return nwo ? `https://github.com/${nwo}/pull/${prNumber}` : null;
27
+ }
28
+
5
29
  /**
6
30
  * Get PR number for a branch (auto-detect via gh).
7
31
  * Returns null if no PR exists.