forkctl 0.0.3

Control audited StGit downstream patch stacks
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};

pub struct Fixture {
    _directory: tempfile::TempDir,
    pub upstream_work: PathBuf,
    pub downstream_bare: PathBuf,
    pub repo: PathBuf,
}

impl Fixture {
    pub fn tooling_only() -> Self {
        let directory = tempfile::tempdir().unwrap();
        let upstream_bare = directory.path().join("upstream.git");
        let downstream_bare = directory.path().join("downstream.git");
        init_bare(directory.path(), &upstream_bare);
        init_bare(directory.path(), &downstream_bare);

        let upstream_work = directory.path().join("upstream-work");
        let base = create_upstream(directory.path(), &upstream_work, &upstream_bare);
        create_tooling_commit(&upstream_work, &upstream_bare, &downstream_bare, &base);

        let repo = directory.path().join("consumer");
        git_ok(
            directory.path(),
            [
                "clone",
                "--quiet",
                downstream_bare.to_str().unwrap(),
                repo.to_str().unwrap(),
            ],
        );
        configure_identity(&repo);

        Self {
            _directory: directory,
            upstream_work,
            downstream_bare,
            repo,
        }
    }

    pub fn forkctl(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_forkctl"))
            .arg("--manifest")
            .arg("fork.json")
            .args(args)
            .current_dir(&self.repo)
            .output()
            .unwrap()
    }

    pub fn forkctl_ok(&self, args: &[&str]) -> String {
        let output = self.forkctl(args);
        assert!(
            output.status.success(),
            "{} {args:?} failed:\nstdout: {}\nstderr: {}",
            env!("CARGO_BIN_EXE_forkctl"),
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout).unwrap()
    }

    pub fn advance_upstream(&self, tag: &str, contents: &str) -> String {
        fs::write(self.upstream_work.join("upstream.txt"), contents).unwrap();
        git_ok(&self.upstream_work, ["add", "upstream.txt"]);
        git_ok(&self.upstream_work, ["commit", "--quiet", "-m", tag]);
        git_ok(
            &self.upstream_work,
            ["tag", "--annotate", "--message", tag, tag],
        );
        git_ok(
            &self.upstream_work,
            [
                "push",
                "--quiet",
                "upstream-bare",
                "main",
                &format!("refs/tags/{tag}"),
            ],
        );
        git_capture(&self.upstream_work, ["rev-parse", "HEAD"])
    }

    pub fn implement_patch(&self, name: &str, path: &str, contents: &str) {
        stg_ok(&self.repo, ["goto", name]);
        fs::write(self.repo.join(path), contents).unwrap();
        git_ok(&self.repo, ["add", path]);
        stg_ok(&self.repo, ["refresh", "--index"]);
        stg_ok(&self.repo, ["push", "--all"]);
    }
}

fn init_bare(directory: &Path, path: &Path) {
    git_ok(
        directory,
        ["init", "--bare", "--quiet", path.to_str().unwrap()],
    );
    git_ok(
        directory,
        [
            "--git-dir",
            path.to_str().unwrap(),
            "symbolic-ref",
            "HEAD",
            "refs/heads/main",
        ],
    );
}

fn create_upstream(directory: &Path, work: &Path, bare: &Path) -> String {
    git_ok(
        directory,
        [
            "init",
            "--quiet",
            "--initial-branch=main",
            work.to_str().unwrap(),
        ],
    );
    configure_identity(work);
    fs::write(work.join("base.txt"), "base\n").unwrap();
    git_ok(work, ["add", "base.txt"]);
    git_ok(work, ["commit", "--quiet", "-m", "base"]);
    git_ok(work, ["tag", "--annotate", "--message", "v1", "v1"]);
    git_ok(
        work,
        ["remote", "add", "upstream-bare", bare.to_str().unwrap()],
    );
    git_ok(
        work,
        ["push", "--quiet", "upstream-bare", "main", "refs/tags/v1"],
    );
    git_capture(work, ["rev-parse", "HEAD"])
}

fn create_tooling_commit(work: &Path, upstream: &Path, downstream: &Path, base: &str) {
    fs::write(
        work.join("FORK.md"),
        "Run `mise run fork:verify` before publication.\n",
    )
    .unwrap();
    let manifest = json!({
        "schema": 1,
        "downstream": {
            "remote": "origin",
            "branch": "main",
            "backup_tag_prefix": "vsh/pre-sync"
        },
        "upstream": {
            "remote": "upstream",
            "url": upstream.to_string_lossy(),
            "fetch_ref": "refs/heads/main"
        },
        "base": {
            "label": "refs/tags/v1",
            "canonical": base,
            "stack": base
        },
        "ledger": "PATCHES.md",
        "bookkeeping_patch": "fork-tooling",
        "patches": [{
            "name": "fork-tooling",
            "kind": "tooling",
            "purpose": "Own downstream fork policy and bookkeeping.",
            "upstream_status": "inappropriate: downstream-only tooling",
            "drop_when": "The downstream fork is retired.",
            "paths": ["fork.json", "PATCHES.md", "FORK.md", "patches/*"]
        }],
        "allow": {"base": []},
        "required": [{"path": "FORK.md", "contains": "mise run fork:verify"}]
    });
    fs::write(
        work.join("fork.json"),
        format!("{}\n", serde_json::to_string_pretty(&manifest).unwrap()),
    )
    .unwrap();
    fs::write(
        work.join("PATCHES.md"),
        ledger(
            "refs/tags/v1",
            base,
            &[LedgerPatch {
                name: "fork-tooling",
                kind: "tooling",
                purpose: "Own downstream fork policy and bookkeeping.",
                upstream_status: "inappropriate: downstream-only tooling",
                drop_when: "The downstream fork is retired.",
            }],
        ),
    )
    .unwrap();
    git_ok(work, ["add", "FORK.md", "fork.json", "PATCHES.md"]);
    git_ok(
        work,
        [
            "commit",
            "--quiet",
            "-m",
            "fork-tooling\n\nDownstream-Reason: Own downstream fork policy and bookkeeping.\nUpstream-Status: inappropriate: downstream-only tooling\nDrop-When: The downstream fork is retired.",
        ],
    );
    git_ok(
        work,
        ["remote", "add", "downstream", downstream.to_str().unwrap()],
    );
    git_ok(work, ["push", "--quiet", "downstream", "HEAD:main"]);
    git_ok(work, ["reset", "--hard", "--quiet", base]);
}

pub fn git_ok<const N: usize>(dir: &Path, args: [&str; N]) {
    let output = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git failed:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

pub fn stg_ok<const N: usize>(dir: &Path, args: [&str; N]) {
    let output = Command::new("stg")
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stg failed:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

pub fn stg_capture<const N: usize>(dir: &Path, args: [&str; N]) -> String {
    let output = Command::new("stg")
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stg failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).unwrap().trim().to_string()
}

pub fn git_capture<const N: usize>(dir: &Path, args: [&str; N]) -> String {
    let output = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).unwrap().trim().to_string()
}

fn configure_identity(repo: &Path) {
    git_ok(repo, ["config", "user.name", "Forkctl Test"]);
    git_ok(repo, ["config", "user.email", "forkctl@example.com"]);
}

struct LedgerPatch<'a> {
    name: &'a str,
    kind: &'a str,
    purpose: &'a str,
    upstream_status: &'a str,
    drop_when: &'a str,
}

fn ledger(label: &str, base: &str, patches: &[LedgerPatch<'_>]) -> String {
    let mut output = format!(
        "# Downstream Patches\n\n> Generated by `forkctl`; edit the manifest, not this file.\n\nBase: `{label}` (`{base}`)\n\n| Order | Patch | Kind | Purpose | Upstream status | Drop condition |\n|--:|:--|:--|:--|:--|:--|\n"
    );
    for (index, patch) in patches.iter().enumerate() {
        output.push_str("| ");
        output.push_str(&(index + 1).to_string());
        output.push_str(" | `");
        output.push_str(patch.name);
        output.push_str("` | ");
        output.push_str(patch.kind);
        output.push_str(" | ");
        output.push_str(patch.purpose);
        output.push_str(" | ");
        output.push_str(patch.upstream_status);
        output.push_str(" | ");
        output.push_str(patch.drop_when);
        output.push_str(" |\n");
    }
    output
}