feat(readers): list the file paths of a revision
- commit
- 329477a21d9e558d97f746268249936f3fb4db8d
- author
- brice · · 2026-10-11 18:42 UTC
- parent
- 7559d0f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +337 -13
M internal/adapters/ermitegit/bench_test.go +4 -0
| @@ -163,6 +163,10 @@ func BenchmarkRepoReader(b *testing.B) { | |||
| 163 | 163 | _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true) | |
| 164 | 164 | return | |
| 165 | 165 | }}, | |
| 166 | + | {"ListPaths", func(r ports.RepoReader) (err error) { | |
| 167 | + | _, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 168 | + | return | |
| 169 | + | }}, | |
| 166 | 170 | {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }}, | |
| 167 | 171 | {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }}, | |
| 168 | 172 | {"ListCommits/page1", func(r ports.RepoReader) (err error) { | |
| @@ -163,6 +163,10 @@ func BenchmarkRepoReader(b *testing.B) { | |||||
| 163 | _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true) | 163 | _, err = r.GetTree(ctx, t.name, domain.Revision{}, path(t.treeDir), true) | ||
| 164 | return | 164 | return | ||
| 165 | }}, | 165 | }}, | ||
| 166 | + | {"ListPaths", func(r ports.RepoReader) (err error) { | |||
| 167 | + | _, err = r.ListPaths(ctx, t.name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 168 | + | return | |||
| 169 | + | }}, | |||
| 166 | {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }}, | 170 | {"OpenBlob/small", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.file)) }}, | ||
| 167 | {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }}, | 171 | {"OpenBlob/largest", func(r ports.RepoReader) error { return drain(r, domain.Revision{}, path(t.big)) }}, | ||
| 168 | {"ListCommits/page1", func(r ports.RepoReader) (err error) { | 172 | {"ListCommits/page1", func(r ports.RepoReader) (err error) { | ||
M internal/adapters/ermitegit/convert.go +110 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | + | "strings" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| 13 | 14 | git "bnema.dev/ermite-git" | |
| @@ -326,6 +327,115 @@ func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain. | |||
| 326 | 327 | return tree, nil | |
| 327 | 328 | } | |
| 328 | 329 | ||
| 330 | + | // pathChunk is the size of the blocks that hold the bytes of the paths. | |
| 331 | + | const pathChunk = 32 << 10 | |
| 332 | + | ||
| 333 | + | // pathWalker lists the files below a tree, depth first, in tree order. | |
| 334 | + | type pathWalker struct { | |
| 335 | + | ctx context.Context | |
| 336 | + | r *git.Repo | |
| 337 | + | maxPaths int | |
| 338 | + | maxBytes int | |
| 339 | + | ||
| 340 | + | // paths are the paths kept. Their bytes live in chunks: a chunk is filled | |
| 341 | + | // by a strings.Builder, so a path is read back as a string without a copy, | |
| 342 | + | // and a full chunk is left to the paths that point into it instead of | |
| 343 | + | // being grown and copied. | |
| 344 | + | paths []string | |
| 345 | + | chunk strings.Builder | |
| 346 | + | prefix []byte // "dir/sub/" of the directory being read | |
| 347 | + | size int // bytes of the paths kept | |
| 348 | + | truncated bool | |
| 349 | + | bad error | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // listPaths lists the files and symbolic links of a revision recursively. | |
| 353 | + | // | |
| 354 | + | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |
| 355 | + | func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |
| 356 | + | commit, root, err := commitTree(r, rev) | |
| 357 | + | if err != nil { | |
| 358 | + | return domain.PathList{}, err | |
| 359 | + | } | |
| 360 | + | treeID, err := domainID(root) | |
| 361 | + | if err != nil { | |
| 362 | + | return domain.PathList{}, err | |
| 363 | + | } | |
| 364 | + | w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes} | |
| 365 | + | if err := w.walk(root); err != nil { | |
| 366 | + | return domain.PathList{}, err | |
| 367 | + | } | |
| 368 | + | list := domain.PathList{CommitID: commit, TreeID: treeID, Truncated: w.truncated} | |
| 369 | + | list.Paths = w.paths | |
| 370 | + | return list, nil | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // walk lists one directory. The recursion is inside the callback of ReadTree, | |
| 374 | + | // where the entries of a subdirectory come at the place of the subdirectory, | |
| 375 | + | // like in `git ls-tree -r`; the callback can only say stop, so the reasons to | |
| 376 | + | // stop are kept in the walker. Every ReadTree takes its own pooled buffer, so | |
| 377 | + | // the nesting is safe. | |
| 378 | + | func (w *pathWalker) walk(tree git.ObjectID) error { | |
| 379 | + | if err := w.ctx.Err(); err != nil { | |
| 380 | + | return err | |
| 381 | + | } | |
| 382 | + | err := w.r.ReadTree(tree, func(e git.Entry) bool { | |
| 383 | + | switch { | |
| 384 | + | case e.Mode.IsGitlink(): | |
| 385 | + | return true | |
| 386 | + | case e.Mode.IsTree(): | |
| 387 | + | mark := len(w.prefix) | |
| 388 | + | w.prefix = append(w.prefix, e.Name...) | |
| 389 | + | // The paths below a directory that is not addressable are not | |
| 390 | + | // either: this also bounds the depth of the recursion. | |
| 391 | + | if !domain.IsAddressablePath(string(w.prefix)) { | |
| 392 | + | w.prefix = w.prefix[:mark] | |
| 393 | + | return true | |
| 394 | + | } | |
| 395 | + | w.prefix = append(w.prefix, '/') | |
| 396 | + | err := w.walk(e.ID) | |
| 397 | + | w.prefix = w.prefix[:mark] | |
| 398 | + | if err != nil { | |
| 399 | + | w.bad = err | |
| 400 | + | return false | |
| 401 | + | } | |
| 402 | + | return !w.truncated | |
| 403 | + | case e.Mode.IsRegular(), e.Mode.IsSymlink(): | |
| 404 | + | return w.add(e.Name) | |
| 405 | + | } | |
| 406 | + | w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name) | |
| 407 | + | return false | |
| 408 | + | }) | |
| 409 | + | if err == nil { | |
| 410 | + | err = w.bad | |
| 411 | + | } | |
| 412 | + | return err | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // add lists the file name of the current directory, and says whether the walk | |
| 416 | + | // goes on. | |
| 417 | + | func (w *pathWalker) add(name []byte) bool { | |
| 418 | + | n := len(w.prefix) + len(name) | |
| 419 | + | if w.chunk.Cap()-w.chunk.Len() < n { | |
| 420 | + | w.chunk = strings.Builder{} | |
| 421 | + | w.chunk.Grow(max(pathChunk, n)) | |
| 422 | + | } | |
| 423 | + | start := w.chunk.Len() | |
| 424 | + | w.chunk.Write(w.prefix) | |
| 425 | + | w.chunk.Write(name) | |
| 426 | + | path := w.chunk.String()[start:] | |
| 427 | + | switch { | |
| 428 | + | case !domain.IsAddressablePath(path): | |
| 429 | + | case len(w.paths) >= w.maxPaths || w.size+n > w.maxBytes: | |
| 430 | + | w.truncated = true | |
| 431 | + | return false | |
| 432 | + | default: | |
| 433 | + | w.size += n | |
| 434 | + | w.paths = append(w.paths, path) | |
| 435 | + | } | |
| 436 | + | return true | |
| 437 | + | } | |
| 438 | + | ||
| 329 | 439 | // fillLastCommits sets the last commit of the first MaxLastCommitEntries | |
| 330 | 440 | // entries with one walk. | |
| 331 | 441 | // | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "io" | 10 | "io" | ||
| 11 | + | "strings" | |||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| 13 | git "bnema.dev/ermite-git" | 14 | git "bnema.dev/ermite-git" | ||
| @@ -326,6 +327,115 @@ func getTree(ctx context.Context, r *git.Repo, rev domain.Revision, path domain. | |||||
| 326 | return tree, nil | 327 | return tree, nil | ||
| 327 | } | 328 | } | ||
| 328 | 329 | ||||
| 330 | + | // pathChunk is the size of the blocks that hold the bytes of the paths. | |||
| 331 | + | const pathChunk = 32 << 10 | |||
| 332 | + | ||||
| 333 | + | // pathWalker lists the files below a tree, depth first, in tree order. | |||
| 334 | + | type pathWalker struct { | |||
| 335 | + | ctx context.Context | |||
| 336 | + | r *git.Repo | |||
| 337 | + | maxPaths int | |||
| 338 | + | maxBytes int | |||
| 339 | + | ||||
| 340 | + | // paths are the paths kept. Their bytes live in chunks: a chunk is filled | |||
| 341 | + | // by a strings.Builder, so a path is read back as a string without a copy, | |||
| 342 | + | // and a full chunk is left to the paths that point into it instead of | |||
| 343 | + | // being grown and copied. | |||
| 344 | + | paths []string | |||
| 345 | + | chunk strings.Builder | |||
| 346 | + | prefix []byte // "dir/sub/" of the directory being read | |||
| 347 | + | size int // bytes of the paths kept | |||
| 348 | + | truncated bool | |||
| 349 | + | bad error | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | // listPaths lists the files and symbolic links of a revision recursively. | |||
| 353 | + | // | |||
| 354 | + | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |||
| 355 | + | func listPaths(ctx context.Context, r *git.Repo, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |||
| 356 | + | commit, root, err := commitTree(r, rev) | |||
| 357 | + | if err != nil { | |||
| 358 | + | return domain.PathList{}, err | |||
| 359 | + | } | |||
| 360 | + | treeID, err := domainID(root) | |||
| 361 | + | if err != nil { | |||
| 362 | + | return domain.PathList{}, err | |||
| 363 | + | } | |||
| 364 | + | w := &pathWalker{ctx: ctx, r: r, maxPaths: maxPaths, maxBytes: maxBytes} | |||
| 365 | + | if err := w.walk(root); err != nil { | |||
| 366 | + | return domain.PathList{}, err | |||
| 367 | + | } | |||
| 368 | + | list := domain.PathList{CommitID: commit, TreeID: treeID, Truncated: w.truncated} | |||
| 369 | + | list.Paths = w.paths | |||
| 370 | + | return list, nil | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // walk lists one directory. The recursion is inside the callback of ReadTree, | |||
| 374 | + | // where the entries of a subdirectory come at the place of the subdirectory, | |||
| 375 | + | // like in `git ls-tree -r`; the callback can only say stop, so the reasons to | |||
| 376 | + | // stop are kept in the walker. Every ReadTree takes its own pooled buffer, so | |||
| 377 | + | // the nesting is safe. | |||
| 378 | + | func (w *pathWalker) walk(tree git.ObjectID) error { | |||
| 379 | + | if err := w.ctx.Err(); err != nil { | |||
| 380 | + | return err | |||
| 381 | + | } | |||
| 382 | + | err := w.r.ReadTree(tree, func(e git.Entry) bool { | |||
| 383 | + | switch { | |||
| 384 | + | case e.Mode.IsGitlink(): | |||
| 385 | + | return true | |||
| 386 | + | case e.Mode.IsTree(): | |||
| 387 | + | mark := len(w.prefix) | |||
| 388 | + | w.prefix = append(w.prefix, e.Name...) | |||
| 389 | + | // The paths below a directory that is not addressable are not | |||
| 390 | + | // either: this also bounds the depth of the recursion. | |||
| 391 | + | if !domain.IsAddressablePath(string(w.prefix)) { | |||
| 392 | + | w.prefix = w.prefix[:mark] | |||
| 393 | + | return true | |||
| 394 | + | } | |||
| 395 | + | w.prefix = append(w.prefix, '/') | |||
| 396 | + | err := w.walk(e.ID) | |||
| 397 | + | w.prefix = w.prefix[:mark] | |||
| 398 | + | if err != nil { | |||
| 399 | + | w.bad = err | |||
| 400 | + | return false | |||
| 401 | + | } | |||
| 402 | + | return !w.truncated | |||
| 403 | + | case e.Mode.IsRegular(), e.Mode.IsSymlink(): | |||
| 404 | + | return w.add(e.Name) | |||
| 405 | + | } | |||
| 406 | + | w.bad = fmt.Errorf("unknown mode %o of %q", uint32(e.Mode), e.Name) | |||
| 407 | + | return false | |||
| 408 | + | }) | |||
| 409 | + | if err == nil { | |||
| 410 | + | err = w.bad | |||
| 411 | + | } | |||
| 412 | + | return err | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // add lists the file name of the current directory, and says whether the walk | |||
| 416 | + | // goes on. | |||
| 417 | + | func (w *pathWalker) add(name []byte) bool { | |||
| 418 | + | n := len(w.prefix) + len(name) | |||
| 419 | + | if w.chunk.Cap()-w.chunk.Len() < n { | |||
| 420 | + | w.chunk = strings.Builder{} | |||
| 421 | + | w.chunk.Grow(max(pathChunk, n)) | |||
| 422 | + | } | |||
| 423 | + | start := w.chunk.Len() | |||
| 424 | + | w.chunk.Write(w.prefix) | |||
| 425 | + | w.chunk.Write(name) | |||
| 426 | + | path := w.chunk.String()[start:] | |||
| 427 | + | switch { | |||
| 428 | + | case !domain.IsAddressablePath(path): | |||
| 429 | + | case len(w.paths) >= w.maxPaths || w.size+n > w.maxBytes: | |||
| 430 | + | w.truncated = true | |||
| 431 | + | return false | |||
| 432 | + | default: | |||
| 433 | + | w.size += n | |||
| 434 | + | w.paths = append(w.paths, path) | |||
| 435 | + | } | |||
| 436 | + | return true | |||
| 437 | + | } | |||
| 438 | + | ||||
| 329 | // fillLastCommits sets the last commit of the first MaxLastCommitEntries | 439 | // fillLastCommits sets the last commit of the first MaxLastCommitEntries | ||
| 330 | // entries with one walk. | 440 | // entries with one walk. | ||
| 331 | // | 441 | // | ||
M internal/adapters/ermitegit/ermitegit.go +16 -4
| @@ -94,12 +94,14 @@ type Routes struct { | |||
| 94 | 94 | ListCommits Backend | |
| 95 | 95 | // ListCommitsPath is a page of the history of a file or a directory. | |
| 96 | 96 | ListCommitsPath Backend | |
| 97 | + | // ListPaths is the recursive list of the file paths of a revision. | |
| 98 | + | ListPaths Backend | |
| 97 | 99 | } | |
| 98 | 100 | ||
| 99 | 101 | // AllRoutes returns routes that send every method to b. The tests and the | |
| 100 | 102 | // benchmarks use it to exercise a backend whatever the production routes are. | |
| 101 | 103 | func AllRoutes(b Backend) Routes { | |
| 102 | - | return Routes{b, b, b, b, b, b, b, b, b} | |
| 104 | + | return Routes{b, b, b, b, b, b, b, b, b, b} | |
| 103 | 105 | } | |
| 104 | 106 | ||
| 105 | 107 | // Config configures an Adapter. | |
| @@ -315,9 +317,19 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path | |||
| 315 | 317 | }, cli) | |
| 316 | 318 | } | |
| 317 | 319 | ||
| 318 | - | // ListPaths is not implemented yet. | |
| 319 | - | func (a *Adapter) ListPaths(_ context.Context, _ domain.RepoName, _ domain.Revision, _, _ int) (domain.PathList, error) { | |
| 320 | - | return domain.PathList{}, errors.ErrUnsupported | |
| 320 | + | // ListPaths lists the file and symbolic link paths of a revision. | |
| 321 | + | func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |
| 322 | + | cli := func() (domain.PathList, error) { return a.cli.ListPaths(ctx, repo, rev, maxPaths, maxBytes) } | |
| 323 | + | if a.routes.ListPaths == CLI { | |
| 324 | + | return cli() | |
| 325 | + | } | |
| 326 | + | return serve(ctx, a, repo, func(r *git.Repo) (domain.PathList, error) { | |
| 327 | + | list, err := listPaths(ctx, r, rev, maxPaths, maxBytes) | |
| 328 | + | if err != nil { | |
| 329 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |
| 330 | + | } | |
| 331 | + | return list, nil | |
| 332 | + | }, cli) | |
| 321 | 333 | } | |
| 322 | 334 | ||
| 323 | 335 | // GetTree lists a directory. | |
| @@ -94,12 +94,14 @@ type Routes struct { | |||||
| 94 | ListCommits Backend | 94 | ListCommits Backend | ||
| 95 | // ListCommitsPath is a page of the history of a file or a directory. | 95 | // ListCommitsPath is a page of the history of a file or a directory. | ||
| 96 | ListCommitsPath Backend | 96 | ListCommitsPath Backend | ||
| 97 | + | // ListPaths is the recursive list of the file paths of a revision. | |||
| 98 | + | ListPaths Backend | |||
| 97 | } | 99 | } | ||
| 98 | 100 | ||||
| 99 | // AllRoutes returns routes that send every method to b. The tests and the | 101 | // AllRoutes returns routes that send every method to b. The tests and the | ||
| 100 | // benchmarks use it to exercise a backend whatever the production routes are. | 102 | // benchmarks use it to exercise a backend whatever the production routes are. | ||
| 101 | func AllRoutes(b Backend) Routes { | 103 | func AllRoutes(b Backend) Routes { | ||
| 102 | - | return Routes{b, b, b, b, b, b, b, b, b} | 104 | + | return Routes{b, b, b, b, b, b, b, b, b, b} |
| 103 | } | 105 | } | ||
| 104 | 106 | ||||
| 105 | // Config configures an Adapter. | 107 | // Config configures an Adapter. | ||
| @@ -315,9 +317,19 @@ func (a *Adapter) ResolveRefPath(ctx context.Context, repo domain.RepoName, path | |||||
| 315 | }, cli) | 317 | }, cli) | ||
| 316 | } | 318 | } | ||
| 317 | 319 | ||||
| 318 | - | // ListPaths is not implemented yet. | 320 | + | // ListPaths lists the file and symbolic link paths of a revision. |
| 319 | - | func (a *Adapter) ListPaths(_ context.Context, _ domain.RepoName, _ domain.Revision, _, _ int) (domain.PathList, error) { | 321 | + | func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { |
| 320 | - | return domain.PathList{}, errors.ErrUnsupported | 322 | + | cli := func() (domain.PathList, error) { return a.cli.ListPaths(ctx, repo, rev, maxPaths, maxBytes) } |
| 323 | + | if a.routes.ListPaths == CLI { | |||
| 324 | + | return cli() | |||
| 325 | + | } | |||
| 326 | + | return serve(ctx, a, repo, func(r *git.Repo) (domain.PathList, error) { | |||
| 327 | + | list, err := listPaths(ctx, r, rev, maxPaths, maxBytes) | |||
| 328 | + | if err != nil { | |||
| 329 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |||
| 330 | + | } | |||
| 331 | + | return list, nil | |||
| 332 | + | }, cli) | |||
| 321 | } | 333 | } | ||
| 322 | 334 | ||||
| 323 | // GetTree lists a directory. | 335 | // GetTree lists a directory. | ||
M internal/adapters/ermitegit/routes.go +17 -6
| @@ -12,7 +12,8 @@ package ermitegit | |||
| 12 | 12 | // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and | |
| 13 | 13 | // ListCommitsPath cover directories of any size and files of any history. A | |
| 14 | 14 | // method that does not win stays on the CLI. GetCommit is not routable: the | |
| 15 | - | // diff stays on git. | |
| 15 | + | // diff stays on git. ListPaths has one shape: the fixture lists 131 files and | |
| 16 | + | // lazygit 2,180. | |
| 16 | 17 | // | |
| 17 | 18 | // Warm is a repository that is open; cold closes it before every call, the | |
| 18 | 19 | // price of the first read after the idle TTL, an eviction or a restart (a | |
| @@ -67,6 +68,10 @@ package ermitegit | |||
| 67 | 68 | // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win | |
| 68 | 69 | // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win | |
| 69 | 70 | // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win | |
| 71 | + | // ListPaths fixture warm 760µs 118.4K 225 41µs 37.9K 30 win | |
| 72 | + | // ListPaths fixture cold 771µs 118.4K 225 107µs 54.7K 160 win | |
| 73 | + | // ListPaths lazygit warm 1.8ms 432.9K 2280 842µs 250.3K 477 win | |
| 74 | + | // ListPaths lazygit cold 1.8ms 432.9K 2280 1.2ms 298.7K 749 win | |
| 70 | 75 | // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win | |
| 71 | 76 | // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba | |
| 72 | 77 | // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba | |
| @@ -103,11 +108,16 @@ package ermitegit | |||
| 103 | 108 | // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200 | |
| 104 | 109 | // against 60 to 140), and in time too for the ones that cost a few | |
| 105 | 110 | // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs). | |
| 106 | - | // - Only the history of a revision (ListCommits page 1 and deep) wins every | |
| 107 | - | // line, so it is the only method routed. GetTree dir+last also wins every | |
| 108 | - | // line, but GetTreeLastCommits covers GetTree root+last too, which loses | |
| 109 | - | // cold allocations on lazygit (4204 against 3073): it is the nearest | |
| 110 | - | // miss, and the first to flip once opening a repository allocates less. | |
| 111 | + | // - Only the history of a revision (ListCommits page 1 and deep) and the | |
| 112 | + | // recursive path list (ListPaths) win every line, so they are the only | |
| 113 | + | // methods routed. ListPaths wins even cold because one walk replaces a | |
| 114 | + | // process and the parsing of its output (the bytes of the paths are kept in | |
| 115 | + | // 32 KiB blocks, which is what brought the lazygit bytes under the CLI's: | |
| 116 | + | // a single growing buffer lost them, 699K against 433K). GetTree dir+last | |
| 117 | + | // also wins every line, but GetTreeLastCommits covers GetTree root+last | |
| 118 | + | // too, which loses cold allocations on lazygit (4204 against 3073): it is | |
| 119 | + | // the nearest miss, and the first to flip once opening a repository | |
| 120 | + | // allocates less. | |
| 111 | 121 | // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit, | |
| 112 | 122 | // warm bytes and allocations; above, it loses time. No size makes it win, | |
| 113 | 123 | // so there is no size-based route. | |
| @@ -126,5 +136,6 @@ func DefaultRoutes() Routes { | |||
| 126 | 136 | OpenBlob: CLI, | |
| 127 | 137 | ListCommits: ErmiteGit, | |
| 128 | 138 | ListCommitsPath: CLI, | |
| 139 | + | ListPaths: ErmiteGit, | |
| 129 | 140 | } | |
| 130 | 141 | } | |
| @@ -12,7 +12,8 @@ package ermitegit | |||||
| 12 | // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and | 12 | // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and | ||
| 13 | // ListCommitsPath cover directories of any size and files of any history. A | 13 | // ListCommitsPath cover directories of any size and files of any history. A | ||
| 14 | // method that does not win stays on the CLI. GetCommit is not routable: the | 14 | // method that does not win stays on the CLI. GetCommit is not routable: the | ||
| 15 | - | // diff stays on git. | 15 | + | // diff stays on git. ListPaths has one shape: the fixture lists 131 files and |
| 16 | + | // lazygit 2,180. | |||
| 16 | // | 17 | // | ||
| 17 | // Warm is a repository that is open; cold closes it before every call, the | 18 | // Warm is a repository that is open; cold closes it before every call, the | ||
| 18 | // price of the first read after the idle TTL, an eviction or a restart (a | 19 | // price of the first read after the idle TTL, an eviction or a restart (a | ||
| @@ -67,6 +68,10 @@ package ermitegit | |||||
| 67 | // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win | 68 | // GetTree/dir+last fixture cold 5.4ms 1.8M 1455 316µs 247.6K 323 win | ||
| 68 | // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win | 69 | // GetTree/dir+last lazygit warm 195.2ms 20.5M 17116 7.9ms 1.5M 855 win | ||
| 69 | // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win | 70 | // GetTree/dir+last lazygit cold 195.9ms 20.5M 17107 14.8ms 5.1M 6989 win | ||
| 71 | + | // ListPaths fixture warm 760µs 118.4K 225 41µs 37.9K 30 win | |||
| 72 | + | // ListPaths fixture cold 771µs 118.4K 225 107µs 54.7K 160 win | |||
| 73 | + | // ListPaths lazygit warm 1.8ms 432.9K 2280 842µs 250.3K 477 win | |||
| 74 | + | // ListPaths lazygit cold 1.8ms 432.9K 2280 1.2ms 298.7K 749 win | |||
| 70 | // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win | 75 | // OpenBlob/small fixture warm 46µs 6.1K 25 17µs 5.8K 14 win | ||
| 71 | // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba | 76 | // OpenBlob/small fixture cold 47µs 6.1K 25 87µs 27.7K 147 loses tba | ||
| 72 | // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba | 77 | // OpenBlob/small lazygit warm 51µs 5.4K 25 29µs 7.0K 42 loses ba | ||
| @@ -103,11 +108,16 @@ package ermitegit | |||||
| 103 | // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200 | 108 | // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200 | ||
| 104 | // against 60 to 140), and in time too for the ones that cost a few | 109 | // against 60 to 140), and in time too for the ones that cost a few | ||
| 105 | // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs). | 110 | // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs). | ||
| 106 | - | // - Only the history of a revision (ListCommits page 1 and deep) wins every | 111 | + | // - Only the history of a revision (ListCommits page 1 and deep) and the |
| 107 | - | // line, so it is the only method routed. GetTree dir+last also wins every | 112 | + | // recursive path list (ListPaths) win every line, so they are the only |
| 108 | - | // line, but GetTreeLastCommits covers GetTree root+last too, which loses | 113 | + | // methods routed. ListPaths wins even cold because one walk replaces a |
| 109 | - | // cold allocations on lazygit (4204 against 3073): it is the nearest | 114 | + | // process and the parsing of its output (the bytes of the paths are kept in |
| 110 | - | // miss, and the first to flip once opening a repository allocates less. | 115 | + | // 32 KiB blocks, which is what brought the lazygit bytes under the CLI's: |
| 116 | + | // a single growing buffer lost them, 699K against 433K). GetTree dir+last | |||
| 117 | + | // also wins every line, but GetTreeLastCommits covers GetTree root+last | |||
| 118 | + | // too, which loses cold allocations on lazygit (4204 against 3073): it is | |||
| 119 | + | // the nearest miss, and the first to flip once opening a repository | |||
| 120 | + | // allocates less. | |||
| 111 | // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit, | 121 | // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit, | ||
| 112 | // warm bytes and allocations; above, it loses time. No size makes it win, | 122 | // warm bytes and allocations; above, it loses time. No size makes it win, | ||
| 113 | // so there is no size-based route. | 123 | // so there is no size-based route. | ||
| @@ -126,5 +136,6 @@ func DefaultRoutes() Routes { | |||||
| 126 | OpenBlob: CLI, | 136 | OpenBlob: CLI, | ||
| 127 | ListCommits: ErmiteGit, | 137 | ListCommits: ErmiteGit, | ||
| 128 | ListCommitsPath: CLI, | 138 | ListCommitsPath: CLI, | ||
| 139 | + | ListPaths: ErmiteGit, | |||
| 129 | } | 140 | } | ||
| 130 | } | 141 | } | ||
M internal/adapters/gitcli/read.go +49 -3
| @@ -288,9 +288,55 @@ func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path | |||
| 288 | 288 | return obj, err | |
| 289 | 289 | } | |
| 290 | 290 | ||
| 291 | - | // ListPaths is not implemented yet. | |
| 292 | - | func (g *Git) ListPaths(_ context.Context, _ domain.RepoName, _ domain.Revision, _, _ int) (domain.PathList, error) { | |
| 293 | - | return domain.PathList{}, errors.ErrUnsupported | |
| 291 | + | // ListPaths lists the files and symbolic links of a revision with | |
| 292 | + | // `git ls-tree -r -z`, in its order, up to the bounds of the port. | |
| 293 | + | // | |
| 294 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
| 295 | + | func (g *Git) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |
| 296 | + | dir, err := g.repoDir(repo) | |
| 297 | + | if err != nil { | |
| 298 | + | return domain.PathList{}, err | |
| 299 | + | } | |
| 300 | + | obj, err := g.lookup(ctx, dir, rev, domain.TreePath{}) | |
| 301 | + | if err != nil { | |
| 302 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |
| 303 | + | } | |
| 304 | + | treeID, err := domain.ParseObjectID(obj.info.ID) | |
| 305 | + | if err != nil { | |
| 306 | + | return domain.PathList{}, err | |
| 307 | + | } | |
| 308 | + | list := domain.PathList{CommitID: obj.commit, TreeID: treeID} | |
| 309 | + | size := 0 | |
| 310 | + | err = g.pipe(ctx, readArgs(dir, "ls-tree", "-r", "-z", treeID.String()), func(r *bufio.Reader) error { | |
| 311 | + | for { | |
| 312 | + | tok, err := readToken(r) | |
| 313 | + | if errors.Is(err, io.EOF) { | |
| 314 | + | return nil | |
| 315 | + | } | |
| 316 | + | if err != nil { | |
| 317 | + | return err | |
| 318 | + | } | |
| 319 | + | meta, path, ok := strings.Cut(tok, "\t") | |
| 320 | + | if !ok { | |
| 321 | + | return fmt.Errorf("malformed ls-tree entry %q", tok) | |
| 322 | + | } | |
| 323 | + | // meta is "<mode> <type> <id>": a submodule is a commit. | |
| 324 | + | _, rest, _ := strings.Cut(meta, " ") | |
| 325 | + | if typ, _, _ := strings.Cut(rest, " "); typ == "commit" || !domain.IsAddressablePath(path) { | |
| 326 | + | continue | |
| 327 | + | } | |
| 328 | + | if len(list.Paths) >= maxPaths || size+len(path) > maxBytes { | |
| 329 | + | list.Truncated = true | |
| 330 | + | return errStop | |
| 331 | + | } | |
| 332 | + | size += len(path) | |
| 333 | + | list.Paths = append(list.Paths, path) | |
| 334 | + | } | |
| 335 | + | }) | |
| 336 | + | if err != nil { | |
| 337 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |
| 338 | + | } | |
| 339 | + | return list, nil | |
| 294 | 340 | } | |
| 295 | 341 | ||
| 296 | 342 | // GetTree lists a directory with `git ls-tree -z -l`. | |
| @@ -288,9 +288,55 @@ func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path | |||||
| 288 | return obj, err | 288 | return obj, err | ||
| 289 | } | 289 | } | ||
| 290 | 290 | ||||
| 291 | - | // ListPaths is not implemented yet. | 291 | + | // ListPaths lists the files and symbolic links of a revision with |
| 292 | - | func (g *Git) ListPaths(_ context.Context, _ domain.RepoName, _ domain.Revision, _, _ int) (domain.PathList, error) { | 292 | + | // `git ls-tree -r -z`, in its order, up to the bounds of the port. |
| 293 | - | return domain.PathList{}, errors.ErrUnsupported | 293 | + | // |
| 294 | + | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |||
| 295 | + | func (g *Git) ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error) { | |||
| 296 | + | dir, err := g.repoDir(repo) | |||
| 297 | + | if err != nil { | |||
| 298 | + | return domain.PathList{}, err | |||
| 299 | + | } | |||
| 300 | + | obj, err := g.lookup(ctx, dir, rev, domain.TreePath{}) | |||
| 301 | + | if err != nil { | |||
| 302 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |||
| 303 | + | } | |||
| 304 | + | treeID, err := domain.ParseObjectID(obj.info.ID) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return domain.PathList{}, err | |||
| 307 | + | } | |||
| 308 | + | list := domain.PathList{CommitID: obj.commit, TreeID: treeID} | |||
| 309 | + | size := 0 | |||
| 310 | + | err = g.pipe(ctx, readArgs(dir, "ls-tree", "-r", "-z", treeID.String()), func(r *bufio.Reader) error { | |||
| 311 | + | for { | |||
| 312 | + | tok, err := readToken(r) | |||
| 313 | + | if errors.Is(err, io.EOF) { | |||
| 314 | + | return nil | |||
| 315 | + | } | |||
| 316 | + | if err != nil { | |||
| 317 | + | return err | |||
| 318 | + | } | |||
| 319 | + | meta, path, ok := strings.Cut(tok, "\t") | |||
| 320 | + | if !ok { | |||
| 321 | + | return fmt.Errorf("malformed ls-tree entry %q", tok) | |||
| 322 | + | } | |||
| 323 | + | // meta is "<mode> <type> <id>": a submodule is a commit. | |||
| 324 | + | _, rest, _ := strings.Cut(meta, " ") | |||
| 325 | + | if typ, _, _ := strings.Cut(rest, " "); typ == "commit" || !domain.IsAddressablePath(path) { | |||
| 326 | + | continue | |||
| 327 | + | } | |||
| 328 | + | if len(list.Paths) >= maxPaths || size+len(path) > maxBytes { | |||
| 329 | + | list.Truncated = true | |||
| 330 | + | return errStop | |||
| 331 | + | } | |||
| 332 | + | size += len(path) | |||
| 333 | + | list.Paths = append(list.Paths, path) | |||
| 334 | + | } | |||
| 335 | + | }) | |||
| 336 | + | if err != nil { | |||
| 337 | + | return domain.PathList{}, fmt.Errorf("paths of %q: %w", repo, err) | |||
| 338 | + | } | |||
| 339 | + | return list, nil | |||
| 294 | } | 340 | } | ||
| 295 | 341 | ||||
| 296 | // GetTree lists a directory with `git ls-tree -z -l`. | 342 | // GetTree lists a directory with `git ls-tree -z -l`. | ||
M internal/readertest/compare.go +15 -0
| @@ -264,6 +264,17 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir | |||
| 264 | 264 | } | |
| 265 | 265 | } | |
| 266 | 266 | }) | |
| 267 | + | t.Run("ListPaths", func(t *testing.T) { | |
| 268 | + | for _, rev := range revs { | |
| 269 | + | for _, lim := range [...][2]int{{domain.MaxPathEntries, domain.MaxPathBytes}, {5, domain.MaxPathBytes}, {domain.MaxPathEntries, 60}} { | |
| 270 | + | w, werr := want.ListPaths(ctx, repo, rev, lim[0], lim[1]) | |
| 271 | + | g, gerr := got.ListPaths(ctx, repo, rev, lim[0], lim[1]) | |
| 272 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "paths at %q %v", rev, lim) { | |
| 273 | + | assert.Equal(t, w, g, "paths at %q %v", rev, lim) | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | }) | |
| 267 | 278 | t.Run("OpenBlob", func(t *testing.T) { | |
| 268 | 279 | read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) { | |
| 269 | 280 | info, rc, err := r.OpenBlob(ctx, repo, rev, path) | |
| @@ -338,6 +349,10 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir | |||
| 338 | 349 | _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false) | |
| 339 | 350 | return err | |
| 340 | 351 | }, | |
| 352 | + | "ListPaths": func(r ports.RepoReader) error { | |
| 353 | + | _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 354 | + | return err | |
| 355 | + | }, | |
| 341 | 356 | "OpenBlob": func(r ports.RepoReader) error { | |
| 342 | 357 | _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{}) | |
| 343 | 358 | return err | |
| @@ -264,6 +264,17 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir | |||||
| 264 | } | 264 | } | ||
| 265 | } | 265 | } | ||
| 266 | }) | 266 | }) | ||
| 267 | + | t.Run("ListPaths", func(t *testing.T) { | |||
| 268 | + | for _, rev := range revs { | |||
| 269 | + | for _, lim := range [...][2]int{{domain.MaxPathEntries, domain.MaxPathBytes}, {5, domain.MaxPathBytes}, {domain.MaxPathEntries, 60}} { | |||
| 270 | + | w, werr := want.ListPaths(ctx, repo, rev, lim[0], lim[1]) | |||
| 271 | + | g, gerr := got.ListPaths(ctx, repo, rev, lim[0], lim[1]) | |||
| 272 | + | if assert.Equal(t, errClass(werr), errClass(gerr), "paths at %q %v", rev, lim) { | |||
| 273 | + | assert.Equal(t, w, g, "paths at %q %v", rev, lim) | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | }) | |||
| 267 | t.Run("OpenBlob", func(t *testing.T) { | 278 | t.Run("OpenBlob", func(t *testing.T) { | ||
| 268 | read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) { | 279 | read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) { | ||
| 269 | info, rc, err := r.OpenBlob(ctx, repo, rev, path) | 280 | info, rc, err := r.OpenBlob(ctx, repo, rev, path) | ||
| @@ -338,6 +349,10 @@ func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir | |||||
| 338 | _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false) | 349 | _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false) | ||
| 339 | return err | 350 | return err | ||
| 340 | }, | 351 | }, | ||
| 352 | + | "ListPaths": func(r ports.RepoReader) error { | |||
| 353 | + | _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 354 | + | return err | |||
| 355 | + | }, | |||
| 341 | "OpenBlob": func(r ports.RepoReader) error { | 356 | "OpenBlob": func(r ports.RepoReader) error { | ||
| 342 | _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{}) | 357 | _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{}) | ||
| 343 | return err | 358 | return err | ||
M internal/readertest/conformance.go +108 -0
| @@ -46,6 +46,7 @@ func Conformance(t *testing.T, mk Maker) { | |||
| 46 | 46 | {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral}, | |
| 47 | 47 | {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs}, | |
| 48 | 48 | {"GetTreeIsBounded", testGetTreeIsBounded}, | |
| 49 | + | {"ListPaths", testListPaths}, | |
| 49 | 50 | } { | |
| 50 | 51 | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | |
| 51 | 52 | } | |
| @@ -701,3 +702,110 @@ func testGetTreeIsBounded(t *testing.T, mk Maker) { | |||
| 701 | 702 | require.NoError(t, err) | |
| 702 | 703 | assert.False(t, small.Truncated) | |
| 703 | 704 | } | |
| 705 | + | ||
| 706 | + | func testListPaths(t *testing.T, mk Maker) { | |
| 707 | + | ctx := context.Background() | |
| 708 | + | g, data := mk(t) | |
| 709 | + | f := buildPaths(t, data) | |
| 710 | + | ||
| 711 | + | // git ls-tree -r in order, minus the submodule and the path no address can | |
| 712 | + | // carry. | |
| 713 | + | var want []string | |
| 714 | + | for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") { | |
| 715 | + | meta, p, _ := strings.Cut(rec, "\t") | |
| 716 | + | if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") { | |
| 717 | + | want = append(want, p) | |
| 718 | + | } | |
| 719 | + | } | |
| 720 | + | require.Equal(t, []string{ | |
| 721 | + | "a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt", | |
| 722 | + | }, want, "the fixture") | |
| 723 | + | ||
| 724 | + | got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 725 | + | require.NoError(t, err) | |
| 726 | + | assert.Equal(t, want, got.Paths) | |
| 727 | + | assert.False(t, got.Truncated) | |
| 728 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String()) | |
| 729 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String()) | |
| 730 | + | ||
| 731 | + | t.Run("revisions", func(t *testing.T) { | |
| 732 | + | for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} { | |
| 733 | + | list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes) | |
| 734 | + | require.NoError(t, err, s) | |
| 735 | + | assert.Equal(t, want, list.Paths, s) | |
| 736 | + | } | |
| 737 | + | // The first commit has no submodule: the same files. | |
| 738 | + | first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes) | |
| 739 | + | require.NoError(t, err) | |
| 740 | + | assert.Equal(t, want, first.Paths) | |
| 741 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String()) | |
| 742 | + | assert.NotEqual(t, got.TreeID, first.TreeID) | |
| 743 | + | }) | |
| 744 | + | t.Run("not found", func(t *testing.T) { | |
| 745 | + | for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} { | |
| 746 | + | _, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes) | |
| 747 | + | assert.ErrorIs(t, err, domain.ErrNotFound, s) | |
| 748 | + | } | |
| 749 | + | _, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 750 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 751 | + | }) | |
| 752 | + | t.Run("empty repository", func(t *testing.T) { | |
| 753 | + | e := NewFixture(t, data, "empty-paths") | |
| 754 | + | _, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 755 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 756 | + | }) | |
| 757 | + | t.Run("bounds", func(t *testing.T) { | |
| 758 | + | list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes) | |
| 759 | + | require.NoError(t, err) | |
| 760 | + | assert.Equal(t, want[:3], list.Paths) | |
| 761 | + | assert.True(t, list.Truncated) | |
| 762 | + | assert.Equal(t, got.CommitID, list.CommitID) | |
| 763 | + | assert.Equal(t, got.TreeID, list.TreeID) | |
| 764 | + | ||
| 765 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes) | |
| 766 | + | require.NoError(t, err) | |
| 767 | + | assert.Equal(t, want, list.Paths) | |
| 768 | + | assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated") | |
| 769 | + | ||
| 770 | + | sum := len(want[0]) + len(want[1]) + len(want[2]) | |
| 771 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1) | |
| 772 | + | require.NoError(t, err) | |
| 773 | + | assert.Equal(t, want[:2], list.Paths) | |
| 774 | + | assert.True(t, list.Truncated) | |
| 775 | + | ||
| 776 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum) | |
| 777 | + | require.NoError(t, err) | |
| 778 | + | assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit") | |
| 779 | + | assert.True(t, list.Truncated) | |
| 780 | + | ||
| 781 | + | total := 0 | |
| 782 | + | for _, p := range want { | |
| 783 | + | total += len(p) | |
| 784 | + | } | |
| 785 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total) | |
| 786 | + | require.NoError(t, err) | |
| 787 | + | assert.Equal(t, want, list.Paths) | |
| 788 | + | assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated") | |
| 789 | + | }) | |
| 790 | + | t.Run("cancelled context", func(t *testing.T) { | |
| 791 | + | ctx, cancel := context.WithCancel(ctx) | |
| 792 | + | cancel() | |
| 793 | + | _, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 794 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 795 | + | }) | |
| 796 | + | t.Run("a large tree", func(t *testing.T) { | |
| 797 | + | w := NewFixture(t, data, "paths-wide") | |
| 798 | + | for i := range 300 { | |
| 799 | + | w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x") | |
| 800 | + | } | |
| 801 | + | w.Commit("wide") | |
| 802 | + | order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD")) | |
| 803 | + | list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |
| 804 | + | require.NoError(t, err) | |
| 805 | + | assert.Equal(t, order, list.Paths) | |
| 806 | + | list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes) | |
| 807 | + | require.NoError(t, err) | |
| 808 | + | assert.Equal(t, order[:100], list.Paths) | |
| 809 | + | assert.True(t, list.Truncated) | |
| 810 | + | }) | |
| 811 | + | } | |
| @@ -46,6 +46,7 @@ func Conformance(t *testing.T, mk Maker) { | |||||
| 46 | {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral}, | 46 | {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral}, | ||
| 47 | {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs}, | 47 | {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs}, | ||
| 48 | {"GetTreeIsBounded", testGetTreeIsBounded}, | 48 | {"GetTreeIsBounded", testGetTreeIsBounded}, | ||
| 49 | + | {"ListPaths", testListPaths}, | |||
| 49 | } { | 50 | } { | ||
| 50 | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | 51 | t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) }) | ||
| 51 | } | 52 | } | ||
| @@ -701,3 +702,110 @@ func testGetTreeIsBounded(t *testing.T, mk Maker) { | |||||
| 701 | require.NoError(t, err) | 702 | require.NoError(t, err) | ||
| 702 | assert.False(t, small.Truncated) | 703 | assert.False(t, small.Truncated) | ||
| 703 | } | 704 | } | ||
| 705 | + | ||||
| 706 | + | func testListPaths(t *testing.T, mk Maker) { | |||
| 707 | + | ctx := context.Background() | |||
| 708 | + | g, data := mk(t) | |||
| 709 | + | f := buildPaths(t, data) | |||
| 710 | + | ||||
| 711 | + | // git ls-tree -r in order, minus the submodule and the path no address can | |||
| 712 | + | // carry. | |||
| 713 | + | var want []string | |||
| 714 | + | for _, rec := range strings.Split(strings.TrimSuffix(GitOut(t, f.Bare, "ls-tree", "-r", "-z", "HEAD"), "\x00"), "\x00") { | |||
| 715 | + | meta, p, _ := strings.Cut(rec, "\t") | |||
| 716 | + | if !strings.Contains(meta, " commit ") && !strings.Contains(p, "\t") { | |||
| 717 | + | want = append(want, p) | |||
| 718 | + | } | |||
| 719 | + | } | |||
| 720 | + | require.Equal(t, []string{ | |||
| 721 | + | "a-b.txt", "a/b.txt", "a/b/c.txt", "d/e/f/deep.txt", "héllo wörld.txt", "link", "run.sh", "z.txt", | |||
| 722 | + | }, want, "the fixture") | |||
| 723 | + | ||||
| 724 | + | got, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 725 | + | require.NoError(t, err) | |||
| 726 | + | assert.Equal(t, want, got.Paths) | |||
| 727 | + | assert.False(t, got.Truncated) | |||
| 728 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD"), got.CommitID.String()) | |||
| 729 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD^{tree}"), got.TreeID.String()) | |||
| 730 | + | ||||
| 731 | + | t.Run("revisions", func(t *testing.T) { | |||
| 732 | + | for _, s := range []string{"main", "v1", "refs/tags/v1", f.Git("rev-parse", "HEAD"), f.Git("rev-parse", "HEAD")[:10]} { | |||
| 733 | + | list, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 734 | + | require.NoError(t, err, s) | |||
| 735 | + | assert.Equal(t, want, list.Paths, s) | |||
| 736 | + | } | |||
| 737 | + | // The first commit has no submodule: the same files. | |||
| 738 | + | first, err := g.ListPaths(ctx, f.Name, Rev(t, f.Git("rev-parse", "HEAD~1")), domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 739 | + | require.NoError(t, err) | |||
| 740 | + | assert.Equal(t, want, first.Paths) | |||
| 741 | + | assert.Equal(t, GitOut(t, f.Bare, "rev-parse", "HEAD~1^{tree}"), first.TreeID.String()) | |||
| 742 | + | assert.NotEqual(t, got.TreeID, first.TreeID) | |||
| 743 | + | }) | |||
| 744 | + | t.Run("not found", func(t *testing.T) { | |||
| 745 | + | for _, s := range []string{"nope", "refs/heads/nope", strings.Repeat("ab", 20), "deadbeef"} { | |||
| 746 | + | _, err := g.ListPaths(ctx, f.Name, Rev(t, s), domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 747 | + | assert.ErrorIs(t, err, domain.ErrNotFound, s) | |||
| 748 | + | } | |||
| 749 | + | _, err := g.ListPaths(ctx, RepoName(t, "no-such-repo"), domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 750 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 751 | + | }) | |||
| 752 | + | t.Run("empty repository", func(t *testing.T) { | |||
| 753 | + | e := NewFixture(t, data, "empty-paths") | |||
| 754 | + | _, err := g.ListPaths(ctx, e.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 755 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 756 | + | }) | |||
| 757 | + | t.Run("bounds", func(t *testing.T) { | |||
| 758 | + | list, err := g.ListPaths(ctx, f.Name, domain.Revision{}, 3, domain.MaxPathBytes) | |||
| 759 | + | require.NoError(t, err) | |||
| 760 | + | assert.Equal(t, want[:3], list.Paths) | |||
| 761 | + | assert.True(t, list.Truncated) | |||
| 762 | + | assert.Equal(t, got.CommitID, list.CommitID) | |||
| 763 | + | assert.Equal(t, got.TreeID, list.TreeID) | |||
| 764 | + | ||||
| 765 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, len(want), domain.MaxPathBytes) | |||
| 766 | + | require.NoError(t, err) | |||
| 767 | + | assert.Equal(t, want, list.Paths) | |||
| 768 | + | assert.False(t, list.Truncated, "exactly maxPaths paths is not truncated") | |||
| 769 | + | ||||
| 770 | + | sum := len(want[0]) + len(want[1]) + len(want[2]) | |||
| 771 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum-1) | |||
| 772 | + | require.NoError(t, err) | |||
| 773 | + | assert.Equal(t, want[:2], list.Paths) | |||
| 774 | + | assert.True(t, list.Truncated) | |||
| 775 | + | ||||
| 776 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, sum) | |||
| 777 | + | require.NoError(t, err) | |||
| 778 | + | assert.Equal(t, want[:3], list.Paths, "bytes up to maxBytes fit") | |||
| 779 | + | assert.True(t, list.Truncated) | |||
| 780 | + | ||||
| 781 | + | total := 0 | |||
| 782 | + | for _, p := range want { | |||
| 783 | + | total += len(p) | |||
| 784 | + | } | |||
| 785 | + | list, err = g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, total) | |||
| 786 | + | require.NoError(t, err) | |||
| 787 | + | assert.Equal(t, want, list.Paths) | |||
| 788 | + | assert.False(t, list.Truncated, "exactly maxBytes bytes is not truncated") | |||
| 789 | + | }) | |||
| 790 | + | t.Run("cancelled context", func(t *testing.T) { | |||
| 791 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 792 | + | cancel() | |||
| 793 | + | _, err := g.ListPaths(ctx, f.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 794 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 795 | + | }) | |||
| 796 | + | t.Run("a large tree", func(t *testing.T) { | |||
| 797 | + | w := NewFixture(t, data, "paths-wide") | |||
| 798 | + | for i := range 300 { | |||
| 799 | + | w.Write(fmt.Sprintf("d%02d/e%d/f%03d.txt", i%17, i%5, i), "x") | |||
| 800 | + | } | |||
| 801 | + | w.Commit("wide") | |||
| 802 | + | order := lines(GitOut(t, w.Bare, "ls-tree", "-r", "--name-only", "HEAD")) | |||
| 803 | + | list, err := g.ListPaths(ctx, w.Name, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes) | |||
| 804 | + | require.NoError(t, err) | |||
| 805 | + | assert.Equal(t, order, list.Paths) | |||
| 806 | + | list, err = g.ListPaths(ctx, w.Name, domain.Revision{}, 100, domain.MaxPathBytes) | |||
| 807 | + | require.NoError(t, err) | |||
| 808 | + | assert.Equal(t, order[:100], list.Paths) | |||
| 809 | + | assert.True(t, list.Truncated) | |||
| 810 | + | }) | |||
| 811 | + | } | |||
M internal/readertest/fixtures.go +18 -0
| @@ -63,6 +63,7 @@ func Cases() []Case { | |||
| 63 | 63 | {"refs", buildRefs}, | |
| 64 | 64 | {"dangling ref", buildDangling}, | |
| 65 | 65 | {"submodule", buildSubmodule}, | |
| 66 | + | {"paths", buildPaths}, | |
| 66 | 67 | {"wide directory", buildWide}, | |
| 67 | 68 | {"detached HEAD", func(t *testing.T, data string) *Fixture { | |
| 68 | 69 | h := NewHistory(t, data) | |
| @@ -265,6 +266,23 @@ func buildSubmodule(t *testing.T, data string) *Fixture { | |||
| 265 | 266 | return f | |
| 266 | 267 | } | |
| 267 | 268 | ||
| 269 | + | // buildPaths has what ListPaths must order and skip: nested directories whose | |
| 270 | + | // names sort around "/" (a/b/c.txt, a/b.txt, a-b.txt), an executable, a | |
| 271 | + | // symlink, a submodule, and a name with a tab that no address can carry. The | |
| 272 | + | // tag v1 points at the commit. | |
| 273 | + | func buildPaths(t *testing.T, data string) *Fixture { | |
| 274 | + | f := NewFixture(t, data, "paths") | |
| 275 | + | f.Write("a/b/c.txt", "c\n").Write("a/b.txt", "b\n").Write("a-b.txt", "ab\n").Write("z.txt", "z\n") | |
| 276 | + | f.Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("z.txt", "link") | |
| 277 | + | f.Write("tab\tname.txt", "t\n").Write("d/e/f/deep.txt", "d\n").Write("héllo wörld.txt", "h\n") | |
| 278 | + | f.Commit("base") | |
| 279 | + | f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod") | |
| 280 | + | f.Git("commit", "--quiet", "-m", "add submodule") | |
| 281 | + | f.Git("tag", "v1") | |
| 282 | + | f.Maintain() | |
| 283 | + | return f | |
| 284 | + | } | |
| 285 | + | ||
| 268 | 286 | // buildWide has a directory over MaxTreeEntries entries and one over | |
| 269 | 287 | // MaxLastCommitEntries, with commits that touch some of them. | |
| 270 | 288 | func buildWide(t *testing.T, data string) *Fixture { | |
| @@ -63,6 +63,7 @@ func Cases() []Case { | |||||
| 63 | {"refs", buildRefs}, | 63 | {"refs", buildRefs}, | ||
| 64 | {"dangling ref", buildDangling}, | 64 | {"dangling ref", buildDangling}, | ||
| 65 | {"submodule", buildSubmodule}, | 65 | {"submodule", buildSubmodule}, | ||
| 66 | + | {"paths", buildPaths}, | |||
| 66 | {"wide directory", buildWide}, | 67 | {"wide directory", buildWide}, | ||
| 67 | {"detached HEAD", func(t *testing.T, data string) *Fixture { | 68 | {"detached HEAD", func(t *testing.T, data string) *Fixture { | ||
| 68 | h := NewHistory(t, data) | 69 | h := NewHistory(t, data) | ||
| @@ -265,6 +266,23 @@ func buildSubmodule(t *testing.T, data string) *Fixture { | |||||
| 265 | return f | 266 | return f | ||
| 266 | } | 267 | } | ||
| 267 | 268 | ||||
| 269 | + | // buildPaths has what ListPaths must order and skip: nested directories whose | |||
| 270 | + | // names sort around "/" (a/b/c.txt, a/b.txt, a-b.txt), an executable, a | |||
| 271 | + | // symlink, a submodule, and a name with a tab that no address can carry. The | |||
| 272 | + | // tag v1 points at the commit. | |||
| 273 | + | func buildPaths(t *testing.T, data string) *Fixture { | |||
| 274 | + | f := NewFixture(t, data, "paths") | |||
| 275 | + | f.Write("a/b/c.txt", "c\n").Write("a/b.txt", "b\n").Write("a-b.txt", "ab\n").Write("z.txt", "z\n") | |||
| 276 | + | f.Write("run.sh", "#!/bin/sh\n").Chmod("run.sh", 0o755).Symlink("z.txt", "link") | |||
| 277 | + | f.Write("tab\tname.txt", "t\n").Write("d/e/f/deep.txt", "d\n").Write("héllo wörld.txt", "h\n") | |||
| 278 | + | f.Commit("base") | |||
| 279 | + | f.Git("update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("ab", 20)+",vendor/mod") | |||
| 280 | + | f.Git("commit", "--quiet", "-m", "add submodule") | |||
| 281 | + | f.Git("tag", "v1") | |||
| 282 | + | f.Maintain() | |||
| 283 | + | return f | |||
| 284 | + | } | |||
| 285 | + | ||||
| 268 | // buildWide has a directory over MaxTreeEntries entries and one over | 286 | // buildWide has a directory over MaxTreeEntries entries and one over | ||
| 269 | // MaxLastCommitEntries, with commits that touch some of them. | 287 | // MaxLastCommitEntries, with commits that touch some of them. | ||
| 270 | func buildWide(t *testing.T, data string) *Fixture { | 288 | func buildWide(t *testing.T, data string) *Fixture { | ||