gh-stack 0.1.0 → 0.1.2
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 +22 -8
- package/package.json +2 -2
- package/src/commands/add.ts +5 -2
- package/src/commands/list.ts +5 -2
- package/src/commands/merge.ts +2 -1
- package/src/commands/remove.ts +2 -1
- package/src/commands/restack.ts +31 -23
- package/src/commands/show.ts +5 -2
- package/src/commands/sync.ts +25 -2
- package/src/commands/undo.ts +13 -1
- package/src/commands/update-prs.ts +7 -2
- package/src/index.ts +7 -5
- package/src/lib/cli.ts +32 -0
- package/src/lib/git.ts +25 -3
- package/src/lib/safety.ts +109 -1
package/README.md
CHANGED
|
@@ -47,23 +47,24 @@ gh-stack status
|
|
|
47
47
|
### Stack Management
|
|
48
48
|
|
|
49
49
|
```
|
|
50
|
-
init [--name <name>] [--description <desc>]
|
|
50
|
+
init [--name <name>] [--description <desc>] [--parent <branch>]
|
|
51
51
|
Create a new stack and add the current branch as the first entry.
|
|
52
52
|
|
|
53
|
-
add [<branch>] [--parent <branch>] [--create] [--description <desc>]
|
|
53
|
+
add [<branch>] [--parent <branch>] [--create <branch>] [--description <desc>]
|
|
54
54
|
Add a branch to the current stack. Defaults to the current branch.
|
|
55
|
-
--create makes a new branch off the top of stack first.
|
|
55
|
+
--create <branch> makes a new branch off the top of stack first.
|
|
56
56
|
|
|
57
|
-
remove <branch>
|
|
57
|
+
remove [<branch>]
|
|
58
58
|
Remove a branch from the stack and re-parent its children.
|
|
59
|
+
If omitted, an interactive selector is shown.
|
|
59
60
|
```
|
|
60
61
|
|
|
61
62
|
### Navigation
|
|
62
63
|
|
|
63
64
|
```
|
|
64
65
|
show
|
|
65
|
-
Display the current stack as a tree with
|
|
66
|
-
|
|
66
|
+
Display the current stack as a tree with branch numbers,
|
|
67
|
+
PR numbers, and descriptions. This is the default command.
|
|
67
68
|
|
|
68
69
|
list
|
|
69
70
|
List branches with position numbers. Lightweight output for
|
|
@@ -104,6 +105,7 @@ merge [--dry-run]
|
|
|
104
105
|
Squash-merge the stack top-down locally (PR3 -> PR2 -> PR1),
|
|
105
106
|
then optionally rebase onto main. Keeps all commits local to
|
|
106
107
|
avoid orphaned squash commits from GitHub-only merges.
|
|
108
|
+
Can also close intermediate PRs and archives the stack on completion.
|
|
107
109
|
|
|
108
110
|
update-prs
|
|
109
111
|
Update all PR descriptions with a stack visualization:
|
|
@@ -119,13 +121,25 @@ update-prs
|
|
|
119
121
|
### Maintenance
|
|
120
122
|
|
|
121
123
|
```
|
|
122
|
-
archive [--
|
|
123
|
-
|
|
124
|
+
archive [--restore <name>]
|
|
125
|
+
List archived stacks by default, or restore one by name.
|
|
124
126
|
|
|
125
127
|
undo
|
|
126
128
|
Restore the last snapshot taken before a destructive operation.
|
|
127
129
|
```
|
|
128
130
|
|
|
131
|
+
## Not Implemented Yet
|
|
132
|
+
|
|
133
|
+
These are still intended, but not shipped in `v0.1.2`:
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
split
|
|
137
|
+
Guided helper for splitting a branch/PR into two and updating stack metadata.
|
|
138
|
+
|
|
139
|
+
insert
|
|
140
|
+
Insert a branch into the middle of an existing stack and re-parent the chain.
|
|
141
|
+
```
|
|
142
|
+
|
|
129
143
|
## Global Options
|
|
130
144
|
|
|
131
145
|
```
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gh-stack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Stacked PR manager for squash-merge workflows",
|
|
5
5
|
"author": "Kiliman <kiliman@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/kiliman/gh-stack"
|
|
9
|
+
"url": "git+https://github.com/kiliman/gh-stack.git"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"git",
|
package/src/commands/add.ts
CHANGED
|
@@ -3,9 +3,9 @@ import * as p from "@clack/prompts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { addBranchToStack, getTopOfStack, getOrderedBranches } from "../lib/metadata.ts";
|
|
6
|
-
import { ensureMetadata, ensureCurrentStack } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
|
|
7
7
|
import { getPrNumber } from "../lib/github.ts";
|
|
8
|
-
import { selectParent } from "../lib/ui.ts";
|
|
8
|
+
import { isAutoYes, selectParent } from "../lib/ui.ts";
|
|
9
9
|
import type { Branch } from "../types.ts";
|
|
10
10
|
|
|
11
11
|
export default async function add(args: string[]): Promise<void> {
|
|
@@ -48,6 +48,7 @@ OPTIONS
|
|
|
48
48
|
|
|
49
49
|
const meta = await ensureMetadata();
|
|
50
50
|
const stackName = ensureCurrentStack(meta);
|
|
51
|
+
await ensureValidStack(meta, stackName);
|
|
51
52
|
const stack = meta.stacks[stackName]!;
|
|
52
53
|
|
|
53
54
|
p.intro(pc.cyan(`Add branch to ${pc.yellow(stackName)}`));
|
|
@@ -95,6 +96,8 @@ OPTIONS
|
|
|
95
96
|
|
|
96
97
|
if (ordered.length === 0) {
|
|
97
98
|
parent = "main";
|
|
99
|
+
} else if (isAutoYes()) {
|
|
100
|
+
parent = getTopOfStack(stack) ?? "main";
|
|
98
101
|
} else {
|
|
99
102
|
parent = (await selectParent(stack, branchToAdd)) ?? undefined;
|
|
100
103
|
if (!parent) {
|
package/src/commands/list.ts
CHANGED
|
@@ -26,7 +26,8 @@ ALIASES
|
|
|
26
26
|
const currentBranch = await git.currentBranch();
|
|
27
27
|
|
|
28
28
|
// Find which stack contains the current branch
|
|
29
|
-
|
|
29
|
+
const branchStackName = findStackForBranch(meta, currentBranch);
|
|
30
|
+
let stackName = branchStackName;
|
|
30
31
|
if (!stackName) stackName = meta.current_stack;
|
|
31
32
|
|
|
32
33
|
if (!stackName || !meta.stacks[stackName]) {
|
|
@@ -39,7 +40,9 @@ ALIASES
|
|
|
39
40
|
|
|
40
41
|
// Update tracking
|
|
41
42
|
meta.current_stack = stackName;
|
|
42
|
-
|
|
43
|
+
if (branchStackName === stackName) {
|
|
44
|
+
stack.last_branch = currentBranch;
|
|
45
|
+
}
|
|
43
46
|
await writeMetadata(meta);
|
|
44
47
|
|
|
45
48
|
// Header
|
package/src/commands/merge.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as p from "@clack/prompts";
|
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
|
|
7
|
-
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
7
|
+
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
8
8
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
9
9
|
import { closePr } from "../lib/github.ts";
|
|
10
10
|
import { confirmAction } from "../lib/ui.ts";
|
|
@@ -39,6 +39,7 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const stack = meta.stacks[stackName]!;
|
|
42
|
+
await ensureValidStack(meta, stackName);
|
|
42
43
|
const ordered = getOrderedBranches(stack);
|
|
43
44
|
|
|
44
45
|
if (ordered.length <= 1) {
|
package/src/commands/remove.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
|
|
5
|
-
import { ensureMetadata, ensureCurrentStack } from "../lib/safety.ts";
|
|
5
|
+
import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
|
|
6
6
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
7
7
|
import { selectBranch } from "../lib/ui.ts";
|
|
8
8
|
import * as git from "../lib/git.ts";
|
|
@@ -23,6 +23,7 @@ If no branch is specified, shows interactive selector.
|
|
|
23
23
|
|
|
24
24
|
const meta = await ensureMetadata();
|
|
25
25
|
const stackName = ensureCurrentStack(meta);
|
|
26
|
+
await ensureValidStack(meta, stackName);
|
|
26
27
|
const stack = meta.stacks[stackName]!;
|
|
27
28
|
const currentBranch = await git.currentBranch();
|
|
28
29
|
|
package/src/commands/restack.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
loadRestackState,
|
|
11
11
|
clearRestackState,
|
|
12
12
|
} from "../lib/metadata.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ensureMetadata,
|
|
15
|
+
ensureCleanWorkingTree,
|
|
16
|
+
ensureNotMain,
|
|
17
|
+
ensureValidStack,
|
|
18
|
+
} from "../lib/safety.ts";
|
|
14
19
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
15
20
|
import { confirmAction } from "../lib/ui.ts";
|
|
16
21
|
import type { StackMetadata } from "../types.ts";
|
|
@@ -72,7 +77,7 @@ ALIASES
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
// Cleanup tags
|
|
75
|
-
await git.deleteTagsMatching(
|
|
80
|
+
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
76
81
|
|
|
77
82
|
p.outro(pc.green("Stack Restack Complete!"));
|
|
78
83
|
}
|
|
@@ -142,7 +147,7 @@ async function handleResume(
|
|
|
142
147
|
const parent = stack.branches[branch]?.parent;
|
|
143
148
|
if (!parent) continue;
|
|
144
149
|
|
|
145
|
-
const tagName =
|
|
150
|
+
const tagName = git.tempBaseTagName(branch);
|
|
146
151
|
if (await git.tagExists(tagName)) {
|
|
147
152
|
const tagSha = await git.revParse(tagName);
|
|
148
153
|
console.log(
|
|
@@ -183,6 +188,7 @@ async function handleFreshRestack(
|
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
const stack = meta.stacks[stackName]!;
|
|
191
|
+
await ensureValidStack(meta, stackName);
|
|
186
192
|
|
|
187
193
|
// Check for stale tags from previous runs
|
|
188
194
|
await handleStaleTags();
|
|
@@ -235,25 +241,27 @@ async function handleFreshRestack(
|
|
|
235
241
|
console.log();
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
|
|
239
|
-
|
|
244
|
+
if (!dryRun) {
|
|
245
|
+
// Take snapshot before destructive operation
|
|
246
|
+
await takeSnapshot(meta, stackName, "restack");
|
|
240
247
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
}
|
|
254
262
|
}
|
|
263
|
+
console.log();
|
|
255
264
|
}
|
|
256
|
-
console.log();
|
|
257
265
|
|
|
258
266
|
// Process the chain
|
|
259
267
|
await processChain(meta, stackName, chain, 0, rebasedBranches, dryRun, verbose);
|
|
@@ -317,7 +325,7 @@ async function processChain(
|
|
|
317
325
|
});
|
|
318
326
|
|
|
319
327
|
// Use temporary tag as stable base reference
|
|
320
|
-
const tagName =
|
|
328
|
+
const tagName = git.tempBaseTagName(branch);
|
|
321
329
|
let success: boolean;
|
|
322
330
|
|
|
323
331
|
if (await git.tagExists(tagName)) {
|
|
@@ -431,11 +439,11 @@ async function promptPush(branch: string): Promise<void> {
|
|
|
431
439
|
async function handleStaleTags(): Promise<void> {
|
|
432
440
|
try {
|
|
433
441
|
const { $ } = await import("bun");
|
|
434
|
-
const result = await $`git tag -l
|
|
442
|
+
const result = await $`git tag -l ${git.STACK_SYNC_TAG_GLOB}`.text();
|
|
435
443
|
const tags = result.trim();
|
|
436
444
|
if (!tags) return;
|
|
437
445
|
|
|
438
|
-
p.log.warn(
|
|
446
|
+
p.log.warn(`Found stale ${git.STACK_SYNC_TAG_GLOB} tags from a previous run`);
|
|
439
447
|
console.log();
|
|
440
448
|
|
|
441
449
|
const action = await p.select({
|
|
@@ -460,7 +468,7 @@ async function handleStaleTags(): Promise<void> {
|
|
|
460
468
|
|
|
461
469
|
if (action === "clean") {
|
|
462
470
|
p.log.info("Cleaning up stale tags...");
|
|
463
|
-
await git.deleteTagsMatching(
|
|
471
|
+
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
464
472
|
p.log.success("Tags cleaned");
|
|
465
473
|
}
|
|
466
474
|
|
package/src/commands/show.ts
CHANGED
|
@@ -29,7 +29,8 @@ export default async function show(_args: string[]): Promise<void> {
|
|
|
29
29
|
const branch = await git.currentBranch();
|
|
30
30
|
|
|
31
31
|
// Find which stack contains the current branch
|
|
32
|
-
|
|
32
|
+
const branchStackName = findStackForBranch(meta, branch);
|
|
33
|
+
let stackName = branchStackName;
|
|
33
34
|
|
|
34
35
|
if (!stackName) {
|
|
35
36
|
// Fall back to current_stack
|
|
@@ -51,7 +52,9 @@ export default async function show(_args: string[]): Promise<void> {
|
|
|
51
52
|
|
|
52
53
|
// Update current_stack and last_branch
|
|
53
54
|
meta.current_stack = stackName;
|
|
54
|
-
|
|
55
|
+
if (branchStackName === stackName) {
|
|
56
|
+
stack.last_branch = branch;
|
|
57
|
+
}
|
|
55
58
|
await writeMetadata(meta);
|
|
56
59
|
|
|
57
60
|
// Header
|
package/src/commands/sync.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "@clack/prompts";
|
|
|
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";
|
|
6
|
-
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
7
7
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
8
8
|
import { confirmAction } from "../lib/ui.ts";
|
|
9
9
|
|
|
@@ -36,6 +36,7 @@ with the base branch included.
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const stack = meta.stacks[stackName]!;
|
|
39
|
+
await ensureValidStack(meta, stackName);
|
|
39
40
|
const ordered = getOrderedBranches(stack);
|
|
40
41
|
|
|
41
42
|
if (ordered.length === 0) {
|
|
@@ -54,6 +55,28 @@ with the base branch included.
|
|
|
54
55
|
process.exit(1);
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
if (dryRun) {
|
|
59
|
+
p.log.warn("[DRY RUN] No changes will be made");
|
|
60
|
+
p.log.info("Planned operations:");
|
|
61
|
+
console.log(` ${pc.yellow("1.")} Fetch latest ${pc.cyan("origin/main")}`);
|
|
62
|
+
console.log(
|
|
63
|
+
` ${pc.yellow("2.")} Rebase ${pc.yellow(baseBranch)} onto ${pc.cyan("origin/main")}`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const children = ordered.slice(1);
|
|
67
|
+
if (children.length === 0) {
|
|
68
|
+
console.log(` ${pc.yellow("3.")} No child branches to restack`);
|
|
69
|
+
} else {
|
|
70
|
+
console.log(` ${pc.yellow("3.")} Restack child branches in order:`);
|
|
71
|
+
for (const branch of children) {
|
|
72
|
+
const parent = stack.branches[branch]?.parent || "(unknown)";
|
|
73
|
+
console.log(` ${pc.yellow("→")} ${branch} ${pc.blue(`(parent: ${parent})`)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
p.outro(pc.green("Sync dry run complete"));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
// Fetch main
|
|
58
81
|
const fetchSpinner = p.spinner();
|
|
59
82
|
fetchSpinner.start("Fetching latest main...");
|
|
@@ -76,7 +99,7 @@ with the base branch included.
|
|
|
76
99
|
const parentRef = parent === "main" ? "origin/main" : parent;
|
|
77
100
|
const mb = await git.mergeBase(branch, parentRef);
|
|
78
101
|
if (mb) {
|
|
79
|
-
const tagName =
|
|
102
|
+
const tagName = git.tempBaseTagName(branch);
|
|
80
103
|
await git.createTag(tagName, mb);
|
|
81
104
|
console.log(
|
|
82
105
|
` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
|
package/src/commands/undo.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// gh-stack undo — Restore from last snapshot
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
|
+
import * as git from "../lib/git.ts";
|
|
3
4
|
import pc from "picocolors";
|
|
4
5
|
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
5
6
|
import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
|
|
@@ -47,10 +48,21 @@ a destructive operation (restack, merge, sync, remove).
|
|
|
47
48
|
process.exit(0);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
let activeBranch: string | null = null;
|
|
52
|
+
try {
|
|
53
|
+
activeBranch = await git.currentBranch();
|
|
54
|
+
} catch {
|
|
55
|
+
// Detached HEAD is fine — we can restore all branches with branch -f.
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
// Restore each branch HEAD
|
|
51
59
|
for (const [branch, sha] of Object.entries(snapshot.branches)) {
|
|
52
60
|
try {
|
|
53
|
-
|
|
61
|
+
if (branch === activeBranch) {
|
|
62
|
+
await $`git reset --hard ${sha}`.quiet();
|
|
63
|
+
} else {
|
|
64
|
+
await $`git branch -f ${branch} ${sha}`.quiet();
|
|
65
|
+
}
|
|
54
66
|
p.log.success(`Restored ${pc.yellow(branch)} → ${sha.slice(0, 8)}`);
|
|
55
67
|
} catch {
|
|
56
68
|
p.log.error(`Failed to restore ${branch}`);
|
|
@@ -46,6 +46,7 @@ showing the tree structure, PR links, and review status.
|
|
|
46
46
|
branch: string;
|
|
47
47
|
prNumber: number | null;
|
|
48
48
|
prTitle: string;
|
|
49
|
+
prUrl: string | null;
|
|
49
50
|
reviewEmojiStr: string;
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -55,12 +56,14 @@ showing the tree structure, PR links, and review status.
|
|
|
55
56
|
const branchMeta = stack.branches[branchName]!;
|
|
56
57
|
const prNumber = branchMeta.pr ?? null;
|
|
57
58
|
let prTitle = branchMeta.description || branchName;
|
|
59
|
+
let prUrl: string | null = null;
|
|
58
60
|
let review = "PENDING";
|
|
59
61
|
|
|
60
62
|
if (prNumber) {
|
|
61
63
|
const info = await getPrInfo(prNumber);
|
|
62
64
|
if (info) {
|
|
63
65
|
prTitle = info.title || prTitle;
|
|
66
|
+
prUrl = info.url;
|
|
64
67
|
review = info.reviewDecision || "PENDING";
|
|
65
68
|
}
|
|
66
69
|
}
|
|
@@ -69,6 +72,7 @@ showing the tree structure, PR links, and review status.
|
|
|
69
72
|
branch: branchName,
|
|
70
73
|
prNumber,
|
|
71
74
|
prTitle,
|
|
75
|
+
prUrl,
|
|
72
76
|
reviewEmojiStr: reviewEmoji(review),
|
|
73
77
|
});
|
|
74
78
|
}
|
|
@@ -131,10 +135,11 @@ interface BranchPrInfo {
|
|
|
131
135
|
branch: string;
|
|
132
136
|
prNumber: number | null;
|
|
133
137
|
prTitle: string;
|
|
138
|
+
prUrl: string | null;
|
|
134
139
|
reviewEmojiStr: string;
|
|
135
140
|
}
|
|
136
141
|
|
|
137
|
-
function buildStackViz(branches: BranchPrInfo[], targetIndex: number): string {
|
|
142
|
+
export function buildStackViz(branches: BranchPrInfo[], targetIndex: number): string {
|
|
138
143
|
const lines: string[] = ["### 📚 Stacked on", ""];
|
|
139
144
|
|
|
140
145
|
if (branches.length === 1) {
|
|
@@ -154,7 +159,7 @@ function buildStackViz(branches: BranchPrInfo[], targetIndex: number): string {
|
|
|
154
159
|
// PR link
|
|
155
160
|
let prLink: string;
|
|
156
161
|
if (info.prNumber) {
|
|
157
|
-
prLink = `<a href="
|
|
162
|
+
prLink = info.prUrl ? `<a href="${info.prUrl}">#${info.prNumber}</a>` : `#${info.prNumber}`;
|
|
158
163
|
} else {
|
|
159
164
|
prLink = "(no PR yet)";
|
|
160
165
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,26 +7,28 @@ if (process.env.GH_STACK_NO_COLOR) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
import { ensureGitRepo } from "./lib/safety.ts";
|
|
10
|
+
import { parseCliArgs } from "./lib/cli.ts";
|
|
10
11
|
import { setAutoYes } from "./lib/ui.ts";
|
|
11
12
|
|
|
12
13
|
const { version: VERSION } = await import("../package.json");
|
|
13
14
|
|
|
14
15
|
const args = process.argv.slice(2);
|
|
15
|
-
const
|
|
16
|
+
const parsed = parseCliArgs(args, process.env);
|
|
17
|
+
const command = parsed.command;
|
|
16
18
|
|
|
17
19
|
// Handle global flags
|
|
18
|
-
if (
|
|
20
|
+
if (parsed.showVersion) {
|
|
19
21
|
console.log(`gh-stack v${VERSION}`);
|
|
20
22
|
process.exit(0);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
if (
|
|
25
|
+
if (parsed.showGlobalHelp) {
|
|
24
26
|
printHelp();
|
|
25
27
|
process.exit(0);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
// Global --yes flag: skip all interactive confirmations (for agents/CI)
|
|
29
|
-
if (
|
|
31
|
+
if (parsed.autoYes) {
|
|
30
32
|
setAutoYes(true);
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -34,7 +36,7 @@ if (args.includes("--yes") || args.includes("-y") || process.env.GH_STACK_YES ==
|
|
|
34
36
|
await ensureGitRepo();
|
|
35
37
|
|
|
36
38
|
// Route to subcommand — strip global flags from command args
|
|
37
|
-
const commandArgs =
|
|
39
|
+
const commandArgs = parsed.commandArgs;
|
|
38
40
|
|
|
39
41
|
switch (command) {
|
|
40
42
|
case "show":
|
package/src/lib/cli.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ParsedCliArgs {
|
|
2
|
+
command: string;
|
|
3
|
+
commandArgs: string[];
|
|
4
|
+
autoYes: boolean;
|
|
5
|
+
showVersion: boolean;
|
|
6
|
+
showGlobalHelp: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const LEADING_GLOBAL_FLAGS = new Set(["--yes", "-y", "--help", "--version", "-V"]);
|
|
10
|
+
|
|
11
|
+
export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedCliArgs {
|
|
12
|
+
let commandIndex = 0;
|
|
13
|
+
const leadingFlags = new Set<string>();
|
|
14
|
+
|
|
15
|
+
while (commandIndex < rawArgs.length && LEADING_GLOBAL_FLAGS.has(rawArgs[commandIndex]!)) {
|
|
16
|
+
leadingFlags.add(rawArgs[commandIndex]!);
|
|
17
|
+
commandIndex++;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const command = rawArgs[commandIndex] || "show";
|
|
21
|
+
const commandArgs = rawArgs
|
|
22
|
+
.slice(commandIndex + 1)
|
|
23
|
+
.filter((arg) => arg !== "--yes" && arg !== "-y");
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
command,
|
|
27
|
+
commandArgs,
|
|
28
|
+
autoYes: rawArgs.includes("--yes") || rawArgs.includes("-y") || env.GH_STACK_YES === "1",
|
|
29
|
+
showVersion: leadingFlags.has("--version") || leadingFlags.has("-V"),
|
|
30
|
+
showGlobalHelp: !rawArgs[commandIndex] && leadingFlags.has("--help"),
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/lib/git.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Git command helpers using Bun's shell API
|
|
2
2
|
import { $ } from "bun";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const STACK_SYNC_TAG_PREFIX = "stack-sync-base";
|
|
6
|
+
export const STACK_SYNC_TAG_GLOB = `${STACK_SYNC_TAG_PREFIX}-*`;
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Get the current branch name.
|
|
@@ -141,6 +145,16 @@ export async function remoteBranchExists(branch: string): Promise<boolean> {
|
|
|
141
145
|
return exitCode === 0;
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Check if a branch exists locally.
|
|
150
|
+
*/
|
|
151
|
+
export async function localBranchExists(branch: string): Promise<boolean> {
|
|
152
|
+
const { exitCode } = await $`git show-ref --verify --quiet refs/heads/${branch}`
|
|
153
|
+
.nothrow()
|
|
154
|
+
.quiet();
|
|
155
|
+
return exitCode === 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
144
158
|
/**
|
|
145
159
|
* Create a temporary tag at a specific commit.
|
|
146
160
|
*/
|
|
@@ -230,8 +244,16 @@ export async function commitCount(from: string, to: string): Promise<number> {
|
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
/**
|
|
233
|
-
*
|
|
247
|
+
* Build a deterministic temp tag name for a branch.
|
|
248
|
+
* Includes a readable slug plus a stable hash to avoid collisions.
|
|
234
249
|
*/
|
|
235
|
-
export function
|
|
236
|
-
|
|
250
|
+
export function tempBaseTagName(branch: string): string {
|
|
251
|
+
const slug =
|
|
252
|
+
branch
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
255
|
+
.replace(/^-+|-+$/g, "")
|
|
256
|
+
.slice(0, 24) || "branch";
|
|
257
|
+
const hash = createHash("sha256").update(branch).digest("hex").slice(0, 10);
|
|
258
|
+
return `${STACK_SYNC_TAG_PREFIX}-${slug}-${hash}`;
|
|
237
259
|
}
|
package/src/lib/safety.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pre-flight safety checks
|
|
2
2
|
import * as git from "./git.ts";
|
|
3
3
|
import { metadataExists, readMetadata, findStackForBranch } from "./metadata.ts";
|
|
4
|
-
import type { StackMetadata } from "../types.ts";
|
|
4
|
+
import type { StackMetadata, Stack } from "../types.ts";
|
|
5
5
|
import * as p from "@clack/prompts";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
|
|
@@ -96,3 +96,111 @@ export function ensureNotMain(branch: string): void {
|
|
|
96
96
|
process.exit(1);
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validate stack metadata before destructive operations.
|
|
102
|
+
*/
|
|
103
|
+
export async function validateStack(meta: StackMetadata, stackName: string): Promise<string[]> {
|
|
104
|
+
const stack = meta.stacks[stackName];
|
|
105
|
+
if (!stack) return [`Stack "${stackName}" not found`];
|
|
106
|
+
|
|
107
|
+
const branchNames = Object.keys(stack.branches);
|
|
108
|
+
const errors: string[] = [];
|
|
109
|
+
|
|
110
|
+
for (const branchName of branchNames) {
|
|
111
|
+
const branch = stack.branches[branchName]!;
|
|
112
|
+
|
|
113
|
+
if (!(await git.localBranchExists(branchName))) {
|
|
114
|
+
errors.push(`Branch "${branchName}" does not exist locally`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (branch.parent === branchName) {
|
|
118
|
+
errors.push(`Branch "${branchName}" cannot be its own parent`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (branch.parent !== "main" && branch.parent !== "master" && !stack.branches[branch.parent]) {
|
|
122
|
+
errors.push(`Branch "${branchName}" has unknown parent "${branch.parent}"`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
errors.push(...validateStackGraph(stack));
|
|
127
|
+
return errors;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function ensureValidStack(meta: StackMetadata, stackName: string): Promise<void> {
|
|
131
|
+
const errors = await validateStack(meta, stackName);
|
|
132
|
+
if (errors.length === 0) return;
|
|
133
|
+
|
|
134
|
+
p.cancel(`Invalid stack metadata for ${pc.yellow(stackName)}`);
|
|
135
|
+
console.log();
|
|
136
|
+
for (const error of errors) {
|
|
137
|
+
console.log(` ${pc.red("•")} ${error}`);
|
|
138
|
+
}
|
|
139
|
+
console.log();
|
|
140
|
+
console.log(
|
|
141
|
+
pc.dim("Fix .git/gh-stack-metadata.json or repair the local branches before retrying."),
|
|
142
|
+
);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateStackGraph(stack: Stack): string[] {
|
|
147
|
+
const branchNames = Object.keys(stack.branches);
|
|
148
|
+
if (branchNames.length === 0) return [];
|
|
149
|
+
|
|
150
|
+
const errors: string[] = [];
|
|
151
|
+
const roots = branchNames.filter((branchName) => {
|
|
152
|
+
const parent = stack.branches[branchName]!.parent;
|
|
153
|
+
return parent === "main" || parent === "master";
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (roots.length === 0) {
|
|
157
|
+
errors.push("Stack has no root branch with parent main/master");
|
|
158
|
+
}
|
|
159
|
+
if (roots.length > 1) {
|
|
160
|
+
errors.push(`Stack must have exactly one root branch; found ${roots.length}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const childrenByParent = new Map<string, string[]>();
|
|
164
|
+
for (const [branchName, branch] of Object.entries(stack.branches)) {
|
|
165
|
+
const siblings = childrenByParent.get(branch.parent) ?? [];
|
|
166
|
+
siblings.push(branchName);
|
|
167
|
+
childrenByParent.set(branch.parent, siblings);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const visited = new Set<string>();
|
|
171
|
+
const visiting = new Set<string>();
|
|
172
|
+
|
|
173
|
+
function walk(branchName: string) {
|
|
174
|
+
if (visiting.has(branchName)) {
|
|
175
|
+
errors.push(`Cycle detected involving "${branchName}"`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (visited.has(branchName)) return;
|
|
179
|
+
|
|
180
|
+
visiting.add(branchName);
|
|
181
|
+
for (const child of childrenByParent.get(branchName) ?? []) {
|
|
182
|
+
walk(child);
|
|
183
|
+
}
|
|
184
|
+
visiting.delete(branchName);
|
|
185
|
+
visited.add(branchName);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const root of roots) {
|
|
189
|
+
walk(root);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If the graph is malformed, walk any remaining nodes so we can report
|
|
193
|
+
// cycles/unreachable branches instead of stopping at the root error.
|
|
194
|
+
for (const branchName of branchNames) {
|
|
195
|
+
if (!visited.has(branchName)) {
|
|
196
|
+
walk(branchName);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (visited.size !== branchNames.length) {
|
|
201
|
+
const unreachable = branchNames.filter((branchName) => !visited.has(branchName));
|
|
202
|
+
errors.push(`Unreachable or cyclic branches detected: ${unreachable.join(", ")}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return errors;
|
|
206
|
+
}
|