gh-stack 0.3.1 → 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 +32 -6
- 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 +74 -141
- package/src/commands/submit.ts +83 -7
- package/src/commands/sync.ts +14 -24
- 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 +74 -1
- 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.
|
|
@@ -117,7 +124,8 @@ ls
|
|
|
117
124
|
```
|
|
118
125
|
restack [--resume] [--dry-run] [--verbose]
|
|
119
126
|
Rebase the current branch and all descendants onto their parents.
|
|
120
|
-
Uses
|
|
127
|
+
Uses metadata snapshots to recover the correct rebase base even
|
|
128
|
+
after a parent's history has been rewritten.
|
|
121
129
|
|
|
122
130
|
On conflict:
|
|
123
131
|
git rebase --continue
|
|
@@ -127,14 +135,19 @@ restack [--resume] [--dry-run] [--verbose]
|
|
|
127
135
|
|
|
128
136
|
sync [--dry-run]
|
|
129
137
|
Fetch main, rebase the base branch onto main, then restack all
|
|
130
|
-
children.
|
|
138
|
+
children. Snapshots every branch's pre-sync tip so children can be
|
|
139
|
+
correctly rebased even though their parent's history was rewritten.
|
|
131
140
|
|
|
132
|
-
merge [--dry-run] [-d|--delete-branch]
|
|
141
|
+
merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
133
142
|
Squash-merge the stack top-down via GitHub (PR3 → PR2 → PR1),
|
|
134
143
|
then enables auto-merge for the base PR into main. All merges
|
|
135
144
|
happen on GitHub so PRs show as "Merged", Linear tickets close
|
|
136
145
|
automatically, and GitHub Actions fire normally. Skips already-
|
|
137
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.
|
|
138
151
|
|
|
139
152
|
delete [<branch>]
|
|
140
153
|
Remove a branch from the stack and re-parent its children.
|
|
@@ -164,14 +177,18 @@ archive [--restore <name>]
|
|
|
164
177
|
|
|
165
178
|
```
|
|
166
179
|
--yes, -y Skip all confirmations (for agents/CI)
|
|
180
|
+
--plain Plain output — no spinners, colors, or banner boxes
|
|
167
181
|
--help Show help for a command
|
|
168
182
|
--version, -V Show version
|
|
169
183
|
```
|
|
170
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
|
+
|
|
171
187
|
## Environment Variables
|
|
172
188
|
|
|
173
189
|
```
|
|
174
|
-
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)
|
|
175
192
|
GH_STACK_NO_COLOR=1 Disable colored output
|
|
176
193
|
```
|
|
177
194
|
|
|
@@ -181,9 +198,13 @@ GH_STACK_NO_COLOR=1 Disable colored output
|
|
|
181
198
|
|
|
182
199
|
`gh-stack init` auto-detects branch chains by walking git ancestry. From the top branch, it finds all local branches whose tips are strict ancestors (but not already merged into trunk) and reconstructs the chain with correct parent relationships.
|
|
183
200
|
|
|
184
|
-
###
|
|
201
|
+
### Snapshot-Based Rebasing
|
|
202
|
+
|
|
203
|
+
The critical insight: after a parent branch's history is rewritten (e.g., rebased onto a new main), `git merge-base(child, parent)` falls all the way back to the original main — and using that as a rebase base would replay the parent's old commits onto the child, producing ghost-conflicts on the parent's own work.
|
|
185
204
|
|
|
186
|
-
|
|
205
|
+
gh-stack solves this by snapshotting every branch's tip **before** any destructive operation. When restacking a child whose parent has been rewritten, gh-stack walks the snapshots newest-first and finds the most recent recorded tip that's no longer an ancestor of the parent's current tip. That orphaned SHA is the correct rebase base — `git rebase --onto <new-parent-tip> <orphaned-old-tip> <child>` replays only the child's unique commits.
|
|
206
|
+
|
|
207
|
+
Snapshots also power `gh-stack undo`, so the same data structure does double duty.
|
|
187
208
|
|
|
188
209
|
### Stack Visualization
|
|
189
210
|
|
|
@@ -256,6 +277,11 @@ gh-stack status
|
|
|
256
277
|
|
|
257
278
|
# When PRs are approved, merge the stack
|
|
258
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
|
|
259
285
|
```
|
|
260
286
|
|
|
261
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)));
|