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        for line in &self.lines {
448            match line {
449                Modification::Addition(a) => {
450                    table.push([
451                        term::Label::space()
452                            .pad(5)
453                            .bg(theme.color("positive"))
454                            .to_line()
455                            .filled(theme.color("positive")),
456                        term::label(a.line_no.to_string())
457                            .pad(5)
458                            .fg(theme.color("positive.light"))
459                            .to_line()
460                            .filled(theme.color("positive")),
461                        term::label(" + ")
462                            .fg(theme.color("positive.light"))
463                            .to_line()
464                            .filled(theme.color("positive.dark")),
465                        line.pretty(hi, blobs, repo)
466                            .filled(theme.color("positive.dark")),
467                        term::Line::blank().filled(term::Color::default()),
468                    ]);
469                }
470                Modification::Deletion(a) => {
471                    table.push([
472                        term::label(a.line_no.to_string())
473                            .pad(5)
474                            .fg(theme.color("negative.light"))
475                            .to_line()
476                            .filled(theme.color("negative")),
477                        term::Label::space()
478                            .pad(5)
479                            .fg(theme.color("dim"))
480                            .to_line()
481                            .filled(theme.color("negative")),
482                        term::label(" - ")
483                            .fg(theme.color("negative.light"))
484                            .to_line()
485                            .filled(theme.color("negative.dark")),
486                        line.pretty(hi, blobs, repo)
487                            .filled(theme.color("negative.dark")),
488                        term::Line::blank().filled(term::Color::default()),
489                    ]);
490                }
491                Modification::Context {
492                    line_no_old,
493                    line_no_new,
494                    ..
495                } => {
496                    table.push([
497                        term::label(line_no_old.to_string())
498                            .pad(5)
499                            .fg(theme.color("dim"))
500                            .to_line()
501                            .filled(theme.color("faint")),
502                        term::label(line_no_new.to_string())
503                            .pad(5)
504                            .fg(theme.color("dim"))
505                            .to_line()
506                            .filled(theme.color("faint")),
507                        term::label("   ").to_line().filled(term::Color::default()),
508                        line.pretty(hi, blobs, repo).filled(term::Color::default()),
509                        term::Line::blank().filled(term::Color::default()),
510                    ]);
511                }
512            }
513        }
514        vstack.push(table);
515        vstack
516    }
517}
518
519impl ToPretty for Modification {
520    type Output = term::Line;
521    type Context = Blobs<Vec<term::Line>>;
522
523    fn pretty<R: Repo>(
524        &self,
525        _hi: &mut Highlighter,
526        blobs: &Blobs<Vec<term::Line>>,
527        _repo: &R,
528    ) -> Self::Output {
529        match self {
530            Modification::Deletion(diff::Deletion { line, line_no }) => {
531                if let Some(lines) = &blobs.old.as_ref() {
532                    lines[*line_no as usize - 1].clone()
533                } else {
534                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
535                }
536            }
537            Modification::Addition(diff::Addition { line, line_no }) => {
538                if let Some(lines) = &blobs.new.as_ref() {
539                    lines[*line_no as usize - 1].clone()
540                } else {
541                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
542                }
543            }
544            Modification::Context {
545                line, line_no_new, ..
546            } => {
547                // Nb. we can check in the old or the new blob, we choose the new.
548                if let Some(lines) = &blobs.new.as_ref() {
549                    lines[*line_no_new as usize - 1].clone()
550                } else {
551                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
552                }
553            }
554        }
555    }
556}
557
558/// Render a file added, deleted or modified.
559fn pretty_modification<R: Repo>(
560    header: &FileHeader,
561    diff: &DiffContent,
562    old: Option<(&Path, Oid)>,
563    new: Option<(&Path, Oid)>,
564    repo: &R,
565    hi: &mut Highlighter,
566) -> VStack<'static> {
567    let blobs = Blobs::from_paths(old, new, repo);
568    let header = header.pretty(hi, &diff.stats().copied(), repo);
569    let vstack = term::VStack::default()
570        .border(Some(term::colors::FAINT))
571        .padding(1)
572        .child(header);
573
574    let body = diff.pretty(hi, &blobs, repo);
575    if body.is_empty() {
576        vstack
577    } else {
578        vstack.divider().merge(body)
579    }
580}
581
582#[cfg(test)]
583mod test {
584    use std::ffi::OsStr;
585
586    use term::Constraint;
587    use term::Element;
588
589    use super::*;
590    use radicle::git::raw::RepositoryOpenFlags;
591    use radicle::git::raw::{Oid, Repository};
592
593    #[test]
594    #[ignore]
595    fn test_pretty() {
596        let repo = Repository::open_ext::<_, _, &[&OsStr]>(
597            env!("CARGO_MANIFEST_DIR"),
598            RepositoryOpenFlags::all(),
599            &[],
600        )
601        .unwrap();
602        let commit = repo
603            .find_commit(Oid::from_str("5078396028e2ec5660aa54a00208f6e11df84aa9").unwrap())
604            .unwrap();
605        let parent = commit.parents().next().unwrap();
606        let old_tree = parent.tree().unwrap();
607        let new_tree = commit.tree().unwrap();
608        let diff = repo
609            .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)
610            .unwrap();
611        let diff = Diff::try_from(diff).unwrap();
612
613        let mut hi = Highlighter::default();
614        let pretty = diff.pretty(&mut hi, &(), &repo);
615
616        pretty
617            .write(Constraint::from_env().unwrap_or_default())
618            .unwrap();
619    }
620}