Skip to content

Commit a2da717

Browse files
Merge pull request #919 from saulshanabrook/codex/git-size-budget
[codex] Add Git size budget check
2 parents f0da95b + 2ba87f4 commit a2da717

3 files changed

Lines changed: 287 additions & 0 deletions

File tree

.github/workflows/git-size.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Git size budget
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened, ready_for_review]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
git-size:
12+
name: Git size budget
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
19+
- name: Measure compressed Git growth
20+
shell: bash
21+
env:
22+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
23+
PR_NUMBER: ${{ github.event.pull_request.number }}
24+
run: |
25+
set -euo pipefail
26+
git fetch --no-tags origin "$BASE_SHA"
27+
28+
# Since egglog uses merge commits, measure GitHub's actual PR merge ref.
29+
# This counts all objects that would become reachable by merging the PR,
30+
# including large files added in an earlier PR commit and deleted later.
31+
if ! git fetch --no-tags origin "+refs/pull/${PR_NUMBER}/merge:refs/pr/merge"; then
32+
echo "::warning title=Could not fetch PR merge ref::GitHub may not have created a merge ref, likely because the PR has conflicts."
33+
exit 0
34+
fi
35+
36+
python3 scripts/git-size-budget.py "$BASE_SHA" refs/pr/merge

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ To view documentation in a browser, run `cargo doc --open`.
7070

7171
Run `cargo test` to run the core `egglog` tests.
7272

73+
### Git size budget
74+
75+
PRs run a Git size budget check to catch accidentally committed generated data
76+
or other large files. The check measures compressed Git objects reachable from
77+
the PR merge commit but not from the base commit, so it also catches files that
78+
were added in an earlier PR commit and deleted before the final diff.
79+
80+
To run the same check locally, compare the base ref with the ref you want to
81+
measure:
82+
83+
```bash
84+
python3 scripts/git-size-budget.py upstream/main HEAD
85+
```
86+
87+
To try different thresholds locally, set `SOFT_LIMIT_BYTES` and
88+
`HARD_LIMIT_BYTES`.
89+
7390
## Community extensions
7491

7592
The community has maintained egglog extensions for IDEs. However, they are outdated at the time of writing.

scripts/git-size-budget.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#!/usr/bin/env python3
2+
"""Measure compressed Git growth between two refs."""
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import subprocess
8+
import sys
9+
10+
DEFAULT_SOFT_LIMIT_BYTES = 512 * 1024
11+
DEFAULT_HARD_LIMIT_BYTES = 2 * 1024 * 1024
12+
LARGEST_BLOBS = 20
13+
14+
15+
def usage() -> str:
16+
return f"""Usage:
17+
scripts/git-size-budget.py BASE_REF HEAD_REF
18+
19+
Measures compressed Git objects reachable from HEAD_REF but not BASE_REF.
20+
21+
Environment:
22+
SOFT_LIMIT_BYTES Warning threshold. Default: {DEFAULT_SOFT_LIMIT_BYTES}.
23+
HARD_LIMIT_BYTES Failure threshold. Default: {DEFAULT_HARD_LIMIT_BYTES}.
24+
GITHUB_STEP_SUMMARY, when set, receives the Markdown summary.
25+
"""
26+
27+
28+
def run_text(args: list[str], *, input_text: str | None = None) -> str:
29+
proc = subprocess.run(
30+
args,
31+
input=input_text,
32+
text=True,
33+
capture_output=True,
34+
check=True,
35+
)
36+
return proc.stdout
37+
38+
39+
def run_bytes(args: list[str], *, input_text: str) -> bytes:
40+
proc = subprocess.run(
41+
args,
42+
input=input_text.encode(),
43+
stdout=subprocess.PIPE,
44+
check=True,
45+
)
46+
return proc.stdout
47+
48+
49+
def rev_parse(ref: str) -> str:
50+
return run_text(["git", "rev-parse", "--verify", f"{ref}^{{commit}}"]).strip()
51+
52+
53+
def pack_size(revs: list[str], *, thin: bool) -> int:
54+
args = [
55+
"git",
56+
"pack-objects",
57+
"--revs",
58+
"--stdout",
59+
"--window=50",
60+
"--depth=50",
61+
"--threads=1",
62+
]
63+
if thin:
64+
args.insert(3, "--thin")
65+
66+
pack = run_bytes(args, input_text="\n".join(revs) + "\n")
67+
return len(pack)
68+
69+
70+
def parse_limit(name: str, default: int) -> int:
71+
value = os.environ.get(name, str(default))
72+
try:
73+
parsed = int(value)
74+
except ValueError:
75+
raise SystemExit(f"{name} must be an integer, got {value!r}") from None
76+
if parsed < 0:
77+
raise SystemExit(f"{name} must be non-negative, got {value!r}")
78+
return parsed
79+
80+
81+
def fmt_bytes(value: int) -> str:
82+
sign = "-" if value < 0 else ""
83+
value = abs(value)
84+
units = ["B", "KiB", "MiB", "GiB", "TiB"]
85+
size = float(value)
86+
87+
for unit in units:
88+
if size < 1024 or unit == units[-1]:
89+
break
90+
size /= 1024
91+
92+
if unit == "B":
93+
return f"{sign}{value} B"
94+
95+
text = f"{size:.1f}".rstrip("0").rstrip(".")
96+
return f"{sign}{text} {unit}"
97+
98+
99+
def github_escape(value: str) -> str:
100+
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
101+
102+
103+
def annotate(level: str, title: str, message: str) -> None:
104+
if os.environ.get("GITHUB_ACTIONS"):
105+
print(
106+
f"::{level} title={github_escape(title)}::{github_escape(message)}",
107+
file=sys.stderr,
108+
)
109+
else:
110+
print(f"{level}: {title}: {message}", file=sys.stderr)
111+
112+
113+
def largest_new_blobs(base_sha: str, head_sha: str) -> list[tuple[int, str]]:
114+
objects = run_text(["git", "rev-list", "--objects", head_sha, f"^{base_sha}"])
115+
oids: list[str] = []
116+
paths: dict[str, str] = {}
117+
118+
for line in objects.splitlines():
119+
oid, _, path = line.partition(" ")
120+
if not oid:
121+
continue
122+
oids.append(oid)
123+
paths.setdefault(oid, path)
124+
125+
if not oids:
126+
return []
127+
128+
batch_input = "\n".join(oids) + "\n"
129+
batch_output = run_text(
130+
["git", "cat-file", "--batch-check=%(objectname)\t%(objecttype)\t%(objectsize)"],
131+
input_text=batch_input,
132+
)
133+
134+
rows = []
135+
for line in batch_output.splitlines():
136+
oid, typ, size_text = line.split("\t")
137+
if typ == "blob":
138+
rows.append((int(size_text), paths.get(oid, "")))
139+
140+
return sorted(rows, reverse=True)[:LARGEST_BLOBS]
141+
142+
143+
def build_summary(
144+
*,
145+
base_sha: str,
146+
head_sha: str,
147+
delta_thin: int,
148+
delta_self_contained: int,
149+
soft_limit: int,
150+
hard_limit: int,
151+
blobs: list[tuple[int, str]],
152+
) -> str:
153+
lines = [
154+
"## Git size budget",
155+
"",
156+
"| Metric | Size |",
157+
"|---|---:|",
158+
f"| Delta pack, thin/network-like | {fmt_bytes(delta_thin)} |",
159+
f"| Delta pack, self-contained | {fmt_bytes(delta_self_contained)} |",
160+
"",
161+
f"Base: `{base_sha}`",
162+
"",
163+
f"Head: `{head_sha}`",
164+
"",
165+
"Thresholds:",
166+
"",
167+
"| Level | Limit | Behavior |",
168+
"|---|---:|---|",
169+
f"| Soft | {fmt_bytes(soft_limit)} | pass with warning annotation |",
170+
f"| Hard | {fmt_bytes(hard_limit)} | fail check |",
171+
"",
172+
"### Largest newly reachable blobs",
173+
"",
174+
"```text",
175+
]
176+
177+
if blobs:
178+
for size, path in blobs:
179+
lines.append(f"{fmt_bytes(size):>12} {path}")
180+
else:
181+
lines.append("(none)")
182+
183+
lines.append("```")
184+
return "\n".join(lines) + "\n"
185+
186+
187+
def write_summary(summary: str) -> None:
188+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
189+
if summary_path:
190+
with open(summary_path, "a", encoding="utf-8") as f:
191+
f.write(summary)
192+
else:
193+
print(summary, end="")
194+
195+
196+
def main() -> int:
197+
if len(sys.argv) != 3 or sys.argv[1] in {"-h", "--help"}:
198+
print(usage(), end="")
199+
return 0 if len(sys.argv) == 2 and sys.argv[1] in {"-h", "--help"} else 2
200+
201+
base_sha = rev_parse(sys.argv[1])
202+
head_sha = rev_parse(sys.argv[2])
203+
revs = [head_sha, f"^{base_sha}"]
204+
205+
soft_limit = parse_limit("SOFT_LIMIT_BYTES", DEFAULT_SOFT_LIMIT_BYTES)
206+
hard_limit = parse_limit("HARD_LIMIT_BYTES", DEFAULT_HARD_LIMIT_BYTES)
207+
delta_thin = pack_size(revs, thin=True)
208+
delta_self_contained = pack_size(revs, thin=False)
209+
blobs = largest_new_blobs(base_sha, head_sha)
210+
211+
write_summary(
212+
build_summary(
213+
base_sha=base_sha,
214+
head_sha=head_sha,
215+
delta_thin=delta_thin,
216+
delta_self_contained=delta_self_contained,
217+
soft_limit=soft_limit,
218+
hard_limit=hard_limit,
219+
blobs=blobs,
220+
)
221+
)
222+
223+
message = f"Git delta is {fmt_bytes(delta_thin)}."
224+
if delta_thin > hard_limit:
225+
annotate("error", "Git size budget exceeded", message)
226+
return 1
227+
if delta_thin > soft_limit:
228+
annotate("warning", "Git size budget warning", message)
229+
230+
return 0
231+
232+
233+
if __name__ == "__main__":
234+
raise SystemExit(main())

0 commit comments

Comments
 (0)