Skip to main content

anodizer_core/git/
commits.rs

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