use anodizer_core::git;
use anodizer_core::log::{StageLogger, Verbosity};
use anyhow::{Result, bail};
use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
All,
Lockstep,
PerCrate,
}
impl std::str::FromStr for Scope {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"all" => Ok(Scope::All),
"lockstep" => Ok(Scope::Lockstep),
"per-crate" | "percrate" => Ok(Scope::PerCrate),
other => Err(format!(
"invalid --scope value: {other:?} (expected all | lockstep | per-crate)"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Revert,
Reset,
}
impl std::str::FromStr for Mode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"revert" => Ok(Mode::Revert),
"reset" => Ok(Mode::Reset),
other => Err(format!(
"invalid --mode value: {other:?} (expected revert | reset)"
)),
}
}
}
pub struct RollbackOpts {
pub sha: Option<String>,
pub dry_run: bool,
pub no_push: bool,
pub scope: Scope,
pub mode: Mode,
pub branch: Option<String>,
pub verbose: bool,
pub debug: bool,
pub quiet: bool,
}
static PER_CRATE_TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[A-Za-z_][A-Za-z0-9_-]*-v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$")
.expect("static regex compiles")
});
static LOCKSTEP_TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$")
.expect("static regex compiles")
});
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TagKind {
Lockstep,
PerCrate,
}
fn classify_tag(tag: &str) -> Option<TagKind> {
if LOCKSTEP_TAG_RE.is_match(tag) {
Some(TagKind::Lockstep)
} else if PER_CRATE_TAG_RE.is_match(tag) {
Some(TagKind::PerCrate)
} else {
None
}
}
fn scope_includes(scope: Scope, kind: TagKind) -> bool {
matches!(
(scope, kind),
(Scope::All, _)
| (Scope::Lockstep, TagKind::Lockstep)
| (Scope::PerCrate, TagKind::PerCrate)
)
}
fn build_revert_message(target_sha: &str, deleted_tags: &[String], dry_run: bool) -> String {
let primary = deleted_tags
.iter()
.find(|t| LOCKSTEP_TAG_RE.is_match(t))
.cloned()
.unwrap_or_else(|| {
deleted_tags
.first()
.cloned()
.unwrap_or_else(|| "release".to_string())
});
let short = if target_sha.len() > 7 {
&target_sha[..7]
} else {
target_sha
};
let mut body = format!("chore(release): rollback {primary} [skip ci]\n\nReverts {short}.",);
if !deleted_tags.is_empty() {
let label = if dry_run {
"Tags that WOULD be deleted"
} else {
"Tags deleted"
};
body.push_str(&format!("\n{label}: {}", deleted_tags.join(", ")));
}
body
}
const ANODIZE_REVERT_SUBJECT_PREFIX: &str = "Revert \"chore(release): ";
pub fn run(opts: RollbackOpts) -> Result<()> {
let cwd = std::env::current_dir()?;
let log = StageLogger::new(
"tag-rollback",
Verbosity::from_flags(opts.quiet, opts.verbose, opts.debug),
);
let raw_target = opts.sha.as_deref().unwrap_or("HEAD");
let target_sha = git::rev_parse_in(&cwd, raw_target)?;
log.status(&format!("target: {} ({})", raw_target, short(&target_sha)));
let all_tags_at_sha = git::get_tags_at_sha_in(&cwd, &target_sha)?;
if all_tags_at_sha.is_empty() {
log.warn(&format!("no tags found at {}", short(&target_sha)));
bail!(
"refusing to roll back: no tags point at {} — pass the bumped commit's SHA explicitly",
short(&target_sha)
);
}
let mut deletable: Vec<String> = Vec::new();
for tag in &all_tags_at_sha {
match classify_tag(tag) {
None => log.status(&format!("skip (not anodize-shaped): {tag}")),
Some(kind) if !scope_includes(opts.scope, kind) => log.status(&format!(
"skip (scope filter --scope={:?}): {tag}",
opts.scope
)),
Some(_) => deletable.push(tag.clone()),
}
}
if deletable.is_empty() {
log.warn(&format!(
"no anodize-managed tags at {} match --scope={:?}",
short(&target_sha),
opts.scope
));
return Ok(());
}
if opts.mode == Mode::Revert {
let intervening = git::commits_with_subjects_in(&cwd, &target_sha)?;
let mut suspicious: Vec<(String, String)> = Vec::new();
for (sha, subject) in &intervening {
if subject.starts_with(ANODIZE_REVERT_SUBJECT_PREFIX)
|| subject.starts_with("chore(release): rollback")
{
continue;
}
suspicious.push((sha.clone(), subject.clone()));
}
if !suspicious.is_empty() {
let mut msg = format!(
"cannot rollback — {} non-bump commit(s) sit between HEAD and {}:\n",
suspicious.len(),
short(&target_sha)
);
for (sha, subj) in &suspicious {
msg.push_str(&format!(" {} {}\n", short(sha), subj));
}
msg.push_str("resolve manually, or use --mode=reset to force.");
bail!("{msg}");
}
}
if opts.mode == Mode::Reset {
let parent = format!("{}~1", target_sha);
if opts.dry_run {
log.status(&format!(
"(dry-run) would: git reset --hard {} (parent of bump commit)",
short(&target_sha)
));
} else {
git::reset_hard_in(&cwd, &parent)?;
log.status(&format!(
"reset HEAD to {} (parent of bump commit)",
short(&target_sha)
));
}
delete_tags(&cwd, &deletable, &opts, &log);
log.warn(
"--mode=reset rewrote local history. Push with \
`git push --force-with-lease origin <branch>` when ready.",
);
return Ok(());
}
let message = build_revert_message(&target_sha, &deletable, opts.dry_run);
if opts.dry_run {
log.status(&format!(
"(dry-run) would: git revert --no-edit {} && git commit --amend -m {:?}",
short(&target_sha),
message
));
} else {
let identity = git::resolve_rollback_identity(&cwd);
git::revert_commit_in(&cwd, &target_sha, Some(&message), &identity)?;
log.status(&format!("created revert commit: {}", first_line(&message)));
}
delete_tags(&cwd, &deletable, &opts, &log);
if opts.no_push {
log.status("--no-push: skipping branch push");
return Ok(());
}
let branch = resolve_push_branch(&cwd, &target_sha, opts.branch.as_deref())?;
if opts.dry_run {
log.status(&format!("(dry-run) would: git push origin {branch}"));
} else {
git::push_branch_in(&cwd, &branch)?;
log.status(&format!("pushed revert to origin/{branch}"));
}
Ok(())
}
fn delete_tags(
cwd: &std::path::Path,
deletable: &[String],
opts: &RollbackOpts,
log: &StageLogger,
) {
for tag in deletable {
if opts.dry_run {
log.status(&format!("(dry-run) would delete tag: {tag} (remote+local)"));
continue;
}
if !opts.no_push {
match git::delete_remote_tag_in(cwd, tag) {
Ok(()) => log.status(&format!("deleted remote tag: {tag}")),
Err(e) => log.warn(&format!(
"remote tag delete failed for {tag}: {e} (continuing)"
)),
}
} else {
log.status(&format!("--no-push: skipping remote delete for {tag}"));
}
match git::delete_local_tag_in(cwd, tag) {
Ok(()) => log.status(&format!("deleted local tag: {tag}")),
Err(e) => log.warn(&format!(
"local tag delete failed for {tag}: {e} (continuing)"
)),
}
}
}
fn resolve_push_branch(
cwd: &std::path::Path,
bump_sha: &str,
explicit: Option<&str>,
) -> Result<String> {
if let Some(b) = explicit {
return Ok(b.to_string());
}
if let Ok(branches) = git::branches_containing_sha_in(cwd, bump_sha) {
match branches.as_slice() {
[only] => return Ok(only.clone()),
[_, ..] => bail!(
"bump commit {} is reachable from {} remote branches: {}.\n\
pass --branch <name> to disambiguate.",
&bump_sha[..bump_sha.len().min(12)],
branches.len(),
branches.join(", ")
),
[] => {}
}
}
match git::get_current_branch_in(cwd) {
Ok(b) => Ok(b),
Err(_) => bail!(
"cannot determine branch for revert push — bump commit {} is \
not reachable from any remote branch and HEAD resolution failed.\n\
pass --branch <name> explicitly.",
&bump_sha[..bump_sha.len().min(12)]
),
}
}
fn short(sha: &str) -> &str {
if sha.len() > 7 { &sha[..7] } else { sha }
}
fn first_line(msg: &str) -> &str {
msg.lines().next().unwrap_or(msg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_lockstep_release_tags() {
assert_eq!(classify_tag("v1.2.3"), Some(TagKind::Lockstep));
assert_eq!(classify_tag("v0.0.1"), Some(TagKind::Lockstep));
assert_eq!(classify_tag("v10.20.30"), Some(TagKind::Lockstep));
}
#[test]
fn classifies_lockstep_prerelease_and_build_tags() {
assert_eq!(classify_tag("v1.2.3-rc.1"), Some(TagKind::Lockstep));
assert_eq!(classify_tag("v1.2.3-beta.10"), Some(TagKind::Lockstep));
assert_eq!(classify_tag("v1.2.3+build.42"), Some(TagKind::Lockstep));
assert_eq!(
classify_tag("v1.2.3-rc.1+build.42"),
Some(TagKind::Lockstep)
);
}
#[test]
fn classifies_per_crate_tags() {
assert_eq!(classify_tag("mycrate-v1.2.3"), Some(TagKind::PerCrate));
assert_eq!(
classify_tag("cfgd-operator-v0.4.0"),
Some(TagKind::PerCrate)
);
assert_eq!(
classify_tag("my_crate-v1.2.3-rc.1"),
Some(TagKind::PerCrate)
);
}
#[test]
fn rejects_non_anodize_shaped_tags() {
assert_eq!(classify_tag("foo-bar"), None);
assert_eq!(classify_tag("v1.2"), None);
assert_eq!(classify_tag("v1"), None);
assert_eq!(classify_tag("release-1.2.3"), None);
assert_eq!(classify_tag("tag-without-version"), None);
assert_eq!(classify_tag(""), None);
assert_eq!(classify_tag("v1.2.3.4"), None);
}
#[test]
fn scope_lockstep_excludes_per_crate() {
assert!(scope_includes(Scope::Lockstep, TagKind::Lockstep));
assert!(!scope_includes(Scope::Lockstep, TagKind::PerCrate));
}
#[test]
fn scope_per_crate_excludes_lockstep() {
assert!(scope_includes(Scope::PerCrate, TagKind::PerCrate));
assert!(!scope_includes(Scope::PerCrate, TagKind::Lockstep));
}
#[test]
fn scope_all_accepts_both() {
assert!(scope_includes(Scope::All, TagKind::Lockstep));
assert!(scope_includes(Scope::All, TagKind::PerCrate));
}
#[test]
fn scope_parser_round_trip() {
assert_eq!("all".parse::<Scope>().unwrap(), Scope::All);
assert_eq!("lockstep".parse::<Scope>().unwrap(), Scope::Lockstep);
assert_eq!("per-crate".parse::<Scope>().unwrap(), Scope::PerCrate);
assert_eq!("percrate".parse::<Scope>().unwrap(), Scope::PerCrate);
assert!("nope".parse::<Scope>().is_err());
}
#[test]
fn mode_parser_round_trip() {
assert_eq!("revert".parse::<Mode>().unwrap(), Mode::Revert);
assert_eq!("reset".parse::<Mode>().unwrap(), Mode::Reset);
assert!("rewind".parse::<Mode>().is_err());
}
#[test]
fn revert_message_uses_lockstep_as_subject() {
let msg = build_revert_message(
"abcdef1234567890",
&[
"mycrate-v1.0.0".into(),
"v1.0.0".into(),
"other-v1.0.0".into(),
],
false,
);
assert!(msg.starts_with("chore(release): rollback v1.0.0 [skip ci]"));
assert!(msg.contains("Reverts abcdef1."));
assert!(msg.contains("Tags deleted: mycrate-v1.0.0, v1.0.0, other-v1.0.0"));
}
#[test]
fn revert_message_falls_back_to_first_when_no_lockstep() {
let msg = build_revert_message(
"abcdef1234567890",
&["mycrate-v1.0.0".into(), "other-v1.0.0".into()],
false,
);
assert!(msg.starts_with("chore(release): rollback mycrate-v1.0.0 [skip ci]"));
}
#[test]
fn revert_message_dry_run_marks_pending_tag_deletion() {
let msg = build_revert_message("abcdef1234567890", &["v1.0.0".into()], true);
assert!(
msg.contains("Tags that WOULD be deleted: v1.0.0"),
"dry-run preview must distinguish pending deletion: {msg}"
);
assert!(
!msg.contains("\nTags deleted:"),
"dry-run preview must NOT emit the real-run label: {msg}"
);
}
#[test]
fn per_crate_regex_rejects_leading_digit() {
assert_eq!(classify_tag("9-foo-v1.2.3"), None);
assert_eq!(classify_tag("0bad-v1.0.0"), None);
assert_eq!(classify_tag("_foo-v1.2.3"), Some(TagKind::PerCrate));
}
#[test]
fn safety_check_prefix_admits_anodize_revert_only() {
let anodize_subject = "Revert \"chore(release): rollback v1.2.3 [skip ci]\"";
assert!(
anodize_subject.starts_with(ANODIZE_REVERT_SUBJECT_PREFIX),
"anodize-generated revert must be recognised"
);
let github_subject = "Revert \"feat: add new flag\"";
assert!(
!github_subject.starts_with(ANODIZE_REVERT_SUBJECT_PREFIX),
"unrelated revert PR subjects must NOT be admitted as anodize-shaped"
);
}
use std::path::Path;
use std::process::Command;
fn run_git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.expect("git invoke");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn init_bump_repo(dir: &Path, extra_commits: usize) -> String {
run_git(dir, &["init", "-b", "master"]);
run_git(dir, &["config", "user.email", "test@test.com"]);
run_git(dir, &["config", "user.name", "test"]);
std::fs::write(dir.join("README"), "init").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "initial"]);
std::fs::write(dir.join("Cargo.toml"), "[package]\nversion = \"1.0.0\"\n").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "chore(release): v1.0.0"]);
run_git(dir, &["tag", "v1.0.0"]);
let bump_sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
for i in 0..extra_commits {
let fname = format!("extra-{i}.txt");
std::fs::write(dir.join(&fname), "x").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", &format!("feat: extra work {i}")]);
}
bump_sha
}
fn opts_for(dir: &Path, sha: Option<String>) -> RollbackOpts {
let _ = dir; RollbackOpts {
sha,
dry_run: false,
no_push: true,
scope: Scope::All,
mode: Mode::Revert,
branch: None,
verbose: false,
debug: false,
quiet: true,
}
}
use serial_test::serial;
#[test]
#[serial]
fn safety_check_fires_when_non_bump_commits_sit_on_top() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let bump_sha = init_bump_repo(dir, 2);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let opts = opts_for(dir, Some(bump_sha));
let err = run(opts).expect_err("safety check should fire");
let msg = format!("{err}");
assert!(msg.contains("cannot rollback"), "got: {msg}");
assert!(
msg.contains("non-bump commit"),
"missing safety-check phrasing: {msg}"
);
}
#[test]
#[serial]
fn safety_check_passes_against_clean_head_at_bump_commit() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let _bump_sha = init_bump_repo(dir, 0);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let mut opts = opts_for(dir, None);
opts.dry_run = true; run(opts).expect("safety check should pass at HEAD == bump commit");
let tags = git::get_tags_at_head_in(dir).unwrap();
assert_eq!(tags, vec!["v1.0.0".to_string()]);
}
#[test]
#[serial]
fn dry_run_makes_no_mutations() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let _bump_sha = init_bump_repo(dir, 0);
let head_before = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let mut opts = opts_for(dir, None);
opts.dry_run = true;
run(opts).expect("dry-run should succeed");
let tags = git::get_tags_at_head_in(dir).unwrap();
assert_eq!(tags, vec!["v1.0.0".to_string()]);
let head_after = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
assert_eq!(head_before, head_after);
}
#[test]
#[serial]
fn no_push_skips_remote_ops_but_does_local_revert() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let bump_sha = init_bump_repo(dir, 0);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let opts = RollbackOpts {
sha: None,
dry_run: false,
no_push: true,
scope: Scope::All,
mode: Mode::Revert,
branch: None,
verbose: false,
debug: false,
quiet: true,
};
run(opts).expect("no-push rollback should succeed locally");
let tags = git::get_tags_at_sha_in(dir, &bump_sha).unwrap();
assert!(
tags.is_empty(),
"expected no tags at bump_sha; got {tags:?}"
);
let subj = git::commit_subject_in(dir, "HEAD").unwrap();
assert!(
subj.starts_with("chore(release): rollback v1.0.0"),
"unexpected HEAD subject: {subj}"
);
}
#[test]
#[serial]
fn skips_tags_not_matching_anodize_shape() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let bump_sha = init_bump_repo(dir, 0);
run_git(dir, &["tag", "internal-release"]);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let opts = RollbackOpts {
sha: None,
dry_run: false,
no_push: true,
scope: Scope::All,
mode: Mode::Revert,
branch: None,
verbose: false,
debug: false,
quiet: true,
};
run(opts).expect("rollback should ignore non-anodize tag");
let surviving = git::get_tags_at_sha_in(dir, &bump_sha).unwrap();
assert_eq!(surviving, vec!["internal-release".to_string()]);
}
#[test]
fn resolve_push_branch_honors_explicit_flag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let b = resolve_push_branch(
dir,
"0000000000000000000000000000000000000000",
Some("release/v9.9.9-prep"),
)
.unwrap();
assert_eq!(b, "release/v9.9.9-prep");
}
#[test]
#[serial]
fn resolve_push_branch_hard_fails_on_detached_head_without_branch() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
run_git(dir, &["init", "-b", "master"]);
run_git(dir, &["config", "user.email", "t@t.com"]);
run_git(dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c1"]);
let older_sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(dir.join("a"), "2").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c2"]);
run_git(dir, &["checkout", "--detach", &older_sha]);
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => unsafe { std::env::set_var(self.0, v) },
None => unsafe { std::env::remove_var(self.0) },
}
}
}
let _g = EnvGuard("GITHUB_REF_NAME", std::env::var("GITHUB_REF_NAME").ok());
unsafe { std::env::remove_var("GITHUB_REF_NAME") };
let err = resolve_push_branch(dir, &older_sha, None).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("cannot determine branch for revert push"),
"missing hard-fail phrasing: {msg}"
);
assert!(
msg.contains("--branch <name>"),
"hard-fail must name the remediation flag: {msg}"
);
}
#[test]
#[serial]
fn resolve_push_branch_hard_fails_when_github_ref_name_looks_like_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
run_git(dir, &["init", "-b", "master"]);
run_git(dir, &["config", "user.email", "t@t.com"]);
run_git(dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c1"]);
let older_sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(dir.join("a"), "2").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c2"]);
run_git(dir, &["checkout", "--detach", &older_sha]);
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => unsafe { std::env::set_var(self.0, v) },
None => unsafe { std::env::remove_var(self.0) },
}
}
}
let _g = EnvGuard("GITHUB_REF_NAME", std::env::var("GITHUB_REF_NAME").ok());
unsafe { std::env::set_var("GITHUB_REF_NAME", "v0.4.5") };
let err = resolve_push_branch(dir, &older_sha, None).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("cannot determine branch for revert push"),
"tag-shaped GITHUB_REF_NAME must trigger the operator-facing hard-fail: {msg}"
);
}
#[test]
#[serial]
fn resolve_push_branch_explicit_branch_wins_over_detached_head() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
run_git(dir, &["init", "-b", "master"]);
run_git(dir, &["config", "user.email", "t@t.com"]);
run_git(dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c1"]);
let older_sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(dir.join("a"), "2").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c2"]);
run_git(dir, &["checkout", "--detach", &older_sha]);
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => unsafe { std::env::set_var(self.0, v) },
None => unsafe { std::env::remove_var(self.0) },
}
}
}
let _g = EnvGuard("GITHUB_REF_NAME", std::env::var("GITHUB_REF_NAME").ok());
unsafe { std::env::set_var("GITHUB_REF_NAME", "v0.4.5") };
let b = resolve_push_branch(dir, &older_sha, Some("master")).unwrap();
assert_eq!(b, "master");
}
}