gh-stack 0.1.1 → 0.2.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/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] || "log";
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.
@@ -36,9 +40,12 @@ export async function gitDir(): Promise<string> {
36
40
 
37
41
  /**
38
42
  * Check if working tree is clean (no uncommitted changes).
43
+ * Refreshes the index first to avoid false positives from stale stat info.
39
44
  */
40
45
  export async function isCleanWorkingTree(): Promise<boolean> {
41
46
  try {
47
+ // Refresh index to avoid false positives from stat changes (timestamps, etc.)
48
+ await $`git update-index -q --refresh`.quiet().nothrow();
42
49
  await $`git diff-index --quiet HEAD --`.quiet();
43
50
  return true;
44
51
  } catch {
@@ -141,6 +148,16 @@ export async function remoteBranchExists(branch: string): Promise<boolean> {
141
148
  return exitCode === 0;
142
149
  }
143
150
 
151
+ /**
152
+ * Check if a branch exists locally.
153
+ */
154
+ export async function localBranchExists(branch: string): Promise<boolean> {
155
+ const { exitCode } = await $`git show-ref --verify --quiet refs/heads/${branch}`
156
+ .nothrow()
157
+ .quiet();
158
+ return exitCode === 0;
159
+ }
160
+
144
161
  /**
145
162
  * Create a temporary tag at a specific commit.
146
163
  */
@@ -230,8 +247,24 @@ export async function commitCount(from: string, to: string): Promise<number> {
230
247
  }
231
248
 
232
249
  /**
233
- * Sanitize a branch name for use in tag names.
250
+ * Get all local branch names.
251
+ */
252
+ export async function allLocalBranches(): Promise<string[]> {
253
+ const result = await $`git branch --format=${"%(refname:short)"}`.text();
254
+ return result.trim().split("\n").filter(Boolean);
255
+ }
256
+
257
+ /**
258
+ * Build a deterministic temp tag name for a branch.
259
+ * Includes a readable slug plus a stable hash to avoid collisions.
234
260
  */
235
- export function sanitizeBranchForTag(branch: string): string {
236
- return branch.replace(/[^a-zA-Z0-9-]/g, "_");
261
+ export function tempBaseTagName(branch: string): string {
262
+ const slug =
263
+ branch
264
+ .toLowerCase()
265
+ .replace(/[^a-z0-9]+/g, "-")
266
+ .replace(/^-+|-+$/g, "")
267
+ .slice(0, 24) || "branch";
268
+ const hash = createHash("sha256").update(branch).digest("hex").slice(0, 10);
269
+ return `${STACK_SYNC_TAG_PREFIX}-${slug}-${hash}`;
237
270
  }
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
 
@@ -58,7 +58,7 @@ export async function ensureBranchInStack(meta: StackMetadata): Promise<string>
58
58
 
59
59
  if (!stackName) {
60
60
  p.cancel(
61
- `Branch ${pc.blue(branch)} is not in any stack.\n\n Add it with:\n ${pc.green("gh-stack add")}\n\n Or create a new stack:\n ${pc.green("gh-stack init")}`,
61
+ `Branch ${pc.blue(branch)} is not in any stack.\n\n Add it with:\n ${pc.green("gh-stack create")}\n\n Or create a new stack:\n ${pc.green("gh-stack init")}`,
62
62
  );
63
63
  process.exit(1);
64
64
  }
@@ -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
+ }
@@ -1,139 +0,0 @@
1
- // gh-stack add — Add a branch to the current stack
2
- import * as p from "@clack/prompts";
3
- import pc from "picocolors";
4
- import * as git from "../lib/git.ts";
5
- import { addBranchToStack, getTopOfStack, getOrderedBranches } from "../lib/metadata.ts";
6
- import { ensureMetadata, ensureCurrentStack } from "../lib/safety.ts";
7
- import { getPrNumber } from "../lib/github.ts";
8
- import { isAutoYes, selectParent } from "../lib/ui.ts";
9
- import type { Branch } from "../types.ts";
10
-
11
- export default async function add(args: string[]): Promise<void> {
12
- // Parse flags
13
- let parentFlag: string | undefined;
14
- let descFlag: string | undefined;
15
- let createFlag: string | undefined;
16
- let branchArg: string | undefined;
17
-
18
- for (let i = 0; i < args.length; i++) {
19
- switch (args[i]) {
20
- case "--parent":
21
- parentFlag = args[++i];
22
- break;
23
- case "--description":
24
- descFlag = args[++i];
25
- break;
26
- case "--create":
27
- createFlag = args[++i];
28
- break;
29
- case "--help":
30
- console.log(`
31
- gh-stack add — Add a branch to the current stack
32
-
33
- USAGE
34
- gh-stack add [<branch>] [options]
35
-
36
- OPTIONS
37
- --parent <branch> Parent branch (default: top of stack)
38
- --create <branch> Create new branch off top of stack and add it
39
- --description <desc> Description for the branch
40
- `);
41
- return;
42
- default:
43
- if (!args[i]!.startsWith("--")) {
44
- branchArg = args[i];
45
- }
46
- }
47
- }
48
-
49
- const meta = await ensureMetadata();
50
- const stackName = ensureCurrentStack(meta);
51
- const stack = meta.stacks[stackName]!;
52
-
53
- p.intro(pc.cyan(`Add branch to ${pc.yellow(stackName)}`));
54
-
55
- let branchToAdd: string;
56
-
57
- // --create: make a new branch off the top of the stack
58
- if (createFlag) {
59
- const topBranch = getTopOfStack(stack);
60
- if (!topBranch) {
61
- p.cancel("Stack has no branches yet. Add the base branch first.");
62
- process.exit(1);
63
- }
64
-
65
- const currentBranch = await git.currentBranch();
66
- if (currentBranch !== topBranch) {
67
- p.log.info(`Switching to ${pc.yellow(topBranch)}...`);
68
- await git.checkout(topBranch);
69
- }
70
-
71
- p.log.info(`Creating branch off top of stack: ${pc.yellow(topBranch)}`);
72
- await git.createBranch(createFlag);
73
- p.log.success(`Created branch: ${pc.yellow(createFlag)}`);
74
-
75
- branchToAdd = createFlag;
76
- parentFlag = topBranch;
77
- } else {
78
- branchToAdd = branchArg || (await git.currentBranch());
79
- }
80
-
81
- // Check if branch is already in the stack
82
- if (stack.branches[branchToAdd]) {
83
- p.cancel(`Branch ${pc.yellow(branchToAdd)} is already in stack ${pc.blue(stackName)}`);
84
- process.exit(1);
85
- }
86
-
87
- console.log(` Adding: ${pc.yellow(branchToAdd)}`);
88
- console.log();
89
-
90
- // Get parent
91
- let parent = parentFlag;
92
- if (!parent) {
93
- // Default to top of stack
94
- const ordered = getOrderedBranches(stack);
95
-
96
- if (ordered.length === 0) {
97
- parent = "main";
98
- } else if (isAutoYes()) {
99
- parent = getTopOfStack(stack) ?? "main";
100
- } else {
101
- parent = (await selectParent(stack, branchToAdd)) ?? undefined;
102
- if (!parent) {
103
- p.cancel("Cancelled");
104
- process.exit(0);
105
- }
106
- }
107
- }
108
-
109
- p.log.info(`Parent: ${pc.yellow(parent)}`);
110
-
111
- // Auto-detect PR number
112
- const s = p.spinner();
113
- s.start("Looking for PR...");
114
- const prNumber = await getPrNumber(branchToAdd);
115
- s.stop(prNumber ? `Found PR #${prNumber}` : "No PR found");
116
-
117
- // Get description
118
- let description = descFlag;
119
- if (description === undefined && !createFlag) {
120
- const result = await p.text({
121
- message: "Branch description (optional)",
122
- placeholder: "e.g., API layer",
123
- });
124
- if (!p.isCancel(result)) {
125
- description = (result as string) || undefined;
126
- }
127
- }
128
-
129
- // Add branch
130
- const branchData: Branch = {
131
- parent,
132
- ...(prNumber && { pr: prNumber }),
133
- ...(description && { description }),
134
- };
135
-
136
- await addBranchToStack(meta, stackName, branchToAdd, branchData);
137
-
138
- p.outro(pc.green(`Added ${pc.yellow(branchToAdd)} to stack ${pc.blue(stackName)}`));
139
- }