use super::*;
use anyhow::{Context as _, Result, bail};
use std::path::Path;
use std::process::Command;
pub fn get_current_branch() -> Result<String> {
get_current_branch_in(&cwd_or_dot())
}
pub fn is_branchlike(name: &str) -> bool {
use regex::Regex;
use std::sync::OnceLock;
static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
static PER_CRATE: OnceLock<Regex> = OnceLock::new();
let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
let per_crate =
PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
!(lockstep.is_match(name) || per_crate.is_match(name))
}
pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
get_current_branch_in_with_env(cwd, &crate::ProcessEnvSource)
}
pub fn get_current_branch_in_with_env<E: crate::EnvSource + ?Sized>(
cwd: &Path,
env: &E,
) -> Result<String> {
if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
return Ok(name);
}
if let Ok(out) = git_output_in(
cwd,
&[
"for-each-ref",
"--points-at",
"HEAD",
"--format=%(refname:short)",
"refs/heads/",
],
) && !out.is_empty()
{
let branches: Vec<&str> = out.lines().collect();
for preferred in ["master", "main"] {
if branches.contains(&preferred) {
return Ok(preferred.to_string());
}
}
if let Some(first) = branches.first() {
return Ok((*first).to_string());
}
}
if let Ok(out) = git_output_in(
cwd,
&["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
) && let Some(name) = out.strip_prefix("origin/")
{
return Ok(name.to_string());
}
if let Some(name) = env.var("GITHUB_REF_NAME")
&& !name.is_empty()
&& is_branchlike(&name)
{
return Ok(name);
}
anyhow::bail!(
"could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
)
}
pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
let out = git_output_in(
cwd,
&[
"branch",
"-r",
"--contains",
sha,
"--format=%(refname:short)",
],
)?;
Ok(out
.lines()
.filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
.filter(|name| !name.is_empty() && name != "HEAD")
.collect())
}
pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
has_commits_since_tag_in(&cwd_or_dot(), tag)
}
pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
let range = format!("{}..HEAD", tag);
let output = git_output_in(
cwd,
&["-c", "log.showSignature=false", "log", "--oneline", &range],
)?;
Ok(!output.is_empty())
}
pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
let match_arg;
let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
if let Some(prefix) = monorepo_prefix {
match_arg = format!("--match={}*", prefix);
describe_args.push(&match_arg);
}
let exclude_args: Vec<String> = crate::git::tags::nightly_exclude_describe_args();
describe_args.extend(exclude_args.iter().map(String::as_str));
describe_args.push("HEAD");
let range = match git_output_in(cwd, &describe_args) {
Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
_ => "HEAD".to_string(),
};
let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
Err(_) => 0,
};
Ok(count)
}
pub fn get_short_commit() -> Result<String> {
get_short_commit_in(&cwd_or_dot())
}
pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
}
pub const SHORT_COMMIT_LEN: usize = 7;
pub fn short_commit_str(commit: &str) -> String {
if commit.len() > SHORT_COMMIT_LEN {
commit[..SHORT_COMMIT_LEN].to_string()
} else {
commit.to_string()
}
}
pub fn get_head_commit() -> Result<String> {
get_head_commit_in(&cwd_or_dot())
}
pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
git_output_in(cwd, &["rev-parse", "HEAD"])
}
pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
has_changes_since_in(&cwd_or_dot(), tag, path)
}
pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
let output = git_output_in(
cwd,
&["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
)?;
Ok(!output.is_empty())
}
pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
}
pub fn get_last_commit_messages_path_in(
cwd: &Path,
count: usize,
path: &str,
) -> Result<Vec<String>> {
let output = git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
&format!("-{count}"),
"--pretty=format:%s",
"--",
path,
],
)?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
}
pub fn get_commit_messages_between_path_in(
cwd: &Path,
from: &str,
to: &str,
path: &str,
) -> Result<Vec<String>> {
let output = git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%s",
&format!("{from}..{to}"),
"--",
path,
],
)?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
stage_and_commit_in(&cwd_or_dot(), files, message)
}
pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
let mut args = vec!["add", "--"];
args.extend(files.iter().copied());
git_output_in(cwd, &args)?;
let diff = Command::new("git")
.current_dir(cwd)
.args(["diff", "--cached", "--quiet", "--"])
.args(files)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.status()?;
if diff.success() {
return Ok(false);
}
git_output_in(cwd, &["commit", "-m", message])?;
Ok(true)
}
pub fn log_subjects_for_range(
workspace_root: &std::path::Path,
range: &str,
rel_path: &str,
) -> Result<Vec<String>> {
let out = Command::new("git")
.arg("-C")
.arg(workspace_root)
.args([
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%B%x1e",
range,
"--",
rel_path,
])
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let missing_range = stderr.contains("unknown revision")
|| stderr.contains("bad revision")
|| stderr.contains("does not have any commits yet");
if missing_range {
return Ok(Vec::new());
}
let raw = format!("git log {} failed: {}", range, stderr.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
let text = String::from_utf8_lossy(&out.stdout);
Ok(text
.split('\x1e')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
let out = Command::new("git")
.arg("-C")
.arg(workspace_root)
.arg("add")
.arg(rel)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()
.context("failed to invoke git add")?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
Ok(())
}
pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(workspace_root).arg("commit");
if sign {
cmd.arg("-S");
}
cmd.arg("-m")
.arg(message)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C");
let out = cmd.output().context("failed to invoke git commit")?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let raw = format!("git commit failed: {}", stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
Ok(())
}
pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
}
pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
let mut args: Vec<String> = vec![
"diff".to_string(),
"--name-only".to_string(),
format!("{tag}..HEAD"),
"--".to_string(),
];
for p in paths {
args.push((*p).to_string());
}
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = Command::new("git")
.current_dir(cwd)
.args(&arg_refs)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()?;
if output.status.success() {
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
} else {
Ok(false)
}
}
pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
get_head_commit_in(repo)
}
pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
git_output_in(cwd, &["rev-parse", rev])
}
pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
git_output_in(
cwd,
&["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
)
}
pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
let range = format!("{}..HEAD", sha);
let out = git_output_in(cwd, &["rev-list", &range])?;
if out.is_empty() {
return Ok(Vec::new());
}
Ok(out.lines().map(|s| s.trim().to_string()).collect())
}
pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
"-1",
"--format=%s",
sha,
],
)
}
pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
let range = format!("{}..HEAD", sha);
let out = git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
"--format=%H%x1f%s",
&range,
],
)?;
if out.is_empty() {
return Ok(Vec::new());
}
Ok(out
.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, '\x1f');
let sha = parts.next()?.trim().to_string();
let subj = parts.next().unwrap_or("").to_string();
if sha.is_empty() {
None
} else {
Some((sha, subj))
}
})
.collect())
}