codex_cli/agent/
commit.rs1use 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}