Skip to main content

diffler_core/
review.rs

1//! Facade over the VCS backend, session and store, used by the TUI and MCP
2//! layers.
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::{CommitLists, HeadInfo, JjView, StatusModel, Vcs, VcsError, commit_lists};
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#[derive(Debug, Default)]
36pub struct WalkthroughFiles {
37    pub contents: HashMap<String, String>,
38    /// A `rev` was named but no longer resolves (a squash, a rebase, a gc), so
39    /// every file fell back to the worktree. `false` when nothing was pinned.
40    pub pin_broken: bool,
41}
42
43pub const MAX_PREVIEW_BYTES: u64 = 32 * 1024 * 1024;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum BinarySide {
47    Bytes(Vec<u8>),
48    /// The side exists but is over [`MAX_PREVIEW_BYTES`]; its size in bytes.
49    TooLarge(u64),
50}
51
52impl BinarySide {
53    fn of(bytes: Vec<u8>) -> Self {
54        let size = bytes.len() as u64;
55        if size > MAX_PREVIEW_BYTES {
56            Self::TooLarge(size)
57        } else {
58            Self::Bytes(bytes)
59        }
60    }
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct BinarySides {
65    pub old: Option<BinarySide>,
66    pub new: Option<BinarySide>,
67}
68
69/// Which copy of a walkthrough's file [`Review::compute_walkthrough_files`]
70/// reads first, falling back to the other.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum ReadFirst {
73    Pin,
74    Worktree,
75}
76
77#[derive(Debug)]
78pub struct Refreshed {
79    pub status: StatusModel,
80    pub model: DiffModel,
81    /// The [`ReviewSource::Against`] rev asked about and its diff. We carry
82    /// the rev so a review swapped while the worker ran drops the answer.
83    pub against: Option<(String, Result<DiffModel, VcsError>)>,
84    pub pinned: Option<Result<DiffModel, VcsError>>,
85    pub head: Result<HeadInfo, VcsError>,
86    pub commits: Result<CommitLists, VcsError>,
87    /// Read here since a jj repo answers it with a `jj` run.
88    pub worktrees: Result<Vec<crate::vcs::Worktree>, VcsError>,
89    /// Read here since it costs `jj` runs; the UI's own handle adopts it.
90    pub jj: Result<Option<JjView>, VcsError>,
91}
92
93/// How much history a refresh reads.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct CommitLimits {
96    pub recent: usize,
97    pub unpushed: usize,
98    /// What the log screen shows, which jj's change ids cover.
99    pub log: usize,
100}
101
102/// The diff for a commit, range or PR source. `pr_head` is the PR's
103/// `(merge_base, head)`; `None` rejects the PR so it never reads as
104/// unchanged. Other sources read back empty.
105pub fn pinned_diff(
106    vcs: &dyn Vcs,
107    source: &ReviewSource,
108    pr_head: Option<(&str, &str)>,
109) -> Result<DiffModel, VcsError> {
110    match source {
111        ReviewSource::Commit { oid } => vcs.commit_diff(oid),
112        ReviewSource::Range { oldest, newest } => vcs.range_diff(oldest, newest),
113        ReviewSource::Pr { number } => {
114            let (base, head) = pr_head
115                .ok_or_else(|| VcsError::Rejected(format!("PR #{number} is not resolved")))?;
116            vcs.tree_diff(base, head)
117        }
118        ReviewSource::WorkingTree
119        | ReviewSource::Walkthrough { .. }
120        | ReviewSource::Against { .. } => Ok(DiffModel::default()),
121    }
122}
123
124pub struct Review {
125    pub repo_root: PathBuf,
126    pub vcs: Box<dyn Vcs>,
127    pub status: StatusModel,
128    /// Computed on first [`Review::model`] access, since the status screen
129    /// opens first and needs no working diff.
130    model: OnceCell<DiffModel>,
131    /// The working-tree review session.
132    pub session: Session,
133    /// Sessions of every other source, keyed by [`ReviewSource::key`].
134    sources: HashMap<String, (ReviewSource, Session)>,
135    empty: Session,
136}
137
138impl Review {
139    pub fn open(repo_root: &Path) -> Result<Self, ReviewError> {
140        Self::open_with_settings(repo_root, &DiffSettings::default())
141    }
142
143    pub fn open_with_settings(
144        repo_root: &Path,
145        settings: &DiffSettings,
146    ) -> Result<Self, ReviewError> {
147        let vcs = repo::open_with_settings(repo_root, settings)?;
148        let status = vcs.status()?;
149        let session = store::load(repo_root)?;
150        Ok(Self {
151            repo_root: repo_root.to_path_buf(),
152            vcs,
153            status,
154            model: OnceCell::new(),
155            session,
156            sources: HashMap::new(),
157            empty: Session::default(),
158        })
159    }
160
161    pub fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
162        self.vcs.set_diff_algorithm(algorithm, indent_heuristic);
163    }
164
165    /// The working-tree diff, cached on first access. A backend error caches
166    /// an empty diff until the next [`Review::refresh`].
167    pub fn model(&self) -> &DiffModel {
168        self.model
169            .get_or_init(|| self.vcs.working_tree_diff().unwrap_or_default())
170    }
171
172    pub fn model_mut(&mut self) -> &mut DiffModel {
173        self.model();
174        #[allow(clippy::expect_used)]
175        self.model.get_mut().expect("model just initialized")
176    }
177
178    /// Recompute status and diff, and drop viewed marks for files that changed
179    /// or left the diff.
180    pub fn refresh(&mut self) -> Result<(), ReviewError> {
181        self.status = self.vcs.status()?;
182        let model = self.vcs.working_tree_diff()?;
183        self.install_refresh(self.status.clone(), model);
184        Ok(())
185    }
186
187    /// A refresh on its own repo handle, for a worker thread; apply it with
188    /// [`Review::install_refresh`]. `against` and `pinned` re-diff the open
189    /// three-dot or pinned source in the same pass.
190    pub fn compute_refresh(
191        repo_root: &Path,
192        settings: &DiffSettings,
193        against: Option<&str>,
194        pinned: Option<(&ReviewSource, Option<(&str, &str)>)>,
195        limits: CommitLimits,
196    ) -> Result<Refreshed, ReviewError> {
197        let vcs = repo::open_with_settings(repo_root, settings)?;
198        // first, so a repo with no `.git` reads every later HEAD as `@-`
199        let jj = vcs.read_jj_view(limits.log);
200        let status = vcs.status()?;
201        let model = vcs.working_tree_diff()?;
202        let against =
203            against.map(|rev| (rev.to_owned(), crate::vcs::against_diff(vcs.as_ref(), rev)));
204        let pinned = pinned.map(|(source, pr_head)| pinned_diff(vcs.as_ref(), source, pr_head));
205        Ok(Refreshed {
206            status,
207            model,
208            against,
209            pinned,
210            head: vcs.head(),
211            commits: commit_lists(vcs.as_ref(), limits.recent, limits.unpushed),
212            worktrees: vcs.worktrees(),
213            jj,
214        })
215    }
216
217    /// What the repo's git attributes declare about each of `paths`, leaving
218    /// out paths they say nothing about. Each lookup is real IO, so we run
219    /// this on a worker.
220    pub fn compute_declared(
221        repo_root: &Path,
222        paths: &[String],
223    ) -> Result<HashMap<String, crate::classify::Kind>, ReviewError> {
224        let vcs = repo::open(repo_root)?;
225        Ok(paths
226            .iter()
227            .filter_map(|path| {
228                let rel = Path::new(path);
229                let kind = crate::classify::declared(|name| vcs.attr(rel, name))?;
230                Some((path.clone(), kind))
231            })
232            .collect())
233    }
234
235    /// Every requested file's content from the copy `read_first` names, else
236    /// the other. A path neither copy has is left out. Runs on a worker.
237    pub fn compute_walkthrough_files(
238        repo_root: &Path,
239        rev: Option<&str>,
240        read_first: ReadFirst,
241        files: &[String],
242    ) -> WalkthroughFiles {
243        let vcs = repo::open(repo_root).ok();
244        let pin_broken = match (rev, vcs.as_ref()) {
245            (Some(rev), Some(vcs)) => vcs.resolve(rev).is_err(),
246            (Some(_), None) => true,
247            (None, _) => false,
248        };
249        let rev = (!pin_broken).then_some(rev).flatten();
250        let contents = files
251            .iter()
252            .filter_map(|path| {
253                let pinned = || rev.and_then(|rev| vcs.as_ref()?.read_at(rev, path).ok().flatten());
254                let worktree = || std::fs::read_to_string(repo_root.join(path)).ok();
255                let content = match read_first {
256                    ReadFirst::Pin => pinned().or_else(worktree),
257                    ReadFirst::Worktree => worktree().or_else(pinned),
258                }?;
259                Some((path.clone(), content))
260            })
261            .collect();
262        WalkthroughFiles {
263            contents,
264            pin_broken,
265        }
266    }
267
268    /// Both sides of a binary file, each from its blob, the new side from the
269    /// worktree when the store has no blob for it. We check the size first so
270    /// a side over [`MAX_PREVIEW_BYTES`] never loads into memory.
271    pub fn compute_binary_sides(
272        repo_root: &Path,
273        path: &str,
274        blobs: &crate::model::BlobIds,
275        deleted: bool,
276    ) -> BinarySides {
277        let vcs = repo::open(repo_root).ok();
278        let from_blob = |oid: &Option<String>| {
279            let bytes = vcs.as_ref()?.read_blob(oid.as_deref()?).ok()??;
280            Some(BinarySide::of(bytes))
281        };
282        let new = from_blob(&blobs.new).or_else(|| {
283            if deleted {
284                return None;
285            }
286            let full = repo_root.join(path);
287            let size = std::fs::metadata(&full).ok()?.len();
288            if size > MAX_PREVIEW_BYTES {
289                return Some(BinarySide::TooLarge(size));
290            }
291            std::fs::read(full).ok().map(BinarySide::of)
292        });
293        BinarySides {
294            old: from_blob(&blobs.old),
295            new,
296        }
297    }
298
299    /// One file's worktree text and blame, for a worker. A file git cannot
300    /// blame comes back with no spans.
301    pub fn compute_file(repo_root: &Path, rel: &str) -> Result<FileSnapshot, ReviewError> {
302        let vcs = repo::open(repo_root)?;
303        let path = Path::new(rel);
304        let content = std::fs::read_to_string(repo_root.join(path)).map_err(VcsError::from)?;
305        Ok(FileSnapshot {
306            path: rel.to_owned(),
307            blame: vcs.blame(path).unwrap_or_default(),
308            content,
309        })
310    }
311
312    /// [`Review::compute_file`] as `rev`'s tree has `rel`.
313    pub fn compute_file_at(
314        repo_root: &Path,
315        rev: &str,
316        rel: &str,
317    ) -> Result<FileSnapshot, ReviewError> {
318        let vcs = repo::open(repo_root)?;
319        let content = vcs.read_at(rev, rel)?.ok_or_else(|| {
320            VcsError::Rejected(format!("{rel} has no text at {}", crate::git::short7(rev)))
321        })?;
322        Ok(FileSnapshot {
323            path: rel.to_owned(),
324            blame: vcs.blame_at(rev, Path::new(rel)).unwrap_or_default(),
325            content,
326        })
327    }
328
329    /// [`Vcs::file_history`] on a backend of its own, for a worker.
330    pub fn compute_file_history(
331        repo_root: &Path,
332        rel: &str,
333        limit: usize,
334    ) -> Result<Vec<crate::vcs::FileVersion>, ReviewError> {
335        Ok(repo::open(repo_root)?.file_history(rel, limit)?)
336    }
337
338    pub fn install_refresh(&mut self, status: StatusModel, model: DiffModel) {
339        self.status = status;
340        self.session.reconcile(&model);
341        self.model = OnceCell::from(model);
342    }
343
344    pub fn save(&self) -> Result<(), ReviewError> {
345        store::save(&self.repo_root, &self.session)?;
346        Ok(())
347    }
348
349    /// Load a source's session into the cache. Call before
350    /// [`Review::session_for`].
351    pub fn ensure_source(&mut self, source: &ReviewSource) -> Result<(), ReviewError> {
352        if matches!(source, ReviewSource::WorkingTree) {
353            return Ok(());
354        }
355        let key = source.key();
356        if !self.sources.contains_key(&key) {
357            let session = store::load_source(&self.repo_root, source)?;
358            self.sources.insert(key, (source.clone(), session));
359        }
360        Ok(())
361    }
362
363    /// Empty for a source not yet [`Review::ensure_source`]d.
364    pub fn session_for(&self, source: &ReviewSource) -> &Session {
365        match source {
366            ReviewSource::WorkingTree => &self.session,
367            other => self
368                .sources
369                .get(&other.key())
370                .map_or(&self.empty, |(_, session)| session),
371        }
372    }
373
374    pub fn session_for_mut(&mut self, source: &ReviewSource) -> &mut Session {
375        match source {
376            ReviewSource::WorkingTree => &mut self.session,
377            other => {
378                &mut self
379                    .sources
380                    .entry(other.key())
381                    .or_insert_with(|| (other.clone(), Session::default()))
382                    .1
383            }
384        }
385    }
386
387    pub fn save_for(&self, source: &ReviewSource) -> Result<(), ReviewError> {
388        store::save_source(&self.repo_root, source, self.session_for(source))?;
389        Ok(())
390    }
391
392    /// Drop a source's cached session once its file is deleted, so a later
393    /// access reloads it.
394    pub fn forget_source(&mut self, source: &ReviewSource) {
395        self.sources.remove(&source.key());
396    }
397
398    /// Every review across all sources, in-memory state overriding disk,
399    /// sorted by source key. A review file that fails to parse is skipped.
400    pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
401        Ok(self.all_reviews_and_corrupt()?.0)
402    }
403
404    /// [`Review::all_reviews`] plus the path of every review file skipped.
405    pub fn all_reviews_and_corrupt(&self) -> Result<store::LoadedReviews, ReviewError> {
406        let (loaded, corrupt) = store::load_all(&self.repo_root)?;
407        let mut by_key: BTreeMap<String, (ReviewSource, Session)> = loaded
408            .into_iter()
409            .map(|(source, session)| (source.key(), (source, session)))
410            .collect();
411        by_key.insert(
412            ReviewSource::WorkingTree.key(),
413            (ReviewSource::WorkingTree, self.session.clone()),
414        );
415        for (key, (source, session)) in &self.sources {
416            by_key.insert(key.clone(), (source.clone(), session.clone()));
417        }
418        Ok((by_key.into_values().collect(), corrupt))
419    }
420
421    /// Swap a previous model back in after a no-op refresh, since it carries
422    /// render-time emphasis the rebuilt one lacks.
423    pub fn restore_model(&mut self, model: DiffModel) {
424        self.model = OnceCell::from(model);
425    }
426
427    #[cfg(test)]
428    fn model_is_cached(&self) -> bool {
429        self.model.get().is_some()
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use crate::repo;
436
437    use super::*;
438
439    #[allow(clippy::expect_used)]
440    fn write(root: &std::path::Path, rel: &str, content: &str) {
441        std::fs::write(root.join(rel), content).expect("write");
442    }
443
444    #[allow(clippy::expect_used)]
445    fn commit_all(root: &std::path::Path, message: &str) {
446        for args in [&["add", "-A"][..], &["commit", "-q", "-m", message][..]] {
447            let status = std::process::Command::new("git")
448                .arg("-C")
449                .arg(root)
450                .args(args)
451                .env("GIT_AUTHOR_NAME", "t")
452                .env("GIT_AUTHOR_EMAIL", "t@t")
453                .env("GIT_COMMITTER_NAME", "t")
454                .env("GIT_COMMITTER_EMAIL", "t@t")
455                .status()
456                .expect("git");
457            assert!(status.success(), "git {args:?}");
458        }
459    }
460
461    #[allow(clippy::expect_used)]
462    fn init_repo(root: &std::path::Path) {
463        let status = std::process::Command::new("git")
464            .arg("-C")
465            .arg(root)
466            .args(["init", "-q"])
467            .status()
468            .expect("git init");
469        assert!(status.success());
470    }
471
472    #[test]
473    fn open_defers_the_working_model_until_first_access() {
474        let dir = tempfile::tempdir().expect("tempdir");
475        let root = dir.path();
476        init_repo(root);
477        write(root, "a.py", "value = old\n");
478        commit_all(root, "base");
479        write(root, "a.py", "value = new\n");
480
481        let root = repo::discover(root).expect("discover");
482        let review = Review::open(&root).expect("open");
483        assert!(
484            !review.model_is_cached(),
485            "open must not compute the working model"
486        );
487        assert_eq!(review.status.unstaged.files.len(), 1);
488
489        let lazy = review.model().clone();
490        assert!(review.model_is_cached(), "access caches the model");
491        let eager = review.vcs.working_tree_diff().expect("diff");
492        assert_eq!(lazy, eager, "lazy model equals the eager build");
493    }
494
495    #[allow(clippy::expect_used)]
496    fn git(root: &std::path::Path, args: &[&str]) {
497        let status = std::process::Command::new("git")
498            .arg("-C")
499            .arg(root)
500            .args(args)
501            .status()
502            .expect("git");
503        assert!(status.success(), "git {args:?}");
504    }
505
506    #[test]
507    fn against_a_base_branch_shows_committed_and_uncommitted_work() {
508        let dir = tempfile::tempdir().expect("tempdir");
509        let root = dir.path();
510        init_repo(root);
511        git(root, &["symbolic-ref", "HEAD", "refs/heads/main"]);
512        write(root, "base.txt", "base\n");
513        commit_all(root, "base");
514        git(root, &["checkout", "-q", "-b", "feature"]);
515        write(root, "committed.txt", "landed\n");
516        commit_all(root, "feature work");
517        git(root, &["checkout", "-q", "main"]);
518        write(root, "elsewhere.txt", "not mine\n");
519        commit_all(root, "base moved on");
520        git(root, &["checkout", "-q", "feature"]);
521        write(root, "dirty.txt", "still editing\n");
522
523        let root = repo::discover(root).expect("discover");
524        let review = Review::open(&root).expect("open");
525        let model = crate::vcs::against_diff(review.vcs.as_ref(), "main").expect("against");
526        let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
527        assert_eq!(paths, ["committed.txt", "dirty.txt"]);
528    }
529
530    #[test]
531    fn per_source_sessions_persist_independently_and_aggregate() {
532        let dir = tempfile::tempdir().expect("tempdir");
533        let root = dir.path();
534        init_repo(root);
535        write(root, "a.py", "value = old\n");
536        commit_all(root, "base");
537        write(root, "a.py", "value = new\n");
538
539        let root = repo::discover(root).expect("discover");
540        let mut review = Review::open(&root).expect("open");
541
542        let commit = crate::source::ReviewSource::commit("deadbeef");
543        review.ensure_source(&commit).expect("ensure");
544        review
545            .session_for_mut(&commit)
546            .mark_viewed("a.py", "hash-commit");
547        review.save_for(&commit).expect("save commit");
548        review.session.mark_viewed("a.py", "hash-working");
549        review.save().expect("save working");
550
551        assert!(review.session_for(&commit).is_viewed("a.py", "hash-commit"));
552        assert!(!review.session.is_viewed("a.py", "hash-commit"));
553
554        let mut reopened = Review::open(&root).expect("reopen");
555        reopened.ensure_source(&commit).expect("ensure");
556        assert!(
557            reopened
558                .session_for(&commit)
559                .is_viewed("a.py", "hash-commit")
560        );
561        assert!(reopened.session.is_viewed("a.py", "hash-working"));
562
563        let all = reopened.all_reviews().expect("all");
564        let keys: Vec<String> = all.iter().map(|(s, _)| s.key()).collect();
565        assert_eq!(keys, ["commit-deadbeef", "working"]);
566    }
567
568    #[test]
569    fn compute_walkthrough_files_reads_the_pinned_revision_and_falls_back_for_the_rest() {
570        let dir = tempfile::tempdir().expect("tempdir");
571        let root = dir.path();
572        init_repo(root);
573        write(root, "a.txt", "old\n");
574        commit_all(root, "base");
575        let root = repo::discover(root).expect("discover");
576        let pinned = Review::open(&root)
577            .expect("open")
578            .vcs
579            .resolve("HEAD")
580            .expect("resolve");
581        write(&root, "a.txt", "new\n");
582        write(&root, "b.txt", "worktree only\n");
583
584        let files = ["a.txt".to_owned(), "b.txt".to_owned()];
585        let read = Review::compute_walkthrough_files(&root, Some(&pinned), ReadFirst::Pin, &files);
586        assert_eq!(
587            read.contents.get("a.txt").map(String::as_str),
588            Some("old\n"),
589            "reads the pinned revision, not the dirty worktree"
590        );
591        assert_eq!(
592            read.contents.get("b.txt").map(String::as_str),
593            Some("worktree only\n"),
594            "a path the revision never had falls back to the worktree"
595        );
596        assert!(!read.pin_broken, "the pin itself still resolves");
597    }
598
599    #[test]
600    fn compute_walkthrough_files_with_no_revision_reads_the_worktree() {
601        let dir = tempfile::tempdir().expect("tempdir");
602        let root = dir.path();
603        init_repo(root);
604        write(root, "a.txt", "committed\n");
605        commit_all(root, "base");
606        write(root, "a.txt", "edited\n");
607        let root = repo::discover(root).expect("discover");
608
609        let files = ["a.txt".to_owned()];
610        let read = Review::compute_walkthrough_files(&root, None, ReadFirst::Pin, &files);
611        assert_eq!(
612            read.contents.get("a.txt").map(String::as_str),
613            Some("edited\n")
614        );
615        assert!(
616            !read.pin_broken,
617            "no revision was ever pinned, so nothing is broken"
618        );
619    }
620
621    #[test]
622    fn compute_walkthrough_files_with_an_unresolvable_revision_reports_the_broken_pin() {
623        let dir = tempfile::tempdir().expect("tempdir");
624        let root = dir.path();
625        init_repo(root);
626        write(root, "a.txt", "edited\n");
627        commit_all(root, "base");
628        let root = repo::discover(root).expect("discover");
629
630        let files = ["a.txt".to_owned()];
631        let read = Review::compute_walkthrough_files(
632            &root,
633            Some("0000000000000000000000000000000000dead"),
634            ReadFirst::Pin,
635            &files,
636        );
637        assert_eq!(
638            read.contents.get("a.txt").map(String::as_str),
639            Some("edited\n"),
640            "still falls back to the worktree"
641        );
642        assert!(read.pin_broken, "the named revision does not resolve");
643    }
644}