just playing with tangled
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}