memlay 0.1.3

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! Git integration: repository discovery and plumbing queries (PRD §20).
//! All operations shell out to the `git` binary; nothing here mutates the
//! working tree, normal branches, or source files.

use crate::errors::{err, ErrorCode};
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;

/// Git's well-known empty tree object ID (used when `.memlay/records` does
/// not exist at the baseline, PRD §9.1).
pub const EMPTY_TREE_OID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";

#[derive(Debug, Clone)]
pub struct Repo {
    /// Absolute repository worktree root.
    pub root: PathBuf,
    /// Worktree-specific git directory (index/HEAD live here).
    pub git_dir: PathBuf,
    /// Common git directory (objects/refs shared across worktrees).
    pub common_dir: PathBuf,
}

fn run_in(dir: &Path, args: &[&str]) -> Result<String> {
    let out = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .with_context(|| format!("running git {}", args.join(" ")))?;
    if !out.status.success() {
        anyhow::bail!(
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(String::from_utf8_lossy(&out.stdout)
        .trim_end_matches(['\n', '\r'])
        .to_string())
}

impl Repo {
    /// Discover the repository containing `start` (PRD §20.1). Uses git to
    /// resolve every path rather than assuming `.git` is a directory.
    pub fn discover(start: &Path) -> Result<Repo> {
        let root = run_in(start, &["rev-parse", "--show-toplevel"]).map_err(|_| {
            err(
                ErrorCode::NotAGitRepository,
                format!("{} is not inside a Git repository", start.display()),
            )
        })?;
        let root = PathBuf::from(root);
        let git_dir = PathBuf::from(run_in(&root, &["rev-parse", "--absolute-git-dir"])?);
        let common_dir_raw = run_in(&root, &["rev-parse", "--git-common-dir"])?;
        let common_dir = if Path::new(&common_dir_raw).is_absolute() {
            PathBuf::from(common_dir_raw)
        } else {
            root.join(common_dir_raw)
        };
        Ok(Repo {
            root,
            git_dir,
            common_dir,
        })
    }

    pub fn run(&self, args: &[&str]) -> Result<String> {
        run_in(&self.root, args)
    }

    fn run_ok(&self, args: &[&str]) -> Option<String> {
        self.run(args).ok().filter(|s| !s.is_empty())
    }

    /// Current branch name (including unborn branches), or None when HEAD is
    /// detached.
    pub fn branch(&self) -> Option<String> {
        self.run_ok(&["symbolic-ref", "--short", "HEAD"])
    }

    /// HEAD commit OID, or None on an unborn branch.
    pub fn head_oid(&self) -> Option<String> {
        self.run_ok(&["rev-parse", "HEAD"])
    }

    /// True when the working tree or index has any changes or untracked files.
    pub fn dirty(&self) -> Result<bool> {
        Ok(!self.run(&["status", "--porcelain"])?.is_empty())
    }

    /// Whether a ref resolves.
    pub fn ref_exists(&self, r: &str) -> bool {
        self.run(&[
            "rev-parse",
            "--verify",
            "--quiet",
            &format!("{r}^{{commit}}"),
        ])
        .is_ok()
    }

    /// Tree OID of `.memlay/records` at a ref; empty-tree identity if the
    /// directory does not exist there (PRD §9.1).
    pub fn records_tree_oid(&self, at_ref: &str) -> Result<String> {
        if !self.ref_exists(at_ref) {
            return Err(err(
                ErrorCode::TeamMemoryUnknown,
                format!("baseline ref '{at_ref}' does not resolve; fetch it or configure [team].baseline_ref"),
            ));
        }
        match self.run(&["rev-parse", &format!("{at_ref}:.memlay/records")]) {
            Ok(oid) => Ok(oid),
            Err(_) => Ok(EMPTY_TREE_OID.to_string()),
        }
    }

    /// Merge base between HEAD and a ref, if computable.
    pub fn merge_base(&self, other: &str) -> Option<String> {
        self.run_ok(&["merge-base", "HEAD", other])
    }

    /// `git config user.email`, if set.
    pub fn user_email(&self) -> Option<String> {
        self.run_ok(&["config", "user.email"])
    }

    /// Repository-relative paths (with `/` separators) changed between the
    /// merge base with `base` and HEAD.
    pub fn changed_paths_since(&self, base: &str) -> Result<Vec<String>> {
        let Some(mb) = self.merge_base(base) else {
            return Ok(vec![]);
        };
        let out = self.run(&["diff", "--name-only", &format!("{mb}..HEAD")])?;
        Ok(out
            .lines()
            .filter(|l| !l.is_empty())
            .map(|s| s.to_string())
            .collect())
    }

    /// Name-status pairs relative to the merge base, e.g. `[("M", path)]`.
    pub fn name_status_since(&self, base: &str) -> Result<Vec<(String, String)>> {
        let Some(mb) = self.merge_base(base) else {
            return Ok(vec![]);
        };
        let out = self.run(&["diff", "--name-status", &format!("{mb}..HEAD")])?;
        Ok(out
            .lines()
            .filter_map(|l| {
                let mut parts = l.split('\t');
                let status = parts.next()?.to_string();
                let path = parts.next_back()?.to_string();
                Some((status, path))
            })
            .collect())
    }

    /// Paths changed in the working tree/index relative to HEAD, plus
    /// untracked files.
    pub fn uncommitted_paths(&self) -> Result<Vec<String>> {
        let out = self.run(&["status", "--porcelain"])?;
        Ok(out
            .lines()
            .filter_map(|l| {
                if l.len() < 4 {
                    return None;
                }
                let path = l[3..].trim();
                // Renames appear as "old -> new"; keep the new path.
                let path = path.rsplit(" -> ").next().unwrap_or(path);
                Some(path.trim_matches('"').to_string())
            })
            .collect())
    }

    /// First commit that introduced a path (for derived provenance, PRD §8.6).
    /// Bulk indexing uses `records_provenance`; this single-path variant
    /// serves spot lookups and tests.
    #[cfg_attr(not(test), allow(dead_code))]
    pub fn first_commit_of(&self, rel_path: &str) -> Option<CommitInfo> {
        let out = self
            .run(&[
                "log",
                "--diff-filter=A",
                "--follow",
                "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%s",
                "--max-count=1",
                "--",
                rel_path,
            ])
            .ok()?;
        let line = out.lines().last()?.trim();
        if line.is_empty() {
            return None;
        }
        let f: Vec<&str> = line.split('\u{1f}').collect();
        if f.len() < 8 {
            return None;
        }
        Some(CommitInfo {
            oid: f[0].into(),
            author_name: f[1].into(),
            author_email: f[2].into(),
            author_date: f[3].into(),
            committer_name: f[4].into(),
            committer_email: f[5].into(),
            commit_date: f[6].into(),
            summary: f[7].into(),
        })
    }

    /// First-introducing commit for every record path, from one history walk
    /// (PRD §8.6: Git-derived provenance is authoritative after commit).
    pub fn records_provenance(&self) -> std::collections::HashMap<String, CommitInfo> {
        let mut map = std::collections::HashMap::new();
        let Ok(out) = self.run(&[
            "log",
            "--diff-filter=A",
            "--format=%x01%H%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%s",
            "--name-only",
            "--",
            ".memlay/records",
        ]) else {
            return map;
        };
        let mut current: Option<CommitInfo> = None;
        for line in out.lines() {
            if let Some(rest) = line.strip_prefix('\u{1}') {
                let f: Vec<&str> = rest.split('\u{1f}').collect();
                current = (f.len() >= 8).then(|| CommitInfo {
                    oid: f[0].into(),
                    author_name: f[1].into(),
                    author_email: f[2].into(),
                    author_date: f[3].into(),
                    committer_name: f[4].into(),
                    committer_email: f[5].into(),
                    commit_date: f[6].into(),
                    summary: f[7].into(),
                });
            } else if !line.trim().is_empty() {
                if let Some(info) = &current {
                    // --diff-filter=A with newest-first order: keep the OLDEST
                    // (last seen) introduction for each path.
                    map.insert(line.trim().to_string(), info.clone());
                }
            }
        }
        map
    }

    /// Worktree-local memlay state directory (`<git-dir>/memlay`, PRD §7.2).
    pub fn state_dir(&self) -> PathBuf {
        self.git_dir.join("memlay")
    }

    /// Shared repository cache (`<git-common-dir>/memlay`, PRD §7.3).
    pub fn shared_dir(&self) -> PathBuf {
        self.common_dir.join("memlay")
    }
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct CommitInfo {
    pub oid: String,
    pub author_name: String,
    pub author_email: String,
    pub author_date: String,
    pub committer_name: String,
    pub committer_email: String,
    pub commit_date: String,
    pub summary: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn init_repo() -> tempfile::TempDir {
        let tmp = tempfile::tempdir().unwrap();
        run_in(tmp.path(), &["init", "-b", "main"]).unwrap();
        run_in(tmp.path(), &["config", "user.email", "test@example.com"]).unwrap();
        run_in(tmp.path(), &["config", "user.name", "Test"]).unwrap();
        run_in(tmp.path(), &["config", "commit.gpgsign", "false"]).unwrap();
        tmp
    }

    #[test]
    fn discover_and_basics() {
        let tmp = init_repo();
        let repo = Repo::discover(tmp.path()).unwrap();
        assert!(repo.git_dir.ends_with(".git"));
        assert_eq!(repo.branch().as_deref(), Some("main"));
        assert!(repo.head_oid().is_none()); // unborn branch
        assert!(!repo.dirty().unwrap());

        std::fs::write(repo.root.join("a.txt"), "hi").unwrap();
        assert!(repo.dirty().unwrap());
        repo.run(&["add", "."]).unwrap();
        repo.run(&["commit", "-m", "initial"]).unwrap();
        assert!(repo.head_oid().is_some());
        assert!(!repo.dirty().unwrap());
    }

    #[test]
    fn records_tree_oid_empty_when_missing() {
        let tmp = init_repo();
        let repo = Repo::discover(tmp.path()).unwrap();
        std::fs::write(repo.root.join("a.txt"), "hi").unwrap();
        repo.run(&["add", "."]).unwrap();
        repo.run(&["commit", "-m", "initial"]).unwrap();
        let oid = repo.records_tree_oid("HEAD").unwrap();
        assert_eq!(oid, EMPTY_TREE_OID);
    }

    #[test]
    fn not_a_repo_error() {
        let tmp = tempfile::tempdir().unwrap();
        // A plain temp dir may still be inside some parent repo on dev
        // machines; guard by pointing at the filesystem root of the temp dir.
        let e = Repo::discover(&tmp.path().join("nope"));
        assert!(e.is_err());
    }

    #[test]
    fn first_commit_provenance() {
        let tmp = init_repo();
        let repo = Repo::discover(tmp.path()).unwrap();
        std::fs::write(repo.root.join("f.txt"), "1").unwrap();
        repo.run(&["add", "."]).unwrap();
        repo.run(&["commit", "-m", "add f"]).unwrap();
        let info = repo.first_commit_of("f.txt").unwrap();
        assert_eq!(info.author_email, "test@example.com");
        assert_eq!(info.summary, "add f");
    }
}