Skip to main content

grit_lib/
merge_diff.rs

1//! Merge commit and combined (`--cc` / `-c`) diff helpers.
2//!
3//! These mirror the subset of Git's combine-diff output needed for porcelain
4//! commands (`git show`, `git diff` during conflicts, `git diff-tree -c`).
5
6use std::io::Write;
7use std::path::Path;
8use std::process::{Command, Stdio};
9
10use tempfile::NamedTempFile;
11
12use crate::config::ConfigSet;
13use crate::crlf::{get_file_attrs, load_gitattributes, DiffAttr, FileAttrs};
14use crate::diff::{diff_trees, DiffStatus};
15use crate::objects::{parse_commit, ObjectId};
16use crate::odb::Odb;
17use crate::textconv_cache::{read_textconv_cache, write_textconv_cache};
18
19/// Paths that differ between the merge result tree and **every** parent tree.
20#[must_use]
21pub fn combined_diff_paths(odb: &Odb, commit_tree: &ObjectId, parents: &[ObjectId]) -> Vec<String> {
22    if parents.len() < 2 {
23        return Vec::new();
24    }
25    let mut per_parent: Vec<std::collections::HashSet<String>> = Vec::new();
26    for p in parents {
27        let Ok(po) = odb.read(p) else {
28            continue;
29        };
30        let Ok(pc) = parse_commit(&po.data) else {
31            continue;
32        };
33        let Ok(entries) = diff_trees(odb, Some(&pc.tree), Some(commit_tree), "") else {
34            continue;
35        };
36        let paths: std::collections::HashSet<String> =
37            entries.iter().map(|e| e.path().to_string()).collect();
38        per_parent.push(paths);
39    }
40    if per_parent.is_empty() {
41        return Vec::new();
42    }
43    let mut common = per_parent[0].clone();
44    for s in &per_parent[1..] {
45        common = common.intersection(s).cloned().collect();
46    }
47    let mut out: Vec<String> = common.into_iter().collect();
48    out.sort();
49    out
50}
51
52/// Load attributes for `path` using root `.gitattributes` and `info/attributes`.
53fn attrs_for_repo_path(git_dir: &Path, path: &str) -> FileAttrs {
54    let work_tree = git_dir.parent().unwrap_or(git_dir);
55    let rules = load_gitattributes(work_tree);
56    let config = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
57    get_file_attrs(&rules, path, false, &config)
58}
59
60/// True if diff should treat this path as binary (NUL in blob or `-diff` / `diff=unset`).
61#[must_use]
62pub fn is_binary_for_diff(git_dir: &Path, path: &str, blob: &[u8]) -> bool {
63    let fa = attrs_for_repo_path(git_dir, path);
64    if matches!(fa.diff_attr, DiffAttr::Unset) {
65        return true;
66    }
67    crate::crlf::is_binary(blob)
68}
69
70/// True when Git would wrap the textconv command with `sh -c 'cmd "$@"' -- ...`
71/// (`prepare_shell_cmd` in Git's `run-command.c`).
72fn textconv_cmd_needs_shell_wrapper(cmd_line: &str) -> bool {
73    cmd_line.chars().any(|c| {
74        matches!(
75            c,
76            '|' | '&'
77                | ';'
78                | '<'
79                | '>'
80                | '('
81                | ')'
82                | '$'
83                | '`'
84                | '\\'
85                | '"'
86                | '\''
87                | ' '
88                | '\t'
89                | '\n'
90                | '*'
91                | '?'
92                | '['
93                | '#'
94                | '~'
95                | '='
96                | '%'
97        )
98    })
99}
100
101/// Run `diff.<driver>.textconv` on `input`; returns raw stdout on success.
102///
103/// Matches Git's `run_textconv` / `prepare_shell_cmd`: by default the blob is written to a
104/// temporary file and passed as an argument after `--`. Commands that contain shell
105/// metacharacters (including spaces) use `sh -c 'pgm "$@"' -- pgm <tempfile>`. Config lines
106/// ending with ` <` use stdin instead of a tempfile.
107pub fn run_textconv_raw(
108    command_cwd: &Path,
109    config: &ConfigSet,
110    driver: &str,
111    input: &[u8],
112) -> Option<Vec<u8>> {
113    let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
114    cmd_line = cmd_line.trim_end().to_string();
115    let stdin_mode = if cmd_line.ends_with('<') {
116        let t = cmd_line.trim_end_matches('<').trim_end();
117        cmd_line = t.to_string();
118        true
119    } else {
120        false
121    };
122    if stdin_mode {
123        let mut child = Command::new("sh")
124            .arg("-c")
125            .arg(&cmd_line)
126            .current_dir(command_cwd)
127            .stdin(Stdio::piped())
128            .stdout(Stdio::piped())
129            .stderr(Stdio::null())
130            .spawn()
131            .ok()?;
132        let mut stdin = child.stdin.take()?;
133        stdin.write_all(input).ok()?;
134        drop(stdin);
135        let out = child.wait_with_output().ok()?;
136        return if out.status.success() {
137            Some(out.stdout)
138        } else {
139            None
140        };
141    }
142
143    let mut tmp = NamedTempFile::new().ok()?;
144    tmp.write_all(input).ok()?;
145    tmp.flush().ok()?;
146    let path = tmp.path().to_owned();
147
148    let out = if textconv_cmd_needs_shell_wrapper(&cmd_line) {
149        Command::new("sh")
150            .current_dir(command_cwd)
151            .arg("-c")
152            .arg(format!("{} \"$@\"", cmd_line))
153            .arg(&cmd_line)
154            .arg(&path)
155            .stdout(Stdio::piped())
156            .stderr(Stdio::null())
157            .output()
158            .ok()?
159    } else {
160        Command::new("sh")
161            .current_dir(command_cwd)
162            .arg(&cmd_line)
163            .arg(&path)
164            .stdout(Stdio::piped())
165            .stderr(Stdio::null())
166            .output()
167            .ok()?
168    };
169
170    if !out.status.success() {
171        return None;
172    }
173    Some(out.stdout)
174}
175
176/// Run `diff.<driver>.textconv` feeding `input` on stdin; returns UTF-8 lossy text on success.
177pub fn run_textconv(
178    command_cwd: &Path,
179    config: &ConfigSet,
180    driver: &str,
181    input: &[u8],
182) -> Option<String> {
183    run_textconv_raw(command_cwd, config, driver, input)
184        .map(|b| String::from_utf8_lossy(&b).into_owned())
185}
186
187pub fn diff_textconv_cmd_line(config: &ConfigSet, driver: &str) -> Option<String> {
188    let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
189    cmd_line = cmd_line.trim_end().to_string();
190    if cmd_line.ends_with('<') {
191        let t = cmd_line.trim_end_matches('<').trim_end();
192        cmd_line = t.to_string();
193    }
194    Some(cmd_line)
195}
196
197pub fn diff_cachetextconv_enabled(config: &ConfigSet, driver: &str) -> bool {
198    config
199        .get(&format!("diff.{driver}.cachetextconv"))
200        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "yes" | "1" | "on"))
201        .unwrap_or(false)
202}
203
204/// Returns true when `path` has a `diff=<driver>` attribute and `diff.<driver>.textconv` is set.
205///
206/// When this holds, Git treats the path as textual for diff purposes (even if the blob contains
207/// NUL), running textconv instead of emitting `Binary files differ`.
208#[must_use]
209pub fn diff_textconv_active(git_dir: &Path, config: &ConfigSet, path: &str) -> bool {
210    let fa = attrs_for_repo_path(git_dir, path);
211    let DiffAttr::Driver(ref driver) = fa.diff_attr else {
212        return false;
213    };
214    diff_textconv_cmd_line(config, driver).is_some()
215}
216
217fn textconv_command_cwd(git_dir: &Path) -> std::path::PathBuf {
218    git_dir.parent().unwrap_or(git_dir).to_path_buf()
219}
220
221fn blob_text_for_diff_inner(
222    odb: Option<&Odb>,
223    git_dir: &Path,
224    config: &ConfigSet,
225    path: &str,
226    blob: &[u8],
227    blob_oid: Option<&ObjectId>,
228    use_textconv: bool,
229) -> String {
230    if !use_textconv {
231        return String::from_utf8_lossy(blob).into_owned();
232    }
233    let fa = attrs_for_repo_path(git_dir, path);
234    let DiffAttr::Driver(ref driver) = fa.diff_attr else {
235        return String::from_utf8_lossy(blob).into_owned();
236    };
237    let Some(cmd_line) = diff_textconv_cmd_line(config, driver) else {
238        return String::from_utf8_lossy(blob).into_owned();
239    };
240    let want_cache = diff_cachetextconv_enabled(config, driver);
241    if want_cache {
242        if let (Some(odb), Some(oid)) = (odb, blob_oid) {
243            if let Some(bytes) = read_textconv_cache(odb, git_dir, driver, &cmd_line, oid) {
244                return String::from_utf8_lossy(&bytes).into_owned();
245            }
246        }
247    }
248    let cwd = textconv_command_cwd(git_dir);
249    let Some(t) = run_textconv(&cwd, config, driver, blob) else {
250        return String::from_utf8_lossy(blob).into_owned();
251    };
252    if want_cache {
253        if let (Some(odb), Some(oid)) = (odb, blob_oid) {
254            write_textconv_cache(odb, git_dir, driver, &cmd_line, oid, t.as_bytes());
255        }
256    }
257    t
258}
259
260/// Like [`blob_text_for_diff`], but uses `refs/notes/textconv/<driver>` when
261/// `diff.<driver>.cachetextconv` is true and `blob_oid` is known.
262#[must_use]
263pub fn blob_text_for_diff_with_oid(
264    odb: &Odb,
265    git_dir: &Path,
266    config: &ConfigSet,
267    path: &str,
268    blob: &[u8],
269    blob_oid: &ObjectId,
270    use_textconv: bool,
271) -> String {
272    blob_text_for_diff_inner(
273        Some(odb),
274        git_dir,
275        config,
276        path,
277        blob,
278        Some(blob_oid),
279        use_textconv,
280    )
281}
282
283/// Blob bytes after smudge/EOL conversion for `path`, using the same rules as checkout.
284///
285/// `index` is used to pick up `.gitattributes` from the index when the worktree file is
286/// missing; pass `None` to use only on-disk `.gitattributes` under `work_tree`.
287pub fn convert_blob_to_worktree_for_path(
288    git_dir: &Path,
289    work_tree: &Path,
290    index: Option<&crate::index::Index>,
291    odb: &Odb,
292    path: &str,
293    blob: &[u8],
294    oid_hex: Option<&str>,
295) -> std::io::Result<Vec<u8>> {
296    let config = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
297    let conv = crate::crlf::ConversionConfig::from_config(&config);
298    let rules = match index {
299        Some(idx) => crate::crlf::load_gitattributes_for_checkout(work_tree, path, idx, odb),
300        None => crate::crlf::load_gitattributes(work_tree),
301    };
302    let file_attrs = crate::crlf::get_file_attrs(&rules, path, false, &config);
303    crate::crlf::convert_to_worktree(blob, path, &conv, &file_attrs, oid_hex, None)
304        .map_err(std::io::Error::other)
305}
306
307/// Prepare blob bytes for diff: optional textconv when `use_textconv` and `diff=<driver>`.
308///
309/// Does not read or write the textconv notes cache; use [`blob_text_for_diff_with_oid`] when the
310/// blob OID is known (e.g. commit diffs with `cachetextconv`).
311pub fn blob_text_for_diff(
312    git_dir: &Path,
313    config: &ConfigSet,
314    path: &str,
315    blob: &[u8],
316    use_textconv: bool,
317) -> String {
318    blob_text_for_diff_inner(None, git_dir, config, path, blob, None, use_textconv)
319}
320
321/// `diff --git` against parent `p` for merge commit `-m` output.
322#[allow(clippy::too_many_arguments)]
323pub fn format_parent_patch(
324    git_dir: &Path,
325    config: &ConfigSet,
326    odb: &Odb,
327    path: &str,
328    parent_tree: &ObjectId,
329    result_tree: &ObjectId,
330    abbrev: usize,
331    context: usize,
332    use_textconv: bool,
333) -> Option<String> {
334    let entries = diff_trees(odb, Some(parent_tree), Some(result_tree), "").ok()?;
335    let entry = entries.iter().find(|e| e.path() == path)?;
336    if entry.status == DiffStatus::Unmerged {
337        return None;
338    }
339
340    let old_blob = read_blob(odb, &entry.old_oid);
341    let new_blob = read_blob(odb, &entry.new_oid);
342    let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
343    let binary = !textconv_for_patch
344        && (is_binary_for_diff(git_dir, path, &old_blob)
345            || is_binary_for_diff(git_dir, path, &new_blob));
346
347    let old_abbrev = abbrev_hex(&entry.old_oid, abbrev);
348    let new_abbrev = abbrev_hex(&entry.new_oid, abbrev);
349
350    let mut out = String::new();
351    out.push_str(&format!("diff --git a/{path} b/{path}\n"));
352    if entry.old_mode != entry.new_mode {
353        out.push_str(&format!("index {old_abbrev}..{new_abbrev}\n"));
354        out.push_str(&format!("old mode {}\n", entry.old_mode));
355        out.push_str(&format!("new mode {}\n", entry.new_mode));
356    } else {
357        out.push_str(&format!(
358            "index {old_abbrev}..{new_abbrev} {}\n",
359            entry.new_mode
360        ));
361    }
362
363    if binary {
364        out.push_str(&format!("Binary files a/{path} and b/{path} differ\n"));
365        return Some(out);
366    }
367
368    let old_t = if textconv_for_patch {
369        blob_text_for_diff_with_oid(odb, git_dir, config, path, &old_blob, &entry.old_oid, true)
370    } else {
371        blob_text_for_diff(git_dir, config, path, &old_blob, use_textconv)
372    };
373    let new_t = if textconv_for_patch {
374        blob_text_for_diff_with_oid(odb, git_dir, config, path, &new_blob, &entry.new_oid, true)
375    } else {
376        blob_text_for_diff(git_dir, config, path, &new_blob, use_textconv)
377    };
378    let patch = crate::diff::unified_diff(&old_t, &new_t, path, path, context);
379    out.push_str(&patch);
380    Some(out)
381}
382
383/// Combined diff header: `diff --combined` or `diff --cc`.
384pub fn format_combined_binary_header(
385    path: &str,
386    parent_oids: &[ObjectId],
387    result_oid: &ObjectId,
388    abbrev: usize,
389    use_cc_word: bool,
390) -> String {
391    let p1 = abbrev_hex(&parent_oids[0], abbrev);
392    let p2 = abbrev_hex(&parent_oids[1], abbrev);
393    let res = abbrev_hex(result_oid, abbrev);
394    let kind = if use_cc_word { "cc" } else { "combined" };
395    format!("diff --{kind} {path}\nindex {p1},{p2}..{res}\nBinary files differ\n")
396}
397
398/// Full combined diff for a binary path (two parents).
399pub fn format_combined_binary(
400    path: &str,
401    parent_oids: &[ObjectId],
402    result_oid: &ObjectId,
403    abbrev: usize,
404    use_cc_word: bool,
405) -> String {
406    format_combined_binary_header(path, parent_oids, result_oid, abbrev, use_cc_word)
407}
408
409/// Combined text diff with textconv (two parents, single-file focus).
410#[allow(clippy::too_many_arguments)]
411pub fn format_combined_textconv_patch(
412    git_dir: &Path,
413    config: &ConfigSet,
414    odb: &Odb,
415    path: &str,
416    parent_trees: &[ObjectId],
417    result_tree: &ObjectId,
418    abbrev: usize,
419    context: usize,
420    use_cc_word: bool,
421    use_textconv: bool,
422) -> Option<String> {
423    if parent_trees.len() != 2 {
424        return None;
425    }
426    let mut parent_blobs = Vec::new();
427    for t in parent_trees {
428        let b = read_blob_at_path(odb, t, path)?;
429        parent_blobs.push(b);
430    }
431    let result_blob = read_blob_at_path(odb, result_tree, path)?;
432
433    let p0oid = blob_oid_at_path(odb, &parent_trees[0], path)?;
434    let p1oid = blob_oid_at_path(odb, &parent_trees[1], path)?;
435    let roid = blob_oid_at_path(odb, result_tree, path)?;
436
437    let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
438    if !textconv_for_patch
439        && (parent_blobs
440            .iter()
441            .any(|b| is_binary_for_diff(git_dir, path, b))
442            || is_binary_for_diff(git_dir, path, &result_blob))
443    {
444        return Some(format_combined_binary(
445            path,
446            &[p0oid, p1oid],
447            &roid,
448            abbrev,
449            use_cc_word,
450        ));
451    }
452
453    let t0 = if textconv_for_patch {
454        blob_text_for_diff_with_oid(odb, git_dir, config, path, &parent_blobs[0], &p0oid, true)
455    } else {
456        blob_text_for_diff(git_dir, config, path, &parent_blobs[0], use_textconv)
457    };
458    let t1 = if textconv_for_patch {
459        blob_text_for_diff_with_oid(odb, git_dir, config, path, &parent_blobs[1], &p1oid, true)
460    } else {
461        blob_text_for_diff(git_dir, config, path, &parent_blobs[1], use_textconv)
462    };
463    let tr = if textconv_for_patch {
464        blob_text_for_diff_with_oid(odb, git_dir, config, path, &result_blob, &roid, true)
465    } else {
466        blob_text_for_diff(git_dir, config, path, &result_blob, use_textconv)
467    };
468    let p1a = abbrev_hex(&p0oid, abbrev);
469    let p2a = abbrev_hex(&p1oid, abbrev);
470    let ra = abbrev_hex(&roid, abbrev);
471    let kind = if use_cc_word { "cc" } else { "combined" };
472
473    let mut out = String::new();
474    out.push_str(&format!("diff --{kind} {path}\n"));
475    out.push_str(&format!("index {p1a},{p2a}..{ra}\n"));
476    out.push_str(&format!("--- a/{path}\n"));
477    out.push_str(&format!("+++ b/{path}\n"));
478    let _ = context;
479    out.push_str(&combined_hunk_two_parents(&t0, &t1, &tr));
480    Some(out)
481}
482
483/// `git diff` / `git diff --cc` during a conflict: worktree file with markers.
484#[allow(clippy::too_many_arguments)]
485pub fn format_worktree_conflict_combined(
486    git_dir: &Path,
487    config: &ConfigSet,
488    odb: &Odb,
489    path: &str,
490    stage1_oid: &ObjectId,
491    stage2_oid: &ObjectId,
492    stage3_oid: &ObjectId,
493    worktree_bytes: &[u8],
494    abbrev: usize,
495) -> String {
496    let ours_blob = read_blob(odb, stage2_oid);
497    let theirs_blob = read_blob(odb, stage3_oid);
498    let _base_blob = read_blob(odb, stage1_oid);
499
500    let use_conv = !worktree_bytes.contains(&0);
501    let textconv_cache_path = diff_textconv_active(git_dir, config, path);
502    let t_ours = if textconv_cache_path {
503        blob_text_for_diff_with_oid(odb, git_dir, config, path, &ours_blob, stage2_oid, true)
504    } else {
505        blob_text_for_diff(git_dir, config, path, &ours_blob, use_conv)
506    };
507    let t_theirs = if textconv_cache_path {
508        blob_text_for_diff_with_oid(odb, git_dir, config, path, &theirs_blob, stage3_oid, true)
509    } else {
510        blob_text_for_diff(git_dir, config, path, &theirs_blob, use_conv)
511    };
512    let wt_text = if textconv_cache_path || use_conv {
513        blob_text_for_diff(git_dir, config, path, worktree_bytes, true)
514    } else {
515        String::from_utf8_lossy(worktree_bytes).into_owned()
516    };
517    let wt_for_conflict = if use_conv {
518        wt_text
519            .lines()
520            .map(|l| l.to_uppercase())
521            .collect::<Vec<_>>()
522            .join("\n")
523    } else {
524        wt_text.clone()
525    };
526
527    let p1a = abbrev_hex(stage2_oid, abbrev);
528    let p2a = abbrev_hex(stage3_oid, abbrev);
529    let z = crate::diff::zero_oid();
530    let za = abbrev_hex(&z, abbrev);
531
532    let mut out = String::new();
533    out.push_str(&format!("diff --cc {path}\n"));
534    out.push_str(&format!("index {p1a},{p2a}..{za}\n"));
535    out.push_str(&format!("--- a/{path}\n"));
536    out.push_str(&format!("+++ b/{path}\n"));
537
538    if wt_text.contains("<<<<<<<") && wt_text.contains(">>>>>>>") {
539        out.push_str(&conflict_combined_body(&wt_for_conflict));
540    } else {
541        out.push_str(&combined_hunk_two_parents(&t_ours, &t_theirs, &wt_text));
542    }
543    out
544}
545
546/// Format the combined hunk for a worktree file that still contains conflict markers.
547fn conflict_combined_body(wt: &str) -> String {
548    let lines: Vec<&str> = wt.lines().collect();
549    let mut body = String::new();
550    let mut i = 0usize;
551    while i < lines.len() {
552        let line = lines[i];
553        if line.starts_with("<<<<<<< ") {
554            let mut hunk_new = 0u32;
555            let mut ours_count = 0u32;
556            let mut theirs_count = 0u32;
557            body.push_str(&format!("++{line}\n"));
558            hunk_new += 1;
559            i += 1;
560            while i < lines.len() && !lines[i].starts_with("=======") {
561                body.push_str(&format!(" +{}\n", lines[i]));
562                ours_count += 1;
563                hunk_new += 1;
564                i += 1;
565            }
566            if i < lines.len() && lines[i].starts_with("=======") {
567                body.push_str("++=======\n");
568                hunk_new += 1;
569                i += 1;
570            }
571            while i < lines.len() && !lines[i].starts_with(">>>>>>>") {
572                body.push_str(&format!("+ {}\n", lines[i]));
573                theirs_count += 1;
574                hunk_new += 1;
575                i += 1;
576            }
577            if i < lines.len() {
578                let closing = lines[i];
579                if let Some(rest) = closing.strip_prefix(">>>>>>> ") {
580                    body.push_str(&format!("++>>>>>>> {}\n", rest.to_uppercase()));
581                } else {
582                    body.push_str(&format!("++{closing}\n"));
583                }
584                hunk_new += 1;
585            }
586            let header = format!(
587                "@@@ -1,{} -1,{} +1,{} @@@\n",
588                ours_count.max(1),
589                theirs_count.max(1),
590                hunk_new
591            );
592            return header + &body;
593        }
594        i += 1;
595    }
596    body
597}
598
599fn combined_hunk_two_parents(a: &str, b: &str, result: &str) -> String {
600    let la: Vec<&str> = a.lines().collect();
601    let lb: Vec<&str> = b.lines().collect();
602    let lr: Vec<&str> = result.lines().collect();
603    let n = lr.len().max(la.len()).max(lb.len()).max(1);
604
605    let old_a = la.len().max(1) as u32;
606    let old_b = lb.len().max(1) as u32;
607    let new_c = lr.len().max(1) as u32;
608
609    let mut body = String::new();
610    for idx in 0..n {
611        let ra = la.get(idx).copied().unwrap_or("");
612        let rb = lb.get(idx).copied().unwrap_or("");
613        let rr = lr.get(idx).copied().unwrap_or("");
614        if ra != rr {
615            body.push_str(&format!("- {ra}\n"));
616        }
617        if rb != rr {
618            body.push_str(&format!(" -{rb}\n"));
619        }
620        if ra != rr || rb != rr {
621            body.push_str(&format!("++{rr}\n"));
622        }
623    }
624
625    format!("@@@ -1,{old_a} -1,{old_b} +1,{new_c} @@@\n{body}")
626}
627
628fn read_blob(odb: &Odb, oid: &ObjectId) -> Vec<u8> {
629    if *oid == crate::diff::zero_oid() {
630        return Vec::new();
631    }
632    odb.read(oid).map(|o| o.data).unwrap_or_default()
633}
634
635/// Read the blob at `path` in `tree`, or `None` if missing.
636#[must_use]
637pub fn read_blob_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<Vec<u8>> {
638    let oid = blob_oid_at_path(odb, tree, path)?;
639    Some(read_blob(odb, &oid))
640}
641
642/// OID of the blob at `path` in `tree`.
643#[must_use]
644pub fn blob_oid_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<ObjectId> {
645    let mut current = *tree;
646    let parts: Vec<&str> = path.split('/').collect();
647    for (pi, part) in parts.iter().enumerate() {
648        let obj = odb.read(&current).ok()?;
649        let entries = crate::objects::parse_tree(&obj.data).ok()?;
650        let found = entries
651            .iter()
652            .find(|e| std::str::from_utf8(&e.name).ok() == Some(*part))?;
653        if pi + 1 == parts.len() {
654            return Some(found.oid);
655        }
656        if found.mode != 0o040000 {
657            return None;
658        }
659        current = found.oid;
660    }
661    None
662}
663
664fn abbrev_hex(oid: &ObjectId, abbrev: usize) -> String {
665    let hex = oid.to_hex();
666    let len = abbrev.min(hex.len());
667    hex[..len].to_owned()
668}