diff --git a/README.md b/README.md index ac7167a..819f45d 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ agentdiff status --remote --no-fetch # fast: show refs + SHAs only, skip trace |-------|---------------|----------| | **Claude Code** | `PostToolUse` hook (`~/.claude/settings.json`) | Edit, Write, MultiEdit | | **Cursor** | `afterFileEdit`, `afterTabFileEdit` hooks | Agent edits + Tab completions | -| **GitHub Copilot** | VS Code extension (`~/.vscode/extensions/`) | Inline completions, chat edits | +| **GitHub Copilot** | VS Code extension (`~/.vscode/extensions/`) | Large inline insertions, saved AI edits, manual captures | | **Windsurf** | `post_write_code` hook (`~/.codeium/windsurf/hooks.json`) | Cascade agent writes | | **OpenCode** | `tool.execute.after` plugin (`~/.config/opencode/plugins/`) | All tool writes | | **Codex CLI** | `notify` hook (`~/.codex/config.toml`) | Task-level file changes | @@ -390,7 +390,7 @@ Installs Python capture scripts to `~/.agentdiff/scripts/` and registers hooks w - Gemini → `~/.gemini/settings.json` (BeforeTool, AfterTool) - Windsurf → `~/.codeium/windsurf/hooks.json` (post_write_code) - OpenCode → `~/.config/opencode/plugins/agentdiff.ts` (tool.execute.after) -- Copilot → VS Code extension in `~/.vscode/extensions/agentdiff-copilot-0.1.0/` +- Copilot → VS Code extension in `~/.vscode/extensions/agentdiff-copilot-0.1.0/` or the matching VS Code Server extensions directory for WSL/remote workspaces **2. `agentdiff init` — per-repo setup** @@ -413,6 +413,8 @@ When an AI agent makes an edit, its hook fires and writes a JSON entry to ` { + origExecFile = childProcess.execFile; + origSpawn = childProcess.spawn; + origExistsSync = fs.existsSync; + origAppendFileSync = fs.appendFileSync; + origMkdirSync = fs.mkdirSync; + fs.appendFileSync = () => {}; + fs.mkdirSync = () => {}; +}); + +afterEach(() => { + childProcess.execFile = origExecFile; + childProcess.spawn = origSpawn; + fs.existsSync = origExistsSync; + fs.appendFileSync = origAppendFileSync; + fs.mkdirSync = origMkdirSync; + delete require.cache[EXT_PATH]; + delete require.cache.__vscode_mock__; +}); + +function makeUri(fsPath, scheme = 'file') { + return { scheme, fsPath }; +} + +function makeDocument(filePath, { scheme = 'file', version = 1, lineCount = 1 } = {}) { + return { uri: makeUri(filePath, scheme), version, lineCount }; +} + +function makeVscodeMock({ copilotInstalled = true, copilotActive = true, workspaceFolders } = {}) { const subscriptions = []; const registeredCommands = {}; + const outputLines = []; + const folders = (workspaceFolders || ['/tmp/repo-a', '/tmp/repo-b']).map((folderPath) => ({ + uri: makeUri(folderPath), + name: path.basename(folderPath), + })); - // EventEmitter-style helpers function makeEvent() { let _handler = null; const event = (handler) => { @@ -35,21 +72,31 @@ function makeVscodeMock({ copilotInstalled = true, copilotActive = true } = {}) return event; } + function folderFor(uri) { + return folders + .filter((folder) => { + const rel = path.relative(folder.uri.fsPath, uri.fsPath); + return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel)); + }) + .sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length)[0]; + } + const onDidChangeTextDocument = makeEvent(); const onDidSaveTextDocument = makeEvent(); + const copilotExt = copilotInstalled ? { isActive: copilotActive } : undefined; - const copilotExt = copilotInstalled - ? { isActive: copilotActive } - : undefined; - - const mock = { + return { workspace: { + workspaceFolders: folders, onDidChangeTextDocument, onDidSaveTextDocument, - asRelativePath: (fsPath) => fsPath.replace(/^\/tmp\/[^/]+\//, ''), - getConfiguration: (_section) => ({ - get: (_key) => null, - }), + getWorkspaceFolder: folderFor, + asRelativePath: (uriOrPath) => { + const uri = typeof uriOrPath === 'string' ? makeUri(uriOrPath) : uriOrPath; + const folder = folderFor(uri); + return folder ? path.relative(folder.uri.fsPath, uri.fsPath) : uri.fsPath; + }, + getConfiguration: () => ({ get: () => null }), }, extensions: { getExtension: (id) => { @@ -62,6 +109,11 @@ function makeVscodeMock({ copilotInstalled = true, copilotActive = true } = {}) window: { activeTextEditor: null, showInformationMessage: () => {}, + createOutputChannel: () => ({ + appendLine: (line) => outputLines.push(line), + show: () => { outputLines.push('__shown__'); }, + dispose: () => {}, + }), }, commands: { registerCommand: (id, fn) => { @@ -69,36 +121,25 @@ function makeVscodeMock({ copilotInstalled = true, copilotActive = true } = {}) return { dispose: () => {} }; }, }, - Uri: { parse: (s) => ({ scheme: 'file', fsPath: s }) }, - - // Test helpers (not part of real vscode API) + lm: { + selectChatModels: async () => [{ id: 'copilot-test-model' }], + }, + Uri: { parse: (s) => makeUri(s) }, _fire: { change: onDidChangeTextDocument.fire, save: onDidSaveTextDocument.fire }, _commands: registeredCommands, _subscriptions: subscriptions, + _outputLines: outputLines, }; - - return mock; } -// ─── Load extension with a given vscode mock ───────────────────────────────── - -const EXT_PATH = path.resolve(__dirname, '../../scripts/vscode-extension/extension.js'); - function loadExtension(vscodeMock) { - // Patch require.cache with the mock before loading. - // The module key must match what extension.js resolves 'vscode' to. - const fakeVscodeId = require.resolve('path'); // any stable key — we overwrite below - const vscodeKey = 'vscode'; // extension does: require('vscode') - - // Node resolves built-in modules differently; inject via _resolveFilename hook. const origResolve = Module._resolveFilename; Module._resolveFilename = function (request, parent, isMain, options) { if (request === 'vscode') return '__vscode_mock__'; return origResolve.call(this, request, parent, isMain, options); }; - // Place the mock in require.cache under our fake id. - require.cache['__vscode_mock__'] = { + require.cache.__vscode_mock__ = { id: '__vscode_mock__', filename: '__vscode_mock__', loaded: true, @@ -107,17 +148,13 @@ function loadExtension(vscodeMock) { paths: [], }; - // Clear the extension from cache so it re-executes with the new mock. delete require.cache[EXT_PATH]; - let ext; try { - ext = require(EXT_PATH); + return require(EXT_PATH); } finally { Module._resolveFilename = origResolve; } - - return ext; } function activateExt(vscodeMock) { @@ -127,139 +164,182 @@ function activateExt(vscodeMock) { return { ext, vscode: vscodeMock }; } -// ─── Helper: build a fake text document change event ──────────────────────── - -function makeChangeEvent(filePath, changes, { scheme = 'file', isActive = true } = {}) { +function makeChangeEvent(filePath, changes, { scheme = 'file', version = 1 } = {}) { return { - document: { uri: { scheme, fsPath: filePath } }, + document: makeDocument(filePath, { scheme, version }), contentChanges: changes.map(({ text, startLine = 0 }) => ({ text, range: { start: { line: startLine } }, })), - // stub — vscode extension checks copilotExt.isActive, not this }; } -// ─── Tests ─────────────────────────────────────────────────────────────────── +function installRepoStubs({ initialized = true } = {}) { + const spawned = []; + + childProcess.execFile = (_cmd, args, opts, cb) => { + const cwd = opts.cwd; + const repo = cwd.startsWith('/tmp/repo-a') ? '/tmp/repo-a' + : cwd.startsWith('/tmp/repo-b') ? '/tmp/repo-b' + : null; + if (!repo) { + cb(new Error('not a repo'), '', 'fatal'); + return; + } + if (args.join(' ') === 'rev-parse --show-toplevel') { + cb(null, `${repo}\n`, ''); + return; + } + if (args.join(' ') === 'rev-parse --git-common-dir') { + cb(null, `${repo}/.git\n`, ''); + return; + } + cb(new Error('unexpected git args'), '', ''); + }; + + fs.existsSync = (p) => { + if (p === '__AGENTDIFF_CAPTURE_COPILOT__') return true; + if (String(p).endsWith('/.git/agentdiff')) return initialized; + return origExistsSync(p); + }; -describe('Extension: Copilot detection threshold (MIN_COPILOT_CHANGE_LEN = 50)', () => { - test('does not capture a short single-line insertion (<50 chars)', (t, done) => { + childProcess.spawn = (command, args) => { + const proc = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { + chunks: [], + write(chunk) { this.chunks.push(String(chunk)); }, + end() { + spawned.push({ + command, + args, + payload: JSON.parse(this.chunks.join('')), + }); + }, + }; + return proc; + }; + + return spawned; +} + +describe('Extension: edit capture flow', () => { + test('captures a large insertion on save with repo, workspace, version, and lines', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - // 49 chars — below threshold - const shortText = 'x'.repeat(49); - vscode._fire.change(makeChangeEvent('/tmp/repo/src/main.rs', [{ text: shortText }])); + const filePath = '/tmp/repo-a/src/main.rs'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'x'.repeat(80), startLine: 2 }], { version: 7 })); + await vscode._fire.save(makeDocument(filePath, { version: 7 })); + + assert.equal(spawned.length, 1); + assert.equal(spawned[0].command, 'python3'); + assert.deepEqual(spawned[0].payload.lines, [3]); + assert.equal(spawned[0].payload.event, 'save'); + assert.equal(spawned[0].payload.cwd, '/tmp/repo-a'); + assert.equal(spawned[0].payload.repo_root, '/tmp/repo-a'); + assert.equal(spawned[0].payload.workspace_folder, '/tmp/repo-a'); + assert.equal(spawned[0].payload.document_version, 7); + assert.equal(spawned[0].payload.model, 'copilot-test-model'); + }); - // Wait longer than the 2-second debounce to confirm nothing fires. - // We can't easily intercept the spawn, so we just check no error is thrown - // and the handler runs without crashing. - setTimeout(() => done(), 50); + test('captures multi-line insertions below the single-line threshold', async () => { + const spawned = installRepoStubs(); + const vscode = makeVscodeMock(); + activateExt(vscode); + + const filePath = '/tmp/repo-a/src/main.rs'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'a\nb', startLine: 4 }], { version: 2 })); + await vscode._fire.save(makeDocument(filePath, { version: 2 })); + + assert.equal(spawned.length, 1); + assert.deepEqual(spawned[0].payload.lines, [5, 6]); }); - test('handles multi-line insertion (>1 newline) regardless of length', (t, done) => { + test('does not capture a short single-line insertion', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - // Only 5 chars but spans 2 lines - vscode._fire.change(makeChangeEvent('/tmp/repo/src/main.rs', [{ text: 'a\nb' }])); + const filePath = '/tmp/repo-a/src/main.rs'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'x'.repeat(49) }])); + await vscode._fire.save(makeDocument(filePath)); - // Extension should not throw - setTimeout(() => done(), 50); + assert.equal(spawned.length, 0); }); - test('ignores changes from non-file URI schemes', (t, done) => { + test('skips non-file documents before buffering capture state', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - // git scheme — should be ignored immediately - vscode._fire.change(makeChangeEvent('/tmp/repo/src/main.rs', [{ text: 'x'.repeat(100) }], { scheme: 'git' })); + vscode._fire.change(makeChangeEvent('/tmp/repo-a/src/main.rs', [{ text: 'x'.repeat(80) }], { scheme: 'git' })); - setTimeout(() => done(), 50); + assert.equal(spawned.length, 0); }); }); -describe('Extension: EXCLUDED_PATHS filtering', () => { - test('ignores changes to .agentdiff/ paths', (t, done) => { +describe('Extension: multi-root workspace and path filtering', () => { + test('uses the owning workspace folder in a multi-root workspace', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - // This path resolves to something that starts with .agentdiff/ after asRelativePath - const event = { - document: { uri: { scheme: 'file', fsPath: '/tmp/repo/.agentdiff/ledger.jsonl' } }, - contentChanges: [{ text: 'x'.repeat(200), range: { start: { line: 0 } } }], - }; - // asRelativePath will return '.agentdiff/ledger.jsonl' — should be filtered - const vscodeReal = require.__spy || vscode; - vscode.workspace.asRelativePath = (_p) => '.agentdiff/ledger.jsonl'; + const filePath = '/tmp/repo-b/pkg/lib.js'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'y'.repeat(80), startLine: 0 }])); + await vscode._fire.save(makeDocument(filePath)); - vscode._fire.change(event); - setTimeout(() => done(), 50); + assert.equal(spawned.length, 1); + assert.equal(spawned[0].payload.cwd, '/tmp/repo-b'); + assert.equal(spawned[0].payload.repo_root, '/tmp/repo-b'); + assert.equal(spawned[0].payload.workspace_folder, '/tmp/repo-b'); }); - test('ignores changes to .git/ paths', (t, done) => { + test('ignores .agentdiff and .git paths relative to the owning root', async () => { + const spawned = installRepoStubs(); const vscode = makeVscodeMock(); activateExt(vscode); - const event = { - document: { uri: { scheme: 'file', fsPath: '/tmp/repo/.git/COMMIT_EDITMSG' } }, - contentChanges: [{ text: 'x'.repeat(200), range: { start: { line: 0 } } }], - }; - vscode.workspace.asRelativePath = (_p) => '.git/COMMIT_EDITMSG'; + vscode._fire.change(makeChangeEvent('/tmp/repo-b/.agentdiff/ledger.jsonl', [{ text: 'x'.repeat(80) }])); + vscode._fire.change(makeChangeEvent('/tmp/repo-b/.git/COMMIT_EDITMSG', [{ text: 'x'.repeat(80) }])); - vscode._fire.change(event); - setTimeout(() => done(), 50); + assert.equal(spawned.length, 0); }); }); -describe('Extension: Copilot not installed', () => { - test('activate() returns early without registering listeners when Copilot is absent', () => { - const vscode = makeVscodeMock({ copilotInstalled: false }); - const { ext } = activateExt(vscode); +describe('Extension: diagnostics and commands', () => { + test('logs a structured warning when the repo has not been initialized', async () => { + const spawned = installRepoStubs({ initialized: false }); + const vscode = makeVscodeMock(); + activateExt(vscode); - // If Copilot is not installed, no subscriptions should be registered - assert.equal(vscode._subscriptions.length, 0, - 'Should not register any listeners when Copilot extension is absent'); - }); -}); + const filePath = '/tmp/repo-a/src/main.rs'; + vscode._fire.change(makeChangeEvent(filePath, [{ text: 'x'.repeat(80) }])); + await vscode._fire.save(makeDocument(filePath)); -describe('Extension: getCopilotModel() fallback', () => { - test('returns "gpt-4o" when copilot-chat extension is present', () => { - // Load module and inspect getCopilotModel directly by activating - // and checking what model ends up in the capture payload. - // We do this indirectly — if chat ext is present without advanced config, - // getCopilotModel returns 'gpt-4o'. We verify activate() doesn't throw. - const vscode = makeVscodeMock({ copilotInstalled: true, copilotActive: true }); - // Make copilot-chat also present - vscode.extensions.getExtension = (id) => { - if (id === 'GitHub.copilot') return { isActive: true }; - if (id === 'GitHub.copilot-chat') return { isActive: true }; - return undefined; - }; - assert.doesNotThrow(() => activateExt(vscode)); + assert.equal(spawned.length, 0); + assert.ok(vscode._outputLines.some((line) => line.includes('"event":"capture.skipped_repo_not_initialized"'))); }); - test('returns "copilot" fallback when no config and no chat ext', () => { + test('registers capture and log commands', () => { const vscode = makeVscodeMock(); - vscode.extensions.getExtension = (id) => { - if (id === 'GitHub.copilot') return { isActive: true }; - // no chat ext - return undefined; - }; - assert.doesNotThrow(() => activateExt(vscode)); + activateExt(vscode); + + assert.ok('agentdiff.captureNow' in vscode._commands); + assert.ok('agentdiff.openLogs' in vscode._commands); }); -}); -describe('Extension: agentdiff.captureNow command', () => { - test('command is registered after activation', () => { + test('open logs command shows the output channel', () => { const vscode = makeVscodeMock(); activateExt(vscode); - assert.ok('agentdiff.captureNow' in vscode._commands, - 'agentdiff.captureNow command should be registered'); + vscode._commands['agentdiff.openLogs'](); + + assert.ok(vscode._outputLines.includes('__shown__')); }); - test('captureNow shows message when no active editor', async () => { + test('captureNow shows a message when no active editor exists', async () => { const vscode = makeVscodeMock(); activateExt(vscode); @@ -271,10 +351,29 @@ describe('Extension: agentdiff.captureNow command', () => { assert.ok(shown && shown.includes('agentdiff'), `Expected info message, got: ${shown}`); }); + + test('activate returns early without listeners when Copilot is absent', () => { + const vscode = makeVscodeMock({ copilotInstalled: false }); + activateExt(vscode); + + assert.equal(vscode._subscriptions.length, 0); + assert.ok(vscode._outputLines.some((line) => line.includes('"event":"extension.inactive_copilot_missing"'))); + }); +}); + +describe('Extension: WSL path helpers', () => { + test('translates common Windows and WSL UNC paths for WSL capture scripts', () => { + const vscode = makeVscodeMock(); + const { ext } = activateExt(vscode); + + assert.equal(ext._test.windowsPathToWsl('C:\\Users\\me\\repo\\file.js'), '/mnt/c/Users/me/repo/file.js'); + assert.equal(ext._test.windowsPathToWsl('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\file.js'), '/home/me/repo/file.js'); + assert.equal(ext._test.windowsPathToWsl('/home/me/repo/file.js'), '/home/me/repo/file.js'); + }); }); describe('Extension: deactivate', () => { - test('deactivate() does not throw', () => { + test('deactivate does not throw', () => { const vscode = makeVscodeMock(); const { ext } = activateExt(vscode); assert.doesNotThrow(() => ext.deactivate()); diff --git a/scripts/vscode-extension/extension.js b/scripts/vscode-extension/extension.js index cd14ab2..be56159 100644 --- a/scripts/vscode-extension/extension.js +++ b/scripts/vscode-extension/extension.js @@ -12,116 +12,340 @@ const CAPTURE_SCRIPT = '__AGENTDIFF_CAPTURE_COPILOT__'; // Minimum insertion length to be considered a Copilot-originated change. // Must be high enough to avoid capturing human typing, copy-paste, and edits // from other agents (Claude, Cursor, Codex) that also trigger VS Code's -// onDidChangeTextDocument events. 50 chars catches multi-line Copilot +// onDidChangeTextDocument events. 50 chars catches multi-line Copilot // completions while filtering out most false positives. const MIN_COPILOT_CHANGE_LEN = 50; +const DEFAULT_FLUSH_DELAY_MS = 2000; +const FLUSH_DELAY_MS = Number(process.env.AGENTDIFF_EXT_FLUSH_DELAY_MS || DEFAULT_FLUSH_DELAY_MS); // Paths that should never be attributed to Copilot (auto-generated metadata). const EXCLUDED_PATHS = ['.agentdiff/', '.git/']; +let outputChannel = null; + function isDebug() { const v = process.env.AGENTDIFF_DEBUG || ''; - return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes'; + return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase()); +} + +function getOutputChannel() { + if (!outputChannel && vscode.window && typeof vscode.window.createOutputChannel === 'function') { + outputChannel = vscode.window.createOutputChannel('AgentDiff'); + } + return outputChannel; } -function debugLog(msg) { - if (!isDebug()) return; +function writeFileLog(line, force = false) { + if (!force && !isDebug()) return; try { const logDir = path.join(os.homedir(), '.agentdiff', 'logs'); fs.mkdirSync(logDir, { recursive: true }); - const ts = new Date().toISOString(); - fs.appendFileSync(path.join(logDir, 'capture-copilot-ext.log'), `${ts} ${msg}\n`); + fs.appendFileSync(path.join(logDir, 'capture-copilot-ext.log'), `${line}\n`); } catch (_) {} } -function findRepoRoot(filePath) { +function logEvent(level, event, fields = {}) { + const entry = { + ts: new Date().toISOString(), + level, + component: 'vscode-copilot-extension', + event, + ...fields, + }; + const line = JSON.stringify(entry); + const channel = getOutputChannel(); + if (channel && (level !== 'debug' || isDebug())) { + channel.appendLine(line); + } + writeFileLog(line, level !== 'debug'); +} + +function normalizeSlashes(value) { + return String(value || '').replace(/\\/g, '/'); +} + +function isExcludedRelativePath(relPath) { + const normalized = normalizeSlashes(relPath).replace(/^\/+/, ''); + return EXCLUDED_PATHS.some((excluded) => normalized === excluded.slice(0, -1) || normalized.startsWith(excluded)); +} + +function isPathInside(child, parent) { + if (!child || !parent) return false; + const relative = path.relative(parent, child); + return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function getWorkspaceFolder(uri) { + if (vscode.workspace && typeof vscode.workspace.getWorkspaceFolder === 'function') { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (folder) return folder; + } + + const folders = (vscode.workspace && vscode.workspace.workspaceFolders) || []; + return folders.find((folder) => isPathInside(uri.fsPath, folder.uri.fsPath)) || null; +} + +function getRelativePath(uri) { + const folder = getWorkspaceFolder(uri); + if (folder) { + return normalizeSlashes(path.relative(folder.uri.fsPath, uri.fsPath)); + } + if (vscode.workspace && typeof vscode.workspace.asRelativePath === 'function') { + return normalizeSlashes(vscode.workspace.asRelativePath(uri, false)); + } + return normalizeSlashes(uri.fsPath); +} + +function execGit(args, cwd) { return new Promise((resolve) => { - const dir = path.dirname(filePath); - cp.exec('git rev-parse --show-toplevel', { cwd: dir }, (err, stdout) => { - resolve(err ? null : stdout.trim()); + const useWsl = looksLikeWslScript(CAPTURE_SCRIPT); + const command = useWsl ? 'wsl.exe' : 'git'; + const commandArgs = useWsl ? ['--cd', windowsPathToWsl(cwd), '-e', 'git', ...args] : args; + const options = useWsl ? {} : { cwd }; + + cp.execFile(command, commandArgs, options, (err, stdout, stderr) => { + if (err) { + logEvent('debug', 'git.rev_parse.failed', { + cwd, + args, + error: err.message, + stderr: String(stderr || '').trim(), + }); + resolve(null); + return; + } + resolve(String(stdout || '').trim() || null); + }); + }); +} + +function pathExistsDir(dirPath) { + if (!dirPath) return Promise.resolve(false); + if (!looksLikeWslScript(CAPTURE_SCRIPT)) { + return Promise.resolve(fs.existsSync(dirPath)); + } + return new Promise((resolve) => { + cp.execFile('wsl.exe', ['-e', 'test', '-d', dirPath], {}, (err) => { + resolve(!err); }); }); } +function resolveGitDir(gitDir, cwd) { + if (!gitDir) return null; + if (looksLikeWslScript(CAPTURE_SCRIPT)) { + return gitDir.startsWith('/') ? gitDir : path.posix.resolve(windowsPathToWsl(cwd), gitDir); + } + return path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir); +} + +async function findRepoInfo(uri) { + const filePath = uri.fsPath; + const workspaceFolder = getWorkspaceFolder(uri); + const startDir = path.dirname(filePath); + const repoRoot = await execGit(['rev-parse', '--show-toplevel'], startDir); + const gitDirRaw = await execGit(['rev-parse', '--git-common-dir'], startDir); + const gitDir = resolveGitDir(gitDirRaw, startDir); + const agentdiffDir = gitDir + ? (looksLikeWslScript(CAPTURE_SCRIPT) ? path.posix.join(gitDir, 'agentdiff') : path.join(gitDir, 'agentdiff')) + : null; + const initialized = await pathExistsDir(agentdiffDir); + + return { + cwd: repoRoot || (workspaceFolder && workspaceFolder.uri.fsPath) || startDir, + repoRoot, + gitDir, + agentdiffDir, + initialized, + workspaceFolder: workspaceFolder ? workspaceFolder.uri.fsPath : null, + }; +} + async function getCopilotModel() { try { + if (!vscode.lm || typeof vscode.lm.selectChatModels !== 'function') { + return 'copilot'; + } const models = await vscode.lm.selectChatModels({ vendor: 'copilot' }); if (models && models.length > 0) { return models[0].id; } - } catch (_) {} + } catch (err) { + logEvent('debug', 'copilot.model.lookup_failed', { error: err.message }); + } return 'copilot'; } -function fireCapture(payload) { - if (!fs.existsSync(CAPTURE_SCRIPT)) { - debugLog(`capture script not found: ${CAPTURE_SCRIPT}`); - return; +function looksLikeWslScript(scriptPath) { + return process.platform === 'win32' && /^\/(home|mnt|opt|usr|var|tmp)\//.test(scriptPath); +} + +function windowsPathToWsl(value) { + if (!value) return value; + const normalized = normalizeSlashes(value); + const drive = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (drive) { + return `/mnt/${drive[1].toLowerCase()}/${drive[2]}`; + } + const unc = normalized.match(/^\/\/wsl(?:\.localhost|\$)\/[^/]+(\/.*)$/i); + if (unc) { + return unc[1]; + } + return value; +} + +function buildCaptureProcess(payload) { + if (looksLikeWslScript(CAPTURE_SCRIPT)) { + const translatedPayload = { ...payload }; + for (const key of ['cwd', 'file_path', 'repo_root', 'workspace_folder']) { + translatedPayload[key] = windowsPathToWsl(translatedPayload[key]); + } + return { + command: 'wsl.exe', + args: ['-e', 'python3', CAPTURE_SCRIPT], + payload: translatedPayload, + allowMissingScript: true, + }; } + const python = process.platform === 'win32' ? 'python' : 'python3'; - const proc = cp.spawn(python, [CAPTURE_SCRIPT], { stdio: ['pipe', 'ignore', 'ignore'] }); - proc.stdin.write(JSON.stringify(payload)); + return { + command: python, + args: [CAPTURE_SCRIPT], + payload, + allowMissingScript: false, + }; +} + +function fireCapture(payload) { + const procSpec = buildCaptureProcess(payload); + if (!procSpec.allowMissingScript && !fs.existsSync(CAPTURE_SCRIPT)) { + logEvent('error', 'capture.script_missing', { captureScript: CAPTURE_SCRIPT }); + return false; + } + + const proc = cp.spawn(procSpec.command, procSpec.args, { stdio: ['pipe', 'ignore', 'pipe'] }); + proc.stdin.write(JSON.stringify(procSpec.payload)); proc.stdin.end(); - proc.on('error', (err) => debugLog(`spawn error: ${err.message}`)); - debugLog(`fired capture: file=${payload.file_path} lines=${JSON.stringify(payload.lines)}`); + proc.stderr.on('data', (chunk) => { + logEvent('error', 'capture.stderr', { message: String(chunk).trim() }); + }); + proc.on('error', (err) => logEvent('error', 'capture.spawn_failed', { error: err.message })); + logEvent('debug', 'capture.spawned', { + command: procSpec.command, + args: procSpec.args, + filePath: procSpec.payload.file_path, + lines: procSpec.payload.lines, + repoRoot: procSpec.payload.repo_root, + }); + return true; } -async function captureFile(filePath, pending) { - const repoRoot = await findRepoRoot(filePath); - const cwd = repoRoot || path.dirname(filePath); - fireCapture({ +async function captureDocument(document, pending) { + const uri = document.uri; + const filePath = uri.fsPath; + const repoInfo = await findRepoInfo(uri); + + if (!repoInfo.initialized) { + logEvent('warn', 'capture.skipped_repo_not_initialized', { + filePath, + repoRoot: repoInfo.repoRoot, + gitDir: repoInfo.gitDir, + workspaceFolder: repoInfo.workspaceFolder, + }); + return false; + } + + const payload = { event: pending.tool, - cwd, + cwd: repoInfo.cwd, file_path: filePath, + repo_root: repoInfo.repoRoot, + workspace_folder: repoInfo.workspaceFolder, + document_version: pending.documentVersion || document.version || null, + captured_at: new Date().toISOString(), + changed_at: pending.changedAt || null, model: await getCopilotModel(), session_id: `vscode-${Date.now()}`, prompt: null, lines: Array.from(pending.lines).sort((a, b) => a - b), - }); + }; + + return fireCapture(payload); } function activate(context) { + const channel = getOutputChannel(); + + // Register openLogs before the Copilot guard so it works even when capture is inactive. + const openLogsCmd = vscode.commands.registerCommand('agentdiff.openLogs', () => { + if (channel && typeof channel.show === 'function') { + channel.show(); + } + }); + context.subscriptions.push(openLogsCmd); + const copilotExt = vscode.extensions.getExtension('GitHub.copilot') || vscode.extensions.getExtension('GitHub.copilot-chat'); if (!copilotExt) { - debugLog('GitHub Copilot extension not found — agentdiff Copilot capture inactive'); + logEvent('warn', 'extension.inactive_copilot_missing'); return; } - debugLog('agentdiff Copilot extension activated'); + logEvent('info', 'extension.activated', { + copilotActive: !!copilotExt.isActive, + workspaceFolders: ((vscode.workspace && vscode.workspace.workspaceFolders) || []).map((f) => f.uri.fsPath), + }); - // pendingChanges: filePath -> { lines: Set, tool: string } + // pendingChanges: filePath -> { document, lines, tool, documentVersion, changedAt } const pendingChanges = new Map(); let flushTimer; async function flushAll() { - for (const [filePath, pending] of pendingChanges) { + flushTimer = null; + const entries = Array.from(pendingChanges.entries()); + pendingChanges.clear(); + for (const [, pending] of entries) { if (pending.lines.size > 0) { - await captureFile(filePath, pending); + await captureDocument(pending.document, pending); } } - pendingChanges.clear(); } // Track document changes and attribute "large" insertions to Copilot. const changeDisposable = vscode.workspace.onDidChangeTextDocument((event) => { - if (event.document.uri.scheme !== 'file') return; - if (!copilotExt.isActive) return; + if (event.document.uri.scheme !== 'file') { + logEvent('debug', 'change.ignored_non_file', { scheme: event.document.uri.scheme }); + return; + } + if (!copilotExt.isActive) { + logEvent('debug', 'change.ignored_copilot_inactive'); + return; + } const filePath = event.document.uri.fsPath; + const relPath = getRelativePath(event.document.uri); + if (isExcludedRelativePath(relPath)) { + logEvent('debug', 'change.ignored_excluded_path', { filePath, relPath }); + return; + } - // Skip metadata paths that are auto-generated. - const relPath = vscode.workspace.asRelativePath(filePath, false); - if (EXCLUDED_PATHS.some((p) => relPath.startsWith(p))) return; - const pending = pendingChanges.get(filePath) || { lines: new Set(), tool: 'inline' }; + const pending = pendingChanges.get(filePath) || { + document: event.document, + lines: new Set(), + tool: 'inline', + }; + pending.document = event.document; + pending.documentVersion = event.document.version || null; + pending.changedAt = new Date().toISOString(); let changed = false; for (const change of event.contentChanges) { const insertedLen = change.text.length; const insertedLineCount = change.text.split('\n').length; - // Treat as Copilot if multi-line insertion or single-line >= threshold + // Treat as Copilot if multi-line insertion or single-line >= threshold. if (insertedLen >= MIN_COPILOT_CHANGE_LEN || insertedLineCount > 1) { const startLine = change.range.start.line + 1; // 1-based for (let l = 0; l < insertedLineCount; l++) { @@ -131,11 +355,20 @@ function activate(context) { } } - if (!changed) return; + if (!changed) { + logEvent('debug', 'change.ignored_below_threshold', { filePath, relPath }); + return; + } pendingChanges.set(filePath, pending); + logEvent('debug', 'change.buffered', { + filePath, + relPath, + documentVersion: pending.documentVersion, + lines: Array.from(pending.lines).sort((a, b) => a - b), + }); if (flushTimer) clearTimeout(flushTimer); - flushTimer = setTimeout(flushAll, 2000); + flushTimer = setTimeout(flushAll, FLUSH_DELAY_MS); }); // On save, flush pending changes for that file immediately. @@ -144,8 +377,17 @@ function activate(context) { const filePath = doc.uri.fsPath; const pending = pendingChanges.get(filePath); if (!pending || pending.lines.size === 0) return; - await captureFile(filePath, { lines: pending.lines, tool: 'save' }); + pending.tool = 'save'; + pending.document = doc; + pending.documentVersion = doc.version || pending.documentVersion || null; + // Remove before awaiting so the flush timer cannot fire a duplicate capture + // for the same save event while captureDocument is in flight. pendingChanges.delete(filePath); + if (pendingChanges.size === 0 && flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + await captureDocument(doc, pending); }); // Command: manually record all lines of the current file as Copilot-authored. @@ -156,16 +398,37 @@ function activate(context) { vscode.window.showInformationMessage('agentdiff: No active editor'); return; } - const filePath = editor.document.uri.fsPath; const lines = new Set(); for (let i = 1; i <= editor.document.lineCount; i++) lines.add(i); - await captureFile(filePath, { lines, tool: 'manual' }); - vscode.window.showInformationMessage('agentdiff: Copilot capture recorded'); + const ok = await captureDocument(editor.document, { + lines, + tool: 'manual', + documentVersion: editor.document.version || null, + changedAt: null, + }); + vscode.window.showInformationMessage(ok + ? 'agentdiff: Copilot capture recorded' + : 'agentdiff: Copilot capture skipped; see AgentDiff output'); }); context.subscriptions.push(changeDisposable, saveDisposable, captureCmd); } -function deactivate() {} +function deactivate() { + logEvent('info', 'extension.deactivated'); + if (outputChannel && typeof outputChannel.dispose === 'function') { + outputChannel.dispose(); + outputChannel = null; + } +} -module.exports = { activate, deactivate }; +module.exports = { + activate, + deactivate, + _test: { + buildCaptureProcess, + getRelativePath, + isExcludedRelativePath, + windowsPathToWsl, + }, +}; diff --git a/scripts/vscode-extension/package.json b/scripts/vscode-extension/package.json index a7f946e..da0632f 100644 --- a/scripts/vscode-extension/package.json +++ b/scripts/vscode-extension/package.json @@ -13,6 +13,10 @@ { "command": "agentdiff.captureNow", "title": "agentdiff: Capture Copilot edits for current file" + }, + { + "command": "agentdiff.openLogs", + "title": "agentdiff: Open Logs" } ] }