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, serde::Deserialize))]
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 pub fn tree(&self, odb: &crate::objects::odb::Odb) -> Result<Option<ObjectId>> {
114 self.oid().map(|oid| odb.commit_tree(oid)).transpose()
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
121#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
122pub enum InProgressOperation {
123 Merge,
125 RebaseInteractive,
127 Rebase,
129 CherryPick,
131 Revert,
133 Bisect,
135 Am,
137 RewriteApply,
140}
141
142impl InProgressOperation {
143 #[must_use]
145 pub fn description(&self) -> &'static str {
146 match self {
147 Self::Merge => "merge",
148 Self::RebaseInteractive => "interactive rebase",
149 Self::Rebase => "rebase",
150 Self::CherryPick => "cherry-pick",
151 Self::Revert => "revert",
152 Self::Bisect => "bisect",
153 Self::Am => "am",
154 Self::RewriteApply => "rewrite apply",
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
162#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
163pub enum NextStep {
164 ResolveConflicts,
166 StageResolved,
168 Commit,
170 Abort(InProgressOperation),
172 Continue(InProgressOperation),
174 Skip(InProgressOperation),
176 ContinueWithGit(InProgressOperation),
179}
180
181impl NextStep {
182 #[must_use]
185 pub fn command(&self) -> Option<String> {
186 self.grit_command().or_else(|| self.git_command())
187 }
188
189 #[must_use]
191 pub fn git_command(&self) -> Option<String> {
192 let Self::ContinueWithGit(kind) = self else {
193 return None;
194 };
195 let verb = match kind {
196 InProgressOperation::Merge => "merge",
197 InProgressOperation::Rebase | InProgressOperation::RebaseInteractive => "rebase",
198 InProgressOperation::CherryPick => "cherry-pick",
199 InProgressOperation::Revert => "revert",
200 InProgressOperation::Am => "am",
201 InProgressOperation::Bisect | InProgressOperation::RewriteApply => return None,
202 };
203 Some(format!("git {verb} --continue"))
204 }
205
206 #[must_use]
208 pub fn grit_command(&self) -> Option<String> {
209 match self {
210 Self::ResolveConflicts => Some("grit diff".to_owned()),
211 Self::StageResolved => Some("grit add .".to_owned()),
212 Self::Commit => Some("grit commit \"message\"".to_owned()),
213 Self::Continue(InProgressOperation::Merge) => Some("grit merge --continue".to_owned()),
214 Self::Abort(InProgressOperation::Merge) => Some("grit merge --abort".to_owned()),
215 Self::Abort(InProgressOperation::CherryPick) => Some("grit pick --abort".to_owned()),
216 Self::Abort(InProgressOperation::Revert) => Some("grit revert --abort".to_owned()),
217 Self::Continue(InProgressOperation::CherryPick) => {
218 Some("grit pick --continue".to_owned())
219 }
220 Self::Continue(InProgressOperation::Revert) => {
221 Some("grit revert --continue".to_owned())
222 }
223 Self::Skip(InProgressOperation::CherryPick) => Some("grit pick --skip".to_owned()),
224 Self::Skip(InProgressOperation::Revert) => Some("grit revert --skip".to_owned()),
225 Self::Continue(InProgressOperation::Rebase) => {
226 Some("grit rebase --continue".to_owned())
227 }
228 Self::Abort(InProgressOperation::Rebase | InProgressOperation::RebaseInteractive) => {
229 Some("grit rebase --abort".to_owned())
230 }
231 Self::Skip(InProgressOperation::Rebase) => Some("grit rebase --skip".to_owned()),
232 Self::Continue(InProgressOperation::RewriteApply) => {
233 Some("grit rebase --continue".to_owned())
234 }
235 Self::Abort(InProgressOperation::RewriteApply) => {
236 Some("grit rebase --abort".to_owned())
237 }
238 Self::ContinueWithGit(_)
239 | Self::Abort(InProgressOperation::Am | InProgressOperation::Bisect)
240 | Self::Continue(
241 InProgressOperation::RebaseInteractive
242 | InProgressOperation::Am
243 | InProgressOperation::Bisect,
244 )
245 | Self::Skip(InProgressOperation::Merge)
246 | Self::Skip(
247 InProgressOperation::RebaseInteractive
248 | InProgressOperation::Am
249 | InProgressOperation::Bisect
250 | InProgressOperation::RewriteApply,
251 ) => None,
252 }
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
258#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
259pub struct Operation {
260 pub kind: InProgressOperation,
262 pub next_steps: Vec<NextStep>,
264 pub alternatives: Vec<NextStep>,
266}
267
268#[must_use]
270pub fn primary_in_progress_operation(state: &WtStatusState) -> Option<InProgressOperation> {
271 if state.merge_in_progress {
272 return Some(InProgressOperation::Merge);
273 }
274 if state.rebase_interactive_in_progress {
275 return Some(InProgressOperation::RebaseInteractive);
276 }
277 if state.rebase_in_progress {
278 return Some(InProgressOperation::Rebase);
279 }
280 if state.rewrite_apply_in_progress {
281 return Some(InProgressOperation::RewriteApply);
282 }
283 if state.am_in_progress {
284 return Some(InProgressOperation::Am);
285 }
286 if state.cherry_pick_in_progress {
287 return Some(InProgressOperation::CherryPick);
288 }
289 if state.revert_in_progress {
290 return Some(InProgressOperation::Revert);
291 }
292 if state.bisect_in_progress {
293 return Some(InProgressOperation::Bisect);
294 }
295 None
296}
297
298#[must_use]
300pub fn operation_next_steps_empty(kind: InProgressOperation) -> Vec<NextStep> {
301 match kind {
302 InProgressOperation::CherryPick | InProgressOperation::Revert => {
303 vec![NextStep::Skip(kind)]
304 }
305 other => operation_next_steps(other, false),
306 }
307}
308
309#[must_use]
311pub fn operation_next_steps(
312 kind: InProgressOperation,
313 has_unmerged_conflicts: bool,
314) -> Vec<NextStep> {
315 match kind {
316 InProgressOperation::Merge => {
317 if has_unmerged_conflicts {
318 vec![NextStep::ResolveConflicts, NextStep::StageResolved]
319 } else {
320 vec![NextStep::Continue(InProgressOperation::Merge)]
321 }
322 }
323 InProgressOperation::CherryPick | InProgressOperation::Revert => {
324 if has_unmerged_conflicts {
325 vec![
326 NextStep::ResolveConflicts,
327 NextStep::StageResolved,
328 NextStep::Continue(kind),
329 ]
330 } else {
331 vec![NextStep::Continue(kind)]
332 }
333 }
334 InProgressOperation::Rebase => {
335 if has_unmerged_conflicts {
336 vec![
337 NextStep::ResolveConflicts,
338 NextStep::StageResolved,
339 NextStep::Continue(InProgressOperation::Rebase),
340 ]
341 } else {
342 vec![NextStep::Continue(InProgressOperation::Rebase)]
343 }
344 }
345 InProgressOperation::RebaseInteractive
346 | InProgressOperation::Am
347 | InProgressOperation::Bisect => vec![],
348 InProgressOperation::RewriteApply => {
349 vec![NextStep::Continue(InProgressOperation::RewriteApply)]
350 }
351 }
352}
353
354#[must_use]
356pub fn operation_alternatives(kind: InProgressOperation) -> Vec<NextStep> {
357 match kind {
358 InProgressOperation::Merge => vec![NextStep::Abort(InProgressOperation::Merge)],
359 InProgressOperation::CherryPick => vec![
360 NextStep::Abort(InProgressOperation::CherryPick),
361 NextStep::Skip(InProgressOperation::CherryPick),
362 ],
363 InProgressOperation::Revert => vec![
364 NextStep::Abort(InProgressOperation::Revert),
365 NextStep::Skip(InProgressOperation::Revert),
366 ],
367 InProgressOperation::Rebase => vec![
368 NextStep::Abort(InProgressOperation::Rebase),
369 NextStep::Skip(InProgressOperation::Rebase),
370 ],
371 InProgressOperation::RebaseInteractive
372 | InProgressOperation::Am
373 | InProgressOperation::Bisect => vec![],
374 InProgressOperation::RewriteApply => {
375 vec![NextStep::Abort(InProgressOperation::RewriteApply)]
376 }
377 }
378}
379
380#[must_use]
387pub fn status_operation(state: &WtStatusState, has_unmerged_conflicts: bool) -> Option<Operation> {
388 let foreign_rebase = !state.merge_in_progress
389 && !state.grit_rebase_in_progress
390 && (state.rebase_in_progress || state.rebase_interactive_in_progress);
391 if foreign_rebase {
392 let kind = if state.rebase_interactive_in_progress {
393 InProgressOperation::RebaseInteractive
394 } else {
395 InProgressOperation::Rebase
396 };
397 let mut next_steps = Vec::new();
398 if has_unmerged_conflicts {
399 next_steps.extend([NextStep::ResolveConflicts, NextStep::StageResolved]);
400 }
401 next_steps.push(NextStep::ContinueWithGit(kind));
402 return Some(Operation {
403 kind,
404 next_steps,
405 alternatives: vec![NextStep::Abort(kind)],
406 });
407 }
408 let kind = if state.merge_in_progress {
409 InProgressOperation::Merge
410 } else if state.grit_rebase_in_progress {
411 InProgressOperation::Rebase
412 } else if let Some(kind) = state.grit_replay_kind {
413 kind
414 } else if state.cherry_pick_in_progress {
415 InProgressOperation::CherryPick
416 } else if state.revert_in_progress {
417 InProgressOperation::Revert
418 } else if state.rewrite_apply_in_progress {
419 InProgressOperation::RewriteApply
420 } else {
421 return None;
422 };
423 let next_steps = if state.grit_replay_empty_stop && !has_unmerged_conflicts {
424 operation_next_steps_empty(kind)
425 } else {
426 operation_next_steps(kind, has_unmerged_conflicts)
427 };
428 Some(Operation {
429 kind,
430 next_steps,
431 alternatives: operation_alternatives(kind),
432 })
433}
434
435#[derive(Debug, Clone)]
440pub struct RepoState {
441 pub head: HeadState,
443 pub in_progress: Vec<InProgressOperation>,
445 pub is_bare: bool,
447}
448
449pub fn resolve_head(git_dir: &Path) -> Result<HeadState> {
461 let store = crate::refs::store::open_ref_store(git_dir)?;
462 if store.format() == crate::refs::store::RefStorageFormat::Reftable {
463 return resolve_head_from_store(store.as_ref());
464 }
465 resolve_head_impl(git_dir, None)
466}
467
468pub fn resolve_head_for_repository(repo: &crate::repo::Repository) -> Result<HeadState> {
478 if repo.refs().format() == crate::refs::store::RefStorageFormat::Files {
479 return resolve_head_impl(repo.git_dir(), Some(repo));
480 }
481 resolve_head_from_store(repo.refs())
482}
483
484fn resolve_head_from_store(store: &dyn crate::refs::store::RefStore) -> Result<HeadState> {
485 use crate::refs::store::RawRef;
486 match store.read_raw("HEAD")? {
487 None => Ok(HeadState::Invalid),
488 Some(RawRef::Direct(oid)) => Ok(HeadState::Detached { oid }),
489 Some(RawRef::Symbolic(refname)) => {
490 symbolic_head_state(refname, |name| match store.resolve(name) {
491 Ok(oid) => Ok(Some(oid)),
492 Err(Error::RefStore(RefStoreError::NotFound { .. })) => Ok(None),
493 Err(err) => Err(err),
494 })
495 }
496 }
497}
498
499impl Repository {
500 pub fn head_commit(&self) -> Result<ObjectId> {
512 match resolve_head_for_repository(self)? {
513 HeadState::Branch { oid: Some(oid), .. } | HeadState::Detached { oid } => Ok(oid),
514 HeadState::Branch { oid: None, .. } => Err(Error::UnbornHead),
515 HeadState::Invalid => Err(Error::InvalidHead),
516 }
517 }
518
519 pub fn head_tree(&self) -> Result<Option<ObjectId>> {
530 match resolve_head_for_repository(self)? {
531 HeadState::Invalid => Err(Error::InvalidHead),
532 head => head.tree(self.odb()),
533 }
534 }
535}
536
537pub(crate) fn resolve_linked_worktree_head(
543 repo: &crate::repo::Repository,
544 admin_dir: &Path,
545) -> HeadState {
546 if repo.refs_on_disk() {
547 resolve_head(admin_dir)
548 } else {
549 resolve_head_impl(admin_dir, Some(repo))
550 }
551 .unwrap_or(HeadState::Invalid)
552}
553
554fn symbolic_head_state(
559 refname: String,
560 resolve_named: impl Fn(&str) -> Result<Option<ObjectId>>,
561) -> Result<HeadState> {
562 if check_refname_format(&refname, &RefNameOptions::default()).is_err() {
563 return Ok(HeadState::Invalid);
564 }
565 let oid = match resolve_named(&refname)? {
566 Some(oid) => Some(oid),
567 None if refname.starts_with("refs/heads/") => None,
568 None => return Ok(HeadState::Invalid),
569 };
570 let short_name = refname
571 .strip_prefix("refs/heads/")
572 .unwrap_or(&refname)
573 .to_owned();
574 Ok(HeadState::Branch {
575 refname,
576 short_name,
577 oid,
578 })
579}
580
581fn resolve_head_impl(git_dir: &Path, repo: Option<&crate::repo::Repository>) -> Result<HeadState> {
582 let resolve_named = |refname: &str| -> Result<Option<crate::objects::ObjectId>> {
583 if let Some(r) = repo {
584 return r.references().resolve_if_exists(refname);
585 }
586 match crate::refs::resolve_ref(git_dir, refname) {
587 Ok(oid) => Ok(Some(oid)),
588 Err(Error::RefStore(RefStoreError::NotFound { .. })) => Ok(None),
589 Err(e) => Err(e),
590 }
591 };
592 let read_symbolic = |refname: &str| -> Result<Option<String>> {
593 if let Some(r) = repo {
594 crate::refs::read_symbolic_ref_for_repository(r, refname)
595 } else {
596 crate::refs::read_symbolic_ref(git_dir, refname)
597 }
598 };
599 let head_path = git_dir.join("HEAD");
600 let content = match fs::read_link(&head_path) {
601 Ok(link_target) => {
602 let rendered = link_target.to_string_lossy();
603 if link_target.is_absolute() || rendered.starts_with("refs/") {
604 format!("ref: {rendered}")
605 } else {
606 fs::read_to_string(&head_path).map_err(Error::Io)?
607 }
608 }
609 Err(_) => match fs::read_to_string(&head_path) {
610 Ok(c) => c,
611 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HeadState::Invalid),
612 Err(e) => return Err(Error::Io(e)),
613 },
614 };
615
616 let trimmed = content.trim();
617
618 if let Some(refname) = trimmed.strip_prefix("ref: ") {
619 let refname = if refname == "refs/heads/.invalid" {
620 if let Ok(Some(target)) = read_symbolic("HEAD") {
621 target
622 } else if let Ok(Some(oid)) = resolve_named("HEAD") {
623 return Ok(HeadState::Detached { oid });
625 } else {
626 match crate::refs::read_ref_file(&git_dir.join("refs").join("heads")) {
627 Ok(crate::refs::Ref::Symbolic(target)) => target,
628 _ => refname.to_owned(),
629 }
630 }
631 } else {
632 refname.to_owned()
633 };
634 symbolic_head_state(refname, resolve_named)
635 } else {
636 match ObjectId::from_hex(trimmed) {
638 Ok(oid) => Ok(HeadState::Detached { oid }),
639 Err(_) => Ok(HeadState::Invalid),
640 }
641 }
642}
643
644fn grit_rebase_plan_active(rebase_merge: &Path) -> bool {
646 rebase_merge.join("grit-plan").is_file() || rebase_merge.join("grit-exec").is_file()
647}
648
649pub(crate) fn grit_rebase_conflict_stop_active(git_dir: &Path) -> bool {
651 let merge = git_dir.join("rebase-merge");
652 merge.is_dir() && merge.join("grit-plan").is_file() && merge.join("stopped-sha").is_file()
653}
654
655pub(crate) fn read_grit_rebase_stopped_source(
657 git_dir: &Path,
658) -> crate::error::Result<Option<ObjectId>> {
659 if !grit_rebase_conflict_stop_active(git_dir) {
660 return Ok(None);
661 }
662 let raw = fs::read_to_string(git_dir.join("rebase-merge/stopped-sha"))?;
663 let oid =
664 ObjectId::from_hex(raw.trim()).map_err(|e| crate::error::Error::Message(e.to_string()))?;
665 Ok(Some(oid))
666}
667
668pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
678 let mut ops = Vec::new();
679
680 if git_dir.join("MERGE_HEAD").exists() {
681 ops.push(InProgressOperation::Merge);
682 }
683
684 let rebase_merge = git_dir.join("rebase-merge");
686 if rebase_merge.is_dir() {
687 if rebase_merge.join("interactive").exists() && !grit_rebase_plan_active(&rebase_merge) {
688 ops.push(InProgressOperation::RebaseInteractive);
689 } else {
690 ops.push(InProgressOperation::Rebase);
691 }
692 }
693
694 let rebase_apply = git_dir.join("rebase-apply");
696 if rebase_apply.is_dir() {
697 if rebase_apply.join("applying").exists() {
698 ops.push(InProgressOperation::Am);
699 } else {
700 ops.push(InProgressOperation::Rebase);
701 }
702 }
703
704 if git_dir.join("CHERRY_PICK_HEAD").exists() {
705 ops.push(InProgressOperation::CherryPick);
706 }
707
708 if git_dir.join("REVERT_HEAD").exists() {
709 ops.push(InProgressOperation::Revert);
710 }
711
712 let bisect_log = crate::refs::common_dir(git_dir)
713 .unwrap_or_else(|| git_dir.to_path_buf())
714 .join("BISECT_LOG");
715 if bisect_log.exists() {
716 ops.push(InProgressOperation::Bisect);
717 }
718
719 if crate::history::rewrite_apply_journal_active(git_dir) {
720 ops.push(InProgressOperation::RewriteApply);
721 }
722
723 ops
724}
725
726pub fn clear_in_progress(repo: &Repository) -> Result<Vec<InProgressOperation>> {
734 crate::porcelain::workspace_snapshot::clear_in_progress(repo)
735}
736
737pub fn operation_blocking_switch(git_dir: &Path) -> Result<Option<InProgressOperation>> {
751 let state = wt_status_get_state(git_dir, &HeadState::Invalid, false)?;
752 Ok(primary_in_progress_operation(&state).filter(|op| *op != InProgressOperation::Bisect))
753}
754
755#[derive(Debug, Clone, Default)]
760#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
761pub struct WtStatusState {
762 pub merge_in_progress: bool,
764 pub rebase_interactive_in_progress: bool,
766 pub rebase_in_progress: bool,
768 pub rebase_branch: Option<String>,
770 pub rebase_onto: Option<String>,
772 pub am_in_progress: bool,
774 pub am_empty_patch: bool,
776 pub cherry_pick_in_progress: bool,
778 pub cherry_pick_head_oid: Option<ObjectId>,
780 pub revert_in_progress: bool,
782 pub revert_head_oid: Option<ObjectId>,
784 pub bisect_in_progress: bool,
786 pub bisecting_from: Option<String>,
788 pub detached_from: Option<String>,
790 pub detached_at: bool,
792 pub grit_replay_sequencer: bool,
794 pub grit_replay_kind: Option<InProgressOperation>,
796 pub grit_replay_empty_stop: bool,
798 pub grit_rebase_in_progress: bool,
800 pub rewrite_apply_in_progress: bool,
802}
803
804fn read_trimmed_line(path: &Path) -> Option<String> {
805 let s = fs::read_to_string(path).ok()?;
806 let mut line = s.lines().next()?.to_string();
807 while line.ends_with('\n') || line.ends_with('\r') {
808 line.pop();
809 }
810 if line.is_empty() {
811 None
812 } else {
813 Some(line)
814 }
815}
816
817fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
819 let path = git_dir.join(rel);
820 let mut sb = read_trimmed_line(&path)?;
821 if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
822 sb = branch_name.to_string();
823 } else if sb.starts_with("refs/") {
824 } else if ObjectId::from_hex(&sb).is_ok() {
826 let oid = ObjectId::from_hex(&sb).ok()?;
827 sb = oid.to_short_hex();
828 } else if sb == "detached HEAD" {
829 return None;
830 }
831 Some(sb)
832}
833
834fn strip_ref_for_display(full: &str) -> String {
835 if let Some(s) = full.strip_prefix("refs/tags/") {
836 return s.to_string();
837 }
838 if let Some(s) = full.strip_prefix("refs/remotes/") {
839 return s.to_string();
840 }
841 if let Some(s) = full.strip_prefix("refs/heads/") {
842 return s.to_string();
843 }
844 full.to_string()
845}
846
847fn ref_name_exists(git_dir: &Path, refname: &str) -> bool {
848 git_dir.join(refname).exists()
849 || crate::refs::packed_refs_entry_exists(git_dir, refname).unwrap_or(false)
850}
851
852fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
853 if target == "HEAD" {
854 return noid.to_short_hex();
855 }
856 if target.starts_with("refs/") {
857 if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
858 if oid == noid {
859 return strip_ref_for_display(target);
860 }
861 }
862 }
863 for candidate in [
864 format!("refs/heads/{target}"),
865 format!("refs/tags/{target}"),
866 format!("refs/remotes/{target}"),
867 ] {
868 if candidate.starts_with("refs/tags/") && ref_name_exists(git_dir, &candidate) {
869 return strip_ref_for_display(&candidate);
870 }
871 if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
872 if oid == noid {
873 return strip_ref_for_display(&candidate);
874 }
875 }
876 }
877 if ObjectId::is_hex_len(target.len()) {
878 if let Ok(oid) = ObjectId::from_hex(target) {
879 if oid == noid {
880 return noid.to_short_hex();
881 }
882 }
883 }
884 if !target.is_empty()
887 && target.chars().all(|c| c.is_ascii_hexdigit())
888 && target.len() <= noid.algo().hex_len()
889 && noid.to_hex().starts_with(target)
890 {
891 return target.to_owned();
892 }
893 noid.to_short_hex()
894}
895
896fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
897 let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
898 for entry in entries.iter().rev() {
899 let msg = entry.message.trim();
900 let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
901 continue;
902 };
903 let Some(idx) = rest.rfind(" to ") else {
904 continue;
905 };
906 let target = rest[idx + 4..].trim();
907 let noid = entry.new_oid;
908 let label = dwim_detach_label(git_dir, target, noid);
909 let detached_at = head_oid == noid;
910 return Some((label, detached_at));
911 }
912 None
913}
914
915fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
916 let apply = git_dir.join("rebase-apply");
917 if apply.is_dir() {
918 if apply.join("applying").exists() {
919 state.am_in_progress = true;
920 let patch = apply.join("patch");
921 if let Ok(meta) = patch.metadata() {
922 if meta.len() == 0 {
923 state.am_empty_patch = true;
924 }
925 }
926 } else {
927 state.rebase_in_progress = true;
928 state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
929 state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
930 }
931 return true;
932 }
933 let merge = git_dir.join("rebase-merge");
934 if merge.is_dir() {
935 let grit_plan = grit_rebase_plan_active(&merge);
936 if merge.join("interactive").exists() && !grit_plan {
937 state.rebase_interactive_in_progress = true;
938 } else {
939 state.rebase_in_progress = true;
940 }
941 state.grit_rebase_in_progress = grit_plan;
942 state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
943 state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
944 return true;
945 }
946 false
947}
948
949fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
950 let path = git_dir.join("sequencer").join("todo");
951 if !path.is_file() {
952 return None;
953 }
954 let content = fs::read_to_string(&path).ok()?;
955 for line in content.lines() {
956 let t = line.trim();
957 if t.is_empty() || t.starts_with('#') {
958 continue;
959 }
960 let mut parts = t.split_whitespace();
961 let cmd = parts.next()?;
962 return match cmd {
963 "pick" | "p" => Some(true),
964 "revert" | "r" => Some(false),
965 _ => None,
966 };
967 }
968 None
969}
970
971pub fn wt_status_get_state(
976 git_dir: &Path,
977 head: &HeadState,
978 get_detached_from: bool,
979) -> Result<WtStatusState> {
980 let mut state = WtStatusState::default();
981
982 if git_dir.join("MERGE_HEAD").exists() {
983 wt_status_check_rebase(git_dir, &mut state);
984 state.merge_in_progress = true;
985 } else if wt_status_check_rebase(git_dir, &mut state) {
986 } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
988 state.cherry_pick_in_progress = true;
989 state.cherry_pick_head_oid = Some(oid);
990 }
991
992 let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
993 if bisect_base.join("BISECT_LOG").exists() {
994 state.bisect_in_progress = true;
995 state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
996 }
997
998 if let Some(oid) = read_revert_head(git_dir)? {
999 state.revert_in_progress = true;
1000 state.revert_head_oid = Some(oid);
1001 }
1002
1003 if let Some(is_pick) = sequencer_first_replay(git_dir) {
1004 if is_pick && !state.cherry_pick_in_progress {
1005 state.cherry_pick_in_progress = true;
1006 state.cherry_pick_head_oid = None;
1007 } else if !is_pick && !state.revert_in_progress {
1008 state.revert_in_progress = true;
1009 state.revert_head_oid = None;
1010 }
1011 }
1012
1013 if get_detached_from {
1014 if let HeadState::Detached { oid } = head {
1015 if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
1016 state.detached_from = Some(label);
1017 state.detached_at = at;
1018 }
1019 }
1020 }
1021
1022 state.rewrite_apply_in_progress = crate::history::rewrite_apply_journal_active(git_dir);
1023
1024 state.grit_replay_sequencer = git_dir.join("sequencer").join("grit-opts").is_file();
1025 if state.grit_replay_sequencer {
1026 if let Some((kind, empty_stop)) = read_grit_replay_status_hints(git_dir) {
1027 state.grit_replay_kind = Some(kind);
1028 state.grit_replay_empty_stop = empty_stop;
1029 match kind {
1030 InProgressOperation::CherryPick => {
1031 state.revert_in_progress = false;
1032 state.revert_head_oid = None;
1033 if !state.cherry_pick_in_progress {
1034 state.cherry_pick_in_progress = true;
1035 }
1036 }
1037 InProgressOperation::Revert => {
1038 state.cherry_pick_in_progress = false;
1039 state.cherry_pick_head_oid = None;
1040 if !state.revert_in_progress {
1041 state.revert_in_progress = true;
1042 }
1043 }
1044 _ => {}
1045 }
1046 }
1047 }
1048
1049 Ok(state)
1050}
1051
1052fn read_grit_replay_status_hints(git_dir: &Path) -> Option<(InProgressOperation, bool)> {
1053 let path = git_dir.join("sequencer").join("grit-opts");
1054 let content = fs::read_to_string(&path).ok()?;
1055 let mut direction = None;
1056 let mut empty = "stop";
1057 for line in content.lines() {
1058 let line = line.trim();
1059 let Some((key, value)) = line.split_once('=') else {
1060 continue;
1061 };
1062 match key.trim() {
1063 "direction" => {
1064 direction = Some(if value.trim() == "revert" {
1065 InProgressOperation::Revert
1066 } else {
1067 InProgressOperation::CherryPick
1068 });
1069 }
1070 "empty" => empty = value.trim(),
1071 _ => {}
1072 }
1073 }
1074 let kind = direction?;
1075 let empty_stop = empty == "stop"
1076 && (read_cherry_pick_head(git_dir).ok().flatten().is_some()
1077 || read_revert_head(git_dir).ok().flatten().is_some());
1078 Some((kind, empty_stop))
1079}
1080
1081pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
1083 let HeadState::Detached { oid: head_oid } = head else {
1084 return false;
1085 };
1086 let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
1087 return false;
1088 };
1089 let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
1090 return false;
1091 };
1092 let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
1093 return false;
1094 };
1095 let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
1096 return false;
1097 };
1098 if amend_line == orig_line {
1099 head_oid != &amend_oid
1100 } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
1101 cur_orig != orig_head_oid
1102 } else {
1103 false
1104 }
1105}
1106
1107pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
1118 let head = resolve_head(git_dir)?;
1119 let in_progress = detect_in_progress(git_dir);
1120
1121 Ok(RepoState {
1122 head,
1123 in_progress,
1124 is_bare,
1125 })
1126}
1127
1128pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
1138 let path = git_dir.join("MERGE_HEAD");
1139 let content = match fs::read_to_string(&path) {
1140 Ok(c) => c,
1141 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1142 Err(e) => return Err(Error::Io(e)),
1143 };
1144
1145 let mut oids = Vec::new();
1146 for line in content.lines() {
1147 let trimmed = line.trim();
1148 if !trimmed.is_empty() {
1149 oids.push(ObjectId::from_hex(trimmed)?);
1150 }
1151 }
1152 Ok(oids)
1153}
1154
1155pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
1165 let path = git_dir.join("MERGE_MSG");
1166 match fs::read_to_string(&path) {
1167 Ok(c) => Ok(Some(c)),
1168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1169 Err(e) => Err(Error::Io(e)),
1170 }
1171}
1172
1173pub fn clear_merge_state(git_dir: &Path) -> Result<()> {
1185 for name in merge_completion_sentinels() {
1186 let path = git_dir.join(name);
1187 if path.exists() {
1188 fs::remove_file(&path).map_err(Error::Io)?;
1189 }
1190 }
1191 Ok(())
1192}
1193
1194pub fn clear_replay_state(git_dir: &Path) -> Result<()> {
1207 for name in ["CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_MSG"] {
1208 let path = git_dir.join(name);
1209 if path.exists() {
1210 fs::remove_file(&path).map_err(Error::Io)?;
1211 }
1212 }
1213 let seq = git_dir.join("sequencer");
1214 if seq.is_dir() {
1215 fs::remove_dir_all(&seq).map_err(Error::Io)?;
1216 }
1217 Ok(())
1218}
1219
1220#[must_use]
1222pub fn merge_completion_sentinels() -> &'static [&'static str] {
1223 &[
1224 "MERGE_HEAD",
1225 "MERGE_MSG",
1226 "MERGE_MODE",
1227 "MERGE_LOG",
1228 "AUTO_MERGE",
1229 "SQUASH_MSG",
1230 "MERGE_AUTOSTASH",
1231 ]
1232}
1233
1234pub fn finish_merge_state(repo: &Repository) -> Result<bool> {
1247 let autostash_conflicts = crate::porcelain::stash::apply_merge_autostash(repo)?;
1248 let _ = crate::rerere::finish_merge_rerere(repo)?;
1249 clear_merge_state(repo.git_dir())?;
1250 Ok(autostash_conflicts)
1251}
1252
1253pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1256 read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
1257}
1258
1259pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1261 read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
1262}
1263
1264fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
1265 match fs::read_to_string(path) {
1266 Ok(content) => {
1267 let trimmed = content.trim();
1268 if trimmed.is_empty() {
1269 Ok(None)
1270 } else {
1271 Ok(ObjectId::from_hex(trimmed).ok())
1272 }
1273 }
1274 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1275 Err(e) => Err(Error::Io(e)),
1276 }
1277}
1278
1279pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1289 read_single_oid_file(&git_dir.join("ORIG_HEAD"))
1290}
1291
1292pub fn read_orig_head_for_repository(repo: &Repository) -> Result<Option<ObjectId>> {
1302 repo.references().resolve_if_exists("ORIG_HEAD")
1303}
1304
1305pub fn write_orig_head(repo: &Repository, oid: ObjectId) -> Result<()> {
1316 crate::refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), "ORIG_HEAD", &oid)
1317}
1318
1319fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
1321 match fs::read_to_string(path) {
1322 Ok(content) => {
1323 let trimmed = content.trim();
1324 if trimmed.is_empty() {
1325 Ok(None)
1326 } else {
1327 Ok(Some(ObjectId::from_hex(trimmed)?))
1328 }
1329 }
1330 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1331 Err(e) => Err(Error::Io(e)),
1332 }
1333}
1334
1335#[cfg(test)]
1336mod merge_sentinel_tests {
1337 #![allow(clippy::unwrap_used)]
1338
1339 use super::{clear_merge_state, merge_completion_sentinels};
1340 use std::fs;
1341 use tempfile::TempDir;
1342
1343 #[test]
1344 fn clear_merge_state_removes_every_completion_sentinel() {
1345 let tmp = TempDir::new().unwrap();
1346 let git = tmp.path().join(".git");
1347 fs::create_dir_all(&git).unwrap();
1348 for name in merge_completion_sentinels() {
1349 fs::write(git.join(name), b"x").unwrap();
1350 }
1351 clear_merge_state(&git).unwrap();
1352 for name in merge_completion_sentinels() {
1353 assert!(!git.join(name).exists(), "expected {name} to be removed");
1354 }
1355 }
1356}
1357
1358#[cfg(test)]
1359mod head_state_tests {
1360 #![allow(clippy::unwrap_used)]
1361
1362 use super::{resolve_head, resolve_head_for_repository, HeadState};
1363 use crate::error::Error;
1364 use crate::objects::{ObjectId, ObjectKind};
1365 use crate::repo::init_repository;
1366 use crate::RefStorageFormat;
1367 use tempfile::TempDir;
1368
1369 fn unborn_main() -> HeadState {
1370 HeadState::Branch {
1371 refname: "refs/heads/main".to_owned(),
1372 short_name: "main".to_owned(),
1373 oid: None,
1374 }
1375 }
1376
1377 #[test]
1378 fn unborn_branch_resolves_without_oid_on_every_backend() {
1379 for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1380 let tmp = TempDir::new().unwrap();
1381 let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1382 assert_eq!(resolve_head(repo.git_dir()).unwrap(), unborn_main());
1383 assert_eq!(resolve_head_for_repository(&repo).unwrap(), unborn_main());
1384 }
1385 }
1386
1387 #[test]
1388 fn head_symref_to_missing_non_branch_is_invalid() {
1389 let tmp = TempDir::new().unwrap();
1390 let repo =
1391 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1392 std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1393 assert_eq!(resolve_head(repo.git_dir()).unwrap(), HeadState::Invalid);
1394 assert_eq!(
1395 resolve_head_for_repository(&repo).unwrap(),
1396 HeadState::Invalid
1397 );
1398 }
1399
1400 fn commit_on_head(repo: &crate::repo::Repository) -> (ObjectId, ObjectId) {
1401 let tree = repo.odb().write(ObjectKind::Tree, b"").unwrap();
1402 let body = format!(
1403 "tree {tree}\nauthor A <a@example.com> 0 +0000\ncommitter A <a@example.com> 0 +0000\n\nc\n"
1404 );
1405 let commit = repo
1406 .odb()
1407 .write(ObjectKind::Commit, body.as_bytes())
1408 .unwrap();
1409 std::fs::write(repo.git_dir().join("HEAD"), format!("{commit}\n")).unwrap();
1410 (commit, tree)
1411 }
1412
1413 #[test]
1414 fn head_tree_is_none_on_an_unborn_branch_on_every_backend() {
1415 for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1416 let tmp = TempDir::new().unwrap();
1417 let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1418 assert_eq!(repo.head_tree().unwrap(), None);
1419 assert_eq!(unborn_main().tree(repo.odb()).unwrap(), None);
1420 }
1421 }
1422
1423 #[test]
1424 fn head_tree_is_the_tree_of_the_head_commit() {
1425 let tmp = TempDir::new().unwrap();
1426 let repo =
1427 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1428 let (commit, tree) = commit_on_head(&repo);
1429 assert_eq!(repo.head_tree().unwrap(), Some(tree));
1430 assert_eq!(
1431 HeadState::Detached { oid: commit }
1432 .tree(repo.odb())
1433 .unwrap(),
1434 Some(tree)
1435 );
1436 }
1437
1438 #[test]
1439 fn invalid_head_is_an_error_for_head_tree_and_none_for_head_state_tree() {
1440 let tmp = TempDir::new().unwrap();
1441 let repo =
1442 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1443 std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1444 assert!(matches!(repo.head_tree(), Err(Error::InvalidHead)));
1445 assert_eq!(HeadState::Invalid.tree(repo.odb()).unwrap(), None);
1446 }
1447
1448 #[test]
1449 fn head_on_a_non_commit_is_an_unexpected_object_kind() {
1450 let tmp = TempDir::new().unwrap();
1451 let repo =
1452 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1453 let blob = repo.odb().write(ObjectKind::Blob, b"data").unwrap();
1454 std::fs::write(repo.git_dir().join("HEAD"), format!("{blob}\n")).unwrap();
1455 assert!(matches!(
1456 repo.head_tree(),
1457 Err(Error::UnexpectedObjectKind {
1458 expected: ObjectKind::Commit,
1459 actual: ObjectKind::Blob,
1460 ..
1461 })
1462 ));
1463 }
1464}
1465
1466#[cfg(test)]
1467mod switch_guard_tests {
1468 #![allow(clippy::unwrap_used)]
1469
1470 use super::{operation_blocking_switch, InProgressOperation};
1471 use std::fs;
1472 use tempfile::TempDir;
1473
1474 fn blocking(setup: impl FnOnce(&std::path::Path)) -> Option<InProgressOperation> {
1475 let tmp = TempDir::new().unwrap();
1476 let git = tmp.path().join(".git");
1477 fs::create_dir_all(&git).unwrap();
1478 setup(&git);
1479 operation_blocking_switch(&git).unwrap()
1480 }
1481
1482 #[test]
1483 fn clean_and_bisect_do_not_block_switch() {
1484 assert_eq!(blocking(|_| {}), None);
1485 assert_eq!(
1486 blocking(|git| fs::write(git.join("BISECT_LOG"), "").unwrap()),
1487 None
1488 );
1489 }
1490
1491 #[test]
1492 fn paused_operations_block_switch() {
1493 let oid = "1111111111111111111111111111111111111111\n";
1494 assert_eq!(
1495 blocking(|git| fs::write(git.join("MERGE_HEAD"), oid).unwrap()),
1496 Some(InProgressOperation::Merge)
1497 );
1498 assert_eq!(
1499 blocking(|git| {
1500 fs::create_dir_all(git.join("rebase-merge")).unwrap();
1501 fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1502 }),
1503 Some(InProgressOperation::RebaseInteractive)
1504 );
1505 assert_eq!(
1506 blocking(|git| {
1507 fs::create_dir_all(git.join("rebase-merge")).unwrap();
1508 fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1509 fs::write(git.join("rebase-merge/grit-plan"), "{}").unwrap();
1510 }),
1511 Some(InProgressOperation::Rebase)
1512 );
1513 assert_eq!(
1514 blocking(|git| fs::create_dir_all(git.join("rebase-apply")).unwrap()),
1515 Some(InProgressOperation::Rebase)
1516 );
1517 assert_eq!(
1518 blocking(|git| {
1519 fs::create_dir_all(git.join("rebase-apply")).unwrap();
1520 fs::write(git.join("rebase-apply/applying"), "").unwrap();
1521 }),
1522 Some(InProgressOperation::Am)
1523 );
1524 assert_eq!(
1525 blocking(|git| fs::write(git.join("CHERRY_PICK_HEAD"), oid).unwrap()),
1526 Some(InProgressOperation::CherryPick)
1527 );
1528 assert_eq!(
1529 blocking(|git| fs::write(git.join("REVERT_HEAD"), oid).unwrap()),
1530 Some(InProgressOperation::Revert)
1531 );
1532 }
1533}