From f7d7532d651dc6e4ff878725ce9dea86f996789b Mon Sep 17 00:00:00 2001 From: Huanyi Xie <74809554+xiehuanyi@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:05:58 +0300 Subject: [PATCH] fix(proximity): decode quoted paths with precise line ranges --- scripts/lib/control-pane/proximity.js | 37 ++++++++++++++++--- tests/lib/control-pane-proximity.test.js | 46 ++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/scripts/lib/control-pane/proximity.js b/scripts/lib/control-pane/proximity.js index 63de333ed..a4525f41e 100644 --- a/scripts/lib/control-pane/proximity.js +++ b/scripts/lib/control-pane/proximity.js @@ -15,6 +15,34 @@ const { execFileSync } = require('child_process'); const { scanAirspace, buildProximityTriggers } = require('../agent-proximity'); const { buildDependencyGraph } = require('../agent-proximity/graph'); +/** Decode Git C-style path escapes as bytes before UTF-8 decoding. */ +function decodeGitPath(header) { + const value = header.replace(/\t$/, ''); + if (!value.startsWith('"')) return value; + if (!value.endsWith('"')) return null; + const parts = []; + let literal = ''; + const escaped = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 }; + for (let i = 1; i < value.length - 1; i += 1) { + const character = value[i]; + if (character !== '\\') { literal += character; continue; } + if (literal) { parts.push(Buffer.from(literal, 'utf8')); literal = ''; } + const next = value[++i]; + if (/[0-7]/.test(next || '')) { + let octal = next; + while (octal.length < 3 && /[0-7]/.test(value[i + 1] || '')) octal += value[++i]; + const byte = parseInt(octal, 8); + if (byte > 255) return null; + parts.push(Buffer.from([byte])); + } else { + if (!Object.prototype.hasOwnProperty.call(escaped, next)) return null; + parts.push(Buffer.from([escaped[next]])); + } + } + if (literal) parts.push(Buffer.from(literal, 'utf8')); + return Buffer.concat(parts).toString('utf8'); +} + /** * Parse `git diff --unified=0` output into per-file NEW-side line ranges. Hunk * headers look like `@@ -a,b +c,d @@`; we keep the +c,d (new) side so the overlap @@ -32,10 +60,9 @@ function parseDiffRanges(diff) { current = null; continue; } - const fileMatch = line.match(/^\+\+\+ b\/(.+)$/); - if (fileMatch) { - const name = fileMatch[1].replace(/\t$/, ''); - current = name === '/dev/null' ? null : name; + if (line.startsWith('+++ ')) { + const gitPath = decodeGitPath(line.slice(4)); + current = gitPath && gitPath.startsWith('b/') ? gitPath.slice(2) : null; if (current && !byFile.has(current)) byFile.set(current, []); continue; } @@ -60,7 +87,7 @@ function runGit(worktreePath, args) { function runGitDiff(worktreePath, base, extraArgs) { const mergeBase = runGit(worktreePath, ['merge-base', '--', base, 'HEAD']).trim(); - return runGit(worktreePath, ['diff', ...extraArgs, mergeBase, '--']); + return runGit(worktreePath, ['-c', 'core.quotePath=false', 'diff', ...extraArgs, mergeBase, '--']); } function untrackedFiles(worktreePath) { diff --git a/tests/lib/control-pane-proximity.test.js b/tests/lib/control-pane-proximity.test.js index 74978efaf..1104214bc 100644 --- a/tests/lib/control-pane-proximity.test.js +++ b/tests/lib/control-pane-proximity.test.js @@ -148,7 +148,7 @@ test('quoted staged paths and untracked paths share identity, and untracked fail fs.writeFileSync(path.join(repo, 'README'), 'base\n'); git(repo, 'add', '.'); git(repo, 'commit', '-qm', 'base'); - const file = 'café"résumé.js'; + const file = process.platform === 'win32' ? 'café-résumé.js' : 'café"résumé.js'; const worktrees = ['a', 'b'].map(name => { const wt = path.join(dir, name); git(repo, 'worktree', 'add', '-q', '-b', name, wt, 'main'); @@ -177,7 +177,7 @@ test('quoted staged paths and untracked paths share identity, and untracked fail }; delete require.cache[modulePath]; const partial = require(modulePath).sessionsToAgents([worktrees[0]]); - assert.deepStrictEqual(partial[0].files.map(entry => entry.path), ['README']); + assert.deepStrictEqual(partial[0].files.map(entry => entry.path).sort(), ['README', file].sort()); } } finally { childProcess.execFileSync = originalExec; @@ -241,6 +241,48 @@ test('diff headers isolate quoted/deleted hunks and preserve trailing-space path assert.strictEqual(ranges.has('trailing'), false); }); +test('quoted Git headers decode UTF-8 octal bytes, quotes and control escapes into exact paths', () => { + const ranges = parseDiffRanges([ + 'diff --git "a/café.js" "b/café.js"', + '+++ "b/caf\\303\\251\\"r\\303\\251sum\\303\\251\\tname.js"', '@@ -4,2 +4,2 @@', + 'diff --git a/other.js b/other.js', '+++ b/other.js', '@@ -8 +8 @@', + ].join('\n')); + assert.deepStrictEqual(ranges.get('café"résumé\tname.js'), [[4, 5]]); + assert.deepStrictEqual(ranges.get('other.js'), [[8, 8]]); +}); + +test('real quoted filenames retain disjoint hunk ranges rather than whole-file collisions', () => { + 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-quoted-lines-')); + const repo = path.join(dir, 'repo'); + const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { stdio: 'pipe' }); + const file = process.platform === 'win32' ? 'café-résumé.js' : 'café"résumé\tname.js'; + 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'); + const original = Array.from({ length: 40 }, (_, i) => `const line${i} = ${i};`); + fs.writeFileSync(path.join(repo, file), `${original.join('\n')}\n`); + git(repo, 'add', '.'); git(repo, 'commit', '-qm', 'base'); + const sessions = ['a', 'b'].map((name, index) => { + const wt = path.join(dir, name); + git(repo, 'worktree', 'add', '-q', '-b', name, wt, 'main'); + const lines = original.map((line, i) => i === index * 39 ? `${line} // edit` : line); + fs.writeFileSync(path.join(wt, file), `${lines.join('\n')}\n`); + return { id: name, worktree: { path: wt, base: 'main' } }; + }); + const agents = sessionsToAgents(sessions); + assert.deepStrictEqual(agents[0].files, [{ weight: 1, path: file, lines: [[1, 1]] }]); + assert.deepStrictEqual(agents[1].files, [{ weight: 1, path: file, lines: [[40, 40]] }]); + const snapshot = buildProximitySnapshot(sessions, { repoRoot: repo, graph: { adjacency: {} } }); + assert.ok(!snapshot.advisories.some(advisory => advisory.level === 'resolution')); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + test('line-range channel: same file but disjoint ranges ⇒ no resolution', () => { // Two agents in the same file, far-apart functions. workingSetFor provides ranges. const workingSetFor = s =>