1use std::io::Write;
40
41use clap::Parser;
42use mkit_core::hash::Hash;
43use mkit_core::layout::RepoLayout;
44use mkit_core::object::{EntryMode, Object};
45use mkit_core::ops::merge::find_merge_base;
46use mkit_core::ops::{
47 DEFAULT_CONTEXT_LINES, DiffEntry, DiffKind, WhitespaceMode, detect_content_renames, diff_trees,
48 unified_hunks_opts,
49};
50use mkit_core::refs;
51use mkit_core::store::{DisplaySource, EphemeralSink, ObjectSource, ObjectStore};
52use mkit_core::worktree;
53
54use super::revspec;
55use crate::clap_shim;
56use crate::exit;
57use crate::format;
58
59mod stat;
60pub(super) use stat::render_stat;
61
62#[derive(Debug, Parser)]
63#[command(
64 name = "mkit diff",
65 about = "Show changes as a unified patch (HEAD vs worktree, --staged, or two trees)."
66)]
67#[allow(clippy::struct_excessive_bools)] struct DiffOpts {
69 #[arg(long, visible_alias = "cached")]
72 staged: bool,
73
74 #[arg(long, conflicts_with = "name_status")]
77 name_only: bool,
78
79 #[arg(long)]
82 name_status: bool,
83
84 #[arg(long, conflicts_with_all = ["name_only", "name_status"])]
88 stat: bool,
89
90 #[arg(long = "merge-base", conflicts_with = "staged")]
95 merge_base: bool,
96
97 #[arg(short = 'z')]
102 z: bool,
103
104 #[arg(long = "exit-code")]
108 exit_code: bool,
109
110 #[arg(long)]
112 quiet: bool,
113
114 #[arg(long = "no-renames")]
117 no_renames: bool,
118
119 #[arg(short = 'M', long = "find-renames", value_name = "N", num_args = 0..=1, default_missing_value = "100")]
123 find_renames: Option<String>,
124
125 #[arg(long = "color", value_name = "WHEN", num_args = 0..=1, require_equals = true, default_missing_value = "always", conflicts_with = "no_color")]
129 color: Option<String>,
130
131 #[arg(long = "no-color")]
133 no_color: bool,
134
135 #[arg(short = 'w', long = "ignore-all-space")]
141 ignore_all_space: bool,
142
143 #[arg(short = 'b', long = "ignore-space-change")]
148 ignore_space_change: bool,
149
150 #[arg(short = 'U', long = "unified", value_name = "N")]
153 unified: Option<usize>,
154
155 args: Vec<String>,
161}
162
163impl DiffOpts {
164 fn whitespace_mode(&self) -> WhitespaceMode {
168 if self.ignore_all_space {
169 WhitespaceMode::IgnoreAllSpace
170 } else if self.ignore_space_change {
171 WhitespaceMode::IgnoreSpaceChange
172 } else {
173 WhitespaceMode::Exact
174 }
175 }
176}
177
178#[must_use]
179pub fn run(args: &[String]) -> u8 {
180 let opts = match clap_shim::parse::<DiffOpts>("mkit diff", args) {
181 Ok(o) => o,
182 Err(code) => return code,
183 };
184 if opts.z && !(opts.name_only || opts.name_status) {
188 return emit_err(
189 "`-z` is only valid with `--name-only` or `--name-status`",
190 exit::USAGE,
191 );
192 }
193 let Some(color_choice) = crate::term::ColorChoice::parse(opts.color.as_deref()) else {
194 return emit_err("--color expects always, auto, or never", exit::USAGE);
195 };
196 let use_color = !opts.no_color
197 && color_choice.resolve(std::io::IsTerminal::is_terminal(&std::io::stdout()));
198 let ws_mode = opts.whitespace_mode();
199 let context = opts.unified.unwrap_or(DEFAULT_CONTEXT_LINES);
200 let cwd = match std::env::current_dir() {
201 Ok(p) => p,
202 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
203 };
204 let layout = match super::resolve_layout(&cwd) {
205 Ok(layout) => layout,
206 Err(code) => return code,
207 };
208 let store = match ObjectStore::open(&layout) {
209 Ok(s) => s,
210 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
211 };
212
213 let snapshot = EphemeralSink::new(&store);
217
218 let (old_tree, new_tree, pathspecs) = match resolve_diff_endpoints(
219 &store,
220 &snapshot,
221 &layout,
222 opts.staged,
223 opts.merge_base,
224 &opts.args,
225 ) {
226 Ok(v) => v,
227 Err((msg, code)) => return emit_err(&msg, code),
228 };
229
230 let mut result = match diff_trees(&snapshot, old_tree, new_tree) {
231 Ok(r) => r,
232 Err(e) => return emit_err(&format!("diff: {e}"), exit::GENERAL_ERROR),
233 };
234
235 if let Some(t) = &opts.find_renames {
240 let n = t.trim_end_matches('%');
241 if !n.is_empty() && n.parse::<u8>().is_err() {
242 return emit_err(&format!("invalid --find-renames value: {t}"), exit::USAGE);
243 }
244 }
245 if !opts.no_renames
246 && let Err(e) = detect_content_renames(&snapshot, &mut result.entries)
247 {
248 return emit_err(&format!("rename detection: {e}"), exit::GENERAL_ERROR);
249 }
250
251 let normalized: Vec<String> = pathspecs.iter().map(|p| normalize_pathspec(p)).collect();
252 let selected: Vec<&mkit_core::ops::DiffEntry> = result
253 .entries
254 .iter()
255 .filter(|e| normalized.is_empty() || path_matches_any(&e.path, &normalized))
256 .collect();
257
258 let report_exit = opts.exit_code || opts.quiet;
261 let diff_status = if report_exit && !selected.is_empty() {
262 exit::GENERAL_ERROR
263 } else {
264 exit::OK
265 };
266 if opts.quiet {
267 return diff_status;
268 }
269
270 let mut stdout = std::io::stdout().lock();
271 if opts.stat {
272 return match render_stat(&mut stdout, &snapshot, selected.into_iter()) {
274 Ok(()) => diff_status,
275 Err(msg) => emit_err(&msg, exit::GENERAL_ERROR),
276 };
277 }
278 let display = DisplaySource::new(&snapshot);
282 for e in selected {
283 let res = if opts.name_only || opts.name_status {
284 emit_entry_name(&mut stdout, e, opts.name_status, opts.z);
285 Ok(())
286 } else if use_color {
287 let mut buf: Vec<u8> = Vec::new();
290 match emit_entry_patch(&mut buf, &display, e, context, ws_mode) {
291 Ok(()) => stdout
295 .write_all(&colorize_patch(&buf))
296 .map_err(|err| format!("write: {err}")),
297 Err(msg) => Err(msg),
298 }
299 } else {
300 emit_entry_patch(&mut stdout, &display, e, context, ws_mode)
301 };
302 if let Err(msg) = res {
303 return emit_err(&msg, exit::GENERAL_ERROR);
304 }
305 }
306 diff_status
307}
308
309fn colorize_patch(text: &[u8]) -> Vec<u8> {
315 const RESET: &[u8] = b"\x1b[0m";
316 let mut out: Vec<u8> = Vec::with_capacity(text.len() + 64);
317 for line in text.split_inclusive(|&b| b == b'\n') {
318 let (body, nl): (&[u8], &[u8]) = if line.last() == Some(&b'\n') {
319 (&line[..line.len() - 1], b"\n")
320 } else {
321 (line, b"")
322 };
323 let code: Option<&[u8]> = if body.starts_with(b"@@") {
324 Some(b"\x1b[36m") } else if body.starts_with(b"diff ")
326 || body.starts_with(b"index ")
327 || body.starts_with(b"new file")
328 || body.starts_with(b"deleted file")
329 || body.starts_with(b"old mode")
330 || body.starts_with(b"new mode")
331 || body.starts_with(b"rename ")
332 || body.starts_with(b"similarity ")
333 || body.starts_with(b"--- ")
334 || body.starts_with(b"+++ ")
335 {
336 Some(b"\x1b[1m") } else if body.first() == Some(&b'+') {
338 Some(b"\x1b[32m") } else if body.first() == Some(&b'-') {
340 Some(b"\x1b[31m") } else {
342 None
343 };
344 match code {
345 Some(c) => {
346 out.extend_from_slice(c);
347 out.extend_from_slice(body);
348 out.extend_from_slice(RESET);
349 out.extend_from_slice(nl);
350 }
351 None => out.extend_from_slice(line),
352 }
353 }
354 out
355}
356
357fn c_quote_name(path: &str) -> String {
360 super::c_quote_path(path).unwrap_or_else(|| path.to_string())
361}
362
363fn name_status_letter(kind: DiffKind) -> char {
367 match kind {
368 DiffKind::Added => 'A',
369 DiffKind::Removed => 'D',
370 DiffKind::Modified => 'M',
371 DiffKind::ModeChanged => 'T',
372 DiffKind::Renamed => 'R',
376 }
377}
378
379fn emit_entry_name(out: &mut impl Write, e: &DiffEntry, name_status: bool, z: bool) {
388 if name_status && e.kind == DiffKind::Renamed {
392 let src = e.old_path.as_deref().unwrap_or(&e.path);
393 if z {
394 let _ = write!(out, "R100\0{src}\0{}\0", e.path);
395 } else {
396 let sq = super::c_quote_path(src).unwrap_or_else(|| src.to_string());
397 let dq = super::c_quote_path(&e.path).unwrap_or_else(|| e.path.clone());
398 let _ = writeln!(out, "R100\t{sq}\t{dq}");
399 }
400 return;
401 }
402 if z {
403 if name_status {
404 let _ = write!(out, "{}\0", name_status_letter(e.kind));
405 }
406 let _ = write!(out, "{}\0", e.path);
407 return;
408 }
409 let path = super::c_quote_path(&e.path);
410 let shown = path.as_deref().unwrap_or(&e.path);
411 if name_status {
412 let _ = writeln!(out, "{}\t{shown}", name_status_letter(e.kind));
413 } else {
414 let _ = writeln!(out, "{shown}");
415 }
416}
417
418type DiffEndpoints = (Option<Hash>, Option<Hash>, Vec<String>);
420
421fn resolve_merge_base_endpoints(
439 store: &ObjectStore,
440 snapshot: &EphemeralSink<'_>,
441 layout: &RepoLayout,
442 args: &[String],
443) -> Result<DiffEndpoints, (String, u8)> {
444 let first = args.first().ok_or_else(|| {
445 (
446 "`--merge-base` requires at least one revision".to_string(),
447 exit::USAGE,
448 )
449 })?;
450 let a = peel_tags(
451 store,
452 revspec::resolve_revision(store, layout, first)
453 .map_err(|e| (format!("bad revision '{first}': {e}"), exit::DATAERR))?,
454 );
455
456 if let Some(second) = args.get(1) {
461 match revspec::resolve_revision(store, layout, second) {
462 Ok(h) => {
463 let b = peel_tags(store, h);
464 let base = merge_base_of(store, a, b)?;
465 let old = object_to_tree(store, &base).map_err(|e| (e, exit::GENERAL_ERROR))?;
466 let new = object_to_tree(store, &b).map_err(|e| (e, exit::GENERAL_ERROR))?;
467 return Ok((Some(old), Some(new), args[2..].to_vec()));
468 }
469 Err(e)
476 if matches!(e, revspec::RevError::Unknown(_))
477 && looks_like_pathspec(layout, second) => {}
478 Err(e) => return Err((format!("bad revision '{second}': {e}"), exit::DATAERR)),
479 }
480 }
481
482 let head = refs::resolve_head(layout)
484 .map_err(|e| (format!("resolve HEAD: {e}"), exit::GENERAL_ERROR))?
485 .ok_or_else(|| {
486 (
487 "HEAD has no commit to take a merge base with".to_string(),
488 exit::GENERAL_ERROR,
489 )
490 })?;
491 let head = peel_tags(store, head);
492 let base = merge_base_of(store, a, head)?;
493 let old = object_to_tree(store, &base).map_err(|e| (e, exit::GENERAL_ERROR))?;
494 let new = worktree_tree_filtered(store, snapshot, layout)?;
495 Ok((Some(old), Some(new), args[1..].to_vec()))
496}
497
498fn merge_base_of(store: &ObjectStore, a: Hash, b: Hash) -> Result<Hash, (String, u8)> {
501 find_merge_base(store, a, b)
502 .map_err(|e| (format!("merge base: {e}"), exit::GENERAL_ERROR))?
503 .ok_or_else(|| {
504 (
505 "no merge base between the given revisions".to_string(),
506 exit::DATAERR,
507 )
508 })
509}
510
511#[allow(clippy::too_many_arguments)]
512fn resolve_diff_endpoints(
513 store: &ObjectStore,
514 snapshot: &EphemeralSink<'_>,
515 layout: &RepoLayout,
516 staged: bool,
517 merge_base: bool,
518 args: &[String],
519) -> Result<DiffEndpoints, (String, u8)> {
520 if merge_base {
524 return resolve_merge_base_endpoints(store, snapshot, layout, args);
525 }
526
527 if staged {
537 if let Some(first) = args.first()
538 && looks_like_rev_request(first)
539 {
540 if revspec::resolve_revision(store, layout, strip_range_end(first).0).is_ok() {
541 return Err((
542 "`--staged` diffs HEAD vs the index; it cannot take an explicit revision"
543 .to_string(),
544 exit::USAGE,
545 ));
546 }
547 return Err((
548 format!("bad revision '{first}': not a known ref, commit, or short hash"),
549 exit::DATAERR,
550 ));
551 }
552 let head = head_tree(store, layout).map_err(|e| (e, exit::GENERAL_ERROR))?;
554 let idx = index_tree(layout, store, snapshot).map_err(|e| (e, exit::GENERAL_ERROR))?;
555 return Ok((head, idx, args.to_vec()));
556 }
557
558 if let Some(first) = args.first()
561 && let Some((a, b)) = split_symmetric(first)
562 {
563 let commit_a = peel_tags(
566 store,
567 revspec::resolve_revision(store, layout, a)
568 .map_err(|e| (format!("bad revision '{a}': {e}"), exit::DATAERR))?,
569 );
570 let commit_b = peel_tags(
571 store,
572 revspec::resolve_revision(store, layout, b)
573 .map_err(|e| (format!("bad revision '{b}': {e}"), exit::DATAERR))?,
574 );
575 let mb = find_merge_base(store, commit_a, commit_b)
576 .map_err(|e| (format!("merge base: {e}"), exit::GENERAL_ERROR))?
577 .ok_or_else(|| {
578 (
579 format!("no merge base between '{a}' and '{b}'"),
580 exit::DATAERR,
581 )
582 })?;
583 let old = object_to_tree(store, &mb).map_err(|e| (e, exit::GENERAL_ERROR))?;
584 let new = object_to_tree(store, &commit_b).map_err(|e| (e, exit::GENERAL_ERROR))?;
585 return Ok((Some(old), Some(new), args[1..].to_vec()));
586 }
587
588 if let Some(first) = args.first()
590 && let Some((a, b)) = split_range(first)
591 {
592 let old = rev_to_tree(store, layout, a)?;
593 let new = rev_to_tree(store, layout, b)?;
594 return Ok((Some(old), Some(new), args[1..].to_vec()));
595 }
596
597 let first_rev = args.first().and_then(|a| try_rev_to_tree(store, layout, a));
599 match first_rev {
600 None => {
601 if let Some(first) = args.first()
606 && looks_like_rev_request(first)
607 {
608 return Err((
609 format!("bad revision '{first}': not a known ref, commit, or short hash"),
610 exit::DATAERR,
611 ));
612 }
613 let head = head_tree(store, layout).map_err(|e| (e, exit::GENERAL_ERROR))?;
614 let new = Some(worktree_tree_filtered(store, snapshot, layout)?);
615 Ok((head, new, args.to_vec()))
616 }
617 Some(Err(e)) => Err(e),
618 Some(Ok(old)) => {
619 let second_rev = args.get(1).and_then(|a| try_rev_to_tree(store, layout, a));
622 match second_rev {
623 Some(Ok(new)) => Ok((Some(old), Some(new), args[2..].to_vec())),
624 Some(Err(e)) => Err(e),
625 None => {
626 let new = Some(worktree_tree_filtered(store, snapshot, layout)?);
627 Ok((Some(old), new, args[1..].to_vec()))
628 }
629 }
630 }
631 }
632}
633
634fn worktree_tree_filtered(
641 store: &ObjectStore,
642 snapshot: &EphemeralSink<'_>,
643 layout: &RepoLayout,
644) -> Result<Hash, (String, u8)> {
645 let idx =
646 super::read_or_seed_index_from_head(layout, store).map_err(|e| (e, exit::GENERAL_ERROR))?;
647 worktree::build_tree_filtered_observed_with_source(
648 snapshot,
649 snapshot,
650 layout.worktree_root(),
651 Some(&idx),
652 &mut Vec::new(),
653 )
654 .map_err(|e| (format!("build tree: {e}"), exit::GENERAL_ERROR))
655}
656
657fn rev_to_tree(store: &ObjectStore, layout: &RepoLayout, spec: &str) -> Result<Hash, (String, u8)> {
658 let h = revspec::resolve_revision(store, layout, spec)
659 .map_err(|e| (format!("bad revision '{spec}': {e}"), exit::DATAERR))?;
660 object_to_tree(store, &h).map_err(|e| (e, exit::GENERAL_ERROR))
661}
662
663fn try_rev_to_tree(
666 store: &ObjectStore,
667 layout: &RepoLayout,
668 spec: &str,
669) -> Option<Result<Hash, (String, u8)>> {
670 match revspec::resolve_revision(store, layout, spec) {
671 Ok(h) => Some(object_to_tree(store, &h).map_err(|e| (e, exit::GENERAL_ERROR))),
672 Err(revspec::RevError::Unknown(_)) => {
673 if looks_like_rev_request(spec) {
677 Some(Err((
678 format!("bad revision '{spec}': not a known ref, commit, or short hash"),
679 exit::DATAERR,
680 )))
681 } else {
682 None
683 }
684 }
685 Err(e) => Some(Err((format!("bad revision '{spec}': {e}"), exit::DATAERR))),
686 }
687}
688
689fn peel_tags(store: &ObjectStore, h: Hash) -> Hash {
693 super::log::peel_tags(store, h)
694}
695
696pub(super) fn object_to_tree(store: &ObjectStore, h: &Hash) -> Result<Hash, String> {
699 match store.read_object(h) {
700 Ok(Object::Commit(c)) => Ok(c.tree_hash),
701 Ok(Object::Remix(r)) => Ok(r.tree_hash),
702 Ok(Object::Tree(_)) => Ok(*h),
703 Ok(_) => Err(format!(
704 "{} is not a commit, remix, or tree",
705 mkit_core::hash::to_hex(h)
706 )),
707 Err(e) => Err(read_err(e)),
708 }
709}
710
711fn split_range(s: &str) -> Option<(&str, &str)> {
713 let (a, b) = s.split_once("..")?;
714 if a.is_empty() || b.is_empty() {
715 return None;
716 }
717 Some((a, b))
718}
719
720fn split_symmetric(s: &str) -> Option<(&str, &str)> {
723 let (a, b) = s.split_once("...")?;
724 Some((
725 if a.is_empty() { "HEAD" } else { a },
726 if b.is_empty() { "HEAD" } else { b },
727 ))
728}
729
730fn strip_range_end(s: &str) -> (&str, bool) {
733 match s.split_once("..") {
734 Some((a, _)) if !a.is_empty() => (a, true),
735 _ => (s, false),
736 }
737}
738
739fn looks_like_rev_request(s: &str) -> bool {
745 if s.contains("..") {
746 return true;
747 }
748 let base = s.split(['~', '^']).next().unwrap_or(s);
750 if base == "HEAD" {
751 return true;
752 }
753 base.len() >= revspec::MIN_SHORT_HASH
755 && !base.contains('/')
756 && !base.contains('.')
757 && base.bytes().all(|b| b.is_ascii_hexdigit())
758}
759
760fn looks_like_pathspec(layout: &RepoLayout, arg: &str) -> bool {
767 if layout.worktree_root().join(arg).symlink_metadata().is_ok() {
768 return true;
769 }
770 let spec = normalize_pathspec(arg);
774 let Ok(idx) = mkit_core::index::read_index(layout) else {
775 return false;
776 };
777 let prefix = format!("{spec}/");
778 idx.entries
779 .iter()
780 .any(|e| e.path == spec || e.path.starts_with(&prefix))
781}
782
783fn head_tree(store: &ObjectStore, layout: &RepoLayout) -> Result<Option<Hash>, String> {
784 let head = refs::resolve_head(layout).map_err(|e| format!("resolve HEAD: {e}"))?;
785 match head {
786 None => Ok(None),
787 Some(h) => match store.read_object(&h) {
788 Ok(Object::Commit(c)) => Ok(Some(c.tree_hash)),
789 Ok(Object::Remix(r)) => Ok(Some(r.tree_hash)),
790 Ok(_) => Ok(None),
791 Err(e) => Err(format!("read HEAD: {e}")),
792 },
793 }
794}
795
796fn index_tree(
797 layout: &RepoLayout,
798 store: &ObjectStore,
799 snapshot: &EphemeralSink<'_>,
800) -> Result<Option<Hash>, String> {
801 let idx = super::read_or_seed_index_from_head(layout, store)?;
802 let tree = worktree::build_tree_from_index_with(store, snapshot, &idx, false)
805 .map_err(|e| format!("build index tree: {e}"))?;
806 Ok(Some(tree))
807}
808
809fn normalize_pathspec(spec: &str) -> String {
815 let s = spec.replace('\\', "/");
816 let s = s.strip_prefix("./").unwrap_or(&s);
817 let s = s.strip_suffix('/').unwrap_or(s);
818 if s == "." {
819 String::new()
820 } else {
821 s.to_string()
822 }
823}
824
825fn path_matches_any(path: &str, specs: &[String]) -> bool {
826 specs
827 .iter()
828 .any(|spec| spec.is_empty() || super::index_path_matches_or_descends(path, spec))
830}
831
832const ZERO_ABBREV: &str = "0000000";
834
835fn git_octal(mode: Option<EntryMode>) -> &'static str {
837 match mode {
838 Some(EntryMode::Executable) => "100755",
839 Some(EntryMode::Symlink) => "120000",
840 Some(EntryMode::Tree) => "040000",
841 _ => "100644",
842 }
843}
844
845fn abbrev(h: Option<Hash>) -> String {
847 h.map_or_else(|| ZERO_ABBREV.to_string(), |h| format::short_hash(&h, 7))
848}
849
850pub(super) fn emit_entry_patch<S: ObjectSource + ?Sized>(
864 out: &mut impl Write,
865 store: &S,
866 e: &DiffEntry,
867 context: usize,
868 ws: WhitespaceMode,
869) -> Result<(), String> {
870 let a_src = if e.kind == DiffKind::Renamed {
874 e.old_path.as_deref().unwrap_or(&e.path)
875 } else {
876 e.path.as_str()
877 };
878 let a_path = quoted_side('a', a_src);
879 let b_path = quoted_side('b', &e.path);
880 let _ = writeln!(out, "diff --git {a_path} {b_path}");
881
882 match e.kind {
883 DiffKind::Renamed => {
884 let from = super::c_quote_path(a_src).unwrap_or_else(|| a_src.to_string());
886 let to = super::c_quote_path(&e.path).unwrap_or_else(|| e.path.clone());
887 let _ = writeln!(out, "similarity index 100%");
888 let _ = writeln!(out, "rename from {from}");
889 let _ = writeln!(out, "rename to {to}");
890 return Ok(());
891 }
892 DiffKind::ModeChanged => {
893 let _ = writeln!(out, "old mode {}", git_octal(e.old_mode));
895 let _ = writeln!(out, "new mode {}", git_octal(e.new_mode));
896 return Ok(());
897 }
898 DiffKind::Added => {
899 let _ = writeln!(out, "new file mode {}", git_octal(e.new_mode));
900 let _ = writeln!(out, "index {}..{}", ZERO_ABBREV, abbrev(e.new_hash));
901 }
902 DiffKind::Removed => {
903 let _ = writeln!(out, "deleted file mode {}", git_octal(e.old_mode));
904 let _ = writeln!(out, "index {}..{}", abbrev(e.old_hash), ZERO_ABBREV);
905 }
906 DiffKind::Modified if e.old_mode != e.new_mode => {
907 let _ = writeln!(out, "old mode {}", git_octal(e.old_mode));
909 let _ = writeln!(out, "new mode {}", git_octal(e.new_mode));
910 let _ = writeln!(out, "index {}..{}", abbrev(e.old_hash), abbrev(e.new_hash));
911 }
912 DiffKind::Modified => {
913 let _ = writeln!(
914 out,
915 "index {}..{} {}",
916 abbrev(e.old_hash),
917 abbrev(e.new_hash),
918 git_octal(e.new_mode)
919 );
920 }
921 }
922
923 let old_bytes = match e.old_hash {
924 Some(h) => read_blob(store, &h)?,
925 None => Vec::new(),
926 };
927 let new_bytes = match e.new_hash {
928 Some(h) => read_blob(store, &h)?,
929 None => Vec::new(),
930 };
931 let (minus, plus) = match e.kind {
933 DiffKind::Added => ("/dev/null".to_string(), b_path.clone()),
934 DiffKind::Removed => (a_path.clone(), "/dev/null".to_string()),
935 _ => (a_path.clone(), b_path.clone()),
936 };
937 match unified_hunks_opts(&old_bytes, &new_bytes, context, ws) {
938 None => {
939 let _ = writeln!(out, "Binary files {minus} and {plus} differ");
940 }
941 Some(hunks) if hunks.is_empty() => {}
942 Some(hunks) => {
943 let _ = writeln!(out, "--- {minus}");
944 let _ = writeln!(out, "+++ {plus}");
945 let _ = out.write_all(&hunks);
946 }
947 }
948 Ok(())
949}
950
951fn quoted_side(side: char, path: &str) -> String {
955 let s = format!("{side}/{path}");
956 super::c_quote_path(&s).unwrap_or(s)
957}
958
959fn read_blob<S: ObjectSource + ?Sized>(store: &S, h: &Hash) -> Result<Vec<u8>, String> {
962 worktree::read_blob(store, h).map_err(read_err)
963}
964
965fn read_err<E: std::fmt::Display>(e: E) -> String {
967 format!("read object: {e}")
968}
969
970use super::error as emit_err;
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975
976 fn de(path: &str, kind: DiffKind) -> DiffEntry {
977 DiffEntry {
978 path: path.to_string(),
979 kind,
980 old_hash: None,
981 new_hash: None,
982 old_mode: None,
983 new_mode: None,
984 old_path: None,
985 }
986 }
987
988 fn render(e: &DiffEntry, name_status: bool, z: bool) -> String {
989 let mut buf = Vec::new();
990 emit_entry_name(&mut buf, e, name_status, z);
991 String::from_utf8(buf).unwrap()
992 }
993
994 #[test]
995 fn name_status_letters_cover_every_kind() {
996 assert_eq!(name_status_letter(DiffKind::Added), 'A');
997 assert_eq!(name_status_letter(DiffKind::Removed), 'D');
998 assert_eq!(name_status_letter(DiffKind::Modified), 'M');
999 assert_eq!(name_status_letter(DiffKind::ModeChanged), 'T');
1000 }
1001 #[test]
1002 fn name_only_newline_plain_path() {
1003 assert_eq!(
1004 render(&de("a.txt", DiffKind::Modified), false, false),
1005 "a.txt\n"
1006 );
1007 }
1008
1009 #[test]
1010 fn name_status_newline_is_letter_tab_path() {
1011 assert_eq!(
1012 render(&de("a.txt", DiffKind::Added), true, false),
1013 "A\ta.txt\n"
1014 );
1015 }
1016
1017 #[test]
1018 fn name_only_quotes_special_path_in_newline_mode() {
1019 assert_eq!(
1021 render(&de("a\tb.txt", DiffKind::Modified), false, false),
1022 "\"a\\tb.txt\"\n"
1023 );
1024 }
1025
1026 #[test]
1027 fn z_mode_is_raw_and_nul_terminated() {
1028 assert_eq!(
1030 render(&de("a\tb.txt", DiffKind::Modified), false, true),
1031 "a\tb.txt\0"
1032 );
1033 assert_eq!(
1035 render(&de("del.txt", DiffKind::Removed), true, true),
1036 "D\0del.txt\0"
1037 );
1038 }
1039}