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