gh-stack 0.1.1 → 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
@@ -130,7 +130,7 @@ undo
130
130
 
131
131
  ## Not Implemented Yet
132
132
 
133
- These are still intended, but not shipped in `v0.1.1`:
133
+ These are still intended, but not shipped in `v0.1.2`:
134
134
 
135
135
  ```text
136
136
  split
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.1.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",
@@ -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 { 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
8
  import { isAutoYes, selectParent } from "../lib/ui.ts";
9
9
  import type { Branch } from "../types.ts";
@@ -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)}`));
@@ -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();
@@ -247,7 +253,7 @@ async function handleFreshRestack(
247
253
 
248
254
  const mb = await git.mergeBase(branch, parent);
249
255
  if (mb) {
250
- const tagName = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
256
+ const tagName = git.tempBaseTagName(branch);
251
257
  await git.createTag(tagName, mb);
252
258
  console.log(
253
259
  ` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
@@ -319,7 +325,7 @@ async function processChain(
319
325
  });
320
326
 
321
327
  // Use temporary tag as stable base reference
322
- const tagName = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
328
+ const tagName = git.tempBaseTagName(branch);
323
329
  let success: boolean;
324
330
 
325
331
  if (await git.tagExists(tagName)) {
@@ -433,11 +439,11 @@ async function promptPush(branch: string): Promise<void> {
433
439
  async function handleStaleTags(): Promise<void> {
434
440
  try {
435
441
  const { $ } = await import("bun");
436
- const result = await $`git tag -l "stack-sync-*"`.text();
442
+ const result = await $`git tag -l ${git.STACK_SYNC_TAG_GLOB}`.text();
437
443
  const tags = result.trim();
438
444
  if (!tags) return;
439
445
 
440
- 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`);
441
447
  console.log();
442
448
 
443
449
  const action = await p.select({
@@ -462,7 +468,7 @@ async function handleStaleTags(): Promise<void> {
462
468
 
463
469
  if (action === "clean") {
464
470
  p.log.info("Cleaning up stale tags...");
465
- await git.deleteTagsMatching("stack-sync-*");
471
+ await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
466
472
  p.log.success("Tags cleaned");
467
473
  }
468
474
 
@@ -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) {
@@ -98,7 +99,7 @@ with the base branch included.
98
99
  const parentRef = parent === "main" ? "origin/main" : parent;
99
100
  const mb = await git.mergeBase(branch, parentRef);
100
101
  if (mb) {
101
- const tagName = `stack-sync-base-${git.sanitizeBranchForTag(branch)}`;
102
+ const tagName = git.tempBaseTagName(branch);
102
103
  await git.createTag(tagName, mb);
103
104
  console.log(
104
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}`);
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
+ }