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))]
41pub enum HeadState {
42 Branch {
44 refname: String,
46 short_name: String,
48 oid: Option<ObjectId>,
51 },
52 Detached {
54 oid: ObjectId,
56 },
57 Invalid,
59}
60
61impl HeadState {
62 #[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 #[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 #[must_use]
83 pub fn is_unborn(&self) -> bool {
84 matches!(self, Self::Branch { oid: None, .. })
85 }
86
87 #[must_use]
89 pub fn is_detached(&self) -> bool {
90 matches!(self, Self::Detached { .. })
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum InProgressOperation {
97 Merge,
99 RebaseInteractive,
101 Rebase,
103 CherryPick,
105 Revert,
107 Bisect,
109 Am,
111 RewriteApply,
114}
115
116impl InProgressOperation {
117 #[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#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum NextStep {
136 ResolveConflicts,
138 StageResolved,
140 Commit,
142 Abort(InProgressOperation),
144 Continue(InProgressOperation),
146 Skip(InProgressOperation),
148 ContinueWithGit(InProgressOperation),
151}
152
153impl NextStep {
154 #[must_use]
157 pub fn command(&self) -> Option<String> {
158 self.grit_command().or_else(|| self.git_command())
159 }
160
161 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct Operation {
231 pub kind: InProgressOperation,
233 pub next_steps: Vec<NextStep>,
235 pub alternatives: Vec<NextStep>,
237}
238
239#[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#[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#[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#[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#[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#[derive(Debug, Clone)]
411pub struct RepoState {
412 pub head: HeadState,
414 pub in_progress: Vec<InProgressOperation>,
416 pub is_bare: bool,
418}
419
420pub 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
439pub 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 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
491pub(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
508fn 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 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 match ObjectId::from_hex(trimmed) {
592 Ok(oid) => Ok(HeadState::Detached { oid }),
593 Err(_) => Ok(HeadState::Invalid),
594 }
595 }
596}
597
598fn grit_rebase_plan_active(rebase_merge: &Path) -> bool {
600 rebase_merge.join("grit-plan").is_file()
601}
602
603pub 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 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 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
661pub 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#[derive(Debug, Clone, Default)]
684pub struct WtStatusState {
685 pub merge_in_progress: bool,
687 pub rebase_interactive_in_progress: bool,
689 pub rebase_in_progress: bool,
691 pub rebase_branch: Option<String>,
693 pub rebase_onto: Option<String>,
695 pub am_in_progress: bool,
697 pub am_empty_patch: bool,
699 pub cherry_pick_in_progress: bool,
701 pub cherry_pick_head_oid: Option<ObjectId>,
703 pub revert_in_progress: bool,
705 pub revert_head_oid: Option<ObjectId>,
707 pub bisect_in_progress: bool,
709 pub bisecting_from: Option<String>,
711 pub detached_from: Option<String>,
713 pub detached_at: bool,
715 pub grit_replay_sequencer: bool,
717 pub grit_replay_kind: Option<InProgressOperation>,
719 pub grit_replay_empty_stop: bool,
721 pub grit_rebase_in_progress: bool,
723 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
744fn 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 } 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 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
898pub 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 } 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
1008pub 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
1034pub 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
1055pub 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
1082pub 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
1100pub 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
1121pub 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#[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
1161pub 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
1180pub 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
1186pub 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
1206pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1208 read_single_oid_file(&git_dir.join("ORIG_HEAD"))
1209}
1210
1211fn 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}