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 share one activation state machine. Existing consumers may use
21//! the serialized [`PostActivateHook`] callback family; concurrency-aware
22//! consumers use [`ActivationTransactionHook`] to prepare off-lock and publish
23//! only while their request generation is current. Both honour the same
24//! `last_built_sha` gating to skip pointless rebuilds.
25
26#![allow(dead_code)]
27
28use std::collections::BTreeMap;
29use std::fmt;
30use std::fs;
31use std::path::{Path, PathBuf};
32use std::process::Command;
33use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard};
34use std::time::SystemTime;
35
36use anyhow::{anyhow, Context, Result};
37use serde::{Deserialize, Serialize};
38use serde_json::json;
39
40/// Repo name format: ``org/repo``. Letters, digits, dots, hyphens, underscores.
41fn validate_repo_name(name: &str) -> Result<()> {
42    let mut parts = name.split('/');
43    let org = parts.next().unwrap_or("");
44    let repo = parts.next().unwrap_or("");
45    if parts.next().is_some() || org.is_empty() || repo.is_empty() {
46        return Err(anyhow!(
47            "Invalid repo name {name:?}. Expected 'org/repo' (exactly one slash)."
48        ));
49    }
50    let valid = |s: &str| {
51        !s.is_empty()
52            && s.chars()
53                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
54    };
55    if !valid(org) || !valid(repo) {
56        return Err(anyhow!(
57            "Invalid repo name {name:?}. Letters/digits/dots/hyphens/underscores only."
58        ));
59    }
60    Ok(())
61}
62
63/// Hook fired after a successful clone or update. Receives the absolute
64/// path to the cloned repo and the org/repo name. Legacy callback activations
65/// are serialized through summary generation. Errors abort publication of the
66/// framework's new active source state; use [`ActivationTransactionHook`] when
67/// downstream product installation must also be deferred until commit.
68pub type PostActivateHook = Arc<dyn Fn(&Path, &str) -> Result<()> + Send + Sync>;
69
70/// Optional hook that returns a short agent-facing summary appended to
71/// the activation result message — the "graph ready" mini-map / opening
72/// steer (e.g. `"Graph ready: 9,999 Functions · 656 Classes · 31k CALLS.
73/// Open with graph_overview() → cypher_query; grep = literal text only."`).
74///
75/// Kept separate from [`PostActivateHook`] so adding it is a non-breaking
76/// addition — existing consumers that register only the build hook are
77/// unaffected. Receives the repo path + name; returns `Some(text)` to
78/// append (blank-line separated), or `None` for the terse default
79/// message. Called after a successful activation (skipped when the build
80/// hook failed).
81pub type ActivationSummaryHook = Arc<dyn Fn(&Path, &str) -> Option<String> + Send + Sync>;
82
83/// Hook fired after a successful clone/update **when revisions were
84/// requested** on the activation call (`repo_management(revs=…)` /
85/// `set_root_dir(revs=…)`). Receives the repo path, the `org/repo` (or
86/// synthetic local) name, and the resolved revspecs in **oldest→newest**
87/// order — for a `Count(n)` request the final entry is always `HEAD`, so
88/// a downstream multi-rev builder can merge oldest→newest with HEAD's
89/// signature winning. Set via [`Workspace::with_post_activate_revs`].
90///
91/// Additive by design (mirrors [`ActivationSummaryHook`]): existing
92/// consumers that register only the plain [`PostActivateHook`] are
93/// unaffected. When revs are requested but this hook is *not* set, the
94/// plain hook runs instead (a single-rev / HEAD build) and the resolved
95/// list is not reported in the activation message.
96pub type PostActivateRevsHook = Arc<dyn Fn(&Path, &str, &[String]) -> Result<()> + Send + Sync>;
97
98/// Monotonically increasing identity for one workspace activation request.
99///
100/// Identities are allocated before an activation mutates active source state.
101/// A higher identity is therefore newer intent; a prepared activation may
102/// commit only while its identity is still the latest requested one.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
104pub struct ActivationId(u64);
105
106impl ActivationId {
107    /// The process-local monotonically increasing integer value.
108    pub fn get(self) -> u64 {
109        self.0
110    }
111}
112
113impl fmt::Display for ActivationId {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        self.0.fmt(f)
116    }
117}
118
119/// Work required for a request-scoped activation.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ActivationBuild {
122    /// Build the root at its current working-tree / HEAD state.
123    Plain,
124    /// Build the already-resolved revisions in oldest-to-newest order.
125    Revisions(Vec<String>),
126    /// Reuse the product already live for this root; only refresh its summary.
127    Reuse,
128}
129
130/// Immutable input to an [`ActivationTransactionHook`].
131///
132/// The hook may perform expensive preparation before returning. It must not
133/// publish the prepared product itself; publication belongs in the
134/// [`PreparedActivation`] closure so the framework can discard stale work.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ActivationRequest {
137    id: ActivationId,
138    path: PathBuf,
139    name: String,
140    build: ActivationBuild,
141}
142
143impl ActivationRequest {
144    pub fn id(&self) -> ActivationId {
145        self.id
146    }
147
148    pub fn path(&self) -> &Path {
149        &self.path
150    }
151
152    pub fn name(&self) -> &str {
153        &self.name
154    }
155
156    pub fn build(&self) -> &ActivationBuild {
157        &self.build
158    }
159}
160
161/// Prepared downstream activation that has not yet been published.
162///
163/// The closure should atomically install the prepared product and return the
164/// summary describing that exact product. The framework runs it only when the
165/// request is still current, under the same generation boundary used to
166/// publish active source and built identity. Dropping this value must be safe:
167/// stale requests are superseded by dropping their prepared activation. The
168/// closure runs while workspace activation state is write-locked, so it must
169/// not call back into [`Workspace`] accessors; keep it to the downstream slot
170/// swap and request-scoped summary generation.
171pub struct PreparedActivation {
172    commit: Box<dyn FnOnce() -> Result<Option<String>> + Send + 'static>,
173}
174
175impl PreparedActivation {
176    pub fn new<F>(commit: F) -> Self
177    where
178        F: FnOnce() -> Result<Option<String>> + Send + 'static,
179    {
180        Self {
181            commit: Box::new(commit),
182        }
183    }
184
185    /// A prepared activation with no publication side effect.
186    pub fn summary(summary: Option<String>) -> Self {
187        Self::new(move || Ok(summary))
188    }
189
190    fn commit(self) -> Result<Option<String>> {
191        (self.commit)()
192    }
193}
194
195/// Request-scoped activation transaction.
196///
197/// Preparation runs concurrently and off-lock. The returned
198/// [`PreparedActivation`] is committed only if this request remains the latest
199/// intent; otherwise it is dropped and the caller receives a superseded
200/// outcome. This single callback replaces the legacy plain/revisions/summary
201/// trio for consumers that need coherent concurrent activation.
202pub type ActivationTransactionHook =
203    Arc<dyn Fn(&ActivationRequest) -> Result<PreparedActivation> + Send + Sync>;
204
205/// A revisions request carried by the activation tools. `Count(n)`
206/// resolves to the newest `n` **stable release** tags of the repo's
207/// dominant tag family (plus `HEAD`); `List(revs)` is an explicit set of
208/// git revspecs used verbatim. The untagged deserialization maps a JSON
209/// integer to `Count` and a JSON array of strings to `List`, so the tool
210/// arg accepts `int | [str]`. Resolution happens at activate time — see
211/// [`Workspace::resolve_revs`].
212#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
213#[serde(untagged)]
214pub enum RevsRequest {
215    /// Last `n` **stable** release tags of the repo's dominant tag family,
216    /// ordered oldest→newest with `HEAD` appended. Tags are classified
217    /// into `(prefix, version, is_prerelease)` and grouped by prefix; the
218    /// family with the most stable tags wins, so on a repo with several
219    /// tag families (e.g. `apache-arrow-*`, `go/v*`, `r-*`) the release
220    /// line is chosen, not an unrelated package family. Prereleases (rc,
221    /// alpha, beta, dev, pre, preview) and non-version tags (e.g.
222    /// `r-universe-release`) are excluded. See [`Workspace::resolve_revs`]
223    /// for the full selection + fallback semantics.
224    Count(usize),
225    /// Explicit git revspecs (tags, branches, or SHAs), used as given.
226    List(Vec<String>),
227}
228
229/// Per-repo inventory entry persisted in `inventory.json`.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231struct InventoryEntry {
232    cloned_at: String,
233    last_accessed: String,
234    #[serde(default)]
235    access_count: u64,
236    #[serde(default)]
237    stale: bool,
238    /// HEAD SHA at the time the post-activate hook last completed
239    /// successfully. Drives auto-rebuild gating: when an `update=True`
240    /// call ends with `action=="current"` AND the new HEAD matches this,
241    /// the post-activate hook can be skipped. `serde(default)` keeps
242    /// older inventory.json files (without this field) loading cleanly.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    last_built_sha: Option<String>,
245    /// The revisions request last **successfully** built for this repo,
246    /// when that build was a multi-rev (`revs=`) activation via the
247    /// revs-aware hook. `None` when the last build was a plain
248    /// (single-rev / HEAD) activation, or the revs hook was absent (the
249    /// plain-hook fallback loads HEAD only, so it records no request).
250    /// Two jobs: (1) the skip gate refuses to skip a plain re-activation
251    /// when the last build was multi-rev (else the tool would report a
252    /// plain activation while the live product is still the rev-set);
253    /// (2) `update=True` with no explicit `revs` re-applies this stored
254    /// request (re-resolving it so `HEAD`/`Count(n)` re-point). Additive:
255    /// `serde(default)` keeps older inventory.json files (without this
256    /// field) loading cleanly.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    last_built_revs: Option<RevsRequest>,
259}
260
261// `WorkspaceKind` is re-used from the manifest module so config and
262// runtime share one enum — the values mean the same thing.
263pub use crate::server::manifest::WorkspaceKind;
264
265/// Who chose the currently active root.
266///
267/// The flag exists so a root proposed by an *external party* (an MCP
268/// client advertising `roots`, see [`Workspace::adopt_client_root`])
269/// can never silently displace one the operator chose. It is a
270/// precedence marker, not an access-control mechanism — containment is
271/// [`Workspace::with_sandbox_root`]'s job.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum RootOwnership {
274    /// No root is bound and nobody has claimed one. Only an unanchored
275    /// local boot ([`Workspace::open_local_unanchored`]) starts here.
276    Unowned,
277    /// The active root came from a client-advertised MCP root. A later
278    /// `roots/list_changed` may replace it.
279    Adopted,
280    /// The active root came from the operator — manifest `workspace.root`,
281    /// a CLI flag, or an explicit `set_root_dir` call. **Permanent**: no
282    /// client advertisement ever overrides it.
283    Operator,
284}
285
286/// Workspace runtime state. Shared across MCP request clones via Arc.
287#[derive(Clone)]
288pub struct Workspace {
289    inner: Arc<WorkspaceInner>,
290}
291
292struct WorkspaceInner {
293    kind: WorkspaceKind,
294    workspace_dir: PathBuf,
295    stale_after_days: u32,
296    state: RwLock<WorkspaceState>,
297    /// Serializes inventory read-modify-write cycles across concurrent
298    /// activation preparations so per-repo SHA/revision receipts are not
299    /// lost when two requests finish close together.
300    inventory: Mutex<()>,
301    /// Serializes the legacy callback trio, whose signatures cannot carry a
302    /// request id or defer publication. Transaction-hook activations do not
303    /// take this lock: they prepare concurrently and commit by generation.
304    legacy_activation: Mutex<()>,
305    post_activate: Option<PostActivateHook>,
306    /// Optional summary hook (see [`ActivationSummaryHook`]). Set via
307    /// [`Workspace::with_activation_summary`], `None` by default.
308    activation_summary: Option<ActivationSummaryHook>,
309    /// Optional revs-aware hook (see [`PostActivateRevsHook`]). Set via
310    /// [`Workspace::with_post_activate_revs`], `None` by default. Called
311    /// in place of `post_activate` only when the activation carried a
312    /// revs request AND this hook is set.
313    post_activate_revs: Option<PostActivateRevsHook>,
314    /// Request-scoped prepare/commit contract. When configured it replaces
315    /// the legacy callback trio for activation work and summary generation.
316    activation_transaction: Option<ActivationTransactionHook>,
317    /// Optional outer containment boundary for runtime root swaps, stored
318    /// **canonicalized**. Set via [`Workspace::with_sandbox_root`] (manifest
319    /// key `workspace.sandbox_root`), `None` by default.
320    ///
321    /// `None` means unbounded — [`Workspace::set_root_dir`] accepts any
322    /// directory, which is the historical behaviour. When `Some`, a swap
323    /// target whose canonical path is not inside this directory is rejected
324    /// before any state is touched.
325    sandbox_root: Option<PathBuf>,
326    /// Who owns the active root (see [`RootOwnership`]). `Operator` for
327    /// every workspace opened with a configured root; `Unowned` only
328    /// after [`Workspace::open_local_unanchored`].
329    ///
330    /// Every **writer** also holds [`root_swap`](Self::root_swap), so the
331    /// value is stable for as long as that guard is held. This lock exists
332    /// separately only so the cheap [`Workspace::root_ownership`] read
333    /// never queues behind a root swap's activation.
334    root_ownership: Mutex<RootOwnership>,
335    /// Orders a client-root adoption against operator root swaps.
336    ///
337    /// **Read** = an operator swap ([`Workspace::set_root_dir`]);
338    /// **write** = an adoption ([`Workspace::adopt_client_root`]).
339    ///
340    /// Operator swaps deliberately still overlap *each other* — two
341    /// concurrent `set_root_dir` calls are made coherent by activation
342    /// generations (the newer request supersedes the older one), not by
343    /// exclusion. An adoption cannot use that mechanism, because its
344    /// precedence rule is not "newest wins" but "the operator always
345    /// wins", and that rule spans three steps: read the ownership flag,
346    /// swap, publish the flag. Holding the write side across all three
347    /// makes those steps atomic with respect to any operator swap.
348    ///
349    /// Without it the two entry points interleave as: adoption passes its
350    /// ownership check → the operator swaps to B → adoption's activation
351    /// commits A last → adoption publishes `Adopted` → the operator
352    /// publishes `Operator`. Final state: the *client's* root active,
353    /// flagged as the operator's, after the operator's tool call already
354    /// returned naming a different path.
355    root_swap: RwLock<()>,
356    /// Local mode, unanchored boot only: the directory that ends up
357    /// holding `.mcp-workspace/`, chosen at the first activation that
358    /// **commits** and then fixed forever (the anchored constructors
359    /// decide it up front and leave this unset). Fixed-forever mirrors
360    /// the anchored rule that `workspace_dir` survives root swaps so the
361    /// inventory does too — see the note in [`Workspace::set_root_dir`].
362    /// Set by [`Workspace::anchor_inventory`], never by a mere attempt.
363    deferred_anchor: OnceLock<PathBuf>,
364    /// Opt-in (`workspace.adopt_client_roots`): may this server adopt a
365    /// root advertised by the MCP client as a *fallback* when the
366    /// operator configured none? `false` by default, and the only thing
367    /// that makes the `roots/list` round-trip happen at all.
368    adopt_client_roots: bool,
369}
370
371#[derive(Debug, Default)]
372struct WorkspaceState {
373    active_repo_name: Option<String>,
374    active_repo_path: Option<PathBuf>,
375    /// Last identity allocated under this state lock. Allocation and
376    /// publication both use the lock, so an older completion can never
377    /// overwrite a newer request's intent.
378    last_activation_id: u64,
379    /// Newest request of **any** kind. A [`ActivationIntent::Refresh`]
380    /// must still match this to publish, so a newer refresh (or any
381    /// bind) discards an older refresh's stale rebuild.
382    latest_requested: Option<ActivationId>,
383    /// Newest request that may *change* the active binding — every
384    /// [`ActivationIntent::Bind`], and deliberately **no** refresh.
385    ///
386    /// A bind is superseded only by a newer bind. Gating it on
387    /// `latest_requested` instead let a concurrent
388    /// `repo_management(update=true)` — which by definition wants the
389    /// binding left alone — discard an in-flight root swap merely by
390    /// holding a newer generation. In local mode that produced the
391    /// framework's one incoherent state: `set_root_dir` reported
392    /// supersession but still published [`RootOwnership::Operator`],
393    /// over a root the *client* had chosen.
394    latest_root_intent: Option<ActivationId>,
395    /// The product currently live in this process. Deliberately not
396    /// persisted: a new process must rehydrate its downstream product even
397    /// when inventory says the source SHA was built previously.
398    active_build: Option<ActiveBuildState>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
402struct ActiveBuildState {
403    activation_id: ActivationId,
404    name: String,
405    path: PathBuf,
406    head_sha: String,
407    resolved_revs: Option<Vec<String>>,
408}
409
410/// What an activation intends to do with the **active binding**
411/// (`active_repo_name` + `active_repo_path`).
412///
413/// The distinction exists because the two intents need opposite
414/// supersession rules. A bind is new intent about *which* root is
415/// active, so newest-wins is exactly right. A refresh
416/// (`repo_management(update=true)`) carries no opinion about that at
417/// all: it means "rebuild whatever is bound". Letting it take part in
418/// newest-wins gave it the power to cancel a root swap and re-commit the
419/// root the swap was replacing — a refresh silently *deciding* which
420/// root is active, which is the one thing it must never do.
421#[derive(Debug, Clone, Copy)]
422enum ActivationIntent<'a> {
423    /// Bind `name`; in local mode, to this canonical root. Participates
424    /// in generation supersession on both sides.
425    Bind(Option<&'a Path>),
426    /// Rebuild the binding named by `expected_root` (and by `activate`'s
427    /// `name`), which the caller read from live state before it called.
428    ///
429    /// That expectation is a compare-and-swap, checked twice under the
430    /// state write lock: once where the generation is allocated (so a
431    /// binding that already moved never even reaches the build hook —
432    /// the legacy hook publishes its product itself, so "abandon at
433    /// commit" would be too late for it) and once at the commit point (so
434    /// a binding that moves *during* the build cannot be reverted). A
435    /// refresh therefore only ever re-commits the binding it found.
436    Refresh { expected_root: Option<&'a Path> },
437}
438
439impl<'a> ActivationIntent<'a> {
440    /// The local root this request builds against, if any. For a refresh
441    /// that is the binding it expects to still find.
442    fn local_root(self) -> Option<&'a Path> {
443        match self {
444            Self::Bind(root) => root,
445            Self::Refresh { expected_root } => expected_root,
446        }
447    }
448
449    /// The root a refresh expects to still be bound, or `None` for a bind
450    /// (which is *allowed* to move the binding, so it checks nothing).
451    fn refresh_expectation(self) -> Option<Option<&'a Path>> {
452        match self {
453            Self::Bind(_) => None,
454            Self::Refresh { expected_root } => Some(expected_root),
455        }
456    }
457}
458
459impl WorkspaceState {
460    /// The request `id` must still equal to be entitled to publish — see
461    /// [`latest_root_intent`](Self::latest_root_intent).
462    fn current_intent(&self, intent: ActivationIntent<'_>) -> Option<ActivationId> {
463        match intent {
464            ActivationIntent::Bind(_) => self.latest_root_intent,
465            ActivationIntent::Refresh { .. } => self.latest_requested,
466        }
467    }
468
469    /// Is the active binding still the `(name, root)` a refresh expects?
470    /// Both halves are read under one lock, so they can never be compared
471    /// across a commit that changed them together.
472    fn binding_is(&self, name: &str, root: Option<&Path>) -> bool {
473        self.active_repo_name.as_deref() == Some(name) && self.active_repo_path.as_deref() == root
474    }
475
476    /// How to name the binding that displaced a refresh's expectation.
477    fn binding_description(&self) -> String {
478        match (&self.active_repo_path, &self.active_repo_name) {
479            (Some(path), _) => path.display().to_string(),
480            (None, Some(name)) => name.clone(),
481            (None, None) => "nothing".to_string(),
482        }
483    }
484}
485
486impl Workspace {
487    /// Open a github-flavoured workspace (clone + track flow).
488    pub fn open(
489        workspace_dir: PathBuf,
490        stale_after_days: u32,
491        post_activate: Option<PostActivateHook>,
492    ) -> Result<Self> {
493        if !workspace_dir.is_dir() {
494            fs::create_dir_all(&workspace_dir).with_context(|| {
495                format!("failed to create workspace dir {}", workspace_dir.display())
496            })?;
497        }
498        let repos_dir = workspace_dir.join("repos");
499        if !repos_dir.is_dir() {
500            fs::create_dir_all(&repos_dir)
501                .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
502        }
503        let ws = Self {
504            inner: Arc::new(WorkspaceInner {
505                kind: WorkspaceKind::Github,
506                workspace_dir,
507                stale_after_days,
508                state: RwLock::new(WorkspaceState::default()),
509                inventory: Mutex::new(()),
510                legacy_activation: Mutex::new(()),
511                post_activate,
512                activation_summary: None,
513                post_activate_revs: None,
514                activation_transaction: None,
515                sandbox_root: None,
516                root_ownership: Mutex::new(RootOwnership::Operator),
517                root_swap: RwLock::new(()),
518                deferred_anchor: OnceLock::new(),
519                adopt_client_roots: false,
520            }),
521        };
522        ws.reconcile_inventory()?;
523        Ok(ws)
524    }
525
526    /// Open a local-directory workspace.
527    ///
528    /// Binds `root` as the active source root immediately and fires the
529    /// post-activate hook (subject to last-built-sha gating). `inventory.json`
530    /// is kept under `<root>/.mcp-workspace/` so the local mode mirrors
531    /// the same gating / fingerprinting infra without polluting the
532    /// user's tree with a `repos/` directory.
533    pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
534        if !root.is_dir() {
535            anyhow::bail!(
536                "local workspace root does not exist or is not a directory: {}",
537                root.display()
538            );
539        }
540        let canon_root = root
541            .canonicalize()
542            .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
543        // Store inventory under a hidden subdir so we don't litter the
544        // user's repo. The "workspace dir" for local mode IS the root.
545        let inv_dir = canon_root.join(".mcp-workspace");
546        if !inv_dir.is_dir() {
547            fs::create_dir_all(&inv_dir).with_context(|| {
548                format!("failed to create local-workspace dir {}", inv_dir.display())
549            })?;
550        }
551        let mut state = WorkspaceState::default();
552        let synthetic_name = synthesize_local_name(&canon_root);
553        state.active_repo_name = Some(synthetic_name);
554        state.active_repo_path = Some(canon_root.clone());
555        Ok(Self {
556            inner: Arc::new(WorkspaceInner {
557                kind: WorkspaceKind::Local,
558                workspace_dir: canon_root,
559                stale_after_days: u32::MAX, // sweeping is github-only
560                state: RwLock::new(state),
561                inventory: Mutex::new(()),
562                legacy_activation: Mutex::new(()),
563                post_activate,
564                activation_summary: None,
565                post_activate_revs: None,
566                activation_transaction: None,
567                sandbox_root: None,
568                // A configured root is the operator's choice, so it is
569                // `Operator` from the first instant — that is exactly what
570                // makes client-root adoption fallback-only.
571                root_ownership: Mutex::new(RootOwnership::Operator),
572                root_swap: RwLock::new(()),
573                deferred_anchor: OnceLock::new(),
574                adopt_client_roots: false,
575            }),
576        })
577    }
578
579    /// Open a local-directory workspace with **no active root**.
580    ///
581    /// The unanchored sibling of [`open_local`](Self::open_local), for the
582    /// case where the root is expected to arrive later from an MCP client
583    /// (`workspace.adopt_client_roots`, see
584    /// [`adopt_client_root`](Self::adopt_client_root)). Nothing is bound,
585    /// nothing is created on disk, and no hook fires: until something
586    /// activates, [`active_repo_path`](Self::active_repo_path) is `None`
587    /// and the source tools behave exactly as they do with no root — which
588    /// is the designed outcome when no root ever arrives.
589    ///
590    /// The inventory directory is deliberately *not* created here: with no
591    /// root there is nowhere to put it. The first activation that
592    /// **commits** picks the directory (`<root>/.mcp-workspace/`) and it is
593    /// fixed from then on, mirroring the anchored mode's rule that the
594    /// inventory survives later root swaps. An activation that fails its
595    /// build picks nothing and creates nothing — a client-proposed root
596    /// that never activated must not end up owning the inventory, nor
597    /// gain a directory it did not have.
598    ///
599    /// [`root_ownership`](Self::root_ownership) starts at
600    /// [`RootOwnership::Unowned`] — the only constructor that does.
601    pub fn open_local_unanchored(post_activate: Option<PostActivateHook>) -> Result<Self> {
602        Ok(Self {
603            inner: Arc::new(WorkspaceInner {
604                kind: WorkspaceKind::Local,
605                // Sentinel: no directory is known yet. `inventory_dir()`
606                // treats an empty base as "unanchored" and skips inventory
607                // I/O entirely until `deferred_anchor` is set.
608                workspace_dir: PathBuf::new(),
609                stale_after_days: u32::MAX, // sweeping is github-only
610                state: RwLock::new(WorkspaceState::default()),
611                inventory: Mutex::new(()),
612                legacy_activation: Mutex::new(()),
613                post_activate,
614                activation_summary: None,
615                post_activate_revs: None,
616                activation_transaction: None,
617                sandbox_root: None,
618                root_ownership: Mutex::new(RootOwnership::Unowned),
619                root_swap: RwLock::new(()),
620                deferred_anchor: OnceLock::new(),
621                adopt_client_roots: false,
622            }),
623        })
624    }
625
626    /// Allow this workspace to adopt a client-advertised MCP root as a
627    /// fallback when the operator configured none (manifest key
628    /// `workspace.adopt_client_roots`).
629    ///
630    /// Off by default. With it off no `roots/list` request is ever issued,
631    /// which is what keeps clients that do not advertise roots — and
632    /// deployments that never opt in — bit-for-bit unaffected.
633    ///
634    /// Setting it on a workspace that already has a root is harmless and
635    /// intentional: ownership is already [`RootOwnership::Operator`], so
636    /// adoption is refused. Call before the workspace is cloned into
637    /// [`crate::server::ServerOptions`], like the other builders.
638    ///
639    /// <div class="warning">
640    ///
641    /// MCP `roots` is **deprecated** as of protocol revision `2026-07-28`
642    /// ([SEP-2577]) and is eligible for removal in the first revision
643    /// released on or after 2027-07-28. New deployments should prefer the
644    /// spec's own migration path — pass directories via tool parameters,
645    /// resource URIs, or server configuration (`workspace.root`).
646    ///
647    /// [SEP-2577]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577
648    ///
649    /// </div>
650    pub fn with_adopt_client_roots(mut self) -> Self {
651        match Arc::get_mut(&mut self.inner) {
652            Some(inner) => inner.adopt_client_roots = true,
653            None => tracing::warn!(
654                "with_adopt_client_roots called after the workspace was cloned; client roots will not be adopted"
655            ),
656        }
657        self
658    }
659
660    /// Is client-root adoption enabled (`workspace.adopt_client_roots`)?
661    pub fn adopts_client_roots(&self) -> bool {
662        self.inner.adopt_client_roots
663    }
664
665    /// Who chose the active root — see [`RootOwnership`].
666    ///
667    /// Never blocks on an in-flight activation: the flag has a lock of its
668    /// own, distinct from the swap ordering lock that a root swap holds
669    /// across its (arbitrarily long) build.
670    pub fn root_ownership(&self) -> RootOwnership {
671        *self.lock_ownership()
672    }
673
674    /// Lock the root-ownership flag, recovering from poisoning.
675    ///
676    /// The guarded value is one `Copy` enum written by a single
677    /// assignment, so a panic elsewhere under the guard cannot leave it
678    /// half-updated — there is nothing torn to protect against.
679    /// Recovering matters because these guards are now reachable from a
680    /// request handler and from the client-`roots` task: a panicking
681    /// post-activate hook must not turn every later root operation
682    /// (including the read-only `root_ownership()`) into a panic of its
683    /// own.
684    fn lock_ownership(&self) -> MutexGuard<'_, RootOwnership> {
685        self.inner
686            .root_ownership
687            .lock()
688            .unwrap_or_else(|poisoned| poisoned.into_inner())
689    }
690
691    /// Shared side of the swap ordering lock — taken by an operator swap.
692    /// See [`WorkspaceInner::root_swap`]. Poison-recovering for the same
693    /// reason as [`lock_ownership`](Self::lock_ownership); the guarded
694    /// value is `()`.
695    fn lock_operator_swap(&self) -> RwLockReadGuard<'_, ()> {
696        self.inner
697            .root_swap
698            .read()
699            .unwrap_or_else(|poisoned| poisoned.into_inner())
700    }
701
702    /// Exclusive side of the swap ordering lock — taken by a client-root
703    /// adoption for its whole check-swap-publish sequence.
704    fn lock_adoption(&self) -> RwLockWriteGuard<'_, ()> {
705        self.inner
706            .root_swap
707            .write()
708            .unwrap_or_else(|poisoned| poisoned.into_inner())
709    }
710
711    /// Bound runtime root swaps to a containment boundary (local mode).
712    ///
713    /// With a boundary attached, [`set_root_dir`](Self::set_root_dir)
714    /// refuses any target whose *canonical* path is not inside `boundary`
715    /// — `..` traversals and symlinks out of the tree are therefore
716    /// rejected too, and the rejection happens before any state is
717    /// touched. Without it (the default) `set_root_dir` stays unbounded,
718    /// which is the historical behaviour.
719    ///
720    /// Call immediately after `open_local`, before the workspace is cloned
721    /// into [`crate::server::ServerOptions`] — like
722    /// [`with_activation_summary`](Self::with_activation_summary) it mutates
723    /// the still-unique inner `Arc`. Unlike the hook builders, a late call
724    /// is an **error** rather than a warning: a containment boundary that
725    /// silently failed to attach is worse than no boundary at all.
726    ///
727    /// Errors when the boundary does not exist, when the workspace is not
728    /// local-flavoured, or when the already-active root lies outside the
729    /// boundary — a config that contradicts itself must die at boot, not at
730    /// the first swap.
731    pub fn with_sandbox_root(mut self, boundary: &Path) -> Result<Self> {
732        if !matches!(self.inner.kind, WorkspaceKind::Local) {
733            anyhow::bail!(
734                "sandbox_root is only valid for local workspaces (this one is {})",
735                self.inner.kind.as_str()
736            );
737        }
738        if !boundary.is_dir() {
739            anyhow::bail!(
740                "sandbox_root does not exist or is not a directory: {}",
741                boundary.display()
742            );
743        }
744        let canon = boundary.canonicalize().with_context(|| {
745            format!("failed to canonicalize sandbox_root {}", boundary.display())
746        })?;
747        if let Some(active) = self.active_repo_path() {
748            if !active.starts_with(&canon) {
749                anyhow::bail!(
750                    "active root {} is outside sandbox_root {}: the configured root must lie inside the containment boundary",
751                    active.display(),
752                    canon.display()
753                );
754            }
755        }
756        match Arc::get_mut(&mut self.inner) {
757            Some(inner) => inner.sandbox_root = Some(canon),
758            None => anyhow::bail!(
759                "with_sandbox_root called after the workspace was cloned; \
760                 the containment boundary {} would not be enforced",
761                canon.display()
762            ),
763        }
764        Ok(self)
765    }
766
767    /// Attach an [`ActivationSummaryHook`]. Call immediately after
768    /// `open`/`open_local` (before the workspace is cloned into
769    /// `ServerOptions`): it mutates the still-unique inner `Arc`. Calling
770    /// it after the workspace has been cloned is a no-op with a warning —
771    /// the summary simply won't be attached.
772    pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
773        match Arc::get_mut(&mut self.inner) {
774            Some(inner) => inner.activation_summary = Some(hook),
775            None => tracing::warn!(
776                "with_activation_summary called after the workspace was cloned; summary not attached"
777            ),
778        }
779        self
780    }
781
782    /// Attach a [`PostActivateRevsHook`]. Call immediately after
783    /// `open`/`open_local` (before the workspace is cloned into
784    /// `ServerOptions`): it mutates the still-unique inner `Arc`, exactly
785    /// like [`with_activation_summary`](Self::with_activation_summary).
786    /// Calling it after the workspace has been cloned is a no-op with a
787    /// warning. Additive — consumers that don't set it keep the plain
788    /// single-rev activation behaviour.
789    pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
790        match Arc::get_mut(&mut self.inner) {
791            Some(inner) => inner.post_activate_revs = Some(hook),
792            None => tracing::warn!(
793                "with_post_activate_revs called after the workspace was cloned; revs hook not attached"
794            ),
795        }
796        self
797    }
798
799    /// Attach the request-scoped activation prepare/commit contract.
800    ///
801    /// When set, this hook owns plain builds, revision-set builds, cheap-skip
802    /// summaries, and atomic product publication. It replaces the legacy
803    /// `post_activate`, `post_activate_revs`, and `activation_summary`
804    /// callbacks for activation calls. Configure it before cloning the
805    /// workspace into [`crate::server::ServerOptions`].
806    pub fn with_activation_transaction(mut self, hook: ActivationTransactionHook) -> Self {
807        match Arc::get_mut(&mut self.inner) {
808            Some(inner) => inner.activation_transaction = Some(hook),
809            None => tracing::warn!(
810                "with_activation_transaction called after the workspace was cloned; transaction not attached"
811            ),
812        }
813        self
814    }
815
816    pub fn kind(&self) -> WorkspaceKind {
817        self.inner.kind
818    }
819
820    /// The directory this workspace keeps its bookkeeping under.
821    ///
822    /// After an unanchored local boot ([`open_local_unanchored`](Self::open_local_unanchored))
823    /// this is the empty path until the first activation anchors it; every
824    /// other constructor knows it up front.
825    pub fn workspace_dir(&self) -> &Path {
826        match self.inner.deferred_anchor.get() {
827            Some(anchored) => anchored.as_path(),
828            None => &self.inner.workspace_dir,
829        }
830    }
831
832    pub fn repos_dir(&self) -> PathBuf {
833        self.workspace_dir().join("repos")
834    }
835
836    /// Base directory for inventory bookkeeping, or `None` while an
837    /// unanchored local workspace has yet to activate anything.
838    fn inventory_base(&self) -> Option<&Path> {
839        let base = self.workspace_dir();
840        (!base.as_os_str().is_empty()).then_some(base)
841    }
842
843    fn inventory_path(&self) -> Option<PathBuf> {
844        let base = self.inventory_base()?;
845        Some(match self.inner.kind {
846            WorkspaceKind::Github => base.join("inventory.json"),
847            WorkspaceKind::Local => base.join(".mcp-workspace").join("inventory.json"),
848        })
849    }
850
851    /// Active repo's full org/repo name, or None if nothing is active.
852    pub fn active_repo_name(&self) -> Option<String> {
853        self.inner.state.read().unwrap().active_repo_name.clone()
854    }
855
856    /// Active repo's filesystem path, or None.
857    pub fn active_repo_path(&self) -> Option<PathBuf> {
858        self.inner.state.read().unwrap().active_repo_path.clone()
859    }
860
861    /// Default `org/repo` for the GitHub tools when the caller passes none.
862    ///
863    /// Github mode: the active repo — there the inventory key *is* the
864    /// `org/repo`. Local mode: the active root's `origin` remote parsed
865    /// to `org/repo`, or `None` when there's no GitHub remote. Crucially
866    /// it is *never* the `local/<dir>` inventory key (see
867    /// [`active_repo_name`](Self::active_repo_name)), which is a
868    /// filesystem-derived key, not a valid repo slug.
869    pub fn default_github_repo(&self) -> Option<String> {
870        match self.inner.kind {
871            WorkspaceKind::Github => self.active_repo_name(),
872            WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
873        }
874    }
875
876    // ------------------------------------------------------------------
877    // Inventory management
878    // ------------------------------------------------------------------
879
880    fn load_inventory_unlocked(&self) -> BTreeMap<String, InventoryEntry> {
881        let Some(path) = self.inventory_path() else {
882            return BTreeMap::new();
883        };
884        let Ok(text) = fs::read_to_string(&path) else {
885            return BTreeMap::new();
886        };
887        serde_json::from_str(&text).unwrap_or_default()
888    }
889
890    fn save_inventory_unlocked(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
891        // No anchor yet (unanchored local boot) means nowhere to write.
892        // Bookkeeping is best-effort in that window, by construction.
893        let Some(path) = self.inventory_path() else {
894            return Ok(());
895        };
896        let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
897        fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
898        Ok(())
899    }
900
901    fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
902        let _guard = self.inner.inventory.lock().unwrap();
903        self.load_inventory_unlocked()
904    }
905
906    fn reconcile_inventory(&self) -> Result<()> {
907        let _guard = self.inner.inventory.lock().unwrap();
908        let mut inv = self.load_inventory_unlocked();
909        let mut on_disk: Vec<String> = Vec::new();
910        if self.repos_dir().is_dir() {
911            for org_entry in fs::read_dir(self.repos_dir())? {
912                let Ok(org_entry) = org_entry else { continue };
913                if !org_entry.path().is_dir() {
914                    continue;
915                }
916                let org = org_entry.file_name().to_string_lossy().into_owned();
917                if org.starts_with('.') {
918                    continue;
919                }
920                for repo_entry in fs::read_dir(org_entry.path())? {
921                    let Ok(repo_entry) = repo_entry else { continue };
922                    if !repo_entry.path().is_dir() {
923                        continue;
924                    }
925                    let repo = repo_entry.file_name().to_string_lossy().into_owned();
926                    if repo.starts_with('.') {
927                        continue;
928                    }
929                    let rname = format!("{org}/{repo}");
930                    on_disk.push(rname.clone());
931                    inv.entry(rname).or_insert_with(|| {
932                        let mtime = repo_entry
933                            .metadata()
934                            .ok()
935                            .and_then(|m| m.modified().ok())
936                            .map(format_iso)
937                            .unwrap_or_else(now_iso);
938                        InventoryEntry {
939                            cloned_at: mtime.clone(),
940                            last_accessed: mtime,
941                            access_count: 0,
942                            stale: false,
943                            last_built_sha: None,
944                            last_built_revs: None,
945                        }
946                    });
947                }
948            }
949        }
950        for (rname, entry) in inv.iter_mut() {
951            if !on_disk.contains(rname) && !entry.stale {
952                entry.stale = true;
953            }
954        }
955        self.save_inventory_unlocked(&inv)?;
956        Ok(())
957    }
958
959    fn bump_access(&self, name: &str, action: &str) {
960        let _guard = self.inner.inventory.lock().unwrap();
961        let mut inv = self.load_inventory_unlocked();
962        let now = now_iso();
963        let entry = inv
964            .entry(name.to_string())
965            .or_insert_with(|| InventoryEntry {
966                cloned_at: now.clone(),
967                last_accessed: now.clone(),
968                access_count: 0,
969                stale: false,
970                last_built_sha: None,
971                last_built_revs: None,
972            });
973        entry.last_accessed = now.clone();
974        entry.access_count += 1;
975        entry.stale = false;
976        if action == "cloned" || entry.cloned_at.is_empty() {
977            entry.cloned_at = now;
978        }
979        let _ = self.save_inventory_unlocked(&inv);
980    }
981
982    fn mark_stale(&self, name: &str) {
983        let _guard = self.inner.inventory.lock().unwrap();
984        let mut inv = self.load_inventory_unlocked();
985        if let Some(entry) = inv.get_mut(name) {
986            entry.stale = true;
987            let _ = self.save_inventory_unlocked(&inv);
988        }
989    }
990
991    fn sweep_stale(&self) -> Vec<String> {
992        // Local mode has nothing to sweep — the operator owns the root.
993        if matches!(self.inner.kind, WorkspaceKind::Local) {
994            return Vec::new();
995        }
996        let active = self.active_repo_name();
997        let _guard = self.inner.inventory.lock().unwrap();
998        let mut inv = self.load_inventory_unlocked();
999        let cutoff = SystemTime::now()
1000            - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
1001        let mut swept: Vec<String> = Vec::new();
1002        for (rname, entry) in inv.iter_mut() {
1003            if entry.stale {
1004                continue;
1005            }
1006            if Some(rname.as_str()) == active.as_deref() {
1007                continue;
1008            }
1009            let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
1010            if last >= cutoff {
1011                continue;
1012            }
1013            let parts: Vec<&str> = rname.splitn(2, '/').collect();
1014            if parts.len() != 2 {
1015                continue;
1016            }
1017            let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
1018            if repo_path.exists() {
1019                let _ = fs::remove_dir_all(&repo_path);
1020            }
1021            entry.stale = true;
1022            swept.push(rname.clone());
1023        }
1024        if !swept.is_empty() {
1025            let _ = self.save_inventory_unlocked(&inv);
1026            self.prune_empty_org_dirs();
1027        }
1028        swept
1029    }
1030
1031    fn prune_empty_org_dirs(&self) {
1032        let Ok(entries) = fs::read_dir(self.repos_dir()) else {
1033            return;
1034        };
1035        for entry in entries.flatten() {
1036            let path = entry.path();
1037            if !path.is_dir() {
1038                continue;
1039            }
1040            if let Ok(children) = fs::read_dir(&path) {
1041                let real: Vec<_> = children
1042                    .flatten()
1043                    .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
1044                    .collect();
1045                if real.is_empty() {
1046                    let _ = fs::remove_dir_all(&path);
1047                }
1048            }
1049        }
1050    }
1051
1052    // ------------------------------------------------------------------
1053    // Git operations
1054    // ------------------------------------------------------------------
1055
1056    /// Clone (if missing) or fast-forward (if cloned). Returns the
1057    /// action label, the repo path, and the new HEAD SHA after the op.
1058    ///
1059    /// Local-mode short-circuits: there's nothing to clone or fetch.
1060    /// The "SHA" is a cheap content fingerprint (recursive walk of file
1061    /// mtimes + sizes) so the auto-rebuild gate still works.
1062    fn clone_or_update(
1063        &self,
1064        name: &str,
1065        requested_local_root: Option<&Path>,
1066    ) -> Result<(String, PathBuf, String)> {
1067        if matches!(self.inner.kind, WorkspaceKind::Local) {
1068            // `set_root_dir` passes its canonical target explicitly. This
1069            // avoids publishing the requested path before its build commits,
1070            // and prevents a concurrent activation from changing the path
1071            // fingerprinted by this request. Refresh calls snapshot the
1072            // currently committed root and pass it through the same
1073            // argument — as an *expectation*, re-checked under the state
1074            // lock at both ends of the build (see [`ActivationIntent`]), so
1075            // a snapshot the binding has outrun never gets built.
1076            //
1077            // So every local caller supplies the root — `repo_management`
1078            // having already refused with "No active local root." when
1079            // there is none. The fallback below is an internal invariant,
1080            // not a user-facing state, and deliberately does not describe
1081            // one.
1082            let root = requested_local_root
1083                .map(Path::to_path_buf)
1084                .or_else(|| self.active_repo_path())
1085                .context("internal error: local activation without a root")?;
1086            // NB: the inventory anchor is *not* chosen here. An unanchored
1087            // boot picks the directory that owns `.mcp-workspace/` at the
1088            // first activation that actually commits — see
1089            // `anchor_inventory` — so a proposal that fails its build
1090            // writes nothing inside the proposed root.
1091            let prev_sha = self.last_built_sha(name);
1092            let fingerprint = fingerprint_dir(&root);
1093            let action = match prev_sha {
1094                Some(p) if p == fingerprint => "current",
1095                None => "cloned", // first activation
1096                Some(_) => "updated",
1097            };
1098            return Ok((action.to_string(), root, fingerprint));
1099        }
1100        let parts: Vec<&str> = name.splitn(2, '/').collect();
1101        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
1102        if !repo_path.exists() {
1103            fs::create_dir_all(repo_path.parent().unwrap()).ok();
1104            let url = format!("https://github.com/{name}.git");
1105            // Treeless clone (`--filter=tree:0`): keeps the FULL commit
1106            // history — so `git log -S` (pickaxe) and any rev walk work —
1107            // while fetching tree/blob objects lazily on demand, keeping
1108            // the initial transfer near a shallow clone's cost. `--tags`
1109            // pulls all tags up front so tag-scoped rev reads
1110            // (`read_source rev=v1.2.3`) resolve without a follow-up fetch.
1111            // (Was `--depth 1`, which truncated history and broke pickaxe.)
1112            let out = Command::new("git")
1113                .args([
1114                    "clone",
1115                    "--filter=tree:0",
1116                    "--tags",
1117                    &url,
1118                    repo_path.to_str().unwrap(),
1119                ])
1120                .output()
1121                .context("failed to spawn `git clone`")?;
1122            if !out.status.success() {
1123                anyhow::bail!(
1124                    "git clone failed: {}",
1125                    String::from_utf8_lossy(&out.stderr).trim()
1126                );
1127            }
1128            let sha = git_rev_parse(&repo_path, "HEAD")?;
1129            return Ok(("cloned".to_string(), repo_path, sha));
1130        }
1131
1132        // Fetch + check head delta. Plain `git fetch origin --tags` (no
1133        // `--depth 1`) so the treeless clone stays history-complete and
1134        // newly-pushed tags become available for rev-scoped reads; blobs
1135        // are still fetched lazily. FETCH_HEAD records the remote's
1136        // default-branch tip, so the SHA-gate below is unchanged.
1137        Command::new("git")
1138            .args(["fetch", "origin", "--tags"])
1139            .current_dir(&repo_path)
1140            .output()
1141            .context("git fetch failed")?;
1142        let local = git_rev_parse(&repo_path, "HEAD")?;
1143        let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
1144        if local != remote {
1145            Command::new("git")
1146                .args(["reset", "--hard", "FETCH_HEAD"])
1147                .current_dir(&repo_path)
1148                .output()
1149                .context("git reset failed")?;
1150            let sha = git_rev_parse(&repo_path, "HEAD")?;
1151            return Ok(("updated".to_string(), repo_path, sha));
1152        }
1153        Ok(("current".to_string(), repo_path, local))
1154    }
1155
1156    /// Resolve a [`RevsRequest`] against the git repo at `repo_path` into
1157    /// a concrete, ordered list of git revspecs.
1158    ///
1159    /// - `Count(n)`: the newest `n` **stable release** tags of the repo's
1160    ///   dominant tag family, **oldest→newest**, with `HEAD` appended as
1161    ///   the final (newest) rev. Selection (see [`select_family_tags`]):
1162    ///   every tag is classified into `(prefix, version, is_prerelease)`
1163    ///   by stripping a trailing version component; tags with no version
1164    ///   component (e.g. `r-universe-release`) are excluded. Tags are
1165    ///   grouped by prefix and the family with the most **stable**
1166    ///   (non-prerelease) tags is chosen; within it the newest `n` stable
1167    ///   tags (version-sorted) are taken. Prerelease markers (rc, alpha,
1168    ///   beta, dev, pre, preview — case-insensitive) never count as
1169    ///   releases. Fallback chain for degenerate repos: if the winning
1170    ///   family has no stable tags its prereleases are used; if no tag is
1171    ///   version-like at all, the raw `git tag --sort=-v:refname` top-`n`
1172    ///   is used. Errors only if the repo has no tags whatsoever. Fewer
1173    ///   than `n` matching tags is not an error — all available are used.
1174    /// - `List(revs)`: each revspec is validated with
1175    ///   `git rev-parse --verify <rev>^{commit}` and used verbatim (no
1176    ///   sort, no `HEAD` appended). Errors on the first unknown rev.
1177    ///
1178    /// The resolved list is deduplicated order-preserving on the label
1179    /// string (first occurrence wins) before being returned — see
1180    /// [`dedup_labels`] — so a downstream hook never receives duplicate
1181    /// revspecs (e.g. `revs=["HEAD","HEAD"]`). Dedup is on the label, not
1182    /// the resolved commit: two *different* revspecs that happen to point
1183    /// at the same commit are deliberately both kept, because labels are
1184    /// the graph-facing names a multi-rev builder attaches.
1185    ///
1186    /// A non-git `repo_path` surfaces as the `git tag` / `git rev-parse`
1187    /// failure with a clear message.
1188    fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
1189        let resolved = match req {
1190            RevsRequest::Count(n) => {
1191                let out = Command::new("git")
1192                    .args(["tag", "--sort=-v:refname"])
1193                    .current_dir(repo_path)
1194                    .output()
1195                    .context("failed to spawn `git tag`")?;
1196                if !out.status.success() {
1197                    anyhow::bail!(
1198                        "cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
1199                        repo_path.display(),
1200                        String::from_utf8_lossy(&out.stderr).trim()
1201                    );
1202                }
1203                let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
1204                    .lines()
1205                    .map(|l| l.trim().to_string())
1206                    .filter(|l| !l.is_empty())
1207                    .collect();
1208                if tags.is_empty() {
1209                    anyhow::bail!(
1210                        "revs={n} requested but '{}' has no tags to resolve",
1211                        repo_path.display()
1212                    );
1213                }
1214                // Classify + pick the dominant release family's newest `n`
1215                // stable tags (oldest→newest). Falls back to the raw
1216                // version-sorted top-`n` when no tag is version-like.
1217                let mut chosen = select_family_tags(&tags, *n).unwrap_or_else(|| {
1218                    let mut raw: Vec<String> = tags.into_iter().take(*n).collect();
1219                    raw.reverse();
1220                    raw
1221                });
1222                // HEAD last so a multi-rev builder merges oldest→newest
1223                // with HEAD's signature winning.
1224                chosen.push("HEAD".to_string());
1225                chosen
1226            }
1227            RevsRequest::List(revs) => {
1228                if revs.is_empty() {
1229                    anyhow::bail!("revs list is empty — pass at least one revision");
1230                }
1231                for r in revs {
1232                    let out = Command::new("git")
1233                        .args([
1234                            "rev-parse",
1235                            "--verify",
1236                            "--quiet",
1237                            &format!("{r}^{{commit}}"),
1238                        ])
1239                        .current_dir(repo_path)
1240                        .output()
1241                        .context("failed to spawn `git rev-parse`")?;
1242                    if !out.status.success() {
1243                        anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
1244                    }
1245                }
1246                revs.clone()
1247            }
1248        };
1249        Ok(dedup_labels(resolved))
1250    }
1251
1252    /// Activate a repo: prepare source, build, and publish if still current.
1253    ///
1254    /// Auto-rebuild gating: if `force_rebuild` is false AND no `revs` were
1255    /// requested AND the repo is already at the HEAD it was last built at
1256    /// (`action == "current"` AND `prev_built_sha == new_head`), the
1257    /// post-activate hook is skipped. This makes `repo_management(update=True)`
1258    /// cheap when upstream hasn't moved. Set `force_rebuild=true` to bypass
1259    /// (e.g. after upgrading the builder itself).
1260    ///
1261    /// When `revs` are requested the skip gate never applies — a
1262    /// revs-requested activation always fires the hook (see the gate
1263    /// comment below). If the revs-aware hook is set, it is called with
1264    /// the resolved revspecs; otherwise the plain hook runs (single-rev
1265    /// build) and the resolved list is not reported.
1266    ///
1267    /// Each request receives an identity before active source state mutates.
1268    /// A transaction hook prepares off-lock and returns the commit closure
1269    /// that installs its product and generates its summary. If newer intent
1270    /// arrives first, the closure is dropped and the response reports
1271    /// supersession. On commit, source identity, in-process built identity,
1272    /// and inventory receipt publish under one generation boundary.
1273    ///
1274    /// On successful hook completion the new HEAD SHA is persisted to
1275    /// `inventory.json[name].last_built_sha`. If the hook fails the SHA and
1276    /// active source state are not changed, so the next request retries.
1277    ///
1278    /// `intent` says whether this request may change the active binding —
1279    /// see [`ActivationIntent`]. A refresh cannot: it neither supersedes a
1280    /// bind nor publishes over one.
1281    fn activate(
1282        &self,
1283        name: &str,
1284        force_rebuild: bool,
1285        revs: Option<&RevsRequest>,
1286        intent: ActivationIntent<'_>,
1287    ) -> Result<String> {
1288        // Legacy callbacks publish their downstream product inside the hook,
1289        // so they cannot safely overlap. Keep that API coherent by
1290        // serializing the complete request before allocating its identity.
1291        // Transaction hooks prepare concurrently and therefore skip this
1292        // lock; their stale work is discarded at the generation gate below.
1293        let _legacy_guard = self
1294            .inner
1295            .activation_transaction
1296            .is_none()
1297            .then(|| self.inner.legacy_activation.lock().unwrap());
1298        let activation_id = {
1299            let mut state = self.inner.state.write().unwrap();
1300            // First half of a refresh's compare-and-swap. The caller read
1301            // the binding off live state; between that read and this lock
1302            // a bind may have committed a different one (in the legacy
1303            // path that gap spans a whole serialized activation, since
1304            // `_legacy_guard` above is taken first). Returning here — with
1305            // no identity allocated and nothing touched — keeps a refresh
1306            // from rebuilding, or even *reading*, a root nobody asked for.
1307            if let Some(expected_root) = intent.refresh_expectation() {
1308                if !state.binding_is(name, expected_root) {
1309                    let now = state.binding_description();
1310                    return Ok(format!(
1311                        "Refresh of '{name}' was abandoned before it started: the active root is now {now}. \
1312                         Nothing was rebuilt — a refresh never changes which root is active."
1313                    ));
1314                }
1315            }
1316            state.last_activation_id += 1;
1317            let id = ActivationId(state.last_activation_id);
1318            state.latest_requested = Some(id);
1319            // A refresh deliberately does not claim the root intent: it
1320            // must not be able to supersede an in-flight bind.
1321            if intent.refresh_expectation().is_none() {
1322                state.latest_root_intent = Some(id);
1323            }
1324            id
1325        };
1326        let prev_built_sha = self.last_built_sha(name);
1327        let prev_built_revs = self.last_built_revs(name);
1328        let (action, repo_path, head_sha) = self
1329            .clone_or_update(name, intent.local_root())
1330            .with_context(|| {
1331                format!("activation request {activation_id} source preparation failed")
1332            })?;
1333        // Resolve any requested revs before mutating active state, so a
1334        // bad request (no tags / unknown rev) returns a clean error with
1335        // the repo cloned-but-not-activated rather than half-bound.
1336        let resolved_revs = match revs {
1337            Some(req) => Some(self.resolve_revs(&repo_path, req).with_context(|| {
1338                format!("activation request {activation_id} revision resolution failed")
1339            })?),
1340            None => None,
1341        };
1342        self.bump_access(name, &action);
1343        let is_active_built = {
1344            let state = self.inner.state.read().unwrap();
1345            state.active_build.as_ref().is_some_and(|built| {
1346                built.name == name && built.path == repo_path && built.resolved_revs.is_none()
1347            })
1348        };
1349
1350        // The skip gate must be satisfied on BOTH axes: the git repo is
1351        // at its last-built SHA (persisted, cross-process) AND `name` is
1352        // the *currently active* built root in this process (in-memory).
1353        // Without the second axis a fresh process would inherit
1354        // `last_built_sha` from disk, skip the hook, and leave the
1355        // consumer's in-memory state (e.g. the code graph) empty —
1356        // activate would report success with nothing loaded. The axis
1357        // checks the *active* built name, not any name ever built, so an
1358        // A→B→A swap correctly rebuilds A: after activate(B) the live
1359        // slot holds B, so re-binding A must not skip (see the
1360        // `active_build` field doc).
1361        // Skip-gate / revs interaction: a revs-requested activation ALWAYS
1362        // fires the hook — the SHA-skip gate only applies to the plain
1363        // (no-revs) path (`resolved_revs.is_none()`). Rationale: the gate
1364        // keys off HEAD's SHA alone, which says nothing about which *set*
1365        // of revs a prior build loaded, so a rev-set request at the same
1366        // HEAD must rebuild. The tradeoff is that repeat `revs=` calls at
1367        // an unchanged HEAD re-parse every rev — acceptable for an
1368        // explicit multi-rev request.
1369        //
1370        // The plain path additionally requires `prev_built_revs.is_none()`
1371        // — a plain re-activation must NOT skip when the last build was
1372        // multi-rev. Without this the tool would report a plain activation
1373        // (and, downstream, the plain hook rebuilding a single-rev graph
1374        // was bypassed) while the live product is still the rev-set union.
1375        // A non-skip here means the plain hook runs and `record_built`
1376        // clears the stored request — resetting to a genuine plain graph.
1377        let already_built = !force_rebuild
1378            && resolved_revs.is_none()
1379            && prev_built_revs.is_none()
1380            && action == "current"
1381            && prev_built_sha.as_deref() == Some(head_sha.as_str())
1382            && is_active_built;
1383        let uses_transaction = self.inner.activation_transaction.is_some();
1384        let revision_build = !already_built
1385            && resolved_revs.is_some()
1386            && (uses_transaction || self.inner.post_activate_revs.is_some());
1387        let build = if already_built {
1388            ActivationBuild::Reuse
1389        } else if revision_build {
1390            ActivationBuild::Revisions(resolved_revs.clone().unwrap_or_default())
1391        } else {
1392            ActivationBuild::Plain
1393        };
1394        let request = ActivationRequest {
1395            id: activation_id,
1396            path: repo_path.clone(),
1397            name: name.to_string(),
1398            build,
1399        };
1400
1401        let prepared = if let Some(hook) = &self.inner.activation_transaction {
1402            hook(&request)
1403        } else {
1404            // Compatibility path. The complete request is serialized by
1405            // `_legacy_guard`, making the old build-then-summary sequence
1406            // coherent even though those callbacks cannot carry an id.
1407            let hook_result = match request.build() {
1408                ActivationBuild::Reuse => Ok(()),
1409                ActivationBuild::Revisions(resolved) => self
1410                    .inner
1411                    .post_activate_revs
1412                    .as_ref()
1413                    .map_or(Ok(()), |hook| hook(&repo_path, name, resolved)),
1414                ActivationBuild::Plain => self
1415                    .inner
1416                    .post_activate
1417                    .as_ref()
1418                    .map_or(Ok(()), |hook| hook(&repo_path, name)),
1419            };
1420            hook_result.map(|()| {
1421                let summary = self
1422                    .inner
1423                    .activation_summary
1424                    .as_ref()
1425                    .and_then(|hook| hook(&repo_path, name));
1426                PreparedActivation::summary(summary)
1427            })
1428        };
1429
1430        let prepared = match prepared {
1431            Ok(prepared) => prepared,
1432            Err(error) => {
1433                let latest = self.inner.state.read().unwrap().current_intent(intent);
1434                if latest != Some(activation_id) {
1435                    return Ok(format!(
1436                        "Activation request {activation_id} for '{name}' was superseded by request {} before its failed build could publish.",
1437                        latest.map_or_else(|| "unknown".to_string(), |id| id.to_string())
1438                    ));
1439                }
1440                return Err(anyhow!(
1441                    "activation request {activation_id} for '{name}' failed during preparation: {error}"
1442                ));
1443            }
1444        };
1445
1446        // Generation commit point. While this write lock is held, no newer
1447        // identity can be allocated. The downstream product, its request-
1448        // scoped summary, source binding, and built identity therefore become
1449        // visible as one ordered transaction.
1450        let summary = {
1451            let mut state = self.inner.state.write().unwrap();
1452            if state.current_intent(intent) != Some(activation_id) {
1453                let superseding = state.current_intent(intent);
1454                drop(state);
1455                drop(prepared);
1456                return Ok(format!(
1457                    "Activation request {activation_id} for '{name}' was superseded by request {} before publication; its prepared build was discarded.",
1458                    superseding.map_or_else(|| "unknown".to_string(), |id| id.to_string())
1459                ));
1460            }
1461            // Second half of a refresh's compare-and-swap. Reaching here
1462            // means no *newer* request exists — but a bind older than this
1463            // refresh may still have committed while it built, and that
1464            // bind is not superseded (it holds the root intent). Publishing
1465            // now would revert the binding it just established, after its
1466            // caller was told the swap succeeded.
1467            if let Some(expected_root) = intent.refresh_expectation() {
1468                if !state.binding_is(name, expected_root) {
1469                    let now = state.binding_description();
1470                    drop(state);
1471                    drop(prepared);
1472                    return Ok(format!(
1473                        "Refresh request {activation_id} for '{name}' was abandoned: the active root moved to \
1474                         {now} while it rebuilt, and a refresh never changes which root is active. \
1475                         Its prepared build was discarded."
1476                    ));
1477                }
1478            }
1479            let summary = prepared.commit().with_context(|| {
1480                format!("activation request {activation_id} for '{name}' failed during commit")
1481            })?;
1482            // The build is live, so this activation is the one that gets to
1483            // fix the inventory anchor (no-op unless this is the first
1484            // commit after an unanchored boot). Must precede `record_built`,
1485            // which needs somewhere to write its receipt.
1486            self.anchor_inventory(&repo_path, name, &action);
1487            if !matches!(request.build(), ActivationBuild::Reuse) {
1488                let built_revs = revision_build.then_some(revs).flatten();
1489                self.record_built(name, &head_sha, built_revs);
1490            }
1491            state.active_repo_name = Some(name.to_string());
1492            state.active_repo_path = Some(repo_path.clone());
1493            state.active_build = Some(ActiveBuildState {
1494                activation_id,
1495                name: name.to_string(),
1496                path: repo_path.clone(),
1497                head_sha: head_sha.clone(),
1498                resolved_revs: match request.build() {
1499                    ActivationBuild::Revisions(resolved) => Some(resolved.clone()),
1500                    ActivationBuild::Plain | ActivationBuild::Reuse => None,
1501                },
1502            });
1503            summary
1504        };
1505
1506        let verb = match action.as_str() {
1507            "cloned" => "Cloned",
1508            "updated" => "Updated",
1509            "current" => "Activated (already up to date)",
1510            other => other,
1511        };
1512        let suffix = if already_built {
1513            " [build skipped: HEAD matches last-built SHA]"
1514        } else {
1515            ""
1516        };
1517        let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
1518        // Name the resolved revisions on their own line so agents see
1519        // exactly what got loaded. Only when the revs-hook actually ran
1520        // (revs requested AND hook set AND it succeeded) — a fallback to
1521        // the plain hook loads HEAD only, so claiming a rev-set would lie.
1522        if let ActivationBuild::Revisions(resolved) = request.build() {
1523            base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
1524        }
1525        Ok(match summary {
1526            Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
1527            _ => base,
1528        })
1529    }
1530
1531    /// Fix the inventory home on the first activation that **commits**.
1532    ///
1533    /// A no-op outside one window: a local workspace that booted
1534    /// unanchored ([`open_local_unanchored`](Self::open_local_unanchored))
1535    /// and has not committed an activation yet. Every anchored constructor
1536    /// decides the directory up front.
1537    ///
1538    /// Deliberately driven from the commit block rather than from
1539    /// `clone_or_update`. The root of an unanchored workspace arrives from
1540    /// an *external* party (an MCP client's advertised root), so a
1541    /// proposal that fails its build must leave nothing of itself behind:
1542    /// no `.mcp-workspace/` created inside the client's directory, and no
1543    /// permanently-fixed anchor pointing at a root that never activated.
1544    ///
1545    /// `bump_access` already ran for this activation, while there was
1546    /// still nowhere to write; it is repeated here so the entry
1547    /// `record_built` is about to update exists. Not a double count — the
1548    /// earlier call's save was a no-op.
1549    ///
1550    /// A directory-creation failure is logged, not propagated: the
1551    /// downstream product is published by the time this runs, and
1552    /// bookkeeping in the unanchored window is best-effort by
1553    /// construction (see [`save_inventory_unlocked`](Self::save_inventory_unlocked)).
1554    /// The workspace stays unanchored and the next activation retries.
1555    fn anchor_inventory(&self, root: &Path, name: &str, action: &str) {
1556        if !matches!(self.inner.kind, WorkspaceKind::Local) || self.inventory_base().is_some() {
1557            return;
1558        }
1559        let inv_dir = root.join(".mcp-workspace");
1560        if let Err(e) = fs::create_dir_all(&inv_dir) {
1561            tracing::warn!(
1562                "failed to create local-workspace dir {}: {e}",
1563                inv_dir.display()
1564            );
1565            return;
1566        }
1567        let _ = self.inner.deferred_anchor.set(root.to_path_buf());
1568        self.bump_access(name, action);
1569    }
1570
1571    /// Record the outcome of a successful build: the HEAD SHA plus the
1572    /// revisions request that produced it (`Some` only for a multi-rev
1573    /// build via the revs hook; `None` for a plain / HEAD-only build,
1574    /// which *clears* any previously-stored request). Called only on hook
1575    /// success — a failed build records nothing, so the next `update` retries.
1576    fn record_built(&self, name: &str, sha: &str, revs: Option<&RevsRequest>) {
1577        let _guard = self.inner.inventory.lock().unwrap();
1578        let mut inv = self.load_inventory_unlocked();
1579        if let Some(entry) = inv.get_mut(name) {
1580            entry.last_built_sha = Some(sha.to_string());
1581            entry.last_built_revs = revs.cloned();
1582            let _ = self.save_inventory_unlocked(&inv);
1583        }
1584    }
1585
1586    /// Read the SHA recorded after the last successful post-activate hook
1587    /// for the named repo. `None` if the repo was never built (or the
1588    /// hook last failed). Useful for downstream consumers gating
1589    /// "is the active graph up to date with the repo HEAD?" checks.
1590    pub fn last_built_sha(&self, name: &str) -> Option<String> {
1591        self.load_inventory()
1592            .get(name)
1593            .and_then(|e| e.last_built_sha.clone())
1594    }
1595
1596    /// Read the revisions request last successfully built for the named
1597    /// repo — `Some` when the last build was multi-rev (`revs=`), `None`
1598    /// for a plain / HEAD-only build or a never-built repo. Drives the
1599    /// rev-set-aware skip gate and the `update`-preserves-rev-set path.
1600    pub fn last_built_revs(&self, name: &str) -> Option<RevsRequest> {
1601        self.load_inventory()
1602            .get(name)
1603            .and_then(|e| e.last_built_revs.clone())
1604    }
1605
1606    fn delete(&self, name: &str) -> Result<String> {
1607        let parts: Vec<&str> = name.splitn(2, '/').collect();
1608        if parts.len() != 2 {
1609            anyhow::bail!("Invalid repo name");
1610        }
1611        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
1612        let mut deleted = Vec::new();
1613        if repo_path.exists() {
1614            fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
1615            deleted.push("repo");
1616        }
1617        self.mark_stale(name);
1618        self.prune_empty_org_dirs();
1619        if deleted.is_empty() {
1620            return Ok(format!("Nothing to delete — '{name}' not found."));
1621        }
1622        let mut state = self.inner.state.write().unwrap();
1623        if state.active_repo_name.as_deref() == Some(name) {
1624            state.active_repo_name = None;
1625            state.active_repo_path = None;
1626            state.active_build = None;
1627            return Ok(format!(
1628                "Deleted {}. Active repo cleared.",
1629                deleted.join(", ")
1630            ));
1631        }
1632        Ok(format!("Deleted {}.", deleted.join(", ")))
1633    }
1634
1635    fn list(&self) -> String {
1636        let inv = self.load_inventory();
1637        if inv.is_empty() {
1638            return "No repos cloned yet. Call repo_management('org/repo') to clone one."
1639                .to_string();
1640        }
1641        let active = self.active_repo_name();
1642        let mut live: Vec<String> = Vec::new();
1643        let mut stale_lines: Vec<String> = Vec::new();
1644        for (rname, entry) in &inv {
1645            let marker = if Some(rname.as_str()) == active.as_deref() {
1646                " [active]"
1647            } else {
1648                ""
1649            };
1650            let access = format!(
1651                "{} access{}, last {}",
1652                entry.access_count,
1653                if entry.access_count == 1 { "" } else { "es" },
1654                relative_time(&entry.last_accessed)
1655            );
1656            if entry.stale {
1657                stale_lines.push(format!(
1658                    "  {rname}  [STALE — re-fetch with repo_management('{rname}')]  ({access})"
1659                ));
1660            } else {
1661                live.push(format!("  {rname}{marker}  ({access})"));
1662            }
1663        }
1664        let mut out = String::new();
1665        if !live.is_empty() {
1666            out.push_str(&format!(
1667                "{} live repo(s):\n{}",
1668                live.len(),
1669                live.join("\n")
1670            ));
1671        }
1672        if !stale_lines.is_empty() {
1673            if !out.is_empty() {
1674                out.push_str("\n\n");
1675            }
1676            out.push_str(&format!(
1677                "{} stale repo(s):\n{}",
1678                stale_lines.len(),
1679                stale_lines.join("\n")
1680            ));
1681        }
1682        out
1683    }
1684
1685    /// Public entry for the `repo_management` MCP tool.
1686    ///
1687    /// - `name`: `org/repo` to activate (None = list / refresh mode).
1688    /// - `delete`: remove the named repo + inventory entry. Github only.
1689    /// - `update`: refresh the active repo (auto-rebuild gated).
1690    /// - `force_rebuild`: with `update=true` (or initial activation),
1691    ///   re-run the post-activate hook even when the HEAD SHA matches
1692    ///   `last_built_sha`. Useful after the builder itself has been
1693    ///   upgraded.
1694    ///
1695    /// Local mode behaviour: `name` and `delete` are rejected; pass
1696    /// `update=true` (or no args after the initial activation) to
1697    /// re-fingerprint the root and rebuild if anything changed.
1698    pub fn repo_management(
1699        &self,
1700        name: Option<&str>,
1701        delete: bool,
1702        update: bool,
1703        force_rebuild: bool,
1704        revs: Option<&RevsRequest>,
1705    ) -> String {
1706        // Local mode: most github-only semantics are nonsensical here.
1707        if matches!(self.inner.kind, WorkspaceKind::Local) {
1708            if name.is_some() {
1709                return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
1710                        to switch the active root, or pass `update=true` / `force_rebuild=true` \
1711                        to rebuild against the current root."
1712                    .to_string();
1713            }
1714            if delete {
1715                return "Local-workspace mode does not support `delete`. The root is owned by the \
1716                        operator; remove it manually."
1717                    .to_string();
1718            }
1719            // Name and root are read under one lock: they are published
1720            // together at commit, so reading them separately could pair a
1721            // name with the path of a *different* binding.
1722            let (active, active_root) = {
1723                let state = self.inner.state.read().unwrap();
1724                match &state.active_repo_name {
1725                    Some(n) => (n.clone(), state.active_repo_path.clone()),
1726                    None => return "No active local root.".to_string(),
1727                }
1728            };
1729            // `update`: re-fingerprint and rebuild if anything changed.
1730            // `force_rebuild`: rebuild even when the fingerprint matches.
1731            // Either flag (or neither — initial bind path) routes through
1732            // `activate`; `activate` itself consults the gate using the
1733            // force flag plus the SHA comparison.
1734            let _ = update; // explicit: update is implicit in local mode
1735                            // A local `repo_management` call is always a refresh of the
1736                            // bound root, so when no explicit `revs` are passed re-apply
1737                            // the stored rev-set (if the last build was multi-rev) — a
1738                            // bare refresh must not silently collapse a rev-set graph to
1739                            // HEAD-only. Re-`set_root_dir` (which passes `revs` verbatim)
1740                            // is the way to reset back to a plain single-rev build.
1741            let effective = match revs {
1742                Some(r) => Some(r.clone()),
1743                None => self.last_built_revs(&active),
1744            };
1745            return self
1746                .activate(
1747                    &active,
1748                    force_rebuild,
1749                    effective.as_ref(),
1750                    ActivationIntent::Refresh {
1751                        expected_root: active_root.as_deref(),
1752                    },
1753                )
1754                .unwrap_or_else(|e| format!("rebuild failed: {e}"));
1755        }
1756
1757        let swept = self.sweep_stale();
1758        let prefix = if swept.is_empty() {
1759            String::new()
1760        } else {
1761            format!(
1762                "[Swept {} idle repo(s) (>{}d): {}]\n\n",
1763                swept.len(),
1764                self.inner.stale_after_days,
1765                swept.join(", ")
1766            )
1767        };
1768
1769        if name.is_none() && !update {
1770            return prefix + &self.list();
1771        }
1772
1773        if update {
1774            // One lock for both halves of the binding — see the local
1775            // branch above.
1776            let (active, active_root) = {
1777                let state = self.inner.state.read().unwrap();
1778                match &state.active_repo_name {
1779                    Some(n) => (n.clone(), state.active_repo_path.clone()),
1780                    None => {
1781                        return prefix
1782                            + "No active repository. Call repo_management('org/repo') first."
1783                    }
1784                }
1785            };
1786            // `update=True` refreshes the active repo. When no explicit
1787            // `revs` are passed, re-apply the stored rev-set (if the last
1788            // build was multi-rev) so a bare `update` after HEAD moves
1789            // re-resolves and rebuilds the SAME rev-set rather than
1790            // collapsing it to a single-rev HEAD build. An explicit `revs`
1791            // argument overrides; a plain re-activation (name path, not
1792            // `update`) still resets to plain.
1793            let effective = match revs {
1794                Some(r) => Some(r.clone()),
1795                None => self.last_built_revs(&active),
1796            };
1797            return prefix
1798                + &self
1799                    .activate(
1800                        &active,
1801                        force_rebuild,
1802                        effective.as_ref(),
1803                        // Same invariant as the local branch: `update=True`
1804                        // rebuilds the active repo, so it must never
1805                        // discard — or publish over — a concurrent
1806                        // `repo_management('org/repo')` that binds another.
1807                        ActivationIntent::Refresh {
1808                            expected_root: active_root.as_deref(),
1809                        },
1810                    )
1811                    .unwrap_or_else(|e| format!("update failed: {e}"));
1812        }
1813
1814        let Some(name) = name else {
1815            return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
1816        };
1817        if let Err(e) = validate_repo_name(name) {
1818            return prefix + &e.to_string();
1819        }
1820        if delete {
1821            return prefix
1822                + &self
1823                    .delete(name)
1824                    .unwrap_or_else(|e| format!("delete failed: {e}"));
1825        }
1826        prefix
1827            + &self
1828                .activate(name, force_rebuild, revs, ActivationIntent::Bind(None))
1829                .unwrap_or_else(|e| format!("activate failed: {e}"))
1830    }
1831
1832    /// Swap the active root (local mode only). Re-fires the post-activate
1833    /// hook against the new root. Errors if the workspace is github-flavoured.
1834    ///
1835    /// `revs` (optional): resolve revisions against the new root (which
1836    /// must be a git repo) and fire the revs-aware hook — see
1837    /// [`activate`](Self::activate) / [`RevsRequest`].
1838    ///
1839    /// Concurrency: two `set_root_dir` calls may overlap — the newer
1840    /// activation supersedes the older one. A client-root adoption may not
1841    /// overlap either of them: it is exclusive with every operator swap
1842    /// for its whole check-swap-publish sequence, which is what makes
1843    /// "the operator always wins" true rather than merely likely. Must
1844    /// not be called from inside an activation hook (that has always been
1845    /// true — the legacy hook path already serializes on its own lock).
1846    pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
1847        // Held across the swap *and* the ownership publication below, so
1848        // an adoption cannot slip its own activation between them. Shared,
1849        // so concurrent operator swaps keep overlapping as before.
1850        let _swap = self.lock_operator_swap();
1851        match self.swap_root(new_root, revs, "set_root_dir") {
1852            Ok(msg) => {
1853                // Operator intent is permanent: from here on no
1854                // client-advertised root may displace this one, including
1855                // via `roots/list_changed`.
1856                *self.lock_ownership() = RootOwnership::Operator;
1857                msg
1858            }
1859            Err(msg) => msg,
1860        }
1861    }
1862
1863    /// Adopt a root proposed by the MCP client (`roots/list`).
1864    ///
1865    /// Routes through **the same** validation and containment path as
1866    /// [`set_root_dir`](Self::set_root_dir) — one code path, so a
1867    /// `workspace.sandbox_root` boundary applies identically to an
1868    /// operator swap and to a path proposed by an external party. The MCP
1869    /// spec is explicit that roots are "informational guidance rather than
1870    /// an access-control mechanism", so the boundary, not the client, is
1871    /// what bounds this.
1872    ///
1873    /// Refuses (without touching any state) when ownership is already
1874    /// [`RootOwnership::Operator`] — an operator-chosen root always wins.
1875    /// On success ownership becomes [`RootOwnership::Adopted`], leaving a
1876    /// later `roots/list_changed` free to replace it.
1877    ///
1878    /// `Err` is a human-readable reason for the log; adoption failure is
1879    /// never fatal to the server.
1880    pub fn adopt_client_root(&self, new_root: &Path) -> Result<String, String> {
1881        // Exclusive for the whole check → swap → publish sequence, which
1882        // is what makes "the operator always wins" true rather than
1883        // merely likely. A racing `set_root_dir` either has not started
1884        // (it then waits here and ends up both active and `Operator`), or
1885        // it completed first (it published `Operator` before releasing,
1886        // so the check below refuses). What is *not* possible any more is
1887        // the two overlapping: the operator swapping while this adoption
1888        // is mid-activation, and this activation committing last.
1889        let _swap = self.lock_adoption();
1890        if *self.lock_ownership() == RootOwnership::Operator {
1891            return Err(
1892                "the active root was chosen by the operator; client roots are fallback-only"
1893                    .to_string(),
1894            );
1895        }
1896        let msg = self.swap_root(new_root, None, "adopt_client_root")?;
1897        *self.lock_ownership() = RootOwnership::Adopted;
1898        Ok(msg)
1899    }
1900
1901    /// The shared root-swap path: local-mode check, existence check,
1902    /// canonicalize, **containment**, activate. Both the operator entry
1903    /// point ([`set_root_dir`](Self::set_root_dir)) and the client-root
1904    /// entry point ([`adopt_client_root`](Self::adopt_client_root)) go
1905    /// through here, so neither can grow its own boundary semantics.
1906    ///
1907    /// `Err` carries the same message the tool would have returned; the
1908    /// caller decides whether that is a tool result or a log line.
1909    ///
1910    /// Both callers hold [`root_swap`](WorkspaceInner::root_swap) across
1911    /// this call — shared for an operator swap, exclusive for an adoption.
1912    ///
1913    /// `who` names the entry point and appears in every message this
1914    /// returns, so a rejection logged by the adoption path never claims to
1915    /// be about `set_root_dir`.
1916    fn swap_root(
1917        &self,
1918        new_root: &Path,
1919        revs: Option<&RevsRequest>,
1920        who: &str,
1921    ) -> Result<String, String> {
1922        if !matches!(self.inner.kind, WorkspaceKind::Local) {
1923            return Err(format!("{who} is only valid in local-workspace mode."));
1924        }
1925        if !new_root.is_dir() {
1926            return Err(format!(
1927                "Path does not exist or is not a directory: {}",
1928                new_root.display()
1929            ));
1930        }
1931        let canon = match new_root.canonicalize() {
1932            Ok(p) => p,
1933            Err(e) => return Err(format!("canonicalize failed: {e}")),
1934        };
1935        // Containment (opt-in, see `with_sandbox_root`). Tested on the
1936        // *canonical* path — never the raw argument — so `..` traversals and
1937        // symlinks pointing out of the tree are caught. Returns before
1938        // `activate`, so a rejected swap leaves the active root untouched.
1939        if let Some(sandbox) = self.inner.sandbox_root.as_ref() {
1940            if !canon.starts_with(sandbox) {
1941                return Err(format!(
1942                    "{who}: {} escapes workspace.sandbox_root ({}). \
1943                     The active root is unchanged.",
1944                    canon.display(),
1945                    sandbox.display()
1946                ));
1947            }
1948        }
1949        let synthetic = synthesize_local_name(&canon);
1950        // Note: the WorkspaceInner.workspace_dir field is the path the
1951        // inventory is stored under. We keep the *original* one (from
1952        // open_local, or the first activation after an unanchored boot) so
1953        // the inventory survives across root swaps.
1954        self.activate(
1955            &synthetic,
1956            false,
1957            revs,
1958            ActivationIntent::Bind(Some(&canon)),
1959        )
1960        .map_err(|e| format!("{who} failed: {e}"))
1961    }
1962}
1963
1964/// Deduplicate a resolved revspec list order-preserving, first
1965/// occurrence wins. Dedup is on the **label** string, not the resolved
1966/// commit: a downstream multi-rev builder attaches each label as a
1967/// graph-facing name, so two *different* labels pointing at the same
1968/// commit are deliberately kept — only literal repeats (e.g. a `HEAD`
1969/// that appears twice) collapse.
1970fn dedup_labels(revs: Vec<String>) -> Vec<String> {
1971    let mut seen = std::collections::HashSet::new();
1972    revs.into_iter()
1973        .filter(|r| seen.insert(r.clone()))
1974        .collect()
1975}
1976
1977/// Prerelease markers recognised in a tag's trailing suffix
1978/// (case-insensitive, with an optional `-`/`.`/`_` separator). A tag
1979/// whose version is followed by one of these is never treated as a
1980/// stable release.
1981const PRERELEASE_MARKERS: &[&str] = &["rc", "alpha", "beta", "dev", "pre", "preview"];
1982
1983/// A git tag decomposed into its release family `prefix`, numeric
1984/// `version` components, and whether it carries a prerelease marker.
1985/// Produced by [`classify_tag`]; consumed by [`select_family_tags`].
1986#[derive(Debug, Clone, PartialEq, Eq)]
1987struct ClassifiedTag {
1988    raw: String,
1989    prefix: String,
1990    version: Vec<u64>,
1991    is_prerelease: bool,
1992}
1993
1994/// Classify a single tag into `(prefix, version, is_prerelease)` by
1995/// locating its trailing version component.
1996///
1997/// The version is the *first* `DIGITS(.DIGITS)*` run whose remainder is
1998/// empty or a recognised prerelease suffix; everything before it is the
1999/// family `prefix`. Taking the first *cleanly-parsing* run resolves both
2000/// tricky shapes: `arrow2-0.17.0` skips the `2` in `arrow2` (its
2001/// remainder `-0.17.0` isn't a recognised suffix, so that run is
2002/// rejected) and lands on `0.17.0`; while `v3.0.0-rc1` stops at `3.0.0`
2003/// (with `-rc1` recognised as a prerelease) rather than mistaking the
2004/// trailing `1` of `rc1` for a version. Returns `None` when the tag has
2005/// no version-like component at all (e.g. `r-universe-release`), so such
2006/// tags are excluded from family selection.
2007fn classify_tag(tag: &str) -> Option<ClassifiedTag> {
2008    let bytes = tag.as_bytes();
2009    for i in 0..bytes.len() {
2010        if !bytes[i].is_ascii_digit() {
2011            continue;
2012        }
2013        // Only consider the *start* of a digit run as a version start.
2014        if i > 0 && bytes[i - 1].is_ascii_digit() {
2015            continue;
2016        }
2017        if let Some((version, is_prerelease)) = parse_version_at(&tag[i..]) {
2018            return Some(ClassifiedTag {
2019                raw: tag.to_string(),
2020                prefix: tag[..i].to_string(),
2021                version,
2022                is_prerelease,
2023            });
2024        }
2025    }
2026    None
2027}
2028
2029/// Parse `s` as `DIGITS(.DIGITS)*` optionally followed by a recognised
2030/// prerelease suffix. Returns the numeric components and whether a
2031/// prerelease marker follows. `None` if `s` doesn't start with a digit,
2032/// or carries an *unrecognised* trailing suffix (so the caller rejects
2033/// this candidate start and tries an earlier digit run).
2034fn parse_version_at(s: &str) -> Option<(Vec<u64>, bool)> {
2035    let bytes = s.as_bytes();
2036    let mut nums: Vec<u64> = Vec::new();
2037    let mut idx = 0usize;
2038    loop {
2039        let start = idx;
2040        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
2041            idx += 1;
2042        }
2043        if idx == start {
2044            return None; // expected digits (leading, or after a '.')
2045        }
2046        nums.push(s[start..idx].parse().ok()?);
2047        // Continue only when a '.' is followed by another digit.
2048        if idx + 1 < bytes.len() && bytes[idx] == b'.' && bytes[idx + 1].is_ascii_digit() {
2049            idx += 1;
2050            continue;
2051        }
2052        break;
2053    }
2054    let rest = &s[idx..];
2055    if rest.is_empty() {
2056        return Some((nums, false));
2057    }
2058    // A single optional separator, then a recognised prerelease marker.
2059    let after_sep = rest
2060        .strip_prefix(|c| c == '-' || c == '.' || c == '_')
2061        .unwrap_or(rest);
2062    let lower = after_sep.to_ascii_lowercase();
2063    if PRERELEASE_MARKERS.iter().any(|m| lower.starts_with(m)) {
2064        Some((nums, true))
2065    } else {
2066        None
2067    }
2068}
2069
2070/// From a repo's tag list, choose the dominant **release family** and
2071/// return its newest `n` tags **oldest→newest** (HEAD is appended by the
2072/// caller, not here). Returns `None` when no tag is version-like, so the
2073/// caller can fall back to the raw version-sorted top-`n`.
2074///
2075/// Tags are classified ([`classify_tag`]) and grouped by prefix. The
2076/// family with the most **stable** (non-prerelease) tags wins; if no
2077/// family has any stable tag, the family with the most tags overall wins
2078/// and its prereleases are used. Within the winning family the newest
2079/// `n` tags in the applicable pool (stable if any exist, else
2080/// prerelease) are taken by descending version, then reversed to
2081/// oldest→newest. Deterministic: grouping iterates prefixes in sorted
2082/// order and the version/`raw` sort is total, so a given tag set always
2083/// resolves to the same list. Family ties are broken toward the
2084/// lexicographically-greatest prefix.
2085fn select_family_tags(tags: &[String], n: usize) -> Option<Vec<String>> {
2086    let classified: Vec<ClassifiedTag> = tags.iter().filter_map(|t| classify_tag(t)).collect();
2087    if classified.is_empty() {
2088        return None;
2089    }
2090    // Group by family prefix (BTreeMap → deterministic prefix order).
2091    let mut families: BTreeMap<String, Vec<&ClassifiedTag>> = BTreeMap::new();
2092    for c in &classified {
2093        families.entry(c.prefix.clone()).or_default().push(c);
2094    }
2095    let stable_count = |v: &Vec<&ClassifiedTag>| v.iter().filter(|c| !c.is_prerelease).count();
2096    let any_stable = families.values().any(|v| stable_count(v) > 0);
2097    // Prefer the family with the most stable tags; when nothing is
2098    // stable anywhere, prefer the family with the most tags overall.
2099    // `max_by` returns the last maximum, and BTreeMap yields ascending
2100    // prefixes, so ties resolve to the greatest prefix — deterministic.
2101    let chosen = families.values().max_by(|a, b| {
2102        if any_stable {
2103            stable_count(a).cmp(&stable_count(b))
2104        } else {
2105            a.len().cmp(&b.len())
2106        }
2107    })?;
2108    let mut pool: Vec<&ClassifiedTag> = if any_stable {
2109        chosen
2110            .iter()
2111            .copied()
2112            .filter(|c| !c.is_prerelease)
2113            .collect()
2114    } else {
2115        chosen.to_vec()
2116    };
2117    // Newest first: descending version, then descending raw for a total,
2118    // stable order on identical versions.
2119    pool.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| b.raw.cmp(&a.raw)));
2120    let mut newest: Vec<String> = pool.into_iter().take(n).map(|c| c.raw.clone()).collect();
2121    newest.reverse(); // oldest→newest
2122    Some(newest)
2123}
2124
2125/// Synthesise a stable "repo name" for a local workspace from its path.
2126/// Used as the inventory key so the same gating + persistence code paths
2127/// that github mode uses can apply to local mode unchanged.
2128fn synthesize_local_name(root: &Path) -> String {
2129    let name = root
2130        .file_name()
2131        .map(|s| s.to_string_lossy().into_owned())
2132        .unwrap_or_else(|| "local".to_string());
2133    format!("local/{name}")
2134}
2135
2136/// Parse the `org/repo` slug from a local checkout's `origin` remote.
2137///
2138/// Shells out to `git -C <root> remote get-url origin` and parses both
2139/// canonical GitHub remote forms, stripping the trailing `.git`:
2140///   - `git@github.com:kkollsga/kglite.git`     → `kkollsga/kglite`
2141///   - `https://github.com/kkollsga/kglite.git` → `kkollsga/kglite`
2142///
2143/// Returns `None` for a non-git directory, a missing `origin` remote, or
2144/// a non-GitHub remote — so the GitHub tools fall back to their existing
2145/// empty-default path (ask the caller for `repo_name`).
2146fn parse_origin_repo(root: &Path) -> Option<String> {
2147    let out = Command::new("git")
2148        .arg("-C")
2149        .arg(root)
2150        .args(["remote", "get-url", "origin"])
2151        .output()
2152        .ok()?;
2153    if !out.status.success() {
2154        return None;
2155    }
2156    let url = String::from_utf8(out.stdout).ok()?;
2157    parse_github_remote(url.trim())
2158}
2159
2160/// Pure-string half of [`parse_origin_repo`]: turn a GitHub remote URL
2161/// into `org/repo`, or `None` if it isn't a recognisable GitHub remote.
2162fn parse_github_remote(url: &str) -> Option<String> {
2163    // Accept both SSH (`git@github.com:org/repo`) and HTTPS
2164    // (`https://github.com/org/repo`) forms; everything after the host
2165    // separator is the path.
2166    let path = url
2167        .strip_prefix("git@github.com:")
2168        .or_else(|| url.strip_prefix("https://github.com/"))
2169        .or_else(|| url.strip_prefix("http://github.com/"))
2170        .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
2171    let path = path.strip_suffix(".git").unwrap_or(path);
2172    let path = path.trim_end_matches('/');
2173    // Must be exactly `org/repo` — both segments non-empty, one slash.
2174    let mut parts = path.split('/');
2175    let org = parts.next().filter(|s| !s.is_empty())?;
2176    let repo = parts.next().filter(|s| !s.is_empty())?;
2177    if parts.next().is_some() {
2178        return None;
2179    }
2180    Some(format!("{org}/{repo}"))
2181}
2182
2183/// Cheap recursive content fingerprint of a directory tree. Walks files
2184/// (respecting common ignore patterns) and folds `(path, mtime, len)`
2185/// into a 64-bit hash, then hex-formats it. Good enough to detect
2186/// "did anything change?" for auto-rebuild gating — not cryptographic.
2187fn fingerprint_dir(root: &Path) -> String {
2188    use std::hash::{Hash, Hasher};
2189    let mut hasher = std::collections::hash_map::DefaultHasher::new();
2190    let walker = ignore::WalkBuilder::new(root)
2191        .standard_filters(true)
2192        .hidden(true)
2193        .git_ignore(true)
2194        .build();
2195    for entry in walker.flatten() {
2196        if !entry.path().is_file() {
2197            continue;
2198        }
2199        let Ok(meta) = entry.metadata() else { continue };
2200        let mtime = meta
2201            .modified()
2202            .ok()
2203            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
2204            .map(|d| d.as_secs())
2205            .unwrap_or(0);
2206        entry.path().to_string_lossy().hash(&mut hasher);
2207        mtime.hash(&mut hasher);
2208        meta.len().hash(&mut hasher);
2209    }
2210    format!("local-{:016x}", hasher.finish())
2211}
2212
2213fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
2214    let out = Command::new("git")
2215        .args(["rev-parse", refspec])
2216        .current_dir(repo_path)
2217        .output()
2218        .context("git rev-parse failed")?;
2219    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
2220}
2221
2222fn now_iso() -> String {
2223    format_iso(SystemTime::now())
2224}
2225
2226fn format_iso(t: SystemTime) -> String {
2227    let secs = t
2228        .duration_since(SystemTime::UNIX_EPOCH)
2229        .map(|d| d.as_secs())
2230        .unwrap_or(0);
2231    // Lightweight RFC3339-ish formatter. Drop sub-second precision; matches Python isoformat(timespec=seconds).
2232    chrono_lite::format_secs(secs)
2233}
2234
2235fn parse_iso(s: &str) -> Option<SystemTime> {
2236    let secs = chrono_lite::parse_secs(s)?;
2237    SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
2238}
2239
2240fn relative_time(iso: &str) -> String {
2241    let Some(t) = parse_iso(iso) else {
2242        return "unknown".to_string();
2243    };
2244    let now = SystemTime::now();
2245    let delta = now.duration_since(t).unwrap_or_default().as_secs();
2246    if delta < 3600 {
2247        "just now".to_string()
2248    } else if delta < 86_400 {
2249        format!("{}h ago", delta / 3600)
2250    } else {
2251        format!("{}d ago", delta / 86_400)
2252    }
2253}
2254
2255/// Tiny self-contained ISO-8601 (seconds-precision) formatter so we
2256/// don't pull in `chrono` for a handful of timestamps.
2257mod chrono_lite {
2258    pub fn format_secs(secs: u64) -> String {
2259        // Civil-from-days algorithm (Howard Hinnant). Output: YYYY-MM-DDTHH:MM:SS.
2260        let days = (secs / 86_400) as i64;
2261        let time = secs % 86_400;
2262        let (y, mo, d) = days_to_civil(days + 719_468);
2263        let h = time / 3600;
2264        let m = (time / 60) % 60;
2265        let s = time % 60;
2266        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
2267    }
2268
2269    pub fn parse_secs(s: &str) -> Option<u64> {
2270        // Accept "YYYY-MM-DDTHH:MM:SS" (no zone) — same shape as format_secs output
2271        // and Python's datetime.isoformat(timespec="seconds").
2272        let bytes = s.as_bytes();
2273        if bytes.len() < 19 {
2274            return None;
2275        }
2276        let y: i64 = s.get(0..4)?.parse().ok()?;
2277        let mo: u32 = s.get(5..7)?.parse().ok()?;
2278        let d: u32 = s.get(8..10)?.parse().ok()?;
2279        let h: u64 = s.get(11..13)?.parse().ok()?;
2280        let m: u64 = s.get(14..16)?.parse().ok()?;
2281        let sc: u64 = s.get(17..19)?.parse().ok()?;
2282        let days = civil_to_days(y, mo, d) - 719_468;
2283        Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
2284    }
2285
2286    fn days_to_civil(z: i64) -> (i64, u32, u32) {
2287        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
2288        let doe = (z - era * 146_097) as u64;
2289        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
2290        let y = (yoe as i64) + era * 400;
2291        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2292        let mp = (5 * doy + 2) / 153;
2293        let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
2294        let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
2295        let y = if m <= 2 { y + 1 } else { y };
2296        (y, m, d)
2297    }
2298
2299    fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
2300        let y = if m <= 2 { y - 1 } else { y };
2301        let era = if y >= 0 { y } else { y - 399 } / 400;
2302        let yoe = (y - era * 400) as u64;
2303        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
2304        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2305        era * 146_097 + doe as i64
2306    }
2307}
2308
2309// silences unused-import-when-helper-only-via-json! macro check.
2310#[allow(dead_code)]
2311fn _json_keepalive() {
2312    let _ = json!({});
2313}
2314
2315#[cfg(test)]
2316mod tests {
2317    use super::*;
2318
2319    #[test]
2320    fn validates_repo_names() {
2321        assert!(validate_repo_name("pydata/xarray").is_ok());
2322        assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
2323        assert!(validate_repo_name("xarray").is_err());
2324        assert!(validate_repo_name("a/b/c").is_err());
2325        assert!(validate_repo_name("foo/bar; rm -rf").is_err());
2326    }
2327
2328    #[test]
2329    fn open_creates_layout() {
2330        let dir = tempfile::tempdir().unwrap();
2331        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2332        assert!(ws.repos_dir().is_dir());
2333    }
2334
2335    #[test]
2336    fn empty_list() {
2337        let dir = tempfile::tempdir().unwrap();
2338        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2339        let out = ws.repo_management(None, false, false, false, None);
2340        assert!(out.contains("No repos cloned yet"));
2341    }
2342
2343    #[test]
2344    fn invalid_repo_name_rejected() {
2345        let dir = tempfile::tempdir().unwrap();
2346        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2347        let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
2348        assert!(out.contains("Invalid repo name"));
2349    }
2350
2351    #[test]
2352    fn delete_unknown() {
2353        let dir = tempfile::tempdir().unwrap();
2354        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2355        let out = ws.repo_management(Some("nope/none"), true, false, false, None);
2356        assert!(out.contains("Nothing to delete"));
2357    }
2358
2359    #[test]
2360    fn iso_round_trip() {
2361        let now = SystemTime::now()
2362            .duration_since(SystemTime::UNIX_EPOCH)
2363            .unwrap()
2364            .as_secs();
2365        let s = chrono_lite::format_secs(now);
2366        let back = chrono_lite::parse_secs(&s).unwrap();
2367        assert_eq!(now, back);
2368    }
2369
2370    #[test]
2371    fn last_built_sha_round_trip() {
2372        let dir = tempfile::tempdir().unwrap();
2373        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2374        // Seed an inventory entry directly (clone_or_update needs git).
2375        ws.bump_access("acme/widgets", "cloned");
2376        assert_eq!(ws.last_built_sha("acme/widgets"), None);
2377        ws.record_built("acme/widgets", "abc1234deadbeef", None);
2378        assert_eq!(
2379            ws.last_built_sha("acme/widgets").as_deref(),
2380            Some("abc1234deadbeef")
2381        );
2382        // Survives an Workspace::open re-read (proves persistence).
2383        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2384        assert_eq!(
2385            ws2.last_built_sha("acme/widgets").as_deref(),
2386            Some("abc1234deadbeef")
2387        );
2388    }
2389
2390    #[test]
2391    fn inventory_loads_legacy_entries_without_sha_field() {
2392        let dir = tempfile::tempdir().unwrap();
2393        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2394        // Hand-craft an old-style inventory.json without `last_built_sha`.
2395        let legacy = r#"{
2396            "old/repo": {
2397                "cloned_at": "2024-01-01T00:00:00",
2398                "last_accessed": "2024-01-01T00:00:00",
2399                "access_count": 5,
2400                "stale": false
2401            }
2402        }"#;
2403        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
2404        // Re-open and confirm graceful read.
2405        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2406        assert_eq!(ws2.last_built_sha("old/repo"), None);
2407        let _ = ws;
2408    }
2409
2410    #[test]
2411    fn auto_rebuild_gate_skips_when_sha_matches() {
2412        use std::sync::atomic::{AtomicUsize, Ordering};
2413        let dir = tempfile::tempdir().unwrap();
2414        let calls = Arc::new(AtomicUsize::new(0));
2415        let calls_h = calls.clone();
2416        let hook: PostActivateHook = Arc::new(move |_path, _name| {
2417            calls_h.fetch_add(1, Ordering::SeqCst);
2418            Ok(())
2419        });
2420        // Build a workspace pointing at a tempdir with a fake repo dir,
2421        // then simulate consecutive activates. We can't drive clone_or_update
2422        // without git, so test the gating directly by tracking the SHA
2423        // record-then-re-record case via Workspace::record_built +
2424        // last_built_sha — the same predicate `activate` uses.
2425        let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
2426        // Seed inventory entry + initial sha record.
2427        ws.bump_access("acme/widgets", "cloned");
2428        ws.record_built("acme/widgets", "sha_one", None);
2429        assert_eq!(
2430            ws.last_built_sha("acme/widgets").as_deref(),
2431            Some("sha_one")
2432        );
2433        // Repeated record with the same value is idempotent (gating
2434        // logic uses last_built_sha as the source of truth).
2435        ws.record_built("acme/widgets", "sha_one", None);
2436        assert_eq!(
2437            ws.last_built_sha("acme/widgets").as_deref(),
2438            Some("sha_one")
2439        );
2440        // No hook calls have been driven directly — this test exercises
2441        // the persistence path that the gate consults.
2442        assert_eq!(calls.load(Ordering::SeqCst), 0);
2443    }
2444
2445    #[test]
2446    fn local_workspace_binds_root_immediately() {
2447        let dir = tempfile::tempdir().unwrap();
2448        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2449        assert_eq!(ws.kind(), WorkspaceKind::Local);
2450        assert!(ws.active_repo_path().is_some());
2451        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
2452    }
2453
2454    #[test]
2455    fn local_workspace_rejects_github_ops() {
2456        let dir = tempfile::tempdir().unwrap();
2457        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2458        let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
2459        assert!(out.contains("does not accept a repo name"));
2460        let out = ws.repo_management(None, true, false, false, None);
2461        assert!(out.contains("does not support `delete`"));
2462    }
2463
2464    #[test]
2465    fn local_workspace_update_rebuilds() {
2466        use std::sync::atomic::{AtomicUsize, Ordering};
2467        let dir = tempfile::tempdir().unwrap();
2468        // Drop a file so the fingerprint has something to hash.
2469        std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
2470        let calls = Arc::new(AtomicUsize::new(0));
2471        let calls_h = calls.clone();
2472        let hook: PostActivateHook = Arc::new(move |_p, _n| {
2473            calls_h.fetch_add(1, Ordering::SeqCst);
2474            Ok(())
2475        });
2476        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
2477        // First update: nothing built yet → hook fires.
2478        let _ = ws.repo_management(None, false, true, false, None);
2479        assert_eq!(calls.load(Ordering::SeqCst), 1);
2480        // Second update without changes → SHA matches → hook skipped.
2481        let out = ws.repo_management(None, false, true, false, None);
2482        assert_eq!(
2483            calls.load(Ordering::SeqCst),
2484            1,
2485            "auto-rebuild gate must skip"
2486        );
2487        assert!(out.contains("build skipped"));
2488    }
2489
2490    #[test]
2491    fn parses_github_remote_forms() {
2492        assert_eq!(
2493            parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
2494            Some("kkollsga/kglite")
2495        );
2496        assert_eq!(
2497            parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
2498            Some("kkollsga/kglite")
2499        );
2500        // No .git suffix, trailing slash.
2501        assert_eq!(
2502            parse_github_remote("https://github.com/acme/widget/").as_deref(),
2503            Some("acme/widget")
2504        );
2505        assert_eq!(
2506            parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
2507            Some("acme/widget")
2508        );
2509        // Non-github / malformed → None.
2510        assert_eq!(
2511            parse_github_remote("https://gitlab.com/acme/widget.git"),
2512            None
2513        );
2514        assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
2515        assert_eq!(parse_github_remote("not a url"), None);
2516    }
2517
2518    #[test]
2519    fn local_default_github_repo_uses_origin_remote() {
2520        let dir = tempfile::tempdir().unwrap();
2521        let root = dir.path();
2522        // Stand up a real git repo with a faked origin so default_github_repo
2523        // exercises the actual `git remote get-url` path.
2524        let git = |args: &[&str]| {
2525            Command::new("git")
2526                .arg("-C")
2527                .arg(root)
2528                .args(args)
2529                .output()
2530                .unwrap()
2531        };
2532        if !git(&["init"]).status.success() {
2533            // git unavailable in this environment — skip rather than fail.
2534            return;
2535        }
2536        git(&[
2537            "remote",
2538            "add",
2539            "origin",
2540            "https://github.com/acme/widget.git",
2541        ]);
2542        let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
2543        assert_eq!(
2544            ws.default_github_repo().as_deref(),
2545            Some("acme/widget"),
2546            "local default repo must come from the origin remote, not the inventory key"
2547        );
2548        // The inventory key remains the synthetic local name.
2549        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
2550    }
2551
2552    #[test]
2553    fn local_default_github_repo_none_without_remote() {
2554        let dir = tempfile::tempdir().unwrap();
2555        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2556        // No git remote → None, and crucially NOT Some("local/<dir>").
2557        let def = ws.default_github_repo();
2558        assert!(
2559            def.is_none(),
2560            "expected None for a non-git local root, got {def:?}"
2561        );
2562    }
2563
2564    #[test]
2565    fn set_root_dir_only_in_local_mode() {
2566        let dir = tempfile::tempdir().unwrap();
2567        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2568        let out = ws.set_root_dir(dir.path(), None);
2569        assert!(out.contains("only valid in local-workspace"));
2570    }
2571
2572    #[test]
2573    fn update_with_no_active_repo() {
2574        let dir = tempfile::tempdir().unwrap();
2575        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2576        let out = ws.repo_management(None, false, true, false, None);
2577        assert!(out.contains("No active repository"));
2578    }
2579
2580    #[test]
2581    fn set_root_dir_updates_active_path() {
2582        let dir = tempfile::tempdir().unwrap();
2583        let child = dir.path().join("child");
2584        std::fs::create_dir_all(&child).unwrap();
2585        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2586        let _ = ws.set_root_dir(&child, None);
2587        assert_eq!(
2588            ws.active_repo_path().unwrap(),
2589            child.canonicalize().unwrap(),
2590            "set_root_dir didn't update active_repo_path"
2591        );
2592    }
2593
2594    #[test]
2595    fn set_root_dir_post_activate_fires_against_new_root() {
2596        let dir = tempfile::tempdir().unwrap();
2597        let child = dir.path().join("child");
2598        std::fs::create_dir_all(&child).unwrap();
2599        std::fs::write(child.join("a.txt"), b"hi").unwrap();
2600        let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
2601        let seen = seen_path.clone();
2602        let hook: PostActivateHook = Arc::new(move |p, _n| {
2603            *seen.lock().unwrap() = Some(p.to_path_buf());
2604            Ok(())
2605        });
2606        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
2607        let _ = ws.set_root_dir(&child, None);
2608        assert_eq!(
2609            seen_path.lock().unwrap().clone().unwrap(),
2610            child.canonicalize().unwrap(),
2611            "post_activate hook saw the wrong root after set_root_dir"
2612        );
2613    }
2614
2615    /// Containment-test layout: `<base>/sandbox/child` and a sibling
2616    /// `<base>/outside`, with **every path canonicalized**. macOS tempdirs
2617    /// live under the `/var` → `/private/var` symlink, so an
2618    /// un-canonicalized boundary would make every `starts_with` assertion
2619    /// (and every raw-vs-canonical mutation) vacuously true.
2620    fn sandbox_layout() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
2621        let td = tempfile::tempdir().unwrap();
2622        let base = td.path().canonicalize().unwrap();
2623        let sandbox = base.join("sandbox");
2624        let inside = sandbox.join("child");
2625        let outside = base.join("outside");
2626        std::fs::create_dir_all(&inside).unwrap();
2627        std::fs::create_dir_all(&outside).unwrap();
2628        (td, sandbox, inside, outside)
2629    }
2630
2631    #[test]
2632    fn set_root_dir_outside_sandbox_root_rejected_and_active_root_unchanged() {
2633        let (_td, sandbox, _inside, outside) = sandbox_layout();
2634        let ws = Workspace::open_local(sandbox.clone(), None)
2635            .unwrap()
2636            .with_sandbox_root(&sandbox)
2637            .unwrap();
2638        let before = ws.active_repo_path().unwrap();
2639        assert_eq!(before, sandbox);
2640
2641        let out = ws.set_root_dir(&outside, None);
2642        assert!(
2643            out.contains("sandbox_root") && out.contains(&sandbox.display().to_string()),
2644            "rejection must name the boundary it violated, got: {out}"
2645        );
2646        // The failure that matters is a partial activation, not the string.
2647        assert_eq!(
2648            ws.active_repo_path().unwrap(),
2649            before,
2650            "a rejected swap must leave the active root untouched"
2651        );
2652    }
2653
2654    #[test]
2655    fn set_root_dir_inside_sandbox_root_activates() {
2656        let (_td, sandbox, inside, _outside) = sandbox_layout();
2657        let ws = Workspace::open_local(sandbox.clone(), None)
2658            .unwrap()
2659            .with_sandbox_root(&sandbox)
2660            .unwrap();
2661        let out = ws.set_root_dir(&inside, None);
2662        assert_eq!(
2663            ws.active_repo_path().unwrap(),
2664            inside,
2665            "a target inside the boundary must activate; set_root_dir said: {out}"
2666        );
2667    }
2668
2669    #[test]
2670    fn set_root_dir_dotdot_traversal_out_of_sandbox_rejected() {
2671        let (_td, sandbox, inside, outside) = sandbox_layout();
2672        let ws = Workspace::open_local(sandbox.clone(), None)
2673            .unwrap()
2674            .with_sandbox_root(&sandbox)
2675            .unwrap();
2676        // Lexically inside the boundary, actually outside it — only the
2677        // canonicalized path reveals the escape.
2678        let traversal = inside.join("..").join("..").join("outside");
2679        assert!(
2680            traversal.starts_with(&sandbox),
2681            "test is meaningless unless the raw path looks contained"
2682        );
2683        let out = ws.set_root_dir(&traversal, None);
2684        assert!(
2685            out.contains("sandbox_root"),
2686            "`..` escape must be rejected, got: {out}"
2687        );
2688        assert_eq!(ws.active_repo_path().unwrap(), sandbox);
2689        assert_ne!(ws.active_repo_path().unwrap(), outside);
2690    }
2691
2692    #[cfg(unix)]
2693    #[test]
2694    fn set_root_dir_symlink_out_of_sandbox_rejected() {
2695        let (_td, sandbox, _inside, outside) = sandbox_layout();
2696        let link = sandbox.join("escape-hatch");
2697        std::os::unix::fs::symlink(&outside, &link).unwrap();
2698        let ws = Workspace::open_local(sandbox.clone(), None)
2699            .unwrap()
2700            .with_sandbox_root(&sandbox)
2701            .unwrap();
2702        assert!(
2703            link.starts_with(&sandbox),
2704            "test is meaningless unless the raw path looks contained"
2705        );
2706        let out = ws.set_root_dir(&link, None);
2707        assert!(
2708            out.contains("sandbox_root"),
2709            "symlink escape must be rejected, got: {out}"
2710        );
2711        assert_eq!(ws.active_repo_path().unwrap(), sandbox);
2712    }
2713
2714    #[test]
2715    fn no_sandbox_root_configured_keeps_swaps_unbounded() {
2716        // The backwards-compatibility bar: without the opt-in key an
2717        // arbitrary sibling directory still activates, exactly as before.
2718        let (_td, sandbox, _inside, outside) = sandbox_layout();
2719        let ws = Workspace::open_local(sandbox, None).unwrap();
2720        let out = ws.set_root_dir(&outside, None);
2721        assert_eq!(
2722            ws.active_repo_path().unwrap(),
2723            outside,
2724            "unbounded default broken; set_root_dir said: {out}"
2725        );
2726    }
2727
2728    #[test]
2729    fn with_sandbox_root_rejects_active_root_outside_the_boundary() {
2730        // A manifest whose `root` sits outside its own `sandbox_root`
2731        // contradicts itself — it must die at boot, not at the first swap.
2732        let (_td, sandbox, _inside, outside) = sandbox_layout();
2733        let err = Workspace::open_local(outside.clone(), None)
2734            .unwrap()
2735            .with_sandbox_root(&sandbox)
2736            .map(|_| ())
2737            .expect_err("root outside the boundary must not boot");
2738        let msg = err.to_string();
2739        assert!(
2740            msg.contains(&sandbox.display().to_string())
2741                && msg.contains(&outside.display().to_string()),
2742            "boot error must name both the root and the boundary, got: {msg}"
2743        );
2744    }
2745
2746    #[test]
2747    fn with_sandbox_root_accepts_root_equal_to_the_boundary() {
2748        let (_td, sandbox, inside, _outside) = sandbox_layout();
2749        assert!(Workspace::open_local(sandbox.clone(), None)
2750            .unwrap()
2751            .with_sandbox_root(&sandbox)
2752            .is_ok());
2753        // …and a root strictly inside it.
2754        assert!(Workspace::open_local(inside, None)
2755            .unwrap()
2756            .with_sandbox_root(&sandbox)
2757            .is_ok());
2758    }
2759
2760    #[test]
2761    fn with_sandbox_root_rejects_github_workspaces_and_missing_dirs() {
2762        let (_td, sandbox, _inside, _outside) = sandbox_layout();
2763        let gh = Workspace::open(sandbox.join("gh"), 7, None).unwrap();
2764        assert!(
2765            gh.with_sandbox_root(&sandbox)
2766                .map(|_| ())
2767                .unwrap_err()
2768                .to_string()
2769                .contains("only valid for local"),
2770            "sandbox_root on a github workspace must be a loud error"
2771        );
2772        let missing = sandbox.join("nope");
2773        assert!(Workspace::open_local(sandbox, None)
2774            .unwrap()
2775            .with_sandbox_root(&missing)
2776            .is_err());
2777    }
2778
2779    // ------------------------------------------------------------------
2780    // Unanchored boot + client-root adoption (`workspace.adopt_client_roots`)
2781    // ------------------------------------------------------------------
2782
2783    #[test]
2784    fn unanchored_boot_binds_nothing_and_creates_nothing() {
2785        let td = tempfile::tempdir().unwrap();
2786        let base = td.path().canonicalize().unwrap();
2787        let ws = Workspace::open_local_unanchored(None).unwrap();
2788        assert!(ws.active_repo_path().is_none());
2789        assert!(ws.active_repo_name().is_none());
2790        assert_eq!(ws.root_ownership(), RootOwnership::Unowned);
2791        assert!(!ws.adopts_client_roots(), "the knob is opt-in");
2792        assert!(
2793            !base.join(".mcp-workspace").exists(),
2794            "an unanchored boot must not create an inventory dir anywhere"
2795        );
2796    }
2797
2798    #[test]
2799    fn open_local_is_operator_owned_from_the_start() {
2800        let td = tempfile::tempdir().unwrap();
2801        let ws = Workspace::open_local(td.path().to_path_buf(), None).unwrap();
2802        assert_eq!(
2803            ws.root_ownership(),
2804            RootOwnership::Operator,
2805            "a configured root is the operator's, which is what makes adoption fallback-only"
2806        );
2807    }
2808
2809    #[test]
2810    fn adopt_client_root_activates_and_defers_the_inventory_dir() {
2811        let td = tempfile::tempdir().unwrap();
2812        let base = td.path().canonicalize().unwrap();
2813        let project = base.join("project");
2814        std::fs::create_dir_all(&project).unwrap();
2815        let ws = Workspace::open_local_unanchored(None).unwrap();
2816
2817        ws.adopt_client_root(&project).unwrap();
2818
2819        assert_eq!(ws.active_repo_path().as_deref(), Some(project.as_path()));
2820        assert_eq!(ws.root_ownership(), RootOwnership::Adopted);
2821        assert!(
2822            project.join(".mcp-workspace").is_dir(),
2823            "the first activation must create the deferred inventory dir"
2824        );
2825        assert_eq!(ws.workspace_dir(), project.as_path());
2826    }
2827
2828    #[test]
2829    fn the_inventory_home_is_fixed_at_the_first_adoption() {
2830        let td = tempfile::tempdir().unwrap();
2831        let base = td.path().canonicalize().unwrap();
2832        let first = base.join("first");
2833        let second = base.join("second");
2834        std::fs::create_dir_all(&first).unwrap();
2835        std::fs::create_dir_all(&second).unwrap();
2836        let ws = Workspace::open_local_unanchored(None).unwrap();
2837        ws.adopt_client_root(&first).unwrap();
2838        ws.set_root_dir(&second, None);
2839
2840        assert_eq!(ws.active_repo_path().as_deref(), Some(second.as_path()));
2841        assert_eq!(
2842            ws.workspace_dir(),
2843            first.as_path(),
2844            "the inventory must survive later root swaps, exactly as it does after open_local"
2845        );
2846        assert!(!second.join(".mcp-workspace").exists());
2847    }
2848
2849    #[test]
2850    fn adoption_is_refused_once_the_operator_owns_the_root() {
2851        let td = tempfile::tempdir().unwrap();
2852        let base = td.path().canonicalize().unwrap();
2853        let configured = base.join("configured");
2854        let advertised = base.join("advertised");
2855        std::fs::create_dir_all(&configured).unwrap();
2856        std::fs::create_dir_all(&advertised).unwrap();
2857
2858        let ws = Workspace::open_local(configured.clone(), None).unwrap();
2859        let err = ws.adopt_client_root(&advertised).unwrap_err();
2860        assert!(err.contains("operator"), "unexpected reason: {err}");
2861        assert_eq!(
2862            ws.active_repo_path().as_deref(),
2863            Some(configured.as_path()),
2864            "a refused adoption must not touch the active root"
2865        );
2866    }
2867
2868    #[test]
2869    fn set_root_dir_claims_ownership_permanently() {
2870        let td = tempfile::tempdir().unwrap();
2871        let base = td.path().canonicalize().unwrap();
2872        let adopted = base.join("adopted");
2873        let operator = base.join("operator");
2874        let later = base.join("later");
2875        for d in [&adopted, &operator, &later] {
2876            std::fs::create_dir_all(d).unwrap();
2877        }
2878        let ws = Workspace::open_local_unanchored(None).unwrap();
2879        ws.adopt_client_root(&adopted).unwrap();
2880        assert_eq!(ws.root_ownership(), RootOwnership::Adopted);
2881
2882        ws.set_root_dir(&operator, None);
2883        assert_eq!(ws.root_ownership(), RootOwnership::Operator);
2884
2885        // Which is exactly what a later `roots/list_changed` runs into.
2886        assert!(ws.adopt_client_root(&later).is_err());
2887        assert_eq!(ws.active_repo_path().as_deref(), Some(operator.as_path()));
2888    }
2889
2890    #[test]
2891    fn a_failed_set_root_dir_does_not_claim_ownership() {
2892        let (_td, sandbox, inside, outside) = sandbox_layout();
2893        let ws = Workspace::open_local_unanchored(None)
2894            .unwrap()
2895            .with_sandbox_root(&sandbox)
2896            .unwrap();
2897        let msg = ws.set_root_dir(&outside, None);
2898        assert!(msg.contains("sandbox_root"), "unexpected message: {msg}");
2899        assert_eq!(
2900            ws.root_ownership(),
2901            RootOwnership::Unowned,
2902            "a rejected swap must not lock out adoption"
2903        );
2904        // ... so a valid client root can still be adopted afterwards.
2905        ws.adopt_client_root(&inside).unwrap();
2906        assert_eq!(ws.root_ownership(), RootOwnership::Adopted);
2907    }
2908
2909    #[test]
2910    fn adoption_goes_through_the_same_containment_check_as_set_root_dir() {
2911        let (_td, sandbox, inside, outside) = sandbox_layout();
2912        let ws = Workspace::open_local_unanchored(None)
2913            .unwrap()
2914            .with_sandbox_root(&sandbox)
2915            .unwrap();
2916
2917        let err = ws.adopt_client_root(&outside).unwrap_err();
2918        assert!(
2919            err.contains("sandbox_root") && err.contains(&sandbox.display().to_string()),
2920            "the rejection must name the boundary it violated: {err}"
2921        );
2922        assert!(
2923            ws.active_repo_path().is_none(),
2924            "a rejected adoption must leave the server unanchored"
2925        );
2926        assert_eq!(ws.root_ownership(), RootOwnership::Unowned);
2927
2928        ws.adopt_client_root(&inside).unwrap();
2929        assert_eq!(ws.active_repo_path().as_deref(), Some(inside.as_path()));
2930    }
2931
2932    #[test]
2933    fn adoption_rejects_a_dotdot_escape_from_the_sandbox() {
2934        let (_td, sandbox, inside, outside) = sandbox_layout();
2935        let ws = Workspace::open_local_unanchored(None)
2936            .unwrap()
2937            .with_sandbox_root(&sandbox)
2938            .unwrap();
2939        // Lexically inside, actually outside — only the canonicalized form
2940        // catches it.
2941        let traversal = inside.join("..").join("..").join("outside");
2942        assert!(ws.adopt_client_root(&traversal).is_err());
2943        assert!(ws.active_repo_path().is_none());
2944        let _ = outside;
2945    }
2946
2947    #[test]
2948    fn unanchored_refresh_before_adoption_is_a_clean_error() {
2949        let ws = Workspace::open_local_unanchored(None).unwrap();
2950        let out = ws.repo_management(None, false, true, false, None);
2951        // The exact message, not a family of them: `repo_management`
2952        // refuses here, before `activate` is ever called, and asserting
2953        // loosely would let a second (unreachable) error string pass for
2954        // coverage it does not have.
2955        assert_eq!(out, "No active local root.", "unexpected output: {out}");
2956        assert!(ws.active_repo_path().is_none());
2957    }
2958
2959    /// A build that fails must leave nothing of itself in a root the
2960    /// *client* proposed: no `.mcp-workspace/` created inside it, and no
2961    /// permanently-fixed inventory anchor pointing at a root that never
2962    /// activated.
2963    #[test]
2964    fn a_failed_first_adoption_writes_nothing_into_the_clients_root() {
2965        let td = tempfile::tempdir().unwrap();
2966        let base = td.path().canonicalize().unwrap();
2967        let rejected = base.join("rejected");
2968        let good = base.join("good");
2969        std::fs::create_dir_all(&rejected).unwrap();
2970        std::fs::create_dir_all(&good).unwrap();
2971
2972        let hook: PostActivateHook = {
2973            let rejected = rejected.clone();
2974            Arc::new(move |path, _name| {
2975                if path == rejected {
2976                    anyhow::bail!("builder refused this root");
2977                }
2978                Ok(())
2979            })
2980        };
2981        let ws = Workspace::open_local_unanchored(Some(hook)).unwrap();
2982
2983        assert!(
2984            ws.adopt_client_root(&rejected).is_err(),
2985            "the hook refused, so the adoption must fail"
2986        );
2987        assert!(
2988            !rejected.join(".mcp-workspace").exists(),
2989            "a failed adoption must not create a directory inside the client's root"
2990        );
2991        assert!(ws.active_repo_path().is_none(), "nothing activated");
2992        assert_eq!(
2993            ws.workspace_dir(),
2994            Path::new(""),
2995            "a failed attempt must not fix the inventory anchor"
2996        );
2997
2998        // ... so the *next* root, the first one that actually commits, is
2999        // the one that gets to own the inventory.
3000        ws.adopt_client_root(&good).unwrap();
3001        assert_eq!(ws.workspace_dir(), good.as_path());
3002        assert!(good.join(".mcp-workspace").is_dir());
3003        assert!(
3004            good.join(".mcp-workspace").join("inventory.json").is_file(),
3005            "the anchoring activation must still write its inventory receipt"
3006        );
3007        assert!(!rejected.join(".mcp-workspace").exists());
3008    }
3009
3010    /// `swap_root`'s mode check names the entry point that called it, so a
3011    /// rejection logged by the adoption path never claims to be about
3012    /// `set_root_dir`. (Reachable only through `swap_root` itself: a
3013    /// github workspace is `Operator`-owned, so `adopt_client_root`
3014    /// refuses one step earlier.)
3015    #[test]
3016    fn a_non_local_swap_names_the_caller_that_attempted_it() {
3017        let dir = tempfile::tempdir().unwrap();
3018        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
3019        let err = ws
3020            .swap_root(dir.path(), None, "adopt_client_root")
3021            .unwrap_err();
3022        assert_eq!(
3023            err, "adopt_client_root is only valid in local-workspace mode.",
3024            "the message must name the caller: {err}"
3025        );
3026    }
3027
3028    /// A one-shot gate: a flag plus a condvar, so two threads can be
3029    /// sequenced by *signal* rather than by sleeping.
3030    #[derive(Default)]
3031    struct Gate {
3032        open: Mutex<bool>,
3033        cv: std::sync::Condvar,
3034    }
3035
3036    impl Gate {
3037        fn open(&self) {
3038            *self.open.lock().unwrap() = true;
3039            self.cv.notify_all();
3040        }
3041
3042        fn wait(&self) {
3043            let mut open = self.open.lock().unwrap();
3044            while !*open {
3045                open = self.cv.wait(open).unwrap();
3046            }
3047        }
3048
3049        /// Wait, but give up after `limit`. Used where the *point* of the
3050        /// fix is that the awaited signal never comes.
3051        fn wait_until(&self, limit: std::time::Duration) {
3052            let open = self.open.lock().unwrap();
3053            let _ = self
3054                .cv
3055                .wait_timeout_while(open, limit, |open| !*open)
3056                .unwrap();
3057        }
3058    }
3059
3060    /// A client-root adoption may not interleave with an operator swap.
3061    ///
3062    /// The window this closes: adoption passes its ownership check, the
3063    /// operator's `set_root_dir` swaps to its own root, adoption's
3064    /// activation commits *last*, and the ownership publications land
3065    /// `Adopted` then `Operator`. The result was the **client's** root
3066    /// active while flagged as the operator's — after the operator's tool
3067    /// call had already returned.
3068    ///
3069    /// Sequencing is by signal, not by sleep. The operator's activation is
3070    /// held inside the transaction hook until the adoption's own hook
3071    /// reports that it got past the ownership check and into preparation —
3072    /// precisely the state the race needs, and the reason the failure is
3073    /// deterministic without the fix. *With* the fix the adoption never
3074    /// reaches that point (it waits on the swap ordering lock), so the
3075    /// operator's wait falls through on its belt; the outcome is then the
3076    /// same for every interleaving, which is the whole point.
3077    ///
3078    /// The belt is therefore paid on every run, and it bounds only how
3079    /// strictly this reproduces the *old* failure — never an assertion.
3080    /// It covers a thread spawn plus a canonicalize and a fingerprint of
3081    /// an empty directory, which is sub-millisecond work; 250ms leaves
3082    /// two orders of magnitude of headroom on a loaded machine, and was
3083    /// verified against the unfixed code.
3084    #[test]
3085    fn an_adoption_cannot_displace_a_concurrent_operator_swap() {
3086        let td = tempfile::tempdir().unwrap();
3087        let base = td.path().canonicalize().unwrap();
3088        let client_root = base.join("client");
3089        let operator_root = base.join("operator");
3090        std::fs::create_dir_all(&client_root).unwrap();
3091        std::fs::create_dir_all(&operator_root).unwrap();
3092
3093        let operator_in_hook = Arc::new(Gate::default());
3094        let adoption_in_hook = Arc::new(Gate::default());
3095        let hook: ActivationTransactionHook = {
3096            let client_root = client_root.clone();
3097            let operator_in_hook = operator_in_hook.clone();
3098            let adoption_in_hook = adoption_in_hook.clone();
3099            Arc::new(move |request| {
3100                if request.path() == client_root {
3101                    adoption_in_hook.open();
3102                } else {
3103                    operator_in_hook.open();
3104                    adoption_in_hook.wait_until(std::time::Duration::from_millis(250));
3105                }
3106                Ok(PreparedActivation::summary(None))
3107            })
3108        };
3109        // Unanchored, so adoption is permitted at all.
3110        let ws = Workspace::open_local_unanchored(None)
3111            .unwrap()
3112            .with_activation_transaction(hook);
3113
3114        let operator = {
3115            let ws = ws.clone();
3116            let target = operator_root.clone();
3117            std::thread::spawn(move || ws.set_root_dir(&target, None))
3118        };
3119        // The operator now holds an activation id and is mid-build.
3120        operator_in_hook.wait();
3121        let adoption = {
3122            let ws = ws.clone();
3123            let target = client_root.clone();
3124            std::thread::spawn(move || ws.adopt_client_root(&target))
3125        };
3126
3127        let operator_out = operator.join().unwrap();
3128        let adoption_out = adoption.join().unwrap();
3129
3130        assert_eq!(
3131            ws.active_repo_path().as_deref(),
3132            Some(operator_root.as_path()),
3133            "a client root displaced the operator's swap \
3134             (operator said: {operator_out}; adoption said: {adoption_out:?})"
3135        );
3136        assert_eq!(
3137            ws.root_ownership(),
3138            RootOwnership::Operator,
3139            "the surviving root must also be flagged as the operator's"
3140        );
3141        assert!(
3142            adoption_out.is_err(),
3143            "the adoption ran second and must have been refused: {adoption_out:?}"
3144        );
3145    }
3146
3147    /// Stand up an unanchored local workspace with a client root already
3148    /// adopted, plus a gated transaction hook. Returns the workspace, the
3149    /// adopted (client) root, the root the operator will swap to, and the
3150    /// gates for the operator's and the refresh's builds.
3151    ///
3152    /// The hook gates each root independently, so a test opens only the
3153    /// gates its interleaving needs; a build whose gates are never touched
3154    /// runs straight through.
3155    #[allow(clippy::type_complexity)]
3156    fn adopted_workspace_with_gated_builds(
3157        base: &Path,
3158    ) -> (
3159        Workspace,
3160        PathBuf,
3161        PathBuf,
3162        (Arc<Gate>, Arc<Gate>),
3163        (Arc<Gate>, Arc<Gate>),
3164    ) {
3165        let client_root = base.join("client");
3166        let operator_root = base.join("operator");
3167        std::fs::create_dir_all(&client_root).unwrap();
3168        std::fs::create_dir_all(&operator_root).unwrap();
3169
3170        let operator_gates = (Arc::new(Gate::default()), Arc::new(Gate::default()));
3171        let refresh_gates = (Arc::new(Gate::default()), Arc::new(Gate::default()));
3172        let hook: ActivationTransactionHook = {
3173            let operator_root = operator_root.clone();
3174            let (operator_in_hook, release_operator) = operator_gates.clone();
3175            let (refresh_in_hook, release_refresh) = refresh_gates.clone();
3176            let adopted = Arc::new(Mutex::new(false));
3177            Arc::new(move |request| {
3178                if request.path() == operator_root {
3179                    operator_in_hook.open();
3180                    release_operator.wait();
3181                } else {
3182                    // The client root is built twice: once by the adoption
3183                    // that establishes it, once by the refresh under test.
3184                    // Only the second build is gated.
3185                    let mut adopted = adopted.lock().unwrap();
3186                    if *adopted {
3187                        refresh_in_hook.open();
3188                        release_refresh.wait();
3189                    }
3190                    *adopted = true;
3191                }
3192                Ok(PreparedActivation::summary(None))
3193            })
3194        };
3195        let ws = Workspace::open_local_unanchored(None)
3196            .unwrap()
3197            .with_activation_transaction(hook);
3198        ws.adopt_client_root(&client_root).unwrap();
3199        assert_eq!(ws.root_ownership(), RootOwnership::Adopted);
3200        (
3201            ws,
3202            client_root,
3203            operator_root,
3204            operator_gates,
3205            refresh_gates,
3206        )
3207    }
3208
3209    /// A local `repo_management(update=true)` refresh must not cancel an
3210    /// operator root swap that is still building.
3211    ///
3212    /// The window this closes: the refresh reaches `activate` without
3213    /// touching the swap ordering lock (it is neither an operator swap nor
3214    /// an adoption), so it used to allocate a *newer* generation and, by
3215    /// re-committing the binding it found, supersede the operator's
3216    /// in-flight swap. Final state: the client's adopted root active while
3217    /// `root_ownership()` said `Operator` — the exact flag/root
3218    /// disagreement the swap ordering lock was added to eliminate,
3219    /// reachable by the same semi-trusted party through another entry
3220    /// point.
3221    ///
3222    /// Sequenced by signal: the operator is held inside its build hook
3223    /// until the refresh has run to completion, so the refresh's
3224    /// generation is provably newer and its commit provably first.
3225    #[test]
3226    fn a_refresh_cannot_cancel_an_in_flight_operator_root_swap() {
3227        let td = tempfile::tempdir().unwrap();
3228        let base = td.path().canonicalize().unwrap();
3229        let (ws, client_root, operator_root, (operator_in_hook, release_operator), refresh_gates) =
3230            adopted_workspace_with_gated_builds(&base);
3231        // This test needs the refresh to run *through*, not to park: open
3232        // its release gate up front so its build hook falls straight
3233        // through. It does reach the hook even though the skip gate fires
3234        // — a transaction hook is called for a `Reuse` request too, since
3235        // it still has to produce that request's summary.
3236        refresh_gates.1.open();
3237
3238        let operator = {
3239            let ws = ws.clone();
3240            let target = operator_root.clone();
3241            std::thread::spawn(move || ws.set_root_dir(&target, None))
3242        };
3243        // The operator holds an activation id and is mid-build.
3244        operator_in_hook.wait();
3245        // A whole refresh — newer id, commits first — while it waits.
3246        let refresh_out = ws.repo_management(None, false, true, false, None);
3247        release_operator.open();
3248        let operator_out = operator.join().unwrap();
3249
3250        assert_eq!(
3251            ws.active_repo_path().as_deref(),
3252            Some(operator_root.as_path()),
3253            "a refresh of the adopted root cancelled the operator's swap \
3254             (operator said: {operator_out}; refresh said: {refresh_out})"
3255        );
3256        assert_eq!(
3257            ws.root_ownership(),
3258            RootOwnership::Operator,
3259            "the active root and the ownership flag must name the same party"
3260        );
3261        assert!(
3262            !operator_out.contains("superseded"),
3263            "a refresh must not supersede a root swap: {operator_out}"
3264        );
3265        assert!(
3266            operator_out.contains(&operator_root.display().to_string()),
3267            "the operator's swap must report the root it committed: {operator_out}"
3268        );
3269        // The refresh itself was legitimate at the time and is reported as
3270        // such — it rebuilt what was then bound.
3271        assert!(
3272            refresh_out.contains(&client_root.display().to_string()),
3273            "the refresh rebuilt the binding it found: {refresh_out}"
3274        );
3275    }
3276
3277    /// The narrower variant, and the worse one: the operator's swap
3278    /// reports **full success** naming its root, and a refresh that
3279    /// started earlier then silently reverts to the previous one.
3280    ///
3281    /// It is reachable because a refresh's target is read from live state
3282    /// before its generation exists, so nothing about the generation gate
3283    /// ties the root it commits to the root that is bound when it commits.
3284    /// Here the refresh holds the newest generation *and* commits last —
3285    /// the gate waves it through — and only the binding compare-and-swap
3286    /// stops it.
3287    ///
3288    /// Both halves of the fix are load-bearing: without the root-intent
3289    /// split the operator is superseded and never commits at all; without
3290    /// the commit-time expectation check the refresh publishes the adopted
3291    /// root over a swap whose caller was already told it succeeded.
3292    #[test]
3293    fn a_refresh_cannot_revert_a_root_swap_that_already_reported_success() {
3294        let td = tempfile::tempdir().unwrap();
3295        let base = td.path().canonicalize().unwrap();
3296        let (
3297            ws,
3298            client_root,
3299            operator_root,
3300            (operator_in_hook, release_operator),
3301            (refresh_in_hook, release_refresh),
3302        ) = adopted_workspace_with_gated_builds(&base);
3303
3304        let operator = {
3305            let ws = ws.clone();
3306            let target = operator_root.clone();
3307            std::thread::spawn(move || ws.set_root_dir(&target, None))
3308        };
3309        operator_in_hook.wait();
3310        // `force_rebuild` so the refresh really enters the build hook
3311        // (the adopted root is already built, so it would otherwise skip)
3312        // — that is where it is parked, holding the newest generation.
3313        let refresh = {
3314            let ws = ws.clone();
3315            std::thread::spawn(move || ws.repo_management(None, false, true, true, None))
3316        };
3317        refresh_in_hook.wait();
3318
3319        release_operator.open();
3320        let operator_out = operator.join().unwrap();
3321        // Captured before the refresh is let go: this is the state the
3322        // operator's caller was told about.
3323        let reported_root = ws.active_repo_path();
3324        release_refresh.open();
3325        let refresh_out = refresh.join().unwrap();
3326
3327        assert_eq!(
3328            reported_root.as_deref(),
3329            Some(operator_root.as_path()),
3330            "the operator's swap must commit even though a refresh holds a \
3331             newer generation (operator said: {operator_out})"
3332        );
3333        assert!(
3334            !operator_out.contains("superseded")
3335                && operator_out.contains(&operator_root.display().to_string()),
3336            "the operator's swap must report the root it committed: {operator_out}"
3337        );
3338        assert_eq!(
3339            ws.active_repo_path().as_deref(),
3340            Some(operator_root.as_path()),
3341            "a refresh reverted a root swap that had already reported success \
3342             (refresh said: {refresh_out})"
3343        );
3344        assert_eq!(
3345            ws.root_ownership(),
3346            RootOwnership::Operator,
3347            "the active root and the ownership flag must name the same party"
3348        );
3349        assert!(
3350            refresh_out.contains("abandoned")
3351                && refresh_out.contains(&operator_root.display().to_string())
3352                && !refresh_out.contains(&format!("at {}", client_root.display())),
3353            "the refresh must say it was abandoned, and name what displaced it: {refresh_out}"
3354        );
3355    }
3356
3357    /// The mirror image: an operator swap issued while an adoption is
3358    /// already mid-activation still ends as the operator's root. The
3359    /// adoption completes first (it holds the swap lock), the operator's
3360    /// swap then lands on top of it — no ordering leaves the client's root
3361    /// active.
3362    #[test]
3363    fn an_operator_swap_wins_when_an_adoption_is_already_mid_activation() {
3364        let td = tempfile::tempdir().unwrap();
3365        let base = td.path().canonicalize().unwrap();
3366        let client_root = base.join("client");
3367        let operator_root = base.join("operator");
3368        std::fs::create_dir_all(&client_root).unwrap();
3369        std::fs::create_dir_all(&operator_root).unwrap();
3370
3371        let adoption_in_hook = Arc::new(Gate::default());
3372        let release_adoption = Arc::new(Gate::default());
3373        let hook: ActivationTransactionHook = {
3374            let client_root = client_root.clone();
3375            let adoption_in_hook = adoption_in_hook.clone();
3376            let release_adoption = release_adoption.clone();
3377            Arc::new(move |request| {
3378                if request.path() == client_root {
3379                    adoption_in_hook.open();
3380                    release_adoption.wait();
3381                }
3382                Ok(PreparedActivation::summary(None))
3383            })
3384        };
3385        let ws = Workspace::open_local_unanchored(None)
3386            .unwrap()
3387            .with_activation_transaction(hook);
3388
3389        let adoption = {
3390            let ws = ws.clone();
3391            let target = client_root.clone();
3392            std::thread::spawn(move || ws.adopt_client_root(&target))
3393        };
3394        adoption_in_hook.wait();
3395        let operator = {
3396            let ws = ws.clone();
3397            let target = operator_root.clone();
3398            std::thread::spawn(move || ws.set_root_dir(&target, None))
3399        };
3400        release_adoption.open();
3401
3402        let adoption_out = adoption.join().unwrap();
3403        let operator_out = operator.join().unwrap();
3404
3405        assert_eq!(
3406            ws.active_repo_path().as_deref(),
3407            Some(operator_root.as_path()),
3408            "the operator's swap must survive an in-flight adoption \
3409             (adoption said: {adoption_out:?}; operator said: {operator_out})"
3410        );
3411        assert_eq!(ws.root_ownership(), RootOwnership::Operator);
3412    }
3413
3414    #[test]
3415    fn activation_summary_appended_to_activate_message() {
3416        let dir = tempfile::tempdir().unwrap();
3417        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
3418        let summary: ActivationSummaryHook =
3419            Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
3420        let ws = Workspace::open_local(dir.path().to_path_buf(), None)
3421            .unwrap()
3422            .with_activation_summary(summary);
3423        let out = ws.repo_management(None, false, true, false, None);
3424        assert!(
3425            out.contains("Graph ready: 3 Functions."),
3426            "activation message should include the summary; got: {out}"
3427        );
3428    }
3429
3430    #[test]
3431    fn activation_summary_absent_when_not_configured() {
3432        let dir = tempfile::tempdir().unwrap();
3433        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
3434        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
3435        let out = ws.repo_management(None, false, true, false, None);
3436        assert!(!out.contains("Graph ready"));
3437        assert!(
3438            out.contains(" at "),
3439            "expected the terse default message; got: {out}"
3440        );
3441    }
3442
3443    #[test]
3444    fn hook_fires_once_per_process_even_when_sha_matches() {
3445        use std::sync::atomic::{AtomicUsize, Ordering};
3446        // Local mode fingerprints the dir instead of a git SHA, so we can
3447        // drive the real `activate` path without git. A stable file keeps
3448        // the fingerprint constant across both simulated processes.
3449        let dir = tempfile::tempdir().unwrap();
3450        std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
3451
3452        let calls = Arc::new(AtomicUsize::new(0));
3453        let make_hook = || -> PostActivateHook {
3454            let c = calls.clone();
3455            Arc::new(move |_p, _n| {
3456                c.fetch_add(1, Ordering::SeqCst);
3457                Ok(())
3458            })
3459        };
3460
3461        // --- Process 1 ---------------------------------------------------
3462        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
3463        // First activate (fingerprint not yet recorded) → hook fires.
3464        let _ = ws.repo_management(None, false, true, false, None);
3465        assert_eq!(
3466            calls.load(Ordering::SeqCst),
3467            1,
3468            "first activate must hydrate"
3469        );
3470        // Second activate, same process, unchanged fingerprint → cheap-skip.
3471        let out = ws.repo_management(None, false, true, false, None);
3472        assert_eq!(
3473            calls.load(Ordering::SeqCst),
3474            1,
3475            "repeat activate in same process must skip the hook"
3476        );
3477        assert!(
3478            out.contains("build skipped"),
3479            "expected skip suffix, got: {out}"
3480        );
3481        drop(ws);
3482
3483        // --- Process 2 (restart) ----------------------------------------
3484        // Same dir → inventory.json + last_built_sha persist, but the
3485        // in-memory hydration set does not. The first activate here must
3486        // re-fire the hook to rehydrate the consumer's in-memory state.
3487        let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
3488        assert!(
3489            ws2.last_built_sha(&ws2.active_repo_name().unwrap())
3490                .is_some(),
3491            "sanity: last_built_sha should survive the restart"
3492        );
3493        let _ = ws2.repo_management(None, false, true, false, None);
3494        assert_eq!(
3495            calls.load(Ordering::SeqCst),
3496            2,
3497            "fresh process must re-fire the hook even when the SHA matches"
3498        );
3499    }
3500
3501    #[test]
3502    fn a_b_a_swap_rebuilds_intervening_root() {
3503        // Regression for the single-slot-consumer stale-graph bug: an
3504        // A→B→A swap must rebuild A on the second bind, because activating
3505        // B overwrote the consumer's single live slot. Before the fix the
3506        // skip gate keyed off "A was hydrated at some point this process"
3507        // and wrongly skipped, leaving B's product live under A's name.
3508        use std::sync::atomic::{AtomicUsize, Ordering};
3509        let root = tempfile::tempdir().unwrap();
3510        let a = root.path().join("projA");
3511        let b = root.path().join("projB");
3512        std::fs::create_dir_all(&a).unwrap();
3513        std::fs::create_dir_all(&b).unwrap();
3514        // Stable, distinct contents so each root's fingerprint holds
3515        // constant across re-binds (so `action == "current"` on the
3516        // second bind of A — the exact condition the gate keys on).
3517        std::fs::write(a.join("a.txt"), b"alpha").unwrap();
3518        std::fs::write(b.join("b.txt"), b"beta").unwrap();
3519
3520        // The hook records which root it last built into the single slot,
3521        // mirroring a single-active-graph consumer.
3522        let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
3523        let built_h = built.clone();
3524        let calls = Arc::new(AtomicUsize::new(0));
3525        let calls_h = calls.clone();
3526        let hook: PostActivateHook = Arc::new(move |p, _n| {
3527            *built_h.lock().unwrap() = Some(p.to_path_buf());
3528            calls_h.fetch_add(1, Ordering::SeqCst);
3529            Ok(())
3530        });
3531
3532        let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
3533        // open_local binds A but doesn't fire the hook; first set_root_dir(A)
3534        // hydrates it.
3535        let _ = ws.set_root_dir(&a, None);
3536        assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
3537        assert_eq!(
3538            built.lock().unwrap().clone(),
3539            Some(a.canonicalize().unwrap())
3540        );
3541
3542        let _ = ws.set_root_dir(&b, None);
3543        assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
3544        assert_eq!(
3545            built.lock().unwrap().clone(),
3546            Some(b.canonicalize().unwrap())
3547        );
3548
3549        // The bug: re-binding A must rebuild (slot currently holds B), not
3550        // cheap-skip. The single slot must end up holding A again.
3551        let out = ws.set_root_dir(&a, None);
3552        assert_eq!(
3553            calls.load(Ordering::SeqCst),
3554            3,
3555            "A→B→A must rebuild A; the intervening B overwrote the live slot"
3556        );
3557        assert!(
3558            !out.contains("build skipped"),
3559            "re-bind of a non-active root must not skip; got: {out}"
3560        );
3561        assert_eq!(
3562            built.lock().unwrap().clone(),
3563            Some(a.canonicalize().unwrap()),
3564            "after A→B→A the live slot must hold A, not B"
3565        );
3566
3567        // And an immediate re-bind of the *currently active* root (A→A)
3568        // still cheap-skips — the win the gate was added for is preserved.
3569        let out = ws.set_root_dir(&a, None);
3570        assert_eq!(
3571            calls.load(Ordering::SeqCst),
3572            3,
3573            "re-binding the already-active root must skip the hook"
3574        );
3575        assert!(
3576            out.contains("build skipped"),
3577            "expected skip suffix, got: {out}"
3578        );
3579    }
3580
3581    #[test]
3582    fn transaction_slow_a_fast_b_discards_stale_build_and_keeps_responses_coherent() {
3583        use std::sync::Barrier;
3584
3585        #[derive(Debug, Clone, PartialEq, Eq)]
3586        struct Installed {
3587            id: ActivationId,
3588            path: PathBuf,
3589        }
3590
3591        let root = tempfile::tempdir().unwrap();
3592        let a = root.path().join("slow-a");
3593        let b = root.path().join("fast-b");
3594        std::fs::create_dir_all(&a).unwrap();
3595        std::fs::create_dir_all(&b).unwrap();
3596        std::fs::write(a.join("a.txt"), b"a").unwrap();
3597        std::fs::write(b.join("b.txt"), b"b").unwrap();
3598        let a = a.canonicalize().unwrap();
3599        let b = b.canonicalize().unwrap();
3600
3601        let a_entered = Arc::new(Barrier::new(2));
3602        let release_a = Arc::new(Barrier::new(2));
3603        let installed: Arc<Mutex<Option<Installed>>> = Arc::new(Mutex::new(None));
3604        let hook: ActivationTransactionHook = {
3605            let a = a.clone();
3606            let a_entered = a_entered.clone();
3607            let release_a = release_a.clone();
3608            let installed = installed.clone();
3609            Arc::new(move |request| {
3610                if request.path() == a {
3611                    a_entered.wait();
3612                    release_a.wait();
3613                }
3614                let product = Installed {
3615                    id: request.id(),
3616                    path: request.path().to_path_buf(),
3617                };
3618                let installed = installed.clone();
3619                Ok(PreparedActivation::new(move || {
3620                    *installed.lock().unwrap() = Some(product.clone());
3621                    Ok(Some(format!(
3622                        "product {} for {}",
3623                        product.id,
3624                        product.path.display()
3625                    )))
3626                }))
3627            })
3628        };
3629        let ws = Workspace::open_local(a.clone(), None)
3630            .unwrap()
3631            .with_activation_transaction(hook);
3632
3633        let slow_ws = ws.clone();
3634        let slow_a = a.clone();
3635        let slow = std::thread::spawn(move || slow_ws.set_root_dir(&slow_a, None));
3636        a_entered.wait();
3637
3638        let fast_ws = ws.clone();
3639        let fast_b = b.clone();
3640        let fast = std::thread::spawn(move || fast_ws.set_root_dir(&fast_b, None));
3641        let fast_out = fast.join().unwrap();
3642        release_a.wait();
3643        let slow_out = slow.join().unwrap();
3644
3645        assert!(
3646            fast_out.contains(&b.display().to_string())
3647                && fast_out.contains("product 2")
3648                && !fast_out.contains(&a.display().to_string()),
3649            "fast request response must describe only its own committed product: {fast_out}"
3650        );
3651        assert!(
3652            slow_out.contains("request 1")
3653                && slow_out.contains("superseded by request 2")
3654                && !slow_out.contains("product 1"),
3655            "stale request must report supersession, not a false activation: {slow_out}"
3656        );
3657        assert_eq!(ws.active_repo_path(), Some(b.clone()));
3658        assert_eq!(installed.lock().unwrap().as_ref().unwrap().path, b);
3659        assert_eq!(
3660            ws.inner
3661                .state
3662                .read()
3663                .unwrap()
3664                .active_build
3665                .as_ref()
3666                .unwrap()
3667                .activation_id,
3668            ActivationId(2),
3669            "latest request must own the final framework state"
3670        );
3671    }
3672
3673    #[test]
3674    fn legacy_callbacks_are_serialized_through_build_and_summary() {
3675        use std::sync::Barrier;
3676
3677        let root = tempfile::tempdir().unwrap();
3678        let a = root.path().join("slow-a");
3679        let b = root.path().join("queued-b");
3680        std::fs::create_dir_all(&a).unwrap();
3681        std::fs::create_dir_all(&b).unwrap();
3682        let a = a.canonicalize().unwrap();
3683        let b = b.canonicalize().unwrap();
3684        let a_entered = Arc::new(Barrier::new(2));
3685        let release_a = Arc::new(Barrier::new(2));
3686        let installed: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
3687        let hook: PostActivateHook = {
3688            let a = a.clone();
3689            let a_entered = a_entered.clone();
3690            let release_a = release_a.clone();
3691            let installed = installed.clone();
3692            Arc::new(move |path, _name| {
3693                if path == a {
3694                    a_entered.wait();
3695                    release_a.wait();
3696                }
3697                *installed.lock().unwrap() = Some(path.to_path_buf());
3698                Ok(())
3699            })
3700        };
3701        let summary: ActivationSummaryHook = {
3702            let installed = installed.clone();
3703            Arc::new(move |_path, _name| {
3704                installed
3705                    .lock()
3706                    .unwrap()
3707                    .as_ref()
3708                    .map(|path| format!("legacy product {}", path.display()))
3709            })
3710        };
3711        let ws = Workspace::open_local(a.clone(), Some(hook))
3712            .unwrap()
3713            .with_activation_summary(summary);
3714
3715        let a_ws = ws.clone();
3716        let a_root = a.clone();
3717        let a_thread = std::thread::spawn(move || a_ws.set_root_dir(&a_root, None));
3718        a_entered.wait();
3719        let b_ws = ws.clone();
3720        let b_root = b.clone();
3721        let b_thread = std::thread::spawn(move || b_ws.set_root_dir(&b_root, None));
3722        release_a.wait();
3723        let a_out = a_thread.join().unwrap();
3724        let b_out = b_thread.join().unwrap();
3725
3726        assert!(
3727            a_out.contains(&format!("legacy product {}", a.display()))
3728                && !a_out.contains(&format!("legacy product {}", b.display())),
3729            "legacy A response crossed activation products: {a_out}"
3730        );
3731        assert!(
3732            b_out.contains(&format!("legacy product {}", b.display()))
3733                && !b_out.contains(&format!("legacy product {}", a.display())),
3734            "legacy B response crossed activation products: {b_out}"
3735        );
3736        assert_eq!(ws.active_repo_path(), Some(b.clone()));
3737        assert_eq!(*installed.lock().unwrap(), Some(b));
3738    }
3739
3740    #[test]
3741    fn transaction_same_root_plain_vs_revisions_is_generation_ordered() {
3742        use std::sync::Barrier;
3743
3744        let Some((_dir, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
3745            return;
3746        };
3747        let root = root.canonicalize().unwrap();
3748        let plain_entered = Arc::new(Barrier::new(2));
3749        let release_plain = Arc::new(Barrier::new(2));
3750        let installed: Arc<Mutex<Option<(ActivationId, ActivationBuild)>>> =
3751            Arc::new(Mutex::new(None));
3752        let hook: ActivationTransactionHook = {
3753            let plain_entered = plain_entered.clone();
3754            let release_plain = release_plain.clone();
3755            let installed = installed.clone();
3756            Arc::new(move |request| {
3757                if matches!(request.build(), ActivationBuild::Plain) {
3758                    plain_entered.wait();
3759                    release_plain.wait();
3760                }
3761                let id = request.id();
3762                let build = request.build().clone();
3763                let installed = installed.clone();
3764                Ok(PreparedActivation::new(move || {
3765                    *installed.lock().unwrap() = Some((id, build.clone()));
3766                    Ok(Some(format!("installed request {id}: {build:?}")))
3767                }))
3768            })
3769        };
3770        let ws = Workspace::open_local(root.clone(), None)
3771            .unwrap()
3772            .with_activation_transaction(hook);
3773
3774        let plain_ws = ws.clone();
3775        let plain_root = root.clone();
3776        let plain = std::thread::spawn(move || plain_ws.set_root_dir(&plain_root, None));
3777        plain_entered.wait();
3778
3779        let revs_ws = ws.clone();
3780        let revs_root = root.clone();
3781        let revs = std::thread::spawn(move || {
3782            revs_ws.set_root_dir(&revs_root, Some(&RevsRequest::Count(2)))
3783        });
3784        let revs_out = revs.join().unwrap();
3785        release_plain.wait();
3786        let plain_out = plain.join().unwrap();
3787
3788        assert!(revs_out.contains("revs: v1.0.0, v2.0.0, HEAD"));
3789        assert!(revs_out.contains("installed request 2: Revisions"));
3790        assert!(plain_out.contains("superseded by request 2"));
3791        let state = ws.inner.state.read().unwrap();
3792        assert_eq!(state.active_repo_path.as_deref(), Some(root.as_path()));
3793        assert_eq!(
3794            state
3795                .active_build
3796                .as_ref()
3797                .and_then(|built| built.resolved_revs.clone()),
3798            Some(vec!["v1.0.0".into(), "v2.0.0".into(), "HEAD".into()])
3799        );
3800        assert!(matches!(
3801            installed.lock().unwrap().as_ref(),
3802            Some((ActivationId(2), ActivationBuild::Revisions(_)))
3803        ));
3804    }
3805
3806    #[test]
3807    fn transaction_current_failure_preserves_committed_source_and_product() {
3808        #[derive(Debug, Clone, PartialEq, Eq)]
3809        struct Installed(ActivationId, PathBuf);
3810
3811        let root = tempfile::tempdir().unwrap();
3812        let good = root.path().join("good");
3813        let broken = root.path().join("broken");
3814        std::fs::create_dir_all(&good).unwrap();
3815        std::fs::create_dir_all(&broken).unwrap();
3816        std::fs::write(good.join("good.txt"), b"good").unwrap();
3817        std::fs::write(broken.join("broken.txt"), b"broken").unwrap();
3818        let good = good.canonicalize().unwrap();
3819        let broken = broken.canonicalize().unwrap();
3820
3821        let installed: Arc<Mutex<Option<Installed>>> = Arc::new(Mutex::new(None));
3822        let hook: ActivationTransactionHook = {
3823            let broken = broken.clone();
3824            let installed = installed.clone();
3825            Arc::new(move |request| {
3826                if request.path() == broken {
3827                    anyhow::bail!("builder rejected broken root");
3828                }
3829                let product = Installed(request.id(), request.path().to_path_buf());
3830                let installed = installed.clone();
3831                Ok(PreparedActivation::new(move || {
3832                    *installed.lock().unwrap() = Some(product.clone());
3833                    Ok(Some(format!("installed request {}", product.0)))
3834                }))
3835            })
3836        };
3837        let ws = Workspace::open_local(good.clone(), None)
3838            .unwrap()
3839            .with_activation_transaction(hook);
3840
3841        let good_out = ws.set_root_dir(&good, None);
3842        assert!(good_out.contains("installed request 1"));
3843        let broken_out = ws.set_root_dir(&broken, None);
3844        assert!(
3845            broken_out.contains("request 2")
3846                && broken_out.contains("failed during preparation")
3847                && broken_out.contains("builder rejected broken root"),
3848            "failure must be explicit and request-scoped: {broken_out}"
3849        );
3850        assert_eq!(ws.active_repo_path(), Some(good.clone()));
3851        assert_eq!(installed.lock().unwrap().as_ref().unwrap().1, good);
3852    }
3853
3854    #[test]
3855    fn transaction_stale_failure_reports_superseded_not_current_failure() {
3856        use std::sync::Barrier;
3857
3858        let root = tempfile::tempdir().unwrap();
3859        let slow = root.path().join("slow-failure");
3860        let fast = root.path().join("fast-success");
3861        std::fs::create_dir_all(&slow).unwrap();
3862        std::fs::create_dir_all(&fast).unwrap();
3863        let slow = slow.canonicalize().unwrap();
3864        let fast = fast.canonicalize().unwrap();
3865        let slow_entered = Arc::new(Barrier::new(2));
3866        let release_slow = Arc::new(Barrier::new(2));
3867        let hook: ActivationTransactionHook = {
3868            let slow = slow.clone();
3869            let slow_entered = slow_entered.clone();
3870            let release_slow = release_slow.clone();
3871            Arc::new(move |request| {
3872                if request.path() == slow {
3873                    slow_entered.wait();
3874                    release_slow.wait();
3875                    anyhow::bail!("late preparation failure");
3876                }
3877                Ok(PreparedActivation::summary(Some(format!(
3878                    "committed request {}",
3879                    request.id()
3880                ))))
3881            })
3882        };
3883        let ws = Workspace::open_local(slow.clone(), None)
3884            .unwrap()
3885            .with_activation_transaction(hook);
3886
3887        let slow_ws = ws.clone();
3888        let slow_root = slow.clone();
3889        let slow_thread = std::thread::spawn(move || slow_ws.set_root_dir(&slow_root, None));
3890        slow_entered.wait();
3891        let fast_out = ws.set_root_dir(&fast, None);
3892        release_slow.wait();
3893        let slow_out = slow_thread.join().unwrap();
3894
3895        assert!(fast_out.contains("committed request 2"));
3896        assert!(
3897            slow_out.contains("superseded by request 2")
3898                && slow_out.contains("failed build")
3899                && !slow_out.contains("set_root_dir failed"),
3900            "a stale failure is a superseded outcome: {slow_out}"
3901        );
3902        assert_eq!(ws.active_repo_path(), Some(fast));
3903    }
3904
3905    // ------------------------------------------------------------------
3906    // revs (multi-revision activation)
3907    // ------------------------------------------------------------------
3908
3909    /// Stand up a real git repo at a fresh tempdir with the given tags
3910    /// created in order (so version-sort ordering is exercised). Returns
3911    /// the tempdir (keep alive) + its path, or `None` if git is
3912    /// unavailable in the environment (test then skips).
3913    fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
3914        let dir = tempfile::tempdir().unwrap();
3915        let root = dir.path().to_path_buf();
3916        let git = |args: &[&str]| {
3917            Command::new("git")
3918                .arg("-C")
3919                .arg(&root)
3920                .args(args)
3921                .output()
3922                .unwrap()
3923        };
3924        if !git(&["init"]).status.success() {
3925            return None; // git unavailable — caller skips.
3926        }
3927        git(&["config", "user.email", "t@example.com"]);
3928        git(&["config", "user.name", "Test"]);
3929        git(&["config", "commit.gpgsign", "false"]);
3930        for (i, tag) in tags.iter().enumerate() {
3931            std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
3932            git(&["add", "-A"]);
3933            assert!(
3934                git(&["commit", "-m", &format!("c{i}")]).status.success(),
3935                "git commit failed"
3936            );
3937            assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
3938        }
3939        Some((dir, root))
3940    }
3941
3942    // ---- tag classification (pure, no git) --------------------------
3943
3944    #[test]
3945    fn classify_tag_extracts_prefix_version_prerelease() {
3946        let c = classify_tag("apache-arrow-25.0.0").unwrap();
3947        assert_eq!(c.prefix, "apache-arrow-");
3948        assert_eq!(c.version, vec![25, 0, 0]);
3949        assert!(!c.is_prerelease);
3950
3951        // Prerelease markers with various separators, case-insensitive.
3952        for t in [
3953            "apache-arrow-25.0.0.dev",
3954            "apache-arrow-25.0.0-rc1",
3955            "apache-arrow-25.0.0-RC0",
3956            "v1.2.3-beta2",
3957            "v1.2.3_alpha",
3958            "v2.0.0-preview",
3959        ] {
3960            assert!(
3961                classify_tag(t).unwrap().is_prerelease,
3962                "{t} should be prerelease"
3963            );
3964        }
3965
3966        // Distinct families keyed on prefix.
3967        assert_eq!(classify_tag("go/v18.0.0").unwrap().prefix, "go/v");
3968        assert_eq!(classify_tag("r-15.0.1").unwrap().prefix, "r-");
3969        assert_eq!(classify_tag("v1.2.3").unwrap().prefix, "v");
3970
3971        // Last digit run wins: the `2` in `arrow2` is not the version.
3972        let c = classify_tag("arrow2-0.17.0").unwrap();
3973        assert_eq!(c.prefix, "arrow2-");
3974        assert_eq!(c.version, vec![0, 17, 0]);
3975    }
3976
3977    #[test]
3978    fn classify_tag_excludes_non_version_tags() {
3979        assert_eq!(classify_tag("r-universe-release"), None);
3980        assert_eq!(classify_tag("latest"), None);
3981        assert_eq!(classify_tag("nightly"), None);
3982        // A version followed by an *unrecognised* suffix is not version-like.
3983        assert_eq!(classify_tag("v1.2.3-foobar"), None);
3984    }
3985
3986    #[test]
3987    fn select_family_tags_picks_dominant_release_family_skipping_prereleases() {
3988        // Mirrors the apache/arrow shape: a large `apache-arrow-*` release
3989        // family (with newest entries being prereleases), plus unrelated
3990        // `r-*` / `go/v*` families and a rolling non-version pointer.
3991        let tags: Vec<String> = [
3992            "apache-arrow-22.0.0",
3993            "apache-arrow-23.0.0",
3994            "apache-arrow-24.0.0",
3995            "apache-arrow-25.0.0-rc0",
3996            "apache-arrow-25.0.0-rc1",
3997            "apache-arrow-25.0.0.dev",
3998            "go/v18.0.0",
3999            "r-15.0.1",
4000            "r-16.1.0",
4001            "r-universe-release",
4002        ]
4003        .iter()
4004        .map(|s| s.to_string())
4005        .collect();
4006        // Newest 2 STABLE of the dominant (apache-arrow-) family,
4007        // oldest→newest; the 25.0.0 prereleases and r-*/go/v* are excluded.
4008        let got = select_family_tags(&tags, 2).unwrap();
4009        assert_eq!(got, vec!["apache-arrow-23.0.0", "apache-arrow-24.0.0"]);
4010    }
4011
4012    #[test]
4013    fn select_family_tags_fewer_stable_than_requested_uses_all_stable() {
4014        let tags: Vec<String> = ["v1.0.0", "v2.0.0", "v3.0.0-rc1"]
4015            .iter()
4016            .map(|s| s.to_string())
4017            .collect();
4018        // Only two stable; the rc is skipped even though it's newest.
4019        let got = select_family_tags(&tags, 5).unwrap();
4020        assert_eq!(got, vec!["v1.0.0", "v2.0.0"]);
4021    }
4022
4023    #[test]
4024    fn select_family_tags_prerelease_only_family_falls_back_to_prereleases() {
4025        let tags: Vec<String> = ["v1.0.0-rc1", "v1.0.0-rc2", "v0.9.0-beta"]
4026            .iter()
4027            .map(|s| s.to_string())
4028            .collect();
4029        // No stable tag anywhere → newest prereleases of the family.
4030        let got = select_family_tags(&tags, 2).unwrap();
4031        assert_eq!(got, vec!["v1.0.0-rc1", "v1.0.0-rc2"]);
4032    }
4033
4034    #[test]
4035    fn select_family_tags_no_version_like_tags_returns_none() {
4036        let tags: Vec<String> = ["latest", "nightly", "stable"]
4037            .iter()
4038            .map(|s| s.to_string())
4039            .collect();
4040        assert_eq!(select_family_tags(&tags, 3), None);
4041    }
4042
4043    // ---- resolve_revs Count (git-gated) -----------------------------
4044
4045    #[test]
4046    fn resolve_revs_count_falls_back_to_raw_when_no_version_tags() {
4047        // Non-version tags → the family selector yields None and
4048        // resolve_revs preserves the raw version-sorted top-n behaviour.
4049        let Some((_d, root)) = git_repo_with_tags(&["latest", "nightly", "stable"]) else {
4050            return;
4051        };
4052        let ws = Workspace::open_local(root.clone(), None).unwrap();
4053        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
4054        // Exactly 2 tags + HEAD, HEAD last; contents come from raw top-n.
4055        assert_eq!(resolved.len(), 3);
4056        assert_eq!(resolved.last().unwrap(), "HEAD");
4057        assert!(resolved[..2].iter().all(|r| r != "HEAD"));
4058    }
4059
4060    #[test]
4061    fn resolve_revs_count_skips_prereleases_of_dominant_family() {
4062        let Some((_d, root)) =
4063            git_repo_with_tags(&["v1.0.0", "v2.0.0", "v3.0.0-rc1", "v3.0.0.dev"])
4064        else {
4065            return;
4066        };
4067        let ws = Workspace::open_local(root.clone(), None).unwrap();
4068        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
4069        // Newest 2 stable (v1.0.0, v2.0.0) oldest→newest, then HEAD —
4070        // the v3 prereleases are excluded.
4071        assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
4072    }
4073
4074    #[test]
4075    fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
4076        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
4077            return;
4078        };
4079        let ws = Workspace::open_local(root.clone(), None).unwrap();
4080        let resolved = ws
4081            .resolve_revs(&root, &RevsRequest::Count(2))
4082            .expect("resolve should succeed");
4083        // Newest 2 = v2.0.0, v1.1.0 → oldest→newest → v1.1.0, v2.0.0, then HEAD.
4084        assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
4085    }
4086
4087    #[test]
4088    fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
4089        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
4090            return;
4091        };
4092        let ws = Workspace::open_local(root.clone(), None).unwrap();
4093        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
4094        assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
4095    }
4096
4097    #[test]
4098    fn resolve_revs_count_errors_when_no_tags() {
4099        let Some((_d, root)) = git_repo_with_tags(&[]) else {
4100            return;
4101        };
4102        // Empty repo has no commits yet; make one commit but no tags.
4103        let git = |args: &[&str]| {
4104            Command::new("git")
4105                .arg("-C")
4106                .arg(&root)
4107                .args(args)
4108                .output()
4109                .unwrap()
4110        };
4111        std::fs::write(root.join("f.txt"), b"x").unwrap();
4112        git(&["add", "-A"]);
4113        git(&["commit", "-m", "c0"]);
4114        let ws = Workspace::open_local(root.clone(), None).unwrap();
4115        let err = ws
4116            .resolve_revs(&root, &RevsRequest::Count(3))
4117            .expect_err("no tags → error");
4118        assert!(
4119            err.to_string().contains("no tags"),
4120            "expected a 'no tags' error, got: {err}"
4121        );
4122    }
4123
4124    // ---- dedup of resolved revs -------------------------------------
4125
4126    #[test]
4127    fn dedup_labels_is_order_preserving_first_wins() {
4128        assert_eq!(
4129            dedup_labels(vec!["HEAD".into(), "HEAD".into()]),
4130            vec!["HEAD"]
4131        );
4132        assert_eq!(
4133            dedup_labels(vec![
4134                "v1".into(),
4135                "v2".into(),
4136                "v1".into(),
4137                "v3".into(),
4138                "v2".into(),
4139            ]),
4140            vec!["v1", "v2", "v3"]
4141        );
4142        // Empty and already-unique lists pass through untouched.
4143        assert_eq!(dedup_labels(vec![]), Vec::<String>::new());
4144        assert_eq!(dedup_labels(vec!["a".into(), "b".into()]), vec!["a", "b"]);
4145    }
4146
4147    #[test]
4148    fn resolve_revs_list_dedups_duplicate_revspecs() {
4149        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
4150            return;
4151        };
4152        let ws = Workspace::open_local(root.clone(), None).unwrap();
4153        // `["HEAD","HEAD"]` collapses to a single `HEAD`.
4154        let got = ws
4155            .resolve_revs(
4156                &root,
4157                &RevsRequest::List(vec!["HEAD".into(), "HEAD".into()]),
4158            )
4159            .unwrap();
4160        assert_eq!(got, vec!["HEAD"]);
4161        // First-occurrence order is preserved across mixed duplicates.
4162        let got = ws
4163            .resolve_revs(
4164                &root,
4165                &RevsRequest::List(vec!["v1.0.0".into(), "HEAD".into(), "v1.0.0".into()]),
4166            )
4167            .unwrap();
4168        assert_eq!(got, vec!["v1.0.0", "HEAD"]);
4169    }
4170
4171    #[test]
4172    fn resolve_revs_list_validates_and_rejects_unknown() {
4173        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
4174            return;
4175        };
4176        let ws = Workspace::open_local(root.clone(), None).unwrap();
4177        // Explicit list is used verbatim (no HEAD appended, no sort).
4178        let ok = ws
4179            .resolve_revs(
4180                &root,
4181                &RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
4182            )
4183            .unwrap();
4184        assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
4185        // An unknown rev is a clear error naming the bad rev.
4186        let err = ws
4187            .resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
4188            .expect_err("unknown rev → error");
4189        assert!(
4190            err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
4191            "expected an unknown-rev error, got: {err}"
4192        );
4193    }
4194
4195    #[test]
4196    fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
4197        use std::sync::atomic::{AtomicUsize, Ordering};
4198        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
4199            return;
4200        };
4201        let plain_calls = Arc::new(AtomicUsize::new(0));
4202        let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
4203        let pc = plain_calls.clone();
4204        let plain: PostActivateHook = Arc::new(move |_p, _n| {
4205            pc.fetch_add(1, Ordering::SeqCst);
4206            Ok(())
4207        });
4208        let sr = seen_revs.clone();
4209        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
4210            *sr.lock().unwrap() = Some(revs.to_vec());
4211            Ok(())
4212        });
4213        let ws = Workspace::open_local(root.clone(), Some(plain))
4214            .unwrap()
4215            .with_post_activate_revs(revs_hook);
4216        let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
4217        // The revs-hook ran with the resolved list; the plain hook did NOT.
4218        assert_eq!(
4219            seen_revs.lock().unwrap().clone().unwrap(),
4220            vec!["v1.1.0", "v2.0.0", "HEAD"]
4221        );
4222        assert_eq!(
4223            plain_calls.load(Ordering::SeqCst),
4224            0,
4225            "plain hook must not fire when the revs-hook handled the request"
4226        );
4227        // The activation message names the resolved revs on one line.
4228        assert!(
4229            out.contains("revs: v1.1.0, v2.0.0, HEAD"),
4230            "activation message should list the resolved revs; got: {out}"
4231        );
4232    }
4233
4234    #[test]
4235    fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
4236        use std::sync::atomic::{AtomicUsize, Ordering};
4237        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
4238            return;
4239        };
4240        let plain_calls = Arc::new(AtomicUsize::new(0));
4241        let revs_seen = Arc::new(AtomicUsize::new(0));
4242        let pc = plain_calls.clone();
4243        let plain: PostActivateHook = Arc::new(move |_p, _n| {
4244            pc.fetch_add(1, Ordering::SeqCst);
4245            Ok(())
4246        });
4247        let rs = revs_seen.clone();
4248        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
4249            rs.fetch_add(1, Ordering::SeqCst);
4250            Ok(())
4251        });
4252        let ws = Workspace::open_local(root.clone(), Some(plain))
4253            .unwrap()
4254            .with_post_activate_revs(revs_hook);
4255        // No revs → plain hook fires, revs-hook untouched, no `revs:` line.
4256        let out = ws.repo_management(None, false, true, false, None);
4257        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
4258        assert_eq!(
4259            revs_seen.load(Ordering::SeqCst),
4260            0,
4261            "revs-hook must not fire when no revs were requested"
4262        );
4263        assert!(
4264            !out.contains("revs:"),
4265            "no revs line expected on a plain activation; got: {out}"
4266        );
4267    }
4268
4269    #[test]
4270    fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
4271        use std::sync::atomic::{AtomicUsize, Ordering};
4272        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
4273            return;
4274        };
4275        let plain_calls = Arc::new(AtomicUsize::new(0));
4276        let pc = plain_calls.clone();
4277        let plain: PostActivateHook = Arc::new(move |_p, _n| {
4278            pc.fetch_add(1, Ordering::SeqCst);
4279            Ok(())
4280        });
4281        // No revs-hook attached: a revs request degrades to the plain
4282        // (HEAD-only) build and does NOT claim a rev-set in the message.
4283        let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
4284        let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
4285        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
4286        assert!(
4287            !out.contains("revs:"),
4288            "must not report a rev-set when only the plain hook ran; got: {out}"
4289        );
4290    }
4291
4292    // ---- rev-set-aware skip gate + stored-request persistence -------
4293
4294    /// Build a local workspace over a git repo with tags, wired with both
4295    /// a plain and a revs hook, each incrementing a shared counter.
4296    /// Returns (workspace, tempdir-guard, root, plain_calls, revs_calls).
4297    #[allow(clippy::type_complexity)]
4298    fn ws_with_both_hooks(
4299        tags: &[&str],
4300    ) -> Option<(
4301        Workspace,
4302        tempfile::TempDir,
4303        PathBuf,
4304        Arc<std::sync::atomic::AtomicUsize>,
4305        Arc<std::sync::atomic::AtomicUsize>,
4306    )> {
4307        use std::sync::atomic::{AtomicUsize, Ordering};
4308        let (d, root) = git_repo_with_tags(tags)?;
4309        let plain_calls = Arc::new(AtomicUsize::new(0));
4310        let revs_calls = Arc::new(AtomicUsize::new(0));
4311        let pc = plain_calls.clone();
4312        let plain: PostActivateHook = Arc::new(move |_p, _n| {
4313            pc.fetch_add(1, Ordering::SeqCst);
4314            Ok(())
4315        });
4316        let rc = revs_calls.clone();
4317        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _r| {
4318            rc.fetch_add(1, Ordering::SeqCst);
4319            Ok(())
4320        });
4321        let ws = Workspace::open_local(root.clone(), Some(plain))
4322            .unwrap()
4323            .with_post_activate_revs(revs_hook);
4324        Some((ws, d, root, plain_calls, revs_calls))
4325    }
4326
4327    #[test]
4328    fn plain_activation_after_revs_build_rebuilds_plain() {
4329        use std::sync::atomic::Ordering;
4330        let Some((ws, _d, root, plain_calls, revs_calls)) =
4331            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
4332        else {
4333            return;
4334        };
4335        // Multi-rev build first.
4336        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
4337        assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
4338        assert_eq!(plain_calls.load(Ordering::SeqCst), 0);
4339        // A plain re-bind at the SAME (unchanged) root must NOT cheap-skip
4340        // just because HEAD matches — the last build was multi-rev. It
4341        // rebuilds plain, and the message claims no rev-set.
4342        let out = ws.set_root_dir(&root, None);
4343        assert_eq!(
4344            plain_calls.load(Ordering::SeqCst),
4345            1,
4346            "plain re-activation after a revs build must fire the plain hook"
4347        );
4348        assert!(
4349            !out.contains("build skipped"),
4350            "must not skip a plain re-activation after a revs build; got: {out}"
4351        );
4352        assert!(
4353            !out.contains("revs:"),
4354            "plain rebuild must not claim revs; got: {out}"
4355        );
4356        // The stored request is cleared, so a further plain re-bind now
4357        // cheap-skips (proves the reset took).
4358        let out = ws.set_root_dir(&root, None);
4359        assert_eq!(
4360            plain_calls.load(Ordering::SeqCst),
4361            1,
4362            "second plain re-bind skips"
4363        );
4364        assert!(
4365            out.contains("build skipped"),
4366            "expected skip suffix; got: {out}"
4367        );
4368    }
4369
4370    #[test]
4371    fn update_after_revs_build_reapplies_stored_revs() {
4372        use std::sync::atomic::Ordering;
4373        let Some((ws, _d, root, plain_calls, revs_calls)) =
4374            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
4375        else {
4376            return;
4377        };
4378        // Multi-rev build first.
4379        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
4380        assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
4381        // A bare `update` (no revs) must re-apply the stored rev-set —
4382        // re-firing the revs hook, not collapsing to a plain HEAD build.
4383        let out = ws.repo_management(None, false, true, false, None);
4384        assert_eq!(
4385            revs_calls.load(Ordering::SeqCst),
4386            2,
4387            "bare update must re-apply the stored rev-set"
4388        );
4389        assert_eq!(
4390            plain_calls.load(Ordering::SeqCst),
4391            0,
4392            "bare update after a revs build must not fall to the plain hook"
4393        );
4394        assert!(
4395            out.contains("revs:"),
4396            "re-applied update should list the revs; got: {out}"
4397        );
4398    }
4399
4400    #[test]
4401    fn revs_activation_after_plain_build_always_rebuilds() {
4402        use std::sync::atomic::Ordering;
4403        let Some((ws, _d, root, plain_calls, revs_calls)) =
4404            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
4405        else {
4406            return;
4407        };
4408        // Plain build first.
4409        let _ = ws.set_root_dir(&root, None);
4410        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
4411        assert_eq!(revs_calls.load(Ordering::SeqCst), 0);
4412        // A revs request at the unchanged HEAD still always fires the revs
4413        // hook (revs requests are never skipped by the SHA gate).
4414        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
4415        assert_eq!(
4416            revs_calls.load(Ordering::SeqCst),
4417            1,
4418            "a revs request must always rebuild, even at an unchanged HEAD"
4419        );
4420    }
4421
4422    #[test]
4423    fn last_built_revs_round_trips_and_clears_on_plain_build() {
4424        let dir = tempfile::tempdir().unwrap();
4425        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
4426        ws.bump_access("acme/widgets", "cloned");
4427        assert_eq!(ws.last_built_revs("acme/widgets"), None);
4428        // Record a multi-rev build.
4429        ws.record_built("acme/widgets", "sha1", Some(&RevsRequest::Count(3)));
4430        assert_eq!(
4431            ws.last_built_revs("acme/widgets"),
4432            Some(RevsRequest::Count(3))
4433        );
4434        // Survives a reopen (persisted to inventory.json).
4435        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
4436        assert_eq!(
4437            ws2.last_built_revs("acme/widgets"),
4438            Some(RevsRequest::Count(3))
4439        );
4440        // A subsequent plain build clears the stored request.
4441        ws2.record_built("acme/widgets", "sha2", None);
4442        assert_eq!(ws2.last_built_revs("acme/widgets"), None);
4443        // A List request round-trips too.
4444        ws2.record_built(
4445            "acme/widgets",
4446            "sha3",
4447            Some(&RevsRequest::List(vec!["v1".into(), "v2".into()])),
4448        );
4449        assert_eq!(
4450            ws2.last_built_revs("acme/widgets"),
4451            Some(RevsRequest::List(vec!["v1".into(), "v2".into()]))
4452        );
4453    }
4454
4455    #[test]
4456    fn inventory_loads_legacy_entries_without_revs_field() {
4457        // An entry carrying last_built_sha but no last_built_revs (an
4458        // inventory written before the field existed) loads cleanly with
4459        // the request defaulting to None.
4460        let dir = tempfile::tempdir().unwrap();
4461        let legacy = r#"{
4462            "old/repo": {
4463                "cloned_at": "2024-01-01T00:00:00",
4464                "last_accessed": "2024-01-01T00:00:00",
4465                "access_count": 5,
4466                "stale": false,
4467                "last_built_sha": "deadbeef"
4468            }
4469        }"#;
4470        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
4471        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
4472        assert_eq!(ws.last_built_sha("old/repo").as_deref(), Some("deadbeef"));
4473        assert_eq!(ws.last_built_revs("old/repo"), None);
4474    }
4475}