gh-stack 0.1.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.
@@ -0,0 +1,476 @@
1
+ // gh-stack restack — Rebase children onto updated parents
2
+ // Port of reference/gh-stack-sync.sh — the most critical command
3
+ import * as p from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import * as git from "../lib/git.ts";
6
+ import {
7
+ findStackForBranch,
8
+ buildRebaseChain,
9
+ saveRestackState,
10
+ loadRestackState,
11
+ clearRestackState,
12
+ } from "../lib/metadata.ts";
13
+ import { ensureMetadata, ensureCleanWorkingTree, ensureNotMain } from "../lib/safety.ts";
14
+ import { takeSnapshot } from "../lib/snapshot.ts";
15
+ import { confirmAction } from "../lib/ui.ts";
16
+ import type { StackMetadata } from "../types.ts";
17
+
18
+ export default async function restack(args: string[]): Promise<void> {
19
+ const dryRun = args.includes("--dry-run");
20
+ const resume = args.includes("--resume");
21
+ const verbose = args.includes("--verbose") || args.includes("-v");
22
+
23
+ if (args.includes("--help")) {
24
+ console.log(`
25
+ gh-stack restack — Rebase children onto updated parents
26
+
27
+ USAGE
28
+ gh-stack restack [options]
29
+
30
+ OPTIONS
31
+ --yes, -y Skip confirmations (auto-accept all rebases and pushes)
32
+ --resume Resume after resolving rebase conflicts
33
+ --dry-run Show what would happen without executing
34
+ --verbose Show diagnostic info (tag vs merge-base)
35
+
36
+ EXAMPLES
37
+ gh-stack restack Interactive (prompts before each rebase)
38
+ gh-stack restack --yes Non-interactive (for agents/CI)
39
+ gh-stack restack --resume Continue after resolving conflicts
40
+
41
+ ALIASES
42
+ gh-stack rebase
43
+ `);
44
+ return;
45
+ }
46
+
47
+ // Ensure clean working tree (unless resuming)
48
+ if (!resume) {
49
+ await ensureCleanWorkingTree();
50
+ }
51
+
52
+ const meta = await ensureMetadata();
53
+ const rebasedBranches: string[] = [];
54
+
55
+ p.intro(pc.cyan("Git Stack Restack"));
56
+
57
+ if (resume) {
58
+ await handleResume(meta, rebasedBranches, verbose);
59
+ } else {
60
+ await handleFreshRestack(meta, rebasedBranches, dryRun, verbose);
61
+ }
62
+
63
+ // Summary
64
+ console.log();
65
+ if (rebasedBranches.length === 0) {
66
+ p.log.warn("No branches were rebased");
67
+ } else {
68
+ p.log.success("Rebased branches:");
69
+ for (const branch of rebasedBranches) {
70
+ console.log(` ${pc.green("✓")} ${branch}`);
71
+ }
72
+ }
73
+
74
+ // Cleanup tags
75
+ await git.deleteTagsMatching("stack-sync-*");
76
+
77
+ p.outro(pc.green("Stack Restack Complete!"));
78
+ }
79
+
80
+ async function handleResume(
81
+ meta: StackMetadata,
82
+ rebasedBranches: string[],
83
+ verbose: boolean,
84
+ ): Promise<void> {
85
+ p.log.warn("Resuming from saved state...");
86
+
87
+ const state = await loadRestackState();
88
+ if (!state) {
89
+ p.cancel("No saved state found. Nothing to resume.");
90
+ process.exit(1);
91
+ }
92
+
93
+ // Check if rebase is still in progress
94
+ if (await git.isRebaseInProgress()) {
95
+ p.cancel(
96
+ `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")}`,
97
+ );
98
+ process.exit(1);
99
+ }
100
+
101
+ const currentBranch = await git.currentBranch();
102
+ const expectedBranch = state.chain[state.current_index]!;
103
+
104
+ // Check for branch mismatch (likely means rebase was aborted)
105
+ if (currentBranch !== expectedBranch) {
106
+ p.log.warn("Branch mismatch detected");
107
+ p.log.info(`Expected: ${pc.blue(expectedBranch)}`);
108
+ p.log.info(`Current: ${pc.blue(currentBranch)}`);
109
+ console.log();
110
+ p.log.info("This likely means the rebase was aborted. Cleaning up stale state...");
111
+ await clearRestackState();
112
+ console.log();
113
+ console.log(` State cleared. To sync the stack, run from the desired branch:`);
114
+ console.log(` ${pc.green("gh-stack restack")}`);
115
+ return;
116
+ }
117
+
118
+ // Rebase completed successfully
119
+ p.log.success("Rebase completed successfully");
120
+ rebasedBranches.push(expectedBranch);
121
+ await clearRestackState();
122
+
123
+ // Prompt to push the resumed branch
124
+ await promptPush(expectedBranch);
125
+
126
+ // Continue with remaining branches in chain
127
+ const stackName = state.stack_name;
128
+ const stack = meta.stacks[stackName];
129
+ if (!stack) return;
130
+
131
+ const chain = state.chain;
132
+ const currentIndex = state.current_index + 1;
133
+
134
+ if (currentIndex < chain.length) {
135
+ // Ensure temporary tags exist for remaining branches.
136
+ // If sync created them upfront (correct!), they'll already be here.
137
+ // If not (e.g., old state file), we create them as a fallback —
138
+ // but NOTE: tags created after parent was rebased may be inaccurate.
139
+ p.log.info("Checking base tags for remaining branches...");
140
+ for (let i = currentIndex; i < chain.length; i++) {
141
+ const branch = chain[i]!;
142
+ const parent = stack.branches[branch]?.parent;
143
+ if (!parent) continue;
144
+
145
+ const tagName = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
146
+ if (await git.tagExists(tagName)) {
147
+ const tagSha = await git.revParse(tagName);
148
+ console.log(
149
+ ` ${pc.green("✓")} Pre-existing tag for ${branch}: ${pc.cyan(tagName)} (${tagSha.slice(0, 8)})`,
150
+ );
151
+ } else {
152
+ // Fallback: create tag now (may be inaccurate if parent already rebased)
153
+ p.log.warn(`No pre-existing tag for ${branch} — creating from current merge-base`);
154
+ const mb = await git.mergeBase(branch, parent);
155
+ if (mb) {
156
+ await git.createTag(tagName, mb);
157
+ console.log(
158
+ ` ${pc.yellow("⚠")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
159
+ );
160
+ }
161
+ }
162
+ }
163
+ console.log();
164
+ }
165
+
166
+ await processChain(meta, stackName, chain, currentIndex, rebasedBranches, false, verbose);
167
+ }
168
+
169
+ async function handleFreshRestack(
170
+ meta: StackMetadata,
171
+ rebasedBranches: string[],
172
+ dryRun: boolean,
173
+ verbose: boolean,
174
+ ): Promise<void> {
175
+ const currentBranch = await git.currentBranch();
176
+ const stackName = findStackForBranch(meta, currentBranch);
177
+
178
+ if (!stackName) {
179
+ p.cancel(
180
+ `Branch ${pc.blue(currentBranch)} is not in any stack.\n\n Add it with:\n ${pc.green("gh-stack add")}`,
181
+ );
182
+ process.exit(1);
183
+ }
184
+
185
+ const stack = meta.stacks[stackName]!;
186
+
187
+ // Check for stale tags from previous runs
188
+ await handleStaleTags();
189
+
190
+ // Build the rebase chain from current branch
191
+ p.log.info("Building rebase chain...");
192
+ let chain = buildRebaseChain(stack, currentBranch);
193
+
194
+ if (chain.length === 0) {
195
+ p.cancel("Could not build rebase chain");
196
+ process.exit(1);
197
+ }
198
+
199
+ // Show the chain
200
+ p.log.success(`Found ${chain.length} branch(es) to process:`);
201
+ for (const branch of chain) {
202
+ const parent = stack.branches[branch]?.parent || "(unknown)";
203
+ console.log(` ${pc.yellow("→")} ${branch} ${pc.blue(`(parent: ${parent})`)}`);
204
+ }
205
+ console.log();
206
+
207
+ // Skip base branches (parent = main) unless this is a sync
208
+ const baseBranches = chain.filter((b) => stack.branches[b]?.parent === "main");
209
+ if (baseBranches.length > 0) {
210
+ p.log.warn("Skipping base branch(es) (parent: main):");
211
+ for (const base of baseBranches) {
212
+ console.log(` ${pc.cyan("Skip:")} ${base} ${pc.blue("(parent: main)")}`);
213
+ }
214
+ console.log();
215
+ console.log(` ${pc.dim("This is a restack — propagating changes from parent to children.")}`);
216
+ console.log(` ${pc.dim(`To include rebasing onto main, use: ${pc.green("gh-stack sync")}`)}`);
217
+ console.log();
218
+
219
+ chain = chain.filter((b) => stack.branches[b]?.parent !== "main");
220
+
221
+ if (chain.length === 0) {
222
+ p.log.warn("No branches to process after skipping base branches");
223
+ console.log();
224
+ console.log(" Either:");
225
+ console.log(" 1. Run from a child branch (e.g., PR2)");
226
+ console.log(` 2. Use ${pc.green("gh-stack sync")} to include rebasing base onto main`);
227
+ return;
228
+ }
229
+
230
+ p.log.success(`Processing ${chain.length} branch(es):`);
231
+ for (const branch of chain) {
232
+ const parent = stack.branches[branch]?.parent || "(unknown)";
233
+ console.log(` ${pc.yellow("→")} ${branch} ${pc.blue(`(parent: ${parent})`)}`);
234
+ }
235
+ console.log();
236
+ }
237
+
238
+ // Take snapshot before destructive operation
239
+ await takeSnapshot(meta, stackName, "restack");
240
+
241
+ // Create temporary tags for stable base references
242
+ p.log.info("Creating temporary base tags...");
243
+ for (const branch of chain) {
244
+ const parent = stack.branches[branch]?.parent;
245
+ if (!parent) continue;
246
+
247
+ const mb = await git.mergeBase(branch, parent);
248
+ if (mb) {
249
+ const tagName = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
250
+ await git.createTag(tagName, mb);
251
+ console.log(
252
+ ` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
253
+ );
254
+ }
255
+ }
256
+ console.log();
257
+
258
+ // Process the chain
259
+ await processChain(meta, stackName, chain, 0, rebasedBranches, dryRun, verbose);
260
+ }
261
+
262
+ async function processChain(
263
+ meta: StackMetadata,
264
+ stackName: string,
265
+ chain: string[],
266
+ startIndex: number,
267
+ rebasedBranches: string[],
268
+ dryRun: boolean,
269
+ verbose: boolean,
270
+ ): Promise<void> {
271
+ const stack = meta.stacks[stackName]!;
272
+
273
+ for (let i = startIndex; i < chain.length; i++) {
274
+ const branch = chain[i]!;
275
+ const parent = stack.branches[branch]?.parent;
276
+
277
+ if (!parent) {
278
+ p.log.warn(`Skipping ${branch} (no parent defined)`);
279
+ continue;
280
+ }
281
+
282
+ console.log();
283
+ console.log(pc.cyan("━".repeat(40)));
284
+ console.log(`${pc.blue("Branch:")} ${branch}`);
285
+ console.log(`${pc.blue("Parent:")} ${parent}`);
286
+ console.log(pc.cyan("━".repeat(40)));
287
+ console.log();
288
+
289
+ // Check if already up to date
290
+ if (await git.isAncestor(parent, branch)) {
291
+ p.log.success("Already up to date with parent");
292
+ continue;
293
+ }
294
+
295
+ // Dry run mode
296
+ if (dryRun) {
297
+ p.log.warn(`[DRY RUN] Would rebase ${branch} onto ${parent}`);
298
+ continue;
299
+ }
300
+
301
+ // Prompt for rebase
302
+ const confirmed = await confirmAction(`Rebase ${pc.yellow(branch)} onto ${pc.yellow(parent)}?`);
303
+ if (!confirmed) {
304
+ p.log.warn("Skipping rebase");
305
+ continue;
306
+ }
307
+
308
+ // Checkout the branch
309
+ p.log.info(`Checking out ${pc.yellow(branch)}...`);
310
+ await git.checkout(branch);
311
+
312
+ // Save state for resume
313
+ await saveRestackState({
314
+ current_index: i,
315
+ stack_name: stackName,
316
+ chain,
317
+ });
318
+
319
+ // Use temporary tag as stable base reference
320
+ const tagName = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
321
+ let success: boolean;
322
+
323
+ if (await git.tagExists(tagName)) {
324
+ const taggedBase = await git.revParse(tagName);
325
+ p.log.info(`Using tagged base: ${pc.cyan(tagName)} (${taggedBase.slice(0, 8)})`);
326
+
327
+ if (verbose) {
328
+ const currentMb = await git.mergeBase(branch, parent);
329
+ console.log();
330
+ console.log(pc.yellow(" Diagnostic: Tag vs Merge-Base"));
331
+ console.log(` ${pc.cyan("Tagged base:")} ${taggedBase.slice(0, 8)}`);
332
+ if (currentMb) {
333
+ console.log(` ${pc.cyan("Current merge-base:")} ${currentMb.slice(0, 8)}`);
334
+ if (taggedBase === currentMb) {
335
+ console.log(` ${pc.green("✓ Same")} — Parent hasn't been updated`);
336
+ } else {
337
+ console.log(` ${pc.yellow("⚠ Different")} — Parent was rebased (tag protects us!)`);
338
+ }
339
+ }
340
+ console.log();
341
+ }
342
+
343
+ p.log.info(`Rebasing onto ${pc.yellow(parent)}...`);
344
+ const count = await git.commitCount(taggedBase, branch);
345
+ if (verbose) {
346
+ console.log(` Moving ${count} commit(s)`);
347
+ }
348
+
349
+ success = await git.rebaseOnto(parent, tagName, branch);
350
+ } else {
351
+ // Fallback to merge-base
352
+ p.log.warn("Tag not found, calculating merge-base...");
353
+ const oldBase = await git.mergeBase(branch, parent);
354
+
355
+ if (!oldBase) {
356
+ p.log.warn("Could not find merge-base, using simple rebase");
357
+ success = await git.rebase(parent);
358
+ } else {
359
+ p.log.info(`Found merge-base: ${oldBase.slice(0, 8)}`);
360
+ success = await git.rebaseOnto(parent, oldBase, branch);
361
+ }
362
+ }
363
+
364
+ if (success) {
365
+ p.log.success("Rebase successful");
366
+ rebasedBranches.push(branch);
367
+ await clearRestackState();
368
+
369
+ // Prompt to push immediately (don't batch)
370
+ await promptPush(branch);
371
+ } else {
372
+ // Conflict!
373
+ console.log();
374
+ console.log(pc.red("━".repeat(40)));
375
+ console.log(pc.red(" Rebase Conflict"));
376
+ console.log(pc.red("━".repeat(40)));
377
+ console.log();
378
+ console.log(pc.yellow("Conflicts detected during rebase."));
379
+ console.log();
380
+ console.log(" Please resolve the conflicts, then:");
381
+ console.log(` ${pc.green("git rebase --continue")}`);
382
+ console.log(` ${pc.green("gh-stack restack --resume")}`);
383
+ console.log();
384
+ console.log(" Or abort the rebase:");
385
+ console.log(` ${pc.red("git rebase --abort")}`);
386
+ process.exit(2);
387
+ }
388
+ }
389
+ }
390
+
391
+ async function promptPush(branch: string): Promise<void> {
392
+ ensureNotMain(branch);
393
+
394
+ if (!(await git.remoteBranchExists(branch))) return;
395
+
396
+ const localSha = await git.revParse(branch);
397
+ let remoteSha: string;
398
+ try {
399
+ remoteSha = await git.revParse(`origin/${branch}`);
400
+ } catch {
401
+ return;
402
+ }
403
+
404
+ if (localSha === remoteSha) return;
405
+
406
+ console.log();
407
+ console.log(pc.cyan("━".repeat(40)));
408
+ console.log(pc.blue(" Push Rebased Branch?"));
409
+ console.log(pc.cyan("━".repeat(40)));
410
+ console.log();
411
+ console.log(`${pc.blue("Branch:")} ${branch}`);
412
+ console.log(pc.yellow("⚠ Branch is out of sync with remote"));
413
+ console.log(` Local: ${localSha.slice(0, 8)}`);
414
+ console.log(` Remote: ${remoteSha.slice(0, 8)}`);
415
+ console.log();
416
+
417
+ const confirmed = await confirmAction("Push with --force-with-lease?");
418
+ if (confirmed) {
419
+ p.log.info(`Pushing ${branch}...`);
420
+ const ok = await git.forcePushWithLease(branch);
421
+ if (ok) {
422
+ p.log.success("Pushed successfully");
423
+ } else {
424
+ p.log.error("Push failed — you may need to push manually later");
425
+ }
426
+ } else {
427
+ p.log.warn("Skipping push (you'll need to push manually)");
428
+ }
429
+ }
430
+
431
+ async function handleStaleTags(): Promise<void> {
432
+ try {
433
+ const { $ } = await import("bun");
434
+ const result = await $`git tag -l "stack-sync-*"`.text();
435
+ const tags = result.trim();
436
+ if (!tags) return;
437
+
438
+ p.log.warn("Found stale stack-sync-* tags from a previous run");
439
+ console.log();
440
+
441
+ const action = await p.select({
442
+ message: "What would you like to do?",
443
+ options: [
444
+ {
445
+ value: "clean",
446
+ label: "Clean up tags and start fresh",
447
+ },
448
+ {
449
+ value: "resume",
450
+ label: "Resume from previous sync (--resume)",
451
+ },
452
+ { value: "abort", label: "Abort (keep tags, exit)" },
453
+ ],
454
+ });
455
+
456
+ if (p.isCancel(action) || action === "abort") {
457
+ p.cancel("Aborted");
458
+ process.exit(0);
459
+ }
460
+
461
+ if (action === "clean") {
462
+ p.log.info("Cleaning up stale tags...");
463
+ await git.deleteTagsMatching("stack-sync-*");
464
+ p.log.success("Tags cleaned");
465
+ }
466
+
467
+ if (action === "resume") {
468
+ // Re-run with --resume flag
469
+ const { default: restack } = await import("./restack.ts");
470
+ await restack(["--resume"]);
471
+ process.exit(0);
472
+ }
473
+ } catch {
474
+ // No stale tags — carry on
475
+ }
476
+ }
@@ -0,0 +1,73 @@
1
+ // gh-stack show — Display current stack tree (default command)
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import * as git from "../lib/git.ts";
5
+ import {
6
+ readMetadata,
7
+ metadataExists,
8
+ findStackForBranch,
9
+ writeMetadata,
10
+ } from "../lib/metadata.ts";
11
+ import { renderStackTree } from "../lib/ui.ts";
12
+
13
+ export default async function show(_args: string[]): Promise<void> {
14
+ // Check if metadata exists
15
+ if (!(await metadataExists())) {
16
+ p.log.warn("No stack metadata found");
17
+ console.log();
18
+ console.log(` Create your first stack with:`);
19
+ console.log(` ${pc.green("gh-stack init")}`);
20
+ process.exit(1);
21
+ }
22
+
23
+ const meta = await readMetadata();
24
+ if (!meta) {
25
+ p.cancel("Failed to read stack metadata");
26
+ process.exit(1);
27
+ }
28
+
29
+ const branch = await git.currentBranch();
30
+
31
+ // Find which stack contains the current branch
32
+ let stackName = findStackForBranch(meta, branch);
33
+
34
+ if (!stackName) {
35
+ // Fall back to current_stack
36
+ stackName = meta.current_stack;
37
+ }
38
+
39
+ if (!stackName || !meta.stacks[stackName]) {
40
+ p.log.warn(`Branch ${pc.blue(branch)} is not in any stack`);
41
+ console.log();
42
+ console.log(` Add it to a stack with:`);
43
+ console.log(` ${pc.green("gh-stack add")}`);
44
+ console.log();
45
+ console.log(` Or create a new stack:`);
46
+ console.log(` ${pc.green("gh-stack init")}`);
47
+ process.exit(1);
48
+ }
49
+
50
+ const stack = meta.stacks[stackName]!;
51
+
52
+ // Update current_stack and last_branch
53
+ meta.current_stack = stackName;
54
+ stack.last_branch = branch;
55
+ await writeMetadata(meta);
56
+
57
+ // Header
58
+ console.log();
59
+ console.log(`${pc.blue("📚 PR Stack:")} ${pc.yellow(stackName)}`);
60
+ if (stack.description) {
61
+ console.log(` ${pc.dim(stack.description)}`);
62
+ }
63
+ console.log();
64
+
65
+ // Render tree
66
+ const tree = renderStackTree(stack, branch);
67
+ console.log(tree);
68
+ console.log();
69
+
70
+ // Tip
71
+ console.log(pc.dim(`Tip: Switch stacks with 'gh-stack switch --stack'`));
72
+ console.log();
73
+ }