Skip to main content

anodizer_core/git/commits/
repo_state.rs

1use super::*;
2use anyhow::{Context as _, Result, bail};
3use std::path::Path;
4use std::process::Command;
5
6/// Get the current branch name.
7pub fn get_current_branch() -> Result<String> {
8    get_current_branch_in(&cwd_or_dot())
9}
10
11/// Return `true` when `name` looks like a branch (NOT an anodize-shaped
12/// release tag). Tag shapes: `^v\d+\.\d+\.\d+` (lockstep
13/// `v1.2.3[-pre][+build]`) or `^<crate>-v\d+\.\d+\.\d+`
14/// (per-crate `mycrate-v1.2.3[...]`).
15///
16/// Both regexes are start-anchored, and the per-crate `<crate>` segment is
17/// constrained to non-`/` characters. Without that, a branch like
18/// `feature/fix-v2.0.0` contains `-v2.0.0` and would be misclassified as a
19/// tag — leaving its `GITHUB_REF_NAME` fallback rejected. A real per-crate
20/// tag's name prefix is a crate name (no path separators), so anchoring on
21/// `^[^/]+-v` keeps that branch shape branch-like while still matching
22/// `mycrate-v1.2.3`.
23///
24/// Guards the `GITHUB_REF_NAME` fallback in [`get_current_branch_in`]: on
25/// a `push: tags:` workflow trigger, `GITHUB_REF_NAME` is the TAG name
26/// (e.g. `v0.4.5`), and accepting it would make `git push origin v0.4.5`
27/// from detached HEAD silently create a branch named after the tag.
28///
29/// Drift-risk pair with `cli::commands::tag::rollback`'s `LOCKSTEP_TAG_RE` /
30/// `PER_CRATE_TAG_RE`: those classify the same two anodize tag shapes but
31/// are fully anchored and strict (a rollback must touch only real tags).
32/// The patterns here are deliberately looser and prefix-only (branch-vs-tag
33/// disambiguation, not classification). Keep both in sync when the tag
34/// grammar changes — they are intentionally separate, not duplicated.
35pub fn is_branchlike(name: &str) -> bool {
36    use regex::Regex;
37    use std::sync::OnceLock;
38    static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
39    static PER_CRATE: OnceLock<Regex> = OnceLock::new();
40    let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
41    let per_crate =
42        PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
43    !(lockstep.is_match(name) || per_crate.is_match(name))
44}
45
46/// Path-taking sibling of [`get_current_branch`].
47///
48/// Handles detached-HEAD checkouts (e.g. `actions/checkout@v4` with `ref:`)
49/// by resolving the branch HEAD points at via `for-each-ref`, falling back
50/// to the remote's default branch and finally `GITHUB_REF_NAME` when set —
51/// so downstream `git push origin <branch>` produces a valid refspec
52/// instead of a literal `HEAD` that git can't auto-qualify.
53///
54/// The `GITHUB_REF_NAME` fallback is guarded by [`is_branchlike`]: on a
55/// `push: tags:` trigger, `GITHUB_REF_NAME` is the TAG name, and accepting
56/// it would push to a branch named after the tag. Tag-shaped values fall
57/// through to the bail at the end so callers hard-fail and prompt the
58/// operator for `--branch <name>` explicitly.
59pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
60    get_current_branch_in_with_env(cwd, &crate::ProcessEnvSource)
61}
62
63/// [`EnvSource`](crate::EnvSource)-injecting form of [`get_current_branch_in`].
64///
65/// Reads the `GITHUB_REF_NAME` fallback from `env` rather than the process
66/// environment, so tests can drive the tag-shaped / branch-shaped fallback
67/// branches deterministically without mutating global env state.
68pub fn get_current_branch_in_with_env<E: crate::EnvSource + ?Sized>(
69    cwd: &Path,
70    env: &E,
71) -> Result<String> {
72    if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
73        return Ok(name);
74    }
75    if let Ok(out) = git_output_in(
76        cwd,
77        &[
78            "for-each-ref",
79            "--points-at",
80            "HEAD",
81            "--format=%(refname:short)",
82            "refs/heads/",
83        ],
84    ) && !out.is_empty()
85    {
86        let branches: Vec<&str> = out.lines().collect();
87        for preferred in ["master", "main"] {
88            if branches.contains(&preferred) {
89                return Ok(preferred.to_string());
90            }
91        }
92        if let Some(first) = branches.first() {
93            return Ok((*first).to_string());
94        }
95    }
96    if let Ok(out) = git_output_in(
97        cwd,
98        &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
99    ) && let Some(name) = out.strip_prefix("origin/")
100    {
101        return Ok(name.to_string());
102    }
103    if let Some(name) = env.var("GITHUB_REF_NAME")
104        && !name.is_empty()
105        && is_branchlike(&name)
106    {
107        return Ok(name);
108    }
109    anyhow::bail!(
110        "could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
111    )
112}
113
114/// Return remote branch short names that contain `sha` (e.g. `master`,
115/// `release/v1`). The bump commit's SHA is the deterministic anchor of
116/// the tag, so deriving the push branch from it is race-immune to the
117/// default branch moving between bump and rollback. Empty `Vec` when
118/// the SHA is not on any remote branch (orphan / not-yet-pushed).
119pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
120    let out = git_output_in(
121        cwd,
122        &[
123            "branch",
124            "-r",
125            "--contains",
126            sha,
127            "--format=%(refname:short)",
128        ],
129    )?;
130    Ok(out
131        .lines()
132        .filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
133        .filter(|name| !name.is_empty() && name != "HEAD")
134        .collect())
135}
136
137/// Check if there are any commits since a given tag.
138pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
139    has_commits_since_tag_in(&cwd_or_dot(), tag)
140}
141
142/// Path-taking sibling of [`has_commits_since_tag`].
143pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
144    let range = format!("{}..HEAD", tag);
145    let output = git_output_in(
146        cwd,
147        &["-c", "log.showSignature=false", "log", "--oneline", &range],
148    )?;
149    Ok(!output.is_empty())
150}
151
152/// Count the commits on HEAD since the most recent reachable tag.
153///
154/// Resolves the last tag with `git describe --tags --abbrev=0 HEAD`, then
155/// returns `git rev-list --count <tag>..HEAD`. When HEAD has no reachable
156/// tag (a repo whose first version tag has not landed yet), the total
157/// commit count on HEAD is returned instead (`git rev-list --count HEAD`).
158///
159/// `monorepo_prefix` constrains the `describe` to tags matching
160/// `<prefix>*` (via `--match`), so in a per-crate workspace the count is
161/// since the matching crate's tag rather than the nearest tag from ANY
162/// subproject. `None` considers all tags.
163///
164/// This is the stateless basis for the `{{ .NightlyBuild }}` template var:
165/// the count resets to a small number the moment a new version tag lands,
166/// so a nightly build counter increments per base version with no state
167/// anodizer must persist.
168///
169/// Returns `Ok(0)` for an empty repository (no commits) so callers never
170/// have to special-case the unborn-HEAD state.
171pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
172    // `--abbrev=0` yields the bare tag name (no `-<n>-g<sha>` suffix).
173    // A repo with no reachable tag exits non-zero here; treat that as
174    // "count every commit on HEAD" rather than an error.
175    //
176    // `--match=<prefix>*` (when a monorepo prefix is set) restricts the
177    // describe to the matching crate's tags — without it, describe returns
178    // the nearest reachable tag from ANY subproject and the count would be
179    // since the wrong crate's tag. Mirrors `find_previous_tag_with_prefix_in`.
180    let match_arg;
181    let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
182    if let Some(prefix) = monorepo_prefix {
183        match_arg = format!("--match={}*", prefix);
184        describe_args.push(&match_arg);
185    }
186    describe_args.push("HEAD");
187    let range = match git_output_in(cwd, &describe_args) {
188        Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
189        _ => "HEAD".to_string(),
190    };
191    // An empty repo (unborn HEAD) makes `rev-list` fail; map that to 0.
192    let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
193        Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
194        Err(_) => 0,
195    };
196    Ok(count)
197}
198
199/// Get the short commit hash of HEAD.
200pub fn get_short_commit() -> Result<String> {
201    get_short_commit_in(&cwd_or_dot())
202}
203
204/// Path-taking sibling of [`get_short_commit`].
205pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
206    git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
207}
208
209/// Default short-commit length used across error messages, log
210/// output, and any place that needs to truncate a full SHA for
211/// human display. Matches git's `--short` default (7) — and the
212/// `ShortCommit` template var populated by [`crate::git::detect_git_info`]
213/// (which delegates to `git rev-parse --short`).
214pub const SHORT_COMMIT_LEN: usize = 7;
215
216/// Truncate a full commit SHA string to [`SHORT_COMMIT_LEN`]
217/// characters. Returns the input unchanged when it's already shorter
218/// or equal in length. Use this any time the SHA arrives as a string
219/// (e.g. deserialized from a manifest or read from a template var)
220/// rather than running `git rev-parse --short` again — saves a
221/// subprocess and keeps the length convention in one place.
222///
223/// Empty input returns empty; callers needing fail-closed semantics
224/// (e.g. publish-only's commit cross-check) check `is_empty()`
225/// before calling.
226pub fn short_commit_str(commit: &str) -> String {
227    if commit.len() > SHORT_COMMIT_LEN {
228        commit[..SHORT_COMMIT_LEN].to_string()
229    } else {
230        commit.to_string()
231    }
232}
233
234/// Get the full commit hash of HEAD.
235///
236/// The full commit SHA (resolved at git-pipe time and
237/// reused everywhere downstream). Used by the source-archive stage to
238/// produce deterministic archives across consecutive commits when
239/// `git_info` was not pre-populated by an earlier pipe.
240pub fn get_head_commit() -> Result<String> {
241    get_head_commit_in(&cwd_or_dot())
242}
243
244/// Path-taking sibling of [`get_head_commit`].
245pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
246    git_output_in(cwd, &["rev-parse", "HEAD"])
247}
248
249/// Check if there are changes in a path since a given tag.
250pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
251    has_changes_since_in(&cwd_or_dot(), tag, path)
252}
253
254/// Path-taking sibling of [`has_changes_since`].
255pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
256    let output = git_output_in(
257        cwd,
258        &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
259    )?;
260    Ok(!output.is_empty())
261}
262
263/// Get last N commit subjects that touched a specific path.
264pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
265    get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
266}
267
268/// Path-taking sibling of [`get_last_commit_messages_path`].
269pub fn get_last_commit_messages_path_in(
270    cwd: &Path,
271    count: usize,
272    path: &str,
273) -> Result<Vec<String>> {
274    let output = git_output_in(
275        cwd,
276        &[
277            "-c",
278            "log.showSignature=false",
279            "log",
280            &format!("-{count}"),
281            "--pretty=format:%s",
282            "--",
283            path,
284        ],
285    )?;
286    Ok(output.lines().map(str::to_string).collect())
287}
288
289/// Get commit subjects between two refs that touched a specific path.
290pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
291    get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
292}
293
294/// Path-taking sibling of [`get_commit_messages_between_path`].
295pub fn get_commit_messages_between_path_in(
296    cwd: &Path,
297    from: &str,
298    to: &str,
299    path: &str,
300) -> Result<Vec<String>> {
301    let output = git_output_in(
302        cwd,
303        &[
304            "-c",
305            "log.showSignature=false",
306            "log",
307            "--pretty=format:%s",
308            &format!("{from}..{to}"),
309            "--",
310            path,
311        ],
312    )?;
313    Ok(output.lines().map(str::to_string).collect())
314}
315
316/// Stage specific files and create a commit.
317///
318/// Returns `Ok(true)` when a commit was created, `Ok(false)` when staging
319/// produced no diff (e.g. files are already at the target state) — callers
320/// that need idempotent bump-then-tag flows can use the boolean to decide
321/// whether to skip downstream commit-dependent work without inspecting git
322/// state separately.
323pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
324    stage_and_commit_in(&cwd_or_dot(), files, message)
325}
326
327/// Path-taking sibling of [`stage_and_commit`].
328pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
329    let mut args = vec!["add", "--"];
330    args.extend(files.iter().copied());
331    git_output_in(cwd, &args)?;
332    // Idempotency guard: `git add` happily stages nothing when the working
333    // tree already matches HEAD for the given paths. Running `git commit`
334    // after would fail with "nothing to commit" (printed to stdout, not
335    // stderr) and surface a confusing empty-stderr error. Detect the
336    // no-diff case here so callers can re-run safely.
337    let diff = Command::new("git")
338        .current_dir(cwd)
339        .args(["diff", "--cached", "--quiet", "--"])
340        .args(files)
341        .env("GIT_TERMINAL_PROMPT", "0")
342        .env("LC_ALL", "C")
343        .status()?;
344    if diff.success() {
345        return Ok(false);
346    }
347    git_output_in(cwd, &["commit", "-m", message])?;
348    Ok(true)
349}
350
351/// `git -C <workspace_root> -c log.showSignature=false log
352/// --pretty=format:%B%x1e <range> -- <rel_path>` — list commit message
353/// bodies (subject+body) for commits in `range` touching `rel_path`,
354/// using the `\x1e` (RS) byte as a between-commits separator so multi-line
355/// bodies survive parsing.
356///
357/// `range` is the git revision range as a string (e.g. `"HEAD"`,
358/// `"v0.3.0..HEAD"`); the empty string is invalid (caller must pre-filter).
359/// Returns `Ok(Vec::new())` when the range's base does not exist yet
360/// (unknown/bad revision, empty repo) — a legitimate "no commits" outcome.
361/// Any other git failure (e.g. an invalid pathspec) is an `Err`, never an
362/// empty success.
363pub fn log_subjects_for_range(
364    workspace_root: &std::path::Path,
365    range: &str,
366    rel_path: &str,
367) -> Result<Vec<String>> {
368    let out = Command::new("git")
369        .arg("-C")
370        .arg(workspace_root)
371        .args([
372            "-c",
373            "log.showSignature=false",
374            "log",
375            "--pretty=format:%B%x1e",
376            range,
377            "--",
378            rel_path,
379        ])
380        .env("GIT_TERMINAL_PROMPT", "0")
381        .env("LC_ALL", "C")
382        .output()?;
383    if !out.status.success() {
384        // A range whose base doesn't exist yet (no prior tag, empty repo)
385        // is a legitimate "no commits" outcome. Any other fatal (e.g. an
386        // empty pathspec) must propagate — swallowing it made `bump`
387        // preview Skip on repos whose crate lives at the workspace root.
388        let stderr = String::from_utf8_lossy(&out.stderr);
389        let missing_range = stderr.contains("unknown revision")
390            || stderr.contains("bad revision")
391            || stderr.contains("does not have any commits yet");
392        if missing_range {
393            return Ok(Vec::new());
394        }
395        let raw = format!("git log {} failed: {}", range, stderr.trim());
396        bail!("{}", crate::redact::redact_process_env(&raw));
397    }
398    let text = String::from_utf8_lossy(&out.stdout);
399    Ok(text
400        .split('\x1e')
401        .map(|s| s.trim().to_string())
402        .filter(|s| !s.is_empty())
403        .collect())
404}
405
406/// `git -C <workspace_root> add <rel>` — stage a single relative path.
407pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
408    let out = Command::new("git")
409        .arg("-C")
410        .arg(workspace_root)
411        .arg("add")
412        .arg(rel)
413        .env("GIT_TERMINAL_PROMPT", "0")
414        .env("LC_ALL", "C")
415        .output()
416        .context("failed to invoke git add")?;
417    if !out.status.success() {
418        let stderr_raw = String::from_utf8_lossy(&out.stderr);
419        let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
420        bail!("{}", crate::redact::redact_process_env(&raw));
421    }
422    Ok(())
423}
424
425/// `git -C <workspace_root> commit [-S] -m <message>` — create a commit
426/// with the given message, optionally GPG-signed.
427pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
428    let mut cmd = Command::new("git");
429    cmd.arg("-C").arg(workspace_root).arg("commit");
430    if sign {
431        cmd.arg("-S");
432    }
433    cmd.arg("-m")
434        .arg(message)
435        .env("GIT_TERMINAL_PROMPT", "0")
436        .env("LC_ALL", "C");
437    let out = cmd.output().context("failed to invoke git commit")?;
438    if !out.status.success() {
439        let stderr_raw = String::from_utf8_lossy(&out.stderr);
440        let raw = format!("git commit failed: {}", stderr_raw.trim());
441        bail!("{}", crate::redact::redact_process_env(&raw));
442    }
443    Ok(())
444}
445
446/// `git diff --name-only <tag>..HEAD -- <paths>...` — return `true` when
447/// any of the named paths changed between `tag` and `HEAD`. Returns
448/// `Ok(false)` when git fails (e.g. not a git repo) so callers can treat
449/// the absence-of-info case as "no changes".
450pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
451    paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
452}
453
454/// Path-taking sibling of [`paths_changed_since_tag`].
455pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
456    let mut args: Vec<String> = vec![
457        "diff".to_string(),
458        "--name-only".to_string(),
459        format!("{tag}..HEAD"),
460        "--".to_string(),
461    ];
462    for p in paths {
463        args.push((*p).to_string());
464    }
465    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
466    let output = Command::new("git")
467        .current_dir(cwd)
468        .args(&arg_refs)
469        .env("GIT_TERMINAL_PROMPT", "0")
470        .env("LC_ALL", "C")
471        .output()?;
472    if output.status.success() {
473        Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
474    } else {
475        Ok(false)
476    }
477}
478
479/// Return HEAD's full commit hash for the given repository (or worktree).
480///
481/// Retained as a named entry point for the determinism harness / CI glue;
482/// delegates to [`get_head_commit_in`] so HEAD-sha resolution lives in exactly
483/// one place rather than re-implementing its own `rev-parse`.
484pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
485    get_head_commit_in(repo)
486}
487
488/// Resolve a revision (sha, ref name, `HEAD`, etc.) to its full commit hash.
489///
490/// Wrapper over `git rev-parse <rev>` — errors when the revision can't be
491/// resolved (unknown sha, ambiguous short hash, not a git repo).
492pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
493    git_output_in(cwd, &["rev-parse", rev])
494}
495
496/// `git rev-parse --verify <rev>^{commit}` — resolve `rev` to a commit SHA,
497/// erroring when it does not name an existing commit. Stricter than
498/// [`rev_parse_in`]: `--verify` rejects ambiguous / non-existent refs (rather
499/// than echoing the input back), and the `^{commit}` peel rejects a ref that
500/// resolves to a non-commit object (e.g. a tree or blob SHA).
501pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
502    git_output_in(
503        cwd,
504        &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
505    )
506}
507
508/// `git rev-list <sha>..HEAD` — list the commit hashes (newest-first) that
509/// sit on top of `sha` but aren't in `sha`.
510///
511/// Returns an empty vec when `sha` IS `HEAD` (no commits between).
512pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
513    let range = format!("{}..HEAD", sha);
514    let out = git_output_in(cwd, &["rev-list", &range])?;
515    if out.is_empty() {
516        return Ok(Vec::new());
517    }
518    Ok(out.lines().map(|s| s.trim().to_string()).collect())
519}
520
521/// `git log -1 --format=%s <sha>` — return the subject line of a single
522/// commit. Used to render the "non-bump commit subject" list when the
523/// rollback safety check fires.
524pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
525    git_output_in(
526        cwd,
527        &[
528            "-c",
529            "log.showSignature=false",
530            "log",
531            "-1",
532            "--format=%s",
533            sha,
534        ],
535    )
536}
537
538/// `git log --format=%H%x1f%s <sha>..HEAD` — return every `(full_sha, subject)`
539/// pair in the range in one subprocess. Used by the rollback safety check so
540/// classifying N intervening commits is a single `git` spawn rather than
541/// `1 + N` (one `rev-list` plus one `log -1` per commit).
542///
543/// Empty range (sha IS HEAD) returns an empty vec.
544pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
545    let range = format!("{}..HEAD", sha);
546    let out = git_output_in(
547        cwd,
548        &[
549            "-c",
550            "log.showSignature=false",
551            "log",
552            "--format=%H%x1f%s",
553            &range,
554        ],
555    )?;
556    if out.is_empty() {
557        return Ok(Vec::new());
558    }
559    Ok(out
560        .lines()
561        .filter_map(|line| {
562            let mut parts = line.splitn(2, '\x1f');
563            let sha = parts.next()?.trim().to_string();
564            let subj = parts.next().unwrap_or("").to_string();
565            if sha.is_empty() {
566                None
567            } else {
568                Some((sha, subj))
569            }
570        })
571        .collect())
572}