Blob: docs/diff.md
Commit Diff Proposal
This document proposes a staged implementation for a GitHub-style "what changed in this commit" view that fits the current git-on-cloudflare architecture and respects the existing soft IO/subrequest budget.
Goals
- Show the files changed by a commit in the web UI.
- Keep the implementation aligned with the current Worker read path instead of introducing a second object-read stack in the Repository DO.
- Preserve runtime headroom by short-circuiting unchanged subtrees and applying hard caps.
- Keep the initial implementation small enough to land without schema or storage changes.
Non-goals
- Exact GitHub parity.
- Rename or copy detection.
- Combined merge diffs.
- Eager inline patch rendering for all files on first page load.
- New SQLite tables or write-path materialization in v1 or v2.
Relevant Current Architecture
- The commit detail page already exists at
GET /:owner/:repo/commit/:oidinsrc/routes/ui.ts. - The current page only renders commit metadata through
src/ui/pages/CommitPage.tsx. - The authoritative object read path is the Worker-side
readLooseObjectRaw(...)insrc/git/operations/read.ts.- It already handles DO-first reads, pack fallback, per-request memoization, immutable object caching, and the request limiter.
- The Repository DO exposes batch helpers such as
getObjectsBatch(...)andgetObjectRefsBatch(...), butgetObjectRefsBatch(...)only returns referenced OIDs. It does not preserve tree entry names or modes, so it is not sufficient by itself for a path-level file diff. - The read path already enforces a soft budget through
countSubrequest(...)and bounded concurrency throughSubrequestLimiter. - Commit OIDs are immutable, so diff results are strong candidates for JSON cache entries.
Why the Diff Should Live in the Worker Read Path
The lowest-surface implementation is to compute the commit delta in the Worker, using the same object loading path that already serves commits, trees, and blobs.
Reasons:
readLooseObjectRaw(...)already knows how to fall back from DO state to R2 packs.- Request memoization and immutable object caching are already in place.
- Adding a DO-side diff RPC would either be incomplete for packed objects or would duplicate Worker read logic.
- No SQLite schema changes are required.
V1: Changed File List on the Commit Page
User-facing scope
V1 adds a "Files changed" section to GET /:owner/:repo/commit/:oid.
V1 should show:
- A summary count of files changed.
- A list of changed paths.
- Per-file change type:
Afor addedMfor modifiedDfor deleted
- A note when the result is truncated.
- For merge commits, a note that the comparison is against the first parent.
V1 should not show:
- Inline patch hunks.
- Rename detection.
- Combined merge comparison.
Proposed code changes
src/git/operations/read.ts
Add a small diff-specific API next to the existing commit/tree readers:
export type CommitDiffChangeType = "A" | "M" | "D";
export interface CommitDiffEntry {
path: string;
changeType: CommitDiffChangeType;
oldOid?: string;
newOid?: string;
oldMode?: string;
newMode?: string;
}
export interface CommitDiffResult {
baseCommitOid?: string;
compareMode: "root" | "first-parent";
entries: CommitDiffEntry[];
added: number;
modified: number;
deleted: number;
total: number;
truncated: boolean;
truncateReason?: "max_files" | "max_tree_pairs" | "time_budget" | "soft_budget";
}
export async function listCommitChangedFiles(
env: Env,
repoId: string,
oid: string,
cacheCtx?: CacheContext,
opts?: {
maxFiles?: number;
maxTreePairs?: number;
timeBudgetMs?: number;
}
): Promise<CommitDiffResult>;Recommended helper internals:
readTreeEntriesMemoized(...)walkAddedOrDeletedSubtree(...)diffTreePair(...)
Keep the logic in read.ts for v1 so the code surface stays small. If v2 grows substantially, the diff logic can later move to src/git/operations/diff.ts.
src/routes/ui.ts
Update the commit route to load and cache the diff result:
- Keep the existing
readCommitInfo(...)call. - Add a cache key such as
/_cache/commit-diffwith params:repooidv=1
- Load the diff through
cacheOrLoadJSONWithTTL(...). - Pass the diff result into
renderUiView(env, "commit", ...).
Because the route is keyed by commit OID, the diff result is immutable and can use a longer TTL than branch-based UI pages.
Suggested TTL for the JSON diff payload:
3600to86400seconds
src/ui/pages/CommitPage.tsx
Extend the page props to include:
diffCompareModediffEntriesdiffSummarydiffTruncateddiffTruncateReason
Recommended UI:
- Summary row:
12 files changed - Merge label when
compareMode === "first-parent" && parents.length > 1 - A simple table or list:
- badge for
A,M,D - path
- link for files present in the target commit
- badge for
- Deleted paths can render as plain text or link to the first parent when one exists
If change-type styling is done with dynamic classes, make sure the corresponding classes are safelisted in src/styles/app.css.
V1 algorithm
Base selection
- If the commit has no parent, compare against an empty tree state.
- If the commit has one or more parents, compare against
parents[0]. - For merges, set
compareModetofirst-parentand expose that in the UI.
Do not use a synthetic empty tree object OID. Use an in-memory empty-tree sentinel so the code does not depend on the empty tree object being present in repo storage.
Tree walk
The diff algorithm should compare tree objects recursively and short-circuit whenever tree OIDs match.
High-level flow:
- Read the target commit via
readCommitInfo(...). - Resolve the base tree:
- root commit: no base tree
- otherwise: read
parents[0]and use its tree OID
- Walk the pair
(baseTreeOid, targetTreeOid)recursively. - If the two tree OIDs are equal, stop immediately.
- When both sides are trees, compare entries by name.
- When a subtree exists only on one side, recursively enumerate leaf paths as
AorD. - When both sides contain non-tree entries at the same path:
- same OID and same mode: unchanged
- otherwise: emit
M
- When the path changes type:
- file -> tree: emit
Dfor the file, then enumerate added leaves under the tree - tree -> file: enumerate deleted leaves from the tree, then emit
Afor the file
- file -> tree: emit
Treat any non-40000 mode as a leaf entry. That keeps symlinks and submodule entries in the same file-like bucket for v1.
Ordering
Return entries sorted by path before rendering. This keeps results stable across requests and cache hits.
V1 runtime guardrails
The tree walk must degrade gracefully before it risks the worker runtime limit.
Recommended defaults:
maxFiles:300maxTreePairs:2000timeBudgetMs:1500to2500
When a limit is hit:
- Stop traversal.
- Return accumulated results.
- Set
truncated: true. - Set
truncateReason.
This should be treated as a partial success, not an error.
The helper should also treat repeated soft-budget exhaustion from readLooseObjectRaw(...) as a truncation signal rather than throwing a hard failure.
V1 caching and memoization
Use two layers already present in the codebase:
- Per-request memo via
CacheContext.memo - Cache API via
cacheOrLoadJSONWithTTL(...)
Within a single diff computation, use a local tree-entry memo:
const treeMemo = new Map<string, TreeEntry[]>();This is enough for v1. No new RequestMemo field is required.
V1 testing
Add tests for the read-layer helper and the route integration.
Recommended cases:
- Root commit with only added files.
- Single-file modification.
- Nested directory addition.
- Nested directory deletion.
- File -> directory transition.
- Directory -> file transition.
- Merge commit compares only against the first parent.
- Truncation when
maxFilesis exceeded. - Truncation when
timeBudgetMsis exceeded.
V2: Lazy Patch Expansion Per File
User-facing scope
V2 keeps the v1 file list and adds on-demand patch expansion for individual files.
The commit page should still render quickly with only the file list on first load. Patch bodies should be fetched only when the user expands a file.
Why patches should be lazy
Generating patch hunks is much more expensive than generating a changed-file list because it requires blob reads, text decoding, and diff generation.
Lazy loading keeps the hot path cheap and predictable:
- Initial commit page load only reads commit/tree objects.
- Blob reads only happen for the file the user expands.
- Large or binary files can be skipped without penalizing the entire page.
Proposed code changes
Reuse v1 diff result as the index
V2 should not recompute the full tree diff for every patch request.
Instead:
- Load the cached v1
CommitDiffResult. - Find the requested path in
entries. - Use that entry's
oldOidandnewOidto build the patch.
This keeps v2 layered on top of v1 instead of duplicating tree traversal.
New route in src/routes/ui.ts
Add a lazy patch endpoint:
GET /:owner/:repo/commit/:oid/diff?path=<path>
Suggested response shape:
{
path: string,
changeType: "A" | "M" | "D",
oldOid?: string,
newOid?: string,
oldTooLarge?: boolean,
newTooLarge?: boolean,
binary?: boolean,
skipped?: boolean,
skipReason?: "binary" | "too_large" | "not_found" | "too_many_lines",
patch?: string
}The endpoint can return JSON for a small React island to render.
Read-layer helper
Add a blob-level helper, either in src/git/operations/read.ts or a new src/git/operations/diff.ts if the logic becomes large:
export async function readCommitFilePatch(
env: Env,
repoId: string,
oid: string,
path: string,
cacheCtx?: CacheContext,
opts?: {
maxBlobBytes?: number;
maxPatchBytes?: number;
maxLines?: number;
}
);Recommended flow:
- Load cached v1 diff metadata.
- Resolve
oldOidandnewOid. - Read only the blobs needed for the requested path.
- Reject binary or oversized content with a structured summary.
- Generate a unified patch string.
- Cache the path-specific patch response.
Patch generation choice
There is no existing diff dependency in package.json.
Recommended approach for v2:
- Add a small pure-JS diff dependency rather than maintaining a custom Myers implementation in-repo.
If adding a dependency is undesirable, a minimal line-based diff utility can be implemented locally, but that increases code surface and maintenance cost.
V2 runtime guardrails
V2 should be much stricter than v1 because blob content is larger and text diff generation is CPU-heavy.
Recommended defaults per expanded file:
maxBlobBytes:128 KiBmaxPatchBytes:256 KiBmaxLines:4000
Behavior:
- If either side exceeds the size cap, return a summary without a patch.
- If either side is binary, return a summary without a patch.
- If the generated patch would exceed the output cap, truncate or skip it with a clear reason.
V2 UI
Add a small island, for example:
src/ui/islands/commit-diff-expander.tsx
Behavior:
- Start with all files collapsed.
- On expand, fetch
/:owner/:repo/commit/:oid/diff?path=.... - Render:
- patch text in a
<pre> - or a summary message for binary / large files
- patch text in a
- Cache the loaded patch in client state so collapsing and re-expanding does not re-fetch during the same page session.
For v2, plain text patch rendering is sufficient. Syntax highlighting is optional and can be deferred.
V2 caching
Because a patch is immutable for (repoId, commitOid, path), cache it through the existing JSON cache helper.
Suggested key:
/_cache/commit-patch?repo=<repoId>&oid=<oid>&path=<path>&v=1
Suggested TTL:
3600to86400seconds
Deferred Alternatives
These are explicitly out of scope for v1 and v2 but remain possible later:
- Rename detection by similarity scoring.
- Combined merge diff.
- Precomputing commit deltas during unpack.
- Materializing commit diff metadata into SQLite.
The last option should only be considered if on-demand diff computation proves too slow in real workloads. If that path is taken, all SQLite access must go through src/do/repo/db/dal.ts.
Recommended Rollout
Phase 1
- Implement v1 changed-file list.
- Ship with truncation support.
- Observe latency and cache hit rate.
Phase 2
- Add lazy per-file patch expansion.
- Keep file list SSR and patch loading client-driven.
- Skip binary and oversized files.
Phase 3
- Re-evaluate whether rename detection or unpack-time materialization is worth the added complexity.
Recommendation Summary
- V1 should be a Worker-side tree diff against the first parent, cached by commit OID, with hard traversal caps and a truncated partial-success mode.
- V2 should reuse the cached v1 file list and add lazy, path-specific patch expansion for small text files only.
- Neither v1 nor v2 needs new DO RPC methods, SQLite tables, or write-path changes.