gh-stack 0.18.0 → 0.19.1
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 +247 -18
- package/package.json +9 -8
- package/src/commands/config.ts +133 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +352 -12
- package/src/commands/restack.ts +943 -237
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +144 -156
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +11 -6
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +67 -17
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +84 -22
- package/src/lib/github.ts +63 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +29 -8
- 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/safety.ts +12 -4
- package/src/lib/using-gh-stack.md +131 -5
- package/src/types.ts +62 -4
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,32 +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";
|
|
21
40
|
|
|
22
41
|
/**
|
|
23
|
-
* `--no-push` / `GH_STACK_NO_PUSH=1
|
|
24
|
-
* Module-level because every rebase path
|
|
25
|
-
* Wins over `--yes`, which still auto-accepts
|
|
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.
|
|
26
46
|
*/
|
|
27
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
|
+
}
|
|
28
101
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
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);
|
|
32
127
|
}
|
|
33
128
|
|
|
34
129
|
export default async function restack(args: string[]): Promise<void> {
|
|
35
130
|
const dryRun = args.includes("--dry-run");
|
|
36
|
-
noPush = wantsNoPush(args);
|
|
37
131
|
const resume = args.includes("--resume");
|
|
132
|
+
const fresh = args.includes("--fresh");
|
|
38
133
|
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
39
134
|
|
|
40
135
|
// --onto <branch>: re-root the current stack onto a new base (e.g. move a
|
|
@@ -55,12 +150,40 @@ USAGE
|
|
|
55
150
|
OPTIONS
|
|
56
151
|
--onto <ref> Re-root the stack onto a new base ref (changes stack base)
|
|
57
152
|
--yes, -y Skip confirmations (auto-accept all rebases and pushes)
|
|
58
|
-
--no-push Rebase locally only — never push (also GH_STACK_NO_PUSH=1
|
|
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).
|
|
59
155
|
Wins over --yes for pushes; rebases are still auto-accepted
|
|
60
|
-
--
|
|
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
|
|
61
166
|
--dry-run Show what would happen without executing
|
|
62
167
|
--verbose Show diagnostic info (snapshot vs merge-base)
|
|
63
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
|
+
|
|
64
187
|
EXAMPLES
|
|
65
188
|
gh-stack restack Interactive (prompts before each rebase)
|
|
66
189
|
gh-stack restack --yes Non-interactive (for agents/CI)
|
|
@@ -76,26 +199,53 @@ ALIASES
|
|
|
76
199
|
return;
|
|
77
200
|
}
|
|
78
201
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
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"}.`);
|
|
86
208
|
process.exit(1);
|
|
87
209
|
}
|
|
88
210
|
|
|
89
|
-
//
|
|
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;
|
|
90
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);
|
|
91
244
|
await ensureCleanWorkingTree();
|
|
92
245
|
}
|
|
93
246
|
|
|
94
247
|
const meta = await ensureMetadata();
|
|
95
|
-
const
|
|
96
|
-
// Every branch restack is responsible for this run (rebased OR already
|
|
97
|
-
// up to date) — we push each one and verify its ref landed at the end.
|
|
98
|
-
const touched: string[] = [];
|
|
248
|
+
const ctx = newContext(dryRun, verbose);
|
|
99
249
|
|
|
100
250
|
p.intro(pc.cyan("Git Stack Restack"));
|
|
101
251
|
|
|
@@ -104,60 +254,75 @@ ALIASES
|
|
|
104
254
|
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
105
255
|
|
|
106
256
|
if (ontoFlag) {
|
|
107
|
-
await handleReroot(meta, ontoFlag,
|
|
108
|
-
} else if (
|
|
109
|
-
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;
|
|
110
261
|
} else {
|
|
111
|
-
await handleFreshRestack(meta,
|
|
262
|
+
await handleFreshRestack(meta, ctx);
|
|
112
263
|
}
|
|
113
264
|
|
|
114
|
-
|
|
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> {
|
|
115
278
|
console.log();
|
|
116
|
-
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) {
|
|
117
283
|
p.log.warn("No branches were rebased");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (ctx.dryRun) {
|
|
287
|
+
p.outro(pc.green(doneMessage));
|
|
288
|
+
return;
|
|
123
289
|
}
|
|
124
290
|
|
|
125
291
|
// Verification pass: assert every touched branch's ref actually landed on
|
|
126
292
|
// origin. Fail loudly (listing the stale ones) rather than reporting success
|
|
127
293
|
// when a push didn't take. (#12)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
console.log(` ${pc.yellow("↑")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
|
|
137
|
-
}
|
|
138
|
-
console.log();
|
|
139
|
-
console.log(
|
|
140
|
-
` Publish when ready: ${pc.green("gh-stack restack")} or ${pc.green("gh-stack submit")}`,
|
|
141
|
-
);
|
|
142
|
-
p.outro(pc.green("Stack Restack Complete (local only)"));
|
|
143
|
-
return;
|
|
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}`);
|
|
144
302
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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}`);
|
|
157
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);
|
|
158
322
|
}
|
|
159
323
|
|
|
160
|
-
|
|
324
|
+
await markRunFinished();
|
|
325
|
+
p.outro(pc.green(doneMessage));
|
|
161
326
|
}
|
|
162
327
|
|
|
163
328
|
/**
|
|
@@ -180,10 +345,11 @@ ALIASES
|
|
|
180
345
|
export async function findStaleRefs(
|
|
181
346
|
branches: string[],
|
|
182
347
|
meta: StackMetadata,
|
|
348
|
+
remote?: Map<string, string>,
|
|
183
349
|
): Promise<{ branch: string; local: string; remote: string }[]> {
|
|
184
350
|
const stale: { branch: string; local: string; remote: string }[] = [];
|
|
185
351
|
const seen = new Set<string>();
|
|
186
|
-
const remoteHeads = await git.remoteHeads();
|
|
352
|
+
const remoteHeads = remote ?? (await git.remoteHeads());
|
|
187
353
|
for (const branch of branches) {
|
|
188
354
|
if (seen.has(branch) || branch === "main" || branch === "master") continue;
|
|
189
355
|
seen.add(branch);
|
|
@@ -204,101 +370,401 @@ export async function findStaleRefs(
|
|
|
204
370
|
return stale;
|
|
205
371
|
}
|
|
206
372
|
|
|
207
|
-
|
|
208
|
-
meta: StackMetadata,
|
|
209
|
-
rebasedBranches: string[],
|
|
210
|
-
touched: string[],
|
|
211
|
-
verbose: boolean,
|
|
212
|
-
): Promise<void> {
|
|
213
|
-
p.log.warn("Resuming from saved state...");
|
|
373
|
+
// ── Interrupted runs (#29) ──
|
|
214
374
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
);
|
|
219
394
|
}
|
|
220
|
-
|
|
221
|
-
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
222
397
|
|
|
223
|
-
|
|
224
|
-
|
|
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) {
|
|
225
441
|
p.cancel(
|
|
226
|
-
`
|
|
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`)}`,
|
|
227
445
|
);
|
|
228
446
|
process.exit(1);
|
|
229
447
|
}
|
|
230
448
|
|
|
231
|
-
const
|
|
232
|
-
|
|
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
|
+
}
|
|
233
455
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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();
|
|
241
476
|
await clearRestackState();
|
|
242
|
-
|
|
243
|
-
console.log(` State cleared. To sync the stack, run from the desired branch:`);
|
|
244
|
-
console.log(` ${pc.green("gh-stack restack")}`);
|
|
245
|
-
return;
|
|
477
|
+
return "fresh";
|
|
246
478
|
}
|
|
479
|
+
return "resume";
|
|
480
|
+
}
|
|
247
481
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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);
|
|
276
614
|
}
|
|
615
|
+
return out;
|
|
277
616
|
}
|
|
278
617
|
|
|
279
|
-
async function
|
|
618
|
+
async function handleResume(
|
|
280
619
|
meta: StackMetadata,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
}
|
|
288
635
|
|
|
289
|
-
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) {
|
|
290
675
|
p.cancel(
|
|
291
|
-
`
|
|
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")}`,
|
|
292
678
|
);
|
|
293
679
|
process.exit(1);
|
|
294
680
|
}
|
|
295
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
|
+
|
|
296
762
|
const stack = meta.stacks[stackName]!;
|
|
297
763
|
await ensureValidStack(meta, stackName);
|
|
298
764
|
|
|
299
765
|
// Build the rebase chain from current branch
|
|
300
766
|
p.log.info("Building rebase chain...");
|
|
301
|
-
|
|
767
|
+
const chain = buildRebaseChain(stack, currentBranch);
|
|
302
768
|
|
|
303
769
|
if (chain.length === 0) {
|
|
304
770
|
p.cancel("Could not build rebase chain");
|
|
@@ -316,10 +782,12 @@ async function handleFreshRestack(
|
|
|
316
782
|
// Skip the stack's root branch (parent = base) when the base is the trunk —
|
|
317
783
|
// rebasing onto trunk is `sync`'s job, not restack's. For a split stack
|
|
318
784
|
// whose base is a sibling branch, the root SHOULD be rebased onto that base
|
|
319
|
-
// 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).
|
|
320
787
|
const base = stackBase(stack);
|
|
321
788
|
const baseIsTrunk = base === "main" || base === "master";
|
|
322
789
|
const baseBranches = baseIsTrunk ? chain.filter((b) => stack.branches[b]?.parent === base) : [];
|
|
790
|
+
const toRebase = chain.filter((b) => !baseBranches.includes(b));
|
|
323
791
|
if (baseBranches.length > 0) {
|
|
324
792
|
p.log.warn(`Skipping base branch(es) (parent: ${base}):`);
|
|
325
793
|
for (const baseBranch of baseBranches) {
|
|
@@ -332,41 +800,30 @@ async function handleFreshRestack(
|
|
|
332
800
|
);
|
|
333
801
|
console.log();
|
|
334
802
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
touched.push(baseBranch);
|
|
343
|
-
await pushBranchRef(meta, baseBranch);
|
|
344
|
-
}
|
|
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})`)}`);
|
|
345
810
|
}
|
|
811
|
+
console.log();
|
|
346
812
|
}
|
|
347
|
-
|
|
348
|
-
if (chain.length === 0) {
|
|
349
|
-
p.log.info("No child branches to rebase — base branch ref reconciled above.");
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
p.log.success(`Processing ${chain.length} branch(es):`);
|
|
354
|
-
for (const branch of chain) {
|
|
355
|
-
const parent = stack.branches[branch]?.parent || "(unknown)";
|
|
356
|
-
console.log(` ${pc.yellow("→")} ${branch} ${pc.blue(`(parent: ${parent})`)}`);
|
|
357
|
-
}
|
|
358
|
-
console.log();
|
|
359
813
|
}
|
|
360
814
|
|
|
361
|
-
|
|
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) {
|
|
362
819
|
// Take snapshot before destructive operation. This preserves the current
|
|
363
820
|
// tip of every branch so subsequent restacks (after a parent is rewritten)
|
|
364
821
|
// can find the correct rebase base.
|
|
365
|
-
await takeSnapshot(meta, stackName, "restack");
|
|
822
|
+
if (toRebase.length > 0) await takeSnapshot(meta, stackName, "restack");
|
|
823
|
+
await beginRun({ command: "restack", stackName, chain, pushOnly: baseBranches });
|
|
366
824
|
}
|
|
367
825
|
|
|
368
|
-
|
|
369
|
-
await processChain(meta, stackName, chain, 0, rebasedBranches, touched, dryRun, verbose);
|
|
826
|
+
await processChain(meta, stackName, chain, 0, new Set(baseBranches), ctx);
|
|
370
827
|
}
|
|
371
828
|
|
|
372
829
|
/**
|
|
@@ -412,10 +869,7 @@ async function resolveRerootBoundary(
|
|
|
412
869
|
async function handleReroot(
|
|
413
870
|
meta: StackMetadata,
|
|
414
871
|
newBase: string,
|
|
415
|
-
|
|
416
|
-
touched: string[],
|
|
417
|
-
dryRun: boolean,
|
|
418
|
-
verbose: boolean,
|
|
872
|
+
ctx: ChainContext,
|
|
419
873
|
): Promise<void> {
|
|
420
874
|
const currentBranch = await git.currentBranch();
|
|
421
875
|
const stackName = findStackForBranch(meta, currentBranch);
|
|
@@ -473,12 +927,13 @@ async function handleReroot(
|
|
|
473
927
|
|
|
474
928
|
p.log.info(`Re-rooting ${pc.yellow(stackName)}: ${pc.dim(oldBase)} → ${pc.green(newBase)}`);
|
|
475
929
|
p.log.info(`Root branch: ${pc.yellow(root)}`);
|
|
476
|
-
if (verbose) {
|
|
930
|
+
if (ctx.verbose) {
|
|
477
931
|
p.log.info(`Rebase base: ${oldBaseTip.slice(0, 8)} (${oldBaseSource})`);
|
|
478
932
|
}
|
|
479
933
|
console.log();
|
|
480
934
|
|
|
481
|
-
if (dryRun) {
|
|
935
|
+
if (ctx.dryRun) {
|
|
936
|
+
await guardRemote(meta, buildRebaseChain(stack, root), true);
|
|
482
937
|
p.log.warn(`[DRY RUN] Would rebase ${root} onto ${newBase} (from ${oldBaseTip.slice(0, 8)})`);
|
|
483
938
|
const children = ordered.slice(1);
|
|
484
939
|
if (children.length > 0) {
|
|
@@ -495,55 +950,189 @@ async function handleReroot(
|
|
|
495
950
|
process.exit(0);
|
|
496
951
|
}
|
|
497
952
|
|
|
953
|
+
await guardRemote(meta, buildRebaseChain(stack, root), false);
|
|
954
|
+
|
|
498
955
|
// Snapshot before rewriting, then commit the new base into metadata.
|
|
499
956
|
await takeSnapshot(meta, stackName, "reroot");
|
|
500
957
|
stack.base = newBase;
|
|
501
958
|
stack.branches[root]!.parent = newBase;
|
|
502
959
|
await writeMetadata(meta);
|
|
503
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
|
+
|
|
504
965
|
// Rebase the root onto the new base, dropping the old base's commits.
|
|
505
966
|
p.log.info(`Checking out ${pc.yellow(root)}...`);
|
|
506
967
|
await git.checkout(root);
|
|
507
968
|
p.log.info(`Rebasing ${pc.yellow(root)} onto ${pc.yellow(newBase)}...`);
|
|
508
|
-
|
|
969
|
+
await startRebase(root, newBase, await git.revParse(root), oldBaseTip);
|
|
970
|
+
if (!(await git.rebaseOnto(newBase, oldBaseTip, root))) conflictStop();
|
|
509
971
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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);
|
|
518
1064
|
}
|
|
519
1065
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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();
|
|
523
1073
|
|
|
524
|
-
// Restack the
|
|
1074
|
+
// Step 2: Restack the rest of the stack
|
|
525
1075
|
const children = ordered.slice(1);
|
|
526
1076
|
if (children.length > 0) {
|
|
527
|
-
|
|
528
|
-
|
|
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;
|
|
529
1099
|
}
|
|
530
1100
|
}
|
|
531
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
|
+
*/
|
|
532
1107
|
async function processChain(
|
|
533
1108
|
meta: StackMetadata,
|
|
534
1109
|
stackName: string,
|
|
535
1110
|
chain: string[],
|
|
536
1111
|
startIndex: number,
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
dryRun: boolean,
|
|
540
|
-
verbose: boolean,
|
|
1112
|
+
pushOnly: Set<string>,
|
|
1113
|
+
ctx: ChainContext,
|
|
541
1114
|
): Promise<void> {
|
|
542
1115
|
const stack = meta.stacks[stackName]!;
|
|
1116
|
+
const { dryRun, verbose } = ctx;
|
|
543
1117
|
|
|
544
1118
|
for (let i = startIndex; i < chain.length; i++) {
|
|
545
1119
|
const branch = chain[i]!;
|
|
546
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
|
+
}
|
|
547
1136
|
|
|
548
1137
|
if (!parent) {
|
|
549
1138
|
p.log.warn(`Skipping ${branch} (no parent defined)`);
|
|
@@ -564,8 +1153,9 @@ async function processChain(
|
|
|
564
1153
|
if (await git.isAncestor(parent, branch)) {
|
|
565
1154
|
p.log.success("Already up to date with parent");
|
|
566
1155
|
if (!dryRun) {
|
|
567
|
-
|
|
568
|
-
|
|
1156
|
+
await record({ branch, action: "up-to-date", onto: parent });
|
|
1157
|
+
ctx.touched.push(branch);
|
|
1158
|
+
await pushStep(meta, stackName, branch, ctx);
|
|
569
1159
|
}
|
|
570
1160
|
continue;
|
|
571
1161
|
}
|
|
@@ -580,6 +1170,11 @@ async function processChain(
|
|
|
580
1170
|
const confirmed = await confirmAction(`Rebase ${pc.yellow(branch)} onto ${pc.yellow(parent)}?`);
|
|
581
1171
|
if (!confirmed) {
|
|
582
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 });
|
|
583
1178
|
continue;
|
|
584
1179
|
}
|
|
585
1180
|
|
|
@@ -618,7 +1213,7 @@ async function processChain(
|
|
|
618
1213
|
console.log(" Rebase it manually with an explicit boundary:");
|
|
619
1214
|
console.log(` ${pc.green(`git rebase --onto ${parent} <old-parent-tip> ${branch}`)}`);
|
|
620
1215
|
console.log();
|
|
621
|
-
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.`);
|
|
622
1217
|
process.exit(1);
|
|
623
1218
|
}
|
|
624
1219
|
|
|
@@ -626,16 +1221,11 @@ async function processChain(
|
|
|
626
1221
|
p.log.info(`Checking out ${pc.yellow(branch)}...`);
|
|
627
1222
|
await git.checkout(branch);
|
|
628
1223
|
|
|
629
|
-
// Save state for resume
|
|
630
|
-
await saveRestackState({
|
|
631
|
-
current_index: i,
|
|
632
|
-
stack_name: stackName,
|
|
633
|
-
chain,
|
|
634
|
-
...(noPush && { no_push: true }),
|
|
635
|
-
});
|
|
636
|
-
|
|
637
1224
|
let success: boolean;
|
|
638
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);
|
|
639
1229
|
if (boundary.sha === branchTip) {
|
|
640
1230
|
// The child has no commits of its own (a placeholder branch created off
|
|
641
1231
|
// the parent before any work landed). Nothing to replay — move the ref
|
|
@@ -661,38 +1251,24 @@ async function processChain(
|
|
|
661
1251
|
|
|
662
1252
|
if (success) {
|
|
663
1253
|
p.log.success("Rebase successful");
|
|
664
|
-
|
|
665
|
-
touched.push(branch);
|
|
666
|
-
await clearRestackState();
|
|
1254
|
+
await finishRebase(branch, parent, ctx);
|
|
1255
|
+
ctx.touched.push(branch);
|
|
667
1256
|
|
|
668
1257
|
// Push immediately (don't batch) so an interrupt can't leave later
|
|
669
1258
|
// branches un-pushed.
|
|
670
|
-
await
|
|
1259
|
+
await pushStep(meta, stackName, branch, ctx);
|
|
671
1260
|
} else {
|
|
672
|
-
|
|
673
|
-
console.log();
|
|
674
|
-
console.log(pc.red("━".repeat(40)));
|
|
675
|
-
console.log(pc.red(" Rebase Conflict"));
|
|
676
|
-
console.log(pc.red("━".repeat(40)));
|
|
677
|
-
console.log();
|
|
678
|
-
console.log(pc.yellow("Conflicts detected during rebase."));
|
|
679
|
-
console.log();
|
|
680
|
-
console.log(" Please resolve the conflicts, then:");
|
|
681
|
-
console.log(` ${pc.green("git rebase --continue")}`);
|
|
682
|
-
console.log(` ${pc.green("gh-stack restack --resume")}`);
|
|
683
|
-
console.log();
|
|
684
|
-
console.log(" Or abort the rebase:");
|
|
685
|
-
console.log(` ${pc.red("git rebase --abort")}`);
|
|
686
|
-
process.exit(2);
|
|
1261
|
+
conflictStop();
|
|
687
1262
|
}
|
|
688
1263
|
}
|
|
1264
|
+
|
|
1265
|
+
if (run && !dryRun) {
|
|
1266
|
+
run.current_index = chain.length;
|
|
1267
|
+
run.rebase = null;
|
|
1268
|
+
await saveRun();
|
|
1269
|
+
}
|
|
689
1270
|
}
|
|
690
1271
|
|
|
691
|
-
/**
|
|
692
|
-
* Reconcile a single branch's ref with origin. Pushes when the local tip
|
|
693
|
-
* differs from origin OR the remote branch doesn't exist yet (so a branch
|
|
694
|
-
* that was never pushed still lands). No-op when already in sync. (#12)
|
|
695
|
-
*/
|
|
696
1272
|
/**
|
|
697
1273
|
* Whether a branch has been published — it has a PR recorded in metadata.
|
|
698
1274
|
* restack/sync keep published branches' remotes in sync after a rebase, but must
|
|
@@ -705,13 +1281,140 @@ function branchHasPr(meta: StackMetadata, branch: string): boolean {
|
|
|
705
1281
|
return meta.stacks[stackName]?.branches[branch]?.pr != null;
|
|
706
1282
|
}
|
|
707
1283
|
|
|
708
|
-
|
|
709
|
-
|
|
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";
|
|
710
1412
|
// --no-push: leave origin alone; the end-of-run summary lists what's ahead.
|
|
711
|
-
if (noPush) return;
|
|
1413
|
+
if (noPush) return "no-push";
|
|
712
1414
|
|
|
713
1415
|
const localSha = await git.revParse(branch);
|
|
714
|
-
const
|
|
1416
|
+
const remoteSha = (await remoteTips(ctx)).get(branch) ?? null;
|
|
1417
|
+
const hasRemote = remoteSha !== null;
|
|
715
1418
|
|
|
716
1419
|
// Don't publish an unsubmitted branch. A local-only branch (no remote AND no
|
|
717
1420
|
// PR) is intentionally not on origin — restack/sync rebase it locally but
|
|
@@ -722,12 +1425,10 @@ async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void>
|
|
|
722
1425
|
p.log.info(
|
|
723
1426
|
`Skipping push of ${pc.yellow(branch)} — local-only (not submitted). Run ${pc.green("gh-stack submit")} to open its PR.`,
|
|
724
1427
|
);
|
|
725
|
-
return;
|
|
1428
|
+
return "local-only";
|
|
726
1429
|
}
|
|
727
1430
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
if (hasRemote && remoteSha === localSha) return; // already in sync — nothing to do
|
|
1431
|
+
if (remoteSha === localSha) return "in-sync";
|
|
731
1432
|
|
|
732
1433
|
console.log();
|
|
733
1434
|
console.log(pc.cyan("━".repeat(40)));
|
|
@@ -748,15 +1449,20 @@ async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void>
|
|
|
748
1449
|
const confirmed = await confirmAction(
|
|
749
1450
|
hasRemote ? "Push with --force-with-lease?" : `Push ${branch} to origin?`,
|
|
750
1451
|
);
|
|
751
|
-
if (confirmed) {
|
|
752
|
-
p.log.info(`Pushing ${branch}...`);
|
|
753
|
-
const ok = await git.pushStackedBranch(branch);
|
|
754
|
-
if (ok) {
|
|
755
|
-
p.log.success("Pushed successfully");
|
|
756
|
-
} else {
|
|
757
|
-
p.log.error("Push failed — you may need to push manually later");
|
|
758
|
-
}
|
|
759
|
-
} else {
|
|
1452
|
+
if (!confirmed) {
|
|
760
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";
|
|
761
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";
|
|
762
1468
|
}
|