Skip to main content

anodizer_core/git/
commits.rs

1use anyhow::{Context as _, Result, bail};
2use std::process::Command;
3
4use super::git_output;
5
6#[derive(Debug, Clone)]
7pub struct Commit {
8    pub hash: String,
9    pub short_hash: String,
10    pub message: String,
11    pub author_name: String,
12    pub author_email: String,
13    /// Full commit message body (everything after the subject line).
14    /// Contains trailers like `Co-Authored-By:`.
15    pub body: String,
16}
17
18/// Parse git log output (formatted as `%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e`)
19/// into a vec of [`Commit`]s.
20///
21/// Uses ASCII record separator (0x1e) between commits and unit separator (0x1f)
22/// between fields, so multi-line body text doesn't break parsing.
23fn parse_commit_output(output: &str) -> Vec<Commit> {
24    if output.is_empty() {
25        return vec![];
26    }
27    output
28        .split('\x1e')
29        .filter(|record| !record.trim().is_empty())
30        .filter_map(|record| {
31            let fields: Vec<&str> = record.split('\x1f').collect();
32            if fields.len() >= 5 {
33                Some(Commit {
34                    hash: fields[0].trim().to_string(),
35                    short_hash: fields[1].to_string(),
36                    message: fields[2].to_string(),
37                    author_name: fields[3].to_string(),
38                    author_email: fields[4].to_string(),
39                    body: fields.get(5).unwrap_or(&"").trim().to_string(),
40                })
41            } else {
42                None
43            }
44        })
45        .collect()
46}
47
48/// Get commits between two refs, optionally filtered to a path.
49pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
50    get_commits_between_paths(
51        from,
52        to,
53        &path_filter
54            .into_iter()
55            .map(String::from)
56            .collect::<Vec<_>>(),
57    )
58}
59
60/// Get commits between two refs, filtered to multiple paths (git log -- path1 path2 ...).
61pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
62    let range = format!("{}..{}", from, to);
63    let mut args = vec![
64        "-c".to_string(),
65        "log.showSignature=false".to_string(),
66        "log".to_string(),
67        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
68        range,
69    ];
70    if !paths.is_empty() {
71        args.push("--".to_string());
72        for p in paths {
73            args.push(p.clone());
74        }
75    }
76    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
77    let output = git_output(&arg_refs)?;
78    Ok(parse_commit_output(&output))
79}
80
81/// Get all commits reachable from HEAD, optionally filtered to a path.
82/// Used for initial releases where there is no previous tag.
83pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
84    get_all_commits_paths(
85        &path_filter
86            .into_iter()
87            .map(String::from)
88            .collect::<Vec<_>>(),
89    )
90}
91
92/// Get all commits reachable from HEAD, filtered to multiple paths.
93pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
94    let mut args = vec![
95        "-c".to_string(),
96        "log.showSignature=false".to_string(),
97        "log".to_string(),
98        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
99        "HEAD".to_string(),
100    ];
101    if !paths.is_empty() {
102        args.push("--".to_string());
103        for p in paths {
104            args.push(p.clone());
105        }
106    }
107    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
108    let output = git_output(&arg_refs)?;
109    Ok(parse_commit_output(&output))
110}
111
112/// Get last N commit subjects.
113pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
114    let output = git_output(&[
115        "-c",
116        "log.showSignature=false",
117        "log",
118        &format!("-{count}"),
119        "--pretty=format:%s",
120    ])?;
121    Ok(output.lines().map(str::to_string).collect())
122}
123
124/// Get commit subjects between two refs.
125pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
126    let output = git_output(&[
127        "-c",
128        "log.showSignature=false",
129        "log",
130        "--pretty=format:%s",
131        &format!("{from}..{to}"),
132    ])?;
133    Ok(output.lines().map(str::to_string).collect())
134}
135
136/// Get the current branch name.
137pub fn get_current_branch() -> Result<String> {
138    git_output(&["rev-parse", "--abbrev-ref", "HEAD"])
139}
140
141/// Check if there are any commits since a given tag.
142pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
143    let range = format!("{}..HEAD", tag);
144    let output = git_output(&["-c", "log.showSignature=false", "log", "--oneline", &range])?;
145    Ok(!output.is_empty())
146}
147
148/// Get the short commit hash of HEAD.
149pub fn get_short_commit() -> Result<String> {
150    git_output(&["rev-parse", "--short", "HEAD"])
151}
152
153/// Get the full commit hash of HEAD.
154///
155/// Mirrors `ctx.Git.FullCommit` in GoReleaser (resolved at git-pipe time and
156/// reused everywhere downstream). Used by the source-archive stage to
157/// produce deterministic archives across consecutive commits when
158/// `git_info` was not pre-populated by an earlier pipe.
159pub fn get_head_commit() -> Result<String> {
160    git_output(&["rev-parse", "HEAD"])
161}
162
163/// Check if there are changes in a path since a given tag.
164pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
165    let output = git_output(&["diff", "--name-only", &format!("{}..HEAD", tag), "--", path])?;
166    Ok(!output.is_empty())
167}
168
169/// Get last N commit subjects that touched a specific path.
170pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
171    let output = git_output(&[
172        "-c",
173        "log.showSignature=false",
174        "log",
175        &format!("-{count}"),
176        "--pretty=format:%s",
177        "--",
178        path,
179    ])?;
180    Ok(output.lines().map(str::to_string).collect())
181}
182
183/// Get commit subjects between two refs that touched a specific path.
184pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
185    let output = git_output(&[
186        "-c",
187        "log.showSignature=false",
188        "log",
189        "--pretty=format:%s",
190        &format!("{from}..{to}"),
191        "--",
192        path,
193    ])?;
194    Ok(output.lines().map(str::to_string).collect())
195}
196
197/// Stage specific files and create a commit.
198pub fn stage_and_commit(files: &[&str], message: &str) -> Result<()> {
199    let mut args = vec!["add", "--"];
200    args.extend(files.iter().copied());
201    git_output(&args)?;
202    git_output(&["commit", "-m", message])?;
203    Ok(())
204}
205
206/// `git -C <workspace_root> -c log.showSignature=false log
207/// --pretty=format:%B%x1e <range> -- <rel_path>` — list commit message
208/// bodies (subject+body) for commits in `range` touching `rel_path`,
209/// using the `\x1e` (RS) byte as a between-commits separator so multi-line
210/// bodies survive parsing.
211///
212/// `range` is the git revision range as a string (e.g. `"HEAD"`,
213/// `"v0.3.0..HEAD"`); the empty string is invalid (caller must pre-filter).
214/// Returns `Ok(Vec::new())` when git fails so callers treat
215/// "range doesn't exist yet" as a non-error.
216pub fn log_subjects_for_range(
217    workspace_root: &std::path::Path,
218    range: &str,
219    rel_path: &str,
220) -> Result<Vec<String>> {
221    let out = Command::new("git")
222        .arg("-C")
223        .arg(workspace_root)
224        .args([
225            "-c",
226            "log.showSignature=false",
227            "log",
228            "--pretty=format:%B%x1e",
229            range,
230            "--",
231            rel_path,
232        ])
233        .output()?;
234    if !out.status.success() {
235        // Range may not exist yet (no last_tag, path not in history).
236        return Ok(Vec::new());
237    }
238    let text = String::from_utf8_lossy(&out.stdout);
239    Ok(text
240        .split('\x1e')
241        .map(|s| s.trim().to_string())
242        .filter(|s| !s.is_empty())
243        .collect())
244}
245
246/// `git -C <workspace_root> add <rel>` — stage a single relative path.
247pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
248    let out = Command::new("git")
249        .arg("-C")
250        .arg(workspace_root)
251        .arg("add")
252        .arg(rel)
253        .output()
254        .context("failed to invoke git add")?;
255    if !out.status.success() {
256        let stderr_raw = String::from_utf8_lossy(&out.stderr);
257        let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
258        bail!("{}", crate::redact::redact_process_env(&raw));
259    }
260    Ok(())
261}
262
263/// `git -C <workspace_root> commit [-S] -m <message>` — create a commit
264/// with the given message, optionally GPG-signed.
265pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
266    let mut cmd = Command::new("git");
267    cmd.arg("-C").arg(workspace_root).arg("commit");
268    if sign {
269        cmd.arg("-S");
270    }
271    cmd.arg("-m").arg(message);
272    let out = cmd.output().context("failed to invoke git commit")?;
273    if !out.status.success() {
274        let stderr_raw = String::from_utf8_lossy(&out.stderr);
275        let raw = format!("git commit failed: {}", stderr_raw.trim());
276        bail!("{}", crate::redact::redact_process_env(&raw));
277    }
278    Ok(())
279}
280
281/// `git diff --name-only <tag>..HEAD -- <paths>...` — return `true` when
282/// any of the named paths changed between `tag` and `HEAD`. Returns
283/// `Ok(false)` when git fails (e.g. not a git repo) so callers can treat
284/// the absence-of-info case as "no changes".
285pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
286    let mut args: Vec<String> = vec![
287        "diff".to_string(),
288        "--name-only".to_string(),
289        format!("{tag}..HEAD"),
290        "--".to_string(),
291    ];
292    for p in paths {
293        args.push((*p).to_string());
294    }
295    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
296    let output = Command::new("git").args(&arg_refs).output()?;
297    if output.status.success() {
298        Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
299    } else {
300        Ok(false)
301    }
302}