summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-16 21:02:09 -0400
committerGitHub <[email protected]>2026-04-17 01:02:09 +0000
commitc59df636cc3d9b203e2b84dcefecba15eda5b457 (patch)
treebf9966ae42f5501a97157d33057ad33243d36738 /packages
parent94878d76f8a32c36909647a0d9e1f6e383f60908 (diff)
downloadopencode-c59df636cc3d9b203e2b84dcefecba15eda5b457.tar.gz
opencode-c59df636cc3d9b203e2b84dcefecba15eda5b457.zip
chore: delete empty v2/session-common + collapse patch barrel (#22981)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/patch/index.ts681
-rw-r--r--packages/opencode/src/patch/patch.ts678
-rw-r--r--packages/opencode/src/v2/session-common.ts1
3 files changed, 680 insertions, 680 deletions
diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts
index cec24614d..19e1d7555 100644
--- a/packages/opencode/src/patch/index.ts
+++ b/packages/opencode/src/patch/index.ts
@@ -1 +1,680 @@
-export * as Patch from "./patch"
+import z from "zod"
+import * as path from "path"
+import * as fs from "fs/promises"
+import { readFileSync } from "fs"
+import { Log } from "../util"
+
+const log = Log.create({ service: "patch" })
+
+// Schema definitions
+export const PatchSchema = z.object({
+ patchText: z.string().describe("The full patch text that describes all changes to be made"),
+})
+
+export type PatchParams = z.infer<typeof PatchSchema>
+
+// Core types matching the Rust implementation
+export interface ApplyPatchArgs {
+ patch: string
+ hunks: Hunk[]
+ workdir?: string
+}
+
+export type Hunk =
+ | { type: "add"; path: string; contents: string }
+ | { type: "delete"; path: string }
+ | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] }
+
+export interface UpdateFileChunk {
+ old_lines: string[]
+ new_lines: string[]
+ change_context?: string
+ is_end_of_file?: boolean
+}
+
+export interface ApplyPatchAction {
+ changes: Map<string, ApplyPatchFileChange>
+ patch: string
+ cwd: string
+}
+
+export type ApplyPatchFileChange =
+ | { type: "add"; content: string }
+ | { type: "delete"; content: string }
+ | { type: "update"; unified_diff: string; move_path?: string; new_content: string }
+
+export interface AffectedPaths {
+ added: string[]
+ modified: string[]
+ deleted: string[]
+}
+
+export enum ApplyPatchError {
+ ParseError = "ParseError",
+ IoError = "IoError",
+ ComputeReplacements = "ComputeReplacements",
+ ImplicitInvocation = "ImplicitInvocation",
+}
+
+export enum MaybeApplyPatch {
+ Body = "Body",
+ ShellParseError = "ShellParseError",
+ PatchParseError = "PatchParseError",
+ NotApplyPatch = "NotApplyPatch",
+}
+
+export enum MaybeApplyPatchVerified {
+ Body = "Body",
+ ShellParseError = "ShellParseError",
+ CorrectnessError = "CorrectnessError",
+ NotApplyPatch = "NotApplyPatch",
+}
+
+// Parser implementation
+function parsePatchHeader(
+ lines: string[],
+ startIdx: number,
+): { filePath: string; movePath?: string; nextIdx: number } | null {
+ const line = lines[startIdx]
+
+ if (line.startsWith("*** Add File:")) {
+ const filePath = line.slice("*** Add File:".length).trim()
+ return filePath ? { filePath, nextIdx: startIdx + 1 } : null
+ }
+
+ if (line.startsWith("*** Delete File:")) {
+ const filePath = line.slice("*** Delete File:".length).trim()
+ return filePath ? { filePath, nextIdx: startIdx + 1 } : null
+ }
+
+ if (line.startsWith("*** Update File:")) {
+ const filePath = line.slice("*** Update File:".length).trim()
+ let movePath: string | undefined
+ let nextIdx = startIdx + 1
+
+ // Check for move directive
+ if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
+ movePath = lines[nextIdx].slice("*** Move to:".length).trim()
+ nextIdx++
+ }
+
+ return filePath ? { filePath, movePath, nextIdx } : null
+ }
+
+ return null
+}
+
+function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } {
+ const chunks: UpdateFileChunk[] = []
+ let i = startIdx
+
+ while (i < lines.length && !lines[i].startsWith("***")) {
+ if (lines[i].startsWith("@@")) {
+ // Parse context line
+ const contextLine = lines[i].substring(2).trim()
+ i++
+
+ const oldLines: string[] = []
+ const newLines: string[] = []
+ let isEndOfFile = false
+
+ // Parse change lines
+ while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
+ const changeLine = lines[i]
+
+ if (changeLine === "*** End of File") {
+ isEndOfFile = true
+ i++
+ break
+ }
+
+ if (changeLine.startsWith(" ")) {
+ // Keep line - appears in both old and new
+ const content = changeLine.substring(1)
+ oldLines.push(content)
+ newLines.push(content)
+ } else if (changeLine.startsWith("-")) {
+ // Remove line - only in old
+ oldLines.push(changeLine.substring(1))
+ } else if (changeLine.startsWith("+")) {
+ // Add line - only in new
+ newLines.push(changeLine.substring(1))
+ }
+
+ i++
+ }
+
+ chunks.push({
+ old_lines: oldLines,
+ new_lines: newLines,
+ change_context: contextLine || undefined,
+ is_end_of_file: isEndOfFile || undefined,
+ })
+ } else {
+ i++
+ }
+ }
+
+ return { chunks, nextIdx: i }
+}
+
+function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } {
+ let content = ""
+ let i = startIdx
+
+ while (i < lines.length && !lines[i].startsWith("***")) {
+ if (lines[i].startsWith("+")) {
+ content += lines[i].substring(1) + "\n"
+ }
+ i++
+ }
+
+ // Remove trailing newline
+ if (content.endsWith("\n")) {
+ content = content.slice(0, -1)
+ }
+
+ return { content, nextIdx: i }
+}
+
+function stripHeredoc(input: string): string {
+ // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
+ const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
+ if (heredocMatch) {
+ return heredocMatch[2]
+ }
+ return input
+}
+
+export function parsePatch(patchText: string): { hunks: Hunk[] } {
+ const cleaned = stripHeredoc(patchText.trim())
+ const lines = cleaned.split("\n")
+ const hunks: Hunk[] = []
+ let i = 0
+
+ // Look for Begin/End patch markers
+ const beginMarker = "*** Begin Patch"
+ const endMarker = "*** End Patch"
+
+ const beginIdx = lines.findIndex((line) => line.trim() === beginMarker)
+ const endIdx = lines.findIndex((line) => line.trim() === endMarker)
+
+ if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
+ throw new Error("Invalid patch format: missing Begin/End markers")
+ }
+
+ // Parse content between markers
+ i = beginIdx + 1
+
+ while (i < endIdx) {
+ const header = parsePatchHeader(lines, i)
+ if (!header) {
+ i++
+ continue
+ }
+
+ if (lines[i].startsWith("*** Add File:")) {
+ const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx)
+ hunks.push({
+ type: "add",
+ path: header.filePath,
+ contents: content,
+ })
+ i = nextIdx
+ } else if (lines[i].startsWith("*** Delete File:")) {
+ hunks.push({
+ type: "delete",
+ path: header.filePath,
+ })
+ i = header.nextIdx
+ } else if (lines[i].startsWith("*** Update File:")) {
+ const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx)
+ hunks.push({
+ type: "update",
+ path: header.filePath,
+ move_path: header.movePath,
+ chunks,
+ })
+ i = nextIdx
+ } else {
+ i++
+ }
+ }
+
+ return { hunks }
+}
+
+// Apply patch functionality
+export function maybeParseApplyPatch(
+ argv: string[],
+):
+ | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs }
+ | { type: MaybeApplyPatch.PatchParseError; error: Error }
+ | { type: MaybeApplyPatch.NotApplyPatch } {
+ const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"]
+
+ // Direct invocation: apply_patch <patch>
+ if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) {
+ try {
+ const { hunks } = parsePatch(argv[1])
+ return {
+ type: MaybeApplyPatch.Body,
+ args: {
+ patch: argv[1],
+ hunks,
+ },
+ }
+ } catch (error) {
+ return {
+ type: MaybeApplyPatch.PatchParseError,
+ error: error as Error,
+ }
+ }
+ }
+
+ // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...'
+ if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") {
+ // Simple extraction - in real implementation would need proper bash parsing
+ const script = argv[2]
+ const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/)
+
+ if (heredocMatch) {
+ const patchContent = heredocMatch[2]
+ try {
+ const { hunks } = parsePatch(patchContent)
+ return {
+ type: MaybeApplyPatch.Body,
+ args: {
+ patch: patchContent,
+ hunks,
+ },
+ }
+ } catch (error) {
+ return {
+ type: MaybeApplyPatch.PatchParseError,
+ error: error as Error,
+ }
+ }
+ }
+ }
+
+ return { type: MaybeApplyPatch.NotApplyPatch }
+}
+
+// File content manipulation
+interface ApplyPatchFileUpdate {
+ unified_diff: string
+ content: string
+}
+
+export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
+ // Read original file content
+ let originalContent: string
+ try {
+ originalContent = readFileSync(filePath, "utf-8")
+ } catch (error) {
+ throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
+ }
+
+ let originalLines = originalContent.split("\n")
+
+ // Drop trailing empty element for consistent line counting
+ if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
+ originalLines.pop()
+ }
+
+ const replacements = computeReplacements(originalLines, filePath, chunks)
+ let newLines = applyReplacements(originalLines, replacements)
+
+ // Ensure trailing newline
+ if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
+ newLines.push("")
+ }
+
+ const newContent = newLines.join("\n")
+
+ // Generate unified diff
+ const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
+
+ return {
+ unified_diff: unifiedDiff,
+ content: newContent,
+ }
+}
+
+function computeReplacements(
+ originalLines: string[],
+ filePath: string,
+ chunks: UpdateFileChunk[],
+): Array<[number, number, string[]]> {
+ const replacements: Array<[number, number, string[]]> = []
+ let lineIndex = 0
+
+ for (const chunk of chunks) {
+ // Handle context-based seeking
+ if (chunk.change_context) {
+ const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex)
+ if (contextIdx === -1) {
+ throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`)
+ }
+ lineIndex = contextIdx + 1
+ }
+
+ // Handle pure addition (no old lines)
+ if (chunk.old_lines.length === 0) {
+ const insertionIdx =
+ originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
+ ? originalLines.length - 1
+ : originalLines.length
+ replacements.push([insertionIdx, 0, chunk.new_lines])
+ continue
+ }
+
+ // Try to match old lines in the file
+ let pattern = chunk.old_lines
+ let newSlice = chunk.new_lines
+ let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
+
+ // Retry without trailing empty line if not found
+ if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
+ pattern = pattern.slice(0, -1)
+ if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
+ newSlice = newSlice.slice(0, -1)
+ }
+ found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
+ }
+
+ if (found !== -1) {
+ replacements.push([found, pattern.length, newSlice])
+ lineIndex = found + pattern.length
+ } else {
+ throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`)
+ }
+ }
+
+ // Sort replacements by index to apply in order
+ replacements.sort((a, b) => a[0] - b[0])
+
+ return replacements
+}
+
+function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] {
+ // Apply replacements in reverse order to avoid index shifting
+ const result = [...lines]
+
+ for (let i = replacements.length - 1; i >= 0; i--) {
+ const [startIdx, oldLen, newSegment] = replacements[i]
+
+ // Remove old lines
+ result.splice(startIdx, oldLen)
+
+ // Insert new lines
+ for (let j = 0; j < newSegment.length; j++) {
+ result.splice(startIdx + j, 0, newSegment[j])
+ }
+ }
+
+ return result
+}
+
+// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
+function normalizeUnicode(str: string): string {
+ return str
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
+ .replace(/\u2026/g, "...") // ellipsis
+ .replace(/\u00A0/g, " ") // non-breaking space
+}
+
+type Comparator = (a: string, b: string) => boolean
+
+function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
+ // If EOF anchor, try matching from end of file first
+ if (eof) {
+ const fromEnd = lines.length - pattern.length
+ if (fromEnd >= startIndex) {
+ let matches = true
+ for (let j = 0; j < pattern.length; j++) {
+ if (!compare(lines[fromEnd + j], pattern[j])) {
+ matches = false
+ break
+ }
+ }
+ if (matches) return fromEnd
+ }
+ }
+
+ // Forward search from startIndex
+ for (let i = startIndex; i <= lines.length - pattern.length; i++) {
+ let matches = true
+ for (let j = 0; j < pattern.length; j++) {
+ if (!compare(lines[i + j], pattern[j])) {
+ matches = false
+ break
+ }
+ }
+ if (matches) return i
+ }
+
+ return -1
+}
+
+function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
+ if (pattern.length === 0) return -1
+
+ // Pass 1: exact match
+ const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
+ if (exact !== -1) return exact
+
+ // Pass 2: rstrip (trim trailing whitespace)
+ const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
+ if (rstrip !== -1) return rstrip
+
+ // Pass 3: trim (both ends)
+ const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
+ if (trim !== -1) return trim
+
+ // Pass 4: normalized (Unicode punctuation to ASCII)
+ const normalized = tryMatch(
+ lines,
+ pattern,
+ startIndex,
+ (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
+ eof,
+ )
+ return normalized
+}
+
+function generateUnifiedDiff(oldContent: string, newContent: string): string {
+ const oldLines = oldContent.split("\n")
+ const newLines = newContent.split("\n")
+
+ // Simple diff generation - in a real implementation you'd use a proper diff algorithm
+ let diff = "@@ -1 +1 @@\n"
+
+ // Find changes (simplified approach)
+ const maxLen = Math.max(oldLines.length, newLines.length)
+ let hasChanges = false
+
+ for (let i = 0; i < maxLen; i++) {
+ const oldLine = oldLines[i] || ""
+ const newLine = newLines[i] || ""
+
+ if (oldLine !== newLine) {
+ if (oldLine) diff += `-${oldLine}\n`
+ if (newLine) diff += `+${newLine}\n`
+ hasChanges = true
+ } else if (oldLine) {
+ diff += ` ${oldLine}\n`
+ }
+ }
+
+ return hasChanges ? diff : ""
+}
+
+// Apply hunks to filesystem
+export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
+ if (hunks.length === 0) {
+ throw new Error("No files were modified.")
+ }
+
+ const added: string[] = []
+ const modified: string[] = []
+ const deleted: string[] = []
+
+ for (const hunk of hunks) {
+ switch (hunk.type) {
+ case "add":
+ // Create parent directories
+ const addDir = path.dirname(hunk.path)
+ if (addDir !== "." && addDir !== "/") {
+ await fs.mkdir(addDir, { recursive: true })
+ }
+
+ await fs.writeFile(hunk.path, hunk.contents, "utf-8")
+ added.push(hunk.path)
+ log.info(`Added file: ${hunk.path}`)
+ break
+
+ case "delete":
+ await fs.unlink(hunk.path)
+ deleted.push(hunk.path)
+ log.info(`Deleted file: ${hunk.path}`)
+ break
+
+ case "update":
+ const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
+
+ if (hunk.move_path) {
+ // Handle file move
+ const moveDir = path.dirname(hunk.move_path)
+ if (moveDir !== "." && moveDir !== "/") {
+ await fs.mkdir(moveDir, { recursive: true })
+ }
+
+ await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
+ await fs.unlink(hunk.path)
+ modified.push(hunk.move_path)
+ log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
+ } else {
+ // Regular update
+ await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
+ modified.push(hunk.path)
+ log.info(`Updated file: ${hunk.path}`)
+ }
+ break
+ }
+ }
+
+ return { added, modified, deleted }
+}
+
+// Main patch application function
+export async function applyPatch(patchText: string): Promise<AffectedPaths> {
+ const { hunks } = parsePatch(patchText)
+ return applyHunksToFiles(hunks)
+}
+
+// Async version of maybeParseApplyPatchVerified
+export async function maybeParseApplyPatchVerified(
+ argv: string[],
+ cwd: string,
+): Promise<
+ | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
+ | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
+ | { type: MaybeApplyPatchVerified.NotApplyPatch }
+> {
+ // Detect implicit patch invocation (raw patch without apply_patch command)
+ if (argv.length === 1) {
+ try {
+ parsePatch(argv[0])
+ return {
+ type: MaybeApplyPatchVerified.CorrectnessError,
+ error: new Error(ApplyPatchError.ImplicitInvocation),
+ }
+ } catch {
+ // Not a patch, continue
+ }
+ }
+
+ const result = maybeParseApplyPatch(argv)
+
+ switch (result.type) {
+ case MaybeApplyPatch.Body:
+ const { args } = result
+ const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
+ const changes = new Map<string, ApplyPatchFileChange>()
+
+ for (const hunk of args.hunks) {
+ const resolvedPath = path.resolve(
+ effectiveCwd,
+ hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path,
+ )
+
+ switch (hunk.type) {
+ case "add":
+ changes.set(resolvedPath, {
+ type: "add",
+ content: hunk.contents,
+ })
+ break
+
+ case "delete":
+ // For delete, we need to read the current content
+ const deletePath = path.resolve(effectiveCwd, hunk.path)
+ try {
+ const content = await fs.readFile(deletePath, "utf-8")
+ changes.set(resolvedPath, {
+ type: "delete",
+ content,
+ })
+ } catch {
+ return {
+ type: MaybeApplyPatchVerified.CorrectnessError,
+ error: new Error(`Failed to read file for deletion: ${deletePath}`),
+ }
+ }
+ break
+
+ case "update":
+ const updatePath = path.resolve(effectiveCwd, hunk.path)
+ try {
+ const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
+ changes.set(resolvedPath, {
+ type: "update",
+ unified_diff: fileUpdate.unified_diff,
+ move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined,
+ new_content: fileUpdate.content,
+ })
+ } catch (error) {
+ return {
+ type: MaybeApplyPatchVerified.CorrectnessError,
+ error: error as Error,
+ }
+ }
+ break
+ }
+ }
+
+ return {
+ type: MaybeApplyPatchVerified.Body,
+ action: {
+ changes,
+ patch: args.patch,
+ cwd: effectiveCwd,
+ },
+ }
+
+ case MaybeApplyPatch.PatchParseError:
+ return {
+ type: MaybeApplyPatchVerified.CorrectnessError,
+ error: result.error,
+ }
+
+ case MaybeApplyPatch.NotApplyPatch:
+ return { type: MaybeApplyPatchVerified.NotApplyPatch }
+ }
+}
+
+export * as Patch from "."
diff --git a/packages/opencode/src/patch/patch.ts b/packages/opencode/src/patch/patch.ts
deleted file mode 100644
index 1dc99b4da..000000000
--- a/packages/opencode/src/patch/patch.ts
+++ /dev/null
@@ -1,678 +0,0 @@
-import z from "zod"
-import * as path from "path"
-import * as fs from "fs/promises"
-import { readFileSync } from "fs"
-import { Log } from "../util"
-
-const log = Log.create({ service: "patch" })
-
-// Schema definitions
-export const PatchSchema = z.object({
- patchText: z.string().describe("The full patch text that describes all changes to be made"),
-})
-
-export type PatchParams = z.infer<typeof PatchSchema>
-
-// Core types matching the Rust implementation
-export interface ApplyPatchArgs {
- patch: string
- hunks: Hunk[]
- workdir?: string
-}
-
-export type Hunk =
- | { type: "add"; path: string; contents: string }
- | { type: "delete"; path: string }
- | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] }
-
-export interface UpdateFileChunk {
- old_lines: string[]
- new_lines: string[]
- change_context?: string
- is_end_of_file?: boolean
-}
-
-export interface ApplyPatchAction {
- changes: Map<string, ApplyPatchFileChange>
- patch: string
- cwd: string
-}
-
-export type ApplyPatchFileChange =
- | { type: "add"; content: string }
- | { type: "delete"; content: string }
- | { type: "update"; unified_diff: string; move_path?: string; new_content: string }
-
-export interface AffectedPaths {
- added: string[]
- modified: string[]
- deleted: string[]
-}
-
-export enum ApplyPatchError {
- ParseError = "ParseError",
- IoError = "IoError",
- ComputeReplacements = "ComputeReplacements",
- ImplicitInvocation = "ImplicitInvocation",
-}
-
-export enum MaybeApplyPatch {
- Body = "Body",
- ShellParseError = "ShellParseError",
- PatchParseError = "PatchParseError",
- NotApplyPatch = "NotApplyPatch",
-}
-
-export enum MaybeApplyPatchVerified {
- Body = "Body",
- ShellParseError = "ShellParseError",
- CorrectnessError = "CorrectnessError",
- NotApplyPatch = "NotApplyPatch",
-}
-
-// Parser implementation
-function parsePatchHeader(
- lines: string[],
- startIdx: number,
-): { filePath: string; movePath?: string; nextIdx: number } | null {
- const line = lines[startIdx]
-
- if (line.startsWith("*** Add File:")) {
- const filePath = line.slice("*** Add File:".length).trim()
- return filePath ? { filePath, nextIdx: startIdx + 1 } : null
- }
-
- if (line.startsWith("*** Delete File:")) {
- const filePath = line.slice("*** Delete File:".length).trim()
- return filePath ? { filePath, nextIdx: startIdx + 1 } : null
- }
-
- if (line.startsWith("*** Update File:")) {
- const filePath = line.slice("*** Update File:".length).trim()
- let movePath: string | undefined
- let nextIdx = startIdx + 1
-
- // Check for move directive
- if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
- movePath = lines[nextIdx].slice("*** Move to:".length).trim()
- nextIdx++
- }
-
- return filePath ? { filePath, movePath, nextIdx } : null
- }
-
- return null
-}
-
-function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } {
- const chunks: UpdateFileChunk[] = []
- let i = startIdx
-
- while (i < lines.length && !lines[i].startsWith("***")) {
- if (lines[i].startsWith("@@")) {
- // Parse context line
- const contextLine = lines[i].substring(2).trim()
- i++
-
- const oldLines: string[] = []
- const newLines: string[] = []
- let isEndOfFile = false
-
- // Parse change lines
- while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
- const changeLine = lines[i]
-
- if (changeLine === "*** End of File") {
- isEndOfFile = true
- i++
- break
- }
-
- if (changeLine.startsWith(" ")) {
- // Keep line - appears in both old and new
- const content = changeLine.substring(1)
- oldLines.push(content)
- newLines.push(content)
- } else if (changeLine.startsWith("-")) {
- // Remove line - only in old
- oldLines.push(changeLine.substring(1))
- } else if (changeLine.startsWith("+")) {
- // Add line - only in new
- newLines.push(changeLine.substring(1))
- }
-
- i++
- }
-
- chunks.push({
- old_lines: oldLines,
- new_lines: newLines,
- change_context: contextLine || undefined,
- is_end_of_file: isEndOfFile || undefined,
- })
- } else {
- i++
- }
- }
-
- return { chunks, nextIdx: i }
-}
-
-function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } {
- let content = ""
- let i = startIdx
-
- while (i < lines.length && !lines[i].startsWith("***")) {
- if (lines[i].startsWith("+")) {
- content += lines[i].substring(1) + "\n"
- }
- i++
- }
-
- // Remove trailing newline
- if (content.endsWith("\n")) {
- content = content.slice(0, -1)
- }
-
- return { content, nextIdx: i }
-}
-
-function stripHeredoc(input: string): string {
- // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
- const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
- if (heredocMatch) {
- return heredocMatch[2]
- }
- return input
-}
-
-export function parsePatch(patchText: string): { hunks: Hunk[] } {
- const cleaned = stripHeredoc(patchText.trim())
- const lines = cleaned.split("\n")
- const hunks: Hunk[] = []
- let i = 0
-
- // Look for Begin/End patch markers
- const beginMarker = "*** Begin Patch"
- const endMarker = "*** End Patch"
-
- const beginIdx = lines.findIndex((line) => line.trim() === beginMarker)
- const endIdx = lines.findIndex((line) => line.trim() === endMarker)
-
- if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
- throw new Error("Invalid patch format: missing Begin/End markers")
- }
-
- // Parse content between markers
- i = beginIdx + 1
-
- while (i < endIdx) {
- const header = parsePatchHeader(lines, i)
- if (!header) {
- i++
- continue
- }
-
- if (lines[i].startsWith("*** Add File:")) {
- const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx)
- hunks.push({
- type: "add",
- path: header.filePath,
- contents: content,
- })
- i = nextIdx
- } else if (lines[i].startsWith("*** Delete File:")) {
- hunks.push({
- type: "delete",
- path: header.filePath,
- })
- i = header.nextIdx
- } else if (lines[i].startsWith("*** Update File:")) {
- const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx)
- hunks.push({
- type: "update",
- path: header.filePath,
- move_path: header.movePath,
- chunks,
- })
- i = nextIdx
- } else {
- i++
- }
- }
-
- return { hunks }
-}
-
-// Apply patch functionality
-export function maybeParseApplyPatch(
- argv: string[],
-):
- | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs }
- | { type: MaybeApplyPatch.PatchParseError; error: Error }
- | { type: MaybeApplyPatch.NotApplyPatch } {
- const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"]
-
- // Direct invocation: apply_patch <patch>
- if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) {
- try {
- const { hunks } = parsePatch(argv[1])
- return {
- type: MaybeApplyPatch.Body,
- args: {
- patch: argv[1],
- hunks,
- },
- }
- } catch (error) {
- return {
- type: MaybeApplyPatch.PatchParseError,
- error: error as Error,
- }
- }
- }
-
- // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...'
- if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") {
- // Simple extraction - in real implementation would need proper bash parsing
- const script = argv[2]
- const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/)
-
- if (heredocMatch) {
- const patchContent = heredocMatch[2]
- try {
- const { hunks } = parsePatch(patchContent)
- return {
- type: MaybeApplyPatch.Body,
- args: {
- patch: patchContent,
- hunks,
- },
- }
- } catch (error) {
- return {
- type: MaybeApplyPatch.PatchParseError,
- error: error as Error,
- }
- }
- }
- }
-
- return { type: MaybeApplyPatch.NotApplyPatch }
-}
-
-// File content manipulation
-interface ApplyPatchFileUpdate {
- unified_diff: string
- content: string
-}
-
-export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
- // Read original file content
- let originalContent: string
- try {
- originalContent = readFileSync(filePath, "utf-8")
- } catch (error) {
- throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
- }
-
- let originalLines = originalContent.split("\n")
-
- // Drop trailing empty element for consistent line counting
- if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
- originalLines.pop()
- }
-
- const replacements = computeReplacements(originalLines, filePath, chunks)
- let newLines = applyReplacements(originalLines, replacements)
-
- // Ensure trailing newline
- if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
- newLines.push("")
- }
-
- const newContent = newLines.join("\n")
-
- // Generate unified diff
- const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
-
- return {
- unified_diff: unifiedDiff,
- content: newContent,
- }
-}
-
-function computeReplacements(
- originalLines: string[],
- filePath: string,
- chunks: UpdateFileChunk[],
-): Array<[number, number, string[]]> {
- const replacements: Array<[number, number, string[]]> = []
- let lineIndex = 0
-
- for (const chunk of chunks) {
- // Handle context-based seeking
- if (chunk.change_context) {
- const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex)
- if (contextIdx === -1) {
- throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`)
- }
- lineIndex = contextIdx + 1
- }
-
- // Handle pure addition (no old lines)
- if (chunk.old_lines.length === 0) {
- const insertionIdx =
- originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
- ? originalLines.length - 1
- : originalLines.length
- replacements.push([insertionIdx, 0, chunk.new_lines])
- continue
- }
-
- // Try to match old lines in the file
- let pattern = chunk.old_lines
- let newSlice = chunk.new_lines
- let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
-
- // Retry without trailing empty line if not found
- if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
- pattern = pattern.slice(0, -1)
- if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
- newSlice = newSlice.slice(0, -1)
- }
- found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
- }
-
- if (found !== -1) {
- replacements.push([found, pattern.length, newSlice])
- lineIndex = found + pattern.length
- } else {
- throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`)
- }
- }
-
- // Sort replacements by index to apply in order
- replacements.sort((a, b) => a[0] - b[0])
-
- return replacements
-}
-
-function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] {
- // Apply replacements in reverse order to avoid index shifting
- const result = [...lines]
-
- for (let i = replacements.length - 1; i >= 0; i--) {
- const [startIdx, oldLen, newSegment] = replacements[i]
-
- // Remove old lines
- result.splice(startIdx, oldLen)
-
- // Insert new lines
- for (let j = 0; j < newSegment.length; j++) {
- result.splice(startIdx + j, 0, newSegment[j])
- }
- }
-
- return result
-}
-
-// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
-function normalizeUnicode(str: string): string {
- return str
- .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
- .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
- .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
- .replace(/\u2026/g, "...") // ellipsis
- .replace(/\u00A0/g, " ") // non-breaking space
-}
-
-type Comparator = (a: string, b: string) => boolean
-
-function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
- // If EOF anchor, try matching from end of file first
- if (eof) {
- const fromEnd = lines.length - pattern.length
- if (fromEnd >= startIndex) {
- let matches = true
- for (let j = 0; j < pattern.length; j++) {
- if (!compare(lines[fromEnd + j], pattern[j])) {
- matches = false
- break
- }
- }
- if (matches) return fromEnd
- }
- }
-
- // Forward search from startIndex
- for (let i = startIndex; i <= lines.length - pattern.length; i++) {
- let matches = true
- for (let j = 0; j < pattern.length; j++) {
- if (!compare(lines[i + j], pattern[j])) {
- matches = false
- break
- }
- }
- if (matches) return i
- }
-
- return -1
-}
-
-function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
- if (pattern.length === 0) return -1
-
- // Pass 1: exact match
- const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
- if (exact !== -1) return exact
-
- // Pass 2: rstrip (trim trailing whitespace)
- const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
- if (rstrip !== -1) return rstrip
-
- // Pass 3: trim (both ends)
- const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
- if (trim !== -1) return trim
-
- // Pass 4: normalized (Unicode punctuation to ASCII)
- const normalized = tryMatch(
- lines,
- pattern,
- startIndex,
- (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
- eof,
- )
- return normalized
-}
-
-function generateUnifiedDiff(oldContent: string, newContent: string): string {
- const oldLines = oldContent.split("\n")
- const newLines = newContent.split("\n")
-
- // Simple diff generation - in a real implementation you'd use a proper diff algorithm
- let diff = "@@ -1 +1 @@\n"
-
- // Find changes (simplified approach)
- const maxLen = Math.max(oldLines.length, newLines.length)
- let hasChanges = false
-
- for (let i = 0; i < maxLen; i++) {
- const oldLine = oldLines[i] || ""
- const newLine = newLines[i] || ""
-
- if (oldLine !== newLine) {
- if (oldLine) diff += `-${oldLine}\n`
- if (newLine) diff += `+${newLine}\n`
- hasChanges = true
- } else if (oldLine) {
- diff += ` ${oldLine}\n`
- }
- }
-
- return hasChanges ? diff : ""
-}
-
-// Apply hunks to filesystem
-export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
- if (hunks.length === 0) {
- throw new Error("No files were modified.")
- }
-
- const added: string[] = []
- const modified: string[] = []
- const deleted: string[] = []
-
- for (const hunk of hunks) {
- switch (hunk.type) {
- case "add":
- // Create parent directories
- const addDir = path.dirname(hunk.path)
- if (addDir !== "." && addDir !== "/") {
- await fs.mkdir(addDir, { recursive: true })
- }
-
- await fs.writeFile(hunk.path, hunk.contents, "utf-8")
- added.push(hunk.path)
- log.info(`Added file: ${hunk.path}`)
- break
-
- case "delete":
- await fs.unlink(hunk.path)
- deleted.push(hunk.path)
- log.info(`Deleted file: ${hunk.path}`)
- break
-
- case "update":
- const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
-
- if (hunk.move_path) {
- // Handle file move
- const moveDir = path.dirname(hunk.move_path)
- if (moveDir !== "." && moveDir !== "/") {
- await fs.mkdir(moveDir, { recursive: true })
- }
-
- await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
- await fs.unlink(hunk.path)
- modified.push(hunk.move_path)
- log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
- } else {
- // Regular update
- await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
- modified.push(hunk.path)
- log.info(`Updated file: ${hunk.path}`)
- }
- break
- }
- }
-
- return { added, modified, deleted }
-}
-
-// Main patch application function
-export async function applyPatch(patchText: string): Promise<AffectedPaths> {
- const { hunks } = parsePatch(patchText)
- return applyHunksToFiles(hunks)
-}
-
-// Async version of maybeParseApplyPatchVerified
-export async function maybeParseApplyPatchVerified(
- argv: string[],
- cwd: string,
-): Promise<
- | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
- | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
- | { type: MaybeApplyPatchVerified.NotApplyPatch }
-> {
- // Detect implicit patch invocation (raw patch without apply_patch command)
- if (argv.length === 1) {
- try {
- parsePatch(argv[0])
- return {
- type: MaybeApplyPatchVerified.CorrectnessError,
- error: new Error(ApplyPatchError.ImplicitInvocation),
- }
- } catch {
- // Not a patch, continue
- }
- }
-
- const result = maybeParseApplyPatch(argv)
-
- switch (result.type) {
- case MaybeApplyPatch.Body:
- const { args } = result
- const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
- const changes = new Map<string, ApplyPatchFileChange>()
-
- for (const hunk of args.hunks) {
- const resolvedPath = path.resolve(
- effectiveCwd,
- hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path,
- )
-
- switch (hunk.type) {
- case "add":
- changes.set(resolvedPath, {
- type: "add",
- content: hunk.contents,
- })
- break
-
- case "delete":
- // For delete, we need to read the current content
- const deletePath = path.resolve(effectiveCwd, hunk.path)
- try {
- const content = await fs.readFile(deletePath, "utf-8")
- changes.set(resolvedPath, {
- type: "delete",
- content,
- })
- } catch {
- return {
- type: MaybeApplyPatchVerified.CorrectnessError,
- error: new Error(`Failed to read file for deletion: ${deletePath}`),
- }
- }
- break
-
- case "update":
- const updatePath = path.resolve(effectiveCwd, hunk.path)
- try {
- const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
- changes.set(resolvedPath, {
- type: "update",
- unified_diff: fileUpdate.unified_diff,
- move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined,
- new_content: fileUpdate.content,
- })
- } catch (error) {
- return {
- type: MaybeApplyPatchVerified.CorrectnessError,
- error: error as Error,
- }
- }
- break
- }
- }
-
- return {
- type: MaybeApplyPatchVerified.Body,
- action: {
- changes,
- patch: args.patch,
- cwd: effectiveCwd,
- },
- }
-
- case MaybeApplyPatch.PatchParseError:
- return {
- type: MaybeApplyPatchVerified.CorrectnessError,
- error: result.error,
- }
-
- case MaybeApplyPatch.NotApplyPatch:
- return { type: MaybeApplyPatchVerified.NotApplyPatch }
- }
-}
diff --git a/packages/opencode/src/v2/session-common.ts b/packages/opencode/src/v2/session-common.ts
deleted file mode 100644
index 556bd79b6..000000000
--- a/packages/opencode/src/v2/session-common.ts
+++ /dev/null
@@ -1 +0,0 @@
-export namespace SessionCommon {}