detached 65a5c34

feat(gitcli): write v2 Bloom filters in new repositories, expose RepoDir and OnTrash

Stage sets commitGraph.changedPathsVersion=2 next to core.commitGraph, so
the changed-path filters of a repository ermite creates hash like v2 and
carry no signed/unsigned char ambiguity. Maintain keeps writing v2: git
replaces a v1 chain on the first write with the setting (checked by
TestMaintainWritesBloomFiltersV2).

RepoDir gives the directory of a repository to another reader, and OnTrash
tells it when Trash moved a repository away, as the cat-file pool is told.
commit
65a5c34ce034e38981bfdb9bc8153d7e1b5a0256
author
brice · · 2026-10-10 23:07 UTC
parent
7f0f2f2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +102 -2
M internal/adapters/gitcli/backend_test.go +77 -0
@@ -4,6 +4,7 @@ package gitcli_test
44
55 import (
66 "context"
7+ "encoding/binary"
78 "os"
89 "os/exec"
910 "path/filepath"
@@ -182,3 +183,79 @@ func TestDefaultBranchAndMaintain(t *testing.T) {
182183 assert.FileExists(t, filepath.Join(dir, "objects", "info", "commit-graphs", "commit-graph-chain"))
183184 assert.ErrorIs(t, g.Maintain(ctx, name(t, "ghost")), domain.ErrNotFound)
184185 }
186+
187+// bloomVersions returns the Bloom filter hash version (BDAT chunk) of every
188+// layer of the commit-graph chain of the repository.
189+func bloomVersions(t *testing.T, dir string) []uint32 {
190+ t.Helper()
191+ files, err := filepath.Glob(filepath.Join(dir, "objects", "info", "commit-graphs", "graph-*.graph"))
192+ require.NoError(t, err)
193+ require.NotEmpty(t, files)
194+ var out []uint32
195+ for _, f := range files {
196+ d, err := os.ReadFile(f)
197+ require.NoError(t, err)
198+ chunks := int(d[6])
199+ found := false
200+ for i := range chunks {
201+ row := 8 + 12*i
202+ if string(d[row:row+4]) == "BDAT" {
203+ off := binary.BigEndian.Uint64(d[row+4 : row+12])
204+ out = append(out, binary.BigEndian.Uint32(d[off:off+4]))
205+ found = true
206+ }
207+ }
208+ require.True(t, found, "%s has no Bloom filters", f)
209+ }
210+ return out
211+}
212+
213+// New repositories write v2 Bloom filters (no signed/unsigned char ambiguity),
214+// and Maintain keeps writing v2: on top of a v2 chain, and over a v1 chain that
215+// an older ermite or a plain git left behind.
216+func TestMaintainWritesBloomFiltersV2(t *testing.T) {
217+ ctx := context.Background()
218+ g, data := newGit(t)
219+ n := name(t, "bloom")
220+ id, err := g.Stage(ctx)
221+ require.NoError(t, err)
222+ require.NoError(t, g.Publish(ctx, id, n))
223+ dir := filepath.Join(data, "repos", "bloom.git")
224+
225+ wt := t.TempDir()
226+ env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
227+ "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
228+ run := func(dir string, args ...string) {
229+ cmd := exec.Command("git", args...)
230+ cmd.Dir, cmd.Env = dir, env
231+ out, err := cmd.CombinedOutput()
232+ require.NoError(t, err, string(out))
233+ }
234+ run(wt, "init", "-q", "--initial-branch=main")
235+ commit := func(i int) {
236+ require.NoError(t, os.WriteFile(filepath.Join(wt, "f"), []byte(strings.Repeat("x", i+1)), 0o644))
237+ run(wt, "add", "f")
238+ run(wt, "commit", "-q", "-m", "c")
239+ run(wt, "push", "-q", dir, "main")
240+ }
241+
242+ commit(0)
243+ commit(1)
244+ require.NoError(t, g.Maintain(ctx, n))
245+ assert.Equal(t, []uint32{2}, bloomVersions(t, dir))
246+
247+ // A v1 chain written by a git that was not configured for v2.
248+ run(dir, "-c", "commitGraph.changedPathsVersion=1", "commit-graph", "write", "--reachable", "--changed-paths", "--split=replace")
249+ require.Equal(t, []uint32{1}, bloomVersions(t, dir))
250+ commit(2)
251+ require.NoError(t, g.Maintain(ctx, n))
252+ for _, v := range bloomVersions(t, dir) {
253+ assert.Equal(t, uint32(2), v, "Maintain does not leave v1 filters in the chain")
254+ }
255+ commit(3)
256+ require.NoError(t, g.Maintain(ctx, n))
257+ for _, v := range bloomVersions(t, dir) {
258+ assert.Equal(t, uint32(2), v)
259+ }
260+ run(dir, "commit-graph", "verify")
261+}
@@ -4,6 +4,7 @@ package gitcli_test
4 4
5 import (5 import (
6 "context"6 "context"
7+ "encoding/binary"
7 "os"8 "os"
8 "os/exec"9 "os/exec"
9 "path/filepath"10 "path/filepath"
@@ -182,3 +183,79 @@ func TestDefaultBranchAndMaintain(t *testing.T) {
182 assert.FileExists(t, filepath.Join(dir, "objects", "info", "commit-graphs", "commit-graph-chain"))183 assert.FileExists(t, filepath.Join(dir, "objects", "info", "commit-graphs", "commit-graph-chain"))
183 assert.ErrorIs(t, g.Maintain(ctx, name(t, "ghost")), domain.ErrNotFound)184 assert.ErrorIs(t, g.Maintain(ctx, name(t, "ghost")), domain.ErrNotFound)
184 }185 }
186+
187+// bloomVersions returns the Bloom filter hash version (BDAT chunk) of every
188+// layer of the commit-graph chain of the repository.
189+func bloomVersions(t *testing.T, dir string) []uint32 {
190+ t.Helper()
191+ files, err := filepath.Glob(filepath.Join(dir, "objects", "info", "commit-graphs", "graph-*.graph"))
192+ require.NoError(t, err)
193+ require.NotEmpty(t, files)
194+ var out []uint32
195+ for _, f := range files {
196+ d, err := os.ReadFile(f)
197+ require.NoError(t, err)
198+ chunks := int(d[6])
199+ found := false
200+ for i := range chunks {
201+ row := 8 + 12*i
202+ if string(d[row:row+4]) == "BDAT" {
203+ off := binary.BigEndian.Uint64(d[row+4 : row+12])
204+ out = append(out, binary.BigEndian.Uint32(d[off:off+4]))
205+ found = true
206+ }
207+ }
208+ require.True(t, found, "%s has no Bloom filters", f)
209+ }
210+ return out
211+}
212+
213+// New repositories write v2 Bloom filters (no signed/unsigned char ambiguity),
214+// and Maintain keeps writing v2: on top of a v2 chain, and over a v1 chain that
215+// an older ermite or a plain git left behind.
216+func TestMaintainWritesBloomFiltersV2(t *testing.T) {
217+ ctx := context.Background()
218+ g, data := newGit(t)
219+ n := name(t, "bloom")
220+ id, err := g.Stage(ctx)
221+ require.NoError(t, err)
222+ require.NoError(t, g.Publish(ctx, id, n))
223+ dir := filepath.Join(data, "repos", "bloom.git")
224+
225+ wt := t.TempDir()
226+ env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
227+ "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
228+ run := func(dir string, args ...string) {
229+ cmd := exec.Command("git", args...)
230+ cmd.Dir, cmd.Env = dir, env
231+ out, err := cmd.CombinedOutput()
232+ require.NoError(t, err, string(out))
233+ }
234+ run(wt, "init", "-q", "--initial-branch=main")
235+ commit := func(i int) {
236+ require.NoError(t, os.WriteFile(filepath.Join(wt, "f"), []byte(strings.Repeat("x", i+1)), 0o644))
237+ run(wt, "add", "f")
238+ run(wt, "commit", "-q", "-m", "c")
239+ run(wt, "push", "-q", dir, "main")
240+ }
241+
242+ commit(0)
243+ commit(1)
244+ require.NoError(t, g.Maintain(ctx, n))
245+ assert.Equal(t, []uint32{2}, bloomVersions(t, dir))
246+
247+ // A v1 chain written by a git that was not configured for v2.
248+ run(dir, "-c", "commitGraph.changedPathsVersion=1", "commit-graph", "write", "--reachable", "--changed-paths", "--split=replace")
249+ require.Equal(t, []uint32{1}, bloomVersions(t, dir))
250+ commit(2)
251+ require.NoError(t, g.Maintain(ctx, n))
252+ for _, v := range bloomVersions(t, dir) {
253+ assert.Equal(t, uint32(2), v, "Maintain does not leave v1 filters in the chain")
254+ }
255+ commit(3)
256+ require.NoError(t, g.Maintain(ctx, n))
257+ for _, v := range bloomVersions(t, dir) {
258+ assert.Equal(t, uint32(2), v)
259+ }
260+ run(dir, "commit-graph", "verify")
261+}
M internal/adapters/gitcli/git.go +3 -0
@@ -16,6 +16,7 @@ import (
1616 "runtime"
1717 "strings"
1818 "sync"
19+ "sync/atomic"
1920 "time"
2021 )
2122
@@ -62,6 +63,8 @@ type Git struct {
6263
6364 cat *catPool // long-lived cat-file processes of the read side
6465
66+ onTrash atomic.Pointer[func(dir string)] // see OnTrash
67+
6568 exec chan struct{} // slots of the one-shot processes, see Config.MaxExec
6669 blobs chan struct{} // slots of the blob streams, see Config.MaxBlobStreams
6770 }
@@ -16,6 +16,7 @@ import (
16 "runtime"16 "runtime"
17 "strings"17 "strings"
18 "sync"18 "sync"
19+ "sync/atomic"
19 "time"20 "time"
20 )21 )
21 22
@@ -62,6 +63,8 @@ type Git struct {
62 63
63 cat *catPool // long-lived cat-file processes of the read side64 cat *catPool // long-lived cat-file processes of the read side
64 65
66+ onTrash atomic.Pointer[func(dir string)] // see OnTrash
67+
65 exec chan struct{} // slots of the one-shot processes, see Config.MaxExec68 exec chan struct{} // slots of the one-shot processes, see Config.MaxExec
66 blobs chan struct{} // slots of the blob streams, see Config.MaxBlobStreams69 blobs chan struct{} // slots of the blob streams, see Config.MaxBlobStreams
67 }70 }
M internal/adapters/gitcli/gitcli_test.go +2 -0
@@ -193,6 +193,8 @@ func TestStagePublish(t *testing.T) {
193193 assert.Equal(t, "files", gitOut(t, staged, "rev-parse", "--show-ref-format"))
194194 assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "repack.writeBitmaps"))
195195 assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "core.commitGraph"))
196+ assert.Equal(t, "2", gitOut(t, staged, "config", "--local", "commitGraph.changedPathsVersion"),
197+ "v2 Bloom filters: no signed/unsigned char ambiguity")
196198 assert.Equal(t, "refs/heads/main", gitOut(t, staged, "symbolic-ref", "HEAD"))
197199
198200 n := name(t, "tools")
@@ -193,6 +193,8 @@ func TestStagePublish(t *testing.T) {
193 assert.Equal(t, "files", gitOut(t, staged, "rev-parse", "--show-ref-format"))193 assert.Equal(t, "files", gitOut(t, staged, "rev-parse", "--show-ref-format"))
194 assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "repack.writeBitmaps"))194 assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "repack.writeBitmaps"))
195 assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "core.commitGraph"))195 assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "core.commitGraph"))
196+ assert.Equal(t, "2", gitOut(t, staged, "config", "--local", "commitGraph.changedPathsVersion"),
197+ "v2 Bloom filters: no signed/unsigned char ambiguity")
196 assert.Equal(t, "refs/heads/main", gitOut(t, staged, "symbolic-ref", "HEAD"))198 assert.Equal(t, "refs/heads/main", gitOut(t, staged, "symbolic-ref", "HEAD"))
197 199
198 n := name(t, "tools")200 n := name(t, "tools")
M internal/adapters/gitcli/repos.go +20 -2
@@ -42,7 +42,10 @@ func (g *Git) Stage(ctx context.Context) (ports.StagedID, error) {
4242
4343 _, err := g.run(ctx, "init", "--quiet", "--bare", "--object-format=sha1", "--ref-format=files", "--initial-branch=main", dir)
4444 if err == nil {
45- for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}} {
45+ // changedPathsVersion=2: the Bloom filters of the commit-graph hash
46+ // like v2, which has no signed/unsigned char ambiguity (readers on
47+ // other platforms agree with the writer).
48+ for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}, {"commitGraph.changedPathsVersion", "2"}} {
4649 if _, err = g.run(ctx, "-C", dir, "config", "--local", kv[0], kv[1]); err != nil {
4750 break
4851 }
@@ -174,6 +177,9 @@ func (g *Git) Trash(ctx context.Context, name domain.RepoName) error {
174177 // by directory, so the stale process cannot answer for a new repository
175178 // before it is dropped here.
176179 g.cat.forget(src)
180+ if fn := g.onTrash.Load(); fn != nil {
181+ (*fn)(src)
182+ }
177183 if err := syncDir(g.reposDir()); err != nil {
178184 return fmt.Errorf("trash repository %q: %w", name, err)
179185 }
@@ -222,9 +228,21 @@ func syncDir(dir string) error {
222228 return f.Sync()
223229 }
224230
231+// RepoDir returns the directory of the repository, repos/<name>.git below the
232+// data directory. It does not check that it exists.
233+func RepoDir(dataDir string, name domain.RepoName) string {
234+ return filepath.Join(dataDir, "repos", name.String()+gitSuffix)
235+}
236+
237+// OnTrash registers fn, which Trash calls with the directory the repository
238+// had, after it moved it away. Another reader of the repository (the
239+// ermitegit adapter) uses it to drop what it holds open. Call it once while
240+// wiring, before the adapter serves.
241+func (g *Git) OnTrash(fn func(dir string)) { g.onTrash.Store(&fn) }
242+
225243 // repoDir returns repos/<name>.git, or domain.ErrNotFound if it is missing.
226244 func (g *Git) repoDir(name domain.RepoName) (string, error) {
227- dir := filepath.Join(g.reposDir(), name.String()+gitSuffix)
245+ dir := RepoDir(g.dataDir, name)
228246 fi, err := os.Stat(dir)
229247 if err != nil {
230248 if errors.Is(err, fs.ErrNotExist) {
@@ -42,7 +42,10 @@ func (g *Git) Stage(ctx context.Context) (ports.StagedID, error) {
42 42
43 _, err := g.run(ctx, "init", "--quiet", "--bare", "--object-format=sha1", "--ref-format=files", "--initial-branch=main", dir)43 _, err := g.run(ctx, "init", "--quiet", "--bare", "--object-format=sha1", "--ref-format=files", "--initial-branch=main", dir)
44 if err == nil {44 if err == nil {
45- for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}} {45+ // changedPathsVersion=2: the Bloom filters of the commit-graph hash
46+ // like v2, which has no signed/unsigned char ambiguity (readers on
47+ // other platforms agree with the writer).
48+ for _, kv := range [][2]string{{"repack.writeBitmaps", "true"}, {"core.commitGraph", "true"}, {"commitGraph.changedPathsVersion", "2"}} {
46 if _, err = g.run(ctx, "-C", dir, "config", "--local", kv[0], kv[1]); err != nil {49 if _, err = g.run(ctx, "-C", dir, "config", "--local", kv[0], kv[1]); err != nil {
47 break50 break
48 }51 }
@@ -174,6 +177,9 @@ func (g *Git) Trash(ctx context.Context, name domain.RepoName) error {
174 // by directory, so the stale process cannot answer for a new repository177 // by directory, so the stale process cannot answer for a new repository
175 // before it is dropped here.178 // before it is dropped here.
176 g.cat.forget(src)179 g.cat.forget(src)
180+ if fn := g.onTrash.Load(); fn != nil {
181+ (*fn)(src)
182+ }
177 if err := syncDir(g.reposDir()); err != nil {183 if err := syncDir(g.reposDir()); err != nil {
178 return fmt.Errorf("trash repository %q: %w", name, err)184 return fmt.Errorf("trash repository %q: %w", name, err)
179 }185 }
@@ -222,9 +228,21 @@ func syncDir(dir string) error {
222 return f.Sync()228 return f.Sync()
223 }229 }
224 230
231+// RepoDir returns the directory of the repository, repos/<name>.git below the
232+// data directory. It does not check that it exists.
233+func RepoDir(dataDir string, name domain.RepoName) string {
234+ return filepath.Join(dataDir, "repos", name.String()+gitSuffix)
235+}
236+
237+// OnTrash registers fn, which Trash calls with the directory the repository
238+// had, after it moved it away. Another reader of the repository (the
239+// ermitegit adapter) uses it to drop what it holds open. Call it once while
240+// wiring, before the adapter serves.
241+func (g *Git) OnTrash(fn func(dir string)) { g.onTrash.Store(&fn) }
242+
225 // repoDir returns repos/<name>.git, or domain.ErrNotFound if it is missing.243 // repoDir returns repos/<name>.git, or domain.ErrNotFound if it is missing.
226 func (g *Git) repoDir(name domain.RepoName) (string, error) {244 func (g *Git) repoDir(name domain.RepoName) (string, error) {
227- dir := filepath.Join(g.reposDir(), name.String()+gitSuffix)245+ dir := RepoDir(g.dataDir, name)
228 fi, err := os.Stat(dir)246 fi, err := os.Stat(dir)
229 if err != nil {247 if err != nil {
230 if errors.Is(err, fs.ErrNotExist) {248 if errors.Is(err, fs.ErrNotExist) {

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 2

Tags 45

↑↓ moveenter openesc close