release-tool 0.4.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use release_tool::command::SystemCommandRunner;
use release_tool::git::GitRepository;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use tempfile::TempDir;

#[test]
fn dirty_tracked_worktree_is_rejected() {
    let fixture = GitFixture::new();
    fs::write(fixture.work.join("source"), "changed\n").unwrap();

    let error = fixture.repository().snapshot("main").unwrap_err();

    assert!(error.to_string().contains("clean worktree"));
}

#[test]
fn detached_head_is_rejected() {
    let fixture = GitFixture::new();
    run(&fixture.work, &["git", "checkout", "--detach", "HEAD"]);

    let error = fixture.repository().snapshot("main").unwrap_err();

    assert!(error.to_string().contains("detached HEAD"));
}

#[test]
fn wrong_branch_is_rejected() {
    let fixture = GitFixture::new();
    run(&fixture.work, &["git", "switch", "-c", "feature"]);

    let error = fixture.repository().snapshot("main").unwrap_err();

    assert!(error.to_string().contains("current branch is `feature`"));
}

#[test]
fn head_that_differs_from_the_remote_branch_is_rejected() {
    let fixture = GitFixture::new();
    fs::write(fixture.work.join("source"), "next\n").unwrap();
    run(&fixture.work, &["git", "add", "--", "source"]);
    run(&fixture.work, &["git", "commit", "-m", "Local only"]);

    let error = fixture.repository().snapshot("main").unwrap_err();

    assert!(error.to_string().contains("HEAD must equal origin/main"));
}

#[test]
fn matching_remote_branch_does_not_require_a_local_upstream() {
    let fixture = GitFixture::new();
    run(&fixture.work, &["git", "branch", "--unset-upstream"]);

    let snapshot = fixture.repository().snapshot("main").unwrap();

    assert_eq!(snapshot.branch, "main");
    assert_eq!(snapshot.head, snapshot.remote_branch_head);
}

struct GitFixture {
    _root: TempDir,
    work: 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("source"), "seed\n").unwrap();
        run(&work, &["git", "add", "--", "source"]);
        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 }
    }

    fn repository(&self) -> GitRepository {
        GitRepository::new(&self.work, Arc::new(SystemCommandRunner))
    }
}

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 {command:?} failed: {}",
        String::from_utf8_lossy(&result.stderr)
    );
}