use std::env;
use std::fs;
use std::path::{Path, PathBuf};
pub fn detect_real_git_dir(dir: &Path) -> Option<PathBuf> {
let git_path = dir.join(".git");
let metadata = fs::metadata(&git_path).ok()?;
if !metadata.is_file() {
return None;
}
let first_line = fs::read_to_string(&git_path)
.ok()?
.lines()
.next()?
.trim()
.to_string();
let gitdir = first_line.strip_prefix("gitdir: ")?;
let resolved = if Path::new(gitdir).is_absolute() {
PathBuf::from(gitdir)
} else {
git_path.parent()?.join(gitdir)
};
let resolved_str = resolved.to_string_lossy().into_owned();
let real = match resolved_str.split_once("/worktrees/") {
Some((prefix, _)) => PathBuf::from(prefix),
None => resolved,
};
validate_git_dir(real)
}
fn validate_git_dir(path: PathBuf) -> Option<PathBuf> {
let path = match path.canonicalize() {
Ok(path) => path,
Err(_) => {
warn_rejected(&path, "cannot be resolved");
return None;
}
};
if !path.is_dir() {
warn_rejected(&path, "not a directory");
return None;
}
if !path.join("HEAD").exists() {
warn_rejected(&path, "does not look like a git directory (no HEAD)");
return None;
}
if let Some(why) = dangerous_grant_reason(&path, home_dir().as_deref()) {
warn_rejected(&path, why);
return None;
}
Some(path)
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.filter(|h| !h.is_empty())
.and_then(|h| PathBuf::from(h).canonicalize().ok())
}
fn dangerous_grant_reason(path: &Path, home: Option<&Path>) -> Option<&'static str> {
if path.parent().is_none() {
return Some("refusing to grant the filesystem root");
}
if home == Some(path) {
return Some("refusing to grant the home directory");
}
None
}
fn warn_rejected(path: &Path, why: &str) {
eprintln!(
"agent-locker: ignoring git directory from .git file ({}): {why}; \
leaving its parent git directory read-only",
path.display()
);
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let base =
env::temp_dir().join(format!("agent-locker-test-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
TempDir(base)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn make_git_dir(at: &Path) {
fs::create_dir_all(at).unwrap();
fs::write(at.join("HEAD"), "ref: refs/heads/main\n").unwrap();
}
#[test]
fn ordinary_repo_with_git_directory_is_not_a_worktree() {
let tmp = TempDir::new("ordinary");
let repo = tmp.path().join("repo");
make_git_dir(&repo.join(".git"));
assert_eq!(detect_real_git_dir(&repo), None);
}
#[test]
fn valid_worktree_resolves_to_parent_git_dir() {
let tmp = TempDir::new("worktree");
let main_git = tmp.path().join("main").join(".git");
make_git_dir(&main_git);
fs::create_dir_all(main_git.join("worktrees").join("wt")).unwrap();
let work = tmp.path().join("work");
fs::create_dir_all(&work).unwrap();
let gitdir = main_git.join("worktrees").join("wt");
fs::write(work.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap();
assert_eq!(
detect_real_git_dir(&work),
Some(main_git.canonicalize().unwrap()),
);
}
#[test]
fn hostile_gitdir_pointing_at_non_git_dir_is_rejected() {
let tmp = TempDir::new("hostile");
let evil = tmp.path().join("evil");
fs::create_dir_all(&evil).unwrap();
let work = tmp.path().join("work");
fs::create_dir_all(&work).unwrap();
fs::write(work.join(".git"), format!("gitdir: {}\n", evil.display())).unwrap();
assert_eq!(detect_real_git_dir(&work), None);
}
#[test]
fn gitdir_pointing_at_nonexistent_path_is_rejected() {
let tmp = TempDir::new("missing");
let work = tmp.path().join("work");
fs::create_dir_all(&work).unwrap();
let missing = tmp.path().join("does-not-exist");
fs::write(
work.join(".git"),
format!("gitdir: {}\n", missing.display()),
)
.unwrap();
assert_eq!(detect_real_git_dir(&work), None);
}
#[test]
fn submodule_gitdir_resolves_to_module_dir_as_is() {
let tmp = TempDir::new("submodule");
let module_git = tmp
.path()
.join("super")
.join(".git")
.join("modules")
.join("sub");
make_git_dir(&module_git);
let work = tmp.path().join("super").join("sub");
fs::create_dir_all(&work).unwrap();
fs::write(
work.join(".git"),
format!("gitdir: {}\n", module_git.display()),
)
.unwrap();
assert_eq!(
detect_real_git_dir(&work),
Some(module_git.canonicalize().unwrap()),
);
}
#[test]
fn home_directory_is_rejected_even_if_it_looks_like_a_git_dir() {
let tmp = TempDir::new("home");
let home = tmp.path().canonicalize().unwrap();
assert_eq!(
dangerous_grant_reason(&home, Some(&home)),
Some("refusing to grant the home directory"),
);
let repo = home.join("repo").join(".git");
assert_eq!(dangerous_grant_reason(&repo, Some(&home)), None);
}
}