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 ref_name_exists(git_dir: &Path, refname: &str) -> bool {
359 git_dir.join(refname).exists()
360 || crate::refs::packed_refs_entry_exists(git_dir, refname).unwrap_or(false)
361}
362
363fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
364 if target == "HEAD" {
365 return abbrev_oid(&noid);
366 }
367 if target.starts_with("refs/") {
368 if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
369 if oid == noid {
370 return strip_ref_for_display(target);
371 }
372 }
373 }
374 for candidate in [
375 format!("refs/heads/{target}"),
376 format!("refs/tags/{target}"),
377 format!("refs/remotes/{target}"),
378 ] {
379 if candidate.starts_with("refs/tags/") && ref_name_exists(git_dir, &candidate) {
380 return strip_ref_for_display(&candidate);
381 }
382 if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
383 if oid == noid {
384 return strip_ref_for_display(&candidate);
385 }
386 }
387 }
388 if target.len() == 40 {
389 if let Ok(oid) = ObjectId::from_hex(target) {
390 if oid == noid {
391 return abbrev_oid(&noid);
392 }
393 }
394 }
395 if !target.is_empty()
398 && target.chars().all(|c| c.is_ascii_hexdigit())
399 && target.len() <= 40
400 && noid.to_hex().starts_with(target)
401 {
402 return target.to_owned();
403 }
404 abbrev_oid(&noid)
405}
406
407fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
408 let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
409 for entry in entries.iter().rev() {
410 let msg = entry.message.trim();
411 let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
412 continue;
413 };
414 let Some(idx) = rest.rfind(" to ") else {
415 continue;
416 };
417 let target = rest[idx + 4..].trim();
418 let noid = entry.new_oid;
419 let label = dwim_detach_label(git_dir, target, noid);
420 let detached_at = head_oid == noid;
421 return Some((label, detached_at));
422 }
423 None
424}
425
426fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
427 let apply = git_dir.join("rebase-apply");
428 if apply.is_dir() {
429 if apply.join("applying").exists() {
430 state.am_in_progress = true;
431 let patch = apply.join("patch");
432 if let Ok(meta) = patch.metadata() {
433 if meta.len() == 0 {
434 state.am_empty_patch = true;
435 }
436 }
437 } else {
438 state.rebase_in_progress = true;
439 state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
440 state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
441 }
442 return true;
443 }
444 let merge = git_dir.join("rebase-merge");
445 if merge.is_dir() {
446 if merge.join("interactive").exists() {
447 state.rebase_interactive_in_progress = true;
448 } else {
449 state.rebase_in_progress = true;
450 }
451 state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
452 state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
453 return true;
454 }
455 false
456}
457
458fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
459 let path = git_dir.join("sequencer").join("todo");
460 if !path.is_file() {
461 return None;
462 }
463 let content = fs::read_to_string(&path).ok()?;
464 for line in content.lines() {
465 let t = line.trim();
466 if t.is_empty() || t.starts_with('#') {
467 continue;
468 }
469 let mut parts = t.split_whitespace();
470 let cmd = parts.next()?;
471 return match cmd {
472 "pick" | "p" => Some(true),
473 "revert" | "r" => Some(false),
474 _ => None,
475 };
476 }
477 None
478}
479
480pub fn wt_status_get_state(
485 git_dir: &Path,
486 head: &HeadState,
487 get_detached_from: bool,
488) -> Result<WtStatusState> {
489 let mut state = WtStatusState::default();
490
491 if git_dir.join("MERGE_HEAD").exists() {
492 wt_status_check_rebase(git_dir, &mut state);
493 state.merge_in_progress = true;
494 } else if wt_status_check_rebase(git_dir, &mut state) {
495 } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
497 state.cherry_pick_in_progress = true;
498 state.cherry_pick_head_oid = Some(oid);
499 }
500
501 let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
502 if bisect_base.join("BISECT_LOG").exists() {
503 state.bisect_in_progress = true;
504 state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
505 }
506
507 if let Some(oid) = read_revert_head(git_dir)? {
508 state.revert_in_progress = true;
509 state.revert_head_oid = Some(oid);
510 }
511
512 if let Some(is_pick) = sequencer_first_replay(git_dir) {
513 if is_pick && !state.cherry_pick_in_progress {
514 state.cherry_pick_in_progress = true;
515 state.cherry_pick_head_oid = None;
516 } else if !is_pick && !state.revert_in_progress {
517 state.revert_in_progress = true;
518 state.revert_head_oid = None;
519 }
520 }
521
522 if get_detached_from {
523 if let HeadState::Detached { oid } = head {
524 if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
525 state.detached_from = Some(label);
526 state.detached_at = at;
527 }
528 }
529 }
530
531 Ok(state)
532}
533
534pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
536 let HeadState::Detached { oid: head_oid } = head else {
537 return false;
538 };
539 let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
540 return false;
541 };
542 let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
543 return false;
544 };
545 let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
546 return false;
547 };
548 let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
549 return false;
550 };
551 if amend_line == orig_line {
552 head_oid != &amend_oid
553 } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
554 cur_orig != orig_head_oid
555 } else {
556 false
557 }
558}
559
560pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
571 let head = resolve_head(git_dir)?;
572 let in_progress = detect_in_progress(git_dir);
573
574 Ok(RepoState {
575 head,
576 in_progress,
577 is_bare,
578 })
579}
580
581pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
591 let path = git_dir.join("MERGE_HEAD");
592 let content = match fs::read_to_string(&path) {
593 Ok(c) => c,
594 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
595 Err(e) => return Err(Error::Io(e)),
596 };
597
598 let mut oids = Vec::new();
599 for line in content.lines() {
600 let trimmed = line.trim();
601 if !trimmed.is_empty() {
602 oids.push(ObjectId::from_hex(trimmed)?);
603 }
604 }
605 Ok(oids)
606}
607
608pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
618 let path = git_dir.join("MERGE_MSG");
619 match fs::read_to_string(&path) {
620 Ok(c) => Ok(Some(c)),
621 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
622 Err(e) => Err(Error::Io(e)),
623 }
624}
625
626pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
629 read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
630}
631
632pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
634 read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
635}
636
637fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
638 match fs::read_to_string(path) {
639 Ok(content) => {
640 let trimmed = content.trim();
641 if trimmed.is_empty() {
642 Ok(None)
643 } else {
644 Ok(ObjectId::from_hex(trimmed).ok())
645 }
646 }
647 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
648 Err(e) => Err(Error::Io(e)),
649 }
650}
651
652pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
654 read_single_oid_file(&git_dir.join("ORIG_HEAD"))
655}
656
657fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
659 match fs::read_to_string(path) {
660 Ok(content) => {
661 let trimmed = content.trim();
662 if trimmed.is_empty() {
663 Ok(None)
664 } else {
665 Ok(Some(ObjectId::from_hex(trimmed)?))
666 }
667 }
668 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
669 Err(e) => Err(Error::Io(e)),
670 }
671}
672
673pub fn upstream_tracking(_git_dir: &Path, _branch: &str) -> Result<Option<(usize, usize)>> {
687 Ok(None)
689}