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, PathBuf};
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        // Try to resolve the ref to an OID
180        let oid = resolve_ref(git_dir, &refname)?;
181
182        Ok(HeadState::Branch {
183            refname,
184            short_name,
185            oid,
186        })
187    } else {
188        // Detached HEAD — should be a hex OID
189        match ObjectId::from_hex(trimmed) {
190            Ok(oid) => Ok(HeadState::Detached { oid }),
191            Err(_) => Ok(HeadState::Invalid),
192        }
193    }
194}
195
196/// Resolve a ref name to an OID by reading the refs filesystem.
197///
198/// Follows symbolic refs and packed-refs.
199///
200/// # Parameters
201///
202/// - `git_dir` — path to the `.git` directory.
203/// - `refname` — the full ref name (e.g. `refs/heads/main`).
204///
205/// # Returns
206///
207/// `Ok(Some(oid))` if the ref exists, `Ok(None)` if it doesn't (unborn),
208/// or `Err` on I/O failure.
209fn resolve_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
210    // Dispatch to reftable backend if configured
211    if crate::reftable::is_reftable_repo(git_dir) {
212        match crate::reftable::reftable_resolve_ref(git_dir, refname) {
213            Ok(oid) => return Ok(Some(oid)),
214            Err(_) => return Ok(None),
215        }
216    }
217
218    let ref_path = git_dir.join(refname);
219
220    // Try loose ref first
221    match fs::read_to_string(&ref_path) {
222        Ok(content) => {
223            let trimmed = content.trim();
224            // Follow symbolic ref chains
225            if let Some(target) = trimmed.strip_prefix("ref: ") {
226                return resolve_ref(git_dir, target);
227            }
228            match ObjectId::from_hex(trimmed) {
229                Ok(oid) => Ok(Some(oid)),
230                Err(_) => Ok(None),
231            }
232        }
233        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
234            // Try packed-refs in git_dir
235            if let Some(oid) = resolve_packed_ref(git_dir, refname)? {
236                return Ok(Some(oid));
237            }
238
239            // For worktrees, fall back to the common git directory for
240            // shared refs (branches, tags, etc.).
241            if let Some(common) = common_dir_for(git_dir) {
242                if common != git_dir {
243                    // Try loose ref in common dir
244                    let common_ref = common.join(refname);
245                    match fs::read_to_string(&common_ref) {
246                        Ok(content) => {
247                            let trimmed = content.trim();
248                            if let Some(target) = trimmed.strip_prefix("ref: ") {
249                                return resolve_ref(git_dir, target);
250                            }
251                            if let Ok(oid) = ObjectId::from_hex(trimmed) {
252                                return Ok(Some(oid));
253                            }
254                        }
255                        Err(e2) if e2.kind() == std::io::ErrorKind::NotFound => {}
256                        Err(e2)
257                            if e2.kind() == std::io::ErrorKind::IsADirectory
258                                || e2.kind() == std::io::ErrorKind::NotADirectory
259                                || e2.raw_os_error() == Some(21)
260                                || e2.raw_os_error() == Some(20) => {}
261                        Err(e2) => return Err(Error::Io(e2)),
262                    }
263                    // Try packed-refs in common dir
264                    return resolve_packed_ref(&common, refname);
265                }
266            }
267            Ok(None)
268        }
269        Err(e)
270            if e.kind() == std::io::ErrorKind::IsADirectory
271                || e.kind() == std::io::ErrorKind::NotADirectory
272                || e.raw_os_error() == Some(21)
273                || e.raw_os_error() == Some(20) =>
274        {
275            // Directory/file conflicts in refs (e.g. HEAD points at
276            // refs/heads/outer while refs/heads/outer/inner exists) should be
277            // treated like a missing ref, not a hard I/O failure.
278            Ok(None)
279        }
280        Err(e) => Err(Error::Io(e)),
281    }
282}
283
284/// Determine the common git directory for worktree-aware ref resolution.
285fn common_dir_for(git_dir: &Path) -> Option<PathBuf> {
286    let raw = fs::read_to_string(git_dir.join("commondir")).ok()?;
287    let rel = raw.trim();
288    let path = if Path::new(rel).is_absolute() {
289        PathBuf::from(rel)
290    } else {
291        git_dir.join(rel)
292    };
293    path.canonicalize().ok()
294}
295
296/// Look up a ref in `packed-refs`.
297fn resolve_packed_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
298    let packed_path = git_dir.join("packed-refs");
299    let content = match fs::read_to_string(&packed_path) {
300        Ok(c) => c,
301        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
302        Err(e) => return Err(Error::Io(e)),
303    };
304
305    for line in content.lines() {
306        let line = line.trim();
307        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
308            continue;
309        }
310        // Format: "<hex-oid> <refname>"
311        if let Some((hex, name)) = line.split_once(' ') {
312            if name == refname {
313                if let Ok(oid) = ObjectId::from_hex(hex) {
314                    return Ok(Some(oid));
315                }
316            }
317        }
318    }
319
320    Ok(None)
321}
322
323/// Detect in-progress operations by checking for sentinel files.
324///
325/// # Parameters
326///
327/// - `git_dir` — path to the `.git` directory.
328///
329/// # Returns
330///
331/// A list of detected in-progress operations.
332pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
333    let mut ops = Vec::new();
334
335    if git_dir.join("MERGE_HEAD").exists() {
336        ops.push(InProgressOperation::Merge);
337    }
338
339    // Interactive rebase: rebase-merge/ directory
340    let rebase_merge = git_dir.join("rebase-merge");
341    if rebase_merge.is_dir() {
342        if rebase_merge.join("interactive").exists() {
343            ops.push(InProgressOperation::RebaseInteractive);
344        } else {
345            ops.push(InProgressOperation::Rebase);
346        }
347    }
348
349    // Non-interactive rebase or am: rebase-apply/ directory
350    let rebase_apply = git_dir.join("rebase-apply");
351    if rebase_apply.is_dir() {
352        if rebase_apply.join("applying").exists() {
353            ops.push(InProgressOperation::Am);
354        } else {
355            ops.push(InProgressOperation::Rebase);
356        }
357    }
358
359    if git_dir.join("CHERRY_PICK_HEAD").exists() {
360        ops.push(InProgressOperation::CherryPick);
361    }
362
363    if git_dir.join("REVERT_HEAD").exists() {
364        ops.push(InProgressOperation::Revert);
365    }
366
367    if git_dir.join("BISECT_LOG").exists() {
368        ops.push(InProgressOperation::Bisect);
369    }
370
371    ops
372}
373
374/// Build a complete [`RepoState`] snapshot for a repository.
375///
376/// # Parameters
377///
378/// - `git_dir` — path to the `.git` directory.
379/// - `is_bare` — whether this is a bare repository.
380///
381/// # Errors
382///
383/// Returns [`Error::Io`] on filesystem failures.
384pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
385    let head = resolve_head(git_dir)?;
386    let in_progress = detect_in_progress(git_dir);
387
388    Ok(RepoState {
389        head,
390        in_progress,
391        is_bare,
392    })
393}
394
395/// Read the MERGE_HEAD file and return the OIDs listed.
396///
397/// # Parameters
398///
399/// - `git_dir` — path to the `.git` directory.
400///
401/// # Returns
402///
403/// A vector of merge parent OIDs, or empty if not in a merge.
404pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
405    let path = git_dir.join("MERGE_HEAD");
406    let content = match fs::read_to_string(&path) {
407        Ok(c) => c,
408        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
409        Err(e) => return Err(Error::Io(e)),
410    };
411
412    let mut oids = Vec::new();
413    for line in content.lines() {
414        let trimmed = line.trim();
415        if !trimmed.is_empty() {
416            oids.push(ObjectId::from_hex(trimmed)?);
417        }
418    }
419    Ok(oids)
420}
421
422/// Read the MERGE_MSG file.
423///
424/// # Parameters
425///
426/// - `git_dir` — path to the `.git` directory.
427///
428/// # Returns
429///
430/// The merge message text, or `None` if not in a merge.
431pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
432    let path = git_dir.join("MERGE_MSG");
433    match fs::read_to_string(&path) {
434        Ok(c) => Ok(Some(c)),
435        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
436        Err(e) => Err(Error::Io(e)),
437    }
438}
439
440/// Read CHERRY_PICK_HEAD.
441pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
442    read_single_oid_file(&git_dir.join("CHERRY_PICK_HEAD"))
443}
444
445/// Read REVERT_HEAD.
446pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
447    read_single_oid_file(&git_dir.join("REVERT_HEAD"))
448}
449
450/// Read ORIG_HEAD.
451pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
452    read_single_oid_file(&git_dir.join("ORIG_HEAD"))
453}
454
455/// Read a file that contains a single OID on its first line.
456fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
457    match fs::read_to_string(path) {
458        Ok(content) => {
459            let trimmed = content.trim();
460            if trimmed.is_empty() {
461                Ok(None)
462            } else {
463                Ok(Some(ObjectId::from_hex(trimmed)?))
464            }
465        }
466        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
467        Err(e) => Err(Error::Io(e)),
468    }
469}
470
471/// Check upstream (tracking) information for the current branch.
472///
473/// Returns `(ahead, behind)` counts relative to the tracking branch.
474/// This requires commit walking and is deferred for now.
475///
476/// # Parameters
477///
478/// - `_git_dir` — path to the `.git` directory.
479/// - `_branch` — the local branch name.
480///
481/// # Returns
482///
483/// `None` if no upstream is configured.
484pub fn upstream_tracking(_git_dir: &Path, _branch: &str) -> Result<Option<(usize, usize)>> {
485    // TODO: Implement ahead/behind counting once config + rev-list integration is ready.
486    Ok(None)
487}