// Copyright 2020-2022 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::borrow::Cow; use std::cmp::max; use std::future; use std::io; use std::iter; use std::ops::Range; use std::path::Path; use std::path::PathBuf; use bstr::BStr; use bstr::BString; use clap_complete::ArgValueCandidates; use futures::StreamExt as _; use futures::TryStreamExt as _; use futures::stream::BoxStream; use itertools::Itertools as _; use jj_lib::backend::BackendError; use jj_lib::backend::BackendResult; use jj_lib::backend::CommitId; use jj_lib::backend::CopyRecord; use jj_lib::backend::TreeValue; use jj_lib::commit::Commit; use jj_lib::config::ConfigGetError; use jj_lib::conflict_labels::ConflictLabels; use jj_lib::conflicts::ConflictMarkerStyle; use jj_lib::conflicts::ConflictMaterializeOptions; use jj_lib::conflicts::MaterializedTreeDiffEntry; use jj_lib::conflicts::MaterializedTreeValue; use jj_lib::conflicts::materialize_merge_result_to_bytes; use jj_lib::conflicts::materialized_diff_stream; use jj_lib::copies::CopiesTreeDiffEntry; use jj_lib::copies::CopiesTreeDiffEntryPath; use jj_lib::copies::CopyOperation; use jj_lib::copies::CopyRecords; use jj_lib::diff::ContentDiff; use jj_lib::diff::DiffHunk; use jj_lib::diff::DiffHunkKind; use jj_lib::diff_presentation::DiffTokenType; use jj_lib::diff_presentation::FileContent; use jj_lib::diff_presentation::LineCompareMode; use jj_lib::diff_presentation::diff_by_line; use jj_lib::diff_presentation::file_content_for_diff; use jj_lib::diff_presentation::unified::DiffLineType; use jj_lib::diff_presentation::unified::UnifiedDiffError; use jj_lib::diff_presentation::unified::git_diff_part; use jj_lib::diff_presentation::unified::unified_diff_hunks; use jj_lib::diff_presentation::unzip_diff_hunks_to_lines; use jj_lib::files; use jj_lib::files::ConflictDiffHunk; use jj_lib::files::DiffLineHunkSide; use jj_lib::files::DiffLineIterator; use jj_lib::files::DiffLineNumber; use jj_lib::matchers::Matcher; use jj_lib::merge::Diff; use jj_lib::merge::Merge; use jj_lib::merge::MergeBuilder; use jj_lib::merge::MergedTreeValue; use jj_lib::merged_tree::MergedTree; use jj_lib::repo::Repo; use jj_lib::repo_path::InvalidRepoPathError; use jj_lib::repo_path::RepoPath; use jj_lib::repo_path::RepoPathUiConverter; use jj_lib::rewrite::rebase_to_dest_parent; use jj_lib::settings::UserSettings; use jj_lib::store::Store; use thiserror::Error; use tracing::instrument; use unicode_width::UnicodeWidthStr as _; use crate::command_error::CommandError; use crate::command_error::cli_error; use crate::commit_templater; use crate::config::CommandNameAndArgs; use crate::formatter::Formatter; use crate::formatter::FormatterExt as _; use crate::merge_tools; use crate::merge_tools::DiffGenerateError; use crate::merge_tools::DiffToolMode; use crate::merge_tools::ExternalMergeTool; use crate::merge_tools::generate_diff; use crate::merge_tools::invoke_external_diff; use crate::merge_tools::new_utf8_temp_dir; use crate::templater::TemplateRenderer; use crate::text_util; use crate::ui::Ui; #[derive(clap::Args, Clone, Debug)] #[command(next_help_heading = "Diff Formatting Options")] #[command(group(clap::ArgGroup::new("short-format").args(&["summary", "stat", "types", "name_only"])))] #[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words"])))] pub struct DiffFormatArgs { /// For each path, show only whether it was modified, added, or deleted #[arg(long, short)] pub summary: bool, /// Show a histogram of the changes #[arg(long)] pub stat: bool, /// For each path, show only its type before and after /// /// The diff is shown as two letters. The first letter indicates the type /// before and the second letter indicates the type after. '-' indicates /// that the path was not present, 'F' represents a regular file, `L' /// represents a symlink, 'C' represents a conflict, and 'G' represents a /// Git submodule. #[arg(long)] pub types: bool, /// For each path, show only its path /// /// Typically useful for shell commands like: /// `jj diff -r @- --name-only | xargs perl -pi -e's/OLD/NEW/g` #[arg(long)] pub name_only: bool, /// Show a Git-format diff #[arg(long)] pub git: bool, /// Show a word-level diff with changes indicated only by color #[arg(long)] pub color_words: bool, /// Generate diff by external command /// /// A builtin format can also be specified as `:`. For example, /// `--tool=:git` is equivalent to `--git`. #[arg(long)] #[arg(add = ArgValueCandidates::new(crate::complete::diff_formatters))] pub tool: Option, /// Number of lines of context to show #[arg(long)] context: Option, // Short flags are set by command to avoid future conflicts. /// Ignore whitespace when comparing lines. #[arg(long)] // short = 'w' ignore_all_space: bool, /// Ignore changes in amount of whitespace when comparing lines. #[arg(long, conflicts_with = "ignore_all_space")] // short = 'b' ignore_space_change: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum DiffFormat { // Non-trivial parameters are boxed in order to keep the variants small Summary, Stat(Box), Types, NameOnly, Git(Box), ColorWords(Box), Tool(Box), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum BuiltinFormatKind { Summary, Stat, Types, NameOnly, Git, ColorWords, } impl BuiltinFormatKind { // Alternatively, we could use or vendor one of the crates `strum`, // `enum-iterator`, or `variant_count` (for a check that the length of the array // is correct). The latter is very simple and is also a nightly feature. const ALL_VARIANTS: &[Self] = &[ Self::Summary, Self::Stat, Self::Types, Self::NameOnly, Self::Git, Self::ColorWords, ]; fn from_name(name: &str) -> Result { match name { "summary" => Ok(Self::Summary), "stat" => Ok(Self::Stat), "types" => Ok(Self::Types), "name-only" => Ok(Self::NameOnly), "git" => Ok(Self::Git), "color-words" => Ok(Self::ColorWords), _ => Err(format!("Invalid builtin diff format: {name}")), } } fn short_from_args(args: &DiffFormatArgs) -> Option { if args.summary { Some(Self::Summary) } else if args.stat { Some(Self::Stat) } else if args.types { Some(Self::Types) } else if args.name_only { Some(Self::NameOnly) } else { None } } fn long_from_args(args: &DiffFormatArgs) -> Option { if args.git { Some(Self::Git) } else if args.color_words { Some(Self::ColorWords) } else { None } } fn is_short(self) -> bool { match self { Self::Summary | Self::Stat | Self::Types | Self::NameOnly => true, Self::Git | Self::ColorWords => false, } } fn to_arg_name(self) -> &'static str { match self { Self::Summary => "summary", Self::Stat => "stat", Self::Types => "types", Self::NameOnly => "name-only", Self::Git => "git", Self::ColorWords => "color-words", } } fn to_format( self, settings: &UserSettings, args: &DiffFormatArgs, ) -> Result { match self { Self::Summary => Ok(DiffFormat::Summary), Self::Stat => { let mut options = DiffStatOptions::default(); options.merge_args(args); Ok(DiffFormat::Stat(Box::new(options))) } Self::Types => Ok(DiffFormat::Types), Self::NameOnly => Ok(DiffFormat::NameOnly), Self::Git => { let mut options = UnifiedDiffOptions::from_settings(settings)?; options.merge_args(args); Ok(DiffFormat::Git(Box::new(options))) } Self::ColorWords => { let mut options = ColorWordsDiffOptions::from_settings(settings)?; options.merge_args(args); Ok(DiffFormat::ColorWords(Box::new(options))) } } } } /// Returns the list of builtin diff format names such as `:git` pub fn all_builtin_diff_format_names() -> Vec { BuiltinFormatKind::ALL_VARIANTS .iter() .map(|kind| format!(":{}", kind.to_arg_name())) .collect() } fn diff_formatter_tool( settings: &UserSettings, name: &str, ) -> Result, CommandError> { let maybe_tool = merge_tools::get_external_tool_config(settings, name)?; if let Some(tool) = &maybe_tool && tool.diff_args.is_empty() { return Err(cli_error(format!( "The tool `{name}` cannot be used for diff formatting" ))); } Ok(maybe_tool) } /// Returns a list of requested diff formats, which will never be empty. pub fn diff_formats_for( settings: &UserSettings, args: &DiffFormatArgs, ) -> Result, CommandError> { let formats = diff_formats_from_args(settings, args)?; if formats.iter().all(|f| f.is_none()) { Ok(vec![default_diff_format(settings, args)?]) } else { Ok(formats.into_iter().flatten().collect()) } } /// Returns a list of requested diff formats for log-like commands, which may be /// empty. pub fn diff_formats_for_log( settings: &UserSettings, args: &DiffFormatArgs, patch: bool, ) -> Result, CommandError> { let [short_format, mut long_format] = diff_formats_from_args(settings, args)?; // --patch implies default if no "long" format is specified if patch && long_format.is_none() { // TODO: maybe better to error out if the configured default isn't a // "long" format? let default_format = default_diff_format(settings, args)?; if short_format.as_ref() != Some(&default_format) { long_format = Some(default_format); } } Ok([short_format, long_format].into_iter().flatten().collect()) } fn diff_formats_from_args( settings: &UserSettings, args: &DiffFormatArgs, ) -> Result<[Option; 2], CommandError> { let short_kind = BuiltinFormatKind::short_from_args(args); let long_kind = BuiltinFormatKind::long_from_args(args); let mut short_format = short_kind .map(|kind| kind.to_format(settings, args)) .transpose()?; let mut long_format = long_kind .map(|kind| kind.to_format(settings, args)) .transpose()?; if let Some(name) = &args.tool { let ensure_new = |old_kind: Option| match old_kind { Some(old) => Err(cli_error(format!( "--tool={name} cannot be used with --{old}", old = old.to_arg_name() ))), None => Ok(()), }; if let Some(name) = name.strip_prefix(':') { let kind = BuiltinFormatKind::from_name(name).map_err(cli_error)?; let format = kind.to_format(settings, args)?; if kind.is_short() { ensure_new(short_kind)?; short_format = Some(format); } else { ensure_new(long_kind)?; long_format = Some(format); } } else { ensure_new(long_kind)?; let tool = diff_formatter_tool(settings, name)? .unwrap_or_else(|| ExternalMergeTool::with_program(name)); long_format = Some(DiffFormat::Tool(Box::new(tool))); } } Ok([short_format, long_format]) } fn default_diff_format( settings: &UserSettings, args: &DiffFormatArgs, ) -> Result { let tool_args: CommandNameAndArgs = settings.get("ui.diff-formatter")?; if let Some(name) = tool_args.as_str().and_then(|s| s.strip_prefix(':')) { Ok(BuiltinFormatKind::from_name(name) .map_err(|err| ConfigGetError::Type { name: "ui.diff-formatter".to_owned(), error: err.into(), source_path: None, })? .to_format(settings, args)?) } else { let tool = if let Some(name) = tool_args.as_str() { diff_formatter_tool(settings, name)? } else { None } .unwrap_or_else(|| ExternalMergeTool::with_diff_args(&tool_args)); Ok(DiffFormat::Tool(Box::new(tool))) } } #[derive(Debug, Error)] pub enum DiffRenderError { #[error("Failed to generate diff")] DiffGenerate(#[source] DiffGenerateError), #[error(transparent)] Backend(#[from] BackendError), #[error("Access denied to {path}")] AccessDenied { path: String, source: Box, }, #[error(transparent)] InvalidRepoPath(#[from] InvalidRepoPathError), #[error(transparent)] Io(#[from] io::Error), } impl From for DiffRenderError { fn from(value: UnifiedDiffError) -> Self { match value { UnifiedDiffError::Backend(error) => Self::Backend(error), UnifiedDiffError::AccessDenied { path, source } => Self::AccessDenied { path, source }, } } } /// Configuration and environment to render textual diff. pub struct DiffRenderer<'a> { repo: &'a dyn Repo, path_converter: &'a RepoPathUiConverter, conflict_marker_style: ConflictMarkerStyle, formats: Vec, } impl<'a> DiffRenderer<'a> { pub fn new( repo: &'a dyn Repo, path_converter: &'a RepoPathUiConverter, conflict_marker_style: ConflictMarkerStyle, formats: Vec, ) -> Self { Self { repo, path_converter, conflict_marker_style, formats, } } /// Generates diff between `trees`. pub async fn show_diff( &self, ui: &Ui, // TODO: remove Ui dependency if possible formatter: &mut dyn Formatter, trees: Diff<&MergedTree>, matcher: &dyn Matcher, copy_records: &CopyRecords, width: usize, ) -> Result<(), DiffRenderError> { let mut formatter = formatter.labeled("diff"); self.show_diff_trees(ui, *formatter, trees, matcher, copy_records, width) .await } async fn show_diff_trees( &self, ui: &Ui, formatter: &mut dyn Formatter, trees: Diff<&MergedTree>, matcher: &dyn Matcher, copy_records: &CopyRecords, width: usize, ) -> Result<(), DiffRenderError> { let diff_stream = || { trees .before .diff_stream_with_copies(trees.after, matcher, copy_records) }; let conflict_labels = trees.map(|tree| tree.labels()); let store = self.repo.store(); let path_converter = self.path_converter; for format in &self.formats { match format { DiffFormat::Summary => { let tree_diff = diff_stream(); show_diff_summary(*formatter.labeled("summary"), tree_diff, path_converter) .await?; } DiffFormat::Stat(options) => { let tree_diff = diff_stream(); let stats = DiffStats::calculate(store, tree_diff, options, self.conflict_marker_style) .await?; show_diff_stats(*formatter.labeled("stat"), &stats, path_converter, width)?; } DiffFormat::Types => { let tree_diff = diff_stream(); show_types(*formatter.labeled("types"), tree_diff, path_converter).await?; } DiffFormat::NameOnly => { let tree_diff = diff_stream(); show_names(*formatter.labeled("name_only"), tree_diff, path_converter).await?; } DiffFormat::Git(options) => { let tree_diff = diff_stream(); show_git_diff( *formatter.labeled("git"), store, tree_diff, conflict_labels, options, self.conflict_marker_style, ) .await?; } DiffFormat::ColorWords(options) => { let tree_diff = diff_stream(); show_color_words_diff( *formatter.labeled("color_words"), store, tree_diff, conflict_labels, path_converter, options, self.conflict_marker_style, ) .await?; } DiffFormat::Tool(tool) => { match tool.diff_invocation_mode { DiffToolMode::FileByFile => { let tree_diff = diff_stream(); show_file_by_file_diff( ui, formatter, store, tree_diff, conflict_labels, path_converter, tool, self.conflict_marker_style, width, ) .await } DiffToolMode::Dir => { let mut writer = formatter.raw()?; generate_diff( ui, writer.as_mut(), trees, matcher, tool, self.conflict_marker_style, width, ) .await .map_err(DiffRenderError::DiffGenerate) } }?; } } } Ok(()) } fn show_diff_commit_descriptions( &self, formatter: &mut dyn Formatter, descriptions: Diff<&Merge<&str>>, ) -> Result<(), DiffRenderError> { if !descriptions.is_changed() { return Ok(()); } const DUMMY_PATH: &str = "JJ-COMMIT-DESCRIPTION"; let materialize_options = ConflictMaterializeOptions { marker_style: self.conflict_marker_style, marker_len: None, merge: self.repo.store().merge_options().clone(), }; for format in &self.formats { match format { // Omit diff from "short" formats. Printing dummy file path // wouldn't be useful. DiffFormat::Summary | DiffFormat::Stat(_) | DiffFormat::Types | DiffFormat::NameOnly => {} DiffFormat::Git(options) => { // Git format must be parsable, so use dummy file path. show_git_diff_texts( formatter, Diff::new(DUMMY_PATH, DUMMY_PATH), descriptions, options, &materialize_options, )?; } DiffFormat::ColorWords(options) => { writeln!(formatter.labeled("header"), "Modified commit description:")?; show_color_words_diff_hunks( formatter, descriptions, Diff::new(&ConflictLabels::unlabeled(), &ConflictLabels::unlabeled()), options, &materialize_options, )?; } DiffFormat::Tool(_) => { // TODO: materialize commit description as file? } } } Ok(()) } /// Generates diff between `from_commits` and `to_commit` based off their /// parents. The `from_commits` will temporarily be rebased onto the /// `to_commit` parents to exclude unrelated changes. pub async fn show_inter_diff( &self, ui: &Ui, formatter: &mut dyn Formatter, from_commits: &[Commit], to_commit: &Commit, matcher: &dyn Matcher, width: usize, ) -> Result<(), DiffRenderError> { let mut formatter = formatter.labeled("diff"); let from_description = if from_commits.is_empty() { Merge::resolved("") } else { // TODO: use common predecessor as the base description? MergeBuilder::from_iter(itertools::intersperse( from_commits.iter().map(|c| c.description()), "", )) .build() .simplify() }; let to_description = Merge::resolved(to_commit.description()); let from_tree = rebase_to_dest_parent(self.repo, from_commits, to_commit).await?; let to_tree = to_commit.tree(); let copy_records = CopyRecords::default(); // TODO self.show_diff_commit_descriptions( *formatter, Diff::new(&from_description, &to_description), )?; self.show_diff_trees( ui, *formatter, Diff::new(&from_tree, &to_tree), matcher, ©_records, width, ) .await } /// Generates diff of the given `commit` compared to its parents. pub async fn show_patch( &self, ui: &Ui, formatter: &mut dyn Formatter, commit: &Commit, matcher: &dyn Matcher, width: usize, ) -> Result<(), DiffRenderError> { let from_tree = commit.parent_tree(self.repo).await?; let to_tree = commit.tree(); let mut copy_records = CopyRecords::default(); for parent_id in commit.parent_ids() { let records = get_copy_records(self.repo.store(), parent_id, commit.id(), matcher).await?; copy_records.add_records(records); } self.show_diff( ui, formatter, Diff::new(&from_tree, &to_tree), matcher, ©_records, width, ) .await } } pub async fn get_copy_records( store: &Store, root: &CommitId, head: &CommitId, matcher: &dyn Matcher, ) -> BackendResult> { // TODO: teach backend about matching path prefixes? let stream = store.get_copy_records(None, root, head)?; // TODO: test record.source as well? should be AND-ed or OR-ed? stream .try_filter(|record| future::ready(matcher.matches(&record.target))) .try_collect() .await } /// How conflicts are processed and rendered in diffs. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ConflictDiffMethod { /// Compares materialized contents. #[default] Materialize, /// Compares individual pairs of left and right contents. Pair, } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct LineDiffOptions { /// How equivalence of lines is tested. pub compare_mode: LineCompareMode, // TODO: add --ignore-blank-lines, etc. which aren't mutually exclusive. } impl LineDiffOptions { fn merge_args(&mut self, args: &DiffFormatArgs) { self.compare_mode = if args.ignore_all_space { LineCompareMode::IgnoreAllSpace } else if args.ignore_space_change { LineCompareMode::IgnoreSpaceChange } else { LineCompareMode::Exact }; } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct ColorWordsDiffOptions { /// How conflicts are processed and rendered. pub conflict: ConflictDiffMethod, /// Number of context lines to show. pub context: usize, /// How lines are tokenized and compared. pub line_diff: LineDiffOptions, /// Maximum number of removed/added word alternation to inline. pub max_inline_alternation: Option, } impl ColorWordsDiffOptions { pub fn from_settings(settings: &UserSettings) -> Result { let max_inline_alternation = { let name = "diff.color-words.max-inline-alternation"; match settings.get_int(name)? { -1 => None, // unlimited n => Some(usize::try_from(n).map_err(|err| ConfigGetError::Type { name: name.to_owned(), error: err.into(), source_path: None, })?), } }; Ok(Self { conflict: settings.get("diff.color-words.conflict")?, context: settings.get("diff.color-words.context")?, line_diff: LineDiffOptions::default(), max_inline_alternation, }) } fn merge_args(&mut self, args: &DiffFormatArgs) { if let Some(context) = args.context { self.context = context; } self.line_diff.merge_args(args); } } fn show_color_words_diff_hunks>( formatter: &mut dyn Formatter, contents: Diff<&Merge>, conflict_labels: Diff<&ConflictLabels>, options: &ColorWordsDiffOptions, materialize_options: &ConflictMaterializeOptions, ) -> io::Result<()> { let line_number = DiffLineNumber { left: 1, right: 1 }; let labels = Diff::new("removed", "added"); if let (Some(left), Some(right)) = (contents.before.as_resolved(), contents.after.as_resolved()) { let contents = Diff::new(left, right).map(BStr::new); show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?; return Ok(()); } match options.conflict { ConflictDiffMethod::Materialize => { let contents = contents.zip(conflict_labels).map(|(side, labels)| { materialize_merge_result_to_bytes(side, labels, materialize_options) }); show_color_words_resolved_hunks( formatter, contents.as_ref().map(BStr::new), line_number, labels, options, )?; } ConflictDiffMethod::Pair => { let contents = contents.map(|side| files::merge(side, &materialize_options.merge)); show_color_words_conflict_hunks( formatter, contents.as_ref(), line_number, labels, options, )?; } } Ok(()) } fn show_color_words_conflict_hunks( formatter: &mut dyn Formatter, contents: Diff<&Merge>, mut line_number: DiffLineNumber, labels: Diff<&str>, options: &ColorWordsDiffOptions, ) -> io::Result { let num_lefts = contents.before.as_slice().len(); let line_diff = diff_by_line( itertools::chain(contents.before, contents.after), &options.line_diff.compare_mode, ); // Matching entries shouldn't appear consecutively in diff of two inputs. // However, if the inputs have conflicts, there may be a hunk that can be // resolved, resulting [matching, resolved, matching] sequence. let mut contexts: Vec> = Vec::new(); let mut emitted = false; for hunk in files::conflict_diff_hunks(line_diff.hunks(), num_lefts) { match hunk.kind { // There may be conflicts in matching hunk, but just pick one. It // would be too verbose to show all conflict pairs as context. DiffHunkKind::Matching => { contexts.push(Diff::new(hunk.lefts.first(), hunk.rights.first())); } DiffHunkKind::Different => { let num_after = if emitted { options.context } else { 0 }; let num_before = options.context; line_number = show_color_words_context_lines( formatter, &contexts, line_number, labels, options, num_after, num_before, )?; contexts.clear(); emitted = true; line_number = if let (Some(&left), Some(&right)) = (hunk.lefts.as_resolved(), hunk.rights.as_resolved()) { show_color_words_diff_lines( formatter, Diff::new(left, right), line_number, labels, options, )? } else { show_color_words_unresolved_hunk( formatter, &hunk, line_number, labels, options, )? } } } } let num_after = if emitted { options.context } else { 0 }; let num_before = 0; show_color_words_context_lines( formatter, &contexts, line_number, labels, options, num_after, num_before, ) } fn show_color_words_unresolved_hunk( formatter: &mut dyn Formatter, hunk: &ConflictDiffHunk, line_number: DiffLineNumber, labels: Diff<&str>, options: &ColorWordsDiffOptions, ) -> io::Result { let hunk_desc = if hunk.lefts.is_resolved() { "Created conflict" } else if hunk.rights.is_resolved() { "Resolved conflict" } else { "Modified conflict" }; writeln!(formatter.labeled("hunk_header"), "<<<<<<< {hunk_desc}")?; // Pad with identical (negative, positive) terms. It's common that one of // the sides is resolved, or both sides have the same numbers of terms. If // both sides are conflicts, and the numbers of terms are different, the // choice of padding terms is arbitrary. let num_terms = max(hunk.lefts.as_slice().len(), hunk.rights.as_slice().len()); let lefts = hunk.lefts.iter().enumerate(); let rights = hunk.rights.iter().enumerate(); let padded = iter::zip( lefts.chain(iter::repeat((0, hunk.lefts.first()))), rights.chain(iter::repeat((0, hunk.rights.first()))), ) .take(num_terms); let mut max_line_number = line_number; for (i, ((left_index, &left_content), (right_index, &right_content))) in padded.enumerate() { let positive = i % 2 == 0; writeln!( formatter.labeled("hunk_header"), "{sep} left {left_name} #{left_index} to right {right_name} #{right_index}", sep = if positive { "+++++++" } else { "-------" }, // these numbers should be compatible with the "tree-set" language #5307 left_name = if left_index % 2 == 0 { "side" } else { "base" }, left_index = left_index / 2 + 1, right_name = if right_index % 2 == 0 { "side" } else { "base" }, right_index = right_index / 2 + 1, )?; let contents = Diff::new(left_content, right_content); let labels = match positive { true => labels, false => labels.invert(), }; // Individual hunk pair may be largely the same, so diff it again. let new_line_number = show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?; // Take max to assign unique line numbers to trailing hunks. The line // numbers can't be real anyway because preceding conflict hunks might // have been resolved. max_line_number.left = max(max_line_number.left, new_line_number.left); max_line_number.right = max(max_line_number.right, new_line_number.right); } writeln!(formatter.labeled("hunk_header"), ">>>>>>> Conflict ends")?; Ok(max_line_number) } fn show_color_words_resolved_hunks( formatter: &mut dyn Formatter, contents: Diff<&BStr>, mut line_number: DiffLineNumber, labels: Diff<&str>, options: &ColorWordsDiffOptions, ) -> io::Result { let line_diff = diff_by_line(contents.into_array(), &options.line_diff.compare_mode); // Matching entries shouldn't appear consecutively in diff of two inputs. let mut context: Option> = None; let mut emitted = false; for hunk in line_diff.hunks() { let &[left, right] = hunk.contents.as_slice() else { panic!("hunk contents should have two sides") }; let hunk_contents = Diff::new(left, right); match hunk.kind { DiffHunkKind::Matching => { context = Some(hunk_contents); } DiffHunkKind::Different => { let num_after = if emitted { options.context } else { 0 }; let num_before = options.context; line_number = show_color_words_context_lines( formatter, context.as_slice(), line_number, labels, options, num_after, num_before, )?; context = None; emitted = true; line_number = show_color_words_diff_lines( formatter, hunk_contents, line_number, labels, options, )?; } } } let num_after = if emitted { options.context } else { 0 }; let num_before = 0; show_color_words_context_lines( formatter, context.as_slice(), line_number, labels, options, num_after, num_before, ) } /// Prints `num_after` lines, ellipsis, and `num_before` lines. fn show_color_words_context_lines( formatter: &mut dyn Formatter, contexts: &[Diff<&BStr>], mut line_number: DiffLineNumber, labels: Diff<&str>, options: &ColorWordsDiffOptions, num_after: usize, num_before: usize, ) -> io::Result { const SKIPPED_CONTEXT_LINE: &str = " ...\n"; let extract = |after: bool| -> (Vec<&[u8]>, Vec<&[u8]>, u32) { let mut lines = contexts .iter() .map(|contents| { if after { contents.after } else { contents.before } }) .flat_map(|side| side.split_inclusive(|b| *b == b'\n')) .fuse(); let after_lines = lines.by_ref().take(num_after).collect(); let before_lines = lines.by_ref().rev().take(num_before + 1).collect(); let num_skipped: u32 = lines.count().try_into().unwrap(); (after_lines, before_lines, num_skipped) }; let show = |formatter: &mut dyn Formatter, [left_lines, right_lines]: [&[&[u8]]; 2], mut line_number: DiffLineNumber| { // Whether or not left/right lines differ in whitespace, they are // "context" lines. let mut formatter = formatter.labeled("context"); if left_lines == right_lines { for line in left_lines { show_color_words_line_number( *formatter, Diff::new(Some(line_number.left), Some(line_number.right)), labels, )?; show_color_words_inline_hunks( *formatter, &[(DiffLineHunkSide::Both, line.as_ref())], labels, )?; line_number.left += 1; line_number.right += 1; } Ok(line_number) } else { let left = left_lines.concat(); let right = right_lines.concat(); show_color_words_diff_lines( *formatter, Diff::new(&left, &right).map(BStr::new), line_number, labels, options, ) } }; let (left_after, mut left_before, num_left_skipped) = extract(false); let (right_after, mut right_before, num_right_skipped) = extract(true); line_number = show(formatter, [&left_after, &right_after], line_number)?; if num_left_skipped > 0 || num_right_skipped > 0 { write!(formatter, "{SKIPPED_CONTEXT_LINE}")?; line_number.left += num_left_skipped; line_number.right += num_right_skipped; if left_before.len() > num_before { left_before.pop(); line_number.left += 1; } if right_before.len() > num_before { right_before.pop(); line_number.right += 1; } } left_before.reverse(); right_before.reverse(); line_number = show(formatter, [&left_before, &right_before], line_number)?; Ok(line_number) } fn show_color_words_diff_lines( formatter: &mut dyn Formatter, contents: Diff<&BStr>, mut line_number: DiffLineNumber, labels: Diff<&str>, options: &ColorWordsDiffOptions, ) -> io::Result { let word_diff_hunks = ContentDiff::by_word(contents.into_array()) .hunks() .collect_vec(); let can_inline = match options.max_inline_alternation { None => true, // unlimited Some(0) => false, // no need to count alternation Some(max_num) => { let groups = split_diff_hunks_by_matching_newline(&word_diff_hunks); groups.map(count_diff_alternation).max().unwrap_or(0) <= max_num } }; if can_inline { let mut diff_line_iter = DiffLineIterator::with_line_number(word_diff_hunks.iter(), line_number); for diff_line in diff_line_iter.by_ref() { show_color_words_line_number( formatter, Diff::new( diff_line .has_left_content() .then_some(diff_line.line_number.left), diff_line .has_right_content() .then_some(diff_line.line_number.right), ), labels, )?; show_color_words_inline_hunks(formatter, &diff_line.hunks, labels)?; } line_number = diff_line_iter.next_line_number(); } else { let lines = unzip_diff_hunks_to_lines(&word_diff_hunks); for tokens in &lines.before { show_color_words_line_number( formatter, Diff::new(Some(line_number.left), None), labels, )?; show_color_words_single_sided_line(formatter, tokens, labels.before)?; line_number.left += 1; } for tokens in &lines.after { show_color_words_line_number( formatter, Diff::new(None, Some(line_number.right)), labels, )?; show_color_words_single_sided_line(formatter, tokens, labels.after)?; line_number.right += 1; } } Ok(line_number) } fn show_color_words_line_number( formatter: &mut dyn Formatter, line_numbers: Diff>, labels: Diff<&str>, ) -> io::Result<()> { if let Some(line_number) = line_numbers.before { write!( formatter.labeled(labels.before).labeled("line_number"), "{line_number:>4}" )?; write!(formatter, " ")?; } else { write!(formatter, " ")?; } if let Some(line_number) = line_numbers.after { write!( formatter.labeled(labels.after).labeled("line_number"), "{line_number:>4}" )?; write!(formatter, ": ")?; } else { write!(formatter, " : ")?; } Ok(()) } /// Prints line hunks which may contain tokens originating from both sides. fn show_color_words_inline_hunks( formatter: &mut dyn Formatter, line_hunks: &[(DiffLineHunkSide, &BStr)], labels: Diff<&str>, ) -> io::Result<()> { for (side, data) in line_hunks { let label = match side { DiffLineHunkSide::Both => None, DiffLineHunkSide::Left => Some(labels.before), DiffLineHunkSide::Right => Some(labels.after), }; if let Some(label) = label { formatter.labeled(label).labeled("token").write_all(data)?; } else { formatter.write_all(data)?; } } let (_, data) = line_hunks.last().expect("diff line must not be empty"); if !data.ends_with(b"\n") { writeln!(formatter)?; } Ok(()) } /// Prints left/right-only line tokens with the given label. fn show_color_words_single_sided_line( formatter: &mut dyn Formatter, tokens: &[(DiffTokenType, &[u8])], label: &str, ) -> io::Result<()> { show_diff_line_tokens(*formatter.labeled(label), tokens)?; let (_, data) = tokens.last().expect("diff line must not be empty"); if !data.ends_with(b"\n") { writeln!(formatter)?; } Ok(()) } /// Counts number of diff-side alternation, ignoring matching hunks. /// /// This function is meant to measure visual complexity of diff hunks. It's easy /// to read hunks containing some removed or added words, but is getting harder /// as more removes and adds interleaved. /// /// For example, /// - `[matching]` -> 0 /// - `[left]` -> 1 /// - `[left, matching, left]` -> 1 /// - `[matching, left, right, matching, right]` -> 2 /// - `[left, right, matching, right, left]` -> 3 fn count_diff_alternation(diff_hunks: &[DiffHunk]) -> usize { diff_hunks .iter() .filter_map(|hunk| match hunk.kind { DiffHunkKind::Matching => None, DiffHunkKind::Different => Some(&hunk.contents), }) // Map non-empty diff side to index (0: left, 1: right) .flat_map(|contents| contents.iter().positions(|content| !content.is_empty())) // Omit e.g. left->(matching->)*left .dedup() .count() } /// Splits hunks into slices of contiguous changed lines. fn split_diff_hunks_by_matching_newline<'a, 'b>( diff_hunks: &'a [DiffHunk<'b>], ) -> impl Iterator]> { diff_hunks.split_inclusive(|hunk| match hunk.kind { DiffHunkKind::Matching => hunk.contents.iter().all(|content| content.contains(&b'\n')), DiffHunkKind::Different => false, }) } async fn diff_content( path: &RepoPath, value: MaterializedTreeValue, materialize_options: &ConflictMaterializeOptions, ) -> BackendResult> { diff_content_with( path, value, |content| content, |contents, labels| { materialize_merge_result_to_bytes(&contents, &labels, materialize_options) }, ) .await } #[derive(PartialEq, Eq, Debug)] struct DiffContentAsMerge { file_content: Merge, conflict_labels: ConflictLabels, } impl DiffContentAsMerge { pub fn is_empty(&self) -> bool { self.file_content .as_resolved() .is_some_and(|c| c.is_empty()) } } async fn diff_content_as_merge( path: &RepoPath, value: MaterializedTreeValue, ) -> BackendResult> { diff_content_with( path, value, |contents| DiffContentAsMerge { file_content: Merge::resolved(contents), conflict_labels: ConflictLabels::unlabeled(), }, |contents, labels| DiffContentAsMerge { file_content: contents, conflict_labels: labels, }, ) .await } async fn diff_content_with( path: &RepoPath, value: MaterializedTreeValue, map_resolved: impl FnOnce(BString) -> T, map_conflict: impl FnOnce(Merge, ConflictLabels) -> T, ) -> BackendResult> { match value { MaterializedTreeValue::Absent => Ok(FileContent { is_binary: false, contents: map_resolved(BString::default()), }), MaterializedTreeValue::AccessDenied(err) => Ok(FileContent { is_binary: false, contents: map_resolved(format!("Access denied: {err}").into()), }), MaterializedTreeValue::File(mut file) => { file_content_for_diff(path, &mut file, map_resolved).await } MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent { // Unix file paths can't contain null bytes. is_binary: false, contents: map_resolved(target.into()), }), MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent { is_binary: false, contents: map_resolved(format!("Git submodule checked out at {id}").into()), }), // TODO: are we sure this is never binary? MaterializedTreeValue::FileConflict(file) => Ok(FileContent { is_binary: false, contents: map_conflict(file.contents, file.labels), }), MaterializedTreeValue::OtherConflict { id, labels } => Ok(FileContent { is_binary: false, contents: map_resolved(id.describe(&labels).into()), }), MaterializedTreeValue::Tree(id) => { panic!("Unexpected tree with id {id:?} in diff at path {path:?}"); } } } fn basic_diff_file_type(value: &MaterializedTreeValue) -> &'static str { match value { MaterializedTreeValue::Absent => { panic!("absent path in diff"); } MaterializedTreeValue::AccessDenied(_) => "access denied", MaterializedTreeValue::File(file) => { if file.executable { "executable file" } else { "regular file" } } MaterializedTreeValue::Symlink { .. } => "symlink", MaterializedTreeValue::Tree(_) => "tree", MaterializedTreeValue::GitSubmodule(_) => "Git submodule", MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. } => { "conflict" } } } pub async fn show_color_words_diff( formatter: &mut dyn Formatter, store: &Store, tree_diff: BoxStream<'_, CopiesTreeDiffEntry>, conflict_labels: Diff<&ConflictLabels>, path_converter: &RepoPathUiConverter, options: &ColorWordsDiffOptions, marker_style: ConflictMarkerStyle, ) -> Result<(), DiffRenderError> { let materialize_options = ConflictMaterializeOptions { marker_style, marker_len: None, merge: store.merge_options().clone(), }; let empty_content = || Merge::resolved(BString::default()); let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels); while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await { let left_path = path.source(); let right_path = path.target(); let left_ui_path = path_converter.format_file_path(left_path); let right_ui_path = path_converter.format_file_path(right_path); let Diff { before: left_value, after: right_value, } = values?; match (&left_value, &right_value) { (MaterializedTreeValue::AccessDenied(source), _) => { write!( formatter.labeled("access-denied"), "Access denied to {left_ui_path}:" )?; writeln!(formatter, " {source}")?; continue; } (_, MaterializedTreeValue::AccessDenied(source)) => { write!( formatter.labeled("access-denied"), "Access denied to {right_ui_path}:" )?; writeln!(formatter, " {source}")?; continue; } _ => {} } if left_value.is_absent() { let description = basic_diff_file_type(&right_value); writeln!( formatter.labeled("header"), "Added {description} {right_ui_path}:" )?; let right_content = diff_content_as_merge(right_path, right_value).await?; if right_content.contents.is_empty() { writeln!(formatter.labeled("empty"), " (empty)")?; } else if right_content.is_binary { writeln!(formatter.labeled("binary"), " (binary)")?; } else { show_color_words_diff_hunks( formatter, Diff::new(&empty_content(), &right_content.contents.file_content), Diff::new( &ConflictLabels::unlabeled(), &right_content.contents.conflict_labels, ), options, &materialize_options, )?; } } else if right_value.is_present() { let description = match (&left_value, &right_value) { (MaterializedTreeValue::File(left), MaterializedTreeValue::File(right)) => { if left.executable && right.executable { "Modified executable file".to_string() } else if left.executable { "Executable file became non-executable at".to_string() } else if right.executable { "Non-executable file became executable at".to_string() } else { "Modified regular file".to_string() } } ( MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. }, MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. }, ) => "Modified conflict in".to_string(), ( MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. }, _, ) => "Resolved conflict in".to_string(), ( _, MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. }, ) => "Created conflict in".to_string(), (MaterializedTreeValue::Symlink { .. }, MaterializedTreeValue::Symlink { .. }) => { "Symlink target changed at".to_string() } (_, _) => { let left_type = basic_diff_file_type(&left_value); let right_type = basic_diff_file_type(&right_value); let (first, rest) = left_type.split_at(1); format!( "{}{} became {} at", first.to_ascii_uppercase(), rest, right_type ) } }; let left_content = diff_content_as_merge(left_path, left_value).await?; let right_content = diff_content_as_merge(right_path, right_value).await?; if left_path == right_path { writeln!( formatter.labeled("header"), "{description} {right_ui_path}:" )?; } else { writeln!( formatter.labeled("header"), "{description} {right_ui_path} ({left_ui_path} => {right_ui_path}):" )?; } if left_content.is_binary || right_content.is_binary { writeln!(formatter.labeled("binary"), " (binary)")?; } else if left_content.contents != right_content.contents { show_color_words_diff_hunks( formatter, Diff::new( &left_content.contents.file_content, &right_content.contents.file_content, ), Diff::new( &left_content.contents.conflict_labels, &right_content.contents.conflict_labels, ), options, &materialize_options, )?; } } else { let description = basic_diff_file_type(&left_value); writeln!( formatter.labeled("header"), "Removed {description} {right_ui_path}:" )?; let left_content = diff_content_as_merge(left_path, left_value).await?; if left_content.contents.is_empty() { writeln!(formatter.labeled("empty"), " (empty)")?; } else if left_content.is_binary { writeln!(formatter.labeled("binary"), " (binary)")?; } else { show_color_words_diff_hunks( formatter, Diff::new(&left_content.contents.file_content, &empty_content()), Diff::new( &left_content.contents.conflict_labels, &ConflictLabels::unlabeled(), ), options, &materialize_options, )?; } } } Ok(()) } #[expect(clippy::too_many_arguments)] pub async fn show_file_by_file_diff( ui: &Ui, formatter: &mut dyn Formatter, store: &Store, tree_diff: BoxStream<'_, CopiesTreeDiffEntry>, conflict_labels: Diff<&ConflictLabels>, path_converter: &RepoPathUiConverter, tool: &ExternalMergeTool, marker_style: ConflictMarkerStyle, width: usize, ) -> Result<(), DiffRenderError> { let materialize_options = ConflictMaterializeOptions { marker_style, marker_len: None, merge: store.merge_options().clone(), }; let create_file = async |path: &RepoPath, wc_dir: &Path, value: MaterializedTreeValue| -> Result { let fs_path = path.to_fs_path(wc_dir)?; std::fs::create_dir_all(fs_path.parent().unwrap())?; let content = diff_content(path, value, &materialize_options).await?; std::fs::write(&fs_path, content.contents)?; Ok(fs_path) }; let temp_dir = new_utf8_temp_dir("jj-diff-")?; let left_wc_dir = temp_dir.path().join("left"); let right_wc_dir = temp_dir.path().join("right"); let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels); while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await { let Diff { before: left_value, after: right_value, } = values?; let left_path = path.source(); let right_path = path.target(); let left_ui_path = path_converter.format_file_path(left_path); let right_ui_path = path_converter.format_file_path(right_path); match (&left_value, &right_value) { (_, MaterializedTreeValue::AccessDenied(source)) => { write!( formatter.labeled("access-denied"), "Access denied to {right_ui_path}:" )?; writeln!(formatter, " {source}")?; continue; } (MaterializedTreeValue::AccessDenied(source), _) => { write!( formatter.labeled("access-denied"), "Access denied to {left_ui_path}:" )?; writeln!(formatter, " {source}")?; continue; } _ => {} } let left_path = create_file(left_path, &left_wc_dir, left_value).await?; let right_path = create_file(right_path, &right_wc_dir, right_value).await?; let patterns = &maplit::hashmap! { "left" => left_path .strip_prefix(temp_dir.path()) .expect("path should be relative to temp_dir") .to_str() .expect("temp_dir should be valid utf-8") .to_owned(), "right" => right_path .strip_prefix(temp_dir.path()) .expect("path should be relative to temp_dir") .to_str() .expect("temp_dir should be valid utf-8") .to_owned(), "width" => width.to_string(), }; let mut writer = formatter.raw()?; invoke_external_diff(ui, writer.as_mut(), tool, temp_dir.path(), patterns) .map_err(DiffRenderError::DiffGenerate)?; } Ok::<(), DiffRenderError>(()) } #[derive(Clone, Debug, Eq, PartialEq)] pub struct UnifiedDiffOptions { /// Number of context lines to show. pub context: usize, /// How lines are tokenized and compared. pub line_diff: LineDiffOptions, } impl UnifiedDiffOptions { pub fn from_settings(settings: &UserSettings) -> Result { Ok(Self { context: settings.get("diff.git.context")?, line_diff: LineDiffOptions::default(), }) } fn merge_args(&mut self, args: &DiffFormatArgs) { if let Some(context) = args.context { self.context = context; } self.line_diff.merge_args(args); } } fn show_unified_diff_hunks( formatter: &mut dyn Formatter, contents: Diff<&BStr>, options: &UnifiedDiffOptions, ) -> io::Result<()> { // "If the chunk size is 0, the first number is one lower than one would // expect." - https://www.artima.com/weblogs/viewpost.jsp?thread=164293 // // The POSIX spec also states that "the ending line number of an empty range // shall be the number of the preceding line, or 0 if the range is at the // start of the file." // - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/diff.html fn to_line_number(range: Range) -> usize { if range.is_empty() { range.start } else { range.start + 1 } } for hunk in unified_diff_hunks(contents, options.context, options.line_diff.compare_mode) { writeln!( formatter.labeled("hunk_header"), "@@ -{},{} +{},{} @@", to_line_number(hunk.left_line_range.clone()), hunk.left_line_range.len(), to_line_number(hunk.right_line_range.clone()), hunk.right_line_range.len() )?; for (line_type, tokens) in &hunk.lines { let (label, sigil) = match line_type { DiffLineType::Context => ("context", " "), DiffLineType::Removed => ("removed", "-"), DiffLineType::Added => ("added", "+"), }; write!(formatter.labeled(label), "{sigil}")?; show_diff_line_tokens(*formatter.labeled(label), tokens)?; let (_, content) = tokens.last().expect("hunk line must not be empty"); if !content.ends_with(b"\n") { write!(formatter, "\n\\ No newline at end of file\n")?; } } } Ok(()) } fn show_diff_line_tokens( formatter: &mut dyn Formatter, tokens: &[(DiffTokenType, &[u8])], ) -> io::Result<()> { for (token_type, content) in tokens { match token_type { DiffTokenType::Matching => formatter.write_all(content)?, DiffTokenType::Different => formatter.labeled("token").write_all(content)?, } } Ok(()) } pub async fn show_git_diff( formatter: &mut dyn Formatter, store: &Store, tree_diff: BoxStream<'_, CopiesTreeDiffEntry>, conflict_labels: Diff<&ConflictLabels>, options: &UnifiedDiffOptions, marker_style: ConflictMarkerStyle, ) -> Result<(), DiffRenderError> { let materialize_options = ConflictMaterializeOptions { marker_style, marker_len: None, merge: store.merge_options().clone(), }; let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels); while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await { let left_path = path.source(); let right_path = path.target(); let left_path_string = left_path.as_internal_file_string(); let right_path_string = right_path.as_internal_file_string(); let values = values?; let left_part = git_diff_part(left_path, values.before, &materialize_options).await?; let right_part = git_diff_part(right_path, values.after, &materialize_options).await?; { let mut formatter = formatter.labeled("file_header"); writeln!( formatter, "diff --git a/{left_path_string} b/{right_path_string}" )?; let left_hash = &left_part.hash; let right_hash = &right_part.hash; match (left_part.mode, right_part.mode) { (None, Some(right_mode)) => { writeln!(formatter, "new file mode {right_mode}")?; writeln!(formatter, "index {left_hash}..{right_hash}")?; } (Some(left_mode), None) => { writeln!(formatter, "deleted file mode {left_mode}")?; writeln!(formatter, "index {left_hash}..{right_hash}")?; } (Some(left_mode), Some(right_mode)) => { if let Some(op) = path.copy_operation() { let operation = match op { CopyOperation::Copy => "copy", CopyOperation::Rename => "rename", }; // TODO: include similarity index? writeln!(formatter, "{operation} from {left_path_string}")?; writeln!(formatter, "{operation} to {right_path_string}")?; } if left_mode != right_mode { writeln!(formatter, "old mode {left_mode}")?; writeln!(formatter, "new mode {right_mode}")?; if left_hash != right_hash { writeln!(formatter, "index {left_hash}..{right_hash}")?; } } else if left_hash != right_hash { writeln!(formatter, "index {left_hash}..{right_hash} {left_mode}")?; } } (None, None) => panic!("either left or right part should be present"), } } if left_part.content.contents == right_part.content.contents { continue; // no content hunks } let left_path = match left_part.mode { Some(_) => format!("a/{left_path_string}"), None => "/dev/null".to_owned(), }; let right_path = match right_part.mode { Some(_) => format!("b/{right_path_string}"), None => "/dev/null".to_owned(), }; if left_part.content.is_binary || right_part.content.is_binary { // TODO: add option to emit Git binary diff writeln!( formatter, "Binary files {left_path} and {right_path} differ" )?; } else { writeln!(formatter.labeled("file_header"), "--- {left_path}")?; writeln!(formatter.labeled("file_header"), "+++ {right_path}")?; show_unified_diff_hunks( formatter, Diff::new(&left_part.content.contents, &right_part.content.contents).map(BStr::new), options, )?; } } Ok(()) } /// Generates diff of non-binary contents in Git format. fn show_git_diff_texts>( formatter: &mut dyn Formatter, paths: Diff<&str>, contents: Diff<&Merge>, options: &UnifiedDiffOptions, materialize_options: &ConflictMaterializeOptions, ) -> io::Result<()> { let Diff { before: left_path, after: right_path, } = paths; { let mut formatter = formatter.labeled("file_header"); writeln!(formatter, "diff --git a/{left_path} b/{right_path}")?; writeln!(formatter, "--- {left_path}")?; writeln!(formatter, "+++ {right_path}")?; } let contents = contents.map(|content| match content.as_resolved() { Some(text) => Cow::Borrowed(BStr::new(text)), None => Cow::Owned(materialize_merge_result_to_bytes( content, &ConflictLabels::unlabeled(), materialize_options, )), }); show_unified_diff_hunks(formatter, contents.as_ref().map(Cow::as_ref), options) } #[instrument(skip_all)] pub async fn show_diff_summary( formatter: &mut dyn Formatter, mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>, path_converter: &RepoPathUiConverter, ) -> Result<(), DiffRenderError> { while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await { let values = values?; let status = diff_status(&path, &values); let (label, sigil) = (status.label(), status.char()); let ui_path = match path.to_diff() { Some(paths) => path_converter.format_copied_path(paths), None => path_converter.format_file_path(path.target()), }; writeln!(formatter.labeled(label), "{sigil} {ui_path}")?; } Ok(()) } fn diff_status_inner( path: &CopiesTreeDiffEntryPath, is_present_before: bool, is_present_after: bool, ) -> DiffEntryStatus { if let Some(op) = path.copy_operation() { match op { CopyOperation::Copy => DiffEntryStatus::Copied, CopyOperation::Rename => DiffEntryStatus::Renamed, } } else { match (is_present_before, is_present_after) { (true, true) => DiffEntryStatus::Modified, (false, true) => DiffEntryStatus::Added, (true, false) => DiffEntryStatus::Removed, (false, false) => panic!("values pair must differ"), } } } pub fn diff_status( path: &CopiesTreeDiffEntryPath, values: &Diff, ) -> DiffEntryStatus { diff_status_inner(path, values.before.is_present(), values.after.is_present()) } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct DiffStatOptions { /// How lines are tokenized and compared. pub line_diff: LineDiffOptions, } impl DiffStatOptions { fn merge_args(&mut self, args: &DiffFormatArgs) { self.line_diff.merge_args(args); } } #[derive(Clone, Debug)] pub struct DiffStats { entries: Vec, } impl DiffStats { /// Calculates stats of changed lines per file. pub async fn calculate( store: &Store, tree_diff: BoxStream<'_, CopiesTreeDiffEntry>, options: &DiffStatOptions, marker_style: ConflictMarkerStyle, ) -> BackendResult { let materialize_options = ConflictMaterializeOptions { marker_style, marker_len: None, merge: store.merge_options().clone(), }; let conflict_labels = ConflictLabels::unlabeled(); let entries = materialized_diff_stream( store, tree_diff, Diff::new(&conflict_labels, &conflict_labels), ) .then(async |MaterializedTreeDiffEntry { path, values }| { let values = values?; let status = diff_status_inner(&path, values.before.is_present(), values.after.is_present()); let left_content = diff_content(path.source(), values.before, &materialize_options).await?; let right_content = diff_content(path.target(), values.after, &materialize_options).await?; let stat = get_diff_stat_entry( path, status, Diff::new(&left_content, &right_content), options, ); BackendResult::Ok(stat) }) .try_collect() .await?; Ok(Self { entries }) } /// List of stats per file. pub fn entries(&self) -> &[DiffStatEntry] { &self.entries } /// Total number of inserted lines. pub fn count_total_added(&self) -> usize { self.entries .iter() .filter_map(|stat| stat.added_removed.map(|(added, _)| added)) .sum() } /// Total number of deleted lines. pub fn count_total_removed(&self) -> usize { self.entries .iter() .filter_map(|stat| stat.added_removed.map(|(_, removed)| removed)) .sum() } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DiffEntryStatus { Added, Removed, Modified, Copied, Renamed, } impl DiffEntryStatus { pub fn label(&self) -> &'static str { match self { Self::Added => "added", Self::Removed => "removed", Self::Modified => "modified", Self::Copied => "copied", Self::Renamed => "renamed", } } pub fn char(&self) -> char { match self { Self::Added => 'A', Self::Removed => 'D', Self::Modified => 'M', Self::Copied => 'C', Self::Renamed => 'R', } } } #[derive(Clone, Debug)] pub struct DiffStatEntry { pub path: CopiesTreeDiffEntryPath, /// Lines added and removed; None for binary files. pub added_removed: Option<(usize, usize)>, /// Change in file size in bytes. pub bytes_delta: isize, pub status: DiffEntryStatus, } fn get_diff_stat_entry( path: CopiesTreeDiffEntryPath, status: DiffEntryStatus, contents: Diff<&FileContent>, options: &DiffStatOptions, ) -> DiffStatEntry { let added_removed = if contents.before.is_binary || contents.after.is_binary { None } else { let diff = diff_by_line( contents.map(|content| &content.contents).into_array(), &options.line_diff.compare_mode, ); let mut added = 0; let mut removed = 0; for hunk in diff.hunks() { match hunk.kind { DiffHunkKind::Matching => {} DiffHunkKind::Different => { let [left, right] = hunk.contents[..].try_into().unwrap(); removed += left.split_inclusive(|b| *b == b'\n').count(); added += right.split_inclusive(|b| *b == b'\n').count(); } } } Some((added, removed)) }; DiffStatEntry { path, added_removed, bytes_delta: contents.after.contents.len() as isize - contents.before.contents.len() as isize, status, } } pub fn show_diff_stats( formatter: &mut dyn Formatter, stats: &DiffStats, path_converter: &RepoPathUiConverter, display_width: usize, ) -> io::Result<()> { let ui_paths = stats .entries() .iter() .map(|stat| match stat.path.to_diff() { Some(paths) => path_converter.format_copied_path(paths), None => path_converter.format_file_path(stat.path.target()), }) .collect_vec(); // Entries format like: // path/to/file | 123 ++-- // or, for binary files: // path/to/file | (binary) +1234 bytes // // Depending on display widths, we can elide part of the path, // and the ++-- bar will adjust its scale to fill the rest. // Choose how many columns to use for the path. The right side will use the // rest. Start with the longest path. The code below might shorten it. let mut max_path_width = ui_paths.iter().map(|s| s.width()).max().unwrap_or(0); // Fit to the available display width, but always assume at least a tiny bit of // room. let available_width = max(display_width.saturating_sub(" | ".len()), 8); // Measure the widest right side for line diffs and reduce max_path_width if // needed. let max_diffs = stats .entries() .iter() .filter_map(|stat| { let (added, removed) = stat.added_removed?; Some(added + removed) }) .max(); let diff_number_width = max_diffs.map_or(0, |n| n.to_string().len()); if max_diffs.is_some() { let width = diff_number_width + " ".len(); // Ensure 30% of the space for displaying the ++-- graph: max_path_width = max_path_width.min((0.7 * available_width.saturating_sub(width) as f64) as usize); } // Measure widest right side for binary diffs and reduce max_path_width if // needed. let max_bytes = stats .entries .iter() .filter(|stat| stat.added_removed.is_none()) .map(|stat| stat.bytes_delta.abs()) .max(); if let Some(max) = max_bytes { let width = if max > 0 { format!("(binary) {max:+} bytes").len() } else { "(binary)".len() }; max_path_width = max_path_width.min(available_width.saturating_sub(width)); } // Now that we've chosen the path width, use the rest of the space for the ++-- // bar. let max_bar_width = available_width.saturating_sub(max_path_width + diff_number_width + " ".len()); let factor = match max_diffs { Some(max) if max > max_bar_width => max_bar_width as f64 / max as f64, _ => 1.0, }; for (stat, ui_path) in iter::zip(stats.entries(), &ui_paths) { // replace start of path with ellipsis if the path is too long let (path, path_width) = text_util::elide_start(ui_path, "...", max_path_width); let path_pad_width = max_path_width - path_width; write!( formatter, "{path}{:path_pad_width$} | ", "", // pad to max_path_width )?; if let Some((added, removed)) = stat.added_removed { let bar_length = ((added + removed) as f64 * factor) as usize; // If neither adds nor removes are present, bar length should be zero. // If only one is present, bar length should be at least 1. // If both are present, bar length should be at least 2. // // Fractional space after scaling is given to whichever of adds/removes is // smaller, to show at least one tick for small (but nonzero) counts. let bar_length = bar_length.max(usize::from(added > 0) + usize::from(removed > 0)); let (bar_added, bar_removed) = if added < removed { let len = (added as f64 * factor).ceil() as usize; (len, bar_length - len) } else { let len = (removed as f64 * factor).ceil() as usize; (bar_length - len, len) }; write!( formatter, "{:>diff_number_width$}{}", added + removed, if bar_added + bar_removed > 0 { " " } else { "" }, )?; write!(formatter.labeled("added"), "{}", "+".repeat(bar_added))?; writeln!(formatter.labeled("removed"), "{}", "-".repeat(bar_removed))?; } else { write!(formatter.labeled("binary"), "(binary)")?; if stat.bytes_delta != 0 { let label = if stat.bytes_delta < 0 { "removed" } else { "added" }; write!(formatter.labeled(label), " {:+}", stat.bytes_delta)?; write!(formatter, " bytes")?; } writeln!(formatter)?; } } let total_added = stats.count_total_added(); let total_removed = stats.count_total_removed(); let total_files = stats.entries().len(); writeln!( formatter.labeled("stat-summary"), "{} file{} changed, {} insertion{}(+), {} deletion{}(-)", total_files, if total_files == 1 { "" } else { "s" }, total_added, if total_added == 1 { "" } else { "s" }, total_removed, if total_removed == 1 { "" } else { "s" }, )?; Ok(()) } pub async fn show_types( formatter: &mut dyn Formatter, mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>, path_converter: &RepoPathUiConverter, ) -> Result<(), DiffRenderError> { while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await { let values = values?; let ui_path = match path.to_diff() { Some(paths) => path_converter.format_copied_path(paths), None => path_converter.format_file_path(path.target()), }; writeln!( formatter.labeled("modified"), "{before}{after} {ui_path}", before = diff_summary_char(&values.before), after = diff_summary_char(&values.after) )?; } Ok(()) } fn diff_summary_char(value: &MergedTreeValue) -> char { match value.as_resolved() { Some(None) => '-', Some(Some(TreeValue::File { .. })) => 'F', Some(Some(TreeValue::Symlink(_))) => 'L', Some(Some(TreeValue::GitSubmodule(_))) => 'G', None => 'C', Some(Some(TreeValue::Tree(_))) => { panic!("Unexpected {value:?} in diff") } } } pub async fn show_names( formatter: &mut dyn Formatter, mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>, path_converter: &RepoPathUiConverter, ) -> io::Result<()> { while let Some(CopiesTreeDiffEntry { path, .. }) = tree_diff.next().await { writeln!( formatter, "{}", path_converter.format_file_path(path.target()) )?; } Ok(()) } pub async fn show_templated( formatter: &mut dyn Formatter, mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>, template: &TemplateRenderer<'_, commit_templater::TreeDiffEntry>, ) -> Result<(), DiffRenderError> { while let Some(entry) = tree_diff.next().await { let entry = commit_templater::TreeDiffEntry::from_backend_entry_with_copies(entry)?; template.format(&entry, formatter)?; } Ok(()) }