diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index 9ec2e52..cba341f 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -3454,3 +3454,34 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +# Release brief resolver tolerates unrelated vanished Git sibling + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-setup-unrelated-vanished-sibling-tolerated + given: "A real installed Git fixture has a selected registered worktree and a separate Git-registered sibling whose directory is renamed away but whose fixture data and registration remain" + when: "The installed-project resolver reads the selected worktree, and strict refusal cases exercise missing selected root, foreign/unregistered checkout, stale or absent installation identity, registered common-Git mismatch, and registered branch mismatch" + then: "The resolver returns the canonical root while local-state inputs, Git worktree-admin bytes and refs, install manifest, and moved sibling bytes remain unchanged; every strict refusal retains its existing error code" + observable_outcomes: + - "The selected same-installation registered branch resolves to its canonical project root despite an unrelated missing Git sibling" + - "The sibling remains absent; the full relevant Git worktree-admin entry and exact ref listing remain present and byte-identical" + - "Every file, directory inventory, and mode under declared local project state remains identical, including state.db and WAL/SHM files when present" + - "The moved sibling fixture, including its .git administration file, remains byte-identical" + - "The install manifest remains byte-identical; the resolver performs no prune, unregister, or fixture cleanup" + - "A missing selected root retains ENOENT; foreign/unregistered, stale or absent install identity, registered common-Git mismatch, and registered branch mismatch retain UNREGISTERED_PROJECT_WORKTREE" + determinism_controls: + - "Fixed fixture inputs and branch names, real local Git worktrees, temporary project and state roots, and no network or provider calls" + - "A filesystem rename makes one registered sibling path absent without pruning or changing Git's registration; deterministic recursive SHA-256/mode/inventory snapshots cover local state, Git worktree administration, and moved fixture data" + anti_cheat_rationale: + prevents: + - "Treating an unregistered or copied checkout as canonical after a lookup error" + - "Swallowing current-root or non-absence filesystem failures" + - "Pruning, unregistering, or rewriting the missing sibling to make resolution pass" + - "Relying only on porcelain output while silently changing SQLite, Git admin/ref, or moved-sibling bytes" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/src/kernel/project-root.mjs b/src/kernel/project-root.mjs index eeea65b..ee7a6b5 100644 --- a/src/kernel/project-root.mjs +++ b/src/kernel/project-root.mjs @@ -28,7 +28,17 @@ export function resolveInstalledProjectRoot(root, env = process.env) { const current = read(join(canonical, '.torch', 'install-manifest.json')); const registered = current.external?.find(entry => entry.type === 'worktree' && existsSync(entry.path) && realpathSync(entry.path) === root); const common = directory => realpathSync(resolve(directory, git(directory, ['rev-parse', '--git-common-dir']))); - const members = git(canonical, ['worktree', 'list', '--porcelain']).split('\n').filter(line => line.startsWith('worktree ')).map(line => realpathSync(line.slice(9))); + const members = []; + for (const line of git(canonical, ['worktree', 'list', '--porcelain']).split('\n').filter(value => value.startsWith('worktree '))) { + const memberPath = line.slice(9); + try { + members.push(realpathSync(memberPath)); + } catch (error) { + // A vanished unrelated worktree must not block this checkout's lookup. + // Keep errors for the selected root and non-absence failures fail-closed. + if (resolve(memberPath) === root || !['ENOENT', 'ENOTDIR'].includes(error.code)) throw error; + } + } if (current.projectId !== manifest.projectId || current.installationId !== manifest.installationId || !registered || common(root) !== common(canonical) || !members.includes(root) || git(root, ['branch', '--show-current']) !== registered.branch) { diff --git a/test/fleet/setup.test.mjs b/test/fleet/setup.test.mjs index b051f36..d7d6ad2 100644 --- a/test/fleet/setup.test.mjs +++ b/test/fleet/setup.test.mjs @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, writeFileSync, cpSync } from 'node:fs'; +import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, writeFileSync, cpSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import test from 'node:test'; import { analyzeRepository } from '../../src/kernel/analyze.mjs'; import { proposeDomains } from '../../src/kernel/domains.mjs'; @@ -30,6 +31,33 @@ function fixture() { installProject({ repository, proposal, env }); return { root, env, parentOverride: join(root, 'worktrees') }; } +function snapshotTree(root) { + const records = []; + const visit = (path, relativePath) => { + const stat = lstatSync(path); + const record = { path: relativePath || '.', mode: stat.mode & 0o7777 }; + if (stat.isDirectory()) { + record.type = 'directory'; + records.push(record); + for (const name of readdirSync(path).sort()) visit(join(path, name), relativePath ? join(relativePath, name) : name); + } else if (stat.isSymbolicLink()) { + record.type = 'symlink'; + record.target = readlinkSync(path); + records.push(record); + } else if (stat.isFile()) { + const bytes = readFileSync(path); + record.type = 'file'; + record.size = bytes.length; + record.sha256 = createHash('sha256').update(bytes).digest('hex'); + records.push(record); + } else { + record.type = 'other'; + records.push(record); + } + }; + visit(root, ''); + return records; +} test('SCN-guided-setup: approved setup checkpoints owned files and provisions without agents', () => { const f = fixture(); @@ -79,6 +107,69 @@ test('SCN-worktree-canonical-brief: current instructions win and unregistered or assert.throws(() => resolveInstalledProjectRoot(entry.path, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); }); +test('SCN-setup-unrelated-vanished-sibling-tolerated: registered checkout resolves while an absent Git sibling remains registered', () => { + const f = fixture(); + setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }); + const entry = readInstallManifest(f.root).external.find(item => item.type === 'worktree'); + const missingSibling = join(f.parentOverride, 'vanished-sibling'); + const movedSibling = join(f.parentOverride, 'moved-sibling-fixture'); + git(f.root, ['worktree', 'add', '-b', 'vanished-sibling', missingSibling]); + const siblingAdminPath = resolve(missingSibling, git(missingSibling, ['rev-parse', '--git-dir'])); + renameSync(missingSibling, movedSibling); + assert.equal(existsSync(missingSibling), false); + + const manifestPath = join(f.root, '.torch/install-manifest.json'); + const stateRoot = readInstallManifest(f.root).external.find(item => item.type === 'local-state').path; + const manifestBefore = readFileSync(manifestPath, 'utf8'); + const stateBefore = snapshotTree(stateRoot); + const siblingAdminBefore = snapshotTree(siblingAdminPath); + const movedSiblingBefore = snapshotTree(movedSibling); + const worktreesBefore = git(f.root, ['worktree', 'list', '--porcelain']); + const refsBefore = git(f.root, ['for-each-ref', '--format=%(refname) %(objectname)']); + assert.ok(worktreesBefore.includes(`worktree ${missingSibling}`)); + assert.ok(refsBefore.split('\n').includes(`refs/heads/vanished-sibling ${git(f.root, ['rev-parse', 'refs/heads/vanished-sibling'])}`)); + + assert.equal(resolveInstalledProjectRoot(entry.path, f.env), f.root); + + assert.equal(existsSync(missingSibling), false); + assert.equal(readFileSync(manifestPath, 'utf8'), manifestBefore); + assert.deepEqual(snapshotTree(stateRoot), stateBefore); + assert.deepEqual(snapshotTree(siblingAdminPath), siblingAdminBefore); + assert.deepEqual(snapshotTree(movedSibling), movedSiblingBefore); + assert.equal(git(f.root, ['worktree', 'list', '--porcelain']), worktreesBefore); + assert.equal(git(f.root, ['for-each-ref', '--format=%(refname) %(objectname)']), refsBefore); + + assert.throws(() => resolveInstalledProjectRoot(join(f.parentOverride, 'missing-selected-root'), f.env), { code: 'ENOENT' }); + + const foreignRoot = mkdtempSync(join(tmpdir(), 'torch-foreign-worktree-')); + cpSync(join(entry.path, '.torch'), join(foreignRoot, '.torch'), { recursive: true }); + git(foreignRoot, ['init', '-b', entry.branch]); + const foreignManifestPath = join(foreignRoot, '.torch/install-manifest.json'); + const canonicalManifest = JSON.parse(manifestBefore); + const foreignManifest = JSON.parse(readFileSync(foreignManifestPath, 'utf8')); + foreignManifest.installationId = 'stale-installation'; + writeFileSync(foreignManifestPath, JSON.stringify(foreignManifest)); + assert.throws(() => resolveInstalledProjectRoot(foreignRoot, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + delete foreignManifest.installationId; + writeFileSync(foreignManifestPath, JSON.stringify(foreignManifest)); + assert.throws(() => resolveInstalledProjectRoot(foreignRoot, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + foreignManifest.installationId = canonicalManifest.installationId; + writeFileSync(foreignManifestPath, JSON.stringify(foreignManifest)); + assert.throws(() => resolveInstalledProjectRoot(foreignRoot, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + + const foreignRegistration = { ...entry, path: foreignRoot }; + canonicalManifest.external.push(foreignRegistration); + writeFileSync(manifestPath, JSON.stringify(canonicalManifest)); + try { + assert.throws(() => resolveInstalledProjectRoot(foreignRoot, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + } finally { + writeFileSync(manifestPath, manifestBefore); + } + + git(entry.path, ['switch', '-c', 'registered-branch-mismatch']); + assert.throws(() => resolveInstalledProjectRoot(entry.path, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); +}); + test('SCN-install-restore: detach and explicit restore preserve configuration and worktree history without starting sessions', () => { const f = fixture(); setupProject({ ...f, repository: inspectRepository(f.root), authorized: true });