Skip to main content

anodizer_core/git/
commits.rs

1use anyhow::{Context as _, Result, bail};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use super::git_output_in;
6
7#[derive(Debug, Clone)]
8pub struct Commit {
9    pub hash: String,
10    pub short_hash: String,
11    pub message: String,
12    pub author_name: String,
13    pub author_email: String,
14    /// Full commit message body (everything after the subject line).
15    /// Contains trailers like `Co-Authored-By:`.
16    pub body: String,
17}
18
19/// Parse git log output (formatted as `%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e`)
20/// into a vec of [`Commit`]s.
21///
22/// Uses ASCII record separator (0x1e) between commits and unit separator (0x1f)
23/// between fields, so multi-line body text doesn't break parsing.
24///
25/// The single record decoder for this wire format: every changelog path
26/// (`parse_commit_output_with_files` here, and the changelog stage's git
27/// fetch) decodes through this function so the body / author fields can never
28/// drift between call sites.
29pub fn parse_commit_output(output: &str) -> Vec<Commit> {
30    if output.is_empty() {
31        return vec![];
32    }
33    output
34        .split('\x1e')
35        .filter(|record| !record.trim().is_empty())
36        .filter_map(|record| {
37            let fields: Vec<&str> = record.split('\x1f').collect();
38            if fields.len() >= 5 {
39                Some(Commit {
40                    hash: fields[0].trim().to_string(),
41                    short_hash: fields[1].to_string(),
42                    message: fields[2].to_string(),
43                    author_name: fields[3].to_string(),
44                    author_email: fields[4].to_string(),
45                    body: fields.get(5).unwrap_or(&"").trim().to_string(),
46                })
47            } else {
48                None
49            }
50        })
51        .collect()
52}
53
54fn cwd_or_dot() -> PathBuf {
55    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
56}
57
58/// Get commits between two refs, optionally filtered to a path.
59pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
60    get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
61}
62
63/// Path-taking sibling of [`get_commits_between`].
64pub fn get_commits_between_in(
65    cwd: &Path,
66    from: &str,
67    to: &str,
68    path_filter: Option<&str>,
69) -> Result<Vec<Commit>> {
70    get_commits_between_paths_in(
71        cwd,
72        from,
73        to,
74        &path_filter
75            .into_iter()
76            .map(String::from)
77            .collect::<Vec<_>>(),
78    )
79}
80
81/// Get commits between two refs, filtered to multiple paths (git log -- path1 path2 ...).
82pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
83    get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
84}
85
86/// Path-taking sibling of [`get_commits_between_paths`].
87pub fn get_commits_between_paths_in(
88    cwd: &Path,
89    from: &str,
90    to: &str,
91    paths: &[String],
92) -> Result<Vec<Commit>> {
93    let range = format!("{}..{}", from, to);
94    let mut args = vec![
95        "-c".to_string(),
96        "log.showSignature=false".to_string(),
97        "log".to_string(),
98        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
99        range,
100    ];
101    if !paths.is_empty() {
102        args.push("--".to_string());
103        for p in paths {
104            args.push(p.clone());
105        }
106    }
107    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
108    let output = git_output_in(cwd, &arg_refs)?;
109    Ok(parse_commit_output(&output))
110}
111
112/// Get all commits reachable from HEAD, optionally filtered to a path.
113/// Used for initial releases where there is no previous tag.
114pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
115    get_all_commits_in(&cwd_or_dot(), path_filter)
116}
117
118/// Path-taking sibling of [`get_all_commits`].
119pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
120    get_all_commits_paths_in(
121        cwd,
122        &path_filter
123            .into_iter()
124            .map(String::from)
125            .collect::<Vec<_>>(),
126    )
127}
128
129/// Get all commits reachable from HEAD, filtered to multiple paths.
130pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
131    get_all_commits_paths_in(&cwd_or_dot(), paths)
132}
133
134/// Path-taking sibling of [`get_all_commits_paths`].
135pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
136    let mut args = vec![
137        "-c".to_string(),
138        "log.showSignature=false".to_string(),
139        "log".to_string(),
140        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
141        "HEAD".to_string(),
142    ];
143    if !paths.is_empty() {
144        args.push("--".to_string());
145        for p in paths {
146            args.push(p.clone());
147        }
148    }
149    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
150    let output = git_output_in(cwd, &arg_refs)?;
151    Ok(parse_commit_output(&output))
152}
153
154/// A commit paired with the workspace-relative paths it touched.
155///
156/// Produced by the `--name-only` fetch variants so the changelog renderers can
157/// apply a precise `changelog.paths` glob intersect over the git-pathspec
158/// scope (see [`crate::changelog_scope`]).
159#[derive(Debug, Clone)]
160pub struct CommitWithFiles {
161    /// The commit metadata.
162    pub commit: Commit,
163    /// Paths this commit touched, relative to the repo root.
164    pub files: Vec<String>,
165}
166
167/// Parse `git log --name-only` output (metadata formatted as
168/// `%H%x1f...%b%x1e`, followed by one touched-file path per line) into
169/// [`CommitWithFiles`].
170///
171/// git emits each commit as `<metadata>\x1e\n<file>\n<file>\n\n` (the touched
172/// files follow the `%x1e`-terminated metadata, then a blank line). Splitting
173/// on `\x1e` yields `[metadata_0, "\n<files_0>\n\n<metadata_1>", ...]`: the
174/// file block trailing each record up to the next metadata belongs to THAT
175/// record's commit.
176///
177/// The metadata record is multi-line because `%b` (the commit body) carries
178/// newlines, so the record runs from the first `\x1f`-bearing line through the
179/// end of the segment — NOT just the first matching line. Truncating to one
180/// line would drop body trailers (e.g. `Co-Authored-By:`) for every commit
181/// after the first, diverging from the full-body parse the changelog stage's
182/// `parse_git_log_records` performs.
183pub fn parse_commit_output_with_files(output: &str) -> Vec<CommitWithFiles> {
184    if output.is_empty() {
185        return vec![];
186    }
187    let segments: Vec<&str> = output.split('\x1e').collect();
188    let mut out: Vec<CommitWithFiles> = Vec::new();
189    // segments[i] for i>0 begins with the file block of commit i-1 followed by
190    // the metadata of commit i. The first segment is pure metadata (commit 0);
191    // the last segment is the file block of the final commit (no trailing
192    // metadata). Walk pairwise: metadata from this segment, files from the
193    // NEXT segment's leading lines (before its own metadata's first field).
194    for (idx, seg) in segments.iter().enumerate() {
195        // The metadata of commit `idx` is the part of `seg` AFTER the leading
196        // file block (file block present only for idx>0). For idx==0 the whole
197        // segment is metadata. For idx>0 the metadata record begins at the
198        // first `\x1f`-bearing line and continues to the segment end (a
199        // multi-line `%b` body keeps emitting newline-separated lines after the
200        // unit-separator fields), so the remainder is kept verbatim — joined
201        // from that line onward — rather than just the first matching line.
202        let metadata = if idx == 0 {
203            seg.trim_start_matches(['\n', '\r']).to_string()
204        } else {
205            let lines: Vec<&str> = seg.split('\n').collect();
206            match lines.iter().position(|line| line.contains('\x1f')) {
207                Some(start) => lines[start..].join("\n"),
208                None => String::new(),
209            }
210        };
211        if metadata.trim().is_empty() {
212            continue;
213        }
214        let commits = parse_commit_output(&metadata);
215        let Some(commit) = commits.into_iter().next() else {
216            continue;
217        };
218        // Files for THIS commit are the leading lines of the NEXT segment,
219        // before that segment's own metadata line.
220        let files = match segments.get(idx + 1) {
221            Some(next) => next
222                .split('\n')
223                .map(str::trim)
224                .take_while(|line| !line.contains('\x1f'))
225                .filter(|line| !line.is_empty())
226                .map(str::to_string)
227                .collect(),
228            None => Vec::new(),
229        };
230        out.push(CommitWithFiles { commit, files });
231    }
232    out
233}
234
235/// `--name-only` sibling of [`get_commits_between_paths_in`]: each commit is
236/// paired with the repo-relative paths it touched, for a precise
237/// `changelog.paths` glob intersect over the git-pathspec scope.
238pub fn get_commits_between_paths_with_files_in(
239    cwd: &Path,
240    from: &str,
241    to: &str,
242    paths: &[String],
243) -> Result<Vec<CommitWithFiles>> {
244    let range = format!("{}..{}", from, to);
245    let mut args = vec![
246        "-c".to_string(),
247        "log.showSignature=false".to_string(),
248        "log".to_string(),
249        "--name-only".to_string(),
250        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
251        range,
252    ];
253    if !paths.is_empty() {
254        args.push("--".to_string());
255        for p in paths {
256            args.push(p.clone());
257        }
258    }
259    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
260    let output = git_output_in(cwd, &arg_refs)?;
261    Ok(parse_commit_output_with_files(&output))
262}
263
264/// `--name-only` sibling of [`get_all_commits_paths_in`].
265pub fn get_all_commits_paths_with_files_in(
266    cwd: &Path,
267    paths: &[String],
268) -> Result<Vec<CommitWithFiles>> {
269    let mut args = vec![
270        "-c".to_string(),
271        "log.showSignature=false".to_string(),
272        "log".to_string(),
273        "--name-only".to_string(),
274        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
275        "HEAD".to_string(),
276    ];
277    if !paths.is_empty() {
278        args.push("--".to_string());
279        for p in paths {
280            args.push(p.clone());
281        }
282    }
283    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
284    let output = git_output_in(cwd, &arg_refs)?;
285    Ok(parse_commit_output_with_files(&output))
286}
287
288/// All commits reachable from an arbitrary `rev` (not just `HEAD`), filtered to
289/// `paths`. Used by the changelog stage to bound a no-lower-bound range at an
290/// explicit upper ref (`changelog ..<tag>`): the range is then every ancestor
291/// of `<tag>`, excluding commits made after it.
292pub fn get_commits_reachable_paths_in(
293    cwd: &Path,
294    rev: &str,
295    paths: &[String],
296) -> Result<Vec<Commit>> {
297    let mut args = vec![
298        "-c".to_string(),
299        "log.showSignature=false".to_string(),
300        "log".to_string(),
301        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
302        rev.to_string(),
303    ];
304    if !paths.is_empty() {
305        args.push("--".to_string());
306        for p in paths {
307            args.push(p.clone());
308        }
309    }
310    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
311    let output = git_output_in(cwd, &arg_refs)?;
312    Ok(parse_commit_output(&output))
313}
314
315/// `--name-only` sibling of [`get_commits_reachable_paths_in`].
316pub fn get_commits_reachable_paths_with_files_in(
317    cwd: &Path,
318    rev: &str,
319    paths: &[String],
320) -> Result<Vec<CommitWithFiles>> {
321    let mut args = vec![
322        "-c".to_string(),
323        "log.showSignature=false".to_string(),
324        "log".to_string(),
325        "--name-only".to_string(),
326        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
327        rev.to_string(),
328    ];
329    if !paths.is_empty() {
330        args.push("--".to_string());
331        for p in paths {
332            args.push(p.clone());
333        }
334    }
335    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
336    let output = git_output_in(cwd, &arg_refs)?;
337    Ok(parse_commit_output_with_files(&output))
338}
339
340/// Get last N commit subjects.
341pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
342    get_last_commit_messages_in(&cwd_or_dot(), count)
343}
344
345/// Path-taking sibling of [`get_last_commit_messages`].
346pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
347    let output = git_output_in(
348        cwd,
349        &[
350            "-c",
351            "log.showSignature=false",
352            "log",
353            &format!("-{count}"),
354            "--pretty=format:%s",
355        ],
356    )?;
357    Ok(output.lines().map(str::to_string).collect())
358}
359
360/// Get commit subjects between two refs.
361pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
362    get_commit_messages_between_in(&cwd_or_dot(), from, to)
363}
364
365/// Path-taking sibling of [`get_commit_messages_between`].
366pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
367    let output = git_output_in(
368        cwd,
369        &[
370            "-c",
371            "log.showSignature=false",
372            "log",
373            "--pretty=format:%s",
374            &format!("{from}..{to}"),
375        ],
376    )?;
377    Ok(output.lines().map(str::to_string).collect())
378}
379
380/// Get the current branch name.
381pub fn get_current_branch() -> Result<String> {
382    get_current_branch_in(&cwd_or_dot())
383}
384
385/// Return `true` when `name` looks like a branch (NOT an anodize-shaped
386/// release tag). Tag shapes: `^v\d+\.\d+\.\d+` (lockstep
387/// `v1.2.3[-pre][+build]`) or `^<crate>-v\d+\.\d+\.\d+`
388/// (per-crate `mycrate-v1.2.3[...]`).
389///
390/// Both regexes are start-anchored, and the per-crate `<crate>` segment is
391/// constrained to non-`/` characters. Without that, a branch like
392/// `feature/fix-v2.0.0` contains `-v2.0.0` and would be misclassified as a
393/// tag — leaving its `GITHUB_REF_NAME` fallback rejected. A real per-crate
394/// tag's name prefix is a crate name (no path separators), so anchoring on
395/// `^[^/]+-v` keeps that branch shape branch-like while still matching
396/// `mycrate-v1.2.3`.
397///
398/// Guards the `GITHUB_REF_NAME` fallback in [`get_current_branch_in`]: on
399/// a `push: tags:` workflow trigger, `GITHUB_REF_NAME` is the TAG name
400/// (e.g. `v0.4.5`), and accepting it would make `git push origin v0.4.5`
401/// from detached HEAD silently create a branch named after the tag.
402///
403/// Drift-risk pair with `cli::commands::tag::rollback`'s `LOCKSTEP_TAG_RE` /
404/// `PER_CRATE_TAG_RE`: those classify the same two anodize tag shapes but
405/// are fully anchored and strict (a rollback must touch only real tags).
406/// The patterns here are deliberately looser and prefix-only (branch-vs-tag
407/// disambiguation, not classification). Keep both in sync when the tag
408/// grammar changes — they are intentionally separate, not duplicated.
409pub fn is_branchlike(name: &str) -> bool {
410    use regex::Regex;
411    use std::sync::OnceLock;
412    static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
413    static PER_CRATE: OnceLock<Regex> = OnceLock::new();
414    let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
415    let per_crate =
416        PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
417    !(lockstep.is_match(name) || per_crate.is_match(name))
418}
419
420/// Path-taking sibling of [`get_current_branch`].
421///
422/// Handles detached-HEAD checkouts (e.g. `actions/checkout@v4` with `ref:`)
423/// by resolving the branch HEAD points at via `for-each-ref`, falling back
424/// to the remote's default branch and finally `GITHUB_REF_NAME` when set —
425/// so downstream `git push origin <branch>` produces a valid refspec
426/// instead of a literal `HEAD` that git can't auto-qualify.
427///
428/// The `GITHUB_REF_NAME` fallback is guarded by [`is_branchlike`]: on a
429/// `push: tags:` trigger, `GITHUB_REF_NAME` is the TAG name, and accepting
430/// it would push to a branch named after the tag. Tag-shaped values fall
431/// through to the bail at the end so callers hard-fail and prompt the
432/// operator for `--branch <name>` explicitly.
433pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
434    if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
435        return Ok(name);
436    }
437    if let Ok(out) = git_output_in(
438        cwd,
439        &[
440            "for-each-ref",
441            "--points-at",
442            "HEAD",
443            "--format=%(refname:short)",
444            "refs/heads/",
445        ],
446    ) && !out.is_empty()
447    {
448        let branches: Vec<&str> = out.lines().collect();
449        for preferred in ["master", "main"] {
450            if branches.contains(&preferred) {
451                return Ok(preferred.to_string());
452            }
453        }
454        if let Some(first) = branches.first() {
455            return Ok((*first).to_string());
456        }
457    }
458    if let Ok(out) = git_output_in(
459        cwd,
460        &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
461    ) && let Some(name) = out.strip_prefix("origin/")
462    {
463        return Ok(name.to_string());
464    }
465    if let Ok(name) = std::env::var("GITHUB_REF_NAME")
466        && !name.is_empty()
467        && is_branchlike(&name)
468    {
469        return Ok(name);
470    }
471    anyhow::bail!(
472        "could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
473    )
474}
475
476/// Return remote branch short names that contain `sha` (e.g. `master`,
477/// `release/v1`). The bump commit's SHA is the deterministic anchor of
478/// the tag, so deriving the push branch from it is race-immune to the
479/// default branch moving between bump and rollback. Empty `Vec` when
480/// the SHA is not on any remote branch (orphan / not-yet-pushed).
481pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
482    let out = git_output_in(
483        cwd,
484        &[
485            "branch",
486            "-r",
487            "--contains",
488            sha,
489            "--format=%(refname:short)",
490        ],
491    )?;
492    Ok(out
493        .lines()
494        .filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
495        .filter(|name| !name.is_empty() && name != "HEAD")
496        .collect())
497}
498
499/// Check if there are any commits since a given tag.
500pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
501    has_commits_since_tag_in(&cwd_or_dot(), tag)
502}
503
504/// Path-taking sibling of [`has_commits_since_tag`].
505pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
506    let range = format!("{}..HEAD", tag);
507    let output = git_output_in(
508        cwd,
509        &["-c", "log.showSignature=false", "log", "--oneline", &range],
510    )?;
511    Ok(!output.is_empty())
512}
513
514/// Count the commits on HEAD since the most recent reachable tag.
515///
516/// Resolves the last tag with `git describe --tags --abbrev=0 HEAD`, then
517/// returns `git rev-list --count <tag>..HEAD`. When HEAD has no reachable
518/// tag (a repo whose first version tag has not landed yet), the total
519/// commit count on HEAD is returned instead (`git rev-list --count HEAD`).
520///
521/// `monorepo_prefix` constrains the `describe` to tags matching
522/// `<prefix>*` (via `--match`), so in a per-crate workspace the count is
523/// since the matching crate's tag rather than the nearest tag from ANY
524/// subproject. `None` considers all tags.
525///
526/// This is the stateless basis for the `{{ .NightlyBuild }}` template var:
527/// the count resets to a small number the moment a new version tag lands,
528/// so a nightly build counter increments per base version with no state
529/// anodizer must persist.
530///
531/// Returns `Ok(0)` for an empty repository (no commits) so callers never
532/// have to special-case the unborn-HEAD state.
533pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
534    // `--abbrev=0` yields the bare tag name (no `-<n>-g<sha>` suffix).
535    // A repo with no reachable tag exits non-zero here; treat that as
536    // "count every commit on HEAD" rather than an error.
537    //
538    // `--match=<prefix>*` (when a monorepo prefix is set) restricts the
539    // describe to the matching crate's tags — without it, describe returns
540    // the nearest reachable tag from ANY subproject and the count would be
541    // since the wrong crate's tag. Mirrors `find_previous_tag_with_prefix_in`.
542    let match_arg;
543    let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
544    if let Some(prefix) = monorepo_prefix {
545        match_arg = format!("--match={}*", prefix);
546        describe_args.push(&match_arg);
547    }
548    describe_args.push("HEAD");
549    let range = match git_output_in(cwd, &describe_args) {
550        Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
551        _ => "HEAD".to_string(),
552    };
553    // An empty repo (unborn HEAD) makes `rev-list` fail; map that to 0.
554    let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
555        Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
556        Err(_) => 0,
557    };
558    Ok(count)
559}
560
561/// Get the short commit hash of HEAD.
562pub fn get_short_commit() -> Result<String> {
563    get_short_commit_in(&cwd_or_dot())
564}
565
566/// Path-taking sibling of [`get_short_commit`].
567pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
568    git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
569}
570
571/// Default short-commit length used across error messages, log
572/// output, and any place that needs to truncate a full SHA for
573/// human display. Matches git's `--short` default (7) — and the
574/// `ShortCommit` template var populated by [`super::detect_git_info`]
575/// (which delegates to `git rev-parse --short`).
576pub const SHORT_COMMIT_LEN: usize = 7;
577
578/// Truncate a full commit SHA string to [`SHORT_COMMIT_LEN`]
579/// characters. Returns the input unchanged when it's already shorter
580/// or equal in length. Use this any time the SHA arrives as a string
581/// (e.g. deserialized from a manifest or read from a template var)
582/// rather than running `git rev-parse --short` again — saves a
583/// subprocess and keeps the length convention in one place.
584///
585/// Empty input returns empty; callers needing fail-closed semantics
586/// (e.g. publish-only's commit cross-check) check `is_empty()`
587/// before calling.
588pub fn short_commit_str(commit: &str) -> String {
589    if commit.len() > SHORT_COMMIT_LEN {
590        commit[..SHORT_COMMIT_LEN].to_string()
591    } else {
592        commit.to_string()
593    }
594}
595
596/// Get the full commit hash of HEAD.
597///
598/// The full commit SHA (resolved at git-pipe time and
599/// reused everywhere downstream). Used by the source-archive stage to
600/// produce deterministic archives across consecutive commits when
601/// `git_info` was not pre-populated by an earlier pipe.
602pub fn get_head_commit() -> Result<String> {
603    get_head_commit_in(&cwd_or_dot())
604}
605
606/// Path-taking sibling of [`get_head_commit`].
607pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
608    git_output_in(cwd, &["rev-parse", "HEAD"])
609}
610
611/// Check if there are changes in a path since a given tag.
612pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
613    has_changes_since_in(&cwd_or_dot(), tag, path)
614}
615
616/// Path-taking sibling of [`has_changes_since`].
617pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
618    let output = git_output_in(
619        cwd,
620        &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
621    )?;
622    Ok(!output.is_empty())
623}
624
625/// Get last N commit subjects that touched a specific path.
626pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
627    get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
628}
629
630/// Path-taking sibling of [`get_last_commit_messages_path`].
631pub fn get_last_commit_messages_path_in(
632    cwd: &Path,
633    count: usize,
634    path: &str,
635) -> Result<Vec<String>> {
636    let output = git_output_in(
637        cwd,
638        &[
639            "-c",
640            "log.showSignature=false",
641            "log",
642            &format!("-{count}"),
643            "--pretty=format:%s",
644            "--",
645            path,
646        ],
647    )?;
648    Ok(output.lines().map(str::to_string).collect())
649}
650
651/// Get commit subjects between two refs that touched a specific path.
652pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
653    get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
654}
655
656/// Path-taking sibling of [`get_commit_messages_between_path`].
657pub fn get_commit_messages_between_path_in(
658    cwd: &Path,
659    from: &str,
660    to: &str,
661    path: &str,
662) -> Result<Vec<String>> {
663    let output = git_output_in(
664        cwd,
665        &[
666            "-c",
667            "log.showSignature=false",
668            "log",
669            "--pretty=format:%s",
670            &format!("{from}..{to}"),
671            "--",
672            path,
673        ],
674    )?;
675    Ok(output.lines().map(str::to_string).collect())
676}
677
678/// Stage specific files and create a commit.
679///
680/// Returns `Ok(true)` when a commit was created, `Ok(false)` when staging
681/// produced no diff (e.g. files are already at the target state) — callers
682/// that need idempotent bump-then-tag flows can use the boolean to decide
683/// whether to skip downstream commit-dependent work without inspecting git
684/// state separately.
685pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
686    stage_and_commit_in(&cwd_or_dot(), files, message)
687}
688
689/// Path-taking sibling of [`stage_and_commit`].
690pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
691    let mut args = vec!["add", "--"];
692    args.extend(files.iter().copied());
693    git_output_in(cwd, &args)?;
694    // Idempotency guard: `git add` happily stages nothing when the working
695    // tree already matches HEAD for the given paths. Running `git commit`
696    // after would fail with "nothing to commit" (printed to stdout, not
697    // stderr) and surface a confusing empty-stderr error. Detect the
698    // no-diff case here so callers can re-run safely.
699    let diff = Command::new("git")
700        .current_dir(cwd)
701        .args(["diff", "--cached", "--quiet", "--"])
702        .args(files)
703        .env("GIT_TERMINAL_PROMPT", "0")
704        .env("LC_ALL", "C")
705        .status()?;
706    if diff.success() {
707        return Ok(false);
708    }
709    git_output_in(cwd, &["commit", "-m", message])?;
710    Ok(true)
711}
712
713/// `git -C <workspace_root> -c log.showSignature=false log
714/// --pretty=format:%B%x1e <range> -- <rel_path>` — list commit message
715/// bodies (subject+body) for commits in `range` touching `rel_path`,
716/// using the `\x1e` (RS) byte as a between-commits separator so multi-line
717/// bodies survive parsing.
718///
719/// `range` is the git revision range as a string (e.g. `"HEAD"`,
720/// `"v0.3.0..HEAD"`); the empty string is invalid (caller must pre-filter).
721/// Returns `Ok(Vec::new())` when git fails so callers treat
722/// "range doesn't exist yet" as a non-error.
723pub fn log_subjects_for_range(
724    workspace_root: &std::path::Path,
725    range: &str,
726    rel_path: &str,
727) -> Result<Vec<String>> {
728    let out = Command::new("git")
729        .arg("-C")
730        .arg(workspace_root)
731        .args([
732            "-c",
733            "log.showSignature=false",
734            "log",
735            "--pretty=format:%B%x1e",
736            range,
737            "--",
738            rel_path,
739        ])
740        .env("GIT_TERMINAL_PROMPT", "0")
741        .env("LC_ALL", "C")
742        .output()?;
743    if !out.status.success() {
744        // Range may not exist yet (no last_tag, path not in history).
745        return Ok(Vec::new());
746    }
747    let text = String::from_utf8_lossy(&out.stdout);
748    Ok(text
749        .split('\x1e')
750        .map(|s| s.trim().to_string())
751        .filter(|s| !s.is_empty())
752        .collect())
753}
754
755/// `git -C <workspace_root> add <rel>` — stage a single relative path.
756pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
757    let out = Command::new("git")
758        .arg("-C")
759        .arg(workspace_root)
760        .arg("add")
761        .arg(rel)
762        .env("GIT_TERMINAL_PROMPT", "0")
763        .env("LC_ALL", "C")
764        .output()
765        .context("failed to invoke git add")?;
766    if !out.status.success() {
767        let stderr_raw = String::from_utf8_lossy(&out.stderr);
768        let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
769        bail!("{}", crate::redact::redact_process_env(&raw));
770    }
771    Ok(())
772}
773
774/// `git -C <workspace_root> commit [-S] -m <message>` — create a commit
775/// with the given message, optionally GPG-signed.
776pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
777    let mut cmd = Command::new("git");
778    cmd.arg("-C").arg(workspace_root).arg("commit");
779    if sign {
780        cmd.arg("-S");
781    }
782    cmd.arg("-m")
783        .arg(message)
784        .env("GIT_TERMINAL_PROMPT", "0")
785        .env("LC_ALL", "C");
786    let out = cmd.output().context("failed to invoke git commit")?;
787    if !out.status.success() {
788        let stderr_raw = String::from_utf8_lossy(&out.stderr);
789        let raw = format!("git commit failed: {}", stderr_raw.trim());
790        bail!("{}", crate::redact::redact_process_env(&raw));
791    }
792    Ok(())
793}
794
795/// `git diff --name-only <tag>..HEAD -- <paths>...` — return `true` when
796/// any of the named paths changed between `tag` and `HEAD`. Returns
797/// `Ok(false)` when git fails (e.g. not a git repo) so callers can treat
798/// the absence-of-info case as "no changes".
799pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
800    paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
801}
802
803/// Path-taking sibling of [`paths_changed_since_tag`].
804pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
805    let mut args: Vec<String> = vec![
806        "diff".to_string(),
807        "--name-only".to_string(),
808        format!("{tag}..HEAD"),
809        "--".to_string(),
810    ];
811    for p in paths {
812        args.push((*p).to_string());
813    }
814    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
815    let output = Command::new("git")
816        .current_dir(cwd)
817        .args(&arg_refs)
818        .env("GIT_TERMINAL_PROMPT", "0")
819        .env("LC_ALL", "C")
820        .output()?;
821    if output.status.success() {
822        Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
823    } else {
824        Ok(false)
825    }
826}
827
828/// `git -C <repo> rev-parse HEAD` — return HEAD's full commit hash for the
829/// given repository (or worktree). Path-taking sibling of
830/// [`get_head_commit`] so callers (the determinism harness, future CI
831/// glue) can resolve HEAD without `cd`-ing into the repo first.
832pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
833    let out = Command::new("git")
834        .arg("-C")
835        .arg(repo)
836        .args(["rev-parse", "HEAD"])
837        .env("GIT_TERMINAL_PROMPT", "0")
838        .env("LC_ALL", "C")
839        .output()
840        .context("failed to invoke git rev-parse HEAD")?;
841    if !out.status.success() {
842        let stderr_raw = String::from_utf8_lossy(&out.stderr);
843        let raw = format!("git rev-parse HEAD failed: {}", stderr_raw.trim());
844        bail!("{}", crate::redact::redact_process_env(&raw));
845    }
846    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
847}
848
849/// Resolve a revision (sha, ref name, `HEAD`, etc.) to its full commit hash.
850///
851/// Wrapper over `git rev-parse <rev>` — errors when the revision can't be
852/// resolved (unknown sha, ambiguous short hash, not a git repo).
853pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
854    git_output_in(cwd, &["rev-parse", rev])
855}
856
857/// `git rev-parse --verify <rev>^{commit}` — resolve `rev` to a commit SHA,
858/// erroring when it does not name an existing commit. Stricter than
859/// [`rev_parse_in`]: `--verify` rejects ambiguous / non-existent refs (rather
860/// than echoing the input back), and the `^{commit}` peel rejects a ref that
861/// resolves to a non-commit object (e.g. a tree or blob SHA).
862pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
863    git_output_in(
864        cwd,
865        &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
866    )
867}
868
869/// `git rev-list <sha>..HEAD` — list the commit hashes (newest-first) that
870/// sit on top of `sha` but aren't in `sha`.
871///
872/// Returns an empty vec when `sha` IS `HEAD` (no commits between).
873pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
874    let range = format!("{}..HEAD", sha);
875    let out = git_output_in(cwd, &["rev-list", &range])?;
876    if out.is_empty() {
877        return Ok(Vec::new());
878    }
879    Ok(out.lines().map(|s| s.trim().to_string()).collect())
880}
881
882/// `git log -1 --format=%s <sha>` — return the subject line of a single
883/// commit. Used to render the "non-bump commit subject" list when the
884/// rollback safety check fires.
885pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
886    git_output_in(
887        cwd,
888        &[
889            "-c",
890            "log.showSignature=false",
891            "log",
892            "-1",
893            "--format=%s",
894            sha,
895        ],
896    )
897}
898
899/// `git log --format=%H%x1f%s <sha>..HEAD` — return every `(full_sha, subject)`
900/// pair in the range in one subprocess. Used by the rollback safety check so
901/// classifying N intervening commits is a single `git` spawn rather than
902/// `1 + N` (one `rev-list` plus one `log -1` per commit).
903///
904/// Empty range (sha IS HEAD) returns an empty vec.
905pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
906    let range = format!("{}..HEAD", sha);
907    let out = git_output_in(
908        cwd,
909        &[
910            "-c",
911            "log.showSignature=false",
912            "log",
913            "--format=%H%x1f%s",
914            &range,
915        ],
916    )?;
917    if out.is_empty() {
918        return Ok(Vec::new());
919    }
920    Ok(out
921        .lines()
922        .filter_map(|line| {
923            let mut parts = line.splitn(2, '\x1f');
924            let sha = parts.next()?.trim().to_string();
925            let subj = parts.next().unwrap_or("").to_string();
926            if sha.is_empty() {
927                None
928            } else {
929                Some((sha, subj))
930            }
931        })
932        .collect())
933}
934
935/// Committer identity (author + committer name/email) for the rare path
936/// where a git invocation lands on a host with no `user.email` /
937/// `user.name` configured — notably `actions/checkout@v6`, which does
938/// NOT set committer identity for the workflow runner. Resolved once per
939/// caller and threaded through to [`revert_commit_in`] so the CLI never
940/// mutates the repo's git config (env-only, scoped to the single spawn).
941///
942/// Convention: when both `name` and `email` are populated, the values
943/// are exported as `GIT_AUTHOR_NAME` / `GIT_AUTHOR_EMAIL` AND
944/// `GIT_COMMITTER_NAME` / `GIT_COMMITTER_EMAIL` on the git child
945/// processes (revert + amend). When `None`, the child inherits whatever
946/// the parent / repo config provides.
947#[derive(Debug, Clone, Default)]
948pub struct CommitterIdentity {
949    pub name: Option<String>,
950    pub email: Option<String>,
951}
952
953impl CommitterIdentity {
954    /// Return a default committer identity to use when `user.email` and
955    /// `user.name` are both unset on the host. Email uses the
956    /// short-hostname (best-effort; falls back to `"localhost"`) so a
957    /// reviewer can tell at a glance which machine emitted the
958    /// rollback commit.
959    pub fn default_for_rollback() -> Self {
960        let host = std::env::var("HOSTNAME")
961            .ok()
962            .or_else(|| std::env::var("COMPUTERNAME").ok())
963            .and_then(|h| h.split('.').next().map(str::to_string))
964            .filter(|h| !h.is_empty())
965            .unwrap_or_else(|| "localhost".to_string());
966        Self {
967            name: Some("anodize-rollback".to_string()),
968            email: Some(format!("anodize-rollback@{host}")),
969        }
970    }
971
972    fn apply_to(&self, cmd: &mut Command) {
973        if let Some(n) = &self.name {
974            cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
975        }
976        if let Some(e) = &self.email {
977            cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
978        }
979    }
980}
981
982/// Read `git config user.email` / `user.name` in `cwd`. Returns
983/// `(name, email)`, each `Some(value)` when configured (and non-empty)
984/// or `None` when unset. Used by [`revert_commit_in`] to detect the
985/// CI-checkout case where neither identity is configured and the
986/// committer env fallback must fire.
987fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
988    let one = |key: &str| -> Option<String> {
989        let out = Command::new("git")
990            .current_dir(cwd)
991            .args(["config", "--get", key])
992            .env("LC_ALL", "C")
993            .env("GIT_TERMINAL_PROMPT", "0")
994            .output()
995            .ok()?;
996        if !out.status.success() {
997            return None;
998        }
999        let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
1000        if value.is_empty() { None } else { Some(value) }
1001    };
1002    (one("user.name"), one("user.email"))
1003}
1004
1005/// Resolve the committer identity to use for a rollback-style commit.
1006/// When the host already has `user.name` AND `user.email` configured
1007/// (or `GIT_AUTHOR_*` / `GIT_COMMITTER_*` are set in the parent env),
1008/// returns an empty identity so the child inherits the existing
1009/// values. Otherwise returns a synthetic identity so the commit
1010/// doesn't fail with "Author identity unknown" on bare-CI hosts.
1011pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
1012    let env_author_set =
1013        std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
1014    let env_committer_set =
1015        std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
1016    if env_author_set && env_committer_set {
1017        return CommitterIdentity::default();
1018    }
1019    let (name, email) = read_git_identity(cwd);
1020    if name.is_some() && email.is_some() {
1021        return CommitterIdentity::default();
1022    }
1023    CommitterIdentity::default_for_rollback()
1024}
1025
1026/// Run `git revert --no-edit <sha>` in `cwd`, optionally followed by
1027/// `git commit --amend -m <message>`.
1028///
1029/// Refuses against a dirty working tree (`git revert` would surface a
1030/// less actionable "your local changes would be overwritten" message
1031/// otherwise). Mirrors the dirty-tree guard used by
1032/// `stage-publish/src/util/git_revert.rs`.
1033///
1034/// On revert failure (typically a merge conflict against later commits
1035/// on top of the bump), runs `git revert --abort` to restore the
1036/// working tree before bubbling the error — otherwise the next
1037/// rollback attempt would trip the dirty-tree guard and the operator
1038/// would be stuck.
1039///
1040/// `identity` is threaded through as committer env vars so the call
1041/// works on bare-CI hosts where the workflow checkout doesn't set
1042/// `user.email` / `user.name`. The env is scoped to the spawn; the
1043/// repo's git config is never mutated.
1044pub fn revert_commit_in(
1045    cwd: &Path,
1046    sha: &str,
1047    message: Option<&str>,
1048    identity: &CommitterIdentity,
1049) -> Result<()> {
1050    let status = Command::new("git")
1051        .args(["status", "--porcelain"])
1052        .current_dir(cwd)
1053        .env("LC_ALL", "C")
1054        .env("GIT_TERMINAL_PROMPT", "0")
1055        .output()
1056        .with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
1057    if !status.status.success() {
1058        let stderr_raw = String::from_utf8_lossy(&status.stderr);
1059        let raw = format!("git status failed: {}", stderr_raw.trim());
1060        bail!("{}", crate::redact::redact_process_env(&raw));
1061    }
1062    if !status.stdout.is_empty() {
1063        bail!(
1064            "refusing to revert in a dirty working tree at {}\nstatus:\n{}",
1065            cwd.display(),
1066            String::from_utf8_lossy(&status.stdout),
1067        );
1068    }
1069
1070    let mut revert_cmd = Command::new("git");
1071    revert_cmd
1072        .current_dir(cwd)
1073        .args(["revert", "--no-edit", sha])
1074        .env("LC_ALL", "C")
1075        .env("GIT_TERMINAL_PROMPT", "0");
1076    identity.apply_to(&mut revert_cmd);
1077    let out = revert_cmd
1078        .output()
1079        .with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
1080    if !out.status.success() {
1081        let stderr_raw = String::from_utf8_lossy(&out.stderr);
1082        // Restore the working tree before bubbling — otherwise the dirty-tree
1083        // guard above traps a subsequent rollback retry forever.
1084        let _ = Command::new("git")
1085            .current_dir(cwd)
1086            .args(["revert", "--abort"])
1087            .env("LC_ALL", "C")
1088            .env("GIT_TERMINAL_PROMPT", "0")
1089            .output();
1090        let raw = format!(
1091            "git revert {sha} hit conflicts and was aborted (working tree restored). \
1092             The bump commit overlaps with later changes — resolve manually, \
1093             or re-run with --mode=reset to force.\nstderr: {}",
1094            stderr_raw.trim()
1095        );
1096        bail!("{}", crate::redact::redact_process_env(&raw));
1097    }
1098    if let Some(msg) = message {
1099        let mut amend_cmd = Command::new("git");
1100        amend_cmd
1101            .current_dir(cwd)
1102            .args(["commit", "--amend", "-m", msg])
1103            .env("LC_ALL", "C")
1104            .env("GIT_TERMINAL_PROMPT", "0");
1105        identity.apply_to(&mut amend_cmd);
1106        let out = amend_cmd.output().with_context(|| {
1107            format!("revert_commit_in: git commit --amend in {}", cwd.display())
1108        })?;
1109        if !out.status.success() {
1110            let stderr_raw = String::from_utf8_lossy(&out.stderr);
1111            let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
1112            bail!("{}", crate::redact::redact_process_env(&raw));
1113        }
1114    }
1115    Ok(())
1116}
1117
1118/// Run `git reset --hard <sha>` in `cwd`. **Destructive** — rewrites HEAD
1119/// and the index in place; callers must surface a warning before invoking.
1120pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
1121    git_output_in(cwd, &["reset", "--hard", sha])?;
1122    Ok(())
1123}
1124
1125/// Push a branch (`HEAD:refs/heads/<branch>`) to the `origin` remote.
1126///
1127/// Errors when no `origin` remote is configured — callers driving local-only
1128/// flows should pass `--no-push` to skip the call entirely.
1129pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
1130    if !super::has_remote_in(cwd, "origin") {
1131        bail!("no 'origin' remote configured, cannot push branch '{branch}'");
1132    }
1133    let refspec = format!("HEAD:refs/heads/{}", branch);
1134    let out = Command::new("git")
1135        .current_dir(cwd)
1136        .args(["push", "origin", &refspec])
1137        .env("GIT_TERMINAL_PROMPT", "0")
1138        .env("LC_ALL", "C")
1139        .output()
1140        .with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
1141    if !out.status.success() {
1142        let stderr_raw = String::from_utf8_lossy(&out.stderr);
1143        let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
1144        bail!("{}", crate::redact::redact_process_env(&raw));
1145    }
1146    Ok(())
1147}
1148
1149/// `git -C <repo> log -1 --format=%ct HEAD` — return HEAD's committer
1150/// timestamp (seconds since UNIX epoch) for the given repository. Used by
1151/// the determinism harness as the non-snapshot SDE seed.
1152pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
1153    let out = Command::new("git")
1154        .arg("-C")
1155        .arg(repo)
1156        .args(["log", "-1", "--format=%ct", "HEAD"])
1157        .env("GIT_TERMINAL_PROMPT", "0")
1158        .env("LC_ALL", "C")
1159        .output()
1160        .context("failed to invoke git log -1 --format=%ct HEAD")?;
1161    if !out.status.success() {
1162        let stderr_raw = String::from_utf8_lossy(&out.stderr);
1163        let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
1164        bail!("{}", crate::redact::redact_process_env(&raw));
1165    }
1166    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
1167    text.parse::<i64>()
1168        .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::*;
1174    use std::process::Command;
1175
1176    fn init_repo_with_commits(dir: &Path, files: &[&str]) {
1177        let run = |args: &[&str]| {
1178            let out = Command::new("git")
1179                .args(args)
1180                .current_dir(dir)
1181                .env("GIT_AUTHOR_NAME", "t")
1182                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1183                .env("GIT_COMMITTER_NAME", "t")
1184                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1185                .output()
1186                .unwrap();
1187            assert!(out.status.success(), "git {args:?} failed");
1188        };
1189        run(&["init"]);
1190        run(&["config", "user.email", "t@t.com"]);
1191        run(&["config", "user.name", "t"]);
1192        for (i, f) in files.iter().enumerate() {
1193            std::fs::write(dir.join(f), format!("c{i}")).unwrap();
1194            run(&["add", "."]);
1195            run(&["commit", "-m", &format!("commit-{i}: {f}")]);
1196        }
1197    }
1198
1199    #[test]
1200    fn get_head_commit_in_returns_tempdirs_head_sha() {
1201        let tmp = tempfile::tempdir().unwrap();
1202        init_repo_with_commits(tmp.path(), &["a"]);
1203        let expected = String::from_utf8(
1204            Command::new("git")
1205                .args(["rev-parse", "HEAD"])
1206                .current_dir(tmp.path())
1207                .output()
1208                .unwrap()
1209                .stdout,
1210        )
1211        .unwrap()
1212        .trim()
1213        .to_string();
1214        let sha = get_head_commit_in(tmp.path()).unwrap();
1215        assert_eq!(sha, expected);
1216    }
1217
1218    #[test]
1219    fn get_short_commit_in_returns_tempdirs_short_sha() {
1220        let tmp = tempfile::tempdir().unwrap();
1221        init_repo_with_commits(tmp.path(), &["a"]);
1222        let expected = String::from_utf8(
1223            Command::new("git")
1224                .args(["rev-parse", "--short", "HEAD"])
1225                .current_dir(tmp.path())
1226                .output()
1227                .unwrap()
1228                .stdout,
1229        )
1230        .unwrap()
1231        .trim()
1232        .to_string();
1233        let short = get_short_commit_in(tmp.path()).unwrap();
1234        assert_eq!(short, expected);
1235    }
1236
1237    #[test]
1238    fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
1239        let tmp = tempfile::tempdir().unwrap();
1240        let dir = tmp.path();
1241        init_repo_with_commits(dir, &["a"]);
1242        let run = |args: &[&str]| {
1243            Command::new("git")
1244                .args(args)
1245                .current_dir(dir)
1246                .env("GIT_AUTHOR_NAME", "t")
1247                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1248                .env("GIT_COMMITTER_NAME", "t")
1249                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1250                .output()
1251                .unwrap();
1252        };
1253        run(&["tag", "v1.0.0"]);
1254        assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
1255    }
1256
1257    fn git_in(dir: &Path, args: &[&str]) {
1258        let out = Command::new("git")
1259            .args(args)
1260            .current_dir(dir)
1261            .env("GIT_AUTHOR_NAME", "t")
1262            .env("GIT_AUTHOR_EMAIL", "t@t.com")
1263            .env("GIT_COMMITTER_NAME", "t")
1264            .env("GIT_COMMITTER_EMAIL", "t@t.com")
1265            .output()
1266            .unwrap();
1267        assert!(out.status.success(), "git {args:?} failed");
1268    }
1269
1270    #[test]
1271    fn count_commits_since_last_tag_counts_commits_after_tag() {
1272        let tmp = tempfile::tempdir().unwrap();
1273        let dir = tmp.path();
1274        // 2 commits, tag v1.0.0 at the 2nd, then 3 more commits.
1275        init_repo_with_commits(dir, &["a", "b"]);
1276        git_in(dir, &["tag", "v1.0.0"]);
1277        for f in ["c", "d", "e"] {
1278            std::fs::write(dir.join(f), "x").unwrap();
1279            git_in(dir, &["add", "."]);
1280            git_in(dir, &["commit", "-m", f]);
1281        }
1282        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1283    }
1284
1285    #[test]
1286    fn count_commits_since_last_tag_resets_on_newer_tag() {
1287        let tmp = tempfile::tempdir().unwrap();
1288        let dir = tmp.path();
1289        init_repo_with_commits(dir, &["a"]);
1290        git_in(dir, &["tag", "v1.0.0"]);
1291        for f in ["b", "c"] {
1292            std::fs::write(dir.join(f), "x").unwrap();
1293            git_in(dir, &["add", "."]);
1294            git_in(dir, &["commit", "-m", f]);
1295        }
1296        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 2);
1297        // A newer version tag lands -> counter resets to 0 at the tag.
1298        git_in(dir, &["tag", "v1.1.0"]);
1299        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 0);
1300        std::fs::write(dir.join("d"), "x").unwrap();
1301        git_in(dir, &["add", "."]);
1302        git_in(dir, &["commit", "-m", "d"]);
1303        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 1);
1304    }
1305
1306    #[test]
1307    fn count_commits_since_last_tag_counts_all_when_no_tag() {
1308        let tmp = tempfile::tempdir().unwrap();
1309        let dir = tmp.path();
1310        init_repo_with_commits(dir, &["a", "b", "c"]);
1311        // No tag at all -> count every commit on HEAD.
1312        assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
1313    }
1314
1315    #[test]
1316    fn count_commits_since_last_tag_respects_monorepo_prefix() {
1317        // Per-crate workspace: tags for two subprojects interleave on one
1318        // branch. The `core/` count must be since the latest `core/*` tag,
1319        // NOT the nearer `api/*` tag from a different subproject.
1320        let tmp = tempfile::tempdir().unwrap();
1321        let dir = tmp.path();
1322        init_repo_with_commits(dir, &["a"]);
1323        git_in(dir, &["tag", "core/v1.0.0"]); // matching-prefix tag (older)
1324        for f in ["b", "c"] {
1325            std::fs::write(dir.join(f), "x").unwrap();
1326            git_in(dir, &["add", "."]);
1327            git_in(dir, &["commit", "-m", f]);
1328        }
1329        git_in(dir, &["tag", "api/v2.0.0"]); // DIFFERENT prefix, NEARER to HEAD
1330        std::fs::write(dir.join("d"), "x").unwrap();
1331        git_in(dir, &["add", "."]);
1332        git_in(dir, &["commit", "-m", "d"]);
1333
1334        // With prefix filtering: count since core/v1.0.0 = 3 commits (b, c, d).
1335        assert_eq!(
1336            count_commits_since_last_tag_in(dir, Some("core/")).unwrap(),
1337            3,
1338            "must count since the matching-prefix tag, ignoring api/v2.0.0",
1339        );
1340        // Without filtering (None): describe picks the nearer api/v2.0.0,
1341        // so the count is only 1 (d). This is the mutation-check baseline
1342        // proving the --match arg is load-bearing.
1343        assert_eq!(
1344            count_commits_since_last_tag_in(dir, None).unwrap(),
1345            1,
1346            "unfiltered count picks the nearest (wrong) subproject tag",
1347        );
1348    }
1349
1350    #[test]
1351    fn get_current_branch_in_returns_branch_name() {
1352        let tmp = tempfile::tempdir().unwrap();
1353        let dir = tmp.path();
1354        let run = |args: &[&str]| {
1355            let out = Command::new("git")
1356                .args(args)
1357                .current_dir(dir)
1358                .env("GIT_AUTHOR_NAME", "t")
1359                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1360                .env("GIT_COMMITTER_NAME", "t")
1361                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1362                .output()
1363                .unwrap();
1364            assert!(out.status.success(), "git {args:?} failed");
1365        };
1366        run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
1367        run(&["config", "user.email", "t@t.com"]);
1368        run(&["config", "user.name", "t"]);
1369        std::fs::write(dir.join("a"), "1").unwrap();
1370        run(&["add", "."]);
1371        run(&["commit", "-m", "c1"]);
1372        let branch = get_current_branch_in(dir).unwrap();
1373        assert_eq!(branch, "t1-test-branch");
1374    }
1375
1376    #[test]
1377    fn get_current_branch_in_resolves_detached_head_via_points_at() {
1378        let tmp = tempfile::tempdir().unwrap();
1379        let dir = tmp.path();
1380        let run = |args: &[&str]| {
1381            let out = Command::new("git")
1382                .args(args)
1383                .current_dir(dir)
1384                .env("GIT_AUTHOR_NAME", "t")
1385                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1386                .env("GIT_COMMITTER_NAME", "t")
1387                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1388                .output()
1389                .unwrap();
1390            assert!(out.status.success(), "git {args:?} failed");
1391        };
1392        run(&["-c", "init.defaultBranch=master", "init"]);
1393        run(&["config", "user.email", "t@t.com"]);
1394        run(&["config", "user.name", "t"]);
1395        std::fs::write(dir.join("a"), "1").unwrap();
1396        run(&["add", "."]);
1397        run(&["commit", "-m", "c1"]);
1398        let sha = get_head_commit_in(dir).unwrap();
1399        run(&["checkout", "--detach", &sha]);
1400        let branch = get_current_branch_in(dir).unwrap();
1401        assert_eq!(
1402            branch, "master",
1403            "detached HEAD pointing at master must resolve to master, not literal HEAD"
1404        );
1405    }
1406
1407    #[test]
1408    fn is_branchlike_rejects_lockstep_tag_shapes() {
1409        assert!(!is_branchlike("v0.4.5"));
1410        assert!(!is_branchlike("v1.2.3"));
1411        assert!(!is_branchlike("v10.20.30"));
1412        assert!(!is_branchlike("v1.2.3-rc.1"));
1413        assert!(!is_branchlike("v1.2.3+build.42"));
1414    }
1415
1416    #[test]
1417    fn is_branchlike_rejects_per_crate_tag_shapes() {
1418        assert!(!is_branchlike("mycrate-v1.2.3"));
1419        assert!(!is_branchlike("cfgd-operator-v0.4.0"));
1420        assert!(!is_branchlike("anodize-core-v1.2.3-rc.1"));
1421    }
1422
1423    #[test]
1424    fn is_branchlike_accepts_real_branch_names() {
1425        assert!(is_branchlike("master"));
1426        assert!(is_branchlike("main"));
1427        assert!(is_branchlike("publisher-required-config"));
1428        assert!(is_branchlike("release/v1.2.3-prep"));
1429        assert!(is_branchlike("dependabot/cargo/serde-1.0.200"));
1430    }
1431
1432    #[test]
1433    fn is_branchlike_accepts_slashed_branch_with_embedded_version() {
1434        // `feature/fix-v2.0.0` embeds `-v2.0.0` but is a branch, not a
1435        // per-crate tag: the unanchored `-v\d+\.\d+\.\d+` regex misclassified
1436        // it as a tag. The `^[^/]+-v` anchor keeps slashed branch names
1437        // branch-like.
1438        assert!(is_branchlike("feature/fix-v2.0.0"));
1439        assert!(is_branchlike("hotfix/release-v1.0.0-blocker"));
1440        assert!(is_branchlike("user/wip-v3.1.4"));
1441    }
1442
1443    #[test]
1444    #[serial_test::serial]
1445    fn get_current_branch_in_rejects_tag_shaped_github_ref_name() {
1446        let tmp = tempfile::tempdir().unwrap();
1447        let dir = tmp.path();
1448        let run = |args: &[&str]| {
1449            let out = Command::new("git")
1450                .args(args)
1451                .current_dir(dir)
1452                .env("GIT_AUTHOR_NAME", "t")
1453                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1454                .env("GIT_COMMITTER_NAME", "t")
1455                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1456                .output()
1457                .unwrap();
1458            assert!(out.status.success(), "git {args:?} failed");
1459        };
1460        // Build a repo whose HEAD is detached AND no local branch points
1461        // at it, so every fallback BEFORE GITHUB_REF_NAME fails. The only
1462        // way the fallback chain produces a value is via the env var.
1463        run(&["-c", "init.defaultBranch=master", "init"]);
1464        run(&["config", "user.email", "t@t.com"]);
1465        run(&["config", "user.name", "t"]);
1466        std::fs::write(dir.join("a"), "1").unwrap();
1467        run(&["add", "."]);
1468        run(&["commit", "-m", "c1"]);
1469        let sha = get_head_commit_in(dir).unwrap();
1470        // Move master forward so the detached HEAD has no branch
1471        // pointing at it; for-each-ref --points-at HEAD returns empty.
1472        std::fs::write(dir.join("a"), "2").unwrap();
1473        run(&["add", "."]);
1474        run(&["commit", "-m", "c2"]);
1475        run(&["checkout", "--detach", &sha]);
1476
1477        // Restore env on every exit path — set/remove in a guard so a
1478        // panic doesn't leak state across the serial-test queue.
1479        struct EnvGuard(&'static str, Option<String>);
1480        impl Drop for EnvGuard {
1481            fn drop(&mut self) {
1482                match &self.1 {
1483                    Some(v) => unsafe { std::env::set_var(self.0, v) },
1484                    None => unsafe { std::env::remove_var(self.0) },
1485                }
1486            }
1487        }
1488        let _g = EnvGuard("GITHUB_REF_NAME", std::env::var("GITHUB_REF_NAME").ok());
1489
1490        // Tag-shaped: must NOT be accepted; bail surfaces.
1491        unsafe { std::env::set_var("GITHUB_REF_NAME", "v0.4.5") };
1492        let err = get_current_branch_in(dir).unwrap_err();
1493        assert!(
1494            err.to_string().contains("could not resolve current branch"),
1495            "tag-shaped GITHUB_REF_NAME must trigger bail: {err}"
1496        );
1497
1498        // Per-crate-shaped: must NOT be accepted either.
1499        unsafe { std::env::set_var("GITHUB_REF_NAME", "mycrate-v1.2.3") };
1500        let err = get_current_branch_in(dir).unwrap_err();
1501        assert!(
1502            err.to_string().contains("could not resolve current branch"),
1503            "per-crate tag GITHUB_REF_NAME must trigger bail: {err}"
1504        );
1505
1506        // Real branch name: accepted.
1507        unsafe { std::env::set_var("GITHUB_REF_NAME", "master") };
1508        let branch = get_current_branch_in(dir).unwrap();
1509        assert_eq!(branch, "master");
1510    }
1511
1512    #[test]
1513    fn branches_containing_sha_in_returns_empty_without_remote() {
1514        let tmp = tempfile::tempdir().unwrap();
1515        let dir = tmp.path();
1516        let run = |args: &[&str]| {
1517            let out = Command::new("git")
1518                .args(args)
1519                .current_dir(dir)
1520                .env("GIT_AUTHOR_NAME", "t")
1521                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1522                .env("GIT_COMMITTER_NAME", "t")
1523                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1524                .output()
1525                .unwrap();
1526            assert!(out.status.success(), "git {args:?} failed");
1527        };
1528        run(&["-c", "init.defaultBranch=master", "init"]);
1529        run(&["config", "user.email", "t@t.com"]);
1530        run(&["config", "user.name", "t"]);
1531        std::fs::write(dir.join("a"), "1").unwrap();
1532        run(&["add", "."]);
1533        run(&["commit", "-m", "c1"]);
1534        let sha = get_head_commit_in(dir).unwrap();
1535        // No remote configured → `git branch -r --contains` returns
1536        // empty, which the helper surfaces as `Vec::new()` so the
1537        // caller can fall back to local branch resolution.
1538        let branches = branches_containing_sha_in(dir, &sha).unwrap();
1539        assert!(branches.is_empty(), "no remote → no remote branches");
1540    }
1541
1542    #[test]
1543    fn branches_containing_sha_in_finds_remote_branch_after_push() {
1544        let tmp = tempfile::tempdir().unwrap();
1545        let bare = tempfile::tempdir().unwrap();
1546        let dir = tmp.path();
1547        let run_in = |cwd: &Path, args: &[&str]| {
1548            let out = Command::new("git")
1549                .args(args)
1550                .current_dir(cwd)
1551                .env("GIT_AUTHOR_NAME", "t")
1552                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1553                .env("GIT_COMMITTER_NAME", "t")
1554                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1555                .output()
1556                .unwrap();
1557            assert!(out.status.success(), "git {args:?} failed");
1558        };
1559        run_in(
1560            bare.path(),
1561            &["-c", "init.defaultBranch=master", "init", "--bare"],
1562        );
1563        run_in(dir, &["-c", "init.defaultBranch=master", "init"]);
1564        run_in(dir, &["config", "user.email", "t@t.com"]);
1565        run_in(dir, &["config", "user.name", "t"]);
1566        run_in(
1567            dir,
1568            &["remote", "add", "origin", bare.path().to_str().unwrap()],
1569        );
1570        std::fs::write(dir.join("a"), "1").unwrap();
1571        run_in(dir, &["add", "."]);
1572        run_in(dir, &["commit", "-m", "c1"]);
1573        let sha = get_head_commit_in(dir).unwrap();
1574        run_in(dir, &["push", "-u", "origin", "master"]);
1575
1576        let branches = branches_containing_sha_in(dir, &sha).unwrap();
1577        assert_eq!(branches, vec!["master".to_string()]);
1578    }
1579
1580    #[test]
1581    fn stage_and_commit_in_returns_false_when_no_diff() {
1582        let tmp = tempfile::tempdir().unwrap();
1583        let dir = tmp.path();
1584        init_repo_with_commits(dir, &["a"]);
1585        // File is committed and unchanged — staging it should not produce
1586        // a diff, and stage_and_commit must report Ok(false) instead of
1587        // bailing on the "nothing to commit" path.
1588        let created = stage_and_commit_in(dir, &["a"], "chore: should be a no-op").unwrap();
1589        assert!(!created, "no diff → no commit should be created");
1590        let log = Command::new("git")
1591            .args(["log", "--oneline"])
1592            .current_dir(dir)
1593            .output()
1594            .unwrap();
1595        let log_text = String::from_utf8_lossy(&log.stdout);
1596        assert!(
1597            !log_text.contains("should be a no-op"),
1598            "stage_and_commit_in must not create a commit when no diff: {log_text}"
1599        );
1600    }
1601
1602    #[test]
1603    fn stage_and_commit_in_returns_true_when_file_changed() {
1604        let tmp = tempfile::tempdir().unwrap();
1605        let dir = tmp.path();
1606        init_repo_with_commits(dir, &["a"]);
1607        std::fs::write(dir.join("a"), "changed").unwrap();
1608        let created = stage_and_commit_in(dir, &["a"], "chore: real change").unwrap();
1609        assert!(created, "real change → commit must be created");
1610        let log = Command::new("git")
1611            .args(["log", "-1", "--pretty=%s"])
1612            .current_dir(dir)
1613            .output()
1614            .unwrap();
1615        let subject = String::from_utf8_lossy(&log.stdout).trim().to_string();
1616        assert_eq!(subject, "chore: real change");
1617    }
1618
1619    #[test]
1620    fn git_output_in_error_falls_back_to_stdout_when_stderr_empty() {
1621        let tmp = tempfile::tempdir().unwrap();
1622        let dir = tmp.path();
1623        init_repo_with_commits(dir, &["a"]);
1624        // `git commit -m ...` with an unchanged tree prints "nothing to
1625        // commit" to STDOUT (not stderr); the error message must surface
1626        // that detail instead of `failed: ` with nothing after.
1627        let err = git_output_in(dir, &["commit", "-m", "no-op"]).unwrap_err();
1628        let msg = err.to_string();
1629        assert!(
1630            msg.contains("nothing to commit") || msg.contains("clean"),
1631            "error must include stdout detail when stderr is empty: {msg}"
1632        );
1633    }
1634
1635    /// `CommitterIdentity::default_for_rollback` produces a populated
1636    /// (name + email) identity. The exact host-derived suffix isn't
1637    /// load-bearing — what matters is that both fields are present so
1638    /// `apply_to` produces all four `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
1639    /// envs on the spawn.
1640    #[test]
1641    fn default_for_rollback_populates_both_name_and_email() {
1642        let id = CommitterIdentity::default_for_rollback();
1643        assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
1644        let email = id.email.expect("email must be Some");
1645        assert!(
1646            email.starts_with("anodize-rollback@"),
1647            "email must use the anodize-rollback@<host> shape; got {email}"
1648        );
1649        assert!(!email.ends_with('@'), "host portion must not be empty");
1650    }
1651
1652    /// `revert_commit_in` with an injected `CommitterIdentity` writes a
1653    /// commit whose author/committer match the identity. Exercises the
1654    /// env-injection path end-to-end against a real fixture repo whose
1655    /// only configured identity is the override — so a future regression
1656    /// that drops the env threading would show up as the commit
1657    /// inheriting the host's `user.email` instead.
1658    #[test]
1659    fn revert_commit_in_uses_injected_identity_envs() {
1660        let tmp = tempfile::tempdir().unwrap();
1661        let dir = tmp.path();
1662        let run_env = |args: &[&str], extra: &[(&str, &str)]| {
1663            let mut cmd = Command::new("git");
1664            cmd.args(args)
1665                .current_dir(dir)
1666                .env("GIT_AUTHOR_NAME", "bootstrap")
1667                .env("GIT_AUTHOR_EMAIL", "bootstrap@b.com")
1668                .env("GIT_COMMITTER_NAME", "bootstrap")
1669                .env("GIT_COMMITTER_EMAIL", "bootstrap@b.com");
1670            for (k, v) in extra {
1671                cmd.env(k, v);
1672            }
1673            let out = cmd.output().unwrap();
1674            assert!(
1675                out.status.success(),
1676                "git {args:?} failed: {}",
1677                String::from_utf8_lossy(&out.stderr)
1678            );
1679        };
1680        run_env(&["init", "-b", "master"], &[]);
1681        std::fs::write(dir.join("a"), "0").unwrap();
1682        run_env(&["add", "."], &[]);
1683        run_env(&["commit", "-m", "initial"], &[]);
1684        std::fs::write(dir.join("a"), "1").unwrap();
1685        run_env(&["add", "."], &[]);
1686        run_env(&["commit", "-m", "chore(release): v1.0.0"], &[]);
1687        let bump_sha = get_head_commit_in(dir).unwrap();
1688
1689        // Inject a distinct identity so the resulting revert commit can
1690        // be attributed unambiguously to the env path (the bootstrap
1691        // commits used a different identity above).
1692        let identity = CommitterIdentity {
1693            name: Some("rollback-bot".to_string()),
1694            email: Some("rollback-bot@anodize.test".to_string()),
1695        };
1696        revert_commit_in(dir, &bump_sha, Some("chore(release): rollback"), &identity)
1697            .expect("revert with injected identity must succeed");
1698
1699        // The new HEAD commit's author email must be the injected one,
1700        // proving the env threading reached the git child.
1701        let out = Command::new("git")
1702            .current_dir(dir)
1703            .args(["log", "-1", "--format=%ae"])
1704            .env("GIT_TERMINAL_PROMPT", "0")
1705            .env("LC_ALL", "C")
1706            .output()
1707            .unwrap();
1708        let author_email = String::from_utf8_lossy(&out.stdout).trim().to_string();
1709        assert_eq!(
1710            author_email, "rollback-bot@anodize.test",
1711            "revert commit must carry the injected committer identity"
1712        );
1713
1714        // Repo config must remain unchanged — env-only fallback, no
1715        // `git config user.email ...` mutation.
1716        let cfg = Command::new("git")
1717            .current_dir(dir)
1718            .args(["config", "--local", "--get", "user.email"])
1719            .env("GIT_TERMINAL_PROMPT", "0")
1720            .env("LC_ALL", "C")
1721            .output()
1722            .unwrap();
1723        assert!(
1724            !cfg.status.success() || cfg.stdout.is_empty(),
1725            "revert must not write user.email into the repo's local config; got: {}",
1726            String::from_utf8_lossy(&cfg.stdout)
1727        );
1728    }
1729
1730    /// B-R4: a revert that hits conflicts (because later commits overlap
1731    /// with the bump) must run `git revert --abort`, restoring the working
1732    /// tree so the operator isn't trapped by the dirty-tree guard on the
1733    /// next attempt. Bail message must mention "aborted".
1734    #[test]
1735    fn revert_commit_in_aborts_on_conflict_and_leaves_tree_clean() {
1736        let tmp = tempfile::tempdir().unwrap();
1737        let dir = tmp.path();
1738        let run = |args: &[&str]| {
1739            let out = Command::new("git")
1740                .args(args)
1741                .current_dir(dir)
1742                .env("GIT_AUTHOR_NAME", "t")
1743                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1744                .env("GIT_COMMITTER_NAME", "t")
1745                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1746                .output()
1747                .unwrap();
1748            assert!(
1749                out.status.success(),
1750                "git {args:?} failed: {}",
1751                String::from_utf8_lossy(&out.stderr)
1752            );
1753        };
1754        run(&["init", "-b", "master"]);
1755        run(&["config", "user.email", "t@t.com"]);
1756        run(&["config", "user.name", "t"]);
1757        // Initial commit: file `x` with line "v1".
1758        std::fs::write(dir.join("x"), "v1\n").unwrap();
1759        run(&["add", "."]);
1760        run(&["commit", "-m", "initial"]);
1761        // "Bump" commit: change to "v2".
1762        std::fs::write(dir.join("x"), "v2\n").unwrap();
1763        run(&["add", "."]);
1764        run(&["commit", "-m", "chore(release): v2"]);
1765        let bump_sha = get_head_commit_in(dir).unwrap();
1766        // Later overlapping commit: change to "v3". A revert of the bump
1767        // would try to restore "v1" from a base of "v2", but HEAD is now
1768        // "v3" — that's the canonical revert-conflict shape.
1769        std::fs::write(dir.join("x"), "v3\n").unwrap();
1770        run(&["add", "."]);
1771        run(&["commit", "-m", "feat: overlap"]);
1772
1773        let identity = CommitterIdentity::default();
1774        let err = revert_commit_in(dir, &bump_sha, None, &identity)
1775            .expect_err("revert against overlapping HEAD must conflict and bail");
1776        let msg = format!("{err}");
1777        assert!(
1778            msg.contains("aborted"),
1779            "bail message must mention abort recovery: {msg}"
1780        );
1781
1782        // Working tree must be clean post-bail: no REVERT_HEAD, no
1783        // unmerged paths. The next rollback attempt must NOT hit the
1784        // dirty-tree guard.
1785        assert!(
1786            !dir.join(".git/REVERT_HEAD").exists(),
1787            ".git/REVERT_HEAD must be cleaned up after --abort"
1788        );
1789        let status_out = Command::new("git")
1790            .args(["status", "--porcelain"])
1791            .current_dir(dir)
1792            .output()
1793            .unwrap();
1794        assert!(
1795            status_out.stdout.is_empty(),
1796            "working tree must be clean after revert --abort; got:\n{}",
1797            String::from_utf8_lossy(&status_out.stdout)
1798        );
1799    }
1800
1801    /// S-R7: `commits_with_subjects_in` returns every (sha, subject)
1802    /// pair in one git spawn. Asserts both correctness (matches per-commit
1803    /// `commit_subject_in`) and that the range bound is exclusive on the
1804    /// `<sha>` side.
1805    #[test]
1806    fn commits_with_subjects_in_returns_all_pairs_in_one_call() {
1807        let tmp = tempfile::tempdir().unwrap();
1808        let dir = tmp.path();
1809        let run = |args: &[&str]| {
1810            let out = Command::new("git")
1811                .args(args)
1812                .current_dir(dir)
1813                .env("GIT_AUTHOR_NAME", "t")
1814                .env("GIT_AUTHOR_EMAIL", "t@t.com")
1815                .env("GIT_COMMITTER_NAME", "t")
1816                .env("GIT_COMMITTER_EMAIL", "t@t.com")
1817                .output()
1818                .unwrap();
1819            assert!(out.status.success(), "git {args:?} failed");
1820        };
1821        run(&["init", "-b", "master"]);
1822        run(&["config", "user.email", "t@t.com"]);
1823        run(&["config", "user.name", "t"]);
1824        std::fs::write(dir.join("a"), "0").unwrap();
1825        run(&["add", "."]);
1826        run(&["commit", "-m", "initial"]);
1827        let base = get_head_commit_in(dir).unwrap();
1828        std::fs::write(dir.join("a"), "1").unwrap();
1829        run(&["add", "."]);
1830        run(&["commit", "-m", "feat: A with extra detail"]);
1831        std::fs::write(dir.join("a"), "2").unwrap();
1832        run(&["add", "."]);
1833        run(&["commit", "-m", "fix: B"]);
1834
1835        let pairs = commits_with_subjects_in(dir, &base).unwrap();
1836        assert_eq!(pairs.len(), 2, "two commits sit on top of base");
1837        // Newest-first ordering (matches `git log` default).
1838        assert_eq!(pairs[0].1, "fix: B");
1839        assert_eq!(pairs[1].1, "feat: A with extra detail");
1840
1841        // Empty range (sha IS HEAD) → empty vec.
1842        let head = get_head_commit_in(dir).unwrap();
1843        assert!(commits_with_subjects_in(dir, &head).unwrap().is_empty());
1844    }
1845
1846    #[test]
1847    fn parse_commit_output_with_files_pairs_each_commit_with_its_files() {
1848        // Two commits: newest first (git log order). Each metadata record is
1849        // `%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e`, then `--name-only` files.
1850        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";
1851        let parsed = parse_commit_output_with_files(raw);
1852        assert_eq!(parsed.len(), 2);
1853        assert_eq!(parsed[0].commit.message, "fix: B");
1854        assert_eq!(parsed[0].files, vec!["crates/cli/main.rs".to_string()]);
1855        assert_eq!(parsed[1].commit.message, "feat: A");
1856        assert_eq!(
1857            parsed[1].files,
1858            vec!["crates/core/lib.rs".to_string(), "Cargo.toml".to_string()]
1859        );
1860    }
1861
1862    #[test]
1863    fn parse_commit_output_with_files_preserves_multiline_body_at_idx_gt_0() {
1864        // A multi-line `%b` body for the SECOND commit (idx>0): the body spans
1865        // several newline-separated lines, and the parser must keep the full
1866        // record — not just its first line — so trailers like `Co-Authored-By:`
1867        // survive, matching the metadata-only `parse_git_log_records` path.
1868        let body0 = "detail line one\ndetail line two\n\nCo-Authored-By: Bob <bob@b.com>";
1869        let raw = format!(
1870            "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\n\
1871             h0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f{body0}\x1e\ncrates/core/lib.rs\n"
1872        );
1873        let parsed = parse_commit_output_with_files(&raw);
1874        assert_eq!(parsed.len(), 2);
1875        // The idx>0 commit retains its FULL multi-line body and trailer.
1876        assert_eq!(parsed[1].commit.message, "feat: A");
1877        assert_eq!(parsed[1].commit.body, body0);
1878        assert!(
1879            parsed[1]
1880                .commit
1881                .body
1882                .contains("Co-Authored-By: Bob <bob@b.com>"),
1883            "multi-line body trailer dropped: {:?}",
1884            parsed[1].commit.body
1885        );
1886        assert_eq!(parsed[1].files, vec!["crates/core/lib.rs".to_string()]);
1887    }
1888
1889    #[test]
1890    fn get_commits_between_paths_with_files_in_reports_touched_files() {
1891        let tmp = tempfile::tempdir().unwrap();
1892        let dir = tmp.path();
1893        let run = |args: &[&str]| {
1894            assert!(
1895                Command::new("git")
1896                    .args(args)
1897                    .current_dir(dir)
1898                    .env("GIT_AUTHOR_NAME", "t")
1899                    .env("GIT_AUTHOR_EMAIL", "t@t.com")
1900                    .env("GIT_COMMITTER_NAME", "t")
1901                    .env("GIT_COMMITTER_EMAIL", "t@t.com")
1902                    .output()
1903                    .unwrap()
1904                    .status
1905                    .success()
1906            );
1907        };
1908        run(&["init"]);
1909        run(&["config", "user.email", "t@t.com"]);
1910        run(&["config", "user.name", "t"]);
1911        std::fs::write(dir.join("base"), "0").unwrap();
1912        run(&["add", "."]);
1913        run(&["commit", "-m", "initial"]);
1914        let base = get_head_commit_in(dir).unwrap();
1915        std::fs::create_dir_all(dir.join("crates/core")).unwrap();
1916        std::fs::write(dir.join("crates/core/lib.rs"), "1").unwrap();
1917        run(&["add", "."]);
1918        run(&["commit", "-m", "feat: core"]);
1919
1920        let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
1921        assert_eq!(pairs.len(), 1);
1922        assert_eq!(pairs[0].commit.message, "feat: core");
1923        assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
1924    }
1925
1926    #[test]
1927    fn get_commits_between_paths_with_files_in_preserves_multiline_body_for_later_commits() {
1928        // Real `git log --name-only` over TWO post-base commits, the OLDER one
1929        // (idx>0 in the newest-first output) carrying a multi-line body with a
1930        // `Co-Authored-By:` trailer. The full body must survive — proving the
1931        // narrowed fetch path agrees with the metadata-only path on body
1932        // content, not just the subject.
1933        let tmp = tempfile::tempdir().unwrap();
1934        let dir = tmp.path();
1935        let run = |args: &[&str]| {
1936            assert!(
1937                Command::new("git")
1938                    .args(args)
1939                    .current_dir(dir)
1940                    .env("GIT_AUTHOR_NAME", "t")
1941                    .env("GIT_AUTHOR_EMAIL", "t@t.com")
1942                    .env("GIT_COMMITTER_NAME", "t")
1943                    .env("GIT_COMMITTER_EMAIL", "t@t.com")
1944                    .output()
1945                    .unwrap()
1946                    .status
1947                    .success()
1948            );
1949        };
1950        run(&["init"]);
1951        run(&["config", "user.email", "t@t.com"]);
1952        run(&["config", "user.name", "t"]);
1953        std::fs::write(dir.join("base"), "0").unwrap();
1954        run(&["add", "."]);
1955        run(&["commit", "-m", "initial"]);
1956        let base = get_head_commit_in(dir).unwrap();
1957
1958        // Older of the two reported commits — multi-line body + trailer.
1959        std::fs::write(dir.join("a.rs"), "1").unwrap();
1960        run(&["add", "."]);
1961        run(&[
1962            "commit",
1963            "-m",
1964            "feat: with body\n\nfirst body line\nsecond body line\n\nCo-Authored-By: Bob <bob@b.com>",
1965        ]);
1966        // Newer commit (idx 0 in newest-first output), single-line.
1967        std::fs::write(dir.join("b.rs"), "2").unwrap();
1968        run(&["add", "."]);
1969        run(&["commit", "-m", "fix: later"]);
1970
1971        let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
1972        assert_eq!(pairs.len(), 2);
1973        // Newest-first: [0] = "fix: later", [1] = "feat: with body" (idx>0).
1974        assert_eq!(pairs[0].commit.message, "fix: later");
1975        let body = &pairs[1].commit.body;
1976        assert!(
1977            body.contains("first body line") && body.contains("second body line"),
1978            "multi-line body truncated for idx>0 commit: {body:?}"
1979        );
1980        assert!(
1981            body.contains("Co-Authored-By: Bob <bob@b.com>"),
1982            "Co-Authored-By trailer dropped for idx>0 commit: {body:?}"
1983        );
1984    }
1985}