Skip to main content

gemini_cli/agent/
commit.rs

1use 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            // SAFETY: tests mutate env in guarded scope.
372            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                // SAFETY: tests restore env in guarded scope.
381                unsafe { std::env::set_var(self.key, value) };
382            } else {
383                // SAFETY: tests restore env in guarded scope.
384                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}