1use std::collections::{BTreeSet, HashMap};
31use std::path::PathBuf;
32
33use anyhow::{Context, Result, bail};
34
35use crate::config::{Config, VcsKind};
36use crate::paths::Paths;
37use crate::repo::{OnMissing, RepoStore};
38use crate::source::{Lifecycle, Review, ReviewSource};
39use crate::stacks::{self, Stack};
40use crate::state::{ReviewEntry, ReviewKey, State, Status, Workspace};
41use crate::vcs::Vcs;
42use crate::vcs::git::GitVcs;
43use crate::vcs::jj::JjVcs;
44
45const STACK_SCAN_DEPTH: usize = 200;
48
49#[derive(Debug, Default)]
50pub struct SyncReport {
51 pub added: Vec<ReviewKey>,
52 pub updated: Vec<ReviewKey>,
53 pub removed: Vec<ReviewKey>,
54 pub flagged: Vec<(ReviewKey, String)>,
56 pub errors: Vec<(ReviewKey, String)>,
57}
58
59fn vcs_for(kind: VcsKind) -> Box<dyn Vcs> {
60 match kind {
61 VcsKind::Git => Box::new(GitVcs),
62 VcsKind::Jj => Box::new(JjVcs),
63 }
64}
65
66pub async fn sync(
67 sources: &[Box<dyn ReviewSource>],
68 paths: &Paths,
69 only_source: Option<&str>,
70 dry_run: bool,
71) -> Result<SyncReport> {
72 paths.ensure_dirs()?;
73 let mut state = State::load(&paths.state_file())?;
74 let mut report = SyncReport::default();
75 let mut seen: BTreeSet<ReviewKey> = BTreeSet::new();
76
77 for source in sources {
78 if only_source.is_some_and(|o| o != source.name()) {
79 continue;
80 }
81 let queue = source
82 .fetch_queue()
83 .await
84 .with_context(|| format!("fetching queue from `{}`", source.name()))?;
85 for review in queue {
86 seen.insert(review.key.clone());
87 record_review(&review, &mut state, &mut report);
88 }
89 }
90
91 let sources_by_name: HashMap<&str, &Box<dyn ReviewSource>> =
92 sources.iter().map(|s| (s.name(), s)).collect();
93 let mut ids_by_source: HashMap<String, Vec<String>> = HashMap::new();
94 for entry in state.iter() {
95 if seen.contains(&entry.key) {
96 continue;
97 }
98 if only_source.is_some_and(|o| o != entry.key.source) {
99 continue;
100 }
101 ids_by_source
102 .entry(entry.key.source.clone())
103 .or_default()
104 .push(entry.key.id.clone());
105 }
106
107 for (source_name, ids) in ids_by_source {
108 let Some(source) = sources_by_name.get(source_name.as_str()) else {
109 continue;
110 };
111 let statuses = source
112 .fetch_status(&ids)
113 .await
114 .with_context(|| format!("checking status from `{source_name}`"))?;
115 for (id, lifecycle) in statuses {
116 let key = ReviewKey::new(source_name.clone(), id);
117 handle_out_of_queue(&key, lifecycle, &mut state, &mut report);
118 }
119 }
120
121 reconcile_workspaces(sources, only_source, &mut state, dry_run, &mut report).await;
122
123 if !dry_run {
124 state.save(&paths.state_file())?;
125 }
126 Ok(report)
127}
128
129fn record_review(review: &Review, state: &mut State, report: &mut SyncReport) {
132 let mut entry = match state.get(&review.key).cloned() {
133 Some(entry) => entry,
134 None => {
135 report.added.push(review.key.clone());
136 ReviewEntry {
137 key: review.key.clone(),
138 title: String::new(),
139 author: String::new(),
140 url: String::new(),
141 repo: review.repo.clone(),
142 kind: review.kind.clone(),
143 version: String::new(),
144 in_queue: true,
145 resolved: false,
146 last_synced: chrono::Utc::now(),
147 stack_id: None,
148 ancestors: Vec::new(),
149 diff_stat: None,
150 description: None,
151 }
152 }
153 };
154 entry.title = review.title.clone();
155 entry.author = review.author.clone();
156 entry.url = review.url.clone();
157 entry.repo = review.repo.clone();
158 entry.kind = review.kind.clone();
159 entry.version = review.version.clone();
160 entry.ancestors = review.ancestors.clone();
161 entry.in_queue = true;
162 entry.resolved = false;
163 entry.diff_stat = review.diff_stat.clone();
164 entry.description = review.description.clone();
165 entry.last_synced = chrono::Utc::now();
166 state.insert(entry);
167}
168
169fn handle_out_of_queue(
170 key: &ReviewKey,
171 lifecycle: Lifecycle,
172 state: &mut State,
173 report: &mut SyncReport,
174) {
175 let Some(mut entry) = state.get(key).cloned() else {
176 return;
177 };
178 entry.in_queue = false;
179 entry.last_synced = chrono::Utc::now();
180
181 match lifecycle {
182 Lifecycle::Open => {
183 entry.resolved = false;
184 state.insert(entry);
185 }
186 Lifecycle::Resolved => {
187 entry.resolved = true;
188 let has_workspace = state.workspace_of(&entry).is_some();
189 if has_workspace {
190 state.insert(entry);
193 } else {
194 state.remove(key);
196 report.removed.push(key.clone());
197 }
198 }
199 }
200}
201
202fn attach_stack_ids(state: &mut State, stacks: &[Stack]) {
207 for stack in stacks {
208 let existing = |key: &ReviewKey| state.get(key).and_then(|e| e.stack_id.clone());
209 let chosen = existing(&stack.tip).or_else(|| stack.members.iter().find_map(existing));
210 let Some(id) = chosen else {
211 continue;
212 };
213 for key in &stack.members {
214 if let Some(mut entry) = state.get(key).cloned()
215 && entry.stack_id.as_deref() != Some(id.as_str())
216 {
217 entry.stack_id = Some(id.clone());
218 state.insert(entry);
219 }
220 }
221 }
222}
223
224fn review_from_entry(entry: &ReviewEntry) -> Review {
225 Review {
226 key: entry.key.clone(),
227 title: entry.title.clone(),
228 author: entry.author.clone(),
229 url: entry.url.clone(),
230 repo: entry.repo.clone(),
231 version: entry.version.clone(),
232 kind: entry.kind.clone(),
233 diff_stat: entry.diff_stat.clone(),
234 description: entry.description.clone(),
235 ancestors: entry.ancestors.clone(),
236 }
237}
238
239async fn reconcile_workspaces(
242 sources: &[Box<dyn ReviewSource>],
243 only_source: Option<&str>,
244 state: &mut State,
245 dry_run: bool,
246 report: &mut SyncReport,
247) {
248 let stacks = stacks::group(state.iter());
249 attach_stack_ids(state, &stacks);
250
251 let ids: Vec<String> = state.workspaces().map(|(id, _)| id.clone()).collect();
252 for id in ids {
253 let Some(mut ws) = state.workspace(&id).cloned() else {
254 continue;
255 };
256 if only_source.is_some_and(|o| o != ws.tip.source) {
257 continue;
258 }
259 let members = state.members_of(&id);
260 let live: Vec<&ReviewKey> = members
261 .iter()
262 .filter(|k| state.get(k).is_some_and(|e| !e.resolved))
263 .collect();
264
265 if live.is_empty() {
266 remove_finished_workspace(&id, &ws, &members, state, dry_run, report);
267 continue;
268 }
269
270 let stack = stacks::stack_containing(&stacks, &ws.tip)
272 .or_else(|| stacks::stack_containing(&stacks, live[0]));
273 let Some(stack) = stack else {
274 continue;
275 };
276 let Some(tip) = state.get(&stack.tip).cloned() else {
277 continue;
278 };
279 if ws.tip == tip.key && ws.version == tip.version {
280 continue;
281 }
282
283 let vcs = vcs_for(ws.vcs);
284 if vcs
285 .is_dirty(&ws.workspace_path, &ws.head_id)
286 .unwrap_or(true)
287 {
288 ws.status = Status::Dirty;
289 state.insert_workspace(id, ws);
290 report.flagged.push((
291 tip.key.clone(),
292 "local changes; not updated to the new version".into(),
293 ));
294 continue;
295 }
296 if dry_run {
297 report.updated.push(tip.key.clone());
298 continue;
299 }
300
301 let Some(source) = sources.iter().find(|s| s.name() == tip.key.source) else {
302 continue;
303 };
304 let review = review_from_entry(&tip);
305 let checkout = match source.checkout_spec(&review, &ws.repo_path).await {
306 Ok(checkout) => checkout,
307 Err(e) => {
308 report.errors.push((tip.key.clone(), e.to_string()));
309 continue;
310 }
311 };
312 match vcs.update_workspace(
313 &ws.repo_path,
314 &ws.workspace_path,
315 &checkout,
316 &id,
317 &tip.version,
318 ) {
319 Ok(head) => {
320 ws.head_id = head;
321 ws.tip = tip.key.clone();
322 ws.version = tip.version.clone();
323 ws.status = Status::Ready;
324 state.insert_workspace(id, ws);
325 report.updated.push(tip.key.clone());
326 }
327 Err(e) => {
328 ws.status = Status::ApplyFailed;
329 state.insert_workspace(id, ws);
330 report.errors.push((tip.key.clone(), e.to_string()));
331 }
332 }
333 }
334}
335
336fn remove_finished_workspace(
339 id: &str,
340 ws: &Workspace,
341 members: &[ReviewKey],
342 state: &mut State,
343 dry_run: bool,
344 report: &mut SyncReport,
345) {
346 let vcs = vcs_for(ws.vcs);
347 if !dry_run {
348 if vcs
352 .remove_workspace(&ws.repo_path, &ws.workspace_path, id)
353 .is_err()
354 && ws.workspace_path.exists()
355 && let Err(e) = std::fs::remove_dir_all(&ws.workspace_path)
356 {
357 report.errors.push((
358 ws.tip.clone(),
359 format!("removing {}: {e}", ws.workspace_path.display()),
360 ));
361 return;
362 }
363 state.remove_workspace(id);
364 for key in members {
365 state.remove(key);
366 }
367 }
368 report.removed.extend(members.iter().cloned());
369}
370
371pub async fn fetch_local(
384 sources: &[Box<dyn ReviewSource>],
385 paths: &Paths,
386 config: &Config,
387 key: &ReviewKey,
388 on_missing: OnMissing,
389) -> Result<std::path::PathBuf> {
390 paths.ensure_dirs()?;
391 let mut state = State::load(&paths.state_file())?;
392 let entry = state
393 .get(key)
394 .cloned()
395 .with_context(|| format!("`{key}` isn't tracked; run `rq sync` first"))?;
396
397 let stacks = stacks::group(state.iter());
398 attach_stack_ids(&mut state, &stacks);
399 let stack = stacks::stack_containing(&stacks, key)
400 .cloned()
401 .unwrap_or_else(|| Stack {
402 tip: key.clone(),
403 members: vec![key.clone()],
404 });
405 let stack_id = state
406 .get(key)
407 .and_then(|e| e.stack_id.clone())
408 .unwrap_or_else(|| stack.members[0].slug());
409
410 let source = sources
411 .iter()
412 .find(|s| s.name() == key.source)
413 .with_context(|| format!("no configured source named `{}`", key.source))?;
414
415 if let Some(ws) = state.workspace(&stack_id).cloned() {
416 if ws.status != Status::ApplyFailed {
417 position_at(source.as_ref(), &ws, key);
418 state.save(&paths.state_file())?;
419 return Ok(ws.workspace_path);
420 }
421 let stale_vcs = vcs_for(ws.vcs);
425 let _ = stale_vcs.remove_workspace(&ws.repo_path, &ws.workspace_path, &stack_id);
426 let _ = std::fs::remove_dir_all(&ws.workspace_path);
427 state.remove_workspace(&stack_id);
428 }
429
430 let tip = state
431 .get(&stack.tip)
432 .cloned()
433 .unwrap_or_else(|| entry.clone());
434 let tip_source = sources
435 .iter()
436 .find(|s| s.name() == tip.key.source)
437 .with_context(|| format!("no configured source named `{}`", tip.key.source))?;
438 let review = review_from_entry(&tip);
439
440 let mut repo_store = RepoStore::load(paths, config)?;
441 let canon = repo_store.resolve(&review.repo, on_missing)?;
442 let checkout = tip_source.checkout_spec(&review, &canon.path).await?;
443 let vcs = vcs_for(canon.vcs);
444 let ws_path = paths.workspace_dir(&stack_id);
445 if let Some(parent) = ws_path.parent() {
446 std::fs::create_dir_all(parent)?;
447 }
448
449 let outcome = vcs.add_workspace(&canon.path, &ws_path, &checkout, &stack_id, &review.version);
450 let (status, head_id) = match &outcome {
451 Ok(head) => (Status::Ready, head.clone()),
452 Err(_) => (Status::ApplyFailed, String::new()),
455 };
456 let ws = Workspace {
457 repo_path: canon.path.clone(),
458 vcs: canon.vcs,
459 workspace_path: ws_path.clone(),
460 head_id,
461 status,
462 tip: tip.key.clone(),
463 version: tip.version.clone(),
464 };
465 state.insert_workspace(stack_id.clone(), ws.clone());
466 for member in &stack.members {
467 if let Some(mut e) = state.get(member).cloned() {
468 e.stack_id = Some(stack_id.clone());
469 state.insert(e);
470 }
471 }
472 if outcome.is_ok() {
473 position_at(source.as_ref(), &ws, key);
474 }
475 state.save(&paths.state_file())?;
476 repo_store.save()?;
477 outcome.map(|_| ws_path)
478}
479
480fn position_at(source: &dyn ReviewSource, ws: &Workspace, key: &ReviewKey) {
485 let vcs = vcs_for(ws.vcs);
486 if vcs
487 .is_dirty(&ws.workspace_path, &ws.head_id)
488 .unwrap_or(true)
489 {
490 return;
491 }
492 let target = vcs
493 .commits(&ws.workspace_path, &ws.head_id, STACK_SCAN_DEPTH)
494 .ok()
495 .and_then(|commits| {
496 commits
497 .into_iter()
498 .find(|(_, message)| source.is_commit_for(key, message))
499 .map(|(id, _)| id)
500 })
501 .or_else(|| (*key == ws.tip).then(|| ws.head_id.clone()));
502 if let Some(commit) = target {
503 let _ = vcs.position(&ws.workspace_path, &commit);
504 }
505}
506
507#[derive(Debug, thiserror::Error)]
511#[error("workspace at {} has local changes", path.display())]
512pub struct WorkspaceDirty {
513 pub path: PathBuf,
514}
515
516pub fn remove_workspace(paths: &Paths, key: &ReviewKey, force: bool) -> Result<()> {
524 let mut state = State::load(&paths.state_file())?;
525 let entry = state
526 .get(key)
527 .cloned()
528 .with_context(|| format!("`{key}` isn't tracked"))?;
529 let (Some(stack_id), Some(ws)) = (entry.stack_id.clone(), state.workspace_of(&entry).cloned())
530 else {
531 bail!("`{key}` has no local workspace");
532 };
533
534 let vcs = vcs_for(ws.vcs);
535 let exists = ws.workspace_path.exists();
536 if exists
537 && !force
538 && vcs
539 .is_dirty(&ws.workspace_path, &ws.head_id)
540 .unwrap_or(true)
541 {
542 return Err(WorkspaceDirty {
543 path: ws.workspace_path.clone(),
544 }
545 .into());
546 }
547 if vcs
550 .remove_workspace(&ws.repo_path, &ws.workspace_path, &stack_id)
551 .is_err()
552 {
553 if !force {
554 bail!(
555 "failed to remove workspace at {}",
556 ws.workspace_path.display()
557 );
558 }
559 if exists {
560 std::fs::remove_dir_all(&ws.workspace_path)
561 .with_context(|| format!("removing {}", ws.workspace_path.display()))?;
562 }
563 }
564
565 state.remove_workspace(&stack_id);
566 state.for_each_entry_mut(|e| {
567 if e.stack_id.as_deref() == Some(stack_id.as_str()) {
568 e.stack_id = None;
569 }
570 });
571 state.save(&paths.state_file())
572}
573
574#[cfg(test)]
575mod tests {
576 use std::path::Path;
577 use std::process::Command;
578
579 use tempfile::TempDir;
580 use wiremock::matchers::{method, path, query_param};
581 use wiremock::{Mock, MockServer, ResponseTemplate};
582
583 use super::*;
584 use crate::config::GithubConfig;
585 use crate::source::github::GithubSource;
586
587 fn git(dir: &Path, args: &[&str]) {
588 let out = Command::new("git")
589 .current_dir(dir)
590 .args(args)
591 .output()
592 .unwrap();
593 assert!(
594 out.status.success(),
595 "git {args:?} failed: {}",
596 String::from_utf8_lossy(&out.stderr)
597 );
598 }
599
600 fn git_rev_parse(dir: &Path, rev: &str) -> String {
601 let out = Command::new("git")
602 .current_dir(dir)
603 .args(["rev-parse", rev])
604 .output()
605 .unwrap();
606 assert!(out.status.success());
607 String::from_utf8_lossy(&out.stdout).trim().to_string()
608 }
609
610 fn expose_pr_ref(upstream: &Path, fork: &Path, branch: &str, number: u32) {
614 git(
615 upstream,
616 &[
617 "fetch",
618 "-q",
619 fork.to_str().unwrap(),
620 &format!("{branch}:refs/pull/{number}/head"),
621 ],
622 );
623 }
624
625 fn paths_in(tmp: &Path) -> Paths {
626 Paths::discover()
627 .unwrap()
628 .with_overrides(Some(tmp.join("data")))
629 }
630
631 fn pull_json(
632 owner: &str,
633 repo: &str,
634 upstream: &Path,
635 fork: &Path,
636 sha: &str,
637 state: &str,
638 ) -> serde_json::Value {
639 serde_json::json!({
640 "number": 1,
641 "title": "Fix the thing",
642 "html_url": format!("https://github.com/{owner}/{repo}/pull/1"),
643 "state": state,
644 "user": {"login": "contributor"},
645 "head": {"ref": "feature", "sha": sha, "repo": {
646 "clone_url": fork.to_string_lossy(),
647 "full_name": format!("contributor/{repo}"),
648 "owner": {"login": "contributor"},
649 }},
650 "base": {"ref": "main", "sha": "unused", "repo": {
651 "clone_url": upstream.to_string_lossy(),
652 "full_name": format!("{owner}/{repo}"),
653 "owner": {"login": owner},
654 }},
655 "additions": 1,
656 "deletions": 0,
657 "changed_files": 1,
658 })
659 }
660
661 async fn mount_search(server: &MockServer, has_item: bool) {
662 let body = if has_item {
663 serde_json::json!({"items": [{"html_url": "https://github.com/moz/proj/pull/1"}]})
664 } else {
665 serde_json::json!({"items": []})
666 };
667 Mock::given(method("GET"))
668 .and(path("/search/issues"))
669 .and(query_param("page", "1"))
670 .respond_with(ResponseTemplate::new(200).set_body_json(body))
671 .mount(server)
672 .await;
673 }
674
675 async fn mount_pull(server: &MockServer, body: serde_json::Value) {
676 Mock::given(method("GET"))
677 .and(path("/repos/moz/proj/pulls/1"))
678 .respond_with(ResponseTemplate::new(200).set_body_json(body))
679 .mount(server)
680 .await;
681 }
682
683 fn github_source(server: &MockServer) -> Box<dyn ReviewSource> {
684 Box::new(GithubSource::for_test(
685 GithubConfig {
686 api_url: None,
687 token: Some("t".into()),
688 token_cmd: None,
689 ignore_repos: vec![],
690 ignore_authors: vec![],
691 ignore_teams: vec![],
692 include_drafts: false,
693 },
694 Some("t".into()),
695 server.uri(),
696 100,
697 ))
698 }
699
700 struct Repos {
702 _tmp: TempDir,
703 upstream: std::path::PathBuf,
704 fork: std::path::PathBuf,
705 }
706
707 fn make_repos() -> Repos {
708 let tmp = TempDir::new().unwrap();
709 let upstream = tmp.path().join("upstream");
710 std::fs::create_dir(&upstream).unwrap();
711 git(&upstream, &["init", "-q", "-b", "main"]);
712 git(&upstream, &["config", "user.name", "test"]);
713 git(&upstream, &["config", "user.email", "test@example.com"]);
714 git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
715
716 let fork = tmp.path().join("fork");
717 git(
718 tmp.path(),
719 &[
720 "clone",
721 "-q",
722 upstream.to_str().unwrap(),
723 fork.to_str().unwrap(),
724 ],
725 );
726 git(&fork, &["checkout", "-q", "-b", "feature"]);
727 std::fs::write(fork.join("pr.txt"), "pr change\n").unwrap();
728 git(&fork, &["add", "pr.txt"]);
729 git(&fork, &["commit", "-q", "-m", "pr change"]);
730 expose_pr_ref(&upstream, &fork, "feature", 1);
731
732 Repos {
733 _tmp: tmp,
734 upstream,
735 fork,
736 }
737 }
738
739 #[tokio::test]
740 async fn tracks_new_review_without_creating_a_workspace() {
741 let repos = make_repos();
742 let sha = git_rev_parse(&repos.fork, "HEAD");
743 let server = MockServer::start().await;
744 mount_search(&server, true).await;
745 mount_pull(
746 &server,
747 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
748 )
749 .await;
750
751 let work_tmp = TempDir::new().unwrap();
752 let paths = paths_in(work_tmp.path());
753 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
754
755 let report = sync(&sources, &paths, None, false).await.unwrap();
756
757 assert_eq!(report.added, vec![ReviewKey::new("gh", "moz/proj/1")]);
758 let state = State::load(&paths.state_file()).unwrap();
759 let entry = state.get(&ReviewKey::new("gh", "moz/proj/1")).unwrap();
760 assert!(entry.in_queue);
761 assert!(!entry.resolved);
762 assert_eq!(entry.version, sha);
763 assert_eq!(
764 entry.diff_stat.as_deref(),
765 Some("1 file changed, 1 insertion(+)"),
766 "diff_stat should be fetched as part of the same sync that adds the review"
767 );
768 assert!(
769 state.workspace_of(entry).is_none(),
770 "sync must not create a workspace on its own"
771 );
772
773 assert!(!paths.repos_file().exists());
775 assert!(!paths.repos_dir().exists());
776 }
777
778 #[tokio::test]
779 async fn fetch_local_creates_workspace_on_demand() {
780 let repos = make_repos();
781 let sha = git_rev_parse(&repos.fork, "HEAD");
782 let server = MockServer::start().await;
783 mount_search(&server, true).await;
784 mount_pull(
785 &server,
786 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
787 )
788 .await;
789
790 let work_tmp = TempDir::new().unwrap();
791 let paths = paths_in(work_tmp.path());
792 let config = Config::default();
793 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
794
795 sync(&sources, &paths, None, false).await.unwrap();
796
797 let key = ReviewKey::new("gh", "moz/proj/1");
798 let ws_path = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
799 .await
800 .unwrap();
801
802 assert!(ws_path.join("pr.txt").exists());
803 let state = State::load(&paths.state_file()).unwrap();
804 let ws = state.workspace_for(&key).unwrap();
805 assert_eq!(ws.status, Status::Ready);
806 assert_eq!(ws.head_id, sha);
807 assert_eq!(ws.workspace_path, ws_path);
808
809 assert!(ws.repo_path.join(".git").exists());
812 assert!(paths.repos_file().exists());
813
814 let ws_path2 = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
816 .await
817 .unwrap();
818 assert_eq!(ws_path2, ws_path);
819 }
820
821 #[tokio::test]
822 async fn fetch_local_retries_an_apply_failed_workspace_instead_of_returning_it() {
823 let repos = make_repos();
824 let sha = git_rev_parse(&repos.fork, "HEAD");
825 let server = MockServer::start().await;
826 mount_search(&server, true).await;
827 mount_pull(
828 &server,
829 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
830 )
831 .await;
832
833 let work_tmp = TempDir::new().unwrap();
834 let paths = paths_in(work_tmp.path());
835 let config = Config::default();
836 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
837 let key = ReviewKey::new("gh", "moz/proj/1");
838
839 sync(&sources, &paths, None, false).await.unwrap();
840 let ws_path = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
841 .await
842 .unwrap();
843
844 let mut state = State::load(&paths.state_file()).unwrap();
848 let stack_id = state.get(&key).unwrap().stack_id.clone().unwrap();
849 let mut ws = state.workspace(&stack_id).cloned().unwrap();
850 ws.status = Status::ApplyFailed;
851 ws.head_id = String::new();
852 state.insert_workspace(stack_id, ws);
853 state.save(&paths.state_file()).unwrap();
854
855 let retried_path = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
856 .await
857 .unwrap();
858
859 assert_eq!(retried_path, ws_path);
860 assert!(retried_path.join("pr.txt").exists());
861 let state = State::load(&paths.state_file()).unwrap();
862 let ws = state.workspace_for(&key).unwrap();
863 assert_eq!(ws.status, Status::Ready);
864 assert_eq!(ws.head_id, sha);
865 }
866
867 #[tokio::test]
868 async fn resolved_review_without_a_workspace_is_dropped_with_no_git_calls() {
869 let repos = make_repos();
870 let sha = git_rev_parse(&repos.fork, "HEAD");
871 let server = MockServer::start().await;
872 mount_search(&server, true).await;
873 mount_pull(
874 &server,
875 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
876 )
877 .await;
878
879 let work_tmp = TempDir::new().unwrap();
880 let paths = paths_in(work_tmp.path());
881 let key = ReviewKey::new("gh", "moz/proj/1");
882
883 sync(&[github_source(&server)], &paths, None, false)
884 .await
885 .unwrap();
886 assert!(
887 State::load(&paths.state_file())
888 .unwrap()
889 .get(&key)
890 .unwrap()
891 .stack_id
892 .is_none()
893 );
894 drop(server);
895
896 let server = MockServer::start().await;
899 mount_search(&server, false).await;
900 mount_pull(
901 &server,
902 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "closed"),
903 )
904 .await;
905
906 let report = sync(&[github_source(&server)], &paths, None, false)
907 .await
908 .unwrap();
909
910 assert_eq!(report.removed, vec![key.clone()]);
911 assert!(
912 State::load(&paths.state_file())
913 .unwrap()
914 .get(&key)
915 .is_none()
916 );
917 }
918
919 #[tokio::test]
920 async fn removes_workspace_once_resolved_and_clean() {
921 let repos = make_repos();
922 let sha = git_rev_parse(&repos.fork, "HEAD");
923 let server = MockServer::start().await;
924 mount_search(&server, true).await;
925 mount_pull(
926 &server,
927 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
928 )
929 .await;
930
931 let work_tmp = TempDir::new().unwrap();
932 let paths = paths_in(work_tmp.path());
933 let config = Config::default();
934 let key = ReviewKey::new("gh", "moz/proj/1");
935
936 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
937 sync(&sources, &paths, None, false).await.unwrap();
938 let ws_path = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
939 .await
940 .unwrap();
941 assert!(ws_path.exists());
942 drop(server);
943
944 let server = MockServer::start().await;
947 mount_search(&server, false).await;
948 mount_pull(
949 &server,
950 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "closed"),
951 )
952 .await;
953
954 let report = sync(&[github_source(&server)], &paths, None, false)
955 .await
956 .unwrap();
957
958 assert_eq!(report.removed, vec![ReviewKey::new("gh", "moz/proj/1")]);
959 assert!(!ws_path.exists());
960 assert!(
961 State::load(&paths.state_file())
962 .unwrap()
963 .get(&ReviewKey::new("gh", "moz/proj/1"))
964 .is_none()
965 );
966 }
967
968 #[tokio::test]
969 async fn keeps_out_of_queue_workspace_when_still_open() {
970 let repos = make_repos();
971 let sha = git_rev_parse(&repos.fork, "HEAD");
972 let server = MockServer::start().await;
973 mount_search(&server, true).await;
974 mount_pull(
975 &server,
976 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
977 )
978 .await;
979
980 let work_tmp = TempDir::new().unwrap();
981 let paths = paths_in(work_tmp.path());
982 let config = Config::default();
983 let key = ReviewKey::new("gh", "moz/proj/1");
984 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
985 sync(&sources, &paths, None, false).await.unwrap();
986 fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
987 .await
988 .unwrap();
989 drop(server);
990
991 let server = MockServer::start().await;
993 mount_search(&server, false).await;
994 mount_pull(
995 &server,
996 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
997 )
998 .await;
999
1000 let report = sync(&[github_source(&server)], &paths, None, false)
1001 .await
1002 .unwrap();
1003
1004 assert!(report.removed.is_empty());
1005 let state = State::load(&paths.state_file()).unwrap();
1006 let entry = state.get(&key).unwrap();
1007 assert!(!entry.in_queue);
1008 assert!(state.workspace_of(entry).unwrap().workspace_path.exists());
1009 }
1010
1011 #[tokio::test]
1012 async fn dirty_workspace_is_flagged_not_updated() {
1013 let repos = make_repos();
1014 let sha1 = git_rev_parse(&repos.fork, "HEAD");
1015 let server = MockServer::start().await;
1016 mount_search(&server, true).await;
1017 mount_pull(
1018 &server,
1019 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha1, "open"),
1020 )
1021 .await;
1022
1023 let work_tmp = TempDir::new().unwrap();
1024 let paths = paths_in(work_tmp.path());
1025 let config = Config::default();
1026 let key = ReviewKey::new("gh", "moz/proj/1");
1027 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
1028 sync(&sources, &paths, None, false).await.unwrap();
1029 let ws_path = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
1030 .await
1031 .unwrap();
1032 drop(server);
1033
1034 std::fs::write(ws_path.join("untracked.txt"), "local edit\n").unwrap();
1035
1036 std::fs::write(repos.fork.join("pr2.txt"), "more change\n").unwrap();
1038 git(&repos.fork, &["add", "pr2.txt"]);
1039 git(&repos.fork, &["commit", "-q", "-m", "pr change 2"]);
1040 let sha2 = git_rev_parse(&repos.fork, "HEAD");
1041 assert_ne!(sha1, sha2);
1042 expose_pr_ref(&repos.upstream, &repos.fork, "feature", 1);
1043
1044 let server = MockServer::start().await;
1045 mount_search(&server, true).await;
1046 mount_pull(
1047 &server,
1048 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha2, "open"),
1049 )
1050 .await;
1051
1052 let report = sync(&[github_source(&server)], &paths, None, false)
1053 .await
1054 .unwrap();
1055
1056 assert!(report.updated.is_empty());
1057 assert_eq!(report.flagged.len(), 1);
1058 let state = State::load(&paths.state_file()).unwrap();
1059 let ws = state.workspace_for(&key).unwrap();
1060 assert_eq!(ws.status, Status::Dirty);
1061 assert_eq!(
1062 ws.version, sha1,
1063 "dirty workspace must not be moved to the new version"
1064 );
1065 assert!(
1066 ws_path.join("untracked.txt").exists(),
1067 "local edit must survive untouched"
1068 );
1069 }
1070
1071 #[tokio::test]
1072 async fn remove_workspace_deletes_a_clean_one() {
1073 let repos = make_repos();
1074 let sha = git_rev_parse(&repos.fork, "HEAD");
1075 let server = MockServer::start().await;
1076 mount_search(&server, true).await;
1077 mount_pull(
1078 &server,
1079 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
1080 )
1081 .await;
1082
1083 let work_tmp = TempDir::new().unwrap();
1084 let paths = paths_in(work_tmp.path());
1085 let config = Config::default();
1086 let key = ReviewKey::new("gh", "moz/proj/1");
1087 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
1088 sync(&sources, &paths, None, false).await.unwrap();
1089 let ws_path = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
1090 .await
1091 .unwrap();
1092
1093 remove_workspace(&paths, &key, false).unwrap();
1094
1095 assert!(!ws_path.exists());
1096 let state = State::load(&paths.state_file()).unwrap();
1097 assert!(
1098 state.workspace_for(&key).is_none(),
1099 "review stays tracked, just without a workspace"
1100 );
1101 assert!(state.get(&key).is_some());
1102 }
1103
1104 #[tokio::test]
1105 async fn remove_workspace_refuses_a_dirty_one_without_force() {
1106 let repos = make_repos();
1107 let sha = git_rev_parse(&repos.fork, "HEAD");
1108 let server = MockServer::start().await;
1109 mount_search(&server, true).await;
1110 mount_pull(
1111 &server,
1112 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
1113 )
1114 .await;
1115
1116 let work_tmp = TempDir::new().unwrap();
1117 let paths = paths_in(work_tmp.path());
1118 let config = Config::default();
1119 let key = ReviewKey::new("gh", "moz/proj/1");
1120 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
1121 sync(&sources, &paths, None, false).await.unwrap();
1122 let ws_path = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
1123 .await
1124 .unwrap();
1125 std::fs::write(ws_path.join("untracked.txt"), "local edit\n").unwrap();
1126
1127 let err = remove_workspace(&paths, &key, false).unwrap_err();
1128
1129 assert!(err.downcast_ref::<WorkspaceDirty>().is_some());
1130 assert!(ws_path.exists(), "must not touch a dirty workspace");
1131 let state = State::load(&paths.state_file()).unwrap();
1132 assert!(state.workspace_for(&key).is_some());
1133 }
1134
1135 #[tokio::test]
1136 async fn remove_workspace_with_force_discards_local_changes() {
1137 let repos = make_repos();
1138 let sha = git_rev_parse(&repos.fork, "HEAD");
1139 let server = MockServer::start().await;
1140 mount_search(&server, true).await;
1141 mount_pull(
1142 &server,
1143 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
1144 )
1145 .await;
1146
1147 let work_tmp = TempDir::new().unwrap();
1148 let paths = paths_in(work_tmp.path());
1149 let config = Config::default();
1150 let key = ReviewKey::new("gh", "moz/proj/1");
1151 let sources: Vec<Box<dyn ReviewSource>> = vec![github_source(&server)];
1152 sync(&sources, &paths, None, false).await.unwrap();
1153 let ws_path = fetch_local(&sources, &paths, &config, &key, OnMissing::Clone)
1154 .await
1155 .unwrap();
1156 std::fs::write(ws_path.join("untracked.txt"), "local edit\n").unwrap();
1157
1158 remove_workspace(&paths, &key, true).unwrap();
1159
1160 assert!(!ws_path.exists());
1161 let state = State::load(&paths.state_file()).unwrap();
1162 assert!(state.workspace_for(&key).is_none());
1163 }
1164
1165 #[test]
1166 fn remove_workspace_errors_when_review_has_none() {
1167 let work_tmp = TempDir::new().unwrap();
1168 let paths = paths_in(work_tmp.path());
1169 let key = ReviewKey::new("gh", "moz/proj/1");
1170 let mut state = State::default();
1171 state.insert(ReviewEntry {
1172 key: key.clone(),
1173 title: "Fix the thing".into(),
1174 author: "someone".into(),
1175 url: "https://example.com/1".into(),
1176 repo: crate::source::RepoRef {
1177 urls: vec!["https://example.com/o/r".into()],
1178 display_name: "o/r".into(),
1179 },
1180 kind: crate::source::ReviewKind::Direct,
1181 version: "1".into(),
1182 in_queue: true,
1183 resolved: false,
1184 last_synced: chrono::Utc::now(),
1185 stack_id: None,
1186 ancestors: Vec::new(),
1187 diff_stat: None,
1188 description: None,
1189 });
1190 state.save(&paths.state_file()).unwrap();
1191
1192 let err = remove_workspace(&paths, &key, false).unwrap_err();
1193
1194 assert!(err.downcast_ref::<WorkspaceDirty>().is_none());
1195 assert!(format!("{err:#}").contains("no local workspace"));
1196 }
1197
1198 #[tokio::test]
1199 async fn dry_run_makes_no_changes() {
1200 let repos = make_repos();
1201 let sha = git_rev_parse(&repos.fork, "HEAD");
1202 let server = MockServer::start().await;
1203 mount_search(&server, true).await;
1204 mount_pull(
1205 &server,
1206 pull_json("moz", "proj", &repos.upstream, &repos.fork, &sha, "open"),
1207 )
1208 .await;
1209
1210 let work_tmp = TempDir::new().unwrap();
1211 let paths = paths_in(work_tmp.path());
1212
1213 let report = sync(&[github_source(&server)], &paths, None, true)
1214 .await
1215 .unwrap();
1216
1217 assert_eq!(report.added, vec![ReviewKey::new("gh", "moz/proj/1")]);
1218 assert!(
1219 !paths.state_file().exists(),
1220 "dry-run must not write state.json"
1221 );
1222 assert!(
1225 !paths.repos_file().exists(),
1226 "dry-run must not touch the repo registry"
1227 );
1228 assert!(
1229 !paths.repos_dir().exists(),
1230 "dry-run must not clone the canonical repo"
1231 );
1232 }
1233
1234 struct StackSource {
1238 upstream: std::path::PathBuf,
1239 queue: std::sync::Mutex<Vec<Review>>,
1240 lifecycle: std::sync::Mutex<Lifecycle>,
1241 }
1242
1243 impl StackSource {
1244 fn new(upstream: &Path) -> Self {
1245 Self {
1246 upstream: upstream.to_path_buf(),
1247 queue: Default::default(),
1248 lifecycle: std::sync::Mutex::new(Lifecycle::Open),
1249 }
1250 }
1251
1252 fn review(&self, n: u32, ancestors: &[u32], version: &str) -> Review {
1253 Review {
1254 key: ReviewKey::new("stk", format!("D{n}")),
1255 title: format!("Review {n}"),
1256 author: "alice".into(),
1257 url: format!("https://phab.example.com/D{n}"),
1258 repo: crate::source::RepoRef {
1259 urls: vec![self.upstream.to_string_lossy().to_string()],
1260 display_name: "proj".into(),
1261 },
1262 version: version.into(),
1263 kind: crate::source::ReviewKind::Direct,
1264 diff_stat: None,
1265 description: None,
1266 ancestors: ancestors
1267 .iter()
1268 .map(|a| ReviewKey::new("stk", format!("D{a}")))
1269 .collect(),
1270 }
1271 }
1272
1273 fn set_queue(&self, reviews: Vec<Review>) {
1274 *self.queue.lock().unwrap() = reviews;
1275 }
1276 }
1277
1278 fn patch_for(id: &str) -> crate::source::Patch {
1279 let file = format!("{id}.txt");
1280 crate::source::Patch {
1281 title: format!("patch {id}"),
1282 author: "Patch Author <patch@example.com>".into(),
1283 message: format!("patch {id}\n\nDifferential Revision: https://phab.example.com/{id}"),
1284 diff: format!(
1285 "diff --git a/{file} b/{file}\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/{file}\n@@ -0,0 +1 @@\n+{id}\n"
1286 ),
1287 }
1288 }
1289
1290 #[async_trait::async_trait]
1291 impl ReviewSource for StackSource {
1292 fn name(&self) -> &str {
1293 "stk"
1294 }
1295
1296 async fn fetch_queue(&self) -> Result<Vec<Review>> {
1297 Ok(self.queue.lock().unwrap().clone())
1298 }
1299
1300 async fn checkout_spec(
1301 &self,
1302 review: &Review,
1303 _canonical_repo: &Path,
1304 ) -> Result<crate::source::Checkout> {
1305 let patches = review
1306 .ancestors
1307 .iter()
1308 .chain(std::iter::once(&review.key))
1309 .map(|k| patch_for(&k.id))
1310 .collect();
1311 Ok(crate::source::Checkout::Patches {
1312 base: None,
1313 patches,
1314 })
1315 }
1316
1317 async fn fetch_status(&self, ids: &[String]) -> Result<Vec<(String, Lifecycle)>> {
1318 let lifecycle = *self.lifecycle.lock().unwrap();
1319 Ok(ids.iter().map(|id| (id.clone(), lifecycle)).collect())
1320 }
1321
1322 fn is_commit_for(&self, review: &ReviewKey, message: &str) -> bool {
1323 message
1324 .lines()
1325 .any(|l| l.ends_with(&format!("/{}", review.id)))
1326 }
1327 }
1328
1329 fn stack_fixture() -> (Repos, TempDir, Paths, Vec<Box<dyn ReviewSource>>) {
1330 let repos = make_repos();
1331 let work_tmp = TempDir::new().unwrap();
1332 let paths = paths_in(work_tmp.path());
1333 let source = StackSource::new(&repos.upstream);
1334 source.set_queue(vec![
1335 source.review(1, &[], "1"),
1336 source.review(2, &[1], "1"),
1337 ]);
1338 (repos, work_tmp, paths, vec![Box::new(source)])
1339 }
1340
1341 fn stk(n: u32) -> ReviewKey {
1342 ReviewKey::new("stk", format!("D{n}"))
1343 }
1344
1345 #[tokio::test]
1346 async fn stacked_reviews_share_one_workspace_positioned_at_the_requested_patch() {
1347 let (_repos, _tmp, paths, sources) = stack_fixture();
1348 let config = Config::default();
1349 sync(&sources, &paths, None, false).await.unwrap();
1350
1351 let ws_path = fetch_local(&sources, &paths, &config, &stk(1), OnMissing::Clone)
1352 .await
1353 .unwrap();
1354 assert!(ws_path.join("D1.txt").exists());
1356 assert!(!ws_path.join("D2.txt").exists());
1357
1358 let state = State::load(&paths.state_file()).unwrap();
1359 assert_eq!(state.workspaces().count(), 1);
1360 assert_eq!(
1361 state.get(&stk(1)).unwrap().stack_id,
1362 state.get(&stk(2)).unwrap().stack_id,
1363 "both reviews point at the one workspace"
1364 );
1365 assert_eq!(state.workspace_for(&stk(2)).unwrap().tip, stk(2));
1366
1367 let again = fetch_local(&sources, &paths, &config, &stk(2), OnMissing::Clone)
1369 .await
1370 .unwrap();
1371 assert_eq!(again, ws_path);
1372 assert!(ws_path.join("D2.txt").exists());
1373 assert_eq!(
1374 State::load(&paths.state_file())
1375 .unwrap()
1376 .workspaces()
1377 .count(),
1378 1
1379 );
1380 }
1381
1382 #[tokio::test]
1383 async fn local_changes_stop_fetch_local_from_moving_the_workspace() {
1384 let (_repos, _tmp, paths, sources) = stack_fixture();
1385 let config = Config::default();
1386 sync(&sources, &paths, None, false).await.unwrap();
1387 let ws_path = fetch_local(&sources, &paths, &config, &stk(2), OnMissing::Clone)
1388 .await
1389 .unwrap();
1390 std::fs::write(ws_path.join("scratch.txt"), "review notes\n").unwrap();
1391
1392 fetch_local(&sources, &paths, &config, &stk(1), OnMissing::Clone)
1393 .await
1394 .unwrap();
1395
1396 assert!(ws_path.join("D2.txt").exists(), "must stay on the tip");
1397 assert!(ws_path.join("scratch.txt").exists());
1398 }
1399
1400 #[tokio::test]
1401 async fn a_new_patch_on_top_rebuilds_the_workspace_with_the_new_tip() {
1402 let (repos, _tmp, paths, sources) = stack_fixture();
1403 let config = Config::default();
1404 sync(&sources, &paths, None, false).await.unwrap();
1405 let ws_path = fetch_local(&sources, &paths, &config, &stk(1), OnMissing::Clone)
1406 .await
1407 .unwrap();
1408
1409 let next = StackSource::new(&repos.upstream);
1410 next.set_queue(vec![
1411 next.review(1, &[], "1"),
1412 next.review(2, &[1], "1"),
1413 next.review(3, &[1, 2], "1"),
1414 ]);
1415 let sources: Vec<Box<dyn ReviewSource>> = vec![Box::new(next)];
1416 let report = sync(&sources, &paths, None, false).await.unwrap();
1417
1418 assert_eq!(report.updated, vec![stk(3)]);
1419 let state = State::load(&paths.state_file()).unwrap();
1420 let ws = state.workspace_for(&stk(3)).unwrap();
1421 assert_eq!(ws.tip, stk(3));
1422 assert_eq!(ws.status, Status::Ready);
1423 assert_eq!(state.workspaces().count(), 1, "still the one workspace");
1424 assert!(ws_path.join("D3.txt").exists());
1425 }
1426
1427 #[tokio::test]
1428 async fn a_changed_patch_mid_stack_updates_the_workspace_via_the_tip_version() {
1429 let (repos, _tmp, paths, sources) = stack_fixture();
1430 let config = Config::default();
1431 sync(&sources, &paths, None, false).await.unwrap();
1432 fetch_local(&sources, &paths, &config, &stk(2), OnMissing::Clone)
1433 .await
1434 .unwrap();
1435
1436 let next = StackSource::new(&repos.upstream);
1438 next.set_queue(vec![next.review(1, &[], "2"), next.review(2, &[1], "2")]);
1439 let sources: Vec<Box<dyn ReviewSource>> = vec![Box::new(next)];
1440 let report = sync(&sources, &paths, None, false).await.unwrap();
1441
1442 assert_eq!(report.updated, vec![stk(2)]);
1443 let state = State::load(&paths.state_file()).unwrap();
1444 assert_eq!(state.workspace_for(&stk(2)).unwrap().version, "2");
1445 }
1446
1447 #[tokio::test]
1448 async fn workspace_is_removed_only_once_every_review_in_the_stack_resolves() {
1449 let (repos, _tmp, paths, sources) = stack_fixture();
1450 let config = Config::default();
1451 sync(&sources, &paths, None, false).await.unwrap();
1452 let ws_path = fetch_local(&sources, &paths, &config, &stk(2), OnMissing::Clone)
1453 .await
1454 .unwrap();
1455
1456 let next = StackSource::new(&repos.upstream);
1458 next.set_queue(vec![next.review(2, &[], "3")]);
1459 let sources: Vec<Box<dyn ReviewSource>> = vec![Box::new(next)];
1460 let report = sync(&sources, &paths, None, false).await.unwrap();
1461 assert_eq!(report.updated, vec![stk(2)]);
1462 assert!(ws_path.exists());
1463 assert!(ws_path.join("D2.txt").exists());
1465 assert!(!ws_path.join("D1.txt").exists());
1466
1467 let done = StackSource::new(&repos.upstream);
1469 *done.lifecycle.lock().unwrap() = Lifecycle::Resolved;
1470 let sources: Vec<Box<dyn ReviewSource>> = vec![Box::new(done)];
1471 let report = sync(&sources, &paths, None, false).await.unwrap();
1472
1473 assert!(report.removed.contains(&stk(2)));
1474 assert!(!ws_path.exists());
1475 let state = State::load(&paths.state_file()).unwrap();
1476 assert_eq!(state.workspaces().count(), 0);
1477 assert!(state.get(&stk(2)).is_none());
1478 }
1479
1480 #[tokio::test]
1481 async fn remove_workspace_takes_the_whole_stack_with_it() {
1482 let (_repos, _tmp, paths, sources) = stack_fixture();
1483 let config = Config::default();
1484 sync(&sources, &paths, None, false).await.unwrap();
1485 let ws_path = fetch_local(&sources, &paths, &config, &stk(1), OnMissing::Clone)
1486 .await
1487 .unwrap();
1488
1489 remove_workspace(&paths, &stk(1), false).unwrap();
1490
1491 assert!(!ws_path.exists());
1492 let state = State::load(&paths.state_file()).unwrap();
1493 assert!(state.workspace_for(&stk(2)).is_none());
1494 assert!(state.get(&stk(2)).unwrap().stack_id.is_none());
1495 }
1496}