mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(tools): make apply_patch tolerant and re-expose edit_file
Head-to-head benchmarking against Grok Build on grok-4.6 (3 tasks × 3 runs)
showed Zero solving every task but spending 2.7× the tokens and 2.3× the
time on the multi-file feature task. Every one of the 20 apply_patch calls
in those runs failed, after which the model degraded to whole-file
write_file rewrites and read→write verification loops.
Root causes and fixes:
- The sandbox and the tool both rejected an absolute path that lies inside
the workspace ("must stay inside the workspace"). Models routinely echo
the absolute path read_file showed them. Absolute paths now flow through
the normal workspace-scope validation (inside → allowed, outside → still
denied); unified-diff headers are rewritten to a/ b/ relative form before
git apply, and platform symlink prefixes (/var → /private/var) are
normalised the same way the other file tools do.
- The structured parser demanded a byte-exact "*** Begin Patch" /
"*** End Patch"; grok-4.6 writes "*** Begin Patch ***". Decorated marker
spellings are accepted, and a unified range after "@@" ("-12,4 +12,6",
optionally with a heading) no longer becomes a literal context search.
- Parse errors now carry a one-line format reminder so a failed call is
recoverable in one retry instead of a format-guessing loop.
- edit_file (exact search/replace with uniqueness guard, CRLF and fuzzy
fallbacks) is advertised to the model again alongside apply_patch, and
the apply_patch description shows the structured format.
- Writers (edit_file, write_file, apply_patch) recorded a file's line total
with lineCount, which is one higher than read_file's count for a file
ending in a newline. RecordSeenRange resets every observation when the
total changes, so a partial read_file after a successful edit silently
discarded whole-file knowledge and the next edit was refused with
"content that has not been read exactly in this session". All writers
now record the same total read_file reports (trackedLineTotal).
- read_file's padded " N | " line prefix was ~19% of every read (about
1.5k tokens per 800-line file) and was re-sent on every later call. It is
now the compact "N→" prefix models already know; nothing parsed the old
form.
- The editing-discipline prompt steers: edit_file for a targeted change,
apply_patch for multi-hunk/multi-file changes, write_file only for new
files or full rewrites; a successful edit result is confirmation, so the
file is not re-read to verify it.
Tests: regression tests for decorated markers, writer/reader line-total
agreement (edit → partial read → edit far away still succeeds), range hunk headers,
absolute in-workspace paths (structured + unified), outside-workspace
rejection, header-line-only relativisation, and tool visibility; the
prompt and tool-budget tests updated to the new contract.
Benchmark (grok-4.6, 3 tasks x 3 runs, harness in demo/bench-zero-vs-grok):
Zero 0.8.0 617 s / 1,527k tokens -> with this change 346 s / 493k tokens;
Grok Build 407 s / 713k. Every run of every configuration solved its task.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): keep CRLF line endings when relativizing patch headers
relativizeUnifiedPatchPaths split on a normalized "\n" and rejoined with
"\n", so any CRLF patch that needed a header rewrite lost its line endings
everywhere; git apply could then fail to match a CRLF file. Split on "\n"
only and carry each line's trailing "\r" through, including on rewritten
headers.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(sandbox): share the structured-patch classifier with apply_patch
Review follow-ups for #956:
- The tool accepted decorated structured markers ("***Begin Patch") that
the sandbox's applyPatchPaths did not recognise, so for that spelling the
boundary scanned the patch as a unified diff, extracted no targets and
validated nothing. The marker classifier now lives in the sandbox package
(StructuredPatchMarker / IsStructuredPatch) and the tool delegates to it,
so both sides accept exactly the same set; tests cover inside, traversal
and outside targets for the no-space spelling at the boundary.
- Document the containment model at the apply site: structured patches are
applied through an opened os.Root (descriptor-relative, no-follow);
unified diffs keep the pre-existing git-apply pathname flow, which the
absolute-path rewrite does not widen (they become the same root-relative
form relative paths always used).
- OpenAI addendum: the one-file-per-call rule names read_file, edit_file and
write_file explicitly and allows a single multi-file apply_patch.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(sandbox): compare slash-normalised patch paths so the check passes on Windows
structuredPatchHeaderPaths normalises separators to "/", so the absolute
outside-workspace path in the boundary test must be compared in its slash
form; on Windows the backslash original never matched and the Smoke job
failed.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(sandbox): validate the parsed patch path at the scope boundary
For each accepted marker spelling, pass the exact path applyPatchRequestPaths
produced to scope.validate: an outside path must be denied and an inside
path accepted, so a separator-handling regression between the parser and the
scope cannot slip past the test.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): apply unified diffs in-process through the workspace root
Review follow-up for #956. Unified diffs were validated by pathname and then
handed to git apply, which reopened the targets by name — a check-to-use
window a symlink swap could exploit — and the absolute-path rewrite added in
this PR normalised `diff --git` headers with strings.Fields, which splits a
git C-quoted path containing whitespace.
Both go away by not shelling out at all: parseUnifiedPatch translates a
unified diff into the same operations a structured patch produces, and both
formats are applied by applyPatchOperations through os.OpenRoot — every
stat, read, create and write is descriptor-relative and refuses to follow a
link out of the root. git is no longer required by apply_patch.
Unified-diff support: modifications, creations (--- /dev/null), deletions
(+++ /dev/null), multiple hunks per file, git "rename from/to" (a move,
hunks optional) and "copy from/to" (a new copy operation whose destination
inherits only the tracker credit the source had), "\ No newline at end of
file" on either side, a/ b/ prefixes, C-quoted paths, CRLF patches, and
git's diff/index/mode lines. Hunks are placed at their "@@ -a,b" range when
the expected lines match there and located by context otherwise (so a
stale range still applies and duplicate blocks are disambiguated by the
range). Binary patches are rejected.
Removed: the git apply flow, relativizeUnifiedPatchPaths,
recheckPatchWriteTargets, missingPatchTargets, completeCreatedPatchTargets
and recordCreatedPatchTargets. validatePatchPaths/changedFilesFromPatch stay
for mutation-target listing.
Tests: the existing unified-diff suite now runs through the in-process
applier unchanged; new coverage for quoted whitespace paths (inside applies,
outside is refused untouched), a workspace path swapped for a symlink that
escapes the root (both formats refused, outside file unmodified), create +
delete + no-newline markers, range hint vs context fallback, rename with a
hunk, and binary/empty rejection.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): harden unified-diff parsing and cover the copy operation
Review follow-ups for #956 (in-process unified applier):
- A hunk that ends before its declared line counts is reported as
malformed instead of silently absorbing the next file's ---/+++ header
pair (which then failed later against the wrong file). Both shapes are
caught: leftover counts at a header or at end of input, and a --- line
directly followed by +++ inside a hunk. The element after a patch's final
newline is no longer counted as an empty context line, so a truncated
hunk cannot look complete.
- "\ No newline at end of file" may only follow a file's final hunk; a
marker before another hunk is rejected rather than leaking into that
hunk's result.
- Zero-context insertions (git diff -U0) are documented as
position-trusting by design, matching git apply without fuzz; hunks with
context are verified at the range before it is used.
Tests: copy from/to (source kept, destination created with the hunk
applied and tracked with the source's whole-file state; existing
destination refused untouched; copy onto itself refused), over-declared and
truncated hunk counts, and the no-newline marker before vs after the final
hunk.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): require an @@ line before treating ---/+++ inside a hunk as a file boundary
A removed "-- x" line directly followed by an added "++ y" line is valid
hunk content that also matches the ---/+++ header shape; the boundary check
now additionally requires the following line to be a hunk header, and a
regression test keeps such a pair intact. End-of-input strictness for a hunk
cut short is documented as deliberate: applying a truncated hunk would make
a partial change (removals without their replacements).
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* refactor(tools): share one line-count implementation between reader and writer
sourceLineCount and trackedLineTotal computed the same count; keeping two
copies invites exactly the reader/writer drift this PR fixed. The reader now
delegates to trackedLineTotal, which carries the tracker contract.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): verify a unified deletion's expected content before removing the file
A unified diff's "+++ /dev/null" hunks state the content being removed, but
the in-process applier dropped them and deleted unconditionally, so a stale
deletion removed a file whose current content the patch never described.
Unified deletions now keep their hunks, require at least one, and run them
through the same verifier as updates: the hunks must match the current file
and leave nothing behind, otherwise the deletion is refused and the file is
untouched. A structured "*** Delete File" keeps its unconditional meaning.
Hardening from the same review: every non-create change re-reads its source
through the opened root immediately before commit and refuses to proceed if
the bytes differ from what was planned.
Tests: matching deletion, stale deletion (file byte-for-byte unchanged),
partial deletion, header-only deletion, multi-hunk deletion with one stale
hunk (nothing removed) and all-good hunks, structured delete unchanged, and
a source changed between planning and commit for update and delete.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(tools): a move with a missing source is refused before publishing
The pre-commit recheck now refuses a move whose source vanished after
planning instead of publishing the destination and then failing; update the
test to the safer contract (error names the recheck, nothing is created).
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): byte-exact unified deletions and git's header-only empty-file forms
Review follow-ups for #956:
- A unified deletion is verified byte for byte: the removed lines (with the
removed side's "\ No newline at end of file" honoured) must equal the
current file exactly, with only the file's own line-ending style
normalised. None of the whitespace tolerance used to locate update hunks
applies, and a whitespace-only remainder no longer passes; a deletion is
not recoverable, so it fails closed.
- git describes an empty file's deletion or creation with header lines only
("diff --git", "deleted file mode"/"new file mode", "index") and no
hunks. Both forms are recognised: the deletion is applied only when the
current file is empty, the creation writes an empty file, and they compose
with hunk-carrying files in the same patch.
- The pre-commit recheck test also covers the copy operation and asserts no
destination is created on refusal.
Tests: whitespace/indentation/blank-line/space-only/missing-newline
mismatches all refused with the file unchanged, matching no-newline marker
and CRLF file accepted, header-only deletion of a non-empty file refused,
multi-file patch with header-only delete + create + a normal hunk.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(tools): remove the move source after planning via a deterministic hook
The refused-move test now creates source.txt, plans the move, and removes
the file through a test-only pre-commit hook (structuredPatchBeforeCommit)
so it exercises the post-planning disappearance path rather than a source
that was never there. Assertions unchanged: refused before publishing, no
destination created.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* test(tui): read_file fixtures use the compact N→ prefix
Three TUI fixtures still built fake read_file output in the legacy
" N | text" shape; they pass either way but pinned an outdated contract.
They now mirror what appendReadFileRange emits, and the shape comment and
the expanded-card assertion are updated.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
* fix(tools): name the committed files when a patch is partially applied
Review follow-up for #956. When a later change in a multi-file patch fails
(for example the pre-commit recheck refuses a file that changed after
planning), the error now lists exactly which files were already committed
and states that the rest are unchanged, instead of a generic "partially
applied" warning. The model can then re-read only what actually changed.
Test: a three-file structured patch whose second file is altered after
planning fails with "already committed: first.txt", first.txt holds the
patched content, and the other two files are untouched.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
---------
Co-authored-by: Claude Fable 5 <[email protected]>
394 lines
14 KiB
Go
394 lines
14 KiB
Go
package tools
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// parseUnifiedPatch converts a unified diff into the same operations a
|
|
// structured patch produces, so both formats are applied by Zero itself
|
|
// through an opened workspace root (descriptor-relative, no-follow writes)
|
|
// instead of handing pathnames to git apply after validation.
|
|
//
|
|
// Supported: file modifications, creations (--- /dev/null) and deletions
|
|
// (+++ /dev/null, verified against the content the hunks expect to remove),
|
|
// multiple hunks per file, "\ No newline at end of file"
|
|
// markers, a/ and b/ prefixes, git C-quoted paths, CRLF patches, and git's
|
|
// "rename from/to" and "copy from/to" headers (with or without hunks). Headers
|
|
// git emits around a hunk (diff --git, index, mode lines) are skipped; binary
|
|
// patches are rejected.
|
|
func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) {
|
|
normalized := strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff")
|
|
lines := strings.Split(normalized, "\n")
|
|
// The element after a final "\n" is the line terminator, not an empty
|
|
// context line; counting it would let a truncated hunk look complete.
|
|
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
|
|
var operations []structuredPatchOperation
|
|
var current *structuredPatchOperation
|
|
var chunk *structuredPatchChunk
|
|
var added []string // collected "+" lines for a file creation
|
|
oldPath, newPath := "", ""
|
|
pendingFrom, pendingKind := "", structuredPatchUpdate
|
|
// git's header-only forms: "deleted file mode" / "new file mode" after a
|
|
// "diff --git" line with no ---/+++ pair describe an empty file.
|
|
diffPath, headerOnly := "", byte(0) // headerOnly is 'd' (deleted) or 'n' (new)
|
|
oldRemaining, newRemaining := 0, 0
|
|
inHunk := false
|
|
lastSide := byte(0) // '+' or '-' for the most recent content line
|
|
|
|
finish := func() error {
|
|
if current == nil {
|
|
return nil
|
|
}
|
|
switch current.kind {
|
|
case structuredPatchAdd:
|
|
if len(added) > 0 {
|
|
current.contents = strings.Join(added, "\n")
|
|
if current.eofNewline != eofNewlineAbsent {
|
|
current.contents += "\n"
|
|
}
|
|
}
|
|
case structuredPatchUpdate, structuredPatchCopy:
|
|
if !allStructuredPatchChunksHaveContent(current.chunks) {
|
|
return fmt.Errorf("unified diff for %s has an empty hunk", current.path)
|
|
}
|
|
if len(current.chunks) == 0 && current.movePath == "" {
|
|
return fmt.Errorf("unified diff for %s has no hunk lines", current.path)
|
|
}
|
|
case structuredPatchDelete:
|
|
if len(current.chunks) == 0 || !allStructuredPatchChunksHaveContent(current.chunks) {
|
|
return fmt.Errorf("unified deletion of %s must include the hunk with the content being removed", current.path)
|
|
}
|
|
}
|
|
operations = append(operations, *current)
|
|
current, chunk, added = nil, nil, nil
|
|
return nil
|
|
}
|
|
flushHeaderOnly := func(line int) error {
|
|
if headerOnly == 0 {
|
|
return nil
|
|
}
|
|
if diffPath == "" {
|
|
return fmt.Errorf("invalid unified diff at line %d: file mode header without a diff --git path", line)
|
|
}
|
|
if err := finish(); err != nil {
|
|
return err
|
|
}
|
|
switch headerOnly {
|
|
case 'd':
|
|
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: diffPath, line: line, verifyDelete: true})
|
|
case 'n':
|
|
operations = append(operations, structuredPatchOperation{kind: structuredPatchAdd, path: diffPath, line: line})
|
|
}
|
|
headerOnly, diffPath = 0, ""
|
|
return nil
|
|
}
|
|
startFile := func(line int) error {
|
|
// A ---/+++ pair means the file has hunks; it is not header-only.
|
|
headerOnly, diffPath = 0, ""
|
|
if oldPath == "" || newPath == "" {
|
|
return fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", line)
|
|
}
|
|
// A ---/+++ pair after a rename/copy header names the same files; keep
|
|
// accumulating that operation's hunks instead of starting a new one.
|
|
if current != nil && current.movePath != "" && current.path == oldPath && (current.movePath == newPath || oldPath == newPath) {
|
|
return nil
|
|
}
|
|
if err := finish(); err != nil {
|
|
return err
|
|
}
|
|
op := structuredPatchOperation{line: line}
|
|
switch {
|
|
case oldPath == "/dev/null" && newPath == "/dev/null":
|
|
return fmt.Errorf("invalid unified diff at line %d: both sides are /dev/null", line)
|
|
case oldPath == "/dev/null":
|
|
op.kind, op.path = structuredPatchAdd, newPath
|
|
case newPath == "/dev/null":
|
|
// A unified deletion states the content it expects to remove; keep
|
|
// its hunks so the planner verifies them before deleting.
|
|
op.kind, op.path, op.verifyDelete = structuredPatchDelete, oldPath, true
|
|
default:
|
|
op.kind, op.path = structuredPatchUpdate, oldPath
|
|
if newPath != oldPath {
|
|
op.movePath = newPath
|
|
}
|
|
}
|
|
current = &op
|
|
return nil
|
|
}
|
|
|
|
for index, raw := range lines {
|
|
lineNumber := index + 1
|
|
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
|
// A "--- " line followed by "+++ " and then a "@@" hunk header is
|
|
// the next file's header pair, not a removed "-- x" line followed by
|
|
// an added "++ y" line: an over-declared count would otherwise
|
|
// swallow both and the hunk would later fail against the wrong
|
|
// file. The "@@" requirement keeps a genuine adjacent pair intact.
|
|
if strings.HasPrefix(raw, "--- ") && index+2 < len(lines) && strings.HasPrefix(lines[index+1], "+++ ") && strings.HasPrefix(lines[index+2], "@@") {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(raw, "\\"):
|
|
// "\ No newline at end of file" qualifies the previous line's side.
|
|
if current != nil && lastSide == '+' {
|
|
current.eofNewline = eofNewlineAbsent
|
|
} else if current != nil && lastSide == '-' {
|
|
current.oldNoNewline = true
|
|
if current.eofNewline == eofNewlineKeep {
|
|
current.eofNewline = eofNewlinePresent
|
|
}
|
|
}
|
|
continue
|
|
case strings.HasPrefix(raw, "-"):
|
|
oldRemaining--
|
|
lastSide = '-'
|
|
if chunk != nil {
|
|
chunk.old = append(chunk.old, raw[1:])
|
|
}
|
|
case strings.HasPrefix(raw, "+"):
|
|
newRemaining--
|
|
lastSide = '+'
|
|
if current != nil && current.kind == structuredPatchAdd {
|
|
added = append(added, raw[1:])
|
|
} else if chunk != nil {
|
|
chunk.new = append(chunk.new, raw[1:])
|
|
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
|
|
}
|
|
default:
|
|
content := raw
|
|
if strings.HasPrefix(raw, " ") {
|
|
content = raw[1:]
|
|
} else if raw != "" {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: hunk lines must start with ' ', '+', '-' or '\\'", lineNumber)
|
|
}
|
|
oldRemaining--
|
|
newRemaining--
|
|
lastSide = ' '
|
|
if chunk != nil {
|
|
offset := len(chunk.old)
|
|
chunk.old = append(chunk.old, content)
|
|
chunk.new = append(chunk.new, content)
|
|
chunk.newSourceOffsets = append(chunk.newSourceOffsets, offset)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
inHunk = false
|
|
trimmed := strings.TrimSpace(raw)
|
|
if oldRemaining > 0 || newRemaining > 0 {
|
|
// The hunk ended before its declared counts were consumed. Report
|
|
// it here rather than absorbing the next file's ---/+++ headers as
|
|
// "-"/"+" content and failing later against the wrong file.
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
|
|
}
|
|
switch {
|
|
case trimmed == "":
|
|
continue
|
|
case strings.HasPrefix(raw, "\\"):
|
|
// "\ No newline at end of file" directly after a hunk's last line.
|
|
if current != nil && lastSide == '+' {
|
|
current.eofNewline = eofNewlineAbsent
|
|
} else if current != nil && lastSide == '-' {
|
|
current.oldNoNewline = true
|
|
if current.eofNewline == eofNewlineKeep {
|
|
current.eofNewline = eofNewlinePresent
|
|
}
|
|
}
|
|
continue
|
|
case strings.HasPrefix(raw, "diff --git "):
|
|
if err := flushHeaderOnly(lineNumber); err != nil {
|
|
return nil, err
|
|
}
|
|
diffPath = diffGitNewPath(raw)
|
|
continue
|
|
case strings.HasPrefix(raw, "deleted file mode "):
|
|
headerOnly = 'd'
|
|
continue
|
|
case strings.HasPrefix(raw, "new file mode "):
|
|
headerOnly = 'n'
|
|
continue
|
|
case strings.HasPrefix(raw, "index "), strings.HasPrefix(raw, "old mode "), strings.HasPrefix(raw, "new mode "):
|
|
continue
|
|
case strings.HasPrefix(raw, "similarity index "), strings.HasPrefix(raw, "dissimilarity index "):
|
|
continue
|
|
case strings.HasPrefix(raw, "rename from "), strings.HasPrefix(raw, "copy from "):
|
|
pendingFrom = strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename from "), "copy from ")))
|
|
pendingKind = structuredPatchUpdate
|
|
if strings.HasPrefix(raw, "copy from ") {
|
|
pendingKind = structuredPatchCopy
|
|
}
|
|
if pendingFrom == "" {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: missing source path", lineNumber)
|
|
}
|
|
case strings.HasPrefix(raw, "rename to "), strings.HasPrefix(raw, "copy to "):
|
|
to := strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename to "), "copy to ")))
|
|
if pendingFrom == "" || to == "" {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: rename/copy destination without a source", lineNumber)
|
|
}
|
|
if err := finish(); err != nil {
|
|
return nil, err
|
|
}
|
|
current = &structuredPatchOperation{kind: pendingKind, path: pendingFrom, movePath: to, line: lineNumber}
|
|
oldPath, newPath, pendingFrom = pendingFrom, to, ""
|
|
case strings.HasPrefix(raw, "Binary files "), strings.HasPrefix(raw, "GIT binary patch"):
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: binary patches are not supported", lineNumber)
|
|
case strings.HasPrefix(raw, "--- "):
|
|
oldPath = stripPatchPrefix(patchFileHeaderPath(raw))
|
|
newPath = ""
|
|
if oldPath == "" {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in --- header", lineNumber)
|
|
}
|
|
case strings.HasPrefix(raw, "+++ "):
|
|
newPath = stripPatchPrefix(patchFileHeaderPath(raw))
|
|
if newPath == "" {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in +++ header", lineNumber)
|
|
}
|
|
if err := startFile(lineNumber); err != nil {
|
|
return nil, err
|
|
}
|
|
case strings.HasPrefix(raw, "@@"):
|
|
if current == nil {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", lineNumber)
|
|
}
|
|
oldStart, oldCount, newCount, ok := parseHunkRange(raw)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: malformed hunk header", lineNumber)
|
|
}
|
|
// "\ No newline at end of file" describes the end of the file, so it
|
|
// may only follow a file's final hunk; a marker before another hunk
|
|
// would otherwise leak into that later hunk's result.
|
|
if current.eofNewline != eofNewlineKeep {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: \"\\ No newline at end of file\" must follow the last hunk of a file", lineNumber)
|
|
}
|
|
oldRemaining, newRemaining = oldCount, newCount
|
|
inHunk = oldRemaining > 0 || newRemaining > 0
|
|
lastSide = 0
|
|
if current.kind != structuredPatchAdd {
|
|
next := structuredPatchChunk{hasHint: true, hint: oldStart - 1}
|
|
if oldCount == 0 {
|
|
// A pure insertion's range names the line after which the
|
|
// new lines go, so the insertion index is oldStart itself.
|
|
next.hint = oldStart
|
|
}
|
|
if next.hint < 0 {
|
|
next.hint = 0
|
|
}
|
|
current.chunks = append(current.chunks, next)
|
|
chunk = ¤t.chunks[len(current.chunks)-1]
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: unexpected %q", lineNumber, trimmed)
|
|
}
|
|
}
|
|
// Strict at end of input on purpose: a hunk cut short (typically a model
|
|
// response that stopped mid-patch) would otherwise apply as a partial
|
|
// change — removals without their replacements — which is worse than a
|
|
// clear error and a retry. Miscounted ranges with complete content are
|
|
// still tolerated at every other boundary.
|
|
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
|
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", len(lines))
|
|
}
|
|
if err := flushHeaderOnly(len(lines)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := finish(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(operations) == 0 {
|
|
return nil, fmt.Errorf("unified diff contains no file changes")
|
|
}
|
|
return operations, nil
|
|
}
|
|
|
|
// diffGitNewPath returns the post-image path named by a "diff --git a/X b/Y"
|
|
// line, handling git's C-quoted form; "" when it cannot be determined.
|
|
func diffGitNewPath(line string) string {
|
|
rest := strings.TrimSpace(strings.TrimPrefix(line, "diff --git "))
|
|
if strings.HasPrefix(rest, "\"") {
|
|
// Two quoted tokens: skip the first, unquote the second.
|
|
end := strings.Index(rest[1:], "\"")
|
|
for end > 0 && rest[end] == '\\' {
|
|
next := strings.Index(rest[end+2:], "\"")
|
|
if next < 0 {
|
|
return ""
|
|
}
|
|
end += next + 2
|
|
}
|
|
if end < 0 || end+2 > len(rest) {
|
|
return ""
|
|
}
|
|
rest = strings.TrimSpace(rest[end+2:])
|
|
return stripPatchPrefix(unquoteGitPath(rest))
|
|
}
|
|
fields := strings.Fields(rest)
|
|
if len(fields) < 2 {
|
|
return ""
|
|
}
|
|
last := fields[len(fields)-1]
|
|
if strings.HasPrefix(last, "\"") {
|
|
return stripPatchPrefix(unquoteGitPath(last))
|
|
}
|
|
return stripPatchPrefix(last)
|
|
}
|
|
|
|
// parseHunkRange reads "@@ -a[,b] +c[,d] @@" and returns a, b and d; a missing
|
|
// count means 1 per unified-diff convention.
|
|
func parseHunkRange(line string) (oldStart, oldCount, newCount int, ok bool) {
|
|
_, rest, found := strings.Cut(line, "@@")
|
|
if !found {
|
|
return 0, 0, 0, false
|
|
}
|
|
rangeSection := rest
|
|
if before, _, found := strings.Cut(rest, "@@"); found {
|
|
rangeSection = before
|
|
}
|
|
fields := strings.Fields(rangeSection)
|
|
if len(fields) != 2 || !strings.HasPrefix(fields[0], "-") || !strings.HasPrefix(fields[1], "+") {
|
|
return 0, 0, 0, false
|
|
}
|
|
parse := func(spec string) (int, int, bool) {
|
|
startText, countText, hasCount := strings.Cut(spec, ",")
|
|
start, err := parseNonNegativeInt(startText)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
count := 1
|
|
if hasCount {
|
|
if count, err = parseNonNegativeInt(countText); err != nil {
|
|
return 0, 0, false
|
|
}
|
|
}
|
|
return start, count, true
|
|
}
|
|
oldStart, oldCount, ok = parse(fields[0][1:])
|
|
if !ok {
|
|
return 0, 0, 0, false
|
|
}
|
|
_, newCount, ok = parse(fields[1][1:])
|
|
if !ok {
|
|
return 0, 0, 0, false
|
|
}
|
|
return oldStart, oldCount, newCount, true
|
|
}
|
|
|
|
func parseNonNegativeInt(text string) (int, error) {
|
|
if text == "" {
|
|
return 0, fmt.Errorf("empty number")
|
|
}
|
|
value := 0
|
|
for _, r := range text {
|
|
if r < '0' || r > '9' {
|
|
return 0, fmt.Errorf("not a number: %q", text)
|
|
}
|
|
value = value*10 + int(r-'0')
|
|
if value > 1<<30 {
|
|
return 0, fmt.Errorf("number too large: %q", text)
|
|
}
|
|
}
|
|
return value, nil
|
|
}
|