@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
- }, GIT_TIMEOUT_MS);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.40.0",
3
+ "version": "3.41.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [