gh-stack 0.1.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/LICENSE +21 -0
- package/README.md +229 -0
- package/package.json +66 -0
- package/src/commands/add.ts +137 -0
- package/src/commands/archive.ts +60 -0
- package/src/commands/init.ts +156 -0
- package/src/commands/list.ts +64 -0
- package/src/commands/merge.ts +175 -0
- package/src/commands/remove.ts +78 -0
- package/src/commands/restack.ts +476 -0
- package/src/commands/show.ts +73 -0
- package/src/commands/status.ts +372 -0
- package/src/commands/switch.ts +114 -0
- package/src/commands/sync.ts +189 -0
- package/src/commands/undo.ts +64 -0
- package/src/commands/update-prs.ts +177 -0
- package/src/index.ts +146 -0
- package/src/lib/git.ts +237 -0
- package/src/lib/github.ts +184 -0
- package/src/lib/metadata.ts +307 -0
- package/src/lib/safety.ts +98 -0
- package/src/lib/snapshot.ts +74 -0
- package/src/lib/ui.ts +234 -0
- package/src/types.ts +68 -0
package/src/lib/ui.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// TUI helpers: tree rendering, formatting, prompts
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import type { Stack, StackMetadata } from "../types.ts";
|
|
5
|
+
import { getOrderedBranches } from "./metadata.ts";
|
|
6
|
+
|
|
7
|
+
// ── Auto-yes mode (for agents/CI) ──
|
|
8
|
+
|
|
9
|
+
let _autoYes = false;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Enable/disable auto-yes mode (skip all confirmations).
|
|
13
|
+
* Set via --yes flag or GH_STACK_YES=1 env var.
|
|
14
|
+
*/
|
|
15
|
+
export function setAutoYes(enabled: boolean): void {
|
|
16
|
+
_autoYes = enabled;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isAutoYes(): boolean {
|
|
20
|
+
return _autoYes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Render a stack as a tree with branch numbers, PR info, and current marker.
|
|
25
|
+
*/
|
|
26
|
+
export function renderStackTree(
|
|
27
|
+
stack: Stack,
|
|
28
|
+
currentBranch: string,
|
|
29
|
+
options?: { showNumbers?: boolean },
|
|
30
|
+
): string {
|
|
31
|
+
const ordered = getOrderedBranches(stack);
|
|
32
|
+
const showNumbers = options?.showNumbers ?? true;
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
|
|
35
|
+
lines.push(`${pc.green("◯")} main`);
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
38
|
+
const branchName = ordered[i]!;
|
|
39
|
+
const branch = stack.branches[branchName]!;
|
|
40
|
+
const isLast = i === ordered.length - 1;
|
|
41
|
+
const isCurrent = branchName === currentBranch;
|
|
42
|
+
|
|
43
|
+
// Tree characters
|
|
44
|
+
const tree = isLast ? "┗━◯" : "┣━◯";
|
|
45
|
+
|
|
46
|
+
// Branch number label
|
|
47
|
+
const numLabel = showNumbers ? `[${pc.blue(String(i + 1))}] ` : "";
|
|
48
|
+
|
|
49
|
+
// Current marker
|
|
50
|
+
const marker = isCurrent ? ` ${pc.yellow("(current)")}` : "";
|
|
51
|
+
|
|
52
|
+
// PR info
|
|
53
|
+
const prNum = branch.pr ? `#${branch.pr}` : "(no PR)";
|
|
54
|
+
const desc = branch.description || branchName;
|
|
55
|
+
|
|
56
|
+
// Separator
|
|
57
|
+
lines.push("┃");
|
|
58
|
+
|
|
59
|
+
if (isCurrent) {
|
|
60
|
+
lines.push(`${tree} ${numLabel}${pc.yellow(branchName)}${marker}`);
|
|
61
|
+
lines.push(`┃ ${pc.blue(prNum)}: ${desc}`);
|
|
62
|
+
} else {
|
|
63
|
+
lines.push(`${tree} ${numLabel}${branchName}`);
|
|
64
|
+
lines.push(`┃ ${pc.dim(`${prNum}: ${desc}`)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Display a header banner.
|
|
73
|
+
*/
|
|
74
|
+
export function header(title: string): void {
|
|
75
|
+
p.intro(pc.cyan(title));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Display a footer banner.
|
|
80
|
+
*/
|
|
81
|
+
export function footer(title: string): void {
|
|
82
|
+
p.outro(pc.green(title));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Interactive branch selector for a stack.
|
|
87
|
+
* Returns the selected branch name, or null if cancelled.
|
|
88
|
+
*/
|
|
89
|
+
export async function selectBranch(
|
|
90
|
+
stack: Stack,
|
|
91
|
+
message: string,
|
|
92
|
+
currentBranch?: string,
|
|
93
|
+
): Promise<string | null> {
|
|
94
|
+
const ordered = getOrderedBranches(stack);
|
|
95
|
+
|
|
96
|
+
if (ordered.length === 0) {
|
|
97
|
+
p.log.warn("No branches in this stack");
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// In auto-yes mode, can't select — return null (caller should use explicit args)
|
|
102
|
+
if (_autoYes) {
|
|
103
|
+
p.log.warn("Interactive selection skipped (--yes mode). Use explicit arguments.");
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const options = ordered.map((name) => {
|
|
108
|
+
const branch = stack.branches[name]!;
|
|
109
|
+
const prNum = branch.pr ? `#${branch.pr}` : "";
|
|
110
|
+
const desc = branch.description || "";
|
|
111
|
+
const current = name === currentBranch ? pc.yellow(" (current)") : "";
|
|
112
|
+
const label = `${name}${current}`;
|
|
113
|
+
const hint = [prNum, desc].filter(Boolean).join(" — ");
|
|
114
|
+
|
|
115
|
+
return { value: name, label, hint };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = await p.select({
|
|
119
|
+
message,
|
|
120
|
+
options,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (p.isCancel(result)) return null;
|
|
124
|
+
return result as string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Interactive stack selector.
|
|
129
|
+
* Returns the selected stack name, or null if cancelled.
|
|
130
|
+
*/
|
|
131
|
+
export async function selectStack(meta: StackMetadata, message: string): Promise<string | null> {
|
|
132
|
+
const stackNames = Object.keys(meta.stacks);
|
|
133
|
+
|
|
134
|
+
if (stackNames.length === 0) {
|
|
135
|
+
p.log.warn("No stacks found");
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// In auto-yes mode, can't select — return null
|
|
140
|
+
if (_autoYes) {
|
|
141
|
+
p.log.warn("Interactive selection skipped (--yes mode). Use explicit arguments.");
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const options = stackNames.map((name) => {
|
|
146
|
+
const stack = meta.stacks[name]!;
|
|
147
|
+
const branchCount = Object.keys(stack.branches).length;
|
|
148
|
+
const current = name === meta.current_stack ? pc.yellow(" (current)") : "";
|
|
149
|
+
const label = `${name}${current}`;
|
|
150
|
+
const hint = `${branchCount} branch${branchCount !== 1 ? "es" : ""}${stack.description ? ` — ${stack.description}` : ""}`;
|
|
151
|
+
|
|
152
|
+
return { value: name, label, hint };
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const result = await p.select({
|
|
156
|
+
message,
|
|
157
|
+
options,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (p.isCancel(result)) return null;
|
|
161
|
+
return result as string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Interactive parent branch selector.
|
|
166
|
+
* Offers: branches in stack, "main", or custom input.
|
|
167
|
+
*/
|
|
168
|
+
export async function selectParent(
|
|
169
|
+
stack: Stack | null,
|
|
170
|
+
currentBranch: string,
|
|
171
|
+
): Promise<string | null> {
|
|
172
|
+
// In auto-yes mode, default to "main"
|
|
173
|
+
if (_autoYes) {
|
|
174
|
+
p.log.info("Auto-selecting parent: main (--yes mode)");
|
|
175
|
+
return "main";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const options: { value: string; label: string; hint?: string }[] = [
|
|
179
|
+
{ value: "main", label: "main", hint: "Base branch" },
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
// Add stack branches (if we have a stack)
|
|
183
|
+
if (stack) {
|
|
184
|
+
const ordered = getOrderedBranches(stack);
|
|
185
|
+
for (const name of ordered) {
|
|
186
|
+
if (name !== currentBranch) {
|
|
187
|
+
const branch = stack.branches[name]!;
|
|
188
|
+
const prNum = branch.pr ? `#${branch.pr}` : "";
|
|
189
|
+
options.push({
|
|
190
|
+
value: name,
|
|
191
|
+
label: name,
|
|
192
|
+
hint: [prNum, branch.description].filter(Boolean).join(" — "),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const result = await p.select({
|
|
199
|
+
message: `Select parent branch for ${pc.yellow(currentBranch)}`,
|
|
200
|
+
options,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (p.isCancel(result)) return null;
|
|
204
|
+
return result as string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Confirm a destructive action.
|
|
209
|
+
* In auto-yes mode, returns true immediately without prompting.
|
|
210
|
+
*/
|
|
211
|
+
export async function confirmAction(message: string): Promise<boolean> {
|
|
212
|
+
if (_autoYes) {
|
|
213
|
+
p.log.info(`${message} ${pc.dim("(auto-yes)")}`);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = await p.confirm({ message });
|
|
218
|
+
if (p.isCancel(result)) return false;
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Format a branch for inline display.
|
|
224
|
+
*/
|
|
225
|
+
export function branchLabel(name: string): string {
|
|
226
|
+
return pc.yellow(name);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Format a PR number for inline display.
|
|
231
|
+
*/
|
|
232
|
+
export function prLabel(num: number): string {
|
|
233
|
+
return pc.blue(`#${num}`);
|
|
234
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// gh-stack metadata schema (v2)
|
|
2
|
+
|
|
3
|
+
export interface StackMetadata {
|
|
4
|
+
version: 2;
|
|
5
|
+
current_stack: string | null;
|
|
6
|
+
stacks: Record<string, Stack>;
|
|
7
|
+
archive?: Record<string, Stack>;
|
|
8
|
+
snapshots?: Snapshot[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Stack {
|
|
12
|
+
description: string;
|
|
13
|
+
last_branch: string | null;
|
|
14
|
+
branches: Record<string, Branch>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Branch {
|
|
18
|
+
parent: string; // "main" or another branch name
|
|
19
|
+
pr?: number; // GitHub PR number
|
|
20
|
+
description?: string; // Human-readable label
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Snapshot {
|
|
24
|
+
timestamp: string; // ISO 8601
|
|
25
|
+
operation: string; // "restack" | "merge" | "sync" | "remove"
|
|
26
|
+
branches: Record<string, string>; // branch name -> commit SHA
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resume state for restack --resume
|
|
30
|
+
export interface RestackState {
|
|
31
|
+
current_index: number;
|
|
32
|
+
stack_name: string;
|
|
33
|
+
chain: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// PR info from GitHub API
|
|
37
|
+
export interface PrInfo {
|
|
38
|
+
number: number;
|
|
39
|
+
title: string;
|
|
40
|
+
state: "OPEN" | "CLOSED" | "MERGED";
|
|
41
|
+
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "PENDING" | null;
|
|
42
|
+
isDraft: boolean;
|
|
43
|
+
url: string;
|
|
44
|
+
updatedAt?: string;
|
|
45
|
+
headRefName?: string; // branch name
|
|
46
|
+
statusCheckRollup?: CheckRun[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CheckRun {
|
|
50
|
+
name: string;
|
|
51
|
+
status: string;
|
|
52
|
+
conclusion: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Parsed PR status for display
|
|
56
|
+
export interface PrStatus extends PrInfo {
|
|
57
|
+
totalChecks: number;
|
|
58
|
+
passedChecks: number;
|
|
59
|
+
failedChecks: number;
|
|
60
|
+
pendingChecks: number;
|
|
61
|
+
failedNames: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// v1 schema (for migration)
|
|
65
|
+
export interface StackMetadataV1 {
|
|
66
|
+
current_stack: string | null;
|
|
67
|
+
stacks: Record<string, Stack>;
|
|
68
|
+
}
|