use std::path::{Path, PathBuf};
const MAX_ALTERNATES_DEPTH: usize = 5;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct GitScopeExtension {
pub(crate) read_write: Vec<PathBuf>,
pub(crate) read_only: Vec<PathBuf>,
}
pub(crate) fn git_scope_extension(root: &Path) -> GitScopeExtension {
let mut ext = GitScopeExtension::default();
let git_path = root.join(".git");
let Some(common_dir) = resolve_common_dir(&git_path, &mut ext.read_write) else {
return ext;
};
collect_alternates(&common_dir, &mut ext.read_only, 0);
ext
}
fn resolve_common_dir(git_path: &Path, read_write: &mut Vec<PathBuf>) -> Option<PathBuf> {
let meta = std::fs::symlink_metadata(git_path).ok()?;
if meta.is_dir() {
return Some(git_path.to_path_buf());
}
let git_dir = read_gitdir_file(git_path)?;
push_existing_dir(read_write, &git_dir);
let common_dir = read_commondir(&git_dir).unwrap_or_else(|| git_dir.clone());
push_existing_dir(read_write, &common_dir);
Some(common_dir)
}
fn collect_alternates(common_dir: &Path, read_only: &mut Vec<PathBuf>, depth: usize) {
if depth >= MAX_ALTERNATES_DEPTH {
return;
}
let objects_dir = common_dir.join("objects");
let Some(text) = read_text_if_exists(&objects_dir.join("info").join("alternates")) else {
return;
};
for line in text.lines() {
let entry = line.trim();
if entry.is_empty() || entry.starts_with('#') {
continue;
}
let candidate = PathBuf::from(entry);
let alt_objects = if candidate.is_absolute() {
candidate
} else {
objects_dir.join(candidate)
};
if !push_existing_dir(read_only, &alt_objects) {
continue;
}
if let Some(alt_git_dir) = alt_objects.parent() {
collect_alternates(alt_git_dir, read_only, depth + 1);
}
}
}
pub(crate) fn read_gitdir_file(git_path: &Path) -> Option<PathBuf> {
let text = read_text_if_exists(git_path)?;
let raw = text.trim().strip_prefix("gitdir:")?.trim();
let candidate = PathBuf::from(raw);
if candidate.is_absolute() {
Some(candidate)
} else {
Some(git_path.parent()?.join(candidate))
}
}
pub(crate) fn read_commondir(git_dir: &Path) -> Option<PathBuf> {
let raw = read_text_if_exists(&git_dir.join("commondir"))?;
let candidate = PathBuf::from(raw.trim());
if candidate.is_absolute() {
Some(candidate)
} else {
Some(git_dir.join(candidate))
}
}
fn push_existing_dir(dirs: &mut Vec<PathBuf>, candidate: &Path) -> bool {
if !candidate.is_dir() {
return false;
}
if !dirs.iter().any(|existing| existing == candidate) {
dirs.push(candidate.to_path_buf());
}
true
}
fn read_text_if_exists(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
#[test]
fn ordinary_repo_yields_no_extension() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".git/objects")).unwrap();
let ext = git_scope_extension(root);
assert!(ext.read_write.is_empty());
assert!(ext.read_only.is_empty());
}
#[test]
fn non_git_directory_yields_no_extension() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(
git_scope_extension(tmp.path()),
GitScopeExtension::default()
);
}
#[test]
fn linked_worktree_resolves_gitdir_and_commondir() {
let tmp = tempfile::tempdir().unwrap();
let main = tmp.path().join("main");
let git_common = main.join(".git");
let worktree_gitdir = git_common.join("worktrees/feature");
std::fs::create_dir_all(git_common.join("objects")).unwrap();
std::fs::create_dir_all(&worktree_gitdir).unwrap();
write(&worktree_gitdir.join("commondir"), "../..\n");
let worktree = tmp.path().join("feature");
std::fs::create_dir_all(&worktree).unwrap();
write(
&worktree.join(".git"),
&format!("gitdir: {}\n", worktree_gitdir.display()),
);
let ext = git_scope_extension(&worktree);
assert!(
ext.read_write.iter().any(|p| p == &worktree_gitdir),
"expected worktree gitdir in {:?}",
ext.read_write
);
let resolved_common = worktree_gitdir.join("../..");
assert!(
ext.read_write.iter().any(|p| p == &resolved_common),
"expected common dir in {:?}",
ext.read_write
);
}
#[test]
fn worktree_with_relative_gitdir_resolves_against_dot_git_parent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let git_common = root.join(".git");
let worktree_gitdir = git_common.join("worktrees/feature");
std::fs::create_dir_all(git_common.join("objects")).unwrap();
std::fs::create_dir_all(&worktree_gitdir).unwrap();
write(&worktree_gitdir.join("commondir"), "../..\n");
let worktree = root.join("feature");
std::fs::create_dir_all(&worktree).unwrap();
write(
&worktree.join(".git"),
"gitdir: ../.git/worktrees/feature\n",
);
let ext = git_scope_extension(&worktree);
assert!(
ext.read_write
.iter()
.any(|p| p == &worktree.join("../.git/worktrees/feature")),
"expected relative gitdir resolved in {:?}",
ext.read_write
);
}
#[test]
fn absolute_alternates_are_read_only() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir_all(root.join(".git/objects/info")).unwrap();
let shared_objects = tmp.path().join("source/.git/objects");
std::fs::create_dir_all(&shared_objects).unwrap();
write(
&root.join(".git/objects/info/alternates"),
&format!("{}\n", shared_objects.display()),
);
let ext = git_scope_extension(&root);
assert!(ext.read_write.is_empty());
assert_eq!(ext.read_only, vec![shared_objects]);
}
#[test]
fn relative_alternates_resolve_against_objects_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir_all(root.join(".git/objects/info")).unwrap();
let shared_objects = tmp.path().join("source/.git/objects");
std::fs::create_dir_all(&shared_objects).unwrap();
write(
&root.join(".git/objects/info/alternates"),
"../../../source/.git/objects\n",
);
let ext = git_scope_extension(&root);
let expected = root
.join(".git/objects")
.join("../../../source/.git/objects");
assert_eq!(ext.read_only, vec![expected]);
}
#[test]
fn alternates_blank_and_comment_lines_ignored() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir_all(root.join(".git/objects/info")).unwrap();
let shared_objects = tmp.path().join("source/.git/objects");
std::fs::create_dir_all(&shared_objects).unwrap();
write(
&root.join(".git/objects/info/alternates"),
&format!("# a comment\n\n{}\n", shared_objects.display()),
);
let ext = git_scope_extension(&root);
assert_eq!(ext.read_only, vec![shared_objects]);
}
#[test]
fn nonexistent_alternate_target_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir_all(root.join(".git/objects/info")).unwrap();
write(
&root.join(".git/objects/info/alternates"),
"/does/not/exist/objects\n",
);
let ext = git_scope_extension(&root);
assert!(ext.read_only.is_empty());
}
#[test]
fn chained_alternates_are_followed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir_all(root.join(".git/objects/info")).unwrap();
let mid_objects = tmp.path().join("mid/.git/objects");
std::fs::create_dir_all(mid_objects.join("info")).unwrap();
let base_objects = tmp.path().join("base/.git/objects");
std::fs::create_dir_all(&base_objects).unwrap();
write(
&root.join(".git/objects/info/alternates"),
&format!("{}\n", mid_objects.display()),
);
write(
&mid_objects.join("info/alternates"),
&format!("{}\n", base_objects.display()),
);
let ext = git_scope_extension(&root);
assert!(ext.read_only.contains(&mid_objects));
assert!(ext.read_only.contains(&base_objects));
}
#[test]
fn malformed_gitdir_file_yields_no_extension() {
let tmp = tempfile::tempdir().unwrap();
let worktree = tmp.path().join("feature");
std::fs::create_dir_all(&worktree).unwrap();
write(&worktree.join(".git"), "this is not a gitdir pointer\n");
assert_eq!(git_scope_extension(&worktree), GitScopeExtension::default());
}
#[test]
fn dangling_gitdir_pointer_is_not_added() {
let tmp = tempfile::tempdir().unwrap();
let worktree = tmp.path().join("feature");
std::fs::create_dir_all(&worktree).unwrap();
write(&worktree.join(".git"), "gitdir: /nonexistent/git/dir\n");
let ext = git_scope_extension(&worktree);
assert!(ext.read_write.is_empty());
assert!(ext.read_only.is_empty());
}
}