use crate::store::Store;
use anyhow::Result;
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const GIT_TIMEOUT: Duration = Duration::from_secs(2);
const MAX_INVENTORY_BYTES: u64 = 1 << 20;
#[derive(Debug, PartialEq, Eq)]
struct Worktree {
path: PathBuf,
bare: bool,
locked: bool,
prunable: bool,
}
fn parse_worktrees(bytes: &[u8]) -> Vec<Worktree> {
let mut trees = Vec::new();
let mut current: Option<Worktree> = None;
for field in bytes.split(|byte| *byte == 0) {
if field.is_empty() {
if let Some(tree) = current.take() {
trees.push(tree);
}
continue;
}
if let Some(path) = field.strip_prefix(b"worktree ") {
if let Some(tree) = current.take() {
trees.push(tree);
}
#[cfg(unix)]
let path = {
use std::os::unix::ffi::OsStringExt;
Some(PathBuf::from(std::ffi::OsString::from_vec(path.to_vec())))
};
#[cfg(not(unix))]
let path = String::from_utf8(path.to_vec()).ok().map(PathBuf::from);
current = path.map(|path| Worktree {
path,
bare: false,
locked: false,
prunable: false,
});
} else if let Some(tree) = current.as_mut() {
tree.bare |= field == b"bare";
tree.locked |= field == b"locked" || field.starts_with(b"locked ");
tree.prunable |= field == b"prunable" || field.starts_with(b"prunable ");
}
}
if let Some(tree) = current {
trees.push(tree);
}
trees
}
fn inventory(workspace: &Path) -> Option<Vec<Worktree>> {
let mut child = Command::new("git")
.arg("-C")
.arg(workspace)
.args(["worktree", "list", "--porcelain", "-z"])
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_INDEX_FILE")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let stdout = child.stdout.take()?;
let reader = match std::thread::Builder::new()
.name("kache-git-worktrees".into())
.spawn(move || {
let mut bytes = Vec::new();
stdout
.take(MAX_INVENTORY_BYTES)
.read_to_end(&mut bytes)
.ok()?;
Some(bytes)
}) {
Ok(reader) => reader,
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
};
let deadline = Instant::now() + GIT_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => {
if deadline.checked_duration_since(Instant::now()).is_none() {
break None;
}
std::thread::sleep(Duration::from_millis(10));
}
Err(_) => break None,
}
};
if status.is_none() {
let _ = child.kill();
let _ = child.wait();
}
let bytes = reader.join().ok().flatten()?;
if !status?.success() {
return None;
}
if bytes.len() as u64 == MAX_INVENTORY_BYTES {
return None;
}
Some(
parse_worktrees(&bytes)
.into_iter()
.filter_map(|mut tree| {
tree.path = tree.path.canonicalize().ok()?;
Some(tree)
})
.collect(),
)
}
fn sibling_workspaces(seed: &Path, trees: &[Worktree]) -> Vec<PathBuf> {
let Some(source) = trees
.iter()
.filter(|tree| seed.starts_with(&tree.path))
.max_by_key(|tree| tree.path.components().count())
else {
return Vec::new();
};
let Ok(suffix) = seed.strip_prefix(&source.path) else {
return Vec::new();
};
trees
.iter()
.filter(|tree| !tree.bare && !tree.locked && !tree.prunable)
.map(|tree| tree.path.join(suffix))
.filter(|workspace| {
std::fs::symlink_metadata(workspace).is_ok_and(|meta| meta.file_type().is_dir())
&& workspace.join("Cargo.toml").is_file()
})
.collect()
}
pub(crate) fn discover(store: &Store) -> Result<usize> {
let tracked = store.tracked_target_roots(0)?;
let known: HashSet<_> = tracked.iter().map(|root| root.identity).collect();
let mut covered = HashSet::new();
let mut found = 0;
for root in tracked.iter().filter(|root| !root.discovered) {
let Ok(seed) = root.workspace_root.canonicalize() else {
continue;
};
if covered.contains(&seed) {
continue;
}
let Some(trees) = inventory(&seed) else {
continue;
};
for workspace in sibling_workspaces(&seed, &trees) {
covered.insert(workspace.clone());
let target = workspace.join("target");
if crate::machine::directory_identity(&target).is_some_and(|id| known.contains(&id)) {
continue;
}
found += usize::from(store.remember_discovered_target_root(&target, &workspace)?);
}
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
fn git(repo: &Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn cargo_target(workspace: &Path) -> PathBuf {
std::fs::create_dir_all(workspace).unwrap();
std::fs::write(workspace.join("Cargo.toml"), "[workspace]\n").unwrap();
let target = workspace.join("target");
std::fs::create_dir_all(target.join("debug")).unwrap();
std::fs::write(
target.join("CACHEDIR.TAG"),
"Signature: 8a477f597d28d172789f06886806bc55\n",
)
.unwrap();
target
}
#[test]
fn porcelain_parser_keeps_state_with_each_worktree() {
let trees = parse_worktrees(
b"worktree /repo\0HEAD abc\0\0worktree /other\0locked offline\0\0worktree /old\0prunable missing\0\0worktree /bare\0bare\0\0",
);
assert_eq!(trees.len(), 4);
assert_eq!(trees[0].path, Path::new("/repo"));
assert!(!trees[0].locked);
assert!(trees[1].locked);
assert!(trees[2].prunable);
assert!(trees[3].bare);
}
#[test]
fn maps_a_nested_workspace_only_into_available_worktrees() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first");
let second = dir.path().join("second");
let locked = dir.path().join("locked");
let no_manifest = dir.path().join("no-manifest");
for path in [&first, &second, &locked] {
std::fs::create_dir_all(path.join("rust")).unwrap();
std::fs::write(path.join("rust/Cargo.toml"), "").unwrap();
}
std::fs::create_dir_all(no_manifest.join("rust")).unwrap();
let trees = vec![
Worktree {
path: first.clone(),
bare: false,
locked: false,
prunable: false,
},
Worktree {
path: second.clone(),
bare: false,
locked: false,
prunable: false,
},
Worktree {
path: locked,
bare: false,
locked: true,
prunable: false,
},
Worktree {
path: no_manifest,
bare: false,
locked: false,
prunable: false,
},
];
assert_eq!(
sibling_workspaces(&first.join("rust"), &trees),
vec![first.join("rust"), second.join("rust")]
);
}
#[test]
fn daemon_finds_a_sibling_target_without_claiming_a_build_used_it() {
if Command::new("git")
.arg("--version")
.output()
.is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
{
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let sibling = dir.path().join("sibling");
let cache = dir.path().join("cache");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q"]);
git(
&repo,
&[
"-c",
"user.name=Kache Test",
"-c",
"user.email=kache@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"--allow-empty",
"-m",
"base",
],
);
git(
&repo,
&[
"worktree",
"add",
"-q",
"--detach",
sibling.to_str().unwrap(),
],
);
let original = cargo_target(&repo.join("rust"));
let found = cargo_target(&sibling.join("rust"));
let config = crate::test_support::test_config(cache);
let store = Store::open(&config).unwrap();
store
.remember_target_root(&original, &repo.join("rust"))
.unwrap();
assert_eq!(discover(&store).unwrap(), 1);
assert_eq!(discover(&store).unwrap(), 0);
let roots = store.tracked_target_roots(0).unwrap();
assert_eq!(roots.len(), 2);
let sibling_row = roots
.iter()
.find(|root| root.path == found.canonicalize().unwrap())
.unwrap();
assert!(sibling_row.discovered);
assert_eq!(
sibling_row.workspace_root,
sibling.join("rust").canonicalize().unwrap()
);
assert_eq!(sibling_row.rustc, None);
let old_identity = sibling_row.identity;
std::fs::rename(&found, sibling.join("old-target")).unwrap();
cargo_target(&sibling.join("rust"));
assert_eq!(discover(&store).unwrap(), 1);
let replaced = store
.tracked_target_roots(0)
.unwrap()
.into_iter()
.find(|root| root.path == found.canonicalize().unwrap())
.unwrap();
assert!(replaced.discovered);
assert_ne!(replaced.identity, old_identity);
store
.remember_target_root(&found, &sibling.join("rust"))
.unwrap();
let row = store
.tracked_target_roots(0)
.unwrap()
.into_iter()
.find(|root| root.path == found)
.unwrap();
assert_eq!(store.tracked_target_roots(0).unwrap().len(), 2);
assert!(
!row.discovered,
"a real build replaces discovery provenance"
);
store.forget_target_root(&found).unwrap();
std::fs::remove_dir_all(&found).unwrap();
std::fs::create_dir_all(found.join("debug")).unwrap();
assert_eq!(discover(&store).unwrap(), 0);
assert_eq!(store.tracked_target_roots(0).unwrap().len(), 1);
}
}