1use std::fs;
16use std::path::Path;
17
18use crate::error::{Error, Result};
19use crate::objects::ObjectId;
20use crate::reflog;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum HeadState {
25 Branch {
27 refname: String,
29 short_name: String,
31 oid: Option<ObjectId>,
34 },
35 Detached {
37 oid: ObjectId,
39 },
40 Invalid,
42}
43
44impl HeadState {
45 #[must_use]
47 pub fn oid(&self) -> Option<&ObjectId> {
48 match self {
49 Self::Branch { oid, .. } => oid.as_ref(),
50 Self::Detached { oid } => Some(oid),
51 Self::Invalid => None,
52 }
53 }
54
55 #[must_use]
57 pub fn branch_name(&self) -> Option<&str> {
58 match self {
59 Self::Branch { short_name, .. } => Some(short_name),
60 _ => None,
61 }
62 }
63
64 #[must_use]
66 pub fn is_unborn(&self) -> bool {
67 matches!(self, Self::Branch { oid: None, .. })
68 }
69
70 #[must_use]
72 pub fn is_detached(&self) -> bool {
73 matches!(self, Self::Detached { .. })
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum InProgressOperation {
80 Merge,
82 RebaseInteractive,
84 Rebase,
86 CherryPick,
88 Revert,
90 Bisect,
92 Am,
94}
95
96impl InProgressOperation {
97 #[must_use]
99 pub fn description(&self) -> &'static str {
100 match self {
101 Self::Merge => "merge",
102 Self::RebaseInteractive => "interactive rebase",
103 Self::Rebase => "rebase",
104 Self::CherryPick => "cherry-pick",
105 Self::Revert => "revert",
106 Self::Bisect => "bisect",
107 Self::Am => "am",
108 }
109 }
110
111 #[must_use]
113 pub fn hint(&self) -> &'static str {
114 match self {
115 Self::Merge => "fix conflicts and run \"git commit\"\n (use \"git merge --abort\" to abort the merge)",
116 Self::RebaseInteractive => "fix conflicts and then run \"git rebase --continue\"\n (use \"git rebase --abort\" to abort the rebase)",
117 Self::Rebase => "fix conflicts and then run \"git rebase --continue\"\n (use \"git rebase --abort\" to abort the rebase)",
118 Self::CherryPick => "fix conflicts and run \"git cherry-pick --continue\"\n (use \"git cherry-pick --abort\" to abort the cherry-pick)",
119 Self::Revert => "fix conflicts and run \"git revert --continue\"\n (use \"git revert --abort\" to abort the revert)",
120 Self::Bisect => "use \"git bisect reset\" to get back to the original branch",
121 Self::Am => "fix conflicts and then run \"git am --continue\"\n (use \"git am --abort\" to abort the am)",
122 }
123 }
124}
125
126#[derive(Debug, Clone)]
131pub struct RepoState {
132 pub head: HeadState,
134 pub in_progress: Vec<InProgressOperation>,
136 pub is_bare: bool,
138}
139
140pub fn resolve_head(git_dir: &Path) -> Result<HeadState> {
152 let head_path = git_dir.join("HEAD");
153 let content = match fs::read_link(&head_path) {
154 Ok(link_target) => {
155 let rendered = link_target.to_string_lossy();
156 if link_target.is_absolute() {
157 format!("ref: {rendered}")
158 } else if rendered.starts_with("refs/") {
159 format!("ref: {rendered}")
160 } else {
161 fs::read_to_string(&head_path).map_err(Error::Io)?
162 }
163 }
164 Err(_) => match fs::read_to_string(&head_path) {
165 Ok(c) => c,
166 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HeadState::Invalid),
167 Err(e) => return Err(Error::Io(e)),
168 },
169 };
170
171 let trimmed = content.trim();
172
173 if let Some(refname) = trimmed.strip_prefix("ref: ") {
174 let refname = if refname == "refs/heads/.invalid" {
175 match crate::refs::read_ref_file(&git_dir.join("refs").join("heads")) {
176 Ok(crate::refs::Ref::Symbolic(target)) => target,
177 _ => refname.to_owned(),
178 }
179 } else {
180 refname.to_owned()
181 };
182 let short_name = refname
183 .strip_prefix("refs/heads/")
184 .unwrap_or(&refname)
185 .to_owned();
186
187 let oid = match crate::refs::resolve_ref(git_dir, &refname) {
190 Ok(oid) => Some(oid),
191 Err(Error::InvalidRef(msg)) if msg.starts_with("ref not found:") => None,
192 Err(e) => return Err(e),
193 };
194
195 Ok(HeadState::Branch {
196 refname,
197 short_name,
198 oid,
199 })
200 } else {
201 match ObjectId::from_hex(trimmed) {
203 Ok(oid) => Ok(HeadState::Detached { oid }),
204 Err(_) => Ok(HeadState::Invalid),
205 }
206 }
207}
208
209pub fn detect_in_progress(git_dir: &Path) -> Vec<InProgressOperation> {
219 let mut ops = Vec::new();
220
221 if git_dir.join("MERGE_HEAD").exists() {
222 ops.push(InProgressOperation::Merge);
223 }
224
225 let rebase_merge = git_dir.join("rebase-merge");
227 if rebase_merge.is_dir() {
228 if rebase_merge.join("interactive").exists() {
229 ops.push(InProgressOperation::RebaseInteractive);
230 } else {
231 ops.push(InProgressOperation::Rebase);
232 }
233 }
234
235 let rebase_apply = git_dir.join("rebase-apply");
237 if rebase_apply.is_dir() {
238 if rebase_apply.join("applying").exists() {
239 ops.push(InProgressOperation::Am);
240 } else {
241 ops.push(InProgressOperation::Rebase);
242 }
243 }
244
245 if git_dir.join("CHERRY_PICK_HEAD").exists() {
246 ops.push(InProgressOperation::CherryPick);
247 }
248
249 if git_dir.join("REVERT_HEAD").exists() {
250 ops.push(InProgressOperation::Revert);
251 }
252
253 let bisect_log = crate::refs::common_dir(git_dir)
254 .unwrap_or_else(|| git_dir.to_path_buf())
255 .join("BISECT_LOG");
256 if bisect_log.exists() {
257 ops.push(InProgressOperation::Bisect);
258 }
259
260 ops
261}
262
263#[derive(Debug, Clone, Default)]
268pub struct WtStatusState {
269 pub merge_in_progress: bool,
271 pub rebase_interactive_in_progress: bool,
273 pub rebase_in_progress: bool,
275 pub rebase_branch: Option<String>,
277 pub rebase_onto: Option<String>,
279 pub am_in_progress: bool,
281 pub am_empty_patch: bool,
283 pub cherry_pick_in_progress: bool,
285 pub cherry_pick_head_oid: Option<ObjectId>,
287 pub revert_in_progress: bool,
289 pub revert_head_oid: Option<ObjectId>,
290 pub bisect_in_progress: bool,
292 pub bisecting_from: Option<String>,
293 pub detached_from: Option<String>,
295 pub detached_at: bool,
297}
298
299fn abbrev_oid(oid: &ObjectId) -> String {
300 oid.to_hex()[..7].to_string()
301}
302
303fn read_trimmed_line(path: &Path) -> Option<String> {
304 let s = fs::read_to_string(path).ok()?;
305 let mut line = s.lines().next()?.to_string();
306 while line.ends_with('\n') || line.ends_with('\r') {
307 line.pop();
308 }
309 if line.is_empty() {
310 None
311 } else {
312 Some(line)
313 }
314}
315
316fn get_branch_display(git_dir: &Path, rel: &str) -> Option<String> {
318 let path = git_dir.join(rel);
319 let mut sb = read_trimmed_line(&path)?;
320 if let Some(branch_name) = sb.strip_prefix("refs/heads/") {
321 sb = branch_name.to_string();
322 } else if sb.starts_with("refs/") {
323 } else if ObjectId::from_hex(&sb).is_ok() {
325 let oid = ObjectId::from_hex(&sb).ok()?;
326 sb = abbrev_oid(&oid);
327 } else if sb == "detached HEAD" {
328 return None;
329 }
330 Some(sb)
331}
332
333fn strip_ref_for_display(full: &str) -> String {
334 if let Some(s) = full.strip_prefix("refs/tags/") {
335 return s.to_string();
336 }
337 if let Some(s) = full.strip_prefix("refs/remotes/") {
338 return s.to_string();
339 }
340 if let Some(s) = full.strip_prefix("refs/heads/") {
341 return s.to_string();
342 }
343 full.to_string()
344}
345
346fn dwim_detach_label(git_dir: &Path, target: &str, noid: ObjectId) -> String {
347 if target == "HEAD" {
348 return abbrev_oid(&noid);
349 }
350 if target.starts_with("refs/") {
351 if let Ok(oid) = crate::refs::resolve_ref(git_dir, target) {
352 if oid == noid {
353 return strip_ref_for_display(target);
354 }
355 }
356 }
357 for candidate in [
358 format!("refs/heads/{target}"),
359 format!("refs/tags/{target}"),
360 format!("refs/remotes/{target}"),
361 ] {
362 if let Ok(oid) = crate::refs::resolve_ref(git_dir, &candidate) {
363 if oid == noid {
364 return strip_ref_for_display(&candidate);
365 }
366 }
367 }
368 if target.len() == 40 {
369 if let Ok(oid) = ObjectId::from_hex(target) {
370 if oid == noid {
371 return abbrev_oid(&noid);
372 }
373 }
374 }
375 if !target.is_empty()
378 && target.chars().all(|c| c.is_ascii_hexdigit())
379 && target.len() <= 40
380 && noid.to_hex().starts_with(target)
381 {
382 return target.to_owned();
383 }
384 abbrev_oid(&noid)
385}
386
387fn wt_status_get_detached_from(git_dir: &Path, head_oid: ObjectId) -> Option<(String, bool)> {
388 let entries = reflog::read_reflog(git_dir, "HEAD").ok()?;
389 for entry in entries.iter().rev() {
390 let msg = entry.message.trim();
391 let Some(rest) = msg.strip_prefix("checkout: moving from ") else {
392 continue;
393 };
394 let Some(idx) = rest.rfind(" to ") else {
395 continue;
396 };
397 let target = rest[idx + 4..].trim();
398 let noid = entry.new_oid;
399 let label = dwim_detach_label(git_dir, target, noid);
400 let detached_at = head_oid == noid;
401 return Some((label, detached_at));
402 }
403 None
404}
405
406fn wt_status_check_rebase(git_dir: &Path, state: &mut WtStatusState) -> bool {
407 let apply = git_dir.join("rebase-apply");
408 if apply.is_dir() {
409 if apply.join("applying").exists() {
410 state.am_in_progress = true;
411 let patch = apply.join("patch");
412 if let Ok(meta) = patch.metadata() {
413 if meta.len() == 0 {
414 state.am_empty_patch = true;
415 }
416 }
417 } else {
418 state.rebase_in_progress = true;
419 state.rebase_branch = get_branch_display(git_dir, "rebase-apply/head-name");
420 state.rebase_onto = get_branch_display(git_dir, "rebase-apply/onto");
421 }
422 return true;
423 }
424 let merge = git_dir.join("rebase-merge");
425 if merge.is_dir() {
426 if merge.join("interactive").exists() {
427 state.rebase_interactive_in_progress = true;
428 } else {
429 state.rebase_in_progress = true;
430 }
431 state.rebase_branch = get_branch_display(git_dir, "rebase-merge/head-name");
432 state.rebase_onto = get_branch_display(git_dir, "rebase-merge/onto");
433 return true;
434 }
435 false
436}
437
438fn sequencer_first_replay(git_dir: &Path) -> Option<bool> {
439 let path = git_dir.join("sequencer").join("todo");
440 if !path.is_file() {
441 return None;
442 }
443 let content = fs::read_to_string(&path).ok()?;
444 for line in content.lines() {
445 let t = line.trim();
446 if t.is_empty() || t.starts_with('#') {
447 continue;
448 }
449 let mut parts = t.split_whitespace();
450 let cmd = parts.next()?;
451 return Some(matches!(cmd, "pick" | "p" | "revert" | "r"));
452 }
453 None
454}
455
456pub fn wt_status_get_state(
461 git_dir: &Path,
462 head: &HeadState,
463 get_detached_from: bool,
464) -> Result<WtStatusState> {
465 let mut state = WtStatusState::default();
466
467 if git_dir.join("MERGE_HEAD").exists() {
468 wt_status_check_rebase(git_dir, &mut state);
469 state.merge_in_progress = true;
470 } else if wt_status_check_rebase(git_dir, &mut state) {
471 } else if let Some(oid) = read_cherry_pick_head(git_dir)? {
473 state.cherry_pick_in_progress = true;
474 state.cherry_pick_head_oid = Some(oid);
475 }
476
477 let bisect_base = crate::refs::common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
478 if bisect_base.join("BISECT_LOG").exists() {
479 state.bisect_in_progress = true;
480 state.bisecting_from = get_branch_display(&bisect_base, "BISECT_START");
481 }
482
483 if let Some(oid) = read_revert_head(git_dir)? {
484 state.revert_in_progress = true;
485 state.revert_head_oid = Some(oid);
486 }
487
488 if let Some(is_pick) = sequencer_first_replay(git_dir) {
489 if is_pick && !state.cherry_pick_in_progress {
490 state.cherry_pick_in_progress = true;
491 state.cherry_pick_head_oid = None;
492 } else if !is_pick && !state.revert_in_progress {
493 state.revert_in_progress = true;
494 state.revert_head_oid = None;
495 }
496 }
497
498 if get_detached_from {
499 if let HeadState::Detached { oid } = head {
500 if let Some((label, at)) = wt_status_get_detached_from(git_dir, *oid) {
501 state.detached_from = Some(label);
502 state.detached_at = at;
503 }
504 }
505 }
506
507 Ok(state)
508}
509
510pub fn split_commit_in_progress(git_dir: &Path, head: &HeadState) -> bool {
512 let HeadState::Detached { oid: head_oid } = head else {
513 return false;
514 };
515 let Some(amend_line) = read_trimmed_line(&git_dir.join("rebase-merge/amend")) else {
516 return false;
517 };
518 let Some(orig_line) = read_trimmed_line(&git_dir.join("rebase-merge/orig-head")) else {
519 return false;
520 };
521 let Ok(amend_oid) = ObjectId::from_hex(amend_line.trim()) else {
522 return false;
523 };
524 let Ok(orig_head_oid) = ObjectId::from_hex(orig_line.trim()) else {
525 return false;
526 };
527 if amend_line == orig_line {
528 head_oid != &amend_oid
529 } else if let Ok(Some(cur_orig)) = read_orig_head(git_dir) {
530 cur_orig != orig_head_oid
531 } else {
532 false
533 }
534}
535
536pub fn repo_state(git_dir: &Path, is_bare: bool) -> Result<RepoState> {
547 let head = resolve_head(git_dir)?;
548 let in_progress = detect_in_progress(git_dir);
549
550 Ok(RepoState {
551 head,
552 in_progress,
553 is_bare,
554 })
555}
556
557pub fn read_merge_heads(git_dir: &Path) -> Result<Vec<ObjectId>> {
567 let path = git_dir.join("MERGE_HEAD");
568 let content = match fs::read_to_string(&path) {
569 Ok(c) => c,
570 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
571 Err(e) => return Err(Error::Io(e)),
572 };
573
574 let mut oids = Vec::new();
575 for line in content.lines() {
576 let trimmed = line.trim();
577 if !trimmed.is_empty() {
578 oids.push(ObjectId::from_hex(trimmed)?);
579 }
580 }
581 Ok(oids)
582}
583
584pub fn read_merge_msg(git_dir: &Path) -> Result<Option<String>> {
594 let path = git_dir.join("MERGE_MSG");
595 match fs::read_to_string(&path) {
596 Ok(c) => Ok(Some(c)),
597 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
598 Err(e) => Err(Error::Io(e)),
599 }
600}
601
602pub fn read_cherry_pick_head(git_dir: &Path) -> Result<Option<ObjectId>> {
605 read_oid_head_file_optional(&git_dir.join("CHERRY_PICK_HEAD"))
606}
607
608pub fn read_revert_head(git_dir: &Path) -> Result<Option<ObjectId>> {
610 read_oid_head_file_optional(&git_dir.join("REVERT_HEAD"))
611}
612
613fn read_oid_head_file_optional(path: &Path) -> Result<Option<ObjectId>> {
614 match fs::read_to_string(path) {
615 Ok(content) => {
616 let trimmed = content.trim();
617 if trimmed.is_empty() {
618 Ok(None)
619 } else {
620 Ok(ObjectId::from_hex(trimmed).ok())
621 }
622 }
623 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
624 Err(e) => Err(Error::Io(e)),
625 }
626}
627
628pub fn read_orig_head(git_dir: &Path) -> Result<Option<ObjectId>> {
630 read_single_oid_file(&git_dir.join("ORIG_HEAD"))
631}
632
633fn read_single_oid_file(path: &Path) -> Result<Option<ObjectId>> {
635 match fs::read_to_string(path) {
636 Ok(content) => {
637 let trimmed = content.trim();
638 if trimmed.is_empty() {
639 Ok(None)
640 } else {
641 Ok(Some(ObjectId::from_hex(trimmed)?))
642 }
643 }
644 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
645 Err(e) => Err(Error::Io(e)),
646 }
647}
648
649pub fn upstream_tracking(_git_dir: &Path, _branch: &str) -> Result<Option<(usize, usize)>> {
663 Ok(None)
665}