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