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_to_string(&head_path) {
153        Ok(c) => c,
154        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HeadState::Invalid),
155        Err(e) => return Err(Error::Io(e)),
156    };
157
158    let trimmed = content.trim();
159
160    if let Some(refname) = trimmed.strip_prefix("ref: ") {
161        let refname = refname.to_owned();
162        let short_name = refname
163            .strip_prefix("refs/heads/")
164            .unwrap_or(&refname)
165            .to_owned();
166
167        // Try to resolve the ref to an OID
168        let oid = resolve_ref(git_dir, &refname)?;
169
170        Ok(HeadState::Branch {
171            refname,
172            short_name,
173            oid,
174        })
175    } else {
176        // Detached HEAD — should be a hex OID
177        match ObjectId::from_hex(trimmed) {
178            Ok(oid) => Ok(HeadState::Detached { oid }),
179            Err(_) => Ok(HeadState::Invalid),
180        }
181    }
182}
183
184/// Resolve a ref name to an OID by reading the refs filesystem.
185///
186/// Follows symbolic refs and packed-refs.
187///
188/// # Parameters
189///
190/// - `git_dir` — path to the `.git` directory.
191/// - `refname` — the full ref name (e.g. `refs/heads/main`).
192///
193/// # Returns
194///
195/// `Ok(Some(oid))` if the ref exists, `Ok(None)` if it doesn't (unborn),
196/// or `Err` on I/O failure.
197fn resolve_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
198    let ref_path = git_dir.join(refname);
199
200    // Try loose ref first
201    match fs::read_to_string(&ref_path) {
202        Ok(content) => {
203            let trimmed = content.trim();
204            // Follow symbolic ref chains
205            if let Some(target) = trimmed.strip_prefix("ref: ") {
206                return resolve_ref(git_dir, target);
207            }
208            match ObjectId::from_hex(trimmed) {
209                Ok(oid) => Ok(Some(oid)),
210                Err(_) => Ok(None),
211            }
212        }
213        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
214            // Try packed-refs
215            resolve_packed_ref(git_dir, refname)
216        }
217        Err(e) => Err(Error::Io(e)),
218    }
219}
220
221/// Look up a ref in `packed-refs`.
222fn resolve_packed_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
223    let packed_path = git_dir.join("packed-refs");
224    let content = match fs::read_to_string(&packed_path) {
225        Ok(c) => c,
226        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
227        Err(e) => return Err(Error::Io(e)),
228    };
229
230    for line in content.lines() {
231        let line = line.trim();
232        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
233            continue;
234        }
235        // Format: "<hex-oid> <refname>"
236        if let Some((hex, name)) = line.split_once(' ') {
237            if name == refname {
238                if let Ok(oid) = ObjectId::from_hex(hex) {
239                    return Ok(Some(oid));
240                }
241            }
242        }
243    }
244
245    Ok(None)
246}
247
248/// Detect in-progress operations by checking for sentinel files.
249///
250/// # Parameters
251///
252/// - `git_dir` — path to the `.git` directory.
253///
254/// # Returns
255///
256/// A list of detected in-progress operations.
257pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
258    let mut ops = Vec::new();
259
260    if git_dir.join("MERGE_HEAD").exists() {
261        ops.push(InProgressOperation::Merge);
262    }
263
264    // Interactive rebase: rebase-merge/ directory
265    let rebase_merge = git_dir.join("rebase-merge");
266    if rebase_merge.is_dir() {
267        if rebase_merge.join("interactive").exists() {
268            ops.push(InProgressOperation::RebaseInteractive);
269        } else {
270            ops.push(InProgressOperation::Rebase);
271        }
272    }
273
274    // Non-interactive rebase or am: rebase-apply/ directory
275    let rebase_apply = git_dir.join("rebase-apply");
276    if rebase_apply.is_dir() {
277        if rebase_apply.join("applying").exists() {
278            ops.push(InProgressOperation::Am);
279        } else {
280            ops.push(InProgressOperation::Rebase);
281        }
282    }
283
284    if git_dir.join("CHERRY_PICK_HEAD").exists() {
285        ops.push(InProgressOperation::CherryPick);
286    }
287
288    if git_dir.join("REVERT_HEAD").exists() {
289        ops.push(InProgressOperation::Revert);
290    }
291
292    if git_dir.join("BISECT_LOG").exists() {
293        ops.push(InProgressOperation::Bisect);
294    }
295
296    ops
297}
298
299/// Build a complete [`RepoState`] snapshot for a repository.
300///
301/// # Parameters
302///
303/// - `git_dir` — path to the `.git` directory.
304/// - `is_bare` — whether this is a bare repository.
305///
306/// # Errors
307///
308/// Returns [`Error::Io`] on filesystem failures.
309pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
310    let head = resolve_head(git_dir)?;
311    let in_progress = detect_in_progress(git_dir);
312
313    Ok(RepoState {
314        head,
315        in_progress,
316        is_bare,
317    })
318}
319
320/// Read the MERGE_HEAD file and return the OIDs listed.
321///
322/// # Parameters
323///
324/// - `git_dir` — path to the `.git` directory.
325///
326/// # Returns
327///
328/// A vector of merge parent OIDs, or empty if not in a merge.
329pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
330    let path = git_dir.join("MERGE_HEAD");
331    let content = match fs::read_to_string(&path) {
332        Ok(c) => c,
333        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
334        Err(e) => return Err(Error::Io(e)),
335    };
336
337    let mut oids = Vec::new();
338    for line in content.lines() {
339        let trimmed = line.trim();
340        if !trimmed.is_empty() {
341            oids.push(ObjectId::from_hex(trimmed)?);
342        }
343    }
344    Ok(oids)
345}
346
347/// Read the MERGE_MSG file.
348///
349/// # Parameters
350///
351/// - `git_dir` — path to the `.git` directory.
352///
353/// # Returns
354///
355/// The merge message text, or `None` if not in a merge.
356pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
357    let path = git_dir.join("MERGE_MSG");
358    match fs::read_to_string(&path) {
359        Ok(c) => Ok(Some(c)),
360        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
361        Err(e) => Err(Error::Io(e)),
362    }
363}
364
365/// Read CHERRY_PICK_HEAD.
366pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
367    read_single_oid_file(&git_dir.join("CHERRY_PICK_HEAD"))
368}
369
370/// Read REVERT_HEAD.
371pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
372    read_single_oid_file(&git_dir.join("REVERT_HEAD"))
373}
374
375/// Read ORIG_HEAD.
376pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
377    read_single_oid_file(&git_dir.join("ORIG_HEAD"))
378}
379
380/// Read a file that contains a single OID on its first line.
381fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
382    match fs::read_to_string(path) {
383        Ok(content) => {
384            let trimmed = content.trim();
385            if trimmed.is_empty() {
386                Ok(None)
387            } else {
388                Ok(Some(ObjectId::from_hex(trimmed)?))
389            }
390        }
391        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
392        Err(e) => Err(Error::Io(e)),
393    }
394}
395
396/// Check upstream (tracking) information for the current branch.
397///
398/// Returns `(ahead, behind)` counts relative to the tracking branch.
399/// This requires commit walking and is deferred for now.
400///
401/// # Parameters
402///
403/// - `_git_dir` — path to the `.git` directory.
404/// - `_branch` — the local branch name.
405///
406/// # Returns
407///
408/// `None` if no upstream is configured.
409pub fn upstream_tracking(_git_dir: &Path, _branch: &str) -> Result<Option<(usize, usize)>> {
410    // TODO: Implement ahead/behind counting once config + rev-list integration is ready.
411    Ok(None)
412}