use std::path::{Path, PathBuf};
pub fn find_enclosing_git_repo(workspace_root: &Path) -> Option<PathBuf> {
let start = workspace_root.parent()?;
let mut current = Some(start);
while let Some(dir) = current {
let candidate = dir.join(".git");
if candidate.is_dir() || candidate.is_file() {
return Some(dir.to_path_buf());
}
current = dir.parent();
}
None
}
pub fn outer_repo_ignores_mem_repo(outer_repo_root: &Path, workspace_root: &Path) -> bool {
let gitignore = outer_repo_root.join(".gitignore");
let Ok(contents) = std::fs::read_to_string(&gitignore) else {
return false;
};
let rel_prefix: Option<String> = workspace_root
.strip_prefix(outer_repo_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"));
let mut matched = false;
for raw in contents.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (negated, body) = match line.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, line),
};
let body = body.trim_end_matches('/').trim();
if line_matches_mem_repo(body, rel_prefix.as_deref()) {
matched = !negated;
}
}
matched
}
fn line_matches_mem_repo(body: &str, rel_prefix: Option<&str>) -> bool {
let body = body.trim_start_matches('/');
if body == "mem-repo" {
return true;
}
if let Some(rel) = rel_prefix {
let rel = rel.trim_start_matches('/').trim_end_matches('/');
if !rel.is_empty() {
let combined = format!("{rel}/mem-repo");
if body == combined {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn returns_none_when_no_outer_git() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
match find_enclosing_git_repo(&workspace) {
None => {}
Some(found) => {
let canon_found = found.canonicalize().unwrap_or(found);
let canon_tmp = tmp
.path()
.canonicalize()
.unwrap_or(tmp.path().to_path_buf());
assert!(
!canon_found.starts_with(&canon_tmp),
"test environment leaked a `.git` under TempDir: {}",
canon_found.display()
);
}
}
}
#[test]
fn finds_outer_git_dir() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let workspace = outer.join("memstead");
fs::create_dir_all(workspace.join(".memstead")).unwrap();
fs::create_dir_all(outer.join(".git")).unwrap();
let found = find_enclosing_git_repo(&workspace).expect("should find outer .git");
assert_eq!(found, outer);
}
#[test]
fn skips_workspace_self_git_dir() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(workspace.join(".git")).unwrap();
let canon_workspace = workspace.canonicalize().unwrap_or(workspace.clone());
match find_enclosing_git_repo(&workspace) {
None => {}
Some(found) => {
let canon_found = found.canonicalize().unwrap_or(found);
assert_ne!(
canon_found, canon_workspace,
"walker must skip the workspace's own `.git/`"
);
let canon_tmp = tmp
.path()
.canonicalize()
.unwrap_or(tmp.path().to_path_buf());
assert!(
!canon_found.starts_with(&canon_tmp),
"match must come from outside the TempDir"
);
}
}
}
#[test]
fn detects_dot_git_file_for_worktrees() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let workspace = outer.join("memstead");
fs::create_dir_all(&workspace).unwrap();
fs::write(outer.join(".git"), "gitdir: /elsewhere\n").unwrap();
let found = find_enclosing_git_repo(&workspace).expect("should detect .git file");
assert_eq!(found, outer);
}
#[test]
fn ignore_check_matches_bare_mem_repo() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let workspace = outer.join("memstead");
fs::create_dir_all(&workspace).unwrap();
fs::write(outer.join(".gitignore"), "mem-repo/\n").unwrap();
assert!(outer_repo_ignores_mem_repo(&outer, &workspace));
}
#[test]
fn ignore_check_matches_workspace_prefixed() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let workspace = outer.join("memstead");
fs::create_dir_all(&workspace).unwrap();
fs::write(outer.join(".gitignore"), "memstead/mem-repo/\n").unwrap();
assert!(outer_repo_ignores_mem_repo(&outer, &workspace));
}
#[test]
fn ignore_check_returns_false_when_not_listed() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let workspace = outer.join("memstead");
fs::create_dir_all(&workspace).unwrap();
fs::write(outer.join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
assert!(!outer_repo_ignores_mem_repo(&outer, &workspace));
}
#[test]
fn ignore_check_returns_false_when_no_gitignore() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let workspace = outer.join("memstead");
fs::create_dir_all(&workspace).unwrap();
assert!(!outer_repo_ignores_mem_repo(&outer, &workspace));
}
#[test]
fn ignore_check_honours_negation() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let workspace = outer.join("memstead");
fs::create_dir_all(&workspace).unwrap();
fs::write(outer.join(".gitignore"), "mem-repo/\n!mem-repo/\n").unwrap();
assert!(!outer_repo_ignores_mem_repo(&outer, &workspace));
}
#[test]
fn ignore_check_skips_comments() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let workspace = outer.join("memstead");
fs::create_dir_all(&workspace).unwrap();
fs::write(outer.join(".gitignore"), "# mem-repo/\n").unwrap();
assert!(!outer_repo_ignores_mem_repo(&outer, &workspace));
}
}