Skip to main content

atomcode_core/agent/
git_auto_commit.rs

1//! Auto-commit edited files after each agent turn.
2//!
3//! When `config.auto_commit` is enabled and the working directory is a git repo,
4//! files edited during the turn are staged and committed with an auto-generated message.
5
6use 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
16/// Auto-commit files edited during the agent turn.
17pub 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    // Do not mix user-staged changes into an automatic commit. `git commit`
31    // commits the whole index, so auto-commit is only safe when the index is
32    // clean before we stage this turn's edited files.
33    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    // Stage only the files that were actually edited.
51    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    // Check if there are staged changes
76    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            // Exit code 0 means no staged changes
84            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    // Extract commit SHA
116    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    // Extract short file names for the message
157    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}