lha 1.0.6

Long-Horizon Agent command-line package that installs the lha binary.
Documentation
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitBaselineDiff {
    pub changes: Vec<GitBaselineChange>,
    pub unified_diff: String,
}

impl GitBaselineDiff {
    pub fn has_changes(&self) -> bool {
        !self.changes.is_empty() || !self.unified_diff.trim().is_empty()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitBaselineChange {
    pub status: GitBaselineChangeStatus,
    pub path: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitBaselineChangeStatus {
    Added,
    Modified,
    Deleted,
    Renamed,
    Copied,
    TypeChanged,
    Unmerged,
    Unknown,
}

impl GitBaselineChangeStatus {
    pub fn label(self) -> &'static str {
        match self {
            Self::Added => "A",
            Self::Modified => "M",
            Self::Deleted => "D",
            Self::Renamed => "R",
            Self::Copied => "C",
            Self::TypeChanged => "T",
            Self::Unmerged => "U",
            Self::Unknown => "?",
        }
    }
}

pub fn ensure_git_baseline_repository(root: &Path) -> anyhow::Result<()> {
    std::fs::create_dir_all(root)?;
    if is_valid_git_repository(root) {
        return Ok(());
    }
    remove_git_dir_or_file(root)?;
    init_and_commit(root)
}

pub fn reset_git_repository(root: &Path) -> anyhow::Result<()> {
    std::fs::create_dir_all(root)?;
    remove_git_dir_or_file(root)?;
    init_and_commit(root)
}

pub fn diff_since_latest_init(root: &Path) -> anyhow::Result<GitBaselineDiff> {
    ensure_git_baseline_repository(root)?;
    run_git(root, ["add", "-N", "."])?;
    let status = run_git(root, ["status", "--porcelain=v1"])?;
    let changes = parse_status(status.stdout.as_str());
    let unified_diff = run_git(root, ["diff", "--no-ext-diff", "--binary", "HEAD", "--"])
        .map(|output| output.stdout)?;
    Ok(GitBaselineDiff {
        changes,
        unified_diff,
    })
}

fn init_and_commit(root: &Path) -> anyhow::Result<()> {
    run_git(root, ["init"])?;
    run_git(root, ["add", "-A"])?;
    run_git(
        root,
        [
            "-c",
            "user.name=LHA Memory",
            "-c",
            "user.email=lha-memory@example.invalid",
            "commit",
            "--allow-empty",
            "-m",
            "Initialize LHA memory git baseline",
        ],
    )?;
    Ok(())
}

fn remove_git_dir_or_file(root: &Path) -> std::io::Result<()> {
    let git_dir = root.join(".git");
    let Ok(metadata) = std::fs::symlink_metadata(&git_dir) else {
        return Ok(());
    };
    if metadata.is_dir() {
        std::fs::remove_dir_all(git_dir)
    } else {
        std::fs::remove_file(git_dir)
    }
}

fn is_valid_git_repository(root: &Path) -> bool {
    let Ok(canonical_root) = canonical_root(root) else {
        return false;
    };
    let inside_work_tree = run_git(root, ["rev-parse", "--is-inside-work-tree"])
        .map(|output| output.stdout.trim() == "true")
        .unwrap_or(false);
    if !inside_work_tree {
        return false;
    }
    let Ok(Some(top_level)) = git_top_level(root) else {
        return false;
    };
    if top_level != canonical_root {
        return false;
    }
    run_git(root, ["rev-parse", "--verify", "HEAD"])
        .map(|output| !output.stdout.trim().is_empty())
        .unwrap_or(false)
}

fn canonical_root(root: &Path) -> anyhow::Result<PathBuf> {
    Ok(root.canonicalize()?)
}

fn git_top_level(root: &Path) -> anyhow::Result<Option<PathBuf>> {
    let output = match run_git(root, ["rev-parse", "--show-toplevel"]) {
        Ok(output) => output,
        Err(_) => return Ok(None),
    };
    let top_level = output.stdout.trim();
    if top_level.is_empty() {
        return Ok(None);
    }
    Ok(Some(PathBuf::from(top_level).canonicalize()?))
}

struct GitOutput {
    stdout: String,
}

fn run_git<I, S>(root: &Path, args: I) -> anyhow::Result<GitOutput>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let output = Command::new("git").args(args).current_dir(root).output()?;
    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "git command failed in {}: {}",
            root.display(),
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }
    Ok(GitOutput {
        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
    })
}

fn parse_status(status: &str) -> Vec<GitBaselineChange> {
    status
        .lines()
        .filter_map(|line| {
            if line.len() < 4 {
                return None;
            }
            let code = &line[..2];
            let path = line[3..].trim().to_string();
            if path.is_empty() {
                return None;
            }
            Some(GitBaselineChange {
                status: status_from_code(code),
                path,
            })
        })
        .collect()
}

fn status_from_code(code: &str) -> GitBaselineChangeStatus {
    if code.contains('U') {
        GitBaselineChangeStatus::Unmerged
    } else if code.contains('R') {
        GitBaselineChangeStatus::Renamed
    } else if code.contains('C') {
        GitBaselineChangeStatus::Copied
    } else if code.contains('A') || code.contains('?') {
        GitBaselineChangeStatus::Added
    } else if code.contains('D') {
        GitBaselineChangeStatus::Deleted
    } else if code.contains('M') {
        GitBaselineChangeStatus::Modified
    } else if code.contains('T') {
        GitBaselineChangeStatus::TypeChanged
    } else {
        GitBaselineChangeStatus::Unknown
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use tempfile::TempDir;

    #[test]
    fn baseline_diff_detects_added_file() {
        let tmp = TempDir::new().expect("tempdir");
        ensure_git_baseline_repository(tmp.path()).expect("baseline");
        std::fs::write(tmp.path().join("added.txt"), "hello").expect("write");

        let diff = diff_since_latest_init(tmp.path()).expect("diff");

        assert_change(&diff, GitBaselineChangeStatus::Added, "added.txt");
    }

    #[test]
    fn baseline_diff_detects_modified_file() {
        let tmp = TempDir::new().expect("tempdir");
        std::fs::write(tmp.path().join("memory.md"), "before").expect("write");
        ensure_git_baseline_repository(tmp.path()).expect("baseline");
        std::fs::write(tmp.path().join("memory.md"), "after").expect("write");

        let diff = diff_since_latest_init(tmp.path()).expect("diff");

        assert_change(&diff, GitBaselineChangeStatus::Modified, "memory.md");
    }

    #[test]
    fn baseline_diff_detects_deleted_file() {
        let tmp = TempDir::new().expect("tempdir");
        std::fs::write(tmp.path().join("memory.md"), "before").expect("write");
        ensure_git_baseline_repository(tmp.path()).expect("baseline");
        std::fs::remove_file(tmp.path().join("memory.md")).expect("delete");

        let diff = diff_since_latest_init(tmp.path()).expect("diff");

        assert_change(&diff, GitBaselineChangeStatus::Deleted, "memory.md");
    }

    #[test]
    fn baseline_diff_detects_nested_path() {
        let tmp = TempDir::new().expect("tempdir");
        ensure_git_baseline_repository(tmp.path()).expect("baseline");
        std::fs::create_dir_all(tmp.path().join("rollout_summaries")).expect("mkdir");
        std::fs::write(tmp.path().join("rollout_summaries/thread.md"), "summary").expect("write");

        let diff = diff_since_latest_init(tmp.path()).expect("diff");

        assert_change(
            &diff,
            GitBaselineChangeStatus::Added,
            "rollout_summaries/thread.md",
        );
    }

    #[test]
    fn reset_baseline_clears_diff() {
        let tmp = TempDir::new().expect("tempdir");
        ensure_git_baseline_repository(tmp.path()).expect("baseline");
        std::fs::write(tmp.path().join("memory.md"), "memory").expect("write");
        assert!(
            diff_since_latest_init(tmp.path())
                .expect("diff")
                .has_changes()
        );

        reset_git_repository(tmp.path()).expect("reset");

        assert!(
            !diff_since_latest_init(tmp.path())
                .expect("diff")
                .has_changes()
        );
    }

    #[test]
    fn baseline_diff_handles_binary_non_utf8_without_panic() {
        let tmp = TempDir::new().expect("tempdir");
        ensure_git_baseline_repository(tmp.path()).expect("baseline");
        std::fs::write(tmp.path().join("binary.bin"), [0, 159, 146, 150]).expect("write");

        let diff = diff_since_latest_init(tmp.path()).expect("diff");

        assert_change(&diff, GitBaselineChangeStatus::Added, "binary.bin");
    }

    #[test]
    fn invalid_git_directory_is_replaced() {
        let tmp = TempDir::new().expect("tempdir");
        std::fs::create_dir_all(tmp.path().join(".git")).expect("mkdir");
        std::fs::write(tmp.path().join(".git/garbage"), "not git").expect("write");

        ensure_git_baseline_repository(tmp.path()).expect("baseline");

        assert!(is_valid_git_repository(tmp.path()));
    }

    #[test]
    fn invalid_git_file_is_replaced() {
        let tmp = TempDir::new().expect("tempdir");
        std::fs::write(tmp.path().join(".git"), "not git").expect("write");

        ensure_git_baseline_repository(tmp.path()).expect("baseline");

        assert!(is_valid_git_repository(tmp.path()));
    }

    #[test]
    fn nested_memory_root_gets_own_git_repo() {
        let tmp = TempDir::new().expect("tempdir");
        let parent = tmp.path();
        std::fs::write(parent.join(".gitignore"), "memories/\n").expect("write gitignore");
        init_and_commit(parent).expect("parent baseline");
        let memories = parent.join("memories");
        std::fs::create_dir_all(&memories).expect("mkdir memories");

        ensure_git_baseline_repository(&memories).expect("memory baseline");

        let top_level = git_top_level(&memories)
            .expect("top level")
            .expect("top level should exist");
        assert_eq!(
            top_level,
            memories.canonicalize().expect("canonical memories")
        );
        let parent_status = run_git(parent, ["status", "--porcelain=v1"])
            .expect("parent status")
            .stdout;
        assert_eq!(parent_status, "");
    }

    #[test]
    fn diff_inside_nested_memory_root_does_not_touch_parent_index() {
        let tmp = TempDir::new().expect("tempdir");
        let parent = tmp.path();
        std::fs::write(parent.join(".gitignore"), "memories/\n").expect("write gitignore");
        init_and_commit(parent).expect("parent baseline");
        let memories = parent.join("memories");
        ensure_git_baseline_repository(&memories).expect("memory baseline");
        std::fs::write(memories.join("memory.md"), "remember this").expect("write memory");

        let diff = diff_since_latest_init(&memories).expect("diff");

        assert_eq!(
            diff.changes,
            vec![GitBaselineChange {
                status: GitBaselineChangeStatus::Added,
                path: "memory.md".to_string(),
            }]
        );
        let parent_cached_diff = run_git(parent, ["diff", "--cached", "--name-only"])
            .expect("parent cached diff")
            .stdout;
        assert_eq!(parent_cached_diff, "");
    }

    fn assert_change(diff: &GitBaselineDiff, status: GitBaselineChangeStatus, path: &'static str) {
        assert!(
            diff.changes
                .iter()
                .any(|change| change.status == status && change.path == path),
            "expected {status:?} change for {path}; got {:?}",
            diff.changes
        );
    }

    #[test]
    fn status_labels_match_git_porcelain_codes() {
        assert_eq!(GitBaselineChangeStatus::Added.label(), "A");
        assert_eq!(GitBaselineChangeStatus::Modified.label(), "M");
        assert_eq!(GitBaselineChangeStatus::Deleted.label(), "D");
    }
}