Files
orca/src/main/gitlab/mr-file-diffs.ts
T
53233be289 perf: count GitLab diff line prefixes without splitting all lines (#19505)
* perf: count GitLab diff line prefixes without splitting all lines

* test(gitlab): pin diff-count parity for CRLF, lone CR and non-ASCII lines

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-08 00:10:38 -07:00

99 lines
3.1 KiB
TypeScript

import type { GitLabMRFile } from '../../shared/gitlab-types'
import { encodedProject } from './project-path-encoding'
import {
glabHostnameArgs,
glabRepoExecOptions,
glabExecFileAsync,
type LocalGitExecOptions,
type ProjectRef
} from './gl-utils'
/**
* Counts the added/removed lines in a single GitLab MR file's unified diff,
* feeding the +N/-N shown in the MR file list. `---`/`+++` are file headers
* only before the first `@@` hunk; every `+`/`-` line inside a hunk is content.
* Requires hunk headers: a diff with no `@@` counts zero, so do not reuse this
* for header-less agent-tool diffs (see `diffFromText` in shared/native-chat-diff).
*
* @internal - exposed for tests only.
*/
export function countDiffLines(diff: string): { additions: number; deletions: number } {
let additions = 0
let deletions = 0
// Why: `---`/`+++` are file headers only before the first hunk. A removed line
// whose original text began with `--` (SQL/Lua/Haskell `-- comment`) becomes a
// diff line `---<content>`, colliding with the `--- a/file` header — so it must
// be counted once inside a hunk, not skipped.
let inHunk = false
let cursor = 0
while (cursor < diff.length) {
if (diff.startsWith('@@', cursor)) {
inHunk = true
} else if (inHunk) {
const prefix = diff.charCodeAt(cursor)
if (prefix === 43) {
additions += 1
} else if (prefix === 45) {
deletions += 1
}
}
const newline = diff.indexOf('\n', cursor)
if (newline === -1) {
break
}
cursor = newline + 1
}
return { additions, deletions }
}
function mapMRFile(raw: {
new_path?: string
old_path?: string
diff?: string
new_file?: boolean
deleted_file?: boolean
renamed_file?: boolean
binary?: boolean
too_large?: boolean
}): GitLabMRFile {
const diff = raw.diff ?? ''
const counts = countDiffLines(diff)
const status = raw.new_file
? 'added'
: raw.deleted_file
? 'removed'
: raw.renamed_file
? 'renamed'
: 'modified'
return {
path: raw.new_path ?? raw.old_path ?? '',
...(raw.old_path && raw.old_path !== raw.new_path ? { oldPath: raw.old_path } : {}),
status,
additions: counts.additions,
deletions: counts.deletions,
isBinary: Boolean(raw.binary || raw.too_large || !diff),
...(diff ? { diff } : {})
}
}
export async function fetchMRFiles(
repoPath: string,
projectRef: ProjectRef,
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabMRFile[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
// Why: GitLab deprecated the all-in-one `changes` endpoint in favor of
// the paginated diffs endpoint; cap the file snapshot at one visible page.
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/diffs?per_page=100`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as Parameters<typeof mapMRFile>[0][]
return data.map(mapMRFile).filter((file) => file.path)
}