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