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/// Detect in-progress operations by checking for sentinel files.
610///
611/// # Parameters
612///
613/// - `git_dir` — path to the `.git` directory.
614///
615/// # Returns
616///
617/// A list of detected in-progress operations.
618pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
619    let mut ops = Vec::new();
620
621    if git_dir.join("MERGE_HEAD").exists() {
622        ops.push(InProgressOperation::Merge);
623    }
624
625    // Interactive rebase: rebase-merge/ directory
626    let rebase_merge = git_dir.join("rebase-merge");
627    if rebase_merge.is_dir() {
628        if rebase_merge.join("interactive").exists() && !grit_rebase_plan_active(&rebase_merge) {
629            ops.push(InProgressOperation::RebaseInteractive);
630        } else {
631            ops.push(InProgressOperation::Rebase);
632        }
633    }
634
635    // Non-interactive rebase or am: rebase-apply/ directory
636    let rebase_apply = git_dir.join("rebase-apply");
637    if rebase_apply.is_dir() {
638        if rebase_apply.join("applying").exists() {
639            ops.push(InProgressOperation::Am);
640        } else {
641            ops.push(InProgressOperation::Rebase);
642        }
643    }
644
645    if git_dir.join("CHERRY_PICK_HEAD").exists() {
646        ops.push(InProgressOperation::CherryPick);
647    }
648
649    if git_dir.join("REVERT_HEAD").exists() {
650        ops.push(InProgressOperation::Revert);
651    }
652
653    let bisect_log = crate::refs::common_dir(git_dir)
654        .unwrap_or_else(|| git_dir.to_path_buf())
655        .join("BISECT_LOG");
656    if bisect_log.exists() {
657        ops.push(InProgressOperation::Bisect);
658    }
659
660    if crate::history::rewrite_apply_journal_active(git_dir) {
661        ops.push(InProgressOperation::RewriteApply);
662    }
663
664    ops
665}
666
667/// The paused operation that must finish before `HEAD` may move to another branch or commit.
668///
669/// Merge, rebase (grit's or git's), am, cherry-pick and revert all keep state that refers to
670/// the current `HEAD`; moving it would make their continue step commit onto the wrong branch.
671/// A bisect does not block a switch.
672///
673/// # Parameters
674///
675/// - `git_dir` — the (per-worktree) git directory.
676///
677/// # Errors
678///
679/// Returns [`Error::Io`] when an operation sentinel cannot be read.
680pub fn operation_blocking_switch(git_dir: &Path) -> Result<Option<InProgressOperation>> {
681    let state = wt_status_get_state(git_dir, &HeadState::Invalid, false)?;
682    Ok(primary_in_progress_operation(&state).filter(|op| *op != InProgressOperation::Bisect))
683}
684
685/// Snapshot of repository state used by `git status` long-format output (`wt-status.c`).
686///
687/// This mirrors Git's `struct wt_status_state` closely enough for advice lines and
688/// branch headers (merge, rebase, cherry-pick, revert, bisect, am, detached HEAD).
689#[derive(Debug, Clone, Default)]
690#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
691pub struct WtStatusState {
692    /// `MERGE_HEAD` exists (merge or merge+rebase).
693    pub merge_in_progress: bool,
694    /// `.git/rebase-merge/` exists and `interactive` is present.
695    pub rebase_interactive_in_progress: bool,
696    /// Rebase without interactive marker (`rebase-merge` non-interactive or `rebase-apply`).
697    pub rebase_in_progress: bool,
698    /// Display string for the branch being rebased (from `head-name`, may be absent).
699    pub rebase_branch: Option<String>,
700    /// Display string for the rebase onto commit (from `onto`, abbreviated OID or name).
701    pub rebase_onto: Option<String>,
702    /// `rebase-apply/applying` exists.
703    pub am_in_progress: bool,
704    /// Empty patch in `am` session (`rebase-apply/patch` has size 0).
705    pub am_empty_patch: bool,
706    /// `CHERRY_PICK_HEAD` or sequencer pick without head.
707    pub cherry_pick_in_progress: bool,
708    /// Commit being cherry-picked; `None` when only the sequencer records progress.
709    pub cherry_pick_head_oid: Option<ObjectId>,
710    /// `REVERT_HEAD` or sequencer revert without head.
711    pub revert_in_progress: bool,
712    /// Commit being reverted when [`Self::revert_in_progress`] is true.
713    pub revert_head_oid: Option<ObjectId>,
714    /// `BISECT_LOG` exists (checked under common dir).
715    pub bisect_in_progress: bool,
716    /// Label read from `BISECT_LOG` for status output when bisecting.
717    pub bisecting_from: Option<String>,
718    /// Detached HEAD: human label (`wt_status_get_detached_from`).
719    pub detached_from: Option<String>,
720    /// True when `HEAD` OID equals the detached tip OID.
721    pub detached_at: bool,
722    /// Grit wrote `.git/sequencer/grit-opts` for this replay (show pick/revert operation hints).
723    pub grit_replay_sequencer: bool,
724    /// When [`Self::grit_replay_sequencer`] is true, the persisted pick/revert kind from `grit-opts`.
725    pub grit_replay_kind: Option<InProgressOperation>,
726    /// Grit `--empty stop` pause without index conflicts (suggest `--skip`, not `--continue`).
727    pub grit_replay_empty_stop: bool,
728    /// Grit wrote `.git/rebase-merge/grit-plan` for a resumable rebase session.
729    pub grit_rebase_in_progress: bool,
730    /// Grit recorded an incomplete rewrite apply under `.git/grit/rewrite-apply/`.
731    pub rewrite_apply_in_progress: bool,
732}
733
734fn abbrev_oid(oid: &ObjectId) -> String {
735    oid.to_hex()[..7].to_string()
736}
737
738fn read_trimmed_line(path: &Path) -> Option<String> {
739    let s = fs::read_to_string(path).ok()?;
740    let mut line = s.lines().next()?.to_string();
741    while line.ends_with('\n') || line.ends_with('\r') {
742        line.pop();
743    }
744    if line.is_empty() {
745        None
746    } else {
747        Some(line)
748    }
749}
750
751/// Read a single-line ref/OID file like Git `get_branch()` in `wt-status.c`.
752fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
753    let path = git_dir.join(rel);
754    let mut sb = read_trimmed_line(&path)?;
755    if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
756        sb = branch_name.to_string();
757    } else if sb.starts_with("refs/") {
758        // keep full ref for remotes etc.
759    } else if ObjectId::from_hex(&sb).is_ok() {
760        let oid = ObjectId::from_hex(&sb).ok()?;
761        sb = abbrev_oid(&oid);
762    } else if sb == "detached HEAD" {
763        return None;
764    }
765    Some(sb)
766}
767
768fn strip_ref_for_display(full: &str) -> String {
769    if let Some(s) = full.strip_prefix("refs/tags/") {
770        return s.to_string();
771    }
772    if let Some(s) = full.strip_prefix("refs/remotes/") {
773        return s.to_string();
774    }
775    if let Some(s) = full.strip_prefix("refs/heads/") {
776        return s.to_string();
777    }
778    full.to_string()
779}
780
781fn ref_name_exists(git_dir: &Path, refname: &str) -> bool {
782    git_dir.join(refname).exists()
783        || crate::refs::packed_refs_entry_exists(git_dir, refname).unwrap_or(false)
784}
785
786fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
787    if target == "HEAD" {
788        return abbrev_oid(&noid);
789    }
790    if target.starts_with("refs/") {
791        if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
792            if oid == noid {
793                return strip_ref_for_display(target);
794            }
795        }
796    }
797    for candidate in [
798        format!("refs/heads/{target}"),
799        format!("refs/tags/{target}"),
800        format!("refs/remotes/{target}"),
801    ] {
802        if candidate.starts_with("refs/tags/") && ref_name_exists(git_dir, &candidate) {
803            return strip_ref_for_display(&candidate);
804        }
805        if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
806            if oid == noid {
807                return strip_ref_for_display(&candidate);
808            }
809        }
810    }
811    if ObjectId::is_hex_len(target.len()) {
812        if let Ok(oid) = ObjectId::from_hex(target) {
813            if oid == noid {
814                return abbrev_oid(&noid);
815            }
816        }
817    }
818    // `checkout … to <abbrev>` records the object name from the user's input; show that
819    // abbreviation (Git does not substitute a tag name here — see t3203 detached HEAD).
820    if !target.is_empty()
821        && target.chars().all(|c| c.is_ascii_hexdigit())
822        && target.len() <= noid.algo().hex_len()
823        && noid.to_hex().starts_with(target)
824    {
825        return target.to_owned();
826    }
827    abbrev_oid(&noid)
828}
829
830fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
831    let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
832    for entry in entries.iter().rev() {
833        let msg = entry.message.trim();
834        let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
835            continue;
836        };
837        let Some(idx) = rest.rfind(" to ") else {
838            continue;
839        };
840        let target = rest[idx + 4..].trim();
841        let noid = entry.new_oid;
842        let label = dwim_detach_label(git_dir, target, noid);
843        let detached_at = head_oid == noid;
844        return Some((label, detached_at));
845    }
846    None
847}
848
849fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
850    let apply = git_dir.join("rebase-apply");
851    if apply.is_dir() {
852        if apply.join("applying").exists() {
853            state.am_in_progress = true;
854            let patch = apply.join("patch");
855            if let Ok(meta) = patch.metadata() {
856                if meta.len() == 0 {
857                    state.am_empty_patch = true;
858                }
859            }
860        } else {
861            state.rebase_in_progress = true;
862            state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
863            state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
864        }
865        return true;
866    }
867    let merge = git_dir.join("rebase-merge");
868    if merge.is_dir() {
869        let grit_plan = grit_rebase_plan_active(&merge);
870        if merge.join("interactive").exists() && !grit_plan {
871            state.rebase_interactive_in_progress = true;
872        } else {
873            state.rebase_in_progress = true;
874        }
875        state.grit_rebase_in_progress = grit_plan;
876        state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
877        state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
878        return true;
879    }
880    false
881}
882
883fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
884    let path = git_dir.join("sequencer").join("todo");
885    if !path.is_file() {
886        return None;
887    }
888    let content = fs::read_to_string(&path).ok()?;
889    for line in content.lines() {
890        let t = line.trim();
891        if t.is_empty() || t.starts_with('#') {
892            continue;
893        }
894        let mut parts = t.split_whitespace();
895        let cmd = parts.next()?;
896        return match cmd {
897            "pick" | "p" => Some(true),
898            "revert" | "r" => Some(false),
899            _ => None,
900        };
901    }
902    None
903}
904
905/// Fill [`WtStatusState`] the same way Git `wt_status_get_state` does (without sparse checkout %).
906///
907/// `get_detached_from` matches Git's third parameter: when true and `head` is detached, populate
908/// `detached_from` / `detached_at` from the `HEAD` reflog.
909pub fn wt_status_get_state(
910    git_dir: &Path,
911    head: &HeadState,
912    get_detached_from: bool,
913) -> Result<WtStatusState> {
914    let mut state = WtStatusState::default();
915
916    if git_dir.join("MERGE_HEAD").exists() {
917        wt_status_check_rebase(git_dir, &mut state);
918        state.merge_in_progress = true;
919    } else if wt_status_check_rebase(git_dir, &mut state) {
920        // rebase/am state already filled
921    } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
922        state.cherry_pick_in_progress = true;
923        state.cherry_pick_head_oid = Some(oid);
924    }
925
926    let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
927    if bisect_base.join("BISECT_LOG").exists() {
928        state.bisect_in_progress = true;
929        state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
930    }
931
932    if let Some(oid) = read_revert_head(git_dir)? {
933        state.revert_in_progress = true;
934        state.revert_head_oid = Some(oid);
935    }
936
937    if let Some(is_pick) = sequencer_first_replay(git_dir) {
938        if is_pick && !state.cherry_pick_in_progress {
939            state.cherry_pick_in_progress = true;
940            state.cherry_pick_head_oid = None;
941        } else if !is_pick && !state.revert_in_progress {
942            state.revert_in_progress = true;
943            state.revert_head_oid = None;
944        }
945    }
946
947    if get_detached_from {
948        if let HeadState::Detached { oid } = head {
949            if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
950                state.detached_from = Some(label);
951                state.detached_at = at;
952            }
953        }
954    }
955
956    state.rewrite_apply_in_progress = crate::history::rewrite_apply_journal_active(git_dir);
957
958    state.grit_replay_sequencer = git_dir.join("sequencer").join("grit-opts").is_file();
959    if state.grit_replay_sequencer {
960        if let Some((kind, empty_stop)) = read_grit_replay_status_hints(git_dir) {
961            state.grit_replay_kind = Some(kind);
962            state.grit_replay_empty_stop = empty_stop;
963            match kind {
964                InProgressOperation::CherryPick => {
965                    state.revert_in_progress = false;
966                    state.revert_head_oid = None;
967                    if !state.cherry_pick_in_progress {
968                        state.cherry_pick_in_progress = true;
969                    }
970                }
971                InProgressOperation::Revert => {
972                    state.cherry_pick_in_progress = false;
973                    state.cherry_pick_head_oid = None;
974                    if !state.revert_in_progress {
975                        state.revert_in_progress = true;
976                    }
977                }
978                _ => {}
979            }
980        }
981    }
982
983    Ok(state)
984}
985
986fn read_grit_replay_status_hints(git_dir: &Path) -> Option<(InProgressOperation, bool)> {
987    let path = git_dir.join("sequencer").join("grit-opts");
988    let content = fs::read_to_string(&path).ok()?;
989    let mut direction = None;
990    let mut empty = "stop";
991    for line in content.lines() {
992        let line = line.trim();
993        let Some((key, value)) = line.split_once('=') else {
994            continue;
995        };
996        match key.trim() {
997            "direction" => {
998                direction = Some(if value.trim() == "revert" {
999                    InProgressOperation::Revert
1000                } else {
1001                    InProgressOperation::CherryPick
1002                });
1003            }
1004            "empty" => empty = value.trim(),
1005            _ => {}
1006        }
1007    }
1008    let kind = direction?;
1009    let empty_stop = empty == "stop"
1010        && (read_cherry_pick_head(git_dir).ok().flatten().is_some()
1011            || read_revert_head(git_dir).ok().flatten().is_some());
1012    Some((kind, empty_stop))
1013}
1014
1015/// Whether a split commit is in progress during interactive rebase (`wt-status.c` `split_commit_in_progress`).
1016pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
1017    let HeadState::Detached { oid: head_oid } = head else {
1018        return false;
1019    };
1020    let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
1021        return false;
1022    };
1023    let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
1024        return false;
1025    };
1026    let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
1027        return false;
1028    };
1029    let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
1030        return false;
1031    };
1032    if amend_line == orig_line {
1033        head_oid != &amend_oid
1034    } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
1035        cur_orig != orig_head_oid
1036    } else {
1037        false
1038    }
1039}
1040
1041/// Build a complete [`RepoState`] snapshot for a repository.
1042///
1043/// # Parameters
1044///
1045/// - `git_dir` — path to the `.git` directory.
1046/// - `is_bare` — whether this is a bare repository.
1047///
1048/// # Errors
1049///
1050/// Returns [`Error::Io`] on filesystem failures.
1051pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
1052    let head = resolve_head(git_dir)?;
1053    let in_progress = detect_in_progress(git_dir);
1054
1055    Ok(RepoState {
1056        head,
1057        in_progress,
1058        is_bare,
1059    })
1060}
1061
1062/// Read the MERGE_HEAD file and return the OIDs listed.
1063///
1064/// # Parameters
1065///
1066/// - `git_dir` — path to the `.git` directory.
1067///
1068/// # Returns
1069///
1070/// A vector of merge parent OIDs, or empty if not in a merge.
1071pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
1072    let path = git_dir.join("MERGE_HEAD");
1073    let content = match fs::read_to_string(&path) {
1074        Ok(c) => c,
1075        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1076        Err(e) => return Err(Error::Io(e)),
1077    };
1078
1079    let mut oids = Vec::new();
1080    for line in content.lines() {
1081        let trimmed = line.trim();
1082        if !trimmed.is_empty() {
1083            oids.push(ObjectId::from_hex(trimmed)?);
1084        }
1085    }
1086    Ok(oids)
1087}
1088
1089/// Read the MERGE_MSG file.
1090///
1091/// # Parameters
1092///
1093/// - `git_dir` — path to the `.git` directory.
1094///
1095/// # Returns
1096///
1097/// The merge message text, or `None` if not in a merge.
1098pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
1099    let path = git_dir.join("MERGE_MSG");
1100    match fs::read_to_string(&path) {
1101        Ok(c) => Ok(Some(c)),
1102        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1103        Err(e) => Err(Error::Io(e)),
1104    }
1105}
1106
1107/// Remove merge-in-progress sentinel files after a successful merge commit.
1108///
1109/// Prefer [`finish_merge_state`] for merge commits; it also restores autostash and clears rerere.
1110///
1111/// # Parameters
1112///
1113/// - `git_dir` — path to the `.git` directory.
1114///
1115/// # Errors
1116///
1117/// Returns [`Error::Io`] if a present sentinel file cannot be removed.
1118pub fn clear_merge_state(git_dir: &Path) -> Result<()> {
1119    for name in merge_completion_sentinels() {
1120        let path = git_dir.join(name);
1121        if path.exists() {
1122            fs::remove_file(&path).map_err(Error::Io)?;
1123        }
1124    }
1125    Ok(())
1126}
1127
1128/// Remove cherry-pick, revert, and sequencer state without touching merge sentinels.
1129///
1130/// Git's `reset` clears merge state separately; replay abort uses this together with
1131/// [`clear_merge_state`] after restoring `HEAD`.
1132///
1133/// # Parameters
1134///
1135/// - `git_dir` — path to the `.git` directory.
1136///
1137/// # Errors
1138///
1139/// Returns [`Error::Io`] if a sentinel file or the sequencer directory cannot be removed.
1140pub fn clear_replay_state(git_dir: &Path) -> Result<()> {
1141    for name in ["CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_MSG"] {
1142        let path = git_dir.join(name);
1143        if path.exists() {
1144            fs::remove_file(&path).map_err(Error::Io)?;
1145        }
1146    }
1147    let seq = git_dir.join("sequencer");
1148    if seq.is_dir() {
1149        fs::remove_dir_all(&seq).map_err(Error::Io)?;
1150    }
1151    Ok(())
1152}
1153
1154/// Sentinel filenames Git removes when a merge concludes with `git commit`.
1155#[must_use]
1156pub fn merge_completion_sentinels() -> &'static [&'static str] {
1157    &[
1158        "MERGE_HEAD",
1159        "MERGE_MSG",
1160        "MERGE_MODE",
1161        "MERGE_LOG",
1162        "AUTO_MERGE",
1163        "SQUASH_MSG",
1164        "MERGE_AUTOSTASH",
1165    ]
1166}
1167
1168/// Complete merge auxiliary state after a successful merge commit.
1169///
1170/// Applies `MERGE_AUTOSTASH` (when present), clears rerere merge records, then removes merge
1171/// sentinel files via [`clear_merge_state`].
1172///
1173/// # Returns
1174///
1175/// `true` when applying the merge autostash left conflicts (the autostash entry is kept).
1176///
1177/// # Errors
1178///
1179/// Propagates autostash apply, rerere, and I/O failures.
1180pub fn finish_merge_state(repo: &Repository) -> Result<bool> {
1181    let autostash_conflicts = crate::porcelain::stash::apply_merge_autostash(repo)?;
1182    let _ = crate::rerere::finish_merge_rerere(repo)?;
1183    clear_merge_state(repo.git_dir())?;
1184    Ok(autostash_conflicts)
1185}
1186
1187/// Read CHERRY_PICK_HEAD when it contains a valid 40-hex OID; `None` if missing, empty, or invalid
1188/// (Git ignores malformed `CHERRY_PICK_HEAD` for the "commit $abbrev" line; sequencer still applies).
1189pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1190    read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
1191}
1192
1193/// Read REVERT_HEAD when it contains a valid OID; `None` if missing, empty, or invalid.
1194pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1195    read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
1196}
1197
1198fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
1199    match fs::read_to_string(path) {
1200        Ok(content) => {
1201            let trimmed = content.trim();
1202            if trimmed.is_empty() {
1203                Ok(None)
1204            } else {
1205                Ok(ObjectId::from_hex(trimmed).ok())
1206            }
1207        }
1208        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1209        Err(e) => Err(Error::Io(e)),
1210    }
1211}
1212
1213/// Read ORIG_HEAD.
1214pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1215    read_single_oid_file(&git_dir.join("ORIG_HEAD"))
1216}
1217
1218/// Read a file that contains a single OID on its first line.
1219fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
1220    match fs::read_to_string(path) {
1221        Ok(content) => {
1222            let trimmed = content.trim();
1223            if trimmed.is_empty() {
1224                Ok(None)
1225            } else {
1226                Ok(Some(ObjectId::from_hex(trimmed)?))
1227            }
1228        }
1229        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1230        Err(e) => Err(Error::Io(e)),
1231    }
1232}
1233
1234#[cfg(test)]
1235mod merge_sentinel_tests {
1236    #![allow(clippy::unwrap_used)]
1237
1238    use super::{clear_merge_state, merge_completion_sentinels};
1239    use std::fs;
1240    use tempfile::TempDir;
1241
1242    #[test]
1243    fn clear_merge_state_removes_every_completion_sentinel() {
1244        let tmp = TempDir::new().unwrap();
1245        let git = tmp.path().join(".git");
1246        fs::create_dir_all(&git).unwrap();
1247        for name in merge_completion_sentinels() {
1248            fs::write(git.join(name), b"x").unwrap();
1249        }
1250        clear_merge_state(&git).unwrap();
1251        for name in merge_completion_sentinels() {
1252            assert!(!git.join(name).exists(), "expected {name} to be removed");
1253        }
1254    }
1255}
1256
1257#[cfg(test)]
1258mod head_state_tests {
1259    #![allow(clippy::unwrap_used)]
1260
1261    use super::{resolve_head, resolve_head_for_repository, HeadState};
1262    use crate::repo::init_repository;
1263    use crate::RefStorageFormat;
1264    use tempfile::TempDir;
1265
1266    fn unborn_main() -> HeadState {
1267        HeadState::Branch {
1268            refname: "refs/heads/main".to_owned(),
1269            short_name: "main".to_owned(),
1270            oid: None,
1271        }
1272    }
1273
1274    #[test]
1275    fn unborn_branch_resolves_without_oid_on_every_backend() {
1276        for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1277            let tmp = TempDir::new().unwrap();
1278            let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1279            assert_eq!(resolve_head(repo.git_dir()).unwrap(), unborn_main());
1280            assert_eq!(resolve_head_for_repository(&repo).unwrap(), unborn_main());
1281        }
1282    }
1283
1284    #[test]
1285    fn head_symref_to_missing_non_branch_is_invalid() {
1286        let tmp = TempDir::new().unwrap();
1287        let repo =
1288            init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1289        std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1290        assert_eq!(resolve_head(repo.git_dir()).unwrap(), HeadState::Invalid);
1291        assert_eq!(
1292            resolve_head_for_repository(&repo).unwrap(),
1293            HeadState::Invalid
1294        );
1295    }
1296}
1297
1298#[cfg(test)]
1299mod switch_guard_tests {
1300    #![allow(clippy::unwrap_used)]
1301
1302    use super::{operation_blocking_switch, InProgressOperation};
1303    use std::fs;
1304    use tempfile::TempDir;
1305
1306    fn blocking(setup: impl FnOnce(&std::path::Path)) -> Option<InProgressOperation> {
1307        let tmp = TempDir::new().unwrap();
1308        let git = tmp.path().join(".git");
1309        fs::create_dir_all(&git).unwrap();
1310        setup(&git);
1311        operation_blocking_switch(&git).unwrap()
1312    }
1313
1314    #[test]
1315    fn clean_and_bisect_do_not_block_switch() {
1316        assert_eq!(blocking(|_| {}), None);
1317        assert_eq!(
1318            blocking(|git| fs::write(git.join("BISECT_LOG"), "").unwrap()),
1319            None
1320        );
1321    }
1322
1323    #[test]
1324    fn paused_operations_block_switch() {
1325        let oid = "1111111111111111111111111111111111111111\n";
1326        assert_eq!(
1327            blocking(|git| fs::write(git.join("MERGE_HEAD"), oid).unwrap()),
1328            Some(InProgressOperation::Merge)
1329        );
1330        assert_eq!(
1331            blocking(|git| {
1332                fs::create_dir_all(git.join("rebase-merge")).unwrap();
1333                fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1334            }),
1335            Some(InProgressOperation::RebaseInteractive)
1336        );
1337        assert_eq!(
1338            blocking(|git| {
1339                fs::create_dir_all(git.join("rebase-merge")).unwrap();
1340                fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1341                fs::write(git.join("rebase-merge/grit-plan"), "{}").unwrap();
1342            }),
1343            Some(InProgressOperation::Rebase)
1344        );
1345        assert_eq!(
1346            blocking(|git| fs::create_dir_all(git.join("rebase-apply")).unwrap()),
1347            Some(InProgressOperation::Rebase)
1348        );
1349        assert_eq!(
1350            blocking(|git| {
1351                fs::create_dir_all(git.join("rebase-apply")).unwrap();
1352                fs::write(git.join("rebase-apply/applying"), "").unwrap();
1353            }),
1354            Some(InProgressOperation::Am)
1355        );
1356        assert_eq!(
1357            blocking(|git| fs::write(git.join("CHERRY_PICK_HEAD"), oid).unwrap()),
1358            Some(InProgressOperation::CherryPick)
1359        );
1360        assert_eq!(
1361            blocking(|git| fs::write(git.join("REVERT_HEAD"), oid).unwrap()),
1362            Some(InProgressOperation::Revert)
1363        );
1364    }
1365}