diff --git a/nested b/nested new file mode 160000 index 000000000..4ede622bd --- /dev/null +++ b/nested @@ -0,0 +1 @@ +Subproject commit 4ede622bd6b590ced6ead3d09cc087aba975b3b1 diff --git a/packages/core/src/evaluation/workspace/file-changes.ts b/packages/core/src/evaluation/workspace/file-changes.ts index 028c16b4a..35d52ad25 100644 --- a/packages/core/src/evaluation/workspace/file-changes.ts +++ b/packages/core/src/evaluation/workspace/file-changes.ts @@ -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'; @@ -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( + workspacePath: string, + run: (opts: { cwd: string; env: NodeJS.ProcessEnv }) => Promise, +): Promise { + 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. * @@ -67,6 +87,25 @@ function gitExecOpts(workspacePath: string) { export async function initializeBaseline(workspacePath: string): Promise { 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( @@ -78,6 +117,27 @@ export async function initializeBaseline(workspacePath: string): Promise return stdout.trim(); } +async function createPrivateBaselineCommit( + workspacePath: string, + parentCommit: string, +): Promise { + 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. @@ -90,32 +150,35 @@ export async function captureFileChanges( workspacePath: string, baselineCommit: string, ): Promise { - 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 { +async function withNestedRepoChanges(workspacePath: string, run: () => Promise): Promise { 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); @@ -126,9 +189,35 @@ async function stageNestedRepoChanges(workspacePath: string): Promise { 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 ─────────────────────────────────────────────────────── diff --git a/packages/core/test/evaluation/workspace/file-changes.test.ts b/packages/core/test/evaluation/workspace/file-changes.test.ts index 5b77d016e..ab2dca629 100644 --- a/packages/core/test/evaluation/workspace/file-changes.test.ts +++ b/packages/core/test/evaluation/workspace/file-changes.test.ts @@ -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; @@ -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 ', + ); + expect(git(`git show -s --format='%cn <%ce>' ${baselineCommit}`, workspacePath)).toBe( + 'agentv ', + ); + }); + + 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 ', + ); + 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); diff --git a/root.txt b/root.txt new file mode 100644 index 000000000..d8649da39 --- /dev/null +++ b/root.txt @@ -0,0 +1 @@ +root