use crate::session::Session;
use std::collections::{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 live: Vec<&Session> = sessions
.iter()
.filter(|s| s.is_running() && s.remote.is_none() && !s.label_source.is_empty())
.collect();
let mut out = Map::new();
let mut keys: Vec<Option<String>> = vec![None; live.len()];
let mut keys_repeat: Option<bool> = None;
for (i, s) in live.iter().enumerate() {
let mine: HashSet<&str> = s
.recent_writes
.iter()
.map(String::as_str)
.filter(|p| contested(p))
.collect();
if mine.is_empty() {
continue;
}
for (j, other) in live.iter().enumerate().skip(i + 1) {
let common: Vec<&str> = other
.recent_writes
.iter()
.map(String::as_str)
.filter(|p| mine.contains(p))
.collect();
if common.is_empty() {
continue;
}
let repeat = *keys_repeat.get_or_insert_with(|| keys_repeat_in(&live));
for (a, b) in [(i, j), (j, i)] {
derive_key(&mut keys, a, live[a]);
derive_key(&mut keys, b, live[b]);
let (key, peer) = (key_of(&keys, a), key_of(&keys, b));
let entry = slot(&mut out, key, Overlap::File);
if !repeat || !entry.peers.iter().any(|p| p == peer) {
entry.peers.push(peer.to_string());
}
entry.files.extend(common.iter().map(|p| p.to_string()));
}
}
}
let mut by_repo: HashMap<PathBuf, Vec<usize>> = HashMap::new();
for (i, s) in live.iter().enumerate() {
by_repo.entry(ground(&s.label_source)).or_default().push(i);
}
for group in by_repo.into_values().filter(|g| g.len() > 1) {
for &i in &group {
derive_key(&mut keys, i, live[i]);
}
for &i in &group {
let key = key_of(&keys, i);
for &j in &group {
let other = key_of(&keys, j);
if other == key {
continue;
}
let entry = slot(&mut out, key, Overlap::Directory);
if entry.level == Overlap::Directory && !entry.peers.iter().any(|p| p == other) {
entry.peers.push(other.to_string());
}
}
}
}
for c in out.values_mut() {
c.files.sort();
c.files.dedup();
}
out
}
fn keys_repeat_in(live: &[&Session]) -> bool {
let mut seen: HashSet<(&crate::pricing::Provider, &str)> = HashSet::new();
live.iter()
.any(|s| !seen.insert((&s.provider, s.session_id.as_str())))
}
fn derive_key(keys: &mut [Option<String>], i: usize, s: &Session) {
if keys[i].is_none() {
keys[i] = Some(s.key());
}
}
fn key_of(keys: &[Option<String>], i: usize) -> &str {
keys[i].as_deref().expect("key derived before it was read")
}
fn slot<'a>(out: &'a mut Map, key: &str, level: Overlap) -> &'a mut Collision {
if out.contains_key(key) {
return out.get_mut(key).expect("row present a moment ago");
}
out.entry(key.to_string()).or_insert(Collision {
level,
peers: Vec::new(),
files: Vec::new(),
})
}
const UNCONTESTED_EXT: [&str; 9] = [
"md", "markdown", "rst", "adoc", "org", "txt", "lock", "sum", "log", ];
const UNCONTESTED_NAME: [&str; 3] = ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml"];
pub fn contested(path: &str) -> bool {
let path = Path::new(path);
let matches = |part: Option<&std::ffi::OsStr>, list: &[&str]| {
part.and_then(|p| p.to_str())
.is_some_and(|p| list.contains(&p.to_ascii_lowercase().as_str()))
};
!matches(path.file_name(), &UNCONTESTED_NAME) && !matches(path.extension(), &UNCONTESTED_EXT)
}
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())
.map(|s| {
let shared: Vec<String> = s
.recent_writes
.iter()
.filter(|w| wanted.contains(*w) && contested(w))
.cloned()
.collect();
(s, shared)
})
.filter(|(s, shared)| !shared.is_empty() || ground(&s.label_source) == here)
.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 fresh = {
let cache = ROOTS.lock().unwrap_or_else(PoisonError::into_inner);
match cache.get(dir) {
Some((root, at)) if at.elapsed() < ROOT_TTL => Some(root.clone()),
_ => None,
}
};
if let Some(root) = fresh {
return root;
}
let root = repo_root(Path::new(dir)).unwrap_or_else(|| PathBuf::from(dir));
let mut cache = ROOTS.lock().unwrap_or_else(PoisonError::into_inner);
let answered = cache.get(dir).filter(|(_, at)| at.elapsed() < ROOT_TTL);
match answered {
Some((cached, _)) => cached.clone(),
None => {
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() {
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 a_session_listed_twice_is_told_about_its_peers_once() {
let fx = Fixture::new("twice");
let repo = fx.checkout("repo");
let shared = format!("{repo}/src/ui.rs");
let a = live("a", &repo, &[&shared]);
let b = live("b", &repo, &[&shared]);
let hit = &detect(&[a.clone(), a.clone(), b.clone()])[&a.key()];
assert_eq!(hit.level, Overlap::File);
assert_eq!(
hit.peers,
vec![a.key(), b.key()],
"one row, one mention each"
);
}
#[test]
fn a_session_listed_twice_is_not_its_own_neighbour() {
let fx = Fixture::new("twice-quiet");
let repo = fx.checkout("repo");
let a = live("a", &repo, &[&format!("{repo}/README.md")]);
assert!(detect(&[a.clone(), a.clone()]).is_empty());
}
#[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 two_agents_in_one_notes_file_are_not_a_collision() {
let fx = Fixture::new("prose");
let repo = fx.checkout("repo");
let notes = format!("{repo}/memory/MEMORY.md");
let a = live("a", &repo, &[¬es, &format!("{repo}/TODO.txt")]);
let b = live("b", &repo, &[¬es, &format!("{repo}/TODO.txt")]);
let map = detect(&[a.clone(), b.clone()]);
assert_eq!(map[&a.key()].level, Overlap::Directory);
assert!(map[&a.key()].files.is_empty());
}
#[test]
fn a_lock_file_is_not_somebody_s_work() {
let fx = Fixture::new("locks");
let repo = fx.checkout("repo");
let locks: Vec<String> = [
"Cargo.lock",
"package-lock.json",
"pnpm-lock.yaml",
"go.sum",
"build.log",
]
.iter()
.map(|f| format!("{repo}/{f}"))
.collect();
let borrowed: Vec<&str> = locks.iter().map(String::as_str).collect();
let a = live("a", &repo, &borrowed);
let b = live("b", &repo, &borrowed);
assert_eq!(detect(&[a.clone(), b])[&a.key()].level, Overlap::Directory);
}
#[test]
fn a_lock_file_s_extension_does_not_exempt_its_neighbours() {
assert!(!contested("/repo/pnpm-lock.yaml"));
assert!(contested("/repo/package.json"));
assert!(contested("/repo/docker-compose.yaml"));
assert!(contested("/repo/Cargo.toml"));
assert!(contested("/repo/.env"));
assert!(contested("/repo/data.csv"));
}
#[test]
fn prose_alongside_code_still_reports_the_code() {
let fx = Fixture::new("prose-and-code");
let repo = fx.checkout("repo");
let code = format!("{repo}/src/ui.rs");
let notes = format!("{repo}/NOTES.md");
let a = live("a", &repo, &[&code, ¬es]);
let b = live("b", &repo, &[&code, ¬es]);
let hit = &detect(&[a.clone(), b])[&a.key()];
assert_eq!(hit.level, Overlap::File);
assert_eq!(hit.files, vec![normalise(&code, "/anywhere")]);
}
#[test]
fn the_agent_facing_answer_makes_the_same_exemption() {
let fx = Fixture::new("prose-peers");
let repo = fx.checkout("repo");
let notes = format!("{repo}/MEMORY.md");
let code = format!("{repo}/src/ui.rs");
let sessions = [live("a", &repo, &[¬es, &code])];
let asked = [notes.clone(), code.clone()];
let peers = peers_of(&sessions, &repo, &asked);
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].1, vec![normalise(&code, "/anywhere")]);
}
#[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, &[&format!("{one}/x.rs")]);
let b = live("b", &two, &[&format!("{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, &[&format!("{main}/src/ui.rs")]);
let b = live("b", &wt, &[&format!("{wt}/src/ui.rs")]);
assert!(detect(&[a, b]).is_empty(), "worktrees are separate ground");
let shared = format!("{wt}/src/ui.rs");
let c = live("c", &wt, &[&shared]);
let d = live("d", &wt, &[&shared]);
assert_eq!(detect(&[c, d]).len(), 2);
}
#[test]
fn a_shared_file_collides_across_grounds() {
let fx = Fixture::new("cross-ground");
let outer = fx.checkout("outer");
let inner = fx.checkout("outer/nested");
let shared = format!("{inner}/src/x.rs");
let a = live("a", &outer, &[&shared]);
let b = live("b", &inner, &[&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[&b.key()].level, Overlap::File);
}
#[test]
fn the_agent_facing_answer_reaches_across_grounds() {
let fx = Fixture::new("peers-cross");
let outer = fx.checkout("outer");
let inner = fx.checkout("outer/nested");
let shared = format!("{inner}/src/x.rs");
let sessions = [live("a", &inner, &[&shared])];
let peers = peers_of(&sessions, &outer, std::slice::from_ref(&shared));
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].1, vec![normalise(&shared, "/anywhere")]);
}
#[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"));
}
}