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::ffi::OsStr;
9use std::fs;
10use std::path::{Component, Path};
11
12use regex::Regex;
13
14use crate::error::{Error, Result};
15use crate::objects::{parse_commit, parse_tree, ObjectId, ObjectKind};
16use crate::reflog::read_reflog;
17use crate::refs;
18use crate::repo::Repository;
19
20/// Return `Some(repo)` when a repository can be discovered at `start`.
21///
22/// # Parameters
23///
24/// - `start` - starting path for discovery; when `None`, uses current directory.
25///
26/// # Errors
27///
28/// Returns errors other than "not a repository" (for example I/O and path
29/// canonicalization failures).
30pub fn discover_optional(start: Option<&Path>) -> Result<Option<Repository>> {
31    match Repository::discover(start) {
32        Ok(repo) => Ok(Some(repo)),
33        Err(Error::NotARepository(msg)) => {
34            // Repository not found while walking parents is optional, but
35            // structural `.git` problems at the starting directory should be
36            // surfaced so callers can show diagnostics (e.g. t0002/t0009).
37            if msg.contains("invalid gitfile format")
38                || msg.contains("gitfile does not contain 'gitdir:' line")
39                || msg.contains("not a regular file")
40            {
41                return Err(Error::NotARepository(msg));
42            }
43
44            if let Some(start) = start {
45                let start = if start.is_absolute() {
46                    start.to_path_buf()
47                } else if let Ok(cwd) = std::env::current_dir() {
48                    cwd.join(start)
49                } else {
50                    start.to_path_buf()
51                };
52                let dot_git = start.join(".git");
53                if dot_git.is_file() || dot_git.is_symlink() {
54                    return Err(Error::NotARepository(msg));
55                }
56            }
57
58            Ok(None)
59        }
60        Err(err) => Err(err),
61    }
62}
63
64/// Compute whether `cwd` is inside the repository's work tree.
65#[must_use]
66pub fn is_inside_work_tree(repo: &Repository, cwd: &Path) -> bool {
67    let Some(work_tree) = &repo.work_tree else {
68        return false;
69    };
70    path_is_within(cwd, work_tree)
71}
72
73/// Compute whether `cwd` is inside the repository's git-dir.
74#[must_use]
75pub fn is_inside_git_dir(repo: &Repository, cwd: &Path) -> bool {
76    path_is_within(cwd, &repo.git_dir)
77}
78
79/// Compute the `--show-prefix` output.
80///
81/// Returns an empty string when `cwd` is at repository root or outside the work
82/// tree. Returned prefixes always use `/` separators and end with `/`.
83#[must_use]
84pub fn show_prefix(repo: &Repository, cwd: &Path) -> String {
85    let Some(work_tree) = &repo.work_tree else {
86        return String::new();
87    };
88    if !path_is_within(cwd, work_tree) {
89        return String::new();
90    }
91    if cwd == work_tree {
92        return String::new();
93    }
94    let Ok(rel) = cwd.strip_prefix(work_tree) else {
95        return String::new();
96    };
97    let mut out = rel
98        .components()
99        .filter_map(component_to_text)
100        .collect::<Vec<_>>()
101        .join("/");
102    if !out.is_empty() {
103        out.push('/');
104    }
105    out
106}
107
108/// Resolve a symbolic ref name to its full form.
109///
110/// For `HEAD`, returns the symbolic target (e.g., `refs/heads/main`).
111/// For branch names, returns `refs/heads/<name>`.
112/// For tag names, returns `refs/tags/<name>`.
113/// Returns `None` when the name cannot be resolved symbolically.
114#[must_use]
115pub fn symbolic_full_name(repo: &Repository, spec: &str) -> Option<String> {
116    // Handle @{upstream} and @{push} suffixes
117    if let Some(base) = spec
118        .strip_suffix("@{upstream}")
119        .or_else(|| spec.strip_suffix("@{u}"))
120        .or_else(|| spec.strip_suffix("@{UPSTREAM}"))
121        .or_else(|| spec.strip_suffix("@{U}"))
122        .or_else(|| spec.strip_suffix("@{UpSTReam}"))
123    {
124        return resolve_upstream_ref(repo, base);
125    }
126    if let Some(base) = spec.strip_suffix("@{push}") {
127        return resolve_push_ref(repo, base);
128    }
129
130    if let Ok(Some(branch)) = expand_at_minus_to_branch_name(repo, spec) {
131        let ref_name = format!("refs/heads/{branch}");
132        if refs::resolve_ref(&repo.git_dir, &ref_name).is_ok() {
133            return Some(ref_name);
134        }
135        return None;
136    }
137
138    if spec == "HEAD" {
139        if let Ok(Some(target)) = refs::read_symbolic_ref(&repo.git_dir, "HEAD") {
140            return Some(target);
141        }
142        return None;
143    }
144    // If it's already a full ref path
145    if spec.starts_with("refs/") {
146        if refs::resolve_ref(&repo.git_dir, spec).is_ok() {
147            return Some(spec.to_owned());
148        }
149        return None;
150    }
151    // DWIM: try refs/heads, refs/tags, refs/remotes
152    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
153        let candidate = format!("{prefix}{spec}");
154        if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
155            return Some(candidate);
156        }
157    }
158    None
159}
160
161/// Expand an `@{-N}` token to the corresponding previous branch name.
162///
163/// Returns:
164/// - `Ok(Some(branch_name))` when `spec` is an `@{-N}` token and resolves
165///   to a branch name.
166/// - `Ok(None)` when `spec` is not an `@{-N}` token.
167/// - `Err(...)` when `spec` matches `@{-N}` syntax but cannot be resolved.
168pub fn expand_at_minus_to_branch_name(repo: &Repository, spec: &str) -> Result<Option<String>> {
169    if !spec.starts_with("@{-") || !spec.ends_with('}') {
170        return Ok(None);
171    }
172    let inner = &spec[3..spec.len() - 1];
173    let n: usize = inner
174        .parse()
175        .map_err(|_| Error::InvalidRef(format!("invalid N in @{{-N}} for '{spec}'")))?;
176    if n < 1 {
177        return Ok(None);
178    }
179    resolve_at_minus_to_branch(repo, n).map(Some)
180}
181
182/// Resolve `@{-N}` to the commit OID it points to.
183pub fn resolve_at_minus_to_oid(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
184    try_resolve_at_minus(repo, spec)
185}
186
187/// Abbreviate a full ref name to its shortest unambiguous form.
188///
189/// For example, `refs/heads/main` becomes `main`.
190#[must_use]
191pub fn abbreviate_ref_name(full_name: &str) -> String {
192    for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
193        if let Some(short) = full_name.strip_prefix(prefix) {
194            return short.to_owned();
195        }
196    }
197    if let Some(short) = full_name.strip_prefix("refs/") {
198        return short.to_owned();
199    }
200    full_name.to_owned()
201}
202
203/// Resolve `@{upstream}` for a given branch.
204fn resolve_upstream_ref(repo: &Repository, branch: &str) -> Option<String> {
205    // If branch is empty, use current branch from HEAD
206    let branch_name = if branch.is_empty() {
207        match refs::read_head(&repo.git_dir) {
208            Ok(Some(target)) => target.strip_prefix("refs/heads/")?.to_owned(),
209            _ => return None,
210        }
211    } else {
212        // Handle @ prefix (e.g., @funny) and branch names with @
213        branch.to_owned()
214    };
215
216    // Read branch.<name>.remote and branch.<name>.merge from config
217    let config_path = repo.git_dir.join("config");
218    let config_content = fs::read_to_string(&config_path).ok()?;
219    let (remote, merge) = parse_branch_tracking(&config_content, &branch_name)?;
220
221    // For local tracking (remote = "."), use the merge ref directly.
222    // For remote tracking, convert to refs/remotes/<remote>/<branch>.
223    if remote == "." {
224        Some(merge.clone())
225    } else {
226        let merge_branch = merge.strip_prefix("refs/heads/")?;
227        Some(format!("refs/remotes/{remote}/{merge_branch}"))
228    }
229}
230
231/// Resolve `@{push}` for a given branch.
232fn resolve_push_ref(repo: &Repository, branch: &str) -> Option<String> {
233    // Check push.default configuration
234    let config_path = crate::refs::common_dir(&repo.git_dir)
235        .unwrap_or_else(|| repo.git_dir.clone())
236        .join("config");
237    let config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
238    // Parse push.default
239    let push_default = parse_config_value(&config_content, "push", "default");
240    match push_default.as_deref().unwrap_or("simple") {
241        "nothing" => return None, // no push destination
242        _ => {}
243    }
244
245    // Check for explicit push remote (remote.pushRemote or branch.<name>.pushRemote)
246    let push_remote = parse_config_value(&config_content, "remote", "pushRemote").or_else(|| {
247        let section = format!("[branch \"{}\"]", branch);
248        let mut in_section = false;
249        for line in config_content.lines() {
250            let trimmed = line.trim();
251            if trimmed.starts_with('[') {
252                in_section = trimmed == section;
253                continue;
254            }
255            if in_section {
256                if let Some(v) = trimmed
257                    .strip_prefix("pushremote = ")
258                    .or_else(|| trimmed.strip_prefix("pushRemote = "))
259                {
260                    return Some(v.trim().to_owned());
261                }
262            }
263        }
264        None
265    });
266
267    if let Some(remote) = push_remote {
268        // Find the push refspec for this remote
269        let tracking_ref = format!("refs/remotes/{remote}/{branch}");
270        if crate::refs::resolve_ref(&repo.git_dir, &tracking_ref).is_ok() {
271            return Some(tracking_ref);
272        }
273    }
274
275    // Fall back to upstream tracking
276    resolve_upstream_ref(repo, branch)
277}
278
279fn parse_config_value(config: &str, section: &str, key: &str) -> Option<String> {
280    let section_header = format!("[{}]", section);
281    let key_lower = key.to_ascii_lowercase();
282    let mut in_section = false;
283    for line in config.lines() {
284        let trimmed = line.trim();
285        if trimmed.starts_with('[') {
286            in_section = trimmed.eq_ignore_ascii_case(&section_header);
287            continue;
288        }
289        if in_section {
290            let lower = trimmed.to_ascii_lowercase();
291            if lower.starts_with(&key_lower) {
292                let rest = lower[key_lower.len()..].trim_start().to_string();
293                if rest.starts_with('=') {
294                    if let Some(eq_pos) = trimmed.find('=') {
295                        return Some(trimmed[eq_pos + 1..].trim().to_owned());
296                    }
297                }
298            }
299        }
300    }
301    None
302}
303
304/// Parse branch tracking configuration from git config content.
305fn parse_branch_tracking(config: &str, branch: &str) -> Option<(String, String)> {
306    let mut remote = None;
307    let mut merge = None;
308    let mut in_section = false;
309    let target_section = format!("[branch \"{}\"]", branch);
310
311    for line in config.lines() {
312        let trimmed = line.trim();
313        if trimmed.starts_with('[') {
314            in_section = trimmed == target_section
315                || trimmed.starts_with(&format!("[branch \"{}\"", branch));
316            continue;
317        }
318        if !in_section {
319            continue;
320        }
321        if let Some(value) = trimmed.strip_prefix("remote = ") {
322            remote = Some(value.trim().to_owned());
323        } else if let Some(value) = trimmed.strip_prefix("merge = ") {
324            merge = Some(value.trim().to_owned());
325        }
326        // Also handle with tabs
327        if let Some(value) = trimmed.strip_prefix("remote=") {
328            remote = Some(value.trim().to_owned());
329        } else if let Some(value) = trimmed.strip_prefix("merge=") {
330            merge = Some(value.trim().to_owned());
331        }
332    }
333
334    match (remote, merge) {
335        (Some(r), Some(m)) => Some((r, m)),
336        _ => None,
337    }
338}
339
340/// Resolve a revision string to an object ID.
341///
342/// Supports:
343/// - full 40-hex object IDs (must exist in loose store),
344/// - abbreviated object IDs (length 4-39, must resolve uniquely),
345/// - direct refs (`HEAD`, `refs/...`),
346/// - DWIM branch/tag/remote names (`name` -> `refs/heads/name`, etc.),
347/// - peeling suffixes: `^{}`, `^{object}`, `^{commit}`.
348///
349/// # Errors
350///
351/// Returns [`Error::ObjectNotFound`] or [`Error::InvalidRef`] when resolution
352/// fails.
353pub fn resolve_revision(repo: &Repository, spec: &str) -> Result<ObjectId> {
354    // Handle `:/message` early — it can contain any characters so must
355    // not be confused with peel/nav syntax.
356    if let Some(pattern) = spec.strip_prefix(":/") {
357        if !pattern.is_empty() {
358            return resolve_commit_message_search(repo, pattern);
359        }
360    }
361
362    // Handle A...B (symmetric difference / merge-base)
363    // Also handles A... (implies A...HEAD)
364    if let Some(idx) = spec.find("...") {
365        let left_raw = &spec[..idx];
366        let right_raw = &spec[idx + 3..];
367        if !left_raw.is_empty() || !right_raw.is_empty() {
368            let left_oid = if left_raw.is_empty() {
369                resolve_revision(repo, "HEAD")?
370            } else {
371                resolve_revision(repo, left_raw)?
372            };
373            let right_oid = if right_raw.is_empty() {
374                resolve_revision(repo, "HEAD")?
375            } else {
376                resolve_revision(repo, right_raw)?
377            };
378            let bases = crate::merge_base::merge_bases_first_vs_rest(repo, left_oid, &[right_oid])?;
379            return bases
380                .into_iter()
381                .next()
382                .ok_or_else(|| Error::ObjectNotFound(format!("no merge base for '{spec}'")));
383        }
384    }
385
386    // Handle <rev>:<path> — resolve a tree entry.
387    // Must come after :/ handling. The colon must not be inside `^{...}` (e.g.
388    // `other^{/msg:}:file`) and must not be the `:path` / `:N:path` index forms.
389    if let Some((before, after)) = split_treeish_colon(spec) {
390        if !before.is_empty() && !spec.starts_with(":/") {
391            // <rev>:<path> — resolve rev to tree, then navigate path
392            let rev_oid = resolve_revision(repo, before)?;
393            let tree_oid = peel_to_tree(repo, rev_oid)?;
394            if after.is_empty() {
395                // <rev>: means the tree itself
396                return Ok(tree_oid);
397            }
398            // Navigate into the tree by path
399            // Strip leading ./ prefix (e.g., first:./file.t → file.t)
400            let clean_path = after.strip_prefix("./").unwrap_or(after);
401            return resolve_tree_path(repo, &tree_oid, clean_path);
402        }
403    }
404
405    let (base_with_nav, peel) = parse_peel_suffix(spec);
406    let (base, nav_steps) = parse_nav_steps(base_with_nav);
407    let mut oid = resolve_base(repo, base)?;
408    for step in nav_steps {
409        oid = apply_nav_step(repo, oid, step)?;
410    }
411    apply_peel(repo, oid, peel)
412}
413
414/// Peel an object to a tree (commit → tree, tree → tree).
415fn peel_to_tree(repo: &Repository, oid: ObjectId) -> Result<ObjectId> {
416    let obj = repo.odb.read(&oid)?;
417    match obj.kind {
418        crate::objects::ObjectKind::Tree => Ok(oid),
419        crate::objects::ObjectKind::Commit => {
420            let commit = crate::objects::parse_commit(&obj.data)?;
421            Ok(commit.tree)
422        }
423        crate::objects::ObjectKind::Tag => {
424            let tag = crate::objects::parse_tag(&obj.data)?;
425            peel_to_tree(repo, tag.object)
426        }
427        _ => Err(Error::ObjectNotFound(format!(
428            "cannot peel {} to tree",
429            oid
430        ))),
431    }
432}
433
434/// Navigate a tree to find an object at a given path.
435fn resolve_tree_path(repo: &Repository, tree_oid: &ObjectId, path: &str) -> Result<ObjectId> {
436    let obj = repo.odb.read(tree_oid)?;
437    let entries = crate::objects::parse_tree(&obj.data)?;
438    let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
439    if components.is_empty() {
440        return Ok(*tree_oid);
441    }
442    let first = components[0];
443    let rest: Vec<&str> = components[1..].to_vec();
444    for entry in entries {
445        let name = String::from_utf8_lossy(&entry.name);
446        if name == first {
447            if rest.is_empty() {
448                return Ok(entry.oid);
449            } else {
450                return resolve_tree_path(repo, &entry.oid, &rest.join("/"));
451            }
452        }
453    }
454    Err(Error::ObjectNotFound(format!(
455        "path '{}' not found in tree {}",
456        path, tree_oid
457    )))
458}
459
460/// A single parent/ancestor navigation step.
461#[derive(Debug, Clone, Copy)]
462enum NavStep {
463    /// `^N` — navigate to the Nth parent (1-indexed; 0 is a no-op).
464    ParentN(usize),
465    /// `~N` — follow the first parent N times.
466    AncestorN(usize),
467}
468
469/// Parse and strip any trailing `^N` / `~N` navigation steps from `spec`.
470///
471/// Returns `(base, steps)` where `steps` are in left-to-right application order.
472fn parse_nav_steps(spec: &str) -> (&str, Vec<NavStep>) {
473    let mut steps = Vec::new();
474    let mut remaining = spec;
475
476    loop {
477        // Try `~<digits>` or bare `~` at the end.
478        if let Some(tilde_pos) = remaining.rfind('~') {
479            let after = &remaining[tilde_pos + 1..];
480            if after.is_empty() {
481                // bare `~` = `~1`
482                steps.push(NavStep::AncestorN(1));
483                remaining = &remaining[..tilde_pos];
484                continue;
485            }
486            if after.bytes().all(|b| b.is_ascii_digit()) {
487                let n: usize = after.parse().unwrap_or(1);
488                steps.push(NavStep::AncestorN(n));
489                remaining = &remaining[..tilde_pos];
490                continue;
491            }
492        }
493
494        // Try `^<single-digit>` or bare `^` at the end (but not `^{...}`).
495        if let Some(caret_pos) = remaining.rfind('^') {
496            let after = &remaining[caret_pos + 1..];
497            if after.is_empty() {
498                // bare `^` = `^1`
499                steps.push(NavStep::ParentN(1));
500                remaining = &remaining[..caret_pos];
501                continue;
502            }
503            if after.len() == 1 && after.as_bytes()[0].is_ascii_digit() {
504                let n = (after.as_bytes()[0] - b'0') as usize;
505                steps.push(NavStep::ParentN(n));
506                remaining = &remaining[..caret_pos];
507                continue;
508            }
509        }
510
511        break;
512    }
513
514    steps.reverse();
515    (remaining, steps)
516}
517
518/// Apply a single navigation step to an OID, resolving parent/ancestor links.
519fn apply_nav_step(repo: &Repository, oid: ObjectId, step: NavStep) -> Result<ObjectId> {
520    match step {
521        NavStep::ParentN(0) => Ok(oid),
522        NavStep::ParentN(n) => {
523            let obj = repo.odb.read(&oid)?;
524            if obj.kind != ObjectKind::Commit {
525                return Err(Error::InvalidRef(format!("{oid} is not a commit")));
526            }
527            let commit = parse_commit(&obj.data)?;
528            commit
529                .parents
530                .get(n - 1)
531                .copied()
532                .ok_or_else(|| Error::ObjectNotFound(format!("{oid}^{n}")))
533        }
534        NavStep::AncestorN(n) => {
535            let mut current = oid;
536            for _ in 0..n {
537                current = apply_nav_step(repo, current, NavStep::ParentN(1))?;
538            }
539            Ok(current)
540        }
541    }
542}
543
544/// Abbreviate an object ID to a unique prefix.
545///
546/// The returned prefix is at least `min_len` and at most 40 hex characters.
547///
548/// # Errors
549///
550/// Returns [`Error::ObjectNotFound`] when the target OID does not exist in the
551/// object database.
552pub fn abbreviate_object_id(repo: &Repository, oid: ObjectId, min_len: usize) -> Result<String> {
553    let min_len = min_len.clamp(4, 40);
554    let target = oid.to_hex();
555
556    // If object doesn't exist, just return the minimum abbreviation
557    if !repo.odb.exists(&oid) {
558        return Ok(target[..min_len].to_owned());
559    }
560
561    let all = collect_loose_object_ids(repo)?;
562
563    for len in min_len..=40 {
564        let prefix = &target[..len];
565        let matches = all
566            .iter()
567            .filter(|candidate| candidate.starts_with(prefix))
568            .count();
569        if matches <= 1 {
570            return Ok(prefix.to_owned());
571        }
572    }
573
574    Ok(target)
575}
576
577/// Render `path` relative to `cwd` with `/` separators.
578#[must_use]
579pub fn to_relative_path(path: &Path, cwd: &Path) -> String {
580    let path_components = normalize_components(path);
581    let cwd_components = normalize_components(cwd);
582
583    let mut common = 0usize;
584    let max_common = path_components.len().min(cwd_components.len());
585    while common < max_common && path_components[common] == cwd_components[common] {
586        common += 1;
587    }
588
589    let mut parts = Vec::new();
590    let up_count = cwd_components.len().saturating_sub(common);
591    for _ in 0..up_count {
592        parts.push("..".to_owned());
593    }
594    for item in path_components.iter().skip(common) {
595        parts.push(item.clone());
596    }
597
598    if parts.is_empty() {
599        ".".to_owned()
600    } else {
601        parts.join("/")
602    }
603}
604
605fn resolve_base(repo: &Repository, spec: &str) -> Result<ObjectId> {
606    // Handle @{upstream} / @{u} / @{push} suffixes
607    if let Some(full_ref) = try_resolve_at_suffix(repo, spec) {
608        return refs::resolve_ref(&repo.git_dir, &full_ref)
609            .map_err(|_| Error::ObjectNotFound(spec.to_owned()));
610    }
611
612    // Handle @{-N} syntax: Nth previously checked out branch
613    // Also handle @{-N}@{M} compound form: resolve branch, then reflog
614    if spec.starts_with("@{-") {
615        // Find the closing } for the @{-N} part
616        if let Some(close) = spec[3..].find('}') {
617            let n_str = &spec[3..3 + close];
618            if let Ok(n) = n_str.parse::<usize>() {
619                if n >= 1 {
620                    let suffix = &spec[3 + close + 1..]; // after the first }
621                    if suffix.is_empty() {
622                        // Plain @{-N}
623                        if let Some(oid) = try_resolve_at_minus(repo, spec)? {
624                            return Ok(oid);
625                        }
626                    } else {
627                        // @{-N}@{M} or @{-N}@{...} compound form
628                        // Resolve @{-N} to branch name, then re-resolve as branch+suffix
629                        let branch = resolve_at_minus_to_branch(repo, n)?;
630                        let new_spec = format!("{branch}{suffix}");
631                        return resolve_base(repo, &new_spec);
632                    }
633                }
634            }
635        }
636    }
637
638    // Handle @{N} reflog syntax: ref@{N} or @{N} (meaning HEAD@{N})
639    if let Some(oid) = try_resolve_reflog_index(repo, spec)? {
640        return Ok(oid);
641    }
642
643    // Handle `:/pattern` — search commit messages from HEAD
644    if let Some(pattern) = spec.strip_prefix(":/") {
645        if !pattern.is_empty() {
646            return resolve_commit_message_search(repo, pattern);
647        }
648    }
649
650    // Handle `:N:path` — look up path in the index at stage N
651    // Also handle `:path` — look up path in the index (stage 0)
652    if let Some(rest) = spec.strip_prefix(':') {
653        if !rest.is_empty() && !rest.starts_with('/') {
654            // Check for :N:path pattern (N is a single digit 0-3)
655            if rest.len() >= 3 && rest.as_bytes()[1] == b':' {
656                if let Some(stage_char) = rest.chars().next() {
657                    if let Some(stage) = stage_char.to_digit(10) {
658                        if stage <= 3 {
659                            let raw_path = &rest[2..];
660                            let path = raw_path.strip_prefix("./").unwrap_or(raw_path);
661                            return resolve_index_path_at_stage(repo, path, stage as u8);
662                        }
663                    }
664                }
665            }
666            // Strip leading ./ prefix for index paths (e.g., :./file.t)
667            let clean_rest = rest.strip_prefix("./").unwrap_or(rest);
668            return resolve_index_path(repo, clean_rest);
669        }
670    }
671
672    if let Some((treeish, path)) = split_treeish_spec(spec) {
673        let root_oid = resolve_revision(repo, treeish)?;
674        return resolve_treeish_path(repo, root_oid, path);
675    }
676
677    if let Ok(oid) = spec.parse::<ObjectId>() {
678        // A full 40-hex OID is always accepted, even if the object
679        // doesn't exist in the ODB (matches git behavior).
680        return Ok(oid);
681    }
682
683    if is_hex_prefix(spec) {
684        let matches = find_abbrev_matches(repo, spec)?;
685        if matches.len() == 1 {
686            return Ok(matches[0]);
687        }
688        if matches.len() > 1 {
689            return Err(Error::InvalidRef(format!(
690                "short object ID {} is ambiguous",
691                spec
692            )));
693        }
694    }
695
696    if let Ok(oid) = refs::resolve_ref(&repo.git_dir, spec) {
697        return Ok(oid);
698    }
699    for candidate in &[
700        format!("refs/heads/{spec}"),
701        format!("refs/tags/{spec}"),
702        format!("refs/remotes/{spec}"),
703    ] {
704        if let Ok(oid) = refs::resolve_ref(&repo.git_dir, candidate) {
705            return Ok(oid);
706        }
707    }
708
709    // As a last resort, try resolving as HEAD:<spec> (index path lookup)
710    // This allows `git rev-parse b` to resolve to the blob for file `b`
711    // in the current HEAD tree, matching Git's behavior for path arguments.
712    if !spec.contains(':') && !spec.starts_with('-') {
713        if let Ok(oid) = resolve_index_path(repo, spec) {
714            return Ok(oid);
715        }
716    }
717
718    Err(Error::ObjectNotFound(spec.to_owned()))
719}
720
721/// Resolve `@{-N}` to the branch name (e.g. "side"), not to an OID.
722fn resolve_at_minus_to_branch(repo: &Repository, n: usize) -> Result<String> {
723    let entries = read_reflog(&repo.git_dir, "HEAD")?;
724    let mut count = 0usize;
725    for entry in entries.iter().rev() {
726        let msg = &entry.message;
727        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
728            count += 1;
729            if count == n {
730                if let Some(to_pos) = rest.find(" to ") {
731                    return Ok(rest[..to_pos].to_string());
732                }
733            }
734        }
735    }
736    Err(Error::InvalidRef(format!(
737        "@{{-{n}}}: only {count} checkout(s) in reflog"
738    )))
739}
740
741/// Try to resolve `@{-N}` syntax — the Nth previously checked out branch.
742/// Returns the resolved OID if matching, or None if not matching.
743fn try_resolve_at_minus(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
744    // Match @{-N} only (no ref prefix)
745    if !spec.starts_with("@{-") || !spec.ends_with('}') {
746        return Ok(None);
747    }
748    let inner = &spec[3..spec.len() - 1];
749    let n: usize = match inner.parse() {
750        Ok(n) if n >= 1 => n,
751        _ => return Ok(None),
752    };
753    // Read HEAD reflog and find the Nth "checkout: moving from X to Y" entry
754    let entries = read_reflog(&repo.git_dir, "HEAD")?;
755    let mut count = 0usize;
756    // Iterate newest-first
757    for entry in entries.iter().rev() {
758        let msg = &entry.message;
759        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
760            count += 1;
761            if count == n {
762                // Extract the "from" branch name
763                if let Some(to_pos) = rest.find(" to ") {
764                    let from_branch = &rest[..to_pos];
765                    // Try to resolve the branch name
766                    let ref_name = format!("refs/heads/{from_branch}");
767                    if let Ok(oid) = refs::resolve_ref(&repo.git_dir, &ref_name) {
768                        return Ok(Some(oid));
769                    }
770                    // Try as-is (might be a detached HEAD SHA)
771                    if let Ok(oid) = from_branch.parse::<ObjectId>() {
772                        if repo.odb.exists(&oid) {
773                            return Ok(Some(oid));
774                        }
775                    }
776                    return Err(Error::InvalidRef(format!(
777                        "cannot resolve @{{-{n}}}: branch '{}' not found",
778                        from_branch
779                    )));
780                }
781            }
782        }
783    }
784    Err(Error::InvalidRef(format!(
785        "@{{-{n}}}: only {count} checkout(s) in reflog"
786    )))
787}
788
789/// Try to resolve `ref@{N}` reflog index syntax.
790/// Returns the OID at that reflog position, or None if not matching.
791fn try_resolve_reflog_index(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
792    // Match patterns like HEAD@{0}, main@{1}, @{0}, refs/heads/main@{2}
793    let at_pos = match spec.find("@{") {
794        Some(p) => p,
795        None => return Ok(None),
796    };
797    if !spec.ends_with('}') {
798        return Ok(None);
799    }
800    let inner = &spec[at_pos + 2..spec.len() - 1];
801    // Handle @{now} — equivalent to @{0} (most recent reflog entry)
802    let index_or_date: ReflogSelector = if inner.eq_ignore_ascii_case("now") {
803        ReflogSelector::Index(0)
804    } else if let Ok(n) = inner.parse::<usize>() {
805        ReflogSelector::Index(n)
806    } else if let Some(ts) = approxidate(inner) {
807        ReflogSelector::Date(ts)
808    } else {
809        return Ok(None);
810    };
811    let refname_raw = &spec[..at_pos];
812    let refname = if refname_raw.is_empty() {
813        "HEAD".to_string()
814    } else if refname_raw == "HEAD" || refname_raw.starts_with("refs/") {
815        refname_raw.to_string()
816    } else {
817        // DWIM: try refs/heads/<name>
818        let candidate = format!("refs/heads/{refname_raw}");
819        if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
820            candidate
821        } else {
822            refname_raw.to_string()
823        }
824    };
825    let entries = read_reflog(&repo.git_dir, &refname)?;
826    if entries.is_empty() {
827        return Err(Error::InvalidRef(format!(
828            "log for '{}' is empty",
829            refname_raw
830        )));
831    }
832    match index_or_date {
833        ReflogSelector::Index(index) => {
834            // Reflog entries are oldest-first in file; @{0} is the newest (last)
835            let reversed_idx = entries.len().checked_sub(1 + index).ok_or_else(|| {
836                Error::InvalidRef(format!(
837                    "log for '{}' only has {} entries",
838                    refname_raw,
839                    entries.len()
840                ))
841            })?;
842            Ok(Some(entries[reversed_idx].new_oid))
843        }
844        ReflogSelector::Date(target_ts) => {
845            // Find the reflog entry whose timestamp is closest to but >= target_ts.
846            // Entries are oldest-first; scan newest-first to find the first
847            // entry at or before the target date.
848            for entry in entries.iter().rev() {
849                let ts = parse_reflog_entry_timestamp(entry);
850                if let Some(t) = ts {
851                    if t <= target_ts {
852                        return Ok(Some(entry.new_oid));
853                    }
854                }
855            }
856            // If all entries are after target date, return the oldest entry
857            Ok(Some(entries[0].new_oid))
858        }
859    }
860}
861
862enum ReflogSelector {
863    Index(usize),
864    Date(i64),
865}
866
867/// Parse a timestamp from a reflog entry's identity string.
868fn parse_reflog_entry_timestamp(entry: &crate::reflog::ReflogEntry) -> Option<i64> {
869    // Identity looks like: "Name <email> 1234567890 +0000"
870    let parts: Vec<&str> = entry.identity.rsplitn(3, ' ').collect();
871    if parts.len() >= 2 {
872        parts[1].parse::<i64>().ok()
873    } else {
874        None
875    }
876}
877
878/// Simple approximate date parser for reflog date lookups.
879/// Handles formats like "2001-09-17", "3.hot.dogs.on.2001-09-17", etc.
880fn approxidate(s: &str) -> Option<i64> {
881    let now_ts = std::time::SystemTime::now()
882        .duration_since(std::time::UNIX_EPOCH)
883        .ok()
884        .map(|d| d.as_secs() as i64)
885        .unwrap_or(0);
886    let lower = s.trim().to_ascii_lowercase();
887    if lower == "now" {
888        return Some(now_ts);
889    }
890    // Handle relative time: "N.unit.ago" or "N unit ago"
891    // e.g. "1.year.ago", "2.weeks.ago", "3 hours ago"
892    let relative = lower.replace('.', " ");
893    let parts: Vec<&str> = relative.split_whitespace().collect();
894    if parts.len() >= 2 {
895        // Try to parse "N unit ago" or just "N unit"
896        let (n_str, unit, is_ago) = if parts.len() >= 3 && parts[2] == "ago" {
897            (parts[0], parts[1], true)
898        } else if parts.len() == 2 {
899            (parts[0], parts[1], false)
900        } else {
901            ("", "", false)
902        };
903        if !n_str.is_empty() {
904            if let Ok(n) = n_str.parse::<i64>() {
905                let secs: Option<i64> = match unit.trim_end_matches('s') {
906                    "second" => Some(n),
907                    "minute" => Some(n * 60),
908                    "hour" => Some(n * 3600),
909                    "day" => Some(n * 86400),
910                    "week" => Some(n * 604800),
911                    "month" => Some(n * 2592000),
912                    "year" => Some(n * 31536000),
913                    _ => None,
914                };
915                if let Some(s) = secs {
916                    return Some(if is_ago || true {
917                        now_ts - s
918                    } else {
919                        now_ts + s
920                    });
921                }
922            }
923        }
924    }
925    // Try to extract a YYYY-MM-DD pattern from the string
926    let re_like = |input: &str| -> Option<i64> {
927        // Scan for 4-digit year followed by -MM-DD
928        for (i, _) in input.char_indices() {
929            let rest = &input[i..];
930            if rest.len() >= 10 {
931                let bytes = rest.as_bytes();
932                if bytes[4] == b'-'
933                    && bytes[7] == b'-'
934                    && bytes[0..4].iter().all(|b| b.is_ascii_digit())
935                    && bytes[5..7].iter().all(|b| b.is_ascii_digit())
936                    && bytes[8..10].iter().all(|b| b.is_ascii_digit())
937                {
938                    let year: i32 = rest[0..4].parse().ok()?;
939                    let month: u8 = rest[5..7].parse().ok()?;
940                    let day: u8 = rest[8..10].parse().ok()?;
941                    let date = time::Date::from_calendar_date(
942                        year,
943                        time::Month::try_from(month).ok()?,
944                        day,
945                    )
946                    .ok()?;
947                    let dt = date.with_hms(0, 0, 0).ok()?;
948                    let odt = dt.assume_utc();
949                    return Some(odt.unix_timestamp());
950                }
951            }
952        }
953        None
954    };
955    re_like(s)
956}
957
958/// Try to resolve `@{upstream}`, `@{u}`, `@{push}` style suffixes.
959/// Returns the full ref name if recognized, None otherwise.
960fn try_resolve_at_suffix(repo: &Repository, spec: &str) -> Option<String> {
961    // Check for @{upstream}, @{u}, @{UPSTREAM}, @{U}, @{push} (case-insensitive for upstream)
962    let lower = spec.to_lowercase();
963    if lower.ends_with("@{upstream}") || lower.ends_with("@{u}") {
964        let suffix_len = if lower.ends_with("@{upstream}") {
965            11
966        } else {
967            4
968        };
969        let base = &spec[..spec.len() - suffix_len];
970        return resolve_upstream_ref(repo, base);
971    }
972    if lower.ends_with("@{push}") {
973        let base = &spec[..spec.len() - 7];
974        return resolve_push_ref(repo, base);
975    }
976    None
977}
978
979/// Look up a path in the index (stage 0) and return its OID.
980
981fn resolve_index_path(repo: &Repository, path: &str) -> Result<ObjectId> {
982    resolve_index_path_at_stage(repo, path, 0)
983}
984
985/// Look up a path in the index at a given stage and return its OID.
986fn resolve_index_path_at_stage(repo: &Repository, path: &str, stage: u8) -> Result<ObjectId> {
987    use crate::index::Index;
988    let index_path = if let Ok(raw) = std::env::var("GIT_INDEX_FILE") {
989        let p = std::path::PathBuf::from(raw);
990        if p.is_absolute() {
991            p
992        } else if let Ok(cwd) = std::env::current_dir() {
993            cwd.join(p)
994        } else {
995            p
996        }
997    } else {
998        repo.index_path()
999    };
1000    let index =
1001        Index::load(&index_path).map_err(|_| Error::ObjectNotFound(format!(":{stage}:{path}")))?;
1002    match index.get(path.as_bytes(), stage) {
1003        Some(entry) => Ok(entry.oid),
1004        None => Err(Error::ObjectNotFound(format!(":{stage}:{path}"))),
1005    }
1006}
1007
1008/// Split `treeish:path` at the first colon that separates a revision from a path,
1009/// ignoring colons inside `^{...}` peel operators.
1010///
1011/// Returns [`None`] for index-only forms like `:path` and `:N:path` (leading `:`).
1012fn split_treeish_colon(spec: &str) -> Option<(&str, &str)> {
1013    if spec.starts_with(':') {
1014        return None;
1015    }
1016    let bytes = spec.as_bytes();
1017    let mut i = 0usize;
1018    let mut peel_depth = 0usize;
1019    while i < bytes.len() {
1020        if i + 1 < bytes.len() && bytes[i] == b'^' && bytes[i + 1] == b'{' {
1021            peel_depth += 1;
1022            i += 2;
1023            continue;
1024        }
1025        if peel_depth > 0 {
1026            if bytes[i] == b'}' {
1027                peel_depth -= 1;
1028            }
1029            i += 1;
1030            continue;
1031        }
1032        if bytes[i] == b':' && i > 0 {
1033            let before = &spec[..i];
1034            let after = &spec[i + 1..];
1035            if !before.is_empty() {
1036                return Some((before, after)); // after may be empty ("HEAD:" = root tree)
1037            }
1038        }
1039        i += 1;
1040    }
1041    None
1042}
1043
1044fn split_treeish_spec(spec: &str) -> Option<(&str, &str)> {
1045    split_treeish_colon(spec)
1046}
1047
1048fn resolve_treeish_path(repo: &Repository, treeish: ObjectId, path: &str) -> Result<ObjectId> {
1049    let object = repo.odb.read(&treeish)?;
1050    let mut current_tree = match object.kind {
1051        ObjectKind::Commit => parse_commit(&object.data)?.tree,
1052        ObjectKind::Tree => treeish,
1053        _ => {
1054            return Err(Error::InvalidRef(format!(
1055                "object {treeish} does not name a tree"
1056            )))
1057        }
1058    };
1059
1060    let mut parts = path.split('/').filter(|part| !part.is_empty()).peekable();
1061    if parts.peek().is_none() {
1062        return Ok(current_tree);
1063    }
1064    while let Some(part) = parts.next() {
1065        let tree_object = repo.odb.read(&current_tree)?;
1066        if tree_object.kind != ObjectKind::Tree {
1067            return Err(Error::CorruptObject(format!(
1068                "object {current_tree} is not a tree"
1069            )));
1070        }
1071        let entries = parse_tree(&tree_object.data)?;
1072        let Some(entry) = entries.iter().find(|entry| entry.name == part.as_bytes()) else {
1073            return Err(Error::ObjectNotFound(path.to_owned()));
1074        };
1075        if parts.peek().is_none() {
1076            return Ok(entry.oid);
1077        }
1078        current_tree = entry.oid;
1079    }
1080
1081    Err(Error::ObjectNotFound(path.to_owned()))
1082}
1083
1084fn apply_peel(repo: &Repository, mut oid: ObjectId, peel: Option<&str>) -> Result<ObjectId> {
1085    match peel {
1086        None | Some("object") => Ok(oid),
1087        Some(search) if search.starts_with('/') => {
1088            let pattern = &search[1..];
1089            if pattern.is_empty() {
1090                return Err(Error::InvalidRef(
1091                    "empty commit message search pattern".to_owned(),
1092                ));
1093            }
1094            resolve_commit_message_search_from(repo, oid, pattern)
1095        }
1096        Some("") => {
1097            while let Ok(obj) = repo.odb.read(&oid) {
1098                if obj.kind != ObjectKind::Tag {
1099                    break;
1100                }
1101                oid = parse_tag_target(&obj.data)?;
1102            }
1103            Ok(oid)
1104        }
1105        Some("commit") => {
1106            oid = apply_peel(repo, oid, Some(""))?;
1107            let obj = repo.odb.read(&oid)?;
1108            if obj.kind == ObjectKind::Commit {
1109                Ok(oid)
1110            } else {
1111                Err(Error::InvalidRef("expected commit".to_owned()))
1112            }
1113        }
1114        Some("tree") => {
1115            // Peel tags, then dereference a commit to its tree.
1116            oid = apply_peel(repo, oid, Some(""))?;
1117            let obj = repo.odb.read(&oid)?;
1118            match obj.kind {
1119                ObjectKind::Tree => Ok(oid),
1120                ObjectKind::Commit => Ok(parse_commit(&obj.data)?.tree),
1121                _ => Err(Error::InvalidRef("expected tree or commit".to_owned())),
1122            }
1123        }
1124        Some("blob") => {
1125            // ^{blob}: peel tags until we reach a blob
1126            let mut cur = oid;
1127            loop {
1128                let obj = repo.odb.read(&cur)?;
1129                match obj.kind {
1130                    ObjectKind::Blob => return Ok(cur),
1131                    ObjectKind::Tag => {
1132                        cur = parse_tag_target(&obj.data)?;
1133                    }
1134                    _ => return Err(Error::InvalidRef("expected blob".to_owned())),
1135                }
1136            }
1137        }
1138        Some("object") => {
1139            // ^{object}: just return the OID as-is (any object)
1140            Ok(oid)
1141        }
1142        Some("tag") => {
1143            // ^{tag}: return if it's a tag object
1144            let obj = repo.odb.read(&oid)?;
1145            if obj.kind == ObjectKind::Tag {
1146                Ok(oid)
1147            } else {
1148                Err(Error::InvalidRef("expected tag".to_owned()))
1149            }
1150        }
1151        Some(other) => Err(Error::InvalidRef(format!(
1152            "unsupported peel operator '{{{other}}}'"
1153        ))),
1154    }
1155}
1156
1157fn parse_peel_suffix(spec: &str) -> (&str, Option<&str>) {
1158    if let Some(base) = spec.strip_suffix("^{}") {
1159        return (base, Some(""));
1160    }
1161    if let Some(start) = spec.rfind("^{") {
1162        if spec.ends_with('}') {
1163            let base = &spec[..start];
1164            let op = &spec[start + 2..spec.len() - 1];
1165            return (base, Some(op));
1166        }
1167    }
1168    // `^0` is shorthand for `^{commit}` — peel tags and verify commit.
1169    if let Some(base) = spec.strip_suffix("^0") {
1170        // Only match if the character before `^0` is not also a `^` (avoid
1171        // matching `^^0` as a peel instead of nav+nav).
1172        if !base.ends_with('^') {
1173            return (base, Some("commit"));
1174        }
1175    }
1176    (spec, None)
1177}
1178
1179fn parse_tag_target(data: &[u8]) -> Result<ObjectId> {
1180    let text = std::str::from_utf8(data)
1181        .map_err(|_| Error::CorruptObject("invalid tag object".to_owned()))?;
1182    let Some(line) = text.lines().find(|line| line.starts_with("object ")) else {
1183        return Err(Error::CorruptObject("tag missing object header".to_owned()));
1184    };
1185    let oid_text = line.trim_start_matches("object ").trim();
1186    oid_text.parse::<ObjectId>()
1187}
1188
1189/// Search commit messages reachable from `start` and return the first commit
1190/// whose message contains `pattern`.
1191fn resolve_commit_message_search_from(
1192    repo: &Repository,
1193    start: ObjectId,
1194    pattern: &str,
1195) -> Result<ObjectId> {
1196    // Note: ! negation is NOT supported in ^{/pattern} peel context (only in :/! prefix)
1197    let regex = Regex::new(pattern).ok();
1198    let mut visited = std::collections::HashSet::new();
1199    let mut queue = std::collections::VecDeque::new();
1200    queue.push_back(start);
1201    visited.insert(start);
1202
1203    while let Some(oid) = queue.pop_front() {
1204        let obj = match repo.odb.read(&oid) {
1205            Ok(o) => o,
1206            Err(_) => continue,
1207        };
1208        if obj.kind != ObjectKind::Commit {
1209            continue;
1210        }
1211        let commit = match parse_commit(&obj.data) {
1212            Ok(c) => c,
1213            Err(_) => continue,
1214        };
1215
1216        let is_match = if let Some(re) = &regex {
1217            re.is_match(&commit.message)
1218        } else {
1219            commit.message.contains(pattern)
1220        };
1221        if is_match {
1222            return Ok(oid);
1223        }
1224
1225        for parent in &commit.parents {
1226            if visited.insert(*parent) {
1227                queue.push_back(*parent);
1228            }
1229        }
1230    }
1231
1232    Err(Error::ObjectNotFound(format!(":/{pattern}")))
1233}
1234
1235fn find_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
1236    if !is_hex_prefix(prefix) || !(4..=40).contains(&prefix.len()) {
1237        return Ok(Vec::new());
1238    }
1239    let all = collect_loose_object_ids(repo)?;
1240    let mut matches = Vec::new();
1241    for candidate in all {
1242        if candidate.starts_with(prefix) {
1243            matches.push(candidate.parse::<ObjectId>()?);
1244        }
1245    }
1246    Ok(matches)
1247}
1248
1249fn collect_loose_object_ids(repo: &Repository) -> Result<Vec<String>> {
1250    let mut ids = Vec::new();
1251    let objects_dir = repo.odb.objects_dir();
1252    let read = match fs::read_dir(objects_dir) {
1253        Ok(read) => read,
1254        Err(err) => return Err(Error::Io(err)),
1255    };
1256
1257    for dir_entry in read {
1258        let dir_entry = dir_entry?;
1259        let name = dir_entry.file_name();
1260        let Some(prefix) = name.to_str() else {
1261            continue;
1262        };
1263        if !is_two_hex(prefix) {
1264            continue;
1265        }
1266        if !dir_entry.file_type()?.is_dir() {
1267            continue;
1268        }
1269
1270        let files = fs::read_dir(dir_entry.path())?;
1271        for file_entry in files {
1272            let file_entry = file_entry?;
1273            if !file_entry.file_type()?.is_file() {
1274                continue;
1275            }
1276            let file_name = file_entry.file_name();
1277            let Some(suffix) = file_name.to_str() else {
1278                continue;
1279            };
1280            if suffix.len() == 38 && suffix.chars().all(|ch| ch.is_ascii_hexdigit()) {
1281                ids.push(format!("{prefix}{suffix}"));
1282            }
1283        }
1284    }
1285
1286    Ok(ids)
1287}
1288
1289fn is_two_hex(text: &str) -> bool {
1290    text.len() == 2 && text.chars().all(|ch| ch.is_ascii_hexdigit())
1291}
1292
1293fn is_hex_prefix(text: &str) -> bool {
1294    !text.is_empty() && text.chars().all(|ch| ch.is_ascii_hexdigit())
1295}
1296
1297fn path_is_within(path: &Path, container: &Path) -> bool {
1298    if path == container {
1299        return true;
1300    }
1301    path.starts_with(container)
1302}
1303
1304fn normalize_components(path: &Path) -> Vec<String> {
1305    path.components()
1306        .filter_map(|component| match component {
1307            Component::RootDir => Some(String::from("/")),
1308            Component::Normal(item) => Some(item.to_string_lossy().into_owned()),
1309            _ => None,
1310        })
1311        .collect()
1312}
1313
1314fn component_to_text(component: Component<'_>) -> Option<String> {
1315    match component {
1316        Component::Normal(item) => Some(os_to_string(item)),
1317        _ => None,
1318    }
1319}
1320
1321fn os_to_string(text: &OsStr) -> String {
1322    text.to_string_lossy().into_owned()
1323}
1324
1325/// Search commit messages from HEAD backwards for a commit whose message
1326/// contains `pattern`.  Returns the first matching commit OID.
1327fn resolve_commit_message_search(
1328    repo: &crate::repo::Repository,
1329    pattern: &str,
1330) -> Result<ObjectId> {
1331    // Handle negated pattern: /! means negate; /!! means literal /!
1332    let (negate, effective_pattern) = if pattern.starts_with('!') {
1333        if pattern.starts_with("!!") {
1334            (false, &pattern[1..]) // !! = literal !
1335        } else {
1336            (true, &pattern[1..]) // ! = negate
1337        }
1338    } else {
1339        (false, pattern)
1340    };
1341    let regex = Regex::new(effective_pattern).ok();
1342    use crate::state::resolve_head;
1343    let head =
1344        resolve_head(&repo.git_dir).map_err(|_| Error::ObjectNotFound(format!(":/{pattern}")))?;
1345    let start_oid = match head.oid() {
1346        Some(oid) => *oid,
1347        None => return Err(Error::ObjectNotFound(format!(":/{pattern}"))),
1348    };
1349
1350    let mut visited = std::collections::HashSet::new();
1351    let mut queue = std::collections::VecDeque::new();
1352    queue.push_back(start_oid);
1353    visited.insert(start_oid);
1354
1355    while let Some(oid) = queue.pop_front() {
1356        let obj = match repo.odb.read(&oid) {
1357            Ok(o) => o,
1358            Err(_) => continue,
1359        };
1360        // Skip non-commit objects
1361        if obj.kind != ObjectKind::Commit {
1362            continue;
1363        }
1364        let commit = match parse_commit(&obj.data) {
1365            Ok(c) => c,
1366            Err(_) => continue,
1367        };
1368
1369        // Check if message matches pattern (regex, with literal fallback)
1370        let base_match = if let Some(re) = &regex {
1371            re.is_match(&commit.message)
1372        } else {
1373            commit.message.contains(effective_pattern)
1374        };
1375        let is_match = if negate { !base_match } else { base_match };
1376        if is_match {
1377            return Ok(oid);
1378        }
1379
1380        // Enqueue parents
1381        for parent in &commit.parents {
1382            if visited.insert(*parent) {
1383                queue.push_back(*parent);
1384            }
1385        }
1386    }
1387
1388    Err(Error::ObjectNotFound(format!(":/{pattern}")))
1389}
1390
1391/// Public: find all object IDs whose hex prefix matches the given string.
1392pub fn list_loose_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
1393    find_abbrev_matches(repo, prefix)
1394}