just playing with tangled
at diffedit3 1048 lines 38 kB view raw
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::cmp::max; 16use std::collections::VecDeque; 17use std::io; 18use std::ops::Range; 19 20use futures::{try_join, Stream, StreamExt}; 21use itertools::Itertools; 22use jj_lib::backend::{BackendResult, TreeValue}; 23use jj_lib::commit::Commit; 24use jj_lib::conflicts::{materialize_tree_value, MaterializedTreeValue}; 25use jj_lib::diff::{Diff, DiffHunk}; 26use jj_lib::files::DiffLine; 27use jj_lib::matchers::Matcher; 28use jj_lib::merge::MergedTreeValue; 29use jj_lib::merged_tree::{MergedTree, TreeDiffStream}; 30use jj_lib::object_id::ObjectId; 31use jj_lib::repo::Repo; 32use jj_lib::repo_path::{RepoPath, RepoPathBuf}; 33use jj_lib::settings::{ConfigResultExt as _, UserSettings}; 34use jj_lib::store::Store; 35use jj_lib::{diff, files, rewrite}; 36use pollster::FutureExt; 37use tracing::instrument; 38use unicode_width::UnicodeWidthStr as _; 39 40use crate::cli_util::WorkspaceCommandHelper; 41use crate::command_error::CommandError; 42use crate::config::CommandNameAndArgs; 43use crate::formatter::Formatter; 44use crate::merge_tools::{self, ExternalMergeTool}; 45use crate::text_util; 46use crate::ui::Ui; 47 48const DEFAULT_CONTEXT_LINES: usize = 3; 49 50#[derive(clap::Args, Clone, Debug)] 51#[command(next_help_heading = "Diff Formatting Options")] 52#[command(group(clap::ArgGroup::new("short-format").args(&["summary", "stat", "types"])))] 53#[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words", "tool"])))] 54pub struct DiffFormatArgs { 55 /// For each path, show only whether it was modified, added, or deleted 56 #[arg(long, short)] 57 pub summary: bool, 58 /// Show a histogram of the changes 59 #[arg(long)] 60 pub stat: bool, 61 /// For each path, show only its type before and after 62 /// 63 /// The diff is shown as two letters. The first letter indicates the type 64 /// before and the second letter indicates the type after. '-' indicates 65 /// that the path was not present, 'F' represents a regular file, `L' 66 /// represents a symlink, 'C' represents a conflict, and 'G' represents a 67 /// Git submodule. 68 #[arg(long)] 69 pub types: bool, 70 /// Show a Git-format diff 71 #[arg(long)] 72 pub git: bool, 73 /// Show a word-level diff with changes indicated only by color 74 #[arg(long)] 75 pub color_words: bool, 76 /// Generate diff by external command 77 #[arg(long)] 78 pub tool: Option<String>, 79 /// Number of lines of context to show 80 #[arg(long)] 81 context: Option<usize>, 82} 83 84#[derive(Clone, Debug, Eq, PartialEq)] 85pub enum DiffFormat { 86 Summary, 87 Stat, 88 Types, 89 Git { context: usize }, 90 ColorWords { context: usize }, 91 Tool(Box<ExternalMergeTool>), 92} 93 94/// Returns a list of requested diff formats, which will never be empty. 95pub fn diff_formats_for( 96 settings: &UserSettings, 97 args: &DiffFormatArgs, 98) -> Result<Vec<DiffFormat>, config::ConfigError> { 99 let formats = diff_formats_from_args(settings, args)?; 100 if formats.is_empty() { 101 Ok(vec![default_diff_format(settings, args.context)?]) 102 } else { 103 Ok(formats) 104 } 105} 106 107/// Returns a list of requested diff formats for log-like commands, which may be 108/// empty. 109pub fn diff_formats_for_log( 110 settings: &UserSettings, 111 args: &DiffFormatArgs, 112 patch: bool, 113) -> Result<Vec<DiffFormat>, config::ConfigError> { 114 let mut formats = diff_formats_from_args(settings, args)?; 115 // --patch implies default if no format other than --summary is specified 116 if patch && matches!(formats.as_slice(), [] | [DiffFormat::Summary]) { 117 formats.push(default_diff_format(settings, args.context)?); 118 formats.dedup(); 119 } 120 Ok(formats) 121} 122 123fn diff_formats_from_args( 124 settings: &UserSettings, 125 args: &DiffFormatArgs, 126) -> Result<Vec<DiffFormat>, config::ConfigError> { 127 let mut formats = [ 128 (args.summary, DiffFormat::Summary), 129 (args.types, DiffFormat::Types), 130 ( 131 args.git, 132 DiffFormat::Git { 133 context: args.context.unwrap_or(DEFAULT_CONTEXT_LINES), 134 }, 135 ), 136 ( 137 args.color_words, 138 DiffFormat::ColorWords { 139 context: args.context.unwrap_or(DEFAULT_CONTEXT_LINES), 140 }, 141 ), 142 (args.stat, DiffFormat::Stat), 143 ] 144 .into_iter() 145 .filter_map(|(arg, format)| arg.then_some(format)) 146 .collect_vec(); 147 if let Some(name) = &args.tool { 148 let tool = merge_tools::get_external_tool_config(settings, name)? 149 .unwrap_or_else(|| ExternalMergeTool::with_program(name)); 150 formats.push(DiffFormat::Tool(Box::new(tool))); 151 } 152 Ok(formats) 153} 154 155fn default_diff_format( 156 settings: &UserSettings, 157 num_context_lines: Option<usize>, 158) -> Result<DiffFormat, config::ConfigError> { 159 let config = settings.config(); 160 if let Some(args) = config.get("ui.diff.tool").optional()? { 161 // External "tool" overrides the internal "format" option. 162 let tool = if let CommandNameAndArgs::String(name) = &args { 163 merge_tools::get_external_tool_config(settings, name)? 164 } else { 165 None 166 } 167 .unwrap_or_else(|| ExternalMergeTool::with_diff_args(&args)); 168 return Ok(DiffFormat::Tool(Box::new(tool))); 169 } 170 let name = if let Some(name) = config.get_string("ui.diff.format").optional()? { 171 name 172 } else if let Some(name) = config.get_string("diff.format").optional()? { 173 name // old config name 174 } else { 175 "color-words".to_owned() 176 }; 177 match name.as_ref() { 178 "summary" => Ok(DiffFormat::Summary), 179 "types" => Ok(DiffFormat::Types), 180 "git" => Ok(DiffFormat::Git { 181 context: num_context_lines.unwrap_or(DEFAULT_CONTEXT_LINES), 182 }), 183 "color-words" => Ok(DiffFormat::ColorWords { 184 context: num_context_lines.unwrap_or(DEFAULT_CONTEXT_LINES), 185 }), 186 "stat" => Ok(DiffFormat::Stat), 187 _ => Err(config::ConfigError::Message(format!( 188 "invalid diff format: {name}" 189 ))), 190 } 191} 192 193pub fn show_diff( 194 ui: &Ui, 195 formatter: &mut dyn Formatter, 196 workspace_command: &WorkspaceCommandHelper, 197 from_tree: &MergedTree, 198 to_tree: &MergedTree, 199 matcher: &dyn Matcher, 200 formats: &[DiffFormat], 201) -> Result<(), CommandError> { 202 for format in formats { 203 match format { 204 DiffFormat::Summary => { 205 let tree_diff = from_tree.diff_stream(to_tree, matcher); 206 show_diff_summary(formatter, workspace_command, tree_diff)?; 207 } 208 DiffFormat::Stat => { 209 let tree_diff = from_tree.diff_stream(to_tree, matcher); 210 show_diff_stat(ui, formatter, workspace_command, tree_diff)?; 211 } 212 DiffFormat::Types => { 213 let tree_diff = from_tree.diff_stream(to_tree, matcher); 214 show_types(formatter, workspace_command, tree_diff)?; 215 } 216 DiffFormat::Git { context } => { 217 let tree_diff = from_tree.diff_stream(to_tree, matcher); 218 show_git_diff(formatter, workspace_command, *context, tree_diff)?; 219 } 220 DiffFormat::ColorWords { context } => { 221 let tree_diff = from_tree.diff_stream(to_tree, matcher); 222 show_color_words_diff(formatter, workspace_command, *context, tree_diff)?; 223 } 224 DiffFormat::Tool(tool) => { 225 merge_tools::generate_diff(ui, formatter.raw(), from_tree, to_tree, matcher, tool)?; 226 } 227 } 228 } 229 Ok(()) 230} 231 232pub fn show_patch( 233 ui: &Ui, 234 formatter: &mut dyn Formatter, 235 workspace_command: &WorkspaceCommandHelper, 236 commit: &Commit, 237 matcher: &dyn Matcher, 238 formats: &[DiffFormat], 239) -> Result<(), CommandError> { 240 let parents = commit.parents(); 241 let from_tree = rewrite::merge_commit_trees(workspace_command.repo().as_ref(), &parents)?; 242 let to_tree = commit.tree()?; 243 show_diff( 244 ui, 245 formatter, 246 workspace_command, 247 &from_tree, 248 &to_tree, 249 matcher, 250 formats, 251 ) 252} 253 254fn show_color_words_diff_hunks( 255 left: &[u8], 256 right: &[u8], 257 num_context_lines: usize, 258 formatter: &mut dyn Formatter, 259) -> io::Result<()> { 260 const SKIPPED_CONTEXT_LINE: &str = " ...\n"; 261 let mut context = VecDeque::new(); 262 // Have we printed "..." for any skipped context? 263 let mut skipped_context = false; 264 // Are the lines in `context` to be printed before the next modified line? 265 let mut context_before = true; 266 for diff_line in files::diff(left, right) { 267 if diff_line.is_unmodified() { 268 context.push_back(diff_line.clone()); 269 let mut start_skipping_context = false; 270 if context_before { 271 if skipped_context && context.len() > num_context_lines { 272 context.pop_front(); 273 } else if !skipped_context && context.len() > num_context_lines + 1 { 274 start_skipping_context = true; 275 } 276 } else if context.len() > num_context_lines * 2 + 1 { 277 for line in context.drain(..num_context_lines) { 278 show_color_words_diff_line(formatter, &line)?; 279 } 280 start_skipping_context = true; 281 } 282 if start_skipping_context { 283 context.drain(..2); 284 write!(formatter, "{SKIPPED_CONTEXT_LINE}")?; 285 skipped_context = true; 286 context_before = true; 287 } 288 } else { 289 for line in &context { 290 show_color_words_diff_line(formatter, line)?; 291 } 292 context.clear(); 293 show_color_words_diff_line(formatter, &diff_line)?; 294 context_before = false; 295 skipped_context = false; 296 } 297 } 298 if !context_before { 299 if context.len() > num_context_lines + 1 { 300 context.truncate(num_context_lines); 301 skipped_context = true; 302 context_before = true; 303 } 304 for line in &context { 305 show_color_words_diff_line(formatter, line)?; 306 } 307 if context_before { 308 write!(formatter, "{SKIPPED_CONTEXT_LINE}")?; 309 } 310 } 311 312 // If the last diff line doesn't end with newline, add it. 313 let no_hunk = left.is_empty() && right.is_empty(); 314 let any_last_newline = left.ends_with(b"\n") || right.ends_with(b"\n"); 315 if !skipped_context && !no_hunk && !any_last_newline { 316 writeln!(formatter)?; 317 } 318 319 Ok(()) 320} 321 322fn show_color_words_diff_line( 323 formatter: &mut dyn Formatter, 324 diff_line: &DiffLine, 325) -> io::Result<()> { 326 if diff_line.has_left_content { 327 write!( 328 formatter.labeled("removed"), 329 "{:>4}", 330 diff_line.left_line_number 331 )?; 332 write!(formatter, " ")?; 333 } else { 334 write!(formatter, " ")?; 335 } 336 if diff_line.has_right_content { 337 write!( 338 formatter.labeled("added"), 339 "{:>4}", 340 diff_line.right_line_number 341 )?; 342 write!(formatter, ": ")?; 343 } else { 344 write!(formatter, " : ")?; 345 } 346 for hunk in &diff_line.hunks { 347 match hunk { 348 DiffHunk::Matching(data) => { 349 formatter.write_all(data)?; 350 } 351 DiffHunk::Different(data) => { 352 let before = data[0]; 353 let after = data[1]; 354 if !before.is_empty() { 355 formatter.with_label("removed", |formatter| formatter.write_all(before))?; 356 } 357 if !after.is_empty() { 358 formatter.with_label("added", |formatter| formatter.write_all(after))?; 359 } 360 } 361 } 362 } 363 364 Ok(()) 365} 366 367struct FileContent { 368 /// false if this file is likely text; true if it is likely binary. 369 is_binary: bool, 370 contents: Vec<u8>, 371} 372 373impl FileContent { 374 fn empty() -> Self { 375 Self { 376 is_binary: false, 377 contents: vec![], 378 } 379 } 380 381 pub(crate) fn is_empty(&self) -> bool { 382 self.contents.is_empty() 383 } 384} 385 386fn file_content_for_diff(reader: &mut dyn io::Read) -> io::Result<FileContent> { 387 // If this is a binary file, don't show the full contents. 388 // Determine whether it's binary by whether the first 8k bytes contain a null 389 // character; this is the same heuristic used by git as of writing: https://github.com/git/git/blob/eea0e59ffbed6e33d171ace5be13cde9faa41639/xdiff-interface.c#L192-L198 390 const PEEK_SIZE: usize = 8000; 391 // TODO: currently we look at the whole file, even though for binary files we 392 // only need to know the file size. To change that we'd have to extend all 393 // the data backends to support getting the length. 394 let mut contents = vec![]; 395 reader.read_to_end(&mut contents)?; 396 397 let start = &contents[..PEEK_SIZE.min(contents.len())]; 398 Ok(FileContent { 399 is_binary: start.contains(&b'\0'), 400 contents, 401 }) 402} 403 404fn diff_content( 405 path: &RepoPath, 406 value: MaterializedTreeValue, 407) -> Result<FileContent, CommandError> { 408 match value { 409 MaterializedTreeValue::Absent => Ok(FileContent::empty()), 410 MaterializedTreeValue::File { mut reader, .. } => { 411 file_content_for_diff(&mut reader).map_err(Into::into) 412 } 413 MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent { 414 // Unix file paths can't contain null bytes. 415 is_binary: false, 416 contents: target.into_bytes(), 417 }), 418 MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent { 419 is_binary: false, 420 contents: format!("Git submodule checked out at {}", id.hex()).into_bytes(), 421 }), 422 // TODO: are we sure this is never binary? 423 MaterializedTreeValue::Conflict { id: _, contents } => Ok(FileContent { 424 is_binary: false, 425 contents, 426 }), 427 MaterializedTreeValue::Tree(id) => { 428 panic!("Unexpected tree with id {id:?} in diff at path {path:?}"); 429 } 430 } 431} 432 433fn basic_diff_file_type(value: &MaterializedTreeValue) -> &'static str { 434 match value { 435 MaterializedTreeValue::Absent => { 436 panic!("absent path in diff"); 437 } 438 MaterializedTreeValue::File { executable, .. } => { 439 if *executable { 440 "executable file" 441 } else { 442 "regular file" 443 } 444 } 445 MaterializedTreeValue::Symlink { .. } => "symlink", 446 MaterializedTreeValue::Tree(_) => "tree", 447 MaterializedTreeValue::GitSubmodule(_) => "Git submodule", 448 MaterializedTreeValue::Conflict { .. } => "conflict", 449 } 450} 451 452pub fn show_color_words_diff( 453 formatter: &mut dyn Formatter, 454 workspace_command: &WorkspaceCommandHelper, 455 num_context_lines: usize, 456 tree_diff: TreeDiffStream, 457) -> Result<(), CommandError> { 458 formatter.push_label("diff")?; 459 let mut diff_stream = materialized_diff_stream(workspace_command.repo().store(), tree_diff); 460 async { 461 while let Some((path, diff)) = diff_stream.next().await { 462 let ui_path = workspace_command.format_file_path(&path); 463 let (left_value, right_value) = diff?; 464 if left_value.is_absent() { 465 let description = basic_diff_file_type(&right_value); 466 writeln!( 467 formatter.labeled("header"), 468 "Added {description} {ui_path}:" 469 )?; 470 let right_content = diff_content(&path, right_value)?; 471 if right_content.is_empty() { 472 writeln!(formatter.labeled("empty"), " (empty)")?; 473 } else if right_content.is_binary { 474 writeln!(formatter.labeled("binary"), " (binary)")?; 475 } else { 476 show_color_words_diff_hunks( 477 &[], 478 &right_content.contents, 479 num_context_lines, 480 formatter, 481 )?; 482 } 483 } else if right_value.is_present() { 484 let description = match (&left_value, &right_value) { 485 ( 486 MaterializedTreeValue::File { 487 executable: left_executable, 488 .. 489 }, 490 MaterializedTreeValue::File { 491 executable: right_executable, 492 .. 493 }, 494 ) => { 495 if *left_executable && *right_executable { 496 "Modified executable file".to_string() 497 } else if *left_executable { 498 "Executable file became non-executable at".to_string() 499 } else if *right_executable { 500 "Non-executable file became executable at".to_string() 501 } else { 502 "Modified regular file".to_string() 503 } 504 } 505 ( 506 MaterializedTreeValue::Conflict { .. }, 507 MaterializedTreeValue::Conflict { .. }, 508 ) => "Modified conflict in".to_string(), 509 (MaterializedTreeValue::Conflict { .. }, _) => { 510 "Resolved conflict in".to_string() 511 } 512 (_, MaterializedTreeValue::Conflict { .. }) => { 513 "Created conflict in".to_string() 514 } 515 ( 516 MaterializedTreeValue::Symlink { .. }, 517 MaterializedTreeValue::Symlink { .. }, 518 ) => "Symlink target changed at".to_string(), 519 (_, _) => { 520 let left_type = basic_diff_file_type(&left_value); 521 let right_type = basic_diff_file_type(&right_value); 522 let (first, rest) = left_type.split_at(1); 523 format!( 524 "{}{} became {} at", 525 first.to_ascii_uppercase(), 526 rest, 527 right_type 528 ) 529 } 530 }; 531 let left_content = diff_content(&path, left_value)?; 532 let right_content = diff_content(&path, right_value)?; 533 writeln!(formatter.labeled("header"), "{description} {ui_path}:")?; 534 if left_content.is_binary || right_content.is_binary { 535 writeln!(formatter.labeled("binary"), " (binary)")?; 536 } else { 537 show_color_words_diff_hunks( 538 &left_content.contents, 539 &right_content.contents, 540 num_context_lines, 541 formatter, 542 )?; 543 } 544 } else { 545 let description = basic_diff_file_type(&left_value); 546 writeln!( 547 formatter.labeled("header"), 548 "Removed {description} {ui_path}:" 549 )?; 550 let left_content = diff_content(&path, left_value)?; 551 if left_content.is_empty() { 552 writeln!(formatter.labeled("empty"), " (empty)")?; 553 } else if left_content.is_binary { 554 writeln!(formatter.labeled("binary"), " (binary)")?; 555 } else { 556 show_color_words_diff_hunks( 557 &left_content.contents, 558 &[], 559 num_context_lines, 560 formatter, 561 )?; 562 } 563 } 564 } 565 Ok::<(), CommandError>(()) 566 } 567 .block_on()?; 568 formatter.pop_label()?; 569 Ok(()) 570} 571 572struct GitDiffPart { 573 mode: String, 574 hash: String, 575 content: Vec<u8>, 576} 577 578fn git_diff_part( 579 path: &RepoPath, 580 value: MaterializedTreeValue, 581) -> Result<GitDiffPart, CommandError> { 582 let mode; 583 let hash; 584 let mut contents: Vec<u8>; 585 match value { 586 MaterializedTreeValue::Absent => { 587 panic!("Absent path {path:?} in diff should have been handled by caller"); 588 } 589 MaterializedTreeValue::File { 590 id, 591 executable, 592 mut reader, 593 } => { 594 mode = if executable { 595 "100755".to_string() 596 } else { 597 "100644".to_string() 598 }; 599 hash = id.hex(); 600 // TODO: use `file_content_for_diff` instead of showing binary 601 contents = vec![]; 602 reader.read_to_end(&mut contents)?; 603 } 604 MaterializedTreeValue::Symlink { id, target } => { 605 mode = "120000".to_string(); 606 hash = id.hex(); 607 contents = target.into_bytes(); 608 } 609 MaterializedTreeValue::GitSubmodule(id) => { 610 // TODO: What should we actually do here? 611 mode = "040000".to_string(); 612 hash = id.hex(); 613 contents = vec![]; 614 } 615 MaterializedTreeValue::Conflict { 616 id: _, 617 contents: conflict_data, 618 } => { 619 mode = "100644".to_string(); 620 hash = "0000000000".to_string(); 621 contents = conflict_data 622 } 623 MaterializedTreeValue::Tree(_) => { 624 panic!("Unexpected tree in diff at path {path:?}"); 625 } 626 } 627 let hash = hash[0..10].to_string(); 628 Ok(GitDiffPart { 629 mode, 630 hash, 631 content: contents, 632 }) 633} 634 635#[derive(PartialEq)] 636enum DiffLineType { 637 Context, 638 Removed, 639 Added, 640} 641 642struct UnifiedDiffHunk<'content> { 643 left_line_range: Range<usize>, 644 right_line_range: Range<usize>, 645 lines: Vec<(DiffLineType, &'content [u8])>, 646} 647 648fn unified_diff_hunks<'content>( 649 left_content: &'content [u8], 650 right_content: &'content [u8], 651 num_context_lines: usize, 652) -> Vec<UnifiedDiffHunk<'content>> { 653 let mut hunks = vec![]; 654 let mut current_hunk = UnifiedDiffHunk { 655 left_line_range: 1..1, 656 right_line_range: 1..1, 657 lines: vec![], 658 }; 659 let mut show_context_after = false; 660 let diff = Diff::for_tokenizer(&[left_content, right_content], &diff::find_line_ranges); 661 for hunk in diff.hunks() { 662 match hunk { 663 DiffHunk::Matching(content) => { 664 let lines = content.split_inclusive(|b| *b == b'\n').collect_vec(); 665 // Number of context lines to print after the previous non-matching hunk. 666 let num_after_lines = lines.len().min(if show_context_after { 667 num_context_lines 668 } else { 669 0 670 }); 671 current_hunk.left_line_range.end += num_after_lines; 672 current_hunk.right_line_range.end += num_after_lines; 673 for line in lines.iter().take(num_after_lines) { 674 current_hunk.lines.push((DiffLineType::Context, line)); 675 } 676 let num_skip_lines = lines 677 .len() 678 .saturating_sub(num_after_lines) 679 .saturating_sub(num_context_lines); 680 if num_skip_lines > 0 { 681 let left_start = current_hunk.left_line_range.end + num_skip_lines; 682 let right_start = current_hunk.right_line_range.end + num_skip_lines; 683 if !current_hunk.lines.is_empty() { 684 hunks.push(current_hunk); 685 } 686 current_hunk = UnifiedDiffHunk { 687 left_line_range: left_start..left_start, 688 right_line_range: right_start..right_start, 689 lines: vec![], 690 }; 691 } 692 let num_before_lines = lines.len() - num_after_lines - num_skip_lines; 693 current_hunk.left_line_range.end += num_before_lines; 694 current_hunk.right_line_range.end += num_before_lines; 695 for line in lines.iter().skip(num_after_lines + num_skip_lines) { 696 current_hunk.lines.push((DiffLineType::Context, line)); 697 } 698 } 699 DiffHunk::Different(content) => { 700 show_context_after = true; 701 let left_lines = content[0].split_inclusive(|b| *b == b'\n').collect_vec(); 702 let right_lines = content[1].split_inclusive(|b| *b == b'\n').collect_vec(); 703 if !left_lines.is_empty() { 704 current_hunk.left_line_range.end += left_lines.len(); 705 for line in left_lines { 706 current_hunk.lines.push((DiffLineType::Removed, line)); 707 } 708 } 709 if !right_lines.is_empty() { 710 current_hunk.right_line_range.end += right_lines.len(); 711 for line in right_lines { 712 current_hunk.lines.push((DiffLineType::Added, line)); 713 } 714 } 715 } 716 } 717 } 718 if !current_hunk 719 .lines 720 .iter() 721 .all(|(diff_type, _line)| *diff_type == DiffLineType::Context) 722 { 723 hunks.push(current_hunk); 724 } 725 hunks 726} 727 728fn show_unified_diff_hunks( 729 formatter: &mut dyn Formatter, 730 left_content: &[u8], 731 right_content: &[u8], 732 num_context_lines: usize, 733) -> Result<(), CommandError> { 734 for hunk in unified_diff_hunks(left_content, right_content, num_context_lines) { 735 writeln!( 736 formatter.labeled("hunk_header"), 737 "@@ -{},{} +{},{} @@", 738 hunk.left_line_range.start, 739 hunk.left_line_range.len(), 740 hunk.right_line_range.start, 741 hunk.right_line_range.len() 742 )?; 743 for (line_type, content) in hunk.lines { 744 match line_type { 745 DiffLineType::Context => { 746 formatter.with_label("context", |formatter| { 747 write!(formatter, " ")?; 748 formatter.write_all(content) 749 })?; 750 } 751 DiffLineType::Removed => { 752 formatter.with_label("removed", |formatter| { 753 write!(formatter, "-")?; 754 formatter.write_all(content) 755 })?; 756 } 757 DiffLineType::Added => { 758 formatter.with_label("added", |formatter| { 759 write!(formatter, "+")?; 760 formatter.write_all(content) 761 })?; 762 } 763 } 764 if !content.ends_with(b"\n") { 765 write!(formatter, "\n\\ No newline at end of file\n")?; 766 } 767 } 768 } 769 Ok(()) 770} 771 772fn materialized_diff_stream<'a>( 773 store: &'a Store, 774 tree_diff: TreeDiffStream<'a>, 775) -> impl Stream< 776 Item = ( 777 RepoPathBuf, 778 BackendResult<(MaterializedTreeValue, MaterializedTreeValue)>, 779 ), 780> + 'a { 781 tree_diff 782 .map(|(path, diff)| async { 783 match diff { 784 Err(err) => (path, Err(err)), 785 Ok((before, after)) => { 786 let before_future = materialize_tree_value(store, &path, before); 787 let after_future = materialize_tree_value(store, &path, after); 788 let values = try_join!(before_future, after_future); 789 (path, values) 790 } 791 } 792 }) 793 .buffered((store.concurrency() / 2).max(1)) 794} 795 796pub fn show_git_diff( 797 formatter: &mut dyn Formatter, 798 workspace_command: &WorkspaceCommandHelper, 799 num_context_lines: usize, 800 tree_diff: TreeDiffStream, 801) -> Result<(), CommandError> { 802 formatter.push_label("diff")?; 803 804 let mut diff_stream = materialized_diff_stream(workspace_command.repo().store(), tree_diff); 805 async { 806 while let Some((path, diff)) = diff_stream.next().await { 807 let path_string = path.as_internal_file_string(); 808 let (left_value, right_value) = diff?; 809 if left_value.is_absent() { 810 let right_part = git_diff_part(&path, right_value)?; 811 formatter.with_label("file_header", |formatter| { 812 writeln!(formatter, "diff --git a/{path_string} b/{path_string}")?; 813 writeln!(formatter, "new file mode {}", &right_part.mode)?; 814 writeln!(formatter, "index 0000000000..{}", &right_part.hash)?; 815 writeln!(formatter, "--- /dev/null")?; 816 writeln!(formatter, "+++ b/{path_string}") 817 })?; 818 show_unified_diff_hunks(formatter, &[], &right_part.content, num_context_lines)?; 819 } else if right_value.is_present() { 820 let left_part = git_diff_part(&path, left_value)?; 821 let right_part = git_diff_part(&path, right_value)?; 822 formatter.with_label("file_header", |formatter| { 823 writeln!(formatter, "diff --git a/{path_string} b/{path_string}")?; 824 if left_part.mode != right_part.mode { 825 writeln!(formatter, "old mode {}", &left_part.mode)?; 826 writeln!(formatter, "new mode {}", &right_part.mode)?; 827 if left_part.hash != right_part.hash { 828 writeln!(formatter, "index {}...{}", &left_part.hash, right_part.hash)?; 829 } 830 } else if left_part.hash != right_part.hash { 831 writeln!( 832 formatter, 833 "index {}...{} {}", 834 &left_part.hash, right_part.hash, left_part.mode 835 )?; 836 } 837 if left_part.content != right_part.content { 838 writeln!(formatter, "--- a/{path_string}")?; 839 writeln!(formatter, "+++ b/{path_string}")?; 840 } 841 Ok(()) 842 })?; 843 show_unified_diff_hunks( 844 formatter, 845 &left_part.content, 846 &right_part.content, 847 num_context_lines, 848 )?; 849 } else { 850 let left_part = git_diff_part(&path, left_value)?; 851 formatter.with_label("file_header", |formatter| { 852 writeln!(formatter, "diff --git a/{path_string} b/{path_string}")?; 853 writeln!(formatter, "deleted file mode {}", &left_part.mode)?; 854 writeln!(formatter, "index {}..0000000000", &left_part.hash)?; 855 writeln!(formatter, "--- a/{path_string}")?; 856 writeln!(formatter, "+++ /dev/null") 857 })?; 858 show_unified_diff_hunks(formatter, &left_part.content, &[], num_context_lines)?; 859 } 860 } 861 Ok::<(), CommandError>(()) 862 } 863 .block_on()?; 864 formatter.pop_label()?; 865 Ok(()) 866} 867 868#[instrument(skip_all)] 869pub fn show_diff_summary( 870 formatter: &mut dyn Formatter, 871 workspace_command: &WorkspaceCommandHelper, 872 mut tree_diff: TreeDiffStream, 873) -> io::Result<()> { 874 formatter.with_label("diff", |formatter| -> io::Result<()> { 875 async { 876 while let Some((repo_path, diff)) = tree_diff.next().await { 877 let (before, after) = diff.unwrap(); 878 if before.is_present() && after.is_present() { 879 writeln!( 880 formatter.labeled("modified"), 881 "M {}", 882 workspace_command.format_file_path(&repo_path) 883 )?; 884 } else if before.is_absent() { 885 writeln!( 886 formatter.labeled("added"), 887 "A {}", 888 workspace_command.format_file_path(&repo_path) 889 )?; 890 } else { 891 writeln!( 892 formatter.labeled("removed"), 893 "D {}", // `R` could be interpreted as "renamed" 894 workspace_command.format_file_path(&repo_path) 895 )?; 896 } 897 } 898 Ok(()) 899 } 900 .block_on() 901 }) 902} 903 904struct DiffStat { 905 path: String, 906 added: usize, 907 removed: usize, 908} 909 910fn get_diff_stat( 911 path: String, 912 left_content: &FileContent, 913 right_content: &FileContent, 914) -> DiffStat { 915 // TODO: this matches git's behavior, which is to count the number of newlines 916 // in the file. but that behavior seems unhelpful; no one really cares how 917 // many `0xa0` characters are in an image. 918 let hunks = unified_diff_hunks(&left_content.contents, &right_content.contents, 0); 919 let mut added = 0; 920 let mut removed = 0; 921 for hunk in hunks { 922 for (line_type, _content) in hunk.lines { 923 match line_type { 924 DiffLineType::Context => {} 925 DiffLineType::Removed => removed += 1, 926 DiffLineType::Added => added += 1, 927 } 928 } 929 } 930 DiffStat { 931 path, 932 added, 933 removed, 934 } 935} 936 937pub fn show_diff_stat( 938 ui: &Ui, 939 formatter: &mut dyn Formatter, 940 workspace_command: &WorkspaceCommandHelper, 941 tree_diff: TreeDiffStream, 942) -> Result<(), CommandError> { 943 let mut stats: Vec<DiffStat> = vec![]; 944 let mut max_path_width = 0; 945 let mut max_diffs = 0; 946 947 let mut diff_stream = materialized_diff_stream(workspace_command.repo().store(), tree_diff); 948 async { 949 while let Some((repo_path, diff)) = diff_stream.next().await { 950 let (left, right) = diff?; 951 let path = workspace_command.format_file_path(&repo_path); 952 let left_content = diff_content(&repo_path, left)?; 953 let right_content = diff_content(&repo_path, right)?; 954 max_path_width = max(max_path_width, path.width()); 955 let stat = get_diff_stat(path, &left_content, &right_content); 956 max_diffs = max(max_diffs, stat.added + stat.removed); 957 stats.push(stat); 958 } 959 Ok::<(), CommandError>(()) 960 } 961 .block_on()?; 962 963 let number_padding = max_diffs.to_string().len(); 964 // 4 characters padding for the graph 965 let available_width = 966 usize::from(ui.term_width().unwrap_or(80)).saturating_sub(4 + " | ".len() + number_padding); 967 // Always give at least a tiny bit of room 968 let available_width = max(available_width, 5); 969 let max_path_width = max_path_width.clamp(3, (0.7 * available_width as f64) as usize); 970 let max_bar_length = available_width.saturating_sub(max_path_width); 971 let factor = if max_diffs < max_bar_length { 972 1.0 973 } else { 974 max_bar_length as f64 / max_diffs as f64 975 }; 976 977 formatter.with_label("diff", |formatter| { 978 let mut total_added = 0; 979 let mut total_removed = 0; 980 let total_files = stats.len(); 981 for stat in &stats { 982 total_added += stat.added; 983 total_removed += stat.removed; 984 let bar_added = (stat.added as f64 * factor).ceil() as usize; 985 let bar_removed = (stat.removed as f64 * factor).ceil() as usize; 986 // replace start of path with ellipsis if the path is too long 987 let (path, path_width) = text_util::elide_start(&stat.path, "...", max_path_width); 988 let path_pad_width = max_path_width - path_width; 989 write!( 990 formatter, 991 "{path}{:path_pad_width$} | {:>number_padding$}{}", 992 "", // pad to max_path_width 993 stat.added + stat.removed, 994 if bar_added + bar_removed > 0 { " " } else { "" }, 995 )?; 996 write!(formatter.labeled("added"), "{}", "+".repeat(bar_added))?; 997 writeln!(formatter.labeled("removed"), "{}", "-".repeat(bar_removed))?; 998 } 999 writeln!( 1000 formatter.labeled("stat-summary"), 1001 "{} file{} changed, {} insertion{}(+), {} deletion{}(-)", 1002 total_files, 1003 if total_files == 1 { "" } else { "s" }, 1004 total_added, 1005 if total_added == 1 { "" } else { "s" }, 1006 total_removed, 1007 if total_removed == 1 { "" } else { "s" }, 1008 )?; 1009 Ok(()) 1010 })?; 1011 Ok(()) 1012} 1013 1014pub fn show_types( 1015 formatter: &mut dyn Formatter, 1016 workspace_command: &WorkspaceCommandHelper, 1017 mut tree_diff: TreeDiffStream, 1018) -> io::Result<()> { 1019 formatter.with_label("diff", |formatter| { 1020 async { 1021 while let Some((repo_path, diff)) = tree_diff.next().await { 1022 let (before, after) = diff.unwrap(); 1023 writeln!( 1024 formatter.labeled("modified"), 1025 "{}{} {}", 1026 diff_summary_char(&before), 1027 diff_summary_char(&after), 1028 workspace_command.format_file_path(&repo_path) 1029 )?; 1030 } 1031 Ok(()) 1032 } 1033 .block_on() 1034 }) 1035} 1036 1037fn diff_summary_char(value: &MergedTreeValue) -> char { 1038 match value.as_resolved() { 1039 Some(None) => '-', 1040 Some(Some(TreeValue::File { .. })) => 'F', 1041 Some(Some(TreeValue::Symlink(_))) => 'L', 1042 Some(Some(TreeValue::GitSubmodule(_))) => 'G', 1043 None => 'C', 1044 Some(Some(TreeValue::Tree(_))) | Some(Some(TreeValue::Conflict(_))) => { 1045 panic!("Unexpected {value:?} in diff") 1046 } 1047 } 1048}