Skip to main content

anodizer_core/git/
commits.rs

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