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