feat(core): add mercurial adapter for vcs status and diff (#35141)
This commit is contained in:
parent
8d790a9ed5
commit
5657cec4f2
12 changed files with 769 additions and 99 deletions
|
|
@ -2,10 +2,12 @@ export * as ProjectV2 from "./project"
|
|||
export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { AppProcess } from "./process"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { Hash } from "./util/hash"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
|
|
@ -45,7 +47,7 @@ export const root = Effect.fn("Project.root")(function* (
|
|||
fs: FSUtil.Interface,
|
||||
input: AbsolutePath,
|
||||
) {
|
||||
return yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
|
|
@ -73,6 +75,7 @@ const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
const projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
||||
|
|
@ -124,20 +127,65 @@ const layer = Layer.effect(
|
|||
return root ? ID.make(root) : undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.repo.discover(input)
|
||||
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
// Mercurial identity uses the cached ID or the first root changeset; remote-derived
|
||||
// identity (the git `remote()` path) is a follow-up.
|
||||
const hgRoot = Effect.fnUntraced(function* (worktree: AbsolutePath) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("hg", ["log", "-r", "roots(all())", "-T", "{node}\n"], {
|
||||
cwd: worktree,
|
||||
env: { HGPLAIN: "1" },
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!result || result.exitCode !== 0) return undefined
|
||||
const node = result.stdout
|
||||
.toString("utf8")
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.toSorted()[0]
|
||||
return node ? ID.make(node) : undefined
|
||||
})
|
||||
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!dotHg) return undefined
|
||||
const worktree = AbsolutePath.make(path.dirname(dotHg))
|
||||
const store = AbsolutePath.make(dotHg)
|
||||
const previous = yield* cached(store)
|
||||
const id = previous ?? (yield* hgRoot(worktree))
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.worktree,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
directory: worktree,
|
||||
vcs: { type: "hg" as const, store },
|
||||
}
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.repo.discover(input)
|
||||
if (repo) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.worktree,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
}
|
||||
}
|
||||
|
||||
const hg = yield* hgDiscover(input)
|
||||
if (hg) return hg
|
||||
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
})
|
||||
|
||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
||||
})
|
||||
|
|
@ -149,5 +197,5 @@ const layer = Layer.effect(
|
|||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
||||
deps: [FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,5 +24,9 @@ export const Vcs = Schema.Union([
|
|||
type: Schema.Literal("git"),
|
||||
store: AbsolutePath,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("hg"),
|
||||
store: AbsolutePath,
|
||||
}),
|
||||
])
|
||||
export type Vcs = typeof Vcs.Type
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { Context, Effect, Layer } from "effect"
|
|||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
import { AppProcess } from "./process"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
import { VcsHg } from "./vcs/hg"
|
||||
|
||||
export { FileStatus, Mode }
|
||||
|
||||
|
|
@ -24,17 +26,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
// Adapter seam: one working-copy implementation per VCS type, selected by the
|
||||
// resolved location. Locations without a supported VCS degrade to empty
|
||||
// results so callers never need to special-case.
|
||||
const adapter = (proc: AppProcess.Interface, location: Location.Interface) => {
|
||||
if (location.vcs?.type === "git")
|
||||
return VcsGit.make(proc, { directory: location.directory, worktree: location.project.directory })
|
||||
const adapter = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
|
||||
const scope = { directory: location.directory, worktree: location.project.directory }
|
||||
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
|
||||
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const proc = yield* AppProcess.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const impl = adapter(proc, location)
|
||||
const impl = adapter(proc, fs, location)
|
||||
return Service.of({
|
||||
status: Effect.fn("Vcs.status")(function* () {
|
||||
if (!impl) return []
|
||||
|
|
@ -48,4 +52,8 @@ const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: layer, deps: [AppProcess.node, Location.node] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
export * as VcsGit from "./git"
|
||||
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { AppProcess } from "../process"
|
||||
import type { DiffOptions, Interface } from "../vcs"
|
||||
|
||||
const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
const MAX_PATCH_BYTES = 10_000_000
|
||||
const MAX_TOTAL_PATCH_BYTES = 10_000_000
|
||||
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch"
|
||||
import type { Patch } from "./patch"
|
||||
|
||||
/**
|
||||
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
|
||||
|
|
@ -84,11 +81,6 @@ interface Stat {
|
|||
readonly deletions: number
|
||||
}
|
||||
|
||||
interface Patch {
|
||||
readonly text: string
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
interface PatchOptions {
|
||||
readonly context?: number
|
||||
readonly maxOutputBytes?: number
|
||||
|
|
@ -325,8 +317,6 @@ function makeGit(proc: AppProcess.Interface) {
|
|||
}
|
||||
}
|
||||
|
||||
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
|
||||
|
||||
const nums = (list: Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
|
||||
|
|
@ -340,70 +330,6 @@ const merge = (...lists: Item[][]) => {
|
|||
|
||||
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
|
||||
|
||||
const parseQuotedPath = (value: string) => {
|
||||
let out = ""
|
||||
for (let idx = 1; idx < value.length; idx++) {
|
||||
const char = value[idx]
|
||||
if (char === '"') return { value: out, end: idx + 1 }
|
||||
if (char !== "\\") {
|
||||
out += char
|
||||
continue
|
||||
}
|
||||
|
||||
const next = value[++idx]
|
||||
if (next === "t") out += "\t"
|
||||
else if (next === "n") out += "\n"
|
||||
else if (next === "r") out += "\r"
|
||||
else if (next === '"' || next === "\\") out += next
|
||||
else out += next ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
const parsePathToken = (value: string) => {
|
||||
if (!value.startsWith('"')) return value.split("\t")[0]
|
||||
return parseQuotedPath(value)?.value ?? value
|
||||
}
|
||||
|
||||
const fileFromDiffPath = (value: string | undefined) => {
|
||||
if (!value || value === "/dev/null") return
|
||||
const file = parsePathToken(value)
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const fileFromGitHeader = (header: string) => {
|
||||
if (header.startsWith('"')) {
|
||||
const first = parseQuotedPath(header)
|
||||
const second = first ? header.slice(first.end).trimStart() : undefined
|
||||
if (!second) return
|
||||
if (!second.startsWith('"')) return fileFromDiffPath(second)
|
||||
return fileFromDiffPath(parseQuotedPath(second)?.value)
|
||||
}
|
||||
|
||||
const separator = header.indexOf(" b/")
|
||||
if (separator === -1) return
|
||||
return fileFromDiffPath(header.slice(separator + 1))
|
||||
}
|
||||
|
||||
const fileFromPatchChunk = (chunk: string) => {
|
||||
const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
|
||||
const before = /^--- (.+)$/m.exec(chunk)?.[1]
|
||||
const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
|
||||
if (file) return file
|
||||
|
||||
const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
|
||||
return fileFromGitHeader(header ?? "")
|
||||
}
|
||||
|
||||
const splitGitPatch = (patch: Patch) => {
|
||||
const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
|
||||
match[0].startsWith("\n") ? match.index + 1 : match.index,
|
||||
)
|
||||
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
|
||||
if (!patch.truncated) return chunks
|
||||
return chunks.slice(0, -1)
|
||||
}
|
||||
|
||||
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: DiffOptions) {
|
||||
if (list.length === 0) return emptyBatch()
|
||||
|
||||
|
|
@ -413,12 +339,7 @@ const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: I
|
|||
})
|
||||
|
||||
return {
|
||||
patches: splitGitPatch(result).reduce((acc, patch, index) => {
|
||||
const file = fileFromPatchChunk(patch) ?? list[index]?.file
|
||||
if (!file) return acc
|
||||
acc.set(file, (acc.get(file) ?? "") + patch)
|
||||
return acc
|
||||
}, new Map<string, string>()),
|
||||
patches: chunksByFile(result, (index) => list[index]?.file),
|
||||
capped: result.truncated,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
192
packages/core/src/vcs/hg.ts
Normal file
192
packages/core/src/vcs/hg.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
export * as VcsHg from "./hg"
|
||||
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { AppProcess } from "../process"
|
||||
import type { DiffOptions, Interface } from "../vcs"
|
||||
import {
|
||||
addPatch,
|
||||
chunksByFile,
|
||||
countPatch,
|
||||
deletePatch,
|
||||
emptyPatch,
|
||||
MAX_PATCH_BYTES,
|
||||
MAX_TOTAL_PATCH_BYTES,
|
||||
PATCH_CONTEXT_LINES,
|
||||
} from "./patch"
|
||||
|
||||
/**
|
||||
* Mercurial adapter for the Vcs service. `hg diff --git` emits git-format
|
||||
* patches for tracked changes; untracked (`?`) and missing (`!`) files never
|
||||
* appear in `hg diff`, so their patches are synthesized from file contents.
|
||||
*/
|
||||
export function make(
|
||||
proc: AppProcess.Interface,
|
||||
fs: FSUtil.Interface,
|
||||
input: { directory: string; worktree: string },
|
||||
): Interface {
|
||||
const hg = makeHg(proc, input.worktree)
|
||||
// All commands run from the worktree root (hg prints root-relative paths);
|
||||
// this pathspec scopes them to the requested directory.
|
||||
const scope = path.relative(input.worktree, input.directory) || "."
|
||||
|
||||
const patchFor = Effect.fnUntraced(function* (item: Item, rev: string | undefined, chunk: string | undefined) {
|
||||
if (chunk !== undefined) return chunk
|
||||
if (item.code === "?") {
|
||||
const content = yield* fs
|
||||
.readFileString(path.join(input.worktree, item.file))
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (content === undefined || Buffer.byteLength(content) > MAX_PATCH_BYTES) return emptyPatch(item.file)
|
||||
return addPatch(item.file, content)
|
||||
}
|
||||
if (item.code === "!") {
|
||||
const content = yield* hg.cat(rev ?? ".", item.file)
|
||||
if (content === undefined || Buffer.byteLength(content) > MAX_PATCH_BYTES) return emptyPatch(item.file)
|
||||
return deletePatch(item.file, content)
|
||||
}
|
||||
return emptyPatch(item.file)
|
||||
})
|
||||
|
||||
const diffAgainst = Effect.fnUntraced(function* (rev: string | undefined, options?: DiffOptions) {
|
||||
const [items, batch] = yield* Effect.all(
|
||||
[hg.status(rev, scope), hg.diff(rev, scope, { context: options?.context ?? PATCH_CONTEXT_LINES })],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const chunks = chunksByFile(batch, () => undefined)
|
||||
const list: FileDiff.Info[] = []
|
||||
for (const item of items.toSorted((a, b) => a.file.localeCompare(b.file))) {
|
||||
const patch = yield* patchFor(item, rev, chunks.get(item.file))
|
||||
const counts = countPatch(patch)
|
||||
list.push({
|
||||
file: item.file,
|
||||
patch,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: item.status,
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
return {
|
||||
status: Effect.fn("VcsHg.status")(function* () {
|
||||
const [items, batch] = yield* Effect.all(
|
||||
// Zero-context patches are enough to count changed lines.
|
||||
[hg.status(undefined, scope), hg.diff(undefined, scope, { context: 0 })],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const chunks = chunksByFile(batch, () => undefined)
|
||||
return yield* Effect.forEach(
|
||||
items.toSorted((a, b) => a.file.localeCompare(b.file)),
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const patch = yield* patchFor(item, undefined, chunks.get(item.file))
|
||||
const counts = countPatch(patch)
|
||||
return {
|
||||
file: item.file,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: item.status,
|
||||
} satisfies FileStatus
|
||||
}),
|
||||
)
|
||||
}),
|
||||
diff: Effect.fn("VcsHg.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
if (mode === "working") return yield* diffAgainst(undefined, options)
|
||||
|
||||
const branch = yield* hg.branch()
|
||||
if (!branch || branch === "default") return []
|
||||
const ancestor = yield* hg.ancestor()
|
||||
if (!ancestor) return []
|
||||
return yield* diffAgainst(ancestor, options)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type Kind = FileStatus["status"]
|
||||
|
||||
interface Item {
|
||||
readonly file: string
|
||||
readonly code: string
|
||||
readonly status: Kind
|
||||
}
|
||||
|
||||
const kind = (code: string): Kind => {
|
||||
if (code === "A" || code === "?") return "added"
|
||||
if (code === "R" || code === "!") return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
function makeHg(proc: AppProcess.Interface, worktree: string) {
|
||||
const run = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { maxOutputBytes?: number }) {
|
||||
const result = yield* proc.run(
|
||||
ChildProcess.make("hg", args, {
|
||||
cwd: worktree,
|
||||
env: { HGPLAIN: "1" },
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ maxOutputBytes: opts?.maxOutputBytes },
|
||||
)
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
text: () => result.stdout.toString("utf8"),
|
||||
truncated: result.stdoutTruncated || result.stderrTruncated,
|
||||
}
|
||||
},
|
||||
Effect.catch(() => Effect.succeed({ exitCode: 1, text: () => "", truncated: false })),
|
||||
)
|
||||
|
||||
const status = Effect.fn("VcsHg.statusNames")(function* (rev: string | undefined, scope: string) {
|
||||
const result = yield* run(["status", "-0", ...(rev ? ["--rev", rev] : []), scope])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result
|
||||
.text()
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.flatMap((entry) => {
|
||||
const code = entry.slice(0, 1)
|
||||
const file = entry.slice(2)
|
||||
if (!file) return []
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
})
|
||||
|
||||
const diff = Effect.fn("VcsHg.diffPatch")(function* (
|
||||
rev: string | undefined,
|
||||
scope: string,
|
||||
options: { context: number },
|
||||
) {
|
||||
const result = yield* run(
|
||||
["diff", "--git", "--unified", String(options.context), ...(rev ? ["-r", rev] : []), scope],
|
||||
{ maxOutputBytes: MAX_TOTAL_PATCH_BYTES },
|
||||
)
|
||||
if (result.exitCode !== 0) return { text: "", truncated: false }
|
||||
return { text: result.text(), truncated: result.truncated }
|
||||
})
|
||||
|
||||
const branch = Effect.fn("VcsHg.branch")(function* () {
|
||||
const result = yield* run(["branch"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text().trim() || undefined
|
||||
})
|
||||
|
||||
const ancestor = Effect.fn("VcsHg.ancestor")(function* () {
|
||||
const result = yield* run(["log", "-r", "ancestor(., default)", "-T", "{node}"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text().trim() || undefined
|
||||
})
|
||||
|
||||
const cat = Effect.fn("VcsHg.cat")(function* (rev: string, file: string) {
|
||||
const result = yield* run(["cat", "-r", rev, "--", file], { maxOutputBytes: MAX_PATCH_BYTES })
|
||||
if (result.exitCode !== 0 || result.truncated) return undefined
|
||||
return result.text()
|
||||
})
|
||||
|
||||
return { status, diff, branch, ancestor, cat }
|
||||
}
|
||||
106
packages/core/src/vcs/patch.ts
Normal file
106
packages/core/src/vcs/patch.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
export * as VcsPatch from "./patch"
|
||||
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
|
||||
// Effectively "full file" context; adapters use it when no context is requested.
|
||||
export const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
export const MAX_PATCH_BYTES = 10_000_000
|
||||
export const MAX_TOTAL_PATCH_BYTES = 10_000_000
|
||||
|
||||
export interface Patch {
|
||||
readonly text: string
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
|
||||
|
||||
export const addPatch = (file: string, content: string) =>
|
||||
formatPatch(structuredPatch(file, file, "", content, "", "", { context: 0 }))
|
||||
|
||||
export const deletePatch = (file: string, content: string) =>
|
||||
formatPatch(structuredPatch(file, file, content, "", "", "", { context: 0 }))
|
||||
|
||||
/** Count changed lines in a unified diff, ignoring `+++`/`---` file headers. */
|
||||
export const countPatch = (patch: string) => {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) additions++
|
||||
else if (line.startsWith("-") && !line.startsWith("---")) deletions++
|
||||
}
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
const parseQuotedPath = (value: string) => {
|
||||
let out = ""
|
||||
for (let idx = 1; idx < value.length; idx++) {
|
||||
const char = value[idx]
|
||||
if (char === '"') return { value: out, end: idx + 1 }
|
||||
if (char !== "\\") {
|
||||
out += char
|
||||
continue
|
||||
}
|
||||
|
||||
const next = value[++idx]
|
||||
if (next === "t") out += "\t"
|
||||
else if (next === "n") out += "\n"
|
||||
else if (next === "r") out += "\r"
|
||||
else if (next === '"' || next === "\\") out += next
|
||||
else out += next ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
const parsePathToken = (value: string) => {
|
||||
if (!value.startsWith('"')) return value.split("\t")[0]
|
||||
return parseQuotedPath(value)?.value ?? value
|
||||
}
|
||||
|
||||
const fileFromDiffPath = (value: string | undefined) => {
|
||||
if (!value || value === "/dev/null") return
|
||||
const file = parsePathToken(value)
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const fileFromGitHeader = (header: string) => {
|
||||
if (header.startsWith('"')) {
|
||||
const first = parseQuotedPath(header)
|
||||
const second = first ? header.slice(first.end).trimStart() : undefined
|
||||
if (!second) return
|
||||
if (!second.startsWith('"')) return fileFromDiffPath(second)
|
||||
return fileFromDiffPath(parseQuotedPath(second)?.value)
|
||||
}
|
||||
|
||||
const separator = header.indexOf(" b/")
|
||||
if (separator === -1) return
|
||||
return fileFromDiffPath(header.slice(separator + 1))
|
||||
}
|
||||
|
||||
export const fileFromPatchChunk = (chunk: string) => {
|
||||
const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
|
||||
const before = /^--- (.+)$/m.exec(chunk)?.[1]
|
||||
const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
|
||||
if (file) return file
|
||||
|
||||
const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
|
||||
return fileFromGitHeader(header ?? "")
|
||||
}
|
||||
|
||||
/** Split `git diff`-format output into per-file chunks, dropping a trailing partial chunk when truncated. */
|
||||
export const splitGitPatch = (patch: Patch) => {
|
||||
const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
|
||||
match[0].startsWith("\n") ? match.index + 1 : match.index,
|
||||
)
|
||||
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
|
||||
if (!patch.truncated) return chunks
|
||||
return chunks.slice(0, -1)
|
||||
}
|
||||
|
||||
/** Map per-file chunks by the file they touch, concatenating chunks for the same file. */
|
||||
export const chunksByFile = (patch: Patch, fallback: (index: number) => string | undefined) =>
|
||||
splitGitPatch(patch).reduce((acc, chunk, index) => {
|
||||
const file = fileFromPatchChunk(chunk) ?? fallback(index)
|
||||
if (!file) return acc
|
||||
acc.set(file, (acc.get(file) ?? "") + chunk)
|
||||
return acc
|
||||
}, new Map<string, string>())
|
||||
Loading…
Add table
Add a link
Reference in a new issue