diff --git a/src/commands/batch.ts b/src/commands/batch.ts new file mode 100644 index 0000000..a94f134 --- /dev/null +++ b/src/commands/batch.ts @@ -0,0 +1,481 @@ +import { existsSync, readdirSync, statSync, writeFileSync, unlinkSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { execSync, spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { intro, outro, confirm, select, text, isCancel } from '@clack/prompts'; +import pc from 'picocolors'; +import { loadOrPromptConfig } from '../config/store.js'; +import { assertApiKeyAvailable, generateSuggestions } from '../llm/client.js'; +import { buildProfile, appendEntry } from '../history/store.js'; +import type { Config, Suggestion } from '../types.js'; + +export interface BatchResult { + repo: string; + repoName: string; + status: 'success' | 'skipped' | 'failed'; + message?: string; +} + +/** + * Scan a directory for git repositories (directories containing a `.git` folder). + * When `recursive` is true, descends into subdirectories to find nested repos. + */ +export function findGitRepositories(rootDir: string, recursive: boolean): string[] { + const repos: string[] = []; + + if (!existsSync(rootDir)) return repos; + + // If rootDir itself is a git repo, return it directly + if (existsSync(join(rootDir, '.git'))) { + repos.push(rootDir); + return repos.sort(); + } + + let entries; + try { + entries = readdirSync(rootDir, { withFileTypes: true }); + } catch { + return repos; // skip unreadable directories + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + + const fullPath = join(rootDir, entry.name); + + if (existsSync(join(fullPath, '.git'))) { + repos.push(fullPath); + } else if (recursive) { + repos.push(...findGitRepositories(fullPath, true)); + } + } + + return repos.sort(); +} + +/** + * Check whether a git repository at `cwd` has staged or unstaged changes. + */ +export function gitHasChanges(cwd: string): { staged: boolean; unstaged: boolean } { + let staged = false; + let unstaged = false; + + try { + execSync('git diff --cached --quiet', { cwd, stdio: 'pipe' }); + } catch { + staged = true; + } + + try { + execSync('git diff --quiet', { cwd, stdio: 'pipe' }); + } catch { + unstaged = true; + } + + return { staged, unstaged }; +} + +/** + * Get the git diff for a repository at `cwd`. + */ +export function getGitDiff(cwd: string, staged: boolean): string { + const cmd = staged ? 'git diff --cached' : 'git diff'; + try { + return execSync(cmd, { cwd, encoding: 'utf-8', maxBuffer: 100 * 1024 * 1024 }).trim(); + } catch (err) { + throw new Error( + `Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Run `git commit` inside a specific repository directory. + */ +export function gitCommit( + cwd: string, + message: string, + body?: string, +): { hash: string; summary: string } { + const fullMessage = body ? `${message}\n\n${body}` : message; + const tmpFile = join( + tmpdir(), + `commit-echo-batch-${process.pid}-${Date.now()}.txt`, + ); + + try { + writeFileSync(tmpFile, fullMessage, 'utf-8'); + const result = spawnSync('git', ['commit', '-F', tmpFile], { + cwd, + encoding: 'utf-8', + shell: false, + }); + + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = [result.stderr, result.stdout] + .filter(Boolean) + .join('\n') + .trim(); + throw new Error(detail || `git commit exited with code ${result.status}`); + } + + const summary = result.stdout.trim().split('\n').find(Boolean) ?? ''; + const match = summary.match( + /\[.*?([a-f0-9]{7,})\]\s+(.+)$/i, + ); + + return { + hash: match?.[1] ?? '', + summary: match?.[2] ?? summary, + }; + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + } +} + +/** + * Display a set of suggestions to the user. + */ +function displaySuggestions(suggestions: Suggestion[]): void { + for (const s of suggestions) { + const full = s.body ? `${s.message}\n ${pc.dim(s.body)}` : s.message; + console.log(` ${pc.cyan(`${s.index}.`)} ${full}`); + } +} + +export async function batchCommand( + options: { + directory?: string; + recursive?: boolean; + yes?: boolean; + } = {}, +): Promise { + intro(pc.bold(pc.cyan('commit-echo batch'))); + + const dir = options.directory ?? process.cwd(); + + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + outro(pc.red(`Directory not found: ${dir}`)); + return; + } + + // Discover git repositories in the target directory + const repos = findGitRepositories(dir, options.recursive ?? false); + + if (repos.length === 0) { + outro(pc.yellow(`No git repositories found in ${dir}`)); + return; + } + + console.log( + `\n Found ${pc.bold(String(repos.length))} repo(s) — checking for changes...\n`, + ); + + // Load configuration once (shared across all repos) + let config: Config; + try { + config = await loadOrPromptConfig(); + } catch (err) { + outro(pc.red(err instanceof Error ? err.message : 'Configuration error')); + return; + } + + // Verify API key once + let apiKey: string; + try { + apiKey = assertApiKeyAvailable(config); + } catch (err) { + outro(pc.red(err instanceof Error ? err.message : 'Missing API key')); + return; + } + + // Build style profile once (shared across all repos) + const profile = await buildProfile(config.historySize); + + const results: BatchResult[] = []; + + for (const repoPath of repos) { + const repoName = basename(repoPath); + console.log(` ${pc.bold(pc.cyan(`▶ ${repoName}`))} ${pc.dim(repoPath)}`); + + // Check what kind of changes exist + const { staged, unstaged } = gitHasChanges(repoPath); + + if (!staged) { + if (!unstaged) { + console.log(` ${pc.yellow('↻ No changes found, skipping')}\n`); + results.push({ + repo: repoPath, + repoName, + status: 'skipped', + message: 'No changes', + }); + continue; + } + console.log( + ` ${pc.yellow('ℹ Unstaged changes only (stage with `git add` first), skipping')}\n`, + ); + results.push({ + repo: repoPath, + repoName, + status: 'skipped', + message: 'Unstaged only', + }); + continue; + } + + // Get the staged diff + let diff: string; + try { + diff = getGitDiff(repoPath, true); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` ${pc.red(`✖ ${msg}`)}\n`); + results.push({ repo: repoPath, repoName, status: 'failed', message: msg }); + continue; + } + + if (!diff) { + console.log(` ${pc.yellow('↻ Empty diff, skipping')}\n`); + results.push({ + repo: repoPath, + repoName, + status: 'skipped', + message: 'Empty diff', + }); + continue; + } + + // Generate suggestions using the shared profile + let suggestions: Suggestion[]; + try { + const result = await generateSuggestions(config, diff, profile, apiKey); + suggestions = result.suggestions; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log( + ` ${pc.red(`✖ Failed to generate suggestions: ${msg}`)}\n`, + ); + results.push({ + repo: repoPath, + repoName, + status: 'failed', + message: msg, + }); + continue; + } + + // Display suggestions + console.log(''); + displaySuggestions(suggestions); + + if (options.yes) { + // Unattended mode: auto-select first suggestion and commit + const first = suggestions[0]; + if (!first) { + console.log( + ` ${pc.yellow('↻ No suggestions generated, skipping')}`, + ); + results.push({ + repo: repoPath, + repoName, + status: 'skipped', + message: 'No suggestions', + }); + console.log(''); + continue; + } + + try { + const commitResult = gitCommit(repoPath, first.message, first.body); + await appendEntry({ + timestamp: new Date().toISOString(), + message: first.body + ? `${first.message}\n\n${first.body}` + : first.message, + diff, + model: config.model, + provider: config.provider, + }); + console.log( + ` ${pc.green(`✓ ${pc.bold(commitResult.hash)} ${commitResult.summary}`)}`, + ); + results.push({ + repo: repoPath, + repoName, + status: 'success', + message: first.message, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` ${pc.red(`✖ Commit failed: ${msg}`)}`); + results.push({ + repo: repoPath, + repoName, + status: 'failed', + message: msg, + }); + } + } else if (suggestions.length > 0) { + // Interactive mode: prompt per repo + const proceed = await confirm({ + message: `Commit changes in ${repoName}?`, + initialValue: true, + }); + + if (isCancel(proceed)) { + console.log(` ${pc.dim('– Cancelled, skipping')}`); + results.push({ + repo: repoPath, + repoName, + status: 'skipped', + message: 'Cancelled', + }); + console.log(''); + continue; + } + + if (!proceed) { + console.log(` ${pc.dim('– Skipped')}`); + results.push({ + repo: repoPath, + repoName, + status: 'skipped', + message: 'User skipped', + }); + console.log(''); + continue; + } + + // Let user select which suggestion to use + const suggestionOptions = suggestions.map((s) => ({ + value: s.index, + label: + s.message.length > 60 + ? s.message.slice(0, 57) + '...' + : s.message, + })); + + const selectedIndex = await select({ + message: `Select message for ${repoName}:`, + options: suggestionOptions, + }); + + if (isCancel(selectedIndex)) { + console.log(` ${pc.dim('– Cancelled, skipping')}`); + results.push({ + repo: repoPath, + repoName, + status: 'skipped', + message: 'Cancelled', + }); + console.log(''); + continue; + } + + const selected = suggestions.find( + (s) => s.index === selectedIndex, + ); + if (!selected) { + console.log(` ${pc.red('✖ Invalid selection')}`); + results.push({ + repo: repoPath, + repoName, + status: 'failed', + message: 'Invalid selection', + }); + console.log(''); + continue; + } + + // Prompt for an optional commit body (consistent with `suggest` UX) + const customBody = await text({ + message: `Optional body for ${repoName}:`, + initialValue: selected.body ?? '', + }); + const finalBody = + isCancel(customBody) || !customBody + ? selected.body + : customBody; + + try { + const commitResult = gitCommit( + repoPath, + selected.message, + finalBody, + ); + await appendEntry({ + timestamp: new Date().toISOString(), + message: finalBody + ? `${selected.message}\n\n${finalBody}` + : selected.message, + diff, + model: config.model, + provider: config.provider, + }); + console.log( + ` ${pc.green(`✓ ${pc.bold(commitResult.hash)} ${commitResult.summary}`)}`, + ); + results.push({ + repo: repoPath, + repoName, + status: 'success', + message: selected.message, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` ${pc.red(`✖ Commit failed: ${msg}`)}`); + results.push({ + repo: repoPath, + repoName, + status: 'failed', + message: msg, + }); + } + } else { + console.log( + ` ${pc.yellow('↻ No suggestions generated, skipping')}`, + ); + results.push({ + repo: repoPath, + repoName, + status: 'skipped', + message: 'No suggestions', + }); + } + + console.log(''); + } + + // Print summary report + const succeeded = results.filter((r) => r.status === 'success'); + const failed = results.filter((r) => r.status === 'failed'); + const skipped = results.filter((r) => r.status === 'skipped'); + + console.log(pc.bold('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')); + console.log(pc.bold('📋 Batch Summary\n')); + + for (const r of results) { + const icon = + r.status === 'success' + ? pc.green('✓') + : r.status === 'failed' + ? pc.red('✖') + : pc.yellow('–'); + const msg = r.message + ? ` — ${r.message.length > 60 ? r.message.slice(0, 57) + '...' : r.message}` + : ''; + console.log(` ${icon} ${r.repoName}${pc.dim(msg)}`); + } + + console.log( + `\n ${pc.green(String(succeeded.length))} succeeded, ${pc.yellow(String(skipped.length))} skipped, ${pc.red(String(failed.length))} failed`, + ); + outro('Batch processing complete.'); +} diff --git a/src/index.ts b/src/index.ts index ab06ce8..76b94f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { initCommand } from './commands/init.js'; import { suggestCommand } from './commands/suggest.js'; import { historyCommand } from './commands/history.js'; +import { batchCommand } from './commands/batch.js'; import { getAvailableTemplateVars } from './llm/prompt.js'; import { runPostCommitHook, runPrepareCommitMsgHook } from './git/hook.js'; @@ -39,6 +40,9 @@ ${pc.dim('Examples:')} ${pc.cyan('commit-echo suggest')} Generate suggestions without committing ${pc.cyan('commit-echo suggest --yes')} Auto-select first suggestion (no commit) ${pc.cyan('commit-echo history')} View learned style profile and history + ${pc.cyan('commit-echo batch')} Process all git repos in current directory + ${pc.cyan('commit-echo batch --recursive')} Search subdirectories for repos + ${pc.cyan('commit-echo batch --yes')} Auto-commit repos with staged changes ${pc.dim('Custom prompt template variables:')} ${getAvailableTemplateVars() @@ -81,6 +85,22 @@ program program.command('history').description('View learned style profile and recent commit history').action(historyCommand); +program + .command('batch') + .description('Process multiple git repositories in batch mode') + .argument('[directory]', 'Directory to scan for git repositories') + .option('-r, --recursive', 'Recursively search subdirectories for git repos') + .option('-y, --yes', 'Automatically accept the first suggestion and commit without prompts') + .option('--auto', 'Alias for --yes') + .action(async (directory, options) => { + const globalOpts = program.opts<{ yes?: boolean; auto?: boolean }>(); + await batchCommand({ + directory: directory || undefined, + recursive: Boolean(options.recursive), + yes: Boolean(options.yes || options.auto || globalOpts.yes || globalOpts.auto), + }); + }); + const hookCommand = new Command('hook') .description('Internal Git hook entry point') .argument('', 'Git hook name') diff --git a/tests/batch.test.mjs b/tests/batch.test.mjs new file mode 100644 index 0000000..78d3884 --- /dev/null +++ b/tests/batch.test.mjs @@ -0,0 +1,323 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + findGitRepositories, + gitHasChanges, + getGitDiff, + gitCommit, +} from '../dist/commands/batch.js'; + +function createTempDir() { + return realpathSync.native( + mkdtempSync(join(tmpdir(), 'commit-echo-batch-test-')), + ); +} + +function git(args, cwd) { + return execFileSync('git', args, { + cwd, + encoding: 'utf-8', + stdio: 'pipe', + }); +} + +function initRepo(root, name) { + 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); + + return repoDir; +} + +// ─── findGitRepositories ──────────────────────────────────────────────────── + +test('findGitRepositories returns repos in a flat directory', () => { + const root = createTempDir(); + try { + const repoA = initRepo(root, 'repo-a'); + const repoB = initRepo(root, 'repo-b'); + const repos = findGitRepositories(root, false); + + assert.equal(repos.length, 2); + assert.ok(repos.includes(repoA)); + assert.ok(repos.includes(repoB)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('findGitRepositories ignores hidden directories', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'visible-repo'); + mkdirSync(join(root, '.hidden'), { recursive: true }); + const repos = findGitRepositories(root, false); + + assert.equal(repos.length, 1); + assert.equal(repos[0], repo); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('findGitRepositories non-recursive does not descend into subdirectories', () => { + const root = createTempDir(); + try { + const topRepo = initRepo(root, 'top-repo'); + const nestedDir = join(root, 'nested'); + mkdirSync(nestedDir, { recursive: true }); + const nestedRepo = initRepo(nestedDir, 'inner-repo'); + + const flat = findGitRepositories(root, false); + assert.equal(flat.length, 1); + assert.equal(flat[0], topRepo); + + const recursive = findGitRepositories(root, true); + assert.equal(recursive.length, 2); + assert.ok(recursive.includes(topRepo)); + assert.ok(recursive.includes(nestedRepo)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('findGitRepositories returns empty array for non-existent directory', () => { + const repos = findGitRepositories('/path/does/not/exist', false); + assert.deepEqual(repos, []); +}); + +test('findGitRepositories returns empty array for directory with no repos', () => { + const root = createTempDir(); + try { + mkdirSync(join(root, 'plain-dir'), { recursive: true }); + const repos = findGitRepositories(root, false); + assert.deepEqual(repos, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('findGitRepositories sorts results alphabetically', () => { + const root = createTempDir(); + try { + const repoB = initRepo(root, 'b-repo'); + const repoA = initRepo(root, 'a-repo'); + const repoC = initRepo(root, 'c-repo'); + + const repos = findGitRepositories(root, false); + assert.equal(repos.length, 3); + assert.equal(repos[0], repoA); + assert.equal(repos[1], repoB); + assert.equal(repos[2], repoC); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('findGitRepositories returns rootDir when it is itself a git repo', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'inner'); + // Point directly at the repo itself, not its parent + const repos = findGitRepositories(repo, false); + + assert.equal(repos.length, 1); + assert.equal(repos[0], repo); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('findGitRepositories returns rootDir even with recursive flag', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'inner'); + const repos = findGitRepositories(repo, true); + + assert.equal(repos.length, 1); + assert.equal(repos[0], repo); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// ─── gitHasChanges ────────────────────────────────────────────────────────── + +test('gitHasChanges detects staged changes', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + writeFileSync(join(repo, 'file.txt'), 'content\n', 'utf-8'); + git(['add', 'file.txt'], repo); + + const { staged, unstaged } = gitHasChanges(repo); + assert.equal(staged, true); + assert.equal(unstaged, false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('gitHasChanges detects unstaged changes', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + // Create a tracked file first, then modify it + writeFileSync(join(repo, 'file.txt'), 'initial\n', 'utf-8'); + git(['add', 'file.txt'], repo); + git(['commit', '-m', 'feat: initial'], repo); + writeFileSync(join(repo, 'file.txt'), 'modified\n', 'utf-8'); + + const { staged, unstaged } = gitHasChanges(repo); + assert.equal(staged, false); + assert.equal(unstaged, true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('gitHasChanges returns false for clean repo', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + writeFileSync(join(repo, 'file.txt'), 'content\n', 'utf-8'); + git(['add', 'file.txt'], repo); + git(['commit', '-m', 'feat: initial'], repo); + + const { staged, unstaged } = gitHasChanges(repo); + assert.equal(staged, false); + assert.equal(unstaged, false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('gitHasChanges detects both staged and unstaged', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + // Create a tracked file + writeFileSync(join(repo, 'tracked.txt'), 'base\n', 'utf-8'); + git(['add', 'tracked.txt'], repo); + git(['commit', '-m', 'feat: initial'], repo); + // Modify and stage it + writeFileSync(join(repo, 'tracked.txt'), 'staged change\n', 'utf-8'); + git(['add', 'tracked.txt'], repo); + // Modify again (unstaged) + writeFileSync(join(repo, 'tracked.txt'), 'staged + unstaged\n', 'utf-8'); + + const { staged, unstaged } = gitHasChanges(repo); + assert.equal(staged, true); + assert.equal(unstaged, true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// ─── getGitDiff ───────────────────────────────────────────────────────────── + +test('getGitDiff returns the staged diff', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + writeFileSync(join(repo, 'file.txt'), 'hello\n', 'utf-8'); + git(['add', 'file.txt'], repo); + + const diff = getGitDiff(repo, true); + assert.match(diff, /diff --git/); + assert.match(diff, /\+hello/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('getGitDiff returns the unstaged diff', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + writeFileSync(join(repo, 'file.txt'), 'initial\n', 'utf-8'); + git(['add', 'file.txt'], repo); + git(['commit', '-m', 'feat: initial'], repo); + writeFileSync(join(repo, 'file.txt'), 'modified\n', 'utf-8'); + + const diff = getGitDiff(repo, false); + assert.match(diff, /diff --git/); + assert.match(diff, /-initial/); + assert.match(diff, /\+modified/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('getGitDiff throws when not in a git repo', () => { + const root = createTempDir(); + try { + assert.throws(() => getGitDiff(root, true), /Failed to get diff/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// ─── gitCommit ──────────────────────────────────────────────────────────── + +test('gitCommit creates a commit and returns hash and summary', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + writeFileSync(join(repo, 'file.txt'), 'content\n', 'utf-8'); + git(['add', 'file.txt'], repo); + + const result = gitCommit(repo, 'feat: initial commit'); + + assert.ok(result.hash, 'expected a commit hash'); + assert.ok(/^[0-9a-f]+$/.test(result.hash), 'hash should be hex'); + assert.ok(result.hash.length >= 7, 'hash should be at least 7 chars'); + assert.ok(result.summary.includes('feat: initial commit')); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('gitCommit includes body in the commit message', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + writeFileSync(join(repo, 'file.txt'), 'content\n', 'utf-8'); + git(['add', 'file.txt'], repo); + + const result = gitCommit(repo, 'feat: with body', 'Optional body text here'); + + assert.ok(result.hash, 'expected a commit hash'); + // Verify body is in the full commit message + const log = git(['log', '--format=%B', '-1'], repo); + assert.match(log, /feat: with body/); + assert.match(log, /Optional body text here/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('gitCommit throws on empty commit (nothing to commit)', () => { + const root = createTempDir(); + try { + const repo = initRepo(root, 'repo'); + assert.throws(() => gitCommit(repo, 'message'), /nothing to commit/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});