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;
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/// Hook fired after a successful clone/update **when revisions were
80/// requested** on the activation call (`repo_management(revs=…)` /
81/// `set_root_dir(revs=…)`). Receives the repo path, the `org/repo` (or
82/// synthetic local) name, and the resolved revspecs in **oldest→newest**
83/// order — for a `Count(n)` request the final entry is always `HEAD`, so
84/// a downstream multi-rev builder can merge oldest→newest with HEAD's
85/// signature winning. Set via [`Workspace::with_post_activate_revs`].
86///
87/// Additive by design (mirrors [`ActivationSummaryHook`]): existing
88/// consumers that register only the plain [`PostActivateHook`] are
89/// unaffected. When revs are requested but this hook is *not* set, the
90/// plain hook runs instead (a single-rev / HEAD build) and the resolved
91/// list is not reported in the activation message.
92pub type PostActivateRevsHook = Arc<dyn Fn(&Path, &str, &[String]) -> Result<()> + Send + Sync>;
93
94/// A revisions request carried by the activation tools. `Count(n)`
95/// resolves to the newest `n` version-sorted tags (plus `HEAD`);
96/// `List(revs)` is an explicit set of git revspecs used verbatim. The
97/// untagged deserialization maps a JSON integer to `Count` and a JSON
98/// array of strings to `List`, so the tool arg accepts `int | [str]`.
99/// Resolution happens at activate time — see [`Workspace::resolve_revs`].
100#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
101#[serde(untagged)]
102pub enum RevsRequest {
103    /// Last `n` release tags (`git tag --sort=-v:refname`, take `n`),
104    /// ordered oldest→newest with `HEAD` appended.
105    Count(usize),
106    /// Explicit git revspecs (tags, branches, or SHAs), used as given.
107    List(Vec<String>),
108}
109
110/// Per-repo inventory entry persisted in `inventory.json`.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112struct InventoryEntry {
113    cloned_at: String,
114    last_accessed: String,
115    #[serde(default)]
116    access_count: u64,
117    #[serde(default)]
118    stale: bool,
119    /// HEAD SHA at the time the post-activate hook last completed
120    /// successfully. Drives auto-rebuild gating: when an `update=True`
121    /// call ends with `action=="current"` AND the new HEAD matches this,
122    /// the post-activate hook can be skipped. `serde(default)` keeps
123    /// older inventory.json files (without this field) loading cleanly.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    last_built_sha: Option<String>,
126}
127
128// `WorkspaceKind` is re-used from the manifest module so config and
129// runtime share one enum — the values mean the same thing.
130pub use crate::server::manifest::WorkspaceKind;
131
132/// Workspace runtime state. Shared across MCP request clones via Arc.
133#[derive(Clone)]
134pub struct Workspace {
135    inner: Arc<WorkspaceInner>,
136}
137
138struct WorkspaceInner {
139    kind: WorkspaceKind,
140    workspace_dir: PathBuf,
141    stale_after_days: u32,
142    state: RwLock<WorkspaceState>,
143    post_activate: Option<PostActivateHook>,
144    /// Optional summary hook (see [`ActivationSummaryHook`]). Set via
145    /// [`Workspace::with_activation_summary`], `None` by default.
146    activation_summary: Option<ActivationSummaryHook>,
147    /// Optional revs-aware hook (see [`PostActivateRevsHook`]). Set via
148    /// [`Workspace::with_post_activate_revs`], `None` by default. Called
149    /// in place of `post_activate` only when the activation carried a
150    /// revs request AND this hook is set.
151    post_activate_revs: Option<PostActivateRevsHook>,
152}
153
154#[derive(Debug, Default)]
155struct WorkspaceState {
156    active_repo_name: Option<String>,
157    active_repo_path: Option<PathBuf>,
158    /// The name whose post-activate hook **most recently ran to
159    /// completion in this process** — i.e. the root whose product is
160    /// currently live. Deliberately NOT persisted: `last_built_sha`
161    /// records that the git repo is at the built SHA (a cross-process
162    /// fact), but the hook's *product* — the consumer's in-memory graph
163    /// — lives only for the process lifetime. On a fresh process this is
164    /// `None`, so the first activate re-fires the hook to rehydrate even
165    /// when the persisted SHA already matches HEAD.
166    ///
167    /// Crucially this is a **single** name, not a set of every name ever
168    /// hydrated. Many consumers keep a *single* active-graph slot that
169    /// each activate overwrites, so "hook fired for X at some point" does
170    /// NOT imply "X's product is still live". Only re-binding the
171    /// currently-active root is safe to skip; an A→B→A swap must rebuild
172    /// A because B overwrote the slot. Tracking one name makes the skip
173    /// gate correct for single-slot and per-name consumers alike.
174    active_built_name: Option<String>,
175}
176
177impl Workspace {
178    /// Open a github-flavoured workspace (clone + track flow).
179    pub fn open(
180        workspace_dir: PathBuf,
181        stale_after_days: u32,
182        post_activate: Option<PostActivateHook>,
183    ) -> Result<Self> {
184        if !workspace_dir.is_dir() {
185            fs::create_dir_all(&workspace_dir).with_context(|| {
186                format!("failed to create workspace dir {}", workspace_dir.display())
187            })?;
188        }
189        let repos_dir = workspace_dir.join("repos");
190        if !repos_dir.is_dir() {
191            fs::create_dir_all(&repos_dir)
192                .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
193        }
194        let ws = Self {
195            inner: Arc::new(WorkspaceInner {
196                kind: WorkspaceKind::Github,
197                workspace_dir,
198                stale_after_days,
199                state: RwLock::new(WorkspaceState::default()),
200                post_activate,
201                activation_summary: None,
202                post_activate_revs: None,
203            }),
204        };
205        ws.reconcile_inventory()?;
206        Ok(ws)
207    }
208
209    /// Open a local-directory workspace.
210    ///
211    /// Binds `root` as the active source root immediately and fires the
212    /// post-activate hook (subject to last-built-sha gating). `inventory.json`
213    /// is kept under `<root>/.mcp-workspace/` so the local mode mirrors
214    /// the same gating / fingerprinting infra without polluting the
215    /// user's tree with a `repos/` directory.
216    pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
217        if !root.is_dir() {
218            anyhow::bail!(
219                "local workspace root does not exist or is not a directory: {}",
220                root.display()
221            );
222        }
223        let canon_root = root
224            .canonicalize()
225            .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
226        // Store inventory under a hidden subdir so we don't litter the
227        // user's repo. The "workspace dir" for local mode IS the root.
228        let inv_dir = canon_root.join(".mcp-workspace");
229        if !inv_dir.is_dir() {
230            fs::create_dir_all(&inv_dir).with_context(|| {
231                format!("failed to create local-workspace dir {}", inv_dir.display())
232            })?;
233        }
234        let mut state = WorkspaceState::default();
235        let synthetic_name = synthesize_local_name(&canon_root);
236        state.active_repo_name = Some(synthetic_name);
237        state.active_repo_path = Some(canon_root.clone());
238        Ok(Self {
239            inner: Arc::new(WorkspaceInner {
240                kind: WorkspaceKind::Local,
241                workspace_dir: canon_root,
242                stale_after_days: u32::MAX, // sweeping is github-only
243                state: RwLock::new(state),
244                post_activate,
245                activation_summary: None,
246                post_activate_revs: None,
247            }),
248        })
249    }
250
251    /// Attach an [`ActivationSummaryHook`]. Call immediately after
252    /// `open`/`open_local` (before the workspace is cloned into
253    /// `ServerOptions`): it mutates the still-unique inner `Arc`. Calling
254    /// it after the workspace has been cloned is a no-op with a warning —
255    /// the summary simply won't be attached.
256    pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
257        match Arc::get_mut(&mut self.inner) {
258            Some(inner) => inner.activation_summary = Some(hook),
259            None => tracing::warn!(
260                "with_activation_summary called after the workspace was cloned; summary not attached"
261            ),
262        }
263        self
264    }
265
266    /// Attach a [`PostActivateRevsHook`]. Call immediately after
267    /// `open`/`open_local` (before the workspace is cloned into
268    /// `ServerOptions`): it mutates the still-unique inner `Arc`, exactly
269    /// like [`with_activation_summary`](Self::with_activation_summary).
270    /// Calling it after the workspace has been cloned is a no-op with a
271    /// warning. Additive — consumers that don't set it keep the plain
272    /// single-rev activation behaviour.
273    pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
274        match Arc::get_mut(&mut self.inner) {
275            Some(inner) => inner.post_activate_revs = Some(hook),
276            None => tracing::warn!(
277                "with_post_activate_revs called after the workspace was cloned; revs hook not attached"
278            ),
279        }
280        self
281    }
282
283    pub fn kind(&self) -> WorkspaceKind {
284        self.inner.kind
285    }
286
287    pub fn workspace_dir(&self) -> &Path {
288        &self.inner.workspace_dir
289    }
290
291    pub fn repos_dir(&self) -> PathBuf {
292        self.inner.workspace_dir.join("repos")
293    }
294
295    fn inventory_path(&self) -> PathBuf {
296        match self.inner.kind {
297            WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
298            WorkspaceKind::Local => self
299                .inner
300                .workspace_dir
301                .join(".mcp-workspace")
302                .join("inventory.json"),
303        }
304    }
305
306    /// Active repo's full org/repo name, or None if nothing is active.
307    pub fn active_repo_name(&self) -> Option<String> {
308        self.inner.state.read().unwrap().active_repo_name.clone()
309    }
310
311    /// Active repo's filesystem path, or None.
312    pub fn active_repo_path(&self) -> Option<PathBuf> {
313        self.inner.state.read().unwrap().active_repo_path.clone()
314    }
315
316    /// Default `org/repo` for the GitHub tools when the caller passes none.
317    ///
318    /// Github mode: the active repo — there the inventory key *is* the
319    /// `org/repo`. Local mode: the active root's `origin` remote parsed
320    /// to `org/repo`, or `None` when there's no GitHub remote. Crucially
321    /// it is *never* the `local/<dir>` inventory key (see
322    /// [`active_repo_name`](Self::active_repo_name)), which is a
323    /// filesystem-derived key, not a valid repo slug.
324    pub fn default_github_repo(&self) -> Option<String> {
325        match self.inner.kind {
326            WorkspaceKind::Github => self.active_repo_name(),
327            WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
328        }
329    }
330
331    // ------------------------------------------------------------------
332    // Inventory management
333    // ------------------------------------------------------------------
334
335    fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
336        let path = self.inventory_path();
337        let Ok(text) = fs::read_to_string(&path) else {
338            return BTreeMap::new();
339        };
340        serde_json::from_str(&text).unwrap_or_default()
341    }
342
343    fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
344        let path = self.inventory_path();
345        let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
346        fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
347        Ok(())
348    }
349
350    fn reconcile_inventory(&self) -> Result<()> {
351        let mut inv = self.load_inventory();
352        let mut on_disk: Vec<String> = Vec::new();
353        if self.repos_dir().is_dir() {
354            for org_entry in fs::read_dir(self.repos_dir())? {
355                let Ok(org_entry) = org_entry else { continue };
356                if !org_entry.path().is_dir() {
357                    continue;
358                }
359                let org = org_entry.file_name().to_string_lossy().into_owned();
360                if org.starts_with('.') {
361                    continue;
362                }
363                for repo_entry in fs::read_dir(org_entry.path())? {
364                    let Ok(repo_entry) = repo_entry else { continue };
365                    if !repo_entry.path().is_dir() {
366                        continue;
367                    }
368                    let repo = repo_entry.file_name().to_string_lossy().into_owned();
369                    if repo.starts_with('.') {
370                        continue;
371                    }
372                    let rname = format!("{org}/{repo}");
373                    on_disk.push(rname.clone());
374                    inv.entry(rname).or_insert_with(|| {
375                        let mtime = repo_entry
376                            .metadata()
377                            .ok()
378                            .and_then(|m| m.modified().ok())
379                            .map(format_iso)
380                            .unwrap_or_else(now_iso);
381                        InventoryEntry {
382                            cloned_at: mtime.clone(),
383                            last_accessed: mtime,
384                            access_count: 0,
385                            stale: false,
386                            last_built_sha: None,
387                        }
388                    });
389                }
390            }
391        }
392        for (rname, entry) in inv.iter_mut() {
393            if !on_disk.contains(rname) && !entry.stale {
394                entry.stale = true;
395            }
396        }
397        self.save_inventory(&inv)?;
398        Ok(())
399    }
400
401    fn bump_access(&self, name: &str, action: &str) {
402        let mut inv = self.load_inventory();
403        let now = now_iso();
404        let entry = inv
405            .entry(name.to_string())
406            .or_insert_with(|| InventoryEntry {
407                cloned_at: now.clone(),
408                last_accessed: now.clone(),
409                access_count: 0,
410                stale: false,
411                last_built_sha: None,
412            });
413        entry.last_accessed = now.clone();
414        entry.access_count += 1;
415        entry.stale = false;
416        if action == "cloned" || entry.cloned_at.is_empty() {
417            entry.cloned_at = now;
418        }
419        let _ = self.save_inventory(&inv);
420    }
421
422    fn mark_stale(&self, name: &str) {
423        let mut inv = self.load_inventory();
424        if let Some(entry) = inv.get_mut(name) {
425            entry.stale = true;
426            let _ = self.save_inventory(&inv);
427        }
428    }
429
430    fn sweep_stale(&self) -> Vec<String> {
431        // Local mode has nothing to sweep — the operator owns the root.
432        if matches!(self.inner.kind, WorkspaceKind::Local) {
433            return Vec::new();
434        }
435        let mut inv = self.load_inventory();
436        let cutoff = SystemTime::now()
437            - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
438        let active = self.active_repo_name();
439        let mut swept: Vec<String> = Vec::new();
440        for (rname, entry) in inv.iter_mut() {
441            if entry.stale {
442                continue;
443            }
444            if Some(rname.as_str()) == active.as_deref() {
445                continue;
446            }
447            let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
448            if last >= cutoff {
449                continue;
450            }
451            let parts: Vec<&str> = rname.splitn(2, '/').collect();
452            if parts.len() != 2 {
453                continue;
454            }
455            let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
456            if repo_path.exists() {
457                let _ = fs::remove_dir_all(&repo_path);
458            }
459            entry.stale = true;
460            swept.push(rname.clone());
461        }
462        if !swept.is_empty() {
463            let _ = self.save_inventory(&inv);
464            self.prune_empty_org_dirs();
465        }
466        swept
467    }
468
469    fn prune_empty_org_dirs(&self) {
470        let Ok(entries) = fs::read_dir(self.repos_dir()) else {
471            return;
472        };
473        for entry in entries.flatten() {
474            let path = entry.path();
475            if !path.is_dir() {
476                continue;
477            }
478            if let Ok(children) = fs::read_dir(&path) {
479                let real: Vec<_> = children
480                    .flatten()
481                    .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
482                    .collect();
483                if real.is_empty() {
484                    let _ = fs::remove_dir_all(&path);
485                }
486            }
487        }
488    }
489
490    // ------------------------------------------------------------------
491    // Git operations
492    // ------------------------------------------------------------------
493
494    /// Clone (if missing) or fast-forward (if cloned). Returns the
495    /// action label, the repo path, and the new HEAD SHA after the op.
496    ///
497    /// Local-mode short-circuits: there's nothing to clone or fetch.
498    /// The "SHA" is a cheap content fingerprint (recursive walk of file
499    /// mtimes + sizes) so the auto-rebuild gate still works.
500    fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
501        if matches!(self.inner.kind, WorkspaceKind::Local) {
502            // Local mode tracks the *currently bound* root, not the
503            // immutable configured `workspace_dir`. `set_root_dir` writes
504            // the target to `active_repo_path` before calling `activate`;
505            // this read picks that up so the fingerprint and the
506            // post-activate hook fire against the new root, and so the
507            // subsequent `active_repo_path` write in `activate` doesn't
508            // clobber the just-set target back to `workspace_dir`. Falls
509            // back to `workspace_dir` only if state is unset, which
510            // shouldn't happen after `open_local` seeds it.
511            let root = self
512                .inner
513                .state
514                .read()
515                .unwrap()
516                .active_repo_path
517                .clone()
518                .unwrap_or_else(|| self.inner.workspace_dir.clone());
519            let prev_sha = self.last_built_sha(name);
520            let fingerprint = fingerprint_dir(&root);
521            let action = match prev_sha {
522                Some(p) if p == fingerprint => "current",
523                None => "cloned", // first activation
524                Some(_) => "updated",
525            };
526            return Ok((action.to_string(), root, fingerprint));
527        }
528        let parts: Vec<&str> = name.splitn(2, '/').collect();
529        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
530        if !repo_path.exists() {
531            fs::create_dir_all(repo_path.parent().unwrap()).ok();
532            let url = format!("https://github.com/{name}.git");
533            // Treeless clone (`--filter=tree:0`): keeps the FULL commit
534            // history — so `git log -S` (pickaxe) and any rev walk work —
535            // while fetching tree/blob objects lazily on demand, keeping
536            // the initial transfer near a shallow clone's cost. `--tags`
537            // pulls all tags up front so tag-scoped rev reads
538            // (`read_source rev=v1.2.3`) resolve without a follow-up fetch.
539            // (Was `--depth 1`, which truncated history and broke pickaxe.)
540            let out = Command::new("git")
541                .args([
542                    "clone",
543                    "--filter=tree:0",
544                    "--tags",
545                    &url,
546                    repo_path.to_str().unwrap(),
547                ])
548                .output()
549                .context("failed to spawn `git clone`")?;
550            if !out.status.success() {
551                anyhow::bail!(
552                    "git clone failed: {}",
553                    String::from_utf8_lossy(&out.stderr).trim()
554                );
555            }
556            let sha = git_rev_parse(&repo_path, "HEAD")?;
557            return Ok(("cloned".to_string(), repo_path, sha));
558        }
559
560        // Fetch + check head delta. Plain `git fetch origin --tags` (no
561        // `--depth 1`) so the treeless clone stays history-complete and
562        // newly-pushed tags become available for rev-scoped reads; blobs
563        // are still fetched lazily. FETCH_HEAD records the remote's
564        // default-branch tip, so the SHA-gate below is unchanged.
565        Command::new("git")
566            .args(["fetch", "origin", "--tags"])
567            .current_dir(&repo_path)
568            .output()
569            .context("git fetch failed")?;
570        let local = git_rev_parse(&repo_path, "HEAD")?;
571        let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
572        if local != remote {
573            Command::new("git")
574                .args(["reset", "--hard", "FETCH_HEAD"])
575                .current_dir(&repo_path)
576                .output()
577                .context("git reset failed")?;
578            let sha = git_rev_parse(&repo_path, "HEAD")?;
579            return Ok(("updated".to_string(), repo_path, sha));
580        }
581        Ok(("current".to_string(), repo_path, local))
582    }
583
584    /// Resolve a [`RevsRequest`] against the git repo at `repo_path` into
585    /// a concrete, ordered list of git revspecs.
586    ///
587    /// - `Count(n)`: the newest `n` tags by version sort
588    ///   (`git tag --sort=-v:refname`, take `n`), **reversed to
589    ///   oldest→newest**, with `HEAD` appended as the final (newest) rev.
590    ///   Errors if the repo has no tags at all (nothing to resolve).
591    ///   Fewer than `n` tags is not an error — all available tags are
592    ///   used.
593    /// - `List(revs)`: each revspec is validated with
594    ///   `git rev-parse --verify <rev>^{commit}` and used verbatim (no
595    ///   sort, no `HEAD` appended). Errors on the first unknown rev.
596    ///
597    /// A non-git `repo_path` surfaces as the `git tag` / `git rev-parse`
598    /// failure with a clear message.
599    fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
600        match req {
601            RevsRequest::Count(n) => {
602                let out = Command::new("git")
603                    .args(["tag", "--sort=-v:refname"])
604                    .current_dir(repo_path)
605                    .output()
606                    .context("failed to spawn `git tag`")?;
607                if !out.status.success() {
608                    anyhow::bail!(
609                        "cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
610                        repo_path.display(),
611                        String::from_utf8_lossy(&out.stderr).trim()
612                    );
613                }
614                let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
615                    .lines()
616                    .map(|l| l.trim().to_string())
617                    .filter(|l| !l.is_empty())
618                    .collect();
619                if tags.is_empty() {
620                    anyhow::bail!(
621                        "revs={n} requested but '{}' has no tags to resolve",
622                        repo_path.display()
623                    );
624                }
625                // Newest `n` (version-sorted desc), reversed to
626                // oldest→newest, then HEAD last so a multi-rev builder
627                // merges with HEAD's signature winning.
628                let mut chosen: Vec<String> = tags.into_iter().take(*n).collect();
629                chosen.reverse();
630                chosen.push("HEAD".to_string());
631                Ok(chosen)
632            }
633            RevsRequest::List(revs) => {
634                if revs.is_empty() {
635                    anyhow::bail!("revs list is empty — pass at least one revision");
636                }
637                for r in revs {
638                    let out = Command::new("git")
639                        .args([
640                            "rev-parse",
641                            "--verify",
642                            "--quiet",
643                            &format!("{r}^{{commit}}"),
644                        ])
645                        .current_dir(repo_path)
646                        .output()
647                        .context("failed to spawn `git rev-parse`")?;
648                    if !out.status.success() {
649                        anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
650                    }
651                }
652                Ok(revs.clone())
653            }
654        }
655    }
656
657    /// Activate a repo: clone if needed, fast-forward, fire post-activate hook.
658    ///
659    /// Auto-rebuild gating: if `force_rebuild` is false AND no `revs` were
660    /// requested AND the repo is already at the HEAD it was last built at
661    /// (`action == "current"` AND `prev_built_sha == new_head`), the
662    /// post-activate hook is skipped. This makes `repo_management(update=True)`
663    /// cheap when upstream hasn't moved. Set `force_rebuild=true` to bypass
664    /// (e.g. after upgrading the builder itself).
665    ///
666    /// When `revs` are requested the skip gate never applies — a
667    /// revs-requested activation always fires the hook (see the gate
668    /// comment below). If the revs-aware hook is set, it is called with
669    /// the resolved revspecs; otherwise the plain hook runs (single-rev
670    /// build) and the resolved list is not reported.
671    ///
672    /// On successful hook completion the new HEAD SHA is persisted to
673    /// `inventory.json[name].last_built_sha`. If the hook fails the SHA
674    /// is NOT recorded, so the next `update=True` re-attempts the build.
675    fn activate(
676        &self,
677        name: &str,
678        force_rebuild: bool,
679        revs: Option<&RevsRequest>,
680    ) -> Result<String> {
681        let prev_built_sha = self.last_built_sha(name);
682        let (action, repo_path, head_sha) = self.clone_or_update(name)?;
683        // Resolve any requested revs before mutating active state, so a
684        // bad request (no tags / unknown rev) returns a clean error with
685        // the repo cloned-but-not-activated rather than half-bound.
686        let resolved_revs = match revs {
687            Some(req) => Some(self.resolve_revs(&repo_path, req)?),
688            None => None,
689        };
690        self.bump_access(name, &action);
691        let is_active_built = {
692            let mut state = self.inner.state.write().unwrap();
693            state.active_repo_name = Some(name.to_string());
694            state.active_repo_path = Some(repo_path.clone());
695            state.active_built_name.as_deref() == Some(name)
696        };
697
698        // The skip gate must be satisfied on BOTH axes: the git repo is
699        // at its last-built SHA (persisted, cross-process) AND `name` is
700        // the *currently active* built root in this process (in-memory).
701        // Without the second axis a fresh process would inherit
702        // `last_built_sha` from disk, skip the hook, and leave the
703        // consumer's in-memory state (e.g. the code graph) empty —
704        // activate would report success with nothing loaded. The axis
705        // checks the *active* built name, not any name ever built, so an
706        // A→B→A swap correctly rebuilds A: after activate(B) the live
707        // slot holds B, so re-binding A must not skip (see the
708        // `active_built_name` field doc).
709        // Skip-gate / revs interaction (SIMPLEST CORRECT, by design): a
710        // revs-requested activation ALWAYS fires the hook — the SHA-skip
711        // gate only applies to the plain (no-revs) path. Rationale: the
712        // gate keys off HEAD's SHA alone, which says nothing about which
713        // *set* of revs a prior build loaded; a request for a different
714        // rev-set at the same HEAD must rebuild. Rev-aware skip logic
715        // (hashing the resolved rev-set into the inventory) is deliberately
716        // NOT built here — the tradeoff is that repeat `revs=` calls at an
717        // unchanged HEAD re-parse every rev, which is acceptable for an
718        // explicit multi-rev request.
719        let already_built = !force_rebuild
720            && resolved_revs.is_none()
721            && action == "current"
722            && prev_built_sha.as_deref() == Some(head_sha.as_str())
723            && is_active_built;
724        let mut hook_skipped = false;
725        // Tracks whether the revs-aware hook actually ran (revs requested
726        // AND that hook set) — only then do we report the resolved list.
727        let mut revs_hook_ran = false;
728        let hook_ok = if already_built {
729            hook_skipped = true;
730            true
731        } else if let (Some(resolved), Some(revs_hook)) =
732            (resolved_revs.as_ref(), &self.inner.post_activate_revs)
733        {
734            revs_hook_ran = true;
735            match revs_hook(&repo_path, name, resolved) {
736                Ok(()) => true,
737                Err(e) => {
738                    tracing::warn!("post-activate revs hook for {name} failed: {e}");
739                    false
740                }
741            }
742        } else if let Some(hook) = &self.inner.post_activate {
743            // No revs requested, or revs requested with no revs-hook set:
744            // fall back to the plain single-rev (HEAD) build.
745            match hook(&repo_path, name) {
746                Ok(()) => true,
747                Err(e) => {
748                    tracing::warn!("post-activate hook for {name} failed: {e}");
749                    false
750                }
751            }
752        } else {
753            // No hook configured — record the SHA so future calls can
754            // see "no work to do" without consulting an empty store.
755            true
756        };
757        if hook_ok {
758            self.record_built_sha(name, &head_sha);
759        }
760        // Mark this name the currently-active built root only when the
761        // hook actually ran this process (not on the cheap-skip path,
762        // where it is already the active built name). A no-op "no hook
763        // configured" activation also counts: there is no per-process
764        // product to lose, so a future same-root skip is safe. This
765        // *overwrites* any prior name — modelling that each activate
766        // replaces the live product — so the next swap back rebuilds.
767        if hook_ok && !hook_skipped {
768            self.inner.state.write().unwrap().active_built_name = Some(name.to_string());
769        }
770        let verb = match action.as_str() {
771            "cloned" => "Cloned",
772            "updated" => "Updated",
773            "current" => "Activated (already up to date)",
774            other => other,
775        };
776        let suffix = if hook_skipped {
777            " [build skipped: HEAD matches last-built SHA]"
778        } else {
779            ""
780        };
781        let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
782        // Name the resolved revisions on their own line so agents see
783        // exactly what got loaded. Only when the revs-hook actually ran
784        // (revs requested AND hook set AND it succeeded) — a fallback to
785        // the plain hook loads HEAD only, so claiming a rev-set would lie.
786        if revs_hook_ran && hook_ok {
787            if let Some(resolved) = &resolved_revs {
788                base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
789            }
790        }
791        // Append the consumer's opening-steer mini-map, if configured.
792        // Fired on any successful activation (fresh build or cheap-skip —
793        // in both cases the in-memory product is live this process); the
794        // hook recomputes the summary from that live state. Skipped only
795        // when the build hook itself failed.
796        let summary = if hook_ok {
797            self.inner
798                .activation_summary
799                .as_ref()
800                .and_then(|h| h(&repo_path, name))
801        } else {
802            None
803        };
804        Ok(match summary {
805            Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
806            _ => base,
807        })
808    }
809
810    fn record_built_sha(&self, name: &str, sha: &str) {
811        let mut inv = self.load_inventory();
812        if let Some(entry) = inv.get_mut(name) {
813            entry.last_built_sha = Some(sha.to_string());
814            let _ = self.save_inventory(&inv);
815        }
816    }
817
818    /// Read the SHA recorded after the last successful post-activate hook
819    /// for the named repo. `None` if the repo was never built (or the
820    /// hook last failed). Useful for downstream consumers gating
821    /// "is the active graph up to date with the repo HEAD?" checks.
822    pub fn last_built_sha(&self, name: &str) -> Option<String> {
823        self.load_inventory()
824            .get(name)
825            .and_then(|e| e.last_built_sha.clone())
826    }
827
828    fn delete(&self, name: &str) -> Result<String> {
829        let parts: Vec<&str> = name.splitn(2, '/').collect();
830        if parts.len() != 2 {
831            anyhow::bail!("Invalid repo name");
832        }
833        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
834        let mut deleted = Vec::new();
835        if repo_path.exists() {
836            fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
837            deleted.push("repo");
838        }
839        self.mark_stale(name);
840        self.prune_empty_org_dirs();
841        if deleted.is_empty() {
842            return Ok(format!("Nothing to delete — '{name}' not found."));
843        }
844        let mut state = self.inner.state.write().unwrap();
845        if state.active_repo_name.as_deref() == Some(name) {
846            state.active_repo_name = None;
847            state.active_repo_path = None;
848            return Ok(format!(
849                "Deleted {}. Active repo cleared.",
850                deleted.join(", ")
851            ));
852        }
853        Ok(format!("Deleted {}.", deleted.join(", ")))
854    }
855
856    fn list(&self) -> String {
857        let inv = self.load_inventory();
858        if inv.is_empty() {
859            return "No repos cloned yet. Call repo_management('org/repo') to clone one."
860                .to_string();
861        }
862        let active = self.active_repo_name();
863        let mut live: Vec<String> = Vec::new();
864        let mut stale_lines: Vec<String> = Vec::new();
865        for (rname, entry) in &inv {
866            let marker = if Some(rname.as_str()) == active.as_deref() {
867                " [active]"
868            } else {
869                ""
870            };
871            let access = format!(
872                "{} access{}, last {}",
873                entry.access_count,
874                if entry.access_count == 1 { "" } else { "es" },
875                relative_time(&entry.last_accessed)
876            );
877            if entry.stale {
878                stale_lines.push(format!(
879                    "  {rname}  [STALE — re-fetch with repo_management('{rname}')]  ({access})"
880                ));
881            } else {
882                live.push(format!("  {rname}{marker}  ({access})"));
883            }
884        }
885        let mut out = String::new();
886        if !live.is_empty() {
887            out.push_str(&format!(
888                "{} live repo(s):\n{}",
889                live.len(),
890                live.join("\n")
891            ));
892        }
893        if !stale_lines.is_empty() {
894            if !out.is_empty() {
895                out.push_str("\n\n");
896            }
897            out.push_str(&format!(
898                "{} stale repo(s):\n{}",
899                stale_lines.len(),
900                stale_lines.join("\n")
901            ));
902        }
903        out
904    }
905
906    /// Public entry for the `repo_management` MCP tool.
907    ///
908    /// - `name`: `org/repo` to activate (None = list / refresh mode).
909    /// - `delete`: remove the named repo + inventory entry. Github only.
910    /// - `update`: refresh the active repo (auto-rebuild gated).
911    /// - `force_rebuild`: with `update=true` (or initial activation),
912    ///   re-run the post-activate hook even when the HEAD SHA matches
913    ///   `last_built_sha`. Useful after the builder itself has been
914    ///   upgraded.
915    ///
916    /// Local mode behaviour: `name` and `delete` are rejected; pass
917    /// `update=true` (or no args after the initial activation) to
918    /// re-fingerprint the root and rebuild if anything changed.
919    pub fn repo_management(
920        &self,
921        name: Option<&str>,
922        delete: bool,
923        update: bool,
924        force_rebuild: bool,
925        revs: Option<&RevsRequest>,
926    ) -> String {
927        // Local mode: most github-only semantics are nonsensical here.
928        if matches!(self.inner.kind, WorkspaceKind::Local) {
929            if name.is_some() {
930                return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
931                        to switch the active root, or pass `update=true` / `force_rebuild=true` \
932                        to rebuild against the current root."
933                    .to_string();
934            }
935            if delete {
936                return "Local-workspace mode does not support `delete`. The root is owned by the \
937                        operator; remove it manually."
938                    .to_string();
939            }
940            let active = match self.active_repo_name() {
941                Some(n) => n,
942                None => return "No active local root.".to_string(),
943            };
944            // `update`: re-fingerprint and rebuild if anything changed.
945            // `force_rebuild`: rebuild even when the fingerprint matches.
946            // Either flag (or neither — initial bind path) routes through
947            // `activate`; `activate` itself consults the gate using the
948            // force flag plus the SHA comparison.
949            let _ = update; // explicit: update is implicit in local mode
950            return self
951                .activate(&active, force_rebuild, revs)
952                .unwrap_or_else(|e| format!("rebuild failed: {e}"));
953        }
954
955        let swept = self.sweep_stale();
956        let prefix = if swept.is_empty() {
957            String::new()
958        } else {
959            format!(
960                "[Swept {} idle repo(s) (>{}d): {}]\n\n",
961                swept.len(),
962                self.inner.stale_after_days,
963                swept.join(", ")
964            )
965        };
966
967        if name.is_none() && !update {
968            return prefix + &self.list();
969        }
970
971        if update {
972            let Some(active) = self.active_repo_name() else {
973                return prefix + "No active repository. Call repo_management('org/repo') first.";
974            };
975            return prefix
976                + &self
977                    .activate(&active, force_rebuild, revs)
978                    .unwrap_or_else(|e| format!("update failed: {e}"));
979        }
980
981        let Some(name) = name else {
982            return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
983        };
984        if let Err(e) = validate_repo_name(name) {
985            return prefix + &e.to_string();
986        }
987        if delete {
988            return prefix
989                + &self
990                    .delete(name)
991                    .unwrap_or_else(|e| format!("delete failed: {e}"));
992        }
993        prefix
994            + &self
995                .activate(name, force_rebuild, revs)
996                .unwrap_or_else(|e| format!("activate failed: {e}"))
997    }
998
999    /// Swap the active root (local mode only). Re-fires the post-activate
1000    /// hook against the new root. Errors if the workspace is github-flavoured.
1001    ///
1002    /// `revs` (optional): resolve revisions against the new root (which
1003    /// must be a git repo) and fire the revs-aware hook — see
1004    /// [`activate`](Self::activate) / [`RevsRequest`].
1005    pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
1006        if !matches!(self.inner.kind, WorkspaceKind::Local) {
1007            return "set_root_dir is only valid in local-workspace mode.".to_string();
1008        }
1009        if !new_root.is_dir() {
1010            return format!(
1011                "Path does not exist or is not a directory: {}",
1012                new_root.display()
1013            );
1014        }
1015        let canon = match new_root.canonicalize() {
1016            Ok(p) => p,
1017            Err(e) => return format!("canonicalize failed: {e}"),
1018        };
1019        let synthetic = synthesize_local_name(&canon);
1020        {
1021            let mut state = self.inner.state.write().unwrap();
1022            state.active_repo_name = Some(synthetic.clone());
1023            state.active_repo_path = Some(canon.clone());
1024        }
1025        // Note: the WorkspaceInner.workspace_dir field is the path the
1026        // inventory is stored under. We keep the *original* one (from
1027        // open_local) so the inventory survives across root swaps.
1028        self.activate(&synthetic, false, revs)
1029            .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
1030    }
1031}
1032
1033/// Synthesise a stable "repo name" for a local workspace from its path.
1034/// Used as the inventory key so the same gating + persistence code paths
1035/// that github mode uses can apply to local mode unchanged.
1036fn synthesize_local_name(root: &Path) -> String {
1037    let name = root
1038        .file_name()
1039        .map(|s| s.to_string_lossy().into_owned())
1040        .unwrap_or_else(|| "local".to_string());
1041    format!("local/{name}")
1042}
1043
1044/// Parse the `org/repo` slug from a local checkout's `origin` remote.
1045///
1046/// Shells out to `git -C <root> remote get-url origin` and parses both
1047/// canonical GitHub remote forms, stripping the trailing `.git`:
1048///   - `git@github.com:kkollsga/kglite.git`     → `kkollsga/kglite`
1049///   - `https://github.com/kkollsga/kglite.git` → `kkollsga/kglite`
1050///
1051/// Returns `None` for a non-git directory, a missing `origin` remote, or
1052/// a non-GitHub remote — so the GitHub tools fall back to their existing
1053/// empty-default path (ask the caller for `repo_name`).
1054fn parse_origin_repo(root: &Path) -> Option<String> {
1055    let out = Command::new("git")
1056        .arg("-C")
1057        .arg(root)
1058        .args(["remote", "get-url", "origin"])
1059        .output()
1060        .ok()?;
1061    if !out.status.success() {
1062        return None;
1063    }
1064    let url = String::from_utf8(out.stdout).ok()?;
1065    parse_github_remote(url.trim())
1066}
1067
1068/// Pure-string half of [`parse_origin_repo`]: turn a GitHub remote URL
1069/// into `org/repo`, or `None` if it isn't a recognisable GitHub remote.
1070fn parse_github_remote(url: &str) -> Option<String> {
1071    // Accept both SSH (`git@github.com:org/repo`) and HTTPS
1072    // (`https://github.com/org/repo`) forms; everything after the host
1073    // separator is the path.
1074    let path = url
1075        .strip_prefix("git@github.com:")
1076        .or_else(|| url.strip_prefix("https://github.com/"))
1077        .or_else(|| url.strip_prefix("http://github.com/"))
1078        .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
1079    let path = path.strip_suffix(".git").unwrap_or(path);
1080    let path = path.trim_end_matches('/');
1081    // Must be exactly `org/repo` — both segments non-empty, one slash.
1082    let mut parts = path.split('/');
1083    let org = parts.next().filter(|s| !s.is_empty())?;
1084    let repo = parts.next().filter(|s| !s.is_empty())?;
1085    if parts.next().is_some() {
1086        return None;
1087    }
1088    Some(format!("{org}/{repo}"))
1089}
1090
1091/// Cheap recursive content fingerprint of a directory tree. Walks files
1092/// (respecting common ignore patterns) and folds `(path, mtime, len)`
1093/// into a 64-bit hash, then hex-formats it. Good enough to detect
1094/// "did anything change?" for auto-rebuild gating — not cryptographic.
1095fn fingerprint_dir(root: &Path) -> String {
1096    use std::hash::{Hash, Hasher};
1097    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1098    let walker = ignore::WalkBuilder::new(root)
1099        .standard_filters(true)
1100        .hidden(true)
1101        .git_ignore(true)
1102        .build();
1103    for entry in walker.flatten() {
1104        if !entry.path().is_file() {
1105            continue;
1106        }
1107        let Ok(meta) = entry.metadata() else { continue };
1108        let mtime = meta
1109            .modified()
1110            .ok()
1111            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1112            .map(|d| d.as_secs())
1113            .unwrap_or(0);
1114        entry.path().to_string_lossy().hash(&mut hasher);
1115        mtime.hash(&mut hasher);
1116        meta.len().hash(&mut hasher);
1117    }
1118    format!("local-{:016x}", hasher.finish())
1119}
1120
1121fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
1122    let out = Command::new("git")
1123        .args(["rev-parse", refspec])
1124        .current_dir(repo_path)
1125        .output()
1126        .context("git rev-parse failed")?;
1127    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1128}
1129
1130fn now_iso() -> String {
1131    format_iso(SystemTime::now())
1132}
1133
1134fn format_iso(t: SystemTime) -> String {
1135    let secs = t
1136        .duration_since(SystemTime::UNIX_EPOCH)
1137        .map(|d| d.as_secs())
1138        .unwrap_or(0);
1139    // Lightweight RFC3339-ish formatter. Drop sub-second precision; matches Python isoformat(timespec=seconds).
1140    chrono_lite::format_secs(secs)
1141}
1142
1143fn parse_iso(s: &str) -> Option<SystemTime> {
1144    let secs = chrono_lite::parse_secs(s)?;
1145    SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
1146}
1147
1148fn relative_time(iso: &str) -> String {
1149    let Some(t) = parse_iso(iso) else {
1150        return "unknown".to_string();
1151    };
1152    let now = SystemTime::now();
1153    let delta = now.duration_since(t).unwrap_or_default().as_secs();
1154    if delta < 3600 {
1155        "just now".to_string()
1156    } else if delta < 86_400 {
1157        format!("{}h ago", delta / 3600)
1158    } else {
1159        format!("{}d ago", delta / 86_400)
1160    }
1161}
1162
1163/// Tiny self-contained ISO-8601 (seconds-precision) formatter so we
1164/// don't pull in `chrono` for a handful of timestamps.
1165mod chrono_lite {
1166    pub fn format_secs(secs: u64) -> String {
1167        // Civil-from-days algorithm (Howard Hinnant). Output: YYYY-MM-DDTHH:MM:SS.
1168        let days = (secs / 86_400) as i64;
1169        let time = secs % 86_400;
1170        let (y, mo, d) = days_to_civil(days + 719_468);
1171        let h = time / 3600;
1172        let m = (time / 60) % 60;
1173        let s = time % 60;
1174        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
1175    }
1176
1177    pub fn parse_secs(s: &str) -> Option<u64> {
1178        // Accept "YYYY-MM-DDTHH:MM:SS" (no zone) — same shape as format_secs output
1179        // and Python's datetime.isoformat(timespec="seconds").
1180        let bytes = s.as_bytes();
1181        if bytes.len() < 19 {
1182            return None;
1183        }
1184        let y: i64 = s.get(0..4)?.parse().ok()?;
1185        let mo: u32 = s.get(5..7)?.parse().ok()?;
1186        let d: u32 = s.get(8..10)?.parse().ok()?;
1187        let h: u64 = s.get(11..13)?.parse().ok()?;
1188        let m: u64 = s.get(14..16)?.parse().ok()?;
1189        let sc: u64 = s.get(17..19)?.parse().ok()?;
1190        let days = civil_to_days(y, mo, d) - 719_468;
1191        Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
1192    }
1193
1194    fn days_to_civil(z: i64) -> (i64, u32, u32) {
1195        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1196        let doe = (z - era * 146_097) as u64;
1197        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1198        let y = (yoe as i64) + era * 400;
1199        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1200        let mp = (5 * doy + 2) / 153;
1201        let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1202        let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
1203        let y = if m <= 2 { y + 1 } else { y };
1204        (y, m, d)
1205    }
1206
1207    fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
1208        let y = if m <= 2 { y - 1 } else { y };
1209        let era = if y >= 0 { y } else { y - 399 } / 400;
1210        let yoe = (y - era * 400) as u64;
1211        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
1212        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1213        era * 146_097 + doe as i64
1214    }
1215}
1216
1217// silences unused-import-when-helper-only-via-json! macro check.
1218#[allow(dead_code)]
1219fn _json_keepalive() {
1220    let _ = json!({});
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226
1227    #[test]
1228    fn validates_repo_names() {
1229        assert!(validate_repo_name("pydata/xarray").is_ok());
1230        assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1231        assert!(validate_repo_name("xarray").is_err());
1232        assert!(validate_repo_name("a/b/c").is_err());
1233        assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1234    }
1235
1236    #[test]
1237    fn open_creates_layout() {
1238        let dir = tempfile::tempdir().unwrap();
1239        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1240        assert!(ws.repos_dir().is_dir());
1241    }
1242
1243    #[test]
1244    fn empty_list() {
1245        let dir = tempfile::tempdir().unwrap();
1246        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1247        let out = ws.repo_management(None, false, false, false, None);
1248        assert!(out.contains("No repos cloned yet"));
1249    }
1250
1251    #[test]
1252    fn invalid_repo_name_rejected() {
1253        let dir = tempfile::tempdir().unwrap();
1254        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1255        let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
1256        assert!(out.contains("Invalid repo name"));
1257    }
1258
1259    #[test]
1260    fn delete_unknown() {
1261        let dir = tempfile::tempdir().unwrap();
1262        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1263        let out = ws.repo_management(Some("nope/none"), true, false, false, None);
1264        assert!(out.contains("Nothing to delete"));
1265    }
1266
1267    #[test]
1268    fn iso_round_trip() {
1269        let now = SystemTime::now()
1270            .duration_since(SystemTime::UNIX_EPOCH)
1271            .unwrap()
1272            .as_secs();
1273        let s = chrono_lite::format_secs(now);
1274        let back = chrono_lite::parse_secs(&s).unwrap();
1275        assert_eq!(now, back);
1276    }
1277
1278    #[test]
1279    fn last_built_sha_round_trip() {
1280        let dir = tempfile::tempdir().unwrap();
1281        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1282        // Seed an inventory entry directly (clone_or_update needs git).
1283        ws.bump_access("acme/widgets", "cloned");
1284        assert_eq!(ws.last_built_sha("acme/widgets"), None);
1285        ws.record_built_sha("acme/widgets", "abc1234deadbeef");
1286        assert_eq!(
1287            ws.last_built_sha("acme/widgets").as_deref(),
1288            Some("abc1234deadbeef")
1289        );
1290        // Survives an Workspace::open re-read (proves persistence).
1291        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1292        assert_eq!(
1293            ws2.last_built_sha("acme/widgets").as_deref(),
1294            Some("abc1234deadbeef")
1295        );
1296    }
1297
1298    #[test]
1299    fn inventory_loads_legacy_entries_without_sha_field() {
1300        let dir = tempfile::tempdir().unwrap();
1301        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1302        // Hand-craft an old-style inventory.json without `last_built_sha`.
1303        let legacy = r#"{
1304            "old/repo": {
1305                "cloned_at": "2024-01-01T00:00:00",
1306                "last_accessed": "2024-01-01T00:00:00",
1307                "access_count": 5,
1308                "stale": false
1309            }
1310        }"#;
1311        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1312        // Re-open and confirm graceful read.
1313        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1314        assert_eq!(ws2.last_built_sha("old/repo"), None);
1315        let _ = ws;
1316    }
1317
1318    #[test]
1319    fn auto_rebuild_gate_skips_when_sha_matches() {
1320        use std::sync::atomic::{AtomicUsize, Ordering};
1321        let dir = tempfile::tempdir().unwrap();
1322        let calls = Arc::new(AtomicUsize::new(0));
1323        let calls_h = calls.clone();
1324        let hook: PostActivateHook = Arc::new(move |_path, _name| {
1325            calls_h.fetch_add(1, Ordering::SeqCst);
1326            Ok(())
1327        });
1328        // Build a workspace pointing at a tempdir with a fake repo dir,
1329        // then simulate consecutive activates. We can't drive clone_or_update
1330        // without git, so test the gating directly by tracking the SHA
1331        // record-then-re-record case via Workspace::record_built_sha +
1332        // last_built_sha — the same predicate `activate` uses.
1333        let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1334        // Seed inventory entry + initial sha record.
1335        ws.bump_access("acme/widgets", "cloned");
1336        ws.record_built_sha("acme/widgets", "sha_one");
1337        assert_eq!(
1338            ws.last_built_sha("acme/widgets").as_deref(),
1339            Some("sha_one")
1340        );
1341        // Repeated record with the same value is idempotent (gating
1342        // logic uses last_built_sha as the source of truth).
1343        ws.record_built_sha("acme/widgets", "sha_one");
1344        assert_eq!(
1345            ws.last_built_sha("acme/widgets").as_deref(),
1346            Some("sha_one")
1347        );
1348        // No hook calls have been driven directly — this test exercises
1349        // the persistence path that the gate consults.
1350        assert_eq!(calls.load(Ordering::SeqCst), 0);
1351    }
1352
1353    #[test]
1354    fn local_workspace_binds_root_immediately() {
1355        let dir = tempfile::tempdir().unwrap();
1356        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1357        assert_eq!(ws.kind(), WorkspaceKind::Local);
1358        assert!(ws.active_repo_path().is_some());
1359        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1360    }
1361
1362    #[test]
1363    fn local_workspace_rejects_github_ops() {
1364        let dir = tempfile::tempdir().unwrap();
1365        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1366        let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
1367        assert!(out.contains("does not accept a repo name"));
1368        let out = ws.repo_management(None, true, false, false, None);
1369        assert!(out.contains("does not support `delete`"));
1370    }
1371
1372    #[test]
1373    fn local_workspace_update_rebuilds() {
1374        use std::sync::atomic::{AtomicUsize, Ordering};
1375        let dir = tempfile::tempdir().unwrap();
1376        // Drop a file so the fingerprint has something to hash.
1377        std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1378        let calls = Arc::new(AtomicUsize::new(0));
1379        let calls_h = calls.clone();
1380        let hook: PostActivateHook = Arc::new(move |_p, _n| {
1381            calls_h.fetch_add(1, Ordering::SeqCst);
1382            Ok(())
1383        });
1384        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1385        // First update: nothing built yet → hook fires.
1386        let _ = ws.repo_management(None, false, true, false, None);
1387        assert_eq!(calls.load(Ordering::SeqCst), 1);
1388        // Second update without changes → SHA matches → hook skipped.
1389        let out = ws.repo_management(None, false, true, false, None);
1390        assert_eq!(
1391            calls.load(Ordering::SeqCst),
1392            1,
1393            "auto-rebuild gate must skip"
1394        );
1395        assert!(out.contains("build skipped"));
1396    }
1397
1398    #[test]
1399    fn parses_github_remote_forms() {
1400        assert_eq!(
1401            parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1402            Some("kkollsga/kglite")
1403        );
1404        assert_eq!(
1405            parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1406            Some("kkollsga/kglite")
1407        );
1408        // No .git suffix, trailing slash.
1409        assert_eq!(
1410            parse_github_remote("https://github.com/acme/widget/").as_deref(),
1411            Some("acme/widget")
1412        );
1413        assert_eq!(
1414            parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1415            Some("acme/widget")
1416        );
1417        // Non-github / malformed → None.
1418        assert_eq!(
1419            parse_github_remote("https://gitlab.com/acme/widget.git"),
1420            None
1421        );
1422        assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1423        assert_eq!(parse_github_remote("not a url"), None);
1424    }
1425
1426    #[test]
1427    fn local_default_github_repo_uses_origin_remote() {
1428        let dir = tempfile::tempdir().unwrap();
1429        let root = dir.path();
1430        // Stand up a real git repo with a faked origin so default_github_repo
1431        // exercises the actual `git remote get-url` path.
1432        let git = |args: &[&str]| {
1433            Command::new("git")
1434                .arg("-C")
1435                .arg(root)
1436                .args(args)
1437                .output()
1438                .unwrap()
1439        };
1440        if !git(&["init"]).status.success() {
1441            // git unavailable in this environment — skip rather than fail.
1442            return;
1443        }
1444        git(&[
1445            "remote",
1446            "add",
1447            "origin",
1448            "https://github.com/acme/widget.git",
1449        ]);
1450        let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1451        assert_eq!(
1452            ws.default_github_repo().as_deref(),
1453            Some("acme/widget"),
1454            "local default repo must come from the origin remote, not the inventory key"
1455        );
1456        // The inventory key remains the synthetic local name.
1457        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1458    }
1459
1460    #[test]
1461    fn local_default_github_repo_none_without_remote() {
1462        let dir = tempfile::tempdir().unwrap();
1463        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1464        // No git remote → None, and crucially NOT Some("local/<dir>").
1465        let def = ws.default_github_repo();
1466        assert!(
1467            def.is_none(),
1468            "expected None for a non-git local root, got {def:?}"
1469        );
1470    }
1471
1472    #[test]
1473    fn set_root_dir_only_in_local_mode() {
1474        let dir = tempfile::tempdir().unwrap();
1475        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1476        let out = ws.set_root_dir(dir.path(), None);
1477        assert!(out.contains("only valid in local-workspace"));
1478    }
1479
1480    #[test]
1481    fn update_with_no_active_repo() {
1482        let dir = tempfile::tempdir().unwrap();
1483        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1484        let out = ws.repo_management(None, false, true, false, None);
1485        assert!(out.contains("No active repository"));
1486    }
1487
1488    #[test]
1489    fn set_root_dir_updates_active_path() {
1490        let dir = tempfile::tempdir().unwrap();
1491        let child = dir.path().join("child");
1492        std::fs::create_dir_all(&child).unwrap();
1493        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1494        let _ = ws.set_root_dir(&child, None);
1495        assert_eq!(
1496            ws.active_repo_path().unwrap(),
1497            child.canonicalize().unwrap(),
1498            "set_root_dir didn't update active_repo_path"
1499        );
1500    }
1501
1502    #[test]
1503    fn set_root_dir_post_activate_fires_against_new_root() {
1504        let dir = tempfile::tempdir().unwrap();
1505        let child = dir.path().join("child");
1506        std::fs::create_dir_all(&child).unwrap();
1507        std::fs::write(child.join("a.txt"), b"hi").unwrap();
1508        let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1509        let seen = seen_path.clone();
1510        let hook: PostActivateHook = Arc::new(move |p, _n| {
1511            *seen.lock().unwrap() = Some(p.to_path_buf());
1512            Ok(())
1513        });
1514        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1515        let _ = ws.set_root_dir(&child, None);
1516        assert_eq!(
1517            seen_path.lock().unwrap().clone().unwrap(),
1518            child.canonicalize().unwrap(),
1519            "post_activate hook saw the wrong root after set_root_dir"
1520        );
1521    }
1522
1523    #[test]
1524    fn activation_summary_appended_to_activate_message() {
1525        let dir = tempfile::tempdir().unwrap();
1526        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1527        let summary: ActivationSummaryHook =
1528            Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
1529        let ws = Workspace::open_local(dir.path().to_path_buf(), None)
1530            .unwrap()
1531            .with_activation_summary(summary);
1532        let out = ws.repo_management(None, false, true, false, None);
1533        assert!(
1534            out.contains("Graph ready: 3 Functions."),
1535            "activation message should include the summary; got: {out}"
1536        );
1537    }
1538
1539    #[test]
1540    fn activation_summary_absent_when_not_configured() {
1541        let dir = tempfile::tempdir().unwrap();
1542        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1543        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1544        let out = ws.repo_management(None, false, true, false, None);
1545        assert!(!out.contains("Graph ready"));
1546        assert!(
1547            out.contains(" at "),
1548            "expected the terse default message; got: {out}"
1549        );
1550    }
1551
1552    #[test]
1553    fn hook_fires_once_per_process_even_when_sha_matches() {
1554        use std::sync::atomic::{AtomicUsize, Ordering};
1555        // Local mode fingerprints the dir instead of a git SHA, so we can
1556        // drive the real `activate` path without git. A stable file keeps
1557        // the fingerprint constant across both simulated processes.
1558        let dir = tempfile::tempdir().unwrap();
1559        std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1560
1561        let calls = Arc::new(AtomicUsize::new(0));
1562        let make_hook = || -> PostActivateHook {
1563            let c = calls.clone();
1564            Arc::new(move |_p, _n| {
1565                c.fetch_add(1, Ordering::SeqCst);
1566                Ok(())
1567            })
1568        };
1569
1570        // --- Process 1 ---------------------------------------------------
1571        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1572        // First activate (fingerprint not yet recorded) → hook fires.
1573        let _ = ws.repo_management(None, false, true, false, None);
1574        assert_eq!(
1575            calls.load(Ordering::SeqCst),
1576            1,
1577            "first activate must hydrate"
1578        );
1579        // Second activate, same process, unchanged fingerprint → cheap-skip.
1580        let out = ws.repo_management(None, false, true, false, None);
1581        assert_eq!(
1582            calls.load(Ordering::SeqCst),
1583            1,
1584            "repeat activate in same process must skip the hook"
1585        );
1586        assert!(
1587            out.contains("build skipped"),
1588            "expected skip suffix, got: {out}"
1589        );
1590        drop(ws);
1591
1592        // --- Process 2 (restart) ----------------------------------------
1593        // Same dir → inventory.json + last_built_sha persist, but the
1594        // in-memory hydration set does not. The first activate here must
1595        // re-fire the hook to rehydrate the consumer's in-memory state.
1596        let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1597        assert!(
1598            ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1599                .is_some(),
1600            "sanity: last_built_sha should survive the restart"
1601        );
1602        let _ = ws2.repo_management(None, false, true, false, None);
1603        assert_eq!(
1604            calls.load(Ordering::SeqCst),
1605            2,
1606            "fresh process must re-fire the hook even when the SHA matches"
1607        );
1608    }
1609
1610    #[test]
1611    fn a_b_a_swap_rebuilds_intervening_root() {
1612        // Regression for the single-slot-consumer stale-graph bug: an
1613        // A→B→A swap must rebuild A on the second bind, because activating
1614        // B overwrote the consumer's single live slot. Before the fix the
1615        // skip gate keyed off "A was hydrated at some point this process"
1616        // and wrongly skipped, leaving B's product live under A's name.
1617        use std::sync::atomic::{AtomicUsize, Ordering};
1618        let root = tempfile::tempdir().unwrap();
1619        let a = root.path().join("projA");
1620        let b = root.path().join("projB");
1621        std::fs::create_dir_all(&a).unwrap();
1622        std::fs::create_dir_all(&b).unwrap();
1623        // Stable, distinct contents so each root's fingerprint holds
1624        // constant across re-binds (so `action == "current"` on the
1625        // second bind of A — the exact condition the gate keys on).
1626        std::fs::write(a.join("a.txt"), b"alpha").unwrap();
1627        std::fs::write(b.join("b.txt"), b"beta").unwrap();
1628
1629        // The hook records which root it last built into the single slot,
1630        // mirroring a single-active-graph consumer.
1631        let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1632        let built_h = built.clone();
1633        let calls = Arc::new(AtomicUsize::new(0));
1634        let calls_h = calls.clone();
1635        let hook: PostActivateHook = Arc::new(move |p, _n| {
1636            *built_h.lock().unwrap() = Some(p.to_path_buf());
1637            calls_h.fetch_add(1, Ordering::SeqCst);
1638            Ok(())
1639        });
1640
1641        let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
1642        // open_local binds A but doesn't fire the hook; first set_root_dir(A)
1643        // hydrates it.
1644        let _ = ws.set_root_dir(&a, None);
1645        assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
1646        assert_eq!(
1647            built.lock().unwrap().clone(),
1648            Some(a.canonicalize().unwrap())
1649        );
1650
1651        let _ = ws.set_root_dir(&b, None);
1652        assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
1653        assert_eq!(
1654            built.lock().unwrap().clone(),
1655            Some(b.canonicalize().unwrap())
1656        );
1657
1658        // The bug: re-binding A must rebuild (slot currently holds B), not
1659        // cheap-skip. The single slot must end up holding A again.
1660        let out = ws.set_root_dir(&a, None);
1661        assert_eq!(
1662            calls.load(Ordering::SeqCst),
1663            3,
1664            "A→B→A must rebuild A; the intervening B overwrote the live slot"
1665        );
1666        assert!(
1667            !out.contains("build skipped"),
1668            "re-bind of a non-active root must not skip; got: {out}"
1669        );
1670        assert_eq!(
1671            built.lock().unwrap().clone(),
1672            Some(a.canonicalize().unwrap()),
1673            "after A→B→A the live slot must hold A, not B"
1674        );
1675
1676        // And an immediate re-bind of the *currently active* root (A→A)
1677        // still cheap-skips — the win the gate was added for is preserved.
1678        let out = ws.set_root_dir(&a, None);
1679        assert_eq!(
1680            calls.load(Ordering::SeqCst),
1681            3,
1682            "re-binding the already-active root must skip the hook"
1683        );
1684        assert!(
1685            out.contains("build skipped"),
1686            "expected skip suffix, got: {out}"
1687        );
1688    }
1689
1690    // ------------------------------------------------------------------
1691    // revs (multi-revision activation)
1692    // ------------------------------------------------------------------
1693
1694    /// Stand up a real git repo at a fresh tempdir with the given tags
1695    /// created in order (so version-sort ordering is exercised). Returns
1696    /// the tempdir (keep alive) + its path, or `None` if git is
1697    /// unavailable in the environment (test then skips).
1698    fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
1699        let dir = tempfile::tempdir().unwrap();
1700        let root = dir.path().to_path_buf();
1701        let git = |args: &[&str]| {
1702            Command::new("git")
1703                .arg("-C")
1704                .arg(&root)
1705                .args(args)
1706                .output()
1707                .unwrap()
1708        };
1709        if !git(&["init"]).status.success() {
1710            return None; // git unavailable — caller skips.
1711        }
1712        git(&["config", "user.email", "t@example.com"]);
1713        git(&["config", "user.name", "Test"]);
1714        git(&["config", "commit.gpgsign", "false"]);
1715        for (i, tag) in tags.iter().enumerate() {
1716            std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
1717            git(&["add", "-A"]);
1718            assert!(
1719                git(&["commit", "-m", &format!("c{i}")]).status.success(),
1720                "git commit failed"
1721            );
1722            assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
1723        }
1724        Some((dir, root))
1725    }
1726
1727    #[test]
1728    fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
1729        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
1730            return;
1731        };
1732        let ws = Workspace::open_local(root.clone(), None).unwrap();
1733        let resolved = ws
1734            .resolve_revs(&root, &RevsRequest::Count(2))
1735            .expect("resolve should succeed");
1736        // Newest 2 = v2.0.0, v1.1.0 → oldest→newest → v1.1.0, v2.0.0, then HEAD.
1737        assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
1738    }
1739
1740    #[test]
1741    fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
1742        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
1743            return;
1744        };
1745        let ws = Workspace::open_local(root.clone(), None).unwrap();
1746        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
1747        assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
1748    }
1749
1750    #[test]
1751    fn resolve_revs_count_errors_when_no_tags() {
1752        let Some((_d, root)) = git_repo_with_tags(&[]) else {
1753            return;
1754        };
1755        // Empty repo has no commits yet; make one commit but no tags.
1756        let git = |args: &[&str]| {
1757            Command::new("git")
1758                .arg("-C")
1759                .arg(&root)
1760                .args(args)
1761                .output()
1762                .unwrap()
1763        };
1764        std::fs::write(root.join("f.txt"), b"x").unwrap();
1765        git(&["add", "-A"]);
1766        git(&["commit", "-m", "c0"]);
1767        let ws = Workspace::open_local(root.clone(), None).unwrap();
1768        let err = ws
1769            .resolve_revs(&root, &RevsRequest::Count(3))
1770            .expect_err("no tags → error");
1771        assert!(
1772            err.to_string().contains("no tags"),
1773            "expected a 'no tags' error, got: {err}"
1774        );
1775    }
1776
1777    #[test]
1778    fn resolve_revs_list_validates_and_rejects_unknown() {
1779        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
1780            return;
1781        };
1782        let ws = Workspace::open_local(root.clone(), None).unwrap();
1783        // Explicit list is used verbatim (no HEAD appended, no sort).
1784        let ok = ws
1785            .resolve_revs(
1786                &root,
1787                &RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
1788            )
1789            .unwrap();
1790        assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
1791        // An unknown rev is a clear error naming the bad rev.
1792        let err = ws
1793            .resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
1794            .expect_err("unknown rev → error");
1795        assert!(
1796            err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
1797            "expected an unknown-rev error, got: {err}"
1798        );
1799    }
1800
1801    #[test]
1802    fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
1803        use std::sync::atomic::{AtomicUsize, Ordering};
1804        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
1805            return;
1806        };
1807        let plain_calls = Arc::new(AtomicUsize::new(0));
1808        let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
1809        let pc = plain_calls.clone();
1810        let plain: PostActivateHook = Arc::new(move |_p, _n| {
1811            pc.fetch_add(1, Ordering::SeqCst);
1812            Ok(())
1813        });
1814        let sr = seen_revs.clone();
1815        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
1816            *sr.lock().unwrap() = Some(revs.to_vec());
1817            Ok(())
1818        });
1819        let ws = Workspace::open_local(root.clone(), Some(plain))
1820            .unwrap()
1821            .with_post_activate_revs(revs_hook);
1822        let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
1823        // The revs-hook ran with the resolved list; the plain hook did NOT.
1824        assert_eq!(
1825            seen_revs.lock().unwrap().clone().unwrap(),
1826            vec!["v1.1.0", "v2.0.0", "HEAD"]
1827        );
1828        assert_eq!(
1829            plain_calls.load(Ordering::SeqCst),
1830            0,
1831            "plain hook must not fire when the revs-hook handled the request"
1832        );
1833        // The activation message names the resolved revs on one line.
1834        assert!(
1835            out.contains("revs: v1.1.0, v2.0.0, HEAD"),
1836            "activation message should list the resolved revs; got: {out}"
1837        );
1838    }
1839
1840    #[test]
1841    fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
1842        use std::sync::atomic::{AtomicUsize, Ordering};
1843        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
1844            return;
1845        };
1846        let plain_calls = Arc::new(AtomicUsize::new(0));
1847        let revs_seen = Arc::new(AtomicUsize::new(0));
1848        let pc = plain_calls.clone();
1849        let plain: PostActivateHook = Arc::new(move |_p, _n| {
1850            pc.fetch_add(1, Ordering::SeqCst);
1851            Ok(())
1852        });
1853        let rs = revs_seen.clone();
1854        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
1855            rs.fetch_add(1, Ordering::SeqCst);
1856            Ok(())
1857        });
1858        let ws = Workspace::open_local(root.clone(), Some(plain))
1859            .unwrap()
1860            .with_post_activate_revs(revs_hook);
1861        // No revs → plain hook fires, revs-hook untouched, no `revs:` line.
1862        let out = ws.repo_management(None, false, true, false, None);
1863        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
1864        assert_eq!(
1865            revs_seen.load(Ordering::SeqCst),
1866            0,
1867            "revs-hook must not fire when no revs were requested"
1868        );
1869        assert!(
1870            !out.contains("revs:"),
1871            "no revs line expected on a plain activation; got: {out}"
1872        );
1873    }
1874
1875    #[test]
1876    fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
1877        use std::sync::atomic::{AtomicUsize, Ordering};
1878        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
1879            return;
1880        };
1881        let plain_calls = Arc::new(AtomicUsize::new(0));
1882        let pc = plain_calls.clone();
1883        let plain: PostActivateHook = Arc::new(move |_p, _n| {
1884            pc.fetch_add(1, Ordering::SeqCst);
1885            Ok(())
1886        });
1887        // No revs-hook attached: a revs request degrades to the plain
1888        // (HEAD-only) build and does NOT claim a rev-set in the message.
1889        let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
1890        let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
1891        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
1892        assert!(
1893            !out.contains("revs:"),
1894            "must not report a rev-set when only the plain hook ran; got: {out}"
1895        );
1896    }
1897}