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 dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
359    if target == "HEAD" {
360        return abbrev_oid(&noid);
361    }
362    if target.starts_with("refs/") {
363        if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
364            if oid == noid {
365                return strip_ref_for_display(target);
366            }
367        }
368    }
369    for candidate in [
370        format!("refs/heads/{target}"),
371        format!("refs/tags/{target}"),
372        format!("refs/remotes/{target}"),
373    ] {
374        if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
375            if oid == noid {
376                return strip_ref_for_display(&candidate);
377            }
378        }
379    }
380    if target.len() == 40 {
381        if let Ok(oid) = ObjectId::from_hex(target) {
382            if oid == noid {
383                return abbrev_oid(&noid);
384            }
385        }
386    }
387    // `checkout … to <abbrev>` records the object name from the user's input; show that
388    // abbreviation (Git does not substitute a tag name here — see t3203 detached HEAD).
389    if !target.is_empty()
390        && target.chars().all(|c| c.is_ascii_hexdigit())
391        && target.len() <= 40
392        && noid.to_hex().starts_with(target)
393    {
394        return target.to_owned();
395    }
396    abbrev_oid(&noid)
397}
398
399fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
400    let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
401    for entry in entries.iter().rev() {
402        let msg = entry.message.trim();
403        let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
404            continue;
405        };
406        let Some(idx) = rest.rfind(" to ") else {
407            continue;
408        };
409        let target = rest[idx + 4..].trim();
410        let noid = entry.new_oid;
411        let label = dwim_detach_label(git_dir, target, noid);
412        let detached_at = head_oid == noid;
413        return Some((label, detached_at));
414    }
415    None
416}
417
418fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
419    let apply = git_dir.join("rebase-apply");
420    if apply.is_dir() {
421        if apply.join("applying").exists() {
422            state.am_in_progress = true;
423            let patch = apply.join("patch");
424            if let Ok(meta) = patch.metadata() {
425                if meta.len() == 0 {
426                    state.am_empty_patch = true;
427                }
428            }
429        } else {
430            state.rebase_in_progress = true;
431            state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
432            state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
433        }
434        return true;
435    }
436    let merge = git_dir.join("rebase-merge");
437    if merge.is_dir() {
438        if merge.join("interactive").exists() {
439            state.rebase_interactive_in_progress = true;
440        } else {
441            state.rebase_in_progress = true;
442        }
443        state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
444        state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
445        return true;
446    }
447    false
448}
449
450fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
451    let path = git_dir.join("sequencer").join("todo");
452    if !path.is_file() {
453        return None;
454    }
455    let content = fs::read_to_string(&path).ok()?;
456    for line in content.lines() {
457        let t = line.trim();
458        if t.is_empty() || t.starts_with('#') {
459            continue;
460        }
461        let mut parts = t.split_whitespace();
462        let cmd = parts.next()?;
463        return Some(matches!(cmd, "pick" | "p" | "revert" | "r"));
464    }
465    None
466}
467
468/// Fill [`WtStatusState`] the same way Git `wt_status_get_state` does (without sparse checkout %).
469///
470/// `get_detached_from` matches Git's third parameter: when true and `head` is detached, populate
471/// `detached_from` / `detached_at` from the `HEAD` reflog.
472pub fn wt_status_get_state(
473    git_dir: &Path,
474    head: &HeadState,
475    get_detached_from: bool,
476) -> Result<WtStatusState> {
477    let mut state = WtStatusState::default();
478
479    if git_dir.join("MERGE_HEAD").exists() {
480        wt_status_check_rebase(git_dir, &mut state);
481        state.merge_in_progress = true;
482    } else if wt_status_check_rebase(git_dir, &mut state) {
483        // rebase/am state already filled
484    } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
485        state.cherry_pick_in_progress = true;
486        state.cherry_pick_head_oid = Some(oid);
487    }
488
489    let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
490    if bisect_base.join("BISECT_LOG").exists() {
491        state.bisect_in_progress = true;
492        state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
493    }
494
495    if let Some(oid) = read_revert_head(git_dir)? {
496        state.revert_in_progress = true;
497        state.revert_head_oid = Some(oid);
498    }
499
500    if let Some(is_pick) = sequencer_first_replay(git_dir) {
501        if is_pick && !state.cherry_pick_in_progress {
502            state.cherry_pick_in_progress = true;
503            state.cherry_pick_head_oid = None;
504        } else if !is_pick && !state.revert_in_progress {
505            state.revert_in_progress = true;
506            state.revert_head_oid = None;
507        }
508    }
509
510    if get_detached_from {
511        if let HeadState::Detached { oid } = head {
512            if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
513                state.detached_from = Some(label);
514                state.detached_at = at;
515            }
516        }
517    }
518
519    Ok(state)
520}
521
522/// Whether a split commit is in progress during interactive rebase (`wt-status.c` `split_commit_in_progress`).
523pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
524    let HeadState::Detached { oid: head_oid } = head else {
525        return false;
526    };
527    let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
528        return false;
529    };
530    let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
531        return false;
532    };
533    let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
534        return false;
535    };
536    let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
537        return false;
538    };
539    if amend_line == orig_line {
540        head_oid != &amend_oid
541    } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
542        cur_orig != orig_head_oid
543    } else {
544        false
545    }
546}
547
548/// Build a complete [`RepoState`] snapshot for a repository.
549///
550/// # Parameters
551///
552/// - `git_dir` — path to the `.git` directory.
553/// - `is_bare` — whether this is a bare repository.
554///
555/// # Errors
556///
557/// Returns [`Error::Io`] on filesystem failures.
558pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
559    let head = resolve_head(git_dir)?;
560    let in_progress = detect_in_progress(git_dir);
561
562    Ok(RepoState {
563        head,
564        in_progress,
565        is_bare,
566    })
567}
568
569/// Read the MERGE_HEAD file and return the OIDs listed.
570///
571/// # Parameters
572///
573/// - `git_dir` — path to the `.git` directory.
574///
575/// # Returns
576///
577/// A vector of merge parent OIDs, or empty if not in a merge.
578pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
579    let path = git_dir.join("MERGE_HEAD");
580    let content = match fs::read_to_string(&path) {
581        Ok(c) => c,
582        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
583        Err(e) => return Err(Error::Io(e)),
584    };
585
586    let mut oids = Vec::new();
587    for line in content.lines() {
588        let trimmed = line.trim();
589        if !trimmed.is_empty() {
590            oids.push(ObjectId::from_hex(trimmed)?);
591        }
592    }
593    Ok(oids)
594}
595
596/// Read the MERGE_MSG file.
597///
598/// # Parameters
599///
600/// - `git_dir` — path to the `.git` directory.
601///
602/// # Returns
603///
604/// The merge message text, or `None` if not in a merge.
605pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
606    let path = git_dir.join("MERGE_MSG");
607    match fs::read_to_string(&path) {
608        Ok(c) => Ok(Some(c)),
609        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
610        Err(e) => Err(Error::Io(e)),
611    }
612}
613
614/// Read CHERRY_PICK_HEAD when it contains a valid 40-hex OID; `None` if missing, empty, or invalid
615/// (Git ignores malformed `CHERRY_PICK_HEAD` for the "commit $abbrev" line; sequencer still applies).
616pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
617    read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
618}
619
620/// Read REVERT_HEAD when it contains a valid OID; `None` if missing, empty, or invalid.
621pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
622    read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
623}
624
625fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
626    match fs::read_to_string(path) {
627        Ok(content) => {
628            let trimmed = content.trim();
629            if trimmed.is_empty() {
630                Ok(None)
631            } else {
632                Ok(ObjectId::from_hex(trimmed).ok())
633            }
634        }
635        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
636        Err(e) => Err(Error::Io(e)),
637    }
638}
639
640/// Read ORIG_HEAD.
641pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
642    read_single_oid_file(&git_dir.join("ORIG_HEAD"))
643}
644
645/// Read a file that contains a single OID on its first line.
646fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
647    match fs::read_to_string(path) {
648        Ok(content) => {
649            let trimmed = content.trim();
650            if trimmed.is_empty() {
651                Ok(None)
652            } else {
653                Ok(Some(ObjectId::from_hex(trimmed)?))
654            }
655        }
656        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
657        Err(e) => Err(Error::Io(e)),
658    }
659}
660
661/// Check upstream (tracking) information for the current branch.
662///
663/// Returns `(ahead, behind)` counts relative to the tracking branch.
664/// This requires commit walking and is deferred for now.
665///
666/// # Parameters
667///
668/// - `_git_dir` — path to the `.git` directory.
669/// - `_branch` — the local branch name.
670///
671/// # Returns
672///
673/// `None` if no upstream is configured.
674pub fn upstream_tracking(_git_dir: &Path, _branch: &str) -> Result<Option<(usize, usize)>> {
675    // TODO: Implement ahead/behind counting once config + rev-list integration is ready.
676    Ok(None)
677}