Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: CI

on:
push:
pull_request:

jobs:
Expand Down
246 changes: 129 additions & 117 deletions .opencode/tools/hashline-core.ts → .opencode/lib/hashline-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ export interface HashlineOperation {
content?: string
}

export interface HashlineOperationResultMetadata {
filediff: {
file: string
before: string
after: string
additions: number
deletions: number
}
files: Array<{
filePath: string
before: string
after: string
additions: number
deletions: number
}>
}

export interface HashlineOperationResult {
summary: string
metadata: HashlineOperationResultMetadata
}

interface FileSnapshot {
absolutePath: string
raw: string
Expand Down Expand Up @@ -801,6 +823,50 @@ function formatEditResult(params: {
return body.join("\n")
}

interface HashlineExecutionParams {
filePath: string
operations: HashlineOperation[]
expectedFileHash?: string
fileRev?: string
safeReapply?: boolean
dryRun?: boolean
context?: { directory?: string }
}

function buildOperationResult(params: {
filePath: string
mode: "hashline" | "legacy"
dryRun: boolean
before: FileSnapshot
after: FileSnapshot
operations: number
additions: number
removals: number
diffPreview?: string
}): HashlineOperationResult {
return {
summary: formatEditResult(params),
metadata: {
filediff: {
file: params.filePath,
before: params.before.raw,
after: params.after.raw,
additions: params.additions,
deletions: params.removals,
},
files: [
{
filePath: params.filePath,
before: params.before.raw,
after: params.after.raw,
additions: params.additions,
deletions: params.removals,
},
],
},
}
}

function countOccurrences(haystack: string, needle: string): number {
if (needle.length === 0) {
return 0
Expand Down Expand Up @@ -868,15 +934,7 @@ export async function runHashlineRead(params: {
].join("\n")
}

export async function runHashlineOperations(params: {
filePath: string
operations: HashlineOperation[]
expectedFileHash?: string
fileRev?: string
safeReapply?: boolean
dryRun?: boolean
context?: HashlineToolContext
}): Promise<string> {
export async function runHashlineOperationsDetailed(params: HashlineExecutionParams): Promise<HashlineOperationResult> {
const absolutePath = resolveFilePath(params.filePath, params.context)
const existingSnapshot = await readSnapshotIfExists(absolutePath)

Expand All @@ -903,21 +961,7 @@ export async function runHashlineOperations(params: {
await writeSnapshot(after)
}

emitHashlineOperationMetadata({
filePath: params.filePath,
dryRun: Boolean(params.dryRun),
before: snapshot,
after,
operations: normalizedOps.length,
additions: applied.additions,
removals: applied.removals,
existed: Boolean(existingSnapshot),
diff,
diffPreview,
context: params.context,
})

return formatEditResult({
return buildOperationResult({
filePath: params.filePath,
mode: "hashline",
dryRun: Boolean(params.dryRun),
Expand All @@ -930,99 +974,9 @@ export async function runHashlineOperations(params: {
})
}

export async function runHashlineCheck(params: {
filePath: string
targets?: Array<{
op?: HashlineOpName
ref?: string
startRef?: string
endRef?: string
}>
expectedFileHash?: string
fileRev?: string
safeReapply?: boolean
verbose?: boolean
context?: { directory?: string }
}): Promise<string> {
const absolutePath = resolveFilePath(params.filePath, params.context)
const existingSnapshot = await readSnapshotIfExists(absolutePath)
const snapshot = existingSnapshot ?? emptySnapshot(absolutePath)

if (params.expectedFileHash && snapshot.fileHash !== params.expectedFileHash.toUpperCase()) {
throw new Error(
`File hash mismatch for ${params.filePath}. Expected ${params.expectedFileHash.toUpperCase()}, actual ${snapshot.fileHash}. Read the file again before editing.`,
)
}

assertFileRevisionMatches(snapshot, params.filePath, params.fileRev)

const safeReapply = Boolean(params.safeReapply)
const targets = Array.isArray(params.targets) ? params.targets : []
const resolvedTargets: string[] = []

for (let idx = 0; idx < targets.length; idx += 1) {
const target = targets[idx] ?? {}
const op: HashlineOpName = target.op ?? (target.startRef && target.endRef ? "replace_range" : target.ref || target.startRef ? "replace" : "set_file")
const label = `target[${idx + 1}] ${op}`

switch (op) {
case "set_file": {
resolvedTargets.push(`${label}: set_file (no refs)`)
break
}

case "replace_range": {
if (!target.startRef || !target.endRef) {
throw new Error(`${label} requires startRef and endRef`)
}

const start = resolveRef(target.startRef, snapshot, safeReapply)
const end = resolveRef(target.endRef, snapshot, safeReapply)
if (start.index > end.index) {
throw new Error(`${label} startRef must be on or before endRef`)
}

resolvedTargets.push(`${label}: ${start.lineNumber}-${end.lineNumber}`)
break
}

case "replace":
case "delete":
case "insert_before":
case "insert_after": {
const resolvedRange = resolveRefRange({
snapshot,
ref: target.ref,
startRef: target.startRef,
endRef: target.endRef,
safeReapply,
label,
})

const span =
resolvedRange.start.lineNumber === resolvedRange.end.lineNumber
? `${resolvedRange.start.lineNumber}`
: `${resolvedRange.start.lineNumber}-${resolvedRange.end.lineNumber}`
resolvedTargets.push(`${label}: ${span}`)
break
}

default: {
throw new Error(`Unsupported check operation: ${(op as string) ?? "unknown"}`)
}
}
}

const summary = [
`Hashline check passed for ${params.filePath}.`,
`file_hash=${snapshot.fileHash} file_rev=${computeFileRev(snapshot.raw)} targets=${targets.length}`,
]

if (params.verbose && resolvedTargets.length > 0) {
summary.push(...resolvedTargets.map((item) => `- ${item}`))
}

return summary.join("\n")
export async function runHashlineOperations(params: HashlineExecutionParams): Promise<string> {
const result = await runHashlineOperationsDetailed(params)
return result.summary
}

export async function runLegacyEdit(params: {
Expand Down Expand Up @@ -1164,3 +1118,61 @@ export function mapOperationInput(input: HashlineOperationInput): HashlineOperat
content: input.content ?? input.replacement,
}
}

export interface HashlineResolveEditResult {
filePath: string
oldString: string
newString: string
fileRev?: string
summary: {
operations: number
additions: number
removals: number
linesBefore: number
linesAfter: number
}
}

export async function resolveHashlineEdit(params: HashlineExecutionParams): Promise<HashlineResolveEditResult> {
const absolutePath = resolveFilePath(params.filePath, params.context)
const existingSnapshot = await readSnapshotIfExists(absolutePath)

const snapshot = existingSnapshot ?? emptySnapshot(absolutePath)
const normalizedOps = normalizeOperations(params.operations)

if (params.expectedFileHash && snapshot.fileHash !== params.expectedFileHash.toUpperCase()) {
throw new Error(
`File hash mismatch for ${params.filePath}. Expected ${params.expectedFileHash.toUpperCase()}, actual ${snapshot.fileHash}. Read the file again before editing.`,
)
}

if (params.fileRev) {
const expectedRev = params.fileRev.toUpperCase()
const actualRev = computeFileRev(snapshot.raw)
if (actualRev !== expectedRev) {
throw new Error(
`File revision mismatch for ${params.filePath}. Expected ${expectedRev}, actual ${actualRev}. Read the file again before editing.`,
)
}
}

const changes = resolveChanges(snapshot, normalizedOps, Boolean(params.safeReapply))
validateChangeConflicts(changes)

const applied = applyChanges(snapshot, changes)
const after = snapshotFromLines(snapshot, applied.lines)

return {
filePath: params.filePath,
oldString: snapshot.raw,
newString: after.raw,
fileRev: computeFileRev(snapshot.raw),
summary: {
operations: normalizedOps.length,
additions: applied.additions,
removals: applied.removals,
linesBefore: snapshot.lines.length,
linesAfter: after.lines.length,
},
}
}
Loading
Loading