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 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 PR numbers, titles,
66
- review status, and CI state. This is the default command.
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 [--list] [--restore <name>]
123
- Manage archived stacks.
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.0",
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",
@@ -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) {
@@ -26,7 +26,8 @@ ALIASES
26
26
  const currentBranch = await git.currentBranch();
27
27
 
28
28
  // Find which stack contains the current branch
29
- let stackName = findStackForBranch(meta, currentBranch);
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
- stack.last_branch = currentBranch;
43
+ if (branchStackName === stackName) {
44
+ stack.last_branch = currentBranch;
45
+ }
43
46
  await writeMetadata(meta);
44
47
 
45
48
  // Header
@@ -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) {
@@ -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
 
@@ -10,7 +10,12 @@ import {
10
10
  loadRestackState,
11
11
  clearRestackState,
12
12
  } from "../lib/metadata.ts";
13
- import { ensureMetadata, ensureCleanWorkingTree, ensureNotMain } from "../lib/safety.ts";
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("stack-sync-*");
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 = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
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
- // Take snapshot before destructive operation
239
- await takeSnapshot(meta, stackName, "restack");
244
+ if (!dryRun) {
245
+ // Take snapshot before destructive operation
246
+ await takeSnapshot(meta, stackName, "restack");
240
247
 
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
- );
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 = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
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 "stack-sync-*"`.text();
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("Found stale stack-sync-* tags from a previous run");
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("stack-sync-*");
471
+ await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
464
472
  p.log.success("Tags cleaned");
465
473
  }
466
474
 
@@ -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
- let stackName = findStackForBranch(meta, branch);
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
- stack.last_branch = branch;
55
+ if (branchStackName === stackName) {
56
+ stack.last_branch = branch;
57
+ }
55
58
  await writeMetadata(meta);
56
59
 
57
60
  // Header
@@ -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 = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
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)})`,
@@ -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
- await $`git branch -f ${branch} ${sha}`.quiet();
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="https://github.com/beehiiv/swarm/pull/${info.prNumber}">#${info.prNumber}</a>`;
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 command = args[0] || "show";
16
+ const parsed = parseCliArgs(args, process.env);
17
+ const command = parsed.command;
16
18
 
17
19
  // Handle global flags
18
- if (args.includes("--version") || args.includes("-V")) {
20
+ if (parsed.showVersion) {
19
21
  console.log(`gh-stack v${VERSION}`);
20
22
  process.exit(0);
21
23
  }
22
24
 
23
- if (args.includes("--help") && !args[0]) {
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 (args.includes("--yes") || args.includes("-y") || process.env.GH_STACK_YES === "1") {
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 = args.slice(1).filter((a) => a !== "--yes" && a !== "-y");
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
- * Sanitize a branch name for use in tag names.
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 sanitizeBranchForTag(branch: string): string {
236
- return branch.replace(/[^a-zA-Z0-9-]/g, "_");
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
+ }