cluttry 1.0.3 → 1.5.1
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/CHANGELOG.md +77 -0
- package/README.md +203 -300
- package/dist/commands/completions.d.ts +16 -0
- package/dist/commands/completions.d.ts.map +1 -0
- package/dist/commands/completions.js +46 -0
- package/dist/commands/completions.js.map +1 -0
- package/dist/commands/explain-copy.d.ts +11 -0
- package/dist/commands/explain-copy.d.ts.map +1 -0
- package/dist/commands/explain-copy.js +34 -0
- package/dist/commands/explain-copy.js.map +1 -0
- package/dist/commands/finish.d.ts +20 -0
- package/dist/commands/finish.d.ts.map +1 -0
- package/dist/commands/finish.js +817 -0
- package/dist/commands/finish.js.map +1 -0
- package/dist/commands/gc.d.ts +22 -0
- package/dist/commands/gc.d.ts.map +1 -0
- package/dist/commands/gc.js +163 -0
- package/dist/commands/gc.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +1 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/open.d.ts +15 -1
- package/dist/commands/open.d.ts.map +1 -1
- package/dist/commands/open.js +99 -17
- package/dist/commands/open.js.map +1 -1
- package/dist/commands/resume.d.ts +21 -0
- package/dist/commands/resume.d.ts.map +1 -0
- package/dist/commands/resume.js +106 -0
- package/dist/commands/resume.js.map +1 -0
- package/dist/commands/rm.d.ts.map +1 -1
- package/dist/commands/rm.js +6 -14
- package/dist/commands/rm.js.map +1 -1
- package/dist/commands/spawn.d.ts +3 -0
- package/dist/commands/spawn.d.ts.map +1 -1
- package/dist/commands/spawn.js +182 -19
- package/dist/commands/spawn.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +198 -9
- package/dist/index.js.map +1 -1
- package/dist/lib/completions.d.ts +35 -0
- package/dist/lib/completions.d.ts.map +1 -0
- package/dist/lib/completions.js +368 -0
- package/dist/lib/completions.js.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +2 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/errors.d.ts +43 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +251 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/git.d.ts +17 -0
- package/dist/lib/git.d.ts.map +1 -1
- package/dist/lib/git.js +78 -10
- package/dist/lib/git.js.map +1 -1
- package/dist/lib/safety.d.ts +79 -0
- package/dist/lib/safety.d.ts.map +1 -0
- package/dist/lib/safety.js +133 -0
- package/dist/lib/safety.js.map +1 -0
- package/dist/lib/secrets.d.ts +29 -0
- package/dist/lib/secrets.d.ts.map +1 -1
- package/dist/lib/secrets.js +115 -0
- package/dist/lib/secrets.js.map +1 -1
- package/dist/lib/session.d.ts +93 -0
- package/dist/lib/session.d.ts.map +1 -0
- package/dist/lib/session.js +254 -0
- package/dist/lib/session.js.map +1 -0
- package/dist/lib/types.d.ts +6 -1
- package/dist/lib/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -7
- package/src/commands/doctor.ts +0 -222
- package/src/commands/init.ts +0 -120
- package/src/commands/list.ts +0 -133
- package/src/commands/open.ts +0 -78
- package/src/commands/prune.ts +0 -36
- package/src/commands/rm.ts +0 -125
- package/src/commands/shell.ts +0 -99
- package/src/commands/spawn.ts +0 -169
- package/src/index.ts +0 -123
- package/src/lib/config.ts +0 -120
- package/src/lib/git.ts +0 -243
- package/src/lib/output.ts +0 -102
- package/src/lib/paths.ts +0 -108
- package/src/lib/secrets.ts +0 -193
- package/src/lib/types.ts +0 -69
- package/tests/config.test.ts +0 -102
- package/tests/paths.test.ts +0 -155
- package/tests/secrets.test.ts +0 -150
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -15
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cry finish command
|
|
3
|
+
*
|
|
4
|
+
* Show session summary and optionally create PR, cleanup worktree.
|
|
5
|
+
* Safe by default - never auto-merges, never deletes without confirmation.
|
|
6
|
+
*/
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
9
|
+
import { isGitRepo, getRepoRoot, getCurrentBranch, git, removeWorktree, deleteBranch, getDefaultBranch, getUpstreamBranch, } from '../lib/git.js';
|
|
10
|
+
import { findSessionForCwd, findMainRepoRoot, deleteSession, } from '../lib/session.js';
|
|
11
|
+
import * as out from '../lib/output.js';
|
|
12
|
+
import { fail, errors, printError } from '../lib/errors.js';
|
|
13
|
+
/**
|
|
14
|
+
* Parse git status --porcelain output
|
|
15
|
+
*/
|
|
16
|
+
function parseGitStatus(output) {
|
|
17
|
+
const lines = output.split('\n').filter(line => line.trim());
|
|
18
|
+
const staged = [];
|
|
19
|
+
const unstaged = [];
|
|
20
|
+
const untracked = [];
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const index = line[0];
|
|
23
|
+
const worktree = line[1];
|
|
24
|
+
const file = line.slice(3);
|
|
25
|
+
if (index === '?' && worktree === '?') {
|
|
26
|
+
untracked.push(file);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (index !== ' ' && index !== '?') {
|
|
30
|
+
staged.push(file);
|
|
31
|
+
}
|
|
32
|
+
if (worktree !== ' ' && worktree !== '?') {
|
|
33
|
+
unstaged.push(file);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
staged,
|
|
39
|
+
unstaged,
|
|
40
|
+
untracked,
|
|
41
|
+
clean: staged.length === 0 && unstaged.length === 0 && untracked.length === 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse git diff --stat output
|
|
46
|
+
*/
|
|
47
|
+
function parseDiffStat(output) {
|
|
48
|
+
const lines = output.trim().split('\n');
|
|
49
|
+
if (lines.length === 0 || output.trim() === '') {
|
|
50
|
+
return { filesChanged: 0, insertions: 0, deletions: 0, summary: 'No changes' };
|
|
51
|
+
}
|
|
52
|
+
// Last line contains summary like: "5 files changed, 100 insertions(+), 20 deletions(-)"
|
|
53
|
+
const lastLine = lines[lines.length - 1];
|
|
54
|
+
const filesMatch = lastLine.match(/(\d+) files? changed/);
|
|
55
|
+
const insertMatch = lastLine.match(/(\d+) insertions?\(\+\)/);
|
|
56
|
+
const deleteMatch = lastLine.match(/(\d+) deletions?\(-\)/);
|
|
57
|
+
return {
|
|
58
|
+
filesChanged: filesMatch ? parseInt(filesMatch[1], 10) : 0,
|
|
59
|
+
insertions: insertMatch ? parseInt(insertMatch[1], 10) : 0,
|
|
60
|
+
deletions: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
|
|
61
|
+
summary: lastLine.trim() || 'No changes',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get commits ahead/behind base branch
|
|
66
|
+
*/
|
|
67
|
+
function getCommitInfo(baseBranch, cwd) {
|
|
68
|
+
const commits = {
|
|
69
|
+
ahead: 0,
|
|
70
|
+
behind: 0,
|
|
71
|
+
list: [],
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
// Get commits ahead (on this branch but not on base)
|
|
75
|
+
const aheadOutput = git(['rev-list', '--count', `${baseBranch}..HEAD`], cwd);
|
|
76
|
+
commits.ahead = parseInt(aheadOutput, 10) || 0;
|
|
77
|
+
// Get commits behind (on base but not on this branch)
|
|
78
|
+
const behindOutput = git(['rev-list', '--count', `HEAD..${baseBranch}`], cwd);
|
|
79
|
+
commits.behind = parseInt(behindOutput, 10) || 0;
|
|
80
|
+
// Get list of commits ahead
|
|
81
|
+
if (commits.ahead > 0) {
|
|
82
|
+
const logOutput = git(['log', '--oneline', '--no-decorate', `${baseBranch}..HEAD`], cwd);
|
|
83
|
+
commits.list = logOutput.split('\n').filter(line => line.trim()).map(line => {
|
|
84
|
+
const [sha, ...rest] = line.split(' ');
|
|
85
|
+
return { sha, message: rest.join(' ') };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Base branch might not exist or be reachable
|
|
91
|
+
}
|
|
92
|
+
return commits;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Detect session info from git when no manifest is available
|
|
96
|
+
* Uses improved base branch detection via merge-base and upstream tracking
|
|
97
|
+
*/
|
|
98
|
+
function detectSessionFromGit(cwd) {
|
|
99
|
+
try {
|
|
100
|
+
const branch = getCurrentBranch(cwd);
|
|
101
|
+
if (!branch)
|
|
102
|
+
return null;
|
|
103
|
+
const repoRoot = getRepoRoot(cwd);
|
|
104
|
+
const mainRepoRoot = findMainRepoRoot(cwd);
|
|
105
|
+
// Determine base branch with fallback chain:
|
|
106
|
+
// 1. Upstream tracking branch (e.g., origin/main)
|
|
107
|
+
// 2. Default branch from origin/HEAD
|
|
108
|
+
// 3. 'main' or 'master' if they exist
|
|
109
|
+
// 4. Current branch as last resort
|
|
110
|
+
let baseBranch = null;
|
|
111
|
+
// Try upstream tracking branch
|
|
112
|
+
const upstream = getUpstreamBranch(cwd);
|
|
113
|
+
if (upstream) {
|
|
114
|
+
// Extract branch name from origin/branch format
|
|
115
|
+
const upstreamBranch = upstream.replace(/^origin\//, '');
|
|
116
|
+
// Verify it's a different branch
|
|
117
|
+
if (upstreamBranch !== branch) {
|
|
118
|
+
baseBranch = upstreamBranch;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Try default branch
|
|
122
|
+
if (!baseBranch) {
|
|
123
|
+
const defaultBranch = getDefaultBranch(cwd);
|
|
124
|
+
if (defaultBranch && defaultBranch !== branch) {
|
|
125
|
+
baseBranch = defaultBranch;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Try common branch names
|
|
129
|
+
if (!baseBranch) {
|
|
130
|
+
for (const candidate of ['main', 'master', 'develop']) {
|
|
131
|
+
try {
|
|
132
|
+
git(['rev-parse', '--verify', `refs/heads/${candidate}`], cwd);
|
|
133
|
+
if (candidate !== branch) {
|
|
134
|
+
baseBranch = candidate;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Branch doesn't exist, try next
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Last resort: use current branch (no comparison possible)
|
|
144
|
+
if (!baseBranch) {
|
|
145
|
+
baseBranch = branch;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
branch,
|
|
149
|
+
baseBranch,
|
|
150
|
+
worktreePath: repoRoot,
|
|
151
|
+
repoRoot: mainRepoRoot ?? repoRoot,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Build session summary
|
|
160
|
+
*/
|
|
161
|
+
function buildSummary(session, sessionId) {
|
|
162
|
+
const cwd = session.worktreePath;
|
|
163
|
+
const baseBranch = session.baseBranch ?? 'main';
|
|
164
|
+
// Get git status
|
|
165
|
+
let statusOutput = '';
|
|
166
|
+
try {
|
|
167
|
+
statusOutput = git(['status', '--porcelain'], cwd);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Ignore errors
|
|
171
|
+
}
|
|
172
|
+
// Get diff stat against base branch
|
|
173
|
+
let diffOutput = '';
|
|
174
|
+
try {
|
|
175
|
+
diffOutput = git(['diff', '--stat', baseBranch], cwd);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Base branch might not exist
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
branch: session.branch,
|
|
182
|
+
baseBranch,
|
|
183
|
+
worktreePath: session.worktreePath,
|
|
184
|
+
repoRoot: session.repoRoot,
|
|
185
|
+
sessionId,
|
|
186
|
+
status: parseGitStatus(statusOutput),
|
|
187
|
+
diff: parseDiffStat(diffOutput),
|
|
188
|
+
commits: getCommitInfo(baseBranch, cwd),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Print summary in human-readable format
|
|
193
|
+
*/
|
|
194
|
+
function printSummary(summary) {
|
|
195
|
+
out.header('Session Summary');
|
|
196
|
+
out.newline();
|
|
197
|
+
// Basic info
|
|
198
|
+
out.log(` Branch: ${out.fmt.branch(summary.branch)}`);
|
|
199
|
+
out.log(` Base: ${out.fmt.branch(summary.baseBranch)}`);
|
|
200
|
+
out.log(` Worktree: ${out.fmt.path(summary.worktreePath)}`);
|
|
201
|
+
if (summary.sessionId) {
|
|
202
|
+
out.log(` Session ID: ${out.fmt.dim(summary.sessionId)}`);
|
|
203
|
+
}
|
|
204
|
+
out.newline();
|
|
205
|
+
// Status
|
|
206
|
+
out.log(out.fmt.bold('Working Tree Status:'));
|
|
207
|
+
if (summary.status.clean) {
|
|
208
|
+
out.log(` ${out.fmt.green('✓')} Clean`);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
if (summary.status.staged.length > 0) {
|
|
212
|
+
out.log(` ${out.fmt.green('Staged:')} ${summary.status.staged.length} file(s)`);
|
|
213
|
+
for (const file of summary.status.staged.slice(0, 5)) {
|
|
214
|
+
out.log(` ${out.fmt.green('+')} ${file}`);
|
|
215
|
+
}
|
|
216
|
+
if (summary.status.staged.length > 5) {
|
|
217
|
+
out.log(` ${out.fmt.dim(`... and ${summary.status.staged.length - 5} more`)}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (summary.status.unstaged.length > 0) {
|
|
221
|
+
out.log(` ${out.fmt.yellow('Modified:')} ${summary.status.unstaged.length} file(s)`);
|
|
222
|
+
for (const file of summary.status.unstaged.slice(0, 5)) {
|
|
223
|
+
out.log(` ${out.fmt.yellow('~')} ${file}`);
|
|
224
|
+
}
|
|
225
|
+
if (summary.status.unstaged.length > 5) {
|
|
226
|
+
out.log(` ${out.fmt.dim(`... and ${summary.status.unstaged.length - 5} more`)}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (summary.status.untracked.length > 0) {
|
|
230
|
+
out.log(` ${out.fmt.gray('Untracked:')} ${summary.status.untracked.length} file(s)`);
|
|
231
|
+
for (const file of summary.status.untracked.slice(0, 3)) {
|
|
232
|
+
out.log(` ${out.fmt.gray('?')} ${file}`);
|
|
233
|
+
}
|
|
234
|
+
if (summary.status.untracked.length > 3) {
|
|
235
|
+
out.log(` ${out.fmt.dim(`... and ${summary.status.untracked.length - 3} more`)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
out.newline();
|
|
240
|
+
// Diff stats
|
|
241
|
+
out.log(out.fmt.bold(`Changes vs ${summary.baseBranch}:`));
|
|
242
|
+
if (summary.diff.filesChanged === 0 && summary.commits.ahead === 0) {
|
|
243
|
+
out.log(` ${out.fmt.dim('No changes')}`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
out.log(` ${summary.diff.summary}`);
|
|
247
|
+
}
|
|
248
|
+
out.newline();
|
|
249
|
+
// Commits
|
|
250
|
+
out.log(out.fmt.bold('Commits:'));
|
|
251
|
+
if (summary.commits.ahead === 0 && summary.commits.behind === 0) {
|
|
252
|
+
out.log(` ${out.fmt.dim('Up to date with')} ${summary.baseBranch}`);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
if (summary.commits.ahead > 0) {
|
|
256
|
+
out.log(` ${out.fmt.green(`↑ ${summary.commits.ahead}`)} ahead of ${summary.baseBranch}`);
|
|
257
|
+
for (const commit of summary.commits.list.slice(0, 5)) {
|
|
258
|
+
out.log(` ${out.fmt.dim(commit.sha)} ${commit.message}`);
|
|
259
|
+
}
|
|
260
|
+
if (summary.commits.list.length > 5) {
|
|
261
|
+
out.log(` ${out.fmt.dim(`... and ${summary.commits.list.length - 5} more`)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (summary.commits.behind > 0) {
|
|
265
|
+
out.log(` ${out.fmt.yellow(`↓ ${summary.commits.behind}`)} behind ${summary.baseBranch}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
out.newline();
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Interactive prompt with choices
|
|
272
|
+
*/
|
|
273
|
+
async function promptChoice(message, choices) {
|
|
274
|
+
const rl = createInterface({
|
|
275
|
+
input: process.stdin,
|
|
276
|
+
output: process.stdout,
|
|
277
|
+
});
|
|
278
|
+
out.log(message);
|
|
279
|
+
for (const choice of choices) {
|
|
280
|
+
out.log(` ${out.fmt.bold(choice.key)}) ${choice.label}`);
|
|
281
|
+
}
|
|
282
|
+
return new Promise((resolve) => {
|
|
283
|
+
rl.question('Choice: ', (answer) => {
|
|
284
|
+
rl.close();
|
|
285
|
+
const match = choices.find(c => c.key.toLowerCase() === answer.toLowerCase());
|
|
286
|
+
if (match) {
|
|
287
|
+
resolve(match.value);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// Default to first choice or abort
|
|
291
|
+
resolve(choices.find(c => c.value === 'abort')?.value ?? choices[0].value);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Prompt for text input
|
|
298
|
+
*/
|
|
299
|
+
async function promptText(message, defaultValue) {
|
|
300
|
+
const rl = createInterface({
|
|
301
|
+
input: process.stdin,
|
|
302
|
+
output: process.stdout,
|
|
303
|
+
});
|
|
304
|
+
const prompt = defaultValue
|
|
305
|
+
? `${message} [${defaultValue}]: `
|
|
306
|
+
: `${message}: `;
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
rl.question(prompt, (answer) => {
|
|
309
|
+
rl.close();
|
|
310
|
+
resolve(answer.trim() || defaultValue || '');
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Simple yes/no confirmation
|
|
316
|
+
*/
|
|
317
|
+
async function confirm(message) {
|
|
318
|
+
const rl = createInterface({
|
|
319
|
+
input: process.stdin,
|
|
320
|
+
output: process.stdout,
|
|
321
|
+
});
|
|
322
|
+
return new Promise((resolve) => {
|
|
323
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
324
|
+
rl.close();
|
|
325
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Check if gh CLI is installed and authenticated
|
|
331
|
+
*/
|
|
332
|
+
function isGhAvailable() {
|
|
333
|
+
try {
|
|
334
|
+
const result = spawnSync('gh', ['auth', 'status'], {
|
|
335
|
+
encoding: 'utf-8',
|
|
336
|
+
stdio: 'pipe',
|
|
337
|
+
});
|
|
338
|
+
return result.status === 0;
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Check if repo has an origin remote
|
|
346
|
+
*/
|
|
347
|
+
function hasOriginRemote(cwd) {
|
|
348
|
+
try {
|
|
349
|
+
const remotes = git(['remote'], cwd);
|
|
350
|
+
return remotes.split('\n').some(r => r.trim() === 'origin');
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Push branch to origin
|
|
358
|
+
*/
|
|
359
|
+
function pushToOrigin(branch, cwd) {
|
|
360
|
+
try {
|
|
361
|
+
git(['push', '-u', 'origin', branch], cwd);
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Check if branch is pushed to origin
|
|
370
|
+
*/
|
|
371
|
+
function isBranchPushed(branch, cwd) {
|
|
372
|
+
try {
|
|
373
|
+
git(['rev-parse', `origin/${branch}`], cwd);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Create PR using gh CLI
|
|
382
|
+
*/
|
|
383
|
+
function createPullRequest(branch, baseBranch, cwd) {
|
|
384
|
+
try {
|
|
385
|
+
const result = spawnSync('gh', ['pr', 'create', '--base', baseBranch, '--head', branch, '--fill'], {
|
|
386
|
+
cwd,
|
|
387
|
+
encoding: 'utf-8',
|
|
388
|
+
stdio: 'pipe',
|
|
389
|
+
});
|
|
390
|
+
if (result.status === 0) {
|
|
391
|
+
const url = result.stdout.trim().split('\n').pop() || '';
|
|
392
|
+
return { success: true, url };
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
return { success: false, error: result.stderr || 'Unknown error' };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
return { success: false, error: error.message };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Show git diff in pager
|
|
404
|
+
*/
|
|
405
|
+
function showDiff(cwd) {
|
|
406
|
+
try {
|
|
407
|
+
execSync('git diff', {
|
|
408
|
+
cwd,
|
|
409
|
+
stdio: 'inherit',
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// User may have quit pager
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Generate a default commit message from branch name
|
|
418
|
+
* feature/add-login -> Add login
|
|
419
|
+
* fix-auth-bug -> Fix auth bug
|
|
420
|
+
* feat-oauth -> Feat oauth
|
|
421
|
+
*/
|
|
422
|
+
function generateDefaultMessage(branch) {
|
|
423
|
+
// Remove common prefixes
|
|
424
|
+
let message = branch
|
|
425
|
+
.replace(/^(feature|feat|fix|bugfix|hotfix|chore|refactor|docs|test|ci)[\/\-]/i, '')
|
|
426
|
+
.replace(/[-_]/g, ' ')
|
|
427
|
+
.trim();
|
|
428
|
+
// Capitalize first letter
|
|
429
|
+
if (message.length > 0) {
|
|
430
|
+
message = message.charAt(0).toUpperCase() + message.slice(1);
|
|
431
|
+
}
|
|
432
|
+
return message || branch;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Stage all changes
|
|
436
|
+
*/
|
|
437
|
+
function stageAll(cwd) {
|
|
438
|
+
try {
|
|
439
|
+
git(['add', '-A'], cwd);
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Create a commit with the given message
|
|
448
|
+
*/
|
|
449
|
+
function createCommit(message, cwd) {
|
|
450
|
+
try {
|
|
451
|
+
git(['commit', '-m', message], cwd);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Check if there are staged changes
|
|
460
|
+
*/
|
|
461
|
+
function hasStagedChanges(cwd) {
|
|
462
|
+
try {
|
|
463
|
+
const output = git(['diff', '--cached', '--name-only'], cwd);
|
|
464
|
+
return output.trim().length > 0;
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Full commit wizard with staging options
|
|
472
|
+
*
|
|
473
|
+
* Returns true if commit was successful, false if aborted/failed
|
|
474
|
+
*/
|
|
475
|
+
async function runCommitWizard(cwd, branch, status, providedMessage) {
|
|
476
|
+
const hasStaged = status.staged.length > 0;
|
|
477
|
+
const hasUnstaged = status.unstaged.length > 0 || status.untracked.length > 0;
|
|
478
|
+
// Non-interactive mode with provided message
|
|
479
|
+
if (providedMessage) {
|
|
480
|
+
out.log('Staging all changes...');
|
|
481
|
+
if (!stageAll(cwd)) {
|
|
482
|
+
out.error('Failed to stage changes.');
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
out.log(`Committing with message: "${providedMessage}"`);
|
|
486
|
+
if (!createCommit(providedMessage, cwd)) {
|
|
487
|
+
out.error('Failed to create commit.');
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
out.success('Commit created');
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
// Interactive mode
|
|
494
|
+
out.newline();
|
|
495
|
+
out.header('Commit Wizard');
|
|
496
|
+
out.newline();
|
|
497
|
+
// Show current state
|
|
498
|
+
if (hasStaged) {
|
|
499
|
+
out.log(` ${out.fmt.green('Staged:')} ${status.staged.length} file(s)`);
|
|
500
|
+
}
|
|
501
|
+
if (hasUnstaged) {
|
|
502
|
+
out.log(` ${out.fmt.yellow('Unstaged:')} ${status.unstaged.length + status.untracked.length} file(s)`);
|
|
503
|
+
}
|
|
504
|
+
out.newline();
|
|
505
|
+
// Build choices based on current state
|
|
506
|
+
const choices = [];
|
|
507
|
+
if (hasUnstaged) {
|
|
508
|
+
choices.push({ key: 'a', label: 'Stage all changes and commit', value: 'stage-all' });
|
|
509
|
+
}
|
|
510
|
+
if (hasStaged) {
|
|
511
|
+
choices.push({ key: 's', label: 'Commit only staged changes', value: 'staged-only' });
|
|
512
|
+
}
|
|
513
|
+
choices.push({ key: 'x', label: 'Abort', value: 'abort' });
|
|
514
|
+
// If nothing staged and nothing unstaged, nothing to do
|
|
515
|
+
if (!hasStaged && !hasUnstaged) {
|
|
516
|
+
out.log(out.fmt.dim('Nothing to commit.'));
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
const action = await promptChoice('How would you like to commit?', choices);
|
|
520
|
+
if (action === 'abort') {
|
|
521
|
+
out.log('Commit aborted.');
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
// Stage if needed
|
|
525
|
+
if (action === 'stage-all') {
|
|
526
|
+
out.log('Staging all changes...');
|
|
527
|
+
if (!stageAll(cwd)) {
|
|
528
|
+
out.error('Failed to stage changes.');
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Verify we have something to commit
|
|
533
|
+
if (!hasStagedChanges(cwd)) {
|
|
534
|
+
out.warn('No changes staged for commit.');
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
// Get commit message
|
|
538
|
+
const defaultMessage = generateDefaultMessage(branch);
|
|
539
|
+
const message = await promptText('Commit message', defaultMessage);
|
|
540
|
+
if (!message) {
|
|
541
|
+
out.warn('Empty commit message. Aborting.');
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
// Create commit
|
|
545
|
+
out.log('Creating commit...');
|
|
546
|
+
if (!createCommit(message, cwd)) {
|
|
547
|
+
out.error('Failed to create commit.');
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
out.success('Commit created');
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Print manual PR instructions when gh is not available
|
|
555
|
+
*/
|
|
556
|
+
function printManualInstructions(summary) {
|
|
557
|
+
out.newline();
|
|
558
|
+
out.header('Manual PR Instructions');
|
|
559
|
+
out.newline();
|
|
560
|
+
const { branch, baseBranch, worktreePath } = summary;
|
|
561
|
+
out.log('GitHub CLI (gh) is not available. To create a PR manually:');
|
|
562
|
+
out.newline();
|
|
563
|
+
if (!isBranchPushed(branch, worktreePath)) {
|
|
564
|
+
out.log(` 1. Push your branch:`);
|
|
565
|
+
out.log(` ${out.fmt.dim(`git push -u origin ${branch}`)}`);
|
|
566
|
+
out.newline();
|
|
567
|
+
}
|
|
568
|
+
out.log(` 2. Create a PR on GitHub:`);
|
|
569
|
+
out.log(` ${out.fmt.dim(`https://github.com/<owner>/<repo>/compare/${baseBranch}...${branch}`)}`);
|
|
570
|
+
out.newline();
|
|
571
|
+
out.log(` Or install gh CLI:`);
|
|
572
|
+
out.log(` ${out.fmt.dim('brew install gh && gh auth login')}`);
|
|
573
|
+
out.newline();
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Perform cleanup: remove worktree, optionally delete branch and session
|
|
577
|
+
*/
|
|
578
|
+
async function performCleanup(summary, options, dryRun) {
|
|
579
|
+
const { branch, worktreePath, repoRoot, sessionId } = summary;
|
|
580
|
+
if (dryRun) {
|
|
581
|
+
out.log(out.fmt.dim('[dry-run] Would remove worktree:') + ` ${worktreePath}`);
|
|
582
|
+
if (options.deleteBranch) {
|
|
583
|
+
out.log(out.fmt.dim('[dry-run] Would delete branch:') + ` ${branch}`);
|
|
584
|
+
}
|
|
585
|
+
if (sessionId) {
|
|
586
|
+
out.log(out.fmt.dim('[dry-run] Would delete session:') + ` ${sessionId}`);
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
// Remove worktree
|
|
591
|
+
out.log(`Removing worktree: ${out.fmt.path(worktreePath)}`);
|
|
592
|
+
try {
|
|
593
|
+
removeWorktree(worktreePath, false, repoRoot);
|
|
594
|
+
out.success('Worktree removed');
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
out.error(`Failed to remove worktree: ${error.message}`);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
// Delete branch if requested
|
|
601
|
+
if (options.deleteBranch) {
|
|
602
|
+
const currentBranch = getCurrentBranch(repoRoot);
|
|
603
|
+
if (branch === currentBranch) {
|
|
604
|
+
out.warn(`Cannot delete branch '${branch}' - it's currently checked out.`);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
out.log(`Deleting branch: ${out.fmt.branch(branch)}`);
|
|
608
|
+
try {
|
|
609
|
+
deleteBranch(branch, false, repoRoot);
|
|
610
|
+
out.success('Branch deleted');
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
out.warn(`Failed to delete branch: ${error.message}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Delete session manifest
|
|
618
|
+
if (sessionId) {
|
|
619
|
+
if (deleteSession(repoRoot, sessionId)) {
|
|
620
|
+
out.success('Session manifest removed');
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
export async function finish(options) {
|
|
626
|
+
const cwd = process.cwd();
|
|
627
|
+
// Check if we're in a git repo
|
|
628
|
+
if (!isGitRepo(cwd)) {
|
|
629
|
+
if (options.json) {
|
|
630
|
+
console.log(JSON.stringify({ error: 'Not a git repository' }));
|
|
631
|
+
process.exit(1);
|
|
632
|
+
}
|
|
633
|
+
fail(errors.notGitRepo());
|
|
634
|
+
}
|
|
635
|
+
// Try to find session manifest first
|
|
636
|
+
let session = findSessionForCwd(cwd);
|
|
637
|
+
let sessionId = null;
|
|
638
|
+
if (session) {
|
|
639
|
+
sessionId = session.id;
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
// Fallback to git introspection
|
|
643
|
+
const detected = detectSessionFromGit(cwd);
|
|
644
|
+
if (!detected) {
|
|
645
|
+
if (options.json) {
|
|
646
|
+
console.log(JSON.stringify({ error: 'Could not detect session info' }));
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
649
|
+
fail(errors.sessionNotFound('current directory'));
|
|
650
|
+
}
|
|
651
|
+
session = detected;
|
|
652
|
+
}
|
|
653
|
+
// Build summary
|
|
654
|
+
let summary = buildSummary(session, sessionId);
|
|
655
|
+
// JSON mode - just output and exit
|
|
656
|
+
if (options.json) {
|
|
657
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Print summary
|
|
661
|
+
printSummary(summary);
|
|
662
|
+
const isDirty = !summary.status.clean;
|
|
663
|
+
const hasCommits = summary.commits.ahead > 0;
|
|
664
|
+
const dryRun = options.dryRun ?? false;
|
|
665
|
+
// Handle dirty state
|
|
666
|
+
if (isDirty) {
|
|
667
|
+
// Skip commit entirely if --skip-commit is set
|
|
668
|
+
if (options.skipCommit) {
|
|
669
|
+
out.log(out.fmt.dim('Skipping commit (--skip-commit).'));
|
|
670
|
+
}
|
|
671
|
+
else if (options.message) {
|
|
672
|
+
// Non-interactive commit with provided message
|
|
673
|
+
if (dryRun) {
|
|
674
|
+
out.log(out.fmt.dim(`[dry-run] Would stage all and commit with message: "${options.message}"`));
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
const success = await runCommitWizard(cwd, summary.branch, summary.status, options.message);
|
|
678
|
+
if (!success) {
|
|
679
|
+
out.error('Commit failed.');
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
// Refresh summary
|
|
683
|
+
summary = buildSummary(session, sessionId);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else if (options.nonInteractive) {
|
|
687
|
+
if (options.allowDirty) {
|
|
688
|
+
out.warn('Working tree is dirty. Proceeding with --allow-dirty.');
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
out.error('Working tree has uncommitted changes.');
|
|
692
|
+
out.info('Use --allow-dirty to proceed anyway, --message to commit, or --skip-commit to bypass.');
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
// Interactive dirty handling
|
|
698
|
+
const action = await promptChoice('Working tree has uncommitted changes. What would you like to do?', [
|
|
699
|
+
{ key: 'd', label: 'View diff', value: 'diff' },
|
|
700
|
+
{ key: 'c', label: 'Commit changes', value: 'commit' },
|
|
701
|
+
{ key: 'a', label: 'Abort', value: 'abort' },
|
|
702
|
+
]);
|
|
703
|
+
if (action === 'diff') {
|
|
704
|
+
showDiff(cwd);
|
|
705
|
+
out.newline();
|
|
706
|
+
// After viewing diff, ask again
|
|
707
|
+
const nextAction = await promptChoice('What next?', [
|
|
708
|
+
{ key: 'c', label: 'Commit changes', value: 'commit' },
|
|
709
|
+
{ key: 'a', label: 'Abort', value: 'abort' },
|
|
710
|
+
]);
|
|
711
|
+
if (nextAction === 'abort') {
|
|
712
|
+
out.log('Aborted.');
|
|
713
|
+
process.exit(0);
|
|
714
|
+
}
|
|
715
|
+
// Run commit wizard
|
|
716
|
+
const success = await runCommitWizard(cwd, summary.branch, summary.status);
|
|
717
|
+
if (!success) {
|
|
718
|
+
out.error('Commit failed or was cancelled.');
|
|
719
|
+
process.exit(1);
|
|
720
|
+
}
|
|
721
|
+
// Refresh summary
|
|
722
|
+
summary = buildSummary(session, sessionId);
|
|
723
|
+
}
|
|
724
|
+
else if (action === 'commit') {
|
|
725
|
+
const success = await runCommitWizard(cwd, summary.branch, summary.status);
|
|
726
|
+
if (!success) {
|
|
727
|
+
out.error('Commit failed or was cancelled.');
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
// Refresh summary
|
|
731
|
+
summary = buildSummary(session, sessionId);
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
out.log('Aborted.');
|
|
735
|
+
process.exit(0);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Check for commits to create PR
|
|
740
|
+
const updatedHasCommits = summary.commits.ahead > 0 || hasCommits;
|
|
741
|
+
const ghAvailable = isGhAvailable();
|
|
742
|
+
const hasOrigin = hasOriginRemote(cwd);
|
|
743
|
+
const canCreatePr = ghAvailable && hasOrigin;
|
|
744
|
+
if (updatedHasCommits || options.pr) {
|
|
745
|
+
out.newline();
|
|
746
|
+
if (dryRun) {
|
|
747
|
+
out.log(out.fmt.dim('[dry-run] Would push branch and create PR'));
|
|
748
|
+
}
|
|
749
|
+
else if (canCreatePr) {
|
|
750
|
+
// Push if needed
|
|
751
|
+
if (!isBranchPushed(summary.branch, cwd)) {
|
|
752
|
+
out.log(`Pushing branch: ${out.fmt.branch(summary.branch)}`);
|
|
753
|
+
if (!pushToOrigin(summary.branch, cwd)) {
|
|
754
|
+
fail(errors.pushFailed(summary.branch));
|
|
755
|
+
}
|
|
756
|
+
out.success('Branch pushed');
|
|
757
|
+
}
|
|
758
|
+
// Create PR
|
|
759
|
+
out.log('Creating pull request...');
|
|
760
|
+
const prResult = createPullRequest(summary.branch, summary.baseBranch, cwd);
|
|
761
|
+
if (prResult.success) {
|
|
762
|
+
out.success(`PR created: ${prResult.url}`);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
// PR might already exist
|
|
766
|
+
if (prResult.error?.includes('already exists')) {
|
|
767
|
+
out.info('A pull request already exists for this branch.');
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
out.warn(`Could not create PR: ${prResult.error}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
else if (!ghAvailable) {
|
|
775
|
+
printManualInstructions(summary);
|
|
776
|
+
// Exit 0 as requested - not an error
|
|
777
|
+
process.exit(0);
|
|
778
|
+
}
|
|
779
|
+
else if (!hasOrigin) {
|
|
780
|
+
printError(errors.noRemoteConfigured());
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
out.log(out.fmt.dim('No commits to push.'));
|
|
785
|
+
}
|
|
786
|
+
// Cleanup prompt
|
|
787
|
+
if (options.noCleanup) {
|
|
788
|
+
// Skip cleanup entirely
|
|
789
|
+
out.newline();
|
|
790
|
+
out.success('Done (cleanup skipped)');
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (options.cleanup) {
|
|
794
|
+
// Auto cleanup
|
|
795
|
+
out.newline();
|
|
796
|
+
await performCleanup(summary, options, dryRun);
|
|
797
|
+
out.newline();
|
|
798
|
+
out.success('Done');
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// Interactive cleanup prompt
|
|
802
|
+
if (!options.nonInteractive) {
|
|
803
|
+
out.newline();
|
|
804
|
+
const shouldCleanup = await confirm('Remove worktree and clean up?');
|
|
805
|
+
if (shouldCleanup) {
|
|
806
|
+
let deleteBranchChoice = options.deleteBranch ?? false;
|
|
807
|
+
if (!deleteBranchChoice) {
|
|
808
|
+
deleteBranchChoice = await confirm('Also delete the branch?');
|
|
809
|
+
}
|
|
810
|
+
const cleanupOpts = { ...options, deleteBranch: deleteBranchChoice };
|
|
811
|
+
await performCleanup(summary, cleanupOpts, dryRun);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
out.newline();
|
|
815
|
+
out.success('Done');
|
|
816
|
+
}
|
|
817
|
+
//# sourceMappingURL=finish.js.map
|