From 8824d79f2a96f84b172249663f295d978c90bf89 Mon Sep 17 00:00:00 2001 From: Christopher Date: Tue, 23 Jun 2026 17:17:59 +1000 Subject: [PATCH 1/3] feat(workspace): read git_cache.mirrors from project-local .agentv/config.yaml Mirror map entries can now be defined in a project-local .agentv/config.yaml (nearest to the eval file, up to the repo root) and merge over the user-global ~/.agentv/config.yaml, with project entries taking precedence. This lets users point a workspace repo at a local clone without editing user-global config, replacing the workspace `mode: static` + `path` mechanism. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/evaluation/workspace/repo-manager.ts | 36 +++++- .../core/src/evaluation/workspace/setup.ts | 8 +- .../evaluation/workspace/repo-manager.test.ts | 116 ++++++++++++++++++ 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/packages/core/src/evaluation/workspace/repo-manager.ts b/packages/core/src/evaluation/workspace/repo-manager.ts index b56ad4517..59c1a61dd 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,36 @@ export class RepoManager { } } + private findProjectConfigPath(): string | undefined { + if (!this.projectConfigDir) return undefined; + + let current = path.resolve(this.projectConfigDir); + while (true) { + const candidate = path.join(current, '.agentv', 'config.yaml'); + if (existsSync(candidate)) return candidate; + + const reachedRepoRoot = existsSync(path.join(current, '.git')); + const parent = path.dirname(current); + if (reachedRepoRoot || parent === current) return undefined; + current = parent; + } + } + + private loadConfiguredMirrors(): Record { + const globalMirrors = this.loadConfiguredMirrorsFrom(configPath()); + const projectConfigPath = this.findProjectConfigPath(); + if (!projectConfigPath) return globalMirrors; + + const projectMirrors = this.loadConfiguredMirrorsFrom(projectConfigPath); + const projectRepoIdentities = new Set(Object.keys(projectMirrors).map(normalizeRepoIdentity)); + const globalWithoutProjectOverrides = Object.fromEntries( + Object.entries(globalMirrors).filter( + ([repo]) => !projectRepoIdentities.has(normalizeRepoIdentity(repo)), + ), + ); + return { ...globalWithoutProjectOverrides, ...projectMirrors }; + } + 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..e19f9c470 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,98 @@ 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('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'); From 6beb6d6471bc4f77bf30ee26bd30304161b3e310 Mon Sep 17 00:00:00 2001 From: Christopher Date: Tue, 23 Jun 2026 17:40:56 +1000 Subject: [PATCH 2/3] feat(workspace): support gitignored .agentv/config.override.yaml for mirrors Add a highest-precedence project-local override file (.agentv/config.override.yaml, intended to be gitignored) for git_cache.mirrors, layered over the committed .agentv/config.yaml and the user-global ~/.agentv/config.yaml. This lets users set machine-specific local mirror paths without editing tracked or user-global config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/evaluation/workspace/repo-manager.ts | 54 ++++++++++---- .../evaluation/workspace/repo-manager.test.ts | 71 +++++++++++++++++++ 2 files changed, 112 insertions(+), 13 deletions(-) diff --git a/packages/core/src/evaluation/workspace/repo-manager.ts b/packages/core/src/evaluation/workspace/repo-manager.ts index 59c1a61dd..5ffedb83b 100644 --- a/packages/core/src/evaluation/workspace/repo-manager.ts +++ b/packages/core/src/evaluation/workspace/repo-manager.ts @@ -266,13 +266,18 @@ export class RepoManager { } } - private findProjectConfigPath(): string | undefined { + private findProjectAgentvDir(): string | undefined { if (!this.projectConfigDir) return undefined; let current = path.resolve(this.projectConfigDir); while (true) { - const candidate = path.join(current, '.agentv', 'config.yaml'); - if (existsSync(candidate)) return candidate; + 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); @@ -281,19 +286,42 @@ export class RepoManager { } } + 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 projectConfigPath = this.findProjectConfigPath(); - if (!projectConfigPath) return globalMirrors; - - const projectMirrors = this.loadConfiguredMirrorsFrom(projectConfigPath); - const projectRepoIdentities = new Set(Object.keys(projectMirrors).map(normalizeRepoIdentity)); - const globalWithoutProjectOverrides = Object.fromEntries( - Object.entries(globalMirrors).filter( - ([repo]) => !projectRepoIdentities.has(normalizeRepoIdentity(repo)), - ), + 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 { ...globalWithoutProjectOverrides, ...projectMirrors }; + return this.mergeConfiguredMirrorLayers([ + globalMirrors, + projectMirrors, + projectOverrideMirrors, + ]); } private findConfiguredMirror(repoIdentity: string): string | undefined { diff --git a/packages/core/test/evaluation/workspace/repo-manager.test.ts b/packages/core/test/evaluation/workspace/repo-manager.test.ts index e19f9c470..5f3942d3b 100644 --- a/packages/core/test/evaluation/workspace/repo-manager.test.ts +++ b/packages/core/test/evaluation/workspace/repo-manager.test.ts @@ -173,6 +173,77 @@ describe('RepoManager', () => { ); }); + 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'); From 7c2d7a92eb4068eb0313cd02f64f0720f3974a07 Mon Sep 17 00:00:00 2001 From: Christopher Date: Tue, 23 Jun 2026 17:56:53 +1000 Subject: [PATCH 3/3] docs(eval-writer): document git_cache.mirrors and config.override.yaml Add git_cache to the config schema and note in the eval-writer skill that git_cache.mirrors resolves from user-global, committed project, and gitignored .agentv/config.override.yaml (highest precedence) layers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills-data/agentv-eval-writer/SKILL.md | 2 +- .../references/config-schema.json | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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": {