Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 75 additions & 6 deletions src/commands/suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ import {
checkGitRepo,
getStagedDiff,
getUnstagedDiff,
getBranchName,
commit,
} from "../git/diff.js";
import {
assertApiKeyAvailable,
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 {
Expand Down Expand Up @@ -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<void> {
for (const s of suggestions) {
const full = s.body ? `${s.message}\n ${pc.dim(s.body)}` : s.message;
Expand All @@ -82,10 +119,20 @@ export async function suggestCommand(
verbose?: boolean;
model?: string;
stream?: boolean;
dryRun?: boolean;
noCommit?: boolean;
} = {},
): Promise<void> {
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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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...");

Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ program
.option('-v, --verbose', 'Print diagnostic information about the suggestion request')
.option('-m, --model <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 }>();
Expand All @@ -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'),
});
});

Expand Down
98 changes: 98 additions & 0 deletions tests/e2e/suggest-dry-run.test.mjs
Original file line number Diff line number Diff line change
@@ -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:/);
});
75 changes: 75 additions & 0 deletions tests/e2e/suggest-no-commit-deprecation.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading