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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// GitHub CLI (gh) helpers
|
|
2
|
+
import { $ } from "bun";
|
|
3
|
+
import type { PrInfo, PrStatus } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get PR number for a branch (auto-detect via gh).
|
|
7
|
+
* Returns null if no PR exists.
|
|
8
|
+
*/
|
|
9
|
+
export async function getPrNumber(branch: string): Promise<number | null> {
|
|
10
|
+
try {
|
|
11
|
+
const result = await $`gh pr list --head ${branch} --json number --jq '.[0].number'`.text();
|
|
12
|
+
const num = parseInt(result.trim(), 10);
|
|
13
|
+
return isNaN(num) ? null : num;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get PR info for a specific PR number.
|
|
21
|
+
*/
|
|
22
|
+
export async function getPrInfo(prNumber: number): Promise<PrInfo | null> {
|
|
23
|
+
try {
|
|
24
|
+
const result =
|
|
25
|
+
await $`gh pr view ${prNumber} --json number,title,state,reviewDecision,isDraft,url,updatedAt,statusCheckRollup`.text();
|
|
26
|
+
const data = JSON.parse(result.trim());
|
|
27
|
+
return {
|
|
28
|
+
number: data.number,
|
|
29
|
+
title: data.title,
|
|
30
|
+
state: data.state,
|
|
31
|
+
reviewDecision: data.reviewDecision || null,
|
|
32
|
+
isDraft: data.isDraft || false,
|
|
33
|
+
url: data.url,
|
|
34
|
+
updatedAt: data.updatedAt,
|
|
35
|
+
statusCheckRollup: data.statusCheckRollup || [],
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get all open PRs for the current user with full status info.
|
|
44
|
+
*/
|
|
45
|
+
export async function getMyOpenPrs(): Promise<PrStatus[]> {
|
|
46
|
+
try {
|
|
47
|
+
const result =
|
|
48
|
+
await $`gh pr list --author @me --state open --json number,title,headRefName,reviewDecision,statusCheckRollup,isDraft,state,updatedAt,url --limit 50`.text();
|
|
49
|
+
const data = JSON.parse(result.trim()) as any[];
|
|
50
|
+
|
|
51
|
+
return data.map((pr) => {
|
|
52
|
+
const checks = pr.statusCheckRollup || [];
|
|
53
|
+
const validChecks = checks.filter((c: any) => c.name != null);
|
|
54
|
+
const passed = validChecks.filter(
|
|
55
|
+
(c: any) => c.conclusion === "SUCCESS" || c.conclusion === "NEUTRAL",
|
|
56
|
+
).length;
|
|
57
|
+
const failed = validChecks.filter(
|
|
58
|
+
(c: any) =>
|
|
59
|
+
c.conclusion === "FAILURE" ||
|
|
60
|
+
c.conclusion === "CANCELLED" ||
|
|
61
|
+
c.conclusion === "TIMED_OUT",
|
|
62
|
+
).length;
|
|
63
|
+
const pending = validChecks.filter(
|
|
64
|
+
(c: any) => c.status === "IN_PROGRESS" || c.status === "QUEUED" || c.status === "PENDING",
|
|
65
|
+
).length;
|
|
66
|
+
const failedNames = validChecks
|
|
67
|
+
.filter(
|
|
68
|
+
(c: any) =>
|
|
69
|
+
c.conclusion === "FAILURE" ||
|
|
70
|
+
c.conclusion === "CANCELLED" ||
|
|
71
|
+
c.conclusion === "TIMED_OUT",
|
|
72
|
+
)
|
|
73
|
+
.map((c: any) => c.name)
|
|
74
|
+
.slice(0, 3);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
number: pr.number,
|
|
78
|
+
title: pr.title,
|
|
79
|
+
state: pr.state,
|
|
80
|
+
reviewDecision: pr.reviewDecision || null,
|
|
81
|
+
isDraft: pr.isDraft || false,
|
|
82
|
+
url: pr.url,
|
|
83
|
+
updatedAt: pr.updatedAt,
|
|
84
|
+
headRefName: pr.headRefName,
|
|
85
|
+
statusCheckRollup: checks,
|
|
86
|
+
totalChecks: validChecks.length,
|
|
87
|
+
passedChecks: passed,
|
|
88
|
+
failedChecks: failed,
|
|
89
|
+
pendingChecks: pending,
|
|
90
|
+
failedNames,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Update a PR's body (description).
|
|
100
|
+
*/
|
|
101
|
+
export async function updatePrBody(prNumber: number, body: string): Promise<boolean> {
|
|
102
|
+
try {
|
|
103
|
+
// Write to a temp file to avoid shell escaping issues
|
|
104
|
+
const tmpFile = `${await import("node:os").then((os) => os.tmpdir())}/gh-stack-pr-body-${prNumber}.md`;
|
|
105
|
+
await Bun.write(tmpFile, body);
|
|
106
|
+
await $`gh pr edit ${prNumber} --body-file ${tmpFile}`.quiet();
|
|
107
|
+
const { unlink } = await import("node:fs/promises");
|
|
108
|
+
await unlink(tmpFile);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get PR body text.
|
|
117
|
+
*/
|
|
118
|
+
export async function getPrBody(prNumber: number): Promise<string | null> {
|
|
119
|
+
try {
|
|
120
|
+
const result = await $`gh pr view ${prNumber} --json body --jq '.body'`.text();
|
|
121
|
+
return result.trim();
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Close a PR with a comment.
|
|
129
|
+
*/
|
|
130
|
+
export async function closePr(prNumber: number, comment?: string): Promise<boolean> {
|
|
131
|
+
try {
|
|
132
|
+
if (comment) {
|
|
133
|
+
await $`gh pr close ${prNumber} --comment ${comment}`.quiet();
|
|
134
|
+
} else {
|
|
135
|
+
await $`gh pr close ${prNumber}`.quiet();
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get review decision emoji for display.
|
|
145
|
+
*/
|
|
146
|
+
export function reviewEmoji(decision: string | null | undefined): string {
|
|
147
|
+
switch (decision) {
|
|
148
|
+
case "APPROVED":
|
|
149
|
+
return "\u2705"; // ✅
|
|
150
|
+
case "CHANGES_REQUESTED":
|
|
151
|
+
return "\u274C"; // ❌
|
|
152
|
+
case "REVIEW_REQUIRED":
|
|
153
|
+
return "\uD83D\uDC40"; // 👀
|
|
154
|
+
default:
|
|
155
|
+
return "\u23F3"; // ⏳
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get CI status emoji for display.
|
|
161
|
+
*/
|
|
162
|
+
export function ciEmoji(status: {
|
|
163
|
+
totalChecks: number;
|
|
164
|
+
passedChecks: number;
|
|
165
|
+
failedChecks: number;
|
|
166
|
+
pendingChecks: number;
|
|
167
|
+
}): { icon: string; text: string } {
|
|
168
|
+
if (status.totalChecks === 0) {
|
|
169
|
+
return { icon: "\u26AA", text: "No checks" };
|
|
170
|
+
}
|
|
171
|
+
if (status.failedChecks > 0) {
|
|
172
|
+
return { icon: "\u274C", text: `${status.failedChecks} failed` };
|
|
173
|
+
}
|
|
174
|
+
if (status.pendingChecks > 0) {
|
|
175
|
+
return { icon: "\uD83D\uDFE1", text: `${status.pendingChecks} running` };
|
|
176
|
+
}
|
|
177
|
+
if (status.passedChecks === status.totalChecks) {
|
|
178
|
+
return {
|
|
179
|
+
icon: "\u2705",
|
|
180
|
+
text: `All passing (${status.totalChecks})`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { icon: "\u26AA", text: "Unknown" };
|
|
184
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Read/write/validate .git/gh-stack-metadata.json
|
|
2
|
+
import type { StackMetadata, StackMetadataV1, Stack, Branch, RestackState } from "../types.ts";
|
|
3
|
+
import { gitDir } from "./git.ts";
|
|
4
|
+
|
|
5
|
+
let cachedGitDir: string | null = null;
|
|
6
|
+
|
|
7
|
+
async function getGitDir(): Promise<string> {
|
|
8
|
+
if (!cachedGitDir) {
|
|
9
|
+
cachedGitDir = await gitDir();
|
|
10
|
+
}
|
|
11
|
+
return cachedGitDir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the path to the metadata file.
|
|
16
|
+
*/
|
|
17
|
+
export async function metadataPath(): Promise<string> {
|
|
18
|
+
const dir = await getGitDir();
|
|
19
|
+
return `${dir}/gh-stack-metadata.json`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the path to the restack state file.
|
|
24
|
+
*/
|
|
25
|
+
export async function restackStatePath(): Promise<string> {
|
|
26
|
+
const dir = await getGitDir();
|
|
27
|
+
return `${dir}/.gh-stack-sync-state`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if metadata file exists.
|
|
32
|
+
*/
|
|
33
|
+
export async function metadataExists(): Promise<boolean> {
|
|
34
|
+
const path = await metadataPath();
|
|
35
|
+
return Bun.file(path).exists();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read and parse metadata, with auto-migration from v1.
|
|
40
|
+
* Returns null if file doesn't exist.
|
|
41
|
+
*/
|
|
42
|
+
export async function readMetadata(): Promise<StackMetadata | null> {
|
|
43
|
+
const path = await metadataPath();
|
|
44
|
+
const file = Bun.file(path);
|
|
45
|
+
|
|
46
|
+
if (!(await file.exists())) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const raw = await file.json();
|
|
51
|
+
|
|
52
|
+
// Auto-migrate v1 -> v2
|
|
53
|
+
if (!raw.version) {
|
|
54
|
+
const v1 = raw as StackMetadataV1;
|
|
55
|
+
const v2: StackMetadata = {
|
|
56
|
+
version: 2,
|
|
57
|
+
current_stack: v1.current_stack,
|
|
58
|
+
stacks: v1.stacks,
|
|
59
|
+
};
|
|
60
|
+
await writeMetadata(v2);
|
|
61
|
+
return v2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return raw as StackMetadata;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Write metadata to disk.
|
|
69
|
+
*/
|
|
70
|
+
export async function writeMetadata(data: StackMetadata): Promise<void> {
|
|
71
|
+
const path = await metadataPath();
|
|
72
|
+
await Bun.write(path, JSON.stringify(data, null, 2) + "\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Initialize an empty metadata file.
|
|
77
|
+
*/
|
|
78
|
+
export async function initMetadata(): Promise<StackMetadata> {
|
|
79
|
+
const data: StackMetadata = {
|
|
80
|
+
version: 2,
|
|
81
|
+
current_stack: null,
|
|
82
|
+
stacks: {},
|
|
83
|
+
};
|
|
84
|
+
await writeMetadata(data);
|
|
85
|
+
return data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get current stack name from metadata.
|
|
90
|
+
*/
|
|
91
|
+
export function getCurrentStack(meta: StackMetadata): string | null {
|
|
92
|
+
return meta.current_stack;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Set the current stack.
|
|
97
|
+
*/
|
|
98
|
+
export async function setCurrentStack(
|
|
99
|
+
meta: StackMetadata,
|
|
100
|
+
stackName: string,
|
|
101
|
+
): Promise<StackMetadata> {
|
|
102
|
+
meta.current_stack = stackName;
|
|
103
|
+
await writeMetadata(meta);
|
|
104
|
+
return meta;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find which stack contains a given branch.
|
|
109
|
+
*/
|
|
110
|
+
export function findStackForBranch(meta: StackMetadata, branch: string): string | null {
|
|
111
|
+
for (const [name, stack] of Object.entries(meta.stacks)) {
|
|
112
|
+
if (branch in stack.branches) {
|
|
113
|
+
return name;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a new stack.
|
|
121
|
+
*/
|
|
122
|
+
export async function createStack(
|
|
123
|
+
meta: StackMetadata,
|
|
124
|
+
name: string,
|
|
125
|
+
description: string,
|
|
126
|
+
): Promise<StackMetadata> {
|
|
127
|
+
meta.stacks[name] = {
|
|
128
|
+
description,
|
|
129
|
+
last_branch: null,
|
|
130
|
+
branches: {},
|
|
131
|
+
};
|
|
132
|
+
meta.current_stack = name;
|
|
133
|
+
await writeMetadata(meta);
|
|
134
|
+
return meta;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Add a branch to a stack.
|
|
139
|
+
*/
|
|
140
|
+
export async function addBranchToStack(
|
|
141
|
+
meta: StackMetadata,
|
|
142
|
+
stackName: string,
|
|
143
|
+
branchName: string,
|
|
144
|
+
branch: Branch,
|
|
145
|
+
): Promise<StackMetadata> {
|
|
146
|
+
const stack = meta.stacks[stackName];
|
|
147
|
+
if (!stack) {
|
|
148
|
+
throw new Error(`Stack "${stackName}" not found`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
stack.branches[branchName] = branch;
|
|
152
|
+
stack.last_branch = branchName;
|
|
153
|
+
await writeMetadata(meta);
|
|
154
|
+
return meta;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Remove a branch from a stack, re-parenting children to the removed branch's parent.
|
|
159
|
+
*/
|
|
160
|
+
export async function removeBranchFromStack(
|
|
161
|
+
meta: StackMetadata,
|
|
162
|
+
stackName: string,
|
|
163
|
+
branchName: string,
|
|
164
|
+
): Promise<StackMetadata> {
|
|
165
|
+
const stack = meta.stacks[stackName];
|
|
166
|
+
if (!stack) {
|
|
167
|
+
throw new Error(`Stack "${stackName}" not found`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const branch = stack.branches[branchName];
|
|
171
|
+
if (!branch) {
|
|
172
|
+
throw new Error(`Branch "${branchName}" not found in stack "${stackName}"`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const removedParent = branch.parent;
|
|
176
|
+
|
|
177
|
+
// Re-parent children
|
|
178
|
+
for (const [, b] of Object.entries(stack.branches)) {
|
|
179
|
+
if (b.parent === branchName) {
|
|
180
|
+
b.parent = removedParent;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Remove the branch
|
|
185
|
+
delete stack.branches[branchName];
|
|
186
|
+
|
|
187
|
+
// Update last_branch if it was the removed one
|
|
188
|
+
if (stack.last_branch === branchName) {
|
|
189
|
+
const remaining = Object.keys(stack.branches);
|
|
190
|
+
stack.last_branch = remaining.length > 0 ? remaining[remaining.length - 1]! : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await writeMetadata(meta);
|
|
194
|
+
return meta;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get the ordered list of branches from main to leaf for a stack.
|
|
199
|
+
* Follows parent pointers to build the chain.
|
|
200
|
+
*/
|
|
201
|
+
export function getOrderedBranches(stack: Stack): string[] {
|
|
202
|
+
const ordered: string[] = [];
|
|
203
|
+
const visited = new Set<string>();
|
|
204
|
+
|
|
205
|
+
function addChildrenOf(parent: string) {
|
|
206
|
+
for (const [name, branch] of Object.entries(stack.branches)) {
|
|
207
|
+
if (branch.parent === parent && !visited.has(name)) {
|
|
208
|
+
visited.add(name);
|
|
209
|
+
ordered.push(name);
|
|
210
|
+
addChildrenOf(name);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
addChildrenOf("main");
|
|
216
|
+
return ordered;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get children of a branch in a stack.
|
|
221
|
+
*/
|
|
222
|
+
export function getChildren(stack: Stack, branchName: string): string[] {
|
|
223
|
+
return Object.entries(stack.branches)
|
|
224
|
+
.filter(([_, b]) => b.parent === branchName)
|
|
225
|
+
.map(([name]) => name);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Get the top (leaf) branch of a stack.
|
|
230
|
+
*/
|
|
231
|
+
export function getTopOfStack(stack: Stack): string | null {
|
|
232
|
+
return stack.last_branch;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Build the rebase chain: starting branch + all descendants (BFS order).
|
|
237
|
+
*/
|
|
238
|
+
export function buildRebaseChain(stack: Stack, startBranch: string): string[] {
|
|
239
|
+
const chain: string[] = [startBranch];
|
|
240
|
+
const queue: string[] = [startBranch];
|
|
241
|
+
|
|
242
|
+
while (queue.length > 0) {
|
|
243
|
+
const current = queue.shift()!;
|
|
244
|
+
const children = getChildren(stack, current);
|
|
245
|
+
|
|
246
|
+
for (const child of children) {
|
|
247
|
+
chain.push(child);
|
|
248
|
+
queue.push(child);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return chain;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Update the last_branch for a stack.
|
|
257
|
+
*/
|
|
258
|
+
export async function updateLastBranch(
|
|
259
|
+
meta: StackMetadata,
|
|
260
|
+
stackName: string,
|
|
261
|
+
branch: string,
|
|
262
|
+
): Promise<StackMetadata> {
|
|
263
|
+
const stack = meta.stacks[stackName];
|
|
264
|
+
if (stack) {
|
|
265
|
+
stack.last_branch = branch;
|
|
266
|
+
await writeMetadata(meta);
|
|
267
|
+
}
|
|
268
|
+
return meta;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Restack state (for --resume) ──
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Save restack state for resume.
|
|
275
|
+
*/
|
|
276
|
+
export async function saveRestackState(state: RestackState): Promise<void> {
|
|
277
|
+
const path = await restackStatePath();
|
|
278
|
+
await Bun.write(path, JSON.stringify(state, null, 2) + "\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Load restack state for resume.
|
|
283
|
+
* Returns null if no state file exists.
|
|
284
|
+
*/
|
|
285
|
+
export async function loadRestackState(): Promise<RestackState | null> {
|
|
286
|
+
const path = await restackStatePath();
|
|
287
|
+
const file = Bun.file(path);
|
|
288
|
+
|
|
289
|
+
if (!(await file.exists())) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return file.json();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Clear restack state file.
|
|
298
|
+
*/
|
|
299
|
+
export async function clearRestackState(): Promise<void> {
|
|
300
|
+
const path = await restackStatePath();
|
|
301
|
+
const file = Bun.file(path);
|
|
302
|
+
|
|
303
|
+
if (await file.exists()) {
|
|
304
|
+
const { unlink } = await import("node:fs/promises");
|
|
305
|
+
await unlink(path);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Pre-flight safety checks
|
|
2
|
+
import * as git from "./git.ts";
|
|
3
|
+
import { metadataExists, readMetadata, findStackForBranch } from "./metadata.ts";
|
|
4
|
+
import type { StackMetadata } from "../types.ts";
|
|
5
|
+
import * as p from "@clack/prompts";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensure we're in a git repository.
|
|
10
|
+
*/
|
|
11
|
+
export async function ensureGitRepo(): Promise<void> {
|
|
12
|
+
if (!(await git.isGitRepo())) {
|
|
13
|
+
p.cancel("Not in a git repository");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ensure metadata file exists. Returns the metadata.
|
|
20
|
+
*/
|
|
21
|
+
export async function ensureMetadata(): Promise<StackMetadata> {
|
|
22
|
+
if (!(await metadataExists())) {
|
|
23
|
+
p.cancel(
|
|
24
|
+
`No stack metadata found.\n\n Create your first stack with:\n ${pc.green("gh-stack init")}`,
|
|
25
|
+
);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const meta = await readMetadata();
|
|
30
|
+
if (!meta) {
|
|
31
|
+
p.cancel("Failed to read stack metadata");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return meta;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ensure there's a current stack set. Returns the stack name.
|
|
40
|
+
*/
|
|
41
|
+
export function ensureCurrentStack(meta: StackMetadata): string {
|
|
42
|
+
const stack = meta.current_stack;
|
|
43
|
+
if (!stack || !meta.stacks[stack]) {
|
|
44
|
+
p.cancel(
|
|
45
|
+
`No current stack set.\n\n Create a new stack with:\n ${pc.green("gh-stack init")}`,
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
return stack;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Ensure current branch is in a stack. Returns the stack name.
|
|
54
|
+
*/
|
|
55
|
+
export async function ensureBranchInStack(meta: StackMetadata): Promise<string> {
|
|
56
|
+
const branch = await git.currentBranch();
|
|
57
|
+
const stackName = findStackForBranch(meta, branch);
|
|
58
|
+
|
|
59
|
+
if (!stackName) {
|
|
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")}`,
|
|
62
|
+
);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return stackName;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Ensure working tree is clean (no uncommitted changes).
|
|
71
|
+
*/
|
|
72
|
+
export async function ensureCleanWorkingTree(): Promise<void> {
|
|
73
|
+
if (!(await git.isCleanWorkingTree())) {
|
|
74
|
+
p.cancel("Working tree is not clean.\n\n Please commit or stash your changes first.");
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ensure we're not on main.
|
|
81
|
+
*/
|
|
82
|
+
export async function ensureNotOnMain(): Promise<void> {
|
|
83
|
+
const branch = await git.currentBranch();
|
|
84
|
+
if (branch === "main" || branch === "master") {
|
|
85
|
+
p.cancel(`Cannot run this command on ${pc.red(branch)}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Guard against force-pushing main.
|
|
92
|
+
*/
|
|
93
|
+
export function ensureNotMain(branch: string): void {
|
|
94
|
+
if (branch === "main" || branch === "master") {
|
|
95
|
+
p.cancel(`${pc.red("REFUSED:")} Cannot force-push ${pc.red(branch)}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Snapshot system for undo support
|
|
2
|
+
import type { StackMetadata, Snapshot } from "../types.ts";
|
|
3
|
+
import * as git from "./git.ts";
|
|
4
|
+
import { writeMetadata, getOrderedBranches } from "./metadata.ts";
|
|
5
|
+
|
|
6
|
+
const MAX_SNAPSHOTS = 10;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Take a snapshot of all branch HEADs in the current stack before a destructive operation.
|
|
10
|
+
*/
|
|
11
|
+
export async function takeSnapshot(
|
|
12
|
+
meta: StackMetadata,
|
|
13
|
+
stackName: string,
|
|
14
|
+
operation: string,
|
|
15
|
+
): Promise<StackMetadata> {
|
|
16
|
+
const stack = meta.stacks[stackName];
|
|
17
|
+
if (!stack) return meta;
|
|
18
|
+
|
|
19
|
+
const branches: Record<string, string> = {};
|
|
20
|
+
const ordered = getOrderedBranches(stack);
|
|
21
|
+
|
|
22
|
+
for (const branch of ordered) {
|
|
23
|
+
try {
|
|
24
|
+
branches[branch] = await git.revParse(branch);
|
|
25
|
+
} catch {
|
|
26
|
+
// Branch might not exist locally — skip
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const snapshot: Snapshot = {
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
operation,
|
|
33
|
+
branches,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (!meta.snapshots) {
|
|
37
|
+
meta.snapshots = [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
meta.snapshots.push(snapshot);
|
|
41
|
+
|
|
42
|
+
// Keep only the last N snapshots
|
|
43
|
+
if (meta.snapshots.length > MAX_SNAPSHOTS) {
|
|
44
|
+
meta.snapshots = meta.snapshots.slice(-MAX_SNAPSHOTS);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await writeMetadata(meta);
|
|
48
|
+
return meta;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the last snapshot (for undo).
|
|
53
|
+
*/
|
|
54
|
+
export function getLastSnapshot(meta: StackMetadata): Snapshot | null {
|
|
55
|
+
if (!meta.snapshots || meta.snapshots.length === 0) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return meta.snapshots[meta.snapshots.length - 1]!;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pop the last snapshot (remove it after restoring).
|
|
63
|
+
*/
|
|
64
|
+
export async function popSnapshot(
|
|
65
|
+
meta: StackMetadata,
|
|
66
|
+
): Promise<{ meta: StackMetadata; snapshot: Snapshot | null }> {
|
|
67
|
+
if (!meta.snapshots || meta.snapshots.length === 0) {
|
|
68
|
+
return { meta, snapshot: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const snapshot = meta.snapshots.pop()!;
|
|
72
|
+
await writeMetadata(meta);
|
|
73
|
+
return { meta, snapshot };
|
|
74
|
+
}
|