atomcode_core/agent/
git_auto_commit.rs1use std::path::Path;
7use std::process::Command;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum AutoCommitOutcome {
11 Committed { sha: String, message: String },
12 Skipped { reason: String },
13 Failed { reason: String },
14}
15
16pub fn auto_commit_edited_files(working_dir: &Path, edited_files: &[String]) -> AutoCommitOutcome {
18 if edited_files.is_empty() {
19 return AutoCommitOutcome::Skipped {
20 reason: "no edited files".to_string(),
21 };
22 }
23
24 if !is_git_repo(working_dir) {
25 return AutoCommitOutcome::Skipped {
26 reason: "not a git repository".to_string(),
27 };
28 }
29
30 if has_staged_changes(working_dir) {
34 return AutoCommitOutcome::Skipped {
35 reason: "index has pre-existing staged changes".to_string(),
36 };
37 }
38
39 let file_paths: Vec<String> = edited_files
40 .iter()
41 .map(|file| {
42 if Path::new(file).is_absolute() {
43 file.to_string()
44 } else {
45 working_dir.join(file).to_string_lossy().to_string()
46 }
47 })
48 .collect();
49
50 let mut add_cmd = Command::new("git");
52 add_cmd.arg("add").arg("--").args(&file_paths).current_dir(working_dir);
53 crate::process_utils::suppress_console_window_sync(&mut add_cmd);
54 let output = match add_cmd.output() {
55 Ok(output) => output,
56 Err(e) => {
57 return AutoCommitOutcome::Failed {
58 reason: format!("git add failed to start: {e}"),
59 };
60 }
61 };
62
63 if !output.status.success() {
64 return AutoCommitOutcome::Failed {
65 reason: format!("git add failed: {}", command_output_message(&output)),
66 };
67 }
68
69 if file_paths.is_empty() {
70 return AutoCommitOutcome::Skipped {
71 reason: "no edited files".to_string(),
72 };
73 }
74
75 let mut diff_cmd = Command::new("git");
77 diff_cmd.args(["diff", "--cached", "--quiet"])
78 .current_dir(working_dir);
79 crate::process_utils::suppress_console_window_sync(&mut diff_cmd);
80 let diff_output = diff_cmd.status();
81 if let Ok(status) = diff_output {
82 if status.success() {
83 return AutoCommitOutcome::Skipped {
85 reason: "no staged changes after git add".to_string(),
86 };
87 }
88 } else if let Err(e) = diff_output {
89 return AutoCommitOutcome::Failed {
90 reason: format!("git diff --cached failed to start: {e}"),
91 };
92 }
93
94 let message = generate_commit_message(edited_files);
95
96 let mut commit_cmd = Command::new("git");
97 commit_cmd.args(["commit", "-m", &message])
98 .current_dir(working_dir);
99 crate::process_utils::suppress_console_window_sync(&mut commit_cmd);
100 let output = match commit_cmd.output() {
101 Ok(output) => output,
102 Err(e) => {
103 return AutoCommitOutcome::Failed {
104 reason: format!("git commit failed to start: {e}"),
105 };
106 }
107 };
108
109 if !output.status.success() {
110 return AutoCommitOutcome::Failed {
111 reason: format!("git commit failed: {}", command_output_message(&output)),
112 };
113 }
114
115 let mut rev_cmd = Command::new("git");
117 rev_cmd.args(["rev-parse", "--short", "HEAD"])
118 .current_dir(working_dir);
119 crate::process_utils::suppress_console_window_sync(&mut rev_cmd);
120 let sha_output = match rev_cmd.output() {
121 Ok(output) => output,
122 Err(e) => {
123 return AutoCommitOutcome::Failed {
124 reason: format!("git rev-parse failed to start: {e}"),
125 };
126 }
127 };
128
129 let sha = String::from_utf8_lossy(&sha_output.stdout)
130 .trim()
131 .to_string();
132 if sha.is_empty() {
133 AutoCommitOutcome::Failed {
134 reason: "git rev-parse returned an empty sha".to_string(),
135 }
136 } else {
137 AutoCommitOutcome::Committed { sha, message }
138 }
139}
140
141fn command_output_message(output: &std::process::Output) -> String {
142 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
143 if !stderr.is_empty() {
144 return stderr;
145 }
146 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
147 if !stdout.is_empty() {
148 return stdout;
149 }
150 format!("exit status {}", output.status)
151}
152
153fn generate_commit_message(files: &[String]) -> String {
154 let file_count = files.len();
155
156 let short_names: Vec<&str> = files
158 .iter()
159 .map(|f| f.rsplit('/').next().unwrap_or(f))
160 .collect();
161
162 if file_count == 1 {
163 format!("atomcode: edit {}", short_names[0])
164 } else if file_count <= 3 {
165 format!("atomcode: edit {}", short_names.join(", "))
166 } else {
167 format!(
168 "atomcode: edit {} and {} more",
169 short_names[..2].join(", "),
170 file_count - 2
171 )
172 }
173}
174
175fn is_git_repo(working_dir: &Path) -> bool {
176 let mut cmd = Command::new("git");
177 cmd.args(["rev-parse", "--git-dir"])
178 .current_dir(working_dir);
179 crate::process_utils::suppress_console_window_sync(&mut cmd);
180 cmd.output()
181 .ok()
182 .map(|o| o.status.success())
183 .unwrap_or(false)
184}
185
186fn has_staged_changes(working_dir: &Path) -> bool {
187 let mut cmd = Command::new("git");
188 cmd.args(["diff", "--cached", "--quiet"])
189 .current_dir(working_dir);
190 crate::process_utils::suppress_console_window_sync(&mut cmd);
191 cmd.status()
192 .map(|status| !status.success())
193 .unwrap_or(true)
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use std::fs;
200
201 fn run_git(dir: &Path, args: &[&str]) {
202 let output = Command::new("git")
203 .args(args)
204 .current_dir(dir)
205 .output()
206 .unwrap();
207 assert!(
208 output.status.success(),
209 "git {:?} failed: {}",
210 args,
211 String::from_utf8_lossy(&output.stderr)
212 );
213 }
214
215 fn init_repo() -> tempfile::TempDir {
216 let dir = tempfile::tempdir().unwrap();
217 run_git(dir.path(), &["init"]);
218 run_git(
219 dir.path(),
220 &["config", "user.email", "atomcode@example.com"],
221 );
222 run_git(dir.path(), &["config", "user.name", "AtomCode"]);
223 dir
224 }
225
226 #[test]
227 fn auto_commit_commits_only_when_index_is_clean() {
228 let dir = init_repo();
229 let edited = dir.path().join("edited.txt");
230 fs::write(&edited, "hello\n").unwrap();
231
232 let outcome = auto_commit_edited_files(dir.path(), &["edited.txt".to_string()]);
233 assert!(matches!(outcome, AutoCommitOutcome::Committed { .. }));
234
235 let log = Command::new("git")
236 .args(["log", "--oneline", "-1"])
237 .current_dir(dir.path())
238 .output()
239 .unwrap();
240 assert!(String::from_utf8_lossy(&log.stdout).contains("atomcode: edit edited.txt"));
241 }
242
243 #[test]
244 fn auto_commit_skips_when_user_has_staged_changes() {
245 let dir = init_repo();
246 fs::write(dir.path().join("pre_staged.txt"), "user work\n").unwrap();
247 run_git(dir.path(), &["add", "pre_staged.txt"]);
248
249 fs::write(dir.path().join("edited.txt"), "agent work\n").unwrap();
250 let outcome = auto_commit_edited_files(dir.path(), &["edited.txt".to_string()]);
251
252 assert!(matches!(outcome, AutoCommitOutcome::Skipped { .. }));
253 let status = Command::new("git")
254 .args(["diff", "--cached", "--name-only"])
255 .current_dir(dir.path())
256 .output()
257 .unwrap();
258 assert_eq!(
259 String::from_utf8_lossy(&status.stdout).trim(),
260 "pre_staged.txt"
261 );
262 }
263}