semver-sync 0.1.2

Keep one version across every crate in a cargo workspace; bumps are checked as docker tags before any manifest is written.
//! Releasing a version through git, the way `git-tag` asks for it.

use std::path::Path;
use std::process::Command;

use semver::Version;

use crate::Result;

/// Tag `v<VERSION>` on the commit HEAD is standing on, then push the commits
/// and the tag with them. Each step waits for the one before it: a tag nobody
/// else can see is not a release, and pushing tags git never made pushes
/// nothing.
///
/// git explains its own failures on stderr, so what comes back here is only
/// which step stopped and what is therefore still on this machine alone.
pub fn release(root: &Path, version: &Version) -> Result<()> {
    let tag = format!("v{version}");

    if !run(root, &["tag", "-a", &tag, "-m", &tag])? {
        return Err(format!("`git tag -a {tag}` failed; nothing was pushed"));
    }

    for args in [&["push"][..], &["push", "--tags"][..]] {
        if !run(root, args)? {
            let step = format!("git {}", args.join(" "));

            return Err(format!(
                "`{step}` failed; {tag} is a tag on this machine only"
            ));
        }
    }

    Ok(())
}

/// One git command, run inside the repo. True when git exited 0: only the
/// spawning is this function's to name, since git reports the rest itself.
fn run(root: &Path, args: &[&str]) -> Result<bool> {
    Command::new("git")
        .args(args)
        .current_dir(root)
        .status()
        .map(|done| done.success())
        .map_err(|err| format!("cannot run `git {}`: {err}", args[0]))
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;

    /// The identity and the signing options, so a test commits the same way on
    /// a machine whose owner has gpg hooks wired up.
    const CONFIG: [&str; 8] = [
        "-c",
        "user.name=semver-sync",
        "-c",
        "user.email=semver-sync@example.com",
        "-c",
        "commit.gpgsign=false",
        "-c",
        "tag.gpgSign=false",
    ];

    /// One git command that has to work, with what it printed.
    fn git(dir: &Path, args: &[&str]) -> String {
        let done = Command::new("git")
            .args(CONFIG)
            .args(args)
            .current_dir(dir)
            .output()
            .expect("run git");

        assert!(
            done.status.success(),
            "`git {}` failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&done.stderr)
        );

        String::from_utf8_lossy(&done.stdout).trim_end().to_string()
    }

    /// A repo with one commit and a bare remote it has pushed to, which is the
    /// shape a real repo is in when a release is being made.
    fn a_repo_with_somewhere_to_push(dir: &Path) -> (PathBuf, PathBuf) {
        let repo = dir.join("repo");
        let remote = dir.join("remote.git");
        std::fs::create_dir_all(&repo).expect("create the repo dir");

        let remote_name = remote.to_string_lossy().to_string();
        git(dir, &["init", "--bare", "-q", &remote_name]);
        git(&repo, &["init", "-q", "-b", "main"]);
        std::fs::write(repo.join("Cargo.toml"), "[package]\n").expect("commit a file");
        git(&repo, &["add", "."]);
        git(&repo, &["commit", "-q", "-m", "one commit"]);
        git(&repo, &["remote", "add", "origin", &remote_name]);
        git(&repo, &["push", "-q", "--set-upstream", "origin", "main"]);

        (repo, remote)
    }

    fn version() -> Version {
        Version::parse("1.2.3").expect("a version")
    }

    #[test]
    fn releasing_tags_the_version_and_sends_the_tag() {
        let dirs = tempfile::tempdir().expect("temp dir");
        let (repo, remote) = a_repo_with_somewhere_to_push(dirs.path());

        release(&repo, &version()).expect("release");

        assert_eq!(git(&remote, &["tag", "--list", "v1.2.3"]), "v1.2.3");
        // Annotated with the version as its message, the way `git tag -a -m`
        // is asked for: the tag says what it is without the manifest at hand.
        assert_eq!(git(&repo, &["cat-file", "-t", "v1.2.3"]), "tag");
        assert_eq!(
            git(
                &repo,
                &["for-each-ref", "refs/tags/v1.2.3", "--format=%(contents)"]
            ),
            "v1.2.3"
        );
    }

    #[test]
    fn a_repo_with_nowhere_to_push_leaves_the_tag_local() {
        let dirs = tempfile::tempdir().expect("temp dir");
        let (repo, _) = a_repo_with_somewhere_to_push(dirs.path());
        git(&repo, &["remote", "remove", "origin"]);

        let err = release(&repo, &version()).expect_err("a push with no remote cannot work");

        // The tag was made, the push is what stopped the run: saying which
        // step failed is what tells the user the tag is still theirs to send.
        assert!(err.contains("git push"), "{err}");
        assert_eq!(git(&repo, &["tag", "--list", "v1.2.3"]), "v1.2.3");
    }
}