gh-stack 0.3.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -6
- package/package.json +1 -1
- package/src/commands/archive.ts +1 -1
- package/src/commands/bottom.ts +1 -1
- package/src/commands/checkout.ts +1 -1
- package/src/commands/create.ts +1 -1
- package/src/commands/delete.ts +1 -1
- package/src/commands/down.ts +1 -1
- package/src/commands/init.ts +2 -45
- package/src/commands/log.ts +1 -1
- package/src/commands/merge.ts +103 -8
- package/src/commands/restack.ts +74 -141
- package/src/commands/submit.ts +83 -7
- package/src/commands/sync.ts +14 -24
- package/src/commands/top.ts +1 -1
- package/src/commands/undo.ts +1 -1
- package/src/commands/up.ts +1 -1
- package/src/commands/update-prs.ts +1 -1
- package/src/index.ts +19 -3
- package/src/lib/chain.ts +140 -0
- package/src/lib/cli.ts +10 -3
- package/src/lib/git.ts +10 -0
- package/src/lib/output.ts +90 -0
- package/src/lib/safety.ts +1 -1
- package/src/lib/snapshot.ts +74 -1
- package/src/lib/ui.ts +1 -1
package/src/commands/restack.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// gh-stack restack — Rebase children onto updated parents
|
|
2
2
|
// Port of reference/gh-stack-sync.sh — the most critical command
|
|
3
|
-
import * as p from "
|
|
3
|
+
import * as p from "../lib/output.ts";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
saveRestackState,
|
|
10
10
|
loadRestackState,
|
|
11
11
|
clearRestackState,
|
|
12
|
+
readMetadata,
|
|
12
13
|
} from "../lib/metadata.ts";
|
|
13
14
|
import {
|
|
14
15
|
ensureMetadata,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
ensureNotMain,
|
|
17
18
|
ensureValidStack,
|
|
18
19
|
} from "../lib/safety.ts";
|
|
19
|
-
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
20
|
+
import { takeSnapshot, findPreRewriteSha } from "../lib/snapshot.ts";
|
|
20
21
|
import { confirmAction } from "../lib/ui.ts";
|
|
21
22
|
import type { StackMetadata } from "../types.ts";
|
|
22
23
|
|
|
@@ -36,7 +37,7 @@ OPTIONS
|
|
|
36
37
|
--yes, -y Skip confirmations (auto-accept all rebases and pushes)
|
|
37
38
|
--resume Resume after resolving rebase conflicts
|
|
38
39
|
--dry-run Show what would happen without executing
|
|
39
|
-
--verbose Show diagnostic info (
|
|
40
|
+
--verbose Show diagnostic info (snapshot vs merge-base)
|
|
40
41
|
|
|
41
42
|
EXAMPLES
|
|
42
43
|
gh-stack restack Interactive (prompts before each rebase)
|
|
@@ -49,6 +50,16 @@ ALIASES
|
|
|
49
50
|
return;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Reject if a git rebase is already in progress (unless we're resuming).
|
|
54
|
+
// Letting commands run on top of a half-finished rebase produces deeply
|
|
55
|
+
// confusing state — better to make the user finish or abort first.
|
|
56
|
+
if (!resume && (await git.isRebaseInProgress())) {
|
|
57
|
+
p.cancel(
|
|
58
|
+
`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 restack")} (or ${pc.green("gh-stack restack --resume")} to continue).`,
|
|
59
|
+
);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
// Ensure clean working tree (unless resuming)
|
|
53
64
|
if (!resume) {
|
|
54
65
|
await ensureCleanWorkingTree();
|
|
@@ -59,6 +70,10 @@ ALIASES
|
|
|
59
70
|
|
|
60
71
|
p.intro(pc.cyan("Git Stack Restack"));
|
|
61
72
|
|
|
73
|
+
// Silently drop any leftover temp tags from older versions of gh-stack
|
|
74
|
+
// (pre-snapshot). Keeps users upgrading from <0.4 from seeing stale tags.
|
|
75
|
+
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
76
|
+
|
|
62
77
|
if (resume) {
|
|
63
78
|
await handleResume(meta, rebasedBranches, verbose);
|
|
64
79
|
} else {
|
|
@@ -76,9 +91,6 @@ ALIASES
|
|
|
76
91
|
}
|
|
77
92
|
}
|
|
78
93
|
|
|
79
|
-
// Cleanup tags
|
|
80
|
-
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
81
|
-
|
|
82
94
|
p.outro(pc.green("Stack Restack Complete!"));
|
|
83
95
|
}
|
|
84
96
|
|
|
@@ -137,38 +149,8 @@ async function handleResume(
|
|
|
137
149
|
const currentIndex = state.current_index + 1;
|
|
138
150
|
|
|
139
151
|
if (currentIndex < chain.length) {
|
|
140
|
-
|
|
141
|
-
// If sync created them upfront (correct!), they'll already be here.
|
|
142
|
-
// If not (e.g., old state file), we create them as a fallback —
|
|
143
|
-
// but NOTE: tags created after parent was rebased may be inaccurate.
|
|
144
|
-
p.log.info("Checking base tags for remaining branches...");
|
|
145
|
-
for (let i = currentIndex; i < chain.length; i++) {
|
|
146
|
-
const branch = chain[i]!;
|
|
147
|
-
const parent = stack.branches[branch]?.parent;
|
|
148
|
-
if (!parent) continue;
|
|
149
|
-
|
|
150
|
-
const tagName = git.tempBaseTagName(branch);
|
|
151
|
-
if (await git.tagExists(tagName)) {
|
|
152
|
-
const tagSha = await git.revParse(tagName);
|
|
153
|
-
console.log(
|
|
154
|
-
` ${pc.green("✓")} Pre-existing tag for ${branch}: ${pc.cyan(tagName)} (${tagSha.slice(0, 8)})`,
|
|
155
|
-
);
|
|
156
|
-
} else {
|
|
157
|
-
// Fallback: create tag now (may be inaccurate if parent already rebased)
|
|
158
|
-
p.log.warn(`No pre-existing tag for ${branch} — creating from current merge-base`);
|
|
159
|
-
const mb = await git.mergeBase(branch, parent);
|
|
160
|
-
if (mb) {
|
|
161
|
-
await git.createTag(tagName, mb);
|
|
162
|
-
console.log(
|
|
163
|
-
` ${pc.yellow("⚠")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
console.log();
|
|
152
|
+
await processChain(meta, stackName, chain, currentIndex, rebasedBranches, false, verbose);
|
|
169
153
|
}
|
|
170
|
-
|
|
171
|
-
await processChain(meta, stackName, chain, currentIndex, rebasedBranches, false, verbose);
|
|
172
154
|
}
|
|
173
155
|
|
|
174
156
|
async function handleFreshRestack(
|
|
@@ -190,9 +172,6 @@ async function handleFreshRestack(
|
|
|
190
172
|
const stack = meta.stacks[stackName]!;
|
|
191
173
|
await ensureValidStack(meta, stackName);
|
|
192
174
|
|
|
193
|
-
// Check for stale tags from previous runs
|
|
194
|
-
await handleStaleTags();
|
|
195
|
-
|
|
196
175
|
// Build the rebase chain from current branch
|
|
197
176
|
p.log.info("Building rebase chain...");
|
|
198
177
|
let chain = buildRebaseChain(stack, currentBranch);
|
|
@@ -242,25 +221,10 @@ async function handleFreshRestack(
|
|
|
242
221
|
}
|
|
243
222
|
|
|
244
223
|
if (!dryRun) {
|
|
245
|
-
// Take snapshot before destructive operation
|
|
224
|
+
// Take snapshot before destructive operation. This preserves the current
|
|
225
|
+
// tip of every branch so subsequent restacks (after a parent is rewritten)
|
|
226
|
+
// can find the correct rebase base.
|
|
246
227
|
await takeSnapshot(meta, stackName, "restack");
|
|
247
|
-
|
|
248
|
-
// Create temporary tags for stable base references
|
|
249
|
-
p.log.info("Creating temporary base tags...");
|
|
250
|
-
for (const branch of chain) {
|
|
251
|
-
const parent = stack.branches[branch]?.parent;
|
|
252
|
-
if (!parent) continue;
|
|
253
|
-
|
|
254
|
-
const mb = await git.mergeBase(branch, parent);
|
|
255
|
-
if (mb) {
|
|
256
|
-
const tagName = git.tempBaseTagName(branch);
|
|
257
|
-
await git.createTag(tagName, mb);
|
|
258
|
-
console.log(
|
|
259
|
-
` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
console.log();
|
|
264
228
|
}
|
|
265
229
|
|
|
266
230
|
// Process the chain
|
|
@@ -313,6 +277,49 @@ async function processChain(
|
|
|
313
277
|
continue;
|
|
314
278
|
}
|
|
315
279
|
|
|
280
|
+
// Resolve the rebase base.
|
|
281
|
+
//
|
|
282
|
+
// Strategy:
|
|
283
|
+
// 1. Look for a recent snapshot where the parent's recorded tip is no
|
|
284
|
+
// longer an ancestor of the parent's current tip AND is still an
|
|
285
|
+
// ancestor of the child. That recorded SHA is the orphaned
|
|
286
|
+
// old-parent tip — exactly the rebase base we need.
|
|
287
|
+
// 2. Fall back to `merge-base(branch, parent)` if no snapshot tells us
|
|
288
|
+
// the parent was rewritten (typical case: parent only had commits
|
|
289
|
+
// appended, not rebased).
|
|
290
|
+
//
|
|
291
|
+
// The child-ancestry check (issue #5) prevents stale snapshots from
|
|
292
|
+
// older sessions — where the child has already been rebased past the
|
|
293
|
+
// recorded point — from being used as a rebase base.
|
|
294
|
+
//
|
|
295
|
+
// Re-read metadata fresh because previous iterations of this loop may
|
|
296
|
+
// have updated snapshots.
|
|
297
|
+
const freshMeta = (await readMetadata()) ?? meta;
|
|
298
|
+
const oldBaseFromSnapshot = await findPreRewriteSha(freshMeta, parent, branch);
|
|
299
|
+
const oldBaseFromMergeBase = await git.mergeBase(branch, parent);
|
|
300
|
+
const oldBase = oldBaseFromSnapshot ?? oldBaseFromMergeBase;
|
|
301
|
+
|
|
302
|
+
if (verbose) {
|
|
303
|
+
console.log();
|
|
304
|
+
console.log(pc.yellow(" Diagnostic: base resolution"));
|
|
305
|
+
console.log(
|
|
306
|
+
` ${pc.cyan("Snapshot pre-rewrite tip:")} ${oldBaseFromSnapshot?.slice(0, 8) ?? "(none)"}`,
|
|
307
|
+
);
|
|
308
|
+
console.log(
|
|
309
|
+
` ${pc.cyan("Current merge-base:")} ${oldBaseFromMergeBase?.slice(0, 8) ?? "(none)"}`,
|
|
310
|
+
);
|
|
311
|
+
if (
|
|
312
|
+
oldBaseFromSnapshot &&
|
|
313
|
+
oldBaseFromMergeBase &&
|
|
314
|
+
oldBaseFromSnapshot !== oldBaseFromMergeBase
|
|
315
|
+
) {
|
|
316
|
+
console.log(
|
|
317
|
+
` ${pc.yellow("⚠ Parent was rewritten")} — using snapshot base (would have replayed parent commits otherwise)`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
console.log();
|
|
321
|
+
}
|
|
322
|
+
|
|
316
323
|
// Checkout the branch
|
|
317
324
|
p.log.info(`Checking out ${pc.yellow(branch)}...`);
|
|
318
325
|
await git.checkout(branch);
|
|
@@ -324,49 +331,22 @@ async function processChain(
|
|
|
324
331
|
chain,
|
|
325
332
|
});
|
|
326
333
|
|
|
327
|
-
// Use temporary tag as stable base reference
|
|
328
|
-
const tagName = git.tempBaseTagName(branch);
|
|
329
334
|
let success: boolean;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
335
|
+
if (oldBase) {
|
|
336
|
+
const sourceLabel = oldBaseFromSnapshot ? "snapshot" : "merge-base";
|
|
337
|
+
p.log.info(
|
|
338
|
+
`Rebasing onto ${pc.yellow(parent)} (base: ${oldBase.slice(0, 8)} from ${sourceLabel})`,
|
|
339
|
+
);
|
|
334
340
|
|
|
335
341
|
if (verbose) {
|
|
336
|
-
const
|
|
337
|
-
console.log();
|
|
338
|
-
console.log(pc.yellow(" Diagnostic: Tag vs Merge-Base"));
|
|
339
|
-
console.log(` ${pc.cyan("Tagged base:")} ${taggedBase.slice(0, 8)}`);
|
|
340
|
-
if (currentMb) {
|
|
341
|
-
console.log(` ${pc.cyan("Current merge-base:")} ${currentMb.slice(0, 8)}`);
|
|
342
|
-
if (taggedBase === currentMb) {
|
|
343
|
-
console.log(` ${pc.green("✓ Same")} — Parent hasn't been updated`);
|
|
344
|
-
} else {
|
|
345
|
-
console.log(` ${pc.yellow("⚠ Different")} — Parent was rebased (tag protects us!)`);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
console.log();
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
p.log.info(`Rebasing onto ${pc.yellow(parent)}...`);
|
|
352
|
-
const count = await git.commitCount(taggedBase, branch);
|
|
353
|
-
if (verbose) {
|
|
342
|
+
const count = await git.commitCount(oldBase, branch);
|
|
354
343
|
console.log(` Moving ${count} commit(s)`);
|
|
355
344
|
}
|
|
356
345
|
|
|
357
|
-
success = await git.rebaseOnto(parent,
|
|
346
|
+
success = await git.rebaseOnto(parent, oldBase, branch);
|
|
358
347
|
} else {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const oldBase = await git.mergeBase(branch, parent);
|
|
362
|
-
|
|
363
|
-
if (!oldBase) {
|
|
364
|
-
p.log.warn("Could not find merge-base, using simple rebase");
|
|
365
|
-
success = await git.rebase(parent);
|
|
366
|
-
} else {
|
|
367
|
-
p.log.info(`Found merge-base: ${oldBase.slice(0, 8)}`);
|
|
368
|
-
success = await git.rebaseOnto(parent, oldBase, branch);
|
|
369
|
-
}
|
|
348
|
+
p.log.warn("Could not determine rebase base — using simple rebase");
|
|
349
|
+
success = await git.rebase(parent);
|
|
370
350
|
}
|
|
371
351
|
|
|
372
352
|
if (success) {
|
|
@@ -435,50 +415,3 @@ async function promptPush(branch: string): Promise<void> {
|
|
|
435
415
|
p.log.warn("Skipping push (you'll need to push manually)");
|
|
436
416
|
}
|
|
437
417
|
}
|
|
438
|
-
|
|
439
|
-
async function handleStaleTags(): Promise<void> {
|
|
440
|
-
try {
|
|
441
|
-
const { $ } = await import("bun");
|
|
442
|
-
const result = await $`git tag -l ${git.STACK_SYNC_TAG_GLOB}`.text();
|
|
443
|
-
const tags = result.trim();
|
|
444
|
-
if (!tags) return;
|
|
445
|
-
|
|
446
|
-
p.log.warn(`Found stale ${git.STACK_SYNC_TAG_GLOB} tags from a previous run`);
|
|
447
|
-
console.log();
|
|
448
|
-
|
|
449
|
-
const action = await p.select({
|
|
450
|
-
message: "What would you like to do?",
|
|
451
|
-
options: [
|
|
452
|
-
{
|
|
453
|
-
value: "clean",
|
|
454
|
-
label: "Clean up tags and start fresh",
|
|
455
|
-
},
|
|
456
|
-
{
|
|
457
|
-
value: "resume",
|
|
458
|
-
label: "Resume from previous sync (--resume)",
|
|
459
|
-
},
|
|
460
|
-
{ value: "abort", label: "Abort (keep tags, exit)" },
|
|
461
|
-
],
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
if (p.isCancel(action) || action === "abort") {
|
|
465
|
-
p.cancel("Aborted");
|
|
466
|
-
process.exit(0);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (action === "clean") {
|
|
470
|
-
p.log.info("Cleaning up stale tags...");
|
|
471
|
-
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
472
|
-
p.log.success("Tags cleaned");
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (action === "resume") {
|
|
476
|
-
// Re-run with --resume flag
|
|
477
|
-
const { default: restack } = await import("./restack.ts");
|
|
478
|
-
await restack(["--resume"]);
|
|
479
|
-
process.exit(0);
|
|
480
|
-
}
|
|
481
|
-
} catch {
|
|
482
|
-
// No stale tags — carry on
|
|
483
|
-
}
|
|
484
|
-
}
|
package/src/commands/submit.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// gh-stack submit — Push branches and create/update PRs
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { $ } from "bun";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
findStackForBranch,
|
|
8
|
+
getOrderedBranches,
|
|
9
|
+
readMetadata,
|
|
10
|
+
writeMetadata,
|
|
11
|
+
} from "../lib/metadata.ts";
|
|
8
12
|
import { isAutoYes } from "../lib/ui.ts";
|
|
13
|
+
import type { StackMetadata } from "../types.ts";
|
|
9
14
|
import { getPrNumber, getPrInfo, getPrBody, updatePrBody, reviewEmoji } from "../lib/github.ts";
|
|
15
|
+
import { resolveOrCreateStack } from "../lib/chain.ts";
|
|
10
16
|
import { buildStackViz } from "./update-prs.ts";
|
|
11
17
|
|
|
12
18
|
const HELP = `
|
|
@@ -19,6 +25,11 @@ Pushes all branches from trunk to the current branch, creating PRs
|
|
|
19
25
|
for branches that don't have them and updating stack visualization
|
|
20
26
|
in all PR descriptions. Idempotent — safe to run repeatedly.
|
|
21
27
|
|
|
28
|
+
Self-healing: if the current branch isn't tracked in a stack yet,
|
|
29
|
+
submit auto-detects the branch chain from trunk → current, registers
|
|
30
|
+
(or reconciles into) a stack, then pushes + creates PRs for the whole
|
|
31
|
+
chain. You never need to run \`gh-stack init\` first.
|
|
32
|
+
|
|
22
33
|
OPTIONS
|
|
23
34
|
-d, --draft Create new PRs as drafts
|
|
24
35
|
-n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
|
|
@@ -110,13 +121,50 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
110
121
|
|
|
111
122
|
const noEdit = args.includes("--no-edit") || args.includes("-n") || isAutoYes() || !!titleFlag;
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
// Read metadata, or start with an empty in-memory store. submit self-heals,
|
|
125
|
+
// so a missing metadata file is fine — we'll build the stack from local
|
|
126
|
+
// branch ancestry and persist it (unless --dry-run).
|
|
127
|
+
const meta: StackMetadata = (await readMetadata()) ?? {
|
|
128
|
+
version: 2,
|
|
129
|
+
current_stack: null,
|
|
130
|
+
stacks: {},
|
|
131
|
+
};
|
|
114
132
|
const branch = await git.currentBranch();
|
|
115
|
-
|
|
133
|
+
let stackName = findStackForBranch(meta, branch);
|
|
116
134
|
|
|
135
|
+
// ── Self-heal: branch isn't in any stack ──
|
|
136
|
+
// Reconstruct the stack from local branch ancestry so the end state is
|
|
137
|
+
// always stack registered + branches pushed + PRs created — no need to
|
|
138
|
+
// remember to run `gh-stack init` first.
|
|
139
|
+
let heal: { trunk: string; chain: string[]; created: boolean; added: string[] } | null = null;
|
|
117
140
|
if (!stackName) {
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
const trunk = await git.trunkBranch();
|
|
142
|
+
|
|
143
|
+
if (branch === trunk || branch === "main" || branch === "master") {
|
|
144
|
+
p.cancel(
|
|
145
|
+
`You're on ${pc.blue(branch)} — nothing to submit.\n\n Checkout a feature branch first.`,
|
|
146
|
+
);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let resolution;
|
|
151
|
+
try {
|
|
152
|
+
resolution = await resolveOrCreateStack(meta, branch, trunk);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
p.cancel((err as Error).message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
stackName = resolution.stackName;
|
|
159
|
+
heal = {
|
|
160
|
+
trunk,
|
|
161
|
+
chain: resolution.chain,
|
|
162
|
+
created: resolution.created,
|
|
163
|
+
added: resolution.addedBranches,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Persist the reconstructed/updated stack before pushing (skip on dry-run)
|
|
167
|
+
if (!dryRun) await writeMetadata(meta);
|
|
120
168
|
}
|
|
121
169
|
|
|
122
170
|
const stack = meta.stacks[stackName]!;
|
|
@@ -127,6 +175,34 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
127
175
|
const scope = currentIndex >= 0 ? ordered.slice(0, currentIndex + 1) : ordered;
|
|
128
176
|
|
|
129
177
|
p.intro(pc.cyan("Submit Stack"));
|
|
178
|
+
|
|
179
|
+
// Report self-heal results (stack creation / reconciliation + detected chain)
|
|
180
|
+
if (heal) {
|
|
181
|
+
if (heal.created) {
|
|
182
|
+
p.log.success(
|
|
183
|
+
`No stack found — created ${pc.yellow(stackName)} from ${heal.chain.length} local branch(es)`,
|
|
184
|
+
);
|
|
185
|
+
} else if (heal.added.length > 0) {
|
|
186
|
+
p.log.success(
|
|
187
|
+
`Reconciled ${heal.added.length} untracked branch(es) into ${pc.yellow(stackName)}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log();
|
|
192
|
+
console.log(` ${pc.dim(heal.trunk)}`);
|
|
193
|
+
for (let i = 0; i < heal.chain.length; i++) {
|
|
194
|
+
const b = heal.chain[i]!;
|
|
195
|
+
const isLast = i === heal.chain.length - 1;
|
|
196
|
+
const tree = isLast ? " ┗━" : " ┣━";
|
|
197
|
+
const isNew = heal.added.includes(b);
|
|
198
|
+
const label = b === branch ? pc.yellow(b) + pc.dim(" (current)") : b;
|
|
199
|
+
const tag = isNew ? pc.green(" +") : "";
|
|
200
|
+
console.log(`${tree} ${label}${tag}`);
|
|
201
|
+
if (!isLast) console.log(" ┃");
|
|
202
|
+
}
|
|
203
|
+
console.log();
|
|
204
|
+
}
|
|
205
|
+
|
|
130
206
|
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
131
207
|
p.log.info(
|
|
132
208
|
`Scope: ${scope.length} of ${ordered.length} branch(es) (downstack from ${pc.blue(branch)})`,
|
package/src/commands/sync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack sync — Fetch main, rebase base branch onto main, then restack all
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getOrderedBranches, saveRestackState } from "../lib/metadata.ts";
|
|
@@ -24,6 +24,16 @@ with the base branch included.
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Reject if a git rebase is already in progress. Letting sync run on top
|
|
28
|
+
// of a half-finished rebase produces deeply confusing state — make the
|
|
29
|
+
// user finish or abort it first.
|
|
30
|
+
if (await git.isRebaseInProgress()) {
|
|
31
|
+
p.cancel(
|
|
32
|
+
`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 sync")}.`,
|
|
33
|
+
);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
await ensureCleanWorkingTree();
|
|
28
38
|
|
|
29
39
|
const meta = await ensureMetadata();
|
|
@@ -83,31 +93,11 @@ with the base branch included.
|
|
|
83
93
|
await git.fetchMain();
|
|
84
94
|
fetchSpinner.stop("Fetched latest main");
|
|
85
95
|
|
|
86
|
-
// Take snapshot
|
|
96
|
+
// Take snapshot BEFORE rebasing. This records every branch's pre-rebase
|
|
97
|
+
// tip — including the orphaned base-branch tip that restack will later
|
|
98
|
+
// need to find when restacking children. (See findPreRewriteSha.)
|
|
87
99
|
await takeSnapshot(meta, stackName, "sync");
|
|
88
100
|
|
|
89
|
-
// Create temporary tags for ALL branches BEFORE any rebasing starts.
|
|
90
|
-
// This is critical: after rebasing PR1, `git merge-base PR2 PR1` returns
|
|
91
|
-
// wrong results. Tags capture the true divergence points while history
|
|
92
|
-
// is still intact.
|
|
93
|
-
p.log.info("Creating temporary base tags for all branches...");
|
|
94
|
-
for (const branch of ordered) {
|
|
95
|
-
const parent = stack.branches[branch]?.parent;
|
|
96
|
-
if (!parent) continue;
|
|
97
|
-
|
|
98
|
-
// For the base branch, the "parent" is main — tag its merge-base with main
|
|
99
|
-
const parentRef = parent === "main" ? "origin/main" : parent;
|
|
100
|
-
const mb = await git.mergeBase(branch, parentRef);
|
|
101
|
-
if (mb) {
|
|
102
|
-
const tagName = git.tempBaseTagName(branch);
|
|
103
|
-
await git.createTag(tagName, mb);
|
|
104
|
-
console.log(
|
|
105
|
-
` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
console.log();
|
|
110
|
-
|
|
111
101
|
// Step 1: Rebase base branch onto main
|
|
112
102
|
console.log();
|
|
113
103
|
console.log(pc.cyan("━".repeat(40)));
|
package/src/commands/top.ts
CHANGED
package/src/commands/undo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack undo — Restore from last snapshot
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import * as git from "../lib/git.ts";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
package/src/commands/up.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// gh-stack up — Move to child branch (upstack)
|
|
2
|
-
import * as p from "
|
|
2
|
+
import * as p from "../lib/output.ts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getChildren, writeMetadata } from "../lib/metadata.ts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// gh-stack update-prs — Update PR descriptions with stack visualization
|
|
2
2
|
// Port of reference/gh-stack-update-pr.sh
|
|
3
|
-
import * as p from "
|
|
3
|
+
import * as p from "../lib/output.ts";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import { findStackForBranch, getOrderedBranches } from "../lib/metadata.ts";
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// gh-stack — Unified stacked PR manager for squash-merge workflows
|
|
3
3
|
|
|
4
|
-
// Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it
|
|
5
|
-
|
|
4
|
+
// Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it.
|
|
5
|
+
// Also pre-detect plain mode (--plain flag, GH_STACK_YES=1, or GH_STACK_PLAIN=1)
|
|
6
|
+
// and set NO_COLOR *before* any import below — picocolors snapshots env at
|
|
7
|
+
// load time, so this must run before the import block.
|
|
8
|
+
const _earlyArgs = process.argv.slice(2);
|
|
9
|
+
const _earlyPlain =
|
|
10
|
+
_earlyArgs.includes("--plain") ||
|
|
11
|
+
process.env.GH_STACK_YES === "1" ||
|
|
12
|
+
process.env.GH_STACK_PLAIN === "1";
|
|
13
|
+
if (process.env.GH_STACK_NO_COLOR || _earlyPlain) {
|
|
6
14
|
process.env.NO_COLOR = "1";
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
import { ensureGitRepo } from "./lib/safety.ts";
|
|
10
18
|
import { parseCliArgs } from "./lib/cli.ts";
|
|
11
19
|
import { setAutoYes } from "./lib/ui.ts";
|
|
20
|
+
import { setPlain } from "./lib/output.ts";
|
|
12
21
|
|
|
13
22
|
const { version: VERSION } = await import("../package.json");
|
|
14
23
|
|
|
@@ -32,6 +41,11 @@ if (parsed.autoYes) {
|
|
|
32
41
|
setAutoYes(true);
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
// Global --plain (or GH_STACK_YES=1): drop intro/outro/spinner/color chrome.
|
|
45
|
+
if (parsed.plain) {
|
|
46
|
+
setPlain(true);
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
// Ensure we're in a git repo for all commands
|
|
36
50
|
await ensureGitRepo();
|
|
37
51
|
|
|
@@ -160,11 +174,13 @@ ${bold("INFO & MAINTENANCE")}
|
|
|
160
174
|
|
|
161
175
|
${bold("GLOBAL OPTIONS")}
|
|
162
176
|
--yes, -y Skip confirmations ${dim("(for agents/CI)")}
|
|
177
|
+
--plain Plain output: no spinners, colors, or box drawing
|
|
163
178
|
--help Show help
|
|
164
179
|
--version, -V Show version
|
|
165
180
|
|
|
166
181
|
${bold("ENVIRONMENT")}
|
|
167
|
-
GH_STACK_YES=1 Same as --yes
|
|
182
|
+
GH_STACK_YES=1 Same as --yes (also enables --plain)
|
|
183
|
+
GH_STACK_PLAIN=1 Same as --plain
|
|
168
184
|
GH_STACK_NO_COLOR Disable colored output
|
|
169
185
|
`);
|
|
170
186
|
}
|