aboutsummaryrefslogtreecommitdiff
path: root/src/workspace/git/discovery.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/workspace/git/discovery.rs')
-rw-r--r--src/workspace/git/discovery.rs278
1 files changed, 278 insertions, 0 deletions
diff --git a/src/workspace/git/discovery.rs b/src/workspace/git/discovery.rs
new file mode 100644
index 00000000..de4b090c
--- /dev/null
+++ b/src/workspace/git/discovery.rs
@@ -0,0 +1,278 @@
+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<GitWorktreeInfo> {
+ 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<GitSpaceMetadata> {
+ 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<String> {
+ 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<PathBuf> {
+ 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<String> {
+ 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<PathBuf> {
+ let mut current = if start.is_dir() {
+ start.to_path_buf()
+ } else {
+ start.parent()?.to_path_buf()
+ };
+
+ loop {
+ if git_dir_for_repo_root(&current)
+ .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<String> {
+ 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();
+ }
+}