heddle-cli 0.8.0

An AI-native version control system
Documentation
// SPDX-License-Identifier: Apache-2.0
use std::{
    path::{Path, PathBuf},
    process::{Command, Output},
};

use serde_json::Value;
use tempfile::TempDir;

use super::{git_hermetic, heddle, heddle_output};

pub(crate) struct GitOverlayFixture {
    temp: TempDir,
    work: PathBuf,
    origin: Option<PathBuf>,
    peer: Option<PathBuf>,
    ready_thread: Option<(String, PathBuf)>,
}

impl GitOverlayFixture {
    pub(crate) fn adopted_main() -> Self {
        let temp = TempDir::new().expect("create git-overlay fixture tempdir");
        let work = temp.path().join("work");
        std::fs::create_dir_all(&work).expect("create git-overlay worktree");
        git_hermetic(&["init", "-b", "main"], &work);
        git_hermetic(&["config", "user.name", "Heddle Test"], &work);
        git_hermetic(&["config", "user.email", "heddle@example.com"], &work);
        std::fs::write(work.join("README.md"), "base\n").expect("seed README");
        git_hermetic(&["add", "README.md"], &work);
        git_hermetic(&["commit", "-m", "base"], &work);
        heddle(&["adopt", "--ref", "main"], Some(&work)).expect("adopt main");
        Self {
            temp,
            work,
            origin: None,
            peer: None,
            ready_thread: None,
        }
    }

    pub(crate) fn with_bare_origin(mut self) -> Self {
        if self.origin.is_some() {
            return self;
        }
        let origin = self.temp.path().join("origin.git");
        self.git_at(
            self.temp.path(),
            &[
                "init",
                "--bare",
                "--initial-branch=main",
                origin_str(&origin),
            ],
        );
        self.git(&["remote", "add", "origin", origin_str(&origin)]);
        self.git(&["push", "-u", "origin", "main"]);
        self.origin = Some(origin);
        self
    }

    pub(crate) fn with_ready_materialized_thread(mut self, thread: &str) -> Self {
        let thread_path = self.temp.path().join(thread.replace(['/', '\\'], "-"));
        let _started = self.json(&[
            "--output",
            "json",
            "start",
            thread,
            "--path",
            path_str(&thread_path),
        ]);
        std::fs::write(thread_path.join("feature.txt"), "ready work\n")
            .expect("write ready thread work");
        let _ready = self.json_at(
            &thread_path,
            &["--output", "json", "ready", "-m", "ready thread work"],
        );
        assert_eq!(_ready["status"], "completed");
        assert_eq!(_ready["verification"]["verified"], true);
        assert_eq!(_ready["verification"]["status"], "clean");
        let recommended_action = _ready["recommended_action"].as_str().unwrap_or("");
        assert!(
            recommended_action.contains(&format!("land --thread {thread} --no-push")),
            "ready helper should preserve the first-transition land action: {_ready}"
        );
        self.ready_thread = Some((thread.to_string(), thread_path));
        self
    }

    pub(crate) fn with_remote_behind(mut self) -> Self {
        if self.origin.is_none() {
            self = self.with_bare_origin();
        }
        let origin = self.origin_path().to_path_buf();
        let peer = self.temp.path().join("peer");
        self.git_at(
            self.temp.path(),
            &["clone", path_str(&origin), path_str(&peer)],
        );
        self.git_at(&peer, &["config", "user.name", "Peer"]);
        self.git_at(&peer, &["config", "user.email", "peer@example.com"]);
        std::fs::write(peer.join("README.md"), "base\npeer\n").expect("advance peer");
        self.git_at(&peer, &["add", "README.md"]);
        self.git_at(&peer, &["commit", "-m", "peer"]);
        self.git_at(&peer, &["push", "origin", "main"]);
        self.heddle(&["fetch", "origin"])
            .expect("fetch upstream drift");
        self.peer = Some(peer);
        self
    }

    pub(crate) fn with_index_lock(self) -> Self {
        std::fs::write(self.work.join(".git/index.lock"), "held by fixture\n")
            .expect("write git index lock");
        self
    }

    pub(crate) fn path(&self) -> &Path {
        &self.work
    }

    pub(crate) fn origin_path(&self) -> &Path {
        self.origin.as_deref().expect("fixture has bare origin")
    }

    pub(crate) fn ready_thread_path(&self) -> &Path {
        &self
            .ready_thread
            .as_ref()
            .expect("fixture has ready thread")
            .1
    }

    pub(crate) fn heddle(&self, args: &[&str]) -> Result<String, String> {
        heddle(args, Some(&self.work))
    }

    pub(crate) fn heddle_output(&self, args: &[&str]) -> Result<Output, String> {
        heddle_output(args, Some(&self.work))
    }

    pub(crate) fn json(&self, args: &[&str]) -> Value {
        self.json_at(&self.work, args)
    }

    pub(crate) fn json_at(&self, path: &Path, args: &[&str]) -> Value {
        let output = heddle(args, Some(path))
            .unwrap_or_else(|err| panic!("heddle {args:?} failed in {}: {err}", path.display()));
        serde_json::from_str(&output)
            .unwrap_or_else(|err| panic!("heddle {args:?} emitted invalid JSON: {err}: {output}"))
    }

    pub(crate) fn git(&self, args: &[&str]) {
        self.git_at(&self.work, args);
    }

    pub(crate) fn git_at(&self, path: &Path, args: &[&str]) {
        git_hermetic(args, path);
    }

    pub(crate) fn git_stdout(&self, args: &[&str]) -> String {
        self.git_stdout_at(&self.work, args)
    }

    pub(crate) fn git_stdout_at(&self, path: &Path, args: &[&str]) -> String {
        let output = self.git_output_at(path, args);
        assert!(
            output.status.success(),
            "git {args:?} failed in {}\nstdout: {}\nstderr: {}",
            path.display(),
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8_lossy(&output.stdout).trim().to_string()
    }

    fn git_output_at(&self, path: &Path, args: &[&str]) -> Output {
        let mut command = Command::new("git");
        command.env_clear();
        if let Some(path_env) = std::env::var_os("PATH") {
            command.env("PATH", path_env);
        }
        command
            .env("HOME", path)
            .env("GIT_CONFIG_GLOBAL", "/dev/null")
            .env("GIT_CONFIG_SYSTEM", "/dev/null")
            .env("GIT_CONFIG_NOSYSTEM", "1")
            .env("GIT_AUTHOR_NAME", "test")
            .env("GIT_AUTHOR_EMAIL", "test@example.com")
            .env("GIT_COMMITTER_NAME", "test")
            .env("GIT_COMMITTER_EMAIL", "test@example.com")
            .env("LANG", "C")
            .env("LC_ALL", "C")
            .env("TERM", "dumb")
            .args([
                "-c",
                "core.hooksPath=/dev/null",
                "-c",
                "commit.gpgsign=false",
                "-c",
                "user.name=test",
                "-c",
                "user.email=test@example.com",
                "-c",
                "init.defaultBranch=main",
            ])
            .args(args)
            .current_dir(path)
            .output()
            .unwrap_or_else(|err| panic!("spawn git {args:?}: {err}"))
    }
}

fn path_str(path: &Path) -> &str {
    path.to_str().expect("fixture path utf8")
}

fn origin_str(path: &Path) -> &str {
    path.to_str().expect("origin path utf8")
}