1use crate::domain::config::AppConfig;
2use crate::domain::diff::{DiffDocument, DiffFile, DiffHunk, DiffLine, DiffLineKind};
3use anyhow::{Context, Result, anyhow};
4use git2::{Commit, DiffFormat, DiffOptions, Repository};
5use std::collections::BTreeSet;
6use std::path::{Component, Path, PathBuf};
7use tokio::fs;
8use tokio::task::spawn_blocking;
9use tracing::{debug, info};
10
11const MAX_ROOT_FILE_PREVIEW_BYTES: u64 = 2 * 1024 * 1024;
12const MAX_ROOT_FILE_PREVIEW_LINES: usize = 20_000;
13
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub enum DiffSource {
16 #[default]
17 WorkingTree,
18 RootDirectory,
19 Commit {
20 rev: String,
21 },
22 Range {
23 base: String,
24 head: String,
25 },
26}
27
28impl DiffSource {
29 #[must_use]
30 pub fn working_tree() -> Self {
31 Self::WorkingTree
32 }
33}
34
35pub async fn load_git_diff(
40 config: &AppConfig,
41 source: &DiffSource,
42 worktree_path: &Path,
43) -> Result<DiffDocument> {
44 debug!(?source, "loading git diff");
45 let document = match source {
46 DiffSource::RootDirectory => load_root_directory_document(config, worktree_path).await?,
47 _ => {
48 let source_for_worker = source.clone();
49 let config = config.clone();
50 let path = worktree_path.to_path_buf();
51 spawn_blocking(move || load_git_diff_sync(&config, &source_for_worker, &path))
52 .await
53 .context("failed to join git diff worker task")??
54 }
55 };
56 info!(files = document.files.len(), ?source, "git diff loaded");
57 Ok(document)
58}
59
60pub async fn load_git_diff_head(config: &AppConfig, worktree_path: &Path) -> Result<DiffDocument> {
65 load_git_diff(config, &DiffSource::WorkingTree, worktree_path).await
66}
67
68pub async fn load_root_directory_file_list(
72 config: &AppConfig,
73 worktree_path: &Path,
74) -> Result<DiffDocument> {
75 let (_workdir, source_paths) =
76 collect_root_directory_source_paths(config, worktree_path).await?;
77 let files = source_paths
78 .iter()
79 .map(|path| root_directory_placeholder_file(path))
80 .collect();
81 Ok(DiffDocument { files })
82}
83
84pub fn read_file_at_ref(worktree_path: &Path, ref_path: &str) -> Result<String> {
91 let repo = Repository::discover(worktree_path).context("failed to discover git repository")?;
92
93 let Some((rev, path)) = ref_path.split_once(':') else {
94 return Err(anyhow::anyhow!(
95 "invalid format: use 'ref:path' (e.g., 'main:src/lib.rs')"
96 ));
97 };
98
99 let obj = repo
100 .revparse_single(rev)
101 .with_context(|| format!("failed to resolve ref '{rev}'"))?;
102
103 let tree = if let Some(commit) = obj.as_commit() {
104 commit
105 .tree()
106 .with_context(|| format!("failed to read tree for commit {rev}"))?
107 } else if let Some(tree) = obj.as_tree() {
108 tree.clone()
109 } else {
110 return Err(anyhow::anyhow!("ref '{rev}' is not a commit or tree"));
111 };
112
113 let entry = tree
114 .get_path(std::path::Path::new(path))
115 .with_context(|| format!("file '{path}' not found at ref '{rev}'"))?;
116
117 let blob = repo
118 .find_blob(entry.id())
119 .with_context(|| format!("failed to read blob for '{path}'"))?;
120
121 String::from_utf8(blob.content().to_vec())
122 .with_context(|| format!("file '{}' contains invalid UTF-8", path))
123}
124
125pub async fn load_root_directory_file(
130 config: &AppConfig,
131 relative_path: String,
132 worktree_path: &Path,
133) -> Result<Option<DiffFile>> {
134 let Some(relative_path) = safe_root_relative_path(&relative_path) else {
135 return Ok(None);
136 };
137 let workdir = match spawn_blocking({
138 let path = worktree_path.to_path_buf();
139 move || {
140 let repo = Repository::discover(&path).context("failed to discover git repository")?;
141 let workdir = repo
142 .workdir()
143 .context("root directory reviews require a non-bare git repository")?;
144 Ok::<_, anyhow::Error>(workdir.to_path_buf())
145 }
146 })
147 .await
148 .context("failed to resolve root workdir")?
149 {
150 Ok(workdir) => workdir,
151 Err(_) => return Ok(None),
152 };
153
154 let filtered = spawn_blocking({
155 let config = config.clone();
156 let relative_path = relative_path.clone();
157 let worktree_path = worktree_path.to_path_buf();
158 move || filter_paths_for_root_directory(&config, vec![relative_path], &worktree_path)
159 })
160 .await
161 .context("failed to filter root file path")??;
162 if filtered.is_empty() {
163 return Ok(None);
164 }
165
166 root_directory_file(&workdir, &relative_path).await
167}
168
169fn load_git_diff_sync(
170 config: &AppConfig,
171 source: &DiffSource,
172 path: &Path,
173) -> Result<DiffDocument> {
174 let repo = Repository::discover(path).context("failed to discover git repository")?;
175 load_git_diff_for_repo(&repo, config, source)
176}
177
178fn load_git_diff_for_repo(
179 repo: &Repository,
180 config: &AppConfig,
181 source: &DiffSource,
182) -> Result<DiffDocument> {
183 if matches!(source, DiffSource::RootDirectory) {
184 return Err(anyhow!(
185 "root directory reviews must use the async root directory loader"
186 ));
187 }
188
189 let text = load_diff_text(repo, source)?;
190 let mut document = parse_unified_diff(&text)?;
191 let ignore_repo = matches!(source, DiffSource::WorkingTree).then_some(repo);
192 filter_ignored_files(&mut document, config, ignore_repo)?;
193 Ok(document)
194}
195
196fn load_diff_text(repo: &Repository, source: &DiffSource) -> Result<String> {
197 let mut diff_opts = DiffOptions::new();
198 diff_opts.context_lines(3).include_typechange(true);
199
200 let diff = match source {
201 DiffSource::WorkingTree => {
202 configure_worktree_diff_options(&mut diff_opts);
203 let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
204 repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
205 .context("failed to compute repository diff")?
206 }
207 DiffSource::RootDirectory => return Ok(String::new()),
208 DiffSource::Commit { rev } => {
209 let commit = resolve_commit(repo, rev)?;
210 let new_tree = commit.tree().context("failed to read commit tree")?;
211 let old_tree = commit
212 .parent(0)
213 .ok()
214 .map(|parent| parent.tree().context("failed to read parent tree"))
215 .transpose()?;
216 repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_opts))
217 .with_context(|| format!("failed to diff commit {rev}"))?
218 }
219 DiffSource::Range { base, head } => {
220 let base_tree = resolve_commit(repo, base)?
221 .tree()
222 .with_context(|| format!("failed to read base tree for {base}"))?;
223 let head_tree = resolve_commit(repo, head)?
224 .tree()
225 .with_context(|| format!("failed to read head tree for {head}"))?;
226 repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut diff_opts))
227 .with_context(|| format!("failed to diff range {base}..{head}"))?
228 }
229 };
230
231 render_diff_text(diff)
232}
233
234async fn load_root_directory_document(
235 config: &AppConfig,
236 worktree_path: &Path,
237) -> Result<DiffDocument> {
238 let (workdir, source_paths) =
239 collect_root_directory_source_paths(config, worktree_path).await?;
240
241 let mut files = Vec::new();
242 for path in source_paths {
243 if let Some(file) = root_directory_file(&workdir, &path).await? {
244 files.push(file);
245 }
246 }
247
248 Ok(DiffDocument { files })
249}
250
251async fn collect_root_directory_source_paths(
252 config: &AppConfig,
253 worktree_path: &Path,
254) -> Result<(PathBuf, BTreeSet<PathBuf>)> {
255 let (workdir, mut paths) = spawn_blocking({
256 let config = config.clone();
257 let path = worktree_path.to_path_buf();
258 move || {
259 let repo = Repository::discover(&path).context("failed to discover git repository")?;
260 let workdir = repo
261 .workdir()
262 .context("root directory reviews require a non-bare git repository")?;
263 let tracked = tracked_file_paths(&repo)?;
264 let _ = config;
265 Ok::<_, anyhow::Error>((workdir.to_path_buf(), tracked))
266 }
267 })
268 .await
269 .context("failed to collect tracked root paths")??;
270
271 collect_untracked_file_paths(&workdir, workdir.as_path(), config, &mut paths).await?;
272
273 let candidate_paths = {
274 let mut candidate_paths = Vec::with_capacity(paths.len());
275 candidate_paths.extend(paths);
276 candidate_paths
277 };
278 let source_paths = spawn_blocking({
279 let config = config.clone();
280 let worktree_path = worktree_path.to_path_buf();
281 move || filter_paths_for_root_directory(&config, candidate_paths, &worktree_path)
282 })
283 .await
284 .context("failed to filter git-aware root directory paths")??;
285
286 Ok((workdir, source_paths))
287}
288
289fn filter_paths_for_root_directory(
290 config: &AppConfig,
291 mut paths: Vec<PathBuf>,
292 worktree_path: &Path,
293) -> Result<BTreeSet<PathBuf>> {
294 let repo = Repository::discover(worktree_path).context("failed to discover git repository")?;
295 let mut filtered_paths = BTreeSet::new();
296 for path in paths.drain(..) {
297 if should_ignore_file(path.to_string_lossy().as_ref(), config, Some(&repo))? {
298 continue;
299 }
300 filtered_paths.insert(path);
301 }
302 Ok(filtered_paths)
303}
304
305fn tracked_file_paths(repo: &Repository) -> Result<BTreeSet<PathBuf>> {
306 let index = repo.index().context("failed to read git index")?;
307 let mut paths = BTreeSet::new();
308 for entry in index.iter() {
309 let path = std::str::from_utf8(&entry.path).context("git index path is not utf-8")?;
310 paths.insert(PathBuf::from(path));
311 }
312 Ok(paths)
313}
314
315async fn collect_untracked_file_paths(
316 workdir: &Path,
317 dir: &Path,
318 config: &AppConfig,
319 paths: &mut BTreeSet<PathBuf>,
320) -> Result<()> {
321 let mut entries = fs::read_dir(dir)
322 .await
323 .with_context(|| format!("failed to read {}", dir.display()))?;
324 while let Some(entry) = entries
325 .next_entry()
326 .await
327 .with_context(|| format!("failed to read entry in {}", dir.display()))?
328 {
329 let path = entry.path();
330 let relative_path = path
331 .strip_prefix(workdir)
332 .with_context(|| format!("failed to relativize {}", path.display()))?;
333
334 if should_skip_root_directory_path(relative_path, config) {
335 continue;
336 }
337
338 let file_type = entry
339 .file_type()
340 .await
341 .with_context(|| format!("failed to inspect {}", path.display()))?;
342 if file_type.is_dir() {
343 Box::pin(collect_untracked_file_paths(workdir, &path, config, paths)).await?;
344 continue;
345 }
346 if !file_type.is_file() {
347 continue;
348 }
349 paths.insert(relative_path.to_path_buf());
350 }
351 Ok(())
352}
353
354async fn root_directory_file(workdir: &Path, relative_path: &Path) -> Result<Option<DiffFile>> {
355 let path = workdir.join(relative_path);
356 let metadata = match fs::metadata(&path).await {
357 Ok(metadata) => metadata,
358 Err(error) => {
359 if error.kind() == std::io::ErrorKind::NotFound {
360 return Ok(None);
361 }
362 return Err(error).with_context(|| format!("failed to inspect {}", path.display()));
363 }
364 };
365 if !metadata.is_file() {
366 return Ok(None);
367 }
368 let display_path = normalize_relative_path(relative_path);
369 if metadata.len() > MAX_ROOT_FILE_PREVIEW_BYTES {
370 return Ok(Some(root_directory_large_file_preview(
371 &display_path,
372 metadata.len(),
373 "file is too large to preview",
374 )));
375 }
376
377 let bytes = fs::read(&path)
378 .await
379 .with_context(|| format!("failed to read {}", path.display()))?;
380 if bytes.contains(&0) {
381 return Ok(None);
382 }
383 let content = match String::from_utf8(bytes) {
384 Ok(content) => content,
385 Err(_) => return Ok(None),
386 };
387 if content
388 .lines()
389 .take(MAX_ROOT_FILE_PREVIEW_LINES + 1)
390 .count()
391 > MAX_ROOT_FILE_PREVIEW_LINES
392 {
393 return Ok(Some(root_directory_large_file_preview(
394 &display_path,
395 metadata.len(),
396 "file has too many lines to preview",
397 )));
398 }
399 Ok(Some(diff_file_from_content(&display_path, &content)))
400}
401
402fn root_directory_placeholder_file(relative_path: &Path) -> DiffFile {
403 let display_path = normalize_relative_path(relative_path);
404 DiffFile {
405 path: display_path.clone(),
406 header_lines: vec![format!("file {display_path}")],
407 hunks: Vec::new(),
408 }
409}
410
411fn safe_root_relative_path(path: &str) -> Option<PathBuf> {
412 let path = Path::new(path);
413 if path.is_absolute() {
414 return None;
415 }
416 let mut safe = PathBuf::new();
417 for component in path.components() {
418 let Component::Normal(value) = component else {
419 return None;
420 };
421 safe.push(value);
422 }
423 Some(safe)
424}
425
426fn diff_file_from_content(path: &str, content: &str) -> DiffFile {
427 let lines = content.lines().collect::<Vec<_>>();
428 let line_count = u32::try_from(lines.len()).unwrap_or(u32::MAX);
429 let mut hunk = DiffHunk {
430 old_start: 1,
431 old_count: line_count,
432 new_start: 1,
433 new_count: line_count,
434 header: format!("@@ -1,{line_count} +1,{line_count} @@"),
435 lines: Vec::with_capacity(lines.len() + 1),
436 };
437 hunk.lines.push(DiffLine {
438 kind: DiffLineKind::HunkHeader,
439 old_line: None,
440 new_line: None,
441 raw: hunk.header.clone(),
442 code: hunk.header.clone(),
443 });
444 for (index, line) in lines.into_iter().enumerate() {
445 let line_number = u32::try_from(index + 1).unwrap_or(u32::MAX);
446 hunk.lines.push(DiffLine {
447 kind: DiffLineKind::Context,
448 old_line: None,
449 new_line: Some(line_number),
450 raw: format!(" {line}"),
451 code: line.to_string(),
452 });
453 }
454
455 DiffFile {
456 path: path.to_string(),
457 header_lines: vec![format!("file {path}")],
458 hunks: vec![hunk],
459 }
460}
461
462fn root_directory_large_file_preview(path: &str, byte_len: u64, reason: &str) -> DiffFile {
463 let size = format_root_file_size(byte_len);
464 diff_file_from_content(
465 path,
466 &format!("{reason}; {size}. Use search or open the file directly."),
467 )
468}
469
470fn format_root_file_size(byte_len: u64) -> String {
471 const KIB: u64 = 1024;
472 const MIB: u64 = 1024 * 1024;
473 if byte_len >= MIB {
474 format!("{:.1} MiB", byte_len as f64 / MIB as f64)
475 } else if byte_len >= KIB {
476 format!("{:.1} KiB", byte_len as f64 / KIB as f64)
477 } else {
478 format!("{byte_len} B")
479 }
480}
481
482fn normalize_relative_path(path: &Path) -> String {
483 path.components()
484 .filter_map(|component| match component {
485 Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
486 _ => None,
487 })
488 .collect::<Vec<_>>()
489 .join("/")
490}
491
492fn should_skip_root_directory_path(path: &Path, config: &AppConfig) -> bool {
493 let mut components = path.components();
494 let Some(Component::Normal(first)) = components.next() else {
495 return false;
496 };
497 if first == ".git" || first == "worktrees" {
498 return true;
499 }
500 config.ignore_parley_dir && first == ".parley"
501}
502
503fn configure_worktree_diff_options(diff_opts: &mut DiffOptions) {
504 diff_opts
505 .include_untracked(true)
506 .recurse_untracked_dirs(true)
507 .show_untracked_content(true);
508}
509
510fn render_diff_text(diff: git2::Diff<'_>) -> Result<String> {
511 let mut patch_bytes = Vec::new();
512 diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
513 match line.origin() {
514 '+' | '-' | ' ' => patch_bytes.push(line.origin() as u8),
515 _ => {}
516 }
517 patch_bytes.extend_from_slice(line.content());
518 true
519 })
520 .context("failed to render patch text")?;
521
522 Ok(String::from_utf8_lossy(&patch_bytes).into_owned())
523}
524
525fn resolve_commit<'repo>(repo: &'repo Repository, rev: &str) -> Result<Commit<'repo>> {
526 repo.revparse_single(rev)
527 .with_context(|| format!("failed to resolve revision {rev}"))?
528 .peel_to_commit()
529 .with_context(|| format!("revision {rev} does not resolve to a commit"))
530}
531
532pub fn parse_unified_diff(text: &str) -> Result<DiffDocument> {
536 let mut files = Vec::new();
537
538 let mut current_file: Option<DiffFile> = None;
539 let mut current_hunk: Option<DiffHunk> = None;
540 let mut old_cursor: u32 = 0;
541 let mut new_cursor: u32 = 0;
542
543 for line in text.lines() {
544 if line.starts_with("diff --git ") {
545 if let Some(hunk) = current_hunk.take()
546 && let Some(file) = current_file.as_mut()
547 {
548 file.hunks.push(hunk);
549 }
550 if let Some(file) = current_file.take() {
551 files.push(file);
552 }
553 current_file = Some(DiffFile {
554 path: parse_diff_git_path(line).unwrap_or_default(),
555 header_lines: vec![line.to_string()],
556 hunks: Vec::new(),
557 });
558 continue;
559 }
560
561 if line.starts_with("@@") {
562 if current_file.is_none() {
563 current_file = Some(DiffFile {
564 path: String::new(),
565 header_lines: Vec::new(),
566 hunks: Vec::new(),
567 });
568 }
569
570 if let Some(hunk) = current_hunk.take()
571 && let Some(file) = current_file.as_mut()
572 {
573 file.hunks.push(hunk);
574 }
575
576 let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?;
577 old_cursor = old_start;
578 new_cursor = new_start;
579
580 let mut hunk = DiffHunk {
581 old_start,
582 old_count,
583 new_start,
584 new_count,
585 header: line.to_string(),
586 lines: Vec::new(),
587 };
588 hunk.lines.push(DiffLine {
589 kind: DiffLineKind::HunkHeader,
590 old_line: None,
591 new_line: None,
592 raw: line.to_string(),
593 code: line.to_string(),
594 });
595 current_hunk = Some(hunk);
596 continue;
597 }
598
599 if let Some(file) = current_file.as_mut()
600 && current_hunk.is_none()
601 {
602 if line.starts_with("+++ ") {
603 if let Some(path) = parse_patch_path(line, "+++ ") {
604 file.path = path;
605 }
606 file.header_lines.push(line.to_string());
607 continue;
608 }
609
610 if line.starts_with("--- ") {
611 if file.path.is_empty()
612 && let Some(path) = parse_patch_path(line, "--- ")
613 {
614 file.path = path;
615 }
616 file.header_lines.push(line.to_string());
617 continue;
618 }
619
620 file.header_lines.push(line.to_string());
621 continue;
622 }
623
624 if let Some(hunk) = current_hunk.as_mut() {
625 let parsed = if let Some(code) = line.strip_prefix('+') {
626 let line_value = DiffLine {
627 kind: DiffLineKind::Added,
628 old_line: None,
629 new_line: Some(new_cursor),
630 raw: line.to_string(),
631 code: code.to_string(),
632 };
633 new_cursor += 1;
634 line_value
635 } else if let Some(code) = line.strip_prefix('-') {
636 let line_value = DiffLine {
637 kind: DiffLineKind::Removed,
638 old_line: Some(old_cursor),
639 new_line: None,
640 raw: line.to_string(),
641 code: code.to_string(),
642 };
643 old_cursor += 1;
644 line_value
645 } else if let Some(code) = line.strip_prefix(' ') {
646 let line_value = DiffLine {
647 kind: DiffLineKind::Context,
648 old_line: Some(old_cursor),
649 new_line: Some(new_cursor),
650 raw: line.to_string(),
651 code: code.to_string(),
652 };
653 old_cursor += 1;
654 new_cursor += 1;
655 line_value
656 } else {
657 DiffLine {
658 kind: DiffLineKind::Meta,
659 old_line: None,
660 new_line: None,
661 raw: line.to_string(),
662 code: line.to_string(),
663 }
664 };
665
666 hunk.lines.push(parsed);
667 }
668 }
669
670 if let Some(hunk) = current_hunk.take()
671 && let Some(file) = current_file.as_mut()
672 {
673 file.hunks.push(hunk);
674 }
675
676 if let Some(file) = current_file.take() {
677 files.push(file);
678 }
679
680 Ok(DiffDocument { files })
681}
682
683fn filter_ignored_files(
684 document: &mut DiffDocument,
685 config: &AppConfig,
686 repo: Option<&Repository>,
687) -> Result<()> {
688 if !config.ignore_parley_dir && repo.is_none() {
689 return Ok(());
690 }
691
692 let mut retained = Vec::with_capacity(document.files.len());
693 for file in document.files.drain(..) {
694 if should_ignore_file(&file.path, config, repo)? {
695 continue;
696 }
697 retained.push(file);
698 }
699 document.files = retained;
700 Ok(())
701}
702
703fn is_parley_internal_path(path: &str) -> bool {
704 path == ".parley" || path.starts_with(".parley/")
705}
706
707fn should_ignore_file(path: &str, config: &AppConfig, repo: Option<&Repository>) -> Result<bool> {
708 if config.ignore_parley_dir && is_parley_internal_path(path) {
709 return Ok(true);
710 }
711
712 let Some(repo) = repo else {
713 return Ok(false);
714 };
715 repo.status_should_ignore(Path::new(path))
716 .with_context(|| format!("failed to evaluate gitignore rules for {path}"))
717}
718
719fn parse_hunk_header(line: &str) -> Result<(u32, u32, u32, u32)> {
720 let Some(rest) = line.strip_prefix("@@ -") else {
721 return Err(anyhow!("invalid hunk header format: {line}"));
722 };
723 let Some((left, right_tail)) = rest.split_once(" +") else {
724 return Err(anyhow!("invalid hunk header body: {line}"));
725 };
726 let Some((right, _tail)) = right_tail.split_once(" @@") else {
727 return Err(anyhow!("invalid hunk header end: {line}"));
728 };
729
730 let (old_start, old_count) = parse_range(left)?;
731 let (new_start, new_count) = parse_range(right)?;
732 Ok((old_start, old_count, new_start, new_count))
733}
734
735fn parse_range(value: &str) -> Result<(u32, u32)> {
736 if let Some((start, count)) = value.split_once(',') {
737 Ok((start.parse()?, count.parse()?))
738 } else {
739 Ok((value.parse()?, 1))
740 }
741}
742
743fn parse_patch_path(line: &str, marker: &str) -> Option<String> {
744 let raw = line.strip_prefix(marker)?.trim();
745 parse_diff_path(raw)
746}
747
748fn parse_diff_git_path(line: &str) -> Option<String> {
749 let raw = line.strip_prefix("diff --git ")?;
750 let (_, right) = split_diff_paths(raw)?;
751 parse_diff_path(right)
752}
753
754fn split_diff_paths(raw: &str) -> Option<(&str, &str)> {
755 let raw = raw.trim();
756 if raw.is_empty() {
757 return None;
758 }
759
760 if let Some(rest) = raw.strip_prefix('"') {
761 let end_left = rest.find('"')?;
762 let left = &raw[..=end_left + 1];
763 let rest = rest[end_left + 1..].trim_start();
764 let rest = rest.strip_prefix('"')?;
765 let end_right = rest.find('"')?;
766 let right = &rest[..=end_right];
767 return Some((left, right));
768 }
769
770 let (left, right) = raw.split_once(' ')?;
771 Some((left, right.trim_start()))
772}
773
774fn parse_diff_path(raw: &str) -> Option<String> {
775 let raw = raw.trim();
776 if raw == "/dev/null" {
777 return None;
778 }
779
780 let unquoted = raw
781 .strip_prefix('"')
782 .and_then(|v| v.strip_suffix('"'))
783 .unwrap_or(raw);
784 let normalized = unquoted
785 .strip_prefix("a/")
786 .or_else(|| unquoted.strip_prefix("b/"))
787 .unwrap_or(unquoted);
788 Some(normalized.to_string())
789}
790
791#[cfg(test)]
792async fn load_root_directory_document_for_repo(
793 repo: &Repository,
794 config: &AppConfig,
795) -> Result<DiffDocument> {
796 let workdir = repo
797 .workdir()
798 .context("root directory reviews require a non-bare git repository")?;
799 let mut paths = tracked_file_paths(repo)?;
800 collect_untracked_file_paths(workdir, workdir, config, &mut paths).await?;
801
802 let mut files = Vec::new();
803 for path in paths {
804 if should_ignore_file(path.to_string_lossy().as_ref(), config, Some(repo))? {
805 continue;
806 }
807 if let Some(file) = root_directory_file(workdir, &path).await? {
808 files.push(file);
809 }
810 }
811
812 Ok(DiffDocument { files })
813}
814
815#[cfg(test)]
816mod tests {
817 use super::{
818 DiffSource, MAX_ROOT_FILE_PREVIEW_BYTES, filter_ignored_files, load_git_diff_for_repo,
819 load_root_directory_document_for_repo, parse_unified_diff, root_directory_file,
820 root_directory_placeholder_file, safe_root_relative_path,
821 };
822 use crate::domain::config::AppConfig;
823 use crate::domain::diff::DiffLineKind;
824 use anyhow::{Result, anyhow};
825 use git2::{Oid, Repository, Signature};
826 use std::fs;
827 use std::path::PathBuf;
828 use tempfile::tempdir;
829
830 #[test]
831 fn parse_unified_diff_should_parse_added_and_removed_lines_with_numbers() -> Result<()> {
832 let input = "diff --git a/src/lib.rs b/src/lib.rs\nindex 123..456 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,2 +1,3 @@\n fn a() {}\n-fn b() {}\n+fn b() {\"x\";}\n+fn c() {}\n";
833
834 let doc = parse_unified_diff(input)?;
835
836 assert_eq!(doc.files.len(), 1);
837 assert_eq!(doc.files[0].path, "src/lib.rs");
838 assert!(
839 doc.files[0]
840 .header_lines
841 .iter()
842 .any(|line| line.starts_with("index "))
843 );
844 assert_eq!(doc.files[0].hunks.len(), 1);
845 let hunk = &doc.files[0].hunks[0];
846 assert_eq!(hunk.lines[0].kind, DiffLineKind::HunkHeader);
847 assert_eq!(hunk.lines[2].kind, DiffLineKind::Removed);
848 assert_eq!(hunk.lines[2].old_line, Some(2));
849 assert_eq!(hunk.lines[2].new_line, None);
850 assert_eq!(hunk.lines[3].kind, DiffLineKind::Added);
851 assert_eq!(hunk.lines[3].old_line, None);
852 assert_eq!(hunk.lines[3].new_line, Some(2));
853 Ok(())
854 }
855
856 #[test]
857 fn parse_unified_diff_should_use_old_path_for_deleted_files() -> Result<()> {
858 let input = "diff --git a/src/old.rs b/src/old.rs\nindex 123..456 100644\n--- a/src/old.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-fn old() {}\n";
859
860 let doc = parse_unified_diff(input)?;
861
862 assert_eq!(doc.files.len(), 1);
863 assert_eq!(doc.files[0].path, "src/old.rs");
864 Ok(())
865 }
866
867 #[test]
868 fn parse_unified_diff_should_parse_quoted_paths() -> Result<()> {
869 let input = "diff --git \"a/src/with space.rs\" \"b/src/with space.rs\"\nindex 123..456 100644\n--- \"a/src/with space.rs\"\n+++ \"b/src/with space.rs\"\n@@ -1 +1 @@\n-fn before() {}\n+fn after() {}\n";
870
871 let doc = parse_unified_diff(input)?;
872
873 assert_eq!(doc.files.len(), 1);
874 assert_eq!(doc.files[0].path, "src/with space.rs");
875 Ok(())
876 }
877
878 #[test]
879 fn parse_unified_diff_should_use_diff_header_path_for_binary_new_files() -> Result<()> {
880 let input = "diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png\nnew file mode 100644\nindex 0000000..6be5e50\nBinary files /dev/null and b/src-tauri/icons/128x128.png differ\n";
881
882 let doc = parse_unified_diff(input)?;
883
884 assert_eq!(doc.files.len(), 1);
885 assert_eq!(doc.files[0].path, "src-tauri/icons/128x128.png");
886 assert!(doc.files[0].hunks.is_empty());
887 Ok(())
888 }
889
890 #[test]
891 fn filter_ignored_files_removes_parley_entries_by_default() -> Result<()> {
892 let input = "diff --git a/.parley/config.toml b/.parley/config.toml\n--- a/.parley/config.toml\n+++ b/.parley/config.toml\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n";
893 let mut doc = parse_unified_diff(input)?;
894
895 filter_ignored_files(&mut doc, &AppConfig::default(), None)?;
896
897 assert_eq!(doc.files.len(), 1);
898 assert_eq!(doc.files[0].path, "src/lib.rs");
899 Ok(())
900 }
901
902 #[test]
903 fn filter_ignored_files_can_keep_parley_entries_when_configured() -> Result<()> {
904 let input = "diff --git a/.parley/config.toml b/.parley/config.toml\n--- a/.parley/config.toml\n+++ b/.parley/config.toml\n@@ -1 +1 @@\n-old\n+new\n";
905 let mut doc = parse_unified_diff(input)?;
906 let config = AppConfig {
907 ignore_parley_dir: false,
908 ..AppConfig::default()
909 };
910
911 filter_ignored_files(&mut doc, &config, None)?;
912
913 assert_eq!(doc.files.len(), 1);
914 assert_eq!(doc.files[0].path, ".parley/config.toml");
915 Ok(())
916 }
917
918 #[test]
919 fn filter_ignored_files_removes_gitignored_paths() -> Result<()> {
920 let temp = tempdir()?;
921 let repo = Repository::init(temp.path())?;
922 fs::write(
923 temp.path().join(".gitignore"),
924 "ignored.txt\nignored-dir/\n",
925 )?;
926 fs::write(temp.path().join("ignored.txt"), "ignored\n")?;
927 fs::create_dir_all(temp.path().join("ignored-dir"))?;
928 fs::write(temp.path().join("ignored-dir/file.txt"), "ignored\n")?;
929 fs::write(temp.path().join("tracked.txt"), "tracked\n")?;
930
931 let input = "diff --git a/ignored.txt b/ignored.txt\nnew file mode 100644\nindex 0000000..1111111\nBinary files /dev/null and b/ignored.txt differ\ndiff --git a/ignored-dir/file.txt b/ignored-dir/file.txt\nnew file mode 100644\nindex 0000000..2222222\nBinary files /dev/null and b/ignored-dir/file.txt differ\ndiff --git a/tracked.txt b/tracked.txt\nnew file mode 100644\nindex 0000000..3333333\nBinary files /dev/null and b/tracked.txt differ\n";
932 let mut doc = parse_unified_diff(input)?;
933
934 filter_ignored_files(&mut doc, &AppConfig::default(), Some(&repo))?;
935
936 assert_eq!(doc.files.len(), 1);
937 assert_eq!(doc.files[0].path, "tracked.txt");
938 Ok(())
939 }
940
941 #[test]
942 fn load_git_diff_for_commit_uses_first_parent_diff() -> Result<()> {
943 let temp = tempdir()?;
944 let repo = Repository::init(temp.path())?;
945
946 let first = commit_file(&repo, temp.path(), "src/lib.rs", "fn first() {}\n", "first")?;
947 let second = commit_file(
948 &repo,
949 temp.path(),
950 "src/lib.rs",
951 "fn second() {}\n",
952 "second",
953 )?;
954
955 let doc = load_git_diff_for_repo(
956 &repo,
957 &AppConfig::default(),
958 &DiffSource::Commit {
959 rev: second.to_string(),
960 },
961 )?;
962
963 assert_eq!(doc.files.len(), 1);
964 assert_eq!(doc.files[0].path, "src/lib.rs");
965 let lines = &doc.files[0].hunks[0].lines;
966 assert!(lines.iter().any(|line| line.raw == "-fn first() {}"));
967 assert!(lines.iter().any(|line| line.raw == "+fn second() {}"));
968
969 let root_doc = load_git_diff_for_repo(
970 &repo,
971 &AppConfig::default(),
972 &DiffSource::Commit {
973 rev: first.to_string(),
974 },
975 )?;
976
977 assert_eq!(root_doc.files.len(), 1);
978 assert!(
979 root_doc.files[0]
980 .hunks
981 .iter()
982 .flat_map(|hunk| hunk.lines.iter())
983 .any(|line| line.raw == "+fn first() {}")
984 );
985 Ok(())
986 }
987
988 #[test]
989 fn load_git_diff_for_range_uses_explicit_base_and_head() -> Result<()> {
990 let temp = tempdir()?;
991 let repo = Repository::init(temp.path())?;
992
993 let base = commit_file(&repo, temp.path(), "src/lib.rs", "fn one() {}\n", "one")?;
994 let _middle = commit_file(&repo, temp.path(), "src/lib.rs", "fn two() {}\n", "two")?;
995 let head = commit_file(&repo, temp.path(), "src/lib.rs", "fn three() {}\n", "three")?;
996
997 let doc = load_git_diff_for_repo(
998 &repo,
999 &AppConfig::default(),
1000 &DiffSource::Range {
1001 base: base.to_string(),
1002 head: head.to_string(),
1003 },
1004 )?;
1005
1006 assert_eq!(doc.files.len(), 1);
1007 let lines = &doc.files[0].hunks[0].lines;
1008 assert!(lines.iter().any(|line| line.raw == "-fn one() {}"));
1009 assert!(lines.iter().any(|line| line.raw == "+fn three() {}"));
1010 assert!(!lines.iter().any(|line| line.raw == "+fn two() {}"));
1011 Ok(())
1012 }
1013
1014 #[test]
1015 fn load_git_diff_tolerates_non_utf8_patch_content() -> Result<()> {
1016 let temp = tempdir()?;
1017 let repo = Repository::init(temp.path())?;
1018 commit_file(&repo, temp.path(), "notes.txt", "hello\n", "base")?;
1019 fs::write(temp.path().join("notes.txt"), b"hello \xFF\n")?;
1020
1021 let doc = load_git_diff_for_repo(&repo, &AppConfig::default(), &DiffSource::WorkingTree)?;
1022
1023 assert_eq!(doc.files.len(), 1);
1024 let lines = &doc.files[0].hunks[0].lines;
1025 assert!(lines.iter().any(|line| line.raw == "-hello"));
1026 assert!(lines.iter().any(|line| line.raw == "+hello �"));
1027 Ok(())
1028 }
1029
1030 #[tokio::test]
1031 async fn load_root_directory_includes_tracked_and_untracked_files() -> Result<()> {
1032 let temp = tempdir()?;
1033 let repo = Repository::init(temp.path())?;
1034
1035 commit_file(&repo, temp.path(), ".gitignore", "ignored.log\n", "ignore")?;
1036 commit_file(
1037 &repo,
1038 temp.path(),
1039 "src/lib.rs",
1040 "fn tracked() {}\n",
1041 "tracked",
1042 )?;
1043 fs::write(temp.path().join("src/extra.rs"), "fn untracked() {}\n")?;
1044 fs::write(temp.path().join("ignored.log"), "ignored\n")?;
1045 fs::create_dir_all(temp.path().join("worktrees/other/src"))?;
1046 fs::write(
1047 temp.path().join("worktrees/other/src/lib.rs"),
1048 "fn other_worktree() {}\n",
1049 )?;
1050
1051 let doc = load_root_directory_document_for_repo(&repo, &AppConfig::default()).await?;
1052
1053 let paths = doc
1054 .files
1055 .iter()
1056 .map(|file| file.path.as_str())
1057 .collect::<Vec<_>>();
1058 assert_eq!(paths, vec![".gitignore", "src/extra.rs", "src/lib.rs"]);
1059
1060 let tracked = doc
1061 .files
1062 .iter()
1063 .find(|file| file.path == "src/lib.rs")
1064 .ok_or_else(|| anyhow!("tracked file should be present"))?;
1065 let tracked_lines = &tracked.hunks[0].lines;
1066 assert!(tracked_lines.iter().any(|line| {
1067 line.kind == DiffLineKind::Context
1068 && line.old_line.is_none()
1069 && line.new_line == Some(1)
1070 && line.code == "fn tracked() {}"
1071 }));
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn root_directory_placeholder_file_defers_content_loading() {
1077 let file = root_directory_placeholder_file(std::path::Path::new("src/lib.rs"));
1078
1079 assert_eq!(file.path, "src/lib.rs");
1080 assert_eq!(file.header_lines, vec!["file src/lib.rs"]);
1081 assert!(file.hunks.is_empty());
1082 }
1083
1084 #[tokio::test]
1085 async fn large_root_directory_file_renders_preview_without_content() -> Result<()> {
1086 let temp = tempdir()?;
1087 let relative_path = std::path::Path::new("large.json");
1088 let path = temp.path().join(relative_path);
1089 fs::write(
1090 &path,
1091 "x".repeat((MAX_ROOT_FILE_PREVIEW_BYTES + 1) as usize),
1092 )?;
1093
1094 let file = root_directory_file(temp.path(), relative_path)
1095 .await?
1096 .ok_or_else(|| anyhow!("large file preview should be present"))?;
1097
1098 assert_eq!(file.path, "large.json");
1099 assert_eq!(file.hunks.len(), 1);
1100 assert!(
1101 file.hunks[0]
1102 .lines
1103 .iter()
1104 .any(|line| line.code.contains("file is too large to preview"))
1105 );
1106 Ok(())
1107 }
1108
1109 #[test]
1110 fn safe_root_relative_path_rejects_unsafe_paths() {
1111 assert_eq!(
1112 safe_root_relative_path("src/lib.rs"),
1113 Some(PathBuf::from("src/lib.rs"))
1114 );
1115 assert!(safe_root_relative_path("../secret").is_none());
1116 assert!(safe_root_relative_path("/tmp/secret").is_none());
1117 }
1118
1119 fn commit_file(
1120 repo: &Repository,
1121 root: &std::path::Path,
1122 relative_path: &str,
1123 content: &str,
1124 message: &str,
1125 ) -> Result<Oid> {
1126 let path = root.join(relative_path);
1127 if let Some(parent) = path.parent() {
1128 fs::create_dir_all(parent)?;
1129 }
1130 fs::write(&path, content)?;
1131
1132 let mut index = repo.index()?;
1133 index.add_path(std::path::Path::new(relative_path))?;
1134 index.write()?;
1135
1136 let tree_oid = index.write_tree()?;
1137 let tree = repo.find_tree(tree_oid)?;
1138 let signature = Signature::now("Parley Test", "parley@example.com")?;
1139 let parents = repo
1140 .head()
1141 .ok()
1142 .and_then(|head| head.target())
1143 .map(|oid| repo.find_commit(oid))
1144 .transpose()?
1145 .into_iter()
1146 .collect::<Vec<_>>();
1147 let parent_refs = parents.iter().collect::<Vec<_>>();
1148
1149 Ok(repo.commit(
1150 Some("HEAD"),
1151 &signature,
1152 &signature,
1153 message,
1154 &tree,
1155 &parent_refs,
1156 )?)
1157 }
1158}