fix(rollup): discover git worktrees - .git tested for existence, not directory-ness

The catalog half of a shared gap that was previously PINNED rather than fixed.
This reader of ~/repos tested `.git` with a directory check, so a plain
`git worktree add ~/repos/feature-x` produced a depth-1 sibling that can carry
its own STATE.md and was dropped SILENTLY - no warning, no count,
indistinguishable from a repo with nothing to say. A submodule fails the same
way for the same reason.

It was pinned, not fixed, because diverging would break the very
same-name-from-both-readers invariant discoverRepos exists to hold. ~/repos
answered YES to the gated question and reported their half landed. Verified
here rather than taken on report: board.sh's Discovery block tests `-e`, and
has done since board.sh was introduced (repo-mailbox 61e224c, first released
v0.9.0) - so the released reader has no `-d` era at all, and only this side
was ever the outlier. The two move together from here - change one, change the
other.

THE ACCEPTANCE TRAP, MEASURED RATHER THAN ASSUMED. This changes NO number
against real ~/repos. Old and new builders were run back to back against the
live tree and diffed: stdout and stderr byte-identical, both exit 0. Directly
measured why: 0 directories at depth 1 or 2 currently carry .git as a file.
A number standing still is not ambiguous here, it is the ONLY possible outcome,
and it is why a fixture is not the best way to test this but the only way.

FIXTURE GATED BEFORE BEHAVIOUR. The worktree fixture uses a real `git worktree
add`, not a hand-written `.git` file - faking it would assert against our guess
at git's on-disk format instead of against git. A separate test asserts the
fixture itself: .git exists, is a file, is not a directory. Without it, the day
git stops writing worktree .git as a file the behaviour tests would go green
while measuring nothing, and a green run cannot distinguish that from success.
The gate says which one it was, and says the premise of the -e rule is gone
rather than inviting a test tweak.

Two behaviour tests: a worktree at depth 1, and one under a polyrepo container
at depth 2 - the same rule at both depths board.sh walks.

Suite 90/90 across the six files (rollup 31 -> 34). check-versions 11 OK,
0 WARN, 0 ERROR. Real run unchanged at 8 repos, 17 markers, Output B 0 lines,
1 V6 warning, exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSX2v1m7Fz22BKrGJZuUpQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 15:41:08 +02:00
commit 2efe98ba02
2 changed files with 91 additions and 6 deletions

View file

@ -207,13 +207,20 @@ export function buildRegister({ repos }) {
// //
// Not descending into a git repo also makes the known nested-name trap // Not descending into a git repo also makes the known nested-name trap
// (claude-code-100x/claude-code-100x, both git repos) structurally unreachable rather than // (claude-code-100x/claude-code-100x, both git repos) structurally unreachable rather than
// merely absent. SHARED, DELIBERATE GAP: like board.sh, "is a repo" means `.git` is a // merely absent.
// DIRECTORY, so a git worktree or submodule (where `.git` is a file) is not seen by either //
// reader. Pinned here rather than fixed one-sidedly — diverging would break the very // "Is a repo" tests `.git` for EXISTENCE, not directory-ness: a worktree or submodule has
// same-name-from-both-readers invariant this function exists to hold. // `.git` as a FILE. A plain `git worktree add ~/repos/feature-x` lands a depth-1 sibling
// that can carry its own STATE.md, and a directory test dropped it SILENTLY — no warning,
// no count, indistinguishable from a repo with nothing to say. This was pinned rather than
// fixed one-sidedly because diverging would break the very same-name-from-both-readers
// invariant this function exists to hold. board.sh's side is in place — MEASURED, not
// reported: its Discovery block tests `-e`, and has since board.sh was introduced
// (repo-mailbox 61e224c, first released v0.9.0). This is the other half. The two move
// together or not at all — change one, change the other.
function isGitRepo(dir) { function isGitRepo(dir) {
const dotGit = join(dir, '.git'); const dotGit = join(dir, '.git');
return existsSync(dotGit) && statSync(dotGit).isDirectory(); return existsSync(dotGit);
} }
function subdirs(dir) { function subdirs(dir) {

View file

@ -11,7 +11,8 @@
// and to make the V4 "no em-dash in carrier" axis unambiguous. // and to make the V4 "no em-dash in carrier" axis unambiguous.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, statSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { import {
@ -350,3 +351,80 @@ test('D1: a basename collision fails LOUDLY instead of silently overwriting', ()
'two STATE.md files resolving to one name must stop the build', 'two STATE.md files resolving to one name must stop the build',
); );
}); });
// --- D1 discovery: git worktrees, where .git is a FILE and not a directory -------------
// Closes a gap that was previously PINNED rather than fixed: both readers tested `.git`
// with a directory check, so `git worktree add ~/repos/feature-x` produced a depth-1
// sibling that can carry its own STATE.md and was dropped SILENTLY here. board.sh's
// Discovery block tests `-e` (measured: repo-mailbox 61e224c, first released v0.9.0); this
// is the catalog half. Diverging would break the one invariant the mirroring exists to
// hold, so the two move together or not at all.
//
// This cannot be faked with mkdirSync: a fixture that writes `.git` as a file by hand
// would assert against our own guess at git's on-disk format rather than against git.
// Hence a REAL `git worktree add` -- and hence the fixture gate below.
function gitWorktreeTree() {
const root = mkdtempSync(join(tmpdir(), 'rollup-worktree-'));
const main = join(root, 'main');
mkdirSync(main, { recursive: true });
const git = (args, cwd) => execFileSync('git', args, { cwd, stdio: 'pipe' });
git(['init', '-q', '-b', 'main'], main);
git(['config', 'user.email', 'test@example.invalid'], main);
git(['config', 'user.name', 'test'], main);
writeFileSync(join(main, 'seed.txt'), 'seed\n');
git(['add', 'seed.txt'], main);
git(['commit', '-q', '-m', 'seed'], main);
// A worktree needs a commit to attach to, which is why the seed above exists.
const worktree = join(root, 'feature-x');
git(['worktree', 'add', '-q', '-b', 'feature-x', worktree], main);
writeFileSync(join(main, 'STATE.md'), MARKER);
writeFileSync(join(worktree, 'STATE.md'), MARKER);
return { root, main, worktree };
}
test('D1 fixture gate: git really does write a worktree .git as a FILE', () => {
// Gate the fixture BEFORE gating the behaviour. Without this, the day git starts
// writing worktree `.git` as a directory, the test below would go green while
// measuring nothing -- passing for the wrong reason is the failure mode that a
// green run cannot distinguish from success. This assertion says which one it was.
const { worktree } = gitWorktreeTree();
const dotGit = join(worktree, '.git');
assert.ok(existsSync(dotGit), 'a worktree must carry a .git entry at all');
assert.ok(
statSync(dotGit).isFile(),
'git no longer writes a worktree .git as a file -- the premise of the -e rule is gone, '
+ 'and both this builder and board.sh need re-deciding, not a test tweak',
);
assert.ok(!statSync(dotGit).isDirectory(), 'a file and a directory must not both be true');
});
test('D1: a git worktree at depth 1 is discovered (.git is a file, not a directory)', () => {
const { root } = gitWorktreeTree();
const repos = discoverRepos(root);
assert.deepEqual(
repos.map((r) => r.name).sort(),
['feature-x', 'main'],
'the worktree carries its own STATE.md and must not be dropped for having a .git FILE',
);
});
test('D1: a git worktree under a polyrepo container (depth 2) is discovered', () => {
// Same rule at the depth board.sh also walks: a container is not itself a repo, so a
// worktree parked inside one is reached by the same child pass as an ordinary repo.
const { root, main, worktree } = gitWorktreeTree();
const outer = mkdtempSync(join(tmpdir(), 'rollup-worktree-outer-'));
const container = join(outer, 'container');
mkdirSync(container, { recursive: true });
execFileSync('git', ['worktree', 'add', '-q', '-b', 'nested-x', join(container, 'nested-x')], {
cwd: main,
stdio: 'pipe',
});
writeFileSync(join(container, 'nested-x', 'STATE.md'), MARKER);
assert.ok(statSync(join(container, 'nested-x', '.git')).isFile(), 'fixture gate: .git is a file');
assert.deepEqual(
discoverRepos(outer).map((r) => r.name),
['nested-x'],
'a container holding only a worktree still contributes that worktree',
);
assert.ok(existsSync(join(root, 'main')) && existsSync(worktree), 'fixture roots intact');
});