gemini_cli/agent/
commit.rs1use 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}