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