use crate::session::Session;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex, PoisonError};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Overlap {
Directory,
File,
}
#[derive(Debug, Clone)]
pub struct Collision {
pub level: Overlap,
pub peers: Vec<String>,
pub files: Vec<String>,
}
pub type Map = HashMap<String, Collision>;
pub fn apply(sessions: &mut [Session]) -> Map {
let map = detect(sessions);
for s in sessions.iter_mut().filter(|s| s.remote.is_none()) {
s.conflict = map.get(&s.key()).map(|c| c.level);
}
map
}
pub fn detect(sessions: &[Session]) -> Map {
let mut by_repo: HashMap<PathBuf, Vec<&Session>> = HashMap::new();
for s in sessions
.iter()
.filter(|s| s.is_running() && s.remote.is_none())
{
if s.label_source.is_empty() {
continue;
}
by_repo.entry(ground(&s.label_source)).or_default().push(s);
}
let mut out = Map::new();
for group in by_repo.into_values().filter(|g| g.len() > 1) {
for s in &group {
let mine: HashSet<&str> = s.recent_writes.iter().map(String::as_str).collect();
let mut sharing = Vec::new();
let mut neighbours = Vec::new();
let mut files: BTreeSet<&str> = BTreeSet::new();
let key = s.key();
for other in group.iter().filter(|o| o.key() != key) {
let common: Vec<&str> = other
.recent_writes
.iter()
.map(String::as_str)
.filter(|p| mine.contains(p))
.collect();
if common.is_empty() {
neighbours.push(other.key());
} else {
files.extend(common);
sharing.push(other.key());
}
}
let (level, peers) = match sharing.is_empty() {
false => (Overlap::File, sharing),
true => (Overlap::Directory, neighbours),
};
if peers.is_empty() {
continue;
}
out.insert(
key,
Collision {
level,
peers,
files: files.into_iter().map(str::to_string).collect(),
},
);
}
}
out
}
pub fn peers_of<'a>(
sessions: &'a [Session],
dir: &str,
files: &[String],
) -> Vec<(&'a Session, Vec<String>)> {
let here = ground(dir);
let wanted: HashSet<String> = files.iter().map(|f| normalise(f, dir)).collect();
sessions
.iter()
.filter(|s| s.is_running() && s.remote.is_none() && !s.label_source.is_empty())
.filter(|s| ground(&s.label_source) == here)
.map(|s| {
let shared = s
.recent_writes
.iter()
.filter(|w| wanted.contains(*w))
.cloned()
.collect();
(s, shared)
})
.collect()
}
const ROOT_TTL: Duration = Duration::from_secs(60);
static ROOTS: LazyLock<Mutex<HashMap<String, (PathBuf, Instant)>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn ground(dir: &str) -> PathBuf {
let mut cache = ROOTS.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((root, at)) = cache.get(dir)
&& at.elapsed() < ROOT_TTL
{
return root.clone();
}
let root = repo_root(Path::new(dir)).unwrap_or_else(|| PathBuf::from(dir));
cache.insert(dir.to_string(), (root.clone(), Instant::now()));
root
}
fn repo_root(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|dir| dir.join(".git").exists())
.map(Path::to_path_buf)
}
pub fn normalise(path: &str, cwd: &str) -> String {
use std::path::Component;
let path = path.trim();
let rooted = Path::new(path);
let joined = match rooted.is_absolute() || rooted.has_root() {
true => rooted.to_path_buf(),
false => Path::new(cwd).join(path),
};
let mut out = PathBuf::new();
let mut depth = 0usize;
for part in joined.components() {
match part {
Component::CurDir => {}
Component::ParentDir if depth > 0 => {
out.pop();
depth -= 1;
}
Component::ParentDir => out.push(".."),
other => {
out.push(other.as_os_str());
if matches!(other, Component::Normal(_)) {
depth += 1;
}
}
}
}
out.to_string_lossy().into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pricing::Provider;
struct Fixture(PathBuf);
impl Fixture {
fn new(name: &str) -> Fixture {
let root = std::env::temp_dir().join(format!(
"cctop-collide-{}-{name}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("scratch directory");
Fixture(root)
}
fn checkout(&self, rel: &str) -> String {
let dir = self.0.join(rel);
std::fs::create_dir_all(dir.join(".git")).expect("checkout");
dir.to_string_lossy().into_owned()
}
fn dir(&self, rel: &str) -> String {
let dir = self.0.join(rel);
std::fs::create_dir_all(&dir).expect("directory");
dir.to_string_lossy().into_owned()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
fn live(id: &str, dir: &str, writes: &[&str]) -> Session {
let mut s = Session::new(Provider::Claude, id.into());
s.label_source = dir.into();
s.process = Some(crate::proc::ProcInfo::default());
s.recent_writes = writes
.iter()
.map(|w| normalise(w, "/anywhere"))
.collect::<Vec<_>>();
s
}
#[test]
fn two_sessions_writing_one_file_collide() {
let fx = Fixture::new("same-file");
let repo = fx.checkout("repo");
let sub = fx.dir("repo/src");
let shared = format!("{repo}/src/ui.rs");
let a = live("a", &repo, &[&shared, &format!("{repo}/README.md")]);
let b = live("b", &sub, &[&shared]);
let map = detect(&[a.clone(), b.clone()]);
let hit = map.get(&a.key()).expect("a collides");
assert_eq!(hit.level, Overlap::File);
assert_eq!(hit.peers, vec![b.key()]);
assert_eq!(hit.files, vec![normalise(&shared, "/anywhere")]);
assert_eq!(map.get(&b.key()).expect("b collides").level, Overlap::File);
}
#[test]
fn sharing_a_repository_alone_is_the_lesser_overlap() {
let fx = Fixture::new("same-repo");
let repo = fx.checkout("repo");
let a = live("a", &repo, &[&format!("{repo}/a.rs")]);
let b = live("b", &repo, &[&format!("{repo}/b.rs")]);
let map = detect(&[a.clone(), b]);
assert_eq!(map[&a.key()].level, Overlap::Directory);
assert!(map[&a.key()].files.is_empty());
}
#[test]
fn a_stopped_session_races_nobody() {
let fx = Fixture::new("stopped");
let repo = fx.checkout("repo");
let file = format!("{repo}/x.rs");
let a = live("a", &repo, &[&file]);
let mut b = live("b", &repo, &[&file]);
b.process = None;
assert!(detect(&[a, b]).is_empty());
}
#[test]
fn a_shared_prefix_is_not_a_shared_repository() {
let fx = Fixture::new("prefix");
let one = fx.checkout("repo");
let two = fx.checkout("repo2");
let a = live("a", &one, &["x.rs"]);
let b = live("b", &two, &["x.rs"]);
assert!(detect(&[a, b]).is_empty());
}
#[test]
fn agents_in_separate_worktrees_do_not_collide() {
let fx = Fixture::new("worktree");
let main = fx.checkout("repo");
let wt = fx.dir("repo/.claude/worktrees/agent-1");
std::fs::write(Path::new(&wt).join(".git"), "gitdir: /elsewhere\n").unwrap();
let a = live("a", &main, &["src/ui.rs"]);
let b = live("b", &wt, &["src/ui.rs"]);
assert!(detect(&[a, b]).is_empty(), "worktrees are separate ground");
let c = live("c", &wt, &["src/ui.rs"]);
let d = live("d", &wt, &["src/ui.rs"]);
assert_eq!(detect(&[c, d]).len(), 2);
}
#[test]
fn paths_are_compared_in_one_spelling() {
let sep = std::path::MAIN_SEPARATOR;
assert_eq!(
normalise("src/ui.rs", "/repo"),
format!("{sep}repo{sep}src{sep}ui.rs")
);
assert_eq!(
normalise("/repo/src/ui.rs", "/other"),
format!("{sep}repo{sep}src{sep}ui.rs")
);
assert_eq!(
normalise("./src/../src/ui.rs", "/repo"),
format!("{sep}repo{sep}src{sep}ui.rs")
);
assert_eq!(normalise("../out", ""), format!("..{sep}out"));
}
}