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/// Default short-commit length used across error messages, log
154/// output, and any place that needs to truncate a full SHA for
155/// human display. Matches git's `--short` default (7) — and the
156/// `ShortCommit` template var populated by [`git::detect_git_info`]
157/// (which delegates to `git rev-parse --short`).
158pub const SHORT_COMMIT_LEN: usize = 7;
159
160/// Truncate a full commit SHA string to [`SHORT_COMMIT_LEN`]
161/// characters. Returns the input unchanged when it's already shorter
162/// or equal in length. Use this any time the SHA arrives as a string
163/// (e.g. deserialized from a manifest or read from a template var)
164/// rather than running `git rev-parse --short` again — saves a
165/// subprocess and keeps the length convention in one place.
166///
167/// Empty input returns empty; callers needing fail-closed semantics
168/// (e.g. publish-only's commit cross-check) check `is_empty()`
169/// before calling.
170pub fn short_commit_str(commit: &str) -> String {
171    if commit.len() > SHORT_COMMIT_LEN {
172        commit[..SHORT_COMMIT_LEN].to_string()
173    } else {
174        commit.to_string()
175    }
176}
177
178/// Get the full commit hash of HEAD.
179///
180/// Mirrors `ctx.Git.FullCommit` in GoReleaser (resolved at git-pipe time and
181/// reused everywhere downstream). Used by the source-archive stage to
182/// produce deterministic archives across consecutive commits when
183/// `git_info` was not pre-populated by an earlier pipe.
184pub fn get_head_commit() -> Result<String> {
185    git_output(&["rev-parse", "HEAD"])
186}
187
188/// Check if there are changes in a path since a given tag.
189pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
190    let output = git_output(&["diff", "--name-only", &format!("{}..HEAD", tag), "--", path])?;
191    Ok(!output.is_empty())
192}
193
194/// Get last N commit subjects that touched a specific path.
195pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
196    let output = git_output(&[
197        "-c",
198        "log.showSignature=false",
199        "log",
200        &format!("-{count}"),
201        "--pretty=format:%s",
202        "--",
203        path,
204    ])?;
205    Ok(output.lines().map(str::to_string).collect())
206}
207
208/// Get commit subjects between two refs that touched a specific path.
209pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
210    let output = git_output(&[
211        "-c",
212        "log.showSignature=false",
213        "log",
214        "--pretty=format:%s",
215        &format!("{from}..{to}"),
216        "--",
217        path,
218    ])?;
219    Ok(output.lines().map(str::to_string).collect())
220}
221
222/// Stage specific files and create a commit.
223pub fn stage_and_commit(files: &[&str], message: &str) -> Result<()> {
224    let mut args = vec!["add", "--"];
225    args.extend(files.iter().copied());
226    git_output(&args)?;
227    git_output(&["commit", "-m", message])?;
228    Ok(())
229}
230
231/// `git -C <workspace_root> -c log.showSignature=false log
232/// --pretty=format:%B%x1e <range> -- <rel_path>` — list commit message
233/// bodies (subject+body) for commits in `range` touching `rel_path`,
234/// using the `\x1e` (RS) byte as a between-commits separator so multi-line
235/// bodies survive parsing.
236///
237/// `range` is the git revision range as a string (e.g. `"HEAD"`,
238/// `"v0.3.0..HEAD"`); the empty string is invalid (caller must pre-filter).
239/// Returns `Ok(Vec::new())` when git fails so callers treat
240/// "range doesn't exist yet" as a non-error.
241pub fn log_subjects_for_range(
242    workspace_root: &std::path::Path,
243    range: &str,
244    rel_path: &str,
245) -> Result<Vec<String>> {
246    let out = Command::new("git")
247        .arg("-C")
248        .arg(workspace_root)
249        .args([
250            "-c",
251            "log.showSignature=false",
252            "log",
253            "--pretty=format:%B%x1e",
254            range,
255            "--",
256            rel_path,
257        ])
258        .output()?;
259    if !out.status.success() {
260        // Range may not exist yet (no last_tag, path not in history).
261        return Ok(Vec::new());
262    }
263    let text = String::from_utf8_lossy(&out.stdout);
264    Ok(text
265        .split('\x1e')
266        .map(|s| s.trim().to_string())
267        .filter(|s| !s.is_empty())
268        .collect())
269}
270
271/// `git -C <workspace_root> add <rel>` — stage a single relative path.
272pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
273    let out = Command::new("git")
274        .arg("-C")
275        .arg(workspace_root)
276        .arg("add")
277        .arg(rel)
278        .output()
279        .context("failed to invoke git add")?;
280    if !out.status.success() {
281        let stderr_raw = String::from_utf8_lossy(&out.stderr);
282        let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
283        bail!("{}", crate::redact::redact_process_env(&raw));
284    }
285    Ok(())
286}
287
288/// `git -C <workspace_root> commit [-S] -m <message>` — create a commit
289/// with the given message, optionally GPG-signed.
290pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
291    let mut cmd = Command::new("git");
292    cmd.arg("-C").arg(workspace_root).arg("commit");
293    if sign {
294        cmd.arg("-S");
295    }
296    cmd.arg("-m").arg(message);
297    let out = cmd.output().context("failed to invoke git commit")?;
298    if !out.status.success() {
299        let stderr_raw = String::from_utf8_lossy(&out.stderr);
300        let raw = format!("git commit failed: {}", stderr_raw.trim());
301        bail!("{}", crate::redact::redact_process_env(&raw));
302    }
303    Ok(())
304}
305
306/// `git diff --name-only <tag>..HEAD -- <paths>...` — return `true` when
307/// any of the named paths changed between `tag` and `HEAD`. Returns
308/// `Ok(false)` when git fails (e.g. not a git repo) so callers can treat
309/// the absence-of-info case as "no changes".
310pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
311    let mut args: Vec<String> = vec![
312        "diff".to_string(),
313        "--name-only".to_string(),
314        format!("{tag}..HEAD"),
315        "--".to_string(),
316    ];
317    for p in paths {
318        args.push((*p).to_string());
319    }
320    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
321    let output = Command::new("git").args(&arg_refs).output()?;
322    if output.status.success() {
323        Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
324    } else {
325        Ok(false)
326    }
327}
328
329/// `git -C <repo> rev-parse HEAD` — return HEAD's full commit hash for the
330/// given repository (or worktree). Path-taking sibling of
331/// [`get_head_commit`] so callers (the determinism harness, future CI
332/// glue) can resolve HEAD without `cd`-ing into the repo first.
333pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
334    let out = Command::new("git")
335        .arg("-C")
336        .arg(repo)
337        .args(["rev-parse", "HEAD"])
338        .output()
339        .context("failed to invoke git rev-parse HEAD")?;
340    if !out.status.success() {
341        let stderr_raw = String::from_utf8_lossy(&out.stderr);
342        let raw = format!("git rev-parse HEAD failed: {}", stderr_raw.trim());
343        bail!("{}", crate::redact::redact_process_env(&raw));
344    }
345    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
346}
347
348/// `git -C <repo> log -1 --format=%ct HEAD` — return HEAD's committer
349/// timestamp (seconds since UNIX epoch) for the given repository. Used by
350/// the determinism harness as the non-snapshot SDE seed.
351pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
352    let out = Command::new("git")
353        .arg("-C")
354        .arg(repo)
355        .args(["log", "-1", "--format=%ct", "HEAD"])
356        .output()
357        .context("failed to invoke git log -1 --format=%ct HEAD")?;
358    if !out.status.success() {
359        let stderr_raw = String::from_utf8_lossy(&out.stderr);
360        let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
361        bail!("{}", crate::redact::redact_process_env(&raw));
362    }
363    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
364    text.parse::<i64>()
365        .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
366}