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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "encoding/binary" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/exec" | |
| 9 | 10 | "path/filepath" | |
| @@ -182,3 +183,79 @@ func TestDefaultBranchAndMaintain(t *testing.T) { | |||
| 182 | 183 | assert.FileExists(t, filepath.Join(dir, "objects", "info", "commit-graphs", "commit-graph-chain")) | |
| 183 | 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 | + | } | |
| @@ -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 ( | |||
| 16 | 16 | "runtime" | |
| 17 | 17 | "strings" | |
| 18 | 18 | "sync" | |
| 19 | + | "sync/atomic" | |
| 19 | 20 | "time" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| @@ -62,6 +63,8 @@ type Git struct { | |||
| 62 | 63 | ||
| 63 | 64 | cat *catPool // long-lived cat-file processes of the read side | |
| 64 | 65 | ||
| 66 | + | onTrash atomic.Pointer[func(dir string)] // see OnTrash | |
| 67 | + | ||
| 65 | 68 | exec chan struct{} // slots of the one-shot processes, see Config.MaxExec | |
| 66 | 69 | blobs chan struct{} // slots of the blob streams, see Config.MaxBlobStreams | |
| 67 | 70 | } | |
| @@ -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 side | 64 | 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.MaxExec | 68 | exec chan struct{} // slots of the one-shot processes, see Config.MaxExec | ||
| 66 | blobs chan struct{} // slots of the blob streams, see Config.MaxBlobStreams | 69 | 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) { | |||
| 193 | 193 | assert.Equal(t, "files", gitOut(t, staged, "rev-parse", "--show-ref-format")) | |
| 194 | 194 | assert.Equal(t, "true", gitOut(t, staged, "config", "--local", "repack.writeBitmaps")) | |
| 195 | 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 | 198 | assert.Equal(t, "refs/heads/main", gitOut(t, staged, "symbolic-ref", "HEAD")) | |
| 197 | 199 | ||
| 198 | 200 | 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) { | |||
| 42 | 42 | ||
| 43 | 43 | _, err := g.run(ctx, "init", "--quiet", "--bare", "--object-format=sha1", "--ref-format=files", "--initial-branch=main", dir) | |
| 44 | 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 | 49 | if _, err = g.run(ctx, "-C", dir, "config", "--local", kv[0], kv[1]); err != nil { | |
| 47 | 50 | break | |
| 48 | 51 | } | |
| @@ -174,6 +177,9 @@ func (g *Git) Trash(ctx context.Context, name domain.RepoName) error { | |||
| 174 | 177 | // by directory, so the stale process cannot answer for a new repository | |
| 175 | 178 | // before it is dropped here. | |
| 176 | 179 | g.cat.forget(src) | |
| 180 | + | if fn := g.onTrash.Load(); fn != nil { | |
| 181 | + | (*fn)(src) | |
| 182 | + | } | |
| 177 | 183 | if err := syncDir(g.reposDir()); err != nil { | |
| 178 | 184 | return fmt.Errorf("trash repository %q: %w", name, err) | |
| 179 | 185 | } | |
| @@ -222,9 +228,21 @@ func syncDir(dir string) error { | |||
| 222 | 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 | 243 | // repoDir returns repos/<name>.git, or domain.ErrNotFound if it is missing. | |
| 226 | 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 | 246 | fi, err := os.Stat(dir) | |
| 229 | 247 | if err != nil { | |
| 230 | 248 | 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 | break | 50 | 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 repository | 177 | // 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) { | ||