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 jj: Result<Option<JjView>, VcsError>,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct CommitLimits {
94 pub recent: usize,
95 pub unpushed: usize,
96 pub log: usize,
98}
99
100pub 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 model: OnceCell<DiffModel>,
129 pub session: Session,
131 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 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 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 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 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 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 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 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 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 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 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 pub fn forget_source(&mut self, source: &ReviewSource) {
366 self.sources.remove(&source.key());
367 }
368
369 pub fn all_reviews(&self) -> Result<Vec<(ReviewSource, Session)>, ReviewError> {
372 Ok(self.all_reviews_and_corrupt()?.0)
373 }
374
375 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 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}