detached 3d0ad4f

perf(repo): compute Refresh paths once and stat without allocating (GO-006)

Open builds the paths of everything Refresh looks at (layout files, refs/replace, packed-refs, objects/pack, the commit-graph files). They are stat'ed into a stack Stat_t: on linux amd64 and arm64 with the fstatat system call on a NUL-terminated path built once, elsewhere on unix with syscall.Stat (one allocation), with os.Stat behind a build tag for other systems. refs/replace is listed only when its stat data changed since it was last found empty, with Git's racy rule. An unchanged Refresh allocates nothing.
commit
3d0ad4fda2d7072f7d1d9ecc9ad82992a4605db8
author
brice · · 2026-10-11 00:18 UTC
parent
3da52c2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +531 -29
M bench_test.go +16 -0
@@ -13,6 +13,7 @@ import (
1313 "strings"
1414 "sync"
1515 "testing"
16+ "time"
1617 )
1718
1819 // benchFixture is a repository with loose objects, a deltified pack and a
@@ -412,8 +413,23 @@ func BenchmarkOpenBlob(b *testing.B) {
412413 }
413414 }
414415
416+// BenchmarkRefresh is Refresh on a repository that did not change, the case
417+// of every Refresh but the one after a push. The generated fixture was written
418+// a moment ago, inside the racy window of its stat data, so its files are
419+// aged first and one Refresh records that they are old.
415420 func BenchmarkRefresh(b *testing.B) {
416421 fx := getBench(b)
422+ if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" {
423+ old := time.Now().Add(-time.Hour)
424+ for _, p := range []string{"packed-refs", "objects/pack", "objects/info/commit-graph", "objects/info/commit-graphs/commit-graph-chain"} {
425+ _ = os.Chtimes(fx.r.Dir()+"/"+p, old, old)
426+ }
427+ for range 3 {
428+ if err := fx.r.Refresh(); err != nil {
429+ b.Fatal(err)
430+ }
431+ }
432+ }
417433 b.ReportAllocs()
418434 for b.Loop() {
419435 if err := fx.r.Refresh(); err != nil {
@@ -13,6 +13,7 @@ import (
13 "strings"13 "strings"
14 "sync"14 "sync"
15 "testing"15 "testing"
16+ "time"
16 )17 )
17 18
18 // benchFixture is a repository with loose objects, a deltified pack and a19 // benchFixture is a repository with loose objects, a deltified pack and a
@@ -412,8 +413,23 @@ func BenchmarkOpenBlob(b *testing.B) {
412 }413 }
413 }414 }
414 415
416+// BenchmarkRefresh is Refresh on a repository that did not change, the case
417+// of every Refresh but the one after a push. The generated fixture was written
418+// a moment ago, inside the racy window of its stat data, so its files are
419+// aged first and one Refresh records that they are old.
415 func BenchmarkRefresh(b *testing.B) {420 func BenchmarkRefresh(b *testing.B) {
416 fx := getBench(b)421 fx := getBench(b)
422+ if os.Getenv("ERMITE_GIT_BENCH_REPO") == "" {
423+ old := time.Now().Add(-time.Hour)
424+ for _, p := range []string{"packed-refs", "objects/pack", "objects/info/commit-graph", "objects/info/commit-graphs/commit-graph-chain"} {
425+ _ = os.Chtimes(fx.r.Dir()+"/"+p, old, old)
426+ }
427+ for range 3 {
428+ if err := fx.r.Refresh(); err != nil {
429+ b.Fatal(err)
430+ }
431+ }
432+ }
417 b.ReportAllocs()433 b.ReportAllocs()
418 for b.Loop() {434 for b.Loop() {
419 if err := fx.r.Refresh(); err != nil {435 if err := fx.r.Refresh(); err != nil {
M graph.go +22 -12
@@ -746,22 +746,32 @@ func (s graphStamp) trusted(at int64) bool {
746746 return newest == math.MinInt64 || at-newest > int64(racyWindow)
747747 }
748748
749-func statGraphFile(path string) graphFileStamp {
750- st, err := os.Stat(path)
751- if err != nil {
749+// graphPaths are the paths statGraph looks at, built once.
750+type graphPaths struct{ single, chain statPath }
751+
752+func newGraphPaths(objectsDir string) graphPaths {
753+ return graphPaths{
754+ single: newStatPath(objectsDir + "/info/commit-graph"),
755+ chain: newStatPath(objectsDir + "/info/commit-graphs/commit-graph-chain"),
756+ }
757+}
758+
759+func statGraphFile(p *statPath) graphFileStamp {
760+ st, err := p.stat(false)
761+ if err != nil || !st.exists {
752762 return graphFileStamp{}
753763 }
754- s := graphFileStamp{exists: true, size: st.Size(), mtime: st.ModTime().UnixNano()}
755- s.ino = fileIno(st)
756- return s
764+ return graphFileStamp{exists: true, size: st.size, mtime: st.mtime, ino: st.ino}
765+}
766+
767+// stamp returns the stamp of the commit-graph files. Comparing two stamps
768+// tells whether openCommitGraph may give another result.
769+func (g *graphPaths) stamp() graphStamp {
770+ return graphStamp{single: statGraphFile(&g.single), chain: statGraphFile(&g.chain)}
757771 }
758772
759773 // statGraph returns the stamp of the commit-graph files of objectsDir.
760-// Comparing two stamps tells whether openCommitGraph may give another result.
761774 func statGraph(objectsDir string) graphStamp {
762- info := filepath.Join(objectsDir, "info")
763- return graphStamp{
764- single: statGraphFile(filepath.Join(info, "commit-graph")),
765- chain: statGraphFile(filepath.Join(info, "commit-graphs", "commit-graph-chain")),
766- }
775+ p := newGraphPaths(objectsDir)
776+ return p.stamp()
767777 }
@@ -746,22 +746,32 @@ func (s graphStamp) trusted(at int64) bool {
746 return newest == math.MinInt64 || at-newest > int64(racyWindow)746 return newest == math.MinInt64 || at-newest > int64(racyWindow)
747 }747 }
748 748
749-func statGraphFile(path string) graphFileStamp {749+// graphPaths are the paths statGraph looks at, built once.
750- st, err := os.Stat(path)750+type graphPaths struct{ single, chain statPath }
751- if err != nil {751+
752+func newGraphPaths(objectsDir string) graphPaths {
753+ return graphPaths{
754+ single: newStatPath(objectsDir + "/info/commit-graph"),
755+ chain: newStatPath(objectsDir + "/info/commit-graphs/commit-graph-chain"),
756+ }
757+}
758+
759+func statGraphFile(p *statPath) graphFileStamp {
760+ st, err := p.stat(false)
761+ if err != nil || !st.exists {
752 return graphFileStamp{}762 return graphFileStamp{}
753 }763 }
754- s := graphFileStamp{exists: true, size: st.Size(), mtime: st.ModTime().UnixNano()}764+ return graphFileStamp{exists: true, size: st.size, mtime: st.mtime, ino: st.ino}
755- s.ino = fileIno(st)765+}
756- return s766+
767+// stamp returns the stamp of the commit-graph files. Comparing two stamps
768+// tells whether openCommitGraph may give another result.
769+func (g *graphPaths) stamp() graphStamp {
770+ return graphStamp{single: statGraphFile(&g.single), chain: statGraphFile(&g.chain)}
757 }771 }
758 772
759 // statGraph returns the stamp of the commit-graph files of objectsDir.773 // statGraph returns the stamp of the commit-graph files of objectsDir.
760-// Comparing two stamps tells whether openCommitGraph may give another result.
761 func statGraph(objectsDir string) graphStamp {774 func statGraph(objectsDir string) graphStamp {
762- info := filepath.Join(objectsDir, "info")775+ p := newGraphPaths(objectsDir)
763- return graphStamp{776+ return p.stamp()
764- single: statGraphFile(filepath.Join(info, "commit-graph")),
765- chain: statGraphFile(filepath.Join(info, "commit-graphs", "commit-graph-chain")),
766- }
767 }777 }
A refresh_test.go +194 -0
@@ -0,0 +1,194 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "os"
8+ "testing"
9+ "time"
10+)
11+
12+// age sets the modification time of the given files of the Git directory to
13+// an hour ago (the missing ones are skipped).
14+func ageFiles(t *testing.T, dir string, names ...string) {
15+ t.Helper()
16+ old := time.Now().Add(-time.Hour)
17+ for _, n := range names {
18+ if err := os.Chtimes(dir+"/"+n, old, old); err != nil && !os.IsNotExist(err) {
19+ t.Fatal(err)
20+ }
21+ }
22+}
23+
24+// An unchanged repository costs Refresh no allocation once the stat data of
25+// what it looks at is older than the racy window.
26+func TestRefreshUnchangedAllocatesNothing(t *testing.T) {
27+ if raceEnabled {
28+ t.Skip("allocation counts are not stable under the race detector")
29+ }
30+ f := newFx(t)
31+ f.write("a", []byte("a"), 0o644)
32+ f.commit("one")
33+ f.git("repack", "-adq")
34+ f.git("pack-refs", "--all")
35+ f.git("commit-graph", "write", "--reachable", "--split")
36+ if err := os.MkdirAll(f.dir+"/refs/replace", 0o755); err != nil {
37+ t.Fatal(err)
38+ }
39+ ageFiles(t, f.dir, "packed-refs", "objects/pack", "objects/info/commit-graph", "objects/info/commit-graphs/commit-graph-chain", "refs/replace")
40+ r := f.open()
41+ for range 3 { // records the old stat data
42+ if err := r.Refresh(); err != nil {
43+ t.Fatal(err)
44+ }
45+ }
46+ snap, graph := r.snap.Load(), r.graph.Load()
47+ got := testing.AllocsPerRun(100, func() {
48+ if err := r.Refresh(); err != nil {
49+ t.Fatal(err)
50+ }
51+ })
52+ if got != 0 {
53+ t.Errorf("an unchanged Refresh allocates %v, want 0", got)
54+ }
55+ if r.snap.Load() != snap || r.graph.Load() != graph {
56+ t.Error("an unchanged Refresh replaced the pack list or the graph")
57+ }
58+}
59+
60+// refs/replace is only listed again when its stat data moved, and a replace
61+// ref added later is still refused, also right after the directory was
62+// listed (inside the racy window).
63+func TestRefreshSeesReplaceRefs(t *testing.T) {
64+ f := newFx(t)
65+ f.write("a", []byte("a"), 0o644)
66+ c := f.commit("one")
67+ if err := os.MkdirAll(f.dir+"/refs/replace", 0o755); err != nil {
68+ t.Fatal(err)
69+ }
70+ r := f.open() // an empty refs/replace is fine
71+ for range 3 {
72+ if err := r.Refresh(); err != nil {
73+ t.Fatal(err)
74+ }
75+ }
76+ writeFile(t, f.dir+"/refs/replace/"+c, c+"\n")
77+ if err := r.Refresh(); !errors.Is(err, ErrUnsupported) {
78+ t.Fatalf("Refresh after a replace ref = %v, want ErrUnsupported", err)
79+ }
80+ // And the same once the empty directory is old enough to be trusted.
81+ _ = os.Remove(f.dir + "/refs/replace/" + c)
82+ ageFiles(t, f.dir, "refs/replace")
83+ for range 3 {
84+ if err := r.Refresh(); err != nil {
85+ t.Fatal(err)
86+ }
87+ }
88+ if r.paths.replaceSig.read-r.paths.replaceSig.mtime <= int64(racyWindow) {
89+ t.Fatal("the empty directory should have been recorded as trusted")
90+ }
91+ writeFile(t, f.dir+"/refs/replace/"+c, c+"\n")
92+ if err := r.Refresh(); !errors.Is(err, ErrUnsupported) {
93+ t.Fatalf("Refresh after a replace ref in a trusted directory = %v, want ErrUnsupported", err)
94+ }
95+ // Removing the directory is fine again.
96+ _ = os.Remove(f.dir + "/refs/replace/" + c)
97+ _ = os.Remove(f.dir + "/refs/replace")
98+ if err := r.Refresh(); err != nil {
99+ t.Fatalf("Refresh with no refs/replace: %v", err)
100+ }
101+}
102+
103+// The other unsupported layouts are noticed at each Refresh.
104+func TestRefreshSeesUnsupportedLayouts(t *testing.T) {
105+ for _, name := range []string{"shallow", "info/grafts", "commondir", "objects/info/alternates"} {
106+ f := newFx(t)
107+ f.write("a", []byte("a"), 0o644)
108+ f.commit("one")
109+ r := f.open()
110+ if err := r.Refresh(); err != nil {
111+ t.Fatal(err)
112+ }
113+ _ = os.MkdirAll(f.dir+"/"+name[:max(0, len(name)-len(baseName(name)))], 0o755)
114+ writeFile(t, f.dir+"/"+name, "x\n")
115+ if err := r.Refresh(); !errors.Is(err, ErrUnsupported) {
116+ t.Errorf("%s: Refresh = %v, want ErrUnsupported", name, err)
117+ }
118+ }
119+}
120+
121+func baseName(p string) string {
122+ for i := len(p) - 1; i >= 0; i-- {
123+ if p[i] == '/' {
124+ return p[i+1:]
125+ }
126+ }
127+ return p
128+}
129+
130+func TestStatPath(t *testing.T) {
131+ dir := t.TempDir()
132+ writeFile(t, dir+"/f", "hello")
133+ f := newStatPath(dir + "/f")
134+ st, err := f.stat(false)
135+ if err != nil || !st.exists || st.isDir || st.size != 5 || st.ino == 0 {
136+ t.Fatalf("stat file = %+v, %v", st, err)
137+ }
138+ fi, _ := os.Stat(dir + "/f")
139+ if st.mtime != fi.ModTime().UnixNano() || st.ino != fileIno(fi) {
140+ t.Errorf("mtime %d ino %d, os.Stat says %d %d", st.mtime, st.ino, fi.ModTime().UnixNano(), fileIno(fi))
141+ }
142+ d := newStatPath(dir)
143+ if st, err := d.stat(false); err != nil || !st.exists || !st.isDir {
144+ t.Fatalf("stat dir = %+v, %v", st, err)
145+ }
146+ _ = os.Symlink(dir+"/f", dir+"/link")
147+ l := newStatPath(dir + "/link")
148+ if st, _ := l.stat(false); !st.exists || st.isDir || st.size != 5 {
149+ t.Errorf("stat through a link = %+v", st)
150+ }
151+ if st, _ := l.stat(true); !st.exists || st.size == 5 {
152+ t.Errorf("lstat of a link = %+v, want the link itself", st)
153+ }
154+ _ = os.Symlink(dir+"/gone", dir+"/dangling")
155+ dl := newStatPath(dir + "/dangling")
156+ if st, err := dl.stat(false); err != nil || st.exists {
157+ t.Errorf("stat of a dangling link = %+v, %v", st, err)
158+ }
159+ if st, err := dl.stat(true); err != nil || !st.exists {
160+ t.Errorf("lstat of a dangling link = %+v, %v", st, err)
161+ }
162+ // Absent, and a file where a directory is expected: not errors.
163+ for _, p := range []string{dir + "/missing", dir + "/f/below", dir + "/missing/below"} {
164+ sp := newStatPath(p)
165+ if st, err := sp.stat(false); err != nil || st.exists {
166+ t.Errorf("stat(%s) = %+v, %v, want a quiet absence", p, st, err)
167+ }
168+ }
169+ // A real failure is reported with the path.
170+ long := make([]byte, 5000)
171+ for i := range long {
172+ long[i] = 'x'
173+ }
174+ sp := newStatPath(dir + "/" + string(long))
175+ if _, err := sp.stat(false); err == nil {
176+ t.Error("a name that is too long should be an error")
177+ } else {
178+ var pe *os.PathError
179+ if !errors.As(err, &pe) {
180+ t.Errorf("error %v is not a *os.PathError", err)
181+ }
182+ }
183+ // sig matches statSig.
184+ want, _ := statSig(dir + "/f")
185+ got, _ := f.sig()
186+ if !got.sameStat(want) {
187+ t.Errorf("sig = %+v, statSig = %+v", got, want)
188+ }
189+ if gone := newStatPath(dir + "/missing"); true {
190+ if s, err := gone.sig(); err != nil || s.exists || s.read == 0 {
191+ t.Errorf("sig of a missing file = %+v, %v", s, err)
192+ }
193+ }
194+}
@@ -0,0 +1,194 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "os"
8+ "testing"
9+ "time"
10+)
11+
12+// age sets the modification time of the given files of the Git directory to
13+// an hour ago (the missing ones are skipped).
14+func ageFiles(t *testing.T, dir string, names ...string) {
15+ t.Helper()
16+ old := time.Now().Add(-time.Hour)
17+ for _, n := range names {
18+ if err := os.Chtimes(dir+"/"+n, old, old); err != nil && !os.IsNotExist(err) {
19+ t.Fatal(err)
20+ }
21+ }
22+}
23+
24+// An unchanged repository costs Refresh no allocation once the stat data of
25+// what it looks at is older than the racy window.
26+func TestRefreshUnchangedAllocatesNothing(t *testing.T) {
27+ if raceEnabled {
28+ t.Skip("allocation counts are not stable under the race detector")
29+ }
30+ f := newFx(t)
31+ f.write("a", []byte("a"), 0o644)
32+ f.commit("one")
33+ f.git("repack", "-adq")
34+ f.git("pack-refs", "--all")
35+ f.git("commit-graph", "write", "--reachable", "--split")
36+ if err := os.MkdirAll(f.dir+"/refs/replace", 0o755); err != nil {
37+ t.Fatal(err)
38+ }
39+ ageFiles(t, f.dir, "packed-refs", "objects/pack", "objects/info/commit-graph", "objects/info/commit-graphs/commit-graph-chain", "refs/replace")
40+ r := f.open()
41+ for range 3 { // records the old stat data
42+ if err := r.Refresh(); err != nil {
43+ t.Fatal(err)
44+ }
45+ }
46+ snap, graph := r.snap.Load(), r.graph.Load()
47+ got := testing.AllocsPerRun(100, func() {
48+ if err := r.Refresh(); err != nil {
49+ t.Fatal(err)
50+ }
51+ })
52+ if got != 0 {
53+ t.Errorf("an unchanged Refresh allocates %v, want 0", got)
54+ }
55+ if r.snap.Load() != snap || r.graph.Load() != graph {
56+ t.Error("an unchanged Refresh replaced the pack list or the graph")
57+ }
58+}
59+
60+// refs/replace is only listed again when its stat data moved, and a replace
61+// ref added later is still refused, also right after the directory was
62+// listed (inside the racy window).
63+func TestRefreshSeesReplaceRefs(t *testing.T) {
64+ f := newFx(t)
65+ f.write("a", []byte("a"), 0o644)
66+ c := f.commit("one")
67+ if err := os.MkdirAll(f.dir+"/refs/replace", 0o755); err != nil {
68+ t.Fatal(err)
69+ }
70+ r := f.open() // an empty refs/replace is fine
71+ for range 3 {
72+ if err := r.Refresh(); err != nil {
73+ t.Fatal(err)
74+ }
75+ }
76+ writeFile(t, f.dir+"/refs/replace/"+c, c+"\n")
77+ if err := r.Refresh(); !errors.Is(err, ErrUnsupported) {
78+ t.Fatalf("Refresh after a replace ref = %v, want ErrUnsupported", err)
79+ }
80+ // And the same once the empty directory is old enough to be trusted.
81+ _ = os.Remove(f.dir + "/refs/replace/" + c)
82+ ageFiles(t, f.dir, "refs/replace")
83+ for range 3 {
84+ if err := r.Refresh(); err != nil {
85+ t.Fatal(err)
86+ }
87+ }
88+ if r.paths.replaceSig.read-r.paths.replaceSig.mtime <= int64(racyWindow) {
89+ t.Fatal("the empty directory should have been recorded as trusted")
90+ }
91+ writeFile(t, f.dir+"/refs/replace/"+c, c+"\n")
92+ if err := r.Refresh(); !errors.Is(err, ErrUnsupported) {
93+ t.Fatalf("Refresh after a replace ref in a trusted directory = %v, want ErrUnsupported", err)
94+ }
95+ // Removing the directory is fine again.
96+ _ = os.Remove(f.dir + "/refs/replace/" + c)
97+ _ = os.Remove(f.dir + "/refs/replace")
98+ if err := r.Refresh(); err != nil {
99+ t.Fatalf("Refresh with no refs/replace: %v", err)
100+ }
101+}
102+
103+// The other unsupported layouts are noticed at each Refresh.
104+func TestRefreshSeesUnsupportedLayouts(t *testing.T) {
105+ for _, name := range []string{"shallow", "info/grafts", "commondir", "objects/info/alternates"} {
106+ f := newFx(t)
107+ f.write("a", []byte("a"), 0o644)
108+ f.commit("one")
109+ r := f.open()
110+ if err := r.Refresh(); err != nil {
111+ t.Fatal(err)
112+ }
113+ _ = os.MkdirAll(f.dir+"/"+name[:max(0, len(name)-len(baseName(name)))], 0o755)
114+ writeFile(t, f.dir+"/"+name, "x\n")
115+ if err := r.Refresh(); !errors.Is(err, ErrUnsupported) {
116+ t.Errorf("%s: Refresh = %v, want ErrUnsupported", name, err)
117+ }
118+ }
119+}
120+
121+func baseName(p string) string {
122+ for i := len(p) - 1; i >= 0; i-- {
123+ if p[i] == '/' {
124+ return p[i+1:]
125+ }
126+ }
127+ return p
128+}
129+
130+func TestStatPath(t *testing.T) {
131+ dir := t.TempDir()
132+ writeFile(t, dir+"/f", "hello")
133+ f := newStatPath(dir + "/f")
134+ st, err := f.stat(false)
135+ if err != nil || !st.exists || st.isDir || st.size != 5 || st.ino == 0 {
136+ t.Fatalf("stat file = %+v, %v", st, err)
137+ }
138+ fi, _ := os.Stat(dir + "/f")
139+ if st.mtime != fi.ModTime().UnixNano() || st.ino != fileIno(fi) {
140+ t.Errorf("mtime %d ino %d, os.Stat says %d %d", st.mtime, st.ino, fi.ModTime().UnixNano(), fileIno(fi))
141+ }
142+ d := newStatPath(dir)
143+ if st, err := d.stat(false); err != nil || !st.exists || !st.isDir {
144+ t.Fatalf("stat dir = %+v, %v", st, err)
145+ }
146+ _ = os.Symlink(dir+"/f", dir+"/link")
147+ l := newStatPath(dir + "/link")
148+ if st, _ := l.stat(false); !st.exists || st.isDir || st.size != 5 {
149+ t.Errorf("stat through a link = %+v", st)
150+ }
151+ if st, _ := l.stat(true); !st.exists || st.size == 5 {
152+ t.Errorf("lstat of a link = %+v, want the link itself", st)
153+ }
154+ _ = os.Symlink(dir+"/gone", dir+"/dangling")
155+ dl := newStatPath(dir + "/dangling")
156+ if st, err := dl.stat(false); err != nil || st.exists {
157+ t.Errorf("stat of a dangling link = %+v, %v", st, err)
158+ }
159+ if st, err := dl.stat(true); err != nil || !st.exists {
160+ t.Errorf("lstat of a dangling link = %+v, %v", st, err)
161+ }
162+ // Absent, and a file where a directory is expected: not errors.
163+ for _, p := range []string{dir + "/missing", dir + "/f/below", dir + "/missing/below"} {
164+ sp := newStatPath(p)
165+ if st, err := sp.stat(false); err != nil || st.exists {
166+ t.Errorf("stat(%s) = %+v, %v, want a quiet absence", p, st, err)
167+ }
168+ }
169+ // A real failure is reported with the path.
170+ long := make([]byte, 5000)
171+ for i := range long {
172+ long[i] = 'x'
173+ }
174+ sp := newStatPath(dir + "/" + string(long))
175+ if _, err := sp.stat(false); err == nil {
176+ t.Error("a name that is too long should be an error")
177+ } else {
178+ var pe *os.PathError
179+ if !errors.As(err, &pe) {
180+ t.Errorf("error %v is not a *os.PathError", err)
181+ }
182+ }
183+ // sig matches statSig.
184+ want, _ := statSig(dir + "/f")
185+ got, _ := f.sig()
186+ if !got.sameStat(want) {
187+ t.Errorf("sig = %+v, statSig = %+v", got, want)
188+ }
189+ if gone := newStatPath(dir + "/missing"); true {
190+ if s, err := gone.sig(); err != nil || s.exists || s.read == 0 {
191+ t.Errorf("sig of a missing file = %+v, %v", s, err)
192+ }
193+ }
194+}
M refs.go +2 -2
@@ -70,8 +70,7 @@ func (p *packedRefs) find(name string) (packedRef, bool) {
7070 // when its stat data changed (or always with force). Concurrent callers share
7171 // one parse: the check is repeated once pmu is held.
7272 func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
73- path := r.dir + "/packed-refs"
74- sig, err := statSig(path)
73+ sig, err := r.paths.packedRefs.sig()
7574 if err != nil {
7675 return nil, err
7776 }
@@ -88,6 +87,7 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
8887 return cur, nil
8988 }
9089 read := time.Now().UnixNano()
90+ path := r.dir + "/packed-refs"
9191 f, err := os.Open(path)
9292 if errors.Is(err, fs.ErrNotExist) {
9393 p := &packedRefs{sig: fileSig{read: read}}
@@ -70,8 +70,7 @@ func (p *packedRefs) find(name string) (packedRef, bool) {
70 // when its stat data changed (or always with force). Concurrent callers share70 // when its stat data changed (or always with force). Concurrent callers share
71 // one parse: the check is repeated once pmu is held.71 // one parse: the check is repeated once pmu is held.
72 func (r *Repo) loadPacked(force bool) (*packedRefs, error) {72 func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
73- path := r.dir + "/packed-refs"73+ sig, err := r.paths.packedRefs.sig()
74- sig, err := statSig(path)
75 if err != nil {74 if err != nil {
76 return nil, err75 return nil, err
77 }76 }
@@ -88,6 +87,7 @@ func (r *Repo) loadPacked(force bool) (*packedRefs, error) {
88 return cur, nil87 return cur, nil
89 }88 }
90 read := time.Now().UnixNano()89 read := time.Now().UnixNano()
90+ path := r.dir + "/packed-refs"
91 f, err := os.Open(path)91 f, err := os.Open(path)
92 if errors.Is(err, fs.ErrNotExist) {92 if errors.Is(err, fs.ErrNotExist) {
93 p := &packedRefs{sig: fileSig{read: read}}93 p := &packedRefs{sig: fileSig{read: read}}
M repo.go +79 -10
@@ -9,6 +9,7 @@ import (
99 "math"
1010 "os"
1111 "path/filepath"
12+ "strings"
1213 "sync"
1314 "sync/atomic"
1415 "syscall"
@@ -75,6 +76,8 @@ type Repo struct {
7576 graph atomic.Pointer[graphState] // see walk.go
7677 gmu sync.Mutex // serializes commit-graph reloads
7778
79+ paths *repoPaths // the paths Refresh stats, built once
80+
7881 clock func() time.Time // tests only: time.Now when nil
7982 }
8083
@@ -109,10 +112,11 @@ func Open(dir string, opts ...Option) (*Repo, error) {
109112 if err := checkConfig(filepath.Join(gitDir, "config")); err != nil {
110113 return nil, err
111114 }
112- if err := checkLayout(gitDir); err != nil {
115+ paths := newRepoPaths(gitDir)
116+ if err := paths.checkLayout(); err != nil {
113117 return nil, err
114118 }
115- r := &Repo{dir: gitDir, opts: o, cache: newDeltaCache(o.cacheBytes)}
119+ r := &Repo{dir: gitDir, opts: o, cache: newDeltaCache(o.cacheBytes), paths: paths}
116120 r.snap.Store(newSnapshot(nil))
117121 if _, err := r.reloadPacks(true); err != nil {
118122 _ = r.Close()
@@ -151,7 +155,7 @@ func (r *Repo) Close() error {
151155 // that disappeared stay mapped until the readers using them are done, so a
152156 // concurrent "git repack -ad" never breaks a read in flight.
153157 func (r *Repo) Refresh() error {
154- if err := checkLayout(r.dir); err != nil {
158+ if err := r.paths.checkLayout(); err != nil {
155159 return err
156160 }
157161 if _, err := r.reloadPacks(false); err != nil {
@@ -202,20 +206,84 @@ func exists(path string) bool {
202206 return err == nil
203207 }
204208
205-// checkLayout rejects the repository layouts that change what objects or
206-// refs mean and that the package does not implement.
207-func checkLayout(dir string) error {
208- for _, c := range []struct{ path, why string }{
209+// repoPaths holds the paths that Refresh looks at, built once by Open, so
210+// that an unchanged repository costs a few stat system calls and no
211+// allocation.
212+type repoPaths struct {
213+ dir string
214+
215+ // unsupported are the files whose presence makes a repository
216+ // unsupported, with the reason.
217+ unsupported [4]layoutFile
218+ replace statPath // refs/replace
219+ packedRefs statPath
220+ packDir statPath // objects/pack
221+ graph graphPaths
222+
223+ mu sync.Mutex
224+ replaceSig fileSig // of refs/replace when it was last listed and found empty
225+}
226+
227+type layoutFile struct {
228+ path statPath
229+ why string
230+}
231+
232+func newRepoPaths(dir string) *repoPaths {
233+ p := &repoPaths{
234+ dir: dir,
235+ replace: newStatPath(dir + "/refs/replace"),
236+ packedRefs: newStatPath(dir + "/packed-refs"),
237+ packDir: newStatPath(dir + "/objects/pack"),
238+ graph: newGraphPaths(dir + "/objects"),
239+ }
240+ for i, c := range [...]struct{ path, why string }{
209241 {"shallow", "shallow repository"},
210242 {"info/grafts", "grafts file"},
211243 {"commondir", "linked worktree"},
212244 {"objects/info/alternates", "alternate object stores"},
213245 } {
214- if exists(filepath.Join(dir, c.path)) {
215- return unsupported("%s (%s)", c.why, c.path)
246+ p.unsupported[i] = layoutFile{newStatPath(dir + "/" + c.path), c.why}
247+ }
248+ return p
249+}
250+
251+// checkLayout rejects the repository layouts that change what objects or
252+// refs mean and that the package does not implement.
253+func checkLayout(dir string) error { return newRepoPaths(dir).checkLayout() }
254+
255+func (p *repoPaths) checkLayout() error {
256+ for i := range p.unsupported {
257+ c := &p.unsupported[i]
258+ if st, err := c.path.stat(true); err == nil && st.exists {
259+ return unsupported("%s (%s)", c.why, strings.TrimPrefix(c.path.path(), p.dir+"/"))
216260 }
217261 }
218- f, err := os.Open(filepath.Join(dir, "refs", "replace"))
262+ return p.checkReplace()
263+}
264+
265+// checkReplace refuses a repository with replace refs. The directory is only
266+// listed when its stat data changed since it was last found empty (a replace
267+// ref is a file in it, so adding one changes its modification time), with
268+// Git's racy rule for a change that leaves the stat data as it was.
269+func (p *repoPaths) checkReplace() error {
270+ read := time.Now().UnixNano() // before the stat, see sigOf
271+ st, err := p.replace.stat(false)
272+ if err != nil {
273+ return err
274+ }
275+ p.mu.Lock()
276+ defer p.mu.Unlock()
277+ if !st.exists || !st.isDir {
278+ p.replaceSig = fileSig{}
279+ return nil
280+ }
281+ sig := fileSig{exists: true, size: st.size, mtime: st.mtime, ino: st.ino, read: read}
282+ if p.replaceSig.exists && p.replaceSig.same(sig) {
283+ return nil
284+ }
285+ p.replaceSig = fileSig{}
286+ f, err := os.Open(p.replace.path())
219287 if err != nil {
220288 if isNotExist(err) {
221289 return nil
@@ -228,6 +296,7 @@ func checkLayout(dir string) error {
228296 case rerr == nil && len(names) > 0:
229297 return unsupported("replace refs (refs/replace)")
230298 case rerr == nil, errors.Is(rerr, io.EOF), isNotExist(rerr):
299+ p.replaceSig = sig
231300 return nil
232301 }
233302 return rerr
@@ -9,6 +9,7 @@ import (
9 "math"9 "math"
10 "os"10 "os"
11 "path/filepath"11 "path/filepath"
12+ "strings"
12 "sync"13 "sync"
13 "sync/atomic"14 "sync/atomic"
14 "syscall"15 "syscall"
@@ -75,6 +76,8 @@ type Repo struct {
75 graph atomic.Pointer[graphState] // see walk.go76 graph atomic.Pointer[graphState] // see walk.go
76 gmu sync.Mutex // serializes commit-graph reloads77 gmu sync.Mutex // serializes commit-graph reloads
77 78
79+ paths *repoPaths // the paths Refresh stats, built once
80+
78 clock func() time.Time // tests only: time.Now when nil81 clock func() time.Time // tests only: time.Now when nil
79 }82 }
80 83
@@ -109,10 +112,11 @@ func Open(dir string, opts ...Option) (*Repo, error) {
109 if err := checkConfig(filepath.Join(gitDir, "config")); err != nil {112 if err := checkConfig(filepath.Join(gitDir, "config")); err != nil {
110 return nil, err113 return nil, err
111 }114 }
112- if err := checkLayout(gitDir); err != nil {115+ paths := newRepoPaths(gitDir)
116+ if err := paths.checkLayout(); err != nil {
113 return nil, err117 return nil, err
114 }118 }
115- r := &Repo{dir: gitDir, opts: o, cache: newDeltaCache(o.cacheBytes)}119+ r := &Repo{dir: gitDir, opts: o, cache: newDeltaCache(o.cacheBytes), paths: paths}
116 r.snap.Store(newSnapshot(nil))120 r.snap.Store(newSnapshot(nil))
117 if _, err := r.reloadPacks(true); err != nil {121 if _, err := r.reloadPacks(true); err != nil {
118 _ = r.Close()122 _ = r.Close()
@@ -151,7 +155,7 @@ func (r *Repo) Close() error {
151 // that disappeared stay mapped until the readers using them are done, so a155 // that disappeared stay mapped until the readers using them are done, so a
152 // concurrent "git repack -ad" never breaks a read in flight.156 // concurrent "git repack -ad" never breaks a read in flight.
153 func (r *Repo) Refresh() error {157 func (r *Repo) Refresh() error {
154- if err := checkLayout(r.dir); err != nil {158+ if err := r.paths.checkLayout(); err != nil {
155 return err159 return err
156 }160 }
157 if _, err := r.reloadPacks(false); err != nil {161 if _, err := r.reloadPacks(false); err != nil {
@@ -202,20 +206,84 @@ func exists(path string) bool {
202 return err == nil206 return err == nil
203 }207 }
204 208
205-// checkLayout rejects the repository layouts that change what objects or209+// repoPaths holds the paths that Refresh looks at, built once by Open, so
206-// refs mean and that the package does not implement.210+// that an unchanged repository costs a few stat system calls and no
207-func checkLayout(dir string) error {211+// allocation.
208- for _, c := range []struct{ path, why string }{212+type repoPaths struct {
213+ dir string
214+
215+ // unsupported are the files whose presence makes a repository
216+ // unsupported, with the reason.
217+ unsupported [4]layoutFile
218+ replace statPath // refs/replace
219+ packedRefs statPath
220+ packDir statPath // objects/pack
221+ graph graphPaths
222+
223+ mu sync.Mutex
224+ replaceSig fileSig // of refs/replace when it was last listed and found empty
225+}
226+
227+type layoutFile struct {
228+ path statPath
229+ why string
230+}
231+
232+func newRepoPaths(dir string) *repoPaths {
233+ p := &repoPaths{
234+ dir: dir,
235+ replace: newStatPath(dir + "/refs/replace"),
236+ packedRefs: newStatPath(dir + "/packed-refs"),
237+ packDir: newStatPath(dir + "/objects/pack"),
238+ graph: newGraphPaths(dir + "/objects"),
239+ }
240+ for i, c := range [...]struct{ path, why string }{
209 {"shallow", "shallow repository"},241 {"shallow", "shallow repository"},
210 {"info/grafts", "grafts file"},242 {"info/grafts", "grafts file"},
211 {"commondir", "linked worktree"},243 {"commondir", "linked worktree"},
212 {"objects/info/alternates", "alternate object stores"},244 {"objects/info/alternates", "alternate object stores"},
213 } {245 } {
214- if exists(filepath.Join(dir, c.path)) {246+ p.unsupported[i] = layoutFile{newStatPath(dir + "/" + c.path), c.why}
215- return unsupported("%s (%s)", c.why, c.path)247+ }
248+ return p
249+}
250+
251+// checkLayout rejects the repository layouts that change what objects or
252+// refs mean and that the package does not implement.
253+func checkLayout(dir string) error { return newRepoPaths(dir).checkLayout() }
254+
255+func (p *repoPaths) checkLayout() error {
256+ for i := range p.unsupported {
257+ c := &p.unsupported[i]
258+ if st, err := c.path.stat(true); err == nil && st.exists {
259+ return unsupported("%s (%s)", c.why, strings.TrimPrefix(c.path.path(), p.dir+"/"))
216 }260 }
217 }261 }
218- f, err := os.Open(filepath.Join(dir, "refs", "replace"))262+ return p.checkReplace()
263+}
264+
265+// checkReplace refuses a repository with replace refs. The directory is only
266+// listed when its stat data changed since it was last found empty (a replace
267+// ref is a file in it, so adding one changes its modification time), with
268+// Git's racy rule for a change that leaves the stat data as it was.
269+func (p *repoPaths) checkReplace() error {
270+ read := time.Now().UnixNano() // before the stat, see sigOf
271+ st, err := p.replace.stat(false)
272+ if err != nil {
273+ return err
274+ }
275+ p.mu.Lock()
276+ defer p.mu.Unlock()
277+ if !st.exists || !st.isDir {
278+ p.replaceSig = fileSig{}
279+ return nil
280+ }
281+ sig := fileSig{exists: true, size: st.size, mtime: st.mtime, ino: st.ino, read: read}
282+ if p.replaceSig.exists && p.replaceSig.same(sig) {
283+ return nil
284+ }
285+ p.replaceSig = fileSig{}
286+ f, err := os.Open(p.replace.path())
219 if err != nil {287 if err != nil {
220 if isNotExist(err) {288 if isNotExist(err) {
221 return nil289 return nil
@@ -228,6 +296,7 @@ func checkLayout(dir string) error {
228 case rerr == nil && len(names) > 0:296 case rerr == nil && len(names) > 0:
229 return unsupported("replace refs (refs/replace)")297 return unsupported("replace refs (refs/replace)")
230 case rerr == nil, errors.Is(rerr, io.EOF), isNotExist(rerr):298 case rerr == nil, errors.Is(rerr, io.EOF), isNotExist(rerr):
299+ p.replaceSig = sig
231 return nil300 return nil
232 }301 }
233 return rerr302 return rerr
M snapshot.go +2 -2
@@ -132,15 +132,15 @@ func (r *Repo) reloadPacks(force bool) (bool, error) {
132132 if r.closed {
133133 return false, ErrClosed
134134 }
135- dir := r.packDir()
136135 cur := r.snap.Load()
137- dsig, err := statSig(dir)
136+ dsig, err := r.paths.packDir.sig()
138137 if err != nil {
139138 return false, err
140139 }
141140 if !force && cur.dirSig.same(dsig) {
142141 return false, nil
143142 }
143+ dir := r.packDir()
144144 entries, err := os.ReadDir(dir)
145145 if err != nil && !errors.Is(err, fs.ErrNotExist) {
146146 return false, err
@@ -132,15 +132,15 @@ func (r *Repo) reloadPacks(force bool) (bool, error) {
132 if r.closed {132 if r.closed {
133 return false, ErrClosed133 return false, ErrClosed
134 }134 }
135- dir := r.packDir()
136 cur := r.snap.Load()135 cur := r.snap.Load()
137- dsig, err := statSig(dir)136+ dsig, err := r.paths.packDir.sig()
138 if err != nil {137 if err != nil {
139 return false, err138 return false, err
140 }139 }
141 if !force && cur.dirSig.same(dsig) {140 if !force && cur.dirSig.same(dsig) {
142 return false, nil141 return false, nil
143 }142 }
143+ dir := r.packDir()
144 entries, err := os.ReadDir(dir)144 entries, err := os.ReadDir(dir)
145 if err != nil && !errors.Is(err, fs.ErrNotExist) {145 if err != nil && !errors.Is(err, fs.ErrNotExist) {
146 return false, err146 return false, err
A stat.go +50 -0
@@ -0,0 +1,50 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import "time"
6+
7+// fileStat is the part of a stat result that change detection uses.
8+type fileStat struct {
9+ exists bool
10+ isDir bool
11+ size int64
12+ mtime int64 // unix nanoseconds
13+ ino uint64
14+}
15+
16+// statPath is a path prepared once, so that stat-ing it again and again (what
17+// Refresh does) does not build a string, nor the NUL-terminated copy of it
18+// that the system call takes, at each call.
19+type statPath struct {
20+ nul []byte // the path and a NUL byte
21+}
22+
23+func newStatPath(path string) statPath {
24+ return statPath{nul: append([]byte(path), 0)}
25+}
26+
27+func (p *statPath) path() string { return string(p.nul[:len(p.nul)-1]) }
28+
29+// stat returns the stat data of the path, following symbolic links unless
30+// lstat. A path that is absent, or has a file in place of a directory on the
31+// way, is not an error: exists is false.
32+func (p *statPath) stat(lstat bool) (fileStat, error) {
33+ var st fileStat
34+ err := statNul(p.nul, lstat, &st)
35+ return st, err
36+}
37+
38+// sig returns the signature of the file. The time of the read is taken
39+// before the stat, see sigOf.
40+func (p *statPath) sig() (fileSig, error) {
41+ read := time.Now().UnixNano()
42+ st, err := p.stat(false)
43+ if err != nil {
44+ return fileSig{}, err
45+ }
46+ if !st.exists {
47+ return fileSig{read: read}, nil
48+ }
49+ return fileSig{exists: true, size: st.size, mtime: st.mtime, ino: st.ino, read: read}, nil
50+}
@@ -0,0 +1,50 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import "time"
6+
7+// fileStat is the part of a stat result that change detection uses.
8+type fileStat struct {
9+ exists bool
10+ isDir bool
11+ size int64
12+ mtime int64 // unix nanoseconds
13+ ino uint64
14+}
15+
16+// statPath is a path prepared once, so that stat-ing it again and again (what
17+// Refresh does) does not build a string, nor the NUL-terminated copy of it
18+// that the system call takes, at each call.
19+type statPath struct {
20+ nul []byte // the path and a NUL byte
21+}
22+
23+func newStatPath(path string) statPath {
24+ return statPath{nul: append([]byte(path), 0)}
25+}
26+
27+func (p *statPath) path() string { return string(p.nul[:len(p.nul)-1]) }
28+
29+// stat returns the stat data of the path, following symbolic links unless
30+// lstat. A path that is absent, or has a file in place of a directory on the
31+// way, is not an error: exists is false.
32+func (p *statPath) stat(lstat bool) (fileStat, error) {
33+ var st fileStat
34+ err := statNul(p.nul, lstat, &st)
35+ return st, err
36+}
37+
38+// sig returns the signature of the file. The time of the read is taken
39+// before the stat, see sigOf.
40+func (p *statPath) sig() (fileSig, error) {
41+ read := time.Now().UnixNano()
42+ st, err := p.stat(false)
43+ if err != nil {
44+ return fileSig{}, err
45+ }
46+ if !st.exists {
47+ return fileSig{read: read}, nil
48+ }
49+ return fileSig{exists: true, size: st.size, mtime: st.mtime, ino: st.ino, read: read}, nil
50+}
A stat_linux.go +37 -0
@@ -0,0 +1,37 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux && (amd64 || arm64)
4+
5+package git
6+
7+import (
8+ "syscall"
9+ "unsafe"
10+)
11+
12+const atSymlinkNoFollow = 0x100
13+
14+// statNul is syscall.Stat and syscall.Lstat without their allocation: package
15+// syscall copies the path into a new NUL-terminated byte slice at each call,
16+// here the caller built it once. It makes the system call they make on this
17+// platform, fstatat(AT_FDCWD, path, &stat, flags), into the same Stat_t.
18+func statNul(nul []byte, lstat bool, st *fileStat) error {
19+ var sys syscall.Stat_t
20+ var flags uintptr
21+ if lstat {
22+ flags = atSymlinkNoFollow
23+ }
24+ atFDCWD := -100
25+ var errno syscall.Errno
26+ for {
27+ _, _, errno = syscall.Syscall6(sysFstatat, uintptr(atFDCWD), uintptr(unsafe.Pointer(&nul[0])), uintptr(unsafe.Pointer(&sys)), flags, 0, 0)
28+ if errno != syscall.EINTR {
29+ break
30+ }
31+ }
32+ var err error
33+ if errno != 0 {
34+ err = errno
35+ }
36+ return fillStat(st, &sys, nul, err)
37+}
@@ -0,0 +1,37 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux && (amd64 || arm64)
4+
5+package git
6+
7+import (
8+ "syscall"
9+ "unsafe"
10+)
11+
12+const atSymlinkNoFollow = 0x100
13+
14+// statNul is syscall.Stat and syscall.Lstat without their allocation: package
15+// syscall copies the path into a new NUL-terminated byte slice at each call,
16+// here the caller built it once. It makes the system call they make on this
17+// platform, fstatat(AT_FDCWD, path, &stat, flags), into the same Stat_t.
18+func statNul(nul []byte, lstat bool, st *fileStat) error {
19+ var sys syscall.Stat_t
20+ var flags uintptr
21+ if lstat {
22+ flags = atSymlinkNoFollow
23+ }
24+ atFDCWD := -100
25+ var errno syscall.Errno
26+ for {
27+ _, _, errno = syscall.Syscall6(sysFstatat, uintptr(atFDCWD), uintptr(unsafe.Pointer(&nul[0])), uintptr(unsafe.Pointer(&sys)), flags, 0, 0)
28+ if errno != syscall.EINTR {
29+ break
30+ }
31+ }
32+ var err error
33+ if errno != 0 {
34+ err = errno
35+ }
36+ return fillStat(st, &sys, nul, err)
37+}
A stat_linux_amd64.go +9 -0
@@ -0,0 +1,9 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux && amd64
4+
5+package git
6+
7+import "syscall"
8+
9+const sysFstatat = syscall.SYS_NEWFSTATAT
@@ -0,0 +1,9 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux && amd64
4+
5+package git
6+
7+import "syscall"
8+
9+const sysFstatat = syscall.SYS_NEWFSTATAT
A stat_linux_arm64.go +9 -0
@@ -0,0 +1,9 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux && arm64
4+
5+package git
6+
7+import "syscall"
8+
9+const sysFstatat = syscall.SYS_FSTATAT
@@ -0,0 +1,9 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux && arm64
4+
5+package git
6+
7+import "syscall"
8+
9+const sysFstatat = syscall.SYS_FSTATAT
A stat_mtim.go +11 -0
@@ -0,0 +1,11 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux || openbsd || dragonfly
4+
5+package git
6+
7+import "syscall"
8+
9+func statMtime(sys *syscall.Stat_t) int64 {
10+ return int64(sys.Mtim.Sec)*1e9 + int64(sys.Mtim.Nsec) //nolint:unconvert // the types differ per platform
11+}
@@ -0,0 +1,11 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux || openbsd || dragonfly
4+
5+package git
6+
7+import "syscall"
8+
9+func statMtime(sys *syscall.Stat_t) int64 {
10+ return int64(sys.Mtim.Sec)*1e9 + int64(sys.Mtim.Nsec) //nolint:unconvert // the types differ per platform
11+}
A stat_mtimespec.go +11 -0
@@ -0,0 +1,11 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build darwin || freebsd || netbsd
4+
5+package git
6+
7+import "syscall"
8+
9+func statMtime(sys *syscall.Stat_t) int64 {
10+ return int64(sys.Mtimespec.Sec)*1e9 + int64(sys.Mtimespec.Nsec) //nolint:unconvert // the types differ per platform
11+}
@@ -0,0 +1,11 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build darwin || freebsd || netbsd
4+
5+package git
6+
7+import "syscall"
8+
9+func statMtime(sys *syscall.Stat_t) int64 {
10+ return int64(sys.Mtimespec.Sec)*1e9 + int64(sys.Mtimespec.Nsec) //nolint:unconvert // the types differ per platform
11+}
A stat_other.go +29 -0
@@ -0,0 +1,29 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build !(linux || darwin || freebsd || openbsd || netbsd || dragonfly)
4+
5+package git
6+
7+import "os"
8+
9+// statNul is os.Stat or os.Lstat where there is no stat system call to make
10+// directly.
11+func statNul(nul []byte, lstat bool, st *fileStat) error {
12+ path := string(nul[:len(nul)-1])
13+ var fi os.FileInfo
14+ var err error
15+ if lstat {
16+ fi, err = os.Lstat(path)
17+ } else {
18+ fi, err = os.Stat(path)
19+ }
20+ *st = fileStat{}
21+ if err != nil {
22+ if isNotExist(err) {
23+ return nil
24+ }
25+ return err
26+ }
27+ *st = fileStat{exists: true, isDir: fi.IsDir(), size: fi.Size(), mtime: fi.ModTime().UnixNano(), ino: fileIno(fi)}
28+ return nil
29+}
@@ -0,0 +1,29 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build !(linux || darwin || freebsd || openbsd || netbsd || dragonfly)
4+
5+package git
6+
7+import "os"
8+
9+// statNul is os.Stat or os.Lstat where there is no stat system call to make
10+// directly.
11+func statNul(nul []byte, lstat bool, st *fileStat) error {
12+ path := string(nul[:len(nul)-1])
13+ var fi os.FileInfo
14+ var err error
15+ if lstat {
16+ fi, err = os.Lstat(path)
17+ } else {
18+ fi, err = os.Stat(path)
19+ }
20+ *st = fileStat{}
21+ if err != nil {
22+ if isNotExist(err) {
23+ return nil
24+ }
25+ return err
26+ }
27+ *st = fileStat{exists: true, isDir: fi.IsDir(), size: fi.Size(), mtime: fi.ModTime().UnixNano(), ino: fileIno(fi)}
28+ return nil
29+}
A stat_portable.go +28 -0
@@ -0,0 +1,28 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build (linux && !(amd64 || arm64)) || darwin || freebsd || openbsd || netbsd || dragonfly
4+
5+package git
6+
7+import "syscall"
8+
9+// statNul fills st with a stat system call into a Stat_t on the stack. The
10+// one allocation is the NUL-free copy of the path that package syscall turns
11+// into a C string at each call; stat_linux.go avoids it where the system call
12+// can be made directly.
13+func statNul(nul []byte, lstat bool, st *fileStat) error {
14+ var sys syscall.Stat_t
15+ path := string(nul[:len(nul)-1])
16+ var err error
17+ for {
18+ if lstat {
19+ err = syscall.Lstat(path, &sys)
20+ } else {
21+ err = syscall.Stat(path, &sys)
22+ }
23+ if err != syscall.EINTR {
24+ break
25+ }
26+ }
27+ return fillStat(st, &sys, nul, err)
28+}
@@ -0,0 +1,28 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build (linux && !(amd64 || arm64)) || darwin || freebsd || openbsd || netbsd || dragonfly
4+
5+package git
6+
7+import "syscall"
8+
9+// statNul fills st with a stat system call into a Stat_t on the stack. The
10+// one allocation is the NUL-free copy of the path that package syscall turns
11+// into a C string at each call; stat_linux.go avoids it where the system call
12+// can be made directly.
13+func statNul(nul []byte, lstat bool, st *fileStat) error {
14+ var sys syscall.Stat_t
15+ path := string(nul[:len(nul)-1])
16+ var err error
17+ for {
18+ if lstat {
19+ err = syscall.Lstat(path, &sys)
20+ } else {
21+ err = syscall.Stat(path, &sys)
22+ }
23+ if err != syscall.EINTR {
24+ break
25+ }
26+ }
27+ return fillStat(st, &sys, nul, err)
28+}
A stat_unix.go +30 -0
@@ -0,0 +1,30 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly
4+
5+package git
6+
7+import (
8+ "os"
9+ "syscall"
10+)
11+
12+// fillStat converts the result of a stat system call into st. An absent path
13+// is not an error.
14+func fillStat(st *fileStat, sys *syscall.Stat_t, nul []byte, err error) error {
15+ *st = fileStat{}
16+ if err != nil {
17+ if err == syscall.ENOENT || err == syscall.ENOTDIR {
18+ return nil
19+ }
20+ return &os.PathError{Op: "stat", Path: string(nul[:len(nul)-1]), Err: err}
21+ }
22+ *st = fileStat{
23+ exists: true,
24+ isDir: sys.Mode&syscall.S_IFMT == syscall.S_IFDIR,
25+ size: sys.Size,
26+ mtime: statMtime(sys),
27+ ino: uint64(sys.Ino), //nolint:unconvert // the type differs per OS
28+ }
29+ return nil
30+}
@@ -0,0 +1,30 @@
1+// SPDX-License-Identifier: MIT
2+
3+//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly
4+
5+package git
6+
7+import (
8+ "os"
9+ "syscall"
10+)
11+
12+// fillStat converts the result of a stat system call into st. An absent path
13+// is not an error.
14+func fillStat(st *fileStat, sys *syscall.Stat_t, nul []byte, err error) error {
15+ *st = fileStat{}
16+ if err != nil {
17+ if err == syscall.ENOENT || err == syscall.ENOTDIR {
18+ return nil
19+ }
20+ return &os.PathError{Op: "stat", Path: string(nul[:len(nul)-1]), Err: err}
21+ }
22+ *st = fileStat{
23+ exists: true,
24+ isDir: sys.Mode&syscall.S_IFMT == syscall.S_IFDIR,
25+ size: sys.Size,
26+ mtime: statMtime(sys),
27+ ino: uint64(sys.Ino), //nolint:unconvert // the type differs per OS
28+ }
29+ return nil
30+}
M walk.go +2 -3
@@ -506,16 +506,15 @@ func (r *Repo) refreshGraph(force bool) error {
506506 if cur != nil && cur.err == ErrClosed {
507507 return ErrClosed
508508 }
509- objects := r.dir + "/objects"
510509 // Take the time before the stat data and the content, so that a write
511510 // that follows is never mistaken for the version that was loaded.
512511 at := r.now().UnixNano()
513- stamp := statGraph(objects)
512+ stamp := r.paths.graph.stamp()
514513 if cur != nil && !force && cur.stamp == stamp && cur.stamp.trusted(cur.at) {
515514 return nil
516515 }
517516 st := &graphState{stamp: stamp, at: at}
518- g, err := openCommitGraph(objects)
517+ g, err := openCommitGraph(r.dir + "/objects")
519518 switch {
520519 case err == nil:
521520 st.g = g
@@ -506,16 +506,15 @@ func (r *Repo) refreshGraph(force bool) error {
506 if cur != nil && cur.err == ErrClosed {506 if cur != nil && cur.err == ErrClosed {
507 return ErrClosed507 return ErrClosed
508 }508 }
509- objects := r.dir + "/objects"
510 // Take the time before the stat data and the content, so that a write509 // Take the time before the stat data and the content, so that a write
511 // that follows is never mistaken for the version that was loaded.510 // that follows is never mistaken for the version that was loaded.
512 at := r.now().UnixNano()511 at := r.now().UnixNano()
513- stamp := statGraph(objects)512+ stamp := r.paths.graph.stamp()
514 if cur != nil && !force && cur.stamp == stamp && cur.stamp.trusted(cur.at) {513 if cur != nil && !force && cur.stamp == stamp && cur.stamp.trusted(cur.at) {
515 return nil514 return nil
516 }515 }
517 st := &graphState{stamp: stamp, at: at}516 st := &graphState{stamp: stamp, at: at}
518- g, err := openCommitGraph(objects)517+ g, err := openCommitGraph(r.dir + "/objects")
519 switch {518 switch {
520 case err == nil:519 case err == nil:
521 st.g = g520 st.g = g

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