use crate::errors::{err, ErrorCode};
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
pub const EMPTY_TREE_OID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
#[derive(Debug, Clone)]
pub struct Repo {
pub root: PathBuf,
pub git_dir: PathBuf,
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 {
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())
}
pub fn branch(&self) -> Option<String> {
self.run_ok(&["symbolic-ref", "--short", "HEAD"])
}
pub fn head_oid(&self) -> Option<String> {
self.run_ok(&["rev-parse", "HEAD"])
}
pub fn dirty(&self) -> Result<bool> {
Ok(!self.run(&["status", "--porcelain"])?.is_empty())
}
pub fn ref_exists(&self, r: &str) -> bool {
self.run(&[
"rev-parse",
"--verify",
"--quiet",
&format!("{r}^{{commit}}"),
])
.is_ok()
}
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()),
}
}
pub fn merge_base(&self, other: &str) -> Option<String> {
self.run_ok(&["merge-base", "HEAD", other])
}
pub fn user_email(&self) -> Option<String> {
self.run_ok(&["config", "user.email"])
}
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())
}
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())
}
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();
let path = path.rsplit(" -> ").next().unwrap_or(path);
Some(path.trim_matches('"').to_string())
})
.collect())
}
#[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(),
})
}
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) = ¤t {
map.insert(line.trim().to_string(), info.clone());
}
}
}
map
}
pub fn state_dir(&self) -> PathBuf {
self.git_dir.join("memlay")
}
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()); 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();
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");
}
}