diff --git a/docs/design/agent-proximity.md b/docs/design/agent-proximity.md index c9d3e2b96..6aed3faad 100644 --- a/docs/design/agent-proximity.md +++ b/docs/design/agent-proximity.md @@ -149,3 +149,8 @@ the renderer draws), and pairwise `links` with risk (the edges to color). escalation wired to live session diffs. - v2: cross-machine airspace over Tailscale (teammate agents enter the same space); the recorded "N agents, M humans, zero merge conflicts" demo. + +The control pane compares each worktree against the merge base of its declared base +and HEAD. Committed, staged, and unstaged tracked edits carry changed line ranges. +Untracked files that Git does not ignore participate as whole-file working sets. +Changes made only on the base branch after the worktree diverged do not participate. diff --git a/scripts/lib/control-pane/proximity.js b/scripts/lib/control-pane/proximity.js index a958ef36f..1db91c81e 100644 --- a/scripts/lib/control-pane/proximity.js +++ b/scripts/lib/control-pane/proximity.js @@ -45,8 +45,8 @@ function parseDiffRanges(diff) { return byFile; } -function runGitDiff(worktreePath, base, extraArgs) { - return execFileSync('git', ['-C', worktreePath, 'diff', ...extraArgs, `${base}...HEAD`], { +function runGit(worktreePath, args) { + return execFileSync('git', ['-C', worktreePath, ...args], { encoding: 'utf8', timeout: 5000, maxBuffer: 8 * 1024 * 1024, @@ -54,6 +54,15 @@ function runGitDiff(worktreePath, base, extraArgs) { }); } +function runGitDiff(worktreePath, base, extraArgs) { + const mergeBase = runGit(worktreePath, ['merge-base', '--', base, 'HEAD']).trim(); + return runGit(worktreePath, ['diff', ...extraArgs, mergeBase, '--']); +} + +function untrackedFiles(worktreePath) { + return runGit(worktreePath, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean); +} + /** * Default working-set source: a session's worktree diff against its base, with * per-file changed line ranges. Returns [{ path, lines? }], or [] on failure so @@ -65,6 +74,7 @@ function defaultWorkingSetFor(session) { const base = wt.base || 'HEAD'; try { const ranges = parseDiffRanges(runGitDiff(wt.path, base, ['--unified=0'])); + for (const file of untrackedFiles(wt.path)) ranges.set(file, []); return [...ranges.entries()].map(([path, lines]) => (lines.length > 0 ? { path, lines } : { path })); } catch { return []; @@ -79,10 +89,7 @@ function defaultChangedFilesFor(session) { if (!wt || !wt.path) return []; const base = wt.base || 'HEAD'; try { - return runGitDiff(wt.path, base, ['--name-only']) - .split('\n') - .map(s => s.trim()) - .filter(Boolean); + return [...new Set([...runGitDiff(wt.path, base, ['--name-only', '-z']).split('\0').filter(Boolean), ...untrackedFiles(wt.path)])]; } catch { return []; } diff --git a/tests/lib/control-pane-proximity.test.js b/tests/lib/control-pane-proximity.test.js index c91603f46..790959c39 100644 --- a/tests/lib/control-pane-proximity.test.js +++ b/tests/lib/control-pane-proximity.test.js @@ -54,6 +54,55 @@ const changedFilesFor = session => 'no-worktree': [] })[session.id] || []; +test('real worktrees expose committed, staged, unstaged and untracked work before commit', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const { execFileSync } = require('child_process'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-proximity-worktree-')); + const repo = path.join(dir, 'repo'); + const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { stdio: 'pipe' }); + try { + fs.mkdirSync(repo); + git(repo, 'init', '-q', '-b', 'main'); + git(repo, 'config', 'user.name', 'proximity-test'); + git(repo, 'config', 'user.email', 'proximity-test@example.com'); + fs.writeFileSync(path.join(repo, 'shared.js'), 'const value = 1;\n'); + fs.writeFileSync(path.join(repo, '.gitignore'), 'ignored.js\n'); + git(repo, 'add', '.'); + git(repo, 'commit', '-qm', 'base'); + const worktrees = ['a', 'b'].map(name => { + const wt = path.join(dir, name); + git(repo, 'worktree', 'add', '-q', '-b', name, wt, 'main'); + fs.writeFileSync(path.join(wt, 'shared.js'), `const value = '${name}';\n`); + fs.writeFileSync(path.join(wt, 'new.js'), `export const name = '${name}';\n`); + fs.writeFileSync(path.join(wt, 'ignored.js'), 'ignored\n'); + return { id: name, worktree: { path: wt, base: 'main' } }; + }); + git(worktrees[0].worktree.path, 'add', 'shared.js'); + fs.writeFileSync(path.join(repo, 'upstream.js'), 'upstream only\n'); + git(repo, 'add', '.'); + git(repo, 'commit', '-qm', 'advance base'); + const assertWorkingSets = () => { + const agents = sessionsToAgents(worktrees); + assert.strictEqual(agents.length, 2); + for (const agent of agents) { + assert.deepStrictEqual(agent.files.map(f => f.path).sort(), ['new.js', 'shared.js']); + assert.deepStrictEqual(agent.files.find(f => f.path === 'shared.js').lines, [[1, 1]]); + assert.strictEqual(agent.files.find(f => f.path === 'new.js').lines, undefined); + } + const snapshot = buildProximitySnapshot(worktrees, { repoRoot: repo, graph: { adjacency: {} } }); + assert.strictEqual(snapshot.counts.agents, 2); + assert.ok(snapshot.advisories.some(a => a.level === 'resolution')); + }; + assertWorkingSets(); + git(worktrees[0].worktree.path, 'commit', '-qm', 'commit staged edit'); + assertWorkingSets(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('sessionsToAgents: only worktree sessions with edits participate', () => { const agents = sessionsToAgents(sessions, { changedFilesFor }); assert.deepStrictEqual(agents.map(a => a.agentId).sort(), ['docs-bot', 'lead-hermes', 'worker-kb']);