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