use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct AnchorRef {
pub kind: String,
pub id: String,
}
impl AnchorRef {
pub fn is_empty_tree(&self) -> bool {
self.id.is_empty()
}
pub fn short(&self) -> &str {
let n = self.id.len().min(7);
&self.id[..n]
}
}
#[derive(Debug, Clone)]
pub struct Change {
pub file_path: String,
pub times: usize,
}
pub trait Anchor {
fn snapshot(&self) -> AnchorRef;
fn changed_since(&self, r: &AnchorRef) -> Vec<Change>;
}
pub struct Null;
impl Anchor for Null {
fn snapshot(&self) -> AnchorRef {
AnchorRef {
kind: "null".into(),
id: String::new(),
}
}
fn changed_since(&self, _r: &AnchorRef) -> Vec<Change> {
vec![]
}
}
pub struct Git {
root: PathBuf,
gitdir: PathBuf,
}
#[derive(Clone)]
struct Location {
root: PathBuf,
gitdir: PathBuf,
}
type LocationCache = std::sync::Mutex<std::collections::HashMap<PathBuf, Option<Location>>>;
fn location_cache() -> &'static LocationCache {
static CACHE: std::sync::OnceLock<LocationCache> = std::sync::OnceLock::new();
CACHE.get_or_init(Default::default)
}
fn locate_cached(root: &Path) -> Option<Location> {
let mut cache = location_cache().lock().unwrap_or_else(|e| e.into_inner());
if let Some(hit) = cache.get(root) {
return hit.clone();
}
let found = locate(root);
cache.insert(root.to_path_buf(), found.clone());
found
}
fn locate(root: &Path) -> Option<Location> {
let mut d = root.to_path_buf();
loop {
let g = d.join(".git");
if g.is_dir() {
return Some(Location { root: d, gitdir: g });
}
if g.is_file() {
let t = std::fs::read_to_string(&g).ok()?;
let p = t.trim().strip_prefix("gitdir:")?.trim();
let abs = if Path::new(p).is_absolute() {
PathBuf::from(p)
} else {
d.join(p)
};
return Some(Location {
root: d,
gitdir: abs,
});
}
if !d.pop() {
return None;
}
}
}
pub fn detect(root: &Path) -> Box<dyn Anchor> {
match Git::new(root) {
Some(g) => Box::new(g),
None => Box::new(Null),
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Head {
pub branch: Option<String>,
pub sha: Option<String>,
pub rebasing: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Where {
Head(Head),
Missing,
}
pub(crate) fn where_of(repo_root: &Path) -> Where {
let Some(g) = Git::new(repo_root) else {
return Where::Missing;
};
Where::Head(g.where_now())
}
pub(crate) fn in_working_tree(root: &Path) -> bool {
locate_cached(root).is_some()
}
pub(crate) fn linked_worktree(from: &Path) -> Option<PathBuf> {
let mut d = from.to_path_buf();
loop {
let g = d.join(".git");
if g.is_dir() {
return None;
}
if g.is_file() {
let t = std::fs::read_to_string(&g).ok()?;
let p = t.trim().strip_prefix("gitdir:")?.trim();
let gitdir = if Path::new(p).is_absolute() {
PathBuf::from(p)
} else {
d.join(p)
};
if gitdir.join("commondir").is_file() {
return Some(d);
}
}
if !d.pop() {
return None;
}
}
}
pub(crate) fn main_copy_of(worktree_root: &Path) -> Option<PathBuf> {
let location = locate_cached(worktree_root)?;
let common_gitdir = common_gitdir(&location.gitdir)?;
if common_gitdir.file_name() != Some(std::ffi::OsStr::new(".git")) {
return None;
}
common_gitdir.parent().map(Path::to_path_buf)
}
fn ref_gitdir(gitdir: &Path) -> PathBuf {
match common_gitdir(gitdir) {
Some(common) => common,
None => gitdir.to_path_buf(),
}
}
fn common_gitdir(gitdir: &Path) -> Option<PathBuf> {
let raw = std::fs::read_to_string(gitdir.join("commondir")).ok()?;
let rel = raw.trim();
if rel.is_empty() {
return None;
}
Some(if Path::new(rel).is_absolute() {
PathBuf::from(rel)
} else {
normalize(&gitdir.join(rel))
})
}
pub(crate) fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
pub(crate) fn same_folder(a: &Path, b: &Path) -> bool {
if normalize(a) == normalize(b) {
return true;
}
matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y)
}
pub(crate) fn tracks(root: &Path, rel: &str) -> Option<bool> {
let status = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["ls-files", "--error-unmatch", "--", rel])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok()?;
match status.code() {
Some(0) => Some(true),
Some(1) => Some(false),
_ => None,
}
}
pub(crate) const EVENTS_TRACKED_WARNING: &str =
".vivac/events is tracked by git here. Every clone and worktree gets its \
own copy of the log, and the copies diverge. Remove it from the index \
(git rm -r --cached .vivac) and let .vivac/.gitignore keep it out.";
impl Git {
fn new(root: &Path) -> Option<Git> {
locate_cached(root).map(|l| Git {
root: l.root,
gitdir: l.gitdir,
})
}
fn head(&self) -> Option<String> {
let h = std::fs::read_to_string(self.gitdir.join("HEAD")).ok()?;
let h = h.trim();
let Some(refname) = h.strip_prefix("ref:").map(str::trim) else {
return is_sha(h).then(|| h.to_string());
};
let refs = ref_gitdir(&self.gitdir);
if let Ok(s) = std::fs::read_to_string(refs.join(refname)) {
let s = s.trim().to_string();
if is_sha(&s) {
return Some(s);
}
}
let packed = std::fs::read_to_string(refs.join("packed-refs")).ok()?;
packed.lines().find_map(|l| {
let (sha, name) = l.split_once(' ')?;
(name.trim() == refname && is_sha(sha)).then(|| sha.to_string())
})
}
fn where_now(&self) -> Head {
let sha = self.head();
if let Some(branch) = self.rebasing_onto() {
return Head {
branch: Some(branch),
sha,
rebasing: true,
};
}
Head {
branch: self.head_branch(),
sha,
rebasing: false,
}
}
fn head_branch(&self) -> Option<String> {
let h = std::fs::read_to_string(self.gitdir.join("HEAD")).ok()?;
let refname = h.trim().strip_prefix("ref:")?.trim().to_string();
Some(short_branch(&refname))
}
fn rebasing_onto(&self) -> Option<String> {
for dir in ["rebase-merge", "rebase-apply"] {
let f = self.gitdir.join(dir).join("head-name");
if let Ok(name) = std::fs::read_to_string(f) {
let name = name.trim();
if !name.is_empty() {
return Some(short_branch(name));
}
}
}
None
}
fn git(&self, args: &[&str]) -> Option<String> {
let s = std::process::Command::new("git")
.arg("-C")
.arg(&self.root)
.args(args)
.output()
.ok()?;
s.status
.success()
.then(|| String::from_utf8_lossy(&s.stdout).into_owned())
}
}
fn is_sha(s: &str) -> bool {
s.len() >= 7 && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
fn short_branch(refname: &str) -> String {
refname
.strip_prefix("refs/heads/")
.unwrap_or(refname)
.to_string()
}
impl Anchor for Git {
fn snapshot(&self) -> AnchorRef {
AnchorRef {
kind: "git".into(),
id: self.head().unwrap_or_default(),
}
}
fn changed_since(&self, r: &AnchorRef) -> Vec<Change> {
if r.is_empty_tree() || r.kind != "git" {
return vec![];
}
let mut count: std::collections::BTreeMap<String, usize> = Default::default();
if let Some(out) = self.git(&[
"log",
"--format=",
"--name-only",
&format!("{}..HEAD", r.id),
]) {
for l in out.lines().map(str::trim).filter(|l| !l.is_empty()) {
*count.entry(l.to_string()).or_default() += 1;
}
}
if let Some(out) = self.git(&["status", "--porcelain", "-uall"]) {
for l in out.lines() {
if let Some(file_path) = l.get(3..) {
let file_path = file_path.rsplit(" -> ").next().unwrap_or(file_path).trim();
if !file_path.is_empty() {
*count
.entry(file_path.trim_matches('"').to_string())
.or_default() += 1;
}
}
}
}
let mut v: Vec<Change> = count
.into_iter()
.map(|(file_path, times)| Change { file_path, times })
.collect();
v.sort_by(|a, b| {
b.times
.cmp(&a.times)
.then_with(|| a.file_path.cmp(&b.file_path))
});
v
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("vivac-anchor-{name}-{}", crate::id::ulid()))
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn git_repo_with_one_commit(at: &Path) {
std::fs::create_dir_all(at).unwrap();
git(at, &["init", "-q"]);
git(at, &["config", "user.email", "t@example.com"]);
git(at, &["config", "user.name", "t"]);
std::fs::write(at.join("f.txt"), "x").unwrap();
git(at, &["add", "."]);
git(at, &["commit", "-q", "-m", "first"]);
}
#[test]
fn head_resolves_a_linked_worktrees_branch_through_commondir() {
let t = tmp("commondir-head");
let main = t.join("main");
let worktree = t.join("side");
git_repo_with_one_commit(&main);
git(
&main,
&["worktree", "add", worktree.to_str().unwrap(), "-b", "side"],
);
let g = Git::new(&worktree).expect("the worktree is a working tree");
assert!(
g.head().is_some(),
"a worktree on a branch has a HEAD like any other checkout"
);
}
#[test]
fn a_rebase_in_progress_names_the_branch_being_rebased() {
let t = tmp("rebase-merge");
git_repo_with_one_commit(&t);
let gitdir = t.join(".git");
std::fs::create_dir_all(gitdir.join("rebase-merge")).unwrap();
std::fs::write(
gitdir.join("rebase-merge").join("head-name"),
"refs/heads/side\n",
)
.unwrap();
let w = where_of(&t);
let Where::Head(h) = w else {
panic!("the repository is there")
};
assert_eq!(h.branch.as_deref(), Some("side"));
assert!(h.rebasing, "a rebase is under way");
}
#[test]
fn a_loose_reference_gives_its_branch_and_sha() {
let t = tmp("loose-ref");
git_repo_with_one_commit(&t);
git(&t, &["checkout", "-q", "-b", "develop"]);
let h = Git::new(&t).unwrap().where_now();
assert_eq!(h.branch.as_deref(), Some("develop"));
assert!(h.sha.as_deref().is_some_and(is_sha));
assert!(!h.rebasing);
}
#[test]
fn a_packed_reference_gives_its_branch_and_sha() {
let t = tmp("packed-ref");
git_repo_with_one_commit(&t);
git(&t, &["checkout", "-q", "-b", "develop"]);
git(&t, &["pack-refs", "--all"]);
std::fs::remove_file(t.join(".git").join("refs").join("heads").join("develop")).ok();
let h = Git::new(&t).unwrap().where_now();
assert_eq!(h.branch.as_deref(), Some("develop"));
assert!(h.sha.as_deref().is_some_and(is_sha));
assert!(!h.rebasing);
}
#[test]
fn a_detached_head_gives_a_sha_and_no_branch() {
let t = tmp("detached-head");
git_repo_with_one_commit(&t);
let sha = Git::new(&t)
.unwrap()
.head()
.expect("a fresh commit resolves");
git(&t, &["checkout", "-q", &sha]);
let h = Git::new(&t).unwrap().where_now();
assert_eq!(h.branch, None);
assert_eq!(h.sha.as_deref(), Some(sha.as_str()));
assert!(!h.rebasing);
}
#[test]
fn a_rebase_apply_names_the_branch_too() {
let t = tmp("rebase-apply");
git_repo_with_one_commit(&t);
let gitdir = t.join(".git");
std::fs::create_dir_all(gitdir.join("rebase-apply")).unwrap();
std::fs::write(
gitdir.join("rebase-apply").join("head-name"),
"refs/heads/side\n",
)
.unwrap();
let w = where_of(&t);
let Where::Head(h) = w else {
panic!("the repository is there")
};
assert_eq!(h.branch.as_deref(), Some("side"));
assert!(h.rebasing, "a rebase is under way");
}
#[test]
fn a_worktree_with_a_detached_head_gives_no_branch() {
let t = tmp("worktree-detached");
let main = t.join("main");
let worktree = t.join("side");
git_repo_with_one_commit(&main);
git(
&main,
&["worktree", "add", "--detach", worktree.to_str().unwrap()],
);
let h = Git::new(&worktree)
.expect("the worktree is a working tree")
.where_now();
assert_eq!(h.branch, None);
assert!(h.sha.as_deref().is_some_and(is_sha));
}
#[test]
fn a_submodule_is_not_a_worktree_and_answers_for_itself() {
let t = tmp("submodule");
let sub_gitdir = t.join("modules").join("sub");
std::fs::create_dir_all(sub_gitdir.join("refs").join("heads")).unwrap();
let sha = "c".repeat(40);
std::fs::write(
sub_gitdir.join("refs").join("heads").join("feature"),
format!("{sha}\n"),
)
.unwrap();
std::fs::write(sub_gitdir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
let sub_dir = t.join("sub");
std::fs::create_dir_all(&sub_dir).unwrap();
std::fs::write(
sub_dir.join(".git"),
format!("gitdir: {}\n", sub_gitdir.display()),
)
.unwrap();
let h = Git::new(&sub_dir)
.expect("the submodule is its own working tree")
.where_now();
assert_eq!(h.branch.as_deref(), Some("feature"));
assert_eq!(h.sha.as_deref(), Some(sha.as_str()));
}
#[test]
fn a_repository_that_is_gone_is_missing_not_unreadable() {
let t = tmp("gone");
assert_eq!(where_of(&t.join("nothing-here")), Where::Missing);
}
#[test]
fn an_unreadable_head_is_a_head_that_knows_neither_branch_nor_sha() {
let t = tmp("unreadable-head");
std::fs::create_dir_all(t.join(".git")).unwrap();
assert_eq!(
where_of(&t),
Where::Head(Head {
branch: None,
sha: None,
rebasing: false,
})
);
}
#[test]
fn null_invents_no_precision() {
let n = Null;
assert!(n.snapshot().is_empty_tree());
assert!(n.changed_since(&AnchorRef::default()).is_empty());
}
#[test]
fn head_is_read_without_spawning_git() {
let g = Git::new(Path::new(".")).expect("vivac/ is a git repo");
let s = g.snapshot();
assert_eq!(s.kind, "git");
assert!(is_sha(&s.id), "HEAD did not resolve: {:?}", s.id);
assert_eq!(s.short().len(), 7);
}
#[test]
fn an_anchor_from_another_world_gives_no_changes() {
let g = Git::new(Path::new(".")).unwrap();
let bogus = AnchorRef {
kind: "git".into(),
id: "0000000000000000000000000000000000000000".into(),
};
assert!(g
.changed_since(&bogus)
.iter()
.all(|c| !c.file_path.is_empty()));
}
#[test]
fn a_cached_location_still_reads_the_head_a_later_commit_left() {
let root = std::env::temp_dir().join(format!(
"vivac-anchor-live-{}-{}",
std::process::id(),
crate::id::ulid()
));
let git_dir = root.join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let first = "a".repeat(40);
std::fs::write(git_dir.join("HEAD"), &first).unwrap();
let before = detect(&root).snapshot();
assert_eq!(before.id, first, "the first read did not see the commit");
let second = "b".repeat(40);
std::fs::write(git_dir.join("HEAD"), &second).unwrap();
let after = detect(&root).snapshot();
assert_eq!(
after.id, second,
"the cached walk froze the commit instead of just the location"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn main_copy_of_refuses_a_bare_repository() {
let root = std::env::temp_dir().join(format!(
"vivac-anchor-bare-{}-{}",
std::process::id(),
crate::id::ulid()
));
let bare_repo = root.join("proj.git");
let worktree_dir = root.join("feature");
let gitdir = bare_repo.join("worktrees").join("feature");
std::fs::create_dir_all(&gitdir).unwrap();
std::fs::create_dir_all(&worktree_dir).unwrap();
std::fs::write(
worktree_dir.join(".git"),
format!("gitdir: {}\n", gitdir.display()),
)
.unwrap();
std::fs::write(gitdir.join("commondir"), "../..\n").unwrap();
assert!(main_copy_of(&worktree_dir).is_none());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn main_copy_of_resolves_an_absolute_common_directory() {
let root = std::env::temp_dir().join(format!(
"vivac-anchor-abscommon-{}-{}",
std::process::id(),
crate::id::ulid()
));
let main_dir = root.join("main");
let worktree_dir = root.join("feature");
let gitdir = main_dir.join(".git").join("worktrees").join("feature");
std::fs::create_dir_all(&gitdir).unwrap();
std::fs::create_dir_all(&worktree_dir).unwrap();
std::fs::write(
worktree_dir.join(".git"),
format!("gitdir: {}\n", gitdir.display()),
)
.unwrap();
let common = main_dir.join(".git");
std::fs::write(gitdir.join("commondir"), format!("{}\n", common.display())).unwrap();
assert_eq!(main_copy_of(&worktree_dir), Some(main_dir));
std::fs::remove_dir_all(&root).ok();
}
}