Skip to main content

grit_lib/
refs_fsck.rs

1//! Reference database consistency checks for `git refs verify` and `git fsck --references`.
2//!
3//! Aligns with Git's `refs_fsck` / `files_fsck_*` and `packed_fsck` behavior and message text.
4
5use 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/// Severity of a refs-fsck diagnostic.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum RefsFsckSeverity {
19    Error,
20    Warning,
21}
22
23/// One diagnostic (use [`format_refs_fsck_line`] for Git-compatible output).
24#[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/// `error: path: msgId: detail` / `warning: ...`
33#[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
73/// Matches Git's `is_root_ref` closely enough for fsck root ref enumeration.
74fn 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
160/// Run ref database checks (files backend + packed-refs). `strict` is `git refs verify --strict`.
161pub 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    // Preserve discovery order (matches Git). Do not sort: message order matters for the same
194    // path (e.g. `symlinkRef` before `badReferentName`), and `packed-refs line N` sorts
195    // incorrectly as strings (`line 10` before `line 2`). Aggregate tests use `sort` on output.
196    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
238/// Resolve `base.join(rel)` with `..` collapsed (Git-style symlink target handling when the
239/// destination path does not exist and `canonicalize` fails).
240fn 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    // A valid ref points at a full hex OID (40 chars for SHA-1, 64 for SHA-256).
402    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        // Git: warn when `*trailing != '\n' || *(trailing + 1)` — only a lone `\n` after the oid
460        // is valid; anything else (including `\n\n\n`) is reported with the full tail string.
461        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    // Match `files_fsck_symref_target` + `strbuf_rtrim` (trim trailing ASCII whitespace).
537    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        // Skip the single separator whitespace after the oid (Git: `p++` after `isspace`).
804        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}