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 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, tree_diff, path_converter).await?;
485 }
486 DiffFormat::Stat(options) => {
487 let tree_diff = diff_stream();
488 let stats =
489 DiffStats::calculate(store, tree_diff, options, self.conflict_marker_style)
490 .await?;
491 show_diff_stats(formatter, &stats, path_converter, width)?;
492 }
493 DiffFormat::Types => {
494 let tree_diff = diff_stream();
495 show_types(formatter, tree_diff, path_converter).await?;
496 }
497 DiffFormat::NameOnly => {
498 let tree_diff = diff_stream();
499 show_names(formatter, tree_diff, path_converter).await?;
500 }
501 DiffFormat::Git(options) => {
502 let tree_diff = diff_stream();
503 show_git_diff(
504 formatter,
505 store,
506 tree_diff,
507 conflict_labels,
508 options,
509 self.conflict_marker_style,
510 )
511 .await?;
512 }
513 DiffFormat::ColorWords(options) => {
514 let tree_diff = diff_stream();
515 show_color_words_diff(
516 formatter,
517 store,
518 tree_diff,
519 conflict_labels,
520 path_converter,
521 options,
522 self.conflict_marker_style,
523 )
524 .await?;
525 }
526 DiffFormat::Tool(tool) => {
527 match tool.diff_invocation_mode {
528 DiffToolMode::FileByFile => {
529 let tree_diff = diff_stream();
530 show_file_by_file_diff(
531 ui,
532 formatter,
533 store,
534 tree_diff,
535 conflict_labels,
536 path_converter,
537 tool,
538 self.conflict_marker_style,
539 width,
540 )
541 .await
542 }
543 DiffToolMode::Dir => {
544 let mut writer = formatter.raw()?;
545 generate_diff(
546 ui,
547 writer.as_mut(),
548 trees,
549 matcher,
550 tool,
551 self.conflict_marker_style,
552 width,
553 )
554 .map_err(DiffRenderError::DiffGenerate)
555 }
556 }?;
557 }
558 }
559 }
560 Ok(())
561 }
562
563 fn show_diff_commit_descriptions(
564 &self,
565 formatter: &mut dyn Formatter,
566 descriptions: Diff<&Merge<&str>>,
567 ) -> Result<(), DiffRenderError> {
568 if !descriptions.is_changed() {
569 return Ok(());
570 }
571 const DUMMY_PATH: &str = "JJ-COMMIT-DESCRIPTION";
572 let materialize_options = ConflictMaterializeOptions {
573 marker_style: self.conflict_marker_style,
574 marker_len: None,
575 merge: self.repo.store().merge_options().clone(),
576 };
577 for format in &self.formats {
578 match format {
579 DiffFormat::Summary
582 | DiffFormat::Stat(_)
583 | DiffFormat::Types
584 | DiffFormat::NameOnly => {}
585 DiffFormat::Git(options) => {
586 show_git_diff_texts(
588 formatter,
589 Diff::new(DUMMY_PATH, DUMMY_PATH),
590 descriptions,
591 options,
592 &materialize_options,
593 )?;
594 }
595 DiffFormat::ColorWords(options) => {
596 writeln!(formatter.labeled("header"), "Modified commit description:")?;
597 show_color_words_diff_hunks(
598 formatter,
599 descriptions,
600 Diff::new(&ConflictLabels::unlabeled(), &ConflictLabels::unlabeled()),
601 options,
602 &materialize_options,
603 )?;
604 }
605 DiffFormat::Tool(_) => {
606 }
608 }
609 }
610 Ok(())
611 }
612
613 pub async fn show_inter_diff(
617 &self,
618 ui: &Ui,
619 formatter: &mut dyn Formatter,
620 from_commits: &[Commit],
621 to_commit: &Commit,
622 matcher: &dyn Matcher,
623 width: usize,
624 ) -> Result<(), DiffRenderError> {
625 let mut formatter = formatter.labeled("diff");
626 let from_description = if from_commits.is_empty() {
627 Merge::resolved("")
628 } else {
629 MergeBuilder::from_iter(itertools::intersperse(
631 from_commits.iter().map(|c| c.description()),
632 "",
633 ))
634 .build()
635 .simplify()
636 };
637 let to_description = Merge::resolved(to_commit.description());
638 let from_tree = rebase_to_dest_parent(self.repo, from_commits, to_commit).await?;
639 let to_tree = to_commit.tree();
640 let copy_records = CopyRecords::default(); self.show_diff_commit_descriptions(
642 *formatter,
643 Diff::new(&from_description, &to_description),
644 )?;
645 self.show_diff_trees(
646 ui,
647 *formatter,
648 Diff::new(&from_tree, &to_tree),
649 matcher,
650 ©_records,
651 width,
652 )
653 .await
654 }
655
656 pub async fn show_patch(
658 &self,
659 ui: &Ui,
660 formatter: &mut dyn Formatter,
661 commit: &Commit,
662 matcher: &dyn Matcher,
663 width: usize,
664 ) -> Result<(), DiffRenderError> {
665 let from_tree = commit.parent_tree(self.repo).await?;
666 let to_tree = commit.tree();
667 let mut copy_records = CopyRecords::default();
668 for parent_id in commit.parent_ids() {
669 let records = get_copy_records(self.repo.store(), parent_id, commit.id(), matcher)?;
670 copy_records.add_records(records)?;
671 }
672 self.show_diff(
673 ui,
674 formatter,
675 Diff::new(&from_tree, &to_tree),
676 matcher,
677 ©_records,
678 width,
679 )
680 .await
681 }
682}
683
684pub fn get_copy_records<'a>(
685 store: &'a Store,
686 root: &CommitId,
687 head: &CommitId,
688 matcher: &'a dyn Matcher,
689) -> BackendResult<impl Iterator<Item = BackendResult<CopyRecord>> + use<'a>> {
690 let stream = store.get_copy_records(None, root, head)?;
692 Ok(block_on_stream(stream).filter_ok(|record| matcher.matches(&record.target)))
694}
695
696#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
698#[serde(rename_all = "kebab-case")]
699pub enum ConflictDiffMethod {
700 #[default]
702 Materialize,
703 Pair,
705}
706
707#[derive(Clone, Debug, Default, Eq, PartialEq)]
708pub struct LineDiffOptions {
709 pub compare_mode: LineCompareMode,
711 }
713
714impl LineDiffOptions {
715 fn merge_args(&mut self, args: &DiffFormatArgs) {
716 self.compare_mode = if args.ignore_all_space {
717 LineCompareMode::IgnoreAllSpace
718 } else if args.ignore_space_change {
719 LineCompareMode::IgnoreSpaceChange
720 } else {
721 LineCompareMode::Exact
722 };
723 }
724}
725
726#[derive(Clone, Debug, Eq, PartialEq)]
727pub struct ColorWordsDiffOptions {
728 pub conflict: ConflictDiffMethod,
730 pub context: usize,
732 pub line_diff: LineDiffOptions,
734 pub max_inline_alternation: Option<usize>,
736}
737
738impl ColorWordsDiffOptions {
739 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
740 let max_inline_alternation = {
741 let name = "diff.color-words.max-inline-alternation";
742 match settings.get_int(name)? {
743 -1 => None, n => Some(usize::try_from(n).map_err(|err| ConfigGetError::Type {
745 name: name.to_owned(),
746 error: err.into(),
747 source_path: None,
748 })?),
749 }
750 };
751 Ok(Self {
752 conflict: settings.get("diff.color-words.conflict")?,
753 context: settings.get("diff.color-words.context")?,
754 line_diff: LineDiffOptions::default(),
755 max_inline_alternation,
756 })
757 }
758
759 fn merge_args(&mut self, args: &DiffFormatArgs) {
760 if let Some(context) = args.context {
761 self.context = context;
762 }
763 self.line_diff.merge_args(args);
764 }
765}
766
767fn show_color_words_diff_hunks<T: AsRef<[u8]>>(
768 formatter: &mut dyn Formatter,
769 contents: Diff<&Merge<T>>,
770 conflict_labels: Diff<&ConflictLabels>,
771 options: &ColorWordsDiffOptions,
772 materialize_options: &ConflictMaterializeOptions,
773) -> io::Result<()> {
774 let line_number = DiffLineNumber { left: 1, right: 1 };
775 let labels = Diff::new("removed", "added");
776 if let (Some(left), Some(right)) = (contents.before.as_resolved(), contents.after.as_resolved())
777 {
778 let contents = Diff::new(left, right).map(BStr::new);
779 show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
780 return Ok(());
781 }
782 match options.conflict {
783 ConflictDiffMethod::Materialize => {
784 let contents = contents.zip(conflict_labels).map(|(side, labels)| {
785 materialize_merge_result_to_bytes(side, labels, materialize_options)
786 });
787 show_color_words_resolved_hunks(
788 formatter,
789 contents.as_ref().map(BStr::new),
790 line_number,
791 labels,
792 options,
793 )?;
794 }
795 ConflictDiffMethod::Pair => {
796 let contents = contents.map(|side| files::merge(side, &materialize_options.merge));
797 show_color_words_conflict_hunks(
798 formatter,
799 contents.as_ref(),
800 line_number,
801 labels,
802 options,
803 )?;
804 }
805 }
806 Ok(())
807}
808
809fn show_color_words_conflict_hunks(
810 formatter: &mut dyn Formatter,
811 contents: Diff<&Merge<BString>>,
812 mut line_number: DiffLineNumber,
813 labels: Diff<&str>,
814 options: &ColorWordsDiffOptions,
815) -> io::Result<DiffLineNumber> {
816 let num_lefts = contents.before.as_slice().len();
817 let line_diff = diff_by_line(
818 itertools::chain(contents.before, contents.after),
819 &options.line_diff.compare_mode,
820 );
821 let mut contexts: Vec<Diff<&BStr>> = Vec::new();
825 let mut emitted = false;
826
827 for hunk in files::conflict_diff_hunks(line_diff.hunks(), num_lefts) {
828 match hunk.kind {
829 DiffHunkKind::Matching => {
832 contexts.push(Diff::new(hunk.lefts.first(), hunk.rights.first()));
833 }
834 DiffHunkKind::Different => {
835 let num_after = if emitted { options.context } else { 0 };
836 let num_before = options.context;
837 line_number = show_color_words_context_lines(
838 formatter,
839 &contexts,
840 line_number,
841 labels,
842 options,
843 num_after,
844 num_before,
845 )?;
846 contexts.clear();
847 emitted = true;
848 line_number = if let (Some(&left), Some(&right)) =
849 (hunk.lefts.as_resolved(), hunk.rights.as_resolved())
850 {
851 show_color_words_diff_lines(
852 formatter,
853 Diff::new(left, right),
854 line_number,
855 labels,
856 options,
857 )?
858 } else {
859 show_color_words_unresolved_hunk(
860 formatter,
861 &hunk,
862 line_number,
863 labels,
864 options,
865 )?
866 }
867 }
868 }
869 }
870
871 let num_after = if emitted { options.context } else { 0 };
872 let num_before = 0;
873 show_color_words_context_lines(
874 formatter,
875 &contexts,
876 line_number,
877 labels,
878 options,
879 num_after,
880 num_before,
881 )
882}
883
884fn show_color_words_unresolved_hunk(
885 formatter: &mut dyn Formatter,
886 hunk: &ConflictDiffHunk,
887 line_number: DiffLineNumber,
888 labels: Diff<&str>,
889 options: &ColorWordsDiffOptions,
890) -> io::Result<DiffLineNumber> {
891 let hunk_desc = if hunk.lefts.is_resolved() {
892 "Created conflict"
893 } else if hunk.rights.is_resolved() {
894 "Resolved conflict"
895 } else {
896 "Modified conflict"
897 };
898 writeln!(formatter.labeled("hunk_header"), "<<<<<<< {hunk_desc}")?;
899
900 let num_terms = max(hunk.lefts.as_slice().len(), hunk.rights.as_slice().len());
905 let lefts = hunk.lefts.iter().enumerate();
906 let rights = hunk.rights.iter().enumerate();
907 let padded = iter::zip(
908 lefts.chain(iter::repeat((0, hunk.lefts.first()))),
909 rights.chain(iter::repeat((0, hunk.rights.first()))),
910 )
911 .take(num_terms);
912 let mut max_line_number = line_number;
913 for (i, ((left_index, &left_content), (right_index, &right_content))) in padded.enumerate() {
914 let positive = i % 2 == 0;
915 writeln!(
916 formatter.labeled("hunk_header"),
917 "{sep} left {left_name} #{left_index} to right {right_name} #{right_index}",
918 sep = if positive { "+++++++" } else { "-------" },
919 left_name = if left_index % 2 == 0 { "side" } else { "base" },
921 left_index = left_index / 2 + 1,
922 right_name = if right_index % 2 == 0 { "side" } else { "base" },
923 right_index = right_index / 2 + 1,
924 )?;
925 let contents = Diff::new(left_content, right_content);
926 let labels = match positive {
927 true => labels,
928 false => labels.invert(),
929 };
930 let new_line_number =
932 show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
933 max_line_number.left = max(max_line_number.left, new_line_number.left);
937 max_line_number.right = max(max_line_number.right, new_line_number.right);
938 }
939
940 writeln!(formatter.labeled("hunk_header"), ">>>>>>> Conflict ends")?;
941 Ok(max_line_number)
942}
943
944fn show_color_words_resolved_hunks(
945 formatter: &mut dyn Formatter,
946 contents: Diff<&BStr>,
947 mut line_number: DiffLineNumber,
948 labels: Diff<&str>,
949 options: &ColorWordsDiffOptions,
950) -> io::Result<DiffLineNumber> {
951 let line_diff = diff_by_line(contents.into_array(), &options.line_diff.compare_mode);
952 let mut context: Option<Diff<&BStr>> = None;
954 let mut emitted = false;
955
956 for hunk in line_diff.hunks() {
957 let &[left, right] = hunk.contents.as_slice() else {
958 panic!("hunk contents should have two sides")
959 };
960 let hunk_contents = Diff::new(left, right);
961 match hunk.kind {
962 DiffHunkKind::Matching => {
963 context = Some(hunk_contents);
964 }
965 DiffHunkKind::Different => {
966 let num_after = if emitted { options.context } else { 0 };
967 let num_before = options.context;
968 line_number = show_color_words_context_lines(
969 formatter,
970 context.as_slice(),
971 line_number,
972 labels,
973 options,
974 num_after,
975 num_before,
976 )?;
977 context = None;
978 emitted = true;
979 line_number = show_color_words_diff_lines(
980 formatter,
981 hunk_contents,
982 line_number,
983 labels,
984 options,
985 )?;
986 }
987 }
988 }
989
990 let num_after = if emitted { options.context } else { 0 };
991 let num_before = 0;
992 show_color_words_context_lines(
993 formatter,
994 context.as_slice(),
995 line_number,
996 labels,
997 options,
998 num_after,
999 num_before,
1000 )
1001}
1002
1003fn show_color_words_context_lines(
1005 formatter: &mut dyn Formatter,
1006 contexts: &[Diff<&BStr>],
1007 mut line_number: DiffLineNumber,
1008 labels: Diff<&str>,
1009 options: &ColorWordsDiffOptions,
1010 num_after: usize,
1011 num_before: usize,
1012) -> io::Result<DiffLineNumber> {
1013 const SKIPPED_CONTEXT_LINE: &str = " ...\n";
1014 let extract = |after: bool| -> (Vec<&[u8]>, Vec<&[u8]>, u32) {
1015 let mut lines = contexts
1016 .iter()
1017 .map(|contents| {
1018 if after {
1019 contents.after
1020 } else {
1021 contents.before
1022 }
1023 })
1024 .flat_map(|side| side.split_inclusive(|b| *b == b'\n'))
1025 .fuse();
1026 let after_lines = lines.by_ref().take(num_after).collect();
1027 let before_lines = lines.by_ref().rev().take(num_before + 1).collect();
1028 let num_skipped: u32 = lines.count().try_into().unwrap();
1029 (after_lines, before_lines, num_skipped)
1030 };
1031 let show = |formatter: &mut dyn Formatter,
1032 [left_lines, right_lines]: [&[&[u8]]; 2],
1033 mut line_number: DiffLineNumber| {
1034 let mut formatter = formatter.labeled("context");
1037 if left_lines == right_lines {
1038 for line in left_lines {
1039 show_color_words_line_number(
1040 *formatter,
1041 Diff::new(Some(line_number.left), Some(line_number.right)),
1042 labels,
1043 )?;
1044 show_color_words_inline_hunks(
1045 *formatter,
1046 &[(DiffLineHunkSide::Both, line.as_ref())],
1047 labels,
1048 )?;
1049 line_number.left += 1;
1050 line_number.right += 1;
1051 }
1052 Ok(line_number)
1053 } else {
1054 let left = left_lines.concat();
1055 let right = right_lines.concat();
1056 show_color_words_diff_lines(
1057 *formatter,
1058 Diff::new(&left, &right).map(BStr::new),
1059 line_number,
1060 labels,
1061 options,
1062 )
1063 }
1064 };
1065
1066 let (left_after, mut left_before, num_left_skipped) = extract(false);
1067 let (right_after, mut right_before, num_right_skipped) = extract(true);
1068 line_number = show(formatter, [&left_after, &right_after], line_number)?;
1069 if num_left_skipped > 0 || num_right_skipped > 0 {
1070 write!(formatter, "{SKIPPED_CONTEXT_LINE}")?;
1071 line_number.left += num_left_skipped;
1072 line_number.right += num_right_skipped;
1073 if left_before.len() > num_before {
1074 left_before.pop();
1075 line_number.left += 1;
1076 }
1077 if right_before.len() > num_before {
1078 right_before.pop();
1079 line_number.right += 1;
1080 }
1081 }
1082 left_before.reverse();
1083 right_before.reverse();
1084 line_number = show(formatter, [&left_before, &right_before], line_number)?;
1085 Ok(line_number)
1086}
1087
1088fn show_color_words_diff_lines(
1089 formatter: &mut dyn Formatter,
1090 contents: Diff<&BStr>,
1091 mut line_number: DiffLineNumber,
1092 labels: Diff<&str>,
1093 options: &ColorWordsDiffOptions,
1094) -> io::Result<DiffLineNumber> {
1095 let word_diff_hunks = ContentDiff::by_word(contents.into_array())
1096 .hunks()
1097 .collect_vec();
1098 let can_inline = match options.max_inline_alternation {
1099 None => true, Some(0) => false, Some(max_num) => {
1102 let groups = split_diff_hunks_by_matching_newline(&word_diff_hunks);
1103 groups.map(count_diff_alternation).max().unwrap_or(0) <= max_num
1104 }
1105 };
1106 if can_inline {
1107 let mut diff_line_iter =
1108 DiffLineIterator::with_line_number(word_diff_hunks.iter(), line_number);
1109 for diff_line in diff_line_iter.by_ref() {
1110 show_color_words_line_number(
1111 formatter,
1112 Diff::new(
1113 diff_line
1114 .has_left_content()
1115 .then_some(diff_line.line_number.left),
1116 diff_line
1117 .has_right_content()
1118 .then_some(diff_line.line_number.right),
1119 ),
1120 labels,
1121 )?;
1122 show_color_words_inline_hunks(formatter, &diff_line.hunks, labels)?;
1123 }
1124 line_number = diff_line_iter.next_line_number();
1125 } else {
1126 let lines = unzip_diff_hunks_to_lines(&word_diff_hunks);
1127 for tokens in &lines.before {
1128 show_color_words_line_number(
1129 formatter,
1130 Diff::new(Some(line_number.left), None),
1131 labels,
1132 )?;
1133 show_color_words_single_sided_line(formatter, tokens, labels.before)?;
1134 line_number.left += 1;
1135 }
1136 for tokens in &lines.after {
1137 show_color_words_line_number(
1138 formatter,
1139 Diff::new(None, Some(line_number.right)),
1140 labels,
1141 )?;
1142 show_color_words_single_sided_line(formatter, tokens, labels.after)?;
1143 line_number.right += 1;
1144 }
1145 }
1146 Ok(line_number)
1147}
1148
1149fn show_color_words_line_number(
1150 formatter: &mut dyn Formatter,
1151 line_numbers: Diff<Option<u32>>,
1152 labels: Diff<&str>,
1153) -> io::Result<()> {
1154 if let Some(line_number) = line_numbers.before {
1155 write!(
1156 formatter.labeled(labels.before).labeled("line_number"),
1157 "{line_number:>4}"
1158 )?;
1159 write!(formatter, " ")?;
1160 } else {
1161 write!(formatter, " ")?;
1162 }
1163 if let Some(line_number) = line_numbers.after {
1164 write!(
1165 formatter.labeled(labels.after).labeled("line_number"),
1166 "{line_number:>4}"
1167 )?;
1168 write!(formatter, ": ")?;
1169 } else {
1170 write!(formatter, " : ")?;
1171 }
1172 Ok(())
1173}
1174
1175fn show_color_words_inline_hunks(
1177 formatter: &mut dyn Formatter,
1178 line_hunks: &[(DiffLineHunkSide, &BStr)],
1179 labels: Diff<&str>,
1180) -> io::Result<()> {
1181 for (side, data) in line_hunks {
1182 let label = match side {
1183 DiffLineHunkSide::Both => None,
1184 DiffLineHunkSide::Left => Some(labels.before),
1185 DiffLineHunkSide::Right => Some(labels.after),
1186 };
1187 if let Some(label) = label {
1188 formatter.labeled(label).labeled("token").write_all(data)?;
1189 } else {
1190 formatter.write_all(data)?;
1191 }
1192 }
1193 let (_, data) = line_hunks.last().expect("diff line must not be empty");
1194 if !data.ends_with(b"\n") {
1195 writeln!(formatter)?;
1196 }
1197 Ok(())
1198}
1199
1200fn show_color_words_single_sided_line(
1202 formatter: &mut dyn Formatter,
1203 tokens: &[(DiffTokenType, &[u8])],
1204 label: &str,
1205) -> io::Result<()> {
1206 show_diff_line_tokens(*formatter.labeled(label), tokens)?;
1207 let (_, data) = tokens.last().expect("diff line must not be empty");
1208 if !data.ends_with(b"\n") {
1209 writeln!(formatter)?;
1210 }
1211 Ok(())
1212}
1213
1214fn count_diff_alternation(diff_hunks: &[DiffHunk]) -> usize {
1227 diff_hunks
1228 .iter()
1229 .filter_map(|hunk| match hunk.kind {
1230 DiffHunkKind::Matching => None,
1231 DiffHunkKind::Different => Some(&hunk.contents),
1232 })
1233 .flat_map(|contents| contents.iter().positions(|content| !content.is_empty()))
1235 .dedup()
1237 .count()
1238}
1239
1240fn split_diff_hunks_by_matching_newline<'a, 'b>(
1242 diff_hunks: &'a [DiffHunk<'b>],
1243) -> impl Iterator<Item = &'a [DiffHunk<'b>]> {
1244 diff_hunks.split_inclusive(|hunk| match hunk.kind {
1245 DiffHunkKind::Matching => hunk.contents.iter().all(|content| content.contains(&b'\n')),
1246 DiffHunkKind::Different => false,
1247 })
1248}
1249
1250fn diff_content(
1251 path: &RepoPath,
1252 value: MaterializedTreeValue,
1253 materialize_options: &ConflictMaterializeOptions,
1254) -> BackendResult<FileContent<BString>> {
1255 diff_content_with(
1256 path,
1257 value,
1258 |content| content,
1259 |contents, labels| {
1260 materialize_merge_result_to_bytes(&contents, &labels, materialize_options)
1261 },
1262 )
1263}
1264
1265#[derive(PartialEq, Eq, Debug)]
1266struct DiffContentAsMerge {
1267 file_content: Merge<BString>,
1268 conflict_labels: ConflictLabels,
1269}
1270
1271impl DiffContentAsMerge {
1272 pub fn is_empty(&self) -> bool {
1273 self.file_content
1274 .as_resolved()
1275 .is_some_and(|c| c.is_empty())
1276 }
1277}
1278
1279fn diff_content_as_merge(
1280 path: &RepoPath,
1281 value: MaterializedTreeValue,
1282) -> BackendResult<FileContent<DiffContentAsMerge>> {
1283 diff_content_with(
1284 path,
1285 value,
1286 |contents| DiffContentAsMerge {
1287 file_content: Merge::resolved(contents),
1288 conflict_labels: ConflictLabels::unlabeled(),
1289 },
1290 |contents, labels| DiffContentAsMerge {
1291 file_content: contents,
1292 conflict_labels: labels,
1293 },
1294 )
1295}
1296
1297fn diff_content_with<T>(
1298 path: &RepoPath,
1299 value: MaterializedTreeValue,
1300 map_resolved: impl FnOnce(BString) -> T,
1301 map_conflict: impl FnOnce(Merge<BString>, ConflictLabels) -> T,
1302) -> BackendResult<FileContent<T>> {
1303 match value {
1304 MaterializedTreeValue::Absent => Ok(FileContent {
1305 is_binary: false,
1306 contents: map_resolved(BString::default()),
1307 }),
1308 MaterializedTreeValue::AccessDenied(err) => Ok(FileContent {
1309 is_binary: false,
1310 contents: map_resolved(format!("Access denied: {err}").into()),
1311 }),
1312 MaterializedTreeValue::File(mut file) => {
1313 file_content_for_diff(path, &mut file, map_resolved)
1314 }
1315 MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent {
1316 is_binary: false,
1318 contents: map_resolved(target.into()),
1319 }),
1320 MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent {
1321 is_binary: false,
1322 contents: map_resolved(format!("Git submodule checked out at {id}").into()),
1323 }),
1324 MaterializedTreeValue::FileConflict(file) => Ok(FileContent {
1326 is_binary: false,
1327 contents: map_conflict(file.contents, file.labels),
1328 }),
1329 MaterializedTreeValue::OtherConflict { id, labels } => Ok(FileContent {
1330 is_binary: false,
1331 contents: map_resolved(id.describe(&labels).into()),
1332 }),
1333 MaterializedTreeValue::Tree(id) => {
1334 panic!("Unexpected tree with id {id:?} in diff at path {path:?}");
1335 }
1336 }
1337}
1338
1339fn basic_diff_file_type(value: &MaterializedTreeValue) -> &'static str {
1340 match value {
1341 MaterializedTreeValue::Absent => {
1342 panic!("absent path in diff");
1343 }
1344 MaterializedTreeValue::AccessDenied(_) => "access denied",
1345 MaterializedTreeValue::File(file) => {
1346 if file.executable {
1347 "executable file"
1348 } else {
1349 "regular file"
1350 }
1351 }
1352 MaterializedTreeValue::Symlink { .. } => "symlink",
1353 MaterializedTreeValue::Tree(_) => "tree",
1354 MaterializedTreeValue::GitSubmodule(_) => "Git submodule",
1355 MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. } => {
1356 "conflict"
1357 }
1358 }
1359}
1360
1361pub async fn show_color_words_diff(
1362 formatter: &mut dyn Formatter,
1363 store: &Store,
1364 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1365 conflict_labels: Diff<&ConflictLabels>,
1366 path_converter: &RepoPathUiConverter,
1367 options: &ColorWordsDiffOptions,
1368 marker_style: ConflictMarkerStyle,
1369) -> Result<(), DiffRenderError> {
1370 let materialize_options = ConflictMaterializeOptions {
1371 marker_style,
1372 marker_len: None,
1373 merge: store.merge_options().clone(),
1374 };
1375 let empty_content = || Merge::resolved(BString::default());
1376 let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1377 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1378 let left_path = path.source();
1379 let right_path = path.target();
1380 let left_ui_path = path_converter.format_file_path(left_path);
1381 let right_ui_path = path_converter.format_file_path(right_path);
1382 let Diff {
1383 before: left_value,
1384 after: right_value,
1385 } = values?;
1386
1387 match (&left_value, &right_value) {
1388 (MaterializedTreeValue::AccessDenied(source), _) => {
1389 write!(
1390 formatter.labeled("access-denied"),
1391 "Access denied to {left_ui_path}:"
1392 )?;
1393 writeln!(formatter, " {source}")?;
1394 continue;
1395 }
1396 (_, MaterializedTreeValue::AccessDenied(source)) => {
1397 write!(
1398 formatter.labeled("access-denied"),
1399 "Access denied to {right_ui_path}:"
1400 )?;
1401 writeln!(formatter, " {source}")?;
1402 continue;
1403 }
1404 _ => {}
1405 }
1406 if left_value.is_absent() {
1407 let description = basic_diff_file_type(&right_value);
1408 writeln!(
1409 formatter.labeled("header"),
1410 "Added {description} {right_ui_path}:"
1411 )?;
1412 let right_content = diff_content_as_merge(right_path, right_value)?;
1413 if right_content.contents.is_empty() {
1414 writeln!(formatter.labeled("empty"), " (empty)")?;
1415 } else if right_content.is_binary {
1416 writeln!(formatter.labeled("binary"), " (binary)")?;
1417 } else {
1418 show_color_words_diff_hunks(
1419 formatter,
1420 Diff::new(&empty_content(), &right_content.contents.file_content),
1421 Diff::new(
1422 &ConflictLabels::unlabeled(),
1423 &right_content.contents.conflict_labels,
1424 ),
1425 options,
1426 &materialize_options,
1427 )?;
1428 }
1429 } else if right_value.is_present() {
1430 let description = match (&left_value, &right_value) {
1431 (MaterializedTreeValue::File(left), MaterializedTreeValue::File(right)) => {
1432 if left.executable && right.executable {
1433 "Modified executable file".to_string()
1434 } else if left.executable {
1435 "Executable file became non-executable at".to_string()
1436 } else if right.executable {
1437 "Non-executable file became executable at".to_string()
1438 } else {
1439 "Modified regular file".to_string()
1440 }
1441 }
1442 (
1443 MaterializedTreeValue::FileConflict(_)
1444 | MaterializedTreeValue::OtherConflict { .. },
1445 MaterializedTreeValue::FileConflict(_)
1446 | MaterializedTreeValue::OtherConflict { .. },
1447 ) => "Modified conflict in".to_string(),
1448 (
1449 MaterializedTreeValue::FileConflict(_)
1450 | MaterializedTreeValue::OtherConflict { .. },
1451 _,
1452 ) => "Resolved conflict in".to_string(),
1453 (
1454 _,
1455 MaterializedTreeValue::FileConflict(_)
1456 | MaterializedTreeValue::OtherConflict { .. },
1457 ) => "Created conflict in".to_string(),
1458 (MaterializedTreeValue::Symlink { .. }, MaterializedTreeValue::Symlink { .. }) => {
1459 "Symlink target changed at".to_string()
1460 }
1461 (_, _) => {
1462 let left_type = basic_diff_file_type(&left_value);
1463 let right_type = basic_diff_file_type(&right_value);
1464 let (first, rest) = left_type.split_at(1);
1465 format!(
1466 "{}{} became {} at",
1467 first.to_ascii_uppercase(),
1468 rest,
1469 right_type
1470 )
1471 }
1472 };
1473 let left_content = diff_content_as_merge(left_path, left_value)?;
1474 let right_content = diff_content_as_merge(right_path, right_value)?;
1475 if left_path == right_path {
1476 writeln!(
1477 formatter.labeled("header"),
1478 "{description} {right_ui_path}:"
1479 )?;
1480 } else {
1481 writeln!(
1482 formatter.labeled("header"),
1483 "{description} {right_ui_path} ({left_ui_path} => {right_ui_path}):"
1484 )?;
1485 }
1486 if left_content.is_binary || right_content.is_binary {
1487 writeln!(formatter.labeled("binary"), " (binary)")?;
1488 } else if left_content.contents != right_content.contents {
1489 show_color_words_diff_hunks(
1490 formatter,
1491 Diff::new(
1492 &left_content.contents.file_content,
1493 &right_content.contents.file_content,
1494 ),
1495 Diff::new(
1496 &left_content.contents.conflict_labels,
1497 &right_content.contents.conflict_labels,
1498 ),
1499 options,
1500 &materialize_options,
1501 )?;
1502 }
1503 } else {
1504 let description = basic_diff_file_type(&left_value);
1505 writeln!(
1506 formatter.labeled("header"),
1507 "Removed {description} {right_ui_path}:"
1508 )?;
1509 let left_content = diff_content_as_merge(left_path, left_value)?;
1510 if left_content.contents.is_empty() {
1511 writeln!(formatter.labeled("empty"), " (empty)")?;
1512 } else if left_content.is_binary {
1513 writeln!(formatter.labeled("binary"), " (binary)")?;
1514 } else {
1515 show_color_words_diff_hunks(
1516 formatter,
1517 Diff::new(&left_content.contents.file_content, &empty_content()),
1518 Diff::new(
1519 &left_content.contents.conflict_labels,
1520 &ConflictLabels::unlabeled(),
1521 ),
1522 options,
1523 &materialize_options,
1524 )?;
1525 }
1526 }
1527 }
1528 Ok(())
1529}
1530
1531#[expect(clippy::too_many_arguments)]
1532pub async fn show_file_by_file_diff(
1533 ui: &Ui,
1534 formatter: &mut dyn Formatter,
1535 store: &Store,
1536 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1537 conflict_labels: Diff<&ConflictLabels>,
1538 path_converter: &RepoPathUiConverter,
1539 tool: &ExternalMergeTool,
1540 marker_style: ConflictMarkerStyle,
1541 width: usize,
1542) -> Result<(), DiffRenderError> {
1543 let materialize_options = ConflictMaterializeOptions {
1544 marker_style,
1545 marker_len: None,
1546 merge: store.merge_options().clone(),
1547 };
1548 let create_file = |path: &RepoPath,
1549 wc_dir: &Path,
1550 value: MaterializedTreeValue|
1551 -> Result<PathBuf, DiffRenderError> {
1552 let fs_path = path.to_fs_path(wc_dir)?;
1553 std::fs::create_dir_all(fs_path.parent().unwrap())?;
1554 let content = diff_content(path, value, &materialize_options)?;
1555 std::fs::write(&fs_path, content.contents)?;
1556 Ok(fs_path)
1557 };
1558
1559 let temp_dir = new_utf8_temp_dir("jj-diff-")?;
1560 let left_wc_dir = temp_dir.path().join("left");
1561 let right_wc_dir = temp_dir.path().join("right");
1562 let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1563 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1564 let Diff {
1565 before: left_value,
1566 after: right_value,
1567 } = values?;
1568 let left_path = path.source();
1569 let right_path = path.target();
1570 let left_ui_path = path_converter.format_file_path(left_path);
1571 let right_ui_path = path_converter.format_file_path(right_path);
1572
1573 match (&left_value, &right_value) {
1574 (_, MaterializedTreeValue::AccessDenied(source)) => {
1575 write!(
1576 formatter.labeled("access-denied"),
1577 "Access denied to {right_ui_path}:"
1578 )?;
1579 writeln!(formatter, " {source}")?;
1580 continue;
1581 }
1582 (MaterializedTreeValue::AccessDenied(source), _) => {
1583 write!(
1584 formatter.labeled("access-denied"),
1585 "Access denied to {left_ui_path}:"
1586 )?;
1587 writeln!(formatter, " {source}")?;
1588 continue;
1589 }
1590 _ => {}
1591 }
1592 let left_path = create_file(left_path, &left_wc_dir, left_value)?;
1593 let right_path = create_file(right_path, &right_wc_dir, right_value)?;
1594 let patterns = &maplit::hashmap! {
1595 "left" => left_path
1596 .strip_prefix(temp_dir.path())
1597 .expect("path should be relative to temp_dir")
1598 .to_str()
1599 .expect("temp_dir should be valid utf-8")
1600 .to_owned(),
1601 "right" => right_path
1602 .strip_prefix(temp_dir.path())
1603 .expect("path should be relative to temp_dir")
1604 .to_str()
1605 .expect("temp_dir should be valid utf-8")
1606 .to_owned(),
1607 "width" => width.to_string(),
1608 };
1609
1610 let mut writer = formatter.raw()?;
1611 invoke_external_diff(ui, writer.as_mut(), tool, temp_dir.path(), patterns)
1612 .map_err(DiffRenderError::DiffGenerate)?;
1613 }
1614 Ok::<(), DiffRenderError>(())
1615}
1616
1617#[derive(Clone, Debug, Eq, PartialEq)]
1618pub struct UnifiedDiffOptions {
1619 pub context: usize,
1621 pub line_diff: LineDiffOptions,
1623}
1624
1625impl UnifiedDiffOptions {
1626 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
1627 Ok(Self {
1628 context: settings.get("diff.git.context")?,
1629 line_diff: LineDiffOptions::default(),
1630 })
1631 }
1632
1633 fn merge_args(&mut self, args: &DiffFormatArgs) {
1634 if let Some(context) = args.context {
1635 self.context = context;
1636 }
1637 self.line_diff.merge_args(args);
1638 }
1639}
1640
1641fn show_unified_diff_hunks(
1642 formatter: &mut dyn Formatter,
1643 contents: Diff<&BStr>,
1644 options: &UnifiedDiffOptions,
1645) -> io::Result<()> {
1646 fn to_line_number(range: Range<usize>) -> usize {
1654 if range.is_empty() {
1655 range.start
1656 } else {
1657 range.start + 1
1658 }
1659 }
1660
1661 for hunk in unified_diff_hunks(contents, options.context, options.line_diff.compare_mode) {
1662 writeln!(
1663 formatter.labeled("hunk_header"),
1664 "@@ -{},{} +{},{} @@",
1665 to_line_number(hunk.left_line_range.clone()),
1666 hunk.left_line_range.len(),
1667 to_line_number(hunk.right_line_range.clone()),
1668 hunk.right_line_range.len()
1669 )?;
1670 for (line_type, tokens) in &hunk.lines {
1671 let (label, sigil) = match line_type {
1672 DiffLineType::Context => ("context", " "),
1673 DiffLineType::Removed => ("removed", "-"),
1674 DiffLineType::Added => ("added", "+"),
1675 };
1676 write!(formatter.labeled(label), "{sigil}")?;
1677 show_diff_line_tokens(*formatter.labeled(label), tokens)?;
1678 let (_, content) = tokens.last().expect("hunk line must not be empty");
1679 if !content.ends_with(b"\n") {
1680 write!(formatter, "\n\\ No newline at end of file\n")?;
1681 }
1682 }
1683 }
1684 Ok(())
1685}
1686
1687fn show_diff_line_tokens(
1688 formatter: &mut dyn Formatter,
1689 tokens: &[(DiffTokenType, &[u8])],
1690) -> io::Result<()> {
1691 for (token_type, content) in tokens {
1692 match token_type {
1693 DiffTokenType::Matching => formatter.write_all(content)?,
1694 DiffTokenType::Different => formatter.labeled("token").write_all(content)?,
1695 }
1696 }
1697 Ok(())
1698}
1699
1700pub async fn show_git_diff(
1701 formatter: &mut dyn Formatter,
1702 store: &Store,
1703 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1704 conflict_labels: Diff<&ConflictLabels>,
1705 options: &UnifiedDiffOptions,
1706 marker_style: ConflictMarkerStyle,
1707) -> Result<(), DiffRenderError> {
1708 let materialize_options = ConflictMaterializeOptions {
1709 marker_style,
1710 marker_len: None,
1711 merge: store.merge_options().clone(),
1712 };
1713 let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1714 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1715 let left_path = path.source();
1716 let right_path = path.target();
1717 let left_path_string = left_path.as_internal_file_string();
1718 let right_path_string = right_path.as_internal_file_string();
1719 let values = values?;
1720
1721 let left_part = git_diff_part(left_path, values.before, &materialize_options)?;
1722 let right_part = git_diff_part(right_path, values.after, &materialize_options)?;
1723
1724 {
1725 let mut formatter = formatter.labeled("file_header");
1726 writeln!(
1727 formatter,
1728 "diff --git a/{left_path_string} b/{right_path_string}"
1729 )?;
1730 let left_hash = &left_part.hash;
1731 let right_hash = &right_part.hash;
1732 match (left_part.mode, right_part.mode) {
1733 (None, Some(right_mode)) => {
1734 writeln!(formatter, "new file mode {right_mode}")?;
1735 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1736 }
1737 (Some(left_mode), None) => {
1738 writeln!(formatter, "deleted file mode {left_mode}")?;
1739 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1740 }
1741 (Some(left_mode), Some(right_mode)) => {
1742 if let Some(op) = path.copy_operation() {
1743 let operation = match op {
1744 CopyOperation::Copy => "copy",
1745 CopyOperation::Rename => "rename",
1746 };
1747 writeln!(formatter, "{operation} from {left_path_string}")?;
1749 writeln!(formatter, "{operation} to {right_path_string}")?;
1750 }
1751 if left_mode != right_mode {
1752 writeln!(formatter, "old mode {left_mode}")?;
1753 writeln!(formatter, "new mode {right_mode}")?;
1754 if left_hash != right_hash {
1755 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1756 }
1757 } else if left_hash != right_hash {
1758 writeln!(formatter, "index {left_hash}..{right_hash} {left_mode}")?;
1759 }
1760 }
1761 (None, None) => panic!("either left or right part should be present"),
1762 }
1763 }
1764
1765 if left_part.content.contents == right_part.content.contents {
1766 continue; }
1768
1769 let left_path = match left_part.mode {
1770 Some(_) => format!("a/{left_path_string}"),
1771 None => "/dev/null".to_owned(),
1772 };
1773 let right_path = match right_part.mode {
1774 Some(_) => format!("b/{right_path_string}"),
1775 None => "/dev/null".to_owned(),
1776 };
1777 if left_part.content.is_binary || right_part.content.is_binary {
1778 writeln!(
1780 formatter,
1781 "Binary files {left_path} and {right_path} differ"
1782 )?;
1783 } else {
1784 writeln!(formatter.labeled("file_header"), "--- {left_path}")?;
1785 writeln!(formatter.labeled("file_header"), "+++ {right_path}")?;
1786 show_unified_diff_hunks(
1787 formatter,
1788 Diff::new(&left_part.content.contents, &right_part.content.contents).map(BStr::new),
1789 options,
1790 )?;
1791 }
1792 }
1793 Ok(())
1794}
1795
1796fn show_git_diff_texts<T: AsRef<[u8]>>(
1798 formatter: &mut dyn Formatter,
1799 paths: Diff<&str>,
1800 contents: Diff<&Merge<T>>,
1801 options: &UnifiedDiffOptions,
1802 materialize_options: &ConflictMaterializeOptions,
1803) -> io::Result<()> {
1804 let Diff {
1805 before: left_path,
1806 after: right_path,
1807 } = paths;
1808 {
1809 let mut formatter = formatter.labeled("file_header");
1810 writeln!(formatter, "diff --git a/{left_path} b/{right_path}")?;
1811 writeln!(formatter, "--- {left_path}")?;
1812 writeln!(formatter, "+++ {right_path}")?;
1813 }
1814 let contents = contents.map(|content| match content.as_resolved() {
1815 Some(text) => Cow::Borrowed(BStr::new(text)),
1816 None => Cow::Owned(materialize_merge_result_to_bytes(
1817 content,
1818 &ConflictLabels::unlabeled(),
1819 materialize_options,
1820 )),
1821 });
1822 show_unified_diff_hunks(formatter, contents.as_ref().map(Cow::as_ref), options)
1823}
1824
1825#[instrument(skip_all)]
1826pub async fn show_diff_summary(
1827 formatter: &mut dyn Formatter,
1828 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1829 path_converter: &RepoPathUiConverter,
1830) -> Result<(), DiffRenderError> {
1831 while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
1832 let values = values?;
1833 let status = diff_status(&path, &values);
1834 let (label, sigil) = (status.label(), status.char());
1835 let ui_path = match path.to_diff() {
1836 Some(paths) => path_converter.format_copied_path(paths),
1837 None => path_converter.format_file_path(path.target()),
1838 };
1839 writeln!(formatter.labeled(label), "{sigil} {ui_path}")?;
1840 }
1841 Ok(())
1842}
1843
1844fn diff_status_inner(
1845 path: &CopiesTreeDiffEntryPath,
1846 is_present_before: bool,
1847 is_present_after: bool,
1848) -> DiffEntryStatus {
1849 if let Some(op) = path.copy_operation() {
1850 match op {
1851 CopyOperation::Copy => DiffEntryStatus::Copied,
1852 CopyOperation::Rename => DiffEntryStatus::Renamed,
1853 }
1854 } else {
1855 match (is_present_before, is_present_after) {
1856 (true, true) => DiffEntryStatus::Modified,
1857 (false, true) => DiffEntryStatus::Added,
1858 (true, false) => DiffEntryStatus::Removed,
1859 (false, false) => panic!("values pair must differ"),
1860 }
1861 }
1862}
1863
1864pub fn diff_status(
1865 path: &CopiesTreeDiffEntryPath,
1866 values: &Diff<MergedTreeValue>,
1867) -> DiffEntryStatus {
1868 diff_status_inner(path, values.before.is_present(), values.after.is_present())
1869}
1870
1871#[derive(Clone, Debug, Default, Eq, PartialEq)]
1872pub struct DiffStatOptions {
1873 pub line_diff: LineDiffOptions,
1875}
1876
1877impl DiffStatOptions {
1878 fn merge_args(&mut self, args: &DiffFormatArgs) {
1879 self.line_diff.merge_args(args);
1880 }
1881}
1882
1883#[derive(Clone, Debug)]
1884pub struct DiffStats {
1885 entries: Vec<DiffStatEntry>,
1886}
1887
1888impl DiffStats {
1889 pub async fn calculate(
1891 store: &Store,
1892 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1893 options: &DiffStatOptions,
1894 marker_style: ConflictMarkerStyle,
1895 ) -> BackendResult<Self> {
1896 let materialize_options = ConflictMaterializeOptions {
1897 marker_style,
1898 marker_len: None,
1899 merge: store.merge_options().clone(),
1900 };
1901 let conflict_labels = ConflictLabels::unlabeled();
1902 let entries = materialized_diff_stream(
1903 store,
1904 tree_diff,
1905 Diff::new(&conflict_labels, &conflict_labels),
1906 )
1907 .map(|MaterializedTreeDiffEntry { path, values }| {
1908 let values = values?;
1909 let status =
1910 diff_status_inner(&path, values.before.is_present(), values.after.is_present());
1911 let left_content = diff_content(path.source(), values.before, &materialize_options)?;
1912 let right_content = diff_content(path.target(), values.after, &materialize_options)?;
1913 let stat = get_diff_stat_entry(
1914 path,
1915 status,
1916 Diff::new(&left_content, &right_content),
1917 options,
1918 );
1919 BackendResult::Ok(stat)
1920 })
1921 .try_collect()
1922 .await?;
1923 Ok(Self { entries })
1924 }
1925
1926 pub fn entries(&self) -> &[DiffStatEntry] {
1928 &self.entries
1929 }
1930
1931 pub fn count_total_added(&self) -> usize {
1933 self.entries
1934 .iter()
1935 .filter_map(|stat| stat.added_removed.map(|(added, _)| added))
1936 .sum()
1937 }
1938
1939 pub fn count_total_removed(&self) -> usize {
1941 self.entries
1942 .iter()
1943 .filter_map(|stat| stat.added_removed.map(|(_, removed)| removed))
1944 .sum()
1945 }
1946}
1947
1948#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1949pub enum DiffEntryStatus {
1950 Added,
1951 Removed,
1952 Modified,
1953 Copied,
1954 Renamed,
1955}
1956
1957impl DiffEntryStatus {
1958 pub fn label(&self) -> &'static str {
1959 match self {
1960 Self::Added => "added",
1961 Self::Removed => "removed",
1962 Self::Modified => "modified",
1963 Self::Copied => "copied",
1964 Self::Renamed => "renamed",
1965 }
1966 }
1967
1968 pub fn char(&self) -> char {
1969 match self {
1970 Self::Added => 'A',
1971 Self::Removed => 'D',
1972 Self::Modified => 'M',
1973 Self::Copied => 'C',
1974 Self::Renamed => 'R',
1975 }
1976 }
1977}
1978
1979#[derive(Clone, Debug)]
1980pub struct DiffStatEntry {
1981 pub path: CopiesTreeDiffEntryPath,
1982 pub added_removed: Option<(usize, usize)>,
1984 pub bytes_delta: isize,
1986 pub status: DiffEntryStatus,
1987}
1988
1989fn get_diff_stat_entry(
1990 path: CopiesTreeDiffEntryPath,
1991 status: DiffEntryStatus,
1992 contents: Diff<&FileContent<BString>>,
1993 options: &DiffStatOptions,
1994) -> DiffStatEntry {
1995 let added_removed = if contents.before.is_binary || contents.after.is_binary {
1996 None
1997 } else {
1998 let diff = diff_by_line(
1999 contents.map(|content| &content.contents).into_array(),
2000 &options.line_diff.compare_mode,
2001 );
2002 let mut added = 0;
2003 let mut removed = 0;
2004 for hunk in diff.hunks() {
2005 match hunk.kind {
2006 DiffHunkKind::Matching => {}
2007 DiffHunkKind::Different => {
2008 let [left, right] = hunk.contents[..].try_into().unwrap();
2009 removed += left.split_inclusive(|b| *b == b'\n').count();
2010 added += right.split_inclusive(|b| *b == b'\n').count();
2011 }
2012 }
2013 }
2014 Some((added, removed))
2015 };
2016
2017 DiffStatEntry {
2018 path,
2019 added_removed,
2020 bytes_delta: contents.after.contents.len() as isize
2021 - contents.before.contents.len() as isize,
2022 status,
2023 }
2024}
2025
2026pub fn show_diff_stats(
2027 formatter: &mut dyn Formatter,
2028 stats: &DiffStats,
2029 path_converter: &RepoPathUiConverter,
2030 display_width: usize,
2031) -> io::Result<()> {
2032 let ui_paths = stats
2033 .entries()
2034 .iter()
2035 .map(|stat| match stat.path.to_diff() {
2036 Some(paths) => path_converter.format_copied_path(paths),
2037 None => path_converter.format_file_path(stat.path.target()),
2038 })
2039 .collect_vec();
2040
2041 let mut max_path_width = ui_paths.iter().map(|s| s.width()).max().unwrap_or(0);
2052
2053 let available_width = max(display_width.saturating_sub(" | ".len()), 8);
2056
2057 let max_diffs = stats
2060 .entries()
2061 .iter()
2062 .filter_map(|stat| {
2063 let (added, removed) = stat.added_removed?;
2064 Some(added + removed)
2065 })
2066 .max();
2067 let diff_number_width = max_diffs.map_or(0, |n| n.to_string().len());
2068 if max_diffs.is_some() {
2069 let width = diff_number_width + " ".len();
2070 max_path_width =
2072 max_path_width.min((0.7 * available_width.saturating_sub(width) as f64) as usize);
2073 }
2074
2075 let max_bytes = stats
2078 .entries
2079 .iter()
2080 .filter(|stat| stat.added_removed.is_none())
2081 .map(|stat| stat.bytes_delta.abs())
2082 .max();
2083 if let Some(max) = max_bytes {
2084 let width = if max > 0 {
2085 format!("(binary) {max:+} bytes").len()
2086 } else {
2087 "(binary)".len()
2088 };
2089 max_path_width = max_path_width.min(available_width.saturating_sub(width));
2090 }
2091
2092 let max_bar_width =
2095 available_width.saturating_sub(max_path_width + diff_number_width + " ".len());
2096 let factor = match max_diffs {
2097 Some(max) if max > max_bar_width => max_bar_width as f64 / max as f64,
2098 _ => 1.0,
2099 };
2100
2101 for (stat, ui_path) in iter::zip(stats.entries(), &ui_paths) {
2102 let (path, path_width) = text_util::elide_start(ui_path, "...", max_path_width);
2104 let path_pad_width = max_path_width - path_width;
2105 write!(
2106 formatter,
2107 "{path}{:path_pad_width$} | ",
2108 "", )?;
2110 if let Some((added, removed)) = stat.added_removed {
2111 let bar_length = ((added + removed) as f64 * factor) as usize;
2112 let bar_length = bar_length.max(usize::from(added > 0) + usize::from(removed > 0));
2119 let (bar_added, bar_removed) = if added < removed {
2120 let len = (added as f64 * factor).ceil() as usize;
2121 (len, bar_length - len)
2122 } else {
2123 let len = (removed as f64 * factor).ceil() as usize;
2124 (bar_length - len, len)
2125 };
2126 write!(
2127 formatter,
2128 "{:>diff_number_width$}{}",
2129 added + removed,
2130 if bar_added + bar_removed > 0 { " " } else { "" },
2131 )?;
2132 write!(formatter.labeled("added"), "{}", "+".repeat(bar_added))?;
2133 writeln!(formatter.labeled("removed"), "{}", "-".repeat(bar_removed))?;
2134 } else {
2135 write!(formatter.labeled("binary"), "(binary)")?;
2136 if stat.bytes_delta != 0 {
2137 let label = if stat.bytes_delta < 0 {
2138 "removed"
2139 } else {
2140 "added"
2141 };
2142 write!(formatter.labeled(label), " {:+}", stat.bytes_delta)?;
2143 write!(formatter, " bytes")?;
2144 }
2145 writeln!(formatter)?;
2146 }
2147 }
2148
2149 let total_added = stats.count_total_added();
2150 let total_removed = stats.count_total_removed();
2151 let total_files = stats.entries().len();
2152 writeln!(
2153 formatter.labeled("stat-summary"),
2154 "{} file{} changed, {} insertion{}(+), {} deletion{}(-)",
2155 total_files,
2156 if total_files == 1 { "" } else { "s" },
2157 total_added,
2158 if total_added == 1 { "" } else { "s" },
2159 total_removed,
2160 if total_removed == 1 { "" } else { "s" },
2161 )?;
2162 Ok(())
2163}
2164
2165pub async fn show_types(
2166 formatter: &mut dyn Formatter,
2167 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2168 path_converter: &RepoPathUiConverter,
2169) -> Result<(), DiffRenderError> {
2170 while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
2171 let values = values?;
2172 let ui_path = match path.to_diff() {
2173 Some(paths) => path_converter.format_copied_path(paths),
2174 None => path_converter.format_file_path(path.target()),
2175 };
2176 writeln!(
2177 formatter.labeled("modified"),
2178 "{before}{after} {ui_path}",
2179 before = diff_summary_char(&values.before),
2180 after = diff_summary_char(&values.after)
2181 )?;
2182 }
2183 Ok(())
2184}
2185
2186fn diff_summary_char(value: &MergedTreeValue) -> char {
2187 match value.as_resolved() {
2188 Some(None) => '-',
2189 Some(Some(TreeValue::File { .. })) => 'F',
2190 Some(Some(TreeValue::Symlink(_))) => 'L',
2191 Some(Some(TreeValue::GitSubmodule(_))) => 'G',
2192 None => 'C',
2193 Some(Some(TreeValue::Tree(_))) => {
2194 panic!("Unexpected {value:?} in diff")
2195 }
2196 }
2197}
2198
2199pub async fn show_names(
2200 formatter: &mut dyn Formatter,
2201 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2202 path_converter: &RepoPathUiConverter,
2203) -> io::Result<()> {
2204 while let Some(CopiesTreeDiffEntry { path, .. }) = tree_diff.next().await {
2205 writeln!(
2206 formatter,
2207 "{}",
2208 path_converter.format_file_path(path.target())
2209 )?;
2210 }
2211 Ok(())
2212}
2213
2214pub async fn show_templated(
2215 formatter: &mut dyn Formatter,
2216 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2217 template: &TemplateRenderer<'_, commit_templater::TreeDiffEntry>,
2218) -> Result<(), DiffRenderError> {
2219 while let Some(entry) = tree_diff.next().await {
2220 let entry = commit_templater::TreeDiffEntry::from_backend_entry_with_copies(entry)?;
2221 template.format(&entry, formatter)?;
2222 }
2223 Ok(())
2224}