Diffs
S19 ships the diff renderer wired into the S18 single-commit page. The same renderer will host S20's compare view and S22's PR files-changed once those sprints land.
Pipeline
git diff (--patch) → []byte
internal/repos/diff/source produces the raw patch bytes
▼
internal/repos/diff/parse wraps go-gitdiff into our types
▼
internal/repos/diff/render emits HTML for the templates
The shape is library-on-the-inside, package-on-the-outside: the
public surface exposes our Diff/File/Hunk/Line structs so we can
swap the parser later without breaking callers.
Source helpers (source/)
| Function | Git invocation | Use |
|---|---|---|
FromCommit |
git diff-tree -p -r --root <sha> |
single-commit page |
FromRange |
git diff <base>..<head> |
"show all changes" |
FromMergeBase |
git diff <base>...<head> (three-dot) |
PR / compare |
Options carries IgnoreWhitespace (-w) and FindRenames (-M -C).
Whitespace toggles re-source rather than parser-side filter — git's
-w respects language quirks (Python indentation, etc.) more
correctly than post-processing hunks.
The --root flag on FromCommit is what lets the initial parentless
commit emit its files (against the empty tree); without it the
parser sees nothing.
Parser (parse/)
Wraps github.com/bluekeyes/go-gitdiff/gitdiff. Translates each
*gitdiff.File into our File, expanding TextFragments into
Hunks whose Lines carry one of four LineKinds:
| Kind | Meaning |
|---|---|
LineContext |
unchanged context line |
LineAdd |
+ — added line |
LineDelete |
- — deleted line |
LineNoNewline |
\ No newline at end of file (rare) |
Old/new line numbers are populated based on kind:
- Context: both populated
- Add: only
NewLineNo - Delete: only
OldLineNo
The renderer reads SizeBytes (sum of line content) for the
"per-file too large" decision — cheap to compute during parse.
Renderer (render/)
Two modes share the same RenderFile skeleton (header → hunks →
lines):
- Unified — old/new line numbers in two adjacent
<td>cells, content in the third. The+/-/space marker is prepended to the content for visual consistency withgit diff. - Split — left side (old) and right side (new) in separate columns. Adds and deletes pair row-by-row; trailing additions on either side leave the other column padded.
Per-file collapse + whole-diff truncate
| Threshold | Default | Trigger |
|---|---|---|
PerFileLineCap |
1000 | files with > 1000 changed lines collapse under <details> |
PerFileBytesCap |
500 KiB | per-file size cap |
WholeDiffFileCap |
100 | whole-diff truncation: every file collapses, file list eager |
The collapsed <details> currently renders the hunks inside the
toggle (lazy at the user's click but still inline). The spec calls
for a separate /diff-fragment endpoint that fetches per-file hunks
on demand; that's a polish for S36 when we have a real big-PR
workload to bench against.
Binary + image
Binary files emit a shithub-diff-binary placeholder. Image files
(by extension) render an shithub-diff-image placeholder note —
the side-by-side preview wires once the renderer accepts ref+path
context for /raw/... URLs (deferred polish; the commit page already
links the changed file in the file table).
Highlighting
The renderer currently HTML-escapes line content; the file-level
<a> link to the blob view (which uses the S17 Chroma highlighter)
is the path users follow when they want syntax highlighting on a
specific change. Per-line Chroma highlighting in the diff itself is
deferred — escape is the floor; richer rendering is polish.
Routes
The S19 changes are entirely additive to the S18 commit page. New query parameters:
| Param | Default | Effect |
|---|---|---|
?diff=unified |
yes | unified mode |
?diff=split |
split mode | |
?w=1 |
re-source with -w (ignore whitespace) |
The toggles emit links that preserve the other parameter so users don't lose a mode when toggling whitespace and vice versa.
Tests
parse/parse_test.go— happy path, rename, binary, empty, multi-file.render/render_test.go— unified + split classes present, binary placeholder, rename header, too-large collapse, empty-diff label.
Pitfalls handled
- Initial-commit diff emits nothing without
--root— the source helper passes the flag. go-gitdiffreturns(nil, _, io.EOF)for empty input — treated as "no files," no error.- HTML escape on every line — content goes through
html.EscapeString; only the renderer's wrapper markup is raw. - Whitespace toggle cache key — currently no cache; when one
lands in S36, the cache key must include
IgnoreWhitespace. - Trailing-newline preservation —
parse.trimNewlinestrips one\n; multi-newline trailing data is preserved verbatim.
Deferred polish (tracked)
POST /{owner}/{repo}/diff-fragment?...endpoint — per-file lazy fetch for whole-diff-truncated cases. Track in S36 with the rest of the diff/blame caching work; current<details>inline expand is the floor.- Per-line Chroma highlighting in diff hunks — every line
currently HTML-escapes; richer rendering is post-MVP. The Chroma
helper (
internal/repos/highlight) is the same one the blob view uses, so the wiring is mechanical when desired. - Image diff side-by-side preview — needs
/raw/<sha>/<path>threading into the renderer. Current placeholder is honest; the file table already links the new path's blob. Diffcache keyed on(base_sha, head_sha)— S36, with the rest of the immutable-content cache layer.
View source
| 1 | # Diffs |
| 2 | |
| 3 | S19 ships the diff renderer wired into the S18 single-commit page. |
| 4 | The same renderer will host S20's compare view and S22's PR |
| 5 | files-changed once those sprints land. |
| 6 | |
| 7 | ## Pipeline |
| 8 | |
| 9 | ``` |
| 10 | git diff (--patch) → []byte |
| 11 | internal/repos/diff/source produces the raw patch bytes |
| 12 | ▼ |
| 13 | internal/repos/diff/parse wraps go-gitdiff into our types |
| 14 | ▼ |
| 15 | internal/repos/diff/render emits HTML for the templates |
| 16 | ``` |
| 17 | |
| 18 | The shape is library-on-the-inside, package-on-the-outside: the |
| 19 | public surface exposes our `Diff/File/Hunk/Line` structs so we can |
| 20 | swap the parser later without breaking callers. |
| 21 | |
| 22 | ## Source helpers (`source/`) |
| 23 | |
| 24 | | Function | Git invocation | Use | |
| 25 | | ----------------- | -------------------------------------------- | ------------------ | |
| 26 | | `FromCommit` | `git diff-tree -p -r --root <sha>` | single-commit page | |
| 27 | | `FromRange` | `git diff <base>..<head>` | "show all changes" | |
| 28 | | `FromMergeBase` | `git diff <base>...<head>` (three-dot) | PR / compare | |
| 29 | |
| 30 | `Options` carries `IgnoreWhitespace` (`-w`) and `FindRenames` (`-M -C`). |
| 31 | Whitespace toggles re-source rather than parser-side filter — git's |
| 32 | `-w` respects language quirks (Python indentation, etc.) more |
| 33 | correctly than post-processing hunks. |
| 34 | |
| 35 | The `--root` flag on `FromCommit` is what lets the initial parentless |
| 36 | commit emit its files (against the empty tree); without it the |
| 37 | parser sees nothing. |
| 38 | |
| 39 | ## Parser (`parse/`) |
| 40 | |
| 41 | Wraps `github.com/bluekeyes/go-gitdiff/gitdiff`. Translates each |
| 42 | `*gitdiff.File` into our `File`, expanding `TextFragments` into |
| 43 | `Hunks` whose `Lines` carry one of four `LineKind`s: |
| 44 | |
| 45 | | Kind | Meaning | |
| 46 | | --------------- | ---------------------------------------- | |
| 47 | | `LineContext` | unchanged context line | |
| 48 | | `LineAdd` | `+` — added line | |
| 49 | | `LineDelete` | `-` — deleted line | |
| 50 | | `LineNoNewline` | `\ No newline at end of file` (rare) | |
| 51 | |
| 52 | Old/new line numbers are populated based on kind: |
| 53 | - Context: both populated |
| 54 | - Add: only `NewLineNo` |
| 55 | - Delete: only `OldLineNo` |
| 56 | |
| 57 | The renderer reads `SizeBytes` (sum of line content) for the |
| 58 | "per-file too large" decision — cheap to compute during parse. |
| 59 | |
| 60 | ## Renderer (`render/`) |
| 61 | |
| 62 | Two modes share the same `RenderFile` skeleton (header → hunks → |
| 63 | lines): |
| 64 | |
| 65 | - **Unified** — old/new line numbers in two adjacent `<td>` cells, |
| 66 | content in the third. The `+`/`-`/space marker is prepended to the |
| 67 | content for visual consistency with `git diff`. |
| 68 | - **Split** — left side (old) and right side (new) in separate |
| 69 | columns. Adds and deletes pair row-by-row; trailing additions on |
| 70 | either side leave the other column padded. |
| 71 | |
| 72 | ### Per-file collapse + whole-diff truncate |
| 73 | |
| 74 | | Threshold | Default | Trigger | |
| 75 | | ----------------- | ------- | ----------------------------------------------------------- | |
| 76 | | `PerFileLineCap` | 1000 | files with > 1000 changed lines collapse under `<details>` | |
| 77 | | `PerFileBytesCap` | 500 KiB | per-file size cap | |
| 78 | | `WholeDiffFileCap`| 100 | whole-diff truncation: every file collapses, file list eager | |
| 79 | |
| 80 | The collapsed `<details>` currently renders the hunks inside the |
| 81 | toggle (lazy at the user's click but still inline). The spec calls |
| 82 | for a separate `/diff-fragment` endpoint that fetches per-file hunks |
| 83 | on demand; that's a polish for **S36** when we have a real big-PR |
| 84 | workload to bench against. |
| 85 | |
| 86 | ### Binary + image |
| 87 | |
| 88 | Binary files emit a `shithub-diff-binary` placeholder. Image files |
| 89 | (by extension) render an `shithub-diff-image` placeholder note — |
| 90 | the side-by-side preview wires once the renderer accepts ref+path |
| 91 | context for `/raw/...` URLs (deferred polish; the commit page already |
| 92 | links the changed file in the file table). |
| 93 | |
| 94 | ### Highlighting |
| 95 | |
| 96 | The renderer currently HTML-escapes line content; the file-level |
| 97 | `<a>` link to the blob view (which uses the S17 Chroma highlighter) |
| 98 | is the path users follow when they want syntax highlighting on a |
| 99 | specific change. Per-line Chroma highlighting in the diff itself is |
| 100 | deferred — escape is the floor; richer rendering is polish. |
| 101 | |
| 102 | ## Routes |
| 103 | |
| 104 | The S19 changes are entirely additive to the S18 commit page. New |
| 105 | query parameters: |
| 106 | |
| 107 | | Param | Default | Effect | |
| 108 | | ------------- | ------- | --------------------------------------- | |
| 109 | | `?diff=unified` | yes | unified mode | |
| 110 | | `?diff=split` | | split mode | |
| 111 | | `?w=1` | | re-source with `-w` (ignore whitespace) | |
| 112 | |
| 113 | The toggles emit links that preserve the other parameter so users |
| 114 | don't lose a mode when toggling whitespace and vice versa. |
| 115 | |
| 116 | ## Tests |
| 117 | |
| 118 | - `parse/parse_test.go` — happy path, rename, binary, empty, multi-file. |
| 119 | - `render/render_test.go` — unified + split classes present, binary |
| 120 | placeholder, rename header, too-large collapse, empty-diff label. |
| 121 | |
| 122 | ## Pitfalls handled |
| 123 | |
| 124 | - **Initial-commit diff emits nothing without `--root`** — the |
| 125 | source helper passes the flag. |
| 126 | - **`go-gitdiff` returns `(nil, _, io.EOF)` for empty input** — |
| 127 | treated as "no files," no error. |
| 128 | - **HTML escape on every line** — content goes through |
| 129 | `html.EscapeString`; only the renderer's wrapper markup is raw. |
| 130 | - **Whitespace toggle cache key** — currently no cache; when one |
| 131 | lands in S36, the cache key must include `IgnoreWhitespace`. |
| 132 | - **Trailing-newline preservation** — `parse.trimNewline` strips one |
| 133 | `\n`; multi-newline trailing data is preserved verbatim. |
| 134 | |
| 135 | ## Deferred polish (tracked) |
| 136 | |
| 137 | * **`POST /{owner}/{repo}/diff-fragment?...` endpoint** — per-file |
| 138 | lazy fetch for whole-diff-truncated cases. Track in S36 with the |
| 139 | rest of the diff/blame caching work; current `<details>` inline |
| 140 | expand is the floor. |
| 141 | * **Per-line Chroma highlighting in diff hunks** — every line |
| 142 | currently HTML-escapes; richer rendering is post-MVP. The Chroma |
| 143 | helper (`internal/repos/highlight`) is the same one the blob view |
| 144 | uses, so the wiring is mechanical when desired. |
| 145 | * **Image diff side-by-side preview** — needs `/raw/<sha>/<path>` |
| 146 | threading into the renderer. Current placeholder is honest; the |
| 147 | file table already links the new path's blob. |
| 148 | * **`Diff` cache keyed on `(base_sha, head_sha)`** — S36, with the |
| 149 | rest of the immutable-content cache layer. |