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/README.md +131 -25
- package/package.json +1 -1
- package/src/commands/doctor.ts +268 -0
- package/src/commands/down.ts +14 -6
- package/src/commands/init.ts +4 -1
- package/src/commands/log.ts +2 -0
- package/src/commands/merge.ts +186 -23
- package/src/commands/rename.ts +111 -0
- package/src/commands/restack.ts +270 -44
- package/src/commands/split.ts +159 -0
- package/src/commands/stacks.ts +136 -0
- package/src/commands/status.ts +7 -0
- package/src/commands/submit.ts +30 -88
- package/src/commands/sync.ts +24 -4
- package/src/commands/update-prs.ts +201 -100
- package/src/index.ts +25 -0
- package/src/lib/branch-config.ts +132 -0
- package/src/lib/chain.ts +83 -50
- package/src/lib/git.ts +21 -0
- package/src/lib/github.ts +73 -5
- package/src/lib/metadata.ts +354 -59
- package/src/lib/paths.ts +69 -0
- package/src/lib/safety.ts +33 -7
- package/src/lib/snapshot.ts +83 -14
- package/src/lib/ui.ts +5 -2
- package/src/types.ts +28 -2
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
|
-
|
|
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
|
-
* -
|
|
72
|
-
* into that
|
|
73
|
-
*
|
|
74
|
-
* (
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
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 {
|
|
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
|
-
|
|
133
|
-
|
|
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 =
|
|
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
|
*/
|