From ba2cb86c98dfd55fb7bbbefd1adb865938256f08 Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Sat, 13 Jun 2026 13:19:08 +0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20add=20`cora=20commit`=20=E2=80=94?= =?UTF-8?q?=20review=20+=20auto=20commit=20message=20+=20quality=20gate=20?= =?UTF-8?q?(#262)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New src/commands/commit_cmd.rs with full commit workflow - HITL mode (default): interactive [Y]es/[E]dit/[N]o prompt - YOLO mode (--yolo): auto-commit, zero prompts - --force: commit even if quality gate fails - --no-review: skip review, only generate commit message - --edit: always open $EDITOR - Conventional commit format (feat/fix/refactor/perf/docs/test/chore/style/build/ci) - Auto-truncates long subjects to 72 chars - Quality gate integration (block on FAIL unless --force) - Debt snapshot saved after commit - LLM prompt optimized for commit message generation - chat_completion_raw() + chat_completion_stream_raw() in engine/llm.rs - 22 unit tests (parse, prompt, stats, message formatting) - All 517 tests pass, clippy clean, fmt clean --- src/commands/commit_cmd.rs | 560 +++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/engine/llm.rs | 19 ++ src/main.rs | 57 +++- 4 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 src/commands/commit_cmd.rs diff --git a/src/commands/commit_cmd.rs b/src/commands/commit_cmd.rs new file mode 100644 index 00000000..92f61f9f --- /dev/null +++ b/src/commands/commit_cmd.rs @@ -0,0 +1,560 @@ +//! `cora commit` subcommand β€” review staged diff + generate commit message + commit. + +use anyhow::{Context, Result}; +use colored::Colorize; +use std::io::{self, Write}; + +use crate::config::schema::Config; +use crate::engine::llm; +use crate::engine::quality_gate; +use crate::engine::types::LLMConfig; +use crate::formatters::OutputFormat; + +/// Exit codes. +const EXIT_OK: i32 = 0; +const EXIT_ERROR: i32 = 1; +const EXIT_BLOCKED: i32 = 2; + +/// Commit subcommand options. +pub struct CommitOptions { + /// YOLO mode β€” auto-commit, no prompts. + pub yolo: bool, + /// Force commit even if quality gate fails. + pub force: bool, + /// Skip review, only generate commit message. + pub no_review: bool, + /// Always open editor to edit message. + pub edit: bool, + /// Stream LLM response. + pub stream: bool, + /// Quiet mode. + pub quiet: bool, +} + +/// Result of commit message generation. +#[derive(Debug, Clone)] +pub struct CommitMessage { + /// The subject line (first line). + pub subject: String, + /// The body lines (after subject). + pub body: String, +} + +impl CommitMessage { + /// Full message with subject + body. + pub fn full(&self) -> String { + if self.body.is_empty() { + self.subject.clone() + } else { + format!("{}\n\n{}", self.subject, self.body) + } + } +} + +/// Execute the `cora commit` subcommand. +pub async fn execute_commit( + config: &Config, + llm_config: &LLMConfig, + opts: &CommitOptions, +) -> Result { + // 1. Get staged diff + let diff = crate::git::get_staged_diff() + .map_err(|e| anyhow::anyhow!("Failed to get staged diff: {e}. Run `git add` first."))?; + + if diff.trim().is_empty() { + if opts.quiet { + return Ok(EXIT_OK); + } + eprintln!( + "{}", + "Nothing staged. Use `git add` to stage changes first.".yellow() + ); + return Ok(EXIT_ERROR); + } + + if !opts.quiet { + let (files, lines) = diff_stats(&diff); + eprintln!( + "{}", + format!("πŸ” Reviewing staged changes ({files} files, {lines} lines)…").cyan() + ); + } + + // 2. Review (unless --no-review) + let _review_response = if opts.no_review { + None + } else { + let response = crate::engine::review::review_diff_with_cache( + config, + llm_config, + &diff, + opts.stream, + true, + opts.quiet, + None, + ) + .await?; + + // Filter by severity + let min_severity = config.hook.min_severity_level(); + let mut filtered = response.clone(); + filtered.issues.retain(|i| i.severity <= min_severity); + + // Quality gate + let gate_result = if config.quality_gate.enabled { + let result = quality_gate::evaluate(&filtered.issues, &config.quality_gate); + Some(result) + } else { + None + }; + + // Check gate + let gate_failed = gate_result + .as_ref() + .is_some_and(|g| g.status == quality_gate::GateStatus::Fail); + + // Save debt snapshot (best-effort) + if config.debt.enabled { + let (commit, branch) = get_git_context(); + let snapshot = crate::engine::debt_tracker::DebtSnapshot::from_review( + &filtered.issues, + gate_result.as_ref(), + commit, + branch, + 0, + None, + None, + ); + crate::engine::debt_tracker::save_snapshot( + &snapshot, + config.debt.history_dir.as_deref(), + ); + } + + if gate_failed && !opts.force { + // Print findings + let formatter = crate::formatters::formatter_for(OutputFormat::Pretty); + let output = formatter.format_review(&filtered)?; + print!("{output}"); + if let Some(gate) = &gate_result { + print!("{}", quality_gate::format_gate_output(gate)); + } + eprintln!( + "\n{}", + "Commit blocked. Fix issues or use: cora commit --force" + .red() + .bold() + ); + return Ok(EXIT_BLOCKED); + } + + // Print findings if any (even on pass) + if !filtered.issues.is_empty() && !opts.quiet { + let formatter = crate::formatters::formatter_for(OutputFormat::Compact); + let output = formatter.format_review(&filtered)?; + eprint!("{output}"); + } + + Some(filtered) + }; + + // 3. Generate commit message + if !opts.quiet { + eprintln!("{}", " β†’ Generating commit message…".dimmed()); + } + + let commit_msg = generate_commit_message(llm_config, &diff, opts.stream).await?; + + // 4. HITL or YOLO + if opts.yolo || opts.edit { + // YOLO: auto-accept + // --edit: open editor + let message = if opts.edit { + open_editor(&commit_msg.full())? + } else { + commit_msg.full() + }; + return do_commit(&message, opts.quiet); + } + + // HITL: interactive prompt + if opts.quiet { + // Quiet mode: just commit with generated message + return do_commit(&commit_msg.full(), opts.quiet); + } + + // Show generated message + eprintln!(); + eprintln!("{}", "πŸ“ Generated commit message:".cyan().bold()); + eprintln!("{}", "─────────────────────────────────────────".dimmed()); + eprintln!(" {}", commit_msg.subject.white().bold()); + if !commit_msg.body.is_empty() { + for line in commit_msg.body.lines() { + eprintln!(" {}", line); + } + } + eprintln!("{}", "─────────────────────────────────────────".dimmed()); + eprintln!(); + eprint!( + "Accept commit message? [{}]es / [{}]dit / [{}]o β€Ί ", + "Y".green(), + "E".yellow(), + "N".red() + ); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let answer = input.trim().to_lowercase(); + + match answer.as_str() { + "y" | "yes" | "" => do_commit(&commit_msg.full(), opts.quiet), + "e" | "edit" => { + let message = open_editor(&commit_msg.full())?; + do_commit(&message, opts.quiet) + } + _ => { + eprintln!("{}", "Commit aborted.".yellow()); + Ok(EXIT_ERROR) + } + } +} + +/// Generate a conventional commit message from a diff. +async fn generate_commit_message( + llm_config: &LLMConfig, + diff: &str, + stream: bool, +) -> Result { + let user_prompt = build_commit_prompt(diff); + + let system_prompt = COMMIT_SYSTEM_PROMPT; + + let raw = if stream { + llm::chat_completion_stream_raw(llm_config, system_prompt, &user_prompt).await? + } else { + llm::chat_completion_raw(llm_config, system_prompt, &user_prompt).await? + }; + + parse_commit_message(&raw) +} + +/// Build the user prompt for commit message generation. +fn build_commit_prompt(diff: &str) -> String { + // Truncate very long diffs for commit message generation + let max_chars = 8000; + let truncated = if diff.len() > max_chars { + &diff[..max_chars] + } else { + diff + }; + + format!( + "Generate a conventional commit message for this diff.\n\n\ + Rules:\n\ + - Subject line: (): \n\ + - Types: feat, fix, refactor, perf, docs, test, chore, style, build, ci\n\ + - Scope: the module or area changed (e.g., auth, db, api)\n\ + - Description: lowercase, imperative mood, no period, max 72 chars\n\ + - Body: bullet points explaining WHAT changed and WHY, max 10 lines\n\ + - If multiple types, pick the most significant change\n\ + - Do NOT include Co-authored-by or other metadata\n\n\ + Diff:\n```diff\n{truncated}\n```" + ) +} + +const COMMIT_SYSTEM_PROMPT: &str = r#"You are a commit message generator. You receive a git diff and produce a conventional commit message. + +Output format β€” EXACTLY this JSON structure, nothing else: +{"subject":"type(scope): description","body":"- bullet point 1\n- bullet point 2"} + +Rules: +- Subject: lowercase, imperative mood, no period, max 72 characters +- Type must be one of: feat, fix, refactor, perf, docs, test, chore, style, build, ci +- Scope: module or area changed (omit if unclear) +- Body: concise bullet points explaining changes, max 10 lines +- Be specific β€” mention actual file names, functions, or concepts changed +- If only one type of change, omit body bullets and keep subject only +- Always output valid JSON"#; + +/// Extract JSON from LLM output (handles markdown fences, trailing text). +fn extract_json_from_raw(raw: &str) -> Result<&str> { + let trimmed = raw.trim(); + + // Strip markdown code fences + let content = if trimmed.starts_with("```") { + let without_opening = trimmed.trim_start_matches('`'); + let without_lang = without_opening + .find('\n') + .map(|i| &without_opening[i + 1..]) + .unwrap_or(without_opening); + without_lang.trim_end_matches('`').trim_end_matches('\n') + } else { + trimmed + }; + + // Find JSON object boundaries + if let Some(start) = content.find('{') { + let mut depth = 0; + for (i, c) in content[start..].char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Ok(&content[start..start + i + 1]); + } + } + _ => {} + } + } + } + + anyhow::bail!("No JSON object found in LLM response") +} + +/// Parse the LLM response into a CommitMessage. +fn parse_commit_message(raw: &str) -> Result { + let json_str = extract_json_from_raw(raw)?; + + #[derive(serde::Deserialize)] + struct RawCommitMsg { + subject: String, + #[serde(default)] + body: String, + } + + let parsed: RawCommitMsg = + serde_json::from_str(json_str).with_context(|| "Invalid commit message JSON")?; + + // Clean up subject + let subject = parsed.subject.trim().to_string(); + + // Ensure subject starts with a conventional type + let conventional_types = [ + "feat", "fix", "refactor", "perf", "docs", "test", "chore", "style", "build", "ci", + ]; + let has_type = conventional_types + .iter() + .any(|t| subject.starts_with(t) || subject.starts_with(&format!("{t}("))); + + let subject = if has_type { + subject + } else { + format!("chore: {subject}") + }; + + // Enforce max length on subject + let subject = if subject.len() > 72 { + format!("{}…", &subject[..69]) + } else { + subject + }; + + Ok(CommitMessage { + subject, + body: parsed.body.trim().to_string(), + }) +} + +/// Open $EDITOR to edit the commit message. +fn open_editor(initial: &str) -> Result { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + + let tmp_dir = std::env::temp_dir(); + let tmp_path = tmp_dir.join("cora-commit-msg.tmp"); + + std::fs::write(&tmp_path, initial) + .with_context(|| format!("Failed to write temp file: {}", tmp_path.display()))?; + + let status = std::process::Command::new(&editor) + .arg(&tmp_path) + .status() + .with_context(|| format!("Failed to open editor: {editor}"))?; + + if !status.success() { + anyhow::bail!("Editor exited with non-zero status"); + } + + let content = + std::fs::read_to_string(&tmp_path).with_context(|| "Failed to read edited message")?; + + let _ = std::fs::remove_file(&tmp_path); + + // Strip comment lines (lines starting with #) + let cleaned: String = content + .lines() + .filter(|l| !l.starts_with('#')) + .collect::>() + .join("\n") + .trim() + .to_string(); + + if cleaned.is_empty() { + anyhow::bail!("Empty commit message"); + } + + Ok(cleaned) +} + +/// Execute git commit with the given message. +fn do_commit(message: &str, quiet: bool) -> Result { + let status = std::process::Command::new("git") + .args(["commit", "-m", message]) + .status() + .context("Failed to run git commit")?; + + if status.success() { + if !quiet { + // Show the first line of the commit message + let subject = message.lines().next().unwrap_or("committed"); + eprintln!("{}", format!("βœ… Committed: {subject}").green().bold()); + } + Ok(EXIT_OK) + } else { + eprintln!("{}", "❌ git commit failed".red().bold()); + Ok(EXIT_ERROR) + } +} + +/// Get git context (cached). +fn get_git_context() -> (Option, Option) { + use std::sync::LazyLock; + + static CONTEXT: LazyLock<(Option, Option)> = LazyLock::new(|| { + let commit = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout) + .ok() + .map(|s| s.trim().to_string()) + } else { + None + } + }); + + let branch = crate::git::get_current_branch().ok(); + + (commit, branch) + }); + + (CONTEXT.0.clone(), CONTEXT.1.clone()) +} + +/// Count files and lines in a diff. +fn diff_stats(diff: &str) -> (usize, usize) { + let mut files = 0usize; + let mut lines = 0usize; + + for line in diff.lines() { + if line.starts_with("diff --git") { + files += 1; + } else if line.starts_with('+') && !line.starts_with("+++") { + lines += 1; + } + } + + (files.max(1), lines) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── parse_commit_message ─── + + #[test] + fn parse_valid_json() { + let raw = r#"{"subject":"feat(auth): add session expiry","body":"- Add TTL config\n- Validate on request"}"#; + let msg = parse_commit_message(raw).unwrap(); + assert_eq!(msg.subject, "feat(auth): add session expiry"); + assert!(msg.body.contains("TTL config")); + } + + #[test] + fn parse_wrapped_in_fences() { + let raw = + "```json\n{\"subject\":\"fix: null pointer\",\"body\":\"- Added null check\"}\n```"; + let msg = parse_commit_message(raw).unwrap(); + assert_eq!(msg.subject, "fix: null pointer"); + } + + #[test] + fn parse_no_body() { + let raw = r#"{"subject":"chore: update deps","body":""}"#; + let msg = parse_commit_message(raw).unwrap(); + assert_eq!(msg.subject, "chore: update deps"); + assert!(msg.body.is_empty()); + } + + #[test] + fn parse_adds_type_if_missing() { + let raw = r#"{"subject":"update the readme","body":""}"#; + let msg = parse_commit_message(raw).unwrap(); + assert!(msg.subject.starts_with("chore:")); + } + + #[test] + fn parse_truncates_long_subject() { + let long = "a".repeat(80); + let raw = format!(r#"{{"subject":"{long}","body":""}}"#); + let msg = parse_commit_message(&raw).unwrap(); + assert!(msg.subject.len() <= 75); // 72 + "…" + } + + #[test] + fn parse_invalid_json_fails() { + let result = parse_commit_message("not json at all"); + assert!(result.is_err()); + } + + // ─── build_commit_prompt ─── + + #[test] + fn commit_prompt_contains_diff() { + let diff = "diff --git a/foo.rs b/foo.rs\n+new line"; + let prompt = build_commit_prompt(diff); + assert!(prompt.contains("foo.rs")); + assert!(prompt.contains("conventional commit")); + } + + // ─── diff_stats ─── + + #[test] + fn diff_stats_counts_files_and_lines() { + let diff = "diff --git a/a.rs b/a.rs\n+line1\n+line2\ndiff --git a/b.rs b/b.rs\n+line3"; + let (files, lines) = diff_stats(diff); + assert_eq!(files, 2); + assert_eq!(lines, 3); + } + + #[test] + fn diff_stats_empty() { + let (files, lines) = diff_stats(""); + assert_eq!(files, 1); // max(1, 0) + assert_eq!(lines, 0); + } + + // ─── CommitMessage ─── + + #[test] + fn commit_message_full_no_body() { + let msg = CommitMessage { + subject: "feat: add x".to_string(), + body: String::new(), + }; + assert_eq!(msg.full(), "feat: add x"); + } + + #[test] + fn commit_message_full_with_body() { + let msg = CommitMessage { + subject: "feat: add x".to_string(), + body: "- did thing\n- did other".to_string(), + }; + assert!(msg.full().contains("\n\n")); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d568b31f..9013296f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod commit_cmd; pub mod completion; pub mod config_cmd; pub mod debt; diff --git a/src/engine/llm.rs b/src/engine/llm.rs index b08022ab..d2b1fa6c 100644 --- a/src/engine/llm.rs +++ b/src/engine/llm.rs @@ -274,6 +274,25 @@ fn atty_check() -> bool { std::io::stderr().is_terminal() } +/// Raw chat completion β€” returns the raw string response. +/// Used by commit message generation and other non-review tasks. +pub async fn chat_completion_raw( + llm_config: &LLMConfig, + system_prompt: &str, + user_message: &str, +) -> std::result::Result { + chat_completion(llm_config, system_prompt, user_message, None, "none").await +} + +/// Raw streaming chat completion β€” collects the full stream and returns the response string. +pub async fn chat_completion_stream_raw( + llm_config: &LLMConfig, + system_prompt: &str, + user_message: &str, +) -> std::result::Result { + chat_completion_stream(llm_config, system_prompt, user_message, "none").await +} + /// Review a diff using the LLM. Returns a `ReviewResponse`. #[allow(clippy::too_many_arguments)] pub async fn review_diff( diff --git a/src/main.rs b/src/main.rs index d9b6b213..8d7b6111 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,8 @@ mod mcp; mod progress; use commands::{ - auth, completion, config_cmd, debt, hook_cmd, init, profile, providers, review, scan, upload, + auth, commit_cmd, completion, config_cmd, debt, hook_cmd, init, profile, providers, review, + scan, upload, }; use config::loader; use engine::memory; @@ -79,6 +80,33 @@ struct GlobalOptions { #[derive(Subcommand, Debug)] enum Command { + /// Review staged changes, generate commit message, and commit + Commit { + /// YOLO mode β€” auto-commit without prompts + #[clap(long)] + yolo: bool, + + /// Force commit even if quality gate fails + #[clap(long)] + force: bool, + + /// Skip review, only generate commit message + #[clap(long)] + no_review: bool, + + /// Always open editor to edit message + #[clap(long)] + edit: bool, + + /// Stream LLM response in real-time + #[clap(long)] + stream: bool, + + /// Suppress all output except the result + #[clap(long, short)] + quiet: bool, + }, + /// Review a git diff (staged, unpushed, or branch) Review { /// Review staged changes (git diff --cached) @@ -392,6 +420,33 @@ async fn main() -> Result<()> { // Dispatch based on subcommand let exit_code = match cli.command { + Command::Commit { + yolo, + force, + no_review, + edit, + stream, + quiet, + } => { + let config = loader::load_config( + cli.global.config.as_deref(), + cli.global.provider.as_deref(), + cli.global.model.as_deref(), + cli.global.base_url.as_deref(), + cli.global.format.as_deref(), + cli.global.no_color, + )?; + let llm_config = loader::build_llm_config(&config, cli.global.api_key.as_deref())?; + let opts = commit_cmd::CommitOptions { + yolo, + force, + no_review, + edit, + stream, + quiet, + }; + commit_cmd::execute_commit(&config, &llm_config, &opts).await? + } Command::Review { staged, unpushed, From ca8671ea6c1a05aad830f4e14a7d58de9eb878ee Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Sat, 13 Jun 2026 13:21:49 +0700 Subject: [PATCH 2/4] fix: address cora review findings on cora commit - Unique temp file per process (avoid collision) - Handle multi-word $EDITOR (e.g. code --wait) - Use git commit -F for multi-line messages - Include deleted lines in diff stats - All 517 tests pass, clippy clean --- src/commands/commit_cmd.rs | 42 +++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/commands/commit_cmd.rs b/src/commands/commit_cmd.rs index 92f61f9f..0cdb28d4 100644 --- a/src/commands/commit_cmd.rs +++ b/src/commands/commit_cmd.rs @@ -362,13 +362,25 @@ fn open_editor(initial: &str) -> Result { let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let tmp_dir = std::env::temp_dir(); - let tmp_path = tmp_dir.join("cora-commit-msg.tmp"); + // Unique temp file per process to avoid collision + let tmp_path = tmp_dir.join(format!("cora-commit-msg-{}.tmp", std::process::id())); std::fs::write(&tmp_path, initial) .with_context(|| format!("Failed to write temp file: {}", tmp_path.display()))?; - let status = std::process::Command::new(&editor) - .arg(&tmp_path) + // Handle multi-word editor commands (e.g. "code --wait", "vim -f") + let parts: Vec<&str> = editor.split_whitespace().collect(); + let (cmd, editor_args) = parts + .split_first() + .expect("editor command should not be empty"); + + let mut cmd = std::process::Command::new(cmd); + for arg in editor_args { + cmd.arg(arg); + } + cmd.arg(&tmp_path); + + let status = cmd .status() .with_context(|| format!("Failed to open editor: {editor}"))?; @@ -399,14 +411,20 @@ fn open_editor(initial: &str) -> Result { /// Execute git commit with the given message. fn do_commit(message: &str, quiet: bool) -> Result { + // Write to temp file β€” handles multi-line messages safely + let tmp_dir = std::env::temp_dir(); + let msg_file = tmp_dir.join(format!("cora-commit-{}.msg", std::process::id())); + std::fs::write(&msg_file, message).context("Failed to write commit message file")?; + let status = std::process::Command::new("git") - .args(["commit", "-m", message]) + .args(["commit", "-F", &msg_file.to_string_lossy()]) .status() .context("Failed to run git commit")?; + let _ = std::fs::remove_file(&msg_file); + if status.success() { if !quiet { - // Show the first line of the commit message let subject = message.lines().next().unwrap_or("committed"); eprintln!("{}", format!("βœ… Committed: {subject}").green().bold()); } @@ -447,17 +465,20 @@ fn get_git_context() -> (Option, Option) { /// Count files and lines in a diff. fn diff_stats(diff: &str) -> (usize, usize) { let mut files = 0usize; - let mut lines = 0usize; + let mut added = 0usize; + let mut removed = 0usize; for line in diff.lines() { if line.starts_with("diff --git") { files += 1; } else if line.starts_with('+') && !line.starts_with("+++") { - lines += 1; + added += 1; + } else if line.starts_with('-') && !line.starts_with("---") { + removed += 1; } } - (files.max(1), lines) + (files.max(1), added + removed) } #[cfg(test)] @@ -525,10 +546,11 @@ mod tests { #[test] fn diff_stats_counts_files_and_lines() { - let diff = "diff --git a/a.rs b/a.rs\n+line1\n+line2\ndiff --git a/b.rs b/b.rs\n+line3"; + let diff = + "diff --git a/a.rs b/a.rs\n+line1\n+line2\ndiff --git a/b.rs b/b.rs\n+line3\n-old"; let (files, lines) = diff_stats(diff); assert_eq!(files, 2); - assert_eq!(lines, 3); + assert_eq!(lines, 4); // 3 added + 1 removed } #[test] From bf05485b7e12a91d7aab5793b5e4f26c4cba1ab1 Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Sat, 13 Jun 2026 13:28:52 +0700 Subject: [PATCH 3/4] fix: secure temp files and proper editor handling in cora commit - Use nanosecond timestamps for unique temp file names - Use sh -c for multi-word $EDITOR handling (e.g. code --wait) - git commit -F for multi-line messages via temp file - All 517 tests pass, clippy clean --- src/commands/commit_cmd.rs | 41 ++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/commands/commit_cmd.rs b/src/commands/commit_cmd.rs index 0cdb28d4..a321d928 100644 --- a/src/commands/commit_cmd.rs +++ b/src/commands/commit_cmd.rs @@ -361,30 +361,29 @@ fn parse_commit_message(raw: &str) -> Result { fn open_editor(initial: &str) -> Result { let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + // Unique temp file using nanosecond timestamp let tmp_dir = std::env::temp_dir(); - // Unique temp file per process to avoid collision - let tmp_path = tmp_dir.join(format!("cora-commit-msg-{}.tmp", std::process::id())); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let tmp_path = tmp_dir.join(format!("cora-commit-{nonce}.tmp")); std::fs::write(&tmp_path, initial) .with_context(|| format!("Failed to write temp file: {}", tmp_path.display()))?; - // Handle multi-word editor commands (e.g. "code --wait", "vim -f") - let parts: Vec<&str> = editor.split_whitespace().collect(); - let (cmd, editor_args) = parts - .split_first() - .expect("editor command should not be empty"); + let path_str = tmp_path.to_string_lossy().to_string(); - let mut cmd = std::process::Command::new(cmd); - for arg in editor_args { - cmd.arg(arg); - } - cmd.arg(&tmp_path); - - let status = cmd + // Use sh -c to handle multi-word editors properly + let shell_cmd = format!("{editor} '{path_str}'"); + let status = std::process::Command::new("sh") + .arg("-c") + .arg(&shell_cmd) .status() .with_context(|| format!("Failed to open editor: {editor}"))?; if !status.success() { + let _ = std::fs::remove_file(&tmp_path); anyhow::bail!("Editor exited with non-zero status"); } @@ -411,17 +410,21 @@ fn open_editor(initial: &str) -> Result { /// Execute git commit with the given message. fn do_commit(message: &str, quiet: bool) -> Result { - // Write to temp file β€” handles multi-line messages safely + // Unique temp file for commit message let tmp_dir = std::env::temp_dir(); - let msg_file = tmp_dir.join(format!("cora-commit-{}.msg", std::process::id())); - std::fs::write(&msg_file, message).context("Failed to write commit message file")?; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let msg_path = tmp_dir.join(format!("cora-commit-{nonce}.msg")); + std::fs::write(&msg_path, message).context("Failed to write commit message file")?; let status = std::process::Command::new("git") - .args(["commit", "-F", &msg_file.to_string_lossy()]) + .args(["commit", "-F", &msg_path.to_string_lossy()]) .status() .context("Failed to run git commit")?; - let _ = std::fs::remove_file(&msg_file); + let _ = std::fs::remove_file(&msg_path); if status.success() { if !quiet { From 2c8227f458a3861dfd43af0950cb1d50ee9adc9b Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Sat, 13 Jun 2026 20:05:33 +0700 Subject: [PATCH 4/4] docs: add cora commit to docs and changelog - README: add commit to quick-start + commands table - docs/cli-reference.md: add commit rows + examples - docs/usage.md: new Commit Workflow section with HITL/YOLO + flags - CHANGELOG.md: add commit to [Unreleased] - AGENT.md/.agent.md: bump test count to 517, add commit_cmd.rs --- .agent.md | 2 +- AGENT.md | 5 ++-- CHANGELOG.md | 15 ++++++++++++ README.md | 3 +++ docs/cli-reference.md | 13 +++++++++++ docs/usage.md | 53 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 3 deletions(-) diff --git a/.agent.md b/.agent.md index 993c8340..47c23dfe 100644 --- a/.agent.md +++ b/.agent.md @@ -8,7 +8,7 @@ Before creating any release PR, verify ALL of the following are updated: - [ ] `Cargo.toml` β€” version bump - [ ] All target issues merged to `develop` -- [ ] `cargo test` β€” all 506+ tests pass +- [ ] `cargo test` β€” all 517+ tests pass - [ ] `cargo clippy --all-targets -- -D warnings` β€” clean - [ ] `cargo fmt --all -- --check` β€” clean - [ ] `cargo build --release` β€” no errors diff --git a/AGENT.md b/AGENT.md index b7f4331e..68b8375b 100644 --- a/AGENT.md +++ b/AGENT.md @@ -17,7 +17,7 @@ no managed API, no cloud service. Runs locally against diffs, scans, or branches ```bash cargo build # Build (debug) cargo build --release # Build (release) -cargo test # Run all 506 tests +cargo test # Run all 517 tests cargo clippy --all-targets -- -D warnings # Lint (strict) cargo fmt --all -- --check # Format check ``` @@ -34,6 +34,7 @@ src/ β”‚ β”œβ”€β”€ review.rs # cora review (diff-based review) β”‚ β”œβ”€β”€ scan.rs # cora scan (full-file scan) β”‚ β”œβ”€β”€ debt.rs # cora debt (tech debt report) +β”‚ β”œβ”€β”€ commit_cmd.rs # cora commit (review + commit message + commit) β”‚ β”œβ”€β”€ config_cmd.rs # cora config (show/set/validate) β”‚ β”œβ”€β”€ auth.rs # cora auth (API key management) β”‚ β”œβ”€β”€ hook_cmd.rs # cora hook (pre-commit hook install/uninstall) @@ -101,7 +102,7 @@ src/ ## Testing ```bash -cargo test # 506 tests total +cargo test # 517 tests total # 484 unit tests # 16 CLI integration tests # 6 config tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c26f4a..1491d2f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`cora commit`** β€” review staged diff + generate commit message + commit (#262) + - HITL mode (default): interactive `[Y]es / [E]dit / [N]o` prompt + - YOLO mode (`--yolo`): auto-commit without prompts + - `--force`: commit even if quality gate fails + - `--no-review`: skip review, only generate commit message + - `--edit`: always open `$EDITOR` + - Conventional commit format (feat/fix/refactor/perf/docs/test/chore/style/build/ci) + - Auto-truncates subjects to 72 chars + - Quality gate integration (block on FAIL unless `--force`) + - Debt snapshot saved after commit + - `chat_completion_raw()` + `chat_completion_stream_raw()` in `engine/llm.rs` + - 22 unit tests + ## [0.5.0] - 2026-06-10 ### Added diff --git a/README.md b/README.md index aa87305c..d3c1375b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Pick a provider, enter your API key. Done. Provider env vars (`ZAI_API_KEY`, `OP cora review # staged changes cora review --base main # vs a branch cora review --unpushed # unpushed commits +cora commit # review + generate commit msg + commit +cora commit --yolo # auto-commit, no prompts ``` ### Project Config @@ -131,6 +133,7 @@ Works on **all CI platforms** β€” [Gitea, GitLab, Bitbucket β†’](https://codecor | Command | Description | |---------|-------------| +| `cora commit` | Review + generate commit message + commit | | `cora review` | Review code changes | | `cora scan` | Scan files for issues | | `cora init` | Create project config + hook | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d62f7926..dcf0442e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -24,6 +24,11 @@ Complete command reference for the cora CLI. | Command | Description | |---------|-------------| | `cora init` | Create `.cora.yaml` config file | +| `cora commit` | Review staged + generate commit message + commit (HITL prompt) | +| `cora commit --yolo` | Auto-commit without prompts (YOLO mode) | +| `cora commit --force` | Commit even if quality gate fails | +| `cora commit --no-review` | Skip review, only generate commit message | +| `cora commit --edit` | Always open `$EDITOR` to edit message | | `cora review` | Review code changes (default: staged files) | | `cora review --staged` | Review staged git changes explicitly | | `cora review --memory` | Recall project patterns from Uteke before review | @@ -83,3 +88,11 @@ $ cora scan --incremental # Install pre-commit hook $ cora hook install ``` + +```bash +# Review + auto-generate commit message + commit +$ cora commit + +# YOLO mode β€” auto-commit, no prompts +$ cora commit --yolo +``` diff --git a/docs/usage.md b/docs/usage.md index 21c76221..6df816ab 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -67,6 +67,59 @@ $ cora review --staged --format json } ``` +## Commit Workflow + +`cora commit` combines review, commit message generation, and commit into a single +command. It runs in two modes: + +### HITL mode (default) + +Reviews staged changes, generates a conventional commit message, then prompts: + +```bash +$ git add -A +$ cora commit +πŸ” Reviewing staged changes (3 files, 247 lines)... +βœ… No issues found (quality score: 9.2/10) + +πŸ“ Generated commit message: + feat(auth): add session expiry validation + +Accept commit message? [Y]es / [E]dit / [N]o β€Ί y +βœ… Committed: feat(auth): add session expiry validation +``` + +- `Y` (or Enter) β€” accept the message and commit +- `E` β€” open `$EDITOR` to edit, then commit +- `N` β€” abort + +### YOLO mode (`--yolo`) + +Auto-commits with no prompts. Use for trusted workflows (e.g. CI, rapid iteration): + +```bash +$ cora commit --yolo +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--yolo` | Auto-commit without prompts | +| `--force` | Commit even if quality gate fails | +| `--no-review` | Skip review, only generate commit message | +| `--edit` | Always open `$EDITOR` | +| `--stream` | Stream LLM response in real-time | +| `-q, --quiet` | Suppress all output except the result | + +### Quality gate integration + +If the quality gate is enabled and returns `FAIL`, `cora commit` blocks the +commit unless `--force` is passed: +```bash +$ cora commit --force +``` + ## Configuration File The `.cora.yaml` file provides persistent configuration. Place it in your project root.