use std::cmp::Ordering;
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use crate::check_ref_format::{check_refname_format, RefNameOptions};
use crate::config::ConfigSet;
use crate::objects::ObjectId;
use crate::odb::Odb;
use crate::repo::Repository;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefsFsckSeverity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct RefsFsckIssue {
pub severity: RefsFsckSeverity,
pub path: String,
pub msg_id: &'static str,
pub detail: String,
}
#[must_use]
pub fn format_refs_fsck_line(issue: &RefsFsckIssue) -> String {
let level = match issue.severity {
RefsFsckSeverity::Error => "error",
RefsFsckSeverity::Warning => "warning",
};
format!(
"{}: {}: {}: {}",
level, issue.path, issue.msg_id, issue.detail
)
}
fn canonical_git_dir(git_dir: &Path) -> PathBuf {
let commondir_file = git_dir.join("commondir");
let Some(raw) = fs::read_to_string(commondir_file).ok() else {
return git_dir.to_path_buf();
};
let rel = raw.trim();
if rel.is_empty() {
return git_dir.to_path_buf();
}
let path = if Path::new(rel).is_absolute() {
PathBuf::from(rel)
} else {
git_dir.join(rel)
};
path.canonicalize().unwrap_or(path)
}
fn is_pseudo_ref(name: &str) -> bool {
matches!(name, "FETCH_HEAD" | "MERGE_HEAD")
}
fn is_root_ref_syntax(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_uppercase() || b == b'-' || b == b'_')
}
fn is_root_ref(name: &str) -> bool {
if !is_root_ref_syntax(name) || is_pseudo_ref(name) {
return false;
}
if name.ends_with("_HEAD") {
return true;
}
matches!(
name,
"HEAD"
| "AUTO_MERGE"
| "BISECT_EXPECTED_REV"
| "NOTES_MERGE_PARTIAL"
| "NOTES_MERGE_REF"
| "MERGE_AUTOSTASH"
)
}
fn stripped_for_head_check(display_path: &str) -> &str {
display_path
.strip_prefix("worktrees/")
.and_then(|s| s.find('/').map(|i| &s[i + 1..]))
.unwrap_or(display_path)
}
fn ref_path_for_name_check(display_path: &str) -> &str {
if let Some(rest) = display_path.strip_prefix("worktrees/") {
if let Some(idx) = rest.find("/refs/") {
return &rest[idx + 1..];
}
if rest.ends_with("/HEAD") || rest == "HEAD" {
return "HEAD";
}
}
display_path
}
fn fsck_refs_msg_severity(
config: &ConfigSet,
camel_id: &str,
default_warn: bool,
strict: bool,
) -> Option<RefsFsckSeverity> {
let key = format!("fsck.{camel_id}");
let v = config.get(&key).map(|s| s.to_ascii_lowercase());
if matches!(v.as_deref(), Some("ignore")) {
return None;
}
let level = match v.as_deref() {
Some("warn") => RefsFsckSeverity::Warning,
Some("error") => RefsFsckSeverity::Error,
_ => {
if default_warn {
if strict {
RefsFsckSeverity::Error
} else {
RefsFsckSeverity::Warning
}
} else {
RefsFsckSeverity::Error
}
}
};
Some(level)
}
fn push_issue(
issues: &mut Vec<RefsFsckIssue>,
config: &ConfigSet,
strict: bool,
camel_id: &'static str,
default_warn: bool,
path: String,
detail: String,
) {
let Some(sev) = fsck_refs_msg_severity(config, camel_id, default_warn, strict) else {
return;
};
issues.push(RefsFsckIssue {
severity: sev,
path,
msg_id: camel_id,
detail,
});
}
pub fn refs_fsck(
repo: &Repository,
odb: &Odb,
config: &ConfigSet,
strict: bool,
) -> io::Result<Vec<RefsFsckIssue>> {
let mut issues = Vec::new();
let common = canonical_git_dir(&repo.git_dir);
let mut stores: Vec<(PathBuf, Option<String>)> = vec![(common.clone(), None)];
let worktrees_dir = common.join("worktrees");
if let Ok(rd) = fs::read_dir(&worktrees_dir) {
for e in rd.flatten() {
if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let id = e.file_name().to_string_lossy().to_string();
stores.push((e.path(), Some(id)));
}
}
}
for (git_dir, wt_id) in stores {
fsck_worktree(
&git_dir,
wt_id.as_deref(),
&common,
odb,
config,
strict,
&mut issues,
)?;
}
Ok(issues)
}
fn fsck_worktree(
git_dir: &Path,
worktree_id: Option<&str>,
common_dir: &Path,
odb: &Odb,
config: &ConfigSet,
strict: bool,
issues: &mut Vec<RefsFsckIssue>,
) -> io::Result<()> {
let refs_dir = git_dir.join("refs");
if refs_dir.is_dir() {
walk_refs_files(common_dir, &refs_dir, odb, config, strict, issues)?;
}
if worktree_id.is_none() {
fsck_packed_refs(common_dir, config, strict, issues)?;
}
fsck_root_refs(
git_dir,
common_dir,
path_prefix_for_root(worktree_id),
odb,
config,
strict,
issues,
)?;
Ok(())
}
fn path_prefix_for_root(worktree_id: Option<&str>) -> Option<String> {
worktree_id.map(|id| format!("worktrees/{id}/"))
}
fn display_rel_path(common_dir: &Path, path: &Path) -> String {
let rel = path.strip_prefix(common_dir).unwrap_or(path);
rel.to_string_lossy().replace('\\', "/")
}
fn normalize_joined_path(base: &Path, rel: &Path) -> PathBuf {
let combined = base.join(rel);
let mut out = PathBuf::new();
for comp in combined.components() {
match comp {
Component::Prefix(p) => out.push(p.as_os_str()),
Component::RootDir => out.push(Component::RootDir.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
let _ = out.pop();
}
Component::Normal(p) => out.push(p),
}
}
out
}
fn walk_refs_files(
common_dir: &Path,
dir: &Path,
odb: &Odb,
config: &ConfigSet,
strict: bool,
issues: &mut Vec<RefsFsckIssue>,
) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let fname = entry.file_name().to_string_lossy().to_string();
if fname == "." || fname == ".." {
continue;
}
if path.is_dir() {
walk_refs_files(common_dir, &path, odb, config, strict, issues)?;
continue;
}
if !path.is_file() && !path.is_symlink() {
continue;
}
if !fname.starts_with('.') && fname.ends_with(".lock") {
continue;
}
let display = display_rel_path(common_dir, &path);
verify_loose_ref(common_dir, &display, &path, odb, config, strict, issues)?;
}
Ok(())
}
fn fsck_root_refs(
git_dir: &Path,
common_dir: &Path,
path_prefix: Option<String>,
odb: &Odb,
config: &ConfigSet,
strict: bool,
issues: &mut Vec<RefsFsckIssue>,
) -> io::Result<()> {
let Ok(rd) = fs::read_dir(git_dir) else {
return Ok(());
};
for entry in rd.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if !name.starts_with('.') && name.ends_with(".lock") {
continue;
}
if !is_root_ref(&name) {
continue;
}
let path = entry.path();
let meta = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() && !meta.is_symlink() {
continue;
}
let display = match &path_prefix {
Some(p) => format!("{p}{name}"),
None => name,
};
verify_loose_ref(common_dir, &display, &path, odb, config, strict, issues)?;
}
Ok(())
}
fn verify_loose_ref(
common_dir: &Path,
display_path: &str,
path: &Path,
odb: &Odb,
config: &ConfigSet,
strict: bool,
issues: &mut Vec<RefsFsckIssue>,
) -> io::Result<()> {
check_ref_file_name(display_path, config, strict, issues);
let meta = fs::symlink_metadata(path)?;
if !meta.is_file() && !meta.is_symlink() {
push_issue(
issues,
config,
strict,
"badRefFiletype",
false,
display_path.to_owned(),
"unexpected file type".to_owned(),
);
return Ok(());
}
if meta.is_symlink() {
push_issue(
issues,
config,
strict,
"symlinkRef",
true,
display_path.to_owned(),
"use deprecated symbolic link for symref".to_owned(),
);
let target = fs::read_link(path)?;
let parent = path.parent().unwrap_or(Path::new(""));
let joined = normalize_joined_path(parent, Path::new(&target));
let resolved = fs::canonicalize(&joined).unwrap_or(joined);
let abs_common = fs::canonicalize(common_dir).unwrap_or(common_dir.to_path_buf());
let g = abs_common.to_string_lossy();
let r = resolved.to_string_lossy().to_string();
let referent = if r.starts_with(g.as_ref()) {
let rest = &r[g.len()..];
rest.trim_start_matches(['/', '\\']).replace('\\', "/")
} else {
r.replace('\\', "/")
};
refs_fsck_symref(display_path, &referent, config, strict, issues);
return Ok(());
}
let raw = fs::read_to_string(path)?;
let buf = raw.strip_suffix('\r').unwrap_or(&raw);
if let Some(after) = buf.strip_prefix("ref:") {
let mut s = after;
while s
.as_bytes()
.first()
.is_some_and(|b| b.is_ascii_whitespace())
{
s = &s[1..];
}
fsck_symref_contents(display_path, s, config, strict, issues);
return Ok(());
}
let bytes = buf.as_bytes();
let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
i += 1;
}
if i != 40 {
push_issue(
issues,
config,
strict,
"badRefContent",
false,
display_path.to_owned(),
buf.trim_end_matches(['\n', '\r']).to_owned(),
);
return Ok(());
}
let oid: ObjectId = match buf[..40].parse() {
Ok(o) => o,
Err(_) => {
push_issue(
issues,
config,
strict,
"badRefContent",
false,
display_path.to_owned(),
buf.trim_end_matches(['\n', '\r']).to_owned(),
);
return Ok(());
}
};
let trailing = &buf[40..];
if !trailing.is_empty()
&& !trailing
.as_bytes()
.first()
.is_some_and(|b| b.is_ascii_whitespace())
{
push_issue(
issues,
config,
strict,
"badRefContent",
false,
display_path.to_owned(),
buf.trim_end_matches(['\n', '\r']).to_owned(),
);
return Ok(());
}
if trailing.is_empty() {
push_issue(
issues,
config,
strict,
"refMissingNewline",
true,
display_path.to_owned(),
"misses LF at the end".to_owned(),
);
} else if trailing != "\n" {
push_issue(
issues,
config,
strict,
"trailingRefContent",
true,
display_path.to_owned(),
format!("has trailing garbage: '{trailing}'"),
);
}
if oid.is_zero() {
push_issue(
issues,
config,
strict,
"badRefOid",
false,
display_path.to_owned(),
format!("points to invalid object ID '{}'", oid.to_hex()),
);
} else if !odb.exists(&oid) {
push_issue(
issues,
config,
strict,
"missingObject",
false,
display_path.to_owned(),
format!("points to missing object {}", oid.to_hex()),
);
}
Ok(())
}
fn check_ref_file_name(
display_path: &str,
config: &ConfigSet,
strict: bool,
issues: &mut Vec<RefsFsckIssue>,
) {
let check_path = ref_path_for_name_check(display_path);
if is_root_ref(check_path) || check_path == "HEAD" {
return;
}
if check_refname_format(
check_path,
&RefNameOptions {
allow_onelevel: false,
refspec_pattern: false,
normalize: false,
},
)
.is_err()
{
push_issue(
issues,
config,
strict,
"badRefName",
false,
display_path.to_owned(),
"invalid refname format".to_owned(),
);
}
}
fn fsck_symref_contents(
display_path: &str,
referent_raw: &str,
config: &ConfigSet,
strict: bool,
issues: &mut Vec<RefsFsckIssue>,
) {
let orig_len = referent_raw.len();
let orig_last_byte = referent_raw.as_bytes().last().copied();
let trimmed = referent_raw.trim_end_matches(|c: char| c.is_ascii_whitespace());
let after_len = trimmed.len();
if after_len == orig_len || (after_len < orig_len && orig_last_byte != Some(b'\n')) {
push_issue(
issues,
config,
strict,
"refMissingNewline",
true,
display_path.to_owned(),
"misses LF at the end".to_owned(),
);
}
if after_len != orig_len && after_len != orig_len.saturating_sub(1) {
push_issue(
issues,
config,
strict,
"trailingRefContent",
true,
display_path.to_owned(),
"has trailing whitespaces or newlines".to_owned(),
);
}
refs_fsck_symref(display_path, trimmed, config, strict, issues);
}
fn refs_fsck_symref(
display_path: &str,
target: &str,
config: &ConfigSet,
strict: bool,
issues: &mut Vec<RefsFsckIssue>,
) {
let stripped = stripped_for_head_check(display_path);
if stripped == "HEAD" && !target.starts_with("refs/heads/") {
push_issue(
issues,
config,
strict,
"badHeadTarget",
false,
display_path.to_owned(),
format!("HEAD points to non-branch '{target}'"),
);
}
if is_root_ref(target) {
return;
}
if check_refname_format(
target,
&RefNameOptions {
allow_onelevel: false,
refspec_pattern: false,
normalize: false,
},
)
.is_err()
{
push_issue(
issues,
config,
strict,
"badReferentName",
false,
display_path.to_owned(),
format!("points to invalid refname '{target}'"),
);
return;
}
if !target.starts_with("refs/") && !target.starts_with("worktrees/") {
push_issue(
issues,
config,
strict,
"symrefTargetIsNotARef",
true,
display_path.to_owned(),
format!("points to non-ref target '{target}'"),
);
}
}
fn cmp_packed_refname(r1: &str, r2: &str) -> Ordering {
let b1 = r1.as_bytes();
let b2 = r2.as_bytes();
let mut i = 0;
loop {
let c1 = b1.get(i).copied();
let c2 = b2.get(i).copied();
match (c1, c2) {
(None, None) => return Ordering::Equal,
(Some(b'\n'), None) => return Ordering::Less,
(None, Some(b'\n')) => return Ordering::Greater,
(Some(b'\n'), Some(b'\n')) => return Ordering::Equal,
(Some(b'\n'), _) => return Ordering::Less,
(_, Some(b'\n')) => return Ordering::Greater,
(Some(a), Some(b)) if a != b => return a.cmp(&b),
(Some(_), Some(_)) => i += 1,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
}
}
}
fn fsck_packed_refs(
common_dir: &Path,
config: &ConfigSet,
strict: bool,
issues: &mut Vec<RefsFsckIssue>,
) -> io::Result<()> {
let path = common_dir.join("packed-refs");
let meta = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
if meta.is_symlink() {
push_issue(
issues,
config,
strict,
"badRefFiletype",
false,
"packed-refs".to_owned(),
"not a regular file but a symlink".to_owned(),
);
return Ok(());
}
if !meta.is_file() {
push_issue(
issues,
config,
strict,
"badRefFiletype",
false,
"packed-refs".to_owned(),
"not a regular file".to_owned(),
);
return Ok(());
}
let data = fs::read(&path)?;
if data.is_empty() {
push_issue(
issues,
config,
strict,
"emptyPackedRefsFile",
true,
"packed-refs".to_owned(),
"file is empty".to_owned(),
);
return Ok(());
}
let text = String::from_utf8_lossy(&data).into_owned();
let mut sorted = false;
let mut main_ref_order: Vec<(usize, String)> = Vec::new();
for (line_no, raw_line) in text.lines().enumerate() {
let line_no = line_no + 1;
let line = raw_line.trim_end_matches('\r');
if line_no == 1 && line.starts_with('#') {
if line.starts_with("# pack-refs with: ") {
let traits = line
.strip_prefix("# pack-refs with: ")
.unwrap_or("")
.split_whitespace();
sorted = traits.clone().any(|t| t == "sorted");
} else if line.contains("pack-refs") {
push_issue(
issues,
config,
strict,
"badPackedRefHeader",
false,
"packed-refs.header".to_owned(),
format!("'{line}' does not start with '# pack-refs with: '"),
);
}
continue;
}
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(inner) = line.strip_prefix('^') {
let mut j = 0usize;
while j < inner.len() && inner.as_bytes()[j].is_ascii_hexdigit() {
j += 1;
}
if j != 40 {
push_issue(
issues,
config,
strict,
"badPackedRefEntry",
false,
format!("packed-refs line {line_no}"),
format!("'{inner}' has invalid peeled oid"),
);
} else if j < inner.len() {
push_issue(
issues,
config,
strict,
"badPackedRefEntry",
false,
format!("packed-refs line {line_no}"),
format!("has trailing garbage after peeled oid '{}'", &inner[40..]),
);
}
continue;
}
let mut j = 0usize;
while j < line.len() && line.as_bytes()[j].is_ascii_hexdigit() {
j += 1;
}
let oid_hex = &line[..j];
let rest = &line[j..];
if oid_hex.len() != 40 {
let display_line = format!("{oid_hex}{rest}");
push_issue(
issues,
config,
strict,
"badPackedRefEntry",
false,
format!("packed-refs line {line_no}"),
format!("'{display_line}' has invalid oid"),
);
continue;
}
if rest.is_empty()
|| !rest
.as_bytes()
.first()
.is_some_and(|b| b.is_ascii_whitespace())
{
push_issue(
issues,
config,
strict,
"badPackedRefEntry",
false,
format!("packed-refs line {line_no}"),
format!(
"has no space after oid '{oid_hex}' but with '{}'",
rest.trim_end_matches('\r')
),
);
continue;
}
let rest = rest.trim_end_matches('\r');
let refname = match rest.chars().next() {
Some(c) if c.is_whitespace() => &rest[c.len_utf8()..],
_ => rest,
};
if check_refname_format(
refname,
&RefNameOptions {
allow_onelevel: false,
refspec_pattern: false,
normalize: false,
},
)
.is_err()
{
push_issue(
issues,
config,
strict,
"badRefName",
false,
format!("packed-refs line {line_no}"),
format!("has bad refname '{refname}'"),
);
}
main_ref_order.push((line_no, refname.to_owned()));
}
if sorted && main_ref_order.len() >= 2 {
let mut former: Option<&str> = None;
for (line_no, refname) in &main_ref_order {
if let Some(prev) = former {
if cmp_packed_refname(refname, prev) != Ordering::Greater {
push_issue(
issues,
config,
strict,
"packedRefUnsorted",
false,
format!("packed-refs line {line_no}"),
format!("refname '{refname}' is less than previous refname '{prev}'"),
);
break;
}
}
former = Some(refname.as_str());
}
}
Ok(())
}