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_exact_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 detect_exact_renames(&mut result.entries);
247 }
248
249 let normalized: Vec<String> = pathspecs.iter().map(|p| normalize_pathspec(p)).collect();
250 let selected: Vec<&mkit_core::ops::DiffEntry> = result
251 .entries
252 .iter()
253 .filter(|e| normalized.is_empty() || path_matches_any(&e.path, &normalized))
254 .collect();
255
256 let report_exit = opts.exit_code || opts.quiet;
259 let diff_status = if report_exit && !selected.is_empty() {
260 exit::GENERAL_ERROR
261 } else {
262 exit::OK
263 };
264 if opts.quiet {
265 return diff_status;
266 }
267
268 let mut stdout = std::io::stdout().lock();
269 if opts.stat {
270 return match render_stat(&mut stdout, &snapshot, selected.into_iter()) {
272 Ok(()) => diff_status,
273 Err(msg) => emit_err(&msg, exit::GENERAL_ERROR),
274 };
275 }
276 let display = DisplaySource::new(&snapshot);
280 for e in selected {
281 let res = if opts.name_only || opts.name_status {
282 emit_entry_name(&mut stdout, e, opts.name_status, opts.z);
283 Ok(())
284 } else if use_color {
285 let mut buf: Vec<u8> = Vec::new();
288 match emit_entry_patch(&mut buf, &display, e, context, ws_mode) {
289 Ok(()) => stdout
293 .write_all(&colorize_patch(&buf))
294 .map_err(|err| format!("write: {err}")),
295 Err(msg) => Err(msg),
296 }
297 } else {
298 emit_entry_patch(&mut stdout, &display, e, context, ws_mode)
299 };
300 if let Err(msg) = res {
301 return emit_err(&msg, exit::GENERAL_ERROR);
302 }
303 }
304 diff_status
305}
306
307fn colorize_patch(text: &[u8]) -> Vec<u8> {
313 const RESET: &[u8] = b"\x1b[0m";
314 let mut out: Vec<u8> = Vec::with_capacity(text.len() + 64);
315 for line in text.split_inclusive(|&b| b == b'\n') {
316 let (body, nl): (&[u8], &[u8]) = if line.last() == Some(&b'\n') {
317 (&line[..line.len() - 1], b"\n")
318 } else {
319 (line, b"")
320 };
321 let code: Option<&[u8]> = if body.starts_with(b"@@") {
322 Some(b"\x1b[36m") } else if body.starts_with(b"diff ")
324 || body.starts_with(b"index ")
325 || body.starts_with(b"new file")
326 || body.starts_with(b"deleted file")
327 || body.starts_with(b"old mode")
328 || body.starts_with(b"new mode")
329 || body.starts_with(b"rename ")
330 || body.starts_with(b"similarity ")
331 || body.starts_with(b"--- ")
332 || body.starts_with(b"+++ ")
333 {
334 Some(b"\x1b[1m") } else if body.first() == Some(&b'+') {
336 Some(b"\x1b[32m") } else if body.first() == Some(&b'-') {
338 Some(b"\x1b[31m") } else {
340 None
341 };
342 match code {
343 Some(c) => {
344 out.extend_from_slice(c);
345 out.extend_from_slice(body);
346 out.extend_from_slice(RESET);
347 out.extend_from_slice(nl);
348 }
349 None => out.extend_from_slice(line),
350 }
351 }
352 out
353}
354
355fn c_quote_name(path: &str) -> String {
358 super::c_quote_path(path).unwrap_or_else(|| path.to_string())
359}
360
361fn name_status_letter(kind: DiffKind) -> char {
365 match kind {
366 DiffKind::Added => 'A',
367 DiffKind::Removed => 'D',
368 DiffKind::Modified => 'M',
369 DiffKind::ModeChanged => 'T',
370 DiffKind::Renamed => 'R',
374 }
375}
376
377fn emit_entry_name(out: &mut impl Write, e: &DiffEntry, name_status: bool, z: bool) {
386 if name_status && e.kind == DiffKind::Renamed {
390 let src = e.old_path.as_deref().unwrap_or(&e.path);
391 if z {
392 let _ = write!(out, "R100\0{src}\0{}\0", e.path);
393 } else {
394 let sq = super::c_quote_path(src).unwrap_or_else(|| src.to_string());
395 let dq = super::c_quote_path(&e.path).unwrap_or_else(|| e.path.clone());
396 let _ = writeln!(out, "R100\t{sq}\t{dq}");
397 }
398 return;
399 }
400 if z {
401 if name_status {
402 let _ = write!(out, "{}\0", name_status_letter(e.kind));
403 }
404 let _ = write!(out, "{}\0", e.path);
405 return;
406 }
407 let path = super::c_quote_path(&e.path);
408 let shown = path.as_deref().unwrap_or(&e.path);
409 if name_status {
410 let _ = writeln!(out, "{}\t{shown}", name_status_letter(e.kind));
411 } else {
412 let _ = writeln!(out, "{shown}");
413 }
414}
415
416type DiffEndpoints = (Option<Hash>, Option<Hash>, Vec<String>);
418
419fn resolve_merge_base_endpoints(
437 store: &ObjectStore,
438 snapshot: &EphemeralSink<'_>,
439 layout: &RepoLayout,
440 args: &[String],
441) -> Result<DiffEndpoints, (String, u8)> {
442 let first = args.first().ok_or_else(|| {
443 (
444 "`--merge-base` requires at least one revision".to_string(),
445 exit::USAGE,
446 )
447 })?;
448 let a = peel_tags(
449 store,
450 revspec::resolve_revision(store, layout, first)
451 .map_err(|e| (format!("bad revision '{first}': {e}"), exit::DATAERR))?,
452 );
453
454 if let Some(second) = args.get(1) {
459 match revspec::resolve_revision(store, layout, second) {
460 Ok(h) => {
461 let b = peel_tags(store, h);
462 let base = merge_base_of(store, a, b)?;
463 let old = object_to_tree(store, &base).map_err(|e| (e, exit::GENERAL_ERROR))?;
464 let new = object_to_tree(store, &b).map_err(|e| (e, exit::GENERAL_ERROR))?;
465 return Ok((Some(old), Some(new), args[2..].to_vec()));
466 }
467 Err(e)
474 if matches!(e, revspec::RevError::Unknown(_))
475 && looks_like_pathspec(layout, second) => {}
476 Err(e) => return Err((format!("bad revision '{second}': {e}"), exit::DATAERR)),
477 }
478 }
479
480 let head = refs::resolve_head(layout)
482 .map_err(|e| (format!("resolve HEAD: {e}"), exit::GENERAL_ERROR))?
483 .ok_or_else(|| {
484 (
485 "HEAD has no commit to take a merge base with".to_string(),
486 exit::GENERAL_ERROR,
487 )
488 })?;
489 let head = peel_tags(store, head);
490 let base = merge_base_of(store, a, head)?;
491 let old = object_to_tree(store, &base).map_err(|e| (e, exit::GENERAL_ERROR))?;
492 let new = worktree_tree_filtered(store, snapshot, layout)?;
493 Ok((Some(old), Some(new), args[1..].to_vec()))
494}
495
496fn merge_base_of(store: &ObjectStore, a: Hash, b: Hash) -> Result<Hash, (String, u8)> {
499 find_merge_base(store, a, b)
500 .map_err(|e| (format!("merge base: {e}"), exit::GENERAL_ERROR))?
501 .ok_or_else(|| {
502 (
503 "no merge base between the given revisions".to_string(),
504 exit::DATAERR,
505 )
506 })
507}
508
509#[allow(clippy::too_many_arguments)]
510fn resolve_diff_endpoints(
511 store: &ObjectStore,
512 snapshot: &EphemeralSink<'_>,
513 layout: &RepoLayout,
514 staged: bool,
515 merge_base: bool,
516 args: &[String],
517) -> Result<DiffEndpoints, (String, u8)> {
518 if merge_base {
522 return resolve_merge_base_endpoints(store, snapshot, layout, args);
523 }
524
525 if staged {
535 if let Some(first) = args.first()
536 && looks_like_rev_request(first)
537 {
538 if revspec::resolve_revision(store, layout, strip_range_end(first).0).is_ok() {
539 return Err((
540 "`--staged` diffs HEAD vs the index; it cannot take an explicit revision"
541 .to_string(),
542 exit::USAGE,
543 ));
544 }
545 return Err((
546 format!("bad revision '{first}': not a known ref, commit, or short hash"),
547 exit::DATAERR,
548 ));
549 }
550 let head = head_tree(store, layout).map_err(|e| (e, exit::GENERAL_ERROR))?;
552 let idx = index_tree(layout, store, snapshot).map_err(|e| (e, exit::GENERAL_ERROR))?;
553 return Ok((head, idx, args.to_vec()));
554 }
555
556 if let Some(first) = args.first()
559 && let Some((a, b)) = split_symmetric(first)
560 {
561 let commit_a = peel_tags(
564 store,
565 revspec::resolve_revision(store, layout, a)
566 .map_err(|e| (format!("bad revision '{a}': {e}"), exit::DATAERR))?,
567 );
568 let commit_b = peel_tags(
569 store,
570 revspec::resolve_revision(store, layout, b)
571 .map_err(|e| (format!("bad revision '{b}': {e}"), exit::DATAERR))?,
572 );
573 let mb = find_merge_base(store, commit_a, commit_b)
574 .map_err(|e| (format!("merge base: {e}"), exit::GENERAL_ERROR))?
575 .ok_or_else(|| {
576 (
577 format!("no merge base between '{a}' and '{b}'"),
578 exit::DATAERR,
579 )
580 })?;
581 let old = object_to_tree(store, &mb).map_err(|e| (e, exit::GENERAL_ERROR))?;
582 let new = object_to_tree(store, &commit_b).map_err(|e| (e, exit::GENERAL_ERROR))?;
583 return Ok((Some(old), Some(new), args[1..].to_vec()));
584 }
585
586 if let Some(first) = args.first()
588 && let Some((a, b)) = split_range(first)
589 {
590 let old = rev_to_tree(store, layout, a)?;
591 let new = rev_to_tree(store, layout, b)?;
592 return Ok((Some(old), Some(new), args[1..].to_vec()));
593 }
594
595 let first_rev = args.first().and_then(|a| try_rev_to_tree(store, layout, a));
597 match first_rev {
598 None => {
599 if let Some(first) = args.first()
604 && looks_like_rev_request(first)
605 {
606 return Err((
607 format!("bad revision '{first}': not a known ref, commit, or short hash"),
608 exit::DATAERR,
609 ));
610 }
611 let head = head_tree(store, layout).map_err(|e| (e, exit::GENERAL_ERROR))?;
612 let new = Some(worktree_tree_filtered(store, snapshot, layout)?);
613 Ok((head, new, args.to_vec()))
614 }
615 Some(Err(e)) => Err(e),
616 Some(Ok(old)) => {
617 let second_rev = args.get(1).and_then(|a| try_rev_to_tree(store, layout, a));
620 match second_rev {
621 Some(Ok(new)) => Ok((Some(old), Some(new), args[2..].to_vec())),
622 Some(Err(e)) => Err(e),
623 None => {
624 let new = Some(worktree_tree_filtered(store, snapshot, layout)?);
625 Ok((Some(old), new, args[1..].to_vec()))
626 }
627 }
628 }
629 }
630}
631
632fn worktree_tree_filtered(
639 store: &ObjectStore,
640 snapshot: &EphemeralSink<'_>,
641 layout: &RepoLayout,
642) -> Result<Hash, (String, u8)> {
643 let idx =
644 super::read_or_seed_index_from_head(layout, store).map_err(|e| (e, exit::GENERAL_ERROR))?;
645 worktree::build_tree_filtered(snapshot, layout.worktree_root(), Some(&idx))
646 .map_err(|e| (format!("build tree: {e}"), exit::GENERAL_ERROR))
647}
648
649fn rev_to_tree(store: &ObjectStore, layout: &RepoLayout, spec: &str) -> Result<Hash, (String, u8)> {
650 let h = revspec::resolve_revision(store, layout, spec)
651 .map_err(|e| (format!("bad revision '{spec}': {e}"), exit::DATAERR))?;
652 object_to_tree(store, &h).map_err(|e| (e, exit::GENERAL_ERROR))
653}
654
655fn try_rev_to_tree(
658 store: &ObjectStore,
659 layout: &RepoLayout,
660 spec: &str,
661) -> Option<Result<Hash, (String, u8)>> {
662 match revspec::resolve_revision(store, layout, spec) {
663 Ok(h) => Some(object_to_tree(store, &h).map_err(|e| (e, exit::GENERAL_ERROR))),
664 Err(revspec::RevError::Unknown(_)) => {
665 if looks_like_rev_request(spec) {
669 Some(Err((
670 format!("bad revision '{spec}': not a known ref, commit, or short hash"),
671 exit::DATAERR,
672 )))
673 } else {
674 None
675 }
676 }
677 Err(e) => Some(Err((format!("bad revision '{spec}': {e}"), exit::DATAERR))),
678 }
679}
680
681fn peel_tags(store: &ObjectStore, h: Hash) -> Hash {
685 super::log::peel_tags(store, h)
686}
687
688pub(super) fn object_to_tree(store: &ObjectStore, h: &Hash) -> Result<Hash, String> {
691 match store.read_object(h) {
692 Ok(Object::Commit(c)) => Ok(c.tree_hash),
693 Ok(Object::Remix(r)) => Ok(r.tree_hash),
694 Ok(Object::Tree(_)) => Ok(*h),
695 Ok(_) => Err(format!(
696 "{} is not a commit, remix, or tree",
697 mkit_core::hash::to_hex(h)
698 )),
699 Err(e) => Err(read_err(e)),
700 }
701}
702
703fn split_range(s: &str) -> Option<(&str, &str)> {
705 let (a, b) = s.split_once("..")?;
706 if a.is_empty() || b.is_empty() {
707 return None;
708 }
709 Some((a, b))
710}
711
712fn split_symmetric(s: &str) -> Option<(&str, &str)> {
715 let (a, b) = s.split_once("...")?;
716 Some((
717 if a.is_empty() { "HEAD" } else { a },
718 if b.is_empty() { "HEAD" } else { b },
719 ))
720}
721
722fn strip_range_end(s: &str) -> (&str, bool) {
725 match s.split_once("..") {
726 Some((a, _)) if !a.is_empty() => (a, true),
727 _ => (s, false),
728 }
729}
730
731fn looks_like_rev_request(s: &str) -> bool {
737 if s.contains("..") {
738 return true;
739 }
740 let base = s.split(['~', '^']).next().unwrap_or(s);
742 if base == "HEAD" {
743 return true;
744 }
745 base.len() >= revspec::MIN_SHORT_HASH
747 && !base.contains('/')
748 && !base.contains('.')
749 && base.bytes().all(|b| b.is_ascii_hexdigit())
750}
751
752fn looks_like_pathspec(layout: &RepoLayout, arg: &str) -> bool {
759 if layout.worktree_root().join(arg).symlink_metadata().is_ok() {
760 return true;
761 }
762 let spec = normalize_pathspec(arg);
766 let Ok(idx) = mkit_core::index::read_index(layout) else {
767 return false;
768 };
769 let prefix = format!("{spec}/");
770 idx.entries
771 .iter()
772 .any(|e| e.path == spec || e.path.starts_with(&prefix))
773}
774
775fn head_tree(store: &ObjectStore, layout: &RepoLayout) -> Result<Option<Hash>, String> {
776 let head = refs::resolve_head(layout).map_err(|e| format!("resolve HEAD: {e}"))?;
777 match head {
778 None => Ok(None),
779 Some(h) => match store.read_object(&h) {
780 Ok(Object::Commit(c)) => Ok(Some(c.tree_hash)),
781 Ok(Object::Remix(r)) => Ok(Some(r.tree_hash)),
782 Ok(_) => Ok(None),
783 Err(e) => Err(format!("read HEAD: {e}")),
784 },
785 }
786}
787
788fn index_tree(
789 layout: &RepoLayout,
790 store: &ObjectStore,
791 snapshot: &EphemeralSink<'_>,
792) -> Result<Option<Hash>, String> {
793 let idx = super::read_or_seed_index_from_head(layout, store)?;
794 let tree = worktree::build_tree_from_index_with(store, snapshot, &idx, false)
797 .map_err(|e| format!("build index tree: {e}"))?;
798 Ok(Some(tree))
799}
800
801fn normalize_pathspec(spec: &str) -> String {
807 let s = spec.replace('\\', "/");
808 let s = s.strip_prefix("./").unwrap_or(&s);
809 let s = s.strip_suffix('/').unwrap_or(s);
810 if s == "." {
811 String::new()
812 } else {
813 s.to_string()
814 }
815}
816
817fn path_matches_any(path: &str, specs: &[String]) -> bool {
818 specs
819 .iter()
820 .any(|spec| spec.is_empty() || super::index_path_matches_or_descends(path, spec))
822}
823
824const ZERO_ABBREV: &str = "0000000";
826
827fn git_octal(mode: Option<EntryMode>) -> &'static str {
829 match mode {
830 Some(EntryMode::Executable) => "100755",
831 Some(EntryMode::Symlink) => "120000",
832 Some(EntryMode::Tree) => "040000",
833 _ => "100644",
834 }
835}
836
837fn abbrev(h: Option<Hash>) -> String {
839 h.map_or_else(|| ZERO_ABBREV.to_string(), |h| format::short_hash(&h, 7))
840}
841
842pub(super) fn emit_entry_patch<S: ObjectSource + ?Sized>(
856 out: &mut impl Write,
857 store: &S,
858 e: &DiffEntry,
859 context: usize,
860 ws: WhitespaceMode,
861) -> Result<(), String> {
862 let a_src = if e.kind == DiffKind::Renamed {
866 e.old_path.as_deref().unwrap_or(&e.path)
867 } else {
868 e.path.as_str()
869 };
870 let a_path = quoted_side('a', a_src);
871 let b_path = quoted_side('b', &e.path);
872 let _ = writeln!(out, "diff --git {a_path} {b_path}");
873
874 match e.kind {
875 DiffKind::Renamed => {
876 let from = super::c_quote_path(a_src).unwrap_or_else(|| a_src.to_string());
878 let to = super::c_quote_path(&e.path).unwrap_or_else(|| e.path.clone());
879 let _ = writeln!(out, "similarity index 100%");
880 let _ = writeln!(out, "rename from {from}");
881 let _ = writeln!(out, "rename to {to}");
882 return Ok(());
883 }
884 DiffKind::ModeChanged => {
885 let _ = writeln!(out, "old mode {}", git_octal(e.old_mode));
887 let _ = writeln!(out, "new mode {}", git_octal(e.new_mode));
888 return Ok(());
889 }
890 DiffKind::Added => {
891 let _ = writeln!(out, "new file mode {}", git_octal(e.new_mode));
892 let _ = writeln!(out, "index {}..{}", ZERO_ABBREV, abbrev(e.new_hash));
893 }
894 DiffKind::Removed => {
895 let _ = writeln!(out, "deleted file mode {}", git_octal(e.old_mode));
896 let _ = writeln!(out, "index {}..{}", abbrev(e.old_hash), ZERO_ABBREV);
897 }
898 DiffKind::Modified if e.old_mode != e.new_mode => {
899 let _ = writeln!(out, "old mode {}", git_octal(e.old_mode));
901 let _ = writeln!(out, "new mode {}", git_octal(e.new_mode));
902 let _ = writeln!(out, "index {}..{}", abbrev(e.old_hash), abbrev(e.new_hash));
903 }
904 DiffKind::Modified => {
905 let _ = writeln!(
906 out,
907 "index {}..{} {}",
908 abbrev(e.old_hash),
909 abbrev(e.new_hash),
910 git_octal(e.new_mode)
911 );
912 }
913 }
914
915 let old_bytes = match e.old_hash {
916 Some(h) => read_blob(store, &h)?,
917 None => Vec::new(),
918 };
919 let new_bytes = match e.new_hash {
920 Some(h) => read_blob(store, &h)?,
921 None => Vec::new(),
922 };
923 let (minus, plus) = match e.kind {
925 DiffKind::Added => ("/dev/null".to_string(), b_path.clone()),
926 DiffKind::Removed => (a_path.clone(), "/dev/null".to_string()),
927 _ => (a_path.clone(), b_path.clone()),
928 };
929 match unified_hunks_opts(&old_bytes, &new_bytes, context, ws) {
930 None => {
931 let _ = writeln!(out, "Binary files {minus} and {plus} differ");
932 }
933 Some(hunks) if hunks.is_empty() => {}
934 Some(hunks) => {
935 let _ = writeln!(out, "--- {minus}");
936 let _ = writeln!(out, "+++ {plus}");
937 let _ = out.write_all(&hunks);
938 }
939 }
940 Ok(())
941}
942
943fn quoted_side(side: char, path: &str) -> String {
947 let s = format!("{side}/{path}");
948 super::c_quote_path(&s).unwrap_or(s)
949}
950
951fn read_blob<S: ObjectSource + ?Sized>(store: &S, h: &Hash) -> Result<Vec<u8>, String> {
954 worktree::read_blob(store, h).map_err(read_err)
955}
956
957fn read_err<E: std::fmt::Display>(e: E) -> String {
959 format!("read object: {e}")
960}
961
962use super::error as emit_err;
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 fn de(path: &str, kind: DiffKind) -> DiffEntry {
969 DiffEntry {
970 path: path.to_string(),
971 kind,
972 old_hash: None,
973 new_hash: None,
974 old_mode: None,
975 new_mode: None,
976 old_path: None,
977 }
978 }
979
980 fn render(e: &DiffEntry, name_status: bool, z: bool) -> String {
981 let mut buf = Vec::new();
982 emit_entry_name(&mut buf, e, name_status, z);
983 String::from_utf8(buf).unwrap()
984 }
985
986 #[test]
987 fn name_status_letters_cover_every_kind() {
988 assert_eq!(name_status_letter(DiffKind::Added), 'A');
989 assert_eq!(name_status_letter(DiffKind::Removed), 'D');
990 assert_eq!(name_status_letter(DiffKind::Modified), 'M');
991 assert_eq!(name_status_letter(DiffKind::ModeChanged), 'T');
992 }
993 #[test]
994 fn name_only_newline_plain_path() {
995 assert_eq!(
996 render(&de("a.txt", DiffKind::Modified), false, false),
997 "a.txt\n"
998 );
999 }
1000
1001 #[test]
1002 fn name_status_newline_is_letter_tab_path() {
1003 assert_eq!(
1004 render(&de("a.txt", DiffKind::Added), true, false),
1005 "A\ta.txt\n"
1006 );
1007 }
1008
1009 #[test]
1010 fn name_only_quotes_special_path_in_newline_mode() {
1011 assert_eq!(
1013 render(&de("a\tb.txt", DiffKind::Modified), false, false),
1014 "\"a\\tb.txt\"\n"
1015 );
1016 }
1017
1018 #[test]
1019 fn z_mode_is_raw_and_nul_terminated() {
1020 assert_eq!(
1022 render(&de("a\tb.txt", DiffKind::Modified), false, true),
1023 "a\tb.txt\0"
1024 );
1025 assert_eq!(
1027 render(&de("del.txt", DiffKind::Removed), true, true),
1028 "D\0del.txt\0"
1029 );
1030 }
1031}