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` **stable release** tags of the repo's
96/// dominant tag family (plus `HEAD`); `List(revs)` is an explicit set of
97/// git revspecs used verbatim. The untagged deserialization maps a JSON
98/// integer to `Count` and a JSON array of strings to `List`, so the tool
99/// arg accepts `int | [str]`. Resolution happens at activate time — see
100/// [`Workspace::resolve_revs`].
101#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
102#[serde(untagged)]
103pub enum RevsRequest {
104    /// Last `n` **stable** release tags of the repo's dominant tag family,
105    /// ordered oldest→newest with `HEAD` appended. Tags are classified
106    /// into `(prefix, version, is_prerelease)` and grouped by prefix; the
107    /// family with the most stable tags wins, so on a repo with several
108    /// tag families (e.g. `apache-arrow-*`, `go/v*`, `r-*`) the release
109    /// line is chosen, not an unrelated package family. Prereleases (rc,
110    /// alpha, beta, dev, pre, preview) and non-version tags (e.g.
111    /// `r-universe-release`) are excluded. See [`Workspace::resolve_revs`]
112    /// for the full selection + fallback semantics.
113    Count(usize),
114    /// Explicit git revspecs (tags, branches, or SHAs), used as given.
115    List(Vec<String>),
116}
117
118/// Per-repo inventory entry persisted in `inventory.json`.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120struct InventoryEntry {
121    cloned_at: String,
122    last_accessed: String,
123    #[serde(default)]
124    access_count: u64,
125    #[serde(default)]
126    stale: bool,
127    /// HEAD SHA at the time the post-activate hook last completed
128    /// successfully. Drives auto-rebuild gating: when an `update=True`
129    /// call ends with `action=="current"` AND the new HEAD matches this,
130    /// the post-activate hook can be skipped. `serde(default)` keeps
131    /// older inventory.json files (without this field) loading cleanly.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    last_built_sha: Option<String>,
134    /// The revisions request last **successfully** built for this repo,
135    /// when that build was a multi-rev (`revs=`) activation via the
136    /// revs-aware hook. `None` when the last build was a plain
137    /// (single-rev / HEAD) activation, or the revs hook was absent (the
138    /// plain-hook fallback loads HEAD only, so it records no request).
139    /// Two jobs: (1) the skip gate refuses to skip a plain re-activation
140    /// when the last build was multi-rev (else the tool would report a
141    /// plain activation while the live product is still the rev-set);
142    /// (2) `update=True` with no explicit `revs` re-applies this stored
143    /// request (re-resolving it so `HEAD`/`Count(n)` re-point). Additive:
144    /// `serde(default)` keeps older inventory.json files (without this
145    /// field) loading cleanly.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    last_built_revs: Option<RevsRequest>,
148}
149
150// `WorkspaceKind` is re-used from the manifest module so config and
151// runtime share one enum — the values mean the same thing.
152pub use crate::server::manifest::WorkspaceKind;
153
154/// Workspace runtime state. Shared across MCP request clones via Arc.
155#[derive(Clone)]
156pub struct Workspace {
157    inner: Arc<WorkspaceInner>,
158}
159
160struct WorkspaceInner {
161    kind: WorkspaceKind,
162    workspace_dir: PathBuf,
163    stale_after_days: u32,
164    state: RwLock<WorkspaceState>,
165    post_activate: Option<PostActivateHook>,
166    /// Optional summary hook (see [`ActivationSummaryHook`]). Set via
167    /// [`Workspace::with_activation_summary`], `None` by default.
168    activation_summary: Option<ActivationSummaryHook>,
169    /// Optional revs-aware hook (see [`PostActivateRevsHook`]). Set via
170    /// [`Workspace::with_post_activate_revs`], `None` by default. Called
171    /// in place of `post_activate` only when the activation carried a
172    /// revs request AND this hook is set.
173    post_activate_revs: Option<PostActivateRevsHook>,
174}
175
176#[derive(Debug, Default)]
177struct WorkspaceState {
178    active_repo_name: Option<String>,
179    active_repo_path: Option<PathBuf>,
180    /// The name whose post-activate hook **most recently ran to
181    /// completion in this process** — i.e. the root whose product is
182    /// currently live. Deliberately NOT persisted: `last_built_sha`
183    /// records that the git repo is at the built SHA (a cross-process
184    /// fact), but the hook's *product* — the consumer's in-memory graph
185    /// — lives only for the process lifetime. On a fresh process this is
186    /// `None`, so the first activate re-fires the hook to rehydrate even
187    /// when the persisted SHA already matches HEAD.
188    ///
189    /// Crucially this is a **single** name, not a set of every name ever
190    /// hydrated. Many consumers keep a *single* active-graph slot that
191    /// each activate overwrites, so "hook fired for X at some point" does
192    /// NOT imply "X's product is still live". Only re-binding the
193    /// currently-active root is safe to skip; an A→B→A swap must rebuild
194    /// A because B overwrote the slot. Tracking one name makes the skip
195    /// gate correct for single-slot and per-name consumers alike.
196    active_built_name: Option<String>,
197}
198
199impl Workspace {
200    /// Open a github-flavoured workspace (clone + track flow).
201    pub fn open(
202        workspace_dir: PathBuf,
203        stale_after_days: u32,
204        post_activate: Option<PostActivateHook>,
205    ) -> Result<Self> {
206        if !workspace_dir.is_dir() {
207            fs::create_dir_all(&workspace_dir).with_context(|| {
208                format!("failed to create workspace dir {}", workspace_dir.display())
209            })?;
210        }
211        let repos_dir = workspace_dir.join("repos");
212        if !repos_dir.is_dir() {
213            fs::create_dir_all(&repos_dir)
214                .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
215        }
216        let ws = Self {
217            inner: Arc::new(WorkspaceInner {
218                kind: WorkspaceKind::Github,
219                workspace_dir,
220                stale_after_days,
221                state: RwLock::new(WorkspaceState::default()),
222                post_activate,
223                activation_summary: None,
224                post_activate_revs: None,
225            }),
226        };
227        ws.reconcile_inventory()?;
228        Ok(ws)
229    }
230
231    /// Open a local-directory workspace.
232    ///
233    /// Binds `root` as the active source root immediately and fires the
234    /// post-activate hook (subject to last-built-sha gating). `inventory.json`
235    /// is kept under `<root>/.mcp-workspace/` so the local mode mirrors
236    /// the same gating / fingerprinting infra without polluting the
237    /// user's tree with a `repos/` directory.
238    pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
239        if !root.is_dir() {
240            anyhow::bail!(
241                "local workspace root does not exist or is not a directory: {}",
242                root.display()
243            );
244        }
245        let canon_root = root
246            .canonicalize()
247            .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
248        // Store inventory under a hidden subdir so we don't litter the
249        // user's repo. The "workspace dir" for local mode IS the root.
250        let inv_dir = canon_root.join(".mcp-workspace");
251        if !inv_dir.is_dir() {
252            fs::create_dir_all(&inv_dir).with_context(|| {
253                format!("failed to create local-workspace dir {}", inv_dir.display())
254            })?;
255        }
256        let mut state = WorkspaceState::default();
257        let synthetic_name = synthesize_local_name(&canon_root);
258        state.active_repo_name = Some(synthetic_name);
259        state.active_repo_path = Some(canon_root.clone());
260        Ok(Self {
261            inner: Arc::new(WorkspaceInner {
262                kind: WorkspaceKind::Local,
263                workspace_dir: canon_root,
264                stale_after_days: u32::MAX, // sweeping is github-only
265                state: RwLock::new(state),
266                post_activate,
267                activation_summary: None,
268                post_activate_revs: None,
269            }),
270        })
271    }
272
273    /// Attach an [`ActivationSummaryHook`]. Call immediately after
274    /// `open`/`open_local` (before the workspace is cloned into
275    /// `ServerOptions`): it mutates the still-unique inner `Arc`. Calling
276    /// it after the workspace has been cloned is a no-op with a warning —
277    /// the summary simply won't be attached.
278    pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
279        match Arc::get_mut(&mut self.inner) {
280            Some(inner) => inner.activation_summary = Some(hook),
281            None => tracing::warn!(
282                "with_activation_summary called after the workspace was cloned; summary not attached"
283            ),
284        }
285        self
286    }
287
288    /// Attach a [`PostActivateRevsHook`]. Call immediately after
289    /// `open`/`open_local` (before the workspace is cloned into
290    /// `ServerOptions`): it mutates the still-unique inner `Arc`, exactly
291    /// like [`with_activation_summary`](Self::with_activation_summary).
292    /// Calling it after the workspace has been cloned is a no-op with a
293    /// warning. Additive — consumers that don't set it keep the plain
294    /// single-rev activation behaviour.
295    pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
296        match Arc::get_mut(&mut self.inner) {
297            Some(inner) => inner.post_activate_revs = Some(hook),
298            None => tracing::warn!(
299                "with_post_activate_revs called after the workspace was cloned; revs hook not attached"
300            ),
301        }
302        self
303    }
304
305    pub fn kind(&self) -> WorkspaceKind {
306        self.inner.kind
307    }
308
309    pub fn workspace_dir(&self) -> &Path {
310        &self.inner.workspace_dir
311    }
312
313    pub fn repos_dir(&self) -> PathBuf {
314        self.inner.workspace_dir.join("repos")
315    }
316
317    fn inventory_path(&self) -> PathBuf {
318        match self.inner.kind {
319            WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
320            WorkspaceKind::Local => self
321                .inner
322                .workspace_dir
323                .join(".mcp-workspace")
324                .join("inventory.json"),
325        }
326    }
327
328    /// Active repo's full org/repo name, or None if nothing is active.
329    pub fn active_repo_name(&self) -> Option<String> {
330        self.inner.state.read().unwrap().active_repo_name.clone()
331    }
332
333    /// Active repo's filesystem path, or None.
334    pub fn active_repo_path(&self) -> Option<PathBuf> {
335        self.inner.state.read().unwrap().active_repo_path.clone()
336    }
337
338    /// Default `org/repo` for the GitHub tools when the caller passes none.
339    ///
340    /// Github mode: the active repo — there the inventory key *is* the
341    /// `org/repo`. Local mode: the active root's `origin` remote parsed
342    /// to `org/repo`, or `None` when there's no GitHub remote. Crucially
343    /// it is *never* the `local/<dir>` inventory key (see
344    /// [`active_repo_name`](Self::active_repo_name)), which is a
345    /// filesystem-derived key, not a valid repo slug.
346    pub fn default_github_repo(&self) -> Option<String> {
347        match self.inner.kind {
348            WorkspaceKind::Github => self.active_repo_name(),
349            WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
350        }
351    }
352
353    // ------------------------------------------------------------------
354    // Inventory management
355    // ------------------------------------------------------------------
356
357    fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
358        let path = self.inventory_path();
359        let Ok(text) = fs::read_to_string(&path) else {
360            return BTreeMap::new();
361        };
362        serde_json::from_str(&text).unwrap_or_default()
363    }
364
365    fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
366        let path = self.inventory_path();
367        let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
368        fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
369        Ok(())
370    }
371
372    fn reconcile_inventory(&self) -> Result<()> {
373        let mut inv = self.load_inventory();
374        let mut on_disk: Vec<String> = Vec::new();
375        if self.repos_dir().is_dir() {
376            for org_entry in fs::read_dir(self.repos_dir())? {
377                let Ok(org_entry) = org_entry else { continue };
378                if !org_entry.path().is_dir() {
379                    continue;
380                }
381                let org = org_entry.file_name().to_string_lossy().into_owned();
382                if org.starts_with('.') {
383                    continue;
384                }
385                for repo_entry in fs::read_dir(org_entry.path())? {
386                    let Ok(repo_entry) = repo_entry else { continue };
387                    if !repo_entry.path().is_dir() {
388                        continue;
389                    }
390                    let repo = repo_entry.file_name().to_string_lossy().into_owned();
391                    if repo.starts_with('.') {
392                        continue;
393                    }
394                    let rname = format!("{org}/{repo}");
395                    on_disk.push(rname.clone());
396                    inv.entry(rname).or_insert_with(|| {
397                        let mtime = repo_entry
398                            .metadata()
399                            .ok()
400                            .and_then(|m| m.modified().ok())
401                            .map(format_iso)
402                            .unwrap_or_else(now_iso);
403                        InventoryEntry {
404                            cloned_at: mtime.clone(),
405                            last_accessed: mtime,
406                            access_count: 0,
407                            stale: false,
408                            last_built_sha: None,
409                            last_built_revs: None,
410                        }
411                    });
412                }
413            }
414        }
415        for (rname, entry) in inv.iter_mut() {
416            if !on_disk.contains(rname) && !entry.stale {
417                entry.stale = true;
418            }
419        }
420        self.save_inventory(&inv)?;
421        Ok(())
422    }
423
424    fn bump_access(&self, name: &str, action: &str) {
425        let mut inv = self.load_inventory();
426        let now = now_iso();
427        let entry = inv
428            .entry(name.to_string())
429            .or_insert_with(|| InventoryEntry {
430                cloned_at: now.clone(),
431                last_accessed: now.clone(),
432                access_count: 0,
433                stale: false,
434                last_built_sha: None,
435                last_built_revs: None,
436            });
437        entry.last_accessed = now.clone();
438        entry.access_count += 1;
439        entry.stale = false;
440        if action == "cloned" || entry.cloned_at.is_empty() {
441            entry.cloned_at = now;
442        }
443        let _ = self.save_inventory(&inv);
444    }
445
446    fn mark_stale(&self, name: &str) {
447        let mut inv = self.load_inventory();
448        if let Some(entry) = inv.get_mut(name) {
449            entry.stale = true;
450            let _ = self.save_inventory(&inv);
451        }
452    }
453
454    fn sweep_stale(&self) -> Vec<String> {
455        // Local mode has nothing to sweep — the operator owns the root.
456        if matches!(self.inner.kind, WorkspaceKind::Local) {
457            return Vec::new();
458        }
459        let mut inv = self.load_inventory();
460        let cutoff = SystemTime::now()
461            - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
462        let active = self.active_repo_name();
463        let mut swept: Vec<String> = Vec::new();
464        for (rname, entry) in inv.iter_mut() {
465            if entry.stale {
466                continue;
467            }
468            if Some(rname.as_str()) == active.as_deref() {
469                continue;
470            }
471            let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
472            if last >= cutoff {
473                continue;
474            }
475            let parts: Vec<&str> = rname.splitn(2, '/').collect();
476            if parts.len() != 2 {
477                continue;
478            }
479            let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
480            if repo_path.exists() {
481                let _ = fs::remove_dir_all(&repo_path);
482            }
483            entry.stale = true;
484            swept.push(rname.clone());
485        }
486        if !swept.is_empty() {
487            let _ = self.save_inventory(&inv);
488            self.prune_empty_org_dirs();
489        }
490        swept
491    }
492
493    fn prune_empty_org_dirs(&self) {
494        let Ok(entries) = fs::read_dir(self.repos_dir()) else {
495            return;
496        };
497        for entry in entries.flatten() {
498            let path = entry.path();
499            if !path.is_dir() {
500                continue;
501            }
502            if let Ok(children) = fs::read_dir(&path) {
503                let real: Vec<_> = children
504                    .flatten()
505                    .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
506                    .collect();
507                if real.is_empty() {
508                    let _ = fs::remove_dir_all(&path);
509                }
510            }
511        }
512    }
513
514    // ------------------------------------------------------------------
515    // Git operations
516    // ------------------------------------------------------------------
517
518    /// Clone (if missing) or fast-forward (if cloned). Returns the
519    /// action label, the repo path, and the new HEAD SHA after the op.
520    ///
521    /// Local-mode short-circuits: there's nothing to clone or fetch.
522    /// The "SHA" is a cheap content fingerprint (recursive walk of file
523    /// mtimes + sizes) so the auto-rebuild gate still works.
524    fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
525        if matches!(self.inner.kind, WorkspaceKind::Local) {
526            // Local mode tracks the *currently bound* root, not the
527            // immutable configured `workspace_dir`. `set_root_dir` writes
528            // the target to `active_repo_path` before calling `activate`;
529            // this read picks that up so the fingerprint and the
530            // post-activate hook fire against the new root, and so the
531            // subsequent `active_repo_path` write in `activate` doesn't
532            // clobber the just-set target back to `workspace_dir`. Falls
533            // back to `workspace_dir` only if state is unset, which
534            // shouldn't happen after `open_local` seeds it.
535            let root = self
536                .inner
537                .state
538                .read()
539                .unwrap()
540                .active_repo_path
541                .clone()
542                .unwrap_or_else(|| self.inner.workspace_dir.clone());
543            let prev_sha = self.last_built_sha(name);
544            let fingerprint = fingerprint_dir(&root);
545            let action = match prev_sha {
546                Some(p) if p == fingerprint => "current",
547                None => "cloned", // first activation
548                Some(_) => "updated",
549            };
550            return Ok((action.to_string(), root, fingerprint));
551        }
552        let parts: Vec<&str> = name.splitn(2, '/').collect();
553        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
554        if !repo_path.exists() {
555            fs::create_dir_all(repo_path.parent().unwrap()).ok();
556            let url = format!("https://github.com/{name}.git");
557            // Treeless clone (`--filter=tree:0`): keeps the FULL commit
558            // history — so `git log -S` (pickaxe) and any rev walk work —
559            // while fetching tree/blob objects lazily on demand, keeping
560            // the initial transfer near a shallow clone's cost. `--tags`
561            // pulls all tags up front so tag-scoped rev reads
562            // (`read_source rev=v1.2.3`) resolve without a follow-up fetch.
563            // (Was `--depth 1`, which truncated history and broke pickaxe.)
564            let out = Command::new("git")
565                .args([
566                    "clone",
567                    "--filter=tree:0",
568                    "--tags",
569                    &url,
570                    repo_path.to_str().unwrap(),
571                ])
572                .output()
573                .context("failed to spawn `git clone`")?;
574            if !out.status.success() {
575                anyhow::bail!(
576                    "git clone failed: {}",
577                    String::from_utf8_lossy(&out.stderr).trim()
578                );
579            }
580            let sha = git_rev_parse(&repo_path, "HEAD")?;
581            return Ok(("cloned".to_string(), repo_path, sha));
582        }
583
584        // Fetch + check head delta. Plain `git fetch origin --tags` (no
585        // `--depth 1`) so the treeless clone stays history-complete and
586        // newly-pushed tags become available for rev-scoped reads; blobs
587        // are still fetched lazily. FETCH_HEAD records the remote's
588        // default-branch tip, so the SHA-gate below is unchanged.
589        Command::new("git")
590            .args(["fetch", "origin", "--tags"])
591            .current_dir(&repo_path)
592            .output()
593            .context("git fetch failed")?;
594        let local = git_rev_parse(&repo_path, "HEAD")?;
595        let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
596        if local != remote {
597            Command::new("git")
598                .args(["reset", "--hard", "FETCH_HEAD"])
599                .current_dir(&repo_path)
600                .output()
601                .context("git reset failed")?;
602            let sha = git_rev_parse(&repo_path, "HEAD")?;
603            return Ok(("updated".to_string(), repo_path, sha));
604        }
605        Ok(("current".to_string(), repo_path, local))
606    }
607
608    /// Resolve a [`RevsRequest`] against the git repo at `repo_path` into
609    /// a concrete, ordered list of git revspecs.
610    ///
611    /// - `Count(n)`: the newest `n` **stable release** tags of the repo's
612    ///   dominant tag family, **oldest→newest**, with `HEAD` appended as
613    ///   the final (newest) rev. Selection (see [`select_family_tags`]):
614    ///   every tag is classified into `(prefix, version, is_prerelease)`
615    ///   by stripping a trailing version component; tags with no version
616    ///   component (e.g. `r-universe-release`) are excluded. Tags are
617    ///   grouped by prefix and the family with the most **stable**
618    ///   (non-prerelease) tags is chosen; within it the newest `n` stable
619    ///   tags (version-sorted) are taken. Prerelease markers (rc, alpha,
620    ///   beta, dev, pre, preview — case-insensitive) never count as
621    ///   releases. Fallback chain for degenerate repos: if the winning
622    ///   family has no stable tags its prereleases are used; if no tag is
623    ///   version-like at all, the raw `git tag --sort=-v:refname` top-`n`
624    ///   is used. Errors only if the repo has no tags whatsoever. Fewer
625    ///   than `n` matching tags is not an error — all available are used.
626    /// - `List(revs)`: each revspec is validated with
627    ///   `git rev-parse --verify <rev>^{commit}` and used verbatim (no
628    ///   sort, no `HEAD` appended). Errors on the first unknown rev.
629    ///
630    /// The resolved list is deduplicated order-preserving on the label
631    /// string (first occurrence wins) before being returned — see
632    /// [`dedup_labels`] — so a downstream hook never receives duplicate
633    /// revspecs (e.g. `revs=["HEAD","HEAD"]`). Dedup is on the label, not
634    /// the resolved commit: two *different* revspecs that happen to point
635    /// at the same commit are deliberately both kept, because labels are
636    /// the graph-facing names a multi-rev builder attaches.
637    ///
638    /// A non-git `repo_path` surfaces as the `git tag` / `git rev-parse`
639    /// failure with a clear message.
640    fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
641        let resolved = match req {
642            RevsRequest::Count(n) => {
643                let out = Command::new("git")
644                    .args(["tag", "--sort=-v:refname"])
645                    .current_dir(repo_path)
646                    .output()
647                    .context("failed to spawn `git tag`")?;
648                if !out.status.success() {
649                    anyhow::bail!(
650                        "cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
651                        repo_path.display(),
652                        String::from_utf8_lossy(&out.stderr).trim()
653                    );
654                }
655                let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
656                    .lines()
657                    .map(|l| l.trim().to_string())
658                    .filter(|l| !l.is_empty())
659                    .collect();
660                if tags.is_empty() {
661                    anyhow::bail!(
662                        "revs={n} requested but '{}' has no tags to resolve",
663                        repo_path.display()
664                    );
665                }
666                // Classify + pick the dominant release family's newest `n`
667                // stable tags (oldest→newest). Falls back to the raw
668                // version-sorted top-`n` when no tag is version-like.
669                let mut chosen = select_family_tags(&tags, *n).unwrap_or_else(|| {
670                    let mut raw: Vec<String> = tags.into_iter().take(*n).collect();
671                    raw.reverse();
672                    raw
673                });
674                // HEAD last so a multi-rev builder merges oldest→newest
675                // with HEAD's signature winning.
676                chosen.push("HEAD".to_string());
677                chosen
678            }
679            RevsRequest::List(revs) => {
680                if revs.is_empty() {
681                    anyhow::bail!("revs list is empty — pass at least one revision");
682                }
683                for r in revs {
684                    let out = Command::new("git")
685                        .args([
686                            "rev-parse",
687                            "--verify",
688                            "--quiet",
689                            &format!("{r}^{{commit}}"),
690                        ])
691                        .current_dir(repo_path)
692                        .output()
693                        .context("failed to spawn `git rev-parse`")?;
694                    if !out.status.success() {
695                        anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
696                    }
697                }
698                revs.clone()
699            }
700        };
701        Ok(dedup_labels(resolved))
702    }
703
704    /// Activate a repo: clone if needed, fast-forward, fire post-activate hook.
705    ///
706    /// Auto-rebuild gating: if `force_rebuild` is false AND no `revs` were
707    /// requested AND the repo is already at the HEAD it was last built at
708    /// (`action == "current"` AND `prev_built_sha == new_head`), the
709    /// post-activate hook is skipped. This makes `repo_management(update=True)`
710    /// cheap when upstream hasn't moved. Set `force_rebuild=true` to bypass
711    /// (e.g. after upgrading the builder itself).
712    ///
713    /// When `revs` are requested the skip gate never applies — a
714    /// revs-requested activation always fires the hook (see the gate
715    /// comment below). If the revs-aware hook is set, it is called with
716    /// the resolved revspecs; otherwise the plain hook runs (single-rev
717    /// build) and the resolved list is not reported.
718    ///
719    /// On successful hook completion the new HEAD SHA is persisted to
720    /// `inventory.json[name].last_built_sha`. If the hook fails the SHA
721    /// is NOT recorded, so the next `update=True` re-attempts the build.
722    fn activate(
723        &self,
724        name: &str,
725        force_rebuild: bool,
726        revs: Option<&RevsRequest>,
727    ) -> Result<String> {
728        let prev_built_sha = self.last_built_sha(name);
729        let prev_built_revs = self.last_built_revs(name);
730        let (action, repo_path, head_sha) = self.clone_or_update(name)?;
731        // Resolve any requested revs before mutating active state, so a
732        // bad request (no tags / unknown rev) returns a clean error with
733        // the repo cloned-but-not-activated rather than half-bound.
734        let resolved_revs = match revs {
735            Some(req) => Some(self.resolve_revs(&repo_path, req)?),
736            None => None,
737        };
738        self.bump_access(name, &action);
739        let is_active_built = {
740            let mut state = self.inner.state.write().unwrap();
741            state.active_repo_name = Some(name.to_string());
742            state.active_repo_path = Some(repo_path.clone());
743            state.active_built_name.as_deref() == Some(name)
744        };
745
746        // The skip gate must be satisfied on BOTH axes: the git repo is
747        // at its last-built SHA (persisted, cross-process) AND `name` is
748        // the *currently active* built root in this process (in-memory).
749        // Without the second axis a fresh process would inherit
750        // `last_built_sha` from disk, skip the hook, and leave the
751        // consumer's in-memory state (e.g. the code graph) empty —
752        // activate would report success with nothing loaded. The axis
753        // checks the *active* built name, not any name ever built, so an
754        // A→B→A swap correctly rebuilds A: after activate(B) the live
755        // slot holds B, so re-binding A must not skip (see the
756        // `active_built_name` field doc).
757        // Skip-gate / revs interaction: a revs-requested activation ALWAYS
758        // fires the hook — the SHA-skip gate only applies to the plain
759        // (no-revs) path (`resolved_revs.is_none()`). Rationale: the gate
760        // keys off HEAD's SHA alone, which says nothing about which *set*
761        // of revs a prior build loaded, so a rev-set request at the same
762        // HEAD must rebuild. The tradeoff is that repeat `revs=` calls at
763        // an unchanged HEAD re-parse every rev — acceptable for an
764        // explicit multi-rev request.
765        //
766        // The plain path additionally requires `prev_built_revs.is_none()`
767        // — a plain re-activation must NOT skip when the last build was
768        // multi-rev. Without this the tool would report a plain activation
769        // (and, downstream, the plain hook rebuilding a single-rev graph
770        // was bypassed) while the live product is still the rev-set union.
771        // A non-skip here means the plain hook runs and `record_built`
772        // clears the stored request — resetting to a genuine plain graph.
773        let already_built = !force_rebuild
774            && resolved_revs.is_none()
775            && prev_built_revs.is_none()
776            && action == "current"
777            && prev_built_sha.as_deref() == Some(head_sha.as_str())
778            && is_active_built;
779        let mut hook_skipped = false;
780        // Tracks whether the revs-aware hook actually ran (revs requested
781        // AND that hook set) — only then do we report the resolved list.
782        let mut revs_hook_ran = false;
783        let hook_ok = if already_built {
784            hook_skipped = true;
785            true
786        } else if let (Some(resolved), Some(revs_hook)) =
787            (resolved_revs.as_ref(), &self.inner.post_activate_revs)
788        {
789            revs_hook_ran = true;
790            match revs_hook(&repo_path, name, resolved) {
791                Ok(()) => true,
792                Err(e) => {
793                    tracing::warn!("post-activate revs hook for {name} failed: {e}");
794                    false
795                }
796            }
797        } else if let Some(hook) = &self.inner.post_activate {
798            // No revs requested, or revs requested with no revs-hook set:
799            // fall back to the plain single-rev (HEAD) build.
800            match hook(&repo_path, name) {
801                Ok(()) => true,
802                Err(e) => {
803                    tracing::warn!("post-activate hook for {name} failed: {e}");
804                    false
805                }
806            }
807        } else {
808            // No hook configured — record the SHA so future calls can
809            // see "no work to do" without consulting an empty store.
810            true
811        };
812        if hook_ok {
813            // Record the request that actually built: `Some` only when the
814            // revs hook ran (a plain-hook fallback loads HEAD only, and a
815            // cheap-skip / no-hook path built nothing new), so a plain
816            // build clears any previously-stored request.
817            let built_revs = if revs_hook_ran { revs } else { None };
818            self.record_built(name, &head_sha, built_revs);
819        }
820        // Mark this name the currently-active built root only when the
821        // hook actually ran this process (not on the cheap-skip path,
822        // where it is already the active built name). A no-op "no hook
823        // configured" activation also counts: there is no per-process
824        // product to lose, so a future same-root skip is safe. This
825        // *overwrites* any prior name — modelling that each activate
826        // replaces the live product — so the next swap back rebuilds.
827        if hook_ok && !hook_skipped {
828            self.inner.state.write().unwrap().active_built_name = Some(name.to_string());
829        }
830        let verb = match action.as_str() {
831            "cloned" => "Cloned",
832            "updated" => "Updated",
833            "current" => "Activated (already up to date)",
834            other => other,
835        };
836        let suffix = if hook_skipped {
837            " [build skipped: HEAD matches last-built SHA]"
838        } else {
839            ""
840        };
841        let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
842        // Name the resolved revisions on their own line so agents see
843        // exactly what got loaded. Only when the revs-hook actually ran
844        // (revs requested AND hook set AND it succeeded) — a fallback to
845        // the plain hook loads HEAD only, so claiming a rev-set would lie.
846        if revs_hook_ran && hook_ok {
847            if let Some(resolved) = &resolved_revs {
848                base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
849            }
850        }
851        // Append the consumer's opening-steer mini-map, if configured.
852        // Fired on any successful activation (fresh build or cheap-skip —
853        // in both cases the in-memory product is live this process); the
854        // hook recomputes the summary from that live state. Skipped only
855        // when the build hook itself failed.
856        let summary = if hook_ok {
857            self.inner
858                .activation_summary
859                .as_ref()
860                .and_then(|h| h(&repo_path, name))
861        } else {
862            None
863        };
864        Ok(match summary {
865            Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
866            _ => base,
867        })
868    }
869
870    /// Record the outcome of a successful build: the HEAD SHA plus the
871    /// revisions request that produced it (`Some` only for a multi-rev
872    /// build via the revs hook; `None` for a plain / HEAD-only build,
873    /// which *clears* any previously-stored request). Called only on hook
874    /// success — a failed build records nothing, so the next `update` retries.
875    fn record_built(&self, name: &str, sha: &str, revs: Option<&RevsRequest>) {
876        let mut inv = self.load_inventory();
877        if let Some(entry) = inv.get_mut(name) {
878            entry.last_built_sha = Some(sha.to_string());
879            entry.last_built_revs = revs.cloned();
880            let _ = self.save_inventory(&inv);
881        }
882    }
883
884    /// Read the SHA recorded after the last successful post-activate hook
885    /// for the named repo. `None` if the repo was never built (or the
886    /// hook last failed). Useful for downstream consumers gating
887    /// "is the active graph up to date with the repo HEAD?" checks.
888    pub fn last_built_sha(&self, name: &str) -> Option<String> {
889        self.load_inventory()
890            .get(name)
891            .and_then(|e| e.last_built_sha.clone())
892    }
893
894    /// Read the revisions request last successfully built for the named
895    /// repo — `Some` when the last build was multi-rev (`revs=`), `None`
896    /// for a plain / HEAD-only build or a never-built repo. Drives the
897    /// rev-set-aware skip gate and the `update`-preserves-rev-set path.
898    pub fn last_built_revs(&self, name: &str) -> Option<RevsRequest> {
899        self.load_inventory()
900            .get(name)
901            .and_then(|e| e.last_built_revs.clone())
902    }
903
904    fn delete(&self, name: &str) -> Result<String> {
905        let parts: Vec<&str> = name.splitn(2, '/').collect();
906        if parts.len() != 2 {
907            anyhow::bail!("Invalid repo name");
908        }
909        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
910        let mut deleted = Vec::new();
911        if repo_path.exists() {
912            fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
913            deleted.push("repo");
914        }
915        self.mark_stale(name);
916        self.prune_empty_org_dirs();
917        if deleted.is_empty() {
918            return Ok(format!("Nothing to delete — '{name}' not found."));
919        }
920        let mut state = self.inner.state.write().unwrap();
921        if state.active_repo_name.as_deref() == Some(name) {
922            state.active_repo_name = None;
923            state.active_repo_path = None;
924            return Ok(format!(
925                "Deleted {}. Active repo cleared.",
926                deleted.join(", ")
927            ));
928        }
929        Ok(format!("Deleted {}.", deleted.join(", ")))
930    }
931
932    fn list(&self) -> String {
933        let inv = self.load_inventory();
934        if inv.is_empty() {
935            return "No repos cloned yet. Call repo_management('org/repo') to clone one."
936                .to_string();
937        }
938        let active = self.active_repo_name();
939        let mut live: Vec<String> = Vec::new();
940        let mut stale_lines: Vec<String> = Vec::new();
941        for (rname, entry) in &inv {
942            let marker = if Some(rname.as_str()) == active.as_deref() {
943                " [active]"
944            } else {
945                ""
946            };
947            let access = format!(
948                "{} access{}, last {}",
949                entry.access_count,
950                if entry.access_count == 1 { "" } else { "es" },
951                relative_time(&entry.last_accessed)
952            );
953            if entry.stale {
954                stale_lines.push(format!(
955                    "  {rname}  [STALE — re-fetch with repo_management('{rname}')]  ({access})"
956                ));
957            } else {
958                live.push(format!("  {rname}{marker}  ({access})"));
959            }
960        }
961        let mut out = String::new();
962        if !live.is_empty() {
963            out.push_str(&format!(
964                "{} live repo(s):\n{}",
965                live.len(),
966                live.join("\n")
967            ));
968        }
969        if !stale_lines.is_empty() {
970            if !out.is_empty() {
971                out.push_str("\n\n");
972            }
973            out.push_str(&format!(
974                "{} stale repo(s):\n{}",
975                stale_lines.len(),
976                stale_lines.join("\n")
977            ));
978        }
979        out
980    }
981
982    /// Public entry for the `repo_management` MCP tool.
983    ///
984    /// - `name`: `org/repo` to activate (None = list / refresh mode).
985    /// - `delete`: remove the named repo + inventory entry. Github only.
986    /// - `update`: refresh the active repo (auto-rebuild gated).
987    /// - `force_rebuild`: with `update=true` (or initial activation),
988    ///   re-run the post-activate hook even when the HEAD SHA matches
989    ///   `last_built_sha`. Useful after the builder itself has been
990    ///   upgraded.
991    ///
992    /// Local mode behaviour: `name` and `delete` are rejected; pass
993    /// `update=true` (or no args after the initial activation) to
994    /// re-fingerprint the root and rebuild if anything changed.
995    pub fn repo_management(
996        &self,
997        name: Option<&str>,
998        delete: bool,
999        update: bool,
1000        force_rebuild: bool,
1001        revs: Option<&RevsRequest>,
1002    ) -> String {
1003        // Local mode: most github-only semantics are nonsensical here.
1004        if matches!(self.inner.kind, WorkspaceKind::Local) {
1005            if name.is_some() {
1006                return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
1007                        to switch the active root, or pass `update=true` / `force_rebuild=true` \
1008                        to rebuild against the current root."
1009                    .to_string();
1010            }
1011            if delete {
1012                return "Local-workspace mode does not support `delete`. The root is owned by the \
1013                        operator; remove it manually."
1014                    .to_string();
1015            }
1016            let active = match self.active_repo_name() {
1017                Some(n) => n,
1018                None => return "No active local root.".to_string(),
1019            };
1020            // `update`: re-fingerprint and rebuild if anything changed.
1021            // `force_rebuild`: rebuild even when the fingerprint matches.
1022            // Either flag (or neither — initial bind path) routes through
1023            // `activate`; `activate` itself consults the gate using the
1024            // force flag plus the SHA comparison.
1025            let _ = update; // explicit: update is implicit in local mode
1026                            // A local `repo_management` call is always a refresh of the
1027                            // bound root, so when no explicit `revs` are passed re-apply
1028                            // the stored rev-set (if the last build was multi-rev) — a
1029                            // bare refresh must not silently collapse a rev-set graph to
1030                            // HEAD-only. Re-`set_root_dir` (which passes `revs` verbatim)
1031                            // is the way to reset back to a plain single-rev build.
1032            let effective = match revs {
1033                Some(r) => Some(r.clone()),
1034                None => self.last_built_revs(&active),
1035            };
1036            return self
1037                .activate(&active, force_rebuild, effective.as_ref())
1038                .unwrap_or_else(|e| format!("rebuild failed: {e}"));
1039        }
1040
1041        let swept = self.sweep_stale();
1042        let prefix = if swept.is_empty() {
1043            String::new()
1044        } else {
1045            format!(
1046                "[Swept {} idle repo(s) (>{}d): {}]\n\n",
1047                swept.len(),
1048                self.inner.stale_after_days,
1049                swept.join(", ")
1050            )
1051        };
1052
1053        if name.is_none() && !update {
1054            return prefix + &self.list();
1055        }
1056
1057        if update {
1058            let Some(active) = self.active_repo_name() else {
1059                return prefix + "No active repository. Call repo_management('org/repo') first.";
1060            };
1061            // `update=True` refreshes the active repo. When no explicit
1062            // `revs` are passed, re-apply the stored rev-set (if the last
1063            // build was multi-rev) so a bare `update` after HEAD moves
1064            // re-resolves and rebuilds the SAME rev-set rather than
1065            // collapsing it to a single-rev HEAD build. An explicit `revs`
1066            // argument overrides; a plain re-activation (name path, not
1067            // `update`) still resets to plain.
1068            let effective = match revs {
1069                Some(r) => Some(r.clone()),
1070                None => self.last_built_revs(&active),
1071            };
1072            return prefix
1073                + &self
1074                    .activate(&active, force_rebuild, effective.as_ref())
1075                    .unwrap_or_else(|e| format!("update failed: {e}"));
1076        }
1077
1078        let Some(name) = name else {
1079            return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
1080        };
1081        if let Err(e) = validate_repo_name(name) {
1082            return prefix + &e.to_string();
1083        }
1084        if delete {
1085            return prefix
1086                + &self
1087                    .delete(name)
1088                    .unwrap_or_else(|e| format!("delete failed: {e}"));
1089        }
1090        prefix
1091            + &self
1092                .activate(name, force_rebuild, revs)
1093                .unwrap_or_else(|e| format!("activate failed: {e}"))
1094    }
1095
1096    /// Swap the active root (local mode only). Re-fires the post-activate
1097    /// hook against the new root. Errors if the workspace is github-flavoured.
1098    ///
1099    /// `revs` (optional): resolve revisions against the new root (which
1100    /// must be a git repo) and fire the revs-aware hook — see
1101    /// [`activate`](Self::activate) / [`RevsRequest`].
1102    pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
1103        if !matches!(self.inner.kind, WorkspaceKind::Local) {
1104            return "set_root_dir is only valid in local-workspace mode.".to_string();
1105        }
1106        if !new_root.is_dir() {
1107            return format!(
1108                "Path does not exist or is not a directory: {}",
1109                new_root.display()
1110            );
1111        }
1112        let canon = match new_root.canonicalize() {
1113            Ok(p) => p,
1114            Err(e) => return format!("canonicalize failed: {e}"),
1115        };
1116        let synthetic = synthesize_local_name(&canon);
1117        {
1118            let mut state = self.inner.state.write().unwrap();
1119            state.active_repo_name = Some(synthetic.clone());
1120            state.active_repo_path = Some(canon.clone());
1121        }
1122        // Note: the WorkspaceInner.workspace_dir field is the path the
1123        // inventory is stored under. We keep the *original* one (from
1124        // open_local) so the inventory survives across root swaps.
1125        self.activate(&synthetic, false, revs)
1126            .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
1127    }
1128}
1129
1130/// Deduplicate a resolved revspec list order-preserving, first
1131/// occurrence wins. Dedup is on the **label** string, not the resolved
1132/// commit: a downstream multi-rev builder attaches each label as a
1133/// graph-facing name, so two *different* labels pointing at the same
1134/// commit are deliberately kept — only literal repeats (e.g. a `HEAD`
1135/// that appears twice) collapse.
1136fn dedup_labels(revs: Vec<String>) -> Vec<String> {
1137    let mut seen = std::collections::HashSet::new();
1138    revs.into_iter()
1139        .filter(|r| seen.insert(r.clone()))
1140        .collect()
1141}
1142
1143/// Prerelease markers recognised in a tag's trailing suffix
1144/// (case-insensitive, with an optional `-`/`.`/`_` separator). A tag
1145/// whose version is followed by one of these is never treated as a
1146/// stable release.
1147const PRERELEASE_MARKERS: &[&str] = &["rc", "alpha", "beta", "dev", "pre", "preview"];
1148
1149/// A git tag decomposed into its release family `prefix`, numeric
1150/// `version` components, and whether it carries a prerelease marker.
1151/// Produced by [`classify_tag`]; consumed by [`select_family_tags`].
1152#[derive(Debug, Clone, PartialEq, Eq)]
1153struct ClassifiedTag {
1154    raw: String,
1155    prefix: String,
1156    version: Vec<u64>,
1157    is_prerelease: bool,
1158}
1159
1160/// Classify a single tag into `(prefix, version, is_prerelease)` by
1161/// locating its trailing version component.
1162///
1163/// The version is the *first* `DIGITS(.DIGITS)*` run whose remainder is
1164/// empty or a recognised prerelease suffix; everything before it is the
1165/// family `prefix`. Taking the first *cleanly-parsing* run resolves both
1166/// tricky shapes: `arrow2-0.17.0` skips the `2` in `arrow2` (its
1167/// remainder `-0.17.0` isn't a recognised suffix, so that run is
1168/// rejected) and lands on `0.17.0`; while `v3.0.0-rc1` stops at `3.0.0`
1169/// (with `-rc1` recognised as a prerelease) rather than mistaking the
1170/// trailing `1` of `rc1` for a version. Returns `None` when the tag has
1171/// no version-like component at all (e.g. `r-universe-release`), so such
1172/// tags are excluded from family selection.
1173fn classify_tag(tag: &str) -> Option<ClassifiedTag> {
1174    let bytes = tag.as_bytes();
1175    for i in 0..bytes.len() {
1176        if !bytes[i].is_ascii_digit() {
1177            continue;
1178        }
1179        // Only consider the *start* of a digit run as a version start.
1180        if i > 0 && bytes[i - 1].is_ascii_digit() {
1181            continue;
1182        }
1183        if let Some((version, is_prerelease)) = parse_version_at(&tag[i..]) {
1184            return Some(ClassifiedTag {
1185                raw: tag.to_string(),
1186                prefix: tag[..i].to_string(),
1187                version,
1188                is_prerelease,
1189            });
1190        }
1191    }
1192    None
1193}
1194
1195/// Parse `s` as `DIGITS(.DIGITS)*` optionally followed by a recognised
1196/// prerelease suffix. Returns the numeric components and whether a
1197/// prerelease marker follows. `None` if `s` doesn't start with a digit,
1198/// or carries an *unrecognised* trailing suffix (so the caller rejects
1199/// this candidate start and tries an earlier digit run).
1200fn parse_version_at(s: &str) -> Option<(Vec<u64>, bool)> {
1201    let bytes = s.as_bytes();
1202    let mut nums: Vec<u64> = Vec::new();
1203    let mut idx = 0usize;
1204    loop {
1205        let start = idx;
1206        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
1207            idx += 1;
1208        }
1209        if idx == start {
1210            return None; // expected digits (leading, or after a '.')
1211        }
1212        nums.push(s[start..idx].parse().ok()?);
1213        // Continue only when a '.' is followed by another digit.
1214        if idx + 1 < bytes.len() && bytes[idx] == b'.' && bytes[idx + 1].is_ascii_digit() {
1215            idx += 1;
1216            continue;
1217        }
1218        break;
1219    }
1220    let rest = &s[idx..];
1221    if rest.is_empty() {
1222        return Some((nums, false));
1223    }
1224    // A single optional separator, then a recognised prerelease marker.
1225    let after_sep = rest
1226        .strip_prefix(|c| c == '-' || c == '.' || c == '_')
1227        .unwrap_or(rest);
1228    let lower = after_sep.to_ascii_lowercase();
1229    if PRERELEASE_MARKERS.iter().any(|m| lower.starts_with(m)) {
1230        Some((nums, true))
1231    } else {
1232        None
1233    }
1234}
1235
1236/// From a repo's tag list, choose the dominant **release family** and
1237/// return its newest `n` tags **oldest→newest** (HEAD is appended by the
1238/// caller, not here). Returns `None` when no tag is version-like, so the
1239/// caller can fall back to the raw version-sorted top-`n`.
1240///
1241/// Tags are classified ([`classify_tag`]) and grouped by prefix. The
1242/// family with the most **stable** (non-prerelease) tags wins; if no
1243/// family has any stable tag, the family with the most tags overall wins
1244/// and its prereleases are used. Within the winning family the newest
1245/// `n` tags in the applicable pool (stable if any exist, else
1246/// prerelease) are taken by descending version, then reversed to
1247/// oldest→newest. Deterministic: grouping iterates prefixes in sorted
1248/// order and the version/`raw` sort is total, so a given tag set always
1249/// resolves to the same list. Family ties are broken toward the
1250/// lexicographically-greatest prefix.
1251fn select_family_tags(tags: &[String], n: usize) -> Option<Vec<String>> {
1252    let classified: Vec<ClassifiedTag> = tags.iter().filter_map(|t| classify_tag(t)).collect();
1253    if classified.is_empty() {
1254        return None;
1255    }
1256    // Group by family prefix (BTreeMap → deterministic prefix order).
1257    let mut families: BTreeMap<String, Vec<&ClassifiedTag>> = BTreeMap::new();
1258    for c in &classified {
1259        families.entry(c.prefix.clone()).or_default().push(c);
1260    }
1261    let stable_count = |v: &Vec<&ClassifiedTag>| v.iter().filter(|c| !c.is_prerelease).count();
1262    let any_stable = families.values().any(|v| stable_count(v) > 0);
1263    // Prefer the family with the most stable tags; when nothing is
1264    // stable anywhere, prefer the family with the most tags overall.
1265    // `max_by` returns the last maximum, and BTreeMap yields ascending
1266    // prefixes, so ties resolve to the greatest prefix — deterministic.
1267    let chosen = families.values().max_by(|a, b| {
1268        if any_stable {
1269            stable_count(a).cmp(&stable_count(b))
1270        } else {
1271            a.len().cmp(&b.len())
1272        }
1273    })?;
1274    let mut pool: Vec<&ClassifiedTag> = if any_stable {
1275        chosen
1276            .iter()
1277            .copied()
1278            .filter(|c| !c.is_prerelease)
1279            .collect()
1280    } else {
1281        chosen.to_vec()
1282    };
1283    // Newest first: descending version, then descending raw for a total,
1284    // stable order on identical versions.
1285    pool.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| b.raw.cmp(&a.raw)));
1286    let mut newest: Vec<String> = pool.into_iter().take(n).map(|c| c.raw.clone()).collect();
1287    newest.reverse(); // oldest→newest
1288    Some(newest)
1289}
1290
1291/// Synthesise a stable "repo name" for a local workspace from its path.
1292/// Used as the inventory key so the same gating + persistence code paths
1293/// that github mode uses can apply to local mode unchanged.
1294fn synthesize_local_name(root: &Path) -> String {
1295    let name = root
1296        .file_name()
1297        .map(|s| s.to_string_lossy().into_owned())
1298        .unwrap_or_else(|| "local".to_string());
1299    format!("local/{name}")
1300}
1301
1302/// Parse the `org/repo` slug from a local checkout's `origin` remote.
1303///
1304/// Shells out to `git -C <root> remote get-url origin` and parses both
1305/// canonical GitHub remote forms, stripping the trailing `.git`:
1306///   - `git@github.com:kkollsga/kglite.git`     → `kkollsga/kglite`
1307///   - `https://github.com/kkollsga/kglite.git` → `kkollsga/kglite`
1308///
1309/// Returns `None` for a non-git directory, a missing `origin` remote, or
1310/// a non-GitHub remote — so the GitHub tools fall back to their existing
1311/// empty-default path (ask the caller for `repo_name`).
1312fn parse_origin_repo(root: &Path) -> Option<String> {
1313    let out = Command::new("git")
1314        .arg("-C")
1315        .arg(root)
1316        .args(["remote", "get-url", "origin"])
1317        .output()
1318        .ok()?;
1319    if !out.status.success() {
1320        return None;
1321    }
1322    let url = String::from_utf8(out.stdout).ok()?;
1323    parse_github_remote(url.trim())
1324}
1325
1326/// Pure-string half of [`parse_origin_repo`]: turn a GitHub remote URL
1327/// into `org/repo`, or `None` if it isn't a recognisable GitHub remote.
1328fn parse_github_remote(url: &str) -> Option<String> {
1329    // Accept both SSH (`git@github.com:org/repo`) and HTTPS
1330    // (`https://github.com/org/repo`) forms; everything after the host
1331    // separator is the path.
1332    let path = url
1333        .strip_prefix("git@github.com:")
1334        .or_else(|| url.strip_prefix("https://github.com/"))
1335        .or_else(|| url.strip_prefix("http://github.com/"))
1336        .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
1337    let path = path.strip_suffix(".git").unwrap_or(path);
1338    let path = path.trim_end_matches('/');
1339    // Must be exactly `org/repo` — both segments non-empty, one slash.
1340    let mut parts = path.split('/');
1341    let org = parts.next().filter(|s| !s.is_empty())?;
1342    let repo = parts.next().filter(|s| !s.is_empty())?;
1343    if parts.next().is_some() {
1344        return None;
1345    }
1346    Some(format!("{org}/{repo}"))
1347}
1348
1349/// Cheap recursive content fingerprint of a directory tree. Walks files
1350/// (respecting common ignore patterns) and folds `(path, mtime, len)`
1351/// into a 64-bit hash, then hex-formats it. Good enough to detect
1352/// "did anything change?" for auto-rebuild gating — not cryptographic.
1353fn fingerprint_dir(root: &Path) -> String {
1354    use std::hash::{Hash, Hasher};
1355    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1356    let walker = ignore::WalkBuilder::new(root)
1357        .standard_filters(true)
1358        .hidden(true)
1359        .git_ignore(true)
1360        .build();
1361    for entry in walker.flatten() {
1362        if !entry.path().is_file() {
1363            continue;
1364        }
1365        let Ok(meta) = entry.metadata() else { continue };
1366        let mtime = meta
1367            .modified()
1368            .ok()
1369            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1370            .map(|d| d.as_secs())
1371            .unwrap_or(0);
1372        entry.path().to_string_lossy().hash(&mut hasher);
1373        mtime.hash(&mut hasher);
1374        meta.len().hash(&mut hasher);
1375    }
1376    format!("local-{:016x}", hasher.finish())
1377}
1378
1379fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
1380    let out = Command::new("git")
1381        .args(["rev-parse", refspec])
1382        .current_dir(repo_path)
1383        .output()
1384        .context("git rev-parse failed")?;
1385    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1386}
1387
1388fn now_iso() -> String {
1389    format_iso(SystemTime::now())
1390}
1391
1392fn format_iso(t: SystemTime) -> String {
1393    let secs = t
1394        .duration_since(SystemTime::UNIX_EPOCH)
1395        .map(|d| d.as_secs())
1396        .unwrap_or(0);
1397    // Lightweight RFC3339-ish formatter. Drop sub-second precision; matches Python isoformat(timespec=seconds).
1398    chrono_lite::format_secs(secs)
1399}
1400
1401fn parse_iso(s: &str) -> Option<SystemTime> {
1402    let secs = chrono_lite::parse_secs(s)?;
1403    SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
1404}
1405
1406fn relative_time(iso: &str) -> String {
1407    let Some(t) = parse_iso(iso) else {
1408        return "unknown".to_string();
1409    };
1410    let now = SystemTime::now();
1411    let delta = now.duration_since(t).unwrap_or_default().as_secs();
1412    if delta < 3600 {
1413        "just now".to_string()
1414    } else if delta < 86_400 {
1415        format!("{}h ago", delta / 3600)
1416    } else {
1417        format!("{}d ago", delta / 86_400)
1418    }
1419}
1420
1421/// Tiny self-contained ISO-8601 (seconds-precision) formatter so we
1422/// don't pull in `chrono` for a handful of timestamps.
1423mod chrono_lite {
1424    pub fn format_secs(secs: u64) -> String {
1425        // Civil-from-days algorithm (Howard Hinnant). Output: YYYY-MM-DDTHH:MM:SS.
1426        let days = (secs / 86_400) as i64;
1427        let time = secs % 86_400;
1428        let (y, mo, d) = days_to_civil(days + 719_468);
1429        let h = time / 3600;
1430        let m = (time / 60) % 60;
1431        let s = time % 60;
1432        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
1433    }
1434
1435    pub fn parse_secs(s: &str) -> Option<u64> {
1436        // Accept "YYYY-MM-DDTHH:MM:SS" (no zone) — same shape as format_secs output
1437        // and Python's datetime.isoformat(timespec="seconds").
1438        let bytes = s.as_bytes();
1439        if bytes.len() < 19 {
1440            return None;
1441        }
1442        let y: i64 = s.get(0..4)?.parse().ok()?;
1443        let mo: u32 = s.get(5..7)?.parse().ok()?;
1444        let d: u32 = s.get(8..10)?.parse().ok()?;
1445        let h: u64 = s.get(11..13)?.parse().ok()?;
1446        let m: u64 = s.get(14..16)?.parse().ok()?;
1447        let sc: u64 = s.get(17..19)?.parse().ok()?;
1448        let days = civil_to_days(y, mo, d) - 719_468;
1449        Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
1450    }
1451
1452    fn days_to_civil(z: i64) -> (i64, u32, u32) {
1453        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1454        let doe = (z - era * 146_097) as u64;
1455        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1456        let y = (yoe as i64) + era * 400;
1457        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1458        let mp = (5 * doy + 2) / 153;
1459        let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1460        let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
1461        let y = if m <= 2 { y + 1 } else { y };
1462        (y, m, d)
1463    }
1464
1465    fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
1466        let y = if m <= 2 { y - 1 } else { y };
1467        let era = if y >= 0 { y } else { y - 399 } / 400;
1468        let yoe = (y - era * 400) as u64;
1469        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
1470        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1471        era * 146_097 + doe as i64
1472    }
1473}
1474
1475// silences unused-import-when-helper-only-via-json! macro check.
1476#[allow(dead_code)]
1477fn _json_keepalive() {
1478    let _ = json!({});
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483    use super::*;
1484
1485    #[test]
1486    fn validates_repo_names() {
1487        assert!(validate_repo_name("pydata/xarray").is_ok());
1488        assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1489        assert!(validate_repo_name("xarray").is_err());
1490        assert!(validate_repo_name("a/b/c").is_err());
1491        assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1492    }
1493
1494    #[test]
1495    fn open_creates_layout() {
1496        let dir = tempfile::tempdir().unwrap();
1497        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1498        assert!(ws.repos_dir().is_dir());
1499    }
1500
1501    #[test]
1502    fn empty_list() {
1503        let dir = tempfile::tempdir().unwrap();
1504        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1505        let out = ws.repo_management(None, false, false, false, None);
1506        assert!(out.contains("No repos cloned yet"));
1507    }
1508
1509    #[test]
1510    fn invalid_repo_name_rejected() {
1511        let dir = tempfile::tempdir().unwrap();
1512        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1513        let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
1514        assert!(out.contains("Invalid repo name"));
1515    }
1516
1517    #[test]
1518    fn delete_unknown() {
1519        let dir = tempfile::tempdir().unwrap();
1520        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1521        let out = ws.repo_management(Some("nope/none"), true, false, false, None);
1522        assert!(out.contains("Nothing to delete"));
1523    }
1524
1525    #[test]
1526    fn iso_round_trip() {
1527        let now = SystemTime::now()
1528            .duration_since(SystemTime::UNIX_EPOCH)
1529            .unwrap()
1530            .as_secs();
1531        let s = chrono_lite::format_secs(now);
1532        let back = chrono_lite::parse_secs(&s).unwrap();
1533        assert_eq!(now, back);
1534    }
1535
1536    #[test]
1537    fn last_built_sha_round_trip() {
1538        let dir = tempfile::tempdir().unwrap();
1539        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1540        // Seed an inventory entry directly (clone_or_update needs git).
1541        ws.bump_access("acme/widgets", "cloned");
1542        assert_eq!(ws.last_built_sha("acme/widgets"), None);
1543        ws.record_built("acme/widgets", "abc1234deadbeef", None);
1544        assert_eq!(
1545            ws.last_built_sha("acme/widgets").as_deref(),
1546            Some("abc1234deadbeef")
1547        );
1548        // Survives an Workspace::open re-read (proves persistence).
1549        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1550        assert_eq!(
1551            ws2.last_built_sha("acme/widgets").as_deref(),
1552            Some("abc1234deadbeef")
1553        );
1554    }
1555
1556    #[test]
1557    fn inventory_loads_legacy_entries_without_sha_field() {
1558        let dir = tempfile::tempdir().unwrap();
1559        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1560        // Hand-craft an old-style inventory.json without `last_built_sha`.
1561        let legacy = r#"{
1562            "old/repo": {
1563                "cloned_at": "2024-01-01T00:00:00",
1564                "last_accessed": "2024-01-01T00:00:00",
1565                "access_count": 5,
1566                "stale": false
1567            }
1568        }"#;
1569        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1570        // Re-open and confirm graceful read.
1571        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1572        assert_eq!(ws2.last_built_sha("old/repo"), None);
1573        let _ = ws;
1574    }
1575
1576    #[test]
1577    fn auto_rebuild_gate_skips_when_sha_matches() {
1578        use std::sync::atomic::{AtomicUsize, Ordering};
1579        let dir = tempfile::tempdir().unwrap();
1580        let calls = Arc::new(AtomicUsize::new(0));
1581        let calls_h = calls.clone();
1582        let hook: PostActivateHook = Arc::new(move |_path, _name| {
1583            calls_h.fetch_add(1, Ordering::SeqCst);
1584            Ok(())
1585        });
1586        // Build a workspace pointing at a tempdir with a fake repo dir,
1587        // then simulate consecutive activates. We can't drive clone_or_update
1588        // without git, so test the gating directly by tracking the SHA
1589        // record-then-re-record case via Workspace::record_built +
1590        // last_built_sha — the same predicate `activate` uses.
1591        let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1592        // Seed inventory entry + initial sha record.
1593        ws.bump_access("acme/widgets", "cloned");
1594        ws.record_built("acme/widgets", "sha_one", None);
1595        assert_eq!(
1596            ws.last_built_sha("acme/widgets").as_deref(),
1597            Some("sha_one")
1598        );
1599        // Repeated record with the same value is idempotent (gating
1600        // logic uses last_built_sha as the source of truth).
1601        ws.record_built("acme/widgets", "sha_one", None);
1602        assert_eq!(
1603            ws.last_built_sha("acme/widgets").as_deref(),
1604            Some("sha_one")
1605        );
1606        // No hook calls have been driven directly — this test exercises
1607        // the persistence path that the gate consults.
1608        assert_eq!(calls.load(Ordering::SeqCst), 0);
1609    }
1610
1611    #[test]
1612    fn local_workspace_binds_root_immediately() {
1613        let dir = tempfile::tempdir().unwrap();
1614        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1615        assert_eq!(ws.kind(), WorkspaceKind::Local);
1616        assert!(ws.active_repo_path().is_some());
1617        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1618    }
1619
1620    #[test]
1621    fn local_workspace_rejects_github_ops() {
1622        let dir = tempfile::tempdir().unwrap();
1623        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1624        let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
1625        assert!(out.contains("does not accept a repo name"));
1626        let out = ws.repo_management(None, true, false, false, None);
1627        assert!(out.contains("does not support `delete`"));
1628    }
1629
1630    #[test]
1631    fn local_workspace_update_rebuilds() {
1632        use std::sync::atomic::{AtomicUsize, Ordering};
1633        let dir = tempfile::tempdir().unwrap();
1634        // Drop a file so the fingerprint has something to hash.
1635        std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1636        let calls = Arc::new(AtomicUsize::new(0));
1637        let calls_h = calls.clone();
1638        let hook: PostActivateHook = Arc::new(move |_p, _n| {
1639            calls_h.fetch_add(1, Ordering::SeqCst);
1640            Ok(())
1641        });
1642        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1643        // First update: nothing built yet → hook fires.
1644        let _ = ws.repo_management(None, false, true, false, None);
1645        assert_eq!(calls.load(Ordering::SeqCst), 1);
1646        // Second update without changes → SHA matches → hook skipped.
1647        let out = ws.repo_management(None, false, true, false, None);
1648        assert_eq!(
1649            calls.load(Ordering::SeqCst),
1650            1,
1651            "auto-rebuild gate must skip"
1652        );
1653        assert!(out.contains("build skipped"));
1654    }
1655
1656    #[test]
1657    fn parses_github_remote_forms() {
1658        assert_eq!(
1659            parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1660            Some("kkollsga/kglite")
1661        );
1662        assert_eq!(
1663            parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1664            Some("kkollsga/kglite")
1665        );
1666        // No .git suffix, trailing slash.
1667        assert_eq!(
1668            parse_github_remote("https://github.com/acme/widget/").as_deref(),
1669            Some("acme/widget")
1670        );
1671        assert_eq!(
1672            parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1673            Some("acme/widget")
1674        );
1675        // Non-github / malformed → None.
1676        assert_eq!(
1677            parse_github_remote("https://gitlab.com/acme/widget.git"),
1678            None
1679        );
1680        assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1681        assert_eq!(parse_github_remote("not a url"), None);
1682    }
1683
1684    #[test]
1685    fn local_default_github_repo_uses_origin_remote() {
1686        let dir = tempfile::tempdir().unwrap();
1687        let root = dir.path();
1688        // Stand up a real git repo with a faked origin so default_github_repo
1689        // exercises the actual `git remote get-url` path.
1690        let git = |args: &[&str]| {
1691            Command::new("git")
1692                .arg("-C")
1693                .arg(root)
1694                .args(args)
1695                .output()
1696                .unwrap()
1697        };
1698        if !git(&["init"]).status.success() {
1699            // git unavailable in this environment — skip rather than fail.
1700            return;
1701        }
1702        git(&[
1703            "remote",
1704            "add",
1705            "origin",
1706            "https://github.com/acme/widget.git",
1707        ]);
1708        let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1709        assert_eq!(
1710            ws.default_github_repo().as_deref(),
1711            Some("acme/widget"),
1712            "local default repo must come from the origin remote, not the inventory key"
1713        );
1714        // The inventory key remains the synthetic local name.
1715        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1716    }
1717
1718    #[test]
1719    fn local_default_github_repo_none_without_remote() {
1720        let dir = tempfile::tempdir().unwrap();
1721        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1722        // No git remote → None, and crucially NOT Some("local/<dir>").
1723        let def = ws.default_github_repo();
1724        assert!(
1725            def.is_none(),
1726            "expected None for a non-git local root, got {def:?}"
1727        );
1728    }
1729
1730    #[test]
1731    fn set_root_dir_only_in_local_mode() {
1732        let dir = tempfile::tempdir().unwrap();
1733        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1734        let out = ws.set_root_dir(dir.path(), None);
1735        assert!(out.contains("only valid in local-workspace"));
1736    }
1737
1738    #[test]
1739    fn update_with_no_active_repo() {
1740        let dir = tempfile::tempdir().unwrap();
1741        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1742        let out = ws.repo_management(None, false, true, false, None);
1743        assert!(out.contains("No active repository"));
1744    }
1745
1746    #[test]
1747    fn set_root_dir_updates_active_path() {
1748        let dir = tempfile::tempdir().unwrap();
1749        let child = dir.path().join("child");
1750        std::fs::create_dir_all(&child).unwrap();
1751        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1752        let _ = ws.set_root_dir(&child, None);
1753        assert_eq!(
1754            ws.active_repo_path().unwrap(),
1755            child.canonicalize().unwrap(),
1756            "set_root_dir didn't update active_repo_path"
1757        );
1758    }
1759
1760    #[test]
1761    fn set_root_dir_post_activate_fires_against_new_root() {
1762        let dir = tempfile::tempdir().unwrap();
1763        let child = dir.path().join("child");
1764        std::fs::create_dir_all(&child).unwrap();
1765        std::fs::write(child.join("a.txt"), b"hi").unwrap();
1766        let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1767        let seen = seen_path.clone();
1768        let hook: PostActivateHook = Arc::new(move |p, _n| {
1769            *seen.lock().unwrap() = Some(p.to_path_buf());
1770            Ok(())
1771        });
1772        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1773        let _ = ws.set_root_dir(&child, None);
1774        assert_eq!(
1775            seen_path.lock().unwrap().clone().unwrap(),
1776            child.canonicalize().unwrap(),
1777            "post_activate hook saw the wrong root after set_root_dir"
1778        );
1779    }
1780
1781    #[test]
1782    fn activation_summary_appended_to_activate_message() {
1783        let dir = tempfile::tempdir().unwrap();
1784        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1785        let summary: ActivationSummaryHook =
1786            Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
1787        let ws = Workspace::open_local(dir.path().to_path_buf(), None)
1788            .unwrap()
1789            .with_activation_summary(summary);
1790        let out = ws.repo_management(None, false, true, false, None);
1791        assert!(
1792            out.contains("Graph ready: 3 Functions."),
1793            "activation message should include the summary; got: {out}"
1794        );
1795    }
1796
1797    #[test]
1798    fn activation_summary_absent_when_not_configured() {
1799        let dir = tempfile::tempdir().unwrap();
1800        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1801        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1802        let out = ws.repo_management(None, false, true, false, None);
1803        assert!(!out.contains("Graph ready"));
1804        assert!(
1805            out.contains(" at "),
1806            "expected the terse default message; got: {out}"
1807        );
1808    }
1809
1810    #[test]
1811    fn hook_fires_once_per_process_even_when_sha_matches() {
1812        use std::sync::atomic::{AtomicUsize, Ordering};
1813        // Local mode fingerprints the dir instead of a git SHA, so we can
1814        // drive the real `activate` path without git. A stable file keeps
1815        // the fingerprint constant across both simulated processes.
1816        let dir = tempfile::tempdir().unwrap();
1817        std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1818
1819        let calls = Arc::new(AtomicUsize::new(0));
1820        let make_hook = || -> PostActivateHook {
1821            let c = calls.clone();
1822            Arc::new(move |_p, _n| {
1823                c.fetch_add(1, Ordering::SeqCst);
1824                Ok(())
1825            })
1826        };
1827
1828        // --- Process 1 ---------------------------------------------------
1829        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1830        // First activate (fingerprint not yet recorded) → hook fires.
1831        let _ = ws.repo_management(None, false, true, false, None);
1832        assert_eq!(
1833            calls.load(Ordering::SeqCst),
1834            1,
1835            "first activate must hydrate"
1836        );
1837        // Second activate, same process, unchanged fingerprint → cheap-skip.
1838        let out = ws.repo_management(None, false, true, false, None);
1839        assert_eq!(
1840            calls.load(Ordering::SeqCst),
1841            1,
1842            "repeat activate in same process must skip the hook"
1843        );
1844        assert!(
1845            out.contains("build skipped"),
1846            "expected skip suffix, got: {out}"
1847        );
1848        drop(ws);
1849
1850        // --- Process 2 (restart) ----------------------------------------
1851        // Same dir → inventory.json + last_built_sha persist, but the
1852        // in-memory hydration set does not. The first activate here must
1853        // re-fire the hook to rehydrate the consumer's in-memory state.
1854        let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1855        assert!(
1856            ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1857                .is_some(),
1858            "sanity: last_built_sha should survive the restart"
1859        );
1860        let _ = ws2.repo_management(None, false, true, false, None);
1861        assert_eq!(
1862            calls.load(Ordering::SeqCst),
1863            2,
1864            "fresh process must re-fire the hook even when the SHA matches"
1865        );
1866    }
1867
1868    #[test]
1869    fn a_b_a_swap_rebuilds_intervening_root() {
1870        // Regression for the single-slot-consumer stale-graph bug: an
1871        // A→B→A swap must rebuild A on the second bind, because activating
1872        // B overwrote the consumer's single live slot. Before the fix the
1873        // skip gate keyed off "A was hydrated at some point this process"
1874        // and wrongly skipped, leaving B's product live under A's name.
1875        use std::sync::atomic::{AtomicUsize, Ordering};
1876        let root = tempfile::tempdir().unwrap();
1877        let a = root.path().join("projA");
1878        let b = root.path().join("projB");
1879        std::fs::create_dir_all(&a).unwrap();
1880        std::fs::create_dir_all(&b).unwrap();
1881        // Stable, distinct contents so each root's fingerprint holds
1882        // constant across re-binds (so `action == "current"` on the
1883        // second bind of A — the exact condition the gate keys on).
1884        std::fs::write(a.join("a.txt"), b"alpha").unwrap();
1885        std::fs::write(b.join("b.txt"), b"beta").unwrap();
1886
1887        // The hook records which root it last built into the single slot,
1888        // mirroring a single-active-graph consumer.
1889        let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1890        let built_h = built.clone();
1891        let calls = Arc::new(AtomicUsize::new(0));
1892        let calls_h = calls.clone();
1893        let hook: PostActivateHook = Arc::new(move |p, _n| {
1894            *built_h.lock().unwrap() = Some(p.to_path_buf());
1895            calls_h.fetch_add(1, Ordering::SeqCst);
1896            Ok(())
1897        });
1898
1899        let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
1900        // open_local binds A but doesn't fire the hook; first set_root_dir(A)
1901        // hydrates it.
1902        let _ = ws.set_root_dir(&a, None);
1903        assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
1904        assert_eq!(
1905            built.lock().unwrap().clone(),
1906            Some(a.canonicalize().unwrap())
1907        );
1908
1909        let _ = ws.set_root_dir(&b, None);
1910        assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
1911        assert_eq!(
1912            built.lock().unwrap().clone(),
1913            Some(b.canonicalize().unwrap())
1914        );
1915
1916        // The bug: re-binding A must rebuild (slot currently holds B), not
1917        // cheap-skip. The single slot must end up holding A again.
1918        let out = ws.set_root_dir(&a, None);
1919        assert_eq!(
1920            calls.load(Ordering::SeqCst),
1921            3,
1922            "A→B→A must rebuild A; the intervening B overwrote the live slot"
1923        );
1924        assert!(
1925            !out.contains("build skipped"),
1926            "re-bind of a non-active root must not skip; got: {out}"
1927        );
1928        assert_eq!(
1929            built.lock().unwrap().clone(),
1930            Some(a.canonicalize().unwrap()),
1931            "after A→B→A the live slot must hold A, not B"
1932        );
1933
1934        // And an immediate re-bind of the *currently active* root (A→A)
1935        // still cheap-skips — the win the gate was added for is preserved.
1936        let out = ws.set_root_dir(&a, None);
1937        assert_eq!(
1938            calls.load(Ordering::SeqCst),
1939            3,
1940            "re-binding the already-active root must skip the hook"
1941        );
1942        assert!(
1943            out.contains("build skipped"),
1944            "expected skip suffix, got: {out}"
1945        );
1946    }
1947
1948    // ------------------------------------------------------------------
1949    // revs (multi-revision activation)
1950    // ------------------------------------------------------------------
1951
1952    /// Stand up a real git repo at a fresh tempdir with the given tags
1953    /// created in order (so version-sort ordering is exercised). Returns
1954    /// the tempdir (keep alive) + its path, or `None` if git is
1955    /// unavailable in the environment (test then skips).
1956    fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
1957        let dir = tempfile::tempdir().unwrap();
1958        let root = dir.path().to_path_buf();
1959        let git = |args: &[&str]| {
1960            Command::new("git")
1961                .arg("-C")
1962                .arg(&root)
1963                .args(args)
1964                .output()
1965                .unwrap()
1966        };
1967        if !git(&["init"]).status.success() {
1968            return None; // git unavailable — caller skips.
1969        }
1970        git(&["config", "user.email", "t@example.com"]);
1971        git(&["config", "user.name", "Test"]);
1972        git(&["config", "commit.gpgsign", "false"]);
1973        for (i, tag) in tags.iter().enumerate() {
1974            std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
1975            git(&["add", "-A"]);
1976            assert!(
1977                git(&["commit", "-m", &format!("c{i}")]).status.success(),
1978                "git commit failed"
1979            );
1980            assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
1981        }
1982        Some((dir, root))
1983    }
1984
1985    // ---- tag classification (pure, no git) --------------------------
1986
1987    #[test]
1988    fn classify_tag_extracts_prefix_version_prerelease() {
1989        let c = classify_tag("apache-arrow-25.0.0").unwrap();
1990        assert_eq!(c.prefix, "apache-arrow-");
1991        assert_eq!(c.version, vec![25, 0, 0]);
1992        assert!(!c.is_prerelease);
1993
1994        // Prerelease markers with various separators, case-insensitive.
1995        for t in [
1996            "apache-arrow-25.0.0.dev",
1997            "apache-arrow-25.0.0-rc1",
1998            "apache-arrow-25.0.0-RC0",
1999            "v1.2.3-beta2",
2000            "v1.2.3_alpha",
2001            "v2.0.0-preview",
2002        ] {
2003            assert!(
2004                classify_tag(t).unwrap().is_prerelease,
2005                "{t} should be prerelease"
2006            );
2007        }
2008
2009        // Distinct families keyed on prefix.
2010        assert_eq!(classify_tag("go/v18.0.0").unwrap().prefix, "go/v");
2011        assert_eq!(classify_tag("r-15.0.1").unwrap().prefix, "r-");
2012        assert_eq!(classify_tag("v1.2.3").unwrap().prefix, "v");
2013
2014        // Last digit run wins: the `2` in `arrow2` is not the version.
2015        let c = classify_tag("arrow2-0.17.0").unwrap();
2016        assert_eq!(c.prefix, "arrow2-");
2017        assert_eq!(c.version, vec![0, 17, 0]);
2018    }
2019
2020    #[test]
2021    fn classify_tag_excludes_non_version_tags() {
2022        assert_eq!(classify_tag("r-universe-release"), None);
2023        assert_eq!(classify_tag("latest"), None);
2024        assert_eq!(classify_tag("nightly"), None);
2025        // A version followed by an *unrecognised* suffix is not version-like.
2026        assert_eq!(classify_tag("v1.2.3-foobar"), None);
2027    }
2028
2029    #[test]
2030    fn select_family_tags_picks_dominant_release_family_skipping_prereleases() {
2031        // Mirrors the apache/arrow shape: a large `apache-arrow-*` release
2032        // family (with newest entries being prereleases), plus unrelated
2033        // `r-*` / `go/v*` families and a rolling non-version pointer.
2034        let tags: Vec<String> = [
2035            "apache-arrow-22.0.0",
2036            "apache-arrow-23.0.0",
2037            "apache-arrow-24.0.0",
2038            "apache-arrow-25.0.0-rc0",
2039            "apache-arrow-25.0.0-rc1",
2040            "apache-arrow-25.0.0.dev",
2041            "go/v18.0.0",
2042            "r-15.0.1",
2043            "r-16.1.0",
2044            "r-universe-release",
2045        ]
2046        .iter()
2047        .map(|s| s.to_string())
2048        .collect();
2049        // Newest 2 STABLE of the dominant (apache-arrow-) family,
2050        // oldest→newest; the 25.0.0 prereleases and r-*/go/v* are excluded.
2051        let got = select_family_tags(&tags, 2).unwrap();
2052        assert_eq!(got, vec!["apache-arrow-23.0.0", "apache-arrow-24.0.0"]);
2053    }
2054
2055    #[test]
2056    fn select_family_tags_fewer_stable_than_requested_uses_all_stable() {
2057        let tags: Vec<String> = ["v1.0.0", "v2.0.0", "v3.0.0-rc1"]
2058            .iter()
2059            .map(|s| s.to_string())
2060            .collect();
2061        // Only two stable; the rc is skipped even though it's newest.
2062        let got = select_family_tags(&tags, 5).unwrap();
2063        assert_eq!(got, vec!["v1.0.0", "v2.0.0"]);
2064    }
2065
2066    #[test]
2067    fn select_family_tags_prerelease_only_family_falls_back_to_prereleases() {
2068        let tags: Vec<String> = ["v1.0.0-rc1", "v1.0.0-rc2", "v0.9.0-beta"]
2069            .iter()
2070            .map(|s| s.to_string())
2071            .collect();
2072        // No stable tag anywhere → newest prereleases of the family.
2073        let got = select_family_tags(&tags, 2).unwrap();
2074        assert_eq!(got, vec!["v1.0.0-rc1", "v1.0.0-rc2"]);
2075    }
2076
2077    #[test]
2078    fn select_family_tags_no_version_like_tags_returns_none() {
2079        let tags: Vec<String> = ["latest", "nightly", "stable"]
2080            .iter()
2081            .map(|s| s.to_string())
2082            .collect();
2083        assert_eq!(select_family_tags(&tags, 3), None);
2084    }
2085
2086    // ---- resolve_revs Count (git-gated) -----------------------------
2087
2088    #[test]
2089    fn resolve_revs_count_falls_back_to_raw_when_no_version_tags() {
2090        // Non-version tags → the family selector yields None and
2091        // resolve_revs preserves the raw version-sorted top-n behaviour.
2092        let Some((_d, root)) = git_repo_with_tags(&["latest", "nightly", "stable"]) else {
2093            return;
2094        };
2095        let ws = Workspace::open_local(root.clone(), None).unwrap();
2096        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
2097        // Exactly 2 tags + HEAD, HEAD last; contents come from raw top-n.
2098        assert_eq!(resolved.len(), 3);
2099        assert_eq!(resolved.last().unwrap(), "HEAD");
2100        assert!(resolved[..2].iter().all(|r| r != "HEAD"));
2101    }
2102
2103    #[test]
2104    fn resolve_revs_count_skips_prereleases_of_dominant_family() {
2105        let Some((_d, root)) =
2106            git_repo_with_tags(&["v1.0.0", "v2.0.0", "v3.0.0-rc1", "v3.0.0.dev"])
2107        else {
2108            return;
2109        };
2110        let ws = Workspace::open_local(root.clone(), None).unwrap();
2111        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
2112        // Newest 2 stable (v1.0.0, v2.0.0) oldest→newest, then HEAD —
2113        // the v3 prereleases are excluded.
2114        assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
2115    }
2116
2117    #[test]
2118    fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
2119        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
2120            return;
2121        };
2122        let ws = Workspace::open_local(root.clone(), None).unwrap();
2123        let resolved = ws
2124            .resolve_revs(&root, &RevsRequest::Count(2))
2125            .expect("resolve should succeed");
2126        // Newest 2 = v2.0.0, v1.1.0 → oldest→newest → v1.1.0, v2.0.0, then HEAD.
2127        assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
2128    }
2129
2130    #[test]
2131    fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
2132        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2133            return;
2134        };
2135        let ws = Workspace::open_local(root.clone(), None).unwrap();
2136        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
2137        assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
2138    }
2139
2140    #[test]
2141    fn resolve_revs_count_errors_when_no_tags() {
2142        let Some((_d, root)) = git_repo_with_tags(&[]) else {
2143            return;
2144        };
2145        // Empty repo has no commits yet; make one commit but no tags.
2146        let git = |args: &[&str]| {
2147            Command::new("git")
2148                .arg("-C")
2149                .arg(&root)
2150                .args(args)
2151                .output()
2152                .unwrap()
2153        };
2154        std::fs::write(root.join("f.txt"), b"x").unwrap();
2155        git(&["add", "-A"]);
2156        git(&["commit", "-m", "c0"]);
2157        let ws = Workspace::open_local(root.clone(), None).unwrap();
2158        let err = ws
2159            .resolve_revs(&root, &RevsRequest::Count(3))
2160            .expect_err("no tags → error");
2161        assert!(
2162            err.to_string().contains("no tags"),
2163            "expected a 'no tags' error, got: {err}"
2164        );
2165    }
2166
2167    // ---- dedup of resolved revs -------------------------------------
2168
2169    #[test]
2170    fn dedup_labels_is_order_preserving_first_wins() {
2171        assert_eq!(
2172            dedup_labels(vec!["HEAD".into(), "HEAD".into()]),
2173            vec!["HEAD"]
2174        );
2175        assert_eq!(
2176            dedup_labels(vec![
2177                "v1".into(),
2178                "v2".into(),
2179                "v1".into(),
2180                "v3".into(),
2181                "v2".into(),
2182            ]),
2183            vec!["v1", "v2", "v3"]
2184        );
2185        // Empty and already-unique lists pass through untouched.
2186        assert_eq!(dedup_labels(vec![]), Vec::<String>::new());
2187        assert_eq!(dedup_labels(vec!["a".into(), "b".into()]), vec!["a", "b"]);
2188    }
2189
2190    #[test]
2191    fn resolve_revs_list_dedups_duplicate_revspecs() {
2192        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
2193            return;
2194        };
2195        let ws = Workspace::open_local(root.clone(), None).unwrap();
2196        // `["HEAD","HEAD"]` collapses to a single `HEAD`.
2197        let got = ws
2198            .resolve_revs(
2199                &root,
2200                &RevsRequest::List(vec!["HEAD".into(), "HEAD".into()]),
2201            )
2202            .unwrap();
2203        assert_eq!(got, vec!["HEAD"]);
2204        // First-occurrence order is preserved across mixed duplicates.
2205        let got = ws
2206            .resolve_revs(
2207                &root,
2208                &RevsRequest::List(vec!["v1.0.0".into(), "HEAD".into(), "v1.0.0".into()]),
2209            )
2210            .unwrap();
2211        assert_eq!(got, vec!["v1.0.0", "HEAD"]);
2212    }
2213
2214    #[test]
2215    fn resolve_revs_list_validates_and_rejects_unknown() {
2216        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
2217            return;
2218        };
2219        let ws = Workspace::open_local(root.clone(), None).unwrap();
2220        // Explicit list is used verbatim (no HEAD appended, no sort).
2221        let ok = ws
2222            .resolve_revs(
2223                &root,
2224                &RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
2225            )
2226            .unwrap();
2227        assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
2228        // An unknown rev is a clear error naming the bad rev.
2229        let err = ws
2230            .resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
2231            .expect_err("unknown rev → error");
2232        assert!(
2233            err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
2234            "expected an unknown-rev error, got: {err}"
2235        );
2236    }
2237
2238    #[test]
2239    fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
2240        use std::sync::atomic::{AtomicUsize, Ordering};
2241        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
2242            return;
2243        };
2244        let plain_calls = Arc::new(AtomicUsize::new(0));
2245        let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
2246        let pc = plain_calls.clone();
2247        let plain: PostActivateHook = Arc::new(move |_p, _n| {
2248            pc.fetch_add(1, Ordering::SeqCst);
2249            Ok(())
2250        });
2251        let sr = seen_revs.clone();
2252        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
2253            *sr.lock().unwrap() = Some(revs.to_vec());
2254            Ok(())
2255        });
2256        let ws = Workspace::open_local(root.clone(), Some(plain))
2257            .unwrap()
2258            .with_post_activate_revs(revs_hook);
2259        let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
2260        // The revs-hook ran with the resolved list; the plain hook did NOT.
2261        assert_eq!(
2262            seen_revs.lock().unwrap().clone().unwrap(),
2263            vec!["v1.1.0", "v2.0.0", "HEAD"]
2264        );
2265        assert_eq!(
2266            plain_calls.load(Ordering::SeqCst),
2267            0,
2268            "plain hook must not fire when the revs-hook handled the request"
2269        );
2270        // The activation message names the resolved revs on one line.
2271        assert!(
2272            out.contains("revs: v1.1.0, v2.0.0, HEAD"),
2273            "activation message should list the resolved revs; got: {out}"
2274        );
2275    }
2276
2277    #[test]
2278    fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
2279        use std::sync::atomic::{AtomicUsize, Ordering};
2280        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
2281            return;
2282        };
2283        let plain_calls = Arc::new(AtomicUsize::new(0));
2284        let revs_seen = Arc::new(AtomicUsize::new(0));
2285        let pc = plain_calls.clone();
2286        let plain: PostActivateHook = Arc::new(move |_p, _n| {
2287            pc.fetch_add(1, Ordering::SeqCst);
2288            Ok(())
2289        });
2290        let rs = revs_seen.clone();
2291        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
2292            rs.fetch_add(1, Ordering::SeqCst);
2293            Ok(())
2294        });
2295        let ws = Workspace::open_local(root.clone(), Some(plain))
2296            .unwrap()
2297            .with_post_activate_revs(revs_hook);
2298        // No revs → plain hook fires, revs-hook untouched, no `revs:` line.
2299        let out = ws.repo_management(None, false, true, false, None);
2300        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2301        assert_eq!(
2302            revs_seen.load(Ordering::SeqCst),
2303            0,
2304            "revs-hook must not fire when no revs were requested"
2305        );
2306        assert!(
2307            !out.contains("revs:"),
2308            "no revs line expected on a plain activation; got: {out}"
2309        );
2310    }
2311
2312    #[test]
2313    fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
2314        use std::sync::atomic::{AtomicUsize, Ordering};
2315        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2316            return;
2317        };
2318        let plain_calls = Arc::new(AtomicUsize::new(0));
2319        let pc = plain_calls.clone();
2320        let plain: PostActivateHook = Arc::new(move |_p, _n| {
2321            pc.fetch_add(1, Ordering::SeqCst);
2322            Ok(())
2323        });
2324        // No revs-hook attached: a revs request degrades to the plain
2325        // (HEAD-only) build and does NOT claim a rev-set in the message.
2326        let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
2327        let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
2328        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2329        assert!(
2330            !out.contains("revs:"),
2331            "must not report a rev-set when only the plain hook ran; got: {out}"
2332        );
2333    }
2334
2335    // ---- rev-set-aware skip gate + stored-request persistence -------
2336
2337    /// Build a local workspace over a git repo with tags, wired with both
2338    /// a plain and a revs hook, each incrementing a shared counter.
2339    /// Returns (workspace, tempdir-guard, root, plain_calls, revs_calls).
2340    #[allow(clippy::type_complexity)]
2341    fn ws_with_both_hooks(
2342        tags: &[&str],
2343    ) -> Option<(
2344        Workspace,
2345        tempfile::TempDir,
2346        PathBuf,
2347        Arc<std::sync::atomic::AtomicUsize>,
2348        Arc<std::sync::atomic::AtomicUsize>,
2349    )> {
2350        use std::sync::atomic::{AtomicUsize, Ordering};
2351        let (d, root) = git_repo_with_tags(tags)?;
2352        let plain_calls = Arc::new(AtomicUsize::new(0));
2353        let revs_calls = Arc::new(AtomicUsize::new(0));
2354        let pc = plain_calls.clone();
2355        let plain: PostActivateHook = Arc::new(move |_p, _n| {
2356            pc.fetch_add(1, Ordering::SeqCst);
2357            Ok(())
2358        });
2359        let rc = revs_calls.clone();
2360        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _r| {
2361            rc.fetch_add(1, Ordering::SeqCst);
2362            Ok(())
2363        });
2364        let ws = Workspace::open_local(root.clone(), Some(plain))
2365            .unwrap()
2366            .with_post_activate_revs(revs_hook);
2367        Some((ws, d, root, plain_calls, revs_calls))
2368    }
2369
2370    #[test]
2371    fn plain_activation_after_revs_build_rebuilds_plain() {
2372        use std::sync::atomic::Ordering;
2373        let Some((ws, _d, root, plain_calls, revs_calls)) =
2374            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2375        else {
2376            return;
2377        };
2378        // Multi-rev build first.
2379        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2380        assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
2381        assert_eq!(plain_calls.load(Ordering::SeqCst), 0);
2382        // A plain re-bind at the SAME (unchanged) root must NOT cheap-skip
2383        // just because HEAD matches — the last build was multi-rev. It
2384        // rebuilds plain, and the message claims no rev-set.
2385        let out = ws.set_root_dir(&root, None);
2386        assert_eq!(
2387            plain_calls.load(Ordering::SeqCst),
2388            1,
2389            "plain re-activation after a revs build must fire the plain hook"
2390        );
2391        assert!(
2392            !out.contains("build skipped"),
2393            "must not skip a plain re-activation after a revs build; got: {out}"
2394        );
2395        assert!(
2396            !out.contains("revs:"),
2397            "plain rebuild must not claim revs; got: {out}"
2398        );
2399        // The stored request is cleared, so a further plain re-bind now
2400        // cheap-skips (proves the reset took).
2401        let out = ws.set_root_dir(&root, None);
2402        assert_eq!(
2403            plain_calls.load(Ordering::SeqCst),
2404            1,
2405            "second plain re-bind skips"
2406        );
2407        assert!(
2408            out.contains("build skipped"),
2409            "expected skip suffix; got: {out}"
2410        );
2411    }
2412
2413    #[test]
2414    fn update_after_revs_build_reapplies_stored_revs() {
2415        use std::sync::atomic::Ordering;
2416        let Some((ws, _d, root, plain_calls, revs_calls)) =
2417            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2418        else {
2419            return;
2420        };
2421        // Multi-rev build first.
2422        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2423        assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
2424        // A bare `update` (no revs) must re-apply the stored rev-set —
2425        // re-firing the revs hook, not collapsing to a plain HEAD build.
2426        let out = ws.repo_management(None, false, true, false, None);
2427        assert_eq!(
2428            revs_calls.load(Ordering::SeqCst),
2429            2,
2430            "bare update must re-apply the stored rev-set"
2431        );
2432        assert_eq!(
2433            plain_calls.load(Ordering::SeqCst),
2434            0,
2435            "bare update after a revs build must not fall to the plain hook"
2436        );
2437        assert!(
2438            out.contains("revs:"),
2439            "re-applied update should list the revs; got: {out}"
2440        );
2441    }
2442
2443    #[test]
2444    fn revs_activation_after_plain_build_always_rebuilds() {
2445        use std::sync::atomic::Ordering;
2446        let Some((ws, _d, root, plain_calls, revs_calls)) =
2447            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2448        else {
2449            return;
2450        };
2451        // Plain build first.
2452        let _ = ws.set_root_dir(&root, None);
2453        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2454        assert_eq!(revs_calls.load(Ordering::SeqCst), 0);
2455        // A revs request at the unchanged HEAD still always fires the revs
2456        // hook (revs requests are never skipped by the SHA gate).
2457        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2458        assert_eq!(
2459            revs_calls.load(Ordering::SeqCst),
2460            1,
2461            "a revs request must always rebuild, even at an unchanged HEAD"
2462        );
2463    }
2464
2465    #[test]
2466    fn last_built_revs_round_trips_and_clears_on_plain_build() {
2467        let dir = tempfile::tempdir().unwrap();
2468        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2469        ws.bump_access("acme/widgets", "cloned");
2470        assert_eq!(ws.last_built_revs("acme/widgets"), None);
2471        // Record a multi-rev build.
2472        ws.record_built("acme/widgets", "sha1", Some(&RevsRequest::Count(3)));
2473        assert_eq!(
2474            ws.last_built_revs("acme/widgets"),
2475            Some(RevsRequest::Count(3))
2476        );
2477        // Survives a reopen (persisted to inventory.json).
2478        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2479        assert_eq!(
2480            ws2.last_built_revs("acme/widgets"),
2481            Some(RevsRequest::Count(3))
2482        );
2483        // A subsequent plain build clears the stored request.
2484        ws2.record_built("acme/widgets", "sha2", None);
2485        assert_eq!(ws2.last_built_revs("acme/widgets"), None);
2486        // A List request round-trips too.
2487        ws2.record_built(
2488            "acme/widgets",
2489            "sha3",
2490            Some(&RevsRequest::List(vec!["v1".into(), "v2".into()])),
2491        );
2492        assert_eq!(
2493            ws2.last_built_revs("acme/widgets"),
2494            Some(RevsRequest::List(vec!["v1".into(), "v2".into()]))
2495        );
2496    }
2497
2498    #[test]
2499    fn inventory_loads_legacy_entries_without_revs_field() {
2500        // An entry carrying last_built_sha but no last_built_revs (an
2501        // inventory written before the field existed) loads cleanly with
2502        // the request defaulting to None.
2503        let dir = tempfile::tempdir().unwrap();
2504        let legacy = r#"{
2505            "old/repo": {
2506                "cloned_at": "2024-01-01T00:00:00",
2507                "last_accessed": "2024-01-01T00:00:00",
2508                "access_count": 5,
2509                "stale": false,
2510                "last_built_sha": "deadbeef"
2511            }
2512        }"#;
2513        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
2514        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2515        assert_eq!(ws.last_built_sha("old/repo").as_deref(), Some("deadbeef"));
2516        assert_eq!(ws.last_built_revs("old/repo"), None);
2517    }
2518}