#![allow(dead_code)]
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
fn validate_repo_name(name: &str) -> Result<()> {
let mut parts = name.split('/');
let org = parts.next().unwrap_or("");
let repo = parts.next().unwrap_or("");
if parts.next().is_some() || org.is_empty() || repo.is_empty() {
return Err(anyhow!(
"Invalid repo name {name:?}. Expected 'org/repo' (exactly one slash)."
));
}
let valid = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
};
if !valid(org) || !valid(repo) {
return Err(anyhow!(
"Invalid repo name {name:?}. Letters/digits/dots/hyphens/underscores only."
));
}
Ok(())
}
pub type PostActivateHook = Arc<dyn Fn(&Path, &str) -> Result<()> + Send + Sync>;
pub type ActivationSummaryHook = Arc<dyn Fn(&Path, &str) -> Option<String> + Send + Sync>;
pub type PostActivateRevsHook = Arc<dyn Fn(&Path, &str, &[String]) -> Result<()> + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(untagged)]
pub enum RevsRequest {
Count(usize),
List(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct InventoryEntry {
cloned_at: String,
last_accessed: String,
#[serde(default)]
access_count: u64,
#[serde(default)]
stale: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_built_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_built_revs: Option<RevsRequest>,
}
pub use crate::server::manifest::WorkspaceKind;
#[derive(Clone)]
pub struct Workspace {
inner: Arc<WorkspaceInner>,
}
struct WorkspaceInner {
kind: WorkspaceKind,
workspace_dir: PathBuf,
stale_after_days: u32,
state: RwLock<WorkspaceState>,
post_activate: Option<PostActivateHook>,
activation_summary: Option<ActivationSummaryHook>,
post_activate_revs: Option<PostActivateRevsHook>,
}
#[derive(Debug, Default)]
struct WorkspaceState {
active_repo_name: Option<String>,
active_repo_path: Option<PathBuf>,
active_built_name: Option<String>,
}
impl Workspace {
pub fn open(
workspace_dir: PathBuf,
stale_after_days: u32,
post_activate: Option<PostActivateHook>,
) -> Result<Self> {
if !workspace_dir.is_dir() {
fs::create_dir_all(&workspace_dir).with_context(|| {
format!("failed to create workspace dir {}", workspace_dir.display())
})?;
}
let repos_dir = workspace_dir.join("repos");
if !repos_dir.is_dir() {
fs::create_dir_all(&repos_dir)
.with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
}
let ws = Self {
inner: Arc::new(WorkspaceInner {
kind: WorkspaceKind::Github,
workspace_dir,
stale_after_days,
state: RwLock::new(WorkspaceState::default()),
post_activate,
activation_summary: None,
post_activate_revs: None,
}),
};
ws.reconcile_inventory()?;
Ok(ws)
}
pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
if !root.is_dir() {
anyhow::bail!(
"local workspace root does not exist or is not a directory: {}",
root.display()
);
}
let canon_root = root
.canonicalize()
.with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
let inv_dir = canon_root.join(".mcp-workspace");
if !inv_dir.is_dir() {
fs::create_dir_all(&inv_dir).with_context(|| {
format!("failed to create local-workspace dir {}", inv_dir.display())
})?;
}
let mut state = WorkspaceState::default();
let synthetic_name = synthesize_local_name(&canon_root);
state.active_repo_name = Some(synthetic_name);
state.active_repo_path = Some(canon_root.clone());
Ok(Self {
inner: Arc::new(WorkspaceInner {
kind: WorkspaceKind::Local,
workspace_dir: canon_root,
stale_after_days: u32::MAX, state: RwLock::new(state),
post_activate,
activation_summary: None,
post_activate_revs: None,
}),
})
}
pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
match Arc::get_mut(&mut self.inner) {
Some(inner) => inner.activation_summary = Some(hook),
None => tracing::warn!(
"with_activation_summary called after the workspace was cloned; summary not attached"
),
}
self
}
pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
match Arc::get_mut(&mut self.inner) {
Some(inner) => inner.post_activate_revs = Some(hook),
None => tracing::warn!(
"with_post_activate_revs called after the workspace was cloned; revs hook not attached"
),
}
self
}
pub fn kind(&self) -> WorkspaceKind {
self.inner.kind
}
pub fn workspace_dir(&self) -> &Path {
&self.inner.workspace_dir
}
pub fn repos_dir(&self) -> PathBuf {
self.inner.workspace_dir.join("repos")
}
fn inventory_path(&self) -> PathBuf {
match self.inner.kind {
WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
WorkspaceKind::Local => self
.inner
.workspace_dir
.join(".mcp-workspace")
.join("inventory.json"),
}
}
pub fn active_repo_name(&self) -> Option<String> {
self.inner.state.read().unwrap().active_repo_name.clone()
}
pub fn active_repo_path(&self) -> Option<PathBuf> {
self.inner.state.read().unwrap().active_repo_path.clone()
}
pub fn default_github_repo(&self) -> Option<String> {
match self.inner.kind {
WorkspaceKind::Github => self.active_repo_name(),
WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
}
}
fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
let path = self.inventory_path();
let Ok(text) = fs::read_to_string(&path) else {
return BTreeMap::new();
};
serde_json::from_str(&text).unwrap_or_default()
}
fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
let path = self.inventory_path();
let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn reconcile_inventory(&self) -> Result<()> {
let mut inv = self.load_inventory();
let mut on_disk: Vec<String> = Vec::new();
if self.repos_dir().is_dir() {
for org_entry in fs::read_dir(self.repos_dir())? {
let Ok(org_entry) = org_entry else { continue };
if !org_entry.path().is_dir() {
continue;
}
let org = org_entry.file_name().to_string_lossy().into_owned();
if org.starts_with('.') {
continue;
}
for repo_entry in fs::read_dir(org_entry.path())? {
let Ok(repo_entry) = repo_entry else { continue };
if !repo_entry.path().is_dir() {
continue;
}
let repo = repo_entry.file_name().to_string_lossy().into_owned();
if repo.starts_with('.') {
continue;
}
let rname = format!("{org}/{repo}");
on_disk.push(rname.clone());
inv.entry(rname).or_insert_with(|| {
let mtime = repo_entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.map(format_iso)
.unwrap_or_else(now_iso);
InventoryEntry {
cloned_at: mtime.clone(),
last_accessed: mtime,
access_count: 0,
stale: false,
last_built_sha: None,
last_built_revs: None,
}
});
}
}
}
for (rname, entry) in inv.iter_mut() {
if !on_disk.contains(rname) && !entry.stale {
entry.stale = true;
}
}
self.save_inventory(&inv)?;
Ok(())
}
fn bump_access(&self, name: &str, action: &str) {
let mut inv = self.load_inventory();
let now = now_iso();
let entry = inv
.entry(name.to_string())
.or_insert_with(|| InventoryEntry {
cloned_at: now.clone(),
last_accessed: now.clone(),
access_count: 0,
stale: false,
last_built_sha: None,
last_built_revs: None,
});
entry.last_accessed = now.clone();
entry.access_count += 1;
entry.stale = false;
if action == "cloned" || entry.cloned_at.is_empty() {
entry.cloned_at = now;
}
let _ = self.save_inventory(&inv);
}
fn mark_stale(&self, name: &str) {
let mut inv = self.load_inventory();
if let Some(entry) = inv.get_mut(name) {
entry.stale = true;
let _ = self.save_inventory(&inv);
}
}
fn sweep_stale(&self) -> Vec<String> {
if matches!(self.inner.kind, WorkspaceKind::Local) {
return Vec::new();
}
let mut inv = self.load_inventory();
let cutoff = SystemTime::now()
- std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
let active = self.active_repo_name();
let mut swept: Vec<String> = Vec::new();
for (rname, entry) in inv.iter_mut() {
if entry.stale {
continue;
}
if Some(rname.as_str()) == active.as_deref() {
continue;
}
let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
if last >= cutoff {
continue;
}
let parts: Vec<&str> = rname.splitn(2, '/').collect();
if parts.len() != 2 {
continue;
}
let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
if repo_path.exists() {
let _ = fs::remove_dir_all(&repo_path);
}
entry.stale = true;
swept.push(rname.clone());
}
if !swept.is_empty() {
let _ = self.save_inventory(&inv);
self.prune_empty_org_dirs();
}
swept
}
fn prune_empty_org_dirs(&self) {
let Ok(entries) = fs::read_dir(self.repos_dir()) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Ok(children) = fs::read_dir(&path) {
let real: Vec<_> = children
.flatten()
.filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
.collect();
if real.is_empty() {
let _ = fs::remove_dir_all(&path);
}
}
}
}
fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
if matches!(self.inner.kind, WorkspaceKind::Local) {
let root = self
.inner
.state
.read()
.unwrap()
.active_repo_path
.clone()
.unwrap_or_else(|| self.inner.workspace_dir.clone());
let prev_sha = self.last_built_sha(name);
let fingerprint = fingerprint_dir(&root);
let action = match prev_sha {
Some(p) if p == fingerprint => "current",
None => "cloned", Some(_) => "updated",
};
return Ok((action.to_string(), root, fingerprint));
}
let parts: Vec<&str> = name.splitn(2, '/').collect();
let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
if !repo_path.exists() {
fs::create_dir_all(repo_path.parent().unwrap()).ok();
let url = format!("https://github.com/{name}.git");
let out = Command::new("git")
.args([
"clone",
"--filter=tree:0",
"--tags",
&url,
repo_path.to_str().unwrap(),
])
.output()
.context("failed to spawn `git clone`")?;
if !out.status.success() {
anyhow::bail!(
"git clone failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let sha = git_rev_parse(&repo_path, "HEAD")?;
return Ok(("cloned".to_string(), repo_path, sha));
}
Command::new("git")
.args(["fetch", "origin", "--tags"])
.current_dir(&repo_path)
.output()
.context("git fetch failed")?;
let local = git_rev_parse(&repo_path, "HEAD")?;
let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
if local != remote {
Command::new("git")
.args(["reset", "--hard", "FETCH_HEAD"])
.current_dir(&repo_path)
.output()
.context("git reset failed")?;
let sha = git_rev_parse(&repo_path, "HEAD")?;
return Ok(("updated".to_string(), repo_path, sha));
}
Ok(("current".to_string(), repo_path, local))
}
fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
let resolved = match req {
RevsRequest::Count(n) => {
let out = Command::new("git")
.args(["tag", "--sort=-v:refname"])
.current_dir(repo_path)
.output()
.context("failed to spawn `git tag`")?;
if !out.status.success() {
anyhow::bail!(
"cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
repo_path.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if tags.is_empty() {
anyhow::bail!(
"revs={n} requested but '{}' has no tags to resolve",
repo_path.display()
);
}
let mut chosen = select_family_tags(&tags, *n).unwrap_or_else(|| {
let mut raw: Vec<String> = tags.into_iter().take(*n).collect();
raw.reverse();
raw
});
chosen.push("HEAD".to_string());
chosen
}
RevsRequest::List(revs) => {
if revs.is_empty() {
anyhow::bail!("revs list is empty — pass at least one revision");
}
for r in revs {
let out = Command::new("git")
.args([
"rev-parse",
"--verify",
"--quiet",
&format!("{r}^{{commit}}"),
])
.current_dir(repo_path)
.output()
.context("failed to spawn `git rev-parse`")?;
if !out.status.success() {
anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
}
}
revs.clone()
}
};
Ok(dedup_labels(resolved))
}
fn activate(
&self,
name: &str,
force_rebuild: bool,
revs: Option<&RevsRequest>,
) -> Result<String> {
let prev_built_sha = self.last_built_sha(name);
let prev_built_revs = self.last_built_revs(name);
let (action, repo_path, head_sha) = self.clone_or_update(name)?;
let resolved_revs = match revs {
Some(req) => Some(self.resolve_revs(&repo_path, req)?),
None => None,
};
self.bump_access(name, &action);
let is_active_built = {
let mut state = self.inner.state.write().unwrap();
state.active_repo_name = Some(name.to_string());
state.active_repo_path = Some(repo_path.clone());
state.active_built_name.as_deref() == Some(name)
};
let already_built = !force_rebuild
&& resolved_revs.is_none()
&& prev_built_revs.is_none()
&& action == "current"
&& prev_built_sha.as_deref() == Some(head_sha.as_str())
&& is_active_built;
let mut hook_skipped = false;
let mut revs_hook_ran = false;
let hook_ok = if already_built {
hook_skipped = true;
true
} else if let (Some(resolved), Some(revs_hook)) =
(resolved_revs.as_ref(), &self.inner.post_activate_revs)
{
revs_hook_ran = true;
match revs_hook(&repo_path, name, resolved) {
Ok(()) => true,
Err(e) => {
tracing::warn!("post-activate revs hook for {name} failed: {e}");
false
}
}
} else if let Some(hook) = &self.inner.post_activate {
match hook(&repo_path, name) {
Ok(()) => true,
Err(e) => {
tracing::warn!("post-activate hook for {name} failed: {e}");
false
}
}
} else {
true
};
if hook_ok {
let built_revs = if revs_hook_ran { revs } else { None };
self.record_built(name, &head_sha, built_revs);
}
if hook_ok && !hook_skipped {
self.inner.state.write().unwrap().active_built_name = Some(name.to_string());
}
let verb = match action.as_str() {
"cloned" => "Cloned",
"updated" => "Updated",
"current" => "Activated (already up to date)",
other => other,
};
let suffix = if hook_skipped {
" [build skipped: HEAD matches last-built SHA]"
} else {
""
};
let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
if revs_hook_ran && hook_ok {
if let Some(resolved) = &resolved_revs {
base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
}
}
let summary = if hook_ok {
self.inner
.activation_summary
.as_ref()
.and_then(|h| h(&repo_path, name))
} else {
None
};
Ok(match summary {
Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
_ => base,
})
}
fn record_built(&self, name: &str, sha: &str, revs: Option<&RevsRequest>) {
let mut inv = self.load_inventory();
if let Some(entry) = inv.get_mut(name) {
entry.last_built_sha = Some(sha.to_string());
entry.last_built_revs = revs.cloned();
let _ = self.save_inventory(&inv);
}
}
pub fn last_built_sha(&self, name: &str) -> Option<String> {
self.load_inventory()
.get(name)
.and_then(|e| e.last_built_sha.clone())
}
pub fn last_built_revs(&self, name: &str) -> Option<RevsRequest> {
self.load_inventory()
.get(name)
.and_then(|e| e.last_built_revs.clone())
}
fn delete(&self, name: &str) -> Result<String> {
let parts: Vec<&str> = name.splitn(2, '/').collect();
if parts.len() != 2 {
anyhow::bail!("Invalid repo name");
}
let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
let mut deleted = Vec::new();
if repo_path.exists() {
fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
deleted.push("repo");
}
self.mark_stale(name);
self.prune_empty_org_dirs();
if deleted.is_empty() {
return Ok(format!("Nothing to delete — '{name}' not found."));
}
let mut state = self.inner.state.write().unwrap();
if state.active_repo_name.as_deref() == Some(name) {
state.active_repo_name = None;
state.active_repo_path = None;
return Ok(format!(
"Deleted {}. Active repo cleared.",
deleted.join(", ")
));
}
Ok(format!("Deleted {}.", deleted.join(", ")))
}
fn list(&self) -> String {
let inv = self.load_inventory();
if inv.is_empty() {
return "No repos cloned yet. Call repo_management('org/repo') to clone one."
.to_string();
}
let active = self.active_repo_name();
let mut live: Vec<String> = Vec::new();
let mut stale_lines: Vec<String> = Vec::new();
for (rname, entry) in &inv {
let marker = if Some(rname.as_str()) == active.as_deref() {
" [active]"
} else {
""
};
let access = format!(
"{} access{}, last {}",
entry.access_count,
if entry.access_count == 1 { "" } else { "es" },
relative_time(&entry.last_accessed)
);
if entry.stale {
stale_lines.push(format!(
" {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
));
} else {
live.push(format!(" {rname}{marker} ({access})"));
}
}
let mut out = String::new();
if !live.is_empty() {
out.push_str(&format!(
"{} live repo(s):\n{}",
live.len(),
live.join("\n")
));
}
if !stale_lines.is_empty() {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&format!(
"{} stale repo(s):\n{}",
stale_lines.len(),
stale_lines.join("\n")
));
}
out
}
pub fn repo_management(
&self,
name: Option<&str>,
delete: bool,
update: bool,
force_rebuild: bool,
revs: Option<&RevsRequest>,
) -> String {
if matches!(self.inner.kind, WorkspaceKind::Local) {
if name.is_some() {
return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
to switch the active root, or pass `update=true` / `force_rebuild=true` \
to rebuild against the current root."
.to_string();
}
if delete {
return "Local-workspace mode does not support `delete`. The root is owned by the \
operator; remove it manually."
.to_string();
}
let active = match self.active_repo_name() {
Some(n) => n,
None => return "No active local root.".to_string(),
};
let _ = update; let effective = match revs {
Some(r) => Some(r.clone()),
None => self.last_built_revs(&active),
};
return self
.activate(&active, force_rebuild, effective.as_ref())
.unwrap_or_else(|e| format!("rebuild failed: {e}"));
}
let swept = self.sweep_stale();
let prefix = if swept.is_empty() {
String::new()
} else {
format!(
"[Swept {} idle repo(s) (>{}d): {}]\n\n",
swept.len(),
self.inner.stale_after_days,
swept.join(", ")
)
};
if name.is_none() && !update {
return prefix + &self.list();
}
if update {
let Some(active) = self.active_repo_name() else {
return prefix + "No active repository. Call repo_management('org/repo') first.";
};
let effective = match revs {
Some(r) => Some(r.clone()),
None => self.last_built_revs(&active),
};
return prefix
+ &self
.activate(&active, force_rebuild, effective.as_ref())
.unwrap_or_else(|e| format!("update failed: {e}"));
}
let Some(name) = name else {
return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
};
if let Err(e) = validate_repo_name(name) {
return prefix + &e.to_string();
}
if delete {
return prefix
+ &self
.delete(name)
.unwrap_or_else(|e| format!("delete failed: {e}"));
}
prefix
+ &self
.activate(name, force_rebuild, revs)
.unwrap_or_else(|e| format!("activate failed: {e}"))
}
pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
if !matches!(self.inner.kind, WorkspaceKind::Local) {
return "set_root_dir is only valid in local-workspace mode.".to_string();
}
if !new_root.is_dir() {
return format!(
"Path does not exist or is not a directory: {}",
new_root.display()
);
}
let canon = match new_root.canonicalize() {
Ok(p) => p,
Err(e) => return format!("canonicalize failed: {e}"),
};
let synthetic = synthesize_local_name(&canon);
{
let mut state = self.inner.state.write().unwrap();
state.active_repo_name = Some(synthetic.clone());
state.active_repo_path = Some(canon.clone());
}
self.activate(&synthetic, false, revs)
.unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
}
}
fn dedup_labels(revs: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
revs.into_iter()
.filter(|r| seen.insert(r.clone()))
.collect()
}
const PRERELEASE_MARKERS: &[&str] = &["rc", "alpha", "beta", "dev", "pre", "preview"];
#[derive(Debug, Clone, PartialEq, Eq)]
struct ClassifiedTag {
raw: String,
prefix: String,
version: Vec<u64>,
is_prerelease: bool,
}
fn classify_tag(tag: &str) -> Option<ClassifiedTag> {
let bytes = tag.as_bytes();
for i in 0..bytes.len() {
if !bytes[i].is_ascii_digit() {
continue;
}
if i > 0 && bytes[i - 1].is_ascii_digit() {
continue;
}
if let Some((version, is_prerelease)) = parse_version_at(&tag[i..]) {
return Some(ClassifiedTag {
raw: tag.to_string(),
prefix: tag[..i].to_string(),
version,
is_prerelease,
});
}
}
None
}
fn parse_version_at(s: &str) -> Option<(Vec<u64>, bool)> {
let bytes = s.as_bytes();
let mut nums: Vec<u64> = Vec::new();
let mut idx = 0usize;
loop {
let start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == start {
return None; }
nums.push(s[start..idx].parse().ok()?);
if idx + 1 < bytes.len() && bytes[idx] == b'.' && bytes[idx + 1].is_ascii_digit() {
idx += 1;
continue;
}
break;
}
let rest = &s[idx..];
if rest.is_empty() {
return Some((nums, false));
}
let after_sep = rest
.strip_prefix(|c| c == '-' || c == '.' || c == '_')
.unwrap_or(rest);
let lower = after_sep.to_ascii_lowercase();
if PRERELEASE_MARKERS.iter().any(|m| lower.starts_with(m)) {
Some((nums, true))
} else {
None
}
}
fn select_family_tags(tags: &[String], n: usize) -> Option<Vec<String>> {
let classified: Vec<ClassifiedTag> = tags.iter().filter_map(|t| classify_tag(t)).collect();
if classified.is_empty() {
return None;
}
let mut families: BTreeMap<String, Vec<&ClassifiedTag>> = BTreeMap::new();
for c in &classified {
families.entry(c.prefix.clone()).or_default().push(c);
}
let stable_count = |v: &Vec<&ClassifiedTag>| v.iter().filter(|c| !c.is_prerelease).count();
let any_stable = families.values().any(|v| stable_count(v) > 0);
let chosen = families.values().max_by(|a, b| {
if any_stable {
stable_count(a).cmp(&stable_count(b))
} else {
a.len().cmp(&b.len())
}
})?;
let mut pool: Vec<&ClassifiedTag> = if any_stable {
chosen
.iter()
.copied()
.filter(|c| !c.is_prerelease)
.collect()
} else {
chosen.to_vec()
};
pool.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| b.raw.cmp(&a.raw)));
let mut newest: Vec<String> = pool.into_iter().take(n).map(|c| c.raw.clone()).collect();
newest.reverse(); Some(newest)
}
fn synthesize_local_name(root: &Path) -> String {
let name = root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "local".to_string());
format!("local/{name}")
}
fn parse_origin_repo(root: &Path) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8(out.stdout).ok()?;
parse_github_remote(url.trim())
}
fn parse_github_remote(url: &str) -> Option<String> {
let path = url
.strip_prefix("git@github.com:")
.or_else(|| url.strip_prefix("https://github.com/"))
.or_else(|| url.strip_prefix("http://github.com/"))
.or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
let path = path.strip_suffix(".git").unwrap_or(path);
let path = path.trim_end_matches('/');
let mut parts = path.split('/');
let org = parts.next().filter(|s| !s.is_empty())?;
let repo = parts.next().filter(|s| !s.is_empty())?;
if parts.next().is_some() {
return None;
}
Some(format!("{org}/{repo}"))
}
fn fingerprint_dir(root: &Path) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let walker = ignore::WalkBuilder::new(root)
.standard_filters(true)
.hidden(true)
.git_ignore(true)
.build();
for entry in walker.flatten() {
if !entry.path().is_file() {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
entry.path().to_string_lossy().hash(&mut hasher);
mtime.hash(&mut hasher);
meta.len().hash(&mut hasher);
}
format!("local-{:016x}", hasher.finish())
}
fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
let out = Command::new("git")
.args(["rev-parse", refspec])
.current_dir(repo_path)
.output()
.context("git rev-parse failed")?;
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn now_iso() -> String {
format_iso(SystemTime::now())
}
fn format_iso(t: SystemTime) -> String {
let secs = t
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
chrono_lite::format_secs(secs)
}
fn parse_iso(s: &str) -> Option<SystemTime> {
let secs = chrono_lite::parse_secs(s)?;
SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
}
fn relative_time(iso: &str) -> String {
let Some(t) = parse_iso(iso) else {
return "unknown".to_string();
};
let now = SystemTime::now();
let delta = now.duration_since(t).unwrap_or_default().as_secs();
if delta < 3600 {
"just now".to_string()
} else if delta < 86_400 {
format!("{}h ago", delta / 3600)
} else {
format!("{}d ago", delta / 86_400)
}
}
mod chrono_lite {
pub fn format_secs(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let time = secs % 86_400;
let (y, mo, d) = days_to_civil(days + 719_468);
let h = time / 3600;
let m = (time / 60) % 60;
let s = time % 60;
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
}
pub fn parse_secs(s: &str) -> Option<u64> {
let bytes = s.as_bytes();
if bytes.len() < 19 {
return None;
}
let y: i64 = s.get(0..4)?.parse().ok()?;
let mo: u32 = s.get(5..7)?.parse().ok()?;
let d: u32 = s.get(8..10)?.parse().ok()?;
let h: u64 = s.get(11..13)?.parse().ok()?;
let m: u64 = s.get(14..16)?.parse().ok()?;
let sc: u64 = s.get(17..19)?.parse().ok()?;
let days = civil_to_days(y, mo, d) - 719_468;
Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
}
fn days_to_civil(z: i64) -> (i64, u32, u32) {
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = (yoe as i64) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i64
}
}
#[allow(dead_code)]
fn _json_keepalive() {
let _ = json!({});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_repo_names() {
assert!(validate_repo_name("pydata/xarray").is_ok());
assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
assert!(validate_repo_name("xarray").is_err());
assert!(validate_repo_name("a/b/c").is_err());
assert!(validate_repo_name("foo/bar; rm -rf").is_err());
}
#[test]
fn open_creates_layout() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
assert!(ws.repos_dir().is_dir());
}
#[test]
fn empty_list() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
let out = ws.repo_management(None, false, false, false, None);
assert!(out.contains("No repos cloned yet"));
}
#[test]
fn invalid_repo_name_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
assert!(out.contains("Invalid repo name"));
}
#[test]
fn delete_unknown() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
let out = ws.repo_management(Some("nope/none"), true, false, false, None);
assert!(out.contains("Nothing to delete"));
}
#[test]
fn iso_round_trip() {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let s = chrono_lite::format_secs(now);
let back = chrono_lite::parse_secs(&s).unwrap();
assert_eq!(now, back);
}
#[test]
fn last_built_sha_round_trip() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
ws.bump_access("acme/widgets", "cloned");
assert_eq!(ws.last_built_sha("acme/widgets"), None);
ws.record_built("acme/widgets", "abc1234deadbeef", None);
assert_eq!(
ws.last_built_sha("acme/widgets").as_deref(),
Some("abc1234deadbeef")
);
let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
assert_eq!(
ws2.last_built_sha("acme/widgets").as_deref(),
Some("abc1234deadbeef")
);
}
#[test]
fn inventory_loads_legacy_entries_without_sha_field() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
let legacy = r#"{
"old/repo": {
"cloned_at": "2024-01-01T00:00:00",
"last_accessed": "2024-01-01T00:00:00",
"access_count": 5,
"stale": false
}
}"#;
std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
assert_eq!(ws2.last_built_sha("old/repo"), None);
let _ = ws;
}
#[test]
fn auto_rebuild_gate_skips_when_sha_matches() {
use std::sync::atomic::{AtomicUsize, Ordering};
let dir = tempfile::tempdir().unwrap();
let calls = Arc::new(AtomicUsize::new(0));
let calls_h = calls.clone();
let hook: PostActivateHook = Arc::new(move |_path, _name| {
calls_h.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
ws.bump_access("acme/widgets", "cloned");
ws.record_built("acme/widgets", "sha_one", None);
assert_eq!(
ws.last_built_sha("acme/widgets").as_deref(),
Some("sha_one")
);
ws.record_built("acme/widgets", "sha_one", None);
assert_eq!(
ws.last_built_sha("acme/widgets").as_deref(),
Some("sha_one")
);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[test]
fn local_workspace_binds_root_immediately() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
assert_eq!(ws.kind(), WorkspaceKind::Local);
assert!(ws.active_repo_path().is_some());
assert!(ws.active_repo_name().unwrap().starts_with("local/"));
}
#[test]
fn local_workspace_rejects_github_ops() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
assert!(out.contains("does not accept a repo name"));
let out = ws.repo_management(None, true, false, false, None);
assert!(out.contains("does not support `delete`"));
}
#[test]
fn local_workspace_update_rebuilds() {
use std::sync::atomic::{AtomicUsize, Ordering};
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
let calls = Arc::new(AtomicUsize::new(0));
let calls_h = calls.clone();
let hook: PostActivateHook = Arc::new(move |_p, _n| {
calls_h.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
let _ = ws.repo_management(None, false, true, false, None);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let out = ws.repo_management(None, false, true, false, None);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"auto-rebuild gate must skip"
);
assert!(out.contains("build skipped"));
}
#[test]
fn parses_github_remote_forms() {
assert_eq!(
parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
Some("kkollsga/kglite")
);
assert_eq!(
parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
Some("kkollsga/kglite")
);
assert_eq!(
parse_github_remote("https://github.com/acme/widget/").as_deref(),
Some("acme/widget")
);
assert_eq!(
parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
Some("acme/widget")
);
assert_eq!(
parse_github_remote("https://gitlab.com/acme/widget.git"),
None
);
assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
assert_eq!(parse_github_remote("not a url"), None);
}
#[test]
fn local_default_github_repo_uses_origin_remote() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.unwrap()
};
if !git(&["init"]).status.success() {
return;
}
git(&[
"remote",
"add",
"origin",
"https://github.com/acme/widget.git",
]);
let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
assert_eq!(
ws.default_github_repo().as_deref(),
Some("acme/widget"),
"local default repo must come from the origin remote, not the inventory key"
);
assert!(ws.active_repo_name().unwrap().starts_with("local/"));
}
#[test]
fn local_default_github_repo_none_without_remote() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
let def = ws.default_github_repo();
assert!(
def.is_none(),
"expected None for a non-git local root, got {def:?}"
);
}
#[test]
fn set_root_dir_only_in_local_mode() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
let out = ws.set_root_dir(dir.path(), None);
assert!(out.contains("only valid in local-workspace"));
}
#[test]
fn update_with_no_active_repo() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
let out = ws.repo_management(None, false, true, false, None);
assert!(out.contains("No active repository"));
}
#[test]
fn set_root_dir_updates_active_path() {
let dir = tempfile::tempdir().unwrap();
let child = dir.path().join("child");
std::fs::create_dir_all(&child).unwrap();
let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
let _ = ws.set_root_dir(&child, None);
assert_eq!(
ws.active_repo_path().unwrap(),
child.canonicalize().unwrap(),
"set_root_dir didn't update active_repo_path"
);
}
#[test]
fn set_root_dir_post_activate_fires_against_new_root() {
let dir = tempfile::tempdir().unwrap();
let child = dir.path().join("child");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(child.join("a.txt"), b"hi").unwrap();
let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
let seen = seen_path.clone();
let hook: PostActivateHook = Arc::new(move |p, _n| {
*seen.lock().unwrap() = Some(p.to_path_buf());
Ok(())
});
let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
let _ = ws.set_root_dir(&child, None);
assert_eq!(
seen_path.lock().unwrap().clone().unwrap(),
child.canonicalize().unwrap(),
"post_activate hook saw the wrong root after set_root_dir"
);
}
#[test]
fn activation_summary_appended_to_activate_message() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
let summary: ActivationSummaryHook =
Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
let ws = Workspace::open_local(dir.path().to_path_buf(), None)
.unwrap()
.with_activation_summary(summary);
let out = ws.repo_management(None, false, true, false, None);
assert!(
out.contains("Graph ready: 3 Functions."),
"activation message should include the summary; got: {out}"
);
}
#[test]
fn activation_summary_absent_when_not_configured() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
let out = ws.repo_management(None, false, true, false, None);
assert!(!out.contains("Graph ready"));
assert!(
out.contains(" at "),
"expected the terse default message; got: {out}"
);
}
#[test]
fn hook_fires_once_per_process_even_when_sha_matches() {
use std::sync::atomic::{AtomicUsize, Ordering};
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
let calls = Arc::new(AtomicUsize::new(0));
let make_hook = || -> PostActivateHook {
let c = calls.clone();
Arc::new(move |_p, _n| {
c.fetch_add(1, Ordering::SeqCst);
Ok(())
})
};
let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
let _ = ws.repo_management(None, false, true, false, None);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"first activate must hydrate"
);
let out = ws.repo_management(None, false, true, false, None);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"repeat activate in same process must skip the hook"
);
assert!(
out.contains("build skipped"),
"expected skip suffix, got: {out}"
);
drop(ws);
let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
assert!(
ws2.last_built_sha(&ws2.active_repo_name().unwrap())
.is_some(),
"sanity: last_built_sha should survive the restart"
);
let _ = ws2.repo_management(None, false, true, false, None);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"fresh process must re-fire the hook even when the SHA matches"
);
}
#[test]
fn a_b_a_swap_rebuilds_intervening_root() {
use std::sync::atomic::{AtomicUsize, Ordering};
let root = tempfile::tempdir().unwrap();
let a = root.path().join("projA");
let b = root.path().join("projB");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("a.txt"), b"alpha").unwrap();
std::fs::write(b.join("b.txt"), b"beta").unwrap();
let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
let built_h = built.clone();
let calls = Arc::new(AtomicUsize::new(0));
let calls_h = calls.clone();
let hook: PostActivateHook = Arc::new(move |p, _n| {
*built_h.lock().unwrap() = Some(p.to_path_buf());
calls_h.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
let _ = ws.set_root_dir(&a, None);
assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
assert_eq!(
built.lock().unwrap().clone(),
Some(a.canonicalize().unwrap())
);
let _ = ws.set_root_dir(&b, None);
assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
assert_eq!(
built.lock().unwrap().clone(),
Some(b.canonicalize().unwrap())
);
let out = ws.set_root_dir(&a, None);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"A→B→A must rebuild A; the intervening B overwrote the live slot"
);
assert!(
!out.contains("build skipped"),
"re-bind of a non-active root must not skip; got: {out}"
);
assert_eq!(
built.lock().unwrap().clone(),
Some(a.canonicalize().unwrap()),
"after A→B→A the live slot must hold A, not B"
);
let out = ws.set_root_dir(&a, None);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"re-binding the already-active root must skip the hook"
);
assert!(
out.contains("build skipped"),
"expected skip suffix, got: {out}"
);
}
fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&root)
.args(args)
.output()
.unwrap()
};
if !git(&["init"]).status.success() {
return None; }
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "Test"]);
git(&["config", "commit.gpgsign", "false"]);
for (i, tag) in tags.iter().enumerate() {
std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
git(&["add", "-A"]);
assert!(
git(&["commit", "-m", &format!("c{i}")]).status.success(),
"git commit failed"
);
assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
}
Some((dir, root))
}
#[test]
fn classify_tag_extracts_prefix_version_prerelease() {
let c = classify_tag("apache-arrow-25.0.0").unwrap();
assert_eq!(c.prefix, "apache-arrow-");
assert_eq!(c.version, vec![25, 0, 0]);
assert!(!c.is_prerelease);
for t in [
"apache-arrow-25.0.0.dev",
"apache-arrow-25.0.0-rc1",
"apache-arrow-25.0.0-RC0",
"v1.2.3-beta2",
"v1.2.3_alpha",
"v2.0.0-preview",
] {
assert!(
classify_tag(t).unwrap().is_prerelease,
"{t} should be prerelease"
);
}
assert_eq!(classify_tag("go/v18.0.0").unwrap().prefix, "go/v");
assert_eq!(classify_tag("r-15.0.1").unwrap().prefix, "r-");
assert_eq!(classify_tag("v1.2.3").unwrap().prefix, "v");
let c = classify_tag("arrow2-0.17.0").unwrap();
assert_eq!(c.prefix, "arrow2-");
assert_eq!(c.version, vec![0, 17, 0]);
}
#[test]
fn classify_tag_excludes_non_version_tags() {
assert_eq!(classify_tag("r-universe-release"), None);
assert_eq!(classify_tag("latest"), None);
assert_eq!(classify_tag("nightly"), None);
assert_eq!(classify_tag("v1.2.3-foobar"), None);
}
#[test]
fn select_family_tags_picks_dominant_release_family_skipping_prereleases() {
let tags: Vec<String> = [
"apache-arrow-22.0.0",
"apache-arrow-23.0.0",
"apache-arrow-24.0.0",
"apache-arrow-25.0.0-rc0",
"apache-arrow-25.0.0-rc1",
"apache-arrow-25.0.0.dev",
"go/v18.0.0",
"r-15.0.1",
"r-16.1.0",
"r-universe-release",
]
.iter()
.map(|s| s.to_string())
.collect();
let got = select_family_tags(&tags, 2).unwrap();
assert_eq!(got, vec!["apache-arrow-23.0.0", "apache-arrow-24.0.0"]);
}
#[test]
fn select_family_tags_fewer_stable_than_requested_uses_all_stable() {
let tags: Vec<String> = ["v1.0.0", "v2.0.0", "v3.0.0-rc1"]
.iter()
.map(|s| s.to_string())
.collect();
let got = select_family_tags(&tags, 5).unwrap();
assert_eq!(got, vec!["v1.0.0", "v2.0.0"]);
}
#[test]
fn select_family_tags_prerelease_only_family_falls_back_to_prereleases() {
let tags: Vec<String> = ["v1.0.0-rc1", "v1.0.0-rc2", "v0.9.0-beta"]
.iter()
.map(|s| s.to_string())
.collect();
let got = select_family_tags(&tags, 2).unwrap();
assert_eq!(got, vec!["v1.0.0-rc1", "v1.0.0-rc2"]);
}
#[test]
fn select_family_tags_no_version_like_tags_returns_none() {
let tags: Vec<String> = ["latest", "nightly", "stable"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(select_family_tags(&tags, 3), None);
}
#[test]
fn resolve_revs_count_falls_back_to_raw_when_no_version_tags() {
let Some((_d, root)) = git_repo_with_tags(&["latest", "nightly", "stable"]) else {
return;
};
let ws = Workspace::open_local(root.clone(), None).unwrap();
let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
assert_eq!(resolved.len(), 3);
assert_eq!(resolved.last().unwrap(), "HEAD");
assert!(resolved[..2].iter().all(|r| r != "HEAD"));
}
#[test]
fn resolve_revs_count_skips_prereleases_of_dominant_family() {
let Some((_d, root)) =
git_repo_with_tags(&["v1.0.0", "v2.0.0", "v3.0.0-rc1", "v3.0.0.dev"])
else {
return;
};
let ws = Workspace::open_local(root.clone(), None).unwrap();
let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
}
#[test]
fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
return;
};
let ws = Workspace::open_local(root.clone(), None).unwrap();
let resolved = ws
.resolve_revs(&root, &RevsRequest::Count(2))
.expect("resolve should succeed");
assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
}
#[test]
fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
return;
};
let ws = Workspace::open_local(root.clone(), None).unwrap();
let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
}
#[test]
fn resolve_revs_count_errors_when_no_tags() {
let Some((_d, root)) = git_repo_with_tags(&[]) else {
return;
};
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&root)
.args(args)
.output()
.unwrap()
};
std::fs::write(root.join("f.txt"), b"x").unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "c0"]);
let ws = Workspace::open_local(root.clone(), None).unwrap();
let err = ws
.resolve_revs(&root, &RevsRequest::Count(3))
.expect_err("no tags → error");
assert!(
err.to_string().contains("no tags"),
"expected a 'no tags' error, got: {err}"
);
}
#[test]
fn dedup_labels_is_order_preserving_first_wins() {
assert_eq!(
dedup_labels(vec!["HEAD".into(), "HEAD".into()]),
vec!["HEAD"]
);
assert_eq!(
dedup_labels(vec![
"v1".into(),
"v2".into(),
"v1".into(),
"v3".into(),
"v2".into(),
]),
vec!["v1", "v2", "v3"]
);
assert_eq!(dedup_labels(vec![]), Vec::<String>::new());
assert_eq!(dedup_labels(vec!["a".into(), "b".into()]), vec!["a", "b"]);
}
#[test]
fn resolve_revs_list_dedups_duplicate_revspecs() {
let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
return;
};
let ws = Workspace::open_local(root.clone(), None).unwrap();
let got = ws
.resolve_revs(
&root,
&RevsRequest::List(vec!["HEAD".into(), "HEAD".into()]),
)
.unwrap();
assert_eq!(got, vec!["HEAD"]);
let got = ws
.resolve_revs(
&root,
&RevsRequest::List(vec!["v1.0.0".into(), "HEAD".into(), "v1.0.0".into()]),
)
.unwrap();
assert_eq!(got, vec!["v1.0.0", "HEAD"]);
}
#[test]
fn resolve_revs_list_validates_and_rejects_unknown() {
let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
return;
};
let ws = Workspace::open_local(root.clone(), None).unwrap();
let ok = ws
.resolve_revs(
&root,
&RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
)
.unwrap();
assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
let err = ws
.resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
.expect_err("unknown rev → error");
assert!(
err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
"expected an unknown-rev error, got: {err}"
);
}
#[test]
fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
use std::sync::atomic::{AtomicUsize, Ordering};
let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
return;
};
let plain_calls = Arc::new(AtomicUsize::new(0));
let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
let pc = plain_calls.clone();
let plain: PostActivateHook = Arc::new(move |_p, _n| {
pc.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let sr = seen_revs.clone();
let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
*sr.lock().unwrap() = Some(revs.to_vec());
Ok(())
});
let ws = Workspace::open_local(root.clone(), Some(plain))
.unwrap()
.with_post_activate_revs(revs_hook);
let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
assert_eq!(
seen_revs.lock().unwrap().clone().unwrap(),
vec!["v1.1.0", "v2.0.0", "HEAD"]
);
assert_eq!(
plain_calls.load(Ordering::SeqCst),
0,
"plain hook must not fire when the revs-hook handled the request"
);
assert!(
out.contains("revs: v1.1.0, v2.0.0, HEAD"),
"activation message should list the resolved revs; got: {out}"
);
}
#[test]
fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
use std::sync::atomic::{AtomicUsize, Ordering};
let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
return;
};
let plain_calls = Arc::new(AtomicUsize::new(0));
let revs_seen = Arc::new(AtomicUsize::new(0));
let pc = plain_calls.clone();
let plain: PostActivateHook = Arc::new(move |_p, _n| {
pc.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let rs = revs_seen.clone();
let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
rs.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let ws = Workspace::open_local(root.clone(), Some(plain))
.unwrap()
.with_post_activate_revs(revs_hook);
let out = ws.repo_management(None, false, true, false, None);
assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
assert_eq!(
revs_seen.load(Ordering::SeqCst),
0,
"revs-hook must not fire when no revs were requested"
);
assert!(
!out.contains("revs:"),
"no revs line expected on a plain activation; got: {out}"
);
}
#[test]
fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
use std::sync::atomic::{AtomicUsize, Ordering};
let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
return;
};
let plain_calls = Arc::new(AtomicUsize::new(0));
let pc = plain_calls.clone();
let plain: PostActivateHook = Arc::new(move |_p, _n| {
pc.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
assert!(
!out.contains("revs:"),
"must not report a rev-set when only the plain hook ran; got: {out}"
);
}
#[allow(clippy::type_complexity)]
fn ws_with_both_hooks(
tags: &[&str],
) -> Option<(
Workspace,
tempfile::TempDir,
PathBuf,
Arc<std::sync::atomic::AtomicUsize>,
Arc<std::sync::atomic::AtomicUsize>,
)> {
use std::sync::atomic::{AtomicUsize, Ordering};
let (d, root) = git_repo_with_tags(tags)?;
let plain_calls = Arc::new(AtomicUsize::new(0));
let revs_calls = Arc::new(AtomicUsize::new(0));
let pc = plain_calls.clone();
let plain: PostActivateHook = Arc::new(move |_p, _n| {
pc.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let rc = revs_calls.clone();
let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _r| {
rc.fetch_add(1, Ordering::SeqCst);
Ok(())
});
let ws = Workspace::open_local(root.clone(), Some(plain))
.unwrap()
.with_post_activate_revs(revs_hook);
Some((ws, d, root, plain_calls, revs_calls))
}
#[test]
fn plain_activation_after_revs_build_rebuilds_plain() {
use std::sync::atomic::Ordering;
let Some((ws, _d, root, plain_calls, revs_calls)) =
ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
else {
return;
};
let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
assert_eq!(plain_calls.load(Ordering::SeqCst), 0);
let out = ws.set_root_dir(&root, None);
assert_eq!(
plain_calls.load(Ordering::SeqCst),
1,
"plain re-activation after a revs build must fire the plain hook"
);
assert!(
!out.contains("build skipped"),
"must not skip a plain re-activation after a revs build; got: {out}"
);
assert!(
!out.contains("revs:"),
"plain rebuild must not claim revs; got: {out}"
);
let out = ws.set_root_dir(&root, None);
assert_eq!(
plain_calls.load(Ordering::SeqCst),
1,
"second plain re-bind skips"
);
assert!(
out.contains("build skipped"),
"expected skip suffix; got: {out}"
);
}
#[test]
fn update_after_revs_build_reapplies_stored_revs() {
use std::sync::atomic::Ordering;
let Some((ws, _d, root, plain_calls, revs_calls)) =
ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
else {
return;
};
let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
let out = ws.repo_management(None, false, true, false, None);
assert_eq!(
revs_calls.load(Ordering::SeqCst),
2,
"bare update must re-apply the stored rev-set"
);
assert_eq!(
plain_calls.load(Ordering::SeqCst),
0,
"bare update after a revs build must not fall to the plain hook"
);
assert!(
out.contains("revs:"),
"re-applied update should list the revs; got: {out}"
);
}
#[test]
fn revs_activation_after_plain_build_always_rebuilds() {
use std::sync::atomic::Ordering;
let Some((ws, _d, root, plain_calls, revs_calls)) =
ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
else {
return;
};
let _ = ws.set_root_dir(&root, None);
assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
assert_eq!(revs_calls.load(Ordering::SeqCst), 0);
let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
assert_eq!(
revs_calls.load(Ordering::SeqCst),
1,
"a revs request must always rebuild, even at an unchanged HEAD"
);
}
#[test]
fn last_built_revs_round_trips_and_clears_on_plain_build() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
ws.bump_access("acme/widgets", "cloned");
assert_eq!(ws.last_built_revs("acme/widgets"), None);
ws.record_built("acme/widgets", "sha1", Some(&RevsRequest::Count(3)));
assert_eq!(
ws.last_built_revs("acme/widgets"),
Some(RevsRequest::Count(3))
);
let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
assert_eq!(
ws2.last_built_revs("acme/widgets"),
Some(RevsRequest::Count(3))
);
ws2.record_built("acme/widgets", "sha2", None);
assert_eq!(ws2.last_built_revs("acme/widgets"), None);
ws2.record_built(
"acme/widgets",
"sha3",
Some(&RevsRequest::List(vec!["v1".into(), "v2".into()])),
);
assert_eq!(
ws2.last_built_revs("acme/widgets"),
Some(RevsRequest::List(vec!["v1".into(), "v2".into()]))
);
}
#[test]
fn inventory_loads_legacy_entries_without_revs_field() {
let dir = tempfile::tempdir().unwrap();
let legacy = r#"{
"old/repo": {
"cloned_at": "2024-01-01T00:00:00",
"last_accessed": "2024-01-01T00:00:00",
"access_count": 5,
"stale": false,
"last_built_sha": "deadbeef"
}
}"#;
std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
assert_eq!(ws.last_built_sha("old/repo").as_deref(), Some("deadbeef"));
assert_eq!(ws.last_built_revs("old/repo"), None);
}
}