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    // A stranded nightly tag at/near HEAD must not reset the counter — the
187    // count is "commits since the last VERSION tag", and nightly tags are
188    // never a version signal (same exclusion as the previous-tag paths).
189    let exclude_args: Vec<String> = crate::git::tags::nightly_exclude_describe_args();
190    describe_args.extend(exclude_args.iter().map(String::as_str));
191    describe_args.push("HEAD");
192    let range = match git_output_in(cwd, &describe_args) {
193        Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
194        _ => "HEAD".to_string(),
195    };
196    // An empty repo (unborn HEAD) makes `rev-list` fail; map that to 0.
197    let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
198        Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
199        Err(_) => 0,
200    };
201    Ok(count)
202}
203
204/// Get the short commit hash of HEAD.
205pub fn get_short_commit() -> Result<String> {
206    get_short_commit_in(&cwd_or_dot())
207}
208
209/// Path-taking sibling of [`get_short_commit`].
210pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
211    git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
212}
213
214/// Default short-commit length used across error messages, log
215/// output, and any place that needs to truncate a full SHA for
216/// human display. Matches git's `--short` default (7) — and the
217/// `ShortCommit` template var populated by [`crate::git::detect_git_info`]
218/// (which delegates to `git rev-parse --short`).
219pub const SHORT_COMMIT_LEN: usize = 7;
220
221/// Truncate a full commit SHA string to [`SHORT_COMMIT_LEN`]
222/// characters. Returns the input unchanged when it's already shorter
223/// or equal in length. Use this any time the SHA arrives as a string
224/// (e.g. deserialized from a manifest or read from a template var)
225/// rather than running `git rev-parse --short` again — saves a
226/// subprocess and keeps the length convention in one place.
227///
228/// Empty input returns empty; callers needing fail-closed semantics
229/// (e.g. publish-only's commit cross-check) check `is_empty()`
230/// before calling.
231pub fn short_commit_str(commit: &str) -> String {
232    if commit.len() > SHORT_COMMIT_LEN {
233        commit[..SHORT_COMMIT_LEN].to_string()
234    } else {
235        commit.to_string()
236    }
237}
238
239/// Get the full commit hash of HEAD.
240///
241/// The full commit SHA (resolved at git-pipe time and
242/// reused everywhere downstream). Used by the source-archive stage to
243/// produce deterministic archives across consecutive commits when
244/// `git_info` was not pre-populated by an earlier pipe.
245pub fn get_head_commit() -> Result<String> {
246    get_head_commit_in(&cwd_or_dot())
247}
248
249/// Path-taking sibling of [`get_head_commit`].
250pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
251    git_output_in(cwd, &["rev-parse", "HEAD"])
252}
253
254/// Check if there are changes in a path since a given tag.
255pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
256    has_changes_since_in(&cwd_or_dot(), tag, path)
257}
258
259/// Path-taking sibling of [`has_changes_since`].
260pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
261    let output = git_output_in(
262        cwd,
263        &["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
264    )?;
265    Ok(!output.is_empty())
266}
267
268/// Get last N commit subjects that touched a specific path.
269pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
270    get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
271}
272
273/// Path-taking sibling of [`get_last_commit_messages_path`].
274pub fn get_last_commit_messages_path_in(
275    cwd: &Path,
276    count: usize,
277    path: &str,
278) -> Result<Vec<String>> {
279    let output = git_output_in(
280        cwd,
281        &[
282            "-c",
283            "log.showSignature=false",
284            "log",
285            &format!("-{count}"),
286            "--pretty=format:%s",
287            "--",
288            path,
289        ],
290    )?;
291    Ok(output.lines().map(str::to_string).collect())
292}
293
294/// Get commit subjects between two refs that touched a specific path.
295pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
296    get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
297}
298
299/// Path-taking sibling of [`get_commit_messages_between_path`].
300pub fn get_commit_messages_between_path_in(
301    cwd: &Path,
302    from: &str,
303    to: &str,
304    path: &str,
305) -> Result<Vec<String>> {
306    let output = git_output_in(
307        cwd,
308        &[
309            "-c",
310            "log.showSignature=false",
311            "log",
312            "--pretty=format:%s",
313            &format!("{from}..{to}"),
314            "--",
315            path,
316        ],
317    )?;
318    Ok(output.lines().map(str::to_string).collect())
319}
320
321/// Stage specific files and create a commit.
322///
323/// Returns `Ok(true)` when a commit was created, `Ok(false)` when staging
324/// produced no diff (e.g. files are already at the target state) — callers
325/// that need idempotent bump-then-tag flows can use the boolean to decide
326/// whether to skip downstream commit-dependent work without inspecting git
327/// state separately.
328pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
329    stage_and_commit_in(&cwd_or_dot(), files, message)
330}
331
332/// Path-taking sibling of [`stage_and_commit`].
333pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
334    let mut args = vec!["add", "--"];
335    args.extend(files.iter().copied());
336    git_output_in(cwd, &args)?;
337    // Idempotency guard: `git add` happily stages nothing when the working
338    // tree already matches HEAD for the given paths. Running `git commit`
339    // after would fail with "nothing to commit" (printed to stdout, not
340    // stderr) and surface a confusing empty-stderr error. Detect the
341    // no-diff case here so callers can re-run safely.
342    let diff = Command::new("git")
343        .current_dir(cwd)
344        .args(["diff", "--cached", "--quiet", "--"])
345        .args(files)
346        .env("GIT_TERMINAL_PROMPT", "0")
347        .env("LC_ALL", "C")
348        .status()?;
349    if diff.success() {
350        return Ok(false);
351    }
352    git_output_in(cwd, &["commit", "-m", message])?;
353    Ok(true)
354}
355
356/// `git -C <workspace_root> -c log.showSignature=false log
357/// --pretty=format:%B%x1e <range> -- <rel_path>` — list commit message
358/// bodies (subject+body) for commits in `range` touching `rel_path`,
359/// using the `\x1e` (RS) byte as a between-commits separator so multi-line
360/// bodies survive parsing.
361///
362/// `range` is the git revision range as a string (e.g. `"HEAD"`,
363/// `"v0.3.0..HEAD"`); the empty string is invalid (caller must pre-filter).
364/// Returns `Ok(Vec::new())` when the range's base does not exist yet
365/// (unknown/bad revision, empty repo) — a legitimate "no commits" outcome.
366/// Any other git failure (e.g. an invalid pathspec) is an `Err`, never an
367/// empty success.
368pub fn log_subjects_for_range(
369    workspace_root: &std::path::Path,
370    range: &str,
371    rel_path: &str,
372) -> Result<Vec<String>> {
373    let out = Command::new("git")
374        .arg("-C")
375        .arg(workspace_root)
376        .args([
377            "-c",
378            "log.showSignature=false",
379            "log",
380            "--pretty=format:%B%x1e",
381            range,
382            "--",
383            rel_path,
384        ])
385        .env("GIT_TERMINAL_PROMPT", "0")
386        .env("LC_ALL", "C")
387        .output()?;
388    if !out.status.success() {
389        // A range whose base doesn't exist yet (no prior tag, empty repo)
390        // is a legitimate "no commits" outcome. Any other fatal (e.g. an
391        // empty pathspec) must propagate — swallowing it made `bump`
392        // preview Skip on repos whose crate lives at the workspace root.
393        let stderr = String::from_utf8_lossy(&out.stderr);
394        let missing_range = stderr.contains("unknown revision")
395            || stderr.contains("bad revision")
396            || stderr.contains("does not have any commits yet");
397        if missing_range {
398            return Ok(Vec::new());
399        }
400        let raw = format!("git log {} failed: {}", range, stderr.trim());
401        bail!("{}", crate::redact::redact_process_env(&raw));
402    }
403    let text = String::from_utf8_lossy(&out.stdout);
404    Ok(text
405        .split('\x1e')
406        .map(|s| s.trim().to_string())
407        .filter(|s| !s.is_empty())
408        .collect())
409}
410
411/// `git -C <workspace_root> add <rel>` — stage a single relative path.
412pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
413    let out = Command::new("git")
414        .arg("-C")
415        .arg(workspace_root)
416        .arg("add")
417        .arg(rel)
418        .env("GIT_TERMINAL_PROMPT", "0")
419        .env("LC_ALL", "C")
420        .output()
421        .context("failed to invoke git add")?;
422    if !out.status.success() {
423        let stderr_raw = String::from_utf8_lossy(&out.stderr);
424        let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
425        bail!("{}", crate::redact::redact_process_env(&raw));
426    }
427    Ok(())
428}
429
430/// `git -C <workspace_root> commit [-S] -m <message>` — create a commit
431/// with the given message, optionally GPG-signed.
432pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
433    let mut cmd = Command::new("git");
434    cmd.arg("-C").arg(workspace_root).arg("commit");
435    if sign {
436        cmd.arg("-S");
437    }
438    cmd.arg("-m")
439        .arg(message)
440        .env("GIT_TERMINAL_PROMPT", "0")
441        .env("LC_ALL", "C");
442    let out = cmd.output().context("failed to invoke git commit")?;
443    if !out.status.success() {
444        let stderr_raw = String::from_utf8_lossy(&out.stderr);
445        let raw = format!("git commit failed: {}", stderr_raw.trim());
446        bail!("{}", crate::redact::redact_process_env(&raw));
447    }
448    Ok(())
449}
450
451/// `git diff --name-only <tag>..HEAD -- <paths>...` — return `true` when
452/// any of the named paths changed between `tag` and `HEAD`. Returns
453/// `Ok(false)` when git fails (e.g. not a git repo) so callers can treat
454/// the absence-of-info case as "no changes".
455pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
456    paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
457}
458
459/// Path-taking sibling of [`paths_changed_since_tag`].
460pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
461    let mut args: Vec<String> = vec![
462        "diff".to_string(),
463        "--name-only".to_string(),
464        format!("{tag}..HEAD"),
465        "--".to_string(),
466    ];
467    for p in paths {
468        args.push((*p).to_string());
469    }
470    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
471    let output = Command::new("git")
472        .current_dir(cwd)
473        .args(&arg_refs)
474        .env("GIT_TERMINAL_PROMPT", "0")
475        .env("LC_ALL", "C")
476        .output()?;
477    if output.status.success() {
478        Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
479    } else {
480        Ok(false)
481    }
482}
483
484/// Return HEAD's full commit hash for the given repository (or worktree).
485///
486/// Retained as a named entry point for the determinism harness / CI glue;
487/// delegates to [`get_head_commit_in`] so HEAD-sha resolution lives in exactly
488/// one place rather than re-implementing its own `rev-parse`.
489pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
490    get_head_commit_in(repo)
491}
492
493/// Resolve a revision (sha, ref name, `HEAD`, etc.) to its full commit hash.
494///
495/// Wrapper over `git rev-parse <rev>` — errors when the revision can't be
496/// resolved (unknown sha, ambiguous short hash, not a git repo).
497pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
498    git_output_in(cwd, &["rev-parse", rev])
499}
500
501/// `git rev-parse --verify <rev>^{commit}` — resolve `rev` to a commit SHA,
502/// erroring when it does not name an existing commit. Stricter than
503/// [`rev_parse_in`]: `--verify` rejects ambiguous / non-existent refs (rather
504/// than echoing the input back), and the `^{commit}` peel rejects a ref that
505/// resolves to a non-commit object (e.g. a tree or blob SHA).
506pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
507    git_output_in(
508        cwd,
509        &["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
510    )
511}
512
513/// `git rev-list <sha>..HEAD` — list the commit hashes (newest-first) that
514/// sit on top of `sha` but aren't in `sha`.
515///
516/// Returns an empty vec when `sha` IS `HEAD` (no commits between).
517pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
518    let range = format!("{}..HEAD", sha);
519    let out = git_output_in(cwd, &["rev-list", &range])?;
520    if out.is_empty() {
521        return Ok(Vec::new());
522    }
523    Ok(out.lines().map(|s| s.trim().to_string()).collect())
524}
525
526/// `git log -1 --format=%s <sha>` — return the subject line of a single
527/// commit. Used to render the "non-bump commit subject" list when the
528/// rollback safety check fires.
529pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
530    git_output_in(
531        cwd,
532        &[
533            "-c",
534            "log.showSignature=false",
535            "log",
536            "-1",
537            "--format=%s",
538            sha,
539        ],
540    )
541}
542
543/// `git log --format=%H%x1f%s <sha>..HEAD` — return every `(full_sha, subject)`
544/// pair in the range in one subprocess. Used by the rollback safety check so
545/// classifying N intervening commits is a single `git` spawn rather than
546/// `1 + N` (one `rev-list` plus one `log -1` per commit).
547///
548/// Empty range (sha IS HEAD) returns an empty vec.
549pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
550    let range = format!("{}..HEAD", sha);
551    let out = git_output_in(
552        cwd,
553        &[
554            "-c",
555            "log.showSignature=false",
556            "log",
557            "--format=%H%x1f%s",
558            &range,
559        ],
560    )?;
561    if out.is_empty() {
562        return Ok(Vec::new());
563    }
564    Ok(out
565        .lines()
566        .filter_map(|line| {
567            let mut parts = line.splitn(2, '\x1f');
568            let sha = parts.next()?.trim().to_string();
569            let subj = parts.next().unwrap_or("").to_string();
570            if sha.is_empty() {
571                None
572            } else {
573                Some((sha, subj))
574            }
575        })
576        .collect())
577}