use super::*;
use std::{fs, process::Command};
struct Repository {
_temp: tempfile::TempDir,
root: PathBuf,
state: PathBuf,
}
impl Repository {
fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let base = fs::canonicalize(temp.path()).unwrap();
let root = base.join("repo");
fs::create_dir(&root).unwrap();
let repo = Self {
_temp: temp,
root,
state: base.join("state"),
};
repo.git(&["init", "-q"]);
repo.git(&["config", "user.email", "review@example.invalid"]);
repo.git(&["config", "user.name", "Review Test"]);
repo
}
fn git(&self, args: &[&str]) {
let result = Command::new("git")
.current_dir(&self.root)
.args(args)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
fn put(&self, path: &str, text: &str) {
fs::write(self.root.join(path), text).unwrap();
}
fn commit(&self) {
self.git(&["add", "."]);
self.git(&["commit", "-qm", "baseline"]);
}
fn snapshot(&self) -> ReviewSnapshot {
snapshot(
&self.root,
&self.state,
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.unwrap()
}
fn comment(&self, path: &str, side: ReviewSide, line: usize) {
store::save(
&self.state,
&self.root,
CommentChange::Save {
id: None,
path: path.into(),
side,
line,
anchor: ReviewAnchor::capture(
&git::read_file(
&self.root,
path,
true,
MAX_SOURCE,
&ReviewReadOptions::new(&AgentCancellation::default()),
),
side,
line,
),
text: "Check this line".into(),
},
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.unwrap();
}
}
#[test]
fn compares_head_with_actual_worktree_including_staged_untracked_and_deleted() {
let repo = Repository::new();
repo.put("edit.txt", "one\ntwo\nthree\n");
repo.put("delete.txt", "deleted\n");
repo.commit();
repo.put("edit.txt", "one\nstaged\nthree\n");
repo.git(&["add", "edit.txt"]);
repo.put("edit.txt", "one\nactual\nthree\n");
repo.put("new.txt", "untracked\n");
fs::remove_file(repo.root.join("delete.txt")).unwrap();
let snapshot = repo.snapshot();
assert_eq!(snapshot.root, repo.root);
assert_eq!(snapshot.files.len(), 3);
let edit = snapshot
.files
.iter()
.find(|f| f.path == "edit.txt")
.unwrap();
assert_eq!(edit.original, "one\ntwo\nthree\n");
assert_eq!(edit.current, "one\nactual\nthree\n");
assert!(edit.rows.iter().any(|r| r.kind == ReviewLineKind::Removed
&& r.old_line == Some(2)
&& r.new_line.is_none()
&& r.text == "two"));
assert!(
edit.rows.iter().any(|r| r.kind == ReviewLineKind::Added
&& r.new_line == Some(2)
&& r.text == "actual")
);
let deleted = snapshot
.files
.iter()
.find(|f| f.path == "delete.txt")
.unwrap();
assert_eq!(deleted.original, "deleted\n");
assert!(deleted.current.is_empty());
let new = snapshot.files.iter().find(|f| f.path == "new.txt").unwrap();
assert!(new.original.is_empty());
assert_eq!(new.current, "untracked\n");
}
#[test]
fn unborn_repo_includes_staged_and_untracked_files() {
let repo = Repository::new();
repo.put("staged.txt", "staged\n");
repo.git(&["add", "staged.txt"]);
repo.put("untracked.txt", "new\n");
let snapshot = repo.snapshot();
assert_eq!(snapshot.files.len(), 2);
assert!(
snapshot.files.iter().all(
|f| f.original.is_empty() && f.rows.iter().all(|r| r.kind == ReviewLineKind::Added)
)
);
}
#[test]
fn comment_reanchors_preserves_anchor_and_stale_is_durable() {
let repo = Repository::new();
repo.put("file.txt", "before\nanchor\nafter\n");
repo.comment("file.txt", ReviewSide::Changed, 2);
repo.put("file.txt", "inserted\nbefore\nanchor\nafter\n");
let moved = repo.snapshot().comments.remove(0);
assert_eq!(moved.line, 3);
assert!(!moved.stale);
repo.put("file.txt", "short\n");
let stale = repo.snapshot().comments.remove(0);
assert_eq!(stale.line, 1);
assert!(stale.stale);
assert_eq!(stale.anchor, "anchor");
repo.put("file.txt", "before\nanchor\nafter\n");
assert!(repo.snapshot().comments[0].stale);
store::save(
&repo.state,
&repo.root,
CommentChange::Save {
id: Some(moved.id.clone()),
path: "file.txt".into(),
side: ReviewSide::Changed,
line: 1,
anchor: None,
text: "Edited".into(),
},
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.unwrap();
let edited = repo.snapshot().comments.remove(0);
assert_eq!(edited.anchor, "anchor");
assert_eq!(edited.text, "Edited");
store::save(
&repo.state,
&repo.root,
CommentChange::Resolve {
id: moved.id.clone(),
resolved: true,
},
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.unwrap();
assert_eq!(
render_comments_context(
&repo.root,
&repo.state,
&ReviewReadOptions::new(&AgentCancellation::default())
)
.unwrap(),
"No unresolved Diff comments."
);
store::save(
&repo.state,
&repo.root,
CommentChange::Resolve {
id: moved.id.clone(),
resolved: false,
},
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.unwrap();
assert!(render_comments(&repo.snapshot().comments).contains("Edited"));
store::save(
&repo.state,
&repo.root,
CommentChange::Delete(moved.id),
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.unwrap();
assert!(repo.snapshot().comments.is_empty());
}
#[test]
fn original_side_reanchors_after_head_changes_and_vanished_files_stay_visible() {
let repo = Repository::new();
repo.put("file.txt", "before\nanchor\nafter\n");
repo.commit();
repo.put("file.txt", "before\nchanged\nafter\n");
repo.comment("file.txt", ReviewSide::Original, 2);
repo.put("file.txt", "prefix\nbefore\nanchor\nafter\n");
repo.commit();
let moved = repo.snapshot();
assert_eq!(moved.comments[0].line, 3);
assert!(!moved.comments[0].stale);
repo.git(&["rm", "file.txt"]);
repo.git(&["commit", "-qm", "remove"]);
let vanished = repo.snapshot();
assert!(vanished.comments[0].stale);
assert_eq!(vanished.comments[0].anchor, "anchor");
assert!(
vanished
.files
.iter()
.any(|f| f.path == "file.txt" && f.notice.is_some())
);
}
#[test]
fn worktrees_have_separate_comment_stores() {
let repo = Repository::new();
repo.put("file.txt", "original\n");
repo.commit();
repo.put("file.txt", "change\n");
repo.comment("file.txt", ReviewSide::Changed, 1);
let other = repo.root.parent().unwrap().join("other");
repo.git(&[
"worktree",
"add",
"-q",
"-b",
"other",
other.to_str().unwrap(),
]);
fs::write(other.join("file.txt"), "other change\n").unwrap();
assert!(
snapshot(
&other,
&repo.state,
&ReviewReadOptions::new(&AgentCancellation::default())
)
.unwrap()
.comments
.is_empty()
);
assert_eq!(repo.snapshot().comments.len(), 1);
}
#[test]
fn oversized_binary_and_symlink_sources_have_explicit_notices() {
let repo = Repository::new();
repo.put("large.txt", &"x".repeat(MAX_SOURCE + 1));
repo.put("binary", "a\0b");
#[cfg(unix)]
std::os::unix::fs::symlink("/etc/passwd", repo.root.join("link")).unwrap();
let snapshot = repo.snapshot();
assert!(
snapshot
.files
.iter()
.all(|f| f.notice.is_some() && f.rows.is_empty())
);
assert!(
store::save(
&repo.state,
&repo.root,
CommentChange::Save {
id: None,
path: "large.txt".into(),
side: ReviewSide::Changed,
line: 1,
anchor: None,
text: "comment".into()
},
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.is_err()
);
}
#[test]
fn comments_context_reports_non_repository() {
let temp = tempfile::tempdir().unwrap();
assert!(
comments_context(temp.path(), &AgentCancellation::default())
.unwrap_err()
.contains("Not a Git worktree")
);
}
#[test]
fn duplicate_anchors_are_marked_stale_and_choose_nearest_line() {
let repo = Repository::new();
repo.put("file.txt", "a\nanchor\nb\n");
repo.comment("file.txt", ReviewSide::Changed, 2);
repo.put("file.txt", "x\nanchor\ny\nanchor\nz\n");
let comment = repo.snapshot().comments.remove(0);
assert_eq!(comment.line, 2);
assert!(comment.stale);
}
#[test]
fn concurrent_comment_writes_do_not_lose_comments() {
let repo = Repository::new();
repo.put("file.txt", "line\n");
std::thread::scope(|scope| {
for _ in 0..4 {
scope.spawn(|| repo.comment("file.txt", ReviewSide::Changed, 1));
}
});
assert_eq!(repo.snapshot().comments.len(), 4);
}
#[test]
fn malformed_store_is_not_overwritten() {
let repo = Repository::new();
repo.put("file.txt", "line\n");
repo.comment("file.txt", ReviewSide::Changed, 1);
let path = fs::read_dir(repo.state.join("diff-review"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
fs::write(&path, b"invalid json").unwrap();
assert!(
store::save(
&repo.state,
&repo.root,
CommentChange::Delete("unknown".into()),
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.is_err()
);
assert_eq!(fs::read(&path).unwrap(), b"invalid json");
}
#[cfg(unix)]
#[test]
fn comment_store_is_private_and_rejects_symlink_replacement() {
use std::os::unix::fs::{PermissionsExt, symlink};
let repo = Repository::new();
repo.put("file.txt", "line\n");
repo.comment("file.txt", ReviewSide::Changed, 1);
let directory = repo.state.join("diff-review");
let path = fs::read_dir(&directory)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
assert_eq!(
fs::metadata(&directory).unwrap().permissions().mode() & 0o777,
0o700
);
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
let outside = repo.state.join("outside");
fs::rename(&path, &outside).unwrap();
let original = fs::read(&outside).unwrap();
symlink(&outside, &path).unwrap();
assert!(
store::save(
&repo.state,
&repo.root,
CommentChange::Delete("unknown".into()),
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.is_err()
);
assert!(
store::load_reanchored(
&repo.state,
&repo.root,
&mut Vec::new(),
&ReviewReadOptions::new(&AgentCancellation::default())
)
.is_err()
);
assert_eq!(fs::read(&outside).unwrap(), original);
}
#[test]
fn file_limit_reports_omissions_without_losing_comment_only_files() {
let repo = Repository::new();
repo.put("z-comment.txt", "line\n");
repo.comment("z-comment.txt", ReviewSide::Changed, 1);
for index in 0..MAX_FILES {
repo.put(&format!("a-{index:03}"), "line\n");
}
let snapshot = repo.snapshot();
assert!(snapshot.files.iter().any(|f| {
f.notice
.as_deref()
.is_some_and(|n| n.contains("files omitted"))
}));
assert!(
snapshot
.files
.iter()
.any(|f| f.path == "z-comment.txt" && f.current == "line\n")
);
assert_eq!(snapshot.comments.len(), 1);
}
#[test]
fn saving_a_draft_uses_the_opened_snapshot_not_the_current_line() {
for (current, expected_line, stale) in [
("inserted\nbefore\nselected\nafter\n", 3, false),
("before\nreplacement\nafter\n", 2, true),
] {
let repo = Repository::new();
repo.put("file.txt", "before\nselected\nafter\n");
let opened = repo.snapshot();
let anchor = ReviewAnchor::capture(&opened.files[0], ReviewSide::Changed, 2);
repo.put("file.txt", current);
store::save(
&repo.state,
&repo.root,
CommentChange::Save {
id: None,
path: "file.txt".into(),
side: ReviewSide::Changed,
line: 2,
anchor,
text: "Draft comment".into(),
},
&ReviewReadOptions::new(&AgentCancellation::default()),
)
.unwrap();
let saved = repo.snapshot().comments.remove(0);
assert_eq!(saved.anchor, "selected");
assert_eq!(saved.line, expected_line);
assert_eq!(saved.stale, stale);
}
}
#[test]
fn comments_context_contains_only_unresolved_notes_and_reanchors_without_path_listing() {
let repo = Repository::new();
repo.put("file.txt", "before\nselected\nafter\n");
repo.put("resolved.txt", "resolved anchor\n");
repo.commit();
repo.comment("file.txt", ReviewSide::Changed, 2);
repo.comment("resolved.txt", ReviewSide::Original, 1);
let comments = repo.snapshot().comments;
let resolved_id = comments
.iter()
.find(|c| c.path == "resolved.txt")
.unwrap()
.id
.clone();
let id = &comments.iter().find(|c| c.path == "file.txt").unwrap().id;
let cancellation = AgentCancellation::default();
store::save(
&repo.state,
&repo.root,
CommentChange::Resolve {
id: resolved_id,
resolved: true,
},
&ReviewReadOptions::new(&cancellation),
)
.unwrap();
repo.put("file.txt", "inserted\nbefore\nselected\nafter\n");
repo.put("unrelated.txt", "unrelated changed source\n");
fs::write(repo.root.join(".git/index"), b"invalid index").unwrap();
assert!(git::files(&repo.root, &ReviewReadOptions::new(&cancellation)).is_err());
let output = render_comments_context(
&repo.root,
&repo.state,
&ReviewReadOptions::new(&cancellation),
)
.unwrap();
assert!(
output.contains(&format!(
"Review comment {id} at file.txt Changed:3 (not stale)"
)),
"{output}"
);
assert!(output.contains("Original anchor: selected\nCheck this line"));
for excluded in [
"File:",
"@@",
"unrelated.txt",
"unrelated changed source",
"resolved.txt",
"inserted",
"before",
"after",
] {
assert!(!output.contains(excluded), "{output}");
}
repo.put("file.txt", "replacement\n");
let output = render_comments_context(
&repo.root,
&repo.state,
&ReviewReadOptions::new(&cancellation),
)
.unwrap();
assert!(output.contains("(stale; verify location)\nOriginal anchor: selected"));
}
#[test]
fn comments_context_explicitly_reports_no_unresolved_comments() {
let repo = Repository::new();
repo.put("unrelated.txt", "changed source\n");
fs::write(repo.root.join(".git/index"), b"invalid index").unwrap();
assert_eq!(
render_comments_context(
&repo.root,
&repo.state,
&ReviewReadOptions::new(&AgentCancellation::default())
)
.unwrap(),
"No unresolved Diff comments."
);
}
#[test]
fn stored_comments_load_without_the_unrelated_git_path_list() {
let repo = Repository::new();
repo.put("file.txt", "selected\n");
repo.comment("file.txt", ReviewSide::Changed, 1);
fs::write(repo.root.join(".git/index"), b"invalid index").unwrap();
let cancellation = AgentCancellation::default();
assert!(git::files(&repo.root, &ReviewReadOptions::new(&cancellation)).is_err());
let mut files = Vec::new();
let comments = store::load_reanchored(
&repo.state,
&repo.root,
&mut files,
&ReviewReadOptions::new(&cancellation),
)
.unwrap();
assert_eq!(comments[0].text, "Check this line");
assert_eq!(comments[0].anchor, "selected");
repo.put("file.txt", &"x".repeat(MAX_SOURCE + 1));
files.clear();
let comments = store::load_reanchored(
&repo.state,
&repo.root,
&mut files,
&ReviewReadOptions::new(&cancellation),
)
.unwrap();
assert_eq!(comments[0].text, "Check this line");
assert!(comments[0].stale);
assert!(files.iter().any(|file| {
file.notice
.as_deref()
.is_some_and(|notice| notice.contains("exceeds size limit"))
}));
}
#[test]
fn canceled_reviews_do_not_scan_or_mutate_comments() {
let repo = Repository::new();
repo.put("file.txt", "selected\n");
repo.comment("file.txt", ReviewSide::Changed, 1);
let id = repo.snapshot().comments[0].id.clone();
let (cancellation, handle) = AgentCancellation::default().child_token();
handle.cancel();
assert!(
load_snapshot(&repo.root, &cancellation, &mut SnapshotCache::default())
.unwrap_err()
.contains("canceled")
);
assert!(
comments_context(&repo.root, &cancellation)
.unwrap_err()
.contains("canceled")
);
assert!(
store::save(
&repo.state,
&repo.root,
CommentChange::Delete(id),
&ReviewReadOptions::new(&cancellation)
)
.unwrap_err()
.contains("canceled")
);
assert_eq!(repo.snapshot().comments.len(), 1);
}
#[test]
fn one_expired_deadline_stops_git_and_preserves_saved_comment_context() {
let repo = Repository::new();
repo.put("file.txt", "selected\n");
repo.comment("file.txt", ReviewSide::Changed, 1);
let cancellation = AgentCancellation::default();
let options = ReviewReadOptions {
cancellation: &cancellation,
deadline: Instant::now(),
};
assert!(
git::files(&repo.root, &options)
.unwrap_err()
.contains("deadline")
);
assert!(
git::read_file(&repo.root, "file.txt", true, MAX_SOURCE, &options)
.notice
.unwrap()
.contains("deadline")
);
let mut files = Vec::new();
let comments = store::load_reanchored(&repo.state, &repo.root, &mut files, &options).unwrap();
assert_eq!(comments[0].text, "Check this line");
assert!(comments[0].stale);
assert!(
files.iter().any(|file| file.notice.as_deref().is_some_and(
|notice| notice.contains("Using saved anchors: Review scan deadline exceeded")
))
);
}
#[test]
fn twenty_changed_files_snapshot_measurement() {
let repo = Repository::new();
for index in 0..20 {
repo.put(
&format!("file{index}.rs"),
&"fn original() {}\n".repeat(500),
);
}
repo.commit();
for index in 0..20 {
repo.put(&format!("file{index}.rs"), &"fn changed() {}\n".repeat(500));
}
let started = Instant::now();
let cancellation = AgentCancellation::default();
let mut cache = SnapshotCache::default();
let files = git::files_cached(
&repo.root,
&ReviewReadOptions::new(&cancellation),
&mut cache,
)
.unwrap();
eprintln!("twenty-file cold snapshot: {:?}", started.elapsed());
assert_eq!(files.len(), 20);
assert!(files.iter().all(|file| {
file.notice.is_none()
&& file
.rows
.iter()
.any(|row| row.kind == ReviewLineKind::Added)
&& file
.rows
.iter()
.any(|row| row.kind == ReviewLineKind::Removed)
}));
let started = Instant::now();
let refreshed = git::files_cached(
&repo.root,
&ReviewReadOptions::new(&cancellation),
&mut cache,
)
.unwrap();
eprintln!("twenty-file unchanged refresh: {:?}", started.elapsed());
assert_eq!(refreshed.len(), files.len());
for (before, after) in files.iter().zip(&refreshed) {
assert_eq!(before.original, after.original);
assert_eq!(before.current, after.current);
assert_eq!(before.rows.len(), after.rows.len());
}
}
#[test]
fn cached_snapshot_reads_same_length_edits_and_invalidates_on_commit_and_root_change() {
let repo = Repository::new();
repo.put("file.rs", "fn first() {}\n");
repo.commit();
repo.put("file.rs", "fn other() {}\n");
let cancellation = AgentCancellation::default();
let mut cache = SnapshotCache::default();
let mut read = |root: &Path| {
git::files_cached(root, &ReviewReadOptions::new(&cancellation), &mut cache).unwrap()
};
assert_eq!(read(&repo.root)[0].original, "fn first() {}\n");
repo.put("file.rs", "fn third() {}\n");
let edited = read(&repo.root);
assert_eq!(edited[0].current, "fn third() {}\n");
assert!(
edited[0]
.rows
.iter()
.any(|row| row.kind == ReviewLineKind::Added && row.text.contains("third"))
);
repo.commit();
repo.put("file.rs", "fn fourth() {}\n");
assert_eq!(read(&repo.root)[0].original, "fn third() {}\n");
let other = Repository::new();
other.put("file.rs", "fn separate() {}\n");
other.commit();
other.put("file.rs", "fn changed() {}\n");
assert_eq!(read(&other.root)[0].original, "fn separate() {}\n");
}
#[test]
fn batched_originals_keep_unusual_paths_and_file_notices() {
let repo = Repository::new();
for path in ["regular.rs", "white space.rs", "line\nbreak.rs", "large.rs"] {
repo.put(path, "fn original() {}\n");
}
repo.put("large.rs", &"x".repeat(MAX_SOURCE + 1));
fs::write(repo.root.join("non-utf8.rs"), [0xff, 0xfe]).unwrap();
repo.commit();
for path in [
"regular.rs",
"white space.rs",
"line\nbreak.rs",
"large.rs",
"non-utf8.rs",
] {
repo.put(path, "fn changed() {}\n");
}
let snapshot = repo.snapshot();
for file in &snapshot.files {
if ["large.rs", "non-utf8.rs"].contains(&file.path.as_str()) {
assert!(file.notice.is_some(), "{}", file.path);
assert!(file.rows.is_empty());
} else {
assert!(file.notice.is_none(), "{}: {:?}", file.path, file.notice);
assert_eq!(file.original, "fn original() {}\n");
assert_eq!(file.current, "fn changed() {}\n");
}
}
assert_eq!(snapshot.files.len(), 5);
}