detached df84e16

fix(refs): parse packed-refs once per listing and apply git's racy rule (GO-001, GO-002, GO-004, GO-012, GO-014)

collectRefs walks the loose files first, then loads packed-refs once and
resolves every name against it, instead of calling lookupRef (and so
statting and possibly re-parsing the file) per name. loadPacked rechecks
after taking pmu so concurrent callers share one parse, and a racy file
whose content did not change keeps its parse.

fileSig now records when it was read and is trusted only when that was more
than racyWindow after the mtime (git's racy-git rule), not measured from
now. Refs over 5000 packed refs right after a rewrite: 677 ms -> 1.5 ms.

A corrupt pseudo-ref at the top of the git dir (not HEAD) is not found.
Head, Refs, ResolveRef, LookupRef and Resolve return ErrClosed after Close.
Resolve documents git's order, tags before heads, and the 40-hex case.
commit
df84e16c30768f472ba52f7a84ace472f681a945
author
brice · · 2026-10-10 22:15 UTC
parent
219ceec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +515 -65
M refs.go +160 -55
@@ -12,6 +12,7 @@ import (
1212 "slices"
1313 "strings"
1414 "syscall"
15+ "time"
1516 )
1617
1718 const (
@@ -51,6 +52,7 @@ type packedRef struct {
5152 // packedRefs is an immutable parsed packed-refs file, sorted by name.
5253 type packedRefs struct {
5354 sig fileSig
55+ raw string // the file content: the names in refs point into it
5456 refs []packedRef
5557 }
5658
@@ -65,7 +67,8 @@ func (p *packedRefs) find(name string) (packedRef, bool) {
6567 }
6668
6769 // loadPacked returns the current content of packed-refs, reparsing the file
68-// when its stat data changed (or always with force).
70+// when its stat data changed (or always with force). Concurrent callers share
71+// one parse: the check is repeated once pmu is held.
6972 func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
7073 path := r.dir + "/packed-refs"
7174 sig, err := statSig(path)
@@ -77,9 +80,17 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
7780 }
7881 r.pmu.Lock()
7982 defer r.pmu.Unlock()
83+ cur := r.packed.Load()
84+ if !force && cur != nil && (cur.sig.read > sig.read || cur.sig.same(sig)) {
85+ // Another caller parsed the file while we waited for the lock. It read
86+ // the file after we looked at it, so its result is at least as fresh
87+ // as ours would be.
88+ return cur, nil
89+ }
90+ read := time.Now().UnixNano()
8091 f, err := os.Open(path)
8192 if errors.Is(err, fs.ErrNotExist) {
82- p := &packedRefs{}
93+ p := &packedRefs{sig: fileSig{read: read}}
8394 r.packed.Store(p)
8495 return p, nil
8596 }
@@ -98,6 +109,13 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
98109 if _, err := io.ReadFull(f, data); err != nil {
99110 return nil, err
100111 }
112+ if !force && cur != nil && cur.raw == string(data) {
113+ // Racy stat data, same content (Git's racy rule): keep the parse
114+ // and record that the file was read now.
115+ p := &packedRefs{sig: sigOf(st, read), raw: cur.raw, refs: cur.refs}
116+ r.packed.Store(p)
117+ return p, nil
118+ }
101119 p, err := parsePackedRefs(string(data))
102120 if err != nil {
103121 return nil, err
@@ -107,7 +125,7 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
107125 }); i < len(p.refs) && strings.HasPrefix(p.refs[i].name, "refs/replace/") {
108126 return nil, unsupported("replace refs (refs/replace in packed-refs)")
109127 }
110- p.sig = sigOf(st)
128+ p.sig = sigOf(st, read)
111129 r.packed.Store(p)
112130 return p, nil
113131 }
@@ -115,7 +133,7 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
115133 // parsePackedRefs parses a packed-refs file. The ref names are substrings of
116134 // data.
117135 func parsePackedRefs(data string) (*packedRefs, error) {
118- p := &packedRefs{}
136+ p := &packedRefs{raw: data}
119137 fullyPeeled, peeled := false, false
120138 first := true
121139 for len(data) > 0 {
@@ -276,52 +294,100 @@ func validRootRef(name string) bool {
276294 // validRef accepts the names lookupRef may read.
277295 func validRef(name string) bool { return validRefName(name) || validRootRef(name) }
278296
279-// lookupRef resolves name through symbolic refs. loose refs win over packed
280-// ones. The loose file is read before packed-refs, so a concurrent pack-refs
281-// (which writes packed-refs first and deletes the loose file after) is never
282-// missed.
283-func (r *Repo) lookupRef(name string) (Ref, bool, error) {
297+// refWalk is a ref lookup that went through the loose files and may still
298+// need packed-refs.
299+type refWalk struct {
300+ ref Ref
301+ cur string // the name to look up in packed-refs when !done
302+ done bool // the lookup is complete: ok tells whether it found a ref
303+ ok bool
304+}
305+
306+// isRootRef reports whether name is a pseudo-ref of the Git directory
307+// (HEAD, ORIG_HEAD...) rather than a name under refs/.
308+func isRootRef(name string) bool { return !strings.HasPrefix(name, "refs/") }
309+
310+// lookupLoose follows name through the loose files and symbolic refs. It
311+// stops at a loose ref that holds an id, or at the first name that has no
312+// loose file: packed-refs decides about that one (see finish).
313+func (r *Repo) lookupLoose(name string) (refWalk, error) {
284314 if !validRef(name) {
285- return Ref{}, false, nil
315+ return refWalk{done: true}, nil
286316 }
287- ref := Ref{Name: name}
288- cur := name
317+ w := refWalk{ref: Ref{Name: name}, cur: name}
289318 for depth := 0; ; depth++ {
290319 if depth > maxSymrefDepth {
291- return Ref{}, false, corrupt("ref %s: symbolic ref loop", name)
320+ return refWalk{}, corrupt("ref %s: symbolic ref loop", name)
292321 }
293- lr, found, err := r.readLooseRef(cur)
322+ lr, found, err := r.readLooseRef(w.cur)
294323 if err != nil {
295- return Ref{}, false, err
324+ if errors.Is(err, ErrCorrupt) && isRootRef(w.cur) && w.cur != "HEAD" {
325+ // A pseudo-ref such as FETCH_HEAD or MERGE_HEAD can hold
326+ // anything (FETCH_HEAD has one line per fetched ref), and
327+ // a revision like "ABCDEF" may name such a file by
328+ // accident: it is not a ref, so not found. A broken HEAD
329+ // stays an error.
330+ return refWalk{done: true}, nil
331+ }
332+ return refWalk{}, err
296333 }
297334 if found && lr.symref != "" {
298335 if depth == 0 {
299- ref.Symref = lr.symref
336+ w.ref.Symref = lr.symref
300337 }
301338 if !validRef(lr.symref) {
302- return Ref{}, false, nil
339+ return refWalk{done: true}, nil
303340 }
304- cur = lr.symref
341+ w.cur = lr.symref
305342 continue
306343 }
307344 if found {
308- ref.ID = lr.id
309- return ref, true, nil
345+ w.ref.ID = lr.id
346+ w.done, w.ok = true, true
347+ return w, nil
310348 }
311- p, err := r.loadPacked(false)
312- if err != nil {
313- return Ref{}, false, err
314- }
315- e, ok := p.find(cur)
316- if !ok {
317- return Ref{}, false, nil
318- }
319- ref.ID = e.id
320- if depth == 0 {
321- ref.Peeled, ref.PeeledKnown = e.peeled, e.peeledKnown
322- }
323- return ref, true, nil
349+ return w, nil
350+ }
351+}
352+
353+// finish completes a lookup with the content of packed-refs. The loose files
354+// have to be read before packed-refs is loaded: a concurrent pack-refs writes
355+// packed-refs first and deletes the loose file after, so it is never missed.
356+func (w *refWalk) finish(p *packedRefs) (Ref, bool) {
357+ if w.done {
358+ return w.ref, w.ok
359+ }
360+ e, ok := p.find(w.cur)
361+ if !ok {
362+ return Ref{}, false
363+ }
364+ w.ref.ID = e.id
365+ if w.cur == w.ref.Name {
366+ w.ref.Peeled, w.ref.PeeledKnown = e.peeled, e.peeledKnown
367+ }
368+ return w.ref, true
369+}
370+
371+// lookupRef resolves name through symbolic refs. loose refs win over packed
372+// ones.
373+func (r *Repo) lookupRef(name string) (Ref, bool, error) {
374+ if r.snap.Load() == nil {
375+ return Ref{}, false, ErrClosed
376+ }
377+ w, err := r.lookupLoose(name)
378+ if err != nil {
379+ return Ref{}, false, err
380+ }
381+ if w.done {
382+ ref, ok := w.finish(nil)
383+ return ref, ok, nil
324384 }
385+ p, err := r.loadPacked(false)
386+ if err != nil {
387+ return Ref{}, false, err
388+ }
389+ ref, ok := w.finish(p)
390+ return ref, ok, nil
325391 }
326392
327393 // ResolveRef returns the object the ref with this full name (such as
@@ -356,6 +422,9 @@ func (r *Repo) LookupRef(name string) (Ref, error) {
356422 // yet) is not an error: id is then the zero ObjectID. For a detached HEAD
357423 // target is empty and id is the commit.
358424 func (r *Repo) Head() (target string, id ObjectID, err error) {
425+ if r.snap.Load() == nil {
426+ return "", ObjectID{}, ErrClosed
427+ }
359428 lr, found, err := r.readLooseRef("HEAD")
360429 if err != nil {
361430 return "", ObjectID{}, err
@@ -397,7 +466,9 @@ func (r *Repo) Refs(prefix string) iter.Seq2[Ref, error] {
397466 }
398467
399468 func (r *Repo) collectRefs(prefix string) ([]Ref, error) {
400- // Loose refs first, packed-refs after: see lookupRef.
469+ if r.snap.Load() == nil {
470+ return nil, ErrClosed
471+ }
401472 var dir string
402473 switch {
403474 case strings.HasPrefix(prefix, "refs/"):
@@ -407,15 +478,33 @@ func (r *Repo) collectRefs(prefix string) ([]Ref, error) {
407478 default:
408479 return nil, nil
409480 }
481+ // Loose refs first, packed-refs after: see refWalk.finish.
410482 var loose []string
411483 if err := r.walkLoose(dir, prefix, &loose, 0); err != nil {
412484 return nil, err
413485 }
486+ slices.Sort(loose)
487+ loose = slices.Compact(loose)
488+ type item struct {
489+ name string
490+ w refWalk
491+ }
492+ items := make([]item, 0, len(loose))
493+ for _, n := range loose {
494+ w, err := r.lookupLoose(n)
495+ if err != nil {
496+ if errors.Is(err, ErrCorrupt) {
497+ continue // Git ignores broken refs when listing
498+ }
499+ return nil, err
500+ }
501+ items = append(items, item{name: n, w: w})
502+ }
503+ // One load of packed-refs serves every name.
414504 p, err := r.loadPacked(false)
415505 if err != nil {
416506 return nil, err
417507 }
418- names := loose
419508 start, _ := slices.BinarySearchFunc(p.refs, prefix, func(e packedRef, pre string) int {
420509 if strings.HasPrefix(e.name, pre) {
421510 return 0
@@ -427,23 +516,25 @@ func (r *Repo) collectRefs(prefix string) ([]Ref, error) {
427516 for start > 0 && strings.HasPrefix(p.refs[start-1].name, prefix) {
428517 start--
429518 }
519+ nLoose := len(items)
430520 for i := start; i < len(p.refs) && strings.HasPrefix(p.refs[i].name, prefix); i++ {
431- names = append(names, p.refs[i].name)
432- }
433- slices.Sort(names)
434- names = slices.Compact(names)
435-
436- out := make([]Ref, 0, len(names))
437- for _, n := range names {
438- ref, ok, err := r.lookupRef(n)
439- if err != nil {
440- if errors.Is(err, ErrCorrupt) {
441- continue // Git ignores broken refs when listing
442- }
443- return nil, err
521+ n := p.refs[i].name
522+ if _, ok := slices.BinarySearch(loose, n); ok {
523+ continue // the loose file wins; its walk is already in items
444524 }
445- if ok {
446- ref.Name = n
525+ if !validRef(n) {
526+ continue
527+ }
528+ items = append(items, item{name: n, w: refWalk{ref: Ref{Name: n}, cur: n}})
529+ }
530+ if len(items) > nLoose {
531+ slices.SortFunc(items, func(a, b item) int { return strings.Compare(a.name, b.name) })
532+ }
533+ out := make([]Ref, 0, len(items))
534+ for i := range items {
535+ it := &items[i]
536+ if ref, ok := it.w.finish(p); ok {
537+ ref.Name = it.name
447538 out = append(out, ref)
448539 }
449540 }
@@ -500,11 +591,25 @@ func (r *Repo) PeelRef(ref Ref) (ObjectID, error) {
500591 }
501592
502593 // Resolve turns a revision name into an object id the way "git rev-parse"
503-// does for plain names: a full 40 digit id, "HEAD", a full ref name, a short
504-// ref name tried as "<n>", "refs/<n>", "refs/tags/<n>", "refs/heads/<n>",
505-// "refs/remotes/<n>" and "refs/remotes/<n>/HEAD" in that order, and finally
506-// an abbreviated id of 4 to 39 digits. Revision operators (~, ^, @{}) are not
507-// supported. The result is not peeled; use PeelToCommit.
594+// does for plain names. The result is not peeled; use PeelToCommit. Revision
595+// operators (~, ^, @{}, :path) are not supported.
596+//
597+// The name is tried in git's order, and the first match wins:
598+//
599+// 1. a full 40 digit id. It is taken as an id even when a ref has that name
600+// (git warns that the ref is ambiguous and ignores it), and it must exist
601+// in the repository, which "git rev-parse" does not check;
602+// 2. a ref, as "<n>", "refs/<n>", "refs/tags/<n>", "refs/heads/<n>",
603+// "refs/remotes/<n>" and "refs/remotes/<n>/HEAD", in that order. A tag
604+// therefore wins over a branch of the same name, whereas "git checkout"
605+// prefers the branch;
606+// 3. an abbreviated id of 4 to 39 digits, which loses against any ref of
607+// that name. It matches any object, whatever its type, and fails with
608+// ErrAmbiguous if several do; see ResolveCommitPrefix.
609+//
610+// A caller that wants a branch or a tag, or a commit, should not use Resolve:
611+// ResolveRef takes the full ref name and ResolveCommitPrefix only considers
612+// commits. Resolve returns an error wrapping ErrNotFound if nothing matches.
508613 func (r *Repo) Resolve(rev string) (ObjectID, error) {
509614 if rev == "" {
510615 return ObjectID{}, notFound("empty revision")
@@ -12,6 +12,7 @@ import (
12 "slices"12 "slices"
13 "strings"13 "strings"
14 "syscall"14 "syscall"
15+ "time"
15 )16 )
16 17
17 const (18 const (
@@ -51,6 +52,7 @@ type packedRef struct {
51 // packedRefs is an immutable parsed packed-refs file, sorted by name.52 // packedRefs is an immutable parsed packed-refs file, sorted by name.
52 type packedRefs struct {53 type packedRefs struct {
53 sig fileSig54 sig fileSig
55+ raw string // the file content: the names in refs point into it
54 refs []packedRef56 refs []packedRef
55 }57 }
56 58
@@ -65,7 +67,8 @@ func (p *packedRefs) find(name string) (packedRef, bool) {
65 }67 }
66 68
67 // loadPacked returns the current content of packed-refs, reparsing the file69 // loadPacked returns the current content of packed-refs, reparsing the file
68-// when its stat data changed (or always with force).70+// when its stat data changed (or always with force). Concurrent callers share
71+// one parse: the check is repeated once pmu is held.
69 func (r *Repo) loadPacked(force bool) (*packedRefs, error) {72 func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
70 path := r.dir + "/packed-refs"73 path := r.dir + "/packed-refs"
71 sig, err := statSig(path)74 sig, err := statSig(path)
@@ -77,9 +80,17 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
77 }80 }
78 r.pmu.Lock()81 r.pmu.Lock()
79 defer r.pmu.Unlock()82 defer r.pmu.Unlock()
83+ cur := r.packed.Load()
84+ if !force && cur != nil && (cur.sig.read > sig.read || cur.sig.same(sig)) {
85+ // Another caller parsed the file while we waited for the lock. It read
86+ // the file after we looked at it, so its result is at least as fresh
87+ // as ours would be.
88+ return cur, nil
89+ }
90+ read := time.Now().UnixNano()
80 f, err := os.Open(path)91 f, err := os.Open(path)
81 if errors.Is(err, fs.ErrNotExist) {92 if errors.Is(err, fs.ErrNotExist) {
82- p := &packedRefs{}93+ p := &packedRefs{sig: fileSig{read: read}}
83 r.packed.Store(p)94 r.packed.Store(p)
84 return p, nil95 return p, nil
85 }96 }
@@ -98,6 +109,13 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
98 if _, err := io.ReadFull(f, data); err != nil {109 if _, err := io.ReadFull(f, data); err != nil {
99 return nil, err110 return nil, err
100 }111 }
112+ if !force && cur != nil && cur.raw == string(data) {
113+ // Racy stat data, same content (Git's racy rule): keep the parse
114+ // and record that the file was read now.
115+ p := &packedRefs{sig: sigOf(st, read), raw: cur.raw, refs: cur.refs}
116+ r.packed.Store(p)
117+ return p, nil
118+ }
101 p, err := parsePackedRefs(string(data))119 p, err := parsePackedRefs(string(data))
102 if err != nil {120 if err != nil {
103 return nil, err121 return nil, err
@@ -107,7 +125,7 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
107 }); i < len(p.refs) && strings.HasPrefix(p.refs[i].name, "refs/replace/") {125 }); i < len(p.refs) && strings.HasPrefix(p.refs[i].name, "refs/replace/") {
108 return nil, unsupported("replace refs (refs/replace in packed-refs)")126 return nil, unsupported("replace refs (refs/replace in packed-refs)")
109 }127 }
110- p.sig = sigOf(st)128+ p.sig = sigOf(st, read)
111 r.packed.Store(p)129 r.packed.Store(p)
112 return p, nil130 return p, nil
113 }131 }
@@ -115,7 +133,7 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
115 // parsePackedRefs parses a packed-refs file. The ref names are substrings of133 // parsePackedRefs parses a packed-refs file. The ref names are substrings of
116 // data.134 // data.
117 func parsePackedRefs(data string) (*packedRefs, error) {135 func parsePackedRefs(data string) (*packedRefs, error) {
118- p := &packedRefs{}136+ p := &packedRefs{raw: data}
119 fullyPeeled, peeled := false, false137 fullyPeeled, peeled := false, false
120 first := true138 first := true
121 for len(data) > 0 {139 for len(data) > 0 {
@@ -276,52 +294,100 @@ func validRootRef(name string) bool {
276 // validRef accepts the names lookupRef may read.294 // validRef accepts the names lookupRef may read.
277 func validRef(name string) bool { return validRefName(name) || validRootRef(name) }295 func validRef(name string) bool { return validRefName(name) || validRootRef(name) }
278 296
279-// lookupRef resolves name through symbolic refs. loose refs win over packed297+// refWalk is a ref lookup that went through the loose files and may still
280-// ones. The loose file is read before packed-refs, so a concurrent pack-refs298+// need packed-refs.
281-// (which writes packed-refs first and deletes the loose file after) is never299+type refWalk struct {
282-// missed.300+ ref Ref
283-func (r *Repo) lookupRef(name string) (Ref, bool, error) {301+ cur string // the name to look up in packed-refs when !done
302+ done bool // the lookup is complete: ok tells whether it found a ref
303+ ok bool
304+}
305+
306+// isRootRef reports whether name is a pseudo-ref of the Git directory
307+// (HEAD, ORIG_HEAD...) rather than a name under refs/.
308+func isRootRef(name string) bool { return !strings.HasPrefix(name, "refs/") }
309+
310+// lookupLoose follows name through the loose files and symbolic refs. It
311+// stops at a loose ref that holds an id, or at the first name that has no
312+// loose file: packed-refs decides about that one (see finish).
313+func (r *Repo) lookupLoose(name string) (refWalk, error) {
284 if !validRef(name) {314 if !validRef(name) {
285- return Ref{}, false, nil315+ return refWalk{done: true}, nil
286 }316 }
287- ref := Ref{Name: name}317+ w := refWalk{ref: Ref{Name: name}, cur: name}
288- cur := name
289 for depth := 0; ; depth++ {318 for depth := 0; ; depth++ {
290 if depth > maxSymrefDepth {319 if depth > maxSymrefDepth {
291- return Ref{}, false, corrupt("ref %s: symbolic ref loop", name)320+ return refWalk{}, corrupt("ref %s: symbolic ref loop", name)
292 }321 }
293- lr, found, err := r.readLooseRef(cur)322+ lr, found, err := r.readLooseRef(w.cur)
294 if err != nil {323 if err != nil {
295- return Ref{}, false, err324+ if errors.Is(err, ErrCorrupt) && isRootRef(w.cur) && w.cur != "HEAD" {
325+ // A pseudo-ref such as FETCH_HEAD or MERGE_HEAD can hold
326+ // anything (FETCH_HEAD has one line per fetched ref), and
327+ // a revision like "ABCDEF" may name such a file by
328+ // accident: it is not a ref, so not found. A broken HEAD
329+ // stays an error.
330+ return refWalk{done: true}, nil
331+ }
332+ return refWalk{}, err
296 }333 }
297 if found && lr.symref != "" {334 if found && lr.symref != "" {
298 if depth == 0 {335 if depth == 0 {
299- ref.Symref = lr.symref336+ w.ref.Symref = lr.symref
300 }337 }
301 if !validRef(lr.symref) {338 if !validRef(lr.symref) {
302- return Ref{}, false, nil339+ return refWalk{done: true}, nil
303 }340 }
304- cur = lr.symref341+ w.cur = lr.symref
305 continue342 continue
306 }343 }
307 if found {344 if found {
308- ref.ID = lr.id345+ w.ref.ID = lr.id
309- return ref, true, nil346+ w.done, w.ok = true, true
347+ return w, nil
310 }348 }
311- p, err := r.loadPacked(false)349+ return w, nil
312- if err != nil {350+ }
313- return Ref{}, false, err351+}
314- }352+
315- e, ok := p.find(cur)353+// finish completes a lookup with the content of packed-refs. The loose files
316- if !ok {354+// have to be read before packed-refs is loaded: a concurrent pack-refs writes
317- return Ref{}, false, nil355+// packed-refs first and deletes the loose file after, so it is never missed.
318- }356+func (w *refWalk) finish(p *packedRefs) (Ref, bool) {
319- ref.ID = e.id357+ if w.done {
320- if depth == 0 {358+ return w.ref, w.ok
321- ref.Peeled, ref.PeeledKnown = e.peeled, e.peeledKnown359+ }
322- }360+ e, ok := p.find(w.cur)
323- return ref, true, nil361+ if !ok {
362+ return Ref{}, false
363+ }
364+ w.ref.ID = e.id
365+ if w.cur == w.ref.Name {
366+ w.ref.Peeled, w.ref.PeeledKnown = e.peeled, e.peeledKnown
367+ }
368+ return w.ref, true
369+}
370+
371+// lookupRef resolves name through symbolic refs. loose refs win over packed
372+// ones.
373+func (r *Repo) lookupRef(name string) (Ref, bool, error) {
374+ if r.snap.Load() == nil {
375+ return Ref{}, false, ErrClosed
376+ }
377+ w, err := r.lookupLoose(name)
378+ if err != nil {
379+ return Ref{}, false, err
380+ }
381+ if w.done {
382+ ref, ok := w.finish(nil)
383+ return ref, ok, nil
324 }384 }
385+ p, err := r.loadPacked(false)
386+ if err != nil {
387+ return Ref{}, false, err
388+ }
389+ ref, ok := w.finish(p)
390+ return ref, ok, nil
325 }391 }
326 392
327 // ResolveRef returns the object the ref with this full name (such as393 // ResolveRef returns the object the ref with this full name (such as
@@ -356,6 +422,9 @@ func (r *Repo) LookupRef(name string) (Ref, error) {
356 // yet) is not an error: id is then the zero ObjectID. For a detached HEAD422 // yet) is not an error: id is then the zero ObjectID. For a detached HEAD
357 // target is empty and id is the commit.423 // target is empty and id is the commit.
358 func (r *Repo) Head() (target string, id ObjectID, err error) {424 func (r *Repo) Head() (target string, id ObjectID, err error) {
425+ if r.snap.Load() == nil {
426+ return "", ObjectID{}, ErrClosed
427+ }
359 lr, found, err := r.readLooseRef("HEAD")428 lr, found, err := r.readLooseRef("HEAD")
360 if err != nil {429 if err != nil {
361 return "", ObjectID{}, err430 return "", ObjectID{}, err
@@ -397,7 +466,9 @@ func (r *Repo) Refs(prefix string) iter.Seq2[Ref, error] {
397 }466 }
398 467
399 func (r *Repo) collectRefs(prefix string) ([]Ref, error) {468 func (r *Repo) collectRefs(prefix string) ([]Ref, error) {
400- // Loose refs first, packed-refs after: see lookupRef.469+ if r.snap.Load() == nil {
470+ return nil, ErrClosed
471+ }
401 var dir string472 var dir string
402 switch {473 switch {
403 case strings.HasPrefix(prefix, "refs/"):474 case strings.HasPrefix(prefix, "refs/"):
@@ -407,15 +478,33 @@ func (r *Repo) collectRefs(prefix string) ([]Ref, error) {
407 default:478 default:
408 return nil, nil479 return nil, nil
409 }480 }
481+ // Loose refs first, packed-refs after: see refWalk.finish.
410 var loose []string482 var loose []string
411 if err := r.walkLoose(dir, prefix, &loose, 0); err != nil {483 if err := r.walkLoose(dir, prefix, &loose, 0); err != nil {
412 return nil, err484 return nil, err
413 }485 }
486+ slices.Sort(loose)
487+ loose = slices.Compact(loose)
488+ type item struct {
489+ name string
490+ w refWalk
491+ }
492+ items := make([]item, 0, len(loose))
493+ for _, n := range loose {
494+ w, err := r.lookupLoose(n)
495+ if err != nil {
496+ if errors.Is(err, ErrCorrupt) {
497+ continue // Git ignores broken refs when listing
498+ }
499+ return nil, err
500+ }
501+ items = append(items, item{name: n, w: w})
502+ }
503+ // One load of packed-refs serves every name.
414 p, err := r.loadPacked(false)504 p, err := r.loadPacked(false)
415 if err != nil {505 if err != nil {
416 return nil, err506 return nil, err
417 }507 }
418- names := loose
419 start, _ := slices.BinarySearchFunc(p.refs, prefix, func(e packedRef, pre string) int {508 start, _ := slices.BinarySearchFunc(p.refs, prefix, func(e packedRef, pre string) int {
420 if strings.HasPrefix(e.name, pre) {509 if strings.HasPrefix(e.name, pre) {
421 return 0510 return 0
@@ -427,23 +516,25 @@ func (r *Repo) collectRefs(prefix string) ([]Ref, error) {
427 for start > 0 && strings.HasPrefix(p.refs[start-1].name, prefix) {516 for start > 0 && strings.HasPrefix(p.refs[start-1].name, prefix) {
428 start--517 start--
429 }518 }
519+ nLoose := len(items)
430 for i := start; i < len(p.refs) && strings.HasPrefix(p.refs[i].name, prefix); i++ {520 for i := start; i < len(p.refs) && strings.HasPrefix(p.refs[i].name, prefix); i++ {
431- names = append(names, p.refs[i].name)521+ n := p.refs[i].name
432- }522+ if _, ok := slices.BinarySearch(loose, n); ok {
433- slices.Sort(names)523+ continue // the loose file wins; its walk is already in items
434- names = slices.Compact(names)
435-
436- out := make([]Ref, 0, len(names))
437- for _, n := range names {
438- ref, ok, err := r.lookupRef(n)
439- if err != nil {
440- if errors.Is(err, ErrCorrupt) {
441- continue // Git ignores broken refs when listing
442- }
443- return nil, err
444 }524 }
445- if ok {525+ if !validRef(n) {
446- ref.Name = n526+ continue
527+ }
528+ items = append(items, item{name: n, w: refWalk{ref: Ref{Name: n}, cur: n}})
529+ }
530+ if len(items) > nLoose {
531+ slices.SortFunc(items, func(a, b item) int { return strings.Compare(a.name, b.name) })
532+ }
533+ out := make([]Ref, 0, len(items))
534+ for i := range items {
535+ it := &items[i]
536+ if ref, ok := it.w.finish(p); ok {
537+ ref.Name = it.name
447 out = append(out, ref)538 out = append(out, ref)
448 }539 }
449 }540 }
@@ -500,11 +591,25 @@ func (r *Repo) PeelRef(ref Ref) (ObjectID, error) {
500 }591 }
501 592
502 // Resolve turns a revision name into an object id the way "git rev-parse"593 // Resolve turns a revision name into an object id the way "git rev-parse"
503-// does for plain names: a full 40 digit id, "HEAD", a full ref name, a short594+// does for plain names. The result is not peeled; use PeelToCommit. Revision
504-// ref name tried as "<n>", "refs/<n>", "refs/tags/<n>", "refs/heads/<n>",595+// operators (~, ^, @{}, :path) are not supported.
505-// "refs/remotes/<n>" and "refs/remotes/<n>/HEAD" in that order, and finally596+//
506-// an abbreviated id of 4 to 39 digits. Revision operators (~, ^, @{}) are not597+// The name is tried in git's order, and the first match wins:
507-// supported. The result is not peeled; use PeelToCommit.598+//
599+// 1. a full 40 digit id. It is taken as an id even when a ref has that name
600+// (git warns that the ref is ambiguous and ignores it), and it must exist
601+// in the repository, which "git rev-parse" does not check;
602+// 2. a ref, as "<n>", "refs/<n>", "refs/tags/<n>", "refs/heads/<n>",
603+// "refs/remotes/<n>" and "refs/remotes/<n>/HEAD", in that order. A tag
604+// therefore wins over a branch of the same name, whereas "git checkout"
605+// prefers the branch;
606+// 3. an abbreviated id of 4 to 39 digits, which loses against any ref of
607+// that name. It matches any object, whatever its type, and fails with
608+// ErrAmbiguous if several do; see ResolveCommitPrefix.
609+//
610+// A caller that wants a branch or a tag, or a commit, should not use Resolve:
611+// ResolveRef takes the full ref name and ResolveCommitPrefix only considers
612+// commits. Resolve returns an error wrapping ErrNotFound if nothing matches.
508 func (r *Repo) Resolve(rev string) (ObjectID, error) {613 func (r *Repo) Resolve(rev string) (ObjectID, error) {
509 if rev == "" {614 if rev == "" {
510 return ObjectID{}, notFound("empty revision")615 return ObjectID{}, notFound("empty revision")
A refs_cache_test.go +241 -0
@@ -0,0 +1,241 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "fmt"
7+ "os"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// writePackedRefs writes n branches, all on id, to packed-refs, in the format
14+// of "git pack-refs --all".
15+func writePackedRefs(t testing.TB, dir string, id ObjectID, n int) {
16+ t.Helper()
17+ var sb strings.Builder
18+ sb.WriteString("# pack-refs with: peeled fully-peeled sorted \n")
19+ for i := 0; i < n; i++ {
20+ fmt.Fprintf(&sb, "%s refs/heads/b%05d\n", id, i)
21+ }
22+ if err := os.WriteFile(dir+"/packed-refs", []byte(sb.String()), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+}
26+
27+// Right after packed-refs was rewritten its stat data cannot be trusted, but
28+// listing the refs must still parse it once, not once per ref.
29+func TestRefsRacyPackedRefsParsedOnce(t *testing.T) {
30+ f := newFx(t)
31+ f.write("a", []byte("a"), 0o644)
32+ id := mustID(t, f.commit("one"))
33+ const n = 5000
34+ writePackedRefs(t, f.dir, id, n)
35+ r := f.open()
36+ first, err := r.loadPacked(false)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if time.Now().UnixNano()-first.sig.mtime > int64(racyWindow) {
41+ t.Skip("slow machine: the file is no longer racy")
42+ }
43+ start := time.Now()
44+ count := 0
45+ for ref, err := range r.Refs("refs/heads/") {
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if ref.ID != id {
50+ t.Fatalf("%s = %s", ref.Name, ref.ID)
51+ }
52+ count++
53+ }
54+ took := time.Since(start)
55+ if count != n+1 { // plus the branch of the fixture
56+ t.Fatalf("%d refs, want %d", count, n+1)
57+ }
58+ again, err := r.loadPacked(false)
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ if &again.refs[0] != &first.refs[0] {
63+ t.Error("packed-refs was parsed again although its content did not change")
64+ }
65+ // Before the fix every ref re-read and re-parsed the file: 0.7 s and more.
66+ if !raceEnabled && took > 250*time.Millisecond {
67+ t.Errorf("Refs over %d packed refs took %v right after a rewrite", n, took)
68+ }
69+ // A real change inside the racy window is seen.
70+ writePackedRefs(t, f.dir, id, n-1)
71+ changed, err := r.loadPacked(false)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ if len(changed.refs) != n-1 {
76+ t.Errorf("rewritten packed-refs has %d refs, want %d", len(changed.refs), n-1)
77+ }
78+}
79+
80+func TestLoadPackedConcurrentShareOneParse(t *testing.T) {
81+ f := newFx(t)
82+ f.write("a", []byte("a"), 0o644)
83+ id := mustID(t, f.commit("one"))
84+ writePackedRefs(t, f.dir, id, 2000)
85+ r := f.open()
86+ base, err := r.loadPacked(false)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ writePackedRefs(t, f.dir, id, 1999) // a different size
91+ res := make(chan *packedRefs, 16)
92+ for range 16 {
93+ go func() {
94+ p, err := r.loadPacked(false)
95+ if err != nil {
96+ t.Error(err)
97+ }
98+ res <- p
99+ }()
100+ }
101+ var got *packedRefs
102+ for range 16 {
103+ p := <-res
104+ if p == nil {
105+ continue
106+ }
107+ if len(p.refs) != 1999 || p == base {
108+ t.Fatalf("stale packed-refs: %d refs", len(p.refs))
109+ }
110+ if got != nil && &p.refs[0] != &got.refs[0] {
111+ t.Fatal("concurrent callers parsed the file more than once")
112+ }
113+ got = p
114+ }
115+}
116+
117+// The racy rule runs from the time the file was read, not from now.
118+func TestFileSigRacy(t *testing.T) {
119+ mtime := time.Now().Add(-time.Hour).UnixNano()
120+ w := int64(racyWindow)
121+ cases := []struct {
122+ name string
123+ read int64 // relative to mtime
124+ same bool
125+ }{
126+ {"read just after the write", 1, false},
127+ {"read inside the window", w / 2, false},
128+ {"read at the end of the window", w, false},
129+ {"read after the window", w + 1, true},
130+ }
131+ for _, c := range cases {
132+ a := fileSig{exists: true, size: 10, mtime: mtime, ino: 7, read: mtime + c.read}
133+ b := a
134+ b.read = time.Now().UnixNano() // when the new stat was taken: irrelevant
135+ // The check happens an hour after the read: the old code, which
136+ // measured from "now", trusted every one of these.
137+ if got := a.same(b); got != c.same {
138+ t.Errorf("%s: same = %v, want %v", c.name, got, c.same)
139+ }
140+ }
141+ a := fileSig{exists: true, size: 10, mtime: mtime, read: mtime + 10*w}
142+ for _, b := range []fileSig{
143+ {exists: true, size: 11, mtime: mtime},
144+ {exists: true, size: 10, mtime: mtime + 1},
145+ {exists: true, size: 10, mtime: mtime, ino: 1},
146+ {},
147+ } {
148+ if a.same(b) {
149+ t.Errorf("%+v taken for %+v", b, a)
150+ }
151+ }
152+ if gone := (fileSig{read: 1}); !gone.same(fileSig{read: 99}) {
153+ t.Error("a missing file is not the same as a missing file")
154+ }
155+ // A recorded signature carries the time of the read.
156+ f := newFx(t)
157+ writeFile(t, f.dir+"/probe", "x")
158+ before := time.Now().UnixNano()
159+ sig, err := statSig(f.dir + "/probe")
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ if sig.read < before || sig.read > time.Now().UnixNano() {
164+ t.Errorf("read = %d, want a time between %d and now", sig.read, before)
165+ }
166+}
167+
168+// A pack directory that was racy when the list was built is trusted again
169+// once a reload has seen it unchanged after the window.
170+func TestPackDirRacyUntilReread(t *testing.T) {
171+ f := newFx(t)
172+ f.write("a", []byte("a"), 0o644)
173+ f.commit("one")
174+ f.git("repack", "-adq")
175+ r := f.open()
176+ s := r.snap.Load()
177+ // Pretend the directory was modified right before the list was read.
178+ r.mu.Lock()
179+ s.dirSig.mtime = s.dirSig.read - int64(time.Millisecond)
180+ r.mu.Unlock()
181+ if s.dirSig.same(s.dirSig) {
182+ t.Fatal("a signature read 1ms after the change must not be trusted")
183+ }
184+ before := s.dirSig.read
185+ // The stat data is the real one, but the stored mtime was faked: the
186+ // reload sees a different directory and installs a new snapshot, which
187+ // carries the time of its own read.
188+ if _, err := r.reloadPacks(false); err != nil {
189+ t.Fatal(err)
190+ }
191+ if got := r.snap.Load().dirSig.read; got <= before {
192+ t.Errorf("dirSig.read = %d, was %d: not refreshed", got, before)
193+ }
194+}
195+
196+// BenchmarkRefsPackedRefs lists 5000 packed refs with a steady packed-refs
197+// and right after it was rewritten (inside the racy window).
198+func BenchmarkRefsPackedRefs(b *testing.B) {
199+ requireGit(b)
200+ dir := b.TempDir()
201+ runGit(b, dir, nil, nil, "init", "-q")
202+ runGit(b, dir, nil, nil, "commit", "-q", "--allow-empty", "-m", "x")
203+ id := mustID(b, strings.TrimSpace(string(runGit(b, dir, nil, nil, "rev-parse", "HEAD"))))
204+ const n = 5000
205+ writePackedRefs(b, dir+"/.git", id, n)
206+ r, err := Open(dir)
207+ if err != nil {
208+ b.Fatal(err)
209+ }
210+ defer func() { _ = r.Close() }()
211+ list := func(b *testing.B) {
212+ count := 0
213+ for _, err := range r.Refs("refs/heads/") {
214+ if err != nil {
215+ b.Fatal(err)
216+ }
217+ count++
218+ }
219+ if count != n+1 {
220+ b.Fatalf("%d refs", count)
221+ }
222+ }
223+ for _, c := range []struct {
224+ name string
225+ age time.Duration
226+ }{{"steady", time.Hour}, {"just-rewritten", 0}} {
227+ b.Run(c.name, func(b *testing.B) {
228+ mt := time.Now().Add(-c.age)
229+ if err := os.Chtimes(dir+"/.git/packed-refs", mt, mt); err != nil {
230+ b.Fatal(err)
231+ }
232+ if _, err := r.loadPacked(true); err != nil {
233+ b.Fatal(err)
234+ }
235+ b.ReportAllocs()
236+ for b.Loop() {
237+ list(b)
238+ }
239+ })
240+ }
241+}
@@ -0,0 +1,241 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "fmt"
7+ "os"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// writePackedRefs writes n branches, all on id, to packed-refs, in the format
14+// of "git pack-refs --all".
15+func writePackedRefs(t testing.TB, dir string, id ObjectID, n int) {
16+ t.Helper()
17+ var sb strings.Builder
18+ sb.WriteString("# pack-refs with: peeled fully-peeled sorted \n")
19+ for i := 0; i < n; i++ {
20+ fmt.Fprintf(&sb, "%s refs/heads/b%05d\n", id, i)
21+ }
22+ if err := os.WriteFile(dir+"/packed-refs", []byte(sb.String()), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+}
26+
27+// Right after packed-refs was rewritten its stat data cannot be trusted, but
28+// listing the refs must still parse it once, not once per ref.
29+func TestRefsRacyPackedRefsParsedOnce(t *testing.T) {
30+ f := newFx(t)
31+ f.write("a", []byte("a"), 0o644)
32+ id := mustID(t, f.commit("one"))
33+ const n = 5000
34+ writePackedRefs(t, f.dir, id, n)
35+ r := f.open()
36+ first, err := r.loadPacked(false)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if time.Now().UnixNano()-first.sig.mtime > int64(racyWindow) {
41+ t.Skip("slow machine: the file is no longer racy")
42+ }
43+ start := time.Now()
44+ count := 0
45+ for ref, err := range r.Refs("refs/heads/") {
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if ref.ID != id {
50+ t.Fatalf("%s = %s", ref.Name, ref.ID)
51+ }
52+ count++
53+ }
54+ took := time.Since(start)
55+ if count != n+1 { // plus the branch of the fixture
56+ t.Fatalf("%d refs, want %d", count, n+1)
57+ }
58+ again, err := r.loadPacked(false)
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ if &again.refs[0] != &first.refs[0] {
63+ t.Error("packed-refs was parsed again although its content did not change")
64+ }
65+ // Before the fix every ref re-read and re-parsed the file: 0.7 s and more.
66+ if !raceEnabled && took > 250*time.Millisecond {
67+ t.Errorf("Refs over %d packed refs took %v right after a rewrite", n, took)
68+ }
69+ // A real change inside the racy window is seen.
70+ writePackedRefs(t, f.dir, id, n-1)
71+ changed, err := r.loadPacked(false)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ if len(changed.refs) != n-1 {
76+ t.Errorf("rewritten packed-refs has %d refs, want %d", len(changed.refs), n-1)
77+ }
78+}
79+
80+func TestLoadPackedConcurrentShareOneParse(t *testing.T) {
81+ f := newFx(t)
82+ f.write("a", []byte("a"), 0o644)
83+ id := mustID(t, f.commit("one"))
84+ writePackedRefs(t, f.dir, id, 2000)
85+ r := f.open()
86+ base, err := r.loadPacked(false)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ writePackedRefs(t, f.dir, id, 1999) // a different size
91+ res := make(chan *packedRefs, 16)
92+ for range 16 {
93+ go func() {
94+ p, err := r.loadPacked(false)
95+ if err != nil {
96+ t.Error(err)
97+ }
98+ res <- p
99+ }()
100+ }
101+ var got *packedRefs
102+ for range 16 {
103+ p := <-res
104+ if p == nil {
105+ continue
106+ }
107+ if len(p.refs) != 1999 || p == base {
108+ t.Fatalf("stale packed-refs: %d refs", len(p.refs))
109+ }
110+ if got != nil && &p.refs[0] != &got.refs[0] {
111+ t.Fatal("concurrent callers parsed the file more than once")
112+ }
113+ got = p
114+ }
115+}
116+
117+// The racy rule runs from the time the file was read, not from now.
118+func TestFileSigRacy(t *testing.T) {
119+ mtime := time.Now().Add(-time.Hour).UnixNano()
120+ w := int64(racyWindow)
121+ cases := []struct {
122+ name string
123+ read int64 // relative to mtime
124+ same bool
125+ }{
126+ {"read just after the write", 1, false},
127+ {"read inside the window", w / 2, false},
128+ {"read at the end of the window", w, false},
129+ {"read after the window", w + 1, true},
130+ }
131+ for _, c := range cases {
132+ a := fileSig{exists: true, size: 10, mtime: mtime, ino: 7, read: mtime + c.read}
133+ b := a
134+ b.read = time.Now().UnixNano() // when the new stat was taken: irrelevant
135+ // The check happens an hour after the read: the old code, which
136+ // measured from "now", trusted every one of these.
137+ if got := a.same(b); got != c.same {
138+ t.Errorf("%s: same = %v, want %v", c.name, got, c.same)
139+ }
140+ }
141+ a := fileSig{exists: true, size: 10, mtime: mtime, read: mtime + 10*w}
142+ for _, b := range []fileSig{
143+ {exists: true, size: 11, mtime: mtime},
144+ {exists: true, size: 10, mtime: mtime + 1},
145+ {exists: true, size: 10, mtime: mtime, ino: 1},
146+ {},
147+ } {
148+ if a.same(b) {
149+ t.Errorf("%+v taken for %+v", b, a)
150+ }
151+ }
152+ if gone := (fileSig{read: 1}); !gone.same(fileSig{read: 99}) {
153+ t.Error("a missing file is not the same as a missing file")
154+ }
155+ // A recorded signature carries the time of the read.
156+ f := newFx(t)
157+ writeFile(t, f.dir+"/probe", "x")
158+ before := time.Now().UnixNano()
159+ sig, err := statSig(f.dir + "/probe")
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ if sig.read < before || sig.read > time.Now().UnixNano() {
164+ t.Errorf("read = %d, want a time between %d and now", sig.read, before)
165+ }
166+}
167+
168+// A pack directory that was racy when the list was built is trusted again
169+// once a reload has seen it unchanged after the window.
170+func TestPackDirRacyUntilReread(t *testing.T) {
171+ f := newFx(t)
172+ f.write("a", []byte("a"), 0o644)
173+ f.commit("one")
174+ f.git("repack", "-adq")
175+ r := f.open()
176+ s := r.snap.Load()
177+ // Pretend the directory was modified right before the list was read.
178+ r.mu.Lock()
179+ s.dirSig.mtime = s.dirSig.read - int64(time.Millisecond)
180+ r.mu.Unlock()
181+ if s.dirSig.same(s.dirSig) {
182+ t.Fatal("a signature read 1ms after the change must not be trusted")
183+ }
184+ before := s.dirSig.read
185+ // The stat data is the real one, but the stored mtime was faked: the
186+ // reload sees a different directory and installs a new snapshot, which
187+ // carries the time of its own read.
188+ if _, err := r.reloadPacks(false); err != nil {
189+ t.Fatal(err)
190+ }
191+ if got := r.snap.Load().dirSig.read; got <= before {
192+ t.Errorf("dirSig.read = %d, was %d: not refreshed", got, before)
193+ }
194+}
195+
196+// BenchmarkRefsPackedRefs lists 5000 packed refs with a steady packed-refs
197+// and right after it was rewritten (inside the racy window).
198+func BenchmarkRefsPackedRefs(b *testing.B) {
199+ requireGit(b)
200+ dir := b.TempDir()
201+ runGit(b, dir, nil, nil, "init", "-q")
202+ runGit(b, dir, nil, nil, "commit", "-q", "--allow-empty", "-m", "x")
203+ id := mustID(b, strings.TrimSpace(string(runGit(b, dir, nil, nil, "rev-parse", "HEAD"))))
204+ const n = 5000
205+ writePackedRefs(b, dir+"/.git", id, n)
206+ r, err := Open(dir)
207+ if err != nil {
208+ b.Fatal(err)
209+ }
210+ defer func() { _ = r.Close() }()
211+ list := func(b *testing.B) {
212+ count := 0
213+ for _, err := range r.Refs("refs/heads/") {
214+ if err != nil {
215+ b.Fatal(err)
216+ }
217+ count++
218+ }
219+ if count != n+1 {
220+ b.Fatalf("%d refs", count)
221+ }
222+ }
223+ for _, c := range []struct {
224+ name string
225+ age time.Duration
226+ }{{"steady", time.Hour}, {"just-rewritten", 0}} {
227+ b.Run(c.name, func(b *testing.B) {
228+ mt := time.Now().Add(-c.age)
229+ if err := os.Chtimes(dir+"/.git/packed-refs", mt, mt); err != nil {
230+ b.Fatal(err)
231+ }
232+ if _, err := r.loadPacked(true); err != nil {
233+ b.Fatal(err)
234+ }
235+ b.ReportAllocs()
236+ for b.Loop() {
237+ list(b)
238+ }
239+ })
240+ }
241+}
A refs_fix_test.go +84 -0
@@ -0,0 +1,84 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func TestCorruptRootRefIsNotFound(t *testing.T) {
11+ f := newFx(t)
12+ f.write("a", []byte("a"), 0o644)
13+ c := mustID(t, f.commit("one"))
14+ writeFile(t, f.dir+"/GARBAGE", "this is not a ref\n")
15+ writeFile(t, f.dir+"/GOOD_REF", c.String()+"\n")
16+ writeFile(t, f.dir+"/refs/heads/sym", "ref: GARBAGE\n")
17+ r := f.open()
18+ if _, err := r.ResolveRef("GARBAGE"); !errors.Is(err, ErrNotFound) {
19+ t.Errorf("ResolveRef(GARBAGE) = %v, want ErrNotFound", err)
20+ }
21+ if _, err := r.LookupRef("GARBAGE"); !errors.Is(err, ErrNotFound) {
22+ t.Errorf("LookupRef(GARBAGE) = %v, want ErrNotFound", err)
23+ }
24+ if _, err := r.Resolve("GARBAGE"); !errors.Is(err, ErrNotFound) {
25+ t.Errorf("Resolve(GARBAGE) = %v, want ErrNotFound", err)
26+ }
27+ if got, err := r.ResolveRef("GOOD_REF"); err != nil || got != c {
28+ t.Errorf("ResolveRef(GOOD_REF) = %s, %v", got, err)
29+ }
30+ // A symbolic ref to a broken pseudo-ref designates nothing; it does not
31+ // break the listing.
32+ n := 0
33+ for ref, err := range r.Refs("refs/heads/") {
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if ref.Name == "refs/heads/sym" {
38+ t.Errorf("sym resolved to %s", ref.ID)
39+ }
40+ n++
41+ }
42+ if n != 1 {
43+ t.Errorf("%d branches listed, want 1", n)
44+ }
45+ // A broken loose ref under refs/ stays corrupt, and HEAD too.
46+ writeFile(t, f.dir+"/refs/heads/broken", "nonsense\n")
47+ if _, err := r.ResolveRef("refs/heads/broken"); !errors.Is(err, ErrCorrupt) {
48+ t.Errorf("ResolveRef(broken) = %v, want ErrCorrupt", err)
49+ }
50+ writeFile(t, f.dir+"/HEAD", "nonsense\n")
51+ if _, err := r.ResolveRef("HEAD"); !errors.Is(err, ErrCorrupt) {
52+ t.Errorf("ResolveRef(HEAD) = %v, want ErrCorrupt", err)
53+ }
54+}
55+
56+func TestClosedRepoRefs(t *testing.T) {
57+ f := newFx(t)
58+ f.write("a", []byte("a"), 0o644)
59+ f.commit("one")
60+ r := f.open()
61+ _ = r.Close()
62+ if _, _, err := r.Head(); !errors.Is(err, ErrClosed) {
63+ t.Errorf("Head = %v", err)
64+ }
65+ if _, err := r.ResolveRef("HEAD"); !errors.Is(err, ErrClosed) {
66+ t.Errorf("ResolveRef = %v", err)
67+ }
68+ if _, err := r.LookupRef("refs/heads/main"); !errors.Is(err, ErrClosed) {
69+ t.Errorf("LookupRef = %v", err)
70+ }
71+ if _, err := r.Resolve("main"); !errors.Is(err, ErrClosed) {
72+ t.Errorf("Resolve = %v", err)
73+ }
74+ n := 0
75+ for _, err := range r.Refs("") {
76+ n++
77+ if !errors.Is(err, ErrClosed) {
78+ t.Errorf("Refs = %v", err)
79+ }
80+ }
81+ if n != 1 {
82+ t.Errorf("Refs yielded %d times, want one error", n)
83+ }
84+}
@@ -0,0 +1,84 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func TestCorruptRootRefIsNotFound(t *testing.T) {
11+ f := newFx(t)
12+ f.write("a", []byte("a"), 0o644)
13+ c := mustID(t, f.commit("one"))
14+ writeFile(t, f.dir+"/GARBAGE", "this is not a ref\n")
15+ writeFile(t, f.dir+"/GOOD_REF", c.String()+"\n")
16+ writeFile(t, f.dir+"/refs/heads/sym", "ref: GARBAGE\n")
17+ r := f.open()
18+ if _, err := r.ResolveRef("GARBAGE"); !errors.Is(err, ErrNotFound) {
19+ t.Errorf("ResolveRef(GARBAGE) = %v, want ErrNotFound", err)
20+ }
21+ if _, err := r.LookupRef("GARBAGE"); !errors.Is(err, ErrNotFound) {
22+ t.Errorf("LookupRef(GARBAGE) = %v, want ErrNotFound", err)
23+ }
24+ if _, err := r.Resolve("GARBAGE"); !errors.Is(err, ErrNotFound) {
25+ t.Errorf("Resolve(GARBAGE) = %v, want ErrNotFound", err)
26+ }
27+ if got, err := r.ResolveRef("GOOD_REF"); err != nil || got != c {
28+ t.Errorf("ResolveRef(GOOD_REF) = %s, %v", got, err)
29+ }
30+ // A symbolic ref to a broken pseudo-ref designates nothing; it does not
31+ // break the listing.
32+ n := 0
33+ for ref, err := range r.Refs("refs/heads/") {
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if ref.Name == "refs/heads/sym" {
38+ t.Errorf("sym resolved to %s", ref.ID)
39+ }
40+ n++
41+ }
42+ if n != 1 {
43+ t.Errorf("%d branches listed, want 1", n)
44+ }
45+ // A broken loose ref under refs/ stays corrupt, and HEAD too.
46+ writeFile(t, f.dir+"/refs/heads/broken", "nonsense\n")
47+ if _, err := r.ResolveRef("refs/heads/broken"); !errors.Is(err, ErrCorrupt) {
48+ t.Errorf("ResolveRef(broken) = %v, want ErrCorrupt", err)
49+ }
50+ writeFile(t, f.dir+"/HEAD", "nonsense\n")
51+ if _, err := r.ResolveRef("HEAD"); !errors.Is(err, ErrCorrupt) {
52+ t.Errorf("ResolveRef(HEAD) = %v, want ErrCorrupt", err)
53+ }
54+}
55+
56+func TestClosedRepoRefs(t *testing.T) {
57+ f := newFx(t)
58+ f.write("a", []byte("a"), 0o644)
59+ f.commit("one")
60+ r := f.open()
61+ _ = r.Close()
62+ if _, _, err := r.Head(); !errors.Is(err, ErrClosed) {
63+ t.Errorf("Head = %v", err)
64+ }
65+ if _, err := r.ResolveRef("HEAD"); !errors.Is(err, ErrClosed) {
66+ t.Errorf("ResolveRef = %v", err)
67+ }
68+ if _, err := r.LookupRef("refs/heads/main"); !errors.Is(err, ErrClosed) {
69+ t.Errorf("LookupRef = %v", err)
70+ }
71+ if _, err := r.Resolve("main"); !errors.Is(err, ErrClosed) {
72+ t.Errorf("Resolve = %v", err)
73+ }
74+ n := 0
75+ for _, err := range r.Refs("") {
76+ n++
77+ if !errors.Is(err, ErrClosed) {
78+ t.Errorf("Refs = %v", err)
79+ }
80+ }
81+ if n != 1 {
82+ t.Errorf("Refs yielded %d times, want one error", n)
83+ }
84+}
M snapshot.go +30 -10
@@ -13,40 +13,55 @@ import (
1313 "time"
1414 )
1515
16-// fileSig identifies a version of a file or directory by stat data.
16+// fileSig identifies a version of a file or directory by stat data, and
17+// remembers when that data was read.
1718 type fileSig struct {
1819 exists bool
1920 size int64
2021 mtime int64 // unix nanoseconds
2122 ino uint64
23+ read int64 // unix nanoseconds: when the file was stat'ed or read
2224 }
2325
24-func sigOf(fi os.FileInfo) fileSig {
25- return fileSig{exists: true, size: fi.Size(), mtime: fi.ModTime().UnixNano(), ino: fileIno(fi)}
26+// sigOf returns the signature of fi, which was obtained at read (unix
27+// nanoseconds). Take read before reading the content: a change that happens
28+// later is then never mistaken for the version that was read.
29+func sigOf(fi os.FileInfo, read int64) fileSig {
30+ return fileSig{exists: true, size: fi.Size(), mtime: fi.ModTime().UnixNano(), ino: fileIno(fi), read: read}
2631 }
2732
2833 func statSig(path string) (fileSig, error) {
34+ read := time.Now().UnixNano()
2935 fi, err := os.Stat(path)
3036 if errors.Is(err, fs.ErrNotExist) {
31- return fileSig{}, nil
37+ return fileSig{read: read}, nil
3238 }
3339 if err != nil {
3440 return fileSig{}, err
3541 }
36- return sigOf(fi), nil
42+ return sigOf(fi, read), nil
3743 }
3844
3945 // racyWindow is how long after its modification time a file may still change
4046 // without its stat data changing, because of coarse file system clocks.
4147 const racyWindow = 2 * time.Second
4248
49+// sameStat reports whether a and b have the same stat data, whenever they
50+// were read.
51+func (a fileSig) sameStat(b fileSig) bool {
52+ return a.exists == b.exists && a.size == b.size && a.mtime == b.mtime && a.ino == b.ino
53+}
54+
4355 // same reports whether b is the same version as a and can be trusted to be
44-// unchanged.
56+// unchanged. This is Git's racy rule: a was read at a.read, and a write that
57+// follows within the clock granularity of the previous modification leaves
58+// the stat data as it was. So the stat data only proves that nothing
59+// changed when the file was read more than racyWindow after its mtime.
4560 func (a fileSig) same(b fileSig) bool {
46- if a != b {
61+ if !a.sameStat(b) {
4762 return false
4863 }
49- return !a.exists || time.Since(time.Unix(0, a.mtime)) > racyWindow
64+ return !a.exists || a.read-a.mtime > int64(racyWindow)
5065 }
5166
5267 func ignoreMissing(err error) error {
@@ -162,7 +177,7 @@ func (r *Repo) reloadPacks(force bool) (bool, error) {
162177 if err != nil {
163178 return false, err
164179 }
165- if p := old[name]; p != nil && p.sig == isig {
180+ if p := old[name]; p != nil && p.sig.sameStat(isig) {
166181 p.retain()
167182 packs = append(packs, p)
168183 continue
@@ -199,7 +214,12 @@ func (r *Repo) reloadPacks(force bool) (bool, error) {
199214 for _, p := range packs {
200215 p.release()
201216 }
202- if cur.dirSig == dsig {
217+ if cur.dirSig.sameStat(dsig) {
218+ // The listing above was made after dsig was taken: record it, so
219+ // that a directory that was racy when first read stops being
220+ // rescanned once its window has passed. dirSig is only used
221+ // with r.mu held.
222+ cur.dirSig = dsig
203223 return false, nil
204224 }
205225 packs = cur.packs
@@ -13,40 +13,55 @@ import (
13 "time"13 "time"
14 )14 )
15 15
16-// fileSig identifies a version of a file or directory by stat data.16+// fileSig identifies a version of a file or directory by stat data, and
17+// remembers when that data was read.
17 type fileSig struct {18 type fileSig struct {
18 exists bool19 exists bool
19 size int6420 size int64
20 mtime int64 // unix nanoseconds21 mtime int64 // unix nanoseconds
21 ino uint6422 ino uint64
23+ read int64 // unix nanoseconds: when the file was stat'ed or read
22 }24 }
23 25
24-func sigOf(fi os.FileInfo) fileSig {26+// sigOf returns the signature of fi, which was obtained at read (unix
25- return fileSig{exists: true, size: fi.Size(), mtime: fi.ModTime().UnixNano(), ino: fileIno(fi)}27+// nanoseconds). Take read before reading the content: a change that happens
28+// later is then never mistaken for the version that was read.
29+func sigOf(fi os.FileInfo, read int64) fileSig {
30+ return fileSig{exists: true, size: fi.Size(), mtime: fi.ModTime().UnixNano(), ino: fileIno(fi), read: read}
26 }31 }
27 32
28 func statSig(path string) (fileSig, error) {33 func statSig(path string) (fileSig, error) {
34+ read := time.Now().UnixNano()
29 fi, err := os.Stat(path)35 fi, err := os.Stat(path)
30 if errors.Is(err, fs.ErrNotExist) {36 if errors.Is(err, fs.ErrNotExist) {
31- return fileSig{}, nil37+ return fileSig{read: read}, nil
32 }38 }
33 if err != nil {39 if err != nil {
34 return fileSig{}, err40 return fileSig{}, err
35 }41 }
36- return sigOf(fi), nil42+ return sigOf(fi, read), nil
37 }43 }
38 44
39 // racyWindow is how long after its modification time a file may still change45 // racyWindow is how long after its modification time a file may still change
40 // without its stat data changing, because of coarse file system clocks.46 // without its stat data changing, because of coarse file system clocks.
41 const racyWindow = 2 * time.Second47 const racyWindow = 2 * time.Second
42 48
49+// sameStat reports whether a and b have the same stat data, whenever they
50+// were read.
51+func (a fileSig) sameStat(b fileSig) bool {
52+ return a.exists == b.exists && a.size == b.size && a.mtime == b.mtime && a.ino == b.ino
53+}
54+
43 // same reports whether b is the same version as a and can be trusted to be55 // same reports whether b is the same version as a and can be trusted to be
44-// unchanged.56+// unchanged. This is Git's racy rule: a was read at a.read, and a write that
57+// follows within the clock granularity of the previous modification leaves
58+// the stat data as it was. So the stat data only proves that nothing
59+// changed when the file was read more than racyWindow after its mtime.
45 func (a fileSig) same(b fileSig) bool {60 func (a fileSig) same(b fileSig) bool {
46- if a != b {61+ if !a.sameStat(b) {
47 return false62 return false
48 }63 }
49- return !a.exists || time.Since(time.Unix(0, a.mtime)) > racyWindow64+ return !a.exists || a.read-a.mtime > int64(racyWindow)
50 }65 }
51 66
52 func ignoreMissing(err error) error {67 func ignoreMissing(err error) error {
@@ -162,7 +177,7 @@ func (r *Repo) reloadPacks(force bool) (bool, error) {
162 if err != nil {177 if err != nil {
163 return false, err178 return false, err
164 }179 }
165- if p := old[name]; p != nil && p.sig == isig {180+ if p := old[name]; p != nil && p.sig.sameStat(isig) {
166 p.retain()181 p.retain()
167 packs = append(packs, p)182 packs = append(packs, p)
168 continue183 continue
@@ -199,7 +214,12 @@ func (r *Repo) reloadPacks(force bool) (bool, error) {
199 for _, p := range packs {214 for _, p := range packs {
200 p.release()215 p.release()
201 }216 }
202- if cur.dirSig == dsig {217+ if cur.dirSig.sameStat(dsig) {
218+ // The listing above was made after dsig was taken: record it, so
219+ // that a directory that was racy when first read stops being
220+ // rescanned once its window has passed. dirSig is only used
221+ // with r.mu held.
222+ cur.dirSig = dsig
203 return false, nil223 return false, nil
204 }224 }
205 packs = cur.packs225 packs = cur.packs

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 2

↑↓ moveenter openesc close