use anyhow::Result;
use chrono::Local;
use release_tool::command::{CommandRequest, CommandResult, CommandRunner, SystemCommandRunner};
use release_tool::domain::{ReleaseCandidate, ReleaseIntent};
use release_tool::git::GitRepository;
use release_tool::tag::CalendarTag;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use tempfile::TempDir;
#[test]
fn seal_tags_the_existing_commit_without_creating_a_release_commit() {
let fixture = GitFixture::new();
let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
let snapshot = repository.snapshot("main").unwrap();
let tag = snapshot.tags.next(Local::now().date_naive()).unwrap();
let candidate = ReleaseCandidate {
repository: "computed-parameter/example".to_owned(),
commit: snapshot.head.clone(),
tag,
intent: ReleaseIntent::New,
tag_already_sealed: false,
};
let branch_before = fixture.remote_ref("refs/heads/main");
let sealed = repository.seal(&candidate, "main").unwrap();
assert_eq!(sealed.release.commit, snapshot.head);
assert!(sealed.release.tag_already_sealed);
assert_eq!(fixture.remote_ref("refs/heads/main"), branch_before);
assert_eq!(
fixture.remote_ref(&format!("refs/tags/{tag}^{{}}")),
snapshot.head
);
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), snapshot.head);
}
#[test]
fn local_only_annotated_tag_is_reused_after_a_rejected_push() {
let fixture = GitFixture::new();
let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
let snapshot = repository.snapshot("main").unwrap();
let tag = snapshot.tags.next(Local::now().date_naive()).unwrap();
let candidate = ReleaseCandidate {
repository: "computed-parameter/example".to_owned(),
commit: snapshot.head,
tag,
intent: ReleaseIntent::New,
tag_already_sealed: false,
};
fixture.reject_tag_pushes();
let error = repository.seal(&candidate, "main").unwrap_err();
assert!(
error
.to_string()
.contains("local annotated tag retained for retry")
);
assert_eq!(
fixture.git(&["cat-file", "-t", &format!("refs/tags/{tag}")]),
"tag"
);
assert!(
fixture
.remote_ref_optional(&format!("refs/tags/{tag}"))
.is_none()
);
fixture.allow_tag_pushes();
let sealed = repository.seal(&candidate, "main").unwrap();
assert!(sealed.release.tag_already_sealed);
assert_eq!(
fixture.remote_ref(&format!("refs/tags/{tag}^{{}}")),
candidate.commit
);
}
#[test]
fn failed_push_response_is_treated_as_success_when_remote_tag_is_complete() {
let fixture = GitFixture::new();
let repository = GitRepository::new(
&fixture.work,
Arc::new(LostPushResponseRunner(SystemCommandRunner)),
);
let snapshot = repository.snapshot("main").unwrap();
let candidate = ReleaseCandidate {
repository: "computed-parameter/example".to_owned(),
commit: snapshot.head,
tag: snapshot.tags.next(Local::now().date_naive()).unwrap(),
intent: ReleaseIntent::New,
tag_already_sealed: false,
};
let sealed = repository.seal(&candidate, "main").unwrap();
assert!(sealed.release.tag_already_sealed);
assert_eq!(
fixture.remote_ref(&format!("refs/tags/{}^{{}}", candidate.tag)),
candidate.commit
);
}
#[test]
fn seal_stops_when_the_candidate_tag_is_lost_to_another_commit() {
let fixture = GitFixture::new();
let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
let snapshot = repository.snapshot("main").unwrap();
let candidate = ReleaseCandidate {
repository: "computed-parameter/example".to_owned(),
commit: snapshot.head,
tag: snapshot.tags.next(Local::now().date_naive()).unwrap(),
intent: ReleaseIntent::New,
tag_already_sealed: false,
};
fixture.publish_tag_on_other_commit(&candidate.tag.to_string());
let error = repository.seal(&candidate, "main").unwrap_err();
assert!(error.to_string().contains("points to"));
assert!(error.to_string().contains("expected"));
}
#[test]
fn seal_stops_when_a_newer_tag_wins_the_candidate_race() {
let fixture = GitFixture::new();
let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
let snapshot = repository.snapshot("main").unwrap();
let candidate = ReleaseCandidate {
repository: "computed-parameter/example".to_owned(),
commit: snapshot.head,
tag: snapshot.tags.next(Local::now().date_naive()).unwrap(),
intent: ReleaseIntent::New,
tag_already_sealed: false,
};
let racing_tag = format!("{}.2", candidate.tag.date().format("%Y.%m.%d"));
fixture.publish_tag_on_other_commit(&racing_tag);
let error = repository.seal(&candidate, "main").unwrap_err();
assert!(error.to_string().contains("release candidate is stale"));
}
#[test]
fn existing_intent_never_recreates_a_disappeared_remote_tag() {
let fixture = GitFixture::new();
let tag = format!("{}.1", Local::now().date_naive().format("%Y.%m.%d"));
run(&fixture.work, &["git", "tag", "-a", &tag, "-m", &tag]);
run(&fixture.work, &["git", "push", "origin", &tag]);
let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
let snapshot = repository.snapshot("main").unwrap();
let candidate = ReleaseCandidate {
repository: "computed-parameter/example".to_owned(),
commit: snapshot.head,
tag: CalendarTag::parse(&tag).unwrap(),
intent: ReleaseIntent::Existing,
tag_already_sealed: true,
};
run(
&fixture.work,
&["git", "push", "origin", &format!(":refs/tags/{tag}")],
);
let error = repository.seal(&candidate, "main").unwrap_err();
assert!(error.to_string().contains("disappeared"));
assert!(
fixture
.remote_ref_optional(&format!("refs/tags/{tag}"))
.is_none()
);
}
struct LostPushResponseRunner(SystemCommandRunner);
impl CommandRunner for LostPushResponseRunner {
fn execute(&self, request: &CommandRequest) -> Result<CommandResult> {
let mut result = self.0.execute(request)?;
if request.program == "git"
&& request.arguments.first().map(String::as_str) == Some("push")
&& result.status == 0
{
result.status = 1;
result.stderr = "simulated lost push response".to_owned();
}
Ok(result)
}
}
struct GitFixture {
_root: TempDir,
work: std::path::PathBuf,
remote: std::path::PathBuf,
}
impl GitFixture {
fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let work = root.path().join("work");
let remote = root.path().join("remote.git");
run(
root.path(),
&["git", "init", "--bare", remote.to_str().unwrap()],
);
run(
root.path(),
&[
"git",
"init",
"--initial-branch=main",
work.to_str().unwrap(),
],
);
run(&work, &["git", "config", "user.name", "Release Test"]);
run(
&work,
&["git", "config", "user.email", "release@example.com"],
);
fs::write(work.join("seed"), "seed\n").unwrap();
run(&work, &["git", "add", "--", "seed"]);
run(&work, &["git", "commit", "-m", "Seed"]);
run(
&work,
&["git", "remote", "add", "origin", remote.to_str().unwrap()],
);
run(&work, &["git", "push", "-u", "origin", "main"]);
Self {
_root: root,
work,
remote,
}
}
fn reject_tag_pushes(&self) {
let hook = self.remote.join("hooks/pre-receive");
fs::write(
&hook,
"#!/usr/bin/env bash\nset -eu\nwhile read -r old new ref; do\n case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\n",
)
.unwrap();
let mut permissions = fs::metadata(&hook).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(hook, permissions).unwrap();
}
fn allow_tag_pushes(&self) {
fs::remove_file(self.remote.join("hooks/pre-receive")).unwrap();
}
fn publish_tag_on_other_commit(&self, tag: &str) {
let other = self.git(&["commit-tree", "HEAD^{tree}", "-m", "Concurrent release"]);
run(&self.work, &["git", "tag", "-a", tag, "-m", tag, &other]);
run(&self.work, &["git", "push", "origin", tag]);
}
fn git(&self, args: &[&str]) -> String {
output(&self.work, "git", args)
}
fn remote_ref(&self, reference: &str) -> String {
output(
self._root.path(),
"git",
&[
"--git-dir",
self.remote.to_str().unwrap(),
"rev-parse",
reference,
],
)
}
fn remote_ref_optional(&self, reference: &str) -> Option<String> {
let output = Command::new("git")
.args([
"--git-dir",
self.remote.to_str().unwrap(),
"rev-parse",
reference,
])
.output()
.unwrap();
output
.status
.success()
.then(|| String::from_utf8(output.stdout).unwrap().trim().to_owned())
}
}
fn run(directory: &Path, command: &[&str]) {
let result = Command::new(command[0])
.args(&command[1..])
.current_dir(directory)
.output()
.unwrap();
assert!(
result.status.success(),
"command {:?} failed: {}",
command,
String::from_utf8_lossy(&result.stderr)
);
}
fn output(directory: &Path, program: &str, args: &[&str]) -> String {
let result = Command::new(program)
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(
result.status.success(),
"{program} {args:?} failed: {}",
String::from_utf8_lossy(&result.stderr)
);
String::from_utf8(result.stdout).unwrap().trim().to_owned()
}