@ikunin/sprintpilot 2.2.19 → 2.2.21
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.
|
@@ -523,6 +523,27 @@ function buildPrBody(state, profile, projectRoot, warnings) {
|
|
|
523
523
|
// Both paths honor profile.squash_on_merge. The PR-merge path also
|
|
524
524
|
// requires profile.platform_provider; non-github platforms fall through
|
|
525
525
|
// to the local-merge sequence with a description note.
|
|
526
|
+
// Build the worktree-cleanup steps appended to every merge_epic plan
|
|
527
|
+
// when profile.worktree_cleanup_on_merge is true. Documented in
|
|
528
|
+
// modules/git/config.yaml ("false = keep worktrees after epic
|
|
529
|
+
// completion for inspection"). Falls back silently when the helper
|
|
530
|
+
// script isn't available (partial install).
|
|
531
|
+
function buildCleanupSteps(profile) {
|
|
532
|
+
if (!profile.worktree_cleanup_on_merge) return [];
|
|
533
|
+
if (!profile.worktree_enabled) return [];
|
|
534
|
+
return [
|
|
535
|
+
{
|
|
536
|
+
args: ['node', '_Sprintpilot/scripts/cleanup-worktrees.js'],
|
|
537
|
+
description: 'remove orphan worktrees whose branches were merged + deleted',
|
|
538
|
+
// SKIPPED (no .worktrees/ dir) returns 0; real failures here should
|
|
539
|
+
// not halt the merge — the epic is already merged, cleanup is
|
|
540
|
+
// best-effort housekeeping.
|
|
541
|
+
tolerate_exit_codes: [0, 1, 2],
|
|
542
|
+
optional: true,
|
|
543
|
+
},
|
|
544
|
+
];
|
|
545
|
+
}
|
|
546
|
+
|
|
526
547
|
function planMergeEpic(state, profile, _action, branch) {
|
|
527
548
|
const baseBranch = profile.base_branch || 'main';
|
|
528
549
|
const squash = !!profile.squash_on_merge;
|
|
@@ -610,6 +631,7 @@ function planMergeEpic(state, profile, _action, branch) {
|
|
|
610
631
|
description: `merge epic PR for ${branch} via gh (${squash ? 'squash' : 'merge'}, delete branch)`,
|
|
611
632
|
env: Object.keys(env).length ? env : undefined,
|
|
612
633
|
},
|
|
634
|
+
...buildCleanupSteps(profile),
|
|
613
635
|
],
|
|
614
636
|
};
|
|
615
637
|
}
|
|
@@ -635,6 +657,7 @@ function planMergeEpic(state, profile, _action, branch) {
|
|
|
635
657
|
description: `merge epic MR for ${branch} via glab${squash ? ' (squash)' : ''}`,
|
|
636
658
|
env: Object.keys(env).length ? env : undefined,
|
|
637
659
|
},
|
|
660
|
+
...buildCleanupSteps(profile),
|
|
638
661
|
],
|
|
639
662
|
};
|
|
640
663
|
}
|
|
@@ -712,6 +735,7 @@ function planMergeEpic(state, profile, _action, branch) {
|
|
|
712
735
|
retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
|
|
713
736
|
});
|
|
714
737
|
}
|
|
738
|
+
for (const s of buildCleanupSteps(profile)) steps.push(s);
|
|
715
739
|
return { branch, steps };
|
|
716
740
|
}
|
|
717
741
|
|
|
@@ -105,6 +105,15 @@ function flatToProfile(resolved, profileName) {
|
|
|
105
105
|
get(resolved, 'git.worktree.health_check_on_boot'),
|
|
106
106
|
true,
|
|
107
107
|
),
|
|
108
|
+
// git.worktree.cleanup_on_merge — when true, planMergeEpic appends
|
|
109
|
+
// `git worktree prune` + per-directory cleanup steps so .worktrees/
|
|
110
|
+
// doesn't accumulate orphans after an epic merges. Documented in
|
|
111
|
+
// modules/git/config.yaml ("false = keep worktrees after epic
|
|
112
|
+
// completion for inspection").
|
|
113
|
+
worktree_cleanup_on_merge: coerceBool(
|
|
114
|
+
get(resolved, 'git.worktree.cleanup_on_merge'),
|
|
115
|
+
true,
|
|
116
|
+
),
|
|
108
117
|
squash_on_merge: coerceBool(get(resolved, 'git.squash_on_merge'), false),
|
|
109
118
|
reuse_user_branch: coerceBool(get(resolved, 'git.reuse_user_branch'), false),
|
|
110
119
|
merge_strategy: coerceEnum(
|
|
@@ -353,22 +353,33 @@ function verifyDevRed(state, out, ctx) {
|
|
|
353
353
|
|
|
354
354
|
function verifyDevGreen(state, out, ctx) {
|
|
355
355
|
const issues = [];
|
|
356
|
+
let runnerTestsRun = null;
|
|
356
357
|
if (ctx.runner) {
|
|
357
358
|
const result = ctx.runner({ phase: 'green', files: out.test_files || [] });
|
|
358
359
|
if (!result || typeof result.exit_code !== 'number') {
|
|
359
360
|
issues.push('runner did not report exit_code');
|
|
360
361
|
} else if (result.exit_code !== 0) {
|
|
361
362
|
issues.push(`tests still failing on GREEN: exit ${result.exit_code}`);
|
|
362
|
-
} else
|
|
363
|
-
if (result.tests_run
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
363
|
+
} else {
|
|
364
|
+
if (typeof result.tests_run === 'number') runnerTestsRun = result.tests_run;
|
|
365
|
+
if (typeof result.tests_run === 'number' && typeof out.tests_run === 'number') {
|
|
366
|
+
if (result.tests_run !== out.tests_run) {
|
|
367
|
+
issues.push(
|
|
368
|
+
`LLM reported ${out.tests_run} tests run but runner reported ${result.tests_run}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
367
371
|
}
|
|
368
372
|
}
|
|
369
373
|
}
|
|
374
|
+
// If the LLM omitted tests_run but the runner reported a positive
|
|
375
|
+
// count, accept the runner's number (same pattern as test_files
|
|
376
|
+
// auto-detect). A non-runner setup still requires the LLM to report.
|
|
370
377
|
if (typeof out.tests_run !== 'number' || out.tests_run <= 0) {
|
|
371
|
-
|
|
378
|
+
if (typeof runnerTestsRun === 'number' && runnerTestsRun > 0) {
|
|
379
|
+
// Recovered — don't push the "must be a positive number" issue.
|
|
380
|
+
} else {
|
|
381
|
+
issues.push('tests_run must be a positive number (per AGENTS.md test-result format)');
|
|
382
|
+
}
|
|
372
383
|
}
|
|
373
384
|
return { ok: issues.length === 0, issues };
|
|
374
385
|
}
|
|
@@ -449,6 +460,7 @@ function verifyPatchApply(state, out, _ctx) {
|
|
|
449
460
|
|
|
450
461
|
function verifyPatchRetest(state, out, ctx) {
|
|
451
462
|
const issues = [];
|
|
463
|
+
let runnerTestsRun = null;
|
|
452
464
|
if (ctx.runner) {
|
|
453
465
|
const result = ctx.runner({
|
|
454
466
|
phase: 'rereview',
|
|
@@ -458,10 +470,16 @@ function verifyPatchRetest(state, out, ctx) {
|
|
|
458
470
|
issues.push('runner did not report exit_code');
|
|
459
471
|
} else if (result.exit_code !== 0) {
|
|
460
472
|
issues.push(`tests failed after patch: exit ${result.exit_code}`);
|
|
473
|
+
} else if (typeof result.tests_run === 'number') {
|
|
474
|
+
runnerTestsRun = result.tests_run;
|
|
461
475
|
}
|
|
462
476
|
}
|
|
477
|
+
// Same auto-recovery as verifyDevGreen: accept the runner's count when
|
|
478
|
+
// the LLM omits tests_run.
|
|
463
479
|
if (typeof out.tests_run !== 'number' || out.tests_run <= 0) {
|
|
464
|
-
|
|
480
|
+
if (!(typeof runnerTestsRun === 'number' && runnerTestsRun > 0)) {
|
|
481
|
+
issues.push('tests_run must be a positive number');
|
|
482
|
+
}
|
|
465
483
|
}
|
|
466
484
|
return { ok: issues.length === 0, issues };
|
|
467
485
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// cleanup-worktrees.js — remove orphan worktrees under .worktrees/.
|
|
4
|
+
//
|
|
5
|
+
// After `gh pr merge --delete-branch` (or any branch deletion), the
|
|
6
|
+
// branch is gone but the `.worktrees/<name>/` directory remains. Git's
|
|
7
|
+
// `git worktree prune` removes the `.git/worktrees/<name>/` metadata
|
|
8
|
+
// but NOT the actual directory. Without explicit cleanup, .worktrees/
|
|
9
|
+
// accumulates orphans every epic merge.
|
|
10
|
+
//
|
|
11
|
+
// This script:
|
|
12
|
+
// 1. Runs `git worktree prune --expire now` to clear metadata.
|
|
13
|
+
// 2. Walks `.worktrees/*` and removes directories whose branches no
|
|
14
|
+
// longer resolve (locally and on origin).
|
|
15
|
+
//
|
|
16
|
+
// Honors `git.worktree.cleanup_on_merge` via the orchestrator's plan —
|
|
17
|
+
// this script is only invoked when that flag is true. Standalone use:
|
|
18
|
+
//
|
|
19
|
+
// node _Sprintpilot/scripts/cleanup-worktrees.js \
|
|
20
|
+
// [--worktrees-dir .worktrees] [--project-root <path>] [--dry-run]
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const cp = require('node:child_process');
|
|
27
|
+
|
|
28
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
29
|
+
const log = require('../lib/runtime/log');
|
|
30
|
+
|
|
31
|
+
function git(cwd, args, opts) {
|
|
32
|
+
return cp.spawnSync('git', ['-C', cwd, ...args], Object.assign({
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
timeout: 10000,
|
|
35
|
+
}, opts || {}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function localBranchExists(projectRoot, branch) {
|
|
39
|
+
const r = git(projectRoot, ['show-ref', '--verify', '--quiet', 'refs/heads/' + branch], {
|
|
40
|
+
stdio: 'ignore',
|
|
41
|
+
});
|
|
42
|
+
return r.status === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function remoteBranchExists(projectRoot, branch) {
|
|
46
|
+
const r = git(
|
|
47
|
+
projectRoot,
|
|
48
|
+
['show-ref', '--verify', '--quiet', 'refs/remotes/origin/' + branch],
|
|
49
|
+
{ stdio: 'ignore' },
|
|
50
|
+
);
|
|
51
|
+
return r.status === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function detectBranchFromGitfile(worktreeDir) {
|
|
55
|
+
const gitfile = path.join(worktreeDir, '.git');
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = fs.readFileSync(gitfile, 'utf8');
|
|
59
|
+
} catch (_e) {
|
|
60
|
+
return { kind: 'unknown', branch: null };
|
|
61
|
+
}
|
|
62
|
+
const m = /^gitdir:\s*(.+)$/m.exec(raw);
|
|
63
|
+
if (!m) return { kind: 'unknown', branch: null };
|
|
64
|
+
const gitdir = m[1].trim();
|
|
65
|
+
if (!fs.existsSync(gitdir)) {
|
|
66
|
+
return { kind: 'orphan', branch: null };
|
|
67
|
+
}
|
|
68
|
+
const headPath = path.join(gitdir, 'HEAD');
|
|
69
|
+
let head;
|
|
70
|
+
try {
|
|
71
|
+
head = fs.readFileSync(headPath, 'utf8').trim();
|
|
72
|
+
} catch (_e) {
|
|
73
|
+
return { kind: 'orphan', branch: null };
|
|
74
|
+
}
|
|
75
|
+
const refMatch = /^ref:\s*refs\/heads\/(.+)$/m.exec(head);
|
|
76
|
+
if (!refMatch) return { kind: 'detached', branch: null };
|
|
77
|
+
return { kind: 'attached', branch: refMatch[1] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function main() {
|
|
81
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
82
|
+
if (opts.help) {
|
|
83
|
+
log.out(
|
|
84
|
+
'Usage: cleanup-worktrees.js [--worktrees-dir .worktrees] [--project-root <path>] [--dry-run]',
|
|
85
|
+
);
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
89
|
+
const worktreesDir = opts['worktrees-dir']
|
|
90
|
+
? path.resolve(opts['worktrees-dir'])
|
|
91
|
+
: path.join(projectRoot, '.worktrees');
|
|
92
|
+
const dryRun = !!opts['dry-run'];
|
|
93
|
+
|
|
94
|
+
const prune = git(projectRoot, ['worktree', 'prune', '--expire', 'now']);
|
|
95
|
+
if (prune.status !== 0 && prune.error) {
|
|
96
|
+
log.error('git worktree prune failed: ' + prune.error.message);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!fs.existsSync(worktreesDir)) {
|
|
101
|
+
log.out('SUMMARY:0:0:0');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = fs.readdirSync(worktreesDir, { withFileTypes: true });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
log.error('cannot read ' + worktreesDir + ': ' + e.message);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let inspected = 0;
|
|
113
|
+
let removed = 0;
|
|
114
|
+
let kept = 0;
|
|
115
|
+
for (const ent of entries) {
|
|
116
|
+
if (!ent.isDirectory()) continue;
|
|
117
|
+
inspected += 1;
|
|
118
|
+
const wt = path.join(worktreesDir, ent.name);
|
|
119
|
+
const info = detectBranchFromGitfile(wt);
|
|
120
|
+
|
|
121
|
+
let orphan = false;
|
|
122
|
+
if (info.kind === 'orphan') {
|
|
123
|
+
orphan = true;
|
|
124
|
+
} else if (info.kind === 'attached' && info.branch) {
|
|
125
|
+
const localOk = localBranchExists(projectRoot, info.branch);
|
|
126
|
+
const remoteOk = remoteBranchExists(projectRoot, info.branch);
|
|
127
|
+
if (!localOk && !remoteOk) orphan = true;
|
|
128
|
+
} else {
|
|
129
|
+
kept += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!orphan) {
|
|
134
|
+
kept += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
log.out('ORPHAN:' + ent.name);
|
|
139
|
+
if (dryRun) {
|
|
140
|
+
removed += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const r = git(projectRoot, ['worktree', 'remove', '--force', wt], { stdio: 'ignore' });
|
|
144
|
+
if (r.status !== 0) {
|
|
145
|
+
try {
|
|
146
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
147
|
+
} catch (e) {
|
|
148
|
+
log.err('WARN: cannot remove ' + wt + ': ' + e.message);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
removed += 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
log.out('SUMMARY:' + inspected + ':' + removed + ':' + kept);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikunin/sprintpilot",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.21",
|
|
4
4
|
"description": "Sprintpilot — autopilot and multi-agent addon for BMad Method v6: git workflow, parallel agents, autonomous story execution",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|