radicle_cli/git/
pretty_diff.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use radicle::git;
5use radicle_git_ext::Oid;
6use radicle_surf::diff;
7use radicle_surf::diff::{Added, Copied, Deleted, FileStats, Hunks, Modified, Moved};
8use radicle_surf::diff::{Diff, DiffContent, FileDiff, Hunk, Modification};
9use radicle_term as term;
10use term::cell::Cell;
11use term::VStack;
12
13use crate::git::unified_diff::FileHeader;
14use crate::terminal::highlight::{Highlighter, Theme};
15
16use super::unified_diff::{Decode, HunkHeader};
17
18/// Blob returned by the [`Repo`] trait.
19#[derive(PartialEq, Eq, Debug)]
20pub enum Blob {
21    Binary,
22    Empty,
23    Plain(Vec<u8>),
24}
25
26/// A repository of Git blobs.
27pub trait Repo {
28    /// Lookup a blob from the repo.
29    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error>;
30    /// Lookup a file in the workdir.
31    fn file(&self, path: &Path) -> Option<Blob>;
32}
33
34impl Repo for git::raw::Repository {
35    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error> {
36        let blob = self.find_blob(*oid)?;
37
38        if blob.is_binary() {
39            Ok(Blob::Binary)
40        } else {
41            let content = blob.content();
42
43            if content.is_empty() {
44                Ok(Blob::Empty)
45            } else {
46                Ok(Blob::Plain(blob.content().to_vec()))
47            }
48        }
49    }
50
51    fn file(&self, path: &Path) -> Option<Blob> {
52        self.workdir()
53            .and_then(|dir| fs::read(dir.join(path)).ok())
54            .map(|content| {
55                // A file is considered binary if there is a zero byte in the first 8 kilobytes
56                // of the file. This is the same heuristic Git uses.
57                let binary = content.iter().take(8192).any(|b| *b == 0);
58                if binary {
59                    Blob::Binary
60                } else {
61                    Blob::Plain(content)
62                }
63            })
64    }
65}
66
67/// Blobs passed down to the hunk renderer.
68#[derive(Debug)]
69pub struct Blobs<T> {
70    pub old: Option<T>,
71    pub new: Option<T>,
72}
73
74impl<T> Blobs<T> {
75    pub fn new(old: Option<T>, new: Option<T>) -> Self {
76        Self { old, new }
77    }
78}
79
80impl Blobs<(PathBuf, Blob)> {
81    pub fn highlight(&self, hi: &mut Highlighter) -> Blobs<Vec<term::Line>> {
82        let mut blobs = Blobs::default();
83        if let Some((path, Blob::Plain(content))) = &self.old {
84            blobs.old = hi.highlight(path, content).ok();
85        }
86        if let Some((path, Blob::Plain(content))) = &self.new {
87            blobs.new = hi.highlight(path, content).ok();
88        }
89        blobs
90    }
91
92    pub fn from_paths<R: Repo>(
93        old: Option<(&Path, Oid)>,
94        new: Option<(&Path, Oid)>,
95        repo: &R,
96    ) -> Blobs<(PathBuf, Blob)> {
97        Blobs::new(
98            old.and_then(|(path, oid)| {
99                repo.blob(oid)
100                    .ok()
101                    .or_else(|| repo.file(path))
102                    .map(|blob| (path.to_path_buf(), blob))
103            }),
104            new.and_then(|(path, oid)| {
105                repo.blob(oid)
106                    .ok()
107                    .or_else(|| repo.file(path))
108                    .map(|blob| (path.to_path_buf(), blob))
109            }),
110        )
111    }
112}
113
114impl<T> Default for Blobs<T> {
115    fn default() -> Self {
116        Self {
117            old: None,
118            new: None,
119        }
120    }
121}
122
123/// Types that can be rendered as pretty diffs.
124pub trait ToPretty {
125    /// The output of the render process.
126    type Output: term::Element;
127    /// Context that can be passed down from parent objects during rendering.
128    type Context;
129
130    /// Render to pretty diff output.
131    fn pretty<R: Repo>(
132        &self,
133        hi: &mut Highlighter,
134        context: &Self::Context,
135        repo: &R,
136    ) -> Self::Output;
137}
138
139impl ToPretty for Diff {
140    type Output = term::VStack<'static>;
141    type Context = ();
142
143    fn pretty<R: Repo>(
144        &self,
145        hi: &mut Highlighter,
146        context: &Self::Context,
147        repo: &R,
148    ) -> Self::Output {
149        term::VStack::default()
150            .padding(0)
151            .children(self.files().flat_map(|f| {
152                [
153                    f.pretty(hi, context, repo).boxed(),
154                    term::Line::blank().boxed(), // Blank line between files.
155                ]
156            }))
157    }
158}
159
160impl ToPretty for FileHeader {
161    type Output = term::Line;
162    type Context = Option<FileStats>;
163
164    fn pretty<R: Repo>(
165        &self,
166        _hi: &mut Highlighter,
167        stats: &Self::Context,
168        _repo: &R,
169    ) -> Self::Output {
170        let theme = Theme::default();
171        let (mut header, badge, binary) = match self {
172            FileHeader::Added { path, binary, .. } => (
173                term::Line::new(path.display().to_string()),
174                Some(term::format::badge_positive("created")),
175                *binary,
176            ),
177            FileHeader::Moved {
178                old_path, new_path, ..
179            } => (
180                term::Line::spaced([
181                    term::label(old_path.display().to_string()),
182                    term::label("->".to_string()),
183                    term::label(new_path.display().to_string()),
184                ]),
185                Some(term::format::badge_secondary("moved")),
186                false,
187            ),
188            FileHeader::Deleted { path, binary, .. } => (
189                term::Line::new(path.display().to_string()),
190                Some(term::format::badge_negative("deleted")),
191                *binary,
192            ),
193            FileHeader::Modified {
194                path,
195                old,
196                new,
197                binary,
198                ..
199            } => {
200                if old.mode != new.mode {
201                    (
202                        term::Line::spaced([
203                            term::label(path.display().to_string()),
204                            term::label(format!("{:o}", u32::from(old.mode.clone())))
205                                .fg(term::Color::Blue),
206                            term::label("->".to_string()),
207                            term::label(format!("{:o}", u32::from(new.mode.clone())))
208                                .fg(term::Color::Blue),
209                        ]),
210                        Some(term::format::badge_secondary("mode changed")),
211                        *binary,
212                    )
213                } else {
214                    (term::Line::new(path.display().to_string()), None, *binary)
215                }
216            }
217            FileHeader::Copied {
218                old_path, new_path, ..
219            } => (
220                term::Line::spaced([
221                    term::label(old_path.display().to_string()),
222                    term::label("->".to_string()),
223                    term::label(new_path.display().to_string()),
224                ]),
225                Some(term::format::badge_secondary("copied")),
226                false,
227            ),
228        };
229
230        if binary {
231            header.push(term::Label::space());
232            header.push(term::label(term::format::badge_yellow("binary")));
233        }
234
235        let (additions, deletions) = if let Some(stats) = stats {
236            (stats.additions, stats.deletions)
237        } else {
238            (0, 0)
239        };
240        if deletions > 0 {
241            header.push(term::Label::space());
242            header.push(term::label(format!("-{deletions}")).fg(theme.color("negative.light")));
243        }
244        if additions > 0 {
245            header.push(term::Label::space());
246            header.push(term::label(format!("+{additions}")).fg(theme.color("positive.light")));
247        }
248        if let Some(badge) = badge {
249            header.push(term::Label::space());
250            header.push(badge);
251        }
252        header
253    }
254}
255
256impl ToPretty for FileDiff {
257    type Output = term::VStack<'static>;
258    type Context = ();
259
260    fn pretty<R: Repo>(
261        &self,
262        hi: &mut Highlighter,
263        _context: &Self::Context,
264        repo: &R,
265    ) -> Self::Output {
266        let header = FileHeader::from(self);
267
268        match self {
269            FileDiff::Added(f) => f.pretty(hi, &header, repo),
270            FileDiff::Deleted(f) => f.pretty(hi, &header, repo),
271            FileDiff::Modified(f) => f.pretty(hi, &header, repo),
272            FileDiff::Moved(f) => f.pretty(hi, &header, repo),
273            FileDiff::Copied(f) => f.pretty(hi, &header, repo),
274        }
275    }
276}
277
278impl ToPretty for DiffContent {
279    type Output = term::VStack<'static>;
280    type Context = Blobs<(PathBuf, Blob)>;
281
282    fn pretty<R: Repo>(
283        &self,
284        hi: &mut Highlighter,
285        blobs: &Self::Context,
286        repo: &R,
287    ) -> Self::Output {
288        let mut vstack = term::VStack::default().padding(0);
289
290        match self {
291            DiffContent::Plain {
292                hunks: Hunks(hunks),
293                ..
294            } => {
295                let blobs = blobs.highlight(hi);
296
297                for (i, h) in hunks.iter().enumerate() {
298                    vstack.push(h.pretty(hi, &blobs, repo));
299                    if i != hunks.len() - 1 {
300                        vstack = vstack.divider();
301                    }
302                }
303            }
304            DiffContent::Empty => {}
305            DiffContent::Binary => {}
306        }
307        vstack
308    }
309}
310
311impl ToPretty for Moved {
312    type Output = term::VStack<'static>;
313    type Context = FileHeader;
314
315    fn pretty<R: Repo>(
316        &self,
317        hi: &mut Highlighter,
318        header: &Self::Context,
319        repo: &R,
320    ) -> Self::Output {
321        let header = header.pretty(hi, &self.diff.stats().copied(), repo);
322
323        term::VStack::default()
324            .border(Some(term::colors::FAINT))
325            .padding(1)
326            .child(term::Line::default().extend(header))
327    }
328}
329
330impl ToPretty for Added {
331    type Output = term::VStack<'static>;
332    type Context = FileHeader;
333
334    fn pretty<R: Repo>(
335        &self,
336        hi: &mut Highlighter,
337        header: &Self::Context,
338        repo: &R,
339    ) -> Self::Output {
340        let old = None;
341        let new = Some((self.path.as_path(), self.new.oid));
342
343        pretty_modification(header, &self.diff, old, new, repo, hi)
344    }
345}
346
347impl ToPretty for Deleted {
348    type Output = term::VStack<'static>;
349    type Context = FileHeader;
350
351    fn pretty<R: Repo>(
352        &self,
353        hi: &mut Highlighter,
354        header: &Self::Context,
355        repo: &R,
356    ) -> Self::Output {
357        let old = Some((self.path.as_path(), self.old.oid));
358        let new = None;
359
360        pretty_modification(header, &self.diff, old, new, repo, hi)
361    }
362}
363
364impl ToPretty for Modified {
365    type Output = term::VStack<'static>;
366    type Context = FileHeader;
367
368    fn pretty<R: Repo>(
369        &self,
370        hi: &mut Highlighter,
371        header: &Self::Context,
372        repo: &R,
373    ) -> Self::Output {
374        let old = Some((self.path.as_path(), self.old.oid));
375        let new = Some((self.path.as_path(), self.new.oid));
376
377        pretty_modification(header, &self.diff, old, new, repo, hi)
378    }
379}
380
381impl ToPretty for Copied {
382    type Output = term::VStack<'static>;
383    type Context = FileHeader;
384
385    fn pretty<R: Repo>(
386        &self,
387        hi: &mut Highlighter,
388        _context: &Self::Context,
389        repo: &R,
390    ) -> Self::Output {
391        let header = FileHeader::Copied {
392            old_path: self.old_path.clone(),
393            new_path: self.old_path.clone(),
394        }
395        .pretty(hi, &self.diff.stats().copied(), repo);
396
397        term::VStack::default()
398            .border(Some(term::colors::FAINT))
399            .padding(1)
400            .child(header)
401    }
402}
403
404impl ToPretty for HunkHeader {
405    type Output = term::Line;
406    type Context = ();
407
408    fn pretty<R: Repo>(
409        &self,
410        _hi: &mut Highlighter,
411        _context: &Self::Context,
412        _repo: &R,
413    ) -> Self::Output {
414        term::Line::spaced([
415            term::label(format!(
416                "@@ -{},{} +{},{} @@",
417                self.old_line_no, self.old_size, self.new_line_no, self.new_size,
418            ))
419            .fg(term::colors::fixed::FAINT),
420            term::label(String::from_utf8_lossy(&self.text).to_string())
421                .fg(term::colors::fixed::DIM),
422        ])
423    }
424}
425
426impl ToPretty for Hunk<Modification> {
427    type Output = term::VStack<'static>;
428    type Context = Blobs<Vec<term::Line>>;
429
430    fn pretty<R: Repo>(
431        &self,
432        hi: &mut Highlighter,
433        blobs: &Self::Context,
434        repo: &R,
435    ) -> Self::Output {
436        let mut vstack = term::VStack::default().padding(0);
437        let mut table = term::Table::<5, term::Filled<term::Line>>::new(term::TableOptions {
438            overflow: false,
439            spacing: 0,
440            border: None,
441        });
442        let theme = Theme::default();
443
444        if let Ok(header) = HunkHeader::from_bytes(self.header.as_bytes()) {
445            vstack.push(header.pretty(hi, &(), repo));
446        }
447
448        table.extend(
449            self.lines
450                .iter()
451                .map(|line| line_to_table_row(hi, blobs, repo, &theme, line)),
452        );
453
454        vstack.push(table);
455        vstack
456    }
457}
458
459fn line_to_table_row<R: Repo>(
460    hi: &mut Highlighter,
461    blobs: &Blobs<Vec<radicle_term::Line>>,
462    repo: &R,
463    theme: &Theme,
464    line: &Modification,
465) -> [radicle_term::Filled<radicle_term::Line>; 5] {
466    match line {
467        Modification::Addition(a) => [
468            term::Label::space()
469                .pad(5)
470                .bg(theme.color("positive"))
471                .to_line()
472                .filled(theme.color("positive")),
473            term::label(a.line_no.to_string())
474                .pad(5)
475                .fg(theme.color("positive.light"))
476                .to_line()
477                .filled(theme.color("positive")),
478            term::label(" + ")
479                .fg(theme.color("positive.light"))
480                .to_line()
481                .filled(theme.color("positive.dark")),
482            line.pretty(hi, blobs, repo)
483                .filled(theme.color("positive.dark")),
484            term::Line::blank().filled(term::Color::default()),
485        ],
486        Modification::Deletion(a) => [
487            term::label(a.line_no.to_string())
488                .pad(5)
489                .fg(theme.color("negative.light"))
490                .to_line()
491                .filled(theme.color("negative")),
492            term::Label::space()
493                .pad(5)
494                .fg(theme.color("dim"))
495                .to_line()
496                .filled(theme.color("negative")),
497            term::label(" - ")
498                .fg(theme.color("negative.light"))
499                .to_line()
500                .filled(theme.color("negative.dark")),
501            line.pretty(hi, blobs, repo)
502                .filled(theme.color("negative.dark")),
503            term::Line::blank().filled(term::Color::default()),
504        ],
505        Modification::Context {
506            line_no_old,
507            line_no_new,
508            ..
509        } => [
510            term::label(line_no_old.to_string())
511                .pad(5)
512                .fg(theme.color("dim"))
513                .to_line()
514                .filled(theme.color("faint")),
515            term::label(line_no_new.to_string())
516                .pad(5)
517                .fg(theme.color("dim"))
518                .to_line()
519                .filled(theme.color("faint")),
520            term::label("   ").to_line().filled(term::Color::default()),
521            line.pretty(hi, blobs, repo).filled(term::Color::default()),
522            term::Line::blank().filled(term::Color::default()),
523        ],
524    }
525}
526
527impl ToPretty for Modification {
528    type Output = term::Line;
529    type Context = Blobs<Vec<term::Line>>;
530
531    fn pretty<R: Repo>(
532        &self,
533        _hi: &mut Highlighter,
534        blobs: &Blobs<Vec<term::Line>>,
535        _repo: &R,
536    ) -> Self::Output {
537        match self {
538            Modification::Deletion(diff::Deletion { line, line_no }) => {
539                if let Some(lines) = &blobs.old.as_ref() {
540                    lines[*line_no as usize - 1].clone()
541                } else {
542                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
543                }
544            }
545            Modification::Addition(diff::Addition { line, line_no }) => {
546                if let Some(lines) = &blobs.new.as_ref() {
547                    lines[*line_no as usize - 1].clone()
548                } else {
549                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
550                }
551            }
552            Modification::Context {
553                line, line_no_new, ..
554            } => {
555                // Nb. we can check in the old or the new blob, we choose the new.
556                if let Some(lines) = &blobs.new.as_ref() {
557                    lines[*line_no_new as usize - 1].clone()
558                } else {
559                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
560                }
561            }
562        }
563    }
564}
565
566/// Render a file added, deleted or modified.
567fn pretty_modification<R: Repo>(
568    header: &FileHeader,
569    diff: &DiffContent,
570    old: Option<(&Path, Oid)>,
571    new: Option<(&Path, Oid)>,
572    repo: &R,
573    hi: &mut Highlighter,
574) -> VStack<'static> {
575    let blobs = Blobs::from_paths(old, new, repo);
576    let header = header.pretty(hi, &diff.stats().copied(), repo);
577    let vstack = term::VStack::default()
578        .border(Some(term::colors::FAINT))
579        .padding(1)
580        .child(header);
581
582    let body = diff.pretty(hi, &blobs, repo);
583    if body.is_empty() {
584        vstack
585    } else {
586        vstack.divider().merge(body)
587    }
588}
589
590#[cfg(test)]
591mod test {
592    use std::ffi::OsStr;
593
594    use term::Constraint;
595    use term::Element;
596
597    use super::*;
598    use radicle::git::raw::RepositoryOpenFlags;
599    use radicle::git::raw::{Oid, Repository};
600
601    #[test]
602    #[ignore]
603    fn test_pretty() {
604        let repo = Repository::open_ext::<_, _, &[&OsStr]>(
605            env!("CARGO_MANIFEST_DIR"),
606            RepositoryOpenFlags::all(),
607            &[],
608        )
609        .unwrap();
610        let commit = repo
611            .find_commit(Oid::from_str("5078396028e2ec5660aa54a00208f6e11df84aa9").unwrap())
612            .unwrap();
613        let parent = commit.parents().next().unwrap();
614        let old_tree = parent.tree().unwrap();
615        let new_tree = commit.tree().unwrap();
616        let diff = repo
617            .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)
618            .unwrap();
619        let diff = Diff::try_from(diff).unwrap();
620
621        let mut hi = Highlighter::default();
622        let pretty = diff.pretty(&mut hi, &(), &repo);
623
624        pretty
625            .write(Constraint::from_env().unwrap_or_default())
626            .unwrap();
627    }
628}