Skip to main content

diffler_core/
review.rs

1//! Facade tying the VCS backend, session, and store together: the one
2//! entry point the TUI and MCP layers consume.
3
4use std::cell::OnceCell;
5use std::collections::{BTreeMap, HashMap};
6use std::path::{Path, PathBuf};
7
8use thiserror::Error;
9
10use crate::diffalgo::{DiffAlgorithm, DiffSettings};
11use crate::model::DiffModel;
12use crate::repo;
13use crate::session::Session;
14use crate::source::ReviewSource;
15use crate::store::{self, StoreError};
16use crate::vcs::{StatusModel, Vcs, VcsError};
17
18#[derive(Debug, Error)]
19pub enum ReviewError {
20    #[error(transparent)]
21    Vcs(#[from] VcsError),
22    #[error(transparent)]
23    Store(#[from] StoreError),
24}
25
26/// One file as the file view reads it: worktree text plus per-line blame.
27#[derive(Debug)]
28pub struct FileSnapshot {
29    pub path: String,
30    pub content: String,
31    /// Empty when git has nothing to attribute, e.g. an untracked file.
32    pub blame: Vec<crate::vcs::BlameSpan>,
33}
34
35/// The result of [`Review::compute_walkthrough_files`]: every file it could
36/// read, plus whether the revision it was asked to pin to still resolves.
37#[derive(Debug, Default)]
38pub struct WalkthroughFiles {
39    pub contents: HashMap<String, String>,
40    /// A `rev` was named but no longer resolves (a squash, a rebase, a gc):
41    /// every file fell back to the worktree, and the reader is looking at
42    /// live code believing it is pinned. `false` when nothing was pinned at
43    /// all, which is not broken, just untracked.
44    pub pin_broken: bool,
45}
46
47/// A preview reads at most this many bytes of one side (32 MiB).
48pub const MAX_PREVIEW_BYTES: u64 = 32 * 1024 * 1024;
49
50/// One side of a binary file, as [`Review::compute_binary_sides`] read it.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum BinarySide {
53    Bytes(Vec<u8>),
54    /// The side exists but is over [`MAX_PREVIEW_BYTES`]; its size in bytes.
55    TooLarge(u64),
56}
57
58impl BinarySide {
59    fn of(bytes: Vec<u8>) -> Self {
60        let size = bytes.len() as u64;
61        if size > MAX_PREVIEW_BYTES {
62            Self::TooLarge(size)
63        } else {
64            Self::Bytes(bytes)
65        }
66    }
67}
68
69/// Both sides of a binary file; `None` for a side that does not exist.
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct BinarySides {
72    pub old: Option<BinarySide>,
73    pub new: Option<BinarySide>,
74}
75
76/// Which copy of a walkthrough's file [`Review::compute_walkthrough_files`]
77/// reads first, falling back to the other.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ReadFirst {
80    /// The commit the walkthrough was pinned to, for code that was committed.
81    Pin,
82    /// The file on disk, for a walkthrough of uncommitted work.
83    Worktree,
84}
85
86/// One landed off-thread refresh.
87#[derive(Debug)]
88pub struct Refreshed {
89    pub status: StatusModel,
90    pub model: DiffModel,
91    /// The [`ReviewSource::Against`] rev the caller asked about and its
92    /// recomputed diff. The rev rides along so a review swapped while the
93    /// worker ran can ignore an answer meant for the previous one.
94    pub against: Option<(String, Result<DiffModel, VcsError>)>,
95    /// A pinned commit, range, or PR source's freshly recomputed diff, when
96    /// the caller asked [`Review::compute_refresh`] for one (an algorithm
97    /// switch re-diffing whatever source is open).
98    pub pinned: Option<Result<DiffModel, VcsError>>,
99}
100
101/// The freshly fetched diff for a commit, range, or PR review source,
102/// straight from the backend: the one place each variant's vcs call is
103/// made, whether the caller reads it on the UI thread or off it.
104/// `pr_head` is the PR's own `(merge_base, head)`, resolved by the caller
105/// since a PR's range lives in app state; `None` rejects an unresolved PR,
106/// so it is never silently read as unchanged.
107/// `WorkingTree`, `Walkthrough`, and `Against` carry no pinned diff of their
108/// own and read back empty.
109pub fn pinned_diff(
110    vcs: &dyn Vcs,
111    source: &ReviewSource,
112    pr_head: Option<(&str, &str)>,
113) -> Result<DiffModel, VcsError> {
114    match source {
115        ReviewSource::Commit { oid } => vcs.commit_diff(oid),
116        ReviewSource::Range { oldest, newest } => vcs.range_diff(oldest, newest),
117        ReviewSource::Pr { number } => {
118            let (base, head) = pr_head
119                .ok_or_else(|| VcsError::Rejected(format!("PR #{number} is not resolved")))?;
120            vcs.tree_diff(base, head)
121        }
122        ReviewSource::WorkingTree
123        | ReviewSource::Walkthrough { .. }
124        | ReviewSource::Against { .. } => Ok(DiffModel::default()),
125    }
126}
127
128pub struct Review {
129    pub repo_root: PathBuf,
130    pub vcs: Box<dyn Vcs>,
131    pub status: StatusModel,
132    /// HEAD vs workdir+index including untracked: the review view. Computed
133    /// lazily on first [`Review::model`] access: the status screen is the
134    /// initial view and needs no working diff up front.
135    model: OnceCell<DiffModel>,
136    /// The working-tree review session, the default view.
137    pub session: Session,
138    /// Lazily-loaded sessions for non-working sources (commits, ranges), keyed
139    /// by [`ReviewSource::key`].
140    sources: HashMap<String, (ReviewSource, Session)>,
141    /// Returned by [`Review::session_for`] for a source that has no review yet.
142    empty: Session,
143}
144
145impl Review {
146    /// Open the git backend at [`DiffSettings::default`], load the persisted
147    /// session (if any), and compute the status sections. The working-tree
148    /// review diff is deferred until first [`Review::model`] access.
149    pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
150        Self::open_with_settings(repo_root, &DiffSettings::default())
151    }
152
153    /// Like [`Review::open`] with a custom context, line-diff algorithm and
154    /// indent heuristic (config keys `ui.context_lines`, `diff.algorithm`,
155    /// `diff.indent_heuristic`).
156    pub fn open_with_settings(
157        repo_root: &Path,
158        settings: &DiffSettings,
159    ) -> Result<Self, ReviewError> {
160        let vcs = repo::open_with_settings(repo_root, settings)?;
161        let status = vcs.status()?;
162        let session = store::load(repo_root)?;
163        Ok(Self {
164            repo_root: repo_root.to_path_buf(),
165            vcs,
166            status,
167            model: OnceCell::new(),
168            session,
169            sources: HashMap::new(),
170            empty: Session::default(),
171        })
172    }
173
174    /// Switch the session's line-diff algorithm live, so every diff this
175    /// review's own backend computes afterward uses it.
176    pub fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
177        self.vcs.set_diff_algorithm(algorithm, indent_heuristic);
178    }
179
180    /// The working-tree review diff, computed and cached on first access. A
181    /// backend error yields an empty diff rather than panicking; the next
182    /// [`Review::refresh`] gets another chance to compute it.
183    pub fn model(&self) -> &DiffModel {
184        self.model
185            .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
186    }
187
188    /// Mutable view of the working-tree review diff, computing it first if
189    /// needed. The TUI uses this to enrich a file with intra-line emphasis
190    /// just before rendering it.
191    pub fn model_mut(&mut self) -> &mut DiffModel {
192        self.model();
193        #[allow(clippy::expect_used)]
194        self.model.get_mut().expect("model just initialized")
195    }
196
197    /// Recompute status + diff (the watcher calls this on changes) and drop
198    /// viewed marks for files that changed or left the diff.
199    pub fn refresh(&mut self) -> Result<(), ReviewError> {
200        self.status = self.vcs.status()?;
201        let model = self.vcs.working_tree_diff()?;
202        self.install_refresh(self.status.clone(), model);
203        Ok(())
204    }
205
206    /// Compute a refresh on a separate repo handle, so it can run off the UI
207    /// thread; the result is applied later with [`Review::install_refresh`].
208    /// `against` recomputes the open three-dot review in the same pass, since
209    /// it tracks edits and cannot be pinned like a commit's diff. `pinned`
210    /// additionally recomputes a commit, range, or PR source's diff on this
211    /// same backend (an algorithm switch re-diffing whatever source is open).
212    pub fn compute_refresh(
213        repo_root: &Path,
214        settings: &DiffSettings,
215        against: Option<&str>,
216        pinned: Option<(&ReviewSource, Option<(&str, &str)>)>,
217    ) -> Result<Refreshed, ReviewError> {
218        let vcs = repo::open_with_settings(repo_root, settings)?;
219        let status = vcs.status()?;
220        let model = vcs.working_tree_diff()?;
221        let against =
222            against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(vcs.as_ref(), rev)));
223        let pinned = pinned.map(|(source, pr_head)| pinned_diff(vcs.as_ref(), source, pr_head));
224        Ok(Refreshed {
225            status,
226            model,
227            against,
228            pinned,
229        })
230    }
231
232    /// What the repo's git attributes declare about each of `paths`, for the
233    /// kinds sidebar. One attribute lookup walks the directory chain and the
234    /// global attribute files, so this is real IO per path and belongs on a
235    /// worker; paths the repo says nothing about are left out.
236    pub fn compute_declared(
237        repo_root: &Path,
238        paths: &[String],
239    ) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
240        let vcs = repo::open(repo_root)?;
241        Ok(paths
242            .iter()
243            .filter_map(|path| {
244                let rel = Path::new(path);
245                let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
246                Some((path.clone(), kind))
247            })
248            .collect())
249    }
250
251    /// Every requested file's content, from the copy `read_first` names and
252    /// else the other: the walkthrough's own `rev`, or the live worktree (the
253    /// only copy when `rev` is `None`, a walkthrough saved before it was
254    /// tracked). Opens its own backend so it runs on a worker thread like
255    /// [`Review::compute_refresh`]; a path neither the revision nor the
256    /// worktree can produce is left out rather than failing the whole read.
257    /// `rev` itself can also stop resolving (a squash, a rebase, a gc): that
258    /// is distinct from a path merely absent from a revision that still
259    /// resolves, so it comes back as `pin_broken` rather than folding into
260    /// the same silent worktree fallback.
261    pub fn compute_walkthrough_files(
262        repo_root: &Path,
263        rev: Option<&str>,
264        read_first: ReadFirst,
265        files: &[String],
266    ) -> WalkthroughFiles {
267        let vcs = repo::open(repo_root).ok();
268        let pin_broken = match (rev, vcs.as_ref()) {
269            (Some(rev), Some(vcs)) => vcs.resolve(rev).is_err(),
270            (Some(_), None) => true,
271            (None, _) => false,
272        };
273        let rev = (!pin_broken).then_some(rev).flatten();
274        let contents = files
275            .iter()
276            .filter_map(|path| {
277                let pinned = || rev.and_then(|rev| vcs.as_ref()?.read_at(rev, path).ok().flatten());
278                let worktree = || std::fs::read_to_string(repo_root.join(path)).ok();
279                let content = match read_first {
280                    ReadFirst::Pin => pinned().or_else(worktree),
281                    ReadFirst::Worktree => worktree().or_else(pinned),
282                }?;
283                Some((path.clone(), content))
284            })
285            .collect();
286        WalkthroughFiles {
287            contents,
288            pin_broken,
289        }
290    }
291
292    /// Both sides of a binary file as raw bytes, for the image preview: each
293    /// side from its blob, the new side from the worktree when the store has
294    /// no blob for it (a working-tree diff). A side over [`MAX_PREVIEW_BYTES`] comes
295    /// back as its size alone, so a huge asset never loads into memory.
296    pub fn compute_binary_sides(
297        repo_root: &Path,
298        path: &str,
299        blobs: &crate::model::BlobIds,
300        deleted: bool,
301    ) -> BinarySides {
302        let vcs = repo::open(repo_root).ok();
303        let from_blob = |oid: &Option<String>| {
304            let bytes = vcs.as_ref()?.read_blob(oid.as_deref()?).ok()??;
305            Some(BinarySide::of(bytes))
306        };
307        let new = from_blob(&blobs.new).or_else(|| {
308            if deleted {
309                return None;
310            }
311            let full = repo_root.join(path);
312            let size = std::fs::metadata(&full).ok()?.len();
313            if size > MAX_PREVIEW_BYTES {
314                return Some(BinarySide::TooLarge(size));
315            }
316            std::fs::read(full).ok().map(BinarySide::of)
317        });
318        BinarySides {
319            old: from_blob(&blobs.old),
320            new,
321        }
322    }
323
324    /// One file's worktree text and blame, for the file view. Opens its own
325    /// backend so it runs on a worker thread like [`Review::compute_refresh`].
326    /// A file git cannot blame (untracked, or newly staged) still loads: it
327    /// comes back with text and no spans.
328    pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
329        let vcs = repo::open(repo_root)?;
330        let path = Path::new(rel);
331        let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
332        Ok(FileSnapshot {
333            path: rel.to_owned(),
334            blame: vcs.blame(path).unwrap_or_default(),
335            content,
336        })
337    }
338
339    /// Swap in freshly computed status + diff and reconcile viewed marks.
340    pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
341        self.status = status;
342        self.session.reconcile(&model);
343        self.model = OnceCell::from(model);
344    }
345
346    pub fn save(&self) -> Result<(), ReviewError> {
347        store::save(&self.repo_root, &self.session)?;
348        Ok(())
349    }
350
351    /// Load a non-working source's session into the cache if not already there.
352    /// Call before reading via [`Review::session_for`] for that source.
353    pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
354        if matches!(source, ReviewSource::WorkingTree) {
355            return Ok(());
356        }
357        let key = source.key();
358        if !self.sources.contains_key(&key) {
359            let session = store::load_source(&self.repo_root, source)?;
360            self.sources.insert(key, (source.clone(), session));
361        }
362        Ok(())
363    }
364
365    /// The session for a source. The working tree is always present; other
366    /// sources must be [`Review::ensure_source`]d first, else an empty session
367    /// is returned.
368    pub fn session_for(&self, source: &ReviewSource) -> &Session {
369        match source {
370            ReviewSource::WorkingTree => &self.session,
371            other => self
372                .sources
373                .get(&other.key())
374                .map_or(&self.empty, |(_, session)| session),
375        }
376    }
377
378    pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
379        match source {
380            ReviewSource::WorkingTree => &mut self.session,
381            other => {
382                &mut self
383                    .sources
384                    .entry(other.key())
385                    .or_insert_with(|| (other.clone(), Session::default()))
386                    .1
387            }
388        }
389    }
390
391    pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
392        store::save_source(&self.repo_root, source, self.session_for(source))?;
393        Ok(())
394    }
395
396    /// Forget a non-working source's cached session, after its file is
397    /// deleted from disk (e.g. a walkthrough removed for good), so a later
398    /// access reloads default state rather than serving stale memory.
399    pub fn forget_source(&mut self, source: &ReviewSource) {
400        self.sources.remove(&source.key());
401    }
402
403    /// Every review across all sources, in-memory state overriding disk, sorted
404    /// by source key. Powers the agent-facing aggregate feed. A review file
405    /// that fails to parse is skipped rather than failing the whole call; see
406    /// [`Review::all_reviews_and_corrupt`] for the list of what was skipped.
407    pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
408        Ok(self.all_reviews_and_corrupt()?.0)
409    }
410
411    /// [`Review::all_reviews`] plus the path of every review file that failed
412    /// to parse and was skipped, for a caller that wants to tell the reader.
413    pub fn all_reviews_and_corrupt(&self) -> Result<store::LoadedReviews, ReviewError> {
414        let (loaded, corrupt) = store::load_all(&self.repo_root)?;
415        let mut by_key: BTreeMap<String, (ReviewSource, Session)> = loaded
416            .into_iter()
417            .map(|(source, session)| (source.key(), (source, session)))
418            .collect();
419        by_key.insert(
420            ReviewSource::WorkingTree.key(),
421            (ReviewSource::WorkingTree, self.session.clone()),
422        );
423        for (key, (source, session)) in &self.sources {
424            by_key.insert(key.clone(), (source.clone(), session.clone()));
425        }
426        Ok((by_key.into_values().collect(), corrupt))
427    }
428
429    /// Swap a previously computed model back in. Used when a refresh proved
430    /// a no-op (same fingerprint): the old model carries render-time emphasis
431    /// the rebuilt one lacks.
432    pub fn restore_model(&mut self, model: DiffModel) {
433        self.model = OnceCell::from(model);
434    }
435
436    /// Whether the working-tree model has been computed yet.
437    #[cfg(test)]
438    fn model_is_cached(&self) -> bool {
439        self.model.get().is_some()
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use crate::repo;
446
447    use super::*;
448
449    #[allow(clippy::expect_used)]
450    fn write(root: &std::path::Path, rel: &str, content: &str) {
451        std::fs::write(root.join(rel), content).expect("write");
452    }
453
454    #[allow(clippy::expect_used)]
455    fn commit_all(root: &std::path::Path, message: &str) {
456        for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
457            let status = std::process::Command::new("git")
458                .arg("-C")
459                .arg(root)
460                .args(args)
461                .env("GIT_AUTHOR_NAME", "t")
462                .env("GIT_AUTHOR_EMAIL", "t@t")
463                .env("GIT_COMMITTER_NAME", "t")
464                .env("GIT_COMMITTER_EMAIL", "t@t")
465                .status()
466                .expect("git");
467            assert!(status.success(), "git {args:?}");
468        }
469    }
470
471    #[allow(clippy::expect_used)]
472    fn init_repo(root: &std::path::Path) {
473        let status = std::process::Command::new("git")
474            .arg("-C")
475            .arg(root)
476            .args(["init", "-q"])
477            .status()
478            .expect("git init");
479        assert!(status.success());
480    }
481
482    #[test]
483    fn open_defers_the_working_model_until_first_access() {
484        let dir = tempfile::tempdir().expect("tempdir");
485        let root = dir.path();
486        init_repo(root);
487        write(root, "a.py", "value = old\n");
488        commit_all(root, "base");
489        write(root, "a.py", "value = new\n");
490
491        let root = repo::discover(root).expect("discover");
492        let review = Review::open(&root).expect("open");
493        // the status sections are computed eagerly; the review model is not
494        assert!(
495            !review.model_is_cached(),
496            "open must not compute the working model"
497        );
498        assert_eq!(review.status.unstaged.files.len(), 1);
499
500        // first access computes it; it matches a fresh working_tree_diff
501        let lazy = review.model().clone();
502        assert!(review.model_is_cached(), "access caches the model");
503        let eager = review.vcs.working_tree_diff().expect("diff");
504        assert_eq!(lazy, eager, "lazy model equals the eager build");
505    }
506
507    #[allow(clippy::expect_used)]
508    fn git(root: &std::path::Path, args: &[&str]) {
509        let status = std::process::Command::new("git")
510            .arg("-C")
511            .arg(root)
512            .args(args)
513            .status()
514            .expect("git");
515        assert!(status.success(), "git {args:?}");
516    }
517
518    #[test]
519    fn against_a_base_branch_shows_committed_and_uncommitted_work() {
520        let dir = tempfile::tempdir().expect("tempdir");
521        let root = dir.path();
522        init_repo(root);
523        git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
524        write(root, "base.txt", "base\n");
525        commit_all(root, "base");
526        git(root, &["checkout", "-q", "-b", "feature"]);
527        write(root, "committed.txt", "landed\n");
528        commit_all(root, "feature work");
529        // main moves on after the fork: three-dot keeps it out of the diff
530        git(root, &["checkout", "-q", "main"]);
531        write(root, "elsewhere.txt", "not mine\n");
532        commit_all(root, "base moved on");
533        git(root, &["checkout", "-q", "feature"]);
534        write(root, "dirty.txt", "still editing\n");
535
536        let root = repo::discover(root).expect("discover");
537        let review = Review::open(&root).expect("open");
538        let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
539        let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
540        assert_eq!(paths, ["committed.txt", "dirty.txt"]);
541    }
542
543    #[test]
544    fn per_source_sessions_persist_independently_and_aggregate() {
545        let dir = tempfile::tempdir().expect("tempdir");
546        let root = dir.path();
547        init_repo(root);
548        write(root, "a.py", "value = old\n");
549        commit_all(root, "base");
550        write(root, "a.py", "value = new\n");
551
552        let root = repo::discover(root).expect("discover");
553        let mut review = Review::open(&root).expect("open");
554
555        let commit = crate::source::ReviewSource::commit("deadbeef");
556        review.ensure_source(&commit).expect("ensure");
557        review
558            .session_for_mut(&commit)
559            .mark_viewed("a.py", "hash-commit");
560        review.save_for(&commit).expect("save commit");
561        review.session.mark_viewed("a.py", "hash-working");
562        review.save().expect("save working");
563
564        // the same path means different things per source
565        assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
566        assert!(!review.session.is_viewed("a.py", "hash-commit"));
567
568        // a fresh open reloads each source from its own file
569        let mut reopened = Review::open(&root).expect("reopen");
570        reopened.ensure_source(&commit).expect("ensure");
571        assert!(
572            reopened
573                .session_for(&commit)
574                .is_viewed("a.py", "hash-commit")
575        );
576        assert!(reopened.session.is_viewed("a.py", "hash-working"));
577
578        let all = reopened.all_reviews().expect("all");
579        let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
580        assert_eq!(keys, ["commit-deadbeef", "working"]);
581    }
582
583    /// A walkthrough pinned to a revision reads a file as that revision had
584    /// it, ignoring a dirty worktree, and falls back to the worktree for a
585    /// path the revision never had.
586    #[test]
587    fn compute_walkthrough_files_reads_the_pinned_revision_and_falls_back_for_the_rest() {
588        let dir = tempfile::tempdir().expect("tempdir");
589        let root = dir.path();
590        init_repo(root);
591        write(root, "a.txt", "old\n");
592        commit_all(root, "base");
593        let root = repo::discover(root).expect("discover");
594        let pinned = Review::open(&root)
595            .expect("open")
596            .vcs
597            .resolve("HEAD")
598            .expect("resolve");
599        write(&root, "a.txt", "new\n");
600        write(&root, "b.txt", "worktree only\n");
601
602        let files = ["a.txt".to_owned(), "b.txt".to_owned()];
603        let read = Review::compute_walkthrough_files(&root, Some(&pinned), ReadFirst::Pin, &files);
604        assert_eq!(
605            read.contents.get("a.txt").map(String::as_str),
606            Some("old\n"),
607            "reads the pinned revision, not the dirty worktree"
608        );
609        assert_eq!(
610            read.contents.get("b.txt").map(String::as_str),
611            Some("worktree only\n"),
612            "a path the revision never had falls back to the worktree"
613        );
614        assert!(!read.pin_broken, "the pin itself still resolves");
615    }
616
617    /// No `rev` at all (a walkthrough saved before it was tracked) reads the
618    /// worktree directly.
619    #[test]
620    fn compute_walkthrough_files_with_no_revision_reads_the_worktree() {
621        let dir = tempfile::tempdir().expect("tempdir");
622        let root = dir.path();
623        init_repo(root);
624        write(root, "a.txt", "committed\n");
625        commit_all(root, "base");
626        write(root, "a.txt", "edited\n");
627        let root = repo::discover(root).expect("discover");
628
629        let files = ["a.txt".to_owned()];
630        let read = Review::compute_walkthrough_files(&root, None, ReadFirst::Pin, &files);
631        assert_eq!(
632            read.contents.get("a.txt").map(String::as_str),
633            Some("edited\n")
634        );
635        assert!(
636            !read.pin_broken,
637            "no revision was ever pinned, so nothing is broken"
638        );
639    }
640
641    /// A `rev` that no longer resolves (a squash, a rebase, a gc) is a
642    /// different fact than a path merely absent from a revision that does
643    /// resolve: every file still falls back to the worktree, but `pin_broken`
644    /// says so, so the reader is not shown live code believing it is pinned.
645    #[test]
646    fn compute_walkthrough_files_with_an_unresolvable_revision_reports_the_broken_pin() {
647        let dir = tempfile::tempdir().expect("tempdir");
648        let root = dir.path();
649        init_repo(root);
650        write(root, "a.txt", "edited\n");
651        commit_all(root, "base");
652        let root = repo::discover(root).expect("discover");
653
654        let files = ["a.txt".to_owned()];
655        let read = Review::compute_walkthrough_files(
656            &root,
657            Some("0000000000000000000000000000000000dead"),
658            ReadFirst::Pin,
659            &files,
660        );
661        assert_eq!(
662            read.contents.get("a.txt").map(String::as_str),
663            Some("edited\n"),
664            "still falls back to the worktree"
665        );
666        assert!(read.pin_broken, "the named revision does not resolve");
667    }
668}