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}