Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions crates/dashboard-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,21 +222,50 @@ 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<PathBuf> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut found: Vec<PathBuf> = 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: <path>` 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.
Expand Down
82 changes: 82 additions & 0 deletions crates/dashboard-core/tests/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading