jj_cli/
diff_util.rs

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