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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// gh-stack stacks — List all stacks and their topology (network-free).
|
|
2
|
+
//
|
|
3
|
+
// This is the machine-readable interface to stack metadata: `--json` emits the
|
|
4
|
+
// full topology (stacks, ordered branches, PR numbers, base) so external tools
|
|
5
|
+
// never have to reach into `.git/.gh-stack/` and parse the storage format
|
|
6
|
+
// themselves. It performs NO network calls and does NOT mutate metadata.
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import * as git from "../lib/git.ts";
|
|
9
|
+
import {
|
|
10
|
+
readMetadata,
|
|
11
|
+
metadataExists,
|
|
12
|
+
legacyMetadataExists,
|
|
13
|
+
getOrderedBranches,
|
|
14
|
+
findStackForBranch,
|
|
15
|
+
stackBase,
|
|
16
|
+
} from "../lib/metadata.ts";
|
|
17
|
+
|
|
18
|
+
export default async function stacks(args: string[]): Promise<void> {
|
|
19
|
+
if (args.includes("--help")) {
|
|
20
|
+
console.log(`
|
|
21
|
+
gh-stack stacks — List all stacks and their topology
|
|
22
|
+
|
|
23
|
+
USAGE
|
|
24
|
+
gh-stack stacks [options]
|
|
25
|
+
|
|
26
|
+
OPTIONS
|
|
27
|
+
--json Output topology as JSON (for tools/scripts; no network calls)
|
|
28
|
+
--current Only the stack containing the current branch
|
|
29
|
+
|
|
30
|
+
Read-only and network-free — emits stack membership straight from local
|
|
31
|
+
metadata. Use --json as a stable interface so scripts don't parse the
|
|
32
|
+
.git/.gh-stack/ store directly.
|
|
33
|
+
`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const jsonMode = args.includes("--json");
|
|
38
|
+
const currentOnly = args.includes("--current");
|
|
39
|
+
|
|
40
|
+
// Read-only: don't hard-block on unmigrated v2 metadata — just note it to
|
|
41
|
+
// stderr (keeps stdout/JSON clean) and emit an empty result.
|
|
42
|
+
if (!(await metadataExists())) {
|
|
43
|
+
if (await legacyMetadataExists()) {
|
|
44
|
+
process.stderr.write("Note: stack metadata is unmigrated (v2). Run `gh-stack doctor`.\n");
|
|
45
|
+
}
|
|
46
|
+
if (jsonMode) {
|
|
47
|
+
console.log(
|
|
48
|
+
JSON.stringify({ current_stack: null, current_branch: null, stacks: [] }, null, 2),
|
|
49
|
+
);
|
|
50
|
+
} else {
|
|
51
|
+
console.log("No stacks found.");
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const meta = await readMetadata();
|
|
57
|
+
if (!meta) {
|
|
58
|
+
if (jsonMode) {
|
|
59
|
+
console.log(
|
|
60
|
+
JSON.stringify({ current_stack: null, current_branch: null, stacks: [] }, null, 2),
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
console.log("No stacks found.");
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let currentBranch: string | null = null;
|
|
69
|
+
try {
|
|
70
|
+
currentBranch = await git.currentBranch();
|
|
71
|
+
} catch {
|
|
72
|
+
// Detached HEAD or not on a branch — fine, just no "current" context.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const currentStackName = currentBranch ? findStackForBranch(meta, currentBranch) : null;
|
|
76
|
+
|
|
77
|
+
const entries = Object.entries(meta.stacks)
|
|
78
|
+
.filter(([name]) => !currentOnly || name === currentStackName)
|
|
79
|
+
.map(([name, stack]) => {
|
|
80
|
+
const ordered = getOrderedBranches(stack);
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
description: stack.description || "",
|
|
84
|
+
base: stackBase(stack),
|
|
85
|
+
is_current: name === currentStackName,
|
|
86
|
+
branches: ordered.map((branchName) => {
|
|
87
|
+
const b = stack.branches[branchName]!;
|
|
88
|
+
return {
|
|
89
|
+
branch: branchName,
|
|
90
|
+
parent: b.parent,
|
|
91
|
+
pr: b.pr ?? null,
|
|
92
|
+
description: b.description ?? null,
|
|
93
|
+
};
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (jsonMode) {
|
|
99
|
+
console.log(
|
|
100
|
+
JSON.stringify(
|
|
101
|
+
{
|
|
102
|
+
current_stack: currentStackName ?? meta.current_stack ?? null,
|
|
103
|
+
current_branch: currentBranch,
|
|
104
|
+
stacks: entries,
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Human-readable ──
|
|
114
|
+
if (entries.length === 0) {
|
|
115
|
+
console.log(currentOnly ? "Current branch is not in a stack." : "No stacks found.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log();
|
|
120
|
+
for (const stack of entries) {
|
|
121
|
+
const marker = stack.is_current ? pc.yellow(" (current)") : "";
|
|
122
|
+
console.log(`${pc.blue("📚")} ${pc.yellow(stack.name)}${marker} ${pc.dim(`→ ${stack.base}`)}`);
|
|
123
|
+
if (stack.description) {
|
|
124
|
+
console.log(` ${pc.dim(stack.description)}`);
|
|
125
|
+
}
|
|
126
|
+
for (let i = 0; i < stack.branches.length; i++) {
|
|
127
|
+
const b = stack.branches[i]!;
|
|
128
|
+
const isLast = i === stack.branches.length - 1;
|
|
129
|
+
const tree = isLast ? "┗━" : "┣━";
|
|
130
|
+
const prNum = b.pr ? pc.dim(`#${b.pr}`) : pc.dim("(no PR)");
|
|
131
|
+
const cur = currentBranch === b.branch ? pc.yellow(" 👈") : "";
|
|
132
|
+
console.log(` ${tree} ${i + 1}. ${b.branch} ${prNum}${cur}`);
|
|
133
|
+
}
|
|
134
|
+
console.log();
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -5,6 +5,7 @@ import * as git from "../lib/git.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
readMetadata,
|
|
7
7
|
metadataExists,
|
|
8
|
+
legacyMetadataExists,
|
|
8
9
|
getOrderedBranches,
|
|
9
10
|
findStackForBranch,
|
|
10
11
|
} from "../lib/metadata.ts";
|
|
@@ -32,6 +33,12 @@ Progress/spinner output goes to stderr so stdout stays clean for piping.
|
|
|
32
33
|
const jsonMode = args.includes("--json");
|
|
33
34
|
const currentOnly = args.includes("--current");
|
|
34
35
|
|
|
36
|
+
// Read-only command, so don't hard-block — but if v2 metadata hasn't been
|
|
37
|
+
// migrated, stacks won't be grouped. Nudge to stderr (keeps stdout/JSON clean).
|
|
38
|
+
if (!(await metadataExists()) && (await legacyMetadataExists())) {
|
|
39
|
+
process.stderr.write("Note: stack metadata is unmigrated (v2). Run `gh-stack doctor`.\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
// Progress goes to stderr so stdout is clean for agents
|
|
36
43
|
process.stderr.write("Fetching open PRs...\n");
|
|
37
44
|
|
package/src/commands/submit.ts
CHANGED
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
} from "../lib/metadata.ts";
|
|
12
12
|
import { isAutoYes } from "../lib/ui.ts";
|
|
13
13
|
import type { StackMetadata } from "../types.ts";
|
|
14
|
-
import { getPrNumber
|
|
14
|
+
import { getPrNumber } from "../lib/github.ts";
|
|
15
15
|
import { resolveOrCreateStack } from "../lib/chain.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { refreshStackViz, branchToTitle } from "./update-prs.ts";
|
|
17
17
|
|
|
18
18
|
const HELP = `
|
|
19
19
|
gh-stack submit — Push branches and create/update PRs
|
|
@@ -51,15 +51,6 @@ EXAMPLES
|
|
|
51
51
|
* Strips common prefixes like "kiliman/", replaces hyphens with spaces,
|
|
52
52
|
* and capitalizes the first letter.
|
|
53
53
|
*/
|
|
54
|
-
function branchToTitle(branch: string): string {
|
|
55
|
-
// Strip common prefixes like "kiliman/", "user/"
|
|
56
|
-
let name = branch.includes("/") ? branch.split("/").slice(1).join("/") : branch;
|
|
57
|
-
// Replace hyphens with spaces
|
|
58
|
-
name = name.replace(/-/g, " ");
|
|
59
|
-
// Capitalize first letter
|
|
60
|
-
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
54
|
/**
|
|
64
55
|
* Check if a local branch is up-to-date with its remote counterpart.
|
|
65
56
|
*/
|
|
@@ -136,7 +127,7 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
136
127
|
// Reconstruct the stack from local branch ancestry so the end state is
|
|
137
128
|
// always stack registered + branches pushed + PRs created — no need to
|
|
138
129
|
// remember to run `gh-stack init` first.
|
|
139
|
-
let heal: {
|
|
130
|
+
let heal: { base: string; chain: string[]; created: boolean; added: string[] } | null = null;
|
|
140
131
|
if (!stackName) {
|
|
141
132
|
const trunk = await git.trunkBranch();
|
|
142
133
|
|
|
@@ -157,7 +148,7 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
157
148
|
|
|
158
149
|
stackName = resolution.stackName;
|
|
159
150
|
heal = {
|
|
160
|
-
|
|
151
|
+
base: resolution.base,
|
|
161
152
|
chain: resolution.chain,
|
|
162
153
|
created: resolution.created,
|
|
163
154
|
added: resolution.addedBranches,
|
|
@@ -183,13 +174,15 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
183
174
|
`No stack found — created ${pc.yellow(stackName)} from ${heal.chain.length} local branch(es)`,
|
|
184
175
|
);
|
|
185
176
|
} else if (heal.added.length > 0) {
|
|
177
|
+
const baseNote =
|
|
178
|
+
heal.base === "main" || heal.base === "master" ? "" : ` (base: ${heal.base})`;
|
|
186
179
|
p.log.success(
|
|
187
|
-
`
|
|
180
|
+
`Adopted ${heal.added.length} untracked branch(es) into ${pc.yellow(stackName)}${pc.dim(baseNote)}`,
|
|
188
181
|
);
|
|
189
182
|
}
|
|
190
183
|
|
|
191
184
|
console.log();
|
|
192
|
-
console.log(` ${pc.dim(heal.
|
|
185
|
+
console.log(` ${pc.dim(heal.base)}`);
|
|
193
186
|
for (let i = 0; i < heal.chain.length; i++) {
|
|
194
187
|
const b = heal.chain[i]!;
|
|
195
188
|
const isLast = i === heal.chain.length - 1;
|
|
@@ -256,16 +249,21 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
256
249
|
}
|
|
257
250
|
|
|
258
251
|
if (prNumber) {
|
|
259
|
-
// ── Existing PR: update base if
|
|
252
|
+
// ── Existing PR: update base only if the parent actually changed ──
|
|
260
253
|
existing++;
|
|
261
254
|
if (dryRun) {
|
|
262
255
|
p.log.step(`${pc.dim("exists")} PR #${prNumber} for ${branchName}`);
|
|
263
256
|
} else {
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
257
|
+
// Skip the network round-trip when the base we last set still matches
|
|
258
|
+
// the current parent — the common case on a re-submit (issue #16).
|
|
259
|
+
if (branchMeta.prBase !== parentBranch) {
|
|
260
|
+
try {
|
|
261
|
+
await $`gh pr edit ${prNumber} --base ${parentBranch}`.quiet();
|
|
262
|
+
branchMeta.prBase = parentBranch;
|
|
263
|
+
metadataChanged = true;
|
|
264
|
+
} catch {
|
|
265
|
+
// Best effort — base might already be correct
|
|
266
|
+
}
|
|
269
267
|
}
|
|
270
268
|
p.log.info(`${pc.dim("✓")} PR #${prNumber} for ${pc.blue(branchName)}`);
|
|
271
269
|
}
|
|
@@ -327,6 +325,8 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
327
325
|
|
|
328
326
|
if (newPrNumber) {
|
|
329
327
|
branchMeta.pr = newPrNumber;
|
|
328
|
+
branchMeta.prTitle = title;
|
|
329
|
+
branchMeta.prBase = parentBranch;
|
|
330
330
|
metadataChanged = true;
|
|
331
331
|
s.stop(`Created PR #${newPrNumber} for ${pc.blue(branchName)}`);
|
|
332
332
|
} else {
|
|
@@ -342,11 +342,6 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
// Save metadata if we discovered or created PRs
|
|
346
|
-
if (metadataChanged && !dryRun) {
|
|
347
|
-
await writeMetadata(meta);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
345
|
console.log();
|
|
351
346
|
|
|
352
347
|
if (dryRun) {
|
|
@@ -358,76 +353,23 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
358
353
|
return;
|
|
359
354
|
}
|
|
360
355
|
|
|
361
|
-
// ── Phase 2:
|
|
356
|
+
// ── Phase 2: Refresh the stack visualization (renders locally, PATCHes only
|
|
357
|
+
// the PRs whose block actually changed; see issue #16). ──
|
|
362
358
|
p.log.info(pc.cyan("Updating stack visualization..."));
|
|
359
|
+
const viz = await refreshStackViz(meta, stack, ordered);
|
|
363
360
|
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
prTitle: string;
|
|
369
|
-
prUrl: string | null;
|
|
370
|
-
reviewEmojiStr: string;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const branchInfos: BranchPrInfo[] = [];
|
|
374
|
-
|
|
375
|
-
for (const branchName of ordered) {
|
|
376
|
-
const branchMeta = stack.branches[branchName]!;
|
|
377
|
-
const prNum = branchMeta.pr ?? null;
|
|
378
|
-
let prTitle = branchMeta.description || branchName;
|
|
379
|
-
let prUrl: string | null = null;
|
|
380
|
-
let review = "PENDING";
|
|
381
|
-
|
|
382
|
-
if (prNum) {
|
|
383
|
-
const info = await getPrInfo(prNum);
|
|
384
|
-
if (info) {
|
|
385
|
-
prTitle = info.title || prTitle;
|
|
386
|
-
prUrl = info.url;
|
|
387
|
-
review = info.reviewDecision || "PENDING";
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
branchInfos.push({
|
|
392
|
-
branch: branchName,
|
|
393
|
-
prNumber: prNum,
|
|
394
|
-
prTitle,
|
|
395
|
-
prUrl,
|
|
396
|
-
reviewEmojiStr: reviewEmoji(review),
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Update each PR that has a number
|
|
401
|
-
let vizUpdated = 0;
|
|
402
|
-
|
|
403
|
-
for (let i = 0; i < branchInfos.length; i++) {
|
|
404
|
-
const info = branchInfos[i]!;
|
|
405
|
-
if (!info.prNumber) continue;
|
|
406
|
-
|
|
407
|
-
const stackViz = buildStackViz(branchInfos, i);
|
|
408
|
-
|
|
409
|
-
const currentBody = await getPrBody(info.prNumber);
|
|
410
|
-
if (currentBody === null) continue;
|
|
411
|
-
|
|
412
|
-
// Remove existing stack section
|
|
413
|
-
let updatedBody = currentBody;
|
|
414
|
-
const stackSectionIdx = updatedBody.indexOf("### 📚 Stacked on");
|
|
415
|
-
if (stackSectionIdx !== -1) {
|
|
416
|
-
updatedBody = updatedBody.slice(0, stackSectionIdx).trimEnd();
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Append new stack section
|
|
420
|
-
const newBody = updatedBody ? `${updatedBody}\n\n${stackViz}` : stackViz;
|
|
421
|
-
|
|
422
|
-
const ok = await updatePrBody(info.prNumber, newBody);
|
|
423
|
-
if (ok) vizUpdated++;
|
|
361
|
+
// One persist for everything we cached this run (discovered PRs, base edits,
|
|
362
|
+
// backfilled titles, viz hashes).
|
|
363
|
+
if (metadataChanged || viz.dirty) {
|
|
364
|
+
await writeMetadata(meta);
|
|
424
365
|
}
|
|
425
366
|
|
|
426
367
|
// ── Summary ──
|
|
427
368
|
console.log();
|
|
428
369
|
p.outro(
|
|
429
370
|
pc.green(
|
|
430
|
-
`Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${
|
|
371
|
+
`Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s)` +
|
|
372
|
+
(viz.unchanged > 0 ? `, ${viz.unchanged} unchanged` : ""),
|
|
431
373
|
),
|
|
432
374
|
);
|
|
433
375
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
findStackForBranch,
|
|
7
|
+
getOrderedBranches,
|
|
8
|
+
saveRestackState,
|
|
9
|
+
stackBase,
|
|
10
|
+
} from "../lib/metadata.ts";
|
|
6
11
|
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
7
12
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
8
13
|
import { confirmAction } from "../lib/ui.ts";
|
|
@@ -56,12 +61,27 @@ with the base branch included.
|
|
|
56
61
|
|
|
57
62
|
p.intro(pc.cyan("Git Stack Sync"));
|
|
58
63
|
|
|
59
|
-
// Find the
|
|
64
|
+
// Find the root branch (whose parent is the stack's base)
|
|
65
|
+
const base = stackBase(stack);
|
|
66
|
+
const baseIsTrunk = base === "main" || base === "master";
|
|
60
67
|
const baseBranch = ordered[0]!;
|
|
61
68
|
const baseParent = stack.branches[baseBranch]?.parent;
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
// sync only makes sense for trunk-rooted stacks — it fetches main and
|
|
71
|
+
// rebases onto it. A split stack is rooted on a branch in another stack, so
|
|
72
|
+
// syncing it means restacking onto that base (handled by `restack`), or
|
|
73
|
+
// re-rooting onto main once the parent stack has merged.
|
|
74
|
+
if (!baseIsTrunk) {
|
|
75
|
+
p.cancel(
|
|
76
|
+
`${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — sync doesn't apply.\n\n` +
|
|
77
|
+
` • Parent moved? Restack onto it: ${pc.green("gh-stack restack")}\n` +
|
|
78
|
+
` • Parent stack merged to main? ${pc.green("gh-stack restack --onto main")}`,
|
|
79
|
+
);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (baseParent !== base) {
|
|
84
|
+
p.cancel(`Root branch ${pc.yellow(baseBranch)} doesn't have ${base} as parent`);
|
|
65
85
|
process.exit(1);
|
|
66
86
|
}
|
|
67
87
|
|