gh-stack 0.7.1 → 0.10.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.
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
  */
@@ -168,6 +179,16 @@ export async function deleteLocalBranch(branch: string): Promise<boolean> {
168
179
  return exitCode === 0;
169
180
  }
170
181
 
182
+ /**
183
+ * Rename a local branch (`git branch -m`). Git moves the whole `branch.<old>`
184
+ * config section to `branch.<new>`, so gh-stack membership rides along for free.
185
+ * Returns false if the rename failed (e.g. the new name already exists).
186
+ */
187
+ export async function renameBranch(oldName: string, newName: string): Promise<boolean> {
188
+ const { exitCode } = await $`git branch -m ${oldName} ${newName}`.nothrow().quiet();
189
+ return exitCode === 0;
190
+ }
191
+
171
192
  /**
172
193
  * Delete a branch on the remote (origin). Returns false on failure.
173
194
  */
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.
@@ -126,19 +150,63 @@ export async function getPrBody(prNumber: number): Promise<string | null> {
126
150
 
127
151
  /**
128
152
  * Get the merge state of a PR.
129
- * Returns { state, mergeable, mergeStateStatus }.
153
+ * Returns { state, mergeable, mergeStateStatus, headRefName, headRefOid }.
154
+ *
155
+ * NOTE: `mergeable`/`mergeStateStatus` are computed against the PR's *recorded*
156
+ * head (`headRefOid`), which GitHub occasionally fails to advance after a child
157
+ * squash-merge lands on this branch. Compare `headRefOid` against the live ref
158
+ * (`getBranchRefSha`) to detect that staleness — see `merge.ts`.
130
159
  */
131
- export async function getPrMergeState(
132
- prNumber: number,
133
- ): Promise<{ state: string; mergeable: string; mergeStateStatus: string } | null> {
160
+ export async function getPrMergeState(prNumber: number): Promise<{
161
+ state: string;
162
+ mergeable: string;
163
+ mergeStateStatus: string;
164
+ headRefName: string;
165
+ headRefOid: string;
166
+ } | null> {
134
167
  try {
135
- const result = await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus`.text();
168
+ const result =
169
+ await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid`.text();
136
170
  return JSON.parse(result.trim());
137
171
  } catch {
138
172
  return null;
139
173
  }
140
174
  }
141
175
 
176
+ /**
177
+ * Resolve the live SHA of a branch ref on the remote (`origin`), independent of
178
+ * any PR's cached head. Returns null if the ref can't be resolved.
179
+ */
180
+ export async function getBranchRefSha(branch: string): Promise<string | null> {
181
+ const nwo = await getRepoNwo();
182
+ if (!nwo) return null;
183
+ try {
184
+ const sha = (
185
+ await $`gh api repos/${nwo}/git/ref/heads/${branch} --jq .object.sha`.text()
186
+ ).trim();
187
+ return sha.length > 0 ? sha : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Force GitHub to re-read a PR's head branch by closing and immediately
195
+ * reopening it. Used when the PR's recorded `headRefOid` is stuck behind the
196
+ * live branch tip (a child squash-merge moved the branch but GitHub missed the
197
+ * head-sync), which makes `mergePullRequest` reject a "Head branch is out of
198
+ * date" even though every status read says the PR is clean.
199
+ */
200
+ export async function resyncPrHead(prNumber: number): Promise<boolean> {
201
+ try {
202
+ await $`gh pr close ${prNumber}`.quiet();
203
+ await $`gh pr reopen ${prNumber}`.quiet();
204
+ return true;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
142
210
  /**
143
211
  * Close a PR with a comment.
144
212
  */