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