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, RwLock};
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(Clone)]
267pub struct Workspace {
268 inner: Arc<WorkspaceInner>,
269}
270
271struct WorkspaceInner {
272 kind: WorkspaceKind,
273 workspace_dir: PathBuf,
274 stale_after_days: u32,
275 state: RwLock<WorkspaceState>,
276 inventory: Mutex<()>,
280 legacy_activation: Mutex<()>,
284 post_activate: Option<PostActivateHook>,
285 activation_summary: Option<ActivationSummaryHook>,
288 post_activate_revs: Option<PostActivateRevsHook>,
293 activation_transaction: Option<ActivationTransactionHook>,
296}
297
298#[derive(Debug, Default)]
299struct WorkspaceState {
300 active_repo_name: Option<String>,
301 active_repo_path: Option<PathBuf>,
302 last_activation_id: u64,
306 latest_requested: Option<ActivationId>,
307 active_build: Option<ActiveBuildState>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314struct ActiveBuildState {
315 activation_id: ActivationId,
316 name: String,
317 path: PathBuf,
318 head_sha: String,
319 resolved_revs: Option<Vec<String>>,
320}
321
322impl Workspace {
323 pub fn open(
325 workspace_dir: PathBuf,
326 stale_after_days: u32,
327 post_activate: Option<PostActivateHook>,
328 ) -> Result<Self> {
329 if !workspace_dir.is_dir() {
330 fs::create_dir_all(&workspace_dir).with_context(|| {
331 format!("failed to create workspace dir {}", workspace_dir.display())
332 })?;
333 }
334 let repos_dir = workspace_dir.join("repos");
335 if !repos_dir.is_dir() {
336 fs::create_dir_all(&repos_dir)
337 .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
338 }
339 let ws = Self {
340 inner: Arc::new(WorkspaceInner {
341 kind: WorkspaceKind::Github,
342 workspace_dir,
343 stale_after_days,
344 state: RwLock::new(WorkspaceState::default()),
345 inventory: Mutex::new(()),
346 legacy_activation: Mutex::new(()),
347 post_activate,
348 activation_summary: None,
349 post_activate_revs: None,
350 activation_transaction: None,
351 }),
352 };
353 ws.reconcile_inventory()?;
354 Ok(ws)
355 }
356
357 pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
365 if !root.is_dir() {
366 anyhow::bail!(
367 "local workspace root does not exist or is not a directory: {}",
368 root.display()
369 );
370 }
371 let canon_root = root
372 .canonicalize()
373 .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
374 let inv_dir = canon_root.join(".mcp-workspace");
377 if !inv_dir.is_dir() {
378 fs::create_dir_all(&inv_dir).with_context(|| {
379 format!("failed to create local-workspace dir {}", inv_dir.display())
380 })?;
381 }
382 let mut state = WorkspaceState::default();
383 let synthetic_name = synthesize_local_name(&canon_root);
384 state.active_repo_name = Some(synthetic_name);
385 state.active_repo_path = Some(canon_root.clone());
386 Ok(Self {
387 inner: Arc::new(WorkspaceInner {
388 kind: WorkspaceKind::Local,
389 workspace_dir: canon_root,
390 stale_after_days: u32::MAX, state: RwLock::new(state),
392 inventory: Mutex::new(()),
393 legacy_activation: Mutex::new(()),
394 post_activate,
395 activation_summary: None,
396 post_activate_revs: None,
397 activation_transaction: None,
398 }),
399 })
400 }
401
402 pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
408 match Arc::get_mut(&mut self.inner) {
409 Some(inner) => inner.activation_summary = Some(hook),
410 None => tracing::warn!(
411 "with_activation_summary called after the workspace was cloned; summary not attached"
412 ),
413 }
414 self
415 }
416
417 pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
425 match Arc::get_mut(&mut self.inner) {
426 Some(inner) => inner.post_activate_revs = Some(hook),
427 None => tracing::warn!(
428 "with_post_activate_revs called after the workspace was cloned; revs hook not attached"
429 ),
430 }
431 self
432 }
433
434 pub fn with_activation_transaction(mut self, hook: ActivationTransactionHook) -> Self {
442 match Arc::get_mut(&mut self.inner) {
443 Some(inner) => inner.activation_transaction = Some(hook),
444 None => tracing::warn!(
445 "with_activation_transaction called after the workspace was cloned; transaction not attached"
446 ),
447 }
448 self
449 }
450
451 pub fn kind(&self) -> WorkspaceKind {
452 self.inner.kind
453 }
454
455 pub fn workspace_dir(&self) -> &Path {
456 &self.inner.workspace_dir
457 }
458
459 pub fn repos_dir(&self) -> PathBuf {
460 self.inner.workspace_dir.join("repos")
461 }
462
463 fn inventory_path(&self) -> PathBuf {
464 match self.inner.kind {
465 WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
466 WorkspaceKind::Local => self
467 .inner
468 .workspace_dir
469 .join(".mcp-workspace")
470 .join("inventory.json"),
471 }
472 }
473
474 pub fn active_repo_name(&self) -> Option<String> {
476 self.inner.state.read().unwrap().active_repo_name.clone()
477 }
478
479 pub fn active_repo_path(&self) -> Option<PathBuf> {
481 self.inner.state.read().unwrap().active_repo_path.clone()
482 }
483
484 pub fn default_github_repo(&self) -> Option<String> {
493 match self.inner.kind {
494 WorkspaceKind::Github => self.active_repo_name(),
495 WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
496 }
497 }
498
499 fn load_inventory_unlocked(&self) -> BTreeMap<String, InventoryEntry> {
504 let path = self.inventory_path();
505 let Ok(text) = fs::read_to_string(&path) else {
506 return BTreeMap::new();
507 };
508 serde_json::from_str(&text).unwrap_or_default()
509 }
510
511 fn save_inventory_unlocked(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
512 let path = self.inventory_path();
513 let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
514 fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
515 Ok(())
516 }
517
518 fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
519 let _guard = self.inner.inventory.lock().unwrap();
520 self.load_inventory_unlocked()
521 }
522
523 fn reconcile_inventory(&self) -> Result<()> {
524 let _guard = self.inner.inventory.lock().unwrap();
525 let mut inv = self.load_inventory_unlocked();
526 let mut on_disk: Vec<String> = Vec::new();
527 if self.repos_dir().is_dir() {
528 for org_entry in fs::read_dir(self.repos_dir())? {
529 let Ok(org_entry) = org_entry else { continue };
530 if !org_entry.path().is_dir() {
531 continue;
532 }
533 let org = org_entry.file_name().to_string_lossy().into_owned();
534 if org.starts_with('.') {
535 continue;
536 }
537 for repo_entry in fs::read_dir(org_entry.path())? {
538 let Ok(repo_entry) = repo_entry else { continue };
539 if !repo_entry.path().is_dir() {
540 continue;
541 }
542 let repo = repo_entry.file_name().to_string_lossy().into_owned();
543 if repo.starts_with('.') {
544 continue;
545 }
546 let rname = format!("{org}/{repo}");
547 on_disk.push(rname.clone());
548 inv.entry(rname).or_insert_with(|| {
549 let mtime = repo_entry
550 .metadata()
551 .ok()
552 .and_then(|m| m.modified().ok())
553 .map(format_iso)
554 .unwrap_or_else(now_iso);
555 InventoryEntry {
556 cloned_at: mtime.clone(),
557 last_accessed: mtime,
558 access_count: 0,
559 stale: false,
560 last_built_sha: None,
561 last_built_revs: None,
562 }
563 });
564 }
565 }
566 }
567 for (rname, entry) in inv.iter_mut() {
568 if !on_disk.contains(rname) && !entry.stale {
569 entry.stale = true;
570 }
571 }
572 self.save_inventory_unlocked(&inv)?;
573 Ok(())
574 }
575
576 fn bump_access(&self, name: &str, action: &str) {
577 let _guard = self.inner.inventory.lock().unwrap();
578 let mut inv = self.load_inventory_unlocked();
579 let now = now_iso();
580 let entry = inv
581 .entry(name.to_string())
582 .or_insert_with(|| InventoryEntry {
583 cloned_at: now.clone(),
584 last_accessed: now.clone(),
585 access_count: 0,
586 stale: false,
587 last_built_sha: None,
588 last_built_revs: None,
589 });
590 entry.last_accessed = now.clone();
591 entry.access_count += 1;
592 entry.stale = false;
593 if action == "cloned" || entry.cloned_at.is_empty() {
594 entry.cloned_at = now;
595 }
596 let _ = self.save_inventory_unlocked(&inv);
597 }
598
599 fn mark_stale(&self, name: &str) {
600 let _guard = self.inner.inventory.lock().unwrap();
601 let mut inv = self.load_inventory_unlocked();
602 if let Some(entry) = inv.get_mut(name) {
603 entry.stale = true;
604 let _ = self.save_inventory_unlocked(&inv);
605 }
606 }
607
608 fn sweep_stale(&self) -> Vec<String> {
609 if matches!(self.inner.kind, WorkspaceKind::Local) {
611 return Vec::new();
612 }
613 let active = self.active_repo_name();
614 let _guard = self.inner.inventory.lock().unwrap();
615 let mut inv = self.load_inventory_unlocked();
616 let cutoff = SystemTime::now()
617 - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
618 let mut swept: Vec<String> = Vec::new();
619 for (rname, entry) in inv.iter_mut() {
620 if entry.stale {
621 continue;
622 }
623 if Some(rname.as_str()) == active.as_deref() {
624 continue;
625 }
626 let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
627 if last >= cutoff {
628 continue;
629 }
630 let parts: Vec<&str> = rname.splitn(2, '/').collect();
631 if parts.len() != 2 {
632 continue;
633 }
634 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
635 if repo_path.exists() {
636 let _ = fs::remove_dir_all(&repo_path);
637 }
638 entry.stale = true;
639 swept.push(rname.clone());
640 }
641 if !swept.is_empty() {
642 let _ = self.save_inventory_unlocked(&inv);
643 self.prune_empty_org_dirs();
644 }
645 swept
646 }
647
648 fn prune_empty_org_dirs(&self) {
649 let Ok(entries) = fs::read_dir(self.repos_dir()) else {
650 return;
651 };
652 for entry in entries.flatten() {
653 let path = entry.path();
654 if !path.is_dir() {
655 continue;
656 }
657 if let Ok(children) = fs::read_dir(&path) {
658 let real: Vec<_> = children
659 .flatten()
660 .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
661 .collect();
662 if real.is_empty() {
663 let _ = fs::remove_dir_all(&path);
664 }
665 }
666 }
667 }
668
669 fn clone_or_update(
680 &self,
681 name: &str,
682 requested_local_root: Option<&Path>,
683 ) -> Result<(String, PathBuf, String)> {
684 if matches!(self.inner.kind, WorkspaceKind::Local) {
685 let root = requested_local_root
691 .map(Path::to_path_buf)
692 .or_else(|| self.active_repo_path())
693 .unwrap_or_else(|| self.inner.workspace_dir.clone());
694 let prev_sha = self.last_built_sha(name);
695 let fingerprint = fingerprint_dir(&root);
696 let action = match prev_sha {
697 Some(p) if p == fingerprint => "current",
698 None => "cloned", Some(_) => "updated",
700 };
701 return Ok((action.to_string(), root, fingerprint));
702 }
703 let parts: Vec<&str> = name.splitn(2, '/').collect();
704 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
705 if !repo_path.exists() {
706 fs::create_dir_all(repo_path.parent().unwrap()).ok();
707 let url = format!("https://github.com/{name}.git");
708 let out = Command::new("git")
716 .args([
717 "clone",
718 "--filter=tree:0",
719 "--tags",
720 &url,
721 repo_path.to_str().unwrap(),
722 ])
723 .output()
724 .context("failed to spawn `git clone`")?;
725 if !out.status.success() {
726 anyhow::bail!(
727 "git clone failed: {}",
728 String::from_utf8_lossy(&out.stderr).trim()
729 );
730 }
731 let sha = git_rev_parse(&repo_path, "HEAD")?;
732 return Ok(("cloned".to_string(), repo_path, sha));
733 }
734
735 Command::new("git")
741 .args(["fetch", "origin", "--tags"])
742 .current_dir(&repo_path)
743 .output()
744 .context("git fetch failed")?;
745 let local = git_rev_parse(&repo_path, "HEAD")?;
746 let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
747 if local != remote {
748 Command::new("git")
749 .args(["reset", "--hard", "FETCH_HEAD"])
750 .current_dir(&repo_path)
751 .output()
752 .context("git reset failed")?;
753 let sha = git_rev_parse(&repo_path, "HEAD")?;
754 return Ok(("updated".to_string(), repo_path, sha));
755 }
756 Ok(("current".to_string(), repo_path, local))
757 }
758
759 fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
792 let resolved = match req {
793 RevsRequest::Count(n) => {
794 let out = Command::new("git")
795 .args(["tag", "--sort=-v:refname"])
796 .current_dir(repo_path)
797 .output()
798 .context("failed to spawn `git tag`")?;
799 if !out.status.success() {
800 anyhow::bail!(
801 "cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
802 repo_path.display(),
803 String::from_utf8_lossy(&out.stderr).trim()
804 );
805 }
806 let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
807 .lines()
808 .map(|l| l.trim().to_string())
809 .filter(|l| !l.is_empty())
810 .collect();
811 if tags.is_empty() {
812 anyhow::bail!(
813 "revs={n} requested but '{}' has no tags to resolve",
814 repo_path.display()
815 );
816 }
817 let mut chosen = select_family_tags(&tags, *n).unwrap_or_else(|| {
821 let mut raw: Vec<String> = tags.into_iter().take(*n).collect();
822 raw.reverse();
823 raw
824 });
825 chosen.push("HEAD".to_string());
828 chosen
829 }
830 RevsRequest::List(revs) => {
831 if revs.is_empty() {
832 anyhow::bail!("revs list is empty — pass at least one revision");
833 }
834 for r in revs {
835 let out = Command::new("git")
836 .args([
837 "rev-parse",
838 "--verify",
839 "--quiet",
840 &format!("{r}^{{commit}}"),
841 ])
842 .current_dir(repo_path)
843 .output()
844 .context("failed to spawn `git rev-parse`")?;
845 if !out.status.success() {
846 anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
847 }
848 }
849 revs.clone()
850 }
851 };
852 Ok(dedup_labels(resolved))
853 }
854
855 fn activate(
881 &self,
882 name: &str,
883 force_rebuild: bool,
884 revs: Option<&RevsRequest>,
885 requested_local_root: Option<&Path>,
886 ) -> Result<String> {
887 let _legacy_guard = self
893 .inner
894 .activation_transaction
895 .is_none()
896 .then(|| self.inner.legacy_activation.lock().unwrap());
897 let activation_id = {
898 let mut state = self.inner.state.write().unwrap();
899 state.last_activation_id += 1;
900 let id = ActivationId(state.last_activation_id);
901 state.latest_requested = Some(id);
902 id
903 };
904 let prev_built_sha = self.last_built_sha(name);
905 let prev_built_revs = self.last_built_revs(name);
906 let (action, repo_path, head_sha) = self
907 .clone_or_update(name, requested_local_root)
908 .with_context(|| {
909 format!("activation request {activation_id} source preparation failed")
910 })?;
911 let resolved_revs = match revs {
915 Some(req) => Some(self.resolve_revs(&repo_path, req).with_context(|| {
916 format!("activation request {activation_id} revision resolution failed")
917 })?),
918 None => None,
919 };
920 self.bump_access(name, &action);
921 let is_active_built = {
922 let state = self.inner.state.read().unwrap();
923 state.active_build.as_ref().is_some_and(|built| {
924 built.name == name && built.path == repo_path && built.resolved_revs.is_none()
925 })
926 };
927
928 let already_built = !force_rebuild
956 && resolved_revs.is_none()
957 && prev_built_revs.is_none()
958 && action == "current"
959 && prev_built_sha.as_deref() == Some(head_sha.as_str())
960 && is_active_built;
961 let uses_transaction = self.inner.activation_transaction.is_some();
962 let revision_build = !already_built
963 && resolved_revs.is_some()
964 && (uses_transaction || self.inner.post_activate_revs.is_some());
965 let build = if already_built {
966 ActivationBuild::Reuse
967 } else if revision_build {
968 ActivationBuild::Revisions(resolved_revs.clone().unwrap_or_default())
969 } else {
970 ActivationBuild::Plain
971 };
972 let request = ActivationRequest {
973 id: activation_id,
974 path: repo_path.clone(),
975 name: name.to_string(),
976 build,
977 };
978
979 let prepared = if let Some(hook) = &self.inner.activation_transaction {
980 hook(&request)
981 } else {
982 let hook_result = match request.build() {
986 ActivationBuild::Reuse => Ok(()),
987 ActivationBuild::Revisions(resolved) => self
988 .inner
989 .post_activate_revs
990 .as_ref()
991 .map_or(Ok(()), |hook| hook(&repo_path, name, resolved)),
992 ActivationBuild::Plain => self
993 .inner
994 .post_activate
995 .as_ref()
996 .map_or(Ok(()), |hook| hook(&repo_path, name)),
997 };
998 hook_result.map(|()| {
999 let summary = self
1000 .inner
1001 .activation_summary
1002 .as_ref()
1003 .and_then(|hook| hook(&repo_path, name));
1004 PreparedActivation::summary(summary)
1005 })
1006 };
1007
1008 let prepared = match prepared {
1009 Ok(prepared) => prepared,
1010 Err(error) => {
1011 let latest = self.inner.state.read().unwrap().latest_requested;
1012 if latest != Some(activation_id) {
1013 return Ok(format!(
1014 "Activation request {activation_id} for '{name}' was superseded by request {} before its failed build could publish.",
1015 latest.map_or_else(|| "unknown".to_string(), |id| id.to_string())
1016 ));
1017 }
1018 return Err(anyhow!(
1019 "activation request {activation_id} for '{name}' failed during preparation: {error}"
1020 ));
1021 }
1022 };
1023
1024 let summary = {
1029 let mut state = self.inner.state.write().unwrap();
1030 if state.latest_requested != Some(activation_id) {
1031 let superseding = state.latest_requested;
1032 drop(state);
1033 drop(prepared);
1034 return Ok(format!(
1035 "Activation request {activation_id} for '{name}' was superseded by request {} before publication; its prepared build was discarded.",
1036 superseding.map_or_else(|| "unknown".to_string(), |id| id.to_string())
1037 ));
1038 }
1039 let summary = prepared.commit().with_context(|| {
1040 format!("activation request {activation_id} for '{name}' failed during commit")
1041 })?;
1042 if !matches!(request.build(), ActivationBuild::Reuse) {
1043 let built_revs = revision_build.then_some(revs).flatten();
1044 self.record_built(name, &head_sha, built_revs);
1045 }
1046 state.active_repo_name = Some(name.to_string());
1047 state.active_repo_path = Some(repo_path.clone());
1048 state.active_build = Some(ActiveBuildState {
1049 activation_id,
1050 name: name.to_string(),
1051 path: repo_path.clone(),
1052 head_sha: head_sha.clone(),
1053 resolved_revs: match request.build() {
1054 ActivationBuild::Revisions(resolved) => Some(resolved.clone()),
1055 ActivationBuild::Plain | ActivationBuild::Reuse => None,
1056 },
1057 });
1058 summary
1059 };
1060
1061 let verb = match action.as_str() {
1062 "cloned" => "Cloned",
1063 "updated" => "Updated",
1064 "current" => "Activated (already up to date)",
1065 other => other,
1066 };
1067 let suffix = if already_built {
1068 " [build skipped: HEAD matches last-built SHA]"
1069 } else {
1070 ""
1071 };
1072 let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
1073 if let ActivationBuild::Revisions(resolved) = request.build() {
1078 base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
1079 }
1080 Ok(match summary {
1081 Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
1082 _ => base,
1083 })
1084 }
1085
1086 fn record_built(&self, name: &str, sha: &str, revs: Option<&RevsRequest>) {
1092 let _guard = self.inner.inventory.lock().unwrap();
1093 let mut inv = self.load_inventory_unlocked();
1094 if let Some(entry) = inv.get_mut(name) {
1095 entry.last_built_sha = Some(sha.to_string());
1096 entry.last_built_revs = revs.cloned();
1097 let _ = self.save_inventory_unlocked(&inv);
1098 }
1099 }
1100
1101 pub fn last_built_sha(&self, name: &str) -> Option<String> {
1106 self.load_inventory()
1107 .get(name)
1108 .and_then(|e| e.last_built_sha.clone())
1109 }
1110
1111 pub fn last_built_revs(&self, name: &str) -> Option<RevsRequest> {
1116 self.load_inventory()
1117 .get(name)
1118 .and_then(|e| e.last_built_revs.clone())
1119 }
1120
1121 fn delete(&self, name: &str) -> Result<String> {
1122 let parts: Vec<&str> = name.splitn(2, '/').collect();
1123 if parts.len() != 2 {
1124 anyhow::bail!("Invalid repo name");
1125 }
1126 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
1127 let mut deleted = Vec::new();
1128 if repo_path.exists() {
1129 fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
1130 deleted.push("repo");
1131 }
1132 self.mark_stale(name);
1133 self.prune_empty_org_dirs();
1134 if deleted.is_empty() {
1135 return Ok(format!("Nothing to delete — '{name}' not found."));
1136 }
1137 let mut state = self.inner.state.write().unwrap();
1138 if state.active_repo_name.as_deref() == Some(name) {
1139 state.active_repo_name = None;
1140 state.active_repo_path = None;
1141 state.active_build = None;
1142 return Ok(format!(
1143 "Deleted {}. Active repo cleared.",
1144 deleted.join(", ")
1145 ));
1146 }
1147 Ok(format!("Deleted {}.", deleted.join(", ")))
1148 }
1149
1150 fn list(&self) -> String {
1151 let inv = self.load_inventory();
1152 if inv.is_empty() {
1153 return "No repos cloned yet. Call repo_management('org/repo') to clone one."
1154 .to_string();
1155 }
1156 let active = self.active_repo_name();
1157 let mut live: Vec<String> = Vec::new();
1158 let mut stale_lines: Vec<String> = Vec::new();
1159 for (rname, entry) in &inv {
1160 let marker = if Some(rname.as_str()) == active.as_deref() {
1161 " [active]"
1162 } else {
1163 ""
1164 };
1165 let access = format!(
1166 "{} access{}, last {}",
1167 entry.access_count,
1168 if entry.access_count == 1 { "" } else { "es" },
1169 relative_time(&entry.last_accessed)
1170 );
1171 if entry.stale {
1172 stale_lines.push(format!(
1173 " {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
1174 ));
1175 } else {
1176 live.push(format!(" {rname}{marker} ({access})"));
1177 }
1178 }
1179 let mut out = String::new();
1180 if !live.is_empty() {
1181 out.push_str(&format!(
1182 "{} live repo(s):\n{}",
1183 live.len(),
1184 live.join("\n")
1185 ));
1186 }
1187 if !stale_lines.is_empty() {
1188 if !out.is_empty() {
1189 out.push_str("\n\n");
1190 }
1191 out.push_str(&format!(
1192 "{} stale repo(s):\n{}",
1193 stale_lines.len(),
1194 stale_lines.join("\n")
1195 ));
1196 }
1197 out
1198 }
1199
1200 pub fn repo_management(
1214 &self,
1215 name: Option<&str>,
1216 delete: bool,
1217 update: bool,
1218 force_rebuild: bool,
1219 revs: Option<&RevsRequest>,
1220 ) -> String {
1221 if matches!(self.inner.kind, WorkspaceKind::Local) {
1223 if name.is_some() {
1224 return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
1225 to switch the active root, or pass `update=true` / `force_rebuild=true` \
1226 to rebuild against the current root."
1227 .to_string();
1228 }
1229 if delete {
1230 return "Local-workspace mode does not support `delete`. The root is owned by the \
1231 operator; remove it manually."
1232 .to_string();
1233 }
1234 let active = match self.active_repo_name() {
1235 Some(n) => n,
1236 None => return "No active local root.".to_string(),
1237 };
1238 let _ = update; let effective = match revs {
1251 Some(r) => Some(r.clone()),
1252 None => self.last_built_revs(&active),
1253 };
1254 let active_root = self.active_repo_path();
1255 return self
1256 .activate(
1257 &active,
1258 force_rebuild,
1259 effective.as_ref(),
1260 active_root.as_deref(),
1261 )
1262 .unwrap_or_else(|e| format!("rebuild failed: {e}"));
1263 }
1264
1265 let swept = self.sweep_stale();
1266 let prefix = if swept.is_empty() {
1267 String::new()
1268 } else {
1269 format!(
1270 "[Swept {} idle repo(s) (>{}d): {}]\n\n",
1271 swept.len(),
1272 self.inner.stale_after_days,
1273 swept.join(", ")
1274 )
1275 };
1276
1277 if name.is_none() && !update {
1278 return prefix + &self.list();
1279 }
1280
1281 if update {
1282 let Some(active) = self.active_repo_name() else {
1283 return prefix + "No active repository. Call repo_management('org/repo') first.";
1284 };
1285 let effective = match revs {
1293 Some(r) => Some(r.clone()),
1294 None => self.last_built_revs(&active),
1295 };
1296 return prefix
1297 + &self
1298 .activate(&active, force_rebuild, effective.as_ref(), None)
1299 .unwrap_or_else(|e| format!("update failed: {e}"));
1300 }
1301
1302 let Some(name) = name else {
1303 return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
1304 };
1305 if let Err(e) = validate_repo_name(name) {
1306 return prefix + &e.to_string();
1307 }
1308 if delete {
1309 return prefix
1310 + &self
1311 .delete(name)
1312 .unwrap_or_else(|e| format!("delete failed: {e}"));
1313 }
1314 prefix
1315 + &self
1316 .activate(name, force_rebuild, revs, None)
1317 .unwrap_or_else(|e| format!("activate failed: {e}"))
1318 }
1319
1320 pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
1327 if !matches!(self.inner.kind, WorkspaceKind::Local) {
1328 return "set_root_dir is only valid in local-workspace mode.".to_string();
1329 }
1330 if !new_root.is_dir() {
1331 return format!(
1332 "Path does not exist or is not a directory: {}",
1333 new_root.display()
1334 );
1335 }
1336 let canon = match new_root.canonicalize() {
1337 Ok(p) => p,
1338 Err(e) => return format!("canonicalize failed: {e}"),
1339 };
1340 let synthetic = synthesize_local_name(&canon);
1341 self.activate(&synthetic, false, revs, Some(&canon))
1345 .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
1346 }
1347}
1348
1349fn dedup_labels(revs: Vec<String>) -> Vec<String> {
1356 let mut seen = std::collections::HashSet::new();
1357 revs.into_iter()
1358 .filter(|r| seen.insert(r.clone()))
1359 .collect()
1360}
1361
1362const PRERELEASE_MARKERS: &[&str] = &["rc", "alpha", "beta", "dev", "pre", "preview"];
1367
1368#[derive(Debug, Clone, PartialEq, Eq)]
1372struct ClassifiedTag {
1373 raw: String,
1374 prefix: String,
1375 version: Vec<u64>,
1376 is_prerelease: bool,
1377}
1378
1379fn classify_tag(tag: &str) -> Option<ClassifiedTag> {
1393 let bytes = tag.as_bytes();
1394 for i in 0..bytes.len() {
1395 if !bytes[i].is_ascii_digit() {
1396 continue;
1397 }
1398 if i > 0 && bytes[i - 1].is_ascii_digit() {
1400 continue;
1401 }
1402 if let Some((version, is_prerelease)) = parse_version_at(&tag[i..]) {
1403 return Some(ClassifiedTag {
1404 raw: tag.to_string(),
1405 prefix: tag[..i].to_string(),
1406 version,
1407 is_prerelease,
1408 });
1409 }
1410 }
1411 None
1412}
1413
1414fn parse_version_at(s: &str) -> Option<(Vec<u64>, bool)> {
1420 let bytes = s.as_bytes();
1421 let mut nums: Vec<u64> = Vec::new();
1422 let mut idx = 0usize;
1423 loop {
1424 let start = idx;
1425 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
1426 idx += 1;
1427 }
1428 if idx == start {
1429 return None; }
1431 nums.push(s[start..idx].parse().ok()?);
1432 if idx + 1 < bytes.len() && bytes[idx] == b'.' && bytes[idx + 1].is_ascii_digit() {
1434 idx += 1;
1435 continue;
1436 }
1437 break;
1438 }
1439 let rest = &s[idx..];
1440 if rest.is_empty() {
1441 return Some((nums, false));
1442 }
1443 let after_sep = rest
1445 .strip_prefix(|c| c == '-' || c == '.' || c == '_')
1446 .unwrap_or(rest);
1447 let lower = after_sep.to_ascii_lowercase();
1448 if PRERELEASE_MARKERS.iter().any(|m| lower.starts_with(m)) {
1449 Some((nums, true))
1450 } else {
1451 None
1452 }
1453}
1454
1455fn select_family_tags(tags: &[String], n: usize) -> Option<Vec<String>> {
1471 let classified: Vec<ClassifiedTag> = tags.iter().filter_map(|t| classify_tag(t)).collect();
1472 if classified.is_empty() {
1473 return None;
1474 }
1475 let mut families: BTreeMap<String, Vec<&ClassifiedTag>> = BTreeMap::new();
1477 for c in &classified {
1478 families.entry(c.prefix.clone()).or_default().push(c);
1479 }
1480 let stable_count = |v: &Vec<&ClassifiedTag>| v.iter().filter(|c| !c.is_prerelease).count();
1481 let any_stable = families.values().any(|v| stable_count(v) > 0);
1482 let chosen = families.values().max_by(|a, b| {
1487 if any_stable {
1488 stable_count(a).cmp(&stable_count(b))
1489 } else {
1490 a.len().cmp(&b.len())
1491 }
1492 })?;
1493 let mut pool: Vec<&ClassifiedTag> = if any_stable {
1494 chosen
1495 .iter()
1496 .copied()
1497 .filter(|c| !c.is_prerelease)
1498 .collect()
1499 } else {
1500 chosen.to_vec()
1501 };
1502 pool.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| b.raw.cmp(&a.raw)));
1505 let mut newest: Vec<String> = pool.into_iter().take(n).map(|c| c.raw.clone()).collect();
1506 newest.reverse(); Some(newest)
1508}
1509
1510fn synthesize_local_name(root: &Path) -> String {
1514 let name = root
1515 .file_name()
1516 .map(|s| s.to_string_lossy().into_owned())
1517 .unwrap_or_else(|| "local".to_string());
1518 format!("local/{name}")
1519}
1520
1521fn parse_origin_repo(root: &Path) -> Option<String> {
1532 let out = Command::new("git")
1533 .arg("-C")
1534 .arg(root)
1535 .args(["remote", "get-url", "origin"])
1536 .output()
1537 .ok()?;
1538 if !out.status.success() {
1539 return None;
1540 }
1541 let url = String::from_utf8(out.stdout).ok()?;
1542 parse_github_remote(url.trim())
1543}
1544
1545fn parse_github_remote(url: &str) -> Option<String> {
1548 let path = url
1552 .strip_prefix("git@github.com:")
1553 .or_else(|| url.strip_prefix("https://github.com/"))
1554 .or_else(|| url.strip_prefix("http://github.com/"))
1555 .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
1556 let path = path.strip_suffix(".git").unwrap_or(path);
1557 let path = path.trim_end_matches('/');
1558 let mut parts = path.split('/');
1560 let org = parts.next().filter(|s| !s.is_empty())?;
1561 let repo = parts.next().filter(|s| !s.is_empty())?;
1562 if parts.next().is_some() {
1563 return None;
1564 }
1565 Some(format!("{org}/{repo}"))
1566}
1567
1568fn fingerprint_dir(root: &Path) -> String {
1573 use std::hash::{Hash, Hasher};
1574 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1575 let walker = ignore::WalkBuilder::new(root)
1576 .standard_filters(true)
1577 .hidden(true)
1578 .git_ignore(true)
1579 .build();
1580 for entry in walker.flatten() {
1581 if !entry.path().is_file() {
1582 continue;
1583 }
1584 let Ok(meta) = entry.metadata() else { continue };
1585 let mtime = meta
1586 .modified()
1587 .ok()
1588 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1589 .map(|d| d.as_secs())
1590 .unwrap_or(0);
1591 entry.path().to_string_lossy().hash(&mut hasher);
1592 mtime.hash(&mut hasher);
1593 meta.len().hash(&mut hasher);
1594 }
1595 format!("local-{:016x}", hasher.finish())
1596}
1597
1598fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
1599 let out = Command::new("git")
1600 .args(["rev-parse", refspec])
1601 .current_dir(repo_path)
1602 .output()
1603 .context("git rev-parse failed")?;
1604 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1605}
1606
1607fn now_iso() -> String {
1608 format_iso(SystemTime::now())
1609}
1610
1611fn format_iso(t: SystemTime) -> String {
1612 let secs = t
1613 .duration_since(SystemTime::UNIX_EPOCH)
1614 .map(|d| d.as_secs())
1615 .unwrap_or(0);
1616 chrono_lite::format_secs(secs)
1618}
1619
1620fn parse_iso(s: &str) -> Option<SystemTime> {
1621 let secs = chrono_lite::parse_secs(s)?;
1622 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
1623}
1624
1625fn relative_time(iso: &str) -> String {
1626 let Some(t) = parse_iso(iso) else {
1627 return "unknown".to_string();
1628 };
1629 let now = SystemTime::now();
1630 let delta = now.duration_since(t).unwrap_or_default().as_secs();
1631 if delta < 3600 {
1632 "just now".to_string()
1633 } else if delta < 86_400 {
1634 format!("{}h ago", delta / 3600)
1635 } else {
1636 format!("{}d ago", delta / 86_400)
1637 }
1638}
1639
1640mod chrono_lite {
1643 pub fn format_secs(secs: u64) -> String {
1644 let days = (secs / 86_400) as i64;
1646 let time = secs % 86_400;
1647 let (y, mo, d) = days_to_civil(days + 719_468);
1648 let h = time / 3600;
1649 let m = (time / 60) % 60;
1650 let s = time % 60;
1651 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
1652 }
1653
1654 pub fn parse_secs(s: &str) -> Option<u64> {
1655 let bytes = s.as_bytes();
1658 if bytes.len() < 19 {
1659 return None;
1660 }
1661 let y: i64 = s.get(0..4)?.parse().ok()?;
1662 let mo: u32 = s.get(5..7)?.parse().ok()?;
1663 let d: u32 = s.get(8..10)?.parse().ok()?;
1664 let h: u64 = s.get(11..13)?.parse().ok()?;
1665 let m: u64 = s.get(14..16)?.parse().ok()?;
1666 let sc: u64 = s.get(17..19)?.parse().ok()?;
1667 let days = civil_to_days(y, mo, d) - 719_468;
1668 Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
1669 }
1670
1671 fn days_to_civil(z: i64) -> (i64, u32, u32) {
1672 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1673 let doe = (z - era * 146_097) as u64;
1674 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1675 let y = (yoe as i64) + era * 400;
1676 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1677 let mp = (5 * doy + 2) / 153;
1678 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1679 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
1680 let y = if m <= 2 { y + 1 } else { y };
1681 (y, m, d)
1682 }
1683
1684 fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
1685 let y = if m <= 2 { y - 1 } else { y };
1686 let era = if y >= 0 { y } else { y - 399 } / 400;
1687 let yoe = (y - era * 400) as u64;
1688 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
1689 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1690 era * 146_097 + doe as i64
1691 }
1692}
1693
1694#[allow(dead_code)]
1696fn _json_keepalive() {
1697 let _ = json!({});
1698}
1699
1700#[cfg(test)]
1701mod tests {
1702 use super::*;
1703
1704 #[test]
1705 fn validates_repo_names() {
1706 assert!(validate_repo_name("pydata/xarray").is_ok());
1707 assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1708 assert!(validate_repo_name("xarray").is_err());
1709 assert!(validate_repo_name("a/b/c").is_err());
1710 assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1711 }
1712
1713 #[test]
1714 fn open_creates_layout() {
1715 let dir = tempfile::tempdir().unwrap();
1716 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1717 assert!(ws.repos_dir().is_dir());
1718 }
1719
1720 #[test]
1721 fn empty_list() {
1722 let dir = tempfile::tempdir().unwrap();
1723 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1724 let out = ws.repo_management(None, false, false, false, None);
1725 assert!(out.contains("No repos cloned yet"));
1726 }
1727
1728 #[test]
1729 fn invalid_repo_name_rejected() {
1730 let dir = tempfile::tempdir().unwrap();
1731 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1732 let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
1733 assert!(out.contains("Invalid repo name"));
1734 }
1735
1736 #[test]
1737 fn delete_unknown() {
1738 let dir = tempfile::tempdir().unwrap();
1739 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1740 let out = ws.repo_management(Some("nope/none"), true, false, false, None);
1741 assert!(out.contains("Nothing to delete"));
1742 }
1743
1744 #[test]
1745 fn iso_round_trip() {
1746 let now = SystemTime::now()
1747 .duration_since(SystemTime::UNIX_EPOCH)
1748 .unwrap()
1749 .as_secs();
1750 let s = chrono_lite::format_secs(now);
1751 let back = chrono_lite::parse_secs(&s).unwrap();
1752 assert_eq!(now, back);
1753 }
1754
1755 #[test]
1756 fn last_built_sha_round_trip() {
1757 let dir = tempfile::tempdir().unwrap();
1758 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1759 ws.bump_access("acme/widgets", "cloned");
1761 assert_eq!(ws.last_built_sha("acme/widgets"), None);
1762 ws.record_built("acme/widgets", "abc1234deadbeef", None);
1763 assert_eq!(
1764 ws.last_built_sha("acme/widgets").as_deref(),
1765 Some("abc1234deadbeef")
1766 );
1767 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1769 assert_eq!(
1770 ws2.last_built_sha("acme/widgets").as_deref(),
1771 Some("abc1234deadbeef")
1772 );
1773 }
1774
1775 #[test]
1776 fn inventory_loads_legacy_entries_without_sha_field() {
1777 let dir = tempfile::tempdir().unwrap();
1778 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1779 let legacy = r#"{
1781 "old/repo": {
1782 "cloned_at": "2024-01-01T00:00:00",
1783 "last_accessed": "2024-01-01T00:00:00",
1784 "access_count": 5,
1785 "stale": false
1786 }
1787 }"#;
1788 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1789 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1791 assert_eq!(ws2.last_built_sha("old/repo"), None);
1792 let _ = ws;
1793 }
1794
1795 #[test]
1796 fn auto_rebuild_gate_skips_when_sha_matches() {
1797 use std::sync::atomic::{AtomicUsize, Ordering};
1798 let dir = tempfile::tempdir().unwrap();
1799 let calls = Arc::new(AtomicUsize::new(0));
1800 let calls_h = calls.clone();
1801 let hook: PostActivateHook = Arc::new(move |_path, _name| {
1802 calls_h.fetch_add(1, Ordering::SeqCst);
1803 Ok(())
1804 });
1805 let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1811 ws.bump_access("acme/widgets", "cloned");
1813 ws.record_built("acme/widgets", "sha_one", None);
1814 assert_eq!(
1815 ws.last_built_sha("acme/widgets").as_deref(),
1816 Some("sha_one")
1817 );
1818 ws.record_built("acme/widgets", "sha_one", None);
1821 assert_eq!(
1822 ws.last_built_sha("acme/widgets").as_deref(),
1823 Some("sha_one")
1824 );
1825 assert_eq!(calls.load(Ordering::SeqCst), 0);
1828 }
1829
1830 #[test]
1831 fn local_workspace_binds_root_immediately() {
1832 let dir = tempfile::tempdir().unwrap();
1833 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1834 assert_eq!(ws.kind(), WorkspaceKind::Local);
1835 assert!(ws.active_repo_path().is_some());
1836 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1837 }
1838
1839 #[test]
1840 fn local_workspace_rejects_github_ops() {
1841 let dir = tempfile::tempdir().unwrap();
1842 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1843 let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
1844 assert!(out.contains("does not accept a repo name"));
1845 let out = ws.repo_management(None, true, false, false, None);
1846 assert!(out.contains("does not support `delete`"));
1847 }
1848
1849 #[test]
1850 fn local_workspace_update_rebuilds() {
1851 use std::sync::atomic::{AtomicUsize, Ordering};
1852 let dir = tempfile::tempdir().unwrap();
1853 std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1855 let calls = Arc::new(AtomicUsize::new(0));
1856 let calls_h = calls.clone();
1857 let hook: PostActivateHook = Arc::new(move |_p, _n| {
1858 calls_h.fetch_add(1, Ordering::SeqCst);
1859 Ok(())
1860 });
1861 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1862 let _ = ws.repo_management(None, false, true, false, None);
1864 assert_eq!(calls.load(Ordering::SeqCst), 1);
1865 let out = ws.repo_management(None, false, true, false, None);
1867 assert_eq!(
1868 calls.load(Ordering::SeqCst),
1869 1,
1870 "auto-rebuild gate must skip"
1871 );
1872 assert!(out.contains("build skipped"));
1873 }
1874
1875 #[test]
1876 fn parses_github_remote_forms() {
1877 assert_eq!(
1878 parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1879 Some("kkollsga/kglite")
1880 );
1881 assert_eq!(
1882 parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1883 Some("kkollsga/kglite")
1884 );
1885 assert_eq!(
1887 parse_github_remote("https://github.com/acme/widget/").as_deref(),
1888 Some("acme/widget")
1889 );
1890 assert_eq!(
1891 parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1892 Some("acme/widget")
1893 );
1894 assert_eq!(
1896 parse_github_remote("https://gitlab.com/acme/widget.git"),
1897 None
1898 );
1899 assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1900 assert_eq!(parse_github_remote("not a url"), None);
1901 }
1902
1903 #[test]
1904 fn local_default_github_repo_uses_origin_remote() {
1905 let dir = tempfile::tempdir().unwrap();
1906 let root = dir.path();
1907 let git = |args: &[&str]| {
1910 Command::new("git")
1911 .arg("-C")
1912 .arg(root)
1913 .args(args)
1914 .output()
1915 .unwrap()
1916 };
1917 if !git(&["init"]).status.success() {
1918 return;
1920 }
1921 git(&[
1922 "remote",
1923 "add",
1924 "origin",
1925 "https://github.com/acme/widget.git",
1926 ]);
1927 let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1928 assert_eq!(
1929 ws.default_github_repo().as_deref(),
1930 Some("acme/widget"),
1931 "local default repo must come from the origin remote, not the inventory key"
1932 );
1933 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1935 }
1936
1937 #[test]
1938 fn local_default_github_repo_none_without_remote() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1941 let def = ws.default_github_repo();
1943 assert!(
1944 def.is_none(),
1945 "expected None for a non-git local root, got {def:?}"
1946 );
1947 }
1948
1949 #[test]
1950 fn set_root_dir_only_in_local_mode() {
1951 let dir = tempfile::tempdir().unwrap();
1952 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1953 let out = ws.set_root_dir(dir.path(), None);
1954 assert!(out.contains("only valid in local-workspace"));
1955 }
1956
1957 #[test]
1958 fn update_with_no_active_repo() {
1959 let dir = tempfile::tempdir().unwrap();
1960 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1961 let out = ws.repo_management(None, false, true, false, None);
1962 assert!(out.contains("No active repository"));
1963 }
1964
1965 #[test]
1966 fn set_root_dir_updates_active_path() {
1967 let dir = tempfile::tempdir().unwrap();
1968 let child = dir.path().join("child");
1969 std::fs::create_dir_all(&child).unwrap();
1970 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1971 let _ = ws.set_root_dir(&child, None);
1972 assert_eq!(
1973 ws.active_repo_path().unwrap(),
1974 child.canonicalize().unwrap(),
1975 "set_root_dir didn't update active_repo_path"
1976 );
1977 }
1978
1979 #[test]
1980 fn set_root_dir_post_activate_fires_against_new_root() {
1981 let dir = tempfile::tempdir().unwrap();
1982 let child = dir.path().join("child");
1983 std::fs::create_dir_all(&child).unwrap();
1984 std::fs::write(child.join("a.txt"), b"hi").unwrap();
1985 let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1986 let seen = seen_path.clone();
1987 let hook: PostActivateHook = Arc::new(move |p, _n| {
1988 *seen.lock().unwrap() = Some(p.to_path_buf());
1989 Ok(())
1990 });
1991 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1992 let _ = ws.set_root_dir(&child, None);
1993 assert_eq!(
1994 seen_path.lock().unwrap().clone().unwrap(),
1995 child.canonicalize().unwrap(),
1996 "post_activate hook saw the wrong root after set_root_dir"
1997 );
1998 }
1999
2000 #[test]
2001 fn activation_summary_appended_to_activate_message() {
2002 let dir = tempfile::tempdir().unwrap();
2003 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
2004 let summary: ActivationSummaryHook =
2005 Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
2006 let ws = Workspace::open_local(dir.path().to_path_buf(), None)
2007 .unwrap()
2008 .with_activation_summary(summary);
2009 let out = ws.repo_management(None, false, true, false, None);
2010 assert!(
2011 out.contains("Graph ready: 3 Functions."),
2012 "activation message should include the summary; got: {out}"
2013 );
2014 }
2015
2016 #[test]
2017 fn activation_summary_absent_when_not_configured() {
2018 let dir = tempfile::tempdir().unwrap();
2019 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
2020 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2021 let out = ws.repo_management(None, false, true, false, None);
2022 assert!(!out.contains("Graph ready"));
2023 assert!(
2024 out.contains(" at "),
2025 "expected the terse default message; got: {out}"
2026 );
2027 }
2028
2029 #[test]
2030 fn hook_fires_once_per_process_even_when_sha_matches() {
2031 use std::sync::atomic::{AtomicUsize, Ordering};
2032 let dir = tempfile::tempdir().unwrap();
2036 std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
2037
2038 let calls = Arc::new(AtomicUsize::new(0));
2039 let make_hook = || -> PostActivateHook {
2040 let c = calls.clone();
2041 Arc::new(move |_p, _n| {
2042 c.fetch_add(1, Ordering::SeqCst);
2043 Ok(())
2044 })
2045 };
2046
2047 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
2049 let _ = ws.repo_management(None, false, true, false, None);
2051 assert_eq!(
2052 calls.load(Ordering::SeqCst),
2053 1,
2054 "first activate must hydrate"
2055 );
2056 let out = ws.repo_management(None, false, true, false, None);
2058 assert_eq!(
2059 calls.load(Ordering::SeqCst),
2060 1,
2061 "repeat activate in same process must skip the hook"
2062 );
2063 assert!(
2064 out.contains("build skipped"),
2065 "expected skip suffix, got: {out}"
2066 );
2067 drop(ws);
2068
2069 let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
2074 assert!(
2075 ws2.last_built_sha(&ws2.active_repo_name().unwrap())
2076 .is_some(),
2077 "sanity: last_built_sha should survive the restart"
2078 );
2079 let _ = ws2.repo_management(None, false, true, false, None);
2080 assert_eq!(
2081 calls.load(Ordering::SeqCst),
2082 2,
2083 "fresh process must re-fire the hook even when the SHA matches"
2084 );
2085 }
2086
2087 #[test]
2088 fn a_b_a_swap_rebuilds_intervening_root() {
2089 use std::sync::atomic::{AtomicUsize, Ordering};
2095 let root = tempfile::tempdir().unwrap();
2096 let a = root.path().join("projA");
2097 let b = root.path().join("projB");
2098 std::fs::create_dir_all(&a).unwrap();
2099 std::fs::create_dir_all(&b).unwrap();
2100 std::fs::write(a.join("a.txt"), b"alpha").unwrap();
2104 std::fs::write(b.join("b.txt"), b"beta").unwrap();
2105
2106 let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
2109 let built_h = built.clone();
2110 let calls = Arc::new(AtomicUsize::new(0));
2111 let calls_h = calls.clone();
2112 let hook: PostActivateHook = Arc::new(move |p, _n| {
2113 *built_h.lock().unwrap() = Some(p.to_path_buf());
2114 calls_h.fetch_add(1, Ordering::SeqCst);
2115 Ok(())
2116 });
2117
2118 let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
2119 let _ = ws.set_root_dir(&a, None);
2122 assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
2123 assert_eq!(
2124 built.lock().unwrap().clone(),
2125 Some(a.canonicalize().unwrap())
2126 );
2127
2128 let _ = ws.set_root_dir(&b, None);
2129 assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
2130 assert_eq!(
2131 built.lock().unwrap().clone(),
2132 Some(b.canonicalize().unwrap())
2133 );
2134
2135 let out = ws.set_root_dir(&a, None);
2138 assert_eq!(
2139 calls.load(Ordering::SeqCst),
2140 3,
2141 "A→B→A must rebuild A; the intervening B overwrote the live slot"
2142 );
2143 assert!(
2144 !out.contains("build skipped"),
2145 "re-bind of a non-active root must not skip; got: {out}"
2146 );
2147 assert_eq!(
2148 built.lock().unwrap().clone(),
2149 Some(a.canonicalize().unwrap()),
2150 "after A→B→A the live slot must hold A, not B"
2151 );
2152
2153 let out = ws.set_root_dir(&a, None);
2156 assert_eq!(
2157 calls.load(Ordering::SeqCst),
2158 3,
2159 "re-binding the already-active root must skip the hook"
2160 );
2161 assert!(
2162 out.contains("build skipped"),
2163 "expected skip suffix, got: {out}"
2164 );
2165 }
2166
2167 #[test]
2168 fn transaction_slow_a_fast_b_discards_stale_build_and_keeps_responses_coherent() {
2169 use std::sync::Barrier;
2170
2171 #[derive(Debug, Clone, PartialEq, Eq)]
2172 struct Installed {
2173 id: ActivationId,
2174 path: PathBuf,
2175 }
2176
2177 let root = tempfile::tempdir().unwrap();
2178 let a = root.path().join("slow-a");
2179 let b = root.path().join("fast-b");
2180 std::fs::create_dir_all(&a).unwrap();
2181 std::fs::create_dir_all(&b).unwrap();
2182 std::fs::write(a.join("a.txt"), b"a").unwrap();
2183 std::fs::write(b.join("b.txt"), b"b").unwrap();
2184 let a = a.canonicalize().unwrap();
2185 let b = b.canonicalize().unwrap();
2186
2187 let a_entered = Arc::new(Barrier::new(2));
2188 let release_a = Arc::new(Barrier::new(2));
2189 let installed: Arc<Mutex<Option<Installed>>> = Arc::new(Mutex::new(None));
2190 let hook: ActivationTransactionHook = {
2191 let a = a.clone();
2192 let a_entered = a_entered.clone();
2193 let release_a = release_a.clone();
2194 let installed = installed.clone();
2195 Arc::new(move |request| {
2196 if request.path() == a {
2197 a_entered.wait();
2198 release_a.wait();
2199 }
2200 let product = Installed {
2201 id: request.id(),
2202 path: request.path().to_path_buf(),
2203 };
2204 let installed = installed.clone();
2205 Ok(PreparedActivation::new(move || {
2206 *installed.lock().unwrap() = Some(product.clone());
2207 Ok(Some(format!(
2208 "product {} for {}",
2209 product.id,
2210 product.path.display()
2211 )))
2212 }))
2213 })
2214 };
2215 let ws = Workspace::open_local(a.clone(), None)
2216 .unwrap()
2217 .with_activation_transaction(hook);
2218
2219 let slow_ws = ws.clone();
2220 let slow_a = a.clone();
2221 let slow = std::thread::spawn(move || slow_ws.set_root_dir(&slow_a, None));
2222 a_entered.wait();
2223
2224 let fast_ws = ws.clone();
2225 let fast_b = b.clone();
2226 let fast = std::thread::spawn(move || fast_ws.set_root_dir(&fast_b, None));
2227 let fast_out = fast.join().unwrap();
2228 release_a.wait();
2229 let slow_out = slow.join().unwrap();
2230
2231 assert!(
2232 fast_out.contains(&b.display().to_string())
2233 && fast_out.contains("product 2")
2234 && !fast_out.contains(&a.display().to_string()),
2235 "fast request response must describe only its own committed product: {fast_out}"
2236 );
2237 assert!(
2238 slow_out.contains("request 1")
2239 && slow_out.contains("superseded by request 2")
2240 && !slow_out.contains("product 1"),
2241 "stale request must report supersession, not a false activation: {slow_out}"
2242 );
2243 assert_eq!(ws.active_repo_path(), Some(b.clone()));
2244 assert_eq!(installed.lock().unwrap().as_ref().unwrap().path, b);
2245 assert_eq!(
2246 ws.inner
2247 .state
2248 .read()
2249 .unwrap()
2250 .active_build
2251 .as_ref()
2252 .unwrap()
2253 .activation_id,
2254 ActivationId(2),
2255 "latest request must own the final framework state"
2256 );
2257 }
2258
2259 #[test]
2260 fn legacy_callbacks_are_serialized_through_build_and_summary() {
2261 use std::sync::Barrier;
2262
2263 let root = tempfile::tempdir().unwrap();
2264 let a = root.path().join("slow-a");
2265 let b = root.path().join("queued-b");
2266 std::fs::create_dir_all(&a).unwrap();
2267 std::fs::create_dir_all(&b).unwrap();
2268 let a = a.canonicalize().unwrap();
2269 let b = b.canonicalize().unwrap();
2270 let a_entered = Arc::new(Barrier::new(2));
2271 let release_a = Arc::new(Barrier::new(2));
2272 let installed: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
2273 let hook: PostActivateHook = {
2274 let a = a.clone();
2275 let a_entered = a_entered.clone();
2276 let release_a = release_a.clone();
2277 let installed = installed.clone();
2278 Arc::new(move |path, _name| {
2279 if path == a {
2280 a_entered.wait();
2281 release_a.wait();
2282 }
2283 *installed.lock().unwrap() = Some(path.to_path_buf());
2284 Ok(())
2285 })
2286 };
2287 let summary: ActivationSummaryHook = {
2288 let installed = installed.clone();
2289 Arc::new(move |_path, _name| {
2290 installed
2291 .lock()
2292 .unwrap()
2293 .as_ref()
2294 .map(|path| format!("legacy product {}", path.display()))
2295 })
2296 };
2297 let ws = Workspace::open_local(a.clone(), Some(hook))
2298 .unwrap()
2299 .with_activation_summary(summary);
2300
2301 let a_ws = ws.clone();
2302 let a_root = a.clone();
2303 let a_thread = std::thread::spawn(move || a_ws.set_root_dir(&a_root, None));
2304 a_entered.wait();
2305 let b_ws = ws.clone();
2306 let b_root = b.clone();
2307 let b_thread = std::thread::spawn(move || b_ws.set_root_dir(&b_root, None));
2308 release_a.wait();
2309 let a_out = a_thread.join().unwrap();
2310 let b_out = b_thread.join().unwrap();
2311
2312 assert!(
2313 a_out.contains(&format!("legacy product {}", a.display()))
2314 && !a_out.contains(&format!("legacy product {}", b.display())),
2315 "legacy A response crossed activation products: {a_out}"
2316 );
2317 assert!(
2318 b_out.contains(&format!("legacy product {}", b.display()))
2319 && !b_out.contains(&format!("legacy product {}", a.display())),
2320 "legacy B response crossed activation products: {b_out}"
2321 );
2322 assert_eq!(ws.active_repo_path(), Some(b.clone()));
2323 assert_eq!(*installed.lock().unwrap(), Some(b));
2324 }
2325
2326 #[test]
2327 fn transaction_same_root_plain_vs_revisions_is_generation_ordered() {
2328 use std::sync::Barrier;
2329
2330 let Some((_dir, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2331 return;
2332 };
2333 let root = root.canonicalize().unwrap();
2334 let plain_entered = Arc::new(Barrier::new(2));
2335 let release_plain = Arc::new(Barrier::new(2));
2336 let installed: Arc<Mutex<Option<(ActivationId, ActivationBuild)>>> =
2337 Arc::new(Mutex::new(None));
2338 let hook: ActivationTransactionHook = {
2339 let plain_entered = plain_entered.clone();
2340 let release_plain = release_plain.clone();
2341 let installed = installed.clone();
2342 Arc::new(move |request| {
2343 if matches!(request.build(), ActivationBuild::Plain) {
2344 plain_entered.wait();
2345 release_plain.wait();
2346 }
2347 let id = request.id();
2348 let build = request.build().clone();
2349 let installed = installed.clone();
2350 Ok(PreparedActivation::new(move || {
2351 *installed.lock().unwrap() = Some((id, build.clone()));
2352 Ok(Some(format!("installed request {id}: {build:?}")))
2353 }))
2354 })
2355 };
2356 let ws = Workspace::open_local(root.clone(), None)
2357 .unwrap()
2358 .with_activation_transaction(hook);
2359
2360 let plain_ws = ws.clone();
2361 let plain_root = root.clone();
2362 let plain = std::thread::spawn(move || plain_ws.set_root_dir(&plain_root, None));
2363 plain_entered.wait();
2364
2365 let revs_ws = ws.clone();
2366 let revs_root = root.clone();
2367 let revs = std::thread::spawn(move || {
2368 revs_ws.set_root_dir(&revs_root, Some(&RevsRequest::Count(2)))
2369 });
2370 let revs_out = revs.join().unwrap();
2371 release_plain.wait();
2372 let plain_out = plain.join().unwrap();
2373
2374 assert!(revs_out.contains("revs: v1.0.0, v2.0.0, HEAD"));
2375 assert!(revs_out.contains("installed request 2: Revisions"));
2376 assert!(plain_out.contains("superseded by request 2"));
2377 let state = ws.inner.state.read().unwrap();
2378 assert_eq!(state.active_repo_path.as_deref(), Some(root.as_path()));
2379 assert_eq!(
2380 state
2381 .active_build
2382 .as_ref()
2383 .and_then(|built| built.resolved_revs.clone()),
2384 Some(vec!["v1.0.0".into(), "v2.0.0".into(), "HEAD".into()])
2385 );
2386 assert!(matches!(
2387 installed.lock().unwrap().as_ref(),
2388 Some((ActivationId(2), ActivationBuild::Revisions(_)))
2389 ));
2390 }
2391
2392 #[test]
2393 fn transaction_current_failure_preserves_committed_source_and_product() {
2394 #[derive(Debug, Clone, PartialEq, Eq)]
2395 struct Installed(ActivationId, PathBuf);
2396
2397 let root = tempfile::tempdir().unwrap();
2398 let good = root.path().join("good");
2399 let broken = root.path().join("broken");
2400 std::fs::create_dir_all(&good).unwrap();
2401 std::fs::create_dir_all(&broken).unwrap();
2402 std::fs::write(good.join("good.txt"), b"good").unwrap();
2403 std::fs::write(broken.join("broken.txt"), b"broken").unwrap();
2404 let good = good.canonicalize().unwrap();
2405 let broken = broken.canonicalize().unwrap();
2406
2407 let installed: Arc<Mutex<Option<Installed>>> = Arc::new(Mutex::new(None));
2408 let hook: ActivationTransactionHook = {
2409 let broken = broken.clone();
2410 let installed = installed.clone();
2411 Arc::new(move |request| {
2412 if request.path() == broken {
2413 anyhow::bail!("builder rejected broken root");
2414 }
2415 let product = Installed(request.id(), request.path().to_path_buf());
2416 let installed = installed.clone();
2417 Ok(PreparedActivation::new(move || {
2418 *installed.lock().unwrap() = Some(product.clone());
2419 Ok(Some(format!("installed request {}", product.0)))
2420 }))
2421 })
2422 };
2423 let ws = Workspace::open_local(good.clone(), None)
2424 .unwrap()
2425 .with_activation_transaction(hook);
2426
2427 let good_out = ws.set_root_dir(&good, None);
2428 assert!(good_out.contains("installed request 1"));
2429 let broken_out = ws.set_root_dir(&broken, None);
2430 assert!(
2431 broken_out.contains("request 2")
2432 && broken_out.contains("failed during preparation")
2433 && broken_out.contains("builder rejected broken root"),
2434 "failure must be explicit and request-scoped: {broken_out}"
2435 );
2436 assert_eq!(ws.active_repo_path(), Some(good.clone()));
2437 assert_eq!(installed.lock().unwrap().as_ref().unwrap().1, good);
2438 }
2439
2440 #[test]
2441 fn transaction_stale_failure_reports_superseded_not_current_failure() {
2442 use std::sync::Barrier;
2443
2444 let root = tempfile::tempdir().unwrap();
2445 let slow = root.path().join("slow-failure");
2446 let fast = root.path().join("fast-success");
2447 std::fs::create_dir_all(&slow).unwrap();
2448 std::fs::create_dir_all(&fast).unwrap();
2449 let slow = slow.canonicalize().unwrap();
2450 let fast = fast.canonicalize().unwrap();
2451 let slow_entered = Arc::new(Barrier::new(2));
2452 let release_slow = Arc::new(Barrier::new(2));
2453 let hook: ActivationTransactionHook = {
2454 let slow = slow.clone();
2455 let slow_entered = slow_entered.clone();
2456 let release_slow = release_slow.clone();
2457 Arc::new(move |request| {
2458 if request.path() == slow {
2459 slow_entered.wait();
2460 release_slow.wait();
2461 anyhow::bail!("late preparation failure");
2462 }
2463 Ok(PreparedActivation::summary(Some(format!(
2464 "committed request {}",
2465 request.id()
2466 ))))
2467 })
2468 };
2469 let ws = Workspace::open_local(slow.clone(), None)
2470 .unwrap()
2471 .with_activation_transaction(hook);
2472
2473 let slow_ws = ws.clone();
2474 let slow_root = slow.clone();
2475 let slow_thread = std::thread::spawn(move || slow_ws.set_root_dir(&slow_root, None));
2476 slow_entered.wait();
2477 let fast_out = ws.set_root_dir(&fast, None);
2478 release_slow.wait();
2479 let slow_out = slow_thread.join().unwrap();
2480
2481 assert!(fast_out.contains("committed request 2"));
2482 assert!(
2483 slow_out.contains("superseded by request 2")
2484 && slow_out.contains("failed build")
2485 && !slow_out.contains("set_root_dir failed"),
2486 "a stale failure is a superseded outcome: {slow_out}"
2487 );
2488 assert_eq!(ws.active_repo_path(), Some(fast));
2489 }
2490
2491 fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
2500 let dir = tempfile::tempdir().unwrap();
2501 let root = dir.path().to_path_buf();
2502 let git = |args: &[&str]| {
2503 Command::new("git")
2504 .arg("-C")
2505 .arg(&root)
2506 .args(args)
2507 .output()
2508 .unwrap()
2509 };
2510 if !git(&["init"]).status.success() {
2511 return None; }
2513 git(&["config", "user.email", "t@example.com"]);
2514 git(&["config", "user.name", "Test"]);
2515 git(&["config", "commit.gpgsign", "false"]);
2516 for (i, tag) in tags.iter().enumerate() {
2517 std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
2518 git(&["add", "-A"]);
2519 assert!(
2520 git(&["commit", "-m", &format!("c{i}")]).status.success(),
2521 "git commit failed"
2522 );
2523 assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
2524 }
2525 Some((dir, root))
2526 }
2527
2528 #[test]
2531 fn classify_tag_extracts_prefix_version_prerelease() {
2532 let c = classify_tag("apache-arrow-25.0.0").unwrap();
2533 assert_eq!(c.prefix, "apache-arrow-");
2534 assert_eq!(c.version, vec![25, 0, 0]);
2535 assert!(!c.is_prerelease);
2536
2537 for t in [
2539 "apache-arrow-25.0.0.dev",
2540 "apache-arrow-25.0.0-rc1",
2541 "apache-arrow-25.0.0-RC0",
2542 "v1.2.3-beta2",
2543 "v1.2.3_alpha",
2544 "v2.0.0-preview",
2545 ] {
2546 assert!(
2547 classify_tag(t).unwrap().is_prerelease,
2548 "{t} should be prerelease"
2549 );
2550 }
2551
2552 assert_eq!(classify_tag("go/v18.0.0").unwrap().prefix, "go/v");
2554 assert_eq!(classify_tag("r-15.0.1").unwrap().prefix, "r-");
2555 assert_eq!(classify_tag("v1.2.3").unwrap().prefix, "v");
2556
2557 let c = classify_tag("arrow2-0.17.0").unwrap();
2559 assert_eq!(c.prefix, "arrow2-");
2560 assert_eq!(c.version, vec![0, 17, 0]);
2561 }
2562
2563 #[test]
2564 fn classify_tag_excludes_non_version_tags() {
2565 assert_eq!(classify_tag("r-universe-release"), None);
2566 assert_eq!(classify_tag("latest"), None);
2567 assert_eq!(classify_tag("nightly"), None);
2568 assert_eq!(classify_tag("v1.2.3-foobar"), None);
2570 }
2571
2572 #[test]
2573 fn select_family_tags_picks_dominant_release_family_skipping_prereleases() {
2574 let tags: Vec<String> = [
2578 "apache-arrow-22.0.0",
2579 "apache-arrow-23.0.0",
2580 "apache-arrow-24.0.0",
2581 "apache-arrow-25.0.0-rc0",
2582 "apache-arrow-25.0.0-rc1",
2583 "apache-arrow-25.0.0.dev",
2584 "go/v18.0.0",
2585 "r-15.0.1",
2586 "r-16.1.0",
2587 "r-universe-release",
2588 ]
2589 .iter()
2590 .map(|s| s.to_string())
2591 .collect();
2592 let got = select_family_tags(&tags, 2).unwrap();
2595 assert_eq!(got, vec!["apache-arrow-23.0.0", "apache-arrow-24.0.0"]);
2596 }
2597
2598 #[test]
2599 fn select_family_tags_fewer_stable_than_requested_uses_all_stable() {
2600 let tags: Vec<String> = ["v1.0.0", "v2.0.0", "v3.0.0-rc1"]
2601 .iter()
2602 .map(|s| s.to_string())
2603 .collect();
2604 let got = select_family_tags(&tags, 5).unwrap();
2606 assert_eq!(got, vec!["v1.0.0", "v2.0.0"]);
2607 }
2608
2609 #[test]
2610 fn select_family_tags_prerelease_only_family_falls_back_to_prereleases() {
2611 let tags: Vec<String> = ["v1.0.0-rc1", "v1.0.0-rc2", "v0.9.0-beta"]
2612 .iter()
2613 .map(|s| s.to_string())
2614 .collect();
2615 let got = select_family_tags(&tags, 2).unwrap();
2617 assert_eq!(got, vec!["v1.0.0-rc1", "v1.0.0-rc2"]);
2618 }
2619
2620 #[test]
2621 fn select_family_tags_no_version_like_tags_returns_none() {
2622 let tags: Vec<String> = ["latest", "nightly", "stable"]
2623 .iter()
2624 .map(|s| s.to_string())
2625 .collect();
2626 assert_eq!(select_family_tags(&tags, 3), None);
2627 }
2628
2629 #[test]
2632 fn resolve_revs_count_falls_back_to_raw_when_no_version_tags() {
2633 let Some((_d, root)) = git_repo_with_tags(&["latest", "nightly", "stable"]) else {
2636 return;
2637 };
2638 let ws = Workspace::open_local(root.clone(), None).unwrap();
2639 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
2640 assert_eq!(resolved.len(), 3);
2642 assert_eq!(resolved.last().unwrap(), "HEAD");
2643 assert!(resolved[..2].iter().all(|r| r != "HEAD"));
2644 }
2645
2646 #[test]
2647 fn resolve_revs_count_skips_prereleases_of_dominant_family() {
2648 let Some((_d, root)) =
2649 git_repo_with_tags(&["v1.0.0", "v2.0.0", "v3.0.0-rc1", "v3.0.0.dev"])
2650 else {
2651 return;
2652 };
2653 let ws = Workspace::open_local(root.clone(), None).unwrap();
2654 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
2655 assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
2658 }
2659
2660 #[test]
2661 fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
2662 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
2663 return;
2664 };
2665 let ws = Workspace::open_local(root.clone(), None).unwrap();
2666 let resolved = ws
2667 .resolve_revs(&root, &RevsRequest::Count(2))
2668 .expect("resolve should succeed");
2669 assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
2671 }
2672
2673 #[test]
2674 fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
2675 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2676 return;
2677 };
2678 let ws = Workspace::open_local(root.clone(), None).unwrap();
2679 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
2680 assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
2681 }
2682
2683 #[test]
2684 fn resolve_revs_count_errors_when_no_tags() {
2685 let Some((_d, root)) = git_repo_with_tags(&[]) else {
2686 return;
2687 };
2688 let git = |args: &[&str]| {
2690 Command::new("git")
2691 .arg("-C")
2692 .arg(&root)
2693 .args(args)
2694 .output()
2695 .unwrap()
2696 };
2697 std::fs::write(root.join("f.txt"), b"x").unwrap();
2698 git(&["add", "-A"]);
2699 git(&["commit", "-m", "c0"]);
2700 let ws = Workspace::open_local(root.clone(), None).unwrap();
2701 let err = ws
2702 .resolve_revs(&root, &RevsRequest::Count(3))
2703 .expect_err("no tags → error");
2704 assert!(
2705 err.to_string().contains("no tags"),
2706 "expected a 'no tags' error, got: {err}"
2707 );
2708 }
2709
2710 #[test]
2713 fn dedup_labels_is_order_preserving_first_wins() {
2714 assert_eq!(
2715 dedup_labels(vec!["HEAD".into(), "HEAD".into()]),
2716 vec!["HEAD"]
2717 );
2718 assert_eq!(
2719 dedup_labels(vec![
2720 "v1".into(),
2721 "v2".into(),
2722 "v1".into(),
2723 "v3".into(),
2724 "v2".into(),
2725 ]),
2726 vec!["v1", "v2", "v3"]
2727 );
2728 assert_eq!(dedup_labels(vec![]), Vec::<String>::new());
2730 assert_eq!(dedup_labels(vec!["a".into(), "b".into()]), vec!["a", "b"]);
2731 }
2732
2733 #[test]
2734 fn resolve_revs_list_dedups_duplicate_revspecs() {
2735 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
2736 return;
2737 };
2738 let ws = Workspace::open_local(root.clone(), None).unwrap();
2739 let got = ws
2741 .resolve_revs(
2742 &root,
2743 &RevsRequest::List(vec!["HEAD".into(), "HEAD".into()]),
2744 )
2745 .unwrap();
2746 assert_eq!(got, vec!["HEAD"]);
2747 let got = ws
2749 .resolve_revs(
2750 &root,
2751 &RevsRequest::List(vec!["v1.0.0".into(), "HEAD".into(), "v1.0.0".into()]),
2752 )
2753 .unwrap();
2754 assert_eq!(got, vec!["v1.0.0", "HEAD"]);
2755 }
2756
2757 #[test]
2758 fn resolve_revs_list_validates_and_rejects_unknown() {
2759 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
2760 return;
2761 };
2762 let ws = Workspace::open_local(root.clone(), None).unwrap();
2763 let ok = ws
2765 .resolve_revs(
2766 &root,
2767 &RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
2768 )
2769 .unwrap();
2770 assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
2771 let err = ws
2773 .resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
2774 .expect_err("unknown rev → error");
2775 assert!(
2776 err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
2777 "expected an unknown-rev error, got: {err}"
2778 );
2779 }
2780
2781 #[test]
2782 fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
2783 use std::sync::atomic::{AtomicUsize, Ordering};
2784 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
2785 return;
2786 };
2787 let plain_calls = Arc::new(AtomicUsize::new(0));
2788 let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
2789 let pc = plain_calls.clone();
2790 let plain: PostActivateHook = Arc::new(move |_p, _n| {
2791 pc.fetch_add(1, Ordering::SeqCst);
2792 Ok(())
2793 });
2794 let sr = seen_revs.clone();
2795 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
2796 *sr.lock().unwrap() = Some(revs.to_vec());
2797 Ok(())
2798 });
2799 let ws = Workspace::open_local(root.clone(), Some(plain))
2800 .unwrap()
2801 .with_post_activate_revs(revs_hook);
2802 let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
2803 assert_eq!(
2805 seen_revs.lock().unwrap().clone().unwrap(),
2806 vec!["v1.1.0", "v2.0.0", "HEAD"]
2807 );
2808 assert_eq!(
2809 plain_calls.load(Ordering::SeqCst),
2810 0,
2811 "plain hook must not fire when the revs-hook handled the request"
2812 );
2813 assert!(
2815 out.contains("revs: v1.1.0, v2.0.0, HEAD"),
2816 "activation message should list the resolved revs; got: {out}"
2817 );
2818 }
2819
2820 #[test]
2821 fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
2822 use std::sync::atomic::{AtomicUsize, Ordering};
2823 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
2824 return;
2825 };
2826 let plain_calls = Arc::new(AtomicUsize::new(0));
2827 let revs_seen = Arc::new(AtomicUsize::new(0));
2828 let pc = plain_calls.clone();
2829 let plain: PostActivateHook = Arc::new(move |_p, _n| {
2830 pc.fetch_add(1, Ordering::SeqCst);
2831 Ok(())
2832 });
2833 let rs = revs_seen.clone();
2834 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
2835 rs.fetch_add(1, Ordering::SeqCst);
2836 Ok(())
2837 });
2838 let ws = Workspace::open_local(root.clone(), Some(plain))
2839 .unwrap()
2840 .with_post_activate_revs(revs_hook);
2841 let out = ws.repo_management(None, false, true, false, None);
2843 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2844 assert_eq!(
2845 revs_seen.load(Ordering::SeqCst),
2846 0,
2847 "revs-hook must not fire when no revs were requested"
2848 );
2849 assert!(
2850 !out.contains("revs:"),
2851 "no revs line expected on a plain activation; got: {out}"
2852 );
2853 }
2854
2855 #[test]
2856 fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
2857 use std::sync::atomic::{AtomicUsize, Ordering};
2858 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2859 return;
2860 };
2861 let plain_calls = Arc::new(AtomicUsize::new(0));
2862 let pc = plain_calls.clone();
2863 let plain: PostActivateHook = Arc::new(move |_p, _n| {
2864 pc.fetch_add(1, Ordering::SeqCst);
2865 Ok(())
2866 });
2867 let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
2870 let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
2871 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2872 assert!(
2873 !out.contains("revs:"),
2874 "must not report a rev-set when only the plain hook ran; got: {out}"
2875 );
2876 }
2877
2878 #[allow(clippy::type_complexity)]
2884 fn ws_with_both_hooks(
2885 tags: &[&str],
2886 ) -> Option<(
2887 Workspace,
2888 tempfile::TempDir,
2889 PathBuf,
2890 Arc<std::sync::atomic::AtomicUsize>,
2891 Arc<std::sync::atomic::AtomicUsize>,
2892 )> {
2893 use std::sync::atomic::{AtomicUsize, Ordering};
2894 let (d, root) = git_repo_with_tags(tags)?;
2895 let plain_calls = Arc::new(AtomicUsize::new(0));
2896 let revs_calls = Arc::new(AtomicUsize::new(0));
2897 let pc = plain_calls.clone();
2898 let plain: PostActivateHook = Arc::new(move |_p, _n| {
2899 pc.fetch_add(1, Ordering::SeqCst);
2900 Ok(())
2901 });
2902 let rc = revs_calls.clone();
2903 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _r| {
2904 rc.fetch_add(1, Ordering::SeqCst);
2905 Ok(())
2906 });
2907 let ws = Workspace::open_local(root.clone(), Some(plain))
2908 .unwrap()
2909 .with_post_activate_revs(revs_hook);
2910 Some((ws, d, root, plain_calls, revs_calls))
2911 }
2912
2913 #[test]
2914 fn plain_activation_after_revs_build_rebuilds_plain() {
2915 use std::sync::atomic::Ordering;
2916 let Some((ws, _d, root, plain_calls, revs_calls)) =
2917 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2918 else {
2919 return;
2920 };
2921 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2923 assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
2924 assert_eq!(plain_calls.load(Ordering::SeqCst), 0);
2925 let out = ws.set_root_dir(&root, None);
2929 assert_eq!(
2930 plain_calls.load(Ordering::SeqCst),
2931 1,
2932 "plain re-activation after a revs build must fire the plain hook"
2933 );
2934 assert!(
2935 !out.contains("build skipped"),
2936 "must not skip a plain re-activation after a revs build; got: {out}"
2937 );
2938 assert!(
2939 !out.contains("revs:"),
2940 "plain rebuild must not claim revs; got: {out}"
2941 );
2942 let out = ws.set_root_dir(&root, None);
2945 assert_eq!(
2946 plain_calls.load(Ordering::SeqCst),
2947 1,
2948 "second plain re-bind skips"
2949 );
2950 assert!(
2951 out.contains("build skipped"),
2952 "expected skip suffix; got: {out}"
2953 );
2954 }
2955
2956 #[test]
2957 fn update_after_revs_build_reapplies_stored_revs() {
2958 use std::sync::atomic::Ordering;
2959 let Some((ws, _d, root, plain_calls, revs_calls)) =
2960 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2961 else {
2962 return;
2963 };
2964 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2966 assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
2967 let out = ws.repo_management(None, false, true, false, None);
2970 assert_eq!(
2971 revs_calls.load(Ordering::SeqCst),
2972 2,
2973 "bare update must re-apply the stored rev-set"
2974 );
2975 assert_eq!(
2976 plain_calls.load(Ordering::SeqCst),
2977 0,
2978 "bare update after a revs build must not fall to the plain hook"
2979 );
2980 assert!(
2981 out.contains("revs:"),
2982 "re-applied update should list the revs; got: {out}"
2983 );
2984 }
2985
2986 #[test]
2987 fn revs_activation_after_plain_build_always_rebuilds() {
2988 use std::sync::atomic::Ordering;
2989 let Some((ws, _d, root, plain_calls, revs_calls)) =
2990 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2991 else {
2992 return;
2993 };
2994 let _ = ws.set_root_dir(&root, None);
2996 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2997 assert_eq!(revs_calls.load(Ordering::SeqCst), 0);
2998 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
3001 assert_eq!(
3002 revs_calls.load(Ordering::SeqCst),
3003 1,
3004 "a revs request must always rebuild, even at an unchanged HEAD"
3005 );
3006 }
3007
3008 #[test]
3009 fn last_built_revs_round_trips_and_clears_on_plain_build() {
3010 let dir = tempfile::tempdir().unwrap();
3011 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
3012 ws.bump_access("acme/widgets", "cloned");
3013 assert_eq!(ws.last_built_revs("acme/widgets"), None);
3014 ws.record_built("acme/widgets", "sha1", Some(&RevsRequest::Count(3)));
3016 assert_eq!(
3017 ws.last_built_revs("acme/widgets"),
3018 Some(RevsRequest::Count(3))
3019 );
3020 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
3022 assert_eq!(
3023 ws2.last_built_revs("acme/widgets"),
3024 Some(RevsRequest::Count(3))
3025 );
3026 ws2.record_built("acme/widgets", "sha2", None);
3028 assert_eq!(ws2.last_built_revs("acme/widgets"), None);
3029 ws2.record_built(
3031 "acme/widgets",
3032 "sha3",
3033 Some(&RevsRequest::List(vec!["v1".into(), "v2".into()])),
3034 );
3035 assert_eq!(
3036 ws2.last_built_revs("acme/widgets"),
3037 Some(RevsRequest::List(vec!["v1".into(), "v2".into()]))
3038 );
3039 }
3040
3041 #[test]
3042 fn inventory_loads_legacy_entries_without_revs_field() {
3043 let dir = tempfile::tempdir().unwrap();
3047 let legacy = r#"{
3048 "old/repo": {
3049 "cloned_at": "2024-01-01T00:00:00",
3050 "last_accessed": "2024-01-01T00:00:00",
3051 "access_count": 5,
3052 "stale": false,
3053 "last_built_sha": "deadbeef"
3054 }
3055 }"#;
3056 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
3057 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
3058 assert_eq!(ws.last_built_sha("old/repo").as_deref(), Some("deadbeef"));
3059 assert_eq!(ws.last_built_revs("old/repo"), None);
3060 }
3061}