detached 4569eaf

fix(diff): count only changed lines against the diff line limits

Context lines no longer consume MaxFileLines and MaxTotalLines, which the
admission check already ignored: a file with many hunks was cut although
its changed lines fit. Context text still counts against MaxTotalBytes.
commit
4569eaff45d1f2b0a2605cc230b3202ff8ef1ede
author
brice · · 2026-10-20 05:44 UTC
parent
cc7ba64
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +125 -21
M internal/adapters/gitcli/diff.go +7 -4
@@ -379,7 +379,7 @@ func (p *patchReader) skip(n int) error {
379379 }
380380
381381 // file reads the sections of one file and returns its hunks, the number of
382-// hunk lines, and whether it exceeded max lines or had a line over
382+// changed lines, and whether it exceeded max changed lines or had a line over
383383 // maxDiffLine. When truncated, the rest of the file is skipped.
384384 func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
385385 for done := range sections {
@@ -405,7 +405,7 @@ func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, t
405405 }
406406
407407 // section reads the hunks of the current section up to the next marker, which
408-// stays pending. It gives up (truncated) after more than max lines.
408+// stays pending. It gives up (truncated) after more than max changed lines.
409409 func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
410410 var cur *domain.Hunk
411411 var oldN, newN int
@@ -459,8 +459,11 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
459459 default:
460460 continue
461461 }
462- lines++
463- fits := p.budget.ChargeLine(len(text))
462+ changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
463+ if changed {
464+ lines++
465+ }
466+ fits := p.budget.ChargeLine(len(text), changed)
464467 if lines > max || !fits {
465468 return nil, lines, true, nil
466469 }
@@ -379,7 +379,7 @@ func (p *patchReader) skip(n int) error {
379 }379 }
380 380
381 // file reads the sections of one file and returns its hunks, the number of381 // file reads the sections of one file and returns its hunks, the number of
382-// hunk lines, and whether it exceeded max lines or had a line over382+// changed lines, and whether it exceeded max changed lines or had a line over
383 // maxDiffLine. When truncated, the rest of the file is skipped.383 // maxDiffLine. When truncated, the rest of the file is skipped.
384 func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {384 func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
385 for done := range sections {385 for done := range sections {
@@ -405,7 +405,7 @@ func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, t
405 }405 }
406 406
407 // section reads the hunks of the current section up to the next marker, which407 // section reads the hunks of the current section up to the next marker, which
408-// stays pending. It gives up (truncated) after more than max lines.408+// stays pending. It gives up (truncated) after more than max changed lines.
409 func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {409 func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
410 var cur *domain.Hunk410 var cur *domain.Hunk
411 var oldN, newN int411 var oldN, newN int
@@ -459,8 +459,11 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
459 default:459 default:
460 continue460 continue
461 }461 }
462- lines++462+ changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion
463- fits := p.budget.ChargeLine(len(text))463+ if changed {
464+ lines++
465+ }
466+ fits := p.budget.ChargeLine(len(text), changed)
464 if lines > max || !fits {467 if lines > max || !fits {
465 return nil, lines, true, nil468 return nil, lines, true, nil
466 }469 }
M internal/adapters/gitcli/diff_test.go +15 -5
@@ -13,6 +13,7 @@ import (
1313 "github.com/stretchr/testify/require"
1414
1515 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
1617 "bnema.dev/ermite/internal/readertest"
1718 )
1819
@@ -327,10 +328,10 @@ func TestDiffLimits(t *testing.T) {
327328 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328329 })
329330
330- t.Run("context counts towards the file limit", func(t *testing.T) {
331+ t.Run("context does not count towards the file limit", func(t *testing.T) {
331332 f := readertest.NewFixture(t, data, "limit-context")
332333 f.Lines("big", "l", 3000).Commit("v1")
333- // Every 7th line changes: ~428 changed lines, but the hunks merge
334+ // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge
334335 // into one that spans the file with its context.
335336 var b strings.Builder
336337 for i := range 3000 {
@@ -346,11 +347,13 @@ func TestDiffLimits(t *testing.T) {
346347 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347348 require.NoError(t, err)
348349 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349- assert.True(t, big.Truncated)
350- assert.Empty(t, big.Hunks)
350+ assert.False(t, big.Truncated)
351+ require.Len(t, big.Hunks, 1)
352+ assert.Len(t, big.Hunks[0].Lines, 3000+429)
353+ assert.False(t, d.Diff.Truncated)
351354 assert.Equal(t, 429, big.Additions)
352355 assert.Equal(t, 429, big.Deletions)
353- assert.False(t, tiny.Truncated, "the file after a truncated one is still read")
356+ assert.False(t, tiny.Truncated, "the following file still fits the changed-line budget")
354357 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355358 assert.Equal(t, id, d.Commit.ID.String())
356359 })
@@ -660,3 +663,10 @@ func TestDiffLimitsWindow(t *testing.T) {
660663 assert.True(t, d.Diff.Truncated)
661664 })
662665 }
666+
667+func TestDiffChangedLineLimits(t *testing.T) {
668+ readertest.DiffChangedLineLimits(t, func(t *testing.T) (ports.RepoReader, string) {
669+ g, data := newReader(t, nil)
670+ return g, data
671+ })
672+}
@@ -13,6 +13,7 @@ import (
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
16 "bnema.dev/ermite/internal/readertest"17 "bnema.dev/ermite/internal/readertest"
17 )18 )
18 19
@@ -327,10 +328,10 @@ func TestDiffLimits(t *testing.T) {
327 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)328 assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats)
328 })329 })
329 330
330- t.Run("context counts towards the file limit", func(t *testing.T) {331+ t.Run("context does not count towards the file limit", func(t *testing.T) {
331 f := readertest.NewFixture(t, data, "limit-context")332 f := readertest.NewFixture(t, data, "limit-context")
332 f.Lines("big", "l", 3000).Commit("v1")333 f.Lines("big", "l", 3000).Commit("v1")
333- // Every 7th line changes: ~428 changed lines, but the hunks merge334+ // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge
334 // into one that spans the file with its context.335 // into one that spans the file with its context.
335 var b strings.Builder336 var b strings.Builder
336 for i := range 3000 {337 for i := range 3000 {
@@ -346,11 +347,13 @@ func TestDiffLimits(t *testing.T) {
346 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)347 d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults)
347 require.NoError(t, err)348 require.NoError(t, err)
348 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")349 big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny")
349- assert.True(t, big.Truncated)350+ assert.False(t, big.Truncated)
350- assert.Empty(t, big.Hunks)351+ require.Len(t, big.Hunks, 1)
352+ assert.Len(t, big.Hunks[0].Lines, 3000+429)
353+ assert.False(t, d.Diff.Truncated)
351 assert.Equal(t, 429, big.Additions)354 assert.Equal(t, 429, big.Additions)
352 assert.Equal(t, 429, big.Deletions)355 assert.Equal(t, 429, big.Deletions)
353- assert.False(t, tiny.Truncated, "the file after a truncated one is still read")356+ assert.False(t, tiny.Truncated, "the following file still fits the changed-line budget")
354 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))357 assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+"))
355 assert.Equal(t, id, d.Commit.ID.String())358 assert.Equal(t, id, d.Commit.ID.String())
356 })359 })
@@ -660,3 +663,10 @@ func TestDiffLimitsWindow(t *testing.T) {
660 assert.True(t, d.Diff.Truncated)663 assert.True(t, d.Diff.Truncated)
661 })664 })
662 }665 }
666+
667+func TestDiffChangedLineLimits(t *testing.T) {
668+ readertest.DiffChangedLineLimits(t, func(t *testing.T) (ports.RepoReader, string) {
669+ g, data := newReader(t, nil)
670+ return g, data
671+ })
672+}
M internal/adapters/web/diff_highlight.go +1 -1
@@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10
4848
4949 const (
5050 // maxDiffHLFileBytes is the most text of one file of a diff that is
51- // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a
51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a
5252 // file; this keeps a file of very long lines out.
5353 maxDiffHLFileBytes = 128 << 10
5454 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
@@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10
48 48
49 const (49 const (
50 // maxDiffHLFileBytes is the most text of one file of a diff that is50 // maxDiffHLFileBytes is the most text of one file of a diff that is
51- // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a51+ // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a
52 // file; this keeps a file of very long lines out.52 // file; this keeps a file of very long lines out.
53 maxDiffHLFileBytes = 128 << 1053 maxDiffHLFileBytes = 128 << 10
54 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified54 // maxDiffHLLine is the longest line that is lexed, in bytes: a minified
M internal/adapters/web/diff_highlight_test.go +1 -1
@@ -273,7 +273,7 @@ func goSource(n int) []string {
273273 }
274274
275275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276-// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a
276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a
277277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278278 func largeDiff(n int) []domain.FileDiff {
279279 var files []domain.FileDiff
@@ -273,7 +273,7 @@ func goSource(n int) []string {
273 }273 }
274 274
275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place275 // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276-// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a276+// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a
277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.277 // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278 func largeDiff(n int) []domain.FileDiff {278 func largeDiff(n int) []domain.FileDiff {
279 var files []domain.FileDiff279 var files []domain.FileDiff
M internal/domain/budget.go +6 -3
@@ -77,9 +77,12 @@ func (b DiffBudget) WantsHunks(binary bool, n int) bool {
7777
7878 func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
7979
80-// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81-func (b *DiffBudget) ChargeLine(bytes int) bool {
82- b.linesLeft--
80+// ChargeLine accounts for decoded text bytes and, for additions or deletions,
81+// one changed line. Context consumes bytes only. It reports whether the line fits.
82+func (b *DiffBudget) ChargeLine(bytes int, changed bool) bool {
83+ if changed {
84+ b.linesLeft--
85+ }
8386 b.bytesLeft -= bytes
8487 return b.linesLeft >= 0 && b.bytesLeft >= 0
8588 }
@@ -77,9 +77,12 @@ func (b DiffBudget) WantsHunks(binary bool, n int) bool {
77 77
78 func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }78 func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
79 79
80-// ChargeLine accounts for one decoded hunk line and reports whether it fits.80+// ChargeLine accounts for decoded text bytes and, for additions or deletions,
81-func (b *DiffBudget) ChargeLine(bytes int) bool {81+// one changed line. Context consumes bytes only. It reports whether the line fits.
82- b.linesLeft--82+func (b *DiffBudget) ChargeLine(bytes int, changed bool) bool {
83+ if changed {
84+ b.linesLeft--
85+ }
83 b.bytesLeft -= bytes86 b.bytesLeft -= bytes
84 return b.linesLeft >= 0 && b.bytesLeft >= 087 return b.linesLeft >= 0 && b.bytesLeft >= 0
85 }88 }
M internal/domain/budget_test.go +18 -2
@@ -126,10 +126,10 @@ func TestDiffBudgetCharge(t *testing.T) {
126126 if b.FileLines() != 1 {
127127 t.Fatal("wrong file limit")
128128 }
129- if b.ChargeLine(tt.bytes) != tt.fits {
129+ if b.ChargeLine(tt.bytes, true) != tt.fits {
130130 t.Fatal("wrong charge result")
131131 }
132- if b.ChargeLine(0) {
132+ if b.ChargeLine(0, true) {
133133 t.Fatal("line budget exceeded")
134134 }
135135 b = before
@@ -139,3 +139,19 @@ func TestDiffBudgetCharge(t *testing.T) {
139139 })
140140 }
141141 }
142+
143+func TestDiffBudgetContext(t *testing.T) {
144+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6})
145+ if !b.ChargeLine(2, false) || b.FileLines() != 2 {
146+ t.Fatal("context consumed changed-line budget")
147+ }
148+ if !b.ChargeLine(1, true) || !b.ChargeLine(1, true) || b.FileLines() != 0 {
149+ t.Fatal("additions and deletions must consume changed-line budget")
150+ }
151+ if !b.ChargeLine(2, false) {
152+ t.Fatal("context at the byte boundary must fit even without changed lines left")
153+ }
154+ if b.ChargeLine(1, false) {
155+ t.Fatal("context must not exceed the byte budget")
156+ }
157+}
@@ -126,10 +126,10 @@ func TestDiffBudgetCharge(t *testing.T) {
126 if b.FileLines() != 1 {126 if b.FileLines() != 1 {
127 t.Fatal("wrong file limit")127 t.Fatal("wrong file limit")
128 }128 }
129- if b.ChargeLine(tt.bytes) != tt.fits {129+ if b.ChargeLine(tt.bytes, true) != tt.fits {
130 t.Fatal("wrong charge result")130 t.Fatal("wrong charge result")
131 }131 }
132- if b.ChargeLine(0) {132+ if b.ChargeLine(0, true) {
133 t.Fatal("line budget exceeded")133 t.Fatal("line budget exceeded")
134 }134 }
135 b = before135 b = before
@@ -139,3 +139,19 @@ func TestDiffBudgetCharge(t *testing.T) {
139 })139 })
140 }140 }
141 }141 }
142+
143+func TestDiffBudgetContext(t *testing.T) {
144+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6})
145+ if !b.ChargeLine(2, false) || b.FileLines() != 2 {
146+ t.Fatal("context consumed changed-line budget")
147+ }
148+ if !b.ChargeLine(1, true) || !b.ChargeLine(1, true) || b.FileLines() != 0 {
149+ t.Fatal("additions and deletions must consume changed-line budget")
150+ }
151+ if !b.ChargeLine(2, false) {
152+ t.Fatal("context at the byte boundary must fit even without changed lines left")
153+ }
154+ if b.ChargeLine(1, false) {
155+ t.Fatal("context must not exceed the byte budget")
156+ }
157+}
M internal/domain/git.go +6 -5
@@ -537,19 +537,19 @@ type CommitDetail struct {
537537 Diff Diff
538538 }
539539
540-// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
540+// DiffLimits bound the size of a Diff. Line limits count additions plus deletions, not context.
541541 type DiffLimits struct {
542542 // MaxFiles is the number of files kept; 0 means no limit on the number of
543543 // files (every changed file is listed).
544544 MaxFiles int
545- // MaxFileLines is the most lines one file may have; a file over it is
545+ // MaxFileLines is the most changed lines one file may have; a file over it is
546546 // truncated.
547547 MaxFileLines int
548- // MaxTotalLines is the most lines of all the files. A file is truncated
548+ // MaxTotalLines is the most changed lines of all the files. A file is truncated
549549 // when it does not fit in what remains; a smaller one after it may still
550550 // fit.
551551 MaxTotalLines int
552- // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
552+ // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds
553553 // the memory of a diff whose lines are very long; files are truncated
554554 // like for MaxTotalLines.
555555 MaxTotalBytes int
@@ -563,7 +563,8 @@ type DiffLimits struct {
563563 }
564564
565565 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
566-// lines per file, 20,000 lines in total, plus 8 MiB of text.
566+// changed lines per file, 20,000 changed lines in total, plus 8 MiB of text
567+// including context.
567568 func DefaultDiffLimits() DiffLimits {
568569 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
569570 }
@@ -537,19 +537,19 @@ type CommitDetail struct {
537 Diff Diff537 Diff Diff
538 }538 }
539 539
540-// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.540+// DiffLimits bound the size of a Diff. Line limits count additions plus deletions, not context.
541 type DiffLimits struct {541 type DiffLimits struct {
542 // MaxFiles is the number of files kept; 0 means no limit on the number of542 // MaxFiles is the number of files kept; 0 means no limit on the number of
543 // files (every changed file is listed).543 // files (every changed file is listed).
544 MaxFiles int544 MaxFiles int
545- // MaxFileLines is the most lines one file may have; a file over it is545+ // MaxFileLines is the most changed lines one file may have; a file over it is
546 // truncated.546 // truncated.
547 MaxFileLines int547 MaxFileLines int
548- // MaxTotalLines is the most lines of all the files. A file is truncated548+ // MaxTotalLines is the most changed lines of all the files. A file is truncated
549 // when it does not fit in what remains; a smaller one after it may still549 // when it does not fit in what remains; a smaller one after it may still
550 // fit.550 // fit.
551 MaxTotalLines int551 MaxTotalLines int
552- // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds552+ // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds
553 // the memory of a diff whose lines are very long; files are truncated553 // the memory of a diff whose lines are very long; files are truncated
554 // like for MaxTotalLines.554 // like for MaxTotalLines.
555 MaxTotalBytes int555 MaxTotalBytes int
@@ -563,7 +563,8 @@ type DiffLimits struct {
563 }563 }
564 564
565 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000565 // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
566-// lines per file, 20,000 lines in total, plus 8 MiB of text.566+// changed lines per file, 20,000 changed lines in total, plus 8 MiB of text
567+// including context.
567 func DefaultDiffLimits() DiffLimits {568 func DefaultDiffLimits() DiffLimits {
568 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}569 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
569 }570 }
M internal/readertest/conformance.go +1 -0
@@ -31,6 +31,7 @@ func Conformance(t *testing.T, mk Maker) {
3131 name string
3232 fn func(*testing.T, Maker)
3333 }{
34+ {"DiffChangedLineLimits", DiffChangedLineLimits},
3435 {"ListRefs", testListRefs},
3536 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
3637 {"DetachedHead", testDetachedHead},
@@ -31,6 +31,7 @@ func Conformance(t *testing.T, mk Maker) {
31 name string31 name string
32 fn func(*testing.T, Maker)32 fn func(*testing.T, Maker)
33 }{33 }{
34+ {"DiffChangedLineLimits", DiffChangedLineLimits},
34 {"ListRefs", testListRefs},35 {"ListRefs", testListRefs},
35 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},36 {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing},
36 {"DetachedHead", testDetachedHead},37 {"DetachedHead", testDetachedHead},
A internal/readertest/diff.go +70 -0
@@ -0,0 +1,70 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// DiffChangedLineLimits checks that context is retained without consuming the
18+// changed-line limits, but still consumes the byte budget.
19+func DiffChangedLineLimits(t *testing.T, mk Maker) {
20+ g, data := mk(t)
21+ f := NewFixture(t, data, "diff-context-budget")
22+ var before, after strings.Builder
23+ const hunks = 96
24+ for i := range hunks * 10 {
25+ line := fmt.Sprintf("context %04d", i)
26+ fmt.Fprintln(&before, line)
27+ if i%10 == 4 {
28+ line = fmt.Sprintf("changed %04d", i)
29+ }
30+ fmt.Fprintln(&after, line)
31+ }
32+ f.Write("many", before.String()).Commit("before")
33+ f.Write("many", after.String()).Commit("after")
34+ limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20}
35+ d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
36+ require.NoError(t, err)
37+ require.Len(t, d.Diff.Files, 1)
38+ file := d.Diff.Files[0]
39+ require.False(t, file.Truncated)
40+ assert.False(t, d.Diff.Truncated)
41+ require.Len(t, file.Hunks, hunks)
42+ var changed, patchLines, textBytes, changedBytes int
43+ for _, h := range file.Hunks {
44+ for _, l := range h.Lines {
45+ patchLines++
46+ textBytes += len(l.Text)
47+ if l.Kind != domain.LineContext {
48+ changed++
49+ changedBytes += len(l.Text)
50+ }
51+ }
52+ }
53+ assert.Equal(t, hunks*2, changed)
54+ assert.Greater(t, patchLines, limits.MaxFileLines)
55+ assert.Equal(t, hunks, file.Additions)
56+ assert.Equal(t, hunks, file.Deletions)
57+ // The exact byte boundary fits; one fewer byte must discard the file even
58+ // though every changed line's text alone easily fits.
59+ limits.MaxTotalBytes = textBytes
60+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
61+ require.NoError(t, err)
62+ assert.False(t, d.Diff.Files[0].Truncated)
63+ limits.MaxTotalBytes--
64+ require.Greater(t, limits.MaxTotalBytes, changedBytes)
65+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
66+ require.NoError(t, err)
67+ assert.True(t, d.Diff.Truncated)
68+ assert.True(t, d.Diff.Files[0].Truncated)
69+ assert.Empty(t, d.Diff.Files[0].Hunks)
70+}
@@ -0,0 +1,70 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// DiffChangedLineLimits checks that context is retained without consuming the
18+// changed-line limits, but still consumes the byte budget.
19+func DiffChangedLineLimits(t *testing.T, mk Maker) {
20+ g, data := mk(t)
21+ f := NewFixture(t, data, "diff-context-budget")
22+ var before, after strings.Builder
23+ const hunks = 96
24+ for i := range hunks * 10 {
25+ line := fmt.Sprintf("context %04d", i)
26+ fmt.Fprintln(&before, line)
27+ if i%10 == 4 {
28+ line = fmt.Sprintf("changed %04d", i)
29+ }
30+ fmt.Fprintln(&after, line)
31+ }
32+ f.Write("many", before.String()).Commit("before")
33+ f.Write("many", after.String()).Commit("after")
34+ limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20}
35+ d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
36+ require.NoError(t, err)
37+ require.Len(t, d.Diff.Files, 1)
38+ file := d.Diff.Files[0]
39+ require.False(t, file.Truncated)
40+ assert.False(t, d.Diff.Truncated)
41+ require.Len(t, file.Hunks, hunks)
42+ var changed, patchLines, textBytes, changedBytes int
43+ for _, h := range file.Hunks {
44+ for _, l := range h.Lines {
45+ patchLines++
46+ textBytes += len(l.Text)
47+ if l.Kind != domain.LineContext {
48+ changed++
49+ changedBytes += len(l.Text)
50+ }
51+ }
52+ }
53+ assert.Equal(t, hunks*2, changed)
54+ assert.Greater(t, patchLines, limits.MaxFileLines)
55+ assert.Equal(t, hunks, file.Additions)
56+ assert.Equal(t, hunks, file.Deletions)
57+ // The exact byte boundary fits; one fewer byte must discard the file even
58+ // though every changed line's text alone easily fits.
59+ limits.MaxTotalBytes = textBytes
60+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
61+ require.NoError(t, err)
62+ assert.False(t, d.Diff.Files[0].Truncated)
63+ limits.MaxTotalBytes--
64+ require.Greater(t, limits.MaxTotalBytes, changedBytes)
65+ d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits)
66+ require.NoError(t, err)
67+ assert.True(t, d.Diff.Truncated)
68+ assert.True(t, d.Diff.Files[0].Truncated)
69+ assert.Empty(t, d.Diff.Files[0].Hunks)
70+}

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 4

Tags 45

↑↓ moveenter openesc close