feat(rpc): report truncated trees and last commits, bound GetTree
GetTree sets last_commits_truncated from Tree.LastCommitsOmitted and truncated when the directory has more than domain.MaxTreeEntries (5,000) entries. The skip of a commit page token is tested at its cap.
- commit
- 15cb272c6b5417db78698354ba7462315a0fabba
- author
- brice · · 2026-10-10 11:14 UTC
- parent
- d97ab57
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +70 -2
M internal/adapters/rpc/git.go +3 -0
| @@ -75,6 +75,9 @@ func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTre | |||
| 75 | 75 | CommitId: tree.CommitID.String(), | |
| 76 | 76 | TreeId: tree.ID.String(), | |
| 77 | 77 | Entries: make([]*pb.TreeEntry, 0, len(tree.Entries)), | |
| 78 | + | ||
| 79 | + | LastCommitsTruncated: tree.LastCommitsOmitted, | |
| 80 | + | Truncated: tree.Truncated, | |
| 78 | 81 | } | |
| 79 | 82 | for _, e := range tree.Entries { | |
| 80 | 83 | out.Entries = append(out.Entries, treeEntryToPB(e)) | |
| @@ -75,6 +75,9 @@ func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTre | |||||
| 75 | CommitId: tree.CommitID.String(), | 75 | CommitId: tree.CommitID.String(), | ||
| 76 | TreeId: tree.ID.String(), | 76 | TreeId: tree.ID.String(), | ||
| 77 | Entries: make([]*pb.TreeEntry, 0, len(tree.Entries)), | 77 | Entries: make([]*pb.TreeEntry, 0, len(tree.Entries)), | ||
| 78 | + | ||||
| 79 | + | LastCommitsTruncated: tree.LastCommitsOmitted, | |||
| 80 | + | Truncated: tree.Truncated, | |||
| 78 | } | 81 | } | ||
| 79 | for _, e := range tree.Entries { | 82 | for _, e := range tree.Entries { | ||
| 80 | out.Entries = append(out.Entries, treeEntryToPB(e)) | 83 | out.Entries = append(out.Entries, treeEntryToPB(e)) | ||
M internal/app/browse_test.go +26 -0
| @@ -4,8 +4,10 @@ package app_test | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "encoding/base64" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "io" | |
| 10 | + | "strconv" | |
| 9 | 11 | "strings" | |
| 10 | 12 | "testing" | |
| 11 | 13 | ||
| @@ -324,6 +326,30 @@ func TestListCommitsPaging(t *testing.T) { | |||
| 324 | 326 | } | |
| 325 | 327 | }) | |
| 326 | 328 | ||
| 329 | + | t.Run("a token cannot skip deeper than the cap", func(t *testing.T) { | |
| 330 | + | f := newBrowseFixture(t) | |
| 331 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe() | |
| 332 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | |
| 333 | + | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once() | |
| 334 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p}) | |
| 335 | + | require.NoError(t, err) | |
| 336 | + | raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken) | |
| 337 | + | require.NoError(t, err) | |
| 338 | + | parts := strings.Split(string(raw), ":") // c1:<head>:<skip>:<path digest> | |
| 339 | + | require.Len(t, parts, 4) | |
| 340 | + | withSkip := func(n int) string { | |
| 341 | + | parts[2] = strconv.Itoa(n) | |
| 342 | + | return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":"))) | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == app.MaxCommitSkip })). | |
| 346 | + | Return(ports.CommitPage{Head: oid("a")}, nil).Once() | |
| 347 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip)}) | |
| 348 | + | require.NoError(t, err, "the cap itself is allowed") | |
| 349 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip + 1)}) | |
| 350 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | |
| 351 | + | }) | |
| 352 | + | ||
| 327 | 353 | t.Run("page size", func(t *testing.T) { | |
| 328 | 354 | f := newBrowseFixture(t) | |
| 329 | 355 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2) | |
| @@ -4,8 +4,10 @@ package app_test | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "encoding/base64" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "io" | 9 | "io" | ||
| 10 | + | "strconv" | |||
| 9 | "strings" | 11 | "strings" | ||
| 10 | "testing" | 12 | "testing" | ||
| 11 | 13 | ||||
| @@ -324,6 +326,30 @@ func TestListCommitsPaging(t *testing.T) { | |||||
| 324 | } | 326 | } | ||
| 325 | }) | 327 | }) | ||
| 326 | 328 | ||||
| 329 | + | t.Run("a token cannot skip deeper than the cap", func(t *testing.T) { | |||
| 330 | + | f := newBrowseFixture(t) | |||
| 331 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe() | |||
| 332 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | |||
| 333 | + | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once() | |||
| 334 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p}) | |||
| 335 | + | require.NoError(t, err) | |||
| 336 | + | raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken) | |||
| 337 | + | require.NoError(t, err) | |||
| 338 | + | parts := strings.Split(string(raw), ":") // c1:<head>:<skip>:<path digest> | |||
| 339 | + | require.Len(t, parts, 4) | |||
| 340 | + | withSkip := func(n int) string { | |||
| 341 | + | parts[2] = strconv.Itoa(n) | |||
| 342 | + | return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":"))) | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == app.MaxCommitSkip })). | |||
| 346 | + | Return(ports.CommitPage{Head: oid("a")}, nil).Once() | |||
| 347 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip)}) | |||
| 348 | + | require.NoError(t, err, "the cap itself is allowed") | |||
| 349 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip + 1)}) | |||
| 350 | + | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | |||
| 351 | + | }) | |||
| 352 | + | ||||
| 327 | t.Run("page size", func(t *testing.T) { | 353 | t.Run("page size", func(t *testing.T) { | ||
| 328 | f := newBrowseFixture(t) | 354 | f := newBrowseFixture(t) | ||
| 329 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2) | 355 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2) | ||
M internal/app/events_test.go +3 -1
| @@ -474,7 +474,7 @@ func TestWatchEndsWhenTheTokenExpires(t *testing.T) { | |||
| 474 | 474 | // The rig's clock stands at now: this token expired at now. | |
| 475 | 475 | p := tokenWith("tok", now) | |
| 476 | 476 | w := r.watch(context.Background(), p, app.WatchParams{}) | |
| 477 | - | r.push(pub, 1) | |
| 477 | + | r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated | |
| 478 | 478 | select { | |
| 479 | 479 | case err := <-w.done: | |
| 480 | 480 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| @@ -502,6 +502,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||
| 502 | 502 | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour}) | |
| 503 | 503 | pub := r.repo("pub", domain.VisibilityPublic) | |
| 504 | 504 | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | |
| 505 | + | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 505 | 506 | for i := range 20 { | |
| 506 | 507 | r.push(pub, i) | |
| 507 | 508 | } | |
| @@ -516,6 +517,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||
| 516 | 517 | pub := r.repo("pub", domain.VisibilityPublic) | |
| 517 | 518 | a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{}) | |
| 518 | 519 | b := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | |
| 520 | + | require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond) | |
| 519 | 521 | r.push(pub, 1) | |
| 520 | 522 | a.next(t) | |
| 521 | 523 | b.next(t) | |
| @@ -474,7 +474,7 @@ func TestWatchEndsWhenTheTokenExpires(t *testing.T) { | |||||
| 474 | // The rig's clock stands at now: this token expired at now. | 474 | // The rig's clock stands at now: this token expired at now. | ||
| 475 | p := tokenWith("tok", now) | 475 | p := tokenWith("tok", now) | ||
| 476 | w := r.watch(context.Background(), p, app.WatchParams{}) | 476 | w := r.watch(context.Background(), p, app.WatchParams{}) | ||
| 477 | - | r.push(pub, 1) | 477 | + | r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated |
| 478 | select { | 478 | select { | ||
| 479 | case err := <-w.done: | 479 | case err := <-w.done: | ||
| 480 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | 480 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | ||
| @@ -502,6 +502,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||||
| 502 | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour}) | 502 | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour}) | ||
| 503 | pub := r.repo("pub", domain.VisibilityPublic) | 503 | pub := r.repo("pub", domain.VisibilityPublic) | ||
| 504 | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | 504 | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | ||
| 505 | + | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |||
| 505 | for i := range 20 { | 506 | for i := range 20 { | ||
| 506 | r.push(pub, i) | 507 | r.push(pub, i) | ||
| 507 | } | 508 | } | ||
| @@ -516,6 +517,7 @@ func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |||||
| 516 | pub := r.repo("pub", domain.VisibilityPublic) | 517 | pub := r.repo("pub", domain.VisibilityPublic) | ||
| 517 | a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{}) | 518 | a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{}) | ||
| 518 | b := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | 519 | b := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | ||
| 520 | + | require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond) | |||
| 519 | r.push(pub, 1) | 521 | r.push(pub, 1) | ||
| 520 | a.next(t) | 522 | a.next(t) | ||
| 521 | b.next(t) | 523 | b.next(t) | ||
M internal/app/repos_write_test.go +30 -0
| @@ -61,6 +61,36 @@ func TestUpdate(t *testing.T) { | |||
| 61 | 61 | assert.Equal(t, priv, got) | |
| 62 | 62 | }) | |
| 63 | 63 | ||
| 64 | + | t.Run("metadata is written before HEAD moves, and the event follows both", func(t *testing.T) { | |
| 65 | + | f := newFixture(t) | |
| 66 | + | var order []string | |
| 67 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 68 | + | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |
| 69 | + | f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error { | |
| 70 | + | order = append(order, "meta") | |
| 71 | + | return nil | |
| 72 | + | }) | |
| 73 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error { | |
| 74 | + | order = append(order, "head") | |
| 75 | + | return nil | |
| 76 | + | }) | |
| 77 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Run(func(context.Context, domain.Event) { order = append(order, "event") }).Return().Once() | |
| 78 | + | _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("dev")}) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Equal(t, []string{"meta", "head", "event"}, order) | |
| 81 | + | }) | |
| 82 | + | ||
| 83 | + | t.Run("a HEAD that cannot move fails the call and publishes nothing", func(t *testing.T) { | |
| 84 | + | f := newFixture(t) | |
| 85 | + | boom := errors.New("boom") | |
| 86 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 87 | + | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |
| 88 | + | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | |
| 89 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | |
| 90 | + | _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("dev")}) | |
| 91 | + | assert.ErrorIs(t, err, boom) | |
| 92 | + | }) | |
| 93 | + | ||
| 64 | 94 | t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) { | |
| 65 | 95 | f := newFixture(t) | |
| 66 | 96 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| @@ -61,6 +61,36 @@ func TestUpdate(t *testing.T) { | |||||
| 61 | assert.Equal(t, priv, got) | 61 | assert.Equal(t, priv, got) | ||
| 62 | }) | 62 | }) | ||
| 63 | 63 | ||||
| 64 | + | t.Run("metadata is written before HEAD moves, and the event follows both", func(t *testing.T) { | |||
| 65 | + | f := newFixture(t) | |||
| 66 | + | var order []string | |||
| 67 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |||
| 68 | + | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |||
| 69 | + | f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error { | |||
| 70 | + | order = append(order, "meta") | |||
| 71 | + | return nil | |||
| 72 | + | }) | |||
| 73 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error { | |||
| 74 | + | order = append(order, "head") | |||
| 75 | + | return nil | |||
| 76 | + | }) | |||
| 77 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Run(func(context.Context, domain.Event) { order = append(order, "event") }).Return().Once() | |||
| 78 | + | _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("dev")}) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | assert.Equal(t, []string{"meta", "head", "event"}, order) | |||
| 81 | + | }) | |||
| 82 | + | ||||
| 83 | + | t.Run("a HEAD that cannot move fails the call and publishes nothing", func(t *testing.T) { | |||
| 84 | + | f := newFixture(t) | |||
| 85 | + | boom := errors.New("boom") | |||
| 86 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |||
| 87 | + | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |||
| 88 | + | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | |||
| 89 | + | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | |||
| 90 | + | _, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("dev")}) | |||
| 91 | + | assert.ErrorIs(t, err, boom) | |||
| 92 | + | }) | |||
| 93 | + | ||||
| 64 | t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) { | 94 | t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) { | ||
| 65 | f := newFixture(t) | 95 | f := newFixture(t) | ||
| 66 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | 96 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | ||
M internal/domain/git.go +6 -0
| @@ -316,8 +316,14 @@ type Tree struct { | |||
| 316 | 316 | // LastCommitsOmitted is true when last commits were asked for and the | |
| 317 | 317 | // reader gave only the first entries (MaxLastCommitEntries) one. | |
| 318 | 318 | LastCommitsOmitted bool | |
| 319 | + | // Truncated is true when the directory has more than MaxTreeEntries | |
| 320 | + | // entries: Entries holds the first ones in the documented order. | |
| 321 | + | Truncated bool | |
| 319 | 322 | } | |
| 320 | 323 | ||
| 324 | + | // MaxTreeEntries bounds the entries of a listed directory. | |
| 325 | + | const MaxTreeEntries = 5000 | |
| 326 | + | ||
| 321 | 327 | // MaxLastCommitEntries bounds the entries of one directory that get a last | |
| 322 | 328 | // commit: each costs a history lookup. | |
| 323 | 329 | const MaxLastCommitEntries = 200 | |
| @@ -316,8 +316,14 @@ type Tree struct { | |||||
| 316 | // LastCommitsOmitted is true when last commits were asked for and the | 316 | // LastCommitsOmitted is true when last commits were asked for and the | ||
| 317 | // reader gave only the first entries (MaxLastCommitEntries) one. | 317 | // reader gave only the first entries (MaxLastCommitEntries) one. | ||
| 318 | LastCommitsOmitted bool | 318 | LastCommitsOmitted bool | ||
| 319 | + | // Truncated is true when the directory has more than MaxTreeEntries | |||
| 320 | + | // entries: Entries holds the first ones in the documented order. | |||
| 321 | + | Truncated bool | |||
| 319 | } | 322 | } | ||
| 320 | 323 | ||||
| 324 | + | // MaxTreeEntries bounds the entries of a listed directory. | |||
| 325 | + | const MaxTreeEntries = 5000 | |||
| 326 | + | ||||
| 321 | // MaxLastCommitEntries bounds the entries of one directory that get a last | 327 | // MaxLastCommitEntries bounds the entries of one directory that get a last | ||
| 322 | // commit: each costs a history lookup. | 328 | // commit: each costs a history lookup. | ||
| 323 | const MaxLastCommitEntries = 200 | 329 | const MaxLastCommitEntries = 200 | ||
M internal/ports/ports.go +2 -1
| @@ -127,7 +127,8 @@ type RepoReader interface { | |||
| 127 | 127 | // to MinAbbrevLen or more). It returns domain.ErrNotFound if nothing | |
| 128 | 128 | // matches. | |
| 129 | 129 | ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) | |
| 130 | - | // GetTree lists the directory at path. With includeLastCommit, the first | |
| 130 | + | // GetTree lists the directory at path, at most domain.MaxTreeEntries | |
| 131 | + | // entries (Tree.Truncated tells). With includeLastCommit, the first | |
| 131 | 132 | // domain.MaxLastCommitEntries entries get their last commit. It returns | |
| 132 | 133 | // domain.ErrNotDirectory if path names something else. | |
| 133 | 134 | GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) | |
| @@ -127,7 +127,8 @@ type RepoReader interface { | |||||
| 127 | // to MinAbbrevLen or more). It returns domain.ErrNotFound if nothing | 127 | // to MinAbbrevLen or more). It returns domain.ErrNotFound if nothing | ||
| 128 | // matches. | 128 | // matches. | ||
| 129 | ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) | 129 | ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) | ||
| 130 | - | // GetTree lists the directory at path. With includeLastCommit, the first | 130 | + | // GetTree lists the directory at path, at most domain.MaxTreeEntries |
| 131 | + | // entries (Tree.Truncated tells). With includeLastCommit, the first | |||
| 131 | // domain.MaxLastCommitEntries entries get their last commit. It returns | 132 | // domain.MaxLastCommitEntries entries get their last commit. It returns | ||
| 132 | // domain.ErrNotDirectory if path names something else. | 133 | // domain.ErrNotDirectory if path names something else. | ||
| 133 | GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) | 134 | GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) | ||