diff --git a/packages/core/src/evaluation/workspace/repo-manager.ts b/packages/core/src/evaluation/workspace/repo-manager.ts index b56ad4517..5ffedb83b 100644 --- a/packages/core/src/evaluation/workspace/repo-manager.ts +++ b/packages/core/src/evaluation/workspace/repo-manager.ts @@ -31,6 +31,7 @@ interface RepoManagerOptions { readonly progress?: boolean; readonly heartbeatMs?: number; readonly timeoutMs?: number; + readonly projectConfigDir?: string; } interface AcquisitionSource { @@ -114,12 +115,14 @@ export class RepoManager { private readonly progress: boolean; private readonly heartbeatMs: number; private readonly timeoutMs: number; + private readonly projectConfigDir?: string; constructor(verbose = false, options: RepoManagerOptions = {}) { this.verbose = verbose; this.progress = options.progress ?? true; this.heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.projectConfigDir = options.projectConfigDir; } private async runGit(args: string[], opts?: GitRunOptions): Promise { @@ -241,8 +244,7 @@ export class RepoManager { }); } - private loadConfiguredMirrors(): Record { - const filePath = configPath(); + private loadConfiguredMirrorsFrom(filePath: string): Record { if (!existsSync(filePath)) return {}; try { const parsed = parseYamlValue(readFileSync(filePath, 'utf-8')) as unknown; @@ -264,6 +266,64 @@ export class RepoManager { } } + private findProjectAgentvDir(): string | undefined { + if (!this.projectConfigDir) return undefined; + + let current = path.resolve(this.projectConfigDir); + while (true) { + const candidateDir = path.join(current, '.agentv'); + if ( + existsSync(path.join(candidateDir, 'config.yaml')) || + existsSync(path.join(candidateDir, 'config.override.yaml')) + ) { + return candidateDir; + } + + const reachedRepoRoot = existsSync(path.join(current, '.git')); + const parent = path.dirname(current); + if (reachedRepoRoot || parent === current) return undefined; + current = parent; + } + } + + private mergeConfiguredMirrorLayers( + layers: Array>, + ): Record { + const mirrorsByRepo = new Map(); + const repoByIdentity = new Map(); + + for (const layer of layers) { + for (const [repo, localPath] of Object.entries(layer)) { + const repoIdentity = normalizeRepoIdentity(repo); + const previousRepo = repoByIdentity.get(repoIdentity); + if (previousRepo) mirrorsByRepo.delete(previousRepo); + + repoByIdentity.set(repoIdentity, repo); + mirrorsByRepo.set(repo, localPath); + } + } + + return Object.fromEntries(mirrorsByRepo); + } + + private loadConfiguredMirrors(): Record { + const globalMirrors = this.loadConfiguredMirrorsFrom(configPath()); + const projectAgentvDir = this.findProjectAgentvDir(); + if (!projectAgentvDir) return globalMirrors; + + const projectMirrors = this.loadConfiguredMirrorsFrom( + path.join(projectAgentvDir, 'config.yaml'), + ); + const projectOverrideMirrors = this.loadConfiguredMirrorsFrom( + path.join(projectAgentvDir, 'config.override.yaml'), + ); + return this.mergeConfiguredMirrorLayers([ + globalMirrors, + projectMirrors, + projectOverrideMirrors, + ]); + } + private findConfiguredMirror(repoIdentity: string): string | undefined { const mirrors = this.loadConfiguredMirrors(); for (const [repo, localPath] of Object.entries(mirrors)) { diff --git a/packages/core/src/evaluation/workspace/setup.ts b/packages/core/src/evaluation/workspace/setup.ts index bc4a57fed..db5251b08 100644 --- a/packages/core/src/evaluation/workspace/setup.ts +++ b/packages/core/src/evaluation/workspace/setup.ts @@ -395,7 +395,7 @@ export async function prepareSharedWorkspaceSetup( const slotsNeeded = workers; setupLog(`acquiring ${slotsNeeded} workspace pool slot(s) (pool capacity: ${poolMaxSlots})`); poolManager = new WorkspacePoolManager(getWorkspacePoolRoot()); - const poolRepoManager = new RepoManager(verbose); + const poolRepoManager = new RepoManager(verbose, { projectConfigDir: evalDir }); repoManager = poolRepoManager; for (let i = 0; i < slotsNeeded; i++) { @@ -459,7 +459,9 @@ export async function prepareSharedWorkspaceSetup( hasReposToMaterialize && useStaticWorkspace && !staticMaterialised && isYamlConfiguredPath; repoManager = repoManager ?? - (needsRepoMaterialisation || needsPerRepoCheck ? new RepoManager(verbose) : undefined); + (needsRepoMaterialisation || needsPerRepoCheck + ? new RepoManager(verbose, { projectConfigDir: evalDir }) + : undefined); if ( (needsRepoMaterialisation || needsPerRepoCheck) && @@ -857,7 +859,7 @@ export async function prepareEvalCaseWorkspace( } if (evalCase.workspace?.repos?.length && workspacePath) { - const perCaseRepoManager = new RepoManager(setupDebug); + const perCaseRepoManager = new RepoManager(setupDebug, { projectConfigDir: evalDir }); try { if (setupDebug) { console.log( diff --git a/packages/core/test/evaluation/workspace/repo-manager.test.ts b/packages/core/test/evaluation/workspace/repo-manager.test.ts index 2948557b7..5f3942d3b 100644 --- a/packages/core/test/evaluation/workspace/repo-manager.test.ts +++ b/packages/core/test/evaluation/workspace/repo-manager.test.ts @@ -71,6 +71,30 @@ function cachePathFor(repo: string): string { return path.join(dataDir, 'git-cache', hash); } +function writeMirrorConfig(configFilePath: string, mirrors: Record): void { + mkdirSync(path.dirname(configFilePath), { recursive: true }); + writeFileSync( + configFilePath, + [ + 'git_cache:', + ' mirrors:', + ...Object.entries(mirrors).map( + ([repo, localPath]) => ` ${JSON.stringify(repo)}: ${JSON.stringify(localPath)}`, + ), + '', + ].join('\n'), + ); +} + +function findConfiguredMirrorFor(manager: RepoManager, repo: string): string | undefined { + const findConfiguredMirror = ( + manager as unknown as { + findConfiguredMirror(repoIdentity: string): string | undefined; + } + ).findConfiguredMirror.bind(manager); + return findConfiguredMirror(normalizeRepoIdentity(repo)); +} + describe('RepoManager', () => { let tmpDir: string; let workspaceDir: string; @@ -103,6 +127,169 @@ describe('RepoManager', () => { await rm(tmpDir, { recursive: true, force: true }); }); + describe('configured mirrors', () => { + it('loads project-local mirrors only when projectConfigDir is set', () => { + const projectDir = path.join(tmpDir, 'project'); + const evalDir = path.join(projectDir, 'evals', 'cases'); + const mirrorDir = path.join(tmpDir, 'project-mirror'); + mkdirSync(path.join(projectDir, '.git'), { recursive: true }); + mkdirSync(evalDir, { recursive: true }); + mkdirSync(mirrorDir, { recursive: true }); + writeMirrorConfig(path.join(projectDir, '.agentv', 'config.yaml'), { + 'https://github.com/example/project-only.git': mirrorDir, + }); + + expect( + findConfiguredMirrorFor(manager, 'https://github.com/example/project-only.git'), + ).toBeUndefined(); + + const projectManager = new RepoManager(false, { progress: false, projectConfigDir: evalDir }); + expect( + findConfiguredMirrorFor(projectManager, 'https://github.com/example/project-only.git'), + ).toBe(mirrorDir); + }); + + it('prefers project-local mirrors over global mirrors for the same normalized repo key', () => { + const homeDir = process.env.AGENTV_HOME; + if (!homeDir) throw new Error('AGENTV_HOME not set'); + const projectDir = path.join(tmpDir, 'project'); + const evalDir = path.join(projectDir, 'evals'); + const globalMirrorDir = path.join(tmpDir, 'global-mirror'); + const projectMirrorDir = path.join(tmpDir, 'project-mirror'); + mkdirSync(path.join(projectDir, '.git'), { recursive: true }); + mkdirSync(evalDir, { recursive: true }); + mkdirSync(globalMirrorDir, { recursive: true }); + mkdirSync(projectMirrorDir, { recursive: true }); + writeMirrorConfig(path.join(homeDir, 'config.yaml'), { + 'https://github.com/example/shared.git': globalMirrorDir, + }); + writeMirrorConfig(path.join(projectDir, '.agentv', 'config.yaml'), { + 'git@github.com:example/shared.git': projectMirrorDir, + }); + + const projectManager = new RepoManager(false, { progress: false, projectConfigDir: evalDir }); + expect(findConfiguredMirrorFor(projectManager, 'https://github.com/example/shared.git')).toBe( + projectMirrorDir, + ); + }); + + it('loads project-local override mirrors without a committed project config', () => { + const projectDir = path.join(tmpDir, 'project'); + const evalDir = path.join(projectDir, 'evals'); + const overrideMirrorDir = path.join(tmpDir, 'override-only-mirror'); + mkdirSync(path.join(projectDir, '.git'), { recursive: true }); + mkdirSync(evalDir, { recursive: true }); + mkdirSync(overrideMirrorDir, { recursive: true }); + writeMirrorConfig(path.join(projectDir, '.agentv', 'config.override.yaml'), { + 'https://github.com/example/override-only.git': overrideMirrorDir, + }); + + const projectManager = new RepoManager(false, { progress: false, projectConfigDir: evalDir }); + expect( + findConfiguredMirrorFor(projectManager, 'https://github.com/example/override-only.git'), + ).toBe(overrideMirrorDir); + }); + + it('prefers override mirrors over committed and global mirrors for the same normalized repo key', () => { + const homeDir = process.env.AGENTV_HOME; + if (!homeDir) throw new Error('AGENTV_HOME not set'); + const projectDir = path.join(tmpDir, 'project'); + const evalDir = path.join(projectDir, 'evals'); + const globalMirrorDir = path.join(tmpDir, 'global-mirror'); + const projectMirrorDir = path.join(tmpDir, 'project-mirror'); + const overrideMirrorDir = path.join(tmpDir, 'override-mirror'); + mkdirSync(path.join(projectDir, '.git'), { recursive: true }); + mkdirSync(evalDir, { recursive: true }); + mkdirSync(globalMirrorDir, { recursive: true }); + mkdirSync(projectMirrorDir, { recursive: true }); + mkdirSync(overrideMirrorDir, { recursive: true }); + writeMirrorConfig(path.join(homeDir, 'config.yaml'), { + 'https://github.com/example/shared.git': globalMirrorDir, + }); + writeMirrorConfig(path.join(projectDir, '.agentv', 'config.yaml'), { + 'git@github.com:example/shared.git': projectMirrorDir, + }); + writeMirrorConfig(path.join(projectDir, '.agentv', 'config.override.yaml'), { + 'https://github.com/Example/Shared': overrideMirrorDir, + }); + + const projectManager = new RepoManager(false, { progress: false, projectConfigDir: evalDir }); + expect(findConfiguredMirrorFor(projectManager, 'https://github.com/example/shared.git')).toBe( + overrideMirrorDir, + ); + }); + + it('merges override mirrors with different committed project mirror entries', () => { + const projectDir = path.join(tmpDir, 'project'); + const evalDir = path.join(projectDir, 'evals'); + const projectMirrorDir = path.join(tmpDir, 'project-only-mirror'); + const overrideMirrorDir = path.join(tmpDir, 'override-only-mirror'); + mkdirSync(path.join(projectDir, '.git'), { recursive: true }); + mkdirSync(evalDir, { recursive: true }); + mkdirSync(projectMirrorDir, { recursive: true }); + mkdirSync(overrideMirrorDir, { recursive: true }); + writeMirrorConfig(path.join(projectDir, '.agentv', 'config.yaml'), { + 'https://github.com/example/project-only.git': projectMirrorDir, + }); + writeMirrorConfig(path.join(projectDir, '.agentv', 'config.override.yaml'), { + 'https://github.com/example/override-only.git': overrideMirrorDir, + }); + + const projectManager = new RepoManager(false, { progress: false, projectConfigDir: evalDir }); + expect( + findConfiguredMirrorFor(projectManager, 'https://github.com/example/project-only.git'), + ).toBe(projectMirrorDir); + expect( + findConfiguredMirrorFor(projectManager, 'https://github.com/example/override-only.git'), + ).toBe(overrideMirrorDir); + }); + + it('merges different project-local and global mirror entries', () => { + const homeDir = process.env.AGENTV_HOME; + if (!homeDir) throw new Error('AGENTV_HOME not set'); + const projectDir = path.join(tmpDir, 'project'); + const evalDir = path.join(projectDir, 'evals'); + const globalMirrorDir = path.join(tmpDir, 'global-only-mirror'); + const projectMirrorDir = path.join(tmpDir, 'project-only-mirror'); + mkdirSync(path.join(projectDir, '.git'), { recursive: true }); + mkdirSync(evalDir, { recursive: true }); + mkdirSync(globalMirrorDir, { recursive: true }); + mkdirSync(projectMirrorDir, { recursive: true }); + writeMirrorConfig(path.join(homeDir, 'config.yaml'), { + 'https://github.com/example/global-only.git': globalMirrorDir, + }); + writeMirrorConfig(path.join(projectDir, '.agentv', 'config.yaml'), { + 'https://github.com/example/project-only.git': projectMirrorDir, + }); + + const projectManager = new RepoManager(false, { progress: false, projectConfigDir: evalDir }); + expect( + findConfiguredMirrorFor(projectManager, 'https://github.com/example/global-only.git'), + ).toBe(globalMirrorDir); + expect( + findConfiguredMirrorFor(projectManager, 'https://github.com/example/project-only.git'), + ).toBe(projectMirrorDir); + }); + + it('does not load project-local config above the .git repo-root boundary', () => { + const outerDir = path.join(tmpDir, 'outer'); + const projectDir = path.join(outerDir, 'repo'); + const evalDir = path.join(projectDir, 'evals'); + const outerMirrorDir = path.join(tmpDir, 'outer-mirror'); + mkdirSync(path.join(projectDir, '.git'), { recursive: true }); + mkdirSync(evalDir, { recursive: true }); + mkdirSync(outerMirrorDir, { recursive: true }); + writeMirrorConfig(path.join(outerDir, '.agentv', 'config.yaml'), { + 'https://github.com/example/outer.git': outerMirrorDir, + }); + + const projectManager = new RepoManager(false, { progress: false, projectConfigDir: evalDir }); + expect( + findConfiguredMirrorFor(projectManager, 'https://github.com/example/outer.git'), + ).toBeUndefined(); + }); + }); + describe('materialize', () => { it('clones repo into workspace at specified path', async () => { const repoDir = path.join(tmpDir, 'source-repo'); diff --git a/skills-data/agentv-eval-writer/SKILL.md b/skills-data/agentv-eval-writer/SKILL.md index d823d930a..ca57ac09f 100644 --- a/skills-data/agentv-eval-writer/SKILL.md +++ b/skills-data/agentv-eval-writer/SKILL.md @@ -318,7 +318,7 @@ For SWE-bench-style evals, keep operational checkout state under `workspace.repo ### Repository Lifecycle -Materialize repos into the eval workspace automatically. Repo entries declare identity and checkout pins only; AgentV resolves acquisition from registered projects, `git_cache.mirrors`, its mirror cache, then remote clone. For shared repo workspaces, pooling is the default: +Materialize repos into the eval workspace automatically. Repo entries declare identity and checkout pins only; AgentV resolves acquisition from registered projects, `git_cache.mirrors`, its mirror cache, then remote clone. `git_cache.mirrors` may be defined in `$AGENTV_HOME/config.yaml`, the project's committed `.agentv/config.yaml`, or a gitignored `.agentv/config.override.yaml` (highest precedence) — use the override for machine-specific local clone paths without editing tracked or user-global config. For shared repo workspaces, pooling is the default: ```yaml workspace: diff --git a/skills-data/agentv-eval-writer/references/config-schema.json b/skills-data/agentv-eval-writer/references/config-schema.json index babedf934..3a75a1ea4 100644 --- a/skills-data/agentv-eval-writer/references/config-schema.json +++ b/skills-data/agentv-eval-writer/references/config-schema.json @@ -66,6 +66,22 @@ } }, "additionalProperties": false + }, + "git_cache": { + "type": "object", + "description": "Local mirror configuration for workspace repo materialization. Mirrors are an optimization: when a repo's mirror path exists locally, AgentV materializes the pinned commit from it instead of cloning from GitHub. Resolvable from three layers, highest precedence first: the project-local '.agentv/config.override.yaml' (intended to be gitignored, for machine-specific paths), the committed project '.agentv/config.yaml', then the user-global '$AGENTV_HOME/config.yaml'. Higher layers override lower ones by normalized repo identity.", + "properties": { + "mirrors": { + "type": "object", + "description": "Map of repo identity (full clone URL or 'org/name' shorthand) to a local clone path used as a mirror. If the path is absent at runtime, AgentV falls back to cloning from GitHub with configured credentials.", + "additionalProperties": { + "type": "string", + "description": "Local path to a clone of the repo (supports '~' home expansion)." + }, + "examples": [{ "https://github.com/org/repo.git": "/path/to/local/repo" }] + } + }, + "additionalProperties": false } }, "definitions": {