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;
20
21/// The current state of HEAD.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum HeadState {
24    /// HEAD points to a branch via a symbolic ref (e.g. `ref: refs/heads/main`).
25    Branch {
26        /// The full ref name (e.g. `refs/heads/main`).
27        refname: String,
28        /// The short branch name (e.g. `main`).
29        short_name: String,
30        /// The commit OID that the branch points to, or `None` if the
31        /// branch is unborn (no commits yet).
32        oid: Option<ObjectId>,
33    },
34    /// HEAD is detached — pointing directly at a commit.
35    Detached {
36        /// The commit OID.
37        oid: ObjectId,
38    },
39    /// HEAD is in an invalid or unreadable state.
40    Invalid,
41}
42
43impl HeadState {
44    /// Return the commit OID if HEAD resolves to one.
45    #[must_use]
46    pub fn oid(&self) -> Option<&ObjectId> {
47        match self {
48            Self::Branch { oid, .. } => oid.as_ref(),
49            Self::Detached { oid } => Some(oid),
50            Self::Invalid => None,
51        }
52    }
53
54    /// Return the branch name if HEAD is on a branch.
55    #[must_use]
56    pub fn branch_name(&self) -> Option<&str> {
57        match self {
58            Self::Branch { short_name, .. } => Some(short_name),
59            _ => None,
60        }
61    }
62
63    /// Whether HEAD is on an unborn branch (no commits yet).
64    #[must_use]
65    pub fn is_unborn(&self) -> bool {
66        matches!(self, Self::Branch { oid: None, .. })
67    }
68
69    /// Whether HEAD is detached.
70    #[must_use]
71    pub fn is_detached(&self) -> bool {
72        matches!(self, Self::Detached { .. })
73    }
74}
75
76/// An in-progress operation that the repository is in the middle of.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum InProgressOperation {
79    /// A merge is in progress (`MERGE_HEAD` exists).
80    Merge,
81    /// An interactive rebase is in progress (`rebase-merge/` exists).
82    RebaseInteractive,
83    /// A non-interactive rebase is in progress (`rebase-apply/` exists).
84    Rebase,
85    /// A cherry-pick is in progress (`CHERRY_PICK_HEAD` exists).
86    CherryPick,
87    /// A revert is in progress (`REVERT_HEAD` exists).
88    Revert,
89    /// A bisect is in progress (`BISECT_LOG` exists).
90    Bisect,
91    /// An `am` (apply mailbox) is in progress (`rebase-apply/applying` exists).
92    Am,
93}
94
95impl InProgressOperation {
96    /// Human-readable description of the operation.
97    #[must_use]
98    pub fn description(&self) -> &'static str {
99        match self {
100            Self::Merge => "merge",
101            Self::RebaseInteractive => "interactive rebase",
102            Self::Rebase => "rebase",
103            Self::CherryPick => "cherry-pick",
104            Self::Revert => "revert",
105            Self::Bisect => "bisect",
106            Self::Am => "am",
107        }
108    }
109
110    /// Hint text for how to continue or abort.
111    #[must_use]
112    pub fn hint(&self) -> &'static str {
113        match self {
114            Self::Merge => "fix conflicts and run \"git commit\"\n  (use \"git merge --abort\" to abort the merge)",
115            Self::RebaseInteractive => "fix conflicts and then run \"git rebase --continue\"\n  (use \"git rebase --abort\" to abort the rebase)",
116            Self::Rebase => "fix conflicts and then run \"git rebase --continue\"\n  (use \"git rebase --abort\" to abort the rebase)",
117            Self::CherryPick => "fix conflicts and run \"git cherry-pick --continue\"\n  (use \"git cherry-pick --abort\" to abort the cherry-pick)",
118            Self::Revert => "fix conflicts and run \"git revert --continue\"\n  (use \"git revert --abort\" to abort the revert)",
119            Self::Bisect => "use \"git bisect reset\" to get back to the original branch",
120            Self::Am => "fix conflicts and then run \"git am --continue\"\n  (use \"git am --abort\" to abort the am)",
121        }
122    }
123}
124
125/// Full snapshot of a repository's state.
126///
127/// This is the information that porcelain commands like `status` need to
128/// display the repository's current situation.
129#[derive(Debug, Clone)]
130pub struct RepoState {
131    /// Current HEAD state.
132    pub head: HeadState,
133    /// In-progress operations (there can be multiple, e.g. rebase + merge).
134    pub in_progress: Vec<InProgressOperation>,
135    /// Whether the repository is bare.
136    pub is_bare: bool,
137}
138
139/// Resolve HEAD from the given git directory.
140///
141/// Reads `HEAD`, follows symbolic refs, and resolves the final OID.
142///
143/// # Parameters
144///
145/// - `git_dir` — path to the `.git` directory.
146///
147/// # Errors
148///
149/// Returns [`Error::Io`] if files cannot be read.
150pub fn resolve_head(git_dir: &Path) -> Result<HeadState> {
151    let head_path = git_dir.join("HEAD");
152    let content = match fs::read_link(&head_path) {
153        Ok(link_target) => {
154            let rendered = link_target.to_string_lossy();
155            if link_target.is_absolute() {
156                format!("ref: {rendered}")
157            } else if rendered.starts_with("refs/") {
158                format!("ref: {rendered}")
159            } else {
160                fs::read_to_string(&head_path).map_err(Error::Io)?
161            }
162        }
163        Err(_) => match fs::read_to_string(&head_path) {
164            Ok(c) => c,
165            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HeadState::Invalid),
166            Err(e) => return Err(Error::Io(e)),
167        },
168    };
169
170    let trimmed = content.trim();
171
172    if let Some(refname) = trimmed.strip_prefix("ref: ") {
173        let refname = refname.to_owned();
174        let short_name = refname
175            .strip_prefix("refs/heads/")
176            .unwrap_or(&refname)
177            .to_owned();
178
179        // Resolve the branch tip via the shared refs backend (worktrees, packed-refs).
180        // Missing ref => unborn branch (`None`); propagate I/O and other errors.
181        let oid = match crate::refs::resolve_ref(git_dir, &refname) {
182            Ok(oid) => Some(oid),
183            Err(Error::InvalidRef(msg)) if msg.starts_with("ref not found:") => None,
184            Err(e) => return Err(e),
185        };
186
187        Ok(HeadState::Branch {
188            refname,
189            short_name,
190            oid,
191        })
192    } else {
193        // Detached HEAD — should be a hex OID
194        match ObjectId::from_hex(trimmed) {
195            Ok(oid) => Ok(HeadState::Detached { oid }),
196            Err(_) => Ok(HeadState::Invalid),
197        }
198    }
199}
200
201/// Detect in-progress operations by checking for sentinel files.
202///
203/// # Parameters
204///
205/// - `git_dir` — path to the `.git` directory.
206///
207/// # Returns
208///
209/// A list of detected in-progress operations.
210pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
211    let mut ops = Vec::new();
212
213    if git_dir.join("MERGE_HEAD").exists() {
214        ops.push(InProgressOperation::Merge);
215    }
216
217    // Interactive rebase: rebase-merge/ directory
218    let rebase_merge = git_dir.join("rebase-merge");
219    if rebase_merge.is_dir() {
220        if rebase_merge.join("interactive").exists() {
221            ops.push(InProgressOperation::RebaseInteractive);
222        } else {
223            ops.push(InProgressOperation::Rebase);
224        }
225    }
226
227    // Non-interactive rebase or am: rebase-apply/ directory
228    let rebase_apply = git_dir.join("rebase-apply");
229    if rebase_apply.is_dir() {
230        if rebase_apply.join("applying").exists() {
231            ops.push(InProgressOperation::Am);
232        } else {
233            ops.push(InProgressOperation::Rebase);
234        }
235    }
236
237    if git_dir.join("CHERRY_PICK_HEAD").exists() {
238        ops.push(InProgressOperation::CherryPick);
239    }
240
241    if git_dir.join("REVERT_HEAD").exists() {
242        ops.push(InProgressOperation::Revert);
243    }
244
245    let bisect_log = crate::refs::common_dir(git_dir)
246        .unwrap_or_else(|| git_dir.to_path_buf())
247        .join("BISECT_LOG");
248    if bisect_log.exists() {
249        ops.push(InProgressOperation::Bisect);
250    }
251
252    ops
253}
254
255/// Build a complete [`RepoState`] snapshot for a repository.
256///
257/// # Parameters
258///
259/// - `git_dir` — path to the `.git` directory.
260/// - `is_bare` — whether this is a bare repository.
261///
262/// # Errors
263///
264/// Returns [`Error::Io`] on filesystem failures.
265pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
266    let head = resolve_head(git_dir)?;
267    let in_progress = detect_in_progress(git_dir);
268
269    Ok(RepoState {
270        head,
271        in_progress,
272        is_bare,
273    })
274}
275
276/// Read the MERGE_HEAD file and return the OIDs listed.
277///
278/// # Parameters
279///
280/// - `git_dir` — path to the `.git` directory.
281///
282/// # Returns
283///
284/// A vector of merge parent OIDs, or empty if not in a merge.
285pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
286    let path = git_dir.join("MERGE_HEAD");
287    let content = match fs::read_to_string(&path) {
288        Ok(c) => c,
289        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
290        Err(e) => return Err(Error::Io(e)),
291    };
292
293    let mut oids = Vec::new();
294    for line in content.lines() {
295        let trimmed = line.trim();
296        if !trimmed.is_empty() {
297            oids.push(ObjectId::from_hex(trimmed)?);
298        }
299    }
300    Ok(oids)
301}
302
303/// Read the MERGE_MSG file.
304///
305/// # Parameters
306///
307/// - `git_dir` — path to the `.git` directory.
308///
309/// # Returns
310///
311/// The merge message text, or `None` if not in a merge.
312pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
313    let path = git_dir.join("MERGE_MSG");
314    match fs::read_to_string(&path) {
315        Ok(c) => Ok(Some(c)),
316        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
317        Err(e) => Err(Error::Io(e)),
318    }
319}
320
321/// Read CHERRY_PICK_HEAD.
322pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
323    read_single_oid_file(&git_dir.join("CHERRY_PICK_HEAD"))
324}
325
326/// Read REVERT_HEAD.
327pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
328    read_single_oid_file(&git_dir.join("REVERT_HEAD"))
329}
330
331/// Read ORIG_HEAD.
332pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
333    read_single_oid_file(&git_dir.join("ORIG_HEAD"))
334}
335
336/// Read a file that contains a single OID on its first line.
337fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
338    match fs::read_to_string(path) {
339        Ok(content) => {
340            let trimmed = content.trim();
341            if trimmed.is_empty() {
342                Ok(None)
343            } else {
344                Ok(Some(ObjectId::from_hex(trimmed)?))
345            }
346        }
347        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
348        Err(e) => Err(Error::Io(e)),
349    }
350}
351
352/// Check upstream (tracking) information for the current branch.
353///
354/// Returns `(ahead, behind)` counts relative to the tracking branch.
355/// This requires commit walking and is deferred for now.
356///
357/// # Parameters
358///
359/// - `_git_dir` — path to the `.git` directory.
360/// - `_branch` — the local branch name.
361///
362/// # Returns
363///
364/// `None` if no upstream is configured.
365pub fn upstream_tracking(_git_dir: &Path, _branch: &str) -> Result<Option<(usize, usize)>> {
366    // TODO: Implement ahead/behind counting once config + rev-list integration is ready.
367    Ok(None)
368}