forkctl 0.0.7

Control audited StGit downstream patch stacks
#![cfg(unix)]

mod support;

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use support::Fixture;

#[test]
fn mounted_mise_proxy_preserves_help_output_exit_and_cwd() {
    let fixture = Fixture::new();
    let catalog = MountedCatalog::new(&fixture.repo);

    let mounted_help = catalog.mise(&["run", "--quiet", "fork", "--help"]);
    assert!(mounted_help.status.success());
    assert!(String::from_utf8_lossy(&mounted_help.stdout).contains("Commands"));
    assert!(String::from_utf8_lossy(&mounted_help.stdout).contains("patch"));

    let direct = fixture.api_call(
        "execute",
        &serde_json::json!({"command":"status","arguments":{}}),
    );
    let mounted = catalog.mise(&[
        "run",
        "--quiet",
        "fork",
        "--manifest",
        "patches/fork.json",
        "--format",
        "json",
        "status",
    ]);
    assert_eq!(mounted.status.code(), direct.status.code());
    assert_eq!(mounted.stdout, direct.stdout);
    assert_eq!(mounted.stderr, direct.stderr);
}

#[test]
fn mounted_mise_completion_covers_supported_shells_and_live_values() {
    let fixture = Fixture::new();
    let catalog = MountedCatalog::new(&fixture.repo);
    let spec = catalog.mise(&["usage"]);
    assert!(
        spec.status.success(),
        "mise usage failed: {}",
        String::from_utf8_lossy(&spec.stderr)
    );
    let spec_path = fixture.repo.join("mise-mounted.usage.kdl");
    fs::write(&spec_path, &spec.stdout).unwrap();

    for shell in ["bash", "fish", "nu", "powershell", "zsh"] {
        let commands =
            catalog.complete(&spec_path, shell, 4, &["mise", "run", "fork", "patch", ""]);
        assert!(
            commands.lines().any(|line| line.starts_with("refresh")),
            "{shell} omitted patch refresh: {commands:?}"
        );

        let patches = catalog.complete(
            &spec_path,
            shell,
            5,
            &["mise", "run", "fork", "patch", "show", "fork"],
        );
        assert!(
            patches.lines().any(|line| line.starts_with("fork-tooling")),
            "{shell} omitted live patch name: {patches:?}"
        );

        let refs = catalog.complete(
            &spec_path,
            shell,
            5,
            &["mise", "run", "fork", "rebase", "--onto", "refs/heads/m"],
        );
        assert!(
            refs.lines().any(|line| line.starts_with("refs/heads/main")),
            "{shell} omitted local ref: {refs:?}"
        );
    }
}

#[test]
fn lefthook_composes_with_mounted_read_only_checks() {
    let fixture = Fixture::new();
    let catalog = MountedCatalog::new(&fixture.repo);
    fs::write(
        fixture.repo.join("lefthook.yml"),
        "pre-commit:\n  commands:\n    forkctl-staged:\n      run: mise run --quiet fork check -s\npre-push:\n  commands:\n    forkctl-check:\n      run: mise run --quiet fork check -q\n",
    )
    .unwrap();
    support::git_ok(&fixture.repo, ["add", "mise.toml", "lefthook.yml"]);
    let refresh = Command::new("stg")
        .args(["refresh", "--patch", "fork-tooling", "--index"])
        .current_dir(&fixture.repo)
        .output()
        .unwrap();
    assert!(
        refresh.status.success(),
        "bookkeeping refresh failed: {}",
        String::from_utf8_lossy(&refresh.stderr)
    );

    let pre_push = catalog.lefthook(&["run", "pre-push"]);
    assert!(
        pre_push.status.success(),
        "pre-push failed:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&pre_push.stdout),
        String::from_utf8_lossy(&pre_push.stderr)
    );

    fs::write(fixture.repo.join("outside.txt"), "outside\n").unwrap();
    support::git_ok(&fixture.repo, ["add", "outside.txt"]);
    let pre_commit = catalog.lefthook(&["run", "pre-commit"]);
    assert!(!pre_commit.status.success());
    let diagnostics = format!(
        "{}{}",
        String::from_utf8_lossy(&pre_commit.stdout),
        String::from_utf8_lossy(&pre_commit.stderr)
    );
    assert!(
        diagnostics.contains("active patch") || diagnostics.contains("outside patch"),
        "unexpected pre-commit diagnostics: {diagnostics}"
    );
}

struct MountedCatalog {
    repo: PathBuf,
    _tasks: tempfile::TempDir,
}

impl MountedCatalog {
    fn new(repo: &Path) -> Self {
        let tasks = tempfile::tempdir().unwrap();
        let task_path = tasks.path().join("fork");
        let source =
            fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tasks/fork/fork")).unwrap();
        let source = source
            .replace(
                &format!(
                    ", \"github:victor-software-house/forkctl\" = \"{}\"",
                    env!("CARGO_PKG_VERSION")
                ),
                "",
            )
            .replace(
                "exec forkctl",
                &format!("exec {}", env!("CARGO_BIN_EXE_forkctl")),
            );
        fs::write(&task_path, source).unwrap();
        let mut permissions = fs::metadata(&task_path).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&task_path, permissions).unwrap();
        let include = serde_json::to_string(&tasks.path().display().to_string()).unwrap();
        fs::write(
            repo.join("mise.toml"),
            format!("[settings]\nexperimental = true\n\n[task_config]\nincludes = [{include}]\n"),
        )
        .unwrap();
        Self {
            repo: repo.to_owned(),
            _tasks: tasks,
        }
    }

    fn mise(&self, args: &[&str]) -> Output {
        Command::new("mise")
            .args(args)
            .env("MISE_TRUSTED_CONFIG_PATHS", &self.repo)
            .current_dir(&self.repo)
            .output()
            .unwrap()
    }

    fn complete(&self, spec: &Path, shell: &str, cword: usize, words: &[&str]) -> String {
        let output = Command::new("usage")
            .args(["complete-word", "--file"])
            .arg(spec)
            .args(["--shell", shell, "--cword", &cword.to_string(), "--"])
            .args(words)
            .env("MISE_TRUSTED_CONFIG_PATHS", &self.repo)
            .current_dir(&self.repo)
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "usage completion failed for {shell}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert!(
            output.stderr.is_empty(),
            "usage completion warned for {shell}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout).unwrap()
    }

    fn lefthook(&self, args: &[&str]) -> Output {
        Command::new("lefthook")
            .args(args)
            .env("MISE_TRUSTED_CONFIG_PATHS", &self.repo)
            .current_dir(&self.repo)
            .output()
            .unwrap()
    }
}