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