mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
292 lines
13 KiB
TypeScript
292 lines
13 KiB
TypeScript
/**
|
|
* Local changes of a Session's checkout: where the `branch` scope forks, the
|
|
* patch of one scope with its untracked files, the new-side text of one file,
|
|
* and the size of the `branch` scope for the panel's card. Git runs through
|
|
* the Session's Git runner; untracked and working-tree files are read through
|
|
* `ctx.fs`, both within `maxDiffBytes`.
|
|
*/
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import { FsError } from '@deepseek-ai/dsh-fs'
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
|
import { decodeText, parseDiff, parseNumstat, untrackedFile } from './diff.ts'
|
|
import { gitIn, GitOutputTooLarge } from './git.ts'
|
|
import type { Git, GitConfig, GitOutput, Inspection } from './git.ts'
|
|
import { parseName, remoteOrder } from './parse.ts'
|
|
import type { ForgeDiffFile, ForgeLocalChanges, ForgeLocalDiff, ForgeLocalScope, ForgeSessionWorktree } from './types.ts'
|
|
|
|
/** Git limits plus the size limits of local diffs. */
|
|
export interface DiffConfig extends GitConfig {
|
|
/** Largest patch, untracked file, or new-side file in bytes. */
|
|
readonly maxDiffBytes: number
|
|
/** Most untracked files listed. */
|
|
readonly maxUntrackedFiles: number
|
|
}
|
|
|
|
/** The commit the `branch` scope compares with and the name the panel shows for it. */
|
|
export interface ForkPoint {
|
|
/** Full commit object name. */
|
|
readonly commit: string
|
|
/** Remote branch such as `origin/master`, the created worktree's base branch, or an abbreviated commit. */
|
|
readonly label: string
|
|
}
|
|
|
|
/** Thrown when a patch, a file list, or a file exceeds `maxDiffBytes`. */
|
|
export class DiffTooLarge extends Error {
|
|
override name = 'DiffTooLarge'
|
|
}
|
|
|
|
/** Thrown when filter configuration cannot be read completely; no comparison has run. */
|
|
export class FilterConfigError extends Error {
|
|
override name = 'FilterConfigError'
|
|
}
|
|
|
|
/** Thrown for the `branch` scope of a checkout without a fork point. */
|
|
export class NoForkPoint extends Error {
|
|
override name = 'NoForkPoint'
|
|
}
|
|
|
|
/** No executable diff helpers or submodule working-tree inspection; preserve recorded submodule commit changes. */
|
|
const DIFF = ['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--submodule=short', '--ignore-submodules=dirty', '--no-color', '-M', '--src-prefix=a/', '--dst-prefix=b/'] as const
|
|
|
|
/** Disable enumerated clean/process filters for one diff without modifying Git configuration. */
|
|
async function diffFilterOverrides(git: Git): Promise<Readonly<Record<string, string>>> {
|
|
let configured: GitOutput
|
|
try {
|
|
configured = await git('filters', ['config', '--null', '--name-only', '--get-regexp', '^filter\\.'], [0, 1])
|
|
} catch (error) {
|
|
throw new FilterConfigError('Cannot read complete Git filter configuration; comparison was not run', { cause: error })
|
|
}
|
|
const overrides: Record<string, string> = {}
|
|
for (const key of configured.stdout.split('\0')) {
|
|
if (!key.endsWith('.clean') && !key.endsWith('.process')) continue
|
|
const driver = key.slice(0, key.lastIndexOf('.'))
|
|
overrides[`${driver}.clean`] = ''
|
|
overrides[`${driver}.process`] = ''
|
|
overrides[`${driver}.required`] = 'false'
|
|
}
|
|
return overrides
|
|
}
|
|
|
|
/** Characters Git inspects for a NUL character before it calls content binary. */
|
|
const BINARY_PROBE = 8000
|
|
|
|
/**
|
|
* Find where the `branch` scope forks: the base commit of the worktree the
|
|
* Session created while the checkout is that worktree, else the merge base of
|
|
* HEAD with the default branch of the first remote that names one, in the
|
|
* order the panel recognizes code hosts.
|
|
* @param git - runner bound to the checkout.
|
|
* @param inspection - the checkout and its remotes.
|
|
* @param created - worktree the Session created, or null.
|
|
* @returns the fork point, or null when no remote names a default branch or HEAD shares no history with it.
|
|
*/
|
|
export async function forkPoint(git: Git, inspection: Inspection, created: ForgeSessionWorktree | null): Promise<ForkPoint | null> {
|
|
if (created !== null && created.path === inspection.checkout.root) {
|
|
return { commit: created.baseCommit, label: created.from ?? created.baseCommit.slice(0, 7) }
|
|
}
|
|
for (const remote of remoteOrder(inspection.upstream, inspection.remotes)) {
|
|
const head = await git('remote-head', ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote.name}/HEAD`], [0, 1, 128])
|
|
const base = head.exitCode === 0 ? parseName(head.stdout) : null
|
|
if (base === null) continue
|
|
const merge = await git('merge-base', ['merge-base', 'HEAD', base], [0, 1, 128])
|
|
const commit = merge.exitCode === 0 ? parseName(merge.stdout) : null
|
|
return commit === null ? null : { commit, label: base }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Read changes at `root` with command-local clean/process filter overrides and without
|
|
* submodule working-tree inspection. Git's built-in newline, ident, and encoding conversions still apply.
|
|
* @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`.
|
|
* @param config - Git and size limits.
|
|
* @param session - Session whose sandbox policy and execution world apply.
|
|
* @param root - top-level directory of the checkout.
|
|
* @param fork - fork point of the `branch` scope, or null without one.
|
|
* @param scope - which changes to read.
|
|
* @param signal - cancellation of every command and read.
|
|
* @returns tracked files in Git's order, then untracked files unless the scope is `staged`.
|
|
* @throws NoForkPoint for the `branch` scope without `fork`; FilterConfigError when filter configuration
|
|
* cannot be read within `maxDiffBytes`; DiffTooLarge for oversized comparison output; an Error when Git fails.
|
|
*/
|
|
export async function localDiff(
|
|
ctx: Context,
|
|
config: DiffConfig,
|
|
session: Session,
|
|
root: string,
|
|
fork: ForkPoint | null,
|
|
scope: ForgeLocalScope,
|
|
signal: AbortSignal,
|
|
): Promise<ForgeLocalDiff> {
|
|
const git = gitIn(ctx, { ...config, maxOutputBytes: config.maxDiffBytes }, session, root, signal)
|
|
let range: readonly string[]
|
|
switch (scope.kind) {
|
|
case 'branch':
|
|
if (fork === null) throw new NoForkPoint('The checkout has no fork point to compare its branch with')
|
|
range = [fork.commit]
|
|
break
|
|
case 'uncommitted': range = [await headOrEmptyTree(git)]; break
|
|
case 'unstaged': range = []; break
|
|
case 'staged': range = ['--cached', await headOrEmptyTree(git)]; break
|
|
/* v8 ignore next -- closed ForgeLocalScope union exhaustiveness guard */
|
|
default: return assertNever(scope, 'ForgeLocalScope.kind')
|
|
}
|
|
const overrides = await diffFilterOverrides(git)
|
|
const tracked = parseDiff((await bounded(git('diff', [...DIFF, ...range, '--'], undefined, overrides))).stdout)
|
|
if (scope.kind === 'staged') return { base: null, files: tracked, omitted: 0 }
|
|
const untracked = await untrackedFiles(ctx, config, git, root, signal)
|
|
return { base: fork === null || scope.kind !== 'branch' ? null : fork.label, files: [...tracked, ...untracked.files], omitted: untracked.omitted }
|
|
}
|
|
|
|
/**
|
|
* Read the new side of one file in a scope, which supplies the unchanged
|
|
* lines between hunks: the index for `staged`, else the working tree.
|
|
* @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`.
|
|
* @param config - Git and size limits.
|
|
* @param session - Session whose sandbox policy and execution world apply.
|
|
* @param root - top-level directory of the checkout.
|
|
* @param scope - the scope whose new side to read.
|
|
* @param path - path relative to `root`, as the diff names it.
|
|
* @param signal - cancellation.
|
|
* @returns the text.
|
|
* @throws DiffTooLarge over `maxDiffBytes`; an Error for a path outside the checkout, binary content, or a failed read.
|
|
*/
|
|
export async function localText(
|
|
ctx: Context,
|
|
config: DiffConfig,
|
|
session: Session,
|
|
root: string,
|
|
scope: ForgeLocalScope,
|
|
path: string,
|
|
signal: AbortSignal,
|
|
): Promise<string> {
|
|
if (!insideCheckout(path)) throw new Error(`forge-panel: ${JSON.stringify(path)} is not a path inside the checkout`)
|
|
let text: string | undefined
|
|
if (scope.kind === 'staged') {
|
|
const git = gitIn(ctx, { ...config, maxOutputBytes: config.maxDiffBytes }, session, root, signal)
|
|
const shown = (await bounded(git('show', ['show', `:${path}`]))).stdout
|
|
text = shown.slice(0, BINARY_PROBE).includes('\0') ? undefined : shown
|
|
} else {
|
|
text = decodeText(await readFile(ctx, config, root, path, signal))
|
|
}
|
|
if (text === undefined) throw new Error(`forge-panel: ${path} is binary`)
|
|
return text
|
|
}
|
|
|
|
/**
|
|
* Measure the `branch` scope for the panel's card.
|
|
* @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`.
|
|
* @param config - Git and size limits.
|
|
* @param session - Session whose sandbox policy and execution world apply.
|
|
* @param inspection - the checkout and its remotes.
|
|
* @param created - worktree the Session created, or null.
|
|
* @param signal - cancellation of every command and read.
|
|
* @returns the base and the changed files, or null without a fork point.
|
|
* @throws FilterConfigError when filter configuration cannot be read within `maxOutputBytes`;
|
|
* DiffTooLarge when the untracked file list exceeds `maxDiffBytes`; an Error when Git fails.
|
|
*/
|
|
export async function localChanges(
|
|
ctx: Context,
|
|
config: DiffConfig,
|
|
session: Session,
|
|
inspection: Inspection,
|
|
created: ForgeSessionWorktree | null,
|
|
signal: AbortSignal,
|
|
): Promise<ForgeLocalChanges | null> {
|
|
const root = inspection.checkout.root
|
|
const git = gitIn(ctx, config, session, root, signal)
|
|
const fork = await forkPoint(git, inspection, created)
|
|
if (fork === null) return null
|
|
const overrides = await diffFilterOverrides(git)
|
|
const numstat = await git('numstat', [...DIFF, '--numstat', '-z', fork.commit, '--'], undefined, overrides)
|
|
const listing = gitIn(ctx, { ...config, maxOutputBytes: config.maxDiffBytes }, session, root, signal)
|
|
const untracked = await untrackedFiles(ctx, config, listing, root, signal)
|
|
return {
|
|
base: fork.label,
|
|
files: [
|
|
...parseNumstat(numstat.stdout),
|
|
...untracked.files.map(file => ({ path: file.path, additions: file.additions, deletions: 0, binary: file.binary })),
|
|
],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count the commits the checkout has beyond a change request's head, which a
|
|
* change request diff does not show yet.
|
|
* @param git - runner bound to the Session's directory.
|
|
* @param branch - the change request's head branch.
|
|
* @param commit - the change request's head commit.
|
|
* @returns the count, or null on another branch, outside a checkout, or without the commit locally.
|
|
*/
|
|
export async function aheadOf(git: Git, branch: string, commit: string): Promise<number | null> {
|
|
const head = await git('symbolic-ref', ['symbolic-ref', '--quiet', '--short', 'HEAD'], [0, 1, 128])
|
|
if (head.exitCode !== 0 || parseName(head.stdout) !== branch) return null
|
|
const present = await git('cat-file', ['cat-file', '-e', `${commit}^{commit}`], [0, 1, 128])
|
|
if (present.exitCode !== 0) return null
|
|
return Number((await git('rev-list', ['rev-list', '--count', `${commit}..HEAD`])).stdout.trim())
|
|
}
|
|
|
|
/** HEAD, or the empty tree while the checked-out branch has no commit. */
|
|
async function headOrEmptyTree(git: Git): Promise<string> {
|
|
const head = await git('head', ['rev-parse', '--verify', '--quiet', 'HEAD'], [0, 1])
|
|
if (head.exitCode === 0) return 'HEAD'
|
|
return (await git('empty-tree', ['hash-object', '-t', 'tree', '--stdin'])).stdout.trim()
|
|
}
|
|
|
|
/** Untracked files Git does not ignore, at most `maxUntrackedFiles`; a nested repository and a file gone since the listing are left out. */
|
|
async function untrackedFiles(
|
|
ctx: Context,
|
|
config: DiffConfig,
|
|
git: Git,
|
|
root: string,
|
|
signal: AbortSignal,
|
|
): Promise<{ readonly files: ForgeDiffFile[]; readonly omitted: number }> {
|
|
const listed = await bounded(git('untracked', ['ls-files', '--others', '--exclude-standard', '-z']))
|
|
const paths = listed.stdout.split('\0').filter(path => path.length > 0 && !path.endsWith('/'))
|
|
const files: ForgeDiffFile[] = []
|
|
for (const path of paths.slice(0, config.maxUntrackedFiles)) {
|
|
let content: Uint8Array
|
|
try {
|
|
content = await readFile(ctx, config, root, path, signal)
|
|
} catch (error) {
|
|
if (error instanceof DiffTooLarge) {
|
|
files.push({ ...untrackedFile(path, new Uint8Array()), oversized: true })
|
|
continue
|
|
}
|
|
if (error instanceof FsError) continue
|
|
throw error
|
|
}
|
|
files.push(untrackedFile(path, content))
|
|
}
|
|
return { files, omitted: Math.max(0, paths.length - config.maxUntrackedFiles) }
|
|
}
|
|
|
|
/** Read one file of the checkout through `ctx.fs`, at most `maxDiffBytes`. */
|
|
async function readFile(ctx: Context, config: DiffConfig, root: string, path: string, signal: AbortSignal): Promise<Uint8Array> {
|
|
const target = await ctx.fs.resolve(path, { cwd: root, signal })
|
|
try {
|
|
return await ctx.fs.readBytes(target, signal, config.maxDiffBytes)
|
|
} catch (error) {
|
|
if (error instanceof FsError && error.code === 'FS_TOO_LARGE') throw new DiffTooLarge(`${path} exceeds maxDiffBytes`, { cause: error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** Translate the runner's output limit into DiffTooLarge. */
|
|
async function bounded(output: Promise<GitOutput>): Promise<GitOutput> {
|
|
try {
|
|
return await output
|
|
} catch (error) {
|
|
if (error instanceof GitOutputTooLarge) throw new DiffTooLarge(error.message, { cause: error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** Whether a diff path stays inside the checkout: relative, without NUL, and without a `..` segment. */
|
|
function insideCheckout(path: string): boolean {
|
|
if (path.length === 0 || path.includes('\0') || path.startsWith('/') || /^[A-Za-z]:/.test(path)) return false
|
|
return path.split(/[/\\]/).every(segment => segment !== '..')
|
|
}
|