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 crate::environment::RepositoryOptions;
9use crate::git_path::CanonicalizeExt;
10
11use std::borrow::Cow;
12use std::ffi::OsStr;
13use std::fs;
14use std::path::{Component, Path, PathBuf};
15
16use regex::Regex;
17
18use std::cmp::Ordering;
19use std::collections::{BinaryHeap, HashMap, HashSet};
20
21use std::ops::ControlFlow;
22use std::sync::Arc;
23
24use crate::check_ref_format::{check_refname_format, RefNameOptions};
25use crate::config::ConfigSet;
26use crate::error::{Error, NotARepositoryReason, Result};
27use crate::objects::{parse_commit, parse_tag, parse_tree, CommitRef, ObjectId, ObjectKind};
28use crate::reflog::ReflogEntry;
29use crate::refs;
30use crate::refs::store::RawRef;
31use crate::repo::Repository;
32use crate::rev_parse_error::{AmbiguousObjectHint, RevParseError};
33
34/// Return `Some(repo)` when a repository can be discovered at `start`.
35///
36/// # Parameters
37///
38/// - `start` - starting path for discovery; when `None`, uses current directory.
39///
40/// # Errors
41///
42/// Returns errors other than "not a repository" (for example I/O and path
43/// canonicalization failures). [`Error::NotARepository`] is returned too when discovery
44/// stopped at a malformed gitfile ([`NotARepositoryReason::GitfileNotRegularFile`] or
45/// [`NotARepositoryReason::GitfileMissingGitdir`]) or when `start` itself holds a `.git`
46/// file or symlink.
47pub fn discover_optional(start: Option<&Path>) -> Result<Option<Repository>> {
48    let err = match Repository::discover(start, &RepositoryOptions::empty()) {
49        Ok(repo) => return Ok(Some(repo)),
50        Err(err) => err,
51    };
52    // A missing repository is an expected outcome, but a malformed `.git` entry found during
53    // discovery, or any `.git` file or symlink at `start`, is reported.
54    let Error::NotARepository(reason) = &err else {
55        return Err(err);
56    };
57    let malformed_gitfile = matches!(
58        reason,
59        NotARepositoryReason::GitfileNotRegularFile { .. }
60            | NotARepositoryReason::GitfileMissingGitdir { .. }
61    );
62    let dot_git_at_start = start.is_some_and(|start| {
63        let dot_git = start.join(".git");
64        dot_git.is_file() || dot_git.is_symlink()
65    });
66    if malformed_gitfile || dot_git_at_start {
67        return Err(err);
68    }
69    Ok(None)
70}
71
72/// Compute whether `cwd` is inside the repository's work tree.
73#[must_use]
74pub fn is_inside_work_tree(repo: &Repository, cwd: &Path) -> bool {
75    let Some(work_tree) = &repo.work_tree() else {
76        return false;
77    };
78    path_is_within(cwd, work_tree)
79}
80
81/// Compute whether `cwd` is inside the repository's git-dir.
82#[must_use]
83pub fn is_inside_git_dir(repo: &Repository, cwd: &Path) -> bool {
84    path_is_within(cwd, repo.git_dir())
85}
86
87/// Compute the `--show-prefix` output.
88///
89/// Returns an empty string when `cwd` is at repository root or outside the work
90/// tree. Returned prefixes always use `/` separators and end with `/`.
91#[must_use]
92pub fn show_prefix(repo: &Repository, cwd: &Path) -> String {
93    let Some(work_tree) = repo.work_tree() else {
94        return String::new();
95    };
96    if !path_is_within(cwd, work_tree) {
97        return String::new();
98    }
99    if cwd == work_tree {
100        return String::new();
101    }
102    let Ok(rel) = cwd.strip_prefix(work_tree) else {
103        return String::new();
104    };
105    let mut out = rel
106        .components()
107        .filter_map(component_to_text)
108        .collect::<Vec<_>>()
109        .join("/");
110    if !out.is_empty() {
111        out.push('/');
112    }
113    out
114}
115
116/// Superproject work tree when `git_dir` lives under `.../<wt>/.git/modules/...` (nested submodule).
117///
118/// Used when the submodule's recorded path in the superproject index does not match the on-disk
119/// layout (e.g. `dir/sub` recorded but git dir is `.../modules/dir/modules/sub`), so
120/// `ls-files`-based superproject detection cannot find a gitlink.
121#[must_use]
122pub fn superproject_work_tree_from_nested_git_modules(git_dir: &Path) -> Option<PathBuf> {
123    let mut p = git_dir.to_path_buf();
124    while let Some(parent) = p.parent() {
125        if p.file_name().is_some_and(|n| n == "modules")
126            && parent.file_name().is_some_and(|n| n == ".git")
127        {
128            return parent.parent().map(PathBuf::from);
129        }
130        if parent == p {
131            break;
132        }
133        p = parent.to_path_buf();
134    }
135    None
136}
137
138/// Resolve a symbolic ref name to its full form.
139///
140/// For `HEAD`, returns the symbolic target (e.g., `refs/heads/main`).
141/// For branch names, returns `refs/heads/<name>`.
142/// For tag names, returns `refs/tags/<name>`.
143/// Returns `None` when the name cannot be resolved symbolically.
144#[must_use]
145pub fn symbolic_full_name(repo: &Repository, spec: &str) -> Option<String> {
146    // @{upstream} / @{push}: must error from rev-parse when invalid; do not fall through to DWIM.
147    if upstream_suffix_info(spec).is_some() {
148        return resolve_upstream_symbolic_name(repo, spec).ok();
149    }
150
151    if let Ok(Some(branch)) = expand_at_minus_to_branch_name(repo, spec) {
152        let ref_name = format!("refs/heads/{branch}");
153        if repo.resolve_ref_oid(&ref_name).is_ok() {
154            return Some(ref_name);
155        }
156        return None;
157    }
158
159    if spec == "HEAD" {
160        if let Ok(Some(target)) = refs::read_symbolic_ref_for_repository(repo, "HEAD") {
161            return Some(target);
162        }
163        return None;
164    }
165    // If it's already a full ref path
166    if spec.starts_with("refs/") {
167        if repo.resolve_ref_oid(spec).is_ok() {
168            return Some(spec.to_owned());
169        }
170        return None;
171    }
172    // DWIM: try refs/heads, refs/tags, refs/remotes
173    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
174        let candidate = format!("{prefix}{spec}");
175        if repo.resolve_ref_oid(&candidate).is_ok() {
176            return Some(candidate);
177        }
178    }
179    // Remote name alone: `one` → `refs/remotes/one/HEAD` when `remote.one.url` exists (matches Git).
180    if let Some(full) = remote_tracking_head_symbolic_target(repo, spec) {
181        return Some(full);
182    }
183    None
184}
185
186/// When `name` is a configured remote, return the full ref `refs/remotes/<name>/HEAD` resolves to.
187fn remote_tracking_head_symbolic_target(repo: &Repository, name: &str) -> Option<String> {
188    if name.contains('/')
189        || matches!(
190            name,
191            "HEAD" | "FETCH_HEAD" | "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD"
192        )
193    {
194        return None;
195    }
196    let config = repo.ensure_config_arc_fast().ok()?;
197    let url_key = format!("remote.{name}.url");
198    config.get(&url_key)?;
199    let head_ref = format!("refs/remotes/{name}/HEAD");
200    let target = refs::read_symbolic_ref_for_repository(repo, &head_ref).ok()??;
201    Some(target)
202}
203
204/// Expand an `@{-N}` token to the corresponding previous branch name.
205///
206/// Returns:
207/// - `Ok(Some(branch_name))` when `spec` is an `@{-N}` token and resolves
208///   to a branch name.
209/// - `Ok(None)` when `spec` is not an `@{-N}` token.
210/// - `Err(...)` when `spec` matches `@{-N}` syntax but cannot be resolved.
211pub fn expand_at_minus_to_branch_name(repo: &Repository, spec: &str) -> Result<Option<String>> {
212    if !spec.starts_with("@{-") || !spec.ends_with('}') {
213        return Ok(None);
214    }
215    let inner = &spec[3..spec.len() - 1];
216    let n: usize = inner
217        .parse()
218        .map_err(|_| Error::InvalidRef(format!("invalid N in @{{-N}} for '{spec}'")))?;
219    if n < 1 {
220        return Ok(None);
221    }
222    resolve_at_minus_to_branch(repo, n).map(Some)
223}
224
225/// Resolve `@{-N}` to the commit OID it points to.
226pub fn resolve_at_minus_to_oid(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
227    try_resolve_at_minus(repo, spec)
228}
229
230/// Abbreviate a full ref name to its shortest unambiguous form.
231///
232/// For example, `refs/heads/main` becomes `main`.
233#[must_use]
234pub fn abbreviate_ref_name(full_name: &str) -> String {
235    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
236        if let Some(short) = full_name.strip_prefix(prefix) {
237            return short.to_owned();
238        }
239    }
240    if let Some(short) = full_name.strip_prefix("refs/") {
241        return short.to_owned();
242    }
243    full_name.to_owned()
244}
245
246/// Returns `(base_without_suffix, is_push)` when `spec` ends with `@{upstream}` / `@{u}` / `@{push}`
247/// (case-insensitive for upstream forms). `is_push` is true only for `@{push}`.
248#[must_use]
249pub fn upstream_suffix_info(spec: &str) -> Option<(&str, bool)> {
250    let lower = spec.to_ascii_lowercase();
251    if lower.ends_with("@{push}") {
252        let base = &spec[..spec.len() - 7];
253        return Some((base, true));
254    }
255    if lower.ends_with("@{upstream}") {
256        let base = &spec[..spec.len() - 11];
257        return Some((base, false));
258    }
259    if lower.ends_with("@{u}") {
260        let base = &spec[..spec.len() - 4];
261        return Some((base, false));
262    }
263    None
264}
265
266/// Resolve `@{upstream}` / `@{u}` / `@{push}` to the symbolic full ref name (for `rev-parse --symbolic-full-name`).
267pub fn resolve_upstream_symbolic_name(repo: &Repository, spec: &str) -> Result<String> {
268    let Some((base, is_push)) = upstream_suffix_info(spec) else {
269        return Err(Error::InvalidRef(format!("not an upstream spec: {spec}")));
270    };
271    resolve_upstream_full_ref_name(repo, base, is_push)
272}
273
274fn resolve_upstream_full_ref_name(repo: &Repository, base: &str, is_push: bool) -> Result<String> {
275    if is_push {
276        return resolve_push_ref_name(repo, base);
277    }
278    let (branch_key, display_branch) = resolve_upstream_branch_context(repo, base)?;
279    let config_path = repo.common_git_dir().join("config");
280    let _ = repo.config()?;
281    let config_content = repo.caches().local_config_text(&config_path)?;
282    let Some((remote, merge)) = parse_branch_tracking(&config_content, &branch_key) else {
283        return Err(RevParseError::NoUpstream {
284            branch: display_branch,
285        }
286        .into());
287    };
288    if remote == "." {
289        let m = merge.trim();
290        if m.starts_with("refs/") {
291            return Ok(m.to_owned());
292        }
293        return Ok(format!("refs/heads/{m}"));
294    }
295    let merge_branch = merge
296        .strip_prefix("refs/heads/")
297        .ok_or_else(|| Error::InvalidRef(format!("invalid merge ref: {merge}")))?;
298    let tracking = format!("refs/remotes/{remote}/{merge_branch}");
299    if repo.resolve_ref_oid(&tracking).is_err() {
300        return Err(RevParseError::UpstreamNotTracked {
301            merge: merge.to_owned(),
302        }
303        .into());
304    }
305    Ok(tracking)
306}
307
308/// Resolve the remote-tracking ref used as `@{push}` for `branch_short` (`refs/heads/...` name).
309///
310/// Honors `remote.pushRemote`, `branch.<name>.pushRemote`, `push.default`, and per-remote
311/// `push` refspecs (exact `refs/heads/<branch>:refs/heads/<dest>` mappings).
312pub fn resolve_push_full_ref_for_branch(repo: &Repository, branch_short: &str) -> Result<String> {
313    let config_path = repo.common_git_dir().join("config");
314    let _ = repo.config()?;
315    let config_content = repo.caches().local_config_text(&config_path)?;
316
317    let upstream_tracking =
318        parse_branch_tracking(&config_content, branch_short).and_then(|(remote, merge)| {
319            if remote == "." {
320                return None;
321            }
322            let mb = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
323            let tr = format!("refs/remotes/{remote}/{mb}");
324            if repo.resolve_ref_oid(&tr).is_ok() {
325                Some(tr)
326            } else {
327                None
328            }
329        });
330
331    let push_remote = parse_config_value(&config_content, "remote", "pushRemote")
332        .or_else(|| parse_config_value(&config_content, "remote", "pushDefault"))
333        .or_else(|| {
334            let section = format!("[branch \"{}\"]", branch_short);
335            let mut in_section = false;
336            for line in config_content.lines() {
337                let trimmed = line.trim();
338                if trimmed.starts_with('[') {
339                    in_section = trimmed == section;
340                    continue;
341                }
342                if in_section {
343                    if let Some(v) = trimmed
344                        .strip_prefix("pushremote = ")
345                        .or_else(|| trimmed.strip_prefix("pushRemote = "))
346                    {
347                        return Some(v.trim().to_owned());
348                    }
349                }
350            }
351            None
352        })
353        .or_else(|| {
354            parse_branch_tracking(&config_content, branch_short)
355                .map(|(r, _)| r)
356                .filter(|r| r != ".")
357        });
358
359    let Some(push_remote_name) = push_remote else {
360        return upstream_tracking.ok_or_else(|| RevParseError::NoPushRemote.into());
361    };
362
363    // When the push remote has any configured `push` refspecs, they take priority
364    // over `push.default` entirely (matching git's branch_get_push_1: the
365    // `if (remote->push.nr)` block returns before the push.default switch). This
366    // means an explicit push refspec overrides even `push.default = nothing`.
367    if remote_has_push_refspec(&config_content, &push_remote_name) {
368        return match push_refspec_mapped_tracking(&config_content, &push_remote_name, branch_short)
369        {
370            Some(mapped) => Ok(mapped),
371            None => Err(RevParseError::PushRefspecMissing {
372                remote: push_remote_name,
373                branch: branch_short.to_owned(),
374            }
375            .into()),
376        };
377    }
378
379    let push_default = parse_config_value(&config_content, "push", "default");
380    let push_default = push_default.as_deref().unwrap_or("simple");
381
382    if push_default == "nothing" {
383        return Err(RevParseError::PushDefaultNothing.into());
384    }
385
386    let current_tracking = format!("refs/remotes/{push_remote_name}/{branch_short}");
387
388    match push_default {
389        "upstream" => upstream_tracking.ok_or_else(|| {
390            RevParseError::NoUpstreamForPush {
391                branch: branch_short.to_owned(),
392            }
393            .into()
394        }),
395        "simple" => {
396            if let Some(ref up) = upstream_tracking {
397                if up == &current_tracking && repo.resolve_ref_oid(&current_tracking).is_ok() {
398                    return Ok(current_tracking);
399                }
400            }
401            Err(RevParseError::PushDefaultSimpleMismatch.into())
402        }
403        _ => {
404            if repo.resolve_ref_oid(&current_tracking).is_ok() {
405                Ok(current_tracking)
406            } else if let Some(up) = upstream_tracking {
407                Ok(up)
408            } else {
409                Err(RevParseError::NoPushTrackingRef {
410                    branch: branch_short.to_owned(),
411                }
412                .into())
413            }
414        }
415    }
416}
417
418/// Collect the values of all `push = <refspec>` entries in `[remote "<name>"]`.
419fn remote_push_refspecs(config_content: &str, remote_name: &str) -> Vec<String> {
420    let section = format!("[remote \"{remote_name}\"]");
421    let mut in_section = false;
422    let mut out = Vec::new();
423    for line in config_content.lines() {
424        let trimmed = line.trim();
425        if trimmed.starts_with('[') {
426            in_section = trimmed == section;
427            continue;
428        }
429        if !in_section {
430            continue;
431        }
432        if let Some(val) = trimmed
433            .strip_prefix("push = ")
434            .or_else(|| trimmed.strip_prefix("push="))
435        {
436            if let Some(spec) = val.split_whitespace().next() {
437                out.push(spec.trim().to_owned());
438            }
439        }
440    }
441    out
442}
443
444/// True when the remote has at least one `push` refspec configured.
445fn remote_has_push_refspec(config_content: &str, remote_name: &str) -> bool {
446    !remote_push_refspecs(config_content, remote_name).is_empty()
447}
448
449/// Match `refname` against a refspec `pattern` (`prefix*suffix`), substituting the
450/// matched glob portion into `replacement` (also `prefix*suffix`). Mirrors git's
451/// `match_refname_with_pattern`. For non-pattern refspecs, both sides are exact.
452fn match_refname_with_pattern(pattern: &str, refname: &str, replacement: &str) -> Option<String> {
453    match (pattern.find('*'), replacement.find('*')) {
454        (Some(pstar), Some(vstar)) => {
455            let kprefix = &pattern[..pstar];
456            let ksuffix = &pattern[pstar + 1..];
457            if !refname.starts_with(kprefix) || !refname.ends_with(ksuffix) {
458                return None;
459            }
460            if refname.len() < kprefix.len() + ksuffix.len() {
461                return None;
462            }
463            let middle = &refname[kprefix.len()..refname.len() - ksuffix.len()];
464            Some(format!(
465                "{}{}{}",
466                &replacement[..vstar],
467                middle,
468                &replacement[vstar + 1..]
469            ))
470        }
471        (None, None) => {
472            if pattern == refname {
473                Some(replacement.to_owned())
474            } else {
475                None
476            }
477        }
478        // A pattern on one side but not the other is invalid in git; treat as no match.
479        _ => None,
480    }
481}
482
483/// Apply the remote's configured `push` refspecs to `refs/heads/<branch_short>`, then
484/// map the resulting push destination to a local remote-tracking ref via the remote's
485/// `fetch` refspecs. Mirrors git's `apply_refspecs(&remote->push, ...)` followed by
486/// `tracking_for_push_dest` (which applies `&remote->fetch`).
487fn push_refspec_mapped_tracking(
488    config_content: &str,
489    remote_name: &str,
490    branch_short: &str,
491) -> Option<String> {
492    let src = format!("refs/heads/{branch_short}");
493
494    // First, map the branch through a push refspec to the push destination on the remote.
495    let mut dst = None;
496    for spec in remote_push_refspecs(config_content, remote_name) {
497        let spec = spec.strip_prefix('+').unwrap_or(&spec);
498        let Some((left, right)) = spec.split_once(':') else {
499            continue;
500        };
501        if let Some(mapped) = match_refname_with_pattern(left.trim(), &src, right.trim()) {
502            dst = Some(mapped);
503            break;
504        }
505    }
506    let dst = dst?;
507
508    // Then map the push destination to a local tracking ref via the fetch refspecs.
509    map_dest_to_tracking(config_content, remote_name, &dst)
510}
511
512/// Map a push destination ref (e.g. `refs/heads/magic/topic`) on `remote_name` to the
513/// local remote-tracking ref using that remote's `fetch` refspecs. Falls back to the
514/// conventional `refs/remotes/<remote>/<rest>` mapping when no fetch refspec matches.
515fn map_dest_to_tracking(config_content: &str, remote_name: &str, dst: &str) -> Option<String> {
516    let section = format!("[remote \"{remote_name}\"]");
517    let mut in_section = false;
518    for line in config_content.lines() {
519        let trimmed = line.trim();
520        if trimmed.starts_with('[') {
521            in_section = trimmed == section;
522            continue;
523        }
524        if !in_section {
525            continue;
526        }
527        let Some(val) = trimmed
528            .strip_prefix("fetch = ")
529            .or_else(|| trimmed.strip_prefix("fetch="))
530        else {
531            continue;
532        };
533        let Some(spec) = val.split_whitespace().next() else {
534            continue;
535        };
536        let spec = spec.trim().strip_prefix('+').unwrap_or(spec.trim());
537        let Some((left, right)) = spec.split_once(':') else {
538            continue;
539        };
540        if let Some(mapped) = match_refname_with_pattern(left.trim(), dst, right.trim()) {
541            return Some(mapped);
542        }
543    }
544    // Conventional fallback: refs/heads/<x> -> refs/remotes/<remote>/<x>.
545    dst.strip_prefix("refs/heads/")
546        .map(|rest| format!("refs/remotes/{remote_name}/{rest}"))
547}
548
549fn resolve_push_ref_name(repo: &Repository, base: &str) -> Result<String> {
550    let (branch_key, _display) = resolve_upstream_branch_context(repo, base)?;
551    resolve_push_full_ref_for_branch(repo, &branch_key)
552}
553
554/// Returns `(config_branch_key, display_name_for_errors)` for upstream resolution.
555fn resolve_upstream_branch_context(repo: &Repository, base: &str) -> Result<(String, String)> {
556    let base = if base == "HEAD" {
557        Cow::Borrowed("")
558    } else if base.starts_with("@{-") && base.ends_with('}') {
559        if let Ok(Some(b)) = expand_at_minus_to_branch_name(repo, base) {
560            Cow::Owned(b)
561        } else {
562            Cow::Borrowed(base)
563        }
564    } else {
565        Cow::Borrowed(base)
566    };
567    let base = base.as_ref();
568    let base = if base == "@" { "" } else { base };
569
570    if base.is_empty() {
571        let Some(head) = refs::read_symbolic_ref_for_repository(repo, "HEAD")? else {
572            return Err(RevParseError::HeadNotBranch.into());
573        };
574        let Some(short) = head.strip_prefix("refs/heads/") else {
575            return Err(RevParseError::HeadNotBranch.into());
576        };
577        return Ok((short.to_owned(), short.to_owned()));
578    }
579    let head_branch = refs::read_symbolic_ref_for_repository(repo, "HEAD")?.and_then(|h| {
580        h.strip_prefix("refs/heads/")
581            .map(std::borrow::ToOwned::to_owned)
582    });
583    if head_branch.as_deref() == Some(base) {
584        return Ok((base.to_owned(), base.to_owned()));
585    }
586    let refname = format!("refs/heads/{base}");
587    if repo.resolve_ref_oid(&refname).is_err() {
588        return Err(RevParseError::NoSuchBranch {
589            branch: base.to_owned(),
590        }
591        .into());
592    }
593    Ok((base.to_owned(), base.to_owned()))
594}
595
596fn parse_config_value(config: &str, section: &str, key: &str) -> Option<String> {
597    let section_header = format!("[{}]", section);
598    let key_lower = key.to_ascii_lowercase();
599    let mut in_section = false;
600    for line in config.lines() {
601        let trimmed = line.trim();
602        if trimmed.starts_with('[') {
603            in_section = trimmed.eq_ignore_ascii_case(&section_header);
604            continue;
605        }
606        if in_section {
607            let lower = trimmed.to_ascii_lowercase();
608            if lower.starts_with(&key_lower) {
609                let rest = lower[key_lower.len()..].trim_start().to_string();
610                if rest.starts_with('=') {
611                    if let Some(eq_pos) = trimmed.find('=') {
612                        return Some(trimmed[eq_pos + 1..].trim().to_owned());
613                    }
614                }
615            }
616        }
617    }
618    None
619}
620
621/// Parse branch tracking configuration from git config content.
622fn parse_branch_tracking(config: &str, branch: &str) -> Option<(String, String)> {
623    let mut remote = None;
624    let mut merge = None;
625    let mut in_section = false;
626    let target_section = format!("[branch \"{}\"]", branch);
627
628    for line in config.lines() {
629        let trimmed = line.trim();
630        if trimmed.starts_with('[') {
631            in_section = trimmed == target_section
632                || trimmed.starts_with(&format!("[branch \"{}\"", branch));
633            continue;
634        }
635        if !in_section {
636            continue;
637        }
638        if let Some(value) = trimmed.strip_prefix("remote = ") {
639            remote = Some(value.trim().to_owned());
640        } else if let Some(value) = trimmed.strip_prefix("merge = ") {
641            merge = Some(value.trim().to_owned());
642        }
643        // Also handle with tabs
644        if let Some(value) = trimmed.strip_prefix("remote=") {
645            remote = Some(value.trim().to_owned());
646        } else if let Some(value) = trimmed.strip_prefix("merge=") {
647            merge = Some(value.trim().to_owned());
648        }
649    }
650
651    match (remote, merge) {
652        (Some(r), Some(m)) => Some((r, m)),
653        _ => None,
654    }
655}
656
657/// Resolve a revision string to an object ID.
658///
659/// Supports:
660/// - full 40-hex object IDs (must exist in loose store),
661/// - abbreviated object IDs (length 4-39, must resolve uniquely),
662/// - direct refs (`HEAD`, `refs/...`),
663/// - DWIM branch/tag/remote names (`name` -> `refs/heads/name`, etc.),
664/// - peeling suffixes: `^{}`, `^{object}`, `^{commit}`.
665///
666/// # Errors
667///
668/// Returns [`Error::ObjectNotFound`] or [`Error::InvalidRef`] when resolution
669/// fails.
670/// Split `spec` at a `..` range operator, avoiding the three-dot symmetric-diff form.
671///
672/// Returns `(left, right)` where either side may be empty (`..HEAD`, `HEAD..`, `..`).
673#[must_use]
674/// Load commit parent overrides from `.git/info/grafts` (same format as Git).
675///
676/// Used for `^N` / `^@` / `^!` / `^-` resolution and for `rev-list` traversal.
677pub fn load_graft_parents(git_dir: &Path) -> HashMap<ObjectId, Vec<ObjectId>> {
678    let graft_path = crate::repo::common_git_dir_for_config(git_dir).join("info/grafts");
679    let mut grafts = HashMap::new();
680    let Ok(contents) = fs::read_to_string(&graft_path) else {
681        return grafts;
682    };
683    for raw_line in contents.lines() {
684        let line = raw_line.trim();
685        if line.is_empty() || line.starts_with('#') {
686            continue;
687        }
688        let mut fields = line.split_whitespace();
689        let Some(commit_hex) = fields.next() else {
690            continue;
691        };
692        let Ok(commit_oid) = commit_hex.parse::<ObjectId>() else {
693            continue;
694        };
695        let mut parents = Vec::new();
696        let mut valid = true;
697        for parent_hex in fields {
698            match parent_hex.parse::<ObjectId>() {
699                Ok(parent_oid) => parents.push(parent_oid),
700                Err(_) => {
701                    valid = false;
702                    break;
703                }
704            }
705        }
706        if valid {
707            grafts.insert(commit_oid, parents);
708        }
709    }
710    grafts
711}
712
713// Short range endpoints benefit from seeding the following walk's metadata cache. Bound
714// that extra retention so resolving a distant ancestor alone does not retain its history.
715const NAV_LINKAGE_CACHE_LIMIT: usize = 1024;
716
717/// Cached graft state for a sequence of `^` / `~` navigation steps.
718///
719/// Parent links come from the object database with header-only parsing so `HEAD~N`
720/// does not parse the full commit-graph file (Git resolves short parent chains lazily).
721struct NavParentCache<'repo> {
722    repo: &'repo Repository,
723    grafts: HashMap<ObjectId, Vec<ObjectId>>,
724    graph: Option<crate::commit_graph_file::CommitGraphChain>,
725    share_linkage: std::cell::OnceCell<bool>,
726}
727
728enum NavigationMetadata {
729    Discard,
730    RetainForWalk,
731}
732
733impl<'repo> NavParentCache<'repo> {
734    fn new(repo: &'repo Repository, metadata: NavigationMetadata) -> Self {
735        let grafts = load_graft_parents(repo.git_dir());
736        let graph = repo.odb().commit_graph_chain().map(|arc| (*arc).clone());
737        Self {
738            repo,
739            grafts,
740            graph,
741            share_linkage: match metadata {
742                NavigationMetadata::Discard => std::cell::OnceCell::from(false),
743                NavigationMetadata::RetainForWalk => std::cell::OnceCell::new(),
744            },
745        }
746    }
747
748    fn parents_of(&self, commit_oid: ObjectId) -> Result<Vec<ObjectId>> {
749        if let Some(grafted) = self.grafts.get(&commit_oid) {
750            return Ok(grafted.clone());
751        }
752        if let Some(graph) = &self.graph {
753            if let Some((parents, _)) = graph.graph_commit(&commit_oid) {
754                return Ok(parents);
755            }
756        }
757        // Reachability uses effective parents, whereas navigation reads the object's own
758        // parents. Share metadata only when shallow/promisor boundaries and rewrites cannot
759        // make those differ. This also avoids inflating a range endpoint's chain twice.
760        let share_linkage = *self.share_linkage.get_or_init(|| {
761            self.grafts.is_empty()
762                && crate::shallow::load_shallow_boundaries(self.repo.git_dir()).is_empty()
763                && !crate::promisor::repo_treats_promisor_packs(
764                    self.repo.git_dir(),
765                    &self.repo.config().unwrap_or_default(),
766                )
767        }) && self.repo.object_id_after_replace(&commit_oid) == commit_oid;
768        if share_linkage {
769            let cache = self
770                .repo
771                .caches()
772                .commit_linkage()
773                .lock()
774                .unwrap_or_else(|e| e.into_inner());
775            if let Some(parents) = cache.parents.get(&commit_oid) {
776                return Ok(parents.clone());
777            }
778        }
779        let object = self.repo.odb().read(&commit_oid)?;
780        if object.kind != ObjectKind::Commit {
781            return Err(Error::InvalidRef(format!(
782                "invalid ref: {commit_oid} is not a commit"
783            )));
784        }
785        let commit = crate::objects::CommitRef::parse(&object.data)?;
786        let parents = commit.parents().to_vec();
787        if share_linkage {
788            let mut cache = self
789                .repo
790                .caches()
791                .commit_linkage()
792                .lock()
793                .unwrap_or_else(|e| e.into_inner());
794            if cache.parents.len() < NAV_LINKAGE_CACHE_LIMIT {
795                cache.times.insert(commit_oid, commit.committer_timestamp());
796                cache.parents.insert(commit_oid, parents.clone());
797            }
798        }
799        Ok(parents)
800    }
801}
802
803/// Parent OIDs of `commit_oid` for revision navigation, honoring grafts.
804pub fn commit_parents_for_navigation(
805    repo: &Repository,
806    commit_oid: ObjectId,
807) -> Result<Vec<ObjectId>> {
808    NavParentCache::new(repo, NavigationMetadata::Discard).parents_of(commit_oid)
809}
810
811#[derive(Debug, Clone, Copy)]
812enum ParentShorthandKind {
813    /// `rev^@` — all parents.
814    At,
815    /// `rev^!` — include `rev`, exclude all parents (merge-safe).
816    Bang,
817    /// `rev^-` / `rev^-N` — include `rev`, exclude parent N (1-based), include other parents.
818    Minus { exclude_parent: usize },
819}
820
821/// Returns true when `spec` ends with Git parent shorthands `^@`, `^!`, or `^-` / `^-N`.
822#[must_use]
823pub fn spec_has_parent_shorthand_suffix(spec: &str) -> bool {
824    find_parent_shorthand(spec).is_some()
825}
826
827fn find_parent_shorthand(spec: &str) -> Option<(usize, ParentShorthandKind)> {
828    let mut best: Option<(usize, ParentShorthandKind, u8)> = None;
829    for (idx, _) in spec.match_indices('^') {
830        let Some(tail) = spec.get(idx + 1..) else {
831            continue;
832        };
833        if tail.starts_with('@') && idx + 2 == spec.len() {
834            best = Some((idx, ParentShorthandKind::At, 0));
835            break;
836        }
837        if tail.starts_with('!') && idx + 2 == spec.len() {
838            let cand = (idx, ParentShorthandKind::Bang, 1);
839            best = Some(match best {
840                Some(b) if b.2 < 1 => b,
841                _ => cand,
842            });
843            continue;
844        }
845        if let Some(after) = tail.strip_prefix('-') {
846            let (exclude_parent, valid) = if after.is_empty() {
847                (1usize, true)
848            } else if after.bytes().all(|b| b.is_ascii_digit()) && !after.is_empty() {
849                let n: usize = after.parse().unwrap_or(0);
850                (n, n >= 1)
851            } else {
852                (0, false)
853            };
854            if !valid {
855                continue;
856            }
857            let cand = (idx, ParentShorthandKind::Minus { exclude_parent }, 2);
858            best = Some(match best {
859                Some(b) if b.2 < 2 => b,
860                _ => cand,
861            });
862        }
863    }
864    best.map(|(i, k, _)| (i, k))
865}
866
867/// Expand Git parent shorthands (`^@`, `^!`, `^-`, `^-N`) to the strings `git rev-parse` would print.
868///
869/// Returns [`None`] when `spec` does not use these suffixes at the end (or the suffix is invalid).
870///
871/// # Errors
872///
873/// Returns resolution errors when the base committish cannot be resolved or is not a commit.
874pub fn expand_parent_shorthand_rev_parse_lines(
875    repo: &Repository,
876    spec: &str,
877    symbolic: bool,
878    short_len: Option<usize>,
879) -> Result<Option<Vec<String>>> {
880    let Some((mark_idx, kind)) = find_parent_shorthand(spec) else {
881        return Ok(None);
882    };
883    let base_spec = &spec[..mark_idx];
884    let base_for_resolve = if base_spec.is_empty() {
885        "HEAD"
886    } else {
887        base_spec
888    };
889    // Git `--symbolic` prints parent specs using the same spelling as the user would type
890    // (e.g. `final^1^1`), not full ref names (`refs/heads/...`).
891    let symbolic_base = if base_spec.is_empty() {
892        "HEAD"
893    } else {
894        base_spec
895    };
896    let tip_oid = resolve_revision_for_range_end(repo, base_for_resolve)?;
897    let commit_oid = peel_to_commit_for_merge_base(repo, tip_oid)?;
898    let parents = commit_parents_for_navigation(repo, commit_oid)?;
899
900    let mut out = Vec::new();
901    match kind {
902        ParentShorthandKind::At => {
903            if parents.is_empty() {
904                return Ok(Some(out));
905            }
906            for (i, p) in parents.iter().enumerate() {
907                let parent_n = i + 1;
908                if symbolic {
909                    out.push(format!("{symbolic_base}^{parent_n}"));
910                } else if let Some(len) = short_len {
911                    out.push(abbreviate_object_id(repo, *p, len)?);
912                } else {
913                    out.push(p.to_string());
914                }
915            }
916        }
917        ParentShorthandKind::Bang => {
918            if parents.is_empty() {
919                if symbolic {
920                    out.push(symbolic_base.to_string());
921                } else if let Some(len) = short_len {
922                    out.push(abbreviate_object_id(repo, commit_oid, len)?);
923                } else {
924                    out.push(commit_oid.to_string());
925                }
926                return Ok(Some(out));
927            }
928            if symbolic {
929                out.push(symbolic_base.to_string());
930                for (i, _) in parents.iter().enumerate() {
931                    let parent_n = i + 1;
932                    out.push(format!("^{symbolic_base}^{parent_n}"));
933                }
934            } else if let Some(len) = short_len {
935                out.push(abbreviate_object_id(repo, commit_oid, len)?);
936                for p in &parents {
937                    out.push(format!("^{}", abbreviate_object_id(repo, *p, len)?));
938                }
939            } else {
940                out.push(commit_oid.to_string());
941                for p in &parents {
942                    out.push(format!("^{p}"));
943                }
944            }
945        }
946        ParentShorthandKind::Minus { exclude_parent } => {
947            if exclude_parent > parents.len() {
948                return Ok(None);
949            }
950            let excluded_parent = parents[exclude_parent - 1];
951            if symbolic {
952                out.push(symbolic_base.to_string());
953                out.push(format!("^{symbolic_base}^{exclude_parent}"));
954            } else if let Some(len) = short_len {
955                out.push(abbreviate_object_id(repo, commit_oid, len)?);
956                out.push(format!(
957                    "^{}",
958                    abbreviate_object_id(repo, excluded_parent, len)?
959                ));
960            } else {
961                out.push(commit_oid.to_string());
962                out.push(format!("^{excluded_parent}"));
963            }
964        }
965    }
966    Ok(Some(out))
967}
968
969/// Split a revision range at `..` (double-dot), excluding `...` triple-dot ranges.
970pub fn split_double_dot_range(spec: &str) -> Option<(&str, &str)> {
971    if spec == ".." {
972        return Some(("", ""));
973    }
974    let bytes = spec.as_bytes();
975    let mut search = 0usize;
976    while let Some(rel) = spec[search..].find("..") {
977        let idx = search + rel;
978        // Reject `..` that is part of `...` (symmetric-diff operator).
979        let touches_dot_before = idx > 0 && bytes[idx - 1] == b'.';
980        let touches_dot_after = idx + 2 < bytes.len() && bytes[idx + 2] == b'.';
981        if touches_dot_before || touches_dot_after {
982            search = idx + 1;
983            continue;
984        }
985        // Reject `..` that starts a path segment (`../` in `HEAD:../file`).
986        if idx + 2 < bytes.len() && (bytes[idx + 2] == b'/' || bytes[idx + 2] == b'\\') {
987            search = idx + 1;
988            continue;
989        }
990        let left = &spec[..idx];
991        let right = &spec[idx + 2..];
992        return Some((left, right));
993    }
994    None
995}
996
997/// Split `spec` at the first `...` symmetric-diff operator (not part of `....`).
998///
999/// Returns `(left, right)` where either side may be empty (`...HEAD`, `A...`, `...`).
1000#[must_use]
1001pub fn split_triple_dot_range(spec: &str) -> Option<(&str, &str)> {
1002    if spec == "..." {
1003        return Some(("", ""));
1004    }
1005    let bytes = spec.as_bytes();
1006    let mut search = 0usize;
1007    while let Some(rel) = spec[search..].find("...") {
1008        let idx = search + rel;
1009        let four_before = idx >= 1 && bytes[idx - 1] == b'.';
1010        let four_after = idx + 3 < bytes.len() && bytes[idx + 3] == b'.';
1011        if four_before || four_after {
1012            search = idx + 1;
1013            continue;
1014        }
1015        let left = &spec[..idx];
1016        let right = &spec[idx + 3..];
1017        return Some((left, right));
1018    }
1019    None
1020}
1021
1022// A bare brace may belong to a ref name; only ^{ and @{ open selectors.
1023// Within a selector, retain nesting for message patterns such as a{2}..b.
1024fn selector_depth_after(bytes: &[u8], i: usize, depth: usize) -> usize {
1025    match bytes[i] {
1026        b'{' if depth > 0 || (i > 0 && matches!(bytes[i - 1], b'^' | b'@')) => depth + 1,
1027        b'}' => depth.saturating_sub(1),
1028        _ => depth,
1029    }
1030}
1031
1032/// Whether `spec` is a range (`a..b`, `a...b`, `..b`, `a..`) rather than a single revision.
1033///
1034/// Only a `..` outside `^{…}` / `@{…}` selectors and before any `:` counts, so a dotted
1035/// message search such as `main^{/release..candidate}` or `:/fix..up`, a reflog date
1036/// like `main@{1.week.ago}`, or a path in `rev:../file` is still a single revision.
1037///
1038/// A selector opens only at `^{` or `@{`. Ref names may contain a bare `{` or `}` (but never
1039/// `^` or `@{`), so `foo{bar...main` is a range over the branch `foo{bar`. Inside an open
1040/// selector every brace nests, which keeps `main^{/a{2}..b}` a single revision.
1041#[must_use]
1042pub(crate) fn is_top_level_range(spec: &str) -> bool {
1043    let bytes = spec.as_bytes();
1044    let mut depth = 0usize;
1045    for (i, &b) in bytes.iter().enumerate() {
1046        depth = selector_depth_after(bytes, i, depth);
1047        match b {
1048            b':' if depth == 0 => return false,
1049            b'.' if depth == 0 && bytes.get(i + 1) == Some(&b'.') => return true,
1050            _ => {}
1051        }
1052    }
1053    false
1054}
1055
1056/// Split `spec` at a top-level `...` symmetric-difference operator.
1057///
1058/// Like [`split_triple_dot_range`], but dots inside `^{…}` / `@{…}` selectors or after a `:`
1059/// are literal text (`main^{/fix...up}`, `:/fix...up`, `HEAD:a...b`), the same rule as
1060/// [`is_top_level_range`]. Either side of the result may be empty.
1061#[must_use]
1062pub(crate) fn split_top_level_triple_dot_range(spec: &str) -> Option<(&str, &str)> {
1063    let bytes = spec.as_bytes();
1064    let mut depth = 0usize;
1065    let mut i = 0;
1066    while i < bytes.len() {
1067        depth = selector_depth_after(bytes, i, depth);
1068        match bytes[i] {
1069            b':' if depth == 0 => return None,
1070            b'.' if depth == 0 => {
1071                let run = bytes[i..].iter().take_while(|&&b| b == b'.').count();
1072                if run == 3 {
1073                    return Some((&spec[..i], &spec[i + 3..]));
1074                }
1075                i += run;
1076                continue;
1077            }
1078            _ => {}
1079        }
1080        i += 1;
1081    }
1082    None
1083}
1084
1085/// Like [`resolve_revision`], but does not treat a bare filename as an index path
1086/// (matches `git rev-parse` / plumbing, where `file.txt` stays ambiguous).
1087pub fn resolve_revision_without_index_dwim(repo: &Repository, spec: &str) -> Result<ObjectId> {
1088    resolve_revision_impl(repo, spec, false, false, true, false, false, false, false)
1089}
1090
1091/// Resolve a revision string to an object ID.
1092pub fn resolve_revision(repo: &Repository, spec: &str) -> Result<ObjectId> {
1093    resolve_revision_impl(repo, spec, true, false, true, false, false, false, true)
1094}
1095
1096/// Like [`resolve_revision`], but can disable remote-tracking DWIM used by `git checkout`
1097/// when `--no-guess` / `checkout.guess=false` (t2024).
1098pub fn resolve_revision_for_checkout_guess(
1099    repo: &Repository,
1100    spec: &str,
1101    remote_branch_guess: bool,
1102) -> Result<ObjectId> {
1103    resolve_revision_impl(
1104        repo,
1105        spec,
1106        true,
1107        false,
1108        true,
1109        false,
1110        false,
1111        false,
1112        remote_branch_guess,
1113    )
1114}
1115
1116/// Resolve `spec` when it appears as the end of a revision range (`A..B`, `A...B`, etc.):
1117/// abbreviated hex and `core.disambiguate` prefer a commit (porcelain range parsing).
1118pub fn resolve_revision_for_range_end(repo: &Repository, spec: &str) -> Result<ObjectId> {
1119    resolve_revision_impl(repo, spec, true, true, true, false, false, false, true)
1120}
1121
1122/// Like [`resolve_revision_for_range_end`], but does not resolve a bare filename as an index path.
1123///
1124/// Matches plumbing-style revision parsing (`git rev-parse` without index DWIM). Used when a
1125/// token must not be confused with a tracked path that happens to match a branch name (e.g.
1126/// `git reset --hard` after `submodule update` when the submodule has a branch `sub1` and the
1127/// superproject index lists path `sub1`).
1128pub fn resolve_revision_for_range_end_without_index_dwim(
1129    repo: &Repository,
1130    spec: &str,
1131) -> Result<ObjectId> {
1132    resolve_revision_impl(repo, spec, false, true, true, false, false, false, true)
1133}
1134
1135/// Resolve a single revision for `git rev-parse --verify` (no index path DWIM).
1136///
1137/// Git's `--verify` mode must reject tokens that only match an index entry when the path is
1138/// missing from the work tree (`t7102-reset` disambiguation).
1139pub fn resolve_revision_for_verify(repo: &Repository, spec: &str) -> Result<ObjectId> {
1140    resolve_revision_impl(repo, spec, false, true, true, false, false, false, true)
1141}
1142
1143/// First argument to `commit-tree`: ambiguous short hex uses tree-ish rules (blob vs tree).
1144pub fn resolve_revision_for_commit_tree_tree(repo: &Repository, spec: &str) -> Result<ObjectId> {
1145    resolve_revision_impl(repo, spec, true, false, true, false, true, false, true)
1146}
1147
1148/// Old blob OID from a patch `index <old>..<new>` line (`git apply --build-fake-ancestor`).
1149pub fn resolve_revision_for_patch_old_blob(repo: &Repository, spec: &str) -> Result<ObjectId> {
1150    resolve_revision_impl(repo, spec, true, false, true, false, false, true, true)
1151}
1152
1153/// When `spec` uses two-dot range syntax (`A..B`, `..B`, `A..`), returns the commits to
1154/// **exclude** (left tip) and **include** (right tip) for `git log`-style walks.
1155///
1156/// Returns `Ok(None)` when `spec` is not a two-dot range. Symmetric `A...B` is handled by
1157/// [`resolve_revision_as_commit`] instead.
1158///
1159/// # Errors
1160///
1161/// Propagates resolution errors from either range endpoint.
1162pub fn try_parse_double_dot_log_range(
1163    repo: &Repository,
1164    spec: &str,
1165) -> Result<Option<(ObjectId, ObjectId)>> {
1166    let Some((left, right)) = split_double_dot_range(spec) else {
1167        return Ok(None);
1168    };
1169    let left_tip = if left.is_empty() {
1170        resolve_revision_for_range_end(repo, "HEAD")?
1171    } else {
1172        resolve_revision_for_range_end(repo, left)?
1173    };
1174    let right_tip = if right.is_empty() {
1175        resolve_revision_for_range_end(repo, "HEAD")?
1176    } else {
1177        resolve_revision_for_range_end(repo, right)?
1178    };
1179    let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
1180    let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
1181    Ok(Some((left_c, right_c)))
1182}
1183
1184fn try_parse_double_dot_log_range_without_index_dwim(
1185    repo: &Repository,
1186    spec: &str,
1187) -> Result<Option<(ObjectId, ObjectId)>> {
1188    let Some((left, right)) = split_double_dot_range(spec) else {
1189        return Ok(None);
1190    };
1191    let left_tip = if left.is_empty() {
1192        resolve_revision_for_range_end_without_index_dwim(repo, "HEAD")?
1193    } else {
1194        resolve_revision_for_range_end_without_index_dwim(repo, left)?
1195    };
1196    let right_tip = if right.is_empty() {
1197        resolve_revision_for_range_end_without_index_dwim(repo, "HEAD")?
1198    } else {
1199        resolve_revision_for_range_end_without_index_dwim(repo, right)?
1200    };
1201    let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
1202    let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
1203    Ok(Some((left_c, right_c)))
1204}
1205
1206/// Resolve `spec` to a commit OID for porcelain history commands (`log`, `reset`, etc.).
1207///
1208/// Handles `A..B` / `..B` / `A..` (tip is the right side, defaulting to `HEAD`) and
1209/// `A...B` symmetric diff (returns the merge base). Other specs are resolved and peeled
1210/// to a commit (tags peeled, abbreviated hex disambiguated as commit-ish on range ends).
1211/// Returns true when `spec` ends with Git parent/ancestor navigation (`~N`, `^N`, bare `~`/`^`).
1212///
1213/// Used by porcelain (`reset`) to distinguish commit-ish arguments from pathspecs when
1214/// full resolution is deferred or fails for other reasons.
1215#[must_use]
1216pub fn revision_spec_contains_ancestry_navigation(spec: &str) -> bool {
1217    let (_, steps) = parse_nav_steps(spec);
1218    !steps.is_empty()
1219}
1220
1221/// `resolve_revision_as_commit` function.
1222pub fn resolve_revision_as_commit(repo: &Repository, spec: &str) -> Result<ObjectId> {
1223    if let Some((left, right)) = split_triple_dot_range(spec) {
1224        let left_tip = if left.is_empty() {
1225            resolve_revision_for_range_end(repo, "HEAD")?
1226        } else {
1227            resolve_revision_for_range_end(repo, left)?
1228        };
1229        let right_tip = if right.is_empty() {
1230            resolve_revision_for_range_end(repo, "HEAD")?
1231        } else {
1232            resolve_revision_for_range_end(repo, right)?
1233        };
1234        let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
1235        let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
1236        let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_c, &[right_c])?;
1237        return bases
1238            .into_iter()
1239            .next()
1240            .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
1241    }
1242    if let Some((_excl, tip)) = try_parse_double_dot_log_range(repo, spec)? {
1243        return Ok(tip);
1244    }
1245    let oid = resolve_revision_for_range_end(repo, spec)?;
1246    peel_to_commit_for_merge_base(repo, oid)
1247}
1248
1249/// Like [`resolve_revision_as_commit`], but never treats a bare path as an index revision.
1250///
1251/// Use when distinguishing the first `git reset` argument from pathspecs: a submodule work tree
1252/// may have a branch whose name equals a path recorded in the **superproject** index (t3426).
1253pub fn resolve_revision_as_commit_without_index_dwim(
1254    repo: &Repository,
1255    spec: &str,
1256) -> Result<ObjectId> {
1257    if let Some((left, right)) = split_triple_dot_range(spec) {
1258        let left_tip = if left.is_empty() {
1259            resolve_revision_for_range_end_without_index_dwim(repo, "HEAD")?
1260        } else {
1261            resolve_revision_for_range_end_without_index_dwim(repo, left)?
1262        };
1263        let right_tip = if right.is_empty() {
1264            resolve_revision_for_range_end_without_index_dwim(repo, "HEAD")?
1265        } else {
1266            resolve_revision_for_range_end_without_index_dwim(repo, right)?
1267        };
1268        let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
1269        let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
1270        let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_c, &[right_c])?;
1271        return bases
1272            .into_iter()
1273            .next()
1274            .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
1275    }
1276    if let Some((_excl, tip)) = try_parse_double_dot_log_range_without_index_dwim(repo, spec)? {
1277        return Ok(tip);
1278    }
1279    let oid = resolve_revision_for_range_end_without_index_dwim(repo, spec)?;
1280    peel_to_commit_for_merge_base(repo, oid)
1281}
1282
1283fn resolve_ref_dwim_for_rev_parse(repo: &Repository, spec: &str) -> (usize, Option<ObjectId>) {
1284    const RULES: &[&str] = &[
1285        "{0}",
1286        "refs/{0}",
1287        "refs/tags/{0}",
1288        "refs/heads/{0}",
1289        "refs/remotes/{0}",
1290        "refs/remotes/{0}/HEAD",
1291    ];
1292
1293    let mut count = 0usize;
1294    let mut first = None;
1295    let refname_opts = RefNameOptions::default();
1296    for rule in RULES {
1297        let candidate = rule.replace("{0}", spec);
1298        // One raw read decides missing and direct candidates; only symrefs need resolving.
1299        let resolved = match repo.refs().read_raw(&candidate) {
1300            Ok(None) => continue,
1301            Ok(Some(refs::store::RawRef::Direct(oid))) => Ok(oid),
1302            Ok(Some(refs::store::RawRef::Symbolic(target))) => {
1303                if check_refname_format(&target, &refname_opts).is_err()
1304                    || repo.resolve_ref_oid(&target).is_err()
1305                {
1306                    // Match upstream `expand_ref` (refs.c): a dangling symref is
1307                    // silently ignored when it is literally `HEAD` (e.g. an unborn
1308                    // branch in a fresh repo). The warning is only emitted for other
1309                    // dangling symrefs encountered while DWIM-resolving a ref.
1310                    if candidate != "HEAD" {
1311                        repo.diagnostics()
1312                            .warn(crate::diagnostics::Warning::DanglingSymref {
1313                                name: candidate.clone(),
1314                            });
1315                    }
1316                    continue;
1317                }
1318                repo.resolve_ref_oid(&candidate)
1319            }
1320            Err(_) => repo.resolve_ref_oid(&candidate),
1321        };
1322        if let Ok(oid) = resolved {
1323            count += 1;
1324            if first.is_none() {
1325                first = Some(oid);
1326            }
1327        }
1328    }
1329    (count, first)
1330}
1331
1332#[expect(clippy::too_many_arguments)]
1333fn resolve_revision_impl(
1334    repo: &Repository,
1335    spec: &str,
1336    index_dwim: bool,
1337    commit_only_hex: bool,
1338    use_disambiguate_config: bool,
1339    treeish_colon_lhs: bool,
1340    implicit_tree_abbrev: bool,
1341    implicit_blob_abbrev: bool,
1342    remote_branch_name_guess: bool,
1343) -> Result<ObjectId> {
1344    resolve_revision_with_navigation(
1345        repo,
1346        spec,
1347        index_dwim,
1348        commit_only_hex,
1349        use_disambiguate_config,
1350        treeish_colon_lhs,
1351        implicit_tree_abbrev,
1352        implicit_blob_abbrev,
1353        remote_branch_name_guess,
1354        NavigationMetadata::Discard,
1355    )
1356}
1357
1358/// Resolve an endpoint whose parsed ancestry is reused by the following walk.
1359pub(crate) fn resolve_walk_range_end(repo: &Repository, spec: &str) -> Result<ObjectId> {
1360    resolve_revision_with_navigation(
1361        repo,
1362        spec,
1363        true,
1364        true,
1365        true,
1366        false,
1367        false,
1368        false,
1369        true,
1370        NavigationMetadata::RetainForWalk,
1371    )
1372}
1373
1374#[expect(clippy::too_many_arguments)]
1375fn resolve_revision_with_navigation(
1376    repo: &Repository,
1377    spec: &str,
1378    index_dwim: bool,
1379    commit_only_hex: bool,
1380    use_disambiguate_config: bool,
1381    treeish_colon_lhs: bool,
1382    implicit_tree_abbrev: bool,
1383    implicit_blob_abbrev: bool,
1384    remote_branch_name_guess: bool,
1385    metadata: NavigationMetadata,
1386) -> Result<ObjectId> {
1387    // Handle `:/message` early — it can contain any characters so must
1388    // not be confused with peel/nav syntax.
1389    if let Some(pattern) = spec.strip_prefix(":/") {
1390        if pattern.is_empty() {
1391            // `:/` with an empty pattern resolves to the youngest reachable commit (HEAD tip).
1392            let head = crate::state::resolve_head_for_repository(repo)
1393                .map_err(|_| Error::ObjectNotFound(":/".to_owned()))?;
1394            return head
1395                .oid()
1396                .copied()
1397                .ok_or_else(|| Error::ObjectNotFound(":/".to_owned()));
1398        }
1399        return resolve_commit_message_search(repo, pattern);
1400    }
1401
1402    if let Some(index_spec) = parse_index_colon_spec(spec) {
1403        let path = normalize_colon_path_for_tree(repo, index_spec.raw_path)?;
1404        return resolve_index_path_at_stage(repo, &path, index_spec.stage)
1405            .map_err(|e| diagnose_index_path_error(repo, &path, index_spec.stage, e));
1406    }
1407
1408    // `tags/<name>` is Git's DWIM for `refs/tags/<name>` (t6101 `tags/start`).
1409    if let Some(tag_path) = spec.strip_prefix("tags/") {
1410        if !tag_path.is_empty() {
1411            let tag_ref = format!("refs/tags/{tag_path}");
1412            if let Ok(oid) = repo.resolve_ref_oid(&tag_ref) {
1413                return Ok(oid);
1414            }
1415        }
1416    }
1417
1418    // Pseudo-ref written by `git merge` / grit merge on conflict (tree OID, one line).
1419    if spec == "AUTO_MERGE" {
1420        let raw = fs::read_to_string(repo.git_dir().join("AUTO_MERGE")).map_err(|e| {
1421            Error::from(RevParseError::AutoMergeRead {
1422                detail: e.to_string(),
1423            })
1424        })?;
1425        let line = raw.lines().next().unwrap_or("").trim();
1426        return line
1427            .parse::<ObjectId>()
1428            .map_err(|_| Error::InvalidRef("AUTO_MERGE: invalid object id".to_owned()));
1429    }
1430
1431    // `refs/...` spelled in full (e.g. `refs/tags/other`): resolve as a ref before any
1432    // treeish / DWIM path logic so a worktree path named `other` cannot shadow `refs/tags/other`
1433    // (`git rev-parse refs/tags/other`, t5332).
1434    if spec.starts_with("refs/") && !spec.contains(':') {
1435        if let Ok(oid) = repo.resolve_ref_oid(spec) {
1436            return Ok(oid);
1437        }
1438    }
1439
1440    // Handle A...B (symmetric difference / merge-base)
1441    // Also handles A... (implies A...HEAD)
1442    if let Some((left_raw, right_raw)) = split_top_level_triple_dot_range(spec) {
1443        if !left_raw.is_empty() || !right_raw.is_empty() {
1444            let left_oid = peel_to_commit_for_merge_base(
1445                repo,
1446                if left_raw.is_empty() {
1447                    resolve_revision_impl(
1448                        repo,
1449                        "HEAD",
1450                        index_dwim,
1451                        commit_only_hex,
1452                        use_disambiguate_config,
1453                        false,
1454                        false,
1455                        false,
1456                        remote_branch_name_guess,
1457                    )?
1458                } else {
1459                    resolve_revision_impl(
1460                        repo,
1461                        left_raw,
1462                        index_dwim,
1463                        commit_only_hex,
1464                        use_disambiguate_config,
1465                        false,
1466                        false,
1467                        false,
1468                        remote_branch_name_guess,
1469                    )?
1470                },
1471            )?;
1472            let right_oid = peel_to_commit_for_merge_base(
1473                repo,
1474                if right_raw.is_empty() {
1475                    resolve_revision_impl(
1476                        repo,
1477                        "HEAD",
1478                        index_dwim,
1479                        commit_only_hex,
1480                        use_disambiguate_config,
1481                        false,
1482                        false,
1483                        false,
1484                        remote_branch_name_guess,
1485                    )?
1486                } else {
1487                    resolve_revision_impl(
1488                        repo,
1489                        right_raw,
1490                        index_dwim,
1491                        commit_only_hex,
1492                        use_disambiguate_config,
1493                        false,
1494                        false,
1495                        false,
1496                        remote_branch_name_guess,
1497                    )?
1498                },
1499            )?;
1500            let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_oid, &[right_oid])?;
1501            return bases
1502                .into_iter()
1503                .next()
1504                .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
1505        }
1506    }
1507
1508    // Handle <rev>:<path> — resolve a tree entry.
1509    // Must come after :/ handling. The colon must not be inside `^{...}` (e.g.
1510    // `other^{/msg:}:file`) and must not be the `:path` / `:N:path` index forms.
1511    if let Some((before, after)) = split_treeish_colon(spec) {
1512        if !before.is_empty() && !spec.starts_with(":/") {
1513            // <rev>:<path> — resolve rev to tree, then navigate path
1514            let rev_oid = match resolve_revision_impl(
1515                repo,
1516                before,
1517                index_dwim,
1518                commit_only_hex,
1519                use_disambiguate_config,
1520                true,
1521                false,
1522                false,
1523                remote_branch_name_guess,
1524            ) {
1525                Ok(o) => o,
1526                Err(Error::ObjectNotFound(s)) if s == before => {
1527                    return Err(RevParseError::InvalidObjectName {
1528                        name: before.to_owned(),
1529                    }
1530                    .into());
1531                }
1532                Err(Error::RevParse(RevParseError::AmbiguousArgument { .. })) => {
1533                    return Err(RevParseError::InvalidObjectName {
1534                        name: before.to_owned(),
1535                    }
1536                    .into());
1537                }
1538                Err(e) => return Err(e),
1539            };
1540            let tree_oid = peel_to_tree(repo, rev_oid)?;
1541            if after.is_empty() {
1542                // <rev>: means the tree itself
1543                return Ok(tree_oid);
1544            }
1545            let clean_path = match normalize_colon_path_for_tree(repo, after) {
1546                Ok(p) => p,
1547                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
1548                    let wt = repo
1549                        .work_tree()
1550                        .and_then(|p| p.canonicalize_simplified().ok())
1551                        .map(|p| p.display().to_string())
1552                        .unwrap_or_default();
1553                    return Err(RevParseError::OutsideRepository {
1554                        path: after.to_owned(),
1555                        work_tree: wt,
1556                    }
1557                    .into());
1558                }
1559                Err(e) => return Err(e),
1560            };
1561            return resolve_tree_path_rev_parse(repo, &tree_oid, &clean_path)
1562                .map_err(|e| diagnose_tree_path_error(repo, before, after, &clean_path, e));
1563        }
1564    }
1565
1566    let (base_with_nav, peel) = parse_peel_suffix(spec);
1567    let (base, nav_steps) = parse_nav_steps(base_with_nav);
1568    let peel_for_hex = peel
1569        .or(((treeish_colon_lhs || implicit_tree_abbrev) && peel.is_none()).then_some("tree"))
1570        .or((implicit_blob_abbrev && peel.is_none()).then_some("blob"));
1571    let mut oid = resolve_base(
1572        repo,
1573        base,
1574        index_dwim,
1575        commit_only_hex,
1576        use_disambiguate_config,
1577        peel_for_hex,
1578        implicit_tree_abbrev,
1579        implicit_blob_abbrev,
1580        remote_branch_name_guess,
1581    )?;
1582    let nav_cache = NavParentCache::new(repo, metadata);
1583    for step in nav_steps {
1584        oid = apply_nav_step(repo, &nav_cache, oid, step).map_err(|e| {
1585            if matches!(e, Error::ObjectNotFound(_)) {
1586                RevParseError::RevisionOutOfRange {
1587                    spec: spec.to_owned(),
1588                }
1589                .into()
1590            } else {
1591                e
1592            }
1593        })?;
1594    }
1595    apply_peel(repo, oid, peel)
1596}
1597
1598/// Normalize a path from `treeish:path` against the work tree and return a `/`-separated path
1599/// relative to the repository root (for tree lookup).
1600fn normalize_path_components(path: PathBuf) -> PathBuf {
1601    let mut out = PathBuf::new();
1602    for c in path.components() {
1603        match c {
1604            Component::Prefix(_) | Component::RootDir => out.push(c),
1605            Component::CurDir => {}
1606            Component::ParentDir => {
1607                let _ = out.pop();
1608            }
1609            Component::Normal(x) => out.push(x),
1610        }
1611    }
1612    out
1613}
1614
1615/// Normalize `treeish:path` path segment for tree lookup when there is no work tree (bare repo).
1616///
1617/// Paths are interpreted relative to the repository root; `./` / `../` / `.` still require a work
1618/// tree in Git and are rejected here.
1619fn normalize_colon_path_for_bare_tree(raw_path: &str) -> Result<String> {
1620    let cwd_relative = raw_path.starts_with("./") || raw_path.starts_with("../") || raw_path == ".";
1621    if cwd_relative {
1622        return Err(Error::InvalidRef(
1623            "relative path syntax can't be used outside working tree".to_owned(),
1624        ));
1625    }
1626    let s = raw_path.trim_start_matches('/');
1627    let mut stack: Vec<&str> = Vec::new();
1628    for part in s.split('/') {
1629        if part.is_empty() || part == "." {
1630            continue;
1631        }
1632        if part == ".." {
1633            let _ = stack.pop();
1634        } else {
1635            stack.push(part);
1636        }
1637    }
1638    Ok(stack.join("/"))
1639}
1640
1641fn normalize_colon_path_for_tree(repo: &Repository, raw_path: &str) -> Result<String> {
1642    let Some(work_tree) = repo.work_tree() else {
1643        return normalize_colon_path_for_bare_tree(raw_path);
1644    };
1645
1646    let cwd = repo.environment().cwd.clone();
1647    let wt_canon = work_tree.canonicalize_simplified().map_err(Error::Io)?;
1648
1649    let cwd_relative = raw_path.starts_with("./") || raw_path.starts_with("../") || raw_path == ".";
1650    if cwd_relative && !path_is_within(&cwd, work_tree) {
1651        return Err(Error::InvalidRef(
1652            "relative path syntax can't be used outside working tree".to_owned(),
1653        ));
1654    }
1655
1656    // `./` / `../` / `.` are relative to cwd; other relative paths are relative to work tree.
1657    let full = if raw_path.starts_with('/') {
1658        PathBuf::from(raw_path)
1659    } else if cwd_relative {
1660        cwd.join(raw_path)
1661    } else {
1662        work_tree.join(raw_path)
1663    };
1664    let full = normalize_path_components(full);
1665
1666    if !path_is_within(&full, &wt_canon) {
1667        return Err(Error::InvalidRef("outside repository".to_owned()));
1668    }
1669    let rel = full
1670        .strip_prefix(&wt_canon)
1671        .map_err(|_| Error::InvalidRef("outside repository".to_owned()))?;
1672    let s = rel.to_string_lossy().replace('\\', "/");
1673    Ok(s.trim_end_matches('/').to_owned())
1674}
1675
1676/// True when `oid` is a commit that no replace ref redirects, known from the commit-graph or the
1677/// object header without inflating the object.
1678///
1679/// Hide sets peel every ref in the repository, so the common case must stay cheap.
1680fn is_unreplaced_commit(repo: &Repository, oid: ObjectId) -> bool {
1681    if repo.object_id_after_replace(&oid) != oid {
1682        return false;
1683    }
1684    let in_commit_graph = repo
1685        .odb()
1686        .commit_graph_chain()
1687        .is_some_and(|graph| graph.find_commit(&oid).is_some());
1688    in_commit_graph
1689        || repo
1690            .odb()
1691            .read_info(&oid)
1692            .is_ok_and(|info| info.kind == ObjectKind::Commit)
1693}
1694
1695/// Peel tags to a commit OID for merge-base computation (`A...B` and `rev-parse` output).
1696pub fn peel_to_commit_for_merge_base(repo: &Repository, oid: ObjectId) -> Result<ObjectId> {
1697    peel_tags_to_commit(repo, oid)?
1698        .map_err(|other| Error::InvalidRef(format!("object {other} does not name a commit")))
1699}
1700
1701/// Peel tags starting at `oid` until a non-tag object.
1702///
1703/// Returns `Ok(Ok(commit))` for a commit, or `Ok(Err(oid))` naming the tree or blob the tags led
1704/// to.
1705pub(crate) fn peel_tags_to_commit(
1706    repo: &Repository,
1707    mut oid: ObjectId,
1708) -> Result<std::result::Result<ObjectId, ObjectId>> {
1709    // Only tags need their payload; every other kind is decided from the object header.
1710    loop {
1711        match repo.read_info_replaced(&oid)?.kind {
1712            ObjectKind::Commit => return Ok(Ok(oid)),
1713            ObjectKind::Tag => oid = parse_tag_target(&repo.read_replaced(&oid)?.data)?,
1714            ObjectKind::Tree | ObjectKind::Blob => return Ok(Err(oid)),
1715        }
1716    }
1717}
1718
1719/// Like [`peel_to_commit_for_merge_base`], but returns `Ok(None)` when the peeled object is not a
1720/// commit (e.g. a tag pointing at a blob). Used by upload-pack fetch negotiation.
1721pub fn try_peel_to_commit_for_merge_base(
1722    repo: &Repository,
1723    oid: ObjectId,
1724) -> Result<Option<ObjectId>> {
1725    if is_unreplaced_commit(repo, oid) {
1726        return Ok(Some(oid));
1727    }
1728    let oid = apply_peel(repo, oid, Some(""))?;
1729    let obj = repo.odb().read(&oid)?;
1730    match obj.kind {
1731        ObjectKind::Commit => Ok(Some(oid)),
1732        ObjectKind::Tree | ObjectKind::Blob => Ok(None),
1733        ObjectKind::Tag => Err(Error::InvalidRef("unexpected tag after peel".to_owned())),
1734    }
1735}
1736
1737/// Peel `oid` to the tree it represents (commits → root tree, tags → recursively, tree → identity).
1738///
1739/// # Errors
1740///
1741/// Returns [`Error::ObjectNotFound`] when the object cannot be peeled to a tree (e.g. a blob).
1742pub fn peel_to_tree(repo: &Repository, oid: ObjectId) -> Result<ObjectId> {
1743    let obj = repo.read_replaced(&oid)?;
1744    match obj.kind {
1745        crate::objects::ObjectKind::Tree => Ok(oid),
1746        crate::objects::ObjectKind::Commit => {
1747            let commit = crate::objects::parse_commit(&obj.data)?;
1748            Ok(commit.tree)
1749        }
1750        crate::objects::ObjectKind::Tag => {
1751            let tag = crate::objects::parse_tag(&obj.data)?;
1752            peel_to_tree(repo, tag.object)
1753        }
1754        _ => Err(Error::ObjectNotFound(format!(
1755            "cannot peel {} to tree",
1756            oid
1757        ))),
1758    }
1759}
1760
1761/// Navigate a tree to find an object at a given path.
1762///
1763/// Git accepts `rev:path` when the leaf is a **blob, symlink, gitlink, or tree** (e.g.
1764/// `HEAD:subdir` for a subdirectory tree, or a submodule path whose leaf is a gitlink). Only
1765/// [`walk_tree_to_blob_entry`] is blob-only.
1766fn resolve_tree_path(repo: &Repository, tree_oid: &ObjectId, path: &str) -> Result<ObjectId> {
1767    resolve_treeish_path_to_object(repo, *tree_oid, path)
1768}
1769
1770/// Why a `treeish:path` walk stopped before reaching a usable leaf.
1771#[derive(Debug)]
1772enum TreePathError {
1773    /// A path component names no entry in the tree being walked.
1774    NotInTree,
1775    /// Any other failure; returned to the caller unchanged.
1776    Other(Error),
1777}
1778
1779impl From<Error> for TreePathError {
1780    fn from(err: Error) -> Self {
1781        Self::Other(err)
1782    }
1783}
1784
1785/// Like Git `rev-parse` for `treeish:path`: the leaf may be a blob or a tree OID.
1786fn resolve_tree_path_rev_parse(
1787    repo: &Repository,
1788    tree_oid: &ObjectId,
1789    path: &str,
1790) -> std::result::Result<ObjectId, TreePathError> {
1791    let obj = repo.odb().read(tree_oid)?;
1792    let entries = crate::objects::parse_tree(&obj.data)?;
1793    let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
1794    if components.is_empty() {
1795        return Err(Error::InvalidRef(format!(
1796            "path '{path}' does not name an object in tree {tree_oid}"
1797        ))
1798        .into());
1799    }
1800
1801    let first = components[0];
1802    let rest: Vec<&str> = components[1..].to_vec();
1803    for entry in entries {
1804        let name = String::from_utf8_lossy(&entry.name);
1805        if name == first {
1806            if rest.is_empty() {
1807                // Git's `rev-parse <treeish>:<path>` returns the entry OID for any leaf —
1808                // blob, tree, symlink, or gitlink. For a gitlink the OID is the submodule's
1809                // recorded commit SHA, which need not exist in this object store (it lives in
1810                // the submodule); do not attempt to read it. (lib-submodule-update
1811                // `test_submodule_content` relies on `rev-parse <commit>:sub1`.)
1812                return Ok(entry.oid);
1813            }
1814            if entry.mode != crate::index::MODE_TREE {
1815                return Err(Error::ObjectNotFound(path.to_owned()).into());
1816            }
1817            return resolve_tree_path_rev_parse(repo, &entry.oid, &rest.join("/"));
1818        }
1819    }
1820    Err(TreePathError::NotInTree)
1821}
1822
1823/// Resolved blob (non-tree) at `treeish:path` for diff plumbing.
1824///
1825/// Returns the repository-relative path, blob OID, and Git mode string (e.g. `"100644"`).
1826#[derive(Debug, Clone)]
1827pub struct TreeishBlobAtPath {
1828    /// Path used in `diff --git` / `---` / `+++` headers (tree path, `/`-separated).
1829    pub path: String,
1830    /// Object id of the blob.
1831    pub oid: ObjectId,
1832    /// File mode as in tree objects (`100644`, `100755`, `120000`, …).
1833    pub mode: String,
1834}
1835
1836/// Resolve `rev:path` to the blob at that path in the tree reached from `rev`.
1837///
1838/// Fails when `spec` is not `treeish:path`, when the path is missing, or when the
1839/// target is a tree or gitlink rather than a blob/symlink blob.
1840pub fn resolve_treeish_blob_at_path(repo: &Repository, spec: &str) -> Result<TreeishBlobAtPath> {
1841    let (before, after) = split_treeish_colon(spec)
1842        .ok_or_else(|| Error::InvalidRef(format!("'{spec}' is not a treeish:path revision")))?;
1843
1844    let rev_oid =
1845        match resolve_revision_impl(repo, before, true, false, true, true, false, false, true) {
1846            Ok(o) => o,
1847            Err(Error::ObjectNotFound(s)) if s == before => {
1848                return Err(RevParseError::InvalidObjectName {
1849                    name: before.to_owned(),
1850                }
1851                .into());
1852            }
1853            Err(Error::RevParse(RevParseError::AmbiguousArgument { .. })) => {
1854                return Err(RevParseError::InvalidObjectName {
1855                    name: before.to_owned(),
1856                }
1857                .into());
1858            }
1859            Err(e) => return Err(e),
1860        };
1861
1862    let tree_oid = peel_to_tree(repo, rev_oid)?;
1863
1864    // Empty path means the root tree itself.
1865    if after.is_empty() {
1866        return Ok(TreeishBlobAtPath {
1867            path: String::new(),
1868            oid: tree_oid,
1869            mode: "040000".to_string(),
1870        });
1871    }
1872
1873    let clean_path = match normalize_colon_path_for_tree(repo, after) {
1874        Ok(p) => p,
1875        Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
1876            let wt = repo
1877                .work_tree()
1878                .and_then(|p| p.canonicalize_simplified().ok())
1879                .map(|p| p.display().to_string())
1880                .unwrap_or_default();
1881            return Err(RevParseError::OutsideRepository {
1882                path: after.to_owned(),
1883                work_tree: wt,
1884            }
1885            .into());
1886        }
1887        Err(e) => return Err(e),
1888    };
1889
1890    let (oid, mode_str) = walk_tree_to_blob_entry(repo, &tree_oid, &clean_path)
1891        .map_err(|e| diagnose_tree_path_error(repo, before, after, &clean_path, e))?;
1892    Ok(TreeishBlobAtPath {
1893        path: clean_path,
1894        oid,
1895        mode: mode_str,
1896    })
1897}
1898
1899/// Walk from `tree_oid` to the leaf named by `path` and return OID + mode string for a blob or symlink.
1900///
1901/// Errors when the leaf is a tree or gitlink. Used by [`resolve_treeish_blob_at_path`] and similar.
1902fn walk_tree_to_blob_entry(
1903    repo: &Repository,
1904    tree_oid: &ObjectId,
1905    path: &str,
1906) -> std::result::Result<(ObjectId, String), TreePathError> {
1907    let obj = repo.read_replaced(tree_oid)?;
1908    let entries = crate::objects::parse_tree(&obj.data)?;
1909    let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
1910    if components.is_empty() {
1911        return Err(Error::InvalidRef(format!(
1912            "path '{path}' does not name a blob in tree {tree_oid}"
1913        ))
1914        .into());
1915    }
1916
1917    let first = components[0];
1918    let rest: Vec<&str> = components[1..].to_vec();
1919    for entry in entries {
1920        let name = String::from_utf8_lossy(&entry.name);
1921        if name == first {
1922            if rest.is_empty() {
1923                if entry.mode == crate::index::MODE_TREE {
1924                    return Err(Error::InvalidRef(format!("'{path}' is a tree, not a blob")).into());
1925                }
1926                return Ok((entry.oid, entry.mode_str()));
1927            }
1928            if entry.mode != crate::index::MODE_TREE {
1929                return Err(Error::ObjectNotFound(path.to_owned()).into());
1930            }
1931            return walk_tree_to_blob_entry(repo, &entry.oid, &rest.join("/"));
1932        }
1933    }
1934    Err(TreePathError::NotInTree)
1935}
1936
1937/// A single parent/ancestor navigation step.
1938#[derive(Debug, Clone, Copy)]
1939enum NavStep {
1940    /// `^N` — navigate to the Nth parent (1-indexed; 0 is a no-op).
1941    ParentN(usize),
1942    /// `~N` — follow the first parent N times.
1943    AncestorN(usize),
1944}
1945
1946/// Parse and strip any trailing `^N` / `~N` navigation steps from `spec`.
1947///
1948/// Returns `(base, steps)` where `steps` are in left-to-right application order.
1949fn parse_nav_steps(spec: &str) -> (&str, Vec<NavStep>) {
1950    let mut steps = Vec::new();
1951    let mut remaining = spec;
1952
1953    loop {
1954        // Try `~<digits>` or bare `~` at the end.
1955        if let Some(tilde_pos) = remaining.rfind('~') {
1956            let after = &remaining[tilde_pos + 1..];
1957            if after.is_empty() {
1958                // bare `~` = `~1`
1959                steps.push(NavStep::AncestorN(1));
1960                remaining = &remaining[..tilde_pos];
1961                continue;
1962            }
1963            if after.bytes().all(|b| b.is_ascii_digit()) {
1964                let n: usize = after.parse().unwrap_or(1);
1965                steps.push(NavStep::AncestorN(n));
1966                remaining = &remaining[..tilde_pos];
1967                continue;
1968            }
1969        }
1970
1971        // Try `^<digits>` or bare `^` at the end (but not `^{...}` — peel strips those first).
1972        if let Some(caret_pos) = remaining.rfind('^') {
1973            let after = &remaining[caret_pos + 1..];
1974            if after.is_empty() {
1975                // bare `^` = `^1`
1976                steps.push(NavStep::ParentN(1));
1977                remaining = &remaining[..caret_pos];
1978                continue;
1979            }
1980            if after.bytes().all(|b| b.is_ascii_digit()) && !after.is_empty() {
1981                let n: usize = after.parse().unwrap_or(usize::MAX);
1982                steps.push(NavStep::ParentN(n));
1983                remaining = &remaining[..caret_pos];
1984                continue;
1985            }
1986        }
1987
1988        break;
1989    }
1990
1991    steps.reverse();
1992    (remaining, steps)
1993}
1994
1995/// Follow annotated tag objects to their peeled target (Git: `^` / `~` peel tags first).
1996fn peel_annotated_tag_chain(repo: &Repository, mut oid: ObjectId) -> Result<ObjectId> {
1997    loop {
1998        let obj = repo.read_replaced(&oid)?;
1999        if obj.kind != ObjectKind::Tag {
2000            return Ok(oid);
2001        }
2002        let tag = parse_tag(&obj.data)?;
2003        oid = tag.object;
2004    }
2005}
2006
2007/// Apply a single navigation step to an OID, resolving parent/ancestor links.
2008fn apply_nav_step(
2009    repo: &Repository,
2010    nav: &NavParentCache<'_>,
2011    oid: ObjectId,
2012    step: NavStep,
2013) -> Result<ObjectId> {
2014    match step {
2015        NavStep::ParentN(0) => Ok(oid),
2016        NavStep::ParentN(n) => {
2017            let oid = peel_annotated_tag_chain(repo, oid)?;
2018            let parents = nav.parents_of(oid)?;
2019            parents
2020                .get(n - 1)
2021                .copied()
2022                .ok_or_else(|| Error::ObjectNotFound(format!("{oid}^{n}")))
2023        }
2024        NavStep::AncestorN(n) => {
2025            let mut current = peel_annotated_tag_chain(repo, oid)?;
2026            for _ in 0..n {
2027                let parents = nav.parents_of(current)?;
2028                current = parents
2029                    .first()
2030                    .copied()
2031                    .ok_or_else(|| Error::ObjectNotFound(format!("{current}~1")))?;
2032            }
2033            Ok(current)
2034        }
2035    }
2036}
2037
2038/// Abbreviate an object ID to a unique prefix.
2039///
2040/// The returned prefix is the shortest one, at least `min_len` (clamped to 4..=the full
2041/// hex length) hex characters long, that no other object in the object database shares.
2042/// An id missing from the object database is cut to `min_len` characters.
2043///
2044/// Costs one [`Odb::lookup_prefix`](crate::odb::Odb::lookup_prefix) at `min_len`
2045/// characters, however long the result turns out to be.
2046///
2047/// # Errors
2048///
2049/// Propagates object database lookup failures.
2050pub fn abbreviate_object_id(repo: &Repository, oid: ObjectId, min_len: usize) -> Result<String> {
2051    abbreviate_object_id_with_prefix_cache(repo, oid, min_len, &mut HashMap::new())
2052}
2053
2054/// Like [`abbreviate_object_id`], remembering in `prefix_ambiguous` whether more than one
2055/// object starts with each `min_len` prefix it looked up, so a known-unique prefix skips
2056/// the lookup.
2057pub(crate) fn abbreviate_object_id_with_prefix_cache(
2058    repo: &Repository,
2059    oid: ObjectId,
2060    min_len: usize,
2061    prefix_ambiguous: &mut HashMap<String, bool>,
2062) -> Result<String> {
2063    let hex_len = oid.algo().hex_len();
2064    let min_len = min_len.clamp(4, hex_len);
2065    let mut target = oid.to_hex();
2066
2067    if !repo.odb().contains(&oid)? {
2068        target.truncate(min_len);
2069        return Ok(target);
2070    }
2071
2072    crate::oid_abbrev::abbreviate_object_id_known(
2073        repo,
2074        oid,
2075        min_len,
2076        prefix_ambiguous,
2077        &mut [None; 256],
2078        &mut None,
2079    )
2080}
2081
2082/// Render `path` relative to `cwd` with `/` separators.
2083#[must_use]
2084pub fn to_relative_path(path: &Path, cwd: &Path) -> String {
2085    let path_components = normalize_components(path);
2086    let cwd_components = normalize_components(cwd);
2087
2088    let mut common = 0usize;
2089    let max_common = path_components.len().min(cwd_components.len());
2090    while common < max_common && path_components[common] == cwd_components[common] {
2091        common += 1;
2092    }
2093
2094    let mut parts = Vec::new();
2095    let up_count = cwd_components.len().saturating_sub(common);
2096    for _ in 0..up_count {
2097        parts.push("..".to_owned());
2098    }
2099    for item in path_components.iter().skip(common) {
2100        parts.push(item.clone());
2101    }
2102
2103    if parts.is_empty() {
2104        ".".to_owned()
2105    } else {
2106        parts.join("/")
2107    }
2108}
2109
2110fn disambiguate_kind_rank(kind: ObjectKind) -> u8 {
2111    match kind {
2112        ObjectKind::Tag => 0,
2113        ObjectKind::Commit => 1,
2114        ObjectKind::Tree => 2,
2115        ObjectKind::Blob => 3,
2116    }
2117}
2118
2119fn oid_satisfies_peel_filter(repo: &Repository, oid: ObjectId, peel_inner: &str) -> bool {
2120    apply_peel(repo, oid, Some(peel_inner)).is_ok()
2121}
2122
2123/// Candidate objects when a short object id is ambiguous (type order, then hex).
2124pub fn ambiguous_object_hint_lines(
2125    repo: &Repository,
2126    short_prefix: &str,
2127    peel_filter: Option<&str>,
2128) -> Result<Vec<AmbiguousObjectHint>> {
2129    let mut typed: Vec<(u8, String, &'static str)> = Vec::new();
2130    let mut bad_hex: Vec<String> = Vec::new();
2131    for oid in list_all_abbrev_matches(repo, short_prefix)? {
2132        let hex = oid.to_hex();
2133        match repo.read_replaced(&oid) {
2134            Ok(obj) => {
2135                let ok = peel_filter.is_none_or(|p| oid_satisfies_peel_filter(repo, oid, p));
2136                if ok {
2137                    typed.push((disambiguate_kind_rank(obj.kind), hex, obj.kind.as_str()));
2138                }
2139            }
2140            Err(_) => bad_hex.push(hex),
2141        }
2142    }
2143    if typed.is_empty() && peel_filter.is_some() {
2144        return ambiguous_object_hint_lines(repo, short_prefix, None);
2145    }
2146    bad_hex.sort();
2147    typed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
2148    let mut out = Vec::new();
2149    for h in bad_hex {
2150        out.push(AmbiguousObjectHint {
2151            oid_hex: h,
2152            kind: None,
2153        });
2154    }
2155    for (_, hex, kind) in typed {
2156        out.push(AmbiguousObjectHint {
2157            oid_hex: hex,
2158            kind: Some(kind),
2159        });
2160    }
2161    Ok(out)
2162}
2163
2164fn read_core_disambiguate(repo: &Repository) -> Option<&'static str> {
2165    let config = repo
2166        .ensure_config_arc_fast()
2167        .unwrap_or_else(|_| Arc::new(ConfigSet::new()));
2168    let v = config.get("core.disambiguate")?;
2169    match v.to_ascii_lowercase().as_str() {
2170        "committish" | "commit" => Some("commit"),
2171        "treeish" | "tree" => Some("tree"),
2172        "blob" => Some("blob"),
2173        "tag" => Some("tag"),
2174        "none" => None,
2175        _ => None,
2176    }
2177}
2178
2179/// When `spec` resolved as an abbreviated object id, warn if `refs/heads/<spec>` exists and
2180/// points at a different object (Git: `rev-parse` warns "refname ... is ambiguous").
2181fn warn_if_branch_refname_collides_with_abbrev_hex(
2182    repo: &Repository,
2183    spec: &str,
2184    object_oid: ObjectId,
2185) {
2186    if spec.len() >= repo.odb().hash_algo().hex_len() {
2187        return;
2188    }
2189    let branch_ref = format!("refs/heads/{spec}");
2190    let Ok(ref_oid) = repo.resolve_ref_oid(&branch_ref) else {
2191        return;
2192    };
2193    if ref_oid != object_oid {
2194        repo.warn(crate::diagnostics::Warning::AmbiguousRefname {
2195            spec: spec.to_owned(),
2196        });
2197    }
2198}
2199
2200/// When a hex-like `spec` resolved as a ref under `refs/heads/` or `refs/tags/`, warn if that name
2201/// also matches object(s) in the ODB (Git: `warning: refname 'abc' is ambiguous.`).
2202fn warn_if_hex_ref_collides_with_objects(repo: &Repository, spec: &str, ref_oid: ObjectId) {
2203    if spec.len() >= repo.odb().hash_algo().hex_len() || !is_hex_prefix(spec) {
2204        return;
2205    }
2206    let Ok(matches) = find_abbrev_matches(repo, spec) else {
2207        return;
2208    };
2209    if matches.is_empty() {
2210        return;
2211    }
2212    if matches.len() > 1 || matches[0] != ref_oid {
2213        repo.warn(crate::diagnostics::Warning::AmbiguousRefname {
2214            spec: spec.to_owned(),
2215        });
2216    }
2217}
2218
2219fn disambiguate_hex_by_peel(
2220    repo: &Repository,
2221    spec: &str,
2222    matches: &[ObjectId],
2223    peel: &str,
2224) -> Result<ObjectId> {
2225    let peel_some = Some(peel);
2226    let filtered: Vec<ObjectId> = matches
2227        .iter()
2228        .copied()
2229        .filter(|oid| apply_peel(repo, *oid, peel_some).is_ok())
2230        .collect();
2231    if filtered.len() == 1 {
2232        return Ok(filtered[0]);
2233    }
2234    if filtered.is_empty() {
2235        return Err(RevParseError::AmbiguousShortObject {
2236            spec: spec.to_owned(),
2237        }
2238        .into());
2239    }
2240    let mut peeled_targets: HashSet<ObjectId> = HashSet::new();
2241    for oid in &filtered {
2242        if let Ok(p) = apply_peel(repo, *oid, peel_some) {
2243            peeled_targets.insert(p);
2244        }
2245    }
2246    if peeled_targets.len() == 1 {
2247        // Several objects (e.g. commit + tag) may peel to the same commit; any representative
2248        // is valid for subsequent `apply_peel` in `resolve_revision_impl`.
2249        let mut sorted = filtered;
2250        sorted.sort_by_key(|o| o.to_hex());
2251        return Ok(sorted[0]);
2252    }
2253    // `^{commit}`: multiple objects may peel to the same commit (e.g. HEAD, tag, peeled tree-ish).
2254    // If exactly one distinct commit is produced, pick a deterministic representative (t1512).
2255    if peel == "commit" {
2256        let mut by_peeled: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
2257        for oid in &filtered {
2258            if let Ok(c) = apply_peel(repo, *oid, Some("commit")) {
2259                by_peeled.entry(c).or_default().push(*oid);
2260            }
2261        }
2262        if by_peeled.len() == 1 {
2263            let mut reps: Vec<ObjectId> = by_peeled.into_values().next().unwrap_or_default();
2264            reps.sort_by_key(|o| o.to_hex());
2265            if let Some(oid) = reps.first().copied() {
2266                return Ok(oid);
2267            }
2268        }
2269    }
2270    Err(RevParseError::AmbiguousShortObject {
2271        spec: spec.to_owned(),
2272    }
2273    .into())
2274}
2275
2276fn commit_reachable_closure(repo: &Repository, start: ObjectId) -> Result<HashSet<ObjectId>> {
2277    use std::collections::VecDeque;
2278    let mut seen = HashSet::new();
2279    let mut q = VecDeque::from([start]);
2280    while let Some(oid) = q.pop_front() {
2281        if !seen.insert(oid) {
2282            continue;
2283        }
2284        let obj = match repo.read_replaced(&oid) {
2285            Ok(o) => o,
2286            Err(_) => continue,
2287        };
2288        if obj.kind != ObjectKind::Commit {
2289            continue;
2290        }
2291        let commit = match parse_commit(&obj.data) {
2292            Ok(c) => c,
2293            Err(_) => continue,
2294        };
2295        for p in &commit.parents {
2296            q.push_back(*p);
2297        }
2298    }
2299    Ok(seen)
2300}
2301
2302/// `git rev-list --count <tag>..<head>` — commits reachable from `head` but not from `tag`.
2303fn describe_generation_count(
2304    repo: &Repository,
2305    head: ObjectId,
2306    tag_commit: ObjectId,
2307) -> Result<usize> {
2308    let from_tag = commit_reachable_closure(repo, tag_commit)?;
2309    let from_head = commit_reachable_closure(repo, head)?;
2310    Ok(from_head.difference(&from_tag).count())
2311}
2312
2313/// Cheap necessary condition for [`try_resolve_describe_name`]'s pattern: the text after the
2314/// last `-` is `g` followed by hex digits, and another `-` precedes it. Lets plain names like
2315/// `HEAD` or `main` skip compiling the regex.
2316fn may_be_describe_name(spec: &str) -> bool {
2317    let Some((head, tail)) = spec.rsplit_once('-') else {
2318        return false;
2319    };
2320    let Some(hex) = tail.strip_prefix(['g', 'G']) else {
2321        return false;
2322    };
2323    !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()) && head.contains('-')
2324}
2325
2326fn try_resolve_describe_name(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
2327    if !may_be_describe_name(spec) {
2328        return Ok(None);
2329    }
2330    let re = Regex::new(r"(?i)^(.+)-(\d+)-g([0-9a-fA-F]+)$")
2331        .map_err(|_| Error::from(RevParseError::InternalDescribeRegex))?;
2332    let Some(caps) = re.captures(spec) else {
2333        return Ok(None);
2334    };
2335    let tag_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
2336    let gen: usize = caps
2337        .get(2)
2338        .and_then(|m| m.as_str().parse().ok())
2339        .unwrap_or(0);
2340    let hex_abbrev = caps.get(3).map(|m| m.as_str()).unwrap_or("");
2341    if tag_name.is_empty() || hex_abbrev.is_empty() {
2342        return Ok(None);
2343    }
2344    let hex_lower = hex_abbrev.to_ascii_lowercase();
2345    let mut commit_candidates: Vec<ObjectId> = find_abbrev_matches(repo, &hex_lower)?
2346        .into_iter()
2347        .filter(|oid| {
2348            repo.odb()
2349                .read(oid)
2350                .is_ok_and(|o| o.kind == ObjectKind::Commit)
2351        })
2352        .collect();
2353    commit_candidates.sort_by_key(|o| o.to_hex());
2354    commit_candidates.dedup();
2355
2356    if let Ok(tag_oid) = repo
2357        .resolve_ref_oid(&format!("refs/tags/{tag_name}"))
2358        .or_else(|_| repo.resolve_ref_oid(tag_name))
2359    {
2360        let tag_commit = peel_to_commit_for_merge_base(repo, tag_oid)?;
2361        let mut strict_candidates = commit_candidates
2362            .iter()
2363            .copied()
2364            .filter(|oid| describe_generation_count(repo, *oid, tag_commit).ok() == Some(gen))
2365            .collect::<Vec<_>>();
2366        strict_candidates.sort_by_key(|o| o.to_hex());
2367        if strict_candidates.len() == 1 {
2368            return Ok(Some(strict_candidates[0]));
2369        }
2370        if strict_candidates.len() > 1 {
2371            return Err(RevParseError::AmbiguousShortObject {
2372                spec: hex_abbrev.to_owned(),
2373            }
2374            .into());
2375        }
2376    }
2377
2378    match commit_candidates.len() {
2379        0 => Err(Error::ObjectNotFound(spec.to_owned())),
2380        1 => Ok(Some(commit_candidates[0])),
2381        _ => Err(RevParseError::AmbiguousShortObject {
2382            spec: hex_abbrev.to_owned(),
2383        }
2384        .into()),
2385    }
2386}
2387
2388#[expect(clippy::too_many_arguments, clippy::only_used_in_recursion)]
2389fn resolve_base(
2390    repo: &Repository,
2391    spec: &str,
2392    index_dwim: bool,
2393    commit_only_hex: bool,
2394    use_disambiguate_config: bool,
2395    peel_for_disambig: Option<&str>,
2396    implicit_tree_abbrev: bool,
2397    implicit_blob_abbrev: bool,
2398    remote_branch_name_guess: bool,
2399) -> Result<ObjectId> {
2400    // Standalone `@` is an alias for `HEAD` in revision parsing.
2401    if spec == "@" {
2402        return resolve_base(
2403            repo,
2404            "HEAD",
2405            index_dwim,
2406            commit_only_hex,
2407            use_disambiguate_config,
2408            peel_for_disambig,
2409            implicit_tree_abbrev,
2410            implicit_blob_abbrev,
2411            remote_branch_name_guess,
2412        );
2413    }
2414
2415    if spec == "HEAD" {
2416        return match crate::state::resolve_head_for_repository(repo) {
2417            Ok(crate::state::HeadState::Branch { oid: None, .. }) => Err(Error::UnbornHead),
2418            Ok(crate::state::HeadState::Branch { oid: Some(oid), .. }) => Ok(oid),
2419            Ok(crate::state::HeadState::Detached { oid }) => Ok(oid),
2420            Ok(crate::state::HeadState::Invalid) | Err(_) => {
2421                Err(Error::ObjectNotFound(spec.to_owned()))
2422            }
2423        };
2424    }
2425
2426    // `FETCH_HEAD`: prefer the first for-merge line, but allow checkout-style consumers to use
2427    // the first fetched OID even when every line is marked `not-for-merge`.
2428    if spec == "FETCH_HEAD" {
2429        let path = repo.git_dir().join("FETCH_HEAD");
2430        let content = std::fs::read_to_string(&path)
2431            .map_err(|_| Error::ObjectNotFound("FETCH_HEAD".to_owned()))?;
2432        let mut first_oid = None;
2433        for line in content.lines() {
2434            let line = line.trim();
2435            if line.is_empty() {
2436                continue;
2437            }
2438            let mut parts = line.split('\t');
2439            let Some(oid_hex) = parts.next() else {
2440                continue;
2441            };
2442            if ObjectId::is_full_hex(oid_hex) {
2443                let oid = oid_hex
2444                    .parse::<ObjectId>()
2445                    .map_err(|_| Error::InvalidRef("invalid FETCH_HEAD object id".to_owned()))?;
2446                first_oid.get_or_insert(oid);
2447                let not_for_merge = parts.next().is_some_and(|v| v == "not-for-merge");
2448                if !not_for_merge {
2449                    return Ok(oid);
2450                }
2451            }
2452        }
2453        return first_oid.ok_or_else(|| Error::ObjectNotFound("FETCH_HEAD".to_owned()));
2454    }
2455
2456    // `@{-N}` must run before reflog parsing so `@{-1}@{1}` is not misread as `@{-1}` + `@{1}`.
2457    if let Some(after_open) = spec.strip_prefix("@{-") {
2458        if let Some(close) = after_open.find('}') {
2459            let n_str = &after_open[..close];
2460            if let Ok(n) = n_str.parse::<usize>() {
2461                if n >= 1 {
2462                    let suffix = &after_open[close + 1..];
2463                    if suffix.is_empty() {
2464                        if let Some(oid) = try_resolve_at_minus(repo, spec)? {
2465                            return Ok(oid);
2466                        }
2467                    } else {
2468                        let branch = resolve_at_minus_to_branch(repo, n)?;
2469                        let new_spec = format!("{branch}{suffix}");
2470                        return resolve_base(
2471                            repo,
2472                            &new_spec,
2473                            index_dwim,
2474                            commit_only_hex,
2475                            use_disambiguate_config,
2476                            peel_for_disambig,
2477                            implicit_tree_abbrev,
2478                            implicit_blob_abbrev,
2479                            remote_branch_name_guess,
2480                        );
2481                    }
2482                }
2483            }
2484        }
2485    }
2486
2487    // Handle @{upstream} / @{u} / @{push} suffixes (including compounds like branch@{u}@{1})
2488    if upstream_suffix_info(spec).is_some() {
2489        let full_ref = resolve_upstream_symbolic_name(repo, spec)?;
2490        return repo
2491            .resolve_ref_oid(&full_ref)
2492            .map_err(|_| Error::ObjectNotFound(spec.to_owned()));
2493    }
2494
2495    // Reflog selectors: `main@{1}`, `@{3}` (current branch), `other@{u}@{1}`, etc.
2496    if let Some(oid) = try_resolve_reflog_index(repo, spec)? {
2497        return Ok(oid);
2498    }
2499
2500    // Handle `:/pattern` — search commit messages from HEAD
2501    if let Some(pattern) = spec.strip_prefix(":/") {
2502        if !pattern.is_empty() {
2503            return resolve_commit_message_search(repo, pattern);
2504        }
2505    }
2506
2507    // Handle `:N:path` — look up path in the index at stage N
2508    // Also handle `:path` — look up path in the index (stage 0)
2509    if let Some(rest) = spec.strip_prefix(':') {
2510        if !rest.is_empty() && !rest.starts_with('/') {
2511            // Check for :N:path pattern (N is a single digit 0-3)
2512            if rest.len() >= 3 && rest.as_bytes()[1] == b':' {
2513                if let Some(stage_char) = rest.chars().next() {
2514                    if let Some(stage) = stage_char.to_digit(10) {
2515                        if stage <= 3 {
2516                            let raw_path = &rest[2..];
2517                            let path = match normalize_colon_path_for_tree(repo, raw_path) {
2518                                Ok(p) => p,
2519                                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
2520                                    let wt = repo
2521                                        .work_tree()
2522                                        .and_then(|p| p.canonicalize_simplified().ok())
2523                                        .map(|p| p.display().to_string())
2524                                        .unwrap_or_default();
2525                                    return Err(RevParseError::OutsideRepository {
2526                                        path: raw_path.to_owned(),
2527                                        work_tree: wt,
2528                                    }
2529                                    .into());
2530                                }
2531                                Err(e) => return Err(e),
2532                            };
2533                            return resolve_index_path_at_stage(repo, &path, stage as u8).map_err(
2534                                |e| diagnose_index_path_error(repo, &path, stage as u8, e),
2535                            );
2536                        }
2537                    }
2538                }
2539            }
2540            let clean_rest = match normalize_colon_path_for_tree(repo, rest) {
2541                Ok(p) => p,
2542                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
2543                    let wt = repo
2544                        .work_tree()
2545                        .and_then(|p| p.canonicalize_simplified().ok())
2546                        .map(|p| p.display().to_string())
2547                        .unwrap_or_default();
2548                    return Err(RevParseError::OutsideRepository {
2549                        path: rest.to_owned(),
2550                        work_tree: wt,
2551                    }
2552                    .into());
2553                }
2554                Err(e) => return Err(e),
2555            };
2556            return resolve_index_path(repo, &clean_rest)
2557                .map_err(|e| diagnose_index_path_error(repo, &clean_rest, 0, e));
2558        }
2559    }
2560
2561    if let Some((treeish, path)) = split_treeish_spec(spec) {
2562        let root_oid = resolve_revision_impl(
2563            repo,
2564            treeish,
2565            index_dwim,
2566            commit_only_hex,
2567            use_disambiguate_config,
2568            false,
2569            false,
2570            false,
2571            false,
2572        )?;
2573        return resolve_treeish_path_to_object(repo, root_oid, path);
2574    }
2575
2576    if let Ok(oid) = spec.parse::<ObjectId>() {
2577        // A full 40-hex OID is always accepted, even if the object
2578        // doesn't exist in the ODB (matches git behavior).
2579        let rn = format!("refs/heads/{spec}");
2580        if repo.resolve_ref_oid(&rn).is_ok() {
2581            repo.warn(crate::diagnostics::Warning::AmbiguousRefname {
2582                spec: spec.to_owned(),
2583            });
2584        }
2585        return Ok(oid);
2586    }
2587
2588    match try_resolve_describe_name(repo, spec) {
2589        Ok(Some(oid)) => return Ok(oid),
2590        Err(e) => return Err(e),
2591        Ok(None) => {}
2592    }
2593
2594    // Hex-like tokens may name refs (e.g. tag `1.2` / `2.2`) — resolve those before treating the
2595    // string as an abbreviated object id (t5334 incremental MIDX).
2596    if is_hex_prefix(spec) && spec.len() < repo.odb().hash_algo().hex_len() {
2597        let tag_ref = format!("refs/tags/{spec}");
2598        if let Ok(oid) = repo.resolve_ref_oid(&tag_ref) {
2599            warn_if_hex_ref_collides_with_objects(repo, spec, oid);
2600            return Ok(oid);
2601        }
2602        let branch_ref = format!("refs/heads/{spec}");
2603        if let Ok(oid) = repo.resolve_ref_oid(&branch_ref) {
2604            warn_if_hex_ref_collides_with_objects(repo, spec, oid);
2605            return Ok(oid);
2606        }
2607    }
2608
2609    if is_hex_prefix(spec) {
2610        let matches = find_abbrev_matches(repo, spec)?;
2611        if matches.is_empty() {
2612            // Git treats 4+ hex digits as an abbreviated object id lookup first. When nothing
2613            // matches, fail as unknown revision — do not fall through to index DWIM (which would
2614            // incorrectly report "ambiguous argument" for paths like `000000000`).
2615            if (4..repo.odb().hash_algo().hex_len()).contains(&spec.len()) {
2616                return Err(Error::ObjectNotFound(spec.to_owned()));
2617            }
2618        } else if matches.len() == 1 {
2619            let oid = matches[0];
2620            warn_if_branch_refname_collides_with_abbrev_hex(repo, spec, oid);
2621            return Ok(oid);
2622        } else if matches.len() > 1 {
2623            if let Some(p) = peel_for_disambig {
2624                let oid = disambiguate_hex_by_peel(repo, spec, &matches, p)?;
2625                warn_if_branch_refname_collides_with_abbrev_hex(repo, spec, oid);
2626                return Ok(oid);
2627            }
2628            if commit_only_hex {
2629                let oid = disambiguate_hex_by_peel(repo, spec, &matches, "commit")?;
2630                warn_if_branch_refname_collides_with_abbrev_hex(repo, spec, oid);
2631                return Ok(oid);
2632            }
2633            if use_disambiguate_config {
2634                if let Some(pref) = read_core_disambiguate(repo) {
2635                    if let Ok(oid) = disambiguate_hex_by_peel(repo, spec, &matches, pref) {
2636                        warn_if_branch_refname_collides_with_abbrev_hex(repo, spec, oid);
2637                        return Ok(oid);
2638                    }
2639                }
2640            }
2641            return Err(RevParseError::AmbiguousShortObject {
2642                spec: spec.to_owned(),
2643            }
2644            .into());
2645        }
2646    }
2647
2648    let (dwim_count, dwim_oid) = resolve_ref_dwim_for_rev_parse(repo, spec);
2649    if dwim_count > 1 {
2650        repo.warn(crate::diagnostics::Warning::AmbiguousRefname {
2651            spec: spec.to_owned(),
2652        });
2653    }
2654    if let Some(oid) = dwim_oid {
2655        return Ok(oid);
2656    }
2657    // `remotes/<remote>/<ref>` is a common shorthand for `refs/remotes/<remote>/<ref>` (t2024).
2658    if let Some(rest) = spec.strip_prefix("remotes/") {
2659        let full = format!("refs/remotes/{rest}");
2660        if let Ok(oid) = repo.resolve_ref_oid(&full) {
2661            return Ok(oid);
2662        }
2663    }
2664    // Remote name alone (`origin`, `upstream`): resolve like Git via
2665    // `refs/remotes/<name>/HEAD` (symref to the default remote-tracking branch).
2666    // Skip when a local branch with the same short name exists.
2667    if !spec.contains('/')
2668        && !spec.starts_with('.')
2669        && spec != "HEAD"
2670        && spec != "FETCH_HEAD"
2671        && spec != "MERGE_HEAD"
2672        && spec != "CHERRY_PICK_HEAD"
2673        && spec != "REVERT_HEAD"
2674        && spec != "REBASE_HEAD"
2675        && spec != "AUTO_MERGE"
2676        && spec != "stash"
2677    {
2678        let local_branch = format!("refs/heads/{spec}");
2679        if repo.resolve_ref_oid(&local_branch).is_err() {
2680            let remote_head = format!("refs/remotes/{spec}/HEAD");
2681            if let Ok(oid) = repo.resolve_ref_oid(&remote_head) {
2682                return Ok(oid);
2683            }
2684        }
2685    }
2686    // DWIM: bare `stash` refers to `refs/stash` (like upstream Git), not `.git/stash`.
2687    if spec == "stash" {
2688        if let Ok(oid) = repo.resolve_ref_oid("refs/stash") {
2689            return Ok(oid);
2690        }
2691    }
2692    // Short names: resolve `refs/heads/<spec>` and `refs/tags/<spec>`. When both exist and
2693    // disagree, prefer the branch (matches `git checkout` / `git reset` for names like `b1`)
2694    // and warn, matching upstream ambiguous-refname behavior.
2695    let head_ref = format!("refs/heads/{spec}");
2696    let tag_ref = format!("refs/tags/{spec}");
2697    let head_oid = repo.resolve_ref_oid(&head_ref).ok();
2698    let tag_oid = repo.resolve_ref_oid(&tag_ref).ok();
2699    match (head_oid, tag_oid) {
2700        (Some(h), Some(t)) if h != t => {
2701            repo.warn(crate::diagnostics::Warning::AmbiguousRefname {
2702                spec: spec.to_owned(),
2703            });
2704            return Ok(h);
2705        }
2706        (Some(h), _) => return Ok(h),
2707        (None, Some(t)) => return Ok(t),
2708        (None, None) => {}
2709    }
2710
2711    // `rev-parse` / `pack-objects --revs`: when `spec` is a single path component and a ref of
2712    // that basename exists (`refs/tags/A` vs worktree file `A.t`), prefer the ref over index
2713    // DWIM (matches Git; t5332).
2714    if !spec.contains('/')
2715        && !spec.contains(':')
2716        && !spec.starts_with('.')
2717        && spec != "HEAD"
2718        && spec.len() <= 255
2719    {
2720        let mut ref_match: Option<ObjectId> = None;
2721        for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/", "refs/notes/"] {
2722            let full = format!("{prefix}{spec}");
2723            if let Ok(oid) = repo.resolve_ref_oid(&full) {
2724                ref_match = Some(oid);
2725                break;
2726            }
2727        }
2728        if let Some(oid) = ref_match {
2729            return Ok(oid);
2730        }
2731    }
2732    for candidate in &[format!("refs/remotes/{spec}"), format!("refs/notes/{spec}")] {
2733        if let Ok(oid) = repo.resolve_ref_oid(candidate) {
2734            return Ok(oid);
2735        }
2736    }
2737
2738    // `git log one` / `git rev-parse one`: remote name → `refs/remotes/<name>/HEAD` (Git DWIM).
2739    if let Some(head_ref) = remote_tracking_head_symbolic_target(repo, spec) {
2740        if let Ok(oid) = repo.resolve_ref_oid(&head_ref) {
2741            return Ok(oid);
2742        }
2743    }
2744
2745    // DWIM: `checkout B2` when only `refs/remotes/origin/B2` exists (common after `fetch`).
2746    if remote_branch_name_guess
2747        && !spec.contains('/')
2748        && spec != "HEAD"
2749        && spec != "FETCH_HEAD"
2750        && spec != "MERGE_HEAD"
2751    {
2752        const REMOTES: &str = "refs/remotes/";
2753        if let Ok(remote_refs) = refs::list_refs_in(repo, REMOTES) {
2754            let matches: Vec<ObjectId> = remote_refs
2755                .into_iter()
2756                .filter(|(r, _)| {
2757                    r.strip_prefix(REMOTES)
2758                        .is_some_and(|rest| rest == spec || rest.ends_with(&format!("/{spec}")))
2759                })
2760                .map(|(_, oid)| oid)
2761                .collect();
2762            if matches.len() == 1 {
2763                return Ok(matches[0]);
2764            }
2765            if matches.len() > 1 {
2766                return Err(Error::InvalidRef(format!(
2767                    "ambiguous refname '{spec}': matches multiple remote-tracking branches"
2768                )));
2769            }
2770        }
2771    }
2772
2773    if crate::change_id::is_change_id_spec_token(spec) {
2774        return crate::change_id::resolve_commit_by_change_id_prefix(repo, spec);
2775    }
2776
2777    // As a last resort, try resolving as an index path (porcelain / DWIM only).
2778    if !spec.contains(':') && !spec.starts_with('-') {
2779        if index_dwim {
2780            if resolve_index_path(repo, spec).is_ok() {
2781                if repo.work_tree().is_some() {
2782                    return Err(RevParseError::AmbiguousArgument {
2783                        spec: spec.to_owned(),
2784                    }
2785                    .into());
2786                }
2787                return resolve_index_path(repo, spec);
2788            }
2789            return Err(RevParseError::AmbiguousArgument {
2790                spec: spec.to_owned(),
2791            }
2792            .into());
2793        }
2794        return Err(RevParseError::InvalidObjectName {
2795            name: spec.to_owned(),
2796        }
2797        .into());
2798    }
2799    Err(Error::ObjectNotFound(spec.to_owned()))
2800}
2801
2802fn read_reflog_for_repo(repo: &Repository, refname: &str) -> Result<Vec<ReflogEntry>> {
2803    let mut entries = Vec::new();
2804    repo.refs()
2805        .for_each_reflog_entry(refname, false, &mut |entry| {
2806            entries.push(entry.clone());
2807            ControlFlow::Continue(())
2808        })?;
2809    Ok(entries)
2810}
2811
2812/// Resolve `@{-N}` to the branch name (e.g. "side"), not to an OID.
2813fn resolve_at_minus_to_branch(repo: &Repository, n: usize) -> Result<String> {
2814    let entries = read_reflog_for_repo(repo, "HEAD")?;
2815    let mut count = 0usize;
2816    for entry in entries.iter().rev() {
2817        let msg = &entry.message;
2818        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
2819            count += 1;
2820            if count == n {
2821                if let Some(to_pos) = rest.find(" to ") {
2822                    return Ok(rest[..to_pos].to_string());
2823                }
2824            }
2825        }
2826    }
2827    Err(Error::InvalidRef(format!(
2828        "@{{-{n}}}: only {count} checkout(s) in reflog"
2829    )))
2830}
2831
2832/// Try to resolve `@{-N}` syntax — the Nth previously checked out branch.
2833/// Returns the resolved OID if matching, or None if not matching.
2834fn try_resolve_at_minus(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
2835    // Match @{-N} only (no ref prefix)
2836    if !spec.starts_with("@{-") || !spec.ends_with('}') {
2837        return Ok(None);
2838    }
2839    let inner = &spec[3..spec.len() - 1];
2840    let n: usize = match inner.parse() {
2841        Ok(n) if n >= 1 => n,
2842        _ => return Ok(None),
2843    };
2844    // Read HEAD reflog and find the Nth "checkout: moving from X to Y" entry
2845    let entries = read_reflog_for_repo(repo, "HEAD")?;
2846    let mut count = 0usize;
2847    // Iterate newest-first
2848    for entry in entries.iter().rev() {
2849        let msg = &entry.message;
2850        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
2851            count += 1;
2852            if count == n {
2853                if let Some(to_pos) = rest.find(" to ") {
2854                    let from_branch = &rest[..to_pos];
2855                    let ref_name = format!("refs/heads/{from_branch}");
2856                    if let Ok(oid) = repo.resolve_ref_oid(&ref_name) {
2857                        return Ok(Some(oid));
2858                    }
2859                    if let Ok(oid) = from_branch.parse::<ObjectId>() {
2860                        if repo.odb().contains(&oid)? {
2861                            return Ok(Some(oid));
2862                        }
2863                    }
2864                    if is_hex_prefix(from_branch) {
2865                        if let Ok(oid) = resolve_revision_for_range_end(repo, from_branch)
2866                            .and_then(|oid| peel_to_commit_for_merge_base(repo, oid))
2867                        {
2868                            return Ok(Some(oid));
2869                        }
2870                    }
2871                    return Err(Error::InvalidRef(format!(
2872                        "cannot resolve @{{-{n}}}: branch '{}' not found",
2873                        from_branch
2874                    )));
2875                }
2876            }
2877        }
2878    }
2879    Err(Error::InvalidRef(format!(
2880        "@{{-{n}}}: only {count} checkout(s) in reflog"
2881    )))
2882}
2883
2884#[derive(Debug, Clone)]
2885enum AtStep {
2886    Index(usize),
2887    Date(i64),
2888    Upstream,
2889    Push,
2890    Now,
2891}
2892
2893fn try_parse_at_step_inner(
2894    inner: &str,
2895    now_sec: i64,
2896) -> std::result::Result<AtStep, RevParseError> {
2897    if inner.eq_ignore_ascii_case("u") || inner.eq_ignore_ascii_case("upstream") {
2898        return Ok(AtStep::Upstream);
2899    }
2900    if inner.eq_ignore_ascii_case("push") {
2901        return Ok(AtStep::Push);
2902    }
2903    if inner.eq_ignore_ascii_case("now") {
2904        return Ok(AtStep::Now);
2905    }
2906    if let Ok(n) = inner.parse::<usize>() {
2907        return Ok(AtStep::Index(n));
2908    }
2909    approxidate_at(inner, now_sec).map_or_else(
2910        || {
2911            Err(RevParseError::ReflogDateUnparseable {
2912                date: inner.to_owned(),
2913            })
2914        },
2915        |ts| Ok(AtStep::Date(ts)),
2916    )
2917}
2918
2919fn next_reflog_at_open(spec: &str, mut from: usize) -> Option<usize> {
2920    let b = spec.as_bytes();
2921    while let Some(rel) = spec[from..].find("@{") {
2922        let i = from + rel;
2923        // `@{-N}` is previous-branch syntax, not a reflog selector — skip the whole token.
2924        if b.get(i + 2) == Some(&b'-') {
2925            let after_open = i + 2;
2926            let close = spec[after_open..].find('}').map(|j| after_open + j)?;
2927            from = close + 1;
2928            continue;
2929        }
2930        return Some(i);
2931    }
2932    None
2933}
2934
2935/// Split `spec` into a ref prefix and a chain of `@{...}` steps (empty chain → not a reflog form).
2936fn split_reflog_at_chain(spec: &str, now_sec: i64) -> Result<Option<(String, Vec<AtStep>)>> {
2937    let Some(at) = next_reflog_at_open(spec, 0) else {
2938        return Ok(None);
2939    };
2940    let prefix = spec[..at].to_owned();
2941    let mut steps = Vec::new();
2942    let mut pos = at;
2943    while pos < spec.len() {
2944        let rest = &spec[pos..];
2945        if !rest.starts_with("@{") {
2946            return Ok(None);
2947        }
2948        if rest.as_bytes().get(2) == Some(&b'-') {
2949            return Ok(None);
2950        }
2951        let inner_start = pos + 2;
2952        let Some(close_rel) = spec[inner_start..].find('}') else {
2953            return Ok(None);
2954        };
2955        let close = inner_start + close_rel;
2956        let inner = &spec[inner_start..close];
2957        let step = try_parse_at_step_inner(inner, now_sec).map_err(Error::from)?;
2958        steps.push(step);
2959        pos = close + 1;
2960    }
2961    if steps.is_empty() {
2962        return Ok(None);
2963    }
2964    Ok(Some((prefix, steps)))
2965}
2966
2967fn dwim_refname(repo: &Repository, raw: &str) -> String {
2968    if raw.is_empty() || raw == "HEAD" || raw.starts_with("refs/") {
2969        return raw.to_owned();
2970    }
2971    // Bare `stash` is `refs/stash` (not `refs/heads/stash`); reflog lives at `logs/refs/stash`.
2972    if raw == "stash" && repo.resolve_ref_oid("refs/stash").is_ok() {
2973        return "refs/stash".to_owned();
2974    }
2975    let candidate = format!("refs/heads/{raw}");
2976    if repo.resolve_ref_oid(&candidate).is_ok() {
2977        candidate
2978    } else {
2979        raw.to_owned()
2980    }
2981}
2982
2983fn reflog_display_name(refname_raw: &str, refname: &str) -> String {
2984    if refname_raw.is_empty() {
2985        if let Some(b) = refname.strip_prefix("refs/heads/") {
2986            return b.to_owned();
2987        }
2988        return refname.to_owned();
2989    }
2990    refname_raw.to_owned()
2991}
2992
2993fn resolve_reflog_oid(
2994    repo: &Repository,
2995    refname: &str,
2996    refname_raw: &str,
2997    index_or_date: ReflogSelector,
2998) -> Result<ObjectId> {
2999    let mut entries = read_reflog_for_repo(repo, refname)?;
3000    if refname == "HEAD" {
3001        if let ReflogSelector::Index(index) = index_or_date {
3002            if index >= entries.len() {
3003                if let Ok(Some(RawRef::Symbolic(branch_ref))) = repo.refs().read_raw("HEAD") {
3004                    if let Ok(branch_entries) = read_reflog_for_repo(repo, &branch_ref) {
3005                        if index < branch_entries.len() {
3006                            entries = branch_entries;
3007                        }
3008                    }
3009                }
3010            }
3011        }
3012    }
3013    let display = reflog_display_name(refname_raw, refname);
3014    match index_or_date {
3015        ReflogSelector::Index(index) => {
3016            let len = entries.len();
3017            if index == 0 {
3018                if len == 0 {
3019                    // Git's `repo_dwim_log` only treats `ref@{N}` as resolvable when the
3020                    // reflog file actually exists; with no reflog at all the resolution
3021                    // fails. Only when the reflog exists but is empty does `ref@{0}` fall
3022                    // back to the ref's current value (the `nth == co_cnt` case in
3023                    // object-name.c).
3024                    if !repo.refs().reflog_exists(refname).unwrap_or(false) {
3025                        return Err(RevParseError::ReflogEmpty {
3026                            ref_display: display.clone(),
3027                        }
3028                        .into());
3029                    }
3030                    return repo.resolve_ref_oid(refname).map_err(|_| {
3031                        RevParseError::ReflogEmpty {
3032                            ref_display: display.clone(),
3033                        }
3034                        .into()
3035                    });
3036                }
3037                return Ok(entries[len - 1].new_oid);
3038            }
3039            if len == 0 {
3040                return Err(RevParseError::ReflogEmpty {
3041                    ref_display: display.clone(),
3042                }
3043                .into());
3044            }
3045            if index > len {
3046                return Err(RevParseError::ReflogInsufficientEntries {
3047                    ref_display: display.clone(),
3048                    available: len,
3049                }
3050                .into());
3051            }
3052            let oid = entries[len - index].old_oid;
3053            if oid.is_zero() {
3054                return Err(RevParseError::ReflogInsufficientEntries {
3055                    ref_display: display.clone(),
3056                    available: len,
3057                }
3058                .into());
3059            }
3060            Ok(oid)
3061        }
3062        ReflogSelector::Date(target_ts) => {
3063            if entries.is_empty() {
3064                return Err(RevParseError::ReflogEmpty {
3065                    ref_display: display.clone(),
3066                }
3067                .into());
3068            }
3069            for entry in entries.iter().rev() {
3070                let ts = parse_reflog_entry_timestamp(entry);
3071                if let Some(t) = ts {
3072                    if t <= target_ts {
3073                        return Ok(entry.new_oid);
3074                    }
3075                }
3076            }
3077            let oldest = &entries[0];
3078            let oid = if oldest.old_oid.is_zero() {
3079                oldest.new_oid
3080            } else {
3081                oldest.old_oid
3082            };
3083            repo.warn(crate::diagnostics::Warning::ReflogDateBeforeOldestEntry {
3084                ref_display: display.clone(),
3085                cutoff_sec: target_ts,
3086            });
3087            Ok(oid)
3088        }
3089    }
3090}
3091
3092fn resolve_at_minus_token_to_branch(repo: &Repository, token: &str) -> Result<Option<String>> {
3093    if !token.starts_with("@{-") || !token.ends_with('}') {
3094        return Ok(None);
3095    }
3096    let inner = &token[3..token.len() - 1];
3097    let n: usize = inner
3098        .parse()
3099        .map_err(|_| Error::InvalidRef(format!("invalid N in @{{-N}} for '{token}'")))?;
3100    if n < 1 {
3101        return Ok(None);
3102    }
3103    Ok(Some(resolve_at_minus_to_branch(repo, n)?))
3104}
3105
3106/// Ref whose reflog `git log -g` should walk for a revision like `other@{u}` or `main@{1}`.
3107///
3108/// Returns `None` when `spec` is not a reflog-chain form (no `@{` step after the prefix).
3109pub fn reflog_walk_refname(repo: &Repository, spec: &str) -> Result<Option<String>> {
3110    let now_sec = repo.wall_clock_epoch();
3111    let Some((prefix, steps)) = split_reflog_at_chain(spec, now_sec)? else {
3112        return Ok(None);
3113    };
3114
3115    let prefix_resolved = if let Some(b) = resolve_at_minus_token_to_branch(repo, &prefix)? {
3116        b
3117    } else {
3118        prefix.clone()
3119    };
3120
3121    let mut current_spec = if prefix_resolved.is_empty() {
3122        if let Ok(Some(b)) = refs::read_symbolic_ref_for_repository(repo, "HEAD") {
3123            if let Some(short) = b.strip_prefix("refs/heads/") {
3124                short.to_owned()
3125            } else {
3126                "HEAD".to_owned()
3127            }
3128        } else {
3129            "HEAD".to_owned()
3130        }
3131    } else {
3132        prefix_resolved
3133    };
3134
3135    let last_reflog_peel = steps
3136        .iter()
3137        .rposition(|s| matches!(s, AtStep::Index(_) | AtStep::Date(_) | AtStep::Now));
3138
3139    let limit = last_reflog_peel.unwrap_or(steps.len());
3140    for step in steps.iter().take(limit) {
3141        match step {
3142            AtStep::Upstream => {
3143                let base = if current_spec == "@" {
3144                    "HEAD"
3145                } else {
3146                    current_spec.as_str()
3147                };
3148                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{u}}"))?;
3149                current_spec = full;
3150            }
3151            AtStep::Push => {
3152                let base = if current_spec == "@" {
3153                    "HEAD"
3154                } else {
3155                    current_spec.as_str()
3156                };
3157                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{push}}"))?;
3158                current_spec = full;
3159            }
3160            AtStep::Now | AtStep::Index(_) | AtStep::Date(_) => {}
3161        }
3162    }
3163
3164    Ok(Some(dwim_refname(repo, current_spec.as_str())))
3165}
3166
3167/// Resolve a user revision string to the reflog file ref name for `log -g` / `rev-list -g`.
3168///
3169/// Mirrors Git `add_reflog_for_walk` / `read_complete_reflog` ref resolution before reading
3170/// `logs/<ref>`.
3171pub fn resolve_reflog_walk_log_ref(repo: &Repository, r: &str) -> Result<String> {
3172    if let Ok(Some(w)) = reflog_walk_refname(repo, r) {
3173        return Ok(w);
3174    }
3175    if r == "HEAD" || r.starts_with("refs/") {
3176        return Ok(r.to_string());
3177    }
3178    if r.starts_with("@{") {
3179        if let Ok(Some(branch)) = expand_at_minus_to_branch_name(repo, r) {
3180            return Ok(format!("refs/heads/{branch}"));
3181        }
3182        return Ok(r.to_string());
3183    }
3184    let candidate = format!("refs/heads/{r}");
3185    if repo.resolve_ref_oid(&candidate).is_ok() {
3186        Ok(candidate)
3187    } else {
3188        Ok(r.to_string())
3189    }
3190}
3191
3192/// Try to resolve `ref@{...}` with optional chained `@{...}` steps (e.g. `other@{u}@{1}`).
3193fn try_resolve_reflog_index(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
3194    let now_sec = repo.wall_clock_epoch();
3195    let Some((prefix, steps)) = split_reflog_at_chain(spec, now_sec)? else {
3196        return Ok(None);
3197    };
3198
3199    let prefix_resolved = if let Some(b) = resolve_at_minus_token_to_branch(repo, &prefix)? {
3200        b
3201    } else {
3202        prefix.clone()
3203    };
3204
3205    let mut current_spec = if prefix_resolved.is_empty() {
3206        if let Ok(Some(b)) = refs::read_symbolic_ref_for_repository(repo, "HEAD") {
3207            if let Some(short) = b.strip_prefix("refs/heads/") {
3208                short.to_owned()
3209            } else {
3210                "HEAD".to_owned()
3211            }
3212        } else {
3213            "HEAD".to_owned()
3214        }
3215    } else {
3216        prefix_resolved
3217    };
3218
3219    for (i, step) in steps.iter().enumerate() {
3220        match step {
3221            AtStep::Upstream => {
3222                let base = if current_spec == "@" {
3223                    "HEAD"
3224                } else {
3225                    current_spec.as_str()
3226                };
3227                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{u}}"))?;
3228                current_spec = full;
3229            }
3230            AtStep::Push => {
3231                let base = if current_spec == "@" {
3232                    "HEAD"
3233                } else {
3234                    current_spec.as_str()
3235                };
3236                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{push}}"))?;
3237                current_spec = full;
3238            }
3239            AtStep::Now => {
3240                let refname_raw = current_spec.as_str();
3241                let refname = dwim_refname(repo, refname_raw);
3242                let oid =
3243                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Index(0))?;
3244                if i + 1 == steps.len() {
3245                    return Ok(Some(oid));
3246                }
3247                current_spec = oid.to_hex();
3248            }
3249            AtStep::Index(n) => {
3250                let refname_raw = current_spec.as_str();
3251                let refname = dwim_refname(repo, refname_raw);
3252                let oid =
3253                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Index(*n))?;
3254                if i + 1 == steps.len() {
3255                    return Ok(Some(oid));
3256                }
3257                current_spec = oid.to_hex();
3258            }
3259            AtStep::Date(ts) => {
3260                let refname_raw = current_spec.as_str();
3261                let refname = dwim_refname(repo, refname_raw);
3262                let oid =
3263                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Date(*ts))?;
3264                if i + 1 == steps.len() {
3265                    return Ok(Some(oid));
3266                }
3267                current_spec = oid.to_hex();
3268            }
3269        }
3270    }
3271
3272    let refname_raw = current_spec.as_str();
3273    let refname = dwim_refname(repo, refname_raw);
3274    repo.resolve_ref_oid(&refname)
3275        .map(Some)
3276        .map_err(|_| Error::ObjectNotFound(spec.to_owned()))
3277}
3278
3279enum ReflogSelector {
3280    Index(usize),
3281    Date(i64),
3282}
3283
3284/// Parse a timestamp from a reflog entry's identity string.
3285fn parse_reflog_entry_timestamp(entry: &crate::reflog::ReflogEntry) -> Option<i64> {
3286    // Identity looks like: "Name <email> 1234567890 +0000"
3287    let parts: Vec<&str> = entry.identity.rsplitn(3, ' ').collect();
3288    if parts.len() >= 2 {
3289        parts[1].parse::<i64>().ok()
3290    } else {
3291        None
3292    }
3293}
3294
3295/// Parse a reflog date selector string (e.g. `yesterday`, `2005-04-07`) to a Unix timestamp.
3296///
3297/// Used by `git log -g` display to match Git's `ref@{date}` formatting in tests.
3298#[must_use]
3299pub fn reflog_date_selector_timestamp(s: &str) -> Option<i64> {
3300    reflog_date_selector_timestamp_at(s, crate::git_date::tm::process_wall_clock_sec())
3301}
3302
3303/// Like [`reflog_date_selector_timestamp`] but uses `now_sec` for relative fields.
3304pub fn reflog_date_selector_timestamp_at(s: &str, now_sec: i64) -> Option<i64> {
3305    approxidate_at(s, now_sec)
3306}
3307
3308/// Approximate date parser for reflog `@{date}` selectors (same grammar as `--since` / `--until`).
3309fn approxidate_at(s: &str, now_ts: i64) -> Option<i64> {
3310    let trimmed = s.trim();
3311    if trimmed.eq_ignore_ascii_case("now") {
3312        // Match Git's test harness: `test_tick` sets GIT_COMMITTER_DATE; `@{now}` must use that
3313        // clock, not wall time (t1507 `log -g other@{u}@{now}`).
3314        if let Some(raw) = crate::environment::Environment::empty()
3315            .var("GIT_COMMITTER_DATE")
3316            .or_else(|| crate::environment::Environment::empty().var("GIT_AUTHOR_DATE"))
3317        {
3318            let mut it = raw.split_whitespace();
3319            if let Some(ts) = it.next().and_then(|p| p.parse::<i64>().ok()) {
3320                return Some(ts);
3321            }
3322        }
3323        return Some(now_ts);
3324    }
3325    crate::git_date::parse_date_cutoff(trimmed, now_ts).ok()
3326}
3327
3328fn head_tree_oid(repo: &Repository) -> Result<ObjectId> {
3329    let head_oid = repo.resolve_ref_oid("HEAD")?;
3330    peel_to_tree(repo, head_oid)
3331}
3332
3333fn path_in_tree(repo: &Repository, tree_oid: ObjectId, path: &str) -> bool {
3334    resolve_tree_path(repo, &tree_oid, path).is_ok()
3335}
3336
3337fn path_in_index(repo: &Repository, path: &str, stage: u8) -> bool {
3338    resolve_index_path_at_stage(repo, path, stage).is_ok()
3339}
3340
3341fn diagnose_tree_path_error(
3342    repo: &Repository,
3343    rev_label: &str,
3344    raw_after_colon: &str,
3345    clean_path: &str,
3346    err: TreePathError,
3347) -> Error {
3348    if let TreePathError::Other(err) = err {
3349        return err;
3350    }
3351    let rel_display: &str =
3352        if raw_after_colon.starts_with("./") || raw_after_colon.starts_with("../") {
3353            clean_path
3354        } else {
3355            raw_after_colon
3356        };
3357    if let Ok(head_tree) = head_tree_oid(repo) {
3358        if path_in_tree(repo, head_tree, clean_path) {
3359            return RevParseError::PathOnDiskNotInRevision {
3360                path: rel_display.to_owned(),
3361                revision: rev_label.to_owned(),
3362            }
3363            .into();
3364        }
3365        {
3366            let cwd = repo.environment().cwd.clone();
3367            let prefix = show_prefix(repo, &cwd);
3368            let pfx = prefix.trim_end_matches('/');
3369            if !pfx.is_empty() {
3370                let candidate = if clean_path.is_empty() {
3371                    pfx.to_owned()
3372                } else {
3373                    format!("{pfx}/{clean_path}")
3374                };
3375                if path_in_tree(repo, head_tree, &candidate) {
3376                    let alternate_spec = Some(format!("{rev_label}:{candidate}"));
3377                    return RevParseError::PathPrefixExists {
3378                        candidate,
3379                        path: rel_display.to_owned(),
3380                        alternate_spec,
3381                    }
3382                    .into();
3383                }
3384            }
3385        }
3386        let on_disk = repo
3387            .work_tree()
3388            .map(|wt| wt.join(clean_path))
3389            .is_some_and(|p| p.exists());
3390        let in_index = path_in_index(repo, clean_path, 0);
3391        if on_disk || in_index {
3392            return RevParseError::PathOnDiskNotInRevision {
3393                path: rel_display.to_owned(),
3394                revision: rev_label.to_owned(),
3395            }
3396            .into();
3397        }
3398    }
3399    RevParseError::PathNotInRevision {
3400        path: rel_display.to_owned(),
3401        revision: rev_label.to_owned(),
3402    }
3403    .into()
3404}
3405
3406fn diagnose_index_path_error(repo: &Repository, path: &str, stage: u8, err: Error) -> Error {
3407    let Error::ObjectNotFound(_) = err else {
3408        return err;
3409    };
3410    let work_path = repo
3411        .work_tree()
3412        .map(|wt| wt.join(path))
3413        .filter(|p| p.exists());
3414    let on_disk = work_path.is_some();
3415    let in_head = head_tree_oid(repo)
3416        .map(|t| path_in_tree(repo, t, path))
3417        .unwrap_or(false);
3418    let in_index = path_in_index(repo, path, 0);
3419    let at_stage = path_in_index(repo, path, stage);
3420
3421    if stage > 0 && !in_index {
3422        {
3423            let cwd = repo.environment().cwd.clone();
3424            let prefix = show_prefix(repo, &cwd);
3425            let pfx = prefix.trim_end_matches('/');
3426            if !pfx.is_empty() {
3427                let candidate = if path.is_empty() {
3428                    pfx.to_owned()
3429                } else {
3430                    format!("{pfx}/{path}")
3431                };
3432                if path_in_index(repo, &candidate, 0) && !path_in_index(repo, &candidate, stage) {
3433                    let alternate_spec = Some(format!(":0:{candidate}"));
3434                    return RevParseError::PathIndexPrefixMismatch {
3435                        candidate,
3436                        path: path.to_owned(),
3437                        alternate_spec,
3438                    }
3439                    .into();
3440                }
3441            }
3442        }
3443        return RevParseError::PathMissingFromWorktreeAndIndex {
3444            path: path.to_owned(),
3445        }
3446        .into();
3447    }
3448
3449    if stage > 0 && in_index && !at_stage {
3450        return RevParseError::PathWrongIndexStage {
3451            path: path.to_owned(),
3452            stage,
3453            alternate_spec: Some(format!(":0:{path}")),
3454        }
3455        .into();
3456    }
3457
3458    if stage == 0 {
3459        if !on_disk && !in_index {
3460            {
3461                let cwd = repo.environment().cwd.clone();
3462                let prefix = show_prefix(repo, &cwd);
3463                let pfx = prefix.trim_end_matches('/');
3464                if !pfx.is_empty() {
3465                    let candidate = if path.is_empty() {
3466                        pfx.to_owned()
3467                    } else {
3468                        format!("{pfx}/{path}")
3469                    };
3470                    if path_in_index(repo, &candidate, 0) {
3471                        let alternate_spec = Some(format!(":0:{candidate}"));
3472                        return RevParseError::PathIndexPrefixMismatch {
3473                            candidate,
3474                            path: path.to_owned(),
3475                            alternate_spec,
3476                        }
3477                        .into();
3478                    }
3479                }
3480            }
3481            return RevParseError::PathMissingFromWorktreeAndIndex {
3482                path: path.to_owned(),
3483            }
3484            .into();
3485        }
3486        if on_disk && !in_index && !in_head {
3487            return RevParseError::PathOnDiskNotInIndex {
3488                path: path.to_owned(),
3489            }
3490            .into();
3491        }
3492    }
3493    RevParseError::PathNotInIndex {
3494        path: path.to_owned(),
3495    }
3496    .into()
3497}
3498
3499/// Look up a path in the index (stage 0) and return its OID.
3500fn resolve_index_path(repo: &Repository, path: &str) -> Result<ObjectId> {
3501    resolve_index_path_at_stage(repo, path, 0)
3502}
3503
3504/// Parsed `:path` / `:N:path` index revision syntax (leading colon, not `:/search`).
3505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3506pub struct IndexColonSpec<'a> {
3507    /// Merge stage (`0` for normal entries, `1`–`3` for unmerged stages).
3508    pub stage: u8,
3509    /// Path segment before normalization against the work tree.
3510    pub raw_path: &'a str,
3511}
3512
3513/// If `spec` uses Git's index-only revision form (`:file`, `:0:file`, …), returns the stage and path segment.
3514///
3515/// Returns [`None`] for non-index forms such as `HEAD:file`, bare OIDs, or `:/message` search.
3516#[must_use]
3517pub fn parse_index_colon_spec(spec: &str) -> Option<IndexColonSpec<'_>> {
3518    if !spec.starts_with(':') || spec.starts_with(":/") || spec.len() <= 1 {
3519        return None;
3520    }
3521    let rest = &spec[1..];
3522    if rest.is_empty() {
3523        return None;
3524    }
3525    if rest.len() >= 3 && rest.as_bytes()[1] == b':' {
3526        if let Some(stage_char) = rest.chars().next() {
3527            if let Some(stage) = stage_char.to_digit(10) {
3528                if stage <= 3 {
3529                    return Some(IndexColonSpec {
3530                        stage: stage as u8,
3531                        raw_path: &rest[2..],
3532                    });
3533                }
3534            }
3535        }
3536    }
3537    Some(IndexColonSpec {
3538        stage: 0,
3539        raw_path: rest,
3540    })
3541}
3542
3543/// One index entry resolved from a `:path` / `:N:path` revision string.
3544#[derive(Debug, Clone, PartialEq, Eq)]
3545pub struct IndexPathEntry {
3546    /// Repository-relative path using `/` separators (normalized from the spec).
3547    pub path: String,
3548    /// Blob OID stored for this index entry.
3549    pub oid: ObjectId,
3550    /// Index entry mode (e.g. `0o100644`).
3551    pub mode: u32,
3552}
3553
3554/// Resolve an index revision string (`:file` or `:N:file`) to the staged entry's path, OID, and mode.
3555///
3556/// # Returns
3557///
3558/// - `Ok(None)` if `spec` is not `:path` index syntax.
3559/// - `Ok(Some(entry))` on success.
3560/// - `Err` if the syntax matches but the path is invalid or missing from the index.
3561pub fn resolve_index_path_entry(repo: &Repository, spec: &str) -> Result<Option<IndexPathEntry>> {
3562    let Some(colon) = parse_index_colon_spec(spec) else {
3563        return Ok(None);
3564    };
3565    let path = match normalize_colon_path_for_tree(repo, colon.raw_path) {
3566        Ok(p) => p,
3567        Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
3568            let wt = repo
3569                .work_tree()
3570                .and_then(|p| p.canonicalize_simplified().ok())
3571                .map(|p| p.display().to_string())
3572                .unwrap_or_default();
3573            return Err(RevParseError::OutsideRepository {
3574                path: colon.raw_path.to_owned(),
3575                work_tree: wt,
3576            }
3577            .into());
3578        }
3579        Err(e) => return Err(e),
3580    };
3581    let index_path = if let Some(raw) = repo.environment().var("GIT_INDEX_FILE") {
3582        let p = std::path::PathBuf::from(raw);
3583        if p.is_absolute() {
3584            p
3585        } else {
3586            let cwd = repo.environment().cwd.clone();
3587            cwd.join(p)
3588        }
3589    } else {
3590        repo.index_path()
3591    };
3592    use crate::index::Index;
3593    let config = repo.config()?;
3594    let index = Index::load_expand_sparse_with_config(&index_path, repo.odb(), &config)
3595        .map_err(|_| Error::ObjectNotFound(format!(":{}:{}", colon.stage, path)))?;
3596    let entry = index
3597        .get(path.as_bytes(), colon.stage)
3598        .ok_or_else(|| Error::ObjectNotFound(format!(":{}:{}", colon.stage, path)))?;
3599    Ok(Some(IndexPathEntry {
3600        path,
3601        oid: entry.oid,
3602        mode: entry.mode,
3603    }))
3604}
3605
3606/// Look up a path in the index at a given stage and return its OID.
3607fn resolve_index_path_at_stage(repo: &Repository, path: &str, stage: u8) -> Result<ObjectId> {
3608    use crate::index::Index;
3609    let index_path = if let Some(raw) = repo.environment().var("GIT_INDEX_FILE") {
3610        let p = std::path::PathBuf::from(raw);
3611        if p.is_absolute() {
3612            p
3613        } else {
3614            let cwd = repo.environment().cwd.clone();
3615            cwd.join(p)
3616        }
3617    } else {
3618        repo.index_path()
3619    };
3620    let config = repo.config()?;
3621    let index = Index::load_expand_sparse_with_config(&index_path, repo.odb(), &config)
3622        .map_err(|_| Error::ObjectNotFound(format!(":{stage}:{path}")))?;
3623    let lookup_path = if repo.pathspec_precompose_enabled() {
3624        crate::unicode_normalization::precompose_utf8_path(path).into_owned()
3625    } else {
3626        path.to_owned()
3627    };
3628    match index.get(lookup_path.as_bytes(), stage) {
3629        Some(entry) => Ok(entry.oid),
3630        None => Err(Error::ObjectNotFound(format!(":{stage}:{path}"))),
3631    }
3632}
3633
3634/// Split `treeish:path` at the first colon that separates a revision from a path,
3635/// ignoring colons inside `^{...}` peel operators.
3636///
3637/// Returns [`None`] for index-only forms like `:path` and `:N:path` (leading `:`).
3638pub fn split_treeish_colon(spec: &str) -> Option<(&str, &str)> {
3639    if spec.starts_with(':') {
3640        return None;
3641    }
3642    let bytes = spec.as_bytes();
3643    let mut i = 0usize;
3644    let mut peel_depth = 0usize;
3645    while i < bytes.len() {
3646        if i + 1 < bytes.len() && bytes[i] == b'^' && bytes[i + 1] == b'{' {
3647            peel_depth += 1;
3648            i += 2;
3649            continue;
3650        }
3651        if peel_depth > 0 {
3652            if bytes[i] == b'}' {
3653                peel_depth -= 1;
3654            }
3655            i += 1;
3656            continue;
3657        }
3658        if bytes[i] == b':' && i > 0 {
3659            let before = &spec[..i];
3660            let after = &spec[i + 1..];
3661            if !before.is_empty() {
3662                return Some((before, after)); // after may be empty ("HEAD:" = root tree)
3663            }
3664        }
3665        i += 1;
3666    }
3667    None
3668}
3669
3670pub(crate) fn split_treeish_spec(spec: &str) -> Option<(&str, &str)> {
3671    split_treeish_colon(spec)
3672}
3673
3674/// Resolve `treeish:path` to the object at `path` (blob, tree, or gitlink OID at the leaf).
3675///
3676/// Unlike [`walk_tree_to_blob_entry`], the final path component may name a tree (Git `rev-parse`).
3677pub(crate) fn resolve_treeish_path_to_object(
3678    repo: &Repository,
3679    treeish: ObjectId,
3680    path: &str,
3681) -> Result<ObjectId> {
3682    let object = repo.read_replaced(&treeish)?;
3683    let mut current_tree = match object.kind {
3684        ObjectKind::Commit => parse_commit(&object.data)?.tree,
3685        ObjectKind::Tree => treeish,
3686        _ => {
3687            return Err(Error::InvalidRef(format!(
3688                "object {treeish} does not name a tree"
3689            )))
3690        }
3691    };
3692
3693    let parts_vec: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
3694    if parts_vec.is_empty() {
3695        return Ok(current_tree);
3696    }
3697    for (idx, part) in parts_vec.iter().enumerate() {
3698        let tree_object = repo.read_replaced(&current_tree)?;
3699        if tree_object.kind != ObjectKind::Tree {
3700            return Err(Error::CorruptObject(format!(
3701                "object {current_tree} is not a tree"
3702            )));
3703        }
3704        let entries = parse_tree(&tree_object.data)?;
3705        let Some(entry) = entries.iter().find(|entry| entry.name == part.as_bytes()) else {
3706            return Err(Error::ObjectNotFound(path.to_owned()));
3707        };
3708        if idx + 1 == parts_vec.len() {
3709            return Ok(entry.oid);
3710        }
3711        if entry.mode != crate::index::MODE_TREE {
3712            return Err(Error::ObjectNotFound(path.to_owned()));
3713        }
3714        current_tree = entry.oid;
3715    }
3716
3717    Err(Error::ObjectNotFound(path.to_owned()))
3718}
3719
3720pub(crate) fn resolve_treeish_path(
3721    repo: &Repository,
3722    treeish: ObjectId,
3723    path: &str,
3724) -> Result<ObjectId> {
3725    resolve_treeish_path_to_object(repo, treeish, path)
3726}
3727
3728fn apply_peel(repo: &Repository, mut oid: ObjectId, peel: Option<&str>) -> Result<ObjectId> {
3729    match peel {
3730        None => Ok(oid),
3731        Some(search) if search.starts_with('/') => {
3732            let pattern = &search[1..];
3733            if pattern.is_empty() {
3734                return Err(Error::InvalidRef(
3735                    "empty commit message search pattern".to_owned(),
3736                ));
3737            }
3738            resolve_commit_message_search_from(repo, oid, pattern)
3739        }
3740        Some("") => {
3741            while let Ok(obj) = repo.read_replaced(&oid) {
3742                if obj.kind != ObjectKind::Tag {
3743                    break;
3744                }
3745                oid = parse_tag_target(&obj.data)?;
3746            }
3747            Ok(oid)
3748        }
3749        Some("commit") => {
3750            oid = apply_peel(repo, oid, Some(""))?;
3751            let obj = repo.read_replaced(&oid)?;
3752            if obj.kind == ObjectKind::Commit {
3753                Ok(oid)
3754            } else {
3755                Err(Error::InvalidRef("expected commit".to_owned()))
3756            }
3757        }
3758        Some("tree") => {
3759            // Peel tags, then dereference a commit to its tree.
3760            oid = apply_peel(repo, oid, Some(""))?;
3761            let obj = repo.read_replaced(&oid)?;
3762            match obj.kind {
3763                ObjectKind::Tree => Ok(oid),
3764                ObjectKind::Commit => Ok(parse_commit(&obj.data)?.tree),
3765                _ => Err(Error::InvalidRef("expected tree or commit".to_owned())),
3766            }
3767        }
3768        Some("blob") => {
3769            // ^{blob}: peel tags until we reach a blob
3770            let mut cur = oid;
3771            loop {
3772                let obj = repo.read_replaced(&cur)?;
3773                match obj.kind {
3774                    ObjectKind::Blob => return Ok(cur),
3775                    ObjectKind::Tag => {
3776                        cur = parse_tag_target(&obj.data)?;
3777                    }
3778                    _ => return Err(Error::InvalidRef("expected blob".to_owned())),
3779                }
3780            }
3781        }
3782        Some("object") => Ok(oid),
3783        Some("tag") => {
3784            // ^{tag}: return if it's a tag object
3785            let obj = repo.read_replaced(&oid)?;
3786            if obj.kind == ObjectKind::Tag {
3787                Ok(oid)
3788            } else {
3789                Err(Error::InvalidRef("expected tag".to_owned()))
3790            }
3791        }
3792        Some(other) => Err(Error::InvalidRef(format!(
3793            "unsupported peel operator '{{{other}}}'"
3794        ))),
3795    }
3796}
3797
3798/// Expand a single revision token that ends with `^!` (Git: commit without its parents).
3799///
3800/// Returns one token unchanged when `^!` is absent. When present, returns the base revision
3801/// (without `^!`) plus one `^<parent-hex>` entry per parent from [`commit_parents_for_navigation`]
3802/// (commit object parents plus graft/replace overrides), matching Git’s `^!` expansion for
3803/// merge commits.
3804///
3805/// # Errors
3806///
3807/// Returns [`RevParseError::AmbiguousArgument`] for an empty base revision and other resolution failures.
3808pub fn expand_rev_token_circ_bang(repo: &Repository, token: &str) -> Result<Vec<String>> {
3809    let Some(base) = token.strip_suffix("^!") else {
3810        return Ok(vec![token.to_owned()]);
3811    };
3812    if base.is_empty() {
3813        return Err(RevParseError::AmbiguousArgument {
3814            spec: token.to_owned(),
3815        }
3816        .into());
3817    }
3818    let oid = resolve_revision_for_range_end(repo, base)?;
3819    let commit_oid = peel_to_commit_for_merge_base(repo, oid)?;
3820    let parents = commit_parents_for_navigation(repo, commit_oid)?;
3821    let mut out = vec![base.to_owned()];
3822    for p in parents {
3823        out.push(format!("^{}", p.to_hex()));
3824    }
3825    Ok(out)
3826}
3827
3828/// Split `spec` into `(base, peel_inner)` for `^{...}` / `^0` suffixes (same rules as revision parsing).
3829#[must_use]
3830pub fn parse_peel_suffix(spec: &str) -> (&str, Option<&str>) {
3831    if let Some(base) = spec.strip_suffix("^{}") {
3832        return (base, Some(""));
3833    }
3834    if let Some(start) = spec.rfind("^{") {
3835        if spec.ends_with('}') {
3836            let base = &spec[..start];
3837            let op = &spec[start + 2..spec.len() - 1];
3838            return (base, Some(op));
3839        }
3840    }
3841    // `^0` is shorthand for `^{commit}` — peel tags and verify commit.
3842    if let Some(base) = spec.strip_suffix("^0") {
3843        // Only match if the character before `^0` is not also a `^` (avoid
3844        // matching `^^0` as a peel instead of nav+nav).
3845        if !base.ends_with('^') {
3846            return (base, Some("commit"));
3847        }
3848    }
3849    (spec, None)
3850}
3851
3852fn parse_tag_target(data: &[u8]) -> Result<ObjectId> {
3853    let text = std::str::from_utf8(data)
3854        .map_err(|_| Error::CorruptObject("invalid tag object".to_owned()))?;
3855    let Some(line) = text.lines().find(|line| line.starts_with("object ")) else {
3856        return Err(Error::CorruptObject("tag missing object header".to_owned()));
3857    };
3858    let oid_text = line.trim_start_matches("object ").trim();
3859    oid_text.parse::<ObjectId>()
3860}
3861
3862/// Search commit messages reachable from `start` and return the first commit
3863/// whose message contains `pattern`.
3864/// Parse the leading `!` semantics of a `^{/<pattern>}` (or `:/<pattern>`)
3865/// commit-message search, matching git's `get_oid_oneline` in `object-name.c`.
3866///
3867/// Returns `(negate, effective_pattern)` where `effective_pattern` is the
3868/// regular expression to compile and `negate` flips the match sense:
3869///   - `!-<pat>` is a negated search (find a commit NOT matching `<pat>`).
3870///   - `!!<pat>` escapes to a literal `!<pat>` regex search.
3871///   - any other `!` prefix (e.g. `!Exp`, `!`, `!-` is fine but `!x` not) is
3872///     reserved and yields `None`, signalling the caller to fail.
3873fn parse_oneline_pattern(pattern: &str) -> Option<(bool, &str)> {
3874    let Some(after_bang) = pattern.strip_prefix('!') else {
3875        return Some((false, pattern));
3876    };
3877    if let Some(neg) = after_bang.strip_prefix('-') {
3878        return Some((true, neg));
3879    }
3880    if after_bang.starts_with('!') {
3881        // `!!<pat>` -> literal `!<pat>`; keep one leading `!` for the regex.
3882        return Some((false, after_bang));
3883    }
3884    // Reserved: `!` followed by anything other than `!` or `-`.
3885    None
3886}
3887
3888#[derive(Eq, PartialEq)]
3889struct CommitMessageHeapEntry {
3890    committer_time: i64,
3891    oid: ObjectId,
3892}
3893
3894impl Ord for CommitMessageHeapEntry {
3895    fn cmp(&self, other: &Self) -> Ordering {
3896        self.committer_time
3897            .cmp(&other.committer_time)
3898            .then_with(|| self.oid.cmp(&other.oid))
3899    }
3900}
3901
3902impl PartialOrd for CommitMessageHeapEntry {
3903    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
3904        Some(self.cmp(other))
3905    }
3906}
3907
3908/// Parents, committer time, and lazily loaded message for one oneline search step.
3909struct MessageSearchCommitMeta {
3910    parents: smallvec::SmallVec<[ObjectId; 2]>,
3911    committer_time: i64,
3912    message: Option<String>,
3913}
3914
3915struct MessageSearchWalker<'repo> {
3916    repo: &'repo Repository,
3917    graph: Option<Arc<crate::commit_graph_file::CommitGraphChain>>,
3918    cache: HashMap<ObjectId, MessageSearchCommitMeta>,
3919    visited: HashSet<ObjectId>,
3920    heap: BinaryHeap<CommitMessageHeapEntry>,
3921}
3922
3923impl<'repo> MessageSearchWalker<'repo> {
3924    fn new(repo: &'repo Repository) -> Self {
3925        Self {
3926            repo,
3927            graph: repo.odb().commit_graph_chain(),
3928            cache: HashMap::new(),
3929            visited: HashSet::new(),
3930            heap: BinaryHeap::new(),
3931        }
3932    }
3933
3934    fn enqueue_peeled_tip(&mut self, tip: ObjectId) {
3935        let commit_oid = match peel_tags_to_commit(self.repo, tip) {
3936            Ok(Ok(oid)) => oid,
3937            Ok(Err(_)) | Err(_) => return,
3938        };
3939        self.try_enqueue(commit_oid);
3940    }
3941
3942    fn try_enqueue(&mut self, oid: ObjectId) {
3943        if !self.visited.insert(oid) {
3944            return;
3945        }
3946        let Some(committer_time) = self.ensure_linkage(oid) else {
3947            self.visited.remove(&oid);
3948            return;
3949        };
3950        self.heap.push(CommitMessageHeapEntry {
3951            committer_time,
3952            oid,
3953        });
3954    }
3955
3956    /// Parents and committer time from the commit-graph when possible; one ODB read otherwise.
3957    fn ensure_linkage(&mut self, oid: ObjectId) -> Option<i64> {
3958        if let Some(meta) = self.cache.get(&oid) {
3959            return Some(meta.committer_time);
3960        }
3961        if let Some(ref graph) = self.graph {
3962            if let Some((parents, committer_time)) = graph.graph_commit(&oid) {
3963                self.cache.insert(
3964                    oid,
3965                    MessageSearchCommitMeta {
3966                        parents: parents.into(),
3967                        committer_time,
3968                        message: None,
3969                    },
3970                );
3971                return Some(committer_time);
3972            }
3973        }
3974        let obj = self.repo.read_replaced(&oid).ok()?;
3975        if obj.kind != ObjectKind::Commit {
3976            return None;
3977        }
3978        let commit = CommitRef::parse(&obj.data).ok()?;
3979        let committer_time = commit.committer_timestamp();
3980        self.cache.insert(
3981            oid,
3982            MessageSearchCommitMeta {
3983                parents: commit.parents().into(),
3984                committer_time,
3985                message: Some(commit.message().into_owned()),
3986            },
3987        );
3988        Some(committer_time)
3989    }
3990
3991    fn ensure_message(&mut self, oid: ObjectId) -> Option<String> {
3992        if let Some(message) = self.cache.get(&oid)?.message.clone() {
3993            return Some(message);
3994        }
3995        let obj = self.repo.read_replaced(&oid).ok()?;
3996        if obj.kind != ObjectKind::Commit {
3997            return None;
3998        }
3999        let commit = CommitRef::parse(&obj.data).ok()?;
4000        let message = commit.message().into_owned();
4001        self.cache.get_mut(&oid)?.message = Some(message.clone());
4002        Some(message)
4003    }
4004
4005    fn run(
4006        mut self,
4007        seeds: &[ObjectId],
4008        pattern: &str,
4009        negate: bool,
4010        effective_pattern: &str,
4011        regex: Option<&Regex>,
4012    ) -> Result<ObjectId> {
4013        for &seed in seeds {
4014            self.enqueue_peeled_tip(seed);
4015        }
4016
4017        while let Some(entry) = self.heap.pop() {
4018            let Some(message) = self.ensure_message(entry.oid) else {
4019                continue;
4020            };
4021            let base_match = if let Some(re) = regex {
4022                re.is_match(&message)
4023            } else {
4024                message.contains(effective_pattern)
4025            };
4026            if negate ^ base_match {
4027                return Ok(entry.oid);
4028            }
4029            let parents = self
4030                .cache
4031                .get(&entry.oid)
4032                .map(|meta| meta.parents.clone())
4033                .unwrap_or_default();
4034            for parent in parents {
4035                self.try_enqueue(parent);
4036            }
4037        }
4038
4039        Err(Error::ObjectNotFound(format!(":/{pattern}")))
4040    }
4041}
4042
4043fn resolve_commit_message_search_seeds(
4044    repo: &Repository,
4045    seeds: &[ObjectId],
4046    pattern: &str,
4047) -> Result<ObjectId> {
4048    let Some((negate, effective_pattern)) = parse_oneline_pattern(pattern) else {
4049        return Err(Error::ObjectNotFound(format!(":/{pattern}")));
4050    };
4051    let regex = Regex::new(effective_pattern).ok();
4052    MessageSearchWalker::new(repo).run(seeds, pattern, negate, effective_pattern, regex.as_ref())
4053}
4054
4055fn resolve_commit_message_search_from(
4056    repo: &Repository,
4057    start: ObjectId,
4058    pattern: &str,
4059) -> Result<ObjectId> {
4060    resolve_commit_message_search_seeds(repo, &[start], pattern)
4061}
4062
4063fn find_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
4064    let max_len = repo.odb().hash_algo().hex_len();
4065    if !is_hex_prefix(prefix) || !(4..=max_len).contains(&prefix.len()) {
4066        return Ok(Vec::new());
4067    }
4068    let mut matches = Vec::new();
4069    repo.odb().lookup_prefix(prefix, 0, &mut matches)?;
4070    Ok(matches)
4071}
4072
4073fn is_hex_prefix(text: &str) -> bool {
4074    !text.is_empty() && text.chars().all(|ch| ch.is_ascii_hexdigit())
4075}
4076
4077fn path_is_within(path: &Path, container: &Path) -> bool {
4078    if path == container {
4079        return true;
4080    }
4081    path.starts_with(container)
4082}
4083
4084fn normalize_components(path: &Path) -> Vec<String> {
4085    path.components()
4086        .filter_map(|component| match component {
4087            Component::RootDir => Some(String::from("/")),
4088            Component::Normal(item) => Some(item.to_string_lossy().into_owned()),
4089            _ => None,
4090        })
4091        .collect()
4092}
4093
4094fn component_to_text(component: Component<'_>) -> Option<String> {
4095    match component {
4096        Component::Normal(item) => Some(os_to_string(item)),
4097        _ => None,
4098    }
4099}
4100
4101fn os_to_string(text: &OsStr) -> String {
4102    text.to_string_lossy().into_owned()
4103}
4104
4105/// Search reachable commits for a message match: `:/<text>` picks the youngest
4106/// commit (by committer date) whose message matches across all ref tips and HEAD.
4107fn resolve_commit_message_search(
4108    repo: &crate::repo::Repository,
4109    pattern: &str,
4110) -> Result<ObjectId> {
4111    let mut seeds = Vec::new();
4112    let mut seen = HashSet::new();
4113    if let Ok(head) = crate::state::resolve_head_for_repository(repo) {
4114        if let Some(oid) = head.oid() {
4115            if seen.insert(*oid) {
4116                seeds.push(*oid);
4117            }
4118        }
4119    }
4120    if let Ok(refs) = crate::refs::list_refs_in(repo, "refs/") {
4121        for (_name, oid) in refs {
4122            if seen.insert(oid) {
4123                seeds.push(oid);
4124            }
4125        }
4126    }
4127    resolve_commit_message_search_seeds(repo, &seeds, pattern)
4128}
4129
4130/// All object IDs (loose and packed) whose hex form starts with `prefix`.
4131pub fn list_all_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
4132    find_abbrev_matches(repo, prefix)
4133}
4134
4135/// Public: find all object IDs whose hex prefix matches the given string.
4136pub fn list_loose_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
4137    list_all_abbrev_matches(repo, prefix)
4138}
4139
4140#[cfg(test)]
4141mod line_coverage_tests {
4142    use super::*;
4143    use crate::index::{entry_from_stat, Index, IndexEntry, MODE_REGULAR};
4144    use crate::objects::ObjectId;
4145    use crate::objects::ObjectKind;
4146
4147    use crate::ref_storage::RefStorageFormat;
4148    use crate::repo::{init_repository, InitOptions, Repository};
4149    use crate::write_tree::write_tree_from_index;
4150    use std::fs;
4151    use tempfile::tempdir;
4152
4153    fn ambiguous_hex_prefix_among(
4154        oids: &[ObjectId],
4155        min_len: usize,
4156        max_len: usize,
4157    ) -> Option<String> {
4158        for i in 0..oids.len() {
4159            for j in (i + 1)..oids.len() {
4160                let a = oids[i].to_hex();
4161                let b = oids[j].to_hex();
4162                let cap = max_len.min(a.len()).min(b.len());
4163                for len in (min_len..=cap).rev() {
4164                    if a[..len] == b[..len] {
4165                        return Some(a[..len].to_string());
4166                    }
4167                }
4168            }
4169        }
4170        None
4171    }
4172
4173    fn seed_commits_until_ambiguous_prefix(
4174        repo: &Repository,
4175        tree: ObjectId,
4176        oids: &mut Vec<ObjectId>,
4177        min_len: usize,
4178        max_commits: usize,
4179    ) -> String {
4180        let mut counter = oids.len() as i32;
4181        while oids.len() < max_commits && max_commits > 0 {
4182            let body = format!(
4183                "tree {tree}\nauthor T <t@e.com> {counter} +0000\ncommitter T <t@e.com> {counter} +0000\n\nc{counter}\n"
4184            );
4185            counter += 1;
4186            let oid = repo
4187                .odb()
4188                .write_loose_materialize(ObjectKind::Commit, body.as_bytes())
4189                .expect("commit");
4190            oids.push(oid);
4191            if let Some(prefix) = ambiguous_hex_prefix_among(oids, min_len, 12) {
4192                return prefix;
4193            }
4194        }
4195        panic!("could not find {min_len}+ hex prefix collision among {max_commits} commits");
4196    }
4197
4198    fn exercise_resolve_base(repo: &Repository, spec: &str) {
4199        for index_dwim in [false, true] {
4200            for commit_only in [false, true] {
4201                for use_disambiguate in [false, true] {
4202                    for implicit_tree in [false, true] {
4203                        for implicit_blob in [false, true] {
4204                            for remote_guess in [false, true] {
4205                                let _ = resolve_base(
4206                                    repo,
4207                                    spec,
4208                                    index_dwim,
4209                                    commit_only,
4210                                    use_disambiguate,
4211                                    None,
4212                                    implicit_tree,
4213                                    implicit_blob,
4214                                    remote_guess,
4215                                );
4216                            }
4217                        }
4218                    }
4219                }
4220            }
4221        }
4222    }
4223
4224    #[test]
4225    fn parser_helpers_and_config_fragments() {
4226        assert_eq!(abbreviate_ref_name("refs/heads/main"), "main");
4227        assert!(upstream_suffix_info("main@{upstream}").is_some());
4228        assert!(spec_has_parent_shorthand_suffix("main^@"));
4229        assert!(revision_spec_contains_ancestry_navigation("HEAD~3"));
4230        let (base, peel) = parse_peel_suffix("v1^{tree}");
4231        assert_eq!(base, "v1");
4232        assert_eq!(peel, Some("tree"));
4233        assert_eq!(
4234            split_treeish_colon("HEAD:path/to"),
4235            Some(("HEAD", "path/to"))
4236        );
4237        assert!(parse_index_colon_spec(":2:path").is_some());
4238        assert!(split_double_dot_range("a..b").is_some());
4239        assert!(split_triple_dot_range("a...b").is_some());
4240        assert!(!split_double_dot_range("a...b").is_some());
4241
4242        let cfg = "[remote \"origin\"]\n\turl = /x\n\tpush = refs/heads/*:refs/heads/*\n";
4243        assert!(!remote_push_refspecs(cfg, "origin").is_empty());
4244        assert!(remote_has_push_refspec(cfg, "origin"));
4245        assert_eq!(
4246            match_refname_with_pattern("refs/heads/*", "refs/heads/main", "refs/remotes/origin/*"),
4247            Some("refs/remotes/origin/main".into())
4248        );
4249        assert_eq!(
4250            parse_config_value(cfg, "remote \"origin\"", "url").as_deref(),
4251            Some("/x")
4252        );
4253        assert!(parse_branch_tracking(
4254            "[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
4255            "main"
4256        )
4257        .is_some());
4258
4259        let (root, steps) = parse_nav_steps("main~3^2");
4260        assert_eq!(root, "main");
4261        assert_eq!(steps.len(), 2);
4262        assert!(find_parent_shorthand("main^!").is_some());
4263        assert!(reflog_date_selector_timestamp_at("1.day.ago", 1_700_000_000).is_some());
4264        assert!(reflog_date_selector_timestamp_at("yesterday", 1_700_000_000).is_some());
4265        assert!(reflog_date_selector_timestamp_at("last week", 1_700_000_000).is_some());
4266        assert!(reflog_date_selector_timestamp_at("noon yesterday", 1_700_000_000).is_some());
4267        assert!(next_reflog_at_open("main@{1}@{0}", 0).is_some());
4268    }
4269
4270    #[test]
4271    fn top_level_triple_dot_skips_selectors_and_paths() {
4272        assert_eq!(
4273            split_top_level_triple_dot_range("main...side"),
4274            Some(("main", "side"))
4275        );
4276        assert_eq!(
4277            split_top_level_triple_dot_range("main..."),
4278            Some(("main", ""))
4279        );
4280        assert_eq!(
4281            split_top_level_triple_dot_range("...side"),
4282            Some(("", "side"))
4283        );
4284        assert_eq!(split_top_level_triple_dot_range("..."), Some(("", "")));
4285        assert_eq!(
4286            split_top_level_triple_dot_range("main^{/a...b}...side"),
4287            Some(("main^{/a...b}", "side"))
4288        );
4289        for (spec, left, right) in [
4290            ("foo{bar...main", "foo{bar", "main"),
4291            ("main...foo{bar", "main", "foo{bar"),
4292            ("foo}bar...main", "foo}bar", "main"),
4293            ("a{b}c...main", "a{b}c", "main"),
4294            ("foo{bar@{0}...main", "foo{bar@{0}", "main"),
4295            ("main^{/a{2}...b}...side", "main^{/a{2}...b}", "side"),
4296        ] {
4297            assert_eq!(
4298                split_top_level_triple_dot_range(spec),
4299                Some((left, right)),
4300                "{spec}"
4301            );
4302        }
4303        for single in [
4304            "foo{bar",
4305            "foo{bar^{/fix...up}",
4306            "main^{/a{2}...b}",
4307            ":/fix...up",
4308            "main^{/fix...up}",
4309            "HEAD:dir/a...b",
4310            "main@{1...}",
4311            "a....b",
4312            "a..b",
4313            "main",
4314        ] {
4315            assert_eq!(split_top_level_triple_dot_range(single), None, "{single}");
4316        }
4317    }
4318
4319    #[test]
4320    fn bare_repo_resolve_variants() -> crate::error::Result<()> {
4321        let dir = tempdir().map_err(crate::error::Error::Io)?;
4322        Repository::init(
4323            dir.path(),
4324            &InitOptions {
4325                bare: true,
4326
4327                ref_storage: crate::RefStorageFormat::Files,
4328                ..InitOptions::default()
4329            },
4330        )?;
4331        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
4332        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
4333        let body = format!(
4334            "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nm\n"
4335        );
4336        let tip = repo
4337            .odb()
4338            .write_loose_materialize(ObjectKind::Commit, body.as_bytes())?;
4339        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
4340            .map_err(crate::error::Error::Io)?;
4341
4342        for spec in ["main", "HEAD", "HEAD^0", "main^{commit}", "main~0"] {
4343            let _ = resolve_revision_for_verify(&repo, spec)?;
4344            let _ = resolve_revision_without_index_dwim(&repo, spec)?;
4345            let _ = resolve_revision_for_range_end(&repo, spec)?;
4346            let _ = resolve_revision_as_commit(&repo, spec)?;
4347        }
4348        let _ = peel_to_tree(&repo, tip)?;
4349        let _ = try_parse_double_dot_log_range(&repo, "main..main")?;
4350        Ok(())
4351    }
4352
4353    #[test]
4354    fn peel_to_commit_for_merge_base_follows_tag_chains_only_to_commits() -> crate::error::Result<()>
4355    {
4356        let dir = tempdir().map_err(crate::error::Error::Io)?;
4357        Repository::init(
4358            dir.path(),
4359            &InitOptions {
4360                bare: true,
4361                ref_storage: crate::RefStorageFormat::Files,
4362                ..InitOptions::default()
4363            },
4364        )?;
4365        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
4366        let odb = repo.odb();
4367        let tree = odb.write_loose_materialize(ObjectKind::Tree, b"")?;
4368        let blob = odb.write_loose_materialize(ObjectKind::Blob, b"data\n")?;
4369        let commit = odb.write_loose_materialize(
4370            ObjectKind::Commit,
4371            format!(
4372                "tree {tree}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
4373            )
4374            .as_bytes(),
4375        )?;
4376        let tag = |target: ObjectId, kind: &str, name: &str| {
4377            odb.write_loose_materialize(
4378                ObjectKind::Tag,
4379                format!(
4380                    "object {target}\ntype {kind}\ntag {name}\ntagger T <t@e.com> 1 +0000\n\nt\n"
4381                )
4382                .as_bytes(),
4383            )
4384        };
4385        let inner = tag(commit, "commit", "inner")?;
4386        let outer = tag(inner, "tag", "outer")?;
4387        let blob_tag = tag(blob, "blob", "blobtag")?;
4388
4389        assert_eq!(peel_to_commit_for_merge_base(&repo, commit)?, commit);
4390        assert_eq!(peel_to_commit_for_merge_base(&repo, inner)?, commit);
4391        assert_eq!(peel_to_commit_for_merge_base(&repo, outer)?, commit);
4392        for not_commit in [tree, blob, blob_tag] {
4393            assert!(matches!(
4394                peel_to_commit_for_merge_base(&repo, not_commit),
4395                Err(crate::error::Error::InvalidRef(_))
4396            ));
4397        }
4398        let absent = ObjectId::from_hex("1111111111111111111111111111111111111111")?;
4399        assert!(matches!(
4400            peel_to_commit_for_merge_base(&repo, absent),
4401            Err(crate::error::Error::ObjectNotFound(_))
4402        ));
4403        Ok(())
4404    }
4405
4406    #[test]
4407    fn deep_worktree_chain_and_index_specs() -> crate::error::Result<()> {
4408        let dir = tempdir().map_err(crate::error::Error::Io)?;
4409        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
4410        let blob = repo
4411            .odb()
4412            .write_loose_materialize(ObjectKind::Blob, b"line\n")?;
4413        fs::write(dir.path().join("f.txt"), b"line\n").map_err(crate::error::Error::Io)?;
4414        let entry = entry_from_stat(&dir.path().join("f.txt"), b"f.txt", blob, MODE_REGULAR)?;
4415        let mut index = Index::new();
4416        index.entries_mut().push(entry);
4417        index.write_to_path(&repo.git_dir().join("index"), false)?;
4418        let tree = write_tree_from_index(&repo.odb(), &index, "")?;
4419        let mut tip = repo.odb().write_loose_materialize(
4420            ObjectKind::Commit,
4421            format!(
4422                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nroot\n"
4423            )
4424            .as_bytes(),
4425        )?;
4426        for i in 1..=120_i64 {
4427            let body = format!(
4428                "tree {tree}\nparent {tip}\nauthor T <t@e.com> {} +0000\ncommitter T <t@e.com> {} +0000\n\nc{i}\n",
4429                100 + i,
4430                100 + i
4431            );
4432            tip = repo
4433                .odb()
4434                .write_loose_materialize(ObjectKind::Commit, body.as_bytes())?;
4435        }
4436        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
4437            .map_err(crate::error::Error::Io)?;
4438        for n in 0..120 {
4439            for spec in [
4440                format!("HEAD~{n}"),
4441                format!("main~{n}"),
4442                format!("HEAD~{n}^{{tree}}"),
4443                format!("HEAD~{n}^{{commit}}"),
4444            ] {
4445                exercise_resolve_base(&repo, &spec);
4446                exercise_impl_matrix(&repo, &spec);
4447                let _ = resolve_revision_for_verify(&repo, &spec);
4448                let _ = resolve_revision_for_checkout_guess(&repo, &spec, true);
4449            }
4450        }
4451        for spec in [":f.txt", ":0:f.txt", "HEAD:f.txt", "f.txt", ":/c80"] {
4452            exercise_resolve_base(&repo, spec);
4453            exercise_impl_matrix(&repo, spec);
4454        }
4455        Ok(())
4456    }
4457
4458    #[test]
4459    fn deep_chain_resolve_exercises_navigation() -> crate::error::Result<()> {
4460        let dir = tempdir().map_err(crate::error::Error::Io)?;
4461        Repository::init(
4462            dir.path(),
4463            &InitOptions {
4464                bare: true,
4465
4466                ref_storage: crate::RefStorageFormat::Files,
4467                ..InitOptions::default()
4468            },
4469        )?;
4470        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
4471        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
4472        let mut tip = repo.odb().write_loose_materialize(
4473            ObjectKind::Commit,
4474            format!(
4475                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nroot\n"
4476            )
4477            .as_bytes(),
4478        )?;
4479        for i in 1..=100_i64 {
4480            let body = format!(
4481                "tree {tree}\nparent {tip}\nauthor T <t@e.com> {} +0000\ncommitter T <t@e.com> {} +0000\n\nc{i}\n",
4482                100 + i,
4483                100 + i
4484            );
4485            tip = repo
4486                .odb()
4487                .write_loose_materialize(ObjectKind::Commit, body.as_bytes())?;
4488        }
4489        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
4490            .map_err(crate::error::Error::Io)?;
4491        for n in 0..100 {
4492            let spec = format!("HEAD~{n}");
4493            let _ = resolve_revision_for_verify(&repo, &spec)?;
4494            let _ = resolve_revision(&repo, &spec)?;
4495            let _ = resolve_revision_for_checkout_guess(&repo, &spec, true)?;
4496        }
4497        Ok(())
4498    }
4499
4500    #[test]
4501    fn push_and_tracking_config_helpers() {
4502        let cfg = "[remote \"origin\"]\n\turl = /x\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n\tpush = refs/heads/*:refs/heads/*\n";
4503        assert!(push_refspec_mapped_tracking(cfg, "origin", "main").is_some());
4504        assert!(map_dest_to_tracking(cfg, "origin", "refs/heads/main").is_some());
4505        let norm = normalize_path_components(std::path::PathBuf::from("./a/../b"));
4506        assert!(norm.to_string_lossy().contains('b'));
4507        assert_eq!(
4508            normalize_colon_path_for_bare_tree("x/y").expect("bare norm"),
4509            "x/y"
4510        );
4511    }
4512
4513    #[test]
4514    fn reflog_chain_and_at_step_parsers() {
4515        assert!(matches!(
4516            try_parse_at_step_inner("0", 1_700_000_000),
4517            Ok(AtStep::Index(0))
4518        ));
4519        assert!(matches!(
4520            try_parse_at_step_inner("now", 1_700_000_000),
4521            Ok(AtStep::Now)
4522        ));
4523        assert!(matches!(
4524            try_parse_at_step_inner("yesterday", 1_700_000_000),
4525            Ok(AtStep::Date(_))
4526        ));
4527        assert!(split_reflog_at_chain("main@{1}@{0}", 1_700_000_000)
4528            .unwrap()
4529            .is_some());
4530        assert!(next_reflog_at_open("HEAD@{2}", 0).is_some());
4531        let (root, steps) = parse_nav_steps("HEAD~2^2^1");
4532        assert_eq!(root, "HEAD");
4533        assert_eq!(steps.len(), 3);
4534    }
4535
4536    fn exercise_impl_matrix(repo: &Repository, spec: &str) {
4537        for index_dwim in [false, true] {
4538            for commit_only in [false, true] {
4539                for use_disambiguate in [false, true] {
4540                    for treeish_colon_lhs in [false, true] {
4541                        for implicit_tree_abbrev in [false, true] {
4542                            for implicit_blob_abbrev in [false, true] {
4543                                for remote_guess in [false, true] {
4544                                    let _ = resolve_revision_impl(
4545                                        repo,
4546                                        spec,
4547                                        index_dwim,
4548                                        commit_only,
4549                                        use_disambiguate,
4550                                        treeish_colon_lhs,
4551                                        implicit_tree_abbrev,
4552                                        implicit_blob_abbrev,
4553                                        remote_guess,
4554                                    );
4555                                }
4556                            }
4557                        }
4558                    }
4559                }
4560            }
4561        }
4562    }
4563
4564    #[test]
4565    fn top_level_range_ignores_dots_in_selectors_and_paths() {
4566        for range in [
4567            "a..b",
4568            "a...b",
4569            "..b",
4570            "a..",
4571            "...main",
4572            "main^{/x}..side",
4573            "main@{1}...side",
4574            "foo{bar...main",
4575            "foo{bar..main",
4576            "main...foo{bar",
4577            "foo}bar...main",
4578            "a{b}c..main",
4579            "foo{bar@{1}...main",
4580            "main^{/a{2}}..side",
4581        ] {
4582            assert!(is_top_level_range(range), "{range}");
4583        }
4584        for single in [
4585            "main",
4586            "v1.2.3",
4587            "keep~1",
4588            "main^{/release..candidate}",
4589            "main^{/release...candidate}",
4590            "main@{1.week.ago}",
4591            ":/fix..up",
4592            "HEAD:../file",
4593            "HEAD:a..b",
4594            "foo{bar",
4595            "foo{bar~1",
4596            "foo{bar^{/release..candidate}",
4597            "main^{/a{2}..b}",
4598            "@{1.week.ago}",
4599        ] {
4600            assert!(!is_top_level_range(single), "{single}");
4601        }
4602    }
4603
4604    #[test]
4605    fn resolve_revision_impl_flag_matrix() -> crate::error::Result<()> {
4606        let dir = tempdir().map_err(crate::error::Error::Io)?;
4607        Repository::init(
4608            dir.path(),
4609            &InitOptions {
4610                bare: true,
4611
4612                ref_storage: crate::RefStorageFormat::Files,
4613                ..InitOptions::default()
4614            },
4615        )?;
4616        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
4617        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
4618        let mut tip = repo.odb().write_loose_materialize(
4619            ObjectKind::Commit,
4620            format!(
4621                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nroot\n"
4622            )
4623            .as_bytes(),
4624        )?;
4625        for i in 1..=60_i64 {
4626            let body = format!(
4627                "tree {tree}\nparent {tip}\nauthor T <t@e.com> {} +0000\ncommitter T <t@e.com> {} +0000\n\nc{i}\n",
4628                100 + i,
4629                100 + i
4630            );
4631            tip = repo
4632                .odb()
4633                .write_loose_materialize(ObjectKind::Commit, body.as_bytes())?;
4634        }
4635        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
4636            .map_err(crate::error::Error::Io)?;
4637        fs::write(
4638            repo.git_dir().join("refs/tags/annotated"),
4639            format!("{tip}\n"),
4640        )
4641        .map_err(crate::error::Error::Io)?;
4642        let specs = [
4643            "HEAD",
4644            "main",
4645            "main~1",
4646            "main~10",
4647            "main^",
4648            "main^1",
4649            "main^0",
4650            "main^{commit}",
4651            "main^{tree}",
4652            "main^{/c50}",
4653            ":/c50",
4654            ":/",
4655            "tags/annotated",
4656            "refs/heads/main",
4657            "refs/tags/annotated",
4658            "HEAD~5",
4659            "HEAD^!",
4660            "HEAD^@",
4661            "HEAD...main",
4662            "main...HEAD",
4663            "main..HEAD",
4664            "@",
4665            "HEAD@{0}",
4666            "HEAD@{1}",
4667            "HEAD@{1979-01-01}",
4668        ];
4669        for spec in specs {
4670            exercise_impl_matrix(&repo, spec);
4671        }
4672        for n in 0..60 {
4673            let spec = format!("HEAD~{n}");
4674            let _ = resolve_revision_for_verify(&repo, &spec);
4675            for index_dwim in [false, true] {
4676                for commit_only in [false, true] {
4677                    for remote_guess in [false, true] {
4678                        let _ = resolve_revision_impl(
4679                            &repo,
4680                            &spec,
4681                            index_dwim,
4682                            commit_only,
4683                            true,
4684                            false,
4685                            false,
4686                            false,
4687                            remote_guess,
4688                        );
4689                    }
4690                }
4691            }
4692        }
4693        Ok(())
4694    }
4695
4696    #[test]
4697    fn bare_merge_commit_and_tag_object_matrix() -> crate::error::Result<()> {
4698        let dir = tempdir().map_err(crate::error::Error::Io)?;
4699        Repository::init(
4700            dir.path(),
4701            &InitOptions {
4702                bare: true,
4703
4704                ref_storage: crate::RefStorageFormat::Files,
4705                ..InitOptions::default()
4706            },
4707        )?;
4708        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
4709        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
4710        let base = repo.odb().write_loose_materialize(
4711            ObjectKind::Commit,
4712            format!(
4713                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nbase\n"
4714            )
4715            .as_bytes(),
4716        )?;
4717        let side = repo.odb().write_loose_materialize(
4718            ObjectKind::Commit,
4719            format!(
4720                "tree {tree}\nparent {base}\nauthor T <t@e.com> 200 +0000\ncommitter T <t@e.com> 200 +0000\n\nside\n"
4721            )
4722            .as_bytes(),
4723        )?;
4724        let tip = repo.odb().write_loose_materialize(
4725            ObjectKind::Commit,
4726            format!(
4727                "tree {tree}\nparent {base}\nauthor T <t@e.com> 300 +0000\ncommitter T <t@e.com> 300 +0000\n\ntip\n"
4728            )
4729            .as_bytes(),
4730        )?;
4731        let merge = repo.odb().write_loose_materialize(
4732            ObjectKind::Commit,
4733            format!(
4734                "tree {tree}\nparent {tip}\nparent {side}\nauthor T <t@e.com> 400 +0000\ncommitter T <t@e.com> 400 +0000\n\nmerge\n"
4735            )
4736            .as_bytes(),
4737        )?;
4738        fs::write(repo.git_dir().join("refs/heads/main"), format!("{merge}\n"))
4739            .map_err(crate::error::Error::Io)?;
4740        fs::write(repo.git_dir().join("refs/heads/side"), format!("{side}\n"))
4741            .map_err(crate::error::Error::Io)?;
4742        let tag_body = format!(
4743            "object {merge}\ntype commit\ntag v-merge\ntagger T <t@e.com> 500 +0000\n\nmsg\n"
4744        );
4745        let tag_oid = repo
4746            .odb()
4747            .write_loose_materialize(ObjectKind::Tag, tag_body.as_bytes())?;
4748        fs::write(
4749            repo.git_dir().join("refs/tags/v-merge"),
4750            format!("{tag_oid}\n"),
4751        )
4752        .map_err(crate::error::Error::Io)?;
4753        for spec in [
4754            "main^1",
4755            "main^2",
4756            "main^3",
4757            "main^",
4758            "main^0",
4759            "main^!",
4760            "main^@",
4761            "main~1",
4762            "side",
4763            "main...side",
4764            "side...main",
4765            "v-merge",
4766            "v-merge^{commit}",
4767            "v-merge^{}",
4768            "tags/v-merge",
4769            &format!("{tip}"),
4770            &format!("{}", &tip.to_hex()[..7]),
4771        ] {
4772            exercise_impl_matrix(&repo, spec);
4773            let _ = resolve_revision_for_verify(&repo, spec);
4774            let _ = resolve_revision_as_commit(&repo, spec);
4775        }
4776        Ok(())
4777    }
4778
4779    #[test]
4780    fn resolve_base_error_path_sweep() -> crate::error::Result<()> {
4781        let dir = tempdir().map_err(crate::error::Error::Io)?;
4782        Repository::init(
4783            dir.path(),
4784            &InitOptions {
4785                bare: true,
4786
4787                ref_storage: RefStorageFormat::Files,
4788                ..InitOptions::default()
4789            },
4790        )?;
4791        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
4792        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
4793        let tip = repo.odb().write_loose_materialize(
4794            ObjectKind::Commit,
4795            format!(
4796                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nx\n"
4797            )
4798            .as_bytes(),
4799        )?;
4800        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
4801            .map_err(crate::error::Error::Io)?;
4802        const SPECS: &[&str] = &[
4803            "missing-ref",
4804            "HEAD~9999",
4805            "main~9999",
4806            "deadbeef",
4807            "00000000",
4808            "main^{tag}",
4809            "main:missing/path",
4810            ":missing",
4811            "HEAD^9",
4812            "main..",
4813            "...main",
4814            "@{-99}",
4815            "main@{upstream}@{upstream}",
4816            "FETCH_HEAD",
4817            "AUTO_MERGE",
4818            "HEAD:../outside",
4819            ":1:missing",
4820            ":0:/etc/passwd",
4821            ":2:/etc/passwd",
4822            "main...",
4823            "...",
4824            "tags/",
4825            "refs/heads/",
4826            "HEAD^{/}",
4827            ":/!!!",
4828            "!!",
4829            "main^999",
4830            "HEAD^^^",
4831            "notfound@{0}",
4832            "bogus@{1}@{2}",
4833            "HEAD@{upstream}@{push}",
4834            "x..y",
4835            "main..main..main",
4836            "HEAD...",
4837            "...HEAD",
4838            "HEAD:HEAD:file",
4839            "HEAD@{-0}",
4840            "@{-0}",
4841            "main@{999}",
4842            "HEAD@",
4843            "@{",
4844            "HEAD@{",
4845            "main@",
4846            "HEAD@{}",
4847            "HEAD@{x}",
4848            "HEAD@{1}@{upstream}",
4849            "upstream@{1}",
4850            "HEAD^{object}",
4851            "HEAD^{garbage}",
4852            "HEAD^{commit}^{commit}",
4853            "HEAD:.",
4854            "HEAD:..",
4855            ":",
4856            "::file",
4857            ":9:file",
4858            "HEAD~",
4859            "main~",
4860            "^",
4861            "^{}",
4862            "HEAD^^{}",
4863            "main^@^!",
4864            ":0:../../../../etc/passwd",
4865            ":2:../../../../etc/passwd",
4866            ":../../../../etc/passwd",
4867            "HEAD:../../../../etc/passwd",
4868            "remotes/origin/main",
4869            "origin",
4870            "FETCH_HEAD",
4871            "MERGE_HEAD",
4872            "AUTO_MERGE",
4873        ];
4874        for spec in SPECS {
4875            exercise_resolve_base(&repo, spec);
4876            exercise_impl_matrix(&repo, spec);
4877        }
4878        Ok(())
4879    }
4880
4881    #[test]
4882    fn ambiguous_abbrev_and_disambiguate_config() -> crate::error::Result<()> {
4883        let dir = tempdir().map_err(crate::error::Error::Io)?;
4884        Repository::init(
4885            dir.path(),
4886            &InitOptions {
4887                bare: true,
4888
4889                ref_storage: RefStorageFormat::Files,
4890                ..InitOptions::default()
4891            },
4892        )?;
4893        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
4894        fs::write(
4895            repo.git_dir().join("config"),
4896            "[core]\n\tdisambiguate = commit\n",
4897        )
4898        .map_err(crate::error::Error::Io)?;
4899        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
4900        let mut oids = Vec::new();
4901        let prefix = seed_commits_until_ambiguous_prefix(&repo, tree, &mut oids, 4, 8_000);
4902        let tip = *oids.last().expect("tip");
4903        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
4904            .map_err(crate::error::Error::Io)?;
4905        for commit_only in [false, true] {
4906            for use_disambiguate in [false, true] {
4907                let _ = resolve_revision_impl(
4908                    &repo,
4909                    &prefix,
4910                    false,
4911                    commit_only,
4912                    use_disambiguate,
4913                    false,
4914                    false,
4915                    false,
4916                    false,
4917                );
4918                let _ = resolve_revision_as_commit(&repo, &prefix);
4919                let _ = ambiguous_object_hint_lines(&repo, &prefix, Some("commit"));
4920                let _ = ambiguous_object_hint_lines(&repo, &prefix, Some("tree"));
4921                let _ = ambiguous_object_hint_lines(&repo, &prefix, Some("blob"));
4922                let _ = ambiguous_object_hint_lines(&repo, &prefix, Some("tag"));
4923                let _ = ambiguous_object_hint_lines(&repo, &prefix, None);
4924            }
4925        }
4926        Ok(())
4927    }
4928
4929    #[test]
4930    fn unborn_head_resolves_as_unborn_not_object_not_found() -> crate::error::Result<()> {
4931        let dir = tempdir().map_err(crate::error::Error::Io)?;
4932        let repo = crate::repo::Repository::init(dir.path(), &crate::repo::InitOptions::default())?;
4933        let err = resolve_revision_for_range_end(&repo, "HEAD").expect_err("HEAD");
4934        assert!(matches!(err, Error::UnbornHead), "{err:?}");
4935        Ok(())
4936    }
4937
4938    #[test]
4939    fn push_remote_ambiguous_and_graft_coverage() -> crate::error::Result<()> {
4940        let dir = tempdir().map_err(crate::error::Error::Io)?;
4941        let repo = crate::repo::Repository::init(dir.path(), &crate::repo::InitOptions::default())?;
4942        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
4943        let tip = repo.odb().write_loose_materialize(
4944            ObjectKind::Commit,
4945            format!(
4946                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nm\n"
4947            )
4948            .as_bytes(),
4949        )?;
4950        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
4951            .map_err(crate::error::Error::Io)?;
4952        fs::create_dir_all(repo.git_dir().join("refs/remotes/origin"))
4953            .map_err(crate::error::Error::Io)?;
4954        fs::write(
4955            repo.git_dir().join("refs/remotes/origin/main"),
4956            format!("{tip}\n"),
4957        )
4958        .map_err(crate::error::Error::Io)?;
4959        fs::write(
4960            repo.git_dir().join("refs/remotes/origin/HEAD"),
4961            b"ref: refs/remotes/origin/main\n",
4962        )
4963        .map_err(crate::error::Error::Io)?;
4964        fs::create_dir_all(repo.git_dir().join("info")).map_err(crate::error::Error::Io)?;
4965
4966        let write_config = |body: &str| {
4967            fs::write(repo.git_dir().join("config"), body).map_err(crate::error::Error::Io)
4968        };
4969        write_config(
4970            "[remote \"origin\"]\n\turl = /x\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n[push]\n\tdefault = nothing\n",
4971        )?;
4972        let _ = resolve_push_full_ref_for_branch(&repo, "main");
4973        write_config(
4974            "[remote \"origin\"]\n\turl = /x\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n[push]\n\tdefault = simple\n",
4975        )?;
4976        let _ = resolve_push_full_ref_for_branch(&repo, "main");
4977        write_config(
4978            "[remote \"origin\"]\n\turl = /x\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n\tpush = refs/heads/main:refs/heads/main\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
4979        )?;
4980        let _ = resolve_push_full_ref_for_branch(&repo, "main");
4981        write_config(
4982            "[remote \"origin\"]\n\turl = /x\n[branch \"main\"]\n\tpushRemote = origin\n[push]\n\tdefault = upstream\n",
4983        )?;
4984        let _ = resolve_push_full_ref_for_branch(&repo, "main");
4985
4986        for s in [
4987            "now",
4988            "1.day.ago",
4989            "2.weeks.ago",
4990            "1979-01-01",
4991            "not-a-date",
4992            "@{1}",
4993        ] {
4994            let _ = reflog_date_selector_timestamp_at(s, 1_700_000_000);
4995        }
4996
4997        let graft_line = format!("{tip} {tip}\n");
4998        fs::write(repo.git_dir().join("info/grafts"), graft_line)
4999            .map_err(crate::error::Error::Io)?;
5000        let _ = load_graft_parents(&repo.git_dir());
5001
5002        let prefix = &tip.to_hex()[..7];
5003        let _ = ambiguous_object_hint_lines(&repo, prefix, None);
5004        let _ = resolve_revision_as_commit_without_index_dwim(&repo, "main");
5005        let _ = resolve_revision_as_commit_without_index_dwim(&repo, prefix);
5006        let _ = symbolic_full_name(&repo, "main");
5007        let _ = discover_optional(Some(dir.path()));
5008        Ok(())
5009    }
5010
5011    #[test]
5012    fn tree_blob_peel_and_dwim_coverage() -> crate::error::Result<()> {
5013        let dir = tempdir().map_err(crate::error::Error::Io)?;
5014        let repo = crate::repo::Repository::init(dir.path(), &crate::repo::InitOptions::default())?;
5015        let sub_tree = repo
5016            .odb()
5017            .write_loose_materialize(ObjectKind::Tree, b"100644 blob {blob}\tleaf.txt\n")?;
5018        let root_tree = repo.odb().write_loose_materialize(
5019            ObjectKind::Tree,
5020            format!("040000 tree {sub_tree}\tsub\n").as_bytes(),
5021        )?;
5022        let c1 = repo.odb().write_loose_materialize(
5023            ObjectKind::Commit,
5024            format!(
5025                "tree {root_tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\none\n"
5026            )
5027            .as_bytes(),
5028        )?;
5029        let c2 = repo.odb().write_loose_materialize(
5030            ObjectKind::Commit,
5031            format!(
5032                "tree {root_tree}\nparent {c1}\nauthor T <t@e.com> 200 +0000\ncommitter T <t@e.com> 200 +0000\n\ntwo\n"
5033            )
5034            .as_bytes(),
5035        )?;
5036        let tag_body_a = format!(
5037            "object {c2}\ntype commit\ntag ta\ntagger T <t@e.com> 300 +0000\n\nannotated\n"
5038        );
5039        let tag_a = repo
5040            .odb()
5041            .write_loose_materialize(ObjectKind::Tag, tag_body_a.as_bytes())?;
5042        let tag_body_b = format!(
5043            "object {c2}\ntype commit\ntag tb\ntagger T <t@e.com> 301 +0000\n\nannotated\n"
5044        );
5045        let tag_b = repo
5046            .odb()
5047            .write_loose_materialize(ObjectKind::Tag, tag_body_b.as_bytes())?;
5048        fs::write(repo.git_dir().join("refs/heads/main"), format!("{c2}\n"))
5049            .map_err(crate::error::Error::Io)?;
5050        fs::write(repo.git_dir().join("refs/tags/t"), format!("{tag_a}\n"))
5051            .map_err(crate::error::Error::Io)?;
5052        let abbrev = &c2.to_hex()[..7];
5053        fs::write(
5054            repo.git_dir().join(format!("refs/heads/{abbrev}")),
5055            format!("{c1}\n"),
5056        )
5057        .map_err(crate::error::Error::Io)?;
5058
5059        for spec in [
5060            "HEAD:sub",
5061            "HEAD:sub/leaf.txt",
5062            "HEAD:",
5063            "main:sub/leaf.txt",
5064            "t",
5065            "t^{commit}",
5066            "tags/t",
5067            abbrev,
5068        ] {
5069            let _ = resolve_treeish_blob_at_path(&repo, spec);
5070            let _ = resolve_revision_as_commit_without_index_dwim(&repo, spec);
5071            exercise_resolve_base(&repo, spec);
5072            exercise_impl_matrix(&repo, spec);
5073        }
5074        let _ = ambiguous_object_hint_lines(&repo, abbrev, Some("commit"));
5075        let _ = disambiguate_hex_by_peel(&repo, abbrev, &[c2, tag_a], "commit");
5076        let _ = disambiguate_hex_by_peel(&repo, "tags", &[tag_a, tag_b], "tag");
5077        Ok(())
5078    }
5079
5080    #[test]
5081    fn resolve_base_outside_paths_and_ambiguous_hex() -> crate::error::Result<()> {
5082        let dir = tempdir().map_err(crate::error::Error::Io)?;
5083        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5084        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
5085        let mut oids = Vec::new();
5086        let prefix = seed_commits_until_ambiguous_prefix(&repo, tree, &mut oids, 4, 8_000);
5087        let tip = *oids.last().expect("tip");
5088        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
5089            .map_err(crate::error::Error::Io)?;
5090        fs::write(
5091            repo.git_dir().join("config"),
5092            "[core]\n\tdisambiguate = commit\n",
5093        )
5094        .map_err(crate::error::Error::Io)?;
5095        let shared_prefix = Some(prefix);
5096
5097        const OUTSIDE: &[&str] = &[":0:/etc/passwd", ":2:/etc/passwd"];
5098        for spec in OUTSIDE {
5099            let err = resolve_base(&repo, spec, true, false, true, None, false, false, false)
5100                .expect_err(spec);
5101            assert!(
5102                matches!(
5103                    err,
5104                    Error::RevParse(RevParseError::OutsideRepository { .. })
5105                ),
5106                "{spec}: {err:?}"
5107            );
5108            for index_dwim in [false, true] {
5109                for commit_only in [false, true] {
5110                    for use_disambiguate in [false, true] {
5111                        let _ = resolve_base(
5112                            &repo,
5113                            spec,
5114                            index_dwim,
5115                            commit_only,
5116                            use_disambiguate,
5117                            None,
5118                            false,
5119                            false,
5120                            false,
5121                        );
5122                    }
5123                }
5124            }
5125        }
5126
5127        if let Some(prefix) = shared_prefix {
5128            for commit_only in [false, true] {
5129                for use_disambiguate in [false, true] {
5130                    let _ = resolve_base(
5131                        &repo,
5132                        &prefix,
5133                        false,
5134                        commit_only,
5135                        use_disambiguate,
5136                        Some("commit"),
5137                        false,
5138                        false,
5139                        false,
5140                    );
5141                    let _ = resolve_revision_as_commit(&repo, &prefix);
5142                }
5143            }
5144            let mut pair: Option<(ObjectId, ObjectId)> = None;
5145            for i in 0..oids.len() {
5146                for j in (i + 1)..oids.len() {
5147                    let a = oids[i].to_hex();
5148                    let b = oids[j].to_hex();
5149                    if a.starts_with(prefix.as_str()) && b.starts_with(prefix.as_str()) {
5150                        pair = Some((oids[i], oids[j]));
5151                        break;
5152                    }
5153                }
5154                if pair.is_some() {
5155                    break;
5156                }
5157            }
5158            if let Some((a, b)) = pair {
5159                let _ = disambiguate_hex_by_peel(&repo, &prefix, &[a, b], "commit");
5160                let _ = resolve_base(
5161                    &repo,
5162                    &prefix,
5163                    false,
5164                    true,
5165                    true,
5166                    Some("commit"),
5167                    false,
5168                    false,
5169                    false,
5170                );
5171            }
5172        }
5173        Ok(())
5174    }
5175
5176    #[test]
5177    fn commit_message_search_abbrev_and_loose_matches() -> crate::error::Result<()> {
5178        let dir = tempdir().map_err(crate::error::Error::Io)?;
5179        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5180        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
5181        let c1 = repo.odb().write_loose_materialize(
5182            ObjectKind::Commit,
5183            format!(
5184                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nneedle alpha\n"
5185            )
5186            .as_bytes(),
5187        )?;
5188        let c2 = repo.odb().write_loose_materialize(
5189            ObjectKind::Commit,
5190            format!(
5191                "tree {tree}\nparent {c1}\nauthor T <t@e.com> 200 +0000\ncommitter T <t@e.com> 200 +0000\n\nbeta only\n"
5192            )
5193            .as_bytes(),
5194        )?;
5195        fs::write(repo.git_dir().join("refs/heads/main"), format!("{c2}\n"))
5196            .map_err(crate::error::Error::Io)?;
5197        fs::write(repo.git_dir().join("refs/heads/side"), format!("{c1}\n"))
5198            .map_err(crate::error::Error::Io)?;
5199
5200        for spec in [
5201            ":/needle",
5202            ":/!beta",
5203            ":/!!needle",
5204            ":/n[e-e]dle",
5205            ":/missing",
5206        ] {
5207            let _ = resolve_revision_for_verify(&repo, spec);
5208            exercise_resolve_base(&repo, spec);
5209        }
5210
5211        let missing = ObjectId::from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").expect("oid");
5212        let _ = abbreviate_object_id(&repo, missing, 7);
5213        let prefix = &c2.to_hex()[..7];
5214        let _ = list_loose_abbrev_matches(&repo, prefix);
5215        let _ = list_all_abbrev_matches(&repo, prefix);
5216        let _ = list_all_abbrev_matches(&repo, "zzz");
5217        let _ = to_relative_path(&dir.path().join("sub/leaf.txt"), &dir.path().join("sub"));
5218        Ok(())
5219    }
5220
5221    #[test]
5222    fn parent_navigation_shares_only_equivalent_linkage() -> crate::error::Result<()> {
5223        let dir = tempdir()?;
5224        let repo = Repository::init(dir.path(), &InitOptions::default())?;
5225        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
5226        let root = repo.odb().write_loose_materialize(
5227            ObjectKind::Commit,
5228            format!(
5229                "tree {tree}\nauthor Test <t@example.com> 1 +0000\n\
5230                 committer Test <t@example.com> 1 +0000\n\nroot\n"
5231            )
5232            .as_bytes(),
5233        )?;
5234        let child = repo.odb().write_loose_materialize(
5235            ObjectKind::Commit,
5236            format!(
5237                "tree {tree}\nparent {root}\nauthor Test <t@example.com> 2 +0000\n\
5238                 committer Test <t@example.com> 2 +0000\n\n{}\n",
5239                "long message".repeat(10_000)
5240            )
5241            .as_bytes(),
5242        )?;
5243        let blob = repo
5244            .odb()
5245            .write_loose_materialize(ObjectKind::Blob, b"not a commit")?;
5246        let malformed = repo
5247            .odb()
5248            .write_loose_materialize(ObjectKind::Commit, b"tree invalid\n")?;
5249        let spec = format!("{child}~1");
5250        assert_eq!(resolve_revision(&repo, &spec)?, root);
5251        assert!(
5252            repo.caches()
5253                .commit_linkage()
5254                .lock()
5255                .unwrap()
5256                .parents
5257                .is_empty(),
5258            "standalone resolution must not retain ancestry metadata"
5259        );
5260        assert_eq!(resolve_walk_range_end(&repo, &spec)?, root);
5261        let nav = NavParentCache::new(&repo, NavigationMetadata::RetainForWalk);
5262        assert_eq!(nav.parents_of(child)?, vec![root]);
5263        assert!(matches!(nav.parents_of(blob), Err(Error::InvalidRef(_))));
5264        assert_eq!(nav.parents_of(root)?, Vec::<ObjectId>::new());
5265        assert!(matches!(
5266            nav.parents_of(malformed),
5267            Err(Error::CorruptObject(_))
5268        ));
5269        assert_eq!(nav.parents_of(child)?, vec![root]);
5270        assert_eq!(
5271            apply_nav_step(&repo, &nav, child, NavStep::AncestorN(1))?,
5272            root
5273        );
5274        use crate::merge_base::intersect_walk_test_hooks::{
5275            commit_parent_lookups, reset_commit_parent_lookups,
5276        };
5277        use crate::merge_base::{
5278            count_commits_reachable_excluding_ancestors_of, ReachableCountFilter,
5279        };
5280        reset_commit_parent_lookups();
5281        assert_eq!(
5282            count_commits_reachable_excluding_ancestors_of(
5283                &repo,
5284                &[child],
5285                &[root],
5286                ReachableCountFilter::default()
5287            )?,
5288            1
5289        );
5290        assert_eq!(
5291            commit_parent_lookups(),
5292            0,
5293            "the range walk must reuse navigation metadata"
5294        );
5295
5296        // Navigation reads original parents, while the subsequent walk observes shallow
5297        // boundaries and replacements. Neither may consume the other's parent cache.
5298        fs::write(repo.git_dir().join("shallow"), format!("{child}\n"))?;
5299        let shallow_repo = Repository::open(&repo.git_dir(), &RepositoryOptions::empty())?;
5300        assert_eq!(
5301            NavParentCache::new(&shallow_repo, NavigationMetadata::RetainForWalk)
5302                .parents_of(child)?,
5303            vec![root]
5304        );
5305        assert_eq!(
5306            count_commits_reachable_excluding_ancestors_of(
5307                &shallow_repo,
5308                &[child],
5309                &[],
5310                ReachableCountFilter::default()
5311            )?,
5312            1
5313        );
5314        fs::remove_file(repo.git_dir().join("shallow"))?;
5315        fs::create_dir_all(repo.git_dir().join("refs/replace"))?;
5316        fs::write(
5317            repo.git_dir().join("refs/replace").join(child.to_hex()),
5318            format!("{root}\n"),
5319        )?;
5320        let replaced_repo = Repository::open(&repo.git_dir(), &RepositoryOptions::empty())?;
5321        assert_eq!(
5322            NavParentCache::new(&replaced_repo, NavigationMetadata::RetainForWalk)
5323                .parents_of(child)?,
5324            vec![root]
5325        );
5326        assert_eq!(
5327            count_commits_reachable_excluding_ancestors_of(
5328                &replaced_repo,
5329                &[child],
5330                &[],
5331                ReachableCountFilter::default()
5332            )?,
5333            1
5334        );
5335        Ok(())
5336    }
5337
5338    #[test]
5339    fn colon_message_search_oneline_bang_patterns() -> crate::error::Result<()> {
5340        let dir = tempdir().map_err(crate::error::Error::Io)?;
5341        Repository::init(
5342            dir.path(),
5343            &InitOptions {
5344                bare: true,
5345                ref_storage: RefStorageFormat::Files,
5346                ..InitOptions::default()
5347            },
5348        )?;
5349        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
5350        let tree = repo
5351            .odb()
5352            .write_loose_materialize(ObjectKind::Tree, b"100644 blob deadbeef\tf\n")?;
5353        let with_feature = repo.odb().write_loose_materialize(
5354            ObjectKind::Commit,
5355            format!(
5356                "tree {tree}\nauthor T <t@e.com> 200 +0000\ncommitter T <t@e.com> 200 +0000\n\nfeature msg\n"
5357            )
5358            .as_bytes(),
5359        )?;
5360        let plain = repo.odb().write_loose_materialize(
5361            ObjectKind::Commit,
5362            format!(
5363                "tree {tree}\nparent {with_feature}\nauthor T <t@e.com> 300 +0000\ncommitter T <t@e.com> 300 +0000\n\nother\n"
5364            )
5365            .as_bytes(),
5366        )?;
5367        fs::write(repo.git_dir().join("refs/heads/main"), format!("{plain}\n"))
5368            .map_err(crate::error::Error::Io)?;
5369        assert_eq!(
5370            resolve_commit_message_search_from(&repo, plain, "!-feature")?,
5371            plain
5372        );
5373        assert!(resolve_commit_message_search_from(&repo, plain, "!foo").is_err());
5374        Ok(())
5375    }
5376
5377    #[test]
5378    fn internal_private_api_sweep() -> crate::error::Result<()> {
5379        use std::path::Path;
5380        assert!(parse_oneline_pattern("needle").is_some());
5381        assert!(parse_oneline_pattern("!-skip").is_some());
5382        assert!(parse_oneline_pattern("!!literal").is_some());
5383        assert!(parse_oneline_pattern("!bad").is_none());
5384        assert!(is_hex_prefix("abc1234"));
5385        assert!(!is_hex_prefix("ghij"));
5386        let _ = normalize_components(Path::new("/a/b/../c"));
5387        let _ = path_is_within(Path::new("/a/b"), Path::new("/a"));
5388        let dir = tempdir().map_err(crate::error::Error::Io)?;
5389        Repository::init(
5390            dir.path(),
5391            &InitOptions {
5392                bare: true,
5393
5394                ref_storage: RefStorageFormat::Files,
5395                ..InitOptions::default()
5396            },
5397        )?;
5398        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
5399        let blob = repo
5400            .odb()
5401            .write_loose_materialize(ObjectKind::Blob, b"payload\n")?;
5402        let tree_body = format!("100644 blob {blob}\tfile.txt\n");
5403        let tree = repo
5404            .odb()
5405            .write_loose_materialize(ObjectKind::Tree, tree_body.as_bytes())?;
5406        let c1 = repo.odb().write_loose_materialize(
5407            ObjectKind::Commit,
5408            format!(
5409                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\none\n"
5410            )
5411            .as_bytes(),
5412        )?;
5413        let c2 = repo.odb().write_loose_materialize(
5414            ObjectKind::Commit,
5415            format!(
5416                "tree {tree}\nparent {c1}\nauthor T <t@e.com> 200 +0000\ncommitter T <t@e.com> 200 +0000\n\ntwo\n"
5417            )
5418            .as_bytes(),
5419        )?;
5420        let merge = repo.odb().write_loose_materialize(
5421            ObjectKind::Commit,
5422            format!(
5423                "tree {tree}\nparent {c2}\nparent {c1}\nauthor T <t@e.com> 300 +0000\ncommitter T <t@e.com> 300 +0000\n\nmerge\n"
5424            )
5425            .as_bytes(),
5426        )?;
5427        fs::write(repo.git_dir().join("refs/heads/main"), format!("{merge}\n"))
5428            .map_err(crate::error::Error::Io)?;
5429        let tag_data = format!(
5430            "object {c1}\ntype commit\ntag ann\ntagger T <t@e.com> 400 +0000\n\nannotated\n"
5431        );
5432        let tag_oid = repo
5433            .odb()
5434            .write_loose_materialize(ObjectKind::Tag, tag_data.as_bytes())?;
5435        fs::write(repo.git_dir().join("refs/tags/ann"), format!("{tag_oid}\n"))
5436            .map_err(crate::error::Error::Io)?;
5437        let _ = parse_tag_target(tag_data.as_bytes());
5438        let _ = peel_annotated_tag_chain(&repo, tag_oid);
5439        let _ = apply_peel(&repo, tag_oid, Some("commit"));
5440        let _ = apply_peel(&repo, c2, Some("tree"));
5441        let nav = NavParentCache::new(&repo, NavigationMetadata::RetainForWalk);
5442        let _ = apply_nav_step(&repo, &nav, merge, NavStep::ParentN(2));
5443        let _ = apply_nav_step(&repo, &nav, merge, NavStep::AncestorN(2));
5444        let _ = commit_reachable_closure(&repo, merge);
5445        let _ = resolve_commit_message_search_from(&repo, merge, "merge");
5446        let _ = resolve_commit_message_search_from(&repo, merge, "!-two");
5447        let _ = find_abbrev_matches(&repo, &c2.to_hex()[..4]);
5448        let _ = disambiguate_kind_rank(ObjectKind::Commit);
5449        let _ = oid_satisfies_peel_filter(&repo, c2, "commit");
5450        let _ = read_core_disambiguate(&repo);
5451        let _ = resolve_tree_path(&repo, &tree, "file.txt");
5452        let _ = resolve_tree_path_rev_parse(&repo, &tree, "file.txt");
5453        let _ = walk_tree_to_blob_entry(&repo, &tree, "file.txt");
5454        if let Some((a, b)) = split_treeish_spec("HEAD:file.txt") {
5455            assert_eq!(a, "HEAD");
5456            assert_eq!(b, "file.txt");
5457        }
5458        let _ = resolve_treeish_path_to_object(&repo, c2, "file.txt");
5459        let _ = resolve_ref_dwim_for_rev_parse(&repo, "main");
5460        let _ = resolve_ref_dwim_for_rev_parse(&repo, "ann");
5461        let _ = remote_tracking_head_symbolic_target(&repo, "origin/main");
5462        warn_if_branch_refname_collides_with_abbrev_hex(&repo, "main", c2);
5463        warn_if_hex_ref_collides_with_objects(&repo, &c2.to_hex()[..7], c2);
5464        let abbrev = &c2.to_hex()[..7];
5465        let matches = find_abbrev_matches(&repo, abbrev)?;
5466        let _ = disambiguate_hex_by_peel(&repo, abbrev, &matches, "commit");
5467        let _ = describe_generation_count(&repo, merge, c1);
5468        let _ = try_resolve_reflog_index(&repo, "main@{0}");
5469        let _ = approxidate_at("1.day.ago", 1_700_000_000);
5470        let _ = parse_reflog_entry_timestamp(&crate::reflog::ReflogEntry {
5471            old_oid: c1,
5472            new_oid: c2,
5473            identity: "T <t@e.com> 100 +0000".into(),
5474            message: "commit: msg".into(),
5475        });
5476        let _ = diagnose_tree_path_error(
5477            &repo,
5478            "HEAD",
5479            "missing",
5480            "missing",
5481            Error::ObjectNotFound("missing".into()).into(),
5482        );
5483        let _ =
5484            diagnose_index_path_error(&repo, "missing", 0, Error::ObjectNotFound("missing".into()));
5485        for spec in [
5486            "main",
5487            "ann",
5488            "ann^{commit}",
5489            "merge^2",
5490            "merge^1",
5491            "merge~1",
5492            "HEAD^!",
5493            "HEAD^@",
5494            ":/merge",
5495            ":/!-two",
5496            "tags/ann",
5497        ] {
5498            exercise_resolve_base(&repo, spec);
5499        }
5500        Ok(())
5501    }
5502
5503    #[test]
5504    fn worktree_fetch_head_index_and_colon_paths() -> crate::error::Result<()> {
5505        let dir = tempdir().map_err(crate::error::Error::Io)?;
5506        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5507        let blob = repo
5508            .odb()
5509            .write_loose_materialize(ObjectKind::Blob, b"indexed\n")?;
5510        fs::write(dir.path().join("tracked.txt"), b"indexed\n").map_err(crate::error::Error::Io)?;
5511        let entry = entry_from_stat(
5512            &dir.path().join("tracked.txt"),
5513            b"tracked.txt",
5514            blob,
5515            MODE_REGULAR,
5516        )?;
5517        let mut index = Index::new();
5518        index.entries_mut().push(entry);
5519        index.write_to_path(&repo.git_dir().join("index"), false)?;
5520        let tree = write_tree_from_index(&repo.odb(), &index, "")?;
5521        let tip = repo.odb().write_loose_materialize(
5522            ObjectKind::Commit,
5523            format!(
5524                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\ninit\n"
5525            )
5526            .as_bytes(),
5527        )?;
5528        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
5529            .map_err(crate::error::Error::Io)?;
5530        fs::write(
5531            repo.git_dir().join("FETCH_HEAD"),
5532            format!("{tip}\tnot-for-merge\n{tip}\trefs/heads/main\n"),
5533        )
5534        .map_err(crate::error::Error::Io)?;
5535        for spec in [
5536            "FETCH_HEAD",
5537            ":tracked.txt",
5538            ":0:tracked.txt",
5539            "HEAD:tracked.txt",
5540            "HEAD:",
5541            "main:tracked.txt",
5542            "tracked.txt",
5543            "main",
5544            "HEAD",
5545            "@",
5546            "HEAD^{tree}",
5547            "HEAD^{commit}",
5548            &tip.to_hex(),
5549            &tip.to_hex()[..7],
5550        ] {
5551            exercise_resolve_base(&repo, spec);
5552            exercise_impl_matrix(&repo, spec);
5553        }
5554        let _ = head_tree_oid(&repo);
5555        let _ = path_in_tree(&repo, tree, "tracked.txt");
5556        let _ = path_in_index(&repo, "tracked.txt", 0);
5557        Ok(())
5558    }
5559
5560    #[test]
5561    fn discovery_graft_and_abbrev_sweep() -> crate::error::Result<()> {
5562        let dir = tempdir().map_err(crate::error::Error::Io)?;
5563        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5564        let cwd = dir.path();
5565        let _ = discover_optional(Some(cwd))?;
5566        let _ = is_inside_work_tree(&repo, cwd);
5567        let _ = is_inside_git_dir(&repo, cwd);
5568        let _ = show_prefix(&repo, cwd);
5569        let _ = symbolic_full_name(&repo, "HEAD");
5570        let _ = expand_at_minus_to_branch_name(&repo, "@{-1}").ok();
5571        let _ = resolve_at_minus_to_oid(&repo, "HEAD").ok();
5572        let _ = resolve_upstream_symbolic_name(&repo, "main@{upstream}").ok();
5573        let _ = resolve_push_full_ref_for_branch(&repo, "main").ok();
5574        let _ = load_graft_parents(&repo.git_dir());
5575        let blob = repo
5576            .odb()
5577            .write_loose_materialize(ObjectKind::Blob, b"x\n")?;
5578        let tree_body = format!("100644 f.txt\0").into_bytes();
5579        let mut tree_bytes = tree_body;
5580        tree_bytes.extend_from_slice(blob.as_bytes());
5581        let tree = repo
5582            .odb()
5583            .write_loose_materialize(ObjectKind::Tree, &tree_bytes)?;
5584        let tip = repo.odb().write_loose_materialize(
5585            ObjectKind::Commit,
5586            format!(
5587                "tree {tree}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
5588            )
5589            .as_bytes(),
5590        )?;
5591        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
5592            .map_err(crate::error::Error::Io)?;
5593        let _ = list_all_abbrev_matches(&repo, &tip.to_hex()[..4])?;
5594        let _ = list_loose_abbrev_matches(&repo, &tip.to_hex()[..4])?;
5595        let _ = abbreviate_object_id(&repo, tip, 7)?;
5596        let _ = resolve_treeish_blob_at_path(&repo, "HEAD:f.txt")?;
5597        let _ = expand_rev_token_circ_bang(&repo, "main")?;
5598        let _ = ambiguous_object_hint_lines(&repo, &tip.to_hex()[..7], None)?;
5599        let _ = reflog_walk_refname(&repo, "HEAD@{0}").ok();
5600        let _ = resolve_reflog_walk_log_ref(&repo, "HEAD").ok();
5601        let _ = resolve_index_path_entry(&repo, ":f.txt").ok();
5602        let _ = to_relative_path(&dir.path().join("f.txt"), dir.path());
5603        Ok(())
5604    }
5605
5606    #[test]
5607    fn describe_prefilter_accepts_everything_the_pattern_matches() {
5608        let re = Regex::new(r"(?i)^(.+)-(\d+)-g([0-9a-fA-F]+)$").unwrap();
5609        for spec in [
5610            "v1-1-gabc1234",
5611            "V1-2-GABC",
5612            "a-b-c-10-g0",
5613            "v2.47.0-rc1-3-gdeadbeef",
5614            "x-1-gab-2-gcd",
5615            "tag-٣-gabc",
5616            "HEAD",
5617            "main",
5618            "v1-gabc",
5619            "-1-gabc",
5620            "v1-1-g",
5621            "v1-1-gxyz",
5622            "v1-1-gabc-",
5623            "feature/x-1-gabc\nfoo",
5624            "a--gabc",
5625        ] {
5626            if re.is_match(spec) {
5627                assert!(may_be_describe_name(spec), "{spec:?}");
5628            }
5629        }
5630        assert!(!may_be_describe_name("HEAD"));
5631        assert!(!may_be_describe_name("refs/heads/main"));
5632        assert!(!may_be_describe_name("v1-gabc"));
5633        assert!(!may_be_describe_name("v1-1-gxyz"));
5634    }
5635
5636    #[test]
5637    fn describe_name_and_dwim_helpers() -> crate::error::Result<()> {
5638        let dir = tempdir().map_err(crate::error::Error::Io)?;
5639        Repository::init(
5640            dir.path(),
5641            &InitOptions {
5642                bare: true,
5643
5644                ref_storage: crate::RefStorageFormat::Files,
5645                ..InitOptions::default()
5646            },
5647        )?;
5648        let repo = crate::repo::Repository::open(dir.path(), &RepositoryOptions::empty())?;
5649        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
5650        let c1 = repo.odb().write_loose_materialize(
5651            ObjectKind::Commit,
5652            format!(
5653                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\none\n"
5654            )
5655            .as_bytes(),
5656        )?;
5657        let c2 = repo.odb().write_loose_materialize(
5658            ObjectKind::Commit,
5659            format!(
5660                "tree {tree}\nparent {c1}\nauthor T <t@e.com> 200 +0000\ncommitter T <t@e.com> 200 +0000\n\ntwo\n"
5661            )
5662            .as_bytes(),
5663        )?;
5664        fs::write(repo.git_dir().join("refs/heads/main"), format!("{c2}\n"))
5665            .map_err(crate::error::Error::Io)?;
5666        fs::write(repo.git_dir().join("refs/tags/v1"), format!("{c1}\n"))
5667            .map_err(crate::error::Error::Io)?;
5668        let abbrev = &c2.to_hex()[..7];
5669        let describe_name = format!("v1-1-g{abbrev}");
5670        assert_eq!(try_resolve_describe_name(&repo, &describe_name)?, Some(c2));
5671        assert_eq!(
5672            try_resolve_describe_name(&repo, &format!("V1-1-G{}", abbrev.to_uppercase()))?,
5673            Some(c2)
5674        );
5675        assert_eq!(try_resolve_describe_name(&repo, "main")?, None);
5676        assert_eq!(try_resolve_describe_name(&repo, "HEAD")?, None);
5677        let _ = dwim_refname(&repo, "main");
5678        let _ = reflog_display_name("refs/heads/main", "main");
5679        Ok(())
5680    }
5681
5682    fn conflict_index_entry(path: &str, stage: u8, oid: ObjectId, mode: u32) -> IndexEntry {
5683        let path_bytes = path.as_bytes().to_vec();
5684        IndexEntry {
5685            ctime_sec: 0,
5686            ctime_nsec: 0,
5687            mtime_sec: 0,
5688            mtime_nsec: 0,
5689            dev: 0,
5690            ino: 0,
5691            mode,
5692            uid: 0,
5693            gid: 0,
5694            size: 0,
5695            oid,
5696            flags: (path_bytes.len().min(0xFFF) as u16) | ((stage as u16) << 12),
5697            flags_extended: None,
5698            path: path_bytes,
5699            base_index_pos: 0,
5700        }
5701    }
5702
5703    #[test]
5704    fn unmerged_index_stages_resolve_and_diagnose() -> crate::error::Result<()> {
5705        let dir = tempdir().map_err(crate::error::Error::Io)?;
5706        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5707        let base = repo
5708            .odb()
5709            .write_loose_materialize(ObjectKind::Blob, b"base\n")?;
5710        let ours = repo
5711            .odb()
5712            .write_loose_materialize(ObjectKind::Blob, b"ours\n")?;
5713        let theirs = repo
5714            .odb()
5715            .write_loose_materialize(ObjectKind::Blob, b"theirs\n")?;
5716        let mut index = Index::new();
5717        index
5718            .entries_mut()
5719            .push(conflict_index_entry("conflict.txt", 1, base, MODE_REGULAR));
5720        index
5721            .entries_mut()
5722            .push(conflict_index_entry("conflict.txt", 2, ours, MODE_REGULAR));
5723        index.entries_mut().push(conflict_index_entry(
5724            "conflict.txt",
5725            3,
5726            theirs,
5727            MODE_REGULAR,
5728        ));
5729        let nested_blob = repo
5730            .odb()
5731            .write_loose_materialize(ObjectKind::Blob, b"nested\n")?;
5732        fs::create_dir_all(dir.path().join("sub")).map_err(crate::error::Error::Io)?;
5733        fs::write(dir.path().join("sub/leaf.txt"), b"nested\n").map_err(crate::error::Error::Io)?;
5734        let nested_entry = entry_from_stat(
5735            &dir.path().join("sub/leaf.txt"),
5736            b"sub/leaf.txt",
5737            nested_blob,
5738            MODE_REGULAR,
5739        )?;
5740        index.entries_mut().push(nested_entry);
5741        index.write_to_path(&repo.git_dir().join("index"), false)?;
5742
5743        assert_eq!(resolve_revision_for_verify(&repo, ":1:conflict.txt")?, base);
5744        assert_eq!(resolve_revision_for_verify(&repo, ":2:conflict.txt")?, ours);
5745        assert_eq!(
5746            resolve_revision_for_verify(&repo, ":3:conflict.txt")?,
5747            theirs
5748        );
5749        assert!(resolve_revision_for_verify(&repo, ":0:conflict.txt").is_err());
5750
5751        let _ = diagnose_index_path_error(
5752            &repo,
5753            "conflict.txt",
5754            1,
5755            Error::ObjectNotFound("conflict.txt".into()),
5756        );
5757        let _ =
5758            diagnose_index_path_error(&repo, "no-such", 2, Error::ObjectNotFound("no-such".into()));
5759        fs::write(dir.path().join("disk-only.txt"), b"x\n").map_err(crate::error::Error::Io)?;
5760        let _ = diagnose_index_path_error(
5761            &repo,
5762            "disk-only.txt",
5763            0,
5764            Error::ObjectNotFound("disk-only.txt".into()),
5765        );
5766
5767        let _ = resolve_revision_for_verify(&repo, ":0:sub/leaf.txt");
5768        let _ = resolve_index_path_entry(&repo, ":sub/leaf.txt");
5769        let _ = diagnose_tree_path_error(
5770            &repo,
5771            "HEAD",
5772            "./sub/leaf.txt",
5773            "sub/leaf.txt",
5774            TreePathError::NotInTree,
5775        );
5776        Ok(())
5777    }
5778
5779    #[test]
5780    fn outside_repo_ambiguous_hex_and_shorthand_matrix() -> crate::error::Result<()> {
5781        let dir = tempdir().map_err(crate::error::Error::Io)?;
5782        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5783        let tree = repo.odb().write_loose_materialize(ObjectKind::Tree, b"")?;
5784        let base = repo.odb().write_loose_materialize(
5785            ObjectKind::Commit,
5786            format!(
5787                "tree {tree}\nauthor T <t@e.com> 100 +0000\ncommitter T <t@e.com> 100 +0000\n\nbase\n"
5788            )
5789            .as_bytes(),
5790        )?;
5791        let side = repo.odb().write_loose_materialize(
5792            ObjectKind::Commit,
5793            format!(
5794                "tree {tree}\nparent {base}\nauthor T <t@e.com> 200 +0000\ncommitter T <t@e.com> 200 +0000\n\nside\n"
5795            )
5796            .as_bytes(),
5797        )?;
5798        let merge = repo.odb().write_loose_materialize(
5799            ObjectKind::Commit,
5800            format!(
5801                "tree {tree}\nparent {base}\nparent {side}\nauthor T <t@e.com> 300 +0000\ncommitter T <t@e.com> 300 +0000\n\nmerge\n"
5802            )
5803            .as_bytes(),
5804        )?;
5805        fs::write(repo.git_dir().join("refs/heads/main"), format!("{merge}\n"))
5806            .map_err(crate::error::Error::Io)?;
5807
5808        let outside = [
5809            "HEAD:../../../../etc/passwd",
5810            ":0:../../../../etc/passwd",
5811            ":2:../../../../etc/passwd",
5812            "HEAD:",
5813        ];
5814        for spec in outside {
5815            let _ = resolve_treeish_blob_at_path(&repo, spec);
5816            let _ = resolve_revision_for_verify(&repo, spec);
5817            let _ = resolve_index_path_entry(&repo, spec);
5818        }
5819
5820        let bang = expand_rev_token_circ_bang(&repo, "main^!")?;
5821        assert!(bang.len() >= 2);
5822        let _ = expand_parent_shorthand_rev_parse_lines(&repo, "main^-2", false, None);
5823        let _ = expand_parent_shorthand_rev_parse_lines(&repo, "main^-99", false, None);
5824        let _ = resolve_reflog_walk_log_ref(&repo, "main");
5825        let _ = resolve_reflog_walk_log_ref(&repo, "missing-branch");
5826        let _ = resolve_reflog_walk_log_ref(&repo, "@{-1}");
5827
5828        fs::write(
5829            repo.git_dir().join("config"),
5830            "[core]\n\tdisambiguate = commit\n",
5831        )
5832        .map_err(crate::error::Error::Io)?;
5833        let mut oids = Vec::new();
5834        let prefix = seed_commits_until_ambiguous_prefix(&repo, tree, &mut oids, 4, 8_000);
5835        let _ = resolve_revision(&repo, &prefix);
5836        let _ = resolve_revision_as_commit(&repo, &prefix);
5837        let _ = resolve_revision_for_verify(&repo, &prefix);
5838        Ok(())
5839    }
5840
5841    #[test]
5842    #[should_panic(expected = "could not find")]
5843    fn seed_commits_until_ambiguous_prefix_exhaustion() {
5844        let dir = tempdir().expect("tempdir");
5845        crate::repo::Repository::init(
5846            dir.path(),
5847            &InitOptions {
5848                bare: true,
5849
5850                ref_storage: RefStorageFormat::Files,
5851                ..InitOptions::default()
5852            },
5853        )
5854        .expect("init");
5855        let repo = crate::repo::Repository::open(
5856            dir.path(),
5857            &crate::environment::RepositoryOptions::empty(),
5858        )
5859        .expect("open");
5860        let tree = repo
5861            .odb()
5862            .write_loose_materialize(ObjectKind::Tree, b"")
5863            .expect("tree");
5864        let mut oids = Vec::new();
5865        let _ = seed_commits_until_ambiguous_prefix(&repo, tree, &mut oids, 40, 0);
5866    }
5867}
5868
5869#[cfg(test)]
5870mod superproject_path_tests {
5871    use super::superproject_work_tree_from_nested_git_modules;
5872    use std::path::PathBuf;
5873
5874    #[test]
5875    fn nested_modules_yields_superproject_work_tree() {
5876        let git_dir = PathBuf::from("/tmp/super/.git/modules/dir/modules/sub");
5877        assert_eq!(
5878            superproject_work_tree_from_nested_git_modules(&git_dir),
5879            Some(PathBuf::from("/tmp/super"))
5880        );
5881    }
5882
5883    #[test]
5884    fn non_nested_returns_none() {
5885        let git_dir = PathBuf::from("/tmp/repo/.git");
5886        assert!(superproject_work_tree_from_nested_git_modules(&git_dir).is_none());
5887    }
5888}