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.
@@ -1,4 +1,4 @@
1
- // gh-stack init — Create a new stack with the current branch
1
+ // gh-stack init — Create a new stack, auto-detecting branch chains
2
2
  import * as p from "@clack/prompts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
@@ -8,14 +8,80 @@ import {
8
8
  createStack,
9
9
  addBranchToStack,
10
10
  metadataExists,
11
+ findStackForBranch,
11
12
  } from "../lib/metadata.ts";
12
13
  import { getPrNumber } from "../lib/github.ts";
13
- import { selectParent } from "../lib/ui.ts";
14
+ import { isAutoYes } from "../lib/ui.ts";
14
15
  import type { StackMetadata, Branch } from "../types.ts";
15
16
 
16
- export default async function init(args: string[]): Promise<void> {
17
- const branch = await git.currentBranch();
17
+ const HELP = `
18
+ gh-stack init — Create a new stack from the current branch
19
+
20
+ USAGE
21
+ gh-stack init [options]
22
+
23
+ Creates a new stack and adds the current branch. If the current branch
24
+ is part of a chain of branches (e.g. main → feat-1 → feat-2), init
25
+ auto-detects the chain and offers to add all branches to the stack.
26
+
27
+ Best used from the TOP of your branch chain so all branches are found.
28
+
29
+ OPTIONS
30
+ --name <name> Stack name (default: current branch name)
31
+ --description <desc> Stack description
32
+ --parent <branch> Parent/trunk branch (default: main)
33
+
34
+ EXAMPLES
35
+ gh-stack init # Auto-detect chain, use defaults
36
+ gh-stack init --name my-feature # Custom stack name
37
+ gh-stack init --parent develop # Different trunk branch
38
+ `;
39
+
40
+ /**
41
+ * Detect a chain of branches from trunk to the current branch.
42
+ *
43
+ * Walks down from the current branch by finding local branches whose
44
+ * tips are strict ancestors of the current branch. Sorts by commit
45
+ * distance (closest = direct parent) to reconstruct the chain.
46
+ *
47
+ * Returns branches in bottom-up order: [closest-to-trunk, ..., current]
48
+ */
49
+ async function detectBranchChain(currentBranch: string, trunk: string): Promise<string[]> {
50
+ const allBranches = await git.allLocalBranches();
51
+
52
+ // Branches to exclude: current, trunk, and always main/master
53
+ const excluded = new Set([currentBranch, trunk, "main", "master"]);
54
+
55
+ // Find branches that are strict ancestors of current (and not trunk)
56
+ const ancestors: { name: string; distance: number }[] = [];
57
+
58
+ for (const branch of allBranches) {
59
+ if (excluded.has(branch)) continue;
60
+
61
+ // Is this branch's tip an ancestor of current?
62
+ if (await git.isAncestor(branch, currentBranch)) {
63
+ // Skip branches already merged into trunk (stale/old branches)
64
+ if (await git.isAncestor(branch, trunk)) continue;
65
+
66
+ // How many commits between this branch and current?
67
+ const distance = await git.commitCount(branch, currentBranch);
68
+ if (distance > 0) {
69
+ ancestors.push({ name: branch, distance });
70
+ }
71
+ }
72
+ }
18
73
 
74
+ // Sort by distance descending (furthest from current = closest to trunk)
75
+ ancestors.sort((a, b) => b.distance - a.distance);
76
+
77
+ // Build chain: ancestors (sorted trunk→current) + current branch
78
+ const chain = ancestors.map((a) => a.name);
79
+ chain.push(currentBranch);
80
+
81
+ return chain;
82
+ }
83
+
84
+ export default async function init(args: string[]): Promise<void> {
19
85
  // Parse flags
20
86
  let nameFlag: string | undefined;
21
87
  let descFlag: string | undefined;
@@ -33,25 +99,15 @@ export default async function init(args: string[]): Promise<void> {
33
99
  parentFlag = args[++i];
34
100
  break;
35
101
  case "--help":
36
- console.log(`
37
- gh-stack init — Create a new stack
38
-
39
- USAGE
40
- gh-stack init [--name <name>] [--description <desc>] [--parent <branch>]
41
-
42
- OPTIONS
43
- --name <name> Stack name (skip prompt)
44
- --description <desc> Stack description
45
- --parent <branch> Parent branch for current branch (default: main)
46
- `);
102
+ console.log(HELP);
47
103
  return;
48
104
  }
49
105
  }
50
106
 
51
- p.intro(pc.cyan("Create New Stack"));
107
+ const branch = await git.currentBranch();
108
+ const trunk = parentFlag || "main";
52
109
 
53
- console.log(` Initializing stack with: ${pc.yellow(branch)}`);
54
- console.log();
110
+ p.intro(pc.cyan("Create New Stack"));
55
111
 
56
112
  // Get or create metadata
57
113
  let meta: StackMetadata;
@@ -59,98 +115,91 @@ OPTIONS
59
115
  meta = (await readMetadata())!;
60
116
  } else {
61
117
  meta = await initMetadata();
62
- p.log.success("Initialized empty stack metadata");
63
118
  }
64
119
 
65
- // Get stack name
66
- let stackName = nameFlag;
67
- if (!stackName) {
68
- const result = await p.text({
69
- message: "Stack name",
70
- placeholder: "e.g., podcast-mvp",
71
- validate: (val = "") => {
72
- if (!val.trim()) return "Stack name is required";
73
- if (meta.stacks[val.trim()]) return `Stack "${val}" already exists`;
74
- },
75
- });
76
- if (p.isCancel(result)) {
77
- p.cancel("Cancelled");
78
- process.exit(0);
120
+ // Check if current branch is already in a stack
121
+ const existingStack = findStackForBranch(meta, branch);
122
+ if (existingStack) {
123
+ p.cancel(`Branch ${pc.yellow(branch)} is already in stack ${pc.yellow(existingStack)}`);
124
+ process.exit(1);
125
+ }
126
+
127
+ // ── Detect branch chain ──
128
+ const s = p.spinner();
129
+ s.start("Detecting branch chain...");
130
+ const chain = await detectBranchChain(branch, trunk);
131
+ s.stop(chain.length > 1 ? `Found ${chain.length} branches in chain` : `Single branch: ${branch}`);
132
+
133
+ // Show detected chain
134
+ if (chain.length > 1) {
135
+ console.log();
136
+ console.log(` ${pc.dim(trunk)}`);
137
+ for (let i = 0; i < chain.length; i++) {
138
+ const isLast = i === chain.length - 1;
139
+ const tree = isLast ? " ┗━" : " ┣━";
140
+ const label = chain[i] === branch ? pc.yellow(chain[i]!) + pc.dim(" (current)") : chain[i];
141
+ console.log(`${tree} ${label}`);
142
+ if (!isLast) console.log(" ┃");
143
+ }
144
+ console.log();
145
+
146
+ // Confirm chain
147
+ if (!isAutoYes()) {
148
+ const confirmed = await p.confirm({
149
+ message: "Create stack with all branches?",
150
+ });
151
+ if (p.isCancel(confirmed) || !confirmed) {
152
+ p.cancel("Cancelled");
153
+ process.exit(0);
154
+ }
79
155
  }
80
- stackName = result as string;
81
156
  }
82
157
 
83
- // Check if stack already exists
158
+ // ── Determine stack name ──
159
+ const stackName = nameFlag || branch;
160
+
84
161
  if (meta.stacks[stackName]) {
85
162
  p.cancel(`Stack "${stackName}" already exists`);
86
163
  process.exit(1);
87
164
  }
88
165
 
89
- // Get stack description
90
- let description = descFlag;
91
- if (description === undefined) {
92
- const result = await p.text({
93
- message: "Stack description (optional)",
94
- placeholder: "e.g., Podcast MVP features",
95
- });
96
- if (p.isCancel(result)) {
97
- p.cancel("Cancelled");
98
- process.exit(0);
99
- }
100
- description = (result as string) || "";
101
- }
166
+ // ── Create stack ──
167
+ meta = await createStack(meta, stackName, descFlag || "");
102
168
 
103
- // Create the stack
104
- meta = await createStack(meta, stackName, description);
105
- p.log.success(`Created stack: ${pc.blue(stackName)}`);
106
-
107
- // Get parent branch
108
- let parent: string | undefined = parentFlag;
109
- if (!parent) {
110
- parent = (await selectParent(null, branch)) ?? undefined;
111
- if (!parent) {
112
- p.cancel("Cancelled");
113
- process.exit(0);
114
- }
115
- }
169
+ // ── Add branches with PR detection ──
170
+ const prSpinner = p.spinner();
171
+ prSpinner.start("Detecting PRs...");
116
172
 
117
- p.log.info(`Parent: ${pc.yellow(parent)}`);
173
+ let prCount = 0;
174
+ for (let i = 0; i < chain.length; i++) {
175
+ const branchName = chain[i]!;
176
+ const parent = i === 0 ? trunk : chain[i - 1]!;
118
177
 
119
- // Auto-detect PR number
120
- const s = p.spinner();
121
- s.start("Looking for PR...");
122
- const prNumber = await getPrNumber(branch);
123
- s.stop(prNumber ? `Found PR #${prNumber}` : "No PR found");
124
-
125
- // Get branch description
126
- let branchDesc: string | undefined;
127
- if (!nameFlag) {
128
- // Only prompt for branch description in interactive mode
129
- const result = await p.text({
130
- message: "Branch description (optional)",
131
- placeholder: "e.g., Backend models",
132
- });
133
- if (!p.isCancel(result)) {
134
- branchDesc = (result as string) || undefined;
135
- }
136
- }
178
+ // Auto-detect PR
179
+ const prNumber = await getPrNumber(branchName);
180
+ if (prNumber) prCount++;
137
181
 
138
- // Add branch to stack
139
- const branchData: Branch = {
140
- parent,
141
- ...(prNumber && { pr: prNumber }),
142
- ...(branchDesc && { description: branchDesc }),
143
- };
182
+ const branchData: Branch = {
183
+ parent,
184
+ ...(prNumber && { pr: prNumber }),
185
+ };
144
186
 
145
- meta = await addBranchToStack(meta, stackName, branch, branchData);
187
+ meta = await addBranchToStack(meta, stackName, branchName, branchData);
188
+ }
146
189
 
147
- p.log.success(`Added ${pc.yellow(branch)} to stack`);
190
+ prSpinner.stop(prCount > 0 ? `Found ${prCount} PR(s)` : "No PRs found");
148
191
 
149
- p.outro(pc.green("Stack initialized!"));
192
+ p.outro(pc.green(`Stack ${pc.yellow(stackName)} created with ${chain.length} branch(es)!`));
150
193
 
194
+ // Next steps
195
+ console.log();
196
+ if (chain.length === 1) {
197
+ console.log(" Next steps:");
198
+ console.log(` ${pc.blue("gh-stack create <branch>")} — Add a branch to the stack`);
199
+ } else {
200
+ console.log(" Next steps:");
201
+ console.log(` ${pc.blue("gh-stack submit")} — Push and create PRs`);
202
+ }
203
+ console.log(` ${pc.blue("gh-stack log")} — View stack`);
151
204
  console.log();
152
- console.log(" Next steps:");
153
- console.log(` ${pc.blue("gh-stack add")} — Add more branches`);
154
- console.log(` ${pc.blue("gh-stack")} — View stack`);
155
- console.log(` ${pc.blue("gh-stack restack")} — Sync stack`);
156
205
  }
@@ -14,7 +14,7 @@ USAGE
14
14
  gh-stack ls
15
15
 
16
16
  Lightweight branch listing for quick reference and scripting.
17
- Use the number with 'gh-stack switch <number>' to jump to a branch.
17
+ Use the number with 'gh-stack checkout <number>' to jump to a branch.
18
18
 
19
19
  ALIASES
20
20
  gh-stack ls
@@ -1,4 +1,4 @@
1
- // gh-stack show — Display current stack tree (default command)
1
+ // gh-stack log — Display current stack tree (default command)
2
2
  import * as p from "@clack/prompts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
@@ -10,7 +10,7 @@ import {
10
10
  } from "../lib/metadata.ts";
11
11
  import { renderStackTree } from "../lib/ui.ts";
12
12
 
13
- export default async function show(_args: string[]): Promise<void> {
13
+ export default async function log(_args: string[]): Promise<void> {
14
14
  // Check if metadata exists
15
15
  if (!(await metadataExists())) {
16
16
  p.log.warn("No stack metadata found");
@@ -71,6 +71,6 @@ export default async function show(_args: string[]): Promise<void> {
71
71
  console.log();
72
72
 
73
73
  // Tip
74
- console.log(pc.dim(`Tip: Switch stacks with 'gh-stack switch --stack'`));
74
+ console.log(pc.dim(`Tip: Switch stacks with 'gh-stack checkout --stack'`));
75
75
  console.log();
76
76
  }
@@ -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) {
@@ -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