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 abbrev_oid(oid: &ObjectId) -> String {
805 oid.to_hex()[..7].to_string()
806}
807
808fn read_trimmed_line(path: &Path) -> Option<String> {
809 let s = fs::read_to_string(path).ok()?;
810 let mut line = s.lines().next()?.to_string();
811 while line.ends_with('\n') || line.ends_with('\r') {
812 line.pop();
813 }
814 if line.is_empty() {
815 None
816 } else {
817 Some(line)
818 }
819}
820
821fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
823 let path = git_dir.join(rel);
824 let mut sb = read_trimmed_line(&path)?;
825 if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
826 sb = branch_name.to_string();
827 } else if sb.starts_with("refs/") {
828 } else if ObjectId::from_hex(&sb).is_ok() {
830 let oid = ObjectId::from_hex(&sb).ok()?;
831 sb = abbrev_oid(&oid);
832 } else if sb == "detached HEAD" {
833 return None;
834 }
835 Some(sb)
836}
837
838fn strip_ref_for_display(full: &str) -> String {
839 if let Some(s) = full.strip_prefix("refs/tags/") {
840 return s.to_string();
841 }
842 if let Some(s) = full.strip_prefix("refs/remotes/") {
843 return s.to_string();
844 }
845 if let Some(s) = full.strip_prefix("refs/heads/") {
846 return s.to_string();
847 }
848 full.to_string()
849}
850
851fn ref_name_exists(git_dir: &Path, refname: &str) -> bool {
852 git_dir.join(refname).exists()
853 || crate::refs::packed_refs_entry_exists(git_dir, refname).unwrap_or(false)
854}
855
856fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
857 if target == "HEAD" {
858 return abbrev_oid(&noid);
859 }
860 if target.starts_with("refs/") {
861 if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
862 if oid == noid {
863 return strip_ref_for_display(target);
864 }
865 }
866 }
867 for candidate in [
868 format!("refs/heads/{target}"),
869 format!("refs/tags/{target}"),
870 format!("refs/remotes/{target}"),
871 ] {
872 if candidate.starts_with("refs/tags/") && ref_name_exists(git_dir, &candidate) {
873 return strip_ref_for_display(&candidate);
874 }
875 if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
876 if oid == noid {
877 return strip_ref_for_display(&candidate);
878 }
879 }
880 }
881 if ObjectId::is_hex_len(target.len()) {
882 if let Ok(oid) = ObjectId::from_hex(target) {
883 if oid == noid {
884 return abbrev_oid(&noid);
885 }
886 }
887 }
888 if !target.is_empty()
891 && target.chars().all(|c| c.is_ascii_hexdigit())
892 && target.len() <= noid.algo().hex_len()
893 && noid.to_hex().starts_with(target)
894 {
895 return target.to_owned();
896 }
897 abbrev_oid(&noid)
898}
899
900fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
901 let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
902 for entry in entries.iter().rev() {
903 let msg = entry.message.trim();
904 let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
905 continue;
906 };
907 let Some(idx) = rest.rfind(" to ") else {
908 continue;
909 };
910 let target = rest[idx + 4..].trim();
911 let noid = entry.new_oid;
912 let label = dwim_detach_label(git_dir, target, noid);
913 let detached_at = head_oid == noid;
914 return Some((label, detached_at));
915 }
916 None
917}
918
919fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
920 let apply = git_dir.join("rebase-apply");
921 if apply.is_dir() {
922 if apply.join("applying").exists() {
923 state.am_in_progress = true;
924 let patch = apply.join("patch");
925 if let Ok(meta) = patch.metadata() {
926 if meta.len() == 0 {
927 state.am_empty_patch = true;
928 }
929 }
930 } else {
931 state.rebase_in_progress = true;
932 state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
933 state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
934 }
935 return true;
936 }
937 let merge = git_dir.join("rebase-merge");
938 if merge.is_dir() {
939 let grit_plan = grit_rebase_plan_active(&merge);
940 if merge.join("interactive").exists() && !grit_plan {
941 state.rebase_interactive_in_progress = true;
942 } else {
943 state.rebase_in_progress = true;
944 }
945 state.grit_rebase_in_progress = grit_plan;
946 state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
947 state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
948 return true;
949 }
950 false
951}
952
953fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
954 let path = git_dir.join("sequencer").join("todo");
955 if !path.is_file() {
956 return None;
957 }
958 let content = fs::read_to_string(&path).ok()?;
959 for line in content.lines() {
960 let t = line.trim();
961 if t.is_empty() || t.starts_with('#') {
962 continue;
963 }
964 let mut parts = t.split_whitespace();
965 let cmd = parts.next()?;
966 return match cmd {
967 "pick" | "p" => Some(true),
968 "revert" | "r" => Some(false),
969 _ => None,
970 };
971 }
972 None
973}
974
975pub fn wt_status_get_state(
980 git_dir: &Path,
981 head: &HeadState,
982 get_detached_from: bool,
983) -> Result<WtStatusState> {
984 let mut state = WtStatusState::default();
985
986 if git_dir.join("MERGE_HEAD").exists() {
987 wt_status_check_rebase(git_dir, &mut state);
988 state.merge_in_progress = true;
989 } else if wt_status_check_rebase(git_dir, &mut state) {
990 } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
992 state.cherry_pick_in_progress = true;
993 state.cherry_pick_head_oid = Some(oid);
994 }
995
996 let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
997 if bisect_base.join("BISECT_LOG").exists() {
998 state.bisect_in_progress = true;
999 state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
1000 }
1001
1002 if let Some(oid) = read_revert_head(git_dir)? {
1003 state.revert_in_progress = true;
1004 state.revert_head_oid = Some(oid);
1005 }
1006
1007 if let Some(is_pick) = sequencer_first_replay(git_dir) {
1008 if is_pick && !state.cherry_pick_in_progress {
1009 state.cherry_pick_in_progress = true;
1010 state.cherry_pick_head_oid = None;
1011 } else if !is_pick && !state.revert_in_progress {
1012 state.revert_in_progress = true;
1013 state.revert_head_oid = None;
1014 }
1015 }
1016
1017 if get_detached_from {
1018 if let HeadState::Detached { oid } = head {
1019 if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
1020 state.detached_from = Some(label);
1021 state.detached_at = at;
1022 }
1023 }
1024 }
1025
1026 state.rewrite_apply_in_progress = crate::history::rewrite_apply_journal_active(git_dir);
1027
1028 state.grit_replay_sequencer = git_dir.join("sequencer").join("grit-opts").is_file();
1029 if state.grit_replay_sequencer {
1030 if let Some((kind, empty_stop)) = read_grit_replay_status_hints(git_dir) {
1031 state.grit_replay_kind = Some(kind);
1032 state.grit_replay_empty_stop = empty_stop;
1033 match kind {
1034 InProgressOperation::CherryPick => {
1035 state.revert_in_progress = false;
1036 state.revert_head_oid = None;
1037 if !state.cherry_pick_in_progress {
1038 state.cherry_pick_in_progress = true;
1039 }
1040 }
1041 InProgressOperation::Revert => {
1042 state.cherry_pick_in_progress = false;
1043 state.cherry_pick_head_oid = None;
1044 if !state.revert_in_progress {
1045 state.revert_in_progress = true;
1046 }
1047 }
1048 _ => {}
1049 }
1050 }
1051 }
1052
1053 Ok(state)
1054}
1055
1056fn read_grit_replay_status_hints(git_dir: &Path) -> Option<(InProgressOperation, bool)> {
1057 let path = git_dir.join("sequencer").join("grit-opts");
1058 let content = fs::read_to_string(&path).ok()?;
1059 let mut direction = None;
1060 let mut empty = "stop";
1061 for line in content.lines() {
1062 let line = line.trim();
1063 let Some((key, value)) = line.split_once('=') else {
1064 continue;
1065 };
1066 match key.trim() {
1067 "direction" => {
1068 direction = Some(if value.trim() == "revert" {
1069 InProgressOperation::Revert
1070 } else {
1071 InProgressOperation::CherryPick
1072 });
1073 }
1074 "empty" => empty = value.trim(),
1075 _ => {}
1076 }
1077 }
1078 let kind = direction?;
1079 let empty_stop = empty == "stop"
1080 && (read_cherry_pick_head(git_dir).ok().flatten().is_some()
1081 || read_revert_head(git_dir).ok().flatten().is_some());
1082 Some((kind, empty_stop))
1083}
1084
1085pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
1087 let HeadState::Detached { oid: head_oid } = head else {
1088 return false;
1089 };
1090 let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
1091 return false;
1092 };
1093 let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
1094 return false;
1095 };
1096 let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
1097 return false;
1098 };
1099 let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
1100 return false;
1101 };
1102 if amend_line == orig_line {
1103 head_oid != &amend_oid
1104 } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
1105 cur_orig != orig_head_oid
1106 } else {
1107 false
1108 }
1109}
1110
1111pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
1122 let head = resolve_head(git_dir)?;
1123 let in_progress = detect_in_progress(git_dir);
1124
1125 Ok(RepoState {
1126 head,
1127 in_progress,
1128 is_bare,
1129 })
1130}
1131
1132pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
1142 let path = git_dir.join("MERGE_HEAD");
1143 let content = match fs::read_to_string(&path) {
1144 Ok(c) => c,
1145 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1146 Err(e) => return Err(Error::Io(e)),
1147 };
1148
1149 let mut oids = Vec::new();
1150 for line in content.lines() {
1151 let trimmed = line.trim();
1152 if !trimmed.is_empty() {
1153 oids.push(ObjectId::from_hex(trimmed)?);
1154 }
1155 }
1156 Ok(oids)
1157}
1158
1159pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
1169 let path = git_dir.join("MERGE_MSG");
1170 match fs::read_to_string(&path) {
1171 Ok(c) => Ok(Some(c)),
1172 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1173 Err(e) => Err(Error::Io(e)),
1174 }
1175}
1176
1177pub fn clear_merge_state(git_dir: &Path) -> Result<()> {
1189 for name in merge_completion_sentinels() {
1190 let path = git_dir.join(name);
1191 if path.exists() {
1192 fs::remove_file(&path).map_err(Error::Io)?;
1193 }
1194 }
1195 Ok(())
1196}
1197
1198pub fn clear_replay_state(git_dir: &Path) -> Result<()> {
1211 for name in ["CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_MSG"] {
1212 let path = git_dir.join(name);
1213 if path.exists() {
1214 fs::remove_file(&path).map_err(Error::Io)?;
1215 }
1216 }
1217 let seq = git_dir.join("sequencer");
1218 if seq.is_dir() {
1219 fs::remove_dir_all(&seq).map_err(Error::Io)?;
1220 }
1221 Ok(())
1222}
1223
1224#[must_use]
1226pub fn merge_completion_sentinels() -> &'static [&'static str] {
1227 &[
1228 "MERGE_HEAD",
1229 "MERGE_MSG",
1230 "MERGE_MODE",
1231 "MERGE_LOG",
1232 "AUTO_MERGE",
1233 "SQUASH_MSG",
1234 "MERGE_AUTOSTASH",
1235 ]
1236}
1237
1238pub fn finish_merge_state(repo: &Repository) -> Result<bool> {
1251 let autostash_conflicts = crate::porcelain::stash::apply_merge_autostash(repo)?;
1252 let _ = crate::rerere::finish_merge_rerere(repo)?;
1253 clear_merge_state(repo.git_dir())?;
1254 Ok(autostash_conflicts)
1255}
1256
1257pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1260 read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
1261}
1262
1263pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1265 read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
1266}
1267
1268fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
1269 match fs::read_to_string(path) {
1270 Ok(content) => {
1271 let trimmed = content.trim();
1272 if trimmed.is_empty() {
1273 Ok(None)
1274 } else {
1275 Ok(ObjectId::from_hex(trimmed).ok())
1276 }
1277 }
1278 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1279 Err(e) => Err(Error::Io(e)),
1280 }
1281}
1282
1283pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
1293 read_single_oid_file(&git_dir.join("ORIG_HEAD"))
1294}
1295
1296pub fn read_orig_head_for_repository(repo: &Repository) -> Result<Option<ObjectId>> {
1306 repo.references().resolve_if_exists("ORIG_HEAD")
1307}
1308
1309pub fn write_orig_head(repo: &Repository, oid: ObjectId) -> Result<()> {
1320 crate::refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), "ORIG_HEAD", &oid)
1321}
1322
1323fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
1325 match fs::read_to_string(path) {
1326 Ok(content) => {
1327 let trimmed = content.trim();
1328 if trimmed.is_empty() {
1329 Ok(None)
1330 } else {
1331 Ok(Some(ObjectId::from_hex(trimmed)?))
1332 }
1333 }
1334 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1335 Err(e) => Err(Error::Io(e)),
1336 }
1337}
1338
1339#[cfg(test)]
1340mod merge_sentinel_tests {
1341 #![allow(clippy::unwrap_used)]
1342
1343 use super::{clear_merge_state, merge_completion_sentinels};
1344 use std::fs;
1345 use tempfile::TempDir;
1346
1347 #[test]
1348 fn clear_merge_state_removes_every_completion_sentinel() {
1349 let tmp = TempDir::new().unwrap();
1350 let git = tmp.path().join(".git");
1351 fs::create_dir_all(&git).unwrap();
1352 for name in merge_completion_sentinels() {
1353 fs::write(git.join(name), b"x").unwrap();
1354 }
1355 clear_merge_state(&git).unwrap();
1356 for name in merge_completion_sentinels() {
1357 assert!(!git.join(name).exists(), "expected {name} to be removed");
1358 }
1359 }
1360}
1361
1362#[cfg(test)]
1363mod head_state_tests {
1364 #![allow(clippy::unwrap_used)]
1365
1366 use super::{resolve_head, resolve_head_for_repository, HeadState};
1367 use crate::error::Error;
1368 use crate::objects::{ObjectId, ObjectKind};
1369 use crate::repo::init_repository;
1370 use crate::RefStorageFormat;
1371 use tempfile::TempDir;
1372
1373 fn unborn_main() -> HeadState {
1374 HeadState::Branch {
1375 refname: "refs/heads/main".to_owned(),
1376 short_name: "main".to_owned(),
1377 oid: None,
1378 }
1379 }
1380
1381 #[test]
1382 fn unborn_branch_resolves_without_oid_on_every_backend() {
1383 for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1384 let tmp = TempDir::new().unwrap();
1385 let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1386 assert_eq!(resolve_head(repo.git_dir()).unwrap(), unborn_main());
1387 assert_eq!(resolve_head_for_repository(&repo).unwrap(), unborn_main());
1388 }
1389 }
1390
1391 #[test]
1392 fn head_symref_to_missing_non_branch_is_invalid() {
1393 let tmp = TempDir::new().unwrap();
1394 let repo =
1395 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1396 std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1397 assert_eq!(resolve_head(repo.git_dir()).unwrap(), HeadState::Invalid);
1398 assert_eq!(
1399 resolve_head_for_repository(&repo).unwrap(),
1400 HeadState::Invalid
1401 );
1402 }
1403
1404 fn commit_on_head(repo: &crate::repo::Repository) -> (ObjectId, ObjectId) {
1405 let tree = repo.odb().write(ObjectKind::Tree, b"").unwrap();
1406 let body = format!(
1407 "tree {tree}\nauthor A <a@example.com> 0 +0000\ncommitter A <a@example.com> 0 +0000\n\nc\n"
1408 );
1409 let commit = repo
1410 .odb()
1411 .write(ObjectKind::Commit, body.as_bytes())
1412 .unwrap();
1413 std::fs::write(repo.git_dir().join("HEAD"), format!("{commit}\n")).unwrap();
1414 (commit, tree)
1415 }
1416
1417 #[test]
1418 fn head_tree_is_none_on_an_unborn_branch_on_every_backend() {
1419 for format in [RefStorageFormat::Files, RefStorageFormat::Reftable] {
1420 let tmp = TempDir::new().unwrap();
1421 let repo = init_repository(tmp.path(), false, "main", None, format).unwrap();
1422 assert_eq!(repo.head_tree().unwrap(), None);
1423 assert_eq!(unborn_main().tree(repo.odb()).unwrap(), None);
1424 }
1425 }
1426
1427 #[test]
1428 fn head_tree_is_the_tree_of_the_head_commit() {
1429 let tmp = TempDir::new().unwrap();
1430 let repo =
1431 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1432 let (commit, tree) = commit_on_head(&repo);
1433 assert_eq!(repo.head_tree().unwrap(), Some(tree));
1434 assert_eq!(
1435 HeadState::Detached { oid: commit }
1436 .tree(repo.odb())
1437 .unwrap(),
1438 Some(tree)
1439 );
1440 }
1441
1442 #[test]
1443 fn invalid_head_is_an_error_for_head_tree_and_none_for_head_state_tree() {
1444 let tmp = TempDir::new().unwrap();
1445 let repo =
1446 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1447 std::fs::write(repo.git_dir().join("HEAD"), "ref: refs/tags/missing\n").unwrap();
1448 assert!(matches!(repo.head_tree(), Err(Error::InvalidHead)));
1449 assert_eq!(HeadState::Invalid.tree(repo.odb()).unwrap(), None);
1450 }
1451
1452 #[test]
1453 fn head_on_a_non_commit_is_an_unexpected_object_kind() {
1454 let tmp = TempDir::new().unwrap();
1455 let repo =
1456 init_repository(tmp.path(), false, "main", None, RefStorageFormat::Files).unwrap();
1457 let blob = repo.odb().write(ObjectKind::Blob, b"data").unwrap();
1458 std::fs::write(repo.git_dir().join("HEAD"), format!("{blob}\n")).unwrap();
1459 assert!(matches!(
1460 repo.head_tree(),
1461 Err(Error::UnexpectedObjectKind {
1462 expected: ObjectKind::Commit,
1463 actual: ObjectKind::Blob,
1464 ..
1465 })
1466 ));
1467 }
1468}
1469
1470#[cfg(test)]
1471mod switch_guard_tests {
1472 #![allow(clippy::unwrap_used)]
1473
1474 use super::{operation_blocking_switch, InProgressOperation};
1475 use std::fs;
1476 use tempfile::TempDir;
1477
1478 fn blocking(setup: impl FnOnce(&std::path::Path)) -> Option<InProgressOperation> {
1479 let tmp = TempDir::new().unwrap();
1480 let git = tmp.path().join(".git");
1481 fs::create_dir_all(&git).unwrap();
1482 setup(&git);
1483 operation_blocking_switch(&git).unwrap()
1484 }
1485
1486 #[test]
1487 fn clean_and_bisect_do_not_block_switch() {
1488 assert_eq!(blocking(|_| {}), None);
1489 assert_eq!(
1490 blocking(|git| fs::write(git.join("BISECT_LOG"), "").unwrap()),
1491 None
1492 );
1493 }
1494
1495 #[test]
1496 fn paused_operations_block_switch() {
1497 let oid = "1111111111111111111111111111111111111111\n";
1498 assert_eq!(
1499 blocking(|git| fs::write(git.join("MERGE_HEAD"), oid).unwrap()),
1500 Some(InProgressOperation::Merge)
1501 );
1502 assert_eq!(
1503 blocking(|git| {
1504 fs::create_dir_all(git.join("rebase-merge")).unwrap();
1505 fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1506 }),
1507 Some(InProgressOperation::RebaseInteractive)
1508 );
1509 assert_eq!(
1510 blocking(|git| {
1511 fs::create_dir_all(git.join("rebase-merge")).unwrap();
1512 fs::write(git.join("rebase-merge/interactive"), "").unwrap();
1513 fs::write(git.join("rebase-merge/grit-plan"), "{}").unwrap();
1514 }),
1515 Some(InProgressOperation::Rebase)
1516 );
1517 assert_eq!(
1518 blocking(|git| fs::create_dir_all(git.join("rebase-apply")).unwrap()),
1519 Some(InProgressOperation::Rebase)
1520 );
1521 assert_eq!(
1522 blocking(|git| {
1523 fs::create_dir_all(git.join("rebase-apply")).unwrap();
1524 fs::write(git.join("rebase-apply/applying"), "").unwrap();
1525 }),
1526 Some(InProgressOperation::Am)
1527 );
1528 assert_eq!(
1529 blocking(|git| fs::write(git.join("CHERRY_PICK_HEAD"), oid).unwrap()),
1530 Some(InProgressOperation::CherryPick)
1531 );
1532 assert_eq!(
1533 blocking(|git| fs::write(git.join("REVERT_HEAD"), oid).unwrap()),
1534 Some(InProgressOperation::Revert)
1535 );
1536 }
1537}