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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/package.json +66 -0
- package/src/commands/add.ts +137 -0
- package/src/commands/archive.ts +60 -0
- package/src/commands/init.ts +156 -0
- package/src/commands/list.ts +64 -0
- package/src/commands/merge.ts +175 -0
- package/src/commands/remove.ts +78 -0
- package/src/commands/restack.ts +476 -0
- package/src/commands/show.ts +73 -0
- package/src/commands/status.ts +372 -0
- package/src/commands/switch.ts +114 -0
- package/src/commands/sync.ts +189 -0
- package/src/commands/undo.ts +64 -0
- package/src/commands/update-prs.ts +177 -0
- package/src/index.ts +146 -0
- package/src/lib/git.ts +237 -0
- package/src/lib/github.ts +184 -0
- package/src/lib/metadata.ts +307 -0
- package/src/lib/safety.ts +98 -0
- package/src/lib/snapshot.ts +74 -0
- package/src/lib/ui.ts +234 -0
- package/src/types.ts +68 -0
|
@@ -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
|
+
}
|