Skip to main content

anodizer_core/git/
commits.rs

1use anyhow::{Context as _, Result, bail};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use super::git_output_in;
6
7#[derive(Debug, Clone)]
8pub struct Commit {
9    pub hash: String,
10    pub short_hash: String,
11    pub message: String,
12    pub author_name: String,
13    pub author_email: String,
14    /// Full commit message body (everything after the subject line).
15    /// Contains trailers like `Co-Authored-By:`.
16    pub body: String,
17}
18
19/// Parse git log output (formatted as `%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e`)
20/// into a vec of [`Commit`]s.
21///
22/// Uses ASCII record separator (0x1e) between commits and unit separator (0x1f)
23/// between fields, so multi-line body text doesn't break parsing.
24fn parse_commit_output(output: &str) -> Vec<Commit> {
25    if output.is_empty() {
26        return vec![];
27    }
28    output
29        .split('\x1e')
30        .filter(|record| !record.trim().is_empty())
31        .filter_map(|record| {
32            let fields: Vec<&str> = record.split('\x1f').collect();
33            if fields.len() >= 5 {
34                Some(Commit {
35                    hash: fields[0].trim().to_string(),
36                    short_hash: fields[1].to_string(),
37                    message: fields[2].to_string(),
38                    author_name: fields[3].to_string(),
39                    author_email: fields[4].to_string(),
40                    body: fields.get(5).unwrap_or(&"").trim().to_string(),
41                })
42            } else {
43                None
44            }
45        })
46        .collect()
47}
48
49fn cwd_or_dot() -> PathBuf {
50    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
51}
52
53/// Get commits between two refs, optionally filtered to a path.
54pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
55    get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
56}
57
58/// Path-taking sibling of [`get_commits_between`].
59pub fn get_commits_between_in(
60    cwd: &Path,
61    from: &str,
62    to: &str,
63    path_filter: Option<&str>,
64) -> Result<Vec<Commit>> {
65    get_commits_between_paths_in(
66        cwd,
67        from,
68        to,
69        &path_filter
70            .into_iter()
71            .map(String::from)
72            .collect::<Vec<_>>(),
73    )
74}
75
76/// Get commits between two refs, filtered to multiple paths (git log -- path1 path2 ...).
77pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
78    get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
79}
80
81/// Path-taking sibling of [`get_commits_between_paths`].
82pub fn get_commits_between_paths_in(
83    cwd: &Path,
84    from: &str,
85    to: &str,
86    paths: &[String],
87) -> Result<Vec<Commit>> {
88    let range = format!("{}..{}", from, to);
89    let mut args = vec![
90        "-c".to_string(),
91        "log.showSignature=false".to_string(),
92        "log".to_string(),
93        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
94        range,
95    ];
96    if !paths.is_empty() {
97        args.push("--".to_string());
98        for p in paths {
99            args.push(p.clone());
100        }
101    }
102    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
103    let output = git_output_in(cwd, &arg_refs)?;
104    Ok(parse_commit_output(&output))
105}
106
107/// Get all commits reachable from HEAD, optionally filtered to a path.
108/// Used for initial releases where there is no previous tag.
109pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
110    get_all_commits_in(&cwd_or_dot(), path_filter)
111}
112
113/// Path-taking sibling of [`get_all_commits`].
114pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
115    get_all_commits_paths_in(
116        cwd,
117        &path_filter
118            .into_iter()
119            .map(String::from)
120            .collect::<Vec<_>>(),
121    )
122}
123
124/// Get all commits reachable from HEAD, filtered to multiple paths.
125pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
126    get_all_commits_paths_in(&cwd_or_dot(), paths)
127}
128
129/// Path-taking sibling of [`get_all_commits_paths`].
130pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
131    let mut args = vec![
132        "-c".to_string(),
133        "log.showSignature=false".to_string(),
134        "log".to_string(),
135        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
136        "HEAD".to_string(),
137    ];
138    if !paths.is_empty() {
139        args.push("--".to_string());
140        for p in paths {
141            args.push(p.clone());
142        }
143    }
144    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
145    let output = git_output_in(cwd, &arg_refs)?;
146    Ok(parse_commit_output(&output))
147}
148
149/// Get last N commit subjects.
150pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
151    get_last_commit_messages_in(&cwd_or_dot(), count)
152}
153
154/// Path-taking sibling of [`get_last_commit_messages`].
155pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
156    let output = git_output_in(
157        cwd,
158        &[
159            "-c",
160            "log.showSignature=false",
161            "log",
162            &format!("-{count}"),
163            "--pretty=format:%s",
164        ],
165    )?;
166    Ok(output.lines().map(str::to_string).collect())
167}
168
169/// Get commit subjects between two refs.
170pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
171    get_commit_messages_between_in(&cwd_or_dot(), from, to)
172}
173
174/// Path-taking sibling of [`get_commit_messages_between`].
175pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
176    let output = git_output_in(
177        cwd,
178        &[
179            "-c",
180            "log.showSignature=false",
181            "log",
182            "--pretty=format:%s",
183            &format!("{from}..{to}"),
184        ],
185    )?;
186    Ok(output.lines().map(str::to_string).collect())
187}
188
189/// Get the current branch name.
190pub fn get_current_branch() -> Result<String> {
191    get_current_branch_in(&cwd_or_dot())
192}
193
194/// Path-taking sibling of [`get_current_branch`].
195pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
196    git_output_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])
197}
198
199/// Check if there are any commits since a given tag.
200pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
201    has_commits_since_tag_in(&cwd_or_dot(), tag)
202}
203
204/// Path-taking sibling of [`has_commits_since_tag`].
205pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
206    let range = format!("{}..HEAD", tag);
207    let output = git_output_in(
208        cwd,
209        &["-c", "log.showSignature=false", "log", "--oneline", &range],
210    )?;
211    Ok(!output.is_empty())
212}
213
214/// Get the short commit hash of HEAD.
215pub fn get_short_commit() -> Result<String> {
216    get_short_commit_in(&cwd_or_dot())
217}
218
219/// Path-taking sibling of [`get_short_commit`].
220pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
221    git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
222}
223
224/// Default short-commit length used across error messages, log
225/// output, and any place that needs to truncate a full SHA for
226/// human display. Matches git's `--short` default (7) — and the
227/// `ShortCommit` template var populated by [`super::detect_git_info`]
228/// (which delegates to `git rev-parse --short`).
229pub const SHORT_COMMIT_LEN: usize = 7;
230
231/// Truncate a full commit SHA string to [`SHORT_COMMIT_LEN`]
232/// characters. Returns the input unchanged when it's already shorter
233/// or equal in length. Use this any time the SHA arrives as a string
234/// (e.g. deserialized from a manifest or read from a template var)
235/// rather than running `git rev-parse --short` again — saves a
236/// subprocess and keeps the length convention in one place.
237///
238/// Empty input returns empty; callers needing fail-closed semantics
239/// (e.g. publish-only's commit cross-check) check `is_empty()`
240/// before calling.
241pub fn short_commit_str(commit: &str) -> String {
242    if commit.len() > SHORT_COMMIT_LEN {
243        commit[..SHORT_COMMIT_LEN].to_string()
244    } else {
245        commit.to_string()
246    }
247}
248
249/// Get the full commit hash of HEAD.
250///
251/// Mirrors `ctx.Git.FullCommit` in GoReleaser (resolved at git-pipe time and
252/// reused everywhere downstream). Used by the source-archive stage to
253/// produce deterministic archives across consecutive commits when
254/// `git_info` was not pre-populated by an earlier pipe.
255pub fn get_head_commit() -> Result<String> {
256    get_head_commit_in(&cwd_or_dot())
257}
258
259/// Path-taking sibling of [`get_head_commit`].
260pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
261    git_output_in(cwd, &["rev-parse", "HEAD"])
262}
263
264/// Check if there are changes in a path since a given tag.
265pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
266    has_changes_since_in(&cwd_or_dot(), tag, path)
267}
268
269/// Path-taking sibling of [`has_changes_since`].
270pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
271    let output = git_output_in(
272        cwd,
273        &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
274    )?;
275    Ok(!output.is_empty())
276}
277
278/// Get last N commit subjects that touched a specific path.
279pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
280    get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
281}
282
283/// Path-taking sibling of [`get_last_commit_messages_path`].
284pub fn get_last_commit_messages_path_in(
285    cwd: &Path,
286    count: usize,
287    path: &str,
288) -> Result<Vec<String>> {
289    let output = git_output_in(
290        cwd,
291        &[
292            "-c",
293            "log.showSignature=false",
294            "log",
295            &format!("-{count}"),
296            "--pretty=format:%s",
297            "--",
298            path,
299        ],
300    )?;
301    Ok(output.lines().map(str::to_string).collect())
302}
303
304/// Get commit subjects between two refs that touched a specific path.
305pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
306    get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
307}
308
309/// Path-taking sibling of [`get_commit_messages_between_path`].
310pub fn get_commit_messages_between_path_in(
311    cwd: &Path,
312    from: &str,
313    to: &str,
314    path: &str,
315) -> Result<Vec<String>> {
316    let output = git_output_in(
317        cwd,
318        &[
319            "-c",
320            "log.showSignature=false",
321            "log",
322            "--pretty=format:%s",
323            &format!("{from}..{to}"),
324            "--",
325            path,
326        ],
327    )?;
328    Ok(output.lines().map(str::to_string).collect())
329}
330
331/// Stage specific files and create a commit.
332pub fn stage_and_commit(files: &[&str], message: &str) -> Result<()> {
333    stage_and_commit_in(&cwd_or_dot(), files, message)
334}
335
336/// Path-taking sibling of [`stage_and_commit`].
337pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<()> {
338    let mut args = vec!["add", "--"];
339    args.extend(files.iter().copied());
340    git_output_in(cwd, &args)?;
341    git_output_in(cwd, &["commit", "-m", message])?;
342    Ok(())
343}
344
345/// `git -C <workspace_root> -c log.showSignature=false log
346/// --pretty=format:%B%x1e <range> -- <rel_path>` — list commit message
347/// bodies (subject+body) for commits in `range` touching `rel_path`,
348/// using the `\x1e` (RS) byte as a between-commits separator so multi-line
349/// bodies survive parsing.
350///
351/// `range` is the git revision range as a string (e.g. `"HEAD"`,
352/// `"v0.3.0..HEAD"`); the empty string is invalid (caller must pre-filter).
353/// Returns `Ok(Vec::new())` when git fails so callers treat
354/// "range doesn't exist yet" as a non-error.
355pub fn log_subjects_for_range(
356    workspace_root: &std::path::Path,
357    range: &str,
358    rel_path: &str,
359) -> Result<Vec<String>> {
360    let out = Command::new("git")
361        .arg("-C")
362        .arg(workspace_root)
363        .args([
364            "-c",
365            "log.showSignature=false",
366            "log",
367            "--pretty=format:%B%x1e",
368            range,
369            "--",
370            rel_path,
371        ])
372        .output()?;
373    if !out.status.success() {
374        // Range may not exist yet (no last_tag, path not in history).
375        return Ok(Vec::new());
376    }
377    let text = String::from_utf8_lossy(&out.stdout);
378    Ok(text
379        .split('\x1e')
380        .map(|s| s.trim().to_string())
381        .filter(|s| !s.is_empty())
382        .collect())
383}
384
385/// `git -C <workspace_root> add <rel>` — stage a single relative path.
386pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
387    let out = Command::new("git")
388        .arg("-C")
389        .arg(workspace_root)
390        .arg("add")
391        .arg(rel)
392        .output()
393        .context("failed to invoke git add")?;
394    if !out.status.success() {
395        let stderr_raw = String::from_utf8_lossy(&out.stderr);
396        let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
397        bail!("{}", crate::redact::redact_process_env(&raw));
398    }
399    Ok(())
400}
401
402/// `git -C <workspace_root> commit [-S] -m <message>` — create a commit
403/// with the given message, optionally GPG-signed.
404pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
405    let mut cmd = Command::new("git");
406    cmd.arg("-C").arg(workspace_root).arg("commit");
407    if sign {
408        cmd.arg("-S");
409    }
410    cmd.arg("-m").arg(message);
411    let out = cmd.output().context("failed to invoke git commit")?;
412    if !out.status.success() {
413        let stderr_raw = String::from_utf8_lossy(&out.stderr);
414        let raw = format!("git commit failed: {}", stderr_raw.trim());
415        bail!("{}", crate::redact::redact_process_env(&raw));
416    }
417    Ok(())
418}
419
420/// `git diff --name-only <tag>..HEAD -- <paths>...` — return `true` when
421/// any of the named paths changed between `tag` and `HEAD`. Returns
422/// `Ok(false)` when git fails (e.g. not a git repo) so callers can treat
423/// the absence-of-info case as "no changes".
424pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
425    paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
426}
427
428/// Path-taking sibling of [`paths_changed_since_tag`].
429pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
430    let mut args: Vec<String> = vec![
431        "diff".to_string(),
432        "--name-only".to_string(),
433        format!("{tag}..HEAD"),
434        "--".to_string(),
435    ];
436    for p in paths {
437        args.push((*p).to_string());
438    }
439    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
440    let output = Command::new("git")
441        .current_dir(cwd)
442        .args(&arg_refs)
443        .output()?;
444    if output.status.success() {
445        Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
446    } else {
447        Ok(false)
448    }
449}
450
451/// `git -C <repo> rev-parse HEAD` — return HEAD's full commit hash for the
452/// given repository (or worktree). Path-taking sibling of
453/// [`get_head_commit`] so callers (the determinism harness, future CI
454/// glue) can resolve HEAD without `cd`-ing into the repo first.
455pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
456    let out = Command::new("git")
457        .arg("-C")
458        .arg(repo)
459        .args(["rev-parse", "HEAD"])
460        .output()
461        .context("failed to invoke git rev-parse HEAD")?;
462    if !out.status.success() {
463        let stderr_raw = String::from_utf8_lossy(&out.stderr);
464        let raw = format!("git rev-parse HEAD failed: {}", stderr_raw.trim());
465        bail!("{}", crate::redact::redact_process_env(&raw));
466    }
467    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
468}
469
470/// `git -C <repo> log -1 --format=%ct HEAD` — return HEAD's committer
471/// timestamp (seconds since UNIX epoch) for the given repository. Used by
472/// the determinism harness as the non-snapshot SDE seed.
473pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
474    let out = Command::new("git")
475        .arg("-C")
476        .arg(repo)
477        .args(["log", "-1", "--format=%ct", "HEAD"])
478        .output()
479        .context("failed to invoke git log -1 --format=%ct HEAD")?;
480    if !out.status.success() {
481        let stderr_raw = String::from_utf8_lossy(&out.stderr);
482        let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
483        bail!("{}", crate::redact::redact_process_env(&raw));
484    }
485    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
486    text.parse::<i64>()
487        .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use std::process::Command;
494
495    fn init_repo_with_commits(dir: &Path, files: &[&str]) {
496        let run = |args: &[&str]| {
497            let out = Command::new("git")
498                .args(args)
499                .current_dir(dir)
500                .env("GIT_AUTHOR_NAME", "t")
501                .env("GIT_AUTHOR_EMAIL", "t@t.com")
502                .env("GIT_COMMITTER_NAME", "t")
503                .env("GIT_COMMITTER_EMAIL", "t@t.com")
504                .output()
505                .unwrap();
506            assert!(out.status.success(), "git {args:?} failed");
507        };
508        run(&["init"]);
509        run(&["config", "user.email", "t@t.com"]);
510        run(&["config", "user.name", "t"]);
511        for (i, f) in files.iter().enumerate() {
512            std::fs::write(dir.join(f), format!("c{i}")).unwrap();
513            run(&["add", "."]);
514            run(&["commit", "-m", &format!("commit-{i}: {f}")]);
515        }
516    }
517
518    #[test]
519    fn get_head_commit_in_returns_tempdirs_head_sha() {
520        let tmp = tempfile::tempdir().unwrap();
521        init_repo_with_commits(tmp.path(), &["a"]);
522        let expected = String::from_utf8(
523            Command::new("git")
524                .args(["rev-parse", "HEAD"])
525                .current_dir(tmp.path())
526                .output()
527                .unwrap()
528                .stdout,
529        )
530        .unwrap()
531        .trim()
532        .to_string();
533        let sha = get_head_commit_in(tmp.path()).unwrap();
534        assert_eq!(sha, expected);
535    }
536
537    #[test]
538    fn get_short_commit_in_returns_tempdirs_short_sha() {
539        let tmp = tempfile::tempdir().unwrap();
540        init_repo_with_commits(tmp.path(), &["a"]);
541        let expected = String::from_utf8(
542            Command::new("git")
543                .args(["rev-parse", "--short", "HEAD"])
544                .current_dir(tmp.path())
545                .output()
546                .unwrap()
547                .stdout,
548        )
549        .unwrap()
550        .trim()
551        .to_string();
552        let short = get_short_commit_in(tmp.path()).unwrap();
553        assert_eq!(short, expected);
554    }
555
556    #[test]
557    fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
558        let tmp = tempfile::tempdir().unwrap();
559        let dir = tmp.path();
560        init_repo_with_commits(dir, &["a"]);
561        let run = |args: &[&str]| {
562            Command::new("git")
563                .args(args)
564                .current_dir(dir)
565                .env("GIT_AUTHOR_NAME", "t")
566                .env("GIT_AUTHOR_EMAIL", "t@t.com")
567                .env("GIT_COMMITTER_NAME", "t")
568                .env("GIT_COMMITTER_EMAIL", "t@t.com")
569                .output()
570                .unwrap();
571        };
572        run(&["tag", "v1.0.0"]);
573        assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
574    }
575
576    #[test]
577    fn get_current_branch_in_returns_branch_name() {
578        let tmp = tempfile::tempdir().unwrap();
579        let dir = tmp.path();
580        let run = |args: &[&str]| {
581            let out = Command::new("git")
582                .args(args)
583                .current_dir(dir)
584                .env("GIT_AUTHOR_NAME", "t")
585                .env("GIT_AUTHOR_EMAIL", "t@t.com")
586                .env("GIT_COMMITTER_NAME", "t")
587                .env("GIT_COMMITTER_EMAIL", "t@t.com")
588                .output()
589                .unwrap();
590            assert!(out.status.success(), "git {args:?} failed");
591        };
592        run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
593        run(&["config", "user.email", "t@t.com"]);
594        run(&["config", "user.name", "t"]);
595        std::fs::write(dir.join("a"), "1").unwrap();
596        run(&["add", "."]);
597        run(&["commit", "-m", "c1"]);
598        let branch = get_current_branch_in(dir).unwrap();
599        assert_eq!(branch, "t1-test-branch");
600    }
601}