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