Files
deepseek-harness/packages/experimental/client-ui-forge/src/diff.ts
T
Yichen Jiang ef34a844de fix(forge-panel): refuse commit runs off the first-parent line and harden review state
- Shift-chosen commits must follow each other as first parents; the picker
  says why it keeps a run that leaves the line, and the Host refuses one with
  forgePanel/commits-not-linear for the Branch scope and change requests.
- The Branch scope lists its newest maxCommits commits with
  --no-show-signature, so log.showSignature cannot corrupt the listing.
- Viewed marks merge with the stored entry on each write and follow the
  storage event, so tabs of one Session keep each other's marks.
- .gitattributes patterns follow Git's wildmatch rules, checked against
  git check-attr; change digests include file mode lines.
- GitHub commit pagination refuses a repeated cursor or a changed count;
  commits validates the repository before its host like the other reads.
- The commit list is read once per head, base, or refresh; the lens menu
  hides the count of the disabled Tests lens.
2026-11-07 12:56:23 +08:00

163 lines
7.0 KiB
TypeScript

/** Unified patch parsing into the panel's browser-safe diff model, and the diff and size of an untracked file. */
import { createHash } from 'node:crypto'
import { parsePatch } from 'diff'
import type { ForgeChangedFile, ForgeDiffFile, ForgeDiffHunk, ForgeDiffStatus } from './types.ts'
/** A changed file before its path is classified into a review layer. */
export type ParsedDiffFile = Omit<ForgeDiffFile, 'layer'>
/** Git's name for the missing side of an added or deleted file. */
const NULL_PATH = '/dev/null'
/** Bytes Git inspects for a NUL byte before it calls content binary. */
const BINARY_PROBE_BYTES = 8000
/** Blob names of both sides on a Git `index` header line, which identify a binary change that carries no hunks. */
const INDEX_LINE = /^index ([0-9a-f]+\.\.[0-9a-f]+)/m
/**
* Parse a Git-style unified patch, as `git diff` or a code host prints it,
* with `a/` and `b/` path prefixes. Hunk ranges follow jsdiff: a range that
* covers no line starts at the line it comes before.
* @param patch - the patch text.
* @returns one file per patch section in patch order.
*/
export function parseDiff(patch: string): ParsedDiffFile[] {
const parsed = parsePatch(patch)
// Hunk lines start with a space, `+`, `-`, or `\`, so only a Git section header starts a line with `diff --git `.
const headers = patch.split(/^(?=diff --git )/m).filter(section => section.startsWith('diff --git '))
return parsed.flatMap((section, index) => {
const before = pathOf(section.oldFileName, 'a/')
const after = pathOf(section.newFileName, 'b/')
const path = after ?? before
if (path === null) return []
const status: ForgeDiffStatus = section.isCreate === true || before === null ? 'added'
: section.isDelete === true || after === null ? 'deleted'
: section.isRename === true ? 'renamed' : 'modified'
let additions = 0
let deletions = 0
for (const hunk of section.hunks) {
for (const line of hunk.lines) {
if (line.startsWith('+')) additions++
else if (line.startsWith('-')) deletions++
}
}
const binary = section.isBinary === true
const previousPath = status === 'renamed' ? before : null
const hunks = section.hunks.map(({ oldStart, oldLines, newStart, newLines, lines }) => (
{ oldStart, oldLines, newStart, newLines, lines }))
// Each header matches its parsed section only when every section starts with a Git header.
const header = binary && headers.length === parsed.length ? String(headers[index]) : ''
const blobs = INDEX_LINE.exec(header)?.[1] ?? ''
// A mode change, such as `chmod +x`, shows only in the `old mode` and `new mode` header lines.
const modes = `${section.oldMode ?? ''}..${section.newMode ?? ''}`
return [{
path, previousPath, status, binary, oversized: false, untracked: false, additions, deletions, hunks,
digest: changeDigest(path, previousPath, status, hunks, blobs, modes),
}]
})
}
/** Digest of one change: its paths, status, hunks, file modes, and the identity of content that hunks do not show. */
function changeDigest(
path: string, previousPath: string | null, status: ForgeDiffStatus, hunks: readonly ForgeDiffHunk[], content: string, modes: string,
): string {
return createHash('sha256').update(JSON.stringify([path, previousPath, status, hunks, content, modes])).digest('hex').slice(0, 32)
}
/**
* Describe an untracked file as added in full.
* @param path - path relative to the repository root.
* @param content - the file's bytes.
* @returns the file with one hunk of added lines, none when it is empty, or none and `binary` when its content is not text.
*/
export function untrackedFile(path: string, content: Uint8Array): ParsedDiffFile {
const text = decodeText(content)
if (text === undefined) return addedFile(path, [], { binary: true, oversized: false, content: createHash('sha256').update(content).digest('hex') })
if (text.length === 0) return addedFile(path, [], { binary: false, oversized: false, content: '' })
const lines = text.split('\n')
const ended = lines.at(-1) === ''
if (ended) lines.pop()
return addedFile(path, [{
oldStart: 1,
oldLines: 0,
newStart: 1,
newLines: lines.length,
lines: [...lines.map(line => `+${line}`), ...ended ? [] : ['\\ No newline at end of file']],
}], { binary: false, oversized: false, content: '' })
}
/**
* Describe an untracked file over `maxDiffBytes`, which carries no hunks and whose content is not read.
* @param path - path relative to the repository root.
* @returns the oversized added file.
*/
export function oversizedUntrackedFile(path: string): ParsedDiffFile {
return addedFile(path, [], { binary: false, oversized: true, content: 'oversized' })
}
/** An untracked file as added, whose digest also covers `content`, the identity of content its hunks do not show. */
function addedFile(path: string, hunks: readonly ForgeDiffHunk[], { binary, oversized, content }: {
readonly binary: boolean
readonly oversized: boolean
readonly content: string
}): ParsedDiffFile {
return {
path, previousPath: null, status: 'added', binary, oversized, untracked: true,
additions: hunks[0]?.newLines ?? 0, deletions: 0, hunks, digest: changeDigest(path, null, 'added', hunks, content, ''),
}
}
/**
* Decode file content as text the way Git decides it is not binary: no NUL
* byte among the first 8000 bytes, and, here, valid UTF-8 throughout.
* @param content - the bytes.
* @returns the text, or undefined for binary content.
*/
export function decodeText(content: Uint8Array): string | undefined {
if (content.subarray(0, BINARY_PROBE_BYTES).includes(0)) return undefined
try {
return new TextDecoder('utf-8', { fatal: true }).decode(content)
} catch (error) {
void error // Bytes that are not UTF-8 render as binary, as a NUL byte does.
return undefined
}
}
/**
* Parse `git diff --numstat -z` output, in which a rename prints its counts,
* an empty path, and then the old and new paths as separate fields.
* @param output - the command's standard output.
* @returns one file per record, named by its new path; a binary file counts no lines.
*/
export function parseNumstat(output: string): ForgeChangedFile[] {
const fields = output.split('\0')
const files: ForgeChangedFile[] = []
let skip = 0
for (const [index, record] of fields.entries()) {
if (skip > 0) {
skip--
continue
}
if (record.length === 0) continue
const [added = '', deleted = ''] = record.split('\t', 2)
let path = record.slice(added.length + deleted.length + 2)
if (path.length === 0) {
const renamed = fields[index + 2]
// A rename record that the output limit cut off ends the list.
if (renamed === undefined) break
path = renamed
skip = 2
}
const binary = added === '-'
files.push({ path, additions: binary ? 0 : Number(added), deletions: binary ? 0 : Number(deleted), binary })
}
return files
}
function pathOf(name: string | undefined, prefix: string): string | null {
if (name === undefined || name === NULL_PATH) return null
return name.startsWith(prefix) ? name.slice(prefix.length) : name
}