//! Git-toplevel probe for the spawn-form worktree section (#25). //! //! When the user picks a working directory, the spawn form wants to offer a //! "git worktree" toggle that needs only a branch name — the `base_repo` is //! *inferred* from the chosen dir rather than typed. This module answers that //! one question: "what git repository, if any, encloses this path?" //! //! [`find_root`] walks up from a path to the nearest ancestor containing a //! `.git` entry and returns that ancestor (the repo toplevel). A regular `.git` //! directory and a `.git` *file* (the linked-worktree / submodule form) both //! count, so a dir that is itself inside a checked-out worktree still resolves //! to its worktree toplevel. //! //! It's a pure filesystem walk with no `git` subprocess — same shape as //! [`crate::dirlist`]: unit-tested in isolation, and the HTTP handler / //! HTTP layer (#26) just wraps its `Option` for the wire. use std::path::{Path, PathBuf}; /// Walk up from `start` to the nearest ancestor that holds a `.git` entry and /// return that ancestor — the enclosing repository's toplevel. Returns `None` /// when no ancestor is a git repo. /// /// Both a `.git` directory (ordinary checkout) and a `.git` file (linked /// worktree or submodule, which stores a `gitdir:` pointer) count as a repo /// marker, matching what `git rev-parse --show-toplevel` would resolve. `start` /// itself is checked first, so passing a repo root returns that root. /// /// The input is used as-is: callers that accept abbreviated client paths /// should resolve them (e.g. via [`crate::dirlist`]'s home-dir expansion) /// before probing, so an empty or `~` path doesn't walk up from the process /// working directory. pub fn find_root(start: &Path) -> Option { // `ancestors()` yields `start`, its parent, grandparent, … up to the root, // so a repo toplevel is matched at the first hit (closest enclosing repo). start .ancestors() .find(|dir| is_git_root(dir)) .map(Path::to_path_buf) } /// Whether `dir` holds a `.git` entry — directory or file. A bare `exists()` /// check is deliberate: we don't open or parse it, only detect the marker. fn is_git_root(dir: &Path) -> bool { dir.join(".git").exists() } #[cfg(test)] mod tests { use super::*; use std::fs; /// A temp repo: `/.git/` plus a nested `a/b/c` subtree. Returns the /// temp dir (its `Drop` cleans up) so callers keep it alive for the test. fn repo_fixture() -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); fs::create_dir(root.join(".git")).unwrap(); fs::create_dir_all(root.join("a/b/c")).unwrap(); tmp } #[test] fn dir_inside_repo_resolves_to_toplevel() { let tmp = repo_fixture(); // The repo root itself resolves to the repo root. assert_eq!(find_root(tmp.path()).as_deref(), Some(tmp.path())); // An immediate subdir resolves to the toplevel, not itself. assert_eq!( find_root(&tmp.path().join("a")).as_deref(), Some(tmp.path()) ); } #[test] fn nested_subdir_resolves_to_toplevel() { let tmp = repo_fixture(); let deep = tmp.path().join("a/b/c"); assert_eq!(find_root(&deep).as_deref(), Some(tmp.path())); } #[test] fn non_repo_dir_is_none() { // A temp dir with no `.git` anywhere up the chain. Use the temp root so // the walk can't accidentally hit a real repo above the test's cwd. let tmp = tempfile::tempdir().unwrap(); let sub = tmp.path().join("plain/dir"); fs::create_dir_all(&sub).unwrap(); assert_eq!(find_root(&sub), None); } #[test] fn git_file_worktree_marker_counts_as_root() { // Linked worktrees and submodules store `.git` as a *file* holding a // `gitdir:` pointer rather than a directory. It must still resolve. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); fs::write(root.join(".git"), b"gitdir: /somewhere/.git/worktrees/wt\n").unwrap(); let sub = root.join("src"); fs::create_dir(&sub).unwrap(); assert_eq!(find_root(&sub).as_deref(), Some(root)); } #[test] fn nearest_enclosing_repo_wins() { // An inner repo nested inside an outer repo resolves to the *inner* // toplevel — the walk stops at the first `.git` it meets going up. let tmp = tempfile::tempdir().unwrap(); let outer = tmp.path(); fs::create_dir(outer.join(".git")).unwrap(); let inner = outer.join("vendored"); fs::create_dir(&inner).unwrap(); fs::create_dir(inner.join(".git")).unwrap(); let inner_sub = inner.join("src"); fs::create_dir(&inner_sub).unwrap(); assert_eq!(find_root(&inner_sub).as_deref(), Some(inner.as_path())); } }