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//!
15//! Library guide: [state](https://grit-scm.com/docs/library/repository)
16//!
17//! ```rust
18//! use grit_lib::repo::{InitOptions, Repository};
19//! # fn main() -> grit_lib::error::Result<()> {
20//! let dir = tempfile::tempdir()?;
21//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
22//! use grit_lib::state::resolve_head; let _ = resolve_head(_repo.git_dir())?;
23//!
24//! # Ok(())
25//! # }
26//! ```
27
28use std::fs;
29use std::path::Path;
30
31use crate::check_ref_format::{check_refname_format, RefNameOptions};
32use crate::error::{Error, Result};
33use crate::objects::ObjectId;
34use crate::reflog;
35use crate::refs::store::RefStoreError;
36use crate::repo::Repository;
37
38/// The current state of HEAD.
39#[derive(Debug, Clone, PartialEq, Eq)]
40#[cfg_attr(feature = "serde-models", derive(serde::Serialize, serde::Deserialize))]
41#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
42pub enum HeadState {
43    /// HEAD points to a branch via a symbolic ref (e.g. `ref: refs/heads/main`).
44    Branch {
45        /// The full ref name (e.g. `refs/heads/main`).
46        refname: String,
47        /// The short branch name (e.g. `main`).
48        short_name: String,
49        /// The commit OID that the branch points to, or `None` if the
50        /// branch is unborn (no commits yet).
51        oid: Option<ObjectId>,
52    },
53    /// HEAD is detached — pointing directly at a commit.
54    Detached {
55        /// The commit OID.
56        oid: ObjectId,
57    },
58    /// HEAD is in an invalid or unreadable state.
59    Invalid,
60}
61
62impl HeadState {
63    /// Return the commit OID if HEAD resolves to one.
64    #[must_use]
65    pub fn oid(&self) -> Option<&ObjectId> {
66        match self {
67            Self::Branch { oid, .. } => oid.as_ref(),
68            Self::Detached { oid } => Some(oid),
69            Self::Invalid => None,
70        }
71    }
72
73    /// Return the branch name if HEAD is on a branch.
74    #[must_use]
75    pub fn branch_name(&self) -> Option<&str> {
76        match self {
77            Self::Branch { short_name, .. } => Some(short_name),
78            _ => None,
79        }
80    }
81
82    /// Whether HEAD is on an unborn branch (no commits yet).
83    #[must_use]
84    pub fn is_unborn(&self) -> bool {
85        matches!(self, Self::Branch { oid: None, .. })
86    }
87
88    /// Whether HEAD is detached.
89    #[must_use]
90    pub fn is_detached(&self) -> bool {
91        matches!(self, Self::Detached { .. })
92    }
93
94    /// The root tree of the commit this state names.
95    ///
96    /// Use this when an unreadable `HEAD` should be treated like an unborn branch (for example
97    /// to diff the index against "nothing"); use [`Repository::head_tree`] when it must be an
98    /// error.
99    ///
100    /// # Parameters
101    ///
102    /// - `odb`: the object database holding the commit.
103    ///
104    /// # Returns
105    ///
106    /// `Some(tree)` when [`Self::oid`] names a commit, `None` on an unborn branch or an
107    /// [`HeadState::Invalid`] `HEAD`.
108    ///
109    /// # Errors
110    ///
111    /// Same as [`Odb::commit_tree`](crate::objects::odb::Odb::commit_tree): the commit is missing, is not
112    /// a commit, or has no valid `tree` header.
113    pub fn tree(&self, odb: &crate::objects::odb::Odb) -> Result<Option<ObjectId>> {
114        self.oid().map(|oid| odb.commit_tree(oid)).transpose()
115    }
116}
117
118/// An in-progress operation that the repository is in the middle of.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
121#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
122pub enum InProgressOperation {
123    /// A merge is in progress (`MERGE_HEAD` exists).
124    Merge,
125    /// An interactive rebase is in progress (`rebase-merge/` exists).
126    RebaseInteractive,
127    /// A non-interactive rebase is in progress (`rebase-apply/` exists).
128    Rebase,
129    /// A cherry-pick is in progress (`CHERRY_PICK_HEAD` exists).
130    CherryPick,
131    /// A revert is in progress (`REVERT_HEAD` exists).
132    Revert,
133    /// A bisect is in progress (`BISECT_LOG` exists).
134    Bisect,
135    /// An `am` (apply mailbox) is in progress (`rebase-apply/applying` exists).
136    Am,
137    /// A history rewrite updated refs but has not finished updating the worktree
138    /// (`.git/grit/rewrite-apply/` exists).
139    RewriteApply,
140}
141
142impl InProgressOperation {
143    /// Human-readable description of the operation.
144    #[must_use]
145    pub fn description(&self) -> &'static str {
146        match self {
147            Self::Merge => "merge",
148            Self::RebaseInteractive => "interactive rebase",
149            Self::Rebase => "rebase",
150            Self::CherryPick => "cherry-pick",
151            Self::Revert => "revert",
152            Self::Bisect => "bisect",
153            Self::Am => "am",
154            Self::RewriteApply => "rewrite apply",
155        }
156    }
157}
158
159/// A suggested follow-up action while an operation is paused.
160#[derive(Debug, Clone, PartialEq, Eq)]
161#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
162#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
163pub enum NextStep {
164    /// Edit conflicted files in the worktree (inspect with `grit diff`).
165    ResolveConflicts,
166    /// Stage resolved paths after editing.
167    StageResolved,
168    /// Record the merge or sequencer result.
169    Commit,
170    /// Cancel the in-progress operation.
171    Abort(InProgressOperation),
172    /// Advance a sequencer operation after conflicts are resolved.
173    Continue(InProgressOperation),
174    /// Skip the current sequencer step.
175    Skip(InProgressOperation),
176    /// Advance an operation that `git` started and only `git` can resume (for example a
177    /// rebase without grit's plan).
178    ContinueWithGit(InProgressOperation),
179}
180
181impl NextStep {
182    /// Render the suggested invocation: the [`Self::grit_command`] when grit implements the
183    /// step, otherwise the [`Self::git_command`] for operations only `git` can finish.
184    #[must_use]
185    pub fn command(&self) -> Option<String> {
186        self.grit_command().or_else(|| self.git_command())
187    }
188
189    /// Render the `git` invocation for [`Self::ContinueWithGit`]; `None` for every other step.
190    #[must_use]
191    pub fn git_command(&self) -> Option<String> {
192        let Self::ContinueWithGit(kind) = self else {
193            return None;
194        };
195        let verb = match kind {
196            InProgressOperation::Merge => "merge",
197            InProgressOperation::Rebase | InProgressOperation::RebaseInteractive => "rebase",
198            InProgressOperation::CherryPick => "cherry-pick",
199            InProgressOperation::Revert => "revert",
200            InProgressOperation::Am => "am",
201            InProgressOperation::Bisect | InProgressOperation::RewriteApply => return None,
202        };
203        Some(format!("git {verb} --continue"))
204    }
205
206    /// Render a non-interactive `grit` invocation for this step, when one exists.
207    #[must_use]
208    pub fn grit_command(&self) -> Option<String> {
209        match self {
210            Self::ResolveConflicts => Some("grit diff".to_owned()),
211            Self::StageResolved => Some("grit add .".to_owned()),
212            Self::Commit => Some("grit commit \"message\"".to_owned()),
213            Self::Continue(InProgressOperation::Merge) => Some("grit merge --continue".to_owned()),
214            Self::Abort(InProgressOperation::Merge) => Some("grit merge --abort".to_owned()),
215            Self::Abort(InProgressOperation::CherryPick) => Some("grit pick --abort".to_owned()),
216            Self::Abort(InProgressOperation::Revert) => Some("grit revert --abort".to_owned()),
217            Self::Continue(InProgressOperation::CherryPick) => {
218                Some("grit pick --continue".to_owned())
219            }
220            Self::Continue(InProgressOperation::Revert) => {
221                Some("grit revert --continue".to_owned())
222            }
223            Self::Skip(InProgressOperation::CherryPick) => Some("grit pick --skip".to_owned()),
224            Self::Skip(InProgressOperation::Revert) => Some("grit revert --skip".to_owned()),
225            Self::Continue(InProgressOperation::Rebase) => {
226                Some("grit rebase --continue".to_owned())
227            }
228            Self::Abort(InProgressOperation::Rebase | InProgressOperation::RebaseInteractive) => {
229                Some("grit rebase --abort".to_owned())
230            }
231            Self::Skip(InProgressOperation::Rebase) => Some("grit rebase --skip".to_owned()),
232            Self::Continue(InProgressOperation::RewriteApply) => {
233                Some("grit rebase --continue".to_owned())
234            }
235            Self::Abort(InProgressOperation::RewriteApply) => {
236                Some("grit rebase --abort".to_owned())
237            }
238            Self::ContinueWithGit(_)
239            | Self::Abort(InProgressOperation::Am | InProgressOperation::Bisect)
240            | Self::Continue(
241                InProgressOperation::RebaseInteractive
242                | InProgressOperation::Am
243                | InProgressOperation::Bisect,
244            )
245            | Self::Skip(InProgressOperation::Merge)
246            | Self::Skip(
247                InProgressOperation::RebaseInteractive
248                | InProgressOperation::Am
249                | InProgressOperation::Bisect
250                | InProgressOperation::RewriteApply,
251            ) => None,
252        }
253    }
254}
255
256/// In-progress operation plus the steps grit can suggest today.
257#[derive(Debug, Clone, PartialEq, Eq)]
258#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
259pub struct Operation {
260    /// Which operation is paused.
261    pub kind: InProgressOperation,
262    /// Ordered follow-ups (for example resolve → stage → commit).
263    pub next_steps: Vec<NextStep>,
264    /// Mutually exclusive options (for example abort the merge instead of finishing).
265    pub alternatives: Vec<NextStep>,
266}
267
268/// Map [`WtStatusState`] to the primary paused operation, if any.
269#[must_use]
270pub fn primary_in_progress_operation(state: &WtStatusState) -> Option<InProgressOperation> {
271    if state.merge_in_progress {
272        return Some(InProgressOperation::Merge);
273    }
274    if state.rebase_interactive_in_progress {
275        return Some(InProgressOperation::RebaseInteractive);
276    }
277    if state.rebase_in_progress {
278        return Some(InProgressOperation::Rebase);
279    }
280    if state.rewrite_apply_in_progress {
281        return Some(InProgressOperation::RewriteApply);
282    }
283    if state.am_in_progress {
284        return Some(InProgressOperation::Am);
285    }
286    if state.cherry_pick_in_progress {
287        return Some(InProgressOperation::CherryPick);
288    }
289    if state.revert_in_progress {
290        return Some(InProgressOperation::Revert);
291    }
292    if state.bisect_in_progress {
293        return Some(InProgressOperation::Bisect);
294    }
295    None
296}
297
298/// Next steps when a pick/revert sequencer stopped on an empty commit (`--empty stop`).
299#[must_use]
300pub fn operation_next_steps_empty(kind: InProgressOperation) -> Vec<NextStep> {
301    match kind {
302        InProgressOperation::CherryPick | InProgressOperation::Revert => {
303            vec![NextStep::Skip(kind)]
304        }
305        other => operation_next_steps(other, false),
306    }
307}
308
309/// Ordered next steps for a paused operation given whether unmerged index entries remain.
310#[must_use]
311pub fn operation_next_steps(
312    kind: InProgressOperation,
313    has_unmerged_conflicts: bool,
314) -> Vec<NextStep> {
315    match kind {
316        InProgressOperation::Merge => {
317            if has_unmerged_conflicts {
318                vec![NextStep::ResolveConflicts, NextStep::StageResolved]
319            } else {
320                vec![NextStep::Continue(InProgressOperation::Merge)]
321            }
322        }
323        InProgressOperation::CherryPick | InProgressOperation::Revert => {
324            if has_unmerged_conflicts {
325                vec![
326                    NextStep::ResolveConflicts,
327                    NextStep::StageResolved,
328                    NextStep::Continue(kind),
329                ]
330            } else {
331                vec![NextStep::Continue(kind)]
332            }
333        }
334        InProgressOperation::Rebase => {
335            if has_unmerged_conflicts {
336                vec![
337                    NextStep::ResolveConflicts,
338                    NextStep::StageResolved,
339                    NextStep::Continue(InProgressOperation::Rebase),
340                ]
341            } else {
342                vec![NextStep::Continue(InProgressOperation::Rebase)]
343            }
344        }
345        InProgressOperation::RebaseInteractive
346        | InProgressOperation::Am
347        | InProgressOperation::Bisect => vec![],
348        InProgressOperation::RewriteApply => {
349            vec![NextStep::Continue(InProgressOperation::RewriteApply)]
350        }
351    }
352}
353
354/// Steps that cancel or bypass the ordered [`operation_next_steps`] sequence.
355#[must_use]
356pub fn operation_alternatives(kind: InProgressOperation) -> Vec<NextStep> {
357    match kind {
358        InProgressOperation::Merge => vec![NextStep::Abort(InProgressOperation::Merge)],
359        InProgressOperation::CherryPick => vec![
360            NextStep::Abort(InProgressOperation::CherryPick),
361            NextStep::Skip(InProgressOperation::CherryPick),
362        ],
363        InProgressOperation::Revert => vec![
364            NextStep::Abort(InProgressOperation::Revert),
365            NextStep::Skip(InProgressOperation::Revert),
366        ],
367        InProgressOperation::Rebase => vec![
368            NextStep::Abort(InProgressOperation::Rebase),
369            NextStep::Skip(InProgressOperation::Rebase),
370        ],
371        InProgressOperation::RebaseInteractive
372        | InProgressOperation::Am
373        | InProgressOperation::Bisect => vec![],
374        InProgressOperation::RewriteApply => {
375            vec![NextStep::Abort(InProgressOperation::RewriteApply)]
376        }
377    }
378}
379
380/// [`Operation`] guidance for [`StatusModel`](crate::porcelain::status::StatusModel).
381///
382/// Merge, cherry-pick, revert, and grit rebase sessions expose continue/abort/skip hints when
383/// grit implements the matching commands. A rebase that `git` started suggests
384/// [`NextStep::ContinueWithGit`] (grit can abort it but not resume git's todo list). Other
385/// in-progress states detected via [`WtStatusState`] omit `operation` until grit implements them.
386#[must_use]
387pub fn status_operation(state: &WtStatusState, has_unmerged_conflicts: bool) -> Option<Operation> {
388    let foreign_rebase = !state.merge_in_progress
389        && !state.grit_rebase_in_progress
390        && (state.rebase_in_progress || state.rebase_interactive_in_progress);
391    if foreign_rebase {
392        let kind = if state.rebase_interactive_in_progress {
393            InProgressOperation::RebaseInteractive
394        } else {
395            InProgressOperation::Rebase
396        };
397        let mut next_steps = Vec::new();
398        if has_unmerged_conflicts {
399            next_steps.extend([NextStep::ResolveConflicts, NextStep::StageResolved]);
400        }
401        next_steps.push(NextStep::ContinueWithGit(kind));
402        return Some(Operation {
403            kind,
404            next_steps,
405            alternatives: vec![NextStep::Abort(kind)],
406        });
407    }
408    let kind = if state.merge_in_progress {
409        InProgressOperation::Merge
410    } else if state.grit_rebase_in_progress {
411        InProgressOperation::Rebase
412    } else if let Some(kind) = state.grit_replay_kind {
413        kind
414    } else if state.cherry_pick_in_progress {
415        InProgressOperation::CherryPick
416    } else if state.revert_in_progress {
417        InProgressOperation::Revert
418    } else if state.rewrite_apply_in_progress {
419        InProgressOperation::RewriteApply
420    } else {
421        return None;
422    };
423    let next_steps = if state.grit_replay_empty_stop && !has_unmerged_conflicts {
424        operation_next_steps_empty(kind)
425    } else {
426        operation_next_steps(kind, has_unmerged_conflicts)
427    };
428    Some(Operation {
429        kind,
430        next_steps,
431        alternatives: operation_alternatives(kind),
432    })
433}
434
435/// Full snapshot of a repository's state.
436///
437/// This is the information that porcelain commands like `status` need to
438/// display the repository's current situation.
439#[derive(Debug, Clone)]
440pub struct RepoState {
441    /// Current HEAD state.
442    pub head: HeadState,
443    /// In-progress operations (there can be multiple, e.g. rebase + merge).
444    pub in_progress: Vec<InProgressOperation>,
445    /// Whether the repository is bare.
446    pub is_bare: bool,
447}
448
449/// Resolve HEAD from the given git directory.
450///
451/// Reads `HEAD`, follows symbolic refs, and resolves the final OID.
452///
453/// # Parameters
454///
455/// - `git_dir` — path to the `.git` directory.
456///
457/// # Errors
458///
459/// Returns [`Error::Io`] if files cannot be read.
460pub fn resolve_head(git_dir: &Path) -> Result<HeadState> {
461    let store = crate::refs::store::open_ref_store(git_dir)?;
462    if store.format() == crate::refs::store::RefStorageFormat::Reftable {
463        return resolve_head_from_store(store.as_ref());
464    }
465    resolve_head_impl(git_dir, None)
466}
467
468/// Resolve `HEAD` through the repository's ref store.
469///
470/// Files repositories read the on-disk `HEAD` (including symlinks) and resolve branch
471/// tips through the cached store. Reftable and injected stores are the only source
472/// of `HEAD`; a loose discovery placeholder or stale file cannot override them.
473///
474/// # Errors
475///
476/// Propagates ref store read and resolution failures.
477pub fn resolve_head_for_repository(repo: &crate::repo::Repository) -> Result<HeadState> {
478    if repo.refs().format() == crate::refs::store::RefStorageFormat::Files {
479        return resolve_head_impl(repo.git_dir(), Some(repo));
480    }
481    resolve_head_from_store(repo.refs())
482}
483
484fn resolve_head_from_store(store: &dyn crate::refs::store::RefStore) -> Result<HeadState> {
485    use crate::refs::store::RawRef;
486    match store.read_raw("HEAD")? {
487        None => Ok(HeadState::Invalid),
488        Some(RawRef::Direct(oid)) => Ok(HeadState::Detached { oid }),
489        Some(RawRef::Symbolic(refname)) => {
490            symbolic_head_state(refname, |name| match store.resolve(name) {
491                Ok(oid) => Ok(Some(oid)),
492                Err(Error::RefStore(RefStoreError::NotFound { .. })) => Ok(None),
493                Err(err) => Err(err),
494            })
495        }
496    }
497}
498
499impl Repository {
500    /// The commit `HEAD` points at, whether through a branch or detached.
501    ///
502    /// `HEAD` is read with [`resolve_head_for_repository`], so injected ref stores are honoured.
503    /// Use that function instead when the caller also needs the branch name or must treat an
504    /// unborn branch as a normal state.
505    ///
506    /// # Errors
507    ///
508    /// - [`Error::UnbornHead`] when `HEAD` names a branch with no commits yet.
509    /// - [`Error::InvalidHead`] when `HEAD` is missing or names something that is not a ref.
510    /// - Ref store read failures while resolving `HEAD`.
511    pub fn head_commit(&self) -> Result<ObjectId> {
512        match resolve_head_for_repository(self)? {
513            HeadState::Branch { oid: Some(oid), .. } | HeadState::Detached { oid } => Ok(oid),
514            HeadState::Branch { oid: None, .. } => Err(Error::UnbornHead),
515            HeadState::Invalid => Err(Error::InvalidHead),
516        }
517    }
518
519    /// The root tree of the commit `HEAD` points at, or `None` on an unborn branch.
520    ///
521    /// `HEAD` is read like [`Self::head_commit`]. Use [`HeadState::tree`] on an already
522    /// resolved `HEAD` when an invalid `HEAD` should also mean "no tree".
523    ///
524    /// # Errors
525    ///
526    /// - [`Error::InvalidHead`] when `HEAD` is missing or names something that is not a ref.
527    /// - Ref store read failures while resolving `HEAD`.
528    /// - The errors of [`Odb::commit_tree`](crate::objects::odb::Odb::commit_tree) for the `HEAD` commit.
529    pub fn head_tree(&self) -> Result<Option<ObjectId>> {
530        match resolve_head_for_repository(self)? {
531            HeadState::Invalid => Err(Error::InvalidHead),
532            head => head.tree(self.odb()),
533        }
534    }
535}
536
537/// Resolve the `HEAD` file of the linked worktree administered at `admin_dir`.
538///
539/// Files and reftable repositories resolve it from `admin_dir` alone, as Git does. A
540/// repository with an injected ref store keeps a linked worktree's `HEAD` in `admin_dir`
541/// but its branches in that store, so the branch tip is resolved through `repo`.
542pub(crate) fn resolve_linked_worktree_head(
543    repo: &crate::repo::Repository,
544    admin_dir: &Path,
545) -> HeadState {
546    if repo.refs_on_disk() {
547        resolve_head(admin_dir)
548    } else {
549        resolve_head_impl(admin_dir, Some(repo))
550    }
551    .unwrap_or(HeadState::Invalid)
552}
553
554/// [`HeadState`] for `HEAD` pointing symbolically at `refname`.
555///
556/// A missing `refs/heads/*` target is an unborn branch; any other unresolvable or malformed
557/// target is [`HeadState::Invalid`] (Git's `refs_resolve_ref_unsafe` yields no target there).
558fn symbolic_head_state(
559    refname: String,
560    resolve_named: impl Fn(&str) -> Result<Option<ObjectId>>,
561) -> Result<HeadState> {
562    if check_refname_format(&refname, &RefNameOptions::default()).is_err() {
563        return Ok(HeadState::Invalid);
564    }
565    let oid = match resolve_named(&refname)? {
566        Some(oid) => Some(oid),
567        None if refname.starts_with("refs/heads/") => None,
568        None => return Ok(HeadState::Invalid),
569    };
570    let short_name = refname
571        .strip_prefix("refs/heads/")
572        .unwrap_or(&refname)
573        .to_owned();
574    Ok(HeadState::Branch {
575        refname,
576        short_name,
577        oid,
578    })
579}
580
581fn resolve_head_impl(git_dir: &Path, repo: Option<&crate::repo::Repository>) -> Result<HeadState> {
582    let resolve_named = |refname: &str| -> Result<Option<crate::objects::ObjectId>> {
583        if let Some(r) = repo {
584            return r.references().resolve_if_exists(refname);
585        }
586        match crate::refs::resolve_ref(git_dir, refname) {
587            Ok(oid) => Ok(Some(oid)),
588            Err(Error::RefStore(RefStoreError::NotFound { .. })) => Ok(None),
589            Err(e) => Err(e),
590        }
591    };
592    let read_symbolic = |refname: &str| -> Result<Option<String>> {
593        if let Some(r) = repo {
594            crate::refs::read_symbolic_ref_for_repository(r, refname)
595        } else {
596            crate::refs::read_symbolic_ref(git_dir, refname)
597        }
598    };
599    let head_path = git_dir.join("HEAD");
600    let content = match fs::read_link(&head_path) {
601        Ok(link_target) => {
602            let rendered = link_target.to_string_lossy();
603            if link_target.is_absolute() || rendered.starts_with("refs/") {
604                format!("ref: {rendered}")
605            } else {
606                fs::read_to_string(&head_path).map_err(Error::Io)?
607            }
608        }
609        Err(_) => match fs::read_to_string(&head_path) {
610            Ok(c) => c,
611            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HeadState::Invalid),
612            Err(e) => return Err(Error::Io(e)),
613        },
614    };
615
616    let trimmed = content.trim();
617
618    if let Some(refname) = trimmed.strip_prefix("ref: ") {
619        let refname = if refname == "refs/heads/.invalid" {
620            if let Ok(Some(target)) = read_symbolic("HEAD") {
621                target
622            } else if let Ok(Some(oid)) = resolve_named("HEAD") {
623                // Detached HEAD: Git keeps the `.invalid` stub on disk while the OID lives in reftable.
624                return Ok(HeadState::Detached { oid });
625            } else {
626                match crate::refs::read_ref_file(&git_dir.join("refs").join("heads")) {
627                    Ok(crate::refs::Ref::Symbolic(target)) => target,
628                    _ => refname.to_owned(),
629                }
630            }
631        } else {
632            refname.to_owned()
633        };
634        symbolic_head_state(refname, resolve_named)
635    } else {
636        // Detached HEAD — should be a hex OID
637        match ObjectId::from_hex(trimmed) {
638            Ok(oid) => Ok(HeadState::Detached { oid }),
639            Err(_) => Ok(HeadState::Invalid),
640        }
641    }
642}
643
644/// True when grit owns the in-progress rebase (`.git/rebase-merge/grit-plan`).
645fn grit_rebase_plan_active(rebase_merge: &Path) -> bool {
646    rebase_merge.join("grit-plan").is_file() || rebase_merge.join("grit-exec").is_file()
647}
648
649/// True when grit stopped a resumable rebase on replay conflicts (`stopped-sha` is set).
650pub(crate) fn grit_rebase_conflict_stop_active(git_dir: &Path) -> bool {
651    let merge = git_dir.join("rebase-merge");
652    merge.is_dir() && merge.join("grit-plan").is_file() && merge.join("stopped-sha").is_file()
653}
654
655/// Source commit being replayed when [`grit_rebase_conflict_stop_active`] is true.
656pub(crate) fn read_grit_rebase_stopped_source(
657    git_dir: &Path,
658) -> crate::error::Result<Option<ObjectId>> {
659    if !grit_rebase_conflict_stop_active(git_dir) {
660        return Ok(None);
661    }
662    let raw = fs::read_to_string(git_dir.join("rebase-merge/stopped-sha"))?;
663    let oid =
664        ObjectId::from_hex(raw.trim()).map_err(|e| crate::error::Error::Message(e.to_string()))?;
665    Ok(Some(oid))
666}
667
668/// Detect in-progress operations by checking for sentinel files.
669///
670/// # Parameters
671///
672/// - `git_dir` — path to the `.git` directory.
673///
674/// # Returns
675///
676/// A list of detected in-progress operations.
677pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
678    let mut ops = Vec::new();
679
680    if git_dir.join("MERGE_HEAD").exists() {
681        ops.push(InProgressOperation::Merge);
682    }
683
684    // Interactive rebase: rebase-merge/ directory
685    let rebase_merge = git_dir.join("rebase-merge");
686    if rebase_merge.is_dir() {
687        if rebase_merge.join("interactive").exists() && !grit_rebase_plan_active(&rebase_merge) {
688            ops.push(InProgressOperation::RebaseInteractive);
689        } else {
690            ops.push(InProgressOperation::Rebase);
691        }
692    }
693
694    // Non-interactive rebase or am: rebase-apply/ directory
695    let rebase_apply = git_dir.join("rebase-apply");
696    if rebase_apply.is_dir() {
697        if rebase_apply.join("applying").exists() {
698            ops.push(InProgressOperation::Am);
699        } else {
700            ops.push(InProgressOperation::Rebase);
701        }
702    }
703
704    if git_dir.join("CHERRY_PICK_HEAD").exists() {
705        ops.push(InProgressOperation::CherryPick);
706    }
707
708    if git_dir.join("REVERT_HEAD").exists() {
709        ops.push(InProgressOperation::Revert);
710    }
711
712    let bisect_log = crate::refs::common_dir(git_dir)
713        .unwrap_or_else(|| git_dir.to_path_buf())
714        .join("BISECT_LOG");
715    if bisect_log.exists() {
716        ops.push(InProgressOperation::Bisect);
717    }
718
719    if crate::history::rewrite_apply_journal_active(git_dir) {
720        ops.push(InProgressOperation::RewriteApply);
721    }
722
723    ops
724}
725
726/// Remove in-progress operation sentinels without moving `HEAD`, refs, or the worktree.
727///
728/// Returns the operations that were active before clearing (same order as [`detect_in_progress`]).
729///
730/// # Errors
731///
732/// Returns [`Error::Io`] when sentinel files cannot be removed.
733pub fn clear_in_progress(repo: &Repository) -> Result<Vec<InProgressOperation>> {
734    crate::porcelain::workspace_snapshot::clear_in_progress(repo)
735}
736
737/// The paused operation that must finish before `HEAD` may move to another branch or commit.
738///
739/// Merge, rebase (grit's or git's), am, cherry-pick and revert all keep state that refers to
740/// the current `HEAD`; moving it would make their continue step commit onto the wrong branch.
741/// A bisect does not block a switch.
742///
743/// # Parameters
744///
745/// - `git_dir` — the (per-worktree) git directory.
746///
747/// # Errors
748///
749/// Returns [`Error::Io`] when an operation sentinel cannot be read.
750pub fn operation_blocking_switch(git_dir: &Path) -> Result<Option<InProgressOperation>> {
751    let state = wt_status_get_state(git_dir, &HeadState::Invalid, false)?;
752    Ok(primary_in_progress_operation(&state).filter(|op| *op != InProgressOperation::Bisect))
753}
754
755/// Snapshot of repository state used by `git status` long-format output (`wt-status.c`).
756///
757/// This mirrors Git's `struct wt_status_state` closely enough for advice lines and
758/// branch headers (merge, rebase, cherry-pick, revert, bisect, am, detached HEAD).
759#[derive(Debug, Clone, Default)]
760#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
761pub struct WtStatusState {
762    /// `MERGE_HEAD` exists (merge or merge+rebase).
763    pub merge_in_progress: bool,
764    /// `.git/rebase-merge/` exists and `interactive` is present.
765    pub rebase_interactive_in_progress: bool,
766    /// Rebase without interactive marker (`rebase-merge` non-interactive or `rebase-apply`).
767    pub rebase_in_progress: bool,
768    /// Display string for the branch being rebased (from `head-name`, may be absent).
769    pub rebase_branch: Option<String>,
770    /// Display string for the rebase onto commit (from `onto`, abbreviated OID or name).
771    pub rebase_onto: Option<String>,
772    /// `rebase-apply/applying` exists.
773    pub am_in_progress: bool,
774    /// Empty patch in `am` session (`rebase-apply/patch` has size 0).
775    pub am_empty_patch: bool,
776    /// `CHERRY_PICK_HEAD` or sequencer pick without head.
777    pub cherry_pick_in_progress: bool,
778    /// Commit being cherry-picked; `None` when only the sequencer records progress.
779    pub cherry_pick_head_oid: Option<ObjectId>,
780    /// `REVERT_HEAD` or sequencer revert without head.
781    pub revert_in_progress: bool,
782    /// Commit being reverted when [`Self::revert_in_progress`] is true.
783    pub revert_head_oid: Option<ObjectId>,
784    /// `BISECT_LOG` exists (checked under common dir).
785    pub bisect_in_progress: bool,
786    /// Label read from `BISECT_LOG` for status output when bisecting.
787    pub bisecting_from: Option<String>,
788    /// Detached HEAD: human label (`wt_status_get_detached_from`).
789    pub detached_from: Option<String>,
790    /// True when `HEAD` OID equals the detached tip OID.
791    pub detached_at: bool,
792    /// Grit wrote `.git/sequencer/grit-opts` for this replay (show pick/revert operation hints).
793    pub grit_replay_sequencer: bool,
794    /// When [`Self::grit_replay_sequencer`] is true, the persisted pick/revert kind from `grit-opts`.
795    pub grit_replay_kind: Option<InProgressOperation>,
796    /// Grit `--empty stop` pause without index conflicts (suggest `--skip`, not `--continue`).
797    pub grit_replay_empty_stop: bool,
798    /// Grit wrote `.git/rebase-merge/grit-plan` for a resumable rebase session.
799    pub grit_rebase_in_progress: bool,
800    /// Grit recorded an incomplete rewrite apply under `.git/grit/rewrite-apply/`.
801    pub rewrite_apply_in_progress: bool,
802}
803
804fn read_trimmed_line(path: &Path) -> Option<String> {
805    let s = fs::read_to_string(path).ok()?;
806    let mut line = s.lines().next()?.to_string();
807    while line.ends_with('\n') || line.ends_with('\r') {
808        line.pop();
809    }
810    if line.is_empty() {
811        None
812    } else {
813        Some(line)
814    }
815}
816
817/// Read a single-line ref/OID file like Git `get_branch()` in `wt-status.c`.
818fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
819    let path = git_dir.join(rel);
820    let mut sb = read_trimmed_line(&path)?;
821    if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
822        sb = branch_name.to_string();
823    } else if sb.starts_with("refs/") {
824        // keep full ref for remotes etc.
825    } else if ObjectId::from_hex(&sb).is_ok() {
826        let oid = ObjectId::from_hex(&sb).ok()?;
827        sb = oid.to_short_hex();
828    } else if sb == "detached HEAD" {
829        return None;
830    }
831    Some(sb)
832}
833
834fn strip_ref_for_display(full: &str) -> String {
835    if let Some(s) = full.strip_prefix("refs/tags/") {
836        return s.to_string();
837    }
838    if let Some(s) = full.strip_prefix("refs/remotes/") {
839        return s.to_string();
840    }
841    if let Some(s) = full.strip_prefix("refs/heads/") {
842        return s.to_string();
843    }
844    full.to_string()
845}
846
847fn ref_name_exists(git_dir: &Path, refname: &str) -> bool {
848    git_dir.join(refname).exists()
849        || crate::refs::packed_refs_entry_exists(git_dir, refname).unwrap_or(false)
850}
851
852fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
853    if target == "HEAD" {
854        return noid.to_short_hex();
855    }
856    if target.starts_with("refs/") {
857        if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
858            if oid == noid {
859                return strip_ref_for_display(target);
860            }
861        }
862    }
863    for candidate in [
864        format!("refs/heads/{target}"),
865        format!("refs/tags/{target}"),
866        format!("refs/remotes/{target}"),
867    ] {
868        if candidate.starts_with("refs/tags/") && ref_name_exists(git_dir, &candidate) {
869            return strip_ref_for_display(&candidate);
870        }
871        if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
872            if oid == noid {
873                return strip_ref_for_display(&candidate);
874            }
875        }
876    }
877    if ObjectId::is_hex_len(target.len()) {
878        if let Ok(oid) = ObjectId::from_hex(target) {
879            if oid == noid {
880                return noid.to_short_hex();
881            }
882        }
883    }
884    // `checkout … to <abbrev>` records the object name from the user's input; show that
885    // abbreviation (Git does not substitute a tag name here — see t3203 detached HEAD).
886    if !target.is_empty()
887        && target.chars().all(|c| c.is_ascii_hexdigit())
888        && target.len() <= noid.algo().hex_len()
889        && noid.to_hex().starts_with(target)
890    {
891        return target.to_owned();
892    }
893    noid.to_short_hex()
894}
895
896fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
897    let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
898    for entry in entries.iter().rev() {
899        let msg = entry.message.trim();
900        let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
901            continue;
902        };
903        let Some(idx) = rest.rfind(" to ") else {
904            continue;
905        };
906        let target = rest[idx + 4..].trim();
907        let noid = entry.new_oid;
908        let label = dwim_detach_label(git_dir, target, noid);
909        let detached_at = head_oid == noid;
910        return Some((label, detached_at));
911    }
912    None
913}
914
915fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
916    let apply = git_dir.join("rebase-apply");
917    if apply.is_dir() {
918        if apply.join("applying").exists() {
919            state.am_in_progress = true;
920            let patch = apply.join("patch");
921            if let Ok(meta) = patch.metadata() {
922                if meta.len() == 0 {
923                    state.am_empty_patch = true;
924                }
925            }
926        } else {
927            state.rebase_in_progress = true;
928            state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
929            state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
930        }
931        return true;
932    }
933    let merge = git_dir.join("rebase-merge");
934    if merge.is_dir() {
935        let grit_plan = grit_rebase_plan_active(&merge);
936        if merge.join("interactive").exists() && !grit_plan {
937            state.rebase_interactive_in_progress = true;
938        } else {
939            state.rebase_in_progress = true;
940        }
941        state.grit_rebase_in_progress = grit_plan;
942        state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
943        state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
944        return true;
945    }
946    false
947}
948
949fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
950    let path = git_dir.join("sequencer").join("todo");
951    if !path.is_file() {
952        return None;
953    }
954    let content = fs::read_to_string(&path).ok()?;
955    for line in content.lines() {
956        let t = line.trim();
957        if t.is_empty() || t.starts_with('#') {
958            continue;
959        }
960        let mut parts = t.split_whitespace();
961        let cmd = parts.next()?;
962        return match cmd {
963            "pick" | "p" => Some(true),
964            "revert" | "r" => Some(false),
965            _ => None,
966        };
967    }
968    None
969}
970
971/// Fill [`WtStatusState`] the same way Git `wt_status_get_state` does (without sparse checkout %).
972///
973/// `get_detached_from` matches Git's third parameter: when true and `head` is detached, populate
974/// `detached_from` / `detached_at` from the `HEAD` reflog.
975pub fn wt_status_get_state(
976    git_dir: &Path,
977    head: &HeadState,
978    get_detached_from: bool,
979) -> Result<WtStatusState> {
980    let mut state = WtStatusState::default();
981
982    if git_dir.join("MERGE_HEAD").exists() {
983        wt_status_check_rebase(git_dir, &mut state);
984        state.merge_in_progress = true;
985    } else if wt_status_check_rebase(git_dir, &mut state) {
986        // rebase/am state already filled
987    } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
988        state.cherry_pick_in_progress = true;
989        state.cherry_pick_head_oid = Some(oid);
990    }
991
992    let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
993    if bisect_base.join("BISECT_LOG").exists() {
994        state.bisect_in_progress = true;
995        state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
996    }
997
998    if let Some(oid) = read_revert_head(git_dir)? {
999        state.revert_in_progress = true;
1000        state.revert_head_oid = Some(oid);
1001    }
1002
1003    if let Some(is_pick) = sequencer_first_replay(git_dir) {
1004        if is_pick && !state.cherry_pick_in_progress {
1005            state.cherry_pick_in_progress = true;
1006            state.cherry_pick_head_oid = None;
1007        } else if !is_pick && !state.revert_in_progress {
1008            state.revert_in_progress = true;
1009            state.revert_head_oid = None;
1010        }
1011    }
1012
1013    if get_detached_from {
1014        if let HeadState::Detached { oid } = head {
1015            if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
1016                state.detached_from = Some(label);
1017                state.detached_at = at;
1018            }
1019        }
1020    }
1021
1022    state.rewrite_apply_in_progress = crate::history::rewrite_apply_journal_active(git_dir);
1023
1024    state.grit_replay_sequencer = git_dir.join("sequencer").join("grit-opts").is_file();
1025    if state.grit_replay_sequencer {
1026        if let Some((kind, empty_stop)) = read_grit_replay_status_hints(git_dir) {
1027            state.grit_replay_kind = Some(kind);
1028            state.grit_replay_empty_stop = empty_stop;
1029            match kind {
1030                InProgressOperation::CherryPick => {
1031                    state.revert_in_progress = false;
1032                    state.revert_head_oid = None;
1033                    if !state.cherry_pick_in_progress {
1034                        state.cherry_pick_in_progress = true;
1035                    }
1036                }
1037                InProgressOperation::Revert => {
1038                    state.cherry_pick_in_progress = false;
1039                    state.cherry_pick_head_oid = None;
1040                    if !state.revert_in_progress {
1041                        state.revert_in_progress = true;
1042                    }
1043                }
1044                _ => {}
1045            }
1046        }
1047    }
1048
1049    Ok(state)
1050}
1051
1052fn read_grit_replay_status_hints(git_dir: &Path) -> Option<(InProgressOperation, bool)> {
1053    let path = git_dir.join("sequencer").join("grit-opts");
1054    let content = fs::read_to_string(&path).ok()?;
1055    let mut direction = None;
1056    let mut empty = "stop";
1057    for line in content.lines() {
1058        let line = line.trim();
1059        let Some((key, value)) = line.split_once('=') else {
1060            continue;
1061        };
1062        match key.trim() {
1063            "direction" => {
1064                direction = Some(if value.trim() == "revert" {
1065                    InProgressOperation::Revert
1066                } else {
1067                    InProgressOperation::CherryPick
1068                });
1069            }
1070            "empty" => empty = value.trim(),
1071            _ => {}
1072        }
1073    }
1074    let kind = direction?;
1075    let empty_stop = empty == "stop"
1076        && (read_cherry_pick_head(git_dir).ok().flatten().is_some()
1077            || read_revert_head(git_dir).ok().flatten().is_some());
1078    Some((kind, empty_stop))
1079}
1080
1081/// Whether a split commit is in progress during interactive rebase (`wt-status.c` `split_commit_in_progress`).
1082pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
1083    let HeadState::Detached { oid: head_oid } = head else {
1084        return false;
1085    };
1086    let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
1087        return false;
1088    };
1089    let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
1090        return false;
1091    };
1092    let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
1093        return false;
1094    };
1095    let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
1096        return false;
1097    };
1098    if amend_line == orig_line {
1099        head_oid != &amend_oid
1100    } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
1101        cur_orig != orig_head_oid
1102    } else {
1103        false
1104    }
1105}
1106
1107/// Build a complete [`RepoState`] snapshot for a repository.
1108///
1109/// # Parameters
1110///
1111/// - `git_dir` — path to the `.git` directory.
1112/// - `is_bare` — whether this is a bare repository.
1113///
1114/// # Errors
1115///
1116/// Returns [`Error::Io`] on filesystem failures.
1117pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
1118    let head = resolve_head(git_dir)?;
1119    let in_progress = detect_in_progress(git_dir);
1120
1121    Ok(RepoState {
1122        head,
1123        in_progress,
1124        is_bare,
1125    })
1126}
1127
1128/// Read the MERGE_HEAD file and return the OIDs listed.
1129///
1130/// # Parameters
1131///
1132/// - `git_dir` — path to the `.git` directory.
1133///
1134/// # Returns
1135///
1136/// A vector of merge parent OIDs, or empty if not in a merge.
1137pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
1138    let path = git_dir.join("MERGE_HEAD");
1139    let content = match fs::read_to_string(&path) {
1140        Ok(c) => c,
1141        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1142        Err(e) => return Err(Error::Io(e)),
1143    };
1144
1145    let mut oids = Vec::new();
1146    for line in content.lines() {
1147        let trimmed = line.trim();
1148        if !trimmed.is_empty() {
1149            oids.push(ObjectId::from_hex(trimmed)?);
1150        }
1151    }
1152    Ok(oids)
1153}
1154
1155/// Read the MERGE_MSG file.
1156///
1157/// # Parameters
1158///
1159/// - `git_dir` — path to the `.git` directory.
1160///
1161/// # Returns
1162///
1163/// The merge message text, or `None` if not in a merge.
1164pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
1165    let path = git_dir.join("MERGE_MSG");
1166    match fs::read_to_string(&path) {
1167        Ok(c) => Ok(Some(c)),
1168        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1169        Err(e) => Err(Error::Io(e)),
1170    }
1171}
1172
1173/// Remove merge-in-progress sentinel files after a successful merge commit.
1174///
1175/// Prefer [`finish_merge_state`] for merge commits; it also restores autostash and clears rerere.
1176///
1177/// # Parameters
1178///
1179/// - `git_dir` — path to the `.git` directory.
1180///
1181/// # Errors
1182///
1183/// Returns [`Error::Io`] if a present sentinel file cannot be removed.
1184pub fn clear_merge_state(git_dir: &Path) -> Result<()> {
1185    for name in merge_completion_sentinels() {
1186        let path = git_dir.join(name);
1187        if path.exists() {
1188            fs::remove_file(&path).map_err(Error::Io)?;
1189        }
1190    }
1191    Ok(())
1192}
1193
1194/// Remove cherry-pick, revert, and sequencer state without touching merge sentinels.
1195///
1196/// Git's `reset` clears merge state separately; replay abort uses this together with
1197/// [`clear_merge_state`] after restoring `HEAD`.
1198///
1199/// # Parameters
1200///
1201/// - `git_dir` — path to the `.git` directory.
1202///
1203/// # Errors
1204///
1205/// Returns [`Error::Io`] if a sentinel file or the sequencer directory cannot be removed.
1206pub fn clear_replay_state(git_dir: &Path) -> Result<()> {
1207    for name in ["CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_MSG"] {
1208        let path = git_dir.join(name);
1209        if path.exists() {
1210            fs::remove_file(&path).map_err(Error::Io)?;
1211        }
1212    }
1213    let seq = git_dir.join("sequencer");
1214    if seq.is_dir() {
1215        fs::remove_dir_all(&seq).map_err(Error::Io)?;
1216    }
1217    Ok(())
1218}
1219
1220/// Sentinel filenames Git removes when a merge concludes with `git commit`.
1221#[must_use]
1222pub fn merge_completion_sentinels() -> &'static [&'static str] {
1223    &[
1224        "MERGE_HEAD",
1225        "MERGE_MSG",
1226        "MERGE_MODE",
1227        "MERGE_LOG",
1228        "AUTO_MERGE",
1229        "SQUASH_MSG",
1230        "MERGE_AUTOSTASH",
1231    ]
1232}
1233
1234/// Complete merge auxiliary state after a successful merge commit.
1235///
1236/// Applies `MERGE_AUTOSTASH` (when present), clears rerere merge records, then removes merge
1237/// sentinel files via [`clear_merge_state`].
1238///
1239/// # Returns
1240///
1241/// `true` when applying the merge autostash left conflicts (the autostash entry is kept).
1242///
1243/// # Errors
1244///
1245/// Propagates autostash apply, rerere, and I/O failures.
1246pub fn finish_merge_state(repo: &Repository) -> Result<bool> {
1247    let autostash_conflicts = crate::porcelain::stash::apply_merge_autostash(repo)?;
1248    let _ = crate::rerere::finish_merge_rerere(repo)?;
1249    clear_merge_state(repo.git_dir())?;
1250    Ok(autostash_conflicts)
1251}
1252
1253/// Read CHERRY_PICK_HEAD when it contains a valid 40-hex OID; `None` if missing, empty, or invalid
1254/// (Git ignores malformed `CHERRY_PICK_HEAD` for the "commit $abbrev" line; sequencer still applies).
1255pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1256    read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
1257}
1258
1259/// Read REVERT_HEAD when it contains a valid OID; `None` if missing, empty, or invalid.
1260pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1261    read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
1262}
1263
1264fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
1265    match fs::read_to_string(path) {
1266        Ok(content) => {
1267            let trimmed = content.trim();
1268            if trimmed.is_empty() {
1269                Ok(None)
1270            } else {
1271                Ok(ObjectId::from_hex(trimmed).ok())
1272            }
1273        }
1274        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1275        Err(e) => Err(Error::Io(e)),
1276    }
1277}
1278
1279/// Read the loose `$GIT_DIR/ORIG_HEAD` file.
1280///
1281/// Only sees a files-backend `ORIG_HEAD`; use [`read_orig_head_for_repository`] to read it
1282/// through the repository's ref store (reftable or injected stores).
1283///
1284/// # Errors
1285///
1286/// Returns [`Error::Io`] when the file cannot be read and an object id parse error when it
1287/// does not hold one.
1288pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1289    read_single_oid_file(&git_dir.join("ORIG_HEAD"))
1290}
1291
1292/// Read `ORIG_HEAD` through `repo`'s ref store.
1293///
1294/// # Returns
1295///
1296/// The commit `ORIG_HEAD` resolves to, or `None` when it is not set.
1297///
1298/// # Errors
1299///
1300/// Propagates ref store read and resolution failures.
1301pub fn read_orig_head_for_repository(repo: &Repository) -> Result<Option<ObjectId>> {
1302    repo.references().resolve_if_exists("ORIG_HEAD")
1303}
1304
1305/// Record `oid` as `ORIG_HEAD`, the `HEAD` an operation started from.
1306///
1307/// Reset, merge and rebase call this so `ORIG_HEAD` names the pre-operation commit and
1308/// `reset --hard ORIG_HEAD` undoes them. The value goes through `repo`'s ref store (the loose
1309/// `$GIT_DIR/ORIG_HEAD` file on the files backend, the reftable stack otherwise), overwrites
1310/// any previous value, and writes no reflog, as Git does.
1311///
1312/// # Errors
1313///
1314/// Propagates ref store write failures (for example a held lock).
1315pub fn write_orig_head(repo: &Repository, oid: ObjectId) -> Result<()> {
1316    crate::refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), "ORIG_HEAD", &oid)
1317}
1318
1319/// Read a file that contains a single OID on its first line.
1320fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
1321    match fs::read_to_string(path) {
1322        Ok(content) => {
1323            let trimmed = content.trim();
1324            if trimmed.is_empty() {
1325                Ok(None)
1326            } else {
1327                Ok(Some(ObjectId::from_hex(trimmed)?))
1328            }
1329        }
1330        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1331        Err(e) => Err(Error::Io(e)),
1332    }
1333}
1334
1335#[cfg(test)]
1336mod merge_sentinel_tests {
1337    #![allow(clippy::unwrap_used)]
1338
1339    use super::{clear_merge_state, merge_completion_sentinels};
1340    use std::fs;
1341    use tempfile::TempDir;
1342
1343    #[test]
1344    fn clear_merge_state_removes_every_completion_sentinel() {
1345        let tmp = TempDir::new().unwrap();
1346        let git = tmp.path().join(".git");
1347        fs::create_dir_all(&git).unwrap();
1348        for name in merge_completion_sentinels() {
1349            fs::write(git.join(name), b"x").unwrap();
1350        }
1351        clear_merge_state(&git).unwrap();
1352        for name in merge_completion_sentinels() {
1353            assert!(!git.join(name).exists(), "expected {name} to be removed");
1354        }
1355    }
1356}
1357
1358#[cfg(test)]
1359mod head_state_tests {
1360    #![allow(clippy::unwrap_used)]
1361
1362    use super::{resolve_head, resolve_head_for_repository, HeadState};
1363    use crate::error::Error;
1364    use crate::objects::{ObjectId, ObjectKind};
1365    use crate::repo::init_repository;
1366    use crate::RefStorageFormat;
1367    use tempfile::TempDir;
1368
1369    fn unborn_main() -> HeadState {
1370        HeadState::Branch {
1371            refname: "refs/heads/main".to_owned(),
1372            short_name: "main".to_owned(),
1373            oid: None,
1374        }
1375    }
1376
1377    #[test]
1378    fn unborn_branch_resolves_without_oid_on_every_backend() {
1379        for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1380            let tmp = TempDir::new().unwrap();
1381            let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1382            assert_eq!(resolve_head(repo.git_dir()).unwrap(), unborn_main());
1383            assert_eq!(resolve_head_for_repository(&repo).unwrap(), unborn_main());
1384        }
1385    }
1386
1387    #[test]
1388    fn head_symref_to_missing_non_branch_is_invalid() {
1389        let tmp = TempDir::new().unwrap();
1390        let repo =
1391            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1392        std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1393        assert_eq!(resolve_head(repo.git_dir()).unwrap(), HeadState::Invalid);
1394        assert_eq!(
1395            resolve_head_for_repository(&repo).unwrap(),
1396            HeadState::Invalid
1397        );
1398    }
1399
1400    fn commit_on_head(repo: &crate::repo::Repository) -> (ObjectId, ObjectId) {
1401        let tree = repo.odb().write(ObjectKind::Tree, b"").unwrap();
1402        let body = format!(
1403            "tree {tree}\nauthor A <a@example.com> 0 +0000\ncommitter A <a@example.com> 0 +0000\n\nc\n"
1404        );
1405        let commit = repo
1406            .odb()
1407            .write(ObjectKind::Commit, body.as_bytes())
1408            .unwrap();
1409        std::fs::write(repo.git_dir().join("HEAD"), format!("{commit}\n")).unwrap();
1410        (commit, tree)
1411    }
1412
1413    #[test]
1414    fn head_tree_is_none_on_an_unborn_branch_on_every_backend() {
1415        for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1416            let tmp = TempDir::new().unwrap();
1417            let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1418            assert_eq!(repo.head_tree().unwrap(), None);
1419            assert_eq!(unborn_main().tree(repo.odb()).unwrap(), None);
1420        }
1421    }
1422
1423    #[test]
1424    fn head_tree_is_the_tree_of_the_head_commit() {
1425        let tmp = TempDir::new().unwrap();
1426        let repo =
1427            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1428        let (commit, tree) = commit_on_head(&repo);
1429        assert_eq!(repo.head_tree().unwrap(), Some(tree));
1430        assert_eq!(
1431            HeadState::Detached { oid: commit }
1432                .tree(repo.odb())
1433                .unwrap(),
1434            Some(tree)
1435        );
1436    }
1437
1438    #[test]
1439    fn invalid_head_is_an_error_for_head_tree_and_none_for_head_state_tree() {
1440        let tmp = TempDir::new().unwrap();
1441        let repo =
1442            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1443        std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1444        assert!(matches!(repo.head_tree(), Err(Error::InvalidHead)));
1445        assert_eq!(HeadState::Invalid.tree(repo.odb()).unwrap(), None);
1446    }
1447
1448    #[test]
1449    fn head_on_a_non_commit_is_an_unexpected_object_kind() {
1450        let tmp = TempDir::new().unwrap();
1451        let repo =
1452            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1453        let blob = repo.odb().write(ObjectKind::Blob, b"data").unwrap();
1454        std::fs::write(repo.git_dir().join("HEAD"), format!("{blob}\n")).unwrap();
1455        assert!(matches!(
1456            repo.head_tree(),
1457            Err(Error::UnexpectedObjectKind {
1458                expected: ObjectKind::Commit,
1459                actual: ObjectKind::Blob,
1460                ..
1461            })
1462        ));
1463    }
1464}
1465
1466#[cfg(test)]
1467mod switch_guard_tests {
1468    #![allow(clippy::unwrap_used)]
1469
1470    use super::{operation_blocking_switch, InProgressOperation};
1471    use std::fs;
1472    use tempfile::TempDir;
1473
1474    fn blocking(setup: impl FnOnce(&std::path::Path)) -> Option<InProgressOperation> {
1475        let tmp = TempDir::new().unwrap();
1476        let git = tmp.path().join(".git");
1477        fs::create_dir_all(&git).unwrap();
1478        setup(&git);
1479        operation_blocking_switch(&git).unwrap()
1480    }
1481
1482    #[test]
1483    fn clean_and_bisect_do_not_block_switch() {
1484        assert_eq!(blocking(|_| {}), None);
1485        assert_eq!(
1486            blocking(|git| fs::write(git.join("BISECT_LOG"), "").unwrap()),
1487            None
1488        );
1489    }
1490
1491    #[test]
1492    fn paused_operations_block_switch() {
1493        let oid = "1111111111111111111111111111111111111111\n";
1494        assert_eq!(
1495            blocking(|git| fs::write(git.join("MERGE_HEAD"), oid).unwrap()),
1496            Some(InProgressOperation::Merge)
1497        );
1498        assert_eq!(
1499            blocking(|git| {
1500                fs::create_dir_all(git.join("rebase-merge")).unwrap();
1501                fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1502            }),
1503            Some(InProgressOperation::RebaseInteractive)
1504        );
1505        assert_eq!(
1506            blocking(|git| {
1507                fs::create_dir_all(git.join("rebase-merge")).unwrap();
1508                fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1509                fs::write(git.join("rebase-merge/grit-plan"), "{}").unwrap();
1510            }),
1511            Some(InProgressOperation::Rebase)
1512        );
1513        assert_eq!(
1514            blocking(|git| fs::create_dir_all(git.join("rebase-apply")).unwrap()),
1515            Some(InProgressOperation::Rebase)
1516        );
1517        assert_eq!(
1518            blocking(|git| {
1519                fs::create_dir_all(git.join("rebase-apply")).unwrap();
1520                fs::write(git.join("rebase-apply/applying"), "").unwrap();
1521            }),
1522            Some(InProgressOperation::Am)
1523        );
1524        assert_eq!(
1525            blocking(|git| fs::write(git.join("CHERRY_PICK_HEAD"), oid).unwrap()),
1526            Some(InProgressOperation::CherryPick)
1527        );
1528        assert_eq!(
1529            blocking(|git| fs::write(git.join("REVERT_HEAD"), oid).unwrap()),
1530            Some(InProgressOperation::Revert)
1531        );
1532    }
1533}