gh-stack 0.3.1 → 0.7.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,140 @@
1
+ // Branch-chain detection and stack reconciliation.
2
+ //
3
+ // Shared by `init` (interactive stack creation) and `submit` (self-healing:
4
+ // auto-create/register a stack from a bare set of local branches so the
5
+ // end state is always stack registered + branches pushed + PRs created).
6
+ import * as git from "./git.ts";
7
+ import { findStackForBranch } from "./metadata.ts";
8
+ import type { StackMetadata, Branch } from "../types.ts";
9
+
10
+ /**
11
+ * Detect a chain of branches from trunk to the current branch.
12
+ *
13
+ * Walks down from the current branch by finding local branches whose tips
14
+ * are strict ancestors of the current branch. Sorts by commit distance
15
+ * (closest = direct parent) to reconstruct the chain. Branches already
16
+ * merged into trunk are skipped (stale/old branches).
17
+ *
18
+ * Returns branches in bottom-up order: [closest-to-trunk, ..., current].
19
+ * For a branch based directly off trunk, returns just [current].
20
+ */
21
+ export async function detectBranchChain(currentBranch: string, trunk: string): Promise<string[]> {
22
+ const allBranches = await git.allLocalBranches();
23
+
24
+ // Branches to exclude: current, trunk, and always main/master
25
+ const excluded = new Set([currentBranch, trunk, "main", "master"]);
26
+
27
+ // Find branches that are strict ancestors of current (and not trunk)
28
+ const ancestors: { name: string; distance: number }[] = [];
29
+
30
+ for (const branch of allBranches) {
31
+ if (excluded.has(branch)) continue;
32
+
33
+ // Is this branch's tip an ancestor of current?
34
+ if (await git.isAncestor(branch, currentBranch)) {
35
+ // Skip branches already merged into trunk (stale/old branches)
36
+ if (await git.isAncestor(branch, trunk)) continue;
37
+
38
+ // How many commits between this branch and current?
39
+ const distance = await git.commitCount(branch, currentBranch);
40
+ if (distance > 0) {
41
+ ancestors.push({ name: branch, distance });
42
+ }
43
+ }
44
+ }
45
+
46
+ // Sort by distance descending (furthest from current = closest to trunk)
47
+ ancestors.sort((a, b) => b.distance - a.distance);
48
+
49
+ // Build chain: ancestors (sorted trunk→current) + current branch
50
+ const chain = ancestors.map((a) => a.name);
51
+ chain.push(currentBranch);
52
+
53
+ return chain;
54
+ }
55
+
56
+ export interface StackResolution {
57
+ stackName: string;
58
+ chain: string[]; // trunk→current order
59
+ created: boolean; // true if a brand-new stack was created
60
+ addedBranches: string[]; // branches newly registered into the stack
61
+ }
62
+
63
+ /**
64
+ * Ensure a stack exists for `branch`, reconstructing it from local branch
65
+ * ancestry if needed. Mutates `meta` in place but does NOT persist — the
66
+ * caller decides when to write (so `--dry-run` can preview without side
67
+ * effects).
68
+ *
69
+ * Behavior:
70
+ * - Detects the chain trunk→current.
71
+ * - If any branch in the chain already belongs to a stack, reconciles
72
+ * into that existing stack (adds only the missing branches).
73
+ * - Otherwise creates a new stack named after the current branch
74
+ * (auto-suffixed if that name is taken).
75
+ * - Registers missing branches with correct parent links (chain[0]'s
76
+ * parent is trunk; each subsequent branch's parent is the prior one).
77
+ *
78
+ * Throws if the chain spans more than one existing stack (ambiguous).
79
+ */
80
+ export async function resolveOrCreateStack(
81
+ meta: StackMetadata,
82
+ branch: string,
83
+ trunk: string,
84
+ ): Promise<StackResolution> {
85
+ const chain = await detectBranchChain(branch, trunk);
86
+
87
+ // Which existing stacks (if any) already own branches in this chain?
88
+ const owningStacks = new Set<string>();
89
+ for (const b of chain) {
90
+ const owner = findStackForBranch(meta, b);
91
+ if (owner) owningStacks.add(owner);
92
+ }
93
+
94
+ if (owningStacks.size > 1) {
95
+ throw new Error(
96
+ `Branches in this chain span multiple stacks (${[...owningStacks].join(
97
+ ", ",
98
+ )}). Resolve manually with gh-stack init / delete.`,
99
+ );
100
+ }
101
+
102
+ let stackName: string;
103
+ let created = false;
104
+
105
+ if (owningStacks.size === 1) {
106
+ // Reconcile into the existing stack
107
+ stackName = [...owningStacks][0]!;
108
+ } else {
109
+ // Create a new stack named after the current branch (auto-suffix on clash)
110
+ stackName = branch;
111
+ let n = 2;
112
+ while (meta.stacks[stackName]) {
113
+ stackName = `${branch}-${n++}`;
114
+ }
115
+ meta.stacks[stackName] = {
116
+ description: "",
117
+ last_branch: null,
118
+ branches: {},
119
+ };
120
+ meta.current_stack = stackName;
121
+ created = true;
122
+ }
123
+
124
+ const stack = meta.stacks[stackName]!;
125
+ const addedBranches: string[] = [];
126
+
127
+ // Register missing branches with correct parent links.
128
+ for (let i = 0; i < chain.length; i++) {
129
+ const name = chain[i]!;
130
+ if (stack.branches[name]) continue; // already tracked — leave as-is
131
+
132
+ const parent = i === 0 ? trunk : chain[i - 1]!;
133
+ const branchData: Branch = { parent };
134
+ stack.branches[name] = branchData;
135
+ stack.last_branch = name;
136
+ addedBranches.push(name);
137
+ }
138
+
139
+ return { stackName, chain, created, addedBranches };
140
+ }
package/src/lib/cli.ts CHANGED
@@ -2,11 +2,12 @@ export interface ParsedCliArgs {
2
2
  command: string;
3
3
  commandArgs: string[];
4
4
  autoYes: boolean;
5
+ plain: boolean;
5
6
  showVersion: boolean;
6
7
  showGlobalHelp: boolean;
7
8
  }
8
9
 
9
- const LEADING_GLOBAL_FLAGS = new Set(["--yes", "-y", "--help", "--version", "-V"]);
10
+ const LEADING_GLOBAL_FLAGS = new Set(["--yes", "-y", "--plain", "--help", "--version", "-V"]);
10
11
 
11
12
  export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedCliArgs {
12
13
  let commandIndex = 0;
@@ -20,12 +21,18 @@ export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedC
20
21
  const command = rawArgs[commandIndex] || "log";
21
22
  const commandArgs = rawArgs
22
23
  .slice(commandIndex + 1)
23
- .filter((arg) => arg !== "--yes" && arg !== "-y");
24
+ .filter((arg) => arg !== "--yes" && arg !== "-y" && arg !== "--plain");
25
+
26
+ const autoYes = rawArgs.includes("--yes") || rawArgs.includes("-y") || env.GH_STACK_YES === "1";
27
+ // Plain mode auto-enables under GH_STACK_YES=1 (agents skip the chrome anyway)
28
+ // or explicit --plain. Both compose with each other.
29
+ const plain = autoYes || rawArgs.includes("--plain") || env.GH_STACK_PLAIN === "1";
24
30
 
25
31
  return {
26
32
  command,
27
33
  commandArgs,
28
- autoYes: rawArgs.includes("--yes") || rawArgs.includes("-y") || env.GH_STACK_YES === "1",
34
+ autoYes,
35
+ plain,
29
36
  showVersion: leadingFlags.has("--version") || leadingFlags.has("-V"),
30
37
  showGlobalHelp: !rawArgs[commandIndex] && leadingFlags.has("--help"),
31
38
  };
package/src/lib/git.ts CHANGED
@@ -254,6 +254,16 @@ export async function allLocalBranches(): Promise<string[]> {
254
254
  return result.trim().split("\n").filter(Boolean);
255
255
  }
256
256
 
257
+ /**
258
+ * Determine the trunk branch name. Prefers "main", falls back to "master"
259
+ * if only that exists locally. Defaults to "main" when neither is present.
260
+ */
261
+ export async function trunkBranch(): Promise<string> {
262
+ if (await localBranchExists("main")) return "main";
263
+ if (await localBranchExists("master")) return "master";
264
+ return "main";
265
+ }
266
+
257
267
  /**
258
268
  * Build a deterministic temp tag name for a branch.
259
269
  * Includes a readable slug plus a stable hash to avoid collisions.
@@ -0,0 +1,90 @@
1
+ // Plain-mode-aware wrapper over @clack/prompts.
2
+ //
3
+ // In plain mode (--plain flag or GH_STACK_YES=1), we strip the decorative
4
+ // chrome that makes gh-stack output painful for agents/log consumers:
5
+ // - no intro/outro boxes
6
+ // - no spinners (start/stop lines collapse to plain prints)
7
+ // - no ANSI colors (picocolors auto-disables via NO_COLOR; we also strip
8
+ // any residual escapes defensively)
9
+ //
10
+ // Interactive prompts (text, confirm, select) are re-exported unchanged —
11
+ // plain mode is about output, not interactivity. Agents already pair --plain
12
+ // with --yes/GH_STACK_YES so the interactive paths aren't reached.
13
+ import * as clack from "@clack/prompts";
14
+
15
+ let _plain = false;
16
+
17
+ export function setPlain(enabled: boolean): void {
18
+ _plain = enabled;
19
+ }
20
+
21
+ export function isPlain(): boolean {
22
+ return _plain;
23
+ }
24
+
25
+ const ANSI_RE = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
26
+ function strip(s: string | undefined): string {
27
+ return (s ?? "").replace(ANSI_RE, "");
28
+ }
29
+
30
+ // ── Interactive prompts (unchanged) ──
31
+ export const isCancel = clack.isCancel;
32
+ export const text = clack.text;
33
+ export const confirm = clack.confirm;
34
+ export const select = clack.select;
35
+
36
+ // ── Banner / error ──
37
+ export function intro(message?: string): void {
38
+ if (_plain) {
39
+ if (message) console.log(strip(message));
40
+ } else {
41
+ clack.intro(message);
42
+ }
43
+ }
44
+
45
+ export function outro(message?: string): void {
46
+ if (_plain) {
47
+ if (message) console.log(strip(message));
48
+ } else {
49
+ clack.outro(message);
50
+ }
51
+ }
52
+
53
+ export function cancel(message?: string): void {
54
+ if (_plain) {
55
+ if (message) console.error(strip(message));
56
+ } else {
57
+ clack.cancel(message);
58
+ }
59
+ }
60
+
61
+ // ── log.* ──
62
+ export const log = {
63
+ info: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.info(msg)),
64
+ success: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.success(msg)),
65
+ warn: (msg: string) => (_plain ? console.warn(strip(msg)) : clack.log.warn(msg)),
66
+ error: (msg: string) => (_plain ? console.error(strip(msg)) : clack.log.error(msg)),
67
+ step: (msg: string) => (_plain ? console.log(strip(msg)) : clack.log.step(msg)),
68
+ };
69
+
70
+ // ── Spinner ──
71
+ export interface Spinner {
72
+ start: (msg?: string) => void;
73
+ stop: (msg?: string, code?: number) => void;
74
+ message: (msg?: string) => void;
75
+ }
76
+
77
+ export function spinner(): Spinner {
78
+ if (!_plain) return clack.spinner() as Spinner;
79
+ return {
80
+ start: (msg) => {
81
+ if (msg) console.log(strip(msg));
82
+ },
83
+ stop: (msg) => {
84
+ if (msg) console.log(strip(msg));
85
+ },
86
+ message: (msg) => {
87
+ if (msg) console.log(strip(msg));
88
+ },
89
+ };
90
+ }
package/src/lib/safety.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import * as git from "./git.ts";
3
3
  import { metadataExists, readMetadata, findStackForBranch } from "./metadata.ts";
4
4
  import type { StackMetadata, Stack } from "../types.ts";
5
- import * as p from "@clack/prompts";
5
+ import * as p from "./output.ts";
6
6
  import pc from "picocolors";
7
7
 
8
8
  /**
@@ -1,8 +1,81 @@
1
- // Snapshot system for undo support
1
+ // Snapshot system for undo support and pre-rewrite base recovery
2
2
  import type { StackMetadata, Snapshot } from "../types.ts";
3
3
  import * as git from "./git.ts";
4
4
  import { writeMetadata, getOrderedBranches } from "./metadata.ts";
5
5
 
6
+ /**
7
+ * Find the pre-rewrite SHA for a parent branch by walking snapshots newest-first.
8
+ *
9
+ * Returns the SHA from the most recent snapshot whose recorded tip for
10
+ * `parentBranch` is NOT an ancestor of the parent's current tip — meaning
11
+ * that snapshot was taken before the parent's history got rewritten
12
+ * (typically by `sync` rebasing it onto a new main).
13
+ *
14
+ * That pre-rewrite SHA is the correct rebase base when restacking a child
15
+ * onto this parent — `git rebase --onto <new-tip> <pre-rewrite-tip> <child>`
16
+ * replays only the child's unique commits, never the parent's old history.
17
+ *
18
+ * **Child validation (issue #5)**: When `childBranch` is provided, any candidate
19
+ * SHA must also still be an ancestor of the child. If the child has already
20
+ * been rebased past this point (e.g., a previous restack already moved it),
21
+ * the recorded SHA is stale for this child and would cause `rebase --onto`
22
+ * to replay a wildly wrong range. We skip those candidates and keep walking.
23
+ *
24
+ * Returns null if no snapshots exist, no recorded tip qualifies, or every
25
+ * snapshot's recorded tip is still an ancestor of the parent's current tip
26
+ * (i.e., the parent has only had commits appended, never been rebased —
27
+ * `merge-base` is still correct).
28
+ */
29
+ export async function findPreRewriteSha(
30
+ meta: StackMetadata,
31
+ parentBranch: string,
32
+ childBranch?: string,
33
+ ): Promise<string | null> {
34
+ if (!meta.snapshots || meta.snapshots.length === 0) return null;
35
+
36
+ let currentParentTip: string;
37
+ try {
38
+ currentParentTip = await git.revParse(parentBranch);
39
+ } catch {
40
+ return null;
41
+ }
42
+
43
+ let currentChildTip: string | null = null;
44
+ if (childBranch) {
45
+ try {
46
+ currentChildTip = await git.revParse(childBranch);
47
+ } catch {
48
+ // Child doesn't exist locally — fall back to parent-only validation
49
+ currentChildTip = null;
50
+ }
51
+ }
52
+
53
+ // Walk newest → oldest
54
+ for (let i = meta.snapshots.length - 1; i >= 0; i--) {
55
+ const recorded = meta.snapshots[i]!.branches[parentBranch];
56
+ if (!recorded) continue;
57
+ if (recorded === currentParentTip) continue;
58
+
59
+ // The recorded SHA must no longer be an ancestor of the parent's current
60
+ // tip — that's how we detect the parent's history was rewritten.
61
+ const stillAncestorOfParent = await git.isAncestor(recorded, currentParentTip);
62
+ if (stillAncestorOfParent) continue;
63
+
64
+ // Issue #5 guard: if we know the child, the recorded SHA must still be
65
+ // an ancestor of the child. Otherwise the child has already been rebased
66
+ // past this point (or never branched off it) and using it as a base
67
+ // would replay unrelated history.
68
+ if (currentChildTip) {
69
+ const stillAncestorOfChild = await git.isAncestor(recorded, currentChildTip);
70
+ if (!stillAncestorOfChild) continue;
71
+ }
72
+
73
+ return recorded;
74
+ }
75
+
76
+ return null;
77
+ }
78
+
6
79
  const MAX_SNAPSHOTS = 10;
7
80
 
8
81
  /**
package/src/lib/ui.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // TUI helpers: tree rendering, formatting, prompts
2
- import * as p from "@clack/prompts";
2
+ import * as p from "./output.ts";
3
3
  import pc from "picocolors";
4
4
  import type { Stack, StackMetadata } from "../types.ts";
5
5
  import { getOrderedBranches } from "./metadata.ts";