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