From e924c3e715d15662561d13f51348ade638bdb797 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:16:58 +1000 Subject: [PATCH 1/6] fix: scope prompt context to repository cwd (#315) --- src/llm/client.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index 9d8cbbb..39727ba 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -40,6 +40,7 @@ export async function generateSuggestions( apiKeyParam?: string, precomputedTruncation?: TruncationInfo, signal?: AbortSignal, + cwdParam = process.cwd(), ): Promise<{ suggestions: Suggestion[]; profile: StyleProfile; @@ -50,9 +51,9 @@ export async function generateSuggestions( const { diff: truncatedDiff, info: truncation } = truncateDiff(diff, config.maxDiffSize); - const branch = getBranchName(); + const branch = getBranchName(cwdParam); const profileStr = formatProfile(profile); - const message = getLastCommitMessage(); + const message = getLastCommitMessage(cwdParam); const vars = { diff: truncatedDiff, @@ -116,14 +117,15 @@ export async function* generateSuggestionsStream( apiKeyParam?: string, provider?: Provider, precomputedTruncation?: TruncationInfo, + cwdParam = process.cwd(), ): AsyncGenerator { const profile = profileParam ?? (await buildProfile(config.historySize)); const { diff: truncatedDiff, info: truncation } = truncateDiff(diff, config.maxDiffSize); - const branch = getBranchName(); + const branch = getBranchName(cwdParam); const profileStr = formatProfile(profile); - const message = getLastCommitMessage(); + const message = getLastCommitMessage(cwdParam); const vars = { diff: truncatedDiff, From 59841627300687ae7f0e1ec71f38b822bc8d966e Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:17:01 +1000 Subject: [PATCH 2/6] fix: scope batch prompt context to each repo (#315) --- src/commands/batch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/batch.ts b/src/commands/batch.ts index abfece3..882ffff 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -174,7 +174,7 @@ export async function batchCommand( let truncation: TruncationInfo | undefined; let model: string; try { - const result = await generateSuggestions(config, diff, profile, apiKey); + const result = await generateSuggestions(config, diff, profile, apiKey, undefined, undefined, repoPath); suggestions = result.suggestions; model = result.model; truncation = result.truncation; From 122bf31cf0cd409b9a5e65fcff7ac4c525896016 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:17:05 +1000 Subject: [PATCH 3/6] fix: allow git prompt context to use explicit cwd (#315) --- src/git/diff.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/git/diff.ts b/src/git/diff.ts index ee533a9..6c728da 100644 --- a/src/git/diff.ts +++ b/src/git/diff.ts @@ -368,18 +368,24 @@ export function getRepoRoot(): string { return normalize(execFileSync(getGitExecutable(), ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' }).trim()); } -export function getBranchName(): string { +export function getBranchName(cwd = process.cwd()): string { try { - return execFileSync(getGitExecutable(), ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8' }).trim(); + return execFileSync(getGitExecutable(), ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd, + encoding: 'utf-8', + env: getGitEnv(), + }).trim(); } catch { return 'unknown'; } } -export function getLastCommitMessage(): string { +export function getLastCommitMessage(cwd = process.cwd()): string { try { return execFileSync(getGitExecutable(), ['log', '-1', '--format=%s'], { + cwd, encoding: 'utf-8', + env: getGitEnv(), stdio: 'pipe', }).trim(); } catch { From f1fecd6c408535a92a2fdb0c23f35dfa7ff54d76 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:17:09 +1000 Subject: [PATCH 4/6] test: cover per-repo batch prompt context (#315) --- tests/batch-prompt-context.test.mjs | 201 ++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/batch-prompt-context.test.mjs diff --git a/tests/batch-prompt-context.test.mjs b/tests/batch-prompt-context.test.mjs new file mode 100644 index 0000000..875d38f --- /dev/null +++ b/tests/batch-prompt-context.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { batchCommand } from '../dist/commands/batch.js'; +import { generateSuggestionsStream } from '../dist/llm/client.js'; +import { invalidateConfigCache } from '../dist/config/store.js'; + +function createTempDir(prefix) { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function git(args, cwd) { + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: 'pipe' }); +} + +function initRepo(root, name, branch, message) { + const repoDir = join(root, name); + mkdirSync(repoDir, { recursive: true }); + git(['init'], repoDir); + git(['config', 'core.fsmonitor', 'false'], repoDir); + git(['config', 'user.name', 'Test User'], repoDir); + git(['config', 'user.email', 'test@example.com'], repoDir); + writeFileSync(join(repoDir, 'file.txt'), name + ' base\n', 'utf-8'); + git(['add', 'file.txt'], repoDir); + git(['commit', '-m', message], repoDir); + git(['switch', '-c', branch], repoDir); + writeFileSync(join(repoDir, 'file.txt'), name + ' staged change\n', 'utf-8'); + git(['add', 'file.txt'], repoDir); + return repoDir; +} + +function initConfig(configHome, baseUrl) { + const configDir = join(configHome, 'commit-echo'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'config.json'), + JSON.stringify({ + provider: '__custom__', + model: 'fixture-model', + baseUrl, + apiKey: 'test-key', + historySize: 5, + maxDiffSize: 4000, + systemPromptTemplate: 'branch={{branch}};message={{message}}', + userPromptTemplate: 'branch={{branch}};message={{message}};diff={{diff}}', + }), + 'utf-8', + ); + writeFileSync(join(configDir, 'history.jsonl'), '', 'utf-8'); +} + +async function startFixtureServer(requests) { + const server = createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) body += chunk; + const payload = JSON.parse(body); + requests.push(payload); + + response.setHeader('Content-Type', payload.stream ? 'text/event-stream' : 'application/json'); + if (payload.stream) { + response.write('data: {"model":"fixture-model","choices":[{"delta":{"content":"1. chore: stream fixture"}}]}\n\n'); + response.write('data: [DONE]\n\n'); + response.end(); + return; + } + + response.end(JSON.stringify({ + model: 'fixture-model', + choices: [{ message: { content: '1. chore: batch fixture' } }], + })); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Fixture server did not expose a TCP address'); + } + return { server, baseUrl: 'http://127.0.0.1:' + address.port + '/v1' }; +} + +async function stopServer(server) { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +} + +test('batch resolves branch and previous message from each repository', async () => { + const parent = createTempDir('commit-echo-batch-context-'); + const callerRepo = createTempDir('commit-echo-caller-'); + const configHome = createTempDir('commit-echo-config-'); + const previousCwd = process.cwd(); + const previousXdg = process.env.XDG_CONFIG_HOME; + const requests = []; + const { server, baseUrl } = await startFixtureServer(requests); + + try { + const repoA = initRepo(parent, 'alpha-repo', 'alpha-feature', 'alpha base commit'); + const repoB = initRepo(parent, 'beta-repo', 'beta-feature', 'beta base commit'); + git(['init'], callerRepo); + git(['config', 'user.name', 'Test User'], callerRepo); + git(['config', 'user.email', 'test@example.com'], callerRepo); + git(['commit', '--allow-empty', '-m', 'caller commit'], callerRepo); + git(['switch', '-c', 'caller-feature'], callerRepo); + + initConfig(configHome, baseUrl); + process.env.XDG_CONFIG_HOME = configHome; + process.chdir(callerRepo); + + const result = await batchCommand({ directory: parent, recursive: false, yes: true }); + + assert.equal(result, true); + assert.equal(requests.length, 2); + + const alphaRequest = requests.find((request) => JSON.stringify(request).includes('alpha-feature')); + const betaRequest = requests.find((request) => JSON.stringify(request).includes('beta-feature')); + assert.ok(alphaRequest); + assert.ok(betaRequest); + + const alphaText = JSON.stringify(alphaRequest); + const betaText = JSON.stringify(betaRequest); + assert.match(alphaText, /alpha-feature/); + assert.match(alphaText, /alpha base commit/); + assert.doesNotMatch(alphaText, /caller-feature|caller commit|beta-feature|beta base commit/); + assert.match(betaText, /beta-feature/); + assert.match(betaText, /beta base commit/); + assert.doesNotMatch(betaText, /caller-feature|caller commit|alpha-feature|alpha base commit/); + + assert.equal(git(['log', '-1', '--format=%s'], repoA).trim(), 'chore: batch fixture'); + assert.equal(git(['log', '-1', '--format=%s'], repoB).trim(), 'chore: batch fixture'); + } finally { + process.chdir(previousCwd); + if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdg; + invalidateConfigCache(); + await stopServer(server); + rmSync(parent, { recursive: true, force: true }); + rmSync(callerRepo, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); + } +}); + +test('generateSuggestionsStream resolves repository context for its prompt', async () => { + const repoRoot = createTempDir('commit-echo-stream-context-'); + const callerDir = createTempDir('commit-echo-stream-caller-'); + const requests = []; + const { server, baseUrl } = await startFixtureServer(requests); + const previousCwd = process.cwd(); + + try { + const repo = initRepo(repoRoot, 'stream-repo', 'stream-feature', 'stream base commit'); + process.chdir(callerDir); + + const events = []; + for await (const event of generateSuggestionsStream( + { + provider: '__custom__', + model: 'fixture-model', + baseUrl, + apiKey: 'test-key', + historySize: 5, + maxDiffSize: 4000, + systemPromptTemplate: 'branch={{branch}};message={{message}}', + userPromptTemplate: 'branch={{branch}};message={{message}};diff={{diff}}', + }, + 'diff --git a/file.txt b/file.txt\n', + { + avgLength: 0, + commonPrefixes: [], + prefixRates: {}, + imperativeRate: 0, + sentenceCaseRate: 0, + usesScopeRate: 0, + usesBodyRate: 0, + totalCommits: 0, + }, + 'test-key', + undefined, + undefined, + repo, + )) { + events.push(event); + } + + assert.equal(requests.length, 1); + const requestText = JSON.stringify(requests[0]); + assert.match(requestText, /stream-feature/); + assert.match(requestText, /stream base commit/); + assert.doesNotMatch(requestText, /caller/); + assert.equal(events[0]?.kind, 'meta'); + assert.equal(events.filter((event) => event.kind === 'text').map((event) => event.text).join(''), '1. chore: stream fixture'); + } finally { + process.chdir(previousCwd); + await stopServer(server); + rmSync(repoRoot, { recursive: true, force: true }); + rmSync(callerDir, { recursive: true, force: true }); + } +}); From defb290d33cef9e077e4c07d1833de5d348ff0f1 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:18:32 +1000 Subject: [PATCH 5/6] test: isolate batch config path across platforms (#315) --- tests/batch-prompt-context.test.mjs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/batch-prompt-context.test.mjs b/tests/batch-prompt-context.test.mjs index 875d38f..8465e50 100644 --- a/tests/batch-prompt-context.test.mjs +++ b/tests/batch-prompt-context.test.mjs @@ -8,7 +8,7 @@ import test from 'node:test'; import { batchCommand } from '../dist/commands/batch.js'; import { generateSuggestionsStream } from '../dist/llm/client.js'; -import { invalidateConfigCache } from '../dist/config/store.js'; +import { getConfigPath, getHistoryPath, invalidateConfigCache } from '../dist/config/store.js'; function createTempDir(prefix) { return mkdtempSync(join(tmpdir(), prefix)); @@ -34,11 +34,12 @@ function initRepo(root, name, branch, message) { return repoDir; } -function initConfig(configHome, baseUrl) { - const configDir = join(configHome, 'commit-echo'); - mkdirSync(configDir, { recursive: true }); +function initConfig(baseUrl) { + const configPath = getConfigPath(); + const historyPath = getHistoryPath(); + mkdirSync(join(configPath, '..'), { recursive: true }); writeFileSync( - join(configDir, 'config.json'), + configPath, JSON.stringify({ provider: '__custom__', model: 'fixture-model', @@ -51,7 +52,7 @@ function initConfig(configHome, baseUrl) { }), 'utf-8', ); - writeFileSync(join(configDir, 'history.jsonl'), '', 'utf-8'); + writeFileSync(historyPath, '', 'utf-8'); } async function startFixtureServer(requests) { @@ -94,6 +95,8 @@ test('batch resolves branch and previous message from each repository', async () const configHome = createTempDir('commit-echo-config-'); const previousCwd = process.cwd(); const previousXdg = process.env.XDG_CONFIG_HOME; + const previousHome = process.env.HOME; + const previousAppData = process.env.APPDATA; const requests = []; const { server, baseUrl } = await startFixtureServer(requests); @@ -106,8 +109,10 @@ test('batch resolves branch and previous message from each repository', async () git(['commit', '--allow-empty', '-m', 'caller commit'], callerRepo); git(['switch', '-c', 'caller-feature'], callerRepo); - initConfig(configHome, baseUrl); process.env.XDG_CONFIG_HOME = configHome; + process.env.HOME = configHome; + process.env.APPDATA = configHome; + initConfig(baseUrl); process.chdir(callerRepo); const result = await batchCommand({ directory: parent, recursive: false, yes: true }); @@ -135,6 +140,10 @@ test('batch resolves branch and previous message from each repository', async () process.chdir(previousCwd); if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previousXdg; + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousAppData; invalidateConfigCache(); await stopServer(server); rmSync(parent, { recursive: true, force: true }); From 648d94598d5216098e98449752c4a9ec908932b4 Mon Sep 17 00:00:00 2001 From: 404-Page-Found <139850808+404-Page-Found@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:33:24 +1000 Subject: [PATCH 6/6] test: isolate batch config env overrides --- tests/batch-prompt-context.test.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/batch-prompt-context.test.mjs b/tests/batch-prompt-context.test.mjs index 8465e50..027fabc 100644 --- a/tests/batch-prompt-context.test.mjs +++ b/tests/batch-prompt-context.test.mjs @@ -8,7 +8,7 @@ import test from 'node:test'; import { batchCommand } from '../dist/commands/batch.js'; import { generateSuggestionsStream } from '../dist/llm/client.js'; -import { getConfigPath, getHistoryPath, invalidateConfigCache } from '../dist/config/store.js'; +import { CONFIG_ENV_VARS, getConfigPath, getHistoryPath, invalidateConfigCache } from '../dist/config/store.js'; function createTempDir(prefix) { return mkdtempSync(join(tmpdir(), prefix)); @@ -97,10 +97,14 @@ test('batch resolves branch and previous message from each repository', async () const previousXdg = process.env.XDG_CONFIG_HOME; const previousHome = process.env.HOME; const previousAppData = process.env.APPDATA; + const previousConfigEnv = Object.fromEntries(CONFIG_ENV_VARS.map((name) => [name, process.env[name]])); const requests = []; const { server, baseUrl } = await startFixtureServer(requests); try { + for (const name of CONFIG_ENV_VARS) delete process.env[name]; + invalidateConfigCache(); + const repoA = initRepo(parent, 'alpha-repo', 'alpha-feature', 'alpha base commit'); const repoB = initRepo(parent, 'beta-repo', 'beta-feature', 'beta base commit'); git(['init'], callerRepo); @@ -144,6 +148,11 @@ test('batch resolves branch and previous message from each repository', async () else process.env.HOME = previousHome; if (previousAppData === undefined) delete process.env.APPDATA; else process.env.APPDATA = previousAppData; + for (const name of CONFIG_ENV_VARS) { + const value = previousConfigEnv[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } invalidateConfigCache(); await stopServer(server); rmSync(parent, { recursive: true, force: true });