From 53ce88f9fe7d6d2bae2387b472f8c14ba5b1a95e Mon Sep 17 00:00:00 2001 From: lex0c Date: Sat, 27 Jun 2026 16:58:57 -0300 Subject: [PATCH 1/2] perf: opt-in blob sizes, single-parse load, parallel stats/report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent performance changes plus their docs/tests. Extract — blob-size resolution is now opt-in (--blob-sizes, default off): git cat-file --batch-check no longer runs by default and old_size/new_size (omitempty) drop from the JSONL. No stat consumes those fields, and the LRU cache had already made the lookup cheap, so this is a dead-code / IO / memory cleanup, not a speedup — extract is bound by the git log stream. Load — each JSONL line is type-detected from its prefix and unmarshalled once instead of twice (peekType, with a full-parse fallback for foreign or reordered lines). Roughly halves the load phase's parse work. Compute — report (Generate) and the stats --format json path now run their independent metric passes concurrently; FileHotspots is computed once and shared by the table and the repo tree instead of 3x. Impact (12-core, isolated): stats/report -27% to -49% vs v2.11.0 (e.g. WordPress report 3.23s -> 1.76s; Linux 1m29s -> 49s). Output is byte-identical to v2.11.0 across Pi-hole, WordPress, Kubernetes, Praat and self (stats JSON + report HTML), and load counts match exactly on all six fixtures incl. Linux (1,438,634 commits). Tests: peekType (+fallback), blob-size gating, and parallel-determinism guards for both report and stats-JSON (12x byte-identical, under -race). Docs: PERF.md tables refreshed and the incorrect "blocks on the cat-file pipe" claim corrected with a control measurement; README perf table and the new --blob-sizes flag documented. Co-Authored-By: Claude Opus 4.8 --- README.md | 31 +++-- cmd/gitcortex/main.go | 69 +++++++---- cmd/gitcortex/main_test.go | 81 +++++++++++++ docs/PERF.md | 196 +++++++++++++++++++++---------- internal/extract/extract.go | 33 ++++-- internal/extract/extract_test.go | 59 ++++++++++ internal/model/model.go | 4 +- internal/report/report.go | 160 ++++++++++++++++++------- internal/report/report_test.go | 24 ++++ internal/stats/reader.go | 46 +++++++- internal/stats/reader_test.go | 67 +++++++++++ 11 files changed, 612 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 776403c..9beb07a 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,17 @@ See [`docs/PERF.md`](docs/PERF.md) for extended benchmarks. -Benchmarked on open-source repositories. `extract` reads bare clones; `stats` and `report` read the resulting JSONL. Measurements taken with a pre-built v2.11.0 binary on a single NVMe-SSD machine (not a controlled lab benchmark; directional, not absolute). `stats`/`report` now include the test-to-source ratio section, so they do slightly more work than pre-v2.11 figures. +Benchmarked on open-source repositories. `extract` reads bare clones; `stats` and `report` read the resulting JSONL. Measurements taken on a single NVMe-SSD machine with a development build after v2.11.0 — blob-size resolution is now opt-in (`--blob-sizes`), and `stats`/`report` fan their independent metric passes across CPU cores (12 here). Not a controlled lab benchmark — directional, not absolute; `extract` is I/O-bound and carries ~10% run-to-run variance. | Repository | Commits | Devs | Extract | Stats (JSON) | Report (HTML) | JSONL size | |------------|---------|------|---------|-------------|--------------|------------| -| [Pi-hole](https://github.com/pi-hole/pi-hole) | 7,077 | 281 | 0.9s | 0.21s | 0.23s | 23K lines / 6.4 MB | -| [Praat](https://github.com/praat/praat) | 10,221 | 19 | 24s | 1.1s | 1.2s | 95K lines / 29 MB | -| [WordPress](https://github.com/WordPress/WordPress) | 52,466 | 131 | 46s | 3.0s | 3.2s | 298K lines / 96 MB | -| [Kubernetes](https://github.com/kubernetes/kubernetes) | 137,016 | 5,295 | 1m 58s | 11.1s | 12.0s | 943K lines / 313 MB | -| [Linux kernel](https://github.com/torvalds/linux) | 1,438,634 | 38,832 | 11m 34s | 1m 24s | 1m 29s | 6.1M lines / 1.9 GB | +| [Pi-hole](https://github.com/pi-hole/pi-hole) | 7,077 | 281 | 0.9s | 0.11s | 0.14s | 23K lines / 6.2 MB | +| [Praat](https://github.com/praat/praat) | 10,221 | 19 | 20s | 0.6s | 0.6s | 95K lines / 27 MB | +| [WordPress](https://github.com/WordPress/WordPress) | 52,466 | 131 | 40s | 1.6s | 1.8s | 298K lines / 90 MB | +| [Kubernetes](https://github.com/kubernetes/kubernetes) | 137,016 | 5,295 | 1m 51s | 8.1s | 8.5s | 943K lines / 295 MB | +| [Linux kernel](https://github.com/torvalds/linux) | 1,438,634 | 38,832 | 12m 46s | 47.6s | 48.8s | 6.1M lines / 1.74 GB | -`extract`, `stats`, and `report` scale roughly linearly with dataset size. The per-dev collaborator map in `report` is pre-computed in a single pass over files (O(F × D_per_file²)); on the kubernetes snapshot that adds ~2 seconds over `stats`, on linux ~40 seconds. A previous implementation computed this nested inside the per-dev loop (O(D × F × D_per_file)) and was 6× slower on kubernetes and 11× slower on linux. If you only need the aggregate data, `stats --format json` is always the fastest path; reach for `report` when you actually want the HTML dashboard. +`extract`, `stats`, and `report` scale roughly linearly with dataset size. Since the post-v2.11.0 work, `stats` and `report` run their independent metric passes concurrently across cores — on this 12-core machine that cut their wall time ~30–49% versus v2.11.0 (e.g. WordPress report 3.2s → 1.8s, Linux 1m29s → 49s) and brings the two within a few percent of each other (`report` does a little more — repo tree, dev network — but it overlaps the other passes). `extract` is unchanged within run-to-run variance: it's bound by the `git log` stream, not the now-optional blob-size lookup. `stats --format json` is the leanest path when you only need aggregate data; reach for `report` when you want the HTML dashboard. ## Privacy and reliability @@ -107,11 +107,13 @@ The `--mailmap` flag uses git's built-in `.mailmap` support to unify developer i ### What gitcortex collects from git -Extraction runs two git commands against the local repository and streams their output. No source-code bytes are read. +Extraction runs one git command against the local repository by default (a +second only when `--blob-sizes` is set) and streams the output. No source-code +bytes are read. ``` git log -M --raw --numstat --format= → commits, parents, per-file diffs (counts only) -git cat-file --batch-check → blob sizes (old/new) for each file change +git cat-file --batch-check (only with --blob-sizes) → blob sizes (old/new) for each file change ``` Per-commit metadata (populates the `commit` record): @@ -130,7 +132,8 @@ Per-file-change metadata (populates the `commit_file` record): |---|---|---| | `path_current`, `path_previous`, `status` | `git log --raw` | hotspots, directories, extensions, rename tracking (`R100` / `C075` trigger merges) | | `additions`, `deletions` | `git log --numstat` | per-file churn, recent churn, coupling | -| `old_hash`, `new_hash`, `old_size`, `new_size` | `git cat-file --batch-check` | retained but not currently used in stats | +| `old_hash`, `new_hash` | `git log --raw` | emitted but not consumed by any stat | +| `old_size`, `new_size` | `git cat-file --batch-check` (opt-in: `--blob-sizes`) | blob byte sizes; **off by default** (the lookup dominated extract time and no stat reads them), so absent from the JSONL unless `--blob-sizes` is passed | **Not collected:** - File contents / diff hunks — only line counts from `--numstat`. @@ -143,6 +146,7 @@ Per-file-change metadata (populates the `commit_file` record): - `--mailmap` — normalizes author/committer names+emails via git's `.mailmap` before recording (off by default; warned when a `.mailmap` exists but the flag is omitted). - `--ignore ` — drops matching `commit_file` records entirely at extract time (counts in the `commit` record are recomputed so totals remain consistent). - `--first-parent` — traverses only the first-parent chain, skipping merged branch history. +- `--blob-sizes` — resolves per-blob byte sizes via `git cat-file --batch-check` and emits `old_size`/`new_size` (off by default). The lookup is the bulk of any cat-file cost and no gitcortex stat consumes the sizes, so enable this only when an external consumer of the JSONL needs them. Full per-record schema (every field, types, enums): see [`docs/RUNBOOK.md`](docs/RUNBOOK.md#jsonl-format). @@ -151,7 +155,7 @@ Output is a JSONL file with one record per line. Four record types: ```jsonl {"type":"commit","sha":"abc...","tree":"def...","parents":["ghi..."],"author_name":"Alice","author_email":"alice@example.com","author_date":"2024-01-15T10:30:00Z","committer_name":"Alice","committer_email":"alice@example.com","committer_date":"2024-01-15T10:30:00Z","message":"","additions":42,"deletions":7,"files_changed":3} {"type":"commit_parent","sha":"abc...","parent_sha":"ghi..."} -{"type":"commit_file","commit":"abc...","path_current":"src/main.go","path_previous":"src/main.go","status":"M","old_hash":"111...","new_hash":"222...","old_size":1024,"new_size":1087,"additions":10,"deletions":3} +{"type":"commit_file","commit":"abc...","path_current":"src/main.go","path_previous":"src/main.go","status":"M","old_hash":"111...","new_hash":"222...","additions":10,"deletions":3} {"type":"dev","dev_id":"sha256hash...","name":"Alice","email":"alice@example.com"} ``` @@ -534,12 +538,13 @@ internal/ ### Extraction pipeline -Two long-running git processes for the entire extraction, regardless of repository size: +One long-running git process for the entire extraction (a second only with +`--blob-sizes`), regardless of repository size: ``` git log --raw --numstat -M --- single stream ---- parse ---- emit JSONL | -git cat-file --batch-check -- long-running ---- resolve blob sizes +git cat-file --batch-check -- long-running ---- resolve blob sizes (only with --blob-sizes) ``` ### Stats pipeline diff --git a/cmd/gitcortex/main.go b/cmd/gitcortex/main.go index 3361b56..678e3f1 100644 --- a/cmd/gitcortex/main.go +++ b/cmd/gitcortex/main.go @@ -12,6 +12,7 @@ import ( "runtime" "sort" "strings" + "sync" "syscall" "time" @@ -84,6 +85,7 @@ func extractCmd() *cobra.Command { cmd.Flags().DurationVar(&cfg.CommandTimeout, "command-timeout", extract.DefaultCommandTimeout, "Maximum duration for git commands") cmd.Flags().BoolVar(&cfg.FirstParent, "first-parent", false, "Restrict to first-parent chain") cmd.Flags().BoolVar(&cfg.Mailmap, "mailmap", false, "Use .mailmap to normalize author/committer identities") + cmd.Flags().BoolVar(&cfg.BlobSizes, "blob-sizes", false, "Resolve per-blob byte sizes via cat-file (off by default; dominates extract time and is unused by stats)") cmd.Flags().StringSliceVar(&cfg.IgnorePatterns, "ignore", nil, "Glob patterns to exclude files (e.g. package-lock.json, *.min.js)") return cmd @@ -423,59 +425,83 @@ func renderStatsJSON(f *stats.Formatter, ds *stats.Dataset, sf *statsFlags) erro showAll := sf.stat == "" report := make(map[string]interface{}) + // Each selected stat is an independent pure read over the immutable + // Dataset, so compute them concurrently and guard only the brief map + // write. In the common showAll path this fans ~13 passes across cores + // instead of running serially. JSON output is unaffected: encoding/json + // sorts map keys, so completion order doesn't change the bytes. + var ( + mu sync.Mutex + wg sync.WaitGroup + ) + put := func(key string, compute func() interface{}) { + wg.Add(1) + go func() { + defer wg.Done() + v := compute() + mu.Lock() + report[key] = v + mu.Unlock() + }() + } + if showAll || sf.stat == "summary" { - report["summary"] = stats.ComputeSummary(ds) + put("summary", func() interface{} { return stats.ComputeSummary(ds) }) } if showAll || sf.stat == "contributors" { - report["contributors"] = stats.TopContributors(ds, sf.topN) + put("contributors", func() interface{} { return stats.TopContributors(ds, sf.topN) }) } if showAll || sf.stat == "hotspots" { - report["hotspots"] = stats.FileHotspots(ds, sf.topN) + put("hotspots", func() interface{} { return stats.FileHotspots(ds, sf.topN) }) } if showAll || sf.stat == "directories" { - report["directories"] = stats.DirectoryStats(ds, sf.topN) + put("directories", func() interface{} { return stats.DirectoryStats(ds, sf.topN) }) } if showAll || sf.stat == "extensions" { - report["extensions"] = stats.ExtensionStats(ds, sf.topN) + put("extensions", func() interface{} { return stats.ExtensionStats(ds, sf.topN) }) } if showAll || sf.stat == "tests" { - cfg := stats.NewTestConfig(sf.testGlobs) - report["tests"] = map[string]interface{}{ - "summary": stats.ComputeTestSummary(ds, cfg, sf.topN), - "trend": stats.TestRatioOverTime(ds, cfg, sf.granularity), - } + put("tests", func() interface{} { + cfg := stats.NewTestConfig(sf.testGlobs) + return map[string]interface{}{ + "summary": stats.ComputeTestSummary(ds, cfg, sf.topN), + "trend": stats.TestRatioOverTime(ds, cfg, sf.granularity), + } + }) } if showAll || sf.stat == "activity" { - report["activity"] = stats.ActivityOverTime(ds, sf.granularity) + put("activity", func() interface{} { return stats.ActivityOverTime(ds, sf.granularity) }) } if showAll || sf.stat == "busfactor" { - report["busfactor"] = stats.BusFactor(ds, sf.topN) + put("busfactor", func() interface{} { return stats.BusFactor(ds, sf.topN) }) } if showAll || sf.stat == "coupling" { - report["coupling"] = stats.FileCoupling(ds, sf.topN, sf.couplingMinChanges) + put("coupling", func() interface{} { return stats.FileCoupling(ds, sf.topN, sf.couplingMinChanges) }) } if showAll || sf.stat == "churn-risk" { - report["churn_risk"] = stats.ChurnRisk(ds, sf.topN) + put("churn_risk", func() interface{} { return stats.ChurnRisk(ds, sf.topN) }) } if showAll || sf.stat == "working-patterns" { - report["working_patterns"] = stats.WorkingPatterns(ds) + put("working_patterns", func() interface{} { return stats.WorkingPatterns(ds) }) } if showAll || sf.stat == "dev-network" { - report["dev_network"] = stats.DeveloperNetwork(ds, sf.topN, sf.networkMinFiles) + put("dev_network", func() interface{} { return stats.DeveloperNetwork(ds, sf.topN, sf.networkMinFiles) }) } if sf.stat == "profile" { - report["profiles"] = stats.DevProfiles(ds, sf.email, 0, stats.NewTestConfig(sf.testGlobs)) + put("profiles", func() interface{} { return stats.DevProfiles(ds, sf.email, 0, stats.NewTestConfig(sf.testGlobs)) }) } if showAll || sf.stat == "pareto" { - report["pareto"] = reportpkg.ComputePareto(ds) + put("pareto", func() interface{} { return reportpkg.ComputePareto(ds) }) } if showAll || sf.stat == "top-commits" { - report["top_commits"] = stats.TopCommits(ds, sf.topN) + put("top_commits", func() interface{} { return stats.TopCommits(ds, sf.topN) }) } if sf.stat == "structure" { - report["structure"] = reportpkg.BuildRepoTree(stats.FileHotspots(ds, 0), sf.treeDepth) + put("structure", func() interface{} { return reportpkg.BuildRepoTree(stats.FileHotspots(ds, 0), sf.treeDepth) }) } + wg.Wait() + return f.PrintReport(report) } @@ -1190,6 +1216,7 @@ func scanCmd() *cobra.Command { mailmap bool firstParent bool includeMessages bool + blobSizes bool couplingMaxFiles int couplingMinChanges int churnHalfLife int @@ -1272,6 +1299,7 @@ breakdown — handy for showing aggregated work across many repos.`, CommandTimeout: extract.DefaultCommandTimeout, FirstParent: firstParent, Mailmap: mailmap, + BlobSizes: blobSizes, IgnorePatterns: extractIgnore, StartOffset: -1, }, @@ -1369,6 +1397,7 @@ breakdown — handy for showing aggregated work across many repos.`, cmd.Flags().IntVar(&batchSize, "batch-size", 1000, "Per-repo extract checkpoint interval") cmd.Flags().BoolVar(&mailmap, "mailmap", false, "Use .mailmap (per repo) to normalize identities") cmd.Flags().BoolVar(&firstParent, "first-parent", false, "Restrict extracts to the first-parent chain") + cmd.Flags().BoolVar(&blobSizes, "blob-sizes", false, "Resolve per-blob byte sizes via cat-file (off by default; dominates extract time and is unused by stats)") cmd.Flags().BoolVar(&includeMessages, "include-commit-messages", false, "Include commit messages in JSONL (needed for Top Commits in the consolidated report)") cmd.Flags().IntVar(&couplingMaxFiles, "coupling-max-files", 50, "Max files per commit for coupling analysis (consolidated report)") cmd.Flags().IntVar(&couplingMinChanges, "coupling-min-changes", 5, "Min co-changes for coupling results (consolidated report)") diff --git a/cmd/gitcortex/main_test.go b/cmd/gitcortex/main_test.go index 2795e2b..418693a 100644 --- a/cmd/gitcortex/main_test.go +++ b/cmd/gitcortex/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "fmt" "os" "os/exec" @@ -13,6 +14,86 @@ import ( "github.com/lex0c/gitcortex/internal/stats" ) +// renderStatsJSON fans the (showAll) stats across goroutines and writes them +// into a shared map under a mutex. This guards that path two ways that the +// rest of the suite did not cover: (1) run under `go test -race` it trips the +// detector on any unsynchronized access; (2) running it repeatedly and +// asserting byte-identical output catches a dropped/racy map write that would +// silently omit a stat or reorder bytes. encoding/json sorts map keys, so a +// correct parallel run is deterministic — any diff here is a real defect. +func TestRenderStatsJSON_ParallelDeterministicAndComplete(t *testing.T) { + // Non-trivial fixture: 4 commits, 2 devs, 3 files, with a.go/b.go + // co-changing twice (coupling edge) and alice/bob sharing files + // (dev-network edge) so every showAll stat produces real output. + fixture := strings.Join([]string{ + `{"type":"dev","dev_id":"d1","name":"Alice","email":"alice@x.com"}`, + `{"type":"dev","dev_id":"d2","name":"Bob","email":"bob@x.com"}`, + `{"type":"commit","sha":"c1","author_name":"Alice","author_email":"alice@x.com","author_date":"2024-01-01T10:00:00Z","additions":15,"deletions":0,"files_changed":2}`, + `{"type":"commit_file","commit":"c1","path_current":"a.go","path_previous":"a.go","status":"M","additions":10,"deletions":0}`, + `{"type":"commit_file","commit":"c1","path_current":"b.go","path_previous":"b.go","status":"M","additions":5,"deletions":0}`, + `{"type":"commit","sha":"c2","author_name":"Bob","author_email":"bob@x.com","author_date":"2024-01-02T10:00:00Z","additions":11,"deletions":1,"files_changed":2}`, + `{"type":"commit_file","commit":"c2","path_current":"a.go","path_previous":"a.go","status":"M","additions":3,"deletions":1}`, + `{"type":"commit_file","commit":"c2","path_current":"c.go","path_previous":"c.go","status":"M","additions":8,"deletions":0}`, + `{"type":"commit","sha":"c3","author_name":"Alice","author_email":"alice@x.com","author_date":"2024-01-03T10:00:00Z","additions":6,"deletions":3,"files_changed":2}`, + `{"type":"commit_file","commit":"c3","path_current":"a.go","path_previous":"a.go","status":"M","additions":2,"deletions":2}`, + `{"type":"commit_file","commit":"c3","path_current":"b.go","path_previous":"b.go","status":"M","additions":4,"deletions":1}`, + `{"type":"commit","sha":"c4","author_name":"Bob","author_email":"bob@x.com","author_date":"2024-02-01T10:00:00Z","additions":1,"deletions":1,"files_changed":1}`, + `{"type":"commit_file","commit":"c4","path_current":"c.go","path_previous":"c.go","status":"M","additions":1,"deletions":1}`, + }, "\n") + "\n" + + dir := t.TempDir() + path := filepath.Join(dir, "fixture.jsonl") + if err := os.WriteFile(path, []byte(fixture), 0o644); err != nil { + t.Fatal(err) + } + ds, err := stats.LoadJSONL(path) + if err != nil { + t.Fatalf("LoadJSONL: %v", err) + } + + sf := &statsFlags{ + format: "json", topN: 10, granularity: "month", stat: "", + couplingMaxFiles: 50, couplingMinChanges: 1, churnHalfLife: 90, + networkMinFiles: 1, treeDepth: 3, + } + + render := func() string { + var buf bytes.Buffer + f := stats.NewFormatter(&buf, "json") + if err := renderStatsJSON(f, ds, sf); err != nil { + t.Fatalf("renderStatsJSON: %v", err) + } + return buf.String() + } + + // Determinism: many runs over the same Dataset must be byte-identical. + // A racy map write or lost goroutine result would diverge here, and + // -race would flag the access. + first := render() + for i := 0; i < 12; i++ { + if got := render(); got != first { + t.Fatalf("run %d diverged from first run — parallel assembly is not deterministic", i) + } + } + + // Completeness: every showAll stat must be present. A dropped parallel + // write would silently omit one — this asserts the full inventory landed. + var out map[string]json.RawMessage + if err := json.Unmarshal([]byte(first), &out); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + want := []string{ + "summary", "contributors", "hotspots", "directories", "extensions", + "tests", "activity", "busfactor", "coupling", "churn_risk", + "working_patterns", "dev_network", "pareto", "top_commits", + } + for _, k := range want { + if _, ok := out[k]; !ok { + t.Errorf("showAll output missing key %q (parallel write dropped?)", k) + } + } +} + // scanCmd must validate --since BEFORE running the discovery walk // and extract pool. Without the early check, an obvious typo like // `--since 1yy` only surfaces after scan.Run has already walked diff --git a/docs/PERF.md b/docs/PERF.md index 0bbd489..27d5a77 100644 --- a/docs/PERF.md +++ b/docs/PERF.md @@ -1,33 +1,68 @@ # Performance How `gitcortex` scales across repositories. All measurements below were -taken on NVMe SSD with the **v2.11.0** binary (LRU blob cache enabled) -and `--batch-size 1000` (default). The full pipeline (extract → stats → -report) was timed on each repo in isolation, one repo at a time, so the -numbers are not skewed by concurrent load. `stats`/`report` since v2.11.0 -also compute the test-to-source ratio section, so they do slightly more -work than the earlier (v2.3.0) figures these tables replace. +taken on NVMe SSD with a **development build following v2.11.0** and +`--batch-size 1000` (default), on a 12-core machine. The full pipeline +(extract → stats → report) was timed on each repo in isolation, one repo +at a time, so the numbers are not skewed by concurrent load. + +Two changes since v2.11.0 reshape these tables: + +- **Blob-size resolution is now opt-in** (`--blob-sizes`). The default + extract no longer runs `git cat-file --batch-check`, and the JSONL no + longer carries `old_size`/`new_size` — so files are a few percent + smaller. Extract wall time is unchanged within run-to-run variance (the + `git log` stream dominates, not the blob lookup — see "What drives + extract time"). +- **`stats` and `report` parallelize.** Their independent metric passes + now run concurrently across cores, and the load path parses each JSONL + line once instead of twice. Together these cut `stats`/`report` wall + time ~30–49% versus v2.11.0 on this machine. + +`extract` is I/O-bound and carries roughly ±10% run-to-run variance; +treat its figures as directional. `stats`/`report` also include the +test-to-source ratio section introduced in v2.11.0. ## Extract benchmarks Six repositories spanning four orders of magnitude in commit count, -extracted end-to-end (git log stream, blob size resolution, JSONL -emission, checkpointing) then analyzed (`stats --format json`, `report`). -None use `--ignore` filters. Chromium is carried over from the v2.3.0 -run (the repo was not available for this round) for the extreme-scale / -OOM reference; its filtered extract used the `--ignore` set in footnote †. +extracted end-to-end (git log stream, JSONL emission, checkpointing; +blob-size resolution off by default) then analyzed (`stats --format +json`, `report`). None use `--ignore` filters. Chromium is carried over +from the v2.3.0 run (the repo was not available for this round) for the +extreme-scale / OOM reference; its filtered extract used the `--ignore` +set in footnote †. | Repository | Commits | Bare size | Extract | Stats (JSON) | Report (HTML) | JSONL | |---|---|---|---|---|---|---| -| [gitcortex](https://github.com/lex0c/gitcortex) (self) | 189 | 620 KB | **0.1s** | 0.01s | 0.02s | 803 lines / 244 KB | -| [Pi-hole](https://github.com/pi-hole/pi-hole) | 7,077 | 9.8 MB | **0.9s** | 0.21s | 0.23s | 23k lines / 6.4 MB | -| [Praat](https://github.com/praat/praat) | 10,221 | 490 MB | **23.8s** | 1.12s | 1.24s | 95k lines / 29 MB | -| [WordPress](https://github.com/WordPress/WordPress) | 52,466 | 629 MB | **46.4s** | 2.96s | 3.23s | 298k lines / 96 MB | -| [Kubernetes](https://github.com/kubernetes/kubernetes) | 137,016 | 1.3 GB | **1m 58.1s** | 11.1s | 12.0s | 943k lines / 313 MB | -| [Linux kernel](https://github.com/torvalds/linux) | 1,438,634 | 6.3 GB | **11m 34.3s** | 1m 23.7s | 1m 29.2s | 6.1M lines / 1.9 GB | +| [gitcortex](https://github.com/lex0c/gitcortex) (self) | 189 | 620 KB | **0.1s** | 0.01s | 0.02s | 803 lines / 231 KB | +| [Pi-hole](https://github.com/pi-hole/pi-hole) | 7,077 | 9.8 MB | **0.9s** | 0.11s | 0.14s | 23k lines / 6.2 MB | +| [Praat](https://github.com/praat/praat) | 10,221 | 490 MB | **20.1s** | 0.57s | 0.64s | 95k lines / 27 MB | +| [WordPress](https://github.com/WordPress/WordPress) | 52,466 | 629 MB | **40.3s** | 1.58s | 1.76s | 298k lines / 90 MB | +| [Kubernetes](https://github.com/kubernetes/kubernetes) | 137,016 | 1.3 GB | **1m 50.6s** | 8.07s | 8.47s | 943k lines / 295 MB | +| [Linux kernel](https://github.com/torvalds/linux) | 1,438,634 | 6.3 GB | **12m 45.6s** | 47.6s | 48.8s | 6.1M lines / 1.74 GB | | [Chromium](https://chromium.googlesource.com/chromium/src) †◇ | 1,738,421 | 61 GB | **1h 55m 52s** | OOM ‡ | OOM ‡ | 12.3M lines / 4.4 GB | -◇ Chromium figures are from the v2.3.0 run, not re-measured this round. +◇ Chromium figures are from the v2.3.0 run (with blob sizes on, the +default at the time), not re-measured this round. + +**`stats`/`report` vs v2.11.0** (same machine, same repos) — the +parallelization + single-parse load: + +| Repository | Stats v2.11.0 → now | Report v2.11.0 → now | +|---|---|---| +| Pi-hole | 0.21s → **0.11s** (−48%) | 0.23s → **0.14s** (−39%) | +| Praat | 1.12s → **0.57s** (−49%) | 1.24s → **0.64s** (−48%) | +| WordPress | 2.96s → **1.58s** (−47%) | 3.23s → **1.76s** (−46%) | +| Kubernetes | 11.1s → **8.07s** (−27%) | 12.0s → **8.47s** (−29%) | +| Linux | 1m 23.7s → **47.6s** (−43%) | 1m 29.2s → **48.8s** (−45%) | + +The win is largest on mid-size repos where compute dominates; on +Kubernetes and Linux the single-threaded JSONL load (one big file) is a +growing share of the total and Amdahl-caps the gain — parallelizing that +load is the next lever. Extract rows are within ±10% run-to-run variance +of v2.11.0 (most came out faster here, Linux slower — all noise; the +default no longer does any blob lookup, so no extract work was added). † Chromium was extracted with `--ignore 'third_party/*' --ignore 'out/*' --ignore 'node_modules/*' --ignore '*.min.js' --ignore '*.min.css' @@ -52,10 +87,10 @@ per second** — normalizing by actual work rather than commit count: | Repository | Records/sec (avg) | |---|---| | Pi-hole | ~25,600 | -| Linux kernel | ~8,750 | -| Kubernetes | ~7,990 | -| WordPress | ~6,425 | -| Praat | ~3,990 | +| Kubernetes | ~8,530 | +| Linux kernel | ~7,940 | +| WordPress | ~7,400 | +| Praat | ~4,720 | | Chromium | ~1,775 ◇ | | gitcortex (self) | noisy † | @@ -65,31 +100,42 @@ extract table because it's useful to see the tool exercising itself — the dogfood benchmark. ◇ Chromium carried from v2.3.0. Small repos benefit from the entire working set fitting in OS page -cache. Linux (6 GB) and Kubernetes (1.3 GB) mostly fit. Chromium -(61 GB bare) exceeds most workstations' available cache, so -`cat-file --batch-check` lookups land on SSD more often than not — -hence the 4× drop in records/sec vs. Linux. +cache. Linux (6 GB) and Kubernetes (1.3 GB) mostly fit. The carried-over +Chromium figure (61 GB bare, measured before blob sizes went opt-in) +exceeds most workstations' available cache, so its `cat-file +--batch-check` lookups landed on SSD more often than not — part of the 4× +drop in records/sec vs. Linux. The current default skips that lookup +entirely, so a re-measured Chromium would close some of that gap; the +`git log` stream over 61 GB of packfiles remains the floor. ## What drives extract time -Extract is an I/O-bound pipeline with three stages: - -1. **`git log --raw --numstat`** streams commit history newest-first. - Sequential read of packfiles, cheap on SSD (typically 200+ MB/s - reading rate from the filesystem). -2. **`cat-file --batch-check`** resolves blob sizes. For each unique - hash in each commit, gitcortex writes a hash to stdin and reads - back a ` blob ` line. Each lookup triggers a small - random read into the packfile index plus the object header. -3. **JSONL emission** is buffered writes, negligible relative to - the two above. - -CPU usage stays between 5% and 10% across all runs — the process -blocks on the `cat-file` pipe the vast majority of wall time. The -LRU blob cache (v2.3.0) removes redundant pipe round-trips when the -same hash appears across consecutive commits, which is the common -case: a file unchanged across N commits would otherwise be queried -N times. +Extract is dominated by a single stage: the **`git log -M --raw +--numstat`** stream. Git computes a rename-detected diff for every commit +server-side and streams the result; gitcortex parses it newest-first and +emits JSONL. The packfile reads are sequential and cheap on SSD (200+ +MB/s), but git's own diff + rename-detection work over the full history +is the real cost — it shows up as *git's* CPU while gitcortex sits at 5–10% +blocking on the log pipe. **JSONL emission** is buffered writes, +negligible by comparison. + +Blob-size resolution via **`git cat-file --batch-check`** (opt-in since +the post-v2.11.0 build) is a *minor* component, not the bottleneck. +Measured on WordPress, same machine, back to back: + +| WordPress extract | wall time | JSONL | +|---|---|---| +| default (cat-file off) | 44.2s | 90 MB | +| `--blob-sizes` (cat-file on) | 42.9s | 96 MB | + +The two are within run-to-run variance — the cat-file run was even +slightly *faster* (warmer cache, ran second). An earlier version of this +doc claimed extract "blocks on the cat-file pipe the vast majority of +wall time"; that did not hold up. Disabling cat-file did not materially +change wall time, because the LRU blob cache (below) already removed +almost all of its pipe round-trips. With the default now skipping the +lookup outright, the JSONL is a few percent smaller and one subprocess is +gone — but extract speed is set by `git log`, not blob resolution. ## Chromium rate trajectory @@ -121,18 +167,21 @@ its modern counterpart. ## LRU blob cache (v2.3.0) -The v2.3.0 resolver adds a 50,000-entry LRU of `hash → blob size`. -Git content-addresses blobs, so `hash → size` is a pure function, -making the cache provably safe — extract output is byte-identical -with or without it, only faster. +Relevant only with `--blob-sizes` (since the post-v2.11.0 build the +resolver is off by default). When enabled, the resolver carries a +50,000-entry LRU of `hash → blob size`. Git content-addresses blobs, so +`hash → size` is a pure function, making the cache provably safe — +extract output is byte-identical with or without it, only faster. -Measured impact on WordPress (52k commits, warm packfiles, SSD): -**50.0s → 46.3s wall time (-7.4%)**. The cache removes pipe -round-trips for blobs that persist across consecutive commits -(the common case: most files change rarely). +Measured impact back when blob resolution was the default — WordPress +(52k commits, warm packfiles, SSD): **50.0s → 46.3s wall time (-7.4%)**. +The cache removes pipe round-trips for blobs that persist across +consecutive commits (the common case: most files change rarely). This is +also *why* disabling cat-file outright now saves so little: the cache had +already eliminated most of its cost. -Memory cost: ~7 MB for the 50k-entry cache regardless of repository -size. +Memory cost: ~7 MB for the 50k-entry cache, and only when `--blob-sizes` +is passed. ## Memory limits @@ -166,10 +215,19 @@ Post-v2.3.0 optimizations reduce several hot spots: the rename merge, not per file at ingest** — so the unrenamed majority (and runs that never read test stats) allocate nothing extra. The test-to-source section itself adds modest CPU to `stats`/`report`. - -Together these changes made the Linux report finish cleanly (~1m 29s on -v2.11.0) on a machine where it previously died at 0 bytes. **Chromium -remains out of reach** for `stats` and `report` on a 15 GB machine. +- **(post-v2.11.0) Single-parse JSONL load** — each line is type-detected + from its prefix and unmarshalled once instead of twice, roughly halving + the load phase's transient allocations and CPU. This is a throughput + win, not a peak-RSS one: peak is still set by the retained `Dataset`. +- **(post-v2.11.0) Parallel `stats`/`report` compute** — the independent + metric passes run concurrently. They were already all held in memory at + once before rendering, so concurrency does not raise peak RSS; Chromium + still OOMs for the same reason (`monthChurn`), just no sooner. + +Together these changes made the Linux report finish cleanly (~49s now, +~1m 29s on v2.11.0) on a machine where it previously died at 0 bytes. +**Chromium remains out of reach** for `stats` and `report` on a 15 GB +machine. The dominant remaining hog is `fileEntry.monthChurn` — a per-month activity map on every file, used only to compute the trend dimension of the Churn Risk classification. Scaling `O(files × months_active)`, @@ -191,17 +249,25 @@ hundred MB of `Dataset` in memory. Chromium is the exception. ## Practical guidance - **Filter aggressively with `--ignore`.** Vendor directories, build - outputs, and generated paths are both the biggest source of noise - in stats and the biggest chunk of extract time. gitcortex skips - them at emit time, so each `--ignore` saves `cat-file` round-trips - and JSONL bytes. + outputs, and generated paths are both the biggest source of noise in + stats and a real chunk of extract work (git still diffs them) and JSONL + bytes. gitcortex skips them at emit time, so each `--ignore` shrinks the + output and the downstream `stats`/`report` load. +- **Leave `--blob-sizes` off unless you need it.** It's off by default; + enabling it adds a `cat-file` subprocess and `old_size`/`new_size` to + every file record. No gitcortex stat reads those — turn it on only for + an external consumer of the JSONL. +- **`stats`/`report` use all your cores.** The metric passes run + concurrently, so more cores = faster analysis; the single-threaded + JSONL load is the part that doesn't scale with cores. `stats --format + json` is the leanest path when you only need aggregate data. - **Extract is resumable.** State is checkpointed every `--batch-size` commits (default 1000). If a run is interrupted, rerunning with the same flags continues from the last checkpoint — important on multi-hour runs like Chromium. -- **Memory stays low.** The resolver cache uses ~7 MB; the commit - stream has no unbounded buffers. Even Chromium extract peaks - around 25 MB RSS. +- **Memory stays low.** The commit stream has no unbounded buffers (the + ~7 MB resolver cache only exists with `--blob-sizes`). Even Chromium + extract peaks around 25 MB RSS. - **Plan capacity by records/second, not commits/second.** The commits/second metric is dominated by repository content: import- heavy histories artificially depress it even when the underlying diff --git a/internal/extract/extract.go b/internal/extract/extract.go index 6ca0859..2ae354c 100644 --- a/internal/extract/extract.go +++ b/internal/extract/extract.go @@ -35,6 +35,13 @@ type Config struct { FirstParent bool Mailmap bool IgnorePatterns []string + // BlobSizes, when true, resolves per-blob byte sizes via + // `git cat-file --batch-check` and emits them as old_size/new_size. + // It is off by default: the lookup dominates extract wall time + // (the process blocks on the cat-file pipe) and no gitcortex stat + // consumes the sizes. Enable only when an external consumer of the + // JSONL needs blob sizes. + BlobSizes bool } type State struct { @@ -164,11 +171,18 @@ func streamExtract(ctx context.Context, cfg Config, initialState State, writer * } }() - resolver, err := git.NewBlobSizeResolver(ctx, cfg.Repo) - if err != nil { - return fmt.Errorf("start blob resolver: %w", err) + // Blob-size resolution is opt-in: it is the dominant cost of extract + // (cat-file pipe round-trips) and nothing in the analysis path reads + // the resulting sizes. When disabled, resolver stays nil and the + // per-commit Resolve call is skipped entirely. + var resolver *git.BlobSizeResolver + if cfg.BlobSizes { + resolver, err = git.NewBlobSizeResolver(ctx, cfg.Repo) + if err != nil { + return fmt.Errorf("start blob resolver: %w", err) + } + defer resolver.Close() } - defer resolver.Close() checkpointInterval := cfg.BatchSize if checkpointInterval <= 0 { @@ -194,10 +208,13 @@ func streamExtract(ctx context.Context, cfg Config, initialState State, writer * break } - sizeMap, err := resolver.Resolve(commit.Raw) - if err != nil { - log.Printf("warning: blob sizes failed for %s: %v", commit.Meta.SHA, err) - sizeMap = map[string]int64{} + var sizeMap map[string]int64 + if resolver != nil { + sizeMap, err = resolver.Resolve(commit.Raw) + if err != nil { + log.Printf("warning: blob sizes failed for %s: %v", commit.Meta.SHA, err) + sizeMap = map[string]int64{} + } } if err := emitCommit(writer, commit, sizeMap, devCache, cfg.IgnorePatterns); err != nil { diff --git a/internal/extract/extract_test.go b/internal/extract/extract_test.go index cda525f..6b2ba5d 100644 --- a/internal/extract/extract_test.go +++ b/internal/extract/extract_test.go @@ -1,11 +1,70 @@ package extract import ( + "bufio" + "bytes" "os" "path/filepath" + "strings" "testing" + + "github.com/lex0c/gitcortex/internal/git" ) +// emitCommit drives the blob-size gating: when the resolver is disabled the +// caller passes a nil sizeMap, and old_size/new_size must be absent from the +// JSONL (omitempty), not emitted as ":0". When sizes are supplied they must +// appear. A regression here would either resurrect the per-line dead bytes or +// drop sizes that --blob-sizes promised. +func TestEmitCommitBlobSizeGating(t *testing.T) { + commit := &git.StreamCommit{ + Meta: git.CommitMeta{ + SHA: "abc123", + AuthorName: "Alice", + AuthorEmail: "alice@example.com", + AuthorDate: "2024-01-01T00:00:00Z", + }, + Raw: []git.RawEntry{{ + Status: "M", + OldHash: "aaa", + NewHash: "bbb", + PathOld: "src/main.go", + PathNew: "src/main.go", + }}, + Numstats: map[string]git.NumstatEntry{ + "src/main.go": {Additions: 10, Deletions: 3}, + }, + } + + render := func(sizeMap map[string]int64) string { + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + if err := emitCommit(w, commit, sizeMap, map[string]struct{}{}, nil); err != nil { + t.Fatalf("emitCommit: %v", err) + } + if err := w.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + return buf.String() + } + + // Disabled (nil map): no size fields at all. + off := render(nil) + if strings.Contains(off, "old_size") || strings.Contains(off, "new_size") { + t.Errorf("disabled path emitted size fields:\n%s", off) + } + // Sanity: the file record (and its churn) is still present. + if !strings.Contains(off, `"path_current":"src/main.go"`) || !strings.Contains(off, `"additions":10`) { + t.Errorf("disabled path dropped non-size file data:\n%s", off) + } + + // Enabled: sizes flow through. + on := render(map[string]int64{"aaa": 1024, "bbb": 2048}) + if !strings.Contains(on, `"old_size":1024`) || !strings.Contains(on, `"new_size":2048`) { + t.Errorf("enabled path missing sizes:\n%s", on) + } +} + func TestLoadStateEmpty(t *testing.T) { s, err := LoadState("/nonexistent/path", -1, "") if err != nil { diff --git a/internal/model/model.go b/internal/model/model.go index 40066d8..fab9ce6 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -32,8 +32,8 @@ type CommitFileInfo struct { Status string `json:"status"` OldHash string `json:"old_hash"` NewHash string `json:"new_hash"` - OldSize int64 `json:"old_size"` - NewSize int64 `json:"new_size"` + OldSize int64 `json:"old_size,omitempty"` + NewSize int64 `json:"new_size,omitempty"` Additions int64 `json:"additions"` Deletions int64 `json:"deletions"` } diff --git a/internal/report/report.go b/internal/report/report.go index c583103..ef042f3 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -7,6 +7,7 @@ import ( "math" "sort" "strings" + "sync" "time" "github.com/lex0c/gitcortex/internal/stats" @@ -334,67 +335,138 @@ func buildActivityGrid(raw []stats.ActivityBucket) ([]string, [][]ActivityCell, } func Generate(w io.Writer, ds *stats.Dataset, repoName string, topN int, sf stats.StatsFlags) error { - patterns := stats.WorkingPatterns(ds) - var grid [7][24]int - maxP := 0 - days := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} - for _, p := range patterns { - for d, name := range days { - if name == p.Day { - grid[d][p.Hour] = p.Commits - if p.Commits > maxP { - maxP = p.Commits + testCfg := stats.NewTestConfig(sf.TestGlobs) + now := time.Now().Format("2006-01-02 15:04") + + // Every computation below is a pure read over the immutable Dataset and + // writes only to its own local, so they run concurrently. The previous + // version ran ~18 independent O(files)/O(commits)/O(file-pairs) passes + // serially; on large repos (Linux: ~90s report) the heavy passes + // (FileCoupling, ChurnRisk, DevProfiles) dominate and overlap well. + var ( + summary stats.Summary + contributors []stats.ContributorStat + allHotspots []stats.FileStat + hotspots []stats.FileStat + structure *TreeNode + directories []stats.DirStat + extensions []stats.ExtensionStat + testSummary stats.TestSummary + testTrend []stats.TestRatioBucket + busFactor []stats.BusFactorResult + coupling []stats.CouplingResult + churnRisk []stats.ChurnRiskResult + labelCounts []LabelCount + topCommits []stats.BigCommit + devNetwork []stats.DevEdge + profiles []stats.DevProfile + pareto ParetoData + patterns []stats.WorkingPattern + actRaw []stats.ActivityBucket + actYears []string + actGrid [][]ActivityCell + maxActCommits int + grid [7][24]int + maxP int + totalDirectories int + totalExtensions int + totalBusFactorFiles int + ) + + var wg sync.WaitGroup + run := func(f func()) { + wg.Add(1) + go func() { + defer wg.Done() + f() + }() + } + + run(func() { summary = stats.ComputeSummary(ds) }) + run(func() { contributors = stats.TopContributors(ds, topN) }) + run(func() { + // FileHotspots is the costliest shared input: the table (top-N) and + // the repo tree (full) both derive from one sorted pass instead of + // recomputing it twice. FileHotspots(ds, topN) == FileHotspots(ds, 0) + // truncated (same sort), so the table is just a prefix of the full set. + allHotspots = stats.FileHotspots(ds, 0) + hotspots = allHotspots + if topN > 0 && topN < len(allHotspots) { + hotspots = allHotspots[:topN] + } + structure = BuildRepoTree(allHotspots, htmlTreeDepth) + CapChildrenPerDir(structure, htmlTreeMaxChildrenPerDir) + }) + run(func() { directories = stats.DirectoryStats(ds, topN) }) + run(func() { extensions = stats.ExtensionStats(ds, topN) }) + run(func() { testSummary = stats.ComputeTestSummary(ds, testCfg, topN) }) + run(func() { testTrend = stats.TestRatioOverTime(ds, testCfg, "year") }) + run(func() { busFactor = stats.BusFactor(ds, topN) }) + run(func() { coupling = stats.FileCoupling(ds, topN, sf.CouplingMinChanges) }) + run(func() { churnRisk = stats.ChurnRisk(ds, topN) }) + run(func() { + // Chip strip needs whole-dataset label counts without building a + // per-file result slice; a dedicated counter avoids that allocation. + labelCounts = buildLabelCountList(stats.ChurnRiskLabelCounts(ds)) + }) + run(func() { topCommits = stats.TopCommits(ds, topN) }) + run(func() { devNetwork = stats.DeveloperNetwork(ds, topN, sf.NetworkMinFiles) }) + run(func() { profiles = stats.DevProfiles(ds, "", topN, testCfg) }) + run(func() { pareto = ComputePareto(ds) }) + run(func() { + patterns = stats.WorkingPatterns(ds) + days := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} + for _, p := range patterns { + for d, name := range days { + if name == p.Day { + grid[d][p.Hour] = p.Commits + if p.Commits > maxP { + maxP = p.Commits + } } } } - } - - actRaw := stats.ActivityOverTime(ds, "month") - actYears, actGrid, maxActCommits := buildActivityGrid(actRaw) - - testCfg := stats.NewTestConfig(sf.TestGlobs) - - now := time.Now().Format("2006-01-02 15:04") + }) + run(func() { + actRaw = stats.ActivityOverTime(ds, "month") + actYears, actGrid, maxActCommits = buildActivityGrid(actRaw) + }) + run(func() { totalDirectories = stats.DirectoryCount(ds) }) + run(func() { totalExtensions = stats.ExtensionCount(ds) }) + run(func() { totalBusFactorFiles = stats.BusFactorCount(ds) }) - // Compute label distribution for the Churn Risk chip strip without - // materializing a full result slice. The display table still takes - // the truncated ChurnRisk(ds, topN) call below — only the chip - // counts needed the whole-dataset view, and we can get those from - // a dedicated counter that never builds per-file structs. - labelCountsMap := stats.ChurnRiskLabelCounts(ds) - labelCounts := buildLabelCountList(labelCountsMap) + wg.Wait() data := ReportData{ GeneratedAt: now, RepoName: repoName, - Summary: stats.ComputeSummary(ds), - Contributors: stats.TopContributors(ds, topN), - Hotspots: stats.FileHotspots(ds, topN), - Directories: stats.DirectoryStats(ds, topN), - Extensions: stats.ExtensionStats(ds, topN), - TestSummary: stats.ComputeTestSummary(ds, testCfg, topN), - TestTrend: stats.TestRatioOverTime(ds, testCfg, "year"), + Summary: summary, + Contributors: contributors, + Hotspots: hotspots, + Directories: directories, + Extensions: extensions, + TestSummary: testSummary, + TestTrend: testTrend, ActivityRaw: actRaw, ActivityYears: actYears, ActivityGrid: actGrid, MaxActivityCommits: maxActCommits, - BusFactor: stats.BusFactor(ds, topN), - Coupling: stats.FileCoupling(ds, topN, sf.CouplingMinChanges), - ChurnRisk: stats.ChurnRisk(ds, topN), + BusFactor: busFactor, + Coupling: coupling, + ChurnRisk: churnRisk, ChurnRiskLabelCounts: labelCounts, Patterns: patterns, - TopCommits: stats.TopCommits(ds, topN), - DevNetwork: stats.DeveloperNetwork(ds, topN, sf.NetworkMinFiles), - Profiles: stats.DevProfiles(ds, "", topN, testCfg), - Pareto: ComputePareto(ds), + TopCommits: topCommits, + DevNetwork: devNetwork, + Profiles: profiles, + Pareto: pareto, PatternGrid: grid, MaxPattern: maxP, - Structure: BuildRepoTree(stats.FileHotspots(ds, 0), htmlTreeDepth), - TotalDirectories: stats.DirectoryCount(ds), - TotalExtensions: stats.ExtensionCount(ds), - TotalBusFactorFiles: stats.BusFactorCount(ds), + Structure: structure, + TotalDirectories: totalDirectories, + TotalExtensions: totalExtensions, + TotalBusFactorFiles: totalBusFactorFiles, } - CapChildrenPerDir(data.Structure, htmlTreeMaxChildrenPerDir) return tmpl.Execute(w, data) } diff --git a/internal/report/report_test.go b/internal/report/report_test.go index bad3f9f..c4eb6ee 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -43,6 +43,30 @@ func loadFixture(t *testing.T) *stats.Dataset { return ds } +// Generate runs ~18 independent stat passes concurrently and assembles them +// into ReportData in fixed order. Under `go test -race` this trips the +// detector on any unsynchronized access to the Dataset; asserting byte- +// identical HTML across many runs (modulo the wall-clock stamp) catches a +// racy or order-dependent result that would make the report nondeterministic. +func TestGenerate_ParallelDeterministic(t *testing.T) { + ds := loadFixture(t) + stamp := regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}`) + render := func() string { + var buf bytes.Buffer + if err := Generate(&buf, ds, "testrepo", 10, stats.StatsFlags{CouplingMinChanges: 1, NetworkMinFiles: 1}); err != nil { + t.Fatalf("Generate: %v", err) + } + // The GeneratedAt timestamp is the only legitimately varying byte. + return stamp.ReplaceAllString(buf.String(), "STAMP") + } + first := render() + for i := 0; i < 12; i++ { + if got := render(); got != first { + t.Fatalf("run %d diverged — parallel report assembly is not deterministic", i) + } + } +} + func TestGenerate_SmokeRender(t *testing.T) { ds := loadFixture(t) var buf bytes.Buffer diff --git a/internal/stats/reader.go b/internal/stats/reader.go index 88107b3..03b557a 100644 --- a/internal/stats/reader.go +++ b/internal/stats/reader.go @@ -2,6 +2,7 @@ package stats import ( "bufio" + "bytes" "encoding/json" "fmt" "io" @@ -16,6 +17,33 @@ import ( "github.com/lex0c/gitcortex/internal/model" ) +// jsonTypePrefix is the literal head of every line the extractor emits. +// Each record is marshalled from a struct whose first field is Type, so +// encoding/json always writes `{"type":"...` first. +var jsonTypePrefix = []byte(`{"type":"`) + +// peekType extracts the record discriminator without a full JSON parse. +// Previously every line was unmarshalled twice — once into a {Type} probe, +// then again into the concrete struct — and the probe is NOT cheap: +// encoding/json must scan the whole object to locate the field. On a +// multi-million-line corpus that probe pass was ~half the total JSON work. +// Matching the fixed prefix and reading up to the closing quote replaces it +// with a few-byte check. The type values are fixed ASCII enums with no +// escapes, so the first quote terminates the value. Returns ok=false for +// any line that doesn't fit this shape (foreign JSONL, reordered fields), +// leaving the caller to fall back to a real parse. +func peekType(line []byte) (string, bool) { + if !bytes.HasPrefix(line, jsonTypePrefix) { + return "", false + } + rest := line[len(jsonTypePrefix):] + end := bytes.IndexByte(rest, '"') + if end < 0 { + return "", false + } + return string(rest[:end]), true +} + type commitEntry struct { email string date time.Time @@ -302,14 +330,20 @@ func streamLoadInto(ds *Dataset, r io.Reader, opt LoadOptions, pathPrefix string continue } - var peek struct { - Type string `json:"type"` - } - if err := json.Unmarshal(line, &peek); err != nil { - return fmt.Errorf("line %d: parse type: %w", lineNum, err) + typ, ok := peekType(line) + if !ok { + // Fallback: the fast path only matches our own marshaller. For + // hand-written or reordered JSONL, parse just the discriminator. + var peek struct { + Type string `json:"type"` + } + if err := json.Unmarshal(line, &peek); err != nil { + return fmt.Errorf("line %d: parse type: %w", lineNum, err) + } + typ = peek.Type } - switch peek.Type { + switch typ { case model.CommitType: var c model.CommitInfo if err := json.Unmarshal(line, &c); err != nil { diff --git a/internal/stats/reader_test.go b/internal/stats/reader_test.go index 0c4de17..22082cb 100644 --- a/internal/stats/reader_test.go +++ b/internal/stats/reader_test.go @@ -1,12 +1,79 @@ package stats import ( + "encoding/json" "os" "path/filepath" "strings" "testing" + + "github.com/lex0c/gitcortex/internal/model" ) +// peekType must agree with a full unmarshal on the discriminator for every +// line our own marshaller emits (the fast path), and must signal ok=false +// for anything it can't read cheaply so the caller falls back to a real +// parse. A disagreement here would silently misroute records during load. +func TestPeekType(t *testing.T) { + // Marshal a real record of each type so the fixtures match the exact + // bytes the extractor writes (Type is the first field, so "type" leads). + marshal := func(v interface{}) []byte { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b + } + cases := []struct { + name string + line []byte + want string + }{ + {"commit", marshal(model.CommitInfo{Type: model.CommitType, SHA: "abc"}), model.CommitType}, + {"commit_file", marshal(model.CommitFileInfo{Type: model.CommitFileType, Commit: "abc"}), model.CommitFileType}, + {"commit_parent", marshal(model.CommitParentInfo{Type: model.CommitParentType, SHA: "abc"}), model.CommitParentType}, + {"dev", marshal(model.DevInfo{Type: model.DevType, Email: "a@b.c"}), model.DevType}, + } + for _, c := range cases { + got, ok := peekType(c.line) + if !ok { + t.Errorf("%s: fast path missed (would fall back, defeating the optimization): %s", c.name, c.line) + continue + } + if got != c.want { + t.Errorf("%s: peekType = %q, want %q", c.name, got, c.want) + } + // Cross-check against the authoritative full parse. + var probe struct { + Type string `json:"type"` + } + if err := json.Unmarshal(c.line, &probe); err != nil { + t.Fatalf("%s: control unmarshal: %v", c.name, err) + } + if got != probe.Type { + t.Errorf("%s: peekType %q disagrees with unmarshal %q", c.name, got, probe.Type) + } + } +} + +// Lines that don't fit the fast shape must return ok=false (never a wrong +// type) so the caller's json.Unmarshal fallback handles them. +func TestPeekTypeFallback(t *testing.T) { + fallbacks := []string{ + `{"sha":"abc","type":"commit"}`, // type not first → fast path declines + `{ "type":"commit"}`, // leading space before key + `{"type": "commit"}`, // space after colon + `not json at all`, + ``, + `{"other":"x"}`, // no type key at all + } + for _, line := range fallbacks { + if _, ok := peekType([]byte(line)); ok { + t.Errorf("expected fast-path decline (ok=false) for %q", line) + } + } +} + func TestTruncateMessage(t *testing.T) { short := "fix: one-line subject" if got := truncateMessage(short); got != short { From 73916fef99c41b886a45261b06450d90a67c7b51 Mon Sep 17 00:00:00 2001 From: lex0c Date: Sat, 27 Jun 2026 17:08:41 -0300 Subject: [PATCH 2/2] fix(extract): preserve resolved zero-byte blob sizes with --blob-sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit old_size/new_size were scalar int64 with omitempty, so a legitimately resolved 0 (an empty blob — e.g. an added empty file) was omitted exactly like the default no-lookup path. A --blob-sizes consumer could not tell a 0-byte blob from a missing size, breaking the flag's contract. Make them *int64: nil omits the field (not resolved, null hash, or blob sizes disabled), a non-nil pointer emits the value including 0. emitCommit sets the pointer only for hashes the resolver actually returned (two-value map lookup), so a null/unresolved hash stays absent while a real empty blob emits "...":0. Default-path output is unchanged (no size fields). Verified on pi-hole --blob-sizes: test/__init__.py (git's empty-blob hash e69de29b) now emits "new_size":0; added files keep old_size absent. Co-Authored-By: Claude Opus 4.8 --- internal/extract/extract.go | 28 ++++++++++++++++++++++++++-- internal/extract/extract_test.go | 21 +++++++++++++++++++++ internal/model/model.go | 9 +++++++-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/internal/extract/extract.go b/internal/extract/extract.go index 2ae354c..d239e46 100644 --- a/internal/extract/extract.go +++ b/internal/extract/extract.go @@ -251,6 +251,22 @@ func streamExtract(ctx context.Context, cfg Config, initialState State, writer * return nil } +// resolvedSize returns a pointer to the blob size for hash when the resolver +// produced one (so a genuine 0-byte blob is preserved), or nil when the hash +// was not resolved — a null hash, a lookup failure, or blob sizes disabled +// (sizeMap nil). nil serializes to an omitted field; a non-nil pointer to 0 +// serializes as "...":0, which a --blob-sizes consumer must be able to see. +func resolvedSize(sizeMap map[string]int64, hash string) *int64 { + if sizeMap == nil { + return nil + } + v, ok := sizeMap[hash] + if !ok { + return nil + } + return &v +} + func emitCommit(writer *bufio.Writer, commit *git.StreamCommit, sizeMap map[string]int64, devCache map[string]struct{}, ignorePatterns []string) error { // Filter files and recalculate totals var totalAdd, totalDel int64 @@ -321,6 +337,14 @@ func emitCommit(writer *bufio.Writer, commit *git.StreamCommit, sizeMap map[stri deletions = stats.Deletions } + // Emit a size only for hashes the resolver actually returned. A + // hash present in sizeMap carries its true size, which may be 0 for + // an empty blob; one absent (null hash, unresolved, or blob sizes + // disabled so sizeMap is nil) leaves the field nil → omitted. The + // two-value lookup is what keeps a real 0 distinct from "absent". + oldSize := resolvedSize(sizeMap, entry.OldHash) + newSize := resolvedSize(sizeMap, entry.NewHash) + if err := writeJSON(writer, model.CommitFileInfo{ Type: model.CommitFileType, Commit: commit.Meta.SHA, @@ -329,8 +353,8 @@ func emitCommit(writer *bufio.Writer, commit *git.StreamCommit, sizeMap map[stri Status: entry.Status, OldHash: entry.OldHash, NewHash: entry.NewHash, - OldSize: sizeMap[entry.OldHash], - NewSize: sizeMap[entry.NewHash], + OldSize: oldSize, + NewSize: newSize, Additions: additions, Deletions: deletions, }); err != nil { diff --git a/internal/extract/extract_test.go b/internal/extract/extract_test.go index 6b2ba5d..b8015f5 100644 --- a/internal/extract/extract_test.go +++ b/internal/extract/extract_test.go @@ -63,6 +63,27 @@ func TestEmitCommitBlobSizeGating(t *testing.T) { if !strings.Contains(on, `"old_size":1024`) || !strings.Contains(on, `"new_size":2048`) { t.Errorf("enabled path missing sizes:\n%s", on) } + + // Resolved 0 (empty blob) MUST be emitted, not collapsed to "absent". + // This is the contract --blob-sizes promises: a 0-byte blob is a real, + // distinguishable size. A scalar omitempty int would drop it here. + zero := render(map[string]int64{"aaa": 0, "bbb": 2048}) + if !strings.Contains(zero, `"old_size":0`) { + t.Errorf("resolved 0-byte size was dropped (omitempty regression):\n%s", zero) + } + if !strings.Contains(zero, `"new_size":2048`) { + t.Errorf("enabled path missing non-zero size:\n%s", zero) + } + + // A hash absent from the map (null hash / unresolved) stays omitted even + // when blob sizes are on — "no blob" must not masquerade as a 0-byte one. + absent := render(map[string]int64{"bbb": 2048}) + if strings.Contains(absent, "old_size") { + t.Errorf("unresolved hash emitted a size; should be absent:\n%s", absent) + } + if !strings.Contains(absent, `"new_size":2048`) { + t.Errorf("enabled path missing the resolved size:\n%s", absent) + } } func TestLoadStateEmpty(t *testing.T) { diff --git a/internal/model/model.go b/internal/model/model.go index fab9ce6..18c50f3 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -32,8 +32,13 @@ type CommitFileInfo struct { Status string `json:"status"` OldHash string `json:"old_hash"` NewHash string `json:"new_hash"` - OldSize int64 `json:"old_size,omitempty"` - NewSize int64 `json:"new_size,omitempty"` + // OldSize/NewSize are pointers so the JSON can distinguish three + // states: absent (nil → not resolved, e.g. blob sizes disabled or a + // null hash) vs. a resolved value, which may legitimately be 0 for an + // empty blob. A scalar int64 with omitempty would collapse a real + // 0-byte size into "absent", breaking the --blob-sizes contract. + OldSize *int64 `json:"old_size,omitempty"` + NewSize *int64 `json:"new_size,omitempty"` Additions int64 `json:"additions"` Deletions int64 `json:"deletions"` }