Skip to main content

grit_lib/
rev_parse.rs

1//! Revision parsing and repository discovery helpers for `rev-parse`.
2//!
3//! This module implements a focused subset of Git's revision parser used by
4//! `grit rev-parse` in v2 scope: repository/work-tree discovery flags, basic
5//! object-name resolution, and lightweight peeling (`^{}`, `^{object}`,
6//! `^{commit}`).
7
8use std::borrow::Cow;
9use std::ffi::OsStr;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12
13use regex::Regex;
14
15use std::collections::HashSet;
16
17use crate::config::ConfigSet;
18use crate::error::{Error, Result};
19use crate::objects::{parse_commit, parse_tree, ObjectId, ObjectKind};
20use crate::pack;
21use crate::reflog::read_reflog;
22use crate::refs;
23use crate::repo::Repository;
24
25/// Return `Some(repo)` when a repository can be discovered at `start`.
26///
27/// # Parameters
28///
29/// - `start` - starting path for discovery; when `None`, uses current directory.
30///
31/// # Errors
32///
33/// Returns errors other than "not a repository" (for example I/O and path
34/// canonicalization failures).
35pub fn discover_optional(start: Option<&Path>) -> Result<Option<Repository>> {
36    match Repository::discover(start) {
37        Ok(repo) => Ok(Some(repo)),
38        Err(Error::NotARepository(msg)) => {
39            // Repository not found while walking parents is optional, but
40            // structural `.git` problems at the starting directory should be
41            // surfaced so callers can show diagnostics (e.g. t0002/t0009).
42            if msg.contains("invalid gitfile format")
43                || msg.contains("gitfile does not contain 'gitdir:' line")
44                || msg.contains("not a regular file")
45            {
46                return Err(Error::NotARepository(msg));
47            }
48
49            if let Some(start) = start {
50                let start = if start.is_absolute() {
51                    start.to_path_buf()
52                } else if let Ok(cwd) = std::env::current_dir() {
53                    cwd.join(start)
54                } else {
55                    start.to_path_buf()
56                };
57                let dot_git = start.join(".git");
58                if dot_git.is_file() || dot_git.is_symlink() {
59                    return Err(Error::NotARepository(msg));
60                }
61            }
62
63            Ok(None)
64        }
65        Err(err) => Err(err),
66    }
67}
68
69/// Compute whether `cwd` is inside the repository's work tree.
70#[must_use]
71pub fn is_inside_work_tree(repo: &Repository, cwd: &Path) -> bool {
72    let Some(work_tree) = &repo.work_tree else {
73        return false;
74    };
75    path_is_within(cwd, work_tree)
76}
77
78/// Compute whether `cwd` is inside the repository's git-dir.
79#[must_use]
80pub fn is_inside_git_dir(repo: &Repository, cwd: &Path) -> bool {
81    path_is_within(cwd, &repo.git_dir)
82}
83
84/// Compute the `--show-prefix` output.
85///
86/// Returns an empty string when `cwd` is at repository root or outside the work
87/// tree. Returned prefixes always use `/` separators and end with `/`.
88#[must_use]
89pub fn show_prefix(repo: &Repository, cwd: &Path) -> String {
90    let Some(work_tree) = &repo.work_tree else {
91        return String::new();
92    };
93    if !path_is_within(cwd, work_tree) {
94        return String::new();
95    }
96    if cwd == work_tree {
97        return String::new();
98    }
99    let Ok(rel) = cwd.strip_prefix(work_tree) else {
100        return String::new();
101    };
102    let mut out = rel
103        .components()
104        .filter_map(component_to_text)
105        .collect::<Vec<_>>()
106        .join("/");
107    if !out.is_empty() {
108        out.push('/');
109    }
110    out
111}
112
113/// Resolve a symbolic ref name to its full form.
114///
115/// For `HEAD`, returns the symbolic target (e.g., `refs/heads/main`).
116/// For branch names, returns `refs/heads/<name>`.
117/// For tag names, returns `refs/tags/<name>`.
118/// Returns `None` when the name cannot be resolved symbolically.
119#[must_use]
120pub fn symbolic_full_name(repo: &Repository, spec: &str) -> Option<String> {
121    // @{upstream} / @{push}: must error from rev-parse when invalid; do not fall through to DWIM.
122    if upstream_suffix_info(spec).is_some() {
123        return resolve_upstream_symbolic_name(repo, spec).ok();
124    }
125
126    if let Ok(Some(branch)) = expand_at_minus_to_branch_name(repo, spec) {
127        let ref_name = format!("refs/heads/{branch}");
128        if refs::resolve_ref(&repo.git_dir, &ref_name).is_ok() {
129            return Some(ref_name);
130        }
131        return None;
132    }
133
134    if spec == "HEAD" {
135        if let Ok(Some(target)) = refs::read_symbolic_ref(&repo.git_dir, "HEAD") {
136            return Some(target);
137        }
138        return None;
139    }
140    // If it's already a full ref path
141    if spec.starts_with("refs/") {
142        if refs::resolve_ref(&repo.git_dir, spec).is_ok() {
143            return Some(spec.to_owned());
144        }
145        return None;
146    }
147    // DWIM: try refs/heads, refs/tags, refs/remotes
148    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
149        let candidate = format!("{prefix}{spec}");
150        if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
151            return Some(candidate);
152        }
153    }
154    // Remote name alone: `one` → `refs/remotes/one/HEAD` when `remote.one.url` exists (matches Git).
155    if let Some(full) = remote_tracking_head_symbolic_target(repo, spec) {
156        return Some(full);
157    }
158    None
159}
160
161/// When `name` is a configured remote, return the full ref `refs/remotes/<name>/HEAD` resolves to.
162fn remote_tracking_head_symbolic_target(repo: &Repository, name: &str) -> Option<String> {
163    if name.contains('/')
164        || matches!(
165            name,
166            "HEAD" | "FETCH_HEAD" | "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD"
167        )
168    {
169        return None;
170    }
171    let config = ConfigSet::load(Some(&repo.git_dir), true).ok()?;
172    let url_key = format!("remote.{name}.url");
173    config.get(&url_key)?;
174    let head_ref = format!("refs/remotes/{name}/HEAD");
175    let target = refs::read_symbolic_ref(&repo.git_dir, &head_ref).ok()??;
176    Some(target)
177}
178
179/// Expand an `@{-N}` token to the corresponding previous branch name.
180///
181/// Returns:
182/// - `Ok(Some(branch_name))` when `spec` is an `@{-N}` token and resolves
183///   to a branch name.
184/// - `Ok(None)` when `spec` is not an `@{-N}` token.
185/// - `Err(...)` when `spec` matches `@{-N}` syntax but cannot be resolved.
186pub fn expand_at_minus_to_branch_name(repo: &Repository, spec: &str) -> Result<Option<String>> {
187    if !spec.starts_with("@{-") || !spec.ends_with('}') {
188        return Ok(None);
189    }
190    let inner = &spec[3..spec.len() - 1];
191    let n: usize = inner
192        .parse()
193        .map_err(|_| Error::InvalidRef(format!("invalid N in @{{-N}} for '{spec}'")))?;
194    if n < 1 {
195        return Ok(None);
196    }
197    resolve_at_minus_to_branch(repo, n).map(Some)
198}
199
200/// Resolve `@{-N}` to the commit OID it points to.
201pub fn resolve_at_minus_to_oid(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
202    try_resolve_at_minus(repo, spec)
203}
204
205/// Abbreviate a full ref name to its shortest unambiguous form.
206///
207/// For example, `refs/heads/main` becomes `main`.
208#[must_use]
209pub fn abbreviate_ref_name(full_name: &str) -> String {
210    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
211        if let Some(short) = full_name.strip_prefix(prefix) {
212            return short.to_owned();
213        }
214    }
215    if let Some(short) = full_name.strip_prefix("refs/") {
216        return short.to_owned();
217    }
218    full_name.to_owned()
219}
220
221/// Returns `(base_without_suffix, is_push)` when `spec` ends with `@{upstream}` / `@{u}` / `@{push}`
222/// (case-insensitive for upstream forms). `is_push` is true only for `@{push}`.
223#[must_use]
224pub fn upstream_suffix_info(spec: &str) -> Option<(&str, bool)> {
225    let lower = spec.to_ascii_lowercase();
226    if lower.ends_with("@{push}") {
227        let base = &spec[..spec.len() - 7];
228        return Some((base, true));
229    }
230    if lower.ends_with("@{upstream}") {
231        let base = &spec[..spec.len() - 11];
232        return Some((base, false));
233    }
234    if lower.ends_with("@{u}") {
235        let base = &spec[..spec.len() - 4];
236        return Some((base, false));
237    }
238    None
239}
240
241/// Resolve `@{upstream}` / `@{u}` / `@{push}` to the symbolic full ref name (for `rev-parse --symbolic-full-name`).
242pub fn resolve_upstream_symbolic_name(repo: &Repository, spec: &str) -> Result<String> {
243    let Some((base, is_push)) = upstream_suffix_info(spec) else {
244        return Err(Error::InvalidRef(format!("not an upstream spec: {spec}")));
245    };
246    resolve_upstream_full_ref_name(repo, base, is_push)
247}
248
249fn resolve_upstream_full_ref_name(repo: &Repository, base: &str, is_push: bool) -> Result<String> {
250    if is_push {
251        return resolve_push_ref_name(repo, base);
252    }
253    let (branch_key, display_branch) = resolve_upstream_branch_context(repo, base)?;
254    let config_path = repo.git_dir.join("config");
255    let config_content = fs::read_to_string(&config_path).map_err(Error::Io)?;
256    let Some((remote, merge)) = parse_branch_tracking(&config_content, &branch_key) else {
257        return Err(Error::Message(format!(
258            "fatal: no upstream configured for branch '{display_branch}'"
259        )));
260    };
261    if remote == "." {
262        return Ok(merge);
263    }
264    let merge_branch = merge
265        .strip_prefix("refs/heads/")
266        .ok_or_else(|| Error::InvalidRef(format!("invalid merge ref: {merge}")))?;
267    let tracking = format!("refs/remotes/{remote}/{merge_branch}");
268    if refs::resolve_ref(&repo.git_dir, &tracking).is_err() {
269        return Err(Error::Message(format!(
270            "fatal: upstream branch '{merge}' not stored as a remote-tracking branch"
271        )));
272    }
273    Ok(tracking)
274}
275
276/// Resolve the remote-tracking ref used as `@{push}` for `branch_short` (`refs/heads/...` name).
277///
278/// Honors `remote.pushRemote`, `branch.<name>.pushRemote`, `push.default`, and per-remote
279/// `push` refspecs (exact `refs/heads/<branch>:refs/heads/<dest>` mappings).
280pub fn resolve_push_full_ref_for_branch(repo: &Repository, branch_short: &str) -> Result<String> {
281    let config_path = crate::refs::common_dir(&repo.git_dir)
282        .unwrap_or_else(|| repo.git_dir.clone())
283        .join("config");
284    let config_content = fs::read_to_string(&config_path).map_err(Error::Io)?;
285
286    let upstream_tracking =
287        parse_branch_tracking(&config_content, branch_short).and_then(|(remote, merge)| {
288            if remote == "." {
289                return None;
290            }
291            let mb = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
292            let tr = format!("refs/remotes/{remote}/{mb}");
293            if refs::resolve_ref(&repo.git_dir, &tr).is_ok() {
294                Some(tr)
295            } else {
296                None
297            }
298        });
299
300    let push_remote = parse_config_value(&config_content, "remote", "pushRemote")
301        .or_else(|| parse_config_value(&config_content, "remote", "pushDefault"))
302        .or_else(|| {
303            let section = format!("[branch \"{}\"]", branch_short);
304            let mut in_section = false;
305            for line in config_content.lines() {
306                let trimmed = line.trim();
307                if trimmed.starts_with('[') {
308                    in_section = trimmed == section;
309                    continue;
310                }
311                if in_section {
312                    if let Some(v) = trimmed
313                        .strip_prefix("pushremote = ")
314                        .or_else(|| trimmed.strip_prefix("pushRemote = "))
315                    {
316                        return Some(v.trim().to_owned());
317                    }
318                }
319            }
320            None
321        })
322        .or_else(|| {
323            parse_branch_tracking(&config_content, branch_short)
324                .map(|(r, _)| r)
325                .filter(|r| r != ".")
326        });
327
328    let Some(push_remote_name) = push_remote else {
329        return upstream_tracking.ok_or_else(|| {
330            Error::Message("fatal: branch has no configured push remote".to_owned())
331        });
332    };
333
334    let push_default = parse_config_value(&config_content, "push", "default");
335    let push_default = push_default.as_deref().unwrap_or("simple");
336
337    if push_default == "nothing" {
338        return Err(Error::Message(
339            "fatal: push.default is nothing; no push destination".to_owned(),
340        ));
341    }
342
343    if let Some(mapped) =
344        push_refspec_mapped_tracking(&config_content, &push_remote_name, branch_short)
345    {
346        if refs::resolve_ref(&repo.git_dir, &mapped).is_ok() {
347            return Ok(mapped);
348        }
349    }
350
351    let current_tracking = format!("refs/remotes/{push_remote_name}/{branch_short}");
352
353    match push_default {
354        "upstream" => upstream_tracking.ok_or_else(|| {
355            Error::Message(format!(
356                "fatal: branch '{branch_short}' has no upstream for push.default upstream"
357            ))
358        }),
359        "simple" => {
360            if let Some(ref up) = upstream_tracking {
361                if up == &current_tracking
362                    && refs::resolve_ref(&repo.git_dir, &current_tracking).is_ok()
363                {
364                    return Ok(current_tracking);
365                }
366            }
367            Err(Error::Message(
368                "fatal: push.default simple: upstream and push ref differ".to_owned(),
369            ))
370        }
371        "current" | "matching" | _ => {
372            if refs::resolve_ref(&repo.git_dir, &current_tracking).is_ok() {
373                Ok(current_tracking)
374            } else if let Some(up) = upstream_tracking {
375                Ok(up)
376            } else {
377                Err(Error::Message(format!(
378                    "fatal: no push tracking ref for branch '{branch_short}'"
379                )))
380            }
381        }
382    }
383}
384
385fn push_refspec_mapped_tracking(
386    config_content: &str,
387    remote_name: &str,
388    branch_short: &str,
389) -> Option<String> {
390    let section = format!("[remote \"{remote_name}\"]");
391    let mut in_section = false;
392    let src_want = format!("refs/heads/{branch_short}");
393    for line in config_content.lines() {
394        let trimmed = line.trim();
395        if trimmed.starts_with('[') {
396            in_section = trimmed == section;
397            continue;
398        }
399        if !in_section {
400            continue;
401        }
402        let Some(val) = trimmed
403            .strip_prefix("push = ")
404            .or_else(|| trimmed.strip_prefix("push="))
405        else {
406            continue;
407        };
408        let Some(spec) = val.split_whitespace().next() else {
409            continue;
410        };
411        let spec = spec.trim().strip_prefix('+').unwrap_or(spec);
412        let Some((left, right)) = spec.split_once(':') else {
413            continue;
414        };
415        let left = left.trim();
416        let right = right.trim();
417        if left != src_want {
418            continue;
419        }
420        let Some(dest_branch) = right.strip_prefix("refs/heads/") else {
421            continue;
422        };
423        return Some(format!("refs/remotes/{remote_name}/{dest_branch}"));
424    }
425    None
426}
427
428fn resolve_push_ref_name(repo: &Repository, base: &str) -> Result<String> {
429    let (branch_key, _display) = resolve_upstream_branch_context(repo, base)?;
430    resolve_push_full_ref_for_branch(repo, &branch_key)
431}
432
433/// Returns `(config_branch_key, display_name_for_errors)` for upstream resolution.
434fn resolve_upstream_branch_context(repo: &Repository, base: &str) -> Result<(String, String)> {
435    let base = if base == "HEAD" {
436        Cow::Borrowed("")
437    } else if base.starts_with("@{-") && base.ends_with('}') {
438        if let Ok(Some(b)) = expand_at_minus_to_branch_name(repo, base) {
439            Cow::Owned(b)
440        } else {
441            Cow::Borrowed(base)
442        }
443    } else {
444        Cow::Borrowed(base)
445    };
446    let base = base.as_ref();
447    let base = if base == "@" { "" } else { base };
448
449    if base.is_empty() {
450        let Some(head) = refs::read_head(&repo.git_dir)? else {
451            return Err(Error::Message(
452                "fatal: HEAD does not point to a branch".to_owned(),
453            ));
454        };
455        let Some(short) = head.strip_prefix("refs/heads/") else {
456            return Err(Error::Message(
457                "fatal: HEAD does not point to a branch".to_owned(),
458            ));
459        };
460        return Ok((short.to_owned(), short.to_owned()));
461    }
462    let head_branch = refs::read_head(&repo.git_dir)?.and_then(|h| {
463        h.strip_prefix("refs/heads/")
464            .map(std::borrow::ToOwned::to_owned)
465    });
466    if head_branch.as_deref() == Some(base) {
467        return Ok((base.to_owned(), base.to_owned()));
468    }
469    let refname = format!("refs/heads/{base}");
470    if refs::resolve_ref(&repo.git_dir, &refname).is_err() {
471        return Err(Error::Message(format!("fatal: no such branch: '{base}'")));
472    }
473    Ok((base.to_owned(), base.to_owned()))
474}
475
476fn parse_config_value(config: &str, section: &str, key: &str) -> Option<String> {
477    let section_header = format!("[{}]", section);
478    let key_lower = key.to_ascii_lowercase();
479    let mut in_section = false;
480    for line in config.lines() {
481        let trimmed = line.trim();
482        if trimmed.starts_with('[') {
483            in_section = trimmed.eq_ignore_ascii_case(&section_header);
484            continue;
485        }
486        if in_section {
487            let lower = trimmed.to_ascii_lowercase();
488            if lower.starts_with(&key_lower) {
489                let rest = lower[key_lower.len()..].trim_start().to_string();
490                if rest.starts_with('=') {
491                    if let Some(eq_pos) = trimmed.find('=') {
492                        return Some(trimmed[eq_pos + 1..].trim().to_owned());
493                    }
494                }
495            }
496        }
497    }
498    None
499}
500
501/// Parse branch tracking configuration from git config content.
502fn parse_branch_tracking(config: &str, branch: &str) -> Option<(String, String)> {
503    let mut remote = None;
504    let mut merge = None;
505    let mut in_section = false;
506    let target_section = format!("[branch \"{}\"]", branch);
507
508    for line in config.lines() {
509        let trimmed = line.trim();
510        if trimmed.starts_with('[') {
511            in_section = trimmed == target_section
512                || trimmed.starts_with(&format!("[branch \"{}\"", branch));
513            continue;
514        }
515        if !in_section {
516            continue;
517        }
518        if let Some(value) = trimmed.strip_prefix("remote = ") {
519            remote = Some(value.trim().to_owned());
520        } else if let Some(value) = trimmed.strip_prefix("merge = ") {
521            merge = Some(value.trim().to_owned());
522        }
523        // Also handle with tabs
524        if let Some(value) = trimmed.strip_prefix("remote=") {
525            remote = Some(value.trim().to_owned());
526        } else if let Some(value) = trimmed.strip_prefix("merge=") {
527            merge = Some(value.trim().to_owned());
528        }
529    }
530
531    match (remote, merge) {
532        (Some(r), Some(m)) => Some((r, m)),
533        _ => None,
534    }
535}
536
537/// Resolve a revision string to an object ID.
538///
539/// Supports:
540/// - full 40-hex object IDs (must exist in loose store),
541/// - abbreviated object IDs (length 4-39, must resolve uniquely),
542/// - direct refs (`HEAD`, `refs/...`),
543/// - DWIM branch/tag/remote names (`name` -> `refs/heads/name`, etc.),
544/// - peeling suffixes: `^{}`, `^{object}`, `^{commit}`.
545///
546/// # Errors
547///
548/// Returns [`Error::ObjectNotFound`] or [`Error::InvalidRef`] when resolution
549/// fails.
550/// Split `spec` at a `..` range operator, avoiding the three-dot symmetric-diff form.
551///
552/// Returns `(left, right)` where either side may be empty (`..HEAD`, `HEAD..`, `..`).
553#[must_use]
554pub fn split_double_dot_range(spec: &str) -> Option<(&str, &str)> {
555    if spec == ".." {
556        return Some(("", ""));
557    }
558    let bytes = spec.as_bytes();
559    let mut search = 0usize;
560    while let Some(rel) = spec[search..].find("..") {
561        let idx = search + rel;
562        // Reject `..` that is part of `...` (symmetric-diff operator).
563        let touches_dot_before = idx > 0 && bytes[idx - 1] == b'.';
564        let touches_dot_after = idx + 2 < bytes.len() && bytes[idx + 2] == b'.';
565        if touches_dot_before || touches_dot_after {
566            search = idx + 1;
567            continue;
568        }
569        // Reject `..` that starts a path segment (`../` in `HEAD:../file`).
570        if idx + 2 < bytes.len() && (bytes[idx + 2] == b'/' || bytes[idx + 2] == b'\\') {
571            search = idx + 1;
572            continue;
573        }
574        let left = &spec[..idx];
575        let right = &spec[idx + 2..];
576        return Some((left, right));
577    }
578    None
579}
580
581/// Split `spec` at the first `...` symmetric-diff operator (not part of `....`).
582///
583/// Returns `(left, right)` where either side may be empty (`...HEAD`, `A...`, `...`).
584#[must_use]
585pub fn split_triple_dot_range(spec: &str) -> Option<(&str, &str)> {
586    if spec == "..." {
587        return Some(("", ""));
588    }
589    let bytes = spec.as_bytes();
590    let mut search = 0usize;
591    while let Some(rel) = spec[search..].find("...") {
592        let idx = search + rel;
593        let four_before = idx >= 1 && bytes[idx - 1] == b'.';
594        let four_after = idx + 3 < bytes.len() && bytes[idx + 3] == b'.';
595        if four_before || four_after {
596            search = idx + 1;
597            continue;
598        }
599        let left = &spec[..idx];
600        let right = &spec[idx + 3..];
601        return Some((left, right));
602    }
603    None
604}
605
606/// Like [`resolve_revision`], but does not treat a bare filename as an index path
607/// (matches `git rev-parse` / plumbing, where `file.txt` stays ambiguous).
608pub fn resolve_revision_without_index_dwim(repo: &Repository, spec: &str) -> Result<ObjectId> {
609    resolve_revision_impl(repo, spec, false, false, true, false, false, false)
610}
611
612/// Resolve a revision string to an object ID.
613pub fn resolve_revision(repo: &Repository, spec: &str) -> Result<ObjectId> {
614    resolve_revision_impl(repo, spec, true, false, true, false, false, false)
615}
616
617/// Resolve `spec` when it appears as the end of a revision range (`A..B`, `A...B`, etc.):
618/// abbreviated hex and `core.disambiguate` prefer a commit (porcelain range parsing).
619pub fn resolve_revision_for_range_end(repo: &Repository, spec: &str) -> Result<ObjectId> {
620    resolve_revision_impl(repo, spec, true, true, true, false, false, false)
621}
622
623/// First argument to `commit-tree`: ambiguous short hex uses tree-ish rules (blob vs tree).
624pub fn resolve_revision_for_commit_tree_tree(repo: &Repository, spec: &str) -> Result<ObjectId> {
625    resolve_revision_impl(repo, spec, true, false, true, false, true, false)
626}
627
628/// Old blob OID from a patch `index <old>..<new>` line (`git apply --build-fake-ancestor`).
629pub fn resolve_revision_for_patch_old_blob(repo: &Repository, spec: &str) -> Result<ObjectId> {
630    resolve_revision_impl(repo, spec, true, false, true, false, false, true)
631}
632
633/// When `spec` uses two-dot range syntax (`A..B`, `..B`, `A..`), returns the commits to
634/// **exclude** (left tip) and **include** (right tip) for `git log`-style walks.
635///
636/// Returns `Ok(None)` when `spec` is not a two-dot range. Symmetric `A...B` is handled by
637/// [`resolve_revision_as_commit`] instead.
638///
639/// # Errors
640///
641/// Propagates resolution errors from either range endpoint.
642pub fn try_parse_double_dot_log_range(
643    repo: &Repository,
644    spec: &str,
645) -> Result<Option<(ObjectId, ObjectId)>> {
646    let Some((left, right)) = split_double_dot_range(spec) else {
647        return Ok(None);
648    };
649    let left_tip = if left.is_empty() {
650        resolve_revision_for_range_end(repo, "HEAD")?
651    } else {
652        resolve_revision_for_range_end(repo, left)?
653    };
654    let right_tip = if right.is_empty() {
655        resolve_revision_for_range_end(repo, "HEAD")?
656    } else {
657        resolve_revision_for_range_end(repo, right)?
658    };
659    let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
660    let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
661    Ok(Some((left_c, right_c)))
662}
663
664/// Resolve `spec` to a commit OID for porcelain history commands (`log`, `reset`, etc.).
665///
666/// Handles `A..B` / `..B` / `A..` (tip is the right side, defaulting to `HEAD`) and
667/// `A...B` symmetric diff (returns the merge base). Other specs are resolved and peeled
668/// to a commit (tags peeled, abbreviated hex disambiguated as commit-ish on range ends).
669/// Returns true when `spec` ends with Git parent/ancestor navigation (`~N`, `^N`, bare `~`/`^`).
670///
671/// Used by porcelain (`reset`) to distinguish commit-ish arguments from pathspecs when
672/// full resolution is deferred or fails for other reasons.
673#[must_use]
674pub fn revision_spec_contains_ancestry_navigation(spec: &str) -> bool {
675    let (_, steps) = parse_nav_steps(spec);
676    !steps.is_empty()
677}
678
679pub fn resolve_revision_as_commit(repo: &Repository, spec: &str) -> Result<ObjectId> {
680    if let Some((left, right)) = split_triple_dot_range(spec) {
681        let left_tip = if left.is_empty() {
682            resolve_revision_for_range_end(repo, "HEAD")?
683        } else {
684            resolve_revision_for_range_end(repo, left)?
685        };
686        let right_tip = if right.is_empty() {
687            resolve_revision_for_range_end(repo, "HEAD")?
688        } else {
689            resolve_revision_for_range_end(repo, right)?
690        };
691        let left_c = peel_to_commit_for_merge_base(repo, left_tip)?;
692        let right_c = peel_to_commit_for_merge_base(repo, right_tip)?;
693        let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_c, &[right_c])?;
694        return bases
695            .into_iter()
696            .next()
697            .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
698    }
699    if let Some((_excl, tip)) = try_parse_double_dot_log_range(repo, spec)? {
700        return Ok(tip);
701    }
702    let oid = resolve_revision_for_range_end(repo, spec)?;
703    peel_to_commit_for_merge_base(repo, oid)
704}
705
706fn resolve_revision_impl(
707    repo: &Repository,
708    spec: &str,
709    index_dwim: bool,
710    commit_only_hex: bool,
711    use_disambiguate_config: bool,
712    treeish_colon_lhs: bool,
713    implicit_tree_abbrev: bool,
714    implicit_blob_abbrev: bool,
715) -> Result<ObjectId> {
716    // Handle `:/message` early — it can contain any characters so must
717    // not be confused with peel/nav syntax.
718    if let Some(pattern) = spec.strip_prefix(":/") {
719        if !pattern.is_empty() {
720            return resolve_commit_message_search(repo, pattern);
721        }
722    }
723
724    // Pseudo-ref written by `git merge` / grit merge on conflict (tree OID, one line).
725    if spec == "AUTO_MERGE" {
726        let raw = fs::read_to_string(repo.git_dir.join("AUTO_MERGE"))
727            .map_err(|e| Error::Message(format!("failed to read AUTO_MERGE: {e}")))?;
728        let line = raw.lines().next().unwrap_or("").trim();
729        return line
730            .parse::<ObjectId>()
731            .map_err(|_| Error::InvalidRef("AUTO_MERGE: invalid object id".to_owned()));
732    }
733
734    // Handle A...B (symmetric difference / merge-base)
735    // Also handles A... (implies A...HEAD)
736    if let Some(idx) = spec.find("...") {
737        let left_raw = &spec[..idx];
738        let right_raw = &spec[idx + 3..];
739        if !left_raw.is_empty() || !right_raw.is_empty() {
740            let left_oid = peel_to_commit_for_merge_base(
741                repo,
742                if left_raw.is_empty() {
743                    resolve_revision_impl(
744                        repo,
745                        "HEAD",
746                        index_dwim,
747                        commit_only_hex,
748                        use_disambiguate_config,
749                        false,
750                        false,
751                        false,
752                    )?
753                } else {
754                    resolve_revision_impl(
755                        repo,
756                        left_raw,
757                        index_dwim,
758                        commit_only_hex,
759                        use_disambiguate_config,
760                        false,
761                        false,
762                        false,
763                    )?
764                },
765            )?;
766            let right_oid = peel_to_commit_for_merge_base(
767                repo,
768                if right_raw.is_empty() {
769                    resolve_revision_impl(
770                        repo,
771                        "HEAD",
772                        index_dwim,
773                        commit_only_hex,
774                        use_disambiguate_config,
775                        false,
776                        false,
777                        false,
778                    )?
779                } else {
780                    resolve_revision_impl(
781                        repo,
782                        right_raw,
783                        index_dwim,
784                        commit_only_hex,
785                        use_disambiguate_config,
786                        false,
787                        false,
788                        false,
789                    )?
790                },
791            )?;
792            let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_oid, &[right_oid])?;
793            return bases
794                .into_iter()
795                .next()
796                .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
797        }
798    }
799
800    // Handle <rev>:<path> — resolve a tree entry.
801    // Must come after :/ handling. The colon must not be inside `^{...}` (e.g.
802    // `other^{/msg:}:file`) and must not be the `:path` / `:N:path` index forms.
803    if let Some((before, after)) = split_treeish_colon(spec) {
804        if !before.is_empty() && !spec.starts_with(":/") {
805            // <rev>:<path> — resolve rev to tree, then navigate path
806            let rev_oid = match resolve_revision_impl(
807                repo,
808                before,
809                index_dwim,
810                commit_only_hex,
811                use_disambiguate_config,
812                true,
813                false,
814                false,
815            ) {
816                Ok(o) => o,
817                Err(Error::ObjectNotFound(s)) if s == before => {
818                    return Err(Error::Message(format!(
819                        "fatal: invalid object name '{before}'."
820                    )));
821                }
822                Err(Error::Message(msg)) if msg.contains("ambiguous argument") => {
823                    return Err(Error::Message(format!(
824                        "fatal: invalid object name '{before}'."
825                    )));
826                }
827                Err(e) => return Err(e),
828            };
829            let tree_oid = peel_to_tree(repo, rev_oid)?;
830            if after.is_empty() {
831                // <rev>: means the tree itself
832                return Ok(tree_oid);
833            }
834            let clean_path = match normalize_colon_path_for_tree(repo, after) {
835                Ok(p) => p,
836                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
837                    let wt = repo
838                        .work_tree
839                        .as_ref()
840                        .and_then(|p| p.canonicalize().ok())
841                        .map(|p| p.display().to_string())
842                        .unwrap_or_default();
843                    return Err(Error::Message(format!(
844                        "fatal: '{after}' is outside repository at '{wt}'"
845                    )));
846                }
847                Err(e) => return Err(e),
848            };
849            return resolve_tree_path(repo, &tree_oid, &clean_path)
850                .map_err(|e| diagnose_tree_path_error(repo, before, after, &clean_path, e));
851        }
852    }
853
854    let (base_with_nav, peel) = parse_peel_suffix(spec);
855    let (base, nav_steps) = parse_nav_steps(base_with_nav);
856    let peel_for_hex = peel
857        .or(((treeish_colon_lhs || implicit_tree_abbrev) && peel.is_none()).then_some("tree"))
858        .or((implicit_blob_abbrev && peel.is_none()).then_some("blob"));
859    let mut oid = resolve_base(
860        repo,
861        base,
862        index_dwim,
863        commit_only_hex,
864        use_disambiguate_config,
865        peel_for_hex,
866        implicit_tree_abbrev,
867        implicit_blob_abbrev,
868    )?;
869    for step in nav_steps {
870        oid = apply_nav_step(repo, oid, step).map_err(|e| {
871            if matches!(e, Error::ObjectNotFound(_)) {
872                Error::Message(format!(
873                    "fatal: ambiguous argument '{spec}': unknown revision or path not in the working tree.\n\
874Use '--' to separate paths from revisions, like this:\n\
875'git <command> [<revision>...] -- [<file>...]'"
876                ))
877            } else {
878                e
879            }
880        })?;
881    }
882    apply_peel(repo, oid, peel)
883}
884
885/// Normalize a path from `treeish:path` against the work tree and return a `/`-separated path
886/// relative to the repository root (for tree lookup).
887fn normalize_path_components(path: PathBuf) -> PathBuf {
888    let mut out = PathBuf::new();
889    for c in path.components() {
890        match c {
891            Component::Prefix(_) | Component::RootDir => out.push(c),
892            Component::CurDir => {}
893            Component::ParentDir => {
894                let _ = out.pop();
895            }
896            Component::Normal(x) => out.push(x),
897        }
898    }
899    out
900}
901
902fn normalize_colon_path_for_tree(repo: &Repository, raw_path: &str) -> Result<String> {
903    let work_tree = repo.work_tree.as_ref().ok_or_else(|| {
904        Error::InvalidRef("relative path syntax can't be used outside working tree".to_owned())
905    })?;
906
907    let cwd = std::env::current_dir().map_err(Error::Io)?;
908    let wt_canon = work_tree.canonicalize().map_err(Error::Io)?;
909
910    let cwd_relative = raw_path.starts_with("./") || raw_path.starts_with("../") || raw_path == ".";
911    if cwd_relative && !path_is_within(&cwd, work_tree) {
912        return Err(Error::InvalidRef(
913            "relative path syntax can't be used outside working tree".to_owned(),
914        ));
915    }
916
917    // `./` / `../` / `.` are relative to cwd; other relative paths are relative to work tree.
918    let full = if raw_path.starts_with('/') {
919        PathBuf::from(raw_path)
920    } else if cwd_relative {
921        cwd.join(raw_path)
922    } else {
923        work_tree.join(raw_path)
924    };
925    let full = normalize_path_components(full);
926
927    if !path_is_within(&full, &wt_canon) {
928        return Err(Error::InvalidRef("outside repository".to_owned()));
929    }
930    let rel = full
931        .strip_prefix(&wt_canon)
932        .map_err(|_| Error::InvalidRef("outside repository".to_owned()))?;
933    let s = rel.to_string_lossy().replace('\\', "/");
934    Ok(s.trim_end_matches('/').to_owned())
935}
936
937/// Peel tags to a commit OID for merge-base computation (`A...B` and `rev-parse` output).
938pub fn peel_to_commit_for_merge_base(repo: &Repository, mut oid: ObjectId) -> Result<ObjectId> {
939    oid = apply_peel(repo, oid, Some(""))?;
940    let obj = repo.odb.read(&oid)?;
941    match obj.kind {
942        ObjectKind::Commit => Ok(oid),
943        ObjectKind::Tree => Err(Error::InvalidRef(format!(
944            "object {oid} does not name a commit"
945        ))),
946        ObjectKind::Blob => Err(Error::InvalidRef(format!(
947            "object {oid} does not name a commit"
948        ))),
949        ObjectKind::Tag => Err(Error::InvalidRef("unexpected tag after peel".to_owned())),
950    }
951}
952
953/// Peel an object to a tree (commit → tree, tree → tree).
954fn peel_to_tree(repo: &Repository, oid: ObjectId) -> Result<ObjectId> {
955    let obj = repo.odb.read(&oid)?;
956    match obj.kind {
957        crate::objects::ObjectKind::Tree => Ok(oid),
958        crate::objects::ObjectKind::Commit => {
959            let commit = crate::objects::parse_commit(&obj.data)?;
960            Ok(commit.tree)
961        }
962        crate::objects::ObjectKind::Tag => {
963            let tag = crate::objects::parse_tag(&obj.data)?;
964            peel_to_tree(repo, tag.object)
965        }
966        _ => Err(Error::ObjectNotFound(format!(
967            "cannot peel {} to tree",
968            oid
969        ))),
970    }
971}
972
973/// Navigate a tree to find an object at a given path.
974fn resolve_tree_path(repo: &Repository, tree_oid: &ObjectId, path: &str) -> Result<ObjectId> {
975    let obj = repo.odb.read(tree_oid)?;
976    let entries = crate::objects::parse_tree(&obj.data)?;
977    let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
978    if components.is_empty() {
979        return Ok(*tree_oid);
980    }
981    let first = components[0];
982    let rest: Vec<&str> = components[1..].to_vec();
983    for entry in entries {
984        let name = String::from_utf8_lossy(&entry.name);
985        if name == first {
986            if rest.is_empty() {
987                return Ok(entry.oid);
988            } else {
989                return resolve_tree_path(repo, &entry.oid, &rest.join("/"));
990            }
991        }
992    }
993    Err(Error::ObjectNotFound(format!(
994        "path '{}' not found in tree {}",
995        path, tree_oid
996    )))
997}
998
999/// A single parent/ancestor navigation step.
1000#[derive(Debug, Clone, Copy)]
1001enum NavStep {
1002    /// `^N` — navigate to the Nth parent (1-indexed; 0 is a no-op).
1003    ParentN(usize),
1004    /// `~N` — follow the first parent N times.
1005    AncestorN(usize),
1006}
1007
1008/// Parse and strip any trailing `^N` / `~N` navigation steps from `spec`.
1009///
1010/// Returns `(base, steps)` where `steps` are in left-to-right application order.
1011fn parse_nav_steps(spec: &str) -> (&str, Vec<NavStep>) {
1012    let mut steps = Vec::new();
1013    let mut remaining = spec;
1014
1015    loop {
1016        // Try `~<digits>` or bare `~` at the end.
1017        if let Some(tilde_pos) = remaining.rfind('~') {
1018            let after = &remaining[tilde_pos + 1..];
1019            if after.is_empty() {
1020                // bare `~` = `~1`
1021                steps.push(NavStep::AncestorN(1));
1022                remaining = &remaining[..tilde_pos];
1023                continue;
1024            }
1025            if after.bytes().all(|b| b.is_ascii_digit()) {
1026                let n: usize = after.parse().unwrap_or(1);
1027                steps.push(NavStep::AncestorN(n));
1028                remaining = &remaining[..tilde_pos];
1029                continue;
1030            }
1031        }
1032
1033        // Try `^<digits>` or bare `^` at the end (but not `^{...}` — peel strips those first).
1034        if let Some(caret_pos) = remaining.rfind('^') {
1035            let after = &remaining[caret_pos + 1..];
1036            if after.is_empty() {
1037                // bare `^` = `^1`
1038                steps.push(NavStep::ParentN(1));
1039                remaining = &remaining[..caret_pos];
1040                continue;
1041            }
1042            if after.bytes().all(|b| b.is_ascii_digit()) && !after.is_empty() {
1043                let n: usize = after.parse().unwrap_or(usize::MAX);
1044                steps.push(NavStep::ParentN(n));
1045                remaining = &remaining[..caret_pos];
1046                continue;
1047            }
1048        }
1049
1050        break;
1051    }
1052
1053    steps.reverse();
1054    (remaining, steps)
1055}
1056
1057/// Apply a single navigation step to an OID, resolving parent/ancestor links.
1058fn apply_nav_step(repo: &Repository, oid: ObjectId, step: NavStep) -> Result<ObjectId> {
1059    match step {
1060        NavStep::ParentN(0) => Ok(oid),
1061        NavStep::ParentN(n) => {
1062            let obj = repo.odb.read(&oid)?;
1063            if obj.kind != ObjectKind::Commit {
1064                return Err(Error::InvalidRef(format!("{oid} is not a commit")));
1065            }
1066            let commit = parse_commit(&obj.data)?;
1067            commit
1068                .parents
1069                .get(n - 1)
1070                .copied()
1071                .ok_or_else(|| Error::ObjectNotFound(format!("{oid}^{n}")))
1072        }
1073        NavStep::AncestorN(n) => {
1074            let mut current = oid;
1075            for _ in 0..n {
1076                current = apply_nav_step(repo, current, NavStep::ParentN(1))?;
1077            }
1078            Ok(current)
1079        }
1080    }
1081}
1082
1083/// Abbreviate an object ID to a unique prefix.
1084///
1085/// The returned prefix is at least `min_len` and at most 40 hex characters.
1086///
1087/// # Errors
1088///
1089/// Returns [`Error::ObjectNotFound`] when the target OID does not exist in the
1090/// object database.
1091pub fn abbreviate_object_id(repo: &Repository, oid: ObjectId, min_len: usize) -> Result<String> {
1092    let min_len = min_len.clamp(4, 40);
1093    let target = oid.to_hex();
1094
1095    // If object doesn't exist, just return the minimum abbreviation
1096    if !repo.odb.exists(&oid) {
1097        return Ok(target[..min_len].to_owned());
1098    }
1099
1100    let all = collect_loose_object_ids(repo)?;
1101
1102    for len in min_len..=40 {
1103        let prefix = &target[..len];
1104        let matches = all
1105            .iter()
1106            .filter(|candidate| candidate.starts_with(prefix))
1107            .count();
1108        if matches <= 1 {
1109            return Ok(prefix.to_owned());
1110        }
1111    }
1112
1113    Ok(target)
1114}
1115
1116/// Render `path` relative to `cwd` with `/` separators.
1117#[must_use]
1118pub fn to_relative_path(path: &Path, cwd: &Path) -> String {
1119    let path_components = normalize_components(path);
1120    let cwd_components = normalize_components(cwd);
1121
1122    let mut common = 0usize;
1123    let max_common = path_components.len().min(cwd_components.len());
1124    while common < max_common && path_components[common] == cwd_components[common] {
1125        common += 1;
1126    }
1127
1128    let mut parts = Vec::new();
1129    let up_count = cwd_components.len().saturating_sub(common);
1130    for _ in 0..up_count {
1131        parts.push("..".to_owned());
1132    }
1133    for item in path_components.iter().skip(common) {
1134        parts.push(item.clone());
1135    }
1136
1137    if parts.is_empty() {
1138        ".".to_owned()
1139    } else {
1140        parts.join("/")
1141    }
1142}
1143
1144fn object_storage_dirs_for_abbrev(repo: &Repository) -> Result<Vec<PathBuf>> {
1145    let mut dirs = Vec::new();
1146    let primary = repo.odb.objects_dir().to_path_buf();
1147    dirs.push(primary.clone());
1148    if let Ok(alts) = pack::read_alternates_recursive(&primary) {
1149        for alt in alts {
1150            if !dirs.iter().any(|d| d == &alt) {
1151                dirs.push(alt);
1152            }
1153        }
1154    }
1155    Ok(dirs)
1156}
1157
1158fn collect_pack_oids_with_prefix(objects_dir: &Path, prefix: &str) -> Result<Vec<ObjectId>> {
1159    let mut out = Vec::new();
1160    for idx in pack::read_local_pack_indexes(objects_dir)? {
1161        for e in idx.entries {
1162            if e.oid.to_hex().starts_with(prefix) {
1163                out.push(e.oid);
1164            }
1165        }
1166    }
1167    Ok(out)
1168}
1169
1170fn disambiguate_kind_rank(kind: ObjectKind) -> u8 {
1171    match kind {
1172        ObjectKind::Tag => 0,
1173        ObjectKind::Commit => 1,
1174        ObjectKind::Tree => 2,
1175        ObjectKind::Blob => 3,
1176    }
1177}
1178
1179fn oid_satisfies_peel_filter(repo: &Repository, oid: ObjectId, peel_inner: &str) -> bool {
1180    apply_peel(repo, oid, Some(peel_inner)).is_ok()
1181}
1182
1183/// Lines for `hint:` output when a short object id is ambiguous (type order, then hex).
1184pub fn ambiguous_object_hint_lines(
1185    repo: &Repository,
1186    short_prefix: &str,
1187    peel_filter: Option<&str>,
1188) -> Result<Vec<String>> {
1189    let mut typed: Vec<(u8, String, &'static str)> = Vec::new();
1190    let mut bad_hex: Vec<String> = Vec::new();
1191    for oid in list_all_abbrev_matches(repo, short_prefix)? {
1192        let hex = oid.to_hex();
1193        match repo.odb.read(&oid) {
1194            Ok(obj) => {
1195                let ok = peel_filter.is_none_or(|p| oid_satisfies_peel_filter(repo, oid, p));
1196                if ok {
1197                    typed.push((disambiguate_kind_rank(obj.kind), hex, obj.kind.as_str()));
1198                }
1199            }
1200            Err(_) => bad_hex.push(hex),
1201        }
1202    }
1203    if typed.is_empty() && peel_filter.is_some() {
1204        return ambiguous_object_hint_lines(repo, short_prefix, None);
1205    }
1206    bad_hex.sort();
1207    typed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1208    let mut out = Vec::new();
1209    for h in bad_hex {
1210        out.push(format!("hint:   {h} [bad object]"));
1211    }
1212    for (_, hex, kind) in typed {
1213        out.push(format!("hint:   {hex} {kind}"));
1214    }
1215    Ok(out)
1216}
1217
1218fn read_core_disambiguate(repo: &Repository) -> Option<&'static str> {
1219    let config = ConfigSet::load(Some(&repo.git_dir), true).unwrap_or_else(|_| ConfigSet::new());
1220    let v = config.get("core.disambiguate")?;
1221    match v.to_ascii_lowercase().as_str() {
1222        "committish" | "commit" => Some("commit"),
1223        "treeish" | "tree" => Some("tree"),
1224        "blob" => Some("blob"),
1225        "tag" => Some("tag"),
1226        "none" => None,
1227        _ => None,
1228    }
1229}
1230
1231fn disambiguate_hex_by_peel(
1232    repo: &Repository,
1233    spec: &str,
1234    matches: &[ObjectId],
1235    peel: &str,
1236) -> Result<ObjectId> {
1237    let peel_some = Some(peel);
1238    let filtered: Vec<ObjectId> = matches
1239        .iter()
1240        .copied()
1241        .filter(|oid| apply_peel(repo, *oid, peel_some).is_ok())
1242        .collect();
1243    if filtered.len() == 1 {
1244        return Ok(filtered[0]);
1245    }
1246    if filtered.is_empty() {
1247        return Err(Error::InvalidRef(format!(
1248            "short object ID {spec} is ambiguous"
1249        )));
1250    }
1251    let mut peeled_targets: HashSet<ObjectId> = HashSet::new();
1252    for oid in &filtered {
1253        if let Ok(p) = apply_peel(repo, *oid, peel_some) {
1254            peeled_targets.insert(p);
1255        }
1256    }
1257    if peeled_targets.len() == 1 {
1258        // Several objects (e.g. commit + tag) may peel to the same commit; any representative
1259        // is valid for subsequent `apply_peel` in `resolve_revision_impl`.
1260        let mut sorted = filtered;
1261        sorted.sort_by_key(|o| o.to_hex());
1262        return Ok(sorted[0]);
1263    }
1264    Err(Error::InvalidRef(format!(
1265        "short object ID {spec} is ambiguous"
1266    )))
1267}
1268
1269fn commit_reachable_closure(repo: &Repository, start: ObjectId) -> Result<HashSet<ObjectId>> {
1270    use std::collections::VecDeque;
1271    let mut seen = HashSet::new();
1272    let mut q = VecDeque::from([start]);
1273    while let Some(oid) = q.pop_front() {
1274        if !seen.insert(oid) {
1275            continue;
1276        }
1277        let obj = match repo.odb.read(&oid) {
1278            Ok(o) => o,
1279            Err(_) => continue,
1280        };
1281        if obj.kind != ObjectKind::Commit {
1282            continue;
1283        }
1284        let commit = match parse_commit(&obj.data) {
1285            Ok(c) => c,
1286            Err(_) => continue,
1287        };
1288        for p in &commit.parents {
1289            q.push_back(*p);
1290        }
1291    }
1292    Ok(seen)
1293}
1294
1295/// `git rev-list --count <tag>..<head>` — commits reachable from `head` but not from `tag`.
1296fn describe_generation_count(
1297    repo: &Repository,
1298    head: ObjectId,
1299    tag_commit: ObjectId,
1300) -> Result<usize> {
1301    let from_tag = commit_reachable_closure(repo, tag_commit)?;
1302    let from_head = commit_reachable_closure(repo, head)?;
1303    Ok(from_head.difference(&from_tag).count())
1304}
1305
1306fn try_resolve_describe_name(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
1307    let re = Regex::new(r"(?i)^(.+)-(\d+)-g([0-9a-fA-F]+)$")
1308        .map_err(|_| Error::Message("internal: describe regex".to_owned()))?;
1309    let Some(caps) = re.captures(spec) else {
1310        return Ok(None);
1311    };
1312    let tag_name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
1313    let gen: usize = caps
1314        .get(2)
1315        .and_then(|m| m.as_str().parse().ok())
1316        .unwrap_or(0);
1317    let hex_abbrev = caps.get(3).map(|m| m.as_str()).unwrap_or("");
1318    if tag_name.is_empty() || hex_abbrev.is_empty() {
1319        return Ok(None);
1320    }
1321    let hex_lower = hex_abbrev.to_ascii_lowercase();
1322    let tag_oid = match refs::resolve_ref(&repo.git_dir, &format!("refs/tags/{tag_name}"))
1323        .or_else(|_| refs::resolve_ref(&repo.git_dir, tag_name))
1324    {
1325        Ok(o) => o,
1326        Err(_) => return Ok(None),
1327    };
1328    let tag_commit = peel_to_commit_for_merge_base(repo, tag_oid)?;
1329    let mut candidates: Vec<ObjectId> = find_abbrev_matches(repo, &hex_lower)?
1330        .into_iter()
1331        .filter(|oid| {
1332            repo.odb
1333                .read(oid)
1334                .map(|o| o.kind == ObjectKind::Commit)
1335                .unwrap_or(false)
1336                && describe_generation_count(repo, *oid, tag_commit).ok() == Some(gen)
1337        })
1338        .collect();
1339    candidates.sort_by_key(|o| o.to_hex());
1340    match candidates.len() {
1341        0 => Err(Error::ObjectNotFound(spec.to_owned())),
1342        1 => Ok(Some(candidates[0])),
1343        _ => Err(Error::InvalidRef(format!(
1344            "short object ID {hex_abbrev} is ambiguous"
1345        ))),
1346    }
1347}
1348
1349fn resolve_base(
1350    repo: &Repository,
1351    spec: &str,
1352    index_dwim: bool,
1353    commit_only_hex: bool,
1354    use_disambiguate_config: bool,
1355    peel_for_disambig: Option<&str>,
1356    implicit_tree_abbrev: bool,
1357    implicit_blob_abbrev: bool,
1358) -> Result<ObjectId> {
1359    // Standalone `@` is an alias for `HEAD` in revision parsing.
1360    if spec == "@" {
1361        return resolve_base(
1362            repo,
1363            "HEAD",
1364            index_dwim,
1365            commit_only_hex,
1366            use_disambiguate_config,
1367            peel_for_disambig,
1368            implicit_tree_abbrev,
1369            implicit_blob_abbrev,
1370        );
1371    }
1372
1373    // `@{-N}` must run before reflog parsing so `@{-1}@{1}` is not misread as `@{-1}` + `@{1}`.
1374    if spec.starts_with("@{-") {
1375        if let Some(close) = spec[3..].find('}') {
1376            let n_str = &spec[3..3 + close];
1377            if let Ok(n) = n_str.parse::<usize>() {
1378                if n >= 1 {
1379                    let suffix = &spec[3 + close + 1..];
1380                    if suffix.is_empty() {
1381                        if let Some(oid) = try_resolve_at_minus(repo, spec)? {
1382                            return Ok(oid);
1383                        }
1384                    } else {
1385                        let branch = resolve_at_minus_to_branch(repo, n)?;
1386                        let new_spec = format!("{branch}{suffix}");
1387                        return resolve_base(
1388                            repo,
1389                            &new_spec,
1390                            index_dwim,
1391                            commit_only_hex,
1392                            use_disambiguate_config,
1393                            peel_for_disambig,
1394                            implicit_tree_abbrev,
1395                            implicit_blob_abbrev,
1396                        );
1397                    }
1398                }
1399            }
1400        }
1401    }
1402
1403    // Handle @{upstream} / @{u} / @{push} suffixes (including compounds like branch@{u}@{1})
1404    if upstream_suffix_info(spec).is_some() {
1405        let full_ref = resolve_upstream_symbolic_name(repo, spec)?;
1406        return refs::resolve_ref(&repo.git_dir, &full_ref)
1407            .map_err(|_| Error::ObjectNotFound(spec.to_owned()));
1408    }
1409
1410    // Reflog selectors: `main@{1}`, `@{3}` (current branch), `other@{u}@{1}`, etc.
1411    if let Some(oid) = try_resolve_reflog_index(repo, spec)? {
1412        return Ok(oid);
1413    }
1414
1415    // Handle `:/pattern` — search commit messages from HEAD
1416    if let Some(pattern) = spec.strip_prefix(":/") {
1417        if !pattern.is_empty() {
1418            return resolve_commit_message_search(repo, pattern);
1419        }
1420    }
1421
1422    // Handle `:N:path` — look up path in the index at stage N
1423    // Also handle `:path` — look up path in the index (stage 0)
1424    if let Some(rest) = spec.strip_prefix(':') {
1425        if !rest.is_empty() && !rest.starts_with('/') {
1426            // Check for :N:path pattern (N is a single digit 0-3)
1427            if rest.len() >= 3 && rest.as_bytes()[1] == b':' {
1428                if let Some(stage_char) = rest.chars().next() {
1429                    if let Some(stage) = stage_char.to_digit(10) {
1430                        if stage <= 3 {
1431                            let raw_path = &rest[2..];
1432                            let path = match normalize_colon_path_for_tree(repo, raw_path) {
1433                                Ok(p) => p,
1434                                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
1435                                    let wt = repo
1436                                        .work_tree
1437                                        .as_ref()
1438                                        .and_then(|p| p.canonicalize().ok())
1439                                        .map(|p| p.display().to_string())
1440                                        .unwrap_or_default();
1441                                    return Err(Error::Message(format!(
1442                                        "fatal: '{raw_path}' is outside repository at '{wt}'"
1443                                    )));
1444                                }
1445                                Err(e) => return Err(e),
1446                            };
1447                            return resolve_index_path_at_stage(repo, &path, stage as u8).map_err(
1448                                |e| diagnose_index_path_error(repo, &path, stage as u8, e),
1449                            );
1450                        }
1451                    }
1452                }
1453            }
1454            let clean_rest = match normalize_colon_path_for_tree(repo, rest) {
1455                Ok(p) => p,
1456                Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
1457                    let wt = repo
1458                        .work_tree
1459                        .as_ref()
1460                        .and_then(|p| p.canonicalize().ok())
1461                        .map(|p| p.display().to_string())
1462                        .unwrap_or_default();
1463                    return Err(Error::Message(format!(
1464                        "fatal: '{rest}' is outside repository at '{wt}'"
1465                    )));
1466                }
1467                Err(e) => return Err(e),
1468            };
1469            return resolve_index_path(repo, &clean_rest)
1470                .map_err(|e| diagnose_index_path_error(repo, &clean_rest, 0, e));
1471        }
1472    }
1473
1474    if let Some((treeish, path)) = split_treeish_spec(spec) {
1475        let root_oid = resolve_revision_impl(
1476            repo,
1477            treeish,
1478            index_dwim,
1479            commit_only_hex,
1480            use_disambiguate_config,
1481            false,
1482            false,
1483            false,
1484        )?;
1485        return resolve_treeish_path(repo, root_oid, path);
1486    }
1487
1488    if let Ok(oid) = spec.parse::<ObjectId>() {
1489        // A full 40-hex OID is always accepted, even if the object
1490        // doesn't exist in the ODB (matches git behavior).
1491        let rn = format!("refs/heads/{spec}");
1492        if refs::resolve_ref(&repo.git_dir, &rn).is_ok() {
1493            eprintln!("warning: refname '{spec}' is ambiguous.");
1494        }
1495        return Ok(oid);
1496    }
1497
1498    match try_resolve_describe_name(repo, spec) {
1499        Ok(Some(oid)) => return Ok(oid),
1500        Err(e) => return Err(e),
1501        Ok(None) => {}
1502    }
1503
1504    // Short hex-like token that is also a branch name (e.g. `b1`, `dead`): prefer the branch
1505    // tip over abbreviated object-id resolution so `git rebase b1 b2` uses the branch (t9903).
1506    if is_hex_prefix(spec) && spec.len() < 40 {
1507        let branch_ref = format!("refs/heads/{spec}");
1508        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &branch_ref) {
1509            return Ok(oid);
1510        }
1511    }
1512
1513    if is_hex_prefix(spec) {
1514        let matches = find_abbrev_matches(repo, spec)?;
1515        if matches.len() == 1 {
1516            return Ok(matches[0]);
1517        }
1518        if matches.len() > 1 {
1519            if commit_only_hex {
1520                return disambiguate_hex_by_peel(repo, spec, &matches, "commit");
1521            }
1522            if let Some(p) = peel_for_disambig {
1523                return disambiguate_hex_by_peel(repo, spec, &matches, p);
1524            }
1525            if use_disambiguate_config {
1526                if let Some(pref) = read_core_disambiguate(repo) {
1527                    if let Ok(oid) = disambiguate_hex_by_peel(repo, spec, &matches, pref) {
1528                        return Ok(oid);
1529                    }
1530                }
1531            }
1532            return Err(Error::InvalidRef(format!(
1533                "short object ID {} is ambiguous",
1534                spec
1535            )));
1536        }
1537    }
1538
1539    if let Ok(oid) = refs::resolve_ref(&repo.git_dir, spec) {
1540        return Ok(oid);
1541    }
1542    // Remote name alone (`origin`, `upstream`): resolve like Git via
1543    // `refs/remotes/<name>/HEAD` (symref to the default remote-tracking branch).
1544    // Skip when a local branch with the same short name exists.
1545    if !spec.contains('/')
1546        && !spec.starts_with('.')
1547        && spec != "HEAD"
1548        && spec != "FETCH_HEAD"
1549        && spec != "MERGE_HEAD"
1550        && spec != "CHERRY_PICK_HEAD"
1551        && spec != "REVERT_HEAD"
1552        && spec != "REBASE_HEAD"
1553        && spec != "AUTO_MERGE"
1554        && spec != "stash"
1555    {
1556        let local_branch = format!("refs/heads/{spec}");
1557        if refs::resolve_ref(&repo.git_dir, &local_branch).is_err() {
1558            let remote_head = format!("refs/remotes/{spec}/HEAD");
1559            if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &remote_head) {
1560                return Ok(oid);
1561            }
1562        }
1563    }
1564    // DWIM: bare `stash` refers to `refs/stash` (like upstream Git), not `.git/stash`.
1565    if spec == "stash" {
1566        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, "refs/stash") {
1567            return Ok(oid);
1568        }
1569    }
1570    // Short names: resolve `refs/heads/<spec>` and `refs/tags/<spec>`. When both exist and
1571    // disagree, prefer the branch (matches `git checkout` / `git reset` for names like `b1`)
1572    // and warn, matching upstream ambiguous-refname behavior.
1573    let head_ref = format!("refs/heads/{spec}");
1574    let tag_ref = format!("refs/tags/{spec}");
1575    let head_oid = refs::resolve_ref(&repo.git_dir, &head_ref).ok();
1576    let tag_oid = refs::resolve_ref(&repo.git_dir, &tag_ref).ok();
1577    match (head_oid, tag_oid) {
1578        (Some(h), Some(t)) if h != t => {
1579            eprintln!("warning: refname '{spec}' is ambiguous.");
1580            return Ok(h);
1581        }
1582        (Some(h), _) => return Ok(h),
1583        (None, Some(t)) => return Ok(t),
1584        (None, None) => {}
1585    }
1586    for candidate in &[format!("refs/remotes/{spec}"), format!("refs/notes/{spec}")] {
1587        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, candidate) {
1588            return Ok(oid);
1589        }
1590    }
1591
1592    // `git log one` / `git rev-parse one`: remote name → `refs/remotes/<name>/HEAD` (Git DWIM).
1593    if let Some(head_ref) = remote_tracking_head_symbolic_target(repo, spec) {
1594        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &head_ref) {
1595            return Ok(oid);
1596        }
1597    }
1598
1599    // DWIM: `checkout B2` when only `refs/remotes/origin/B2` exists (common after `fetch`).
1600    if !spec.contains('/') && spec != "HEAD" && spec != "FETCH_HEAD" && spec != "MERGE_HEAD" {
1601        const REMOTES: &str = "refs/remotes/";
1602        if let Ok(remote_refs) = refs::list_refs(&repo.git_dir, REMOTES) {
1603            let matches: Vec<ObjectId> = remote_refs
1604                .into_iter()
1605                .filter(|(r, _)| {
1606                    r.strip_prefix(REMOTES)
1607                        .is_some_and(|rest| rest == spec || rest.ends_with(&format!("/{spec}")))
1608                })
1609                .map(|(_, oid)| oid)
1610                .collect();
1611            if matches.len() == 1 {
1612                return Ok(matches[0]);
1613            }
1614            if matches.len() > 1 {
1615                return Err(Error::InvalidRef(format!(
1616                    "ambiguous refname '{spec}': matches multiple remote-tracking branches"
1617                )));
1618            }
1619        }
1620    }
1621
1622    // As a last resort, try resolving as an index path (porcelain / DWIM only).
1623    if !spec.contains(':') && !spec.starts_with('-') {
1624        if index_dwim {
1625            if let Ok(oid) = resolve_index_path(repo, spec) {
1626                return Ok(oid);
1627            }
1628        }
1629        return Err(Error::Message(format!(
1630            "fatal: ambiguous argument '{spec}': unknown revision or path not in the working tree.\n\
1631Use '--' to separate paths from revisions, like this:\n\
1632'git <command> [<revision>...] -- [<file>...]'"
1633        )));
1634    }
1635    Err(Error::ObjectNotFound(spec.to_owned()))
1636}
1637
1638/// Resolve `@{-N}` to the branch name (e.g. "side"), not to an OID.
1639fn resolve_at_minus_to_branch(repo: &Repository, n: usize) -> Result<String> {
1640    let entries = read_reflog(&repo.git_dir, "HEAD")?;
1641    let mut count = 0usize;
1642    for entry in entries.iter().rev() {
1643        let msg = &entry.message;
1644        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
1645            count += 1;
1646            if count == n {
1647                if let Some(to_pos) = rest.find(" to ") {
1648                    return Ok(rest[..to_pos].to_string());
1649                }
1650            }
1651        }
1652    }
1653    Err(Error::InvalidRef(format!(
1654        "@{{-{n}}}: only {count} checkout(s) in reflog"
1655    )))
1656}
1657
1658/// Try to resolve `@{-N}` syntax — the Nth previously checked out branch.
1659/// Returns the resolved OID if matching, or None if not matching.
1660fn try_resolve_at_minus(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
1661    // Match @{-N} only (no ref prefix)
1662    if !spec.starts_with("@{-") || !spec.ends_with('}') {
1663        return Ok(None);
1664    }
1665    let inner = &spec[3..spec.len() - 1];
1666    let n: usize = match inner.parse() {
1667        Ok(n) if n >= 1 => n,
1668        _ => return Ok(None),
1669    };
1670    // Read HEAD reflog and find the Nth "checkout: moving from X to Y" entry
1671    let entries = read_reflog(&repo.git_dir, "HEAD")?;
1672    let mut count = 0usize;
1673    // Iterate newest-first
1674    for entry in entries.iter().rev() {
1675        let msg = &entry.message;
1676        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
1677            count += 1;
1678            if count == n {
1679                // Extract the "from" branch name
1680                if let Some(to_pos) = rest.find(" to ") {
1681                    let from_branch = &rest[..to_pos];
1682                    // Try to resolve the branch name
1683                    let ref_name = format!("refs/heads/{from_branch}");
1684                    if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &ref_name) {
1685                        return Ok(Some(oid));
1686                    }
1687                    // Try as-is (might be a detached HEAD SHA)
1688                    if let Ok(oid) = from_branch.parse::<ObjectId>() {
1689                        if repo.odb.exists(&oid) {
1690                            return Ok(Some(oid));
1691                        }
1692                    }
1693                    return Err(Error::InvalidRef(format!(
1694                        "cannot resolve @{{-{n}}}: branch '{}' not found",
1695                        from_branch
1696                    )));
1697                }
1698            }
1699        }
1700    }
1701    Err(Error::InvalidRef(format!(
1702        "@{{-{n}}}: only {count} checkout(s) in reflog"
1703    )))
1704}
1705
1706#[derive(Debug, Clone)]
1707enum AtStep {
1708    Index(usize),
1709    Date(i64),
1710    Upstream,
1711    Push,
1712    Now,
1713}
1714
1715fn try_parse_at_step_inner(inner: &str) -> Option<AtStep> {
1716    if inner.eq_ignore_ascii_case("u") || inner.eq_ignore_ascii_case("upstream") {
1717        return Some(AtStep::Upstream);
1718    }
1719    if inner.eq_ignore_ascii_case("push") {
1720        return Some(AtStep::Push);
1721    }
1722    if inner.eq_ignore_ascii_case("now") {
1723        return Some(AtStep::Now);
1724    }
1725    if let Ok(n) = inner.parse::<usize>() {
1726        return Some(AtStep::Index(n));
1727    }
1728    approxidate(inner).map(AtStep::Date)
1729}
1730
1731fn next_reflog_at_open(spec: &str, mut from: usize) -> Option<usize> {
1732    let b = spec.as_bytes();
1733    while let Some(rel) = spec[from..].find("@{") {
1734        let i = from + rel;
1735        // `@{-N}` is previous-branch syntax, not a reflog selector — skip the whole token.
1736        if b.get(i + 2) == Some(&b'-') {
1737            let after_open = i + 2;
1738            let close = spec[after_open..].find('}').map(|j| after_open + j)?;
1739            from = close + 1;
1740            continue;
1741        }
1742        return Some(i);
1743    }
1744    None
1745}
1746
1747/// Split `spec` into a ref prefix and a chain of `@{...}` steps (empty chain → not a reflog form).
1748fn split_reflog_at_chain(spec: &str) -> Option<(String, Vec<AtStep>)> {
1749    let at = next_reflog_at_open(spec, 0)?;
1750    let prefix = spec[..at].to_owned();
1751    let mut steps = Vec::new();
1752    let mut pos = at;
1753    while pos < spec.len() {
1754        let rest = &spec[pos..];
1755        if !rest.starts_with("@{") {
1756            return None;
1757        }
1758        if rest.as_bytes().get(2) == Some(&b'-') {
1759            return None;
1760        }
1761        let inner_start = pos + 2;
1762        let close = spec[inner_start..].find('}').map(|i| inner_start + i)?;
1763        let inner = &spec[inner_start..close];
1764        let step = try_parse_at_step_inner(inner)?;
1765        steps.push(step);
1766        pos = close + 1;
1767    }
1768    if steps.is_empty() {
1769        return None;
1770    }
1771    Some((prefix, steps))
1772}
1773
1774fn dwim_refname(repo: &Repository, raw: &str) -> String {
1775    if raw.is_empty() || raw == "HEAD" || raw.starts_with("refs/") {
1776        return raw.to_owned();
1777    }
1778    let candidate = format!("refs/heads/{raw}");
1779    if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
1780        candidate
1781    } else {
1782        raw.to_owned()
1783    }
1784}
1785
1786fn reflog_display_name(refname_raw: &str, refname: &str) -> String {
1787    if refname_raw.is_empty() {
1788        if let Some(b) = refname.strip_prefix("refs/heads/") {
1789            return b.to_owned();
1790        }
1791        return refname.to_owned();
1792    }
1793    refname_raw.to_owned()
1794}
1795
1796fn resolve_reflog_oid(
1797    repo: &Repository,
1798    refname: &str,
1799    refname_raw: &str,
1800    index_or_date: ReflogSelector,
1801) -> Result<ObjectId> {
1802    let entries = read_reflog(&repo.git_dir, refname)?;
1803    let display = reflog_display_name(refname_raw, refname);
1804    if entries.is_empty() {
1805        return Err(Error::Message(format!(
1806            "fatal: log for '{display}' is empty"
1807        )));
1808    }
1809    match index_or_date {
1810        ReflogSelector::Index(index) => {
1811            let reversed_idx = entries.len().checked_sub(1 + index).ok_or_else(|| {
1812                Error::Message(format!(
1813                    "fatal: log for '{display}' only has {} entries",
1814                    entries.len()
1815                ))
1816            })?;
1817            Ok(entries[reversed_idx].new_oid)
1818        }
1819        ReflogSelector::Date(target_ts) => {
1820            for entry in entries.iter().rev() {
1821                let ts = parse_reflog_entry_timestamp(entry);
1822                if let Some(t) = ts {
1823                    if t <= target_ts {
1824                        return Ok(entry.new_oid);
1825                    }
1826                }
1827            }
1828            Ok(entries[0].new_oid)
1829        }
1830    }
1831}
1832
1833fn resolve_at_minus_token_to_branch(repo: &Repository, token: &str) -> Result<Option<String>> {
1834    if !token.starts_with("@{-") || !token.ends_with('}') {
1835        return Ok(None);
1836    }
1837    let inner = &token[3..token.len() - 1];
1838    let n: usize = inner
1839        .parse()
1840        .map_err(|_| Error::InvalidRef(format!("invalid N in @{{-N}} for '{token}'")))?;
1841    if n < 1 {
1842        return Ok(None);
1843    }
1844    Ok(Some(resolve_at_minus_to_branch(repo, n)?))
1845}
1846
1847/// Ref whose reflog `git log -g` should walk for a revision like `other@{u}` or `main@{1}`.
1848///
1849/// Returns `None` when `spec` is not a reflog-chain form (no `@{` step after the prefix).
1850pub fn reflog_walk_refname(repo: &Repository, spec: &str) -> Result<Option<String>> {
1851    let Some((prefix, steps)) = split_reflog_at_chain(spec) else {
1852        return Ok(None);
1853    };
1854
1855    let prefix_resolved = if let Some(b) = resolve_at_minus_token_to_branch(repo, &prefix)? {
1856        b
1857    } else {
1858        prefix.clone()
1859    };
1860
1861    let mut current_spec = if prefix_resolved.is_empty() {
1862        if let Ok(Some(b)) = refs::read_head(&repo.git_dir) {
1863            if let Some(short) = b.strip_prefix("refs/heads/") {
1864                short.to_owned()
1865            } else {
1866                "HEAD".to_owned()
1867            }
1868        } else {
1869            "HEAD".to_owned()
1870        }
1871    } else {
1872        prefix_resolved
1873    };
1874
1875    let last_reflog_peel = steps
1876        .iter()
1877        .rposition(|s| matches!(s, AtStep::Index(_) | AtStep::Date(_) | AtStep::Now));
1878
1879    let limit = last_reflog_peel.unwrap_or(steps.len());
1880    for step in steps.iter().take(limit) {
1881        match step {
1882            AtStep::Upstream => {
1883                let base = if current_spec == "@" {
1884                    "HEAD"
1885                } else {
1886                    current_spec.as_str()
1887                };
1888                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{u}}"))?;
1889                current_spec = full;
1890            }
1891            AtStep::Push => {
1892                let base = if current_spec == "@" {
1893                    "HEAD"
1894                } else {
1895                    current_spec.as_str()
1896                };
1897                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{push}}"))?;
1898                current_spec = full;
1899            }
1900            AtStep::Now | AtStep::Index(_) | AtStep::Date(_) => {}
1901        }
1902    }
1903
1904    Ok(Some(dwim_refname(repo, current_spec.as_str())))
1905}
1906
1907/// Try to resolve `ref@{...}` with optional chained `@{...}` steps (e.g. `other@{u}@{1}`).
1908fn try_resolve_reflog_index(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
1909    let Some((prefix, steps)) = split_reflog_at_chain(spec) else {
1910        return Ok(None);
1911    };
1912
1913    let prefix_resolved = if let Some(b) = resolve_at_minus_token_to_branch(repo, &prefix)? {
1914        b
1915    } else {
1916        prefix.clone()
1917    };
1918
1919    let mut current_spec = if prefix_resolved.is_empty() {
1920        if let Ok(Some(b)) = refs::read_head(&repo.git_dir) {
1921            if let Some(short) = b.strip_prefix("refs/heads/") {
1922                short.to_owned()
1923            } else {
1924                "HEAD".to_owned()
1925            }
1926        } else {
1927            "HEAD".to_owned()
1928        }
1929    } else {
1930        prefix_resolved
1931    };
1932
1933    for (i, step) in steps.iter().enumerate() {
1934        match step {
1935            AtStep::Upstream => {
1936                let base = if current_spec == "@" {
1937                    "HEAD"
1938                } else {
1939                    current_spec.as_str()
1940                };
1941                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{u}}"))?;
1942                current_spec = full;
1943            }
1944            AtStep::Push => {
1945                let base = if current_spec == "@" {
1946                    "HEAD"
1947                } else {
1948                    current_spec.as_str()
1949                };
1950                let full = resolve_upstream_symbolic_name(repo, &format!("{base}@{{push}}"))?;
1951                current_spec = full;
1952            }
1953            AtStep::Now => {
1954                let refname_raw = current_spec.as_str();
1955                let refname = dwim_refname(repo, refname_raw);
1956                let oid =
1957                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Index(0))?;
1958                if i + 1 == steps.len() {
1959                    return Ok(Some(oid));
1960                }
1961                current_spec = oid.to_hex();
1962            }
1963            AtStep::Index(n) => {
1964                let refname_raw = current_spec.as_str();
1965                let refname = dwim_refname(repo, refname_raw);
1966                let oid =
1967                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Index(*n))?;
1968                if i + 1 == steps.len() {
1969                    return Ok(Some(oid));
1970                }
1971                current_spec = oid.to_hex();
1972            }
1973            AtStep::Date(ts) => {
1974                let refname_raw = current_spec.as_str();
1975                let refname = dwim_refname(repo, refname_raw);
1976                let oid =
1977                    resolve_reflog_oid(repo, &refname, refname_raw, ReflogSelector::Date(*ts))?;
1978                if i + 1 == steps.len() {
1979                    return Ok(Some(oid));
1980                }
1981                current_spec = oid.to_hex();
1982            }
1983        }
1984    }
1985
1986    let refname_raw = current_spec.as_str();
1987    let refname = dwim_refname(repo, refname_raw);
1988    refs::resolve_ref(&repo.git_dir, &refname)
1989        .map(Some)
1990        .map_err(|_| Error::ObjectNotFound(spec.to_owned()))
1991}
1992
1993enum ReflogSelector {
1994    Index(usize),
1995    Date(i64),
1996}
1997
1998/// Parse a timestamp from a reflog entry's identity string.
1999fn parse_reflog_entry_timestamp(entry: &crate::reflog::ReflogEntry) -> Option<i64> {
2000    // Identity looks like: "Name <email> 1234567890 +0000"
2001    let parts: Vec<&str> = entry.identity.rsplitn(3, ' ').collect();
2002    if parts.len() >= 2 {
2003        parts[1].parse::<i64>().ok()
2004    } else {
2005        None
2006    }
2007}
2008
2009/// Parse a reflog date selector string (e.g. `yesterday`, `2005-04-07`) to a Unix timestamp.
2010///
2011/// Used by `git log -g` display to match Git's `ref@{date}` formatting in tests.
2012#[must_use]
2013pub fn reflog_date_selector_timestamp(s: &str) -> Option<i64> {
2014    approxidate(s)
2015}
2016
2017/// Simple approximate date parser for reflog date lookups.
2018/// Handles formats like "2001-09-17", "3.hot.dogs.on.2001-09-17", etc.
2019fn approxidate(s: &str) -> Option<i64> {
2020    let now_ts = std::time::SystemTime::now()
2021        .duration_since(std::time::UNIX_EPOCH)
2022        .ok()
2023        .map(|d| d.as_secs() as i64)
2024        .unwrap_or(0);
2025    let lower = s.trim().to_ascii_lowercase();
2026    if lower == "now" {
2027        // Match Git's test harness: `test_tick` sets GIT_COMMITTER_DATE; `@{now}` must use that
2028        // clock, not wall time (t1507 `log -g other@{u}@{now}`).
2029        if let Ok(raw) =
2030            std::env::var("GIT_COMMITTER_DATE").or_else(|_| std::env::var("GIT_AUTHOR_DATE"))
2031        {
2032            let mut it = raw.split_whitespace();
2033            if let Some(ts) = it.next().and_then(|p| p.parse::<i64>().ok()) {
2034                return Some(ts);
2035            }
2036        }
2037        return Some(now_ts);
2038    }
2039    // Handle relative time: "N.unit.ago" or "N unit ago"
2040    // e.g. "1.year.ago", "2.weeks.ago", "3 hours ago"
2041    let relative = lower.replace('.', " ");
2042    let parts: Vec<&str> = relative.split_whitespace().collect();
2043    if parts.len() >= 2 {
2044        // Try to parse "N unit ago" or just "N unit"
2045        let (n_str, unit, is_ago) = if parts.len() >= 3 && parts[2] == "ago" {
2046            (parts[0], parts[1], true)
2047        } else if parts.len() == 2 {
2048            (parts[0], parts[1], false)
2049        } else {
2050            ("", "", false)
2051        };
2052        if !n_str.is_empty() {
2053            if let Ok(n) = n_str.parse::<i64>() {
2054                let secs: Option<i64> = match unit.trim_end_matches('s') {
2055                    "second" => Some(n),
2056                    "minute" => Some(n * 60),
2057                    "hour" => Some(n * 3600),
2058                    "day" => Some(n * 86400),
2059                    "week" => Some(n * 604800),
2060                    "month" => Some(n * 2592000),
2061                    "year" => Some(n * 31536000),
2062                    _ => None,
2063                };
2064                if let Some(s) = secs {
2065                    return Some(if is_ago || true {
2066                        now_ts - s
2067                    } else {
2068                        now_ts + s
2069                    });
2070                }
2071            }
2072        }
2073    }
2074    // Try to extract a YYYY-MM-DD pattern from the string
2075    let re_like = |input: &str| -> Option<i64> {
2076        // Scan for 4-digit year followed by -MM-DD
2077        for (i, _) in input.char_indices() {
2078            let rest = &input[i..];
2079            if rest.len() >= 10 {
2080                let bytes = rest.as_bytes();
2081                if bytes[4] == b'-'
2082                    && bytes[7] == b'-'
2083                    && bytes[0..4].iter().all(|b| b.is_ascii_digit())
2084                    && bytes[5..7].iter().all(|b| b.is_ascii_digit())
2085                    && bytes[8..10].iter().all(|b| b.is_ascii_digit())
2086                {
2087                    let year: i32 = rest[0..4].parse().ok()?;
2088                    let month: u8 = rest[5..7].parse().ok()?;
2089                    let day: u8 = rest[8..10].parse().ok()?;
2090                    let date = time::Date::from_calendar_date(
2091                        year,
2092                        time::Month::try_from(month).ok()?,
2093                        day,
2094                    )
2095                    .ok()?;
2096                    let dt = date.with_hms(0, 0, 0).ok()?;
2097                    let odt = dt.assume_utc();
2098                    return Some(odt.unix_timestamp());
2099                }
2100            }
2101        }
2102        None
2103    };
2104    re_like(s)
2105}
2106
2107fn head_tree_oid(repo: &Repository) -> Result<ObjectId> {
2108    let head_oid = refs::resolve_ref(&repo.git_dir, "HEAD")?;
2109    peel_to_tree(repo, head_oid)
2110}
2111
2112fn path_in_tree(repo: &Repository, tree_oid: ObjectId, path: &str) -> bool {
2113    resolve_tree_path(repo, &tree_oid, path).is_ok()
2114}
2115
2116fn path_in_index(repo: &Repository, path: &str, stage: u8) -> bool {
2117    resolve_index_path_at_stage(repo, path, stage).is_ok()
2118}
2119
2120fn diagnose_tree_path_error(
2121    repo: &Repository,
2122    rev_label: &str,
2123    raw_after_colon: &str,
2124    clean_path: &str,
2125    err: Error,
2126) -> Error {
2127    let Error::ObjectNotFound(msg) = err else {
2128        return err;
2129    };
2130    if !msg.contains("not found in tree") {
2131        return Error::ObjectNotFound(msg);
2132    }
2133    let rel_display: &str =
2134        if raw_after_colon.starts_with("./") || raw_after_colon.starts_with("../") {
2135            clean_path
2136        } else {
2137            raw_after_colon
2138        };
2139    if let Ok(head_tree) = head_tree_oid(repo) {
2140        if path_in_tree(repo, head_tree, clean_path) {
2141            return Error::Message(format!(
2142                "fatal: path '{rel_display}' exists on disk, but not in '{rev_label}'."
2143            ));
2144        }
2145        if let Ok(cwd) = std::env::current_dir() {
2146            let prefix = show_prefix(repo, &cwd);
2147            let pfx = prefix.trim_end_matches('/');
2148            if !pfx.is_empty() {
2149                let candidate = if clean_path.is_empty() {
2150                    pfx.to_owned()
2151                } else {
2152                    format!("{pfx}/{clean_path}")
2153                };
2154                if path_in_tree(repo, head_tree, &candidate) {
2155                    return Error::Message(format!(
2156                        "fatal: path '{candidate}' exists, but not '{rel_display}'\n\
2157hint: Did you mean '{rev_label}:{candidate}' aka '{rev_label}:./{rel_display}'?"
2158                    ));
2159                }
2160            }
2161        }
2162        let on_disk = repo
2163            .work_tree
2164            .as_ref()
2165            .map(|wt| wt.join(clean_path))
2166            .is_some_and(|p| p.exists());
2167        let in_index = path_in_index(repo, clean_path, 0);
2168        if on_disk || in_index {
2169            return Error::Message(format!(
2170                "fatal: path '{rel_display}' exists on disk, but not in '{rev_label}'."
2171            ));
2172        }
2173    }
2174    Error::Message(format!(
2175        "fatal: path '{rel_display}' does not exist in '{rev_label}'"
2176    ))
2177}
2178
2179fn diagnose_index_path_error(repo: &Repository, path: &str, stage: u8, err: Error) -> Error {
2180    let Error::ObjectNotFound(_) = err else {
2181        return err;
2182    };
2183    let work_path = repo
2184        .work_tree
2185        .as_ref()
2186        .map(|wt| wt.join(path))
2187        .filter(|p| p.exists());
2188    let on_disk = work_path.is_some();
2189    let in_head = head_tree_oid(repo)
2190        .map(|t| path_in_tree(repo, t, path))
2191        .unwrap_or(false);
2192    let in_index = path_in_index(repo, path, 0);
2193    let at_stage = path_in_index(repo, path, stage);
2194
2195    if stage > 0 && !in_index {
2196        if let Ok(cwd) = std::env::current_dir() {
2197            let prefix = show_prefix(repo, &cwd);
2198            let pfx = prefix.trim_end_matches('/');
2199            if !pfx.is_empty() {
2200                let candidate = if path.is_empty() {
2201                    pfx.to_owned()
2202                } else {
2203                    format!("{pfx}/{path}")
2204                };
2205                if path_in_index(repo, &candidate, 0) && !path_in_index(repo, &candidate, stage) {
2206                    return Error::Message(format!(
2207                        "fatal: path '{candidate}' is in the index, but not '{path}'\n\
2208hint: Did you mean ':0:{candidate}' aka ':0:./{path}'?"
2209                    ));
2210                }
2211            }
2212        }
2213        return Error::Message(format!(
2214            "fatal: path '{path}' does not exist (neither on disk nor in the index)"
2215        ));
2216    }
2217
2218    if stage > 0 && in_index && !at_stage {
2219        return Error::Message(format!(
2220            "fatal: path '{path}' is in the index, but not at stage {stage}\n\
2221hint: Did you mean ':0:{path}'?"
2222        ));
2223    }
2224
2225    if stage == 0 {
2226        if !on_disk && !in_index {
2227            if let Ok(cwd) = std::env::current_dir() {
2228                let prefix = show_prefix(repo, &cwd);
2229                let pfx = prefix.trim_end_matches('/');
2230                if !pfx.is_empty() {
2231                    let candidate = if path.is_empty() {
2232                        pfx.to_owned()
2233                    } else {
2234                        format!("{pfx}/{path}")
2235                    };
2236                    if path_in_index(repo, &candidate, 0) {
2237                        return Error::Message(format!(
2238                            "fatal: path '{candidate}' is in the index, but not '{path}'\n\
2239hint: Did you mean ':0:{candidate}' aka ':0:./{path}'?"
2240                        ));
2241                    }
2242                }
2243            }
2244            return Error::Message(format!(
2245                "fatal: path '{path}' does not exist (neither on disk nor in the index)"
2246            ));
2247        }
2248        if on_disk && !in_index && !in_head {
2249            return Error::Message(format!(
2250                "fatal: path '{path}' exists on disk, but not in the index"
2251            ));
2252        }
2253    }
2254    Error::Message(format!("fatal: path '{path}' does not exist in the index"))
2255}
2256
2257/// Look up a path in the index (stage 0) and return its OID.
2258fn resolve_index_path(repo: &Repository, path: &str) -> Result<ObjectId> {
2259    resolve_index_path_at_stage(repo, path, 0)
2260}
2261
2262/// Parsed `:path` / `:N:path` index revision syntax (leading colon, not `:/search`).
2263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2264pub struct IndexColonSpec<'a> {
2265    /// Merge stage (`0` for normal entries, `1`–`3` for unmerged stages).
2266    pub stage: u8,
2267    /// Path segment before normalization against the work tree.
2268    pub raw_path: &'a str,
2269}
2270
2271/// If `spec` uses Git's index-only revision form (`:file`, `:0:file`, …), returns the stage and path segment.
2272///
2273/// Returns [`None`] for non-index forms such as `HEAD:file`, bare OIDs, or `:/message` search.
2274#[must_use]
2275pub fn parse_index_colon_spec(spec: &str) -> Option<IndexColonSpec<'_>> {
2276    if !spec.starts_with(':') || spec.starts_with(":/") || spec.len() <= 1 {
2277        return None;
2278    }
2279    let rest = &spec[1..];
2280    if rest.is_empty() {
2281        return None;
2282    }
2283    if rest.len() >= 3 && rest.as_bytes()[1] == b':' {
2284        if let Some(stage_char) = rest.chars().next() {
2285            if let Some(stage) = stage_char.to_digit(10) {
2286                if stage <= 3 {
2287                    return Some(IndexColonSpec {
2288                        stage: stage as u8,
2289                        raw_path: &rest[2..],
2290                    });
2291                }
2292            }
2293        }
2294    }
2295    Some(IndexColonSpec {
2296        stage: 0,
2297        raw_path: rest,
2298    })
2299}
2300
2301/// One index entry resolved from a `:path` / `:N:path` revision string.
2302#[derive(Debug, Clone, PartialEq, Eq)]
2303pub struct IndexPathEntry {
2304    /// Repository-relative path using `/` separators (normalized from the spec).
2305    pub path: String,
2306    /// Blob OID stored for this index entry.
2307    pub oid: ObjectId,
2308    /// Index entry mode (e.g. `0o100644`).
2309    pub mode: u32,
2310}
2311
2312/// Resolve an index revision string (`:file` or `:N:file`) to the staged entry's path, OID, and mode.
2313///
2314/// # Returns
2315///
2316/// - `Ok(None)` if `spec` is not `:path` index syntax.
2317/// - `Ok(Some(entry))` on success.
2318/// - `Err` if the syntax matches but the path is invalid or missing from the index.
2319pub fn resolve_index_path_entry(repo: &Repository, spec: &str) -> Result<Option<IndexPathEntry>> {
2320    let Some(colon) = parse_index_colon_spec(spec) else {
2321        return Ok(None);
2322    };
2323    let path = match normalize_colon_path_for_tree(repo, colon.raw_path) {
2324        Ok(p) => p,
2325        Err(Error::InvalidRef(msg)) if msg == "outside repository" => {
2326            let wt = repo
2327                .work_tree
2328                .as_ref()
2329                .and_then(|p| p.canonicalize().ok())
2330                .map(|p| p.display().to_string())
2331                .unwrap_or_default();
2332            return Err(Error::Message(format!(
2333                "fatal: '{}' is outside repository at '{wt}'",
2334                colon.raw_path
2335            )));
2336        }
2337        Err(e) => return Err(e),
2338    };
2339    let index_path = if let Ok(raw) = std::env::var("GIT_INDEX_FILE") {
2340        let p = std::path::PathBuf::from(raw);
2341        if p.is_absolute() {
2342            p
2343        } else if let Ok(cwd) = std::env::current_dir() {
2344            cwd.join(p)
2345        } else {
2346            p
2347        }
2348    } else {
2349        repo.index_path()
2350    };
2351    use crate::index::Index;
2352    let index = Index::load_expand_sparse(&index_path, &repo.odb)
2353        .map_err(|_| Error::ObjectNotFound(format!(":{}:{}", colon.stage, path)))?;
2354    let entry = index
2355        .get(path.as_bytes(), colon.stage)
2356        .ok_or_else(|| Error::ObjectNotFound(format!(":{}:{}", colon.stage, path)))?;
2357    Ok(Some(IndexPathEntry {
2358        path,
2359        oid: entry.oid,
2360        mode: entry.mode,
2361    }))
2362}
2363
2364/// Look up a path in the index at a given stage and return its OID.
2365fn resolve_index_path_at_stage(repo: &Repository, path: &str, stage: u8) -> Result<ObjectId> {
2366    use crate::index::Index;
2367    let index_path = if let Ok(raw) = std::env::var("GIT_INDEX_FILE") {
2368        let p = std::path::PathBuf::from(raw);
2369        if p.is_absolute() {
2370            p
2371        } else if let Ok(cwd) = std::env::current_dir() {
2372            cwd.join(p)
2373        } else {
2374            p
2375        }
2376    } else {
2377        repo.index_path()
2378    };
2379    let index = Index::load_expand_sparse(&index_path, &repo.odb)
2380        .map_err(|_| Error::ObjectNotFound(format!(":{stage}:{path}")))?;
2381    match index.get(path.as_bytes(), stage) {
2382        Some(entry) => Ok(entry.oid),
2383        None => Err(Error::ObjectNotFound(format!(":{stage}:{path}"))),
2384    }
2385}
2386
2387/// Split `treeish:path` at the first colon that separates a revision from a path,
2388/// ignoring colons inside `^{...}` peel operators.
2389///
2390/// Returns [`None`] for index-only forms like `:path` and `:N:path` (leading `:`).
2391pub fn split_treeish_colon(spec: &str) -> Option<(&str, &str)> {
2392    if spec.starts_with(':') {
2393        return None;
2394    }
2395    let bytes = spec.as_bytes();
2396    let mut i = 0usize;
2397    let mut peel_depth = 0usize;
2398    while i < bytes.len() {
2399        if i + 1 < bytes.len() && bytes[i] == b'^' && bytes[i + 1] == b'{' {
2400            peel_depth += 1;
2401            i += 2;
2402            continue;
2403        }
2404        if peel_depth > 0 {
2405            if bytes[i] == b'}' {
2406                peel_depth -= 1;
2407            }
2408            i += 1;
2409            continue;
2410        }
2411        if bytes[i] == b':' && i > 0 {
2412            let before = &spec[..i];
2413            let after = &spec[i + 1..];
2414            if !before.is_empty() {
2415                return Some((before, after)); // after may be empty ("HEAD:" = root tree)
2416            }
2417        }
2418        i += 1;
2419    }
2420    None
2421}
2422
2423pub(crate) fn split_treeish_spec(spec: &str) -> Option<(&str, &str)> {
2424    split_treeish_colon(spec)
2425}
2426
2427pub(crate) fn resolve_treeish_path(
2428    repo: &Repository,
2429    treeish: ObjectId,
2430    path: &str,
2431) -> Result<ObjectId> {
2432    let object = repo.odb.read(&treeish)?;
2433    let mut current_tree = match object.kind {
2434        ObjectKind::Commit => parse_commit(&object.data)?.tree,
2435        ObjectKind::Tree => treeish,
2436        _ => {
2437            return Err(Error::InvalidRef(format!(
2438                "object {treeish} does not name a tree"
2439            )))
2440        }
2441    };
2442
2443    let mut parts = path.split('/').filter(|part| !part.is_empty()).peekable();
2444    if parts.peek().is_none() {
2445        return Ok(current_tree);
2446    }
2447    while let Some(part) = parts.next() {
2448        let tree_object = repo.odb.read(&current_tree)?;
2449        if tree_object.kind != ObjectKind::Tree {
2450            return Err(Error::CorruptObject(format!(
2451                "object {current_tree} is not a tree"
2452            )));
2453        }
2454        let entries = parse_tree(&tree_object.data)?;
2455        let Some(entry) = entries.iter().find(|entry| entry.name == part.as_bytes()) else {
2456            return Err(Error::ObjectNotFound(path.to_owned()));
2457        };
2458        if parts.peek().is_none() {
2459            return Ok(entry.oid);
2460        }
2461        current_tree = entry.oid;
2462    }
2463
2464    Err(Error::ObjectNotFound(path.to_owned()))
2465}
2466
2467fn apply_peel(repo: &Repository, mut oid: ObjectId, peel: Option<&str>) -> Result<ObjectId> {
2468    match peel {
2469        None => Ok(oid),
2470        Some(search) if search.starts_with('/') => {
2471            let pattern = &search[1..];
2472            if pattern.is_empty() {
2473                return Err(Error::InvalidRef(
2474                    "empty commit message search pattern".to_owned(),
2475                ));
2476            }
2477            resolve_commit_message_search_from(repo, oid, pattern)
2478        }
2479        Some("") => {
2480            while let Ok(obj) = repo.odb.read(&oid) {
2481                if obj.kind != ObjectKind::Tag {
2482                    break;
2483                }
2484                oid = parse_tag_target(&obj.data)?;
2485            }
2486            Ok(oid)
2487        }
2488        Some("commit") => {
2489            oid = apply_peel(repo, oid, Some(""))?;
2490            let obj = repo.odb.read(&oid)?;
2491            if obj.kind == ObjectKind::Commit {
2492                Ok(oid)
2493            } else {
2494                Err(Error::InvalidRef("expected commit".to_owned()))
2495            }
2496        }
2497        Some("tree") => {
2498            // Peel tags, then dereference a commit to its tree.
2499            oid = apply_peel(repo, oid, Some(""))?;
2500            let obj = repo.odb.read(&oid)?;
2501            match obj.kind {
2502                ObjectKind::Tree => Ok(oid),
2503                ObjectKind::Commit => Ok(parse_commit(&obj.data)?.tree),
2504                _ => Err(Error::InvalidRef("expected tree or commit".to_owned())),
2505            }
2506        }
2507        Some("blob") => {
2508            // ^{blob}: peel tags until we reach a blob
2509            let mut cur = oid;
2510            loop {
2511                let obj = repo.odb.read(&cur)?;
2512                match obj.kind {
2513                    ObjectKind::Blob => return Ok(cur),
2514                    ObjectKind::Tag => {
2515                        cur = parse_tag_target(&obj.data)?;
2516                    }
2517                    _ => return Err(Error::InvalidRef("expected blob".to_owned())),
2518                }
2519            }
2520        }
2521        Some("object") => Ok(oid),
2522        Some("tag") => {
2523            // ^{tag}: return if it's a tag object
2524            let obj = repo.odb.read(&oid)?;
2525            if obj.kind == ObjectKind::Tag {
2526                Ok(oid)
2527            } else {
2528                Err(Error::InvalidRef("expected tag".to_owned()))
2529            }
2530        }
2531        Some(other) => Err(Error::InvalidRef(format!(
2532            "unsupported peel operator '{{{other}}}'"
2533        ))),
2534    }
2535}
2536
2537/// Expand a single revision token that ends with `^!` (Git: commit without its parents).
2538///
2539/// Returns one token unchanged when `^!` is absent. When present, returns two tokens: the base
2540/// revision (without `^!`) and a negative spec `^<first-parent-hex>` so that `rev-list` includes
2541/// exactly that commit when combined with the exclusion. Merge commits and other multi-parent
2542/// commits are rejected with the same ambiguity error as Git.
2543///
2544/// # Errors
2545///
2546/// Returns [`Error::Message`] for ambiguous `^!` (merge commit) and resolution failures.
2547pub fn expand_rev_token_circ_bang(repo: &Repository, token: &str) -> Result<Vec<String>> {
2548    let Some(base) = token.strip_suffix("^!") else {
2549        return Ok(vec![token.to_owned()]);
2550    };
2551    if base.is_empty() {
2552        return Err(Error::Message(format!(
2553            "fatal: ambiguous argument '{token}': unknown revision or path not in the working tree.\n\
2554Use '--' to separate paths from revisions, like this:\n\
2555'git <command> [<revision>...] -- [<file>...]'"
2556        )));
2557    }
2558    let oid = resolve_revision_for_range_end(repo, base)?;
2559    let commit_oid = peel_to_commit_for_merge_base(repo, oid)?;
2560    let obj = repo.odb.read(&commit_oid)?;
2561    let commit = parse_commit(&obj.data)?;
2562    if commit.parents.len() != 1 {
2563        return Err(Error::Message(format!(
2564            "fatal: ambiguous argument '{token}': unknown revision or path not in the working tree.\n\
2565Use '--' to separate paths from revisions, like this:\n\
2566'git <command> [<revision>...] -- [<file>...]'"
2567        )));
2568    }
2569    Ok(vec![
2570        base.to_owned(),
2571        format!("^{}", commit.parents[0].to_hex()),
2572    ])
2573}
2574
2575/// Split `spec` into `(base, peel_inner)` for `^{...}` / `^0` suffixes (same rules as revision parsing).
2576#[must_use]
2577pub fn parse_peel_suffix(spec: &str) -> (&str, Option<&str>) {
2578    if let Some(base) = spec.strip_suffix("^{}") {
2579        return (base, Some(""));
2580    }
2581    if let Some(start) = spec.rfind("^{") {
2582        if spec.ends_with('}') {
2583            let base = &spec[..start];
2584            let op = &spec[start + 2..spec.len() - 1];
2585            return (base, Some(op));
2586        }
2587    }
2588    // `^0` is shorthand for `^{commit}` — peel tags and verify commit.
2589    if let Some(base) = spec.strip_suffix("^0") {
2590        // Only match if the character before `^0` is not also a `^` (avoid
2591        // matching `^^0` as a peel instead of nav+nav).
2592        if !base.ends_with('^') {
2593            return (base, Some("commit"));
2594        }
2595    }
2596    (spec, None)
2597}
2598
2599fn parse_tag_target(data: &[u8]) -> Result<ObjectId> {
2600    let text = std::str::from_utf8(data)
2601        .map_err(|_| Error::CorruptObject("invalid tag object".to_owned()))?;
2602    let Some(line) = text.lines().find(|line| line.starts_with("object ")) else {
2603        return Err(Error::CorruptObject("tag missing object header".to_owned()));
2604    };
2605    let oid_text = line.trim_start_matches("object ").trim();
2606    oid_text.parse::<ObjectId>()
2607}
2608
2609/// Search commit messages reachable from `start` and return the first commit
2610/// whose message contains `pattern`.
2611fn resolve_commit_message_search_from(
2612    repo: &Repository,
2613    start: ObjectId,
2614    pattern: &str,
2615) -> Result<ObjectId> {
2616    // Note: ! negation is NOT supported in ^{/pattern} peel context (only in :/! prefix)
2617    let regex = Regex::new(pattern).ok();
2618    let mut visited = std::collections::HashSet::new();
2619    let mut queue = std::collections::VecDeque::new();
2620    queue.push_back(start);
2621    visited.insert(start);
2622
2623    while let Some(oid) = queue.pop_front() {
2624        let obj = match repo.odb.read(&oid) {
2625            Ok(o) => o,
2626            Err(_) => continue,
2627        };
2628        if obj.kind != ObjectKind::Commit {
2629            continue;
2630        }
2631        let commit = match parse_commit(&obj.data) {
2632            Ok(c) => c,
2633            Err(_) => continue,
2634        };
2635
2636        let is_match = if let Some(re) = &regex {
2637            re.is_match(&commit.message)
2638        } else {
2639            commit.message.contains(pattern)
2640        };
2641        if is_match {
2642            return Ok(oid);
2643        }
2644
2645        for parent in &commit.parents {
2646            if visited.insert(*parent) {
2647                queue.push_back(*parent);
2648            }
2649        }
2650    }
2651
2652    Err(Error::ObjectNotFound(format!(":/{pattern}")))
2653}
2654
2655fn find_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
2656    if !is_hex_prefix(prefix) || !(4..=40).contains(&prefix.len()) {
2657        return Ok(Vec::new());
2658    }
2659    let mut seen = HashSet::new();
2660    let mut matches = Vec::new();
2661    for objects_dir in object_storage_dirs_for_abbrev(repo)? {
2662        for hex in collect_loose_object_ids_in_dir(&objects_dir)? {
2663            if hex.starts_with(prefix) {
2664                let oid = hex.parse::<ObjectId>()?;
2665                if seen.insert(oid) {
2666                    matches.push(oid);
2667                }
2668            }
2669        }
2670        for oid in collect_pack_oids_with_prefix(&objects_dir, prefix)? {
2671            if seen.insert(oid) {
2672                matches.push(oid);
2673            }
2674        }
2675    }
2676    Ok(matches)
2677}
2678
2679fn collect_loose_object_ids(repo: &Repository) -> Result<Vec<String>> {
2680    collect_loose_object_ids_in_dir(repo.odb.objects_dir())
2681}
2682
2683fn collect_loose_object_ids_in_dir(objects_dir: &Path) -> Result<Vec<String>> {
2684    let mut ids = Vec::new();
2685    let read = match fs::read_dir(objects_dir) {
2686        Ok(read) => read,
2687        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
2688        Err(err) => return Err(Error::Io(err)),
2689    };
2690
2691    for dir_entry in read {
2692        let dir_entry = dir_entry?;
2693        let name = dir_entry.file_name();
2694        let Some(prefix) = name.to_str() else {
2695            continue;
2696        };
2697        if !is_two_hex(prefix) {
2698            continue;
2699        }
2700        if !dir_entry.file_type()?.is_dir() {
2701            continue;
2702        }
2703
2704        let files = fs::read_dir(dir_entry.path())?;
2705        for file_entry in files {
2706            let file_entry = file_entry?;
2707            if !file_entry.file_type()?.is_file() {
2708                continue;
2709            }
2710            let file_name = file_entry.file_name();
2711            let Some(suffix) = file_name.to_str() else {
2712                continue;
2713            };
2714            if suffix.len() == 38 && suffix.chars().all(|ch| ch.is_ascii_hexdigit()) {
2715                ids.push(format!("{prefix}{suffix}"));
2716            }
2717        }
2718    }
2719
2720    Ok(ids)
2721}
2722
2723fn is_two_hex(text: &str) -> bool {
2724    text.len() == 2 && text.chars().all(|ch| ch.is_ascii_hexdigit())
2725}
2726
2727fn is_hex_prefix(text: &str) -> bool {
2728    !text.is_empty() && text.chars().all(|ch| ch.is_ascii_hexdigit())
2729}
2730
2731fn path_is_within(path: &Path, container: &Path) -> bool {
2732    if path == container {
2733        return true;
2734    }
2735    path.starts_with(container)
2736}
2737
2738fn normalize_components(path: &Path) -> Vec<String> {
2739    path.components()
2740        .filter_map(|component| match component {
2741            Component::RootDir => Some(String::from("/")),
2742            Component::Normal(item) => Some(item.to_string_lossy().into_owned()),
2743            _ => None,
2744        })
2745        .collect()
2746}
2747
2748fn component_to_text(component: Component<'_>) -> Option<String> {
2749    match component {
2750        Component::Normal(item) => Some(os_to_string(item)),
2751        _ => None,
2752    }
2753}
2754
2755fn os_to_string(text: &OsStr) -> String {
2756    text.to_string_lossy().into_owned()
2757}
2758
2759/// Search commit messages from HEAD backwards for a commit whose message
2760/// contains `pattern`.  Returns the first matching commit OID.
2761fn resolve_commit_message_search(
2762    repo: &crate::repo::Repository,
2763    pattern: &str,
2764) -> Result<ObjectId> {
2765    // Handle negated pattern: /! means negate; /!! means literal /!
2766    let (negate, effective_pattern) = if pattern.starts_with('!') {
2767        if pattern.starts_with("!!") {
2768            (false, &pattern[1..]) // !! = literal !
2769        } else {
2770            (true, &pattern[1..]) // ! = negate
2771        }
2772    } else {
2773        (false, pattern)
2774    };
2775    let regex = Regex::new(effective_pattern).ok();
2776    use crate::state::resolve_head;
2777    let head =
2778        resolve_head(&repo.git_dir).map_err(|_| Error::ObjectNotFound(format!(":/{pattern}")))?;
2779    let start_oid = match head.oid() {
2780        Some(oid) => *oid,
2781        None => return Err(Error::ObjectNotFound(format!(":/{pattern}"))),
2782    };
2783
2784    let mut visited = std::collections::HashSet::new();
2785    let mut queue = std::collections::VecDeque::new();
2786    queue.push_back(start_oid);
2787    visited.insert(start_oid);
2788
2789    while let Some(oid) = queue.pop_front() {
2790        let obj = match repo.odb.read(&oid) {
2791            Ok(o) => o,
2792            Err(_) => continue,
2793        };
2794        // Skip non-commit objects
2795        if obj.kind != ObjectKind::Commit {
2796            continue;
2797        }
2798        let commit = match parse_commit(&obj.data) {
2799            Ok(c) => c,
2800            Err(_) => continue,
2801        };
2802
2803        // Check if message matches pattern (regex, with literal fallback)
2804        let base_match = if let Some(re) = &regex {
2805            re.is_match(&commit.message)
2806        } else {
2807            commit.message.contains(effective_pattern)
2808        };
2809        let is_match = if negate { !base_match } else { base_match };
2810        if is_match {
2811            return Ok(oid);
2812        }
2813
2814        // Enqueue parents
2815        for parent in &commit.parents {
2816            if visited.insert(*parent) {
2817                queue.push_back(*parent);
2818            }
2819        }
2820    }
2821
2822    Err(Error::ObjectNotFound(format!(":/{pattern}")))
2823}
2824
2825/// All object IDs (loose and packed) whose hex form starts with `prefix`.
2826pub fn list_all_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
2827    find_abbrev_matches(repo, prefix)
2828}
2829
2830/// Public: find all object IDs whose hex prefix matches the given string.
2831pub fn list_loose_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
2832    list_all_abbrev_matches(repo, prefix)
2833}