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