1use std::fs;
16use std::path::Path;
17
18use crate::check_ref_format::{check_refname_format, RefNameOptions};
19use crate::error::{Error, Result};
20use crate::objects::ObjectId;
21use crate::reflog;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum HeadState {
26 Branch {
28 refname: String,
30 short_name: String,
32 oid: Option<ObjectId>,
35 },
36 Detached {
38 oid: ObjectId,
40 },
41 Invalid,
43}
44
45impl HeadState {
46 #[must_use]
48 pub fn oid(&self) -> Option<&ObjectId> {
49 match self {
50 Self::Branch { oid, .. } => oid.as_ref(),
51 Self::Detached { oid } => Some(oid),
52 Self::Invalid => None,
53 }
54 }
55
56 #[must_use]
58 pub fn branch_name(&self) -> Option<&str> {
59 match self {
60 Self::Branch { short_name, .. } => Some(short_name),
61 _ => None,
62 }
63 }
64
65 #[must_use]
67 pub fn is_unborn(&self) -> bool {
68 matches!(self, Self::Branch { oid: None, .. })
69 }
70
71 #[must_use]
73 pub fn is_detached(&self) -> bool {
74 matches!(self, Self::Detached { .. })
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum InProgressOperation {
81 Merge,
83 RebaseInteractive,
85 Rebase,
87 CherryPick,
89 Revert,
91 Bisect,
93 Am,
95}
96
97impl InProgressOperation {
98 #[must_use]
100 pub fn description(&self) -> &'static str {
101 match self {
102 Self::Merge => "merge",
103 Self::RebaseInteractive => "interactive rebase",
104 Self::Rebase => "rebase",
105 Self::CherryPick => "cherry-pick",
106 Self::Revert => "revert",
107 Self::Bisect => "bisect",
108 Self::Am => "am",
109 }
110 }
111
112 #[must_use]
114 pub fn hint(&self) -> &'static str {
115 match self {
116 Self::Merge => "fix conflicts and run \"git commit\"\n (use \"git merge --abort\" to abort the merge)",
117 Self::RebaseInteractive => "fix conflicts and then run \"git rebase --continue\"\n (use \"git rebase --abort\" to abort the rebase)",
118 Self::Rebase => "fix conflicts and then run \"git rebase --continue\"\n (use \"git rebase --abort\" to abort the rebase)",
119 Self::CherryPick => "fix conflicts and run \"git cherry-pick --continue\"\n (use \"git cherry-pick --abort\" to abort the cherry-pick)",
120 Self::Revert => "fix conflicts and run \"git revert --continue\"\n (use \"git revert --abort\" to abort the revert)",
121 Self::Bisect => "use \"git bisect reset\" to get back to the original branch",
122 Self::Am => "fix conflicts and then run \"git am --continue\"\n (use \"git am --abort\" to abort the am)",
123 }
124 }
125}
126
127#[derive(Debug, Clone)]
132pub struct RepoState {
133 pub head: HeadState,
135 pub in_progress: Vec<InProgressOperation>,
137 pub is_bare: bool,
139}
140
141pub fn resolve_head(git_dir: &Path) -> Result<HeadState> {
153 let head_path = git_dir.join("HEAD");
154 let content = match fs::read_link(&head_path) {
155 Ok(link_target) => {
156 let rendered = link_target.to_string_lossy();
157 if link_target.is_absolute() {
158 format!("ref: {rendered}")
159 } else if rendered.starts_with("refs/") {
160 format!("ref: {rendered}")
161 } else {
162 fs::read_to_string(&head_path).map_err(Error::Io)?
163 }
164 }
165 Err(_) => match fs::read_to_string(&head_path) {
166 Ok(c) => c,
167 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HeadState::Invalid),
168 Err(e) => return Err(Error::Io(e)),
169 },
170 };
171
172 let trimmed = content.trim();
173
174 if let Some(refname) = trimmed.strip_prefix("ref: ") {
175 let refname = if refname == "refs/heads/.invalid" {
176 match crate::refs::read_ref_file(&git_dir.join("refs").join("heads")) {
177 Ok(crate::refs::Ref::Symbolic(target)) => target,
178 _ => refname.to_owned(),
179 }
180 } else {
181 refname.to_owned()
182 };
183 if check_refname_format(&refname, &RefNameOptions::default()).is_err() {
184 return Ok(HeadState::Invalid);
185 }
186 let short_name = refname
187 .strip_prefix("refs/heads/")
188 .unwrap_or(&refname)
189 .to_owned();
190
191 let oid = match crate::refs::resolve_ref(git_dir, &refname) {
196 Ok(oid) => Some(oid),
197 Err(Error::InvalidRef(msg)) if msg.starts_with("ref not found:") => {
198 if refname.starts_with("refs/heads/") {
199 None
200 } else {
201 return Ok(HeadState::Invalid);
202 }
203 }
204 Err(e) => return Err(e),
205 };
206
207 Ok(HeadState::Branch {
208 refname,
209 short_name,
210 oid,
211 })
212 } else {
213 match ObjectId::from_hex(trimmed) {
215 Ok(oid) => Ok(HeadState::Detached { oid }),
216 Err(_) => Ok(HeadState::Invalid),
217 }
218 }
219}
220
221pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
231 let mut ops = Vec::new();
232
233 if git_dir.join("MERGE_HEAD").exists() {
234 ops.push(InProgressOperation::Merge);
235 }
236
237 let rebase_merge = git_dir.join("rebase-merge");
239 if rebase_merge.is_dir() {
240 if rebase_merge.join("interactive").exists() {
241 ops.push(InProgressOperation::RebaseInteractive);
242 } else {
243 ops.push(InProgressOperation::Rebase);
244 }
245 }
246
247 let rebase_apply = git_dir.join("rebase-apply");
249 if rebase_apply.is_dir() {
250 if rebase_apply.join("applying").exists() {
251 ops.push(InProgressOperation::Am);
252 } else {
253 ops.push(InProgressOperation::Rebase);
254 }
255 }
256
257 if git_dir.join("CHERRY_PICK_HEAD").exists() {
258 ops.push(InProgressOperation::CherryPick);
259 }
260
261 if git_dir.join("REVERT_HEAD").exists() {
262 ops.push(InProgressOperation::Revert);
263 }
264
265 let bisect_log = crate::refs::common_dir(git_dir)
266 .unwrap_or_else(|| git_dir.to_path_buf())
267 .join("BISECT_LOG");
268 if bisect_log.exists() {
269 ops.push(InProgressOperation::Bisect);
270 }
271
272 ops
273}
274
275#[derive(Debug, Clone, Default)]
280pub struct WtStatusState {
281 pub merge_in_progress: bool,
283 pub rebase_interactive_in_progress: bool,
285 pub rebase_in_progress: bool,
287 pub rebase_branch: Option<String>,
289 pub rebase_onto: Option<String>,
291 pub am_in_progress: bool,
293 pub am_empty_patch: bool,
295 pub cherry_pick_in_progress: bool,
297 pub cherry_pick_head_oid: Option<ObjectId>,
299 pub revert_in_progress: bool,
301 pub revert_head_oid: Option<ObjectId>,
302 pub bisect_in_progress: bool,
304 pub bisecting_from: Option<String>,
305 pub detached_from: Option<String>,
307 pub detached_at: bool,
309}
310
311fn abbrev_oid(oid: &ObjectId) -> String {
312 oid.to_hex()[..7].to_string()
313}
314
315fn read_trimmed_line(path: &Path) -> Option<String> {
316 let s = fs::read_to_string(path).ok()?;
317 let mut line = s.lines().next()?.to_string();
318 while line.ends_with('\n') || line.ends_with('\r') {
319 line.pop();
320 }
321 if line.is_empty() {
322 None
323 } else {
324 Some(line)
325 }
326}
327
328fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
330 let path = git_dir.join(rel);
331 let mut sb = read_trimmed_line(&path)?;
332 if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
333 sb = branch_name.to_string();
334 } else if sb.starts_with("refs/") {
335 } else if ObjectId::from_hex(&sb).is_ok() {
337 let oid = ObjectId::from_hex(&sb).ok()?;
338 sb = abbrev_oid(&oid);
339 } else if sb == "detached HEAD" {
340 return None;
341 }
342 Some(sb)
343}
344
345fn strip_ref_for_display(full: &str) -> String {
346 if let Some(s) = full.strip_prefix("refs/tags/") {
347 return s.to_string();
348 }
349 if let Some(s) = full.strip_prefix("refs/remotes/") {
350 return s.to_string();
351 }
352 if let Some(s) = full.strip_prefix("refs/heads/") {
353 return s.to_string();
354 }
355 full.to_string()
356}
357
358fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
359 if target == "HEAD" {
360 return abbrev_oid(&noid);
361 }
362 if target.starts_with("refs/") {
363 if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
364 if oid == noid {
365 return strip_ref_for_display(target);
366 }
367 }
368 }
369 for candidate in [
370 format!("refs/heads/{target}"),
371 format!("refs/tags/{target}"),
372 format!("refs/remotes/{target}"),
373 ] {
374 if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
375 if oid == noid {
376 return strip_ref_for_display(&candidate);
377 }
378 }
379 }
380 if target.len() == 40 {
381 if let Ok(oid) = ObjectId::from_hex(target) {
382 if oid == noid {
383 return abbrev_oid(&noid);
384 }
385 }
386 }
387 if !target.is_empty()
390 && target.chars().all(|c| c.is_ascii_hexdigit())
391 && target.len() <= 40
392 && noid.to_hex().starts_with(target)
393 {
394 return target.to_owned();
395 }
396 abbrev_oid(&noid)
397}
398
399fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
400 let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
401 for entry in entries.iter().rev() {
402 let msg = entry.message.trim();
403 let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
404 continue;
405 };
406 let Some(idx) = rest.rfind(" to ") else {
407 continue;
408 };
409 let target = rest[idx + 4..].trim();
410 let noid = entry.new_oid;
411 let label = dwim_detach_label(git_dir, target, noid);
412 let detached_at = head_oid == noid;
413 return Some((label, detached_at));
414 }
415 None
416}
417
418fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
419 let apply = git_dir.join("rebase-apply");
420 if apply.is_dir() {
421 if apply.join("applying").exists() {
422 state.am_in_progress = true;
423 let patch = apply.join("patch");
424 if let Ok(meta) = patch.metadata() {
425 if meta.len() == 0 {
426 state.am_empty_patch = true;
427 }
428 }
429 } else {
430 state.rebase_in_progress = true;
431 state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
432 state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
433 }
434 return true;
435 }
436 let merge = git_dir.join("rebase-merge");
437 if merge.is_dir() {
438 if merge.join("interactive").exists() {
439 state.rebase_interactive_in_progress = true;
440 } else {
441 state.rebase_in_progress = true;
442 }
443 state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
444 state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
445 return true;
446 }
447 false
448}
449
450fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
451 let path = git_dir.join("sequencer").join("todo");
452 if !path.is_file() {
453 return None;
454 }
455 let content = fs::read_to_string(&path).ok()?;
456 for line in content.lines() {
457 let t = line.trim();
458 if t.is_empty() || t.starts_with('#') {
459 continue;
460 }
461 let mut parts = t.split_whitespace();
462 let cmd = parts.next()?;
463 return Some(matches!(cmd, "pick" | "p" | "revert" | "r"));
464 }
465 None
466}
467
468pub fn wt_status_get_state(
473 git_dir: &Path,
474 head: &HeadState,
475 get_detached_from: bool,
476) -> Result<WtStatusState> {
477 let mut state = WtStatusState::default();
478
479 if git_dir.join("MERGE_HEAD").exists() {
480 wt_status_check_rebase(git_dir, &mut state);
481 state.merge_in_progress = true;
482 } else if wt_status_check_rebase(git_dir, &mut state) {
483 } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
485 state.cherry_pick_in_progress = true;
486 state.cherry_pick_head_oid = Some(oid);
487 }
488
489 let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
490 if bisect_base.join("BISECT_LOG").exists() {
491 state.bisect_in_progress = true;
492 state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
493 }
494
495 if let Some(oid) = read_revert_head(git_dir)? {
496 state.revert_in_progress = true;
497 state.revert_head_oid = Some(oid);
498 }
499
500 if let Some(is_pick) = sequencer_first_replay(git_dir) {
501 if is_pick && !state.cherry_pick_in_progress {
502 state.cherry_pick_in_progress = true;
503 state.cherry_pick_head_oid = None;
504 } else if !is_pick && !state.revert_in_progress {
505 state.revert_in_progress = true;
506 state.revert_head_oid = None;
507 }
508 }
509
510 if get_detached_from {
511 if let HeadState::Detached { oid } = head {
512 if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
513 state.detached_from = Some(label);
514 state.detached_at = at;
515 }
516 }
517 }
518
519 Ok(state)
520}
521
522pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
524 let HeadState::Detached { oid: head_oid } = head else {
525 return false;
526 };
527 let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
528 return false;
529 };
530 let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
531 return false;
532 };
533 let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
534 return false;
535 };
536 let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
537 return false;
538 };
539 if amend_line == orig_line {
540 head_oid != &amend_oid
541 } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
542 cur_orig != orig_head_oid
543 } else {
544 false
545 }
546}
547
548pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
559 let head = resolve_head(git_dir)?;
560 let in_progress = detect_in_progress(git_dir);
561
562 Ok(RepoState {
563 head,
564 in_progress,
565 is_bare,
566 })
567}
568
569pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
579 let path = git_dir.join("MERGE_HEAD");
580 let content = match fs::read_to_string(&path) {
581 Ok(c) => c,
582 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
583 Err(e) => return Err(Error::Io(e)),
584 };
585
586 let mut oids = Vec::new();
587 for line in content.lines() {
588 let trimmed = line.trim();
589 if !trimmed.is_empty() {
590 oids.push(ObjectId::from_hex(trimmed)?);
591 }
592 }
593 Ok(oids)
594}
595
596pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
606 let path = git_dir.join("MERGE_MSG");
607 match fs::read_to_string(&path) {
608 Ok(c) => Ok(Some(c)),
609 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
610 Err(e) => Err(Error::Io(e)),
611 }
612}
613
614pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
617 read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
618}
619
620pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
622 read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
623}
624
625fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
626 match fs::read_to_string(path) {
627 Ok(content) => {
628 let trimmed = content.trim();
629 if trimmed.is_empty() {
630 Ok(None)
631 } else {
632 Ok(ObjectId::from_hex(trimmed).ok())
633 }
634 }
635 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
636 Err(e) => Err(Error::Io(e)),
637 }
638}
639
640pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
642 read_single_oid_file(&git_dir.join("ORIG_HEAD"))
643}
644
645fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
647 match fs::read_to_string(path) {
648 Ok(content) => {
649 let trimmed = content.trim();
650 if trimmed.is_empty() {
651 Ok(None)
652 } else {
653 Ok(Some(ObjectId::from_hex(trimmed)?))
654 }
655 }
656 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
657 Err(e) => Err(Error::Io(e)),
658 }
659}
660
661pub fn upstream_tracking(_git_dir: &Path, _branch: &str) -> Result<Option<(usize, usize)>> {
675 Ok(None)
677}