Skip to main content

parley/git/
diff.rs

1use std::path::Path;
2
3use anyhow::{Context, Result, anyhow};
4use git2::{Commit, DiffFormat, DiffOptions, Repository};
5use tracing::{debug, info};
6
7use crate::domain::config::AppConfig;
8use crate::domain::diff::{DiffDocument, DiffFile, DiffHunk, DiffLine, DiffLineKind};
9
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub enum DiffSource {
12    #[default]
13    WorkingTree,
14    Commit {
15        rev: String,
16    },
17    Range {
18        base: String,
19        head: String,
20    },
21}
22
23impl DiffSource {
24    pub fn working_tree() -> Self {
25        Self::WorkingTree
26    }
27}
28
29pub async fn load_git_diff(config: &AppConfig, source: &DiffSource) -> Result<DiffDocument> {
30    debug!(?source, "loading git diff");
31    let config = config.clone();
32    let source = source.clone();
33    let source_for_worker = source.clone();
34    let document =
35        tokio::task::spawn_blocking(move || load_git_diff_sync(config, source_for_worker))
36            .await
37            .context("failed to join git2 diff worker")??;
38    info!(files = document.files.len(), ?source, "git diff loaded");
39    Ok(document)
40}
41
42pub async fn load_git_diff_head(config: &AppConfig) -> Result<DiffDocument> {
43    load_git_diff(config, &DiffSource::WorkingTree).await
44}
45
46fn load_git_diff_sync(config: AppConfig, source: DiffSource) -> Result<DiffDocument> {
47    let repo = Repository::discover(".").context("failed to discover git repository")?;
48    load_git_diff_for_repo(&repo, &config, &source)
49}
50
51fn load_git_diff_for_repo(
52    repo: &Repository,
53    config: &AppConfig,
54    source: &DiffSource,
55) -> Result<DiffDocument> {
56    let text = load_diff_text(repo, source)?;
57    let mut document = parse_unified_diff(&text)?;
58    let ignore_repo = matches!(source, DiffSource::WorkingTree).then_some(repo);
59    filter_ignored_files(&mut document, config, ignore_repo)?;
60    Ok(document)
61}
62
63fn load_diff_text(repo: &Repository, source: &DiffSource) -> Result<String> {
64    let mut diff_opts = DiffOptions::new();
65    diff_opts.context_lines(3).include_typechange(true);
66
67    let diff = match source {
68        DiffSource::WorkingTree => {
69            configure_worktree_diff_options(&mut diff_opts);
70            let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
71            repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
72                .context("failed to compute repository diff")?
73        }
74        DiffSource::Commit { rev } => {
75            let commit = resolve_commit(repo, rev)?;
76            let new_tree = commit.tree().context("failed to read commit tree")?;
77            let old_tree = commit
78                .parent(0)
79                .ok()
80                .map(|parent| parent.tree().context("failed to read parent tree"))
81                .transpose()?;
82            repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_opts))
83                .with_context(|| format!("failed to diff commit {rev}"))?
84        }
85        DiffSource::Range { base, head } => {
86            let base_tree = resolve_commit(repo, base)?
87                .tree()
88                .with_context(|| format!("failed to read base tree for {base}"))?;
89            let head_tree = resolve_commit(repo, head)?
90                .tree()
91                .with_context(|| format!("failed to read head tree for {head}"))?;
92            repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut diff_opts))
93                .with_context(|| format!("failed to diff range {base}..{head}"))?
94        }
95    };
96
97    render_diff_text(diff)
98}
99
100fn configure_worktree_diff_options(diff_opts: &mut DiffOptions) {
101    diff_opts
102        .include_untracked(true)
103        .recurse_untracked_dirs(true)
104        .show_untracked_content(true);
105}
106
107fn render_diff_text(diff: git2::Diff<'_>) -> Result<String> {
108    let mut patch_bytes = Vec::new();
109    diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
110        match line.origin() {
111            '+' | '-' | ' ' => patch_bytes.push(line.origin() as u8),
112            _ => {}
113        }
114        patch_bytes.extend_from_slice(line.content());
115        true
116    })
117    .context("failed to render patch text")?;
118
119    String::from_utf8(patch_bytes).context("git2 patch output is not utf-8")
120}
121
122fn resolve_commit<'repo>(repo: &'repo Repository, rev: &str) -> Result<Commit<'repo>> {
123    repo.revparse_single(rev)
124        .with_context(|| format!("failed to resolve revision {rev}"))?
125        .peel_to_commit()
126        .with_context(|| format!("revision {rev} does not resolve to a commit"))
127}
128
129pub fn parse_unified_diff(text: &str) -> Result<DiffDocument> {
130    let mut files = Vec::new();
131
132    let mut current_file: Option<DiffFile> = None;
133    let mut current_hunk: Option<DiffHunk> = None;
134    let mut old_cursor: u32 = 0;
135    let mut new_cursor: u32 = 0;
136
137    for line in text.lines() {
138        if line.starts_with("diff --git ") {
139            if let Some(hunk) = current_hunk.take()
140                && let Some(file) = current_file.as_mut()
141            {
142                file.hunks.push(hunk);
143            }
144            if let Some(file) = current_file.take() {
145                files.push(file);
146            }
147            current_file = Some(DiffFile {
148                path: parse_diff_git_path(line).unwrap_or_default(),
149                header_lines: vec![line.to_string()],
150                hunks: Vec::new(),
151            });
152            continue;
153        }
154
155        if line.starts_with("@@") {
156            if current_file.is_none() {
157                current_file = Some(DiffFile {
158                    path: String::new(),
159                    header_lines: Vec::new(),
160                    hunks: Vec::new(),
161                });
162            }
163
164            if let Some(hunk) = current_hunk.take()
165                && let Some(file) = current_file.as_mut()
166            {
167                file.hunks.push(hunk);
168            }
169
170            let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?;
171            old_cursor = old_start;
172            new_cursor = new_start;
173
174            let mut hunk = DiffHunk {
175                old_start,
176                old_count,
177                new_start,
178                new_count,
179                header: line.to_string(),
180                lines: Vec::new(),
181            };
182            hunk.lines.push(DiffLine {
183                kind: DiffLineKind::HunkHeader,
184                old_line: None,
185                new_line: None,
186                raw: line.to_string(),
187                code: line.to_string(),
188            });
189            current_hunk = Some(hunk);
190            continue;
191        }
192
193        if let Some(file) = current_file.as_mut()
194            && current_hunk.is_none()
195        {
196            if line.starts_with("+++ ") {
197                if let Some(path) = parse_patch_path(line, "+++ ") {
198                    file.path = path;
199                }
200                file.header_lines.push(line.to_string());
201                continue;
202            }
203
204            if line.starts_with("--- ") {
205                if file.path.is_empty()
206                    && let Some(path) = parse_patch_path(line, "--- ")
207                {
208                    file.path = path;
209                }
210                file.header_lines.push(line.to_string());
211                continue;
212            }
213
214            file.header_lines.push(line.to_string());
215            continue;
216        }
217
218        if let Some(hunk) = current_hunk.as_mut() {
219            let parsed = if let Some(code) = line.strip_prefix('+') {
220                let line_value = DiffLine {
221                    kind: DiffLineKind::Added,
222                    old_line: None,
223                    new_line: Some(new_cursor),
224                    raw: line.to_string(),
225                    code: code.to_string(),
226                };
227                new_cursor += 1;
228                line_value
229            } else if let Some(code) = line.strip_prefix('-') {
230                let line_value = DiffLine {
231                    kind: DiffLineKind::Removed,
232                    old_line: Some(old_cursor),
233                    new_line: None,
234                    raw: line.to_string(),
235                    code: code.to_string(),
236                };
237                old_cursor += 1;
238                line_value
239            } else if let Some(code) = line.strip_prefix(' ') {
240                let line_value = DiffLine {
241                    kind: DiffLineKind::Context,
242                    old_line: Some(old_cursor),
243                    new_line: Some(new_cursor),
244                    raw: line.to_string(),
245                    code: code.to_string(),
246                };
247                old_cursor += 1;
248                new_cursor += 1;
249                line_value
250            } else {
251                DiffLine {
252                    kind: DiffLineKind::Meta,
253                    old_line: None,
254                    new_line: None,
255                    raw: line.to_string(),
256                    code: line.to_string(),
257                }
258            };
259
260            hunk.lines.push(parsed);
261        }
262    }
263
264    if let Some(hunk) = current_hunk.take()
265        && let Some(file) = current_file.as_mut()
266    {
267        file.hunks.push(hunk);
268    }
269
270    if let Some(file) = current_file.take() {
271        files.push(file);
272    }
273
274    Ok(DiffDocument { files })
275}
276
277fn filter_ignored_files(
278    document: &mut DiffDocument,
279    config: &AppConfig,
280    repo: Option<&Repository>,
281) -> Result<()> {
282    if !config.ignore_parley_dir && repo.is_none() {
283        return Ok(());
284    }
285
286    let mut retained = Vec::with_capacity(document.files.len());
287    for file in document.files.drain(..) {
288        if should_ignore_file(&file.path, config, repo)? {
289            continue;
290        }
291        retained.push(file);
292    }
293    document.files = retained;
294    Ok(())
295}
296
297fn is_parley_internal_path(path: &str) -> bool {
298    path == ".parley" || path.starts_with(".parley/")
299}
300
301fn should_ignore_file(path: &str, config: &AppConfig, repo: Option<&Repository>) -> Result<bool> {
302    if config.ignore_parley_dir && is_parley_internal_path(path) {
303        return Ok(true);
304    }
305
306    let Some(repo) = repo else {
307        return Ok(false);
308    };
309    repo.status_should_ignore(Path::new(path))
310        .with_context(|| format!("failed to evaluate gitignore rules for {path}"))
311}
312
313fn parse_hunk_header(line: &str) -> Result<(u32, u32, u32, u32)> {
314    let Some(rest) = line.strip_prefix("@@ -") else {
315        return Err(anyhow!("invalid hunk header format: {line}"));
316    };
317    let Some((left, right_tail)) = rest.split_once(" +") else {
318        return Err(anyhow!("invalid hunk header body: {line}"));
319    };
320    let Some((right, _tail)) = right_tail.split_once(" @@") else {
321        return Err(anyhow!("invalid hunk header end: {line}"));
322    };
323
324    let (old_start, old_count) = parse_range(left)?;
325    let (new_start, new_count) = parse_range(right)?;
326    Ok((old_start, old_count, new_start, new_count))
327}
328
329fn parse_range(value: &str) -> Result<(u32, u32)> {
330    if let Some((start, count)) = value.split_once(',') {
331        Ok((start.parse()?, count.parse()?))
332    } else {
333        Ok((value.parse()?, 1))
334    }
335}
336
337fn parse_patch_path(line: &str, marker: &str) -> Option<String> {
338    let raw = line.strip_prefix(marker)?.trim();
339    parse_diff_path(raw)
340}
341
342fn parse_diff_git_path(line: &str) -> Option<String> {
343    let raw = line.strip_prefix("diff --git ")?;
344    let (_, right) = split_diff_paths(raw)?;
345    parse_diff_path(right)
346}
347
348fn split_diff_paths(raw: &str) -> Option<(&str, &str)> {
349    let raw = raw.trim();
350    if raw.is_empty() {
351        return None;
352    }
353
354    if let Some(rest) = raw.strip_prefix('"') {
355        let end_left = rest.find('"')?;
356        let left = &raw[..=end_left + 1];
357        let rest = rest[end_left + 1..].trim_start();
358        let rest = rest.strip_prefix('"')?;
359        let end_right = rest.find('"')?;
360        let right = &rest[..=end_right];
361        return Some((left, right));
362    }
363
364    let (left, right) = raw.split_once(' ')?;
365    Some((left, right.trim_start()))
366}
367
368fn parse_diff_path(raw: &str) -> Option<String> {
369    let raw = raw.trim();
370    if raw == "/dev/null" {
371        return None;
372    }
373
374    let unquoted = raw
375        .strip_prefix('"')
376        .and_then(|v| v.strip_suffix('"'))
377        .unwrap_or(raw);
378    let normalized = unquoted
379        .strip_prefix("a/")
380        .or_else(|| unquoted.strip_prefix("b/"))
381        .unwrap_or(unquoted);
382    Some(normalized.to_string())
383}
384
385#[cfg(test)]
386mod tests {
387    use std::fs;
388
389    use git2::{Oid, Repository, Signature};
390    use tempfile::tempdir;
391
392    use crate::domain::{config::AppConfig, diff::DiffLineKind};
393
394    use super::{DiffSource, filter_ignored_files, load_git_diff_for_repo, parse_unified_diff};
395
396    #[test]
397    fn parse_unified_diff_should_parse_added_and_removed_lines_with_numbers() {
398        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";
399
400        let doc = parse_unified_diff(input).expect("diff should parse");
401
402        assert_eq!(doc.files.len(), 1);
403        assert_eq!(doc.files[0].path, "src/lib.rs");
404        assert!(
405            doc.files[0]
406                .header_lines
407                .iter()
408                .any(|line| line.starts_with("index "))
409        );
410        assert_eq!(doc.files[0].hunks.len(), 1);
411        let hunk = &doc.files[0].hunks[0];
412        assert_eq!(hunk.lines[0].kind, DiffLineKind::HunkHeader);
413        assert_eq!(hunk.lines[2].kind, DiffLineKind::Removed);
414        assert_eq!(hunk.lines[2].old_line, Some(2));
415        assert_eq!(hunk.lines[2].new_line, None);
416        assert_eq!(hunk.lines[3].kind, DiffLineKind::Added);
417        assert_eq!(hunk.lines[3].old_line, None);
418        assert_eq!(hunk.lines[3].new_line, Some(2));
419    }
420
421    #[test]
422    fn parse_unified_diff_should_use_old_path_for_deleted_files() {
423        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";
424
425        let doc = parse_unified_diff(input).expect("diff should parse");
426
427        assert_eq!(doc.files.len(), 1);
428        assert_eq!(doc.files[0].path, "src/old.rs");
429    }
430
431    #[test]
432    fn parse_unified_diff_should_parse_quoted_paths() {
433        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";
434
435        let doc = parse_unified_diff(input).expect("diff should parse");
436
437        assert_eq!(doc.files.len(), 1);
438        assert_eq!(doc.files[0].path, "src/with space.rs");
439    }
440
441    #[test]
442    fn parse_unified_diff_should_use_diff_header_path_for_binary_new_files() {
443        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";
444
445        let doc = parse_unified_diff(input).expect("diff should parse");
446
447        assert_eq!(doc.files.len(), 1);
448        assert_eq!(doc.files[0].path, "src-tauri/icons/128x128.png");
449        assert!(doc.files[0].hunks.is_empty());
450    }
451
452    #[test]
453    fn filter_ignored_files_removes_parley_entries_by_default() {
454        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";
455        let mut doc = parse_unified_diff(input).expect("diff should parse");
456
457        filter_ignored_files(&mut doc, &AppConfig::default(), None).expect("filter should work");
458
459        assert_eq!(doc.files.len(), 1);
460        assert_eq!(doc.files[0].path, "src/lib.rs");
461    }
462
463    #[test]
464    fn filter_ignored_files_can_keep_parley_entries_when_configured() {
465        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";
466        let mut doc = parse_unified_diff(input).expect("diff should parse");
467        let config = AppConfig {
468            ignore_parley_dir: false,
469            ..AppConfig::default()
470        };
471
472        filter_ignored_files(&mut doc, &config, None).expect("filter should work");
473
474        assert_eq!(doc.files.len(), 1);
475        assert_eq!(doc.files[0].path, ".parley/config.toml");
476    }
477
478    #[test]
479    fn filter_ignored_files_removes_gitignored_paths() {
480        let temp = tempdir().expect("tempdir should exist");
481        let repo = Repository::init(temp.path()).expect("repo should init");
482        fs::write(
483            temp.path().join(".gitignore"),
484            "ignored.txt\nignored-dir/\n",
485        )
486        .expect("gitignore should write");
487        fs::write(temp.path().join("ignored.txt"), "ignored\n").expect("ignored file should write");
488        fs::create_dir_all(temp.path().join("ignored-dir")).expect("ignored dir should create");
489        fs::write(temp.path().join("ignored-dir/file.txt"), "ignored\n")
490            .expect("ignored nested file should write");
491        fs::write(temp.path().join("tracked.txt"), "tracked\n").expect("tracked file should write");
492
493        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";
494        let mut doc = parse_unified_diff(input).expect("diff should parse");
495
496        filter_ignored_files(&mut doc, &AppConfig::default(), Some(&repo))
497            .expect("filter should work");
498
499        assert_eq!(doc.files.len(), 1);
500        assert_eq!(doc.files[0].path, "tracked.txt");
501    }
502
503    #[test]
504    fn load_git_diff_for_commit_uses_first_parent_diff() {
505        let temp = tempdir().expect("tempdir should exist");
506        let repo = Repository::init(temp.path()).expect("repo should init");
507
508        let first = commit_file(&repo, temp.path(), "src/lib.rs", "fn first() {}\n", "first");
509        let second = commit_file(
510            &repo,
511            temp.path(),
512            "src/lib.rs",
513            "fn second() {}\n",
514            "second",
515        );
516
517        let doc = load_git_diff_for_repo(
518            &repo,
519            &AppConfig::default(),
520            &DiffSource::Commit {
521                rev: second.to_string(),
522            },
523        )
524        .expect("commit diff should load");
525
526        assert_eq!(doc.files.len(), 1);
527        assert_eq!(doc.files[0].path, "src/lib.rs");
528        let lines = &doc.files[0].hunks[0].lines;
529        assert!(lines.iter().any(|line| line.raw == "-fn first() {}"));
530        assert!(lines.iter().any(|line| line.raw == "+fn second() {}"));
531
532        let root_doc = load_git_diff_for_repo(
533            &repo,
534            &AppConfig::default(),
535            &DiffSource::Commit {
536                rev: first.to_string(),
537            },
538        )
539        .expect("root commit diff should load");
540
541        assert_eq!(root_doc.files.len(), 1);
542        assert!(
543            root_doc.files[0]
544                .hunks
545                .iter()
546                .flat_map(|hunk| hunk.lines.iter())
547                .any(|line| line.raw == "+fn first() {}")
548        );
549    }
550
551    #[test]
552    fn load_git_diff_for_range_uses_explicit_base_and_head() {
553        let temp = tempdir().expect("tempdir should exist");
554        let repo = Repository::init(temp.path()).expect("repo should init");
555
556        let base = commit_file(&repo, temp.path(), "src/lib.rs", "fn one() {}\n", "one");
557        let _middle = commit_file(&repo, temp.path(), "src/lib.rs", "fn two() {}\n", "two");
558        let head = commit_file(&repo, temp.path(), "src/lib.rs", "fn three() {}\n", "three");
559
560        let doc = load_git_diff_for_repo(
561            &repo,
562            &AppConfig::default(),
563            &DiffSource::Range {
564                base: base.to_string(),
565                head: head.to_string(),
566            },
567        )
568        .expect("range diff should load");
569
570        assert_eq!(doc.files.len(), 1);
571        let lines = &doc.files[0].hunks[0].lines;
572        assert!(lines.iter().any(|line| line.raw == "-fn one() {}"));
573        assert!(lines.iter().any(|line| line.raw == "+fn three() {}"));
574        assert!(!lines.iter().any(|line| line.raw == "+fn two() {}"));
575    }
576
577    fn commit_file(
578        repo: &Repository,
579        root: &std::path::Path,
580        relative_path: &str,
581        content: &str,
582        message: &str,
583    ) -> Oid {
584        let path = root.join(relative_path);
585        if let Some(parent) = path.parent() {
586            fs::create_dir_all(parent).expect("parent directory should exist");
587        }
588        fs::write(&path, content).expect("file should write");
589
590        let mut index = repo.index().expect("index should open");
591        index
592            .add_path(std::path::Path::new(relative_path))
593            .expect("path should stage");
594        index.write().expect("index should write");
595
596        let tree_oid = index.write_tree().expect("tree should write");
597        let tree = repo.find_tree(tree_oid).expect("tree should load");
598        let signature =
599            Signature::now("Parley Test", "parley@example.com").expect("signature should create");
600        let parents = repo
601            .head()
602            .ok()
603            .and_then(|head| head.target())
604            .map(|oid| repo.find_commit(oid).expect("parent commit should load"))
605            .into_iter()
606            .collect::<Vec<_>>();
607        let parent_refs = parents.iter().collect::<Vec<_>>();
608
609        repo.commit(
610            Some("HEAD"),
611            &signature,
612            &signature,
613            message,
614            &tree,
615            &parent_refs,
616        )
617        .expect("commit should succeed")
618    }
619}