Skip to main content

codex_cli/agent/
commit.rs

1use anyhow::Result;
2use nils_common::process;
3use std::collections::BTreeSet;
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use crate::prompts;
9
10use super::exec;
11
12pub struct CommitOptions {
13    pub push: bool,
14    pub auto_stage: 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(
88        &prompt,
89        "codex-commit-with-scope",
90        &mut stderr,
91    ))
92}
93
94fn run_fallback(git_root: &Path, push_flag: bool, extra_prompt: &str) -> Result<i32> {
95    let staged = staged_files(git_root);
96    if staged.trim().is_empty() {
97        eprintln!("codex-commit-with-scope: no staged changes (stage files then retry)");
98        return Ok(1);
99    }
100
101    eprintln!("codex-commit-with-scope: semantic-commit not found on PATH (fallback mode)");
102    if !extra_prompt.trim().is_empty() {
103        eprintln!("codex-commit-with-scope: note: extra prompt is ignored in fallback mode");
104    }
105
106    if command_exists("git-scope") {
107        let _ = Command::new("git-scope")
108            .current_dir(git_root)
109            .arg("staged")
110            .status();
111    } else {
112        println!("Staged files:");
113        print!("{staged}");
114    }
115
116    let suggested_scope = suggested_scope_from_staged(&staged);
117
118    let mut commit_type = read_prompt("Type [chore]: ")?;
119    commit_type = commit_type.to_ascii_lowercase();
120    commit_type.retain(|ch| !ch.is_whitespace());
121    if commit_type.is_empty() {
122        commit_type = "chore".to_string();
123    }
124
125    let scope_prompt = if suggested_scope.is_empty() {
126        "Scope (optional): ".to_string()
127    } else {
128        format!("Scope (optional) [{suggested_scope}]: ")
129    };
130    let mut scope = read_prompt(&scope_prompt)?;
131    scope.retain(|ch| !ch.is_whitespace());
132    if scope.is_empty() {
133        scope = suggested_scope;
134    }
135
136    let subject = loop {
137        let raw = read_prompt("Subject: ")?;
138        let trimmed = raw.trim();
139        if !trimmed.is_empty() {
140            break trimmed.to_string();
141        }
142    };
143
144    let header = if scope.is_empty() {
145        format!("{commit_type}: {subject}")
146    } else {
147        format!("{commit_type}({scope}): {subject}")
148    };
149
150    println!();
151    println!("Commit message:");
152    println!("  {header}");
153
154    let confirm = read_prompt("Proceed? [y/N] ")?;
155    if !matches!(confirm.trim().chars().next(), Some('y' | 'Y')) {
156        eprintln!("Aborted.");
157        return Ok(1);
158    }
159
160    let status = Command::new("git")
161        .arg("-C")
162        .arg(git_root)
163        .arg("commit")
164        .arg("-m")
165        .arg(&header)
166        .status()?;
167    if !status.success() {
168        return Ok(1);
169    }
170
171    if push_flag {
172        let status = Command::new("git")
173            .arg("-C")
174            .arg(git_root)
175            .arg("push")
176            .status()?;
177        if !status.success() {
178            return Ok(1);
179        }
180    }
181
182    if command_exists("git-scope") {
183        let _ = Command::new("git-scope")
184            .current_dir(git_root)
185            .arg("commit")
186            .arg("HEAD")
187            .status();
188    } else {
189        let _ = Command::new("git")
190            .arg("-C")
191            .arg(git_root)
192            .arg("show")
193            .arg("-1")
194            .arg("--name-status")
195            .arg("--oneline")
196            .status();
197    }
198
199    Ok(0)
200}
201
202fn suggested_scope_from_staged(staged: &str) -> String {
203    let mut top: BTreeSet<String> = BTreeSet::new();
204    for line in staged.lines() {
205        let file = line.trim();
206        if file.is_empty() {
207            continue;
208        }
209        if let Some((first, _rest)) = file.split_once('/') {
210            top.insert(first.to_string());
211        } else {
212            top.insert(String::new());
213        }
214    }
215
216    if top.len() == 1 {
217        return top.iter().next().cloned().unwrap_or_default();
218    }
219
220    if top.len() == 2 && top.contains("") {
221        for part in top {
222            if !part.is_empty() {
223                return part;
224            }
225        }
226    }
227
228    String::new()
229}
230
231fn read_prompt(prompt: &str) -> Result<String> {
232    print!("{prompt}");
233    let _ = io::stdout().flush();
234
235    let mut line = String::new();
236    let bytes = io::stdin().read_line(&mut line)?;
237    if bytes == 0 {
238        return Ok(String::new());
239    }
240    Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
241}
242
243fn staged_files(git_root: &Path) -> String {
244    let output = Command::new("git")
245        .arg("-C")
246        .arg(git_root)
247        .arg("-c")
248        .arg("core.quotepath=false")
249        .arg("diff")
250        .arg("--cached")
251        .arg("--name-only")
252        .arg("--diff-filter=ACMRTUXBD")
253        .output();
254
255    match output {
256        Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
257        Err(_) => String::new(),
258    }
259}
260
261fn git_root() -> Option<PathBuf> {
262    let output = Command::new("git")
263        .arg("rev-parse")
264        .arg("--show-toplevel")
265        .output()
266        .ok()?;
267    if !output.status.success() {
268        return None;
269    }
270    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
271    if path.is_empty() {
272        return None;
273    }
274    Some(PathBuf::from(path))
275}
276
277fn semantic_commit_prompt(mode: &str) -> Option<String> {
278    let template_name = match mode {
279        "staged" => "semantic-commit-staged",
280        "autostage" => "semantic-commit-autostage",
281        other => {
282            eprintln!("_codex_tools_semantic_commit_prompt: invalid mode: {other}");
283            return None;
284        }
285    };
286
287    let prompts_dir = match prompts::resolve_prompts_dir() {
288        Some(value) => value,
289        None => {
290            eprintln!(
291                "_codex_tools_semantic_commit_prompt: prompts dir not found (expected: $ZDOTDIR/prompts)"
292            );
293            return None;
294        }
295    };
296
297    let prompt_file = prompts_dir.join(format!("{template_name}.md"));
298    if !prompt_file.is_file() {
299        eprintln!(
300            "_codex_tools_semantic_commit_prompt: prompt template not found: {}",
301            prompt_file.to_string_lossy()
302        );
303        return None;
304    }
305
306    match std::fs::read_to_string(&prompt_file) {
307        Ok(content) => Some(content),
308        Err(_) => {
309            eprintln!(
310                "_codex_tools_semantic_commit_prompt: failed to read prompt template: {}",
311                prompt_file.to_string_lossy()
312            );
313            None
314        }
315    }
316}
317
318fn command_exists(name: &str) -> bool {
319    process::cmd_exists(name)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::{command_exists, semantic_commit_prompt, suggested_scope_from_staged};
325    use pretty_assertions::assert_eq;
326
327    struct EnvGuard {
328        key: &'static str,
329        old: Option<std::ffi::OsString>,
330    }
331
332    impl EnvGuard {
333        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
334            let old = std::env::var_os(key);
335            // SAFETY: tests mutate process env only in scoped guard usage.
336            unsafe { std::env::set_var(key, value) };
337            Self { key, old }
338        }
339    }
340
341    impl Drop for EnvGuard {
342        fn drop(&mut self) {
343            if let Some(value) = self.old.take() {
344                // SAFETY: tests restore process env only in scoped guard usage.
345                unsafe { std::env::set_var(self.key, value) };
346            } else {
347                // SAFETY: tests restore process env only in scoped guard usage.
348                unsafe { std::env::remove_var(self.key) };
349            }
350        }
351    }
352
353    #[test]
354    fn suggested_scope_prefers_single_top_level_directory() {
355        let staged = "src/main.rs\nsrc/lib.rs\n";
356        assert_eq!(suggested_scope_from_staged(staged), "src");
357    }
358
359    #[test]
360    fn suggested_scope_ignores_root_file_when_single_directory_exists() {
361        let staged = "README.md\nsrc/main.rs\n";
362        assert_eq!(suggested_scope_from_staged(staged), "src");
363    }
364
365    #[test]
366    fn suggested_scope_returns_empty_for_multiple_directories() {
367        let staged = "src/main.rs\ncrates/a.rs\n";
368        assert_eq!(suggested_scope_from_staged(staged), "");
369    }
370
371    #[test]
372    fn semantic_commit_prompt_rejects_invalid_mode() {
373        assert!(semantic_commit_prompt("unknown").is_none());
374    }
375
376    #[cfg(unix)]
377    #[test]
378    fn command_exists_checks_executable_bit() {
379        use std::os::unix::fs::PermissionsExt;
380
381        let dir = tempfile::TempDir::new().expect("tempdir");
382        let executable = dir.path().join("tool-ok");
383        let non_executable = dir.path().join("tool-no");
384        std::fs::write(&executable, "#!/bin/sh\necho ok\n").expect("write executable");
385        std::fs::write(&non_executable, "plain text").expect("write non executable");
386
387        let mut perms = std::fs::metadata(&executable)
388            .expect("metadata")
389            .permissions();
390        perms.set_mode(0o755);
391        std::fs::set_permissions(&executable, perms).expect("chmod executable");
392
393        let mut perms = std::fs::metadata(&non_executable)
394            .expect("metadata")
395            .permissions();
396        perms.set_mode(0o644);
397        std::fs::set_permissions(&non_executable, perms).expect("chmod non executable");
398
399        let _path_guard = EnvGuard::set("PATH", dir.path().as_os_str());
400        assert!(command_exists("tool-ok"));
401        assert!(!command_exists("tool-no"));
402        assert!(!command_exists("tool-missing"));
403    }
404}