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::future;
18use std::io;
19use std::iter;
20use std::ops::Range;
21use std::path::Path;
22use std::path::PathBuf;
23
24use bstr::BStr;
25use bstr::BString;
26use clap_complete::ArgValueCandidates;
27use futures::StreamExt as _;
28use futures::TryStreamExt as _;
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.labeled("summary"), tree_diff, path_converter)
485                        .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                            .await?;
492                    show_diff_stats(*formatter.labeled("stat"), &stats, path_converter, width)?;
493                }
494                DiffFormat::Types => {
495                    let tree_diff = diff_stream();
496                    show_types(*formatter.labeled("types"), tree_diff, path_converter).await?;
497                }
498                DiffFormat::NameOnly => {
499                    let tree_diff = diff_stream();
500                    show_names(*formatter.labeled("name_only"), tree_diff, path_converter).await?;
501                }
502                DiffFormat::Git(options) => {
503                    let tree_diff = diff_stream();
504                    show_git_diff(
505                        *formatter.labeled("git"),
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.labeled("color_words"),
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                            .await
556                            .map_err(DiffRenderError::DiffGenerate)
557                        }
558                    }?;
559                }
560            }
561        }
562        Ok(())
563    }
564
565    fn show_diff_commit_descriptions(
566        &self,
567        formatter: &mut dyn Formatter,
568        descriptions: Diff<&Merge<&str>>,
569    ) -> Result<(), DiffRenderError> {
570        if !descriptions.is_changed() {
571            return Ok(());
572        }
573        const DUMMY_PATH: &str = "JJ-COMMIT-DESCRIPTION";
574        let materialize_options = ConflictMaterializeOptions {
575            marker_style: self.conflict_marker_style,
576            marker_len: None,
577            merge: self.repo.store().merge_options().clone(),
578        };
579        for format in &self.formats {
580            match format {
581                // Omit diff from "short" formats. Printing dummy file path
582                // wouldn't be useful.
583                DiffFormat::Summary
584                | DiffFormat::Stat(_)
585                | DiffFormat::Types
586                | DiffFormat::NameOnly => {}
587                DiffFormat::Git(options) => {
588                    // Git format must be parsable, so use dummy file path.
589                    show_git_diff_texts(
590                        formatter,
591                        Diff::new(DUMMY_PATH, DUMMY_PATH),
592                        descriptions,
593                        options,
594                        &materialize_options,
595                    )?;
596                }
597                DiffFormat::ColorWords(options) => {
598                    writeln!(formatter.labeled("header"), "Modified commit description:")?;
599                    show_color_words_diff_hunks(
600                        formatter,
601                        descriptions,
602                        Diff::new(&ConflictLabels::unlabeled(), &ConflictLabels::unlabeled()),
603                        options,
604                        &materialize_options,
605                    )?;
606                }
607                DiffFormat::Tool(_) => {
608                    // TODO: materialize commit description as file?
609                }
610            }
611        }
612        Ok(())
613    }
614
615    /// Generates diff between `from_commits` and `to_commit` based off their
616    /// parents. The `from_commits` will temporarily be rebased onto the
617    /// `to_commit` parents to exclude unrelated changes.
618    pub async fn show_inter_diff(
619        &self,
620        ui: &Ui,
621        formatter: &mut dyn Formatter,
622        from_commits: &[Commit],
623        to_commit: &Commit,
624        matcher: &dyn Matcher,
625        width: usize,
626    ) -> Result<(), DiffRenderError> {
627        let mut formatter = formatter.labeled("diff");
628        let from_description = if from_commits.is_empty() {
629            Merge::resolved("")
630        } else {
631            // TODO: use common predecessor as the base description?
632            MergeBuilder::from_iter(itertools::intersperse(
633                from_commits.iter().map(|c| c.description()),
634                "",
635            ))
636            .build()
637            .simplify()
638        };
639        let to_description = Merge::resolved(to_commit.description());
640        let from_tree = rebase_to_dest_parent(self.repo, from_commits, to_commit).await?;
641        let to_tree = to_commit.tree();
642        let copy_records = CopyRecords::default(); // TODO
643        self.show_diff_commit_descriptions(
644            *formatter,
645            Diff::new(&from_description, &to_description),
646        )?;
647        self.show_diff_trees(
648            ui,
649            *formatter,
650            Diff::new(&from_tree, &to_tree),
651            matcher,
652            &copy_records,
653            width,
654        )
655        .await
656    }
657
658    /// Generates diff of the given `commit` compared to its parents.
659    pub async fn show_patch(
660        &self,
661        ui: &Ui,
662        formatter: &mut dyn Formatter,
663        commit: &Commit,
664        matcher: &dyn Matcher,
665        width: usize,
666    ) -> Result<(), DiffRenderError> {
667        let from_tree = commit.parent_tree(self.repo).await?;
668        let to_tree = commit.tree();
669        let mut copy_records = CopyRecords::default();
670        for parent_id in commit.parent_ids() {
671            let records =
672                get_copy_records(self.repo.store(), parent_id, commit.id(), matcher).await?;
673            copy_records.add_records(records);
674        }
675        self.show_diff(
676            ui,
677            formatter,
678            Diff::new(&from_tree, &to_tree),
679            matcher,
680            &copy_records,
681            width,
682        )
683        .await
684    }
685}
686
687pub async fn get_copy_records(
688    store: &Store,
689    root: &CommitId,
690    head: &CommitId,
691    matcher: &dyn Matcher,
692) -> BackendResult<Vec<CopyRecord>> {
693    // TODO: teach backend about matching path prefixes?
694    let stream = store.get_copy_records(None, root, head)?;
695    // TODO: test record.source as well? should be AND-ed or OR-ed?
696    stream
697        .try_filter(|record| future::ready(matcher.matches(&record.target)))
698        .try_collect()
699        .await
700}
701
702/// How conflicts are processed and rendered in diffs.
703#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
704#[serde(rename_all = "kebab-case")]
705pub enum ConflictDiffMethod {
706    /// Compares materialized contents.
707    #[default]
708    Materialize,
709    /// Compares individual pairs of left and right contents.
710    Pair,
711}
712
713#[derive(Clone, Debug, Default, Eq, PartialEq)]
714pub struct LineDiffOptions {
715    /// How equivalence of lines is tested.
716    pub compare_mode: LineCompareMode,
717    // TODO: add --ignore-blank-lines, etc. which aren't mutually exclusive.
718}
719
720impl LineDiffOptions {
721    fn merge_args(&mut self, args: &DiffFormatArgs) {
722        self.compare_mode = if args.ignore_all_space {
723            LineCompareMode::IgnoreAllSpace
724        } else if args.ignore_space_change {
725            LineCompareMode::IgnoreSpaceChange
726        } else {
727            LineCompareMode::Exact
728        };
729    }
730}
731
732#[derive(Clone, Debug, Eq, PartialEq)]
733pub struct ColorWordsDiffOptions {
734    /// How conflicts are processed and rendered.
735    pub conflict: ConflictDiffMethod,
736    /// Number of context lines to show.
737    pub context: usize,
738    /// How lines are tokenized and compared.
739    pub line_diff: LineDiffOptions,
740    /// Maximum number of removed/added word alternation to inline.
741    pub max_inline_alternation: Option<usize>,
742}
743
744impl ColorWordsDiffOptions {
745    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
746        let max_inline_alternation = {
747            let name = "diff.color-words.max-inline-alternation";
748            match settings.get_int(name)? {
749                -1 => None, // unlimited
750                n => Some(usize::try_from(n).map_err(|err| ConfigGetError::Type {
751                    name: name.to_owned(),
752                    error: err.into(),
753                    source_path: None,
754                })?),
755            }
756        };
757        Ok(Self {
758            conflict: settings.get("diff.color-words.conflict")?,
759            context: settings.get("diff.color-words.context")?,
760            line_diff: LineDiffOptions::default(),
761            max_inline_alternation,
762        })
763    }
764
765    fn merge_args(&mut self, args: &DiffFormatArgs) {
766        if let Some(context) = args.context {
767            self.context = context;
768        }
769        self.line_diff.merge_args(args);
770    }
771}
772
773fn show_color_words_diff_hunks<T: AsRef<[u8]>>(
774    formatter: &mut dyn Formatter,
775    contents: Diff<&Merge<T>>,
776    conflict_labels: Diff<&ConflictLabels>,
777    options: &ColorWordsDiffOptions,
778    materialize_options: &ConflictMaterializeOptions,
779) -> io::Result<()> {
780    let line_number = DiffLineNumber { left: 1, right: 1 };
781    let labels = Diff::new("removed", "added");
782    if let (Some(left), Some(right)) = (contents.before.as_resolved(), contents.after.as_resolved())
783    {
784        let contents = Diff::new(left, right).map(BStr::new);
785        show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
786        return Ok(());
787    }
788    match options.conflict {
789        ConflictDiffMethod::Materialize => {
790            let contents = contents.zip(conflict_labels).map(|(side, labels)| {
791                materialize_merge_result_to_bytes(side, labels, materialize_options)
792            });
793            show_color_words_resolved_hunks(
794                formatter,
795                contents.as_ref().map(BStr::new),
796                line_number,
797                labels,
798                options,
799            )?;
800        }
801        ConflictDiffMethod::Pair => {
802            let contents = contents.map(|side| files::merge(side, &materialize_options.merge));
803            show_color_words_conflict_hunks(
804                formatter,
805                contents.as_ref(),
806                line_number,
807                labels,
808                options,
809            )?;
810        }
811    }
812    Ok(())
813}
814
815fn show_color_words_conflict_hunks(
816    formatter: &mut dyn Formatter,
817    contents: Diff<&Merge<BString>>,
818    mut line_number: DiffLineNumber,
819    labels: Diff<&str>,
820    options: &ColorWordsDiffOptions,
821) -> io::Result<DiffLineNumber> {
822    let num_lefts = contents.before.as_slice().len();
823    let line_diff = diff_by_line(
824        itertools::chain(contents.before, contents.after),
825        &options.line_diff.compare_mode,
826    );
827    // Matching entries shouldn't appear consecutively in diff of two inputs.
828    // However, if the inputs have conflicts, there may be a hunk that can be
829    // resolved, resulting [matching, resolved, matching] sequence.
830    let mut contexts: Vec<Diff<&BStr>> = Vec::new();
831    let mut emitted = false;
832
833    for hunk in files::conflict_diff_hunks(line_diff.hunks(), num_lefts) {
834        match hunk.kind {
835            // There may be conflicts in matching hunk, but just pick one. It
836            // would be too verbose to show all conflict pairs as context.
837            DiffHunkKind::Matching => {
838                contexts.push(Diff::new(hunk.lefts.first(), hunk.rights.first()));
839            }
840            DiffHunkKind::Different => {
841                let num_after = if emitted { options.context } else { 0 };
842                let num_before = options.context;
843                line_number = show_color_words_context_lines(
844                    formatter,
845                    &contexts,
846                    line_number,
847                    labels,
848                    options,
849                    num_after,
850                    num_before,
851                )?;
852                contexts.clear();
853                emitted = true;
854                line_number = if let (Some(&left), Some(&right)) =
855                    (hunk.lefts.as_resolved(), hunk.rights.as_resolved())
856                {
857                    show_color_words_diff_lines(
858                        formatter,
859                        Diff::new(left, right),
860                        line_number,
861                        labels,
862                        options,
863                    )?
864                } else {
865                    show_color_words_unresolved_hunk(
866                        formatter,
867                        &hunk,
868                        line_number,
869                        labels,
870                        options,
871                    )?
872                }
873            }
874        }
875    }
876
877    let num_after = if emitted { options.context } else { 0 };
878    let num_before = 0;
879    show_color_words_context_lines(
880        formatter,
881        &contexts,
882        line_number,
883        labels,
884        options,
885        num_after,
886        num_before,
887    )
888}
889
890fn show_color_words_unresolved_hunk(
891    formatter: &mut dyn Formatter,
892    hunk: &ConflictDiffHunk,
893    line_number: DiffLineNumber,
894    labels: Diff<&str>,
895    options: &ColorWordsDiffOptions,
896) -> io::Result<DiffLineNumber> {
897    let hunk_desc = if hunk.lefts.is_resolved() {
898        "Created conflict"
899    } else if hunk.rights.is_resolved() {
900        "Resolved conflict"
901    } else {
902        "Modified conflict"
903    };
904    writeln!(formatter.labeled("hunk_header"), "<<<<<<< {hunk_desc}")?;
905
906    // Pad with identical (negative, positive) terms. It's common that one of
907    // the sides is resolved, or both sides have the same numbers of terms. If
908    // both sides are conflicts, and the numbers of terms are different, the
909    // choice of padding terms is arbitrary.
910    let num_terms = max(hunk.lefts.as_slice().len(), hunk.rights.as_slice().len());
911    let lefts = hunk.lefts.iter().enumerate();
912    let rights = hunk.rights.iter().enumerate();
913    let padded = iter::zip(
914        lefts.chain(iter::repeat((0, hunk.lefts.first()))),
915        rights.chain(iter::repeat((0, hunk.rights.first()))),
916    )
917    .take(num_terms);
918    let mut max_line_number = line_number;
919    for (i, ((left_index, &left_content), (right_index, &right_content))) in padded.enumerate() {
920        let positive = i % 2 == 0;
921        writeln!(
922            formatter.labeled("hunk_header"),
923            "{sep} left {left_name} #{left_index} to right {right_name} #{right_index}",
924            sep = if positive { "+++++++" } else { "-------" },
925            // these numbers should be compatible with the "tree-set" language #5307
926            left_name = if left_index % 2 == 0 { "side" } else { "base" },
927            left_index = left_index / 2 + 1,
928            right_name = if right_index % 2 == 0 { "side" } else { "base" },
929            right_index = right_index / 2 + 1,
930        )?;
931        let contents = Diff::new(left_content, right_content);
932        let labels = match positive {
933            true => labels,
934            false => labels.invert(),
935        };
936        // Individual hunk pair may be largely the same, so diff it again.
937        let new_line_number =
938            show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
939        // Take max to assign unique line numbers to trailing hunks. The line
940        // numbers can't be real anyway because preceding conflict hunks might
941        // have been resolved.
942        max_line_number.left = max(max_line_number.left, new_line_number.left);
943        max_line_number.right = max(max_line_number.right, new_line_number.right);
944    }
945
946    writeln!(formatter.labeled("hunk_header"), ">>>>>>> Conflict ends")?;
947    Ok(max_line_number)
948}
949
950fn show_color_words_resolved_hunks(
951    formatter: &mut dyn Formatter,
952    contents: Diff<&BStr>,
953    mut line_number: DiffLineNumber,
954    labels: Diff<&str>,
955    options: &ColorWordsDiffOptions,
956) -> io::Result<DiffLineNumber> {
957    let line_diff = diff_by_line(contents.into_array(), &options.line_diff.compare_mode);
958    // Matching entries shouldn't appear consecutively in diff of two inputs.
959    let mut context: Option<Diff<&BStr>> = None;
960    let mut emitted = false;
961
962    for hunk in line_diff.hunks() {
963        let &[left, right] = hunk.contents.as_slice() else {
964            panic!("hunk contents should have two sides")
965        };
966        let hunk_contents = Diff::new(left, right);
967        match hunk.kind {
968            DiffHunkKind::Matching => {
969                context = Some(hunk_contents);
970            }
971            DiffHunkKind::Different => {
972                let num_after = if emitted { options.context } else { 0 };
973                let num_before = options.context;
974                line_number = show_color_words_context_lines(
975                    formatter,
976                    context.as_slice(),
977                    line_number,
978                    labels,
979                    options,
980                    num_after,
981                    num_before,
982                )?;
983                context = None;
984                emitted = true;
985                line_number = show_color_words_diff_lines(
986                    formatter,
987                    hunk_contents,
988                    line_number,
989                    labels,
990                    options,
991                )?;
992            }
993        }
994    }
995
996    let num_after = if emitted { options.context } else { 0 };
997    let num_before = 0;
998    show_color_words_context_lines(
999        formatter,
1000        context.as_slice(),
1001        line_number,
1002        labels,
1003        options,
1004        num_after,
1005        num_before,
1006    )
1007}
1008
1009/// Prints `num_after` lines, ellipsis, and `num_before` lines.
1010fn show_color_words_context_lines(
1011    formatter: &mut dyn Formatter,
1012    contexts: &[Diff<&BStr>],
1013    mut line_number: DiffLineNumber,
1014    labels: Diff<&str>,
1015    options: &ColorWordsDiffOptions,
1016    num_after: usize,
1017    num_before: usize,
1018) -> io::Result<DiffLineNumber> {
1019    const SKIPPED_CONTEXT_LINE: &str = "    ...\n";
1020    let extract = |after: bool| -> (Vec<&[u8]>, Vec<&[u8]>, u32) {
1021        let mut lines = contexts
1022            .iter()
1023            .map(|contents| {
1024                if after {
1025                    contents.after
1026                } else {
1027                    contents.before
1028                }
1029            })
1030            .flat_map(|side| side.split_inclusive(|b| *b == b'\n'))
1031            .fuse();
1032        let after_lines = lines.by_ref().take(num_after).collect();
1033        let before_lines = lines.by_ref().rev().take(num_before + 1).collect();
1034        let num_skipped: u32 = lines.count().try_into().unwrap();
1035        (after_lines, before_lines, num_skipped)
1036    };
1037    let show = |formatter: &mut dyn Formatter,
1038                [left_lines, right_lines]: [&[&[u8]]; 2],
1039                mut line_number: DiffLineNumber| {
1040        // Whether or not left/right lines differ in whitespace, they are
1041        // "context" lines.
1042        let mut formatter = formatter.labeled("context");
1043        if left_lines == right_lines {
1044            for line in left_lines {
1045                show_color_words_line_number(
1046                    *formatter,
1047                    Diff::new(Some(line_number.left), Some(line_number.right)),
1048                    labels,
1049                )?;
1050                show_color_words_inline_hunks(
1051                    *formatter,
1052                    &[(DiffLineHunkSide::Both, line.as_ref())],
1053                    labels,
1054                )?;
1055                line_number.left += 1;
1056                line_number.right += 1;
1057            }
1058            Ok(line_number)
1059        } else {
1060            let left = left_lines.concat();
1061            let right = right_lines.concat();
1062            show_color_words_diff_lines(
1063                *formatter,
1064                Diff::new(&left, &right).map(BStr::new),
1065                line_number,
1066                labels,
1067                options,
1068            )
1069        }
1070    };
1071
1072    let (left_after, mut left_before, num_left_skipped) = extract(false);
1073    let (right_after, mut right_before, num_right_skipped) = extract(true);
1074    line_number = show(formatter, [&left_after, &right_after], line_number)?;
1075    if num_left_skipped > 0 || num_right_skipped > 0 {
1076        write!(formatter, "{SKIPPED_CONTEXT_LINE}")?;
1077        line_number.left += num_left_skipped;
1078        line_number.right += num_right_skipped;
1079        if left_before.len() > num_before {
1080            left_before.pop();
1081            line_number.left += 1;
1082        }
1083        if right_before.len() > num_before {
1084            right_before.pop();
1085            line_number.right += 1;
1086        }
1087    }
1088    left_before.reverse();
1089    right_before.reverse();
1090    line_number = show(formatter, [&left_before, &right_before], line_number)?;
1091    Ok(line_number)
1092}
1093
1094fn show_color_words_diff_lines(
1095    formatter: &mut dyn Formatter,
1096    contents: Diff<&BStr>,
1097    mut line_number: DiffLineNumber,
1098    labels: Diff<&str>,
1099    options: &ColorWordsDiffOptions,
1100) -> io::Result<DiffLineNumber> {
1101    let word_diff_hunks = ContentDiff::by_word(contents.into_array())
1102        .hunks()
1103        .collect_vec();
1104    let can_inline = if formatter.maybe_color() {
1105        match options.max_inline_alternation {
1106            None => true,     // unlimited
1107            Some(0) => false, // no need to count alternation
1108            Some(max_num) => {
1109                let groups = split_diff_hunks_by_matching_newline(&word_diff_hunks);
1110                groups.map(count_diff_alternation).max().unwrap_or(0) <= max_num
1111            }
1112        }
1113    } else {
1114        // Inline word hunks rely on color labels to distinguish sides. Without
1115        // color support, show separate before/after lines instead.
1116        false
1117    };
1118    if can_inline {
1119        let mut diff_line_iter =
1120            DiffLineIterator::with_line_number(word_diff_hunks.iter(), line_number);
1121        for diff_line in diff_line_iter.by_ref() {
1122            show_color_words_line_number(
1123                formatter,
1124                Diff::new(
1125                    diff_line
1126                        .has_left_content()
1127                        .then_some(diff_line.line_number.left),
1128                    diff_line
1129                        .has_right_content()
1130                        .then_some(diff_line.line_number.right),
1131                ),
1132                labels,
1133            )?;
1134            show_color_words_inline_hunks(formatter, &diff_line.hunks, labels)?;
1135        }
1136        line_number = diff_line_iter.next_line_number();
1137    } else {
1138        let lines = unzip_diff_hunks_to_lines(&word_diff_hunks);
1139        for tokens in &lines.before {
1140            show_color_words_line_number(
1141                formatter,
1142                Diff::new(Some(line_number.left), None),
1143                labels,
1144            )?;
1145            show_color_words_single_sided_line(formatter, tokens, labels.before)?;
1146            line_number.left += 1;
1147        }
1148        for tokens in &lines.after {
1149            show_color_words_line_number(
1150                formatter,
1151                Diff::new(None, Some(line_number.right)),
1152                labels,
1153            )?;
1154            show_color_words_single_sided_line(formatter, tokens, labels.after)?;
1155            line_number.right += 1;
1156        }
1157    }
1158    Ok(line_number)
1159}
1160
1161fn show_color_words_line_number(
1162    formatter: &mut dyn Formatter,
1163    line_numbers: Diff<Option<u32>>,
1164    labels: Diff<&str>,
1165) -> io::Result<()> {
1166    if let Some(line_number) = line_numbers.before {
1167        write!(
1168            formatter.labeled(labels.before).labeled("line_number"),
1169            "{line_number:>4}"
1170        )?;
1171        write!(formatter, " ")?;
1172    } else {
1173        write!(formatter, "     ")?;
1174    }
1175    if let Some(line_number) = line_numbers.after {
1176        write!(
1177            formatter.labeled(labels.after).labeled("line_number"),
1178            "{line_number:>4}"
1179        )?;
1180        write!(formatter, ": ")?;
1181    } else {
1182        write!(formatter, "    : ")?;
1183    }
1184    Ok(())
1185}
1186
1187/// Prints line hunks which may contain tokens originating from both sides.
1188fn show_color_words_inline_hunks(
1189    formatter: &mut dyn Formatter,
1190    line_hunks: &[(DiffLineHunkSide, &BStr)],
1191    labels: Diff<&str>,
1192) -> io::Result<()> {
1193    for (side, data) in line_hunks {
1194        let label = match side {
1195            DiffLineHunkSide::Both => None,
1196            DiffLineHunkSide::Left => Some(labels.before),
1197            DiffLineHunkSide::Right => Some(labels.after),
1198        };
1199        if let Some(label) = label {
1200            formatter.labeled(label).labeled("token").write_all(data)?;
1201        } else {
1202            formatter.write_all(data)?;
1203        }
1204    }
1205    let (_, data) = line_hunks.last().expect("diff line must not be empty");
1206    if !data.ends_with(b"\n") {
1207        writeln!(formatter)?;
1208    }
1209    Ok(())
1210}
1211
1212/// Prints left/right-only line tokens with the given label.
1213fn show_color_words_single_sided_line(
1214    formatter: &mut dyn Formatter,
1215    tokens: &[(DiffTokenType, &[u8])],
1216    label: &str,
1217) -> io::Result<()> {
1218    show_diff_line_tokens(*formatter.labeled(label), tokens)?;
1219    let (_, data) = tokens.last().expect("diff line must not be empty");
1220    if !data.ends_with(b"\n") {
1221        writeln!(formatter)?;
1222    }
1223    Ok(())
1224}
1225
1226/// Counts number of diff-side alternation, ignoring matching hunks.
1227///
1228/// This function is meant to measure visual complexity of diff hunks. It's easy
1229/// to read hunks containing some removed or added words, but is getting harder
1230/// as more removes and adds interleaved.
1231///
1232/// For example,
1233/// - `[matching]` -> 0
1234/// - `[left]` -> 1
1235/// - `[left, matching, left]` -> 1
1236/// - `[matching, left, right, matching, right]` -> 2
1237/// - `[left, right, matching, right, left]` -> 3
1238fn count_diff_alternation(diff_hunks: &[DiffHunk]) -> usize {
1239    diff_hunks
1240        .iter()
1241        .filter_map(|hunk| match hunk.kind {
1242            DiffHunkKind::Matching => None,
1243            DiffHunkKind::Different => Some(&hunk.contents),
1244        })
1245        // Map non-empty diff side to index (0: left, 1: right)
1246        .flat_map(|contents| contents.iter().positions(|content| !content.is_empty()))
1247        // Omit e.g. left->(matching->)*left
1248        .dedup()
1249        .count()
1250}
1251
1252/// Splits hunks into slices of contiguous changed lines.
1253fn split_diff_hunks_by_matching_newline<'a, 'b>(
1254    diff_hunks: &'a [DiffHunk<'b>],
1255) -> impl Iterator<Item = &'a [DiffHunk<'b>]> {
1256    diff_hunks.split_inclusive(|hunk| match hunk.kind {
1257        DiffHunkKind::Matching => hunk.contents.iter().all(|content| content.contains(&b'\n')),
1258        DiffHunkKind::Different => false,
1259    })
1260}
1261
1262async fn diff_content(
1263    path: &RepoPath,
1264    value: MaterializedTreeValue,
1265    materialize_options: &ConflictMaterializeOptions,
1266) -> BackendResult<FileContent<BString>> {
1267    diff_content_with(
1268        path,
1269        value,
1270        |content| content,
1271        |contents, labels| {
1272            materialize_merge_result_to_bytes(&contents, &labels, materialize_options)
1273        },
1274    )
1275    .await
1276}
1277
1278#[derive(PartialEq, Eq, Debug)]
1279struct DiffContentAsMerge {
1280    file_content: Merge<BString>,
1281    conflict_labels: ConflictLabels,
1282}
1283
1284impl DiffContentAsMerge {
1285    pub fn is_empty(&self) -> bool {
1286        self.file_content
1287            .as_resolved()
1288            .is_some_and(|c| c.is_empty())
1289    }
1290}
1291
1292async fn diff_content_as_merge(
1293    path: &RepoPath,
1294    value: MaterializedTreeValue,
1295) -> BackendResult<FileContent<DiffContentAsMerge>> {
1296    diff_content_with(
1297        path,
1298        value,
1299        |contents| DiffContentAsMerge {
1300            file_content: Merge::resolved(contents),
1301            conflict_labels: ConflictLabels::unlabeled(),
1302        },
1303        |contents, labels| DiffContentAsMerge {
1304            file_content: contents,
1305            conflict_labels: labels,
1306        },
1307    )
1308    .await
1309}
1310
1311async fn diff_content_with<T>(
1312    path: &RepoPath,
1313    value: MaterializedTreeValue,
1314    map_resolved: impl FnOnce(BString) -> T,
1315    map_conflict: impl FnOnce(Merge<BString>, ConflictLabels) -> T,
1316) -> BackendResult<FileContent<T>> {
1317    match value {
1318        MaterializedTreeValue::Absent => Ok(FileContent {
1319            is_binary: false,
1320            contents: map_resolved(BString::default()),
1321        }),
1322        MaterializedTreeValue::AccessDenied(err) => Ok(FileContent {
1323            is_binary: false,
1324            contents: map_resolved(format!("Access denied: {err}").into()),
1325        }),
1326        MaterializedTreeValue::File(mut file) => {
1327            file_content_for_diff(path, &mut file, map_resolved).await
1328        }
1329        MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent {
1330            // Unix file paths can't contain null bytes.
1331            is_binary: false,
1332            contents: map_resolved(target.into()),
1333        }),
1334        MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent {
1335            is_binary: false,
1336            contents: map_resolved(format!("Git submodule checked out at {id}").into()),
1337        }),
1338        // TODO: are we sure this is never binary?
1339        MaterializedTreeValue::FileConflict(file) => Ok(FileContent {
1340            is_binary: false,
1341            contents: map_conflict(file.contents, file.labels),
1342        }),
1343        MaterializedTreeValue::OtherConflict { id, labels } => Ok(FileContent {
1344            is_binary: false,
1345            contents: map_resolved(id.describe(&labels).into()),
1346        }),
1347        MaterializedTreeValue::Tree(id) => {
1348            panic!("Unexpected tree with id {id:?} in diff at path {path:?}");
1349        }
1350    }
1351}
1352
1353fn basic_diff_file_type(value: &MaterializedTreeValue) -> &'static str {
1354    match value {
1355        MaterializedTreeValue::Absent => {
1356            panic!("absent path in diff");
1357        }
1358        MaterializedTreeValue::AccessDenied(_) => "access denied",
1359        MaterializedTreeValue::File(file) => {
1360            if file.executable {
1361                "executable file"
1362            } else {
1363                "regular file"
1364            }
1365        }
1366        MaterializedTreeValue::Symlink { .. } => "symlink",
1367        MaterializedTreeValue::Tree(_) => "tree",
1368        MaterializedTreeValue::GitSubmodule(_) => "Git submodule",
1369        MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. } => {
1370            "conflict"
1371        }
1372    }
1373}
1374
1375pub async fn show_color_words_diff(
1376    formatter: &mut dyn Formatter,
1377    store: &Store,
1378    tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1379    conflict_labels: Diff<&ConflictLabels>,
1380    path_converter: &RepoPathUiConverter,
1381    options: &ColorWordsDiffOptions,
1382    marker_style: ConflictMarkerStyle,
1383) -> Result<(), DiffRenderError> {
1384    let materialize_options = ConflictMaterializeOptions {
1385        marker_style,
1386        marker_len: None,
1387        merge: store.merge_options().clone(),
1388    };
1389    let empty_content = || Merge::resolved(BString::default());
1390    let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1391    while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1392        let left_path = path.source();
1393        let right_path = path.target();
1394        let left_ui_path = path_converter.format_file_path(left_path);
1395        let right_ui_path = path_converter.format_file_path(right_path);
1396        let Diff {
1397            before: left_value,
1398            after: right_value,
1399        } = values?;
1400
1401        match (&left_value, &right_value) {
1402            (MaterializedTreeValue::AccessDenied(source), _) => {
1403                write!(
1404                    formatter.labeled("access-denied"),
1405                    "Access denied to {left_ui_path}:"
1406                )?;
1407                writeln!(formatter, " {source}")?;
1408                continue;
1409            }
1410            (_, MaterializedTreeValue::AccessDenied(source)) => {
1411                write!(
1412                    formatter.labeled("access-denied"),
1413                    "Access denied to {right_ui_path}:"
1414                )?;
1415                writeln!(formatter, " {source}")?;
1416                continue;
1417            }
1418            _ => {}
1419        }
1420        if left_value.is_absent() {
1421            let description = basic_diff_file_type(&right_value);
1422            writeln!(
1423                formatter.labeled("header"),
1424                "Added {description} {right_ui_path}:"
1425            )?;
1426            let right_content = diff_content_as_merge(right_path, right_value).await?;
1427            if right_content.contents.is_empty() {
1428                writeln!(formatter.labeled("empty"), "    (empty)")?;
1429            } else if right_content.is_binary {
1430                writeln!(formatter.labeled("binary"), "    (binary)")?;
1431            } else {
1432                show_color_words_diff_hunks(
1433                    formatter,
1434                    Diff::new(&empty_content(), &right_content.contents.file_content),
1435                    Diff::new(
1436                        &ConflictLabels::unlabeled(),
1437                        &right_content.contents.conflict_labels,
1438                    ),
1439                    options,
1440                    &materialize_options,
1441                )?;
1442            }
1443        } else if right_value.is_present() {
1444            let description = match (&left_value, &right_value) {
1445                (MaterializedTreeValue::File(left), MaterializedTreeValue::File(right)) => {
1446                    if left.executable && right.executable {
1447                        "Modified executable file".to_string()
1448                    } else if left.executable {
1449                        "Executable file became non-executable at".to_string()
1450                    } else if right.executable {
1451                        "Non-executable file became executable at".to_string()
1452                    } else {
1453                        "Modified regular file".to_string()
1454                    }
1455                }
1456                (
1457                    MaterializedTreeValue::FileConflict(_)
1458                    | MaterializedTreeValue::OtherConflict { .. },
1459                    MaterializedTreeValue::FileConflict(_)
1460                    | MaterializedTreeValue::OtherConflict { .. },
1461                ) => "Modified conflict in".to_string(),
1462                (
1463                    MaterializedTreeValue::FileConflict(_)
1464                    | MaterializedTreeValue::OtherConflict { .. },
1465                    _,
1466                ) => "Resolved conflict in".to_string(),
1467                (
1468                    _,
1469                    MaterializedTreeValue::FileConflict(_)
1470                    | MaterializedTreeValue::OtherConflict { .. },
1471                ) => "Created conflict in".to_string(),
1472                (MaterializedTreeValue::Symlink { .. }, MaterializedTreeValue::Symlink { .. }) => {
1473                    "Symlink target changed at".to_string()
1474                }
1475                (_, _) => {
1476                    let left_type = basic_diff_file_type(&left_value);
1477                    let right_type = basic_diff_file_type(&right_value);
1478                    let (first, rest) = left_type.split_at(1);
1479                    format!(
1480                        "{}{} became {} at",
1481                        first.to_ascii_uppercase(),
1482                        rest,
1483                        right_type
1484                    )
1485                }
1486            };
1487            let left_content = diff_content_as_merge(left_path, left_value).await?;
1488            let right_content = diff_content_as_merge(right_path, right_value).await?;
1489            if left_path == right_path {
1490                writeln!(
1491                    formatter.labeled("header"),
1492                    "{description} {right_ui_path}:"
1493                )?;
1494            } else {
1495                writeln!(
1496                    formatter.labeled("header"),
1497                    "{description} {right_ui_path} ({left_ui_path} => {right_ui_path}):"
1498                )?;
1499            }
1500            if left_content.is_binary || right_content.is_binary {
1501                writeln!(formatter.labeled("binary"), "    (binary)")?;
1502            } else if left_content.contents != right_content.contents {
1503                show_color_words_diff_hunks(
1504                    formatter,
1505                    Diff::new(
1506                        &left_content.contents.file_content,
1507                        &right_content.contents.file_content,
1508                    ),
1509                    Diff::new(
1510                        &left_content.contents.conflict_labels,
1511                        &right_content.contents.conflict_labels,
1512                    ),
1513                    options,
1514                    &materialize_options,
1515                )?;
1516            }
1517        } else {
1518            let description = basic_diff_file_type(&left_value);
1519            writeln!(
1520                formatter.labeled("header"),
1521                "Removed {description} {right_ui_path}:"
1522            )?;
1523            let left_content = diff_content_as_merge(left_path, left_value).await?;
1524            if left_content.contents.is_empty() {
1525                writeln!(formatter.labeled("empty"), "    (empty)")?;
1526            } else if left_content.is_binary {
1527                writeln!(formatter.labeled("binary"), "    (binary)")?;
1528            } else {
1529                show_color_words_diff_hunks(
1530                    formatter,
1531                    Diff::new(&left_content.contents.file_content, &empty_content()),
1532                    Diff::new(
1533                        &left_content.contents.conflict_labels,
1534                        &ConflictLabels::unlabeled(),
1535                    ),
1536                    options,
1537                    &materialize_options,
1538                )?;
1539            }
1540        }
1541    }
1542    Ok(())
1543}
1544
1545#[expect(clippy::too_many_arguments)]
1546pub async fn show_file_by_file_diff(
1547    ui: &Ui,
1548    formatter: &mut dyn Formatter,
1549    store: &Store,
1550    tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1551    conflict_labels: Diff<&ConflictLabels>,
1552    path_converter: &RepoPathUiConverter,
1553    tool: &ExternalMergeTool,
1554    marker_style: ConflictMarkerStyle,
1555    width: usize,
1556) -> Result<(), DiffRenderError> {
1557    let materialize_options = ConflictMaterializeOptions {
1558        marker_style,
1559        marker_len: None,
1560        merge: store.merge_options().clone(),
1561    };
1562    let create_file = async |path: &RepoPath,
1563                             wc_dir: &Path,
1564                             value: MaterializedTreeValue|
1565           -> Result<PathBuf, DiffRenderError> {
1566        let fs_path = path.to_fs_path(wc_dir)?;
1567        std::fs::create_dir_all(fs_path.parent().unwrap())?;
1568        let content = diff_content(path, value, &materialize_options).await?;
1569        std::fs::write(&fs_path, content.contents)?;
1570        Ok(fs_path)
1571    };
1572
1573    let temp_dir = new_utf8_temp_dir("jj-diff-")?;
1574    let left_wc_dir = temp_dir.path().join("left");
1575    let right_wc_dir = temp_dir.path().join("right");
1576    let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1577    while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1578        let Diff {
1579            before: left_value,
1580            after: right_value,
1581        } = values?;
1582        let left_path = path.source();
1583        let right_path = path.target();
1584        let left_ui_path = path_converter.format_file_path(left_path);
1585        let right_ui_path = path_converter.format_file_path(right_path);
1586
1587        match (&left_value, &right_value) {
1588            (_, MaterializedTreeValue::AccessDenied(source)) => {
1589                write!(
1590                    formatter.labeled("access-denied"),
1591                    "Access denied to {right_ui_path}:"
1592                )?;
1593                writeln!(formatter, " {source}")?;
1594                continue;
1595            }
1596            (MaterializedTreeValue::AccessDenied(source), _) => {
1597                write!(
1598                    formatter.labeled("access-denied"),
1599                    "Access denied to {left_ui_path}:"
1600                )?;
1601                writeln!(formatter, " {source}")?;
1602                continue;
1603            }
1604            _ => {}
1605        }
1606        let left_path = create_file(left_path, &left_wc_dir, left_value).await?;
1607        let right_path = create_file(right_path, &right_wc_dir, right_value).await?;
1608        let patterns = &maplit::hashmap! {
1609            "left" => left_path
1610                .strip_prefix(temp_dir.path())
1611                .expect("path should be relative to temp_dir")
1612                .to_str()
1613                .expect("temp_dir should be valid utf-8")
1614                .to_owned(),
1615            "right" => right_path
1616                .strip_prefix(temp_dir.path())
1617                .expect("path should be relative to temp_dir")
1618                .to_str()
1619                .expect("temp_dir should be valid utf-8")
1620                .to_owned(),
1621            "width" => width.to_string(),
1622        };
1623
1624        let mut writer = formatter.raw()?;
1625        invoke_external_diff(ui, writer.as_mut(), tool, temp_dir.path(), patterns)
1626            .map_err(DiffRenderError::DiffGenerate)?;
1627    }
1628    Ok::<(), DiffRenderError>(())
1629}
1630
1631#[derive(Clone, Debug, Eq, PartialEq)]
1632pub struct UnifiedDiffOptions {
1633    /// Number of context lines to show.
1634    pub context: usize,
1635    /// Whether to show the 'a/' and 'b/' path prefixes.
1636    pub show_path_prefix: bool,
1637    /// How lines are tokenized and compared.
1638    pub line_diff: LineDiffOptions,
1639}
1640
1641impl UnifiedDiffOptions {
1642    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
1643        Ok(Self {
1644            context: settings.get("diff.git.context")?,
1645            show_path_prefix: settings.get("diff.git.show-path-prefix")?,
1646            line_diff: LineDiffOptions::default(),
1647        })
1648    }
1649
1650    fn merge_args(&mut self, args: &DiffFormatArgs) {
1651        if let Some(context) = args.context {
1652            self.context = context;
1653        }
1654        self.line_diff.merge_args(args);
1655    }
1656}
1657
1658fn show_unified_diff_hunks(
1659    formatter: &mut dyn Formatter,
1660    contents: Diff<&BStr>,
1661    options: &UnifiedDiffOptions,
1662) -> io::Result<()> {
1663    // "If the chunk size is 0, the first number is one lower than one would
1664    // expect." - https://www.artima.com/weblogs/viewpost.jsp?thread=164293
1665    //
1666    // The POSIX spec also states that "the ending line number of an empty range
1667    // shall be the number of the preceding line, or 0 if the range is at the
1668    // start of the file."
1669    // - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/diff.html
1670    fn to_line_number(range: Range<usize>) -> usize {
1671        if range.is_empty() {
1672            range.start
1673        } else {
1674            range.start + 1
1675        }
1676    }
1677
1678    for hunk in unified_diff_hunks(contents, options.context, options.line_diff.compare_mode) {
1679        writeln!(
1680            formatter.labeled("hunk_header"),
1681            "@@ -{},{} +{},{} @@",
1682            to_line_number(hunk.left_line_range.clone()),
1683            hunk.left_line_range.len(),
1684            to_line_number(hunk.right_line_range.clone()),
1685            hunk.right_line_range.len()
1686        )?;
1687        for (line_type, tokens) in &hunk.lines {
1688            let (label, sigil) = match line_type {
1689                DiffLineType::Context => ("context", " "),
1690                DiffLineType::Removed => ("removed", "-"),
1691                DiffLineType::Added => ("added", "+"),
1692            };
1693            write!(formatter.labeled(label), "{sigil}")?;
1694            show_diff_line_tokens(*formatter.labeled(label), tokens)?;
1695            let (_, content) = tokens.last().expect("hunk line must not be empty");
1696            if !content.ends_with(b"\n") {
1697                write!(formatter, "\n\\ No newline at end of file\n")?;
1698            }
1699        }
1700    }
1701    Ok(())
1702}
1703
1704fn show_diff_line_tokens(
1705    formatter: &mut dyn Formatter,
1706    tokens: &[(DiffTokenType, &[u8])],
1707) -> io::Result<()> {
1708    for (token_type, content) in tokens {
1709        match token_type {
1710            DiffTokenType::Matching => formatter.write_all(content)?,
1711            DiffTokenType::Different => formatter.labeled("token").write_all(content)?,
1712        }
1713    }
1714    Ok(())
1715}
1716
1717pub async fn show_git_diff(
1718    formatter: &mut dyn Formatter,
1719    store: &Store,
1720    tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1721    conflict_labels: Diff<&ConflictLabels>,
1722    options: &UnifiedDiffOptions,
1723    marker_style: ConflictMarkerStyle,
1724) -> Result<(), DiffRenderError> {
1725    let materialize_options = ConflictMaterializeOptions {
1726        marker_style,
1727        marker_len: None,
1728        merge: store.merge_options().clone(),
1729    };
1730    let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1731    while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1732        let left_path = path.source();
1733        let right_path = path.target();
1734        let left_prefix = if options.show_path_prefix { "a/" } else { "" };
1735        let right_prefix = if options.show_path_prefix { "b/" } else { "" };
1736        let left_path_string = left_path.as_internal_file_string();
1737        let right_path_string = right_path.as_internal_file_string();
1738        let values = values?;
1739
1740        let left_part = git_diff_part(left_path, values.before, &materialize_options).await?;
1741        let right_part = git_diff_part(right_path, values.after, &materialize_options).await?;
1742
1743        {
1744            let mut formatter = formatter.labeled("file_header");
1745            writeln!(
1746                formatter,
1747                "diff --git {left_prefix}{left_path_string} {right_prefix}{right_path_string}"
1748            )?;
1749            let left_hash = &left_part.hash;
1750            let right_hash = &right_part.hash;
1751            match (left_part.mode, right_part.mode) {
1752                (None, Some(right_mode)) => {
1753                    writeln!(formatter, "new file mode {right_mode}")?;
1754                    writeln!(formatter, "index {left_hash}..{right_hash}")?;
1755                }
1756                (Some(left_mode), None) => {
1757                    writeln!(formatter, "deleted file mode {left_mode}")?;
1758                    writeln!(formatter, "index {left_hash}..{right_hash}")?;
1759                }
1760                (Some(left_mode), Some(right_mode)) => {
1761                    if let Some(op) = path.copy_operation() {
1762                        let operation = match op {
1763                            CopyOperation::Copy => "copy",
1764                            CopyOperation::Rename => "rename",
1765                        };
1766                        // TODO: include similarity index?
1767                        writeln!(formatter, "{operation} from {left_path_string}")?;
1768                        writeln!(formatter, "{operation} to {right_path_string}")?;
1769                    }
1770                    if left_mode != right_mode {
1771                        writeln!(formatter, "old mode {left_mode}")?;
1772                        writeln!(formatter, "new mode {right_mode}")?;
1773                        if left_hash != right_hash {
1774                            writeln!(formatter, "index {left_hash}..{right_hash}")?;
1775                        }
1776                    } else if left_hash != right_hash {
1777                        writeln!(formatter, "index {left_hash}..{right_hash} {left_mode}")?;
1778                    }
1779                }
1780                (None, None) => panic!("either left or right part should be present"),
1781            }
1782        }
1783
1784        if left_part.content.contents == right_part.content.contents {
1785            continue; // no content hunks
1786        }
1787
1788        let left_path = match left_part.mode {
1789            Some(_) => format!("{left_prefix}{left_path_string}"),
1790            None => "/dev/null".to_owned(),
1791        };
1792        let right_path = match right_part.mode {
1793            Some(_) => format!("{right_prefix}{right_path_string}"),
1794            None => "/dev/null".to_owned(),
1795        };
1796        if left_part.content.is_binary || right_part.content.is_binary {
1797            // TODO: add option to emit Git binary diff
1798            writeln!(
1799                formatter,
1800                "Binary files {left_path} and {right_path} differ"
1801            )?;
1802        } else {
1803            writeln!(formatter.labeled("file_header"), "--- {left_path}")?;
1804            writeln!(formatter.labeled("file_header"), "+++ {right_path}")?;
1805            show_unified_diff_hunks(
1806                formatter,
1807                Diff::new(&left_part.content.contents, &right_part.content.contents).map(BStr::new),
1808                options,
1809            )?;
1810        }
1811    }
1812    Ok(())
1813}
1814
1815/// Generates diff of non-binary contents in Git format.
1816fn show_git_diff_texts<T: AsRef<[u8]>>(
1817    formatter: &mut dyn Formatter,
1818    paths: Diff<&str>,
1819    contents: Diff<&Merge<T>>,
1820    options: &UnifiedDiffOptions,
1821    materialize_options: &ConflictMaterializeOptions,
1822) -> io::Result<()> {
1823    let Diff {
1824        before: left_path,
1825        after: right_path,
1826    } = paths;
1827    {
1828        let mut formatter = formatter.labeled("file_header");
1829        writeln!(formatter, "diff --git a/{left_path} b/{right_path}")?;
1830        writeln!(formatter, "--- {left_path}")?;
1831        writeln!(formatter, "+++ {right_path}")?;
1832    }
1833    let contents = contents.map(|content| match content.as_resolved() {
1834        Some(text) => Cow::Borrowed(BStr::new(text)),
1835        None => Cow::Owned(materialize_merge_result_to_bytes(
1836            content,
1837            &ConflictLabels::unlabeled(),
1838            materialize_options,
1839        )),
1840    });
1841    show_unified_diff_hunks(formatter, contents.as_ref().map(Cow::as_ref), options)
1842}
1843
1844#[instrument(skip_all)]
1845pub async fn show_diff_summary(
1846    formatter: &mut dyn Formatter,
1847    mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1848    path_converter: &RepoPathUiConverter,
1849) -> Result<(), DiffRenderError> {
1850    while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
1851        let values = values?;
1852        let status = diff_status(&path, &values);
1853        let (label, sigil) = (status.label(), status.char());
1854        let ui_path = match path.to_diff() {
1855            Some(paths) => path_converter.format_copied_path(paths),
1856            None => path_converter.format_file_path(path.target()),
1857        };
1858        writeln!(formatter.labeled(label), "{sigil} {ui_path}")?;
1859    }
1860    Ok(())
1861}
1862
1863fn diff_status_inner(
1864    path: &CopiesTreeDiffEntryPath,
1865    is_present_before: bool,
1866    is_present_after: bool,
1867) -> DiffEntryStatus {
1868    if let Some(op) = path.copy_operation() {
1869        match op {
1870            CopyOperation::Copy => DiffEntryStatus::Copied,
1871            CopyOperation::Rename => DiffEntryStatus::Renamed,
1872        }
1873    } else {
1874        match (is_present_before, is_present_after) {
1875            (true, true) => DiffEntryStatus::Modified,
1876            (false, true) => DiffEntryStatus::Added,
1877            (true, false) => DiffEntryStatus::Removed,
1878            (false, false) => panic!("values pair must differ"),
1879        }
1880    }
1881}
1882
1883pub fn diff_status(
1884    path: &CopiesTreeDiffEntryPath,
1885    values: &Diff<MergedTreeValue>,
1886) -> DiffEntryStatus {
1887    diff_status_inner(path, values.before.is_present(), values.after.is_present())
1888}
1889
1890#[derive(Clone, Debug, Default, Eq, PartialEq)]
1891pub struct DiffStatOptions {
1892    /// How lines are tokenized and compared.
1893    pub line_diff: LineDiffOptions,
1894}
1895
1896impl DiffStatOptions {
1897    fn merge_args(&mut self, args: &DiffFormatArgs) {
1898        self.line_diff.merge_args(args);
1899    }
1900}
1901
1902#[derive(Clone, Debug)]
1903pub struct DiffStats {
1904    entries: Vec<DiffStatEntry>,
1905}
1906
1907impl DiffStats {
1908    /// Calculates stats of changed lines per file.
1909    pub async fn calculate(
1910        store: &Store,
1911        tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1912        options: &DiffStatOptions,
1913        marker_style: ConflictMarkerStyle,
1914    ) -> BackendResult<Self> {
1915        let materialize_options = ConflictMaterializeOptions {
1916            marker_style,
1917            marker_len: None,
1918            merge: store.merge_options().clone(),
1919        };
1920        let conflict_labels = ConflictLabels::unlabeled();
1921        let entries = materialized_diff_stream(
1922            store,
1923            tree_diff,
1924            Diff::new(&conflict_labels, &conflict_labels),
1925        )
1926        .then(async |MaterializedTreeDiffEntry { path, values }| {
1927            let values = values?;
1928            let status =
1929                diff_status_inner(&path, values.before.is_present(), values.after.is_present());
1930            let left_content =
1931                diff_content(path.source(), values.before, &materialize_options).await?;
1932            let right_content =
1933                diff_content(path.target(), values.after, &materialize_options).await?;
1934            let stat = get_diff_stat_entry(
1935                path,
1936                status,
1937                Diff::new(&left_content, &right_content),
1938                options,
1939            );
1940            BackendResult::Ok(stat)
1941        })
1942        .try_collect()
1943        .await?;
1944        Ok(Self { entries })
1945    }
1946
1947    /// List of stats per file.
1948    pub fn entries(&self) -> &[DiffStatEntry] {
1949        &self.entries
1950    }
1951
1952    /// Total number of inserted lines.
1953    pub fn count_total_added(&self) -> usize {
1954        self.entries
1955            .iter()
1956            .filter_map(|stat| stat.added_removed.map(|(added, _)| added))
1957            .sum()
1958    }
1959
1960    /// Total number of deleted lines.
1961    pub fn count_total_removed(&self) -> usize {
1962        self.entries
1963            .iter()
1964            .filter_map(|stat| stat.added_removed.map(|(_, removed)| removed))
1965            .sum()
1966    }
1967}
1968
1969#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1970pub enum DiffEntryStatus {
1971    Added,
1972    Removed,
1973    Modified,
1974    Copied,
1975    Renamed,
1976}
1977
1978impl DiffEntryStatus {
1979    pub fn label(&self) -> &'static str {
1980        match self {
1981            Self::Added => "added",
1982            Self::Removed => "removed",
1983            Self::Modified => "modified",
1984            Self::Copied => "copied",
1985            Self::Renamed => "renamed",
1986        }
1987    }
1988
1989    pub fn char(&self) -> char {
1990        match self {
1991            Self::Added => 'A',
1992            Self::Removed => 'D',
1993            Self::Modified => 'M',
1994            Self::Copied => 'C',
1995            Self::Renamed => 'R',
1996        }
1997    }
1998}
1999
2000#[derive(Clone, Debug)]
2001pub struct DiffStatEntry {
2002    pub path: CopiesTreeDiffEntryPath,
2003    /// Lines added and removed; None for binary files.
2004    pub added_removed: Option<(usize, usize)>,
2005    /// Change in file size in bytes.
2006    pub bytes_delta: isize,
2007    pub status: DiffEntryStatus,
2008}
2009
2010fn get_diff_stat_entry(
2011    path: CopiesTreeDiffEntryPath,
2012    status: DiffEntryStatus,
2013    contents: Diff<&FileContent<BString>>,
2014    options: &DiffStatOptions,
2015) -> DiffStatEntry {
2016    let added_removed = if contents.before.is_binary || contents.after.is_binary {
2017        None
2018    } else {
2019        let diff = diff_by_line(
2020            contents.map(|content| &content.contents).into_array(),
2021            &options.line_diff.compare_mode,
2022        );
2023        let mut added = 0;
2024        let mut removed = 0;
2025        for hunk in diff.hunks() {
2026            match hunk.kind {
2027                DiffHunkKind::Matching => {}
2028                DiffHunkKind::Different => {
2029                    let [left, right] = hunk.contents[..].try_into().unwrap();
2030                    removed += left.split_inclusive(|b| *b == b'\n').count();
2031                    added += right.split_inclusive(|b| *b == b'\n').count();
2032                }
2033            }
2034        }
2035        Some((added, removed))
2036    };
2037
2038    DiffStatEntry {
2039        path,
2040        added_removed,
2041        bytes_delta: contents.after.contents.len() as isize
2042            - contents.before.contents.len() as isize,
2043        status,
2044    }
2045}
2046
2047pub fn show_diff_stats(
2048    formatter: &mut dyn Formatter,
2049    stats: &DiffStats,
2050    path_converter: &RepoPathUiConverter,
2051    display_width: usize,
2052) -> io::Result<()> {
2053    let ui_paths = stats
2054        .entries()
2055        .iter()
2056        .map(|stat| match stat.path.to_diff() {
2057            Some(paths) => path_converter.format_copied_path(paths),
2058            None => path_converter.format_file_path(stat.path.target()),
2059        })
2060        .collect_vec();
2061
2062    // Entries format like:
2063    //   path/to/file | 123 ++--
2064    // or, for binary files:
2065    //   path/to/file | (binary) +1234 bytes
2066    //
2067    // Depending on display widths, we can elide part of the path,
2068    // and the ++-- bar will adjust its scale to fill the rest.
2069
2070    // Choose how many columns to use for the path.  The right side will use the
2071    // rest. Start with the longest path.  The code below might shorten it.
2072    let mut max_path_width = ui_paths.iter().map(|s| s.width()).max().unwrap_or(0);
2073
2074    // Fit to the available display width, but always assume at least a tiny bit of
2075    // room.
2076    let available_width = max(display_width.saturating_sub(" | ".len()), 8);
2077
2078    // Measure the widest right side for line diffs and reduce max_path_width if
2079    // needed.
2080    let max_diffs = stats
2081        .entries()
2082        .iter()
2083        .filter_map(|stat| {
2084            let (added, removed) = stat.added_removed?;
2085            Some(added + removed)
2086        })
2087        .max();
2088    let diff_number_width = max_diffs.map_or(0, |n| n.to_string().len());
2089    if max_diffs.is_some() {
2090        let width = diff_number_width + " ".len();
2091        // Ensure 30% of the space for displaying the ++-- graph:
2092        max_path_width =
2093            max_path_width.min((0.7 * available_width.saturating_sub(width) as f64) as usize);
2094    }
2095
2096    // Measure widest right side for binary diffs and reduce max_path_width if
2097    // needed.
2098    let max_bytes = stats
2099        .entries
2100        .iter()
2101        .filter(|stat| stat.added_removed.is_none())
2102        .map(|stat| stat.bytes_delta.abs())
2103        .max();
2104    if let Some(max) = max_bytes {
2105        let width = if max > 0 {
2106            format!("(binary) {max:+} bytes").len()
2107        } else {
2108            "(binary)".len()
2109        };
2110        max_path_width = max_path_width.min(available_width.saturating_sub(width));
2111    }
2112
2113    // Now that we've chosen the path width, use the rest of the space for the ++--
2114    // bar.
2115    let max_bar_width =
2116        available_width.saturating_sub(max_path_width + diff_number_width + " ".len());
2117    let factor = match max_diffs {
2118        Some(max) if max > max_bar_width => max_bar_width as f64 / max as f64,
2119        _ => 1.0,
2120    };
2121
2122    for (stat, ui_path) in iter::zip(stats.entries(), &ui_paths) {
2123        // replace start of path with ellipsis if the path is too long
2124        let (path, path_width) = text_util::elide_start(ui_path, "...", max_path_width);
2125        let path_pad_width = max_path_width - path_width;
2126        write!(
2127            formatter,
2128            "{path}{:path_pad_width$} | ",
2129            "", // pad to max_path_width
2130        )?;
2131        if let Some((added, removed)) = stat.added_removed {
2132            let bar_length = ((added + removed) as f64 * factor) as usize;
2133            // If neither adds nor removes are present, bar length should be zero.
2134            // If only one is present, bar length should be at least 1.
2135            // If both are present, bar length should be at least 2.
2136            //
2137            // Fractional space after scaling is given to whichever of adds/removes is
2138            // smaller, to show at least one tick for small (but nonzero) counts.
2139            let bar_length = bar_length.max(usize::from(added > 0) + usize::from(removed > 0));
2140            let (bar_added, bar_removed) = if added < removed {
2141                let len = (added as f64 * factor).ceil() as usize;
2142                (len, bar_length - len)
2143            } else {
2144                let len = (removed as f64 * factor).ceil() as usize;
2145                (bar_length - len, len)
2146            };
2147            write!(
2148                formatter,
2149                "{:>diff_number_width$}{}",
2150                added + removed,
2151                if bar_added + bar_removed > 0 { " " } else { "" },
2152            )?;
2153            write!(formatter.labeled("added"), "{}", "+".repeat(bar_added))?;
2154            writeln!(formatter.labeled("removed"), "{}", "-".repeat(bar_removed))?;
2155        } else {
2156            write!(formatter.labeled("binary"), "(binary)")?;
2157            if stat.bytes_delta != 0 {
2158                let label = if stat.bytes_delta < 0 {
2159                    "removed"
2160                } else {
2161                    "added"
2162                };
2163                write!(formatter.labeled(label), " {:+}", stat.bytes_delta)?;
2164                write!(formatter, " bytes")?;
2165            }
2166            writeln!(formatter)?;
2167        }
2168    }
2169
2170    let total_added = stats.count_total_added();
2171    let total_removed = stats.count_total_removed();
2172    let total_files = stats.entries().len();
2173    writeln!(
2174        formatter.labeled("stat-summary"),
2175        "{} file{} changed, {} insertion{}(+), {} deletion{}(-)",
2176        total_files,
2177        if total_files == 1 { "" } else { "s" },
2178        total_added,
2179        if total_added == 1 { "" } else { "s" },
2180        total_removed,
2181        if total_removed == 1 { "" } else { "s" },
2182    )?;
2183    Ok(())
2184}
2185
2186pub async fn show_types(
2187    formatter: &mut dyn Formatter,
2188    mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2189    path_converter: &RepoPathUiConverter,
2190) -> Result<(), DiffRenderError> {
2191    while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
2192        let values = values?;
2193        let ui_path = match path.to_diff() {
2194            Some(paths) => path_converter.format_copied_path(paths),
2195            None => path_converter.format_file_path(path.target()),
2196        };
2197        writeln!(
2198            formatter.labeled("modified"),
2199            "{before}{after} {ui_path}",
2200            before = diff_summary_char(&values.before),
2201            after = diff_summary_char(&values.after)
2202        )?;
2203    }
2204    Ok(())
2205}
2206
2207fn diff_summary_char(value: &MergedTreeValue) -> char {
2208    match value.as_resolved() {
2209        Some(None) => '-',
2210        Some(Some(TreeValue::File { .. })) => 'F',
2211        Some(Some(TreeValue::Symlink(_))) => 'L',
2212        Some(Some(TreeValue::GitSubmodule(_))) => 'G',
2213        None => 'C',
2214        Some(Some(TreeValue::Tree(_))) => {
2215            panic!("Unexpected {value:?} in diff")
2216        }
2217    }
2218}
2219
2220pub async fn show_names(
2221    formatter: &mut dyn Formatter,
2222    mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2223    path_converter: &RepoPathUiConverter,
2224) -> io::Result<()> {
2225    while let Some(CopiesTreeDiffEntry { path, .. }) = tree_diff.next().await {
2226        writeln!(
2227            formatter,
2228            "{}",
2229            path_converter.format_file_path(path.target())
2230        )?;
2231    }
2232    Ok(())
2233}
2234
2235pub async fn show_templated(
2236    formatter: &mut dyn Formatter,
2237    mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2238    template: &TemplateRenderer<'_, commit_templater::TreeDiffEntry>,
2239) -> Result<(), DiffRenderError> {
2240    while let Some(entry) = tree_diff.next().await {
2241        let entry = commit_templater::TreeDiffEntry::from_backend_entry_with_copies(entry)?;
2242        template.format(&entry, formatter)?;
2243    }
2244    Ok(())
2245}