gitdiff

package
v0.26.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: MIT Imports: 33 Imported by: 0

Documentation

Overview

Package gitdiff — deeplink.go centralises the prereview URL-hash grammar so every consumer (the SetURLHash action, the markdown + HTML link rewriters, the line-gutter permalink template) shares one parser and one stringifier. Extending the grammar means changing these two functions, not hunting through the codebase.

Grammar:

hash      := path [":" target]
path      := <URL-encoded segments joined by "/">
target    := lineRange | "h-" anchorID
lineRange := "L" <n> ["-L" <m>]
anchorID  := <opaque, URL-encoded>

Examples:

foo.go                  — open foo.go
subdir/foo.go:L42       — open subdir/foo.go, select line 42
subdir/foo.go:L42-L48   — open subdir/foo.go, select lines 42–48
README.md:h-architecture — open README.md, scroll to heading "architecture"

Package gitdiff shells out to the git CLI to enumerate changed files and load per-file unified diffs for prereview. Shelling out (vs. a Go git library) is deliberate — it respects the user's local git config (rename detection thresholds, autocrlf, etc.) so what prereview shows matches what `git diff` shows in their terminal.

Index

Constants

This section is empty.

Variables

View Source
var HighlightCSS = func() string {
	var b strings.Builder
	for _, s := range Schemes {
		b.WriteString(schemeSyntaxCSS(s))
	}
	return b.String()
}()

HighlightCSS is the chroma stylesheet served as /syntax.css. It carries every registered scheme × both modes so the page never refetches CSS on a theme or mode switch. For each scheme, all rules are scoped to that scheme's [data-scheme] so the three schemes' (colliding) token classes don't leak into one another. Per scheme:

  • light, scoped `[data-scheme="x"]` — applies unless a dark rule overrides;
  • dark, scoped `[data-scheme="x"][data-mode="dark"]` (higher specificity) for an explicit Dark toggle;
  • the dark block again inside `@media (prefers-color-scheme: dark)`, scoped `[data-scheme="x"]:not([data-mode="light"])` so System mode follows the OS with no JS (an explicit Light opt-out still wins).

Computed once at package-init; main.go serves it verbatim.

View Source
var Schemes = []Scheme{
	{"solarized", "Solarized", "solarized-light", "solarized-dark"},
	{"gruvbox", "Gruvbox", "gruvbox-light", "gruvbox"},
	{"catppuccin", "Catppuccin", "catppuccin-latte", "catppuccin-mocha"},
}

Schemes is the registry. Order is the picker's cycle order; Schemes[0] is the default (see state.SchemeName / DataScheme). internal/review reads Name+Label for the picker; this package reads the chroma styles for /syntax.css.

Functions

func ChangedCount

func ChangedCount(entries []FileEntry) int

ChangedCount returns how many entries have a non-empty Status — i.e. how many of these files differ from the active base. Useful for the drawer header ("N files · M changed").

func CurrentBranch

func CurrentBranch(repo string) string

CurrentBranch returns the short name of the checked-out branch (e.g. "main", "feature/foo"). Returns "HEAD" in a detached state (which is what `git rev-parse --abbrev-ref HEAD` emits there). Errors return an empty string — callers should display "current branch" or similar generic copy when the result is blank.

func EmptyTreeHash added in v0.22.0

func EmptyTreeHash(repo string) (string, error)

EmptyTreeHash returns repo's empty-tree object hash via `git hash-object -t tree /dev/null`. It's computed (never hardcoded) so it's correct under both SHA-1 (4b825dc6…) and SHA-256 repositories. Diffing against it makes every tracked line appear added and thus commentable.

func FormatHash added in v0.3.6

func FormatHash(path string, fromLine, toLine int, anchor string) string

FormatHash serialises path + line range + anchor back into a URL hash (without leading "#"). Empty path → "" (caller decides whether to render an empty data-attr). Line range takes precedence over anchor when both are present — the line selection is the more specific target.

func HighlightLine

func HighlightLine(filename, content string) template.HTML

HighlightLine is retained for callers that want to highlight a single line in isolation (currently none in the project — kept for backward compatibility and potential ad-hoc use). For bulk highlighting use HighlightLines, which amortises tokenizer startup across all lines.

func HighlightLines

func HighlightLines(filename string, contents []string) []template.HTML

HighlightLines tokenises every line of `contents` in a single pass and returns the syntax-highlighted HTML per line. Falls back to per-line HTML escape on tokenizer errors. Empty input returns nil.

Joining with '\n' before tokenising lets chroma understand line transitions naturally — string tokens that span lines keep their type, etc. The output is then split back into per-line groups by walking the token stream and splitting tokens on their '\n' bytes.

func IsHTMLPath added in v0.3.3

func IsHTMLPath(path string) bool

IsHTMLPath reports whether path is an HTML file by extension. The viewer uses this to flip the preview area into a sandboxed iframe instead of the syntax-highlighted line view.

func IsMarkdownPath

func IsMarkdownPath(path string) bool

IsMarkdownPath reports whether path is a Markdown file by extension.

func IsValidRef

func IsValidRef(repo, ref string) bool

IsValidRef reports whether `ref` resolves to a commit in this repo. Used by the runtime base picker to validate user-typed refs before swapping state.Base. Treats both branch names and commit-ish refs (HEAD~3, abc1234) uniformly via `git rev-parse --verify`.

func ListBranches

func ListBranches(repo string) []string

ListBranches returns the short names of every local branch in the repo, sorted alphabetically. Used to populate the base-picker dropdown — `git for-each-ref` runs in microseconds even on big repos, so calling it from every Mount is fine.

func ListRemoteBranches

func ListRemoteBranches(repo string) []string

ListRemoteBranches returns the short names of every remote-tracking branch (e.g. "origin/main"), sorted. The symbolic "<remote>/HEAD" entries are skipped — they're aliases, not useful review bases. Lets the base picker offer "review since I branched off origin/main" without a freeform input.

func MarkRanges added in v0.15.0

func MarkRanges(fragment template.HTML, ranges []ColRange) template.HTML

MarkRanges wraps each [From, To) rune range of a line's rendered HTML in a <mark class="comment-span">, splitting across chroma token spans as needed so a range crossing several tokens still highlights contiguously. The input is the per-line highlighted fragment (chroma token spans) — or any HTML whose textContent equals the raw line; ranges index that textContent by rune.

It fails OPEN: on a parse error or empty/invalid ranges it returns the fragment unchanged (an un-highlighted span beats a dropped line).

func OpenExternalLinksInNewTab added in v0.16.0

func OpenExternalLinksInNewTab(htmlFragment string) string

OpenExternalLinksInNewTab adds target="_blank" rel="noopener noreferrer" to every <a> whose href carries a URL scheme (http/https/mailto/…), so an external link opens in a new tab instead of navigating the current page away. Anchors that already declare a target, and in-page anchors (`#id`), are left untouched. It reuses the same tokenizer round-trip as rewriteRelativeURLs, so a fragment with no external anchors — or a malformed one — returns unchanged.

Used by the standalone Usage page (RenderMarkdownDoc): that page is served on its own route, so a same-tab click on a doc link would drop the reader out of the app. The review-view renderer deliberately does NOT call this — its links deep-link within the SPA.

func RenderMarkdownDoc added in v0.16.0

func RenderMarkdownDoc(src []byte) template.HTML

RenderMarkdownDoc renders a whole Markdown document to a single safe-HTML string using the SAME goldmark instance as the review view (mdRenderer): GFM, class-based chroma fences (so /syntax.css and the Light/Dark toggle apply), footnotes, emoji, and GitHub alert callouts. Unlike RenderMarkdownBlocks it does not split into commentable units — it's for standalone, non-reviewable docs (the in-app Usage page). Empty input → "".

Safe-mode still holds: no html.WithUnsafe, so raw HTML in src is dropped (the one audited exception is a local <img>, htmlimage.go); this content is first-party (an embedded doc), but the guarantee is the renderer's, not the caller's.

func ResolveRelativeLink(currentFile, target string) (string, bool)

ResolveRelativeLink rewrites a link target from inside currentFile to a prereview hash URL when it points at a relative path inside the repo, or returns the original target unchanged when it points elsewhere (http://, https://, mailto:, tel:, data:, // protocol- relative, or absolute path starting with `/`).

Returns the rewritten target and isExternal=false for in-repo relative paths (caller should write the new hash). Returns the original target and isExternal=true for everything else (caller should pass through unchanged AND, for HTML, add target="_blank" rel="noopener" for safety).

Intra-doc anchors (`#foo`) resolve to `currentFile:h-foo`. Pure relative paths (`OTHER.md`, `./sibling.md`, `../parent/file.md`) resolve against currentFile's directory. A path with its own `#anchor` suffix gets the anchor as the h- target. A path with `?query` is treated as external (we don't have a query-string channel into the hash grammar).

func WorktreeClean added in v0.22.0

func WorktreeClean(repo string) (bool, error)

WorktreeClean reports whether repo's working tree has no uncommitted changes: `git status --porcelain` with trimmed-empty output. A git error (not a repo, git missing) propagates so the caller can fall back rather than assume clean.

Types

type ColRange added in v0.15.0

type ColRange struct {
	From int
	To   int
}

ColRange is a half-open [From, To) rune range within a single rendered line, in the same raw-line coordinate space the client computes text-comment offsets in (guaranteed equal to the line's .content textContent by TestHighlightLines_TextContentEqualsRaw). Used to wrap the commented span in a <mark> when rendering the line.

type DiffLine

type DiffLine struct {
	OldNum  int    // 0 when the line doesn't exist on the old side
	NewNum  int    // 0 when the line doesn't exist on the new side
	Kind    string // "add" | "del" | "ctx"
	Content string // raw line text, no leading +/-/space, no trailing \n
	// HighlightedContent is Content rendered as syntax-highlighted HTML
	// spans via chroma. Templates render this instead of Content; the
	// raw Content stays around for CSV exports and other consumers that
	// don't want HTML markup.
	HighlightedContent template.HTML
}

DiffLine is one rendered line in the viewer. Exactly one of OldNum / NewNum may be zero (for additions and deletions respectively); for context lines (Kind == "ctx") both are populated.

func CollapseToHunks

func CollapseToHunks(lines []DiffLine, ctx int) []DiffLine

CollapseToHunks turns a full-file line list (every line tagged add/del/ctx, as LoadDiff produces with -U999999) into a true diff view: only changed lines plus `ctx` unchanged lines on each side of a change. Each maximal run of dropped unchanged lines is replaced by a single synthetic fold line:

Kind == "fold", Content == "<N>"  (N = number of skipped lines)

The fold line's OldNum/NewNum are set to the first skipped line's numbers so the template can derive a stable, collision-free data-key (gaps never overlap).

If the input contains no add/del line (an unchanged file — shown via the all-files scope toggle or the clean-tree fallback) there is no diff to collapse, so the input is returned unchanged. Callers treat that as "show the whole file".

type FileDiff

type FileDiff struct {
	Path     string
	Lines    []DiffLine
	IsBinary bool   // true when git reports "Binary files differ" — Lines is nil
	Note     string // e.g. "file added", "file deleted"; informational, may be empty
	// MaxLineChars is the longest Content length (in characters) across
	// all DiffLines. Used by the template to set `.code` width in `ch`
	// units up front; without this hint, the browser would compute
	// `width: max-content` for every line-row to determine the diff
	// container's scrollable extent — expensive on big files (950+
	// rows) and the dominant cost in perceived vertical-scroll lag.
	MaxLineChars int
	// MarkdownBlocks is the rendered-Markdown view of the current
	// (new-side) file: top-level blocks tagged with their source line
	// range. Populated only for .md/.markdown paths; nil otherwise.
	// Computed once per load and cached with the FileDiff.
	MarkdownBlocks []MarkdownBlock
	// HTMLDoc is the preview document for .html/.htm paths: the file
	// transformed for the sandboxed preview iframe's srcdoc (see
	// RenderHTMLPreview) — real <body>/<head> with a <base> injected,
	// each top-level child stamped with its source line range, and
	// scripts/handlers stripped. Empty for non-HTML paths.
	HTMLDoc string
	// HTMLBlocks pairs with HTMLDoc: one entry per top-level <body>
	// child, carrying the source line range a click in the iframe
	// resolves to. Nil for non-HTML paths.
	HTMLBlocks []HTMLBlock
	// Headings is the TOC source for the rendered-Markdown view:
	// every Markdown heading in the new-side file, in document order,
	// with goldmark's slugified anchor `id`. Populated only for
	// .md/.markdown paths; nil otherwise. Filtering by level (e.g.
	// h1–h3) and the "≥ 2 entries" visibility gate live at the caller
	// (state.RenderedHeadings()).
	Headings []Heading
}

FileDiff is the full-file, line-by-line diff for one path.

func DiffContents added in v0.18.0

func DiffContents(path string, oldData, newData []byte) *FileDiff

DiffContents renders the line diff between two byte contents (old → new) as a FileDiff — used to compare a historical artifact version against the current working-tree file (#90 "Diff vs current"). It uses a pure-Go Myers diff (hexops/gotextdiff), so it needs no git and works on version blobs that were never committed, then feeds the unified output through the same parseUnifiedDiff + highlightLines the git path uses so the result renders identically (add/del/ctx rows, syntax highlighting). Identical inputs render as a plain all-context file; a binary on either side is reported as binary.

func DiffContentsNoHighlight added in v0.23.1

func DiffContentsNoHighlight(path string, oldData, newData []byte) *FileDiff

DiffContentsNoHighlight is DiffContents without the chroma syntax-highlight pass — for callers that read only line Kind + raw Content (e.g. #155's version summary), so highlighting every changed line isn't computed and then thrown away.

func LoadDiff

func LoadDiff(repo, base, path string) (*FileDiff, error)

LoadDiff returns the full-file diff for one path against base.

Uses `git diff --no-color -U999999` so every line of the file appears (additions, deletions, context). For files that are pure additions (A) or pure deletions (D) git produces a diff against /dev/null, which the parser handles naturally — every line is "add" or "del" respectively.

Files larger than maxRenderableFileBytes short-circuit with a Note instead of loading content — the viewer renders the note and skips the per-line markup entirely.

func LoadDiffNoGit added in v0.1.0

func LoadDiffNoGit(repo, path string) (*FileDiff, error)

LoadDiffNoGit returns the full-file view for one path when there is no git base to diff against — the sibling of LoadDiff. Every line is an "add" (the file is wholly "new" with no base), produced by the same loadUntrackedAsAdded reader git mode already uses for untracked files, then run through the shared highlightLines hook so syntax highlighting and rendered-Markdown blocks populate identically to the git path. Files over the render cap short-circuit with a Note, exactly like LoadDiff, so a huge file never gets read into the page.

func RenderBytesAsFile added in v0.18.0

func RenderBytesAsFile(path string, data []byte) *FileDiff

RenderBytesAsFile renders raw file content as an all-context FileDiff — every line is "ctx", so the diff/file views show it plainly with no add/del coloring. It is the read-only render used to VIEW a historical artifact version (#90): the version store hands back the exact bytes of a past version (VersionStore.Blob), and this turns them into the same *FileDiff the live diff pane renders, syntax-highlighted identically via the shared highlightLines hook. Mirrors loadFileAsCtx but reads from bytes instead of a working-tree path, so it works on version blobs that no longer exist on disk.

type FileEntry

type FileEntry struct {
	Path         string // post-rename / current path
	Status       string // "A","M","D","R","C","T","U"
	Renamed      string // pre-rename path for R/C; empty otherwise
	Added        int    // lines added vs base; -1 means "binary / unknown"
	Deleted      int    // lines deleted vs base; -1 means "binary / unknown"
	CommentCount int    // populated by the controller, not by gitdiff
}

FileEntry is one row in the changed-files list.

func ListFiles

func ListFiles(repo, base string) ([]FileEntry, error)

ListFiles enumerates every file currently in the working tree (tracked + untracked-non-ignored), annotated with diff information vs base where applicable.

This is the foundation of "show all files; diff is an overlay": prereview now always lets the reviewer browse the whole repo, with diff metadata layered on top of the files that happen to differ from base. Files unchanged vs base appear in the list with Status="", Added=0, Deleted=0 — the template renders them without a diff badge.

Deleted files (present in base, absent in the working tree) are intentionally omitted — they don't exist in the repo "right now". Reviewers who care about deletions can flip the base or read git history directly.

base may be "HEAD", a branch name, or any revision spec git accepts. If base is empty, diff annotation is skipped and every entry is bare.

func ListFilesNoGit added in v0.1.0

func ListFilesNoGit(repo, single string) ([]FileEntry, error)

ListFilesNoGit enumerates the reviewable files when the target is NOT a git repo — the sibling of ListFiles for loose files / non-git directories.

  • single != "" → exactly one entry (single-file review). single is the basename relative to repo (repo is its parent directory).
  • single == "" → walk repo recursively. Skips the .git/ and .prereview/ control dirs, dotfiles and dotdirs (editor/state cruft a non-git tree has no .gitignore to hide), and files over the render cap (reviewing megabytes in a browser is pointless and would only render a "too large" placeholder anyway).

Every entry is Status "A": with no base there is no diff, so the whole file is "new" — the same convention ListFiles uses for untracked files and the empty-tree (clean-tree whole-branch) review. Added carries the line count so the drawer still shows a "+N" badge; binary files report 0 (countLines can't read them) and render as a binary placeholder when opened. Entries are sorted by path so the drawer is stable.

type HTMLBlock added in v0.3.3

type HTMLBlock struct {
	StartLine int
	EndLine   int
}

HTMLBlock is one independently-commentable region of an HTML file: a top-level <body> child tagged with the 1-based, inclusive SOURCE line range it came from. The line range is what makes HTML commenting line-accurate — a comment placed on a block anchors to these real source lines so it round-trips with the raw view and the CSV (same contract as MarkdownBlock).

The block carries no HTML of its own: the whole document is rendered once into the preview iframe's srcdoc (see RenderHTMLPreview), and the block's element there carries matching data-from/data-to attributes so a click in the iframe resolves to this range.

func RenderHTMLPreview added in v0.6.0

func RenderHTMLPreview(src []byte, currentPath string) (string, []HTMLBlock)

RenderHTMLPreview transforms an HTML document into the form shown in the preview iframe's srcdoc and returns it alongside the per-block source line ranges used for commenting.

The document is passed through almost verbatim so the iframe renders a real page (real <body>, <head> styles, var()/@media/vw/sticky all resolve against the iframe viewport). These transforms apply:

  • A <base href="/<dir>/"> is injected (the file's directory, URL- encoded) so relative href/src resolve to server-absolute paths the static fallback serves — srcdoc otherwise inherits the parent's base and would fetch a subdirectory file's assets from the repo root.
  • Each top-level <body> child element gets data-from/data-to attributes carrying its source line range; the client maps a click inside the iframe to the matching range via closest('[data-from]').
  • The preview bridge beacon (previewBridgeJS) is injected so the iframe can post its height + block rects out (the iframe is opaque-origin, so the parent can't read its contentDocument).
  • on* event-handler attributes and javascript: URLs are still stripped (serializeTag). <script> elements are NOT stripped — the page's own scripts must run (that is the whole point: JS-generated CSS like the Tailwind Play CDN). The execution boundary is the opaque-origin sandbox (sandbox="allow-scripts", NO allow-same-origin), which lets scripts run but blocks all access to the parent app — never the two together.

<head> and <body> are both optional in HTML5, so the tokenizer also synthesizes an implicit body: outside any explicit structural element, head-metadata tags (style/link/script/…) pass through as head content and the first flow element opens the body (see headMetaElements). A body-less page therefore renders a preview like any other.

currentPath is the file's repo-relative path (drives the <base> dir). Empty input, or input with no flow content at all (e.g. head-only), yields an empty document and nil blocks (no commentable surface).

The document is returned as a plain string: the template places it in the iframe's `srcdoc="…"` attribute, where html/template's contextual autoescaper applies the correct escaping. The browser reconstitutes the document from the attribute; the opaque-origin sandbox (allow-scripts, no allow-same-origin) is the security boundary — scripts run but can't reach the parent app.

type Heading added in v0.2.0

type Heading struct {
	Level int
	ID    string
	Text  string
	Line  int
}

Heading captures one rendered Markdown heading for the TOC sidebar. Level is 1–6 (matches the source HN); ID is the slugified anchor goldmark's WithAutoHeadingID emits (stable across renders, collisions disambiguated by `-1`/`-2` suffix); Text is the plain-text inline content; Line is the 1-based source line where the heading starts — used to map a clicked TOC entry to the MarkdownBlock containing it so a server-side scroll directive can target the right block. Empty source → nil.

func ExtractHeadings added in v0.2.0

func ExtractHeadings(src []byte) []Heading

ExtractHeadings walks src as Markdown and returns each heading in document order, with the `id` attribute goldmark's WithAutoHeadingID transformer attached during parse. Callers filter by Level for TOC depth (we typically render h1–h3 only). Empty / heading-less input → nil.

type MarkdownBlock

type MarkdownBlock struct {
	HTML      template.HTML
	StartLine int
	EndLine   int
}

MarkdownBlock is one independently-commentable rendered unit plus the 1-based, inclusive SOURCE line range it came from. The line range is what makes rendered-Markdown commenting line-accurate: a comment placed on a unit anchors to these real source lines, so it round-trips with the raw line view and the CSV — nothing is renumbered.

Single-line nodes (heading, code fence, blockquote, …) are one block. Containers are descended ONE level so review comments can target a single line: a list yields one block per list item, a table one block for the header row plus one per body row. A paragraph that spans multiple SOURCE lines is split into one block per source line ONLY when it is authored one-sentence-per-line (every line except possibly the last ends a sentence) — CommonMark soft-wraps such a paragraph into one <p>, and splitting restores per-line commenting. A hard-wrapped paragraph (lines break mid-sentence) instead renders as a single reflowed CommonMark paragraph, so a sentence is never broken across visual lines; it stays commentable at paragraph granularity (its wrap points are arbitrary, so per-line comments there would be meaningless). An inline span that crosses a soft line-break degrades to literal text on that one line; it never produces invalid HTML.

func RenderMarkdownBlocks

func RenderMarkdownBlocks(src []byte, currentPath string) []MarkdownBlock

RenderMarkdownBlocks parses src and returns each commentable unit as safe HTML tagged with its source line span. currentPath drives the relative-link rewriter: in-repo links like `[other](OTHER.md)` and `[section](#anchor)` are rewritten to deep-link hashes so a click stays in the SPA; external links pass through unchanged. Empty currentPath disables rewriting (used by tests and any caller that doesn't care about deep links). Empty input → nil.

type ParsedHash added in v0.3.6

type ParsedHash struct {
	Path     string
	FromLine int
	ToLine   int
	Anchor   string
}

ParsedHash is the structured form of a prereview URL hash. Empty fields mean "not in this hash". Path is the only required field for the hash to be a meaningful deep link — a hash without a path (e.g. "L42" or ":h-foo") is rejected (Path == "").

func ParseHash added in v0.3.6

func ParseHash(hash string) ParsedHash

ParseHash splits a URL hash (without leading "#") into its components. Tolerant: a hash without a target part is fine (just the path); a hash that doesn't parse as a deep link returns ParsedHash{Path: hash} on the assumption that the leading segment is still a file path, OR an empty struct if the hash isn't even path-shaped (no extension, no slash, no colon — likely a dialog/ popover/details id from the existing setupHashLink machinery).

The server treats any unresolvable Path as "no such file" and no-ops, so a permissive parse is safe — the worst case is a no-op dispatch.

type Scheme added in v0.14.0

type Scheme struct {
	Name  string // data-scheme value on .theme-root (and the CSS scope)
	Label string // picker display name
	// contains filtered or unexported fields
}

Scheme is one curated, fully-coordinated color scheme: a data-scheme value, a human label for the picker, and the chroma styles that color its syntax in each mode. The chrome tokens (surfaces, diff tints) for the same scheme live beside it in prereview.css under the matching [data-scheme="name"] block, so syntax and chrome stay one system. Adding a scheme = one row here + one CSS token block (light + dark + @media).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL