Skip to main content

gemini_cli/agent/
commit.rs

1use std::io::{self, Write};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use nils_common::{git as common_git, process};
6
7use crate::prompts;
8
9use super::exec;
10
11#[derive(Clone, Debug, Default)]
12pub struct CommitOptions {
13    pub push: bool,
14    pub auto_stage: bool,
15    pub extra: Vec<String>,
16}
17
18pub fn run(options: &CommitOptions) -> i32 {
19    if !command_exists("git") {
20        eprintln!("gemini-commit-with-scope: missing binary: git");
21        return 1;
22    }
23
24    let git_root = match git_root() {
25        Some(value) => value,
26        None => {
27            eprintln!("gemini-commit-with-scope: not a git repository");
28            return 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.map(|value| value.success()).unwrap_or(false) {
40            return 1;
41        }
42    } else {
43        let staged = staged_files(&git_root);
44        if staged.trim().is_empty() {
45            eprintln!("gemini-commit-with-scope: no staged changes (stage files then retry)");
46            return 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("gemini-commit-with-scope"), &mut stderr) {
60            return 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 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    exec::exec_dangerous(&prompt, "gemini-commit-with-scope", &mut stderr)
88}
89
90fn run_fallback(git_root: &Path, push_flag: bool, extra_prompt: &str) -> i32 {
91    let staged = staged_files(git_root);
92    if staged.trim().is_empty() {
93        eprintln!("gemini-commit-with-scope: no staged changes (stage files then retry)");
94        return 1;
95    }
96
97    eprintln!("gemini-commit-with-scope: semantic-commit not found on PATH (fallback mode)");
98    if !extra_prompt.trim().is_empty() {
99        eprintln!("gemini-commit-with-scope: note: extra prompt is ignored in fallback mode");
100    }
101
102    println!("Staged files:");
103    print!("{staged}");
104
105    let suggested_scope = suggested_scope_from_staged(&staged);
106
107    let mut commit_type = match read_prompt("Type [chore]: ") {
108        Ok(value) => value,
109        Err(_) => return 1,
110    };
111    commit_type = commit_type.to_ascii_lowercase();
112    commit_type.retain(|ch| !ch.is_whitespace());
113    if commit_type.is_empty() {
114        commit_type = "chore".to_string();
115    }
116
117    let scope_prompt = if suggested_scope.is_empty() {
118        "Scope (optional): ".to_string()
119    } else {
120        format!("Scope (optional) [{suggested_scope}]: ")
121    };
122    let mut scope = match read_prompt(&scope_prompt) {
123        Ok(value) => value,
124        Err(_) => return 1,
125    };
126    scope.retain(|ch| !ch.is_whitespace());
127    if scope.is_empty() {
128        scope = suggested_scope;
129    }
130
131    let subject = loop {
132        let raw = match read_prompt("Subject: ") {
133            Ok(value) => value,
134            Err(_) => return 1,
135        };
136        let trimmed = raw.trim();
137        if !trimmed.is_empty() {
138            break trimmed.to_string();
139        }
140    };
141
142    let header = if scope.is_empty() {
143        format!("{commit_type}: {subject}")
144    } else {
145        format!("{commit_type}({scope}): {subject}")
146    };
147
148    println!();
149    println!("Commit message:");
150    println!("  {header}");
151
152    let confirm = match read_prompt("Proceed? [y/N] ") {
153        Ok(value) => value,
154        Err(_) => return 1,
155    };
156    if !matches!(confirm.trim().chars().next(), Some('y' | 'Y')) {
157        eprintln!("Aborted.");
158        return 1;
159    }
160
161    let status = Command::new("git")
162        .arg("-C")
163        .arg(git_root)
164        .arg("commit")
165        .arg("-m")
166        .arg(&header)
167        .status();
168    if !status.map(|value| value.success()).unwrap_or(false) {
169        return 1;
170    }
171
172    if push_flag {
173        let status = Command::new("git")
174            .arg("-C")
175            .arg(git_root)
176            .arg("push")
177            .status();
178        if !status.map(|value| value.success()).unwrap_or(false) {
179            return 1;
180        }
181    }
182
183    let _ = Command::new("git")
184        .arg("-C")
185        .arg(git_root)
186        .arg("show")
187        .arg("-1")
188        .arg("--name-status")
189        .arg("--oneline")
190        .status();
191
192    0
193}
194
195fn suggested_scope_from_staged(staged: &str) -> String {
196    common_git::suggested_scope_from_staged_paths(staged)
197}
198
199fn read_prompt(prompt: &str) -> io::Result<String> {
200    print!("{prompt}");
201    let _ = io::stdout().flush();
202
203    let mut line = String::new();
204    let bytes = io::stdin().read_line(&mut line)?;
205    if bytes == 0 {
206        return Ok(String::new());
207    }
208    Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
209}
210
211fn staged_files(git_root: &Path) -> String {
212    common_git::staged_name_only_in(git_root).unwrap_or_default()
213}
214
215fn git_root() -> Option<PathBuf> {
216    common_git::repo_root().ok().flatten()
217}
218
219fn semantic_commit_prompt(mode: &str) -> Option<String> {
220    let template_name = match mode {
221        "staged" => "semantic-commit-staged",
222        "autostage" => "semantic-commit-autostage",
223        other => {
224            eprintln!("_gemini_tools_semantic_commit_prompt: invalid mode: {other}");
225            return None;
226        }
227    };
228
229    let prompts_dir = match prompts::resolve_prompts_dir() {
230        Some(value) => value,
231        None => {
232            eprintln!(
233                "_gemini_tools_semantic_commit_prompt: prompts dir not found (expected: $ZDOTDIR/prompts)"
234            );
235            return None;
236        }
237    };
238
239    let prompt_file = prompts_dir.join(format!("{template_name}.md"));
240    if !prompt_file.is_file() {
241        eprintln!(
242            "_gemini_tools_semantic_commit_prompt: prompt template not found: {}",
243            prompt_file.to_string_lossy()
244        );
245        return None;
246    }
247
248    match std::fs::read_to_string(&prompt_file) {
249        Ok(content) => Some(content),
250        Err(_) => {
251            eprintln!(
252                "_gemini_tools_semantic_commit_prompt: failed to read prompt template: {}",
253                prompt_file.to_string_lossy()
254            );
255            None
256        }
257    }
258}
259
260fn command_exists(name: &str) -> bool {
261    process::cmd_exists(name)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::{command_exists, suggested_scope_from_staged};
267    use nils_test_support::{GlobalStateLock, fs as test_fs, prepend_path};
268    use std::fs;
269
270    #[test]
271    fn suggested_scope_prefers_single_top_level_directory() {
272        let staged = "src/main.rs\nsrc/lib.rs\n";
273        assert_eq!(suggested_scope_from_staged(staged), "src");
274    }
275
276    #[test]
277    fn suggested_scope_ignores_root_file_when_single_directory_exists() {
278        let staged = "README.md\nsrc/main.rs\n";
279        assert_eq!(suggested_scope_from_staged(staged), "src");
280    }
281
282    #[test]
283    fn suggested_scope_returns_empty_for_multiple_directories() {
284        let staged = "src/main.rs\ncrates/a.rs\n";
285        assert_eq!(suggested_scope_from_staged(staged), "");
286    }
287
288    #[cfg(unix)]
289    #[test]
290    fn command_exists_checks_executable_bit() {
291        use std::os::unix::fs::PermissionsExt;
292
293        let lock = GlobalStateLock::new();
294        let dir = tempfile::TempDir::new().expect("tempdir");
295        let executable = dir.path().join("tool-ok");
296        let non_executable = dir.path().join("tool-no");
297        test_fs::write_executable(&executable, "#!/bin/sh\necho ok\n");
298        fs::write(&non_executable, "plain text").expect("write non executable");
299        let mut perms = fs::metadata(&non_executable)
300            .expect("metadata")
301            .permissions();
302        perms.set_mode(0o644);
303        fs::set_permissions(&non_executable, perms).expect("chmod non executable");
304
305        let _path_guard = prepend_path(&lock, dir.path());
306        assert!(command_exists("tool-ok"));
307        assert!(!command_exists("tool-no"));
308        assert!(!command_exists("tool-missing"));
309    }
310}