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