use std::path::{Component, Path, PathBuf};
pub fn in_the_main_working_tree(path: &Path) -> Option<PathBuf> {
let root = path.ancestors().find(|dir| dir.join(".git").exists())?;
let dot_git = root.join(".git");
if dot_git.is_dir() {
return None;
}
let said = std::fs::read_to_string(&dot_git).ok()?;
let named = said
.lines()
.find_map(|line| line.strip_prefix("gitdir:"))
.map(str::trim)?;
let folded = Path::new(named).is_relative();
let admin = resolved(root, named);
let common = std::fs::read_to_string(admin.join("commondir")).ok()?;
let common = resolved(&admin, common.trim());
if common.file_name()? != ".git" {
return None;
}
if folded && !the_admin_directory_claims(&admin, root) {
return None;
}
let within = path.strip_prefix(root).ok()?;
Some(common.parent()?.join(within))
}
fn the_admin_directory_claims(admin: &Path, root: &Path) -> bool {
let arrived_from = std::fs::canonicalize(root.join(".git")).ok();
let claimed = std::fs::read_to_string(admin.join("gitdir"))
.ok()
.and_then(|named| std::fs::canonicalize(resolved(admin, named.trim())).ok());
arrived_from.is_some() && arrived_from == claimed
}
fn resolved(base: &Path, given: &str) -> PathBuf {
let given = Path::new(given);
let whole = if given.is_absolute() {
given.to_path_buf()
} else {
base.join(given)
};
let mut folded = PathBuf::new();
for part in whole.components() {
match part {
Component::CurDir => {}
Component::ParentDir => {
if !folded.pop() {
folded.push(part);
}
}
_ => folded.push(part),
}
}
folded
}
#[cfg(test)]
pub mod testing {
use std::path::{Path, PathBuf};
pub struct LinkedWorktree {
pub scratch: PathBuf,
pub checkout: PathBuf,
pub linked: PathBuf,
}
impl Drop for LinkedWorktree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.scratch);
}
}
pub fn a_scratch_directory(named: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("bdi-{named}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("the directory is ours to make");
std::fs::canonicalize(&path).expect("the directory we just made resolves")
}
pub fn git_in(cwd: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args([
"-c",
"user.name=bdi tests",
"-c",
"user.email=tests@beady-eye.invalid",
"-c",
"init.defaultBranch=main",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(cwd)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} in {}: {}",
cwd.display(),
String::from_utf8_lossy(&out.stderr)
);
}
pub fn a_linked_worktree_git_made(named: &str) -> LinkedWorktree {
let scratch = a_scratch_directory(named);
let checkout = scratch.join("checkout");
std::fs::create_dir_all(&checkout).expect("the directory is ours to make");
git_in(&checkout, &["init"]);
git_in(&checkout, &["commit", "--allow-empty", "-m", "root"]);
let linked = scratch.join("seat/wt");
git_in(
&checkout,
&["worktree", "add", "--detach", &linked.display().to_string()],
);
LinkedWorktree {
scratch,
checkout,
linked,
}
}
}
#[cfg(test)]
mod tests {
use super::testing::{a_linked_worktree_git_made, a_scratch_directory, git_in, LinkedWorktree};
use super::*;
use pretty_assertions::assert_eq;
fn a_linked_worktree_written_by_hand(named: &str) -> LinkedWorktree {
let scratch = a_scratch_directory(named);
let checkout = scratch.join("checkout");
let linked = scratch.join("seat/wt");
let admin = checkout.join(".git/worktrees/wt");
std::fs::create_dir_all(&admin).expect("the directory is ours to make");
std::fs::create_dir_all(&linked).expect("the directory is ours to make");
std::fs::write(admin.join("commondir"), "../..\n").expect("ours to write");
std::fs::write(
admin.join("gitdir"),
format!("{}\n", linked.join(".git").display()),
)
.expect("ours to write");
std::fs::write(
linked.join(".git"),
format!("gitdir: {}\n", admin.display()),
)
.expect("ours to write");
LinkedWorktree {
scratch,
checkout,
linked,
}
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
#[test]
fn the_fixture_written_by_hand_is_what_git_writes() {
let by_git = a_linked_worktree_git_made("worktree-by-git");
let by_hand = a_linked_worktree_written_by_hand("worktree-by-hand");
let relocated = |text: String| {
text.replace(
&by_git.scratch.display().to_string(),
&by_hand.scratch.display().to_string(),
)
};
assert_eq!(
relocated(read(&by_git.linked.join(".git"))),
read(&by_hand.linked.join(".git"))
);
assert_eq!(
read(&by_git.checkout.join(".git/worktrees/wt/commondir")),
read(&by_hand.checkout.join(".git/worktrees/wt/commondir"))
);
}
#[test]
fn a_directory_in_a_linked_worktree_is_placed_at_the_same_place_in_the_checkout() {
let fixture = a_linked_worktree_written_by_hand("worktree-placed");
let deep = fixture.linked.join("crates/dish");
std::fs::create_dir_all(&deep).expect("the directory is ours to make");
assert_eq!(
in_the_main_working_tree(&deep),
Some(fixture.checkout.join("crates/dish"))
);
assert_eq!(
in_the_main_working_tree(&fixture.linked),
Some(fixture.checkout.clone()),
"the worktree's own root is the checkout's"
);
}
#[test]
fn a_worktree_git_made_is_placed_the_same_way() {
let fixture = a_linked_worktree_git_made("worktree-git-placed");
let deep = fixture.linked.join("crates/dish");
std::fs::create_dir_all(&deep).expect("the directory is ours to make");
assert_eq!(
in_the_main_working_tree(&deep),
Some(fixture.checkout.join("crates/dish"))
);
}
#[test]
fn a_directory_in_the_main_working_tree_is_left_where_it_is() {
let fixture = a_linked_worktree_written_by_hand("worktree-main");
let inside = fixture.checkout.join("src");
std::fs::create_dir_all(&inside).expect("the directory is ours to make");
assert_eq!(in_the_main_working_tree(&inside), None);
}
#[test]
fn a_directory_in_no_repository_is_left_where_it_is() {
let scratch = a_scratch_directory("worktree-none");
let loose = scratch.join("notes");
std::fs::create_dir_all(&loose).expect("the directory is ours to make");
assert_eq!(in_the_main_working_tree(&loose), None);
std::fs::remove_dir_all(&scratch).expect("the directory is ours to remove");
}
#[test]
fn a_directory_that_does_not_exist_is_left_where_it_is() {
assert_eq!(
in_the_main_working_tree(Path::new("/srv/work/nowhere/at/all")),
None
);
}
#[test]
fn a_dot_git_file_naming_no_gitdir_is_ignored() {
let fixture = a_linked_worktree_written_by_hand("worktree-no-gitdir");
std::fs::write(fixture.linked.join(".git"), "nothing git wrote\n").expect("ours to write");
assert_eq!(in_the_main_working_tree(&fixture.linked), None);
}
#[test]
fn a_gitdir_that_no_longer_exists_is_ignored() {
let fixture = a_linked_worktree_written_by_hand("worktree-gone");
std::fs::remove_dir_all(fixture.checkout.join(".git/worktrees"))
.expect("the directory is ours to remove");
assert_eq!(in_the_main_working_tree(&fixture.linked), None);
}
#[test]
fn an_admin_directory_with_no_commondir_is_ignored() {
let fixture = a_linked_worktree_written_by_hand("worktree-no-commondir");
std::fs::remove_file(fixture.checkout.join(".git/worktrees/wt/commondir"))
.expect("the file is ours to remove");
assert_eq!(in_the_main_working_tree(&fixture.linked), None);
}
#[test]
fn an_absolute_commondir_is_read_as_it_is() {
let fixture = a_linked_worktree_written_by_hand("worktree-absolute-commondir");
std::fs::write(
fixture.checkout.join(".git/worktrees/wt/commondir"),
format!("{}\n", fixture.checkout.join(".git").display()),
)
.expect("ours to write");
assert_eq!(
in_the_main_working_tree(&fixture.linked),
Some(fixture.checkout.clone())
);
}
#[test]
fn a_relative_gitdir_is_resolved_against_the_worktree() {
let fixture = a_linked_worktree_written_by_hand("worktree-relative-gitdir");
std::fs::write(
fixture.linked.join(".git"),
"gitdir: ../../checkout/.git/worktrees/wt\n",
)
.expect("ours to write");
assert_eq!(
in_the_main_working_tree(&fixture.linked),
Some(fixture.checkout.clone())
);
}
#[test]
fn a_relative_gitdir_reached_through_a_symlink_is_left_where_it_is() {
let fixture = a_linked_worktree_written_by_hand("worktree-symlinked-relative-gitdir");
let spelled = fixture.scratch.join("link");
std::os::unix::fs::symlink(&fixture.linked, &spelled).expect("the link is ours to make");
assert_eq!(
in_the_main_working_tree(&spelled),
Some(fixture.checkout.clone()),
"the symlink alone is not what defeats the read: an absolute \
gitdir is followed through it"
);
std::fs::write(
fixture.linked.join(".git"),
"gitdir: ../../checkout/.git/worktrees/wt\n",
)
.expect("ours to write");
assert_eq!(in_the_main_working_tree(&spelled), None);
assert_eq!(
in_the_main_working_tree(&fixture.linked),
Some(fixture.checkout.clone()),
"and the relative form alone is not either: the same worktree by \
its own path is placed"
);
}
#[test]
fn a_folded_gitdir_landing_on_another_repositorys_admin_directory_places_nothing() {
let fixture = a_linked_worktree_written_by_hand("worktree-folded-onto-a-stranger");
std::fs::write(
fixture.linked.join(".git"),
"gitdir: ../../checkout/.git/worktrees/wt\n",
)
.expect("ours to write");
let stranger = fixture.scratch.join("stranger");
let strangers_worktree = stranger.join("seat/wt");
let strangers_admin = stranger.join("checkout/.git/worktrees/wt");
std::fs::create_dir_all(&strangers_admin).expect("the directory is ours to make");
std::fs::create_dir_all(&strangers_worktree).expect("the directory is ours to make");
std::fs::write(strangers_admin.join("commondir"), "../..\n").expect("ours to write");
std::fs::write(
strangers_admin.join("gitdir"),
format!("{}\n", strangers_worktree.join(".git").display()),
)
.expect("ours to write");
std::fs::write(
strangers_worktree.join(".git"),
format!("gitdir: {}\n", strangers_admin.display()),
)
.expect("ours to write");
let spelled = stranger.join("seat/link");
std::os::unix::fs::symlink(&fixture.linked, &spelled).expect("the link is ours to make");
assert_eq!(
in_the_main_working_tree(&spelled),
None,
"a pane in one repository's worktree was placed in another's"
);
assert_eq!(
in_the_main_working_tree(&strangers_worktree),
Some(stranger.join("checkout")),
"the stranger is a working repository, so its own worktree places"
);
}
#[test]
fn a_worktree_under_a_symlinked_ancestor_is_placed_in_the_readers_own_spelling() {
let scratch = a_scratch_directory("worktree-symlinked-ancestor");
let real = scratch.join("real");
let linked = real.join("seat/wt");
let admin = real.join("checkout/.git/worktrees/wt");
std::fs::create_dir_all(&admin).expect("the directory is ours to make");
std::fs::create_dir_all(&linked).expect("the directory is ours to make");
std::fs::write(admin.join("commondir"), "../..\n").expect("ours to write");
std::fs::write(admin.join("gitdir"), "../../../../seat/wt/.git\n").expect("ours to write");
std::fs::write(
linked.join(".git"),
"gitdir: ../../checkout/.git/worktrees/wt\n",
)
.expect("ours to write");
let dev = scratch.join("dev");
std::os::unix::fs::symlink(&real, &dev).expect("the link is ours to make");
assert_eq!(
in_the_main_working_tree(&dev.join("seat/wt/src")),
Some(dev.join("checkout/src")),
"the answer is the spelling the reader arrived by, which is the \
one a config naming the link would be written in"
);
std::fs::remove_dir_all(&scratch).expect("the directory is ours to remove");
}
#[test]
fn a_worktree_moved_without_repair_is_still_placed() {
let fixture = a_linked_worktree_git_made("worktree-moved-without-repair");
let moved = fixture.scratch.join("elsewhere/wt");
std::fs::create_dir_all(moved.parent().expect("it has a parent"))
.expect("the directory is ours to make");
std::fs::rename(&fixture.linked, &moved).expect("the directory is ours to move");
let admin = fixture.checkout.join(".git/worktrees/wt");
assert_eq!(
read(&admin.join("gitdir")).trim(),
format!("{}/.git", fixture.linked.display()),
"the admin directory still names where the worktree used to be"
);
assert_eq!(
in_the_main_working_tree(&moved),
Some(fixture.checkout.clone())
);
}
#[test]
fn a_worktree_of_a_bare_repository_is_left_where_it_is() {
let scratch = a_scratch_directory("worktree-bare");
let bare = scratch.join("ground.git");
let linked = scratch.join("seat/wt");
let admin = bare.join("worktrees/wt");
std::fs::create_dir_all(&admin).expect("the directory is ours to make");
std::fs::create_dir_all(&linked).expect("the directory is ours to make");
std::fs::write(admin.join("commondir"), "../..\n").expect("ours to write");
std::fs::write(
linked.join(".git"),
format!("gitdir: {}\n", admin.display()),
)
.expect("ours to write");
assert_eq!(in_the_main_working_tree(&linked), None);
std::fs::remove_dir_all(&scratch).expect("the directory is ours to remove");
}
#[test]
fn a_worktree_of_a_repository_with_a_separate_git_dir_is_left_where_it_is() {
let scratch = a_scratch_directory("worktree-separate-git-dir");
let checkout = scratch.join("main");
let elsewhere = scratch.join("elsewhere.git");
git_in(
&scratch,
&[
"init",
"--separate-git-dir",
&elsewhere.display().to_string(),
&checkout.display().to_string(),
],
);
git_in(&checkout, &["commit", "--allow-empty", "-m", "root"]);
let linked = scratch.join("seat/wt");
git_in(
&checkout,
&["worktree", "add", "--detach", &linked.display().to_string()],
);
assert!(
std::fs::read_to_string(checkout.join(".git"))
.expect("git wrote it")
.starts_with("gitdir:"),
"the main working tree's .git is a file too, and its gitdir has no commondir"
);
assert_eq!(in_the_main_working_tree(&linked), None);
assert_eq!(
in_the_main_working_tree(&checkout),
None,
"the main working tree is left where it is"
);
std::fs::remove_dir_all(&scratch).expect("the directory is ours to remove");
}
#[test]
fn a_relative_path_is_folded_without_the_filesystem() {
assert_eq!(
resolved(Path::new("/srv/work/main/.git/worktrees/wt"), "../.."),
PathBuf::from("/srv/work/main/.git")
);
assert_eq!(
resolved(
Path::new("/srv/work/seat/wt"),
"./../../main/.git/worktrees/wt"
),
PathBuf::from("/srv/work/main/.git/worktrees/wt")
);
assert_eq!(
resolved(Path::new("/srv/work/seat"), "/elsewhere/.git"),
PathBuf::from("/elsewhere/.git"),
"an absolute path owes nothing to the base"
);
assert_eq!(
resolved(Path::new("./seat/wt"), "../../main/.git"),
PathBuf::from("main/.git"),
"a leading `.` is the only one the components leave to fold"
);
}
#[test]
fn a_dot_git_whose_symlink_dangles_is_walked_past_as_git_walks_past_it() {
let fixture = a_linked_worktree_git_made("worktree-dangling-dot-git");
let deep = fixture.linked.join("nested/sub");
std::fs::create_dir_all(&deep).expect("the directory is ours to make");
let gone = fixture.scratch.join("the-target-a-build-collected");
std::os::unix::fs::symlink(&gone, fixture.linked.join("nested/.git"))
.expect("the link is ours to make");
assert!(
std::fs::symlink_metadata(&gone).is_err(),
"the link only dangles while nothing holds {}",
gone.display()
);
assert_eq!(
in_the_main_working_tree(&deep),
Some(fixture.checkout.join("nested/sub"))
);
}
}