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/// Subject prefix anodizer stamps on its own release-machinery commits
8/// (version-sync bumps, rollback reverts). The matchers that must recognise
9/// those commits — rollback's idempotency check, the changelog stage's
10/// version-sync exclusion — compose their patterns from this same constant
11/// so a reworded writer can never silently break a matcher.
12pub const RELEASE_COMMIT_PREFIX: &str = "chore(release): ";
13
14/// `chore(release): bump ` — the subject prefix shared by every version-sync
15/// bump commit (see [`release_bump_subject`]).
16pub fn release_bump_subject_prefix() -> String {
17    format!("{RELEASE_COMMIT_PREFIX}bump ")
18}
19
20/// Build a version-sync bump commit subject:
21/// `chore(release): bump <summary><suffix>`. `suffix` carries the optional
22/// ` [skip ci]` marker (empty when none applies).
23pub fn release_bump_subject(summary: &str, suffix: &str) -> String {
24    format!("{}{summary}{suffix}", release_bump_subject_prefix())
25}
26
27#[derive(Debug, Clone)]
28pub struct Commit {
29    pub hash: String,
30    pub short_hash: String,
31    pub message: String,
32    pub author_name: String,
33    pub author_email: String,
34    /// Full commit message body (everything after the subject line).
35    /// Contains trailers like `Co-Authored-By:`.
36    pub body: String,
37}
38
39/// Parse git log output (formatted as `%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e`)
40/// into a vec of [`Commit`]s.
41///
42/// Uses ASCII record separator (0x1e) between commits and unit separator (0x1f)
43/// between fields, so multi-line body text doesn't break parsing.
44///
45/// The single record decoder for this wire format: every changelog path
46/// (`parse_commit_output_with_files` here, and the changelog stage's git
47/// fetch) decodes through this function so the body / author fields can never
48/// drift between call sites.
49pub fn parse_commit_output(output: &str) -> Vec<Commit> {
50    if output.is_empty() {
51        return vec![];
52    }
53    output
54        .split('\x1e')
55        .filter(|record| !record.trim().is_empty())
56        .filter_map(|record| {
57            let fields: Vec<&str> = record.split('\x1f').collect();
58            if fields.len() >= 5 {
59                Some(Commit {
60                    hash: fields[0].trim().to_string(),
61                    short_hash: fields[1].to_string(),
62                    message: fields[2].to_string(),
63                    author_name: fields[3].to_string(),
64                    author_email: fields[4].to_string(),
65                    body: fields.get(5).unwrap_or(&"").trim().to_string(),
66                })
67            } else {
68                None
69            }
70        })
71        .collect()
72}
73
74fn cwd_or_dot() -> PathBuf {
75    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
76}
77
78/// Get commits between two refs, optionally filtered to a path.
79pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
80    get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
81}
82
83/// Path-taking sibling of [`get_commits_between`].
84pub fn get_commits_between_in(
85    cwd: &Path,
86    from: &str,
87    to: &str,
88    path_filter: Option<&str>,
89) -> Result<Vec<Commit>> {
90    get_commits_between_paths_in(
91        cwd,
92        from,
93        to,
94        &path_filter
95            .into_iter()
96            .map(String::from)
97            .collect::<Vec<_>>(),
98    )
99}
100
101/// Get commits between two refs, filtered to multiple paths (git log -- path1 path2 ...).
102pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
103    get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
104}
105
106/// Path-taking sibling of [`get_commits_between_paths`].
107pub fn get_commits_between_paths_in(
108    cwd: &Path,
109    from: &str,
110    to: &str,
111    paths: &[String],
112) -> Result<Vec<Commit>> {
113    let range = format!("{}..{}", from, to);
114    let mut args = vec![
115        "-c".to_string(),
116        "log.showSignature=false".to_string(),
117        "log".to_string(),
118        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
119        range,
120    ];
121    if !paths.is_empty() {
122        args.push("--".to_string());
123        for p in paths {
124            args.push(p.clone());
125        }
126    }
127    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
128    let output = git_output_in(cwd, &arg_refs)?;
129    Ok(parse_commit_output(&output))
130}
131
132/// Get all commits reachable from HEAD, optionally filtered to a path.
133/// Used for initial releases where there is no previous tag.
134pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
135    get_all_commits_in(&cwd_or_dot(), path_filter)
136}
137
138/// Path-taking sibling of [`get_all_commits`].
139pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
140    get_all_commits_paths_in(
141        cwd,
142        &path_filter
143            .into_iter()
144            .map(String::from)
145            .collect::<Vec<_>>(),
146    )
147}
148
149/// Get all commits reachable from HEAD, filtered to multiple paths.
150pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
151    get_all_commits_paths_in(&cwd_or_dot(), paths)
152}
153
154/// Path-taking sibling of [`get_all_commits_paths`].
155pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
156    let mut args = vec![
157        "-c".to_string(),
158        "log.showSignature=false".to_string(),
159        "log".to_string(),
160        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
161        "HEAD".to_string(),
162    ];
163    if !paths.is_empty() {
164        args.push("--".to_string());
165        for p in paths {
166            args.push(p.clone());
167        }
168    }
169    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
170    let output = git_output_in(cwd, &arg_refs)?;
171    Ok(parse_commit_output(&output))
172}
173
174/// A commit paired with the workspace-relative paths it touched.
175///
176/// Produced by the `--name-only` fetch variants so the changelog renderers can
177/// apply a precise `changelog.paths` glob intersect over the git-pathspec
178/// scope (see [`crate::changelog_scope`]).
179#[derive(Debug, Clone)]
180pub struct CommitWithFiles {
181    /// The commit metadata.
182    pub commit: Commit,
183    /// Paths this commit touched, relative to the repo root.
184    pub files: Vec<String>,
185}
186
187/// Parse `git log --name-only` output (metadata formatted as
188/// `%H%x1f...%b%x1e`, followed by one touched-file path per line) into
189/// [`CommitWithFiles`].
190///
191/// git emits each commit as `<metadata>\x1e\n<file>\n<file>\n\n` (the touched
192/// files follow the `%x1e`-terminated metadata, then a blank line). Splitting
193/// on `\x1e` yields `[metadata_0, "\n<files_0>\n\n<metadata_1>", ...]`: the
194/// file block trailing each record up to the next metadata belongs to THAT
195/// record's commit.
196///
197/// The metadata record is multi-line because `%b` (the commit body) carries
198/// newlines, so the record runs from the first `\x1f`-bearing line through the
199/// end of the segment — NOT just the first matching line. Truncating to one
200/// line would drop body trailers (e.g. `Co-Authored-By:`) for every commit
201/// after the first, diverging from the full-body parse the changelog stage's
202/// `parse_git_log_records` performs.
203pub fn parse_commit_output_with_files(output: &str) -> Vec<CommitWithFiles> {
204    if output.is_empty() {
205        return vec![];
206    }
207    let segments: Vec<&str> = output.split('\x1e').collect();
208    let mut out: Vec<CommitWithFiles> = Vec::new();
209    // segments[i] for i>0 begins with the file block of commit i-1 followed by
210    // the metadata of commit i. The first segment is pure metadata (commit 0);
211    // the last segment is the file block of the final commit (no trailing
212    // metadata). Walk pairwise: metadata from this segment, files from the
213    // NEXT segment's leading lines (before its own metadata's first field).
214    for (idx, seg) in segments.iter().enumerate() {
215        // The metadata of commit `idx` is the part of `seg` AFTER the leading
216        // file block (file block present only for idx>0). For idx==0 the whole
217        // segment is metadata. For idx>0 the metadata record begins at the
218        // first `\x1f`-bearing line and continues to the segment end (a
219        // multi-line `%b` body keeps emitting newline-separated lines after the
220        // unit-separator fields), so the remainder is kept verbatim — joined
221        // from that line onward — rather than just the first matching line.
222        let metadata = if idx == 0 {
223            seg.trim_start_matches(['\n', '\r']).to_string()
224        } else {
225            let lines: Vec<&str> = seg.split('\n').collect();
226            match lines.iter().position(|line| line.contains('\x1f')) {
227                Some(start) => lines[start..].join("\n"),
228                None => String::new(),
229            }
230        };
231        if metadata.trim().is_empty() {
232            continue;
233        }
234        let commits = parse_commit_output(&metadata);
235        let Some(commit) = commits.into_iter().next() else {
236            continue;
237        };
238        // Files for THIS commit are the leading lines of the NEXT segment,
239        // before that segment's own metadata line.
240        let files = match segments.get(idx + 1) {
241            Some(next) => next
242                .split('\n')
243                .map(str::trim)
244                .take_while(|line| !line.contains('\x1f'))
245                .filter(|line| !line.is_empty())
246                .map(str::to_string)
247                .collect(),
248            None => Vec::new(),
249        };
250        out.push(CommitWithFiles { commit, files });
251    }
252    out
253}
254
255/// `--name-only` sibling of [`get_commits_between_paths_in`]: each commit is
256/// paired with the repo-relative paths it touched, for a precise
257/// `changelog.paths` glob intersect over the git-pathspec scope.
258pub fn get_commits_between_paths_with_files_in(
259    cwd: &Path,
260    from: &str,
261    to: &str,
262    paths: &[String],
263) -> Result<Vec<CommitWithFiles>> {
264    let range = format!("{}..{}", from, to);
265    let mut args = vec![
266        "-c".to_string(),
267        "log.showSignature=false".to_string(),
268        "log".to_string(),
269        "--name-only".to_string(),
270        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
271        range,
272    ];
273    if !paths.is_empty() {
274        args.push("--".to_string());
275        for p in paths {
276            args.push(p.clone());
277        }
278    }
279    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
280    let output = git_output_in(cwd, &arg_refs)?;
281    Ok(parse_commit_output_with_files(&output))
282}
283
284/// `--name-only` sibling of [`get_all_commits_paths_in`].
285pub fn get_all_commits_paths_with_files_in(
286    cwd: &Path,
287    paths: &[String],
288) -> Result<Vec<CommitWithFiles>> {
289    let mut args = vec![
290        "-c".to_string(),
291        "log.showSignature=false".to_string(),
292        "log".to_string(),
293        "--name-only".to_string(),
294        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
295        "HEAD".to_string(),
296    ];
297    if !paths.is_empty() {
298        args.push("--".to_string());
299        for p in paths {
300            args.push(p.clone());
301        }
302    }
303    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
304    let output = git_output_in(cwd, &arg_refs)?;
305    Ok(parse_commit_output_with_files(&output))
306}
307
308/// All commits reachable from an arbitrary `rev` (not just `HEAD`), filtered to
309/// `paths`. Used by the changelog stage to bound a no-lower-bound range at an
310/// explicit upper ref (`changelog ..<tag>`): the range is then every ancestor
311/// of `<tag>`, excluding commits made after it.
312pub fn get_commits_reachable_paths_in(
313    cwd: &Path,
314    rev: &str,
315    paths: &[String],
316) -> Result<Vec<Commit>> {
317    let mut args = vec![
318        "-c".to_string(),
319        "log.showSignature=false".to_string(),
320        "log".to_string(),
321        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
322        rev.to_string(),
323    ];
324    if !paths.is_empty() {
325        args.push("--".to_string());
326        for p in paths {
327            args.push(p.clone());
328        }
329    }
330    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
331    let output = git_output_in(cwd, &arg_refs)?;
332    Ok(parse_commit_output(&output))
333}
334
335/// `--name-only` sibling of [`get_commits_reachable_paths_in`].
336pub fn get_commits_reachable_paths_with_files_in(
337    cwd: &Path,
338    rev: &str,
339    paths: &[String],
340) -> Result<Vec<CommitWithFiles>> {
341    let mut args = vec![
342        "-c".to_string(),
343        "log.showSignature=false".to_string(),
344        "log".to_string(),
345        "--name-only".to_string(),
346        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
347        rev.to_string(),
348    ];
349    if !paths.is_empty() {
350        args.push("--".to_string());
351        for p in paths {
352            args.push(p.clone());
353        }
354    }
355    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
356    let output = git_output_in(cwd, &arg_refs)?;
357    Ok(parse_commit_output_with_files(&output))
358}
359
360/// Get last N commit subjects.
361pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
362    get_last_commit_messages_in(&cwd_or_dot(), count)
363}
364
365/// Path-taking sibling of [`get_last_commit_messages`].
366pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
367    let output = git_output_in(
368        cwd,
369        &[
370            "-c",
371            "log.showSignature=false",
372            "log",
373            &format!("-{count}"),
374            "--pretty=format:%s",
375        ],
376    )?;
377    Ok(output.lines().map(str::to_string).collect())
378}
379
380/// Get commit subjects between two refs.
381pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
382    get_commit_messages_between_in(&cwd_or_dot(), from, to)
383}
384
385/// Path-taking sibling of [`get_commit_messages_between`].
386pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
387    let output = git_output_in(
388        cwd,
389        &[
390            "-c",
391            "log.showSignature=false",
392            "log",
393            "--pretty=format:%s",
394            &format!("{from}..{to}"),
395        ],
396    )?;
397    Ok(output.lines().map(str::to_string).collect())
398}
399
400/// Get the current branch name.
401pub fn get_current_branch() -> Result<String> {
402    get_current_branch_in(&cwd_or_dot())
403}
404
405/// Return `true` when `name` looks like a branch (NOT an anodize-shaped
406/// release tag). Tag shapes: `^v\d+\.\d+\.\d+` (lockstep
407/// `v1.2.3[-pre][+build]`) or `^<crate>-v\d+\.\d+\.\d+`
408/// (per-crate `mycrate-v1.2.3[...]`).
409///
410/// Both regexes are start-anchored, and the per-crate `<crate>` segment is
411/// constrained to non-`/` characters. Without that, a branch like
412/// `feature/fix-v2.0.0` contains `-v2.0.0` and would be misclassified as a
413/// tag — leaving its `GITHUB_REF_NAME` fallback rejected. A real per-crate
414/// tag's name prefix is a crate name (no path separators), so anchoring on
415/// `^[^/]+-v` keeps that branch shape branch-like while still matching
416/// `mycrate-v1.2.3`.
417///
418/// Guards the `GITHUB_REF_NAME` fallback in [`get_current_branch_in`]: on
419/// a `push: tags:` workflow trigger, `GITHUB_REF_NAME` is the TAG name
420/// (e.g. `v0.4.5`), and accepting it would make `git push origin v0.4.5`
421/// from detached HEAD silently create a branch named after the tag.
422///
423/// Drift-risk pair with `cli::commands::tag::rollback`'s `LOCKSTEP_TAG_RE` /
424/// `PER_CRATE_TAG_RE`: those classify the same two anodize tag shapes but
425/// are fully anchored and strict (a rollback must touch only real tags).
426/// The patterns here are deliberately looser and prefix-only (branch-vs-tag
427/// disambiguation, not classification). Keep both in sync when the tag
428/// grammar changes — they are intentionally separate, not duplicated.
429pub fn is_branchlike(name: &str) -> bool {
430    use regex::Regex;
431    use std::sync::OnceLock;
432    static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
433    static PER_CRATE: OnceLock<Regex> = OnceLock::new();
434    let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
435    let per_crate =
436        PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
437    !(lockstep.is_match(name) || per_crate.is_match(name))
438}
439
440/// Path-taking sibling of [`get_current_branch`].
441///
442/// Handles detached-HEAD checkouts (e.g. `actions/checkout@v4` with `ref:`)
443/// by resolving the branch HEAD points at via `for-each-ref`, falling back
444/// to the remote's default branch and finally `GITHUB_REF_NAME` when set —
445/// so downstream `git push origin <branch>` produces a valid refspec
446/// instead of a literal `HEAD` that git can't auto-qualify.
447///
448/// The `GITHUB_REF_NAME` fallback is guarded by [`is_branchlike`]: on a
449/// `push: tags:` trigger, `GITHUB_REF_NAME` is the TAG name, and accepting
450/// it would push to a branch named after the tag. Tag-shaped values fall
451/// through to the bail at the end so callers hard-fail and prompt the
452/// operator for `--branch <name>` explicitly.
453pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
454    get_current_branch_in_with_env(cwd, &crate::ProcessEnvSource)
455}
456
457/// [`EnvSource`](crate::EnvSource)-injecting form of [`get_current_branch_in`].
458///
459/// Reads the `GITHUB_REF_NAME` fallback from `env` rather than the process
460/// environment, so tests can drive the tag-shaped / branch-shaped fallback
461/// branches deterministically without mutating global env state.
462pub fn get_current_branch_in_with_env<E: crate::EnvSource + ?Sized>(
463    cwd: &Path,
464    env: &E,
465) -> Result<String> {
466    if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
467        return Ok(name);
468    }
469    if let Ok(out) = git_output_in(
470        cwd,
471        &[
472            "for-each-ref",
473            "--points-at",
474            "HEAD",
475            "--format=%(refname:short)",
476            "refs/heads/",
477        ],
478    ) && !out.is_empty()
479    {
480        let branches: Vec<&str> = out.lines().collect();
481        for preferred in ["master", "main"] {
482            if branches.contains(&preferred) {
483                return Ok(preferred.to_string());
484            }
485        }
486        if let Some(first) = branches.first() {
487            return Ok((*first).to_string());
488        }
489    }
490    if let Ok(out) = git_output_in(
491        cwd,
492        &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
493    ) && let Some(name) = out.strip_prefix("origin/")
494    {
495        return Ok(name.to_string());
496    }
497    if let Some(name) = env.var("GITHUB_REF_NAME")
498        && !name.is_empty()
499        && is_branchlike(&name)
500    {
501        return Ok(name);
502    }
503    anyhow::bail!(
504        "could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
505    )
506}
507
508/// Return remote branch short names that contain `sha` (e.g. `master`,
509/// `release/v1`). The bump commit's SHA is the deterministic anchor of
510/// the tag, so deriving the push branch from it is race-immune to the
511/// default branch moving between bump and rollback. Empty `Vec` when
512/// the SHA is not on any remote branch (orphan / not-yet-pushed).
513pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
514    let out = git_output_in(
515        cwd,
516        &[
517            "branch",
518            "-r",
519            "--contains",
520            sha,
521            "--format=%(refname:short)",
522        ],
523    )?;
524    Ok(out
525        .lines()
526        .filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
527        .filter(|name| !name.is_empty() && name != "HEAD")
528        .collect())
529}
530
531/// Check if there are any commits since a given tag.
532pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
533    has_commits_since_tag_in(&cwd_or_dot(), tag)
534}
535
536/// Path-taking sibling of [`has_commits_since_tag`].
537pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
538    let range = format!("{}..HEAD", tag);
539    let output = git_output_in(
540        cwd,
541        &["-c", "log.showSignature=false", "log", "--oneline", &range],
542    )?;
543    Ok(!output.is_empty())
544}
545
546/// Count the commits on HEAD since the most recent reachable tag.
547///
548/// Resolves the last tag with `git describe --tags --abbrev=0 HEAD`, then
549/// returns `git rev-list --count <tag>..HEAD`. When HEAD has no reachable
550/// tag (a repo whose first version tag has not landed yet), the total
551/// commit count on HEAD is returned instead (`git rev-list --count HEAD`).
552///
553/// `monorepo_prefix` constrains the `describe` to tags matching
554/// `<prefix>*` (via `--match`), so in a per-crate workspace the count is
555/// since the matching crate's tag rather than the nearest tag from ANY
556/// subproject. `None` considers all tags.
557///
558/// This is the stateless basis for the `{{ .NightlyBuild }}` template var:
559/// the count resets to a small number the moment a new version tag lands,
560/// so a nightly build counter increments per base version with no state
561/// anodizer must persist.
562///
563/// Returns `Ok(0)` for an empty repository (no commits) so callers never
564/// have to special-case the unborn-HEAD state.
565pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
566    // `--abbrev=0` yields the bare tag name (no `-<n>-g<sha>` suffix).
567    // A repo with no reachable tag exits non-zero here; treat that as
568    // "count every commit on HEAD" rather than an error.
569    //
570    // `--match=<prefix>*` (when a monorepo prefix is set) restricts the
571    // describe to the matching crate's tags — without it, describe returns
572    // the nearest reachable tag from ANY subproject and the count would be
573    // since the wrong crate's tag. Mirrors `find_previous_tag_with_prefix_in`.
574    let match_arg;
575    let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
576    if let Some(prefix) = monorepo_prefix {
577        match_arg = format!("--match={}*", prefix);
578        describe_args.push(&match_arg);
579    }
580    describe_args.push("HEAD");
581    let range = match git_output_in(cwd, &describe_args) {
582        Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
583        _ => "HEAD".to_string(),
584    };
585    // An empty repo (unborn HEAD) makes `rev-list` fail; map that to 0.
586    let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
587        Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
588        Err(_) => 0,
589    };
590    Ok(count)
591}
592
593/// Get the short commit hash of HEAD.
594pub fn get_short_commit() -> Result<String> {
595    get_short_commit_in(&cwd_or_dot())
596}
597
598/// Path-taking sibling of [`get_short_commit`].
599pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
600    git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
601}
602
603/// Default short-commit length used across error messages, log
604/// output, and any place that needs to truncate a full SHA for
605/// human display. Matches git's `--short` default (7) — and the
606/// `ShortCommit` template var populated by [`super::detect_git_info`]
607/// (which delegates to `git rev-parse --short`).
608pub const SHORT_COMMIT_LEN: usize = 7;
609
610/// Truncate a full commit SHA string to [`SHORT_COMMIT_LEN`]
611/// characters. Returns the input unchanged when it's already shorter
612/// or equal in length. Use this any time the SHA arrives as a string
613/// (e.g. deserialized from a manifest or read from a template var)
614/// rather than running `git rev-parse --short` again — saves a
615/// subprocess and keeps the length convention in one place.
616///
617/// Empty input returns empty; callers needing fail-closed semantics
618/// (e.g. publish-only's commit cross-check) check `is_empty()`
619/// before calling.
620pub fn short_commit_str(commit: &str) -> String {
621    if commit.len() > SHORT_COMMIT_LEN {
622        commit[..SHORT_COMMIT_LEN].to_string()
623    } else {
624        commit.to_string()
625    }
626}
627
628/// Get the full commit hash of HEAD.
629///
630/// The full commit SHA (resolved at git-pipe time and
631/// reused everywhere downstream). Used by the source-archive stage to
632/// produce deterministic archives across consecutive commits when
633/// `git_info` was not pre-populated by an earlier pipe.
634pub fn get_head_commit() -> Result<String> {
635    get_head_commit_in(&cwd_or_dot())
636}
637
638/// Path-taking sibling of [`get_head_commit`].
639pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
640    git_output_in(cwd, &["rev-parse", "HEAD"])
641}
642
643/// Check if there are changes in a path since a given tag.
644pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
645    has_changes_since_in(&cwd_or_dot(), tag, path)
646}
647
648/// Path-taking sibling of [`has_changes_since`].
649pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
650    let output = git_output_in(
651        cwd,
652        &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
653    )?;
654    Ok(!output.is_empty())
655}
656
657/// Get last N commit subjects that touched a specific path.
658pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
659    get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
660}
661
662/// Path-taking sibling of [`get_last_commit_messages_path`].
663pub fn get_last_commit_messages_path_in(
664    cwd: &Path,
665    count: usize,
666    path: &str,
667) -> Result<Vec<String>> {
668    let output = git_output_in(
669        cwd,
670        &[
671            "-c",
672            "log.showSignature=false",
673            "log",
674            &format!("-{count}"),
675            "--pretty=format:%s",
676            "--",
677            path,
678        ],
679    )?;
680    Ok(output.lines().map(str::to_string).collect())
681}
682
683/// Get commit subjects between two refs that touched a specific path.
684pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
685    get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
686}
687
688/// Path-taking sibling of [`get_commit_messages_between_path`].
689pub fn get_commit_messages_between_path_in(
690    cwd: &Path,
691    from: &str,
692    to: &str,
693    path: &str,
694) -> Result<Vec<String>> {
695    let output = git_output_in(
696        cwd,
697        &[
698            "-c",
699            "log.showSignature=false",
700            "log",
701            "--pretty=format:%s",
702            &format!("{from}..{to}"),
703            "--",
704            path,
705        ],
706    )?;
707    Ok(output.lines().map(str::to_string).collect())
708}
709
710/// Stage specific files and create a commit.
711///
712/// Returns `Ok(true)` when a commit was created, `Ok(false)` when staging
713/// produced no diff (e.g. files are already at the target state) — callers
714/// that need idempotent bump-then-tag flows can use the boolean to decide
715/// whether to skip downstream commit-dependent work without inspecting git
716/// state separately.
717pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
718    stage_and_commit_in(&cwd_or_dot(), files, message)
719}
720
721/// Path-taking sibling of [`stage_and_commit`].
722pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
723    let mut args = vec!["add", "--"];
724    args.extend(files.iter().copied());
725    git_output_in(cwd, &args)?;
726    // Idempotency guard: `git add` happily stages nothing when the working
727    // tree already matches HEAD for the given paths. Running `git commit`
728    // after would fail with "nothing to commit" (printed to stdout, not
729    // stderr) and surface a confusing empty-stderr error. Detect the
730    // no-diff case here so callers can re-run safely.
731    let diff = Command::new("git")
732        .current_dir(cwd)
733        .args(["diff", "--cached", "--quiet", "--"])
734        .args(files)
735        .env("GIT_TERMINAL_PROMPT", "0")
736        .env("LC_ALL", "C")
737        .status()?;
738    if diff.success() {
739        return Ok(false);
740    }
741    git_output_in(cwd, &["commit", "-m", message])?;
742    Ok(true)
743}
744
745/// `git -C <workspace_root> -c log.showSignature=false log
746/// --pretty=format:%B%x1e <range> -- <rel_path>` — list commit message
747/// bodies (subject+body) for commits in `range` touching `rel_path`,
748/// using the `\x1e` (RS) byte as a between-commits separator so multi-line
749/// bodies survive parsing.
750///
751/// `range` is the git revision range as a string (e.g. `"HEAD"`,
752/// `"v0.3.0..HEAD"`); the empty string is invalid (caller must pre-filter).
753/// Returns `Ok(Vec::new())` when the range's base does not exist yet
754/// (unknown/bad revision, empty repo) — a legitimate "no commits" outcome.
755/// Any other git failure (e.g. an invalid pathspec) is an `Err`, never an
756/// empty success.
757pub fn log_subjects_for_range(
758    workspace_root: &std::path::Path,
759    range: &str,
760    rel_path: &str,
761) -> Result<Vec<String>> {
762    let out = Command::new("git")
763        .arg("-C")
764        .arg(workspace_root)
765        .args([
766            "-c",
767            "log.showSignature=false",
768            "log",
769            "--pretty=format:%B%x1e",
770            range,
771            "--",
772            rel_path,
773        ])
774        .env("GIT_TERMINAL_PROMPT", "0")
775        .env("LC_ALL", "C")
776        .output()?;
777    if !out.status.success() {
778        // A range whose base doesn't exist yet (no prior tag, empty repo)
779        // is a legitimate "no commits" outcome. Any other fatal (e.g. an
780        // empty pathspec) must propagate — swallowing it made `bump`
781        // preview Skip on repos whose crate lives at the workspace root.
782        let stderr = String::from_utf8_lossy(&out.stderr);
783        let missing_range = stderr.contains("unknown revision")
784            || stderr.contains("bad revision")
785            || stderr.contains("does not have any commits yet");
786        if missing_range {
787            return Ok(Vec::new());
788        }
789        let raw = format!("git log {} failed: {}", range, stderr.trim());
790        bail!("{}", crate::redact::redact_process_env(&raw));
791    }
792    let text = String::from_utf8_lossy(&out.stdout);
793    Ok(text
794        .split('\x1e')
795        .map(|s| s.trim().to_string())
796        .filter(|s| !s.is_empty())
797        .collect())
798}
799
800/// `git -C <workspace_root> add <rel>` — stage a single relative path.
801pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
802    let out = Command::new("git")
803        .arg("-C")
804        .arg(workspace_root)
805        .arg("add")
806        .arg(rel)
807        .env("GIT_TERMINAL_PROMPT", "0")
808        .env("LC_ALL", "C")
809        .output()
810        .context("failed to invoke git add")?;
811    if !out.status.success() {
812        let stderr_raw = String::from_utf8_lossy(&out.stderr);
813        let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
814        bail!("{}", crate::redact::redact_process_env(&raw));
815    }
816    Ok(())
817}
818
819/// `git -C <workspace_root> commit [-S] -m <message>` — create a commit
820/// with the given message, optionally GPG-signed.
821pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
822    let mut cmd = Command::new("git");
823    cmd.arg("-C").arg(workspace_root).arg("commit");
824    if sign {
825        cmd.arg("-S");
826    }
827    cmd.arg("-m")
828        .arg(message)
829        .env("GIT_TERMINAL_PROMPT", "0")
830        .env("LC_ALL", "C");
831    let out = cmd.output().context("failed to invoke git commit")?;
832    if !out.status.success() {
833        let stderr_raw = String::from_utf8_lossy(&out.stderr);
834        let raw = format!("git commit failed: {}", stderr_raw.trim());
835        bail!("{}", crate::redact::redact_process_env(&raw));
836    }
837    Ok(())
838}
839
840/// `git diff --name-only <tag>..HEAD -- <paths>...` — return `true` when
841/// any of the named paths changed between `tag` and `HEAD`. Returns
842/// `Ok(false)` when git fails (e.g. not a git repo) so callers can treat
843/// the absence-of-info case as "no changes".
844pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
845    paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
846}
847
848/// Path-taking sibling of [`paths_changed_since_tag`].
849pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
850    let mut args: Vec<String> = vec![
851        "diff".to_string(),
852        "--name-only".to_string(),
853        format!("{tag}..HEAD"),
854        "--".to_string(),
855    ];
856    for p in paths {
857        args.push((*p).to_string());
858    }
859    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
860    let output = Command::new("git")
861        .current_dir(cwd)
862        .args(&arg_refs)
863        .env("GIT_TERMINAL_PROMPT", "0")
864        .env("LC_ALL", "C")
865        .output()?;
866    if output.status.success() {
867        Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
868    } else {
869        Ok(false)
870    }
871}
872
873/// Return HEAD's full commit hash for the given repository (or worktree).
874///
875/// Retained as a named entry point for the determinism harness / CI glue;
876/// delegates to [`get_head_commit_in`] so HEAD-sha resolution lives in exactly
877/// one place rather than re-implementing its own `rev-parse`.
878pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
879    get_head_commit_in(repo)
880}
881
882/// Resolve a revision (sha, ref name, `HEAD`, etc.) to its full commit hash.
883///
884/// Wrapper over `git rev-parse <rev>` — errors when the revision can't be
885/// resolved (unknown sha, ambiguous short hash, not a git repo).
886pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
887    git_output_in(cwd, &["rev-parse", rev])
888}
889
890/// `git rev-parse --verify <rev>^{commit}` — resolve `rev` to a commit SHA,
891/// erroring when it does not name an existing commit. Stricter than
892/// [`rev_parse_in`]: `--verify` rejects ambiguous / non-existent refs (rather
893/// than echoing the input back), and the `^{commit}` peel rejects a ref that
894/// resolves to a non-commit object (e.g. a tree or blob SHA).
895pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
896    git_output_in(
897        cwd,
898        &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
899    )
900}
901
902/// `git rev-list <sha>..HEAD` — list the commit hashes (newest-first) that
903/// sit on top of `sha` but aren't in `sha`.
904///
905/// Returns an empty vec when `sha` IS `HEAD` (no commits between).
906pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
907    let range = format!("{}..HEAD", sha);
908    let out = git_output_in(cwd, &["rev-list", &range])?;
909    if out.is_empty() {
910        return Ok(Vec::new());
911    }
912    Ok(out.lines().map(|s| s.trim().to_string()).collect())
913}
914
915/// `git log -1 --format=%s <sha>` — return the subject line of a single
916/// commit. Used to render the "non-bump commit subject" list when the
917/// rollback safety check fires.
918pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
919    git_output_in(
920        cwd,
921        &[
922            "-c",
923            "log.showSignature=false",
924            "log",
925            "-1",
926            "--format=%s",
927            sha,
928        ],
929    )
930}
931
932/// `git log --format=%H%x1f%s <sha>..HEAD` — return every `(full_sha, subject)`
933/// pair in the range in one subprocess. Used by the rollback safety check so
934/// classifying N intervening commits is a single `git` spawn rather than
935/// `1 + N` (one `rev-list` plus one `log -1` per commit).
936///
937/// Empty range (sha IS HEAD) returns an empty vec.
938pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
939    let range = format!("{}..HEAD", sha);
940    let out = git_output_in(
941        cwd,
942        &[
943            "-c",
944            "log.showSignature=false",
945            "log",
946            "--format=%H%x1f%s",
947            &range,
948        ],
949    )?;
950    if out.is_empty() {
951        return Ok(Vec::new());
952    }
953    Ok(out
954        .lines()
955        .filter_map(|line| {
956            let mut parts = line.splitn(2, '\x1f');
957            let sha = parts.next()?.trim().to_string();
958            let subj = parts.next().unwrap_or("").to_string();
959            if sha.is_empty() {
960                None
961            } else {
962                Some((sha, subj))
963            }
964        })
965        .collect())
966}
967
968/// Committer identity (author + committer name/email) for the rare path
969/// where a git invocation lands on a host with no `user.email` /
970/// `user.name` configured — notably `actions/checkout@v6`, which does
971/// NOT set committer identity for the workflow runner. Resolved once per
972/// caller and threaded through to [`revert_commit_in`] so the CLI never
973/// mutates the repo's git config (env-only, scoped to the single spawn).
974///
975/// Convention: when both `name` and `email` are populated, the values
976/// are exported as `GIT_AUTHOR_NAME` / `GIT_AUTHOR_EMAIL` AND
977/// `GIT_COMMITTER_NAME` / `GIT_COMMITTER_EMAIL` on the git child
978/// processes (revert + amend). When `None`, the child inherits whatever
979/// the parent / repo config provides.
980#[derive(Debug, Clone, Default)]
981pub struct CommitterIdentity {
982    pub name: Option<String>,
983    pub email: Option<String>,
984}
985
986impl CommitterIdentity {
987    /// Return a default committer identity to use when `user.email` and
988    /// `user.name` are both unset on the host. Email uses the
989    /// short-hostname (best-effort; falls back to `"localhost"`) so a
990    /// reviewer can tell at a glance which machine emitted the
991    /// rollback commit.
992    pub fn default_for_rollback() -> Self {
993        let host = std::env::var("HOSTNAME")
994            .ok()
995            .or_else(|| std::env::var("COMPUTERNAME").ok())
996            .and_then(|h| h.split('.').next().map(str::to_string))
997            .filter(|h| !h.is_empty())
998            .unwrap_or_else(|| "localhost".to_string());
999        Self {
1000            name: Some("anodize-rollback".to_string()),
1001            email: Some(format!("anodize-rollback@{host}")),
1002        }
1003    }
1004
1005    fn apply_to(&self, cmd: &mut Command) {
1006        if let Some(n) = &self.name {
1007            cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
1008        }
1009        if let Some(e) = &self.email {
1010            cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
1011        }
1012    }
1013}
1014
1015/// Read `git config user.email` / `user.name` in `cwd`. Returns
1016/// `(name, email)`, each `Some(value)` when configured (and non-empty)
1017/// or `None` when unset. Used by [`revert_commit_in`] to detect the
1018/// CI-checkout case where neither identity is configured and the
1019/// committer env fallback must fire.
1020fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
1021    let one = |key: &str| -> Option<String> {
1022        let out = Command::new("git")
1023            .current_dir(cwd)
1024            .args(["config", "--get", key])
1025            .env("LC_ALL", "C")
1026            .env("GIT_TERMINAL_PROMPT", "0")
1027            .output()
1028            .ok()?;
1029        if !out.status.success() {
1030            return None;
1031        }
1032        let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1033        if value.is_empty() { None } else { Some(value) }
1034    };
1035    (one("user.name"), one("user.email"))
1036}
1037
1038/// Resolve the committer identity to use for a rollback-style commit.
1039/// When the host already has `user.name` AND `user.email` configured
1040/// (or `GIT_AUTHOR_*` / `GIT_COMMITTER_*` are set in the parent env),
1041/// returns an empty identity so the child inherits the existing
1042/// values. Otherwise returns a synthetic identity so the commit
1043/// doesn't fail with "Author identity unknown" on bare-CI hosts.
1044pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
1045    let env_author_set =
1046        std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
1047    let env_committer_set =
1048        std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
1049    if env_author_set && env_committer_set {
1050        return CommitterIdentity::default();
1051    }
1052    let (name, email) = read_git_identity(cwd);
1053    if name.is_some() && email.is_some() {
1054        return CommitterIdentity::default();
1055    }
1056    CommitterIdentity::default_for_rollback()
1057}
1058
1059/// Run `git revert --no-edit <sha>` in `cwd`, optionally followed by
1060/// `git commit --amend -m <message>`.
1061///
1062/// Refuses against a dirty working tree (`git revert` would surface a
1063/// less actionable "your local changes would be overwritten" message
1064/// otherwise). Mirrors the dirty-tree guard used by
1065/// `stage-publish/src/util/git_revert.rs`. The guard counts only
1066/// TRACKED modifications (`--untracked-files=no`): a revert never
1067/// touches untracked files, and a failure-recovery rollback runs right
1068/// after a release wrote `dist/` — in repos that don't gitignore their
1069/// dist, an untracked-counts-as-dirty guard would refuse every
1070/// post-release rollback. The one genuine hazard (an untracked file
1071/// where the revert must restore a tracked one) is refused by git
1072/// itself with an explicit "would be overwritten" error.
1073///
1074/// On revert failure (typically a merge conflict against later commits
1075/// on top of the bump), runs `git revert --abort` to restore the
1076/// working tree before bubbling the error — otherwise the next
1077/// rollback attempt would trip the dirty-tree guard and the operator
1078/// would be stuck.
1079///
1080/// `identity` is threaded through as committer env vars so the call
1081/// works on bare-CI hosts where the workflow checkout doesn't set
1082/// `user.email` / `user.name`. The env is scoped to the spawn; the
1083/// repo's git config is never mutated.
1084pub fn revert_commit_in(
1085    cwd: &Path,
1086    sha: &str,
1087    message: Option<&str>,
1088    identity: &CommitterIdentity,
1089) -> Result<()> {
1090    let status = Command::new("git")
1091        .args(["status", "--porcelain", "--untracked-files=no"])
1092        .current_dir(cwd)
1093        .env("LC_ALL", "C")
1094        .env("GIT_TERMINAL_PROMPT", "0")
1095        .output()
1096        .with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
1097    if !status.status.success() {
1098        let stderr_raw = String::from_utf8_lossy(&status.stderr);
1099        let raw = format!("git status failed: {}", stderr_raw.trim());
1100        bail!("{}", crate::redact::redact_process_env(&raw));
1101    }
1102    if !status.stdout.is_empty() {
1103        bail!(
1104            "refusing to revert in a dirty working tree at {}\nstatus:\n{}",
1105            cwd.display(),
1106            String::from_utf8_lossy(&status.stdout),
1107        );
1108    }
1109
1110    let mut revert_cmd = Command::new("git");
1111    revert_cmd
1112        .current_dir(cwd)
1113        .args(["revert", "--no-edit", sha])
1114        .env("LC_ALL", "C")
1115        .env("GIT_TERMINAL_PROMPT", "0");
1116    identity.apply_to(&mut revert_cmd);
1117    let out = revert_cmd
1118        .output()
1119        .with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
1120    if !out.status.success() {
1121        let stderr_raw = String::from_utf8_lossy(&out.stderr);
1122        // Restore the working tree before bubbling — otherwise the dirty-tree
1123        // guard above traps a subsequent rollback retry forever.
1124        let _ = Command::new("git")
1125            .current_dir(cwd)
1126            .args(["revert", "--abort"])
1127            .env("LC_ALL", "C")
1128            .env("GIT_TERMINAL_PROMPT", "0")
1129            .output();
1130        let raw = format!(
1131            "git revert {sha} hit conflicts and was aborted (working tree restored). \
1132             The bump commit overlaps with later changes — resolve manually, \
1133             or re-run with --mode=reset to force.\nstderr: {}",
1134            stderr_raw.trim()
1135        );
1136        bail!("{}", crate::redact::redact_process_env(&raw));
1137    }
1138    if let Some(msg) = message {
1139        let mut amend_cmd = Command::new("git");
1140        amend_cmd
1141            .current_dir(cwd)
1142            .args(["commit", "--amend", "-m", msg])
1143            .env("LC_ALL", "C")
1144            .env("GIT_TERMINAL_PROMPT", "0");
1145        identity.apply_to(&mut amend_cmd);
1146        let out = amend_cmd.output().with_context(|| {
1147            format!("revert_commit_in: git commit --amend in {}", cwd.display())
1148        })?;
1149        if !out.status.success() {
1150            let stderr_raw = String::from_utf8_lossy(&out.stderr);
1151            let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
1152            bail!("{}", crate::redact::redact_process_env(&raw));
1153        }
1154    }
1155    Ok(())
1156}
1157
1158/// Run `git reset --hard <sha>` in `cwd`. **Destructive** — rewrites HEAD
1159/// and the index in place; callers must surface a warning before invoking.
1160pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
1161    git_output_in(cwd, &["reset", "--hard", sha])?;
1162    Ok(())
1163}
1164
1165/// Push a branch (`HEAD:refs/heads/<branch>`) to the `origin` remote.
1166///
1167/// Errors when no `origin` remote is configured — callers driving local-only
1168/// flows should pass `--no-push` to skip the call entirely.
1169pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
1170    if !super::has_remote_in(cwd, "origin") {
1171        bail!("no 'origin' remote configured, cannot push branch '{branch}'");
1172    }
1173    let refspec = format!("HEAD:refs/heads/{}", branch);
1174    let out = Command::new("git")
1175        .current_dir(cwd)
1176        .args(["push", "origin", &refspec])
1177        .env("GIT_TERMINAL_PROMPT", "0")
1178        .env("LC_ALL", "C")
1179        .output()
1180        .with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
1181    if !out.status.success() {
1182        let stderr_raw = String::from_utf8_lossy(&out.stderr);
1183        let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
1184        bail!("{}", crate::redact::redact_process_env(&raw));
1185    }
1186    Ok(())
1187}
1188
1189/// `git -C <repo> log -1 --format=%ct HEAD` — return HEAD's committer
1190/// timestamp (seconds since UNIX epoch) for the given repository. Used by
1191/// the determinism harness as the non-snapshot SDE seed.
1192pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
1193    let out = Command::new("git")
1194        .arg("-C")
1195        .arg(repo)
1196        .args(["log", "-1", "--format=%ct", "HEAD"])
1197        .env("GIT_TERMINAL_PROMPT", "0")
1198        .env("LC_ALL", "C")
1199        .output()
1200        .context("failed to invoke git log -1 --format=%ct HEAD")?;
1201    if !out.status.success() {
1202        let stderr_raw = String::from_utf8_lossy(&out.stderr);
1203        let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
1204        bail!("{}", crate::redact::redact_process_env(&raw));
1205    }
1206    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
1207    text.parse::<i64>()
1208        .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214    use std::process::Command;
1215
1216    fn init_repo_with_commits(dir: &Path, files: &[&str]) {
1217        let run = |args: &[&str]| {
1218            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1219                || {
1220                    let mut cmd = Command::new("git");
1221                    cmd.args(args)
1222                        .current_dir(dir)
1223                        .env("GIT_AUTHOR_NAME", "t")
1224                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1225                        .env("GIT_COMMITTER_NAME", "t")
1226                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1227                    cmd
1228                },
1229                "git",
1230            );
1231            assert!(out.status.success(), "git {args:?} failed");
1232        };
1233        run(&["init"]);
1234        run(&["config", "user.email", "t@t.com"]);
1235        run(&["config", "user.name", "t"]);
1236        for (i, f) in files.iter().enumerate() {
1237            std::fs::write(dir.join(f), format!("c{i}")).unwrap();
1238            run(&["add", "."]);
1239            run(&["commit", "-m", &format!("commit-{i}: {f}")]);
1240        }
1241    }
1242
1243    #[test]
1244    fn get_head_commit_in_returns_tempdirs_head_sha() {
1245        let tmp = tempfile::tempdir().unwrap();
1246        init_repo_with_commits(tmp.path(), &["a"]);
1247        let expected = String::from_utf8(
1248            anodizer_core::test_helpers::output_with_spawn_retry(
1249                || {
1250                    let mut cmd = Command::new("git");
1251                    cmd.args(["rev-parse", "HEAD"]).current_dir(tmp.path());
1252                    cmd
1253                },
1254                "git",
1255            )
1256            .stdout,
1257        )
1258        .unwrap()
1259        .trim()
1260        .to_string();
1261        let sha = get_head_commit_in(tmp.path()).unwrap();
1262        assert_eq!(sha, expected);
1263    }
1264
1265    #[test]
1266    fn get_short_commit_in_returns_tempdirs_short_sha() {
1267        let tmp = tempfile::tempdir().unwrap();
1268        init_repo_with_commits(tmp.path(), &["a"]);
1269        let expected = String::from_utf8(
1270            anodizer_core::test_helpers::output_with_spawn_retry(
1271                || {
1272                    let mut cmd = Command::new("git");
1273                    cmd.args(["rev-parse", "--short", "HEAD"])
1274                        .current_dir(tmp.path());
1275                    cmd
1276                },
1277                "git",
1278            )
1279            .stdout,
1280        )
1281        .unwrap()
1282        .trim()
1283        .to_string();
1284        let short = get_short_commit_in(tmp.path()).unwrap();
1285        assert_eq!(short, expected);
1286    }
1287
1288    #[test]
1289    fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
1290        let tmp = tempfile::tempdir().unwrap();
1291        let dir = tmp.path();
1292        init_repo_with_commits(dir, &["a"]);
1293        let run = |args: &[&str]| {
1294            anodizer_core::test_helpers::output_with_spawn_retry(
1295                || {
1296                    let mut cmd = Command::new("git");
1297                    cmd.args(args)
1298                        .current_dir(dir)
1299                        .env("GIT_AUTHOR_NAME", "t")
1300                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1301                        .env("GIT_COMMITTER_NAME", "t")
1302                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1303                    cmd
1304                },
1305                "git",
1306            );
1307        };
1308        run(&["tag", "v1.0.0"]);
1309        assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
1310    }
1311
1312    fn git_in(dir: &Path, args: &[&str]) {
1313        let out = anodizer_core::test_helpers::output_with_spawn_retry(
1314            || {
1315                let mut cmd = Command::new("git");
1316                cmd.args(args)
1317                    .current_dir(dir)
1318                    .env("GIT_AUTHOR_NAME", "t")
1319                    .env("GIT_AUTHOR_EMAIL", "t@t.com")
1320                    .env("GIT_COMMITTER_NAME", "t")
1321                    .env("GIT_COMMITTER_EMAIL", "t@t.com");
1322                cmd
1323            },
1324            "git",
1325        );
1326        assert!(out.status.success(), "git {args:?} failed");
1327    }
1328
1329    #[test]
1330    fn count_commits_since_last_tag_counts_commits_after_tag() {
1331        let tmp = tempfile::tempdir().unwrap();
1332        let dir = tmp.path();
1333        // 2 commits, tag v1.0.0 at the 2nd, then 3 more commits.
1334        init_repo_with_commits(dir, &["a", "b"]);
1335        git_in(dir, &["tag", "v1.0.0"]);
1336        for f in ["c", "d", "e"] {
1337            std::fs::write(dir.join(f), "x").unwrap();
1338            git_in(dir, &["add", "."]);
1339            git_in(dir, &["commit", "-m", f]);
1340        }
1341        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1342    }
1343
1344    #[test]
1345    fn count_commits_since_last_tag_resets_on_newer_tag() {
1346        let tmp = tempfile::tempdir().unwrap();
1347        let dir = tmp.path();
1348        init_repo_with_commits(dir, &["a"]);
1349        git_in(dir, &["tag", "v1.0.0"]);
1350        for f in ["b", "c"] {
1351            std::fs::write(dir.join(f), "x").unwrap();
1352            git_in(dir, &["add", "."]);
1353            git_in(dir, &["commit", "-m", f]);
1354        }
1355        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 2);
1356        // A newer version tag lands -> counter resets to 0 at the tag.
1357        git_in(dir, &["tag", "v1.1.0"]);
1358        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 0);
1359        std::fs::write(dir.join("d"), "x").unwrap();
1360        git_in(dir, &["add", "."]);
1361        git_in(dir, &["commit", "-m", "d"]);
1362        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 1);
1363    }
1364
1365    #[test]
1366    fn count_commits_since_last_tag_counts_all_when_no_tag() {
1367        let tmp = tempfile::tempdir().unwrap();
1368        let dir = tmp.path();
1369        init_repo_with_commits(dir, &["a", "b", "c"]);
1370        // No tag at all -> count every commit on HEAD.
1371        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1372    }
1373
1374    #[test]
1375    fn count_commits_since_last_tag_respects_monorepo_prefix() {
1376        // Per-crate workspace: tags for two subprojects interleave on one
1377        // branch. The `core/` count must be since the latest `core/*` tag,
1378        // NOT the nearer `api/*` tag from a different subproject.
1379        let tmp = tempfile::tempdir().unwrap();
1380        let dir = tmp.path();
1381        init_repo_with_commits(dir, &["a"]);
1382        git_in(dir, &["tag", "core/v1.0.0"]); // matching-prefix tag (older)
1383        for f in ["b", "c"] {
1384            std::fs::write(dir.join(f), "x").unwrap();
1385            git_in(dir, &["add", "."]);
1386            git_in(dir, &["commit", "-m", f]);
1387        }
1388        git_in(dir, &["tag", "api/v2.0.0"]); // DIFFERENT prefix, NEARER to HEAD
1389        std::fs::write(dir.join("d"), "x").unwrap();
1390        git_in(dir, &["add", "."]);
1391        git_in(dir, &["commit", "-m", "d"]);
1392
1393        // With prefix filtering: count since core/v1.0.0 = 3 commits (b, c, d).
1394        assert_eq!(
1395            count_commits_since_last_tag_in(dir, Some("core/")).unwrap(),
1396            3,
1397            "must count since the matching-prefix tag, ignoring api/v2.0.0",
1398        );
1399        // Without filtering (None): describe picks the nearer api/v2.0.0,
1400        // so the count is only 1 (d). This is the mutation-check baseline
1401        // proving the --match arg is load-bearing.
1402        assert_eq!(
1403            count_commits_since_last_tag_in(dir, None).unwrap(),
1404            1,
1405            "unfiltered count picks the nearest (wrong) subproject tag",
1406        );
1407    }
1408
1409    #[test]
1410    fn get_current_branch_in_returns_branch_name() {
1411        let tmp = tempfile::tempdir().unwrap();
1412        let dir = tmp.path();
1413        let run = |args: &[&str]| {
1414            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1415                || {
1416                    let mut cmd = Command::new("git");
1417                    cmd.args(args)
1418                        .current_dir(dir)
1419                        .env("GIT_AUTHOR_NAME", "t")
1420                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1421                        .env("GIT_COMMITTER_NAME", "t")
1422                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1423                    cmd
1424                },
1425                "git",
1426            );
1427            assert!(out.status.success(), "git {args:?} failed");
1428        };
1429        run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
1430        run(&["config", "user.email", "t@t.com"]);
1431        run(&["config", "user.name", "t"]);
1432        std::fs::write(dir.join("a"), "1").unwrap();
1433        run(&["add", "."]);
1434        run(&["commit", "-m", "c1"]);
1435        let branch = get_current_branch_in(dir).unwrap();
1436        assert_eq!(branch, "t1-test-branch");
1437    }
1438
1439    #[test]
1440    fn get_current_branch_in_resolves_detached_head_via_points_at() {
1441        let tmp = tempfile::tempdir().unwrap();
1442        let dir = tmp.path();
1443        let run = |args: &[&str]| {
1444            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1445                || {
1446                    let mut cmd = Command::new("git");
1447                    cmd.args(args)
1448                        .current_dir(dir)
1449                        .env("GIT_AUTHOR_NAME", "t")
1450                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1451                        .env("GIT_COMMITTER_NAME", "t")
1452                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1453                    cmd
1454                },
1455                "git",
1456            );
1457            assert!(out.status.success(), "git {args:?} failed");
1458        };
1459        run(&["-c", "init.defaultBranch=master", "init"]);
1460        run(&["config", "user.email", "t@t.com"]);
1461        run(&["config", "user.name", "t"]);
1462        std::fs::write(dir.join("a"), "1").unwrap();
1463        run(&["add", "."]);
1464        run(&["commit", "-m", "c1"]);
1465        let sha = get_head_commit_in(dir).unwrap();
1466        run(&["checkout", "--detach", &sha]);
1467        let branch = get_current_branch_in(dir).unwrap();
1468        assert_eq!(
1469            branch, "master",
1470            "detached HEAD pointing at master must resolve to master, not literal HEAD"
1471        );
1472    }
1473
1474    #[test]
1475    fn is_branchlike_rejects_lockstep_tag_shapes() {
1476        assert!(!is_branchlike("v0.4.5"));
1477        assert!(!is_branchlike("v1.2.3"));
1478        assert!(!is_branchlike("v10.20.30"));
1479        assert!(!is_branchlike("v1.2.3-rc.1"));
1480        assert!(!is_branchlike("v1.2.3+build.42"));
1481    }
1482
1483    #[test]
1484    fn is_branchlike_rejects_per_crate_tag_shapes() {
1485        assert!(!is_branchlike("mycrate-v1.2.3"));
1486        assert!(!is_branchlike("cfgd-operator-v0.4.0"));
1487        assert!(!is_branchlike("anodize-core-v1.2.3-rc.1"));
1488    }
1489
1490    #[test]
1491    fn is_branchlike_accepts_real_branch_names() {
1492        assert!(is_branchlike("master"));
1493        assert!(is_branchlike("main"));
1494        assert!(is_branchlike("publisher-required-config"));
1495        assert!(is_branchlike("release/v1.2.3-prep"));
1496        assert!(is_branchlike("dependabot/cargo/serde-1.0.200"));
1497    }
1498
1499    #[test]
1500    fn is_branchlike_accepts_slashed_branch_with_embedded_version() {
1501        // `feature/fix-v2.0.0` embeds `-v2.0.0` but is a branch, not a
1502        // per-crate tag: the unanchored `-v\d+\.\d+\.\d+` regex misclassified
1503        // it as a tag. The `^[^/]+-v` anchor keeps slashed branch names
1504        // branch-like.
1505        assert!(is_branchlike("feature/fix-v2.0.0"));
1506        assert!(is_branchlike("hotfix/release-v1.0.0-blocker"));
1507        assert!(is_branchlike("user/wip-v3.1.4"));
1508    }
1509
1510    #[test]
1511    fn get_current_branch_in_rejects_tag_shaped_github_ref_name() {
1512        let tmp = tempfile::tempdir().unwrap();
1513        let dir = tmp.path();
1514        let run = |args: &[&str]| {
1515            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1516                || {
1517                    let mut cmd = Command::new("git");
1518                    cmd.args(args)
1519                        .current_dir(dir)
1520                        .env("GIT_AUTHOR_NAME", "t")
1521                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1522                        .env("GIT_COMMITTER_NAME", "t")
1523                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1524                    cmd
1525                },
1526                "git",
1527            );
1528            assert!(out.status.success(), "git {args:?} failed");
1529        };
1530        // Build a repo whose HEAD is detached AND no local branch points
1531        // at it, so every fallback BEFORE GITHUB_REF_NAME fails. The only
1532        // way the fallback chain produces a value is via the env var.
1533        run(&["-c", "init.defaultBranch=master", "init"]);
1534        run(&["config", "user.email", "t@t.com"]);
1535        run(&["config", "user.name", "t"]);
1536        std::fs::write(dir.join("a"), "1").unwrap();
1537        run(&["add", "."]);
1538        run(&["commit", "-m", "c1"]);
1539        let sha = get_head_commit_in(dir).unwrap();
1540        // Move master forward so the detached HEAD has no branch
1541        // pointing at it; for-each-ref --points-at HEAD returns empty.
1542        std::fs::write(dir.join("a"), "2").unwrap();
1543        run(&["add", "."]);
1544        run(&["commit", "-m", "c2"]);
1545        run(&["checkout", "--detach", &sha]);
1546
1547        // GITHUB_REF_NAME is injected via the env seam, so each branch of the
1548        // fallback is driven without mutating process-global env.
1549
1550        // Tag-shaped: must NOT be accepted; bail surfaces.
1551        let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "v0.4.5");
1552        let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1553        assert!(
1554            err.to_string().contains("could not resolve current branch"),
1555            "tag-shaped GITHUB_REF_NAME must trigger bail: {err}"
1556        );
1557
1558        // Per-crate-shaped: must NOT be accepted either.
1559        let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "mycrate-v1.2.3");
1560        let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
1561        assert!(
1562            err.to_string().contains("could not resolve current branch"),
1563            "per-crate tag GITHUB_REF_NAME must trigger bail: {err}"
1564        );
1565
1566        // Real branch name: accepted.
1567        let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "master");
1568        let branch = get_current_branch_in_with_env(dir, &env).unwrap();
1569        assert_eq!(branch, "master");
1570    }
1571
1572    #[test]
1573    fn branches_containing_sha_in_returns_empty_without_remote() {
1574        let tmp = tempfile::tempdir().unwrap();
1575        let dir = tmp.path();
1576        let run = |args: &[&str]| {
1577            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1578                || {
1579                    let mut cmd = Command::new("git");
1580                    cmd.args(args)
1581                        .current_dir(dir)
1582                        .env("GIT_AUTHOR_NAME", "t")
1583                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1584                        .env("GIT_COMMITTER_NAME", "t")
1585                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1586                    cmd
1587                },
1588                "git",
1589            );
1590            assert!(out.status.success(), "git {args:?} failed");
1591        };
1592        run(&["-c", "init.defaultBranch=master", "init"]);
1593        run(&["config", "user.email", "t@t.com"]);
1594        run(&["config", "user.name", "t"]);
1595        std::fs::write(dir.join("a"), "1").unwrap();
1596        run(&["add", "."]);
1597        run(&["commit", "-m", "c1"]);
1598        let sha = get_head_commit_in(dir).unwrap();
1599        // No remote configured → `git branch -r --contains` returns
1600        // empty, which the helper surfaces as `Vec::new()` so the
1601        // caller can fall back to local branch resolution.
1602        let branches = branches_containing_sha_in(dir, &sha).unwrap();
1603        assert!(branches.is_empty(), "no remote → no remote branches");
1604    }
1605
1606    #[test]
1607    fn branches_containing_sha_in_finds_remote_branch_after_push() {
1608        let tmp = tempfile::tempdir().unwrap();
1609        let bare = tempfile::tempdir().unwrap();
1610        let dir = tmp.path();
1611        let run_in = |cwd: &Path, args: &[&str]| {
1612            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1613                || {
1614                    let mut cmd = Command::new("git");
1615                    cmd.args(args)
1616                        .current_dir(cwd)
1617                        .env("GIT_AUTHOR_NAME", "t")
1618                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1619                        .env("GIT_COMMITTER_NAME", "t")
1620                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1621                    cmd
1622                },
1623                "git",
1624            );
1625            assert!(out.status.success(), "git {args:?} failed");
1626        };
1627        run_in(
1628            bare.path(),
1629            &["-c", "init.defaultBranch=master", "init", "--bare"],
1630        );
1631        run_in(dir, &["-c", "init.defaultBranch=master", "init"]);
1632        run_in(dir, &["config", "user.email", "t@t.com"]);
1633        run_in(dir, &["config", "user.name", "t"]);
1634        run_in(
1635            dir,
1636            &["remote", "add", "origin", bare.path().to_str().unwrap()],
1637        );
1638        std::fs::write(dir.join("a"), "1").unwrap();
1639        run_in(dir, &["add", "."]);
1640        run_in(dir, &["commit", "-m", "c1"]);
1641        let sha = get_head_commit_in(dir).unwrap();
1642        run_in(dir, &["push", "-u", "origin", "master"]);
1643
1644        let branches = branches_containing_sha_in(dir, &sha).unwrap();
1645        assert_eq!(branches, vec!["master".to_string()]);
1646    }
1647
1648    #[test]
1649    fn stage_and_commit_in_returns_false_when_no_diff() {
1650        let tmp = tempfile::tempdir().unwrap();
1651        let dir = tmp.path();
1652        init_repo_with_commits(dir, &["a"]);
1653        // File is committed and unchanged — staging it should not produce
1654        // a diff, and stage_and_commit must report Ok(false) instead of
1655        // bailing on the "nothing to commit" path.
1656        let created = stage_and_commit_in(dir, &["a"], "chore: should be a no-op").unwrap();
1657        assert!(!created, "no diff → no commit should be created");
1658        let log = anodizer_core::test_helpers::output_with_spawn_retry(
1659            || {
1660                let mut cmd = Command::new("git");
1661                cmd.args(["log", "--oneline"]).current_dir(dir);
1662                cmd
1663            },
1664            "git",
1665        );
1666        let log_text = String::from_utf8_lossy(&log.stdout);
1667        assert!(
1668            !log_text.contains("should be a no-op"),
1669            "stage_and_commit_in must not create a commit when no diff: {log_text}"
1670        );
1671    }
1672
1673    #[test]
1674    fn stage_and_commit_in_returns_true_when_file_changed() {
1675        let tmp = tempfile::tempdir().unwrap();
1676        let dir = tmp.path();
1677        init_repo_with_commits(dir, &["a"]);
1678        std::fs::write(dir.join("a"), "changed").unwrap();
1679        let created = stage_and_commit_in(dir, &["a"], "chore: real change").unwrap();
1680        assert!(created, "real change → commit must be created");
1681        let log = anodizer_core::test_helpers::output_with_spawn_retry(
1682            || {
1683                let mut cmd = Command::new("git");
1684                cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
1685                cmd
1686            },
1687            "git",
1688        );
1689        let subject = String::from_utf8_lossy(&log.stdout).trim().to_string();
1690        assert_eq!(subject, "chore: real change");
1691    }
1692
1693    #[test]
1694    fn git_output_in_error_falls_back_to_stdout_when_stderr_empty() {
1695        let tmp = tempfile::tempdir().unwrap();
1696        let dir = tmp.path();
1697        init_repo_with_commits(dir, &["a"]);
1698        // `git commit -m ...` with an unchanged tree prints "nothing to
1699        // commit" to STDOUT (not stderr); the error message must surface
1700        // that detail instead of `failed: ` with nothing after.
1701        let err = git_output_in(dir, &["commit", "-m", "no-op"]).unwrap_err();
1702        let msg = err.to_string();
1703        assert!(
1704            msg.contains("nothing to commit") || msg.contains("clean"),
1705            "error must include stdout detail when stderr is empty: {msg}"
1706        );
1707    }
1708
1709    /// `CommitterIdentity::default_for_rollback` produces a populated
1710    /// (name + email) identity. The exact host-derived suffix isn't
1711    /// load-bearing — what matters is that both fields are present so
1712    /// `apply_to` produces all four `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
1713    /// envs on the spawn.
1714    #[test]
1715    fn default_for_rollback_populates_both_name_and_email() {
1716        let id = CommitterIdentity::default_for_rollback();
1717        assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
1718        let email = id.email.expect("email must be Some");
1719        assert!(
1720            email.starts_with("anodize-rollback@"),
1721            "email must use the anodize-rollback@<host> shape; got {email}"
1722        );
1723        assert!(!email.ends_with('@'), "host portion must not be empty");
1724    }
1725
1726    /// `revert_commit_in` with an injected `CommitterIdentity` writes a
1727    /// commit whose author/committer match the identity. Exercises the
1728    /// env-injection path end-to-end against a real fixture repo whose
1729    /// only configured identity is the override — so a future regression
1730    /// that drops the env threading would show up as the commit
1731    /// inheriting the host's `user.email` instead.
1732    #[test]
1733    fn revert_commit_in_uses_injected_identity_envs() {
1734        let tmp = tempfile::tempdir().unwrap();
1735        let dir = tmp.path();
1736        let run_env = |args: &[&str], extra: &[(&str, &str)]| {
1737            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1738                || {
1739                    let mut cmd = Command::new("git");
1740                    cmd.args(args)
1741                        .current_dir(dir)
1742                        .env("GIT_AUTHOR_NAME", "bootstrap")
1743                        .env("GIT_AUTHOR_EMAIL", "bootstrap@b.com")
1744                        .env("GIT_COMMITTER_NAME", "bootstrap")
1745                        .env("GIT_COMMITTER_EMAIL", "bootstrap@b.com");
1746                    for (k, v) in extra {
1747                        cmd.env(k, v);
1748                    }
1749                    cmd
1750                },
1751                "git",
1752            );
1753            assert!(
1754                out.status.success(),
1755                "git {args:?} failed: {}",
1756                String::from_utf8_lossy(&out.stderr)
1757            );
1758        };
1759        run_env(&["init", "-b", "master"], &[]);
1760        std::fs::write(dir.join("a"), "0").unwrap();
1761        run_env(&["add", "."], &[]);
1762        run_env(&["commit", "-m", "initial"], &[]);
1763        std::fs::write(dir.join("a"), "1").unwrap();
1764        run_env(&["add", "."], &[]);
1765        run_env(&["commit", "-m", "chore(release): v1.0.0"], &[]);
1766        let bump_sha = get_head_commit_in(dir).unwrap();
1767
1768        // Inject a distinct identity so the resulting revert commit can
1769        // be attributed unambiguously to the env path (the bootstrap
1770        // commits used a different identity above).
1771        let identity = CommitterIdentity {
1772            name: Some("rollback-bot".to_string()),
1773            email: Some("rollback-bot@anodize.test".to_string()),
1774        };
1775        revert_commit_in(dir, &bump_sha, Some("chore(release): rollback"), &identity)
1776            .expect("revert with injected identity must succeed");
1777
1778        // The new HEAD commit's author email must be the injected one,
1779        // proving the env threading reached the git child.
1780        let out = anodizer_core::test_helpers::output_with_spawn_retry(
1781            || {
1782                let mut cmd = Command::new("git");
1783                cmd.current_dir(dir)
1784                    .args(["log", "-1", "--format=%ae"])
1785                    .env("GIT_TERMINAL_PROMPT", "0")
1786                    .env("LC_ALL", "C");
1787                cmd
1788            },
1789            "git",
1790        );
1791        let author_email = String::from_utf8_lossy(&out.stdout).trim().to_string();
1792        assert_eq!(
1793            author_email, "rollback-bot@anodize.test",
1794            "revert commit must carry the injected committer identity"
1795        );
1796
1797        // Repo config must remain unchanged — env-only fallback, no
1798        // `git config user.email ...` mutation.
1799        let cfg = anodizer_core::test_helpers::output_with_spawn_retry(
1800            || {
1801                let mut cmd = Command::new("git");
1802                cmd.current_dir(dir)
1803                    .args(["config", "--local", "--get", "user.email"])
1804                    .env("GIT_TERMINAL_PROMPT", "0")
1805                    .env("LC_ALL", "C");
1806                cmd
1807            },
1808            "git",
1809        );
1810        assert!(
1811            !cfg.status.success() || cfg.stdout.is_empty(),
1812            "revert must not write user.email into the repo's local config; got: {}",
1813            String::from_utf8_lossy(&cfg.stdout)
1814        );
1815    }
1816
1817    /// B-R4: a revert that hits conflicts (because later commits overlap
1818    /// with the bump) must run `git revert --abort`, restoring the working
1819    /// tree so the operator isn't trapped by the dirty-tree guard on the
1820    /// next attempt. Bail message must mention "aborted".
1821    #[test]
1822    fn revert_commit_in_aborts_on_conflict_and_leaves_tree_clean() {
1823        let tmp = tempfile::tempdir().unwrap();
1824        let dir = tmp.path();
1825        let run = |args: &[&str]| {
1826            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1827                || {
1828                    let mut cmd = Command::new("git");
1829                    cmd.args(args)
1830                        .current_dir(dir)
1831                        .env("GIT_AUTHOR_NAME", "t")
1832                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1833                        .env("GIT_COMMITTER_NAME", "t")
1834                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1835                    cmd
1836                },
1837                "git",
1838            );
1839            assert!(
1840                out.status.success(),
1841                "git {args:?} failed: {}",
1842                String::from_utf8_lossy(&out.stderr)
1843            );
1844        };
1845        run(&["init", "-b", "master"]);
1846        run(&["config", "user.email", "t@t.com"]);
1847        run(&["config", "user.name", "t"]);
1848        // Initial commit: file `x` with line "v1".
1849        std::fs::write(dir.join("x"), "v1\n").unwrap();
1850        run(&["add", "."]);
1851        run(&["commit", "-m", "initial"]);
1852        // "Bump" commit: change to "v2".
1853        std::fs::write(dir.join("x"), "v2\n").unwrap();
1854        run(&["add", "."]);
1855        run(&["commit", "-m", "chore(release): v2"]);
1856        let bump_sha = get_head_commit_in(dir).unwrap();
1857        // Later overlapping commit: change to "v3". A revert of the bump
1858        // would try to restore "v1" from a base of "v2", but HEAD is now
1859        // "v3" — that's the canonical revert-conflict shape.
1860        std::fs::write(dir.join("x"), "v3\n").unwrap();
1861        run(&["add", "."]);
1862        run(&["commit", "-m", "feat: overlap"]);
1863
1864        let identity = CommitterIdentity::default();
1865        let err = revert_commit_in(dir, &bump_sha, None, &identity)
1866            .expect_err("revert against overlapping HEAD must conflict and bail");
1867        let msg = format!("{err}");
1868        assert!(
1869            msg.contains("aborted"),
1870            "bail message must mention abort recovery: {msg}"
1871        );
1872
1873        // Working tree must be clean post-bail: no REVERT_HEAD, no
1874        // unmerged paths. The next rollback attempt must NOT hit the
1875        // dirty-tree guard.
1876        assert!(
1877            !dir.join(".git/REVERT_HEAD").exists(),
1878            ".git/REVERT_HEAD must be cleaned up after --abort"
1879        );
1880        let status_out = anodizer_core::test_helpers::output_with_spawn_retry(
1881            || {
1882                let mut cmd = Command::new("git");
1883                cmd.args(["status", "--porcelain"]).current_dir(dir);
1884                cmd
1885            },
1886            "git",
1887        );
1888        assert!(
1889            status_out.stdout.is_empty(),
1890            "working tree must be clean after revert --abort; got:\n{}",
1891            String::from_utf8_lossy(&status_out.stdout)
1892        );
1893    }
1894
1895    /// S-R7: `commits_with_subjects_in` returns every (sha, subject)
1896    /// pair in one git spawn. Asserts both correctness (matches per-commit
1897    /// `commit_subject_in`) and that the range bound is exclusive on the
1898    /// `<sha>` side.
1899    #[test]
1900    fn commits_with_subjects_in_returns_all_pairs_in_one_call() {
1901        let tmp = tempfile::tempdir().unwrap();
1902        let dir = tmp.path();
1903        let run = |args: &[&str]| {
1904            let out = anodizer_core::test_helpers::output_with_spawn_retry(
1905                || {
1906                    let mut cmd = Command::new("git");
1907                    cmd.args(args)
1908                        .current_dir(dir)
1909                        .env("GIT_AUTHOR_NAME", "t")
1910                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
1911                        .env("GIT_COMMITTER_NAME", "t")
1912                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
1913                    cmd
1914                },
1915                "git",
1916            );
1917            assert!(out.status.success(), "git {args:?} failed");
1918        };
1919        run(&["init", "-b", "master"]);
1920        run(&["config", "user.email", "t@t.com"]);
1921        run(&["config", "user.name", "t"]);
1922        std::fs::write(dir.join("a"), "0").unwrap();
1923        run(&["add", "."]);
1924        run(&["commit", "-m", "initial"]);
1925        let base = get_head_commit_in(dir).unwrap();
1926        std::fs::write(dir.join("a"), "1").unwrap();
1927        run(&["add", "."]);
1928        run(&["commit", "-m", "feat: A with extra detail"]);
1929        std::fs::write(dir.join("a"), "2").unwrap();
1930        run(&["add", "."]);
1931        run(&["commit", "-m", "fix: B"]);
1932
1933        let pairs = commits_with_subjects_in(dir, &base).unwrap();
1934        assert_eq!(pairs.len(), 2, "two commits sit on top of base");
1935        // Newest-first ordering (matches `git log` default).
1936        assert_eq!(pairs[0].1, "fix: B");
1937        assert_eq!(pairs[1].1, "feat: A with extra detail");
1938
1939        // Empty range (sha IS HEAD) → empty vec.
1940        let head = get_head_commit_in(dir).unwrap();
1941        assert!(commits_with_subjects_in(dir, &head).unwrap().is_empty());
1942    }
1943
1944    #[test]
1945    fn parse_commit_output_with_files_pairs_each_commit_with_its_files() {
1946        // Two commits: newest first (git log order). Each metadata record is
1947        // `%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e`, then `--name-only` files.
1948        let raw = "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\nh0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f\x1e\ncrates/core/lib.rs\nCargo.toml\n";
1949        let parsed = parse_commit_output_with_files(raw);
1950        assert_eq!(parsed.len(), 2);
1951        assert_eq!(parsed[0].commit.message, "fix: B");
1952        assert_eq!(parsed[0].files, vec!["crates/cli/main.rs".to_string()]);
1953        assert_eq!(parsed[1].commit.message, "feat: A");
1954        assert_eq!(
1955            parsed[1].files,
1956            vec!["crates/core/lib.rs".to_string(), "Cargo.toml".to_string()]
1957        );
1958    }
1959
1960    #[test]
1961    fn parse_commit_output_with_files_preserves_multiline_body_at_idx_gt_0() {
1962        // A multi-line `%b` body for the SECOND commit (idx>0): the body spans
1963        // several newline-separated lines, and the parser must keep the full
1964        // record — not just its first line — so trailers like `Co-Authored-By:`
1965        // survive, matching the metadata-only `parse_git_log_records` path.
1966        let body0 = "detail line one\ndetail line two\n\nCo-Authored-By: Bob <bob@b.com>";
1967        let raw = format!(
1968            "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\n\
1969             h0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f{body0}\x1e\ncrates/core/lib.rs\n"
1970        );
1971        let parsed = parse_commit_output_with_files(&raw);
1972        assert_eq!(parsed.len(), 2);
1973        // The idx>0 commit retains its FULL multi-line body and trailer.
1974        assert_eq!(parsed[1].commit.message, "feat: A");
1975        assert_eq!(parsed[1].commit.body, body0);
1976        assert!(
1977            parsed[1]
1978                .commit
1979                .body
1980                .contains("Co-Authored-By: Bob <bob@b.com>"),
1981            "multi-line body trailer dropped: {:?}",
1982            parsed[1].commit.body
1983        );
1984        assert_eq!(parsed[1].files, vec!["crates/core/lib.rs".to_string()]);
1985    }
1986
1987    #[test]
1988    fn get_commits_between_paths_with_files_in_reports_touched_files() {
1989        let tmp = tempfile::tempdir().unwrap();
1990        let dir = tmp.path();
1991        let run = |args: &[&str]| {
1992            assert!(
1993                anodizer_core::test_helpers::output_with_spawn_retry(
1994                    || {
1995                        let mut cmd = Command::new("git");
1996                        cmd.args(args)
1997                            .current_dir(dir)
1998                            .env("GIT_AUTHOR_NAME", "t")
1999                            .env("GIT_AUTHOR_EMAIL", "t@t.com")
2000                            .env("GIT_COMMITTER_NAME", "t")
2001                            .env("GIT_COMMITTER_EMAIL", "t@t.com");
2002                        cmd
2003                    },
2004                    "git",
2005                )
2006                .status
2007                .success()
2008            );
2009        };
2010        run(&["init"]);
2011        run(&["config", "user.email", "t@t.com"]);
2012        run(&["config", "user.name", "t"]);
2013        std::fs::write(dir.join("base"), "0").unwrap();
2014        run(&["add", "."]);
2015        run(&["commit", "-m", "initial"]);
2016        let base = get_head_commit_in(dir).unwrap();
2017        std::fs::create_dir_all(dir.join("crates/core")).unwrap();
2018        std::fs::write(dir.join("crates/core/lib.rs"), "1").unwrap();
2019        run(&["add", "."]);
2020        run(&["commit", "-m", "feat: core"]);
2021
2022        let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2023        assert_eq!(pairs.len(), 1);
2024        assert_eq!(pairs[0].commit.message, "feat: core");
2025        assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2026    }
2027
2028    #[test]
2029    fn get_commits_between_paths_with_files_in_preserves_multiline_body_for_later_commits() {
2030        // Real `git log --name-only` over TWO post-base commits, the OLDER one
2031        // (idx>0 in the newest-first output) carrying a multi-line body with a
2032        // `Co-Authored-By:` trailer. The full body must survive — proving the
2033        // narrowed fetch path agrees with the metadata-only path on body
2034        // content, not just the subject.
2035        let tmp = tempfile::tempdir().unwrap();
2036        let dir = tmp.path();
2037        let run = |args: &[&str]| {
2038            assert!(
2039                anodizer_core::test_helpers::output_with_spawn_retry(
2040                    || {
2041                        let mut cmd = Command::new("git");
2042                        cmd.args(args)
2043                            .current_dir(dir)
2044                            .env("GIT_AUTHOR_NAME", "t")
2045                            .env("GIT_AUTHOR_EMAIL", "t@t.com")
2046                            .env("GIT_COMMITTER_NAME", "t")
2047                            .env("GIT_COMMITTER_EMAIL", "t@t.com");
2048                        cmd
2049                    },
2050                    "git",
2051                )
2052                .status
2053                .success()
2054            );
2055        };
2056        run(&["init"]);
2057        run(&["config", "user.email", "t@t.com"]);
2058        run(&["config", "user.name", "t"]);
2059        std::fs::write(dir.join("base"), "0").unwrap();
2060        run(&["add", "."]);
2061        run(&["commit", "-m", "initial"]);
2062        let base = get_head_commit_in(dir).unwrap();
2063
2064        // Older of the two reported commits — multi-line body + trailer.
2065        std::fs::write(dir.join("a.rs"), "1").unwrap();
2066        run(&["add", "."]);
2067        run(&[
2068            "commit",
2069            "-m",
2070            "feat: with body\n\nfirst body line\nsecond body line\n\nCo-Authored-By: Bob <bob@b.com>",
2071        ]);
2072        // Newer commit (idx 0 in newest-first output), single-line.
2073        std::fs::write(dir.join("b.rs"), "2").unwrap();
2074        run(&["add", "."]);
2075        run(&["commit", "-m", "fix: later"]);
2076
2077        let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
2078        assert_eq!(pairs.len(), 2);
2079        // Newest-first: [0] = "fix: later", [1] = "feat: with body" (idx>0).
2080        assert_eq!(pairs[0].commit.message, "fix: later");
2081        let body = &pairs[1].commit.body;
2082        assert!(
2083            body.contains("first body line") && body.contains("second body line"),
2084            "multi-line body truncated for idx>0 commit: {body:?}"
2085        );
2086        assert!(
2087            body.contains("Co-Authored-By: Bob <bob@b.com>"),
2088            "Co-Authored-By trailer dropped for idx>0 commit: {body:?}"
2089        );
2090    }
2091
2092    // ---- parse_commit_output: the single wire-format record decoder ----
2093
2094    #[test]
2095    fn parse_commit_output_empty_input_yields_no_commits() {
2096        assert!(parse_commit_output("").is_empty());
2097    }
2098
2099    #[test]
2100    fn parse_commit_output_decodes_all_six_fields() {
2101        // %H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e for a single commit.
2102        let raw =
2103            "abc123def\x1fabc123d\x1ffeat: add thing\x1fAlice\x1falice@x.com\x1fbody text\x1e";
2104        let commits = parse_commit_output(raw);
2105        assert_eq!(commits.len(), 1);
2106        let c = &commits[0];
2107        assert_eq!(c.hash, "abc123def");
2108        assert_eq!(c.short_hash, "abc123d");
2109        assert_eq!(c.message, "feat: add thing");
2110        assert_eq!(c.author_name, "Alice");
2111        assert_eq!(c.author_email, "alice@x.com");
2112        assert_eq!(c.body, "body text");
2113    }
2114
2115    #[test]
2116    fn parse_commit_output_trims_hash_and_body_but_keeps_inner_subject() {
2117        // Per the decoder: hash and body are trimmed; the subject (field 2)
2118        // is taken verbatim. A leading-newline body must come back trimmed.
2119        let raw = "  abc  \x1fabc\x1ffix: keep  spaces\x1ft\x1ft@t\x1f\n\nbody\n\x1e";
2120        let commits = parse_commit_output(raw);
2121        assert_eq!(commits.len(), 1);
2122        assert_eq!(commits[0].hash, "abc", "hash is trimmed");
2123        assert_eq!(commits[0].message, "fix: keep  spaces", "subject verbatim");
2124        assert_eq!(commits[0].body, "body", "body is trimmed");
2125    }
2126
2127    #[test]
2128    fn parse_commit_output_absent_body_field_defaults_to_empty() {
2129        // Exactly 5 fields (no %b segment) is still a valid record; body == "".
2130        let raw = "h\x1fh\x1fsubject\x1fname\x1fmail\x1e";
2131        let commits = parse_commit_output(raw);
2132        assert_eq!(commits.len(), 1);
2133        assert_eq!(commits[0].body, "");
2134        assert_eq!(commits[0].message, "subject");
2135    }
2136
2137    #[test]
2138    fn parse_commit_output_skips_records_with_too_few_fields() {
2139        // A record with <5 unit-separated fields is malformed and dropped,
2140        // while a well-formed sibling record in the same stream survives.
2141        let raw = "only\x1ftwo\x1e\
2142                   h\x1fh\x1fgood: subject\x1fn\x1fe\x1fbody\x1e";
2143        let commits = parse_commit_output(raw);
2144        assert_eq!(commits.len(), 1, "malformed record dropped, good one kept");
2145        assert_eq!(commits[0].message, "good: subject");
2146    }
2147
2148    #[test]
2149    fn parse_commit_output_multiline_body_survives_record_separator_split() {
2150        // Two commits separated by \x1e; the first body spans newlines and
2151        // carries a trailer — the \x1e (not \n) split keeps it intact.
2152        let raw = "h1\x1fh1\x1ffeat: A\x1fA\x1fa@x\x1fline one\nline two\n\nCo-Authored-By: B <b@x>\x1e\
2153                   h0\x1fh0\x1ffix: B\x1fB\x1fb@x\x1f\x1e";
2154        let commits = parse_commit_output(raw);
2155        assert_eq!(commits.len(), 2);
2156        assert_eq!(commits[0].message, "feat: A");
2157        assert!(commits[0].body.contains("line one\nline two"));
2158        assert!(commits[0].body.contains("Co-Authored-By: B <b@x>"));
2159        assert_eq!(commits[1].message, "fix: B");
2160        assert_eq!(commits[1].body, "");
2161    }
2162
2163    // ---- short_commit_str: pure SHA truncation ----
2164
2165    #[test]
2166    fn short_commit_str_truncates_long_sha_to_seven() {
2167        assert_eq!(short_commit_str("abcdef0123456789"), "abcdef0");
2168        assert_eq!(short_commit_str("abcdef0123456789").len(), SHORT_COMMIT_LEN);
2169    }
2170
2171    #[test]
2172    fn short_commit_str_returns_shorter_or_equal_input_unchanged() {
2173        assert_eq!(short_commit_str("abc"), "abc", "shorter than 7 unchanged");
2174        assert_eq!(
2175            short_commit_str("abcdefg"),
2176            "abcdefg",
2177            "exactly 7 unchanged"
2178        );
2179        assert_eq!(short_commit_str(""), "", "empty stays empty");
2180    }
2181
2182    // ---- real-repo fixture helpers for the shelling functions ----
2183
2184    /// Run a git command in `dir` with a pinned identity, asserting success.
2185    fn g(dir: &Path, args: &[&str]) {
2186        let out = anodizer_core::test_helpers::output_with_spawn_retry(
2187            || {
2188                let mut cmd = Command::new("git");
2189                cmd.args(args)
2190                    .current_dir(dir)
2191                    .env("GIT_AUTHOR_NAME", "Ada")
2192                    .env("GIT_AUTHOR_EMAIL", "ada@x.com")
2193                    .env("GIT_COMMITTER_NAME", "Ada")
2194                    .env("GIT_COMMITTER_EMAIL", "ada@x.com")
2195                    .env("GIT_AUTHOR_DATE", "1715000000 +0000")
2196                    .env("GIT_COMMITTER_DATE", "1715000000 +0000");
2197                cmd
2198            },
2199            "git",
2200        );
2201        assert!(
2202            out.status.success(),
2203            "git {args:?} failed: {}",
2204            String::from_utf8_lossy(&out.stderr)
2205        );
2206    }
2207
2208    /// `git init -b master` + identity config; no commits yet.
2209    fn init_bare_repo(dir: &Path) {
2210        g(dir, &["init", "-b", "master"]);
2211        g(dir, &["config", "user.email", "ada@x.com"]);
2212        g(dir, &["config", "user.name", "Ada"]);
2213    }
2214
2215    /// Write `path`=`content`, stage all, commit with `subject`.
2216    fn commit_file(dir: &Path, path: &str, content: &str, subject: &str) {
2217        let full = dir.join(path);
2218        if let Some(parent) = full.parent() {
2219            std::fs::create_dir_all(parent).unwrap();
2220        }
2221        std::fs::write(full, content).unwrap();
2222        g(dir, &["add", "."]);
2223        g(dir, &["commit", "-m", subject]);
2224    }
2225
2226    // ---- get_commits_between_in / paths variants ----
2227
2228    #[test]
2229    fn get_commits_between_in_returns_only_post_base_commits() {
2230        let tmp = tempfile::tempdir().unwrap();
2231        let dir = tmp.path();
2232        init_bare_repo(dir);
2233        commit_file(dir, "a", "0", "initial");
2234        let base = get_head_commit_in(dir).unwrap();
2235        commit_file(dir, "a", "1", "feat: one");
2236        commit_file(dir, "a", "2", "fix: two");
2237
2238        let commits = get_commits_between_in(dir, &base, "HEAD", None).unwrap();
2239        assert_eq!(commits.len(), 2, "two commits sit above base");
2240        // git log default is newest-first.
2241        assert_eq!(commits[0].message, "fix: two");
2242        assert_eq!(commits[1].message, "feat: one");
2243        assert_eq!(commits[1].author_name, "Ada");
2244        assert_eq!(commits[1].author_email, "ada@x.com");
2245    }
2246
2247    #[test]
2248    fn get_commits_between_in_path_filter_excludes_untouched_files() {
2249        let tmp = tempfile::tempdir().unwrap();
2250        let dir = tmp.path();
2251        init_bare_repo(dir);
2252        commit_file(dir, "base", "0", "initial");
2253        let base = get_head_commit_in(dir).unwrap();
2254        commit_file(dir, "src/lib.rs", "1", "feat: touch lib");
2255        commit_file(dir, "docs/readme", "2", "docs: touch docs only");
2256
2257        // Filter to src/ — only the lib commit should be reported.
2258        let commits = get_commits_between_in(dir, &base, "HEAD", Some("src")).unwrap();
2259        assert_eq!(commits.len(), 1, "only the src-touching commit survives");
2260        assert_eq!(commits[0].message, "feat: touch lib");
2261    }
2262
2263    #[test]
2264    fn get_commits_between_paths_in_unions_multiple_paths() {
2265        let tmp = tempfile::tempdir().unwrap();
2266        let dir = tmp.path();
2267        init_bare_repo(dir);
2268        commit_file(dir, "base", "0", "initial");
2269        let base = get_head_commit_in(dir).unwrap();
2270        commit_file(dir, "a/x", "1", "feat: a");
2271        commit_file(dir, "b/y", "2", "feat: b");
2272        commit_file(dir, "c/z", "3", "feat: c");
2273
2274        // Two paths -> union of commits touching either a/ or b/.
2275        let commits =
2276            get_commits_between_paths_in(dir, &base, "HEAD", &["a".into(), "b".into()]).unwrap();
2277        let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2278        assert_eq!(
2279            commits.len(),
2280            2,
2281            "a and b touched, c excluded: {subjects:?}"
2282        );
2283        assert!(subjects.contains(&"feat: a"));
2284        assert!(subjects.contains(&"feat: b"));
2285        assert!(!subjects.contains(&"feat: c"));
2286    }
2287
2288    // ---- get_all_commits_* ----
2289
2290    #[test]
2291    fn get_all_commits_in_returns_every_commit_on_head() {
2292        let tmp = tempfile::tempdir().unwrap();
2293        let dir = tmp.path();
2294        init_bare_repo(dir);
2295        commit_file(dir, "a", "0", "first");
2296        commit_file(dir, "a", "1", "second");
2297        commit_file(dir, "a", "2", "third");
2298
2299        let commits = get_all_commits_in(dir, None).unwrap();
2300        assert_eq!(commits.len(), 3);
2301        assert_eq!(commits[0].message, "third", "newest-first");
2302        assert_eq!(commits[2].message, "first");
2303    }
2304
2305    #[test]
2306    fn get_all_commits_paths_in_filters_to_path() {
2307        let tmp = tempfile::tempdir().unwrap();
2308        let dir = tmp.path();
2309        init_bare_repo(dir);
2310        commit_file(dir, "keep/x", "0", "feat: keep");
2311        commit_file(dir, "drop/y", "1", "feat: drop");
2312
2313        let commits = get_all_commits_paths_in(dir, &["keep".into()]).unwrap();
2314        assert_eq!(commits.len(), 1);
2315        assert_eq!(commits[0].message, "feat: keep");
2316    }
2317
2318    #[test]
2319    fn get_all_commits_paths_with_files_in_pairs_files() {
2320        let tmp = tempfile::tempdir().unwrap();
2321        let dir = tmp.path();
2322        init_bare_repo(dir);
2323        commit_file(dir, "crates/core/lib.rs", "0", "feat: core");
2324
2325        let pairs = get_all_commits_paths_with_files_in(dir, &[]).unwrap();
2326        assert_eq!(pairs.len(), 1);
2327        assert_eq!(pairs[0].commit.message, "feat: core");
2328        assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
2329    }
2330
2331    // ---- get_commits_reachable_paths_in: bound at an explicit ref ----
2332
2333    #[test]
2334    fn get_commits_reachable_paths_in_stops_at_the_given_rev() {
2335        let tmp = tempfile::tempdir().unwrap();
2336        let dir = tmp.path();
2337        init_bare_repo(dir);
2338        commit_file(dir, "a", "0", "first");
2339        commit_file(dir, "a", "1", "second");
2340        let mid = get_head_commit_in(dir).unwrap();
2341        commit_file(dir, "a", "2", "third-after-mid");
2342
2343        // Reachable from `mid` excludes the commit made after it.
2344        let commits = get_commits_reachable_paths_in(dir, &mid, &[]).unwrap();
2345        let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
2346        assert_eq!(commits.len(), 2, "only ancestors of mid: {subjects:?}");
2347        assert!(subjects.contains(&"first"));
2348        assert!(subjects.contains(&"second"));
2349        assert!(!subjects.contains(&"third-after-mid"));
2350    }
2351
2352    #[test]
2353    fn get_commits_reachable_paths_with_files_in_pairs_touched_files() {
2354        let tmp = tempfile::tempdir().unwrap();
2355        let dir = tmp.path();
2356        init_bare_repo(dir);
2357        commit_file(dir, "src/main.rs", "0", "feat: main");
2358        let head = get_head_commit_in(dir).unwrap();
2359
2360        let pairs = get_commits_reachable_paths_with_files_in(dir, &head, &[]).unwrap();
2361        assert_eq!(pairs.len(), 1);
2362        assert_eq!(pairs[0].commit.message, "feat: main");
2363        assert_eq!(pairs[0].files, vec!["src/main.rs".to_string()]);
2364    }
2365
2366    // ---- subject-only message helpers ----
2367
2368    #[test]
2369    fn get_last_commit_messages_in_returns_n_subjects_newest_first() {
2370        let tmp = tempfile::tempdir().unwrap();
2371        let dir = tmp.path();
2372        init_bare_repo(dir);
2373        commit_file(dir, "a", "0", "one");
2374        commit_file(dir, "a", "1", "two");
2375        commit_file(dir, "a", "2", "three");
2376
2377        let msgs = get_last_commit_messages_in(dir, 2).unwrap();
2378        assert_eq!(msgs, vec!["three".to_string(), "two".to_string()]);
2379    }
2380
2381    #[test]
2382    fn get_commit_messages_between_in_lists_post_base_subjects() {
2383        let tmp = tempfile::tempdir().unwrap();
2384        let dir = tmp.path();
2385        init_bare_repo(dir);
2386        commit_file(dir, "a", "0", "initial");
2387        let base = get_head_commit_in(dir).unwrap();
2388        commit_file(dir, "a", "1", "feat: x");
2389        commit_file(dir, "a", "2", "fix: y");
2390
2391        let msgs = get_commit_messages_between_in(dir, &base, "HEAD").unwrap();
2392        assert_eq!(msgs, vec!["fix: y".to_string(), "feat: x".to_string()]);
2393    }
2394
2395    #[test]
2396    fn get_last_commit_messages_path_in_filters_to_path() {
2397        let tmp = tempfile::tempdir().unwrap();
2398        let dir = tmp.path();
2399        init_bare_repo(dir);
2400        commit_file(dir, "keep/a", "0", "feat: keep");
2401        commit_file(dir, "other/b", "1", "feat: other");
2402
2403        let msgs = get_last_commit_messages_path_in(dir, 10, "keep").unwrap();
2404        assert_eq!(msgs, vec!["feat: keep".to_string()]);
2405    }
2406
2407    #[test]
2408    fn get_commit_messages_between_path_in_filters_range_and_path() {
2409        let tmp = tempfile::tempdir().unwrap();
2410        let dir = tmp.path();
2411        init_bare_repo(dir);
2412        commit_file(dir, "base", "0", "initial");
2413        let base = get_head_commit_in(dir).unwrap();
2414        commit_file(dir, "src/x", "1", "feat: src");
2415        commit_file(dir, "doc/y", "2", "docs: doc");
2416
2417        let msgs = get_commit_messages_between_path_in(dir, &base, "HEAD", "src").unwrap();
2418        assert_eq!(msgs, vec!["feat: src".to_string()]);
2419    }
2420
2421    // ---- diff / change-detection helpers ----
2422
2423    #[test]
2424    fn has_changes_since_in_detects_path_touched_after_tag() {
2425        let tmp = tempfile::tempdir().unwrap();
2426        let dir = tmp.path();
2427        init_bare_repo(dir);
2428        commit_file(dir, "watched", "0", "initial");
2429        g(dir, &["tag", "v1.0.0"]);
2430        // No change yet -> false.
2431        assert!(!has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2432        commit_file(dir, "watched", "1", "feat: change watched");
2433        // Now changed -> true.
2434        assert!(has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
2435        // A different, untouched path -> false.
2436        assert!(!has_changes_since_in(dir, "v1.0.0", "unrelated").unwrap());
2437    }
2438
2439    #[test]
2440    fn paths_changed_since_tag_in_true_when_any_path_changed() {
2441        let tmp = tempfile::tempdir().unwrap();
2442        let dir = tmp.path();
2443        init_bare_repo(dir);
2444        commit_file(dir, "a", "0", "initial");
2445        g(dir, &["tag", "v1.0.0"]);
2446        commit_file(dir, "b", "1", "feat: add b");
2447
2448        // b changed; checking [a, b] -> true (b matched).
2449        assert!(paths_changed_since_tag_in(dir, "v1.0.0", &["a", "b"]).unwrap());
2450        // Only a (unchanged) -> false.
2451        assert!(!paths_changed_since_tag_in(dir, "v1.0.0", &["a"]).unwrap());
2452    }
2453
2454    #[test]
2455    fn paths_changed_since_tag_in_returns_false_when_git_fails() {
2456        // Non-existent tag makes `git diff` fail; the helper maps that to
2457        // Ok(false) rather than bubbling an error.
2458        let tmp = tempfile::tempdir().unwrap();
2459        let dir = tmp.path();
2460        init_bare_repo(dir);
2461        commit_file(dir, "a", "0", "initial");
2462        assert!(!paths_changed_since_tag_in(dir, "nope-no-such-tag", &["a"]).unwrap());
2463    }
2464
2465    // ---- rev resolution helpers ----
2466
2467    #[test]
2468    fn head_commit_hash_in_matches_rev_parse_head() {
2469        let tmp = tempfile::tempdir().unwrap();
2470        let dir = tmp.path();
2471        init_bare_repo(dir);
2472        commit_file(dir, "a", "0", "initial");
2473        let expected = get_head_commit_in(dir).unwrap();
2474        assert_eq!(head_commit_hash_in(dir).unwrap(), expected);
2475    }
2476
2477    #[test]
2478    fn head_commit_hash_in_errors_on_non_repo() {
2479        let tmp = tempfile::tempdir().unwrap();
2480        // No git init -> rev-parse HEAD fails.
2481        assert!(head_commit_hash_in(tmp.path()).is_err());
2482    }
2483
2484    #[test]
2485    fn rev_parse_in_resolves_branch_to_full_sha() {
2486        let tmp = tempfile::tempdir().unwrap();
2487        let dir = tmp.path();
2488        init_bare_repo(dir);
2489        commit_file(dir, "a", "0", "initial");
2490        let head = get_head_commit_in(dir).unwrap();
2491        assert_eq!(rev_parse_in(dir, "master").unwrap(), head);
2492    }
2493
2494    #[test]
2495    fn rev_verify_commit_in_accepts_commit_rejects_unknown() {
2496        let tmp = tempfile::tempdir().unwrap();
2497        let dir = tmp.path();
2498        init_bare_repo(dir);
2499        commit_file(dir, "a", "0", "initial");
2500        let head = get_head_commit_in(dir).unwrap();
2501        assert_eq!(rev_verify_commit_in(dir, "HEAD").unwrap(), head);
2502        // A made-up ref must not verify.
2503        assert!(rev_verify_commit_in(dir, "deadbeefdeadbeef").is_err());
2504    }
2505
2506    #[test]
2507    fn commits_between_in_lists_shas_above_base_and_empty_at_head() {
2508        let tmp = tempfile::tempdir().unwrap();
2509        let dir = tmp.path();
2510        init_bare_repo(dir);
2511        commit_file(dir, "a", "0", "initial");
2512        let base = get_head_commit_in(dir).unwrap();
2513        commit_file(dir, "a", "1", "second");
2514        let head = get_head_commit_in(dir).unwrap();
2515
2516        let shas = commits_between_in(dir, &base).unwrap();
2517        assert_eq!(
2518            shas,
2519            vec![head.clone()],
2520            "exactly the one commit above base"
2521        );
2522        // sha IS HEAD -> empty range.
2523        assert!(commits_between_in(dir, &head).unwrap().is_empty());
2524    }
2525
2526    #[test]
2527    fn commit_subject_in_returns_single_commit_subject() {
2528        let tmp = tempfile::tempdir().unwrap();
2529        let dir = tmp.path();
2530        init_bare_repo(dir);
2531        commit_file(dir, "a", "0", "feat: only-subject\n\nignored body");
2532        let head = get_head_commit_in(dir).unwrap();
2533        assert_eq!(commit_subject_in(dir, &head).unwrap(), "feat: only-subject");
2534    }
2535
2536    #[test]
2537    fn head_commit_timestamp_in_returns_pinned_committer_epoch() {
2538        let tmp = tempfile::tempdir().unwrap();
2539        let dir = tmp.path();
2540        init_bare_repo(dir);
2541        // Pinned GIT_COMMITTER_DATE in `g` is 1715000000 +0000.
2542        commit_file(dir, "a", "0", "initial");
2543        assert_eq!(head_commit_timestamp_in(dir).unwrap(), 1_715_000_000);
2544    }
2545
2546    // ---- log_subjects_for_range ----
2547
2548    #[test]
2549    fn log_subjects_for_range_returns_full_bodies_for_path() {
2550        let tmp = tempfile::tempdir().unwrap();
2551        let dir = tmp.path();
2552        init_bare_repo(dir);
2553        commit_file(dir, "watched", "0", "feat: A\n\nbody of A");
2554        commit_file(dir, "watched", "1", "fix: B");
2555
2556        let bodies = log_subjects_for_range(dir, "HEAD", "watched").unwrap();
2557        assert_eq!(bodies.len(), 2);
2558        // %B is subject+body; newest-first.
2559        assert!(bodies[0].starts_with("fix: B"));
2560        assert!(bodies[1].contains("feat: A") && bodies[1].contains("body of A"));
2561    }
2562
2563    #[test]
2564    fn log_subjects_for_range_returns_empty_when_range_invalid() {
2565        let tmp = tempfile::tempdir().unwrap();
2566        let dir = tmp.path();
2567        init_bare_repo(dir);
2568        commit_file(dir, "a", "0", "initial");
2569        // A range referencing a non-existent ref makes git fail; the helper
2570        // maps that to an empty Vec, not an error.
2571        let bodies = log_subjects_for_range(dir, "no-such-ref..HEAD", "a").unwrap();
2572        assert!(bodies.is_empty());
2573    }
2574
2575    /// A pathspec fatal (empty pathspec) is a REAL failure, not an empty
2576    /// history — collapsing it into `Ok(vec![])` made `bump` preview Skip
2577    /// for root-level crates.
2578    #[test]
2579    fn log_subjects_for_range_propagates_pathspec_fatal() {
2580        let tmp = tempfile::tempdir().unwrap();
2581        let dir = tmp.path();
2582        init_bare_repo(dir);
2583        commit_file(dir, "a", "0", "initial");
2584        let err = log_subjects_for_range(dir, "HEAD", "")
2585            .expect_err("empty pathspec must be an error, not empty success")
2586            .to_string();
2587        assert!(err.contains("git log HEAD failed"), "{err}");
2588    }
2589
2590    // ---- add_path_in + commit_in ----
2591
2592    #[test]
2593    fn add_path_in_then_commit_in_creates_commit() {
2594        let tmp = tempfile::tempdir().unwrap();
2595        let dir = tmp.path();
2596        init_bare_repo(dir);
2597        commit_file(dir, "seed", "0", "initial");
2598        std::fs::write(dir.join("new.txt"), "hello").unwrap();
2599
2600        add_path_in(dir, std::path::Path::new("new.txt")).unwrap();
2601        commit_in(dir, "feat: add new.txt", false).unwrap();
2602
2603        let subject = String::from_utf8(
2604            anodizer_core::test_helpers::output_with_spawn_retry(
2605                || {
2606                    let mut cmd = Command::new("git");
2607                    cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
2608                    cmd
2609                },
2610                "git",
2611            )
2612            .stdout,
2613        )
2614        .unwrap()
2615        .trim()
2616        .to_string();
2617        assert_eq!(subject, "feat: add new.txt");
2618    }
2619
2620    #[test]
2621    fn add_path_in_errors_on_missing_file() {
2622        let tmp = tempfile::tempdir().unwrap();
2623        let dir = tmp.path();
2624        init_bare_repo(dir);
2625        let err = add_path_in(dir, std::path::Path::new("does-not-exist")).unwrap_err();
2626        assert!(
2627            err.to_string().contains("git add"),
2628            "error must name the failing git add: {err}"
2629        );
2630    }
2631
2632    // ---- reset_hard_in ----
2633
2634    #[test]
2635    fn reset_hard_in_moves_head_and_restores_tree() {
2636        let tmp = tempfile::tempdir().unwrap();
2637        let dir = tmp.path();
2638        init_bare_repo(dir);
2639        commit_file(dir, "a", "first", "first");
2640        let target = get_head_commit_in(dir).unwrap();
2641        commit_file(dir, "a", "second", "second");
2642        assert_ne!(get_head_commit_in(dir).unwrap(), target);
2643
2644        reset_hard_in(dir, &target).unwrap();
2645        assert_eq!(get_head_commit_in(dir).unwrap(), target, "HEAD moved back");
2646        assert_eq!(
2647            std::fs::read_to_string(dir.join("a")).unwrap(),
2648            "first",
2649            "working tree restored to target content"
2650        );
2651    }
2652
2653    // ---- push_branch_in error path (no remote) ----
2654
2655    #[test]
2656    fn push_branch_in_bails_without_origin_remote() {
2657        let tmp = tempfile::tempdir().unwrap();
2658        let dir = tmp.path();
2659        init_bare_repo(dir);
2660        commit_file(dir, "a", "0", "initial");
2661        let err = push_branch_in(dir, "master").unwrap_err();
2662        assert!(
2663            err.to_string().contains("no 'origin' remote"),
2664            "missing-remote bail must be explicit: {err}"
2665        );
2666    }
2667
2668    // ---- resolve_rollback_identity / read_git_identity ----
2669
2670    #[test]
2671    #[serial_test::serial(git_env)]
2672    fn resolve_rollback_identity_inherits_when_repo_has_identity() {
2673        let tmp = tempfile::tempdir().unwrap();
2674        let dir = tmp.path();
2675        init_bare_repo(dir); // sets user.name + user.email
2676
2677        // Clear any inherited GIT_AUTHOR_*/COMMITTER_* env so the resolver
2678        // falls through to reading the repo config (which IS configured).
2679        struct EnvGuard(Vec<(&'static str, Option<String>)>);
2680        impl Drop for EnvGuard {
2681            fn drop(&mut self) {
2682                for (k, v) in &self.0 {
2683                    match v {
2684                        // env-ok: restore/clear inside #[serial(git_env)] test; no concurrent reader
2685                        Some(val) => unsafe { std::env::set_var(k, val) },
2686                        // env-ok: restore/clear inside #[serial(git_env)] test; no concurrent reader
2687                        None => unsafe { std::env::remove_var(k) },
2688                    }
2689                }
2690            }
2691        }
2692        let keys = [
2693            "GIT_AUTHOR_NAME",
2694            "GIT_AUTHOR_EMAIL",
2695            "GIT_COMMITTER_NAME",
2696            "GIT_COMMITTER_EMAIL",
2697        ];
2698        let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2699        for k in keys {
2700            // env-ok: restore/clear inside #[serial(git_env)] test; no concurrent reader
2701            unsafe { std::env::remove_var(k) };
2702        }
2703
2704        // Repo has user.name + user.email -> inherit (empty identity).
2705        let id = resolve_rollback_identity(dir);
2706        assert!(
2707            id.name.is_none() && id.email.is_none(),
2708            "configured repo identity must be inherited, not overridden: {id:?}"
2709        );
2710    }
2711
2712    #[test]
2713    #[serial_test::serial(git_env)]
2714    fn resolve_rollback_identity_synthesizes_when_no_identity_anywhere() {
2715        let tmp = tempfile::tempdir().unwrap();
2716        let dir = tmp.path();
2717        // init WITHOUT configuring user.name / user.email.
2718        g(dir, &["init", "-b", "master"]);
2719
2720        struct EnvGuard(Vec<(&'static str, Option<String>)>);
2721        impl Drop for EnvGuard {
2722            fn drop(&mut self) {
2723                for (k, v) in &self.0 {
2724                    match v {
2725                        // env-ok: restore/clear inside #[serial(git_env)] test; no concurrent reader
2726                        Some(val) => unsafe { std::env::set_var(k, val) },
2727                        // env-ok: restore/clear inside #[serial(git_env)] test; no concurrent reader
2728                        None => unsafe { std::env::remove_var(k) },
2729                    }
2730                }
2731            }
2732        }
2733        let keys = [
2734            "GIT_AUTHOR_NAME",
2735            "GIT_AUTHOR_EMAIL",
2736            "GIT_COMMITTER_NAME",
2737            "GIT_COMMITTER_EMAIL",
2738        ];
2739        let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
2740        for k in keys {
2741            // env-ok: restore/clear inside #[serial(git_env)] test; no concurrent reader
2742            unsafe { std::env::remove_var(k) };
2743        }
2744
2745        // Best-effort: global git config may still supply an identity on the
2746        // host. Only assert the synthetic path when the repo truly has none.
2747        let (n, e) = read_git_identity(dir);
2748        if n.is_none() || e.is_none() {
2749            let id = resolve_rollback_identity(dir);
2750            assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
2751            assert!(
2752                id.email
2753                    .as_deref()
2754                    .unwrap_or("")
2755                    .starts_with("anodize-rollback@"),
2756                "synthetic identity required when no config present: {id:?}"
2757            );
2758        }
2759    }
2760
2761    #[test]
2762    fn read_git_identity_reads_configured_values() {
2763        let tmp = tempfile::tempdir().unwrap();
2764        let dir = tmp.path();
2765        g(dir, &["init", "-b", "master"]);
2766        g(dir, &["config", "user.name", "Configured Name"]);
2767        g(dir, &["config", "user.email", "configured@x.com"]);
2768
2769        let (name, email) = read_git_identity(dir);
2770        assert_eq!(name.as_deref(), Some("Configured Name"));
2771        assert_eq!(email.as_deref(), Some("configured@x.com"));
2772    }
2773}