1use std::borrow::Borrow;
16use std::cmp::max;
17use std::io;
18use std::iter;
19use std::mem;
20use std::ops::Range;
21use std::path::Path;
22use std::path::PathBuf;
23
24use bstr::BStr;
25use bstr::BString;
26use clap_complete::ArgValueCandidates;
27use futures::StreamExt as _;
28use futures::TryStreamExt as _;
29use futures::executor::block_on_stream;
30use futures::stream::BoxStream;
31use itertools::Itertools as _;
32use jj_lib::backend::BackendError;
33use jj_lib::backend::BackendResult;
34use jj_lib::backend::CommitId;
35use jj_lib::backend::CopyRecord;
36use jj_lib::backend::TreeValue;
37use jj_lib::commit::Commit;
38use jj_lib::config::ConfigGetError;
39use jj_lib::conflicts::ConflictMarkerStyle;
40use jj_lib::conflicts::MaterializedFileValue;
41use jj_lib::conflicts::MaterializedTreeDiffEntry;
42use jj_lib::conflicts::MaterializedTreeValue;
43use jj_lib::conflicts::materialize_merge_result_to_bytes;
44use jj_lib::conflicts::materialized_diff_stream;
45use jj_lib::copies::CopiesTreeDiffEntry;
46use jj_lib::copies::CopiesTreeDiffEntryPath;
47use jj_lib::copies::CopyOperation;
48use jj_lib::copies::CopyRecords;
49use jj_lib::diff::CompareBytesExactly;
50use jj_lib::diff::CompareBytesIgnoreAllWhitespace;
51use jj_lib::diff::CompareBytesIgnoreWhitespaceAmount;
52use jj_lib::diff::Diff;
53use jj_lib::diff::DiffHunk;
54use jj_lib::diff::DiffHunkKind;
55use jj_lib::diff::find_line_ranges;
56use jj_lib::files;
57use jj_lib::files::ConflictDiffHunk;
58use jj_lib::files::DiffLineHunkSide;
59use jj_lib::files::DiffLineIterator;
60use jj_lib::files::DiffLineNumber;
61use jj_lib::matchers::Matcher;
62use jj_lib::merge::Merge;
63use jj_lib::merge::MergedTreeValue;
64use jj_lib::merged_tree::MergedTree;
65use jj_lib::object_id::ObjectId as _;
66use jj_lib::repo::Repo;
67use jj_lib::repo_path::InvalidRepoPathError;
68use jj_lib::repo_path::RepoPath;
69use jj_lib::repo_path::RepoPathUiConverter;
70use jj_lib::rewrite::rebase_to_dest_parent;
71use jj_lib::settings::UserSettings;
72use jj_lib::store::Store;
73use pollster::FutureExt as _;
74use thiserror::Error;
75use tracing::instrument;
76use unicode_width::UnicodeWidthStr as _;
77
78use crate::command_error::CommandError;
79use crate::command_error::cli_error;
80use crate::commit_templater;
81use crate::config::CommandNameAndArgs;
82use crate::formatter::Formatter;
83use crate::merge_tools;
84use crate::merge_tools::DiffGenerateError;
85use crate::merge_tools::DiffToolMode;
86use crate::merge_tools::ExternalMergeTool;
87use crate::merge_tools::generate_diff;
88use crate::merge_tools::invoke_external_diff;
89use crate::merge_tools::new_utf8_temp_dir;
90use crate::templater::TemplateRenderer;
91use crate::text_util;
92use crate::ui::Ui;
93
94#[derive(clap::Args, Clone, Debug)]
95#[command(next_help_heading = "Diff Formatting Options")]
96#[command(group(clap::ArgGroup::new("short-format").args(&["summary", "stat", "types", "name_only"])))]
97#[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words"])))]
98pub struct DiffFormatArgs {
99 #[arg(long, short)]
101 pub summary: bool,
102 #[arg(long)]
104 pub stat: bool,
105 #[arg(long)]
113 pub types: bool,
114 #[arg(long)]
119 pub name_only: bool,
120 #[arg(long)]
122 pub git: bool,
123 #[arg(long)]
125 pub color_words: bool,
126 #[arg(
131 long,
132 add = ArgValueCandidates::new(crate::complete::diff_formatters),
133 )]
134 pub tool: Option<String>,
135 #[arg(long)]
137 context: Option<usize>,
138
139 #[arg(long)] ignore_all_space: bool,
143 #[arg(long, conflicts_with = "ignore_all_space")] ignore_space_change: bool,
146}
147
148#[derive(Clone, Debug, Eq, PartialEq)]
149pub enum DiffFormat {
150 Summary,
152 Stat(Box<DiffStatOptions>),
153 Types,
154 NameOnly,
155 Git(Box<UnifiedDiffOptions>),
156 ColorWords(Box<ColorWordsDiffOptions>),
157 Tool(Box<ExternalMergeTool>),
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161enum BuiltinFormatKind {
162 Summary,
163 Stat,
164 Types,
165 NameOnly,
166 Git,
167 ColorWords,
168}
169
170impl BuiltinFormatKind {
171 const ALL_VARIANTS: &[Self] = &[
175 Self::Summary,
176 Self::Stat,
177 Self::Types,
178 Self::NameOnly,
179 Self::Git,
180 Self::ColorWords,
181 ];
182
183 fn from_name(name: &str) -> Result<Self, String> {
184 match name {
185 "summary" => Ok(Self::Summary),
186 "stat" => Ok(Self::Stat),
187 "types" => Ok(Self::Types),
188 "name-only" => Ok(Self::NameOnly),
189 "git" => Ok(Self::Git),
190 "color-words" => Ok(Self::ColorWords),
191 _ => Err(format!("Invalid builtin diff format: {name}")),
192 }
193 }
194
195 fn short_from_args(args: &DiffFormatArgs) -> Option<Self> {
196 if args.summary {
197 Some(Self::Summary)
198 } else if args.stat {
199 Some(Self::Stat)
200 } else if args.types {
201 Some(Self::Types)
202 } else if args.name_only {
203 Some(Self::NameOnly)
204 } else {
205 None
206 }
207 }
208
209 fn long_from_args(args: &DiffFormatArgs) -> Option<Self> {
210 if args.git {
211 Some(Self::Git)
212 } else if args.color_words {
213 Some(Self::ColorWords)
214 } else {
215 None
216 }
217 }
218
219 fn is_short(self) -> bool {
220 match self {
221 Self::Summary | Self::Stat | Self::Types | Self::NameOnly => true,
222 Self::Git | Self::ColorWords => false,
223 }
224 }
225
226 fn to_arg_name(self) -> &'static str {
227 match self {
228 Self::Summary => "summary",
229 Self::Stat => "stat",
230 Self::Types => "types",
231 Self::NameOnly => "name-only",
232 Self::Git => "git",
233 Self::ColorWords => "color-words",
234 }
235 }
236
237 fn to_format(
238 self,
239 settings: &UserSettings,
240 args: &DiffFormatArgs,
241 ) -> Result<DiffFormat, ConfigGetError> {
242 match self {
243 Self::Summary => Ok(DiffFormat::Summary),
244 Self::Stat => {
245 let mut options = DiffStatOptions::default();
246 options.merge_args(args);
247 Ok(DiffFormat::Stat(Box::new(options)))
248 }
249 Self::Types => Ok(DiffFormat::Types),
250 Self::NameOnly => Ok(DiffFormat::NameOnly),
251 Self::Git => {
252 let mut options = UnifiedDiffOptions::from_settings(settings)?;
253 options.merge_args(args);
254 Ok(DiffFormat::Git(Box::new(options)))
255 }
256 Self::ColorWords => {
257 let mut options = ColorWordsDiffOptions::from_settings(settings)?;
258 options.merge_args(args);
259 Ok(DiffFormat::ColorWords(Box::new(options)))
260 }
261 }
262 }
263}
264
265pub fn all_builtin_diff_format_names() -> Vec<String> {
267 BuiltinFormatKind::ALL_VARIANTS
268 .iter()
269 .map(|kind| format!(":{}", kind.to_arg_name()))
270 .collect()
271}
272
273fn diff_formatter_tool(
274 settings: &UserSettings,
275 name: &str,
276) -> Result<Option<ExternalMergeTool>, CommandError> {
277 let maybe_tool = merge_tools::get_external_tool_config(settings, name)?;
278 if let Some(tool) = &maybe_tool {
279 if tool.diff_args.is_empty() {
280 return Err(cli_error(format!(
281 "The tool `{name}` cannot be used for diff formatting"
282 )));
283 };
284 };
285 Ok(maybe_tool)
286}
287
288pub fn diff_formats_for(
290 settings: &UserSettings,
291 args: &DiffFormatArgs,
292) -> Result<Vec<DiffFormat>, CommandError> {
293 let formats = diff_formats_from_args(settings, args)?;
294 if formats.iter().all(|f| f.is_none()) {
295 Ok(vec![default_diff_format(settings, args)?])
296 } else {
297 Ok(formats.into_iter().flatten().collect())
298 }
299}
300
301pub fn diff_formats_for_log(
304 settings: &UserSettings,
305 args: &DiffFormatArgs,
306 patch: bool,
307) -> Result<Vec<DiffFormat>, CommandError> {
308 let [short_format, mut long_format] = diff_formats_from_args(settings, args)?;
309 if patch && long_format.is_none() {
311 let default_format = default_diff_format(settings, args)?;
314 if short_format.as_ref() != Some(&default_format) {
315 long_format = Some(default_format);
316 }
317 }
318 Ok([short_format, long_format].into_iter().flatten().collect())
319}
320
321fn diff_formats_from_args(
322 settings: &UserSettings,
323 args: &DiffFormatArgs,
324) -> Result<[Option<DiffFormat>; 2], CommandError> {
325 let short_kind = BuiltinFormatKind::short_from_args(args);
326 let long_kind = BuiltinFormatKind::long_from_args(args);
327 let mut short_format = short_kind
328 .map(|kind| kind.to_format(settings, args))
329 .transpose()?;
330 let mut long_format = long_kind
331 .map(|kind| kind.to_format(settings, args))
332 .transpose()?;
333 if let Some(name) = &args.tool {
334 let ensure_new = |old_kind: Option<BuiltinFormatKind>| match old_kind {
335 Some(old) => Err(cli_error(format!(
336 "--tool={name} cannot be used with --{old}",
337 old = old.to_arg_name()
338 ))),
339 None => Ok(()),
340 };
341 if let Some(name) = name.strip_prefix(':') {
342 let kind = BuiltinFormatKind::from_name(name).map_err(cli_error)?;
343 let format = kind.to_format(settings, args)?;
344 if kind.is_short() {
345 ensure_new(short_kind)?;
346 short_format = Some(format);
347 } else {
348 ensure_new(long_kind)?;
349 long_format = Some(format);
350 }
351 } else {
352 ensure_new(long_kind)?;
353 let tool = diff_formatter_tool(settings, name)?
354 .unwrap_or_else(|| ExternalMergeTool::with_program(name));
355 long_format = Some(DiffFormat::Tool(Box::new(tool)));
356 }
357 }
358 Ok([short_format, long_format])
359}
360
361fn default_diff_format(
362 settings: &UserSettings,
363 args: &DiffFormatArgs,
364) -> Result<DiffFormat, CommandError> {
365 let tool_args: CommandNameAndArgs = settings.get("ui.diff-formatter")?;
366 if let Some(name) = tool_args.as_str().and_then(|s| s.strip_prefix(':')) {
367 Ok(BuiltinFormatKind::from_name(name)
368 .map_err(|err| ConfigGetError::Type {
369 name: "ui.diff-formatter".to_owned(),
370 error: err.into(),
371 source_path: None,
372 })?
373 .to_format(settings, args)?)
374 } else {
375 let tool = if let Some(name) = tool_args.as_str() {
376 diff_formatter_tool(settings, name)?
377 } else {
378 None
379 }
380 .unwrap_or_else(|| ExternalMergeTool::with_diff_args(&tool_args));
381 Ok(DiffFormat::Tool(Box::new(tool)))
382 }
383}
384
385#[derive(Debug, Error)]
386pub enum DiffRenderError {
387 #[error("Failed to generate diff")]
388 DiffGenerate(#[source] DiffGenerateError),
389 #[error(transparent)]
390 Backend(#[from] BackendError),
391 #[error("Access denied to {path}")]
392 AccessDenied {
393 path: String,
394 source: Box<dyn std::error::Error + Send + Sync>,
395 },
396 #[error(transparent)]
397 InvalidRepoPath(#[from] InvalidRepoPathError),
398 #[error(transparent)]
399 Io(#[from] io::Error),
400}
401
402pub struct DiffRenderer<'a> {
404 repo: &'a dyn Repo,
405 path_converter: &'a RepoPathUiConverter,
406 conflict_marker_style: ConflictMarkerStyle,
407 formats: Vec<DiffFormat>,
408}
409
410impl<'a> DiffRenderer<'a> {
411 pub fn new(
412 repo: &'a dyn Repo,
413 path_converter: &'a RepoPathUiConverter,
414 conflict_marker_style: ConflictMarkerStyle,
415 formats: Vec<DiffFormat>,
416 ) -> Self {
417 Self {
418 repo,
419 path_converter,
420 conflict_marker_style,
421 formats,
422 }
423 }
424
425 #[expect(clippy::too_many_arguments)]
427 pub fn show_diff(
428 &self,
429 ui: &Ui, formatter: &mut dyn Formatter,
431 from_tree: &MergedTree,
432 to_tree: &MergedTree,
433 matcher: &dyn Matcher,
434 copy_records: &CopyRecords,
435 width: usize,
436 ) -> Result<(), DiffRenderError> {
437 formatter.with_label("diff", |formatter| {
438 self.show_diff_inner(
439 ui,
440 formatter,
441 from_tree,
442 to_tree,
443 matcher,
444 copy_records,
445 width,
446 )
447 .block_on()
448 })
449 }
450
451 #[expect(clippy::too_many_arguments)]
452 async fn show_diff_inner(
453 &self,
454 ui: &Ui,
455 formatter: &mut dyn Formatter,
456 from_tree: &MergedTree,
457 to_tree: &MergedTree,
458 matcher: &dyn Matcher,
459 copy_records: &CopyRecords,
460 width: usize,
461 ) -> Result<(), DiffRenderError> {
462 let store = self.repo.store();
463 let path_converter = self.path_converter;
464 for format in &self.formats {
465 match format {
466 DiffFormat::Summary => {
467 let tree_diff =
468 from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
469 show_diff_summary(formatter, tree_diff, path_converter).await?;
470 }
471 DiffFormat::Stat(options) => {
472 let tree_diff =
473 from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
474 let stats =
475 DiffStats::calculate(store, tree_diff, options, self.conflict_marker_style)
476 .block_on()?;
477 show_diff_stats(formatter, &stats, path_converter, width)?;
478 }
479 DiffFormat::Types => {
480 let tree_diff =
481 from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
482 show_types(formatter, tree_diff, path_converter).await?;
483 }
484 DiffFormat::NameOnly => {
485 let tree_diff =
486 from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
487 show_names(formatter, tree_diff, path_converter).await?;
488 }
489 DiffFormat::Git(options) => {
490 let tree_diff =
491 from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
492 show_git_diff(
493 formatter,
494 store,
495 tree_diff,
496 options,
497 self.conflict_marker_style,
498 )
499 .await?;
500 }
501 DiffFormat::ColorWords(options) => {
502 let tree_diff =
503 from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
504 show_color_words_diff(
505 formatter,
506 store,
507 tree_diff,
508 path_converter,
509 options,
510 self.conflict_marker_style,
511 )
512 .await?;
513 }
514 DiffFormat::Tool(tool) => {
515 match tool.diff_invocation_mode {
516 DiffToolMode::FileByFile => {
517 let tree_diff =
518 from_tree.diff_stream_with_copies(to_tree, matcher, copy_records);
519 show_file_by_file_diff(
520 ui,
521 formatter,
522 store,
523 tree_diff,
524 path_converter,
525 tool,
526 self.conflict_marker_style,
527 )
528 .await
529 }
530 DiffToolMode::Dir => {
531 let mut writer = formatter.raw()?;
532 generate_diff(
533 ui,
534 writer.as_mut(),
535 from_tree,
536 to_tree,
537 matcher,
538 tool,
539 self.conflict_marker_style,
540 )
541 .map_err(DiffRenderError::DiffGenerate)
542 }
543 }?;
544 }
545 }
546 }
547 Ok(())
548 }
549
550 pub fn show_inter_diff(
554 &self,
555 ui: &Ui,
556 formatter: &mut dyn Formatter,
557 from_commits: &[Commit],
558 to_commit: &Commit,
559 matcher: &dyn Matcher,
560 width: usize,
561 ) -> Result<(), DiffRenderError> {
562 let from_tree = rebase_to_dest_parent(self.repo, from_commits, to_commit)?;
563 let to_tree = to_commit.tree()?;
564 let copy_records = CopyRecords::default(); self.show_diff(
566 ui,
567 formatter,
568 &from_tree,
569 &to_tree,
570 matcher,
571 ©_records,
572 width,
573 )
574 }
575
576 pub fn show_patch(
578 &self,
579 ui: &Ui,
580 formatter: &mut dyn Formatter,
581 commit: &Commit,
582 matcher: &dyn Matcher,
583 width: usize,
584 ) -> Result<(), DiffRenderError> {
585 let from_tree = commit.parent_tree(self.repo)?;
586 let to_tree = commit.tree()?;
587 let mut copy_records = CopyRecords::default();
588 for parent_id in commit.parent_ids() {
589 let records = get_copy_records(self.repo.store(), parent_id, commit.id(), matcher)?;
590 copy_records.add_records(records)?;
591 }
592 self.show_diff(
593 ui,
594 formatter,
595 &from_tree,
596 &to_tree,
597 matcher,
598 ©_records,
599 width,
600 )
601 }
602}
603
604pub fn get_copy_records<'a>(
605 store: &'a Store,
606 root: &CommitId,
607 head: &CommitId,
608 matcher: &'a dyn Matcher,
609) -> BackendResult<impl Iterator<Item = BackendResult<CopyRecord>> + use<'a>> {
610 let stream = store.get_copy_records(None, root, head)?;
612 Ok(block_on_stream(stream).filter_ok(|record| matcher.matches(&record.target)))
614}
615
616#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
618#[serde(rename_all = "kebab-case")]
619pub enum ConflictDiffMethod {
620 #[default]
622 Materialize,
623 Pair,
625}
626
627#[derive(Clone, Debug, Default, Eq, PartialEq)]
628pub struct LineDiffOptions {
629 pub compare_mode: LineCompareMode,
631 }
633
634impl LineDiffOptions {
635 fn merge_args(&mut self, args: &DiffFormatArgs) {
636 self.compare_mode = if args.ignore_all_space {
637 LineCompareMode::IgnoreAllSpace
638 } else if args.ignore_space_change {
639 LineCompareMode::IgnoreSpaceChange
640 } else {
641 LineCompareMode::Exact
642 };
643 }
644}
645
646#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
647pub enum LineCompareMode {
648 #[default]
650 Exact,
651 IgnoreAllSpace,
653 IgnoreSpaceChange,
655}
656
657fn diff_by_line<'input, T: AsRef<[u8]> + ?Sized + 'input>(
658 inputs: impl IntoIterator<Item = &'input T>,
659 options: &LineDiffOptions,
660) -> Diff<'input> {
661 match options.compare_mode {
666 LineCompareMode::Exact => {
667 Diff::for_tokenizer(inputs, find_line_ranges, CompareBytesExactly)
668 }
669 LineCompareMode::IgnoreAllSpace => {
670 Diff::for_tokenizer(inputs, find_line_ranges, CompareBytesIgnoreAllWhitespace)
671 }
672 LineCompareMode::IgnoreSpaceChange => {
673 Diff::for_tokenizer(inputs, find_line_ranges, CompareBytesIgnoreWhitespaceAmount)
674 }
675 }
676}
677
678#[derive(Clone, Debug, Eq, PartialEq)]
679pub struct ColorWordsDiffOptions {
680 pub conflict: ConflictDiffMethod,
682 pub context: usize,
684 pub line_diff: LineDiffOptions,
686 pub max_inline_alternation: Option<usize>,
688}
689
690impl ColorWordsDiffOptions {
691 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
692 let max_inline_alternation = {
693 let name = "diff.color-words.max-inline-alternation";
694 match settings.get_int(name)? {
695 -1 => None, n => Some(usize::try_from(n).map_err(|err| ConfigGetError::Type {
697 name: name.to_owned(),
698 error: err.into(),
699 source_path: None,
700 })?),
701 }
702 };
703 Ok(Self {
704 conflict: settings.get("diff.color-words.conflict")?,
705 context: settings.get("diff.color-words.context")?,
706 line_diff: LineDiffOptions::default(),
707 max_inline_alternation,
708 })
709 }
710
711 fn merge_args(&mut self, args: &DiffFormatArgs) {
712 if let Some(context) = args.context {
713 self.context = context;
714 }
715 self.line_diff.merge_args(args);
716 }
717}
718
719fn show_color_words_diff_hunks(
720 formatter: &mut dyn Formatter,
721 [lefts, rights]: [&Merge<BString>; 2],
722 options: &ColorWordsDiffOptions,
723 conflict_marker_style: ConflictMarkerStyle,
724) -> io::Result<()> {
725 let line_number = DiffLineNumber { left: 1, right: 1 };
726 let labels = ["removed", "added"];
727 if let (Some(left), Some(right)) = (lefts.as_resolved(), rights.as_resolved()) {
728 let contents = [left, right].map(BStr::new);
729 show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
730 return Ok(());
731 }
732 match options.conflict {
733 ConflictDiffMethod::Materialize => {
734 let left = materialize_merge_result_to_bytes(lefts, conflict_marker_style);
735 let right = materialize_merge_result_to_bytes(rights, conflict_marker_style);
736 let contents = [&left, &right].map(BStr::new);
737 show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
738 }
739 ConflictDiffMethod::Pair => {
740 let lefts = files::merge(lefts);
741 let rights = files::merge(rights);
742 let contents = [&lefts, &rights];
743 show_color_words_conflict_hunks(formatter, contents, line_number, labels, options)?;
744 }
745 }
746 Ok(())
747}
748
749fn show_color_words_conflict_hunks(
750 formatter: &mut dyn Formatter,
751 [lefts, rights]: [&Merge<BString>; 2],
752 mut line_number: DiffLineNumber,
753 labels: [&str; 2],
754 options: &ColorWordsDiffOptions,
755) -> io::Result<DiffLineNumber> {
756 let num_lefts = lefts.as_slice().len();
757 let line_diff = diff_by_line(lefts.iter().chain(rights.iter()), &options.line_diff);
758 let mut contexts: Vec<[&BStr; 2]> = Vec::new();
762 let mut emitted = false;
763
764 for hunk in files::conflict_diff_hunks(line_diff.hunks(), num_lefts) {
765 match hunk.kind {
766 DiffHunkKind::Matching => {
769 contexts.push([hunk.lefts.first(), hunk.rights.first()]);
770 }
771 DiffHunkKind::Different => {
772 let num_after = if emitted { options.context } else { 0 };
773 let num_before = options.context;
774 line_number = show_color_words_context_lines(
775 formatter,
776 &contexts,
777 line_number,
778 labels,
779 options,
780 num_after,
781 num_before,
782 )?;
783 contexts.clear();
784 emitted = true;
785 line_number = if let (Some(&left), Some(&right)) =
786 (hunk.lefts.as_resolved(), hunk.rights.as_resolved())
787 {
788 let contents = [left, right];
789 show_color_words_diff_lines(formatter, contents, line_number, labels, options)?
790 } else {
791 show_color_words_unresolved_hunk(
792 formatter,
793 &hunk,
794 line_number,
795 labels,
796 options,
797 )?
798 }
799 }
800 }
801 }
802
803 let num_after = if emitted { options.context } else { 0 };
804 let num_before = 0;
805 show_color_words_context_lines(
806 formatter,
807 &contexts,
808 line_number,
809 labels,
810 options,
811 num_after,
812 num_before,
813 )
814}
815
816fn show_color_words_unresolved_hunk(
817 formatter: &mut dyn Formatter,
818 hunk: &ConflictDiffHunk,
819 line_number: DiffLineNumber,
820 [label1, label2]: [&str; 2],
821 options: &ColorWordsDiffOptions,
822) -> io::Result<DiffLineNumber> {
823 let hunk_desc = if hunk.lefts.is_resolved() {
824 "Created conflict"
825 } else if hunk.rights.is_resolved() {
826 "Resolved conflict"
827 } else {
828 "Modified conflict"
829 };
830 writeln!(formatter.labeled("hunk_header"), "<<<<<<< {hunk_desc}")?;
831
832 let num_terms = max(hunk.lefts.as_slice().len(), hunk.rights.as_slice().len());
837 let lefts = hunk.lefts.iter().enumerate();
838 let rights = hunk.rights.iter().enumerate();
839 let padded = iter::zip(
840 lefts.chain(iter::repeat((0, hunk.lefts.first()))),
841 rights.chain(iter::repeat((0, hunk.rights.first()))),
842 )
843 .take(num_terms);
844 let mut max_line_number = line_number;
845 for (i, ((left_index, &left_content), (right_index, &right_content))) in padded.enumerate() {
846 let positive = i % 2 == 0;
847 writeln!(
848 formatter.labeled("hunk_header"),
849 "{sep} left {left_name} #{left_index} to right {right_name} #{right_index}",
850 sep = if positive { "+++++++" } else { "-------" },
851 left_name = if left_index % 2 == 0 { "side" } else { "base" },
853 left_index = left_index / 2 + 1,
854 right_name = if right_index % 2 == 0 { "side" } else { "base" },
855 right_index = right_index / 2 + 1,
856 )?;
857 let contents = [left_content, right_content];
858 let labels = match positive {
859 true => [label1, label2],
860 false => [label2, label1],
861 };
862 let new_line_number =
864 show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
865 max_line_number.left = max(max_line_number.left, new_line_number.left);
869 max_line_number.right = max(max_line_number.right, new_line_number.right);
870 }
871
872 writeln!(formatter.labeled("hunk_header"), ">>>>>>> Conflict ends")?;
873 Ok(max_line_number)
874}
875
876fn show_color_words_resolved_hunks(
877 formatter: &mut dyn Formatter,
878 contents: [&BStr; 2],
879 mut line_number: DiffLineNumber,
880 labels: [&str; 2],
881 options: &ColorWordsDiffOptions,
882) -> io::Result<DiffLineNumber> {
883 let line_diff = diff_by_line(contents, &options.line_diff);
884 let mut context: Option<[&BStr; 2]> = None;
886 let mut emitted = false;
887
888 for hunk in line_diff.hunks() {
889 let hunk_contents: [&BStr; 2] = hunk.contents[..].try_into().unwrap();
890 match hunk.kind {
891 DiffHunkKind::Matching => {
892 context = Some(hunk_contents);
893 }
894 DiffHunkKind::Different => {
895 let num_after = if emitted { options.context } else { 0 };
896 let num_before = options.context;
897 line_number = show_color_words_context_lines(
898 formatter,
899 context.as_slice(),
900 line_number,
901 labels,
902 options,
903 num_after,
904 num_before,
905 )?;
906 context = None;
907 emitted = true;
908 line_number = show_color_words_diff_lines(
909 formatter,
910 hunk_contents,
911 line_number,
912 labels,
913 options,
914 )?;
915 }
916 }
917 }
918
919 let num_after = if emitted { options.context } else { 0 };
920 let num_before = 0;
921 show_color_words_context_lines(
922 formatter,
923 context.as_slice(),
924 line_number,
925 labels,
926 options,
927 num_after,
928 num_before,
929 )
930}
931
932fn show_color_words_context_lines(
934 formatter: &mut dyn Formatter,
935 contexts: &[[&BStr; 2]],
936 mut line_number: DiffLineNumber,
937 labels: [&str; 2],
938 options: &ColorWordsDiffOptions,
939 num_after: usize,
940 num_before: usize,
941) -> io::Result<DiffLineNumber> {
942 const SKIPPED_CONTEXT_LINE: &str = " ...\n";
943 let extract = |side: usize| -> (Vec<&[u8]>, Vec<&[u8]>, u32) {
944 let mut lines = contexts
945 .iter()
946 .flat_map(|contents| contents[side].split_inclusive(|b| *b == b'\n'))
947 .fuse();
948 let after_lines = lines.by_ref().take(num_after).collect();
949 let before_lines = lines.by_ref().rev().take(num_before + 1).collect();
950 let num_skipped: u32 = lines.count().try_into().unwrap();
951 (after_lines, before_lines, num_skipped)
952 };
953 let show = |formatter: &mut dyn Formatter,
954 [left_lines, right_lines]: [&[&[u8]]; 2],
955 mut line_number: DiffLineNumber| {
956 if left_lines == right_lines {
957 for line in left_lines {
958 show_color_words_line_number(
959 formatter,
960 [Some(line_number.left), Some(line_number.right)],
961 labels,
962 )?;
963 show_color_words_inline_hunks(
964 formatter,
965 &[(DiffLineHunkSide::Both, line.as_ref())],
966 labels,
967 )?;
968 line_number.left += 1;
969 line_number.right += 1;
970 }
971 Ok(line_number)
972 } else {
973 let left = left_lines.concat();
974 let right = right_lines.concat();
975 show_color_words_diff_lines(
976 formatter,
977 [&left, &right].map(BStr::new),
978 line_number,
979 labels,
980 options,
981 )
982 }
983 };
984
985 let (left_after, mut left_before, num_left_skipped) = extract(0);
986 let (right_after, mut right_before, num_right_skipped) = extract(1);
987 line_number = show(formatter, [&left_after, &right_after], line_number)?;
988 if num_left_skipped > 0 || num_right_skipped > 0 {
989 write!(formatter, "{SKIPPED_CONTEXT_LINE}")?;
990 line_number.left += num_left_skipped;
991 line_number.right += num_right_skipped;
992 if left_before.len() > num_before {
993 left_before.pop();
994 line_number.left += 1;
995 }
996 if right_before.len() > num_before {
997 right_before.pop();
998 line_number.right += 1;
999 }
1000 }
1001 left_before.reverse();
1002 right_before.reverse();
1003 line_number = show(formatter, [&left_before, &right_before], line_number)?;
1004 Ok(line_number)
1005}
1006
1007fn show_color_words_diff_lines(
1008 formatter: &mut dyn Formatter,
1009 contents: [&BStr; 2],
1010 mut line_number: DiffLineNumber,
1011 labels: [&str; 2],
1012 options: &ColorWordsDiffOptions,
1013) -> io::Result<DiffLineNumber> {
1014 let word_diff_hunks = Diff::by_word(contents).hunks().collect_vec();
1015 let can_inline = match options.max_inline_alternation {
1016 None => true, Some(0) => false, Some(max_num) => {
1019 let groups = split_diff_hunks_by_matching_newline(&word_diff_hunks);
1020 groups.map(count_diff_alternation).max().unwrap_or(0) <= max_num
1021 }
1022 };
1023 if can_inline {
1024 let mut diff_line_iter =
1025 DiffLineIterator::with_line_number(word_diff_hunks.iter(), line_number);
1026 for diff_line in diff_line_iter.by_ref() {
1027 show_color_words_line_number(
1028 formatter,
1029 [
1030 diff_line
1031 .has_left_content()
1032 .then_some(diff_line.line_number.left),
1033 diff_line
1034 .has_right_content()
1035 .then_some(diff_line.line_number.right),
1036 ],
1037 labels,
1038 )?;
1039 show_color_words_inline_hunks(formatter, &diff_line.hunks, labels)?;
1040 }
1041 line_number = diff_line_iter.next_line_number();
1042 } else {
1043 let [left_lines, right_lines] = unzip_diff_hunks_to_lines(&word_diff_hunks);
1044 let [left_label, right_label] = labels;
1045 for tokens in &left_lines {
1046 show_color_words_line_number(formatter, [Some(line_number.left), None], labels)?;
1047 show_color_words_single_sided_line(formatter, tokens, left_label)?;
1048 line_number.left += 1;
1049 }
1050 for tokens in &right_lines {
1051 show_color_words_line_number(formatter, [None, Some(line_number.right)], labels)?;
1052 show_color_words_single_sided_line(formatter, tokens, right_label)?;
1053 line_number.right += 1;
1054 }
1055 }
1056 Ok(line_number)
1057}
1058
1059fn show_color_words_line_number(
1060 formatter: &mut dyn Formatter,
1061 [left_line_number, right_line_number]: [Option<u32>; 2],
1062 [left_label, right_label]: [&str; 2],
1063) -> io::Result<()> {
1064 if let Some(line_number) = left_line_number {
1065 formatter.with_label(left_label, |formatter| {
1066 write!(formatter.labeled("line_number"), "{line_number:>4}")
1067 })?;
1068 write!(formatter, " ")?;
1069 } else {
1070 write!(formatter, " ")?;
1071 }
1072 if let Some(line_number) = right_line_number {
1073 formatter.with_label(right_label, |formatter| {
1074 write!(formatter.labeled("line_number"), "{line_number:>4}",)
1075 })?;
1076 write!(formatter, ": ")?;
1077 } else {
1078 write!(formatter, " : ")?;
1079 }
1080 Ok(())
1081}
1082
1083fn show_color_words_inline_hunks(
1085 formatter: &mut dyn Formatter,
1086 line_hunks: &[(DiffLineHunkSide, &BStr)],
1087 [left_label, right_label]: [&str; 2],
1088) -> io::Result<()> {
1089 for (side, data) in line_hunks {
1090 let label = match side {
1091 DiffLineHunkSide::Both => None,
1092 DiffLineHunkSide::Left => Some(left_label),
1093 DiffLineHunkSide::Right => Some(right_label),
1094 };
1095 if let Some(label) = label {
1096 formatter.with_label(label, |formatter| {
1097 formatter.with_label("token", |formatter| formatter.write_all(data))
1098 })?;
1099 } else {
1100 formatter.write_all(data)?;
1101 }
1102 }
1103 let (_, data) = line_hunks.last().expect("diff line must not be empty");
1104 if !data.ends_with(b"\n") {
1105 writeln!(formatter)?;
1106 };
1107 Ok(())
1108}
1109
1110fn show_color_words_single_sided_line(
1112 formatter: &mut dyn Formatter,
1113 tokens: &[(DiffTokenType, &[u8])],
1114 label: &str,
1115) -> io::Result<()> {
1116 formatter.with_label(label, |formatter| show_diff_line_tokens(formatter, tokens))?;
1117 let (_, data) = tokens.last().expect("diff line must not be empty");
1118 if !data.ends_with(b"\n") {
1119 writeln!(formatter)?;
1120 };
1121 Ok(())
1122}
1123
1124fn count_diff_alternation(diff_hunks: &[DiffHunk]) -> usize {
1137 diff_hunks
1138 .iter()
1139 .filter_map(|hunk| match hunk.kind {
1140 DiffHunkKind::Matching => None,
1141 DiffHunkKind::Different => Some(&hunk.contents),
1142 })
1143 .flat_map(|contents| contents.iter().positions(|content| !content.is_empty()))
1145 .dedup()
1147 .count()
1148}
1149
1150fn split_diff_hunks_by_matching_newline<'a, 'b>(
1152 diff_hunks: &'a [DiffHunk<'b>],
1153) -> impl Iterator<Item = &'a [DiffHunk<'b>]> {
1154 diff_hunks.split_inclusive(|hunk| match hunk.kind {
1155 DiffHunkKind::Matching => hunk.contents.iter().all(|content| content.contains(&b'\n')),
1156 DiffHunkKind::Different => false,
1157 })
1158}
1159
1160struct FileContent<T> {
1161 is_binary: bool,
1163 contents: T,
1164}
1165
1166impl FileContent<Merge<BString>> {
1167 fn is_empty(&self) -> bool {
1168 self.contents.as_resolved().is_some_and(|c| c.is_empty())
1169 }
1170}
1171
1172fn file_content_for_diff<T>(
1173 path: &RepoPath,
1174 file: &mut MaterializedFileValue,
1175 map_resolved: impl FnOnce(BString) -> T,
1176) -> BackendResult<FileContent<T>> {
1177 const PEEK_SIZE: usize = 8000;
1181 let contents = BString::new(file.read_all(path).block_on()?);
1185 let start = &contents[..PEEK_SIZE.min(contents.len())];
1186 Ok(FileContent {
1187 is_binary: start.contains(&b'\0'),
1188 contents: map_resolved(contents),
1189 })
1190}
1191
1192fn diff_content(
1193 path: &RepoPath,
1194 value: MaterializedTreeValue,
1195 conflict_marker_style: ConflictMarkerStyle,
1196) -> BackendResult<FileContent<BString>> {
1197 diff_content_with(
1198 path,
1199 value,
1200 |content| content,
1201 |contents| materialize_merge_result_to_bytes(&contents, conflict_marker_style),
1202 )
1203}
1204
1205fn diff_content_as_merge(
1206 path: &RepoPath,
1207 value: MaterializedTreeValue,
1208) -> BackendResult<FileContent<Merge<BString>>> {
1209 diff_content_with(path, value, Merge::resolved, |contents| contents)
1210}
1211
1212fn diff_content_with<T>(
1213 path: &RepoPath,
1214 value: MaterializedTreeValue,
1215 map_resolved: impl FnOnce(BString) -> T,
1216 map_conflict: impl FnOnce(Merge<BString>) -> T,
1217) -> BackendResult<FileContent<T>> {
1218 match value {
1219 MaterializedTreeValue::Absent => Ok(FileContent {
1220 is_binary: false,
1221 contents: map_resolved(BString::default()),
1222 }),
1223 MaterializedTreeValue::AccessDenied(err) => Ok(FileContent {
1224 is_binary: false,
1225 contents: map_resolved(format!("Access denied: {err}").into()),
1226 }),
1227 MaterializedTreeValue::File(mut file) => {
1228 file_content_for_diff(path, &mut file, map_resolved)
1229 }
1230 MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent {
1231 is_binary: false,
1233 contents: map_resolved(target.into()),
1234 }),
1235 MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent {
1236 is_binary: false,
1237 contents: map_resolved(format!("Git submodule checked out at {id}").into()),
1238 }),
1239 MaterializedTreeValue::FileConflict(file) => Ok(FileContent {
1241 is_binary: false,
1242 contents: map_conflict(file.contents),
1243 }),
1244 MaterializedTreeValue::OtherConflict { id } => Ok(FileContent {
1245 is_binary: false,
1246 contents: map_resolved(id.describe().into()),
1247 }),
1248 MaterializedTreeValue::Tree(id) => {
1249 panic!("Unexpected tree with id {id:?} in diff at path {path:?}");
1250 }
1251 }
1252}
1253
1254fn basic_diff_file_type(value: &MaterializedTreeValue) -> &'static str {
1255 match value {
1256 MaterializedTreeValue::Absent => {
1257 panic!("absent path in diff");
1258 }
1259 MaterializedTreeValue::AccessDenied(_) => "access denied",
1260 MaterializedTreeValue::File(file) => {
1261 if file.executable {
1262 "executable file"
1263 } else {
1264 "regular file"
1265 }
1266 }
1267 MaterializedTreeValue::Symlink { .. } => "symlink",
1268 MaterializedTreeValue::Tree(_) => "tree",
1269 MaterializedTreeValue::GitSubmodule(_) => "Git submodule",
1270 MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. } => {
1271 "conflict"
1272 }
1273 }
1274}
1275
1276pub async fn show_color_words_diff(
1277 formatter: &mut dyn Formatter,
1278 store: &Store,
1279 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1280 path_converter: &RepoPathUiConverter,
1281 options: &ColorWordsDiffOptions,
1282 conflict_marker_style: ConflictMarkerStyle,
1283) -> Result<(), DiffRenderError> {
1284 let empty_content = || Merge::resolved(BString::default());
1285 let mut diff_stream = materialized_diff_stream(store, tree_diff);
1286 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1287 let left_path = path.source();
1288 let right_path = path.target();
1289 let left_ui_path = path_converter.format_file_path(left_path);
1290 let right_ui_path = path_converter.format_file_path(right_path);
1291 let (left_value, right_value) = values?;
1292
1293 match (&left_value, &right_value) {
1294 (MaterializedTreeValue::AccessDenied(source), _) => {
1295 write!(
1296 formatter.labeled("access-denied"),
1297 "Access denied to {left_ui_path}:"
1298 )?;
1299 writeln!(formatter, " {source}")?;
1300 continue;
1301 }
1302 (_, MaterializedTreeValue::AccessDenied(source)) => {
1303 write!(
1304 formatter.labeled("access-denied"),
1305 "Access denied to {right_ui_path}:"
1306 )?;
1307 writeln!(formatter, " {source}")?;
1308 continue;
1309 }
1310 _ => {}
1311 }
1312 if left_value.is_absent() {
1313 let description = basic_diff_file_type(&right_value);
1314 writeln!(
1315 formatter.labeled("header"),
1316 "Added {description} {right_ui_path}:"
1317 )?;
1318 let right_content = diff_content_as_merge(right_path, right_value)?;
1319 if right_content.is_empty() {
1320 writeln!(formatter.labeled("empty"), " (empty)")?;
1321 } else if right_content.is_binary {
1322 writeln!(formatter.labeled("binary"), " (binary)")?;
1323 } else {
1324 show_color_words_diff_hunks(
1325 formatter,
1326 [&empty_content(), &right_content.contents],
1327 options,
1328 conflict_marker_style,
1329 )?;
1330 }
1331 } else if right_value.is_present() {
1332 let description = match (&left_value, &right_value) {
1333 (MaterializedTreeValue::File(left), MaterializedTreeValue::File(right)) => {
1334 if left.executable && right.executable {
1335 "Modified executable file".to_string()
1336 } else if left.executable {
1337 "Executable file became non-executable at".to_string()
1338 } else if right.executable {
1339 "Non-executable file became executable at".to_string()
1340 } else {
1341 "Modified regular file".to_string()
1342 }
1343 }
1344 (
1345 MaterializedTreeValue::FileConflict(_)
1346 | MaterializedTreeValue::OtherConflict { .. },
1347 MaterializedTreeValue::FileConflict(_)
1348 | MaterializedTreeValue::OtherConflict { .. },
1349 ) => "Modified conflict in".to_string(),
1350 (
1351 MaterializedTreeValue::FileConflict(_)
1352 | MaterializedTreeValue::OtherConflict { .. },
1353 _,
1354 ) => "Resolved conflict in".to_string(),
1355 (
1356 _,
1357 MaterializedTreeValue::FileConflict(_)
1358 | MaterializedTreeValue::OtherConflict { .. },
1359 ) => "Created conflict in".to_string(),
1360 (MaterializedTreeValue::Symlink { .. }, MaterializedTreeValue::Symlink { .. }) => {
1361 "Symlink target changed at".to_string()
1362 }
1363 (_, _) => {
1364 let left_type = basic_diff_file_type(&left_value);
1365 let right_type = basic_diff_file_type(&right_value);
1366 let (first, rest) = left_type.split_at(1);
1367 format!(
1368 "{}{} became {} at",
1369 first.to_ascii_uppercase(),
1370 rest,
1371 right_type
1372 )
1373 }
1374 };
1375 let left_content = diff_content_as_merge(left_path, left_value)?;
1376 let right_content = diff_content_as_merge(right_path, right_value)?;
1377 if left_path == right_path {
1378 writeln!(
1379 formatter.labeled("header"),
1380 "{description} {right_ui_path}:"
1381 )?;
1382 } else {
1383 writeln!(
1384 formatter.labeled("header"),
1385 "{description} {right_ui_path} ({left_ui_path} => {right_ui_path}):"
1386 )?;
1387 }
1388 if left_content.is_binary || right_content.is_binary {
1389 writeln!(formatter.labeled("binary"), " (binary)")?;
1390 } else if left_content.contents != right_content.contents {
1391 show_color_words_diff_hunks(
1392 formatter,
1393 [&left_content.contents, &right_content.contents],
1394 options,
1395 conflict_marker_style,
1396 )?;
1397 }
1398 } else {
1399 let description = basic_diff_file_type(&left_value);
1400 writeln!(
1401 formatter.labeled("header"),
1402 "Removed {description} {right_ui_path}:"
1403 )?;
1404 let left_content = diff_content_as_merge(left_path, left_value)?;
1405 if left_content.is_empty() {
1406 writeln!(formatter.labeled("empty"), " (empty)")?;
1407 } else if left_content.is_binary {
1408 writeln!(formatter.labeled("binary"), " (binary)")?;
1409 } else {
1410 show_color_words_diff_hunks(
1411 formatter,
1412 [&left_content.contents, &empty_content()],
1413 options,
1414 conflict_marker_style,
1415 )?;
1416 }
1417 }
1418 }
1419 Ok(())
1420}
1421
1422pub async fn show_file_by_file_diff(
1423 ui: &Ui,
1424 formatter: &mut dyn Formatter,
1425 store: &Store,
1426 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1427 path_converter: &RepoPathUiConverter,
1428 tool: &ExternalMergeTool,
1429 conflict_marker_style: ConflictMarkerStyle,
1430) -> Result<(), DiffRenderError> {
1431 let create_file = |path: &RepoPath,
1432 wc_dir: &Path,
1433 value: MaterializedTreeValue|
1434 -> Result<PathBuf, DiffRenderError> {
1435 let fs_path = path.to_fs_path(wc_dir)?;
1436 std::fs::create_dir_all(fs_path.parent().unwrap())?;
1437 let content = diff_content(path, value, conflict_marker_style)?;
1438 std::fs::write(&fs_path, content.contents)?;
1439 Ok(fs_path)
1440 };
1441
1442 let temp_dir = new_utf8_temp_dir("jj-diff-")?;
1443 let left_wc_dir = temp_dir.path().join("left");
1444 let right_wc_dir = temp_dir.path().join("right");
1445 let mut diff_stream = materialized_diff_stream(store, tree_diff);
1446 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1447 let (left_value, right_value) = values?;
1448 let left_path = path.source();
1449 let right_path = path.target();
1450 let left_ui_path = path_converter.format_file_path(left_path);
1451 let right_ui_path = path_converter.format_file_path(right_path);
1452
1453 match (&left_value, &right_value) {
1454 (_, MaterializedTreeValue::AccessDenied(source)) => {
1455 write!(
1456 formatter.labeled("access-denied"),
1457 "Access denied to {right_ui_path}:"
1458 )?;
1459 writeln!(formatter, " {source}")?;
1460 continue;
1461 }
1462 (MaterializedTreeValue::AccessDenied(source), _) => {
1463 write!(
1464 formatter.labeled("access-denied"),
1465 "Access denied to {left_ui_path}:"
1466 )?;
1467 writeln!(formatter, " {source}")?;
1468 continue;
1469 }
1470 _ => {}
1471 }
1472 let left_path = create_file(left_path, &left_wc_dir, left_value)?;
1473 let right_path = create_file(right_path, &right_wc_dir, right_value)?;
1474 let patterns = &maplit::hashmap! {
1475 "left" => left_path
1476 .strip_prefix(temp_dir.path()).expect("path should be relative to temp_dir")
1477 .to_str().expect("temp_dir should be valid utf-8"),
1478 "right" => right_path
1479 .strip_prefix(temp_dir.path()).expect("path should be relative to temp_dir")
1480 .to_str().expect("temp_dir should be valid utf-8"),
1481 };
1482
1483 let mut writer = formatter.raw()?;
1484 invoke_external_diff(ui, writer.as_mut(), tool, temp_dir.path(), patterns)
1485 .map_err(DiffRenderError::DiffGenerate)?;
1486 }
1487 Ok::<(), DiffRenderError>(())
1488}
1489
1490struct GitDiffPart {
1491 mode: Option<&'static str>,
1493 hash: String,
1494 content: FileContent<BString>,
1495}
1496
1497fn git_diff_part(
1498 path: &RepoPath,
1499 value: MaterializedTreeValue,
1500 conflict_marker_style: ConflictMarkerStyle,
1501) -> Result<GitDiffPart, DiffRenderError> {
1502 const DUMMY_HASH: &str = "0000000000";
1503 let mode;
1504 let mut hash;
1505 let content;
1506 match value {
1507 MaterializedTreeValue::Absent => {
1508 return Ok(GitDiffPart {
1509 mode: None,
1510 hash: DUMMY_HASH.to_owned(),
1511 content: FileContent {
1512 is_binary: false,
1513 contents: BString::default(),
1514 },
1515 });
1516 }
1517 MaterializedTreeValue::AccessDenied(err) => {
1518 return Err(DiffRenderError::AccessDenied {
1519 path: path.as_internal_file_string().to_owned(),
1520 source: err,
1521 });
1522 }
1523 MaterializedTreeValue::File(mut file) => {
1524 mode = if file.executable { "100755" } else { "100644" };
1525 hash = file.id.hex();
1526 content = file_content_for_diff(path, &mut file, |content| content)?;
1527 }
1528 MaterializedTreeValue::Symlink { id, target } => {
1529 mode = "120000";
1530 hash = id.hex();
1531 content = FileContent {
1532 is_binary: false,
1534 contents: target.into(),
1535 };
1536 }
1537 MaterializedTreeValue::GitSubmodule(id) => {
1538 mode = "040000";
1540 hash = id.hex();
1541 content = FileContent {
1542 is_binary: false,
1543 contents: BString::default(),
1544 };
1545 }
1546 MaterializedTreeValue::FileConflict(file) => {
1547 mode = match file.executable {
1548 Some(true) => "100755",
1549 Some(false) | None => "100644",
1550 };
1551 hash = DUMMY_HASH.to_owned();
1552 content = FileContent {
1553 is_binary: false, contents: materialize_merge_result_to_bytes(&file.contents, conflict_marker_style),
1555 };
1556 }
1557 MaterializedTreeValue::OtherConflict { id } => {
1558 mode = "100644";
1559 hash = DUMMY_HASH.to_owned();
1560 content = FileContent {
1561 is_binary: false,
1562 contents: id.describe().into(),
1563 };
1564 }
1565 MaterializedTreeValue::Tree(_) => {
1566 panic!("Unexpected tree in diff at path {path:?}");
1567 }
1568 }
1569 hash.truncate(10);
1570 Ok(GitDiffPart {
1571 mode: Some(mode),
1572 hash,
1573 content,
1574 })
1575}
1576
1577#[derive(Clone, Debug, Eq, PartialEq)]
1578pub struct UnifiedDiffOptions {
1579 pub context: usize,
1581 pub line_diff: LineDiffOptions,
1583}
1584
1585impl UnifiedDiffOptions {
1586 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
1587 Ok(Self {
1588 context: settings.get("diff.git.context")?,
1589 line_diff: LineDiffOptions::default(),
1590 })
1591 }
1592
1593 fn merge_args(&mut self, args: &DiffFormatArgs) {
1594 if let Some(context) = args.context {
1595 self.context = context;
1596 }
1597 self.line_diff.merge_args(args);
1598 }
1599}
1600
1601#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1602enum DiffLineType {
1603 Context,
1604 Removed,
1605 Added,
1606}
1607
1608#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1609enum DiffTokenType {
1610 Matching,
1611 Different,
1612}
1613
1614type DiffTokenVec<'content> = Vec<(DiffTokenType, &'content [u8])>;
1615
1616struct UnifiedDiffHunk<'content> {
1617 left_line_range: Range<usize>,
1618 right_line_range: Range<usize>,
1619 lines: Vec<(DiffLineType, DiffTokenVec<'content>)>,
1620}
1621
1622impl<'content> UnifiedDiffHunk<'content> {
1623 fn extend_context_lines(&mut self, lines: impl IntoIterator<Item = &'content [u8]>) {
1624 let old_len = self.lines.len();
1625 self.lines.extend(lines.into_iter().map(|line| {
1626 let tokens = vec![(DiffTokenType::Matching, line)];
1627 (DiffLineType::Context, tokens)
1628 }));
1629 self.left_line_range.end += self.lines.len() - old_len;
1630 self.right_line_range.end += self.lines.len() - old_len;
1631 }
1632
1633 fn extend_removed_lines(&mut self, lines: impl IntoIterator<Item = DiffTokenVec<'content>>) {
1634 let old_len = self.lines.len();
1635 self.lines
1636 .extend(lines.into_iter().map(|line| (DiffLineType::Removed, line)));
1637 self.left_line_range.end += self.lines.len() - old_len;
1638 }
1639
1640 fn extend_added_lines(&mut self, lines: impl IntoIterator<Item = DiffTokenVec<'content>>) {
1641 let old_len = self.lines.len();
1642 self.lines
1643 .extend(lines.into_iter().map(|line| (DiffLineType::Added, line)));
1644 self.right_line_range.end += self.lines.len() - old_len;
1645 }
1646}
1647
1648fn unified_diff_hunks<'content>(
1649 contents: [&'content BStr; 2],
1650 options: &UnifiedDiffOptions,
1651) -> Vec<UnifiedDiffHunk<'content>> {
1652 let mut hunks = vec![];
1653 let mut current_hunk = UnifiedDiffHunk {
1654 left_line_range: 0..0,
1655 right_line_range: 0..0,
1656 lines: vec![],
1657 };
1658 let diff = diff_by_line(contents, &options.line_diff);
1659 let mut diff_hunks = diff.hunks().peekable();
1660 while let Some(hunk) = diff_hunks.next() {
1661 match hunk.kind {
1662 DiffHunkKind::Matching => {
1663 let [_, right] = hunk.contents[..].try_into().unwrap();
1667 let mut lines = right.split_inclusive(|b| *b == b'\n').fuse();
1668 if !current_hunk.lines.is_empty() {
1669 current_hunk.extend_context_lines(lines.by_ref().take(options.context));
1671 }
1672 let before_lines = if diff_hunks.peek().is_some() {
1673 lines.by_ref().rev().take(options.context).collect()
1674 } else {
1675 vec![] };
1677 let num_skip_lines = lines.count();
1678 if num_skip_lines > 0 {
1679 let left_start = current_hunk.left_line_range.end + num_skip_lines;
1680 let right_start = current_hunk.right_line_range.end + num_skip_lines;
1681 if !current_hunk.lines.is_empty() {
1682 hunks.push(current_hunk);
1683 }
1684 current_hunk = UnifiedDiffHunk {
1685 left_line_range: left_start..left_start,
1686 right_line_range: right_start..right_start,
1687 lines: vec![],
1688 };
1689 }
1690 current_hunk.extend_context_lines(before_lines.into_iter().rev());
1692 }
1693 DiffHunkKind::Different => {
1694 let [left_lines, right_lines] =
1695 unzip_diff_hunks_to_lines(Diff::by_word(hunk.contents).hunks());
1696 current_hunk.extend_removed_lines(left_lines);
1697 current_hunk.extend_added_lines(right_lines);
1698 }
1699 }
1700 }
1701 if !current_hunk.lines.is_empty() {
1702 hunks.push(current_hunk);
1703 }
1704 hunks
1705}
1706
1707fn unzip_diff_hunks_to_lines<'content, I>(diff_hunks: I) -> [Vec<DiffTokenVec<'content>>; 2]
1709where
1710 I: IntoIterator,
1711 I::Item: Borrow<DiffHunk<'content>>,
1712{
1713 let mut left_lines: Vec<DiffTokenVec<'content>> = vec![];
1714 let mut right_lines: Vec<DiffTokenVec<'content>> = vec![];
1715 let mut left_tokens: DiffTokenVec<'content> = vec![];
1716 let mut right_tokens: DiffTokenVec<'content> = vec![];
1717
1718 for hunk in diff_hunks {
1719 let hunk = hunk.borrow();
1720 match hunk.kind {
1721 DiffHunkKind::Matching => {
1722 debug_assert!(hunk.contents.iter().all_equal());
1724 for token in hunk.contents[0].split_inclusive(|b| *b == b'\n') {
1725 left_tokens.push((DiffTokenType::Matching, token));
1726 right_tokens.push((DiffTokenType::Matching, token));
1727 if token.ends_with(b"\n") {
1728 left_lines.push(mem::take(&mut left_tokens));
1729 right_lines.push(mem::take(&mut right_tokens));
1730 }
1731 }
1732 }
1733 DiffHunkKind::Different => {
1734 let [left, right] = hunk.contents[..]
1735 .try_into()
1736 .expect("hunk should have exactly two inputs");
1737 for token in left.split_inclusive(|b| *b == b'\n') {
1738 left_tokens.push((DiffTokenType::Different, token));
1739 if token.ends_with(b"\n") {
1740 left_lines.push(mem::take(&mut left_tokens));
1741 }
1742 }
1743 for token in right.split_inclusive(|b| *b == b'\n') {
1744 right_tokens.push((DiffTokenType::Different, token));
1745 if token.ends_with(b"\n") {
1746 right_lines.push(mem::take(&mut right_tokens));
1747 }
1748 }
1749 }
1750 }
1751 }
1752
1753 if !left_tokens.is_empty() {
1754 left_lines.push(left_tokens);
1755 }
1756 if !right_tokens.is_empty() {
1757 right_lines.push(right_tokens);
1758 }
1759 [left_lines, right_lines]
1760}
1761
1762fn show_unified_diff_hunks(
1763 formatter: &mut dyn Formatter,
1764 contents: [&BStr; 2],
1765 options: &UnifiedDiffOptions,
1766) -> io::Result<()> {
1767 fn to_line_number(range: Range<usize>) -> usize {
1775 if range.is_empty() {
1776 range.start
1777 } else {
1778 range.start + 1
1779 }
1780 }
1781
1782 for hunk in unified_diff_hunks(contents, options) {
1783 writeln!(
1784 formatter.labeled("hunk_header"),
1785 "@@ -{},{} +{},{} @@",
1786 to_line_number(hunk.left_line_range.clone()),
1787 hunk.left_line_range.len(),
1788 to_line_number(hunk.right_line_range.clone()),
1789 hunk.right_line_range.len()
1790 )?;
1791 for (line_type, tokens) in &hunk.lines {
1792 let (label, sigil) = match line_type {
1793 DiffLineType::Context => ("context", " "),
1794 DiffLineType::Removed => ("removed", "-"),
1795 DiffLineType::Added => ("added", "+"),
1796 };
1797 formatter.with_label(label, |formatter| {
1798 write!(formatter, "{sigil}")?;
1799 show_diff_line_tokens(formatter, tokens)
1800 })?;
1801 let (_, content) = tokens.last().expect("hunk line must not be empty");
1802 if !content.ends_with(b"\n") {
1803 write!(formatter, "\n\\ No newline at end of file\n")?;
1804 }
1805 }
1806 }
1807 Ok(())
1808}
1809
1810fn show_diff_line_tokens(
1811 formatter: &mut dyn Formatter,
1812 tokens: &[(DiffTokenType, &[u8])],
1813) -> io::Result<()> {
1814 for (token_type, content) in tokens {
1815 match token_type {
1816 DiffTokenType::Matching => formatter.write_all(content)?,
1817 DiffTokenType::Different => {
1818 formatter.with_label("token", |formatter| formatter.write_all(content))?;
1819 }
1820 }
1821 }
1822 Ok(())
1823}
1824
1825pub async fn show_git_diff(
1826 formatter: &mut dyn Formatter,
1827 store: &Store,
1828 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1829 options: &UnifiedDiffOptions,
1830 conflict_marker_style: ConflictMarkerStyle,
1831) -> Result<(), DiffRenderError> {
1832 let mut diff_stream = materialized_diff_stream(store, tree_diff);
1833 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1834 let left_path = path.source();
1835 let right_path = path.target();
1836 let left_path_string = left_path.as_internal_file_string();
1837 let right_path_string = right_path.as_internal_file_string();
1838 let (left_value, right_value) = values?;
1839
1840 let left_part = git_diff_part(left_path, left_value, conflict_marker_style)?;
1841 let right_part = git_diff_part(right_path, right_value, conflict_marker_style)?;
1842
1843 formatter.with_label("file_header", |formatter| {
1844 writeln!(
1845 formatter,
1846 "diff --git a/{left_path_string} b/{right_path_string}"
1847 )?;
1848 let left_hash = &left_part.hash;
1849 let right_hash = &right_part.hash;
1850 match (left_part.mode, right_part.mode) {
1851 (None, Some(right_mode)) => {
1852 writeln!(formatter, "new file mode {right_mode}")?;
1853 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1854 }
1855 (Some(left_mode), None) => {
1856 writeln!(formatter, "deleted file mode {left_mode}")?;
1857 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1858 }
1859 (Some(left_mode), Some(right_mode)) => {
1860 if let Some(op) = path.copy_operation() {
1861 let operation = match op {
1862 CopyOperation::Copy => "copy",
1863 CopyOperation::Rename => "rename",
1864 };
1865 writeln!(formatter, "{operation} from {left_path_string}")?;
1867 writeln!(formatter, "{operation} to {right_path_string}")?;
1868 }
1869 if left_mode != right_mode {
1870 writeln!(formatter, "old mode {left_mode}")?;
1871 writeln!(formatter, "new mode {right_mode}")?;
1872 if left_hash != right_hash {
1873 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1874 }
1875 } else if left_hash != right_hash {
1876 writeln!(formatter, "index {left_hash}..{right_hash} {left_mode}")?;
1877 }
1878 }
1879 (None, None) => panic!("either left or right part should be present"),
1880 }
1881 Ok::<(), DiffRenderError>(())
1882 })?;
1883
1884 if left_part.content.contents == right_part.content.contents {
1885 continue; }
1887
1888 let left_path = match left_part.mode {
1889 Some(_) => format!("a/{left_path_string}"),
1890 None => "/dev/null".to_owned(),
1891 };
1892 let right_path = match right_part.mode {
1893 Some(_) => format!("b/{right_path_string}"),
1894 None => "/dev/null".to_owned(),
1895 };
1896 if left_part.content.is_binary || right_part.content.is_binary {
1897 writeln!(
1899 formatter,
1900 "Binary files {left_path} and {right_path} differ"
1901 )?;
1902 } else {
1903 formatter.with_label("file_header", |formatter| {
1904 writeln!(formatter, "--- {left_path}")?;
1905 writeln!(formatter, "+++ {right_path}")?;
1906 io::Result::Ok(())
1907 })?;
1908 show_unified_diff_hunks(
1909 formatter,
1910 [&left_part.content.contents, &right_part.content.contents].map(BStr::new),
1911 options,
1912 )?;
1913 }
1914 }
1915 Ok(())
1916}
1917
1918#[instrument(skip_all)]
1919pub async fn show_diff_summary(
1920 formatter: &mut dyn Formatter,
1921 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1922 path_converter: &RepoPathUiConverter,
1923) -> Result<(), DiffRenderError> {
1924 while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
1925 let (before, after) = values?;
1926 let (label, sigil) = diff_status_label_and_char(&path, &before, &after);
1927 let path = if path.copy_operation().is_some() {
1928 path_converter.format_copied_path(path.source(), path.target())
1929 } else {
1930 path_converter.format_file_path(path.target())
1931 };
1932 writeln!(formatter.labeled(label), "{sigil} {path}")?;
1933 }
1934 Ok(())
1935}
1936
1937pub fn diff_status_label_and_char(
1938 path: &CopiesTreeDiffEntryPath,
1939 before: &MergedTreeValue,
1940 after: &MergedTreeValue,
1941) -> (&'static str, char) {
1942 if let Some(op) = path.copy_operation() {
1943 match op {
1944 CopyOperation::Copy => ("copied", 'C'),
1945 CopyOperation::Rename => ("renamed", 'R'),
1946 }
1947 } else {
1948 match (before.is_present(), after.is_present()) {
1949 (true, true) => ("modified", 'M'),
1950 (false, true) => ("added", 'A'),
1951 (true, false) => ("removed", 'D'),
1952 (false, false) => panic!("values pair must differ"),
1953 }
1954 }
1955}
1956
1957#[derive(Clone, Debug, Default, Eq, PartialEq)]
1958pub struct DiffStatOptions {
1959 pub line_diff: LineDiffOptions,
1961}
1962
1963impl DiffStatOptions {
1964 fn merge_args(&mut self, args: &DiffFormatArgs) {
1965 self.line_diff.merge_args(args);
1966 }
1967}
1968
1969#[derive(Clone, Debug)]
1970pub struct DiffStats {
1971 entries: Vec<DiffStatEntry>,
1972}
1973
1974impl DiffStats {
1975 pub async fn calculate(
1977 store: &Store,
1978 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1979 options: &DiffStatOptions,
1980 conflict_marker_style: ConflictMarkerStyle,
1981 ) -> BackendResult<Self> {
1982 let entries = materialized_diff_stream(store, tree_diff)
1983 .map(|MaterializedTreeDiffEntry { path, values }| {
1984 let (left, right) = values?;
1985 let left_content = diff_content(path.source(), left, conflict_marker_style)?;
1986 let right_content = diff_content(path.target(), right, conflict_marker_style)?;
1987 let stat = get_diff_stat_entry(
1988 path,
1989 [&left_content.contents, &right_content.contents].map(BStr::new),
1990 options,
1991 );
1992 BackendResult::Ok(stat)
1993 })
1994 .try_collect()
1995 .await?;
1996 Ok(Self { entries })
1997 }
1998
1999 pub fn entries(&self) -> &[DiffStatEntry] {
2001 &self.entries
2002 }
2003
2004 pub fn count_total_added(&self) -> usize {
2006 self.entries.iter().map(|stat| stat.added).sum()
2007 }
2008
2009 pub fn count_total_removed(&self) -> usize {
2011 self.entries.iter().map(|stat| stat.removed).sum()
2012 }
2013}
2014
2015#[derive(Clone, Debug)]
2016pub struct DiffStatEntry {
2017 pub path: CopiesTreeDiffEntryPath,
2018 pub added: usize,
2019 pub removed: usize,
2020}
2021
2022fn get_diff_stat_entry(
2023 path: CopiesTreeDiffEntryPath,
2024 contents: [&BStr; 2],
2025 options: &DiffStatOptions,
2026) -> DiffStatEntry {
2027 let diff = diff_by_line(contents, &options.line_diff);
2031 let mut added = 0;
2032 let mut removed = 0;
2033 for hunk in diff.hunks() {
2034 match hunk.kind {
2035 DiffHunkKind::Matching => {}
2036 DiffHunkKind::Different => {
2037 let [left, right] = hunk.contents[..].try_into().unwrap();
2038 removed += left.split_inclusive(|b| *b == b'\n').count();
2039 added += right.split_inclusive(|b| *b == b'\n').count();
2040 }
2041 }
2042 }
2043 DiffStatEntry {
2044 path,
2045 added,
2046 removed,
2047 }
2048}
2049
2050pub fn show_diff_stats(
2051 formatter: &mut dyn Formatter,
2052 stats: &DiffStats,
2053 path_converter: &RepoPathUiConverter,
2054 display_width: usize,
2055) -> io::Result<()> {
2056 let ui_paths = stats
2057 .entries()
2058 .iter()
2059 .map(|stat| {
2060 if stat.path.copy_operation().is_some() {
2061 path_converter.format_copied_path(stat.path.source(), stat.path.target())
2062 } else {
2063 path_converter.format_file_path(stat.path.target())
2064 }
2065 })
2066 .collect_vec();
2067
2068 let max_path_width = ui_paths.iter().map(|s| s.width()).max().unwrap_or(0);
2075 let max_diffs = stats
2076 .entries()
2077 .iter()
2078 .map(|stat| stat.added + stat.removed)
2079 .max()
2080 .unwrap_or(0);
2081
2082 let number_width = max_diffs.to_string().len();
2083 let available_width = display_width.saturating_sub(" | ".len() + number_width + " ".len());
2085 let available_width = max(available_width, 5);
2087
2088 let max_path_width = max_path_width.min((0.7 * available_width as f64) as usize);
2089 let max_bar_length = available_width.saturating_sub(max_path_width);
2090 let factor = if max_diffs < max_bar_length {
2091 1.0
2092 } else {
2093 max_bar_length as f64 / max_diffs as f64
2094 };
2095
2096 for (stat, ui_path) in iter::zip(stats.entries(), &ui_paths) {
2097 let bar_length = ((stat.added + stat.removed) as f64 * factor) as usize;
2098 let bar_length = bar_length.max((stat.added > 0) as usize + (stat.removed > 0) as usize);
2105 let (bar_added, bar_removed) = if stat.added < stat.removed {
2106 let len = (stat.added as f64 * factor).ceil() as usize;
2107 (len, bar_length - len)
2108 } else {
2109 let len = (stat.removed as f64 * factor).ceil() as usize;
2110 (bar_length - len, len)
2111 };
2112 let (path, path_width) = text_util::elide_start(ui_path, "...", max_path_width);
2114 let path_pad_width = max_path_width - path_width;
2115 write!(
2116 formatter,
2117 "{path}{:path_pad_width$} | {:>number_width$}{}",
2118 "", stat.added + stat.removed,
2120 if bar_added + bar_removed > 0 { " " } else { "" },
2121 )?;
2122 write!(formatter.labeled("added"), "{}", "+".repeat(bar_added))?;
2123 writeln!(formatter.labeled("removed"), "{}", "-".repeat(bar_removed))?;
2124 }
2125
2126 let total_added = stats.count_total_added();
2127 let total_removed = stats.count_total_removed();
2128 let total_files = stats.entries().len();
2129 writeln!(
2130 formatter.labeled("stat-summary"),
2131 "{} file{} changed, {} insertion{}(+), {} deletion{}(-)",
2132 total_files,
2133 if total_files == 1 { "" } else { "s" },
2134 total_added,
2135 if total_added == 1 { "" } else { "s" },
2136 total_removed,
2137 if total_removed == 1 { "" } else { "s" },
2138 )?;
2139 Ok(())
2140}
2141
2142pub async fn show_types(
2143 formatter: &mut dyn Formatter,
2144 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2145 path_converter: &RepoPathUiConverter,
2146) -> Result<(), DiffRenderError> {
2147 while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
2148 let (before, after) = values?;
2149 writeln!(
2150 formatter.labeled("modified"),
2151 "{}{} {}",
2152 diff_summary_char(&before),
2153 diff_summary_char(&after),
2154 path_converter.format_copied_path(path.source(), path.target())
2155 )?;
2156 }
2157 Ok(())
2158}
2159
2160fn diff_summary_char(value: &MergedTreeValue) -> char {
2161 match value.as_resolved() {
2162 Some(None) => '-',
2163 Some(Some(TreeValue::File { .. })) => 'F',
2164 Some(Some(TreeValue::Symlink(_))) => 'L',
2165 Some(Some(TreeValue::GitSubmodule(_))) => 'G',
2166 None => 'C',
2167 Some(Some(TreeValue::Tree(_))) | Some(Some(TreeValue::Conflict(_))) => {
2168 panic!("Unexpected {value:?} in diff")
2169 }
2170 }
2171}
2172
2173pub async fn show_names(
2174 formatter: &mut dyn Formatter,
2175 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2176 path_converter: &RepoPathUiConverter,
2177) -> io::Result<()> {
2178 while let Some(CopiesTreeDiffEntry { path, .. }) = tree_diff.next().await {
2179 writeln!(
2180 formatter,
2181 "{}",
2182 path_converter.format_file_path(path.target())
2183 )?;
2184 }
2185 Ok(())
2186}
2187
2188pub async fn show_templated(
2189 formatter: &mut dyn Formatter,
2190 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2191 template: &TemplateRenderer<'_, commit_templater::TreeDiffEntry>,
2192) -> Result<(), DiffRenderError> {
2193 while let Some(entry) = tree_diff.next().await {
2194 let entry = commit_templater::TreeDiffEntry::from_backend_entry_with_copies(entry)?;
2195 template.format(&entry, formatter)?;
2196 }
2197 Ok(())
2198}