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