Skip to main content

jj_lib/diff_presentation/
unified.rs

1// Copyright 2025 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Utilities to compute unified (Git-style) diffs of 2 sides
16
17use std::ops::Range;
18
19use bstr::BStr;
20use bstr::BString;
21use thiserror::Error;
22
23use super::DiffTokenType;
24use super::DiffTokenVec;
25use super::FileContent;
26use super::LineCompareMode;
27use super::diff_by_line;
28use super::file_content_for_diff;
29use super::unzip_diff_hunks_to_lines;
30use crate::backend::BackendError;
31use crate::backend::MergedTreeValueExt as _;
32use crate::conflicts::ConflictMaterializeOptions;
33use crate::conflicts::MaterializedTreeValue;
34use crate::conflicts::materialize_merge_result_to_bytes;
35use crate::diff::ContentDiff;
36use crate::diff::DiffHunkKind;
37use crate::merge::Diff;
38use crate::object_id::ObjectId as _;
39use crate::repo_path::RepoPath;
40
41#[derive(Clone, Debug)]
42pub struct GitDiffPart {
43    /// Octal mode string or `None` if the file is absent.
44    pub mode: Option<&'static str>,
45    pub hash: String,
46    pub content: FileContent<BString>,
47}
48
49#[derive(Debug, Error)]
50pub enum UnifiedDiffError {
51    #[error(transparent)]
52    Backend(#[from] BackendError),
53    #[error("Access denied to {path}")]
54    AccessDenied {
55        path: String,
56        source: Box<dyn std::error::Error + Send + Sync>,
57    },
58}
59
60pub async fn git_diff_part(
61    path: &RepoPath,
62    value: MaterializedTreeValue,
63    materialize_options: &ConflictMaterializeOptions,
64) -> Result<GitDiffPart, UnifiedDiffError> {
65    const DUMMY_HASH: &str = "0000000000";
66    let mode;
67    let mut hash;
68    let content;
69    match value {
70        MaterializedTreeValue::Absent => {
71            return Ok(GitDiffPart {
72                mode: None,
73                hash: DUMMY_HASH.to_owned(),
74                content: FileContent {
75                    is_binary: false,
76                    contents: BString::default(),
77                },
78            });
79        }
80        MaterializedTreeValue::AccessDenied(err) => {
81            return Err(UnifiedDiffError::AccessDenied {
82                path: path.as_internal_file_string().to_owned(),
83                source: err,
84            });
85        }
86        MaterializedTreeValue::File(mut file) => {
87            mode = if file.executable { "100755" } else { "100644" };
88            hash = file.id.hex();
89            content = file_content_for_diff(path, &mut file, |content| content).await?;
90        }
91        MaterializedTreeValue::Symlink { id, target } => {
92            mode = "120000";
93            hash = id.hex();
94            content = FileContent {
95                // Unix file paths can't contain null bytes.
96                is_binary: false,
97                contents: target.into(),
98            };
99        }
100        MaterializedTreeValue::GitSubmodule(id) => {
101            // TODO: What should we actually do here?
102            mode = "040000";
103            hash = id.hex();
104            content = FileContent {
105                is_binary: false,
106                contents: BString::default(),
107            };
108        }
109        MaterializedTreeValue::FileConflict(file) => {
110            mode = match file.executable {
111                Some(true) => "100755",
112                Some(false) | None => "100644",
113            };
114            hash = DUMMY_HASH.to_owned();
115            content = FileContent {
116                is_binary: false, // TODO: are we sure this is never binary?
117                contents: materialize_merge_result_to_bytes(
118                    &file.contents,
119                    &file.labels,
120                    materialize_options,
121                ),
122            };
123        }
124        MaterializedTreeValue::OtherConflict { id, labels } => {
125            mode = "100644";
126            hash = DUMMY_HASH.to_owned();
127            content = FileContent {
128                is_binary: false,
129                contents: id.describe(&labels).into(),
130            };
131        }
132        MaterializedTreeValue::Tree(_) => {
133            panic!("Unexpected tree in diff at path {path:?}");
134        }
135    }
136    hash.truncate(10);
137    Ok(GitDiffPart {
138        mode: Some(mode),
139        hash,
140        content,
141    })
142}
143
144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
145pub enum DiffLineType {
146    Context,
147    Removed,
148    Added,
149}
150
151pub struct UnifiedDiffHunk<'content> {
152    pub left_line_range: Range<usize>,
153    pub right_line_range: Range<usize>,
154    pub lines: Vec<(DiffLineType, DiffTokenVec<'content>)>,
155}
156
157impl<'content> UnifiedDiffHunk<'content> {
158    fn extend_context_lines(&mut self, lines: impl IntoIterator<Item = &'content [u8]>) {
159        let old_len = self.lines.len();
160        self.lines.extend(lines.into_iter().map(|line| {
161            let tokens = vec![(DiffTokenType::Matching, line)];
162            (DiffLineType::Context, tokens)
163        }));
164        self.left_line_range.end += self.lines.len() - old_len;
165        self.right_line_range.end += self.lines.len() - old_len;
166    }
167
168    fn extend_removed_lines(&mut self, lines: impl IntoIterator<Item = DiffTokenVec<'content>>) {
169        let old_len = self.lines.len();
170        self.lines
171            .extend(lines.into_iter().map(|line| (DiffLineType::Removed, line)));
172        self.left_line_range.end += self.lines.len() - old_len;
173    }
174
175    fn extend_added_lines(&mut self, lines: impl IntoIterator<Item = DiffTokenVec<'content>>) {
176        let old_len = self.lines.len();
177        self.lines
178            .extend(lines.into_iter().map(|line| (DiffLineType::Added, line)));
179        self.right_line_range.end += self.lines.len() - old_len;
180    }
181}
182
183pub fn unified_diff_hunks(
184    contents: Diff<&BStr>,
185    context: usize,
186    options: LineCompareMode,
187) -> Vec<UnifiedDiffHunk<'_>> {
188    let mut hunks = vec![];
189    let mut current_hunk = UnifiedDiffHunk {
190        left_line_range: 0..0,
191        right_line_range: 0..0,
192        lines: vec![],
193    };
194    let diff = diff_by_line(contents.into_array(), &options);
195    let mut diff_hunks = diff.hunks().peekable();
196    while let Some(hunk) = diff_hunks.next() {
197        match hunk.kind {
198            DiffHunkKind::Matching => {
199                // Just use the right (i.e. new) content. We could count the
200                // number of skipped lines separately, but the number of the
201                // context lines should match the displayed content.
202                let [_, right] = hunk.contents[..].try_into().unwrap();
203                let mut lines = right.split_inclusive(|b| *b == b'\n').fuse();
204                if !current_hunk.lines.is_empty() {
205                    // The previous hunk line should be either removed/added.
206                    current_hunk.extend_context_lines(lines.by_ref().take(context));
207                }
208                let before_lines = if diff_hunks.peek().is_some() {
209                    lines.by_ref().rev().take(context).collect()
210                } else {
211                    vec![] // No more hunks
212                };
213                let num_skip_lines = lines.count();
214                if num_skip_lines > 0 {
215                    let left_start = current_hunk.left_line_range.end + num_skip_lines;
216                    let right_start = current_hunk.right_line_range.end + num_skip_lines;
217                    if !current_hunk.lines.is_empty() {
218                        hunks.push(current_hunk);
219                    }
220                    current_hunk = UnifiedDiffHunk {
221                        left_line_range: left_start..left_start,
222                        right_line_range: right_start..right_start,
223                        lines: vec![],
224                    };
225                }
226                // The next hunk should be of DiffHunk::Different type if any.
227                current_hunk.extend_context_lines(before_lines.into_iter().rev());
228            }
229            DiffHunkKind::Different => {
230                let lines = unzip_diff_hunks_to_lines(ContentDiff::by_word(hunk.contents).hunks());
231                current_hunk.extend_removed_lines(lines.before);
232                current_hunk.extend_added_lines(lines.after);
233            }
234        }
235    }
236    if !current_hunk.lines.is_empty() {
237        hunks.push(current_hunk);
238    }
239    hunks
240}