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