// 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::cmp::max;use std::collections::VecDeque;use std::io;use std::ops::Range; use futures::{try_join, Stream, StreamExt};use itertools::Itertools;use jj_lib::backend::{BackendResult, TreeValue};use jj_lib::commit::Commit;use jj_lib::conflicts::{materialize_tree_value, MaterializedTreeValue};use jj_lib::diff::{Diff, DiffHunk};use jj_lib::files::DiffLine;use jj_lib::matchers::Matcher;use jj_lib::merge::MergedTreeValue;use jj_lib::merged_tree::{MergedTree, TreeDiffStream};use jj_lib::object_id::ObjectId;use jj_lib::repo::Repo;use jj_lib::repo_path::{RepoPath, RepoPathBuf};use jj_lib::settings::{ConfigResultExt as _, UserSettings};use jj_lib::store::Store;use jj_lib::{diff, files, rewrite};use pollster::FutureExt;use tracing::instrument;use unicode_width::UnicodeWidthStr as _; use crate::cli_util::WorkspaceCommandHelper;use crate::command_error::CommandError;use crate::config::CommandNameAndArgs;use crate::formatter::Formatter;use crate::merge_tools::{self, ExternalMergeTool};use crate::text_util;use crate::ui::Ui; const DEFAULT_CONTEXT_LINES: usize = 3; #[derive(clap::Args, Clone, Debug)]#[command(next_help_heading = "Diff Formatting Options")]#[command(group(clap::ArgGroup::new("short-format").args(&["summary", "stat", "types"])))]#[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words", "tool"])))]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, /// 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 #[arg(long)] pub tool: Option<String>, /// Number of lines of context to show #[arg(long)] context: Option<usize>,} #[derive(Clone, Debug, Eq, PartialEq)]pub enum DiffFormat { Summary, Stat, Types, Git { context: usize }, ColorWords { context: usize }, Tool(Box<ExternalMergeTool>),} /// Returns a list of requested diff formats, which will never be empty.pub fn diff_formats_for( settings: &UserSettings, args: &DiffFormatArgs,) -> Result<Vec<DiffFormat>, config::ConfigError> { let formats = diff_formats_from_args(settings, args)?; if formats.is_empty() { Ok(vec![default_diff_format(settings, args.context)?]) } else { Ok(formats) }} /// 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<Vec<DiffFormat>, config::ConfigError> { let mut formats = diff_formats_from_args(settings, args)?; // --patch implies default if no format other than --summary is specified if patch && matches!(formats.as_slice(), [] | [DiffFormat::Summary]) { formats.push(default_diff_format(settings, args.context)?); formats.dedup(); } Ok(formats)} fn diff_formats_from_args( settings: &UserSettings, args: &DiffFormatArgs,) -> Result<Vec<DiffFormat>, config::ConfigError> { let mut formats = [ (args.summary, DiffFormat::Summary), (args.types, DiffFormat::Types), ( args.git, DiffFormat::Git { context: args.context.unwrap_or(DEFAULT_CONTEXT_LINES), }, ), ( args.color_words, DiffFormat::ColorWords { context: args.context.unwrap_or(DEFAULT_CONTEXT_LINES), }, ), (args.stat, DiffFormat::Stat), ] .into_iter() .filter_map(|(arg, format)| arg.then_some(format)) .collect_vec(); if let Some(name) = &args.tool { let tool = merge_tools::get_external_tool_config(settings, name)? .unwrap_or_else(|| ExternalMergeTool::with_program(name)); formats.push(DiffFormat::Tool(Box::new(tool))); } Ok(formats)} fn default_diff_format( settings: &UserSettings, num_context_lines: Option<usize>,) -> Result<DiffFormat, config::ConfigError> { let config = settings.config(); if let Some(args) = config.get("ui.diff.tool").optional()? { // External "tool" overrides the internal "format" option. let tool = if let CommandNameAndArgs::String(name) = &args { merge_tools::get_external_tool_config(settings, name)? } else { None } .unwrap_or_else(|| ExternalMergeTool::with_diff_args(&args)); return Ok(DiffFormat::Tool(Box::new(tool))); } let name = if let Some(name) = config.get_string("ui.diff.format").optional()? { name } else if let Some(name) = config.get_string("diff.format").optional()? { name // old config name } else { "color-words".to_owned() }; match name.as_ref() { "summary" => Ok(DiffFormat::Summary), "types" => Ok(DiffFormat::Types), "git" => Ok(DiffFormat::Git { context: num_context_lines.unwrap_or(DEFAULT_CONTEXT_LINES), }), "color-words" => Ok(DiffFormat::ColorWords { context: num_context_lines.unwrap_or(DEFAULT_CONTEXT_LINES), }), "stat" => Ok(DiffFormat::Stat), _ => Err(config::ConfigError::Message(format!( "invalid diff format: {name}" ))), }} pub fn show_diff( ui: &Ui, formatter: &mut dyn Formatter, workspace_command: &WorkspaceCommandHelper, from_tree: &MergedTree, to_tree: &MergedTree, matcher: &dyn Matcher, formats: &[DiffFormat],) -> Result<(), CommandError> { for format in formats { match format { DiffFormat::Summary => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_diff_summary(formatter, workspace_command, tree_diff)?; } DiffFormat::Stat => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_diff_stat(ui, formatter, workspace_command, tree_diff)?; } DiffFormat::Types => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_types(formatter, workspace_command, tree_diff)?; } DiffFormat::Git { context } => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_git_diff(formatter, workspace_command, *context, tree_diff)?; } DiffFormat::ColorWords { context } => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_color_words_diff(formatter, workspace_command, *context, tree_diff)?; } DiffFormat::Tool(tool) => { merge_tools::generate_diff(ui, formatter.raw(), from_tree, to_tree, matcher, tool)?; } } } Ok(())} pub fn show_patch( ui: &Ui, formatter: &mut dyn Formatter, workspace_command: &WorkspaceCommandHelper, commit: &Commit, matcher: &dyn Matcher, formats: &[DiffFormat],) -> Result<(), CommandError> { let parents = commit.parents(); let from_tree = rewrite::merge_commit_trees(workspace_command.repo().as_ref(), &parents)?; let to_tree = commit.tree()?; show_diff( ui, formatter, workspace_command, &from_tree, &to_tree, matcher, formats, )} fn show_color_words_diff_hunks( left: &[u8], right: &[u8], num_context_lines: usize, formatter: &mut dyn Formatter,) -> io::Result<()> { const SKIPPED_CONTEXT_LINE: &str = " ...\n"; let mut context = VecDeque::new(); // Have we printed "..." for any skipped context? let mut skipped_context = false; // Are the lines in `context` to be printed before the next modified line? let mut context_before = true; for diff_line in files::diff(left, right) { if diff_line.is_unmodified() { context.push_back(diff_line.clone()); let mut start_skipping_context = false; if context_before { if skipped_context && context.len() > num_context_lines { context.pop_front(); } else if !skipped_context && context.len() > num_context_lines + 1 { start_skipping_context = true; } } else if context.len() > num_context_lines * 2 + 1 { for line in context.drain(..num_context_lines) { show_color_words_diff_line(formatter, &line)?; } start_skipping_context = true; } if start_skipping_context { context.drain(..2); formatter.write_str(SKIPPED_CONTEXT_LINE)?; skipped_context = true; context_before = true; } } else { for line in &context { show_color_words_diff_line(formatter, line)?; } context.clear(); show_color_words_diff_line(formatter, &diff_line)?; context_before = false; skipped_context = false; } } if !context_before { if context.len() > num_context_lines + 1 { context.truncate(num_context_lines); skipped_context = true; context_before = true; } for line in &context { show_color_words_diff_line(formatter, line)?; } if context_before { formatter.write_str(SKIPPED_CONTEXT_LINE)?; } } // If the last diff line doesn't end with newline, add it. let no_hunk = left.is_empty() && right.is_empty(); let any_last_newline = left.ends_with(b"\n") || right.ends_with(b"\n"); if !skipped_context && !no_hunk && !any_last_newline { formatter.write_str("\n")?; } Ok(())} fn show_color_words_diff_line( formatter: &mut dyn Formatter, diff_line: &DiffLine,) -> io::Result<()> { if diff_line.has_left_content { write!( formatter.labeled("removed"), "{:>4}", diff_line.left_line_number )?; formatter.write_str(" ")?; } else { formatter.write_str(" ")?; } if diff_line.has_right_content { write!( formatter.labeled("added"), "{:>4}", diff_line.right_line_number )?; formatter.write_str(": ")?; } else { formatter.write_str(" : ")?; } for hunk in &diff_line.hunks { match hunk { DiffHunk::Matching(data) => { formatter.write_all(data)?; } DiffHunk::Different(data) => { let before = data[0]; let after = data[1]; if !before.is_empty() { formatter.with_label("removed", |formatter| formatter.write_all(before))?; } if !after.is_empty() { formatter.with_label("added", |formatter| formatter.write_all(after))?; } } } } Ok(())} struct FileContent { /// false if this file is likely text; true if it is likely binary. is_binary: bool, contents: Vec<u8>,} impl FileContent { fn empty() -> Self { Self { is_binary: false, contents: vec![], } } pub(crate) fn is_empty(&self) -> bool { self.contents.is_empty() }} fn file_content_for_diff(reader: &mut dyn io::Read) -> io::Result<FileContent> { // If this is a binary file, don't show the full contents. // Determine whether it's binary by whether the first 8k bytes contain a null // character; this is the same heuristic used by git as of writing: https://github.com/git/git/blob/eea0e59ffbed6e33d171ace5be13cde9faa41639/xdiff-interface.c#L192-L198 const PEEK_SIZE: usize = 8000; // TODO: currently we look at the whole file, even though for binary files we // only need to know the file size. To change that we'd have to extend all // the data backends to support getting the length. let mut contents = vec![]; reader.read_to_end(&mut contents)?; let start = &contents[..PEEK_SIZE.min(contents.len())]; Ok(FileContent { is_binary: start.contains(&b'\0'), contents, })} fn diff_content( path: &RepoPath, value: MaterializedTreeValue,) -> Result<FileContent, CommandError> { match value { MaterializedTreeValue::Absent => Ok(FileContent::empty()), MaterializedTreeValue::File { mut reader, .. } => { file_content_for_diff(&mut reader).map_err(Into::into) } MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent { // Unix file paths can't contain null bytes. is_binary: false, contents: target.into_bytes(), }), MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent { is_binary: false, contents: format!("Git submodule checked out at {}", id.hex()).into_bytes(), }), // TODO: are we sure this is never binary? MaterializedTreeValue::Conflict { id: _, contents } => Ok(FileContent { is_binary: false, contents, }), 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::File { executable, .. } => { if *executable { "executable file" } else { "regular file" } } MaterializedTreeValue::Symlink { .. } => "symlink", MaterializedTreeValue::Tree(_) => "tree", MaterializedTreeValue::GitSubmodule(_) => "Git submodule", MaterializedTreeValue::Conflict { .. } => "conflict", }} pub fn show_color_words_diff( formatter: &mut dyn Formatter, workspace_command: &WorkspaceCommandHelper, num_context_lines: usize, tree_diff: TreeDiffStream,) -> Result<(), CommandError> { formatter.push_label("diff")?; let mut diff_stream = materialized_diff_stream(workspace_command.repo().store(), tree_diff); async { while let Some((path, diff)) = diff_stream.next().await { let ui_path = workspace_command.format_file_path(&path); let (left_value, right_value) = diff?; if left_value.is_absent() { let description = basic_diff_file_type(&right_value); writeln!( formatter.labeled("header"), "Added {description} {ui_path}:" )?; let right_content = diff_content(&path, right_value)?; if right_content.is_empty() { writeln!(formatter.labeled("empty"), " (empty)")?; } else if right_content.is_binary { writeln!(formatter.labeled("binary"), " (binary)")?; } else { show_color_words_diff_hunks( &[], &right_content.contents, num_context_lines, formatter, )?; } } else if right_value.is_present() { let description = match (&left_value, &right_value) { ( MaterializedTreeValue::File { executable: left_executable, .. }, MaterializedTreeValue::File { executable: right_executable, .. }, ) => { 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()Lines 1–500 of 1048. Load more
// 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::cmp::max; use std::collections::VecDeque; use std::io; use std::ops::Range; use futures::{try_join, Stream, StreamExt}; use itertools::Itertools; use jj_lib::backend::{BackendResult, TreeValue}; use jj_lib::commit::Commit; use jj_lib::conflicts::{materialize_tree_value, MaterializedTreeValue}; use jj_lib::diff::{Diff, DiffHunk}; use jj_lib::files::DiffLine; use jj_lib::matchers::Matcher; use jj_lib::merge::MergedTreeValue; use jj_lib::merged_tree::{MergedTree, TreeDiffStream}; use jj_lib::object_id::ObjectId; use jj_lib::repo::Repo; use jj_lib::repo_path::{RepoPath, RepoPathBuf}; use jj_lib::settings::{ConfigResultExt as _, UserSettings}; use jj_lib::store::Store; use jj_lib::{diff, files, rewrite}; use pollster::FutureExt; use tracing::instrument; use unicode_width::UnicodeWidthStr as _; use crate::cli_util::WorkspaceCommandHelper; use crate::command_error::CommandError; use crate::config::CommandNameAndArgs; use crate::formatter::Formatter; use crate::merge_tools::{self, ExternalMergeTool}; use crate::text_util; use crate::ui::Ui; const DEFAULT_CONTEXT_LINES: usize = 3; #[derive(clap::Args, Clone, Debug)] #[command(next_help_heading = "Diff Formatting Options")] #[command(group(clap::ArgGroup::new("short-format").args(&["summary", "stat", "types"])))] #[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words", "tool"])))] 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, /// 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 #[arg(long)] pub tool: Option<String>, /// Number of lines of context to show #[arg(long)] context: Option<usize>, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum DiffFormat { Summary, Stat, Types, Git { context: usize }, ColorWords { context: usize }, Tool(Box<ExternalMergeTool>), } /// Returns a list of requested diff formats, which will never be empty. pub fn diff_formats_for( settings: &UserSettings, args: &DiffFormatArgs, ) -> Result<Vec<DiffFormat>, config::ConfigError> { let formats = diff_formats_from_args(settings, args)?; if formats.is_empty() { Ok(vec![default_diff_format(settings, args.context)?]) } else { Ok(formats) } } /// 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<Vec<DiffFormat>, config::ConfigError> { let mut formats = diff_formats_from_args(settings, args)?; // --patch implies default if no format other than --summary is specified if patch && matches!(formats.as_slice(), [] | [DiffFormat::Summary]) { formats.push(default_diff_format(settings, args.context)?); formats.dedup(); } Ok(formats) } fn diff_formats_from_args( settings: &UserSettings, args: &DiffFormatArgs, ) -> Result<Vec<DiffFormat>, config::ConfigError> { let mut formats = [ (args.summary, DiffFormat::Summary), (args.types, DiffFormat::Types), ( args.git, DiffFormat::Git { context: args.context.unwrap_or(DEFAULT_CONTEXT_LINES), }, ), ( args.color_words, DiffFormat::ColorWords { context: args.context.unwrap_or(DEFAULT_CONTEXT_LINES), }, ), (args.stat, DiffFormat::Stat), ] .into_iter() .filter_map(|(arg, format)| arg.then_some(format)) .collect_vec(); if let Some(name) = &args.tool { let tool = merge_tools::get_external_tool_config(settings, name)? .unwrap_or_else(|| ExternalMergeTool::with_program(name)); formats.push(DiffFormat::Tool(Box::new(tool))); } Ok(formats) } fn default_diff_format( settings: &UserSettings, num_context_lines: Option<usize>, ) -> Result<DiffFormat, config::ConfigError> { let config = settings.config(); if let Some(args) = config.get("ui.diff.tool").optional()? { // External "tool" overrides the internal "format" option. let tool = if let CommandNameAndArgs::String(name) = &args { merge_tools::get_external_tool_config(settings, name)? } else { None } .unwrap_or_else(|| ExternalMergeTool::with_diff_args(&args)); return Ok(DiffFormat::Tool(Box::new(tool))); } let name = if let Some(name) = config.get_string("ui.diff.format").optional()? { name } else if let Some(name) = config.get_string("diff.format").optional()? { name // old config name } else { "color-words".to_owned() }; match name.as_ref() { "summary" => Ok(DiffFormat::Summary), "types" => Ok(DiffFormat::Types), "git" => Ok(DiffFormat::Git { context: num_context_lines.unwrap_or(DEFAULT_CONTEXT_LINES), }), "color-words" => Ok(DiffFormat::ColorWords { context: num_context_lines.unwrap_or(DEFAULT_CONTEXT_LINES), }), "stat" => Ok(DiffFormat::Stat), _ => Err(config::ConfigError::Message(format!( "invalid diff format: {name}" ))), } } pub fn show_diff( ui: &Ui, formatter: &mut dyn Formatter, workspace_command: &WorkspaceCommandHelper, from_tree: &MergedTree, to_tree: &MergedTree, matcher: &dyn Matcher, formats: &[DiffFormat], ) -> Result<(), CommandError> { for format in formats { match format { DiffFormat::Summary => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_diff_summary(formatter, workspace_command, tree_diff)?; } DiffFormat::Stat => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_diff_stat(ui, formatter, workspace_command, tree_diff)?; } DiffFormat::Types => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_types(formatter, workspace_command, tree_diff)?; } DiffFormat::Git { context } => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_git_diff(formatter, workspace_command, *context, tree_diff)?; } DiffFormat::ColorWords { context } => { let tree_diff = from_tree.diff_stream(to_tree, matcher); show_color_words_diff(formatter, workspace_command, *context, tree_diff)?; } DiffFormat::Tool(tool) => { merge_tools::generate_diff(ui, formatter.raw(), from_tree, to_tree, matcher, tool)?; } } } Ok(()) } pub fn show_patch( ui: &Ui, formatter: &mut dyn Formatter, workspace_command: &WorkspaceCommandHelper, commit: &Commit, matcher: &dyn Matcher, formats: &[DiffFormat], ) -> Result<(), CommandError> { let parents = commit.parents(); let from_tree = rewrite::merge_commit_trees(workspace_command.repo().as_ref(), &parents)?; let to_tree = commit.tree()?; show_diff( ui, formatter, workspace_command, &from_tree, &to_tree, matcher, formats, ) } fn show_color_words_diff_hunks( left: &[u8], right: &[u8], num_context_lines: usize, formatter: &mut dyn Formatter, ) -> io::Result<()> { const SKIPPED_CONTEXT_LINE: &str = " ...\n"; let mut context = VecDeque::new(); // Have we printed "..." for any skipped context? let mut skipped_context = false; // Are the lines in `context` to be printed before the next modified line? let mut context_before = true; for diff_line in files::diff(left, right) { if diff_line.is_unmodified() { context.push_back(diff_line.clone()); let mut start_skipping_context = false; if context_before { if skipped_context && context.len() > num_context_lines { context.pop_front(); } else if !skipped_context && context.len() > num_context_lines + 1 { start_skipping_context = true; } } else if context.len() > num_context_lines * 2 + 1 { for line in context.drain(..num_context_lines) { show_color_words_diff_line(formatter, &line)?; } start_skipping_context = true; } if start_skipping_context { context.drain(..2); formatter.write_str(SKIPPED_CONTEXT_LINE)?; skipped_context = true; context_before = true; } } else { for line in &context { show_color_words_diff_line(formatter, line)?; } context.clear(); show_color_words_diff_line(formatter, &diff_line)?; context_before = false; skipped_context = false; } } if !context_before { if context.len() > num_context_lines + 1 { context.truncate(num_context_lines); skipped_context = true; context_before = true; } for line in &context { show_color_words_diff_line(formatter, line)?; } if context_before { formatter.write_str(SKIPPED_CONTEXT_LINE)?; } } // If the last diff line doesn't end with newline, add it. let no_hunk = left.is_empty() && right.is_empty(); let any_last_newline = left.ends_with(b"\n") || right.ends_with(b"\n"); if !skipped_context && !no_hunk && !any_last_newline { formatter.write_str("\n")?; } Ok(()) } fn show_color_words_diff_line( formatter: &mut dyn Formatter, diff_line: &DiffLine, ) -> io::Result<()> { if diff_line.has_left_content { write!( formatter.labeled("removed"), "{:>4}", diff_line.left_line_number )?; formatter.write_str(" ")?; } else { formatter.write_str(" ")?; } if diff_line.has_right_content { write!( formatter.labeled("added"), "{:>4}", diff_line.right_line_number )?; formatter.write_str(": ")?; } else { formatter.write_str(" : ")?; } for hunk in &diff_line.hunks { match hunk { DiffHunk::Matching(data) => { formatter.write_all(data)?; } DiffHunk::Different(data) => { let before = data[0]; let after = data[1]; if !before.is_empty() { formatter.with_label("removed", |formatter| formatter.write_all(before))?; } if !after.is_empty() { formatter.with_label("added", |formatter| formatter.write_all(after))?; } } } } Ok(()) } struct FileContent { /// false if this file is likely text; true if it is likely binary. is_binary: bool, contents: Vec<u8>, } impl FileContent { fn empty() -> Self { Self { is_binary: false, contents: vec![], } } pub(crate) fn is_empty(&self) -> bool { self.contents.is_empty() } } fn file_content_for_diff(reader: &mut dyn io::Read) -> io::Result<FileContent> { // If this is a binary file, don't show the full contents. // Determine whether it's binary by whether the first 8k bytes contain a null // character; this is the same heuristic used by git as of writing: https://github.com/git/git/blob/eea0e59ffbed6e33d171ace5be13cde9faa41639/xdiff-interface.c#L192-L198 const PEEK_SIZE: usize = 8000; // TODO: currently we look at the whole file, even though for binary files we // only need to know the file size. To change that we'd have to extend all // the data backends to support getting the length. let mut contents = vec![]; reader.read_to_end(&mut contents)?; let start = &contents[..PEEK_SIZE.min(contents.len())]; Ok(FileContent { is_binary: start.contains(&b'\0'), contents, }) } fn diff_content( path: &RepoPath, value: MaterializedTreeValue, ) -> Result<FileContent, CommandError> { match value { MaterializedTreeValue::Absent => Ok(FileContent::empty()), MaterializedTreeValue::File { mut reader, .. } => { file_content_for_diff(&mut reader).map_err(Into::into) } MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent { // Unix file paths can't contain null bytes. is_binary: false, contents: target.into_bytes(), }), MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent { is_binary: false, contents: format!("Git submodule checked out at {}", id.hex()).into_bytes(), }), // TODO: are we sure this is never binary? MaterializedTreeValue::Conflict { id: _, contents } => Ok(FileContent { is_binary: false, contents, }), 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::File { executable, .. } => { if *executable { "executable file" } else { "regular file" } } MaterializedTreeValue::Symlink { .. } => "symlink", MaterializedTreeValue::Tree(_) => "tree", MaterializedTreeValue::GitSubmodule(_) => "Git submodule", MaterializedTreeValue::Conflict { .. } => "conflict", } } pub fn show_color_words_diff( formatter: &mut dyn Formatter, workspace_command: &WorkspaceCommandHelper, num_context_lines: usize, tree_diff: TreeDiffStream, ) -> Result<(), CommandError> { formatter.push_label("diff")?; let mut diff_stream = materialized_diff_stream(workspace_command.repo().store(), tree_diff); async { while let Some((path, diff)) = diff_stream.next().await { let ui_path = workspace_command.format_file_path(&path); let (left_value, right_value) = diff?; if left_value.is_absent() { let description = basic_diff_file_type(&right_value); writeln!( formatter.labeled("header"), "Added {description} {ui_path}:" )?; let right_content = diff_content(&path, right_value)?; if right_content.is_empty() { writeln!(formatter.labeled("empty"), " (empty)")?; } else if right_content.is_binary { writeln!(formatter.labeled("binary"), " (binary)")?; } else { show_color_words_diff_hunks( &[], &right_content.contents, num_context_lines, formatter, )?; } } else if right_value.is_present() { let description = match (&left_value, &right_value) { ( MaterializedTreeValue::File { executable: left_executable, .. }, MaterializedTreeValue::File { executable: right_executable, .. }, ) => { 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()