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 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}