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
package/src/commands/restack.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// gh-stack restack — Rebase children onto updated parents
|
|
2
2
|
// Port of reference/gh-stack-sync.sh — the most critical command
|
|
3
|
+
//
|
|
4
|
+
// Every run (restack, sync, join's restack, restack --onto) keeps its state in
|
|
5
|
+
// `.git/.gh-stack/restack-state.json` from its first step until its last push,
|
|
6
|
+
// with an action log of what it did (#15). `restack --resume` continues from
|
|
7
|
+
// that state, and a bare `restack`/`sync` offers to resume a pending run
|
|
8
|
+
// instead of starting a different one on top of it (#29).
|
|
3
9
|
import * as p from "../lib/output.ts";
|
|
4
10
|
import pc from "picocolors";
|
|
5
11
|
import * as git from "../lib/git.ts";
|
|
@@ -9,19 +15,121 @@ import {
|
|
|
9
15
|
buildRebaseChain,
|
|
10
16
|
getOrderedBranches,
|
|
11
17
|
saveRestackState,
|
|
12
|
-
loadRestackState,
|
|
13
18
|
clearRestackState,
|
|
14
19
|
readMetadata,
|
|
15
20
|
writeMetadata,
|
|
16
21
|
} from "../lib/metadata.ts";
|
|
22
|
+
import { restackStateFile } from "../lib/paths.ts";
|
|
17
23
|
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
18
24
|
import { takeSnapshot, resolveRestackBoundary, findRecordedBaseTip } from "../lib/snapshot.ts";
|
|
19
|
-
import {
|
|
20
|
-
import
|
|
25
|
+
import { guardRemoteContent } from "../lib/remote-guard.ts";
|
|
26
|
+
import { setPrBase } from "../lib/github.ts";
|
|
27
|
+
import { confirmAction, isAutoYes } from "../lib/ui.ts";
|
|
28
|
+
import { describeSource, flagSetting, resolveFlag, type Resolved } from "../lib/config.ts";
|
|
29
|
+
import {
|
|
30
|
+
describeRun,
|
|
31
|
+
findPendingRun,
|
|
32
|
+
formatRunLog,
|
|
33
|
+
loadRunState,
|
|
34
|
+
outstandingPushes,
|
|
35
|
+
remainingSteps,
|
|
36
|
+
stoppedAt,
|
|
37
|
+
} from "../lib/run-log.ts";
|
|
38
|
+
import { RUN_STATE_VERSION } from "../types.ts";
|
|
39
|
+
import type { RestackState, RunLogEntry, Stack, StackMetadata } from "../types.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `--no-push` / `GH_STACK_NO_PUSH=1` / `[restack] push = false`: rebase
|
|
43
|
+
* locally, never publish (#36, #39). Module-level because every rebase path
|
|
44
|
+
* funnels through `pushBranchRef`. Wins over `--yes`, which still auto-accepts
|
|
45
|
+
* the rebases themselves.
|
|
46
|
+
*/
|
|
47
|
+
let noPush = false;
|
|
48
|
+
/** Why pushing is off, for the local-only summary (e.g. "--no-push"). */
|
|
49
|
+
let noPushReason = "--no-push";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The run in progress. Its state is saved after every step, so an interrupted
|
|
53
|
+
* run can be resumed and can report what it did. Null in a dry run.
|
|
54
|
+
*/
|
|
55
|
+
let run: RestackState | null = null;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `--overwrite-remote`: rewrite branches even when origin has commits they
|
|
59
|
+
* lack (#28). Off by default: such a run stops before touching anything.
|
|
60
|
+
*/
|
|
61
|
+
let overwriteRemote = false;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Origin tips the remote guard checked and cleared for overwriting this run
|
|
65
|
+
* (they hold only earlier versions of local commits, or --overwrite-remote
|
|
66
|
+
* said so). Pushes over them use an explicit lease on that sha.
|
|
67
|
+
*/
|
|
68
|
+
let verifiedTips = new Map<string, string>();
|
|
69
|
+
|
|
70
|
+
/** Stop before a run rewrites `branches` if origin holds content they lack (#28). */
|
|
71
|
+
async function guardRemote(
|
|
72
|
+
meta: StackMetadata,
|
|
73
|
+
branches: string[],
|
|
74
|
+
dryRun: boolean,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const cleared = await guardRemoteContent((await readMetadata()) ?? meta, branches, {
|
|
77
|
+
command: "restack",
|
|
78
|
+
overwrite: overwriteRemote,
|
|
79
|
+
dryRun,
|
|
80
|
+
});
|
|
81
|
+
for (const [branch, sha] of cleared) verifiedTips.set(branch, sha);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What one invocation accumulates while it walks a chain. */
|
|
85
|
+
interface ChainContext {
|
|
86
|
+
/** Branches rebased by this invocation. */
|
|
87
|
+
rebased: string[];
|
|
88
|
+
/** Every branch this invocation is responsible for publishing; verified at the end. */
|
|
89
|
+
touched: string[];
|
|
90
|
+
/** Branches whose push failed, was declined or held: their children are held too. */
|
|
91
|
+
unpublished: Set<string>;
|
|
92
|
+
/** Branch tips on origin (one `ls-remote` per run, kept current as we push). */
|
|
93
|
+
remote: Map<string, string> | null;
|
|
94
|
+
dryRun: boolean;
|
|
95
|
+
verbose: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function newContext(dryRun: boolean, verbose: boolean): ChainContext {
|
|
99
|
+
return { rebased: [], touched: [], unpublished: new Set(), remote: null, dryRun, verbose };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Origin's branch tips, read from the remote itself (not remote-tracking refs,
|
|
104
|
+
* which can be stale — #23) on first use.
|
|
105
|
+
*/
|
|
106
|
+
async function remoteTips(ctx: ChainContext): Promise<Map<string, string>> {
|
|
107
|
+
ctx.remote ??= await git.remoteHeads();
|
|
108
|
+
return ctx.remote;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether restack/sync push, from `--push`/`--no-push`, `GH_STACK_NO_PUSH`, or
|
|
113
|
+
* the `[restack] push` config default. Says so when a default turned pushing
|
|
114
|
+
* off, since nothing on the command line explains it.
|
|
115
|
+
*/
|
|
116
|
+
export async function resolvePush(args: string[]): Promise<Resolved> {
|
|
117
|
+
const push = await resolveFlag("restack.push", args);
|
|
118
|
+
if (!push.value && push.source !== "flag") {
|
|
119
|
+
p.log.info(pc.dim(`Not pushing: ${describeSource(push)}. Pass --push to override.`));
|
|
120
|
+
}
|
|
121
|
+
return push;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What to call the source of a "don't push" decision in the summary. */
|
|
125
|
+
function pushReason(push: Resolved): string {
|
|
126
|
+
return push.source === "flag" ? "--no-push" : describeSource(push);
|
|
127
|
+
}
|
|
21
128
|
|
|
22
129
|
export default async function restack(args: string[]): Promise<void> {
|
|
23
130
|
const dryRun = args.includes("--dry-run");
|
|
24
131
|
const resume = args.includes("--resume");
|
|
132
|
+
const fresh = args.includes("--fresh");
|
|
25
133
|
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
26
134
|
|
|
27
135
|
// --onto <branch>: re-root the current stack onto a new base (e.g. move a
|
|
@@ -42,13 +150,45 @@ USAGE
|
|
|
42
150
|
OPTIONS
|
|
43
151
|
--onto <ref> Re-root the stack onto a new base ref (changes stack base)
|
|
44
152
|
--yes, -y Skip confirmations (auto-accept all rebases and pushes)
|
|
45
|
-
--
|
|
153
|
+
--no-push Rebase locally only — never push (also GH_STACK_NO_PUSH=1,
|
|
154
|
+
or \`[restack] push = false\` in config; see gh-stack config).
|
|
155
|
+
Wins over --yes for pushes; rebases are still auto-accepted
|
|
156
|
+
--push Push even when a config default or env var says not to
|
|
157
|
+
--resume Continue an interrupted restack/sync (after a conflict, a
|
|
158
|
+
failed push, or a kill). Keeps the interrupted run's push
|
|
159
|
+
decision unless --push/--no-push is passed. If the run had
|
|
160
|
+
finished, prints what it did
|
|
161
|
+
--fresh Discard an interrupted run instead of resuming it, then
|
|
162
|
+
restack from the current branch
|
|
163
|
+
--overwrite-remote
|
|
164
|
+
Rewrite branches even though origin has commits they lack
|
|
165
|
+
(see below). Those commits are dropped from origin
|
|
46
166
|
--dry-run Show what would happen without executing
|
|
47
167
|
--verbose Show diagnostic info (snapshot vs merge-base)
|
|
48
168
|
|
|
169
|
+
COMMITS ONLY ON ORIGIN
|
|
170
|
+
Before rewriting anything, restack and sync check each branch's copy on
|
|
171
|
+
origin. If it has commits the local branch never had (e.g. the squash
|
|
172
|
+
commit \`merge --collapse\` adds to a parent PR's branch on GitHub), the run
|
|
173
|
+
stops and names them: pull or cherry-pick them first. Earlier versions of
|
|
174
|
+
local commits (left on origin by a --no-push or interrupted run) don't
|
|
175
|
+
count. Interactive runs ask; --yes stops.
|
|
176
|
+
|
|
177
|
+
INTERRUPTED RUNS
|
|
178
|
+
A restack or sync saves its progress after every rebase and push. If one
|
|
179
|
+
was interrupted, a bare \`gh-stack restack\` (or \`sync\`) asks whether to
|
|
180
|
+
resume it; with --yes it resumes. It never starts a different run on top
|
|
181
|
+
of an unfinished one unless you pass --fresh.
|
|
182
|
+
|
|
183
|
+
A branch whose parent's push failed (or was declined) is not pushed:
|
|
184
|
+
its PR would be diffed against the parent's old history. Run
|
|
185
|
+
\`gh-stack restack --resume\` to retry the pushes, parents first.
|
|
186
|
+
|
|
49
187
|
EXAMPLES
|
|
50
188
|
gh-stack restack Interactive (prompts before each rebase)
|
|
51
189
|
gh-stack restack --yes Non-interactive (for agents/CI)
|
|
190
|
+
gh-stack restack --yes --no-push
|
|
191
|
+
Non-interactive, local-only (review first)
|
|
52
192
|
gh-stack restack --resume Continue after resolving conflicts
|
|
53
193
|
gh-stack restack --onto main Re-root a split stack onto main after its
|
|
54
194
|
parent stack has merged
|
|
@@ -59,26 +199,53 @@ ALIASES
|
|
|
59
199
|
return;
|
|
60
200
|
}
|
|
61
201
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
202
|
+
run = null;
|
|
203
|
+
overwriteRemote = args.includes("--overwrite-remote");
|
|
204
|
+
verifiedTips = new Map();
|
|
205
|
+
|
|
206
|
+
if (resume && (fresh || ontoFlag)) {
|
|
207
|
+
p.cancel(`--resume can't be combined with ${fresh ? "--fresh" : "--onto"}.`);
|
|
69
208
|
process.exit(1);
|
|
70
209
|
}
|
|
71
210
|
|
|
72
|
-
//
|
|
211
|
+
// Reject if a git rebase is already in progress (unless we're resuming,
|
|
212
|
+
// which reports it itself). Letting commands run on top of a half-finished
|
|
213
|
+
// rebase produces deeply confusing state.
|
|
214
|
+
if (!resume && (await git.isRebaseInProgress())) {
|
|
215
|
+
await refuseRebaseInProgress("restack");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// An interrupted run is pending: resume it (or, on request, discard it)
|
|
219
|
+
// rather than silently starting a different run on top of it (#29).
|
|
220
|
+
let resuming = resume;
|
|
73
221
|
if (!resume) {
|
|
222
|
+
if (dryRun) await notePendingRun();
|
|
223
|
+
else {
|
|
224
|
+
const preflight = async () => {
|
|
225
|
+
await ensureCleanWorkingTree();
|
|
226
|
+
await resolveFlag("restack.push", args); // a bad setting stops here
|
|
227
|
+
const meta = await ensureMetadata();
|
|
228
|
+
if (!ontoFlag) {
|
|
229
|
+
const branch = await git.currentBranch();
|
|
230
|
+
if (!findStackForBranch(meta, branch)) refuseNotInStack(branch);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
resuming =
|
|
234
|
+
(await routePendingRun("restack", { fresh, onto: ontoFlag, preflight })) === "resume";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A resume keeps the push decision of the run it resumes (unless this
|
|
239
|
+
// command passes --push/--no-push), so it's settled in handleResume.
|
|
240
|
+
if (!resuming) {
|
|
241
|
+
const push = await resolvePush(args);
|
|
242
|
+
noPush = !push.value;
|
|
243
|
+
noPushReason = pushReason(push);
|
|
74
244
|
await ensureCleanWorkingTree();
|
|
75
245
|
}
|
|
76
246
|
|
|
77
247
|
const meta = await ensureMetadata();
|
|
78
|
-
const
|
|
79
|
-
// Every branch restack is responsible for this run (rebased OR already
|
|
80
|
-
// up to date) — we push each one and verify its ref landed at the end.
|
|
81
|
-
const touched: string[] = [];
|
|
248
|
+
const ctx = newContext(dryRun, verbose);
|
|
82
249
|
|
|
83
250
|
p.intro(pc.cyan("Git Stack Restack"));
|
|
84
251
|
|
|
@@ -87,45 +254,75 @@ ALIASES
|
|
|
87
254
|
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
88
255
|
|
|
89
256
|
if (ontoFlag) {
|
|
90
|
-
await handleReroot(meta, ontoFlag,
|
|
91
|
-
} else if (
|
|
92
|
-
await handleResume(meta,
|
|
257
|
+
await handleReroot(meta, ontoFlag, ctx);
|
|
258
|
+
} else if (resuming) {
|
|
259
|
+
const outcome = await handleResume(meta, ctx, flagSetting("restack.push", args));
|
|
260
|
+
if (outcome === "already-finished") return;
|
|
93
261
|
} else {
|
|
94
|
-
await handleFreshRestack(meta,
|
|
262
|
+
await handleFreshRestack(meta, ctx);
|
|
95
263
|
}
|
|
96
264
|
|
|
97
|
-
|
|
265
|
+
await finishRun(meta, ctx, "Stack Restack Complete!");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* End of a run: print its action log, verify every ref it touched landed on
|
|
270
|
+
* origin, and mark the saved state finished — or leave it pending (exit 1)
|
|
271
|
+
* when pushes are outstanding, so `restack --resume` retries them.
|
|
272
|
+
*/
|
|
273
|
+
async function finishRun(
|
|
274
|
+
meta: StackMetadata,
|
|
275
|
+
ctx: ChainContext,
|
|
276
|
+
doneMessage: string,
|
|
277
|
+
): Promise<void> {
|
|
98
278
|
console.log();
|
|
99
|
-
if (
|
|
279
|
+
if (run && run.log.length > 0) {
|
|
280
|
+
p.log.info(`Run log (${describeRun(run)}):`);
|
|
281
|
+
for (const line of formatRunLog(run)) console.log(line);
|
|
282
|
+
} else if (ctx.rebased.length === 0) {
|
|
100
283
|
p.log.warn("No branches were rebased");
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (ctx.dryRun) {
|
|
287
|
+
p.outro(pc.green(doneMessage));
|
|
288
|
+
return;
|
|
106
289
|
}
|
|
107
290
|
|
|
108
291
|
// Verification pass: assert every touched branch's ref actually landed on
|
|
109
292
|
// origin. Fail loudly (listing the stale ones) rather than reporting success
|
|
110
293
|
// when a push didn't take. (#12)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
console.log();
|
|
120
|
-
console.log(
|
|
121
|
-
` Reconcile with: ${pc.green("gh-stack restack")} (re-runs the push for stale refs)`,
|
|
122
|
-
);
|
|
123
|
-
p.outro(pc.red("Restack finished with un-pushed refs"));
|
|
124
|
-
process.exit(1);
|
|
294
|
+
const stale = await findStaleRefs(ctx.touched, meta, await remoteTips(ctx));
|
|
295
|
+
if (stale.length > 0 && noPush) {
|
|
296
|
+
// Expected under --no-push: report what's ahead of origin so a later
|
|
297
|
+
// submit/restack knows what to publish, but don't fail the run.
|
|
298
|
+
console.log();
|
|
299
|
+
p.log.info(`Not pushed (${noPushReason}) — these refs are ahead of origin:`);
|
|
300
|
+
for (const { branch, local, remote } of stale) {
|
|
301
|
+
console.log(` ${pc.yellow("↑")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
|
|
125
302
|
}
|
|
303
|
+
console.log();
|
|
304
|
+
console.log(
|
|
305
|
+
` Publish when ready: ${pc.green("gh-stack restack --push")} or ${pc.green("gh-stack submit")}`,
|
|
306
|
+
);
|
|
307
|
+
await markRunFinished();
|
|
308
|
+
p.outro(pc.green(`${doneMessage.replace(/!$/, "")} (local only)`));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (stale.length > 0) {
|
|
312
|
+
console.log();
|
|
313
|
+
p.log.error("Some branch refs are NOT in sync with origin:");
|
|
314
|
+
for (const { branch, local, remote } of stale) {
|
|
315
|
+
console.log(` ${pc.red("✗")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
|
|
316
|
+
}
|
|
317
|
+
console.log();
|
|
318
|
+
console.log(` Retry the pushes (parents first): ${pc.green("gh-stack restack --resume")}`);
|
|
319
|
+
if (run) await saveRun(); // stays pending: the pushes are what's left
|
|
320
|
+
p.outro(pc.red("Restack finished with un-pushed refs"));
|
|
321
|
+
process.exit(1);
|
|
126
322
|
}
|
|
127
323
|
|
|
128
|
-
|
|
324
|
+
await markRunFinished();
|
|
325
|
+
p.outro(pc.green(doneMessage));
|
|
129
326
|
}
|
|
130
327
|
|
|
131
328
|
/**
|
|
@@ -148,10 +345,11 @@ ALIASES
|
|
|
148
345
|
export async function findStaleRefs(
|
|
149
346
|
branches: string[],
|
|
150
347
|
meta: StackMetadata,
|
|
348
|
+
remote?: Map<string, string>,
|
|
151
349
|
): Promise<{ branch: string; local: string; remote: string }[]> {
|
|
152
350
|
const stale: { branch: string; local: string; remote: string }[] = [];
|
|
153
351
|
const seen = new Set<string>();
|
|
154
|
-
const remoteHeads = await git.remoteHeads();
|
|
352
|
+
const remoteHeads = remote ?? (await git.remoteHeads());
|
|
155
353
|
for (const branch of branches) {
|
|
156
354
|
if (seen.has(branch) || branch === "main" || branch === "master") continue;
|
|
157
355
|
seen.add(branch);
|
|
@@ -172,99 +370,401 @@ export async function findStaleRefs(
|
|
|
172
370
|
return stale;
|
|
173
371
|
}
|
|
174
372
|
|
|
175
|
-
|
|
176
|
-
meta: StackMetadata,
|
|
177
|
-
rebasedBranches: string[],
|
|
178
|
-
touched: string[],
|
|
179
|
-
verbose: boolean,
|
|
180
|
-
): Promise<void> {
|
|
181
|
-
p.log.warn("Resuming from saved state...");
|
|
373
|
+
// ── Interrupted runs (#29) ──
|
|
182
374
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
375
|
+
/**
|
|
376
|
+
* A rebase is in progress, so no new run may start. When it's the conflict a
|
|
377
|
+
* gh-stack run stopped on, say so and give that run's way forward.
|
|
378
|
+
*/
|
|
379
|
+
export async function refuseRebaseInProgress(command: string): Promise<never> {
|
|
380
|
+
const state = await loadRunState();
|
|
381
|
+
if (state && !state.finished_at && state.rebase) {
|
|
382
|
+
p.cancel(
|
|
383
|
+
`An interrupted ${state.command} stopped on a conflict rebasing ${pc.yellow(state.rebase.branch)} onto ${pc.yellow(state.rebase.onto)}, and that rebase is still in progress.\n\n` +
|
|
384
|
+
` Resolve the conflicts, then finish the ${state.command}:\n` +
|
|
385
|
+
` ${pc.green("git add <files>")}\n` +
|
|
386
|
+
` ${pc.green("git rebase --continue")}\n` +
|
|
387
|
+
` ${pc.green("gh-stack restack --resume")}\n\n` +
|
|
388
|
+
` Or give up on it: ${pc.red("git rebase --abort")}`,
|
|
389
|
+
);
|
|
390
|
+
} else {
|
|
391
|
+
p.cancel(
|
|
392
|
+
`Git rebase already in progress.\n\n Finish or abort it first:\n ${pc.green("git rebase --continue")}\n ${pc.red("git rebase --abort")}\n\n Then re-run ${pc.green(`gh-stack ${command}`)}.`,
|
|
393
|
+
);
|
|
187
394
|
}
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
188
397
|
|
|
189
|
-
|
|
190
|
-
|
|
398
|
+
/** Print what an interrupted run did and what it has left. */
|
|
399
|
+
function describePendingRun(state: RestackState): void {
|
|
400
|
+
console.log(` ${pc.dim("Stack:")} ${state.stack_name}`);
|
|
401
|
+
if (state.log.length > 0) {
|
|
402
|
+
console.log(` ${pc.dim("Done so far:")}`);
|
|
403
|
+
for (const line of formatRunLog(state)) console.log(` ${line}`);
|
|
404
|
+
}
|
|
405
|
+
const left = remainingSteps(state);
|
|
406
|
+
if (left.length > 0) {
|
|
407
|
+
console.log(` ${pc.dim("Left to do:")}`);
|
|
408
|
+
for (const step of left) console.log(` ${pc.dim("→")} ${step}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Before a fresh restack/sync: if an interrupted run is pending, decide what
|
|
414
|
+
* to do about it. Interactive: ask (resume / start fresh / cancel). Under
|
|
415
|
+
* --yes: resume, never start fresh. `--fresh` discards it (also the way out
|
|
416
|
+
* of a state file gh-stack can't read).
|
|
417
|
+
*
|
|
418
|
+
* `preflight` is the new run's own preconditions (clean tree, valid push
|
|
419
|
+
* setting, the current branch in a stack, ...). It runs before anything is
|
|
420
|
+
* discarded, so a command that's going to refuse anyway leaves the
|
|
421
|
+
* interrupted run intact.
|
|
422
|
+
*/
|
|
423
|
+
export async function routePendingRun(
|
|
424
|
+
command: "restack" | "sync",
|
|
425
|
+
opts: { fresh: boolean; onto?: string; preflight: () => Promise<void> },
|
|
426
|
+
): Promise<"resume" | "fresh"> {
|
|
427
|
+
if (opts.fresh) await opts.preflight();
|
|
428
|
+
const state = await findPendingRun({ discard: opts.fresh });
|
|
429
|
+
if (!state) return "fresh";
|
|
430
|
+
|
|
431
|
+
if (opts.fresh) {
|
|
432
|
+
p.log.warn(
|
|
433
|
+
`--fresh: discarding the interrupted ${describeRun(state)} (stopped at ${stoppedAt(state)}).`,
|
|
434
|
+
);
|
|
435
|
+
describePendingRun(state);
|
|
436
|
+
await clearRestackState();
|
|
437
|
+
return "fresh";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (opts.onto) {
|
|
191
441
|
p.cancel(
|
|
192
|
-
`
|
|
442
|
+
`An interrupted ${state.command} is pending (stopped at ${stoppedAt(state)}).\n\n` +
|
|
443
|
+
` Finish it first: ${pc.green("gh-stack restack --resume")}\n` +
|
|
444
|
+
` Or discard it: ${pc.green(`gh-stack restack --onto ${opts.onto} --fresh`)}`,
|
|
193
445
|
);
|
|
194
446
|
process.exit(1);
|
|
195
447
|
}
|
|
196
448
|
|
|
197
|
-
const
|
|
198
|
-
|
|
449
|
+
const message = `An interrupted ${state.command} was detected (stopped at ${stoppedAt(state)}). Resume it?`;
|
|
450
|
+
if (isAutoYes()) {
|
|
451
|
+
p.log.warn(`${message} ${pc.dim("(auto-yes: resuming)")}`);
|
|
452
|
+
describePendingRun(state);
|
|
453
|
+
return "resume";
|
|
454
|
+
}
|
|
199
455
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
456
|
+
p.log.warn(`An interrupted ${describeRun(state)} was detected.`);
|
|
457
|
+
describePendingRun(state);
|
|
458
|
+
const choice = await p.select({
|
|
459
|
+
message,
|
|
460
|
+
options: [
|
|
461
|
+
{ value: "resume", label: "Resume it", hint: "recommended" },
|
|
462
|
+
{
|
|
463
|
+
value: "fresh",
|
|
464
|
+
label: `Discard it and start a new ${command}`,
|
|
465
|
+
hint: "same as --fresh",
|
|
466
|
+
},
|
|
467
|
+
{ value: "cancel", label: "Cancel" },
|
|
468
|
+
],
|
|
469
|
+
});
|
|
470
|
+
if (p.isCancel(choice) || choice === "cancel") {
|
|
471
|
+
p.cancel("Cancelled — the interrupted run is still saved.");
|
|
472
|
+
process.exit(0);
|
|
473
|
+
}
|
|
474
|
+
if (choice === "fresh") {
|
|
475
|
+
await opts.preflight();
|
|
207
476
|
await clearRestackState();
|
|
208
|
-
|
|
209
|
-
console.log(` State cleared. To sync the stack, run from the desired branch:`);
|
|
210
|
-
console.log(` ${pc.green("gh-stack restack")}`);
|
|
211
|
-
return;
|
|
477
|
+
return "fresh";
|
|
212
478
|
}
|
|
479
|
+
return "resume";
|
|
480
|
+
}
|
|
213
481
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
482
|
+
/** Dry run: mention a pending run (a real run would offer to resume it), change nothing. */
|
|
483
|
+
export async function notePendingRun(): Promise<void> {
|
|
484
|
+
const state = await findPendingRun();
|
|
485
|
+
if (!state) return;
|
|
486
|
+
p.log.warn(
|
|
487
|
+
`An interrupted ${state.command} is pending (stopped at ${stoppedAt(state)}). A real run would offer to resume it (or pass --fresh to discard it).`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* For commands that rewrite or merge the stack outside a restack run (merge,
|
|
493
|
+
* join): refuse while a run is pending. Its un-pushed branches would otherwise
|
|
494
|
+
* be merged, or rebased again, in their stale state.
|
|
495
|
+
*/
|
|
496
|
+
export async function ensureNoPendingRun(command: string): Promise<void> {
|
|
497
|
+
const state = await findPendingRun();
|
|
498
|
+
if (!state) return;
|
|
499
|
+
// One run can be pending per worktree, and `command` restacks internally,
|
|
500
|
+
// which would overwrite this run's state: so this is global, not per stack.
|
|
501
|
+
const meta = await readMetadata();
|
|
502
|
+
const branch = await git.currentBranch().catch(() => null);
|
|
503
|
+
const here = meta && branch ? findStackForBranch(meta, branch) : null;
|
|
504
|
+
const pending = state.stack_name;
|
|
505
|
+
const freshEffect = !here
|
|
506
|
+
? `it drops ${pending}'s remaining steps; run it from a branch of the stack you want restacked`
|
|
507
|
+
: here === pending
|
|
508
|
+
? `it drops the remaining steps and restacks ${here} from the current branch`
|
|
509
|
+
: `it drops ${pending}'s remaining steps, then restacks ${here} (the current branch's stack), not ${pending}`;
|
|
510
|
+
p.cancel(
|
|
511
|
+
`An interrupted ${state.command} of stack ${pc.yellow(pending)} is pending (stopped at ${stoppedAt(state)}). Only one run can be pending, and ${command} would start another.\n\n` +
|
|
512
|
+
` Finish it (from any branch):\n ${pc.green("gh-stack restack --resume")}\n\n` +
|
|
513
|
+
` Or discard it:\n ${pc.green("gh-stack restack --fresh")} ${pc.dim(`(${freshEffect})`)}`,
|
|
514
|
+
);
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── Run state ──
|
|
519
|
+
|
|
520
|
+
async function saveRun(): Promise<void> {
|
|
521
|
+
if (run) await saveRestackState(run);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function beginRun(init: {
|
|
525
|
+
command: string;
|
|
526
|
+
stackName: string;
|
|
527
|
+
chain: string[];
|
|
528
|
+
pushOnly: string[];
|
|
529
|
+
startIndex?: number;
|
|
530
|
+
}): Promise<void> {
|
|
531
|
+
run = {
|
|
532
|
+
version: RUN_STATE_VERSION,
|
|
533
|
+
command: init.command,
|
|
534
|
+
stack_name: init.stackName,
|
|
535
|
+
chain: init.chain,
|
|
536
|
+
push_only: init.pushOnly,
|
|
537
|
+
current_index: init.startIndex ?? 0,
|
|
538
|
+
rebase: null,
|
|
539
|
+
declined: [],
|
|
540
|
+
push: !noPush,
|
|
541
|
+
started_at: new Date().toISOString(),
|
|
542
|
+
finished_at: null,
|
|
543
|
+
log: [],
|
|
544
|
+
};
|
|
545
|
+
await saveRun();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function record(entry: Omit<RunLogEntry, "at">): Promise<void> {
|
|
549
|
+
if (!run) return;
|
|
550
|
+
run.log.push({ at: new Date().toISOString(), ...entry });
|
|
551
|
+
await saveRun();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function markRunFinished(): Promise<void> {
|
|
555
|
+
if (!run) return;
|
|
556
|
+
run.rebase = null;
|
|
557
|
+
run.finished_at = new Date().toISOString();
|
|
558
|
+
await saveRun();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Record a rebase about to start (with what it takes to run it again), so a
|
|
563
|
+
* resume can tell a finished rebase from one that never ran or was aborted.
|
|
564
|
+
*/
|
|
565
|
+
async function startRebase(
|
|
566
|
+
branch: string,
|
|
567
|
+
onto: string,
|
|
568
|
+
preTip: string,
|
|
569
|
+
boundary: string | null,
|
|
570
|
+
): Promise<void> {
|
|
571
|
+
if (!run) return;
|
|
572
|
+
run.rebase = { branch, onto, onto_sha: await git.revParse(onto), pre_tip: preTip, boundary };
|
|
573
|
+
await saveRun();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** The rebase succeeded: what's left for this branch is its push. */
|
|
577
|
+
async function finishRebase(branch: string, onto: string, ctx: ChainContext): Promise<void> {
|
|
578
|
+
ctx.rebased.push(branch);
|
|
579
|
+
ctx.unpublished.delete(branch); // a declined rebase that's now done; a failed push re-adds it
|
|
580
|
+
if (!run) return;
|
|
581
|
+
run.rebase = null;
|
|
582
|
+
run.declined = run.declined.filter((b) => b !== branch);
|
|
583
|
+
if (!run.push_only.includes(branch)) run.push_only.push(branch);
|
|
584
|
+
await record({ branch, action: "rebased", onto });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Stop on a rebase conflict, leaving the run resumable (exit 2). */
|
|
588
|
+
function conflictStop(): never {
|
|
589
|
+
console.log();
|
|
590
|
+
console.log(pc.red("━".repeat(40)));
|
|
591
|
+
console.log(pc.red(" Rebase Conflict"));
|
|
592
|
+
console.log(pc.red("━".repeat(40)));
|
|
593
|
+
console.log();
|
|
594
|
+
console.log(pc.yellow("Conflicts detected during rebase."));
|
|
595
|
+
console.log();
|
|
596
|
+
console.log(" Please resolve the conflicts, then:");
|
|
597
|
+
console.log(` ${pc.green("git rebase --continue")}`);
|
|
598
|
+
console.log(` ${pc.green("gh-stack restack --resume")}`);
|
|
599
|
+
console.log();
|
|
600
|
+
console.log(" Or give up on the run:");
|
|
601
|
+
console.log(` ${pc.red("git rebase --abort")}`);
|
|
602
|
+
console.log(
|
|
603
|
+
` ${pc.red("gh-stack restack --fresh")} ${pc.dim("(a resume would run this rebase again)")}`,
|
|
604
|
+
);
|
|
605
|
+
process.exit(2);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The chain's branches that are, or sit above, one of `roots`. */
|
|
609
|
+
function withDescendants(stack: Stack, chain: string[], roots: string[]): Set<string> {
|
|
610
|
+
const out = new Set(roots);
|
|
611
|
+
for (const branch of chain) {
|
|
612
|
+
const parent = stack.branches[branch]?.parent;
|
|
613
|
+
if (parent && out.has(parent)) out.add(branch);
|
|
242
614
|
}
|
|
615
|
+
return out;
|
|
243
616
|
}
|
|
244
617
|
|
|
245
|
-
async function
|
|
618
|
+
async function handleResume(
|
|
246
619
|
meta: StackMetadata,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
|
|
620
|
+
ctx: ChainContext,
|
|
621
|
+
explicitPush: Resolved | null,
|
|
622
|
+
): Promise<"resumed" | "already-finished"> {
|
|
623
|
+
const state = await loadRunState();
|
|
624
|
+
if (!state) {
|
|
625
|
+
p.cancel(
|
|
626
|
+
`Nothing to resume: there's no saved run state (${await restackStateFile()}).\n\n` +
|
|
627
|
+
` If a rebase is in progress, finish it first: ${pc.green("git rebase --continue")}\n` +
|
|
628
|
+
` Then reconcile the stack from the branch to restack from:\n` +
|
|
629
|
+
` ${pc.green("gh-stack restack")}\n` +
|
|
630
|
+
` It's safe to re-run: branches already on their parent aren't rebased again,\n` +
|
|
631
|
+
` and refs that differ from origin are pushed.`,
|
|
632
|
+
);
|
|
633
|
+
process.exit(1);
|
|
634
|
+
}
|
|
254
635
|
|
|
255
|
-
if (
|
|
636
|
+
if (state.finished_at) {
|
|
637
|
+
p.log.success(
|
|
638
|
+
`Nothing left to resume — the last run (${describeRun(state)}) finished every step.`,
|
|
639
|
+
);
|
|
640
|
+
for (const line of formatRunLog(state)) console.log(line);
|
|
641
|
+
p.outro(pc.green("Nothing to resume"));
|
|
642
|
+
return "already-finished";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Keep the interrupted run's push decision, so a local-only run stays local
|
|
646
|
+
// even if the config changed meanwhile. An explicit flag on --resume wins,
|
|
647
|
+
// and becomes the run's decision for any later resume too.
|
|
648
|
+
if (explicitPush) {
|
|
649
|
+
noPush = !explicitPush.value;
|
|
650
|
+
noPushReason = "--no-push";
|
|
651
|
+
state.push = explicitPush.value;
|
|
652
|
+
} else {
|
|
653
|
+
noPush = !state.push;
|
|
654
|
+
noPushReason = "resumed a local-only run; its push decision is kept";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (await git.isRebaseInProgress()) {
|
|
658
|
+
p.cancel(
|
|
659
|
+
`Rebase still in progress.\n\n Complete it first:\n ${pc.green("git rebase --continue")}\n\n Or abort:\n ${pc.red("git rebase --abort")}`,
|
|
660
|
+
);
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
await ensureCleanWorkingTree();
|
|
664
|
+
|
|
665
|
+
// The world may have changed since: refuse rather than resume a chain that
|
|
666
|
+
// no longer matches the stack.
|
|
667
|
+
const saved = meta.stacks[state.stack_name];
|
|
668
|
+
let problem: string | null = saved ? null : `stack ${state.stack_name} no longer exists`;
|
|
669
|
+
for (const branch of state.chain) {
|
|
670
|
+
if (problem) break;
|
|
671
|
+
if (!saved!.branches[branch]) problem = `${branch} is no longer in stack ${state.stack_name}`;
|
|
672
|
+
else if (!(await git.localBranchExists(branch))) problem = `branch ${branch} no longer exists`;
|
|
673
|
+
}
|
|
674
|
+
if (problem) {
|
|
256
675
|
p.cancel(
|
|
257
|
-
`
|
|
676
|
+
`Can't resume the interrupted ${state.command}: ${problem}.\n\n` +
|
|
677
|
+
` Discard it and restack from the current branch:\n ${pc.green("gh-stack restack --fresh")}`,
|
|
258
678
|
);
|
|
259
679
|
process.exit(1);
|
|
260
680
|
}
|
|
261
681
|
|
|
682
|
+
p.log.warn(`Resuming the interrupted ${describeRun(state)} (stopped at ${stoppedAt(state)})`);
|
|
683
|
+
if (state.log.length > 0) {
|
|
684
|
+
p.log.info("Done before the interruption:");
|
|
685
|
+
for (const line of formatRunLog(state)) console.log(line);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Origin may have moved while the run was stopped.
|
|
689
|
+
await guardRemote(meta, state.chain, ctx.dryRun);
|
|
690
|
+
|
|
691
|
+
// A dry run reads the state but never writes it: `run` stays null, so
|
|
692
|
+
// nothing below records or saves.
|
|
693
|
+
if (!ctx.dryRun) {
|
|
694
|
+
run = state;
|
|
695
|
+
await saveRun(); // persists a --push/--no-push override
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Branches the run already got past only need their pushes reconciled
|
|
699
|
+
// (failed or held ones are retried, parents first); the rest restack as usual.
|
|
700
|
+
// A declined rebase isn't done, and neither is anything above it: those are
|
|
701
|
+
// offered again, and pushed only once they sit on their parents.
|
|
702
|
+
const stack = meta.stacks[state.stack_name]!;
|
|
703
|
+
const redo = withDescendants(stack, state.chain, state.declined);
|
|
704
|
+
for (const b of state.declined) ctx.unpublished.add(b);
|
|
705
|
+
const pushOnly = new Set(
|
|
706
|
+
[...state.push_only, ...state.chain.slice(0, state.current_index)].filter((b) => !redo.has(b)),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
if (state.rebase) {
|
|
710
|
+
const r = state.rebase;
|
|
711
|
+
const tip = await git.revParse(r.branch);
|
|
712
|
+
if (await git.isAncestor(r.onto_sha, tip)) {
|
|
713
|
+
p.log.success(`Rebase of ${pc.yellow(r.branch)} onto ${pc.yellow(r.onto)} completed`);
|
|
714
|
+
await finishRebase(r.branch, r.onto, ctx);
|
|
715
|
+
pushOnly.add(r.branch);
|
|
716
|
+
} else if (tip === r.pre_tip) {
|
|
717
|
+
// Saved as started, but the branch never moved: the run was killed
|
|
718
|
+
// before git began, or the user aborted the rebase. Either way the step
|
|
719
|
+
// isn't done, so run it again. An abort just meets its conflict again
|
|
720
|
+
// (and `--fresh` discards the run).
|
|
721
|
+
p.log.warn(
|
|
722
|
+
`The rebase of ${pc.yellow(r.branch)} onto ${pc.yellow(r.onto)} didn't happen (interrupted before it started, or aborted). Running it again.`,
|
|
723
|
+
);
|
|
724
|
+
if (ctx.dryRun) {
|
|
725
|
+
p.log.warn(`[DRY RUN] Would rebase ${r.branch} onto ${r.onto}`);
|
|
726
|
+
} else {
|
|
727
|
+
await git.checkout(r.branch);
|
|
728
|
+
const ok = r.boundary
|
|
729
|
+
? await git.rebaseOnto(r.onto, r.boundary, r.branch)
|
|
730
|
+
: await git.rebase(r.onto);
|
|
731
|
+
if (!ok) conflictStop();
|
|
732
|
+
p.log.success("Rebase successful");
|
|
733
|
+
await finishRebase(r.branch, r.onto, ctx);
|
|
734
|
+
}
|
|
735
|
+
pushOnly.add(r.branch);
|
|
736
|
+
} else {
|
|
737
|
+
p.cancel(
|
|
738
|
+
`${pc.yellow(r.branch)} moved since the ${state.command} stopped: it isn't rebased onto ${r.onto} (${r.onto_sha.slice(0, 8)}), and it isn't back at its pre-rebase tip ${r.pre_tip.slice(0, 8)} either. Not guessing what happened.\n\n` +
|
|
739
|
+
` Put ${r.branch} on ${r.onto} yourself, then: ${pc.green("gh-stack restack --resume")}\n` +
|
|
740
|
+
` Or discard the run: ${pc.green("gh-stack restack --fresh")}`,
|
|
741
|
+
);
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
await processChain(meta, state.stack_name, state.chain, 0, pushOnly, ctx);
|
|
747
|
+
return "resumed";
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function refuseNotInStack(branch: string): never {
|
|
751
|
+
p.cancel(
|
|
752
|
+
`Branch ${pc.blue(branch)} is not in any stack.\n\n Add it with:\n ${pc.green("gh-stack add")}`,
|
|
753
|
+
);
|
|
754
|
+
process.exit(1);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function handleFreshRestack(meta: StackMetadata, ctx: ChainContext): Promise<void> {
|
|
758
|
+
const currentBranch = await git.currentBranch();
|
|
759
|
+
const stackName = findStackForBranch(meta, currentBranch);
|
|
760
|
+
if (!stackName) refuseNotInStack(currentBranch);
|
|
761
|
+
|
|
262
762
|
const stack = meta.stacks[stackName]!;
|
|
263
763
|
await ensureValidStack(meta, stackName);
|
|
264
764
|
|
|
265
765
|
// Build the rebase chain from current branch
|
|
266
766
|
p.log.info("Building rebase chain...");
|
|
267
|
-
|
|
767
|
+
const chain = buildRebaseChain(stack, currentBranch);
|
|
268
768
|
|
|
269
769
|
if (chain.length === 0) {
|
|
270
770
|
p.cancel("Could not build rebase chain");
|
|
@@ -282,10 +782,12 @@ async function handleFreshRestack(
|
|
|
282
782
|
// Skip the stack's root branch (parent = base) when the base is the trunk —
|
|
283
783
|
// rebasing onto trunk is `sync`'s job, not restack's. For a split stack
|
|
284
784
|
// whose base is a sibling branch, the root SHOULD be rebased onto that base
|
|
285
|
-
// so changes propagate, so we don't skip it.
|
|
785
|
+
// so changes propagate, so we don't skip it. A skipped root is still pushed:
|
|
786
|
+
// if you committed on it, its ref is stale on origin (#12).
|
|
286
787
|
const base = stackBase(stack);
|
|
287
788
|
const baseIsTrunk = base === "main" || base === "master";
|
|
288
789
|
const baseBranches = baseIsTrunk ? chain.filter((b) => stack.branches[b]?.parent === base) : [];
|
|
790
|
+
const toRebase = chain.filter((b) => !baseBranches.includes(b));
|
|
289
791
|
if (baseBranches.length > 0) {
|
|
290
792
|
p.log.warn(`Skipping base branch(es) (parent: ${base}):`);
|
|
291
793
|
for (const baseBranch of baseBranches) {
|
|
@@ -298,41 +800,30 @@ async function handleFreshRestack(
|
|
|
298
800
|
);
|
|
299
801
|
console.log();
|
|
300
802
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
touched.push(baseBranch);
|
|
309
|
-
await pushBranchRef(meta, baseBranch);
|
|
310
|
-
}
|
|
803
|
+
if (toRebase.length === 0) {
|
|
804
|
+
p.log.info("No child branches to rebase — reconciling the base branch ref.");
|
|
805
|
+
} else {
|
|
806
|
+
p.log.success(`Processing ${toRebase.length} branch(es):`);
|
|
807
|
+
for (const branch of toRebase) {
|
|
808
|
+
const parent = stack.branches[branch]?.parent || "(unknown)";
|
|
809
|
+
console.log(` ${pc.yellow("→")} ${branch} ${pc.blue(`(parent: ${parent})`)}`);
|
|
311
810
|
}
|
|
811
|
+
console.log();
|
|
312
812
|
}
|
|
313
|
-
|
|
314
|
-
if (chain.length === 0) {
|
|
315
|
-
p.log.info("No child branches to rebase — base branch ref reconciled above.");
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
p.log.success(`Processing ${chain.length} branch(es):`);
|
|
320
|
-
for (const branch of chain) {
|
|
321
|
-
const parent = stack.branches[branch]?.parent || "(unknown)";
|
|
322
|
-
console.log(` ${pc.yellow("→")} ${branch} ${pc.blue(`(parent: ${parent})`)}`);
|
|
323
|
-
}
|
|
324
|
-
console.log();
|
|
325
813
|
}
|
|
326
814
|
|
|
327
|
-
|
|
815
|
+
// The base branches are force-pushed too, so they're checked with the rest.
|
|
816
|
+
await guardRemote(meta, chain, ctx.dryRun);
|
|
817
|
+
|
|
818
|
+
if (!ctx.dryRun) {
|
|
328
819
|
// Take snapshot before destructive operation. This preserves the current
|
|
329
820
|
// tip of every branch so subsequent restacks (after a parent is rewritten)
|
|
330
821
|
// can find the correct rebase base.
|
|
331
|
-
await takeSnapshot(meta, stackName, "restack");
|
|
822
|
+
if (toRebase.length > 0) await takeSnapshot(meta, stackName, "restack");
|
|
823
|
+
await beginRun({ command: "restack", stackName, chain, pushOnly: baseBranches });
|
|
332
824
|
}
|
|
333
825
|
|
|
334
|
-
|
|
335
|
-
await processChain(meta, stackName, chain, 0, rebasedBranches, touched, dryRun, verbose);
|
|
826
|
+
await processChain(meta, stackName, chain, 0, new Set(baseBranches), ctx);
|
|
336
827
|
}
|
|
337
828
|
|
|
338
829
|
/**
|
|
@@ -378,10 +869,7 @@ async function resolveRerootBoundary(
|
|
|
378
869
|
async function handleReroot(
|
|
379
870
|
meta: StackMetadata,
|
|
380
871
|
newBase: string,
|
|
381
|
-
|
|
382
|
-
touched: string[],
|
|
383
|
-
dryRun: boolean,
|
|
384
|
-
verbose: boolean,
|
|
872
|
+
ctx: ChainContext,
|
|
385
873
|
): Promise<void> {
|
|
386
874
|
const currentBranch = await git.currentBranch();
|
|
387
875
|
const stackName = findStackForBranch(meta, currentBranch);
|
|
@@ -439,12 +927,13 @@ async function handleReroot(
|
|
|
439
927
|
|
|
440
928
|
p.log.info(`Re-rooting ${pc.yellow(stackName)}: ${pc.dim(oldBase)} → ${pc.green(newBase)}`);
|
|
441
929
|
p.log.info(`Root branch: ${pc.yellow(root)}`);
|
|
442
|
-
if (verbose) {
|
|
930
|
+
if (ctx.verbose) {
|
|
443
931
|
p.log.info(`Rebase base: ${oldBaseTip.slice(0, 8)} (${oldBaseSource})`);
|
|
444
932
|
}
|
|
445
933
|
console.log();
|
|
446
934
|
|
|
447
|
-
if (dryRun) {
|
|
935
|
+
if (ctx.dryRun) {
|
|
936
|
+
await guardRemote(meta, buildRebaseChain(stack, root), true);
|
|
448
937
|
p.log.warn(`[DRY RUN] Would rebase ${root} onto ${newBase} (from ${oldBaseTip.slice(0, 8)})`);
|
|
449
938
|
const children = ordered.slice(1);
|
|
450
939
|
if (children.length > 0) {
|
|
@@ -461,55 +950,189 @@ async function handleReroot(
|
|
|
461
950
|
process.exit(0);
|
|
462
951
|
}
|
|
463
952
|
|
|
953
|
+
await guardRemote(meta, buildRebaseChain(stack, root), false);
|
|
954
|
+
|
|
464
955
|
// Snapshot before rewriting, then commit the new base into metadata.
|
|
465
956
|
await takeSnapshot(meta, stackName, "reroot");
|
|
466
957
|
stack.base = newBase;
|
|
467
958
|
stack.branches[root]!.parent = newBase;
|
|
468
959
|
await writeMetadata(meta);
|
|
469
960
|
|
|
961
|
+
// The root first, then everything above it (parents before children).
|
|
962
|
+
const chain = buildRebaseChain(stack, root);
|
|
963
|
+
await beginRun({ command: "restack --onto", stackName, chain, pushOnly: [] });
|
|
964
|
+
|
|
470
965
|
// Rebase the root onto the new base, dropping the old base's commits.
|
|
471
966
|
p.log.info(`Checking out ${pc.yellow(root)}...`);
|
|
472
967
|
await git.checkout(root);
|
|
473
968
|
p.log.info(`Rebasing ${pc.yellow(root)} onto ${pc.yellow(newBase)}...`);
|
|
474
|
-
|
|
969
|
+
await startRebase(root, newBase, await git.revParse(root), oldBaseTip);
|
|
970
|
+
if (!(await git.rebaseOnto(newBase, oldBaseTip, root))) conflictStop();
|
|
475
971
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
972
|
+
await finishRebase(root, newBase, ctx);
|
|
973
|
+
|
|
974
|
+
// Push the moved root, then restack the children onto it.
|
|
975
|
+
await processChain(meta, stackName, chain, 0, new Set([root]), ctx);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Push one branch under restack's conventions: confirm, `--force-with-lease`,
|
|
980
|
+
* never publish a local-only branch, and do nothing under `--no-push`. Exported
|
|
981
|
+
* for `join` (#32), which syncs the incoming branch itself and publishes it
|
|
982
|
+
* before retargeting PRs onto it.
|
|
983
|
+
*/
|
|
984
|
+
export async function publishBranch(
|
|
985
|
+
meta: StackMetadata,
|
|
986
|
+
branch: string,
|
|
987
|
+
opts: { noPush: boolean },
|
|
988
|
+
): Promise<void> {
|
|
989
|
+
await withNoPush(opts.noPush, () => pushBranchRef(meta, branch, newContext(false, false)));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Restack `chain` from `startIndex`: the same loop `restack` and `--resume` run,
|
|
994
|
+
* including the conflict stop (exit 2, resumable with `restack --resume`).
|
|
995
|
+
* The branches before `startIndex` are taken as done. Exported for `join`
|
|
996
|
+
* (#32). Returns the branches it rebased and touched.
|
|
997
|
+
*/
|
|
998
|
+
export async function restackChain(
|
|
999
|
+
meta: StackMetadata,
|
|
1000
|
+
stackName: string,
|
|
1001
|
+
chain: string[],
|
|
1002
|
+
startIndex: number,
|
|
1003
|
+
opts: { noPush: boolean; verbose: boolean },
|
|
1004
|
+
): Promise<{ rebased: string[]; touched: string[] }> {
|
|
1005
|
+
const ctx = newContext(false, opts.verbose);
|
|
1006
|
+
await withNoPush(opts.noPush, async () => {
|
|
1007
|
+
const done = chain.slice(0, startIndex);
|
|
1008
|
+
await beginRun({ command: "join", stackName, chain, pushOnly: done, startIndex });
|
|
1009
|
+
await processChain(meta, stackName, chain, startIndex, new Set(done), ctx);
|
|
1010
|
+
if (outstandingPushes(run!).length === 0) await markRunFinished();
|
|
1011
|
+
});
|
|
1012
|
+
return { rebased: ctx.rebased, touched: ctx.touched };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* sync's rebase phase, under the same saved state and action log as restack:
|
|
1017
|
+
* rebase the stack's root onto `trunkRef`, push it, then restack every branch
|
|
1018
|
+
* above it. A conflict stops with exit 2, resumable with `restack --resume`.
|
|
1019
|
+
* The caller has fetched the trunk and taken the snapshot.
|
|
1020
|
+
*/
|
|
1021
|
+
export async function syncChain(
|
|
1022
|
+
meta: StackMetadata,
|
|
1023
|
+
stackName: string,
|
|
1024
|
+
ordered: string[],
|
|
1025
|
+
opts: { trunkRef: string; push: Resolved; verified: Map<string, string> },
|
|
1026
|
+
): Promise<void> {
|
|
1027
|
+
noPush = !opts.push.value;
|
|
1028
|
+
noPushReason = pushReason(opts.push);
|
|
1029
|
+
run = null;
|
|
1030
|
+
verifiedTips = opts.verified;
|
|
1031
|
+
const ctx = newContext(false, false);
|
|
1032
|
+
const root = ordered[0]!;
|
|
1033
|
+
const { trunkRef } = opts;
|
|
1034
|
+
|
|
1035
|
+
await beginRun({ command: "sync", stackName, chain: ordered, pushOnly: [root] });
|
|
1036
|
+
|
|
1037
|
+
// Step 1: Rebase the root onto the trunk
|
|
1038
|
+
console.log();
|
|
1039
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
1040
|
+
console.log(`${pc.blue("Step 1:")} Rebase ${pc.yellow(root)} onto ${trunkRef}`);
|
|
1041
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
1042
|
+
console.log();
|
|
1043
|
+
|
|
1044
|
+
if (await git.isAncestor(trunkRef, root)) {
|
|
1045
|
+
p.log.success(`${root} is already up to date with ${trunkRef}`);
|
|
1046
|
+
await record({ branch: root, action: "up-to-date", onto: trunkRef });
|
|
1047
|
+
} else {
|
|
1048
|
+
const confirmed = await confirmAction(`Rebase ${pc.yellow(root)} onto ${trunkRef}?`);
|
|
1049
|
+
if (!confirmed) {
|
|
1050
|
+
await clearRestackState(); // nothing happened: no run to resume
|
|
1051
|
+
run = null;
|
|
1052
|
+
p.cancel("Cancelled");
|
|
1053
|
+
process.exit(0);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
p.log.info(`Checking out ${pc.yellow(root)}...`);
|
|
1057
|
+
await git.checkout(root);
|
|
1058
|
+
await startRebase(root, trunkRef, await git.revParse(root), null);
|
|
1059
|
+
|
|
1060
|
+
p.log.info(`Rebasing onto ${trunkRef}...`);
|
|
1061
|
+
if (!(await git.rebase(trunkRef))) conflictStop();
|
|
1062
|
+
p.log.success("Base branch rebased onto main");
|
|
1063
|
+
await finishRebase(root, trunkRef, ctx);
|
|
484
1064
|
}
|
|
485
1065
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1066
|
+
// Push the root, whether it was rebased or already current: a commit made on
|
|
1067
|
+
// it since the last push is stale on origin too. Its children are held back
|
|
1068
|
+
// if this push doesn't land.
|
|
1069
|
+
ctx.touched.push(root);
|
|
1070
|
+
await pushStep(meta, stackName, root, ctx);
|
|
1071
|
+
run!.current_index = 1;
|
|
1072
|
+
await saveRun();
|
|
489
1073
|
|
|
490
|
-
// Restack the
|
|
1074
|
+
// Step 2: Restack the rest of the stack
|
|
491
1075
|
const children = ordered.slice(1);
|
|
492
1076
|
if (children.length > 0) {
|
|
493
|
-
|
|
494
|
-
|
|
1077
|
+
console.log();
|
|
1078
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
1079
|
+
console.log(`${pc.blue("Step 2:")} Restack ${children.length} child branch(es)`);
|
|
1080
|
+
console.log(pc.cyan("━".repeat(40)));
|
|
1081
|
+
await processChain(meta, stackName, ordered, 1, new Set([root]), ctx);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
await finishRun(meta, ctx, "Sync complete!");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Run `fn` with the module-level `noPush` set, restoring it afterwards. The
|
|
1089
|
+
* exported entry points take `noPush` as a parameter; this scopes it instead of
|
|
1090
|
+
* threading it through processChain/pushBranchRef, which restack() also drives.
|
|
1091
|
+
*/
|
|
1092
|
+
async function withNoPush<T>(value: boolean, fn: () => Promise<T>): Promise<T> {
|
|
1093
|
+
const saved = noPush;
|
|
1094
|
+
noPush = value;
|
|
1095
|
+
try {
|
|
1096
|
+
return await fn();
|
|
1097
|
+
} finally {
|
|
1098
|
+
noPush = saved;
|
|
495
1099
|
}
|
|
496
1100
|
}
|
|
497
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Walk `chain` from `startIndex`: rebase each branch onto its parent (unless
|
|
1104
|
+
* it's in `pushOnly`), then push it. The run state is saved before each rebase
|
|
1105
|
+
* and after each step, so an interruption anywhere leaves a resumable run.
|
|
1106
|
+
*/
|
|
498
1107
|
async function processChain(
|
|
499
1108
|
meta: StackMetadata,
|
|
500
1109
|
stackName: string,
|
|
501
1110
|
chain: string[],
|
|
502
1111
|
startIndex: number,
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
dryRun: boolean,
|
|
506
|
-
verbose: boolean,
|
|
1112
|
+
pushOnly: Set<string>,
|
|
1113
|
+
ctx: ChainContext,
|
|
507
1114
|
): Promise<void> {
|
|
508
1115
|
const stack = meta.stacks[stackName]!;
|
|
1116
|
+
const { dryRun, verbose } = ctx;
|
|
509
1117
|
|
|
510
1118
|
for (let i = startIndex; i < chain.length; i++) {
|
|
511
1119
|
const branch = chain[i]!;
|
|
512
1120
|
const parent = stack.branches[branch]?.parent;
|
|
1121
|
+
if (run && !dryRun) {
|
|
1122
|
+
run.current_index = i;
|
|
1123
|
+
run.rebase = null;
|
|
1124
|
+
await saveRun();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Done already (rebased earlier, or a root restack doesn't rebase): only
|
|
1128
|
+
// its ref needs to reach origin.
|
|
1129
|
+
if (pushOnly.has(branch)) {
|
|
1130
|
+
if (!dryRun && (await git.localBranchExists(branch))) {
|
|
1131
|
+
ctx.touched.push(branch);
|
|
1132
|
+
await pushStep(meta, stackName, branch, ctx);
|
|
1133
|
+
}
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
513
1136
|
|
|
514
1137
|
if (!parent) {
|
|
515
1138
|
p.log.warn(`Skipping ${branch} (no parent defined)`);
|
|
@@ -530,8 +1153,9 @@ async function processChain(
|
|
|
530
1153
|
if (await git.isAncestor(parent, branch)) {
|
|
531
1154
|
p.log.success("Already up to date with parent");
|
|
532
1155
|
if (!dryRun) {
|
|
533
|
-
|
|
534
|
-
|
|
1156
|
+
await record({ branch, action: "up-to-date", onto: parent });
|
|
1157
|
+
ctx.touched.push(branch);
|
|
1158
|
+
await pushStep(meta, stackName, branch, ctx);
|
|
535
1159
|
}
|
|
536
1160
|
continue;
|
|
537
1161
|
}
|
|
@@ -546,6 +1170,11 @@ async function processChain(
|
|
|
546
1170
|
const confirmed = await confirmAction(`Rebase ${pc.yellow(branch)} onto ${pc.yellow(parent)}?`);
|
|
547
1171
|
if (!confirmed) {
|
|
548
1172
|
p.log.warn("Skipping rebase");
|
|
1173
|
+
// Not done: a resume offers it again, and nothing above it is pushed
|
|
1174
|
+
// until it sits on its parent.
|
|
1175
|
+
ctx.unpublished.add(branch);
|
|
1176
|
+
if (run && !run.declined.includes(branch)) run.declined.push(branch);
|
|
1177
|
+
await record({ branch, action: "rebase-declined", onto: parent });
|
|
549
1178
|
continue;
|
|
550
1179
|
}
|
|
551
1180
|
|
|
@@ -584,7 +1213,7 @@ async function processChain(
|
|
|
584
1213
|
console.log(" Rebase it manually with an explicit boundary:");
|
|
585
1214
|
console.log(` ${pc.green(`git rebase --onto ${parent} <old-parent-tip> ${branch}`)}`);
|
|
586
1215
|
console.log();
|
|
587
|
-
console.log(` Then re-run ${pc.green("gh-stack restack")} to continue the chain.`);
|
|
1216
|
+
console.log(` Then re-run ${pc.green("gh-stack restack --resume")} to continue the chain.`);
|
|
588
1217
|
process.exit(1);
|
|
589
1218
|
}
|
|
590
1219
|
|
|
@@ -592,15 +1221,11 @@ async function processChain(
|
|
|
592
1221
|
p.log.info(`Checking out ${pc.yellow(branch)}...`);
|
|
593
1222
|
await git.checkout(branch);
|
|
594
1223
|
|
|
595
|
-
// Save state for resume
|
|
596
|
-
await saveRestackState({
|
|
597
|
-
current_index: i,
|
|
598
|
-
stack_name: stackName,
|
|
599
|
-
chain,
|
|
600
|
-
});
|
|
601
|
-
|
|
602
1224
|
let success: boolean;
|
|
603
1225
|
const branchTip = await git.revParse(branch);
|
|
1226
|
+
// Saved before the rebase starts, so a conflict (or a kill) leaves a
|
|
1227
|
+
// resumable run that knows whether this rebase finished or must run again.
|
|
1228
|
+
await startRebase(branch, parent, branchTip, boundary.sha);
|
|
604
1229
|
if (boundary.sha === branchTip) {
|
|
605
1230
|
// The child has no commits of its own (a placeholder branch created off
|
|
606
1231
|
// the parent before any work landed). Nothing to replay — move the ref
|
|
@@ -626,38 +1251,24 @@ async function processChain(
|
|
|
626
1251
|
|
|
627
1252
|
if (success) {
|
|
628
1253
|
p.log.success("Rebase successful");
|
|
629
|
-
|
|
630
|
-
touched.push(branch);
|
|
631
|
-
await clearRestackState();
|
|
1254
|
+
await finishRebase(branch, parent, ctx);
|
|
1255
|
+
ctx.touched.push(branch);
|
|
632
1256
|
|
|
633
1257
|
// Push immediately (don't batch) so an interrupt can't leave later
|
|
634
1258
|
// branches un-pushed.
|
|
635
|
-
await
|
|
1259
|
+
await pushStep(meta, stackName, branch, ctx);
|
|
636
1260
|
} else {
|
|
637
|
-
|
|
638
|
-
console.log();
|
|
639
|
-
console.log(pc.red("━".repeat(40)));
|
|
640
|
-
console.log(pc.red(" Rebase Conflict"));
|
|
641
|
-
console.log(pc.red("━".repeat(40)));
|
|
642
|
-
console.log();
|
|
643
|
-
console.log(pc.yellow("Conflicts detected during rebase."));
|
|
644
|
-
console.log();
|
|
645
|
-
console.log(" Please resolve the conflicts, then:");
|
|
646
|
-
console.log(` ${pc.green("git rebase --continue")}`);
|
|
647
|
-
console.log(` ${pc.green("gh-stack restack --resume")}`);
|
|
648
|
-
console.log();
|
|
649
|
-
console.log(" Or abort the rebase:");
|
|
650
|
-
console.log(` ${pc.red("git rebase --abort")}`);
|
|
651
|
-
process.exit(2);
|
|
1261
|
+
conflictStop();
|
|
652
1262
|
}
|
|
653
1263
|
}
|
|
1264
|
+
|
|
1265
|
+
if (run && !dryRun) {
|
|
1266
|
+
run.current_index = chain.length;
|
|
1267
|
+
run.rebase = null;
|
|
1268
|
+
await saveRun();
|
|
1269
|
+
}
|
|
654
1270
|
}
|
|
655
1271
|
|
|
656
|
-
/**
|
|
657
|
-
* Reconcile a single branch's ref with origin. Pushes when the local tip
|
|
658
|
-
* differs from origin OR the remote branch doesn't exist yet (so a branch
|
|
659
|
-
* that was never pushed still lands). No-op when already in sync. (#12)
|
|
660
|
-
*/
|
|
661
1272
|
/**
|
|
662
1273
|
* Whether a branch has been published — it has a PR recorded in metadata.
|
|
663
1274
|
* restack/sync keep published branches' remotes in sync after a rebase, but must
|
|
@@ -670,11 +1281,140 @@ function branchHasPr(meta: StackMetadata, branch: string): boolean {
|
|
|
670
1281
|
return meta.stacks[stackName]?.branches[branch]?.pr != null;
|
|
671
1282
|
}
|
|
672
1283
|
|
|
673
|
-
|
|
674
|
-
|
|
1284
|
+
/**
|
|
1285
|
+
* The parent `branch` must not be pushed onto, if any: one whose push failed,
|
|
1286
|
+
* was declined or held this run, or whose origin copy doesn't contain the
|
|
1287
|
+
* local tip `branch` was rebased onto (#29). A child pushed onto such a parent
|
|
1288
|
+
* gets its PR diffed against the parent's old history — the "1000+ files
|
|
1289
|
+
* changed" PR. A parent merely behind origin is fine: the PR's merge-base is
|
|
1290
|
+
* still the commit the child sits on. The trunk and a parent with no remote
|
|
1291
|
+
* copy are never held against.
|
|
1292
|
+
*/
|
|
1293
|
+
async function unpublishedParent(
|
|
1294
|
+
branch: string,
|
|
1295
|
+
parent: string | undefined,
|
|
1296
|
+
ctx: ChainContext,
|
|
1297
|
+
): Promise<string | null> {
|
|
1298
|
+
if (!parent || parent === "main" || parent === "master" || parent === branch) return null;
|
|
1299
|
+
if (ctx.unpublished.has(parent)) return parent;
|
|
1300
|
+
const remote = (await remoteTips(ctx)).get(parent);
|
|
1301
|
+
if (!remote || !(await git.localBranchExists(parent))) return null;
|
|
1302
|
+
const local = await git.revParse(parent);
|
|
1303
|
+
if (local === remote || (await git.isAncestor(local, remote))) return null;
|
|
1304
|
+
return parent;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** Whether pushing `branch` would change origin (it differs, or a published branch is missing). */
|
|
1308
|
+
async function needsPush(meta: StackMetadata, branch: string, ctx: ChainContext): Promise<boolean> {
|
|
1309
|
+
const remote = (await remoteTips(ctx)).get(branch);
|
|
1310
|
+
if (!remote) return branchHasPr(meta, branch);
|
|
1311
|
+
return remote !== (await git.revParse(branch));
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Push one branch as a step of the run: held back when its parent isn't
|
|
1316
|
+
* published (#29), otherwise pushed, with the outcome recorded in the log.
|
|
1317
|
+
*/
|
|
1318
|
+
async function pushStep(
|
|
1319
|
+
meta: StackMetadata,
|
|
1320
|
+
stackName: string,
|
|
1321
|
+
branch: string,
|
|
1322
|
+
ctx: ChainContext,
|
|
1323
|
+
): Promise<void> {
|
|
1324
|
+
if (noPush || branch === "main" || branch === "master") return;
|
|
1325
|
+
const parent = meta.stacks[stackName]?.branches[branch]?.parent;
|
|
1326
|
+
|
|
1327
|
+
const heldBy = await unpublishedParent(branch, parent, ctx);
|
|
1328
|
+
if (heldBy) {
|
|
1329
|
+
if (await needsPush(meta, branch, ctx)) {
|
|
1330
|
+
p.log.warn(
|
|
1331
|
+
`Not pushing ${pc.yellow(branch)}: its parent ${pc.yellow(heldBy)} isn't on origin at the commit ${branch} sits on, so its PR would be diffed against ${heldBy}'s old history. Push ${heldBy} first — ${pc.green("gh-stack restack --resume")} retries both, parent first.`,
|
|
1332
|
+
);
|
|
1333
|
+
ctx.unpublished.add(branch);
|
|
1334
|
+
await record({ branch, action: "push-held", detail: `${heldBy} not on origin` });
|
|
1335
|
+
}
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const outcome = await pushBranchRef(meta, branch, ctx);
|
|
1340
|
+
switch (outcome) {
|
|
1341
|
+
case "pushed":
|
|
1342
|
+
(await remoteTips(ctx)).set(branch, await git.revParse(branch));
|
|
1343
|
+
await record({ branch, action: "pushed" });
|
|
1344
|
+
await retargetIfStale(meta, stackName, branch);
|
|
1345
|
+
break;
|
|
1346
|
+
case "in-sync":
|
|
1347
|
+
await retargetIfStale(meta, stackName, branch);
|
|
1348
|
+
break;
|
|
1349
|
+
case "failed":
|
|
1350
|
+
ctx.unpublished.add(branch);
|
|
1351
|
+
await record({ branch, action: "push-failed" });
|
|
1352
|
+
break;
|
|
1353
|
+
case "declined":
|
|
1354
|
+
ctx.unpublished.add(branch);
|
|
1355
|
+
await record({ branch, action: "push-declined" });
|
|
1356
|
+
break;
|
|
1357
|
+
case "local-only":
|
|
1358
|
+
await record({ branch, action: "local-only" });
|
|
1359
|
+
break;
|
|
1360
|
+
default:
|
|
1361
|
+
break; // already in sync, or pushing is off
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* A PR still targeting a branch that's no longer its parent (an advance past a
|
|
1367
|
+
* merged bottom whose push, or retarget, failed): now that the branch is on
|
|
1368
|
+
* origin, point the PR at its parent. Only acts on a recorded `prBase` that
|
|
1369
|
+
* differs from the parent, so it never guesses a PR's base.
|
|
1370
|
+
*/
|
|
1371
|
+
async function retargetIfStale(
|
|
1372
|
+
meta: StackMetadata,
|
|
1373
|
+
stackName: string,
|
|
1374
|
+
branch: string,
|
|
1375
|
+
): Promise<void> {
|
|
1376
|
+
const entry = meta.stacks[stackName]?.branches[branch];
|
|
1377
|
+
if (entry?.pr == null || !entry.prBase || entry.prBase === entry.parent) return;
|
|
1378
|
+
if (!(await setPrBase(entry.pr, entry.parent))) {
|
|
1379
|
+
p.log.warn(
|
|
1380
|
+
`PR #${entry.pr} still targets ${entry.prBase}; couldn't retarget it to ${entry.parent}. ${pc.green("gh-stack submit")} retries.`,
|
|
1381
|
+
);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
p.log.success(`PR #${entry.pr} base → ${pc.green(entry.parent)}`);
|
|
1385
|
+
entry.prBase = entry.parent;
|
|
1386
|
+
const fresh = await readMetadata();
|
|
1387
|
+
const saved = fresh?.stacks[stackName]?.branches[branch];
|
|
1388
|
+
if (fresh && saved) {
|
|
1389
|
+
saved.prBase = entry.parent;
|
|
1390
|
+
await writeMetadata(fresh);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
type PushOutcome = "no-push" | "in-sync" | "local-only" | "pushed" | "failed" | "declined";
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Reconcile a single branch's ref with origin. Pushes when the local tip
|
|
1398
|
+
* differs from origin OR the remote branch doesn't exist yet (so a branch
|
|
1399
|
+
* that was never pushed still lands). No-op when already in sync. (#12)
|
|
1400
|
+
*
|
|
1401
|
+
* "In sync" is judged from the run's view of origin (`ls-remote`, kept current
|
|
1402
|
+
* as we push), the same view the hold check and the end-of-run check use. A
|
|
1403
|
+
* stale remote-tracking ref (#23) must not make one of them say "in sync"
|
|
1404
|
+
* while another says "not pushed": the run would retry forever.
|
|
1405
|
+
*/
|
|
1406
|
+
async function pushBranchRef(
|
|
1407
|
+
meta: StackMetadata,
|
|
1408
|
+
branch: string,
|
|
1409
|
+
ctx: ChainContext,
|
|
1410
|
+
): Promise<PushOutcome> {
|
|
1411
|
+
if (branch === "main" || branch === "master") return "in-sync";
|
|
1412
|
+
// --no-push: leave origin alone; the end-of-run summary lists what's ahead.
|
|
1413
|
+
if (noPush) return "no-push";
|
|
675
1414
|
|
|
676
1415
|
const localSha = await git.revParse(branch);
|
|
677
|
-
const
|
|
1416
|
+
const remoteSha = (await remoteTips(ctx)).get(branch) ?? null;
|
|
1417
|
+
const hasRemote = remoteSha !== null;
|
|
678
1418
|
|
|
679
1419
|
// Don't publish an unsubmitted branch. A local-only branch (no remote AND no
|
|
680
1420
|
// PR) is intentionally not on origin — restack/sync rebase it locally but
|
|
@@ -685,12 +1425,10 @@ async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void>
|
|
|
685
1425
|
p.log.info(
|
|
686
1426
|
`Skipping push of ${pc.yellow(branch)} — local-only (not submitted). Run ${pc.green("gh-stack submit")} to open its PR.`,
|
|
687
1427
|
);
|
|
688
|
-
return;
|
|
1428
|
+
return "local-only";
|
|
689
1429
|
}
|
|
690
1430
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (hasRemote && remoteSha === localSha) return; // already in sync — nothing to do
|
|
1431
|
+
if (remoteSha === localSha) return "in-sync";
|
|
694
1432
|
|
|
695
1433
|
console.log();
|
|
696
1434
|
console.log(pc.cyan("━".repeat(40)));
|
|
@@ -711,15 +1449,20 @@ async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void>
|
|
|
711
1449
|
const confirmed = await confirmAction(
|
|
712
1450
|
hasRemote ? "Push with --force-with-lease?" : `Push ${branch} to origin?`,
|
|
713
1451
|
);
|
|
714
|
-
if (confirmed) {
|
|
715
|
-
p.log.info(`Pushing ${branch}...`);
|
|
716
|
-
const ok = await git.pushStackedBranch(branch);
|
|
717
|
-
if (ok) {
|
|
718
|
-
p.log.success("Pushed successfully");
|
|
719
|
-
} else {
|
|
720
|
-
p.log.error("Push failed — you may need to push manually later");
|
|
721
|
-
}
|
|
722
|
-
} else {
|
|
1452
|
+
if (!confirmed) {
|
|
723
1453
|
p.log.warn("Skipping push (you'll need to push manually)");
|
|
1454
|
+
return "declined";
|
|
1455
|
+
}
|
|
1456
|
+
p.log.info(`Pushing ${branch}...`);
|
|
1457
|
+
// A tip the remote guard already checked (only earlier versions of local
|
|
1458
|
+
// commits, or --overwrite-remote) gets an explicit lease on exactly that sha.
|
|
1459
|
+
const result = await git.forcePushWithLease(branch, { expect: verifiedTips.get(branch) });
|
|
1460
|
+
if (result.ok) {
|
|
1461
|
+
p.log.success("Pushed successfully");
|
|
1462
|
+
return "pushed";
|
|
724
1463
|
}
|
|
1464
|
+
p.log.error(`Push of ${branch} failed — branches above it won't be pushed onto it`);
|
|
1465
|
+
for (const line of git.pushFailureHint(branch, result)) console.log(` ${line}`);
|
|
1466
|
+
console.log(` Then: ${pc.green("gh-stack restack --resume")}`);
|
|
1467
|
+
return "failed";
|
|
725
1468
|
}
|