From 7c26a53e0987317fc086ef4a85755192ca976b9f Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Sat, 6 Jun 2026 11:22:15 +0800 Subject: [PATCH] feat: add dry-run mode to suggest --- README.md | 4 +- src/commands/suggest.ts | 81 +++++++++++++-- src/index.ts | 4 + tests/e2e/suggest-dry-run.test.mjs | 98 +++++++++++++++++++ .../suggest-no-commit-deprecation.test.mjs | 75 ++++++++++++++ tests/suggest-dry-run-output.test.mjs | 90 +++++++++++++++++ 6 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/suggest-dry-run.test.mjs create mode 100644 tests/e2e/suggest-no-commit-deprecation.test.mjs create mode 100644 tests/suggest-dry-run-output.test.mjs diff --git a/README.md b/README.md index 374b1cc..250f86a 100644 --- a/README.md +++ b/README.md @@ -173,9 +173,11 @@ What it does: ### Generate suggestions without committing ```bash -commit-echo suggest --no-commit +commit-echo suggest ``` +`commit-echo suggest --no-commit` is still accepted as a deprecated compatibility alias. + Sample output: ```text diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts index f6306e9..0b4a5d2 100644 --- a/src/commands/suggest.ts +++ b/src/commands/suggest.ts @@ -20,6 +20,7 @@ import { checkGitRepo, getStagedDiff, getUnstagedDiff, + getBranchName, commit, } from "../git/diff.js"; import { @@ -27,8 +28,13 @@ import { generateSuggestions, generateSuggestionsStream, } from "../llm/client.js"; -import { appendEntry, buildProfile } from "../history/store.js"; -import { parseSuggestions } from "../llm/prompt.js"; +import { + parseSuggestions, + resolveSystemPrompt, + resolveUserPrompt, + truncateDiff, +} from "../llm/prompt.js"; +import { appendEntry, buildProfile, formatProfile } from "../history/store.js"; import { getStreamingProvider } from "../providers/index.js"; function showTruncationWarning(info: TruncationInfo): void { @@ -68,6 +74,37 @@ function showVerboseInfo( ); } +export function formatDryRunOutput( + diff: string, + profileSummary: string, + systemPrompt: string, + userPrompt: string, + truncation?: TruncationInfo, +): string { + return [ + pc.yellow("Dry run: no LLM API call will be made."), + "", + pc.bold("Diff:"), + pc.dim(diff), + "", + pc.bold("Style profile:"), + pc.dim(profileSummary), + "", + pc.bold("System prompt:"), + pc.dim(systemPrompt), + "", + pc.bold("User prompt:"), + pc.dim(userPrompt), + "", + pc.bold("Truncation:"), + pc.dim( + truncation + ? `${truncation.originalSize} -> ${truncation.truncatedSize} chars across ${truncation.filesTruncated} file(s)` + : "None. The diff above will be sent in full.", + ), + ].join("\n"); +} + async function displaySuggestions(suggestions: Suggestion[]): Promise { for (const s of suggestions) { const full = s.body ? `${s.message}\n ${pc.dim(s.body)}` : s.message; @@ -82,10 +119,20 @@ export async function suggestCommand( verbose?: boolean; model?: string; stream?: boolean; + dryRun?: boolean; + noCommit?: boolean; } = {}, ): Promise { intro(pc.bold(pc.cyan("commit-echo"))); + if (options.noCommit) { + console.warn( + pc.yellow( + "Note: --no-commit is deprecated; 'commit-echo suggest' already skips committing.", + ), + ); + } + try { checkGitRepo(); } catch (err) { @@ -119,6 +166,32 @@ export async function suggestCommand( } } + const profile = await buildProfile(config.historySize); + + if (options.dryRun) { + const { diff: truncatedDiff, info: truncation } = truncateDiff( + diffResult.diff, + config.maxDiffSize, + ); + const vars = { + diff: truncatedDiff, + profile: formatProfile(profile), + branch: getBranchName(), + }; + + console.log( + formatDryRunOutput( + truncatedDiff, + vars.profile, + resolveSystemPrompt(profile, vars, config), + resolveUserPrompt(vars, config), + truncation.wasTruncated ? truncation : undefined, + ), + ); + outro(pc.green("Dry run complete.")); + return; + } + let apiKey: string; try { apiKey = assertApiKeyAvailable(config); @@ -127,8 +200,6 @@ export async function suggestCommand( return; } - const profile = await buildProfile(config.historySize); - let suggestions: Suggestion[]; let truncation: TruncationInfo | undefined; let model: string; @@ -142,7 +213,6 @@ export async function suggestCommand( return; } - // Streaming mode: show text as it arrives console.log(pc.dim("Streaming suggestions...\n")); model = config.model; @@ -192,7 +262,6 @@ export async function suggestCommand( return; } } else { - // Non-streaming mode: use spinner and wait for full response const genSpinner = spinner(); genSpinner.start("Generating commit suggestions..."); diff --git a/src/index.ts b/src/index.ts index 76b94f8..10e3131 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,8 @@ program .option('-v, --verbose', 'Print diagnostic information about the suggestion request') .option('-m, --model ', 'Override the configured LLM model for this invocation') .option('--stream', 'Stream suggestions as they are generated (progressive output)') + .option('-n, --dry-run', 'Show the LLM input without generating suggestions') + .option('--no-commit', 'Deprecated alias; suggest already skips committing unless --commit is passed') .option('--auto', 'Alias for --yes') .action(async (options) => { const globalOpts = program.opts<{ yes?: boolean; auto?: boolean }>(); @@ -80,6 +82,8 @@ program verbose: Boolean(options.verbose), model: options.model, stream: Boolean(options.stream), + dryRun: Boolean(options.dryRun), + noCommit: process.argv.includes('--no-commit'), }); }); diff --git a/tests/e2e/suggest-dry-run.test.mjs b/tests/e2e/suggest-dry-run.test.mjs new file mode 100644 index 0000000..43b3f54 --- /dev/null +++ b/tests/e2e/suggest-dry-run.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { platform, tmpdir } from 'node:os'; +import { join } from 'node:path'; + +function configDirFor(home) { + return platform() === 'darwin' + ? join(home, 'Library', 'Application Support', 'commit-echo') + : platform() === 'win32' + ? join(home, 'AppData', 'Roaming', 'commit-echo') + : join(home, '.config', 'commit-echo'); +} + +function onceExit(child) { + return new Promise((resolve, reject) => { + child.on('error', reject); + child.on('exit', (code, signal) => resolve({ code, signal })); + }); +} + +test('suggest --dry-run prints the exact LLM inputs without calling the API', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'commit-echo-dry-run-')); + const home = join(root, 'home'); + const repo = join(root, 'repo'); + const configDir = configDirFor(home); + + t.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + + await mkdir(configDir, { recursive: true }); + await mkdir(repo, { recursive: true }); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['config', 'user.name', 'E2E Tester'], { cwd: repo }); + execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo }); + await writeFile(join(repo, 'README.md'), '# fixture\n', 'utf8'); + execFileSync('git', ['add', 'README.md'], { cwd: repo }); + execFileSync('git', ['commit', '-m', 'feat: initial fixture'], { cwd: repo }); + await writeFile(join(repo, 'README.md'), '# fixture\n\nupdated\n', 'utf8'); + execFileSync('git', ['add', 'README.md'], { cwd: repo }); + + await writeFile( + join(configDir, 'config.json'), + JSON.stringify( + { + provider: 'openai', + model: 'gpt-4.1', + historySize: 5, + maxDiffSize: 120, + systemPromptTemplate: 'system {{branch}} :: {{profile}}', + userPromptTemplate: 'user {{branch}} :: {{diff}}', + }, + null, + 2, + ), + 'utf8', + ); + + const child = spawn(process.execPath, [join(process.cwd(), 'dist/index.js'), 'suggest', '--dry-run'], { + cwd: repo, + env: { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + APPDATA: join(home, 'AppData', 'Roaming'), + FORCE_COLOR: '0', + OPENAI_API_KEY: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const result = await onceExit(child); + + assert.equal(result.code, 0); + assert.equal(stderr, ''); + assert.match(stdout, /Dry run: no LLM API call will be made\./); + assert.match(stdout, /Style profile:/); + assert.match(stdout, /System prompt:/); + assert.match(stdout, /User prompt:/); + assert.match(stdout, /Truncation:/); + assert.match(stdout, /system .* :: /); + assert.match(stdout, /user .* :: diff --git/); + assert.match(stdout, /\[\.\.\.truncated 1 file\.\.\.\]/); + assert.match(stdout, /Dry run complete\./); + assert.doesNotMatch(stdout, /Generating commit suggestions/); + assert.doesNotMatch(stdout, /Suggestions generated:/); +}); diff --git a/tests/e2e/suggest-no-commit-deprecation.test.mjs b/tests/e2e/suggest-no-commit-deprecation.test.mjs new file mode 100644 index 0000000..3087db6 --- /dev/null +++ b/tests/e2e/suggest-no-commit-deprecation.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { platform, tmpdir } from 'node:os'; +import { join } from 'node:path'; + +function configDirFor(home) { + return platform() === 'darwin' + ? join(home, 'Library', 'Application Support', 'commit-echo') + : platform() === 'win32' + ? join(home, 'AppData', 'Roaming', 'commit-echo') + : join(home, '.config', 'commit-echo'); +} + +function onceExit(child) { + return new Promise((resolve, reject) => { + child.on('error', reject); + child.on('exit', (code, signal) => resolve({ code, signal })); + }); +} + +test('suggest --no-commit prints a deprecation warning', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'commit-echo-no-commit-warning-')); + const home = join(root, 'home'); + const repo = join(root, 'repo'); + const configDir = configDirFor(home); + + t.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + + await mkdir(configDir, { recursive: true }); + await mkdir(repo, { recursive: true }); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['config', 'user.name', 'E2E Tester'], { cwd: repo }); + execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo }); + await writeFile(join(repo, 'README.md'), '# fixture\n', 'utf8'); + execFileSync('git', ['add', 'README.md'], { cwd: repo }); + execFileSync('git', ['commit', '-m', 'feat: initial fixture'], { cwd: repo }); + + await writeFile( + join(configDir, 'config.json'), + JSON.stringify({ provider: 'openai', model: 'gpt-4.1', historySize: 5, maxDiffSize: 4000 }, null, 2), + 'utf8', + ); + + const child = spawn(process.execPath, [join(process.cwd(), 'dist/index.js'), 'suggest', '--no-commit'], { + cwd: repo, + env: { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + APPDATA: join(home, 'AppData', 'Roaming'), + FORCE_COLOR: '0', + OPENAI_API_KEY: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const result = await onceExit(child); + + assert.equal(result.code, 0); + assert.match(stderr, /--no-commit is deprecated/); + assert.match(stdout, /No changes detected/); +}); diff --git a/tests/suggest-dry-run-output.test.mjs b/tests/suggest-dry-run-output.test.mjs new file mode 100644 index 0000000..64221b5 --- /dev/null +++ b/tests/suggest-dry-run-output.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { formatDryRunOutput } from '../dist/commands/suggest.js'; +import { resolveSystemPrompt, resolveUserPrompt, truncateDiff } from '../dist/llm/prompt.js'; + +const EMPTY_PROFILE = { + avgLength: 0, + commonPrefixes: [], + prefixRates: {}, + imperativeRate: 0, + sentenceCaseRate: 0, + usesScopeRate: 0, + usesBodyRate: 0, + totalCommits: 0, +}; + +test('formats dry-run output with the LLM inputs and truncation info', () => { + const output = formatDryRunOutput( + 'diff --git a/file.ts b/file.ts', + 'Analyzed 2 commit(s)', + 'system prompt text', + 'user prompt text', + ); + + assert.match(output, /no LLM API call will be made/); + assert.match(output, /Diff:/); + assert.match(output, /diff --git a\/file\.ts b\/file\.ts/); + assert.match(output, /Style profile:/); + assert.match(output, /Analyzed 2 commit\(s\)/); + assert.match(output, /System prompt:/); + assert.match(output, /system prompt text/); + assert.match(output, /User prompt:/); + assert.match(output, /user prompt text/); + assert.match(output, /Truncation:/); + assert.match(output, /sent in full/); +}); + +test('dry-run prompt construction matches template substitution path', () => { + const vars = { + diff: 'trimmed diff', + profile: 'profile summary', + branch: 'feature/dry-run', + }; + const config = { + provider: 'openai', + model: 'gpt-4.1', + historySize: 50, + maxDiffSize: 4000, + systemPromptTemplate: 'system {{branch}} :: {{profile}}', + userPromptTemplate: 'user {{branch}} :: {{diff}}', + }; + + const output = formatDryRunOutput( + vars.diff, + vars.profile, + resolveSystemPrompt(EMPTY_PROFILE, vars, config), + resolveUserPrompt(vars, config), + ); + + assert.match(output, /system feature\/dry-run :: profile summary/); + assert.match(output, /user feature\/dry-run :: trimmed diff/); +}); + +test('dry-run truncation output matches the real prompt payload', () => { + const largeDiff = [ + 'diff --git a/src/a.ts b/src/a.ts', + 'index abc..def 100644', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1,1 +1,20 @@', + ...Array.from({ length: 40 }, (_, i) => `+line ${i}`), + ].join('\n'); + + const { diff, info } = truncateDiff(largeDiff, 120); + const output = formatDryRunOutput( + diff, + 'profile summary', + 'system prompt text', + 'user prompt text', + info.wasTruncated ? info : undefined, + ); + + assert.match(output, /\[\.\.\.truncated 1 file\.\.\.\]/); + assert.match( + output, + new RegExp(`${info.originalSize} -> ${info.truncatedSize} chars across ${info.filesTruncated} file\\(s\\)`), + ); + assert.doesNotMatch(output, /sent in full/); +});