use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitSpaceMetadata { pub key: String, pub checkout_key: String, pub label: String, pub repo_root: PathBuf, pub is_linked_worktree: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitWorktreeInfo { pub repo_root: PathBuf, pub git_dir: PathBuf, pub git_common_dir: PathBuf, pub is_bare: bool, pub is_linked_worktree: bool, } pub fn derive_label_from_cwd(cwd: &Path) -> String { if let Some(repo_root) = git_repo_root(cwd) { if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) { return name.to_string(); } } if let Ok(home) = std::env::var("HOME") { let home = Path::new(&home); if cwd == home { return "~".to_string(); } } cwd.file_name() .and_then(|n| n.to_str()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .unwrap_or_else(|| cwd.display().to_string()) } pub fn git_worktree_info(cwd: &Path) -> Option { let repo_root = git_repo_root(cwd)?; let git_dir = canonicalize_best_effort_path(&git_dir_for_repo_root(&repo_root)?); let git_common_dir = canonicalize_best_effort_path(&git_common_dir_for_git_dir(&git_dir)); let is_linked_worktree = git_dir != git_common_dir; Some(GitWorktreeInfo { repo_root, git_dir, git_common_dir, is_bare: false, is_linked_worktree, }) } pub fn git_space_metadata(cwd: &Path) -> Option { git_repo_root(cwd)?; let info = git_worktree_info(cwd)?; if info.is_bare { return None; } let key = canonicalize_best_effort_path(&info.git_common_dir) .display() .to_string(); let checkout_key = canonicalize_best_effort_path(&info.repo_root) .display() .to_string(); let label_path = if info .git_common_dir .file_name() .and_then(|name| name.to_str()) == Some(".git") { info.git_common_dir.parent().unwrap_or(&info.repo_root) } else { &info.repo_root }; let label = label_path .file_name() .and_then(|name| name.to_str()) .unwrap_or("repo") .to_string(); Some(GitSpaceMetadata { key, checkout_key, label, repo_root: info.repo_root, is_linked_worktree: info.is_linked_worktree, }) } pub(super) fn canonicalize_best_effort_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } fn git_common_dir_for_git_dir(git_dir: &Path) -> PathBuf { let commondir = git_dir.join("commondir"); let Ok(contents) = std::fs::read_to_string(commondir) else { return git_dir.to_path_buf(); }; let path = Path::new(contents.trim()); if path.is_absolute() { path.to_path_buf() } else { git_dir.join(path) } } pub fn git_branch(cwd: &Path) -> Option { let repo_root = git_repo_root(cwd)?; let git_dir = git_dir_for_repo_root(&repo_root)?; let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; parse_git_head_branch(&head) } pub(super) fn git_dir_for_repo_root(repo_root: &Path) -> Option { let git_path = repo_root.join(".git"); if git_path.is_dir() { return Some(git_path); } let gitdir = std::fs::read_to_string(&git_path).ok()?; let relative = gitdir.trim().strip_prefix("gitdir:")?.trim(); let resolved = Path::new(relative); Some(if resolved.is_absolute() { resolved.to_path_buf() } else { repo_root.join(resolved) }) } fn parse_git_head_branch(head: &str) -> Option { let branch = head.trim().strip_prefix("ref: refs/heads/")?; (!branch.is_empty()).then(|| branch.to_string()) } pub(super) fn git_repo_root(start: &Path) -> Option { let mut current = if start.is_dir() { start.to_path_buf() } else { start.parent()?.to_path_buf() }; loop { if git_dir_for_repo_root(¤t) .map(|git_dir| git_dir.join("HEAD").is_file()) .unwrap_or(false) { return Some(current); } if !current.pop() { return None; } } } pub(super) fn read_ref_oid(common_dir: &Path, full_ref: &str) -> Option { let loose_ref = common_dir.join(full_ref); if let Ok(contents) = std::fs::read_to_string(loose_ref) { let oid = contents.trim(); if !oid.is_empty() { return Some(oid.to_string()); } } let packed_refs = std::fs::read_to_string(common_dir.join("packed-refs")).ok()?; for line in packed_refs.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') || line.starts_with('^') { continue; } let mut parts = line.split_whitespace(); let oid = parts.next()?; let name = parts.next()?; if name == full_ref { return Some(oid.to_string()); } } None } #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use super::*; fn temp_test_dir(name: &str) -> PathBuf { let unique = format!( "herdr-workspace-tests-{}-{}-{}", name, std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() ); let path = std::env::temp_dir().join(unique); std::fs::create_dir_all(&path).unwrap(); path } #[test] fn git_branch_reads_head_from_standard_repo() { let root = temp_test_dir("standard-repo"); std::fs::create_dir_all(root.join(".git")).unwrap(); std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); assert_eq!(git_branch(&root).as_deref(), Some("main")); std::fs::remove_dir_all(root).unwrap(); } #[test] fn git_branch_reads_head_from_worktree_gitdir_file() { let root = temp_test_dir("worktree"); let worktree_git_dir = root.join(".bare/worktrees/feature"); std::fs::create_dir_all(&worktree_git_dir).unwrap(); std::fs::write(root.join(".git"), "gitdir: .bare/worktrees/feature\n").unwrap(); std::fs::write(worktree_git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap(); assert_eq!(git_branch(&root).as_deref(), Some("feature")); std::fs::remove_dir_all(root).unwrap(); } #[test] fn git_branch_returns_none_for_detached_head() { let root = temp_test_dir("detached-head"); std::fs::create_dir_all(root.join(".git")).unwrap(); std::fs::write(root.join(".git/HEAD"), "3e1b9a8d\n").unwrap(); assert_eq!(git_branch(&root), None); std::fs::remove_dir_all(root).unwrap(); } #[test] fn git_repo_root_ignores_invalid_git_marker() { let base = temp_test_dir("invalid-git-root"); let cwd = base.join("workspace"); std::fs::create_dir_all(base.join(".git")).unwrap(); std::fs::create_dir_all(&cwd).unwrap(); assert_eq!(git_repo_root(&cwd), None); std::fs::remove_dir_all(base).unwrap(); } #[test] fn derive_label_prefers_repo_root_name() { let root = temp_test_dir("label-repo"); let nested = root.join("nested"); std::fs::create_dir_all(root.join(".git")).unwrap(); std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); std::fs::create_dir_all(&nested).unwrap(); assert_eq!( derive_label_from_cwd(&nested), root.file_name().and_then(|name| name.to_str()).unwrap() ); std::fs::remove_dir_all(root).unwrap(); } #[test] fn derive_label_uses_path_name_outside_git() { let root = temp_test_dir("label-plain"); let label = root.file_name().and_then(|name| name.to_str()).unwrap(); assert_eq!(derive_label_from_cwd(Path::new(&root)), label); std::fs::remove_dir_all(root).unwrap(); } }