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