gh-stack 0.17.0 → 0.17.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/package.json +1 -1
- package/src/commands/restack.ts +47 -12
- package/src/lib/snapshot.ts +57 -1
- package/src/types.ts +7 -0
package/package.json
CHANGED
package/src/commands/restack.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
writeMetadata,
|
|
16
16
|
} from "../lib/metadata.ts";
|
|
17
17
|
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
18
|
-
import { takeSnapshot, findPreRewriteSha } from "../lib/snapshot.ts";
|
|
18
|
+
import { takeSnapshot, findPreRewriteSha, findRecordedBaseTip } from "../lib/snapshot.ts";
|
|
19
19
|
import { confirmAction } from "../lib/ui.ts";
|
|
20
20
|
import type { StackMetadata } from "../types.ts";
|
|
21
21
|
|
|
@@ -345,6 +345,36 @@ async function handleFreshRestack(
|
|
|
345
345
|
* the new base (the now-merged parent commits are dropped). Children then
|
|
346
346
|
* restack onto the moved root via the normal chain logic.
|
|
347
347
|
*/
|
|
348
|
+
// Resolve the boundary commit for re-rooting a stack's root off its old base:
|
|
349
|
+
// the tip of the old base branch that the root was built on. Returns null when
|
|
350
|
+
// it can't be determined safely, so the caller refuses rather than replaying
|
|
351
|
+
// already-merged commits (issue #13).
|
|
352
|
+
async function resolveRerootBoundary(
|
|
353
|
+
meta: StackMetadata,
|
|
354
|
+
oldBase: string,
|
|
355
|
+
root: string,
|
|
356
|
+
): Promise<{ sha: string | null; source: string }> {
|
|
357
|
+
// 1. The live base branch tip — but only if the root still sits on it. If the
|
|
358
|
+
// base was rewritten after the root forked, its current tip isn't the
|
|
359
|
+
// boundary; fall through to the recorded tip.
|
|
360
|
+
if (await git.localBranchExists(oldBase)) {
|
|
361
|
+
const tip = await git.revParse(oldBase);
|
|
362
|
+
if (tip && (await git.isAncestor(tip, root))) {
|
|
363
|
+
return { sha: tip, source: "old-base branch tip" };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 2. The tip recorded in a snapshot — survives the base branch being deleted
|
|
368
|
+
// or rewritten by a squash-merge of the parent stack.
|
|
369
|
+
const recorded = await findRecordedBaseTip(meta, oldBase, root);
|
|
370
|
+
if (recorded) {
|
|
371
|
+
return { sha: recorded, source: "snapshot (old base branch gone/rewritten)" };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 3. Unknown — refuse rather than replay merged commits.
|
|
375
|
+
return { sha: null, source: "" };
|
|
376
|
+
}
|
|
377
|
+
|
|
348
378
|
async function handleReroot(
|
|
349
379
|
meta: StackMetadata,
|
|
350
380
|
newBase: string,
|
|
@@ -383,20 +413,25 @@ async function handleReroot(
|
|
|
383
413
|
}
|
|
384
414
|
|
|
385
415
|
// The rebase base = the tip the root was built on (the old base branch).
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
416
|
+
// Getting this right matters: rebasing from the wrong boundary replays the
|
|
417
|
+
// old base's commits onto the new base, and when the old base was a
|
|
418
|
+
// squash-merged parent stack those commits collide with their own squashed
|
|
419
|
+
// result on main — a self-inflicted conflict storm (issue #13). So we never
|
|
420
|
+
// guess with merge-base; we refuse instead.
|
|
421
|
+
const { sha: oldBaseTip, source: oldBaseSource } = await resolveRerootBoundary(
|
|
422
|
+
meta,
|
|
423
|
+
oldBase,
|
|
424
|
+
root,
|
|
425
|
+
);
|
|
395
426
|
|
|
396
427
|
if (!oldBaseTip) {
|
|
397
428
|
p.cancel(
|
|
398
|
-
`
|
|
399
|
-
|
|
429
|
+
`Can't safely re-root ${pc.yellow(stackName)}: its old base ${pc.yellow(
|
|
430
|
+
oldBase,
|
|
431
|
+
)} is gone (or was rewritten) and no snapshot recorded where ${pc.yellow(
|
|
432
|
+
root,
|
|
433
|
+
)} forked off it.\n\n Rebasing from the merge-base would replay already-merged commits and conflict.\n Re-root manually once you know the old base tip:\n ${pc.green(
|
|
434
|
+
`git rebase --onto ${newBase} <old-base-tip> ${root}`,
|
|
400
435
|
)}`,
|
|
401
436
|
);
|
|
402
437
|
process.exit(1);
|
package/src/lib/snapshot.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// stack's only record while a busy sibling stack churned (see issue #13).
|
|
8
8
|
import type { StackMetadata, Snapshot } from "../types.ts";
|
|
9
9
|
import * as git from "./git.ts";
|
|
10
|
-
import { getOrderedBranches } from "./metadata.ts";
|
|
10
|
+
import { getOrderedBranches, stackBase } from "./metadata.ts";
|
|
11
11
|
import { snapshotsDir } from "./paths.ts";
|
|
12
12
|
import { mkdir, readdir, unlink } from "node:fs/promises";
|
|
13
13
|
|
|
@@ -86,6 +86,48 @@ export async function findPreRewriteSha(
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Find the recorded tip of a stack's (non-main) base branch — the boundary a
|
|
91
|
+
* re-root needs to replay only the dependent stack's own commits.
|
|
92
|
+
*
|
|
93
|
+
* Walks snapshots newest-first for one whose recorded `baseBranch` matches and
|
|
94
|
+
* whose recorded `baseTip` is still an ancestor of `rootBranch` (the dependent
|
|
95
|
+
* stack's root). That tip is exactly where the root forked off the base, so
|
|
96
|
+
* `git rebase --onto <new-base> <baseTip> <root>` drops the already-merged
|
|
97
|
+
* prefix instead of replaying it.
|
|
98
|
+
*
|
|
99
|
+
* Unlike `findPreRewriteSha`, this does NOT require the base branch to still
|
|
100
|
+
* exist locally — that's the whole point: after a squash-merge deletes the base
|
|
101
|
+
* branch, its tip survives only in these snapshots. The ancestor-of-root check
|
|
102
|
+
* rejects stale candidates (an older base tip the root no longer sits on).
|
|
103
|
+
*
|
|
104
|
+
* Returns null if no snapshot recorded this base, or none is still an ancestor
|
|
105
|
+
* of the root (in which case the caller must refuse rather than guess).
|
|
106
|
+
*/
|
|
107
|
+
export async function findRecordedBaseTip(
|
|
108
|
+
meta: StackMetadata,
|
|
109
|
+
baseBranch: string,
|
|
110
|
+
rootBranch: string,
|
|
111
|
+
): Promise<string | null> {
|
|
112
|
+
if (!meta.snapshots || meta.snapshots.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
let rootTip: string;
|
|
115
|
+
try {
|
|
116
|
+
rootTip = await git.revParse(rootBranch);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Walk newest → oldest; first base tip that the root still sits on wins.
|
|
122
|
+
for (let i = meta.snapshots.length - 1; i >= 0; i--) {
|
|
123
|
+
const snap = meta.snapshots[i]!;
|
|
124
|
+
if (snap.baseBranch !== baseBranch || !snap.baseTip) continue;
|
|
125
|
+
if (await git.isAncestor(snap.baseTip, rootTip)) return snap.baseTip;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
// Snapshot files are named `<sortable-timestamp>__<encoded-stack>.json`. The
|
|
90
132
|
// timestamp keeps colons/dots out of the filename (Windows-safe) while sorting
|
|
91
133
|
// chronologically as a plain string; the stack name is URL-encoded so `/` in a
|
|
@@ -161,6 +203,20 @@ export async function takeSnapshot(
|
|
|
161
203
|
stack: stackName,
|
|
162
204
|
};
|
|
163
205
|
|
|
206
|
+
// For a non-main base (a stack stacked on top of another stack), record the
|
|
207
|
+
// base branch's tip too — kept out of `branches` so `undo` won't reset a
|
|
208
|
+
// branch that belongs to the parent stack. This is the boundary a later
|
|
209
|
+
// re-root needs if the base branch is deleted by a squash-merge (issue #13).
|
|
210
|
+
const base = stackBase(stack);
|
|
211
|
+
if (base !== "main" && base !== "master") {
|
|
212
|
+
try {
|
|
213
|
+
snapshot.baseTip = await git.revParse(base);
|
|
214
|
+
snapshot.baseBranch = base;
|
|
215
|
+
} catch {
|
|
216
|
+
// Base branch already gone locally — nothing to record.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
164
220
|
await writeSnapshotFile(snapshot);
|
|
165
221
|
|
|
166
222
|
// Keep in-memory array consistent for callers that read it afterward.
|
package/src/types.ts
CHANGED
|
@@ -50,6 +50,13 @@ export interface Snapshot {
|
|
|
50
50
|
operation: string; // "restack" | "merge" | "sync" | "remove"
|
|
51
51
|
branches: Record<string, string>; // branch name -> commit SHA
|
|
52
52
|
stack?: string; // owning stack (v3: drives per-stack retention + file naming)
|
|
53
|
+
// For a stack with a non-main base: the base branch and its tip at snapshot
|
|
54
|
+
// time. Recorded separately from `branches` (which `undo` force-resets) so a
|
|
55
|
+
// dependent stack can re-root onto main after its base branch is deleted by a
|
|
56
|
+
// squash-merge — without that recorded boundary the reroot would replay the
|
|
57
|
+
// already-merged commits and conflict (issue #13).
|
|
58
|
+
baseBranch?: string;
|
|
59
|
+
baseTip?: string;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
// Resume state for restack --resume
|