detached 0424a8a

refactor(domain): own commit-object and diff read budgets

CommitObjectBudget, ValidateCommitObjects and DiffBudget decide eligibility, deduplication, windows and byte/line limits; gitcli and ermitegit only stream.
commit
0424a8adfd6f2e58982ddc6f693210fd60dae845
author
brice · · 2026-10-19 19:28 UTC
committer
brice · · 2026-10-19 20:01 UTC
parent
ac21956
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +264 -61
M internal/adapters/ermitegit/commitobjects.go +12 -11
@@ -22,10 +22,10 @@ var _ ports.CommitObjectReader = (*Adapter)(nil)
2222 // always serves it, and the wrapped reader only the repositories the library
2323 // cannot read.
2424 //
25-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for commit eligibility and byte budgets; pending move to domain/usecase
25+//hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects
2626 func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
27- if len(ids) > ports.MaxCommitLimit {
28- return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)
27+ if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil {
28+ return nil, err
2929 }
3030 cli := func() (map[domain.ObjectID][]byte, error) {
3131 r, ok := a.cli.(ports.CommitObjectReader)
@@ -36,12 +36,12 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
3636 }
3737 return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) {
3838 out := make(map[domain.ObjectID][]byte, len(ids))
39- var total int64
39+ var budget domain.CommitObjectBudget
4040 for _, id := range ids {
4141 if err := ctx.Err(); err != nil {
4242 return nil, err
4343 }
44- if _, done := out[id]; done {
44+ if budget.Seen(id) {
4545 continue
4646 }
4747 gid, err := git.ParseID(id.String())
@@ -55,12 +55,13 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
5555 if err != nil {
5656 return nil, err
5757 }
58- if typ != git.TypeCommit || size > domain.MaxCommitObjectBytes {
59- continue
60- }
61- if total+size > domain.MaxCommitObjectsBytes {
58+ take, stop := budget.Admit(id, typ == git.TypeCommit, size)
59+ if stop {
6260 break
6361 }
62+ if !take {
63+ continue
64+ }
6465 typ, data, err := r.ReadObject(gid, nil)
6566 if errors.Is(err, git.ErrNotFound) {
6667 continue // removed meanwhile
@@ -68,9 +69,9 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
6869 if err != nil {
6970 return nil, err
7071 }
71- if typ == git.TypeCommit && len(data) <= domain.MaxCommitObjectBytes && total+int64(len(data)) <= domain.MaxCommitObjectsBytes {
72+ if take, _ := budget.Admit(id, typ == git.TypeCommit, int64(len(data))); take {
7273 out[id] = data
73- total += int64(len(data))
74+ budget.Charge(id, int64(len(data)))
7475 }
7576 }
7677 return out, nil
@@ -22,10 +22,10 @@ var _ ports.CommitObjectReader = (*Adapter)(nil)
22 // always serves it, and the wrapped reader only the repositories the library22 // always serves it, and the wrapped reader only the repositories the library
23 // cannot read.23 // cannot read.
24 //24 //
25-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for commit eligibility and byte budgets; pending move to domain/usecase25+//hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects
26 func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {26 func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
27- if len(ids) > ports.MaxCommitLimit {27+ if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil {
28- return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)28+ return nil, err
29 }29 }
30 cli := func() (map[domain.ObjectID][]byte, error) {30 cli := func() (map[domain.ObjectID][]byte, error) {
31 r, ok := a.cli.(ports.CommitObjectReader)31 r, ok := a.cli.(ports.CommitObjectReader)
@@ -36,12 +36,12 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
36 }36 }
37 return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) {37 return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) {
38 out := make(map[domain.ObjectID][]byte, len(ids))38 out := make(map[domain.ObjectID][]byte, len(ids))
39- var total int6439+ var budget domain.CommitObjectBudget
40 for _, id := range ids {40 for _, id := range ids {
41 if err := ctx.Err(); err != nil {41 if err := ctx.Err(); err != nil {
42 return nil, err42 return nil, err
43 }43 }
44- if _, done := out[id]; done {44+ if budget.Seen(id) {
45 continue45 continue
46 }46 }
47 gid, err := git.ParseID(id.String())47 gid, err := git.ParseID(id.String())
@@ -55,12 +55,13 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
55 if err != nil {55 if err != nil {
56 return nil, err56 return nil, err
57 }57 }
58- if typ != git.TypeCommit || size > domain.MaxCommitObjectBytes {58+ take, stop := budget.Admit(id, typ == git.TypeCommit, size)
59- continue59+ if stop {
60- }
61- if total+size > domain.MaxCommitObjectsBytes {
62 break60 break
63 }61 }
62+ if !take {
63+ continue
64+ }
64 typ, data, err := r.ReadObject(gid, nil)65 typ, data, err := r.ReadObject(gid, nil)
65 if errors.Is(err, git.ErrNotFound) {66 if errors.Is(err, git.ErrNotFound) {
66 continue // removed meanwhile67 continue // removed meanwhile
@@ -68,9 +69,9 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
68 if err != nil {69 if err != nil {
69 return nil, err70 return nil, err
70 }71 }
71- if typ == git.TypeCommit && len(data) <= domain.MaxCommitObjectBytes && total+int64(len(data)) <= domain.MaxCommitObjectsBytes {72+ if take, _ := budget.Admit(id, typ == git.TypeCommit, int64(len(data))); take {
72 out[id] = data73 out[id] = data
73- total += int64(len(data))74+ budget.Charge(id, int64(len(data)))
74 }75 }
75 }76 }
76 return out, nil77 return out, nil
M internal/adapters/gitcli/commitobjects.go +11 -15
@@ -19,15 +19,10 @@ var _ ports.CommitObjectReader = (*Git)(nil)
1919 // that would take the total over domain.MaxCommitObjectsBytes (known from
2020 // `info`, before any content is read, so the stream stays consistent).
2121 //
22-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for commit eligibility and byte budgets; pending move to domain/usecase
22+//hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects
2323 func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
24- if len(ids) > ports.MaxCommitLimit {
25- return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)
26- }
27- for _, id := range ids {
28- if id.IsZero() {
29- return nil, fmt.Errorf("%w: empty commit id", domain.ErrInvalidObjectID)
30- }
24+ if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil {
25+ return nil, err
3126 }
3227 dir, err := g.repoDir(repo)
3328 if err != nil {
@@ -36,12 +31,12 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [
3631 out := make(map[domain.ObjectID][]byte, len(ids))
3732 err = g.cat.do(ctx, dir, func(c *catConn) error {
3833 clear(out) // a retry on a fresh process starts over
39- var total int64
34+ var budget domain.CommitObjectBudget
4035 for _, id := range ids {
4136 if err := ctx.Err(); err != nil {
4237 return err
4338 }
44- if _, done := out[id]; done {
39+ if budget.Seen(id) {
4540 continue
4641 }
4742 h, err := c.info(id.String())
@@ -51,18 +46,19 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [
5146 if err != nil {
5247 return err
5348 }
54- if h.Type != "commit" || h.Size > domain.MaxCommitObjectBytes {
55- continue
56- }
57- if total+h.Size > domain.MaxCommitObjectsBytes {
49+ take, stop := budget.Admit(id, h.Type == "commit", h.Size)
50+ if stop {
5851 break
5952 }
53+ if !take {
54+ continue
55+ }
6056 _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes)
6157 if err != nil {
6258 return err
6359 }
6460 out[id] = data
65- total += int64(len(data))
61+ budget.Charge(id, int64(len(data)))
6662 }
6763 return nil
6864 })
@@ -19,15 +19,10 @@ var _ ports.CommitObjectReader = (*Git)(nil)
19 // that would take the total over domain.MaxCommitObjectsBytes (known from19 // that would take the total over domain.MaxCommitObjectsBytes (known from
20 // `info`, before any content is read, so the stream stays consistent).20 // `info`, before any content is read, so the stream stays consistent).
21 //21 //
22-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for commit eligibility and byte budgets; pending move to domain/usecase22+//hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects
23 func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {23 func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
24- if len(ids) > ports.MaxCommitLimit {24+ if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil {
25- return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)25+ return nil, err
26- }
27- for _, id := range ids {
28- if id.IsZero() {
29- return nil, fmt.Errorf("%w: empty commit id", domain.ErrInvalidObjectID)
30- }
31 }26 }
32 dir, err := g.repoDir(repo)27 dir, err := g.repoDir(repo)
33 if err != nil {28 if err != nil {
@@ -36,12 +31,12 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [
36 out := make(map[domain.ObjectID][]byte, len(ids))31 out := make(map[domain.ObjectID][]byte, len(ids))
37 err = g.cat.do(ctx, dir, func(c *catConn) error {32 err = g.cat.do(ctx, dir, func(c *catConn) error {
38 clear(out) // a retry on a fresh process starts over33 clear(out) // a retry on a fresh process starts over
39- var total int6434+ var budget domain.CommitObjectBudget
40 for _, id := range ids {35 for _, id := range ids {
41 if err := ctx.Err(); err != nil {36 if err := ctx.Err(); err != nil {
42 return err37 return err
43 }38 }
44- if _, done := out[id]; done {39+ if budget.Seen(id) {
45 continue40 continue
46 }41 }
47 h, err := c.info(id.String())42 h, err := c.info(id.String())
@@ -51,18 +46,19 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [
51 if err != nil {46 if err != nil {
52 return err47 return err
53 }48 }
54- if h.Type != "commit" || h.Size > domain.MaxCommitObjectBytes {49+ take, stop := budget.Admit(id, h.Type == "commit", h.Size)
55- continue50+ if stop {
56- }
57- if total+h.Size > domain.MaxCommitObjectsBytes {
58 break51 break
59 }52 }
53+ if !take {
54+ continue
55+ }
60 _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes)56 _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes)
61 if err != nil {57 if err != nil {
62 return err58 return err
63 }59 }
64 out[id] = data60 out[id] = data
65- total += int64(len(data))61+ budget.Charge(id, int64(len(data)))
66 }62 }
67 return nil63 return nil
68 })64 })
M internal/adapters/gitcli/diff.go +15 -35
@@ -89,7 +89,7 @@ type rawEntry struct {
8989
9090 // readDiff parses the output of diff-tree --raw --numstat -p.
9191 //
92-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for diff windowing and truncation budgets; pending move to domain/usecase
92+//hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process
9393 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
9494 entries, err := readRaw(r)
9595 if err != nil {
@@ -105,23 +105,17 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
105105 stats.Deletions += entries[i].del
106106 }
107107 var diff domain.Diff
108- if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles {
109- entries = entries[:limits.MaxFiles]
110- diff.Truncated = true
111- }
112- // Only the files of the window [from, to) get their hunks; the others are
113- // listed as they are, neither truncated nor counted in the budgets.
114- from, to := max(limits.From, 0), len(entries)
115- if limits.To > 0 {
116- to = min(limits.To, len(entries))
117- }
108+ budget := domain.NewDiffBudget(limits)
109+ kept, from, to, truncated := budget.Window(len(entries))
110+ entries = entries[:kept]
111+ diff.Truncated = truncated
118112
119113 // The patch holds one section per file, two for a type change, in the
120114 // order of the entries. Entries without lines to show (binary, pure
121115 // rename, mode change) have a section without hunks.
122116 lastRead := -1
123117 for i := from; i < to; i++ {
124- if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) {
118+ if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {
125119 lastRead = i
126120 }
127121 }
@@ -131,8 +125,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
131125 _, _ = r.Discard(1)
132126 }
133127 }
134- pr := &patchReader{lr: newLineReader(r, maxDiffLine), bytesLeft: limits.MaxTotalBytes}
135- budget := limits.MaxTotalLines
128+ pr := &patchReader{lr: newLineReader(r, maxDiffLine), budget: budget}
136129 diff.Files = make([]domain.FileDiff, 0, len(entries))
137130 for i := range entries {
138131 e := &entries[i]
@@ -142,21 +135,19 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
142135 }
143136 inWindow := i >= from && i < to
144137 switch {
145- case !inWindow, !changedLines(e):
138+ case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):
146139 // Nothing to show.
147- case !wantsHunks(e, limits, budget, pr.bytesLeft):
140+ case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):
148141 e.file.Truncated = true
149142 default:
150- bytesBefore := pr.bytesLeft
151- hunks, lines, truncated, err := pr.file(sections, min(limits.MaxFileLines, budget))
143+ before := pr.budget
144+ hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())
152145 if err != nil {
153146 return domain.Diff{}, domain.DiffStats{}, err
154147 }
155148 e.file.Hunks, e.file.Truncated = hunks, truncated
156149 if truncated {
157- pr.bytesLeft = bytesBefore // nothing of this file is kept
158- } else {
159- budget -= lines
150+ pr.budget = before // nothing of this file is kept
160151 }
161152 sections = 0
162153 }
@@ -175,17 +166,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
175166 return diff, stats, nil
176167 }
177168
178-// changedLines reports whether the file has text lines in its patch.
179-func changedLines(e *rawEntry) bool { return !e.file.Binary && e.added+e.del > 0 }
180-
181-// wantsHunks reports whether the lines of e can still be shown: the changed
182-// lines alone, a lower bound of the hunk lines, must fit the per-file limit
183-// and what remains of the total, and some text bytes must remain.
184-func wantsHunks(e *rawEntry, limits domain.DiffLimits, linesLeft, bytesLeft int) bool {
185- n := e.added + e.del
186- return changedLines(e) && n <= limits.MaxFileLines && n <= linesLeft && bytesLeft > 0
187-}
188-
189169 // readRaw reads the `--raw -z` records, which start with ':'.
190170 func readRaw(r *bufio.Reader) ([]rawEntry, error) {
191171 var entries []rawEntry
@@ -333,7 +313,7 @@ type patchReader struct {
333313 eof bool
334314 cut bool // the last line read was cut
335315
336- bytesLeft int // text bytes still allowed in the diff
316+ budget domain.DiffBudget
337317 }
338318
339319 const sectionMarker = "diff --git "
@@ -480,8 +460,8 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
480460 continue
481461 }
482462 lines++
483- p.bytesLeft -= len(text)
484- if lines > max || p.bytesLeft < 0 {
463+ fits := p.budget.ChargeLine(len(text))
464+ if lines > max || !fits {
485465 return nil, lines, true, nil
486466 }
487467 cur.Lines = append(cur.Lines, l)
@@ -89,7 +89,7 @@ type rawEntry struct {
89 89
90 // readDiff parses the output of diff-tree --raw --numstat -p.90 // readDiff parses the output of diff-tree --raw --numstat -p.
91 //91 //
92-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for diff windowing and truncation budgets; pending move to domain/usecase92+//hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process
93 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {93 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
94 entries, err := readRaw(r)94 entries, err := readRaw(r)
95 if err != nil {95 if err != nil {
@@ -105,23 +105,17 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
105 stats.Deletions += entries[i].del105 stats.Deletions += entries[i].del
106 }106 }
107 var diff domain.Diff107 var diff domain.Diff
108- if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles {108+ budget := domain.NewDiffBudget(limits)
109- entries = entries[:limits.MaxFiles]109+ kept, from, to, truncated := budget.Window(len(entries))
110- diff.Truncated = true110+ entries = entries[:kept]
111- }111+ diff.Truncated = truncated
112- // Only the files of the window [from, to) get their hunks; the others are
113- // listed as they are, neither truncated nor counted in the budgets.
114- from, to := max(limits.From, 0), len(entries)
115- if limits.To > 0 {
116- to = min(limits.To, len(entries))
117- }
118 112
119 // The patch holds one section per file, two for a type change, in the113 // The patch holds one section per file, two for a type change, in the
120 // order of the entries. Entries without lines to show (binary, pure114 // order of the entries. Entries without lines to show (binary, pure
121 // rename, mode change) have a section without hunks.115 // rename, mode change) have a section without hunks.
122 lastRead := -1116 lastRead := -1
123 for i := from; i < to; i++ {117 for i := from; i < to; i++ {
124- if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) {118+ if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {
125 lastRead = i119 lastRead = i
126 }120 }
127 }121 }
@@ -131,8 +125,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
131 _, _ = r.Discard(1)125 _, _ = r.Discard(1)
132 }126 }
133 }127 }
134- pr := &patchReader{lr: newLineReader(r, maxDiffLine), bytesLeft: limits.MaxTotalBytes}128+ pr := &patchReader{lr: newLineReader(r, maxDiffLine), budget: budget}
135- budget := limits.MaxTotalLines
136 diff.Files = make([]domain.FileDiff, 0, len(entries))129 diff.Files = make([]domain.FileDiff, 0, len(entries))
137 for i := range entries {130 for i := range entries {
138 e := &entries[i]131 e := &entries[i]
@@ -142,21 +135,19 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
142 }135 }
143 inWindow := i >= from && i < to136 inWindow := i >= from && i < to
144 switch {137 switch {
145- case !inWindow, !changedLines(e):138+ case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):
146 // Nothing to show.139 // Nothing to show.
147- case !wantsHunks(e, limits, budget, pr.bytesLeft):140+ case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):
148 e.file.Truncated = true141 e.file.Truncated = true
149 default:142 default:
150- bytesBefore := pr.bytesLeft143+ before := pr.budget
151- hunks, lines, truncated, err := pr.file(sections, min(limits.MaxFileLines, budget))144+ hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())
152 if err != nil {145 if err != nil {
153 return domain.Diff{}, domain.DiffStats{}, err146 return domain.Diff{}, domain.DiffStats{}, err
154 }147 }
155 e.file.Hunks, e.file.Truncated = hunks, truncated148 e.file.Hunks, e.file.Truncated = hunks, truncated
156 if truncated {149 if truncated {
157- pr.bytesLeft = bytesBefore // nothing of this file is kept150+ pr.budget = before // nothing of this file is kept
158- } else {
159- budget -= lines
160 }151 }
161 sections = 0152 sections = 0
162 }153 }
@@ -175,17 +166,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
175 return diff, stats, nil166 return diff, stats, nil
176 }167 }
177 168
178-// changedLines reports whether the file has text lines in its patch.
179-func changedLines(e *rawEntry) bool { return !e.file.Binary && e.added+e.del > 0 }
180-
181-// wantsHunks reports whether the lines of e can still be shown: the changed
182-// lines alone, a lower bound of the hunk lines, must fit the per-file limit
183-// and what remains of the total, and some text bytes must remain.
184-func wantsHunks(e *rawEntry, limits domain.DiffLimits, linesLeft, bytesLeft int) bool {
185- n := e.added + e.del
186- return changedLines(e) && n <= limits.MaxFileLines && n <= linesLeft && bytesLeft > 0
187-}
188-
189 // readRaw reads the `--raw -z` records, which start with ':'.169 // readRaw reads the `--raw -z` records, which start with ':'.
190 func readRaw(r *bufio.Reader) ([]rawEntry, error) {170 func readRaw(r *bufio.Reader) ([]rawEntry, error) {
191 var entries []rawEntry171 var entries []rawEntry
@@ -333,7 +313,7 @@ type patchReader struct {
333 eof bool313 eof bool
334 cut bool // the last line read was cut314 cut bool // the last line read was cut
335 315
336- bytesLeft int // text bytes still allowed in the diff316+ budget domain.DiffBudget
337 }317 }
338 318
339 const sectionMarker = "diff --git "319 const sectionMarker = "diff --git "
@@ -480,8 +460,8 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
480 continue460 continue
481 }461 }
482 lines++462 lines++
483- p.bytesLeft -= len(text)463+ fits := p.budget.ChargeLine(len(text))
484- if lines > max || p.bytesLeft < 0 {464+ if lines > max || !fits {
485 return nil, lines, true, nil465 return nil, lines, true, nil
486 }466 }
487 cur.Lines = append(cur.Lines, l)467 cur.Lines = append(cur.Lines, l)
A internal/domain/budget.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// ValidateCommitObjects checks a request before any repository I/O. max is the
8+// caller's commit-count limit.
9+func ValidateCommitObjects(ids []ObjectID, max int) error {
10+ if len(ids) > max {
11+ return fmt.Errorf("%w: %d commit objects asked, at most %d", ErrInvalid, len(ids), max)
12+ }
13+ for _, id := range ids {
14+ if id.IsZero() {
15+ return fmt.Errorf("%w: empty commit id", ErrInvalidObjectID)
16+ }
17+ }
18+ return nil
19+}
20+
21+// CommitObjectBudget bounds a batch of raw commits. Its zero value is ready to
22+// use. Admission is a preview; Charge records only successfully read content.
23+type CommitObjectBudget struct {
24+ total int64
25+ seen map[ObjectID]bool
26+}
27+
28+func (b *CommitObjectBudget) Seen(id ObjectID) bool { return b.seen[id] }
29+
30+func (b *CommitObjectBudget) Admit(id ObjectID, commit bool, size int64) (take, stop bool) {
31+ if b.Seen(id) || !commit || size < 0 || size > MaxCommitObjectBytes {
32+ return false, false
33+ }
34+ if size > MaxCommitObjectsBytes-b.total {
35+ return false, true
36+ }
37+ return true, false
38+}
39+
40+func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
41+ if b.seen == nil {
42+ b.seen = make(map[ObjectID]bool)
43+ }
44+ b.seen[id] = true
45+ b.total += size
46+}
47+
48+// DiffBudget owns file selection and retained hunk accounting. Truncated files
49+// consume no budget; streaming readers may use a copy while attempting a file.
50+type DiffBudget struct {
51+ limits DiffLimits
52+ linesLeft, bytesLeft int
53+}
54+
55+func NewDiffBudget(limits DiffLimits) DiffBudget {
56+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}
57+}
58+
59+func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
60+ kept = files
61+ if b.limits.MaxFiles > 0 && kept > b.limits.MaxFiles {
62+ kept = b.limits.MaxFiles
63+ truncated = true
64+ }
65+ from, to = max(b.limits.From, 0), kept
66+ if b.limits.To > 0 {
67+ to = min(b.limits.To, kept)
68+ }
69+ return
70+}
71+
72+func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }
73+
74+func (b DiffBudget) WantsHunks(binary bool, n int) bool {
75+ return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0
76+}
77+
78+func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
79+
80+// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81+func (b *DiffBudget) ChargeLine(bytes int) bool {
82+ b.linesLeft--
83+ b.bytesLeft -= bytes
84+ return b.linesLeft >= 0 && b.bytesLeft >= 0
85+}
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// ValidateCommitObjects checks a request before any repository I/O. max is the
8+// caller's commit-count limit.
9+func ValidateCommitObjects(ids []ObjectID, max int) error {
10+ if len(ids) > max {
11+ return fmt.Errorf("%w: %d commit objects asked, at most %d", ErrInvalid, len(ids), max)
12+ }
13+ for _, id := range ids {
14+ if id.IsZero() {
15+ return fmt.Errorf("%w: empty commit id", ErrInvalidObjectID)
16+ }
17+ }
18+ return nil
19+}
20+
21+// CommitObjectBudget bounds a batch of raw commits. Its zero value is ready to
22+// use. Admission is a preview; Charge records only successfully read content.
23+type CommitObjectBudget struct {
24+ total int64
25+ seen map[ObjectID]bool
26+}
27+
28+func (b *CommitObjectBudget) Seen(id ObjectID) bool { return b.seen[id] }
29+
30+func (b *CommitObjectBudget) Admit(id ObjectID, commit bool, size int64) (take, stop bool) {
31+ if b.Seen(id) || !commit || size < 0 || size > MaxCommitObjectBytes {
32+ return false, false
33+ }
34+ if size > MaxCommitObjectsBytes-b.total {
35+ return false, true
36+ }
37+ return true, false
38+}
39+
40+func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
41+ if b.seen == nil {
42+ b.seen = make(map[ObjectID]bool)
43+ }
44+ b.seen[id] = true
45+ b.total += size
46+}
47+
48+// DiffBudget owns file selection and retained hunk accounting. Truncated files
49+// consume no budget; streaming readers may use a copy while attempting a file.
50+type DiffBudget struct {
51+ limits DiffLimits
52+ linesLeft, bytesLeft int
53+}
54+
55+func NewDiffBudget(limits DiffLimits) DiffBudget {
56+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}
57+}
58+
59+func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
60+ kept = files
61+ if b.limits.MaxFiles > 0 && kept > b.limits.MaxFiles {
62+ kept = b.limits.MaxFiles
63+ truncated = true
64+ }
65+ from, to = max(b.limits.From, 0), kept
66+ if b.limits.To > 0 {
67+ to = min(b.limits.To, kept)
68+ }
69+ return
70+}
71+
72+func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }
73+
74+func (b DiffBudget) WantsHunks(binary bool, n int) bool {
75+ return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0
76+}
77+
78+func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
79+
80+// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81+func (b *DiffBudget) ChargeLine(bytes int) bool {
82+ b.linesLeft--
83+ b.bytesLeft -= bytes
84+ return b.linesLeft >= 0 && b.bytesLeft >= 0
85+}
A internal/domain/budget_test.go +141 -0
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func budgetID(t *testing.T, s string) ObjectID {
11+ t.Helper()
12+ id, err := ParseObjectID(s)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ return id
17+}
18+
19+func TestValidateCommitObjects(t *testing.T) {
20+ id := budgetID(t, "1111111111111111111111111111111111111111")
21+ for _, tt := range []struct {
22+ name string
23+ ids []ObjectID
24+ max int
25+ want error
26+ }{
27+ {"empty", nil, 1, nil}, {"boundary", []ObjectID{id}, 1, nil},
28+ {"too many", []ObjectID{id, id}, 1, ErrInvalid}, {"zero", []ObjectID{{}}, 1, ErrInvalidObjectID},
29+ } {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if err := ValidateCommitObjects(tt.ids, tt.max); !errors.Is(err, tt.want) {
32+ t.Fatalf("got %v, want %v", err, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestCommitObjectBudget(t *testing.T) {
39+ id := budgetID(t, "1111111111111111111111111111111111111111")
40+ for _, tt := range []struct {
41+ name string
42+ commit bool
43+ size, total int64
44+ seen, take, stop bool
45+ }{
46+ {"non commit", false, 1, 0, false, false, false},
47+ {"oversize", true, MaxCommitObjectBytes + 1, 0, false, false, false},
48+ {"exact object", true, MaxCommitObjectBytes, 0, false, true, false},
49+ {"exact total", true, 1, MaxCommitObjectsBytes - 1, false, true, false},
50+ {"over total", true, 2, MaxCommitObjectsBytes - 1, false, false, true},
51+ {"duplicate", true, 1, MaxCommitObjectsBytes, true, false, false},
52+ {"negative", true, -1, 0, false, false, false},
53+ } {
54+ t.Run(tt.name, func(t *testing.T) {
55+ b := CommitObjectBudget{total: tt.total}
56+ if tt.seen {
57+ b.seen = map[ObjectID]bool{id: true}
58+ }
59+ take, stop := b.Admit(id, tt.commit, tt.size)
60+ if take != tt.take || stop != tt.stop {
61+ t.Fatalf("got %v,%v", take, stop)
62+ }
63+ if take {
64+ b.Charge(id, tt.size)
65+ if !b.Seen(id) || b.total != tt.total+tt.size {
66+ t.Fatal("charge not recorded")
67+ }
68+ }
69+ })
70+ }
71+}
72+
73+func TestDiffBudgetWindow(t *testing.T) {
74+ for _, tt := range []struct {
75+ name string
76+ limits DiffLimits
77+ kept, from, to int
78+ truncated bool
79+ }{
80+ {"unlimited", DiffLimits{}, 5, 0, 5, false},
81+ {"cap", DiffLimits{MaxFiles: 3}, 3, 0, 3, true},
82+ {"window", DiffLimits{From: 1, To: 4}, 5, 1, 4, false},
83+ {"clamped", DiffLimits{From: -1, To: 9, MaxFiles: 3}, 3, 0, 3, true},
84+ {"past end", DiffLimits{From: 8}, 5, 8, 5, false},
85+ {"reversed", DiffLimits{From: 4, To: 2}, 5, 4, 2, false},
86+ } {
87+ t.Run(tt.name, func(t *testing.T) {
88+ b := NewDiffBudget(tt.limits)
89+ k, f, to, tr := b.Window(5)
90+ if k != tt.kept || f != tt.from || to != tt.to || tr != tt.truncated {
91+ t.Fatalf("got %d,%d,%d,%v", k, f, to, tr)
92+ }
93+ })
94+ }
95+}
96+
97+func TestDiffBudgetHunks(t *testing.T) {
98+ for _, tt := range []struct {
99+ name string
100+ binary bool
101+ n, lines, bytes int
102+ want bool
103+ }{
104+ {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},
105+ {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},
106+ {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},
107+ } {
108+ t.Run(tt.name, func(t *testing.T) {
109+ b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})
110+ if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {
111+ t.Fatalf("got %v", got)
112+ }
113+ })
114+ }
115+}
116+
117+func TestDiffBudgetCharge(t *testing.T) {
118+ for _, tt := range []struct {
119+ name string
120+ bytes int
121+ fits bool
122+ }{{"exact", 3, true}, {"over", 4, false}} {
123+ t.Run(tt.name, func(t *testing.T) {
124+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125+ before := b
126+ if b.FileLines() != 1 {
127+ t.Fatal("wrong file limit")
128+ }
129+ if b.ChargeLine(tt.bytes) != tt.fits {
130+ t.Fatal("wrong charge result")
131+ }
132+ if b.ChargeLine(0) {
133+ t.Fatal("line budget exceeded")
134+ }
135+ b = before
136+ if !b.WantsHunks(false, 1) {
137+ t.Fatal("discarded file consumed budget")
138+ }
139+ })
140+ }
141+}
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func budgetID(t *testing.T, s string) ObjectID {
11+ t.Helper()
12+ id, err := ParseObjectID(s)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ return id
17+}
18+
19+func TestValidateCommitObjects(t *testing.T) {
20+ id := budgetID(t, "1111111111111111111111111111111111111111")
21+ for _, tt := range []struct {
22+ name string
23+ ids []ObjectID
24+ max int
25+ want error
26+ }{
27+ {"empty", nil, 1, nil}, {"boundary", []ObjectID{id}, 1, nil},
28+ {"too many", []ObjectID{id, id}, 1, ErrInvalid}, {"zero", []ObjectID{{}}, 1, ErrInvalidObjectID},
29+ } {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if err := ValidateCommitObjects(tt.ids, tt.max); !errors.Is(err, tt.want) {
32+ t.Fatalf("got %v, want %v", err, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestCommitObjectBudget(t *testing.T) {
39+ id := budgetID(t, "1111111111111111111111111111111111111111")
40+ for _, tt := range []struct {
41+ name string
42+ commit bool
43+ size, total int64
44+ seen, take, stop bool
45+ }{
46+ {"non commit", false, 1, 0, false, false, false},
47+ {"oversize", true, MaxCommitObjectBytes + 1, 0, false, false, false},
48+ {"exact object", true, MaxCommitObjectBytes, 0, false, true, false},
49+ {"exact total", true, 1, MaxCommitObjectsBytes - 1, false, true, false},
50+ {"over total", true, 2, MaxCommitObjectsBytes - 1, false, false, true},
51+ {"duplicate", true, 1, MaxCommitObjectsBytes, true, false, false},
52+ {"negative", true, -1, 0, false, false, false},
53+ } {
54+ t.Run(tt.name, func(t *testing.T) {
55+ b := CommitObjectBudget{total: tt.total}
56+ if tt.seen {
57+ b.seen = map[ObjectID]bool{id: true}
58+ }
59+ take, stop := b.Admit(id, tt.commit, tt.size)
60+ if take != tt.take || stop != tt.stop {
61+ t.Fatalf("got %v,%v", take, stop)
62+ }
63+ if take {
64+ b.Charge(id, tt.size)
65+ if !b.Seen(id) || b.total != tt.total+tt.size {
66+ t.Fatal("charge not recorded")
67+ }
68+ }
69+ })
70+ }
71+}
72+
73+func TestDiffBudgetWindow(t *testing.T) {
74+ for _, tt := range []struct {
75+ name string
76+ limits DiffLimits
77+ kept, from, to int
78+ truncated bool
79+ }{
80+ {"unlimited", DiffLimits{}, 5, 0, 5, false},
81+ {"cap", DiffLimits{MaxFiles: 3}, 3, 0, 3, true},
82+ {"window", DiffLimits{From: 1, To: 4}, 5, 1, 4, false},
83+ {"clamped", DiffLimits{From: -1, To: 9, MaxFiles: 3}, 3, 0, 3, true},
84+ {"past end", DiffLimits{From: 8}, 5, 8, 5, false},
85+ {"reversed", DiffLimits{From: 4, To: 2}, 5, 4, 2, false},
86+ } {
87+ t.Run(tt.name, func(t *testing.T) {
88+ b := NewDiffBudget(tt.limits)
89+ k, f, to, tr := b.Window(5)
90+ if k != tt.kept || f != tt.from || to != tt.to || tr != tt.truncated {
91+ t.Fatalf("got %d,%d,%d,%v", k, f, to, tr)
92+ }
93+ })
94+ }
95+}
96+
97+func TestDiffBudgetHunks(t *testing.T) {
98+ for _, tt := range []struct {
99+ name string
100+ binary bool
101+ n, lines, bytes int
102+ want bool
103+ }{
104+ {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},
105+ {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},
106+ {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},
107+ } {
108+ t.Run(tt.name, func(t *testing.T) {
109+ b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})
110+ if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {
111+ t.Fatalf("got %v", got)
112+ }
113+ })
114+ }
115+}
116+
117+func TestDiffBudgetCharge(t *testing.T) {
118+ for _, tt := range []struct {
119+ name string
120+ bytes int
121+ fits bool
122+ }{{"exact", 3, true}, {"over", 4, false}} {
123+ t.Run(tt.name, func(t *testing.T) {
124+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125+ before := b
126+ if b.FileLines() != 1 {
127+ t.Fatal("wrong file limit")
128+ }
129+ if b.ChargeLine(tt.bytes) != tt.fits {
130+ t.Fatal("wrong charge result")
131+ }
132+ if b.ChargeLine(0) {
133+ t.Fatal("line budget exceeded")
134+ }
135+ b = before
136+ if !b.WantsHunks(false, 1) {
137+ t.Fatal("discarded file consumed budget")
138+ }
139+ })
140+ }
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