cluttry 1.5.1 → 2.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/README.md +79 -17
- package/dist/commands/finish.d.ts +5 -0
- package/dist/commands/finish.d.ts.map +1 -1
- package/dist/commands/finish.js +346 -3
- package/dist/commands/finish.js.map +1 -1
- package/dist/commands/list.d.ts +2 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +69 -25
- package/dist/commands/list.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 +158 -26
- package/dist/commands/spawn.js.map +1 -1
- package/dist/index.js +19 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +39 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/env.d.ts +38 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +166 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/git.d.ts +17 -1
- package/dist/lib/git.d.ts.map +1 -1
- package/dist/lib/git.js +44 -2
- package/dist/lib/git.js.map +1 -1
- package/dist/lib/hooks.d.ts +43 -0
- package/dist/lib/hooks.d.ts.map +1 -0
- package/dist/lib/hooks.js +120 -0
- package/dist/lib/hooks.js.map +1 -0
- package/dist/lib/secrets.d.ts +54 -0
- package/dist/lib/secrets.d.ts.map +1 -1
- package/dist/lib/secrets.js +140 -0
- package/dist/lib/secrets.js.map +1 -1
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.d.ts.map +1 -1
- package/dist/lib/session.js +4 -1
- package/dist/lib/session.js.map +1 -1
- package/dist/lib/types.d.ts +37 -3
- package/dist/lib/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cluttry
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Worktrees for coding agents. Safe by default.**
|
|
4
4
|
|
|
5
5
|
**CLI command:** `cry`
|
|
6
6
|
|
|
@@ -14,11 +14,11 @@ npm install -g cluttry
|
|
|
14
14
|
cd your-repo
|
|
15
15
|
cry init
|
|
16
16
|
|
|
17
|
-
# One command: create worktree → launch Claude → finish when done
|
|
18
|
-
cry feat-login claude --
|
|
17
|
+
# One command: create worktree → launch Claude → auto-finish when done
|
|
18
|
+
cry feat-login claude --auto
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
That's it. When Claude exits,
|
|
21
|
+
That's it. When Claude exits, cry automatically commits, creates a PR, and cleans up.
|
|
22
22
|
|
|
23
23
|
## The Lifecycle
|
|
24
24
|
|
|
@@ -42,7 +42,10 @@ cry feat-auth claude
|
|
|
42
42
|
# Explicit
|
|
43
43
|
cry spawn feat-auth --new --agent claude
|
|
44
44
|
|
|
45
|
-
# Full
|
|
45
|
+
# Full autopilot: agent exits → commit → PR → cleanup
|
|
46
|
+
cry feat-auth claude --auto
|
|
47
|
+
|
|
48
|
+
# Interactive finish menu when agent exits
|
|
46
49
|
cry feat-auth claude --finish-on-exit
|
|
47
50
|
```
|
|
48
51
|
|
|
@@ -50,7 +53,7 @@ cry feat-auth claude --finish-on-exit
|
|
|
50
53
|
|
|
51
54
|
Your AI agent works in the isolated worktree. Each worktree has:
|
|
52
55
|
- Its own branch
|
|
53
|
-
- Copy of your `.env` and secret files
|
|
56
|
+
- Copy of your `.env` and secret files (or injected env vars)
|
|
54
57
|
- Independent git state
|
|
55
58
|
|
|
56
59
|
Run multiple sessions in parallel—each in its own terminal.
|
|
@@ -64,10 +67,13 @@ cry finish
|
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
Interactive flow:
|
|
67
|
-
1.
|
|
68
|
-
2.
|
|
69
|
-
3.
|
|
70
|
-
4.
|
|
70
|
+
1. Runs `preFinish` hooks (tests, lint, etc.)
|
|
71
|
+
2. Shows session summary (branch, commits, diff stats)
|
|
72
|
+
3. If dirty: offers to commit (with suggested message from branch name)
|
|
73
|
+
4. Pushes branch and creates PR via `gh` CLI
|
|
74
|
+
5. Offers merge options (PR only, local merge, or gh merge)
|
|
75
|
+
6. Runs `postFinish` hooks
|
|
76
|
+
7. Offers cleanup prompt
|
|
71
77
|
|
|
72
78
|
Non-interactive:
|
|
73
79
|
```bash
|
|
@@ -113,21 +119,54 @@ After `cry init`, edit `.cry.json`:
|
|
|
113
119
|
{
|
|
114
120
|
"include": [".env", ".env.*", "config/secrets.json"],
|
|
115
121
|
"defaultMode": "copy",
|
|
116
|
-
"hooks": {
|
|
122
|
+
"hooks": {
|
|
123
|
+
"postCreate": ["npm install"],
|
|
124
|
+
"preFinish": ["npm test", "npm run lint"],
|
|
125
|
+
"postFinish": ["echo 'PR created!'"],
|
|
126
|
+
"preMerge": ["npm run build"]
|
|
127
|
+
},
|
|
117
128
|
"agentCommand": "claude"
|
|
118
129
|
}
|
|
119
130
|
```
|
|
120
131
|
|
|
121
132
|
| Key | Description |
|
|
122
133
|
|-----|-------------|
|
|
123
|
-
| `include` | Glob patterns for files to copy to worktrees |
|
|
124
|
-
| `defaultMode` | `copy`, `symlink`, or `none` |
|
|
134
|
+
| `include` | Glob patterns for files to copy/inject to worktrees |
|
|
135
|
+
| `defaultMode` | `copy`, `symlink`, `inject`, or `none` |
|
|
125
136
|
| `hooks.postCreate` | Commands to run after spawn |
|
|
137
|
+
| `hooks.preFinish` | Commands to run before finish (tests, lint) |
|
|
138
|
+
| `hooks.postFinish` | Commands to run after PR creation |
|
|
139
|
+
| `hooks.preMerge` | Commands to run before merge attempts |
|
|
126
140
|
| `agentCommand` | Agent CLI command (default: `claude`) |
|
|
127
141
|
| `editorCommand` | Editor command (default: `code`) |
|
|
142
|
+
| `injectNonEnv` | For inject mode: `skip` or `symlink` non-dotenv files |
|
|
143
|
+
| `agents` | Agent presets (see below) |
|
|
128
144
|
|
|
129
145
|
Machine-specific overrides go in `.cry.local.json` (gitignored).
|
|
130
146
|
|
|
147
|
+
### Agent Presets
|
|
148
|
+
|
|
149
|
+
Configure per-agent behavior:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"agents": {
|
|
154
|
+
"claude": {
|
|
155
|
+
"command": "claude",
|
|
156
|
+
"deny": [".env", ".env.*"],
|
|
157
|
+
"finishOnExitDefault": true
|
|
158
|
+
},
|
|
159
|
+
"cursor": {
|
|
160
|
+
"command": "cursor",
|
|
161
|
+
"args": ["."],
|
|
162
|
+
"finishOnExitDefault": false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Built-in presets for `claude` and `cursor` are provided. Override or add your own.
|
|
169
|
+
|
|
131
170
|
## Security Model
|
|
132
171
|
|
|
133
172
|
**Tracked files are never copied.** This is enforced, not optional.
|
|
@@ -151,14 +190,20 @@ cry explain-copy
|
|
|
151
190
|
cry spawn feat-test --new --dry-run
|
|
152
191
|
```
|
|
153
192
|
|
|
154
|
-
### Copy vs Symlink
|
|
193
|
+
### Copy vs Symlink vs Inject
|
|
155
194
|
|
|
156
195
|
| Mode | Behavior |
|
|
157
196
|
|------|----------|
|
|
158
197
|
| `copy` | Independent copies. Safe default. |
|
|
159
198
|
| `symlink` | Linked to original. Changes sync everywhere. |
|
|
199
|
+
| `inject` | **No files copied.** Env vars injected into commands. Safest for AI agents. |
|
|
160
200
|
| `none` | Nothing copied. Set up secrets manually. |
|
|
161
201
|
|
|
202
|
+
**Inject mode** is recommended when running AI agents:
|
|
203
|
+
- Parses `.env` files and injects variables into hooks and agent commands
|
|
204
|
+
- No secret files exist in the worktree for the agent to read
|
|
205
|
+
- Non-dotenv files are skipped (or optionally symlinked via `injectNonEnv: "symlink"`)
|
|
206
|
+
|
|
162
207
|
## Commands
|
|
163
208
|
|
|
164
209
|
### Session Lifecycle
|
|
@@ -191,10 +236,13 @@ cry spawn feat-test --new --dry-run
|
|
|
191
236
|
|
|
192
237
|
```
|
|
193
238
|
-n, --new Create new branch
|
|
194
|
-
-a, --agent <agent> Launch agent (claude, cursor,
|
|
239
|
+
-a, --agent <agent> Launch agent (claude, cursor, or custom preset)
|
|
195
240
|
--finish-on-exit Show finish menu when agent exits
|
|
241
|
+
--auto Autopilot: auto-commit, PR, cleanup when agent exits
|
|
242
|
+
--auto-merge With --auto: also merge PR via gh
|
|
243
|
+
--auto-commit-message With --auto: custom commit message
|
|
196
244
|
--base-branch <branch> PR target branch
|
|
197
|
-
-m, --mode <mode> Secret handling (copy, symlink, none)
|
|
245
|
+
-m, --mode <mode> Secret handling (copy, symlink, inject, none)
|
|
198
246
|
-r, --run <cmd> Run command after spawn
|
|
199
247
|
--dry-run Preview without creating
|
|
200
248
|
```
|
|
@@ -203,6 +251,10 @@ cry spawn feat-test --new --dry-run
|
|
|
203
251
|
|
|
204
252
|
```
|
|
205
253
|
-m, --message <msg> Commit with message (non-interactive)
|
|
254
|
+
--skip-hooks Skip all hooks (preFinish, postFinish, preMerge)
|
|
255
|
+
--merge Merge locally into base branch after PR
|
|
256
|
+
--pr-merge Merge PR via GitHub (gh pr merge)
|
|
257
|
+
--no-merge Skip merge prompt (PR only)
|
|
206
258
|
--cleanup Auto-cleanup after PR
|
|
207
259
|
--skip-commit Skip commit step
|
|
208
260
|
--non-interactive Never prompt
|
|
@@ -252,7 +304,17 @@ brew install gh && gh auth login
|
|
|
252
304
|
|
|
253
305
|
### How do I prevent AI from reading secrets?
|
|
254
306
|
|
|
255
|
-
|
|
307
|
+
**Best option: Use inject mode** (no files copied to worktree):
|
|
308
|
+
```bash
|
|
309
|
+
cry spawn feat-auth --new --agent claude --mode inject
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Or configure it as default in `.cry.json`:
|
|
313
|
+
```json
|
|
314
|
+
{ "defaultMode": "inject" }
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
For Claude Code specifically, you can also add to `.clauderc`:
|
|
256
318
|
```json
|
|
257
319
|
{ "deny": [".env", ".env.*"] }
|
|
258
320
|
```
|
|
@@ -15,6 +15,11 @@ export interface FinishOptions {
|
|
|
15
15
|
deleteBranch?: boolean;
|
|
16
16
|
message?: string;
|
|
17
17
|
skipCommit?: boolean;
|
|
18
|
+
skipHooks?: boolean;
|
|
19
|
+
merge?: boolean;
|
|
20
|
+
prMerge?: boolean;
|
|
21
|
+
noMerge?: boolean;
|
|
22
|
+
cherryPick?: boolean;
|
|
18
23
|
}
|
|
19
24
|
export declare function finish(options: FinishOptions): Promise<void>;
|
|
20
25
|
//# sourceMappingURL=finish.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"finish.d.ts","sourceRoot":"","sources":["../../src/commands/finish.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"finish.d.ts","sourceRoot":"","sources":["../../src/commands/finish.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAgCH,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAi4BD,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAoYlE"}
|
package/dist/commands/finish.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createInterface } from 'node:readline';
|
|
8
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';
|
|
9
|
+
import { isGitRepo, getRepoRoot, getCurrentBranch, git, removeWorktree, deleteBranch, isWorktreeDirty, getDefaultBranch, getUpstreamBranch, getCommitsAhead, cherryPick, cherryPickAbort, } from '../lib/git.js';
|
|
10
|
+
import { findSessionForCwd, findMainRepoRoot, deleteSession, updateSessionManifest, } from '../lib/session.js';
|
|
11
|
+
import { runHooks } from '../lib/hooks.js';
|
|
12
|
+
import { getMergedConfig } from '../lib/config.js';
|
|
11
13
|
import * as out from '../lib/output.js';
|
|
12
14
|
import { fail, errors, printError } from '../lib/errors.js';
|
|
13
15
|
/**
|
|
@@ -399,6 +401,171 @@ function createPullRequest(branch, baseBranch, cwd) {
|
|
|
399
401
|
return { success: false, error: error.message };
|
|
400
402
|
}
|
|
401
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Perform local merge in the main worktree
|
|
406
|
+
*/
|
|
407
|
+
function performLocalMerge(mainRepoRoot, sessionBranch, baseBranch, dryRun) {
|
|
408
|
+
if (dryRun) {
|
|
409
|
+
out.log(out.fmt.dim('[dry-run] Would perform local merge:'));
|
|
410
|
+
out.log(out.fmt.dim(` 1. cd ${mainRepoRoot}`));
|
|
411
|
+
out.log(out.fmt.dim(` 2. git fetch origin && git checkout ${baseBranch}`));
|
|
412
|
+
out.log(out.fmt.dim(` 3. git merge --no-ff ${sessionBranch}`));
|
|
413
|
+
out.log(out.fmt.dim(` 4. git push origin ${baseBranch}`));
|
|
414
|
+
return { success: true };
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
// Check if main worktree is clean
|
|
418
|
+
if (isWorktreeDirty(mainRepoRoot)) {
|
|
419
|
+
return { success: false, error: 'Main worktree has uncommitted changes. Please commit or stash them first.' };
|
|
420
|
+
}
|
|
421
|
+
// Save current branch to restore on failure
|
|
422
|
+
let originalBranch = null;
|
|
423
|
+
try {
|
|
424
|
+
originalBranch = getCurrentBranch(mainRepoRoot);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// May be in detached HEAD
|
|
428
|
+
}
|
|
429
|
+
// Fetch and checkout base branch
|
|
430
|
+
out.log(`Fetching and checking out ${out.fmt.branch(baseBranch)}...`);
|
|
431
|
+
try {
|
|
432
|
+
git(['fetch', 'origin'], mainRepoRoot);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Fetch may fail if no remote, continue anyway
|
|
436
|
+
}
|
|
437
|
+
git(['checkout', baseBranch], mainRepoRoot);
|
|
438
|
+
// Try to merge
|
|
439
|
+
out.log(`Merging ${out.fmt.branch(sessionBranch)} into ${out.fmt.branch(baseBranch)}...`);
|
|
440
|
+
try {
|
|
441
|
+
git(['merge', '--no-ff', sessionBranch, '-m', `Merge branch '${sessionBranch}'`], mainRepoRoot);
|
|
442
|
+
}
|
|
443
|
+
catch (mergeError) {
|
|
444
|
+
// Conflict detected - abort and restore
|
|
445
|
+
out.error('Merge conflict detected! Aborting merge...');
|
|
446
|
+
try {
|
|
447
|
+
git(['merge', '--abort'], mainRepoRoot);
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
// Abort may fail if merge wasn't in progress
|
|
451
|
+
}
|
|
452
|
+
if (originalBranch && originalBranch !== baseBranch) {
|
|
453
|
+
try {
|
|
454
|
+
git(['checkout', originalBranch], mainRepoRoot);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// Best effort restore
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { success: false, error: 'Merge conflicts detected. Please resolve manually or use PR workflow.' };
|
|
461
|
+
}
|
|
462
|
+
// Push to origin
|
|
463
|
+
out.log(`Pushing ${out.fmt.branch(baseBranch)} to origin...`);
|
|
464
|
+
try {
|
|
465
|
+
git(['push', 'origin', baseBranch], mainRepoRoot);
|
|
466
|
+
out.success('Local merge and push completed');
|
|
467
|
+
}
|
|
468
|
+
catch (pushError) {
|
|
469
|
+
out.warn('Merge succeeded locally but push failed. You may need to push manually.');
|
|
470
|
+
}
|
|
471
|
+
return { success: true };
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
return { success: false, error: error.message };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Perform cherry-pick of commits to the base branch in main worktree
|
|
479
|
+
*/
|
|
480
|
+
function performCherryPickToBase(mainRepoRoot, _sessionBranch, baseBranch, commits, dryRun) {
|
|
481
|
+
if (dryRun) {
|
|
482
|
+
out.log(out.fmt.dim('[dry-run] Would perform cherry-pick:'));
|
|
483
|
+
out.log(out.fmt.dim(` 1. cd ${mainRepoRoot}`));
|
|
484
|
+
out.log(out.fmt.dim(` 2. git fetch origin && git checkout ${baseBranch}`));
|
|
485
|
+
for (const commit of commits) {
|
|
486
|
+
out.log(out.fmt.dim(` 3. git cherry-pick ${commit.sha} # ${commit.message}`));
|
|
487
|
+
}
|
|
488
|
+
out.log(out.fmt.dim(` 4. git push origin ${baseBranch}`));
|
|
489
|
+
return { success: true };
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
// Check if main worktree is clean
|
|
493
|
+
if (isWorktreeDirty(mainRepoRoot)) {
|
|
494
|
+
return { success: false, error: 'Main worktree has uncommitted changes. Please commit or stash them first.' };
|
|
495
|
+
}
|
|
496
|
+
// Save current branch to restore on failure
|
|
497
|
+
let originalBranch = null;
|
|
498
|
+
try {
|
|
499
|
+
originalBranch = getCurrentBranch(mainRepoRoot);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// May be in detached HEAD
|
|
503
|
+
}
|
|
504
|
+
// Fetch and checkout base branch
|
|
505
|
+
out.log(`Fetching and checking out ${out.fmt.branch(baseBranch)}...`);
|
|
506
|
+
try {
|
|
507
|
+
git(['fetch', 'origin'], mainRepoRoot);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Fetch may fail if no remote, continue anyway
|
|
511
|
+
}
|
|
512
|
+
git(['checkout', baseBranch], mainRepoRoot);
|
|
513
|
+
// Cherry-pick each commit
|
|
514
|
+
out.log(`Cherry-picking ${commits.length} commit(s) onto ${out.fmt.branch(baseBranch)}...`);
|
|
515
|
+
for (const commit of commits) {
|
|
516
|
+
out.log(` Cherry-picking ${out.fmt.dim(commit.sha)} ${commit.message}`);
|
|
517
|
+
if (!cherryPick(commit.sha, mainRepoRoot)) {
|
|
518
|
+
out.error(`Cherry-pick conflict on commit ${commit.sha}! Aborting...`);
|
|
519
|
+
cherryPickAbort(mainRepoRoot);
|
|
520
|
+
// Try to restore original branch
|
|
521
|
+
if (originalBranch && originalBranch !== baseBranch) {
|
|
522
|
+
try {
|
|
523
|
+
git(['checkout', originalBranch], mainRepoRoot);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// Best effort restore
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return { success: false, error: `Cherry-pick conflict on commit ${commit.sha}. Please resolve manually or use PR workflow.` };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
out.success(`Successfully cherry-picked ${commits.length} commit(s)`);
|
|
533
|
+
// Push to origin
|
|
534
|
+
out.log(`Pushing ${out.fmt.branch(baseBranch)} to origin...`);
|
|
535
|
+
try {
|
|
536
|
+
git(['push', 'origin', baseBranch], mainRepoRoot);
|
|
537
|
+
out.success('Cherry-pick and push completed');
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
out.warn('Cherry-pick succeeded locally but push failed. You may need to push manually.');
|
|
541
|
+
}
|
|
542
|
+
return { success: true };
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
return { success: false, error: error.message };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Merge PR using gh CLI
|
|
550
|
+
*/
|
|
551
|
+
function performPrMerge(branch, cwd) {
|
|
552
|
+
try {
|
|
553
|
+
const result = spawnSync('gh', ['pr', 'merge', '--merge', '--delete-branch', branch], {
|
|
554
|
+
cwd,
|
|
555
|
+
encoding: 'utf-8',
|
|
556
|
+
stdio: 'pipe',
|
|
557
|
+
});
|
|
558
|
+
if (result.status === 0) {
|
|
559
|
+
return { success: true };
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
return { success: false, error: result.stderr || 'Unknown error' };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
return { success: false, error: error.message };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
402
569
|
/**
|
|
403
570
|
* Show git diff in pager
|
|
404
571
|
*/
|
|
@@ -659,9 +826,28 @@ export async function finish(options) {
|
|
|
659
826
|
}
|
|
660
827
|
// Print summary
|
|
661
828
|
printSummary(summary);
|
|
829
|
+
// Load config for hooks
|
|
830
|
+
const mainRepoRoot = findMainRepoRoot(cwd);
|
|
831
|
+
const config = mainRepoRoot ? getMergedConfig(mainRepoRoot) : null;
|
|
662
832
|
const isDirty = !summary.status.clean;
|
|
663
833
|
const hasCommits = summary.commits.ahead > 0;
|
|
664
834
|
const dryRun = options.dryRun ?? false;
|
|
835
|
+
// Run preFinish hooks
|
|
836
|
+
if (!options.skipHooks && config && config.hooks.preFinish.length > 0) {
|
|
837
|
+
if (dryRun) {
|
|
838
|
+
out.log(out.fmt.dim('[dry-run] Would run preFinish hooks:'));
|
|
839
|
+
for (const hook of config.hooks.preFinish) {
|
|
840
|
+
out.log(out.fmt.dim(` - ${hook}`));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
const hookResult = await runHooks('preFinish', config.hooks.preFinish, { cwd });
|
|
845
|
+
if (!hookResult.success) {
|
|
846
|
+
out.error('preFinish hooks failed. Aborting finish.');
|
|
847
|
+
process.exit(1);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
665
851
|
// Handle dirty state
|
|
666
852
|
if (isDirty) {
|
|
667
853
|
// Skip commit entirely if --skip-commit is set
|
|
@@ -741,7 +927,54 @@ export async function finish(options) {
|
|
|
741
927
|
const ghAvailable = isGhAvailable();
|
|
742
928
|
const hasOrigin = hasOriginRemote(cwd);
|
|
743
929
|
const canCreatePr = ghAvailable && hasOrigin;
|
|
744
|
-
|
|
930
|
+
// Cherry-pick workflow (alternative to PR)
|
|
931
|
+
if (options.cherryPick) {
|
|
932
|
+
out.newline();
|
|
933
|
+
if (!updatedHasCommits) {
|
|
934
|
+
out.log(out.fmt.dim('No commits to cherry-pick.'));
|
|
935
|
+
}
|
|
936
|
+
else if (!mainRepoRoot) {
|
|
937
|
+
out.error('Could not find main repository root for cherry-pick.');
|
|
938
|
+
process.exit(1);
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
out.header('Cherry-Pick to Base Branch');
|
|
942
|
+
out.newline();
|
|
943
|
+
const commits = getCommitsAhead(summary.baseBranch, cwd);
|
|
944
|
+
if (commits.length === 0) {
|
|
945
|
+
out.log(out.fmt.dim('No commits ahead of base branch.'));
|
|
946
|
+
}
|
|
947
|
+
else {
|
|
948
|
+
out.log(`Found ${commits.length} commit(s) to cherry-pick:`);
|
|
949
|
+
for (const commit of commits.slice(0, 5)) {
|
|
950
|
+
out.log(` ${out.fmt.dim(commit.sha)} ${commit.message}`);
|
|
951
|
+
}
|
|
952
|
+
if (commits.length > 5) {
|
|
953
|
+
out.log(out.fmt.dim(` ... and ${commits.length - 5} more`));
|
|
954
|
+
}
|
|
955
|
+
out.newline();
|
|
956
|
+
const cherryPickResult = performCherryPickToBase(mainRepoRoot, summary.branch, summary.baseBranch, commits, dryRun);
|
|
957
|
+
if (!cherryPickResult.success) {
|
|
958
|
+
out.error(`Cherry-pick failed: ${cherryPickResult.error}`);
|
|
959
|
+
process.exit(1);
|
|
960
|
+
}
|
|
961
|
+
// Update session manifest
|
|
962
|
+
if (sessionId && mainRepoRoot) {
|
|
963
|
+
updateSessionManifest(mainRepoRoot, sessionId, {
|
|
964
|
+
status: 'finished',
|
|
965
|
+
lastActiveAt: new Date().toISOString(),
|
|
966
|
+
lastFinishResult: {
|
|
967
|
+
success: true,
|
|
968
|
+
prCreated: false,
|
|
969
|
+
checksRan: false,
|
|
970
|
+
message: 'Cherry-picked to base branch',
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
else if (updatedHasCommits || options.pr) {
|
|
745
978
|
out.newline();
|
|
746
979
|
if (dryRun) {
|
|
747
980
|
out.log(out.fmt.dim('[dry-run] Would push branch and create PR'));
|
|
@@ -760,6 +993,19 @@ export async function finish(options) {
|
|
|
760
993
|
const prResult = createPullRequest(summary.branch, summary.baseBranch, cwd);
|
|
761
994
|
if (prResult.success) {
|
|
762
995
|
out.success(`PR created: ${prResult.url}`);
|
|
996
|
+
// Update session manifest with PR URL
|
|
997
|
+
if (sessionId && mainRepoRoot) {
|
|
998
|
+
updateSessionManifest(mainRepoRoot, sessionId, {
|
|
999
|
+
prUrl: prResult.url,
|
|
1000
|
+
status: 'finished',
|
|
1001
|
+
lastActiveAt: new Date().toISOString(),
|
|
1002
|
+
lastFinishResult: {
|
|
1003
|
+
success: true,
|
|
1004
|
+
prCreated: true,
|
|
1005
|
+
checksRan: false,
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
763
1009
|
}
|
|
764
1010
|
else {
|
|
765
1011
|
// PR might already exist
|
|
@@ -769,6 +1015,88 @@ export async function finish(options) {
|
|
|
769
1015
|
else {
|
|
770
1016
|
out.warn(`Could not create PR: ${prResult.error}`);
|
|
771
1017
|
}
|
|
1018
|
+
// Update session manifest with finish result
|
|
1019
|
+
if (sessionId && mainRepoRoot) {
|
|
1020
|
+
updateSessionManifest(mainRepoRoot, sessionId, {
|
|
1021
|
+
status: prResult.error?.includes('already exists') ? 'finished' : 'error',
|
|
1022
|
+
lastActiveAt: new Date().toISOString(),
|
|
1023
|
+
lastFinishResult: {
|
|
1024
|
+
success: prResult.error?.includes('already exists') ?? false,
|
|
1025
|
+
prCreated: false,
|
|
1026
|
+
checksRan: false,
|
|
1027
|
+
message: prResult.error,
|
|
1028
|
+
},
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
// Merge options
|
|
1033
|
+
let mergeAction = 'done';
|
|
1034
|
+
// Handle non-interactive merge flags
|
|
1035
|
+
if (options.merge) {
|
|
1036
|
+
mergeAction = 'local';
|
|
1037
|
+
}
|
|
1038
|
+
else if (options.prMerge) {
|
|
1039
|
+
mergeAction = 'gh';
|
|
1040
|
+
}
|
|
1041
|
+
else if (!options.noMerge && !options.nonInteractive) {
|
|
1042
|
+
// Interactive merge menu
|
|
1043
|
+
out.newline();
|
|
1044
|
+
mergeAction = await promptChoice('What would you like to do next?', [
|
|
1045
|
+
{ key: 'p', label: 'Done (PR only)', value: 'done' },
|
|
1046
|
+
{ key: 'm', label: 'Merge locally into base branch', value: 'local' },
|
|
1047
|
+
{ key: 'g', label: 'Merge PR via GitHub (gh pr merge)', value: 'gh' },
|
|
1048
|
+
{ key: 'x', label: 'Cancel', value: 'cancel' },
|
|
1049
|
+
]);
|
|
1050
|
+
}
|
|
1051
|
+
// Handle merge action
|
|
1052
|
+
if (mergeAction === 'cancel') {
|
|
1053
|
+
out.log('Cancelled.');
|
|
1054
|
+
process.exit(0);
|
|
1055
|
+
}
|
|
1056
|
+
if (mergeAction === 'local' || mergeAction === 'gh') {
|
|
1057
|
+
// Run preMerge hooks
|
|
1058
|
+
if (!options.skipHooks && config && config.hooks.preMerge.length > 0) {
|
|
1059
|
+
if (dryRun) {
|
|
1060
|
+
out.log(out.fmt.dim('[dry-run] Would run preMerge hooks:'));
|
|
1061
|
+
for (const hook of config.hooks.preMerge) {
|
|
1062
|
+
out.log(out.fmt.dim(` - ${hook}`));
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
const hookResult = await runHooks('preMerge', config.hooks.preMerge, { cwd });
|
|
1067
|
+
if (!hookResult.success) {
|
|
1068
|
+
out.error('preMerge hooks failed. Merge aborted.');
|
|
1069
|
+
process.exit(1);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// Perform the merge
|
|
1074
|
+
if (mergeAction === 'local') {
|
|
1075
|
+
out.newline();
|
|
1076
|
+
out.header('Local Merge');
|
|
1077
|
+
const mergeResult = performLocalMerge(mainRepoRoot, summary.branch, summary.baseBranch, dryRun);
|
|
1078
|
+
if (!mergeResult.success) {
|
|
1079
|
+
out.error(`Local merge failed: ${mergeResult.error}`);
|
|
1080
|
+
process.exit(1);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
else if (mergeAction === 'gh') {
|
|
1084
|
+
out.newline();
|
|
1085
|
+
out.log('Merging PR via GitHub...');
|
|
1086
|
+
if (dryRun) {
|
|
1087
|
+
out.log(out.fmt.dim(`[dry-run] Would run: gh pr merge --merge --delete-branch ${summary.branch}`));
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
const mergeResult = performPrMerge(summary.branch, cwd);
|
|
1091
|
+
if (mergeResult.success) {
|
|
1092
|
+
out.success('PR merged and branch deleted via GitHub');
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
out.error(`PR merge failed: ${mergeResult.error}`);
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
772
1100
|
}
|
|
773
1101
|
}
|
|
774
1102
|
else if (!ghAvailable) {
|
|
@@ -783,6 +1111,21 @@ export async function finish(options) {
|
|
|
783
1111
|
else {
|
|
784
1112
|
out.log(out.fmt.dim('No commits to push.'));
|
|
785
1113
|
}
|
|
1114
|
+
// Run postFinish hooks
|
|
1115
|
+
if (!options.skipHooks && config && config.hooks.postFinish.length > 0) {
|
|
1116
|
+
if (dryRun) {
|
|
1117
|
+
out.log(out.fmt.dim('[dry-run] Would run postFinish hooks:'));
|
|
1118
|
+
for (const hook of config.hooks.postFinish) {
|
|
1119
|
+
out.log(out.fmt.dim(` - ${hook}`));
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
const hookResult = await runHooks('postFinish', config.hooks.postFinish, { cwd });
|
|
1124
|
+
if (!hookResult.success) {
|
|
1125
|
+
out.warn('postFinish hooks failed, but finish will continue.');
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
786
1129
|
// Cleanup prompt
|
|
787
1130
|
if (options.noCleanup) {
|
|
788
1131
|
// Skip cleanup entirely
|