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.
- package/README.md +32 -6
- package/package.json +1 -1
- package/src/commands/archive.ts +1 -1
- package/src/commands/bottom.ts +1 -1
- package/src/commands/checkout.ts +1 -1
- package/src/commands/create.ts +1 -1
- package/src/commands/delete.ts +1 -1
- package/src/commands/down.ts +1 -1
- package/src/commands/init.ts +2 -45
- package/src/commands/log.ts +1 -1
- package/src/commands/merge.ts +103 -8
- package/src/commands/restack.ts +74 -141
- package/src/commands/submit.ts +83 -7
- package/src/commands/sync.ts +14 -24
- package/src/commands/top.ts +1 -1
- package/src/commands/undo.ts +1 -1
- package/src/commands/up.ts +1 -1
- package/src/commands/update-prs.ts +1 -1
- package/src/index.ts +19 -3
- package/src/lib/chain.ts +140 -0
- package/src/lib/cli.ts +10 -3
- package/src/lib/git.ts +10 -0
- package/src/lib/output.ts +90 -0
- package/src/lib/safety.ts +1 -1
- package/src/lib/snapshot.ts +74 -1
- package/src/lib/ui.ts +1 -1
package/src/lib/chain.ts
ADDED
|
@@ -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
|
|
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 "
|
|
5
|
+
import * as p from "./output.ts";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
|
|
8
8
|
/**
|
package/src/lib/snapshot.ts
CHANGED
|
@@ -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 "
|
|
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";
|