Skip to main content

review_queue/
sync.rs

1//! The sync engine: source-agnostic glue between `ReviewSource`, `RepoStore`, and the `Vcs`
2//! backends.
3//!
4//! Workspaces are per *stack*, not per review: reviews whose `ancestors` chain them together (a
5//! Phabricator stack) share one workspace, built from the stack's tip, and a lone review is just a
6//! stack of one. See `stacks` for how reviews are grouped.
7//!
8//! `rq sync` (this module's `sync()`) only ever tracks metadata and updates/removes *existing*
9//! workspaces - it never creates one. Per source, per run:
10//! 1. `fetch_queue()` - reviews currently waiting on you.
11//! 2. New reviews are recorded with no workspace (see `fetch_local()` for that); tracked ones get
12//!    their metadata (including `version` and `ancestors`) refreshed.
13//!
14//! Then, once per source, for tracked reviews that weren't in this run's queue (you acted on
15//! them - approved, requested changes - so they dropped out): `fetch_status()` tells us whether
16//! the review itself resolved (landed/closed/abandoned/merged). A resolved review that isn't part
17//! of a workspace is just dropped.
18//!
19//! Finally each existing workspace is reconciled against its stack: reviews newly chained onto a
20//! checked-out stack join its workspace; a workspace whose tip or tip `version` changed is
21//! updated in place if clean (`update_workspace`), or flagged if dirty; one whose reviews have
22//! all resolved is removed, discarding any local changes. This is the only
23//! workspace-removal path; there's no separate prune step.
24//!
25//! `fetch_local()` is the on-demand counterpart - resolving a canonical repo (asking before
26//! cloning one, unless told otherwise) and creating a workspace for a single already-tracked
27//! review's stack, or, if it already has one, moving it to that review's patch. It's what `rq
28//! fetch` and the fetch hotkey in `rq show`'s TUI call; `sync()` never calls it itself.
29
30use 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
45/// How far back from a stack's tip to look for a member's commit; comfortably deeper than any
46/// realistic stack.
47const 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    /// Review + human-readable reason it was flagged instead of touched (dirty, apply failure).
55    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
129/// Record a review from this run's queue: new ones are added with no workspace, tracked ones
130/// have their metadata refreshed. Never touches a workspace - see `reconcile_workspaces`.
131fn 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                // Kept while its stack's workspace lives; `reconcile_workspaces` drops it along
191                // with the workspace.
192                state.insert(entry);
193            } else {
194                // Nothing local to preserve for inspection - just forget it.
195                state.remove(key);
196                report.removed.push(key.clone());
197            }
198        }
199    }
200}
201
202/// Make every review in a stack that's checked out share that stack's workspace. Also settles
203/// the case of one stack whose members were fetched separately (older per-review workspaces, or
204/// a review re-parented onto another's stack): the tip's workspace wins and the rest are left
205/// orphaned for `reconcile_workspaces` to clean up.
206fn 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
239/// Bring each existing workspace in line with its stack: rebuild it if the stack's tip or tip
240/// version changed, remove it once every review in it has resolved. See the module docs.
241async 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        // The stack this workspace belongs to; prefer the one still containing its old tip.
271        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
336/// Every review in this workspace has resolved (or none remain): remove the workspace and its
337/// reviews, discarding any local changes.
338fn 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        // Even when the directory is already gone, the parent repo still has the workspace
349        // registered; the backend forgets it.
350        // The backend assumes a clean worktree, so finish a dirty one off with a raw removal.
351        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
371/// Resolve a canonical repo (asking before cloning one, unless `on_missing` says otherwise) and
372/// make sure `key`, a review already tracked by a prior `sync()`, has a workspace - the one for
373/// its whole stack - then move that workspace to `key`'s own patch. If the stack is already
374/// checked out this is just the move (skipped when the workspace has local changes, which are
375/// never disturbed); an `ApplyFailed` workspace is instead cleaned up and rebuilt, since handing
376/// back a broken/empty path again would just repeat the failure silently. This is the on-demand
377/// counterpart to `sync()`'s deliberate refusal to create workspaces on its own - see the module
378/// docs.
379///
380/// Under `OnMissing::Ask`, a repo with no local checkout surfaces as a `repo::NeedsClone` error
381/// (`downcast_ref` it) rather than cloning - callers should confirm with the user and retry with
382/// `OnMissing::Clone` if they agree.
383pub 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        // Retry instead of handing back the broken path: best-effort clean up whatever the
422        // failed attempt left registered/on-disk first, since re-adding a workspace at the same
423        // name/path would otherwise fail again for that reason alone.
424        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        // Recorded anyway (with the workspace left in place, per the vcs backends' own
453        // contract) so `rq show`/`rq path` can point at it for inspection.
454        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
480/// Best-effort: move a stack's workspace to `key`'s patch. Never disturbs a workspace with local
481/// changes, and does nothing if `key`'s commit can't be found (e.g. a patch added to the stack
482/// since the workspace was built, which the next sync will pick up) - the caller still gets the
483/// workspace either way.
484fn 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/// Returned by `remove_workspace` when the workspace has local changes and `force` wasn't set.
508/// Callers should confirm with the user and retry with `force: true` if they agree to discard
509/// them.
510#[derive(Debug, thiserror::Error)]
511#[error("workspace at {} has local changes", path.display())]
512pub struct WorkspaceDirty {
513    pub path: PathBuf,
514}
515
516/// Delete the local workspace of a review's stack on demand - the `d` hotkey in `rq show`'s TUI.
517/// Acts regardless of `resolved`/`in_queue`, unlike `sync()`'s own resolved-and-clean removal
518/// path, and on every review in the stack, since they share the one workspace.
519///
520/// A dirty workspace is left untouched and surfaces as `WorkspaceDirty` (`downcast_ref` it)
521/// unless `force` is set, in which case whatever the backend's own `remove_workspace` can't clean
522/// up on a dirty worktree (it assumes a clean one) is finished off with a raw directory removal.
523pub 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    // Even when the directory is already gone, the parent repo still has the workspace
548    // registered; the backend forgets it.
549    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    /// Real GitHub exposes every PR's head as `refs/pull/N/head` on the *base* repo, which is
611    /// what lets the git backend fetch a fork's commit without ever adding the fork as a remote.
612    /// A plain local git fixture doesn't do this automatically, so tests have to fake it.
613    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    /// An "upstream" (PR base) and "fork" (PR head) repo pair, each a real local git repo.
701    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        // Nothing was cloned or registered either - that only happens on `fetch_local`.
774        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        // The canonical repo was cloned (tool-managed - not found in any workdir) and
810        // registered, so a second fetch would reuse it instead of cloning again.
811        assert!(ws.repo_path.join(".git").exists());
812        assert!(paths.repos_file().exists());
813
814        // Calling it again is a no-op that just returns the existing path.
815        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        // Simulate a previous attempt that left a real, registered worktree behind but got
845        // marked failed (e.g. a later step errored) - a naive retry would otherwise leave this
846        // stuck forever, since `git worktree add`/`jj workspace add` refuse to reuse the path.
847        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        // Never fetched locally, and now merged - nothing to check for dirtiness, so it's just
897        // dropped from tracking.
898        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        // Second sync: the PR is no longer in the queue (merged) and `fetch_status` reports it
945        // closed, with the workspace still clean.
946        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        // Second sync: changes requested, so the PR drops out of the queue, but it's still open.
992        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        // The PR gets a new commit pushed.
1037        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        // `sync` never resolves/clones a canonical repo itself - only `fetch_local` does - so
1223        // this holds regardless of `dry_run`, but is worth pinning down for the dry-run path too.
1224        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    /// A source whose reviews form a Phabricator-style stack, checked out by applying one small
1235    /// patch per review (`D<n>` adds `<n>.txt`), each commit carrying a `Differential Revision:`
1236    /// trailer like `moz-phab patch` leaves.
1237    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        // Opened on D1's patch: D2's file (on top) isn't checked out.
1355        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        // Asking for the top review reuses the workspace and moves to the tip.
1368        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        // Phabricator folds every ancestor's modification time into the tip's version.
1437        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        // D1 lands and D2 is still open (and now stands alone): the workspace stays.
1457        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        // Rebuilt on the landed base, so only D2's own patch remains.
1464        assert!(ws_path.join("D2.txt").exists());
1465        assert!(!ws_path.join("D1.txt").exists());
1466
1467        // Now everything resolves.
1468        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}