just playing with tangled
1// Copyright 2020-2022 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::VecDeque;
16use std::io;
17use std::ops::Range;
18use std::sync::Arc;
19
20use itertools::Itertools;
21use jj_lib::backend::{ObjectId, TreeValue};
22use jj_lib::commit::Commit;
23use jj_lib::diff::{Diff, DiffHunk};
24use jj_lib::files::DiffLine;
25use jj_lib::matchers::Matcher;
26use jj_lib::repo::{ReadonlyRepo, Repo};
27use jj_lib::repo_path::RepoPath;
28use jj_lib::settings::UserSettings;
29use jj_lib::tree::{Tree, TreeDiffIterator};
30use jj_lib::{diff, files, rewrite, tree};
31use tracing::instrument;
32
33use crate::cli_util::{CommandError, WorkspaceCommandHelper};
34use crate::formatter::Formatter;
35
36#[derive(clap::Args, Clone, Debug)]
37#[command(group(clap::ArgGroup::new("short-format").args(&["summary", "types"])))]
38#[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words"])))]
39pub struct DiffFormatArgs {
40 /// For each path, show only whether it was modified, added, or removed
41 #[arg(long, short)]
42 pub summary: bool,
43 /// For each path, show only its type before and after
44 ///
45 /// The diff is shown as two letters. The first letter indicates the type
46 /// before and the second letter indicates the type after. '-' indicates
47 /// that the path was not present, 'F' represents a regular file, `L'
48 /// represents a symlink, 'C' represents a conflict, and 'G' represents a
49 /// Git submodule.
50 #[arg(long)]
51 pub types: bool,
52 /// Show a Git-format diff
53 #[arg(long)]
54 pub git: bool,
55 /// Show a word-level diff with changes indicated only by color
56 #[arg(long)]
57 pub color_words: bool,
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum DiffFormat {
62 Summary,
63 Types,
64 Git,
65 ColorWords,
66}
67
68/// Returns a list of requested diff formats, which will never be empty.
69pub fn diff_formats_for(settings: &UserSettings, args: &DiffFormatArgs) -> Vec<DiffFormat> {
70 let formats = diff_formats_from_args(args);
71 if formats.is_empty() {
72 vec![default_diff_format(settings)]
73 } else {
74 formats
75 }
76}
77
78/// Returns a list of requested diff formats for log-like commands, which may be
79/// empty.
80pub fn diff_formats_for_log(
81 settings: &UserSettings,
82 args: &DiffFormatArgs,
83 patch: bool,
84) -> Vec<DiffFormat> {
85 let mut formats = diff_formats_from_args(args);
86 // --patch implies default if no format other than --summary is specified
87 if patch && matches!(formats.as_slice(), [] | [DiffFormat::Summary]) {
88 formats.push(default_diff_format(settings));
89 formats.dedup();
90 }
91 formats
92}
93
94fn diff_formats_from_args(args: &DiffFormatArgs) -> Vec<DiffFormat> {
95 [
96 (args.summary, DiffFormat::Summary),
97 (args.types, DiffFormat::Types),
98 (args.git, DiffFormat::Git),
99 (args.color_words, DiffFormat::ColorWords),
100 ]
101 .into_iter()
102 .filter_map(|(arg, format)| arg.then_some(format))
103 .collect()
104}
105
106fn default_diff_format(settings: &UserSettings) -> DiffFormat {
107 match settings
108 .config()
109 .get_string("ui.diff.format")
110 // old config name
111 .or_else(|_| settings.config().get_string("diff.format"))
112 .as_deref()
113 {
114 Ok("summary") => DiffFormat::Summary,
115 Ok("types") => DiffFormat::Types,
116 Ok("git") => DiffFormat::Git,
117 Ok("color-words") => DiffFormat::ColorWords,
118 _ => DiffFormat::ColorWords,
119 }
120}
121
122pub fn show_diff(
123 formatter: &mut dyn Formatter,
124 workspace_command: &WorkspaceCommandHelper,
125 from_tree: &Tree,
126 to_tree: &Tree,
127 matcher: &dyn Matcher,
128 formats: &[DiffFormat],
129) -> Result<(), CommandError> {
130 for format in formats {
131 let tree_diff = from_tree.diff(to_tree, matcher);
132 match format {
133 DiffFormat::Summary => {
134 show_diff_summary(formatter, workspace_command, tree_diff)?;
135 }
136 DiffFormat::Types => {
137 show_types(formatter, workspace_command, tree_diff)?;
138 }
139 DiffFormat::Git => {
140 show_git_diff(formatter, workspace_command, tree_diff)?;
141 }
142 DiffFormat::ColorWords => {
143 show_color_words_diff(formatter, workspace_command, tree_diff)?;
144 }
145 }
146 }
147 Ok(())
148}
149
150pub fn show_patch(
151 formatter: &mut dyn Formatter,
152 workspace_command: &WorkspaceCommandHelper,
153 commit: &Commit,
154 matcher: &dyn Matcher,
155 formats: &[DiffFormat],
156) -> Result<(), CommandError> {
157 let parents = commit.parents();
158 let from_tree = rewrite::merge_commit_trees(workspace_command.repo().as_ref(), &parents)?;
159 let to_tree = commit.tree();
160 show_diff(
161 formatter,
162 workspace_command,
163 &from_tree,
164 &to_tree,
165 matcher,
166 formats,
167 )
168}
169
170fn show_color_words_diff_hunks(
171 left: &[u8],
172 right: &[u8],
173 formatter: &mut dyn Formatter,
174) -> io::Result<()> {
175 const SKIPPED_CONTEXT_LINE: &str = " ...\n";
176 let num_context_lines = 3;
177 let mut context = VecDeque::new();
178 // Have we printed "..." for any skipped context?
179 let mut skipped_context = false;
180 // Are the lines in `context` to be printed before the next modified line?
181 let mut context_before = true;
182 for diff_line in files::diff(left, right) {
183 if diff_line.is_unmodified() {
184 context.push_back(diff_line.clone());
185 let mut start_skipping_context = false;
186 if context_before {
187 if skipped_context && context.len() > num_context_lines {
188 context.pop_front();
189 } else if !skipped_context && context.len() > num_context_lines + 1 {
190 start_skipping_context = true;
191 }
192 } else if context.len() > num_context_lines * 2 + 1 {
193 for line in context.drain(..num_context_lines) {
194 show_color_words_diff_line(formatter, &line)?;
195 }
196 start_skipping_context = true;
197 }
198 if start_skipping_context {
199 context.drain(..2);
200 formatter.write_str(SKIPPED_CONTEXT_LINE)?;
201 skipped_context = true;
202 context_before = true;
203 }
204 } else {
205 for line in &context {
206 show_color_words_diff_line(formatter, line)?;
207 }
208 context.clear();
209 show_color_words_diff_line(formatter, &diff_line)?;
210 context_before = false;
211 skipped_context = false;
212 }
213 }
214 if !context_before {
215 if context.len() > num_context_lines + 1 {
216 context.truncate(num_context_lines);
217 skipped_context = true;
218 context_before = true;
219 }
220 for line in &context {
221 show_color_words_diff_line(formatter, line)?;
222 }
223 if context_before {
224 formatter.write_str(SKIPPED_CONTEXT_LINE)?;
225 }
226 }
227
228 // If the last diff line doesn't end with newline, add it.
229 let no_hunk = left.is_empty() && right.is_empty();
230 let any_last_newline = left.ends_with(b"\n") || right.ends_with(b"\n");
231 if !skipped_context && !no_hunk && !any_last_newline {
232 formatter.write_str("\n")?;
233 }
234
235 Ok(())
236}
237
238fn show_color_words_diff_line(
239 formatter: &mut dyn Formatter,
240 diff_line: &DiffLine,
241) -> io::Result<()> {
242 if diff_line.has_left_content {
243 write!(
244 formatter.labeled("removed"),
245 "{:>4}",
246 diff_line.left_line_number
247 )?;
248 formatter.write_str(" ")?;
249 } else {
250 formatter.write_str(" ")?;
251 }
252 if diff_line.has_right_content {
253 write!(
254 formatter.labeled("added"),
255 "{:>4}",
256 diff_line.right_line_number
257 )?;
258 formatter.write_str(": ")?;
259 } else {
260 formatter.write_str(" : ")?;
261 }
262 for hunk in &diff_line.hunks {
263 match hunk {
264 DiffHunk::Matching(data) => {
265 formatter.write_all(data)?;
266 }
267 DiffHunk::Different(data) => {
268 let before = data[0];
269 let after = data[1];
270 if !before.is_empty() {
271 formatter.with_label("removed", |formatter| formatter.write_all(before))?;
272 }
273 if !after.is_empty() {
274 formatter.with_label("added", |formatter| formatter.write_all(after))?;
275 }
276 }
277 }
278 }
279
280 Ok(())
281}
282
283fn diff_content(
284 repo: &Arc<ReadonlyRepo>,
285 path: &RepoPath,
286 value: &TreeValue,
287) -> Result<Vec<u8>, CommandError> {
288 match value {
289 TreeValue::File { id, .. } => {
290 let mut file_reader = repo.store().read_file(path, id).unwrap();
291 let mut content = vec![];
292 file_reader.read_to_end(&mut content)?;
293 Ok(content)
294 }
295 TreeValue::Symlink(id) => {
296 let target = repo.store().read_symlink(path, id)?;
297 Ok(target.into_bytes())
298 }
299 TreeValue::Tree(_) => {
300 panic!(
301 "Got an unexpected tree in a diff of path {}",
302 path.to_internal_file_string()
303 );
304 }
305 TreeValue::GitSubmodule(id) => {
306 Ok(format!("Git submodule checked out at {}", id.hex()).into_bytes())
307 }
308 TreeValue::Conflict(id) => {
309 let conflict = repo.store().read_conflict(path, id).unwrap();
310 let mut content = vec![];
311 conflict
312 .materialize(repo.store(), path, &mut content)
313 .unwrap();
314 Ok(content)
315 }
316 }
317}
318
319fn basic_diff_file_type(value: &TreeValue) -> String {
320 match value {
321 TreeValue::File { executable, .. } => {
322 if *executable {
323 "executable file".to_string()
324 } else {
325 "regular file".to_string()
326 }
327 }
328 TreeValue::Symlink(_) => "symlink".to_string(),
329 TreeValue::Tree(_) => "tree".to_string(),
330 TreeValue::GitSubmodule(_) => "Git submodule".to_string(),
331 TreeValue::Conflict(_) => "conflict".to_string(),
332 }
333}
334
335pub fn show_color_words_diff(
336 formatter: &mut dyn Formatter,
337 workspace_command: &WorkspaceCommandHelper,
338 tree_diff: TreeDiffIterator,
339) -> Result<(), CommandError> {
340 let repo = workspace_command.repo();
341 formatter.push_label("diff")?;
342 for (path, diff) in tree_diff {
343 let ui_path = workspace_command.format_file_path(&path);
344 match diff {
345 tree::Diff::Added(right_value) => {
346 let right_content = diff_content(repo, &path, &right_value)?;
347 let description = basic_diff_file_type(&right_value);
348 writeln!(
349 formatter.labeled("header"),
350 "Added {description} {ui_path}:"
351 )?;
352 show_color_words_diff_hunks(&[], &right_content, formatter)?;
353 }
354 tree::Diff::Modified(left_value, right_value) => {
355 let left_content = diff_content(repo, &path, &left_value)?;
356 let right_content = diff_content(repo, &path, &right_value)?;
357 let description = match (left_value, right_value) {
358 (
359 TreeValue::File {
360 executable: left_executable,
361 ..
362 },
363 TreeValue::File {
364 executable: right_executable,
365 ..
366 },
367 ) => {
368 if left_executable && right_executable {
369 "Modified executable file".to_string()
370 } else if left_executable {
371 "Executable file became non-executable at".to_string()
372 } else if right_executable {
373 "Non-executable file became executable at".to_string()
374 } else {
375 "Modified regular file".to_string()
376 }
377 }
378 (TreeValue::Conflict(_), TreeValue::Conflict(_)) => {
379 "Modified conflict in".to_string()
380 }
381 (TreeValue::Conflict(_), _) => "Resolved conflict in".to_string(),
382 (_, TreeValue::Conflict(_)) => "Created conflict in".to_string(),
383 (TreeValue::Symlink(_), TreeValue::Symlink(_)) => {
384 "Symlink target changed at".to_string()
385 }
386 (left_value, right_value) => {
387 let left_type = basic_diff_file_type(&left_value);
388 let right_type = basic_diff_file_type(&right_value);
389 let (first, rest) = left_type.split_at(1);
390 format!(
391 "{}{} became {} at",
392 first.to_ascii_uppercase(),
393 rest,
394 right_type
395 )
396 }
397 };
398 writeln!(formatter.labeled("header"), "{description} {ui_path}:")?;
399 show_color_words_diff_hunks(&left_content, &right_content, formatter)?;
400 }
401 tree::Diff::Removed(left_value) => {
402 let left_content = diff_content(repo, &path, &left_value)?;
403 let description = basic_diff_file_type(&left_value);
404 writeln!(
405 formatter.labeled("header"),
406 "Removed {description} {ui_path}:"
407 )?;
408 show_color_words_diff_hunks(&left_content, &[], formatter)?;
409 }
410 }
411 }
412 formatter.pop_label()?;
413 Ok(())
414}
415
416struct GitDiffPart {
417 mode: String,
418 hash: String,
419 content: Vec<u8>,
420}
421
422fn git_diff_part(
423 repo: &Arc<ReadonlyRepo>,
424 path: &RepoPath,
425 value: &TreeValue,
426) -> Result<GitDiffPart, CommandError> {
427 let mode;
428 let hash;
429 let mut content = vec![];
430 match value {
431 TreeValue::File { id, executable } => {
432 mode = if *executable {
433 "100755".to_string()
434 } else {
435 "100644".to_string()
436 };
437 hash = id.hex();
438 let mut file_reader = repo.store().read_file(path, id).unwrap();
439 file_reader.read_to_end(&mut content)?;
440 }
441 TreeValue::Symlink(id) => {
442 mode = "120000".to_string();
443 hash = id.hex();
444 let target = repo.store().read_symlink(path, id)?;
445 content = target.into_bytes();
446 }
447 TreeValue::Tree(_) => {
448 panic!(
449 "Got an unexpected tree in a diff of path {}",
450 path.to_internal_file_string()
451 );
452 }
453 TreeValue::GitSubmodule(id) => {
454 // TODO: What should we actually do here?
455 mode = "040000".to_string();
456 hash = id.hex();
457 }
458 TreeValue::Conflict(id) => {
459 mode = "100644".to_string();
460 hash = id.hex();
461 let conflict = repo.store().read_conflict(path, id).unwrap();
462 conflict
463 .materialize(repo.store(), path, &mut content)
464 .unwrap();
465 }
466 }
467 let hash = hash[0..10].to_string();
468 Ok(GitDiffPart {
469 mode,
470 hash,
471 content,
472 })
473}
474
475#[derive(PartialEq)]
476enum DiffLineType {
477 Context,
478 Removed,
479 Added,
480}
481
482struct UnifiedDiffHunk<'content> {
483 left_line_range: Range<usize>,
484 right_line_range: Range<usize>,
485 lines: Vec<(DiffLineType, &'content [u8])>,
486}
487
488fn unified_diff_hunks<'content>(
489 left_content: &'content [u8],
490 right_content: &'content [u8],
491 num_context_lines: usize,
492) -> Vec<UnifiedDiffHunk<'content>> {
493 let mut hunks = vec![];
494 let mut current_hunk = UnifiedDiffHunk {
495 left_line_range: 1..1,
496 right_line_range: 1..1,
497 lines: vec![],
498 };
499 let mut show_context_after = false;
500 let diff = Diff::for_tokenizer(&[left_content, right_content], &diff::find_line_ranges);
501 for hunk in diff.hunks() {
502 match hunk {
503 DiffHunk::Matching(content) => {
504 let lines = content.split_inclusive(|b| *b == b'\n').collect_vec();
505 // Number of context lines to print after the previous non-matching hunk.
506 let num_after_lines = lines.len().min(if show_context_after {
507 num_context_lines
508 } else {
509 0
510 });
511 current_hunk.left_line_range.end += num_after_lines;
512 current_hunk.right_line_range.end += num_after_lines;
513 for line in lines.iter().take(num_after_lines) {
514 current_hunk.lines.push((DiffLineType::Context, line));
515 }
516 let num_skip_lines = lines
517 .len()
518 .saturating_sub(num_after_lines)
519 .saturating_sub(num_context_lines);
520 if num_skip_lines > 0 {
521 let left_start = current_hunk.left_line_range.end + num_skip_lines;
522 let right_start = current_hunk.right_line_range.end + num_skip_lines;
523 if !current_hunk.lines.is_empty() {
524 hunks.push(current_hunk);
525 }
526 current_hunk = UnifiedDiffHunk {
527 left_line_range: left_start..left_start,
528 right_line_range: right_start..right_start,
529 lines: vec![],
530 };
531 }
532 let num_before_lines = lines.len() - num_after_lines - num_skip_lines;
533 current_hunk.left_line_range.end += num_before_lines;
534 current_hunk.right_line_range.end += num_before_lines;
535 for line in lines.iter().skip(num_after_lines + num_skip_lines) {
536 current_hunk.lines.push((DiffLineType::Context, line));
537 }
538 }
539 DiffHunk::Different(content) => {
540 show_context_after = true;
541 let left_lines = content[0].split_inclusive(|b| *b == b'\n').collect_vec();
542 let right_lines = content[1].split_inclusive(|b| *b == b'\n').collect_vec();
543 if !left_lines.is_empty() {
544 current_hunk.left_line_range.end += left_lines.len();
545 for line in left_lines {
546 current_hunk.lines.push((DiffLineType::Removed, line));
547 }
548 }
549 if !right_lines.is_empty() {
550 current_hunk.right_line_range.end += right_lines.len();
551 for line in right_lines {
552 current_hunk.lines.push((DiffLineType::Added, line));
553 }
554 }
555 }
556 }
557 }
558 if !current_hunk
559 .lines
560 .iter()
561 .all(|(diff_type, _line)| *diff_type == DiffLineType::Context)
562 {
563 hunks.push(current_hunk);
564 }
565 hunks
566}
567
568fn show_unified_diff_hunks(
569 formatter: &mut dyn Formatter,
570 left_content: &[u8],
571 right_content: &[u8],
572) -> Result<(), CommandError> {
573 for hunk in unified_diff_hunks(left_content, right_content, 3) {
574 writeln!(
575 formatter.labeled("hunk_header"),
576 "@@ -{},{} +{},{} @@",
577 hunk.left_line_range.start,
578 hunk.left_line_range.len(),
579 hunk.right_line_range.start,
580 hunk.right_line_range.len()
581 )?;
582 for (line_type, content) in hunk.lines {
583 match line_type {
584 DiffLineType::Context => {
585 formatter.with_label("context", |formatter| {
586 formatter.write_str(" ")?;
587 formatter.write_all(content)
588 })?;
589 }
590 DiffLineType::Removed => {
591 formatter.with_label("removed", |formatter| {
592 formatter.write_str("-")?;
593 formatter.write_all(content)
594 })?;
595 }
596 DiffLineType::Added => {
597 formatter.with_label("added", |formatter| {
598 formatter.write_str("+")?;
599 formatter.write_all(content)
600 })?;
601 }
602 }
603 if !content.ends_with(b"\n") {
604 formatter.write_str("\n\\ No newline at end of file\n")?;
605 }
606 }
607 }
608 Ok(())
609}
610
611pub fn show_git_diff(
612 formatter: &mut dyn Formatter,
613 workspace_command: &WorkspaceCommandHelper,
614 tree_diff: TreeDiffIterator,
615) -> Result<(), CommandError> {
616 let repo = workspace_command.repo();
617 formatter.push_label("diff")?;
618 for (path, diff) in tree_diff {
619 let path_string = path.to_internal_file_string();
620 match diff {
621 tree::Diff::Added(right_value) => {
622 let right_part = git_diff_part(repo, &path, &right_value)?;
623 formatter.with_label("file_header", |formatter| {
624 writeln!(formatter, "diff --git a/{path_string} b/{path_string}")?;
625 writeln!(formatter, "new file mode {}", &right_part.mode)?;
626 writeln!(formatter, "index 0000000000..{}", &right_part.hash)?;
627 writeln!(formatter, "--- /dev/null")?;
628 writeln!(formatter, "+++ b/{path_string}")
629 })?;
630 show_unified_diff_hunks(formatter, &[], &right_part.content)?;
631 }
632 tree::Diff::Modified(left_value, right_value) => {
633 let left_part = git_diff_part(repo, &path, &left_value)?;
634 let right_part = git_diff_part(repo, &path, &right_value)?;
635 formatter.with_label("file_header", |formatter| {
636 writeln!(formatter, "diff --git a/{path_string} b/{path_string}")?;
637 if left_part.mode != right_part.mode {
638 writeln!(formatter, "old mode {}", &left_part.mode)?;
639 writeln!(formatter, "new mode {}", &right_part.mode)?;
640 if left_part.hash != right_part.hash {
641 writeln!(formatter, "index {}...{}", &left_part.hash, right_part.hash)?;
642 }
643 } else if left_part.hash != right_part.hash {
644 writeln!(
645 formatter,
646 "index {}...{} {}",
647 &left_part.hash, right_part.hash, left_part.mode
648 )?;
649 }
650 if left_part.content != right_part.content {
651 writeln!(formatter, "--- a/{path_string}")?;
652 writeln!(formatter, "+++ b/{path_string}")?;
653 }
654 Ok(())
655 })?;
656 show_unified_diff_hunks(formatter, &left_part.content, &right_part.content)?;
657 }
658 tree::Diff::Removed(left_value) => {
659 let left_part = git_diff_part(repo, &path, &left_value)?;
660 formatter.with_label("file_header", |formatter| {
661 writeln!(formatter, "diff --git a/{path_string} b/{path_string}")?;
662 writeln!(formatter, "deleted file mode {}", &left_part.mode)?;
663 writeln!(formatter, "index {}..0000000000", &left_part.hash)?;
664 writeln!(formatter, "--- a/{path_string}")?;
665 writeln!(formatter, "+++ /dev/null")
666 })?;
667 show_unified_diff_hunks(formatter, &left_part.content, &[])?;
668 }
669 }
670 }
671 formatter.pop_label()?;
672 Ok(())
673}
674
675#[instrument(skip_all)]
676pub fn show_diff_summary(
677 formatter: &mut dyn Formatter,
678 workspace_command: &WorkspaceCommandHelper,
679 tree_diff: TreeDiffIterator,
680) -> io::Result<()> {
681 formatter.with_label("diff", |formatter| {
682 for (repo_path, diff) in tree_diff {
683 match diff {
684 tree::Diff::Modified(_, _) => {
685 writeln!(
686 formatter.labeled("modified"),
687 "M {}",
688 workspace_command.format_file_path(&repo_path)
689 )?;
690 }
691 tree::Diff::Added(_) => {
692 writeln!(
693 formatter.labeled("added"),
694 "A {}",
695 workspace_command.format_file_path(&repo_path)
696 )?;
697 }
698 tree::Diff::Removed(_) => {
699 writeln!(
700 formatter.labeled("removed"),
701 "R {}",
702 workspace_command.format_file_path(&repo_path)
703 )?;
704 }
705 }
706 }
707 Ok(())
708 })
709}
710
711pub fn show_types(
712 formatter: &mut dyn Formatter,
713 workspace_command: &WorkspaceCommandHelper,
714 tree_diff: TreeDiffIterator,
715) -> io::Result<()> {
716 formatter.with_label("diff", |formatter| {
717 for (repo_path, diff) in tree_diff {
718 let (before, after) = diff.into_options();
719 writeln!(
720 formatter.labeled("modified"),
721 "{}{} {}",
722 diff_summary_char(before.as_ref()),
723 diff_summary_char(after.as_ref()),
724 workspace_command.format_file_path(&repo_path)
725 )?;
726 }
727 Ok(())
728 })
729}
730
731fn diff_summary_char(value: Option<&TreeValue>) -> char {
732 match value {
733 None => '-',
734 Some(TreeValue::File { .. }) => 'F',
735 Some(TreeValue::Symlink(_)) => 'L',
736 Some(TreeValue::GitSubmodule(_)) => 'G',
737 Some(TreeValue::Conflict(_)) => 'C',
738 Some(TreeValue::Tree(_)) => panic!("unexpected tree entry in diff"),
739 }
740}