detached 2a9866e

perf(gitcli): fill tree last commits with a small worker pool

commit
2a9866e74b1c5043b5a42628f3b62f86e27bbe16
author
brice · · 2026-10-10 11:41 UTC
parent
13c62e2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +51 -10
M internal/adapters/gitcli/read.go +51 -10
@@ -14,6 +14,7 @@ import (
1414 "slices"
1515 "strconv"
1616 "strings"
17+ "sync"
1718 "time"
1819
1920 "bnema.dev/ermite/internal/domain"
@@ -409,25 +410,65 @@ func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error
409410 }
410411
411412 // fillLastCommits sets the last commit of the first MaxLastCommitEntries
412-// entries with `git log -1 <commit> -- <path>`, one after the other. Git
413-// answers each from the commit-graph and its changed-path filters when they
414-// exist. Every command takes a slot of the exec semaphore, so a large
415-// directory cannot use more than its share of the processes the server may
416-// run. Entries beyond the bound are left without one and the tree says so.
413+// entries with `git log -1 <commit> -- <path>`, run by a small pool of
414+// workers: min(4, max(1, MaxExec/2)) at most. Git answers each from the
415+// commit-graph and its changed-path filters when they exist. Every command
416+// takes a slot of the exec semaphore on its own (the workers hold none), so a
417+// large directory cannot use more than its share of the processes the server
418+// may run. Each worker writes its own entry, so the result does not depend on
419+// the scheduling; the first error stops the others and is returned. Entries
420+// beyond the bound are left without one and the tree says so.
421+//
422+//hexcheck:ignore suspicious-business-logic-in-adapter fills the tree with git output; the bound comes from the port
417423 func (g *Git) fillLastCommits(ctx context.Context, dir string, tree *domain.Tree) error {
418424 n := len(tree.Entries)
419425 if n > domain.MaxLastCommitEntries {
420426 n = domain.MaxLastCommitEntries
421427 tree.LastCommitsOmitted = true
422428 }
429+ workers := min(4, max(1, cap(g.exec)/2), n)
430+ if workers == 0 {
431+ return nil
432+ }
433+ ctx, cancel := context.WithCancel(ctx)
434+ defer cancel()
435+ var (
436+ wg sync.WaitGroup
437+ mu sync.Mutex
438+ firstErr error
439+ )
440+ next := make(chan int)
441+ for range workers {
442+ wg.Go(func() {
443+ for i := range next {
444+ c, err := g.lastCommit(ctx, dir, tree.CommitID, tree.Entries[i].Path)
445+ if err != nil {
446+ mu.Lock()
447+ if firstErr == nil {
448+ firstErr = err
449+ cancel()
450+ }
451+ mu.Unlock()
452+ continue
453+ }
454+ tree.Entries[i].LastCommit = &c
455+ }
456+ })
457+ }
458+feed:
423459 for i := range n {
424- c, err := g.lastCommit(ctx, dir, tree.CommitID, tree.Entries[i].Path)
425- if err != nil {
426- return err
460+ select {
461+ case next <- i:
462+ case <-ctx.Done():
463+ break feed
427464 }
428- tree.Entries[i].LastCommit = &c
429465 }
430- return nil
466+ close(next)
467+ wg.Wait()
468+ if firstErr != nil {
469+ return firstErr
470+ }
471+ return ctx.Err()
431472 }
432473
433474 // lastCommit returns the newest commit reachable from head that changed path;
@@ -14,6 +14,7 @@ import (
14 "slices"14 "slices"
15 "strconv"15 "strconv"
16 "strings"16 "strings"
17+ "sync"
17 "time"18 "time"
18 19
19 "bnema.dev/ermite/internal/domain"20 "bnema.dev/ermite/internal/domain"
@@ -409,25 +410,65 @@ func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error
409 }410 }
410 411
411 // fillLastCommits sets the last commit of the first MaxLastCommitEntries412 // fillLastCommits sets the last commit of the first MaxLastCommitEntries
412-// entries with `git log -1 <commit> -- <path>`, one after the other. Git413+// entries with `git log -1 <commit> -- <path>`, run by a small pool of
413-// answers each from the commit-graph and its changed-path filters when they414+// workers: min(4, max(1, MaxExec/2)) at most. Git answers each from the
414-// exist. Every command takes a slot of the exec semaphore, so a large415+// commit-graph and its changed-path filters when they exist. Every command
415-// directory cannot use more than its share of the processes the server may416+// takes a slot of the exec semaphore on its own (the workers hold none), so a
416-// run. Entries beyond the bound are left without one and the tree says so.417+// large directory cannot use more than its share of the processes the server
418+// may run. Each worker writes its own entry, so the result does not depend on
419+// the scheduling; the first error stops the others and is returned. Entries
420+// beyond the bound are left without one and the tree says so.
421+//
422+//hexcheck:ignore suspicious-business-logic-in-adapter fills the tree with git output; the bound comes from the port
417 func (g *Git) fillLastCommits(ctx context.Context, dir string, tree *domain.Tree) error {423 func (g *Git) fillLastCommits(ctx context.Context, dir string, tree *domain.Tree) error {
418 n := len(tree.Entries)424 n := len(tree.Entries)
419 if n > domain.MaxLastCommitEntries {425 if n > domain.MaxLastCommitEntries {
420 n = domain.MaxLastCommitEntries426 n = domain.MaxLastCommitEntries
421 tree.LastCommitsOmitted = true427 tree.LastCommitsOmitted = true
422 }428 }
429+ workers := min(4, max(1, cap(g.exec)/2), n)
430+ if workers == 0 {
431+ return nil
432+ }
433+ ctx, cancel := context.WithCancel(ctx)
434+ defer cancel()
435+ var (
436+ wg sync.WaitGroup
437+ mu sync.Mutex
438+ firstErr error
439+ )
440+ next := make(chan int)
441+ for range workers {
442+ wg.Go(func() {
443+ for i := range next {
444+ c, err := g.lastCommit(ctx, dir, tree.CommitID, tree.Entries[i].Path)
445+ if err != nil {
446+ mu.Lock()
447+ if firstErr == nil {
448+ firstErr = err
449+ cancel()
450+ }
451+ mu.Unlock()
452+ continue
453+ }
454+ tree.Entries[i].LastCommit = &c
455+ }
456+ })
457+ }
458+feed:
423 for i := range n {459 for i := range n {
424- c, err := g.lastCommit(ctx, dir, tree.CommitID, tree.Entries[i].Path)460+ select {
425- if err != nil {461+ case next <- i:
426- return err462+ case <-ctx.Done():
463+ break feed
427 }464 }
428- tree.Entries[i].LastCommit = &c
429 }465 }
430- return nil466+ close(next)
467+ wg.Wait()
468+ if firstErr != nil {
469+ return firstErr
470+ }
471+ return ctx.Err()
431 }472 }
432 473
433 // lastCommit returns the newest commit reachable from head that changed path;474 // lastCommit returns the newest commit reachable from head that changed path;

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