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