1use std::cmp::Ordering;
6use std::fs;
7use std::io;
8use std::path::{Component, Path, PathBuf};
9
10use crate::check_ref_format::{check_refname_format, RefNameOptions};
11use crate::config::ConfigSet;
12use crate::objects::ObjectId;
13use crate::odb::Odb;
14use crate::repo::Repository;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum RefsFsckSeverity {
19 Error,
20 Warning,
21}
22
23#[derive(Debug, Clone)]
25pub struct RefsFsckIssue {
26 pub severity: RefsFsckSeverity,
27 pub path: String,
28 pub msg_id: &'static str,
29 pub detail: String,
30}
31
32#[must_use]
34pub fn format_refs_fsck_line(issue: &RefsFsckIssue) -> String {
35 let level = match issue.severity {
36 RefsFsckSeverity::Error => "error",
37 RefsFsckSeverity::Warning => "warning",
38 };
39 format!(
40 "{}: {}: {}: {}",
41 level, issue.path, issue.msg_id, issue.detail
42 )
43}
44
45fn canonical_git_dir(git_dir: &Path) -> PathBuf {
46 let commondir_file = git_dir.join("commondir");
47 let Some(raw) = fs::read_to_string(commondir_file).ok() else {
48 return git_dir.to_path_buf();
49 };
50 let rel = raw.trim();
51 if rel.is_empty() {
52 return git_dir.to_path_buf();
53 }
54 let path = if Path::new(rel).is_absolute() {
55 PathBuf::from(rel)
56 } else {
57 git_dir.join(rel)
58 };
59 path.canonicalize().unwrap_or(path)
60}
61
62fn is_pseudo_ref(name: &str) -> bool {
63 matches!(name, "FETCH_HEAD" | "MERGE_HEAD" | "ORIG_HEAD")
64}
65
66fn is_root_ref_syntax(name: &str) -> bool {
67 !name.is_empty()
68 && name
69 .bytes()
70 .all(|b| b.is_ascii_uppercase() || b == b'-' || b == b'_')
71}
72
73fn is_root_ref(name: &str) -> bool {
75 if !is_root_ref_syntax(name) || is_pseudo_ref(name) {
76 return false;
77 }
78 if name.ends_with("_HEAD") {
79 return true;
80 }
81 matches!(
82 name,
83 "HEAD"
84 | "AUTO_MERGE"
85 | "BISECT_EXPECTED_REV"
86 | "NOTES_MERGE_PARTIAL"
87 | "NOTES_MERGE_REF"
88 | "MERGE_AUTOSTASH"
89 )
90}
91
92fn stripped_for_head_check(display_path: &str) -> &str {
93 display_path
94 .strip_prefix("worktrees/")
95 .and_then(|s| s.find('/').map(|i| &s[i + 1..]))
96 .unwrap_or(display_path)
97}
98
99fn ref_path_for_name_check(display_path: &str) -> &str {
100 if let Some(rest) = display_path.strip_prefix("worktrees/") {
101 if let Some(idx) = rest.find("/refs/") {
102 return &rest[idx + 1..];
103 }
104 if rest.ends_with("/HEAD") || rest == "HEAD" {
105 return "HEAD";
106 }
107 }
108 display_path
109}
110
111fn fsck_refs_msg_severity(
112 config: &ConfigSet,
113 camel_id: &str,
114 default_warn: bool,
115 strict: bool,
116) -> Option<RefsFsckSeverity> {
117 let key = format!("fsck.{camel_id}");
118 let v = config.get(&key).map(|s| s.to_ascii_lowercase());
119 if matches!(v.as_deref(), Some("ignore")) {
120 return None;
121 }
122 let level = match v.as_deref() {
123 Some("warn") => RefsFsckSeverity::Warning,
124 Some("error") => RefsFsckSeverity::Error,
125 _ => {
126 if default_warn {
127 if strict {
128 RefsFsckSeverity::Error
129 } else {
130 RefsFsckSeverity::Warning
131 }
132 } else {
133 RefsFsckSeverity::Error
134 }
135 }
136 };
137 Some(level)
138}
139
140fn push_issue(
141 issues: &mut Vec<RefsFsckIssue>,
142 config: &ConfigSet,
143 strict: bool,
144 camel_id: &'static str,
145 default_warn: bool,
146 path: String,
147 detail: String,
148) {
149 let Some(sev) = fsck_refs_msg_severity(config, camel_id, default_warn, strict) else {
150 return;
151 };
152 issues.push(RefsFsckIssue {
153 severity: sev,
154 path,
155 msg_id: camel_id,
156 detail,
157 });
158}
159
160pub fn refs_fsck(
162 repo: &Repository,
163 odb: &Odb,
164 config: &ConfigSet,
165 strict: bool,
166) -> io::Result<Vec<RefsFsckIssue>> {
167 let mut issues = Vec::new();
168 let common = canonical_git_dir(&repo.git_dir);
169
170 let mut stores: Vec<(PathBuf, Option<String>)> = vec![(common.clone(), None)];
171 let worktrees_dir = common.join("worktrees");
172 if let Ok(rd) = fs::read_dir(&worktrees_dir) {
173 for e in rd.flatten() {
174 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
175 let id = e.file_name().to_string_lossy().to_string();
176 stores.push((e.path(), Some(id)));
177 }
178 }
179 }
180
181 for (git_dir, wt_id) in stores {
182 fsck_worktree(
183 &git_dir,
184 wt_id.as_deref(),
185 &common,
186 odb,
187 config,
188 strict,
189 &mut issues,
190 )?;
191 }
192
193 Ok(issues)
197}
198
199fn fsck_worktree(
200 git_dir: &Path,
201 worktree_id: Option<&str>,
202 common_dir: &Path,
203 odb: &Odb,
204 config: &ConfigSet,
205 strict: bool,
206 issues: &mut Vec<RefsFsckIssue>,
207) -> io::Result<()> {
208 let refs_dir = git_dir.join("refs");
209 if refs_dir.is_dir() {
210 walk_refs_files(common_dir, &refs_dir, odb, config, strict, issues)?;
211 }
212
213 if worktree_id.is_none() {
214 fsck_packed_refs(common_dir, config, strict, issues)?;
215 }
216
217 fsck_root_refs(
218 git_dir,
219 common_dir,
220 path_prefix_for_root(worktree_id),
221 odb,
222 config,
223 strict,
224 issues,
225 )?;
226 Ok(())
227}
228
229fn path_prefix_for_root(worktree_id: Option<&str>) -> Option<String> {
230 worktree_id.map(|id| format!("worktrees/{id}/"))
231}
232
233fn display_rel_path(common_dir: &Path, path: &Path) -> String {
234 let rel = path.strip_prefix(common_dir).unwrap_or(path);
235 rel.to_string_lossy().replace('\\', "/")
236}
237
238fn normalize_joined_path(base: &Path, rel: &Path) -> PathBuf {
241 let combined = base.join(rel);
242 let mut out = PathBuf::new();
243 for comp in combined.components() {
244 match comp {
245 Component::Prefix(p) => out.push(p.as_os_str()),
246 Component::RootDir => out.push(Component::RootDir.as_os_str()),
247 Component::CurDir => {}
248 Component::ParentDir => {
249 let _ = out.pop();
250 }
251 Component::Normal(p) => out.push(p),
252 }
253 }
254 out
255}
256
257fn walk_refs_files(
258 common_dir: &Path,
259 dir: &Path,
260 odb: &Odb,
261 config: &ConfigSet,
262 strict: bool,
263 issues: &mut Vec<RefsFsckIssue>,
264) -> io::Result<()> {
265 for entry in fs::read_dir(dir)? {
266 let entry = entry?;
267 let path = entry.path();
268 let fname = entry.file_name().to_string_lossy().to_string();
269 if fname == "." || fname == ".." {
270 continue;
271 }
272 if path.is_dir() {
273 walk_refs_files(common_dir, &path, odb, config, strict, issues)?;
274 continue;
275 }
276 if !path.is_file() && !path.is_symlink() {
277 continue;
278 }
279 if !fname.starts_with('.') && fname.ends_with(".lock") {
280 continue;
281 }
282 let display = display_rel_path(common_dir, &path);
283 verify_loose_ref(common_dir, &display, &path, odb, config, strict, issues)?;
284 }
285 Ok(())
286}
287
288fn fsck_root_refs(
289 git_dir: &Path,
290 common_dir: &Path,
291 path_prefix: Option<String>,
292 odb: &Odb,
293 config: &ConfigSet,
294 strict: bool,
295 issues: &mut Vec<RefsFsckIssue>,
296) -> io::Result<()> {
297 let Ok(rd) = fs::read_dir(git_dir) else {
298 return Ok(());
299 };
300 for entry in rd.flatten() {
301 let name = entry.file_name().to_string_lossy().to_string();
302 if name.starts_with('.') {
303 continue;
304 }
305 if !name.starts_with('.') && name.ends_with(".lock") {
306 continue;
307 }
308 if !is_root_ref(&name) {
309 continue;
310 }
311 let path = entry.path();
312 let meta = match fs::symlink_metadata(&path) {
313 Ok(m) => m,
314 Err(_) => continue,
315 };
316 if !meta.is_file() && !meta.is_symlink() {
317 continue;
318 }
319 let display = match &path_prefix {
320 Some(p) => format!("{p}{name}"),
321 None => name,
322 };
323 verify_loose_ref(common_dir, &display, &path, odb, config, strict, issues)?;
324 }
325 Ok(())
326}
327
328fn verify_loose_ref(
329 common_dir: &Path,
330 display_path: &str,
331 path: &Path,
332 odb: &Odb,
333 config: &ConfigSet,
334 strict: bool,
335 issues: &mut Vec<RefsFsckIssue>,
336) -> io::Result<()> {
337 check_ref_file_name(display_path, config, strict, issues);
338
339 let meta = fs::symlink_metadata(path)?;
340 if !meta.is_file() && !meta.is_symlink() {
341 push_issue(
342 issues,
343 config,
344 strict,
345 "badRefFiletype",
346 false,
347 display_path.to_owned(),
348 "unexpected file type".to_owned(),
349 );
350 return Ok(());
351 }
352
353 if meta.is_symlink() {
354 push_issue(
355 issues,
356 config,
357 strict,
358 "symlinkRef",
359 true,
360 display_path.to_owned(),
361 "use deprecated symbolic link for symref".to_owned(),
362 );
363 let target = fs::read_link(path)?;
364 let parent = path.parent().unwrap_or(Path::new(""));
365 let joined = normalize_joined_path(parent, Path::new(&target));
366 let resolved = fs::canonicalize(&joined).unwrap_or(joined);
367 let abs_common = fs::canonicalize(common_dir).unwrap_or(common_dir.to_path_buf());
368 let g = abs_common.to_string_lossy();
369 let r = resolved.to_string_lossy().to_string();
370 let referent = if r.starts_with(g.as_ref()) {
371 let rest = &r[g.len()..];
372 rest.trim_start_matches(['/', '\\']).replace('\\', "/")
373 } else {
374 r.replace('\\', "/")
375 };
376 refs_fsck_symref(display_path, &referent, config, strict, issues);
377 return Ok(());
378 }
379
380 let raw = fs::read_to_string(path)?;
381 let buf = raw.strip_suffix('\r').unwrap_or(&raw);
382
383 if let Some(after) = buf.strip_prefix("ref:") {
384 let mut s = after;
385 while s
386 .as_bytes()
387 .first()
388 .is_some_and(|b| b.is_ascii_whitespace())
389 {
390 s = &s[1..];
391 }
392 fsck_symref_contents(display_path, s, config, strict, issues);
393 return Ok(());
394 }
395
396 let bytes = buf.as_bytes();
397 let mut i = 0usize;
398 while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
399 i += 1;
400 }
401 if !ObjectId::is_hex_len(i) {
403 push_issue(
404 issues,
405 config,
406 strict,
407 "badRefContent",
408 false,
409 display_path.to_owned(),
410 buf.trim_end_matches(['\n', '\r']).to_owned(),
411 );
412 return Ok(());
413 }
414 let oid: ObjectId = match buf[..i].parse() {
415 Ok(o) => o,
416 Err(_) => {
417 push_issue(
418 issues,
419 config,
420 strict,
421 "badRefContent",
422 false,
423 display_path.to_owned(),
424 buf.trim_end_matches(['\n', '\r']).to_owned(),
425 );
426 return Ok(());
427 }
428 };
429 let trailing = &buf[i..];
430 if !trailing.is_empty()
431 && !trailing
432 .as_bytes()
433 .first()
434 .is_some_and(|b| b.is_ascii_whitespace())
435 {
436 push_issue(
437 issues,
438 config,
439 strict,
440 "badRefContent",
441 false,
442 display_path.to_owned(),
443 buf.trim_end_matches(['\n', '\r']).to_owned(),
444 );
445 return Ok(());
446 }
447
448 if trailing.is_empty() {
449 push_issue(
450 issues,
451 config,
452 strict,
453 "refMissingNewline",
454 true,
455 display_path.to_owned(),
456 "misses LF at the end".to_owned(),
457 );
458 } else if trailing != "\n" {
459 push_issue(
462 issues,
463 config,
464 strict,
465 "trailingRefContent",
466 true,
467 display_path.to_owned(),
468 format!("has trailing garbage: '{trailing}'"),
469 );
470 }
471
472 if oid.is_zero() {
473 push_issue(
474 issues,
475 config,
476 strict,
477 "badRefOid",
478 false,
479 display_path.to_owned(),
480 format!("points to invalid object ID '{}'", oid.to_hex()),
481 );
482 } else if !odb.exists(&oid) {
483 push_issue(
484 issues,
485 config,
486 strict,
487 "missingObject",
488 false,
489 display_path.to_owned(),
490 format!("points to missing object {}", oid.to_hex()),
491 );
492 }
493
494 Ok(())
495}
496
497fn check_ref_file_name(
498 display_path: &str,
499 config: &ConfigSet,
500 strict: bool,
501 issues: &mut Vec<RefsFsckIssue>,
502) {
503 let check_path = ref_path_for_name_check(display_path);
504 if is_root_ref(check_path) || check_path == "HEAD" {
505 return;
506 }
507 if check_refname_format(
508 check_path,
509 &RefNameOptions {
510 allow_onelevel: false,
511 refspec_pattern: false,
512 normalize: false,
513 },
514 )
515 .is_err()
516 {
517 push_issue(
518 issues,
519 config,
520 strict,
521 "badRefName",
522 false,
523 display_path.to_owned(),
524 "invalid refname format".to_owned(),
525 );
526 }
527}
528
529fn fsck_symref_contents(
530 display_path: &str,
531 referent_raw: &str,
532 config: &ConfigSet,
533 strict: bool,
534 issues: &mut Vec<RefsFsckIssue>,
535) {
536 let orig_len = referent_raw.len();
538 let orig_last_byte = referent_raw.as_bytes().last().copied();
539 let trimmed = referent_raw.trim_end_matches(|c: char| c.is_ascii_whitespace());
540 let after_len = trimmed.len();
541
542 if after_len == orig_len || (after_len < orig_len && orig_last_byte != Some(b'\n')) {
543 push_issue(
544 issues,
545 config,
546 strict,
547 "refMissingNewline",
548 true,
549 display_path.to_owned(),
550 "misses LF at the end".to_owned(),
551 );
552 }
553 if after_len != orig_len && after_len != orig_len.saturating_sub(1) {
554 push_issue(
555 issues,
556 config,
557 strict,
558 "trailingRefContent",
559 true,
560 display_path.to_owned(),
561 "has trailing whitespaces or newlines".to_owned(),
562 );
563 }
564
565 refs_fsck_symref(display_path, trimmed, config, strict, issues);
566}
567
568fn refs_fsck_symref(
569 display_path: &str,
570 target: &str,
571 config: &ConfigSet,
572 strict: bool,
573 issues: &mut Vec<RefsFsckIssue>,
574) {
575 let stripped = stripped_for_head_check(display_path);
576 if stripped == "HEAD" && !target.starts_with("refs/heads/") {
577 push_issue(
578 issues,
579 config,
580 strict,
581 "badHeadTarget",
582 false,
583 display_path.to_owned(),
584 format!("HEAD points to non-branch '{target}'"),
585 );
586 }
587
588 if is_root_ref(target) {
589 return;
590 }
591
592 if check_refname_format(
593 target,
594 &RefNameOptions {
595 allow_onelevel: false,
596 refspec_pattern: false,
597 normalize: false,
598 },
599 )
600 .is_err()
601 {
602 push_issue(
603 issues,
604 config,
605 strict,
606 "badReferentName",
607 false,
608 display_path.to_owned(),
609 format!("points to invalid refname '{target}'"),
610 );
611 return;
612 }
613
614 if !target.starts_with("refs/") && !target.starts_with("worktrees/") {
615 push_issue(
616 issues,
617 config,
618 strict,
619 "symrefTargetIsNotARef",
620 true,
621 display_path.to_owned(),
622 format!("points to non-ref target '{target}'"),
623 );
624 }
625}
626
627fn cmp_packed_refname(r1: &str, r2: &str) -> Ordering {
628 let b1 = r1.as_bytes();
629 let b2 = r2.as_bytes();
630 let mut i = 0;
631 loop {
632 let c1 = b1.get(i).copied();
633 let c2 = b2.get(i).copied();
634 match (c1, c2) {
635 (None, None) => return Ordering::Equal,
636 (Some(b'\n'), None) => return Ordering::Less,
637 (None, Some(b'\n')) => return Ordering::Greater,
638 (Some(b'\n'), Some(b'\n')) => return Ordering::Equal,
639 (Some(b'\n'), _) => return Ordering::Less,
640 (_, Some(b'\n')) => return Ordering::Greater,
641 (Some(a), Some(b)) if a != b => return a.cmp(&b),
642 (Some(_), Some(_)) => i += 1,
643 (None, Some(_)) => return Ordering::Less,
644 (Some(_), None) => return Ordering::Greater,
645 }
646 }
647}
648
649fn fsck_packed_refs(
650 common_dir: &Path,
651 config: &ConfigSet,
652 strict: bool,
653 issues: &mut Vec<RefsFsckIssue>,
654) -> io::Result<()> {
655 let path = common_dir.join("packed-refs");
656 let meta = match fs::symlink_metadata(&path) {
657 Ok(m) => m,
658 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
659 Err(e) => return Err(e),
660 };
661 if meta.is_symlink() {
662 push_issue(
663 issues,
664 config,
665 strict,
666 "badRefFiletype",
667 false,
668 "packed-refs".to_owned(),
669 "not a regular file but a symlink".to_owned(),
670 );
671 return Ok(());
672 }
673 if !meta.is_file() {
674 push_issue(
675 issues,
676 config,
677 strict,
678 "badRefFiletype",
679 false,
680 "packed-refs".to_owned(),
681 "not a regular file".to_owned(),
682 );
683 return Ok(());
684 }
685 let data = fs::read(&path)?;
686 if data.is_empty() {
687 push_issue(
688 issues,
689 config,
690 strict,
691 "emptyPackedRefsFile",
692 true,
693 "packed-refs".to_owned(),
694 "file is empty".to_owned(),
695 );
696 return Ok(());
697 }
698
699 let text = String::from_utf8_lossy(&data).into_owned();
700 let mut sorted = false;
701 let mut main_ref_order: Vec<(usize, String)> = Vec::new();
702
703 for (line_no, raw_line) in text.lines().enumerate() {
704 let line_no = line_no + 1;
705 let line = raw_line.trim_end_matches('\r');
706
707 if line_no == 1 && line.starts_with('#') {
708 if line.starts_with("# pack-refs with: ") {
709 let traits = line
710 .strip_prefix("# pack-refs with: ")
711 .unwrap_or("")
712 .split_whitespace();
713 sorted = traits.clone().any(|t| t == "sorted");
714 } else if line.contains("pack-refs") {
715 push_issue(
716 issues,
717 config,
718 strict,
719 "badPackedRefHeader",
720 false,
721 "packed-refs.header".to_owned(),
722 format!("'{line}' does not start with '# pack-refs with: '"),
723 );
724 }
725 continue;
726 }
727
728 if line.is_empty() || line.starts_with('#') {
729 continue;
730 }
731
732 if let Some(inner) = line.strip_prefix('^') {
733 let mut j = 0usize;
734 while j < inner.len() && inner.as_bytes()[j].is_ascii_hexdigit() {
735 j += 1;
736 }
737 if !ObjectId::is_hex_len(j) {
738 push_issue(
739 issues,
740 config,
741 strict,
742 "badPackedRefEntry",
743 false,
744 format!("packed-refs line {line_no}"),
745 format!("'{inner}' has invalid peeled oid"),
746 );
747 } else if j < inner.len() {
748 push_issue(
749 issues,
750 config,
751 strict,
752 "badPackedRefEntry",
753 false,
754 format!("packed-refs line {line_no}"),
755 format!("has trailing garbage after peeled oid '{}'", &inner[j..]),
756 );
757 }
758 continue;
759 }
760
761 let mut j = 0usize;
762 while j < line.len() && line.as_bytes()[j].is_ascii_hexdigit() {
763 j += 1;
764 }
765 let oid_hex = &line[..j];
766 let rest = &line[j..];
767
768 if !ObjectId::is_hex_len(oid_hex.len()) {
769 let display_line = format!("{oid_hex}{rest}");
770 push_issue(
771 issues,
772 config,
773 strict,
774 "badPackedRefEntry",
775 false,
776 format!("packed-refs line {line_no}"),
777 format!("'{display_line}' has invalid oid"),
778 );
779 continue;
780 }
781
782 if rest.is_empty()
783 || !rest
784 .as_bytes()
785 .first()
786 .is_some_and(|b| b.is_ascii_whitespace())
787 {
788 push_issue(
789 issues,
790 config,
791 strict,
792 "badPackedRefEntry",
793 false,
794 format!("packed-refs line {line_no}"),
795 format!(
796 "has no space after oid '{oid_hex}' but with '{}'",
797 rest.trim_end_matches('\r')
798 ),
799 );
800 continue;
801 }
802
803 let rest = rest.trim_end_matches('\r');
805 let refname = match rest.chars().next() {
806 Some(c) if c.is_whitespace() => &rest[c.len_utf8()..],
807 _ => rest,
808 };
809
810 if check_refname_format(
811 refname,
812 &RefNameOptions {
813 allow_onelevel: false,
814 refspec_pattern: false,
815 normalize: false,
816 },
817 )
818 .is_err()
819 {
820 push_issue(
821 issues,
822 config,
823 strict,
824 "badRefName",
825 false,
826 format!("packed-refs line {line_no}"),
827 format!("has bad refname '{refname}'"),
828 );
829 }
830
831 main_ref_order.push((line_no, refname.to_owned()));
832 }
833
834 if sorted && main_ref_order.len() >= 2 {
835 let mut former: Option<&str> = None;
836 for (line_no, refname) in &main_ref_order {
837 if let Some(prev) = former {
838 if cmp_packed_refname(refname, prev) != Ordering::Greater {
839 push_issue(
840 issues,
841 config,
842 strict,
843 "packedRefUnsorted",
844 false,
845 format!("packed-refs line {line_no}"),
846 format!("refname '{refname}' is less than previous refname '{prev}'"),
847 );
848 break;
849 }
850 }
851 former = Some(refname.as_str());
852 }
853 }
854
855 Ok(())
856}