1#![allow(dead_code)]
27
28use std::collections::BTreeMap;
29use std::fmt;
30use std::fs;
31use std::path::{Path, PathBuf};
32use std::process::Command;
33use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard};
34use std::time::SystemTime;
35
36use anyhow::{anyhow, Context, Result};
37use serde::{Deserialize, Serialize};
38use serde_json::json;
39
40fn validate_repo_name(name: &str) -> Result<()> {
42 let mut parts = name.split('/');
43 let org = parts.next().unwrap_or("");
44 let repo = parts.next().unwrap_or("");
45 if parts.next().is_some() || org.is_empty() || repo.is_empty() {
46 return Err(anyhow!(
47 "Invalid repo name {name:?}. Expected 'org/repo' (exactly one slash)."
48 ));
49 }
50 let valid = |s: &str| {
51 !s.is_empty()
52 && s.chars()
53 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
54 };
55 if !valid(org) || !valid(repo) {
56 return Err(anyhow!(
57 "Invalid repo name {name:?}. Letters/digits/dots/hyphens/underscores only."
58 ));
59 }
60 Ok(())
61}
62
63pub type PostActivateHook = Arc<dyn Fn(&Path, &str) -> Result<()> + Send + Sync>;
69
70pub type ActivationSummaryHook = Arc<dyn Fn(&Path, &str) -> Option<String> + Send + Sync>;
82
83pub type PostActivateRevsHook = Arc<dyn Fn(&Path, &str, &[String]) -> Result<()> + Send + Sync>;
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
104pub struct ActivationId(u64);
105
106impl ActivationId {
107 pub fn get(self) -> u64 {
109 self.0
110 }
111}
112
113impl fmt::Display for ActivationId {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 self.0.fmt(f)
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ActivationBuild {
122 Plain,
124 Revisions(Vec<String>),
126 Reuse,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ActivationRequest {
137 id: ActivationId,
138 path: PathBuf,
139 name: String,
140 build: ActivationBuild,
141}
142
143impl ActivationRequest {
144 pub fn id(&self) -> ActivationId {
145 self.id
146 }
147
148 pub fn path(&self) -> &Path {
149 &self.path
150 }
151
152 pub fn name(&self) -> &str {
153 &self.name
154 }
155
156 pub fn build(&self) -> &ActivationBuild {
157 &self.build
158 }
159}
160
161pub struct PreparedActivation {
172 commit: Box<dyn FnOnce() -> Result<Option<String>> + Send + 'static>,
173}
174
175impl PreparedActivation {
176 pub fn new<F>(commit: F) -> Self
177 where
178 F: FnOnce() -> Result<Option<String>> + Send + 'static,
179 {
180 Self {
181 commit: Box::new(commit),
182 }
183 }
184
185 pub fn summary(summary: Option<String>) -> Self {
187 Self::new(move || Ok(summary))
188 }
189
190 fn commit(self) -> Result<Option<String>> {
191 (self.commit)()
192 }
193}
194
195pub type ActivationTransactionHook =
203 Arc<dyn Fn(&ActivationRequest) -> Result<PreparedActivation> + Send + Sync>;
204
205#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
213#[serde(untagged)]
214pub enum RevsRequest {
215 Count(usize),
225 List(Vec<String>),
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231struct InventoryEntry {
232 cloned_at: String,
233 last_accessed: String,
234 #[serde(default)]
235 access_count: u64,
236 #[serde(default)]
237 stale: bool,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
244 last_built_sha: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
258 last_built_revs: Option<RevsRequest>,
259}
260
261pub use crate::server::manifest::WorkspaceKind;
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum RootOwnership {
274 Unowned,
277 Adopted,
280 Operator,
284}
285
286#[derive(Clone)]
288pub struct Workspace {
289 inner: Arc<WorkspaceInner>,
290}
291
292struct WorkspaceInner {
293 kind: WorkspaceKind,
294 workspace_dir: PathBuf,
295 stale_after_days: u32,
296 state: RwLock<WorkspaceState>,
297 inventory: Mutex<()>,
301 legacy_activation: Mutex<()>,
305 post_activate: Option<PostActivateHook>,
306 activation_summary: Option<ActivationSummaryHook>,
309 post_activate_revs: Option<PostActivateRevsHook>,
314 activation_transaction: Option<ActivationTransactionHook>,
317 sandbox_root: Option<PathBuf>,
326 root_ownership: Mutex<RootOwnership>,
335 root_swap: RwLock<()>,
356 deferred_anchor: OnceLock<PathBuf>,
364 adopt_client_roots: bool,
369}
370
371#[derive(Debug, Default)]
372struct WorkspaceState {
373 active_repo_name: Option<String>,
374 active_repo_path: Option<PathBuf>,
375 last_activation_id: u64,
379 latest_requested: Option<ActivationId>,
383 latest_root_intent: Option<ActivationId>,
395 active_build: Option<ActiveBuildState>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
402struct ActiveBuildState {
403 activation_id: ActivationId,
404 name: String,
405 path: PathBuf,
406 head_sha: String,
407 resolved_revs: Option<Vec<String>>,
408}
409
410#[derive(Debug, Clone, Copy)]
422enum ActivationIntent<'a> {
423 Bind(Option<&'a Path>),
426 Refresh { expected_root: Option<&'a Path> },
437}
438
439impl<'a> ActivationIntent<'a> {
440 fn local_root(self) -> Option<&'a Path> {
443 match self {
444 Self::Bind(root) => root,
445 Self::Refresh { expected_root } => expected_root,
446 }
447 }
448
449 fn refresh_expectation(self) -> Option<Option<&'a Path>> {
452 match self {
453 Self::Bind(_) => None,
454 Self::Refresh { expected_root } => Some(expected_root),
455 }
456 }
457}
458
459impl WorkspaceState {
460 fn current_intent(&self, intent: ActivationIntent<'_>) -> Option<ActivationId> {
463 match intent {
464 ActivationIntent::Bind(_) => self.latest_root_intent,
465 ActivationIntent::Refresh { .. } => self.latest_requested,
466 }
467 }
468
469 fn binding_is(&self, name: &str, root: Option<&Path>) -> bool {
473 self.active_repo_name.as_deref() == Some(name) && self.active_repo_path.as_deref() == root
474 }
475
476 fn binding_description(&self) -> String {
478 match (&self.active_repo_path, &self.active_repo_name) {
479 (Some(path), _) => path.display().to_string(),
480 (None, Some(name)) => name.clone(),
481 (None, None) => "nothing".to_string(),
482 }
483 }
484}
485
486impl Workspace {
487 pub fn open(
489 workspace_dir: PathBuf,
490 stale_after_days: u32,
491 post_activate: Option<PostActivateHook>,
492 ) -> Result<Self> {
493 if !workspace_dir.is_dir() {
494 fs::create_dir_all(&workspace_dir).with_context(|| {
495 format!("failed to create workspace dir {}", workspace_dir.display())
496 })?;
497 }
498 let repos_dir = workspace_dir.join("repos");
499 if !repos_dir.is_dir() {
500 fs::create_dir_all(&repos_dir)
501 .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
502 }
503 let ws = Self {
504 inner: Arc::new(WorkspaceInner {
505 kind: WorkspaceKind::Github,
506 workspace_dir,
507 stale_after_days,
508 state: RwLock::new(WorkspaceState::default()),
509 inventory: Mutex::new(()),
510 legacy_activation: Mutex::new(()),
511 post_activate,
512 activation_summary: None,
513 post_activate_revs: None,
514 activation_transaction: None,
515 sandbox_root: None,
516 root_ownership: Mutex::new(RootOwnership::Operator),
517 root_swap: RwLock::new(()),
518 deferred_anchor: OnceLock::new(),
519 adopt_client_roots: false,
520 }),
521 };
522 ws.reconcile_inventory()?;
523 Ok(ws)
524 }
525
526 pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
534 if !root.is_dir() {
535 anyhow::bail!(
536 "local workspace root does not exist or is not a directory: {}",
537 root.display()
538 );
539 }
540 let canon_root = root
541 .canonicalize()
542 .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
543 let inv_dir = canon_root.join(".mcp-workspace");
546 if !inv_dir.is_dir() {
547 fs::create_dir_all(&inv_dir).with_context(|| {
548 format!("failed to create local-workspace dir {}", inv_dir.display())
549 })?;
550 }
551 let mut state = WorkspaceState::default();
552 let synthetic_name = synthesize_local_name(&canon_root);
553 state.active_repo_name = Some(synthetic_name);
554 state.active_repo_path = Some(canon_root.clone());
555 Ok(Self {
556 inner: Arc::new(WorkspaceInner {
557 kind: WorkspaceKind::Local,
558 workspace_dir: canon_root,
559 stale_after_days: u32::MAX, state: RwLock::new(state),
561 inventory: Mutex::new(()),
562 legacy_activation: Mutex::new(()),
563 post_activate,
564 activation_summary: None,
565 post_activate_revs: None,
566 activation_transaction: None,
567 sandbox_root: None,
568 root_ownership: Mutex::new(RootOwnership::Operator),
572 root_swap: RwLock::new(()),
573 deferred_anchor: OnceLock::new(),
574 adopt_client_roots: false,
575 }),
576 })
577 }
578
579 pub fn open_local_unanchored(post_activate: Option<PostActivateHook>) -> Result<Self> {
602 Ok(Self {
603 inner: Arc::new(WorkspaceInner {
604 kind: WorkspaceKind::Local,
605 workspace_dir: PathBuf::new(),
609 stale_after_days: u32::MAX, state: RwLock::new(WorkspaceState::default()),
611 inventory: Mutex::new(()),
612 legacy_activation: Mutex::new(()),
613 post_activate,
614 activation_summary: None,
615 post_activate_revs: None,
616 activation_transaction: None,
617 sandbox_root: None,
618 root_ownership: Mutex::new(RootOwnership::Unowned),
619 root_swap: RwLock::new(()),
620 deferred_anchor: OnceLock::new(),
621 adopt_client_roots: false,
622 }),
623 })
624 }
625
626 pub fn with_adopt_client_roots(mut self) -> Self {
651 match Arc::get_mut(&mut self.inner) {
652 Some(inner) => inner.adopt_client_roots = true,
653 None => tracing::warn!(
654 "with_adopt_client_roots called after the workspace was cloned; client roots will not be adopted"
655 ),
656 }
657 self
658 }
659
660 pub fn adopts_client_roots(&self) -> bool {
662 self.inner.adopt_client_roots
663 }
664
665 pub fn root_ownership(&self) -> RootOwnership {
671 *self.lock_ownership()
672 }
673
674 fn lock_ownership(&self) -> MutexGuard<'_, RootOwnership> {
685 self.inner
686 .root_ownership
687 .lock()
688 .unwrap_or_else(|poisoned| poisoned.into_inner())
689 }
690
691 fn lock_operator_swap(&self) -> RwLockReadGuard<'_, ()> {
696 self.inner
697 .root_swap
698 .read()
699 .unwrap_or_else(|poisoned| poisoned.into_inner())
700 }
701
702 fn lock_adoption(&self) -> RwLockWriteGuard<'_, ()> {
705 self.inner
706 .root_swap
707 .write()
708 .unwrap_or_else(|poisoned| poisoned.into_inner())
709 }
710
711 pub fn with_sandbox_root(mut self, boundary: &Path) -> Result<Self> {
732 if !matches!(self.inner.kind, WorkspaceKind::Local) {
733 anyhow::bail!(
734 "sandbox_root is only valid for local workspaces (this one is {})",
735 self.inner.kind.as_str()
736 );
737 }
738 if !boundary.is_dir() {
739 anyhow::bail!(
740 "sandbox_root does not exist or is not a directory: {}",
741 boundary.display()
742 );
743 }
744 let canon = boundary.canonicalize().with_context(|| {
745 format!("failed to canonicalize sandbox_root {}", boundary.display())
746 })?;
747 if let Some(active) = self.active_repo_path() {
748 if !active.starts_with(&canon) {
749 anyhow::bail!(
750 "active root {} is outside sandbox_root {}: the configured root must lie inside the containment boundary",
751 active.display(),
752 canon.display()
753 );
754 }
755 }
756 match Arc::get_mut(&mut self.inner) {
757 Some(inner) => inner.sandbox_root = Some(canon),
758 None => anyhow::bail!(
759 "with_sandbox_root called after the workspace was cloned; \
760 the containment boundary {} would not be enforced",
761 canon.display()
762 ),
763 }
764 Ok(self)
765 }
766
767 pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
773 match Arc::get_mut(&mut self.inner) {
774 Some(inner) => inner.activation_summary = Some(hook),
775 None => tracing::warn!(
776 "with_activation_summary called after the workspace was cloned; summary not attached"
777 ),
778 }
779 self
780 }
781
782 pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
790 match Arc::get_mut(&mut self.inner) {
791 Some(inner) => inner.post_activate_revs = Some(hook),
792 None => tracing::warn!(
793 "with_post_activate_revs called after the workspace was cloned; revs hook not attached"
794 ),
795 }
796 self
797 }
798
799 pub fn with_activation_transaction(mut self, hook: ActivationTransactionHook) -> Self {
807 match Arc::get_mut(&mut self.inner) {
808 Some(inner) => inner.activation_transaction = Some(hook),
809 None => tracing::warn!(
810 "with_activation_transaction called after the workspace was cloned; transaction not attached"
811 ),
812 }
813 self
814 }
815
816 pub fn kind(&self) -> WorkspaceKind {
817 self.inner.kind
818 }
819
820 pub fn workspace_dir(&self) -> &Path {
826 match self.inner.deferred_anchor.get() {
827 Some(anchored) => anchored.as_path(),
828 None => &self.inner.workspace_dir,
829 }
830 }
831
832 pub fn repos_dir(&self) -> PathBuf {
833 self.workspace_dir().join("repos")
834 }
835
836 fn inventory_base(&self) -> Option<&Path> {
839 let base = self.workspace_dir();
840 (!base.as_os_str().is_empty()).then_some(base)
841 }
842
843 fn inventory_path(&self) -> Option<PathBuf> {
844 let base = self.inventory_base()?;
845 Some(match self.inner.kind {
846 WorkspaceKind::Github => base.join("inventory.json"),
847 WorkspaceKind::Local => base.join(".mcp-workspace").join("inventory.json"),
848 })
849 }
850
851 pub fn active_repo_name(&self) -> Option<String> {
853 self.inner.state.read().unwrap().active_repo_name.clone()
854 }
855
856 pub fn active_repo_path(&self) -> Option<PathBuf> {
858 self.inner.state.read().unwrap().active_repo_path.clone()
859 }
860
861 pub fn default_github_repo(&self) -> Option<String> {
870 match self.inner.kind {
871 WorkspaceKind::Github => self.active_repo_name(),
872 WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
873 }
874 }
875
876 fn load_inventory_unlocked(&self) -> BTreeMap<String, InventoryEntry> {
881 let Some(path) = self.inventory_path() else {
882 return BTreeMap::new();
883 };
884 let Ok(text) = fs::read_to_string(&path) else {
885 return BTreeMap::new();
886 };
887 serde_json::from_str(&text).unwrap_or_default()
888 }
889
890 fn save_inventory_unlocked(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
891 let Some(path) = self.inventory_path() else {
894 return Ok(());
895 };
896 let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
897 fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
898 Ok(())
899 }
900
901 fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
902 let _guard = self.inner.inventory.lock().unwrap();
903 self.load_inventory_unlocked()
904 }
905
906 fn reconcile_inventory(&self) -> Result<()> {
907 let _guard = self.inner.inventory.lock().unwrap();
908 let mut inv = self.load_inventory_unlocked();
909 let mut on_disk: Vec<String> = Vec::new();
910 if self.repos_dir().is_dir() {
911 for org_entry in fs::read_dir(self.repos_dir())? {
912 let Ok(org_entry) = org_entry else { continue };
913 if !org_entry.path().is_dir() {
914 continue;
915 }
916 let org = org_entry.file_name().to_string_lossy().into_owned();
917 if org.starts_with('.') {
918 continue;
919 }
920 for repo_entry in fs::read_dir(org_entry.path())? {
921 let Ok(repo_entry) = repo_entry else { continue };
922 if !repo_entry.path().is_dir() {
923 continue;
924 }
925 let repo = repo_entry.file_name().to_string_lossy().into_owned();
926 if repo.starts_with('.') {
927 continue;
928 }
929 let rname = format!("{org}/{repo}");
930 on_disk.push(rname.clone());
931 inv.entry(rname).or_insert_with(|| {
932 let mtime = repo_entry
933 .metadata()
934 .ok()
935 .and_then(|m| m.modified().ok())
936 .map(format_iso)
937 .unwrap_or_else(now_iso);
938 InventoryEntry {
939 cloned_at: mtime.clone(),
940 last_accessed: mtime,
941 access_count: 0,
942 stale: false,
943 last_built_sha: None,
944 last_built_revs: None,
945 }
946 });
947 }
948 }
949 }
950 for (rname, entry) in inv.iter_mut() {
951 if !on_disk.contains(rname) && !entry.stale {
952 entry.stale = true;
953 }
954 }
955 self.save_inventory_unlocked(&inv)?;
956 Ok(())
957 }
958
959 fn bump_access(&self, name: &str, action: &str) {
960 let _guard = self.inner.inventory.lock().unwrap();
961 let mut inv = self.load_inventory_unlocked();
962 let now = now_iso();
963 let entry = inv
964 .entry(name.to_string())
965 .or_insert_with(|| InventoryEntry {
966 cloned_at: now.clone(),
967 last_accessed: now.clone(),
968 access_count: 0,
969 stale: false,
970 last_built_sha: None,
971 last_built_revs: None,
972 });
973 entry.last_accessed = now.clone();
974 entry.access_count += 1;
975 entry.stale = false;
976 if action == "cloned" || entry.cloned_at.is_empty() {
977 entry.cloned_at = now;
978 }
979 let _ = self.save_inventory_unlocked(&inv);
980 }
981
982 fn mark_stale(&self, name: &str) {
983 let _guard = self.inner.inventory.lock().unwrap();
984 let mut inv = self.load_inventory_unlocked();
985 if let Some(entry) = inv.get_mut(name) {
986 entry.stale = true;
987 let _ = self.save_inventory_unlocked(&inv);
988 }
989 }
990
991 fn sweep_stale(&self) -> Vec<String> {
992 if matches!(self.inner.kind, WorkspaceKind::Local) {
994 return Vec::new();
995 }
996 let active = self.active_repo_name();
997 let _guard = self.inner.inventory.lock().unwrap();
998 let mut inv = self.load_inventory_unlocked();
999 let cutoff = SystemTime::now()
1000 - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
1001 let mut swept: Vec<String> = Vec::new();
1002 for (rname, entry) in inv.iter_mut() {
1003 if entry.stale {
1004 continue;
1005 }
1006 if Some(rname.as_str()) == active.as_deref() {
1007 continue;
1008 }
1009 let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
1010 if last >= cutoff {
1011 continue;
1012 }
1013 let parts: Vec<&str> = rname.splitn(2, '/').collect();
1014 if parts.len() != 2 {
1015 continue;
1016 }
1017 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
1018 if repo_path.exists() {
1019 let _ = fs::remove_dir_all(&repo_path);
1020 }
1021 entry.stale = true;
1022 swept.push(rname.clone());
1023 }
1024 if !swept.is_empty() {
1025 let _ = self.save_inventory_unlocked(&inv);
1026 self.prune_empty_org_dirs();
1027 }
1028 swept
1029 }
1030
1031 fn prune_empty_org_dirs(&self) {
1032 let Ok(entries) = fs::read_dir(self.repos_dir()) else {
1033 return;
1034 };
1035 for entry in entries.flatten() {
1036 let path = entry.path();
1037 if !path.is_dir() {
1038 continue;
1039 }
1040 if let Ok(children) = fs::read_dir(&path) {
1041 let real: Vec<_> = children
1042 .flatten()
1043 .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
1044 .collect();
1045 if real.is_empty() {
1046 let _ = fs::remove_dir_all(&path);
1047 }
1048 }
1049 }
1050 }
1051
1052 fn clone_or_update(
1063 &self,
1064 name: &str,
1065 requested_local_root: Option<&Path>,
1066 ) -> Result<(String, PathBuf, String)> {
1067 if matches!(self.inner.kind, WorkspaceKind::Local) {
1068 let root = requested_local_root
1083 .map(Path::to_path_buf)
1084 .or_else(|| self.active_repo_path())
1085 .context("internal error: local activation without a root")?;
1086 let prev_sha = self.last_built_sha(name);
1092 let fingerprint = fingerprint_dir(&root);
1093 let action = match prev_sha {
1094 Some(p) if p == fingerprint => "current",
1095 None => "cloned", Some(_) => "updated",
1097 };
1098 return Ok((action.to_string(), root, fingerprint));
1099 }
1100 let parts: Vec<&str> = name.splitn(2, '/').collect();
1101 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
1102 if !repo_path.exists() {
1103 fs::create_dir_all(repo_path.parent().unwrap()).ok();
1104 let url = format!("https://github.com/{name}.git");
1105 let out = Command::new("git")
1113 .args([
1114 "clone",
1115 "--filter=tree:0",
1116 "--tags",
1117 &url,
1118 repo_path.to_str().unwrap(),
1119 ])
1120 .output()
1121 .context("failed to spawn `git clone`")?;
1122 if !out.status.success() {
1123 anyhow::bail!(
1124 "git clone failed: {}",
1125 String::from_utf8_lossy(&out.stderr).trim()
1126 );
1127 }
1128 let sha = git_rev_parse(&repo_path, "HEAD")?;
1129 return Ok(("cloned".to_string(), repo_path, sha));
1130 }
1131
1132 Command::new("git")
1138 .args(["fetch", "origin", "--tags"])
1139 .current_dir(&repo_path)
1140 .output()
1141 .context("git fetch failed")?;
1142 let local = git_rev_parse(&repo_path, "HEAD")?;
1143 let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
1144 if local != remote {
1145 Command::new("git")
1146 .args(["reset", "--hard", "FETCH_HEAD"])
1147 .current_dir(&repo_path)
1148 .output()
1149 .context("git reset failed")?;
1150 let sha = git_rev_parse(&repo_path, "HEAD")?;
1151 return Ok(("updated".to_string(), repo_path, sha));
1152 }
1153 Ok(("current".to_string(), repo_path, local))
1154 }
1155
1156 fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
1189 let resolved = match req {
1190 RevsRequest::Count(n) => {
1191 let out = Command::new("git")
1192 .args(["tag", "--sort=-v:refname"])
1193 .current_dir(repo_path)
1194 .output()
1195 .context("failed to spawn `git tag`")?;
1196 if !out.status.success() {
1197 anyhow::bail!(
1198 "cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
1199 repo_path.display(),
1200 String::from_utf8_lossy(&out.stderr).trim()
1201 );
1202 }
1203 let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
1204 .lines()
1205 .map(|l| l.trim().to_string())
1206 .filter(|l| !l.is_empty())
1207 .collect();
1208 if tags.is_empty() {
1209 anyhow::bail!(
1210 "revs={n} requested but '{}' has no tags to resolve",
1211 repo_path.display()
1212 );
1213 }
1214 let mut chosen = select_family_tags(&tags, *n).unwrap_or_else(|| {
1218 let mut raw: Vec<String> = tags.into_iter().take(*n).collect();
1219 raw.reverse();
1220 raw
1221 });
1222 chosen.push("HEAD".to_string());
1225 chosen
1226 }
1227 RevsRequest::List(revs) => {
1228 if revs.is_empty() {
1229 anyhow::bail!("revs list is empty — pass at least one revision");
1230 }
1231 for r in revs {
1232 let out = Command::new("git")
1233 .args([
1234 "rev-parse",
1235 "--verify",
1236 "--quiet",
1237 &format!("{r}^{{commit}}"),
1238 ])
1239 .current_dir(repo_path)
1240 .output()
1241 .context("failed to spawn `git rev-parse`")?;
1242 if !out.status.success() {
1243 anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
1244 }
1245 }
1246 revs.clone()
1247 }
1248 };
1249 Ok(dedup_labels(resolved))
1250 }
1251
1252 fn activate(
1282 &self,
1283 name: &str,
1284 force_rebuild: bool,
1285 revs: Option<&RevsRequest>,
1286 intent: ActivationIntent<'_>,
1287 ) -> Result<String> {
1288 let _legacy_guard = self
1294 .inner
1295 .activation_transaction
1296 .is_none()
1297 .then(|| self.inner.legacy_activation.lock().unwrap());
1298 let activation_id = {
1299 let mut state = self.inner.state.write().unwrap();
1300 if let Some(expected_root) = intent.refresh_expectation() {
1308 if !state.binding_is(name, expected_root) {
1309 let now = state.binding_description();
1310 return Ok(format!(
1311 "Refresh of '{name}' was abandoned before it started: the active root is now {now}. \
1312 Nothing was rebuilt — a refresh never changes which root is active."
1313 ));
1314 }
1315 }
1316 state.last_activation_id += 1;
1317 let id = ActivationId(state.last_activation_id);
1318 state.latest_requested = Some(id);
1319 if intent.refresh_expectation().is_none() {
1322 state.latest_root_intent = Some(id);
1323 }
1324 id
1325 };
1326 let prev_built_sha = self.last_built_sha(name);
1327 let prev_built_revs = self.last_built_revs(name);
1328 let (action, repo_path, head_sha) = self
1329 .clone_or_update(name, intent.local_root())
1330 .with_context(|| {
1331 format!("activation request {activation_id} source preparation failed")
1332 })?;
1333 let resolved_revs = match revs {
1337 Some(req) => Some(self.resolve_revs(&repo_path, req).with_context(|| {
1338 format!("activation request {activation_id} revision resolution failed")
1339 })?),
1340 None => None,
1341 };
1342 self.bump_access(name, &action);
1343 let is_active_built = {
1344 let state = self.inner.state.read().unwrap();
1345 state.active_build.as_ref().is_some_and(|built| {
1346 built.name == name && built.path == repo_path && built.resolved_revs.is_none()
1347 })
1348 };
1349
1350 let already_built = !force_rebuild
1378 && resolved_revs.is_none()
1379 && prev_built_revs.is_none()
1380 && action == "current"
1381 && prev_built_sha.as_deref() == Some(head_sha.as_str())
1382 && is_active_built;
1383 let uses_transaction = self.inner.activation_transaction.is_some();
1384 let revision_build = !already_built
1385 && resolved_revs.is_some()
1386 && (uses_transaction || self.inner.post_activate_revs.is_some());
1387 let build = if already_built {
1388 ActivationBuild::Reuse
1389 } else if revision_build {
1390 ActivationBuild::Revisions(resolved_revs.clone().unwrap_or_default())
1391 } else {
1392 ActivationBuild::Plain
1393 };
1394 let request = ActivationRequest {
1395 id: activation_id,
1396 path: repo_path.clone(),
1397 name: name.to_string(),
1398 build,
1399 };
1400
1401 let prepared = if let Some(hook) = &self.inner.activation_transaction {
1402 hook(&request)
1403 } else {
1404 let hook_result = match request.build() {
1408 ActivationBuild::Reuse => Ok(()),
1409 ActivationBuild::Revisions(resolved) => self
1410 .inner
1411 .post_activate_revs
1412 .as_ref()
1413 .map_or(Ok(()), |hook| hook(&repo_path, name, resolved)),
1414 ActivationBuild::Plain => self
1415 .inner
1416 .post_activate
1417 .as_ref()
1418 .map_or(Ok(()), |hook| hook(&repo_path, name)),
1419 };
1420 hook_result.map(|()| {
1421 let summary = self
1422 .inner
1423 .activation_summary
1424 .as_ref()
1425 .and_then(|hook| hook(&repo_path, name));
1426 PreparedActivation::summary(summary)
1427 })
1428 };
1429
1430 let prepared = match prepared {
1431 Ok(prepared) => prepared,
1432 Err(error) => {
1433 let latest = self.inner.state.read().unwrap().current_intent(intent);
1434 if latest != Some(activation_id) {
1435 return Ok(format!(
1436 "Activation request {activation_id} for '{name}' was superseded by request {} before its failed build could publish.",
1437 latest.map_or_else(|| "unknown".to_string(), |id| id.to_string())
1438 ));
1439 }
1440 return Err(anyhow!(
1441 "activation request {activation_id} for '{name}' failed during preparation: {error}"
1442 ));
1443 }
1444 };
1445
1446 let summary = {
1451 let mut state = self.inner.state.write().unwrap();
1452 if state.current_intent(intent) != Some(activation_id) {
1453 let superseding = state.current_intent(intent);
1454 drop(state);
1455 drop(prepared);
1456 return Ok(format!(
1457 "Activation request {activation_id} for '{name}' was superseded by request {} before publication; its prepared build was discarded.",
1458 superseding.map_or_else(|| "unknown".to_string(), |id| id.to_string())
1459 ));
1460 }
1461 if let Some(expected_root) = intent.refresh_expectation() {
1468 if !state.binding_is(name, expected_root) {
1469 let now = state.binding_description();
1470 drop(state);
1471 drop(prepared);
1472 return Ok(format!(
1473 "Refresh request {activation_id} for '{name}' was abandoned: the active root moved to \
1474 {now} while it rebuilt, and a refresh never changes which root is active. \
1475 Its prepared build was discarded."
1476 ));
1477 }
1478 }
1479 let summary = prepared.commit().with_context(|| {
1480 format!("activation request {activation_id} for '{name}' failed during commit")
1481 })?;
1482 self.anchor_inventory(&repo_path, name, &action);
1487 if !matches!(request.build(), ActivationBuild::Reuse) {
1488 let built_revs = revision_build.then_some(revs).flatten();
1489 self.record_built(name, &head_sha, built_revs);
1490 }
1491 state.active_repo_name = Some(name.to_string());
1492 state.active_repo_path = Some(repo_path.clone());
1493 state.active_build = Some(ActiveBuildState {
1494 activation_id,
1495 name: name.to_string(),
1496 path: repo_path.clone(),
1497 head_sha: head_sha.clone(),
1498 resolved_revs: match request.build() {
1499 ActivationBuild::Revisions(resolved) => Some(resolved.clone()),
1500 ActivationBuild::Plain | ActivationBuild::Reuse => None,
1501 },
1502 });
1503 summary
1504 };
1505
1506 let verb = match action.as_str() {
1507 "cloned" => "Cloned",
1508 "updated" => "Updated",
1509 "current" => "Activated (already up to date)",
1510 other => other,
1511 };
1512 let suffix = if already_built {
1513 " [build skipped: HEAD matches last-built SHA]"
1514 } else {
1515 ""
1516 };
1517 let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
1518 if let ActivationBuild::Revisions(resolved) = request.build() {
1523 base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
1524 }
1525 Ok(match summary {
1526 Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
1527 _ => base,
1528 })
1529 }
1530
1531 fn anchor_inventory(&self, root: &Path, name: &str, action: &str) {
1556 if !matches!(self.inner.kind, WorkspaceKind::Local) || self.inventory_base().is_some() {
1557 return;
1558 }
1559 let inv_dir = root.join(".mcp-workspace");
1560 if let Err(e) = fs::create_dir_all(&inv_dir) {
1561 tracing::warn!(
1562 "failed to create local-workspace dir {}: {e}",
1563 inv_dir.display()
1564 );
1565 return;
1566 }
1567 let _ = self.inner.deferred_anchor.set(root.to_path_buf());
1568 self.bump_access(name, action);
1569 }
1570
1571 fn record_built(&self, name: &str, sha: &str, revs: Option<&RevsRequest>) {
1577 let _guard = self.inner.inventory.lock().unwrap();
1578 let mut inv = self.load_inventory_unlocked();
1579 if let Some(entry) = inv.get_mut(name) {
1580 entry.last_built_sha = Some(sha.to_string());
1581 entry.last_built_revs = revs.cloned();
1582 let _ = self.save_inventory_unlocked(&inv);
1583 }
1584 }
1585
1586 pub fn last_built_sha(&self, name: &str) -> Option<String> {
1591 self.load_inventory()
1592 .get(name)
1593 .and_then(|e| e.last_built_sha.clone())
1594 }
1595
1596 pub fn last_built_revs(&self, name: &str) -> Option<RevsRequest> {
1601 self.load_inventory()
1602 .get(name)
1603 .and_then(|e| e.last_built_revs.clone())
1604 }
1605
1606 fn delete(&self, name: &str) -> Result<String> {
1607 let parts: Vec<&str> = name.splitn(2, '/').collect();
1608 if parts.len() != 2 {
1609 anyhow::bail!("Invalid repo name");
1610 }
1611 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
1612 let mut deleted = Vec::new();
1613 if repo_path.exists() {
1614 fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
1615 deleted.push("repo");
1616 }
1617 self.mark_stale(name);
1618 self.prune_empty_org_dirs();
1619 if deleted.is_empty() {
1620 return Ok(format!("Nothing to delete — '{name}' not found."));
1621 }
1622 let mut state = self.inner.state.write().unwrap();
1623 if state.active_repo_name.as_deref() == Some(name) {
1624 state.active_repo_name = None;
1625 state.active_repo_path = None;
1626 state.active_build = None;
1627 return Ok(format!(
1628 "Deleted {}. Active repo cleared.",
1629 deleted.join(", ")
1630 ));
1631 }
1632 Ok(format!("Deleted {}.", deleted.join(", ")))
1633 }
1634
1635 fn list(&self) -> String {
1636 let inv = self.load_inventory();
1637 if inv.is_empty() {
1638 return "No repos cloned yet. Call repo_management('org/repo') to clone one."
1639 .to_string();
1640 }
1641 let active = self.active_repo_name();
1642 let mut live: Vec<String> = Vec::new();
1643 let mut stale_lines: Vec<String> = Vec::new();
1644 for (rname, entry) in &inv {
1645 let marker = if Some(rname.as_str()) == active.as_deref() {
1646 " [active]"
1647 } else {
1648 ""
1649 };
1650 let access = format!(
1651 "{} access{}, last {}",
1652 entry.access_count,
1653 if entry.access_count == 1 { "" } else { "es" },
1654 relative_time(&entry.last_accessed)
1655 );
1656 if entry.stale {
1657 stale_lines.push(format!(
1658 " {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
1659 ));
1660 } else {
1661 live.push(format!(" {rname}{marker} ({access})"));
1662 }
1663 }
1664 let mut out = String::new();
1665 if !live.is_empty() {
1666 out.push_str(&format!(
1667 "{} live repo(s):\n{}",
1668 live.len(),
1669 live.join("\n")
1670 ));
1671 }
1672 if !stale_lines.is_empty() {
1673 if !out.is_empty() {
1674 out.push_str("\n\n");
1675 }
1676 out.push_str(&format!(
1677 "{} stale repo(s):\n{}",
1678 stale_lines.len(),
1679 stale_lines.join("\n")
1680 ));
1681 }
1682 out
1683 }
1684
1685 pub fn repo_management(
1699 &self,
1700 name: Option<&str>,
1701 delete: bool,
1702 update: bool,
1703 force_rebuild: bool,
1704 revs: Option<&RevsRequest>,
1705 ) -> String {
1706 if matches!(self.inner.kind, WorkspaceKind::Local) {
1708 if name.is_some() {
1709 return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
1710 to switch the active root, or pass `update=true` / `force_rebuild=true` \
1711 to rebuild against the current root."
1712 .to_string();
1713 }
1714 if delete {
1715 return "Local-workspace mode does not support `delete`. The root is owned by the \
1716 operator; remove it manually."
1717 .to_string();
1718 }
1719 let (active, active_root) = {
1723 let state = self.inner.state.read().unwrap();
1724 match &state.active_repo_name {
1725 Some(n) => (n.clone(), state.active_repo_path.clone()),
1726 None => return "No active local root.".to_string(),
1727 }
1728 };
1729 let _ = update; let effective = match revs {
1742 Some(r) => Some(r.clone()),
1743 None => self.last_built_revs(&active),
1744 };
1745 return self
1746 .activate(
1747 &active,
1748 force_rebuild,
1749 effective.as_ref(),
1750 ActivationIntent::Refresh {
1751 expected_root: active_root.as_deref(),
1752 },
1753 )
1754 .unwrap_or_else(|e| format!("rebuild failed: {e}"));
1755 }
1756
1757 let swept = self.sweep_stale();
1758 let prefix = if swept.is_empty() {
1759 String::new()
1760 } else {
1761 format!(
1762 "[Swept {} idle repo(s) (>{}d): {}]\n\n",
1763 swept.len(),
1764 self.inner.stale_after_days,
1765 swept.join(", ")
1766 )
1767 };
1768
1769 if name.is_none() && !update {
1770 return prefix + &self.list();
1771 }
1772
1773 if update {
1774 let (active, active_root) = {
1777 let state = self.inner.state.read().unwrap();
1778 match &state.active_repo_name {
1779 Some(n) => (n.clone(), state.active_repo_path.clone()),
1780 None => {
1781 return prefix
1782 + "No active repository. Call repo_management('org/repo') first."
1783 }
1784 }
1785 };
1786 let effective = match revs {
1794 Some(r) => Some(r.clone()),
1795 None => self.last_built_revs(&active),
1796 };
1797 return prefix
1798 + &self
1799 .activate(
1800 &active,
1801 force_rebuild,
1802 effective.as_ref(),
1803 ActivationIntent::Refresh {
1808 expected_root: active_root.as_deref(),
1809 },
1810 )
1811 .unwrap_or_else(|e| format!("update failed: {e}"));
1812 }
1813
1814 let Some(name) = name else {
1815 return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
1816 };
1817 if let Err(e) = validate_repo_name(name) {
1818 return prefix + &e.to_string();
1819 }
1820 if delete {
1821 return prefix
1822 + &self
1823 .delete(name)
1824 .unwrap_or_else(|e| format!("delete failed: {e}"));
1825 }
1826 prefix
1827 + &self
1828 .activate(name, force_rebuild, revs, ActivationIntent::Bind(None))
1829 .unwrap_or_else(|e| format!("activate failed: {e}"))
1830 }
1831
1832 pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
1847 let _swap = self.lock_operator_swap();
1851 match self.swap_root(new_root, revs, "set_root_dir") {
1852 Ok(msg) => {
1853 *self.lock_ownership() = RootOwnership::Operator;
1857 msg
1858 }
1859 Err(msg) => msg,
1860 }
1861 }
1862
1863 pub fn adopt_client_root(&self, new_root: &Path) -> Result<String, String> {
1881 let _swap = self.lock_adoption();
1890 if *self.lock_ownership() == RootOwnership::Operator {
1891 return Err(
1892 "the active root was chosen by the operator; client roots are fallback-only"
1893 .to_string(),
1894 );
1895 }
1896 let msg = self.swap_root(new_root, None, "adopt_client_root")?;
1897 *self.lock_ownership() = RootOwnership::Adopted;
1898 Ok(msg)
1899 }
1900
1901 fn swap_root(
1917 &self,
1918 new_root: &Path,
1919 revs: Option<&RevsRequest>,
1920 who: &str,
1921 ) -> Result<String, String> {
1922 if !matches!(self.inner.kind, WorkspaceKind::Local) {
1923 return Err(format!("{who} is only valid in local-workspace mode."));
1924 }
1925 if !new_root.is_dir() {
1926 return Err(format!(
1927 "Path does not exist or is not a directory: {}",
1928 new_root.display()
1929 ));
1930 }
1931 let canon = match new_root.canonicalize() {
1932 Ok(p) => p,
1933 Err(e) => return Err(format!("canonicalize failed: {e}")),
1934 };
1935 if let Some(sandbox) = self.inner.sandbox_root.as_ref() {
1940 if !canon.starts_with(sandbox) {
1941 return Err(format!(
1942 "{who}: {} escapes workspace.sandbox_root ({}). \
1943 The active root is unchanged.",
1944 canon.display(),
1945 sandbox.display()
1946 ));
1947 }
1948 }
1949 let synthetic = synthesize_local_name(&canon);
1950 self.activate(
1955 &synthetic,
1956 false,
1957 revs,
1958 ActivationIntent::Bind(Some(&canon)),
1959 )
1960 .map_err(|e| format!("{who} failed: {e}"))
1961 }
1962}
1963
1964fn dedup_labels(revs: Vec<String>) -> Vec<String> {
1971 let mut seen = std::collections::HashSet::new();
1972 revs.into_iter()
1973 .filter(|r| seen.insert(r.clone()))
1974 .collect()
1975}
1976
1977const PRERELEASE_MARKERS: &[&str] = &["rc", "alpha", "beta", "dev", "pre", "preview"];
1982
1983#[derive(Debug, Clone, PartialEq, Eq)]
1987struct ClassifiedTag {
1988 raw: String,
1989 prefix: String,
1990 version: Vec<u64>,
1991 is_prerelease: bool,
1992}
1993
1994fn classify_tag(tag: &str) -> Option<ClassifiedTag> {
2008 let bytes = tag.as_bytes();
2009 for i in 0..bytes.len() {
2010 if !bytes[i].is_ascii_digit() {
2011 continue;
2012 }
2013 if i > 0 && bytes[i - 1].is_ascii_digit() {
2015 continue;
2016 }
2017 if let Some((version, is_prerelease)) = parse_version_at(&tag[i..]) {
2018 return Some(ClassifiedTag {
2019 raw: tag.to_string(),
2020 prefix: tag[..i].to_string(),
2021 version,
2022 is_prerelease,
2023 });
2024 }
2025 }
2026 None
2027}
2028
2029fn parse_version_at(s: &str) -> Option<(Vec<u64>, bool)> {
2035 let bytes = s.as_bytes();
2036 let mut nums: Vec<u64> = Vec::new();
2037 let mut idx = 0usize;
2038 loop {
2039 let start = idx;
2040 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
2041 idx += 1;
2042 }
2043 if idx == start {
2044 return None; }
2046 nums.push(s[start..idx].parse().ok()?);
2047 if idx + 1 < bytes.len() && bytes[idx] == b'.' && bytes[idx + 1].is_ascii_digit() {
2049 idx += 1;
2050 continue;
2051 }
2052 break;
2053 }
2054 let rest = &s[idx..];
2055 if rest.is_empty() {
2056 return Some((nums, false));
2057 }
2058 let after_sep = rest
2060 .strip_prefix(|c| c == '-' || c == '.' || c == '_')
2061 .unwrap_or(rest);
2062 let lower = after_sep.to_ascii_lowercase();
2063 if PRERELEASE_MARKERS.iter().any(|m| lower.starts_with(m)) {
2064 Some((nums, true))
2065 } else {
2066 None
2067 }
2068}
2069
2070fn select_family_tags(tags: &[String], n: usize) -> Option<Vec<String>> {
2086 let classified: Vec<ClassifiedTag> = tags.iter().filter_map(|t| classify_tag(t)).collect();
2087 if classified.is_empty() {
2088 return None;
2089 }
2090 let mut families: BTreeMap<String, Vec<&ClassifiedTag>> = BTreeMap::new();
2092 for c in &classified {
2093 families.entry(c.prefix.clone()).or_default().push(c);
2094 }
2095 let stable_count = |v: &Vec<&ClassifiedTag>| v.iter().filter(|c| !c.is_prerelease).count();
2096 let any_stable = families.values().any(|v| stable_count(v) > 0);
2097 let chosen = families.values().max_by(|a, b| {
2102 if any_stable {
2103 stable_count(a).cmp(&stable_count(b))
2104 } else {
2105 a.len().cmp(&b.len())
2106 }
2107 })?;
2108 let mut pool: Vec<&ClassifiedTag> = if any_stable {
2109 chosen
2110 .iter()
2111 .copied()
2112 .filter(|c| !c.is_prerelease)
2113 .collect()
2114 } else {
2115 chosen.to_vec()
2116 };
2117 pool.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| b.raw.cmp(&a.raw)));
2120 let mut newest: Vec<String> = pool.into_iter().take(n).map(|c| c.raw.clone()).collect();
2121 newest.reverse(); Some(newest)
2123}
2124
2125fn synthesize_local_name(root: &Path) -> String {
2129 let name = root
2130 .file_name()
2131 .map(|s| s.to_string_lossy().into_owned())
2132 .unwrap_or_else(|| "local".to_string());
2133 format!("local/{name}")
2134}
2135
2136fn parse_origin_repo(root: &Path) -> Option<String> {
2147 let out = Command::new("git")
2148 .arg("-C")
2149 .arg(root)
2150 .args(["remote", "get-url", "origin"])
2151 .output()
2152 .ok()?;
2153 if !out.status.success() {
2154 return None;
2155 }
2156 let url = String::from_utf8(out.stdout).ok()?;
2157 parse_github_remote(url.trim())
2158}
2159
2160fn parse_github_remote(url: &str) -> Option<String> {
2163 let path = url
2167 .strip_prefix("git@github.com:")
2168 .or_else(|| url.strip_prefix("https://github.com/"))
2169 .or_else(|| url.strip_prefix("http://github.com/"))
2170 .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
2171 let path = path.strip_suffix(".git").unwrap_or(path);
2172 let path = path.trim_end_matches('/');
2173 let mut parts = path.split('/');
2175 let org = parts.next().filter(|s| !s.is_empty())?;
2176 let repo = parts.next().filter(|s| !s.is_empty())?;
2177 if parts.next().is_some() {
2178 return None;
2179 }
2180 Some(format!("{org}/{repo}"))
2181}
2182
2183fn fingerprint_dir(root: &Path) -> String {
2188 use std::hash::{Hash, Hasher};
2189 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2190 let walker = ignore::WalkBuilder::new(root)
2191 .standard_filters(true)
2192 .hidden(true)
2193 .git_ignore(true)
2194 .build();
2195 for entry in walker.flatten() {
2196 if !entry.path().is_file() {
2197 continue;
2198 }
2199 let Ok(meta) = entry.metadata() else { continue };
2200 let mtime = meta
2201 .modified()
2202 .ok()
2203 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
2204 .map(|d| d.as_secs())
2205 .unwrap_or(0);
2206 entry.path().to_string_lossy().hash(&mut hasher);
2207 mtime.hash(&mut hasher);
2208 meta.len().hash(&mut hasher);
2209 }
2210 format!("local-{:016x}", hasher.finish())
2211}
2212
2213fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
2214 let out = Command::new("git")
2215 .args(["rev-parse", refspec])
2216 .current_dir(repo_path)
2217 .output()
2218 .context("git rev-parse failed")?;
2219 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
2220}
2221
2222fn now_iso() -> String {
2223 format_iso(SystemTime::now())
2224}
2225
2226fn format_iso(t: SystemTime) -> String {
2227 let secs = t
2228 .duration_since(SystemTime::UNIX_EPOCH)
2229 .map(|d| d.as_secs())
2230 .unwrap_or(0);
2231 chrono_lite::format_secs(secs)
2233}
2234
2235fn parse_iso(s: &str) -> Option<SystemTime> {
2236 let secs = chrono_lite::parse_secs(s)?;
2237 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
2238}
2239
2240fn relative_time(iso: &str) -> String {
2241 let Some(t) = parse_iso(iso) else {
2242 return "unknown".to_string();
2243 };
2244 let now = SystemTime::now();
2245 let delta = now.duration_since(t).unwrap_or_default().as_secs();
2246 if delta < 3600 {
2247 "just now".to_string()
2248 } else if delta < 86_400 {
2249 format!("{}h ago", delta / 3600)
2250 } else {
2251 format!("{}d ago", delta / 86_400)
2252 }
2253}
2254
2255mod chrono_lite {
2258 pub fn format_secs(secs: u64) -> String {
2259 let days = (secs / 86_400) as i64;
2261 let time = secs % 86_400;
2262 let (y, mo, d) = days_to_civil(days + 719_468);
2263 let h = time / 3600;
2264 let m = (time / 60) % 60;
2265 let s = time % 60;
2266 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
2267 }
2268
2269 pub fn parse_secs(s: &str) -> Option<u64> {
2270 let bytes = s.as_bytes();
2273 if bytes.len() < 19 {
2274 return None;
2275 }
2276 let y: i64 = s.get(0..4)?.parse().ok()?;
2277 let mo: u32 = s.get(5..7)?.parse().ok()?;
2278 let d: u32 = s.get(8..10)?.parse().ok()?;
2279 let h: u64 = s.get(11..13)?.parse().ok()?;
2280 let m: u64 = s.get(14..16)?.parse().ok()?;
2281 let sc: u64 = s.get(17..19)?.parse().ok()?;
2282 let days = civil_to_days(y, mo, d) - 719_468;
2283 Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
2284 }
2285
2286 fn days_to_civil(z: i64) -> (i64, u32, u32) {
2287 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
2288 let doe = (z - era * 146_097) as u64;
2289 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
2290 let y = (yoe as i64) + era * 400;
2291 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2292 let mp = (5 * doy + 2) / 153;
2293 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
2294 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
2295 let y = if m <= 2 { y + 1 } else { y };
2296 (y, m, d)
2297 }
2298
2299 fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
2300 let y = if m <= 2 { y - 1 } else { y };
2301 let era = if y >= 0 { y } else { y - 399 } / 400;
2302 let yoe = (y - era * 400) as u64;
2303 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
2304 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2305 era * 146_097 + doe as i64
2306 }
2307}
2308
2309#[allow(dead_code)]
2311fn _json_keepalive() {
2312 let _ = json!({});
2313}
2314
2315#[cfg(test)]
2316mod tests {
2317 use super::*;
2318
2319 #[test]
2320 fn validates_repo_names() {
2321 assert!(validate_repo_name("pydata/xarray").is_ok());
2322 assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
2323 assert!(validate_repo_name("xarray").is_err());
2324 assert!(validate_repo_name("a/b/c").is_err());
2325 assert!(validate_repo_name("foo/bar; rm -rf").is_err());
2326 }
2327
2328 #[test]
2329 fn open_creates_layout() {
2330 let dir = tempfile::tempdir().unwrap();
2331 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2332 assert!(ws.repos_dir().is_dir());
2333 }
2334
2335 #[test]
2336 fn empty_list() {
2337 let dir = tempfile::tempdir().unwrap();
2338 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2339 let out = ws.repo_management(None, false, false, false, None);
2340 assert!(out.contains("No repos cloned yet"));
2341 }
2342
2343 #[test]
2344 fn invalid_repo_name_rejected() {
2345 let dir = tempfile::tempdir().unwrap();
2346 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2347 let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
2348 assert!(out.contains("Invalid repo name"));
2349 }
2350
2351 #[test]
2352 fn delete_unknown() {
2353 let dir = tempfile::tempdir().unwrap();
2354 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2355 let out = ws.repo_management(Some("nope/none"), true, false, false, None);
2356 assert!(out.contains("Nothing to delete"));
2357 }
2358
2359 #[test]
2360 fn iso_round_trip() {
2361 let now = SystemTime::now()
2362 .duration_since(SystemTime::UNIX_EPOCH)
2363 .unwrap()
2364 .as_secs();
2365 let s = chrono_lite::format_secs(now);
2366 let back = chrono_lite::parse_secs(&s).unwrap();
2367 assert_eq!(now, back);
2368 }
2369
2370 #[test]
2371 fn last_built_sha_round_trip() {
2372 let dir = tempfile::tempdir().unwrap();
2373 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2374 ws.bump_access("acme/widgets", "cloned");
2376 assert_eq!(ws.last_built_sha("acme/widgets"), None);
2377 ws.record_built("acme/widgets", "abc1234deadbeef", None);
2378 assert_eq!(
2379 ws.last_built_sha("acme/widgets").as_deref(),
2380 Some("abc1234deadbeef")
2381 );
2382 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2384 assert_eq!(
2385 ws2.last_built_sha("acme/widgets").as_deref(),
2386 Some("abc1234deadbeef")
2387 );
2388 }
2389
2390 #[test]
2391 fn inventory_loads_legacy_entries_without_sha_field() {
2392 let dir = tempfile::tempdir().unwrap();
2393 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2394 let legacy = r#"{
2396 "old/repo": {
2397 "cloned_at": "2024-01-01T00:00:00",
2398 "last_accessed": "2024-01-01T00:00:00",
2399 "access_count": 5,
2400 "stale": false
2401 }
2402 }"#;
2403 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
2404 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2406 assert_eq!(ws2.last_built_sha("old/repo"), None);
2407 let _ = ws;
2408 }
2409
2410 #[test]
2411 fn auto_rebuild_gate_skips_when_sha_matches() {
2412 use std::sync::atomic::{AtomicUsize, Ordering};
2413 let dir = tempfile::tempdir().unwrap();
2414 let calls = Arc::new(AtomicUsize::new(0));
2415 let calls_h = calls.clone();
2416 let hook: PostActivateHook = Arc::new(move |_path, _name| {
2417 calls_h.fetch_add(1, Ordering::SeqCst);
2418 Ok(())
2419 });
2420 let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
2426 ws.bump_access("acme/widgets", "cloned");
2428 ws.record_built("acme/widgets", "sha_one", None);
2429 assert_eq!(
2430 ws.last_built_sha("acme/widgets").as_deref(),
2431 Some("sha_one")
2432 );
2433 ws.record_built("acme/widgets", "sha_one", None);
2436 assert_eq!(
2437 ws.last_built_sha("acme/widgets").as_deref(),
2438 Some("sha_one")
2439 );
2440 assert_eq!(calls.load(Ordering::SeqCst), 0);
2443 }
2444
2445 #[test]
2446 fn local_workspace_binds_root_immediately() {
2447 let dir = tempfile::tempdir().unwrap();
2448 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2449 assert_eq!(ws.kind(), WorkspaceKind::Local);
2450 assert!(ws.active_repo_path().is_some());
2451 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
2452 }
2453
2454 #[test]
2455 fn local_workspace_rejects_github_ops() {
2456 let dir = tempfile::tempdir().unwrap();
2457 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2458 let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
2459 assert!(out.contains("does not accept a repo name"));
2460 let out = ws.repo_management(None, true, false, false, None);
2461 assert!(out.contains("does not support `delete`"));
2462 }
2463
2464 #[test]
2465 fn local_workspace_update_rebuilds() {
2466 use std::sync::atomic::{AtomicUsize, Ordering};
2467 let dir = tempfile::tempdir().unwrap();
2468 std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
2470 let calls = Arc::new(AtomicUsize::new(0));
2471 let calls_h = calls.clone();
2472 let hook: PostActivateHook = Arc::new(move |_p, _n| {
2473 calls_h.fetch_add(1, Ordering::SeqCst);
2474 Ok(())
2475 });
2476 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
2477 let _ = ws.repo_management(None, false, true, false, None);
2479 assert_eq!(calls.load(Ordering::SeqCst), 1);
2480 let out = ws.repo_management(None, false, true, false, None);
2482 assert_eq!(
2483 calls.load(Ordering::SeqCst),
2484 1,
2485 "auto-rebuild gate must skip"
2486 );
2487 assert!(out.contains("build skipped"));
2488 }
2489
2490 #[test]
2491 fn parses_github_remote_forms() {
2492 assert_eq!(
2493 parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
2494 Some("kkollsga/kglite")
2495 );
2496 assert_eq!(
2497 parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
2498 Some("kkollsga/kglite")
2499 );
2500 assert_eq!(
2502 parse_github_remote("https://github.com/acme/widget/").as_deref(),
2503 Some("acme/widget")
2504 );
2505 assert_eq!(
2506 parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
2507 Some("acme/widget")
2508 );
2509 assert_eq!(
2511 parse_github_remote("https://gitlab.com/acme/widget.git"),
2512 None
2513 );
2514 assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
2515 assert_eq!(parse_github_remote("not a url"), None);
2516 }
2517
2518 #[test]
2519 fn local_default_github_repo_uses_origin_remote() {
2520 let dir = tempfile::tempdir().unwrap();
2521 let root = dir.path();
2522 let git = |args: &[&str]| {
2525 Command::new("git")
2526 .arg("-C")
2527 .arg(root)
2528 .args(args)
2529 .output()
2530 .unwrap()
2531 };
2532 if !git(&["init"]).status.success() {
2533 return;
2535 }
2536 git(&[
2537 "remote",
2538 "add",
2539 "origin",
2540 "https://github.com/acme/widget.git",
2541 ]);
2542 let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
2543 assert_eq!(
2544 ws.default_github_repo().as_deref(),
2545 Some("acme/widget"),
2546 "local default repo must come from the origin remote, not the inventory key"
2547 );
2548 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
2550 }
2551
2552 #[test]
2553 fn local_default_github_repo_none_without_remote() {
2554 let dir = tempfile::tempdir().unwrap();
2555 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2556 let def = ws.default_github_repo();
2558 assert!(
2559 def.is_none(),
2560 "expected None for a non-git local root, got {def:?}"
2561 );
2562 }
2563
2564 #[test]
2565 fn set_root_dir_only_in_local_mode() {
2566 let dir = tempfile::tempdir().unwrap();
2567 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2568 let out = ws.set_root_dir(dir.path(), None);
2569 assert!(out.contains("only valid in local-workspace"));
2570 }
2571
2572 #[test]
2573 fn update_with_no_active_repo() {
2574 let dir = tempfile::tempdir().unwrap();
2575 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2576 let out = ws.repo_management(None, false, true, false, None);
2577 assert!(out.contains("No active repository"));
2578 }
2579
2580 #[test]
2581 fn set_root_dir_updates_active_path() {
2582 let dir = tempfile::tempdir().unwrap();
2583 let child = dir.path().join("child");
2584 std::fs::create_dir_all(&child).unwrap();
2585 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2586 let _ = ws.set_root_dir(&child, None);
2587 assert_eq!(
2588 ws.active_repo_path().unwrap(),
2589 child.canonicalize().unwrap(),
2590 "set_root_dir didn't update active_repo_path"
2591 );
2592 }
2593
2594 #[test]
2595 fn set_root_dir_post_activate_fires_against_new_root() {
2596 let dir = tempfile::tempdir().unwrap();
2597 let child = dir.path().join("child");
2598 std::fs::create_dir_all(&child).unwrap();
2599 std::fs::write(child.join("a.txt"), b"hi").unwrap();
2600 let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
2601 let seen = seen_path.clone();
2602 let hook: PostActivateHook = Arc::new(move |p, _n| {
2603 *seen.lock().unwrap() = Some(p.to_path_buf());
2604 Ok(())
2605 });
2606 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
2607 let _ = ws.set_root_dir(&child, None);
2608 assert_eq!(
2609 seen_path.lock().unwrap().clone().unwrap(),
2610 child.canonicalize().unwrap(),
2611 "post_activate hook saw the wrong root after set_root_dir"
2612 );
2613 }
2614
2615 fn sandbox_layout() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
2621 let td = tempfile::tempdir().unwrap();
2622 let base = td.path().canonicalize().unwrap();
2623 let sandbox = base.join("sandbox");
2624 let inside = sandbox.join("child");
2625 let outside = base.join("outside");
2626 std::fs::create_dir_all(&inside).unwrap();
2627 std::fs::create_dir_all(&outside).unwrap();
2628 (td, sandbox, inside, outside)
2629 }
2630
2631 #[test]
2632 fn set_root_dir_outside_sandbox_root_rejected_and_active_root_unchanged() {
2633 let (_td, sandbox, _inside, outside) = sandbox_layout();
2634 let ws = Workspace::open_local(sandbox.clone(), None)
2635 .unwrap()
2636 .with_sandbox_root(&sandbox)
2637 .unwrap();
2638 let before = ws.active_repo_path().unwrap();
2639 assert_eq!(before, sandbox);
2640
2641 let out = ws.set_root_dir(&outside, None);
2642 assert!(
2643 out.contains("sandbox_root") && out.contains(&sandbox.display().to_string()),
2644 "rejection must name the boundary it violated, got: {out}"
2645 );
2646 assert_eq!(
2648 ws.active_repo_path().unwrap(),
2649 before,
2650 "a rejected swap must leave the active root untouched"
2651 );
2652 }
2653
2654 #[test]
2655 fn set_root_dir_inside_sandbox_root_activates() {
2656 let (_td, sandbox, inside, _outside) = sandbox_layout();
2657 let ws = Workspace::open_local(sandbox.clone(), None)
2658 .unwrap()
2659 .with_sandbox_root(&sandbox)
2660 .unwrap();
2661 let out = ws.set_root_dir(&inside, None);
2662 assert_eq!(
2663 ws.active_repo_path().unwrap(),
2664 inside,
2665 "a target inside the boundary must activate; set_root_dir said: {out}"
2666 );
2667 }
2668
2669 #[test]
2670 fn set_root_dir_dotdot_traversal_out_of_sandbox_rejected() {
2671 let (_td, sandbox, inside, outside) = sandbox_layout();
2672 let ws = Workspace::open_local(sandbox.clone(), None)
2673 .unwrap()
2674 .with_sandbox_root(&sandbox)
2675 .unwrap();
2676 let traversal = inside.join("..").join("..").join("outside");
2679 assert!(
2680 traversal.starts_with(&sandbox),
2681 "test is meaningless unless the raw path looks contained"
2682 );
2683 let out = ws.set_root_dir(&traversal, None);
2684 assert!(
2685 out.contains("sandbox_root"),
2686 "`..` escape must be rejected, got: {out}"
2687 );
2688 assert_eq!(ws.active_repo_path().unwrap(), sandbox);
2689 assert_ne!(ws.active_repo_path().unwrap(), outside);
2690 }
2691
2692 #[cfg(unix)]
2693 #[test]
2694 fn set_root_dir_symlink_out_of_sandbox_rejected() {
2695 let (_td, sandbox, _inside, outside) = sandbox_layout();
2696 let link = sandbox.join("escape-hatch");
2697 std::os::unix::fs::symlink(&outside, &link).unwrap();
2698 let ws = Workspace::open_local(sandbox.clone(), None)
2699 .unwrap()
2700 .with_sandbox_root(&sandbox)
2701 .unwrap();
2702 assert!(
2703 link.starts_with(&sandbox),
2704 "test is meaningless unless the raw path looks contained"
2705 );
2706 let out = ws.set_root_dir(&link, None);
2707 assert!(
2708 out.contains("sandbox_root"),
2709 "symlink escape must be rejected, got: {out}"
2710 );
2711 assert_eq!(ws.active_repo_path().unwrap(), sandbox);
2712 }
2713
2714 #[test]
2715 fn no_sandbox_root_configured_keeps_swaps_unbounded() {
2716 let (_td, sandbox, _inside, outside) = sandbox_layout();
2719 let ws = Workspace::open_local(sandbox, None).unwrap();
2720 let out = ws.set_root_dir(&outside, None);
2721 assert_eq!(
2722 ws.active_repo_path().unwrap(),
2723 outside,
2724 "unbounded default broken; set_root_dir said: {out}"
2725 );
2726 }
2727
2728 #[test]
2729 fn with_sandbox_root_rejects_active_root_outside_the_boundary() {
2730 let (_td, sandbox, _inside, outside) = sandbox_layout();
2733 let err = Workspace::open_local(outside.clone(), None)
2734 .unwrap()
2735 .with_sandbox_root(&sandbox)
2736 .map(|_| ())
2737 .expect_err("root outside the boundary must not boot");
2738 let msg = err.to_string();
2739 assert!(
2740 msg.contains(&sandbox.display().to_string())
2741 && msg.contains(&outside.display().to_string()),
2742 "boot error must name both the root and the boundary, got: {msg}"
2743 );
2744 }
2745
2746 #[test]
2747 fn with_sandbox_root_accepts_root_equal_to_the_boundary() {
2748 let (_td, sandbox, inside, _outside) = sandbox_layout();
2749 assert!(Workspace::open_local(sandbox.clone(), None)
2750 .unwrap()
2751 .with_sandbox_root(&sandbox)
2752 .is_ok());
2753 assert!(Workspace::open_local(inside, None)
2755 .unwrap()
2756 .with_sandbox_root(&sandbox)
2757 .is_ok());
2758 }
2759
2760 #[test]
2761 fn with_sandbox_root_rejects_github_workspaces_and_missing_dirs() {
2762 let (_td, sandbox, _inside, _outside) = sandbox_layout();
2763 let gh = Workspace::open(sandbox.join("gh"), 7, None).unwrap();
2764 assert!(
2765 gh.with_sandbox_root(&sandbox)
2766 .map(|_| ())
2767 .unwrap_err()
2768 .to_string()
2769 .contains("only valid for local"),
2770 "sandbox_root on a github workspace must be a loud error"
2771 );
2772 let missing = sandbox.join("nope");
2773 assert!(Workspace::open_local(sandbox, None)
2774 .unwrap()
2775 .with_sandbox_root(&missing)
2776 .is_err());
2777 }
2778
2779 #[test]
2784 fn unanchored_boot_binds_nothing_and_creates_nothing() {
2785 let td = tempfile::tempdir().unwrap();
2786 let base = td.path().canonicalize().unwrap();
2787 let ws = Workspace::open_local_unanchored(None).unwrap();
2788 assert!(ws.active_repo_path().is_none());
2789 assert!(ws.active_repo_name().is_none());
2790 assert_eq!(ws.root_ownership(), RootOwnership::Unowned);
2791 assert!(!ws.adopts_client_roots(), "the knob is opt-in");
2792 assert!(
2793 !base.join(".mcp-workspace").exists(),
2794 "an unanchored boot must not create an inventory dir anywhere"
2795 );
2796 }
2797
2798 #[test]
2799 fn open_local_is_operator_owned_from_the_start() {
2800 let td = tempfile::tempdir().unwrap();
2801 let ws = Workspace::open_local(td.path().to_path_buf(), None).unwrap();
2802 assert_eq!(
2803 ws.root_ownership(),
2804 RootOwnership::Operator,
2805 "a configured root is the operator's, which is what makes adoption fallback-only"
2806 );
2807 }
2808
2809 #[test]
2810 fn adopt_client_root_activates_and_defers_the_inventory_dir() {
2811 let td = tempfile::tempdir().unwrap();
2812 let base = td.path().canonicalize().unwrap();
2813 let project = base.join("project");
2814 std::fs::create_dir_all(&project).unwrap();
2815 let ws = Workspace::open_local_unanchored(None).unwrap();
2816
2817 ws.adopt_client_root(&project).unwrap();
2818
2819 assert_eq!(ws.active_repo_path().as_deref(), Some(project.as_path()));
2820 assert_eq!(ws.root_ownership(), RootOwnership::Adopted);
2821 assert!(
2822 project.join(".mcp-workspace").is_dir(),
2823 "the first activation must create the deferred inventory dir"
2824 );
2825 assert_eq!(ws.workspace_dir(), project.as_path());
2826 }
2827
2828 #[test]
2829 fn the_inventory_home_is_fixed_at_the_first_adoption() {
2830 let td = tempfile::tempdir().unwrap();
2831 let base = td.path().canonicalize().unwrap();
2832 let first = base.join("first");
2833 let second = base.join("second");
2834 std::fs::create_dir_all(&first).unwrap();
2835 std::fs::create_dir_all(&second).unwrap();
2836 let ws = Workspace::open_local_unanchored(None).unwrap();
2837 ws.adopt_client_root(&first).unwrap();
2838 ws.set_root_dir(&second, None);
2839
2840 assert_eq!(ws.active_repo_path().as_deref(), Some(second.as_path()));
2841 assert_eq!(
2842 ws.workspace_dir(),
2843 first.as_path(),
2844 "the inventory must survive later root swaps, exactly as it does after open_local"
2845 );
2846 assert!(!second.join(".mcp-workspace").exists());
2847 }
2848
2849 #[test]
2850 fn adoption_is_refused_once_the_operator_owns_the_root() {
2851 let td = tempfile::tempdir().unwrap();
2852 let base = td.path().canonicalize().unwrap();
2853 let configured = base.join("configured");
2854 let advertised = base.join("advertised");
2855 std::fs::create_dir_all(&configured).unwrap();
2856 std::fs::create_dir_all(&advertised).unwrap();
2857
2858 let ws = Workspace::open_local(configured.clone(), None).unwrap();
2859 let err = ws.adopt_client_root(&advertised).unwrap_err();
2860 assert!(err.contains("operator"), "unexpected reason: {err}");
2861 assert_eq!(
2862 ws.active_repo_path().as_deref(),
2863 Some(configured.as_path()),
2864 "a refused adoption must not touch the active root"
2865 );
2866 }
2867
2868 #[test]
2869 fn set_root_dir_claims_ownership_permanently() {
2870 let td = tempfile::tempdir().unwrap();
2871 let base = td.path().canonicalize().unwrap();
2872 let adopted = base.join("adopted");
2873 let operator = base.join("operator");
2874 let later = base.join("later");
2875 for d in [&adopted, &operator, &later] {
2876 std::fs::create_dir_all(d).unwrap();
2877 }
2878 let ws = Workspace::open_local_unanchored(None).unwrap();
2879 ws.adopt_client_root(&adopted).unwrap();
2880 assert_eq!(ws.root_ownership(), RootOwnership::Adopted);
2881
2882 ws.set_root_dir(&operator, None);
2883 assert_eq!(ws.root_ownership(), RootOwnership::Operator);
2884
2885 assert!(ws.adopt_client_root(&later).is_err());
2887 assert_eq!(ws.active_repo_path().as_deref(), Some(operator.as_path()));
2888 }
2889
2890 #[test]
2891 fn a_failed_set_root_dir_does_not_claim_ownership() {
2892 let (_td, sandbox, inside, outside) = sandbox_layout();
2893 let ws = Workspace::open_local_unanchored(None)
2894 .unwrap()
2895 .with_sandbox_root(&sandbox)
2896 .unwrap();
2897 let msg = ws.set_root_dir(&outside, None);
2898 assert!(msg.contains("sandbox_root"), "unexpected message: {msg}");
2899 assert_eq!(
2900 ws.root_ownership(),
2901 RootOwnership::Unowned,
2902 "a rejected swap must not lock out adoption"
2903 );
2904 ws.adopt_client_root(&inside).unwrap();
2906 assert_eq!(ws.root_ownership(), RootOwnership::Adopted);
2907 }
2908
2909 #[test]
2910 fn adoption_goes_through_the_same_containment_check_as_set_root_dir() {
2911 let (_td, sandbox, inside, outside) = sandbox_layout();
2912 let ws = Workspace::open_local_unanchored(None)
2913 .unwrap()
2914 .with_sandbox_root(&sandbox)
2915 .unwrap();
2916
2917 let err = ws.adopt_client_root(&outside).unwrap_err();
2918 assert!(
2919 err.contains("sandbox_root") && err.contains(&sandbox.display().to_string()),
2920 "the rejection must name the boundary it violated: {err}"
2921 );
2922 assert!(
2923 ws.active_repo_path().is_none(),
2924 "a rejected adoption must leave the server unanchored"
2925 );
2926 assert_eq!(ws.root_ownership(), RootOwnership::Unowned);
2927
2928 ws.adopt_client_root(&inside).unwrap();
2929 assert_eq!(ws.active_repo_path().as_deref(), Some(inside.as_path()));
2930 }
2931
2932 #[test]
2933 fn adoption_rejects_a_dotdot_escape_from_the_sandbox() {
2934 let (_td, sandbox, inside, outside) = sandbox_layout();
2935 let ws = Workspace::open_local_unanchored(None)
2936 .unwrap()
2937 .with_sandbox_root(&sandbox)
2938 .unwrap();
2939 let traversal = inside.join("..").join("..").join("outside");
2942 assert!(ws.adopt_client_root(&traversal).is_err());
2943 assert!(ws.active_repo_path().is_none());
2944 let _ = outside;
2945 }
2946
2947 #[test]
2948 fn unanchored_refresh_before_adoption_is_a_clean_error() {
2949 let ws = Workspace::open_local_unanchored(None).unwrap();
2950 let out = ws.repo_management(None, false, true, false, None);
2951 assert_eq!(out, "No active local root.", "unexpected output: {out}");
2956 assert!(ws.active_repo_path().is_none());
2957 }
2958
2959 #[test]
2964 fn a_failed_first_adoption_writes_nothing_into_the_clients_root() {
2965 let td = tempfile::tempdir().unwrap();
2966 let base = td.path().canonicalize().unwrap();
2967 let rejected = base.join("rejected");
2968 let good = base.join("good");
2969 std::fs::create_dir_all(&rejected).unwrap();
2970 std::fs::create_dir_all(&good).unwrap();
2971
2972 let hook: PostActivateHook = {
2973 let rejected = rejected.clone();
2974 Arc::new(move |path, _name| {
2975 if path == rejected {
2976 anyhow::bail!("builder refused this root");
2977 }
2978 Ok(())
2979 })
2980 };
2981 let ws = Workspace::open_local_unanchored(Some(hook)).unwrap();
2982
2983 assert!(
2984 ws.adopt_client_root(&rejected).is_err(),
2985 "the hook refused, so the adoption must fail"
2986 );
2987 assert!(
2988 !rejected.join(".mcp-workspace").exists(),
2989 "a failed adoption must not create a directory inside the client's root"
2990 );
2991 assert!(ws.active_repo_path().is_none(), "nothing activated");
2992 assert_eq!(
2993 ws.workspace_dir(),
2994 Path::new(""),
2995 "a failed attempt must not fix the inventory anchor"
2996 );
2997
2998 ws.adopt_client_root(&good).unwrap();
3001 assert_eq!(ws.workspace_dir(), good.as_path());
3002 assert!(good.join(".mcp-workspace").is_dir());
3003 assert!(
3004 good.join(".mcp-workspace").join("inventory.json").is_file(),
3005 "the anchoring activation must still write its inventory receipt"
3006 );
3007 assert!(!rejected.join(".mcp-workspace").exists());
3008 }
3009
3010 #[test]
3016 fn a_non_local_swap_names_the_caller_that_attempted_it() {
3017 let dir = tempfile::tempdir().unwrap();
3018 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
3019 let err = ws
3020 .swap_root(dir.path(), None, "adopt_client_root")
3021 .unwrap_err();
3022 assert_eq!(
3023 err, "adopt_client_root is only valid in local-workspace mode.",
3024 "the message must name the caller: {err}"
3025 );
3026 }
3027
3028 #[derive(Default)]
3031 struct Gate {
3032 open: Mutex<bool>,
3033 cv: std::sync::Condvar,
3034 }
3035
3036 impl Gate {
3037 fn open(&self) {
3038 *self.open.lock().unwrap() = true;
3039 self.cv.notify_all();
3040 }
3041
3042 fn wait(&self) {
3043 let mut open = self.open.lock().unwrap();
3044 while !*open {
3045 open = self.cv.wait(open).unwrap();
3046 }
3047 }
3048
3049 fn wait_until(&self, limit: std::time::Duration) {
3052 let open = self.open.lock().unwrap();
3053 let _ = self
3054 .cv
3055 .wait_timeout_while(open, limit, |open| !*open)
3056 .unwrap();
3057 }
3058 }
3059
3060 #[test]
3085 fn an_adoption_cannot_displace_a_concurrent_operator_swap() {
3086 let td = tempfile::tempdir().unwrap();
3087 let base = td.path().canonicalize().unwrap();
3088 let client_root = base.join("client");
3089 let operator_root = base.join("operator");
3090 std::fs::create_dir_all(&client_root).unwrap();
3091 std::fs::create_dir_all(&operator_root).unwrap();
3092
3093 let operator_in_hook = Arc::new(Gate::default());
3094 let adoption_in_hook = Arc::new(Gate::default());
3095 let hook: ActivationTransactionHook = {
3096 let client_root = client_root.clone();
3097 let operator_in_hook = operator_in_hook.clone();
3098 let adoption_in_hook = adoption_in_hook.clone();
3099 Arc::new(move |request| {
3100 if request.path() == client_root {
3101 adoption_in_hook.open();
3102 } else {
3103 operator_in_hook.open();
3104 adoption_in_hook.wait_until(std::time::Duration::from_millis(250));
3105 }
3106 Ok(PreparedActivation::summary(None))
3107 })
3108 };
3109 let ws = Workspace::open_local_unanchored(None)
3111 .unwrap()
3112 .with_activation_transaction(hook);
3113
3114 let operator = {
3115 let ws = ws.clone();
3116 let target = operator_root.clone();
3117 std::thread::spawn(move || ws.set_root_dir(&target, None))
3118 };
3119 operator_in_hook.wait();
3121 let adoption = {
3122 let ws = ws.clone();
3123 let target = client_root.clone();
3124 std::thread::spawn(move || ws.adopt_client_root(&target))
3125 };
3126
3127 let operator_out = operator.join().unwrap();
3128 let adoption_out = adoption.join().unwrap();
3129
3130 assert_eq!(
3131 ws.active_repo_path().as_deref(),
3132 Some(operator_root.as_path()),
3133 "a client root displaced the operator's swap \
3134 (operator said: {operator_out}; adoption said: {adoption_out:?})"
3135 );
3136 assert_eq!(
3137 ws.root_ownership(),
3138 RootOwnership::Operator,
3139 "the surviving root must also be flagged as the operator's"
3140 );
3141 assert!(
3142 adoption_out.is_err(),
3143 "the adoption ran second and must have been refused: {adoption_out:?}"
3144 );
3145 }
3146
3147 #[allow(clippy::type_complexity)]
3156 fn adopted_workspace_with_gated_builds(
3157 base: &Path,
3158 ) -> (
3159 Workspace,
3160 PathBuf,
3161 PathBuf,
3162 (Arc<Gate>, Arc<Gate>),
3163 (Arc<Gate>, Arc<Gate>),
3164 ) {
3165 let client_root = base.join("client");
3166 let operator_root = base.join("operator");
3167 std::fs::create_dir_all(&client_root).unwrap();
3168 std::fs::create_dir_all(&operator_root).unwrap();
3169
3170 let operator_gates = (Arc::new(Gate::default()), Arc::new(Gate::default()));
3171 let refresh_gates = (Arc::new(Gate::default()), Arc::new(Gate::default()));
3172 let hook: ActivationTransactionHook = {
3173 let operator_root = operator_root.clone();
3174 let (operator_in_hook, release_operator) = operator_gates.clone();
3175 let (refresh_in_hook, release_refresh) = refresh_gates.clone();
3176 let adopted = Arc::new(Mutex::new(false));
3177 Arc::new(move |request| {
3178 if request.path() == operator_root {
3179 operator_in_hook.open();
3180 release_operator.wait();
3181 } else {
3182 let mut adopted = adopted.lock().unwrap();
3186 if *adopted {
3187 refresh_in_hook.open();
3188 release_refresh.wait();
3189 }
3190 *adopted = true;
3191 }
3192 Ok(PreparedActivation::summary(None))
3193 })
3194 };
3195 let ws = Workspace::open_local_unanchored(None)
3196 .unwrap()
3197 .with_activation_transaction(hook);
3198 ws.adopt_client_root(&client_root).unwrap();
3199 assert_eq!(ws.root_ownership(), RootOwnership::Adopted);
3200 (
3201 ws,
3202 client_root,
3203 operator_root,
3204 operator_gates,
3205 refresh_gates,
3206 )
3207 }
3208
3209 #[test]
3226 fn a_refresh_cannot_cancel_an_in_flight_operator_root_swap() {
3227 let td = tempfile::tempdir().unwrap();
3228 let base = td.path().canonicalize().unwrap();
3229 let (ws, client_root, operator_root, (operator_in_hook, release_operator), refresh_gates) =
3230 adopted_workspace_with_gated_builds(&base);
3231 refresh_gates.1.open();
3237
3238 let operator = {
3239 let ws = ws.clone();
3240 let target = operator_root.clone();
3241 std::thread::spawn(move || ws.set_root_dir(&target, None))
3242 };
3243 operator_in_hook.wait();
3245 let refresh_out = ws.repo_management(None, false, true, false, None);
3247 release_operator.open();
3248 let operator_out = operator.join().unwrap();
3249
3250 assert_eq!(
3251 ws.active_repo_path().as_deref(),
3252 Some(operator_root.as_path()),
3253 "a refresh of the adopted root cancelled the operator's swap \
3254 (operator said: {operator_out}; refresh said: {refresh_out})"
3255 );
3256 assert_eq!(
3257 ws.root_ownership(),
3258 RootOwnership::Operator,
3259 "the active root and the ownership flag must name the same party"
3260 );
3261 assert!(
3262 !operator_out.contains("superseded"),
3263 "a refresh must not supersede a root swap: {operator_out}"
3264 );
3265 assert!(
3266 operator_out.contains(&operator_root.display().to_string()),
3267 "the operator's swap must report the root it committed: {operator_out}"
3268 );
3269 assert!(
3272 refresh_out.contains(&client_root.display().to_string()),
3273 "the refresh rebuilt the binding it found: {refresh_out}"
3274 );
3275 }
3276
3277 #[test]
3293 fn a_refresh_cannot_revert_a_root_swap_that_already_reported_success() {
3294 let td = tempfile::tempdir().unwrap();
3295 let base = td.path().canonicalize().unwrap();
3296 let (
3297 ws,
3298 client_root,
3299 operator_root,
3300 (operator_in_hook, release_operator),
3301 (refresh_in_hook, release_refresh),
3302 ) = adopted_workspace_with_gated_builds(&base);
3303
3304 let operator = {
3305 let ws = ws.clone();
3306 let target = operator_root.clone();
3307 std::thread::spawn(move || ws.set_root_dir(&target, None))
3308 };
3309 operator_in_hook.wait();
3310 let refresh = {
3314 let ws = ws.clone();
3315 std::thread::spawn(move || ws.repo_management(None, false, true, true, None))
3316 };
3317 refresh_in_hook.wait();
3318
3319 release_operator.open();
3320 let operator_out = operator.join().unwrap();
3321 let reported_root = ws.active_repo_path();
3324 release_refresh.open();
3325 let refresh_out = refresh.join().unwrap();
3326
3327 assert_eq!(
3328 reported_root.as_deref(),
3329 Some(operator_root.as_path()),
3330 "the operator's swap must commit even though a refresh holds a \
3331 newer generation (operator said: {operator_out})"
3332 );
3333 assert!(
3334 !operator_out.contains("superseded")
3335 && operator_out.contains(&operator_root.display().to_string()),
3336 "the operator's swap must report the root it committed: {operator_out}"
3337 );
3338 assert_eq!(
3339 ws.active_repo_path().as_deref(),
3340 Some(operator_root.as_path()),
3341 "a refresh reverted a root swap that had already reported success \
3342 (refresh said: {refresh_out})"
3343 );
3344 assert_eq!(
3345 ws.root_ownership(),
3346 RootOwnership::Operator,
3347 "the active root and the ownership flag must name the same party"
3348 );
3349 assert!(
3350 refresh_out.contains("abandoned")
3351 && refresh_out.contains(&operator_root.display().to_string())
3352 && !refresh_out.contains(&format!("at {}", client_root.display())),
3353 "the refresh must say it was abandoned, and name what displaced it: {refresh_out}"
3354 );
3355 }
3356
3357 #[test]
3363 fn an_operator_swap_wins_when_an_adoption_is_already_mid_activation() {
3364 let td = tempfile::tempdir().unwrap();
3365 let base = td.path().canonicalize().unwrap();
3366 let client_root = base.join("client");
3367 let operator_root = base.join("operator");
3368 std::fs::create_dir_all(&client_root).unwrap();
3369 std::fs::create_dir_all(&operator_root).unwrap();
3370
3371 let adoption_in_hook = Arc::new(Gate::default());
3372 let release_adoption = Arc::new(Gate::default());
3373 let hook: ActivationTransactionHook = {
3374 let client_root = client_root.clone();
3375 let adoption_in_hook = adoption_in_hook.clone();
3376 let release_adoption = release_adoption.clone();
3377 Arc::new(move |request| {
3378 if request.path() == client_root {
3379 adoption_in_hook.open();
3380 release_adoption.wait();
3381 }
3382 Ok(PreparedActivation::summary(None))
3383 })
3384 };
3385 let ws = Workspace::open_local_unanchored(None)
3386 .unwrap()
3387 .with_activation_transaction(hook);
3388
3389 let adoption = {
3390 let ws = ws.clone();
3391 let target = client_root.clone();
3392 std::thread::spawn(move || ws.adopt_client_root(&target))
3393 };
3394 adoption_in_hook.wait();
3395 let operator = {
3396 let ws = ws.clone();
3397 let target = operator_root.clone();
3398 std::thread::spawn(move || ws.set_root_dir(&target, None))
3399 };
3400 release_adoption.open();
3401
3402 let adoption_out = adoption.join().unwrap();
3403 let operator_out = operator.join().unwrap();
3404
3405 assert_eq!(
3406 ws.active_repo_path().as_deref(),
3407 Some(operator_root.as_path()),
3408 "the operator's swap must survive an in-flight adoption \
3409 (adoption said: {adoption_out:?}; operator said: {operator_out})"
3410 );
3411 assert_eq!(ws.root_ownership(), RootOwnership::Operator);
3412 }
3413
3414 #[test]
3415 fn activation_summary_appended_to_activate_message() {
3416 let dir = tempfile::tempdir().unwrap();
3417 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
3418 let summary: ActivationSummaryHook =
3419 Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
3420 let ws = Workspace::open_local(dir.path().to_path_buf(), None)
3421 .unwrap()
3422 .with_activation_summary(summary);
3423 let out = ws.repo_management(None, false, true, false, None);
3424 assert!(
3425 out.contains("Graph ready: 3 Functions."),
3426 "activation message should include the summary; got: {out}"
3427 );
3428 }
3429
3430 #[test]
3431 fn activation_summary_absent_when_not_configured() {
3432 let dir = tempfile::tempdir().unwrap();
3433 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
3434 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
3435 let out = ws.repo_management(None, false, true, false, None);
3436 assert!(!out.contains("Graph ready"));
3437 assert!(
3438 out.contains(" at "),
3439 "expected the terse default message; got: {out}"
3440 );
3441 }
3442
3443 #[test]
3444 fn hook_fires_once_per_process_even_when_sha_matches() {
3445 use std::sync::atomic::{AtomicUsize, Ordering};
3446 let dir = tempfile::tempdir().unwrap();
3450 std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
3451
3452 let calls = Arc::new(AtomicUsize::new(0));
3453 let make_hook = || -> PostActivateHook {
3454 let c = calls.clone();
3455 Arc::new(move |_p, _n| {
3456 c.fetch_add(1, Ordering::SeqCst);
3457 Ok(())
3458 })
3459 };
3460
3461 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
3463 let _ = ws.repo_management(None, false, true, false, None);
3465 assert_eq!(
3466 calls.load(Ordering::SeqCst),
3467 1,
3468 "first activate must hydrate"
3469 );
3470 let out = ws.repo_management(None, false, true, false, None);
3472 assert_eq!(
3473 calls.load(Ordering::SeqCst),
3474 1,
3475 "repeat activate in same process must skip the hook"
3476 );
3477 assert!(
3478 out.contains("build skipped"),
3479 "expected skip suffix, got: {out}"
3480 );
3481 drop(ws);
3482
3483 let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
3488 assert!(
3489 ws2.last_built_sha(&ws2.active_repo_name().unwrap())
3490 .is_some(),
3491 "sanity: last_built_sha should survive the restart"
3492 );
3493 let _ = ws2.repo_management(None, false, true, false, None);
3494 assert_eq!(
3495 calls.load(Ordering::SeqCst),
3496 2,
3497 "fresh process must re-fire the hook even when the SHA matches"
3498 );
3499 }
3500
3501 #[test]
3502 fn a_b_a_swap_rebuilds_intervening_root() {
3503 use std::sync::atomic::{AtomicUsize, Ordering};
3509 let root = tempfile::tempdir().unwrap();
3510 let a = root.path().join("projA");
3511 let b = root.path().join("projB");
3512 std::fs::create_dir_all(&a).unwrap();
3513 std::fs::create_dir_all(&b).unwrap();
3514 std::fs::write(a.join("a.txt"), b"alpha").unwrap();
3518 std::fs::write(b.join("b.txt"), b"beta").unwrap();
3519
3520 let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
3523 let built_h = built.clone();
3524 let calls = Arc::new(AtomicUsize::new(0));
3525 let calls_h = calls.clone();
3526 let hook: PostActivateHook = Arc::new(move |p, _n| {
3527 *built_h.lock().unwrap() = Some(p.to_path_buf());
3528 calls_h.fetch_add(1, Ordering::SeqCst);
3529 Ok(())
3530 });
3531
3532 let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
3533 let _ = ws.set_root_dir(&a, None);
3536 assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
3537 assert_eq!(
3538 built.lock().unwrap().clone(),
3539 Some(a.canonicalize().unwrap())
3540 );
3541
3542 let _ = ws.set_root_dir(&b, None);
3543 assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
3544 assert_eq!(
3545 built.lock().unwrap().clone(),
3546 Some(b.canonicalize().unwrap())
3547 );
3548
3549 let out = ws.set_root_dir(&a, None);
3552 assert_eq!(
3553 calls.load(Ordering::SeqCst),
3554 3,
3555 "A→B→A must rebuild A; the intervening B overwrote the live slot"
3556 );
3557 assert!(
3558 !out.contains("build skipped"),
3559 "re-bind of a non-active root must not skip; got: {out}"
3560 );
3561 assert_eq!(
3562 built.lock().unwrap().clone(),
3563 Some(a.canonicalize().unwrap()),
3564 "after A→B→A the live slot must hold A, not B"
3565 );
3566
3567 let out = ws.set_root_dir(&a, None);
3570 assert_eq!(
3571 calls.load(Ordering::SeqCst),
3572 3,
3573 "re-binding the already-active root must skip the hook"
3574 );
3575 assert!(
3576 out.contains("build skipped"),
3577 "expected skip suffix, got: {out}"
3578 );
3579 }
3580
3581 #[test]
3582 fn transaction_slow_a_fast_b_discards_stale_build_and_keeps_responses_coherent() {
3583 use std::sync::Barrier;
3584
3585 #[derive(Debug, Clone, PartialEq, Eq)]
3586 struct Installed {
3587 id: ActivationId,
3588 path: PathBuf,
3589 }
3590
3591 let root = tempfile::tempdir().unwrap();
3592 let a = root.path().join("slow-a");
3593 let b = root.path().join("fast-b");
3594 std::fs::create_dir_all(&a).unwrap();
3595 std::fs::create_dir_all(&b).unwrap();
3596 std::fs::write(a.join("a.txt"), b"a").unwrap();
3597 std::fs::write(b.join("b.txt"), b"b").unwrap();
3598 let a = a.canonicalize().unwrap();
3599 let b = b.canonicalize().unwrap();
3600
3601 let a_entered = Arc::new(Barrier::new(2));
3602 let release_a = Arc::new(Barrier::new(2));
3603 let installed: Arc<Mutex<Option<Installed>>> = Arc::new(Mutex::new(None));
3604 let hook: ActivationTransactionHook = {
3605 let a = a.clone();
3606 let a_entered = a_entered.clone();
3607 let release_a = release_a.clone();
3608 let installed = installed.clone();
3609 Arc::new(move |request| {
3610 if request.path() == a {
3611 a_entered.wait();
3612 release_a.wait();
3613 }
3614 let product = Installed {
3615 id: request.id(),
3616 path: request.path().to_path_buf(),
3617 };
3618 let installed = installed.clone();
3619 Ok(PreparedActivation::new(move || {
3620 *installed.lock().unwrap() = Some(product.clone());
3621 Ok(Some(format!(
3622 "product {} for {}",
3623 product.id,
3624 product.path.display()
3625 )))
3626 }))
3627 })
3628 };
3629 let ws = Workspace::open_local(a.clone(), None)
3630 .unwrap()
3631 .with_activation_transaction(hook);
3632
3633 let slow_ws = ws.clone();
3634 let slow_a = a.clone();
3635 let slow = std::thread::spawn(move || slow_ws.set_root_dir(&slow_a, None));
3636 a_entered.wait();
3637
3638 let fast_ws = ws.clone();
3639 let fast_b = b.clone();
3640 let fast = std::thread::spawn(move || fast_ws.set_root_dir(&fast_b, None));
3641 let fast_out = fast.join().unwrap();
3642 release_a.wait();
3643 let slow_out = slow.join().unwrap();
3644
3645 assert!(
3646 fast_out.contains(&b.display().to_string())
3647 && fast_out.contains("product 2")
3648 && !fast_out.contains(&a.display().to_string()),
3649 "fast request response must describe only its own committed product: {fast_out}"
3650 );
3651 assert!(
3652 slow_out.contains("request 1")
3653 && slow_out.contains("superseded by request 2")
3654 && !slow_out.contains("product 1"),
3655 "stale request must report supersession, not a false activation: {slow_out}"
3656 );
3657 assert_eq!(ws.active_repo_path(), Some(b.clone()));
3658 assert_eq!(installed.lock().unwrap().as_ref().unwrap().path, b);
3659 assert_eq!(
3660 ws.inner
3661 .state
3662 .read()
3663 .unwrap()
3664 .active_build
3665 .as_ref()
3666 .unwrap()
3667 .activation_id,
3668 ActivationId(2),
3669 "latest request must own the final framework state"
3670 );
3671 }
3672
3673 #[test]
3674 fn legacy_callbacks_are_serialized_through_build_and_summary() {
3675 use std::sync::Barrier;
3676
3677 let root = tempfile::tempdir().unwrap();
3678 let a = root.path().join("slow-a");
3679 let b = root.path().join("queued-b");
3680 std::fs::create_dir_all(&a).unwrap();
3681 std::fs::create_dir_all(&b).unwrap();
3682 let a = a.canonicalize().unwrap();
3683 let b = b.canonicalize().unwrap();
3684 let a_entered = Arc::new(Barrier::new(2));
3685 let release_a = Arc::new(Barrier::new(2));
3686 let installed: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
3687 let hook: PostActivateHook = {
3688 let a = a.clone();
3689 let a_entered = a_entered.clone();
3690 let release_a = release_a.clone();
3691 let installed = installed.clone();
3692 Arc::new(move |path, _name| {
3693 if path == a {
3694 a_entered.wait();
3695 release_a.wait();
3696 }
3697 *installed.lock().unwrap() = Some(path.to_path_buf());
3698 Ok(())
3699 })
3700 };
3701 let summary: ActivationSummaryHook = {
3702 let installed = installed.clone();
3703 Arc::new(move |_path, _name| {
3704 installed
3705 .lock()
3706 .unwrap()
3707 .as_ref()
3708 .map(|path| format!("legacy product {}", path.display()))
3709 })
3710 };
3711 let ws = Workspace::open_local(a.clone(), Some(hook))
3712 .unwrap()
3713 .with_activation_summary(summary);
3714
3715 let a_ws = ws.clone();
3716 let a_root = a.clone();
3717 let a_thread = std::thread::spawn(move || a_ws.set_root_dir(&a_root, None));
3718 a_entered.wait();
3719 let b_ws = ws.clone();
3720 let b_root = b.clone();
3721 let b_thread = std::thread::spawn(move || b_ws.set_root_dir(&b_root, None));
3722 release_a.wait();
3723 let a_out = a_thread.join().unwrap();
3724 let b_out = b_thread.join().unwrap();
3725
3726 assert!(
3727 a_out.contains(&format!("legacy product {}", a.display()))
3728 && !a_out.contains(&format!("legacy product {}", b.display())),
3729 "legacy A response crossed activation products: {a_out}"
3730 );
3731 assert!(
3732 b_out.contains(&format!("legacy product {}", b.display()))
3733 && !b_out.contains(&format!("legacy product {}", a.display())),
3734 "legacy B response crossed activation products: {b_out}"
3735 );
3736 assert_eq!(ws.active_repo_path(), Some(b.clone()));
3737 assert_eq!(*installed.lock().unwrap(), Some(b));
3738 }
3739
3740 #[test]
3741 fn transaction_same_root_plain_vs_revisions_is_generation_ordered() {
3742 use std::sync::Barrier;
3743
3744 let Some((_dir, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
3745 return;
3746 };
3747 let root = root.canonicalize().unwrap();
3748 let plain_entered = Arc::new(Barrier::new(2));
3749 let release_plain = Arc::new(Barrier::new(2));
3750 let installed: Arc<Mutex<Option<(ActivationId, ActivationBuild)>>> =
3751 Arc::new(Mutex::new(None));
3752 let hook: ActivationTransactionHook = {
3753 let plain_entered = plain_entered.clone();
3754 let release_plain = release_plain.clone();
3755 let installed = installed.clone();
3756 Arc::new(move |request| {
3757 if matches!(request.build(), ActivationBuild::Plain) {
3758 plain_entered.wait();
3759 release_plain.wait();
3760 }
3761 let id = request.id();
3762 let build = request.build().clone();
3763 let installed = installed.clone();
3764 Ok(PreparedActivation::new(move || {
3765 *installed.lock().unwrap() = Some((id, build.clone()));
3766 Ok(Some(format!("installed request {id}: {build:?}")))
3767 }))
3768 })
3769 };
3770 let ws = Workspace::open_local(root.clone(), None)
3771 .unwrap()
3772 .with_activation_transaction(hook);
3773
3774 let plain_ws = ws.clone();
3775 let plain_root = root.clone();
3776 let plain = std::thread::spawn(move || plain_ws.set_root_dir(&plain_root, None));
3777 plain_entered.wait();
3778
3779 let revs_ws = ws.clone();
3780 let revs_root = root.clone();
3781 let revs = std::thread::spawn(move || {
3782 revs_ws.set_root_dir(&revs_root, Some(&RevsRequest::Count(2)))
3783 });
3784 let revs_out = revs.join().unwrap();
3785 release_plain.wait();
3786 let plain_out = plain.join().unwrap();
3787
3788 assert!(revs_out.contains("revs: v1.0.0, v2.0.0, HEAD"));
3789 assert!(revs_out.contains("installed request 2: Revisions"));
3790 assert!(plain_out.contains("superseded by request 2"));
3791 let state = ws.inner.state.read().unwrap();
3792 assert_eq!(state.active_repo_path.as_deref(), Some(root.as_path()));
3793 assert_eq!(
3794 state
3795 .active_build
3796 .as_ref()
3797 .and_then(|built| built.resolved_revs.clone()),
3798 Some(vec!["v1.0.0".into(), "v2.0.0".into(), "HEAD".into()])
3799 );
3800 assert!(matches!(
3801 installed.lock().unwrap().as_ref(),
3802 Some((ActivationId(2), ActivationBuild::Revisions(_)))
3803 ));
3804 }
3805
3806 #[test]
3807 fn transaction_current_failure_preserves_committed_source_and_product() {
3808 #[derive(Debug, Clone, PartialEq, Eq)]
3809 struct Installed(ActivationId, PathBuf);
3810
3811 let root = tempfile::tempdir().unwrap();
3812 let good = root.path().join("good");
3813 let broken = root.path().join("broken");
3814 std::fs::create_dir_all(&good).unwrap();
3815 std::fs::create_dir_all(&broken).unwrap();
3816 std::fs::write(good.join("good.txt"), b"good").unwrap();
3817 std::fs::write(broken.join("broken.txt"), b"broken").unwrap();
3818 let good = good.canonicalize().unwrap();
3819 let broken = broken.canonicalize().unwrap();
3820
3821 let installed: Arc<Mutex<Option<Installed>>> = Arc::new(Mutex::new(None));
3822 let hook: ActivationTransactionHook = {
3823 let broken = broken.clone();
3824 let installed = installed.clone();
3825 Arc::new(move |request| {
3826 if request.path() == broken {
3827 anyhow::bail!("builder rejected broken root");
3828 }
3829 let product = Installed(request.id(), request.path().to_path_buf());
3830 let installed = installed.clone();
3831 Ok(PreparedActivation::new(move || {
3832 *installed.lock().unwrap() = Some(product.clone());
3833 Ok(Some(format!("installed request {}", product.0)))
3834 }))
3835 })
3836 };
3837 let ws = Workspace::open_local(good.clone(), None)
3838 .unwrap()
3839 .with_activation_transaction(hook);
3840
3841 let good_out = ws.set_root_dir(&good, None);
3842 assert!(good_out.contains("installed request 1"));
3843 let broken_out = ws.set_root_dir(&broken, None);
3844 assert!(
3845 broken_out.contains("request 2")
3846 && broken_out.contains("failed during preparation")
3847 && broken_out.contains("builder rejected broken root"),
3848 "failure must be explicit and request-scoped: {broken_out}"
3849 );
3850 assert_eq!(ws.active_repo_path(), Some(good.clone()));
3851 assert_eq!(installed.lock().unwrap().as_ref().unwrap().1, good);
3852 }
3853
3854 #[test]
3855 fn transaction_stale_failure_reports_superseded_not_current_failure() {
3856 use std::sync::Barrier;
3857
3858 let root = tempfile::tempdir().unwrap();
3859 let slow = root.path().join("slow-failure");
3860 let fast = root.path().join("fast-success");
3861 std::fs::create_dir_all(&slow).unwrap();
3862 std::fs::create_dir_all(&fast).unwrap();
3863 let slow = slow.canonicalize().unwrap();
3864 let fast = fast.canonicalize().unwrap();
3865 let slow_entered = Arc::new(Barrier::new(2));
3866 let release_slow = Arc::new(Barrier::new(2));
3867 let hook: ActivationTransactionHook = {
3868 let slow = slow.clone();
3869 let slow_entered = slow_entered.clone();
3870 let release_slow = release_slow.clone();
3871 Arc::new(move |request| {
3872 if request.path() == slow {
3873 slow_entered.wait();
3874 release_slow.wait();
3875 anyhow::bail!("late preparation failure");
3876 }
3877 Ok(PreparedActivation::summary(Some(format!(
3878 "committed request {}",
3879 request.id()
3880 ))))
3881 })
3882 };
3883 let ws = Workspace::open_local(slow.clone(), None)
3884 .unwrap()
3885 .with_activation_transaction(hook);
3886
3887 let slow_ws = ws.clone();
3888 let slow_root = slow.clone();
3889 let slow_thread = std::thread::spawn(move || slow_ws.set_root_dir(&slow_root, None));
3890 slow_entered.wait();
3891 let fast_out = ws.set_root_dir(&fast, None);
3892 release_slow.wait();
3893 let slow_out = slow_thread.join().unwrap();
3894
3895 assert!(fast_out.contains("committed request 2"));
3896 assert!(
3897 slow_out.contains("superseded by request 2")
3898 && slow_out.contains("failed build")
3899 && !slow_out.contains("set_root_dir failed"),
3900 "a stale failure is a superseded outcome: {slow_out}"
3901 );
3902 assert_eq!(ws.active_repo_path(), Some(fast));
3903 }
3904
3905 fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
3914 let dir = tempfile::tempdir().unwrap();
3915 let root = dir.path().to_path_buf();
3916 let git = |args: &[&str]| {
3917 Command::new("git")
3918 .arg("-C")
3919 .arg(&root)
3920 .args(args)
3921 .output()
3922 .unwrap()
3923 };
3924 if !git(&["init"]).status.success() {
3925 return None; }
3927 git(&["config", "user.email", "t@example.com"]);
3928 git(&["config", "user.name", "Test"]);
3929 git(&["config", "commit.gpgsign", "false"]);
3930 for (i, tag) in tags.iter().enumerate() {
3931 std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
3932 git(&["add", "-A"]);
3933 assert!(
3934 git(&["commit", "-m", &format!("c{i}")]).status.success(),
3935 "git commit failed"
3936 );
3937 assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
3938 }
3939 Some((dir, root))
3940 }
3941
3942 #[test]
3945 fn classify_tag_extracts_prefix_version_prerelease() {
3946 let c = classify_tag("apache-arrow-25.0.0").unwrap();
3947 assert_eq!(c.prefix, "apache-arrow-");
3948 assert_eq!(c.version, vec![25, 0, 0]);
3949 assert!(!c.is_prerelease);
3950
3951 for t in [
3953 "apache-arrow-25.0.0.dev",
3954 "apache-arrow-25.0.0-rc1",
3955 "apache-arrow-25.0.0-RC0",
3956 "v1.2.3-beta2",
3957 "v1.2.3_alpha",
3958 "v2.0.0-preview",
3959 ] {
3960 assert!(
3961 classify_tag(t).unwrap().is_prerelease,
3962 "{t} should be prerelease"
3963 );
3964 }
3965
3966 assert_eq!(classify_tag("go/v18.0.0").unwrap().prefix, "go/v");
3968 assert_eq!(classify_tag("r-15.0.1").unwrap().prefix, "r-");
3969 assert_eq!(classify_tag("v1.2.3").unwrap().prefix, "v");
3970
3971 let c = classify_tag("arrow2-0.17.0").unwrap();
3973 assert_eq!(c.prefix, "arrow2-");
3974 assert_eq!(c.version, vec![0, 17, 0]);
3975 }
3976
3977 #[test]
3978 fn classify_tag_excludes_non_version_tags() {
3979 assert_eq!(classify_tag("r-universe-release"), None);
3980 assert_eq!(classify_tag("latest"), None);
3981 assert_eq!(classify_tag("nightly"), None);
3982 assert_eq!(classify_tag("v1.2.3-foobar"), None);
3984 }
3985
3986 #[test]
3987 fn select_family_tags_picks_dominant_release_family_skipping_prereleases() {
3988 let tags: Vec<String> = [
3992 "apache-arrow-22.0.0",
3993 "apache-arrow-23.0.0",
3994 "apache-arrow-24.0.0",
3995 "apache-arrow-25.0.0-rc0",
3996 "apache-arrow-25.0.0-rc1",
3997 "apache-arrow-25.0.0.dev",
3998 "go/v18.0.0",
3999 "r-15.0.1",
4000 "r-16.1.0",
4001 "r-universe-release",
4002 ]
4003 .iter()
4004 .map(|s| s.to_string())
4005 .collect();
4006 let got = select_family_tags(&tags, 2).unwrap();
4009 assert_eq!(got, vec!["apache-arrow-23.0.0", "apache-arrow-24.0.0"]);
4010 }
4011
4012 #[test]
4013 fn select_family_tags_fewer_stable_than_requested_uses_all_stable() {
4014 let tags: Vec<String> = ["v1.0.0", "v2.0.0", "v3.0.0-rc1"]
4015 .iter()
4016 .map(|s| s.to_string())
4017 .collect();
4018 let got = select_family_tags(&tags, 5).unwrap();
4020 assert_eq!(got, vec!["v1.0.0", "v2.0.0"]);
4021 }
4022
4023 #[test]
4024 fn select_family_tags_prerelease_only_family_falls_back_to_prereleases() {
4025 let tags: Vec<String> = ["v1.0.0-rc1", "v1.0.0-rc2", "v0.9.0-beta"]
4026 .iter()
4027 .map(|s| s.to_string())
4028 .collect();
4029 let got = select_family_tags(&tags, 2).unwrap();
4031 assert_eq!(got, vec!["v1.0.0-rc1", "v1.0.0-rc2"]);
4032 }
4033
4034 #[test]
4035 fn select_family_tags_no_version_like_tags_returns_none() {
4036 let tags: Vec<String> = ["latest", "nightly", "stable"]
4037 .iter()
4038 .map(|s| s.to_string())
4039 .collect();
4040 assert_eq!(select_family_tags(&tags, 3), None);
4041 }
4042
4043 #[test]
4046 fn resolve_revs_count_falls_back_to_raw_when_no_version_tags() {
4047 let Some((_d, root)) = git_repo_with_tags(&["latest", "nightly", "stable"]) else {
4050 return;
4051 };
4052 let ws = Workspace::open_local(root.clone(), None).unwrap();
4053 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
4054 assert_eq!(resolved.len(), 3);
4056 assert_eq!(resolved.last().unwrap(), "HEAD");
4057 assert!(resolved[..2].iter().all(|r| r != "HEAD"));
4058 }
4059
4060 #[test]
4061 fn resolve_revs_count_skips_prereleases_of_dominant_family() {
4062 let Some((_d, root)) =
4063 git_repo_with_tags(&["v1.0.0", "v2.0.0", "v3.0.0-rc1", "v3.0.0.dev"])
4064 else {
4065 return;
4066 };
4067 let ws = Workspace::open_local(root.clone(), None).unwrap();
4068 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
4069 assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
4072 }
4073
4074 #[test]
4075 fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
4076 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
4077 return;
4078 };
4079 let ws = Workspace::open_local(root.clone(), None).unwrap();
4080 let resolved = ws
4081 .resolve_revs(&root, &RevsRequest::Count(2))
4082 .expect("resolve should succeed");
4083 assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
4085 }
4086
4087 #[test]
4088 fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
4089 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
4090 return;
4091 };
4092 let ws = Workspace::open_local(root.clone(), None).unwrap();
4093 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
4094 assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
4095 }
4096
4097 #[test]
4098 fn resolve_revs_count_errors_when_no_tags() {
4099 let Some((_d, root)) = git_repo_with_tags(&[]) else {
4100 return;
4101 };
4102 let git = |args: &[&str]| {
4104 Command::new("git")
4105 .arg("-C")
4106 .arg(&root)
4107 .args(args)
4108 .output()
4109 .unwrap()
4110 };
4111 std::fs::write(root.join("f.txt"), b"x").unwrap();
4112 git(&["add", "-A"]);
4113 git(&["commit", "-m", "c0"]);
4114 let ws = Workspace::open_local(root.clone(), None).unwrap();
4115 let err = ws
4116 .resolve_revs(&root, &RevsRequest::Count(3))
4117 .expect_err("no tags → error");
4118 assert!(
4119 err.to_string().contains("no tags"),
4120 "expected a 'no tags' error, got: {err}"
4121 );
4122 }
4123
4124 #[test]
4127 fn dedup_labels_is_order_preserving_first_wins() {
4128 assert_eq!(
4129 dedup_labels(vec!["HEAD".into(), "HEAD".into()]),
4130 vec!["HEAD"]
4131 );
4132 assert_eq!(
4133 dedup_labels(vec![
4134 "v1".into(),
4135 "v2".into(),
4136 "v1".into(),
4137 "v3".into(),
4138 "v2".into(),
4139 ]),
4140 vec!["v1", "v2", "v3"]
4141 );
4142 assert_eq!(dedup_labels(vec![]), Vec::<String>::new());
4144 assert_eq!(dedup_labels(vec!["a".into(), "b".into()]), vec!["a", "b"]);
4145 }
4146
4147 #[test]
4148 fn resolve_revs_list_dedups_duplicate_revspecs() {
4149 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
4150 return;
4151 };
4152 let ws = Workspace::open_local(root.clone(), None).unwrap();
4153 let got = ws
4155 .resolve_revs(
4156 &root,
4157 &RevsRequest::List(vec!["HEAD".into(), "HEAD".into()]),
4158 )
4159 .unwrap();
4160 assert_eq!(got, vec!["HEAD"]);
4161 let got = ws
4163 .resolve_revs(
4164 &root,
4165 &RevsRequest::List(vec!["v1.0.0".into(), "HEAD".into(), "v1.0.0".into()]),
4166 )
4167 .unwrap();
4168 assert_eq!(got, vec!["v1.0.0", "HEAD"]);
4169 }
4170
4171 #[test]
4172 fn resolve_revs_list_validates_and_rejects_unknown() {
4173 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
4174 return;
4175 };
4176 let ws = Workspace::open_local(root.clone(), None).unwrap();
4177 let ok = ws
4179 .resolve_revs(
4180 &root,
4181 &RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
4182 )
4183 .unwrap();
4184 assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
4185 let err = ws
4187 .resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
4188 .expect_err("unknown rev → error");
4189 assert!(
4190 err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
4191 "expected an unknown-rev error, got: {err}"
4192 );
4193 }
4194
4195 #[test]
4196 fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
4197 use std::sync::atomic::{AtomicUsize, Ordering};
4198 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
4199 return;
4200 };
4201 let plain_calls = Arc::new(AtomicUsize::new(0));
4202 let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
4203 let pc = plain_calls.clone();
4204 let plain: PostActivateHook = Arc::new(move |_p, _n| {
4205 pc.fetch_add(1, Ordering::SeqCst);
4206 Ok(())
4207 });
4208 let sr = seen_revs.clone();
4209 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
4210 *sr.lock().unwrap() = Some(revs.to_vec());
4211 Ok(())
4212 });
4213 let ws = Workspace::open_local(root.clone(), Some(plain))
4214 .unwrap()
4215 .with_post_activate_revs(revs_hook);
4216 let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
4217 assert_eq!(
4219 seen_revs.lock().unwrap().clone().unwrap(),
4220 vec!["v1.1.0", "v2.0.0", "HEAD"]
4221 );
4222 assert_eq!(
4223 plain_calls.load(Ordering::SeqCst),
4224 0,
4225 "plain hook must not fire when the revs-hook handled the request"
4226 );
4227 assert!(
4229 out.contains("revs: v1.1.0, v2.0.0, HEAD"),
4230 "activation message should list the resolved revs; got: {out}"
4231 );
4232 }
4233
4234 #[test]
4235 fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
4236 use std::sync::atomic::{AtomicUsize, Ordering};
4237 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
4238 return;
4239 };
4240 let plain_calls = Arc::new(AtomicUsize::new(0));
4241 let revs_seen = Arc::new(AtomicUsize::new(0));
4242 let pc = plain_calls.clone();
4243 let plain: PostActivateHook = Arc::new(move |_p, _n| {
4244 pc.fetch_add(1, Ordering::SeqCst);
4245 Ok(())
4246 });
4247 let rs = revs_seen.clone();
4248 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
4249 rs.fetch_add(1, Ordering::SeqCst);
4250 Ok(())
4251 });
4252 let ws = Workspace::open_local(root.clone(), Some(plain))
4253 .unwrap()
4254 .with_post_activate_revs(revs_hook);
4255 let out = ws.repo_management(None, false, true, false, None);
4257 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
4258 assert_eq!(
4259 revs_seen.load(Ordering::SeqCst),
4260 0,
4261 "revs-hook must not fire when no revs were requested"
4262 );
4263 assert!(
4264 !out.contains("revs:"),
4265 "no revs line expected on a plain activation; got: {out}"
4266 );
4267 }
4268
4269 #[test]
4270 fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
4271 use std::sync::atomic::{AtomicUsize, Ordering};
4272 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
4273 return;
4274 };
4275 let plain_calls = Arc::new(AtomicUsize::new(0));
4276 let pc = plain_calls.clone();
4277 let plain: PostActivateHook = Arc::new(move |_p, _n| {
4278 pc.fetch_add(1, Ordering::SeqCst);
4279 Ok(())
4280 });
4281 let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
4284 let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
4285 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
4286 assert!(
4287 !out.contains("revs:"),
4288 "must not report a rev-set when only the plain hook ran; got: {out}"
4289 );
4290 }
4291
4292 #[allow(clippy::type_complexity)]
4298 fn ws_with_both_hooks(
4299 tags: &[&str],
4300 ) -> Option<(
4301 Workspace,
4302 tempfile::TempDir,
4303 PathBuf,
4304 Arc<std::sync::atomic::AtomicUsize>,
4305 Arc<std::sync::atomic::AtomicUsize>,
4306 )> {
4307 use std::sync::atomic::{AtomicUsize, Ordering};
4308 let (d, root) = git_repo_with_tags(tags)?;
4309 let plain_calls = Arc::new(AtomicUsize::new(0));
4310 let revs_calls = Arc::new(AtomicUsize::new(0));
4311 let pc = plain_calls.clone();
4312 let plain: PostActivateHook = Arc::new(move |_p, _n| {
4313 pc.fetch_add(1, Ordering::SeqCst);
4314 Ok(())
4315 });
4316 let rc = revs_calls.clone();
4317 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _r| {
4318 rc.fetch_add(1, Ordering::SeqCst);
4319 Ok(())
4320 });
4321 let ws = Workspace::open_local(root.clone(), Some(plain))
4322 .unwrap()
4323 .with_post_activate_revs(revs_hook);
4324 Some((ws, d, root, plain_calls, revs_calls))
4325 }
4326
4327 #[test]
4328 fn plain_activation_after_revs_build_rebuilds_plain() {
4329 use std::sync::atomic::Ordering;
4330 let Some((ws, _d, root, plain_calls, revs_calls)) =
4331 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
4332 else {
4333 return;
4334 };
4335 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
4337 assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
4338 assert_eq!(plain_calls.load(Ordering::SeqCst), 0);
4339 let out = ws.set_root_dir(&root, None);
4343 assert_eq!(
4344 plain_calls.load(Ordering::SeqCst),
4345 1,
4346 "plain re-activation after a revs build must fire the plain hook"
4347 );
4348 assert!(
4349 !out.contains("build skipped"),
4350 "must not skip a plain re-activation after a revs build; got: {out}"
4351 );
4352 assert!(
4353 !out.contains("revs:"),
4354 "plain rebuild must not claim revs; got: {out}"
4355 );
4356 let out = ws.set_root_dir(&root, None);
4359 assert_eq!(
4360 plain_calls.load(Ordering::SeqCst),
4361 1,
4362 "second plain re-bind skips"
4363 );
4364 assert!(
4365 out.contains("build skipped"),
4366 "expected skip suffix; got: {out}"
4367 );
4368 }
4369
4370 #[test]
4371 fn update_after_revs_build_reapplies_stored_revs() {
4372 use std::sync::atomic::Ordering;
4373 let Some((ws, _d, root, plain_calls, revs_calls)) =
4374 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
4375 else {
4376 return;
4377 };
4378 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
4380 assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
4381 let out = ws.repo_management(None, false, true, false, None);
4384 assert_eq!(
4385 revs_calls.load(Ordering::SeqCst),
4386 2,
4387 "bare update must re-apply the stored rev-set"
4388 );
4389 assert_eq!(
4390 plain_calls.load(Ordering::SeqCst),
4391 0,
4392 "bare update after a revs build must not fall to the plain hook"
4393 );
4394 assert!(
4395 out.contains("revs:"),
4396 "re-applied update should list the revs; got: {out}"
4397 );
4398 }
4399
4400 #[test]
4401 fn revs_activation_after_plain_build_always_rebuilds() {
4402 use std::sync::atomic::Ordering;
4403 let Some((ws, _d, root, plain_calls, revs_calls)) =
4404 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
4405 else {
4406 return;
4407 };
4408 let _ = ws.set_root_dir(&root, None);
4410 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
4411 assert_eq!(revs_calls.load(Ordering::SeqCst), 0);
4412 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
4415 assert_eq!(
4416 revs_calls.load(Ordering::SeqCst),
4417 1,
4418 "a revs request must always rebuild, even at an unchanged HEAD"
4419 );
4420 }
4421
4422 #[test]
4423 fn last_built_revs_round_trips_and_clears_on_plain_build() {
4424 let dir = tempfile::tempdir().unwrap();
4425 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
4426 ws.bump_access("acme/widgets", "cloned");
4427 assert_eq!(ws.last_built_revs("acme/widgets"), None);
4428 ws.record_built("acme/widgets", "sha1", Some(&RevsRequest::Count(3)));
4430 assert_eq!(
4431 ws.last_built_revs("acme/widgets"),
4432 Some(RevsRequest::Count(3))
4433 );
4434 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
4436 assert_eq!(
4437 ws2.last_built_revs("acme/widgets"),
4438 Some(RevsRequest::Count(3))
4439 );
4440 ws2.record_built("acme/widgets", "sha2", None);
4442 assert_eq!(ws2.last_built_revs("acme/widgets"), None);
4443 ws2.record_built(
4445 "acme/widgets",
4446 "sha3",
4447 Some(&RevsRequest::List(vec!["v1".into(), "v2".into()])),
4448 );
4449 assert_eq!(
4450 ws2.last_built_revs("acme/widgets"),
4451 Some(RevsRequest::List(vec!["v1".into(), "v2".into()]))
4452 );
4453 }
4454
4455 #[test]
4456 fn inventory_loads_legacy_entries_without_revs_field() {
4457 let dir = tempfile::tempdir().unwrap();
4461 let legacy = r#"{
4462 "old/repo": {
4463 "cloned_at": "2024-01-01T00:00:00",
4464 "last_accessed": "2024-01-01T00:00:00",
4465 "access_count": 5,
4466 "stale": false,
4467 "last_built_sha": "deadbeef"
4468 }
4469 }"#;
4470 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
4471 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
4472 assert_eq!(ws.last_built_sha("old/repo").as_deref(), Some("deadbeef"));
4473 assert_eq!(ws.last_built_revs("old/repo"), None);
4474 }
4475}