release-tool 0.2.1

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
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
}