1#![allow(dead_code)]
26
27use std::collections::BTreeMap;
28use std::fs;
29use std::path::{Path, PathBuf};
30use std::process::Command;
31use std::sync::{Arc, RwLock};
32use std::time::SystemTime;
33
34use anyhow::{anyhow, Context, Result};
35use serde::{Deserialize, Serialize};
36use serde_json::json;
37
38fn validate_repo_name(name: &str) -> Result<()> {
40 let mut parts = name.split('/');
41 let org = parts.next().unwrap_or("");
42 let repo = parts.next().unwrap_or("");
43 if parts.next().is_some() || org.is_empty() || repo.is_empty() {
44 return Err(anyhow!(
45 "Invalid repo name {name:?}. Expected 'org/repo' (exactly one slash)."
46 ));
47 }
48 let valid = |s: &str| {
49 !s.is_empty()
50 && s.chars()
51 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
52 };
53 if !valid(org) || !valid(repo) {
54 return Err(anyhow!(
55 "Invalid repo name {name:?}. Letters/digits/dots/hyphens/underscores only."
56 ));
57 }
58 Ok(())
59}
60
61pub type PostActivateHook = Arc<dyn Fn(&Path, &str) -> Result<()> + Send + Sync>;
65
66pub type ActivationSummaryHook = Arc<dyn Fn(&Path, &str) -> Option<String> + Send + Sync>;
78
79pub type PostActivateRevsHook = Arc<dyn Fn(&Path, &str, &[String]) -> Result<()> + Send + Sync>;
93
94#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
101#[serde(untagged)]
102pub enum RevsRequest {
103 Count(usize),
106 List(Vec<String>),
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112struct InventoryEntry {
113 cloned_at: String,
114 last_accessed: String,
115 #[serde(default)]
116 access_count: u64,
117 #[serde(default)]
118 stale: bool,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
125 last_built_sha: Option<String>,
126}
127
128pub use crate::server::manifest::WorkspaceKind;
131
132#[derive(Clone)]
134pub struct Workspace {
135 inner: Arc<WorkspaceInner>,
136}
137
138struct WorkspaceInner {
139 kind: WorkspaceKind,
140 workspace_dir: PathBuf,
141 stale_after_days: u32,
142 state: RwLock<WorkspaceState>,
143 post_activate: Option<PostActivateHook>,
144 activation_summary: Option<ActivationSummaryHook>,
147 post_activate_revs: Option<PostActivateRevsHook>,
152}
153
154#[derive(Debug, Default)]
155struct WorkspaceState {
156 active_repo_name: Option<String>,
157 active_repo_path: Option<PathBuf>,
158 active_built_name: Option<String>,
175}
176
177impl Workspace {
178 pub fn open(
180 workspace_dir: PathBuf,
181 stale_after_days: u32,
182 post_activate: Option<PostActivateHook>,
183 ) -> Result<Self> {
184 if !workspace_dir.is_dir() {
185 fs::create_dir_all(&workspace_dir).with_context(|| {
186 format!("failed to create workspace dir {}", workspace_dir.display())
187 })?;
188 }
189 let repos_dir = workspace_dir.join("repos");
190 if !repos_dir.is_dir() {
191 fs::create_dir_all(&repos_dir)
192 .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
193 }
194 let ws = Self {
195 inner: Arc::new(WorkspaceInner {
196 kind: WorkspaceKind::Github,
197 workspace_dir,
198 stale_after_days,
199 state: RwLock::new(WorkspaceState::default()),
200 post_activate,
201 activation_summary: None,
202 post_activate_revs: None,
203 }),
204 };
205 ws.reconcile_inventory()?;
206 Ok(ws)
207 }
208
209 pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
217 if !root.is_dir() {
218 anyhow::bail!(
219 "local workspace root does not exist or is not a directory: {}",
220 root.display()
221 );
222 }
223 let canon_root = root
224 .canonicalize()
225 .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
226 let inv_dir = canon_root.join(".mcp-workspace");
229 if !inv_dir.is_dir() {
230 fs::create_dir_all(&inv_dir).with_context(|| {
231 format!("failed to create local-workspace dir {}", inv_dir.display())
232 })?;
233 }
234 let mut state = WorkspaceState::default();
235 let synthetic_name = synthesize_local_name(&canon_root);
236 state.active_repo_name = Some(synthetic_name);
237 state.active_repo_path = Some(canon_root.clone());
238 Ok(Self {
239 inner: Arc::new(WorkspaceInner {
240 kind: WorkspaceKind::Local,
241 workspace_dir: canon_root,
242 stale_after_days: u32::MAX, state: RwLock::new(state),
244 post_activate,
245 activation_summary: None,
246 post_activate_revs: None,
247 }),
248 })
249 }
250
251 pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
257 match Arc::get_mut(&mut self.inner) {
258 Some(inner) => inner.activation_summary = Some(hook),
259 None => tracing::warn!(
260 "with_activation_summary called after the workspace was cloned; summary not attached"
261 ),
262 }
263 self
264 }
265
266 pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
274 match Arc::get_mut(&mut self.inner) {
275 Some(inner) => inner.post_activate_revs = Some(hook),
276 None => tracing::warn!(
277 "with_post_activate_revs called after the workspace was cloned; revs hook not attached"
278 ),
279 }
280 self
281 }
282
283 pub fn kind(&self) -> WorkspaceKind {
284 self.inner.kind
285 }
286
287 pub fn workspace_dir(&self) -> &Path {
288 &self.inner.workspace_dir
289 }
290
291 pub fn repos_dir(&self) -> PathBuf {
292 self.inner.workspace_dir.join("repos")
293 }
294
295 fn inventory_path(&self) -> PathBuf {
296 match self.inner.kind {
297 WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
298 WorkspaceKind::Local => self
299 .inner
300 .workspace_dir
301 .join(".mcp-workspace")
302 .join("inventory.json"),
303 }
304 }
305
306 pub fn active_repo_name(&self) -> Option<String> {
308 self.inner.state.read().unwrap().active_repo_name.clone()
309 }
310
311 pub fn active_repo_path(&self) -> Option<PathBuf> {
313 self.inner.state.read().unwrap().active_repo_path.clone()
314 }
315
316 pub fn default_github_repo(&self) -> Option<String> {
325 match self.inner.kind {
326 WorkspaceKind::Github => self.active_repo_name(),
327 WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
328 }
329 }
330
331 fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
336 let path = self.inventory_path();
337 let Ok(text) = fs::read_to_string(&path) else {
338 return BTreeMap::new();
339 };
340 serde_json::from_str(&text).unwrap_or_default()
341 }
342
343 fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
344 let path = self.inventory_path();
345 let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
346 fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
347 Ok(())
348 }
349
350 fn reconcile_inventory(&self) -> Result<()> {
351 let mut inv = self.load_inventory();
352 let mut on_disk: Vec<String> = Vec::new();
353 if self.repos_dir().is_dir() {
354 for org_entry in fs::read_dir(self.repos_dir())? {
355 let Ok(org_entry) = org_entry else { continue };
356 if !org_entry.path().is_dir() {
357 continue;
358 }
359 let org = org_entry.file_name().to_string_lossy().into_owned();
360 if org.starts_with('.') {
361 continue;
362 }
363 for repo_entry in fs::read_dir(org_entry.path())? {
364 let Ok(repo_entry) = repo_entry else { continue };
365 if !repo_entry.path().is_dir() {
366 continue;
367 }
368 let repo = repo_entry.file_name().to_string_lossy().into_owned();
369 if repo.starts_with('.') {
370 continue;
371 }
372 let rname = format!("{org}/{repo}");
373 on_disk.push(rname.clone());
374 inv.entry(rname).or_insert_with(|| {
375 let mtime = repo_entry
376 .metadata()
377 .ok()
378 .and_then(|m| m.modified().ok())
379 .map(format_iso)
380 .unwrap_or_else(now_iso);
381 InventoryEntry {
382 cloned_at: mtime.clone(),
383 last_accessed: mtime,
384 access_count: 0,
385 stale: false,
386 last_built_sha: None,
387 }
388 });
389 }
390 }
391 }
392 for (rname, entry) in inv.iter_mut() {
393 if !on_disk.contains(rname) && !entry.stale {
394 entry.stale = true;
395 }
396 }
397 self.save_inventory(&inv)?;
398 Ok(())
399 }
400
401 fn bump_access(&self, name: &str, action: &str) {
402 let mut inv = self.load_inventory();
403 let now = now_iso();
404 let entry = inv
405 .entry(name.to_string())
406 .or_insert_with(|| InventoryEntry {
407 cloned_at: now.clone(),
408 last_accessed: now.clone(),
409 access_count: 0,
410 stale: false,
411 last_built_sha: None,
412 });
413 entry.last_accessed = now.clone();
414 entry.access_count += 1;
415 entry.stale = false;
416 if action == "cloned" || entry.cloned_at.is_empty() {
417 entry.cloned_at = now;
418 }
419 let _ = self.save_inventory(&inv);
420 }
421
422 fn mark_stale(&self, name: &str) {
423 let mut inv = self.load_inventory();
424 if let Some(entry) = inv.get_mut(name) {
425 entry.stale = true;
426 let _ = self.save_inventory(&inv);
427 }
428 }
429
430 fn sweep_stale(&self) -> Vec<String> {
431 if matches!(self.inner.kind, WorkspaceKind::Local) {
433 return Vec::new();
434 }
435 let mut inv = self.load_inventory();
436 let cutoff = SystemTime::now()
437 - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
438 let active = self.active_repo_name();
439 let mut swept: Vec<String> = Vec::new();
440 for (rname, entry) in inv.iter_mut() {
441 if entry.stale {
442 continue;
443 }
444 if Some(rname.as_str()) == active.as_deref() {
445 continue;
446 }
447 let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
448 if last >= cutoff {
449 continue;
450 }
451 let parts: Vec<&str> = rname.splitn(2, '/').collect();
452 if parts.len() != 2 {
453 continue;
454 }
455 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
456 if repo_path.exists() {
457 let _ = fs::remove_dir_all(&repo_path);
458 }
459 entry.stale = true;
460 swept.push(rname.clone());
461 }
462 if !swept.is_empty() {
463 let _ = self.save_inventory(&inv);
464 self.prune_empty_org_dirs();
465 }
466 swept
467 }
468
469 fn prune_empty_org_dirs(&self) {
470 let Ok(entries) = fs::read_dir(self.repos_dir()) else {
471 return;
472 };
473 for entry in entries.flatten() {
474 let path = entry.path();
475 if !path.is_dir() {
476 continue;
477 }
478 if let Ok(children) = fs::read_dir(&path) {
479 let real: Vec<_> = children
480 .flatten()
481 .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
482 .collect();
483 if real.is_empty() {
484 let _ = fs::remove_dir_all(&path);
485 }
486 }
487 }
488 }
489
490 fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
501 if matches!(self.inner.kind, WorkspaceKind::Local) {
502 let root = self
512 .inner
513 .state
514 .read()
515 .unwrap()
516 .active_repo_path
517 .clone()
518 .unwrap_or_else(|| self.inner.workspace_dir.clone());
519 let prev_sha = self.last_built_sha(name);
520 let fingerprint = fingerprint_dir(&root);
521 let action = match prev_sha {
522 Some(p) if p == fingerprint => "current",
523 None => "cloned", Some(_) => "updated",
525 };
526 return Ok((action.to_string(), root, fingerprint));
527 }
528 let parts: Vec<&str> = name.splitn(2, '/').collect();
529 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
530 if !repo_path.exists() {
531 fs::create_dir_all(repo_path.parent().unwrap()).ok();
532 let url = format!("https://github.com/{name}.git");
533 let out = Command::new("git")
541 .args([
542 "clone",
543 "--filter=tree:0",
544 "--tags",
545 &url,
546 repo_path.to_str().unwrap(),
547 ])
548 .output()
549 .context("failed to spawn `git clone`")?;
550 if !out.status.success() {
551 anyhow::bail!(
552 "git clone failed: {}",
553 String::from_utf8_lossy(&out.stderr).trim()
554 );
555 }
556 let sha = git_rev_parse(&repo_path, "HEAD")?;
557 return Ok(("cloned".to_string(), repo_path, sha));
558 }
559
560 Command::new("git")
566 .args(["fetch", "origin", "--tags"])
567 .current_dir(&repo_path)
568 .output()
569 .context("git fetch failed")?;
570 let local = git_rev_parse(&repo_path, "HEAD")?;
571 let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
572 if local != remote {
573 Command::new("git")
574 .args(["reset", "--hard", "FETCH_HEAD"])
575 .current_dir(&repo_path)
576 .output()
577 .context("git reset failed")?;
578 let sha = git_rev_parse(&repo_path, "HEAD")?;
579 return Ok(("updated".to_string(), repo_path, sha));
580 }
581 Ok(("current".to_string(), repo_path, local))
582 }
583
584 fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
600 match req {
601 RevsRequest::Count(n) => {
602 let out = Command::new("git")
603 .args(["tag", "--sort=-v:refname"])
604 .current_dir(repo_path)
605 .output()
606 .context("failed to spawn `git tag`")?;
607 if !out.status.success() {
608 anyhow::bail!(
609 "cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
610 repo_path.display(),
611 String::from_utf8_lossy(&out.stderr).trim()
612 );
613 }
614 let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
615 .lines()
616 .map(|l| l.trim().to_string())
617 .filter(|l| !l.is_empty())
618 .collect();
619 if tags.is_empty() {
620 anyhow::bail!(
621 "revs={n} requested but '{}' has no tags to resolve",
622 repo_path.display()
623 );
624 }
625 let mut chosen: Vec<String> = tags.into_iter().take(*n).collect();
629 chosen.reverse();
630 chosen.push("HEAD".to_string());
631 Ok(chosen)
632 }
633 RevsRequest::List(revs) => {
634 if revs.is_empty() {
635 anyhow::bail!("revs list is empty — pass at least one revision");
636 }
637 for r in revs {
638 let out = Command::new("git")
639 .args([
640 "rev-parse",
641 "--verify",
642 "--quiet",
643 &format!("{r}^{{commit}}"),
644 ])
645 .current_dir(repo_path)
646 .output()
647 .context("failed to spawn `git rev-parse`")?;
648 if !out.status.success() {
649 anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
650 }
651 }
652 Ok(revs.clone())
653 }
654 }
655 }
656
657 fn activate(
676 &self,
677 name: &str,
678 force_rebuild: bool,
679 revs: Option<&RevsRequest>,
680 ) -> Result<String> {
681 let prev_built_sha = self.last_built_sha(name);
682 let (action, repo_path, head_sha) = self.clone_or_update(name)?;
683 let resolved_revs = match revs {
687 Some(req) => Some(self.resolve_revs(&repo_path, req)?),
688 None => None,
689 };
690 self.bump_access(name, &action);
691 let is_active_built = {
692 let mut state = self.inner.state.write().unwrap();
693 state.active_repo_name = Some(name.to_string());
694 state.active_repo_path = Some(repo_path.clone());
695 state.active_built_name.as_deref() == Some(name)
696 };
697
698 let already_built = !force_rebuild
720 && resolved_revs.is_none()
721 && action == "current"
722 && prev_built_sha.as_deref() == Some(head_sha.as_str())
723 && is_active_built;
724 let mut hook_skipped = false;
725 let mut revs_hook_ran = false;
728 let hook_ok = if already_built {
729 hook_skipped = true;
730 true
731 } else if let (Some(resolved), Some(revs_hook)) =
732 (resolved_revs.as_ref(), &self.inner.post_activate_revs)
733 {
734 revs_hook_ran = true;
735 match revs_hook(&repo_path, name, resolved) {
736 Ok(()) => true,
737 Err(e) => {
738 tracing::warn!("post-activate revs hook for {name} failed: {e}");
739 false
740 }
741 }
742 } else if let Some(hook) = &self.inner.post_activate {
743 match hook(&repo_path, name) {
746 Ok(()) => true,
747 Err(e) => {
748 tracing::warn!("post-activate hook for {name} failed: {e}");
749 false
750 }
751 }
752 } else {
753 true
756 };
757 if hook_ok {
758 self.record_built_sha(name, &head_sha);
759 }
760 if hook_ok && !hook_skipped {
768 self.inner.state.write().unwrap().active_built_name = Some(name.to_string());
769 }
770 let verb = match action.as_str() {
771 "cloned" => "Cloned",
772 "updated" => "Updated",
773 "current" => "Activated (already up to date)",
774 other => other,
775 };
776 let suffix = if hook_skipped {
777 " [build skipped: HEAD matches last-built SHA]"
778 } else {
779 ""
780 };
781 let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
782 if revs_hook_ran && hook_ok {
787 if let Some(resolved) = &resolved_revs {
788 base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
789 }
790 }
791 let summary = if hook_ok {
797 self.inner
798 .activation_summary
799 .as_ref()
800 .and_then(|h| h(&repo_path, name))
801 } else {
802 None
803 };
804 Ok(match summary {
805 Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
806 _ => base,
807 })
808 }
809
810 fn record_built_sha(&self, name: &str, sha: &str) {
811 let mut inv = self.load_inventory();
812 if let Some(entry) = inv.get_mut(name) {
813 entry.last_built_sha = Some(sha.to_string());
814 let _ = self.save_inventory(&inv);
815 }
816 }
817
818 pub fn last_built_sha(&self, name: &str) -> Option<String> {
823 self.load_inventory()
824 .get(name)
825 .and_then(|e| e.last_built_sha.clone())
826 }
827
828 fn delete(&self, name: &str) -> Result<String> {
829 let parts: Vec<&str> = name.splitn(2, '/').collect();
830 if parts.len() != 2 {
831 anyhow::bail!("Invalid repo name");
832 }
833 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
834 let mut deleted = Vec::new();
835 if repo_path.exists() {
836 fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
837 deleted.push("repo");
838 }
839 self.mark_stale(name);
840 self.prune_empty_org_dirs();
841 if deleted.is_empty() {
842 return Ok(format!("Nothing to delete — '{name}' not found."));
843 }
844 let mut state = self.inner.state.write().unwrap();
845 if state.active_repo_name.as_deref() == Some(name) {
846 state.active_repo_name = None;
847 state.active_repo_path = None;
848 return Ok(format!(
849 "Deleted {}. Active repo cleared.",
850 deleted.join(", ")
851 ));
852 }
853 Ok(format!("Deleted {}.", deleted.join(", ")))
854 }
855
856 fn list(&self) -> String {
857 let inv = self.load_inventory();
858 if inv.is_empty() {
859 return "No repos cloned yet. Call repo_management('org/repo') to clone one."
860 .to_string();
861 }
862 let active = self.active_repo_name();
863 let mut live: Vec<String> = Vec::new();
864 let mut stale_lines: Vec<String> = Vec::new();
865 for (rname, entry) in &inv {
866 let marker = if Some(rname.as_str()) == active.as_deref() {
867 " [active]"
868 } else {
869 ""
870 };
871 let access = format!(
872 "{} access{}, last {}",
873 entry.access_count,
874 if entry.access_count == 1 { "" } else { "es" },
875 relative_time(&entry.last_accessed)
876 );
877 if entry.stale {
878 stale_lines.push(format!(
879 " {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
880 ));
881 } else {
882 live.push(format!(" {rname}{marker} ({access})"));
883 }
884 }
885 let mut out = String::new();
886 if !live.is_empty() {
887 out.push_str(&format!(
888 "{} live repo(s):\n{}",
889 live.len(),
890 live.join("\n")
891 ));
892 }
893 if !stale_lines.is_empty() {
894 if !out.is_empty() {
895 out.push_str("\n\n");
896 }
897 out.push_str(&format!(
898 "{} stale repo(s):\n{}",
899 stale_lines.len(),
900 stale_lines.join("\n")
901 ));
902 }
903 out
904 }
905
906 pub fn repo_management(
920 &self,
921 name: Option<&str>,
922 delete: bool,
923 update: bool,
924 force_rebuild: bool,
925 revs: Option<&RevsRequest>,
926 ) -> String {
927 if matches!(self.inner.kind, WorkspaceKind::Local) {
929 if name.is_some() {
930 return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
931 to switch the active root, or pass `update=true` / `force_rebuild=true` \
932 to rebuild against the current root."
933 .to_string();
934 }
935 if delete {
936 return "Local-workspace mode does not support `delete`. The root is owned by the \
937 operator; remove it manually."
938 .to_string();
939 }
940 let active = match self.active_repo_name() {
941 Some(n) => n,
942 None => return "No active local root.".to_string(),
943 };
944 let _ = update; return self
951 .activate(&active, force_rebuild, revs)
952 .unwrap_or_else(|e| format!("rebuild failed: {e}"));
953 }
954
955 let swept = self.sweep_stale();
956 let prefix = if swept.is_empty() {
957 String::new()
958 } else {
959 format!(
960 "[Swept {} idle repo(s) (>{}d): {}]\n\n",
961 swept.len(),
962 self.inner.stale_after_days,
963 swept.join(", ")
964 )
965 };
966
967 if name.is_none() && !update {
968 return prefix + &self.list();
969 }
970
971 if update {
972 let Some(active) = self.active_repo_name() else {
973 return prefix + "No active repository. Call repo_management('org/repo') first.";
974 };
975 return prefix
976 + &self
977 .activate(&active, force_rebuild, revs)
978 .unwrap_or_else(|e| format!("update failed: {e}"));
979 }
980
981 let Some(name) = name else {
982 return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
983 };
984 if let Err(e) = validate_repo_name(name) {
985 return prefix + &e.to_string();
986 }
987 if delete {
988 return prefix
989 + &self
990 .delete(name)
991 .unwrap_or_else(|e| format!("delete failed: {e}"));
992 }
993 prefix
994 + &self
995 .activate(name, force_rebuild, revs)
996 .unwrap_or_else(|e| format!("activate failed: {e}"))
997 }
998
999 pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
1006 if !matches!(self.inner.kind, WorkspaceKind::Local) {
1007 return "set_root_dir is only valid in local-workspace mode.".to_string();
1008 }
1009 if !new_root.is_dir() {
1010 return format!(
1011 "Path does not exist or is not a directory: {}",
1012 new_root.display()
1013 );
1014 }
1015 let canon = match new_root.canonicalize() {
1016 Ok(p) => p,
1017 Err(e) => return format!("canonicalize failed: {e}"),
1018 };
1019 let synthetic = synthesize_local_name(&canon);
1020 {
1021 let mut state = self.inner.state.write().unwrap();
1022 state.active_repo_name = Some(synthetic.clone());
1023 state.active_repo_path = Some(canon.clone());
1024 }
1025 self.activate(&synthetic, false, revs)
1029 .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
1030 }
1031}
1032
1033fn synthesize_local_name(root: &Path) -> String {
1037 let name = root
1038 .file_name()
1039 .map(|s| s.to_string_lossy().into_owned())
1040 .unwrap_or_else(|| "local".to_string());
1041 format!("local/{name}")
1042}
1043
1044fn parse_origin_repo(root: &Path) -> Option<String> {
1055 let out = Command::new("git")
1056 .arg("-C")
1057 .arg(root)
1058 .args(["remote", "get-url", "origin"])
1059 .output()
1060 .ok()?;
1061 if !out.status.success() {
1062 return None;
1063 }
1064 let url = String::from_utf8(out.stdout).ok()?;
1065 parse_github_remote(url.trim())
1066}
1067
1068fn parse_github_remote(url: &str) -> Option<String> {
1071 let path = url
1075 .strip_prefix("git@github.com:")
1076 .or_else(|| url.strip_prefix("https://github.com/"))
1077 .or_else(|| url.strip_prefix("http://github.com/"))
1078 .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
1079 let path = path.strip_suffix(".git").unwrap_or(path);
1080 let path = path.trim_end_matches('/');
1081 let mut parts = path.split('/');
1083 let org = parts.next().filter(|s| !s.is_empty())?;
1084 let repo = parts.next().filter(|s| !s.is_empty())?;
1085 if parts.next().is_some() {
1086 return None;
1087 }
1088 Some(format!("{org}/{repo}"))
1089}
1090
1091fn fingerprint_dir(root: &Path) -> String {
1096 use std::hash::{Hash, Hasher};
1097 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1098 let walker = ignore::WalkBuilder::new(root)
1099 .standard_filters(true)
1100 .hidden(true)
1101 .git_ignore(true)
1102 .build();
1103 for entry in walker.flatten() {
1104 if !entry.path().is_file() {
1105 continue;
1106 }
1107 let Ok(meta) = entry.metadata() else { continue };
1108 let mtime = meta
1109 .modified()
1110 .ok()
1111 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1112 .map(|d| d.as_secs())
1113 .unwrap_or(0);
1114 entry.path().to_string_lossy().hash(&mut hasher);
1115 mtime.hash(&mut hasher);
1116 meta.len().hash(&mut hasher);
1117 }
1118 format!("local-{:016x}", hasher.finish())
1119}
1120
1121fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
1122 let out = Command::new("git")
1123 .args(["rev-parse", refspec])
1124 .current_dir(repo_path)
1125 .output()
1126 .context("git rev-parse failed")?;
1127 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1128}
1129
1130fn now_iso() -> String {
1131 format_iso(SystemTime::now())
1132}
1133
1134fn format_iso(t: SystemTime) -> String {
1135 let secs = t
1136 .duration_since(SystemTime::UNIX_EPOCH)
1137 .map(|d| d.as_secs())
1138 .unwrap_or(0);
1139 chrono_lite::format_secs(secs)
1141}
1142
1143fn parse_iso(s: &str) -> Option<SystemTime> {
1144 let secs = chrono_lite::parse_secs(s)?;
1145 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
1146}
1147
1148fn relative_time(iso: &str) -> String {
1149 let Some(t) = parse_iso(iso) else {
1150 return "unknown".to_string();
1151 };
1152 let now = SystemTime::now();
1153 let delta = now.duration_since(t).unwrap_or_default().as_secs();
1154 if delta < 3600 {
1155 "just now".to_string()
1156 } else if delta < 86_400 {
1157 format!("{}h ago", delta / 3600)
1158 } else {
1159 format!("{}d ago", delta / 86_400)
1160 }
1161}
1162
1163mod chrono_lite {
1166 pub fn format_secs(secs: u64) -> String {
1167 let days = (secs / 86_400) as i64;
1169 let time = secs % 86_400;
1170 let (y, mo, d) = days_to_civil(days + 719_468);
1171 let h = time / 3600;
1172 let m = (time / 60) % 60;
1173 let s = time % 60;
1174 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
1175 }
1176
1177 pub fn parse_secs(s: &str) -> Option<u64> {
1178 let bytes = s.as_bytes();
1181 if bytes.len() < 19 {
1182 return None;
1183 }
1184 let y: i64 = s.get(0..4)?.parse().ok()?;
1185 let mo: u32 = s.get(5..7)?.parse().ok()?;
1186 let d: u32 = s.get(8..10)?.parse().ok()?;
1187 let h: u64 = s.get(11..13)?.parse().ok()?;
1188 let m: u64 = s.get(14..16)?.parse().ok()?;
1189 let sc: u64 = s.get(17..19)?.parse().ok()?;
1190 let days = civil_to_days(y, mo, d) - 719_468;
1191 Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
1192 }
1193
1194 fn days_to_civil(z: i64) -> (i64, u32, u32) {
1195 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1196 let doe = (z - era * 146_097) as u64;
1197 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1198 let y = (yoe as i64) + era * 400;
1199 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1200 let mp = (5 * doy + 2) / 153;
1201 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1202 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
1203 let y = if m <= 2 { y + 1 } else { y };
1204 (y, m, d)
1205 }
1206
1207 fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
1208 let y = if m <= 2 { y - 1 } else { y };
1209 let era = if y >= 0 { y } else { y - 399 } / 400;
1210 let yoe = (y - era * 400) as u64;
1211 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
1212 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1213 era * 146_097 + doe as i64
1214 }
1215}
1216
1217#[allow(dead_code)]
1219fn _json_keepalive() {
1220 let _ = json!({});
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use super::*;
1226
1227 #[test]
1228 fn validates_repo_names() {
1229 assert!(validate_repo_name("pydata/xarray").is_ok());
1230 assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1231 assert!(validate_repo_name("xarray").is_err());
1232 assert!(validate_repo_name("a/b/c").is_err());
1233 assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1234 }
1235
1236 #[test]
1237 fn open_creates_layout() {
1238 let dir = tempfile::tempdir().unwrap();
1239 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1240 assert!(ws.repos_dir().is_dir());
1241 }
1242
1243 #[test]
1244 fn empty_list() {
1245 let dir = tempfile::tempdir().unwrap();
1246 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1247 let out = ws.repo_management(None, false, false, false, None);
1248 assert!(out.contains("No repos cloned yet"));
1249 }
1250
1251 #[test]
1252 fn invalid_repo_name_rejected() {
1253 let dir = tempfile::tempdir().unwrap();
1254 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1255 let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
1256 assert!(out.contains("Invalid repo name"));
1257 }
1258
1259 #[test]
1260 fn delete_unknown() {
1261 let dir = tempfile::tempdir().unwrap();
1262 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1263 let out = ws.repo_management(Some("nope/none"), true, false, false, None);
1264 assert!(out.contains("Nothing to delete"));
1265 }
1266
1267 #[test]
1268 fn iso_round_trip() {
1269 let now = SystemTime::now()
1270 .duration_since(SystemTime::UNIX_EPOCH)
1271 .unwrap()
1272 .as_secs();
1273 let s = chrono_lite::format_secs(now);
1274 let back = chrono_lite::parse_secs(&s).unwrap();
1275 assert_eq!(now, back);
1276 }
1277
1278 #[test]
1279 fn last_built_sha_round_trip() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1282 ws.bump_access("acme/widgets", "cloned");
1284 assert_eq!(ws.last_built_sha("acme/widgets"), None);
1285 ws.record_built_sha("acme/widgets", "abc1234deadbeef");
1286 assert_eq!(
1287 ws.last_built_sha("acme/widgets").as_deref(),
1288 Some("abc1234deadbeef")
1289 );
1290 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1292 assert_eq!(
1293 ws2.last_built_sha("acme/widgets").as_deref(),
1294 Some("abc1234deadbeef")
1295 );
1296 }
1297
1298 #[test]
1299 fn inventory_loads_legacy_entries_without_sha_field() {
1300 let dir = tempfile::tempdir().unwrap();
1301 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1302 let legacy = r#"{
1304 "old/repo": {
1305 "cloned_at": "2024-01-01T00:00:00",
1306 "last_accessed": "2024-01-01T00:00:00",
1307 "access_count": 5,
1308 "stale": false
1309 }
1310 }"#;
1311 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1312 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1314 assert_eq!(ws2.last_built_sha("old/repo"), None);
1315 let _ = ws;
1316 }
1317
1318 #[test]
1319 fn auto_rebuild_gate_skips_when_sha_matches() {
1320 use std::sync::atomic::{AtomicUsize, Ordering};
1321 let dir = tempfile::tempdir().unwrap();
1322 let calls = Arc::new(AtomicUsize::new(0));
1323 let calls_h = calls.clone();
1324 let hook: PostActivateHook = Arc::new(move |_path, _name| {
1325 calls_h.fetch_add(1, Ordering::SeqCst);
1326 Ok(())
1327 });
1328 let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1334 ws.bump_access("acme/widgets", "cloned");
1336 ws.record_built_sha("acme/widgets", "sha_one");
1337 assert_eq!(
1338 ws.last_built_sha("acme/widgets").as_deref(),
1339 Some("sha_one")
1340 );
1341 ws.record_built_sha("acme/widgets", "sha_one");
1344 assert_eq!(
1345 ws.last_built_sha("acme/widgets").as_deref(),
1346 Some("sha_one")
1347 );
1348 assert_eq!(calls.load(Ordering::SeqCst), 0);
1351 }
1352
1353 #[test]
1354 fn local_workspace_binds_root_immediately() {
1355 let dir = tempfile::tempdir().unwrap();
1356 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1357 assert_eq!(ws.kind(), WorkspaceKind::Local);
1358 assert!(ws.active_repo_path().is_some());
1359 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1360 }
1361
1362 #[test]
1363 fn local_workspace_rejects_github_ops() {
1364 let dir = tempfile::tempdir().unwrap();
1365 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1366 let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
1367 assert!(out.contains("does not accept a repo name"));
1368 let out = ws.repo_management(None, true, false, false, None);
1369 assert!(out.contains("does not support `delete`"));
1370 }
1371
1372 #[test]
1373 fn local_workspace_update_rebuilds() {
1374 use std::sync::atomic::{AtomicUsize, Ordering};
1375 let dir = tempfile::tempdir().unwrap();
1376 std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1378 let calls = Arc::new(AtomicUsize::new(0));
1379 let calls_h = calls.clone();
1380 let hook: PostActivateHook = Arc::new(move |_p, _n| {
1381 calls_h.fetch_add(1, Ordering::SeqCst);
1382 Ok(())
1383 });
1384 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1385 let _ = ws.repo_management(None, false, true, false, None);
1387 assert_eq!(calls.load(Ordering::SeqCst), 1);
1388 let out = ws.repo_management(None, false, true, false, None);
1390 assert_eq!(
1391 calls.load(Ordering::SeqCst),
1392 1,
1393 "auto-rebuild gate must skip"
1394 );
1395 assert!(out.contains("build skipped"));
1396 }
1397
1398 #[test]
1399 fn parses_github_remote_forms() {
1400 assert_eq!(
1401 parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1402 Some("kkollsga/kglite")
1403 );
1404 assert_eq!(
1405 parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1406 Some("kkollsga/kglite")
1407 );
1408 assert_eq!(
1410 parse_github_remote("https://github.com/acme/widget/").as_deref(),
1411 Some("acme/widget")
1412 );
1413 assert_eq!(
1414 parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1415 Some("acme/widget")
1416 );
1417 assert_eq!(
1419 parse_github_remote("https://gitlab.com/acme/widget.git"),
1420 None
1421 );
1422 assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1423 assert_eq!(parse_github_remote("not a url"), None);
1424 }
1425
1426 #[test]
1427 fn local_default_github_repo_uses_origin_remote() {
1428 let dir = tempfile::tempdir().unwrap();
1429 let root = dir.path();
1430 let git = |args: &[&str]| {
1433 Command::new("git")
1434 .arg("-C")
1435 .arg(root)
1436 .args(args)
1437 .output()
1438 .unwrap()
1439 };
1440 if !git(&["init"]).status.success() {
1441 return;
1443 }
1444 git(&[
1445 "remote",
1446 "add",
1447 "origin",
1448 "https://github.com/acme/widget.git",
1449 ]);
1450 let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1451 assert_eq!(
1452 ws.default_github_repo().as_deref(),
1453 Some("acme/widget"),
1454 "local default repo must come from the origin remote, not the inventory key"
1455 );
1456 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1458 }
1459
1460 #[test]
1461 fn local_default_github_repo_none_without_remote() {
1462 let dir = tempfile::tempdir().unwrap();
1463 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1464 let def = ws.default_github_repo();
1466 assert!(
1467 def.is_none(),
1468 "expected None for a non-git local root, got {def:?}"
1469 );
1470 }
1471
1472 #[test]
1473 fn set_root_dir_only_in_local_mode() {
1474 let dir = tempfile::tempdir().unwrap();
1475 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1476 let out = ws.set_root_dir(dir.path(), None);
1477 assert!(out.contains("only valid in local-workspace"));
1478 }
1479
1480 #[test]
1481 fn update_with_no_active_repo() {
1482 let dir = tempfile::tempdir().unwrap();
1483 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1484 let out = ws.repo_management(None, false, true, false, None);
1485 assert!(out.contains("No active repository"));
1486 }
1487
1488 #[test]
1489 fn set_root_dir_updates_active_path() {
1490 let dir = tempfile::tempdir().unwrap();
1491 let child = dir.path().join("child");
1492 std::fs::create_dir_all(&child).unwrap();
1493 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1494 let _ = ws.set_root_dir(&child, None);
1495 assert_eq!(
1496 ws.active_repo_path().unwrap(),
1497 child.canonicalize().unwrap(),
1498 "set_root_dir didn't update active_repo_path"
1499 );
1500 }
1501
1502 #[test]
1503 fn set_root_dir_post_activate_fires_against_new_root() {
1504 let dir = tempfile::tempdir().unwrap();
1505 let child = dir.path().join("child");
1506 std::fs::create_dir_all(&child).unwrap();
1507 std::fs::write(child.join("a.txt"), b"hi").unwrap();
1508 let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1509 let seen = seen_path.clone();
1510 let hook: PostActivateHook = Arc::new(move |p, _n| {
1511 *seen.lock().unwrap() = Some(p.to_path_buf());
1512 Ok(())
1513 });
1514 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1515 let _ = ws.set_root_dir(&child, None);
1516 assert_eq!(
1517 seen_path.lock().unwrap().clone().unwrap(),
1518 child.canonicalize().unwrap(),
1519 "post_activate hook saw the wrong root after set_root_dir"
1520 );
1521 }
1522
1523 #[test]
1524 fn activation_summary_appended_to_activate_message() {
1525 let dir = tempfile::tempdir().unwrap();
1526 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1527 let summary: ActivationSummaryHook =
1528 Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
1529 let ws = Workspace::open_local(dir.path().to_path_buf(), None)
1530 .unwrap()
1531 .with_activation_summary(summary);
1532 let out = ws.repo_management(None, false, true, false, None);
1533 assert!(
1534 out.contains("Graph ready: 3 Functions."),
1535 "activation message should include the summary; got: {out}"
1536 );
1537 }
1538
1539 #[test]
1540 fn activation_summary_absent_when_not_configured() {
1541 let dir = tempfile::tempdir().unwrap();
1542 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1543 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1544 let out = ws.repo_management(None, false, true, false, None);
1545 assert!(!out.contains("Graph ready"));
1546 assert!(
1547 out.contains(" at "),
1548 "expected the terse default message; got: {out}"
1549 );
1550 }
1551
1552 #[test]
1553 fn hook_fires_once_per_process_even_when_sha_matches() {
1554 use std::sync::atomic::{AtomicUsize, Ordering};
1555 let dir = tempfile::tempdir().unwrap();
1559 std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1560
1561 let calls = Arc::new(AtomicUsize::new(0));
1562 let make_hook = || -> PostActivateHook {
1563 let c = calls.clone();
1564 Arc::new(move |_p, _n| {
1565 c.fetch_add(1, Ordering::SeqCst);
1566 Ok(())
1567 })
1568 };
1569
1570 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1572 let _ = ws.repo_management(None, false, true, false, None);
1574 assert_eq!(
1575 calls.load(Ordering::SeqCst),
1576 1,
1577 "first activate must hydrate"
1578 );
1579 let out = ws.repo_management(None, false, true, false, None);
1581 assert_eq!(
1582 calls.load(Ordering::SeqCst),
1583 1,
1584 "repeat activate in same process must skip the hook"
1585 );
1586 assert!(
1587 out.contains("build skipped"),
1588 "expected skip suffix, got: {out}"
1589 );
1590 drop(ws);
1591
1592 let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1597 assert!(
1598 ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1599 .is_some(),
1600 "sanity: last_built_sha should survive the restart"
1601 );
1602 let _ = ws2.repo_management(None, false, true, false, None);
1603 assert_eq!(
1604 calls.load(Ordering::SeqCst),
1605 2,
1606 "fresh process must re-fire the hook even when the SHA matches"
1607 );
1608 }
1609
1610 #[test]
1611 fn a_b_a_swap_rebuilds_intervening_root() {
1612 use std::sync::atomic::{AtomicUsize, Ordering};
1618 let root = tempfile::tempdir().unwrap();
1619 let a = root.path().join("projA");
1620 let b = root.path().join("projB");
1621 std::fs::create_dir_all(&a).unwrap();
1622 std::fs::create_dir_all(&b).unwrap();
1623 std::fs::write(a.join("a.txt"), b"alpha").unwrap();
1627 std::fs::write(b.join("b.txt"), b"beta").unwrap();
1628
1629 let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1632 let built_h = built.clone();
1633 let calls = Arc::new(AtomicUsize::new(0));
1634 let calls_h = calls.clone();
1635 let hook: PostActivateHook = Arc::new(move |p, _n| {
1636 *built_h.lock().unwrap() = Some(p.to_path_buf());
1637 calls_h.fetch_add(1, Ordering::SeqCst);
1638 Ok(())
1639 });
1640
1641 let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
1642 let _ = ws.set_root_dir(&a, None);
1645 assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
1646 assert_eq!(
1647 built.lock().unwrap().clone(),
1648 Some(a.canonicalize().unwrap())
1649 );
1650
1651 let _ = ws.set_root_dir(&b, None);
1652 assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
1653 assert_eq!(
1654 built.lock().unwrap().clone(),
1655 Some(b.canonicalize().unwrap())
1656 );
1657
1658 let out = ws.set_root_dir(&a, None);
1661 assert_eq!(
1662 calls.load(Ordering::SeqCst),
1663 3,
1664 "A→B→A must rebuild A; the intervening B overwrote the live slot"
1665 );
1666 assert!(
1667 !out.contains("build skipped"),
1668 "re-bind of a non-active root must not skip; got: {out}"
1669 );
1670 assert_eq!(
1671 built.lock().unwrap().clone(),
1672 Some(a.canonicalize().unwrap()),
1673 "after A→B→A the live slot must hold A, not B"
1674 );
1675
1676 let out = ws.set_root_dir(&a, None);
1679 assert_eq!(
1680 calls.load(Ordering::SeqCst),
1681 3,
1682 "re-binding the already-active root must skip the hook"
1683 );
1684 assert!(
1685 out.contains("build skipped"),
1686 "expected skip suffix, got: {out}"
1687 );
1688 }
1689
1690 fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
1699 let dir = tempfile::tempdir().unwrap();
1700 let root = dir.path().to_path_buf();
1701 let git = |args: &[&str]| {
1702 Command::new("git")
1703 .arg("-C")
1704 .arg(&root)
1705 .args(args)
1706 .output()
1707 .unwrap()
1708 };
1709 if !git(&["init"]).status.success() {
1710 return None; }
1712 git(&["config", "user.email", "t@example.com"]);
1713 git(&["config", "user.name", "Test"]);
1714 git(&["config", "commit.gpgsign", "false"]);
1715 for (i, tag) in tags.iter().enumerate() {
1716 std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
1717 git(&["add", "-A"]);
1718 assert!(
1719 git(&["commit", "-m", &format!("c{i}")]).status.success(),
1720 "git commit failed"
1721 );
1722 assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
1723 }
1724 Some((dir, root))
1725 }
1726
1727 #[test]
1728 fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
1729 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
1730 return;
1731 };
1732 let ws = Workspace::open_local(root.clone(), None).unwrap();
1733 let resolved = ws
1734 .resolve_revs(&root, &RevsRequest::Count(2))
1735 .expect("resolve should succeed");
1736 assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
1738 }
1739
1740 #[test]
1741 fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
1742 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
1743 return;
1744 };
1745 let ws = Workspace::open_local(root.clone(), None).unwrap();
1746 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
1747 assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
1748 }
1749
1750 #[test]
1751 fn resolve_revs_count_errors_when_no_tags() {
1752 let Some((_d, root)) = git_repo_with_tags(&[]) else {
1753 return;
1754 };
1755 let git = |args: &[&str]| {
1757 Command::new("git")
1758 .arg("-C")
1759 .arg(&root)
1760 .args(args)
1761 .output()
1762 .unwrap()
1763 };
1764 std::fs::write(root.join("f.txt"), b"x").unwrap();
1765 git(&["add", "-A"]);
1766 git(&["commit", "-m", "c0"]);
1767 let ws = Workspace::open_local(root.clone(), None).unwrap();
1768 let err = ws
1769 .resolve_revs(&root, &RevsRequest::Count(3))
1770 .expect_err("no tags → error");
1771 assert!(
1772 err.to_string().contains("no tags"),
1773 "expected a 'no tags' error, got: {err}"
1774 );
1775 }
1776
1777 #[test]
1778 fn resolve_revs_list_validates_and_rejects_unknown() {
1779 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
1780 return;
1781 };
1782 let ws = Workspace::open_local(root.clone(), None).unwrap();
1783 let ok = ws
1785 .resolve_revs(
1786 &root,
1787 &RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
1788 )
1789 .unwrap();
1790 assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
1791 let err = ws
1793 .resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
1794 .expect_err("unknown rev → error");
1795 assert!(
1796 err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
1797 "expected an unknown-rev error, got: {err}"
1798 );
1799 }
1800
1801 #[test]
1802 fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
1803 use std::sync::atomic::{AtomicUsize, Ordering};
1804 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
1805 return;
1806 };
1807 let plain_calls = Arc::new(AtomicUsize::new(0));
1808 let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
1809 let pc = plain_calls.clone();
1810 let plain: PostActivateHook = Arc::new(move |_p, _n| {
1811 pc.fetch_add(1, Ordering::SeqCst);
1812 Ok(())
1813 });
1814 let sr = seen_revs.clone();
1815 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
1816 *sr.lock().unwrap() = Some(revs.to_vec());
1817 Ok(())
1818 });
1819 let ws = Workspace::open_local(root.clone(), Some(plain))
1820 .unwrap()
1821 .with_post_activate_revs(revs_hook);
1822 let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
1823 assert_eq!(
1825 seen_revs.lock().unwrap().clone().unwrap(),
1826 vec!["v1.1.0", "v2.0.0", "HEAD"]
1827 );
1828 assert_eq!(
1829 plain_calls.load(Ordering::SeqCst),
1830 0,
1831 "plain hook must not fire when the revs-hook handled the request"
1832 );
1833 assert!(
1835 out.contains("revs: v1.1.0, v2.0.0, HEAD"),
1836 "activation message should list the resolved revs; got: {out}"
1837 );
1838 }
1839
1840 #[test]
1841 fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
1842 use std::sync::atomic::{AtomicUsize, Ordering};
1843 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
1844 return;
1845 };
1846 let plain_calls = Arc::new(AtomicUsize::new(0));
1847 let revs_seen = Arc::new(AtomicUsize::new(0));
1848 let pc = plain_calls.clone();
1849 let plain: PostActivateHook = Arc::new(move |_p, _n| {
1850 pc.fetch_add(1, Ordering::SeqCst);
1851 Ok(())
1852 });
1853 let rs = revs_seen.clone();
1854 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
1855 rs.fetch_add(1, Ordering::SeqCst);
1856 Ok(())
1857 });
1858 let ws = Workspace::open_local(root.clone(), Some(plain))
1859 .unwrap()
1860 .with_post_activate_revs(revs_hook);
1861 let out = ws.repo_management(None, false, true, false, None);
1863 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
1864 assert_eq!(
1865 revs_seen.load(Ordering::SeqCst),
1866 0,
1867 "revs-hook must not fire when no revs were requested"
1868 );
1869 assert!(
1870 !out.contains("revs:"),
1871 "no revs line expected on a plain activation; got: {out}"
1872 );
1873 }
1874
1875 #[test]
1876 fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
1877 use std::sync::atomic::{AtomicUsize, Ordering};
1878 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
1879 return;
1880 };
1881 let plain_calls = Arc::new(AtomicUsize::new(0));
1882 let pc = plain_calls.clone();
1883 let plain: PostActivateHook = Arc::new(move |_p, _n| {
1884 pc.fetch_add(1, Ordering::SeqCst);
1885 Ok(())
1886 });
1887 let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
1890 let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
1891 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
1892 assert!(
1893 !out.contains("revs:"),
1894 "must not report a rev-set when only the plain hook ran; got: {out}"
1895 );
1896 }
1897}