diff --git a/crates/maestro-daemon/src/gitroot.rs b/crates/maestro-daemon/src/gitroot.rs new file mode 100644 index 0000000..07dfca1 --- /dev/null +++ b/crates/maestro-daemon/src/gitroot.rs @@ -0,0 +1,118 @@ +//! 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 registry-client task / +//! 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())); + } +} diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 6a08763..a910bb3 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -13,6 +13,7 @@ pub mod cli; pub mod config; pub mod configure; pub mod dirlist; +pub mod gitroot; pub mod jwt; pub mod local_api; pub mod local_events;