Repository navigation
Expand file tree
/
Copy pathmain.go
More file actions
973 lines (927 loc) · 42.3 KB
/
Copy pathmain.go
File metadata and controls
973 lines (927 loc) · 42.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
package main
import (
"bufio"
"bytes"
"context"
"flag"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const (
defaultMaxLines = 1000
defaultBypassLabel = "oversized-ok"
modeEnforce = "enforce"
modeWarn = "warn"
// maxScanBytes bounds how much of a file we read looking for the generated
// marker (which sits at the top). It caps memory/time so a PR cannot point a
// "generated" path at an unbounded or blocking target and hang the job.
maxScanBytes = 4 << 20 // 4 MiB
// maxStderrBytes caps captured git stderr. cmd.Output() installs a 32 KiB
// prefixSuffixSaver on stderr only when it is nil; runGitStdin sets its own
// stderr writer, so it re-adds an equivalent cap to keep a git command that
// floods stderr from buffering unbounded memory.
maxStderrBytes = 32 << 10 // 32 KiB
)
func main() {
base := flag.String("base", "", "base git ref to diff against (e.g. origin/main); required")
head := flag.String("head", "HEAD", "head git ref of the PR")
maxFlag := flag.Int("max", envInt("PR_SIZE_MAX_LINES", defaultMaxLines), "max non-generated changed lines allowed")
bypassFlag := flag.Bool("bypass", envBool("PR_SIZE_BYPASS"), "bypass the cap (set when the bypass label is present)")
modeFlag := flag.String("mode", envStr("PR_SIZE_MODE", modeEnforce), "'enforce' exits non-zero when over the cap; 'warn' reports without failing")
bypassLabel := flag.String("bypass-label", envStr("PR_SIZE_BYPASS_LABEL", defaultBypassLabel), "PR label name the report offers as the bypass")
excludeTests := flag.Bool("exclude-tests", envBool("PR_SIZE_EXCLUDE_TESTS"), "keep test-file lines out of the counted total (they are still reported)")
extraLockfiles := flag.String("extra-lockfiles", os.Getenv("PR_SIZE_EXTRA_LOCKFILES"), "extra lockfile base names to exclude (whitespace/comma separated)")
extraGlobs := flag.String("extra-generated-globs", os.Getenv("PR_SIZE_EXTRA_GENERATED_GLOBS"), "extra glob patterns treated as generated (whitespace/comma separated)")
reviewedDiffOut := flag.String("reviewed-diff-out", "", "if set and the size check passes, also write the PR's unified diff with all generated-file sections excluded to this file (for a downstream review consumer, e.g. cursor-review); a failure writing it exits 2 so the consumer never pairs this run's counts with a missing/partial diff")
diffExcludes := flag.String("diff-excludes", "", "extra git pathspecs (whitespace-separated, passed to git verbatim) applied ONLY when building --reviewed-diff-out; never affects the size verdict")
markerFromBase := flag.Bool("marker-from-base", false, "read the Go generated-code marker from the BASE blob instead of the PR head, so a PR cannot self-exempt a file by adding the marker; files new in the PR then never match the marker (path- and attribute-based rules still apply), which is strictly conservative — set this when --reviewed-diff-out feeds a review that must not be evadable")
ignoreComments := flag.Bool("ignore-comments", envBool("PR_SIZE_IGNORE_COMMENTS"), "exclude blank and comment-only changed lines from the counted total (heuristic, count-only; never affects generated classification or --reviewed-diff-out)")
flag.Parse()
if *base == "" {
fmt.Fprintln(os.Stderr, "check-pr-size: --base is required")
os.Exit(2)
}
if *modeFlag != modeEnforce && *modeFlag != modeWarn {
fmt.Fprintf(os.Stderr, "check-pr-size: --mode must be %q or %q, got %q\n", modeEnforce, modeWarn, *modeFlag)
os.Exit(2)
}
extras, err := ParseExtras(*extraLockfiles, *extraGlobs)
if err != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: %v\n", err)
os.Exit(2)
}
mergeBase, err := resolveMergeBase(*base, *head)
if err != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: %v\n", err)
os.Exit(2)
}
fmt.Fprintf(os.Stderr, "check-pr-size: merge base %s\n", mergeBase)
files, err := diffFiles(mergeBase, *head)
if err != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: %v\n", err)
os.Exit(2)
}
// The linguist-generated attribute is read from the merge-base tree, never
// the PR head, so a PR cannot add `* linguist-generated=true` to
// .gitattributes and shrink its own counted LoC. Two defense-in-depth
// conditions disable the attribute path entirely (unless the bypass label is
// present): git too old to honor `check-attr --source`, or the PR diff
// touching .gitattributes at all. See attrGeneratedBatch / TouchesGitattributes.
attr := attrPolicy{source: mergeBase, useSource: checkAttrSourceSupported(mergeBase)}
attrModified := TouchesGitattributes(files)
attr.trusted = attrTrusted(attr.useSource, attrModified, *bypassFlag)
classify(files, mergeBase, *head, attr, extras, *markerFromBase)
// Discount blank/comment-only changed lines from the count (opt-in). Runs
// after classify so it only ever inspects non-generated, non-binary files.
if *ignoreComments {
annotateDiscounts(files, mergeBase, *head)
}
res := Evaluate(files, Policy{Max: *maxFlag, Bypassed: *bypassFlag, ExcludeTests: *excludeTests})
if !res.OK && !attr.trusted {
// The check is failing and we ignored linguist-generated exclusions; tell
// the contributor every reason so that dropping one (e.g. the .gitattributes
// edit) does not surprise them with the next (an old-git runner).
var reasons []string
if attrModified {
reasons = append(reasons, "this PR modifies `.gitattributes`")
}
if !attr.useSource {
reasons = append(reasons, "this CI runner's git is too old to read `linguist-generated` from the base ref")
}
if len(reasons) > 0 {
res.Note = fmt.Sprintf("`linguist-generated` exclusions were not applied because %s. Add the `%s` label if this large change is intentional.", strings.Join(reasons, " and "), *bypassLabel)
}
}
report(res, *modeFlag, *bypassLabel)
writeGitHubOutputs(res)
// Emit the reviewed diff for a downstream consumer (the cursor-review
// workflow feeds it to the review panel + judge): the PR's full patch with
// every generated file's section filtered out, reusing THIS classification
// as the single source of truth for "what is codegen" — the count and the
// reviewed diff come from one process and can never disagree. Written last;
// a write failure exits 2 (after removing any partial file) so the consumer
// sees a degraded run rather than pairing this run's counts with a
// missing/truncated diff. Skipped when over the cap — nothing downstream
// consumes a diff the gate is about to skip.
if *reviewedDiffOut != "" {
if res.OK {
if err := writeReviewedDiff(mergeBase, *head, strings.Fields(*diffExcludes), files, *reviewedDiffOut); err != nil {
os.Remove(*reviewedDiffOut)
fmt.Fprintf(os.Stderr, "check-pr-size: could not write --reviewed-diff-out %q: %v\n", *reviewedDiffOut, err)
os.Exit(2)
}
} else {
fmt.Fprintln(os.Stderr, "check-pr-size: over the cap — skipping --reviewed-diff-out")
}
}
if shouldFail(res, *modeFlag) {
os.Exit(1)
}
}
// resolveMergeBase returns the commit where the PR head diverged from the base
// history. The supplied base may be today's target-branch tip, well ahead of an
// unrebased PR; none of those later base-only commits belong in the PR review.
func resolveMergeBase(base, head string) (string, error) {
out, err := runGit("merge-base", base, head)
if err != nil {
return "", fmt.Errorf("could not resolve merge base: %w", err)
}
mergeBase := strings.TrimSpace(out)
if mergeBase == "" || strings.ContainsAny(mergeBase, "\r\n") {
return "", fmt.Errorf("could not resolve merge base: git returned %q", mergeBase)
}
return mergeBase, nil
}
// writeReviewedDiff streams `git diff base...head` (plus any caller-supplied
// verbatim exclude pathspecs) through FilterPatch, writing the patch minus
// every generated file's section to outPath. The generated set is passed to
// git by FILTERING ITS OUTPUT, never as per-file argv pathspecs, so an
// unbounded number of generated files (they cost nothing against the cap)
// cannot overflow ARG_MAX and fail the diff. core.quotePath=false keeps
// non-ASCII paths verbatim in the headers so they match the numstat-derived
// classification; a path git still quotes (control chars, quotes) simply won't
// match and its section is kept — reviewed, never hidden.
func writeReviewedDiff(base, head string, diffExcludes []string, files []FileChange, outPath string) error {
gen := make(map[string]bool)
for _, f := range files {
if f.Generated {
gen[f.Path] = true
}
}
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
args := append([]string{"-c", "core.quotePath=false", "diff", base + "..." + head, "--", "."}, diffExcludes...)
cmd := exec.CommandContext(ctx, "git", args...)
cmd.WaitDelay = gitWaitDelay
errBuf := &capWriter{cap: maxStderrBytes}
cmd.Stderr = errBuf
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
if err := cmd.Start(); err != nil {
return err
}
bw := bufio.NewWriter(out)
kept, dropped, ferr := FilterPatch(stdout, bw, func(p string) bool { return gen[p] })
if ferr != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return ferr
}
if err := cmd.Wait(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
err = fmt.Errorf("git %v timed out after %s: %w", args, gitTimeout, ctx.Err())
}
if s := strings.TrimSpace(errBuf.String()); s != "" {
err = fmt.Errorf("%w: %s", err, s)
}
return err
}
if err := bw.Flush(); err != nil {
return err
}
if err := out.Close(); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "check-pr-size: reviewed diff written to %s (%d file section(s) kept, %d generated section(s) excluded)\n", outPath, kept, dropped)
return nil
}
// shouldFail reports whether the process should exit non-zero: over the cap
// (and not bypassed) in enforce mode. Warn mode never fails — it reports and
// lets the workflow's comment job surface the overage.
func shouldFail(res Result, mode string) bool {
return !res.OK && mode == modeEnforce
}
// diffFiles runs `git diff --numstat` for the PR's net changes (three-dot: from
// the merge-base of base and head, to head) and parses the result.
func diffFiles(base, head string) ([]FileChange, error) {
out, err := runGit("diff", "--numstat", "-z", base+"..."+head)
if err != nil {
return nil, fmt.Errorf("git diff failed: %w", err)
}
return ParseNumstat(strings.NewReader(out))
}
// annotateDiscounts sets FileChange.Discounted for each non-generated, non-binary
// file to the number of its changed lines that are blank or comment-only, so the
// counted total reflects significant lines. Count-only: it never changes which
// files are generated, nor any diff a consumer builds from --generated-out.
//
// Best-effort by design: on any git/parse failure it leaves Discounted at 0, so
// that file counts raw — a failure can only make the cap STRICTER, never sneak a
// large change under it. Restricts the patch to the counted paths, so a large
// generated diff is never fetched or parsed.
//
// A RENAME contributes BOTH of its paths to that pathspec. Naming only the
// destination hides the deletion side from the filtered diff, so git cannot pair
// the rename and re-emits the destination as a whole-file ADDITION — every
// blank/comment line in the file then counts as a discount, and the `d >
// Changed()` clamp below drives the rename's Counted() to 0. That would silently
// cancel classify()'s both-paths rename guard for any caller running with
// --ignore-comments: `git mv handwritten/big.go gen/big.go` plus a large edit is
// correctly classified non-generated and then discounted back down to nothing.
// Discounts stay keyed by DESTINATION (idx holds f.Path only), matching the
// paired diff's `+++` header; an unpaired old-path section finds no idx entry
// and is skipped, so the file just counts raw.
func annotateDiscounts(files []FileChange, base, head string) {
idx := make(map[string]*FileChange, len(files))
paths := make([]string, 0, len(files))
for i := range files {
f := &files[i]
if f.Generated || f.Binary {
continue
}
paths = append(paths, f.Path)
if f.OldPath != "" {
paths = append(paths, f.OldPath)
}
idx[f.Path] = f
}
if len(paths) == 0 {
return
}
patch, err := runGitDiffPatch(base, head, paths)
if err != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: comment discounting skipped (git diff failed): %v\n", err)
return
}
discounts, err := ParseDiscounts(strings.NewReader(patch))
if err != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: comment discounting skipped (parse failed): %v\n", err)
return
}
for p, d := range discounts {
f, ok := idx[p]
if !ok {
continue // header path git quoted, or otherwise unmatched — count raw
}
if d > f.Changed() {
d = f.Changed() // clamp: never discount more than the file changed
}
f.Discounted = d
}
}
// runGitDiffPatch returns the unified-diff patch for the PR's net changes (three
// dot) restricted to the given paths. core.quotePath=false keeps non-ASCII paths
// unquoted so ParseDiscounts can read them back; the :(literal) pathspec magic
// disables wildcards so a filename with glob metacharacters is matched literally.
func runGitDiffPatch(base, head string, paths []string) (string, error) {
args := []string{"-c", "core.quotePath=false", "diff", base + "..." + head, "--"}
for _, p := range paths {
args = append(args, ":(literal)"+p)
}
return runGit(args...)
}
// attrPolicy controls how the linguist-generated git attribute is consulted.
// source is the tree-ish whose .gitattributes rules are read (the base ref);
// useSource is whether the installed git honors `check-attr --source` (git
// >=2.40); trusted is whether attribute-based exclusion may be applied at all
// (false disables it, so a PR-introduced rule cannot shrink the count).
type attrPolicy struct {
source string
useSource bool
trusted bool
}
// attrTrusted decides whether linguist-generated attribute exclusions may be
// applied. It always requires useSource, so attributes are only ever read from
// the base ref via `--source` and never from the PR-head working tree. The
// bypass label may override the .gitattributes-touched gate, but must NOT
// re-enable head-controlled reading on a runner too old for `--source` — doing
// so would make the reported excluded/counted numbers head-manipulable,
// breaking the base-only invariant even though the check passes on bypass.
func attrTrusted(useSource, attrModified, bypass bool) bool {
return useSource && (!attrModified || bypass)
}
// classify sets FileChange.Generated for each file using, in order: the
// lockfile name lists (built-in + extras), the caller-supplied generated globs,
// the linguist-generated git attribute (read from the base ref per attr), and
// the canonical Go generated marker in the file's content. It also sets
// FileChange.Test from the path's naming convention (see IsTestPath) — always,
// regardless of policy, so the report can show the test total whether or not the
// caller opted to exclude it.
//
// A RENAME is classified on BOTH its old and new path for every PATH- and
// ATTRIBUTE-based exclusion bucket this function decides — lockfile,
// extra-generated glob, the linguist-generated attribute, and test. (The
// content-marker bucket is destination-only; see contentGenerated for why that
// is not a rename hole.)
// `git diff --numstat` books a rename's deletions against the destination, so
// classifying on the destination alone would let e.g. `git mv src/big.go
// tests/big.go` (or `git mv src/big.go go.sum`) charge removed PRODUCTION
// lines to an excluded bucket, silently slipping the change under the cap.
// The attribute bucket has the same hole with an extra sting: a rename into a
// `linguist-generated` tree hides the moved file from the --reviewed-diff-out
// patch as well as from the count, so an edit made in the same commit reaches
// neither the size verdict nor the review panel.
// Requiring both paths to agree keeps every one of these failures in the safe,
// over-counting direction.
//
// KNOWN LIMIT of the both-paths rule: it can only be as good as git's rename
// pairing, which an adversary with commit access to the base can bias. If the
// generated tree already holds a file byte-identical at base to the one being
// moved in, moving both in one PR lets the exact-rename phase pair the untouched
// destination with the hand-written source, leaving the EDITED destination
// paired with the in-tree generated source — both attribute-carrying, so the
// edit is excluded again. It needs a same-content generated file planted at
// base, so it is a documented residual, not the reported defect.
//
// Binary files are classified by the path- and attribute-based rules too (none
// of those read content), so a binary lockfile or a binary matching an extra
// glob is still excluded from a --reviewed-diff-out consumer's diff; only the
// content marker read is skipped for them. Their Changed() is 0 either way, so
// the size verdict itself is untouched.
//
// The linguist-generated attribute is resolved for every path in a SINGLE
// `git check-attr` pass (attrGeneratedBatch) rather than one subprocess per
// file, so a large PR pays constant process-creation cost instead of O(N).
func classify(files []FileChange, base, head string, attr attrPolicy, extras Extras, markerFromBase bool) {
var attrGen map[string]bool
if attr.trusted {
paths := make([]string, 0, len(files))
for i := range files {
paths = append(paths, files[i].Path)
// A rename's source path needs its attribute resolved too, or the
// both-paths check below has nothing to read. check-attr resolves
// rules for arbitrary path strings, so batching it is safe whether
// or not the path still exists.
if files[i].OldPath != "" {
paths = append(paths, files[i].OldPath)
}
}
attrGen = attrGeneratedBatch(paths, attr.source, attr.useSource)
}
for i := range files {
f := &files[i]
// A rename's OldPath must also match for Test to hold — see the
// both-paths note above.
f.Test = IsTestPath(f.Path) && (f.OldPath == "" || IsTestPath(f.OldPath))
lockfile := IsLockfile(f.Path) && (f.OldPath == "" || IsLockfile(f.OldPath))
extraGen := extras.Generated(f.Path) && (f.OldPath == "" || extras.Generated(f.OldPath))
attrGenOK := attrGen[f.Path] && (f.OldPath == "" || attrGen[f.OldPath])
if lockfile || extraGen || attrGenOK ||
(!f.Binary && contentGenerated(f.Path, base, head, markerFromBase)) {
f.Generated = true
}
}
}
// attrGeneratedBatch reports, per path, whether .gitattributes marks it
// linguist-generated, resolving every path in ONE `git check-attr` pass (paths
// fed on stdin) instead of one subprocess per file. The returned map contains an
// entry only for paths git reported as generated ("true" for an explicit "=true"
// or "set" for a bare attribute); callers treat an absent path as not-generated,
// matching the old err→false fallback.
//
// When useSource is set the rules are read from source (the base ref) via
// `--source`, so .gitattributes rules introduced by the PR head are never
// consulted — a PR cannot mark arbitrary hand-written files generated to escape
// the size count. `--source` needs git >= 2.40 (GitHub runners ship newer); on
// older git useSource is false and `--source` is omitted (the caller only
// invokes this on a runner where the attribute path is trusted; see attrPolicy).
func attrGeneratedBatch(paths []string, source string, useSource bool) map[string]bool {
result := make(map[string]bool, len(paths))
if len(paths) == 0 {
return result
}
// `-z` uses NUL as the separator for both stdin paths and output fields, so a
// path containing spaces/newlines (already possible from the -z numstat that
// produced these) round-trips safely.
args := []string{"check-attr", "-z"}
if useSource {
args = append(args, "--source", source)
}
args = append(args, "--stdin", "linguist-generated")
var stdin bytes.Buffer
for _, p := range paths {
stdin.WriteString(p)
stdin.WriteByte(0)
}
out, _, err := runGitStdin(stdin.Bytes(), args...)
if err != nil {
return result
}
// `-z` output is a flat NUL-separated stream of (path, attribute, value)
// triples, with a trailing NUL after the final value (so Split yields an
// empty tail element the triple loop ignores).
fields := strings.Split(out, "\x00")
for i := 0; i+2 < len(fields); i += 3 {
if v := fields[i+2]; v == "true" || v == "set" {
result[fields[i]] = true
}
}
return result
}
// checkAttrSourceSupported reports whether the installed git honors
// `check-attr --source` (added in git 2.40). Older git rejects the flag as an
// unknown option and exits non-zero, so we probe once rather than parse the
// version string. The probe path need not exist; check-attr resolves rules for
// arbitrary path strings.
//
// Only git's own "unknown flag" signature counts as unsupported. Any OTHER probe
// failure (a transient git error, an unreadable base ref) leaves --source
// trusted: reporting a modern-but-flaky git as "too old" would silently drop
// every base linguist-generated exclusion and emit a misleading "runner's git is
// too old" note (BE-3247). On such an unrelated failure the reads stay
// conservative anyway — attrGeneratedBatch's own --source call fails closed, so
// files are over-counted (never under-counted), and the size cap can only be too
// strict, never too loose.
func checkAttrSourceSupported(source string) bool {
_, stderr, err := runGitStdin(nil, "check-attr", "--source", source, "linguist-generated", "--", ".gitattributes")
if err == nil {
return true
}
// A genuine unknown-flag rejection => --source truly unsupported. Anything
// else is an unrelated failure and must not disable the base-ref attribute path.
return !isUnknownFlagError(stderr)
}
// isUnknownFlagError reports whether git's stderr indicates it rejected an option
// as unrecognized. git's parse-options prints "unknown option" for long flags and
// "unknown switch" for short ones; matching is case-insensitive and substring-based
// so it tolerates the surrounding usage text and minor wording drift across git
// versions. The stderr comes from runGitStdin, which forces LC_ALL=C, so this
// only ever sees git's canonical English wording (BE-3247).
func isUnknownFlagError(stderr string) bool {
s := strings.ToLower(stderr)
return strings.Contains(s, "unknown option") || strings.Contains(s, "unknown switch")
}
// contentGenerated reports whether a .go file carries Go's canonical generated
// marker before its package clause. Only .go files are consulted: the marker's
// anti-gaming guarantee relies on the package-clause gate (see IsGeneratedContent),
// which non-Go files lack, so a contributor cannot exclude a large hand-written
// non-Go file by pasting the marker at its top. Working-tree reads never follow a
// symlink and are capped at maxScanBytes, so a PR cannot point a "generated" path
// at an unbounded/blocking target (e.g. /dev/zero) to hang or OOM the job.
//
// With markerFromBase set, ONLY the base blob is consulted — mirroring the
// base-ref invariant the linguist-generated attribute path already enforces.
// The head-content read is fine for a size cap (self-marking merely shrinks a
// count a maintainer can eyeball), but when the classification decides what a
// blocking review SEES, a head-honored marker would let a PR hide arbitrary
// code by prepending one comment line. Base-only means a file new in the PR
// never matches the marker (it is counted and reviewed — conservative); files
// generated at base stay excluded.
//
// Unlike classify's path/attribute buckets, this one reads the DESTINATION only,
// and deliberately so — a rename grants no evasion here that an in-place edit
// does not already grant. With markerFromBase (what --reviewed-diff-out sets) a
// rename's destination does not exist at base, the show fails, and the bucket
// returns false: it fails CLOSED, counting and reviewing the file. Without it,
// the head read can be self-marked — but `git mv handwritten/big.go gen/big.go`
// plus a pasted marker excludes exactly what pasting the marker into
// handwritten/big.go in place already excludes, so the move adds nothing. That
// residual is the accepted count-only tradeoff described above, not a rename
// hole; the fix for it is --marker-from-base, which every evadability-sensitive
// caller already passes.
func contentGenerated(path, base, head string, markerFromBase bool) bool {
if !strings.HasSuffix(path, ".go") {
return false
}
if markerFromBase {
if data, err := runGitCapped(maxScanBytes, "show", base+":"+path); err == nil {
return IsGeneratedContent(data)
}
return false
}
if info, err := os.Lstat(path); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return false
}
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, maxScanBytes))
if err != nil {
return false
}
return IsGeneratedContent(data)
}
// Not in the working tree (e.g. deleted): read the head/base blob so a
// shrinking regen of a generated file is still classified as generated.
// Capped at maxScanBytes just like the working-tree read above, so a
// deleted path pointing at an oversized blob can't balloon job memory.
for _, ref := range []string{head, base} {
if data, err := runGitCapped(maxScanBytes, "show", ref+":"+path); err == nil {
return IsGeneratedContent(data)
}
}
return false
}
// renderReport builds the markdown report for stdout, the step summary, and the
// sticky PR comment.
func renderReport(res Result, mode, bypassLabel string) string {
var b strings.Builder
status := "✅ Passed"
if !res.OK {
if mode == modeWarn {
status = "⚠️ Over cap (warn-only)"
} else {
status = "❌ Failed"
}
}
// What "counted" means depends on the policy, so the label and the over-cap
// sentence both spell it out — a number whose meaning shifted between repos
// (or between runs) is worse than no number.
counted, handWritten := "non-generated", "hand-written"
if res.TestsExcluded {
counted, handWritten = "non-generated, non-test", "hand-written non-test"
}
fmt.Fprintf(&b, "## %s — PR size check\n\n", status)
fmt.Fprintf(&b, "- Changed lines counted (%s): **%d**\n", counted, res.Counted)
fmt.Fprintf(&b, "- Cap: **%d**\n", res.Max)
fmt.Fprintf(&b, "- Excluded (generated/lockfiles): %d\n", res.Generated)
if res.Discounted > 0 {
fmt.Fprintf(&b, "- Excluded (blank/comment lines): %d\n", res.Discounted)
}
// Always surfaced, both ways round: an exclusion nobody can see is how a
// large test-only PR sails through unremarked.
if res.TestsExcluded {
fmt.Fprintf(&b, "- Excluded (tests): %d\n", res.Test)
} else if res.Test > 0 {
fmt.Fprintf(&b, "- Of the counted lines, %d are tests (`exclude_tests` is off)\n", res.Test)
}
if res.Bypassed {
fmt.Fprintf(&b, "- Bypassed via `%s` label ✅\n", bypassLabel)
}
if !res.OK {
fmt.Fprintf(&b, "\n**This PR changes %d lines of %s code, over the %d-line cap.**\n\n", res.Counted, handWritten, res.Max)
if mode == modeWarn {
b.WriteString("This check runs in `warn` mode, so it will not fail — but consider:\n")
} else {
b.WriteString("Options:\n")
}
b.WriteString("- Split it into smaller, independently reviewable PRs (stacked PRs help).\n")
fmt.Fprintf(&b, "- If the size is justified, add the `%s` label to bypass this check.\n", bypassLabel)
}
// The PR passes ONLY because tests were subtracted — say so unprompted. This
// is the case the whole exclusion-reporting exists for, and it is also the
// one where the check is green and nobody has a reason to look.
if res.OK && res.ExclusionDecisive() {
// "non-generated" is load-bearing: res.Generated is deliberately outside
// this sum, so calling it the PR's total would understate a diff that
// also regenerated a lockfile — in the one sentence whose whole job is
// making the real size visible.
fmt.Fprintf(&b, "\n**Under the cap only because test lines are excluded.** This PR changes %d non-generated lines (%d counted + %d test), over the %d-line cap; `exclude_tests` is what brings it under. Expected for a test-heavy change — surfaced so the real size is visible rather than implicit.\n",
res.Counted+res.Test, res.Counted, res.Test, res.Max)
}
if res.Note != "" {
fmt.Fprintf(&b, "\n> %s\n", res.Note)
}
// Largest contributors to Counted, for quick triage. Capped at 10, so this
// shows the biggest files rather than accounting for every counted line.
// Counted() (not Changed()) gates inclusion so a file whose only changes
// were fully discounted blank/comment lines — a real 0 contribution to
// Counted — does not clutter a list titled "counted".
b.WriteString(topFiles(res.Files, "Largest counted files", func(f FileChange) bool {
return !f.Generated && f.Counted() > 0 && !(res.TestsExcluded && f.Test)
}))
// When tests are excluded, name the biggest of them too — otherwise
// `Excluded (tests): N` is an unauditable number: a reviewer sees a large
// exclusion with no way to check the files really are tests.
if res.TestsExcluded {
b.WriteString(topFiles(res.Files, "Largest excluded test files", func(f FileChange) bool {
return !f.Generated && f.Counted() > 0 && f.Test
}))
}
return b.String()
}
// maxPathDisplay bounds one rendered path. Two 10-entry lists with unbounded
// paths could push the comment body past GitHub's 65,536-character limit, and
// the upsert step runs under continue-on-error — so an over-long body 422s and
// degrades SILENTLY to "no comment posted", which is precisely the unremarked
// pass the decisive-green comment exists to prevent.
const maxPathDisplay = 160
// sanitizePath makes a repo-relative path safe to interpolate into the markdown
// report. `git diff --numstat -z` emits paths VERBATIM (see ParseNumstat), so a
// path may legitimately contain backticks, newlines and other control bytes.
// Untreated, a PR author can name a file so the code span closes and forged
// lines land inside a BOT-authored comment — a second "✅ Passed" heading, a
// fake "Excluded (tests): 0", or another sticky-comment marker — and a newline
// reaching stdout can emit a `::` workflow command. Control characters are
// replaced, backticks neutralized, and the result bounded by maxPathDisplay.
// Unicode format characters (unicode.Cf) are replaced alongside the C0 controls:
// a bidi override (U+202A–U+202E) or isolate (U+2066–U+2069) makes a path render
// as a filename other than the one on disk, and can visually reorder the
// adjacent (+N/-M) counts — which would defeat the "Largest excluded test files"
// list, whose whole purpose is letting a reviewer confirm the excluded lines
// really are tests.
//
// Truncation is bounded by ACCUMULATED BYTES and only ever cuts at a rune
// boundary. Slicing the finished string at a byte offset would split a
// multi-byte rune and emit invalid UTF-8 into the comment body — which GitHub
// can 422 on, and continue-on-error would turn that into the silent no-comment
// degradation maxPathDisplay exists to prevent.
func sanitizePath(path string) string {
var b strings.Builder
for _, r := range path {
out := r
switch {
case r == '`':
// Would close the code span the caller wraps this in.
out = '\''
case unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r) ||
unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r):
// Cc covers C0 AND C1 — U+0085 (NEL) is a C1 control that renders as
// a line break, as do the Zl/Zp separators U+2028/U+2029, so all
// three are the same list-escape this function exists to block. Cf
// covers the invisible format characters (bidi overrides/isolates,
// ZWJ). U+009B (CSI) additionally lets ANSI sequences into the
// public run log even though ESC itself is a C0 control.
out = '?'
}
b.WriteRune(out)
}
return elideMiddle(b.String(), maxPathDisplay)
}
// elideMiddle bounds s to max bytes by dropping the MIDDLE, never the tail.
// Truncating the tail would discard the very evidence the report is being read
// for: a long path's classifying segment usually sits at the end, so
// `<150 bytes of prefix>/tests/prod.go` would render as a truncated
// production-looking path in the "Largest excluded test files" list — defeating
// the auditability that list exists to provide — and distinct paths sharing a
// prefix would render identically. Cuts land only on rune boundaries, so the
// result is always valid UTF-8.
func elideMiddle(s string, max int) string {
const ellipsis = "…"
if len(s) <= max {
return s
}
if max <= len(ellipsis) {
// Returning the ellipsis would EXCEED the bound this function promises.
// Unreachable at maxPathDisplay, but the helper is general and
// independently tested.
return ""
}
budget := max - len(ellipsis)
headBudget, tailBudget := budget/2, budget-budget/2
head := 0
for i := range s { // range over a string yields rune START offsets
if i > headBudget {
break
}
head = i
}
tail := len(s)
for i := len(s); i > 0; {
_, size := utf8.DecodeLastRuneInString(s[:i])
if len(s)-(i-size) > tailBudget {
break
}
i -= size
tail = i
}
if tail < head {
tail = head
}
return s[:head] + ellipsis + s[tail:]
}
// topFiles renders a collapsed list of up to 10 matching files, largest first
// (res.Files is already sorted by Counted descending). Returns "" when nothing
// matches, so the caller can append unconditionally.
func topFiles(files []FileChange, summary string, keep func(FileChange) bool) string {
var b strings.Builder
shown := 0
for _, f := range files {
if !keep(f) {
continue
}
if shown == 0 {
fmt.Fprintf(&b, "\n<details><summary>%s</summary>\n\n", summary)
}
fmt.Fprintf(&b, "- `%s` (+%d/-%d)\n", sanitizePath(f.Path), f.Added, f.Deleted)
shown++
if shown >= 10 {
break
}
}
if shown == 0 {
return ""
}
b.WriteString("\n</details>\n")
return b.String()
}
func report(res Result, mode, bypassLabel string) {
summary := renderReport(res, mode, bypassLabel)
fmt.Println(summary)
// Errors are surfaced, not swallowed: this PR makes the step summary the
// SOLE carrier of the excluded-test total wherever the bot comment cannot
// post (fork and Dependabot PRs never receive the secret), so a failed write
// silently removes those PRs' only surface.
if path := os.Getenv("GITHUB_STEP_SUMMARY"); path != "" {
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
if err != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: cannot open GITHUB_STEP_SUMMARY: %v\n", err)
return
}
_, werr := fmt.Fprintln(f, summary)
cerr := f.Close()
if werr != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: writing GITHUB_STEP_SUMMARY failed: %v\n", werr)
}
if cerr != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: closing GITHUB_STEP_SUMMARY failed: %v\n", cerr)
}
}
}
// writeGitHubOutputs exposes the evaluation to later workflow steps via
// GITHUB_OUTPUT. over_cap drives the sticky-comment job, which must post on
// overage even in warn mode, where this process exits 0 and the job result
// alone cannot distinguish over from under. tests_decisive drives the same job
// in the opposite case — green, but green only because test lines were
// subtracted — which would otherwise post nothing and leave the excluded total
// visible only to someone who opens the Actions step summary. No-op outside
// GitHub Actions.
func writeGitHubOutputs(res Result) {
path := os.Getenv("GITHUB_OUTPUT")
if path == "" {
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
if err != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: cannot open GITHUB_OUTPUT: %v\n", err)
return
}
// tests_excluded reports what was actually EXCLUDED, so it is 0 under the
// default policy — there, res.Test is a subset of counted and naming it an
// exclusion would assert something that did not happen.
testsExcluded := 0
if res.TestsExcluded {
testsExcluded = res.Test
}
// Both errors are surfaced rather than dropped: a partial write can lose
// tests_decisive while the process still exits 0, and the comment job then
// reads the absent flag as false and silently skips the green-check comment
// — the very failure this output exists to close.
// max_lines is the APPLIED cap, so consumers annotate the number actually
// enforced rather than the raw input — envInt silently falls back to the
// default for any value Atoi rejects.
_, werr := fmt.Fprintf(f, "over_cap=%t\ncounted=%d\ntests_excluded=%d\ntests_decisive=%t\nmax_lines=%d\n",
!res.OK, res.Counted, testsExcluded, res.ExclusionDecisive(), res.Max)
cerr := f.Close()
if werr != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: writing GITHUB_OUTPUT failed: %v\n", werr)
}
if cerr != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: closing GITHUB_OUTPUT failed: %v\n", cerr)
}
}
// gitTimeout bounds every git invocation so a hung git (a wedged credential
// prompt, a stuck pack read, an unresponsive filter) cannot stall the job until
// the global CI runner timeout. It is deliberately generous — the heaviest call
// here is a single blob read — so a healthy git never trips it.
const gitTimeout = 60 * time.Second
// gitWaitDelay bounds how long Wait() may block after the timeout fires and the
// child is killed. exec.CommandContext only SIGKILLs the immediate git process;
// a descendant that inherited the stdout/stderr pipes (a diff/filter driver,
// textconv, pager, or credential helper) can hold them open, so the copy
// goroutines behind cmd.Output()/StdoutPipe never see EOF and Wait() would block
// past the deadline. WaitDelay (Go 1.20+) forces those pipes closed and Wait to
// return after the grace period, keeping the BE-3248 timeout robust.
const gitWaitDelay = 10 * time.Second
func runGit(args ...string) (string, error) {
out, stderr, err := runGitStdin(nil, args...)
// runGitStdin sets cmd.Stderr, so a returned *exec.ExitError no longer
// carries git's stderr (Cmd.Output only populates ExitError.Stderr when
// Stderr is nil). Fold the captured stderr back into the error so callers
// like diffFiles keep git's diagnostics.
if err != nil {
if s := strings.TrimSpace(stderr); s != "" {
err = fmt.Errorf("%w: %s", err, s)
}
}
return out, err
}
// runGitStdin runs git with the given args and optional stdin, returning stdout
// and stderr separately. Three behaviors matter to callers:
// - stderr is captured (a plain cmd.Output() discards it) and bounded to
// maxStderrBytes, so checkAttrSourceSupported can tell an old git's
// unknown-flag rejection from an unrelated failure (BE-3247).
// - the call runs under a gitTimeout deadline via exec.CommandContext; when it
// fires the child is killed and the timeout surfaces as an error, which
// callers already treat as "git failed" rather than returning stale data
// (BE-3248).
// - stdin, when non-nil, is fed to git — used for `check-attr --stdin`.
//
// git is forced into the C locale so its parse-options diagnostics (localized
// via gettext under LANG/LC_MESSAGES) stay in the canonical English wording
// isUnknownFlagError matches; the porcelain output we parse (-z numstat, -z
// check-attr, blob contents) is locale-independent.
func runGitStdin(stdin []byte, args ...string) (stdout, stderr string, err error) {
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "git", args...)
cmd.WaitDelay = gitWaitDelay
cmd.Env = append(os.Environ(), "LC_ALL=C")
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
errBuf := &capWriter{cap: maxStderrBytes}
cmd.Stderr = errBuf
out, err := cmd.Output()
if err != nil && ctx.Err() == context.DeadlineExceeded {
err = fmt.Errorf("git %v timed out after %s: %w", args, gitTimeout, ctx.Err())
}
return string(out), errBuf.String(), err
}
// capWriter is an io.Writer that retains at most cap bytes, silently discarding
// the overflow while still reporting a full write so the child process never
// sees a short-write error. It re-adds the stderr cap that cmd.Output() would
// otherwise provide (see maxStderrBytes).
type capWriter struct {
buf bytes.Buffer
cap int
}
func (w *capWriter) Write(p []byte) (int, error) {
if room := w.cap - w.buf.Len(); room > 0 {
if len(p) > room {
w.buf.Write(p[:room])
} else {
w.buf.Write(p)
}
}
return len(p), nil
}
func (w *capWriter) String() string { return w.buf.String() }
// runGitCapped runs git and reads at most maxBytes of its stdout, killing the
// process rather than blocking on Wait() if it had more to write. Used for
// reads of ref-addressed blobs (e.g. `git show <ref>:<path>`) whose size we
// don't control, so a single oversized blob can't spike job memory the way an
// unbounded cmd.Output() would. Bounded by gitTimeout like every other git call
// (BE-3248), so a stuck blob read can't hang the job.
func runGitCapped(maxBytes int64, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "git", args...)
cmd.WaitDelay = gitWaitDelay
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
data, readErr := io.ReadAll(io.LimitReader(stdout, maxBytes))
if readErr != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return nil, readErr
}
// Drain-probe for leftover output beyond the cap; if the process still has
// more to write, kill it instead of letting Wait() block on a full pipe.
extra := make([]byte, 1)
n, _ := stdout.Read(extra)
if n > 0 {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return data, nil
}
if err := cmd.Wait(); err != nil {
// Wrap a timeout hit as %w of ctx.Err() so errors.Is(err,
// context.DeadlineExceeded) works, mirroring runGitStdin (BE-3248).
if ctx.Err() == context.DeadlineExceeded {
err = fmt.Errorf("git %v timed out after %s: %w", args, gitTimeout, ctx.Err())
}
return nil, err
}
return data, nil
}
// envInt reads an int from the environment, falling back to def. An unparseable
// non-empty value WARNS rather than silently substituting: a `max_lines` that
// arrives as `1250.5` (GitHub expressions yield decimals) or with stray
// whitespace from a forwarded var would otherwise become the 1000 default — a
// LOOSER cap than the caller configured, indistinguishable in the report from a
// repo that genuinely meant 1000.
func envInt(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
fmt.Fprintf(os.Stderr, "check-pr-size: %s=%q is not an integer; falling back to %d\n", key, v, def)
return def
}
return n
}
func envBool(key string) bool {
b, _ := strconv.ParseBool(os.Getenv(key))
return b
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}