gh-stack 0.4.0 → 0.7.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 +22 -2
- package/package.json +1 -1
- package/src/commands/archive.ts +1 -1
- package/src/commands/bottom.ts +1 -1
- package/src/commands/checkout.ts +1 -1
- package/src/commands/create.ts +1 -1
- package/src/commands/delete.ts +1 -1
- package/src/commands/down.ts +1 -1
- package/src/commands/init.ts +2 -45
- package/src/commands/log.ts +1 -1
- package/src/commands/merge.ts +103 -8
- package/src/commands/restack.ts +9 -4
- package/src/commands/submit.ts +83 -7
- package/src/commands/sync.ts +1 -1
- package/src/commands/top.ts +1 -1
- package/src/commands/undo.ts +1 -1
- package/src/commands/up.ts +1 -1
- package/src/commands/update-prs.ts +1 -1
- package/src/index.ts +19 -3
- package/src/lib/chain.ts +140 -0
- package/src/lib/cli.ts +10 -3
- package/src/lib/git.ts +10 -0
- package/src/lib/output.ts +90 -0
- package/src/lib/safety.ts +1 -1
- package/src/lib/snapshot.ts +45 -19
- package/src/lib/ui.ts +1 -1
package/README.md
CHANGED
|
@@ -83,6 +83,13 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
|
|
|
83
83
|
that don't have them, and update all PR descriptions with stack
|
|
84
84
|
visualization. Idempotent — safe to run repeatedly.
|
|
85
85
|
|
|
86
|
+
Self-healing: if the current branch isn't tracked in a stack yet,
|
|
87
|
+
submit auto-detects the chain from trunk → current, registers (or
|
|
88
|
+
reconciles into) a stack, then pushes + creates PRs for the whole
|
|
89
|
+
chain. You never need to run `gh-stack init` first — running submit
|
|
90
|
+
from the top of a bare chain of local branches converges to the
|
|
91
|
+
expected end state (stack registered, branches pushed, PRs created).
|
|
92
|
+
|
|
86
93
|
--title/-t and --body/-b provide PR details directly (skips prompts).
|
|
87
94
|
--body-file reads body from a file. In --yes mode without --title,
|
|
88
95
|
auto-generates PR titles from branch names.
|
|
@@ -131,12 +138,16 @@ sync [--dry-run]
|
|
|
131
138
|
children. Snapshots every branch's pre-sync tip so children can be
|
|
132
139
|
correctly rebased even though their parent's history was rewritten.
|
|
133
140
|
|
|
134
|
-
merge [--dry-run] [-d|--delete-branch]
|
|
141
|
+
merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
135
142
|
Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
|
|
136
143
|
then enables auto-merge for the base PR into main. All merges
|
|
137
144
|
happen on GitHub so PRs show as "Merged", Linear tickets close
|
|
138
145
|
automatically, and GitHub Actions fire normally. Skips already-
|
|
139
146
|
merged PRs (safe to re-run). Waits for GitHub between merges.
|
|
147
|
+
--collapse Stop after collapsing the stack into the base PR;
|
|
148
|
+
leaves the base PR open against main so you can
|
|
149
|
+
review the cumulative diff on GitHub. Re-run
|
|
150
|
+
`gh-stack merge` (without --collapse) to finish.
|
|
140
151
|
|
|
141
152
|
delete [<branch>]
|
|
142
153
|
Remove a branch from the stack and re-parent its children.
|
|
@@ -166,14 +177,18 @@ archive [--restore <name>]
|
|
|
166
177
|
|
|
167
178
|
```
|
|
168
179
|
--yes, -y Skip all confirmations (for agents/CI)
|
|
180
|
+
--plain Plain output — no spinners, colors, or banner boxes
|
|
169
181
|
--help Show help for a command
|
|
170
182
|
--version, -V Show version
|
|
171
183
|
```
|
|
172
184
|
|
|
185
|
+
`--plain` is auto-enabled when `--yes` / `GH_STACK_YES=1` is set, so agents get clean, easily-filtered output by default. Use `--plain` alone if you want plain output but still interactive prompts.
|
|
186
|
+
|
|
173
187
|
## Environment Variables
|
|
174
188
|
|
|
175
189
|
```
|
|
176
|
-
GH_STACK_YES=1 Skip all confirmations (same as --yes)
|
|
190
|
+
GH_STACK_YES=1 Skip all confirmations (same as --yes; also enables --plain)
|
|
191
|
+
GH_STACK_PLAIN=1 Plain output (same as --plain)
|
|
177
192
|
GH_STACK_NO_COLOR=1 Disable colored output
|
|
178
193
|
```
|
|
179
194
|
|
|
@@ -262,6 +277,11 @@ gh-stack status
|
|
|
262
277
|
|
|
263
278
|
# When PRs are approved, merge the stack
|
|
264
279
|
gh-stack merge # squash-merges down, pushes, enables auto-merge
|
|
280
|
+
|
|
281
|
+
# Or: collapse first to review the cumulative diff before shipping
|
|
282
|
+
gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
|
|
283
|
+
# ...review base PR on GitHub...
|
|
284
|
+
gh-stack merge # finishes base PR → main + archives the stack
|
|
265
285
|
```
|
|
266
286
|
|
|
267
287
|
## Agent/CI Usage
|
package/package.json
CHANGED
package/src/commands/archive.ts
CHANGED
package/src/commands/bottom.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack bottom — Jump to the base of the stack
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
|
package/src/commands/checkout.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack checkout — Switch between branches or stacks
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { writeMetadata, getOrderedBranches, findStackForBranch } from "../lib/metadata.ts";
|
package/src/commands/create.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack create — Create a new branch and add to the stack
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, addBranchToStack } from "../lib/metadata.ts";
|
package/src/commands/delete.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack delete — Delete a branch from the stack (re-link parent chain)
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
|
|
5
5
|
import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
|
package/src/commands/down.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack down — Move to parent branch (downstack)
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, writeMetadata } from "../lib/metadata.ts";
|
package/src/commands/init.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack init — Create a new stack, auto-detecting branch chains
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "../lib/metadata.ts";
|
|
13
13
|
import { getPrNumber } from "../lib/github.ts";
|
|
14
14
|
import { isAutoYes } from "../lib/ui.ts";
|
|
15
|
+
import { detectBranchChain } from "../lib/chain.ts";
|
|
15
16
|
import type { StackMetadata, Branch } from "../types.ts";
|
|
16
17
|
|
|
17
18
|
const HELP = `
|
|
@@ -37,50 +38,6 @@ EXAMPLES
|
|
|
37
38
|
gh-stack init --parent develop # Different trunk branch
|
|
38
39
|
`;
|
|
39
40
|
|
|
40
|
-
/**
|
|
41
|
-
* Detect a chain of branches from trunk to the current branch.
|
|
42
|
-
*
|
|
43
|
-
* Walks down from the current branch by finding local branches whose
|
|
44
|
-
* tips are strict ancestors of the current branch. Sorts by commit
|
|
45
|
-
* distance (closest = direct parent) to reconstruct the chain.
|
|
46
|
-
*
|
|
47
|
-
* Returns branches in bottom-up order: [closest-to-trunk, ..., current]
|
|
48
|
-
*/
|
|
49
|
-
async function detectBranchChain(currentBranch: string, trunk: string): Promise<string[]> {
|
|
50
|
-
const allBranches = await git.allLocalBranches();
|
|
51
|
-
|
|
52
|
-
// Branches to exclude: current, trunk, and always main/master
|
|
53
|
-
const excluded = new Set([currentBranch, trunk, "main", "master"]);
|
|
54
|
-
|
|
55
|
-
// Find branches that are strict ancestors of current (and not trunk)
|
|
56
|
-
const ancestors: { name: string; distance: number }[] = [];
|
|
57
|
-
|
|
58
|
-
for (const branch of allBranches) {
|
|
59
|
-
if (excluded.has(branch)) continue;
|
|
60
|
-
|
|
61
|
-
// Is this branch's tip an ancestor of current?
|
|
62
|
-
if (await git.isAncestor(branch, currentBranch)) {
|
|
63
|
-
// Skip branches already merged into trunk (stale/old branches)
|
|
64
|
-
if (await git.isAncestor(branch, trunk)) continue;
|
|
65
|
-
|
|
66
|
-
// How many commits between this branch and current?
|
|
67
|
-
const distance = await git.commitCount(branch, currentBranch);
|
|
68
|
-
if (distance > 0) {
|
|
69
|
-
ancestors.push({ name: branch, distance });
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Sort by distance descending (furthest from current = closest to trunk)
|
|
75
|
-
ancestors.sort((a, b) => b.distance - a.distance);
|
|
76
|
-
|
|
77
|
-
// Build chain: ancestors (sorted trunk→current) + current branch
|
|
78
|
-
const chain = ancestors.map((a) => a.name);
|
|
79
|
-
chain.push(currentBranch);
|
|
80
|
-
|
|
81
|
-
return chain;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
41
|
export default async function init(args: string[]): Promise<void> {
|
|
85
42
|
// Parse flags
|
|
86
43
|
let nameFlag: string | undefined;
|
package/src/commands/log.ts
CHANGED
package/src/commands/merge.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack merge — Squash-merge stack top-down via GitHub
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { $ } from "bun";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
@@ -41,13 +41,14 @@ async function waitForMergeable(
|
|
|
41
41
|
export default async function merge(args: string[]): Promise<void> {
|
|
42
42
|
const dryRun = args.includes("--dry-run");
|
|
43
43
|
const deleteFlag = args.includes("--delete-branch") || args.includes("-d");
|
|
44
|
+
const collapse = args.includes("--collapse") || args.includes("--stop-at-base");
|
|
44
45
|
|
|
45
46
|
if (args.includes("--help")) {
|
|
46
47
|
console.log(`
|
|
47
48
|
gh-stack merge — Squash-merge stack via GitHub
|
|
48
49
|
|
|
49
50
|
USAGE
|
|
50
|
-
gh-stack merge [--dry-run] [-d|--delete-branch]
|
|
51
|
+
gh-stack merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
51
52
|
|
|
52
53
|
Squash-merges the stack from top to bottom via GitHub:
|
|
53
54
|
PR3 → squash into PR2, PR2 → squash into PR1, PR1 → auto-merge into main.
|
|
@@ -61,6 +62,11 @@ Waits for GitHub to process between merges if needed.
|
|
|
61
62
|
OPTIONS
|
|
62
63
|
-d, --delete-branch Delete remote branches after merging
|
|
63
64
|
--dry-run Show what would happen without doing anything
|
|
65
|
+
--collapse Stop after collapsing the stack into the base PR;
|
|
66
|
+
do NOT merge base PR into main. Lets you review the
|
|
67
|
+
cumulative diff on GitHub first. Re-run ${pc.green("gh-stack merge")}
|
|
68
|
+
(without --collapse) to finish the job.
|
|
69
|
+
--stop-at-base Alias for --collapse
|
|
64
70
|
`);
|
|
65
71
|
return;
|
|
66
72
|
}
|
|
@@ -81,6 +87,12 @@ OPTIONS
|
|
|
81
87
|
if (ordered.length <= 1) {
|
|
82
88
|
// Single branch — just enable auto-merge
|
|
83
89
|
const basePr = stack.branches[ordered[0]!]?.pr;
|
|
90
|
+
if (collapse) {
|
|
91
|
+
p.log.info(
|
|
92
|
+
`Single-branch stack — nothing to collapse. The base PR${basePr ? ` (#${basePr})` : ""} already targets main.`,
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
84
96
|
if (basePr) {
|
|
85
97
|
p.log.info("Single branch stack — enabling auto-merge on GitHub.");
|
|
86
98
|
if (!dryRun) {
|
|
@@ -97,8 +109,13 @@ OPTIONS
|
|
|
97
109
|
return;
|
|
98
110
|
}
|
|
99
111
|
|
|
100
|
-
p.intro(pc.cyan("Stack Merge (via GitHub)"));
|
|
112
|
+
p.intro(pc.cyan(collapse ? "Stack Collapse (via GitHub)" : "Stack Merge (via GitHub)"));
|
|
101
113
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
114
|
+
if (collapse) {
|
|
115
|
+
p.log.info(
|
|
116
|
+
pc.dim("--collapse: will stop after collapsing into base PR; base will NOT merge to main."),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
102
119
|
console.log();
|
|
103
120
|
|
|
104
121
|
// Check PR states and build the merge plan
|
|
@@ -121,9 +138,15 @@ OPTIONS
|
|
|
121
138
|
` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → squash into ${pc.blue(parent)}${mergedLabel}`,
|
|
122
139
|
);
|
|
123
140
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
if (collapse) {
|
|
142
|
+
console.log(
|
|
143
|
+
` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.dim("(stop — review on GitHub, then re-run merge to finish)")}`,
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
console.log(
|
|
147
|
+
` ${pc.blue(baseBranch)}${basePr ? ` (#${basePr})` : ""} → ${pc.green("main")} (auto-merge)`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
127
150
|
console.log();
|
|
128
151
|
|
|
129
152
|
if (dryRun) {
|
|
@@ -140,14 +163,18 @@ OPTIONS
|
|
|
140
163
|
process.exit(1);
|
|
141
164
|
}
|
|
142
165
|
|
|
143
|
-
const confirmed = await confirmAction(
|
|
166
|
+
const confirmed = await confirmAction(
|
|
167
|
+
collapse
|
|
168
|
+
? "Collapse stack top-down via GitHub (stop at base PR)?"
|
|
169
|
+
: "Squash-merge stack top-down via GitHub?",
|
|
170
|
+
);
|
|
144
171
|
if (!confirmed) {
|
|
145
172
|
p.cancel("Cancelled");
|
|
146
173
|
process.exit(0);
|
|
147
174
|
}
|
|
148
175
|
|
|
149
176
|
// Take snapshot
|
|
150
|
-
await takeSnapshot(meta, stackName, "merge");
|
|
177
|
+
await takeSnapshot(meta, stackName, collapse ? "collapse" : "merge");
|
|
151
178
|
|
|
152
179
|
// Merge top-down via GitHub: for each PR from top, squash-merge into parent
|
|
153
180
|
for (let i = 0; i < reversed.length - 1; i++) {
|
|
@@ -205,6 +232,74 @@ OPTIONS
|
|
|
205
232
|
}
|
|
206
233
|
}
|
|
207
234
|
|
|
235
|
+
// ─── --collapse: stop here. Base PR holds the cumulative diff; let the
|
|
236
|
+
// user review on GitHub and re-run `gh-stack merge` to finish.
|
|
237
|
+
if (collapse) {
|
|
238
|
+
console.log();
|
|
239
|
+
|
|
240
|
+
// Park the user on the base branch — mirrors how normal merge lands you
|
|
241
|
+
// on `main`. Fetch origin so they can see how far behind their local
|
|
242
|
+
// base branch is (the squashed commits live only on origin/<base> now).
|
|
243
|
+
const checkoutSpinner = p.spinner();
|
|
244
|
+
checkoutSpinner.start(`Checking out base branch ${pc.yellow(baseBranch)}...`);
|
|
245
|
+
let localBehind = false;
|
|
246
|
+
try {
|
|
247
|
+
await $`git fetch origin`.quiet();
|
|
248
|
+
await git.checkout(baseBranch);
|
|
249
|
+
|
|
250
|
+
// Compare local vs origin to flag the user if their local base is stale
|
|
251
|
+
try {
|
|
252
|
+
const localSha = await git.revParse(baseBranch);
|
|
253
|
+
const remoteSha = await git.revParse(`origin/${baseBranch}`);
|
|
254
|
+
localBehind = localSha !== remoteSha;
|
|
255
|
+
} catch {}
|
|
256
|
+
|
|
257
|
+
checkoutSpinner.stop(`On base branch ${pc.yellow(baseBranch)}`);
|
|
258
|
+
} catch {
|
|
259
|
+
checkoutSpinner.stop(pc.dim(`Could not checkout ${baseBranch} — switch manually if needed`));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log();
|
|
263
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
264
|
+
console.log(pc.green(" Stack collapsed into base PR"));
|
|
265
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
266
|
+
console.log();
|
|
267
|
+
|
|
268
|
+
// Fetch the base PR URL for a clickable summary line
|
|
269
|
+
let baseUrl: string | null = null;
|
|
270
|
+
try {
|
|
271
|
+
const proc = Bun.spawn(["gh", "pr", "view", String(basePr), "--json", "url", "-q", ".url"], {
|
|
272
|
+
stdout: "pipe",
|
|
273
|
+
stderr: "pipe",
|
|
274
|
+
});
|
|
275
|
+
const out = (await new Response(proc.stdout).text()).trim();
|
|
276
|
+
if ((await proc.exited) === 0 && out) baseUrl = out;
|
|
277
|
+
} catch {}
|
|
278
|
+
|
|
279
|
+
console.log(
|
|
280
|
+
` ${pc.blue("Base PR:")} #${basePr} ${pc.yellow(baseBranch)} → ${pc.green("main")}`,
|
|
281
|
+
);
|
|
282
|
+
if (baseUrl) console.log(` ${pc.dim(baseUrl)}`);
|
|
283
|
+
if (localBehind) {
|
|
284
|
+
console.log();
|
|
285
|
+
console.log(
|
|
286
|
+
` ${pc.yellow("⚠")} Local ${pc.yellow(baseBranch)} is behind ${pc.dim(`origin/${baseBranch}`)}`,
|
|
287
|
+
);
|
|
288
|
+
console.log(
|
|
289
|
+
` ${pc.dim("(squashed commits from upper PRs live on origin only — review the diff on GitHub)")}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
console.log();
|
|
293
|
+
console.log(` ${pc.dim("Review the cumulative diff on GitHub. When ready to ship:")}`);
|
|
294
|
+
console.log(
|
|
295
|
+
` ${pc.green("gh-stack merge")} ${pc.dim("# finishes base PR → main + archives")}`,
|
|
296
|
+
);
|
|
297
|
+
console.log();
|
|
298
|
+
|
|
299
|
+
p.outro(pc.green("Collapse complete — stack left intact for review."));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
208
303
|
// Base PR → enable auto-merge into main
|
|
209
304
|
console.log();
|
|
210
305
|
console.log(pc.cyan("━".repeat(40)));
|
package/src/commands/restack.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// gh-stack restack — Rebase children onto updated parents
|
|
2
2
|
// Port of reference/gh-stack-sync.sh — the most critical command
|
|
3
|
-
import * as p from "
|
|
3
|
+
import * as p from "../lib/output.ts";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import {
|
|
@@ -281,16 +281,21 @@ async function processChain(
|
|
|
281
281
|
//
|
|
282
282
|
// Strategy:
|
|
283
283
|
// 1. Look for a recent snapshot where the parent's recorded tip is no
|
|
284
|
-
// longer an ancestor of the parent's current tip
|
|
285
|
-
//
|
|
284
|
+
// longer an ancestor of the parent's current tip AND is still an
|
|
285
|
+
// ancestor of the child. That recorded SHA is the orphaned
|
|
286
|
+
// old-parent tip — exactly the rebase base we need.
|
|
286
287
|
// 2. Fall back to `merge-base(branch, parent)` if no snapshot tells us
|
|
287
288
|
// the parent was rewritten (typical case: parent only had commits
|
|
288
289
|
// appended, not rebased).
|
|
289
290
|
//
|
|
291
|
+
// The child-ancestry check (issue #5) prevents stale snapshots from
|
|
292
|
+
// older sessions — where the child has already been rebased past the
|
|
293
|
+
// recorded point — from being used as a rebase base.
|
|
294
|
+
//
|
|
290
295
|
// Re-read metadata fresh because previous iterations of this loop may
|
|
291
296
|
// have updated snapshots.
|
|
292
297
|
const freshMeta = (await readMetadata()) ?? meta;
|
|
293
|
-
const oldBaseFromSnapshot = await findPreRewriteSha(freshMeta, parent);
|
|
298
|
+
const oldBaseFromSnapshot = await findPreRewriteSha(freshMeta, parent, branch);
|
|
294
299
|
const oldBaseFromMergeBase = await git.mergeBase(branch, parent);
|
|
295
300
|
const oldBase = oldBaseFromSnapshot ?? oldBaseFromMergeBase;
|
|
296
301
|
|
package/src/commands/submit.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// gh-stack submit — Push branches and create/update PRs
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { $ } from "bun";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
findStackForBranch,
|
|
8
|
+
getOrderedBranches,
|
|
9
|
+
readMetadata,
|
|
10
|
+
writeMetadata,
|
|
11
|
+
} from "../lib/metadata.ts";
|
|
8
12
|
import { isAutoYes } from "../lib/ui.ts";
|
|
13
|
+
import type { StackMetadata } from "../types.ts";
|
|
9
14
|
import { getPrNumber, getPrInfo, getPrBody, updatePrBody, reviewEmoji } from "../lib/github.ts";
|
|
15
|
+
import { resolveOrCreateStack } from "../lib/chain.ts";
|
|
10
16
|
import { buildStackViz } from "./update-prs.ts";
|
|
11
17
|
|
|
12
18
|
const HELP = `
|
|
@@ -19,6 +25,11 @@ Pushes all branches from trunk to the current branch, creating PRs
|
|
|
19
25
|
for branches that don't have them and updating stack visualization
|
|
20
26
|
in all PR descriptions. Idempotent — safe to run repeatedly.
|
|
21
27
|
|
|
28
|
+
Self-healing: if the current branch isn't tracked in a stack yet,
|
|
29
|
+
submit auto-detects the branch chain from trunk → current, registers
|
|
30
|
+
(or reconciles into) a stack, then pushes + creates PRs for the whole
|
|
31
|
+
chain. You never need to run \`gh-stack init\` first.
|
|
32
|
+
|
|
22
33
|
OPTIONS
|
|
23
34
|
-d, --draft Create new PRs as drafts
|
|
24
35
|
-n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
|
|
@@ -110,13 +121,50 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
110
121
|
|
|
111
122
|
const noEdit = args.includes("--no-edit") || args.includes("-n") || isAutoYes() || !!titleFlag;
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
// Read metadata, or start with an empty in-memory store. submit self-heals,
|
|
125
|
+
// so a missing metadata file is fine — we'll build the stack from local
|
|
126
|
+
// branch ancestry and persist it (unless --dry-run).
|
|
127
|
+
const meta: StackMetadata = (await readMetadata()) ?? {
|
|
128
|
+
version: 2,
|
|
129
|
+
current_stack: null,
|
|
130
|
+
stacks: {},
|
|
131
|
+
};
|
|
114
132
|
const branch = await git.currentBranch();
|
|
115
|
-
|
|
133
|
+
let stackName = findStackForBranch(meta, branch);
|
|
116
134
|
|
|
135
|
+
// ── Self-heal: branch isn't in any stack ──
|
|
136
|
+
// Reconstruct the stack from local branch ancestry so the end state is
|
|
137
|
+
// always stack registered + branches pushed + PRs created — no need to
|
|
138
|
+
// remember to run `gh-stack init` first.
|
|
139
|
+
let heal: { trunk: string; chain: string[]; created: boolean; added: string[] } | null = null;
|
|
117
140
|
if (!stackName) {
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
const trunk = await git.trunkBranch();
|
|
142
|
+
|
|
143
|
+
if (branch === trunk || branch === "main" || branch === "master") {
|
|
144
|
+
p.cancel(
|
|
145
|
+
`You're on ${pc.blue(branch)} — nothing to submit.\n\n Checkout a feature branch first.`,
|
|
146
|
+
);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let resolution;
|
|
151
|
+
try {
|
|
152
|
+
resolution = await resolveOrCreateStack(meta, branch, trunk);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
p.cancel((err as Error).message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
stackName = resolution.stackName;
|
|
159
|
+
heal = {
|
|
160
|
+
trunk,
|
|
161
|
+
chain: resolution.chain,
|
|
162
|
+
created: resolution.created,
|
|
163
|
+
added: resolution.addedBranches,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Persist the reconstructed/updated stack before pushing (skip on dry-run)
|
|
167
|
+
if (!dryRun) await writeMetadata(meta);
|
|
120
168
|
}
|
|
121
169
|
|
|
122
170
|
const stack = meta.stacks[stackName]!;
|
|
@@ -127,6 +175,34 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
127
175
|
const scope = currentIndex >= 0 ? ordered.slice(0, currentIndex + 1) : ordered;
|
|
128
176
|
|
|
129
177
|
p.intro(pc.cyan("Submit Stack"));
|
|
178
|
+
|
|
179
|
+
// Report self-heal results (stack creation / reconciliation + detected chain)
|
|
180
|
+
if (heal) {
|
|
181
|
+
if (heal.created) {
|
|
182
|
+
p.log.success(
|
|
183
|
+
`No stack found — created ${pc.yellow(stackName)} from ${heal.chain.length} local branch(es)`,
|
|
184
|
+
);
|
|
185
|
+
} else if (heal.added.length > 0) {
|
|
186
|
+
p.log.success(
|
|
187
|
+
`Reconciled ${heal.added.length} untracked branch(es) into ${pc.yellow(stackName)}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log();
|
|
192
|
+
console.log(` ${pc.dim(heal.trunk)}`);
|
|
193
|
+
for (let i = 0; i < heal.chain.length; i++) {
|
|
194
|
+
const b = heal.chain[i]!;
|
|
195
|
+
const isLast = i === heal.chain.length - 1;
|
|
196
|
+
const tree = isLast ? " ┗━" : " ┣━";
|
|
197
|
+
const isNew = heal.added.includes(b);
|
|
198
|
+
const label = b === branch ? pc.yellow(b) + pc.dim(" (current)") : b;
|
|
199
|
+
const tag = isNew ? pc.green(" +") : "";
|
|
200
|
+
console.log(`${tree} ${label}${tag}`);
|
|
201
|
+
if (!isLast) console.log(" ┃");
|
|
202
|
+
}
|
|
203
|
+
console.log();
|
|
204
|
+
}
|
|
205
|
+
|
|
130
206
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
131
207
|
p.log.info(
|
|
132
208
|
`Scope: ${scope.length} of ${ordered.length} branch(es) (downstack from ${pc.blue(branch)})`,
|
package/src/commands/sync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack sync — Fetch main, rebase base branch onto main, then restack all
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getOrderedBranches, saveRestackState } from "../lib/metadata.ts";
|
package/src/commands/top.ts
CHANGED
package/src/commands/undo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack undo — Restore from last snapshot
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import * as git from "../lib/git.ts";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
package/src/commands/up.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack up — Move to child branch (upstack)
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getChildren, writeMetadata } from "../lib/metadata.ts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// gh-stack update-prs — Update PR descriptions with stack visualization
|
|
2
2
|
// Port of reference/gh-stack-update-pr.sh
|
|
3
|
-
import * as p from "
|
|
3
|
+
import * as p from "../lib/output.ts";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import { findStackForBranch, getOrderedBranches } from "../lib/metadata.ts";
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// gh-stack — Unified stacked PR manager for squash-merge workflows
|
|
3
3
|
|
|
4
|
-
// Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it
|
|
5
|
-
|
|
4
|
+
// Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it.
|
|
5
|
+
// Also pre-detect plain mode (--plain flag, GH_STACK_YES=1, or GH_STACK_PLAIN=1)
|
|
6
|
+
// and set NO_COLOR *before* any import below — picocolors snapshots env at
|
|
7
|
+
// load time, so this must run before the import block.
|
|
8
|
+
const _earlyArgs = process.argv.slice(2);
|
|
9
|
+
const _earlyPlain =
|
|
10
|
+
_earlyArgs.includes("--plain") ||
|
|
11
|
+
process.env.GH_STACK_YES === "1" ||
|
|
12
|
+
process.env.GH_STACK_PLAIN === "1";
|
|
13
|
+
if (process.env.GH_STACK_NO_COLOR || _earlyPlain) {
|
|
6
14
|
process.env.NO_COLOR = "1";
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
import { ensureGitRepo } from "./lib/safety.ts";
|
|
10
18
|
import { parseCliArgs } from "./lib/cli.ts";
|
|
11
19
|
import { setAutoYes } from "./lib/ui.ts";
|
|
20
|
+
import { setPlain } from "./lib/output.ts";
|
|
12
21
|
|
|
13
22
|
const { version: VERSION } = await import("../package.json");
|
|
14
23
|
|
|
@@ -32,6 +41,11 @@ if (parsed.autoYes) {
|
|
|
32
41
|
setAutoYes(true);
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
// Global --plain (or GH_STACK_YES=1): drop intro/outro/spinner/color chrome.
|
|
45
|
+
if (parsed.plain) {
|
|
46
|
+
setPlain(true);
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
// Ensure we're in a git repo for all commands
|
|
36
50
|
await ensureGitRepo();
|
|
37
51
|
|
|
@@ -160,11 +174,13 @@ ${bold("INFO & MAINTENANCE")}
|
|
|
160
174
|
|
|
161
175
|
${bold("GLOBAL OPTIONS")}
|
|
162
176
|
--yes, -y Skip confirmations ${dim("(for agents/CI)")}
|
|
177
|
+
--plain Plain output: no spinners, colors, or box drawing
|
|
163
178
|
--help Show help
|
|
164
179
|
--version, -V Show version
|
|
165
180
|
|
|
166
181
|
${bold("ENVIRONMENT")}
|
|
167
|
-
GH_STACK_YES=1 Same as --yes
|
|
182
|
+
GH_STACK_YES=1 Same as --yes (also enables --plain)
|
|
183
|
+
GH_STACK_PLAIN=1 Same as --plain
|
|
168
184
|
GH_STACK_NO_COLOR Disable colored output
|
|
169
185
|
`);
|
|
170
186
|
}
|
package/src/lib/chain.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Branch-chain detection and stack reconciliation.
|
|
2
|
+
//
|
|
3
|
+
// Shared by `init` (interactive stack creation) and `submit` (self-healing:
|
|
4
|
+
// auto-create/register a stack from a bare set of local branches so the
|
|
5
|
+
// end state is always stack registered + branches pushed + PRs created).
|
|
6
|
+
import * as git from "./git.ts";
|
|
7
|
+
import { findStackForBranch } from "./metadata.ts";
|
|
8
|
+
import type { StackMetadata, Branch } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect a chain of branches from trunk to the current branch.
|
|
12
|
+
*
|
|
13
|
+
* Walks down from the current branch by finding local branches whose tips
|
|
14
|
+
* are strict ancestors of the current branch. Sorts by commit distance
|
|
15
|
+
* (closest = direct parent) to reconstruct the chain. Branches already
|
|
16
|
+
* merged into trunk are skipped (stale/old branches).
|
|
17
|
+
*
|
|
18
|
+
* Returns branches in bottom-up order: [closest-to-trunk, ..., current].
|
|
19
|
+
* For a branch based directly off trunk, returns just [current].
|
|
20
|
+
*/
|
|
21
|
+
export async function detectBranchChain(currentBranch: string, trunk: string): Promise<string[]> {
|
|
22
|
+
const allBranches = await git.allLocalBranches();
|
|
23
|
+
|
|
24
|
+
// Branches to exclude: current, trunk, and always main/master
|
|
25
|
+
const excluded = new Set([currentBranch, trunk, "main", "master"]);
|
|
26
|
+
|
|
27
|
+
// Find branches that are strict ancestors of current (and not trunk)
|
|
28
|
+
const ancestors: { name: string; distance: number }[] = [];
|
|
29
|
+
|
|
30
|
+
for (const branch of allBranches) {
|
|
31
|
+
if (excluded.has(branch)) continue;
|
|
32
|
+
|
|
33
|
+
// Is this branch's tip an ancestor of current?
|
|
34
|
+
if (await git.isAncestor(branch, currentBranch)) {
|
|
35
|
+
// Skip branches already merged into trunk (stale/old branches)
|
|
36
|
+
if (await git.isAncestor(branch, trunk)) continue;
|
|
37
|
+
|
|
38
|
+
// How many commits between this branch and current?
|
|
39
|
+
const distance = await git.commitCount(branch, currentBranch);
|
|
40
|
+
if (distance > 0) {
|
|
41
|
+
ancestors.push({ name: branch, distance });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Sort by distance descending (furthest from current = closest to trunk)
|
|
47
|
+
ancestors.sort((a, b) => b.distance - a.distance);
|
|
48
|
+
|
|
49
|
+
// Build chain: ancestors (sorted trunk→current) + current branch
|
|
50
|
+
const chain = ancestors.map((a) => a.name);
|
|
51
|
+
chain.push(currentBranch);
|
|
52
|
+
|
|
53
|
+
return chain;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface StackResolution {
|
|
57
|
+
stackName: string;
|
|
58
|
+
chain: string[]; // trunk→current order
|
|
59
|
+
created: boolean; // true if a brand-new stack was created
|
|
60
|
+
addedBranches: string[]; // branches newly registered into the stack
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ensure a stack exists for `branch`, reconstructing it from local branch
|
|
65
|
+
* ancestry if needed. Mutates `meta` in place but does NOT persist — the
|
|
66
|
+
* caller decides when to write (so `--dry-run` can preview without side
|
|
67
|
+
* effects).
|
|
68
|
+
*
|
|
69
|
+
* 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).
|
|
77
|
+
*
|
|
78
|
+
* Throws if the chain spans more than one existing stack (ambiguous).
|
|
79
|
+
*/
|
|
80
|
+
export async function resolveOrCreateStack(
|
|
81
|
+
meta: StackMetadata,
|
|
82
|
+
branch: string,
|
|
83
|
+
trunk: string,
|
|
84
|
+
): 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);
|
|
92
|
+
}
|
|
93
|
+
|
|
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
|
+
}
|
|
101
|
+
|
|
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++}`;
|
|
114
|
+
}
|
|
115
|
+
meta.stacks[stackName] = {
|
|
116
|
+
description: "",
|
|
117
|
+
last_branch: null,
|
|
118
|
+
branches: {},
|
|
119
|
+
};
|
|
120
|
+
meta.current_stack = stackName;
|
|
121
|
+
created = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stack = meta.stacks[stackName]!;
|
|
125
|
+
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]!;
|
|
133
|
+
const branchData: Branch = { parent };
|
|
134
|
+
stack.branches[name] = branchData;
|
|
135
|
+
stack.last_branch = name;
|
|
136
|
+
addedBranches.push(name);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { stackName, chain, created, addedBranches };
|
|
140
|
+
}
|
package/src/lib/cli.ts
CHANGED
|
@@ -2,11 +2,12 @@ export interface ParsedCliArgs {
|
|
|
2
2
|
command: string;
|
|
3
3
|
commandArgs: string[];
|
|
4
4
|
autoYes: boolean;
|
|
5
|
+
plain: boolean;
|
|
5
6
|
showVersion: boolean;
|
|
6
7
|
showGlobalHelp: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
const LEADING_GLOBAL_FLAGS = new Set(["--yes", "-y", "--help", "--version", "-V"]);
|
|
10
|
+
const LEADING_GLOBAL_FLAGS = new Set(["--yes", "-y", "--plain", "--help", "--version", "-V"]);
|
|
10
11
|
|
|
11
12
|
export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedCliArgs {
|
|
12
13
|
let commandIndex = 0;
|
|
@@ -20,12 +21,18 @@ export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedC
|
|
|
20
21
|
const command = rawArgs[commandIndex] || "log";
|
|
21
22
|
const commandArgs = rawArgs
|
|
22
23
|
.slice(commandIndex + 1)
|
|
23
|
-
.filter((arg) => arg !== "--yes" && arg !== "-y");
|
|
24
|
+
.filter((arg) => arg !== "--yes" && arg !== "-y" && arg !== "--plain");
|
|
25
|
+
|
|
26
|
+
const autoYes = rawArgs.includes("--yes") || rawArgs.includes("-y") || env.GH_STACK_YES === "1";
|
|
27
|
+
// Plain mode auto-enables under GH_STACK_YES=1 (agents skip the chrome anyway)
|
|
28
|
+
// or explicit --plain. Both compose with each other.
|
|
29
|
+
const plain = autoYes || rawArgs.includes("--plain") || env.GH_STACK_PLAIN === "1";
|
|
24
30
|
|
|
25
31
|
return {
|
|
26
32
|
command,
|
|
27
33
|
commandArgs,
|
|
28
|
-
autoYes
|
|
34
|
+
autoYes,
|
|
35
|
+
plain,
|
|
29
36
|
showVersion: leadingFlags.has("--version") || leadingFlags.has("-V"),
|
|
30
37
|
showGlobalHelp: !rawArgs[commandIndex] && leadingFlags.has("--help"),
|
|
31
38
|
};
|
package/src/lib/git.ts
CHANGED
|
@@ -254,6 +254,16 @@ export async function allLocalBranches(): Promise<string[]> {
|
|
|
254
254
|
return result.trim().split("\n").filter(Boolean);
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Determine the trunk branch name. Prefers "main", falls back to "master"
|
|
259
|
+
* if only that exists locally. Defaults to "main" when neither is present.
|
|
260
|
+
*/
|
|
261
|
+
export async function trunkBranch(): Promise<string> {
|
|
262
|
+
if (await localBranchExists("main")) return "main";
|
|
263
|
+
if (await localBranchExists("master")) return "master";
|
|
264
|
+
return "main";
|
|
265
|
+
}
|
|
266
|
+
|
|
257
267
|
/**
|
|
258
268
|
* Build a deterministic temp tag name for a branch.
|
|
259
269
|
* Includes a readable slug plus a stable hash to avoid collisions.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Plain-mode-aware wrapper over @clack/prompts.
|
|
2
|
+
//
|
|
3
|
+
// In plain mode (--plain flag or GH_STACK_YES=1), we strip the decorative
|
|
4
|
+
// chrome that makes gh-stack output painful for agents/log consumers:
|
|
5
|
+
// - no intro/outro boxes
|
|
6
|
+
// - no spinners (start/stop lines collapse to plain prints)
|
|
7
|
+
// - no ANSI colors (picocolors auto-disables via NO_COLOR; we also strip
|
|
8
|
+
// any residual escapes defensively)
|
|
9
|
+
//
|
|
10
|
+
// Interactive prompts (text, confirm, select) are re-exported unchanged —
|
|
11
|
+
// plain mode is about output, not interactivity. Agents already pair --plain
|
|
12
|
+
// with --yes/GH_STACK_YES so the interactive paths aren't reached.
|
|
13
|
+
import * as clack from "@clack/prompts";
|
|
14
|
+
|
|
15
|
+
let _plain = false;
|
|
16
|
+
|
|
17
|
+
export function setPlain(enabled: boolean): void {
|
|
18
|
+
_plain = enabled;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isPlain(): boolean {
|
|
22
|
+
return _plain;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ANSI_RE = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
|
|
26
|
+
function strip(s: string | undefined): string {
|
|
27
|
+
return (s ?? "").replace(ANSI_RE, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Interactive prompts (unchanged) ──
|
|
31
|
+
export const isCancel = clack.isCancel;
|
|
32
|
+
export const text = clack.text;
|
|
33
|
+
export const confirm = clack.confirm;
|
|
34
|
+
export const select = clack.select;
|
|
35
|
+
|
|
36
|
+
// ── Banner / error ──
|
|
37
|
+
export function intro(message?: string): void {
|
|
38
|
+
if (_plain) {
|
|
39
|
+
if (message) console.log(strip(message));
|
|
40
|
+
} else {
|
|
41
|
+
clack.intro(message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function outro(message?: string): void {
|
|
46
|
+
if (_plain) {
|
|
47
|
+
if (message) console.log(strip(message));
|
|
48
|
+
} else {
|
|
49
|
+
clack.outro(message);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cancel(message?: string): void {
|
|
54
|
+
if (_plain) {
|
|
55
|
+
if (message) console.error(strip(message));
|
|
56
|
+
} else {
|
|
57
|
+
clack.cancel(message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── log.* ──
|
|
62
|
+
export const log = {
|
|
63
|
+
info: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.info(msg)),
|
|
64
|
+
success: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.success(msg)),
|
|
65
|
+
warn: (msg: string) => (_plain ? console.warn(strip(msg)) : clack.log.warn(msg)),
|
|
66
|
+
error: (msg: string) => (_plain ? console.error(strip(msg)) : clack.log.error(msg)),
|
|
67
|
+
step: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.step(msg)),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ── Spinner ──
|
|
71
|
+
export interface Spinner {
|
|
72
|
+
start: (msg?: string) => void;
|
|
73
|
+
stop: (msg?: string, code?: number) => void;
|
|
74
|
+
message: (msg?: string) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function spinner(): Spinner {
|
|
78
|
+
if (!_plain) return clack.spinner() as Spinner;
|
|
79
|
+
return {
|
|
80
|
+
start: (msg) => {
|
|
81
|
+
if (msg) console.log(strip(msg));
|
|
82
|
+
},
|
|
83
|
+
stop: (msg) => {
|
|
84
|
+
if (msg) console.log(strip(msg));
|
|
85
|
+
},
|
|
86
|
+
message: (msg) => {
|
|
87
|
+
if (msg) console.log(strip(msg));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/lib/safety.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as git from "./git.ts";
|
|
3
3
|
import { metadataExists, readMetadata, findStackForBranch } from "./metadata.ts";
|
|
4
4
|
import type { StackMetadata, Stack } from "../types.ts";
|
|
5
|
-
import * as p from "
|
|
5
|
+
import * as p from "./output.ts";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
|
|
8
8
|
/**
|
package/src/lib/snapshot.ts
CHANGED
|
@@ -4,47 +4,73 @@ import * as git from "./git.ts";
|
|
|
4
4
|
import { writeMetadata, getOrderedBranches } from "./metadata.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Find the pre-rewrite SHA for a branch by walking snapshots newest-first.
|
|
7
|
+
* Find the pre-rewrite SHA for a parent branch by walking snapshots newest-first.
|
|
8
8
|
*
|
|
9
9
|
* Returns the SHA from the most recent snapshot whose recorded tip for
|
|
10
|
-
* `
|
|
11
|
-
* that snapshot was taken before the
|
|
10
|
+
* `parentBranch` is NOT an ancestor of the parent's current tip — meaning
|
|
11
|
+
* that snapshot was taken before the parent's history got rewritten
|
|
12
12
|
* (typically by `sync` rebasing it onto a new main).
|
|
13
13
|
*
|
|
14
14
|
* That pre-rewrite SHA is the correct rebase base when restacking a child
|
|
15
|
-
* onto this
|
|
15
|
+
* onto this parent — `git rebase --onto <new-tip> <pre-rewrite-tip> <child>`
|
|
16
16
|
* replays only the child's unique commits, never the parent's old history.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* still an ancestor of the
|
|
20
|
-
*
|
|
18
|
+
* **Child validation (issue #5)**: When `childBranch` is provided, any candidate
|
|
19
|
+
* SHA must also still be an ancestor of the child. If the child has already
|
|
20
|
+
* been rebased past this point (e.g., a previous restack already moved it),
|
|
21
|
+
* the recorded SHA is stale for this child and would cause `rebase --onto`
|
|
22
|
+
* to replay a wildly wrong range. We skip those candidates and keep walking.
|
|
23
|
+
*
|
|
24
|
+
* Returns null if no snapshots exist, no recorded tip qualifies, or every
|
|
25
|
+
* snapshot's recorded tip is still an ancestor of the parent's current tip
|
|
26
|
+
* (i.e., the parent has only had commits appended, never been rebased —
|
|
27
|
+
* `merge-base` is still correct).
|
|
21
28
|
*/
|
|
22
29
|
export async function findPreRewriteSha(
|
|
23
30
|
meta: StackMetadata,
|
|
24
|
-
|
|
31
|
+
parentBranch: string,
|
|
32
|
+
childBranch?: string,
|
|
25
33
|
): Promise<string | null> {
|
|
26
34
|
if (!meta.snapshots || meta.snapshots.length === 0) return null;
|
|
27
35
|
|
|
28
|
-
let
|
|
36
|
+
let currentParentTip: string;
|
|
29
37
|
try {
|
|
30
|
-
|
|
38
|
+
currentParentTip = await git.revParse(parentBranch);
|
|
31
39
|
} catch {
|
|
32
40
|
return null;
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
let currentChildTip: string | null = null;
|
|
44
|
+
if (childBranch) {
|
|
45
|
+
try {
|
|
46
|
+
currentChildTip = await git.revParse(childBranch);
|
|
47
|
+
} catch {
|
|
48
|
+
// Child doesn't exist locally — fall back to parent-only validation
|
|
49
|
+
currentChildTip = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
// Walk newest → oldest
|
|
36
54
|
for (let i = meta.snapshots.length - 1; i >= 0; i--) {
|
|
37
|
-
const recorded = meta.snapshots[i]!.branches[
|
|
55
|
+
const recorded = meta.snapshots[i]!.branches[parentBranch];
|
|
38
56
|
if (!recorded) continue;
|
|
39
|
-
if (recorded ===
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
// the
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
if (recorded === currentParentTip) continue;
|
|
58
|
+
|
|
59
|
+
// The recorded SHA must no longer be an ancestor of the parent's current
|
|
60
|
+
// tip — that's how we detect the parent's history was rewritten.
|
|
61
|
+
const stillAncestorOfParent = await git.isAncestor(recorded, currentParentTip);
|
|
62
|
+
if (stillAncestorOfParent) continue;
|
|
63
|
+
|
|
64
|
+
// Issue #5 guard: if we know the child, the recorded SHA must still be
|
|
65
|
+
// an ancestor of the child. Otherwise the child has already been rebased
|
|
66
|
+
// past this point (or never branched off it) and using it as a base
|
|
67
|
+
// would replay unrelated history.
|
|
68
|
+
if (currentChildTip) {
|
|
69
|
+
const stillAncestorOfChild = await git.isAncestor(recorded, currentChildTip);
|
|
70
|
+
if (!stillAncestorOfChild) continue;
|
|
47
71
|
}
|
|
72
|
+
|
|
73
|
+
return recorded;
|
|
48
74
|
}
|
|
49
75
|
|
|
50
76
|
return null;
|
package/src/lib/ui.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// TUI helpers: tree rendering, formatting, prompts
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "./output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import type { Stack, StackMetadata } from "../types.ts";
|
|
5
5
|
import { getOrderedBranches } from "./metadata.ts";
|