Skip to main content

grit_lib/
state.rs

1//! Repository state machine — HEAD resolution, branch status, and
2//! in-progress operation detection.
3//!
4//! # Overview
5//!
6//! Git repositories can be in various states beyond just "clean":
7//! merging, rebasing, cherry-picking, reverting, bisecting, etc.
8//! This module detects those states by checking for sentinel files
9//! (e.g. `MERGE_HEAD`, `rebase-merge/`) in the `.git` directory.
10//!
11//! It also resolves `HEAD` to determine the current branch and commit,
12//! and provides working tree / index diff summaries used by `status`,
13//! `commit`, and other porcelain commands.
14
15use std::fs;
16use std::path::Path;
17
18use crate::check_ref_format::{check_refname_format, RefNameOptions};
19use crate::error::{Error, Result};
20use crate::objects::ObjectId;
21use crate::reflog;
22
23/// The current state of HEAD.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum HeadState {
26    /// HEAD points to a branch via a symbolic ref (e.g. `ref: refs/heads/main`).
27    Branch {
28        /// The full ref name (e.g. `refs/heads/main`).
29        refname: String,
30        /// The short branch name (e.g. `main`).
31        short_name: String,
32        /// The commit OID that the branch points to, or `None` if the
33        /// branch is unborn (no commits yet).
34        oid: Option<ObjectId>,
35    },
36    /// HEAD is detached — pointing directly at a commit.
37    Detached {
38        /// The commit OID.
39        oid: ObjectId,
40    },
41    /// HEAD is in an invalid or unreadable state.
42    Invalid,
43}
44
45impl HeadState {
46    /// Return the commit OID if HEAD resolves to one.
47    #[must_use]
48    pub fn oid(&self) -> Option<&ObjectId> {
49        match self {
50            Self::Branch { oid, .. } => oid.as_ref(),
51            Self::Detached { oid } => Some(oid),
52            Self::Invalid => None,
53        }
54    }
55
56    /// Return the branch name if HEAD is on a branch.
57    #[must_use]
58    pub fn branch_name(&self) -> Option<&str> {
59        match self {
60            Self::Branch { short_name, .. } => Some(short_name),
61            _ => None,
62        }
63    }
64
65    /// Whether HEAD is on an unborn branch (no commits yet).
66    #[must_use]
67    pub fn is_unborn(&self) -> bool {
68        matches!(self, Self::Branch { oid: None, .. })
69    }
70
71    /// Whether HEAD is detached.
72    #[must_use]
73    pub fn is_detached(&self) -> bool {
74        matches!(self, Self::Detached { .. })
75    }
76}
77
78/// An in-progress operation that the repository is in the middle of.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum InProgressOperation {
81    /// A merge is in progress (`MERGE_HEAD` exists).
82    Merge,
83    /// An interactive rebase is in progress (`rebase-merge/` exists).
84    RebaseInteractive,
85    /// A non-interactive rebase is in progress (`rebase-apply/` exists).
86    Rebase,
87    /// A cherry-pick is in progress (`CHERRY_PICK_HEAD` exists).
88    CherryPick,
89    /// A revert is in progress (`REVERT_HEAD` exists).
90    Revert,
91    /// A bisect is in progress (`BISECT_LOG` exists).
92    Bisect,
93    /// An `am` (apply mailbox) is in progress (`rebase-apply/applying` exists).
94    Am,
95}
96
97impl InProgressOperation {
98    /// Human-readable description of the operation.
99    #[must_use]
100    pub fn description(&self) -> &'static str {
101        match self {
102            Self::Merge => "merge",
103            Self::RebaseInteractive => "interactive rebase",
104            Self::Rebase => "rebase",
105            Self::CherryPick => "cherry-pick",
106            Self::Revert => "revert",
107            Self::Bisect => "bisect",
108            Self::Am => "am",
109        }
110    }
111
112    /// Hint text for how to continue or abort.
113    #[must_use]
114    pub fn hint(&self) -> &'static str {
115        match self {
116            Self::Merge => "fix conflicts and run \"git commit\"\n  (use \"git merge --abort\" to abort the merge)",
117            Self::RebaseInteractive => "fix conflicts and then run \"git rebase --continue\"\n  (use \"git rebase --abort\" to abort the rebase)",
118            Self::Rebase => "fix conflicts and then run \"git rebase --continue\"\n  (use \"git rebase --abort\" to abort the rebase)",
119            Self::CherryPick => "fix conflicts and run \"git cherry-pick --continue\"\n  (use \"git cherry-pick --abort\" to abort the cherry-pick)",
120            Self::Revert => "fix conflicts and run \"git revert --continue\"\n  (use \"git revert --abort\" to abort the revert)",
121            Self::Bisect => "use \"git bisect reset\" to get back to the original branch",
122            Self::Am => "fix conflicts and then run \"git am --continue\"\n  (use \"git am --abort\" to abort the am)",
123        }
124    }
125}
126
127/// Full snapshot of a repository's state.
128///
129/// This is the information that porcelain commands like `status` need to
130/// display the repository's current situation.
131#[derive(Debug, Clone)]
132pub struct RepoState {
133    /// Current HEAD state.
134    pub head: HeadState,
135    /// In-progress operations (there can be multiple, e.g. rebase + merge).
136    pub in_progress: Vec<InProgressOperation>,
137    /// Whether the repository is bare.
138    pub is_bare: bool,
139}
140
141/// Resolve HEAD from the given git directory.
142///
143/// Reads `HEAD`, follows symbolic refs, and resolves the final OID.
144///
145/// # Parameters
146///
147/// - `git_dir` — path to the `.git` directory.
148///
149/// # Errors
150///
151/// Returns [`Error::Io`] if files cannot be read.
152pub fn resolve_head(git_dir: &Path) -> Result<HeadState> {
153    let head_path = git_dir.join("HEAD");
154    let content = match fs::read_link(&head_path) {
155        Ok(link_target) => {
156            let rendered = link_target.to_string_lossy();
157            if link_target.is_absolute() {
158                format!("ref: {rendered}")
159            } else if rendered.starts_with("refs/") {
160                format!("ref: {rendered}")
161            } else {
162                fs::read_to_string(&head_path).map_err(Error::Io)?
163            }
164        }
165        Err(_) => match fs::read_to_string(&head_path) {
166            Ok(c) => c,
167            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HeadState::Invalid),
168            Err(e) => return Err(Error::Io(e)),
169        },
170    };
171
172    let trimmed = content.trim();
173
174    if let Some(refname) = trimmed.strip_prefix("ref: ") {
175        let refname = if refname == "refs/heads/.invalid" {
176            match crate::refs::read_ref_file(&git_dir.join("refs").join("heads")) {
177                Ok(crate::refs::Ref::Symbolic(target)) => target,
178                _ => refname.to_owned(),
179            }
180        } else {
181            refname.to_owned()
182        };
183        if check_refname_format(&refname, &RefNameOptions::default()).is_err() {
184            return Ok(HeadState::Invalid);
185        }
186        let short_name = refname
187            .strip_prefix("refs/heads/")
188            .unwrap_or(&refname)
189            .to_owned();
190
191        // Resolve the branch tip via the shared refs backend (worktrees, packed-refs).
192        // Missing `refs/heads/*` => unborn branch (`None`). A symref to a non-branch
193        // target that cannot be resolved (e.g. HEAD -> `.broken`) is invalid, matching
194        // Git `refs_resolve_ref_unsafe` returning no target for `worktree list`.
195        let oid = match crate::refs::resolve_ref(git_dir, &refname) {
196            Ok(oid) => Some(oid),
197            Err(Error::InvalidRef(msg)) if msg.starts_with("ref not found:") => {
198                if refname.starts_with("refs/heads/") {
199                    None
200                } else {
201                    return Ok(HeadState::Invalid);
202                }
203            }
204            Err(e) => return Err(e),
205        };
206
207        Ok(HeadState::Branch {
208            refname,
209            short_name,
210            oid,
211        })
212    } else {
213        // Detached HEAD — should be a hex OID
214        match ObjectId::from_hex(trimmed) {
215            Ok(oid) => Ok(HeadState::Detached { oid }),
216            Err(_) => Ok(HeadState::Invalid),
217        }
218    }
219}
220
221/// Detect in-progress operations by checking for sentinel files.
222///
223/// # Parameters
224///
225/// - `git_dir` — path to the `.git` directory.
226///
227/// # Returns
228///
229/// A list of detected in-progress operations.
230pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
231    let mut ops = Vec::new();
232
233    if git_dir.join("MERGE_HEAD").exists() {
234        ops.push(InProgressOperation::Merge);
235    }
236
237    // Interactive rebase: rebase-merge/ directory
238    let rebase_merge = git_dir.join("rebase-merge");
239    if rebase_merge.is_dir() {
240        if rebase_merge.join("interactive").exists() {
241            ops.push(InProgressOperation::RebaseInteractive);
242        } else {
243            ops.push(InProgressOperation::Rebase);
244        }
245    }
246
247    // Non-interactive rebase or am: rebase-apply/ directory
248    let rebase_apply = git_dir.join("rebase-apply");
249    if rebase_apply.is_dir() {
250        if rebase_apply.join("applying").exists() {
251            ops.push(InProgressOperation::Am);
252        } else {
253            ops.push(InProgressOperation::Rebase);
254        }
255    }
256
257    if git_dir.join("CHERRY_PICK_HEAD").exists() {
258        ops.push(InProgressOperation::CherryPick);
259    }
260
261    if git_dir.join("REVERT_HEAD").exists() {
262        ops.push(InProgressOperation::Revert);
263    }
264
265    let bisect_log = crate::refs::common_dir(git_dir)
266        .unwrap_or_else(|| git_dir.to_path_buf())
267        .join("BISECT_LOG");
268    if bisect_log.exists() {
269        ops.push(InProgressOperation::Bisect);
270    }
271
272    ops
273}
274
275/// Snapshot of repository state used by `git status` long-format output (`wt-status.c`).
276///
277/// This mirrors Git's `struct wt_status_state` closely enough for advice lines and
278/// branch headers (merge, rebase, cherry-pick, revert, bisect, am, detached HEAD).
279#[derive(Debug, Clone, Default)]
280pub struct WtStatusState {
281    /// `MERGE_HEAD` exists (merge or merge+rebase).
282    pub merge_in_progress: bool,
283    /// `.git/rebase-merge/` exists and `interactive` is present.
284    pub rebase_interactive_in_progress: bool,
285    /// Rebase without interactive marker (`rebase-merge` non-interactive or `rebase-apply`).
286    pub rebase_in_progress: bool,
287    /// Display string for the branch being rebased (from `head-name`, may be absent).
288    pub rebase_branch: Option<String>,
289    /// Display string for the rebase onto commit (from `onto`, abbreviated OID or name).
290    pub rebase_onto: Option<String>,
291    /// `rebase-apply/applying` exists.
292    pub am_in_progress: bool,
293    /// Empty patch in `am` session (`rebase-apply/patch` has size 0).
294    pub am_empty_patch: bool,
295    /// `CHERRY_PICK_HEAD` or sequencer pick without head.
296    pub cherry_pick_in_progress: bool,
297    /// `None` means "in progress" without a specific commit (null OID / sequencer-only).
298    pub cherry_pick_head_oid: Option<ObjectId>,
299    /// `REVERT_HEAD` or sequencer revert without head.
300    pub revert_in_progress: bool,
301    pub revert_head_oid: Option<ObjectId>,
302    /// `BISECT_LOG` exists (checked under common dir).
303    pub bisect_in_progress: bool,
304    pub bisecting_from: Option<String>,
305    /// Detached HEAD: human label (`wt_status_get_detached_from`).
306    pub detached_from: Option<String>,
307    /// True when `HEAD` OID equals the detached tip OID.
308    pub detached_at: bool,
309}
310
311fn abbrev_oid(oid: &ObjectId) -> String {
312    oid.to_hex()[..7].to_string()
313}
314
315fn read_trimmed_line(path: &Path) -> Option<String> {
316    let s = fs::read_to_string(path).ok()?;
317    let mut line = s.lines().next()?.to_string();
318    while line.ends_with('\n') || line.ends_with('\r') {
319        line.pop();
320    }
321    if line.is_empty() {
322        None
323    } else {
324        Some(line)
325    }
326}
327
328/// Read a single-line ref/OID file like Git `get_branch()` in `wt-status.c`.
329fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
330    let path = git_dir.join(rel);
331    let mut sb = read_trimmed_line(&path)?;
332    if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
333        sb = branch_name.to_string();
334    } else if sb.starts_with("refs/") {
335        // keep full ref for remotes etc.
336    } else if ObjectId::from_hex(&sb).is_ok() {
337        let oid = ObjectId::from_hex(&sb).ok()?;
338        sb = abbrev_oid(&oid);
339    } else if sb == "detached HEAD" {
340        return None;
341    }
342    Some(sb)
343}
344
345fn strip_ref_for_display(full: &str) -> String {
346    if let Some(s) = full.strip_prefix("refs/tags/") {
347        return s.to_string();
348    }
349    if let Some(s) = full.strip_prefix("refs/remotes/") {
350        return s.to_string();
351    }
352    if let Some(s) = full.strip_prefix("refs/heads/") {
353        return s.to_string();
354    }
355    full.to_string()
356}
357
358fn ref_name_exists(git_dir: &Path, refname: &str) -> bool {
359    git_dir.join(refname).exists()
360        || crate::refs::packed_refs_entry_exists(git_dir, refname).unwrap_or(false)
361}
362
363fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
364    if target == "HEAD" {
365        return abbrev_oid(&noid);
366    }
367    if target.starts_with("refs/") {
368        if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
369            if oid == noid {
370                return strip_ref_for_display(target);
371            }
372        }
373    }
374    for candidate in [
375        format!("refs/heads/{target}"),
376        format!("refs/tags/{target}"),
377        format!("refs/remotes/{target}"),
378    ] {
379        if candidate.starts_with("refs/tags/") && ref_name_exists(git_dir, &candidate) {
380            return strip_ref_for_display(&candidate);
381        }
382        if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
383            if oid == noid {
384                return strip_ref_for_display(&candidate);
385            }
386        }
387    }
388    if target.len() == 40 {
389        if let Ok(oid) = ObjectId::from_hex(target) {
390            if oid == noid {
391                return abbrev_oid(&noid);
392            }
393        }
394    }
395    // `checkout … to <abbrev>` records the object name from the user's input; show that
396    // abbreviation (Git does not substitute a tag name here — see t3203 detached HEAD).
397    if !target.is_empty()
398        && target.chars().all(|c| c.is_ascii_hexdigit())
399        && target.len() <= 40
400        && noid.to_hex().starts_with(target)
401    {
402        return target.to_owned();
403    }
404    abbrev_oid(&noid)
405}
406
407fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
408    let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
409    for entry in entries.iter().rev() {
410        let msg = entry.message.trim();
411        let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
412            continue;
413        };
414        let Some(idx) = rest.rfind(" to ") else {
415            continue;
416        };
417        let target = rest[idx + 4..].trim();
418        let noid = entry.new_oid;
419        let label = dwim_detach_label(git_dir, target, noid);
420        let detached_at = head_oid == noid;
421        return Some((label, detached_at));
422    }
423    None
424}
425
426fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
427    let apply = git_dir.join("rebase-apply");
428    if apply.is_dir() {
429        if apply.join("applying").exists() {
430            state.am_in_progress = true;
431            let patch = apply.join("patch");
432            if let Ok(meta) = patch.metadata() {
433                if meta.len() == 0 {
434                    state.am_empty_patch = true;
435                }
436            }
437        } else {
438            state.rebase_in_progress = true;
439            state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
440            state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
441        }
442        return true;
443    }
444    let merge = git_dir.join("rebase-merge");
445    if merge.is_dir() {
446        if merge.join("interactive").exists() {
447            state.rebase_interactive_in_progress = true;
448        } else {
449            state.rebase_in_progress = true;
450        }
451        state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
452        state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
453        return true;
454    }
455    false
456}
457
458fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
459    let path = git_dir.join("sequencer").join("todo");
460    if !path.is_file() {
461        return None;
462    }
463    let content = fs::read_to_string(&path).ok()?;
464    for line in content.lines() {
465        let t = line.trim();
466        if t.is_empty() || t.starts_with('#') {
467            continue;
468        }
469        let mut parts = t.split_whitespace();
470        let cmd = parts.next()?;
471        return match cmd {
472            "pick" | "p" => Some(true),
473            "revert" | "r" => Some(false),
474            _ => None,
475        };
476    }
477    None
478}
479
480/// Fill [`WtStatusState`] the same way Git `wt_status_get_state` does (without sparse checkout %).
481///
482/// `get_detached_from` matches Git's third parameter: when true and `head` is detached, populate
483/// `detached_from` / `detached_at` from the `HEAD` reflog.
484pub fn wt_status_get_state(
485    git_dir: &Path,
486    head: &HeadState,
487    get_detached_from: bool,
488) -> Result<WtStatusState> {
489    let mut state = WtStatusState::default();
490
491    if git_dir.join("MERGE_HEAD").exists() {
492        wt_status_check_rebase(git_dir, &mut state);
493        state.merge_in_progress = true;
494    } else if wt_status_check_rebase(git_dir, &mut state) {
495        // rebase/am state already filled
496    } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
497        state.cherry_pick_in_progress = true;
498        state.cherry_pick_head_oid = Some(oid);
499    }
500
501    let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
502    if bisect_base.join("BISECT_LOG").exists() {
503        state.bisect_in_progress = true;
504        state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
505    }
506
507    if let Some(oid) = read_revert_head(git_dir)? {
508        state.revert_in_progress = true;
509        state.revert_head_oid = Some(oid);
510    }
511
512    if let Some(is_pick) = sequencer_first_replay(git_dir) {
513        if is_pick && !state.cherry_pick_in_progress {
514            state.cherry_pick_in_progress = true;
515            state.cherry_pick_head_oid = None;
516        } else if !is_pick && !state.revert_in_progress {
517            state.revert_in_progress = true;
518            state.revert_head_oid = None;
519        }
520    }
521
522    if get_detached_from {
523        if let HeadState::Detached { oid } = head {
524            if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
525                state.detached_from = Some(label);
526                state.detached_at = at;
527            }
528        }
529    }
530
531    Ok(state)
532}
533
534/// Whether a split commit is in progress during interactive rebase (`wt-status.c` `split_commit_in_progress`).
535pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
536    let HeadState::Detached { oid: head_oid } = head else {
537        return false;
538    };
539    let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
540        return false;
541    };
542    let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
543        return false;
544    };
545    let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
546        return false;
547    };
548    let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
549        return false;
550    };
551    if amend_line == orig_line {
552        head_oid != &amend_oid
553    } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
554        cur_orig != orig_head_oid
555    } else {
556        false
557    }
558}
559
560/// Build a complete [`RepoState`] snapshot for a repository.
561///
562/// # Parameters
563///
564/// - `git_dir` — path to the `.git` directory.
565/// - `is_bare` — whether this is a bare repository.
566///
567/// # Errors
568///
569/// Returns [`Error::Io`] on filesystem failures.
570pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
571    let head = resolve_head(git_dir)?;
572    let in_progress = detect_in_progress(git_dir);
573
574    Ok(RepoState {
575        head,
576        in_progress,
577        is_bare,
578    })
579}
580
581/// Read the MERGE_HEAD file and return the OIDs listed.
582///
583/// # Parameters
584///
585/// - `git_dir` — path to the `.git` directory.
586///
587/// # Returns
588///
589/// A vector of merge parent OIDs, or empty if not in a merge.
590pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
591    let path = git_dir.join("MERGE_HEAD");
592    let content = match fs::read_to_string(&path) {
593        Ok(c) => c,
594        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
595        Err(e) => return Err(Error::Io(e)),
596    };
597
598    let mut oids = Vec::new();
599    for line in content.lines() {
600        let trimmed = line.trim();
601        if !trimmed.is_empty() {
602            oids.push(ObjectId::from_hex(trimmed)?);
603        }
604    }
605    Ok(oids)
606}
607
608/// Read the MERGE_MSG file.
609///
610/// # Parameters
611///
612/// - `git_dir` — path to the `.git` directory.
613///
614/// # Returns
615///
616/// The merge message text, or `None` if not in a merge.
617pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
618    let path = git_dir.join("MERGE_MSG");
619    match fs::read_to_string(&path) {
620        Ok(c) => Ok(Some(c)),
621        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
622        Err(e) => Err(Error::Io(e)),
623    }
624}
625
626/// Read CHERRY_PICK_HEAD when it contains a valid 40-hex OID; `None` if missing, empty, or invalid
627/// (Git ignores malformed `CHERRY_PICK_HEAD` for the "commit $abbrev" line; sequencer still applies).
628pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
629    read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
630}
631
632/// Read REVERT_HEAD when it contains a valid OID; `None` if missing, empty, or invalid.
633pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
634    read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
635}
636
637fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
638    match fs::read_to_string(path) {
639        Ok(content) => {
640            let trimmed = content.trim();
641            if trimmed.is_empty() {
642                Ok(None)
643            } else {
644                Ok(ObjectId::from_hex(trimmed).ok())
645            }
646        }
647        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
648        Err(e) => Err(Error::Io(e)),
649    }
650}
651
652/// Read ORIG_HEAD.
653pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
654    read_single_oid_file(&git_dir.join("ORIG_HEAD"))
655}
656
657/// Read a file that contains a single OID on its first line.
658fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
659    match fs::read_to_string(path) {
660        Ok(content) => {
661            let trimmed = content.trim();
662            if trimmed.is_empty() {
663                Ok(None)
664            } else {
665                Ok(Some(ObjectId::from_hex(trimmed)?))
666            }
667        }
668        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
669        Err(e) => Err(Error::Io(e)),
670    }
671}
672
673/// Check upstream (tracking) information for the current branch.
674///
675/// Returns `(ahead, behind)` counts relative to the tracking branch.
676/// This requires commit walking and is deferred for now.
677///
678/// # Parameters
679///
680/// - `_git_dir` — path to the `.git` directory.
681/// - `_branch` — the local branch name.
682///
683/// # Returns
684///
685/// `None` if no upstream is configured.
686pub fn upstream_tracking(_git_dir: &Path, _branch: &str) -> Result<Option<(usize, usize)>> {
687    // TODO: Implement ahead/behind counting once config + rev-list integration is ready.
688    Ok(None)
689}