@rtorcato/repo-tooling 3.40.0 → 3.41.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.
|
@@ -92,7 +92,7 @@ function repoScopedEnv() {
|
|
|
92
92
|
return env;
|
|
93
93
|
}
|
|
94
94
|
/** Never rejects; a missing or failing git resolves to null. */
|
|
95
|
-
export const realGitExec = (args, cwd) => new Promise((resolve) => {
|
|
95
|
+
export const realGitExec = (args, cwd, timeoutMs = GIT_TIMEOUT_MS) => new Promise((resolve) => {
|
|
96
96
|
let settled = false;
|
|
97
97
|
const done = (v) => {
|
|
98
98
|
if (settled)
|
|
@@ -112,7 +112,7 @@ export const realGitExec = (args, cwd) => new Promise((resolve) => {
|
|
|
112
112
|
const timer = setTimeout(() => {
|
|
113
113
|
child.kill();
|
|
114
114
|
done(null);
|
|
115
|
-
},
|
|
115
|
+
}, timeoutMs);
|
|
116
116
|
child.stdout?.on('data', (d) => {
|
|
117
117
|
stdout += d;
|
|
118
118
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { realGitExec } from '../../base/git-identity.js';
|
|
4
|
+
import { realGhExec } from '../../base/github-settings.js';
|
|
5
|
+
import { defaultWorktreeRoot, findLive } from './loop-guard.js';
|
|
6
|
+
/** How far back to look for the squash subject. */
|
|
7
|
+
const SQUASH_WINDOW = 200;
|
|
8
|
+
async function findPr(gh, heads) {
|
|
9
|
+
for (const head of heads) {
|
|
10
|
+
const r = await gh(['pr', 'list', '--head', head, '--state', 'all', '--json', 'number,state']);
|
|
11
|
+
if (!r.ok)
|
|
12
|
+
continue;
|
|
13
|
+
const [pr] = JSON.parse(r.stdout || '[]');
|
|
14
|
+
if (pr)
|
|
15
|
+
return pr;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export async function runLoopCleanup(options = {}) {
|
|
20
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
21
|
+
const worktreeRoot = options.worktreeRoot
|
|
22
|
+
? path.resolve(options.worktreeRoot)
|
|
23
|
+
: defaultWorktreeRoot(root);
|
|
24
|
+
// Fetch and `worktree remove` outlast the 5s probe timeout on a real repo.
|
|
25
|
+
const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
|
|
26
|
+
const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
|
|
27
|
+
const dirs = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
|
|
28
|
+
let subjects = null;
|
|
29
|
+
const worktrees = [];
|
|
30
|
+
for (const dir of dirs) {
|
|
31
|
+
const slug = path.basename(dir);
|
|
32
|
+
const issue = Number(slug.match(/^ai-(\d+)-/)?.[1]) || null;
|
|
33
|
+
// `worktree-` is the prefix EnterWorktree once gave branches — legacy.
|
|
34
|
+
const heads = [slug, `worktree-${slug}`];
|
|
35
|
+
const branch = (await git(['branch', '--list', ...heads, '--format=%(refname:short)']))
|
|
36
|
+
?.split('\n')[0]
|
|
37
|
+
?.trim() || null;
|
|
38
|
+
const pr = await findPr(gh, heads);
|
|
39
|
+
const entry = {
|
|
40
|
+
path: dir,
|
|
41
|
+
issue,
|
|
42
|
+
branch,
|
|
43
|
+
pr: pr?.number ?? null,
|
|
44
|
+
prState: pr?.state ?? null,
|
|
45
|
+
action: 'kept',
|
|
46
|
+
reason: '',
|
|
47
|
+
};
|
|
48
|
+
worktrees.push(entry);
|
|
49
|
+
if (!pr || pr.state === 'OPEN') {
|
|
50
|
+
entry.reason = pr ? 'PR still open' : 'no PR';
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (pr.state === 'MERGED') {
|
|
54
|
+
if (subjects === null) {
|
|
55
|
+
// Best-effort: a failed fetch leaves origin/main stale, which can
|
|
56
|
+
// only keep a worktree, never remove one wrongly.
|
|
57
|
+
await git(['fetch', '--prune', 'origin']);
|
|
58
|
+
const log = await git(['log', 'origin/main', `-n${SQUASH_WINDOW}`, '--format=%s']);
|
|
59
|
+
subjects = log ? log.split('\n') : [];
|
|
60
|
+
}
|
|
61
|
+
if (!subjects.some((s) => s.trimEnd().endsWith(`(#${pr.number})`))) {
|
|
62
|
+
entry.reason = `squash for #${pr.number} not on origin/main`;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if ((await git(['worktree', 'remove', '--force', dir])) === null) {
|
|
67
|
+
entry.action = 'remove-failed';
|
|
68
|
+
entry.reason = 'git worktree remove failed';
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (branch)
|
|
72
|
+
await git(['branch', '-D', branch]);
|
|
73
|
+
entry.action = 'removed';
|
|
74
|
+
entry.reason = pr.state === 'MERGED' ? `#${pr.number} landed on main` : `#${pr.number} closed`;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
root,
|
|
78
|
+
worktreeRoot,
|
|
79
|
+
removed: worktrees.some((w) => w.action === 'removed'),
|
|
80
|
+
worktrees,
|
|
81
|
+
exitCode: worktrees.some((w) => w.action === 'remove-failed') ? 1 : 0,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export async function loopCleanupCommand(options) {
|
|
85
|
+
const result = await runLoopCleanup(options);
|
|
86
|
+
if (options.json) {
|
|
87
|
+
console.log(JSON.stringify(result, null, 2));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.log();
|
|
91
|
+
if (result.worktrees.length === 0)
|
|
92
|
+
console.log(chalk.gray(' no ai-* worktrees'));
|
|
93
|
+
for (const w of result.worktrees) {
|
|
94
|
+
const line = `${w.action.padEnd(13)} ${path.basename(w.path)} — ${w.reason}`;
|
|
95
|
+
console.log(` ${w.action === 'remove-failed' ? chalk.yellow(line) : chalk.gray(line)}`);
|
|
96
|
+
}
|
|
97
|
+
console.log();
|
|
98
|
+
}
|
|
99
|
+
process.exitCode = result.exitCode;
|
|
100
|
+
}
|
|
@@ -107,7 +107,7 @@ async function gitEntryKind(root) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
/** `ai-*` directories one level down, in either place worktrees are kept. */
|
|
110
|
-
async function findLive(dirs) {
|
|
110
|
+
export async function findLive(dirs) {
|
|
111
111
|
const live = [];
|
|
112
112
|
for (const dir of dirs) {
|
|
113
113
|
// A missing worktree root is the normal case, not an error.
|
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Command } from 'commander';
|
|
|
5
5
|
import fs from 'fs-extra';
|
|
6
6
|
import { doctorCommand } from './commands/doctor.js';
|
|
7
7
|
import { fixCommand } from './commands/fix.js';
|
|
8
|
+
import { loopCleanupCommand } from './commands/loop-cleanup.js';
|
|
8
9
|
import { loopEnvCommand } from './commands/loop-env.js';
|
|
9
10
|
import { loopGuardCommand } from './commands/loop-guard.js';
|
|
10
11
|
import { setupProject } from './commands/setup.js';
|
|
@@ -379,6 +380,15 @@ loop
|
|
|
379
380
|
.addHelpText('after', "\nWithout --json, prints KEY='value' lines for eval. Empty means none.\n" +
|
|
380
381
|
'Exits 1 when the checkout or its GitHub repo cannot be resolved.\n')
|
|
381
382
|
.action(loopEnvCommand);
|
|
383
|
+
loop
|
|
384
|
+
.command('cleanup')
|
|
385
|
+
.description('🧹 Remove ai-* worktrees whose PR landed on main or was closed')
|
|
386
|
+
.option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
|
|
387
|
+
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
388
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
389
|
+
.addHelpText('after', '\nPass `removed` from --json to `loop guard --removed`.\n' +
|
|
390
|
+
'Exits 1 when a worktree removal failed.\n')
|
|
391
|
+
.action(loopCleanupCommand);
|
|
382
392
|
program.hook('preAction', async (_, actionCommand) => {
|
|
383
393
|
const name = actionCommand.name();
|
|
384
394
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
package/package.json
CHANGED