@rtorcato/repo-tooling 3.40.0 → 3.42.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/AGENTS.md
CHANGED
|
@@ -28,6 +28,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
|
|
|
28
28
|
| `copy <name>` | ✅ | text only | Copy a single preset (`biome`, `tsconfig`) into the current directory. |
|
|
29
29
|
| `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`), and assert `gh` authenticates as the declared `rules.aiLoop.agentUser`. Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout or the agent identity is wrong — both non-zero halt the tick. |
|
|
30
30
|
| `loop env` | ✅ | ✅ | Resolve an `ai-issue-loop` tick's variables once: `root` (main checkout, correct from inside a worktree), `worktreeRoot`, `ownerRepo`, `agentUser` (empty unless an assignable collaborator), `humanUser` (empty for org repos), `me`. Without `--json`, prints `KEY='value'` lines for `eval`. Exit `1` when the checkout or its GitHub repo cannot be resolved. |
|
|
31
|
+
| `loop worktree add <ai-N-slug>` | ✅ | ✅ | Create an `ai-issue-loop` worktree at `<root>-worktrees/<slug>` on a new branch off `origin/main` (`--base`), symlink every `worktree.symlinkDirectories` entry from `.claude/settings.json`, and add `node_modules` to `.git/info/exclude`. `needsInstall: true` when no list is declared. Exit `1` when the worktree was not created or an entry is left unlinked — do not spawn an implementer. |
|
|
31
32
|
|
|
32
33
|
## Recommended workflows
|
|
33
34
|
|
|
@@ -119,6 +120,7 @@ A fixer may also **refuse** — the target file holds something the generator ca
|
|
|
119
120
|
- `src/cli/commands/fix-targets.ts` — shared check → fix target map (used by both doctor's footer and fix's lookup)
|
|
120
121
|
- `src/cli/commands/loop-guard.ts` — `loop guard`: the `--is-inside-work-tree` / `.git` invariant table and the `node_modules` rebuild gate, drained out of the ai-issue-loop skill's prose (#519)
|
|
121
122
|
- `src/cli/commands/loop-env.ts` — `loop env`: the skill's Pass 0 variables resolved in one call (#615)
|
|
123
|
+
- `src/cli/commands/loop-worktree.ts` — `loop worktree add`: Pass 4 worktree creation, dependency linking and the missing-link assertion (#616)
|
|
122
124
|
- `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
|
|
123
125
|
- `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
|
|
124
126
|
|
|
@@ -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.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { realGitExec } from '../../base/git-identity.js';
|
|
5
|
+
import { defaultWorktreeRoot } from './loop-guard.js';
|
|
6
|
+
/**
|
|
7
|
+
* `repo-tooling loop worktree add` — the ai-issue-loop skill's Pass 4
|
|
8
|
+
* worktree creation, moved into code (#616): `git worktree add`, linking every
|
|
9
|
+
* `worktree.symlinkDirectories` entry, the `.git/info/exclude` line, and the
|
|
10
|
+
* assertion that nothing was left unlinked.
|
|
11
|
+
*
|
|
12
|
+
* The prose version iterated `for d in $DIRS`, which zsh does not word-split,
|
|
13
|
+
* so it silently linked nothing (#585). An array has no such failure mode.
|
|
14
|
+
*
|
|
15
|
+
* Exit `0` means the worktree is ready for an implementer. Exit `1` means do
|
|
16
|
+
* not spawn one — either the worktree was not created, or an entry that exists
|
|
17
|
+
* in the main checkout has no link in the worktree. `needsInstall` (exit `0`)
|
|
18
|
+
* means the repo declares no symlink list, so nothing was linked and a real
|
|
19
|
+
* install in the worktree is safe.
|
|
20
|
+
*/
|
|
21
|
+
/** `ai-<issue>-<kebab words>` — it becomes a branch name and a path segment. */
|
|
22
|
+
export const SLUG = /^ai-\d+-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
23
|
+
/**
|
|
24
|
+
* Relative and inside the checkout, or dropped: the list comes from a
|
|
25
|
+
* committed file, and `../x` would otherwise link outside both trees.
|
|
26
|
+
*/
|
|
27
|
+
function safeEntry(entry) {
|
|
28
|
+
if (typeof entry !== 'string' || entry.trim() === '')
|
|
29
|
+
return false;
|
|
30
|
+
const n = path.normalize(entry);
|
|
31
|
+
return !path.isAbsolute(n) && n !== '..' && !n.startsWith(`..${path.sep}`);
|
|
32
|
+
}
|
|
33
|
+
export async function symlinkDirectories(root) {
|
|
34
|
+
const settings = await fs.readJson(path.join(root, '.claude', 'settings.json')).catch(() => null);
|
|
35
|
+
const dirs = settings?.worktree?.symlinkDirectories;
|
|
36
|
+
return Array.isArray(dirs) ? dirs.filter(safeEntry) : [];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `node_modules/` with a trailing slash does not match a symlink, so a
|
|
40
|
+
* `git add -A` would commit every link. No slash matches at any depth. The
|
|
41
|
+
* exclude file is shared by every worktree and never committed.
|
|
42
|
+
*/
|
|
43
|
+
async function ensureExcluded(root) {
|
|
44
|
+
const file = path.join(root, '.git', 'info', 'exclude');
|
|
45
|
+
const current = await fs.readFile(file, 'utf8').catch(() => '');
|
|
46
|
+
if (current.split('\n').includes('node_modules'))
|
|
47
|
+
return false;
|
|
48
|
+
await fs.outputFile(file, `${current}${current && !current.endsWith('\n') ? '\n' : ''}node_modules\n`);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
async function isSymlink(p) {
|
|
52
|
+
return fs
|
|
53
|
+
.lstat(p)
|
|
54
|
+
.then((s) => s.isSymbolicLink())
|
|
55
|
+
.catch(() => false);
|
|
56
|
+
}
|
|
57
|
+
export async function runLoopWorktreeAdd(slug, options = {}) {
|
|
58
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
59
|
+
const worktreeRoot = options.worktreeRoot
|
|
60
|
+
? path.resolve(options.worktreeRoot)
|
|
61
|
+
: defaultWorktreeRoot(root);
|
|
62
|
+
const worktree = path.join(worktreeRoot, slug);
|
|
63
|
+
// A checkout of a real repo takes longer than realGitExec's 5s default.
|
|
64
|
+
const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
|
|
65
|
+
const result = {
|
|
66
|
+
root,
|
|
67
|
+
worktree,
|
|
68
|
+
branch: slug,
|
|
69
|
+
linked: [],
|
|
70
|
+
missing: [],
|
|
71
|
+
needsInstall: false,
|
|
72
|
+
exitCode: 1,
|
|
73
|
+
messages: [],
|
|
74
|
+
};
|
|
75
|
+
if (!SLUG.test(slug)) {
|
|
76
|
+
result.messages.push(`⚠ '${slug}' is not ai-<issue>-<kebab-slug>`);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
const base = options.base ?? 'origin/main';
|
|
80
|
+
// Goes to git as an argument; a leading dash would be read as an option.
|
|
81
|
+
if (base.startsWith('-')) {
|
|
82
|
+
result.messages.push(`⚠ '${base}' is not a ref`);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
await fs.ensureDir(worktreeRoot);
|
|
86
|
+
if ((await git(['worktree', 'add', worktree, '-b', slug, base])) === null) {
|
|
87
|
+
result.messages.push(`⚠ git worktree add failed — does branch ${slug} or ${worktree} already exist?`);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
result.messages.push(`created ${worktree} on ${slug}`);
|
|
91
|
+
// Before linking, so the links are never momentarily untracked-and-addable.
|
|
92
|
+
if (await ensureExcluded(root))
|
|
93
|
+
result.messages.push('added node_modules to .git/info/exclude');
|
|
94
|
+
const dirs = await symlinkDirectories(root);
|
|
95
|
+
if (dirs.length === 0) {
|
|
96
|
+
result.needsInstall = true;
|
|
97
|
+
result.messages.push('no worktree.symlinkDirectories — run a real install in the worktree (`fix ai` restores the fast path)');
|
|
98
|
+
}
|
|
99
|
+
for (const d of dirs) {
|
|
100
|
+
const source = path.join(root, d);
|
|
101
|
+
// An entry pointing at nothing links nothing, and is not missing.
|
|
102
|
+
if (!(await fs.pathExists(source)))
|
|
103
|
+
continue;
|
|
104
|
+
const target = path.join(worktree, d);
|
|
105
|
+
await fs.ensureDir(path.dirname(target));
|
|
106
|
+
await fs.symlink(source, target).catch(() => { });
|
|
107
|
+
if (await isSymlink(target))
|
|
108
|
+
result.linked.push(d);
|
|
109
|
+
else
|
|
110
|
+
result.missing.push(d);
|
|
111
|
+
}
|
|
112
|
+
if (result.missing.length > 0) {
|
|
113
|
+
result.messages.push(`⚠ FATAL: no symlink for ${result.missing.join(', ')} — do not spawn an implementer`);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
if (result.linked.length > 0)
|
|
117
|
+
result.messages.push(`linked ${result.linked.join(', ')}`);
|
|
118
|
+
result.exitCode = 0;
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
export async function loopWorktreeAddCommand(slug, options) {
|
|
122
|
+
const result = await runLoopWorktreeAdd(slug, options);
|
|
123
|
+
if (options.json) {
|
|
124
|
+
console.log(JSON.stringify(result, null, 2));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
for (const line of result.messages) {
|
|
128
|
+
console.log(` ${line.startsWith('⚠') ? chalk.yellow(line) : chalk.gray(line)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
process.exitCode = result.exitCode;
|
|
132
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -5,8 +5,10 @@ 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';
|
|
11
|
+
import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
|
|
10
12
|
import { setupProject } from './commands/setup.js';
|
|
11
13
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
12
14
|
import { getToolVersion } from './utils/version.js';
|
|
@@ -379,6 +381,27 @@ loop
|
|
|
379
381
|
.addHelpText('after', "\nWithout --json, prints KEY='value' lines for eval. Empty means none.\n" +
|
|
380
382
|
'Exits 1 when the checkout or its GitHub repo cannot be resolved.\n')
|
|
381
383
|
.action(loopEnvCommand);
|
|
384
|
+
loop
|
|
385
|
+
.command('cleanup')
|
|
386
|
+
.description('🧹 Remove ai-* worktrees whose PR landed on main or was closed')
|
|
387
|
+
.option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
|
|
388
|
+
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
389
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
390
|
+
.addHelpText('after', '\nPass `removed` from --json to `loop guard --removed`.\n' +
|
|
391
|
+
'Exits 1 when a worktree removal failed.\n')
|
|
392
|
+
.action(loopCleanupCommand);
|
|
393
|
+
loop
|
|
394
|
+
.command('worktree')
|
|
395
|
+
.description('🌳 Loop worktree mechanics')
|
|
396
|
+
.command('add <slug>')
|
|
397
|
+
.description('🌱 Create ai-<issue>-<slug> off origin/main, link its deps, assert none are missing')
|
|
398
|
+
.option('--root <path>', 'Main checkout to branch the worktree from', process.cwd())
|
|
399
|
+
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
400
|
+
.option('--base <ref>', 'Ref to branch from', 'origin/main')
|
|
401
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
402
|
+
.addHelpText('after', '\nExit 1 when the worktree was not created or a symlinkDirectories entry is left\n' +
|
|
403
|
+
'unlinked — do not spawn an implementer. needsInstall means no list was declared.\n')
|
|
404
|
+
.action(loopWorktreeAddCommand);
|
|
382
405
|
program.hook('preAction', async (_, actionCommand) => {
|
|
383
406
|
const name = actionCommand.name();
|
|
384
407
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
package/package.json
CHANGED