Skip to main content

mcp_methods/server/
workspace.rs

1//! Workspace mode — two variants.
2//!
3//! **Github mode** (`Workspace::open`, the default when
4//! `--workspace DIR` is set): the agent activates a GitHub repo via
5//! `repo_management('org/repo')`, the binary clones it into the
6//! workspace, and the active repo becomes the bound source root for
7//! `read_source` / `grep` / `list_source`. Idle repos auto-sweep after
8//! `--stale-after-days`. Layout:
9//!   workspace/
10//!     repos/<org>/<repo>/         — cloned source
11//!     inventory.json              — per-repo access tracking
12//!
13//! **Local mode** (`Workspace::open_local`, the manifest-driven
14//! `workspace: { kind: local, root: ... }` variant): the active source
15//! root is a fixed local directory, not a clone target. `repo_management`
16//! reports the active root and triggers rebuilds; an `set_root_dir`
17//! tool can swap the root at runtime. Closes the `code_review_mcp_server`
18//! use case from the kglite wishlist.
19//!
20//! Both modes fire the same [`PostActivateHook`] so downstream binaries
21//! (kglite-mcp-server) layer their build step on top with one
22//! registration point, and both honour the same `last_built_sha`
23//! gating to skip pointless rebuilds.
24
25#![allow(dead_code)]
26
27use std::collections::{BTreeMap, HashSet};
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
38/// Repo name format: ``org/repo``. Letters, digits, dots, hyphens, underscores.
39fn 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
61/// Hook fired after a successful clone or update. Receives the absolute
62/// path to the cloned repo and the org/repo name. Errors are logged but
63/// don't abort the activation — the repo is still registered as active.
64pub type PostActivateHook = Arc<dyn Fn(&Path, &str) -> Result<()> + Send + Sync>;
65
66/// Per-repo inventory entry persisted in `inventory.json`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68struct InventoryEntry {
69    cloned_at: String,
70    last_accessed: String,
71    #[serde(default)]
72    access_count: u64,
73    #[serde(default)]
74    stale: bool,
75    /// HEAD SHA at the time the post-activate hook last completed
76    /// successfully. Drives auto-rebuild gating: when an `update=True`
77    /// call ends with `action=="current"` AND the new HEAD matches this,
78    /// the post-activate hook can be skipped. `serde(default)` keeps
79    /// older inventory.json files (without this field) loading cleanly.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    last_built_sha: Option<String>,
82}
83
84// `WorkspaceKind` is re-used from the manifest module so config and
85// runtime share one enum — the values mean the same thing.
86pub use crate::server::manifest::WorkspaceKind;
87
88/// Workspace runtime state. Shared across MCP request clones via Arc.
89#[derive(Clone)]
90pub struct Workspace {
91    inner: Arc<WorkspaceInner>,
92}
93
94struct WorkspaceInner {
95    kind: WorkspaceKind,
96    workspace_dir: PathBuf,
97    stale_after_days: u32,
98    state: RwLock<WorkspaceState>,
99    post_activate: Option<PostActivateHook>,
100}
101
102#[derive(Debug, Default)]
103struct WorkspaceState {
104    active_repo_name: Option<String>,
105    active_repo_path: Option<PathBuf>,
106    /// Repos whose post-activate hook has fired **in this process**.
107    /// Deliberately NOT persisted: `last_built_sha` records that the
108    /// git repo is at the built SHA (a cross-process fact), but the
109    /// hook's *product* — the consumer's in-memory graph — lives only
110    /// for the process lifetime. On a fresh process this set is empty,
111    /// so the first activate re-fires the hook to rehydrate even when
112    /// the persisted SHA already matches HEAD.
113    hydrated_this_process: HashSet<String>,
114}
115
116impl Workspace {
117    /// Open a github-flavoured workspace (clone + track flow).
118    pub fn open(
119        workspace_dir: PathBuf,
120        stale_after_days: u32,
121        post_activate: Option<PostActivateHook>,
122    ) -> Result<Self> {
123        if !workspace_dir.is_dir() {
124            fs::create_dir_all(&workspace_dir).with_context(|| {
125                format!("failed to create workspace dir {}", workspace_dir.display())
126            })?;
127        }
128        let repos_dir = workspace_dir.join("repos");
129        if !repos_dir.is_dir() {
130            fs::create_dir_all(&repos_dir)
131                .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
132        }
133        let ws = Self {
134            inner: Arc::new(WorkspaceInner {
135                kind: WorkspaceKind::Github,
136                workspace_dir,
137                stale_after_days,
138                state: RwLock::new(WorkspaceState::default()),
139                post_activate,
140            }),
141        };
142        ws.reconcile_inventory()?;
143        Ok(ws)
144    }
145
146    /// Open a local-directory workspace.
147    ///
148    /// Binds `root` as the active source root immediately and fires the
149    /// post-activate hook (subject to last-built-sha gating). `inventory.json`
150    /// is kept under `<root>/.mcp-workspace/` so the local mode mirrors
151    /// the same gating / fingerprinting infra without polluting the
152    /// user's tree with a `repos/` directory.
153    pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
154        if !root.is_dir() {
155            anyhow::bail!(
156                "local workspace root does not exist or is not a directory: {}",
157                root.display()
158            );
159        }
160        let canon_root = root
161            .canonicalize()
162            .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
163        // Store inventory under a hidden subdir so we don't litter the
164        // user's repo. The "workspace dir" for local mode IS the root.
165        let inv_dir = canon_root.join(".mcp-workspace");
166        if !inv_dir.is_dir() {
167            fs::create_dir_all(&inv_dir).with_context(|| {
168                format!("failed to create local-workspace dir {}", inv_dir.display())
169            })?;
170        }
171        let mut state = WorkspaceState::default();
172        let synthetic_name = synthesize_local_name(&canon_root);
173        state.active_repo_name = Some(synthetic_name);
174        state.active_repo_path = Some(canon_root.clone());
175        Ok(Self {
176            inner: Arc::new(WorkspaceInner {
177                kind: WorkspaceKind::Local,
178                workspace_dir: canon_root,
179                stale_after_days: u32::MAX, // sweeping is github-only
180                state: RwLock::new(state),
181                post_activate,
182            }),
183        })
184    }
185
186    pub fn kind(&self) -> WorkspaceKind {
187        self.inner.kind
188    }
189
190    pub fn workspace_dir(&self) -> &Path {
191        &self.inner.workspace_dir
192    }
193
194    pub fn repos_dir(&self) -> PathBuf {
195        self.inner.workspace_dir.join("repos")
196    }
197
198    fn inventory_path(&self) -> PathBuf {
199        match self.inner.kind {
200            WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
201            WorkspaceKind::Local => self
202                .inner
203                .workspace_dir
204                .join(".mcp-workspace")
205                .join("inventory.json"),
206        }
207    }
208
209    /// Active repo's full org/repo name, or None if nothing is active.
210    pub fn active_repo_name(&self) -> Option<String> {
211        self.inner.state.read().unwrap().active_repo_name.clone()
212    }
213
214    /// Active repo's filesystem path, or None.
215    pub fn active_repo_path(&self) -> Option<PathBuf> {
216        self.inner.state.read().unwrap().active_repo_path.clone()
217    }
218
219    /// Default `org/repo` for the GitHub tools when the caller passes none.
220    ///
221    /// Github mode: the active repo — there the inventory key *is* the
222    /// `org/repo`. Local mode: the active root's `origin` remote parsed
223    /// to `org/repo`, or `None` when there's no GitHub remote. Crucially
224    /// it is *never* the `local/<dir>` inventory key (see
225    /// [`active_repo_name`](Self::active_repo_name)), which is a
226    /// filesystem-derived key, not a valid repo slug.
227    pub fn default_github_repo(&self) -> Option<String> {
228        match self.inner.kind {
229            WorkspaceKind::Github => self.active_repo_name(),
230            WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
231        }
232    }
233
234    // ------------------------------------------------------------------
235    // Inventory management
236    // ------------------------------------------------------------------
237
238    fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
239        let path = self.inventory_path();
240        let Ok(text) = fs::read_to_string(&path) else {
241            return BTreeMap::new();
242        };
243        serde_json::from_str(&text).unwrap_or_default()
244    }
245
246    fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
247        let path = self.inventory_path();
248        let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
249        fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
250        Ok(())
251    }
252
253    fn reconcile_inventory(&self) -> Result<()> {
254        let mut inv = self.load_inventory();
255        let mut on_disk: Vec<String> = Vec::new();
256        if self.repos_dir().is_dir() {
257            for org_entry in fs::read_dir(self.repos_dir())? {
258                let Ok(org_entry) = org_entry else { continue };
259                if !org_entry.path().is_dir() {
260                    continue;
261                }
262                let org = org_entry.file_name().to_string_lossy().into_owned();
263                if org.starts_with('.') {
264                    continue;
265                }
266                for repo_entry in fs::read_dir(org_entry.path())? {
267                    let Ok(repo_entry) = repo_entry else { continue };
268                    if !repo_entry.path().is_dir() {
269                        continue;
270                    }
271                    let repo = repo_entry.file_name().to_string_lossy().into_owned();
272                    if repo.starts_with('.') {
273                        continue;
274                    }
275                    let rname = format!("{org}/{repo}");
276                    on_disk.push(rname.clone());
277                    inv.entry(rname).or_insert_with(|| {
278                        let mtime = repo_entry
279                            .metadata()
280                            .ok()
281                            .and_then(|m| m.modified().ok())
282                            .map(format_iso)
283                            .unwrap_or_else(now_iso);
284                        InventoryEntry {
285                            cloned_at: mtime.clone(),
286                            last_accessed: mtime,
287                            access_count: 0,
288                            stale: false,
289                            last_built_sha: None,
290                        }
291                    });
292                }
293            }
294        }
295        for (rname, entry) in inv.iter_mut() {
296            if !on_disk.contains(rname) && !entry.stale {
297                entry.stale = true;
298            }
299        }
300        self.save_inventory(&inv)?;
301        Ok(())
302    }
303
304    fn bump_access(&self, name: &str, action: &str) {
305        let mut inv = self.load_inventory();
306        let now = now_iso();
307        let entry = inv
308            .entry(name.to_string())
309            .or_insert_with(|| InventoryEntry {
310                cloned_at: now.clone(),
311                last_accessed: now.clone(),
312                access_count: 0,
313                stale: false,
314                last_built_sha: None,
315            });
316        entry.last_accessed = now.clone();
317        entry.access_count += 1;
318        entry.stale = false;
319        if action == "cloned" || entry.cloned_at.is_empty() {
320            entry.cloned_at = now;
321        }
322        let _ = self.save_inventory(&inv);
323    }
324
325    fn mark_stale(&self, name: &str) {
326        let mut inv = self.load_inventory();
327        if let Some(entry) = inv.get_mut(name) {
328            entry.stale = true;
329            let _ = self.save_inventory(&inv);
330        }
331    }
332
333    fn sweep_stale(&self) -> Vec<String> {
334        // Local mode has nothing to sweep — the operator owns the root.
335        if matches!(self.inner.kind, WorkspaceKind::Local) {
336            return Vec::new();
337        }
338        let mut inv = self.load_inventory();
339        let cutoff = SystemTime::now()
340            - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
341        let active = self.active_repo_name();
342        let mut swept: Vec<String> = Vec::new();
343        for (rname, entry) in inv.iter_mut() {
344            if entry.stale {
345                continue;
346            }
347            if Some(rname.as_str()) == active.as_deref() {
348                continue;
349            }
350            let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
351            if last >= cutoff {
352                continue;
353            }
354            let parts: Vec<&str> = rname.splitn(2, '/').collect();
355            if parts.len() != 2 {
356                continue;
357            }
358            let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
359            if repo_path.exists() {
360                let _ = fs::remove_dir_all(&repo_path);
361            }
362            entry.stale = true;
363            swept.push(rname.clone());
364        }
365        if !swept.is_empty() {
366            let _ = self.save_inventory(&inv);
367            self.prune_empty_org_dirs();
368        }
369        swept
370    }
371
372    fn prune_empty_org_dirs(&self) {
373        let Ok(entries) = fs::read_dir(self.repos_dir()) else {
374            return;
375        };
376        for entry in entries.flatten() {
377            let path = entry.path();
378            if !path.is_dir() {
379                continue;
380            }
381            if let Ok(children) = fs::read_dir(&path) {
382                let real: Vec<_> = children
383                    .flatten()
384                    .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
385                    .collect();
386                if real.is_empty() {
387                    let _ = fs::remove_dir_all(&path);
388                }
389            }
390        }
391    }
392
393    // ------------------------------------------------------------------
394    // Git operations
395    // ------------------------------------------------------------------
396
397    /// Clone (if missing) or fast-forward (if cloned). Returns the
398    /// action label, the repo path, and the new HEAD SHA after the op.
399    ///
400    /// Local-mode short-circuits: there's nothing to clone or fetch.
401    /// The "SHA" is a cheap content fingerprint (recursive walk of file
402    /// mtimes + sizes) so the auto-rebuild gate still works.
403    fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
404        if matches!(self.inner.kind, WorkspaceKind::Local) {
405            // Local mode tracks the *currently bound* root, not the
406            // immutable configured `workspace_dir`. `set_root_dir` writes
407            // the target to `active_repo_path` before calling `activate`;
408            // this read picks that up so the fingerprint and the
409            // post-activate hook fire against the new root, and so the
410            // subsequent `active_repo_path` write in `activate` doesn't
411            // clobber the just-set target back to `workspace_dir`. Falls
412            // back to `workspace_dir` only if state is unset, which
413            // shouldn't happen after `open_local` seeds it.
414            let root = self
415                .inner
416                .state
417                .read()
418                .unwrap()
419                .active_repo_path
420                .clone()
421                .unwrap_or_else(|| self.inner.workspace_dir.clone());
422            let prev_sha = self.last_built_sha(name);
423            let fingerprint = fingerprint_dir(&root);
424            let action = match prev_sha {
425                Some(p) if p == fingerprint => "current",
426                None => "cloned", // first activation
427                Some(_) => "updated",
428            };
429            return Ok((action.to_string(), root, fingerprint));
430        }
431        let parts: Vec<&str> = name.splitn(2, '/').collect();
432        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
433        if !repo_path.exists() {
434            fs::create_dir_all(repo_path.parent().unwrap()).ok();
435            let url = format!("https://github.com/{name}.git");
436            let out = Command::new("git")
437                .args(["clone", "--depth", "1", &url, repo_path.to_str().unwrap()])
438                .output()
439                .context("failed to spawn `git clone`")?;
440            if !out.status.success() {
441                anyhow::bail!(
442                    "git clone failed: {}",
443                    String::from_utf8_lossy(&out.stderr).trim()
444                );
445            }
446            let sha = git_rev_parse(&repo_path, "HEAD")?;
447            return Ok(("cloned".to_string(), repo_path, sha));
448        }
449
450        // Fetch + check head delta
451        Command::new("git")
452            .args(["fetch", "--depth", "1", "origin"])
453            .current_dir(&repo_path)
454            .output()
455            .context("git fetch failed")?;
456        let local = git_rev_parse(&repo_path, "HEAD")?;
457        let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
458        if local != remote {
459            Command::new("git")
460                .args(["reset", "--hard", "FETCH_HEAD"])
461                .current_dir(&repo_path)
462                .output()
463                .context("git reset failed")?;
464            let sha = git_rev_parse(&repo_path, "HEAD")?;
465            return Ok(("updated".to_string(), repo_path, sha));
466        }
467        Ok(("current".to_string(), repo_path, local))
468    }
469
470    /// Activate a repo: clone if needed, fast-forward, fire post-activate hook.
471    ///
472    /// Auto-rebuild gating: if `force_rebuild` is false AND the repo
473    /// is already at the HEAD it was last built at (`action == "current"`
474    /// AND `prev_built_sha == new_head`), the post-activate hook is
475    /// skipped. This makes `repo_management(update=True)` cheap when
476    /// upstream hasn't moved. Set `force_rebuild=true` to bypass (e.g.
477    /// after upgrading the builder itself).
478    ///
479    /// On successful hook completion the new HEAD SHA is persisted to
480    /// `inventory.json[name].last_built_sha`. If the hook fails the SHA
481    /// is NOT recorded, so the next `update=True` re-attempts the build.
482    fn activate(&self, name: &str, force_rebuild: bool) -> Result<String> {
483        let prev_built_sha = self.last_built_sha(name);
484        let (action, repo_path, head_sha) = self.clone_or_update(name)?;
485        self.bump_access(name, &action);
486        let hook_fired_here = {
487            let mut state = self.inner.state.write().unwrap();
488            state.active_repo_name = Some(name.to_string());
489            state.active_repo_path = Some(repo_path.clone());
490            state.hydrated_this_process.contains(name)
491        };
492
493        // The skip gate must be satisfied on BOTH axes: the git repo is
494        // at its last-built SHA (persisted, cross-process) AND the hook
495        // has already run in *this* process (in-memory). Without the
496        // second axis a fresh process would inherit `last_built_sha` from
497        // disk, skip the hook, and leave the consumer's in-memory state
498        // (e.g. the code graph) empty — activate would report success
499        // with nothing loaded.
500        let already_built = !force_rebuild
501            && action == "current"
502            && prev_built_sha.as_deref() == Some(head_sha.as_str())
503            && hook_fired_here;
504        let mut hook_skipped = false;
505        let hook_ok = if already_built {
506            hook_skipped = true;
507            true
508        } else if let Some(hook) = &self.inner.post_activate {
509            match hook(&repo_path, name) {
510                Ok(()) => true,
511                Err(e) => {
512                    tracing::warn!("post-activate hook for {name} failed: {e}");
513                    false
514                }
515            }
516        } else {
517            // No hook configured — record the SHA so future calls can
518            // see "no work to do" without consulting an empty store.
519            true
520        };
521        if hook_ok {
522            self.record_built_sha(name, &head_sha);
523        }
524        // Record in-memory hydration only when the hook actually ran this
525        // process (not on the cheap-skip path). A no-op "no hook
526        // configured" activation also counts as hydrated: there is no
527        // per-process product to lose, so future skips are safe.
528        if hook_ok && !hook_skipped {
529            self.inner
530                .state
531                .write()
532                .unwrap()
533                .hydrated_this_process
534                .insert(name.to_string());
535        }
536        let verb = match action.as_str() {
537            "cloned" => "Cloned",
538            "updated" => "Updated",
539            "current" => "Activated (already up to date)",
540            other => other,
541        };
542        let suffix = if hook_skipped {
543            " [build skipped: HEAD matches last-built SHA]"
544        } else {
545            ""
546        };
547        Ok(format!(
548            "{verb} '{name}' at {}.{suffix}",
549            repo_path.display()
550        ))
551    }
552
553    fn record_built_sha(&self, name: &str, sha: &str) {
554        let mut inv = self.load_inventory();
555        if let Some(entry) = inv.get_mut(name) {
556            entry.last_built_sha = Some(sha.to_string());
557            let _ = self.save_inventory(&inv);
558        }
559    }
560
561    /// Read the SHA recorded after the last successful post-activate hook
562    /// for the named repo. `None` if the repo was never built (or the
563    /// hook last failed). Useful for downstream consumers gating
564    /// "is the active graph up to date with the repo HEAD?" checks.
565    pub fn last_built_sha(&self, name: &str) -> Option<String> {
566        self.load_inventory()
567            .get(name)
568            .and_then(|e| e.last_built_sha.clone())
569    }
570
571    fn delete(&self, name: &str) -> Result<String> {
572        let parts: Vec<&str> = name.splitn(2, '/').collect();
573        if parts.len() != 2 {
574            anyhow::bail!("Invalid repo name");
575        }
576        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
577        let mut deleted = Vec::new();
578        if repo_path.exists() {
579            fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
580            deleted.push("repo");
581        }
582        self.mark_stale(name);
583        self.prune_empty_org_dirs();
584        if deleted.is_empty() {
585            return Ok(format!("Nothing to delete — '{name}' not found."));
586        }
587        let mut state = self.inner.state.write().unwrap();
588        if state.active_repo_name.as_deref() == Some(name) {
589            state.active_repo_name = None;
590            state.active_repo_path = None;
591            return Ok(format!(
592                "Deleted {}. Active repo cleared.",
593                deleted.join(", ")
594            ));
595        }
596        Ok(format!("Deleted {}.", deleted.join(", ")))
597    }
598
599    fn list(&self) -> String {
600        let inv = self.load_inventory();
601        if inv.is_empty() {
602            return "No repos cloned yet. Call repo_management('org/repo') to clone one."
603                .to_string();
604        }
605        let active = self.active_repo_name();
606        let mut live: Vec<String> = Vec::new();
607        let mut stale_lines: Vec<String> = Vec::new();
608        for (rname, entry) in &inv {
609            let marker = if Some(rname.as_str()) == active.as_deref() {
610                " [active]"
611            } else {
612                ""
613            };
614            let access = format!(
615                "{} access{}, last {}",
616                entry.access_count,
617                if entry.access_count == 1 { "" } else { "es" },
618                relative_time(&entry.last_accessed)
619            );
620            if entry.stale {
621                stale_lines.push(format!(
622                    "  {rname}  [STALE — re-fetch with repo_management('{rname}')]  ({access})"
623                ));
624            } else {
625                live.push(format!("  {rname}{marker}  ({access})"));
626            }
627        }
628        let mut out = String::new();
629        if !live.is_empty() {
630            out.push_str(&format!(
631                "{} live repo(s):\n{}",
632                live.len(),
633                live.join("\n")
634            ));
635        }
636        if !stale_lines.is_empty() {
637            if !out.is_empty() {
638                out.push_str("\n\n");
639            }
640            out.push_str(&format!(
641                "{} stale repo(s):\n{}",
642                stale_lines.len(),
643                stale_lines.join("\n")
644            ));
645        }
646        out
647    }
648
649    /// Public entry for the `repo_management` MCP tool.
650    ///
651    /// - `name`: `org/repo` to activate (None = list / refresh mode).
652    /// - `delete`: remove the named repo + inventory entry. Github only.
653    /// - `update`: refresh the active repo (auto-rebuild gated).
654    /// - `force_rebuild`: with `update=true` (or initial activation),
655    ///   re-run the post-activate hook even when the HEAD SHA matches
656    ///   `last_built_sha`. Useful after the builder itself has been
657    ///   upgraded.
658    ///
659    /// Local mode behaviour: `name` and `delete` are rejected; pass
660    /// `update=true` (or no args after the initial activation) to
661    /// re-fingerprint the root and rebuild if anything changed.
662    pub fn repo_management(
663        &self,
664        name: Option<&str>,
665        delete: bool,
666        update: bool,
667        force_rebuild: bool,
668    ) -> String {
669        // Local mode: most github-only semantics are nonsensical here.
670        if matches!(self.inner.kind, WorkspaceKind::Local) {
671            if name.is_some() {
672                return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
673                        to switch the active root, or pass `update=true` / `force_rebuild=true` \
674                        to rebuild against the current root."
675                    .to_string();
676            }
677            if delete {
678                return "Local-workspace mode does not support `delete`. The root is owned by the \
679                        operator; remove it manually."
680                    .to_string();
681            }
682            let active = match self.active_repo_name() {
683                Some(n) => n,
684                None => return "No active local root.".to_string(),
685            };
686            // `update`: re-fingerprint and rebuild if anything changed.
687            // `force_rebuild`: rebuild even when the fingerprint matches.
688            // Either flag (or neither — initial bind path) routes through
689            // `activate`; `activate` itself consults the gate using the
690            // force flag plus the SHA comparison.
691            let _ = update; // explicit: update is implicit in local mode
692            return self
693                .activate(&active, force_rebuild)
694                .unwrap_or_else(|e| format!("rebuild failed: {e}"));
695        }
696
697        let swept = self.sweep_stale();
698        let prefix = if swept.is_empty() {
699            String::new()
700        } else {
701            format!(
702                "[Swept {} idle repo(s) (>{}d): {}]\n\n",
703                swept.len(),
704                self.inner.stale_after_days,
705                swept.join(", ")
706            )
707        };
708
709        if name.is_none() && !update {
710            return prefix + &self.list();
711        }
712
713        if update {
714            let Some(active) = self.active_repo_name() else {
715                return prefix + "No active repository. Call repo_management('org/repo') first.";
716            };
717            return prefix
718                + &self
719                    .activate(&active, force_rebuild)
720                    .unwrap_or_else(|e| format!("update failed: {e}"));
721        }
722
723        let Some(name) = name else {
724            return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
725        };
726        if let Err(e) = validate_repo_name(name) {
727            return prefix + &e.to_string();
728        }
729        if delete {
730            return prefix
731                + &self
732                    .delete(name)
733                    .unwrap_or_else(|e| format!("delete failed: {e}"));
734        }
735        prefix
736            + &self
737                .activate(name, force_rebuild)
738                .unwrap_or_else(|e| format!("activate failed: {e}"))
739    }
740
741    /// Swap the active root (local mode only). Re-fires the post-activate
742    /// hook against the new root. Errors if the workspace is github-flavoured.
743    pub fn set_root_dir(&self, new_root: &Path) -> String {
744        if !matches!(self.inner.kind, WorkspaceKind::Local) {
745            return "set_root_dir is only valid in local-workspace mode.".to_string();
746        }
747        if !new_root.is_dir() {
748            return format!(
749                "Path does not exist or is not a directory: {}",
750                new_root.display()
751            );
752        }
753        let canon = match new_root.canonicalize() {
754            Ok(p) => p,
755            Err(e) => return format!("canonicalize failed: {e}"),
756        };
757        let synthetic = synthesize_local_name(&canon);
758        {
759            let mut state = self.inner.state.write().unwrap();
760            state.active_repo_name = Some(synthetic.clone());
761            state.active_repo_path = Some(canon.clone());
762        }
763        // Note: the WorkspaceInner.workspace_dir field is the path the
764        // inventory is stored under. We keep the *original* one (from
765        // open_local) so the inventory survives across root swaps.
766        self.activate(&synthetic, false)
767            .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
768    }
769}
770
771/// Synthesise a stable "repo name" for a local workspace from its path.
772/// Used as the inventory key so the same gating + persistence code paths
773/// that github mode uses can apply to local mode unchanged.
774fn synthesize_local_name(root: &Path) -> String {
775    let name = root
776        .file_name()
777        .map(|s| s.to_string_lossy().into_owned())
778        .unwrap_or_else(|| "local".to_string());
779    format!("local/{name}")
780}
781
782/// Parse the `org/repo` slug from a local checkout's `origin` remote.
783///
784/// Shells out to `git -C <root> remote get-url origin` and parses both
785/// canonical GitHub remote forms, stripping the trailing `.git`:
786///   - `git@github.com:kkollsga/kglite.git`     → `kkollsga/kglite`
787///   - `https://github.com/kkollsga/kglite.git` → `kkollsga/kglite`
788///
789/// Returns `None` for a non-git directory, a missing `origin` remote, or
790/// a non-GitHub remote — so the GitHub tools fall back to their existing
791/// empty-default path (ask the caller for `repo_name`).
792fn parse_origin_repo(root: &Path) -> Option<String> {
793    let out = Command::new("git")
794        .arg("-C")
795        .arg(root)
796        .args(["remote", "get-url", "origin"])
797        .output()
798        .ok()?;
799    if !out.status.success() {
800        return None;
801    }
802    let url = String::from_utf8(out.stdout).ok()?;
803    parse_github_remote(url.trim())
804}
805
806/// Pure-string half of [`parse_origin_repo`]: turn a GitHub remote URL
807/// into `org/repo`, or `None` if it isn't a recognisable GitHub remote.
808fn parse_github_remote(url: &str) -> Option<String> {
809    // Accept both SSH (`git@github.com:org/repo`) and HTTPS
810    // (`https://github.com/org/repo`) forms; everything after the host
811    // separator is the path.
812    let path = url
813        .strip_prefix("git@github.com:")
814        .or_else(|| url.strip_prefix("https://github.com/"))
815        .or_else(|| url.strip_prefix("http://github.com/"))
816        .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
817    let path = path.strip_suffix(".git").unwrap_or(path);
818    let path = path.trim_end_matches('/');
819    // Must be exactly `org/repo` — both segments non-empty, one slash.
820    let mut parts = path.split('/');
821    let org = parts.next().filter(|s| !s.is_empty())?;
822    let repo = parts.next().filter(|s| !s.is_empty())?;
823    if parts.next().is_some() {
824        return None;
825    }
826    Some(format!("{org}/{repo}"))
827}
828
829/// Cheap recursive content fingerprint of a directory tree. Walks files
830/// (respecting common ignore patterns) and folds `(path, mtime, len)`
831/// into a 64-bit hash, then hex-formats it. Good enough to detect
832/// "did anything change?" for auto-rebuild gating — not cryptographic.
833fn fingerprint_dir(root: &Path) -> String {
834    use std::hash::{Hash, Hasher};
835    let mut hasher = std::collections::hash_map::DefaultHasher::new();
836    let walker = ignore::WalkBuilder::new(root)
837        .standard_filters(true)
838        .hidden(true)
839        .git_ignore(true)
840        .build();
841    for entry in walker.flatten() {
842        if !entry.path().is_file() {
843            continue;
844        }
845        let Ok(meta) = entry.metadata() else { continue };
846        let mtime = meta
847            .modified()
848            .ok()
849            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
850            .map(|d| d.as_secs())
851            .unwrap_or(0);
852        entry.path().to_string_lossy().hash(&mut hasher);
853        mtime.hash(&mut hasher);
854        meta.len().hash(&mut hasher);
855    }
856    format!("local-{:016x}", hasher.finish())
857}
858
859fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
860    let out = Command::new("git")
861        .args(["rev-parse", refspec])
862        .current_dir(repo_path)
863        .output()
864        .context("git rev-parse failed")?;
865    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
866}
867
868fn now_iso() -> String {
869    format_iso(SystemTime::now())
870}
871
872fn format_iso(t: SystemTime) -> String {
873    let secs = t
874        .duration_since(SystemTime::UNIX_EPOCH)
875        .map(|d| d.as_secs())
876        .unwrap_or(0);
877    // Lightweight RFC3339-ish formatter. Drop sub-second precision; matches Python isoformat(timespec=seconds).
878    chrono_lite::format_secs(secs)
879}
880
881fn parse_iso(s: &str) -> Option<SystemTime> {
882    let secs = chrono_lite::parse_secs(s)?;
883    SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
884}
885
886fn relative_time(iso: &str) -> String {
887    let Some(t) = parse_iso(iso) else {
888        return "unknown".to_string();
889    };
890    let now = SystemTime::now();
891    let delta = now.duration_since(t).unwrap_or_default().as_secs();
892    if delta < 3600 {
893        "just now".to_string()
894    } else if delta < 86_400 {
895        format!("{}h ago", delta / 3600)
896    } else {
897        format!("{}d ago", delta / 86_400)
898    }
899}
900
901/// Tiny self-contained ISO-8601 (seconds-precision) formatter so we
902/// don't pull in `chrono` for a handful of timestamps.
903mod chrono_lite {
904    pub fn format_secs(secs: u64) -> String {
905        // Civil-from-days algorithm (Howard Hinnant). Output: YYYY-MM-DDTHH:MM:SS.
906        let days = (secs / 86_400) as i64;
907        let time = secs % 86_400;
908        let (y, mo, d) = days_to_civil(days + 719_468);
909        let h = time / 3600;
910        let m = (time / 60) % 60;
911        let s = time % 60;
912        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
913    }
914
915    pub fn parse_secs(s: &str) -> Option<u64> {
916        // Accept "YYYY-MM-DDTHH:MM:SS" (no zone) — same shape as format_secs output
917        // and Python's datetime.isoformat(timespec="seconds").
918        let bytes = s.as_bytes();
919        if bytes.len() < 19 {
920            return None;
921        }
922        let y: i64 = s.get(0..4)?.parse().ok()?;
923        let mo: u32 = s.get(5..7)?.parse().ok()?;
924        let d: u32 = s.get(8..10)?.parse().ok()?;
925        let h: u64 = s.get(11..13)?.parse().ok()?;
926        let m: u64 = s.get(14..16)?.parse().ok()?;
927        let sc: u64 = s.get(17..19)?.parse().ok()?;
928        let days = civil_to_days(y, mo, d) - 719_468;
929        Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
930    }
931
932    fn days_to_civil(z: i64) -> (i64, u32, u32) {
933        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
934        let doe = (z - era * 146_097) as u64;
935        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
936        let y = (yoe as i64) + era * 400;
937        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
938        let mp = (5 * doy + 2) / 153;
939        let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
940        let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
941        let y = if m <= 2 { y + 1 } else { y };
942        (y, m, d)
943    }
944
945    fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
946        let y = if m <= 2 { y - 1 } else { y };
947        let era = if y >= 0 { y } else { y - 399 } / 400;
948        let yoe = (y - era * 400) as u64;
949        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
950        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
951        era * 146_097 + doe as i64
952    }
953}
954
955// silences unused-import-when-helper-only-via-json! macro check.
956#[allow(dead_code)]
957fn _json_keepalive() {
958    let _ = json!({});
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    #[test]
966    fn validates_repo_names() {
967        assert!(validate_repo_name("pydata/xarray").is_ok());
968        assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
969        assert!(validate_repo_name("xarray").is_err());
970        assert!(validate_repo_name("a/b/c").is_err());
971        assert!(validate_repo_name("foo/bar; rm -rf").is_err());
972    }
973
974    #[test]
975    fn open_creates_layout() {
976        let dir = tempfile::tempdir().unwrap();
977        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
978        assert!(ws.repos_dir().is_dir());
979    }
980
981    #[test]
982    fn empty_list() {
983        let dir = tempfile::tempdir().unwrap();
984        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
985        let out = ws.repo_management(None, false, false, false);
986        assert!(out.contains("No repos cloned yet"));
987    }
988
989    #[test]
990    fn invalid_repo_name_rejected() {
991        let dir = tempfile::tempdir().unwrap();
992        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
993        let out = ws.repo_management(Some("bad name with spaces"), false, false, false);
994        assert!(out.contains("Invalid repo name"));
995    }
996
997    #[test]
998    fn delete_unknown() {
999        let dir = tempfile::tempdir().unwrap();
1000        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1001        let out = ws.repo_management(Some("nope/none"), true, false, false);
1002        assert!(out.contains("Nothing to delete"));
1003    }
1004
1005    #[test]
1006    fn iso_round_trip() {
1007        let now = SystemTime::now()
1008            .duration_since(SystemTime::UNIX_EPOCH)
1009            .unwrap()
1010            .as_secs();
1011        let s = chrono_lite::format_secs(now);
1012        let back = chrono_lite::parse_secs(&s).unwrap();
1013        assert_eq!(now, back);
1014    }
1015
1016    #[test]
1017    fn last_built_sha_round_trip() {
1018        let dir = tempfile::tempdir().unwrap();
1019        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1020        // Seed an inventory entry directly (clone_or_update needs git).
1021        ws.bump_access("acme/widgets", "cloned");
1022        assert_eq!(ws.last_built_sha("acme/widgets"), None);
1023        ws.record_built_sha("acme/widgets", "abc1234deadbeef");
1024        assert_eq!(
1025            ws.last_built_sha("acme/widgets").as_deref(),
1026            Some("abc1234deadbeef")
1027        );
1028        // Survives an Workspace::open re-read (proves persistence).
1029        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1030        assert_eq!(
1031            ws2.last_built_sha("acme/widgets").as_deref(),
1032            Some("abc1234deadbeef")
1033        );
1034    }
1035
1036    #[test]
1037    fn inventory_loads_legacy_entries_without_sha_field() {
1038        let dir = tempfile::tempdir().unwrap();
1039        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1040        // Hand-craft an old-style inventory.json without `last_built_sha`.
1041        let legacy = r#"{
1042            "old/repo": {
1043                "cloned_at": "2024-01-01T00:00:00",
1044                "last_accessed": "2024-01-01T00:00:00",
1045                "access_count": 5,
1046                "stale": false
1047            }
1048        }"#;
1049        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1050        // Re-open and confirm graceful read.
1051        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1052        assert_eq!(ws2.last_built_sha("old/repo"), None);
1053        let _ = ws;
1054    }
1055
1056    #[test]
1057    fn auto_rebuild_gate_skips_when_sha_matches() {
1058        use std::sync::atomic::{AtomicUsize, Ordering};
1059        let dir = tempfile::tempdir().unwrap();
1060        let calls = Arc::new(AtomicUsize::new(0));
1061        let calls_h = calls.clone();
1062        let hook: PostActivateHook = Arc::new(move |_path, _name| {
1063            calls_h.fetch_add(1, Ordering::SeqCst);
1064            Ok(())
1065        });
1066        // Build a workspace pointing at a tempdir with a fake repo dir,
1067        // then simulate consecutive activates. We can't drive clone_or_update
1068        // without git, so test the gating directly by tracking the SHA
1069        // record-then-re-record case via Workspace::record_built_sha +
1070        // last_built_sha — the same predicate `activate` uses.
1071        let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1072        // Seed inventory entry + initial sha record.
1073        ws.bump_access("acme/widgets", "cloned");
1074        ws.record_built_sha("acme/widgets", "sha_one");
1075        assert_eq!(
1076            ws.last_built_sha("acme/widgets").as_deref(),
1077            Some("sha_one")
1078        );
1079        // Repeated record with the same value is idempotent (gating
1080        // logic uses last_built_sha as the source of truth).
1081        ws.record_built_sha("acme/widgets", "sha_one");
1082        assert_eq!(
1083            ws.last_built_sha("acme/widgets").as_deref(),
1084            Some("sha_one")
1085        );
1086        // No hook calls have been driven directly — this test exercises
1087        // the persistence path that the gate consults.
1088        assert_eq!(calls.load(Ordering::SeqCst), 0);
1089    }
1090
1091    #[test]
1092    fn local_workspace_binds_root_immediately() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1095        assert_eq!(ws.kind(), WorkspaceKind::Local);
1096        assert!(ws.active_repo_path().is_some());
1097        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1098    }
1099
1100    #[test]
1101    fn local_workspace_rejects_github_ops() {
1102        let dir = tempfile::tempdir().unwrap();
1103        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1104        let out = ws.repo_management(Some("acme/widgets"), false, false, false);
1105        assert!(out.contains("does not accept a repo name"));
1106        let out = ws.repo_management(None, true, false, false);
1107        assert!(out.contains("does not support `delete`"));
1108    }
1109
1110    #[test]
1111    fn local_workspace_update_rebuilds() {
1112        use std::sync::atomic::{AtomicUsize, Ordering};
1113        let dir = tempfile::tempdir().unwrap();
1114        // Drop a file so the fingerprint has something to hash.
1115        std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1116        let calls = Arc::new(AtomicUsize::new(0));
1117        let calls_h = calls.clone();
1118        let hook: PostActivateHook = Arc::new(move |_p, _n| {
1119            calls_h.fetch_add(1, Ordering::SeqCst);
1120            Ok(())
1121        });
1122        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1123        // First update: nothing built yet → hook fires.
1124        let _ = ws.repo_management(None, false, true, false);
1125        assert_eq!(calls.load(Ordering::SeqCst), 1);
1126        // Second update without changes → SHA matches → hook skipped.
1127        let out = ws.repo_management(None, false, true, false);
1128        assert_eq!(
1129            calls.load(Ordering::SeqCst),
1130            1,
1131            "auto-rebuild gate must skip"
1132        );
1133        assert!(out.contains("build skipped"));
1134    }
1135
1136    #[test]
1137    fn parses_github_remote_forms() {
1138        assert_eq!(
1139            parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1140            Some("kkollsga/kglite")
1141        );
1142        assert_eq!(
1143            parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1144            Some("kkollsga/kglite")
1145        );
1146        // No .git suffix, trailing slash.
1147        assert_eq!(
1148            parse_github_remote("https://github.com/acme/widget/").as_deref(),
1149            Some("acme/widget")
1150        );
1151        assert_eq!(
1152            parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1153            Some("acme/widget")
1154        );
1155        // Non-github / malformed → None.
1156        assert_eq!(
1157            parse_github_remote("https://gitlab.com/acme/widget.git"),
1158            None
1159        );
1160        assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1161        assert_eq!(parse_github_remote("not a url"), None);
1162    }
1163
1164    #[test]
1165    fn local_default_github_repo_uses_origin_remote() {
1166        let dir = tempfile::tempdir().unwrap();
1167        let root = dir.path();
1168        // Stand up a real git repo with a faked origin so default_github_repo
1169        // exercises the actual `git remote get-url` path.
1170        let git = |args: &[&str]| {
1171            Command::new("git")
1172                .arg("-C")
1173                .arg(root)
1174                .args(args)
1175                .output()
1176                .unwrap()
1177        };
1178        if !git(&["init"]).status.success() {
1179            // git unavailable in this environment — skip rather than fail.
1180            return;
1181        }
1182        git(&[
1183            "remote",
1184            "add",
1185            "origin",
1186            "https://github.com/acme/widget.git",
1187        ]);
1188        let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1189        assert_eq!(
1190            ws.default_github_repo().as_deref(),
1191            Some("acme/widget"),
1192            "local default repo must come from the origin remote, not the inventory key"
1193        );
1194        // The inventory key remains the synthetic local name.
1195        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1196    }
1197
1198    #[test]
1199    fn local_default_github_repo_none_without_remote() {
1200        let dir = tempfile::tempdir().unwrap();
1201        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1202        // No git remote → None, and crucially NOT Some("local/<dir>").
1203        let def = ws.default_github_repo();
1204        assert!(
1205            def.is_none(),
1206            "expected None for a non-git local root, got {def:?}"
1207        );
1208    }
1209
1210    #[test]
1211    fn set_root_dir_only_in_local_mode() {
1212        let dir = tempfile::tempdir().unwrap();
1213        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1214        let out = ws.set_root_dir(dir.path());
1215        assert!(out.contains("only valid in local-workspace"));
1216    }
1217
1218    #[test]
1219    fn update_with_no_active_repo() {
1220        let dir = tempfile::tempdir().unwrap();
1221        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1222        let out = ws.repo_management(None, false, true, false);
1223        assert!(out.contains("No active repository"));
1224    }
1225
1226    #[test]
1227    fn set_root_dir_updates_active_path() {
1228        let dir = tempfile::tempdir().unwrap();
1229        let child = dir.path().join("child");
1230        std::fs::create_dir_all(&child).unwrap();
1231        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1232        let _ = ws.set_root_dir(&child);
1233        assert_eq!(
1234            ws.active_repo_path().unwrap(),
1235            child.canonicalize().unwrap(),
1236            "set_root_dir didn't update active_repo_path"
1237        );
1238    }
1239
1240    #[test]
1241    fn set_root_dir_post_activate_fires_against_new_root() {
1242        let dir = tempfile::tempdir().unwrap();
1243        let child = dir.path().join("child");
1244        std::fs::create_dir_all(&child).unwrap();
1245        std::fs::write(child.join("a.txt"), b"hi").unwrap();
1246        let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1247        let seen = seen_path.clone();
1248        let hook: PostActivateHook = Arc::new(move |p, _n| {
1249            *seen.lock().unwrap() = Some(p.to_path_buf());
1250            Ok(())
1251        });
1252        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1253        let _ = ws.set_root_dir(&child);
1254        assert_eq!(
1255            seen_path.lock().unwrap().clone().unwrap(),
1256            child.canonicalize().unwrap(),
1257            "post_activate hook saw the wrong root after set_root_dir"
1258        );
1259    }
1260
1261    #[test]
1262    fn hook_fires_once_per_process_even_when_sha_matches() {
1263        use std::sync::atomic::{AtomicUsize, Ordering};
1264        // Local mode fingerprints the dir instead of a git SHA, so we can
1265        // drive the real `activate` path without git. A stable file keeps
1266        // the fingerprint constant across both simulated processes.
1267        let dir = tempfile::tempdir().unwrap();
1268        std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1269
1270        let calls = Arc::new(AtomicUsize::new(0));
1271        let make_hook = || -> PostActivateHook {
1272            let c = calls.clone();
1273            Arc::new(move |_p, _n| {
1274                c.fetch_add(1, Ordering::SeqCst);
1275                Ok(())
1276            })
1277        };
1278
1279        // --- Process 1 ---------------------------------------------------
1280        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1281        // First activate (fingerprint not yet recorded) → hook fires.
1282        let _ = ws.repo_management(None, false, true, false);
1283        assert_eq!(
1284            calls.load(Ordering::SeqCst),
1285            1,
1286            "first activate must hydrate"
1287        );
1288        // Second activate, same process, unchanged fingerprint → cheap-skip.
1289        let out = ws.repo_management(None, false, true, false);
1290        assert_eq!(
1291            calls.load(Ordering::SeqCst),
1292            1,
1293            "repeat activate in same process must skip the hook"
1294        );
1295        assert!(
1296            out.contains("build skipped"),
1297            "expected skip suffix, got: {out}"
1298        );
1299        drop(ws);
1300
1301        // --- Process 2 (restart) ----------------------------------------
1302        // Same dir → inventory.json + last_built_sha persist, but the
1303        // in-memory hydration set does not. The first activate here must
1304        // re-fire the hook to rehydrate the consumer's in-memory state.
1305        let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1306        assert!(
1307            ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1308                .is_some(),
1309            "sanity: last_built_sha should survive the restart"
1310        );
1311        let _ = ws2.repo_management(None, false, true, false);
1312        assert_eq!(
1313            calls.load(Ordering::SeqCst),
1314            2,
1315            "fresh process must re-fire the hook even when the SHA matches"
1316        );
1317    }
1318}