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