detached 2a79463

feat(diff): share one window policy for MR, commit and comment reads

FileWindow gains Full, a single file read with no line cap and the byte
guard only. windowLimits is the one policy for MergeRequests.Get,
Browse.GetCommit and checkLine: windowed reads collapse generated files,
and a line of a file that can be loaded can be commented on.
commit
2a79463da35da5c4da914e77d4cce55cbb69ea6f
author
brice · · 2026-10-20 07:37 UTC
committer
brice · · 2026-10-20 09:12 UTC
parent
b1b81df
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +229 -37
M internal/adapters/rpc/git.go +1 -1
@@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
180180 if err != nil {
181181 return nil, connectError(ctx, err)
182182 }
183- detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
183+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184184 if err != nil {
185185 return nil, connectError(ctx, err)
186186 }
@@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
180 if err != nil {180 if err != nil {
181 return nil, connectError(ctx, err)181 return nil, connectError(ctx, err)
182 }182 }
183- detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)183+ detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{})
184 if err != nil {184 if err != nil {
185 return nil, connectError(ctx, err)185 return nil, connectError(ctx, err)
186 }186 }
M internal/adapters/rpc/git_test.go +1 -1
@@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
157157 t.Run("GetCommit not found", func(t *testing.T) {
158158 t.Parallel()
159159 browse := mocks.NewBrowseService(t)
160- browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
160+ browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162162 requireCode(t, connect.CodeNotFound, err)
163163 })
@@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
157 t.Run("GetCommit not found", func(t *testing.T) {157 t.Run("GetCommit not found", func(t *testing.T) {
158 t.Parallel()158 t.Parallel()
159 browse := mocks.NewBrowseService(t)159 browse := mocks.NewBrowseService(t)
160- browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()160+ browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))161 _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162 requireCode(t, connect.CodeNotFound, err)162 requireCode(t, connect.CodeNotFound, err)
163 })163 })
M internal/adapters/web/browse.go +2 -2
@@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st
4545 }
4646
4747 // getCommit reads a commit with its diff.
48-func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
48+func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
4949 r, err := domain.ParseRevision(rev)
5050 if err != nil {
5151 return domain.CommitDetail{}, err
5252 }
53- return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r)
53+ return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts)
5454 }
5555
5656 // openBlobAt opens the file path of the commit rev. The caller closes the
@@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st
45 }45 }
46 46
47 // getCommit reads a commit with its diff.47 // getCommit reads a commit with its diff.
48-func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) {48+func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
49 r, err := domain.ParseRevision(rev)49 r, err := domain.ParseRevision(rev)
50 if err != nil {50 if err != nil {
51 return domain.CommitDetail{}, err51 return domain.CommitDetail{}, err
52 }52 }
53- return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r)53+ return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts)
54 }54 }
55 55
56 // openBlobAt opens the file path of the commit rev. The caller closes the56 // openBlobAt opens the file path of the commit rev. The caller closes the
M internal/adapters/web/commit.go +2 -1
@@ -12,6 +12,7 @@ import (
1212 "github.com/a-h/templ"
1313
1414 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
1516 )
1617
1718 // bothViewsLines is the largest diff, in lines, that is sent in both the
@@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
105106 if err != nil {
106107 return nil, 0, err
107108 }
108- res, err := s.getCommit(ctx, d.repo.Name, sha)
109+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{})
109110 if err != nil {
110111 return nil, 0, err
111112 }
@@ -12,6 +12,7 @@ import (
12 "github.com/a-h/templ"12 "github.com/a-h/templ"
13 13
14 "bnema.dev/ermite/internal/domain"14 "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
15 )16 )
16 17
17 // bothViewsLines is the largest diff, in lines, that is sent in both the18 // bothViewsLines is the largest diff, in lines, that is sent in both the
@@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
105 if err != nil {106 if err != nil {
106 return nil, 0, err107 return nil, 0, err
107 }108 }
108- res, err := s.getCommit(ctx, d.repo.Name, sha)109+ res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{})
109 if err != nil {110 if err != nil {
110 return nil, 0, err111 return nil, 0, err
111 }112 }
M internal/adapters/web/compare.go +1 -1
@@ -76,7 +76,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
7676
7777 // The exact diff: one commit on top of the tip of the target.
7878 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())
79+ res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String(), ports.GetCommitOptions{})
8080 if err != nil {
8181 return nil, 0, err
8282 }
@@ -76,7 +76,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
76 76
77 // The exact diff: one commit on top of the tip of the target.77 // The exact diff: one commit on top of the tip of the target.
78 if exact && !behind && len(ahead) == 1 {78 if exact && !behind && len(ahead) == 1 {
79- res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())79+ res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String(), ports.GetCommitOptions{})
80 if err != nil {80 if err != nil {
81 return nil, 0, err81 return nil, 0, err
82 }82 }
M internal/ports/dto.go +20 -3
@@ -298,8 +298,10 @@ type GetMergeRequestOptions struct {
298298 SinceLastRead bool
299299 // Files, when set, lists every changed file, without the file-count
300300 // limit, and reads the hunks of the files in the window only, each within
301- // the per-file and the total limits of domain.DefaultDiffLimits. Unset,
302- // the diff is bounded by domain.DefaultDiffLimits (the API contract).
301+ // the per-file and the total limits of domain.DefaultDiffLimits; generated
302+ // files are listed collapsed (domain.OmitGenerated). With Full, the single
303+ // file is read without the line limits. Unset, the diff is bounded by
304+ // domain.DefaultDiffLimits and generated files are read (the API contract).
303305 Files *FileWindow
304306 // Base and Head, when not zero, are the diff the caller shows: Get
305307 // returns domain.ErrConflict when the request's diff base or head differ
@@ -310,7 +312,22 @@ type GetMergeRequestOptions struct {
310312 }
311313
312314 // FileWindow is the range [From, To) of files whose hunks are read.
313-type FileWindow struct{ From, To int }
315+//
316+// Full asks for the one file From read in full: no line limit applies, only
317+// the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a
318+// window with Full and any other To is domain.ErrInvalid.
319+type FileWindow struct {
320+ From, To int
321+ Full bool
322+}
323+
324+// GetCommitOptions say which diff BrowseService.GetCommit reads.
325+type GetCommitOptions struct {
326+ // Files, when set, reads the hunks of a window of the files, as
327+ // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is
328+ // bounded by domain.DefaultDiffLimits.
329+ Files *FileWindow
330+}
314331
315332 // AddCommentParams describes the comment to add.
316333 type AddCommentParams struct {
@@ -298,8 +298,10 @@ type GetMergeRequestOptions struct {
298 SinceLastRead bool298 SinceLastRead bool
299 // Files, when set, lists every changed file, without the file-count299 // Files, when set, lists every changed file, without the file-count
300 // limit, and reads the hunks of the files in the window only, each within300 // limit, and reads the hunks of the files in the window only, each within
301- // the per-file and the total limits of domain.DefaultDiffLimits. Unset,301+ // the per-file and the total limits of domain.DefaultDiffLimits; generated
302- // the diff is bounded by domain.DefaultDiffLimits (the API contract).302+ // files are listed collapsed (domain.OmitGenerated). With Full, the single
303+ // file is read without the line limits. Unset, the diff is bounded by
304+ // domain.DefaultDiffLimits and generated files are read (the API contract).
303 Files *FileWindow305 Files *FileWindow
304 // Base and Head, when not zero, are the diff the caller shows: Get306 // Base and Head, when not zero, are the diff the caller shows: Get
305 // returns domain.ErrConflict when the request's diff base or head differ307 // returns domain.ErrConflict when the request's diff base or head differ
@@ -310,7 +312,22 @@ type GetMergeRequestOptions struct {
310 }312 }
311 313
312 // FileWindow is the range [From, To) of files whose hunks are read.314 // FileWindow is the range [From, To) of files whose hunks are read.
313-type FileWindow struct{ From, To int }315+//
316+// Full asks for the one file From read in full: no line limit applies, only
317+// the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a
318+// window with Full and any other To is domain.ErrInvalid.
319+type FileWindow struct {
320+ From, To int
321+ Full bool
322+}
323+
324+// GetCommitOptions say which diff BrowseService.GetCommit reads.
325+type GetCommitOptions struct {
326+ // Files, when set, reads the hunks of a window of the files, as
327+ // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is
328+ // bounded by domain.DefaultDiffLimits.
329+ Files *FileWindow
330+}
314 331
315 // AddCommentParams describes the comment to add.332 // AddCommentParams describes the comment to add.
316 type AddCommentParams struct {333 type AddCommentParams struct {
M internal/ports/mocks/BrowseService.go +18 -12
@@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter {
5151 }
5252
5353 // GetCommit provides a mock function for the type BrowseService
54-func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
55- ret := _mock.Called(ctx, p, repo, rev)
54+func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
55+ ret := _mock.Called(ctx, p, repo, rev, opts)
5656
5757 if len(ret) == 0 {
5858 panic("no return value specified for GetCommit")
@@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r
6060
6161 var r0 domain.CommitDetail
6262 var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok {
64- return returnFunc(ctx, p, repo, rev)
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok {
64+ return returnFunc(ctx, p, repo, rev, opts)
6565 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok {
67- r0 = returnFunc(ctx, p, repo, rev)
66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok {
67+ r0 = returnFunc(ctx, p, repo, rev, opts)
6868 } else {
6969 r0 = ret.Get(0).(domain.CommitDetail)
7070 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {
72- r1 = returnFunc(ctx, p, repo, rev)
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok {
72+ r1 = returnFunc(ctx, p, repo, rev, opts)
7373 } else {
7474 r1 = ret.Error(1)
7575 }
@@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct {
8686 // - p domain.Principal
8787 // - repo domain.RepoName
8888 // - rev domain.Revision
89-func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call {
90- return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)}
89+// - opts ports.GetCommitOptions
90+func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call {
91+ return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)}
9192 }
9293
93-func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call {
94+func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call {
9495 _c.Call.Run(func(args mock.Arguments) {
9596 var arg0 context.Context
9697 if args[0] != nil {
@@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma
108109 if args[3] != nil {
109110 arg3 = args[3].(domain.Revision)
110111 }
112+ var arg4 ports.GetCommitOptions
113+ if args[4] != nil {
114+ arg4 = args[4].(ports.GetCommitOptions)
115+ }
111116 run(
112117 arg0,
113118 arg1,
114119 arg2,
115120 arg3,
121+ arg4,
116122 )
117123 })
118124 return _c
@@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail,
123129 return _c
124130 }
125131
126-func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
132+func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
127133 _c.Call.Return(run)
128134 return _c
129135 }
@@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter {
51 }51 }
52 52
53 // GetCommit provides a mock function for the type BrowseService53 // GetCommit provides a mock function for the type BrowseService
54-func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {54+func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
55- ret := _mock.Called(ctx, p, repo, rev)55+ ret := _mock.Called(ctx, p, repo, rev, opts)
56 56
57 if len(ret) == 0 {57 if len(ret) == 0 {
58 panic("no return value specified for GetCommit")58 panic("no return value specified for GetCommit")
@@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r
60 60
61 var r0 domain.CommitDetail61 var r0 domain.CommitDetail
62 var r1 error62 var r1 error
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok {63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok {
64- return returnFunc(ctx, p, repo, rev)64+ return returnFunc(ctx, p, repo, rev, opts)
65 }65 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok {66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok {
67- r0 = returnFunc(ctx, p, repo, rev)67+ r0 = returnFunc(ctx, p, repo, rev, opts)
68 } else {68 } else {
69 r0 = ret.Get(0).(domain.CommitDetail)69 r0 = ret.Get(0).(domain.CommitDetail)
70 }70 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok {71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok {
72- r1 = returnFunc(ctx, p, repo, rev)72+ r1 = returnFunc(ctx, p, repo, rev, opts)
73 } else {73 } else {
74 r1 = ret.Error(1)74 r1 = ret.Error(1)
75 }75 }
@@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct {
86 // - p domain.Principal86 // - p domain.Principal
87 // - repo domain.RepoName87 // - repo domain.RepoName
88 // - rev domain.Revision88 // - rev domain.Revision
89-func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call {89+// - opts ports.GetCommitOptions
90- return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)}90+func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call {
91+ return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)}
91 }92 }
92 93
93-func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call {94+func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call {
94 _c.Call.Run(func(args mock.Arguments) {95 _c.Call.Run(func(args mock.Arguments) {
95 var arg0 context.Context96 var arg0 context.Context
96 if args[0] != nil {97 if args[0] != nil {
@@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma
108 if args[3] != nil {109 if args[3] != nil {
109 arg3 = args[3].(domain.Revision)110 arg3 = args[3].(domain.Revision)
110 }111 }
112+ var arg4 ports.GetCommitOptions
113+ if args[4] != nil {
114+ arg4 = args[4].(ports.GetCommitOptions)
115+ }
111 run(116 run(
112 arg0,117 arg0,
113 arg1,118 arg1,
114 arg2,119 arg2,
115 arg3,120 arg3,
121+ arg4,
116 )122 )
117 })123 })
118 return _c124 return _c
@@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail,
123 return _c129 return _c
124 }130 }
125 131
126-func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {132+func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call {
127 _c.Call.Return(run)133 _c.Call.Return(run)
128 return _c134 return _c
129 }135 }
M internal/ports/services.go +1 -1
@@ -95,7 +95,7 @@ type BrowseService interface {
9595 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)
9696 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
9797 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
98- GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)
98+ GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error)
9999 // RepoStats counts the commits of the branch whose head is given, and
100100 // names the latest tag.
101101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
@@ -95,7 +95,7 @@ type BrowseService interface {
95 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)95 ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)
96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
98- GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)98+ GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error)
99 // RepoStats counts the commits of the branch whose head is given, and99 // RepoStats counts the commits of the branch whose head is given, and
100 // names the latest tag.100 // names the latest tag.
101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)101 RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
M internal/usecase/browse.go +8 -3
@@ -336,12 +336,17 @@ func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int,
336336 }
337337
338338 // GetCommit returns a commit with its statistics and its diff against the
339-// first parent, within the default diff limits.
340-func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
339+// first parent, within the default diff limits or, with opts.Files, the limits
340+// of that file window. An invalid window gives domain.ErrInvalid.
341+func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
342+ limits, err := windowLimits(opts.Files)
343+ if err != nil {
344+ return domain.CommitDetail{}, err
345+ }
341346 if _, err := b.authorize(ctx, p, repo); err != nil {
342347 return domain.CommitDetail{}, err
343348 }
344- d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
349+ d, err := b.reader.GetCommit(ctx, repo, rev, limits)
345350 if err != nil {
346351 return domain.CommitDetail{}, err
347352 }
@@ -336,12 +336,17 @@ func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int,
336 }336 }
337 337
338 // GetCommit returns a commit with its statistics and its diff against the338 // GetCommit returns a commit with its statistics and its diff against the
339-// first parent, within the default diff limits.339+// first parent, within the default diff limits or, with opts.Files, the limits
340-func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {340+// of that file window. An invalid window gives domain.ErrInvalid.
341+func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) {
342+ limits, err := windowLimits(opts.Files)
343+ if err != nil {
344+ return domain.CommitDetail{}, err
345+ }
341 if _, err := b.authorize(ctx, p, repo); err != nil {346 if _, err := b.authorize(ctx, p, repo); err != nil {
342 return domain.CommitDetail{}, err347 return domain.CommitDetail{}, err
343 }348 }
344- d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())349+ d, err := b.reader.GetCommit(ctx, repo, rev, limits)
345 if err != nil {350 if err != nil {
346 return domain.CommitDetail{}, err351 return domain.CommitDetail{}, err
347 }352 }
M internal/usecase/browse_test.go +4 -4
@@ -104,7 +104,7 @@ func browseOps() []browseOp {
104104 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
105105 }},
106106 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
107- _, err := b.GetCommit(ctx, p, repo, domain.Revision{})
107+ _, err := b.GetCommit(ctx, p, repo, domain.Revision{}, ports.GetCommitOptions{})
108108 return err
109109 }, func(r *mocks.RepoReader) {
110110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
@@ -629,7 +629,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
629629 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)
630630 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).
631631 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})
632- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
632+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
633633 require.NoError(t, err)
634634 assert.Equal(t, verified, d.Commit.Verification)
635635 assert.Equal(t, 2, d.Stats.FilesChanged)
@@ -642,7 +642,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
642642 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
643643 require.NoError(t, err)
644644 f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound)
645- _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
645+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
646646 assert.ErrorIs(t, err, domain.ErrNotFound)
647647 })
648648
@@ -657,7 +657,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
657657 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
658658 require.NoError(t, err)
659659 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)
660- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
660+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
661661 require.NoError(t, err)
662662 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)
663663 })
@@ -104,7 +104,7 @@ func browseOps() []browseOp {
104 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)104 r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil)
105 }},105 }},
106 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {106 {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
107- _, err := b.GetCommit(ctx, p, repo, domain.Revision{})107+ _, err := b.GetCommit(ctx, p, repo, domain.Revision{}, ports.GetCommitOptions{})
108 return err108 return err
109 }, func(r *mocks.RepoReader) {109 }, func(r *mocks.RepoReader) {
110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
@@ -629,7 +629,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
629 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)629 Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)
630 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).630 v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).
631 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})631 Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})
632- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})632+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
633 require.NoError(t, err)633 require.NoError(t, err)
634 assert.Equal(t, verified, d.Commit.Verification)634 assert.Equal(t, verified, d.Commit.Verification)
635 assert.Equal(t, 2, d.Stats.FilesChanged)635 assert.Equal(t, 2, d.Stats.FilesChanged)
@@ -642,7 +642,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
642 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})642 _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
643 require.NoError(t, err)643 require.NoError(t, err)
644 f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound)644 f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound)
645- _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})645+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
646 assert.ErrorIs(t, err, domain.ErrNotFound)646 assert.ErrorIs(t, err, domain.ErrNotFound)
647 })647 })
648 648
@@ -657,7 +657,7 @@ func TestBrowseVerifiesCommits(t *testing.T) {
657 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})657 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
658 require.NoError(t, err)658 require.NoError(t, err)
659 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)659 assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)
660- d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})660+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{})
661 require.NoError(t, err)661 require.NoError(t, err)
662 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)662 assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)
663 })663 })
M internal/usecase/export_test.go +3 -0
@@ -217,3 +217,6 @@ func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
217217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218218 return s.popDue(retryItem{key: repo, due: due})
219219 }
220+
221+// WindowLimits exposes windowLimits to the tests.
222+var WindowLimits = windowLimits
@@ -217,3 +217,6 @@ func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {217 func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218 return s.popDue(retryItem{key: repo, due: due})218 return s.popDue(retryItem{key: repo, due: due})
219 }219 }
220+
221+// WindowLimits exposes windowLimits to the tests.
222+var WindowLimits = windowLimits
M internal/usecase/mergerequests.go +27 -8
@@ -493,6 +493,26 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
493493 return repo, mr, nil
494494 }
495495
496+// windowLimits is the diff limits of a read: nil gives
497+// domain.DefaultDiffLimits (the API contract, generated files read); a window
498+// lists every file (MaxFiles 0), reads From..To and skips generated files; a
499+// Full window reads the one file From with domain.OneFileDiffLimits. An invalid
500+// window (negative, To < From, Full without To == From+1) is domain.ErrInvalid.
501+func windowLimits(w *ports.FileWindow) (domain.DiffLimits, error) {
502+ if w == nil {
503+ return domain.DefaultDiffLimits(), nil
504+ }
505+ if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) || (w.Full && w.To != w.From+1) {
506+ return domain.DiffLimits{}, fmt.Errorf("%w: file window [%d, %d) full=%t", domain.ErrInvalid, w.From, w.To, w.Full)
507+ }
508+ if w.Full {
509+ return domain.OneFileDiffLimits(w.From), nil
510+ }
511+ l := domain.DefaultDiffLimits()
512+ l.MaxFiles, l.From, l.To, l.SkipGenerated = 0, w.From, w.To, true
513+ return l, nil
514+}
515+
496516 // Get returns a merge request with its commits, its diff, its comments and the
497517 // read position. The diff compares the merge base of the target and the head
498518 // with the head ("target...head"), within domain.DefaultDiffLimits. With
@@ -507,12 +527,9 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
507527 // diff is no longer the one the caller shows (before any diff is read);
508528 // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.
509529 func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
510- limits := domain.DefaultDiffLimits()
511- if w := opts.Files; w != nil {
512- if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) {
513- return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To)
514- }
515- limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To
530+ limits, err := windowLimits(opts.Files)
531+ if err != nil {
532+ return ports.MergeRequestDetail{}, err
516533 }
517534 _, mr, err := m.get(ctx, p, name, number)
518535 if err != nil {
@@ -773,8 +790,10 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
773790 }
774791 idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path })
775792 if idx >= 0 {
776- one := domain.DefaultDiffLimits()
777- one.MaxFiles, one.From, one.To = 0, idx, idx+1
793+ one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true})
794+ if err != nil {
795+ return err
796+ }
778797 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
779798 return err
780799 }
@@ -493,6 +493,26 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
493 return repo, mr, nil493 return repo, mr, nil
494 }494 }
495 495
496+// windowLimits is the diff limits of a read: nil gives
497+// domain.DefaultDiffLimits (the API contract, generated files read); a window
498+// lists every file (MaxFiles 0), reads From..To and skips generated files; a
499+// Full window reads the one file From with domain.OneFileDiffLimits. An invalid
500+// window (negative, To < From, Full without To == From+1) is domain.ErrInvalid.
501+func windowLimits(w *ports.FileWindow) (domain.DiffLimits, error) {
502+ if w == nil {
503+ return domain.DefaultDiffLimits(), nil
504+ }
505+ if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) || (w.Full && w.To != w.From+1) {
506+ return domain.DiffLimits{}, fmt.Errorf("%w: file window [%d, %d) full=%t", domain.ErrInvalid, w.From, w.To, w.Full)
507+ }
508+ if w.Full {
509+ return domain.OneFileDiffLimits(w.From), nil
510+ }
511+ l := domain.DefaultDiffLimits()
512+ l.MaxFiles, l.From, l.To, l.SkipGenerated = 0, w.From, w.To, true
513+ return l, nil
514+}
515+
496 // Get returns a merge request with its commits, its diff, its comments and the516 // Get returns a merge request with its commits, its diff, its comments and the
497 // read position. The diff compares the merge base of the target and the head517 // read position. The diff compares the merge base of the target and the head
498 // with the head ("target...head"), within domain.DefaultDiffLimits. With518 // with the head ("target...head"), within domain.DefaultDiffLimits. With
@@ -507,12 +527,9 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain
507 // diff is no longer the one the caller shows (before any diff is read);527 // diff is no longer the one the caller shows (before any diff is read);
508 // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.528 // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.
509 func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {529 func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
510- limits := domain.DefaultDiffLimits()530+ limits, err := windowLimits(opts.Files)
511- if w := opts.Files; w != nil {531+ if err != nil {
512- if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) {532+ return ports.MergeRequestDetail{}, err
513- return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To)
514- }
515- limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To
516 }533 }
517 _, mr, err := m.get(ctx, p, name, number)534 _, mr, err := m.get(ctx, p, name, number)
518 if err != nil {535 if err != nil {
@@ -773,8 +790,10 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
773 }790 }
774 idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path })791 idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path })
775 if idx >= 0 {792 if idx >= 0 {
776- one := domain.DefaultDiffLimits()793+ one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true})
777- one.MaxFiles, one.From, one.To = 0, idx, idx+1794+ if err != nil {
795+ return err
796+ }
778 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {797 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
779 return err798 return err
780 }799 }
A internal/usecase/window_test.go +141 -0
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/usecase"
16+)
17+
18+func TestWindowLimits(t *testing.T) {
19+ def := domain.DefaultDiffLimits()
20+ win := def
21+ win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 2, 5, true
22+ open := win
23+ open.From, open.To = 7, 0
24+
25+ tests := []struct {
26+ name string
27+ w *ports.FileWindow
28+ want domain.DiffLimits
29+ bad bool
30+ }{
31+ {name: "nil is the default", want: def},
32+ {name: "a window", w: &ports.FileWindow{From: 2, To: 5}, want: win},
33+ {name: "an open window", w: &ports.FileWindow{From: 7}, want: open},
34+ {name: "full", w: &ports.FileWindow{From: 3, To: 4, Full: true}, want: domain.OneFileDiffLimits(3)},
35+ {name: "negative from", w: &ports.FileWindow{From: -1}, bad: true},
36+ {name: "negative to", w: &ports.FileWindow{To: -1}, bad: true},
37+ {name: "to before from", w: &ports.FileWindow{From: 5, To: 3}, bad: true},
38+ {name: "full without to", w: &ports.FileWindow{From: 3, Full: true}, bad: true},
39+ {name: "full of two files", w: &ports.FileWindow{From: 3, To: 5, Full: true}, bad: true},
40+ {name: "full of no file", w: &ports.FileWindow{From: 3, To: 3, Full: true}, bad: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ got, err := usecase.WindowLimits(tt.w)
45+ if tt.bad {
46+ require.ErrorIs(t, err, domain.ErrInvalid)
47+ return
48+ }
49+ require.NoError(t, err)
50+ assert.Equal(t, tt.want, got)
51+ })
52+ }
53+}
54+
55+func TestGetMergeRequestFullAndGenerated(t *testing.T) {
56+ r := newMRRig(t)
57+ r.seed()
58+ r.sh("checkout", "-q", "-B", "feat", "main")
59+ r.commit(author, "a.txt", "one\n", "a")
60+ r.commit(author, "go.sum", "x y z\n", "sum")
61+ r.push("feat")
62+ n := r.open("feat").Number
63+
64+ t.Run("full with a wider window is invalid", func(t *testing.T) {
65+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
66+ require.ErrorIs(t, err, domain.ErrInvalid)
67+ })
68+
69+ t.Run("a windowed read collapses a generated file, a nil read does not", func(t *testing.T) {
70+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 10}})
71+ require.NoError(t, err)
72+ byPath := map[string]domain.FileDiff{}
73+ for _, f := range d.Diff.Files {
74+ byPath[f.NewPath] = f
75+ }
76+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
77+ assert.Empty(t, byPath["go.sum"].Hunks)
78+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
79+ assert.NotEmpty(t, byPath["a.txt"].Hunks)
80+
81+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
82+ require.NoError(t, err)
83+ for _, f := range d.Diff.Files {
84+ assert.Equal(t, domain.OmitNone, f.Omitted, f.NewPath)
85+ assert.NotEmpty(t, f.Hunks, f.NewPath)
86+ }
87+ })
88+}
89+
90+func bigContent(n int) string {
91+ var b strings.Builder
92+ for i := range n {
93+ fmt.Fprintf(&b, "line %d\n", i)
94+ }
95+ return b.String()
96+}
97+
98+func TestCheckLineBeyondFileLimit(t *testing.T) {
99+ r := newMRRig(t)
100+ r.seed()
101+ r.sh("checkout", "-q", "-B", "feat", "main")
102+ r.commit(author, "big.txt", bigContent(3000), "big")
103+ r.push("feat")
104+ n := r.open("feat").Number
105+
106+ add := func(line int) error {
107+ _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "big.txt", Line: line, Side: domain.SideNew, Text: "c"})
108+ return err
109+ }
110+ require.NoError(t, add(1))
111+ require.NoError(t, add(2500))
112+ require.NoError(t, add(3000))
113+ require.ErrorIs(t, add(3001), domain.ErrInvalid)
114+}
115+
116+func TestBrowseGetCommitFullWindow(t *testing.T) {
117+ r := newMRRig(t)
118+ r.seed()
119+ r.commit(author, "a-big.txt", bigContent(3000), "big")
120+ id := r.head()
121+ r.push("main")
122+ b := usecase.NewBrowse(r.store, r.git)
123+ rev := domain.RevisionOf(id)
124+
125+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
126+ require.NoError(t, err)
127+ require.Len(t, d.Diff.Files, 1)
128+ assert.Equal(t, domain.OmitLarge, d.Diff.Files[0].Omitted)
129+ assert.Empty(t, d.Diff.Files[0].Hunks)
130+
131+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 1, Full: true}})
132+ require.NoError(t, err)
133+ require.Len(t, d.Diff.Files, 1)
134+ f := d.Diff.Files[0]
135+ assert.Equal(t, domain.OmitNone, f.Omitted)
136+ require.NotEmpty(t, f.Hunks)
137+ assert.Equal(t, 3000, f.Additions)
138+
139+ _, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
140+ require.ErrorIs(t, err, domain.ErrInvalid)
141+}
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/usecase"
16+)
17+
18+func TestWindowLimits(t *testing.T) {
19+ def := domain.DefaultDiffLimits()
20+ win := def
21+ win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 2, 5, true
22+ open := win
23+ open.From, open.To = 7, 0
24+
25+ tests := []struct {
26+ name string
27+ w *ports.FileWindow
28+ want domain.DiffLimits
29+ bad bool
30+ }{
31+ {name: "nil is the default", want: def},
32+ {name: "a window", w: &ports.FileWindow{From: 2, To: 5}, want: win},
33+ {name: "an open window", w: &ports.FileWindow{From: 7}, want: open},
34+ {name: "full", w: &ports.FileWindow{From: 3, To: 4, Full: true}, want: domain.OneFileDiffLimits(3)},
35+ {name: "negative from", w: &ports.FileWindow{From: -1}, bad: true},
36+ {name: "negative to", w: &ports.FileWindow{To: -1}, bad: true},
37+ {name: "to before from", w: &ports.FileWindow{From: 5, To: 3}, bad: true},
38+ {name: "full without to", w: &ports.FileWindow{From: 3, Full: true}, bad: true},
39+ {name: "full of two files", w: &ports.FileWindow{From: 3, To: 5, Full: true}, bad: true},
40+ {name: "full of no file", w: &ports.FileWindow{From: 3, To: 3, Full: true}, bad: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ got, err := usecase.WindowLimits(tt.w)
45+ if tt.bad {
46+ require.ErrorIs(t, err, domain.ErrInvalid)
47+ return
48+ }
49+ require.NoError(t, err)
50+ assert.Equal(t, tt.want, got)
51+ })
52+ }
53+}
54+
55+func TestGetMergeRequestFullAndGenerated(t *testing.T) {
56+ r := newMRRig(t)
57+ r.seed()
58+ r.sh("checkout", "-q", "-B", "feat", "main")
59+ r.commit(author, "a.txt", "one\n", "a")
60+ r.commit(author, "go.sum", "x y z\n", "sum")
61+ r.push("feat")
62+ n := r.open("feat").Number
63+
64+ t.Run("full with a wider window is invalid", func(t *testing.T) {
65+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
66+ require.ErrorIs(t, err, domain.ErrInvalid)
67+ })
68+
69+ t.Run("a windowed read collapses a generated file, a nil read does not", func(t *testing.T) {
70+ d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 10}})
71+ require.NoError(t, err)
72+ byPath := map[string]domain.FileDiff{}
73+ for _, f := range d.Diff.Files {
74+ byPath[f.NewPath] = f
75+ }
76+ assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted)
77+ assert.Empty(t, byPath["go.sum"].Hunks)
78+ assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted)
79+ assert.NotEmpty(t, byPath["a.txt"].Hunks)
80+
81+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
82+ require.NoError(t, err)
83+ for _, f := range d.Diff.Files {
84+ assert.Equal(t, domain.OmitNone, f.Omitted, f.NewPath)
85+ assert.NotEmpty(t, f.Hunks, f.NewPath)
86+ }
87+ })
88+}
89+
90+func bigContent(n int) string {
91+ var b strings.Builder
92+ for i := range n {
93+ fmt.Fprintf(&b, "line %d\n", i)
94+ }
95+ return b.String()
96+}
97+
98+func TestCheckLineBeyondFileLimit(t *testing.T) {
99+ r := newMRRig(t)
100+ r.seed()
101+ r.sh("checkout", "-q", "-B", "feat", "main")
102+ r.commit(author, "big.txt", bigContent(3000), "big")
103+ r.push("feat")
104+ n := r.open("feat").Number
105+
106+ add := func(line int) error {
107+ _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "big.txt", Line: line, Side: domain.SideNew, Text: "c"})
108+ return err
109+ }
110+ require.NoError(t, add(1))
111+ require.NoError(t, add(2500))
112+ require.NoError(t, add(3000))
113+ require.ErrorIs(t, add(3001), domain.ErrInvalid)
114+}
115+
116+func TestBrowseGetCommitFullWindow(t *testing.T) {
117+ r := newMRRig(t)
118+ r.seed()
119+ r.commit(author, "a-big.txt", bigContent(3000), "big")
120+ id := r.head()
121+ r.push("main")
122+ b := usecase.NewBrowse(r.store, r.git)
123+ rev := domain.RevisionOf(id)
124+
125+ d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{})
126+ require.NoError(t, err)
127+ require.Len(t, d.Diff.Files, 1)
128+ assert.Equal(t, domain.OmitLarge, d.Diff.Files[0].Omitted)
129+ assert.Empty(t, d.Diff.Files[0].Hunks)
130+
131+ d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 1, Full: true}})
132+ require.NoError(t, err)
133+ require.Len(t, d.Diff.Files, 1)
134+ f := d.Diff.Files[0]
135+ assert.Equal(t, domain.OmitNone, f.Omitted)
136+ require.NotEmpty(t, f.Hunks)
137+ assert.Equal(t, 3000, f.Additions)
138+
139+ _, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}})
140+ require.ErrorIs(t, err, domain.ErrInvalid)
141+}

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 44

↑↓ moveenter openesc close