gemini_cli/agent/
commit.rs1use std::collections::BTreeSet;
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use crate::prompts;
7
8use super::exec;
9
10#[derive(Clone, Debug, Default)]
11pub struct CommitOptions {
12 pub push: bool,
13 pub auto_stage: bool,
14 pub extra: Vec<String>,
15}
16
17pub fn run(options: &CommitOptions) -> i32 {
18 if !command_exists("git") {
19 eprintln!("gemini-commit-with-scope: missing binary: git");
20 return 1;
21 }
22
23 let git_root = match git_root() {
24 Some(value) => value,
25 None => {
26 eprintln!("gemini-commit-with-scope: not a git repository");
27 return 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.map(|value| value.success()).unwrap_or(false) {
39 return 1;
40 }
41 } else {
42 let staged = staged_files(&git_root);
43 if staged.trim().is_empty() {
44 eprintln!("gemini-commit-with-scope: no staged changes (stage files then retry)");
45 return 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("gemini-commit-with-scope"), &mut stderr) {
59 return 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 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 exec::exec_dangerous(&prompt, "gemini-commit-with-scope", &mut stderr)
87}
88
89fn run_fallback(git_root: &Path, push_flag: bool, extra_prompt: &str) -> i32 {
90 let staged = staged_files(git_root);
91 if staged.trim().is_empty() {
92 eprintln!("gemini-commit-with-scope: no staged changes (stage files then retry)");
93 return 1;
94 }
95
96 eprintln!("gemini-commit-with-scope: semantic-commit not found on PATH (fallback mode)");
97 if !extra_prompt.trim().is_empty() {
98 eprintln!("gemini-commit-with-scope: note: extra prompt is ignored in fallback mode");
99 }
100
101 println!("Staged files:");
102 print!("{staged}");
103
104 let suggested_scope = suggested_scope_from_staged(&staged);
105
106 let mut commit_type = match read_prompt("Type [chore]: ") {
107 Ok(value) => value,
108 Err(_) => return 1,
109 };
110 commit_type = commit_type.to_ascii_lowercase();
111 commit_type.retain(|ch| !ch.is_whitespace());
112 if commit_type.is_empty() {
113 commit_type = "chore".to_string();
114 }
115
116 let scope_prompt = if suggested_scope.is_empty() {
117 "Scope (optional): ".to_string()
118 } else {
119 format!("Scope (optional) [{suggested_scope}]: ")
120 };
121 let mut scope = match read_prompt(&scope_prompt) {
122 Ok(value) => value,
123 Err(_) => return 1,
124 };
125 scope.retain(|ch| !ch.is_whitespace());
126 if scope.is_empty() {
127 scope = suggested_scope;
128 }
129
130 let subject = loop {
131 let raw = match read_prompt("Subject: ") {
132 Ok(value) => value,
133 Err(_) => return 1,
134 };
135 let trimmed = raw.trim();
136 if !trimmed.is_empty() {
137 break trimmed.to_string();
138 }
139 };
140
141 let header = if scope.is_empty() {
142 format!("{commit_type}: {subject}")
143 } else {
144 format!("{commit_type}({scope}): {subject}")
145 };
146
147 println!();
148 println!("Commit message:");
149 println!(" {header}");
150
151 let confirm = match read_prompt("Proceed? [y/N] ") {
152 Ok(value) => value,
153 Err(_) => return 1,
154 };
155 if !matches!(confirm.trim().chars().next(), Some('y' | 'Y')) {
156 eprintln!("Aborted.");
157 return 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.map(|value| value.success()).unwrap_or(false) {
168 return 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.map(|value| value.success()).unwrap_or(false) {
178 return 1;
179 }
180 }
181
182 let _ = Command::new("git")
183 .arg("-C")
184 .arg(git_root)
185 .arg("show")
186 .arg("-1")
187 .arg("--name-status")
188 .arg("--oneline")
189 .status();
190
191 0
192}
193
194fn suggested_scope_from_staged(staged: &str) -> String {
195 let mut top: BTreeSet<String> = BTreeSet::new();
196 for line in staged.lines() {
197 let file = line.trim();
198 if file.is_empty() {
199 continue;
200 }
201 if let Some((first, _)) = file.split_once('/') {
202 top.insert(first.to_string());
203 } else {
204 top.insert(String::new());
205 }
206 }
207
208 if top.len() == 1 {
209 return top.iter().next().cloned().unwrap_or_default();
210 }
211
212 if top.len() == 2 && top.contains("") {
213 for part in top {
214 if !part.is_empty() {
215 return part;
216 }
217 }
218 }
219
220 String::new()
221}
222
223fn read_prompt(prompt: &str) -> io::Result<String> {
224 print!("{prompt}");
225 let _ = io::stdout().flush();
226
227 let mut line = String::new();
228 let bytes = io::stdin().read_line(&mut line)?;
229 if bytes == 0 {
230 return Ok(String::new());
231 }
232 Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
233}
234
235fn staged_files(git_root: &Path) -> String {
236 let output = Command::new("git")
237 .arg("-C")
238 .arg(git_root)
239 .arg("-c")
240 .arg("core.quotepath=false")
241 .arg("diff")
242 .arg("--cached")
243 .arg("--name-only")
244 .arg("--diff-filter=ACMRTUXBD")
245 .output();
246
247 match output {
248 Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
249 Err(_) => String::new(),
250 }
251}
252
253fn git_root() -> Option<PathBuf> {
254 let output = Command::new("git")
255 .arg("rev-parse")
256 .arg("--show-toplevel")
257 .output()
258 .ok()?;
259 if !output.status.success() {
260 return None;
261 }
262 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
263 if path.is_empty() {
264 return None;
265 }
266 Some(PathBuf::from(path))
267}
268
269fn semantic_commit_prompt(mode: &str) -> Option<String> {
270 let template_name = match mode {
271 "staged" => "semantic-commit-staged",
272 "autostage" => "semantic-commit-autostage",
273 other => {
274 eprintln!("_gemini_tools_semantic_commit_prompt: invalid mode: {other}");
275 return None;
276 }
277 };
278
279 let prompts_dir = match prompts::resolve_prompts_dir() {
280 Some(value) => value,
281 None => {
282 eprintln!(
283 "_gemini_tools_semantic_commit_prompt: prompts dir not found (expected: $ZDOTDIR/prompts)"
284 );
285 return None;
286 }
287 };
288
289 let prompt_file = prompts_dir.join(format!("{template_name}.md"));
290 if !prompt_file.is_file() {
291 eprintln!(
292 "_gemini_tools_semantic_commit_prompt: prompt template not found: {}",
293 prompt_file.to_string_lossy()
294 );
295 return None;
296 }
297
298 match std::fs::read_to_string(&prompt_file) {
299 Ok(content) => Some(content),
300 Err(_) => {
301 eprintln!(
302 "_gemini_tools_semantic_commit_prompt: failed to read prompt template: {}",
303 prompt_file.to_string_lossy()
304 );
305 None
306 }
307 }
308}
309
310fn command_exists(name: &str) -> bool {
311 if name.is_empty() {
312 return false;
313 }
314
315 if name.contains('/') {
316 return is_executable(Path::new(name));
317 }
318
319 let path = std::env::var_os("PATH").unwrap_or_default();
320 for part in std::env::split_paths(&path) {
321 let candidate = part.join(name);
322 if is_executable(&candidate) {
323 return true;
324 }
325 }
326 false
327}
328
329fn is_executable(path: &Path) -> bool {
330 if !path.is_file() {
331 return false;
332 }
333
334 #[cfg(unix)]
335 {
336 use std::os::unix::fs::PermissionsExt;
337 std::fs::metadata(path)
338 .map(|meta| meta.permissions().mode() & 0o111 != 0)
339 .unwrap_or(false)
340 }
341
342 #[cfg(not(unix))]
343 {
344 true
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::{command_exists, suggested_scope_from_staged};
351 use std::fs;
352 use std::path::{Path, PathBuf};
353 use std::sync::{Mutex, OnceLock};
354 use std::time::{SystemTime, UNIX_EPOCH};
355
356 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
357 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
358 LOCK.get_or_init(|| Mutex::new(()))
359 .lock()
360 .expect("env lock")
361 }
362
363 struct EnvGuard {
364 key: &'static str,
365 old: Option<std::ffi::OsString>,
366 }
367
368 impl EnvGuard {
369 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
370 let old = std::env::var_os(key);
371 unsafe { std::env::set_var(key, value) };
373 Self { key, old }
374 }
375 }
376
377 impl Drop for EnvGuard {
378 fn drop(&mut self) {
379 if let Some(value) = self.old.take() {
380 unsafe { std::env::set_var(self.key, value) };
382 } else {
383 unsafe { std::env::remove_var(self.key) };
385 }
386 }
387 }
388
389 fn temp_dir(prefix: &str) -> PathBuf {
390 let mut path = std::env::temp_dir();
391 let nanos = SystemTime::now()
392 .duration_since(UNIX_EPOCH)
393 .map(|duration| duration.as_nanos())
394 .unwrap_or(0);
395 path.push(format!("{prefix}-{}-{nanos}", std::process::id()));
396 let _ = fs::remove_dir_all(&path);
397 fs::create_dir_all(&path).expect("temp dir");
398 path
399 }
400
401 #[cfg(unix)]
402 fn write_executable(path: &Path, content: &str, mode: u32) {
403 use std::os::unix::fs::PermissionsExt;
404 fs::write(path, content).expect("write");
405 let mut perms = fs::metadata(path).expect("metadata").permissions();
406 perms.set_mode(mode);
407 fs::set_permissions(path, perms).expect("chmod");
408 }
409
410 #[test]
411 fn suggested_scope_prefers_single_top_level_directory() {
412 let staged = "src/main.rs\nsrc/lib.rs\n";
413 assert_eq!(suggested_scope_from_staged(staged), "src");
414 }
415
416 #[test]
417 fn suggested_scope_ignores_root_file_when_single_directory_exists() {
418 let staged = "README.md\nsrc/main.rs\n";
419 assert_eq!(suggested_scope_from_staged(staged), "src");
420 }
421
422 #[test]
423 fn suggested_scope_returns_empty_for_multiple_directories() {
424 let staged = "src/main.rs\ncrates/a.rs\n";
425 assert_eq!(suggested_scope_from_staged(staged), "");
426 }
427
428 #[cfg(unix)]
429 #[test]
430 fn command_exists_checks_executable_bit() {
431 let _lock = env_lock();
432 let dir = temp_dir("gemini-commit-command-exists");
433 let executable = dir.join("tool-ok");
434 let non_executable = dir.join("tool-no");
435 write_executable(&executable, "#!/bin/sh\necho ok\n", 0o755);
436 write_executable(&non_executable, "plain text", 0o644);
437
438 let _path = EnvGuard::set("PATH", dir.as_os_str());
439 assert!(command_exists("tool-ok"));
440 assert!(!command_exists("tool-no"));
441 assert!(!command_exists("tool-missing"));
442
443 let _ = fs::remove_dir_all(dir);
444 }
445}