import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import fs from 'fs/promises'; import path from 'path'; import axios from 'axios'; import { simpleGit } from 'simple-git'; import { getApiKey, getApiUrl, getProjectId } from '../utils/config.js'; import { isCodeFile } from '../utils/files.js'; const git = simpleGit(); export function createCommitCommand(): Command { return new Command('commit') .description('Commit changes with automatic Task ID tagging (Git Trailers)') .argument('[message]', 'Commit message (optional - will auto-generate if omitted)') .option('-a, --all', 'Stage all changes before committing', false) .option('--skip-scan', 'Skip Frank\'s Pre-check quality gate (not recommended)', false) .action(async (message, options) => { const spinner = ora('Preparing commit...').start(); try { const { projectId, apiKey, apiUrl } = getContext(); // 0. Stage changes if requested (Do this early to capture diff for auto-message) if (options.all) { spinner.text = 'Staging all changes...'; await git.add('.'); } // 1. Auto-generate message if missing let finalMessage = message; if (!finalMessage) { spinner.text = 'Analyzing changes for auto-message...'; const diff = await git.diff(['--cached']); if (!diff) { spinner.fail(chalk.red('No staged changes detected. Stage files manually or use -a.')); return; } try { const response = await axios.post( `${apiUrl}/api/v1/agent/scribe/summarize`, { project_id: projectId, diff }, { headers: { 'Authorization': `Bearer ${apiKey}` }, timeout: 15000 } ); if (response.data.success) { finalMessage = response.data.summary; spinner.info(chalk.dim(`Auto-generated: "${finalMessage}"`)); spinner.start(); } else { throw new Error(response.data.error || 'Unknown API error'); } } catch (e: any) { spinner.warn(chalk.yellow(`AI Summarizer failed: ${e.message}. Using fallback.`)); finalMessage = `chore: update ${new Date().toLocaleDateString()}`; spinner.start(); } } // 2. Detect Task ID const contextPath = path.join(process.cwd(), '.rigstate', 'CURRENT_CONTEXT.md'); const contextExists = await fs.access(contextPath).then(() => true).catch(() => false); let taskId = ''; if (contextExists) { const content = await fs.readFile(contextPath, 'utf-8'); const match = content.match(/\*\*ID:\*\*\s*(.+?)(?:\s|\n|$)/i); if (match) taskId = match[1].trim(); } if (!taskId) { spinner.warn(chalk.yellow('No active Task ID detected in .rigstate/CURRENT_CONTEXT.md')); } // 2. Stage changes if requested if (options.all) { spinner.text = 'Staging all changes...'; await git.add('.'); } // 3. Frank's Pre-check (Quality Gate) if (!options.skip_scan) { spinner.start('🛡️ Frank\'s Pre-check: Scanning staged files...'); const status = await git.status(); const stagedFiles = status.staged.filter(f => isCodeFile(f)); if (stagedFiles.length > 0) { let criticalIssues = 0; let highIssues = 0; for (const file of stagedFiles) { spinner.text = `Scanning ${file}...`; try { const content = await fs.readFile(path.resolve(process.cwd(), file), 'utf-8'); const response = await axios.post( `${apiUrl}/api/v1/audit`, { content, file_path: file, project_id: projectId }, { headers: { 'Authorization': `Bearer ${apiKey}` }, timeout: 10000 } ); const vulnerabilities = response.data.vulnerabilities || []; vulnerabilities.forEach((v: any) => { if (v.severity === 'critical') criticalIssues++; if (v.severity === 'high') highIssues++; }); } catch (e) { // Skip individual file errors during pre-check to avoid blocking dev } } if (criticalIssues > 0 || highIssues > 0) { spinner.fail(chalk.red(`\n❌ Quality Gate Failed: Found ${criticalIssues} critical and ${highIssues} high issues.`)); console.log(chalk.dim('Run "rigstate scan" for full report. Use --skip-scan to override (guilt-trip included).')); process.exit(1); } } spinner.succeed('Frank\'s Pre-check: Clean code detected.'); } // 4. Construct message with Trailer if (taskId) { finalMessage = `${finalMessage}\n\nRigstate-Task: ${taskId}`; } // 5. Execute Commit spinner.start('Executing git commit...'); await git.commit(finalMessage); spinner.succeed(chalk.green('Changes committed successfully.')); if (taskId) { console.log(chalk.dim(` Linked to task: ${taskId} (via Git Trailer)`)); } } catch (error: any) { spinner.fail(chalk.red(`Commit failed: ${error.message}`)); process.exit(1); } }); } function getContext() { const apiKey = getApiKey(); const apiUrl = getApiUrl(); const projectId = getProjectId(); if (!projectId) { throw new Error('Project ID missing. Run rigstate link.'); } // Single source of truth for Auth logging console.log(chalk.dim(` [Auth] Context: Project ID ${projectId.substring(0, 8)}...`)); console.log(chalk.dim(` [Auth] Endpoint: ${apiUrl}`)); return { projectId, apiKey, apiUrl }; }