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