use std::path::{Path, PathBuf};
use ahash::AHashMap;
use serde::{Deserialize, Serialize};
use crate::git::{self, scope_key};
pub type WorkspaceKey = String;
pub type RepoId = String;
const REGISTRY_SCHEMA_VER: u16 = 1;
const SNAPSHOT_FILE: &str = "registry.msgpack";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceKind {
Git,
Plain,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceRecord {
pub key: WorkspaceKey,
pub kind: WorkspaceKind,
pub root: PathBuf,
pub repo_id: Option<RepoId>,
pub main_worktree: Option<PathBuf>,
pub last_seen: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepoRecord {
pub repo_id: RepoId,
pub main_root: PathBuf,
pub remote: Option<String>,
pub worktrees: Vec<String>,
pub deps: Vec<RepoId>,
pub last_seen: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorktreeRecord {
pub repo_id: RepoId,
pub name: String,
pub path: PathBuf,
pub head_sha: Option<String>,
pub branch: Option<String>,
pub detached: bool,
pub claimed_by: Option<String>,
pub last_seen: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchRecord {
pub repo_id: RepoId,
pub name: String,
pub head_sha: String,
pub last_seen: i64,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct RegistrySnapshot {
schema_ver: u16,
workspaces: Vec<WorkspaceRecord>,
repos: Vec<RepoRecord>,
worktrees: Vec<WorktreeRecord>,
branches: Vec<BranchRecord>,
}
#[derive(Debug, thiserror::Error)]
pub enum RegistryError {
#[error("registry io at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("registry encode: {0}")]
Encode(#[from] rmp_serde::encode::Error),
#[error("registry git enumeration: {0}")]
Git(#[from] git::GitError),
}
pub struct Registry {
dir: PathBuf,
workspaces: AHashMap<WorkspaceKey, WorkspaceRecord>,
repos: AHashMap<RepoId, RepoRecord>,
worktrees: AHashMap<String, WorktreeRecord>,
branches: AHashMap<String, BranchRecord>,
}
fn composite_key(repo_id: &str, name: &str) -> String {
format!("{repo_id}\u{0}{name}")
}
fn now_micros() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
impl Registry {
pub fn open(registry_dir: &Path) -> Result<Self, RegistryError> {
std::fs::create_dir_all(registry_dir).map_err(|source| RegistryError::Io {
path: registry_dir.to_path_buf(),
source,
})?;
let snapshot = load_snapshot(®istry_dir.join(SNAPSHOT_FILE))?;
let mut registry = Self {
dir: registry_dir.to_path_buf(),
workspaces: AHashMap::new(),
repos: AHashMap::new(),
worktrees: AHashMap::new(),
branches: AHashMap::new(),
};
for record in snapshot.workspaces {
registry.workspaces.insert(record.key.clone(), record);
}
for record in snapshot.repos {
registry.repos.insert(record.repo_id.clone(), record);
}
for record in snapshot.worktrees {
registry
.worktrees
.insert(composite_key(&record.repo_id, &record.name), record);
}
for record in snapshot.branches {
registry
.branches
.insert(composite_key(&record.repo_id, &record.name), record);
}
Ok(registry)
}
pub fn from_data_home() -> Result<Self, RegistryError> {
Self::open(&crate::store::cache_root().join("registry"))
}
pub fn register_workspace(&mut self, root: &Path) -> Result<WorkspaceKey, RegistryError> {
let key = crate::store::workspace_key(root);
let now = now_micros();
let record = match git::Repo::discover(root) {
Ok(repository) => {
let repo_id = scope_key(&repository);
self.populate_git(&repository, &repo_id, now)?;
WorkspaceRecord {
key: key.clone(),
kind: WorkspaceKind::Git,
root: root.to_path_buf(),
repo_id: Some(repo_id),
main_worktree: Some(repository.main_worktree_root()),
last_seen: now,
}
}
Err(_) => WorkspaceRecord {
key: key.clone(),
kind: WorkspaceKind::Plain,
root: root.to_path_buf(),
repo_id: None,
main_worktree: None,
last_seen: now,
},
};
self.workspaces.insert(key.clone(), record);
self.persist()?;
Ok(key)
}
pub fn refresh_from_root(&mut self, root: &Path) -> Result<(), RegistryError> {
let repository = match git::Repo::discover(root) {
Ok(repository) => repository,
Err(_) => return Ok(()),
};
let repo_id = scope_key(&repository);
self.populate_git(&repository, &repo_id, now_micros())?;
self.persist()
}
pub fn refresh_repo(&mut self, repo_id: &RepoId) -> Result<(), RegistryError> {
let main_root = match self.repos.get(repo_id) {
Some(repo) => repo.main_root.clone(),
None => return Ok(()),
};
self.refresh_from_root(&main_root)
}
fn populate_git(&mut self, repository: &git::Repo, repo_id: &RepoId, now: i64) -> Result<(), RegistryError> {
let worktrees = repository.list_worktrees()?;
let branches = repository.list_local_branches()?;
let seen_worktrees: ahash::AHashSet<String> = worktrees.iter().map(|w| w.name.clone()).collect();
let seen_branches: ahash::AHashSet<String> = branches.iter().map(|b| b.name.clone()).collect();
self.worktrees
.retain(|_, record| record.repo_id != *repo_id || seen_worktrees.contains(&record.name));
self.branches
.retain(|_, record| record.repo_id != *repo_id || seen_branches.contains(&record.name));
for worktree in &worktrees {
let composite = composite_key(repo_id, &worktree.name);
let claimed_by = self.worktrees.get(&composite).and_then(|r| r.claimed_by.clone());
self.worktrees.insert(
composite,
WorktreeRecord {
repo_id: repo_id.clone(),
name: worktree.name.clone(),
path: worktree.path.clone(),
head_sha: worktree.head_sha.clone(),
branch: worktree.branch.clone(),
detached: worktree.detached,
claimed_by,
last_seen: now,
},
);
}
for branch in &branches {
self.branches.insert(
composite_key(repo_id, &branch.name),
BranchRecord {
repo_id: repo_id.clone(),
name: branch.name.clone(),
head_sha: branch.head_sha.clone(),
last_seen: now,
},
);
}
let existing_deps = self.repos.get(repo_id).map(|r| r.deps.clone()).unwrap_or_default();
self.repos.insert(
repo_id.clone(),
RepoRecord {
repo_id: repo_id.clone(),
main_root: repository.main_worktree_root(),
remote: repository.remote_url(),
worktrees: worktrees.iter().map(|w| w.name.clone()).collect(),
deps: existing_deps,
last_seen: now,
},
);
Ok(())
}
pub fn workspaces(&self) -> Vec<WorkspaceRecord> {
sorted_by(self.workspaces.values().cloned().collect(), |r| r.key.clone())
}
pub fn repos(&self) -> Vec<RepoRecord> {
sorted_by(self.repos.values().cloned().collect(), |r| r.repo_id.clone())
}
pub fn worktrees(&self, repo_id: &RepoId) -> Vec<WorktreeRecord> {
let rows = self
.worktrees
.values()
.filter(|r| r.repo_id == *repo_id)
.cloned()
.collect();
sorted_by(rows, |r| r.name.clone())
}
pub fn branches(&self, repo_id: &RepoId) -> Vec<BranchRecord> {
let rows = self
.branches
.values()
.filter(|r| r.repo_id == *repo_id)
.cloned()
.collect();
sorted_by(rows, |r| r.name.clone())
}
pub fn get_workspace(&self, key: &str) -> Option<WorkspaceRecord> {
self.workspaces.get(key).cloned()
}
pub fn get_repo(&self, repo_id: &str) -> Option<RepoRecord> {
self.repos.get(repo_id).cloned()
}
pub fn claim_worktree(&mut self, repo_id: &RepoId, name: &str, claimant: &str) -> Result<bool, RegistryError> {
let composite = composite_key(repo_id, name);
let record = match self.worktrees.get_mut(&composite) {
Some(record) => record,
None => return Ok(false),
};
match &record.claimed_by {
Some(holder) if holder != claimant => Ok(false),
_ => {
record.claimed_by = Some(claimant.to_string());
self.persist()?;
Ok(true)
}
}
}
pub fn release_worktree(&mut self, repo_id: &RepoId, name: &str, claimant: &str) -> Result<bool, RegistryError> {
let composite = composite_key(repo_id, name);
let record = match self.worktrees.get_mut(&composite) {
Some(record) => record,
None => return Ok(false),
};
if record.claimed_by.as_deref() == Some(claimant) {
record.claimed_by = None;
self.persist()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn prune_missing(&mut self) -> Result<usize, RegistryError> {
let before = self.total_rows();
self.worktrees.retain(|_, r| r.path.exists());
self.workspaces.retain(|_, r| r.root.exists());
self.repos.retain(|_, r| r.main_root.exists());
let live_repos: ahash::AHashSet<RepoId> = self.repos.keys().cloned().collect();
self.branches.retain(|_, r| live_repos.contains(&r.repo_id));
let removed = before - self.total_rows();
if removed > 0 {
self.persist()?;
}
Ok(removed)
}
fn total_rows(&self) -> usize {
self.workspaces.len() + self.repos.len() + self.worktrees.len() + self.branches.len()
}
fn persist(&self) -> Result<(), RegistryError> {
let snapshot = RegistrySnapshot {
schema_ver: REGISTRY_SCHEMA_VER,
workspaces: self.workspaces(),
repos: self.repos(),
worktrees: sorted_by(self.worktrees.values().cloned().collect(), |r| {
composite_key(&r.repo_id, &r.name)
}),
branches: sorted_by(self.branches.values().cloned().collect(), |r| {
composite_key(&r.repo_id, &r.name)
}),
};
let bytes = rmp_serde::to_vec_named(&snapshot)?;
let target = self.dir.join(SNAPSHOT_FILE);
let tmp = self.dir.join(format!("{SNAPSHOT_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, &bytes).map_err(|source| RegistryError::Io {
path: tmp.clone(),
source,
})?;
std::fs::rename(&tmp, &target).map_err(|source| RegistryError::Io { path: target, source })
}
}
fn sorted_by<T, K: Ord>(mut rows: Vec<T>, key: impl Fn(&T) -> K) -> Vec<T> {
rows.sort_by_key(|row| key(row));
rows
}
fn load_snapshot(path: &Path) -> Result<RegistrySnapshot, RegistryError> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(RegistrySnapshot::default()),
Err(source) => {
return Err(RegistryError::Io {
path: path.to_path_buf(),
source,
});
}
};
match rmp_serde::from_slice::<RegistrySnapshot>(&bytes) {
Ok(snapshot) if snapshot.schema_ver == REGISTRY_SCHEMA_VER => Ok(snapshot),
_ => Ok(RegistrySnapshot::default()),
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;