semver-sync 0.2.1

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::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;

use semver::Version;

use crate::Result;

/// Commit what this run wrote as `v<VERSION>`, tag that commit, 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.
///
/// The commit is a `git add .`, so the tree has to hold nothing beyond the
/// files in `written` — the manifests and lock this run saved.
/// `reject_unwritten` checks that before anything moves, because `git add .`
/// would otherwise sweep somebody's work in progress into the release commit.
/// A run also preflights the same check before its bump writes anything, so
/// a tree that is not the run's own leaves no half-release behind.
/// A run that wrote nothing git had not seen leaves nothing to commit, and its
/// tag carries the commit HEAD is standing on.
///
/// 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, written: &BTreeSet<PathBuf>) -> Result<()> {
    let tag = format!("v{version}");

    reject_unwritten(root, written, &tag)?;

    if !run(root, &["add", "."])? {
        return Err(String::from(
            "`git add .` failed; nothing was committed or tagged",
        ));
    }

    // Exit 0 says the index matches HEAD: every byte this run wrote was
    // already on disk as-is. An empty commit would claim a release
    // movement that did not happen, so the tag just carries HEAD instead.
    if !run(root, &["diff", "--cached", "--quiet"])? && !run(root, &["commit", "-m", &tag])? {
        return Err(format!(
            "`git commit -m {tag}` failed; nothing was tagged or pushed"
        ));
    }

    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(())
}

/// Fail when the tree holds a file this run did not write, naming each one.
/// Unstaged, staged and untracked all count: `git add .` follows every one of
/// them into the release commit, so none of them belongs there unless this
/// run put it there.
pub(crate) fn reject_unwritten(root: &Path, written: &BTreeSet<PathBuf>, tag: &str) -> Result<()> {
    let done = capture(
        root,
        &["status", "--porcelain=v1", "-z", "--untracked-files=all"],
    )?;

    if !done.status.success() {
        return Err(String::from(
            "`git status` failed; the release did not start",
        ));
    }

    let mut offenders: Vec<String> = Vec::new();

    for (_code, paths) in porcelain(&done.stdout) {
        for path in paths {
            if !written.contains(&root.join(&path)) && !offenders.contains(&path) {
                offenders.push(path);
            }
        }
    }

    if !offenders.is_empty() {
        return Err(format!(
            "`git status` shows files this run did not write: {}\n  commit or stash them before releasing {tag}",
            offenders.join(", ")
        ));
    }

    Ok(())
}

/// One `git status --porcelain=v1 -z` record stream, as entries of status
/// code and paths. A rename or copy names its destination in the record and
/// its source in the bare record behind it.
fn porcelain(raw: &[u8]) -> Vec<(String, Vec<String>)> {
    let mut entries = Vec::new();
    let mut records = raw
        .split(|byte| *byte == 0)
        .filter(|record| !record.is_empty());

    while let Some(record) = records.next() {
        let text = String::from_utf8_lossy(record);

        if text.len() < 4 {
            continue;
        }

        let (code, rest) = text.split_at(2);
        let mut paths = vec![rest[1..].to_string()];

        if matches!(code.as_bytes()[0], b'R' | b'C') {
            if let Some(source) = records.next() {
                paths.push(String::from_utf8_lossy(source).into_owned());
            }
        }

        entries.push((code.to_string(), paths));
    }

    entries
}

/// 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]))
}

/// One git command whose stdout this file reads itself.
fn capture(root: &Path, args: &[&str]) -> Result<std::process::Output> {
    Command::new("git")
        .args(args)
        .current_dir(root)
        .output()
        .map_err(|err| format!("cannot run `git {}`: {err}", args[0]))
}

#[cfg(test)]
mod tests {
    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)
    }

    /// The shape a bump leaves behind: the manifest it saved is rewritten and
    /// nobody staged it, and that file alone is the run's to commit.
    fn a_run_wrote_the_manifest(repo: &Path) -> BTreeSet<PathBuf> {
        std::fs::write(repo.join("Cargo.toml"), "[package]\nversion = \"1.2.3\"\n")
            .expect("rewrite the manifest");

        [repo.join("Cargo.toml")].into_iter().collect()
    }

    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(), &BTreeSet::new()).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"
        );
        // The run wrote nothing, so there is no release commit: an empty one
        // would claim movement that did not happen.
        assert_eq!(git(&repo, &["log", "-1", "--format=%s"]), "one commit");
    }

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

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

        assert_eq!(git(&repo, &["log", "-1", "--format=%s"]), "v1.2.3");
        // The tag carries the write, not the commit that predates it.
        assert_eq!(
            git(&repo, &["show", "v1.2.3:Cargo.toml"]),
            "[package]\nversion = \"1.2.3\""
        );
        // The push sent the commit as well as the tag.
        assert_eq!(
            git(&remote, &["log", "-n", "1", "--format=%s", "main"]),
            "v1.2.3"
        );
    }

    #[test]
    fn release_stops_at_files_the_run_did_not_write() {
        let dirs = tempfile::tempdir().expect("temp dir");
        let (repo, remote) = a_repo_with_somewhere_to_push(dirs.path());
        let written = a_run_wrote_the_manifest(&repo);
        std::fs::write(repo.join("scratch.rs"), "fn main() {}\n").expect("leave work in progress");
        std::fs::write(repo.join("notes.md"), "mine\n").expect("leave work staged");
        git(&repo, &["add", "notes.md"]);

        let err =
            release(&repo, &version(), &written).expect_err("the tree is not the run's alone");

        // Names each stranger and stays quiet about the manifest, so the
        // warning says what to move out of the way and nothing else.
        assert!(err.contains("scratch.rs"), "{err}");
        assert!(err.contains("notes.md"), "{err}");
        assert!(!err.contains("Cargo.toml"), "{err}");

        // Nothing moved: no commit, no tag, nothing sent.
        assert_eq!(git(&repo, &["log", "-1", "--format=%s"]), "one commit");
        assert_eq!(git(&repo, &["tag", "--list"]), "");
        assert_eq!(git(&remote, &["tag", "--list"]), "");
    }

    #[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(), &BTreeSet::new())
            .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");
    }

    #[test]
    fn porcelain_pairs_a_rename_with_the_record_behind_it() {
        let entries = porcelain(b"R  new.rs\0old.rs\0?? loose.txt\0M  staged.rs\0");

        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0].1, vec!["new.rs", "old.rs"]);
        assert_eq!(entries[1].1, vec!["loose.txt"]);
        assert_eq!(entries[2].1, vec!["staged.rs"]);
    }
}