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