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.
- package/README.md +97 -27
- package/package.json +1 -1
- package/src/commands/delete.ts +66 -12
- 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 +19 -1
- package/src/commands/restack.ts +270 -44
- package/src/commands/split.ts +159 -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 +15 -0
- package/src/lib/branch-config.ts +126 -0
- package/src/lib/chain.ts +83 -50
- package/src/lib/git.ts +29 -0
- package/src/lib/github.ts +24 -0
- package/src/lib/metadata.ts +246 -60
- 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 +21 -2
|
@@ -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
|
-
|
|
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
|
*/
|
|
@@ -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.
|