codex_cli/agent/
commit.rs1use 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 nils_test_support::{GlobalStateLock, prepend_path};
326 use pretty_assertions::assert_eq;
327
328 #[test]
329 fn suggested_scope_prefers_single_top_level_directory() {
330 let staged = "src/main.rs\nsrc/lib.rs\n";
331 assert_eq!(suggested_scope_from_staged(staged), "src");
332 }
333
334 #[test]
335 fn suggested_scope_ignores_root_file_when_single_directory_exists() {
336 let staged = "README.md\nsrc/main.rs\n";
337 assert_eq!(suggested_scope_from_staged(staged), "src");
338 }
339
340 #[test]
341 fn suggested_scope_returns_empty_for_multiple_directories() {
342 let staged = "src/main.rs\ncrates/a.rs\n";
343 assert_eq!(suggested_scope_from_staged(staged), "");
344 }
345
346 #[test]
347 fn semantic_commit_prompt_rejects_invalid_mode() {
348 assert!(semantic_commit_prompt("unknown").is_none());
349 }
350
351 #[cfg(unix)]
352 #[test]
353 fn command_exists_checks_executable_bit() {
354 use std::os::unix::fs::PermissionsExt;
355
356 let lock = GlobalStateLock::new();
357 let dir = tempfile::TempDir::new().expect("tempdir");
358 let executable = dir.path().join("tool-ok");
359 let non_executable = dir.path().join("tool-no");
360 std::fs::write(&executable, "#!/bin/sh\necho ok\n").expect("write executable");
361 std::fs::write(&non_executable, "plain text").expect("write non executable");
362
363 let mut perms = std::fs::metadata(&executable)
364 .expect("metadata")
365 .permissions();
366 perms.set_mode(0o755);
367 std::fs::set_permissions(&executable, perms).expect("chmod executable");
368
369 let mut perms = std::fs::metadata(&non_executable)
370 .expect("metadata")
371 .permissions();
372 perms.set_mode(0o644);
373 std::fs::set_permissions(&non_executable, perms).expect("chmod non executable");
374
375 let _path_guard = prepend_path(&lock, dir.path());
376 assert!(command_exists("tool-ok"));
377 assert!(!command_exists("tool-no"));
378 assert!(!command_exists("tool-missing"));
379 }
380}