Skip to main content

auto_commit_rs/
git.rs

1use anyhow::{bail, Context, Result};
2use std::fs;
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6/// Get the output of `git diff --staged`
7pub fn get_staged_diff() -> Result<String> {
8    let output = Command::new("git")
9        .args(["diff", "--staged"])
10        .output()
11        .context("Failed to run git diff --staged")?;
12
13    if !output.status.success() {
14        let stderr = String::from_utf8_lossy(&output.stderr);
15        bail!("git diff --staged failed: {stderr}");
16    }
17
18    let diff = String::from_utf8_lossy(&output.stdout).to_string();
19
20    if diff.trim().is_empty() {
21        bail!(
22            "No staged changes found. Stage files with {} first.",
23            colored::Colorize::yellow("git add <files>")
24        );
25    }
26
27    Ok(diff)
28}
29
30/// List staged file paths
31pub fn list_staged_files() -> Result<Vec<String>> {
32    let output = Command::new("git")
33        .args(["diff", "--staged", "--name-only"])
34        .output()
35        .context("Failed to run git diff --staged --name-only")?;
36
37    if !output.status.success() {
38        let stderr = String::from_utf8_lossy(&output.stderr);
39        bail!("git diff --staged --name-only failed: {stderr}");
40    }
41
42    let files = String::from_utf8_lossy(&output.stdout)
43        .lines()
44        .map(str::trim)
45        .filter(|line| !line.is_empty())
46        .map(ToString::to_string)
47        .collect::<Vec<_>>();
48
49    Ok(files)
50}
51
52/// Find the git repository root directory
53pub fn find_repo_root() -> Result<String> {
54    let output = Command::new("git")
55        .args(["rev-parse", "--show-toplevel"])
56        .output()
57        .context("Failed to run git rev-parse")?;
58
59    if !output.status.success() {
60        bail!("Not in a git repository");
61    }
62
63    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
64}
65
66/// Run `git commit -m "<message>" [extra_args...]`
67pub fn run_commit(message: &str, extra_args: &[String], suppress_output: bool) -> Result<()> {
68    let mut cmd = Command::new("git");
69    cmd.args(["commit", "-m", message]);
70    cmd.args(extra_args);
71    configure_stdio(&mut cmd, suppress_output);
72    let status = cmd.status().context("Failed to run git commit")?;
73
74    if !status.success() {
75        bail!("git commit exited with status {status}");
76    }
77
78    Ok(())
79}
80
81/// Run `git push`
82pub fn run_push(suppress_output: bool) -> Result<()> {
83    let mut cmd = Command::new("git");
84    cmd.arg("push");
85    configure_stdio(&mut cmd, suppress_output);
86
87    let status = cmd.status().context("Failed to run git push")?;
88    if !status.success() {
89        bail!("git push exited with status {status}");
90    }
91
92    Ok(())
93}
94
95/// Returns the latest tag according to git version sorting.
96pub fn get_latest_tag() -> Result<Option<String>> {
97    let output = Command::new("git")
98        .args(["tag", "--sort=-version:refname"])
99        .output()
100        .context("Failed to run git tag --sort=-version:refname")?;
101
102    if !output.status.success() {
103        let stderr = String::from_utf8_lossy(&output.stderr);
104        bail!("git tag --sort=-version:refname failed: {stderr}");
105    }
106
107    let latest = String::from_utf8_lossy(&output.stdout)
108        .lines()
109        .map(str::trim)
110        .find(|line| !line.is_empty())
111        .map(ToString::to_string);
112
113    Ok(latest)
114}
115
116/// Compute the next minor semver tag from latest tag.
117pub fn compute_next_minor_tag(latest: Option<&str>) -> Result<String> {
118    let Some(latest_tag) = latest else {
119        return Ok("0.1.0".to_string());
120    };
121
122    let (major, minor, _patch) = parse_semver_tag(latest_tag)?;
123    Ok(format!("{major}.{}.0", minor + 1))
124}
125
126/// Create a git lightweight tag.
127pub fn create_tag(tag_name: &str, suppress_output: bool) -> Result<()> {
128    let mut cmd = Command::new("git");
129    cmd.args(["tag", tag_name]);
130    configure_stdio(&mut cmd, suppress_output);
131    let status = cmd.status().context("Failed to run git tag")?;
132
133    if !status.success() {
134        bail!("git tag exited with status {status}");
135    }
136
137    Ok(())
138}
139
140/// Returns true when HEAD exists on upstream branch
141pub fn is_head_pushed() -> Result<bool> {
142    if !has_upstream_branch()? {
143        return Ok(false);
144    }
145
146    let output = Command::new("git")
147        .args(["branch", "-r", "--contains", "HEAD"])
148        .output()
149        .context("Failed to determine whether HEAD is pushed")?;
150
151    if !output.status.success() {
152        let stderr = String::from_utf8_lossy(&output.stderr);
153        bail!("git branch -r --contains HEAD failed: {stderr}");
154    }
155
156    let stdout = String::from_utf8_lossy(&output.stdout);
157    let pushed = stdout
158        .lines()
159        .map(str::trim)
160        .any(|line| !line.is_empty() && !line.contains("->"));
161    Ok(pushed)
162}
163
164/// Returns true if HEAD has multiple parents
165pub fn head_is_merge_commit() -> Result<bool> {
166    ensure_head_exists()?;
167
168    let output = Command::new("git")
169        .args(["rev-list", "--parents", "-n", "1", "HEAD"])
170        .output()
171        .context("Failed to inspect latest commit parents")?;
172
173    if !output.status.success() {
174        let stderr = String::from_utf8_lossy(&output.stderr);
175        bail!("git rev-list --parents -n 1 HEAD failed: {stderr}");
176    }
177
178    let parent_count = String::from_utf8_lossy(&output.stdout)
179        .split_whitespace()
180        .count()
181        .saturating_sub(1);
182    Ok(parent_count > 1)
183}
184
185/// Undo latest commit, keep all changes staged
186pub fn undo_last_commit_soft(suppress_output: bool) -> Result<()> {
187    ensure_head_exists()?;
188
189    let mut cmd = Command::new("git");
190    cmd.args(["reset", "--soft", "HEAD~1"]);
191    configure_stdio(&mut cmd, suppress_output);
192
193    let status = cmd
194        .status()
195        .context("Failed to run git reset --soft HEAD~1")?;
196    if !status.success() {
197        bail!("git reset --soft HEAD~1 exited with status {status}");
198    }
199    Ok(())
200}
201
202pub fn has_upstream_branch() -> Result<bool> {
203    let status = Command::new("git")
204        .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
205        .stdout(Stdio::null())
206        .stderr(Stdio::null())
207        .status()
208        .context("Failed to detect upstream branch")?;
209    Ok(status.success())
210}
211
212pub fn ensure_head_exists() -> Result<()> {
213    let status = Command::new("git")
214        .args(["rev-parse", "--verify", "HEAD"])
215        .stdout(Stdio::null())
216        .stderr(Stdio::null())
217        .status()
218        .context("Failed to run git rev-parse --verify HEAD")?;
219
220    if !status.success() {
221        bail!("No commits found in this repository.");
222    }
223    Ok(())
224}
225
226fn configure_stdio(cmd: &mut Command, suppress_output: bool) {
227    if suppress_output {
228        cmd.stdout(Stdio::null()).stderr(Stdio::null());
229    }
230}
231
232pub fn ensure_commit_exists(commit: &str) -> Result<()> {
233    let status = Command::new("git")
234        .args(["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
235        .stdout(Stdio::null())
236        .stderr(Stdio::null())
237        .status()
238        .with_context(|| format!("Failed to verify commit reference {commit}"))?;
239
240    if !status.success() {
241        bail!("Commit reference not found: {commit}");
242    }
243    Ok(())
244}
245
246pub fn get_commit_diff(commit: &str) -> Result<String> {
247    ensure_commit_exists(commit)?;
248    let output = Command::new("git")
249        .args(["show", "--format=", "--no-color", commit])
250        .output()
251        .with_context(|| format!("Failed to run git show for {commit}"))?;
252
253    if !output.status.success() {
254        let stderr = String::from_utf8_lossy(&output.stderr);
255        bail!("git show failed for {commit}: {stderr}");
256    }
257
258    let diff = String::from_utf8_lossy(&output.stdout).to_string();
259    if diff.trim().is_empty() {
260        bail!("Selected commit has no diff to analyze: {commit}");
261    }
262    Ok(diff)
263}
264
265pub fn get_range_diff(older: &str, newer: &str) -> Result<String> {
266    ensure_commit_exists(older)?;
267    ensure_commit_exists(newer)?;
268
269    let output = Command::new("git")
270        .args(["diff", "--no-color", older, newer])
271        .output()
272        .with_context(|| format!("Failed to run git diff {older} {newer}"))?;
273
274    if !output.status.success() {
275        let stderr = String::from_utf8_lossy(&output.stderr);
276        bail!("git diff failed for {older}..{newer}: {stderr}");
277    }
278
279    let diff = String::from_utf8_lossy(&output.stdout).to_string();
280    if diff.trim().is_empty() {
281        bail!("No diff found for range {older}..{newer}");
282    }
283    Ok(diff)
284}
285
286pub fn is_head_commit(commit: &str) -> Result<bool> {
287    ensure_commit_exists(commit)?;
288    Ok(resolve_commit("HEAD")? == resolve_commit(commit)?)
289}
290
291pub fn commit_is_merge(commit: &str) -> Result<bool> {
292    ensure_commit_exists(commit)?;
293    let output = Command::new("git")
294        .args(["rev-list", "--parents", "-n", "1", commit])
295        .output()
296        .with_context(|| format!("Failed to inspect parents for {commit}"))?;
297
298    if !output.status.success() {
299        let stderr = String::from_utf8_lossy(&output.stderr);
300        bail!("git rev-list failed for {commit}: {stderr}");
301    }
302
303    let parent_count = String::from_utf8_lossy(&output.stdout)
304        .split_whitespace()
305        .count()
306        .saturating_sub(1);
307    Ok(parent_count > 1)
308}
309
310pub fn commit_is_pushed(commit: &str) -> Result<bool> {
311    ensure_commit_exists(commit)?;
312    if !has_upstream_branch()? {
313        return Ok(false);
314    }
315
316    let output = Command::new("git")
317        .args(["branch", "-r", "--contains", commit])
318        .output()
319        .with_context(|| format!("Failed to determine whether {commit} is pushed"))?;
320
321    if !output.status.success() {
322        let stderr = String::from_utf8_lossy(&output.stderr);
323        bail!("git branch -r --contains {commit} failed: {stderr}");
324    }
325
326    let stdout = String::from_utf8_lossy(&output.stdout);
327    Ok(stdout
328        .lines()
329        .map(str::trim)
330        .any(|line| !line.is_empty() && !line.contains("->")))
331}
332
333pub fn rewrite_commit_message(target: &str, message: &str, suppress_output: bool) -> Result<()> {
334    ensure_commit_exists(target)?;
335
336    if is_head_commit(target)? {
337        let mut cmd = Command::new("git");
338        cmd.args(["commit", "--amend", "-m", message]);
339        configure_stdio(&mut cmd, suppress_output);
340        let status = cmd.status().context("Failed to run git commit --amend")?;
341        if !status.success() {
342            bail!("git commit --amend exited with status {status}");
343        }
344        return Ok(());
345    }
346
347    if commit_is_merge(target)? {
348        bail!("Altering non-HEAD merge commits is not supported.");
349    }
350
351    ensure_ancestor_of_head(target)?;
352    reword_non_head_commit(target, message, suppress_output)
353}
354
355fn resolve_commit(commit: &str) -> Result<String> {
356    let output = Command::new("git")
357        .args(["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
358        .output()
359        .with_context(|| format!("Failed to resolve commit {commit}"))?;
360
361    if !output.status.success() {
362        let stderr = String::from_utf8_lossy(&output.stderr);
363        bail!("Failed to resolve commit {commit}: {stderr}");
364    }
365
366    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
367}
368
369fn ensure_ancestor_of_head(commit: &str) -> Result<()> {
370    let status = Command::new("git")
371        .args(["merge-base", "--is-ancestor", commit, "HEAD"])
372        .stdout(Stdio::null())
373        .stderr(Stdio::null())
374        .status()
375        .with_context(|| format!("Failed to check whether {commit} is an ancestor of HEAD"))?;
376
377    if !status.success() {
378        bail!("Target commit must be on the current branch and reachable from HEAD.");
379    }
380    Ok(())
381}
382
383fn reword_non_head_commit(target: &str, message: &str, suppress_output: bool) -> Result<()> {
384    let parent = format!("{target}^");
385    let temp = std::env::temp_dir();
386    let sequence_editor = temp.join(format!("cgen-seq-editor-{}.sh", std::process::id()));
387    let message_editor = temp.join(format!("cgen-msg-editor-{}.sh", std::process::id()));
388
389    write_sequence_editor_script(&sequence_editor)?;
390    write_message_editor_script(&message_editor)?;
391
392    let mut cmd = Command::new("git");
393    cmd.args(["rebase", "-i", &parent]);
394    cmd.env("GIT_SEQUENCE_EDITOR", script_command(&sequence_editor));
395    cmd.env("GIT_EDITOR", script_command(&message_editor));
396    cmd.env("CGEN_NEW_MESSAGE", message);
397    configure_stdio(&mut cmd, suppress_output);
398
399    let status = cmd.status().context("Failed to run git rebase -i")?;
400
401    let _ = fs::remove_file(&sequence_editor);
402    let _ = fs::remove_file(&message_editor);
403
404    if !status.success() {
405        bail!(
406            "Rewriting commit message failed during rebase. Resolve conflicts and run `git rebase --abort` if needed."
407        );
408    }
409    Ok(())
410}
411
412fn write_sequence_editor_script(path: &Path) -> Result<()> {
413    let script = r#"#!/bin/sh
414set -e
415todo="$1"
416tmp="${todo}.cgen"
417first=1
418
419while IFS= read -r line; do
420  if [ "$first" -eq 1 ] && printf '%s\n' "$line" | grep -q '^pick '; then
421    printf '%s\n' "$line" | sed 's/^pick /reword /' >> "$tmp"
422    first=0
423  else
424    printf '%s\n' "$line" >> "$tmp"
425  fi
426done < "$todo"
427
428mv "$tmp" "$todo"
429"#;
430    fs::write(path, script).with_context(|| format!("Failed to write {:?}", path))?;
431    make_executable(path)?;
432    Ok(())
433}
434
435fn write_message_editor_script(path: &Path) -> Result<()> {
436    let script = r#"#!/bin/sh
437set -e
438msg_file="$1"
439printf '%s\n' "$CGEN_NEW_MESSAGE" > "$msg_file"
440"#;
441    fs::write(path, script).with_context(|| format!("Failed to write {:?}", path))?;
442    make_executable(path)?;
443    Ok(())
444}
445
446fn make_executable(path: &Path) -> Result<()> {
447    #[cfg(not(unix))]
448    let _ = path;
449
450    #[cfg(unix)]
451    {
452        use std::os::unix::fs::PermissionsExt;
453        let mut perms = fs::metadata(path)?.permissions();
454        perms.set_mode(0o700);
455        fs::set_permissions(path, perms)?;
456    }
457    Ok(())
458}
459
460fn script_command(path: &Path) -> String {
461    path.to_string_lossy().replace('\\', "/")
462}
463
464fn parse_semver_tag(tag: &str) -> Result<(u64, u64, u64)> {
465    let parts: Vec<&str> = tag.trim().split('.').collect();
466    if parts.len() != 3 {
467        bail!("Latest tag '{tag}' is not valid semantic versioning (expected MAJOR.MINOR.PATCH).");
468    }
469
470    let major = parts[0]
471        .parse::<u64>()
472        .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
473    let minor = parts[1]
474        .parse::<u64>()
475        .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
476    let patch = parts[2]
477        .parse::<u64>()
478        .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
479
480    Ok((major, minor, patch))
481}