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(crate) fn grit_rebase_conflict_stop_active(git_dir: &Path) -> bool {
611 let merge = git_dir.join("rebase-merge");
612 merge.is_dir() && merge.join("grit-plan").is_file() && merge.join("stopped-sha").is_file()
613}
614
615pub(crate) fn read_grit_rebase_stopped_source(
617 git_dir: &Path,
618) -> crate::error::Result<Option<ObjectId>> {
619 if !grit_rebase_conflict_stop_active(git_dir) {
620 return Ok(None);
621 }
622 let raw = fs::read_to_string(git_dir.join("rebase-merge/stopped-sha"))?;
623 let oid =
624 ObjectId::from_hex(raw.trim()).map_err(|e| crate::error::Error::Message(e.to_string()))?;
625 Ok(Some(oid))
626}
627
628pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
638 let mut ops = Vec::new();
639
640 if git_dir.join("MERGE_HEAD").exists() {
641 ops.push(InProgressOperation::Merge);
642 }
643
644 let rebase_merge = git_dir.join("rebase-merge");
646 if rebase_merge.is_dir() {
647 if rebase_merge.join("interactive").exists() && !grit_rebase_plan_active(&rebase_merge) {
648 ops.push(InProgressOperation::RebaseInteractive);
649 } else {
650 ops.push(InProgressOperation::Rebase);
651 }
652 }
653
654 let rebase_apply = git_dir.join("rebase-apply");
656 if rebase_apply.is_dir() {
657 if rebase_apply.join("applying").exists() {
658 ops.push(InProgressOperation::Am);
659 } else {
660 ops.push(InProgressOperation::Rebase);
661 }
662 }
663
664 if git_dir.join("CHERRY_PICK_HEAD").exists() {
665 ops.push(InProgressOperation::CherryPick);
666 }
667
668 if git_dir.join("REVERT_HEAD").exists() {
669 ops.push(InProgressOperation::Revert);
670 }
671
672 let bisect_log = crate::refs::common_dir(git_dir)
673 .unwrap_or_else(|| git_dir.to_path_buf())
674 .join("BISECT_LOG");
675 if bisect_log.exists() {
676 ops.push(InProgressOperation::Bisect);
677 }
678
679 if crate::history::rewrite_apply_journal_active(git_dir) {
680 ops.push(InProgressOperation::RewriteApply);
681 }
682
683 ops
684}
685
686pub fn clear_in_progress(repo: &Repository) -> Result<Vec<InProgressOperation>> {
694 crate::porcelain::workspace_snapshot::clear_in_progress(repo)
695}
696
697pub fn operation_blocking_switch(git_dir: &Path) -> Result<Option<InProgressOperation>> {
711 let state = wt_status_get_state(git_dir, &HeadState::Invalid, false)?;
712 Ok(primary_in_progress_operation(&state).filter(|op| *op != InProgressOperation::Bisect))
713}
714
715#[derive(Debug, Clone, Default)]
720#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
721pub struct WtStatusState {
722 pub merge_in_progress: bool,
724 pub rebase_interactive_in_progress: bool,
726 pub rebase_in_progress: bool,
728 pub rebase_branch: Option<String>,
730 pub rebase_onto: Option<String>,
732 pub am_in_progress: bool,
734 pub am_empty_patch: bool,
736 pub cherry_pick_in_progress: bool,
738 pub cherry_pick_head_oid: Option<ObjectId>,
740 pub revert_in_progress: bool,
742 pub revert_head_oid: Option<ObjectId>,
744 pub bisect_in_progress: bool,
746 pub bisecting_from: Option<String>,
748 pub detached_from: Option<String>,
750 pub detached_at: bool,
752 pub grit_replay_sequencer: bool,
754 pub grit_replay_kind: Option<InProgressOperation>,
756 pub grit_replay_empty_stop: bool,
758 pub grit_rebase_in_progress: bool,
760 pub rewrite_apply_in_progress: bool,
762}
763
764fn abbrev_oid(oid: &ObjectId) -> String {
765 oid.to_hex()[..7].to_string()
766}
767
768fn read_trimmed_line(path: &Path) -> Option<String> {
769 let s = fs::read_to_string(path).ok()?;
770 let mut line = s.lines().next()?.to_string();
771 while line.ends_with('\n') || line.ends_with('\r') {
772 line.pop();
773 }
774 if line.is_empty() {
775 None
776 } else {
777 Some(line)
778 }
779}
780
781fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
783 let path = git_dir.join(rel);
784 let mut sb = read_trimmed_line(&path)?;
785 if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
786 sb = branch_name.to_string();
787 } else if sb.starts_with("refs/") {
788 } else if ObjectId::from_hex(&sb).is_ok() {
790 let oid = ObjectId::from_hex(&sb).ok()?;
791 sb = abbrev_oid(&oid);
792 } else if sb == "detached HEAD" {
793 return None;
794 }
795 Some(sb)
796}
797
798fn strip_ref_for_display(full: &str) -> String {
799 if let Some(s) = full.strip_prefix("refs/tags/") {
800 return s.to_string();
801 }
802 if let Some(s) = full.strip_prefix("refs/remotes/") {
803 return s.to_string();
804 }
805 if let Some(s) = full.strip_prefix("refs/heads/") {
806 return s.to_string();
807 }
808 full.to_string()
809}
810
811fn ref_name_exists(git_dir: &Path, refname: &str) -> bool {
812 git_dir.join(refname).exists()
813 || crate::refs::packed_refs_entry_exists(git_dir, refname).unwrap_or(false)
814}
815
816fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
817 if target == "HEAD" {
818 return abbrev_oid(&noid);
819 }
820 if target.starts_with("refs/") {
821 if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
822 if oid == noid {
823 return strip_ref_for_display(target);
824 }
825 }
826 }
827 for candidate in [
828 format!("refs/heads/{target}"),
829 format!("refs/tags/{target}"),
830 format!("refs/remotes/{target}"),
831 ] {
832 if candidate.starts_with("refs/tags/") && ref_name_exists(git_dir, &candidate) {
833 return strip_ref_for_display(&candidate);
834 }
835 if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
836 if oid == noid {
837 return strip_ref_for_display(&candidate);
838 }
839 }
840 }
841 if ObjectId::is_hex_len(target.len()) {
842 if let Ok(oid) = ObjectId::from_hex(target) {
843 if oid == noid {
844 return abbrev_oid(&noid);
845 }
846 }
847 }
848 if !target.is_empty()
851 && target.chars().all(|c| c.is_ascii_hexdigit())
852 && target.len() <= noid.algo().hex_len()
853 && noid.to_hex().starts_with(target)
854 {
855 return target.to_owned();
856 }
857 abbrev_oid(&noid)
858}
859
860fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
861 let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
862 for entry in entries.iter().rev() {
863 let msg = entry.message.trim();
864 let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
865 continue;
866 };
867 let Some(idx) = rest.rfind(" to ") else {
868 continue;
869 };
870 let target = rest[idx + 4..].trim();
871 let noid = entry.new_oid;
872 let label = dwim_detach_label(git_dir, target, noid);
873 let detached_at = head_oid == noid;
874 return Some((label, detached_at));
875 }
876 None
877}
878
879fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
880 let apply = git_dir.join("rebase-apply");
881 if apply.is_dir() {
882 if apply.join("applying").exists() {
883 state.am_in_progress = true;
884 let patch = apply.join("patch");
885 if let Ok(meta) = patch.metadata() {
886 if meta.len() == 0 {
887 state.am_empty_patch = true;
888 }
889 }
890 } else {
891 state.rebase_in_progress = true;
892 state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
893 state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
894 }
895 return true;
896 }
897 let merge = git_dir.join("rebase-merge");
898 if merge.is_dir() {
899 let grit_plan = grit_rebase_plan_active(&merge);
900 if merge.join("interactive").exists() && !grit_plan {
901 state.rebase_interactive_in_progress = true;
902 } else {
903 state.rebase_in_progress = true;
904 }
905 state.grit_rebase_in_progress = grit_plan;
906 state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
907 state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
908 return true;
909 }
910 false
911}
912
913fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
914 let path = git_dir.join("sequencer").join("todo");
915 if !path.is_file() {
916 return None;
917 }
918 let content = fs::read_to_string(&path).ok()?;
919 for line in content.lines() {
920 let t = line.trim();
921 if t.is_empty() || t.starts_with('#') {
922 continue;
923 }
924 let mut parts = t.split_whitespace();
925 let cmd = parts.next()?;
926 return match cmd {
927 "pick" | "p" => Some(true),
928 "revert" | "r" => Some(false),
929 _ => None,
930 };
931 }
932 None
933}
934
935pub fn wt_status_get_state(
940 git_dir: &Path,
941 head: &HeadState,
942 get_detached_from: bool,
943) -> Result<WtStatusState> {
944 let mut state = WtStatusState::default();
945
946 if git_dir.join("MERGE_HEAD").exists() {
947 wt_status_check_rebase(git_dir, &mut state);
948 state.merge_in_progress = true;
949 } else if wt_status_check_rebase(git_dir, &mut state) {
950 } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
952 state.cherry_pick_in_progress = true;
953 state.cherry_pick_head_oid = Some(oid);
954 }
955
956 let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
957 if bisect_base.join("BISECT_LOG").exists() {
958 state.bisect_in_progress = true;
959 state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
960 }
961
962 if let Some(oid) = read_revert_head(git_dir)? {
963 state.revert_in_progress = true;
964 state.revert_head_oid = Some(oid);
965 }
966
967 if let Some(is_pick) = sequencer_first_replay(git_dir) {
968 if is_pick && !state.cherry_pick_in_progress {
969 state.cherry_pick_in_progress = true;
970 state.cherry_pick_head_oid = None;
971 } else if !is_pick && !state.revert_in_progress {
972 state.revert_in_progress = true;
973 state.revert_head_oid = None;
974 }
975 }
976
977 if get_detached_from {
978 if let HeadState::Detached { oid } = head {
979 if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
980 state.detached_from = Some(label);
981 state.detached_at = at;
982 }
983 }
984 }
985
986 state.rewrite_apply_in_progress = crate::history::rewrite_apply_journal_active(git_dir);
987
988 state.grit_replay_sequencer = git_dir.join("sequencer").join("grit-opts").is_file();
989 if state.grit_replay_sequencer {
990 if let Some((kind, empty_stop)) = read_grit_replay_status_hints(git_dir) {
991 state.grit_replay_kind = Some(kind);
992 state.grit_replay_empty_stop = empty_stop;
993 match kind {
994 InProgressOperation::CherryPick => {
995 state.revert_in_progress = false;
996 state.revert_head_oid = None;
997 if !state.cherry_pick_in_progress {
998 state.cherry_pick_in_progress = true;
999 }
1000 }
1001 InProgressOperation::Revert => {
1002 state.cherry_pick_in_progress = false;
1003 state.cherry_pick_head_oid = None;
1004 if !state.revert_in_progress {
1005 state.revert_in_progress = true;
1006 }
1007 }
1008 _ => {}
1009 }
1010 }
1011 }
1012
1013 Ok(state)
1014}
1015
1016fn read_grit_replay_status_hints(git_dir: &Path) -> Option<(InProgressOperation, bool)> {
1017 let path = git_dir.join("sequencer").join("grit-opts");
1018 let content = fs::read_to_string(&path).ok()?;
1019 let mut direction = None;
1020 let mut empty = "stop";
1021 for line in content.lines() {
1022 let line = line.trim();
1023 let Some((key, value)) = line.split_once('=') else {
1024 continue;
1025 };
1026 match key.trim() {
1027 "direction" => {
1028 direction = Some(if value.trim() == "revert" {
1029 InProgressOperation::Revert
1030 } else {
1031 InProgressOperation::CherryPick
1032 });
1033 }
1034 "empty" => empty = value.trim(),
1035 _ => {}
1036 }
1037 }
1038 let kind = direction?;
1039 let empty_stop = empty == "stop"
1040 && (read_cherry_pick_head(git_dir).ok().flatten().is_some()
1041 || read_revert_head(git_dir).ok().flatten().is_some());
1042 Some((kind, empty_stop))
1043}
1044
1045pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
1047 let HeadState::Detached { oid: head_oid } = head else {
1048 return false;
1049 };
1050 let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
1051 return false;
1052 };
1053 let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
1054 return false;
1055 };
1056 let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
1057 return false;
1058 };
1059 let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
1060 return false;
1061 };
1062 if amend_line == orig_line {
1063 head_oid != &amend_oid
1064 } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
1065 cur_orig != orig_head_oid
1066 } else {
1067 false
1068 }
1069}
1070
1071pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
1082 let head = resolve_head(git_dir)?;
1083 let in_progress = detect_in_progress(git_dir);
1084
1085 Ok(RepoState {
1086 head,
1087 in_progress,
1088 is_bare,
1089 })
1090}
1091
1092pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
1102 let path = git_dir.join("MERGE_HEAD");
1103 let content = match fs::read_to_string(&path) {
1104 Ok(c) => c,
1105 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1106 Err(e) => return Err(Error::Io(e)),
1107 };
1108
1109 let mut oids = Vec::new();
1110 for line in content.lines() {
1111 let trimmed = line.trim();
1112 if !trimmed.is_empty() {
1113 oids.push(ObjectId::from_hex(trimmed)?);
1114 }
1115 }
1116 Ok(oids)
1117}
1118
1119pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
1129 let path = git_dir.join("MERGE_MSG");
1130 match fs::read_to_string(&path) {
1131 Ok(c) => Ok(Some(c)),
1132 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1133 Err(e) => Err(Error::Io(e)),
1134 }
1135}
1136
1137pub fn clear_merge_state(git_dir: &Path) -> Result<()> {
1149 for name in merge_completion_sentinels() {
1150 let path = git_dir.join(name);
1151 if path.exists() {
1152 fs::remove_file(&path).map_err(Error::Io)?;
1153 }
1154 }
1155 Ok(())
1156}
1157
1158pub fn clear_replay_state(git_dir: &Path) -> Result<()> {
1171 for name in ["CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_MSG"] {
1172 let path = git_dir.join(name);
1173 if path.exists() {
1174 fs::remove_file(&path).map_err(Error::Io)?;
1175 }
1176 }
1177 let seq = git_dir.join("sequencer");
1178 if seq.is_dir() {
1179 fs::remove_dir_all(&seq).map_err(Error::Io)?;
1180 }
1181 Ok(())
1182}
1183
1184#[must_use]
1186pub fn merge_completion_sentinels() -> &'static [&'static str] {
1187 &[
1188 "MERGE_HEAD",
1189 "MERGE_MSG",
1190 "MERGE_MODE",
1191 "MERGE_LOG",
1192 "AUTO_MERGE",
1193 "SQUASH_MSG",
1194 "MERGE_AUTOSTASH",
1195 ]
1196}
1197
1198pub fn finish_merge_state(repo: &Repository) -> Result<bool> {
1211 let autostash_conflicts = crate::porcelain::stash::apply_merge_autostash(repo)?;
1212 let _ = crate::rerere::finish_merge_rerere(repo)?;
1213 clear_merge_state(repo.git_dir())?;
1214 Ok(autostash_conflicts)
1215}
1216
1217pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1220 read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
1221}
1222
1223pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1225 read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
1226}
1227
1228fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
1229 match fs::read_to_string(path) {
1230 Ok(content) => {
1231 let trimmed = content.trim();
1232 if trimmed.is_empty() {
1233 Ok(None)
1234 } else {
1235 Ok(ObjectId::from_hex(trimmed).ok())
1236 }
1237 }
1238 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1239 Err(e) => Err(Error::Io(e)),
1240 }
1241}
1242
1243pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1253 read_single_oid_file(&git_dir.join("ORIG_HEAD"))
1254}
1255
1256pub fn read_orig_head_for_repository(repo: &Repository) -> Result<Option<ObjectId>> {
1266 repo.references().resolve_if_exists("ORIG_HEAD")
1267}
1268
1269pub fn write_orig_head(repo: &Repository, oid: ObjectId) -> Result<()> {
1280 crate::refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), "ORIG_HEAD", &oid)
1281}
1282
1283fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
1285 match fs::read_to_string(path) {
1286 Ok(content) => {
1287 let trimmed = content.trim();
1288 if trimmed.is_empty() {
1289 Ok(None)
1290 } else {
1291 Ok(Some(ObjectId::from_hex(trimmed)?))
1292 }
1293 }
1294 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1295 Err(e) => Err(Error::Io(e)),
1296 }
1297}
1298
1299#[cfg(test)]
1300mod merge_sentinel_tests {
1301 #![allow(clippy::unwrap_used)]
1302
1303 use super::{clear_merge_state, merge_completion_sentinels};
1304 use std::fs;
1305 use tempfile::TempDir;
1306
1307 #[test]
1308 fn clear_merge_state_removes_every_completion_sentinel() {
1309 let tmp = TempDir::new().unwrap();
1310 let git = tmp.path().join(".git");
1311 fs::create_dir_all(&git).unwrap();
1312 for name in merge_completion_sentinels() {
1313 fs::write(git.join(name), b"x").unwrap();
1314 }
1315 clear_merge_state(&git).unwrap();
1316 for name in merge_completion_sentinels() {
1317 assert!(!git.join(name).exists(), "expected {name} to be removed");
1318 }
1319 }
1320}
1321
1322#[cfg(test)]
1323mod head_state_tests {
1324 #![allow(clippy::unwrap_used)]
1325
1326 use super::{resolve_head, resolve_head_for_repository, HeadState};
1327 use crate::repo::init_repository;
1328 use crate::RefStorageFormat;
1329 use tempfile::TempDir;
1330
1331 fn unborn_main() -> HeadState {
1332 HeadState::Branch {
1333 refname: "refs/heads/main".to_owned(),
1334 short_name: "main".to_owned(),
1335 oid: None,
1336 }
1337 }
1338
1339 #[test]
1340 fn unborn_branch_resolves_without_oid_on_every_backend() {
1341 for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1342 let tmp = TempDir::new().unwrap();
1343 let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1344 assert_eq!(resolve_head(repo.git_dir()).unwrap(), unborn_main());
1345 assert_eq!(resolve_head_for_repository(&repo).unwrap(), unborn_main());
1346 }
1347 }
1348
1349 #[test]
1350 fn head_symref_to_missing_non_branch_is_invalid() {
1351 let tmp = TempDir::new().unwrap();
1352 let repo =
1353 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1354 std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1355 assert_eq!(resolve_head(repo.git_dir()).unwrap(), HeadState::Invalid);
1356 assert_eq!(
1357 resolve_head_for_repository(&repo).unwrap(),
1358 HeadState::Invalid
1359 );
1360 }
1361}
1362
1363#[cfg(test)]
1364mod switch_guard_tests {
1365 #![allow(clippy::unwrap_used)]
1366
1367 use super::{operation_blocking_switch, InProgressOperation};
1368 use std::fs;
1369 use tempfile::TempDir;
1370
1371 fn blocking(setup: impl FnOnce(&std::path::Path)) -> Option<InProgressOperation> {
1372 let tmp = TempDir::new().unwrap();
1373 let git = tmp.path().join(".git");
1374 fs::create_dir_all(&git).unwrap();
1375 setup(&git);
1376 operation_blocking_switch(&git).unwrap()
1377 }
1378
1379 #[test]
1380 fn clean_and_bisect_do_not_block_switch() {
1381 assert_eq!(blocking(|_| {}), None);
1382 assert_eq!(
1383 blocking(|git| fs::write(git.join("BISECT_LOG"), "").unwrap()),
1384 None
1385 );
1386 }
1387
1388 #[test]
1389 fn paused_operations_block_switch() {
1390 let oid = "1111111111111111111111111111111111111111\n";
1391 assert_eq!(
1392 blocking(|git| fs::write(git.join("MERGE_HEAD"), oid).unwrap()),
1393 Some(InProgressOperation::Merge)
1394 );
1395 assert_eq!(
1396 blocking(|git| {
1397 fs::create_dir_all(git.join("rebase-merge")).unwrap();
1398 fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1399 }),
1400 Some(InProgressOperation::RebaseInteractive)
1401 );
1402 assert_eq!(
1403 blocking(|git| {
1404 fs::create_dir_all(git.join("rebase-merge")).unwrap();
1405 fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1406 fs::write(git.join("rebase-merge/grit-plan"), "{}").unwrap();
1407 }),
1408 Some(InProgressOperation::Rebase)
1409 );
1410 assert_eq!(
1411 blocking(|git| fs::create_dir_all(git.join("rebase-apply")).unwrap()),
1412 Some(InProgressOperation::Rebase)
1413 );
1414 assert_eq!(
1415 blocking(|git| {
1416 fs::create_dir_all(git.join("rebase-apply")).unwrap();
1417 fs::write(git.join("rebase-apply/applying"), "").unwrap();
1418 }),
1419 Some(InProgressOperation::Am)
1420 );
1421 assert_eq!(
1422 blocking(|git| fs::write(git.join("CHERRY_PICK_HEAD"), oid).unwrap()),
1423 Some(InProgressOperation::CherryPick)
1424 );
1425 assert_eq!(
1426 blocking(|git| fs::write(git.join("REVERT_HEAD"), oid).unwrap()),
1427 Some(InProgressOperation::Revert)
1428 );
1429 }
1430}