Skip to main content

parley/git/
diff.rs

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
35/// # Errors
36///
37/// Returns an error when the git repository cannot be discovered, the requested revision cannot be
38/// resolved, the diff cannot be rendered, or the rendered patch cannot be parsed.
39pub 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
60/// # Errors
61///
62/// Returns an error for the same repository discovery, diff rendering, and parsing failures as
63/// [`load_git_diff`].
64pub 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
68/// # Errors
69///
70/// Returns an error when the git repository cannot be discovered or root paths cannot be listed.
71pub 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
84/// Read file content at a specific git ref (branch, commit, tag).
85/// Format: "ref:path/to/file" e.g., "main:src/lib.rs" or "abc123:file.txt"
86///
87/// # Errors
88///
89/// Returns an error when the repository cannot be found, ref is invalid, or file doesn't exist.
90pub 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
125/// # Errors
126///
127/// Returns an error when the git repository cannot be discovered, the path cannot be inspected, or
128/// the file cannot be read.
129pub 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
532/// # Errors
533///
534/// Returns an error when a hunk header is malformed or line numbers cannot be parsed.
535pub 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}