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