gh-stack 0.17.2 → 0.19.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 +256 -13
- package/package.json +9 -8
- package/src/commands/add.ts +131 -3
- package/src/commands/config.ts +133 -0
- package/src/commands/init.ts +4 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +263 -5
- package/src/commands/restack.ts +954 -211
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +145 -145
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +87 -15
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +45 -9
- package/src/lib/chain.ts +20 -0
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +98 -22
- package/src/lib/github.ts +83 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +64 -14
- package/src/lib/paths.ts +1 -1
- package/src/lib/remote-guard.ts +281 -0
- package/src/lib/run-log.ts +117 -0
- package/src/lib/using-gh-stack.md +140 -5
- package/src/types.ts +62 -2
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Before a run rewrites branches, make sure origin doesn't hold content the
|
|
2
|
+
// local branches lack (#28).
|
|
3
|
+
//
|
|
4
|
+
// The incident: `merge --collapse` squash-merged PR2 into PR1's branch on
|
|
5
|
+
// GitHub. The local PR1 never pulled that squash commit, a `sync` rebased the
|
|
6
|
+
// local PR1 and force-pushed it, and the base PR silently lost every child's
|
|
7
|
+
// content. `--force-with-lease` doesn't protect against this once the
|
|
8
|
+
// remote-tracking ref has been fetched.
|
|
9
|
+
//
|
|
10
|
+
// The hard part is not crying wolf. After a `--no-push` restack, or a run
|
|
11
|
+
// interrupted before its pushes, origin legitimately holds the *pre-rewrite*
|
|
12
|
+
// versions of local commits. Those aren't new: local had them and rewrote them
|
|
13
|
+
// on purpose. So a commit on origin counts as new content only if
|
|
14
|
+
// 1. it was never in the local branch's history (its reflog, or a tip a
|
|
15
|
+
// snapshot recorded), and
|
|
16
|
+
// 2. it isn't patch-equivalent to a local commit.
|
|
17
|
+
import { $ } from "bun";
|
|
18
|
+
import pc from "picocolors";
|
|
19
|
+
import * as p from "./output.ts";
|
|
20
|
+
import * as git from "./git.ts";
|
|
21
|
+
import { isAutoYes } from "./ui.ts";
|
|
22
|
+
import type { StackMetadata } from "../types.ts";
|
|
23
|
+
|
|
24
|
+
/** A branch whose origin copy differs but couldn't be checked. */
|
|
25
|
+
export interface Unverified {
|
|
26
|
+
branch: string;
|
|
27
|
+
remoteSha: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RemoteOnlyContent {
|
|
32
|
+
branch: string;
|
|
33
|
+
localSha: string;
|
|
34
|
+
remoteSha: string;
|
|
35
|
+
/** "sha subject", oldest first. */
|
|
36
|
+
commits: string[];
|
|
37
|
+
/** Local is simply behind origin (a fast-forward brings the commits in). */
|
|
38
|
+
behind: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How many reflog entries count as "versions local already had". */
|
|
42
|
+
const REFLOG_DEPTH = 300;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* For each branch with a copy on origin that differs from local, the commits
|
|
46
|
+
* origin has that are new content (see the module comment). `cleared` maps the
|
|
47
|
+
* branches whose origin copy differs but holds nothing new to the origin tip
|
|
48
|
+
* that was checked. `unverified` lists branches whose origin copy couldn't be
|
|
49
|
+
* checked (not fetched, or git failed): never cleared, the caller fails closed.
|
|
50
|
+
*/
|
|
51
|
+
export async function findRemoteOnlyContent(
|
|
52
|
+
meta: StackMetadata,
|
|
53
|
+
branches: string[],
|
|
54
|
+
): Promise<{
|
|
55
|
+
found: RemoteOnlyContent[];
|
|
56
|
+
cleared: Map<string, string>;
|
|
57
|
+
unverified: Unverified[];
|
|
58
|
+
}> {
|
|
59
|
+
const found: RemoteOnlyContent[] = [];
|
|
60
|
+
const cleared = new Map<string, string>();
|
|
61
|
+
const unverified: Unverified[] = [];
|
|
62
|
+
const heads = await git.remoteHeads();
|
|
63
|
+
const trunk = await git.trunkBranch();
|
|
64
|
+
|
|
65
|
+
const targets: { branch: string; local: string; remote: string }[] = [];
|
|
66
|
+
for (const branch of new Set(branches)) {
|
|
67
|
+
if (branch === trunk || branch === "main" || branch === "master") continue;
|
|
68
|
+
const remote = heads.get(branch);
|
|
69
|
+
if (!remote || !(await git.localBranchExists(branch))) continue;
|
|
70
|
+
const local = await git.revParse(branch);
|
|
71
|
+
if (remote !== local) targets.push({ branch, local, remote });
|
|
72
|
+
}
|
|
73
|
+
if (targets.length === 0) return { found, cleared, unverified };
|
|
74
|
+
|
|
75
|
+
// Origin's trunk too: commits that came from it (GitHub's "Update branch"
|
|
76
|
+
// merges main into a PR branch) aren't the user's to lose.
|
|
77
|
+
const trunkTip = heads.get(trunk);
|
|
78
|
+
await git.fetchObjectsOnly([
|
|
79
|
+
...targets.map((t) => `refs/heads/${t.branch}`),
|
|
80
|
+
...(trunkTip ? [`refs/heads/${trunk}`] : []),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
for (const { branch, local, remote } of targets) {
|
|
84
|
+
if (!(await git.refExists(`${remote}^{commit}`))) {
|
|
85
|
+
unverified.push({
|
|
86
|
+
branch,
|
|
87
|
+
remoteSha: remote,
|
|
88
|
+
reason: `origin/${branch} couldn't be fetched`,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (await git.isAncestor(remote, local)) {
|
|
93
|
+
cleared.set(branch, remote); // local already has all of it
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const known = [
|
|
98
|
+
local,
|
|
99
|
+
...(await reflogTips(branch)),
|
|
100
|
+
...snapshotTips(meta, branch),
|
|
101
|
+
...(trunkTip ? [trunkTip] : []),
|
|
102
|
+
];
|
|
103
|
+
const candidates = await revListLines([
|
|
104
|
+
"--ignore-missing",
|
|
105
|
+
"--reverse",
|
|
106
|
+
"--no-merges",
|
|
107
|
+
remote,
|
|
108
|
+
"--not",
|
|
109
|
+
...new Set(known),
|
|
110
|
+
]);
|
|
111
|
+
const unmatched = await revListLines([
|
|
112
|
+
"--cherry-pick",
|
|
113
|
+
"--right-only",
|
|
114
|
+
"--no-merges",
|
|
115
|
+
`${local}...${remote}`,
|
|
116
|
+
]);
|
|
117
|
+
if (candidates === null || unmatched === null) {
|
|
118
|
+
// No verdict is not a clean verdict: this branch stays unchecked.
|
|
119
|
+
unverified.push({ branch, remoteSha: remote, reason: "git rev-list failed comparing it" });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const fresh = candidates.filter((sha) => unmatched.includes(sha));
|
|
123
|
+
if (fresh.length === 0) {
|
|
124
|
+
cleared.set(branch, remote);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
found.push({
|
|
128
|
+
branch,
|
|
129
|
+
localSha: local,
|
|
130
|
+
remoteSha: remote,
|
|
131
|
+
commits: await oneLines(fresh),
|
|
132
|
+
behind: await git.isAncestor(local, remote),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return { found, cleared, unverified };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Earlier tips of `refs/heads/<branch>`, newest first. */
|
|
139
|
+
async function reflogTips(branch: string): Promise<string[]> {
|
|
140
|
+
const { stdout } = await $`git reflog show --format=%H refs/heads/${branch}`.nothrow().quiet();
|
|
141
|
+
return stdout.toString().split("\n").filter(Boolean).slice(0, REFLOG_DEPTH);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Tips of `branch` that snapshots recorded (the pre-rewrite versions). */
|
|
145
|
+
function snapshotTips(meta: StackMetadata, branch: string): string[] {
|
|
146
|
+
const tips = new Set<string>();
|
|
147
|
+
for (const snap of meta.snapshots ?? []) {
|
|
148
|
+
const sha = snap.branches[branch];
|
|
149
|
+
if (sha) tips.add(sha);
|
|
150
|
+
}
|
|
151
|
+
return [...tips];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `git rev-list` output, or null if it failed (never an empty "nothing found"). */
|
|
155
|
+
async function revListLines(args: string[]): Promise<string[] | null> {
|
|
156
|
+
const { stdout, exitCode } = await $`git rev-list ${args}`.nothrow().quiet();
|
|
157
|
+
if (exitCode !== 0) return null;
|
|
158
|
+
return stdout.toString().split("\n").filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** "abbrev subject" for each sha, in the given order, from one `git log`. */
|
|
162
|
+
async function oneLines(shas: string[]): Promise<string[]> {
|
|
163
|
+
const { stdout } = await $`git log --no-walk=unsorted --format=${"%h %s"} ${shas}`
|
|
164
|
+
.nothrow()
|
|
165
|
+
.quiet();
|
|
166
|
+
return stdout.toString().split("\n").filter(Boolean);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Stop a run before it rewrites a branch whose origin copy has new content.
|
|
171
|
+
* Interactive: ask (default no). Under --yes: refuse, since no flag the user
|
|
172
|
+
* gave said "drop commits from origin". `--overwrite-remote` proceeds. A dry
|
|
173
|
+
* run only reports.
|
|
174
|
+
*/
|
|
175
|
+
export async function guardRemoteContent(
|
|
176
|
+
meta: StackMetadata,
|
|
177
|
+
branches: string[],
|
|
178
|
+
opts: { command: string; overwrite: boolean; dryRun: boolean },
|
|
179
|
+
): Promise<Map<string, string>> {
|
|
180
|
+
const { found, cleared, unverified } = await findRemoteOnlyContent(meta, branches);
|
|
181
|
+
if (found.length === 0 && unverified.length === 0) return cleared;
|
|
182
|
+
|
|
183
|
+
if (unverified.length > 0) {
|
|
184
|
+
console.log();
|
|
185
|
+
p.log.error(
|
|
186
|
+
"Couldn't check these branches' copies on origin for commits the local branches lack:",
|
|
187
|
+
);
|
|
188
|
+
for (const u of unverified) {
|
|
189
|
+
console.log(
|
|
190
|
+
` ${pc.yellow(u.branch)} ${pc.dim(`(origin ${u.remoteSha.slice(0, 8)}: ${u.reason})`)}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
console.log();
|
|
194
|
+
console.log(
|
|
195
|
+
` ${opts.command} would rebase or force-push them without knowing what's there. Look first:`,
|
|
196
|
+
);
|
|
197
|
+
for (const u of unverified) {
|
|
198
|
+
console.log(
|
|
199
|
+
` ${pc.green(`git fetch origin ${u.branch} && git log ${u.branch}..origin/${u.branch}`)}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
console.log(` Then re-run ${pc.green(`gh-stack ${opts.command}`)}.`);
|
|
203
|
+
console.log(
|
|
204
|
+
` Or, to overwrite them regardless: ${pc.green(`gh-stack ${opts.command} --overwrite-remote`)}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (found.length === 0) return decide(cleared, [], unverified, opts);
|
|
208
|
+
|
|
209
|
+
console.log();
|
|
210
|
+
p.log.error(
|
|
211
|
+
"Origin has commits that the local branches don't, and they aren't earlier versions of local commits:",
|
|
212
|
+
);
|
|
213
|
+
for (const f of found) {
|
|
214
|
+
console.log(
|
|
215
|
+
` ${pc.yellow(f.branch)} ${pc.dim(`(origin ${f.remoteSha.slice(0, 8)}, local ${f.localSha.slice(0, 8)})`)}`,
|
|
216
|
+
);
|
|
217
|
+
for (const c of f.commits) console.log(` ${pc.red("+")} ${c}`);
|
|
218
|
+
}
|
|
219
|
+
console.log();
|
|
220
|
+
console.log(
|
|
221
|
+
` ${opts.command} would rebase or force-push ${found.length === 1 ? "this branch" : "these branches"} without them, and they'd be lost from origin and the PR.`,
|
|
222
|
+
);
|
|
223
|
+
console.log(
|
|
224
|
+
" This is what happens after `merge --collapse` squashes a child PR into its parent on GitHub.",
|
|
225
|
+
);
|
|
226
|
+
console.log();
|
|
227
|
+
console.log(" Bring them into the local branch first:");
|
|
228
|
+
for (const f of found) {
|
|
229
|
+
const shas = f.commits.map((c) => c.split(" ")[0]).join(" ");
|
|
230
|
+
console.log(
|
|
231
|
+
` ${pc.green(f.behind ? `git checkout ${f.branch} && git pull --ff-only origin ${f.branch}` : `git checkout ${f.branch} && git cherry-pick ${shas}`)}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
console.log(` Then re-run ${pc.green(`gh-stack ${opts.command}`)}.`);
|
|
235
|
+
console.log(
|
|
236
|
+
` Or, to drop them on purpose: ${pc.green(`gh-stack ${opts.command} --overwrite-remote`)}`,
|
|
237
|
+
);
|
|
238
|
+
return decide(cleared, found, unverified, opts);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What to do about branches with new or unchecked content on origin: report
|
|
243
|
+
* (dry run), proceed (--overwrite-remote, or an interactive yes), or stop
|
|
244
|
+
* (--yes, or no). Proceeding clears their checked tips for an explicit lease.
|
|
245
|
+
*/
|
|
246
|
+
async function decide(
|
|
247
|
+
cleared: Map<string, string>,
|
|
248
|
+
found: RemoteOnlyContent[],
|
|
249
|
+
unverified: Unverified[],
|
|
250
|
+
opts: { overwrite: boolean; dryRun: boolean },
|
|
251
|
+
): Promise<Map<string, string>> {
|
|
252
|
+
console.log();
|
|
253
|
+
if (opts.dryRun) {
|
|
254
|
+
p.log.warn("[DRY RUN] A real run would stop here.");
|
|
255
|
+
return cleared;
|
|
256
|
+
}
|
|
257
|
+
const overwrite = () => {
|
|
258
|
+
for (const f of found) cleared.set(f.branch, f.remoteSha);
|
|
259
|
+
for (const u of unverified) cleared.set(u.branch, u.remoteSha);
|
|
260
|
+
return cleared;
|
|
261
|
+
};
|
|
262
|
+
if (opts.overwrite) {
|
|
263
|
+
p.log.warn(
|
|
264
|
+
"--overwrite-remote: continuing. Origin's copies of those branches will be overwritten.",
|
|
265
|
+
);
|
|
266
|
+
return overwrite();
|
|
267
|
+
}
|
|
268
|
+
if (isAutoYes()) {
|
|
269
|
+
p.cancel("Stopped before rewriting anything (--yes never overwrites what it couldn't check).");
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
const ok = await p.confirm({
|
|
273
|
+
message: "Rewrite anyway, overwriting origin's copies of those branches?",
|
|
274
|
+
initialValue: false,
|
|
275
|
+
});
|
|
276
|
+
if (p.isCancel(ok) || !ok) {
|
|
277
|
+
p.cancel("Stopped before rewriting anything.");
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
return overwrite();
|
|
281
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The saved state of a restack/sync/join run, read back: is a run still
|
|
2
|
+
// pending, where did it stop, and what did it do (#15, #29).
|
|
3
|
+
//
|
|
4
|
+
// The state itself is written by restack.ts as the run goes (see RestackState).
|
|
5
|
+
// This module only interprets it, so `restack`, `sync`, `merge`, `join` and
|
|
6
|
+
// `undo` agree on what "an interrupted run" means.
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import * as p from "./output.ts";
|
|
9
|
+
import { InvalidRunStateError, clearRestackState, loadRestackState } from "./metadata.ts";
|
|
10
|
+
import type { RestackState, RunLogEntry } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The saved run state, or null. A state that can't be used (unreadable, or
|
|
14
|
+
* from another gh-stack version) is never upgraded: with `discard` (the
|
|
15
|
+
* `--fresh` path) it's deleted with a note, otherwise this exits 1 saying how
|
|
16
|
+
* to discard it.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadRunState(opts: { discard?: boolean } = {}): Promise<RestackState | null> {
|
|
19
|
+
try {
|
|
20
|
+
return await loadRestackState();
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (!(err instanceof InvalidRunStateError)) throw err;
|
|
23
|
+
if (opts.discard) {
|
|
24
|
+
p.log.warn(`Discarding the saved run state at ${err.path}: ${err.reason}.`);
|
|
25
|
+
await clearRestackState();
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
p.cancel(
|
|
29
|
+
`${err.message}\n\n Discard it and restack from the current branch:\n ${pc.green("gh-stack restack --fresh")}`,
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A saved run that hasn't finished: a conflict stop, a kill, failed or held
|
|
37
|
+
* pushes, or a rebase that was saved as started but never ran (or was
|
|
38
|
+
* aborted). All of them are resumable; nothing is inferred from the branch
|
|
39
|
+
* position, since a kill before the rebase started looks just like an abort.
|
|
40
|
+
*/
|
|
41
|
+
export async function findPendingRun(
|
|
42
|
+
opts: { discard?: boolean } = {},
|
|
43
|
+
): Promise<RestackState | null> {
|
|
44
|
+
const state = await loadRunState(opts);
|
|
45
|
+
return state && !state.finished_at ? state : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Branches whose push failed, was declined or held, and hasn't succeeded since. */
|
|
49
|
+
export function outstandingPushes(state: RestackState): string[] {
|
|
50
|
+
const out = new Set<string>();
|
|
51
|
+
for (const e of state.log) {
|
|
52
|
+
if (e.action === "push-failed" || e.action === "push-declined" || e.action === "push-held") {
|
|
53
|
+
out.add(e.branch);
|
|
54
|
+
} else if (e.action === "pushed") {
|
|
55
|
+
out.delete(e.branch);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...out];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where the run stopped, for "An interrupted sync was detected (stopped at …)". */
|
|
62
|
+
export function stoppedAt(state: RestackState): string {
|
|
63
|
+
if (state.rebase) return `${state.rebase.branch}: rebasing onto ${state.rebase.onto}`;
|
|
64
|
+
const next = state.chain[state.current_index];
|
|
65
|
+
if (next) return next;
|
|
66
|
+
const unpushed = outstandingPushes(state);
|
|
67
|
+
if (unpushed.length > 0) return `${unpushed.join(", ")}: not pushed`;
|
|
68
|
+
return "the end of its chain";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What's left for a resume to do, one line per item. */
|
|
72
|
+
export function remainingSteps(state: RestackState): string[] {
|
|
73
|
+
const steps: string[] = [];
|
|
74
|
+
for (const b of outstandingPushes(state)) steps.push(`push ${b}`);
|
|
75
|
+
if (state.rebase) steps.push(`finish rebasing ${state.rebase.branch}, then push it`);
|
|
76
|
+
const from = state.current_index + (state.rebase ? 1 : 0);
|
|
77
|
+
const rest = state.chain.slice(from);
|
|
78
|
+
if (rest.length > 0) steps.push(`restack and push ${rest.join(", ")}`);
|
|
79
|
+
return steps;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatLogEntry(e: RunLogEntry): string {
|
|
83
|
+
const b = pc.yellow(e.branch);
|
|
84
|
+
switch (e.action) {
|
|
85
|
+
case "rebased":
|
|
86
|
+
return `${pc.green("✓")} rebased ${b} onto ${e.onto ?? "its parent"}`;
|
|
87
|
+
case "up-to-date":
|
|
88
|
+
return `${pc.dim("·")} ${b} already on ${e.onto ?? "its parent"}`;
|
|
89
|
+
case "rebase-declined":
|
|
90
|
+
return `${pc.dim("–")} skipped rebasing ${b} (declined)`;
|
|
91
|
+
case "pushed":
|
|
92
|
+
return `${pc.green("✓")} pushed ${b}`;
|
|
93
|
+
case "push-failed":
|
|
94
|
+
return `${pc.red("✗")} push of ${b} failed`;
|
|
95
|
+
case "push-declined":
|
|
96
|
+
return `${pc.yellow("–")} push of ${b} declined`;
|
|
97
|
+
case "push-held":
|
|
98
|
+
return `${pc.yellow("⏸")} held ${b} back${e.detail ? ` (${e.detail})` : ""}`;
|
|
99
|
+
case "local-only":
|
|
100
|
+
return `${pc.dim("·")} ${b} is local-only (not submitted), not pushed`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The run's log as indented lines (empty when nothing happened yet). */
|
|
105
|
+
export function formatRunLog(state: RestackState): string[] {
|
|
106
|
+
return state.log.map((e) => ` ${formatLogEntry(e)}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** "sync, started 2026-10-27 14:03" (local time) */
|
|
110
|
+
const two = (n: number) => String(n).padStart(2, "0");
|
|
111
|
+
|
|
112
|
+
export function describeRun(state: RestackState): string {
|
|
113
|
+
const at = state.started_at ? new Date(state.started_at) : null;
|
|
114
|
+
if (!at || Number.isNaN(at.getTime())) return state.command;
|
|
115
|
+
const when = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
116
|
+
return `${state.command}, started ${when}`;
|
|
117
|
+
}
|
|
@@ -62,8 +62,25 @@ export GH_STACK_YES=1 # skip every confirmation, plain output
|
|
|
62
62
|
(network-free) or `gh-stack status --json` (adds PR/CI/review state).
|
|
63
63
|
- **Always pass `-t`/`-b` to `submit`** so PRs get real titles/bodies instead
|
|
64
64
|
of generic auto-generated ones (see below).
|
|
65
|
+
- **Run `gh-stack config` once per repo** to see its defaults. A repo whose
|
|
66
|
+
`.gh-stack.toml` sets `[submit] draft = true` opens every new PR as a draft
|
|
67
|
+
without `--draft`; pass `--no-draft` only when you were asked for a
|
|
68
|
+
ready-for-review PR. `submit --json` reports `draft` for PRs it created.
|
|
69
|
+
- **Need the PR number or URL? Add `--json` to `submit`** instead of running
|
|
70
|
+
`gh pr view` afterwards. stdout is only the JSON; logs go to stderr.
|
|
65
71
|
- Commands that rebase (`restack`, `sync`, `merge`) **refuse to run on a dirty
|
|
66
72
|
tree** — commit or stash first.
|
|
73
|
+
- **`--yes` also accepts pushes.** To rebase without publishing (e.g. until a
|
|
74
|
+
human approves), add `--no-push` to `restack`/`sync`, or export
|
|
75
|
+
`GH_STACK_NO_PUSH=1` (a repo may also set `[restack] push = false`). It wins
|
|
76
|
+
over `--yes` for pushes and lists what's ahead of origin at the end; publish
|
|
77
|
+
later with `gh-stack restack --push`. `merge` and `submit --restack` always
|
|
78
|
+
push. `restack --resume` keeps the interrupted run's push decision unless you
|
|
79
|
+
pass `--push`/`--no-push`. `--yes` itself can't come from a config file:
|
|
80
|
+
always pass it or `GH_STACK_YES=1`.
|
|
81
|
+
- A prompt reached without a TTY on stdin **exits 1** (naming the prompt)
|
|
82
|
+
instead of hanging. If you see `refusing to prompt`, re-run with
|
|
83
|
+
`GH_STACK_YES=1`.
|
|
67
84
|
|
|
68
85
|
## Core workflow
|
|
69
86
|
|
|
@@ -94,16 +111,47 @@ created a branch with `git checkout -b` and want to `sync`/`restack` it _before_
|
|
|
94
111
|
opening a PR, `add` registers it in a stack (network-free — no push, no PR):
|
|
95
112
|
|
|
96
113
|
```bash
|
|
97
|
-
gh-stack add
|
|
98
|
-
gh-stack add <branch>
|
|
114
|
+
gh-stack add # track the current branch
|
|
115
|
+
gh-stack add <branch> # track a named branch
|
|
116
|
+
gh-stack add --parent <branch> # stack the current branch directly on <branch>
|
|
99
117
|
```
|
|
100
118
|
|
|
119
|
+
**A branch with no commits yet can't be placed by ancestry.** It sits _at_ its
|
|
120
|
+
parent's tip, so `init`/`add` root it at trunk and warn that it "has no commits
|
|
121
|
+
of its own". Fix it with `gh-stack add --parent <parent>`, which tracks the
|
|
122
|
+
parent too if needed. `--parent` also re-homes a branch that's already tracked,
|
|
123
|
+
but only when it's alone in its stack with no PR.
|
|
124
|
+
|
|
101
125
|
It walks local ancestry: a branch sitting on top of an existing stack is adopted
|
|
102
126
|
into it (parent auto-detected); otherwise a new stack is created from the chain.
|
|
103
127
|
This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
|
|
104
128
|
tip you hadn't submitted yet. (`submit` still auto-tracks too — `add` is just the
|
|
105
129
|
no-publish path.)
|
|
106
130
|
|
|
131
|
+
**Splice an existing branch/PR into a stack — `gh-stack join`.** For a branch
|
|
132
|
+
that should have been stacked but was cut from main on its own (often with its
|
|
133
|
+
own PR already open):
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
gh-stack join <branch> --dry-run # preview: resulting stack + each PR's projected diff
|
|
137
|
+
gh-stack join <branch> # insert as the new bottom of the current stack
|
|
138
|
+
gh-stack join <branch> --above <parent> # insert between <parent> and its children
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`join` syncs the incoming branch onto its new parent first, replaying only its
|
|
142
|
+
own commits, then restacks everything above it, pushes, and retargets the PR
|
|
143
|
+
bases on GitHub. **Don't do this by hand with `git rebase`:** rebasing children
|
|
144
|
+
onto a branch that's behind main silently drags merged main commits into their
|
|
145
|
+
diffs. Run `--dry-run` first and check each PR's commit/file count. A conflict
|
|
146
|
+
syncing the incoming branch leaves the stack untouched (resolve,
|
|
147
|
+
`git rebase --continue`, re-run the join); a conflict restacking above it resumes
|
|
148
|
+
with `gh-stack restack --resume`. A branch alone in its own one-branch stack is
|
|
149
|
+
absorbed; one in a larger stack is refused. Unless you pass `--no-push`, the
|
|
150
|
+
incoming branch must already be on origin (`git push -u origin <branch>`). If a
|
|
151
|
+
child was cut from an older, since-rewritten version of the incoming branch,
|
|
152
|
+
join refuses and prints the `git rebase --onto` that drops the stale copies: run
|
|
153
|
+
it, then re-run the join.
|
|
154
|
+
|
|
107
155
|
### 2. Push & create PRs — `submit`
|
|
108
156
|
|
|
109
157
|
`submit` is the workhorse: **idempotent, scope-aware, self-healing**. It pushes
|
|
@@ -114,6 +162,7 @@ visualization in every PR body.
|
|
|
114
162
|
```bash
|
|
115
163
|
gh-stack submit # push downstack + create/update PRs
|
|
116
164
|
gh-stack submit -d # create new PRs as drafts
|
|
165
|
+
gh-stack submit --no-draft # ready for review despite a draft default
|
|
117
166
|
gh-stack submit --dry-run # preview, no side effects
|
|
118
167
|
gh-stack submit --restack # then propagate up to children (see below)
|
|
119
168
|
gh-stack submit -t "<title>" -b "<body>" # explicit title/body for new PRs
|
|
@@ -137,6 +186,54 @@ EOF
|
|
|
137
186
|
)"
|
|
138
187
|
```
|
|
139
188
|
|
|
189
|
+
**`submit --json` returns what happened to each branch** (stdout is only the
|
|
190
|
+
JSON document, on every exit; logs go to stderr, so capture stdout alone):
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
gh-stack submit --json -t "<title>" --body-file /tmp/body.md > /tmp/submit.json
|
|
194
|
+
jq -r '.branches[-1].url' /tmp/submit.json # the current branch's PR
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"stack": "my-stack",
|
|
200
|
+
"current_branch": "feat-b",
|
|
201
|
+
"branches": [
|
|
202
|
+
{
|
|
203
|
+
"branch": "feat-a",
|
|
204
|
+
"parent": "main",
|
|
205
|
+
"pr": 101,
|
|
206
|
+
"url": "https://github.com/org/repo/pull/101",
|
|
207
|
+
"title": "Feat A (1/2)",
|
|
208
|
+
"action": "unchanged",
|
|
209
|
+
"pushed": false,
|
|
210
|
+
"draft": null
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"branch": "feat-b",
|
|
214
|
+
"parent": "feat-a",
|
|
215
|
+
"pr": 102,
|
|
216
|
+
"url": "https://github.com/org/repo/pull/102",
|
|
217
|
+
"title": "Feat B (2/2)",
|
|
218
|
+
"action": "created",
|
|
219
|
+
"pushed": true,
|
|
220
|
+
"draft": false
|
|
221
|
+
}
|
|
222
|
+
],
|
|
223
|
+
"error": null
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`branches` is the submit scope (trunk → current) in stack order, so on success
|
|
228
|
+
the last entry is the branch you're on. `action` is `created`, `updated`
|
|
229
|
+
(pushed, or base/title/description edited), `unchanged`, or `failed` (with an
|
|
230
|
+
`error`). `draft` is only known for PRs created this run (else `null`). When
|
|
231
|
+
the run stops early (nothing to submit, a bad `--body-file`, a push failure),
|
|
232
|
+
top-level `error` is `{ "message": "..." }` and `branches` lists what happened
|
|
233
|
+
before it. Exit code is 1 whenever `error` is set or any branch `failed`.
|
|
234
|
+
`--json` implies `--no-edit` and can't be combined with `--dry-run` or
|
|
235
|
+
`--restack`; run `gh-stack restack` as a separate step.
|
|
236
|
+
|
|
140
237
|
**`submit -t`/`-b` also updates an existing PR in place** — pass them while on a
|
|
141
238
|
branch that already has a PR to change just that one (visualization-safe).
|
|
142
239
|
|
|
@@ -163,6 +260,8 @@ gh-stack checkout # interactive picker (alias: co)
|
|
|
163
260
|
gh-stack sync # fetch trunk, rebase base onto it, restack ALL children
|
|
164
261
|
gh-stack restack # rebase current branch + descendants onto their parents
|
|
165
262
|
gh-stack restack --dry-run
|
|
263
|
+
gh-stack restack --no-push # rebase locally only (also: GH_STACK_NO_PUSH=1)
|
|
264
|
+
gh-stack restack --push # push despite GH_STACK_NO_PUSH / [restack] push = false
|
|
166
265
|
```
|
|
167
266
|
|
|
168
267
|
**⚠️ Restack propagates UPWARD from the current branch.** Run it from the
|
|
@@ -181,7 +280,27 @@ edit several branches then restack once at the end (only the last branch ends
|
|
|
181
280
|
up pushed).
|
|
182
281
|
|
|
183
282
|
**On rebase conflict:** resolve, `git add <files>`, `git rebase --continue`,
|
|
184
|
-
then `gh-stack restack --resume` to finish the remaining branches.
|
|
283
|
+
then `gh-stack restack --resume` to finish the remaining branches. That's the
|
|
284
|
+
command even when the conflict came from `sync`. If you forget `--resume`, a
|
|
285
|
+
bare `restack`/`sync` notices the interrupted run and, under `--yes`, resumes
|
|
286
|
+
it. Don't pass `--fresh` unless you were asked to abandon the run: it discards
|
|
287
|
+
the run's remaining steps. `git rebase --abort` does not end the run; a resume
|
|
288
|
+
runs that rebase again.
|
|
289
|
+
|
|
290
|
+
**Interrupted or partly pushed run** (killed, timed out, or a push failed):
|
|
291
|
+
run `gh-stack restack --resume`. It continues from the saved state and retries
|
|
292
|
+
the pushes, parents first. If everything already finished it prints the run's
|
|
293
|
+
log and exits 0. A branch whose parent didn't reach origin is never pushed onto
|
|
294
|
+
it (its PR would be diffed against the parent's old history); the run lists it
|
|
295
|
+
as held and exits 1 until `--resume` gets the parent pushed. `merge` and `join`
|
|
296
|
+
refuse to run while a restack/sync is pending.
|
|
297
|
+
|
|
298
|
+
**"Origin has commits that the local branches don't"** (restack/sync exit 1
|
|
299
|
+
before changing anything): a branch's copy on origin has commits it never had,
|
|
300
|
+
often the squash commit a `merge --collapse` added on GitHub. Run the
|
|
301
|
+
`git pull --ff-only` / `git cherry-pick` lines it prints, then re-run. Don't
|
|
302
|
+
pass `--overwrite-remote` unless you were asked to drop those commits: it
|
|
303
|
+
deletes them from the PR.
|
|
185
304
|
|
|
186
305
|
**`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI,
|
|
187
306
|
`sync` detects it and advances the stack past the merged branch (re-rooting onto
|
|
@@ -220,16 +339,32 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
|
|
|
220
339
|
- **`--collapse`** (with `merge` or `merge --approved`): stop after collapsing
|
|
221
340
|
into the base PR — review on GitHub, then re-run `merge` to finish.
|
|
222
341
|
|
|
342
|
+
After a collapse, the base branch on origin holds the children's squash
|
|
343
|
+
commits. Pull it before touching it locally (`git pull --ff-only origin <base>`).
|
|
344
|
+
Before the base PR goes to main, `merge` checks those commits are still in it.
|
|
345
|
+
If it exits 2 with "PR #N's changes are missing from PR #base", the base was
|
|
346
|
+
rewritten without them: follow the printed steps (`gh api … | git am`) and
|
|
347
|
+
re-run `merge`. If it exits 2 saying PR #N's changes are UNVERIFIED, nothing
|
|
348
|
+
is known to be lost, but it couldn't be checked (often a GitHub or network
|
|
349
|
+
hiccup: re-run first): compare `gh pr diff <N> --name-only` against the base
|
|
350
|
+
PR's files. `merge --base` does the same check for a base with collapsed PRs. Use `--skip-collapse-check` only after
|
|
351
|
+
confirming the base PR's diff on GitHub really has PR #N's changes.
|
|
352
|
+
|
|
353
|
+
A push that fails with "fetched but never brought into local <branch>" means
|
|
354
|
+
origin has commits you haven't integrated (git's --force-if-includes): pull or
|
|
355
|
+
cherry-pick them, don't force past it.
|
|
356
|
+
|
|
223
357
|
### 7. Maintenance
|
|
224
358
|
|
|
225
359
|
```bash
|
|
226
360
|
gh-stack delete # remove a branch from the stack (re-parents children)
|
|
227
361
|
gh-stack split # cut a stack into two at a branch
|
|
362
|
+
gh-stack join <b> # splice an existing branch/PR into the stack (--above <b>, --dry-run)
|
|
228
363
|
gh-stack rename # rename a tracked branch (keeps metadata in sync)
|
|
229
364
|
gh-stack undo # restore from the last snapshot
|
|
230
365
|
gh-stack archive # manage archived stacks
|
|
231
366
|
gh-stack doctor # migrate & repair stack metadata
|
|
232
|
-
gh-stack update-prs #
|
|
367
|
+
gh-stack update-prs # repair stack viz in PR descriptions + adopt PRs opened with gh pr create
|
|
233
368
|
```
|
|
234
369
|
|
|
235
370
|
## Stack visualization
|
|
@@ -249,7 +384,7 @@ needed.
|
|
|
249
384
|
- **Never force-pushes trunk.**
|
|
250
385
|
- **Metadata is local and never committed:** per-stack files under
|
|
251
386
|
`.git/.gh-stack/` plus git branch config. Inspect it via `gh-stack stacks
|
|
252
|
-
--json`, not by reading the store. (`gh-stack doctor` migrates the old v2
|
|
387
|
+
--json`, not by reading the store. (`gh-stack doctor` migrates the old v2
|
|
253
388
|
`.git/gh-stack-metadata.json` monolith.)
|
|
254
389
|
|
|
255
390
|
## Don'ts
|
package/src/types.ts
CHANGED
|
@@ -59,11 +59,71 @@ export interface Snapshot {
|
|
|
59
59
|
baseTip?: string;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* One step of a restack/sync/join run, appended to the run's action log as it
|
|
64
|
+
* happens (#15), so an interrupted run can say what it finished and what's left.
|
|
65
|
+
*/
|
|
66
|
+
export interface RunLogEntry {
|
|
67
|
+
at: string; // ISO 8601
|
|
68
|
+
branch: string;
|
|
69
|
+
action:
|
|
70
|
+
| "rebased" // rebased onto `onto`
|
|
71
|
+
| "up-to-date" // already on its parent: nothing to rebase
|
|
72
|
+
| "rebase-declined"
|
|
73
|
+
| "pushed"
|
|
74
|
+
| "push-failed"
|
|
75
|
+
| "push-declined"
|
|
76
|
+
| "push-held" // not pushed: its parent isn't on origin at the commit it sits on
|
|
77
|
+
| "local-only"; // no remote and no PR: left for `submit`
|
|
78
|
+
onto?: string;
|
|
79
|
+
detail?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A rebase that has started but not been seen to finish (a conflict stop, or a kill). */
|
|
83
|
+
export interface InFlightRebase {
|
|
84
|
+
branch: string;
|
|
85
|
+
onto: string; // ref the branch is being rebased onto
|
|
86
|
+
onto_sha: string; // that ref's tip when the rebase started
|
|
87
|
+
pre_tip: string; // the branch's tip before the rebase (what `git rebase --abort` restores)
|
|
88
|
+
/** The `--onto` upstream (`git rebase --onto <onto> <boundary> <branch>`); null for a plain `git rebase <onto>`. */
|
|
89
|
+
boundary: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Bumped when RestackState changes shape; a file with another version is refused, not upgraded. */
|
|
93
|
+
export const RUN_STATE_VERSION = 2;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The saved state of a restack/sync/join run, at `.git/.gh-stack/restack-state.json`.
|
|
97
|
+
* Written when the run starts and after every step, and kept after it finishes
|
|
98
|
+
* (`finished_at` set) so `restack --resume` can report a completed run.
|
|
99
|
+
*/
|
|
63
100
|
export interface RestackState {
|
|
64
|
-
|
|
101
|
+
version: typeof RUN_STATE_VERSION;
|
|
102
|
+
/** What started the run: "restack", "sync", "join" or "restack --onto". */
|
|
103
|
+
command: string;
|
|
65
104
|
stack_name: string;
|
|
105
|
+
/** Branches the run covers, parents before children. */
|
|
66
106
|
chain: string[];
|
|
107
|
+
/**
|
|
108
|
+
* Branches in `chain` with only their push left: never rebased by the run
|
|
109
|
+
* (restack's trunk-parented roots, sync's root) or already rebased by it.
|
|
110
|
+
*/
|
|
111
|
+
push_only: string[];
|
|
112
|
+
/** First branch in `chain` not yet processed. */
|
|
113
|
+
current_index: number;
|
|
114
|
+
/** The rebase in progress at `chain[current_index]`, if any. */
|
|
115
|
+
rebase: InFlightRebase | null;
|
|
116
|
+
/** Branches whose rebase was declined: not done, so a resume offers them again. */
|
|
117
|
+
declined: string[];
|
|
118
|
+
/**
|
|
119
|
+
* The run's push decision. `--resume` keeps it (a local-only run stays
|
|
120
|
+
* local) unless the resume passes --push/--no-push (#36, #39).
|
|
121
|
+
*/
|
|
122
|
+
push: boolean;
|
|
123
|
+
started_at: string;
|
|
124
|
+
/** Set once every step, pushes included, is done. */
|
|
125
|
+
finished_at: string | null;
|
|
126
|
+
log: RunLogEntry[];
|
|
67
127
|
}
|
|
68
128
|
|
|
69
129
|
// PR info from GitHub API
|