{
if (entry.before !== undefined && entry.after !== undefined) {
const oldSide = gitReadSource(host, root, entry.before)
if (entry.after.kind === 'worktree') {
@@ -369,7 +408,11 @@ async function loadReviewContents(
return { oldContents, newContents }
}
if (entry.baseline === null) {
- throw new Error('earlier content was not captured for this turn')
+ const committed = await loadCommittedFallback(host, root, entry)
+ if (committed !== null) return committed
+ throw new Error(
+ 'earlier content was not captured for this turn, and this file has no committed version to compare against',
+ )
}
const { change } = entry
const oldPath = change.from ?? change.path
@@ -1114,6 +1157,15 @@ function ReviewFile({
{editStatus.message}
) : null}
+ {!collapsed &&
+ renderBody &&
+ state.phase === 'ready' &&
+ state.baselineSource === 'committed' ? (
+
+ compared against the last commit — this turn's earlier content was
+ not captured, so edits made before it are included
+
+ ) : null}
{collapsed ? null : !renderBody ? (
scroll to load diff…
) : state.phase === 'idle' || state.phase === 'loading' ? (
diff --git a/shell/ui/src/page/__tests__/ReviewPane.test.ts b/shell/ui/src/page/__tests__/ReviewPane.test.ts
index ea4ff9ae9..8762421ba 100644
--- a/shell/ui/src/page/__tests__/ReviewPane.test.ts
+++ b/shell/ui/src/page/__tests__/ReviewPane.test.ts
@@ -36,6 +36,8 @@ import {
defaultCollapsedReviewPaths,
desiredReviewEntries,
exactCoderText,
+ gitLookupTarget,
+ loadReviewContents,
expandedReviewPaths,
LARGE_REVIEW_EAGER_FILE_COUNT,
LARGE_REVIEW_THRESHOLD,
@@ -410,3 +412,111 @@ describe('orderedReviewSummaries', () => {
])
})
})
+
+describe('gitLookupTarget', () => {
+ it('runs Git in the file own directory so a nested repository answers', () => {
+ expect(gitLookupTarget('/root', 'nested/repo/src/app.ts')).toEqual({
+ cwd: '/root/nested/repo/src',
+ name: 'app.ts',
+ })
+ expect(gitLookupTarget('/root', 'top.ts')).toEqual({
+ cwd: '/root',
+ name: 'top.ts',
+ })
+ })
+})
+
+function execHost(replies: Record) {
+ const trigger = vi.fn(async (functionId: string, input: unknown) => {
+ const reply = replies[functionId]
+ if (reply === undefined) throw new Error(`unexpected function ${functionId}`)
+ return typeof reply === 'function'
+ ? (reply as (value: unknown) => unknown)(input)
+ : reply
+ })
+ return {
+ host: { iii: { trigger } } as unknown as Parameters[0],
+ trigger,
+ }
+}
+
+describe('loadReviewContents without a captured baseline', () => {
+ const uncaptured: ReviewEntry = {
+ path: 'nested/repo/src/app.ts',
+ change: { path: 'nested/repo/src/app.ts', status: 'modified', staged: false },
+ baseline: null,
+ }
+
+ it('falls back to the committed body and labels it', async () => {
+ const { host, trigger } = execHost({
+ 'shell::exec': {
+ exit_code: 0,
+ stdout: 'committed\n',
+ stderr: '',
+ timed_out: false,
+ stdout_truncated: false,
+ stderr_truncated: false,
+ },
+ 'coder::read-file': {
+ content: 'current\n',
+ is_utf8: true,
+ more_lines: false,
+ revision: 'r1',
+ mode: 420,
+ },
+ })
+
+ await expect(loadReviewContents(host, '/root', uncaptured)).resolves.toEqual({
+ oldContents: 'committed\n',
+ newContents: 'current\n',
+ worktreeRevision: 'r1',
+ mode: 420,
+ baselineSource: 'committed',
+ })
+ expect(trigger).toHaveBeenCalledWith(
+ 'shell::exec',
+ expect.objectContaining({ cwd: '/root/nested/repo/src' }),
+ )
+ })
+
+ it('keeps failing closed when there is no committed body either', async () => {
+ const { host } = execHost({
+ 'shell::exec': {
+ exit_code: 128,
+ stdout: '',
+ stderr: 'fatal: not a git repository',
+ timed_out: false,
+ stdout_truncated: false,
+ stderr_truncated: false,
+ },
+ })
+
+ await expect(loadReviewContents(host, '/root', uncaptured)).rejects.toThrow(
+ 'earlier content was not captured for this turn',
+ )
+ })
+
+ it('compares a deleted file against its committed body', async () => {
+ const { host } = execHost({
+ 'shell::exec': {
+ exit_code: 0,
+ stdout: 'committed\n',
+ stderr: '',
+ timed_out: false,
+ stdout_truncated: false,
+ stderr_truncated: false,
+ },
+ })
+
+ await expect(
+ loadReviewContents(host, '/root', {
+ ...uncaptured,
+ change: { ...uncaptured.change, status: 'deleted' },
+ }),
+ ).resolves.toEqual({
+ oldContents: 'committed\n',
+ newContents: '',
+ baselineSource: 'committed',
+ })
+ })
+})
diff --git a/shell/ui/src/page/__tests__/baseline.test.ts b/shell/ui/src/page/__tests__/baseline.test.ts
index e1e8e1260..231391ece 100644
--- a/shell/ui/src/page/__tests__/baseline.test.ts
+++ b/shell/ui/src/page/__tests__/baseline.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
classifyWorkspaceBaselinePath,
captureWorkspaceBaseline,
+ prioritizedBaselineCandidates,
} from '../baseline'
import type { TreeNode } from '../coder'
import { normalizeLiveReviewEvent } from '../live-review'
@@ -33,16 +34,17 @@ function hostFor(root: TreeNode) {
const trigger = vi.fn(async (functionId: string, _input: unknown) => {
if (functionId === 'coder::tree') return { path: '/repo', root }
if (functionId === 'coder::read-file') {
+ // One result per requested path, so a test can count what the capture
+ // actually asked for rather than a fixture's single canned row.
+ const paths = (_input as { paths?: string[] }).paths ?? []
return {
- results: [
- {
- path: '/repo/visible.ts',
- success: true,
- content: 'before\n',
- is_utf8: true,
- more_lines: false,
- },
- ],
+ results: paths.map((path) => ({
+ path,
+ success: true,
+ content: 'before\n',
+ is_utf8: true,
+ more_lines: false,
+ })),
}
}
throw new Error(`unexpected function ${functionId}`)
@@ -307,3 +309,71 @@ describe('captureWorkspaceBaseline', () => {
})
})
})
+
+describe('prioritizedBaselineCandidates', () => {
+ function aged(name: string, mtime: number): TreeNode {
+ return { name, kind: 'file', size: 7, mtime }
+ }
+
+ it('spends the body budget on the most recently modified files first', () => {
+ const root = workspace([
+ aged('stale.ts', 10),
+ {
+ name: 'src',
+ kind: 'dir',
+ size: 0,
+ mtime: 5,
+ children: [aged('fresh.ts', 99), aged('older.ts', 20)],
+ },
+ ])
+
+ expect(prioritizedBaselineCandidates(root, () => true)).toEqual([
+ 'src/fresh.ts',
+ 'src/older.ts',
+ 'stale.ts',
+ ])
+ })
+
+ it('keeps tree order for equal mtimes and honours the review predicate', () => {
+ const root = workspace([aged('a.ts', 7), aged('b.ts', 7), aged('skip.log', 90)])
+
+ expect(
+ prioritizedBaselineCandidates(root, (path) => !path.endsWith('.log')),
+ ).toEqual(['a.ts', 'b.ts'])
+ })
+})
+
+describe('baseline coverage', () => {
+ it('reports a capped body snapshot without disturbing inventory completeness', async () => {
+ const children = Array.from({ length: 501 }, (_, index) =>
+ file(`file-${index}.ts`),
+ )
+ const baseline = await captureWorkspaceBaseline(
+ hostFor(workspace(children)).host,
+ '/repo',
+ () => true,
+ )
+
+ expect(baseline.coverage).toEqual({
+ candidates: 501,
+ captured: 500,
+ capped: true,
+ })
+ expect(baseline.contents.size).toBe(500)
+ expect(baseline.complete).toBe(true)
+ })
+
+ it('reports full coverage when every candidate fits', async () => {
+ const baseline = await captureWorkspaceBaseline(
+ hostFor(workspace([file('visible.ts')])).host,
+ '/repo',
+ () => true,
+ )
+
+ expect(baseline.coverage).toEqual({
+ candidates: 1,
+ captured: 1,
+ capped: false,
+ })
+ })
+})
diff --git a/shell/ui/src/page/__tests__/git.test.ts b/shell/ui/src/page/__tests__/git.test.ts
index 767faf0bb..c90bb363b 100644
--- a/shell/ui/src/page/__tests__/git.test.ts
+++ b/shell/ui/src/page/__tests__/git.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
gitBranchComparison,
gitChanges,
+ gitHeadBaseline,
gitCommitComparison,
gitComparison,
gitReadSource,
@@ -881,3 +882,45 @@ describe('git metadata', () => {
})
})
})
+
+describe('gitHeadBaseline', () => {
+ it("reads the committed body from the file's own directory", async () => {
+ const { host, trigger } = mockedHost(reply({ stdout: 'committed\n' }))
+
+ await expect(
+ gitHeadBaseline(host, '/root/nested/src', 'app.ts'),
+ ).resolves.toBe('committed\n')
+ expect(trigger).toHaveBeenCalledWith('shell::exec', {
+ command: 'git',
+ args: ['show', 'HEAD:./app.ts'],
+ cwd: '/root/nested/src',
+ timeout_ms: 15_000,
+ })
+ })
+
+ it('reports absence instead of an empty body', async () => {
+ const untracked = mockedHost(
+ reply({ exit_code: 128, stderr: "fatal: path 'app.ts' does not exist" }),
+ )
+ await expect(
+ gitHeadBaseline(untracked.host, '/root', 'app.ts'),
+ ).resolves.toBeNull()
+
+ const truncated = mockedHost(
+ reply({ stdout: 'partial', stdout_truncated: true }),
+ )
+ await expect(
+ gitHeadBaseline(truncated.host, '/root', 'app.ts'),
+ ).resolves.toBeNull()
+
+ const binary = mockedHost(reply({ stdout: 'PNG\0data' }))
+ await expect(
+ gitHeadBaseline(binary.host, '/root', 'logo.png'),
+ ).resolves.toBeNull()
+
+ const failed = mockedHost(new Error('exec unavailable'))
+ await expect(
+ gitHeadBaseline(failed.host, '/root', 'app.ts'),
+ ).resolves.toBeNull()
+ })
+})
diff --git a/shell/ui/src/page/__tests__/live-review.test.ts b/shell/ui/src/page/__tests__/live-review.test.ts
index 595aedbd7..cf4c19518 100644
--- a/shell/ui/src/page/__tests__/live-review.test.ts
+++ b/shell/ui/src/page/__tests__/live-review.test.ts
@@ -118,4 +118,56 @@ describe('normalizeLiveReviewEvent', () => {
baseline: 'original\n',
})
})
+
+ it('believes a witnessed creation over an inventory that only guessed', () => {
+ // A truncated inventory answers "file" for every path it never listed, so
+ // a file the turn created would otherwise read as modified with nothing
+ // to compare against.
+ expect(
+ normalizeLiveReviewEvent({
+ path: 'ReachAI/Dockerfile',
+ rawKind: 'created',
+ priorKind: 'file',
+ priorKindExact: false,
+ existsNow: true,
+ }),
+ ).toEqual({
+ action: 'created',
+ path: 'ReachAI/Dockerfile',
+ baseline: '',
+ })
+ })
+
+ it('keeps a proven existing file a modification, however it was written', () => {
+ expect(
+ normalizeLiveReviewEvent({
+ path: 'src/atomic.ts',
+ rawKind: 'created',
+ priorKind: 'file',
+ priorKindExact: true,
+ existsNow: true,
+ }),
+ ).toEqual({
+ action: 'modified',
+ path: 'src/atomic.ts',
+ baseline: undefined,
+ })
+ })
+
+ it('keeps a captured body even when the classification was a guess', () => {
+ expect(
+ normalizeLiveReviewEvent({
+ path: 'src/guessed.ts',
+ rawKind: 'created',
+ priorKind: 'file',
+ priorKindExact: false,
+ priorBaseline: 'before\n',
+ existsNow: true,
+ }),
+ ).toEqual({
+ action: 'modified',
+ path: 'src/guessed.ts',
+ baseline: 'before\n',
+ })
+ })
})
diff --git a/shell/ui/src/page/baseline.ts b/shell/ui/src/page/baseline.ts
index 7d774e487..4464af909 100644
--- a/shell/ui/src/page/baseline.ts
+++ b/shell/ui/src/page/baseline.ts
@@ -18,6 +18,19 @@ export interface WorkspaceBaseline {
contents: ReadonlyMap
/** False when coder::tree may have omitted reviewable descendants. */
complete: boolean
+ /** How much of the reviewable inventory the body snapshot could hold. A
+ capped snapshot still classifies every path — only bodies are missing —
+ so this stays separate from `complete`, which drives new-vs-existing. */
+ coverage: WorkspaceBaselineCoverage
+}
+
+export interface WorkspaceBaselineCoverage {
+ /** Reviewable files the inventory offered. */
+ candidates: number
+ /** Files whose body the snapshot actually holds. */
+ captured: number
+ /** True when the candidate count exceeded the per-turn body budget. */
+ capped: boolean
}
export interface WorkspaceBaselinePathState {
@@ -76,6 +89,34 @@ export function classifyWorkspaceBaselinePath(
: { priorKind: 'file', exact: false }
}
+/** Reviewable files in the order the body budget should spend itself: most
+ recently modified first. A turn edits the working set, not the
+ alphabetically first 500 paths, so recency buys far more coverage than
+ tree order on a large or shared root. Equal mtimes keep tree order. */
+export function prioritizedBaselineCandidates(
+ root: TreeNode,
+ includePath: (path: string) => boolean,
+): string[] {
+ const candidates: { path: string; mtime: number; order: number }[] = []
+ const walk = (node: TreeNode, prefix: string) => {
+ for (const child of node.children ?? []) {
+ const path = prefix === '' ? child.name : `${prefix}/${child.name}`
+ if (child.kind === 'file' && includePath(path)) {
+ candidates.push({ path, mtime: child.mtime, order: candidates.length })
+ }
+ walk(child, path)
+ }
+ }
+ walk(root, '')
+ return candidates
+ .sort((left, right) =>
+ left.mtime === right.mtime
+ ? left.order - right.order
+ : right.mtime - left.mtime,
+ )
+ .map((candidate) => candidate.path)
+}
+
/**
* Capture a turn baseline at Harness's awaited pre-turn boundary. The result
* is built locally and published atomically, so tree refreshes cannot cancel a
@@ -90,10 +131,8 @@ export async function captureWorkspaceBaseline(
): Promise {
const treeResponse = await baselineTree(host, root)
const tree = flattenTree(treeResponse.root)
- const relPaths = [...tree.kinds]
- .filter(([path, kind]) => kind === 'file' && includePath(path))
- .map(([path]) => path)
- .slice(0, SNAPSHOT_MAX_FILES)
+ const candidates = prioritizedBaselineCandidates(treeResponse.root, includePath)
+ const relPaths = candidates.slice(0, SNAPSHOT_MAX_FILES)
const contents = new Map()
for (let start = 0; start < relPaths.length; start += SNAPSHOT_BATCH_SIZE) {
@@ -115,5 +154,10 @@ export async function captureWorkspaceBaseline(
// rejects that subtree. Capacity, depth, I/O, and reviewable default
// excludes remain fail-closed.
complete: inventoryCompleteForReview(treeResponse.root, includePath),
+ coverage: {
+ candidates: candidates.length,
+ captured: contents.size,
+ capped: candidates.length > SNAPSHOT_MAX_FILES,
+ },
}
}
diff --git a/shell/ui/src/page/git.ts b/shell/ui/src/page/git.ts
index f81c6b3dd..d57ec6162 100644
--- a/shell/ui/src/page/git.ts
+++ b/shell/ui/src/page/git.ts
@@ -1125,11 +1125,32 @@ export async function nestedGitStatus(
return status === 'renamed' ? 'modified' : status
}
+/** The committed body of one path, resolved from the directory it lives in so
+ a repository nested under a non-repository root still answers. Null means
+ there is no usable committed body — no repository, path untracked, binary,
+ truncated, or a failed exec — never an empty string, which a caller would
+ read as a real empty file. */
+export async function gitHeadBaseline(
+ host: Host,
+ cwd: string,
+ path: string,
+): Promise {
+ try {
+ const out = await git(host, cwd, ['show', `HEAD:./${path}`])
+ if (execFailure(out, 'git show HEAD') !== null) return null
+ if (out.stdout.includes('\0') || out.stdout.includes('�')) return null
+ return out.stdout
+ } catch {
+ return null
+ }
+}
+
+/** Committed body or empty, for the callers that already treat a missing
+ HEAD side as an addition. */
export async function gitShowHead(
host: Host,
root: string,
path: string,
): Promise {
- const out = await git(host, root, ['show', `HEAD:./${path}`])
- return out.exit_code === 0 ? out.stdout : ''
+ return (await gitHeadBaseline(host, root, path)) ?? ''
}
diff --git a/shell/ui/src/page/index.tsx b/shell/ui/src/page/index.tsx
index 5c141b8ee..662833c6a 100644
--- a/shell/ui/src/page/index.tsx
+++ b/shell/ui/src/page/index.tsx
@@ -50,6 +50,7 @@ import { errorMessage } from '../lib/format'
import {
captureWorkspaceBaseline,
classifyWorkspaceBaselinePath,
+ type WorkspaceBaselineCoverage,
} from './baseline'
import { ChangeDiffPane } from './ChangeDiffPane'
import {
@@ -444,6 +445,9 @@ export function ShellExplorerPage({
const baselineCompleteRef = useRef(false)
const baselineCapturedRef = useRef(false)
const baselineReadyRef = useRef>(Promise.resolve())
+ // A capped snapshot degrades quietly per row, so the toolbar says so once.
+ const [baselineCoverage, setBaselineCoverage] =
+ useState(null)
const preparedTurnRef = useRef(null)
const lastReviewKeyRef = useRef(observedReviewKey ?? null)
const reviewEpochRef = useRef(0)
@@ -610,6 +614,7 @@ export function ShellExplorerPage({
baselineCompleteRef.current = false
baselineCapturedRef.current = false
baselineReadyRef.current = Promise.resolve()
+ setBaselineCoverage(null)
reviewEntriesRef.current = new Map()
setReviewEntries(new Map())
scopeEntriesRef.current = new Map()
@@ -650,7 +655,7 @@ export function ShellExplorerPage({
baselineCompleteRef.current = false
baselineCapturedRef.current = false
const snapshot = captureWorkspaceBaseline(host, currentRoot, reviewablePath)
- .then(({ contents, kinds, complete }) => {
+ .then(({ contents, kinds, complete, coverage }) => {
if (
rootGenerationRef.current !== generation ||
reviewEpochRef.current !== epoch ||
@@ -662,6 +667,7 @@ export function ShellExplorerPage({
baselineKindsRef.current = kinds
baselineCompleteRef.current = complete
baselineCapturedRef.current = true
+ setBaselineCoverage(coverage)
})
.catch(() => {
// Git can still provide HEAD; non-Git rows fail closed with a clear
@@ -1506,6 +1512,7 @@ export function ShellExplorerPage({
path: rel,
rawKind,
priorKind,
+ priorKindExact: baselinePath?.exact,
priorBaseline: baseline,
existsNow:
results === null
@@ -1799,6 +1806,7 @@ export function ShellExplorerPage({
baselineCompleteRef.current = false
baselineCapturedRef.current = false
baselineReadyRef.current = Promise.resolve()
+ setBaselineCoverage(null)
preparedTurnRef.current = null
reviewEntriesRef.current = new Map()
reviewEditBackupsRef.current.clear()
@@ -2546,6 +2554,15 @@ export function ShellExplorerPage({
unavailable
) : null}
+ {reviewScope.kind === 'last-turn' && baselineCoverage?.capped ? (
+
+ snapshot {baselineCoverage.captured}/
+ {baselineCoverage.candidates}
+
+ ) : null}
{reviewTotals.ready > 0 ? (
<>
diff --git a/shell/ui/src/page/live-review.ts b/shell/ui/src/page/live-review.ts
index 60e054a81..d3f059863 100644
--- a/shell/ui/src/page/live-review.ts
+++ b/shell/ui/src/page/live-review.ts
@@ -5,6 +5,13 @@ export interface LiveReviewEventInput {
rawKind: string
/** `null` means known missing; `undefined` means no tree snapshot. */
priorKind: PriorFilesystemKind
+ /**
+ * Whether the inventory PROVED `priorKind` rather than inferring it. A
+ * truncated inventory cannot tell an omitted file from a new one, so it
+ * guesses `file`; that guess must not outrank a creation the watcher
+ * actually saw during this turn.
+ */
+ priorKindExact?: boolean
/** Undefined means uncaptured. An empty string is a real baseline. */
priorBaseline?: string
/** Whether the changed path is a readable file after the event burst. */
@@ -26,6 +33,16 @@ export function normalizeLiveReviewEvent(input: LiveReviewEventInput): LiveRevie
return { action: 'ignore-directory', path: input.path }
}
+ // A guessed "it existed" loses to a witnessed creation with no captured
+ // body: the watcher saw this path appear, the inventory never listed it.
+ const guessedExisting =
+ input.priorKind === 'file' &&
+ input.priorKindExact === false &&
+ input.priorBaseline === undefined
+ if (guessedExisting && input.rawKind === 'created' && input.existsNow) {
+ return { action: 'created', path: input.path, baseline: '' }
+ }
+
const existedBefore =
input.priorKind === 'file' ||
(input.priorKind === undefined && input.priorBaseline !== undefined)