Skip to content
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
1 change: 1 addition & 0 deletions nested
Submodule nested added at 4ede62
119 changes: 104 additions & 15 deletions packages/core/src/evaluation/workspace/file-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@

import { exec as execCallback } from 'node:child_process';
import { readdirSync, statSync } from 'node:fs';
import { readFile, readdir, stat } from 'node:fs/promises';
import { copyFile, mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';

Expand All @@ -58,6 +59,25 @@ function gitExecOpts(workspacePath: string) {
return { cwd: workspacePath, env };
}

/**
* Execute git commands against an isolated temporary index.
* This keeps mutation out of the workspace user's real index.
*/
async function withTemporaryGitIndex<T>(
workspacePath: string,
run: (opts: { cwd: string; env: NodeJS.ProcessEnv }) => Promise<T>,
): Promise<T> {
const tempDir = await mkdtemp(path.join(tmpdir(), 'agentv-git-index-'));
try {
return await run({
...gitExecOpts(workspacePath),
env: { ...gitExecOpts(workspacePath).env, GIT_INDEX_FILE: path.join(tempDir, 'index') },
});
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}

/**
* Initialize a git baseline for workspace file change tracking.
*
Expand All @@ -67,6 +87,25 @@ function gitExecOpts(workspacePath: string) {
export async function initializeBaseline(workspacePath: string): Promise<string> {
const opts = gitExecOpts(workspacePath);

try {
const { stdout: insideWorkTree } = await execAsync('git rev-parse --is-inside-work-tree', opts);
if (insideWorkTree.trim() === 'true') {
const { stdout: head } = await execAsync('git rev-parse HEAD', opts);
const baselineHead = head.trim();
const { stdout: status } = await execAsync(
'git status --porcelain --untracked-files=all',
opts,
);

if (status.trim() === '') {
return baselineHead;
}
return await createPrivateBaselineCommit(workspacePath, baselineHead);
}
} catch {
// Not already a Git worktree; fall through to create a private workspace repo.
}

await execAsync('git init', opts);
await execAsync('git add -A', opts);
await execAsync(
Expand All @@ -78,6 +117,27 @@ export async function initializeBaseline(workspacePath: string): Promise<string>
return stdout.trim();
}

async function createPrivateBaselineCommit(
workspacePath: string,
parentCommit: string,
): Promise<string> {
return withTemporaryGitIndex(workspacePath, async (opts) => {
await execAsync(`git read-tree ${parentCommit}`, opts);
await execAsync('git add -A', opts);
const { stdout: tree } = await execAsync('git write-tree', opts);
const { stdout: commit } = await execAsync(
`git -c user.email=agentv@localhost -c user.name=agentv commit-tree ${tree.trim()} -p ${parentCommit} -m "agentv-baseline"`,
opts,
);
const commitHash = commit.trim();
await execAsync(
`git update-ref refs/agentv/workspace-baselines/${commitHash} ${commitHash}`,
opts,
);
return commitHash;
});
}

/**
* Capture file changes from workspace relative to the baseline commit.
* Returns a unified diff string, or empty string if no changes.
Expand All @@ -90,32 +150,35 @@ export async function captureFileChanges(
workspacePath: string,
baselineCommit: string,
): Promise<string> {
const opts = gitExecOpts(workspacePath);

// Stage new files in nested repos so they appear in the submodule diff
await stageNestedRepoChanges(workspacePath);

// Stage parent-level changes
await execAsync('git add -A', opts);

// Use --submodule=diff to expand nested repo changes into individual file diffs
const { stdout } = await execAsync(`git diff ${baselineCommit} --submodule=diff`, opts);

return stdout.trim();
return withNestedRepoChanges(workspacePath, async () => {
return withTemporaryGitIndex(workspacePath, async (opts) => {
await execAsync(`git read-tree ${baselineCommit}`, opts);
await execAsync('git add -A', opts);
const { stdout } = await execAsync(`git diff ${baselineCommit} --submodule=diff`, opts);
return stdout.trim();
});
});
}

/**
* Find immediate child directories that contain a `.git/` directory
* and stage all their changes so they appear in the parent's submodule diff.
*/
async function stageNestedRepoChanges(workspacePath: string): Promise<void> {
async function withNestedRepoChanges<T>(workspacePath: string, run: () => Promise<T>): Promise<T> {
let entries: string[];
try {
entries = readdirSync(workspacePath);
} catch {
return;
return run();
}

const stagedNestedRepos: Array<{
nestedIndexPath: string;
backupPath: string;
backupDir: string;
hadBackup: boolean;
}> = [];

for (const entry of entries) {
if (entry === '.git' || entry === 'node_modules') continue;
const childPath = path.join(workspacePath, entry);
Expand All @@ -126,9 +189,35 @@ async function stageNestedRepoChanges(workspacePath: string): Promise<void> {
continue;
}
// Stage all files in the nested repo
const nestedIndexPath = path.join(childPath, '.git', 'index');
const childOpts = gitExecOpts(childPath);
const backupDir = await mkdtemp(path.join(tmpdir(), 'agentv-nested-index-'));
const nestedIndexBackup = path.join(backupDir, 'index');
const hadNestedIndex = await copyFile(nestedIndexPath, nestedIndexBackup)
.then(() => true)
.catch(() => false);
stagedNestedRepos.push({
nestedIndexPath,
backupPath: nestedIndexBackup,
backupDir,
hadBackup: hadNestedIndex,
});
await execAsync('git read-tree HEAD', childOpts);
await execAsync('git add -A', childOpts);
}

try {
return await run();
} finally {
for (const staged of stagedNestedRepos) {
if (staged.hadBackup) {
await copyFile(staged.backupPath, staged.nestedIndexPath).catch(() => {});
} else {
await rm(staged.nestedIndexPath, { force: true }).catch(() => {});
}
await rm(staged.backupDir, { recursive: true, force: true }).catch(() => {});
}
}
}

// ─── Snapshot baseline ───────────────────────────────────────────────────────
Expand Down
95 changes: 95 additions & 0 deletions packages/core/test/evaluation/workspace/file-changes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
// Clean env for git commands — strip GIT_DIR/GIT_WORK_TREE so tests
// don't accidentally target the parent repo (e.g. when run from git hooks).
const { GIT_DIR: _, GIT_WORK_TREE: __, ...cleanEnv } = process.env;
function git(command: string, cwd: string): string {
return execSync(command, { cwd, env: cleanEnv, encoding: 'utf8' }).trim();
}

describe('workspace file-changes', () => {
let workspacePath: string;
Expand All @@ -38,6 +41,98 @@ describe('workspace file-changes', () => {
expect(baselineCommit).toMatch(/^[0-9a-f]{40}$/);
});

it('uses the AgentV identity for newly initialized workspace repos', async () => {
const baselineCommit = await initializeBaseline(workspacePath);

expect(git(`git show -s --format='%an <%ae>' ${baselineCommit}`, workspacePath)).toBe(
'agentv <agentv@localhost>',
);
expect(git(`git show -s --format='%cn <%ce>' ${baselineCommit}`, workspacePath)).toBe(
'agentv <agentv@localhost>',
);
});

it('uses HEAD without creating a baseline commit in clean existing repos', async () => {
git('git init', workspacePath);
git('git config user.name "Existing Repo User"', workspacePath);
git('git config user.email "existing@example.test"', workspacePath);
git('git add -A', workspacePath);
git('git commit -m "existing baseline"', workspacePath);
const originalHead = git('git rev-parse HEAD', workspacePath);
const originalRef = git('git symbolic-ref --short HEAD', workspacePath);

const baselineCommit = await initializeBaseline(workspacePath);

expect(baselineCommit).toBe(originalHead);
expect(git('git rev-list --count HEAD', workspacePath)).toBe('1');
expect(git('git log -1 --format=%s', workspacePath)).toBe('existing baseline');
expect(git('git symbolic-ref --short HEAD', workspacePath)).toBe(originalRef);
});

it('creates a private baseline commit in dirty existing repos without moving HEAD', async () => {
git('git init', workspacePath);
git('git config user.name "Existing Repo User"', workspacePath);
git('git config user.email "existing@example.test"', workspacePath);
await writeFile(path.join(workspacePath, 'tracked.txt'), 'tracked\n', 'utf8');
git('git add -A', workspacePath);
git('git commit -m "existing baseline"', workspacePath);
const originalHead = git('git rev-parse HEAD', workspacePath);
const originalRef = git('git symbolic-ref --short HEAD', workspacePath);

// Introduce pre-existing dirt
await writeFile(path.join(workspacePath, 'hello.txt'), 'pre-existing dirty change\n', 'utf8');

const baselineCommit = await initializeBaseline(workspacePath);
const refList = git('git for-each-ref --format="%(refname:short)" refs/agentv', workspacePath);

expect(git('git rev-parse HEAD', workspacePath)).toBe(originalHead);
expect(git('git symbolic-ref --short HEAD', workspacePath)).toBe(originalRef);
expect(git('git log -1 --format=%s', workspacePath)).toBe('existing baseline');
expect(git(`git show -s --format='%an <%ae>' ${baselineCommit}`, workspacePath)).toBe(
'agentv <agentv@localhost>',
);
expect(refList).toContain(`workspace-baselines/${baselineCommit}`);
expect(git('git status --short --untracked-files=all', workspacePath)).toContain('M hello.txt');
});

it('captureFileChanges excludes pre-existing dirty state and includes only target edits', async () => {
git('git init', workspacePath);
git('git config user.name "Existing Repo User"', workspacePath);
git('git config user.email "existing@example.test"', workspacePath);
await writeFile(path.join(workspacePath, 'tracked.txt'), 'tracked baseline\n', 'utf8');
await writeFile(path.join(workspacePath, 'delete-me.txt'), 'to delete\n', 'utf8');
git('git add -A', workspacePath);
git('git commit -m "existing baseline"', workspacePath);

await writeFile(path.join(workspacePath, 'hello.txt'), 'pre-existing dirty change\n', 'utf8');
await writeFile(path.join(workspacePath, 'pre-existing-untracked.txt'), 'ignore me\n', 'utf8');

const baselineCommit = await initializeBaseline(workspacePath);

await writeFile(path.join(workspacePath, 'hello.txt'), 'post-baseline change\n', 'utf8');
await writeFile(path.join(workspacePath, 'new.txt'), 'new file\n', 'utf8');
await rm(path.join(workspacePath, 'delete-me.txt'));

const diff = await captureFileChanges(workspacePath, baselineCommit);

expect(diff).toContain('hello.txt');
expect(diff).toContain('post-baseline change');
expect(diff).toContain('new.txt');
expect(diff).toContain('new file');
expect(diff).toContain('delete-me.txt');
expect(diff).toContain('pre-existing dirty change');
expect(diff).toContain('post-baseline change');
expect(diff).not.toContain('pre-existing-untracked.txt');
expect(git('git status --short --untracked-files=all', workspacePath)).toContain('M hello.txt');
expect(git('git status --short --untracked-files=all', workspacePath)).toContain(
'D delete-me.txt',
);
expect(git('git status --short --untracked-files=all', workspacePath)).toContain('?? new.txt');
expect(git('git status --short --untracked-files=all', workspacePath)).toContain(
'?? pre-existing-untracked.txt',
);
});

it('captureFileChanges detects added/modified/deleted files', async () => {
const baselineCommit = await initializeBaseline(workspacePath);

Expand Down
1 change: 1 addition & 0 deletions root.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root
Loading