1use 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CanonicalRepo {
28 pub path: PathBuf,
29 pub vcs: VcsKind,
30 pub name: String,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum OnMissing {
39 Ask,
41 Clone,
44}
45
46#[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 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
99const 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 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 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 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 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 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 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
346pub fn normalize_url(url: &str) -> String {
349 let url = url.trim().trim_end_matches(".git");
350
351 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 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 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 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 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 #[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 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 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 let canon3 = store2
617 .resolve(&repo_ref(&[&format!("{url}.git")]), OnMissing::Clone)
618 .unwrap();
619 assert_eq!(canon3.path, canon1.path);
620 }
621
622 #[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 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 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 let canon2 = reloaded
795 .resolve(&repo_ref(&[&url]), OnMissing::Clone)
796 .unwrap();
797 assert!(canon2.path.exists());
798 }
799}