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, RwLock};
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/// Workspace runtime state. Shared across MCP request clones via Arc.
266#[derive(Clone)]
267pub struct Workspace {
268    inner: Arc<WorkspaceInner>,
269}
270
271struct WorkspaceInner {
272    kind: WorkspaceKind,
273    workspace_dir: PathBuf,
274    stale_after_days: u32,
275    state: RwLock<WorkspaceState>,
276    /// Serializes inventory read-modify-write cycles across concurrent
277    /// activation preparations so per-repo SHA/revision receipts are not
278    /// lost when two requests finish close together.
279    inventory: Mutex<()>,
280    /// Serializes the legacy callback trio, whose signatures cannot carry a
281    /// request id or defer publication. Transaction-hook activations do not
282    /// take this lock: they prepare concurrently and commit by generation.
283    legacy_activation: Mutex<()>,
284    post_activate: Option<PostActivateHook>,
285    /// Optional summary hook (see [`ActivationSummaryHook`]). Set via
286    /// [`Workspace::with_activation_summary`], `None` by default.
287    activation_summary: Option<ActivationSummaryHook>,
288    /// Optional revs-aware hook (see [`PostActivateRevsHook`]). Set via
289    /// [`Workspace::with_post_activate_revs`], `None` by default. Called
290    /// in place of `post_activate` only when the activation carried a
291    /// revs request AND this hook is set.
292    post_activate_revs: Option<PostActivateRevsHook>,
293    /// Request-scoped prepare/commit contract. When configured it replaces
294    /// the legacy callback trio for activation work and summary generation.
295    activation_transaction: Option<ActivationTransactionHook>,
296}
297
298#[derive(Debug, Default)]
299struct WorkspaceState {
300    active_repo_name: Option<String>,
301    active_repo_path: Option<PathBuf>,
302    /// Last identity allocated under this state lock. Allocation and
303    /// publication both use the lock, so an older completion can never
304    /// overwrite a newer request's intent.
305    last_activation_id: u64,
306    latest_requested: Option<ActivationId>,
307    /// The product currently live in this process. Deliberately not
308    /// persisted: a new process must rehydrate its downstream product even
309    /// when inventory says the source SHA was built previously.
310    active_build: Option<ActiveBuildState>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314struct ActiveBuildState {
315    activation_id: ActivationId,
316    name: String,
317    path: PathBuf,
318    head_sha: String,
319    resolved_revs: Option<Vec<String>>,
320}
321
322impl Workspace {
323    /// Open a github-flavoured workspace (clone + track flow).
324    pub fn open(
325        workspace_dir: PathBuf,
326        stale_after_days: u32,
327        post_activate: Option<PostActivateHook>,
328    ) -> Result<Self> {
329        if !workspace_dir.is_dir() {
330            fs::create_dir_all(&workspace_dir).with_context(|| {
331                format!("failed to create workspace dir {}", workspace_dir.display())
332            })?;
333        }
334        let repos_dir = workspace_dir.join("repos");
335        if !repos_dir.is_dir() {
336            fs::create_dir_all(&repos_dir)
337                .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
338        }
339        let ws = Self {
340            inner: Arc::new(WorkspaceInner {
341                kind: WorkspaceKind::Github,
342                workspace_dir,
343                stale_after_days,
344                state: RwLock::new(WorkspaceState::default()),
345                inventory: Mutex::new(()),
346                legacy_activation: Mutex::new(()),
347                post_activate,
348                activation_summary: None,
349                post_activate_revs: None,
350                activation_transaction: None,
351            }),
352        };
353        ws.reconcile_inventory()?;
354        Ok(ws)
355    }
356
357    /// Open a local-directory workspace.
358    ///
359    /// Binds `root` as the active source root immediately and fires the
360    /// post-activate hook (subject to last-built-sha gating). `inventory.json`
361    /// is kept under `<root>/.mcp-workspace/` so the local mode mirrors
362    /// the same gating / fingerprinting infra without polluting the
363    /// user's tree with a `repos/` directory.
364    pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
365        if !root.is_dir() {
366            anyhow::bail!(
367                "local workspace root does not exist or is not a directory: {}",
368                root.display()
369            );
370        }
371        let canon_root = root
372            .canonicalize()
373            .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
374        // Store inventory under a hidden subdir so we don't litter the
375        // user's repo. The "workspace dir" for local mode IS the root.
376        let inv_dir = canon_root.join(".mcp-workspace");
377        if !inv_dir.is_dir() {
378            fs::create_dir_all(&inv_dir).with_context(|| {
379                format!("failed to create local-workspace dir {}", inv_dir.display())
380            })?;
381        }
382        let mut state = WorkspaceState::default();
383        let synthetic_name = synthesize_local_name(&canon_root);
384        state.active_repo_name = Some(synthetic_name);
385        state.active_repo_path = Some(canon_root.clone());
386        Ok(Self {
387            inner: Arc::new(WorkspaceInner {
388                kind: WorkspaceKind::Local,
389                workspace_dir: canon_root,
390                stale_after_days: u32::MAX, // sweeping is github-only
391                state: RwLock::new(state),
392                inventory: Mutex::new(()),
393                legacy_activation: Mutex::new(()),
394                post_activate,
395                activation_summary: None,
396                post_activate_revs: None,
397                activation_transaction: None,
398            }),
399        })
400    }
401
402    /// Attach an [`ActivationSummaryHook`]. Call immediately after
403    /// `open`/`open_local` (before the workspace is cloned into
404    /// `ServerOptions`): it mutates the still-unique inner `Arc`. Calling
405    /// it after the workspace has been cloned is a no-op with a warning —
406    /// the summary simply won't be attached.
407    pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
408        match Arc::get_mut(&mut self.inner) {
409            Some(inner) => inner.activation_summary = Some(hook),
410            None => tracing::warn!(
411                "with_activation_summary called after the workspace was cloned; summary not attached"
412            ),
413        }
414        self
415    }
416
417    /// Attach a [`PostActivateRevsHook`]. Call immediately after
418    /// `open`/`open_local` (before the workspace is cloned into
419    /// `ServerOptions`): it mutates the still-unique inner `Arc`, exactly
420    /// like [`with_activation_summary`](Self::with_activation_summary).
421    /// Calling it after the workspace has been cloned is a no-op with a
422    /// warning. Additive — consumers that don't set it keep the plain
423    /// single-rev activation behaviour.
424    pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
425        match Arc::get_mut(&mut self.inner) {
426            Some(inner) => inner.post_activate_revs = Some(hook),
427            None => tracing::warn!(
428                "with_post_activate_revs called after the workspace was cloned; revs hook not attached"
429            ),
430        }
431        self
432    }
433
434    /// Attach the request-scoped activation prepare/commit contract.
435    ///
436    /// When set, this hook owns plain builds, revision-set builds, cheap-skip
437    /// summaries, and atomic product publication. It replaces the legacy
438    /// `post_activate`, `post_activate_revs`, and `activation_summary`
439    /// callbacks for activation calls. Configure it before cloning the
440    /// workspace into [`crate::server::ServerOptions`].
441    pub fn with_activation_transaction(mut self, hook: ActivationTransactionHook) -> Self {
442        match Arc::get_mut(&mut self.inner) {
443            Some(inner) => inner.activation_transaction = Some(hook),
444            None => tracing::warn!(
445                "with_activation_transaction called after the workspace was cloned; transaction not attached"
446            ),
447        }
448        self
449    }
450
451    pub fn kind(&self) -> WorkspaceKind {
452        self.inner.kind
453    }
454
455    pub fn workspace_dir(&self) -> &Path {
456        &self.inner.workspace_dir
457    }
458
459    pub fn repos_dir(&self) -> PathBuf {
460        self.inner.workspace_dir.join("repos")
461    }
462
463    fn inventory_path(&self) -> PathBuf {
464        match self.inner.kind {
465            WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
466            WorkspaceKind::Local => self
467                .inner
468                .workspace_dir
469                .join(".mcp-workspace")
470                .join("inventory.json"),
471        }
472    }
473
474    /// Active repo's full org/repo name, or None if nothing is active.
475    pub fn active_repo_name(&self) -> Option<String> {
476        self.inner.state.read().unwrap().active_repo_name.clone()
477    }
478
479    /// Active repo's filesystem path, or None.
480    pub fn active_repo_path(&self) -> Option<PathBuf> {
481        self.inner.state.read().unwrap().active_repo_path.clone()
482    }
483
484    /// Default `org/repo` for the GitHub tools when the caller passes none.
485    ///
486    /// Github mode: the active repo — there the inventory key *is* the
487    /// `org/repo`. Local mode: the active root's `origin` remote parsed
488    /// to `org/repo`, or `None` when there's no GitHub remote. Crucially
489    /// it is *never* the `local/<dir>` inventory key (see
490    /// [`active_repo_name`](Self::active_repo_name)), which is a
491    /// filesystem-derived key, not a valid repo slug.
492    pub fn default_github_repo(&self) -> Option<String> {
493        match self.inner.kind {
494            WorkspaceKind::Github => self.active_repo_name(),
495            WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
496        }
497    }
498
499    // ------------------------------------------------------------------
500    // Inventory management
501    // ------------------------------------------------------------------
502
503    fn load_inventory_unlocked(&self) -> BTreeMap<String, InventoryEntry> {
504        let path = self.inventory_path();
505        let Ok(text) = fs::read_to_string(&path) else {
506            return BTreeMap::new();
507        };
508        serde_json::from_str(&text).unwrap_or_default()
509    }
510
511    fn save_inventory_unlocked(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
512        let path = self.inventory_path();
513        let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
514        fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
515        Ok(())
516    }
517
518    fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
519        let _guard = self.inner.inventory.lock().unwrap();
520        self.load_inventory_unlocked()
521    }
522
523    fn reconcile_inventory(&self) -> Result<()> {
524        let _guard = self.inner.inventory.lock().unwrap();
525        let mut inv = self.load_inventory_unlocked();
526        let mut on_disk: Vec<String> = Vec::new();
527        if self.repos_dir().is_dir() {
528            for org_entry in fs::read_dir(self.repos_dir())? {
529                let Ok(org_entry) = org_entry else { continue };
530                if !org_entry.path().is_dir() {
531                    continue;
532                }
533                let org = org_entry.file_name().to_string_lossy().into_owned();
534                if org.starts_with('.') {
535                    continue;
536                }
537                for repo_entry in fs::read_dir(org_entry.path())? {
538                    let Ok(repo_entry) = repo_entry else { continue };
539                    if !repo_entry.path().is_dir() {
540                        continue;
541                    }
542                    let repo = repo_entry.file_name().to_string_lossy().into_owned();
543                    if repo.starts_with('.') {
544                        continue;
545                    }
546                    let rname = format!("{org}/{repo}");
547                    on_disk.push(rname.clone());
548                    inv.entry(rname).or_insert_with(|| {
549                        let mtime = repo_entry
550                            .metadata()
551                            .ok()
552                            .and_then(|m| m.modified().ok())
553                            .map(format_iso)
554                            .unwrap_or_else(now_iso);
555                        InventoryEntry {
556                            cloned_at: mtime.clone(),
557                            last_accessed: mtime,
558                            access_count: 0,
559                            stale: false,
560                            last_built_sha: None,
561                            last_built_revs: None,
562                        }
563                    });
564                }
565            }
566        }
567        for (rname, entry) in inv.iter_mut() {
568            if !on_disk.contains(rname) && !entry.stale {
569                entry.stale = true;
570            }
571        }
572        self.save_inventory_unlocked(&inv)?;
573        Ok(())
574    }
575
576    fn bump_access(&self, name: &str, action: &str) {
577        let _guard = self.inner.inventory.lock().unwrap();
578        let mut inv = self.load_inventory_unlocked();
579        let now = now_iso();
580        let entry = inv
581            .entry(name.to_string())
582            .or_insert_with(|| InventoryEntry {
583                cloned_at: now.clone(),
584                last_accessed: now.clone(),
585                access_count: 0,
586                stale: false,
587                last_built_sha: None,
588                last_built_revs: None,
589            });
590        entry.last_accessed = now.clone();
591        entry.access_count += 1;
592        entry.stale = false;
593        if action == "cloned" || entry.cloned_at.is_empty() {
594            entry.cloned_at = now;
595        }
596        let _ = self.save_inventory_unlocked(&inv);
597    }
598
599    fn mark_stale(&self, name: &str) {
600        let _guard = self.inner.inventory.lock().unwrap();
601        let mut inv = self.load_inventory_unlocked();
602        if let Some(entry) = inv.get_mut(name) {
603            entry.stale = true;
604            let _ = self.save_inventory_unlocked(&inv);
605        }
606    }
607
608    fn sweep_stale(&self) -> Vec<String> {
609        // Local mode has nothing to sweep — the operator owns the root.
610        if matches!(self.inner.kind, WorkspaceKind::Local) {
611            return Vec::new();
612        }
613        let active = self.active_repo_name();
614        let _guard = self.inner.inventory.lock().unwrap();
615        let mut inv = self.load_inventory_unlocked();
616        let cutoff = SystemTime::now()
617            - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
618        let mut swept: Vec<String> = Vec::new();
619        for (rname, entry) in inv.iter_mut() {
620            if entry.stale {
621                continue;
622            }
623            if Some(rname.as_str()) == active.as_deref() {
624                continue;
625            }
626            let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
627            if last >= cutoff {
628                continue;
629            }
630            let parts: Vec<&str> = rname.splitn(2, '/').collect();
631            if parts.len() != 2 {
632                continue;
633            }
634            let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
635            if repo_path.exists() {
636                let _ = fs::remove_dir_all(&repo_path);
637            }
638            entry.stale = true;
639            swept.push(rname.clone());
640        }
641        if !swept.is_empty() {
642            let _ = self.save_inventory_unlocked(&inv);
643            self.prune_empty_org_dirs();
644        }
645        swept
646    }
647
648    fn prune_empty_org_dirs(&self) {
649        let Ok(entries) = fs::read_dir(self.repos_dir()) else {
650            return;
651        };
652        for entry in entries.flatten() {
653            let path = entry.path();
654            if !path.is_dir() {
655                continue;
656            }
657            if let Ok(children) = fs::read_dir(&path) {
658                let real: Vec<_> = children
659                    .flatten()
660                    .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
661                    .collect();
662                if real.is_empty() {
663                    let _ = fs::remove_dir_all(&path);
664                }
665            }
666        }
667    }
668
669    // ------------------------------------------------------------------
670    // Git operations
671    // ------------------------------------------------------------------
672
673    /// Clone (if missing) or fast-forward (if cloned). Returns the
674    /// action label, the repo path, and the new HEAD SHA after the op.
675    ///
676    /// Local-mode short-circuits: there's nothing to clone or fetch.
677    /// The "SHA" is a cheap content fingerprint (recursive walk of file
678    /// mtimes + sizes) so the auto-rebuild gate still works.
679    fn clone_or_update(
680        &self,
681        name: &str,
682        requested_local_root: Option<&Path>,
683    ) -> Result<(String, PathBuf, String)> {
684        if matches!(self.inner.kind, WorkspaceKind::Local) {
685            // `set_root_dir` passes its canonical target explicitly. This
686            // avoids publishing the requested path before its build commits,
687            // and prevents a concurrent activation from changing the path
688            // fingerprinted by this request. Refresh calls snapshot the
689            // currently committed root and pass it through the same argument.
690            let root = requested_local_root
691                .map(Path::to_path_buf)
692                .or_else(|| self.active_repo_path())
693                .unwrap_or_else(|| self.inner.workspace_dir.clone());
694            let prev_sha = self.last_built_sha(name);
695            let fingerprint = fingerprint_dir(&root);
696            let action = match prev_sha {
697                Some(p) if p == fingerprint => "current",
698                None => "cloned", // first activation
699                Some(_) => "updated",
700            };
701            return Ok((action.to_string(), root, fingerprint));
702        }
703        let parts: Vec<&str> = name.splitn(2, '/').collect();
704        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
705        if !repo_path.exists() {
706            fs::create_dir_all(repo_path.parent().unwrap()).ok();
707            let url = format!("https://github.com/{name}.git");
708            // Treeless clone (`--filter=tree:0`): keeps the FULL commit
709            // history — so `git log -S` (pickaxe) and any rev walk work —
710            // while fetching tree/blob objects lazily on demand, keeping
711            // the initial transfer near a shallow clone's cost. `--tags`
712            // pulls all tags up front so tag-scoped rev reads
713            // (`read_source rev=v1.2.3`) resolve without a follow-up fetch.
714            // (Was `--depth 1`, which truncated history and broke pickaxe.)
715            let out = Command::new("git")
716                .args([
717                    "clone",
718                    "--filter=tree:0",
719                    "--tags",
720                    &url,
721                    repo_path.to_str().unwrap(),
722                ])
723                .output()
724                .context("failed to spawn `git clone`")?;
725            if !out.status.success() {
726                anyhow::bail!(
727                    "git clone failed: {}",
728                    String::from_utf8_lossy(&out.stderr).trim()
729                );
730            }
731            let sha = git_rev_parse(&repo_path, "HEAD")?;
732            return Ok(("cloned".to_string(), repo_path, sha));
733        }
734
735        // Fetch + check head delta. Plain `git fetch origin --tags` (no
736        // `--depth 1`) so the treeless clone stays history-complete and
737        // newly-pushed tags become available for rev-scoped reads; blobs
738        // are still fetched lazily. FETCH_HEAD records the remote's
739        // default-branch tip, so the SHA-gate below is unchanged.
740        Command::new("git")
741            .args(["fetch", "origin", "--tags"])
742            .current_dir(&repo_path)
743            .output()
744            .context("git fetch failed")?;
745        let local = git_rev_parse(&repo_path, "HEAD")?;
746        let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
747        if local != remote {
748            Command::new("git")
749                .args(["reset", "--hard", "FETCH_HEAD"])
750                .current_dir(&repo_path)
751                .output()
752                .context("git reset failed")?;
753            let sha = git_rev_parse(&repo_path, "HEAD")?;
754            return Ok(("updated".to_string(), repo_path, sha));
755        }
756        Ok(("current".to_string(), repo_path, local))
757    }
758
759    /// Resolve a [`RevsRequest`] against the git repo at `repo_path` into
760    /// a concrete, ordered list of git revspecs.
761    ///
762    /// - `Count(n)`: the newest `n` **stable release** tags of the repo's
763    ///   dominant tag family, **oldest→newest**, with `HEAD` appended as
764    ///   the final (newest) rev. Selection (see [`select_family_tags`]):
765    ///   every tag is classified into `(prefix, version, is_prerelease)`
766    ///   by stripping a trailing version component; tags with no version
767    ///   component (e.g. `r-universe-release`) are excluded. Tags are
768    ///   grouped by prefix and the family with the most **stable**
769    ///   (non-prerelease) tags is chosen; within it the newest `n` stable
770    ///   tags (version-sorted) are taken. Prerelease markers (rc, alpha,
771    ///   beta, dev, pre, preview — case-insensitive) never count as
772    ///   releases. Fallback chain for degenerate repos: if the winning
773    ///   family has no stable tags its prereleases are used; if no tag is
774    ///   version-like at all, the raw `git tag --sort=-v:refname` top-`n`
775    ///   is used. Errors only if the repo has no tags whatsoever. Fewer
776    ///   than `n` matching tags is not an error — all available are used.
777    /// - `List(revs)`: each revspec is validated with
778    ///   `git rev-parse --verify <rev>^{commit}` and used verbatim (no
779    ///   sort, no `HEAD` appended). Errors on the first unknown rev.
780    ///
781    /// The resolved list is deduplicated order-preserving on the label
782    /// string (first occurrence wins) before being returned — see
783    /// [`dedup_labels`] — so a downstream hook never receives duplicate
784    /// revspecs (e.g. `revs=["HEAD","HEAD"]`). Dedup is on the label, not
785    /// the resolved commit: two *different* revspecs that happen to point
786    /// at the same commit are deliberately both kept, because labels are
787    /// the graph-facing names a multi-rev builder attaches.
788    ///
789    /// A non-git `repo_path` surfaces as the `git tag` / `git rev-parse`
790    /// failure with a clear message.
791    fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
792        let resolved = match req {
793            RevsRequest::Count(n) => {
794                let out = Command::new("git")
795                    .args(["tag", "--sort=-v:refname"])
796                    .current_dir(repo_path)
797                    .output()
798                    .context("failed to spawn `git tag`")?;
799                if !out.status.success() {
800                    anyhow::bail!(
801                        "cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
802                        repo_path.display(),
803                        String::from_utf8_lossy(&out.stderr).trim()
804                    );
805                }
806                let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
807                    .lines()
808                    .map(|l| l.trim().to_string())
809                    .filter(|l| !l.is_empty())
810                    .collect();
811                if tags.is_empty() {
812                    anyhow::bail!(
813                        "revs={n} requested but '{}' has no tags to resolve",
814                        repo_path.display()
815                    );
816                }
817                // Classify + pick the dominant release family's newest `n`
818                // stable tags (oldest→newest). Falls back to the raw
819                // version-sorted top-`n` when no tag is version-like.
820                let mut chosen = select_family_tags(&tags, *n).unwrap_or_else(|| {
821                    let mut raw: Vec<String> = tags.into_iter().take(*n).collect();
822                    raw.reverse();
823                    raw
824                });
825                // HEAD last so a multi-rev builder merges oldest→newest
826                // with HEAD's signature winning.
827                chosen.push("HEAD".to_string());
828                chosen
829            }
830            RevsRequest::List(revs) => {
831                if revs.is_empty() {
832                    anyhow::bail!("revs list is empty — pass at least one revision");
833                }
834                for r in revs {
835                    let out = Command::new("git")
836                        .args([
837                            "rev-parse",
838                            "--verify",
839                            "--quiet",
840                            &format!("{r}^{{commit}}"),
841                        ])
842                        .current_dir(repo_path)
843                        .output()
844                        .context("failed to spawn `git rev-parse`")?;
845                    if !out.status.success() {
846                        anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
847                    }
848                }
849                revs.clone()
850            }
851        };
852        Ok(dedup_labels(resolved))
853    }
854
855    /// Activate a repo: prepare source, build, and publish if still current.
856    ///
857    /// Auto-rebuild gating: if `force_rebuild` is false AND no `revs` were
858    /// requested AND the repo is already at the HEAD it was last built at
859    /// (`action == "current"` AND `prev_built_sha == new_head`), the
860    /// post-activate hook is skipped. This makes `repo_management(update=True)`
861    /// cheap when upstream hasn't moved. Set `force_rebuild=true` to bypass
862    /// (e.g. after upgrading the builder itself).
863    ///
864    /// When `revs` are requested the skip gate never applies — a
865    /// revs-requested activation always fires the hook (see the gate
866    /// comment below). If the revs-aware hook is set, it is called with
867    /// the resolved revspecs; otherwise the plain hook runs (single-rev
868    /// build) and the resolved list is not reported.
869    ///
870    /// Each request receives an identity before active source state mutates.
871    /// A transaction hook prepares off-lock and returns the commit closure
872    /// that installs its product and generates its summary. If newer intent
873    /// arrives first, the closure is dropped and the response reports
874    /// supersession. On commit, source identity, in-process built identity,
875    /// and inventory receipt publish under one generation boundary.
876    ///
877    /// On successful hook completion the new HEAD SHA is persisted to
878    /// `inventory.json[name].last_built_sha`. If the hook fails the SHA and
879    /// active source state are not changed, so the next request retries.
880    fn activate(
881        &self,
882        name: &str,
883        force_rebuild: bool,
884        revs: Option<&RevsRequest>,
885        requested_local_root: Option<&Path>,
886    ) -> Result<String> {
887        // Legacy callbacks publish their downstream product inside the hook,
888        // so they cannot safely overlap. Keep that API coherent by
889        // serializing the complete request before allocating its identity.
890        // Transaction hooks prepare concurrently and therefore skip this
891        // lock; their stale work is discarded at the generation gate below.
892        let _legacy_guard = self
893            .inner
894            .activation_transaction
895            .is_none()
896            .then(|| self.inner.legacy_activation.lock().unwrap());
897        let activation_id = {
898            let mut state = self.inner.state.write().unwrap();
899            state.last_activation_id += 1;
900            let id = ActivationId(state.last_activation_id);
901            state.latest_requested = Some(id);
902            id
903        };
904        let prev_built_sha = self.last_built_sha(name);
905        let prev_built_revs = self.last_built_revs(name);
906        let (action, repo_path, head_sha) = self
907            .clone_or_update(name, requested_local_root)
908            .with_context(|| {
909                format!("activation request {activation_id} source preparation failed")
910            })?;
911        // Resolve any requested revs before mutating active state, so a
912        // bad request (no tags / unknown rev) returns a clean error with
913        // the repo cloned-but-not-activated rather than half-bound.
914        let resolved_revs = match revs {
915            Some(req) => Some(self.resolve_revs(&repo_path, req).with_context(|| {
916                format!("activation request {activation_id} revision resolution failed")
917            })?),
918            None => None,
919        };
920        self.bump_access(name, &action);
921        let is_active_built = {
922            let state = self.inner.state.read().unwrap();
923            state.active_build.as_ref().is_some_and(|built| {
924                built.name == name && built.path == repo_path && built.resolved_revs.is_none()
925            })
926        };
927
928        // The skip gate must be satisfied on BOTH axes: the git repo is
929        // at its last-built SHA (persisted, cross-process) AND `name` is
930        // the *currently active* built root in this process (in-memory).
931        // Without the second axis a fresh process would inherit
932        // `last_built_sha` from disk, skip the hook, and leave the
933        // consumer's in-memory state (e.g. the code graph) empty —
934        // activate would report success with nothing loaded. The axis
935        // checks the *active* built name, not any name ever built, so an
936        // A→B→A swap correctly rebuilds A: after activate(B) the live
937        // slot holds B, so re-binding A must not skip (see the
938        // `active_build` field doc).
939        // Skip-gate / revs interaction: a revs-requested activation ALWAYS
940        // fires the hook — the SHA-skip gate only applies to the plain
941        // (no-revs) path (`resolved_revs.is_none()`). Rationale: the gate
942        // keys off HEAD's SHA alone, which says nothing about which *set*
943        // of revs a prior build loaded, so a rev-set request at the same
944        // HEAD must rebuild. The tradeoff is that repeat `revs=` calls at
945        // an unchanged HEAD re-parse every rev — acceptable for an
946        // explicit multi-rev request.
947        //
948        // The plain path additionally requires `prev_built_revs.is_none()`
949        // — a plain re-activation must NOT skip when the last build was
950        // multi-rev. Without this the tool would report a plain activation
951        // (and, downstream, the plain hook rebuilding a single-rev graph
952        // was bypassed) while the live product is still the rev-set union.
953        // A non-skip here means the plain hook runs and `record_built`
954        // clears the stored request — resetting to a genuine plain graph.
955        let already_built = !force_rebuild
956            && resolved_revs.is_none()
957            && prev_built_revs.is_none()
958            && action == "current"
959            && prev_built_sha.as_deref() == Some(head_sha.as_str())
960            && is_active_built;
961        let uses_transaction = self.inner.activation_transaction.is_some();
962        let revision_build = !already_built
963            && resolved_revs.is_some()
964            && (uses_transaction || self.inner.post_activate_revs.is_some());
965        let build = if already_built {
966            ActivationBuild::Reuse
967        } else if revision_build {
968            ActivationBuild::Revisions(resolved_revs.clone().unwrap_or_default())
969        } else {
970            ActivationBuild::Plain
971        };
972        let request = ActivationRequest {
973            id: activation_id,
974            path: repo_path.clone(),
975            name: name.to_string(),
976            build,
977        };
978
979        let prepared = if let Some(hook) = &self.inner.activation_transaction {
980            hook(&request)
981        } else {
982            // Compatibility path. The complete request is serialized by
983            // `_legacy_guard`, making the old build-then-summary sequence
984            // coherent even though those callbacks cannot carry an id.
985            let hook_result = match request.build() {
986                ActivationBuild::Reuse => Ok(()),
987                ActivationBuild::Revisions(resolved) => self
988                    .inner
989                    .post_activate_revs
990                    .as_ref()
991                    .map_or(Ok(()), |hook| hook(&repo_path, name, resolved)),
992                ActivationBuild::Plain => self
993                    .inner
994                    .post_activate
995                    .as_ref()
996                    .map_or(Ok(()), |hook| hook(&repo_path, name)),
997            };
998            hook_result.map(|()| {
999                let summary = self
1000                    .inner
1001                    .activation_summary
1002                    .as_ref()
1003                    .and_then(|hook| hook(&repo_path, name));
1004                PreparedActivation::summary(summary)
1005            })
1006        };
1007
1008        let prepared = match prepared {
1009            Ok(prepared) => prepared,
1010            Err(error) => {
1011                let latest = self.inner.state.read().unwrap().latest_requested;
1012                if latest != Some(activation_id) {
1013                    return Ok(format!(
1014                        "Activation request {activation_id} for '{name}' was superseded by request {} before its failed build could publish.",
1015                        latest.map_or_else(|| "unknown".to_string(), |id| id.to_string())
1016                    ));
1017                }
1018                return Err(anyhow!(
1019                    "activation request {activation_id} for '{name}' failed during preparation: {error}"
1020                ));
1021            }
1022        };
1023
1024        // Generation commit point. While this write lock is held, no newer
1025        // identity can be allocated. The downstream product, its request-
1026        // scoped summary, source binding, and built identity therefore become
1027        // visible as one ordered transaction.
1028        let summary = {
1029            let mut state = self.inner.state.write().unwrap();
1030            if state.latest_requested != Some(activation_id) {
1031                let superseding = state.latest_requested;
1032                drop(state);
1033                drop(prepared);
1034                return Ok(format!(
1035                    "Activation request {activation_id} for '{name}' was superseded by request {} before publication; its prepared build was discarded.",
1036                    superseding.map_or_else(|| "unknown".to_string(), |id| id.to_string())
1037                ));
1038            }
1039            let summary = prepared.commit().with_context(|| {
1040                format!("activation request {activation_id} for '{name}' failed during commit")
1041            })?;
1042            if !matches!(request.build(), ActivationBuild::Reuse) {
1043                let built_revs = revision_build.then_some(revs).flatten();
1044                self.record_built(name, &head_sha, built_revs);
1045            }
1046            state.active_repo_name = Some(name.to_string());
1047            state.active_repo_path = Some(repo_path.clone());
1048            state.active_build = Some(ActiveBuildState {
1049                activation_id,
1050                name: name.to_string(),
1051                path: repo_path.clone(),
1052                head_sha: head_sha.clone(),
1053                resolved_revs: match request.build() {
1054                    ActivationBuild::Revisions(resolved) => Some(resolved.clone()),
1055                    ActivationBuild::Plain | ActivationBuild::Reuse => None,
1056                },
1057            });
1058            summary
1059        };
1060
1061        let verb = match action.as_str() {
1062            "cloned" => "Cloned",
1063            "updated" => "Updated",
1064            "current" => "Activated (already up to date)",
1065            other => other,
1066        };
1067        let suffix = if already_built {
1068            " [build skipped: HEAD matches last-built SHA]"
1069        } else {
1070            ""
1071        };
1072        let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
1073        // Name the resolved revisions on their own line so agents see
1074        // exactly what got loaded. Only when the revs-hook actually ran
1075        // (revs requested AND hook set AND it succeeded) — a fallback to
1076        // the plain hook loads HEAD only, so claiming a rev-set would lie.
1077        if let ActivationBuild::Revisions(resolved) = request.build() {
1078            base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
1079        }
1080        Ok(match summary {
1081            Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
1082            _ => base,
1083        })
1084    }
1085
1086    /// Record the outcome of a successful build: the HEAD SHA plus the
1087    /// revisions request that produced it (`Some` only for a multi-rev
1088    /// build via the revs hook; `None` for a plain / HEAD-only build,
1089    /// which *clears* any previously-stored request). Called only on hook
1090    /// success — a failed build records nothing, so the next `update` retries.
1091    fn record_built(&self, name: &str, sha: &str, revs: Option<&RevsRequest>) {
1092        let _guard = self.inner.inventory.lock().unwrap();
1093        let mut inv = self.load_inventory_unlocked();
1094        if let Some(entry) = inv.get_mut(name) {
1095            entry.last_built_sha = Some(sha.to_string());
1096            entry.last_built_revs = revs.cloned();
1097            let _ = self.save_inventory_unlocked(&inv);
1098        }
1099    }
1100
1101    /// Read the SHA recorded after the last successful post-activate hook
1102    /// for the named repo. `None` if the repo was never built (or the
1103    /// hook last failed). Useful for downstream consumers gating
1104    /// "is the active graph up to date with the repo HEAD?" checks.
1105    pub fn last_built_sha(&self, name: &str) -> Option<String> {
1106        self.load_inventory()
1107            .get(name)
1108            .and_then(|e| e.last_built_sha.clone())
1109    }
1110
1111    /// Read the revisions request last successfully built for the named
1112    /// repo — `Some` when the last build was multi-rev (`revs=`), `None`
1113    /// for a plain / HEAD-only build or a never-built repo. Drives the
1114    /// rev-set-aware skip gate and the `update`-preserves-rev-set path.
1115    pub fn last_built_revs(&self, name: &str) -> Option<RevsRequest> {
1116        self.load_inventory()
1117            .get(name)
1118            .and_then(|e| e.last_built_revs.clone())
1119    }
1120
1121    fn delete(&self, name: &str) -> Result<String> {
1122        let parts: Vec<&str> = name.splitn(2, '/').collect();
1123        if parts.len() != 2 {
1124            anyhow::bail!("Invalid repo name");
1125        }
1126        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
1127        let mut deleted = Vec::new();
1128        if repo_path.exists() {
1129            fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
1130            deleted.push("repo");
1131        }
1132        self.mark_stale(name);
1133        self.prune_empty_org_dirs();
1134        if deleted.is_empty() {
1135            return Ok(format!("Nothing to delete — '{name}' not found."));
1136        }
1137        let mut state = self.inner.state.write().unwrap();
1138        if state.active_repo_name.as_deref() == Some(name) {
1139            state.active_repo_name = None;
1140            state.active_repo_path = None;
1141            state.active_build = None;
1142            return Ok(format!(
1143                "Deleted {}. Active repo cleared.",
1144                deleted.join(", ")
1145            ));
1146        }
1147        Ok(format!("Deleted {}.", deleted.join(", ")))
1148    }
1149
1150    fn list(&self) -> String {
1151        let inv = self.load_inventory();
1152        if inv.is_empty() {
1153            return "No repos cloned yet. Call repo_management('org/repo') to clone one."
1154                .to_string();
1155        }
1156        let active = self.active_repo_name();
1157        let mut live: Vec<String> = Vec::new();
1158        let mut stale_lines: Vec<String> = Vec::new();
1159        for (rname, entry) in &inv {
1160            let marker = if Some(rname.as_str()) == active.as_deref() {
1161                " [active]"
1162            } else {
1163                ""
1164            };
1165            let access = format!(
1166                "{} access{}, last {}",
1167                entry.access_count,
1168                if entry.access_count == 1 { "" } else { "es" },
1169                relative_time(&entry.last_accessed)
1170            );
1171            if entry.stale {
1172                stale_lines.push(format!(
1173                    "  {rname}  [STALE — re-fetch with repo_management('{rname}')]  ({access})"
1174                ));
1175            } else {
1176                live.push(format!("  {rname}{marker}  ({access})"));
1177            }
1178        }
1179        let mut out = String::new();
1180        if !live.is_empty() {
1181            out.push_str(&format!(
1182                "{} live repo(s):\n{}",
1183                live.len(),
1184                live.join("\n")
1185            ));
1186        }
1187        if !stale_lines.is_empty() {
1188            if !out.is_empty() {
1189                out.push_str("\n\n");
1190            }
1191            out.push_str(&format!(
1192                "{} stale repo(s):\n{}",
1193                stale_lines.len(),
1194                stale_lines.join("\n")
1195            ));
1196        }
1197        out
1198    }
1199
1200    /// Public entry for the `repo_management` MCP tool.
1201    ///
1202    /// - `name`: `org/repo` to activate (None = list / refresh mode).
1203    /// - `delete`: remove the named repo + inventory entry. Github only.
1204    /// - `update`: refresh the active repo (auto-rebuild gated).
1205    /// - `force_rebuild`: with `update=true` (or initial activation),
1206    ///   re-run the post-activate hook even when the HEAD SHA matches
1207    ///   `last_built_sha`. Useful after the builder itself has been
1208    ///   upgraded.
1209    ///
1210    /// Local mode behaviour: `name` and `delete` are rejected; pass
1211    /// `update=true` (or no args after the initial activation) to
1212    /// re-fingerprint the root and rebuild if anything changed.
1213    pub fn repo_management(
1214        &self,
1215        name: Option<&str>,
1216        delete: bool,
1217        update: bool,
1218        force_rebuild: bool,
1219        revs: Option<&RevsRequest>,
1220    ) -> String {
1221        // Local mode: most github-only semantics are nonsensical here.
1222        if matches!(self.inner.kind, WorkspaceKind::Local) {
1223            if name.is_some() {
1224                return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
1225                        to switch the active root, or pass `update=true` / `force_rebuild=true` \
1226                        to rebuild against the current root."
1227                    .to_string();
1228            }
1229            if delete {
1230                return "Local-workspace mode does not support `delete`. The root is owned by the \
1231                        operator; remove it manually."
1232                    .to_string();
1233            }
1234            let active = match self.active_repo_name() {
1235                Some(n) => n,
1236                None => return "No active local root.".to_string(),
1237            };
1238            // `update`: re-fingerprint and rebuild if anything changed.
1239            // `force_rebuild`: rebuild even when the fingerprint matches.
1240            // Either flag (or neither — initial bind path) routes through
1241            // `activate`; `activate` itself consults the gate using the
1242            // force flag plus the SHA comparison.
1243            let _ = update; // explicit: update is implicit in local mode
1244                            // A local `repo_management` call is always a refresh of the
1245                            // bound root, so when no explicit `revs` are passed re-apply
1246                            // the stored rev-set (if the last build was multi-rev) — a
1247                            // bare refresh must not silently collapse a rev-set graph to
1248                            // HEAD-only. Re-`set_root_dir` (which passes `revs` verbatim)
1249                            // is the way to reset back to a plain single-rev build.
1250            let effective = match revs {
1251                Some(r) => Some(r.clone()),
1252                None => self.last_built_revs(&active),
1253            };
1254            let active_root = self.active_repo_path();
1255            return self
1256                .activate(
1257                    &active,
1258                    force_rebuild,
1259                    effective.as_ref(),
1260                    active_root.as_deref(),
1261                )
1262                .unwrap_or_else(|e| format!("rebuild failed: {e}"));
1263        }
1264
1265        let swept = self.sweep_stale();
1266        let prefix = if swept.is_empty() {
1267            String::new()
1268        } else {
1269            format!(
1270                "[Swept {} idle repo(s) (>{}d): {}]\n\n",
1271                swept.len(),
1272                self.inner.stale_after_days,
1273                swept.join(", ")
1274            )
1275        };
1276
1277        if name.is_none() && !update {
1278            return prefix + &self.list();
1279        }
1280
1281        if update {
1282            let Some(active) = self.active_repo_name() else {
1283                return prefix + "No active repository. Call repo_management('org/repo') first.";
1284            };
1285            // `update=True` refreshes the active repo. When no explicit
1286            // `revs` are passed, re-apply the stored rev-set (if the last
1287            // build was multi-rev) so a bare `update` after HEAD moves
1288            // re-resolves and rebuilds the SAME rev-set rather than
1289            // collapsing it to a single-rev HEAD build. An explicit `revs`
1290            // argument overrides; a plain re-activation (name path, not
1291            // `update`) still resets to plain.
1292            let effective = match revs {
1293                Some(r) => Some(r.clone()),
1294                None => self.last_built_revs(&active),
1295            };
1296            return prefix
1297                + &self
1298                    .activate(&active, force_rebuild, effective.as_ref(), None)
1299                    .unwrap_or_else(|e| format!("update failed: {e}"));
1300        }
1301
1302        let Some(name) = name else {
1303            return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
1304        };
1305        if let Err(e) = validate_repo_name(name) {
1306            return prefix + &e.to_string();
1307        }
1308        if delete {
1309            return prefix
1310                + &self
1311                    .delete(name)
1312                    .unwrap_or_else(|e| format!("delete failed: {e}"));
1313        }
1314        prefix
1315            + &self
1316                .activate(name, force_rebuild, revs, None)
1317                .unwrap_or_else(|e| format!("activate failed: {e}"))
1318    }
1319
1320    /// Swap the active root (local mode only). Re-fires the post-activate
1321    /// hook against the new root. Errors if the workspace is github-flavoured.
1322    ///
1323    /// `revs` (optional): resolve revisions against the new root (which
1324    /// must be a git repo) and fire the revs-aware hook — see
1325    /// [`activate`](Self::activate) / [`RevsRequest`].
1326    pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
1327        if !matches!(self.inner.kind, WorkspaceKind::Local) {
1328            return "set_root_dir is only valid in local-workspace mode.".to_string();
1329        }
1330        if !new_root.is_dir() {
1331            return format!(
1332                "Path does not exist or is not a directory: {}",
1333                new_root.display()
1334            );
1335        }
1336        let canon = match new_root.canonicalize() {
1337            Ok(p) => p,
1338            Err(e) => return format!("canonicalize failed: {e}"),
1339        };
1340        let synthetic = synthesize_local_name(&canon);
1341        // Note: the WorkspaceInner.workspace_dir field is the path the
1342        // inventory is stored under. We keep the *original* one (from
1343        // open_local) so the inventory survives across root swaps.
1344        self.activate(&synthetic, false, revs, Some(&canon))
1345            .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
1346    }
1347}
1348
1349/// Deduplicate a resolved revspec list order-preserving, first
1350/// occurrence wins. Dedup is on the **label** string, not the resolved
1351/// commit: a downstream multi-rev builder attaches each label as a
1352/// graph-facing name, so two *different* labels pointing at the same
1353/// commit are deliberately kept — only literal repeats (e.g. a `HEAD`
1354/// that appears twice) collapse.
1355fn dedup_labels(revs: Vec<String>) -> Vec<String> {
1356    let mut seen = std::collections::HashSet::new();
1357    revs.into_iter()
1358        .filter(|r| seen.insert(r.clone()))
1359        .collect()
1360}
1361
1362/// Prerelease markers recognised in a tag's trailing suffix
1363/// (case-insensitive, with an optional `-`/`.`/`_` separator). A tag
1364/// whose version is followed by one of these is never treated as a
1365/// stable release.
1366const PRERELEASE_MARKERS: &[&str] = &["rc", "alpha", "beta", "dev", "pre", "preview"];
1367
1368/// A git tag decomposed into its release family `prefix`, numeric
1369/// `version` components, and whether it carries a prerelease marker.
1370/// Produced by [`classify_tag`]; consumed by [`select_family_tags`].
1371#[derive(Debug, Clone, PartialEq, Eq)]
1372struct ClassifiedTag {
1373    raw: String,
1374    prefix: String,
1375    version: Vec<u64>,
1376    is_prerelease: bool,
1377}
1378
1379/// Classify a single tag into `(prefix, version, is_prerelease)` by
1380/// locating its trailing version component.
1381///
1382/// The version is the *first* `DIGITS(.DIGITS)*` run whose remainder is
1383/// empty or a recognised prerelease suffix; everything before it is the
1384/// family `prefix`. Taking the first *cleanly-parsing* run resolves both
1385/// tricky shapes: `arrow2-0.17.0` skips the `2` in `arrow2` (its
1386/// remainder `-0.17.0` isn't a recognised suffix, so that run is
1387/// rejected) and lands on `0.17.0`; while `v3.0.0-rc1` stops at `3.0.0`
1388/// (with `-rc1` recognised as a prerelease) rather than mistaking the
1389/// trailing `1` of `rc1` for a version. Returns `None` when the tag has
1390/// no version-like component at all (e.g. `r-universe-release`), so such
1391/// tags are excluded from family selection.
1392fn classify_tag(tag: &str) -> Option<ClassifiedTag> {
1393    let bytes = tag.as_bytes();
1394    for i in 0..bytes.len() {
1395        if !bytes[i].is_ascii_digit() {
1396            continue;
1397        }
1398        // Only consider the *start* of a digit run as a version start.
1399        if i > 0 && bytes[i - 1].is_ascii_digit() {
1400            continue;
1401        }
1402        if let Some((version, is_prerelease)) = parse_version_at(&tag[i..]) {
1403            return Some(ClassifiedTag {
1404                raw: tag.to_string(),
1405                prefix: tag[..i].to_string(),
1406                version,
1407                is_prerelease,
1408            });
1409        }
1410    }
1411    None
1412}
1413
1414/// Parse `s` as `DIGITS(.DIGITS)*` optionally followed by a recognised
1415/// prerelease suffix. Returns the numeric components and whether a
1416/// prerelease marker follows. `None` if `s` doesn't start with a digit,
1417/// or carries an *unrecognised* trailing suffix (so the caller rejects
1418/// this candidate start and tries an earlier digit run).
1419fn parse_version_at(s: &str) -> Option<(Vec<u64>, bool)> {
1420    let bytes = s.as_bytes();
1421    let mut nums: Vec<u64> = Vec::new();
1422    let mut idx = 0usize;
1423    loop {
1424        let start = idx;
1425        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
1426            idx += 1;
1427        }
1428        if idx == start {
1429            return None; // expected digits (leading, or after a '.')
1430        }
1431        nums.push(s[start..idx].parse().ok()?);
1432        // Continue only when a '.' is followed by another digit.
1433        if idx + 1 < bytes.len() && bytes[idx] == b'.' && bytes[idx + 1].is_ascii_digit() {
1434            idx += 1;
1435            continue;
1436        }
1437        break;
1438    }
1439    let rest = &s[idx..];
1440    if rest.is_empty() {
1441        return Some((nums, false));
1442    }
1443    // A single optional separator, then a recognised prerelease marker.
1444    let after_sep = rest
1445        .strip_prefix(|c| c == '-' || c == '.' || c == '_')
1446        .unwrap_or(rest);
1447    let lower = after_sep.to_ascii_lowercase();
1448    if PRERELEASE_MARKERS.iter().any(|m| lower.starts_with(m)) {
1449        Some((nums, true))
1450    } else {
1451        None
1452    }
1453}
1454
1455/// From a repo's tag list, choose the dominant **release family** and
1456/// return its newest `n` tags **oldest→newest** (HEAD is appended by the
1457/// caller, not here). Returns `None` when no tag is version-like, so the
1458/// caller can fall back to the raw version-sorted top-`n`.
1459///
1460/// Tags are classified ([`classify_tag`]) and grouped by prefix. The
1461/// family with the most **stable** (non-prerelease) tags wins; if no
1462/// family has any stable tag, the family with the most tags overall wins
1463/// and its prereleases are used. Within the winning family the newest
1464/// `n` tags in the applicable pool (stable if any exist, else
1465/// prerelease) are taken by descending version, then reversed to
1466/// oldest→newest. Deterministic: grouping iterates prefixes in sorted
1467/// order and the version/`raw` sort is total, so a given tag set always
1468/// resolves to the same list. Family ties are broken toward the
1469/// lexicographically-greatest prefix.
1470fn select_family_tags(tags: &[String], n: usize) -> Option<Vec<String>> {
1471    let classified: Vec<ClassifiedTag> = tags.iter().filter_map(|t| classify_tag(t)).collect();
1472    if classified.is_empty() {
1473        return None;
1474    }
1475    // Group by family prefix (BTreeMap → deterministic prefix order).
1476    let mut families: BTreeMap<String, Vec<&ClassifiedTag>> = BTreeMap::new();
1477    for c in &classified {
1478        families.entry(c.prefix.clone()).or_default().push(c);
1479    }
1480    let stable_count = |v: &Vec<&ClassifiedTag>| v.iter().filter(|c| !c.is_prerelease).count();
1481    let any_stable = families.values().any(|v| stable_count(v) > 0);
1482    // Prefer the family with the most stable tags; when nothing is
1483    // stable anywhere, prefer the family with the most tags overall.
1484    // `max_by` returns the last maximum, and BTreeMap yields ascending
1485    // prefixes, so ties resolve to the greatest prefix — deterministic.
1486    let chosen = families.values().max_by(|a, b| {
1487        if any_stable {
1488            stable_count(a).cmp(&stable_count(b))
1489        } else {
1490            a.len().cmp(&b.len())
1491        }
1492    })?;
1493    let mut pool: Vec<&ClassifiedTag> = if any_stable {
1494        chosen
1495            .iter()
1496            .copied()
1497            .filter(|c| !c.is_prerelease)
1498            .collect()
1499    } else {
1500        chosen.to_vec()
1501    };
1502    // Newest first: descending version, then descending raw for a total,
1503    // stable order on identical versions.
1504    pool.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| b.raw.cmp(&a.raw)));
1505    let mut newest: Vec<String> = pool.into_iter().take(n).map(|c| c.raw.clone()).collect();
1506    newest.reverse(); // oldest→newest
1507    Some(newest)
1508}
1509
1510/// Synthesise a stable "repo name" for a local workspace from its path.
1511/// Used as the inventory key so the same gating + persistence code paths
1512/// that github mode uses can apply to local mode unchanged.
1513fn synthesize_local_name(root: &Path) -> String {
1514    let name = root
1515        .file_name()
1516        .map(|s| s.to_string_lossy().into_owned())
1517        .unwrap_or_else(|| "local".to_string());
1518    format!("local/{name}")
1519}
1520
1521/// Parse the `org/repo` slug from a local checkout's `origin` remote.
1522///
1523/// Shells out to `git -C <root> remote get-url origin` and parses both
1524/// canonical GitHub remote forms, stripping the trailing `.git`:
1525///   - `git@github.com:kkollsga/kglite.git`     → `kkollsga/kglite`
1526///   - `https://github.com/kkollsga/kglite.git` → `kkollsga/kglite`
1527///
1528/// Returns `None` for a non-git directory, a missing `origin` remote, or
1529/// a non-GitHub remote — so the GitHub tools fall back to their existing
1530/// empty-default path (ask the caller for `repo_name`).
1531fn parse_origin_repo(root: &Path) -> Option<String> {
1532    let out = Command::new("git")
1533        .arg("-C")
1534        .arg(root)
1535        .args(["remote", "get-url", "origin"])
1536        .output()
1537        .ok()?;
1538    if !out.status.success() {
1539        return None;
1540    }
1541    let url = String::from_utf8(out.stdout).ok()?;
1542    parse_github_remote(url.trim())
1543}
1544
1545/// Pure-string half of [`parse_origin_repo`]: turn a GitHub remote URL
1546/// into `org/repo`, or `None` if it isn't a recognisable GitHub remote.
1547fn parse_github_remote(url: &str) -> Option<String> {
1548    // Accept both SSH (`git@github.com:org/repo`) and HTTPS
1549    // (`https://github.com/org/repo`) forms; everything after the host
1550    // separator is the path.
1551    let path = url
1552        .strip_prefix("git@github.com:")
1553        .or_else(|| url.strip_prefix("https://github.com/"))
1554        .or_else(|| url.strip_prefix("http://github.com/"))
1555        .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
1556    let path = path.strip_suffix(".git").unwrap_or(path);
1557    let path = path.trim_end_matches('/');
1558    // Must be exactly `org/repo` — both segments non-empty, one slash.
1559    let mut parts = path.split('/');
1560    let org = parts.next().filter(|s| !s.is_empty())?;
1561    let repo = parts.next().filter(|s| !s.is_empty())?;
1562    if parts.next().is_some() {
1563        return None;
1564    }
1565    Some(format!("{org}/{repo}"))
1566}
1567
1568/// Cheap recursive content fingerprint of a directory tree. Walks files
1569/// (respecting common ignore patterns) and folds `(path, mtime, len)`
1570/// into a 64-bit hash, then hex-formats it. Good enough to detect
1571/// "did anything change?" for auto-rebuild gating — not cryptographic.
1572fn fingerprint_dir(root: &Path) -> String {
1573    use std::hash::{Hash, Hasher};
1574    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1575    let walker = ignore::WalkBuilder::new(root)
1576        .standard_filters(true)
1577        .hidden(true)
1578        .git_ignore(true)
1579        .build();
1580    for entry in walker.flatten() {
1581        if !entry.path().is_file() {
1582            continue;
1583        }
1584        let Ok(meta) = entry.metadata() else { continue };
1585        let mtime = meta
1586            .modified()
1587            .ok()
1588            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1589            .map(|d| d.as_secs())
1590            .unwrap_or(0);
1591        entry.path().to_string_lossy().hash(&mut hasher);
1592        mtime.hash(&mut hasher);
1593        meta.len().hash(&mut hasher);
1594    }
1595    format!("local-{:016x}", hasher.finish())
1596}
1597
1598fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
1599    let out = Command::new("git")
1600        .args(["rev-parse", refspec])
1601        .current_dir(repo_path)
1602        .output()
1603        .context("git rev-parse failed")?;
1604    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1605}
1606
1607fn now_iso() -> String {
1608    format_iso(SystemTime::now())
1609}
1610
1611fn format_iso(t: SystemTime) -> String {
1612    let secs = t
1613        .duration_since(SystemTime::UNIX_EPOCH)
1614        .map(|d| d.as_secs())
1615        .unwrap_or(0);
1616    // Lightweight RFC3339-ish formatter. Drop sub-second precision; matches Python isoformat(timespec=seconds).
1617    chrono_lite::format_secs(secs)
1618}
1619
1620fn parse_iso(s: &str) -> Option<SystemTime> {
1621    let secs = chrono_lite::parse_secs(s)?;
1622    SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
1623}
1624
1625fn relative_time(iso: &str) -> String {
1626    let Some(t) = parse_iso(iso) else {
1627        return "unknown".to_string();
1628    };
1629    let now = SystemTime::now();
1630    let delta = now.duration_since(t).unwrap_or_default().as_secs();
1631    if delta < 3600 {
1632        "just now".to_string()
1633    } else if delta < 86_400 {
1634        format!("{}h ago", delta / 3600)
1635    } else {
1636        format!("{}d ago", delta / 86_400)
1637    }
1638}
1639
1640/// Tiny self-contained ISO-8601 (seconds-precision) formatter so we
1641/// don't pull in `chrono` for a handful of timestamps.
1642mod chrono_lite {
1643    pub fn format_secs(secs: u64) -> String {
1644        // Civil-from-days algorithm (Howard Hinnant). Output: YYYY-MM-DDTHH:MM:SS.
1645        let days = (secs / 86_400) as i64;
1646        let time = secs % 86_400;
1647        let (y, mo, d) = days_to_civil(days + 719_468);
1648        let h = time / 3600;
1649        let m = (time / 60) % 60;
1650        let s = time % 60;
1651        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
1652    }
1653
1654    pub fn parse_secs(s: &str) -> Option<u64> {
1655        // Accept "YYYY-MM-DDTHH:MM:SS" (no zone) — same shape as format_secs output
1656        // and Python's datetime.isoformat(timespec="seconds").
1657        let bytes = s.as_bytes();
1658        if bytes.len() < 19 {
1659            return None;
1660        }
1661        let y: i64 = s.get(0..4)?.parse().ok()?;
1662        let mo: u32 = s.get(5..7)?.parse().ok()?;
1663        let d: u32 = s.get(8..10)?.parse().ok()?;
1664        let h: u64 = s.get(11..13)?.parse().ok()?;
1665        let m: u64 = s.get(14..16)?.parse().ok()?;
1666        let sc: u64 = s.get(17..19)?.parse().ok()?;
1667        let days = civil_to_days(y, mo, d) - 719_468;
1668        Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
1669    }
1670
1671    fn days_to_civil(z: i64) -> (i64, u32, u32) {
1672        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1673        let doe = (z - era * 146_097) as u64;
1674        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1675        let y = (yoe as i64) + era * 400;
1676        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1677        let mp = (5 * doy + 2) / 153;
1678        let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1679        let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
1680        let y = if m <= 2 { y + 1 } else { y };
1681        (y, m, d)
1682    }
1683
1684    fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
1685        let y = if m <= 2 { y - 1 } else { y };
1686        let era = if y >= 0 { y } else { y - 399 } / 400;
1687        let yoe = (y - era * 400) as u64;
1688        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
1689        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1690        era * 146_097 + doe as i64
1691    }
1692}
1693
1694// silences unused-import-when-helper-only-via-json! macro check.
1695#[allow(dead_code)]
1696fn _json_keepalive() {
1697    let _ = json!({});
1698}
1699
1700#[cfg(test)]
1701mod tests {
1702    use super::*;
1703
1704    #[test]
1705    fn validates_repo_names() {
1706        assert!(validate_repo_name("pydata/xarray").is_ok());
1707        assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1708        assert!(validate_repo_name("xarray").is_err());
1709        assert!(validate_repo_name("a/b/c").is_err());
1710        assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1711    }
1712
1713    #[test]
1714    fn open_creates_layout() {
1715        let dir = tempfile::tempdir().unwrap();
1716        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1717        assert!(ws.repos_dir().is_dir());
1718    }
1719
1720    #[test]
1721    fn empty_list() {
1722        let dir = tempfile::tempdir().unwrap();
1723        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1724        let out = ws.repo_management(None, false, false, false, None);
1725        assert!(out.contains("No repos cloned yet"));
1726    }
1727
1728    #[test]
1729    fn invalid_repo_name_rejected() {
1730        let dir = tempfile::tempdir().unwrap();
1731        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1732        let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
1733        assert!(out.contains("Invalid repo name"));
1734    }
1735
1736    #[test]
1737    fn delete_unknown() {
1738        let dir = tempfile::tempdir().unwrap();
1739        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1740        let out = ws.repo_management(Some("nope/none"), true, false, false, None);
1741        assert!(out.contains("Nothing to delete"));
1742    }
1743
1744    #[test]
1745    fn iso_round_trip() {
1746        let now = SystemTime::now()
1747            .duration_since(SystemTime::UNIX_EPOCH)
1748            .unwrap()
1749            .as_secs();
1750        let s = chrono_lite::format_secs(now);
1751        let back = chrono_lite::parse_secs(&s).unwrap();
1752        assert_eq!(now, back);
1753    }
1754
1755    #[test]
1756    fn last_built_sha_round_trip() {
1757        let dir = tempfile::tempdir().unwrap();
1758        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1759        // Seed an inventory entry directly (clone_or_update needs git).
1760        ws.bump_access("acme/widgets", "cloned");
1761        assert_eq!(ws.last_built_sha("acme/widgets"), None);
1762        ws.record_built("acme/widgets", "abc1234deadbeef", None);
1763        assert_eq!(
1764            ws.last_built_sha("acme/widgets").as_deref(),
1765            Some("abc1234deadbeef")
1766        );
1767        // Survives an Workspace::open re-read (proves persistence).
1768        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1769        assert_eq!(
1770            ws2.last_built_sha("acme/widgets").as_deref(),
1771            Some("abc1234deadbeef")
1772        );
1773    }
1774
1775    #[test]
1776    fn inventory_loads_legacy_entries_without_sha_field() {
1777        let dir = tempfile::tempdir().unwrap();
1778        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1779        // Hand-craft an old-style inventory.json without `last_built_sha`.
1780        let legacy = r#"{
1781            "old/repo": {
1782                "cloned_at": "2024-01-01T00:00:00",
1783                "last_accessed": "2024-01-01T00:00:00",
1784                "access_count": 5,
1785                "stale": false
1786            }
1787        }"#;
1788        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1789        // Re-open and confirm graceful read.
1790        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1791        assert_eq!(ws2.last_built_sha("old/repo"), None);
1792        let _ = ws;
1793    }
1794
1795    #[test]
1796    fn auto_rebuild_gate_skips_when_sha_matches() {
1797        use std::sync::atomic::{AtomicUsize, Ordering};
1798        let dir = tempfile::tempdir().unwrap();
1799        let calls = Arc::new(AtomicUsize::new(0));
1800        let calls_h = calls.clone();
1801        let hook: PostActivateHook = Arc::new(move |_path, _name| {
1802            calls_h.fetch_add(1, Ordering::SeqCst);
1803            Ok(())
1804        });
1805        // Build a workspace pointing at a tempdir with a fake repo dir,
1806        // then simulate consecutive activates. We can't drive clone_or_update
1807        // without git, so test the gating directly by tracking the SHA
1808        // record-then-re-record case via Workspace::record_built +
1809        // last_built_sha — the same predicate `activate` uses.
1810        let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1811        // Seed inventory entry + initial sha record.
1812        ws.bump_access("acme/widgets", "cloned");
1813        ws.record_built("acme/widgets", "sha_one", None);
1814        assert_eq!(
1815            ws.last_built_sha("acme/widgets").as_deref(),
1816            Some("sha_one")
1817        );
1818        // Repeated record with the same value is idempotent (gating
1819        // logic uses last_built_sha as the source of truth).
1820        ws.record_built("acme/widgets", "sha_one", None);
1821        assert_eq!(
1822            ws.last_built_sha("acme/widgets").as_deref(),
1823            Some("sha_one")
1824        );
1825        // No hook calls have been driven directly — this test exercises
1826        // the persistence path that the gate consults.
1827        assert_eq!(calls.load(Ordering::SeqCst), 0);
1828    }
1829
1830    #[test]
1831    fn local_workspace_binds_root_immediately() {
1832        let dir = tempfile::tempdir().unwrap();
1833        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1834        assert_eq!(ws.kind(), WorkspaceKind::Local);
1835        assert!(ws.active_repo_path().is_some());
1836        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1837    }
1838
1839    #[test]
1840    fn local_workspace_rejects_github_ops() {
1841        let dir = tempfile::tempdir().unwrap();
1842        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1843        let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
1844        assert!(out.contains("does not accept a repo name"));
1845        let out = ws.repo_management(None, true, false, false, None);
1846        assert!(out.contains("does not support `delete`"));
1847    }
1848
1849    #[test]
1850    fn local_workspace_update_rebuilds() {
1851        use std::sync::atomic::{AtomicUsize, Ordering};
1852        let dir = tempfile::tempdir().unwrap();
1853        // Drop a file so the fingerprint has something to hash.
1854        std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1855        let calls = Arc::new(AtomicUsize::new(0));
1856        let calls_h = calls.clone();
1857        let hook: PostActivateHook = Arc::new(move |_p, _n| {
1858            calls_h.fetch_add(1, Ordering::SeqCst);
1859            Ok(())
1860        });
1861        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1862        // First update: nothing built yet → hook fires.
1863        let _ = ws.repo_management(None, false, true, false, None);
1864        assert_eq!(calls.load(Ordering::SeqCst), 1);
1865        // Second update without changes → SHA matches → hook skipped.
1866        let out = ws.repo_management(None, false, true, false, None);
1867        assert_eq!(
1868            calls.load(Ordering::SeqCst),
1869            1,
1870            "auto-rebuild gate must skip"
1871        );
1872        assert!(out.contains("build skipped"));
1873    }
1874
1875    #[test]
1876    fn parses_github_remote_forms() {
1877        assert_eq!(
1878            parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1879            Some("kkollsga/kglite")
1880        );
1881        assert_eq!(
1882            parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1883            Some("kkollsga/kglite")
1884        );
1885        // No .git suffix, trailing slash.
1886        assert_eq!(
1887            parse_github_remote("https://github.com/acme/widget/").as_deref(),
1888            Some("acme/widget")
1889        );
1890        assert_eq!(
1891            parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1892            Some("acme/widget")
1893        );
1894        // Non-github / malformed → None.
1895        assert_eq!(
1896            parse_github_remote("https://gitlab.com/acme/widget.git"),
1897            None
1898        );
1899        assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1900        assert_eq!(parse_github_remote("not a url"), None);
1901    }
1902
1903    #[test]
1904    fn local_default_github_repo_uses_origin_remote() {
1905        let dir = tempfile::tempdir().unwrap();
1906        let root = dir.path();
1907        // Stand up a real git repo with a faked origin so default_github_repo
1908        // exercises the actual `git remote get-url` path.
1909        let git = |args: &[&str]| {
1910            Command::new("git")
1911                .arg("-C")
1912                .arg(root)
1913                .args(args)
1914                .output()
1915                .unwrap()
1916        };
1917        if !git(&["init"]).status.success() {
1918            // git unavailable in this environment — skip rather than fail.
1919            return;
1920        }
1921        git(&[
1922            "remote",
1923            "add",
1924            "origin",
1925            "https://github.com/acme/widget.git",
1926        ]);
1927        let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1928        assert_eq!(
1929            ws.default_github_repo().as_deref(),
1930            Some("acme/widget"),
1931            "local default repo must come from the origin remote, not the inventory key"
1932        );
1933        // The inventory key remains the synthetic local name.
1934        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1935    }
1936
1937    #[test]
1938    fn local_default_github_repo_none_without_remote() {
1939        let dir = tempfile::tempdir().unwrap();
1940        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1941        // No git remote → None, and crucially NOT Some("local/<dir>").
1942        let def = ws.default_github_repo();
1943        assert!(
1944            def.is_none(),
1945            "expected None for a non-git local root, got {def:?}"
1946        );
1947    }
1948
1949    #[test]
1950    fn set_root_dir_only_in_local_mode() {
1951        let dir = tempfile::tempdir().unwrap();
1952        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1953        let out = ws.set_root_dir(dir.path(), None);
1954        assert!(out.contains("only valid in local-workspace"));
1955    }
1956
1957    #[test]
1958    fn update_with_no_active_repo() {
1959        let dir = tempfile::tempdir().unwrap();
1960        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1961        let out = ws.repo_management(None, false, true, false, None);
1962        assert!(out.contains("No active repository"));
1963    }
1964
1965    #[test]
1966    fn set_root_dir_updates_active_path() {
1967        let dir = tempfile::tempdir().unwrap();
1968        let child = dir.path().join("child");
1969        std::fs::create_dir_all(&child).unwrap();
1970        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1971        let _ = ws.set_root_dir(&child, None);
1972        assert_eq!(
1973            ws.active_repo_path().unwrap(),
1974            child.canonicalize().unwrap(),
1975            "set_root_dir didn't update active_repo_path"
1976        );
1977    }
1978
1979    #[test]
1980    fn set_root_dir_post_activate_fires_against_new_root() {
1981        let dir = tempfile::tempdir().unwrap();
1982        let child = dir.path().join("child");
1983        std::fs::create_dir_all(&child).unwrap();
1984        std::fs::write(child.join("a.txt"), b"hi").unwrap();
1985        let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1986        let seen = seen_path.clone();
1987        let hook: PostActivateHook = Arc::new(move |p, _n| {
1988            *seen.lock().unwrap() = Some(p.to_path_buf());
1989            Ok(())
1990        });
1991        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1992        let _ = ws.set_root_dir(&child, None);
1993        assert_eq!(
1994            seen_path.lock().unwrap().clone().unwrap(),
1995            child.canonicalize().unwrap(),
1996            "post_activate hook saw the wrong root after set_root_dir"
1997        );
1998    }
1999
2000    #[test]
2001    fn activation_summary_appended_to_activate_message() {
2002        let dir = tempfile::tempdir().unwrap();
2003        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
2004        let summary: ActivationSummaryHook =
2005            Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
2006        let ws = Workspace::open_local(dir.path().to_path_buf(), None)
2007            .unwrap()
2008            .with_activation_summary(summary);
2009        let out = ws.repo_management(None, false, true, false, None);
2010        assert!(
2011            out.contains("Graph ready: 3 Functions."),
2012            "activation message should include the summary; got: {out}"
2013        );
2014    }
2015
2016    #[test]
2017    fn activation_summary_absent_when_not_configured() {
2018        let dir = tempfile::tempdir().unwrap();
2019        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
2020        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
2021        let out = ws.repo_management(None, false, true, false, None);
2022        assert!(!out.contains("Graph ready"));
2023        assert!(
2024            out.contains(" at "),
2025            "expected the terse default message; got: {out}"
2026        );
2027    }
2028
2029    #[test]
2030    fn hook_fires_once_per_process_even_when_sha_matches() {
2031        use std::sync::atomic::{AtomicUsize, Ordering};
2032        // Local mode fingerprints the dir instead of a git SHA, so we can
2033        // drive the real `activate` path without git. A stable file keeps
2034        // the fingerprint constant across both simulated processes.
2035        let dir = tempfile::tempdir().unwrap();
2036        std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
2037
2038        let calls = Arc::new(AtomicUsize::new(0));
2039        let make_hook = || -> PostActivateHook {
2040            let c = calls.clone();
2041            Arc::new(move |_p, _n| {
2042                c.fetch_add(1, Ordering::SeqCst);
2043                Ok(())
2044            })
2045        };
2046
2047        // --- Process 1 ---------------------------------------------------
2048        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
2049        // First activate (fingerprint not yet recorded) → hook fires.
2050        let _ = ws.repo_management(None, false, true, false, None);
2051        assert_eq!(
2052            calls.load(Ordering::SeqCst),
2053            1,
2054            "first activate must hydrate"
2055        );
2056        // Second activate, same process, unchanged fingerprint → cheap-skip.
2057        let out = ws.repo_management(None, false, true, false, None);
2058        assert_eq!(
2059            calls.load(Ordering::SeqCst),
2060            1,
2061            "repeat activate in same process must skip the hook"
2062        );
2063        assert!(
2064            out.contains("build skipped"),
2065            "expected skip suffix, got: {out}"
2066        );
2067        drop(ws);
2068
2069        // --- Process 2 (restart) ----------------------------------------
2070        // Same dir → inventory.json + last_built_sha persist, but the
2071        // in-memory hydration set does not. The first activate here must
2072        // re-fire the hook to rehydrate the consumer's in-memory state.
2073        let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
2074        assert!(
2075            ws2.last_built_sha(&ws2.active_repo_name().unwrap())
2076                .is_some(),
2077            "sanity: last_built_sha should survive the restart"
2078        );
2079        let _ = ws2.repo_management(None, false, true, false, None);
2080        assert_eq!(
2081            calls.load(Ordering::SeqCst),
2082            2,
2083            "fresh process must re-fire the hook even when the SHA matches"
2084        );
2085    }
2086
2087    #[test]
2088    fn a_b_a_swap_rebuilds_intervening_root() {
2089        // Regression for the single-slot-consumer stale-graph bug: an
2090        // A→B→A swap must rebuild A on the second bind, because activating
2091        // B overwrote the consumer's single live slot. Before the fix the
2092        // skip gate keyed off "A was hydrated at some point this process"
2093        // and wrongly skipped, leaving B's product live under A's name.
2094        use std::sync::atomic::{AtomicUsize, Ordering};
2095        let root = tempfile::tempdir().unwrap();
2096        let a = root.path().join("projA");
2097        let b = root.path().join("projB");
2098        std::fs::create_dir_all(&a).unwrap();
2099        std::fs::create_dir_all(&b).unwrap();
2100        // Stable, distinct contents so each root's fingerprint holds
2101        // constant across re-binds (so `action == "current"` on the
2102        // second bind of A — the exact condition the gate keys on).
2103        std::fs::write(a.join("a.txt"), b"alpha").unwrap();
2104        std::fs::write(b.join("b.txt"), b"beta").unwrap();
2105
2106        // The hook records which root it last built into the single slot,
2107        // mirroring a single-active-graph consumer.
2108        let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
2109        let built_h = built.clone();
2110        let calls = Arc::new(AtomicUsize::new(0));
2111        let calls_h = calls.clone();
2112        let hook: PostActivateHook = Arc::new(move |p, _n| {
2113            *built_h.lock().unwrap() = Some(p.to_path_buf());
2114            calls_h.fetch_add(1, Ordering::SeqCst);
2115            Ok(())
2116        });
2117
2118        let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
2119        // open_local binds A but doesn't fire the hook; first set_root_dir(A)
2120        // hydrates it.
2121        let _ = ws.set_root_dir(&a, None);
2122        assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
2123        assert_eq!(
2124            built.lock().unwrap().clone(),
2125            Some(a.canonicalize().unwrap())
2126        );
2127
2128        let _ = ws.set_root_dir(&b, None);
2129        assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
2130        assert_eq!(
2131            built.lock().unwrap().clone(),
2132            Some(b.canonicalize().unwrap())
2133        );
2134
2135        // The bug: re-binding A must rebuild (slot currently holds B), not
2136        // cheap-skip. The single slot must end up holding A again.
2137        let out = ws.set_root_dir(&a, None);
2138        assert_eq!(
2139            calls.load(Ordering::SeqCst),
2140            3,
2141            "A→B→A must rebuild A; the intervening B overwrote the live slot"
2142        );
2143        assert!(
2144            !out.contains("build skipped"),
2145            "re-bind of a non-active root must not skip; got: {out}"
2146        );
2147        assert_eq!(
2148            built.lock().unwrap().clone(),
2149            Some(a.canonicalize().unwrap()),
2150            "after A→B→A the live slot must hold A, not B"
2151        );
2152
2153        // And an immediate re-bind of the *currently active* root (A→A)
2154        // still cheap-skips — the win the gate was added for is preserved.
2155        let out = ws.set_root_dir(&a, None);
2156        assert_eq!(
2157            calls.load(Ordering::SeqCst),
2158            3,
2159            "re-binding the already-active root must skip the hook"
2160        );
2161        assert!(
2162            out.contains("build skipped"),
2163            "expected skip suffix, got: {out}"
2164        );
2165    }
2166
2167    #[test]
2168    fn transaction_slow_a_fast_b_discards_stale_build_and_keeps_responses_coherent() {
2169        use std::sync::Barrier;
2170
2171        #[derive(Debug, Clone, PartialEq, Eq)]
2172        struct Installed {
2173            id: ActivationId,
2174            path: PathBuf,
2175        }
2176
2177        let root = tempfile::tempdir().unwrap();
2178        let a = root.path().join("slow-a");
2179        let b = root.path().join("fast-b");
2180        std::fs::create_dir_all(&a).unwrap();
2181        std::fs::create_dir_all(&b).unwrap();
2182        std::fs::write(a.join("a.txt"), b"a").unwrap();
2183        std::fs::write(b.join("b.txt"), b"b").unwrap();
2184        let a = a.canonicalize().unwrap();
2185        let b = b.canonicalize().unwrap();
2186
2187        let a_entered = Arc::new(Barrier::new(2));
2188        let release_a = Arc::new(Barrier::new(2));
2189        let installed: Arc<Mutex<Option<Installed>>> = Arc::new(Mutex::new(None));
2190        let hook: ActivationTransactionHook = {
2191            let a = a.clone();
2192            let a_entered = a_entered.clone();
2193            let release_a = release_a.clone();
2194            let installed = installed.clone();
2195            Arc::new(move |request| {
2196                if request.path() == a {
2197                    a_entered.wait();
2198                    release_a.wait();
2199                }
2200                let product = Installed {
2201                    id: request.id(),
2202                    path: request.path().to_path_buf(),
2203                };
2204                let installed = installed.clone();
2205                Ok(PreparedActivation::new(move || {
2206                    *installed.lock().unwrap() = Some(product.clone());
2207                    Ok(Some(format!(
2208                        "product {} for {}",
2209                        product.id,
2210                        product.path.display()
2211                    )))
2212                }))
2213            })
2214        };
2215        let ws = Workspace::open_local(a.clone(), None)
2216            .unwrap()
2217            .with_activation_transaction(hook);
2218
2219        let slow_ws = ws.clone();
2220        let slow_a = a.clone();
2221        let slow = std::thread::spawn(move || slow_ws.set_root_dir(&slow_a, None));
2222        a_entered.wait();
2223
2224        let fast_ws = ws.clone();
2225        let fast_b = b.clone();
2226        let fast = std::thread::spawn(move || fast_ws.set_root_dir(&fast_b, None));
2227        let fast_out = fast.join().unwrap();
2228        release_a.wait();
2229        let slow_out = slow.join().unwrap();
2230
2231        assert!(
2232            fast_out.contains(&b.display().to_string())
2233                && fast_out.contains("product 2")
2234                && !fast_out.contains(&a.display().to_string()),
2235            "fast request response must describe only its own committed product: {fast_out}"
2236        );
2237        assert!(
2238            slow_out.contains("request 1")
2239                && slow_out.contains("superseded by request 2")
2240                && !slow_out.contains("product 1"),
2241            "stale request must report supersession, not a false activation: {slow_out}"
2242        );
2243        assert_eq!(ws.active_repo_path(), Some(b.clone()));
2244        assert_eq!(installed.lock().unwrap().as_ref().unwrap().path, b);
2245        assert_eq!(
2246            ws.inner
2247                .state
2248                .read()
2249                .unwrap()
2250                .active_build
2251                .as_ref()
2252                .unwrap()
2253                .activation_id,
2254            ActivationId(2),
2255            "latest request must own the final framework state"
2256        );
2257    }
2258
2259    #[test]
2260    fn legacy_callbacks_are_serialized_through_build_and_summary() {
2261        use std::sync::Barrier;
2262
2263        let root = tempfile::tempdir().unwrap();
2264        let a = root.path().join("slow-a");
2265        let b = root.path().join("queued-b");
2266        std::fs::create_dir_all(&a).unwrap();
2267        std::fs::create_dir_all(&b).unwrap();
2268        let a = a.canonicalize().unwrap();
2269        let b = b.canonicalize().unwrap();
2270        let a_entered = Arc::new(Barrier::new(2));
2271        let release_a = Arc::new(Barrier::new(2));
2272        let installed: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
2273        let hook: PostActivateHook = {
2274            let a = a.clone();
2275            let a_entered = a_entered.clone();
2276            let release_a = release_a.clone();
2277            let installed = installed.clone();
2278            Arc::new(move |path, _name| {
2279                if path == a {
2280                    a_entered.wait();
2281                    release_a.wait();
2282                }
2283                *installed.lock().unwrap() = Some(path.to_path_buf());
2284                Ok(())
2285            })
2286        };
2287        let summary: ActivationSummaryHook = {
2288            let installed = installed.clone();
2289            Arc::new(move |_path, _name| {
2290                installed
2291                    .lock()
2292                    .unwrap()
2293                    .as_ref()
2294                    .map(|path| format!("legacy product {}", path.display()))
2295            })
2296        };
2297        let ws = Workspace::open_local(a.clone(), Some(hook))
2298            .unwrap()
2299            .with_activation_summary(summary);
2300
2301        let a_ws = ws.clone();
2302        let a_root = a.clone();
2303        let a_thread = std::thread::spawn(move || a_ws.set_root_dir(&a_root, None));
2304        a_entered.wait();
2305        let b_ws = ws.clone();
2306        let b_root = b.clone();
2307        let b_thread = std::thread::spawn(move || b_ws.set_root_dir(&b_root, None));
2308        release_a.wait();
2309        let a_out = a_thread.join().unwrap();
2310        let b_out = b_thread.join().unwrap();
2311
2312        assert!(
2313            a_out.contains(&format!("legacy product {}", a.display()))
2314                && !a_out.contains(&format!("legacy product {}", b.display())),
2315            "legacy A response crossed activation products: {a_out}"
2316        );
2317        assert!(
2318            b_out.contains(&format!("legacy product {}", b.display()))
2319                && !b_out.contains(&format!("legacy product {}", a.display())),
2320            "legacy B response crossed activation products: {b_out}"
2321        );
2322        assert_eq!(ws.active_repo_path(), Some(b.clone()));
2323        assert_eq!(*installed.lock().unwrap(), Some(b));
2324    }
2325
2326    #[test]
2327    fn transaction_same_root_plain_vs_revisions_is_generation_ordered() {
2328        use std::sync::Barrier;
2329
2330        let Some((_dir, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2331            return;
2332        };
2333        let root = root.canonicalize().unwrap();
2334        let plain_entered = Arc::new(Barrier::new(2));
2335        let release_plain = Arc::new(Barrier::new(2));
2336        let installed: Arc<Mutex<Option<(ActivationId, ActivationBuild)>>> =
2337            Arc::new(Mutex::new(None));
2338        let hook: ActivationTransactionHook = {
2339            let plain_entered = plain_entered.clone();
2340            let release_plain = release_plain.clone();
2341            let installed = installed.clone();
2342            Arc::new(move |request| {
2343                if matches!(request.build(), ActivationBuild::Plain) {
2344                    plain_entered.wait();
2345                    release_plain.wait();
2346                }
2347                let id = request.id();
2348                let build = request.build().clone();
2349                let installed = installed.clone();
2350                Ok(PreparedActivation::new(move || {
2351                    *installed.lock().unwrap() = Some((id, build.clone()));
2352                    Ok(Some(format!("installed request {id}: {build:?}")))
2353                }))
2354            })
2355        };
2356        let ws = Workspace::open_local(root.clone(), None)
2357            .unwrap()
2358            .with_activation_transaction(hook);
2359
2360        let plain_ws = ws.clone();
2361        let plain_root = root.clone();
2362        let plain = std::thread::spawn(move || plain_ws.set_root_dir(&plain_root, None));
2363        plain_entered.wait();
2364
2365        let revs_ws = ws.clone();
2366        let revs_root = root.clone();
2367        let revs = std::thread::spawn(move || {
2368            revs_ws.set_root_dir(&revs_root, Some(&RevsRequest::Count(2)))
2369        });
2370        let revs_out = revs.join().unwrap();
2371        release_plain.wait();
2372        let plain_out = plain.join().unwrap();
2373
2374        assert!(revs_out.contains("revs: v1.0.0, v2.0.0, HEAD"));
2375        assert!(revs_out.contains("installed request 2: Revisions"));
2376        assert!(plain_out.contains("superseded by request 2"));
2377        let state = ws.inner.state.read().unwrap();
2378        assert_eq!(state.active_repo_path.as_deref(), Some(root.as_path()));
2379        assert_eq!(
2380            state
2381                .active_build
2382                .as_ref()
2383                .and_then(|built| built.resolved_revs.clone()),
2384            Some(vec!["v1.0.0".into(), "v2.0.0".into(), "HEAD".into()])
2385        );
2386        assert!(matches!(
2387            installed.lock().unwrap().as_ref(),
2388            Some((ActivationId(2), ActivationBuild::Revisions(_)))
2389        ));
2390    }
2391
2392    #[test]
2393    fn transaction_current_failure_preserves_committed_source_and_product() {
2394        #[derive(Debug, Clone, PartialEq, Eq)]
2395        struct Installed(ActivationId, PathBuf);
2396
2397        let root = tempfile::tempdir().unwrap();
2398        let good = root.path().join("good");
2399        let broken = root.path().join("broken");
2400        std::fs::create_dir_all(&good).unwrap();
2401        std::fs::create_dir_all(&broken).unwrap();
2402        std::fs::write(good.join("good.txt"), b"good").unwrap();
2403        std::fs::write(broken.join("broken.txt"), b"broken").unwrap();
2404        let good = good.canonicalize().unwrap();
2405        let broken = broken.canonicalize().unwrap();
2406
2407        let installed: Arc<Mutex<Option<Installed>>> = Arc::new(Mutex::new(None));
2408        let hook: ActivationTransactionHook = {
2409            let broken = broken.clone();
2410            let installed = installed.clone();
2411            Arc::new(move |request| {
2412                if request.path() == broken {
2413                    anyhow::bail!("builder rejected broken root");
2414                }
2415                let product = Installed(request.id(), request.path().to_path_buf());
2416                let installed = installed.clone();
2417                Ok(PreparedActivation::new(move || {
2418                    *installed.lock().unwrap() = Some(product.clone());
2419                    Ok(Some(format!("installed request {}", product.0)))
2420                }))
2421            })
2422        };
2423        let ws = Workspace::open_local(good.clone(), None)
2424            .unwrap()
2425            .with_activation_transaction(hook);
2426
2427        let good_out = ws.set_root_dir(&good, None);
2428        assert!(good_out.contains("installed request 1"));
2429        let broken_out = ws.set_root_dir(&broken, None);
2430        assert!(
2431            broken_out.contains("request 2")
2432                && broken_out.contains("failed during preparation")
2433                && broken_out.contains("builder rejected broken root"),
2434            "failure must be explicit and request-scoped: {broken_out}"
2435        );
2436        assert_eq!(ws.active_repo_path(), Some(good.clone()));
2437        assert_eq!(installed.lock().unwrap().as_ref().unwrap().1, good);
2438    }
2439
2440    #[test]
2441    fn transaction_stale_failure_reports_superseded_not_current_failure() {
2442        use std::sync::Barrier;
2443
2444        let root = tempfile::tempdir().unwrap();
2445        let slow = root.path().join("slow-failure");
2446        let fast = root.path().join("fast-success");
2447        std::fs::create_dir_all(&slow).unwrap();
2448        std::fs::create_dir_all(&fast).unwrap();
2449        let slow = slow.canonicalize().unwrap();
2450        let fast = fast.canonicalize().unwrap();
2451        let slow_entered = Arc::new(Barrier::new(2));
2452        let release_slow = Arc::new(Barrier::new(2));
2453        let hook: ActivationTransactionHook = {
2454            let slow = slow.clone();
2455            let slow_entered = slow_entered.clone();
2456            let release_slow = release_slow.clone();
2457            Arc::new(move |request| {
2458                if request.path() == slow {
2459                    slow_entered.wait();
2460                    release_slow.wait();
2461                    anyhow::bail!("late preparation failure");
2462                }
2463                Ok(PreparedActivation::summary(Some(format!(
2464                    "committed request {}",
2465                    request.id()
2466                ))))
2467            })
2468        };
2469        let ws = Workspace::open_local(slow.clone(), None)
2470            .unwrap()
2471            .with_activation_transaction(hook);
2472
2473        let slow_ws = ws.clone();
2474        let slow_root = slow.clone();
2475        let slow_thread = std::thread::spawn(move || slow_ws.set_root_dir(&slow_root, None));
2476        slow_entered.wait();
2477        let fast_out = ws.set_root_dir(&fast, None);
2478        release_slow.wait();
2479        let slow_out = slow_thread.join().unwrap();
2480
2481        assert!(fast_out.contains("committed request 2"));
2482        assert!(
2483            slow_out.contains("superseded by request 2")
2484                && slow_out.contains("failed build")
2485                && !slow_out.contains("set_root_dir failed"),
2486            "a stale failure is a superseded outcome: {slow_out}"
2487        );
2488        assert_eq!(ws.active_repo_path(), Some(fast));
2489    }
2490
2491    // ------------------------------------------------------------------
2492    // revs (multi-revision activation)
2493    // ------------------------------------------------------------------
2494
2495    /// Stand up a real git repo at a fresh tempdir with the given tags
2496    /// created in order (so version-sort ordering is exercised). Returns
2497    /// the tempdir (keep alive) + its path, or `None` if git is
2498    /// unavailable in the environment (test then skips).
2499    fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
2500        let dir = tempfile::tempdir().unwrap();
2501        let root = dir.path().to_path_buf();
2502        let git = |args: &[&str]| {
2503            Command::new("git")
2504                .arg("-C")
2505                .arg(&root)
2506                .args(args)
2507                .output()
2508                .unwrap()
2509        };
2510        if !git(&["init"]).status.success() {
2511            return None; // git unavailable — caller skips.
2512        }
2513        git(&["config", "user.email", "t@example.com"]);
2514        git(&["config", "user.name", "Test"]);
2515        git(&["config", "commit.gpgsign", "false"]);
2516        for (i, tag) in tags.iter().enumerate() {
2517            std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
2518            git(&["add", "-A"]);
2519            assert!(
2520                git(&["commit", "-m", &format!("c{i}")]).status.success(),
2521                "git commit failed"
2522            );
2523            assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
2524        }
2525        Some((dir, root))
2526    }
2527
2528    // ---- tag classification (pure, no git) --------------------------
2529
2530    #[test]
2531    fn classify_tag_extracts_prefix_version_prerelease() {
2532        let c = classify_tag("apache-arrow-25.0.0").unwrap();
2533        assert_eq!(c.prefix, "apache-arrow-");
2534        assert_eq!(c.version, vec![25, 0, 0]);
2535        assert!(!c.is_prerelease);
2536
2537        // Prerelease markers with various separators, case-insensitive.
2538        for t in [
2539            "apache-arrow-25.0.0.dev",
2540            "apache-arrow-25.0.0-rc1",
2541            "apache-arrow-25.0.0-RC0",
2542            "v1.2.3-beta2",
2543            "v1.2.3_alpha",
2544            "v2.0.0-preview",
2545        ] {
2546            assert!(
2547                classify_tag(t).unwrap().is_prerelease,
2548                "{t} should be prerelease"
2549            );
2550        }
2551
2552        // Distinct families keyed on prefix.
2553        assert_eq!(classify_tag("go/v18.0.0").unwrap().prefix, "go/v");
2554        assert_eq!(classify_tag("r-15.0.1").unwrap().prefix, "r-");
2555        assert_eq!(classify_tag("v1.2.3").unwrap().prefix, "v");
2556
2557        // Last digit run wins: the `2` in `arrow2` is not the version.
2558        let c = classify_tag("arrow2-0.17.0").unwrap();
2559        assert_eq!(c.prefix, "arrow2-");
2560        assert_eq!(c.version, vec![0, 17, 0]);
2561    }
2562
2563    #[test]
2564    fn classify_tag_excludes_non_version_tags() {
2565        assert_eq!(classify_tag("r-universe-release"), None);
2566        assert_eq!(classify_tag("latest"), None);
2567        assert_eq!(classify_tag("nightly"), None);
2568        // A version followed by an *unrecognised* suffix is not version-like.
2569        assert_eq!(classify_tag("v1.2.3-foobar"), None);
2570    }
2571
2572    #[test]
2573    fn select_family_tags_picks_dominant_release_family_skipping_prereleases() {
2574        // Mirrors the apache/arrow shape: a large `apache-arrow-*` release
2575        // family (with newest entries being prereleases), plus unrelated
2576        // `r-*` / `go/v*` families and a rolling non-version pointer.
2577        let tags: Vec<String> = [
2578            "apache-arrow-22.0.0",
2579            "apache-arrow-23.0.0",
2580            "apache-arrow-24.0.0",
2581            "apache-arrow-25.0.0-rc0",
2582            "apache-arrow-25.0.0-rc1",
2583            "apache-arrow-25.0.0.dev",
2584            "go/v18.0.0",
2585            "r-15.0.1",
2586            "r-16.1.0",
2587            "r-universe-release",
2588        ]
2589        .iter()
2590        .map(|s| s.to_string())
2591        .collect();
2592        // Newest 2 STABLE of the dominant (apache-arrow-) family,
2593        // oldest→newest; the 25.0.0 prereleases and r-*/go/v* are excluded.
2594        let got = select_family_tags(&tags, 2).unwrap();
2595        assert_eq!(got, vec!["apache-arrow-23.0.0", "apache-arrow-24.0.0"]);
2596    }
2597
2598    #[test]
2599    fn select_family_tags_fewer_stable_than_requested_uses_all_stable() {
2600        let tags: Vec<String> = ["v1.0.0", "v2.0.0", "v3.0.0-rc1"]
2601            .iter()
2602            .map(|s| s.to_string())
2603            .collect();
2604        // Only two stable; the rc is skipped even though it's newest.
2605        let got = select_family_tags(&tags, 5).unwrap();
2606        assert_eq!(got, vec!["v1.0.0", "v2.0.0"]);
2607    }
2608
2609    #[test]
2610    fn select_family_tags_prerelease_only_family_falls_back_to_prereleases() {
2611        let tags: Vec<String> = ["v1.0.0-rc1", "v1.0.0-rc2", "v0.9.0-beta"]
2612            .iter()
2613            .map(|s| s.to_string())
2614            .collect();
2615        // No stable tag anywhere → newest prereleases of the family.
2616        let got = select_family_tags(&tags, 2).unwrap();
2617        assert_eq!(got, vec!["v1.0.0-rc1", "v1.0.0-rc2"]);
2618    }
2619
2620    #[test]
2621    fn select_family_tags_no_version_like_tags_returns_none() {
2622        let tags: Vec<String> = ["latest", "nightly", "stable"]
2623            .iter()
2624            .map(|s| s.to_string())
2625            .collect();
2626        assert_eq!(select_family_tags(&tags, 3), None);
2627    }
2628
2629    // ---- resolve_revs Count (git-gated) -----------------------------
2630
2631    #[test]
2632    fn resolve_revs_count_falls_back_to_raw_when_no_version_tags() {
2633        // Non-version tags → the family selector yields None and
2634        // resolve_revs preserves the raw version-sorted top-n behaviour.
2635        let Some((_d, root)) = git_repo_with_tags(&["latest", "nightly", "stable"]) else {
2636            return;
2637        };
2638        let ws = Workspace::open_local(root.clone(), None).unwrap();
2639        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
2640        // Exactly 2 tags + HEAD, HEAD last; contents come from raw top-n.
2641        assert_eq!(resolved.len(), 3);
2642        assert_eq!(resolved.last().unwrap(), "HEAD");
2643        assert!(resolved[..2].iter().all(|r| r != "HEAD"));
2644    }
2645
2646    #[test]
2647    fn resolve_revs_count_skips_prereleases_of_dominant_family() {
2648        let Some((_d, root)) =
2649            git_repo_with_tags(&["v1.0.0", "v2.0.0", "v3.0.0-rc1", "v3.0.0.dev"])
2650        else {
2651            return;
2652        };
2653        let ws = Workspace::open_local(root.clone(), None).unwrap();
2654        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
2655        // Newest 2 stable (v1.0.0, v2.0.0) oldest→newest, then HEAD —
2656        // the v3 prereleases are excluded.
2657        assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
2658    }
2659
2660    #[test]
2661    fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
2662        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
2663            return;
2664        };
2665        let ws = Workspace::open_local(root.clone(), None).unwrap();
2666        let resolved = ws
2667            .resolve_revs(&root, &RevsRequest::Count(2))
2668            .expect("resolve should succeed");
2669        // Newest 2 = v2.0.0, v1.1.0 → oldest→newest → v1.1.0, v2.0.0, then HEAD.
2670        assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
2671    }
2672
2673    #[test]
2674    fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
2675        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2676            return;
2677        };
2678        let ws = Workspace::open_local(root.clone(), None).unwrap();
2679        let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
2680        assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
2681    }
2682
2683    #[test]
2684    fn resolve_revs_count_errors_when_no_tags() {
2685        let Some((_d, root)) = git_repo_with_tags(&[]) else {
2686            return;
2687        };
2688        // Empty repo has no commits yet; make one commit but no tags.
2689        let git = |args: &[&str]| {
2690            Command::new("git")
2691                .arg("-C")
2692                .arg(&root)
2693                .args(args)
2694                .output()
2695                .unwrap()
2696        };
2697        std::fs::write(root.join("f.txt"), b"x").unwrap();
2698        git(&["add", "-A"]);
2699        git(&["commit", "-m", "c0"]);
2700        let ws = Workspace::open_local(root.clone(), None).unwrap();
2701        let err = ws
2702            .resolve_revs(&root, &RevsRequest::Count(3))
2703            .expect_err("no tags → error");
2704        assert!(
2705            err.to_string().contains("no tags"),
2706            "expected a 'no tags' error, got: {err}"
2707        );
2708    }
2709
2710    // ---- dedup of resolved revs -------------------------------------
2711
2712    #[test]
2713    fn dedup_labels_is_order_preserving_first_wins() {
2714        assert_eq!(
2715            dedup_labels(vec!["HEAD".into(), "HEAD".into()]),
2716            vec!["HEAD"]
2717        );
2718        assert_eq!(
2719            dedup_labels(vec![
2720                "v1".into(),
2721                "v2".into(),
2722                "v1".into(),
2723                "v3".into(),
2724                "v2".into(),
2725            ]),
2726            vec!["v1", "v2", "v3"]
2727        );
2728        // Empty and already-unique lists pass through untouched.
2729        assert_eq!(dedup_labels(vec![]), Vec::<String>::new());
2730        assert_eq!(dedup_labels(vec!["a".into(), "b".into()]), vec!["a", "b"]);
2731    }
2732
2733    #[test]
2734    fn resolve_revs_list_dedups_duplicate_revspecs() {
2735        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
2736            return;
2737        };
2738        let ws = Workspace::open_local(root.clone(), None).unwrap();
2739        // `["HEAD","HEAD"]` collapses to a single `HEAD`.
2740        let got = ws
2741            .resolve_revs(
2742                &root,
2743                &RevsRequest::List(vec!["HEAD".into(), "HEAD".into()]),
2744            )
2745            .unwrap();
2746        assert_eq!(got, vec!["HEAD"]);
2747        // First-occurrence order is preserved across mixed duplicates.
2748        let got = ws
2749            .resolve_revs(
2750                &root,
2751                &RevsRequest::List(vec!["v1.0.0".into(), "HEAD".into(), "v1.0.0".into()]),
2752            )
2753            .unwrap();
2754        assert_eq!(got, vec!["v1.0.0", "HEAD"]);
2755    }
2756
2757    #[test]
2758    fn resolve_revs_list_validates_and_rejects_unknown() {
2759        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
2760            return;
2761        };
2762        let ws = Workspace::open_local(root.clone(), None).unwrap();
2763        // Explicit list is used verbatim (no HEAD appended, no sort).
2764        let ok = ws
2765            .resolve_revs(
2766                &root,
2767                &RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
2768            )
2769            .unwrap();
2770        assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
2771        // An unknown rev is a clear error naming the bad rev.
2772        let err = ws
2773            .resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
2774            .expect_err("unknown rev → error");
2775        assert!(
2776            err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
2777            "expected an unknown-rev error, got: {err}"
2778        );
2779    }
2780
2781    #[test]
2782    fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
2783        use std::sync::atomic::{AtomicUsize, Ordering};
2784        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
2785            return;
2786        };
2787        let plain_calls = Arc::new(AtomicUsize::new(0));
2788        let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
2789        let pc = plain_calls.clone();
2790        let plain: PostActivateHook = Arc::new(move |_p, _n| {
2791            pc.fetch_add(1, Ordering::SeqCst);
2792            Ok(())
2793        });
2794        let sr = seen_revs.clone();
2795        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
2796            *sr.lock().unwrap() = Some(revs.to_vec());
2797            Ok(())
2798        });
2799        let ws = Workspace::open_local(root.clone(), Some(plain))
2800            .unwrap()
2801            .with_post_activate_revs(revs_hook);
2802        let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
2803        // The revs-hook ran with the resolved list; the plain hook did NOT.
2804        assert_eq!(
2805            seen_revs.lock().unwrap().clone().unwrap(),
2806            vec!["v1.1.0", "v2.0.0", "HEAD"]
2807        );
2808        assert_eq!(
2809            plain_calls.load(Ordering::SeqCst),
2810            0,
2811            "plain hook must not fire when the revs-hook handled the request"
2812        );
2813        // The activation message names the resolved revs on one line.
2814        assert!(
2815            out.contains("revs: v1.1.0, v2.0.0, HEAD"),
2816            "activation message should list the resolved revs; got: {out}"
2817        );
2818    }
2819
2820    #[test]
2821    fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
2822        use std::sync::atomic::{AtomicUsize, Ordering};
2823        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
2824            return;
2825        };
2826        let plain_calls = Arc::new(AtomicUsize::new(0));
2827        let revs_seen = Arc::new(AtomicUsize::new(0));
2828        let pc = plain_calls.clone();
2829        let plain: PostActivateHook = Arc::new(move |_p, _n| {
2830            pc.fetch_add(1, Ordering::SeqCst);
2831            Ok(())
2832        });
2833        let rs = revs_seen.clone();
2834        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
2835            rs.fetch_add(1, Ordering::SeqCst);
2836            Ok(())
2837        });
2838        let ws = Workspace::open_local(root.clone(), Some(plain))
2839            .unwrap()
2840            .with_post_activate_revs(revs_hook);
2841        // No revs → plain hook fires, revs-hook untouched, no `revs:` line.
2842        let out = ws.repo_management(None, false, true, false, None);
2843        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2844        assert_eq!(
2845            revs_seen.load(Ordering::SeqCst),
2846            0,
2847            "revs-hook must not fire when no revs were requested"
2848        );
2849        assert!(
2850            !out.contains("revs:"),
2851            "no revs line expected on a plain activation; got: {out}"
2852        );
2853    }
2854
2855    #[test]
2856    fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
2857        use std::sync::atomic::{AtomicUsize, Ordering};
2858        let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2859            return;
2860        };
2861        let plain_calls = Arc::new(AtomicUsize::new(0));
2862        let pc = plain_calls.clone();
2863        let plain: PostActivateHook = Arc::new(move |_p, _n| {
2864            pc.fetch_add(1, Ordering::SeqCst);
2865            Ok(())
2866        });
2867        // No revs-hook attached: a revs request degrades to the plain
2868        // (HEAD-only) build and does NOT claim a rev-set in the message.
2869        let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
2870        let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
2871        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2872        assert!(
2873            !out.contains("revs:"),
2874            "must not report a rev-set when only the plain hook ran; got: {out}"
2875        );
2876    }
2877
2878    // ---- rev-set-aware skip gate + stored-request persistence -------
2879
2880    /// Build a local workspace over a git repo with tags, wired with both
2881    /// a plain and a revs hook, each incrementing a shared counter.
2882    /// Returns (workspace, tempdir-guard, root, plain_calls, revs_calls).
2883    #[allow(clippy::type_complexity)]
2884    fn ws_with_both_hooks(
2885        tags: &[&str],
2886    ) -> Option<(
2887        Workspace,
2888        tempfile::TempDir,
2889        PathBuf,
2890        Arc<std::sync::atomic::AtomicUsize>,
2891        Arc<std::sync::atomic::AtomicUsize>,
2892    )> {
2893        use std::sync::atomic::{AtomicUsize, Ordering};
2894        let (d, root) = git_repo_with_tags(tags)?;
2895        let plain_calls = Arc::new(AtomicUsize::new(0));
2896        let revs_calls = Arc::new(AtomicUsize::new(0));
2897        let pc = plain_calls.clone();
2898        let plain: PostActivateHook = Arc::new(move |_p, _n| {
2899            pc.fetch_add(1, Ordering::SeqCst);
2900            Ok(())
2901        });
2902        let rc = revs_calls.clone();
2903        let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _r| {
2904            rc.fetch_add(1, Ordering::SeqCst);
2905            Ok(())
2906        });
2907        let ws = Workspace::open_local(root.clone(), Some(plain))
2908            .unwrap()
2909            .with_post_activate_revs(revs_hook);
2910        Some((ws, d, root, plain_calls, revs_calls))
2911    }
2912
2913    #[test]
2914    fn plain_activation_after_revs_build_rebuilds_plain() {
2915        use std::sync::atomic::Ordering;
2916        let Some((ws, _d, root, plain_calls, revs_calls)) =
2917            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2918        else {
2919            return;
2920        };
2921        // Multi-rev build first.
2922        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2923        assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
2924        assert_eq!(plain_calls.load(Ordering::SeqCst), 0);
2925        // A plain re-bind at the SAME (unchanged) root must NOT cheap-skip
2926        // just because HEAD matches — the last build was multi-rev. It
2927        // rebuilds plain, and the message claims no rev-set.
2928        let out = ws.set_root_dir(&root, None);
2929        assert_eq!(
2930            plain_calls.load(Ordering::SeqCst),
2931            1,
2932            "plain re-activation after a revs build must fire the plain hook"
2933        );
2934        assert!(
2935            !out.contains("build skipped"),
2936            "must not skip a plain re-activation after a revs build; got: {out}"
2937        );
2938        assert!(
2939            !out.contains("revs:"),
2940            "plain rebuild must not claim revs; got: {out}"
2941        );
2942        // The stored request is cleared, so a further plain re-bind now
2943        // cheap-skips (proves the reset took).
2944        let out = ws.set_root_dir(&root, None);
2945        assert_eq!(
2946            plain_calls.load(Ordering::SeqCst),
2947            1,
2948            "second plain re-bind skips"
2949        );
2950        assert!(
2951            out.contains("build skipped"),
2952            "expected skip suffix; got: {out}"
2953        );
2954    }
2955
2956    #[test]
2957    fn update_after_revs_build_reapplies_stored_revs() {
2958        use std::sync::atomic::Ordering;
2959        let Some((ws, _d, root, plain_calls, revs_calls)) =
2960            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2961        else {
2962            return;
2963        };
2964        // Multi-rev build first.
2965        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2966        assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
2967        // A bare `update` (no revs) must re-apply the stored rev-set —
2968        // re-firing the revs hook, not collapsing to a plain HEAD build.
2969        let out = ws.repo_management(None, false, true, false, None);
2970        assert_eq!(
2971            revs_calls.load(Ordering::SeqCst),
2972            2,
2973            "bare update must re-apply the stored rev-set"
2974        );
2975        assert_eq!(
2976            plain_calls.load(Ordering::SeqCst),
2977            0,
2978            "bare update after a revs build must not fall to the plain hook"
2979        );
2980        assert!(
2981            out.contains("revs:"),
2982            "re-applied update should list the revs; got: {out}"
2983        );
2984    }
2985
2986    #[test]
2987    fn revs_activation_after_plain_build_always_rebuilds() {
2988        use std::sync::atomic::Ordering;
2989        let Some((ws, _d, root, plain_calls, revs_calls)) =
2990            ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2991        else {
2992            return;
2993        };
2994        // Plain build first.
2995        let _ = ws.set_root_dir(&root, None);
2996        assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2997        assert_eq!(revs_calls.load(Ordering::SeqCst), 0);
2998        // A revs request at the unchanged HEAD still always fires the revs
2999        // hook (revs requests are never skipped by the SHA gate).
3000        let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
3001        assert_eq!(
3002            revs_calls.load(Ordering::SeqCst),
3003            1,
3004            "a revs request must always rebuild, even at an unchanged HEAD"
3005        );
3006    }
3007
3008    #[test]
3009    fn last_built_revs_round_trips_and_clears_on_plain_build() {
3010        let dir = tempfile::tempdir().unwrap();
3011        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
3012        ws.bump_access("acme/widgets", "cloned");
3013        assert_eq!(ws.last_built_revs("acme/widgets"), None);
3014        // Record a multi-rev build.
3015        ws.record_built("acme/widgets", "sha1", Some(&RevsRequest::Count(3)));
3016        assert_eq!(
3017            ws.last_built_revs("acme/widgets"),
3018            Some(RevsRequest::Count(3))
3019        );
3020        // Survives a reopen (persisted to inventory.json).
3021        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
3022        assert_eq!(
3023            ws2.last_built_revs("acme/widgets"),
3024            Some(RevsRequest::Count(3))
3025        );
3026        // A subsequent plain build clears the stored request.
3027        ws2.record_built("acme/widgets", "sha2", None);
3028        assert_eq!(ws2.last_built_revs("acme/widgets"), None);
3029        // A List request round-trips too.
3030        ws2.record_built(
3031            "acme/widgets",
3032            "sha3",
3033            Some(&RevsRequest::List(vec!["v1".into(), "v2".into()])),
3034        );
3035        assert_eq!(
3036            ws2.last_built_revs("acme/widgets"),
3037            Some(RevsRequest::List(vec!["v1".into(), "v2".into()]))
3038        );
3039    }
3040
3041    #[test]
3042    fn inventory_loads_legacy_entries_without_revs_field() {
3043        // An entry carrying last_built_sha but no last_built_revs (an
3044        // inventory written before the field existed) loads cleanly with
3045        // the request defaulting to None.
3046        let dir = tempfile::tempdir().unwrap();
3047        let legacy = r#"{
3048            "old/repo": {
3049                "cloned_at": "2024-01-01T00:00:00",
3050                "last_accessed": "2024-01-01T00:00:00",
3051                "access_count": 5,
3052                "stale": false,
3053                "last_built_sha": "deadbeef"
3054            }
3055        }"#;
3056        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
3057        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
3058        assert_eq!(ws.last_built_sha("old/repo").as_deref(), Some("deadbeef"));
3059        assert_eq!(ws.last_built_revs("old/repo"), None);
3060    }
3061}