Commit ddab521653

ddab52165310177c117b89a07d5b155e56fb56dc

parent: 0a4bb99d63

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 01:39 UTC

web: a diff cut at 4 MiB says so, and the commit list says how many it hides

Diff and ShowPatch cut the patch at the byte limit mid-line and
reported nothing, so the last hunk rendered wrong and the diffstat
under-reported. Both now cut on a line boundary and return whether
they did; the commit and merge request pages show a notice and the
diffstat says its counts are partial; mr diff and repo commit say so
on stderr. The merge request's commit list is capped at 100 and now
says "first 100 of N".

Closes #117

Layout: unified · split

internal/control/mr.go +4 −1
@@ -548,11 +548,14 @@ func runMRDiff(c *Ctx, args []string) int {
548 } 548 }
549 base = b 549 base = b
550 } 550 }
551 patch, err := gitutil.Diff(dir, base, head, 4<<20) 551 patch, truncated, err := gitutil.Diff(dir, base, head, 4<<20)
552 if err != nil { 552 if err != nil {
553 return c.fail(protocol.ExitFailure, "%v", err) 553 return c.fail(protocol.ExitFailure, "%v", err)
554 } 554 }
555 fmt.Fprint(c.Stdout, patch) 555 fmt.Fprint(c.Stdout, patch)
556 if truncated {
557 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB; fetch the branch for the rest")
558 }
556 return protocol.ExitOK 559 return protocol.ExitOK
557} 560}
558 561
internal/control/sig.go +4 −1
@@ -246,10 +246,13 @@ func runRepoCommit(c *Ctx, args []string) int {
246 if err != nil { 246 if err != nil {
247 return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err) 247 return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
248 } 248 }
249 patch, err := gitutil.ShowPatch(dir, full, 4<<20) 249 patch, truncated, err := gitutil.ShowPatch(dir, full, 4<<20)
250 if err != nil { 250 if err != nil {
251 return c.fail(protocol.ExitFailure, "%v", err) 251 return c.fail(protocol.ExitFailure, "%v", err)
252 } 252 }
253 if truncated {
254 fmt.Fprintln(c.Stderr, "patch truncated at 4 MiB; clone the repository for the rest")
255 }
253 statuses, err := c.Store.ListCommitStatuses(repo.ID, full) 256 statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
254 if err != nil { 257 if err != nil {
255 return c.fail(protocol.ExitFailure, "%v", err) 258 return c.fail(protocol.ExitFailure, "%v", err)
internal/gitutil/endofoptions_test.go +1 −1
@@ -35,7 +35,7 @@ func TestRefsAreNotOptions(t *testing.T) {
35 "Archive": func() error { return Archive(dir, ref, "x", &sink) }, 35 "Archive": func() error { return Archive(dir, ref, "x", &sink) },
36 "Grep": func() error { _, err := Grep(dir, ref, "hi", 10); return err }, 36 "Grep": func() error { _, err := Grep(dir, ref, "hi", 10); return err },
37 "MergeBase": func() error { _, err := MergeBase(dir, ref, "main"); return err }, 37 "MergeBase": func() error { _, err := MergeBase(dir, ref, "main"); return err },
38 "Diff": func() error { _, err := Diff(dir, ref, "main", 1<<20); return err }, 38 "Diff": func() error { _, _, err := Diff(dir, ref, "main", 1<<20); return err },
39 "DiffFiles": func() error { _, err := DiffFiles(dir, ref, "main"); return err }, 39 "DiffFiles": func() error { _, err := DiffFiles(dir, ref, "main"); return err },
40 "RevListRange": func() error { _, err := RevListRange(dir, "main", ref); return err }, 40 "RevListRange": func() error { _, err := RevListRange(dir, "main", ref); return err },
41 "Blame": func() error { _, err := Blame(dir, ref, "f.txt", 1, 1); return err }, 41 "Blame": func() error { _, err := Blame(dir, ref, "f.txt", 1, 1); return err },
internal/gitutil/merge.go +20 −6
@@ -1,6 +1,7 @@
1package gitutil 1package gitutil
2 2
3import ( 3import (
4 "bytes"
4 "fmt" 5 "fmt"
5 "os" 6 "os"
6 "os/exec" 7 "os/exec"
@@ -93,17 +94,30 @@ func CommitTree(dir, tree string, parents []string, name, email, message string)
93} 94}
94 95
95// Diff returns the patch for old..new (three-dot semantics are the caller's 96// Diff returns the patch for old..new (three-dot semantics are the caller's
96// job: pass the merge base as old). 97// job: pass the merge base as old), cut at limit bytes on a line boundary;
97func Diff(dir, old, new string, limit int64) (string, error) { 98// truncated says whether it was cut, so the caller can say so instead of
99// rendering a hunk that ends mid-line (#117).
100func Diff(dir, old, new string, limit int64) (patch string, truncated bool, err error) {
98 cmd := exec.Command("git", "-C", dir, "diff", "--stat", "--patch", "--end-of-options", old, new) 101 cmd := exec.Command("git", "-C", dir, "diff", "--stat", "--patch", "--end-of-options", old, new)
99 out, err := cmd.Output() 102 out, err := cmd.Output()
100 if err != nil { 103 if err != nil {
101 return "", fmt.Errorf("diff: %w", err) 104 return "", false, fmt.Errorf("diff: %w", err)
102 } 105 }
103 if int64(len(out)) > limit { 106 out, truncated = cutAtLine(out, limit)
104 out = out[:limit] 107 return string(out), truncated, nil
108}
109
110// cutAtLine keeps at most limit bytes, ending on the last newline before
111// the limit.
112func cutAtLine(out []byte, limit int64) ([]byte, bool) {
113 if int64(len(out)) <= limit {
114 return out, false
115 }
116 cut := out[:limit]
117 if i := bytes.LastIndexByte(cut, '\n'); i >= 0 {
118 cut = cut[:i+1]
105 } 119 }
106 return string(out), nil 120 return cut, true
107} 121}
108 122
109// MergeBase returns the best common ancestor, or an error if none exists. 123// MergeBase returns the best common ancestor, or an error if none exists.
internal/gitutil/read.go +9 −7
@@ -151,21 +151,23 @@ func (c *cappedWriter) Write(p []byte) (int, error) {
151} 151}
152 152
153// ShowPatch returns the stat+patch text for one commit. 153// ShowPatch returns the stat+patch text for one commit.
154func ShowPatch(dir, sha string, limit int64) (string, error) { 154func ShowPatch(dir, sha string, limit int64) (patch string, truncated bool, err error) {
155 cmd := exec.Command("git", "-C", dir, "show", "--stat", "--patch", "--format=", sha) 155 cmd := exec.Command("git", "-C", dir, "show", "--stat", "--patch", "--format=", "--end-of-options", sha)
156 stdout, err := cmd.StdoutPipe() 156 stdout, err := cmd.StdoutPipe()
157 if err != nil { 157 if err != nil {
158 return "", err 158 return "", false, err
159 } 159 }
160 if err := cmd.Start(); err != nil { 160 if err := cmd.Start(); err != nil {
161 return "", err 161 return "", false, err
162 } 162 }
163 data, _ := io.ReadAll(io.LimitReader(stdout, limit)) 163 // One byte past the limit says whether there was more.
164 data, _ := io.ReadAll(io.LimitReader(stdout, limit+1))
164 io.Copy(io.Discard, stdout) 165 io.Copy(io.Discard, stdout)
165 if err := cmd.Wait(); err != nil { 166 if err := cmd.Wait(); err != nil {
166 return "", fmt.Errorf("show %s: %w", sha, err) 167 return "", false, fmt.Errorf("show %s: %w", sha, err)
167 } 168 }
168 return string(data), nil 169 data, truncated = cutAtLine(data, limit)
170 return string(data), truncated, nil
169} 171}
170 172
171// IsBinary reports whether data looks like binary content. 173// IsBinary reports whether data looks like binary content.
internal/httpd/web.go +11 −5
@@ -1448,7 +1448,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1448 s.notFound(w, r) 1448 s.notFound(w, r)
1449 return 1449 return
1450 } 1450 }
1451 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) 1451 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1452 files := parseDiff(patch) 1452 files := parseDiff(patch)
1453 committerEmail := "" 1453 committerEmail := ""
1454 if parsed.CommitterEmail != parsed.AuthorEmail { 1454 if parsed.CommitterEmail != parsed.AuthorEmail {
@@ -1468,9 +1468,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1468 Sig sigView 1468 Sig sigView
1469 Checks []store.CommitStatus 1469 Checks []store.CommitStatus
1470 DiffFiles []diffFile 1470 DiffFiles []diffFile
1471 DiffTruncated bool
1471 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail, 1472 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1472 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, 1473 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1473 gitutil.Parents(p.Dir, full), v, checks, files}) 1474 gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1474} 1475}
1475 1476
1476// labelPalette provides default label chip colors: mid-tone hues that stay 1477// labelPalette provides default label chip colors: mid-tone hues that stay
@@ -1661,9 +1662,10 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1661 base = b 1662 base = b
1662 } 1663 }
1663 } 1664 }
1665 var diffTruncated bool
1664 if base != "" { 1666 if base != "" {
1665 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil { 1667 if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1666 files = parseDiff(patch) 1668 files, diffTruncated = parseDiff(patch), truncated
1667 } 1669 }
1668 } 1670 }
1669 md := s.ugcFor(r, p.Repo) 1671 md := s.ugcFor(r, p.Repo)
@@ -1682,9 +1684,11 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1682 } 1684 }
1683 mrNames := s.authorNames() 1685 mrNames := s.authorNames()
1684 var commits []commitRow 1686 var commits []commitRow
1687 commitsTotal := 0
1685 if base != "" { 1688 if base != "" {
1686 const maxMRCommits = 100 1689 const maxMRCommits = 100
1687 shas, _ := gitutil.RevListRange(p.Dir, base, headRef) 1690 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1691 commitsTotal = len(shas)
1688 if len(shas) > maxMRCommits { 1692 if len(shas) > maxMRCommits {
1689 shas = shas[:maxMRCommits] 1693 shas = shas[:maxMRCommits]
1690 } 1694 }
@@ -1730,8 +1734,10 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1730 Comments []renderedComment 1734 Comments []renderedComment
1731 Reviews []store.MRReview 1735 Reviews []store.MRReview
1732 DiffFiles []diffFile 1736 DiffFiles []diffFile
1737 DiffTruncated bool
1733 Stat diffStat 1738 Stat diffStat
1734 Commits []commitRow 1739 Commits []commitRow
1740 CommitsTotal int
1735 Branches []gitutil.Ref 1741 Branches []gitutil.Ref
1736 CanEdit bool 1742 CanEdit bool
1737 CanWrite bool 1743 CanWrite bool
@@ -1741,7 +1747,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1741 StackedOn *store.MR 1747 StackedOn *store.MR
1742 Stacked []store.MR 1748 Stacked []store.MR
1743 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 1749 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1744 reviews, files, stat, commits, branches, s.canEditItem(r, p.Repo, m.Author), 1750 reviews, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1745 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads, stackedOn, stacked}) 1751 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads, stackedOn, stacked})
1746} 1752}
1747 1753
internal/web/templates/commit.html +1
@@ -9,5 +9,6 @@
9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p> 9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
10</div> 10</div>
11<pre class="message">{{.Message}}</pre> 11<pre class="message">{{.Message}}</pre>
12{{if .DiffTruncated}}<p class="error" role="alert">This patch is larger than 4 MiB and is cut off below. Clone the repository to see all of it.</p>{{end}}
12{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}} 13{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}}
13{{end}} 14{{end}}
internal/web/templates/mr.html +3 −1
@@ -57,6 +57,7 @@
57{{end}} 57{{end}}
58 58
59{{else if eq .View "commits"}} 59{{else if eq .View "commits"}}
60{{if gt .CommitsTotal (len .Commits)}}<p class="meta">first {{len .Commits}} of {{.CommitsTotal}} commits; the rest are in the branch</p>{{end}}
60<ul class="loglist"> 61<ul class="loglist">
61{{range .Commits}}<li> 62{{range .Commits}}<li>
62 <div class="commitmain"> 63 <div class="commitmain">
@@ -73,7 +74,8 @@
73</ul> 74</ul>
74 75
75{{else}} 76{{else}}
76<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></p> 77<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
78{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}}
77{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}} 79{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}}
78{{end}} 80{{end}}
79 81