1use 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#[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 Branch {
45 refname: String,
47 short_name: String,
49 oid: Option<ObjectId>,
52 },
53 Detached {
55 oid: ObjectId,
57 },
58 Invalid,
60}
61
62impl HeadState {
63 #[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 #[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 #[must_use]
84 pub fn is_unborn(&self) -> bool {
85 matches!(self, Self::Branch { oid: None, .. })
86 }
87
88 #[must_use]
90 pub fn is_detached(&self) -> bool {
91 matches!(self, Self::Detached { .. })
92 }
93}
94
95#[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 Merge,
102 RebaseInteractive,
104 Rebase,
106 CherryPick,
108 Revert,
110 Bisect,
112 Am,
114 RewriteApply,
117}
118
119impl InProgressOperation {
120 #[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#[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 ResolveConflicts,
143 StageResolved,
145 Commit,
147 Abort(InProgressOperation),
149 Continue(InProgressOperation),
151 Skip(InProgressOperation),
153 ContinueWithGit(InProgressOperation),
156}
157
158impl NextStep {
159 #[must_use]
162 pub fn command(&self) -> Option<String> {
163 self.grit_command().or_else(|| self.git_command())
164 }
165
166 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
235#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
236pub struct Operation {
237 pub kind: InProgressOperation,
239 pub next_steps: Vec<NextStep>,
241 pub alternatives: Vec<NextStep>,
243}
244
245#[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#[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#[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#[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#[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#[derive(Debug, Clone)]
417pub struct RepoState {
418 pub head: HeadState,
420 pub in_progress: Vec<InProgressOperation>,
422 pub is_bare: bool,
424}
425
426pub 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
445pub 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 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
497pub(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
514fn 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 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 match ObjectId::from_hex(trimmed) {
598 Ok(oid) => Ok(HeadState::Detached { oid }),
599 Err(_) => Ok(HeadState::Invalid),
600 }
601 }
602}
603
604fn 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
609pub 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 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 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
667pub 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#[derive(Debug, Clone, Default)]
690#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
691pub struct WtStatusState {
692 pub merge_in_progress: bool,
694 pub rebase_interactive_in_progress: bool,
696 pub rebase_in_progress: bool,
698 pub rebase_branch: Option<String>,
700 pub rebase_onto: Option<String>,
702 pub am_in_progress: bool,
704 pub am_empty_patch: bool,
706 pub cherry_pick_in_progress: bool,
708 pub cherry_pick_head_oid: Option<ObjectId>,
710 pub revert_in_progress: bool,
712 pub revert_head_oid: Option<ObjectId>,
714 pub bisect_in_progress: bool,
716 pub bisecting_from: Option<String>,
718 pub detached_from: Option<String>,
720 pub detached_at: bool,
722 pub grit_replay_sequencer: bool,
724 pub grit_replay_kind: Option<InProgressOperation>,
726 pub grit_replay_empty_stop: bool,
728 pub grit_rebase_in_progress: bool,
730 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
751fn 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 } 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 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
905pub 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 } 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
1015pub 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
1041pub 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
1062pub 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
1089pub 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
1107pub 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
1128pub 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#[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
1168pub 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
1187pub 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
1193pub 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
1213pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1215 read_single_oid_file(&git_dir.join("ORIG_HEAD"))
1216}
1217
1218fn 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}