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 abbrev_oid(oid: &ObjectId) -> String {
805    oid.to_hex()[..7].to_string()
806}
807
808fn read_trimmed_line(path: &Path) -> Option<String> {
809    let s = fs::read_to_string(path).ok()?;
810    let mut line = s.lines().next()?.to_string();
811    while line.ends_with('\n') || line.ends_with('\r') {
812        line.pop();
813    }
814    if line.is_empty() {
815        None
816    } else {
817        Some(line)
818    }
819}
820
821/// Read a single-line ref/OID file like Git `get_branch()` in `wt-status.c`.
822fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
823    let path = git_dir.join(rel);
824    let mut sb = read_trimmed_line(&path)?;
825    if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
826        sb = branch_name.to_string();
827    } else if sb.starts_with("refs/") {
828        // keep full ref for remotes etc.
829    } else if ObjectId::from_hex(&sb).is_ok() {
830        let oid = ObjectId::from_hex(&sb).ok()?;
831        sb = abbrev_oid(&oid);
832    } else if sb == "detached HEAD" {
833        return None;
834    }
835    Some(sb)
836}
837
838fn strip_ref_for_display(full: &str) -> String {
839    if let Some(s) = full.strip_prefix("refs/tags/") {
840        return s.to_string();
841    }
842    if let Some(s) = full.strip_prefix("refs/remotes/") {
843        return s.to_string();
844    }
845    if let Some(s) = full.strip_prefix("refs/heads/") {
846        return s.to_string();
847    }
848    full.to_string()
849}
850
851fn ref_name_exists(git_dir: &Path, refname: &str) -> bool {
852    git_dir.join(refname).exists()
853        || crate::refs::packed_refs_entry_exists(git_dir, refname).unwrap_or(false)
854}
855
856fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
857    if target == "HEAD" {
858        return abbrev_oid(&noid);
859    }
860    if target.starts_with("refs/") {
861        if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
862            if oid == noid {
863                return strip_ref_for_display(target);
864            }
865        }
866    }
867    for candidate in [
868        format!("refs/heads/{target}"),
869        format!("refs/tags/{target}"),
870        format!("refs/remotes/{target}"),
871    ] {
872        if candidate.starts_with("refs/tags/") && ref_name_exists(git_dir, &candidate) {
873            return strip_ref_for_display(&candidate);
874        }
875        if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
876            if oid == noid {
877                return strip_ref_for_display(&candidate);
878            }
879        }
880    }
881    if ObjectId::is_hex_len(target.len()) {
882        if let Ok(oid) = ObjectId::from_hex(target) {
883            if oid == noid {
884                return abbrev_oid(&noid);
885            }
886        }
887    }
888    // `checkout … to <abbrev>` records the object name from the user's input; show that
889    // abbreviation (Git does not substitute a tag name here — see t3203 detached HEAD).
890    if !target.is_empty()
891        && target.chars().all(|c| c.is_ascii_hexdigit())
892        && target.len() <= noid.algo().hex_len()
893        && noid.to_hex().starts_with(target)
894    {
895        return target.to_owned();
896    }
897    abbrev_oid(&noid)
898}
899
900fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
901    let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
902    for entry in entries.iter().rev() {
903        let msg = entry.message.trim();
904        let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
905            continue;
906        };
907        let Some(idx) = rest.rfind(" to ") else {
908            continue;
909        };
910        let target = rest[idx + 4..].trim();
911        let noid = entry.new_oid;
912        let label = dwim_detach_label(git_dir, target, noid);
913        let detached_at = head_oid == noid;
914        return Some((label, detached_at));
915    }
916    None
917}
918
919fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
920    let apply = git_dir.join("rebase-apply");
921    if apply.is_dir() {
922        if apply.join("applying").exists() {
923            state.am_in_progress = true;
924            let patch = apply.join("patch");
925            if let Ok(meta) = patch.metadata() {
926                if meta.len() == 0 {
927                    state.am_empty_patch = true;
928                }
929            }
930        } else {
931            state.rebase_in_progress = true;
932            state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
933            state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
934        }
935        return true;
936    }
937    let merge = git_dir.join("rebase-merge");
938    if merge.is_dir() {
939        let grit_plan = grit_rebase_plan_active(&merge);
940        if merge.join("interactive").exists() && !grit_plan {
941            state.rebase_interactive_in_progress = true;
942        } else {
943            state.rebase_in_progress = true;
944        }
945        state.grit_rebase_in_progress = grit_plan;
946        state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
947        state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
948        return true;
949    }
950    false
951}
952
953fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
954    let path = git_dir.join("sequencer").join("todo");
955    if !path.is_file() {
956        return None;
957    }
958    let content = fs::read_to_string(&path).ok()?;
959    for line in content.lines() {
960        let t = line.trim();
961        if t.is_empty() || t.starts_with('#') {
962            continue;
963        }
964        let mut parts = t.split_whitespace();
965        let cmd = parts.next()?;
966        return match cmd {
967            "pick" | "p" => Some(true),
968            "revert" | "r" => Some(false),
969            _ => None,
970        };
971    }
972    None
973}
974
975/// Fill [`WtStatusState`] the same way Git `wt_status_get_state` does (without sparse checkout %).
976///
977/// `get_detached_from` matches Git's third parameter: when true and `head` is detached, populate
978/// `detached_from` / `detached_at` from the `HEAD` reflog.
979pub fn wt_status_get_state(
980    git_dir: &Path,
981    head: &HeadState,
982    get_detached_from: bool,
983) -> Result<WtStatusState> {
984    let mut state = WtStatusState::default();
985
986    if git_dir.join("MERGE_HEAD").exists() {
987        wt_status_check_rebase(git_dir, &mut state);
988        state.merge_in_progress = true;
989    } else if wt_status_check_rebase(git_dir, &mut state) {
990        // rebase/am state already filled
991    } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
992        state.cherry_pick_in_progress = true;
993        state.cherry_pick_head_oid = Some(oid);
994    }
995
996    let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
997    if bisect_base.join("BISECT_LOG").exists() {
998        state.bisect_in_progress = true;
999        state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
1000    }
1001
1002    if let Some(oid) = read_revert_head(git_dir)? {
1003        state.revert_in_progress = true;
1004        state.revert_head_oid = Some(oid);
1005    }
1006
1007    if let Some(is_pick) = sequencer_first_replay(git_dir) {
1008        if is_pick && !state.cherry_pick_in_progress {
1009            state.cherry_pick_in_progress = true;
1010            state.cherry_pick_head_oid = None;
1011        } else if !is_pick && !state.revert_in_progress {
1012            state.revert_in_progress = true;
1013            state.revert_head_oid = None;
1014        }
1015    }
1016
1017    if get_detached_from {
1018        if let HeadState::Detached { oid } = head {
1019            if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
1020                state.detached_from = Some(label);
1021                state.detached_at = at;
1022            }
1023        }
1024    }
1025
1026    state.rewrite_apply_in_progress = crate::history::rewrite_apply_journal_active(git_dir);
1027
1028    state.grit_replay_sequencer = git_dir.join("sequencer").join("grit-opts").is_file();
1029    if state.grit_replay_sequencer {
1030        if let Some((kind, empty_stop)) = read_grit_replay_status_hints(git_dir) {
1031            state.grit_replay_kind = Some(kind);
1032            state.grit_replay_empty_stop = empty_stop;
1033            match kind {
1034                InProgressOperation::CherryPick => {
1035                    state.revert_in_progress = false;
1036                    state.revert_head_oid = None;
1037                    if !state.cherry_pick_in_progress {
1038                        state.cherry_pick_in_progress = true;
1039                    }
1040                }
1041                InProgressOperation::Revert => {
1042                    state.cherry_pick_in_progress = false;
1043                    state.cherry_pick_head_oid = None;
1044                    if !state.revert_in_progress {
1045                        state.revert_in_progress = true;
1046                    }
1047                }
1048                _ => {}
1049            }
1050        }
1051    }
1052
1053    Ok(state)
1054}
1055
1056fn read_grit_replay_status_hints(git_dir: &Path) -> Option<(InProgressOperation, bool)> {
1057    let path = git_dir.join("sequencer").join("grit-opts");
1058    let content = fs::read_to_string(&path).ok()?;
1059    let mut direction = None;
1060    let mut empty = "stop";
1061    for line in content.lines() {
1062        let line = line.trim();
1063        let Some((key, value)) = line.split_once('=') else {
1064            continue;
1065        };
1066        match key.trim() {
1067            "direction" => {
1068                direction = Some(if value.trim() == "revert" {
1069                    InProgressOperation::Revert
1070                } else {
1071                    InProgressOperation::CherryPick
1072                });
1073            }
1074            "empty" => empty = value.trim(),
1075            _ => {}
1076        }
1077    }
1078    let kind = direction?;
1079    let empty_stop = empty == "stop"
1080        && (read_cherry_pick_head(git_dir).ok().flatten().is_some()
1081            || read_revert_head(git_dir).ok().flatten().is_some());
1082    Some((kind, empty_stop))
1083}
1084
1085/// Whether a split commit is in progress during interactive rebase (`wt-status.c` `split_commit_in_progress`).
1086pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
1087    let HeadState::Detached { oid: head_oid } = head else {
1088        return false;
1089    };
1090    let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
1091        return false;
1092    };
1093    let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
1094        return false;
1095    };
1096    let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
1097        return false;
1098    };
1099    let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
1100        return false;
1101    };
1102    if amend_line == orig_line {
1103        head_oid != &amend_oid
1104    } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
1105        cur_orig != orig_head_oid
1106    } else {
1107        false
1108    }
1109}
1110
1111/// Build a complete [`RepoState`] snapshot for a repository.
1112///
1113/// # Parameters
1114///
1115/// - `git_dir` — path to the `.git` directory.
1116/// - `is_bare` — whether this is a bare repository.
1117///
1118/// # Errors
1119///
1120/// Returns [`Error::Io`] on filesystem failures.
1121pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
1122    let head = resolve_head(git_dir)?;
1123    let in_progress = detect_in_progress(git_dir);
1124
1125    Ok(RepoState {
1126        head,
1127        in_progress,
1128        is_bare,
1129    })
1130}
1131
1132/// Read the MERGE_HEAD file and return the OIDs listed.
1133///
1134/// # Parameters
1135///
1136/// - `git_dir` — path to the `.git` directory.
1137///
1138/// # Returns
1139///
1140/// A vector of merge parent OIDs, or empty if not in a merge.
1141pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
1142    let path = git_dir.join("MERGE_HEAD");
1143    let content = match fs::read_to_string(&path) {
1144        Ok(c) => c,
1145        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1146        Err(e) => return Err(Error::Io(e)),
1147    };
1148
1149    let mut oids = Vec::new();
1150    for line in content.lines() {
1151        let trimmed = line.trim();
1152        if !trimmed.is_empty() {
1153            oids.push(ObjectId::from_hex(trimmed)?);
1154        }
1155    }
1156    Ok(oids)
1157}
1158
1159/// Read the MERGE_MSG file.
1160///
1161/// # Parameters
1162///
1163/// - `git_dir` — path to the `.git` directory.
1164///
1165/// # Returns
1166///
1167/// The merge message text, or `None` if not in a merge.
1168pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
1169    let path = git_dir.join("MERGE_MSG");
1170    match fs::read_to_string(&path) {
1171        Ok(c) => Ok(Some(c)),
1172        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1173        Err(e) => Err(Error::Io(e)),
1174    }
1175}
1176
1177/// Remove merge-in-progress sentinel files after a successful merge commit.
1178///
1179/// Prefer [`finish_merge_state`] for merge commits; it also restores autostash and clears rerere.
1180///
1181/// # Parameters
1182///
1183/// - `git_dir` — path to the `.git` directory.
1184///
1185/// # Errors
1186///
1187/// Returns [`Error::Io`] if a present sentinel file cannot be removed.
1188pub fn clear_merge_state(git_dir: &Path) -> Result<()> {
1189    for name in merge_completion_sentinels() {
1190        let path = git_dir.join(name);
1191        if path.exists() {
1192            fs::remove_file(&path).map_err(Error::Io)?;
1193        }
1194    }
1195    Ok(())
1196}
1197
1198/// Remove cherry-pick, revert, and sequencer state without touching merge sentinels.
1199///
1200/// Git's `reset` clears merge state separately; replay abort uses this together with
1201/// [`clear_merge_state`] after restoring `HEAD`.
1202///
1203/// # Parameters
1204///
1205/// - `git_dir` — path to the `.git` directory.
1206///
1207/// # Errors
1208///
1209/// Returns [`Error::Io`] if a sentinel file or the sequencer directory cannot be removed.
1210pub fn clear_replay_state(git_dir: &Path) -> Result<()> {
1211    for name in ["CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_MSG"] {
1212        let path = git_dir.join(name);
1213        if path.exists() {
1214            fs::remove_file(&path).map_err(Error::Io)?;
1215        }
1216    }
1217    let seq = git_dir.join("sequencer");
1218    if seq.is_dir() {
1219        fs::remove_dir_all(&seq).map_err(Error::Io)?;
1220    }
1221    Ok(())
1222}
1223
1224/// Sentinel filenames Git removes when a merge concludes with `git commit`.
1225#[must_use]
1226pub fn merge_completion_sentinels() -> &'static [&'static str] {
1227    &[
1228        "MERGE_HEAD",
1229        "MERGE_MSG",
1230        "MERGE_MODE",
1231        "MERGE_LOG",
1232        "AUTO_MERGE",
1233        "SQUASH_MSG",
1234        "MERGE_AUTOSTASH",
1235    ]
1236}
1237
1238/// Complete merge auxiliary state after a successful merge commit.
1239///
1240/// Applies `MERGE_AUTOSTASH` (when present), clears rerere merge records, then removes merge
1241/// sentinel files via [`clear_merge_state`].
1242///
1243/// # Returns
1244///
1245/// `true` when applying the merge autostash left conflicts (the autostash entry is kept).
1246///
1247/// # Errors
1248///
1249/// Propagates autostash apply, rerere, and I/O failures.
1250pub fn finish_merge_state(repo: &Repository) -> Result<bool> {
1251    let autostash_conflicts = crate::porcelain::stash::apply_merge_autostash(repo)?;
1252    let _ = crate::rerere::finish_merge_rerere(repo)?;
1253    clear_merge_state(repo.git_dir())?;
1254    Ok(autostash_conflicts)
1255}
1256
1257/// Read CHERRY_PICK_HEAD when it contains a valid 40-hex OID; `None` if missing, empty, or invalid
1258/// (Git ignores malformed `CHERRY_PICK_HEAD` for the "commit $abbrev" line; sequencer still applies).
1259pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1260    read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
1261}
1262
1263/// Read REVERT_HEAD when it contains a valid OID; `None` if missing, empty, or invalid.
1264pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1265    read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
1266}
1267
1268fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
1269    match fs::read_to_string(path) {
1270        Ok(content) => {
1271            let trimmed = content.trim();
1272            if trimmed.is_empty() {
1273                Ok(None)
1274            } else {
1275                Ok(ObjectId::from_hex(trimmed).ok())
1276            }
1277        }
1278        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1279        Err(e) => Err(Error::Io(e)),
1280    }
1281}
1282
1283/// Read the loose `$GIT_DIR/ORIG_HEAD` file.
1284///
1285/// Only sees a files-backend `ORIG_HEAD`; use [`read_orig_head_for_repository`] to read it
1286/// through the repository's ref store (reftable or injected stores).
1287///
1288/// # Errors
1289///
1290/// Returns [`Error::Io`] when the file cannot be read and an object id parse error when it
1291/// does not hold one.
1292pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1293    read_single_oid_file(&git_dir.join("ORIG_HEAD"))
1294}
1295
1296/// Read `ORIG_HEAD` through `repo`'s ref store.
1297///
1298/// # Returns
1299///
1300/// The commit `ORIG_HEAD` resolves to, or `None` when it is not set.
1301///
1302/// # Errors
1303///
1304/// Propagates ref store read and resolution failures.
1305pub fn read_orig_head_for_repository(repo: &Repository) -> Result<Option<ObjectId>> {
1306    repo.references().resolve_if_exists("ORIG_HEAD")
1307}
1308
1309/// Record `oid` as `ORIG_HEAD`, the `HEAD` an operation started from.
1310///
1311/// Reset, merge and rebase call this so `ORIG_HEAD` names the pre-operation commit and
1312/// `reset --hard ORIG_HEAD` undoes them. The value goes through `repo`'s ref store (the loose
1313/// `$GIT_DIR/ORIG_HEAD` file on the files backend, the reftable stack otherwise), overwrites
1314/// any previous value, and writes no reflog, as Git does.
1315///
1316/// # Errors
1317///
1318/// Propagates ref store write failures (for example a held lock).
1319pub fn write_orig_head(repo: &Repository, oid: ObjectId) -> Result<()> {
1320    crate::refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), "ORIG_HEAD", &oid)
1321}
1322
1323/// Read a file that contains a single OID on its first line.
1324fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
1325    match fs::read_to_string(path) {
1326        Ok(content) => {
1327            let trimmed = content.trim();
1328            if trimmed.is_empty() {
1329                Ok(None)
1330            } else {
1331                Ok(Some(ObjectId::from_hex(trimmed)?))
1332            }
1333        }
1334        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1335        Err(e) => Err(Error::Io(e)),
1336    }
1337}
1338
1339#[cfg(test)]
1340mod merge_sentinel_tests {
1341    #![allow(clippy::unwrap_used)]
1342
1343    use super::{clear_merge_state, merge_completion_sentinels};
1344    use std::fs;
1345    use tempfile::TempDir;
1346
1347    #[test]
1348    fn clear_merge_state_removes_every_completion_sentinel() {
1349        let tmp = TempDir::new().unwrap();
1350        let git = tmp.path().join(".git");
1351        fs::create_dir_all(&git).unwrap();
1352        for name in merge_completion_sentinels() {
1353            fs::write(git.join(name), b"x").unwrap();
1354        }
1355        clear_merge_state(&git).unwrap();
1356        for name in merge_completion_sentinels() {
1357            assert!(!git.join(name).exists(), "expected {name} to be removed");
1358        }
1359    }
1360}
1361
1362#[cfg(test)]
1363mod head_state_tests {
1364    #![allow(clippy::unwrap_used)]
1365
1366    use super::{resolve_head, resolve_head_for_repository, HeadState};
1367    use crate::error::Error;
1368    use crate::objects::{ObjectId, ObjectKind};
1369    use crate::repo::init_repository;
1370    use crate::RefStorageFormat;
1371    use tempfile::TempDir;
1372
1373    fn unborn_main() -> HeadState {
1374        HeadState::Branch {
1375            refname: "refs/heads/main".to_owned(),
1376            short_name: "main".to_owned(),
1377            oid: None,
1378        }
1379    }
1380
1381    #[test]
1382    fn unborn_branch_resolves_without_oid_on_every_backend() {
1383        for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1384            let tmp = TempDir::new().unwrap();
1385            let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1386            assert_eq!(resolve_head(repo.git_dir()).unwrap(), unborn_main());
1387            assert_eq!(resolve_head_for_repository(&repo).unwrap(), unborn_main());
1388        }
1389    }
1390
1391    #[test]
1392    fn head_symref_to_missing_non_branch_is_invalid() {
1393        let tmp = TempDir::new().unwrap();
1394        let repo =
1395            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1396        std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1397        assert_eq!(resolve_head(repo.git_dir()).unwrap(), HeadState::Invalid);
1398        assert_eq!(
1399            resolve_head_for_repository(&repo).unwrap(),
1400            HeadState::Invalid
1401        );
1402    }
1403
1404    fn commit_on_head(repo: &crate::repo::Repository) -> (ObjectId, ObjectId) {
1405        let tree = repo.odb().write(ObjectKind::Tree, b"").unwrap();
1406        let body = format!(
1407            "tree {tree}\nauthor A <a@example.com> 0 +0000\ncommitter A <a@example.com> 0 +0000\n\nc\n"
1408        );
1409        let commit = repo
1410            .odb()
1411            .write(ObjectKind::Commit, body.as_bytes())
1412            .unwrap();
1413        std::fs::write(repo.git_dir().join("HEAD"), format!("{commit}\n")).unwrap();
1414        (commit, tree)
1415    }
1416
1417    #[test]
1418    fn head_tree_is_none_on_an_unborn_branch_on_every_backend() {
1419        for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1420            let tmp = TempDir::new().unwrap();
1421            let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1422            assert_eq!(repo.head_tree().unwrap(), None);
1423            assert_eq!(unborn_main().tree(repo.odb()).unwrap(), None);
1424        }
1425    }
1426
1427    #[test]
1428    fn head_tree_is_the_tree_of_the_head_commit() {
1429        let tmp = TempDir::new().unwrap();
1430        let repo =
1431            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1432        let (commit, tree) = commit_on_head(&repo);
1433        assert_eq!(repo.head_tree().unwrap(), Some(tree));
1434        assert_eq!(
1435            HeadState::Detached { oid: commit }
1436                .tree(repo.odb())
1437                .unwrap(),
1438            Some(tree)
1439        );
1440    }
1441
1442    #[test]
1443    fn invalid_head_is_an_error_for_head_tree_and_none_for_head_state_tree() {
1444        let tmp = TempDir::new().unwrap();
1445        let repo =
1446            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1447        std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1448        assert!(matches!(repo.head_tree(), Err(Error::InvalidHead)));
1449        assert_eq!(HeadState::Invalid.tree(repo.odb()).unwrap(), None);
1450    }
1451
1452    #[test]
1453    fn head_on_a_non_commit_is_an_unexpected_object_kind() {
1454        let tmp = TempDir::new().unwrap();
1455        let repo =
1456            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1457        let blob = repo.odb().write(ObjectKind::Blob, b"data").unwrap();
1458        std::fs::write(repo.git_dir().join("HEAD"), format!("{blob}\n")).unwrap();
1459        assert!(matches!(
1460            repo.head_tree(),
1461            Err(Error::UnexpectedObjectKind {
1462                expected: ObjectKind::Commit,
1463                actual: ObjectKind::Blob,
1464                ..
1465            })
1466        ));
1467    }
1468}
1469
1470#[cfg(test)]
1471mod switch_guard_tests {
1472    #![allow(clippy::unwrap_used)]
1473
1474    use super::{operation_blocking_switch, InProgressOperation};
1475    use std::fs;
1476    use tempfile::TempDir;
1477
1478    fn blocking(setup: impl FnOnce(&std::path::Path)) -> Option<InProgressOperation> {
1479        let tmp = TempDir::new().unwrap();
1480        let git = tmp.path().join(".git");
1481        fs::create_dir_all(&git).unwrap();
1482        setup(&git);
1483        operation_blocking_switch(&git).unwrap()
1484    }
1485
1486    #[test]
1487    fn clean_and_bisect_do_not_block_switch() {
1488        assert_eq!(blocking(|_| {}), None);
1489        assert_eq!(
1490            blocking(|git| fs::write(git.join("BISECT_LOG"), "").unwrap()),
1491            None
1492        );
1493    }
1494
1495    #[test]
1496    fn paused_operations_block_switch() {
1497        let oid = "1111111111111111111111111111111111111111\n";
1498        assert_eq!(
1499            blocking(|git| fs::write(git.join("MERGE_HEAD"), oid).unwrap()),
1500            Some(InProgressOperation::Merge)
1501        );
1502        assert_eq!(
1503            blocking(|git| {
1504                fs::create_dir_all(git.join("rebase-merge")).unwrap();
1505                fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1506            }),
1507            Some(InProgressOperation::RebaseInteractive)
1508        );
1509        assert_eq!(
1510            blocking(|git| {
1511                fs::create_dir_all(git.join("rebase-merge")).unwrap();
1512                fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1513                fs::write(git.join("rebase-merge/grit-plan"), "{}").unwrap();
1514            }),
1515            Some(InProgressOperation::Rebase)
1516        );
1517        assert_eq!(
1518            blocking(|git| fs::create_dir_all(git.join("rebase-apply")).unwrap()),
1519            Some(InProgressOperation::Rebase)
1520        );
1521        assert_eq!(
1522            blocking(|git| {
1523                fs::create_dir_all(git.join("rebase-apply")).unwrap();
1524                fs::write(git.join("rebase-apply/applying"), "").unwrap();
1525            }),
1526            Some(InProgressOperation::Am)
1527        );
1528        assert_eq!(
1529            blocking(|git| fs::write(git.join("CHERRY_PICK_HEAD"), oid).unwrap()),
1530            Some(InProgressOperation::CherryPick)
1531        );
1532        assert_eq!(
1533            blocking(|git| fs::write(git.join("REVERT_HEAD"), oid).unwrap()),
1534            Some(InProgressOperation::Revert)
1535        );
1536    }
1537}