Skip to main content

grit_lib/
rev_parse.rs

1//! Revision parsing and repository discovery helpers for `rev-parse`.
2//!
3//! This module implements a focused subset of Git's revision parser used by
4//! `grit rev-parse` in v2 scope: repository/work-tree discovery flags, basic
5//! object-name resolution, and lightweight peeling (`^{}`, `^{object}`,
6//! `^{commit}`).
7
8use std::borrow::Cow;
9use std::ffi::OsStr;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12
13use regex::Regex;
14
15use std::collections::{HashMap, HashSet};
16
17use crate::check_ref_format::{check_refname_format, RefNameOptions};
18use crate::config::ConfigSet;
19use crate::error::{Error, Result};
20use crate::objects::{parse_commit, parse_tag, parse_tree, ObjectId, ObjectKind};
21use crate::pack;
22use crate::reflog::read_reflog;
23use crate::refs;
24use crate::repo::Repository;
25
26/// Return `Some(repo)` when a repository can be discovered at `start`.
27///
28/// # Parameters
29///
30/// - `start` - starting path for discovery; when `None`, uses current directory.
31///
32/// # Errors
33///
34/// Returns errors other than "not a repository" (for example I/O and path
35/// canonicalization failures).
36pub fn discover_optional(start: Option<&Path>) -> Result<Option<Repository>> {
37    match Repository::discover(start) {
38        Ok(repo) => Ok(Some(repo)),
39        Err(Error::NotARepository(msg)) => {
40            // Repository not found while walking parents is optional, but
41            // structural `.git` problems at the starting directory should be
42            // surfaced so callers can show diagnostics (e.g. t0002/t0009).
43            if msg.contains("invalid gitfile format")
44                || msg.contains("gitfile does not contain 'gitdir:' line")
45                || msg.contains("not a regular file")
46            {
47                return Err(Error::NotARepository(msg));
48            }
49
50            if let Some(start) = start {
51                let start = if start.is_absolute() {
52                    start.to_path_buf()
53                } else if let Ok(cwd) = std::env::current_dir() {
54                    cwd.join(start)
55                } else {
56                    start.to_path_buf()
57                };
58                let dot_git = start.join(".git");
59                if dot_git.is_file() || dot_git.is_symlink() {
60                    return Err(Error::NotARepository(msg));
61                }
62            }
63
64            Ok(None)
65        }
66        Err(err) => Err(err),
67    }
68}
69
70/// Compute whether `cwd` is inside the repository's work tree.
71#[must_use]
72pub fn is_inside_work_tree(repo: &Repository, cwd: &Path) -> bool {
73    let Some(work_tree) = &repo.work_tree else {
74        return false;
75    };
76    path_is_within(cwd, work_tree)
77}
78
79/// Compute whether `cwd` is inside the repository's git-dir.
80#[must_use]
81pub fn is_inside_git_dir(repo: &Repository, cwd: &Path) -> bool {
82    path_is_within(cwd, &repo.git_dir)
83}
84
85/// Compute the `--show-prefix` output.
86///
87/// Returns an empty string when `cwd` is at repository root or outside the work
88/// tree. Returned prefixes always use `/` separators and end with `/`.
89#[must_use]
90pub fn show_prefix(repo: &Repository, cwd: &Path) -> String {
91    let Some(work_tree) = &repo.work_tree else {
92        return String::new();
93    };
94    if !path_is_within(cwd, work_tree) {
95        return String::new();
96    }
97    if cwd == work_tree {
98        return String::new();
99    }
100    let Ok(rel) = cwd.strip_prefix(work_tree) else {
101        return String::new();
102    };
103    let mut out = rel
104        .components()
105        .filter_map(component_to_text)
106        .collect::<Vec<_>>()
107        .join("/");
108    if !out.is_empty() {
109        out.push('/');
110    }
111    out
112}
113
114/// Superproject work tree when `git_dir` lives under `.../<wt>/.git/modules/...` (nested submodule).
115///
116/// Used when the submodule's recorded path in the superproject index does not match the on-disk
117/// layout (e.g. `dir/sub` recorded but git dir is `.../modules/dir/modules/sub`), so
118/// `ls-files`-based superproject detection cannot find a gitlink.
119#[must_use]
120pub fn superproject_work_tree_from_nested_git_modules(git_dir: &Path) -> Option<PathBuf> {
121    let mut p = git_dir.to_path_buf();
122    while let Some(parent) = p.parent() {
123        if p.file_name().is_some_and(|n| n == "modules")
124            && parent.file_name().is_some_and(|n| n == ".git")
125        {
126            return parent.parent().map(PathBuf::from);
127        }
128        if parent == p {
129            break;
130        }
131        p = parent.to_path_buf();
132    }
133    None
134}
135
136/// Resolve a symbolic ref name to its full form.
137///
138/// For `HEAD`, returns the symbolic target (e.g., `refs/heads/main`).
139/// For branch names, returns `refs/heads/<name>`.
140/// For tag names, returns `refs/tags/<name>`.
141/// Returns `None` when the name cannot be resolved symbolically.
142#[must_use]
143pub fn symbolic_full_name(repo: &Repository, spec: &str) -> Option<String> {
144    // @{upstream} / @{push}: must error from rev-parse when invalid; do not fall through to DWIM.
145    if upstream_suffix_info(spec).is_some() {
146        return resolve_upstream_symbolic_name(repo, spec).ok();
147    }
148
149    if let Ok(Some(branch)) = expand_at_minus_to_branch_name(repo, spec) {
150        let ref_name = format!("refs/heads/{branch}");
151        if refs::resolve_ref(&repo.git_dir, &ref_name).is_ok() {
152            return Some(ref_name);
153        }
154        return None;
155    }
156
157    if spec == "HEAD" {
158        if let Ok(Some(target)) = refs::read_symbolic_ref(&repo.git_dir, "HEAD") {
159            return Some(target);
160        }
161        return None;
162    }
163    // If it's already a full ref path
164    if spec.starts_with("refs/") {
165        if refs::resolve_ref(&repo.git_dir, spec).is_ok() {
166            return Some(spec.to_owned());
167        }
168        return None;
169    }
170    // DWIM: try refs/heads, refs/tags, refs/remotes
171    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
172        let candidate = format!("{prefix}{spec}");
173        if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
174            return Some(candidate);
175        }
176    }
177    // Remote name alone: `one` → `refs/remotes/one/HEAD` when `remote.one.url` exists (matches Git).
178    if let Some(full) = remote_tracking_head_symbolic_target(repo, spec) {
179        return Some(full);
180    }
181    None
182}
183
184/// When `name` is a configured remote, return the full ref `refs/remotes/<name>/HEAD` resolves to.
185fn remote_tracking_head_symbolic_target(repo: &Repository, name: &str) -> Option<String> {
186    if name.contains('/')
187        || matches!(
188            name,
189            "HEAD" | "FETCH_HEAD" | "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD"
190        )
191    {
192        return None;
193    }
194    let config = ConfigSet::load(Some(&repo.git_dir), true).ok()?;
195    let url_key = format!("remote.{name}.url");
196    config.get(&url_key)?;
197    let head_ref = format!("refs/remotes/{name}/HEAD");
198    let target = refs::read_symbolic_ref(&repo.git_dir, &head_ref).ok()??;
199    Some(target)
200}
201
202/// Expand an `@{-N}` token to the corresponding previous branch name.
203///
204/// Returns:
205/// - `Ok(Some(branch_name))` when `spec` is an `@{-N}` token and resolves
206///   to a branch name.
207/// - `Ok(None)` when `spec` is not an `@{-N}` token.
208/// - `Err(...)` when `spec` matches `@{-N}` syntax but cannot be resolved.
209pub fn expand_at_minus_to_branch_name(repo: &Repository, spec: &str) -> Result<Option<String>> {
210    if !spec.starts_with("@{-") || !spec.ends_with('}') {
211        return Ok(None);
212    }
213    let inner = &spec[3..spec.len() - 1];
214    let n: usize = inner
215        .parse()
216        .map_err(|_| Error::InvalidRef(format!("invalid N in @{{-N}} for '{spec}'")))?;
217    if n < 1 {
218        return Ok(None);
219    }
220    resolve_at_minus_to_branch(repo, n).map(Some)
221}
222
223/// Resolve `@{-N}` to the commit OID it points to.
224pub fn resolve_at_minus_to_oid(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
225    try_resolve_at_minus(repo, spec)
226}
227
228/// Abbreviate a full ref name to its shortest unambiguous form.
229///
230/// For example, `refs/heads/main` becomes `main`.
231#[must_use]
232pub fn abbreviate_ref_name(full_name: &str) -> String {
233    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
234        if let Some(short) = full_name.strip_prefix(prefix) {
235            return short.to_owned();
236        }
237    }
238    if let Some(short) = full_name.strip_prefix("refs/") {
239        return short.to_owned();
240    }
241    full_name.to_owned()
242}
243
244/// Returns `(base_without_suffix, is_push)` when `spec` ends with `@{upstream}` / `@{u}` / `@{push}`
245/// (case-insensitive for upstream forms). `is_push` is true only for `@{push}`.
246#[must_use]
247pub fn upstream_suffix_info(spec: &str) -> Option<(&str, bool)> {
248    let lower = spec.to_ascii_lowercase();
249    if lower.ends_with("@{push}") {
250        let base = &spec[..spec.len() - 7];
251        return Some((base, true));
252    }
253    if lower.ends_with("@{upstream}") {
254        let base = &spec[..spec.len() - 11];
255        return Some((base, false));
256    }
257    if lower.ends_with("@{u}") {
258        let base = &spec[..spec.len() - 4];
259        return Some((base, false));
260    }
261    None
262}
263
264/// Resolve `@{upstream}` / `@{u}` / `@{push}` to the symbolic full ref name (for `rev-parse --symbolic-full-name`).
265pub fn resolve_upstream_symbolic_name(repo: &Repository, spec: &str) -> Result<String> {
266    let Some((base, is_push)) = upstream_suffix_info(spec) else {
267        return Err(Error::InvalidRef(format!("not an upstream spec: {spec}")));
268    };
269    resolve_upstream_full_ref_name(repo, base, is_push)
270}
271
272fn resolve_upstream_full_ref_name(repo: &Repository, base: &str, is_push: bool) -> Result<String> {
273    if is_push {
274        return resolve_push_ref_name(repo, base);
275    }
276    let (branch_key, display_branch) = resolve_upstream_branch_context(repo, base)?;
277    let config_path = repo.git_dir.join("config");
278    let config_content = fs::read_to_string(&config_path).map_err(Error::Io)?;
279    let Some((remote, merge)) = parse_branch_tracking(&config_content, &branch_key) else {
280        return Err(Error::Message(format!(
281            "fatal: no upstream configured for branch '{display_branch}'"
282        )));
283    };
284    if remote == "." {
285        let m = merge.trim();
286        if m.starts_with("refs/") {
287            return Ok(m.to_owned());
288        }
289        return Ok(format!("refs/heads/{m}"));
290    }
291    let merge_branch = merge
292        .strip_prefix("refs/heads/")
293        .ok_or_else(|| Error::InvalidRef(format!("invalid merge ref: {merge}")))?;
294    let tracking = format!("refs/remotes/{remote}/{merge_branch}");
295    if refs::resolve_ref(&repo.git_dir, &tracking).is_err() {
296        return Err(Error::Message(format!(
297            "fatal: upstream branch '{merge}' not stored as a remote-tracking branch"
298        )));
299    }
300    Ok(tracking)
301}
302
303/// Resolve the remote-tracking ref used as `@{push}` for `branch_short` (`refs/heads/...` name).
304///
305/// Honors `remote.pushRemote`, `branch.<name>.pushRemote`, `push.default`, and per-remote
306/// `push` refspecs (exact `refs/heads/<branch>:refs/heads/<dest>` mappings).
307pub fn resolve_push_full_ref_for_branch(repo: &Repository, branch_short: &str) -> Result<String> {
308    let config_path = crate::refs::common_dir(&repo.git_dir)
309        .unwrap_or_else(|| repo.git_dir.clone())
310        .join("config");
311    let config_content = fs::read_to_string(&config_path).map_err(Error::Io)?;
312
313    let upstream_tracking =
314        parse_branch_tracking(&config_content, branch_short).and_then(|(remote, merge)| {
315            if remote == "." {
316                return None;
317            }
318            let mb = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
319            let tr = format!("refs/remotes/{remote}/{mb}");
320            if refs::resolve_ref(&repo.git_dir, &tr).is_ok() {
321                Some(tr)
322            } else {
323                None
324            }
325        });
326
327    let push_remote = parse_config_value(&config_content, "remote", "pushRemote")
328        .or_else(|| parse_config_value(&config_content, "remote", "pushDefault"))
329        .or_else(|| {
330            let section = format!("[branch \"{}\"]", branch_short);
331            let mut in_section = false;
332            for line in config_content.lines() {
333                let trimmed = line.trim();
334                if trimmed.starts_with('[') {
335                    in_section = trimmed == section;
336                    continue;
337                }
338                if in_section {
339                    if let Some(v) = trimmed
340                        .strip_prefix("pushremote = ")
341                        .or_else(|| trimmed.strip_prefix("pushRemote = "))
342                    {
343                        return Some(v.trim().to_owned());
344                    }
345                }
346            }
347            None
348        })
349        .or_else(|| {
350            parse_branch_tracking(&config_content, branch_short)
351                .map(|(r, _)| r)
352                .filter(|r| r != ".")
353        });
354
355    let Some(push_remote_name) = push_remote else {
356        return upstream_tracking.ok_or_else(|| {
357            Error::Message("fatal: branch has no configured push remote".to_owned())
358        });
359    };
360
361    // When the push remote has any configured `push` refspecs, they take priority
362    // over `push.default` entirely (matching git's branch_get_push_1: the
363    // `if (remote->push.nr)` block returns before the push.default switch). This
364    // means an explicit push refspec overrides even `push.default = nothing`.
365    if remote_has_push_refspec(&config_content, &push_remote_name) {
366        return match push_refspec_mapped_tracking(&config_content, &push_remote_name, branch_short)
367        {
368            Some(mapped) => Ok(mapped),
369            None => Err(Error::Message(format!(
370                "fatal: push refspecs for '{push_remote_name}' do not include '{branch_short}'"
371            ))),
372        };
373    }
374
375    let push_default = parse_config_value(&config_content, "push", "default");
376    let push_default = push_default.as_deref().unwrap_or("simple");
377
378    if push_default == "nothing" {
379        return Err(Error::Message(
380            "fatal: push.default is nothing; no push destination".to_owned(),
381        ));
382    }
383
384    let current_tracking = format!("refs/remotes/{push_remote_name}/{branch_short}");
385
386    match push_default {
387        "upstream" => upstream_tracking.ok_or_else(|| {
388            Error::Message(format!(
389                "fatal: branch '{branch_short}' has no upstream for push.default upstream"
390            ))
391        }),
392        "simple" => {
393            if let Some(ref up) = upstream_tracking {
394                if up == &current_tracking
395                    && refs::resolve_ref(&repo.git_dir, &current_tracking).is_ok()
396                {
397                    return Ok(current_tracking);
398                }
399            }
400            Err(Error::Message(
401                "fatal: push.default simple: upstream and push ref differ".to_owned(),
402            ))
403        }
404        "current" | "matching" | _ => {
405            if refs::resolve_ref(&repo.git_dir, &current_tracking).is_ok() {
406                Ok(current_tracking)
407            } else if let Some(up) = upstream_tracking {
408                Ok(up)
409            } else {
410                Err(Error::Message(format!(
411                    "fatal: no push tracking ref for branch '{branch_short}'"
412                )))
413            }
414        }
415    }
416}
417
418/// Collect the values of all `push = <refspec>` entries in `[remote "<name>"]`.
419fn remote_push_refspecs(config_content: &str, remote_name: &str) -> Vec<String> {
420    let section = format!("[remote \"{remote_name}\"]");
421    let mut in_section = false;
422    let mut out = Vec::new();
423    for line in config_content.lines() {
424        let trimmed = line.trim();
425        if trimmed.starts_with('[') {
426            in_section = trimmed == section;
427            continue;
428        }
429        if !in_section {
430            continue;
431        }
432        if let Some(val) = trimmed
433            .strip_prefix("push = ")
434            .or_else(|| trimmed.strip_prefix("push="))
435        {
436            if let Some(spec) = val.split_whitespace().next() {
437                out.push(spec.trim().to_owned());
438            }
439        }
440    }
441    out
442}
443
444/// True when the remote has at least one `push` refspec configured.
445fn remote_has_push_refspec(config_content: &str, remote_name: &str) -> bool {
446    !remote_push_refspecs(config_content, remote_name).is_empty()
447}
448
449/// Match `refname` against a refspec `pattern` (`prefix*suffix`), substituting the
450/// matched glob portion into `replacement` (also `prefix*suffix`). Mirrors git's
451/// `match_refname_with_pattern`. For non-pattern refspecs, both sides are exact.
452fn match_refname_with_pattern(pattern: &str, refname: &str, replacement: &str) -> Option<String> {
453    match (pattern.find('*'), replacement.find('*')) {
454        (Some(pstar), Some(vstar)) => {
455            let kprefix = &pattern[..pstar];
456            let ksuffix = &pattern[pstar + 1..];
457            if !refname.starts_with(kprefix) || !refname.ends_with(ksuffix) {
458                return None;
459            }
460            if refname.len() < kprefix.len() + ksuffix.len() {
461                return None;
462            }
463            let middle = &refname[kprefix.len()..refname.len() - ksuffix.len()];
464            Some(format!(
465                "{}{}{}",
466                &replacement[..vstar],
467                middle,
468                &replacement[vstar + 1..]
469            ))
470        }
471        (None, None) => {
472            if pattern == refname {
473                Some(replacement.to_owned())
474            } else {
475                None
476            }
477        }
478        // A pattern on one side but not the other is invalid in git; treat as no match.
479        _ => None,
480    }
481}
482
483/// Apply the remote's configured `push` refspecs to `refs/heads/<branch_short>`, then
484/// map the resulting push destination to a local remote-tracking ref via the remote's
485/// `fetch` refspecs. Mirrors git's `apply_refspecs(&remote->push, ...)` followed by
486/// `tracking_for_push_dest` (which applies `&remote->fetch`).
487fn push_refspec_mapped_tracking(
488    config_content: &str,
489    remote_name: &str,
490    branch_short: &str,
491) -> Option<String> {
492    let src = format!("refs/heads/{branch_short}");
493
494    // First, map the branch through a push refspec to the push destination on the remote.
495    let mut dst = None;
496    for spec in remote_push_refspecs(config_content, remote_name) {
497        let spec = spec.strip_prefix('+').unwrap_or(&spec);
498        let Some((left, right)) = spec.split_once(':') else {
499            continue;
500        };
501        if let Some(mapped) = match_refname_with_pattern(left.trim(), &src, right.trim()) {
502            dst = Some(mapped);
503            break;
504        }
505    }
506    let dst = dst?;
507
508    // Then map the push destination to a local tracking ref via the fetch refspecs.
509    map_dest_to_tracking(config_content, remote_name, &dst)
510}
511
512/// Map a push destination ref (e.g. `refs/heads/magic/topic`) on `remote_name` to the
513/// local remote-tracking ref using that remote's `fetch` refspecs. Falls back to the
514/// conventional `refs/remotes/<remote>/<rest>` mapping when no fetch refspec matches.
515fn map_dest_to_tracking(config_content: &str, remote_name: &str, dst: &str) -> Option<String> {
516    let section = format!("[remote \"{remote_name}\"]");
517    let mut in_section = false;
518    for line in config_content.lines() {
519        let trimmed = line.trim();
520        if trimmed.starts_with('[') {
521            in_section = trimmed == section;
522            continue;
523        }
524        if !in_section {
525            continue;
526        }
527        let Some(val) = trimmed
528            .strip_prefix("fetch = ")
529            .or_else(|| trimmed.strip_prefix("fetch="))
530        else {
531            continue;
532        };
533        let Some(spec) = val.split_whitespace().next() else {
534            continue;
535        };
536        let spec = spec.trim().strip_prefix('+').unwrap_or(spec.trim());
537        let Some((left, right)) = spec.split_once(':') else {
538            continue;
539        };
540        if let Some(mapped) = match_refname_with_pattern(left.trim(), dst, right.trim()) {
541            return Some(mapped);
542        }
543    }
544    // Conventional fallback: refs/heads/<x> -> refs/remotes/<remote>/<x>.
545    dst.strip_prefix("refs/heads/")
546        .map(|rest| format!("refs/remotes/{remote_name}/{rest}"))
547}
548
549fn resolve_push_ref_name(repo: &Repository, base: &str) -> Result<String> {
550    let (branch_key, _display) = resolve_upstream_branch_context(repo, base)?;
551    resolve_push_full_ref_for_branch(repo, &branch_key)
552}
553
554/// Returns `(config_branch_key, display_name_for_errors)` for upstream resolution.
555fn resolve_upstream_branch_context(repo: &Repository, base: &str) -> Result<(String, String)> {
556    let base = if base == "HEAD" {
557        Cow::Borrowed("")
558    } else if base.starts_with("@{-") && base.ends_with('}') {
559        if let Ok(Some(b)) = expand_at_minus_to_branch_name(repo, base) {
560            Cow::Owned(b)
561        } else {
562            Cow::Borrowed(base)
563        }
564    } else {
565        Cow::Borrowed(base)
566    };
567    let base = base.as_ref();
568    let base = if base == "@" { "" } else { base };
569
570    if base.is_empty() {
571        let Some(head) = refs::read_head(&repo.git_dir)? else {
572            return Err(Error::Message(
573                "fatal: HEAD does not point to a branch".to_owned(),
574            ));
575        };
576        let Some(short) = head.strip_prefix("refs/heads/") else {
577            return Err(Error::Message(
578                "fatal: HEAD does not point to a branch".to_owned(),
579            ));
580        };
581        return Ok((short.to_owned(), short.to_owned()));
582    }
583    let head_branch = refs::read_head(&repo.git_dir)?.and_then(|h| {
584        h.strip_prefix("refs/heads/")
585            .map(std::borrow::ToOwned::to_owned)
586    });
587    if head_branch.as_deref() == Some(base) {
588        return Ok((base.to_owned(), base.to_owned()));
589    }
590    let refname = format!("refs/heads/{base}");
591    if refs::resolve_ref(&repo.git_dir, &refname).is_err() {
592        return Err(Error::Message(format!("fatal: no such branch: '{base}'")));
593    }
594    Ok((base.to_owned(), base.to_owned()))
595}
596
597fn parse_config_value(config: &str, section: &str, key: &str) -> Option<String> {
598    let section_header = format!("[{}]", section);
599    let key_lower = key.to_ascii_lowercase();
600    let mut in_section = false;
601    for line in config.lines() {
602        let trimmed = line.trim();
603        if trimmed.starts_with('[') {
604            in_section = trimmed.eq_ignore_ascii_case(&section_header);
605            continue;
606        }
607        if in_section {
608            let lower = trimmed.to_ascii_lowercase();
609            if lower.starts_with(&key_lower) {
610                let rest = lower[key_lower.len()..].trim_start().to_string();
611                if rest.starts_with('=') {
612                    if let Some(eq_pos) = trimmed.find('=') {
613                        return Some(trimmed[eq_pos + 1..].trim().to_owned());
614                    }
615                }
616            }
617        }
618    }
619    None
620}
621
622/// Parse branch tracking configuration from git config content.
623fn parse_branch_tracking(config: &str, branch: &str) -> Option<(String, String)> {
624    let mut remote = None;
625    let mut merge = None;
626    let mut in_section = false;
627    let target_section = format!("[branch \"{}\"]", branch);
628
629    for line in config.lines() {
630        let trimmed = line.trim();
631        if trimmed.starts_with('[') {
632            in_section = trimmed == target_section
633                || trimmed.starts_with(&format!("[branch \"{}\"", branch));
634            continue;
635        }
636        if !in_section {
637            continue;
638        }
639        if let Some(value) = trimmed.strip_prefix("remote = ") {
640            remote = Some(value.trim().to_owned());
641        } else if let Some(value) = trimmed.strip_prefix("merge = ") {
642            merge = Some(value.trim().to_owned());
643        }
644        // Also handle with tabs
645        if let Some(value) = trimmed.strip_prefix("remote=") {
646            remote = Some(value.trim().to_owned());
647        } else if let Some(value) = trimmed.strip_prefix("merge=") {
648            merge = Some(value.trim().to_owned());
649        }
650    }
651
652    match (remote, merge) {
653        (Some(r), Some(m)) => Some((r, m)),
654        _ => None,
655    }
656}
657
658/// Resolve a revision string to an object ID.
659///
660/// Supports:
661/// - full 40-hex object IDs (must exist in loose store),
662/// - abbreviated object IDs (length 4-39, must resolve uniquely),
663/// - direct refs (`HEAD`, `refs/...`),
664/// - DWIM branch/tag/remote names (`name` -> `refs/heads/name`, etc.),
665/// - peeling suffixes: `^{}`, `^{object}`, `^{commit}`.
666///
667/// # Errors
668///
669/// Returns [`Error::ObjectNotFound`] or [`Error::InvalidRef`] when resolution
670/// fails.
671/// Split `spec` at a `..` range operator, avoiding the three-dot symmetric-diff form.
672///
673/// Returns `(left, right)` where either side may be empty (`..HEAD`, `HEAD..`, `..`).
674#[must_use]
675/// Load commit parent overrides from `.git/info/grafts` (same format as Git).
676///
677/// Used for `^N` / `^@` / `^!` / `^-` resolution and for `rev-list` traversal.
678pub fn load_graft_parents(git_dir: &Path) -> HashMap<ObjectId, Vec<ObjectId>> {
679    let graft_path = crate::repo::common_git_dir_for_config(git_dir).join("info/grafts");
680    let mut grafts = HashMap::new();
681    let Ok(contents) = fs::read_to_string(&graft_path) else {
682        return grafts;
683    };
684    for raw_line in contents.lines() {
685        let line = raw_line.trim();
686        if line.is_empty() || line.starts_with('#') {
687            continue;
688        }
689        let mut fields = line.split_whitespace();
690        let Some(commit_hex) = fields.next() else {
691            continue;
692        };
693        let Ok(commit_oid) = commit_hex.parse::<ObjectId>() else {
694            continue;
695        };
696        let mut parents = Vec::new();
697        let mut valid = true;
698        for parent_hex in fields {
699            match parent_hex.parse::<ObjectId>() {
700                Ok(parent_oid) => parents.push(parent_oid),
701                Err(_) => {
702                    valid = false;
703                    break;
704                }
705            }
706        }
707        if valid {
708            grafts.insert(commit_oid, parents);
709        }
710    }
711    grafts
712}
713
714/// Parent OIDs of `commit_oid` for revision navigation, honoring grafts.
715pub fn commit_parents_for_navigation(
716    repo: &Repository,
717    commit_oid: ObjectId,
718) -> Result<Vec<ObjectId>> {
719    let obj = repo.odb.read(&commit_oid)?;
720    if obj.kind != ObjectKind::Commit {
721        return Err(Error::InvalidRef(format!(
722            "invalid ref: {commit_oid} is not a commit"
723        )));
724    }
725    let commit = parse_commit(&obj.data)?;
726    let mut parents = commit.parents;
727    let grafts = load_graft_parents(&repo.git_dir);
728    if let Some(grafted) = grafts.get(&commit_oid) {
729        parents = grafted.clone();
730    }
731    Ok(parents)
732}
733
734#[derive(Debug, Clone, Copy)]
735enum ParentShorthandKind {
736    /// `rev^@` — all parents.
737    At,
738    /// `rev^!` — include `rev`, exclude all parents (merge-safe).
739    Bang,
740    /// `rev^-` / `rev^-N` — include `rev`, exclude parent N (1-based), include other parents.
741    Minus { exclude_parent: usize },
742}
743
744/// Returns true when `spec` ends with Git parent shorthands `^@`, `^!`, or `^-` / `^-N`.
745#[must_use]
746pub fn spec_has_parent_shorthand_suffix(spec: &str) -> bool {
747    find_parent_shorthand(spec).is_some()
748}
749
750fn find_parent_shorthand(spec: &str) -> Option<(usize, ParentShorthandKind)> {
751    let mut best: Option<(usize, ParentShorthandKind, u8)> = None;
752    for (idx, _) in spec.match_indices('^') {
753        let Some(tail) = spec.get(idx + 1..) else {
754            continue;
755        };
756        if tail.starts_with('@') && idx + 2 == spec.len() {
757            best = Some((idx, ParentShorthandKind::At, 0));
758            break;
759        }
760        if tail.starts_with('!') && idx + 2 == spec.len() {
761            let cand = (idx, ParentShorthandKind::Bang, 1);
762            best = Some(match best {
763                Some(b) if b.2 < 1 => b,
764                _ => cand,
765            });
766            continue;
767        }
768        if let Some(after) = tail.strip_prefix('-') {
769            let (exclude_parent, valid) = if after.is_empty() {
770                (1usize, true)
771            } else if after.bytes().all(|b| b.is_ascii_digit()) && !after.is_empty() {
772                let n: usize = after.parse().unwrap_or(0);
773                (n, n >= 1)
774            } else {
775                (0, false)
776            };
777            if !valid {
778                continue;
779            }
780            let cand = (idx, ParentShorthandKind::Minus { exclude_parent }, 2);
781            best = Some(match best {
782                Some(b) if b.2 < 2 => b,
783                _ => cand,
784            });
785        }
786    }
787    best.map(|(i, k, _)| (i, k))
788}
789
790/// Expand Git parent shorthands (`^@`, `^!`, `^-`, `^-N`) to the strings `git rev-parse` would print.
791///
792/// Returns [`None`] when `spec` does not use these suffixes at the end (or the suffix is invalid).
793///
794/// # Errors
795///
796/// Returns resolution errors when the base committish cannot be resolved or is not a commit.
797pub fn expand_parent_shorthand_rev_parse_lines(
798    repo: &Repository,
799    spec: &str,
800    symbolic: bool,
801    short_len: Option<usize>,
802) -> Result<Option<Vec<String>>> {
803    let Some((mark_idx, kind)) = find_parent_shorthand(spec) else {
804        return Ok(None);
805    };
806    let base_spec = &spec[..mark_idx];
807    let base_for_resolve = if base_spec.is_empty() {
808        "HEAD"
809    } else {
810        base_spec
811    };
812    // Git `--symbolic` prints parent specs using the same spelling as the user would type
813    // (e.g. `final^1^1`), not full ref names (`refs/heads/...`).
814    let symbolic_base = if base_spec.is_empty() {
815        "HEAD"
816    } else {
817        base_spec
818    };
819    let tip_oid = resolve_revision_for_range_end(repo, base_for_resolve)?;
820    let commit_oid = peel_to_commit_for_merge_base(repo, tip_oid)?;
821    let parents = commit_parents_for_navigation(repo, commit_oid)?;
822
823    let mut out = Vec::new();
824    match kind {
825        ParentShorthandKind::At => {
826            if parents.is_empty() {
827                return Ok(Some(out));
828            }
829            for (i, p) in parents.iter().enumerate() {
830                let parent_n = i + 1;
831                if symbolic {
832                    out.push(format!("{symbolic_base}^{parent_n}"));
833                } else if let Some(len) = short_len {
834                    out.push(abbreviate_object_id(repo, *p, len)?);
835                } else {
836                    out.push(p.to_string());
837                }
838            }
839        }
840        ParentShorthandKind::Bang => {
841            if parents.is_empty() {
842                if symbolic {
843                    out.push(symbolic_base.to_string());
844                } else if let Some(len) = short_len {
845                    out.push(abbreviate_object_id(repo, commit_oid, len)?);
846                } else {
847                    out.push(commit_oid.to_string());
848                }
849                return Ok(Some(out));
850            }
851            if symbolic {
852                out.push(symbolic_base.to_string());
853                for (i, _) in parents.iter().enumerate() {
854                    let parent_n = i + 1;
855                    out.push(format!("^{symbolic_base}^{parent_n}"));
856                }
857            } else if let Some(len) = short_len {
858                out.push(abbreviate_object_id(repo, commit_oid, len)?);
859                for p in &parents {
860                    out.push(format!("^{}", abbreviate_object_id(repo, *p, len)?));
861                }
862            } else {
863                out.push(commit_oid.to_string());
864                for p in &parents {
865                    out.push(format!("^{p}"));
866                }
867            }
868        }
869        ParentShorthandKind::Minus { exclude_parent } => {
870            if exclude_parent > parents.len() {
871                return Ok(None);
872            }
873            let excluded_parent = parents[exclude_parent - 1];
874            if symbolic {
875                out.push(symbolic_base.to_string());
876                out.push(format!("^{symbolic_base}^{exclude_parent}"));
877            } else if let Some(len) = short_len {
878                out.push(abbreviate_object_id(repo, commit_oid, len)?);
879                out.push(format!(
880                    "^{}",
881                    abbreviate_object_id(repo, excluded_parent, len)?
882                ));
883            } else {
884                out.push(commit_oid.to_string());
885                out.push(format!("^{excluded_parent}"));
886            }
887        }
888    }
889    Ok(Some(out))
890}
891
892pub fn split_double_dot_range(spec: &str) -> Option<(&str, &str)> {
893    if spec == ".." {
894        return Some(("", ""));
895    }
896    let bytes = spec.as_bytes();
897    let mut search = 0usize;
898    while let Some(rel) = spec[search..].find("..") {
899        let idx = search + rel;
900        // Reject `..` that is part of `...` (symmetric-diff operator).
901        let touches_dot_before = idx > 0 && bytes[idx - 1] == b'.';
902        let touches_dot_after = idx + 2 < bytes.len() && bytes[idx + 2] == b'.';
903        if touches_dot_before || touches_dot_after {
904            search = idx + 1;
905            continue;
906        }
907        // Reject `..` that starts a path segment (`../` in `HEAD:../file`).
908        if idx + 2 < bytes.len() && (bytes[idx + 2] == b'/' || bytes[idx + 2] == b'\\') {
909            search = idx + 1;
910            continue;
911        }
912        let left = &spec[..idx];
913        let right = &spec[idx + 2..];
914        return Some((left, right));
915    }
916    None
917}
918
919/// Split `spec` at the first `...` symmetric-diff operator (not part of `....`).
920///
921/// Returns `(left, right)` where either side may be empty (`...HEAD`, `A...`, `...`).
922#[must_use]
923pub fn split_triple_dot_range(spec: &str) -> Option<(&str, &str)> {
924    if spec == "..." {
925        return Some(("", ""));
926    }
927    let bytes = spec.as_bytes();
928    let mut search = 0usize;
929    while let Some(rel) = spec[search..].find("...") {
930        let idx = search + rel;
931        let four_before = idx >= 1 && bytes[idx - 1] == b'.';
932        let four_after = idx + 3 < bytes.len() && bytes[idx + 3] == b'.';
933        if four_before || four_after {
934            search = idx + 1;
935            continue;
936        }
937        let left = &spec[..idx];
938        let right = &spec[idx + 3..];
939        return Some((left, right));
940    }
941    None
942}
943
944/// Like [`resolve_revision`], but does not treat a bare filename as an index path
945/// (matches `git rev-parse` / plumbing, where `file.txt` stays ambiguous).
946pub fn resolve_revision_without_index_dwim(repo: &Repository, spec: &str) -> Result<ObjectId> {
947    resolve_revision_impl(repo, spec, false, false, true, false, false, false, false)
948}
949
950/// Resolve a revision string to an object ID.
951pub fn resolve_revision(repo: &Repository, spec: &str) -> Result<ObjectId> {
952    resolve_revision_impl(repo, spec, true, false, true, false, false, false, true)
953}
954
955/// Like [`resolve_revision`], but can disable remote-tracking DWIM used by `git checkout`
956/// when `--no-guess` / `checkout.guess=false` (t2024).
957pub fn resolve_revision_for_checkout_guess(
958    repo: &Repository,
959    spec: &str,
960    remote_branch_guess: bool,
961) -> Result<ObjectId> {
962    resolve_revision_impl(
963        repo,
964        spec,
965        true,
966        false,
967        true,
968        false,
969        false,
970        false,
971        remote_branch_guess,
972    )
973}
974
975/// Resolve `spec` when it appears as the end of a revision range (`A..B`, `A...B`, etc.):
976/// abbreviated hex and `core.disambiguate` prefer a commit (porcelain range parsing).
977pub fn resolve_revision_for_range_end(repo: &Repository, spec: &str) -> Result<ObjectId> {
978    resolve_revision_impl(repo, spec, true, true, true, false, false, false, true)
979}
980
981/// Like [`resolve_revision_for_range_end`], but does not resolve a bare filename as an index path.
982///
983/// Matches plumbing-style revision parsing (`git rev-parse` without index DWIM). Used when a
984/// token must not be confused with a tracked path that happens to match a branch name (e.g.
985/// `git reset --hard` after `submodule update` when the submodule has a branch `sub1` and the
986/// superproject index lists path `sub1`).
987pub fn resolve_revision_for_range_end_without_index_dwim(
988    repo: &Repository,
989    spec: &str,
990) -> Result<ObjectId> {
991    resolve_revision_impl(repo, spec, false, true, true, false, false, false, true)
992}
993
994/// Resolve a single revision for `git rev-parse --verify` (no index path DWIM).
995///
996/// Git's `--verify` mode must reject tokens that only match an index entry when the path is
997/// missing from the work tree (`t7102-reset` disambiguation).
998pub fn resolve_revision_for_verify(repo: &Repository, spec: &str) -> Result<ObjectId> {
999    resolve_revision_impl(repo, spec, false, true, true, false, false, false, true)
1000}
1001
1002/// First argument to `commit-tree`: ambiguous short hex uses tree-ish rules (blob vs tree).
1003pub fn resolve_revision_for_commit_tree_tree(repo: &Repository, spec: &str) -> Result<ObjectId> {
1004    resolve_revision_impl(repo, spec, true, false, true, false, true, false, true)
1005}
1006
1007/// Old blob OID from a patch `index <old>..<new>` line (`git apply --build-fake-ancestor`).
1008pub fn resolve_revision_for_patch_old_blob(repo: &Repository, spec: &str) -> Result<ObjectId> {
1009    resolve_revision_impl(repo, spec, true, false, true, false, false, true, true)
1010}
1011
1012/// When `spec` uses two-dot range syntax (`A..B`, `..B`, `A..`), returns the commits to
1013/// **exclude** (left tip) and **include** (right tip) for `git log`-style walks.
1014///
1015/// Returns `Ok(None)` when `spec` is not a two-dot range. Symmetric `A...B` is handled by
1016/// [`resolve_revision_as_commit`] instead.
1017///
1018/// # Errors
1019///
1020/// Propagates resolution errors from either range endpoint.
1021pub fn try_parse_double_dot_log_range(
1022    repo: &Repository,
1023    spec: &str,
1024) -> Result<Option<(ObjectId, ObjectId)>> {
1025    let Some((left, right)) = split_double_dot_range(spec) else {
1026        return Ok(None);
1027    };
1028    let left_tip = if left.is_empty() {
1029        resolve_revision_for_range_end(repo, "HEAD")?
1030    } else {
1031        resolve_revision_for_range_end(repo, left)?
1032    };
1033    let right_tip = if right.is_empty() {
1034        resolve_revision_for_range_end(repo, "HEAD")?
1035    } else {
1036        resolve_revision_for_range_end(repo, right)?
1037    };
1038    let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
1039    let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
1040    Ok(Some((left_c, right_c)))
1041}
1042
1043fn try_parse_double_dot_log_range_without_index_dwim(
1044    repo: &Repository,
1045    spec: &str,
1046) -> Result<Option<(ObjectId, ObjectId)>> {
1047    let Some((left, right)) = split_double_dot_range(spec) else {
1048        return Ok(None);
1049    };
1050    let left_tip = if left.is_empty() {
1051        resolve_revision_for_range_end_without_index_dwim(repo, "HEAD")?
1052    } else {
1053        resolve_revision_for_range_end_without_index_dwim(repo, left)?
1054    };
1055    let right_tip = if right.is_empty() {
1056        resolve_revision_for_range_end_without_index_dwim(repo, "HEAD")?
1057    } else {
1058        resolve_revision_for_range_end_without_index_dwim(repo, right)?
1059    };
1060    let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
1061    let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
1062    Ok(Some((left_c, right_c)))
1063}
1064
1065/// Resolve `spec` to a commit OID for porcelain history commands (`log`, `reset`, etc.).
1066///
1067/// Handles `A..B` / `..B` / `A..` (tip is the right side, defaulting to `HEAD`) and
1068/// `A...B` symmetric diff (returns the merge base). Other specs are resolved and peeled
1069/// to a commit (tags peeled, abbreviated hex disambiguated as commit-ish on range ends).
1070/// Returns true when `spec` ends with Git parent/ancestor navigation (`~N`, `^N`, bare `~`/`^`).
1071///
1072/// Used by porcelain (`reset`) to distinguish commit-ish arguments from pathspecs when
1073/// full resolution is deferred or fails for other reasons.
1074#[must_use]
1075pub fn revision_spec_contains_ancestry_navigation(spec: &str) -> bool {
1076    let (_, steps) = parse_nav_steps(spec);
1077    !steps.is_empty()
1078}
1079
1080pub fn resolve_revision_as_commit(repo: &Repository, spec: &str) -> Result<ObjectId> {
1081    if let Some((left, right)) = split_triple_dot_range(spec) {
1082        let left_tip = if left.is_empty() {
1083            resolve_revision_for_range_end(repo, "HEAD")?
1084        } else {
1085            resolve_revision_for_range_end(repo, left)?
1086        };
1087        let right_tip = if right.is_empty() {
1088            resolve_revision_for_range_end(repo, "HEAD")?
1089        } else {
1090            resolve_revision_for_range_end(repo, right)?
1091        };
1092        let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
1093        let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
1094        let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_c, &[right_c])?;
1095        return bases
1096            .into_iter()
1097            .next()
1098            .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
1099    }
1100    if let Some((_excl, tip)) = try_parse_double_dot_log_range(repo, spec)? {
1101        return Ok(tip);
1102    }
1103    let oid = resolve_revision_for_range_end(repo, spec)?;
1104    peel_to_commit_for_merge_base(repo, oid)
1105}
1106
1107/// Like [`resolve_revision_as_commit`], but never treats a bare path as an index revision.
1108///
1109/// Use when distinguishing the first `git reset` argument from pathspecs: a submodule work tree
1110/// may have a branch whose name equals a path recorded in the **superproject** index (t3426).
1111pub fn resolve_revision_as_commit_without_index_dwim(
1112    repo: &Repository,
1113    spec: &str,
1114) -> Result<ObjectId> {
1115    if let Some((left, right)) = split_triple_dot_range(spec) {
1116        let left_tip = if left.is_empty() {
1117            resolve_revision_for_range_end_without_index_dwim(repo, "HEAD")?
1118        } else {
1119            resolve_revision_for_range_end_without_index_dwim(repo, left)?
1120        };
1121        let right_tip = if right.is_empty() {
1122            resolve_revision_for_range_end_without_index_dwim(repo, "HEAD")?
1123        } else {
1124            resolve_revision_for_range_end_without_index_dwim(repo, right)?
1125        };
1126        let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
1127        let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
1128        let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_c, &[right_c])?;
1129        return bases
1130            .into_iter()
1131            .next()
1132            .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
1133    }
1134    if let Some((_excl, tip)) = try_parse_double_dot_log_range_without_index_dwim(repo, spec)? {
1135        return Ok(tip);
1136    }
1137    let oid = resolve_revision_for_range_end_without_index_dwim(repo, spec)?;
1138    peel_to_commit_for_merge_base(repo, oid)
1139}
1140
1141fn resolve_ref_dwim_for_rev_parse(repo: &Repository, spec: &str) -> (usize, Option<ObjectId>) {
1142    const RULES: &[&str] = &[
1143        "{0}",
1144        "refs/{0}",
1145        "refs/tags/{0}",
1146        "refs/heads/{0}",
1147        "refs/remotes/{0}",
1148        "refs/remotes/{0}/HEAD",
1149    ];
1150
1151    let mut count = 0usize;
1152    let mut first = None;
1153    let refname_opts = RefNameOptions::default();
1154    for rule in RULES {
1155        let candidate = rule.replace("{0}", spec);
1156        if let Ok(Some(target)) = refs::read_symbolic_ref(&repo.git_dir, &candidate) {
1157            if check_refname_format(&target, &refname_opts).is_err()
1158                || refs::resolve_ref(&repo.git_dir, &target).is_err()
1159            {
1160                // Match upstream `expand_ref` (refs.c): a dangling symref is
1161                // silently ignored when it is literally `HEAD` (e.g. an unborn
1162                // branch in a fresh repo). The warning is only emitted for other
1163                // dangling symrefs encountered while DWIM-resolving a ref.
1164                if candidate != "HEAD" {
1165                    eprintln!("warning: ignoring dangling symref {candidate}");
1166                }
1167                continue;
1168            }
1169        }
1170        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &candidate) {
1171            count += 1;
1172            if first.is_none() {
1173                first = Some(oid);
1174            }
1175        }
1176    }
1177    (count, first)
1178}
1179
1180fn resolve_revision_impl(
1181    repo: &Repository,
1182    spec: &str,
1183    index_dwim: bool,
1184    commit_only_hex: bool,
1185    use_disambiguate_config: bool,
1186    treeish_colon_lhs: bool,
1187    implicit_tree_abbrev: bool,
1188    implicit_blob_abbrev: bool,
1189    remote_branch_name_guess: bool,
1190) -> Result<ObjectId> {
1191    // Handle `:/message` early — it can contain any characters so must
1192    // not be confused with peel/nav syntax.
1193    if let Some(pattern) = spec.strip_prefix(":/") {
1194        if pattern.is_empty() {
1195            // `:/` with an empty pattern resolves to the youngest reachable commit (HEAD tip).
1196            let head = crate::state::resolve_head(&repo.git_dir)
1197                .map_err(|_| Error::ObjectNotFound(":/".to_owned()))?;
1198            return head
1199                .oid()
1200                .copied()
1201                .ok_or_else(|| Error::ObjectNotFound(":/".to_owned()));
1202        }
1203        return resolve_commit_message_search(repo, pattern);
1204    }
1205
1206    if let Some(index_spec) = parse_index_colon_spec(spec) {
1207        let path = normalize_colon_path_for_tree(repo, index_spec.raw_path)?;
1208        return resolve_index_path_at_stage(repo, &path, index_spec.stage)
1209            .map_err(|e| diagnose_index_path_error(repo, &path, index_spec.stage, e));
1210    }
1211
1212    // `tags/<name>` is Git's DWIM for `refs/tags/<name>` (t6101 `tags/start`).
1213    if let Some(tag_path) = spec.strip_prefix("tags/") {
1214        if !tag_path.is_empty() {
1215            let tag_ref = format!("refs/tags/{tag_path}");
1216            if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &tag_ref) {
1217                return Ok(oid);
1218            }
1219        }
1220    }
1221
1222    // Pseudo-ref written by `git merge` / grit merge on conflict (tree OID, one line).
1223    if spec == "AUTO_MERGE" {
1224        let raw = fs::read_to_string(repo.git_dir.join("AUTO_MERGE"))
1225            .map_err(|e| Error::Message(format!("failed to read AUTO_MERGE: {e}")))?;
1226        let line = raw.lines().next().unwrap_or("").trim();
1227        return line
1228            .parse::<ObjectId>()
1229            .map_err(|_| Error::InvalidRef("AUTO_MERGE: invalid object id".to_owned()));
1230    }
1231
1232    // `refs/...` spelled in full (e.g. `refs/tags/other`): resolve as a ref before any
1233    // treeish / DWIM path logic so a worktree path named `other` cannot shadow `refs/tags/other`
1234    // (`git rev-parse refs/tags/other`, t5332).
1235    if spec.starts_with("refs/") && !spec.contains(':') {
1236        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, spec) {
1237            return Ok(oid);
1238        }
1239    }
1240
1241    // Handle A...B (symmetric difference / merge-base)
1242    // Also handles A... (implies A...HEAD)
1243    if let Some(idx) = spec.find("...") {
1244        let left_raw = &spec[..idx];
1245        let right_raw = &spec[idx + 3..];
1246        if !left_raw.is_empty() || !right_raw.is_empty() {
1247            let left_oid = peel_to_commit_for_merge_base(
1248                repo,
1249                if left_raw.is_empty() {
1250                    resolve_revision_impl(
1251                        repo,
1252                        "HEAD",
1253                        index_dwim,
1254                        commit_only_hex,
1255                        use_disambiguate_config,
1256                        false,
1257                        false,
1258                        false,
1259                        remote_branch_name_guess,
1260                    )?
1261                } else {
1262                    resolve_revision_impl(
1263                        repo,
1264                        left_raw,
1265                        index_dwim,
1266                        commit_only_hex,
1267                        use_disambiguate_config,
1268                        false,
1269                        false,
1270                        false,
1271                        remote_branch_name_guess,
1272                    )?
1273                },
1274            )?;
1275            let right_oid = peel_to_commit_for_merge_base(
1276                repo,
1277                if right_raw.is_empty() {
1278                    resolve_revision_impl(
1279                        repo,
1280                        "HEAD",
1281                        index_dwim,
1282                        commit_only_hex,
1283                        use_disambiguate_config,
1284                        false,
1285                        false,
1286                        false,
1287                        remote_branch_name_guess,
1288                    )?
1289                } else {
1290                    resolve_revision_impl(
1291                        repo,
1292                        right_raw,
1293                        index_dwim,
1294                        commit_only_hex,
1295                        use_disambiguate_config,
1296                        false,
1297                        false,
1298                        false,
1299                        remote_branch_name_guess,
1300                    )?
1301                },
1302            )?;
1303            let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_oid, &[right_oid])?;
1304            return bases
1305                .into_iter()
1306                .next()
1307                .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
1308        }
1309    }
1310
1311    // Handle <rev>:<path> — resolve a tree entry.
1312    // Must come after :/ handling. The colon must not be inside `^{...}` (e.g.
1313    // `other^{/msg:}:file`) and must not be the `:path` / `:N:path` index forms.
1314    if let Some((before, after)) = split_treeish_colon(spec) {
1315        if !before.is_empty() && !spec.starts_with(":/") {
1316            // <rev>:<path> — resolve rev to tree, then navigate path
1317            let rev_oid = match resolve_revision_impl(
1318                repo,
1319                before,
1320                index_dwim,
1321                commit_only_hex,
1322                use_disambiguate_config,
1323                true,
1324                false,
1325                false,
1326                remote_branch_name_guess,
1327            ) {
1328                Ok(o) => o,
1329                Err(Error::ObjectNotFound(s)) if s == before => {
1330                    return Err(Error::Message(format!(
1331                        "fatal: invalid object name '{before}'."
1332                    )));
1333                }
1334                Err(Error::Message(msg)) if msg.contains("ambiguous argument") => {
1335                    return Err(Error::Message(format!(
1336                        "fatal: invalid object name '{before}'."
1337                    )));
1338                }
1339                Err(e) => return Err(e),
1340            };
1341            let tree_oid = peel_to_tree(repo, rev_oid)?;
1342            if after.is_empty() {
1343                // <rev>: means the tree itself
1344                return Ok(tree_oid);
1345            }
1346            let clean_path = match normalize_colon_path_for_tree(repo, after) {
1347                Ok(p) => p,
1348                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
1349                    let wt = repo
1350                        .work_tree
1351                        .as_ref()
1352                        .and_then(|p| p.canonicalize().ok())
1353                        .map(|p| p.display().to_string())
1354                        .unwrap_or_default();
1355                    return Err(Error::Message(format!(
1356                        "fatal: '{after}' is outside repository at '{wt}'"
1357                    )));
1358                }
1359                Err(e) => return Err(e),
1360            };
1361            return resolve_tree_path_rev_parse(repo, &tree_oid, &clean_path)
1362                .map_err(|e| diagnose_tree_path_error(repo, before, after, &clean_path, e));
1363        }
1364    }
1365
1366    let (base_with_nav, peel) = parse_peel_suffix(spec);
1367    let (base, nav_steps) = parse_nav_steps(base_with_nav);
1368    let peel_for_hex = peel
1369        .or(((treeish_colon_lhs || implicit_tree_abbrev) && peel.is_none()).then_some("tree"))
1370        .or((implicit_blob_abbrev && peel.is_none()).then_some("blob"));
1371    let mut oid = resolve_base(
1372        repo,
1373        base,
1374        index_dwim,
1375        commit_only_hex,
1376        use_disambiguate_config,
1377        peel_for_hex,
1378        implicit_tree_abbrev,
1379        implicit_blob_abbrev,
1380        remote_branch_name_guess,
1381    )?;
1382    for step in nav_steps {
1383        oid = apply_nav_step(repo, oid, step).map_err(|e| {
1384            if matches!(e, Error::ObjectNotFound(_)) {
1385                Error::Message(format!(
1386                    "fatal: ambiguous argument '{spec}': unknown revision or path not in the working tree.\n\
1387Use '--' to separate paths from revisions, like this:\n\
1388'git <command> [<revision>...] -- [<file>...]'"
1389                ))
1390            } else {
1391                e
1392            }
1393        })?;
1394    }
1395    apply_peel(repo, oid, peel)
1396}
1397
1398/// Normalize a path from `treeish:path` against the work tree and return a `/`-separated path
1399/// relative to the repository root (for tree lookup).
1400fn normalize_path_components(path: PathBuf) -> PathBuf {
1401    let mut out = PathBuf::new();
1402    for c in path.components() {
1403        match c {
1404            Component::Prefix(_) | Component::RootDir => out.push(c),
1405            Component::CurDir => {}
1406            Component::ParentDir => {
1407                let _ = out.pop();
1408            }
1409            Component::Normal(x) => out.push(x),
1410        }
1411    }
1412    out
1413}
1414
1415/// Normalize `treeish:path` path segment for tree lookup when there is no work tree (bare repo).
1416///
1417/// Paths are interpreted relative to the repository root; `./` / `../` / `.` still require a work
1418/// tree in Git and are rejected here.
1419fn normalize_colon_path_for_bare_tree(raw_path: &str) -> Result<String> {
1420    let cwd_relative = raw_path.starts_with("./") || raw_path.starts_with("../") || raw_path == ".";
1421    if cwd_relative {
1422        return Err(Error::InvalidRef(
1423            "relative path syntax can't be used outside working tree".to_owned(),
1424        ));
1425    }
1426    let s = raw_path.trim_start_matches('/');
1427    let mut stack: Vec<&str> = Vec::new();
1428    for part in s.split('/') {
1429        if part.is_empty() || part == "." {
1430            continue;
1431        }
1432        if part == ".." {
1433            let _ = stack.pop();
1434        } else {
1435            stack.push(part);
1436        }
1437    }
1438    Ok(stack.join("/"))
1439}
1440
1441fn normalize_colon_path_for_tree(repo: &Repository, raw_path: &str) -> Result<String> {
1442    let Some(work_tree) = repo.work_tree.as_ref() else {
1443        return normalize_colon_path_for_bare_tree(raw_path);
1444    };
1445
1446    let cwd = std::env::current_dir().map_err(Error::Io)?;
1447    let wt_canon = work_tree.canonicalize().map_err(Error::Io)?;
1448
1449    let cwd_relative = raw_path.starts_with("./") || raw_path.starts_with("../") || raw_path == ".";
1450    if cwd_relative && !path_is_within(&cwd, work_tree) {
1451        return Err(Error::InvalidRef(
1452            "relative path syntax can't be used outside working tree".to_owned(),
1453        ));
1454    }
1455
1456    // `./` / `../` / `.` are relative to cwd; other relative paths are relative to work tree.
1457    let full = if raw_path.starts_with('/') {
1458        PathBuf::from(raw_path)
1459    } else if cwd_relative {
1460        cwd.join(raw_path)
1461    } else {
1462        work_tree.join(raw_path)
1463    };
1464    let full = normalize_path_components(full);
1465
1466    if !path_is_within(&full, &wt_canon) {
1467        return Err(Error::InvalidRef("outside repository".to_owned()));
1468    }
1469    let rel = full
1470        .strip_prefix(&wt_canon)
1471        .map_err(|_| Error::InvalidRef("outside repository".to_owned()))?;
1472    let s = rel.to_string_lossy().replace('\\', "/");
1473    Ok(s.trim_end_matches('/').to_owned())
1474}
1475
1476/// Peel tags to a commit OID for merge-base computation (`A...B` and `rev-parse` output).
1477pub fn peel_to_commit_for_merge_base(repo: &Repository, mut oid: ObjectId) -> Result<ObjectId> {
1478    oid = apply_peel(repo, oid, Some(""))?;
1479    let obj = repo.read_replaced(&oid)?;
1480    match obj.kind {
1481        ObjectKind::Commit => Ok(oid),
1482        ObjectKind::Tree => Err(Error::InvalidRef(format!(
1483            "object {oid} does not name a commit"
1484        ))),
1485        ObjectKind::Blob => Err(Error::InvalidRef(format!(
1486            "object {oid} does not name a commit"
1487        ))),
1488        ObjectKind::Tag => Err(Error::InvalidRef("unexpected tag after peel".to_owned())),
1489    }
1490}
1491
1492/// Like [`peel_to_commit_for_merge_base`], but returns `Ok(None)` when the peeled object is not a
1493/// commit (e.g. a tag pointing at a blob). Used by upload-pack fetch negotiation.
1494pub fn try_peel_to_commit_for_merge_base(
1495    repo: &Repository,
1496    oid: ObjectId,
1497) -> Result<Option<ObjectId>> {
1498    let oid = apply_peel(repo, oid, Some(""))?;
1499    let obj = repo.odb.read(&oid)?;
1500    match obj.kind {
1501        ObjectKind::Commit => Ok(Some(oid)),
1502        ObjectKind::Tree | ObjectKind::Blob => Ok(None),
1503        ObjectKind::Tag => Err(Error::InvalidRef("unexpected tag after peel".to_owned())),
1504    }
1505}
1506
1507/// Peel `oid` to the tree it represents (commits → root tree, tags → recursively, tree → identity).
1508///
1509/// # Errors
1510///
1511/// Returns [`Error::ObjectNotFound`] when the object cannot be peeled to a tree (e.g. a blob).
1512pub fn peel_to_tree(repo: &Repository, oid: ObjectId) -> Result<ObjectId> {
1513    let obj = repo.read_replaced(&oid)?;
1514    match obj.kind {
1515        crate::objects::ObjectKind::Tree => Ok(oid),
1516        crate::objects::ObjectKind::Commit => {
1517            let commit = crate::objects::parse_commit(&obj.data)?;
1518            Ok(commit.tree)
1519        }
1520        crate::objects::ObjectKind::Tag => {
1521            let tag = crate::objects::parse_tag(&obj.data)?;
1522            peel_to_tree(repo, tag.object)
1523        }
1524        _ => Err(Error::ObjectNotFound(format!(
1525            "cannot peel {} to tree",
1526            oid
1527        ))),
1528    }
1529}
1530
1531/// Navigate a tree to find an object at a given path.
1532///
1533/// Git accepts `rev:path` when the leaf is a **blob, symlink, gitlink, or tree** (e.g.
1534/// `HEAD:subdir` for a subdirectory tree, or a submodule path whose leaf is a gitlink). Only
1535/// [`walk_tree_to_blob_entry`] is blob-only.
1536fn resolve_tree_path(repo: &Repository, tree_oid: &ObjectId, path: &str) -> Result<ObjectId> {
1537    resolve_treeish_path_to_object(repo, *tree_oid, path)
1538}
1539
1540/// Like Git `rev-parse` for `treeish:path`: the leaf may be a blob or a tree OID.
1541fn resolve_tree_path_rev_parse(
1542    repo: &Repository,
1543    tree_oid: &ObjectId,
1544    path: &str,
1545) -> Result<ObjectId> {
1546    let obj = repo.odb.read(tree_oid)?;
1547    let entries = crate::objects::parse_tree(&obj.data)?;
1548    let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
1549    if components.is_empty() {
1550        return Err(Error::InvalidRef(format!(
1551            "path '{path}' does not name an object in tree {tree_oid}"
1552        )));
1553    }
1554
1555    let first = components[0];
1556    let rest: Vec<&str> = components[1..].to_vec();
1557    for entry in entries {
1558        let name = String::from_utf8_lossy(&entry.name);
1559        if name == first {
1560            if rest.is_empty() {
1561                // Git's `rev-parse <treeish>:<path>` returns the entry OID for any leaf —
1562                // blob, tree, symlink, or gitlink. For a gitlink the OID is the submodule's
1563                // recorded commit SHA, which need not exist in this object store (it lives in
1564                // the submodule); do not attempt to read it. (lib-submodule-update
1565                // `test_submodule_content` relies on `rev-parse <commit>:sub1`.)
1566                return Ok(entry.oid);
1567            }
1568            if entry.mode != crate::index::MODE_TREE {
1569                return Err(Error::ObjectNotFound(path.to_owned()));
1570            }
1571            return resolve_tree_path_rev_parse(repo, &entry.oid, &rest.join("/"));
1572        }
1573    }
1574    Err(Error::ObjectNotFound(format!(
1575        "path '{path}' not found in tree {tree_oid}"
1576    )))
1577}
1578
1579/// Resolved blob (non-tree) at `treeish:path` for diff plumbing.
1580///
1581/// Returns the repository-relative path, blob OID, and Git mode string (e.g. `"100644"`).
1582#[derive(Debug, Clone)]
1583pub struct TreeishBlobAtPath {
1584    /// Path used in `diff --git` / `---` / `+++` headers (tree path, `/`-separated).
1585    pub path: String,
1586    /// Object id of the blob.
1587    pub oid: ObjectId,
1588    /// File mode as in tree objects (`100644`, `100755`, `120000`, …).
1589    pub mode: String,
1590}
1591
1592/// Resolve `rev:path` to the blob at that path in the tree reached from `rev`.
1593///
1594/// Fails when `spec` is not `treeish:path`, when the path is missing, or when the
1595/// target is a tree or gitlink rather than a blob/symlink blob.
1596pub fn resolve_treeish_blob_at_path(repo: &Repository, spec: &str) -> Result<TreeishBlobAtPath> {
1597    let (before, after) = split_treeish_colon(spec)
1598        .ok_or_else(|| Error::InvalidRef(format!("'{spec}' is not a treeish:path revision")))?;
1599
1600    let rev_oid =
1601        match resolve_revision_impl(repo, before, true, false, true, true, false, false, true) {
1602            Ok(o) => o,
1603            Err(Error::ObjectNotFound(s)) if s == before => {
1604                return Err(Error::Message(format!(
1605                    "fatal: invalid object name '{before}'."
1606                )));
1607            }
1608            Err(Error::Message(msg)) if msg.contains("ambiguous argument") => {
1609                return Err(Error::Message(format!(
1610                    "fatal: invalid object name '{before}'."
1611                )));
1612            }
1613            Err(e) => return Err(e),
1614        };
1615
1616    let tree_oid = peel_to_tree(repo, rev_oid)?;
1617
1618    // Empty path means the root tree itself.
1619    if after.is_empty() {
1620        return Ok(TreeishBlobAtPath {
1621            path: String::new(),
1622            oid: tree_oid,
1623            mode: "040000".to_string(),
1624        });
1625    }
1626
1627    let clean_path = match normalize_colon_path_for_tree(repo, after) {
1628        Ok(p) => p,
1629        Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
1630            let wt = repo
1631                .work_tree
1632                .as_ref()
1633                .and_then(|p| p.canonicalize().ok())
1634                .map(|p| p.display().to_string())
1635                .unwrap_or_default();
1636            return Err(Error::Message(format!(
1637                "fatal: '{after}' is outside repository at '{wt}'"
1638            )));
1639        }
1640        Err(e) => return Err(e),
1641    };
1642
1643    let (oid, mode_str) = walk_tree_to_blob_entry(repo, &tree_oid, &clean_path)
1644        .map_err(|e| diagnose_tree_path_error(repo, before, after, &clean_path, e))?;
1645    Ok(TreeishBlobAtPath {
1646        path: clean_path,
1647        oid,
1648        mode: mode_str,
1649    })
1650}
1651
1652/// Walk from `tree_oid` to the leaf named by `path` and return OID + mode string for a blob or symlink.
1653///
1654/// Errors when the leaf is a tree or gitlink. Used by [`resolve_treeish_blob_at_path`] and similar.
1655fn walk_tree_to_blob_entry(
1656    repo: &Repository,
1657    tree_oid: &ObjectId,
1658    path: &str,
1659) -> Result<(ObjectId, String)> {
1660    let obj = repo.read_replaced(tree_oid)?;
1661    let entries = crate::objects::parse_tree(&obj.data)?;
1662    let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
1663    if components.is_empty() {
1664        return Err(Error::InvalidRef(format!(
1665            "path '{path}' does not name a blob in tree {tree_oid}"
1666        )));
1667    }
1668
1669    let first = components[0];
1670    let rest: Vec<&str> = components[1..].to_vec();
1671    for entry in entries {
1672        let name = String::from_utf8_lossy(&entry.name);
1673        if name == first {
1674            if rest.is_empty() {
1675                if entry.mode == crate::index::MODE_TREE {
1676                    return Err(Error::InvalidRef(format!("'{path}' is a tree, not a blob")));
1677                }
1678                return Ok((entry.oid, entry.mode_str()));
1679            }
1680            if entry.mode != crate::index::MODE_TREE {
1681                return Err(Error::ObjectNotFound(path.to_owned()));
1682            }
1683            return walk_tree_to_blob_entry(repo, &entry.oid, &rest.join("/"));
1684        }
1685    }
1686    Err(Error::ObjectNotFound(format!(
1687        "path '{path}' not found in tree {tree_oid}"
1688    )))
1689}
1690
1691/// A single parent/ancestor navigation step.
1692#[derive(Debug, Clone, Copy)]
1693enum NavStep {
1694    /// `^N` — navigate to the Nth parent (1-indexed; 0 is a no-op).
1695    ParentN(usize),
1696    /// `~N` — follow the first parent N times.
1697    AncestorN(usize),
1698}
1699
1700/// Parse and strip any trailing `^N` / `~N` navigation steps from `spec`.
1701///
1702/// Returns `(base, steps)` where `steps` are in left-to-right application order.
1703fn parse_nav_steps(spec: &str) -> (&str, Vec<NavStep>) {
1704    let mut steps = Vec::new();
1705    let mut remaining = spec;
1706
1707    loop {
1708        // Try `~<digits>` or bare `~` at the end.
1709        if let Some(tilde_pos) = remaining.rfind('~') {
1710            let after = &remaining[tilde_pos + 1..];
1711            if after.is_empty() {
1712                // bare `~` = `~1`
1713                steps.push(NavStep::AncestorN(1));
1714                remaining = &remaining[..tilde_pos];
1715                continue;
1716            }
1717            if after.bytes().all(|b| b.is_ascii_digit()) {
1718                let n: usize = after.parse().unwrap_or(1);
1719                steps.push(NavStep::AncestorN(n));
1720                remaining = &remaining[..tilde_pos];
1721                continue;
1722            }
1723        }
1724
1725        // Try `^<digits>` or bare `^` at the end (but not `^{...}` — peel strips those first).
1726        if let Some(caret_pos) = remaining.rfind('^') {
1727            let after = &remaining[caret_pos + 1..];
1728            if after.is_empty() {
1729                // bare `^` = `^1`
1730                steps.push(NavStep::ParentN(1));
1731                remaining = &remaining[..caret_pos];
1732                continue;
1733            }
1734            if after.bytes().all(|b| b.is_ascii_digit()) && !after.is_empty() {
1735                let n: usize = after.parse().unwrap_or(usize::MAX);
1736                steps.push(NavStep::ParentN(n));
1737                remaining = &remaining[..caret_pos];
1738                continue;
1739            }
1740        }
1741
1742        break;
1743    }
1744
1745    steps.reverse();
1746    (remaining, steps)
1747}
1748
1749/// Follow annotated tag objects to their peeled target (Git: `^` / `~` peel tags first).
1750fn peel_annotated_tag_chain(repo: &Repository, mut oid: ObjectId) -> Result<ObjectId> {
1751    loop {
1752        let obj = repo.read_replaced(&oid)?;
1753        if obj.kind != ObjectKind::Tag {
1754            return Ok(oid);
1755        }
1756        let tag = parse_tag(&obj.data)?;
1757        oid = tag.object;
1758    }
1759}
1760
1761/// Apply a single navigation step to an OID, resolving parent/ancestor links.
1762fn apply_nav_step(repo: &Repository, oid: ObjectId, step: NavStep) -> Result<ObjectId> {
1763    match step {
1764        NavStep::ParentN(0) => Ok(oid),
1765        NavStep::ParentN(n) => {
1766            let oid = peel_annotated_tag_chain(repo, oid)?;
1767            let parents = commit_parents_for_navigation(repo, oid)?;
1768            parents
1769                .get(n - 1)
1770                .copied()
1771                .ok_or_else(|| Error::ObjectNotFound(format!("{oid}^{n}")))
1772        }
1773        NavStep::AncestorN(n) => {
1774            let mut current = peel_annotated_tag_chain(repo, oid)?;
1775            for _ in 0..n {
1776                current = apply_nav_step(repo, current, NavStep::ParentN(1))?;
1777            }
1778            Ok(current)
1779        }
1780    }
1781}
1782
1783/// Abbreviate an object ID to a unique prefix.
1784///
1785/// The returned prefix is at least `min_len` and at most 40 hex characters.
1786///
1787/// # Errors
1788///
1789/// Returns [`Error::ObjectNotFound`] when the target OID does not exist in the
1790/// object database.
1791pub fn abbreviate_object_id(repo: &Repository, oid: ObjectId, min_len: usize) -> Result<String> {
1792    let min_len = min_len.clamp(4, 40);
1793    let target = oid.to_hex();
1794
1795    // If object doesn't exist, just return the minimum abbreviation
1796    if !repo.odb.exists(&oid) {
1797        return Ok(target[..min_len].to_owned());
1798    }
1799
1800    let all = collect_loose_object_ids(repo)?;
1801
1802    for len in min_len..=40 {
1803        let prefix = &target[..len];
1804        let matches = all
1805            .iter()
1806            .filter(|candidate| candidate.starts_with(prefix))
1807            .count();
1808        if matches <= 1 {
1809            return Ok(prefix.to_owned());
1810        }
1811    }
1812
1813    Ok(target)
1814}
1815
1816/// Render `path` relative to `cwd` with `/` separators.
1817#[must_use]
1818pub fn to_relative_path(path: &Path, cwd: &Path) -> String {
1819    let path_components = normalize_components(path);
1820    let cwd_components = normalize_components(cwd);
1821
1822    let mut common = 0usize;
1823    let max_common = path_components.len().min(cwd_components.len());
1824    while common < max_common && path_components[common] == cwd_components[common] {
1825        common += 1;
1826    }
1827
1828    let mut parts = Vec::new();
1829    let up_count = cwd_components.len().saturating_sub(common);
1830    for _ in 0..up_count {
1831        parts.push("..".to_owned());
1832    }
1833    for item in path_components.iter().skip(common) {
1834        parts.push(item.clone());
1835    }
1836
1837    if parts.is_empty() {
1838        ".".to_owned()
1839    } else {
1840        parts.join("/")
1841    }
1842}
1843
1844fn object_storage_dirs_for_abbrev(repo: &Repository) -> Result<Vec<PathBuf>> {
1845    let mut dirs = Vec::new();
1846    let primary = repo.odb.objects_dir().to_path_buf();
1847    dirs.push(primary.clone());
1848    if let Ok(alts) = pack::read_alternates_recursive(&primary) {
1849        for alt in alts {
1850            if !dirs.iter().any(|d| d == &alt) {
1851                dirs.push(alt);
1852            }
1853        }
1854    }
1855    Ok(dirs)
1856}
1857
1858fn collect_pack_oids_with_prefix(objects_dir: &Path, prefix: &str) -> Result<Vec<ObjectId>> {
1859    let mut out = Vec::new();
1860    for idx in pack::read_local_pack_indexes_cached(objects_dir)? {
1861        for e in &idx.entries {
1862            if e.oid.len() != 20 {
1863                continue;
1864            }
1865            let hex = pack::oid_bytes_to_hex(&e.oid);
1866            if hex.starts_with(prefix) {
1867                if let Ok(oid) = crate::objects::ObjectId::from_bytes(&e.oid) {
1868                    out.push(oid);
1869                }
1870            }
1871        }
1872    }
1873    Ok(out)
1874}
1875
1876fn disambiguate_kind_rank(kind: ObjectKind) -> u8 {
1877    match kind {
1878        ObjectKind::Tag => 0,
1879        ObjectKind::Commit => 1,
1880        ObjectKind::Tree => 2,
1881        ObjectKind::Blob => 3,
1882    }
1883}
1884
1885fn oid_satisfies_peel_filter(repo: &Repository, oid: ObjectId, peel_inner: &str) -> bool {
1886    apply_peel(repo, oid, Some(peel_inner)).is_ok()
1887}
1888
1889/// Lines for `hint:` output when a short object id is ambiguous (type order, then hex).
1890pub fn ambiguous_object_hint_lines(
1891    repo: &Repository,
1892    short_prefix: &str,
1893    peel_filter: Option<&str>,
1894) -> Result<Vec<String>> {
1895    let mut typed: Vec<(u8, String, &'static str)> = Vec::new();
1896    let mut bad_hex: Vec<String> = Vec::new();
1897    for oid in list_all_abbrev_matches(repo, short_prefix)? {
1898        let hex = oid.to_hex();
1899        match repo.read_replaced(&oid) {
1900            Ok(obj) => {
1901                let ok = peel_filter.is_none_or(|p| oid_satisfies_peel_filter(repo, oid, p));
1902                if ok {
1903                    typed.push((disambiguate_kind_rank(obj.kind), hex, obj.kind.as_str()));
1904                }
1905            }
1906            Err(_) => bad_hex.push(hex),
1907        }
1908    }
1909    if typed.is_empty() && peel_filter.is_some() {
1910        return ambiguous_object_hint_lines(repo, short_prefix, None);
1911    }
1912    bad_hex.sort();
1913    typed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1914    let mut out = Vec::new();
1915    for h in bad_hex {
1916        out.push(format!("hint:   {h} [bad object]"));
1917    }
1918    for (_, hex, kind) in typed {
1919        out.push(format!("hint:   {hex} {kind}"));
1920    }
1921    Ok(out)
1922}
1923
1924fn read_core_disambiguate(repo: &Repository) -> Option<&'static str> {
1925    let config = ConfigSet::load(Some(&repo.git_dir), true).unwrap_or_else(|_| ConfigSet::new());
1926    let v = config.get("core.disambiguate")?;
1927    match v.to_ascii_lowercase().as_str() {
1928        "committish" | "commit" => Some("commit"),
1929        "treeish" | "tree" => Some("tree"),
1930        "blob" => Some("blob"),
1931        "tag" => Some("tag"),
1932        "none" => None,
1933        _ => None,
1934    }
1935}
1936
1937/// When `spec` resolved as an abbreviated object id, warn if `refs/heads/<spec>` exists and
1938/// points at a different object (Git: `rev-parse` warns "refname ... is ambiguous").
1939fn warn_if_branch_refname_collides_with_abbrev_hex(
1940    repo: &Repository,
1941    spec: &str,
1942    object_oid: ObjectId,
1943) {
1944    if spec.len() >= 40 {
1945        return;
1946    }
1947    let branch_ref = format!("refs/heads/{spec}");
1948    let Ok(ref_oid) = refs::resolve_ref(&repo.git_dir, &branch_ref) else {
1949        return;
1950    };
1951    if ref_oid != object_oid {
1952        eprintln!("warning: refname '{spec}' is ambiguous.");
1953    }
1954}
1955
1956/// When a hex-like `spec` resolved as a ref under `refs/heads/` or `refs/tags/`, warn if that name
1957/// also matches object(s) in the ODB (Git: `warning: refname 'abc' is ambiguous.`).
1958fn warn_if_hex_ref_collides_with_objects(repo: &Repository, spec: &str, ref_oid: ObjectId) {
1959    if spec.len() >= 40 || !is_hex_prefix(spec) {
1960        return;
1961    }
1962    let Ok(matches) = find_abbrev_matches(repo, spec) else {
1963        return;
1964    };
1965    if matches.is_empty() {
1966        return;
1967    }
1968    if matches.len() > 1 || matches[0] != ref_oid {
1969        eprintln!("warning: refname '{spec}' is ambiguous.");
1970    }
1971}
1972
1973fn disambiguate_hex_by_peel(
1974    repo: &Repository,
1975    spec: &str,
1976    matches: &[ObjectId],
1977    peel: &str,
1978) -> Result<ObjectId> {
1979    let peel_some = Some(peel);
1980    let filtered: Vec<ObjectId> = matches
1981        .iter()
1982        .copied()
1983        .filter(|oid| apply_peel(repo, *oid, peel_some).is_ok())
1984        .collect();
1985    if filtered.len() == 1 {
1986        return Ok(filtered[0]);
1987    }
1988    if filtered.is_empty() {
1989        return Err(Error::InvalidRef(format!(
1990            "short object ID {spec} is ambiguous"
1991        )));
1992    }
1993    let mut peeled_targets: HashSet<ObjectId> = HashSet::new();
1994    for oid in &filtered {
1995        if let Ok(p) = apply_peel(repo, *oid, peel_some) {
1996            peeled_targets.insert(p);
1997        }
1998    }
1999    if peeled_targets.len() == 1 {
2000        // Several objects (e.g. commit + tag) may peel to the same commit; any representative
2001        // is valid for subsequent `apply_peel` in `resolve_revision_impl`.
2002        let mut sorted = filtered;
2003        sorted.sort_by_key(|o| o.to_hex());
2004        return Ok(sorted[0]);
2005    }
2006    // `^{commit}`: multiple objects may peel to the same commit (e.g. HEAD, tag, peeled tree-ish).
2007    // If exactly one distinct commit is produced, pick a deterministic representative (t1512).
2008    if peel == "commit" {
2009        let mut by_peeled: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
2010        for oid in &filtered {
2011            if let Ok(c) = apply_peel(repo, *oid, Some("commit")) {
2012                by_peeled.entry(c).or_default().push(*oid);
2013            }
2014        }
2015        if by_peeled.len() == 1 {
2016            let mut reps: Vec<ObjectId> = by_peeled.into_values().next().unwrap_or_default();
2017            reps.sort_by_key(|o| o.to_hex());
2018            if let Some(oid) = reps.first().copied() {
2019                return Ok(oid);
2020            }
2021        }
2022    }
2023    Err(Error::InvalidRef(format!(
2024        "short object ID {spec} is ambiguous"
2025    )))
2026}
2027
2028fn commit_reachable_closure(repo: &Repository, start: ObjectId) -> Result<HashSet<ObjectId>> {
2029    use std::collections::VecDeque;
2030    let mut seen = HashSet::new();
2031    let mut q = VecDeque::from([start]);
2032    while let Some(oid) = q.pop_front() {
2033        if !seen.insert(oid) {
2034            continue;
2035        }
2036        let obj = match repo.read_replaced(&oid) {
2037            Ok(o) => o,
2038            Err(_) => continue,
2039        };
2040        if obj.kind != ObjectKind::Commit {
2041            continue;
2042        }
2043        let commit = match parse_commit(&obj.data) {
2044            Ok(c) => c,
2045            Err(_) => continue,
2046        };
2047        for p in &commit.parents {
2048            q.push_back(*p);
2049        }
2050    }
2051    Ok(seen)
2052}
2053
2054/// `git rev-list --count <tag>..<head>` — commits reachable from `head` but not from `tag`.
2055fn describe_generation_count(
2056    repo: &Repository,
2057    head: ObjectId,
2058    tag_commit: ObjectId,
2059) -> Result<usize> {
2060    let from_tag = commit_reachable_closure(repo, tag_commit)?;
2061    let from_head = commit_reachable_closure(repo, head)?;
2062    Ok(from_head.difference(&from_tag).count())
2063}
2064
2065fn try_resolve_describe_name(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
2066    let re = Regex::new(r"(?i)^(.+)-(\d+)-g([0-9a-fA-F]+)$")
2067        .map_err(|_| Error::Message("internal: describe regex".to_owned()))?;
2068    let Some(caps) = re.captures(spec) else {
2069        return Ok(None);
2070    };
2071    let tag_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
2072    let gen: usize = caps
2073        .get(2)
2074        .and_then(|m| m.as_str().parse().ok())
2075        .unwrap_or(0);
2076    let hex_abbrev = caps.get(3).map(|m| m.as_str()).unwrap_or("");
2077    if tag_name.is_empty() || hex_abbrev.is_empty() {
2078        return Ok(None);
2079    }
2080    let hex_lower = hex_abbrev.to_ascii_lowercase();
2081    let mut commit_candidates: Vec<ObjectId> = find_abbrev_matches(repo, &hex_lower)?
2082        .into_iter()
2083        .filter(|oid| {
2084            repo.odb
2085                .read(oid)
2086                .is_ok_and(|o| o.kind == ObjectKind::Commit)
2087        })
2088        .collect();
2089    commit_candidates.sort_by_key(|o| o.to_hex());
2090    commit_candidates.dedup();
2091
2092    if let Ok(tag_oid) = refs::resolve_ref(&repo.git_dir, &format!("refs/tags/{tag_name}"))
2093        .or_else(|_| refs::resolve_ref(&repo.git_dir, tag_name))
2094    {
2095        let tag_commit = peel_to_commit_for_merge_base(repo, tag_oid)?;
2096        let mut strict_candidates = commit_candidates
2097            .iter()
2098            .copied()
2099            .filter(|oid| describe_generation_count(repo, *oid, tag_commit).ok() == Some(gen))
2100            .collect::<Vec<_>>();
2101        strict_candidates.sort_by_key(|o| o.to_hex());
2102        if strict_candidates.len() == 1 {
2103            return Ok(Some(strict_candidates[0]));
2104        }
2105        if strict_candidates.len() > 1 {
2106            return Err(Error::InvalidRef(format!(
2107                "short object ID {hex_abbrev} is ambiguous"
2108            )));
2109        }
2110    }
2111
2112    match commit_candidates.len() {
2113        0 => Err(Error::ObjectNotFound(spec.to_owned())),
2114        1 => Ok(Some(commit_candidates[0])),
2115        _ => Err(Error::InvalidRef(format!(
2116            "short object ID {hex_abbrev} is ambiguous"
2117        ))),
2118    }
2119}
2120
2121fn resolve_base(
2122    repo: &Repository,
2123    spec: &str,
2124    index_dwim: bool,
2125    commit_only_hex: bool,
2126    use_disambiguate_config: bool,
2127    peel_for_disambig: Option<&str>,
2128    implicit_tree_abbrev: bool,
2129    implicit_blob_abbrev: bool,
2130    remote_branch_name_guess: bool,
2131) -> Result<ObjectId> {
2132    // Standalone `@` is an alias for `HEAD` in revision parsing.
2133    if spec == "@" {
2134        return resolve_base(
2135            repo,
2136            "HEAD",
2137            index_dwim,
2138            commit_only_hex,
2139            use_disambiguate_config,
2140            peel_for_disambig,
2141            implicit_tree_abbrev,
2142            implicit_blob_abbrev,
2143            remote_branch_name_guess,
2144        );
2145    }
2146
2147    // `FETCH_HEAD`: prefer the first for-merge line, but allow checkout-style consumers to use
2148    // the first fetched OID even when every line is marked `not-for-merge`.
2149    if spec == "FETCH_HEAD" {
2150        let path = repo.git_dir.join("FETCH_HEAD");
2151        let content = std::fs::read_to_string(&path)
2152            .map_err(|_| Error::ObjectNotFound("FETCH_HEAD".to_owned()))?;
2153        let mut first_oid = None;
2154        for line in content.lines() {
2155            let line = line.trim();
2156            if line.is_empty() {
2157                continue;
2158            }
2159            let mut parts = line.split('\t');
2160            let Some(oid_hex) = parts.next() else {
2161                continue;
2162            };
2163            if oid_hex.len() == 40 && oid_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2164                let oid = oid_hex
2165                    .parse::<ObjectId>()
2166                    .map_err(|_| Error::InvalidRef("invalid FETCH_HEAD object id".to_owned()))?;
2167                first_oid.get_or_insert(oid);
2168                let not_for_merge = parts.next().is_some_and(|v| v == "not-for-merge");
2169                if !not_for_merge {
2170                    return Ok(oid);
2171                }
2172            }
2173        }
2174        return first_oid.ok_or_else(|| Error::ObjectNotFound("FETCH_HEAD".to_owned()));
2175    }
2176
2177    // `@{-N}` must run before reflog parsing so `@{-1}@{1}` is not misread as `@{-1}` + `@{1}`.
2178    if spec.starts_with("@{-") {
2179        if let Some(close) = spec[3..].find('}') {
2180            let n_str = &spec[3..3 + close];
2181            if let Ok(n) = n_str.parse::<usize>() {
2182                if n >= 1 {
2183                    let suffix = &spec[3 + close + 1..];
2184                    if suffix.is_empty() {
2185                        if let Some(oid) = try_resolve_at_minus(repo, spec)? {
2186                            return Ok(oid);
2187                        }
2188                    } else {
2189                        let branch = resolve_at_minus_to_branch(repo, n)?;
2190                        let new_spec = format!("{branch}{suffix}");
2191                        return resolve_base(
2192                            repo,
2193                            &new_spec,
2194                            index_dwim,
2195                            commit_only_hex,
2196                            use_disambiguate_config,
2197                            peel_for_disambig,
2198                            implicit_tree_abbrev,
2199                            implicit_blob_abbrev,
2200                            remote_branch_name_guess,
2201                        );
2202                    }
2203                }
2204            }
2205        }
2206    }
2207
2208    // Handle @{upstream} / @{u} / @{push} suffixes (including compounds like branch@{u}@{1})
2209    if upstream_suffix_info(spec).is_some() {
2210        let full_ref = resolve_upstream_symbolic_name(repo, spec)?;
2211        return refs::resolve_ref(&repo.git_dir, &full_ref)
2212            .map_err(|_| Error::ObjectNotFound(spec.to_owned()));
2213    }
2214
2215    // Reflog selectors: `main@{1}`, `@{3}` (current branch), `other@{u}@{1}`, etc.
2216    if let Some(oid) = try_resolve_reflog_index(repo, spec)? {
2217        return Ok(oid);
2218    }
2219
2220    // Handle `:/pattern` — search commit messages from HEAD
2221    if let Some(pattern) = spec.strip_prefix(":/") {
2222        if !pattern.is_empty() {
2223            return resolve_commit_message_search(repo, pattern);
2224        }
2225    }
2226
2227    // Handle `:N:path` — look up path in the index at stage N
2228    // Also handle `:path` — look up path in the index (stage 0)
2229    if let Some(rest) = spec.strip_prefix(':') {
2230        if !rest.is_empty() && !rest.starts_with('/') {
2231            // Check for :N:path pattern (N is a single digit 0-3)
2232            if rest.len() >= 3 && rest.as_bytes()[1] == b':' {
2233                if let Some(stage_char) = rest.chars().next() {
2234                    if let Some(stage) = stage_char.to_digit(10) {
2235                        if stage <= 3 {
2236                            let raw_path = &rest[2..];
2237                            let path = match normalize_colon_path_for_tree(repo, raw_path) {
2238                                Ok(p) => p,
2239                                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
2240                                    let wt = repo
2241                                        .work_tree
2242                                        .as_ref()
2243                                        .and_then(|p| p.canonicalize().ok())
2244                                        .map(|p| p.display().to_string())
2245                                        .unwrap_or_default();
2246                                    return Err(Error::Message(format!(
2247                                        "fatal: '{raw_path}' is outside repository at '{wt}'"
2248                                    )));
2249                                }
2250                                Err(e) => return Err(e),
2251                            };
2252                            return resolve_index_path_at_stage(repo, &path, stage as u8).map_err(
2253                                |e| diagnose_index_path_error(repo, &path, stage as u8, e),
2254                            );
2255                        }
2256                    }
2257                }
2258            }
2259            let clean_rest = match normalize_colon_path_for_tree(repo, rest) {
2260                Ok(p) => p,
2261                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
2262                    let wt = repo
2263                        .work_tree
2264                        .as_ref()
2265                        .and_then(|p| p.canonicalize().ok())
2266                        .map(|p| p.display().to_string())
2267                        .unwrap_or_default();
2268                    return Err(Error::Message(format!(
2269                        "fatal: '{rest}' is outside repository at '{wt}'"
2270                    )));
2271                }
2272                Err(e) => return Err(e),
2273            };
2274            return resolve_index_path(repo, &clean_rest)
2275                .map_err(|e| diagnose_index_path_error(repo, &clean_rest, 0, e));
2276        }
2277    }
2278
2279    if let Some((treeish, path)) = split_treeish_spec(spec) {
2280        let root_oid = resolve_revision_impl(
2281            repo,
2282            treeish,
2283            index_dwim,
2284            commit_only_hex,
2285            use_disambiguate_config,
2286            false,
2287            false,
2288            false,
2289            false,
2290        )?;
2291        return resolve_treeish_path_to_object(repo, root_oid, path);
2292    }
2293
2294    if let Ok(oid) = spec.parse::<ObjectId>() {
2295        // A full 40-hex OID is always accepted, even if the object
2296        // doesn't exist in the ODB (matches git behavior).
2297        let rn = format!("refs/heads/{spec}");
2298        if refs::resolve_ref(&repo.git_dir, &rn).is_ok() {
2299            eprintln!("warning: refname '{spec}' is ambiguous.");
2300        }
2301        return Ok(oid);
2302    }
2303
2304    match try_resolve_describe_name(repo, spec) {
2305        Ok(Some(oid)) => return Ok(oid),
2306        Err(e) => return Err(e),
2307        Ok(None) => {}
2308    }
2309
2310    // Hex-like tokens may name refs (e.g. tag `1.2` / `2.2`) — resolve those before treating the
2311    // string as an abbreviated object id (t5334 incremental MIDX).
2312    if is_hex_prefix(spec) && spec.len() < 40 {
2313        let tag_ref = format!("refs/tags/{spec}");
2314        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &tag_ref) {
2315            warn_if_hex_ref_collides_with_objects(repo, spec, oid);
2316            return Ok(oid);
2317        }
2318        let branch_ref = format!("refs/heads/{spec}");
2319        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &branch_ref) {
2320            warn_if_hex_ref_collides_with_objects(repo, spec, oid);
2321            return Ok(oid);
2322        }
2323    }
2324
2325    if is_hex_prefix(spec) {
2326        let matches = find_abbrev_matches(repo, spec)?;
2327        if matches.is_empty() {
2328            // Git treats 4+ hex digits as an abbreviated object id lookup first. When nothing
2329            // matches, fail as unknown revision — do not fall through to index DWIM (which would
2330            // incorrectly report "ambiguous argument" for paths like `000000000`).
2331            if (4..40).contains(&spec.len()) {
2332                return Err(Error::ObjectNotFound(spec.to_owned()));
2333            }
2334        } else if matches.len() == 1 {
2335            let oid = matches[0];
2336            warn_if_branch_refname_collides_with_abbrev_hex(repo, spec, oid);
2337            return Ok(oid);
2338        } else if matches.len() > 1 {
2339            if let Some(p) = peel_for_disambig {
2340                let oid = disambiguate_hex_by_peel(repo, spec, &matches, p)?;
2341                warn_if_branch_refname_collides_with_abbrev_hex(repo, spec, oid);
2342                return Ok(oid);
2343            }
2344            if commit_only_hex {
2345                let oid = disambiguate_hex_by_peel(repo, spec, &matches, "commit")?;
2346                warn_if_branch_refname_collides_with_abbrev_hex(repo, spec, oid);
2347                return Ok(oid);
2348            }
2349            if use_disambiguate_config {
2350                if let Some(pref) = read_core_disambiguate(repo) {
2351                    if let Ok(oid) = disambiguate_hex_by_peel(repo, spec, &matches, pref) {
2352                        warn_if_branch_refname_collides_with_abbrev_hex(repo, spec, oid);
2353                        return Ok(oid);
2354                    }
2355                }
2356            }
2357            return Err(Error::InvalidRef(format!(
2358                "short object ID {} is ambiguous",
2359                spec
2360            )));
2361        }
2362    }
2363
2364    let (dwim_count, dwim_oid) = resolve_ref_dwim_for_rev_parse(repo, spec);
2365    if dwim_count > 1 {
2366        eprintln!("warning: refname '{spec}' is ambiguous.");
2367    }
2368    if let Some(oid) = dwim_oid {
2369        return Ok(oid);
2370    }
2371    // `remotes/<remote>/<ref>` is a common shorthand for `refs/remotes/<remote>/<ref>` (t2024).
2372    if let Some(rest) = spec.strip_prefix("remotes/") {
2373        let full = format!("refs/remotes/{rest}");
2374        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &full) {
2375            return Ok(oid);
2376        }
2377    }
2378    // Remote name alone (`origin`, `upstream`): resolve like Git via
2379    // `refs/remotes/<name>/HEAD` (symref to the default remote-tracking branch).
2380    // Skip when a local branch with the same short name exists.
2381    if !spec.contains('/')
2382        && !spec.starts_with('.')
2383        && spec != "HEAD"
2384        && spec != "FETCH_HEAD"
2385        && spec != "MERGE_HEAD"
2386        && spec != "CHERRY_PICK_HEAD"
2387        && spec != "REVERT_HEAD"
2388        && spec != "REBASE_HEAD"
2389        && spec != "AUTO_MERGE"
2390        && spec != "stash"
2391    {
2392        let local_branch = format!("refs/heads/{spec}");
2393        if refs::resolve_ref(&repo.git_dir, &local_branch).is_err() {
2394            let remote_head = format!("refs/remotes/{spec}/HEAD");
2395            if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &remote_head) {
2396                return Ok(oid);
2397            }
2398        }
2399    }
2400    // DWIM: bare `stash` refers to `refs/stash` (like upstream Git), not `.git/stash`.
2401    if spec == "stash" {
2402        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, "refs/stash") {
2403            return Ok(oid);
2404        }
2405    }
2406    // Short names: resolve `refs/heads/<spec>` and `refs/tags/<spec>`. When both exist and
2407    // disagree, prefer the branch (matches `git checkout` / `git reset` for names like `b1`)
2408    // and warn, matching upstream ambiguous-refname behavior.
2409    let head_ref = format!("refs/heads/{spec}");
2410    let tag_ref = format!("refs/tags/{spec}");
2411    let head_oid = refs::resolve_ref(&repo.git_dir, &head_ref).ok();
2412    let tag_oid = refs::resolve_ref(&repo.git_dir, &tag_ref).ok();
2413    match (head_oid, tag_oid) {
2414        (Some(h), Some(t)) if h != t => {
2415            eprintln!("warning: refname '{spec}' is ambiguous.");
2416            return Ok(h);
2417        }
2418        (Some(h), _) => return Ok(h),
2419        (None, Some(t)) => return Ok(t),
2420        (None, None) => {}
2421    }
2422
2423    // `rev-parse` / `pack-objects --revs`: when `spec` is a single path component and a ref of
2424    // that basename exists (`refs/tags/A` vs worktree file `A.t`), prefer the ref over index
2425    // DWIM (matches Git; t5332).
2426    if !spec.contains('/')
2427        && !spec.contains(':')
2428        && !spec.starts_with('.')
2429        && spec != "HEAD"
2430        && spec.len() <= 255
2431    {
2432        let mut ref_match: Option<ObjectId> = None;
2433        for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/", "refs/notes/"] {
2434            let full = format!("{prefix}{spec}");
2435            if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &full) {
2436                ref_match = Some(oid);
2437                break;
2438            }
2439        }
2440        if let Some(oid) = ref_match {
2441            return Ok(oid);
2442        }
2443    }
2444    for candidate in &[format!("refs/remotes/{spec}"), format!("refs/notes/{spec}")] {
2445        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, candidate) {
2446            return Ok(oid);
2447        }
2448    }
2449
2450    // `git log one` / `git rev-parse one`: remote name → `refs/remotes/<name>/HEAD` (Git DWIM).
2451    if let Some(head_ref) = remote_tracking_head_symbolic_target(repo, spec) {
2452        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &head_ref) {
2453            return Ok(oid);
2454        }
2455    }
2456
2457    // DWIM: `checkout B2` when only `refs/remotes/origin/B2` exists (common after `fetch`).
2458    if remote_branch_name_guess
2459        && !spec.contains('/')
2460        && spec != "HEAD"
2461        && spec != "FETCH_HEAD"
2462        && spec != "MERGE_HEAD"
2463    {
2464        const REMOTES: &str = "refs/remotes/";
2465        if let Ok(remote_refs) = refs::list_refs(&repo.git_dir, REMOTES) {
2466            let matches: Vec<ObjectId> = remote_refs
2467                .into_iter()
2468                .filter(|(r, _)| {
2469                    r.strip_prefix(REMOTES)
2470                        .is_some_and(|rest| rest == spec || rest.ends_with(&format!("/{spec}")))
2471                })
2472                .map(|(_, oid)| oid)
2473                .collect();
2474            if matches.len() == 1 {
2475                return Ok(matches[0]);
2476            }
2477            if matches.len() > 1 {
2478                return Err(Error::InvalidRef(format!(
2479                    "ambiguous refname '{spec}': matches multiple remote-tracking branches"
2480                )));
2481            }
2482        }
2483    }
2484
2485    // As a last resort, try resolving as an index path (porcelain / DWIM only).
2486    if !spec.contains(':') && !spec.starts_with('-') {
2487        if index_dwim {
2488            if let Ok(oid) = resolve_index_path(repo, spec) {
2489                return Ok(oid);
2490            }
2491        }
2492        return Err(Error::Message(format!(
2493            "fatal: ambiguous argument '{spec}': unknown revision or path not in the working tree.\n\
2494Use '--' to separate paths from revisions, like this:\n\
2495'git <command> [<revision>...] -- [<file>...]'"
2496        )));
2497    }
2498    Err(Error::ObjectNotFound(spec.to_owned()))
2499}
2500
2501/// Resolve `@{-N}` to the branch name (e.g. "side"), not to an OID.
2502fn resolve_at_minus_to_branch(repo: &Repository, n: usize) -> Result<String> {
2503    let entries = read_reflog(&repo.git_dir, "HEAD")?;
2504    let mut count = 0usize;
2505    for entry in entries.iter().rev() {
2506        let msg = &entry.message;
2507        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
2508            count += 1;
2509            if count == n {
2510                if let Some(to_pos) = rest.find(" to ") {
2511                    return Ok(rest[..to_pos].to_string());
2512                }
2513            }
2514        }
2515    }
2516    Err(Error::InvalidRef(format!(
2517        "@{{-{n}}}: only {count} checkout(s) in reflog"
2518    )))
2519}
2520
2521/// Try to resolve `@{-N}` syntax — the Nth previously checked out branch.
2522/// Returns the resolved OID if matching, or None if not matching.
2523fn try_resolve_at_minus(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
2524    // Match @{-N} only (no ref prefix)
2525    if !spec.starts_with("@{-") || !spec.ends_with('}') {
2526        return Ok(None);
2527    }
2528    let inner = &spec[3..spec.len() - 1];
2529    let n: usize = match inner.parse() {
2530        Ok(n) if n >= 1 => n,
2531        _ => return Ok(None),
2532    };
2533    // Read HEAD reflog and find the Nth "checkout: moving from X to Y" entry
2534    let entries = read_reflog(&repo.git_dir, "HEAD")?;
2535    let mut count = 0usize;
2536    // Iterate newest-first
2537    for entry in entries.iter().rev() {
2538        let msg = &entry.message;
2539        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
2540            count += 1;
2541            if count == n {
2542                if let Some(to_pos) = rest.find(" to ") {
2543                    let from_branch = &rest[..to_pos];
2544                    let ref_name = format!("refs/heads/{from_branch}");
2545                    if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &ref_name) {
2546                        return Ok(Some(oid));
2547                    }
2548                    if let Ok(oid) = from_branch.parse::<ObjectId>() {
2549                        if repo.odb.exists(&oid) {
2550                            return Ok(Some(oid));
2551                        }
2552                    }
2553                    if is_hex_prefix(from_branch) {
2554                        if let Ok(oid) = resolve_revision_for_range_end(repo, from_branch)
2555                            .and_then(|oid| peel_to_commit_for_merge_base(repo, oid))
2556                        {
2557                            return Ok(Some(oid));
2558                        }
2559                    }
2560                    return Err(Error::InvalidRef(format!(
2561                        "cannot resolve @{{-{n}}}: branch '{}' not found",
2562                        from_branch
2563                    )));
2564                }
2565            }
2566        }
2567    }
2568    Err(Error::InvalidRef(format!(
2569        "@{{-{n}}}: only {count} checkout(s) in reflog"
2570    )))
2571}
2572
2573#[derive(Debug, Clone)]
2574enum AtStep {
2575    Index(usize),
2576    Date(i64),
2577    Upstream,
2578    Push,
2579    Now,
2580}
2581
2582fn try_parse_at_step_inner(inner: &str) -> Option<AtStep> {
2583    if inner.eq_ignore_ascii_case("u") || inner.eq_ignore_ascii_case("upstream") {
2584        return Some(AtStep::Upstream);
2585    }
2586    if inner.eq_ignore_ascii_case("push") {
2587        return Some(AtStep::Push);
2588    }
2589    if inner.eq_ignore_ascii_case("now") {
2590        return Some(AtStep::Now);
2591    }
2592    if let Ok(n) = inner.parse::<usize>() {
2593        return Some(AtStep::Index(n));
2594    }
2595    approxidate(inner).map(AtStep::Date)
2596}
2597
2598fn next_reflog_at_open(spec: &str, mut from: usize) -> Option<usize> {
2599    let b = spec.as_bytes();
2600    while let Some(rel) = spec[from..].find("@{") {
2601        let i = from + rel;
2602        // `@{-N}` is previous-branch syntax, not a reflog selector — skip the whole token.
2603        if b.get(i + 2) == Some(&b'-') {
2604            let after_open = i + 2;
2605            let close = spec[after_open..].find('}').map(|j| after_open + j)?;
2606            from = close + 1;
2607            continue;
2608        }
2609        return Some(i);
2610    }
2611    None
2612}
2613
2614/// Split `spec` into a ref prefix and a chain of `@{...}` steps (empty chain → not a reflog form).
2615fn split_reflog_at_chain(spec: &str) -> Option<(String, Vec<AtStep>)> {
2616    let at = next_reflog_at_open(spec, 0)?;
2617    let prefix = spec[..at].to_owned();
2618    let mut steps = Vec::new();
2619    let mut pos = at;
2620    while pos < spec.len() {
2621        let rest = &spec[pos..];
2622        if !rest.starts_with("@{") {
2623            return None;
2624        }
2625        if rest.as_bytes().get(2) == Some(&b'-') {
2626            return None;
2627        }
2628        let inner_start = pos + 2;
2629        let close = spec[inner_start..].find('}').map(|i| inner_start + i)?;
2630        let inner = &spec[inner_start..close];
2631        let step = try_parse_at_step_inner(inner)?;
2632        steps.push(step);
2633        pos = close + 1;
2634    }
2635    if steps.is_empty() {
2636        return None;
2637    }
2638    Some((prefix, steps))
2639}
2640
2641fn dwim_refname(repo: &Repository, raw: &str) -> String {
2642    if raw.is_empty() || raw == "HEAD" || raw.starts_with("refs/") {
2643        return raw.to_owned();
2644    }
2645    // Bare `stash` is `refs/stash` (not `refs/heads/stash`); reflog lives at `logs/refs/stash`.
2646    if raw == "stash" && refs::resolve_ref(&repo.git_dir, "refs/stash").is_ok() {
2647        return "refs/stash".to_owned();
2648    }
2649    let candidate = format!("refs/heads/{raw}");
2650    if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
2651        candidate
2652    } else {
2653        raw.to_owned()
2654    }
2655}
2656
2657fn reflog_display_name(refname_raw: &str, refname: &str) -> String {
2658    if refname_raw.is_empty() {
2659        if let Some(b) = refname.strip_prefix("refs/heads/") {
2660            return b.to_owned();
2661        }
2662        return refname.to_owned();
2663    }
2664    refname_raw.to_owned()
2665}
2666
2667fn resolve_reflog_oid(
2668    repo: &Repository,
2669    refname: &str,
2670    refname_raw: &str,
2671    index_or_date: ReflogSelector,
2672) -> Result<ObjectId> {
2673    let mut entries = read_reflog(&repo.git_dir, refname)?;
2674    if refname == "HEAD" {
2675        if let ReflogSelector::Index(index) = index_or_date {
2676            if index >= entries.len() {
2677                if let Ok(Some(branch_ref)) = crate::refs::read_symbolic_ref(&repo.git_dir, "HEAD")
2678                {
2679                    if let Ok(branch_entries) = read_reflog(&repo.git_dir, &branch_ref) {
2680                        if index < branch_entries.len() {
2681                            entries = branch_entries;
2682                        }
2683                    }
2684                }
2685            }
2686        }
2687    }
2688    let display = reflog_display_name(refname_raw, refname);
2689    match index_or_date {
2690        ReflogSelector::Index(index) => {
2691            let len = entries.len();
2692            if index == 0 {
2693                if len == 0 {
2694                    // Git's `repo_dwim_log` only treats `ref@{N}` as resolvable when the
2695                    // reflog file actually exists; with no reflog at all the resolution
2696                    // fails. Only when the reflog exists but is empty does `ref@{0}` fall
2697                    // back to the ref's current value (the `nth == co_cnt` case in
2698                    // object-name.c).
2699                    if !crate::reflog::reflog_exists(&repo.git_dir, refname) {
2700                        return Err(Error::Message(format!(
2701                            "fatal: log for '{display}' is empty"
2702                        )));
2703                    }
2704                    return refs::resolve_ref(&repo.git_dir, refname).map_err(|_| {
2705                        Error::Message(format!("fatal: log for '{display}' is empty"))
2706                    });
2707                }
2708                return Ok(entries[len - 1].new_oid);
2709            }
2710            if len == 0 {
2711                return Err(Error::Message(format!(
2712                    "fatal: log for '{display}' is empty"
2713                )));
2714            }
2715            if index > len {
2716                return Err(Error::Message(format!(
2717                    "fatal: log for '{display}' only has {len} entries"
2718                )));
2719            }
2720            let oid = entries[len - index].old_oid;
2721            if oid.is_zero() {
2722                return Err(Error::Message(format!(
2723                    "fatal: log for '{display}' only has {len} entries"
2724                )));
2725            }
2726            Ok(oid)
2727        }
2728        ReflogSelector::Date(target_ts) => {
2729            if entries.is_empty() {
2730                return Err(Error::Message(format!(
2731                    "fatal: log for '{display}' is empty"
2732                )));
2733            }
2734            for entry in entries.iter().rev() {
2735                let ts = parse_reflog_entry_timestamp(entry);
2736                if let Some(t) = ts {
2737                    if t <= target_ts {
2738                        return Ok(entry.new_oid);
2739                    }
2740                }
2741            }
2742            Ok(entries[0].new_oid)
2743        }
2744    }
2745}
2746
2747fn resolve_at_minus_token_to_branch(repo: &Repository, token: &str) -> Result<Option<String>> {
2748    if !token.starts_with("@{-") || !token.ends_with('}') {
2749        return Ok(None);
2750    }
2751    let inner = &token[3..token.len() - 1];
2752    let n: usize = inner
2753        .parse()
2754        .map_err(|_| Error::InvalidRef(format!("invalid N in @{{-N}} for '{token}'")))?;
2755    if n < 1 {
2756        return Ok(None);
2757    }
2758    Ok(Some(resolve_at_minus_to_branch(repo, n)?))
2759}
2760
2761/// Ref whose reflog `git log -g` should walk for a revision like `other@{u}` or `main@{1}`.
2762///
2763/// Returns `None` when `spec` is not a reflog-chain form (no `@{` step after the prefix).
2764pub fn reflog_walk_refname(repo: &Repository, spec: &str) -> Result<Option<String>> {
2765    let Some((prefix, steps)) = split_reflog_at_chain(spec) else {
2766        return Ok(None);
2767    };
2768
2769    let prefix_resolved = if let Some(b) = resolve_at_minus_token_to_branch(repo, &prefix)? {
2770        b
2771    } else {
2772        prefix.clone()
2773    };
2774
2775    let mut current_spec = if prefix_resolved.is_empty() {
2776        if let Ok(Some(b)) = refs::read_head(&repo.git_dir) {
2777            if let Some(short) = b.strip_prefix("refs/heads/") {
2778                short.to_owned()
2779            } else {
2780                "HEAD".to_owned()
2781            }
2782        } else {
2783            "HEAD".to_owned()
2784        }
2785    } else {
2786        prefix_resolved
2787    };
2788
2789    let last_reflog_peel = steps
2790        .iter()
2791        .rposition(|s| matches!(s, AtStep::Index(_) | AtStep::Date(_) | AtStep::Now));
2792
2793    let limit = last_reflog_peel.unwrap_or(steps.len());
2794    for step in steps.iter().take(limit) {
2795        match step {
2796            AtStep::Upstream => {
2797                let base = if current_spec == "@" {
2798                    "HEAD"
2799                } else {
2800                    current_spec.as_str()
2801                };
2802                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{u}}"))?;
2803                current_spec = full;
2804            }
2805            AtStep::Push => {
2806                let base = if current_spec == "@" {
2807                    "HEAD"
2808                } else {
2809                    current_spec.as_str()
2810                };
2811                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{push}}"))?;
2812                current_spec = full;
2813            }
2814            AtStep::Now | AtStep::Index(_) | AtStep::Date(_) => {}
2815        }
2816    }
2817
2818    Ok(Some(dwim_refname(repo, current_spec.as_str())))
2819}
2820
2821/// Resolve a user revision string to the reflog file ref name for `log -g` / `rev-list -g`.
2822///
2823/// Mirrors Git `add_reflog_for_walk` / `read_complete_reflog` ref resolution before reading
2824/// `logs/<ref>`.
2825pub fn resolve_reflog_walk_log_ref(repo: &Repository, r: &str) -> Result<String> {
2826    if let Ok(Some(w)) = reflog_walk_refname(repo, r) {
2827        return Ok(w);
2828    }
2829    if r == "HEAD" || r.starts_with("refs/") {
2830        return Ok(r.to_string());
2831    }
2832    if r.starts_with("@{") {
2833        if let Some(n_str) = r.strip_prefix("@{").and_then(|s| s.strip_suffix('}')) {
2834            if let Some(stripped) = n_str.strip_prefix('-') {
2835                if stripped.parse::<usize>().is_ok() {
2836                    if let Ok(branch) = refs::resolve_at_n_branch(&repo.git_dir, r) {
2837                        return Ok(format!("refs/heads/{branch}"));
2838                    }
2839                }
2840            }
2841        }
2842        return Ok(r.to_string());
2843    }
2844    let candidate = format!("refs/heads/{r}");
2845    if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
2846        Ok(candidate)
2847    } else {
2848        Ok(r.to_string())
2849    }
2850}
2851
2852/// Try to resolve `ref@{...}` with optional chained `@{...}` steps (e.g. `other@{u}@{1}`).
2853fn try_resolve_reflog_index(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
2854    let Some((prefix, steps)) = split_reflog_at_chain(spec) else {
2855        return Ok(None);
2856    };
2857
2858    let prefix_resolved = if let Some(b) = resolve_at_minus_token_to_branch(repo, &prefix)? {
2859        b
2860    } else {
2861        prefix.clone()
2862    };
2863
2864    let mut current_spec = if prefix_resolved.is_empty() {
2865        if let Ok(Some(b)) = refs::read_head(&repo.git_dir) {
2866            if let Some(short) = b.strip_prefix("refs/heads/") {
2867                short.to_owned()
2868            } else {
2869                "HEAD".to_owned()
2870            }
2871        } else {
2872            "HEAD".to_owned()
2873        }
2874    } else {
2875        prefix_resolved
2876    };
2877
2878    for (i, step) in steps.iter().enumerate() {
2879        match step {
2880            AtStep::Upstream => {
2881                let base = if current_spec == "@" {
2882                    "HEAD"
2883                } else {
2884                    current_spec.as_str()
2885                };
2886                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{u}}"))?;
2887                current_spec = full;
2888            }
2889            AtStep::Push => {
2890                let base = if current_spec == "@" {
2891                    "HEAD"
2892                } else {
2893                    current_spec.as_str()
2894                };
2895                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{push}}"))?;
2896                current_spec = full;
2897            }
2898            AtStep::Now => {
2899                let refname_raw = current_spec.as_str();
2900                let refname = dwim_refname(repo, refname_raw);
2901                let oid =
2902                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Index(0))?;
2903                if i + 1 == steps.len() {
2904                    return Ok(Some(oid));
2905                }
2906                current_spec = oid.to_hex();
2907            }
2908            AtStep::Index(n) => {
2909                let refname_raw = current_spec.as_str();
2910                let refname = dwim_refname(repo, refname_raw);
2911                let oid =
2912                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Index(*n))?;
2913                if i + 1 == steps.len() {
2914                    return Ok(Some(oid));
2915                }
2916                current_spec = oid.to_hex();
2917            }
2918            AtStep::Date(ts) => {
2919                let refname_raw = current_spec.as_str();
2920                let refname = dwim_refname(repo, refname_raw);
2921                let oid =
2922                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Date(*ts))?;
2923                if i + 1 == steps.len() {
2924                    return Ok(Some(oid));
2925                }
2926                current_spec = oid.to_hex();
2927            }
2928        }
2929    }
2930
2931    let refname_raw = current_spec.as_str();
2932    let refname = dwim_refname(repo, refname_raw);
2933    refs::resolve_ref(&repo.git_dir, &refname)
2934        .map(Some)
2935        .map_err(|_| Error::ObjectNotFound(spec.to_owned()))
2936}
2937
2938enum ReflogSelector {
2939    Index(usize),
2940    Date(i64),
2941}
2942
2943/// Parse a timestamp from a reflog entry's identity string.
2944fn parse_reflog_entry_timestamp(entry: &crate::reflog::ReflogEntry) -> Option<i64> {
2945    // Identity looks like: "Name <email> 1234567890 +0000"
2946    let parts: Vec<&str> = entry.identity.rsplitn(3, ' ').collect();
2947    if parts.len() >= 2 {
2948        parts[1].parse::<i64>().ok()
2949    } else {
2950        None
2951    }
2952}
2953
2954/// Parse a reflog date selector string (e.g. `yesterday`, `2005-04-07`) to a Unix timestamp.
2955///
2956/// Used by `git log -g` display to match Git's `ref@{date}` formatting in tests.
2957#[must_use]
2958pub fn reflog_date_selector_timestamp(s: &str) -> Option<i64> {
2959    approxidate(s)
2960}
2961
2962/// Simple approximate date parser for reflog date lookups.
2963/// Handles formats like "2001-09-17", "3.hot.dogs.on.2001-09-17", etc.
2964fn approxidate(s: &str) -> Option<i64> {
2965    let now_ts = std::time::SystemTime::now()
2966        .duration_since(std::time::UNIX_EPOCH)
2967        .ok()
2968        .map(|d| d.as_secs() as i64)
2969        .unwrap_or(0);
2970    let lower = s.trim().to_ascii_lowercase();
2971    if lower.split_whitespace().next() == Some("now") {
2972        // Match Git's test harness: `test_tick` sets GIT_COMMITTER_DATE; `@{now}` must use that
2973        // clock, not wall time (t1507 `log -g other@{u}@{now}`).
2974        if let Ok(raw) =
2975            std::env::var("GIT_COMMITTER_DATE").or_else(|_| std::env::var("GIT_AUTHOR_DATE"))
2976        {
2977            let mut it = raw.split_whitespace();
2978            if let Some(ts) = it.next().and_then(|p| p.parse::<i64>().ok()) {
2979                return Some(ts);
2980            }
2981        }
2982        return Some(now_ts);
2983    }
2984    if let Ok((ts, _offset)) = crate::git_date::parse::parse_date_basic(s.trim()) {
2985        return Some(ts as i64);
2986    }
2987    // Handle relative time: "N.unit.ago" or "N unit ago"
2988    // e.g. "1.year.ago", "2.weeks.ago", "3 hours ago"
2989    let relative = lower.replace('.', " ");
2990    let parts: Vec<&str> = relative.split_whitespace().collect();
2991    if parts.len() >= 2 {
2992        // Try to parse "N unit ago" or just "N unit". Both are past-relative: git's
2993        // approxidate treats a bare "N unit" the same as "N unit ago" (it parses times for
2994        // --since/--until), so the result is always `now - N*unit`.
2995        let (n_str, unit) = if parts.len() >= 3 && parts[2] == "ago" {
2996            (parts[0], parts[1])
2997        } else if parts.len() == 2 {
2998            (parts[0], parts[1])
2999        } else {
3000            ("", "")
3001        };
3002        if !n_str.is_empty() {
3003            if let Ok(n) = n_str.parse::<i64>() {
3004                let secs: Option<i64> = match unit.trim_end_matches('s') {
3005                    "second" => Some(n),
3006                    "minute" => Some(n * 60),
3007                    "hour" => Some(n * 3600),
3008                    "day" => Some(n * 86400),
3009                    "week" => Some(n * 604800),
3010                    "month" => Some(n * 2592000),
3011                    "year" => Some(n * 31536000),
3012                    _ => None,
3013                };
3014                if let Some(s) = secs {
3015                    return Some(now_ts - s);
3016                }
3017            }
3018        }
3019    }
3020    // Try to extract a YYYY-MM-DD pattern from the string
3021    let re_like = |input: &str| -> Option<i64> {
3022        // Scan for 4-digit year followed by -MM-DD
3023        for (i, _) in input.char_indices() {
3024            let rest = &input[i..];
3025            if rest.len() >= 10 {
3026                let bytes = rest.as_bytes();
3027                if bytes[4] == b'-'
3028                    && bytes[7] == b'-'
3029                    && bytes[0..4].iter().all(|b| b.is_ascii_digit())
3030                    && bytes[5..7].iter().all(|b| b.is_ascii_digit())
3031                    && bytes[8..10].iter().all(|b| b.is_ascii_digit())
3032                {
3033                    let year: i32 = rest[0..4].parse().ok()?;
3034                    let month: u8 = rest[5..7].parse().ok()?;
3035                    let day: u8 = rest[8..10].parse().ok()?;
3036                    let date = time::Date::from_calendar_date(
3037                        year,
3038                        time::Month::try_from(month).ok()?,
3039                        day,
3040                    )
3041                    .ok()?;
3042                    let dt = date.with_hms(0, 0, 0).ok()?;
3043                    let odt = dt.assume_utc();
3044                    return Some(odt.unix_timestamp());
3045                }
3046            }
3047        }
3048        None
3049    };
3050    re_like(s)
3051}
3052
3053fn head_tree_oid(repo: &Repository) -> Result<ObjectId> {
3054    let head_oid = refs::resolve_ref(&repo.git_dir, "HEAD")?;
3055    peel_to_tree(repo, head_oid)
3056}
3057
3058fn path_in_tree(repo: &Repository, tree_oid: ObjectId, path: &str) -> bool {
3059    resolve_tree_path(repo, &tree_oid, path).is_ok()
3060}
3061
3062fn path_in_index(repo: &Repository, path: &str, stage: u8) -> bool {
3063    resolve_index_path_at_stage(repo, path, stage).is_ok()
3064}
3065
3066fn diagnose_tree_path_error(
3067    repo: &Repository,
3068    rev_label: &str,
3069    raw_after_colon: &str,
3070    clean_path: &str,
3071    err: Error,
3072) -> Error {
3073    let Error::ObjectNotFound(msg) = err else {
3074        return err;
3075    };
3076    if !msg.contains("not found in tree") {
3077        return Error::ObjectNotFound(msg);
3078    }
3079    let rel_display: &str =
3080        if raw_after_colon.starts_with("./") || raw_after_colon.starts_with("../") {
3081            clean_path
3082        } else {
3083            raw_after_colon
3084        };
3085    if let Ok(head_tree) = head_tree_oid(repo) {
3086        if path_in_tree(repo, head_tree, clean_path) {
3087            return Error::Message(format!(
3088                "fatal: path '{rel_display}' exists on disk, but not in '{rev_label}'."
3089            ));
3090        }
3091        if let Ok(cwd) = std::env::current_dir() {
3092            let prefix = show_prefix(repo, &cwd);
3093            let pfx = prefix.trim_end_matches('/');
3094            if !pfx.is_empty() {
3095                let candidate = if clean_path.is_empty() {
3096                    pfx.to_owned()
3097                } else {
3098                    format!("{pfx}/{clean_path}")
3099                };
3100                if path_in_tree(repo, head_tree, &candidate) {
3101                    return Error::Message(format!(
3102                        "fatal: path '{candidate}' exists, but not '{rel_display}'\n\
3103hint: Did you mean '{rev_label}:{candidate}' aka '{rev_label}:./{rel_display}'?"
3104                    ));
3105                }
3106            }
3107        }
3108        let on_disk = repo
3109            .work_tree
3110            .as_ref()
3111            .map(|wt| wt.join(clean_path))
3112            .is_some_and(|p| p.exists());
3113        let in_index = path_in_index(repo, clean_path, 0);
3114        if on_disk || in_index {
3115            return Error::Message(format!(
3116                "fatal: path '{rel_display}' exists on disk, but not in '{rev_label}'."
3117            ));
3118        }
3119    }
3120    Error::Message(format!(
3121        "fatal: path '{rel_display}' does not exist in '{rev_label}'"
3122    ))
3123}
3124
3125fn diagnose_index_path_error(repo: &Repository, path: &str, stage: u8, err: Error) -> Error {
3126    let Error::ObjectNotFound(_) = err else {
3127        return err;
3128    };
3129    let work_path = repo
3130        .work_tree
3131        .as_ref()
3132        .map(|wt| wt.join(path))
3133        .filter(|p| p.exists());
3134    let on_disk = work_path.is_some();
3135    let in_head = head_tree_oid(repo)
3136        .map(|t| path_in_tree(repo, t, path))
3137        .unwrap_or(false);
3138    let in_index = path_in_index(repo, path, 0);
3139    let at_stage = path_in_index(repo, path, stage);
3140
3141    if stage > 0 && !in_index {
3142        if let Ok(cwd) = std::env::current_dir() {
3143            let prefix = show_prefix(repo, &cwd);
3144            let pfx = prefix.trim_end_matches('/');
3145            if !pfx.is_empty() {
3146                let candidate = if path.is_empty() {
3147                    pfx.to_owned()
3148                } else {
3149                    format!("{pfx}/{path}")
3150                };
3151                if path_in_index(repo, &candidate, 0) && !path_in_index(repo, &candidate, stage) {
3152                    return Error::Message(format!(
3153                        "fatal: path '{candidate}' is in the index, but not '{path}'\n\
3154hint: Did you mean ':0:{candidate}' aka ':0:./{path}'?"
3155                    ));
3156                }
3157            }
3158        }
3159        return Error::Message(format!(
3160            "fatal: path '{path}' does not exist (neither on disk nor in the index)"
3161        ));
3162    }
3163
3164    if stage > 0 && in_index && !at_stage {
3165        return Error::Message(format!(
3166            "fatal: path '{path}' is in the index, but not at stage {stage}\n\
3167hint: Did you mean ':0:{path}'?"
3168        ));
3169    }
3170
3171    if stage == 0 {
3172        if !on_disk && !in_index {
3173            if let Ok(cwd) = std::env::current_dir() {
3174                let prefix = show_prefix(repo, &cwd);
3175                let pfx = prefix.trim_end_matches('/');
3176                if !pfx.is_empty() {
3177                    let candidate = if path.is_empty() {
3178                        pfx.to_owned()
3179                    } else {
3180                        format!("{pfx}/{path}")
3181                    };
3182                    if path_in_index(repo, &candidate, 0) {
3183                        return Error::Message(format!(
3184                            "fatal: path '{candidate}' is in the index, but not '{path}'\n\
3185hint: Did you mean ':0:{candidate}' aka ':0:./{path}'?"
3186                        ));
3187                    }
3188                }
3189            }
3190            return Error::Message(format!(
3191                "fatal: path '{path}' does not exist (neither on disk nor in the index)"
3192            ));
3193        }
3194        if on_disk && !in_index && !in_head {
3195            return Error::Message(format!(
3196                "fatal: path '{path}' exists on disk, but not in the index"
3197            ));
3198        }
3199    }
3200    Error::Message(format!("fatal: path '{path}' does not exist in the index"))
3201}
3202
3203/// Look up a path in the index (stage 0) and return its OID.
3204fn resolve_index_path(repo: &Repository, path: &str) -> Result<ObjectId> {
3205    resolve_index_path_at_stage(repo, path, 0)
3206}
3207
3208/// Parsed `:path` / `:N:path` index revision syntax (leading colon, not `:/search`).
3209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3210pub struct IndexColonSpec<'a> {
3211    /// Merge stage (`0` for normal entries, `1`–`3` for unmerged stages).
3212    pub stage: u8,
3213    /// Path segment before normalization against the work tree.
3214    pub raw_path: &'a str,
3215}
3216
3217/// If `spec` uses Git's index-only revision form (`:file`, `:0:file`, …), returns the stage and path segment.
3218///
3219/// Returns [`None`] for non-index forms such as `HEAD:file`, bare OIDs, or `:/message` search.
3220#[must_use]
3221pub fn parse_index_colon_spec(spec: &str) -> Option<IndexColonSpec<'_>> {
3222    if !spec.starts_with(':') || spec.starts_with(":/") || spec.len() <= 1 {
3223        return None;
3224    }
3225    let rest = &spec[1..];
3226    if rest.is_empty() {
3227        return None;
3228    }
3229    if rest.len() >= 3 && rest.as_bytes()[1] == b':' {
3230        if let Some(stage_char) = rest.chars().next() {
3231            if let Some(stage) = stage_char.to_digit(10) {
3232                if stage <= 3 {
3233                    return Some(IndexColonSpec {
3234                        stage: stage as u8,
3235                        raw_path: &rest[2..],
3236                    });
3237                }
3238            }
3239        }
3240    }
3241    Some(IndexColonSpec {
3242        stage: 0,
3243        raw_path: rest,
3244    })
3245}
3246
3247/// One index entry resolved from a `:path` / `:N:path` revision string.
3248#[derive(Debug, Clone, PartialEq, Eq)]
3249pub struct IndexPathEntry {
3250    /// Repository-relative path using `/` separators (normalized from the spec).
3251    pub path: String,
3252    /// Blob OID stored for this index entry.
3253    pub oid: ObjectId,
3254    /// Index entry mode (e.g. `0o100644`).
3255    pub mode: u32,
3256}
3257
3258/// Resolve an index revision string (`:file` or `:N:file`) to the staged entry's path, OID, and mode.
3259///
3260/// # Returns
3261///
3262/// - `Ok(None)` if `spec` is not `:path` index syntax.
3263/// - `Ok(Some(entry))` on success.
3264/// - `Err` if the syntax matches but the path is invalid or missing from the index.
3265pub fn resolve_index_path_entry(repo: &Repository, spec: &str) -> Result<Option<IndexPathEntry>> {
3266    let Some(colon) = parse_index_colon_spec(spec) else {
3267        return Ok(None);
3268    };
3269    let path = match normalize_colon_path_for_tree(repo, colon.raw_path) {
3270        Ok(p) => p,
3271        Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
3272            let wt = repo
3273                .work_tree
3274                .as_ref()
3275                .and_then(|p| p.canonicalize().ok())
3276                .map(|p| p.display().to_string())
3277                .unwrap_or_default();
3278            return Err(Error::Message(format!(
3279                "fatal: '{}' is outside repository at '{wt}'",
3280                colon.raw_path
3281            )));
3282        }
3283        Err(e) => return Err(e),
3284    };
3285    let index_path = if let Ok(raw) = std::env::var("GIT_INDEX_FILE") {
3286        let p = std::path::PathBuf::from(raw);
3287        if p.is_absolute() {
3288            p
3289        } else if let Ok(cwd) = std::env::current_dir() {
3290            cwd.join(p)
3291        } else {
3292            p
3293        }
3294    } else {
3295        repo.index_path()
3296    };
3297    use crate::index::Index;
3298    let index = Index::load_expand_sparse(&index_path, &repo.odb)
3299        .map_err(|_| Error::ObjectNotFound(format!(":{}:{}", colon.stage, path)))?;
3300    let entry = index
3301        .get(path.as_bytes(), colon.stage)
3302        .ok_or_else(|| Error::ObjectNotFound(format!(":{}:{}", colon.stage, path)))?;
3303    Ok(Some(IndexPathEntry {
3304        path,
3305        oid: entry.oid,
3306        mode: entry.mode,
3307    }))
3308}
3309
3310/// Look up a path in the index at a given stage and return its OID.
3311fn resolve_index_path_at_stage(repo: &Repository, path: &str, stage: u8) -> Result<ObjectId> {
3312    use crate::index::Index;
3313    let index_path = if let Ok(raw) = std::env::var("GIT_INDEX_FILE") {
3314        let p = std::path::PathBuf::from(raw);
3315        if p.is_absolute() {
3316            p
3317        } else if let Ok(cwd) = std::env::current_dir() {
3318            cwd.join(p)
3319        } else {
3320            p
3321        }
3322    } else {
3323        repo.index_path()
3324    };
3325    let index = Index::load_expand_sparse(&index_path, &repo.odb)
3326        .map_err(|_| Error::ObjectNotFound(format!(":{stage}:{path}")))?;
3327    match index.get(path.as_bytes(), stage) {
3328        Some(entry) => Ok(entry.oid),
3329        None => Err(Error::ObjectNotFound(format!(":{stage}:{path}"))),
3330    }
3331}
3332
3333/// Split `treeish:path` at the first colon that separates a revision from a path,
3334/// ignoring colons inside `^{...}` peel operators.
3335///
3336/// Returns [`None`] for index-only forms like `:path` and `:N:path` (leading `:`).
3337pub fn split_treeish_colon(spec: &str) -> Option<(&str, &str)> {
3338    if spec.starts_with(':') {
3339        return None;
3340    }
3341    let bytes = spec.as_bytes();
3342    let mut i = 0usize;
3343    let mut peel_depth = 0usize;
3344    while i < bytes.len() {
3345        if i + 1 < bytes.len() && bytes[i] == b'^' && bytes[i + 1] == b'{' {
3346            peel_depth += 1;
3347            i += 2;
3348            continue;
3349        }
3350        if peel_depth > 0 {
3351            if bytes[i] == b'}' {
3352                peel_depth -= 1;
3353            }
3354            i += 1;
3355            continue;
3356        }
3357        if bytes[i] == b':' && i > 0 {
3358            let before = &spec[..i];
3359            let after = &spec[i + 1..];
3360            if !before.is_empty() {
3361                return Some((before, after)); // after may be empty ("HEAD:" = root tree)
3362            }
3363        }
3364        i += 1;
3365    }
3366    None
3367}
3368
3369pub(crate) fn split_treeish_spec(spec: &str) -> Option<(&str, &str)> {
3370    split_treeish_colon(spec)
3371}
3372
3373/// Resolve `treeish:path` to the object at `path` (blob, tree, or gitlink OID at the leaf).
3374///
3375/// Unlike [`walk_tree_to_blob_entry`], the final path component may name a tree (Git `rev-parse`).
3376pub(crate) fn resolve_treeish_path_to_object(
3377    repo: &Repository,
3378    treeish: ObjectId,
3379    path: &str,
3380) -> Result<ObjectId> {
3381    let object = repo.read_replaced(&treeish)?;
3382    let mut current_tree = match object.kind {
3383        ObjectKind::Commit => parse_commit(&object.data)?.tree,
3384        ObjectKind::Tree => treeish,
3385        _ => {
3386            return Err(Error::InvalidRef(format!(
3387                "object {treeish} does not name a tree"
3388            )))
3389        }
3390    };
3391
3392    let parts_vec: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
3393    if parts_vec.is_empty() {
3394        return Ok(current_tree);
3395    }
3396    for (idx, part) in parts_vec.iter().enumerate() {
3397        let tree_object = repo.read_replaced(&current_tree)?;
3398        if tree_object.kind != ObjectKind::Tree {
3399            return Err(Error::CorruptObject(format!(
3400                "object {current_tree} is not a tree"
3401            )));
3402        }
3403        let entries = parse_tree(&tree_object.data)?;
3404        let Some(entry) = entries.iter().find(|entry| entry.name == part.as_bytes()) else {
3405            return Err(Error::ObjectNotFound(path.to_owned()));
3406        };
3407        if idx + 1 == parts_vec.len() {
3408            return Ok(entry.oid);
3409        }
3410        if entry.mode != crate::index::MODE_TREE {
3411            return Err(Error::ObjectNotFound(path.to_owned()));
3412        }
3413        current_tree = entry.oid;
3414    }
3415
3416    Err(Error::ObjectNotFound(path.to_owned()))
3417}
3418
3419pub(crate) fn resolve_treeish_path(
3420    repo: &Repository,
3421    treeish: ObjectId,
3422    path: &str,
3423) -> Result<ObjectId> {
3424    resolve_treeish_path_to_object(repo, treeish, path)
3425}
3426
3427fn apply_peel(repo: &Repository, mut oid: ObjectId, peel: Option<&str>) -> Result<ObjectId> {
3428    match peel {
3429        None => Ok(oid),
3430        Some(search) if search.starts_with('/') => {
3431            let pattern = &search[1..];
3432            if pattern.is_empty() {
3433                return Err(Error::InvalidRef(
3434                    "empty commit message search pattern".to_owned(),
3435                ));
3436            }
3437            resolve_commit_message_search_from(repo, oid, pattern)
3438        }
3439        Some("") => {
3440            while let Ok(obj) = repo.read_replaced(&oid) {
3441                if obj.kind != ObjectKind::Tag {
3442                    break;
3443                }
3444                oid = parse_tag_target(&obj.data)?;
3445            }
3446            Ok(oid)
3447        }
3448        Some("commit") => {
3449            oid = apply_peel(repo, oid, Some(""))?;
3450            let obj = repo.read_replaced(&oid)?;
3451            if obj.kind == ObjectKind::Commit {
3452                Ok(oid)
3453            } else {
3454                Err(Error::InvalidRef("expected commit".to_owned()))
3455            }
3456        }
3457        Some("tree") => {
3458            // Peel tags, then dereference a commit to its tree.
3459            oid = apply_peel(repo, oid, Some(""))?;
3460            let obj = repo.read_replaced(&oid)?;
3461            match obj.kind {
3462                ObjectKind::Tree => Ok(oid),
3463                ObjectKind::Commit => Ok(parse_commit(&obj.data)?.tree),
3464                _ => Err(Error::InvalidRef("expected tree or commit".to_owned())),
3465            }
3466        }
3467        Some("blob") => {
3468            // ^{blob}: peel tags until we reach a blob
3469            let mut cur = oid;
3470            loop {
3471                let obj = repo.read_replaced(&cur)?;
3472                match obj.kind {
3473                    ObjectKind::Blob => return Ok(cur),
3474                    ObjectKind::Tag => {
3475                        cur = parse_tag_target(&obj.data)?;
3476                    }
3477                    _ => return Err(Error::InvalidRef("expected blob".to_owned())),
3478                }
3479            }
3480        }
3481        Some("object") => Ok(oid),
3482        Some("tag") => {
3483            // ^{tag}: return if it's a tag object
3484            let obj = repo.read_replaced(&oid)?;
3485            if obj.kind == ObjectKind::Tag {
3486                Ok(oid)
3487            } else {
3488                Err(Error::InvalidRef("expected tag".to_owned()))
3489            }
3490        }
3491        Some(other) => Err(Error::InvalidRef(format!(
3492            "unsupported peel operator '{{{other}}}'"
3493        ))),
3494    }
3495}
3496
3497/// Expand a single revision token that ends with `^!` (Git: commit without its parents).
3498///
3499/// Returns one token unchanged when `^!` is absent. When present, returns the base revision
3500/// (without `^!`) plus one `^<parent-hex>` entry per parent from [`commit_parents_for_navigation`]
3501/// (commit object parents plus graft/replace overrides), matching Git’s `^!` expansion for
3502/// merge commits.
3503///
3504/// # Errors
3505///
3506/// Returns [`Error::Message`] for an empty base revision and other resolution failures.
3507pub fn expand_rev_token_circ_bang(repo: &Repository, token: &str) -> Result<Vec<String>> {
3508    let Some(base) = token.strip_suffix("^!") else {
3509        return Ok(vec![token.to_owned()]);
3510    };
3511    if base.is_empty() {
3512        return Err(Error::Message(format!(
3513            "fatal: ambiguous argument '{token}': unknown revision or path not in the working tree.\n\
3514Use '--' to separate paths from revisions, like this:\n\
3515'git <command> [<revision>...] -- [<file>...]'"
3516        )));
3517    }
3518    let oid = resolve_revision_for_range_end(repo, base)?;
3519    let commit_oid = peel_to_commit_for_merge_base(repo, oid)?;
3520    let parents = commit_parents_for_navigation(repo, commit_oid)?;
3521    let mut out = vec![base.to_owned()];
3522    for p in parents {
3523        out.push(format!("^{}", p.to_hex()));
3524    }
3525    Ok(out)
3526}
3527
3528/// Split `spec` into `(base, peel_inner)` for `^{...}` / `^0` suffixes (same rules as revision parsing).
3529#[must_use]
3530pub fn parse_peel_suffix(spec: &str) -> (&str, Option<&str>) {
3531    if let Some(base) = spec.strip_suffix("^{}") {
3532        return (base, Some(""));
3533    }
3534    if let Some(start) = spec.rfind("^{") {
3535        if spec.ends_with('}') {
3536            let base = &spec[..start];
3537            let op = &spec[start + 2..spec.len() - 1];
3538            return (base, Some(op));
3539        }
3540    }
3541    // `^0` is shorthand for `^{commit}` — peel tags and verify commit.
3542    if let Some(base) = spec.strip_suffix("^0") {
3543        // Only match if the character before `^0` is not also a `^` (avoid
3544        // matching `^^0` as a peel instead of nav+nav).
3545        if !base.ends_with('^') {
3546            return (base, Some("commit"));
3547        }
3548    }
3549    (spec, None)
3550}
3551
3552fn parse_tag_target(data: &[u8]) -> Result<ObjectId> {
3553    let text = std::str::from_utf8(data)
3554        .map_err(|_| Error::CorruptObject("invalid tag object".to_owned()))?;
3555    let Some(line) = text.lines().find(|line| line.starts_with("object ")) else {
3556        return Err(Error::CorruptObject("tag missing object header".to_owned()));
3557    };
3558    let oid_text = line.trim_start_matches("object ").trim();
3559    oid_text.parse::<ObjectId>()
3560}
3561
3562/// Search commit messages reachable from `start` and return the first commit
3563/// whose message contains `pattern`.
3564/// Parse the leading `!` semantics of a `^{/<pattern>}` (or `:/<pattern>`)
3565/// commit-message search, matching git's `get_oid_oneline` in `object-name.c`.
3566///
3567/// Returns `(negate, effective_pattern)` where `effective_pattern` is the
3568/// regular expression to compile and `negate` flips the match sense:
3569///   - `!-<pat>` is a negated search (find a commit NOT matching `<pat>`).
3570///   - `!!<pat>` escapes to a literal `!<pat>` regex search.
3571///   - any other `!` prefix (e.g. `!Exp`, `!`, `!-` is fine but `!x` not) is
3572///     reserved and yields `None`, signalling the caller to fail.
3573fn parse_oneline_pattern(pattern: &str) -> Option<(bool, &str)> {
3574    let Some(after_bang) = pattern.strip_prefix('!') else {
3575        return Some((false, pattern));
3576    };
3577    if let Some(neg) = after_bang.strip_prefix('-') {
3578        return Some((true, neg));
3579    }
3580    if after_bang.starts_with('!') {
3581        // `!!<pat>` -> literal `!<pat>`; keep one leading `!` for the regex.
3582        return Some((false, after_bang));
3583    }
3584    // Reserved: `!` followed by anything other than `!` or `-`.
3585    None
3586}
3587
3588fn resolve_commit_message_search_from(
3589    repo: &Repository,
3590    start: ObjectId,
3591    pattern: &str,
3592) -> Result<ObjectId> {
3593    let Some((negate, effective_pattern)) = parse_oneline_pattern(pattern) else {
3594        return Err(Error::ObjectNotFound(format!(":/{pattern}")));
3595    };
3596    let regex = Regex::new(effective_pattern).ok();
3597    let mut visited = std::collections::HashSet::new();
3598    let mut queue = std::collections::VecDeque::new();
3599    queue.push_back(start);
3600    visited.insert(start);
3601
3602    while let Some(oid) = queue.pop_front() {
3603        let obj = match repo.read_replaced(&oid) {
3604            Ok(o) => o,
3605            Err(_) => continue,
3606        };
3607        if obj.kind != ObjectKind::Commit {
3608            continue;
3609        }
3610        let commit = match parse_commit(&obj.data) {
3611            Ok(c) => c,
3612            Err(_) => continue,
3613        };
3614
3615        let base_match = if let Some(re) = &regex {
3616            re.is_match(&commit.message)
3617        } else {
3618            commit.message.contains(effective_pattern)
3619        };
3620        let is_match = negate ^ base_match;
3621        if is_match {
3622            return Ok(oid);
3623        }
3624
3625        for parent in &commit.parents {
3626            if visited.insert(*parent) {
3627                queue.push_back(*parent);
3628            }
3629        }
3630    }
3631
3632    Err(Error::ObjectNotFound(format!(":/{pattern}")))
3633}
3634
3635fn find_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
3636    if !is_hex_prefix(prefix) || !(4..=40).contains(&prefix.len()) {
3637        return Ok(Vec::new());
3638    }
3639    let mut seen = HashSet::new();
3640    let mut matches = Vec::new();
3641    for objects_dir in object_storage_dirs_for_abbrev(repo)? {
3642        for hex in collect_loose_object_ids_in_dir(&objects_dir)? {
3643            if hex.starts_with(prefix) {
3644                let oid = hex.parse::<ObjectId>()?;
3645                if seen.insert(oid) {
3646                    matches.push(oid);
3647                }
3648            }
3649        }
3650        for oid in collect_pack_oids_with_prefix(&objects_dir, prefix)? {
3651            if seen.insert(oid) {
3652                matches.push(oid);
3653            }
3654        }
3655    }
3656    Ok(matches)
3657}
3658
3659fn collect_loose_object_ids(repo: &Repository) -> Result<Vec<String>> {
3660    collect_loose_object_ids_in_dir(repo.odb.objects_dir())
3661}
3662
3663fn collect_loose_object_ids_in_dir(objects_dir: &Path) -> Result<Vec<String>> {
3664    let mut ids = Vec::new();
3665    let read = match fs::read_dir(objects_dir) {
3666        Ok(read) => read,
3667        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
3668        Err(err) => return Err(Error::Io(err)),
3669    };
3670
3671    for dir_entry in read {
3672        let dir_entry = dir_entry?;
3673        let name = dir_entry.file_name();
3674        let Some(prefix) = name.to_str() else {
3675            continue;
3676        };
3677        if !is_two_hex(prefix) {
3678            continue;
3679        }
3680        if !dir_entry.file_type()?.is_dir() {
3681            continue;
3682        }
3683
3684        let files = fs::read_dir(dir_entry.path())?;
3685        for file_entry in files {
3686            let file_entry = file_entry?;
3687            if !file_entry.file_type()?.is_file() {
3688                continue;
3689            }
3690            let file_name = file_entry.file_name();
3691            let Some(suffix) = file_name.to_str() else {
3692                continue;
3693            };
3694            if suffix.len() == 38 && suffix.chars().all(|ch| ch.is_ascii_hexdigit()) {
3695                ids.push(format!("{prefix}{suffix}"));
3696            }
3697        }
3698    }
3699
3700    Ok(ids)
3701}
3702
3703fn is_two_hex(text: &str) -> bool {
3704    text.len() == 2 && text.chars().all(|ch| ch.is_ascii_hexdigit())
3705}
3706
3707fn is_hex_prefix(text: &str) -> bool {
3708    !text.is_empty() && text.chars().all(|ch| ch.is_ascii_hexdigit())
3709}
3710
3711fn path_is_within(path: &Path, container: &Path) -> bool {
3712    if path == container {
3713        return true;
3714    }
3715    path.starts_with(container)
3716}
3717
3718fn normalize_components(path: &Path) -> Vec<String> {
3719    path.components()
3720        .filter_map(|component| match component {
3721            Component::RootDir => Some(String::from("/")),
3722            Component::Normal(item) => Some(item.to_string_lossy().into_owned()),
3723            _ => None,
3724        })
3725        .collect()
3726}
3727
3728fn component_to_text(component: Component<'_>) -> Option<String> {
3729    match component {
3730        Component::Normal(item) => Some(os_to_string(item)),
3731        _ => None,
3732    }
3733}
3734
3735fn os_to_string(text: &OsStr) -> String {
3736    text.to_string_lossy().into_owned()
3737}
3738
3739/// Search commit messages from HEAD backwards for a commit whose message
3740/// contains `pattern`.  Returns the first matching commit OID.
3741fn resolve_commit_message_search(
3742    repo: &crate::repo::Repository,
3743    pattern: &str,
3744) -> Result<ObjectId> {
3745    // Handle negated pattern: /! means negate; /!! means literal /!
3746    let (negate, effective_pattern) = if pattern.starts_with('!') {
3747        if pattern.starts_with("!!") {
3748            (false, &pattern[1..]) // !! = literal !
3749        } else {
3750            (true, &pattern[1..]) // ! = negate
3751        }
3752    } else {
3753        (false, pattern)
3754    };
3755    let regex = Regex::new(effective_pattern).ok();
3756    use crate::state::resolve_head;
3757    let head =
3758        resolve_head(&repo.git_dir).map_err(|_| Error::ObjectNotFound(format!(":/{pattern}")))?;
3759    let start_oid = match head.oid() {
3760        Some(oid) => *oid,
3761        None => return Err(Error::ObjectNotFound(format!(":/{pattern}"))),
3762    };
3763
3764    let mut visited = std::collections::HashSet::new();
3765    let mut queue = std::collections::VecDeque::new();
3766    queue.push_back(start_oid);
3767    visited.insert(start_oid);
3768    if let Ok(refs) = crate::refs::list_refs(&repo.git_dir, "refs/") {
3769        for (_name, oid) in refs {
3770            if visited.insert(oid) {
3771                queue.push_back(oid);
3772            }
3773        }
3774    }
3775
3776    while let Some(oid) = queue.pop_front() {
3777        let obj = match repo.read_replaced(&oid) {
3778            Ok(o) => o,
3779            Err(_) => continue,
3780        };
3781        // Skip non-commit objects
3782        if obj.kind != ObjectKind::Commit {
3783            continue;
3784        }
3785        let commit = match parse_commit(&obj.data) {
3786            Ok(c) => c,
3787            Err(_) => continue,
3788        };
3789
3790        // Check if message matches pattern (regex, with literal fallback)
3791        let base_match = if let Some(re) = &regex {
3792            re.is_match(&commit.message)
3793        } else {
3794            commit.message.contains(effective_pattern)
3795        };
3796        let is_match = if negate { !base_match } else { base_match };
3797        if is_match {
3798            return Ok(oid);
3799        }
3800
3801        // Enqueue parents
3802        for parent in &commit.parents {
3803            if visited.insert(*parent) {
3804                queue.push_back(*parent);
3805            }
3806        }
3807    }
3808
3809    Err(Error::ObjectNotFound(format!(":/{pattern}")))
3810}
3811
3812/// All object IDs (loose and packed) whose hex form starts with `prefix`.
3813pub fn list_all_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
3814    find_abbrev_matches(repo, prefix)
3815}
3816
3817/// Public: find all object IDs whose hex prefix matches the given string.
3818pub fn list_loose_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
3819    list_all_abbrev_matches(repo, prefix)
3820}
3821
3822#[cfg(test)]
3823mod superproject_path_tests {
3824    use super::superproject_work_tree_from_nested_git_modules;
3825    use std::path::PathBuf;
3826
3827    #[test]
3828    fn nested_modules_yields_superproject_work_tree() {
3829        let git_dir = PathBuf::from("/tmp/super/.git/modules/dir/modules/sub");
3830        assert_eq!(
3831            superproject_work_tree_from_nested_git_modules(&git_dir),
3832            Some(PathBuf::from("/tmp/super"))
3833        );
3834    }
3835
3836    #[test]
3837    fn non_nested_returns_none() {
3838        let git_dir = PathBuf::from("/tmp/repo/.git");
3839        assert!(superproject_work_tree_from_nested_git_modules(&git_dir).is_none());
3840    }
3841}