Skip to main content

codex_cli/agent/
commit.rs

1use anyhow::Result;
2use nils_common::{git as common_git, process};
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use crate::prompts;
8
9use super::exec;
10
11pub struct CommitOptions {
12    pub push: bool,
13    pub auto_stage: bool,
14    pub ephemeral: bool,
15    pub extra: Vec<String>,
16}
17
18pub fn run(options: &CommitOptions) -> Result<i32> {
19    if !command_exists("git") {
20        eprintln!("codex-commit-with-scope: missing binary: git");
21        return Ok(1);
22    }
23
24    let git_root = match git_root() {
25        Some(value) => value,
26        None => {
27            eprintln!("codex-commit-with-scope: not a git repository");
28            return Ok(1);
29        }
30    };
31
32    if options.auto_stage {
33        let status = Command::new("git")
34            .arg("-C")
35            .arg(&git_root)
36            .arg("add")
37            .arg("-A")
38            .status()?;
39        if !status.success() {
40            return Ok(1);
41        }
42    } else {
43        let staged = staged_files(&git_root);
44        if staged.trim().is_empty() {
45            eprintln!("codex-commit-with-scope: no staged changes (stage files then retry)");
46            return Ok(1);
47        }
48    }
49
50    let extra_prompt = options.extra.join(" ");
51
52    if !command_exists("semantic-commit") {
53        return run_fallback(&git_root, options.push, &extra_prompt);
54    }
55
56    {
57        let stderr = io::stderr();
58        let mut stderr = stderr.lock();
59        if !exec::require_allow_dangerous(Some("codex-commit-with-scope"), &mut stderr) {
60            return Ok(1);
61        }
62    }
63
64    let mode = if options.auto_stage {
65        "autostage"
66    } else {
67        "staged"
68    };
69    let mut prompt = match semantic_commit_prompt(mode) {
70        Some(value) => value,
71        None => return Ok(1),
72    };
73
74    if options.push {
75        prompt.push_str(
76            "\n\nFurthermore, please push the committed changes to the remote repository.",
77        );
78    }
79
80    if !extra_prompt.trim().is_empty() {
81        prompt.push_str("\n\nAdditional instructions from user:\n");
82        prompt.push_str(extra_prompt.trim());
83    }
84
85    let stderr = io::stderr();
86    let mut stderr = stderr.lock();
87    Ok(exec::exec_dangerous_with_options(
88        &prompt,
89        "codex-commit-with-scope",
90        &mut stderr,
91        exec::ExecOptions {
92            ephemeral: options.ephemeral,
93        },
94    ))
95}
96
97fn run_fallback(git_root: &Path, push_flag: bool, extra_prompt: &str) -> Result<i32> {
98    let staged = staged_files(git_root);
99    if staged.trim().is_empty() {
100        eprintln!("codex-commit-with-scope: no staged changes (stage files then retry)");
101        return Ok(1);
102    }
103
104    eprintln!("codex-commit-with-scope: semantic-commit not found on PATH (fallback mode)");
105    if !extra_prompt.trim().is_empty() {
106        eprintln!("codex-commit-with-scope: note: extra prompt is ignored in fallback mode");
107    }
108
109    if command_exists("git-scope") {
110        let _ = Command::new("git-scope")
111            .current_dir(git_root)
112            .arg("staged")
113            .status();
114    } else {
115        println!("Staged files:");
116        print!("{staged}");
117    }
118
119    let suggested_scope = suggested_scope_from_staged(&staged);
120
121    let mut commit_type = read_prompt("Type [chore]: ")?;
122    commit_type = commit_type.to_ascii_lowercase();
123    commit_type.retain(|ch| !ch.is_whitespace());
124    if commit_type.is_empty() {
125        commit_type = "chore".to_string();
126    }
127
128    let scope_prompt = if suggested_scope.is_empty() {
129        "Scope (optional): ".to_string()
130    } else {
131        format!("Scope (optional) [{suggested_scope}]: ")
132    };
133    let mut scope = read_prompt(&scope_prompt)?;
134    scope.retain(|ch| !ch.is_whitespace());
135    if scope.is_empty() {
136        scope = suggested_scope;
137    }
138
139    let subject = loop {
140        let raw = read_prompt("Subject: ")?;
141        let trimmed = raw.trim();
142        if !trimmed.is_empty() {
143            break trimmed.to_string();
144        }
145    };
146
147    let header = if scope.is_empty() {
148        format!("{commit_type}: {subject}")
149    } else {
150        format!("{commit_type}({scope}): {subject}")
151    };
152
153    println!();
154    println!("Commit message:");
155    println!("  {header}");
156
157    let confirm = read_prompt("Proceed? [y/N] ")?;
158    if !matches!(confirm.trim().chars().next(), Some('y' | 'Y')) {
159        eprintln!("Aborted.");
160        return Ok(1);
161    }
162
163    let status = Command::new("git")
164        .arg("-C")
165        .arg(git_root)
166        .arg("commit")
167        .arg("-m")
168        .arg(&header)
169        .status()?;
170    if !status.success() {
171        return Ok(1);
172    }
173
174    if push_flag {
175        let status = Command::new("git")
176            .arg("-C")
177            .arg(git_root)
178            .arg("push")
179            .status()?;
180        if !status.success() {
181            return Ok(1);
182        }
183    }
184
185    if command_exists("git-scope") {
186        let _ = Command::new("git-scope")
187            .current_dir(git_root)
188            .arg("commit")
189            .arg("HEAD")
190            .status();
191    } else {
192        let _ = Command::new("git")
193            .arg("-C")
194            .arg(git_root)
195            .arg("show")
196            .arg("-1")
197            .arg("--name-status")
198            .arg("--oneline")
199            .status();
200    }
201
202    Ok(0)
203}
204
205fn suggested_scope_from_staged(staged: &str) -> String {
206    common_git::suggested_scope_from_staged_paths(staged)
207}
208
209fn read_prompt(prompt: &str) -> Result<String> {
210    print!("{prompt}");
211    let _ = io::stdout().flush();
212
213    let mut line = String::new();
214    let bytes = io::stdin().read_line(&mut line)?;
215    if bytes == 0 {
216        return Ok(String::new());
217    }
218    Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
219}
220
221fn staged_files(git_root: &Path) -> String {
222    common_git::staged_name_only_in(git_root).unwrap_or_default()
223}
224
225fn git_root() -> Option<PathBuf> {
226    common_git::repo_root().ok().flatten()
227}
228
229fn semantic_commit_prompt(mode: &str) -> Option<String> {
230    let template_name = match mode {
231        "staged" => "semantic-commit-staged",
232        "autostage" => "semantic-commit-autostage",
233        other => {
234            eprintln!("_codex_tools_semantic_commit_prompt: invalid mode: {other}");
235            return None;
236        }
237    };
238
239    let prompts_dir = match prompts::resolve_prompts_dir() {
240        Some(value) => value,
241        None => {
242            eprintln!(
243                "_codex_tools_semantic_commit_prompt: prompts dir not found (expected: $ZDOTDIR/prompts)"
244            );
245            return None;
246        }
247    };
248
249    let prompt_file = prompts_dir.join(format!("{template_name}.md"));
250    if !prompt_file.is_file() {
251        eprintln!(
252            "_codex_tools_semantic_commit_prompt: prompt template not found: {}",
253            prompt_file.to_string_lossy()
254        );
255        return None;
256    }
257
258    match std::fs::read_to_string(&prompt_file) {
259        Ok(content) => Some(content),
260        Err(_) => {
261            eprintln!(
262                "_codex_tools_semantic_commit_prompt: failed to read prompt template: {}",
263                prompt_file.to_string_lossy()
264            );
265            None
266        }
267    }
268}
269
270fn command_exists(name: &str) -> bool {
271    process::cmd_exists(name)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::{command_exists, semantic_commit_prompt, suggested_scope_from_staged};
277    use nils_test_support::{GlobalStateLock, prepend_path};
278    use pretty_assertions::assert_eq;
279
280    #[test]
281    fn suggested_scope_prefers_single_top_level_directory() {
282        let staged = "src/main.rs\nsrc/lib.rs\n";
283        assert_eq!(suggested_scope_from_staged(staged), "src");
284    }
285
286    #[test]
287    fn suggested_scope_ignores_root_file_when_single_directory_exists() {
288        let staged = "README.md\nsrc/main.rs\n";
289        assert_eq!(suggested_scope_from_staged(staged), "src");
290    }
291
292    #[test]
293    fn suggested_scope_returns_empty_for_multiple_directories() {
294        let staged = "src/main.rs\ncrates/a.rs\n";
295        assert_eq!(suggested_scope_from_staged(staged), "");
296    }
297
298    #[test]
299    fn semantic_commit_prompt_rejects_invalid_mode() {
300        assert!(semantic_commit_prompt("unknown").is_none());
301    }
302
303    #[cfg(unix)]
304    #[test]
305    fn command_exists_checks_executable_bit() {
306        use std::os::unix::fs::PermissionsExt;
307
308        let lock = GlobalStateLock::new();
309        let dir = tempfile::TempDir::new().expect("tempdir");
310        let executable = dir.path().join("tool-ok");
311        let non_executable = dir.path().join("tool-no");
312        std::fs::write(&executable, "#!/bin/sh\necho ok\n").expect("write executable");
313        std::fs::write(&non_executable, "plain text").expect("write non executable");
314
315        let mut perms = std::fs::metadata(&executable)
316            .expect("metadata")
317            .permissions();
318        perms.set_mode(0o755);
319        std::fs::set_permissions(&executable, perms).expect("chmod executable");
320
321        let mut perms = std::fs::metadata(&non_executable)
322            .expect("metadata")
323            .permissions();
324        perms.set_mode(0o644);
325        std::fs::set_permissions(&non_executable, perms).expect("chmod non executable");
326
327        let _path_guard = prepend_path(&lock, dir.path());
328        assert!(command_exists("tool-ok"));
329        assert!(!command_exists("tool-no"));
330        assert!(!command_exists("tool-missing"));
331    }
332}