gh-stack 0.1.2 → 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.
@@ -0,0 +1,79 @@
1
+ // gh-stack top — Jump to the tip of the stack
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import * as git from "../lib/git.ts";
5
+ import {
6
+ findStackForBranch,
7
+ getOrderedBranches,
8
+ getChildren,
9
+ writeMetadata,
10
+ } from "../lib/metadata.ts";
11
+ import { ensureMetadata } from "../lib/safety.ts";
12
+
13
+ export default async function top(args: string[]): Promise<void> {
14
+ if (args.includes("--help")) {
15
+ console.log(`
16
+ gh-stack top — Jump to the tip of the stack
17
+
18
+ USAGE
19
+ gh-stack top
20
+
21
+ Switches to the topmost (leaf) branch of the current stack.
22
+ Prompts if there are multiple leaf branches.
23
+ `);
24
+ return;
25
+ }
26
+
27
+ const meta = await ensureMetadata();
28
+ const currentBranch = await git.currentBranch();
29
+ const stackName = findStackForBranch(meta, currentBranch);
30
+
31
+ if (!stackName) {
32
+ p.cancel(`Branch ${pc.yellow(currentBranch)} is not in any stack`);
33
+ process.exit(1);
34
+ }
35
+
36
+ const stack = meta.stacks[stackName]!;
37
+ const ordered = getOrderedBranches(stack);
38
+
39
+ // Find leaf branches (no children)
40
+ const leaves = ordered.filter((b) => getChildren(stack, b).length === 0);
41
+
42
+ if (leaves.length === 0) {
43
+ p.log.warn("No branches in stack");
44
+ return;
45
+ }
46
+
47
+ let target: string;
48
+ if (leaves.length === 1) {
49
+ target = leaves[0]!;
50
+ } else {
51
+ const result = await p.select({
52
+ message: "Multiple tips — which branch?",
53
+ options: leaves.map((l) => ({
54
+ value: l,
55
+ label: l,
56
+ hint: stack.branches[l]?.pr ? `#${stack.branches[l]!.pr}` : undefined,
57
+ })),
58
+ });
59
+ if (p.isCancel(result)) {
60
+ p.cancel("Cancelled");
61
+ process.exit(0);
62
+ }
63
+ target = result as string;
64
+ }
65
+
66
+ if (target === currentBranch) {
67
+ p.log.info(`Already at the top: ${pc.yellow(currentBranch)}`);
68
+ return;
69
+ }
70
+
71
+ await git.checkout(target);
72
+
73
+ // Update tracking
74
+ stack.last_branch = target;
75
+ meta.current_stack = stackName;
76
+ await writeMetadata(meta);
77
+
78
+ p.log.success(`On ${pc.yellow(target)}`);
79
+ }
@@ -0,0 +1,82 @@
1
+ // gh-stack up — Move to child branch (upstack)
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import * as git from "../lib/git.ts";
5
+ import { findStackForBranch, getChildren, writeMetadata } from "../lib/metadata.ts";
6
+ import { ensureMetadata } from "../lib/safety.ts";
7
+
8
+ export default async function up(args: string[]): Promise<void> {
9
+ if (args.includes("--help")) {
10
+ console.log(`
11
+ gh-stack up — Move to child branch (upstack)
12
+
13
+ USAGE
14
+ gh-stack up [steps]
15
+
16
+ OPTIONS
17
+ [steps] Number of levels to move up (default: 1)
18
+ `);
19
+ return;
20
+ }
21
+
22
+ const steps = parseInt(args[0] || "1", 10);
23
+ if (isNaN(steps) || steps < 1) {
24
+ p.cancel("Invalid step count");
25
+ process.exit(1);
26
+ }
27
+
28
+ const meta = await ensureMetadata();
29
+ let currentBranch = await git.currentBranch();
30
+ const stackName = findStackForBranch(meta, currentBranch);
31
+
32
+ if (!stackName) {
33
+ p.cancel(`Branch ${pc.yellow(currentBranch)} is not in any stack`);
34
+ process.exit(1);
35
+ }
36
+
37
+ const stack = meta.stacks[stackName]!;
38
+
39
+ for (let i = 0; i < steps; i++) {
40
+ const children = getChildren(stack, currentBranch);
41
+
42
+ if (children.length === 0) {
43
+ if (i === 0) {
44
+ p.log.warn(`${pc.yellow(currentBranch)} is already at the top of the stack`);
45
+ } else {
46
+ p.log.info(`Reached top of stack at ${pc.yellow(currentBranch)} after ${i} step(s)`);
47
+ }
48
+ return;
49
+ }
50
+
51
+ let target: string;
52
+ if (children.length === 1) {
53
+ target = children[0]!;
54
+ } else {
55
+ // Multiple children — prompt
56
+ const result = await p.select({
57
+ message: "Multiple children — which branch?",
58
+ options: children.map((c) => ({
59
+ value: c,
60
+ label: c,
61
+ hint: stack.branches[c]?.pr ? `#${stack.branches[c]!.pr}` : undefined,
62
+ })),
63
+ });
64
+ if (p.isCancel(result)) {
65
+ p.cancel("Cancelled");
66
+ process.exit(0);
67
+ }
68
+ target = result as string;
69
+ }
70
+
71
+ currentBranch = target;
72
+ }
73
+
74
+ await git.checkout(currentBranch);
75
+
76
+ // Update tracking
77
+ stack.last_branch = currentBranch;
78
+ meta.current_stack = stackName;
79
+ await writeMetadata(meta);
80
+
81
+ p.log.success(`On ${pc.yellow(currentBranch)}`);
82
+ }
package/src/index.ts CHANGED
@@ -39,27 +39,27 @@ await ensureGitRepo();
39
39
  const commandArgs = parsed.commandArgs;
40
40
 
41
41
  switch (command) {
42
- case "show":
43
- await (await import("./commands/show.ts")).default(commandArgs);
42
+ case "log":
43
+ await (await import("./commands/log.ts")).default(commandArgs);
44
44
  break;
45
45
 
46
46
  case "init":
47
47
  await (await import("./commands/init.ts")).default(commandArgs);
48
48
  break;
49
49
 
50
- case "add":
51
- await (await import("./commands/add.ts")).default(commandArgs);
50
+ case "create":
51
+ await (await import("./commands/create.ts")).default(commandArgs);
52
52
  break;
53
53
 
54
- case "remove":
55
- await (await import("./commands/remove.ts")).default(commandArgs);
54
+ case "delete":
55
+ await (await import("./commands/delete.ts")).default(commandArgs);
56
56
  break;
57
57
 
58
- case "switch":
59
- await (await import("./commands/switch.ts")).default(commandArgs);
58
+ case "checkout":
59
+ case "co":
60
+ await (await import("./commands/checkout.ts")).default(commandArgs);
60
61
  break;
61
62
 
62
- case "list":
63
63
  case "ls":
64
64
  await (await import("./commands/list.ts")).default(commandArgs);
65
65
  break;
@@ -73,8 +73,8 @@ switch (command) {
73
73
  await (await import("./commands/sync.ts")).default(commandArgs);
74
74
  break;
75
75
 
76
- case "update-prs":
77
- await (await import("./commands/update-prs.ts")).default(commandArgs);
76
+ case "submit":
77
+ await (await import("./commands/submit.ts")).default(commandArgs);
78
78
  break;
79
79
 
80
80
  case "status":
@@ -93,6 +93,22 @@ switch (command) {
93
93
  await (await import("./commands/archive.ts")).default(commandArgs);
94
94
  break;
95
95
 
96
+ case "up":
97
+ await (await import("./commands/up.ts")).default(commandArgs);
98
+ break;
99
+
100
+ case "down":
101
+ await (await import("./commands/down.ts")).default(commandArgs);
102
+ break;
103
+
104
+ case "top":
105
+ await (await import("./commands/top.ts")).default(commandArgs);
106
+ break;
107
+
108
+ case "bottom":
109
+ await (await import("./commands/bottom.ts")).default(commandArgs);
110
+ break;
111
+
96
112
  case "--help":
97
113
  case "help":
98
114
  printHelp();
@@ -111,18 +127,34 @@ gh-stack v${VERSION} — Stacked PR manager for squash-merge workflows
111
127
  ${bold("USAGE")}
112
128
  gh-stack <command> [options]
113
129
 
114
- ${bold("COMMANDS")}
115
- ${green("show")} Display current stack tree ${dim("(default)")}
116
- ${green("list")} List branches with numbers ${dim("(alias: ls)")}
117
- ${green("init")} Create a new stack
118
- ${green("add")} Add a branch to the current stack
119
- ${green("remove")} Remove a branch from the stack
120
- ${green("switch")} Switch branch or stack
130
+ ${bold("TERMS")}
131
+ trunk Base branch of the repository (usually main)
132
+ stack A chain of dependent branches
133
+ upstack Branches that depend on the current branch (children)
134
+ downstack Branches the current branch depends on (ancestors)
135
+
136
+ ${bold("CORE WORKFLOW")}
137
+ ${green("init")} Create a new stack from the current branch
138
+ ${green("create")} Create a new branch and add to the stack
139
+ ${green("submit")} Push branches and create/update PRs
140
+ ${green("log")} Display current stack tree ${dim("(default)")}
141
+
142
+ ${bold("STACK NAVIGATION")}
143
+ ${green("checkout")} Switch to a branch or stack ${dim("(alias: co)")}
144
+ ${green("up")} Move to child branch (upstack)
145
+ ${green("down")} Move to parent branch (downstack)
146
+ ${green("top")} Jump to the tip of the stack
147
+ ${green("bottom")} Jump to the base of the stack
148
+ ${green("ls")} List branches with numbers
149
+
150
+ ${bold("STACK MANAGEMENT")}
121
151
  ${green("restack")} Rebase children onto updated parents
122
152
  ${green("sync")} Sync base with main + restack all
123
- ${green("update-prs")} Update PR descriptions with stack viz
124
- ${green("status")} PR dashboard (CI, reviews)
125
153
  ${green("merge")} Local squash-merge top-down
154
+ ${green("delete")} Remove a branch from the stack
155
+
156
+ ${bold("INFO & MAINTENANCE")}
157
+ ${green("status")} PR dashboard (CI, reviews)
126
158
  ${green("undo")} Restore from last snapshot
127
159
  ${green("archive")} Manage archived stacks
128
160
 
package/src/lib/cli.ts CHANGED
@@ -17,7 +17,7 @@ export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedC
17
17
  commandIndex++;
18
18
  }
19
19
 
20
- const command = rawArgs[commandIndex] || "show";
20
+ const command = rawArgs[commandIndex] || "log";
21
21
  const commandArgs = rawArgs
22
22
  .slice(commandIndex + 1)
23
23
  .filter((arg) => arg !== "--yes" && arg !== "-y");
package/src/lib/git.ts CHANGED
@@ -40,9 +40,12 @@ export async function gitDir(): Promise<string> {
40
40
 
41
41
  /**
42
42
  * Check if working tree is clean (no uncommitted changes).
43
+ * Refreshes the index first to avoid false positives from stale stat info.
43
44
  */
44
45
  export async function isCleanWorkingTree(): Promise<boolean> {
45
46
  try {
47
+ // Refresh index to avoid false positives from stat changes (timestamps, etc.)
48
+ await $`git update-index -q --refresh`.quiet().nothrow();
46
49
  await $`git diff-index --quiet HEAD --`.quiet();
47
50
  return true;
48
51
  } catch {
@@ -243,6 +246,14 @@ export async function commitCount(from: string, to: string): Promise<number> {
243
246
  }
244
247
  }
245
248
 
249
+ /**
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
+
246
257
  /**
247
258
  * Build a deterministic temp tag name for a branch.
248
259
  * Includes a readable slug plus a stable hash to avoid collisions.
package/src/lib/safety.ts CHANGED
@@ -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
  }
@@ -1,140 +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, ensureValidStack } 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
- await ensureValidStack(meta, stackName);
52
- const stack = meta.stacks[stackName]!;
53
-
54
- p.intro(pc.cyan(`Add branch to ${pc.yellow(stackName)}`));
55
-
56
- let branchToAdd: string;
57
-
58
- // --create: make a new branch off the top of the stack
59
- if (createFlag) {
60
- const topBranch = getTopOfStack(stack);
61
- if (!topBranch) {
62
- p.cancel("Stack has no branches yet. Add the base branch first.");
63
- process.exit(1);
64
- }
65
-
66
- const currentBranch = await git.currentBranch();
67
- if (currentBranch !== topBranch) {
68
- p.log.info(`Switching to ${pc.yellow(topBranch)}...`);
69
- await git.checkout(topBranch);
70
- }
71
-
72
- p.log.info(`Creating branch off top of stack: ${pc.yellow(topBranch)}`);
73
- await git.createBranch(createFlag);
74
- p.log.success(`Created branch: ${pc.yellow(createFlag)}`);
75
-
76
- branchToAdd = createFlag;
77
- parentFlag = topBranch;
78
- } else {
79
- branchToAdd = branchArg || (await git.currentBranch());
80
- }
81
-
82
- // Check if branch is already in the stack
83
- if (stack.branches[branchToAdd]) {
84
- p.cancel(`Branch ${pc.yellow(branchToAdd)} is already in stack ${pc.blue(stackName)}`);
85
- process.exit(1);
86
- }
87
-
88
- console.log(` Adding: ${pc.yellow(branchToAdd)}`);
89
- console.log();
90
-
91
- // Get parent
92
- let parent = parentFlag;
93
- if (!parent) {
94
- // Default to top of stack
95
- const ordered = getOrderedBranches(stack);
96
-
97
- if (ordered.length === 0) {
98
- parent = "main";
99
- } else if (isAutoYes()) {
100
- parent = getTopOfStack(stack) ?? "main";
101
- } else {
102
- parent = (await selectParent(stack, branchToAdd)) ?? undefined;
103
- if (!parent) {
104
- p.cancel("Cancelled");
105
- process.exit(0);
106
- }
107
- }
108
- }
109
-
110
- p.log.info(`Parent: ${pc.yellow(parent)}`);
111
-
112
- // Auto-detect PR number
113
- const s = p.spinner();
114
- s.start("Looking for PR...");
115
- const prNumber = await getPrNumber(branchToAdd);
116
- s.stop(prNumber ? `Found PR #${prNumber}` : "No PR found");
117
-
118
- // Get description
119
- let description = descFlag;
120
- if (description === undefined && !createFlag) {
121
- const result = await p.text({
122
- message: "Branch description (optional)",
123
- placeholder: "e.g., API layer",
124
- });
125
- if (!p.isCancel(result)) {
126
- description = (result as string) || undefined;
127
- }
128
- }
129
-
130
- // Add branch
131
- const branchData: Branch = {
132
- parent,
133
- ...(prNumber && { pr: prNumber }),
134
- ...(description && { description }),
135
- };
136
-
137
- await addBranchToStack(meta, stackName, branchToAdd, branchData);
138
-
139
- p.outro(pc.green(`Added ${pc.yellow(branchToAdd)} to stack ${pc.blue(stackName)}`));
140
- }