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 i != 40 {
402 push_issue(
403 issues,
404 config,
405 strict,
406 "badRefContent",
407 false,
408 display_path.to_owned(),
409 buf.trim_end_matches(['\n', '\r']).to_owned(),
410 );
411 return Ok(());
412 }
413 let oid: ObjectId = match buf[..40].parse() {
414 Ok(o) => o,
415 Err(_) => {
416 push_issue(
417 issues,
418 config,
419 strict,
420 "badRefContent",
421 false,
422 display_path.to_owned(),
423 buf.trim_end_matches(['\n', '\r']).to_owned(),
424 );
425 return Ok(());
426 }
427 };
428 let trailing = &buf[40..];
429 if !trailing.is_empty()
430 && !trailing
431 .as_bytes()
432 .first()
433 .is_some_and(|b| b.is_ascii_whitespace())
434 {
435 push_issue(
436 issues,
437 config,
438 strict,
439 "badRefContent",
440 false,
441 display_path.to_owned(),
442 buf.trim_end_matches(['\n', '\r']).to_owned(),
443 );
444 return Ok(());
445 }
446
447 if trailing.is_empty() {
448 push_issue(
449 issues,
450 config,
451 strict,
452 "refMissingNewline",
453 true,
454 display_path.to_owned(),
455 "misses LF at the end".to_owned(),
456 );
457 } else if trailing != "\n" {
458 push_issue(
461 issues,
462 config,
463 strict,
464 "trailingRefContent",
465 true,
466 display_path.to_owned(),
467 format!("has trailing garbage: '{trailing}'"),
468 );
469 }
470
471 if oid.is_zero() {
472 push_issue(
473 issues,
474 config,
475 strict,
476 "badRefOid",
477 false,
478 display_path.to_owned(),
479 format!("points to invalid object ID '{}'", oid.to_hex()),
480 );
481 } else if !odb.exists(&oid) {
482 push_issue(
483 issues,
484 config,
485 strict,
486 "missingObject",
487 false,
488 display_path.to_owned(),
489 format!("points to missing object {}", oid.to_hex()),
490 );
491 }
492
493 Ok(())
494}
495
496fn check_ref_file_name(
497 display_path: &str,
498 config: &ConfigSet,
499 strict: bool,
500 issues: &mut Vec<RefsFsckIssue>,
501) {
502 let check_path = ref_path_for_name_check(display_path);
503 if is_root_ref(check_path) || check_path == "HEAD" {
504 return;
505 }
506 if check_refname_format(
507 check_path,
508 &RefNameOptions {
509 allow_onelevel: false,
510 refspec_pattern: false,
511 normalize: false,
512 },
513 )
514 .is_err()
515 {
516 push_issue(
517 issues,
518 config,
519 strict,
520 "badRefName",
521 false,
522 display_path.to_owned(),
523 "invalid refname format".to_owned(),
524 );
525 }
526}
527
528fn fsck_symref_contents(
529 display_path: &str,
530 referent_raw: &str,
531 config: &ConfigSet,
532 strict: bool,
533 issues: &mut Vec<RefsFsckIssue>,
534) {
535 let orig_len = referent_raw.len();
537 let orig_last_byte = referent_raw.as_bytes().last().copied();
538 let trimmed = referent_raw.trim_end_matches(|c: char| c.is_ascii_whitespace());
539 let after_len = trimmed.len();
540
541 if after_len == orig_len || (after_len < orig_len && orig_last_byte != Some(b'\n')) {
542 push_issue(
543 issues,
544 config,
545 strict,
546 "refMissingNewline",
547 true,
548 display_path.to_owned(),
549 "misses LF at the end".to_owned(),
550 );
551 }
552 if after_len != orig_len && after_len != orig_len.saturating_sub(1) {
553 push_issue(
554 issues,
555 config,
556 strict,
557 "trailingRefContent",
558 true,
559 display_path.to_owned(),
560 "has trailing whitespaces or newlines".to_owned(),
561 );
562 }
563
564 refs_fsck_symref(display_path, trimmed, config, strict, issues);
565}
566
567fn refs_fsck_symref(
568 display_path: &str,
569 target: &str,
570 config: &ConfigSet,
571 strict: bool,
572 issues: &mut Vec<RefsFsckIssue>,
573) {
574 let stripped = stripped_for_head_check(display_path);
575 if stripped == "HEAD" && !target.starts_with("refs/heads/") {
576 push_issue(
577 issues,
578 config,
579 strict,
580 "badHeadTarget",
581 false,
582 display_path.to_owned(),
583 format!("HEAD points to non-branch '{target}'"),
584 );
585 }
586
587 if is_root_ref(target) {
588 return;
589 }
590
591 if check_refname_format(
592 target,
593 &RefNameOptions {
594 allow_onelevel: false,
595 refspec_pattern: false,
596 normalize: false,
597 },
598 )
599 .is_err()
600 {
601 push_issue(
602 issues,
603 config,
604 strict,
605 "badReferentName",
606 false,
607 display_path.to_owned(),
608 format!("points to invalid refname '{target}'"),
609 );
610 return;
611 }
612
613 if !target.starts_with("refs/") && !target.starts_with("worktrees/") {
614 push_issue(
615 issues,
616 config,
617 strict,
618 "symrefTargetIsNotARef",
619 true,
620 display_path.to_owned(),
621 format!("points to non-ref target '{target}'"),
622 );
623 }
624}
625
626fn cmp_packed_refname(r1: &str, r2: &str) -> Ordering {
627 let b1 = r1.as_bytes();
628 let b2 = r2.as_bytes();
629 let mut i = 0;
630 loop {
631 let c1 = b1.get(i).copied();
632 let c2 = b2.get(i).copied();
633 match (c1, c2) {
634 (None, None) => return Ordering::Equal,
635 (Some(b'\n'), None) => return Ordering::Less,
636 (None, Some(b'\n')) => return Ordering::Greater,
637 (Some(b'\n'), Some(b'\n')) => return Ordering::Equal,
638 (Some(b'\n'), _) => return Ordering::Less,
639 (_, Some(b'\n')) => return Ordering::Greater,
640 (Some(a), Some(b)) if a != b => return a.cmp(&b),
641 (Some(_), Some(_)) => i += 1,
642 (None, Some(_)) => return Ordering::Less,
643 (Some(_), None) => return Ordering::Greater,
644 }
645 }
646}
647
648fn fsck_packed_refs(
649 common_dir: &Path,
650 config: &ConfigSet,
651 strict: bool,
652 issues: &mut Vec<RefsFsckIssue>,
653) -> io::Result<()> {
654 let path = common_dir.join("packed-refs");
655 let meta = match fs::symlink_metadata(&path) {
656 Ok(m) => m,
657 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
658 Err(e) => return Err(e),
659 };
660 if meta.is_symlink() {
661 push_issue(
662 issues,
663 config,
664 strict,
665 "badRefFiletype",
666 false,
667 "packed-refs".to_owned(),
668 "not a regular file but a symlink".to_owned(),
669 );
670 return Ok(());
671 }
672 if !meta.is_file() {
673 push_issue(
674 issues,
675 config,
676 strict,
677 "badRefFiletype",
678 false,
679 "packed-refs".to_owned(),
680 "not a regular file".to_owned(),
681 );
682 return Ok(());
683 }
684 let data = fs::read(&path)?;
685 if data.is_empty() {
686 push_issue(
687 issues,
688 config,
689 strict,
690 "emptyPackedRefsFile",
691 true,
692 "packed-refs".to_owned(),
693 "file is empty".to_owned(),
694 );
695 return Ok(());
696 }
697
698 let text = String::from_utf8_lossy(&data).into_owned();
699 let mut sorted = false;
700 let mut main_ref_order: Vec<(usize, String)> = Vec::new();
701
702 for (line_no, raw_line) in text.lines().enumerate() {
703 let line_no = line_no + 1;
704 let line = raw_line.trim_end_matches('\r');
705
706 if line_no == 1 && line.starts_with('#') {
707 if line.starts_with("# pack-refs with: ") {
708 let traits = line
709 .strip_prefix("# pack-refs with: ")
710 .unwrap_or("")
711 .split_whitespace();
712 sorted = traits.clone().any(|t| t == "sorted");
713 } else if line.contains("pack-refs") {
714 push_issue(
715 issues,
716 config,
717 strict,
718 "badPackedRefHeader",
719 false,
720 "packed-refs.header".to_owned(),
721 format!("'{line}' does not start with '# pack-refs with: '"),
722 );
723 }
724 continue;
725 }
726
727 if line.is_empty() || line.starts_with('#') {
728 continue;
729 }
730
731 if let Some(inner) = line.strip_prefix('^') {
732 let mut j = 0usize;
733 while j < inner.len() && inner.as_bytes()[j].is_ascii_hexdigit() {
734 j += 1;
735 }
736 if j != 40 {
737 push_issue(
738 issues,
739 config,
740 strict,
741 "badPackedRefEntry",
742 false,
743 format!("packed-refs line {line_no}"),
744 format!("'{inner}' has invalid peeled oid"),
745 );
746 } else if j < inner.len() {
747 push_issue(
748 issues,
749 config,
750 strict,
751 "badPackedRefEntry",
752 false,
753 format!("packed-refs line {line_no}"),
754 format!("has trailing garbage after peeled oid '{}'", &inner[40..]),
755 );
756 }
757 continue;
758 }
759
760 let mut j = 0usize;
761 while j < line.len() && line.as_bytes()[j].is_ascii_hexdigit() {
762 j += 1;
763 }
764 let oid_hex = &line[..j];
765 let rest = &line[j..];
766
767 if oid_hex.len() != 40 {
768 let display_line = format!("{oid_hex}{rest}");
769 push_issue(
770 issues,
771 config,
772 strict,
773 "badPackedRefEntry",
774 false,
775 format!("packed-refs line {line_no}"),
776 format!("'{display_line}' has invalid oid"),
777 );
778 continue;
779 }
780
781 if rest.is_empty()
782 || !rest
783 .as_bytes()
784 .first()
785 .is_some_and(|b| b.is_ascii_whitespace())
786 {
787 push_issue(
788 issues,
789 config,
790 strict,
791 "badPackedRefEntry",
792 false,
793 format!("packed-refs line {line_no}"),
794 format!(
795 "has no space after oid '{oid_hex}' but with '{}'",
796 rest.trim_end_matches('\r')
797 ),
798 );
799 continue;
800 }
801
802 let rest = rest.trim_end_matches('\r');
804 let refname = match rest.chars().next() {
805 Some(c) if c.is_whitespace() => &rest[c.len_utf8()..],
806 _ => rest,
807 };
808
809 if check_refname_format(
810 refname,
811 &RefNameOptions {
812 allow_onelevel: false,
813 refspec_pattern: false,
814 normalize: false,
815 },
816 )
817 .is_err()
818 {
819 push_issue(
820 issues,
821 config,
822 strict,
823 "badRefName",
824 false,
825 format!("packed-refs line {line_no}"),
826 format!("has bad refname '{refname}'"),
827 );
828 }
829
830 main_ref_order.push((line_no, refname.to_owned()));
831 }
832
833 if sorted && main_ref_order.len() >= 2 {
834 let mut former: Option<&str> = None;
835 for (line_no, refname) in &main_ref_order {
836 if let Some(prev) = former {
837 if cmp_packed_refname(refname, prev) != Ordering::Greater {
838 push_issue(
839 issues,
840 config,
841 strict,
842 "packedRefUnsorted",
843 false,
844 format!("packed-refs line {line_no}"),
845 format!("refname '{refname}' is less than previous refname '{prev}'"),
846 );
847 break;
848 }
849 }
850 former = Some(refname.as_str());
851 }
852 }
853
854 Ok(())
855}