use crate::command::{CommandRequest, CommandRunner};
use crate::domain::{ReleaseCandidate, ReleaseIntent, SealedRelease};
use crate::tag::TagCatalog;
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RepositorySnapshot {
pub root: PathBuf,
pub branch: String,
pub head: String,
pub remote_branch_head: String,
pub tags: TagCatalog,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitRefSnapshot {
local: Vec<String>,
remote: Vec<String>,
}
pub struct GitRepository {
root: PathBuf,
runner: Arc<dyn CommandRunner>,
}
impl GitRepository {
pub fn new(root: impl Into<PathBuf>, runner: Arc<dyn CommandRunner>) -> Self {
Self {
root: root.into(),
runner,
}
}
pub fn snapshot(&self, expected_branch: &str) -> Result<RepositorySnapshot> {
let status = self.git(&["status", "--porcelain", "--untracked-files=all"])?;
if !status.trim().is_empty() {
bail!("release requires a clean worktree:\n{}", status.trim_end());
}
let branch = self.git(&["branch", "--show-current"])?;
let branch = branch.trim().to_owned();
if branch.is_empty() {
bail!("release requires a branch checkout; detached HEAD is not supported");
}
if branch != expected_branch {
bail!("release requires branch `{expected_branch}`; current branch is `{branch}`");
}
let head = self.git(&["rev-parse", "HEAD"])?;
let head = head.trim().to_owned();
let branch_ref = format!("refs/heads/{expected_branch}");
let remote_branch = self.git(&["ls-remote", "origin", &branch_ref])?;
let remote_branch_head = parse_single_ref(&remote_branch, &branch_ref)?;
if head != remote_branch_head {
bail!(
"HEAD must equal origin/{expected_branch}: local {head}, remote {remote_branch_head}"
);
}
let remote_tags = self.git(&["ls-remote", "--tags", "origin"])?;
let tags = TagCatalog::parse_ls_remote(&remote_tags)?;
Ok(RepositorySnapshot {
root: self.root.clone(),
branch,
head,
remote_branch_head,
tags,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn origin_url(&self) -> Result<String> {
Ok(self
.git(&["remote", "get-url", "origin"])?
.trim()
.to_owned())
}
pub fn ref_snapshot(&self) -> Result<GitRefSnapshot> {
let local = self.git(&["for-each-ref", "--format=%(objectname)%09%(refname)"])?;
let remote = self.git(&["ls-remote", "--heads", "--tags", "origin"])?;
Ok(GitRefSnapshot {
local: normalized_refs(&local),
remote: normalized_refs(&remote),
})
}
pub fn ensure_refs_unchanged(&self, before: &GitRefSnapshot, phase: &str) -> Result<()> {
let after = self.ref_snapshot()?;
if after.local != before.local {
bail!("local Git refs changed during {phase}");
}
if after.remote != before.remote {
bail!("remote Git refs changed during {phase}");
}
Ok(())
}
pub fn seal(
&self,
candidate: &ReleaseCandidate,
expected_branch: &str,
) -> Result<SealedRelease> {
let before = self.snapshot(expected_branch)?;
if before.head != candidate.commit {
bail!(
"release candidate commit changed: expected {}, found {}",
candidate.commit,
before.head
);
}
if let Some(existing) = before.tags.unique_release_for_commit(&candidate.commit)? {
if existing.name != candidate.tag {
bail!(
"commit {} is already sealed by release tag {}, not {}",
candidate.commit,
existing.name,
candidate.tag
);
}
return Ok(sealed(candidate, &existing.object));
}
if candidate.tag_already_sealed || candidate.intent == ReleaseIntent::Existing {
bail!(
"remote release tag {} disappeared; existing releases are never recreated automatically",
candidate.tag
);
}
if let Some(conflict) = before.tags.get(candidate.tag) {
bail!(
"remote release tag {} points to {}, expected {}",
candidate.tag,
conflict.commit,
candidate.commit
);
}
let expected = before.tags.next(candidate.tag.date())?;
if expected != candidate.tag {
bail!(
"release candidate is stale: expected next tag {expected}, planned {}",
candidate.tag
);
}
let reference = format!("refs/tags/{}", candidate.tag);
let local_type = self.git_result(&["cat-file", "-t", &reference])?;
if local_type.status == 0 {
if local_type.stdout.trim() != "tag" {
bail!("local release tag {} must be annotated", candidate.tag);
}
let commit_reference = format!("{reference}^{{commit}}");
let local_commit = self.git(&["rev-parse", &commit_reference])?;
if local_commit.trim() != candidate.commit {
bail!(
"local release tag {} points to {}, expected {}",
candidate.tag,
local_commit.trim(),
candidate.commit
);
}
} else {
let message = format!("Release {}", candidate.tag);
self.git(&["tag", "-a", &candidate.tag.to_string(), "-m", &message])?;
}
let push = self.git_result(&["push", "origin", &reference])?;
let after = self.snapshot(expected_branch)?;
if let Some(current_release) = after.tags.unique_release_for_commit(&candidate.commit)? {
if current_release.name != candidate.tag {
bail!(
"release identity changed during tag push: expected {}, found {}",
candidate.tag,
current_release.name
);
}
}
match after.tags.get(candidate.tag) {
Some(remote) if remote.annotated && remote.commit == candidate.commit => {
Ok(sealed(candidate, &remote.object))
}
Some(remote) => bail!(
"remote release tag {} is inconsistent after push: annotated={}, commit={}",
candidate.tag,
remote.annotated,
remote.commit
),
None => {
let expected_after_push = after.tags.next(candidate.tag.date())?;
if expected_after_push != candidate.tag {
bail!(
"remote tag catalog changed during push; planned {}, next is {expected_after_push}; stop automatic retry",
candidate.tag
);
}
if push.status != 0 {
bail!(
"release tag push did not modify remote; local annotated tag retained for retry: {}",
candidate.tag
);
}
bail!(
"release tag push reported success but remote tag is absent: {}",
candidate.tag
);
}
}
}
pub fn revalidate_prepared(
&self,
candidate: &ReleaseCandidate,
expected_branch: &str,
) -> Result<()> {
let status = self.git(&["status", "--porcelain", "--untracked-files=all"])?;
if !status.trim().is_empty() {
if status.lines().all(|line| line.starts_with("?? ")) {
bail!(
"Prepare created untracked repository files outside tool staging:\n{}",
status.trim_end()
);
}
bail!("Prepare modified tracked source:\n{}", status.trim_end());
}
let branch = self.git(&["branch", "--show-current"])?;
if branch.trim() != expected_branch {
bail!("release branch changed during Prepare");
}
let head = self.git(&["rev-parse", "HEAD"])?;
if head.trim() != candidate.commit {
bail!("HEAD changed during Prepare");
}
let branch_ref = format!("refs/heads/{expected_branch}");
let remote = self.git(&["ls-remote", "origin", &branch_ref])?;
if parse_single_ref(&remote, &branch_ref)? != candidate.commit {
bail!("origin/{expected_branch} changed during Prepare");
}
let tags = TagCatalog::parse_ls_remote(&self.git(&["ls-remote", "--tags", "origin"])?)?;
if candidate.tag_already_sealed {
let release = tags
.unique_release_for_commit(&candidate.commit)?
.context("remote release tag disappeared during Prepare")?;
if release.name != candidate.tag {
bail!("remote release identity changed during Prepare");
}
} else {
if let Some(conflict) = tags.get(candidate.tag) {
bail!(
"candidate tag {} appeared during Prepare at commit {}",
candidate.tag,
conflict.commit
);
}
let expected = tags.next(candidate.tag.date())?;
if expected != candidate.tag {
bail!(
"next release tag changed during Prepare: {} -> {expected}",
candidate.tag
);
}
}
Ok(())
}
fn git(&self, arguments: &[&str]) -> Result<String> {
Ok(self
.git_result(arguments)?
.require_success(&format!("git {}", arguments.join(" ")))?
.stdout)
}
fn git_result(&self, arguments: &[&str]) -> Result<crate::command::CommandResult> {
let request = CommandRequest::new("git", arguments.iter().copied(), &self.root);
self.runner.execute(&request)
}
}
fn sealed(candidate: &ReleaseCandidate, object: &str) -> SealedRelease {
let mut release = candidate.clone();
release.tag_already_sealed = true;
SealedRelease {
release,
tag_object: object.to_owned(),
}
}
fn parse_single_ref(output: &str, expected_ref: &str) -> Result<String> {
let mut matches = output.lines().filter_map(|line| {
let (oid, reference) = line.split_once(char::is_whitespace)?;
(reference.trim() == expected_ref).then(|| oid.to_owned())
});
let value = matches
.next()
.with_context(|| format!("remote ref `{expected_ref}` does not exist"))?;
if matches.next().is_some() {
bail!("remote ref `{expected_ref}` was returned more than once");
}
Ok(value)
}
fn normalized_refs(output: &str) -> Vec<String> {
let mut refs = output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
refs.sort();
refs
}