jj_cli/
diff_util.rs

1// Copyright 2020-2022 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
15use std::borrow::Borrow;
16use std::borrow::Cow;
17use std::cmp::max;
18use std::io;
19use std::iter;
20use std::mem;
21use std::ops::Range;
22use std::path::Path;
23use std::path::PathBuf;
24
25use bstr::BStr;
26use bstr::BString;
27use clap_complete::ArgValueCandidates;
28use futures::StreamExt as _;
29use futures::TryStreamExt as _;
30use futures::executor::block_on_stream;
31use futures::stream::BoxStream;
32use itertools::Itertools as _;
33use jj_lib::backend::BackendError;
34use jj_lib::backend::BackendResult;
35use jj_lib::backend::CommitId;
36use jj_lib::backend::CopyRecord;
37use jj_lib::backend::TreeValue;
38use jj_lib::commit::Commit;
39use jj_lib::config::ConfigGetError;
40use jj_lib::conflicts::ConflictMarkerStyle;
41use jj_lib::conflicts::ConflictMaterializeOptions;
42use jj_lib::conflicts::MaterializedFileValue;
43use jj_lib::conflicts::MaterializedTreeDiffEntry;
44use jj_lib::conflicts::MaterializedTreeValue;
45use jj_lib::conflicts::materialize_merge_result_to_bytes;
46use jj_lib::conflicts::materialized_diff_stream;
47use jj_lib::copies::CopiesTreeDiffEntry;
48use jj_lib::copies::CopiesTreeDiffEntryPath;
49use jj_lib::copies::CopyOperation;
50use jj_lib::copies::CopyRecords;
51use jj_lib::diff::CompareBytesExactly;
52use jj_lib::diff::CompareBytesIgnoreAllWhitespace;
53use jj_lib::diff::CompareBytesIgnoreWhitespaceAmount;
54use jj_lib::diff::Diff;
55use jj_lib::diff::DiffHunk;
56use jj_lib::diff::DiffHunkKind;
57use jj_lib::diff::find_line_ranges;
58use jj_lib::files;
59use jj_lib::files::ConflictDiffHunk;
60use jj_lib::files::DiffLineHunkSide;
61use jj_lib::files::DiffLineIterator;
62use jj_lib::files::DiffLineNumber;
63use jj_lib::matchers::Matcher;
64use jj_lib::merge::Merge;
65use jj_lib::merge::MergeBuilder;
66use jj_lib::merge::MergedTreeValue;
67use jj_lib::merged_tree::MergedTree;
68use jj_lib::object_id::ObjectId as _;
69use jj_lib::repo::Repo;
70use jj_lib::repo_path::InvalidRepoPathError;
71use jj_lib::repo_path::RepoPath;
72use jj_lib::repo_path::RepoPathUiConverter;
73use jj_lib::rewrite::rebase_to_dest_parent;
74use jj_lib::settings::UserSettings;
75use jj_lib::store::Store;
76use pollster::FutureExt as _;
77use thiserror::Error;
78use tracing::instrument;
79use unicode_width::UnicodeWidthStr as _;
80
81use crate::command_error::CommandError;
82use crate::command_error::cli_error;
83use crate::commit_templater;
84use crate::config::CommandNameAndArgs;
85use crate::formatter::Formatter;
86use crate::merge_tools;
87use crate::merge_tools::DiffGenerateError;
88use crate::merge_tools::DiffToolMode;
89use crate::merge_tools::ExternalMergeTool;
90use crate::merge_tools::generate_diff;
91use crate::merge_tools::invoke_external_diff;
92use crate::merge_tools::new_utf8_temp_dir;
93use crate::templater::TemplateRenderer;
94use crate::text_util;
95use crate::ui::Ui;
96
97#[derive(clap::Args, Clone, Debug)]
98#[command(next_help_heading = "Diff Formatting Options")]
99#[command(group(clap::ArgGroup::new("short-format").args(&["summary", "stat", "types", "name_only"])))]
100#[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words"])))]
101pub struct DiffFormatArgs {
102    /// For each path, show only whether it was modified, added, or deleted
103    #[arg(long, short)]
104    pub summary: bool,
105    /// Show a histogram of the changes
106    #[arg(long)]
107    pub stat: bool,
108    /// For each path, show only its type before and after
109    ///
110    /// The diff is shown as two letters. The first letter indicates the type
111    /// before and the second letter indicates the type after. '-' indicates
112    /// that the path was not present, 'F' represents a regular file, `L'
113    /// represents a symlink, 'C' represents a conflict, and 'G' represents a
114    /// Git submodule.
115    #[arg(long)]
116    pub types: bool,
117    /// For each path, show only its path
118    ///
119    /// Typically useful for shell commands like:
120    ///    `jj diff -r @- --name-only | xargs perl -pi -e's/OLD/NEW/g`
121    #[arg(long)]
122    pub name_only: bool,
123    /// Show a Git-format diff
124    #[arg(long)]
125    pub git: bool,
126    /// Show a word-level diff with changes indicated only by color
127    #[arg(long)]
128    pub color_words: bool,
129    /// Generate diff by external command
130    ///
131    /// A builtin format can also be specified as `:<name>`. For example,
132    /// `--tool=:git` is equivalent to `--git`.
133    #[arg(
134        long,
135        add = ArgValueCandidates::new(crate::complete::diff_formatters),
136    )]
137    pub tool: Option<String>,
138    /// Number of lines of context to show
139    #[arg(long)]
140    context: Option<usize>,
141
142    // Short flags are set by command to avoid future conflicts.
143    /// Ignore whitespace when comparing lines.
144    #[arg(long)] // short = 'w'
145    ignore_all_space: bool,
146    /// Ignore changes in amount of whitespace when comparing lines.
147    #[arg(long, conflicts_with = "ignore_all_space")] // short = 'b'
148    ignore_space_change: bool,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
152pub enum DiffFormat {
153    // Non-trivial parameters are boxed in order to keep the variants small
154    Summary,
155    Stat(Box<DiffStatOptions>),
156    Types,
157    NameOnly,
158    Git(Box<UnifiedDiffOptions>),
159    ColorWords(Box<ColorWordsDiffOptions>),
160    Tool(Box<ExternalMergeTool>),
161}
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164enum BuiltinFormatKind {
165    Summary,
166    Stat,
167    Types,
168    NameOnly,
169    Git,
170    ColorWords,
171}
172
173impl BuiltinFormatKind {
174    // Alternatively, we could use or vendor one of the crates `strum`,
175    // `enum-iterator`, or `variant_count` (for a check that the length of the array
176    // is correct). The latter is very simple and is also a nightly feature.
177    const ALL_VARIANTS: &[Self] = &[
178        Self::Summary,
179        Self::Stat,
180        Self::Types,
181        Self::NameOnly,
182        Self::Git,
183        Self::ColorWords,
184    ];
185
186    fn from_name(name: &str) -> Result<Self, String> {
187        match name {
188            "summary" => Ok(Self::Summary),
189            "stat" => Ok(Self::Stat),
190            "types" => Ok(Self::Types),
191            "name-only" => Ok(Self::NameOnly),
192            "git" => Ok(Self::Git),
193            "color-words" => Ok(Self::ColorWords),
194            _ => Err(format!("Invalid builtin diff format: {name}")),
195        }
196    }
197
198    fn short_from_args(args: &DiffFormatArgs) -> Option<Self> {
199        if args.summary {
200            Some(Self::Summary)
201        } else if args.stat {
202            Some(Self::Stat)
203        } else if args.types {
204            Some(Self::Types)
205        } else if args.name_only {
206            Some(Self::NameOnly)
207        } else {
208            None
209        }
210    }
211
212    fn long_from_args(args: &DiffFormatArgs) -> Option<Self> {
213        if args.git {
214            Some(Self::Git)
215        } else if args.color_words {
216            Some(Self::ColorWords)
217        } else {
218            None
219        }
220    }
221
222    fn is_short(self) -> bool {
223        match self {
224            Self::Summary | Self::Stat | Self::Types | Self::NameOnly => true,
225            Self::Git | Self::ColorWords => false,
226        }
227    }
228
229    fn to_arg_name(self) -> &'static str {
230        match self {
231            Self::Summary => "summary",
232            Self::Stat => "stat",
233            Self::Types => "types",
234            Self::NameOnly => "name-only",
235            Self::Git => "git",
236            Self::ColorWords => "color-words",
237        }
238    }
239
240    fn to_format(
241        self,
242        settings: &UserSettings,
243        args: &DiffFormatArgs,
244    ) -> Result<DiffFormat, ConfigGetError> {
245        match self {
246            Self::Summary => Ok(DiffFormat::Summary),
247            Self::Stat => {
248                let mut options = DiffStatOptions::default();
249                options.merge_args(args);
250                Ok(DiffFormat::Stat(Box::new(options)))
251            }
252            Self::Types => Ok(DiffFormat::Types),
253            Self::NameOnly => Ok(DiffFormat::NameOnly),
254            Self::Git => {
255                let mut options = UnifiedDiffOptions::from_settings(settings)?;
256                options.merge_args(args);
257                Ok(DiffFormat::Git(Box::new(options)))
258            }
259            Self::ColorWords => {
260                let mut options = ColorWordsDiffOptions::from_settings(settings)?;
261                options.merge_args(args);
262                Ok(DiffFormat::ColorWords(Box::new(options)))
263            }
264        }
265    }
266}
267
268/// Returns the list of builtin diff format names such as `:git`
269pub fn all_builtin_diff_format_names() -> Vec<String> {
270    BuiltinFormatKind::ALL_VARIANTS
271        .iter()
272        .map(|kind| format!(":{}", kind.to_arg_name()))
273        .collect()
274}
275
276fn diff_formatter_tool(
277    settings: &UserSettings,
278    name: &str,
279) -> Result<Option<ExternalMergeTool>, CommandError> {
280    let maybe_tool = merge_tools::get_external_tool_config(settings, name)?;
281    if let Some(tool) = &maybe_tool {
282        if tool.diff_args.is_empty() {
283            return Err(cli_error(format!(
284                "The tool `{name}` cannot be used for diff formatting"
285            )));
286        };
287    };
288    Ok(maybe_tool)
289}
290
291/// Returns a list of requested diff formats, which will never be empty.
292pub fn diff_formats_for(
293    settings: &UserSettings,
294    args: &DiffFormatArgs,
295) -> Result<Vec<DiffFormat>, CommandError> {
296    let formats = diff_formats_from_args(settings, args)?;
297    if formats.iter().all(|f| f.is_none()) {
298        Ok(vec![default_diff_format(settings, args)?])
299    } else {
300        Ok(formats.into_iter().flatten().collect())
301    }
302}
303
304/// Returns a list of requested diff formats for log-like commands, which may be
305/// empty.
306pub fn diff_formats_for_log(
307    settings: &UserSettings,
308    args: &DiffFormatArgs,
309    patch: bool,
310) -> Result<Vec<DiffFormat>, CommandError> {
311    let [short_format, mut long_format] = diff_formats_from_args(settings, args)?;
312    // --patch implies default if no "long" format is specified
313    if patch && long_format.is_none() {
314        // TODO: maybe better to error out if the configured default isn't a
315        // "long" format?
316        let default_format = default_diff_format(settings, args)?;
317        if short_format.as_ref() != Some(&default_format) {
318            long_format = Some(default_format);
319        }
320    }
321    Ok([short_format, long_format].into_iter().flatten().collect())
322}
323
324fn diff_formats_from_args(
325    settings: &UserSettings,
326    args: &DiffFormatArgs,
327) -> Result<[Option<DiffFormat>; 2], CommandError> {
328    let short_kind = BuiltinFormatKind::short_from_args(args);
329    let long_kind = BuiltinFormatKind::long_from_args(args);
330    let mut short_format = short_kind
331        .map(|kind| kind.to_format(settings, args))
332        .transpose()?;
333    let mut long_format = long_kind
334        .map(|kind| kind.to_format(settings, args))
335        .transpose()?;
336    if let Some(name) = &args.tool {
337        let ensure_new = |old_kind: Option<BuiltinFormatKind>| match old_kind {
338            Some(old) => Err(cli_error(format!(
339                "--tool={name} cannot be used with --{old}",
340                old = old.to_arg_name()
341            ))),
342            None => Ok(()),
343        };
344        if let Some(name) = name.strip_prefix(':') {
345            let kind = BuiltinFormatKind::from_name(name).map_err(cli_error)?;
346            let format = kind.to_format(settings, args)?;
347            if kind.is_short() {
348                ensure_new(short_kind)?;
349                short_format = Some(format);
350            } else {
351                ensure_new(long_kind)?;
352                long_format = Some(format);
353            }
354        } else {
355            ensure_new(long_kind)?;
356            let tool = diff_formatter_tool(settings, name)?
357                .unwrap_or_else(|| ExternalMergeTool::with_program(name));
358            long_format = Some(DiffFormat::Tool(Box::new(tool)));
359        }
360    }
361    Ok([short_format, long_format])
362}
363
364fn default_diff_format(
365    settings: &UserSettings,
366    args: &DiffFormatArgs,
367) -> Result<DiffFormat, CommandError> {
368    let tool_args: CommandNameAndArgs = settings.get("ui.diff-formatter")?;
369    if let Some(name) = tool_args.as_str().and_then(|s| s.strip_prefix(':')) {
370        Ok(BuiltinFormatKind::from_name(name)
371            .map_err(|err| ConfigGetError::Type {
372                name: "ui.diff-formatter".to_owned(),
373                error: err.into(),
374                source_path: None,
375            })?
376            .to_format(settings, args)?)
377    } else {
378        let tool = if let Some(name) = tool_args.as_str() {
379            diff_formatter_tool(settings, name)?
380        } else {
381            None
382        }
383        .unwrap_or_else(|| ExternalMergeTool::with_diff_args(&tool_args));
384        Ok(DiffFormat::Tool(Box::new(tool)))
385    }
386}
387
388#[derive(Debug, Error)]
389pub enum DiffRenderError {
390    #[error("Failed to generate diff")]
391    DiffGenerate(#[source] DiffGenerateError),
392    #[error(transparent)]
393    Backend(#[from] BackendError),
394    #[error("Access denied to {path}")]
395    AccessDenied {
396        path: String,
397        source: Box<dyn std::error::Error + Send + Sync>,
398    },
399    #[error(transparent)]
400    InvalidRepoPath(#[from] InvalidRepoPathError),
401    #[error(transparent)]
402    Io(#[from] io::Error),
403}
404
405/// Configuration and environment to render textual diff.
406pub struct DiffRenderer<'a> {
407    repo: &'a dyn Repo,
408    path_converter: &'a RepoPathUiConverter,
409    conflict_marker_style: ConflictMarkerStyle,
410    formats: Vec<DiffFormat>,
411}
412
413impl<'a> DiffRenderer<'a> {
414    pub fn new(
415        repo: &'a dyn Repo,
416        path_converter: &'a RepoPathUiConverter,
417        conflict_marker_style: ConflictMarkerStyle,
418        formats: Vec<DiffFormat>,
419    ) -> Self {
420        Self {
421            repo,
422            path_converter,
423            conflict_marker_style,
424            formats,
425        }
426    }
427
428    /// Generates diff between `trees`.
429    pub async fn show_diff(
430        &self,
431        ui: &Ui, // TODO: remove Ui dependency if possible
432        formatter: &mut dyn Formatter,
433        trees: [&MergedTree; 2],
434        matcher: &dyn Matcher,
435        copy_records: &CopyRecords,
436        width: usize,
437    ) -> Result<(), DiffRenderError> {
438        formatter
439            .with_label_async("diff", async |formatter| {
440                self.show_diff_trees(ui, formatter, trees, matcher, copy_records, width)
441                    .await
442            })
443            .await
444    }
445
446    async fn show_diff_trees(
447        &self,
448        ui: &Ui,
449        formatter: &mut dyn Formatter,
450        [from_tree, to_tree]: [&MergedTree; 2],
451        matcher: &dyn Matcher,
452        copy_records: &CopyRecords,
453        width: usize,
454    ) -> Result<(), DiffRenderError> {
455        let store = self.repo.store();
456        let path_converter = self.path_converter;
457        for format in &self.formats {
458            match format {
459                DiffFormat::Summary => {
460                    let tree_diff =
461                        from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
462                    show_diff_summary(formatter, tree_diff, path_converter).await?;
463                }
464                DiffFormat::Stat(options) => {
465                    let tree_diff =
466                        from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
467                    let stats =
468                        DiffStats::calculate(store, tree_diff, options, self.conflict_marker_style)
469                            .block_on()?;
470                    show_diff_stats(formatter, &stats, path_converter, width)?;
471                }
472                DiffFormat::Types => {
473                    let tree_diff =
474                        from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
475                    show_types(formatter, tree_diff, path_converter).await?;
476                }
477                DiffFormat::NameOnly => {
478                    let tree_diff =
479                        from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
480                    show_names(formatter, tree_diff, path_converter).await?;
481                }
482                DiffFormat::Git(options) => {
483                    let tree_diff =
484                        from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
485                    show_git_diff(
486                        formatter,
487                        store,
488                        tree_diff,
489                        options,
490                        self.conflict_marker_style,
491                    )
492                    .await?;
493                }
494                DiffFormat::ColorWords(options) => {
495                    let tree_diff =
496                        from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
497                    show_color_words_diff(
498                        formatter,
499                        store,
500                        tree_diff,
501                        path_converter,
502                        options,
503                        self.conflict_marker_style,
504                    )
505                    .await?;
506                }
507                DiffFormat::Tool(tool) => {
508                    match tool.diff_invocation_mode {
509                        DiffToolMode::FileByFile => {
510                            let tree_diff =
511                                from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
512                            show_file_by_file_diff(
513                                ui,
514                                formatter,
515                                store,
516                                tree_diff,
517                                path_converter,
518                                tool,
519                                self.conflict_marker_style,
520                            )
521                            .await
522                        }
523                        DiffToolMode::Dir => {
524                            let mut writer = formatter.raw()?;
525                            generate_diff(
526                                ui,
527                                writer.as_mut(),
528                                from_tree,
529                                to_tree,
530                                matcher,
531                                tool,
532                                self.conflict_marker_style,
533                            )
534                            .map_err(DiffRenderError::DiffGenerate)
535                        }
536                    }?;
537                }
538            }
539        }
540        Ok(())
541    }
542
543    fn show_diff_commit_descriptions(
544        &self,
545        formatter: &mut dyn Formatter,
546        [from_description, to_description]: [&Merge<&str>; 2],
547    ) -> Result<(), DiffRenderError> {
548        if from_description == to_description {
549            return Ok(());
550        }
551        const DUMMY_PATH: &str = "JJ-COMMIT-DESCRIPTION";
552        let materialize_options = ConflictMaterializeOptions {
553            marker_style: self.conflict_marker_style,
554            marker_len: None,
555        };
556        for format in &self.formats {
557            match format {
558                // Omit diff from "short" formats. Printing dummy file path
559                // wouldn't be useful.
560                DiffFormat::Summary
561                | DiffFormat::Stat(_)
562                | DiffFormat::Types
563                | DiffFormat::NameOnly => {}
564                DiffFormat::Git(options) => {
565                    // Git format must be parsable, so use dummy file path.
566                    show_git_diff_texts(
567                        formatter,
568                        [DUMMY_PATH, DUMMY_PATH],
569                        [from_description, to_description],
570                        options,
571                        &materialize_options,
572                    )?;
573                }
574                DiffFormat::ColorWords(options) => {
575                    writeln!(formatter.labeled("header"), "Modified commit description:")?;
576                    show_color_words_diff_hunks(
577                        formatter,
578                        [from_description, to_description],
579                        options,
580                        &materialize_options,
581                    )?;
582                }
583                DiffFormat::Tool(_) => {
584                    // TODO: materialize commit description as file?
585                }
586            }
587        }
588        Ok(())
589    }
590
591    /// Generates diff between `from_commits` and `to_commit` based off their
592    /// parents. The `from_commits` will temporarily be rebased onto the
593    /// `to_commit` parents to exclude unrelated changes.
594    pub async fn show_inter_diff(
595        &self,
596        ui: &Ui,
597        formatter: &mut dyn Formatter,
598        from_commits: &[Commit],
599        to_commit: &Commit,
600        matcher: &dyn Matcher,
601        width: usize,
602    ) -> Result<(), DiffRenderError> {
603        let from_description = if from_commits.is_empty() {
604            Merge::resolved("")
605        } else {
606            // TODO: use common predecessor as the base description?
607            MergeBuilder::from_iter(itertools::intersperse(
608                from_commits.iter().map(|c| c.description()),
609                "",
610            ))
611            .build()
612            .simplify()
613        };
614        let to_description = Merge::resolved(to_commit.description());
615        let from_tree = rebase_to_dest_parent(self.repo, from_commits, to_commit)?;
616        let to_tree = to_commit.tree_async().await?;
617        let copy_records = CopyRecords::default(); // TODO
618        formatter
619            .with_label_async("diff", async |formatter| {
620                self.show_diff_commit_descriptions(
621                    formatter,
622                    [&from_description, &to_description],
623                )?;
624                self.show_diff_trees(
625                    ui,
626                    formatter,
627                    [&from_tree, &to_tree],
628                    matcher,
629                    &copy_records,
630                    width,
631                )
632                .await
633            })
634            .await
635    }
636
637    /// Generates diff of the given `commit` compared to its parents.
638    pub async fn show_patch(
639        &self,
640        ui: &Ui,
641        formatter: &mut dyn Formatter,
642        commit: &Commit,
643        matcher: &dyn Matcher,
644        width: usize,
645    ) -> Result<(), DiffRenderError> {
646        let from_tree = commit.parent_tree(self.repo)?;
647        let to_tree = commit.tree_async().await?;
648        let mut copy_records = CopyRecords::default();
649        for parent_id in commit.parent_ids() {
650            let records = get_copy_records(self.repo.store(), parent_id, commit.id(), matcher)?;
651            copy_records.add_records(records)?;
652        }
653        self.show_diff(
654            ui,
655            formatter,
656            [&from_tree, &to_tree],
657            matcher,
658            &copy_records,
659            width,
660        )
661        .await
662    }
663}
664
665pub fn get_copy_records<'a>(
666    store: &'a Store,
667    root: &CommitId,
668    head: &CommitId,
669    matcher: &'a dyn Matcher,
670) -> BackendResult<impl Iterator<Item = BackendResult<CopyRecord>> + use<'a>> {
671    // TODO: teach backend about matching path prefixes?
672    let stream = store.get_copy_records(None, root, head)?;
673    // TODO: test record.source as well? should be AND-ed or OR-ed?
674    Ok(block_on_stream(stream).filter_ok(|record| matcher.matches(&record.target)))
675}
676
677/// How conflicts are processed and rendered in diffs.
678#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
679#[serde(rename_all = "kebab-case")]
680pub enum ConflictDiffMethod {
681    /// Compares materialized contents.
682    #[default]
683    Materialize,
684    /// Compares individual pairs of left and right contents.
685    Pair,
686}
687
688#[derive(Clone, Debug, Default, Eq, PartialEq)]
689pub struct LineDiffOptions {
690    /// How equivalence of lines is tested.
691    pub compare_mode: LineCompareMode,
692    // TODO: add --ignore-blank-lines, etc. which aren't mutually exclusive.
693}
694
695impl LineDiffOptions {
696    fn merge_args(&mut self, args: &DiffFormatArgs) {
697        self.compare_mode = if args.ignore_all_space {
698            LineCompareMode::IgnoreAllSpace
699        } else if args.ignore_space_change {
700            LineCompareMode::IgnoreSpaceChange
701        } else {
702            LineCompareMode::Exact
703        };
704    }
705}
706
707#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
708pub enum LineCompareMode {
709    /// Compares lines literally.
710    #[default]
711    Exact,
712    /// Compares lines ignoring any whitespace occurrences.
713    IgnoreAllSpace,
714    /// Compares lines ignoring changes in whitespace amount.
715    IgnoreSpaceChange,
716}
717
718fn diff_by_line<'input, T: AsRef<[u8]> + ?Sized + 'input>(
719    inputs: impl IntoIterator<Item = &'input T>,
720    options: &LineDiffOptions,
721) -> Diff<'input> {
722    // TODO: If we add --ignore-blank-lines, its tokenizer will have to attach
723    // blank lines to the preceding range. Maybe it can also be implemented as a
724    // post-process (similar to refine_changed_regions()) that expands unchanged
725    // regions across blank lines.
726    match options.compare_mode {
727        LineCompareMode::Exact => {
728            Diff::for_tokenizer(inputs, find_line_ranges, CompareBytesExactly)
729        }
730        LineCompareMode::IgnoreAllSpace => {
731            Diff::for_tokenizer(inputs, find_line_ranges, CompareBytesIgnoreAllWhitespace)
732        }
733        LineCompareMode::IgnoreSpaceChange => {
734            Diff::for_tokenizer(inputs, find_line_ranges, CompareBytesIgnoreWhitespaceAmount)
735        }
736    }
737}
738
739#[derive(Clone, Debug, Eq, PartialEq)]
740pub struct ColorWordsDiffOptions {
741    /// How conflicts are processed and rendered.
742    pub conflict: ConflictDiffMethod,
743    /// Number of context lines to show.
744    pub context: usize,
745    /// How lines are tokenized and compared.
746    pub line_diff: LineDiffOptions,
747    /// Maximum number of removed/added word alternation to inline.
748    pub max_inline_alternation: Option<usize>,
749}
750
751impl ColorWordsDiffOptions {
752    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
753        let max_inline_alternation = {
754            let name = "diff.color-words.max-inline-alternation";
755            match settings.get_int(name)? {
756                -1 => None, // unlimited
757                n => Some(usize::try_from(n).map_err(|err| ConfigGetError::Type {
758                    name: name.to_owned(),
759                    error: err.into(),
760                    source_path: None,
761                })?),
762            }
763        };
764        Ok(Self {
765            conflict: settings.get("diff.color-words.conflict")?,
766            context: settings.get("diff.color-words.context")?,
767            line_diff: LineDiffOptions::default(),
768            max_inline_alternation,
769        })
770    }
771
772    fn merge_args(&mut self, args: &DiffFormatArgs) {
773        if let Some(context) = args.context {
774            self.context = context;
775        }
776        self.line_diff.merge_args(args);
777    }
778}
779
780fn show_color_words_diff_hunks<T: AsRef<[u8]>>(
781    formatter: &mut dyn Formatter,
782    [lefts, rights]: [&Merge<T>; 2],
783    options: &ColorWordsDiffOptions,
784    materialize_options: &ConflictMaterializeOptions,
785) -> io::Result<()> {
786    let line_number = DiffLineNumber { left: 1, right: 1 };
787    let labels = ["removed", "added"];
788    if let (Some(left), Some(right)) = (lefts.as_resolved(), rights.as_resolved()) {
789        let contents = [left, right].map(BStr::new);
790        show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
791        return Ok(());
792    }
793    match options.conflict {
794        ConflictDiffMethod::Materialize => {
795            let left = materialize_merge_result_to_bytes(lefts, materialize_options);
796            let right = materialize_merge_result_to_bytes(rights, materialize_options);
797            let contents = [&left, &right].map(BStr::new);
798            show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
799        }
800        ConflictDiffMethod::Pair => {
801            let lefts = files::merge(lefts);
802            let rights = files::merge(rights);
803            let contents = [&lefts, &rights];
804            show_color_words_conflict_hunks(formatter, contents, line_number, labels, options)?;
805        }
806    }
807    Ok(())
808}
809
810fn show_color_words_conflict_hunks(
811    formatter: &mut dyn Formatter,
812    [lefts, rights]: [&Merge<BString>; 2],
813    mut line_number: DiffLineNumber,
814    labels: [&str; 2],
815    options: &ColorWordsDiffOptions,
816) -> io::Result<DiffLineNumber> {
817    let num_lefts = lefts.as_slice().len();
818    let line_diff = diff_by_line(lefts.iter().chain(rights.iter()), &options.line_diff);
819    // Matching entries shouldn't appear consecutively in diff of two inputs.
820    // However, if the inputs have conflicts, there may be a hunk that can be
821    // resolved, resulting [matching, resolved, matching] sequence.
822    let mut contexts: Vec<[&BStr; 2]> = Vec::new();
823    let mut emitted = false;
824
825    for hunk in files::conflict_diff_hunks(line_diff.hunks(), num_lefts) {
826        match hunk.kind {
827            // There may be conflicts in matching hunk, but just pick one. It
828            // would be too verbose to show all conflict pairs as context.
829            DiffHunkKind::Matching => {
830                contexts.push([hunk.lefts.first(), hunk.rights.first()]);
831            }
832            DiffHunkKind::Different => {
833                let num_after = if emitted { options.context } else { 0 };
834                let num_before = options.context;
835                line_number = show_color_words_context_lines(
836                    formatter,
837                    &contexts,
838                    line_number,
839                    labels,
840                    options,
841                    num_after,
842                    num_before,
843                )?;
844                contexts.clear();
845                emitted = true;
846                line_number = if let (Some(&left), Some(&right)) =
847                    (hunk.lefts.as_resolved(), hunk.rights.as_resolved())
848                {
849                    let contents = [left, right];
850                    show_color_words_diff_lines(formatter, contents, line_number, labels, options)?
851                } else {
852                    show_color_words_unresolved_hunk(
853                        formatter,
854                        &hunk,
855                        line_number,
856                        labels,
857                        options,
858                    )?
859                }
860            }
861        }
862    }
863
864    let num_after = if emitted { options.context } else { 0 };
865    let num_before = 0;
866    show_color_words_context_lines(
867        formatter,
868        &contexts,
869        line_number,
870        labels,
871        options,
872        num_after,
873        num_before,
874    )
875}
876
877fn show_color_words_unresolved_hunk(
878    formatter: &mut dyn Formatter,
879    hunk: &ConflictDiffHunk,
880    line_number: DiffLineNumber,
881    [label1, label2]: [&str; 2],
882    options: &ColorWordsDiffOptions,
883) -> io::Result<DiffLineNumber> {
884    let hunk_desc = if hunk.lefts.is_resolved() {
885        "Created conflict"
886    } else if hunk.rights.is_resolved() {
887        "Resolved conflict"
888    } else {
889        "Modified conflict"
890    };
891    writeln!(formatter.labeled("hunk_header"), "<<<<<<< {hunk_desc}")?;
892
893    // Pad with identical (negative, positive) terms. It's common that one of
894    // the sides is resolved, or both sides have the same numbers of terms. If
895    // both sides are conflicts, and the numbers of terms are different, the
896    // choice of padding terms is arbitrary.
897    let num_terms = max(hunk.lefts.as_slice().len(), hunk.rights.as_slice().len());
898    let lefts = hunk.lefts.iter().enumerate();
899    let rights = hunk.rights.iter().enumerate();
900    let padded = iter::zip(
901        lefts.chain(iter::repeat((0, hunk.lefts.first()))),
902        rights.chain(iter::repeat((0, hunk.rights.first()))),
903    )
904    .take(num_terms);
905    let mut max_line_number = line_number;
906    for (i, ((left_index, &left_content), (right_index, &right_content))) in padded.enumerate() {
907        let positive = i % 2 == 0;
908        writeln!(
909            formatter.labeled("hunk_header"),
910            "{sep} left {left_name} #{left_index} to right {right_name} #{right_index}",
911            sep = if positive { "+++++++" } else { "-------" },
912            // these numbers should be compatible with the "tree-set" language #5307
913            left_name = if left_index % 2 == 0 { "side" } else { "base" },
914            left_index = left_index / 2 + 1,
915            right_name = if right_index % 2 == 0 { "side" } else { "base" },
916            right_index = right_index / 2 + 1,
917        )?;
918        let contents = [left_content, right_content];
919        let labels = match positive {
920            true => [label1, label2],
921            false => [label2, label1],
922        };
923        // Individual hunk pair may be largely the same, so diff it again.
924        let new_line_number =
925            show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
926        // Take max to assign unique line numbers to trailing hunks. The line
927        // numbers can't be real anyway because preceding conflict hunks might
928        // have been resolved.
929        max_line_number.left = max(max_line_number.left, new_line_number.left);
930        max_line_number.right = max(max_line_number.right, new_line_number.right);
931    }
932
933    writeln!(formatter.labeled("hunk_header"), ">>>>>>> Conflict ends")?;
934    Ok(max_line_number)
935}
936
937fn show_color_words_resolved_hunks(
938    formatter: &mut dyn Formatter,
939    contents: [&BStr; 2],
940    mut line_number: DiffLineNumber,
941    labels: [&str; 2],
942    options: &ColorWordsDiffOptions,
943) -> io::Result<DiffLineNumber> {
944    let line_diff = diff_by_line(contents, &options.line_diff);
945    // Matching entries shouldn't appear consecutively in diff of two inputs.
946    let mut context: Option<[&BStr; 2]> = None;
947    let mut emitted = false;
948
949    for hunk in line_diff.hunks() {
950        let hunk_contents: [&BStr; 2] = hunk.contents[..].try_into().unwrap();
951        match hunk.kind {
952            DiffHunkKind::Matching => {
953                context = Some(hunk_contents);
954            }
955            DiffHunkKind::Different => {
956                let num_after = if emitted { options.context } else { 0 };
957                let num_before = options.context;
958                line_number = show_color_words_context_lines(
959                    formatter,
960                    context.as_slice(),
961                    line_number,
962                    labels,
963                    options,
964                    num_after,
965                    num_before,
966                )?;
967                context = None;
968                emitted = true;
969                line_number = show_color_words_diff_lines(
970                    formatter,
971                    hunk_contents,
972                    line_number,
973                    labels,
974                    options,
975                )?;
976            }
977        }
978    }
979
980    let num_after = if emitted { options.context } else { 0 };
981    let num_before = 0;
982    show_color_words_context_lines(
983        formatter,
984        context.as_slice(),
985        line_number,
986        labels,
987        options,
988        num_after,
989        num_before,
990    )
991}
992
993/// Prints `num_after` lines, ellipsis, and `num_before` lines.
994fn show_color_words_context_lines(
995    formatter: &mut dyn Formatter,
996    contexts: &[[&BStr; 2]],
997    mut line_number: DiffLineNumber,
998    labels: [&str; 2],
999    options: &ColorWordsDiffOptions,
1000    num_after: usize,
1001    num_before: usize,
1002) -> io::Result<DiffLineNumber> {
1003    const SKIPPED_CONTEXT_LINE: &str = "    ...\n";
1004    let extract = |side: usize| -> (Vec<&[u8]>, Vec<&[u8]>, u32) {
1005        let mut lines = contexts
1006            .iter()
1007            .flat_map(|contents| contents[side].split_inclusive(|b| *b == b'\n'))
1008            .fuse();
1009        let after_lines = lines.by_ref().take(num_after).collect();
1010        let before_lines = lines.by_ref().rev().take(num_before + 1).collect();
1011        let num_skipped: u32 = lines.count().try_into().unwrap();
1012        (after_lines, before_lines, num_skipped)
1013    };
1014    let show = |formatter: &mut dyn Formatter,
1015                [left_lines, right_lines]: [&[&[u8]]; 2],
1016                mut line_number: DiffLineNumber| {
1017        if left_lines == right_lines {
1018            for line in left_lines {
1019                show_color_words_line_number(
1020                    formatter,
1021                    [Some(line_number.left), Some(line_number.right)],
1022                    labels,
1023                )?;
1024                show_color_words_inline_hunks(
1025                    formatter,
1026                    &[(DiffLineHunkSide::Both, line.as_ref())],
1027                    labels,
1028                )?;
1029                line_number.left += 1;
1030                line_number.right += 1;
1031            }
1032            Ok(line_number)
1033        } else {
1034            let left = left_lines.concat();
1035            let right = right_lines.concat();
1036            show_color_words_diff_lines(
1037                formatter,
1038                [&left, &right].map(BStr::new),
1039                line_number,
1040                labels,
1041                options,
1042            )
1043        }
1044    };
1045
1046    let (left_after, mut left_before, num_left_skipped) = extract(0);
1047    let (right_after, mut right_before, num_right_skipped) = extract(1);
1048    line_number = show(formatter, [&left_after, &right_after], line_number)?;
1049    if num_left_skipped > 0 || num_right_skipped > 0 {
1050        write!(formatter, "{SKIPPED_CONTEXT_LINE}")?;
1051        line_number.left += num_left_skipped;
1052        line_number.right += num_right_skipped;
1053        if left_before.len() > num_before {
1054            left_before.pop();
1055            line_number.left += 1;
1056        }
1057        if right_before.len() > num_before {
1058            right_before.pop();
1059            line_number.right += 1;
1060        }
1061    }
1062    left_before.reverse();
1063    right_before.reverse();
1064    line_number = show(formatter, [&left_before, &right_before], line_number)?;
1065    Ok(line_number)
1066}
1067
1068fn show_color_words_diff_lines(
1069    formatter: &mut dyn Formatter,
1070    contents: [&BStr; 2],
1071    mut line_number: DiffLineNumber,
1072    labels: [&str; 2],
1073    options: &ColorWordsDiffOptions,
1074) -> io::Result<DiffLineNumber> {
1075    let word_diff_hunks = Diff::by_word(contents).hunks().collect_vec();
1076    let can_inline = match options.max_inline_alternation {
1077        None => true,     // unlimited
1078        Some(0) => false, // no need to count alternation
1079        Some(max_num) => {
1080            let groups = split_diff_hunks_by_matching_newline(&word_diff_hunks);
1081            groups.map(count_diff_alternation).max().unwrap_or(0) <= max_num
1082        }
1083    };
1084    if can_inline {
1085        let mut diff_line_iter =
1086            DiffLineIterator::with_line_number(word_diff_hunks.iter(), line_number);
1087        for diff_line in diff_line_iter.by_ref() {
1088            show_color_words_line_number(
1089                formatter,
1090                [
1091                    diff_line
1092                        .has_left_content()
1093                        .then_some(diff_line.line_number.left),
1094                    diff_line
1095                        .has_right_content()
1096                        .then_some(diff_line.line_number.right),
1097                ],
1098                labels,
1099            )?;
1100            show_color_words_inline_hunks(formatter, &diff_line.hunks, labels)?;
1101        }
1102        line_number = diff_line_iter.next_line_number();
1103    } else {
1104        let [left_lines, right_lines] = unzip_diff_hunks_to_lines(&word_diff_hunks);
1105        let [left_label, right_label] = labels;
1106        for tokens in &left_lines {
1107            show_color_words_line_number(formatter, [Some(line_number.left), None], labels)?;
1108            show_color_words_single_sided_line(formatter, tokens, left_label)?;
1109            line_number.left += 1;
1110        }
1111        for tokens in &right_lines {
1112            show_color_words_line_number(formatter, [None, Some(line_number.right)], labels)?;
1113            show_color_words_single_sided_line(formatter, tokens, right_label)?;
1114            line_number.right += 1;
1115        }
1116    }
1117    Ok(line_number)
1118}
1119
1120fn show_color_words_line_number(
1121    formatter: &mut dyn Formatter,
1122    [left_line_number, right_line_number]: [Option<u32>; 2],
1123    [left_label, right_label]: [&str; 2],
1124) -> io::Result<()> {
1125    if let Some(line_number) = left_line_number {
1126        formatter.with_label(left_label, |formatter| {
1127            write!(formatter.labeled("line_number"), "{line_number:>4}")
1128        })?;
1129        write!(formatter, " ")?;
1130    } else {
1131        write!(formatter, "     ")?;
1132    }
1133    if let Some(line_number) = right_line_number {
1134        formatter.with_label(right_label, |formatter| {
1135            write!(formatter.labeled("line_number"), "{line_number:>4}",)
1136        })?;
1137        write!(formatter, ": ")?;
1138    } else {
1139        write!(formatter, "    : ")?;
1140    }
1141    Ok(())
1142}
1143
1144/// Prints line hunks which may contain tokens originating from both sides.
1145fn show_color_words_inline_hunks(
1146    formatter: &mut dyn Formatter,
1147    line_hunks: &[(DiffLineHunkSide, &BStr)],
1148    [left_label, right_label]: [&str; 2],
1149) -> io::Result<()> {
1150    for (side, data) in line_hunks {
1151        let label = match side {
1152            DiffLineHunkSide::Both => None,
1153            DiffLineHunkSide::Left => Some(left_label),
1154            DiffLineHunkSide::Right => Some(right_label),
1155        };
1156        if let Some(label) = label {
1157            formatter.with_label(label, |formatter| {
1158                formatter.with_label("token", |formatter| formatter.write_all(data))
1159            })?;
1160        } else {
1161            formatter.write_all(data)?;
1162        }
1163    }
1164    let (_, data) = line_hunks.last().expect("diff line must not be empty");
1165    if !data.ends_with(b"\n") {
1166        writeln!(formatter)?;
1167    };
1168    Ok(())
1169}
1170
1171/// Prints left/right-only line tokens with the given label.
1172fn show_color_words_single_sided_line(
1173    formatter: &mut dyn Formatter,
1174    tokens: &[(DiffTokenType, &[u8])],
1175    label: &str,
1176) -> io::Result<()> {
1177    formatter.with_label(label, |formatter| show_diff_line_tokens(formatter, tokens))?;
1178    let (_, data) = tokens.last().expect("diff line must not be empty");
1179    if !data.ends_with(b"\n") {
1180        writeln!(formatter)?;
1181    };
1182    Ok(())
1183}
1184
1185/// Counts number of diff-side alternation, ignoring matching hunks.
1186///
1187/// This function is meant to measure visual complexity of diff hunks. It's easy
1188/// to read hunks containing some removed or added words, but is getting harder
1189/// as more removes and adds interleaved.
1190///
1191/// For example,
1192/// - `[matching]` -> 0
1193/// - `[left]` -> 1
1194/// - `[left, matching, left]` -> 1
1195/// - `[matching, left, right, matching, right]` -> 2
1196/// - `[left, right, matching, right, left]` -> 3
1197fn count_diff_alternation(diff_hunks: &[DiffHunk]) -> usize {
1198    diff_hunks
1199        .iter()
1200        .filter_map(|hunk| match hunk.kind {
1201            DiffHunkKind::Matching => None,
1202            DiffHunkKind::Different => Some(&hunk.contents),
1203        })
1204        // Map non-empty diff side to index (0: left, 1: right)
1205        .flat_map(|contents| contents.iter().positions(|content| !content.is_empty()))
1206        // Omit e.g. left->(matching->)*left
1207        .dedup()
1208        .count()
1209}
1210
1211/// Splits hunks into slices of contiguous changed lines.
1212fn split_diff_hunks_by_matching_newline<'a, 'b>(
1213    diff_hunks: &'a [DiffHunk<'b>],
1214) -> impl Iterator<Item = &'a [DiffHunk<'b>]> {
1215    diff_hunks.split_inclusive(|hunk| match hunk.kind {
1216        DiffHunkKind::Matching => hunk.contents.iter().all(|content| content.contains(&b'\n')),
1217        DiffHunkKind::Different => false,
1218    })
1219}
1220
1221struct FileContent<T> {
1222    /// false if this file is likely text; true if it is likely binary.
1223    is_binary: bool,
1224    contents: T,
1225}
1226
1227impl FileContent<Merge<BString>> {
1228    fn is_empty(&self) -> bool {
1229        self.contents.as_resolved().is_some_and(|c| c.is_empty())
1230    }
1231}
1232
1233fn file_content_for_diff<T>(
1234    path: &RepoPath,
1235    file: &mut MaterializedFileValue,
1236    map_resolved: impl FnOnce(BString) -> T,
1237) -> BackendResult<FileContent<T>> {
1238    // If this is a binary file, don't show the full contents.
1239    // Determine whether it's binary by whether the first 8k bytes contain a null
1240    // character; this is the same heuristic used by git as of writing: https://github.com/git/git/blob/eea0e59ffbed6e33d171ace5be13cde9faa41639/xdiff-interface.c#L192-L198
1241    const PEEK_SIZE: usize = 8000;
1242    // TODO: currently we look at the whole file, even though for binary files we
1243    // only need to know the file size. To change that we'd have to extend all
1244    // the data backends to support getting the length.
1245    let contents = BString::new(file.read_all(path).block_on()?);
1246    let start = &contents[..PEEK_SIZE.min(contents.len())];
1247    Ok(FileContent {
1248        is_binary: start.contains(&b'\0'),
1249        contents: map_resolved(contents),
1250    })
1251}
1252
1253fn diff_content(
1254    path: &RepoPath,
1255    value: MaterializedTreeValue,
1256    materialize_options: &ConflictMaterializeOptions,
1257) -> BackendResult<FileContent<BString>> {
1258    diff_content_with(
1259        path,
1260        value,
1261        |content| content,
1262        |contents| materialize_merge_result_to_bytes(&contents, materialize_options),
1263    )
1264}
1265
1266fn diff_content_as_merge(
1267    path: &RepoPath,
1268    value: MaterializedTreeValue,
1269) -> BackendResult<FileContent<Merge<BString>>> {
1270    diff_content_with(path, value, Merge::resolved, |contents| contents)
1271}
1272
1273fn diff_content_with<T>(
1274    path: &RepoPath,
1275    value: MaterializedTreeValue,
1276    map_resolved: impl FnOnce(BString) -> T,
1277    map_conflict: impl FnOnce(Merge<BString>) -> T,
1278) -> BackendResult<FileContent<T>> {
1279    match value {
1280        MaterializedTreeValue::Absent => Ok(FileContent {
1281            is_binary: false,
1282            contents: map_resolved(BString::default()),
1283        }),
1284        MaterializedTreeValue::AccessDenied(err) => Ok(FileContent {
1285            is_binary: false,
1286            contents: map_resolved(format!("Access denied: {err}").into()),
1287        }),
1288        MaterializedTreeValue::File(mut file) => {
1289            file_content_for_diff(path, &mut file, map_resolved)
1290        }
1291        MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent {
1292            // Unix file paths can't contain null bytes.
1293            is_binary: false,
1294            contents: map_resolved(target.into()),
1295        }),
1296        MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent {
1297            is_binary: false,
1298            contents: map_resolved(format!("Git submodule checked out at {id}").into()),
1299        }),
1300        // TODO: are we sure this is never binary?
1301        MaterializedTreeValue::FileConflict(file) => Ok(FileContent {
1302            is_binary: false,
1303            contents: map_conflict(file.contents),
1304        }),
1305        MaterializedTreeValue::OtherConflict { id } => Ok(FileContent {
1306            is_binary: false,
1307            contents: map_resolved(id.describe().into()),
1308        }),
1309        MaterializedTreeValue::Tree(id) => {
1310            panic!("Unexpected tree with id {id:?} in diff at path {path:?}");
1311        }
1312    }
1313}
1314
1315fn basic_diff_file_type(value: &MaterializedTreeValue) -> &'static str {
1316    match value {
1317        MaterializedTreeValue::Absent => {
1318            panic!("absent path in diff");
1319        }
1320        MaterializedTreeValue::AccessDenied(_) => "access denied",
1321        MaterializedTreeValue::File(file) => {
1322            if file.executable {
1323                "executable file"
1324            } else {
1325                "regular file"
1326            }
1327        }
1328        MaterializedTreeValue::Symlink { .. } => "symlink",
1329        MaterializedTreeValue::Tree(_) => "tree",
1330        MaterializedTreeValue::GitSubmodule(_) => "Git submodule",
1331        MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. } => {
1332            "conflict"
1333        }
1334    }
1335}
1336
1337pub async fn show_color_words_diff(
1338    formatter: &mut dyn Formatter,
1339    store: &Store,
1340    tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1341    path_converter: &RepoPathUiConverter,
1342    options: &ColorWordsDiffOptions,
1343    marker_style: ConflictMarkerStyle,
1344) -> Result<(), DiffRenderError> {
1345    let materialize_options = ConflictMaterializeOptions {
1346        marker_style,
1347        marker_len: None,
1348    };
1349    let empty_content = || Merge::resolved(BString::default());
1350    let mut diff_stream = materialized_diff_stream(store, tree_diff);
1351    while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1352        let left_path = path.source();
1353        let right_path = path.target();
1354        let left_ui_path = path_converter.format_file_path(left_path);
1355        let right_ui_path = path_converter.format_file_path(right_path);
1356        let (left_value, right_value) = values?;
1357
1358        match (&left_value, &right_value) {
1359            (MaterializedTreeValue::AccessDenied(source), _) => {
1360                write!(
1361                    formatter.labeled("access-denied"),
1362                    "Access denied to {left_ui_path}:"
1363                )?;
1364                writeln!(formatter, " {source}")?;
1365                continue;
1366            }
1367            (_, MaterializedTreeValue::AccessDenied(source)) => {
1368                write!(
1369                    formatter.labeled("access-denied"),
1370                    "Access denied to {right_ui_path}:"
1371                )?;
1372                writeln!(formatter, " {source}")?;
1373                continue;
1374            }
1375            _ => {}
1376        }
1377        if left_value.is_absent() {
1378            let description = basic_diff_file_type(&right_value);
1379            writeln!(
1380                formatter.labeled("header"),
1381                "Added {description} {right_ui_path}:"
1382            )?;
1383            let right_content = diff_content_as_merge(right_path, right_value)?;
1384            if right_content.is_empty() {
1385                writeln!(formatter.labeled("empty"), "    (empty)")?;
1386            } else if right_content.is_binary {
1387                writeln!(formatter.labeled("binary"), "    (binary)")?;
1388            } else {
1389                show_color_words_diff_hunks(
1390                    formatter,
1391                    [&empty_content(), &right_content.contents],
1392                    options,
1393                    &materialize_options,
1394                )?;
1395            }
1396        } else if right_value.is_present() {
1397            let description = match (&left_value, &right_value) {
1398                (MaterializedTreeValue::File(left), MaterializedTreeValue::File(right)) => {
1399                    if left.executable && right.executable {
1400                        "Modified executable file".to_string()
1401                    } else if left.executable {
1402                        "Executable file became non-executable at".to_string()
1403                    } else if right.executable {
1404                        "Non-executable file became executable at".to_string()
1405                    } else {
1406                        "Modified regular file".to_string()
1407                    }
1408                }
1409                (
1410                    MaterializedTreeValue::FileConflict(_)
1411                    | MaterializedTreeValue::OtherConflict { .. },
1412                    MaterializedTreeValue::FileConflict(_)
1413                    | MaterializedTreeValue::OtherConflict { .. },
1414                ) => "Modified conflict in".to_string(),
1415                (
1416                    MaterializedTreeValue::FileConflict(_)
1417                    | MaterializedTreeValue::OtherConflict { .. },
1418                    _,
1419                ) => "Resolved conflict in".to_string(),
1420                (
1421                    _,
1422                    MaterializedTreeValue::FileConflict(_)
1423                    | MaterializedTreeValue::OtherConflict { .. },
1424                ) => "Created conflict in".to_string(),
1425                (MaterializedTreeValue::Symlink { .. }, MaterializedTreeValue::Symlink { .. }) => {
1426                    "Symlink target changed at".to_string()
1427                }
1428                (_, _) => {
1429                    let left_type = basic_diff_file_type(&left_value);
1430                    let right_type = basic_diff_file_type(&right_value);
1431                    let (first, rest) = left_type.split_at(1);
1432                    format!(
1433                        "{}{} became {} at",
1434                        first.to_ascii_uppercase(),
1435                        rest,
1436                        right_type
1437                    )
1438                }
1439            };
1440            let left_content = diff_content_as_merge(left_path, left_value)?;
1441            let right_content = diff_content_as_merge(right_path, right_value)?;
1442            if left_path == right_path {
1443                writeln!(
1444                    formatter.labeled("header"),
1445                    "{description} {right_ui_path}:"
1446                )?;
1447            } else {
1448                writeln!(
1449                    formatter.labeled("header"),
1450                    "{description} {right_ui_path} ({left_ui_path} => {right_ui_path}):"
1451                )?;
1452            }
1453            if left_content.is_binary || right_content.is_binary {
1454                writeln!(formatter.labeled("binary"), "    (binary)")?;
1455            } else if left_content.contents != right_content.contents {
1456                show_color_words_diff_hunks(
1457                    formatter,
1458                    [&left_content.contents, &right_content.contents],
1459                    options,
1460                    &materialize_options,
1461                )?;
1462            }
1463        } else {
1464            let description = basic_diff_file_type(&left_value);
1465            writeln!(
1466                formatter.labeled("header"),
1467                "Removed {description} {right_ui_path}:"
1468            )?;
1469            let left_content = diff_content_as_merge(left_path, left_value)?;
1470            if left_content.is_empty() {
1471                writeln!(formatter.labeled("empty"), "    (empty)")?;
1472            } else if left_content.is_binary {
1473                writeln!(formatter.labeled("binary"), "    (binary)")?;
1474            } else {
1475                show_color_words_diff_hunks(
1476                    formatter,
1477                    [&left_content.contents, &empty_content()],
1478                    options,
1479                    &materialize_options,
1480                )?;
1481            }
1482        }
1483    }
1484    Ok(())
1485}
1486
1487pub async fn show_file_by_file_diff(
1488    ui: &Ui,
1489    formatter: &mut dyn Formatter,
1490    store: &Store,
1491    tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1492    path_converter: &RepoPathUiConverter,
1493    tool: &ExternalMergeTool,
1494    marker_style: ConflictMarkerStyle,
1495) -> Result<(), DiffRenderError> {
1496    let materialize_options = ConflictMaterializeOptions {
1497        marker_style,
1498        marker_len: None,
1499    };
1500    let create_file = |path: &RepoPath,
1501                       wc_dir: &Path,
1502                       value: MaterializedTreeValue|
1503     -> Result<PathBuf, DiffRenderError> {
1504        let fs_path = path.to_fs_path(wc_dir)?;
1505        std::fs::create_dir_all(fs_path.parent().unwrap())?;
1506        let content = diff_content(path, value, &materialize_options)?;
1507        std::fs::write(&fs_path, content.contents)?;
1508        Ok(fs_path)
1509    };
1510
1511    let temp_dir = new_utf8_temp_dir("jj-diff-")?;
1512    let left_wc_dir = temp_dir.path().join("left");
1513    let right_wc_dir = temp_dir.path().join("right");
1514    let mut diff_stream = materialized_diff_stream(store, tree_diff);
1515    while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1516        let (left_value, right_value) = values?;
1517        let left_path = path.source();
1518        let right_path = path.target();
1519        let left_ui_path = path_converter.format_file_path(left_path);
1520        let right_ui_path = path_converter.format_file_path(right_path);
1521
1522        match (&left_value, &right_value) {
1523            (_, MaterializedTreeValue::AccessDenied(source)) => {
1524                write!(
1525                    formatter.labeled("access-denied"),
1526                    "Access denied to {right_ui_path}:"
1527                )?;
1528                writeln!(formatter, " {source}")?;
1529                continue;
1530            }
1531            (MaterializedTreeValue::AccessDenied(source), _) => {
1532                write!(
1533                    formatter.labeled("access-denied"),
1534                    "Access denied to {left_ui_path}:"
1535                )?;
1536                writeln!(formatter, " {source}")?;
1537                continue;
1538            }
1539            _ => {}
1540        }
1541        let left_path = create_file(left_path, &left_wc_dir, left_value)?;
1542        let right_path = create_file(right_path, &right_wc_dir, right_value)?;
1543        let patterns = &maplit::hashmap! {
1544            "left" => left_path
1545                .strip_prefix(temp_dir.path()).expect("path should be relative to temp_dir")
1546                .to_str().expect("temp_dir should be valid utf-8"),
1547            "right" => right_path
1548                .strip_prefix(temp_dir.path()).expect("path should be relative to temp_dir")
1549                .to_str().expect("temp_dir should be valid utf-8"),
1550        };
1551
1552        let mut writer = formatter.raw()?;
1553        invoke_external_diff(ui, writer.as_mut(), tool, temp_dir.path(), patterns)
1554            .map_err(DiffRenderError::DiffGenerate)?;
1555    }
1556    Ok::<(), DiffRenderError>(())
1557}
1558
1559struct GitDiffPart {
1560    /// Octal mode string or `None` if the file is absent.
1561    mode: Option<&'static str>,
1562    hash: String,
1563    content: FileContent<BString>,
1564}
1565
1566fn git_diff_part(
1567    path: &RepoPath,
1568    value: MaterializedTreeValue,
1569    materialize_options: &ConflictMaterializeOptions,
1570) -> Result<GitDiffPart, DiffRenderError> {
1571    const DUMMY_HASH: &str = "0000000000";
1572    let mode;
1573    let mut hash;
1574    let content;
1575    match value {
1576        MaterializedTreeValue::Absent => {
1577            return Ok(GitDiffPart {
1578                mode: None,
1579                hash: DUMMY_HASH.to_owned(),
1580                content: FileContent {
1581                    is_binary: false,
1582                    contents: BString::default(),
1583                },
1584            });
1585        }
1586        MaterializedTreeValue::AccessDenied(err) => {
1587            return Err(DiffRenderError::AccessDenied {
1588                path: path.as_internal_file_string().to_owned(),
1589                source: err,
1590            });
1591        }
1592        MaterializedTreeValue::File(mut file) => {
1593            mode = if file.executable { "100755" } else { "100644" };
1594            hash = file.id.hex();
1595            content = file_content_for_diff(path, &mut file, |content| content)?;
1596        }
1597        MaterializedTreeValue::Symlink { id, target } => {
1598            mode = "120000";
1599            hash = id.hex();
1600            content = FileContent {
1601                // Unix file paths can't contain null bytes.
1602                is_binary: false,
1603                contents: target.into(),
1604            };
1605        }
1606        MaterializedTreeValue::GitSubmodule(id) => {
1607            // TODO: What should we actually do here?
1608            mode = "040000";
1609            hash = id.hex();
1610            content = FileContent {
1611                is_binary: false,
1612                contents: BString::default(),
1613            };
1614        }
1615        MaterializedTreeValue::FileConflict(file) => {
1616            mode = match file.executable {
1617                Some(true) => "100755",
1618                Some(false) | None => "100644",
1619            };
1620            hash = DUMMY_HASH.to_owned();
1621            content = FileContent {
1622                is_binary: false, // TODO: are we sure this is never binary?
1623                contents: materialize_merge_result_to_bytes(&file.contents, materialize_options),
1624            };
1625        }
1626        MaterializedTreeValue::OtherConflict { id } => {
1627            mode = "100644";
1628            hash = DUMMY_HASH.to_owned();
1629            content = FileContent {
1630                is_binary: false,
1631                contents: id.describe().into(),
1632            };
1633        }
1634        MaterializedTreeValue::Tree(_) => {
1635            panic!("Unexpected tree in diff at path {path:?}");
1636        }
1637    }
1638    hash.truncate(10);
1639    Ok(GitDiffPart {
1640        mode: Some(mode),
1641        hash,
1642        content,
1643    })
1644}
1645
1646#[derive(Clone, Debug, Eq, PartialEq)]
1647pub struct UnifiedDiffOptions {
1648    /// Number of context lines to show.
1649    pub context: usize,
1650    /// How lines are tokenized and compared.
1651    pub line_diff: LineDiffOptions,
1652}
1653
1654impl UnifiedDiffOptions {
1655    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
1656        Ok(Self {
1657            context: settings.get("diff.git.context")?,
1658            line_diff: LineDiffOptions::default(),
1659        })
1660    }
1661
1662    fn merge_args(&mut self, args: &DiffFormatArgs) {
1663        if let Some(context) = args.context {
1664            self.context = context;
1665        }
1666        self.line_diff.merge_args(args);
1667    }
1668}
1669
1670#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1671enum DiffLineType {
1672    Context,
1673    Removed,
1674    Added,
1675}
1676
1677#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1678enum DiffTokenType {
1679    Matching,
1680    Different,
1681}
1682
1683type DiffTokenVec<'content> = Vec<(DiffTokenType, &'content [u8])>;
1684
1685struct UnifiedDiffHunk<'content> {
1686    left_line_range: Range<usize>,
1687    right_line_range: Range<usize>,
1688    lines: Vec<(DiffLineType, DiffTokenVec<'content>)>,
1689}
1690
1691impl<'content> UnifiedDiffHunk<'content> {
1692    fn extend_context_lines(&mut self, lines: impl IntoIterator<Item = &'content [u8]>) {
1693        let old_len = self.lines.len();
1694        self.lines.extend(lines.into_iter().map(|line| {
1695            let tokens = vec![(DiffTokenType::Matching, line)];
1696            (DiffLineType::Context, tokens)
1697        }));
1698        self.left_line_range.end += self.lines.len() - old_len;
1699        self.right_line_range.end += self.lines.len() - old_len;
1700    }
1701
1702    fn extend_removed_lines(&mut self, lines: impl IntoIterator<Item = DiffTokenVec<'content>>) {
1703        let old_len = self.lines.len();
1704        self.lines
1705            .extend(lines.into_iter().map(|line| (DiffLineType::Removed, line)));
1706        self.left_line_range.end += self.lines.len() - old_len;
1707    }
1708
1709    fn extend_added_lines(&mut self, lines: impl IntoIterator<Item = DiffTokenVec<'content>>) {
1710        let old_len = self.lines.len();
1711        self.lines
1712            .extend(lines.into_iter().map(|line| (DiffLineType::Added, line)));
1713        self.right_line_range.end += self.lines.len() - old_len;
1714    }
1715}
1716
1717fn unified_diff_hunks<'content>(
1718    contents: [&'content BStr; 2],
1719    options: &UnifiedDiffOptions,
1720) -> Vec<UnifiedDiffHunk<'content>> {
1721    let mut hunks = vec![];
1722    let mut current_hunk = UnifiedDiffHunk {
1723        left_line_range: 0..0,
1724        right_line_range: 0..0,
1725        lines: vec![],
1726    };
1727    let diff = diff_by_line(contents, &options.line_diff);
1728    let mut diff_hunks = diff.hunks().peekable();
1729    while let Some(hunk) = diff_hunks.next() {
1730        match hunk.kind {
1731            DiffHunkKind::Matching => {
1732                // Just use the right (i.e. new) content. We could count the
1733                // number of skipped lines separately, but the number of the
1734                // context lines should match the displayed content.
1735                let [_, right] = hunk.contents[..].try_into().unwrap();
1736                let mut lines = right.split_inclusive(|b| *b == b'\n').fuse();
1737                if !current_hunk.lines.is_empty() {
1738                    // The previous hunk line should be either removed/added.
1739                    current_hunk.extend_context_lines(lines.by_ref().take(options.context));
1740                }
1741                let before_lines = if diff_hunks.peek().is_some() {
1742                    lines.by_ref().rev().take(options.context).collect()
1743                } else {
1744                    vec![] // No more hunks
1745                };
1746                let num_skip_lines = lines.count();
1747                if num_skip_lines > 0 {
1748                    let left_start = current_hunk.left_line_range.end + num_skip_lines;
1749                    let right_start = current_hunk.right_line_range.end + num_skip_lines;
1750                    if !current_hunk.lines.is_empty() {
1751                        hunks.push(current_hunk);
1752                    }
1753                    current_hunk = UnifiedDiffHunk {
1754                        left_line_range: left_start..left_start,
1755                        right_line_range: right_start..right_start,
1756                        lines: vec![],
1757                    };
1758                }
1759                // The next hunk should be of DiffHunk::Different type if any.
1760                current_hunk.extend_context_lines(before_lines.into_iter().rev());
1761            }
1762            DiffHunkKind::Different => {
1763                let [left_lines, right_lines] =
1764                    unzip_diff_hunks_to_lines(Diff::by_word(hunk.contents).hunks());
1765                current_hunk.extend_removed_lines(left_lines);
1766                current_hunk.extend_added_lines(right_lines);
1767            }
1768        }
1769    }
1770    if !current_hunk.lines.is_empty() {
1771        hunks.push(current_hunk);
1772    }
1773    hunks
1774}
1775
1776/// Splits `[left, right]` hunk pairs into `[left_lines, right_lines]`.
1777fn unzip_diff_hunks_to_lines<'content, I>(diff_hunks: I) -> [Vec<DiffTokenVec<'content>>; 2]
1778where
1779    I: IntoIterator,
1780    I::Item: Borrow<DiffHunk<'content>>,
1781{
1782    let mut left_lines: Vec<DiffTokenVec<'content>> = vec![];
1783    let mut right_lines: Vec<DiffTokenVec<'content>> = vec![];
1784    let mut left_tokens: DiffTokenVec<'content> = vec![];
1785    let mut right_tokens: DiffTokenVec<'content> = vec![];
1786
1787    for hunk in diff_hunks {
1788        let hunk = hunk.borrow();
1789        match hunk.kind {
1790            DiffHunkKind::Matching => {
1791                // TODO: add support for unmatched contexts
1792                debug_assert!(hunk.contents.iter().all_equal());
1793                for token in hunk.contents[0].split_inclusive(|b| *b == b'\n') {
1794                    left_tokens.push((DiffTokenType::Matching, token));
1795                    right_tokens.push((DiffTokenType::Matching, token));
1796                    if token.ends_with(b"\n") {
1797                        left_lines.push(mem::take(&mut left_tokens));
1798                        right_lines.push(mem::take(&mut right_tokens));
1799                    }
1800                }
1801            }
1802            DiffHunkKind::Different => {
1803                let [left, right] = hunk.contents[..]
1804                    .try_into()
1805                    .expect("hunk should have exactly two inputs");
1806                for token in left.split_inclusive(|b| *b == b'\n') {
1807                    left_tokens.push((DiffTokenType::Different, token));
1808                    if token.ends_with(b"\n") {
1809                        left_lines.push(mem::take(&mut left_tokens));
1810                    }
1811                }
1812                for token in right.split_inclusive(|b| *b == b'\n') {
1813                    right_tokens.push((DiffTokenType::Different, token));
1814                    if token.ends_with(b"\n") {
1815                        right_lines.push(mem::take(&mut right_tokens));
1816                    }
1817                }
1818            }
1819        }
1820    }
1821
1822    if !left_tokens.is_empty() {
1823        left_lines.push(left_tokens);
1824    }
1825    if !right_tokens.is_empty() {
1826        right_lines.push(right_tokens);
1827    }
1828    [left_lines, right_lines]
1829}
1830
1831fn show_unified_diff_hunks(
1832    formatter: &mut dyn Formatter,
1833    contents: [&BStr; 2],
1834    options: &UnifiedDiffOptions,
1835) -> io::Result<()> {
1836    // "If the chunk size is 0, the first number is one lower than one would
1837    // expect." - https://www.artima.com/weblogs/viewpost.jsp?thread=164293
1838    //
1839    // The POSIX spec also states that "the ending line number of an empty range
1840    // shall be the number of the preceding line, or 0 if the range is at the
1841    // start of the file."
1842    // - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/diff.html
1843    fn to_line_number(range: Range<usize>) -> usize {
1844        if range.is_empty() {
1845            range.start
1846        } else {
1847            range.start + 1
1848        }
1849    }
1850
1851    for hunk in unified_diff_hunks(contents, options) {
1852        writeln!(
1853            formatter.labeled("hunk_header"),
1854            "@@ -{},{} +{},{} @@",
1855            to_line_number(hunk.left_line_range.clone()),
1856            hunk.left_line_range.len(),
1857            to_line_number(hunk.right_line_range.clone()),
1858            hunk.right_line_range.len()
1859        )?;
1860        for (line_type, tokens) in &hunk.lines {
1861            let (label, sigil) = match line_type {
1862                DiffLineType::Context => ("context", " "),
1863                DiffLineType::Removed => ("removed", "-"),
1864                DiffLineType::Added => ("added", "+"),
1865            };
1866            formatter.with_label(label, |formatter| {
1867                write!(formatter, "{sigil}")?;
1868                show_diff_line_tokens(formatter, tokens)
1869            })?;
1870            let (_, content) = tokens.last().expect("hunk line must not be empty");
1871            if !content.ends_with(b"\n") {
1872                write!(formatter, "\n\\ No newline at end of file\n")?;
1873            }
1874        }
1875    }
1876    Ok(())
1877}
1878
1879fn show_diff_line_tokens(
1880    formatter: &mut dyn Formatter,
1881    tokens: &[(DiffTokenType, &[u8])],
1882) -> io::Result<()> {
1883    for (token_type, content) in tokens {
1884        match token_type {
1885            DiffTokenType::Matching => formatter.write_all(content)?,
1886            DiffTokenType::Different => {
1887                formatter.with_label("token", |formatter| formatter.write_all(content))?;
1888            }
1889        }
1890    }
1891    Ok(())
1892}
1893
1894pub async fn show_git_diff(
1895    formatter: &mut dyn Formatter,
1896    store: &Store,
1897    tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1898    options: &UnifiedDiffOptions,
1899    marker_style: ConflictMarkerStyle,
1900) -> Result<(), DiffRenderError> {
1901    let materialize_options = ConflictMaterializeOptions {
1902        marker_style,
1903        marker_len: None,
1904    };
1905    let mut diff_stream = materialized_diff_stream(store, tree_diff);
1906    while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1907        let left_path = path.source();
1908        let right_path = path.target();
1909        let left_path_string = left_path.as_internal_file_string();
1910        let right_path_string = right_path.as_internal_file_string();
1911        let (left_value, right_value) = values?;
1912
1913        let left_part = git_diff_part(left_path, left_value, &materialize_options)?;
1914        let right_part = git_diff_part(right_path, right_value, &materialize_options)?;
1915
1916        formatter.with_label("file_header", |formatter| {
1917            writeln!(
1918                formatter,
1919                "diff --git a/{left_path_string} b/{right_path_string}"
1920            )?;
1921            let left_hash = &left_part.hash;
1922            let right_hash = &right_part.hash;
1923            match (left_part.mode, right_part.mode) {
1924                (None, Some(right_mode)) => {
1925                    writeln!(formatter, "new file mode {right_mode}")?;
1926                    writeln!(formatter, "index {left_hash}..{right_hash}")?;
1927                }
1928                (Some(left_mode), None) => {
1929                    writeln!(formatter, "deleted file mode {left_mode}")?;
1930                    writeln!(formatter, "index {left_hash}..{right_hash}")?;
1931                }
1932                (Some(left_mode), Some(right_mode)) => {
1933                    if let Some(op) = path.copy_operation() {
1934                        let operation = match op {
1935                            CopyOperation::Copy => "copy",
1936                            CopyOperation::Rename => "rename",
1937                        };
1938                        // TODO: include similarity index?
1939                        writeln!(formatter, "{operation} from {left_path_string}")?;
1940                        writeln!(formatter, "{operation} to {right_path_string}")?;
1941                    }
1942                    if left_mode != right_mode {
1943                        writeln!(formatter, "old mode {left_mode}")?;
1944                        writeln!(formatter, "new mode {right_mode}")?;
1945                        if left_hash != right_hash {
1946                            writeln!(formatter, "index {left_hash}..{right_hash}")?;
1947                        }
1948                    } else if left_hash != right_hash {
1949                        writeln!(formatter, "index {left_hash}..{right_hash} {left_mode}")?;
1950                    }
1951                }
1952                (None, None) => panic!("either left or right part should be present"),
1953            }
1954            Ok::<(), DiffRenderError>(())
1955        })?;
1956
1957        if left_part.content.contents == right_part.content.contents {
1958            continue; // no content hunks
1959        }
1960
1961        let left_path = match left_part.mode {
1962            Some(_) => format!("a/{left_path_string}"),
1963            None => "/dev/null".to_owned(),
1964        };
1965        let right_path = match right_part.mode {
1966            Some(_) => format!("b/{right_path_string}"),
1967            None => "/dev/null".to_owned(),
1968        };
1969        if left_part.content.is_binary || right_part.content.is_binary {
1970            // TODO: add option to emit Git binary diff
1971            writeln!(
1972                formatter,
1973                "Binary files {left_path} and {right_path} differ"
1974            )?;
1975        } else {
1976            formatter.with_label("file_header", |formatter| {
1977                writeln!(formatter, "--- {left_path}")?;
1978                writeln!(formatter, "+++ {right_path}")?;
1979                io::Result::Ok(())
1980            })?;
1981            show_unified_diff_hunks(
1982                formatter,
1983                [&left_part.content.contents, &right_part.content.contents].map(BStr::new),
1984                options,
1985            )?;
1986        }
1987    }
1988    Ok(())
1989}
1990
1991/// Generates diff of non-binary contents in Git format.
1992fn show_git_diff_texts<T: AsRef<[u8]>>(
1993    formatter: &mut dyn Formatter,
1994    [left_path, right_path]: [&str; 2],
1995    contents: [&Merge<T>; 2],
1996    options: &UnifiedDiffOptions,
1997    materialize_options: &ConflictMaterializeOptions,
1998) -> io::Result<()> {
1999    formatter.with_label("file_header", |formatter| {
2000        writeln!(formatter, "diff --git a/{left_path} b/{right_path}")?;
2001        writeln!(formatter, "--- {left_path}")?;
2002        writeln!(formatter, "+++ {right_path}")
2003    })?;
2004    let [left, right] = contents.map(|content| match content.as_resolved() {
2005        Some(text) => Cow::Borrowed(BStr::new(text)),
2006        None => Cow::Owned(materialize_merge_result_to_bytes(
2007            content,
2008            materialize_options,
2009        )),
2010    });
2011    show_unified_diff_hunks(formatter, [left.as_ref(), right.as_ref()], options)
2012}
2013
2014#[instrument(skip_all)]
2015pub async fn show_diff_summary(
2016    formatter: &mut dyn Formatter,
2017    mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2018    path_converter: &RepoPathUiConverter,
2019) -> Result<(), DiffRenderError> {
2020    while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
2021        let (before, after) = values?;
2022        let (label, sigil) = diff_status_label_and_char(&path, &before, &after);
2023        let path = if path.copy_operation().is_some() {
2024            path_converter.format_copied_path(path.source(), path.target())
2025        } else {
2026            path_converter.format_file_path(path.target())
2027        };
2028        writeln!(formatter.labeled(label), "{sigil} {path}")?;
2029    }
2030    Ok(())
2031}
2032
2033pub fn diff_status_label_and_char(
2034    path: &CopiesTreeDiffEntryPath,
2035    before: &MergedTreeValue,
2036    after: &MergedTreeValue,
2037) -> (&'static str, char) {
2038    if let Some(op) = path.copy_operation() {
2039        match op {
2040            CopyOperation::Copy => ("copied", 'C'),
2041            CopyOperation::Rename => ("renamed", 'R'),
2042        }
2043    } else {
2044        match (before.is_present(), after.is_present()) {
2045            (true, true) => ("modified", 'M'),
2046            (false, true) => ("added", 'A'),
2047            (true, false) => ("removed", 'D'),
2048            (false, false) => panic!("values pair must differ"),
2049        }
2050    }
2051}
2052
2053#[derive(Clone, Debug, Default, Eq, PartialEq)]
2054pub struct DiffStatOptions {
2055    /// How lines are tokenized and compared.
2056    pub line_diff: LineDiffOptions,
2057}
2058
2059impl DiffStatOptions {
2060    fn merge_args(&mut self, args: &DiffFormatArgs) {
2061        self.line_diff.merge_args(args);
2062    }
2063}
2064
2065#[derive(Clone, Debug)]
2066pub struct DiffStats {
2067    entries: Vec<DiffStatEntry>,
2068}
2069
2070impl DiffStats {
2071    /// Calculates stats of changed lines per file.
2072    pub async fn calculate(
2073        store: &Store,
2074        tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2075        options: &DiffStatOptions,
2076        marker_style: ConflictMarkerStyle,
2077    ) -> BackendResult<Self> {
2078        let materialize_options = ConflictMaterializeOptions {
2079            marker_style,
2080            marker_len: None,
2081        };
2082        let entries = materialized_diff_stream(store, tree_diff)
2083            .map(|MaterializedTreeDiffEntry { path, values }| {
2084                let (left, right) = values?;
2085                let left_content = diff_content(path.source(), left, &materialize_options)?;
2086                let right_content = diff_content(path.target(), right, &materialize_options)?;
2087                let stat = get_diff_stat_entry(path, [&left_content, &right_content], options);
2088                BackendResult::Ok(stat)
2089            })
2090            .try_collect()
2091            .await?;
2092        Ok(Self { entries })
2093    }
2094
2095    /// List of stats per file.
2096    pub fn entries(&self) -> &[DiffStatEntry] {
2097        &self.entries
2098    }
2099
2100    /// Total number of inserted lines.
2101    pub fn count_total_added(&self) -> usize {
2102        self.entries
2103            .iter()
2104            .filter_map(|stat| stat.added_removed.map(|(added, _)| added))
2105            .sum()
2106    }
2107
2108    /// Total number of deleted lines.
2109    pub fn count_total_removed(&self) -> usize {
2110        self.entries
2111            .iter()
2112            .filter_map(|stat| stat.added_removed.map(|(_, removed)| removed))
2113            .sum()
2114    }
2115}
2116
2117#[derive(Clone, Debug)]
2118pub struct DiffStatEntry {
2119    pub path: CopiesTreeDiffEntryPath,
2120    /// Lines added and removed; None for binary files.
2121    pub added_removed: Option<(usize, usize)>,
2122    /// Change in file size in bytes.
2123    pub bytes_delta: isize,
2124}
2125
2126fn get_diff_stat_entry(
2127    path: CopiesTreeDiffEntryPath,
2128    contents: [&FileContent<BString>; 2],
2129    options: &DiffStatOptions,
2130) -> DiffStatEntry {
2131    let [left_content, right_content] = contents;
2132    let added_removed = if left_content.is_binary || right_content.is_binary {
2133        None
2134    } else {
2135        let diff = diff_by_line(
2136            contents.map(|content| &content.contents),
2137            &options.line_diff,
2138        );
2139        let mut added = 0;
2140        let mut removed = 0;
2141        for hunk in diff.hunks() {
2142            match hunk.kind {
2143                DiffHunkKind::Matching => {}
2144                DiffHunkKind::Different => {
2145                    let [left, right] = hunk.contents[..].try_into().unwrap();
2146                    removed += left.split_inclusive(|b| *b == b'\n').count();
2147                    added += right.split_inclusive(|b| *b == b'\n').count();
2148                }
2149            }
2150        }
2151        Some((added, removed))
2152    };
2153
2154    DiffStatEntry {
2155        path,
2156        added_removed,
2157        bytes_delta: right_content.contents.len() as isize - left_content.contents.len() as isize,
2158    }
2159}
2160
2161pub fn show_diff_stats(
2162    formatter: &mut dyn Formatter,
2163    stats: &DiffStats,
2164    path_converter: &RepoPathUiConverter,
2165    display_width: usize,
2166) -> io::Result<()> {
2167    let ui_paths = stats
2168        .entries()
2169        .iter()
2170        .map(|stat| {
2171            if stat.path.copy_operation().is_some() {
2172                path_converter.format_copied_path(stat.path.source(), stat.path.target())
2173            } else {
2174                path_converter.format_file_path(stat.path.target())
2175            }
2176        })
2177        .collect_vec();
2178
2179    // Entries format like:
2180    //   path/to/file | 123 ++--
2181    // or, for binary files:
2182    //   path/to/file | (binary) +1234 bytes
2183    //
2184    // Depending on display widths, we can elide part of the path,
2185    // and the the ++-- bar will adjust its scale to fill the rest.
2186
2187    // Choose how many columns to use for the path.  The right side will use the
2188    // rest. Start with the longest path.  The code below might shorten it.
2189    let mut max_path_width = ui_paths.iter().map(|s| s.width()).max().unwrap_or(0);
2190
2191    // Fit to the available display width, but always assume at least a tiny bit of
2192    // room.
2193    let available_width = max(display_width.saturating_sub(" | ".len()), 8);
2194
2195    // Measure the widest right side for line diffs and reduce max_path_width if
2196    // needed.
2197    let max_diffs = stats
2198        .entries()
2199        .iter()
2200        .filter_map(|stat| {
2201            let (added, removed) = stat.added_removed?;
2202            Some(added + removed)
2203        })
2204        .max();
2205    let diff_number_width = max_diffs.map_or(0, |n| n.to_string().len());
2206    if max_diffs.is_some() {
2207        let width = diff_number_width + " ".len();
2208        // Ensure 30% of the space for displaying the ++-- graph:
2209        max_path_width =
2210            max_path_width.min((0.7 * available_width.saturating_sub(width) as f64) as usize);
2211    };
2212
2213    // Measure widest right side for binary diffs and reduce max_path_width if
2214    // needed.
2215    let max_bytes = stats
2216        .entries
2217        .iter()
2218        .filter(|stat| stat.added_removed.is_none())
2219        .map(|stat| stat.bytes_delta.abs())
2220        .max();
2221    if let Some(max) = max_bytes {
2222        let width = if max > 0 {
2223            format!("(binary) {max:+} bytes").len()
2224        } else {
2225            "(binary)".len()
2226        };
2227        max_path_width = max_path_width.min(available_width.saturating_sub(width));
2228    }
2229
2230    // Now that we've chosen the path width, use the rest of the space for the ++--
2231    // bar.
2232    let max_bar_width =
2233        available_width.saturating_sub(max_path_width + diff_number_width + " ".len());
2234    let factor = match max_diffs {
2235        Some(max) if max > max_bar_width => max_bar_width as f64 / max as f64,
2236        _ => 1.0,
2237    };
2238
2239    for (stat, ui_path) in iter::zip(stats.entries(), &ui_paths) {
2240        // replace start of path with ellipsis if the path is too long
2241        let (path, path_width) = text_util::elide_start(ui_path, "...", max_path_width);
2242        let path_pad_width = max_path_width - path_width;
2243        write!(
2244            formatter,
2245            "{path}{:path_pad_width$} | ",
2246            "", // pad to max_path_width
2247        )?;
2248        if let Some((added, removed)) = stat.added_removed {
2249            let bar_length = ((added + removed) as f64 * factor) as usize;
2250            // If neither adds nor removes are present, bar length should be zero.
2251            // If only one is present, bar length should be at least 1.
2252            // If both are present, bar length should be at least 2.
2253            //
2254            // Fractional space after scaling is given to whichever of adds/removes is
2255            // smaller, to show at least one tick for small (but nonzero) counts.
2256            let bar_length = bar_length.max((added > 0) as usize + (removed > 0) as usize);
2257            let (bar_added, bar_removed) = if added < removed {
2258                let len = (added as f64 * factor).ceil() as usize;
2259                (len, bar_length - len)
2260            } else {
2261                let len = (removed as f64 * factor).ceil() as usize;
2262                (bar_length - len, len)
2263            };
2264            write!(
2265                formatter,
2266                "{:>diff_number_width$}{}",
2267                added + removed,
2268                if bar_added + bar_removed > 0 { " " } else { "" },
2269            )?;
2270            write!(formatter.labeled("added"), "{}", "+".repeat(bar_added))?;
2271            writeln!(formatter.labeled("removed"), "{}", "-".repeat(bar_removed))?;
2272        } else {
2273            write!(formatter.labeled("binary"), "(binary)")?;
2274            if stat.bytes_delta != 0 {
2275                let label = if stat.bytes_delta < 0 {
2276                    "removed"
2277                } else {
2278                    "added"
2279                };
2280                write!(formatter.labeled(label), " {:+}", stat.bytes_delta)?;
2281                write!(formatter, " bytes")?;
2282            }
2283            writeln!(formatter)?;
2284        }
2285    }
2286
2287    let total_added = stats.count_total_added();
2288    let total_removed = stats.count_total_removed();
2289    let total_files = stats.entries().len();
2290    writeln!(
2291        formatter.labeled("stat-summary"),
2292        "{} file{} changed, {} insertion{}(+), {} deletion{}(-)",
2293        total_files,
2294        if total_files == 1 { "" } else { "s" },
2295        total_added,
2296        if total_added == 1 { "" } else { "s" },
2297        total_removed,
2298        if total_removed == 1 { "" } else { "s" },
2299    )?;
2300    Ok(())
2301}
2302
2303pub async fn show_types(
2304    formatter: &mut dyn Formatter,
2305    mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2306    path_converter: &RepoPathUiConverter,
2307) -> Result<(), DiffRenderError> {
2308    while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
2309        let (before, after) = values?;
2310        writeln!(
2311            formatter.labeled("modified"),
2312            "{}{} {}",
2313            diff_summary_char(&before),
2314            diff_summary_char(&after),
2315            path_converter.format_copied_path(path.source(), path.target())
2316        )?;
2317    }
2318    Ok(())
2319}
2320
2321fn diff_summary_char(value: &MergedTreeValue) -> char {
2322    match value.as_resolved() {
2323        Some(None) => '-',
2324        Some(Some(TreeValue::File { .. })) => 'F',
2325        Some(Some(TreeValue::Symlink(_))) => 'L',
2326        Some(Some(TreeValue::GitSubmodule(_))) => 'G',
2327        None => 'C',
2328        Some(Some(TreeValue::Tree(_))) | Some(Some(TreeValue::Conflict(_))) => {
2329            panic!("Unexpected {value:?} in diff")
2330        }
2331    }
2332}
2333
2334pub async fn show_names(
2335    formatter: &mut dyn Formatter,
2336    mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2337    path_converter: &RepoPathUiConverter,
2338) -> io::Result<()> {
2339    while let Some(CopiesTreeDiffEntry { path, .. }) = tree_diff.next().await {
2340        writeln!(
2341            formatter,
2342            "{}",
2343            path_converter.format_file_path(path.target())
2344        )?;
2345    }
2346    Ok(())
2347}
2348
2349pub async fn show_templated(
2350    formatter: &mut dyn Formatter,
2351    mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2352    template: &TemplateRenderer<'_, commit_templater::TreeDiffEntry>,
2353) -> Result<(), DiffRenderError> {
2354    while let Some(entry) = tree_diff.next().await {
2355        let entry = commit_templater::TreeDiffEntry::from_backend_entry_with_copies(entry)?;
2356        template.format(&entry, formatter)?;
2357    }
2358    Ok(())
2359}