Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions packages/core/src/evaluation/workspace/repo-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface RepoManagerOptions {
readonly progress?: boolean;
readonly heartbeatMs?: number;
readonly timeoutMs?: number;
readonly projectConfigDir?: string;
}

interface AcquisitionSource {
Expand Down Expand Up @@ -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<string> {
Expand Down Expand Up @@ -241,8 +244,7 @@ export class RepoManager {
});
}

private loadConfiguredMirrors(): Record<string, string> {
const filePath = configPath();
private loadConfiguredMirrorsFrom(filePath: string): Record<string, string> {
if (!existsSync(filePath)) return {};
try {
const parsed = parseYamlValue(readFileSync(filePath, 'utf-8')) as unknown;
Expand All @@ -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<string, string>>,
): Record<string, string> {
const mirrorsByRepo = new Map<string, string>();
const repoByIdentity = new Map<string, string>();

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<string, string> {
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)) {
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/evaluation/workspace/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down Expand Up @@ -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) &&
Expand Down Expand Up @@ -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(
Expand Down
187 changes: 187 additions & 0 deletions packages/core/test/evaluation/workspace/repo-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ function cachePathFor(repo: string): string {
return path.join(dataDir, 'git-cache', hash);
}

function writeMirrorConfig(configFilePath: string, mirrors: Record<string, string>): 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;
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion skills-data/agentv-eval-writer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions skills-data/agentv-eval-writer/references/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading