@rtorcato/repo-tooling 3.41.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 +2 -0
- package/dist/cli/commands/loop-worktree.js +132 -0
- package/dist/cli/index.js +13 -0
- package/package.json +1 -1
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
|
|
|
@@ -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
|
@@ -8,6 +8,7 @@ import { fixCommand } from './commands/fix.js';
|
|
|
8
8
|
import { loopCleanupCommand } from './commands/loop-cleanup.js';
|
|
9
9
|
import { loopEnvCommand } from './commands/loop-env.js';
|
|
10
10
|
import { loopGuardCommand } from './commands/loop-guard.js';
|
|
11
|
+
import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
|
|
11
12
|
import { setupProject } from './commands/setup.js';
|
|
12
13
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
13
14
|
import { getToolVersion } from './utils/version.js';
|
|
@@ -389,6 +390,18 @@ loop
|
|
|
389
390
|
.addHelpText('after', '\nPass `removed` from --json to `loop guard --removed`.\n' +
|
|
390
391
|
'Exits 1 when a worktree removal failed.\n')
|
|
391
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);
|
|
392
405
|
program.hook('preAction', async (_, actionCommand) => {
|
|
393
406
|
const name = actionCommand.name();
|
|
394
407
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
package/package.json
CHANGED