use std::path::Path;
use crate::constants::GIT_DIR;
use crate::project::info::WorktreeHealth;
use crate::project::paths::AbsolutePath;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GitRepoPresence {
InRepo,
OutsideRepo,
}
impl GitRepoPresence {
pub const fn is_in_repo(self) -> bool { matches!(self, Self::InRepo) }
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum WorktreeStatus {
#[default]
NotGit,
Primary {
root: AbsolutePath,
},
Linked {
primary: AbsolutePath,
},
}
impl WorktreeStatus {
pub const fn is_linked_worktree(&self) -> bool { matches!(self, Self::Linked { .. }) }
pub const fn primary_root(&self) -> Option<&AbsolutePath> {
match self {
Self::NotGit => None,
Self::Primary { root } => Some(root),
Self::Linked { primary } => Some(primary),
}
}
}
pub(crate) fn git_repo_root(project_dir: &Path) -> Option<AbsolutePath> {
project_dir
.ancestors()
.find(|dir| {
let git_path = dir.join(GIT_DIR);
git_path.is_dir() || git_path.is_file()
})
.map(AbsolutePath::from)
}
pub(crate) fn resolve_git_dir(repo_root: &Path) -> Option<AbsolutePath> {
let git_path = repo_root.join(GIT_DIR);
if git_path.is_dir() {
return Some(git_path.into());
}
if git_path.is_file() {
let contents = std::fs::read_to_string(&git_path).ok()?;
let target = contents.strip_prefix("gitdir: ")?.trim();
return Some(AbsolutePath::resolve(target, repo_root));
}
None
}
pub(crate) fn resolve_common_git_dir(repo_root: &Path) -> Option<AbsolutePath> {
let git_dir = resolve_git_dir(repo_root)?;
let commondir_path = git_dir.join("commondir");
if !commondir_path.is_file() {
return Some(git_dir);
}
let contents = std::fs::read_to_string(&commondir_path).ok()?;
let target = contents.trim();
Some(AbsolutePath::resolve(target, &git_dir))
}
pub(crate) fn get_worktree_health(project_dir: &Path) -> WorktreeHealth {
let git_path = project_dir.join(GIT_DIR);
if !git_path.is_file() {
return WorktreeHealth::Normal;
}
let Ok(contents) = std::fs::read_to_string(&git_path) else {
return WorktreeHealth::Broken;
};
let Some(gitdir_str) = contents.strip_prefix("gitdir: ") else {
return WorktreeHealth::Broken;
};
let gitdir = AbsolutePath::resolve_no_canonicalize(gitdir_str.trim(), project_dir);
if gitdir.exists() {
WorktreeHealth::Normal
} else {
WorktreeHealth::Broken
}
}
pub(crate) fn get_worktree_status(project_dir: &Path) -> WorktreeStatus {
let mut dir = project_dir;
loop {
let git_path = dir.join(GIT_DIR);
if git_path.is_file() {
return linked_status_from_gitfile(&git_path, dir);
}
if git_path.is_dir() {
return dir
.canonicalize()
.map_or(WorktreeStatus::NotGit, |canonical| {
WorktreeStatus::Primary {
root: AbsolutePath::from(canonical),
}
});
}
let Some(parent) = dir.parent() else {
return WorktreeStatus::NotGit;
};
dir = parent;
}
}
fn linked_status_from_gitfile(git_path: &Path, dir: &Path) -> WorktreeStatus {
let Ok(contents) = std::fs::read_to_string(git_path) else {
return WorktreeStatus::NotGit;
};
let Some(gitdir_str) = contents.strip_prefix("gitdir: ") else {
return WorktreeStatus::NotGit;
};
let gitdir = AbsolutePath::resolve(gitdir_str.trim(), dir);
let Some(primary_root) = gitdir
.parent()
.and_then(Path::parent)
.and_then(Path::parent)
else {
return WorktreeStatus::NotGit;
};
WorktreeStatus::Linked {
primary: AbsolutePath::from(primary_root.to_path_buf()),
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests should panic on unexpected values"
)]
mod tests {
use super::*;
#[test]
fn git_repo_root_finds_ancestor_git_directory() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let repo_root = tmp.path().join("repo");
let nested = repo_root.join("crates").join("demo");
std::fs::create_dir_all(repo_root.join(".git")).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&nested).unwrap_or_else(|_| std::process::abort());
assert_eq!(git_repo_root(&nested).as_deref(), Some(repo_root.as_path()));
}
#[test]
fn git_repo_root_finds_worktree_git_file() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let repo_root = tmp.path().join("repo");
let nested = repo_root.join("crates").join("demo");
std::fs::create_dir_all(&nested).unwrap_or_else(|_| std::process::abort());
std::fs::write(repo_root.join(".git"), "gitdir: /tmp/fake\n")
.unwrap_or_else(|_| std::process::abort());
assert_eq!(git_repo_root(&nested).as_deref(), Some(repo_root.as_path()));
}
#[test]
fn resolve_git_dir_returns_dot_git_for_normal_repo() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let repo = tmp.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).unwrap_or_else(|_| std::process::abort());
assert_eq!(
resolve_git_dir(&repo).as_deref(),
Some(repo.join(".git").as_path())
);
}
#[test]
fn resolve_git_dir_follows_worktree_gitdir_file() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let main_git = tmp
.path()
.join("main")
.join(".git")
.join("worktrees")
.join("wt");
std::fs::create_dir_all(&main_git).unwrap_or_else(|_| std::process::abort());
let wt = tmp.path().join("wt");
std::fs::create_dir_all(&wt).unwrap_or_else(|_| std::process::abort());
std::fs::write(wt.join(".git"), format!("gitdir: {}\n", main_git.display()))
.unwrap_or_else(|_| std::process::abort());
let resolved = resolve_git_dir(&wt).expect("should resolve");
assert_eq!(resolved.canonicalize().ok(), main_git.canonicalize().ok());
}
#[test]
fn resolve_git_dir_returns_none_without_git() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
assert_eq!(resolve_git_dir(tmp.path()), None);
}
}