radicle_surf/diff/
git.rs

1// This file is part of radicle-surf
2// <https://github.com/radicle-dev/radicle-surf>
3//
4// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
5//
6// This program is free software: you can redistribute it and/or modify
7// it under the terms of the GNU General Public License version 3 or
8// later as published by the Free Software Foundation.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18use std::convert::TryFrom;
19
20use super::{
21    Diff, DiffContent, DiffFile, EofNewLine, FileMode, FileStats, Hunk, Hunks, Line, Modification,
22    Stats,
23};
24
25pub mod error {
26    use std::path::PathBuf;
27
28    use thiserror::Error;
29
30    #[derive(Debug, Error)]
31    #[non_exhaustive]
32    pub enum Addition {
33        #[error(transparent)]
34        Git(#[from] git2::Error),
35        #[error("the new line number was missing for an added line")]
36        MissingNewLineNo,
37    }
38
39    #[derive(Debug, Error)]
40    #[non_exhaustive]
41    pub enum Deletion {
42        #[error(transparent)]
43        Git(#[from] git2::Error),
44        #[error("the new line number was missing for an deleted line")]
45        MissingOldLineNo,
46    }
47
48    #[derive(Debug, Error)]
49    #[non_exhaustive]
50    pub enum FileMode {
51        #[error("unknown file mode `{0:?}`")]
52        Unknown(git2::FileMode),
53    }
54
55    #[derive(Debug, Error)]
56    #[non_exhaustive]
57    pub enum Modification {
58        /// A Git `DiffLine` is invalid.
59        #[error(
60            "invalid `git2::DiffLine` which contains no line numbers for either side of the diff"
61        )]
62        Invalid,
63    }
64
65    #[derive(Debug, Error)]
66    #[non_exhaustive]
67    pub enum Hunk {
68        #[error(transparent)]
69        Git(#[from] git2::Error),
70        #[error(transparent)]
71        Line(#[from] Modification),
72    }
73
74    /// A Git diff error.
75    #[derive(Debug, Error)]
76    #[non_exhaustive]
77    pub enum Diff {
78        #[error(transparent)]
79        Addition(#[from] Addition),
80        #[error(transparent)]
81        Deletion(#[from] Deletion),
82        /// A Git delta type isn't currently handled.
83        #[error("git delta type is not handled")]
84        DeltaUnhandled(git2::Delta),
85        #[error(transparent)]
86        Git(#[from] git2::Error),
87        #[error(transparent)]
88        FileMode(#[from] FileMode),
89        #[error(transparent)]
90        Hunk(#[from] Hunk),
91        #[error(transparent)]
92        Line(#[from] Modification),
93        /// A patch is unavailable.
94        #[error("couldn't retrieve patch for {0}")]
95        PatchUnavailable(PathBuf),
96        /// A The path of a file isn't available.
97        #[error("couldn't retrieve file path")]
98        PathUnavailable,
99    }
100}
101
102impl TryFrom<git2::DiffFile<'_>> for DiffFile {
103    type Error = error::FileMode;
104
105    fn try_from(value: git2::DiffFile) -> Result<Self, Self::Error> {
106        Ok(Self {
107            mode: value.mode().try_into()?,
108            oid: value.id().into(),
109        })
110    }
111}
112
113impl TryFrom<git2::FileMode> for FileMode {
114    type Error = error::FileMode;
115
116    fn try_from(value: git2::FileMode) -> Result<Self, Self::Error> {
117        match value {
118            git2::FileMode::Blob => Ok(Self::Blob),
119            git2::FileMode::BlobExecutable => Ok(Self::BlobExecutable),
120            git2::FileMode::Commit => Ok(Self::Commit),
121            git2::FileMode::Tree => Ok(Self::Tree),
122            git2::FileMode::Link => Ok(Self::Link),
123            _ => Err(error::FileMode::Unknown(value)),
124        }
125    }
126}
127
128impl From<FileMode> for git2::FileMode {
129    fn from(m: FileMode) -> Self {
130        match m {
131            FileMode::Blob => git2::FileMode::Blob,
132            FileMode::BlobExecutable => git2::FileMode::BlobExecutable,
133            FileMode::Tree => git2::FileMode::Tree,
134            FileMode::Link => git2::FileMode::Link,
135            FileMode::Commit => git2::FileMode::Commit,
136        }
137    }
138}
139
140impl TryFrom<git2::Patch<'_>> for DiffContent {
141    type Error = error::Hunk;
142
143    fn try_from(patch: git2::Patch) -> Result<Self, Self::Error> {
144        let mut hunks = Vec::new();
145        let mut old_missing_eof = false;
146        let mut new_missing_eof = false;
147        let mut additions = 0;
148        let mut deletions = 0;
149
150        for h in 0..patch.num_hunks() {
151            let (hunk, hunk_lines) = patch.hunk(h)?;
152            let header = Line(hunk.header().to_owned());
153            let mut lines: Vec<Modification> = Vec::new();
154
155            for l in 0..hunk_lines {
156                let line = patch.line_in_hunk(h, l)?;
157                match line.origin_value() {
158                    git2::DiffLineType::ContextEOFNL => {
159                        new_missing_eof = true;
160                        old_missing_eof = true;
161                        continue;
162                    }
163                    git2::DiffLineType::Addition => {
164                        additions += 1;
165                    }
166                    git2::DiffLineType::Deletion => {
167                        deletions += 1;
168                    }
169                    git2::DiffLineType::AddEOFNL => {
170                        additions += 1;
171                        old_missing_eof = true;
172                        continue;
173                    }
174                    git2::DiffLineType::DeleteEOFNL => {
175                        deletions += 1;
176                        new_missing_eof = true;
177                        continue;
178                    }
179                    _ => {}
180                }
181                let line = Modification::try_from(line)?;
182                lines.push(line);
183            }
184            hunks.push(Hunk {
185                header,
186                lines,
187                old: hunk.old_start()..hunk.old_start() + hunk.old_lines(),
188                new: hunk.new_start()..hunk.new_start() + hunk.new_lines(),
189            });
190        }
191        let eof = match (old_missing_eof, new_missing_eof) {
192            (true, true) => EofNewLine::BothMissing,
193            (true, false) => EofNewLine::OldMissing,
194            (false, true) => EofNewLine::NewMissing,
195            (false, false) => EofNewLine::NoneMissing,
196        };
197        Ok(DiffContent::Plain {
198            hunks: Hunks(hunks),
199            stats: FileStats {
200                additions,
201                deletions,
202            },
203            eof,
204        })
205    }
206}
207
208impl TryFrom<git2::DiffLine<'_>> for Modification {
209    type Error = error::Modification;
210
211    fn try_from(line: git2::DiffLine) -> Result<Self, Self::Error> {
212        match (line.old_lineno(), line.new_lineno()) {
213            (None, Some(n)) => Ok(Self::addition(line.content().to_owned(), n)),
214            (Some(n), None) => Ok(Self::deletion(line.content().to_owned(), n)),
215            (Some(l), Some(r)) => Ok(Self::context(line.content().to_owned(), l, r)),
216            (None, None) => Err(error::Modification::Invalid),
217        }
218    }
219}
220
221impl From<git2::DiffStats> for Stats {
222    fn from(stats: git2::DiffStats) -> Self {
223        Self {
224            files_changed: stats.files_changed(),
225            insertions: stats.insertions(),
226            deletions: stats.deletions(),
227        }
228    }
229}
230
231impl TryFrom<git2::Diff<'_>> for Diff {
232    type Error = error::Diff;
233
234    fn try_from(git_diff: git2::Diff) -> Result<Diff, Self::Error> {
235        use git2::Delta;
236
237        let mut diff = Diff::new();
238
239        // This allows libgit2 to run the binary detection.
240        // Reference: <https://github.com/libgit2/libgit2/issues/6637>
241        git_diff.foreach(&mut |_, _| true, None, None, None)?;
242
243        for (idx, delta) in git_diff.deltas().enumerate() {
244            match delta.status() {
245                Delta::Added => created(&mut diff, &git_diff, idx, &delta)?,
246                Delta::Deleted => deleted(&mut diff, &git_diff, idx, &delta)?,
247                Delta::Modified => modified(&mut diff, &git_diff, idx, &delta)?,
248                Delta::Renamed => renamed(&mut diff, &git_diff, idx, &delta)?,
249                Delta::Copied => copied(&mut diff, &git_diff, idx, &delta)?,
250                status => {
251                    return Err(error::Diff::DeltaUnhandled(status));
252                }
253            }
254        }
255
256        Ok(diff)
257    }
258}
259
260fn created(
261    diff: &mut Diff,
262    git_diff: &git2::Diff<'_>,
263    idx: usize,
264    delta: &git2::DiffDelta<'_>,
265) -> Result<(), error::Diff> {
266    let diff_file = delta.new_file();
267    let is_binary = diff_file.is_binary();
268    let path = diff_file
269        .path()
270        .ok_or(error::Diff::PathUnavailable)?
271        .to_path_buf();
272    let new = DiffFile::try_from(diff_file)?;
273
274    let patch = git2::Patch::from_diff(git_diff, idx)?;
275    if is_binary {
276        diff.insert_added(path, DiffContent::Binary, new);
277    } else if let Some(patch) = patch {
278        diff.insert_added(path, DiffContent::try_from(patch)?, new);
279    } else {
280        return Err(error::Diff::PatchUnavailable(path));
281    }
282    Ok(())
283}
284
285fn deleted(
286    diff: &mut Diff,
287    git_diff: &git2::Diff<'_>,
288    idx: usize,
289    delta: &git2::DiffDelta<'_>,
290) -> Result<(), error::Diff> {
291    let diff_file = delta.old_file();
292    let is_binary = diff_file.is_binary();
293    let path = diff_file
294        .path()
295        .ok_or(error::Diff::PathUnavailable)?
296        .to_path_buf();
297    let patch = git2::Patch::from_diff(git_diff, idx)?;
298    let old = DiffFile::try_from(diff_file)?;
299
300    if is_binary {
301        diff.insert_deleted(path, DiffContent::Binary, old);
302    } else if let Some(patch) = patch {
303        diff.insert_deleted(path, DiffContent::try_from(patch)?, old);
304    } else {
305        return Err(error::Diff::PatchUnavailable(path));
306    }
307    Ok(())
308}
309
310fn modified(
311    diff: &mut Diff,
312    git_diff: &git2::Diff<'_>,
313    idx: usize,
314    delta: &git2::DiffDelta<'_>,
315) -> Result<(), error::Diff> {
316    let diff_file = delta.new_file();
317    let path = diff_file
318        .path()
319        .ok_or(error::Diff::PathUnavailable)?
320        .to_path_buf();
321    let patch = git2::Patch::from_diff(git_diff, idx)?;
322    let old = DiffFile::try_from(delta.old_file())?;
323    let new = DiffFile::try_from(delta.new_file())?;
324
325    if diff_file.is_binary() {
326        diff.insert_modified(path, DiffContent::Binary, old, new);
327        Ok(())
328    } else if let Some(patch) = patch {
329        diff.insert_modified(path, DiffContent::try_from(patch)?, old, new);
330        Ok(())
331    } else {
332        Err(error::Diff::PatchUnavailable(path))
333    }
334}
335
336fn renamed(
337    diff: &mut Diff,
338    git_diff: &git2::Diff<'_>,
339    idx: usize,
340    delta: &git2::DiffDelta<'_>,
341) -> Result<(), error::Diff> {
342    let old_path = delta
343        .old_file()
344        .path()
345        .ok_or(error::Diff::PathUnavailable)?
346        .to_path_buf();
347    let new_path = delta
348        .new_file()
349        .path()
350        .ok_or(error::Diff::PathUnavailable)?
351        .to_path_buf();
352    let patch = git2::Patch::from_diff(git_diff, idx)?;
353    let old = DiffFile::try_from(delta.old_file())?;
354    let new = DiffFile::try_from(delta.new_file())?;
355
356    if delta.new_file().is_binary() {
357        diff.insert_moved(old_path, new_path, old, new, DiffContent::Binary);
358    } else if let Some(patch) = patch {
359        diff.insert_moved(old_path, new_path, old, new, DiffContent::try_from(patch)?);
360    } else {
361        diff.insert_moved(old_path, new_path, old, new, DiffContent::Empty);
362    }
363    Ok(())
364}
365
366fn copied(
367    diff: &mut Diff,
368    git_diff: &git2::Diff<'_>,
369    idx: usize,
370    delta: &git2::DiffDelta<'_>,
371) -> Result<(), error::Diff> {
372    let old_path = delta
373        .old_file()
374        .path()
375        .ok_or(error::Diff::PathUnavailable)?
376        .to_path_buf();
377    let new_path = delta
378        .new_file()
379        .path()
380        .ok_or(error::Diff::PathUnavailable)?
381        .to_path_buf();
382    let patch = git2::Patch::from_diff(git_diff, idx)?;
383    let old = DiffFile::try_from(delta.old_file())?;
384    let new = DiffFile::try_from(delta.new_file())?;
385
386    if delta.new_file().is_binary() {
387        diff.insert_copied(old_path, new_path, old, new, DiffContent::Binary);
388    } else if let Some(patch) = patch {
389        diff.insert_copied(old_path, new_path, old, new, DiffContent::try_from(patch)?);
390    } else {
391        diff.insert_copied(old_path, new_path, old, new, DiffContent::Empty);
392    }
393    Ok(())
394}