1use 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#[derive(Debug)]
28pub struct FileSnapshot {
29 pub path: String,
30 pub content: String,
31 pub blame: Vec<crate::vcs::BlameSpan>,
33}
34
35#[derive(Debug, Default)]
36pub struct WalkthroughFiles {
37 pub contents: HashMap<String, String>,
38 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 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#[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 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 pub worktrees: Result<Vec<crate::vcs::Worktree>, VcsError>,
89 pub jj: Result<Option<JjView>, VcsError>,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct CommitLimits {
96 pub recent: usize,
97 pub unpushed: usize,
98 pub log: usize,
100}
101
102pub 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 model: OnceCell<DiffModel>,
131 pub session: Session,
133 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn forget_source(&mut self, source: &ReviewSource) {
395 self.sources.remove(&source.key());
396 }
397
398 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
401 Ok(self.all_reviews_and_corrupt()?.0)
402 }
403
404 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 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}