Skip to main content

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