diff --git a/crates/dashboard-core/src/config.rs b/crates/dashboard-core/src/config.rs index 42739dd..a377a3d 100644 --- a/crates/dashboard-core/src/config.rs +++ b/crates/dashboard-core/src/config.rs @@ -222,8 +222,8 @@ impl Config { } } -/// The git checkouts directly under `root`, sorted by path. Only primary checkouts (a `.git` folder) count: a linked -/// worktree (a `.git` file) is already part of its primary checkout's status. +/// The git checkouts directly under `root`, sorted by path. Only primary checkouts count: a `.git` folder, or a `.git` +/// file pointing at a git folder of its own. A linked worktree is already part of its primary checkout's status. pub fn discover(root: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(root) else { return Vec::new(); @@ -231,12 +231,41 @@ pub fn discover(root: &Path) -> Vec { let mut found: Vec = entries .filter_map(Result::ok) .map(|entry| entry.path()) - .filter(|path| path.is_dir() && path.join(".git").is_dir()) + .filter(|path| path.is_dir() && is_primary_checkout(path)) .collect(); found.sort(); found } +/// The most a `.git` file is read: its one `gitdir:` line, with room for a long path. +const MAX_GIT_FILE: u64 = 64 * 1024; + +/// Whether `checkout`'s `.git` is a folder, or a `gitdir: ` file whose git folder is its own, as +/// `git clone --separate-git-dir` makes (#88). A linked worktree's git folder holds `commondir`, pointing at the +/// primary's. Anything else, such as an unreadable, oversized or malformed `.git` file, a target that isn't a git +/// folder, or a `.git` that is neither a folder nor a regular file, is skipped. Nothing here starts git. +fn is_primary_checkout(checkout: &Path) -> bool { + use std::io::Read as _; + let marker = checkout.join(".git"); + let Ok(metadata) = std::fs::metadata(&marker) else { + return false; + }; + if metadata.is_dir() { + return true; + } + // A pipe or device could block a read, and a huge file isn't a `.git` file. + if !metadata.is_file() || metadata.len() > MAX_GIT_FILE { + return false; + } + let mut pointer = String::new(); + let read = std::fs::File::open(&marker).and_then(|file| file.take(MAX_GIT_FILE).read_to_string(&mut pointer)); + let Some(target) = read.ok().and_then(|_| pointer.trim().strip_prefix("gitdir: ")) else { + return false; + }; + let git_dir = checkout.join(target.trim()); + git_dir.join("HEAD").is_file() && !git_dir.join("commondir").exists() +} + /// Notices edits to `config.toml` while the app runs (#21) by comparing its bytes with the last applied read. The /// file is a few hundred bytes, so reading it every couple of seconds is cheap, and unlike a timestamp and size it /// can't miss a same-length edit. An unchanged file is never parsed again. diff --git a/crates/dashboard-core/tests/storage.rs b/crates/dashboard-core/tests/storage.rs index f2c6d73..fb7f39c 100644 --- a/crates/dashboard-core/tests/storage.rs +++ b/crates/dashboard-core/tests/storage.rs @@ -104,6 +104,88 @@ fn first_run_discovers_checkouts_one_level_under_the_root_and_writes_them() { assert!(dir.join(config::FILE_NAME).is_file()); } +/// A git folder for a `.git` file to point at: `HEAD`, plus `commondir` when it belongs to a linked worktree. +fn git_dir(path: &Path, linked: bool) { + std::fs::create_dir_all(path).unwrap(); + std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + if linked { + std::fs::write(path.join("commondir"), "../..\n").unwrap(); + } +} + +fn git_file(root: &Path, name: &str, contents: &[u8]) { + std::fs::create_dir_all(root.join(name)).unwrap(); + std::fs::write(root.join(name).join(".git"), contents).unwrap(); +} + +#[test] +fn first_run_discovers_separate_git_dir_checkouts_but_not_worktrees_or_broken_git_files() { + let scratch = Scratch::new("first-run-separate"); + let root = scratch.path().join("github"); + let gitdirs = scratch.path().join("gitdirs"); + std::fs::create_dir_all(root.join("plain/.git")).unwrap(); + // `git clone --separate-git-dir`: the .git file points at a git folder of its own, absolute or relative. + git_dir(&gitdirs.join("sep.git"), false); + git_file( + &root, + "separate", + format!("gitdir: {}\n", gitdirs.join("sep.git").display()).as_bytes(), + ); + git_dir(&gitdirs.join("rel.git"), false); + git_file(&root, "relative", b"gitdir: ../../gitdirs/rel.git\r\n"); + // Not checkouts. A linked worktree's git folder has HEAD too; commondir is what tells it apart. + let worktree = root.join("plain/.git/worktrees/pr-1"); + git_dir(&worktree, true); + git_file( + &root, + "plain-pr-1", + format!("gitdir: {}", worktree.display()).as_bytes(), + ); + git_file( + &root, + "missing", + format!("gitdir: {}", gitdirs.join("none.git").display()).as_bytes(), + ); + std::fs::create_dir_all(gitdirs.join("no-head.git")).unwrap(); + git_file( + &root, + "no-head", + format!("gitdir: {}", gitdirs.join("no-head.git").display()).as_bytes(), + ); + git_file( + &root, + "malformed", + format!("{}\n", gitdirs.join("sep.git").display()).as_bytes(), + ); + git_file(&root, "empty", b""); + git_file(&root, "not-utf8", b"gitdir: \xff\xfe\n"); + // A valid first line, but more than a .git file holds: never read. + let mut huge = format!("gitdir: {}\n", gitdirs.join("sep.git").display()).into_bytes(); + huge.resize(70 * 1024, b' '); + git_file(&root, "huge", &huge); + std::fs::create_dir_all(root.join("group/deep/.git")).unwrap(); + #[cfg(target_os = "linux")] + { + // A pipe is never opened: reading one with no writer would block discovery. + std::fs::create_dir_all(root.join("pipe")).unwrap(); + let fifo = rustix::fs::FileType::Fifo; + rustix::fs::mknodat(rustix::fs::CWD, root.join("pipe/.git"), fifo, 0o600.into(), 0).unwrap(); + } + + let expected = [root.join("plain"), root.join("relative"), root.join("separate")]; + assert_eq!(config::discover(&root), expected); + let config = Config::load_or_create_in(&scratch.path().join("config"), root.clone()).unwrap(); + assert_eq!(config.repositories, expected); +} + +#[test] +fn discovery_of_a_missing_root_or_a_file_is_empty() { + let scratch = Scratch::new("first-run-no-root"); + assert!(config::discover(&scratch.path().join("absent")).is_empty()); + std::fs::write(scratch.path().join("file"), "").unwrap(); + assert!(config::discover(&scratch.path().join("file")).is_empty()); +} + #[test] fn cache_round_trips_and_drops_removed_repositories() { let scratch = Scratch::new("cache");