Skip to main content

review_queue/
repo.rs

1//! Canonical repo resolution: every review repo maps to exactly one canonical local repo, which
2//! `sync` never deletes - only the worktrees/workspaces created from it. A canonical repo
3//! is either discovered (an existing checkout found by scanning `Config::workdir`, see
4//! `crate::workdir`) or tool-managed (cloned once under `Paths::repo_dir()` and recorded
5//! in `repos.json`).
6//!
7//! Lookup order: the workdir scan cache, then the `repos.json` registry, then - if `workdir` is
8//! set - a rescan (throttled, see `RepoStore::needs_rescan`) in case the repo was just cloned or
9//! is newly discoverable. Still missing after all that: `OnMissing` decides whether to clone
10//! (always plain git - only a discovered checkout can be jj) or hand back `NeedsClone` for the
11//! caller to ask about first.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result, bail};
17use serde::{Deserialize, Serialize};
18
19use crate::config::{Config, VcsKind};
20use crate::paths::Paths;
21use crate::source::RepoRef;
22use crate::state::State;
23use crate::workdir::{self, WorkdirCache};
24
25/// A resolved local repo that review workspaces are built from.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CanonicalRepo {
28    pub path: PathBuf,
29    pub vcs: VcsKind,
30    /// Identifies this repo's directory under `Paths::repos_dir()` - its normalized URL. Stable
31    /// across aliases (origin vs mirror) so a review resolving through a different alias still
32    /// finds the same clone.
33    pub name: String,
34}
35
36/// What `RepoStore::resolve` should do when a review's repo isn't found locally.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum OnMissing {
39    /// Return `NeedsClone` instead, so the caller can confirm with the user first.
40    Ask,
41    /// Clone into the data dir without asking (`Config::auto_clone`, or a context - like a
42    /// non-interactive `rq sync` - that can't ask).
43    Clone,
44}
45
46/// Returned by `RepoStore::resolve` under `OnMissing::Ask` when a review's repo has no local
47/// checkout. Callers should `downcast_ref` for this to offer the clone prompt, and re-resolve
48/// with `OnMissing::Clone` if the user agrees.
49#[derive(Debug, thiserror::Error)]
50#[error("no local checkout of `{url}` found (would clone into {})", dest.display())]
51pub struct NeedsClone {
52    pub url: String,
53    pub dest: PathBuf,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57struct RegistryEntry {
58    path: PathBuf,
59    origin_url: String,
60    created: chrono::DateTime<chrono::Utc>,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64struct Registry {
65    /// Keyed by `normalize_url(origin_url)`.
66    repos: BTreeMap<String, RegistryEntry>,
67}
68
69impl Registry {
70    fn load(path: &Path) -> Result<Self> {
71        if !path.exists() {
72            return Ok(Self::default());
73        }
74        let text =
75            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
76        if text.trim().is_empty() {
77            return Ok(Self::default());
78        }
79        serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))
80    }
81
82    fn save(&self, path: &Path) -> Result<()> {
83        let dir = path
84            .parent()
85            .context("registry path has no parent directory")?;
86        std::fs::create_dir_all(dir)?;
87        let tmp = dir.join(format!(
88            ".{}.tmp",
89            path.file_name().unwrap().to_string_lossy()
90        ));
91        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)
92            .with_context(|| format!("writing {}", tmp.display()))?;
93        std::fs::rename(&tmp, path)
94            .with_context(|| format!("renaming {} to {}", tmp.display(), path.display()))?;
95        Ok(())
96    }
97}
98
99/// A rescan is skipped if the cache is younger than this - covers the ask/confirm/retry round
100/// trip and bursts of `rq fetch` calls, without letting a real miss go undetected for long.
101const RESCAN_THROTTLE: chrono::Duration = chrono::Duration::seconds(60);
102
103pub struct RepoStore {
104    registry_path: PathBuf,
105    registry: Registry,
106    repos_dir: PathBuf,
107    workdir: Option<PathBuf>,
108    workdir_cache_path: PathBuf,
109    workdir_cache: Option<WorkdirCache>,
110}
111
112impl RepoStore {
113    pub fn load(paths: &Paths, config: &Config) -> Result<Self> {
114        let workdir_cache_path = paths.workdir_cache_file();
115        Ok(Self {
116            registry_path: paths.repos_file(),
117            registry: Registry::load(&paths.repos_file())?,
118            repos_dir: paths.repos_dir(),
119            workdir: config.workdir.clone(),
120            workdir_cache: WorkdirCache::load(&workdir_cache_path)?,
121            workdir_cache_path,
122        })
123    }
124
125    /// Resolve `repo_ref` to a canonical local repo. See the module docs for the lookup order.
126    pub fn resolve(&mut self, repo_ref: &RepoRef, on_missing: OnMissing) -> Result<CanonicalRepo> {
127        let normalized: Vec<String> = repo_ref.urls.iter().map(|u| normalize_url(u)).collect();
128
129        if let Some(canon) = self.lookup_workdir(&normalized) {
130            return Ok(canon);
131        }
132
133        for n in &normalized {
134            if let Some(entry) = self.registry.repos.get(n)
135                && entry.path.exists()
136            {
137                return Ok(CanonicalRepo {
138                    path: entry.path.clone(),
139                    vcs: VcsKind::Git,
140                    name: n.clone(),
141                });
142            }
143        }
144
145        self.rescan_if_needed()?;
146        if let Some(canon) = self.lookup_workdir(&normalized) {
147            return Ok(canon);
148        }
149
150        let url = repo_ref
151            .urls
152            .first()
153            .context("review's repo has no candidate URLs to clone")?;
154        let name = normalize_url(url);
155        let dest = self.repos_dir.join(&name);
156
157        if on_missing == OnMissing::Ask {
158            return Err(NeedsClone {
159                url: url.clone(),
160                dest,
161            }
162            .into());
163        }
164
165        if dest.exists() {
166            // Self-heal a registry that fell out of sync with the filesystem - e.g. a prior run
167            // cloned this but was interrupted before it could save `repos.json`. Trust an
168            // existing directory at the expected cache path over attempting (and failing) to
169            // clone into it again.
170            tracing::warn!(
171                "{} already exists but wasn't registered; reusing it as-is",
172                dest.display()
173            );
174        } else {
175            clone_repo(url, &dest)?;
176        }
177
178        self.registry.repos.insert(
179            name.clone(),
180            RegistryEntry {
181                path: dest.clone(),
182                origin_url: url.clone(),
183                created: chrono::Utc::now(),
184            },
185        );
186        Ok(CanonicalRepo {
187            path: dest,
188            vcs: VcsKind::Git,
189            name,
190        })
191    }
192
193    /// Looks up `normalized` in the workdir cache - but only if it was scanned for the
194    /// currently-configured `workdir`. A cache left over from a since-changed (or removed)
195    /// `workdir` setting is treated as absent rather than trusted, even though the directories it
196    /// recorded may still exist on disk.
197    fn lookup_workdir(&self, normalized: &[String]) -> Option<CanonicalRepo> {
198        let cache = self.workdir_cache.as_ref()?;
199        if Some(&cache.workdir) != self.workdir.as_ref() {
200            return None;
201        }
202        let repo = cache.lookup(normalized)?;
203        Some(CanonicalRepo {
204            path: repo.path.clone(),
205            vcs: repo.vcs,
206            name: repo.name.clone(),
207        })
208    }
209
210    /// Scan `workdir` if it's never been scanned, or the cache is stale - the same throttled
211    /// policy `resolve` uses on a miss. Exposed so `rq repo list` reflects the workdir without
212    /// requiring an `rq fetch` to have triggered a scan first.
213    pub fn rescan_if_needed(&mut self) -> Result<()> {
214        if self.workdir.is_some() && self.needs_rescan() {
215            self.rescan()?;
216        }
217        Ok(())
218    }
219
220    fn needs_rescan(&self) -> bool {
221        match &self.workdir_cache {
222            None => true,
223            Some(cache) => {
224                Some(&cache.workdir) != self.workdir.as_ref()
225                    || chrono::Utc::now().signed_duration_since(cache.scanned_at) >= RESCAN_THROTTLE
226            }
227        }
228    }
229
230    fn rescan(&mut self) -> Result<()> {
231        let Some(workdir) = self.workdir.clone() else {
232            return Ok(());
233        };
234        let repos = workdir::scan(&workdir, &self.repos_dir);
235        let cache = WorkdirCache {
236            workdir,
237            scanned_at: chrono::Utc::now(),
238            repos,
239        };
240        cache.save(&self.workdir_cache_path)?;
241        self.workdir_cache = Some(cache);
242        Ok(())
243    }
244
245    pub fn save(&self) -> Result<()> {
246        self.registry.save(&self.registry_path)
247    }
248
249    /// Every canonical repo (discovered and tool-managed), with how many tracked workspaces
250    /// currently point at it - for `rq repo list`.
251    pub fn list(&self, state: &State) -> Vec<RepoListEntry> {
252        let mut out = Vec::new();
253        if let Some(cache) = &self.workdir_cache {
254            for r in &cache.repos {
255                let workspace_count = state
256                    .workspaces()
257                    .filter(|(_, w)| w.repo_path == r.path)
258                    .count();
259                out.push(RepoListEntry {
260                    url: r.name.clone(),
261                    path: r.path.clone(),
262                    kind: RepoKind::Discovered,
263                    workspace_count,
264                });
265            }
266        }
267        for (url, entry) in &self.registry.repos {
268            let workspace_count = state
269                .workspaces()
270                .filter(|(_, w)| w.repo_path == entry.path)
271                .count();
272            out.push(RepoListEntry {
273                url: url.clone(),
274                path: entry.path.clone(),
275                kind: RepoKind::ToolManaged,
276                workspace_count,
277            });
278        }
279        out
280    }
281
282    /// Delete a tool-managed clone and forget it. Refuses if `url` is a discovered repo instead
283    /// (rq never deletes checkouts it didn't create), or if any tracked workspace still points
284    /// at it.
285    pub fn remove(&mut self, url: &str, state: &State) -> Result<()> {
286        let normalized = normalize_url(url);
287        if self
288            .workdir_cache
289            .as_ref()
290            .is_some_and(|cache| cache.repos.iter().any(|r| r.remotes.contains(&normalized)))
291        {
292            bail!(
293                "`{url}` is a discovered repo in your workdir; rq never deletes those - remove the checkout yourself if you want it forgotten"
294            );
295        }
296        let Some(entry) = self.registry.repos.get(&normalized).cloned() else {
297            bail!("no tool-managed clone registered for `{url}` (see `rq repo list`)");
298        };
299        if state.workspaces().any(|(_, w)| w.repo_path == entry.path) {
300            bail!(
301                "{} still has workspaces using it; wait for `rq sync` to clean up resolved \
302                 ones, or remove them by hand first",
303                entry.path.display()
304            );
305        }
306        std::fs::remove_dir_all(&entry.path)
307            .with_context(|| format!("removing {}", entry.path.display()))?;
308        self.registry.repos.remove(&normalized);
309        Ok(())
310    }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum RepoKind {
315    Discovered,
316    ToolManaged,
317}
318
319#[derive(Debug, Clone)]
320pub struct RepoListEntry {
321    pub url: String,
322    pub path: PathBuf,
323    pub kind: RepoKind,
324    pub workspace_count: usize,
325}
326
327fn clone_repo(url: &str, dest: &Path) -> Result<()> {
328    if let Some(parent) = dest.parent() {
329        std::fs::create_dir_all(parent)
330            .with_context(|| format!("creating {}", parent.display()))?;
331    }
332    let output = std::process::Command::new("git")
333        .args(["clone", "--filter=blob:none", url, &dest.to_string_lossy()])
334        .output()
335        .with_context(|| format!("cloning {url}"))?;
336    if !output.status.success() {
337        bail!(
338            "cloning {url} into {} failed: {}",
339            dest.display(),
340            String::from_utf8_lossy(&output.stderr).trim()
341        );
342    }
343    Ok(())
344}
345
346/// Normalize a clone URL so `git@github.com:o/r.git`, `https://github.com/o/r`, and
347/// `ssh://git@github.com/o/r` all compare equal, as `host/owner/repo`.
348pub fn normalize_url(url: &str) -> String {
349    let url = url.trim().trim_end_matches(".git");
350
351    // scp-like syntax: git@host:owner/repo
352    if let Some((host_part, path_part)) = url.split_once(':')
353        && !host_part.contains('/')
354        && host_part.contains('@')
355    {
356        let host = host_part.rsplit('@').next().unwrap_or(host_part);
357        return format!("{host}/{}", path_part.trim_matches('/'));
358    }
359
360    // URL syntax: scheme://[user@]host/path
361    if let Some(idx) = url.find("://") {
362        let rest = &url[idx + 3..];
363        let rest = rest.split_once('@').map(|(_, h)| h).unwrap_or(rest);
364        return rest.trim_matches('/').to_string();
365    }
366
367    url.trim_matches('/').to_string()
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::paths::Paths;
374    use crate::workdir::DiscoveredRepo;
375    use std::process::Command;
376    use tempfile::TempDir;
377
378    #[test]
379    fn normalizes_equivalent_github_urls() {
380        let expected = "github.com/mozilla-firefox/firefox";
381        assert_eq!(
382            normalize_url("https://github.com/mozilla-firefox/firefox"),
383            expected
384        );
385        assert_eq!(
386            normalize_url("https://github.com/mozilla-firefox/firefox.git"),
387            expected
388        );
389        assert_eq!(
390            normalize_url("git@github.com:mozilla-firefox/firefox.git"),
391            expected
392        );
393        assert_eq!(
394            normalize_url("ssh://git@github.com/mozilla-firefox/firefox"),
395            expected
396        );
397    }
398
399    fn git(dir: &Path, args: &[&str]) {
400        let out = Command::new("git")
401            .current_dir(dir)
402            .args(args)
403            .output()
404            .unwrap();
405        assert!(
406            out.status.success(),
407            "git {args:?} failed: {}",
408            String::from_utf8_lossy(&out.stderr)
409        );
410    }
411
412    fn paths_in(tmp: &Path) -> Paths {
413        Paths::discover()
414            .unwrap()
415            .with_overrides(Some(tmp.join("data")))
416    }
417
418    fn repo_ref(urls: &[&str]) -> RepoRef {
419        RepoRef {
420            urls: urls.iter().map(|s| s.to_string()).collect(),
421            display_name: "test/repo".into(),
422        }
423    }
424
425    fn config_with_workdir(workdir: &Path) -> Config {
426        Config {
427            workdir: Some(workdir.to_path_buf()),
428            ..Default::default()
429        }
430    }
431
432    #[test]
433    fn resolves_a_discovered_repo_without_cloning() {
434        let tmp = TempDir::new().unwrap();
435        let owned = tmp.path().join("dev/owned");
436        std::fs::create_dir_all(&owned).unwrap();
437        git(&owned, &["init", "-q"]);
438        git(
439            &owned,
440            &["remote", "add", "origin", "git@github.com:o/r.git"],
441        );
442
443        let config = config_with_workdir(&tmp.path().join("dev"));
444        let mut store = RepoStore::load(&paths_in(tmp.path()), &config).unwrap();
445        let canon = store
446            .resolve(&repo_ref(&["https://github.com/o/r"]), OnMissing::Ask)
447            .unwrap();
448
449        assert_eq!(canon.path, owned);
450        assert_eq!(canon.vcs, VcsKind::Git);
451        assert!(
452            !tmp.path().join("data").join("repos").exists(),
453            "should not have cloned anything"
454        );
455    }
456
457    #[test]
458    fn a_cache_miss_triggers_a_rescan_that_picks_up_a_newly_cloned_repo() {
459        let tmp = TempDir::new().unwrap();
460        let dev = tmp.path().join("dev");
461        std::fs::create_dir_all(&dev).unwrap();
462
463        let config = config_with_workdir(&dev);
464        let paths = paths_in(tmp.path());
465        let mut store = RepoStore::load(&paths, &config).unwrap();
466
467        // First lookup: nothing in `dev` yet, and no config to clone from - `Ask` reports
468        // `NeedsClone` (this also seeds the workdir cache as "empty, just scanned").
469        let err = store
470            .resolve(&repo_ref(&["https://example.com/o/r"]), OnMissing::Ask)
471            .unwrap_err();
472        assert!(err.downcast_ref::<NeedsClone>().is_some());
473
474        // The repo shows up in `dev` after that (e.g. the user cloned it by hand). A fresh
475        // `RepoStore` (as a later `rq fetch` would construct) still has the stale cache on disk,
476        // but it's older than the throttle window, so resolving rescans and finds it.
477        let repo = dev.join("owned");
478        std::fs::create_dir_all(&repo).unwrap();
479        git(&repo, &["init", "-q"]);
480        git(
481            &repo,
482            &["remote", "add", "origin", "https://example.com/o/r"],
483        );
484        backdate_cache(&paths);
485
486        let mut store2 = RepoStore::load(&paths, &config).unwrap();
487        let canon = store2
488            .resolve(&repo_ref(&["https://example.com/o/r"]), OnMissing::Ask)
489            .unwrap();
490        assert_eq!(canon.path, repo);
491    }
492
493    /// Force the on-disk workdir cache's `scanned_at` far enough into the past to clear
494    /// `RESCAN_THROTTLE`, simulating "it's been a while since the last scan" without sleeping.
495    fn backdate_cache(paths: &Paths) {
496        let mut cache = WorkdirCache::load(&paths.workdir_cache_file())
497            .unwrap()
498            .unwrap();
499        cache.scanned_at -= RESCAN_THROTTLE * 2;
500        cache.save(&paths.workdir_cache_file()).unwrap();
501    }
502
503    #[test]
504    fn a_cached_path_that_no_longer_exists_is_treated_as_a_miss() {
505        let tmp = TempDir::new().unwrap();
506        let paths = paths_in(tmp.path());
507        let dev = tmp.path().join("dev");
508        std::fs::create_dir_all(&dev).unwrap();
509        let config = config_with_workdir(&dev);
510
511        let cache = WorkdirCache {
512            workdir: dev.clone(),
513            scanned_at: chrono::Utc::now(),
514            repos: vec![DiscoveredRepo {
515                path: dev.join("gone"),
516                vcs: VcsKind::Git,
517                name: "example.com/o/r".into(),
518                remotes: vec!["example.com/o/r".into()],
519            }],
520        };
521        cache.save(&paths.workdir_cache_file()).unwrap();
522
523        let mut store = RepoStore::load(&paths, &config).unwrap();
524        let err = store
525            .resolve(&repo_ref(&["https://example.com/o/r"]), OnMissing::Ask)
526            .unwrap_err();
527        assert!(err.downcast_ref::<NeedsClone>().is_some());
528    }
529
530    /// Regression test: a cache scanned under a workdir the user has since changed (or removed
531    /// from config) must not be trusted, even though the directory it recorded still exists on
532    /// disk - otherwise turning `workdir` off doesn't actually stop rq from using it.
533    #[test]
534    fn a_cache_from_a_different_workdir_is_treated_as_a_miss() {
535        let tmp = TempDir::new().unwrap();
536        let paths = paths_in(tmp.path());
537        let old_dev = tmp.path().join("old-dev");
538        let repo = old_dev.join("owned");
539        std::fs::create_dir_all(&repo).unwrap();
540        git(&repo, &["init", "-q"]);
541        git(
542            &repo,
543            &["remote", "add", "origin", "https://example.com/o/r"],
544        );
545
546        let cache = WorkdirCache {
547            workdir: old_dev,
548            scanned_at: chrono::Utc::now(),
549            repos: vec![DiscoveredRepo {
550                path: repo,
551                vcs: VcsKind::Git,
552                name: "example.com/o/r".into(),
553                remotes: vec!["example.com/o/r".into()],
554            }],
555        };
556        cache.save(&paths.workdir_cache_file()).unwrap();
557
558        // No `workdir` configured now (e.g. the user removed it from config.toml).
559        let config = Config::default();
560        let mut store = RepoStore::load(&paths, &config).unwrap();
561        let err = store
562            .resolve(&repo_ref(&["https://example.com/o/r"]), OnMissing::Ask)
563            .unwrap_err();
564        assert!(
565            err.downcast_ref::<NeedsClone>().is_some(),
566            "must not serve a repo cached under a different workdir"
567        );
568    }
569
570    #[test]
571    fn ask_reports_needs_clone_without_touching_the_filesystem() {
572        let tmp = TempDir::new().unwrap();
573        let paths = paths_in(tmp.path());
574        let config = Config::default();
575        let mut store = RepoStore::load(&paths, &config).unwrap();
576
577        let err = store
578            .resolve(&repo_ref(&["https://example.com/o/r"]), OnMissing::Ask)
579            .unwrap_err();
580        let needs_clone = err.downcast_ref::<NeedsClone>().unwrap();
581        assert_eq!(needs_clone.url, "https://example.com/o/r");
582        assert!(!needs_clone.dest.exists());
583        assert!(!paths.repos_file().exists());
584    }
585
586    #[test]
587    fn clones_and_reuses_a_tool_managed_repo() {
588        let tmp = TempDir::new().unwrap();
589        let upstream = tmp.path().join("upstream");
590        std::fs::create_dir(&upstream).unwrap();
591        git(&upstream, &["init", "-q", "-b", "main"]);
592        git(&upstream, &["config", "user.name", "test"]);
593        git(&upstream, &["config", "user.email", "test@example.com"]);
594        git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
595
596        let paths = paths_in(tmp.path());
597        let config = Config::default();
598        let url = upstream.to_string_lossy().to_string();
599
600        let mut store = RepoStore::load(&paths, &config).unwrap();
601        let canon1 = store.resolve(&repo_ref(&[&url]), OnMissing::Clone).unwrap();
602        assert_eq!(canon1.vcs, VcsKind::Git);
603        assert!(canon1.path.join(".git").exists());
604        store.save().unwrap();
605
606        // A fresh store (simulating a later `rq sync` run) reuses the registered clone rather
607        // than cloning again.
608        let mut store2 = RepoStore::load(&paths, &config).unwrap();
609        let canon2 = store2
610            .resolve(&repo_ref(&[&url]), OnMissing::Clone)
611            .unwrap();
612        assert_eq!(canon2.path, canon1.path);
613
614        // Reusing it via a differently-formed but equivalent URL also hits the same registry
615        // entry, not a second clone.
616        let canon3 = store2
617            .resolve(&repo_ref(&[&format!("{url}.git")]), OnMissing::Clone)
618            .unwrap();
619        assert_eq!(canon3.path, canon1.path);
620    }
621
622    /// Regression test: a prior run that cloned a repo but crashed before saving `repos.json`
623    /// left the registry unaware of a clone that already exists on disk. `resolve()` must reuse
624    /// it rather than trying (and failing) to clone into the same non-empty directory again.
625    #[test]
626    fn resolve_self_heals_an_unregistered_but_already_cloned_directory() {
627        let tmp = TempDir::new().unwrap();
628        let upstream = tmp.path().join("upstream");
629        std::fs::create_dir(&upstream).unwrap();
630        git(&upstream, &["init", "-q", "-b", "main"]);
631        git(&upstream, &["config", "user.name", "test"]);
632        git(&upstream, &["config", "user.email", "test@example.com"]);
633        git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
634
635        let paths = paths_in(tmp.path());
636        let config = Config::default();
637        let url = upstream.to_string_lossy().to_string();
638
639        // Simulate the interrupted-prior-run scenario directly: clone to the exact path
640        // `resolve()` would use, but never register it.
641        let dest = paths.repo_dir(&normalize_url(&url));
642        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
643        git(tmp.path(), &["clone", "-q", &url, dest.to_str().unwrap()]);
644        assert!(!paths.repos_file().exists());
645
646        let mut store = RepoStore::load(&paths, &config).unwrap();
647        let canon = store.resolve(&repo_ref(&[&url]), OnMissing::Clone).unwrap();
648
649        assert_eq!(canon.path, dest);
650        assert!(canon.path.join(".git").exists());
651    }
652
653    fn state_using(repo_path: &Path) -> crate::state::State {
654        let mut state = crate::state::State::default();
655        let mut entry = entry_using();
656        entry.stack_id = Some("phab/D1".into());
657        state.insert(entry);
658        state.insert_workspace(
659            "phab/D1".into(),
660            crate::state::Workspace {
661                repo_path: repo_path.to_path_buf(),
662                vcs: VcsKind::Git,
663                workspace_path: PathBuf::from("/tmp/ws/D1"),
664                head_id: "abc".into(),
665                status: crate::state::Status::Ready,
666                tip: crate::state::ReviewKey::new("phab", "D1"),
667                version: "1".into(),
668            },
669        );
670        state
671    }
672
673    fn entry_using() -> crate::state::ReviewEntry {
674        crate::state::ReviewEntry {
675            key: crate::state::ReviewKey::new("phab", "D1"),
676            title: "x".into(),
677            author: "a".into(),
678            url: "https://example.com/D1".into(),
679            repo: RepoRef {
680                urls: vec!["https://example.com/o/r".into()],
681                display_name: "o/r".into(),
682            },
683            kind: crate::source::ReviewKind::Direct,
684            version: "1".into(),
685            in_queue: true,
686            resolved: false,
687            last_synced: chrono::Utc::now(),
688            stack_id: None,
689            ancestors: Vec::new(),
690            diff_stat: None,
691            description: None,
692        }
693    }
694
695    #[test]
696    fn list_reports_discovered_and_registry_repos_with_workspace_counts() {
697        let tmp = TempDir::new().unwrap();
698        let owned = tmp.path().join("dev/owned");
699        std::fs::create_dir_all(&owned).unwrap();
700        git(&owned, &["init", "-q"]);
701        git(
702            &owned,
703            &["remote", "add", "origin", "https://example.com/o/r"],
704        );
705
706        let config = config_with_workdir(&tmp.path().join("dev"));
707        let paths = paths_in(tmp.path());
708        let mut store = RepoStore::load(&paths, &config).unwrap();
709        // Force a scan so `list()` has something to report.
710        store.rescan().unwrap();
711
712        let state = state_using(&owned);
713
714        let repos = store.list(&state);
715        assert_eq!(repos.len(), 1);
716        assert_eq!(repos[0].kind, RepoKind::Discovered);
717        assert_eq!(repos[0].workspace_count, 1);
718    }
719
720    #[test]
721    fn remove_refuses_for_a_discovered_repo() {
722        let tmp = TempDir::new().unwrap();
723        let owned = tmp.path().join("dev/owned");
724        std::fs::create_dir_all(&owned).unwrap();
725        git(&owned, &["init", "-q"]);
726        git(
727            &owned,
728            &["remote", "add", "origin", "https://example.com/o/r"],
729        );
730
731        let config = config_with_workdir(&tmp.path().join("dev"));
732        let paths = paths_in(tmp.path());
733        let mut store = RepoStore::load(&paths, &config).unwrap();
734        store.rescan().unwrap();
735        let state = crate::state::State::default();
736
737        let err = store.remove("https://example.com/o/r", &state).unwrap_err();
738        assert!(
739            err.to_string().contains("workdir"),
740            "unexpected error: {err}"
741        );
742    }
743
744    #[test]
745    fn remove_refuses_while_workspaces_exist() {
746        let tmp = TempDir::new().unwrap();
747        let upstream = tmp.path().join("upstream");
748        std::fs::create_dir(&upstream).unwrap();
749        git(&upstream, &["init", "-q", "-b", "main"]);
750        git(&upstream, &["config", "user.name", "test"]);
751        git(&upstream, &["config", "user.email", "test@example.com"]);
752        git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
753        let url = upstream.to_string_lossy().to_string();
754
755        let paths = paths_in(tmp.path());
756        let config = Config::default();
757        let mut store = RepoStore::load(&paths, &config).unwrap();
758        let canon = store.resolve(&repo_ref(&[&url]), OnMissing::Clone).unwrap();
759
760        let state = state_using(&canon.path);
761
762        let err = store.remove(&url, &state).unwrap_err();
763        assert!(
764            err.to_string().contains("still has workspaces"),
765            "unexpected error: {err}"
766        );
767        assert!(canon.path.exists(), "must not delete while still in use");
768    }
769
770    #[test]
771    fn remove_deletes_an_unused_tool_managed_clone() {
772        let tmp = TempDir::new().unwrap();
773        let upstream = tmp.path().join("upstream");
774        std::fs::create_dir(&upstream).unwrap();
775        git(&upstream, &["init", "-q", "-b", "main"]);
776        git(&upstream, &["config", "user.name", "test"]);
777        git(&upstream, &["config", "user.email", "test@example.com"]);
778        git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
779        let url = upstream.to_string_lossy().to_string();
780
781        let paths = paths_in(tmp.path());
782        let config = Config::default();
783        let mut store = RepoStore::load(&paths, &config).unwrap();
784        let canon = store.resolve(&repo_ref(&[&url]), OnMissing::Clone).unwrap();
785        store.save().unwrap();
786
787        let state = crate::state::State::default();
788        store.remove(&url, &state).unwrap();
789        store.save().unwrap();
790
791        assert!(!canon.path.exists());
792        let mut reloaded = RepoStore::load(&paths, &config).unwrap();
793        // A removed clone is forgotten, not just deleted - resolving again clones fresh.
794        let canon2 = reloaded
795            .resolve(&repo_ref(&[&url]), OnMissing::Clone)
796            .unwrap();
797        assert!(canon2.path.exists());
798    }
799}