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}