From 6df4123d72599ae89531f0cf31b2557a1fd61be0 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sun, 26 Jul 2026 23:07:31 +0200 Subject: [PATCH] Fix applying patches with zero-context fragments In unified diff format a range with a length of zero is positional: the hunk "@@ -3,0 +4 @@" inserts after old line 3 rather than starting at it. The text applier derived the start of every fragment from OldPosition the same way, so each zero-context insertion was applied one line too early. Apply returned nil in that case, so the result was silently corrupted rather than reported as an error. Two nearby checks read file-level facts off the same ambiguous positions. An OldPosition of 0 was taken to mean "create a file", but "@@ -0,0 +1 @@" is also how a patch inserts before the first line of an existing file. A new position of "+0,0" was taken to mean "delete the file", but "@@ -1 +0,0 @@" is also how a patch deletes only the first line. Both rejected patches that git apply --unidiff-zero and GNU patch accept. Start a fragment at OldPosition when it has no old lines, and move the creation and deletion checks to Apply, where File.IsNew and File.IsDelete are available. This matches ParseTextFragments, which already uses those fields rather than fragment positions to decide the same questions. text_fragment_error_new_file.patch is not a new file patch: it has no /dev/null side and no new file mode line, so git and GNU patch both apply it as an insertion before line 1. Replace it with a file-level test that uses a real new file patch, which still reports a conflict. --- gitdiff/apply.go | 21 ++++++++++ gitdiff/apply_test.go | 28 +++++++++---- gitdiff/apply_text.go | 42 +++++++++---------- .../testdata/apply/file_text_delete_start.out | 5 +++ .../apply/file_text_delete_start.patch | 5 +++ .../testdata/apply/file_text_delete_start.src | 6 +++ .../apply/file_text_error_new_non_empty.patch | 8 ++++ .../apply/file_text_insert_zero_context.out | 8 ++++ .../apply/file_text_insert_zero_context.patch | 7 ++++ .../apply/file_text_insert_zero_context.src | 6 +++ .../apply/text_fragment_delete_start.out | 0 .../apply/text_fragment_delete_start.patch | 5 +++ .../apply/text_fragment_delete_start.src | 3 ++ .../apply/text_fragment_error_new_file.patch | 7 ---- .../apply/text_fragment_insert_end.out | 5 +++ .../apply/text_fragment_insert_end.patch | 6 +++ .../apply/text_fragment_insert_end.src | 3 ++ .../apply/text_fragment_insert_middle.out | 3 ++ .../apply/text_fragment_insert_middle.patch | 5 +++ .../apply/text_fragment_insert_middle.src | 3 ++ .../apply/text_fragment_insert_noeol.out | 2 + .../apply/text_fragment_insert_noeol.patch | 5 +++ .../apply/text_fragment_insert_noeol.src | 3 ++ .../apply/text_fragment_insert_start.out | 2 + .../apply/text_fragment_insert_start.patch | 6 +++ .../apply/text_fragment_insert_start.src | 3 ++ 26 files changed, 161 insertions(+), 36 deletions(-) create mode 100644 gitdiff/testdata/apply/file_text_delete_start.out create mode 100644 gitdiff/testdata/apply/file_text_delete_start.patch create mode 100644 gitdiff/testdata/apply/file_text_delete_start.src create mode 100644 gitdiff/testdata/apply/file_text_error_new_non_empty.patch create mode 100644 gitdiff/testdata/apply/file_text_insert_zero_context.out create mode 100644 gitdiff/testdata/apply/file_text_insert_zero_context.patch create mode 100644 gitdiff/testdata/apply/file_text_insert_zero_context.src create mode 100644 gitdiff/testdata/apply/text_fragment_delete_start.out create mode 100644 gitdiff/testdata/apply/text_fragment_delete_start.patch create mode 100644 gitdiff/testdata/apply/text_fragment_delete_start.src delete mode 100644 gitdiff/testdata/apply/text_fragment_error_new_file.patch create mode 100644 gitdiff/testdata/apply/text_fragment_insert_end.out create mode 100644 gitdiff/testdata/apply/text_fragment_insert_end.patch create mode 100644 gitdiff/testdata/apply/text_fragment_insert_end.src create mode 100644 gitdiff/testdata/apply/text_fragment_insert_middle.out create mode 100644 gitdiff/testdata/apply/text_fragment_insert_middle.patch create mode 100644 gitdiff/testdata/apply/text_fragment_insert_middle.src create mode 100644 gitdiff/testdata/apply/text_fragment_insert_noeol.out create mode 100644 gitdiff/testdata/apply/text_fragment_insert_noeol.patch create mode 100644 gitdiff/testdata/apply/text_fragment_insert_noeol.src create mode 100644 gitdiff/testdata/apply/text_fragment_insert_start.out create mode 100644 gitdiff/testdata/apply/text_fragment_insert_start.patch create mode 100644 gitdiff/testdata/apply/text_fragment_insert_start.src diff --git a/gitdiff/apply.go b/gitdiff/apply.go index e1c7209..94970df 100644 --- a/gitdiff/apply.go +++ b/gitdiff/apply.go @@ -120,6 +120,19 @@ func Apply(dst io.Writer, src io.ReaderAt, f *File, opts ...ApplyOption) error { return applier.Close() case len(f.TextFragments) > 0: + // creating a file requires an empty source; a fragment starting at + // position 0 is not enough to detect this, as zero-context patches use + // the same position to insert before the first line of an existing file + if f.IsNew { + ok, err := isLen(src, 0) + if err != nil { + return applyError(err) + } + if !ok { + return applyError(&Conflict{"cannot create new file from non-empty src"}) + } + } + frags := make([]*TextFragment, len(f.TextFragments)) copy(frags, f.TextFragments) @@ -137,6 +150,14 @@ func Apply(dst io.Writer, src io.ReaderAt, f *File, opts ...ApplyOption) error { return applyError(err, fragNum(i)) } } + // deleting a file must consume the whole source; a fragment ending at + // new position 0 is not enough to detect this, as zero-context patches + // use the same position to delete the first line of a file + if f.IsDelete { + if err := applier.checkFullDelete(); err != nil { + return err + } + } return applier.Close() default: diff --git a/gitdiff/apply_test.go b/gitdiff/apply_test.go index c43e9c6..196bb50 100644 --- a/gitdiff/apply_test.go +++ b/gitdiff/apply_test.go @@ -19,6 +19,14 @@ func TestApplyTextFragment(t *testing.T) { "addEnd": {Files: getApplyFiles("text_fragment_add_end")}, "addEndNoEOL": {Files: getApplyFiles("text_fragment_add_end_noeol")}, + // zero-context fragments: the old range is empty and OldPosition is the + // line the content follows, not the line it starts at + "insertStart": {Files: getApplyFiles("text_fragment_insert_start")}, + "insertMiddle": {Files: getApplyFiles("text_fragment_insert_middle")}, + "insertEnd": {Files: getApplyFiles("text_fragment_insert_end")}, + "insertNoEOL": {Files: getApplyFiles("text_fragment_insert_noeol")}, + "deleteStart": {Files: getApplyFiles("text_fragment_delete_start")}, + "changeStart": {Files: getApplyFiles("text_fragment_change_start")}, "changeMiddle": {Files: getApplyFiles("text_fragment_change_middle")}, "changeEnd": {Files: getApplyFiles("text_fragment_change_end")}, @@ -68,13 +76,6 @@ func TestApplyTextFragment(t *testing.T) { }, Err: &Conflict{}, }, - "errorNewFile": { - Files: applyFiles{ - Src: "text_fragment_error.src", - Patch: "text_fragment_error_new_file.patch", - }, - Err: &Conflict{}, - }, } for name, test := range tests { @@ -200,6 +201,19 @@ func TestApplyFile(t *testing.T) { }, Err: &Conflict{}, }, + "textInsertZeroContext": { + Files: getApplyFiles("file_text_insert_zero_context"), + }, + "textDeleteStartZeroContext": { + Files: getApplyFiles("file_text_delete_start"), + }, + "textErrorNewNonEmpty": { + Files: applyFiles{ + Src: "file_text.src", + Patch: "file_text_error_new_non_empty.patch", + }, + Err: &Conflict{}, + }, "binaryModify": { Files: getApplyFiles("file_bin_modify"), }, diff --git a/gitdiff/apply_text.go b/gitdiff/apply_text.go index 0948178..e8c0ad2 100644 --- a/gitdiff/apply_text.go +++ b/gitdiff/apply_text.go @@ -64,8 +64,13 @@ func (a *TextApplier) ApplyFragment(f *TextFragment) error { return applyError(err) } - // lines are 0-indexed, positions are 1-indexed (but new files have position = 0) + // lines are 0-indexed, positions are 1-indexed; a fragment with no old + // lines inserts after OldPosition instead of starting at it, so position 0 + // inserts before the first line fragStart := f.OldPosition - 1 + if f.OldLines == 0 { + fragStart = f.OldPosition + } if fragStart < 0 { fragStart = 0 } @@ -79,16 +84,6 @@ func (a *TextApplier) ApplyFragment(f *TextFragment) error { return applyError(&Conflict{"fragment overlaps with an applied fragment"}) } - if f.OldPosition == 0 { - ok, err := isLen(a.src, 0) - if err != nil { - return applyError(err) - } - if !ok { - return applyError(&Conflict{"cannot create new file from non-empty src"}) - } - } - preimage, err := readPreimage(a.lineSrc, start, fragEnd-start) if err != nil { return applyError(err) @@ -116,18 +111,21 @@ func (a *TextApplier) ApplyFragment(f *TextFragment) error { } a.nextLine = fragStart + used - // new position of +0,0 mean a full delete, so check for leftovers - if f.NewPosition == 0 && f.NewLines == 0 { - var b [1][]byte - n, err := a.lineSrc.ReadLinesAt(b[:], a.nextLine) - if err != nil && err != io.EOF { - return applyError(err, lineNum(a.nextLine)) - } - if n > 0 { - return applyError(&Conflict{"src still has content after full delete"}, lineNum(a.nextLine)) - } - } + return nil +} +// checkFullDelete returns a *Conflict if the source contains content that was +// not consumed by the applied fragments. Deleting a file is a property of the +// file header, so only [Apply] can decide when this check applies. +func (a *TextApplier) checkFullDelete() error { + var b [1][]byte + n, err := a.lineSrc.ReadLinesAt(b[:], a.nextLine) + if err != nil && err != io.EOF { + return applyError(err, lineNum(a.nextLine)) + } + if n > 0 { + return applyError(&Conflict{"src still has content after full delete"}, lineNum(a.nextLine)) + } return nil } diff --git a/gitdiff/testdata/apply/file_text_delete_start.out b/gitdiff/testdata/apply/file_text_delete_start.out new file mode 100644 index 0000000..36642bb --- /dev/null +++ b/gitdiff/testdata/apply/file_text_delete_start.out @@ -0,0 +1,5 @@ +line 2 +line 3 +line 4 +line 5 +line 6 diff --git a/gitdiff/testdata/apply/file_text_delete_start.patch b/gitdiff/testdata/apply/file_text_delete_start.patch new file mode 100644 index 0000000..fe77006 --- /dev/null +++ b/gitdiff/testdata/apply/file_text_delete_start.patch @@ -0,0 +1,5 @@ +diff --git a/gitdiff/testdata/apply/file_text_delete_start.src b/gitdiff/testdata/apply/file_text_delete_start.src +--- a/gitdiff/testdata/apply/file_text_delete_start.src ++++ b/gitdiff/testdata/apply/file_text_delete_start.src +@@ -1 +0,0 @@ +-line 1 diff --git a/gitdiff/testdata/apply/file_text_delete_start.src b/gitdiff/testdata/apply/file_text_delete_start.src new file mode 100644 index 0000000..f985857 --- /dev/null +++ b/gitdiff/testdata/apply/file_text_delete_start.src @@ -0,0 +1,6 @@ +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 diff --git a/gitdiff/testdata/apply/file_text_error_new_non_empty.patch b/gitdiff/testdata/apply/file_text_error_new_non_empty.patch new file mode 100644 index 0000000..cfdac1f --- /dev/null +++ b/gitdiff/testdata/apply/file_text_error_new_non_empty.patch @@ -0,0 +1,8 @@ +diff --git a/gitdiff/testdata/apply/file_text.src b/gitdiff/testdata/apply/file_text.src +new file mode 100644 +--- /dev/null ++++ b/gitdiff/testdata/apply/file_text.src +@@ -0,0 +1,3 @@ ++this is line 1 ++this is line 2 ++this is line 3 diff --git a/gitdiff/testdata/apply/file_text_insert_zero_context.out b/gitdiff/testdata/apply/file_text_insert_zero_context.out new file mode 100644 index 0000000..64c2f87 --- /dev/null +++ b/gitdiff/testdata/apply/file_text_insert_zero_context.out @@ -0,0 +1,8 @@ +new line a +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 +new line b diff --git a/gitdiff/testdata/apply/file_text_insert_zero_context.patch b/gitdiff/testdata/apply/file_text_insert_zero_context.patch new file mode 100644 index 0000000..679e205 --- /dev/null +++ b/gitdiff/testdata/apply/file_text_insert_zero_context.patch @@ -0,0 +1,7 @@ +diff --git a/gitdiff/testdata/apply/file_text_insert_zero_context.src b/gitdiff/testdata/apply/file_text_insert_zero_context.src +--- a/gitdiff/testdata/apply/file_text_insert_zero_context.src ++++ b/gitdiff/testdata/apply/file_text_insert_zero_context.src +@@ -0,0 +1 @@ ++new line a +@@ -6,0 +8 @@ line 6 ++new line b diff --git a/gitdiff/testdata/apply/file_text_insert_zero_context.src b/gitdiff/testdata/apply/file_text_insert_zero_context.src new file mode 100644 index 0000000..f985857 --- /dev/null +++ b/gitdiff/testdata/apply/file_text_insert_zero_context.src @@ -0,0 +1,6 @@ +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 diff --git a/gitdiff/testdata/apply/text_fragment_delete_start.out b/gitdiff/testdata/apply/text_fragment_delete_start.out new file mode 100644 index 0000000..e69de29 diff --git a/gitdiff/testdata/apply/text_fragment_delete_start.patch b/gitdiff/testdata/apply/text_fragment_delete_start.patch new file mode 100644 index 0000000..ea27f12 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_delete_start.patch @@ -0,0 +1,5 @@ +diff --git a/gitdiff/testdata/apply/fragment_delete_start.src b/gitdiff/testdata/apply/fragment_delete_start.src +--- a/gitdiff/testdata/apply/fragment_delete_start.src ++++ b/gitdiff/testdata/apply/fragment_delete_start.src +@@ -1 +0,0 @@ +-line 1 diff --git a/gitdiff/testdata/apply/text_fragment_delete_start.src b/gitdiff/testdata/apply/text_fragment_delete_start.src new file mode 100644 index 0000000..a92d664 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_delete_start.src @@ -0,0 +1,3 @@ +line 1 +line 2 +line 3 diff --git a/gitdiff/testdata/apply/text_fragment_error_new_file.patch b/gitdiff/testdata/apply/text_fragment_error_new_file.patch deleted file mode 100644 index f4fbee6..0000000 --- a/gitdiff/testdata/apply/text_fragment_error_new_file.patch +++ /dev/null @@ -1,7 +0,0 @@ -diff --git a/gitdiff/testdata/apply/text_fragment_error.src b/gitdiff/testdata/apply/text_fragment_error.src ---- a/gitdiff/testdata/apply/text_fragment_error.src -+++ b/gitdiff/testdata/apply/text_fragment_error.src -@@ -0,0 +1,3 @@ -+line 1 -+line 2 -+line 3 diff --git a/gitdiff/testdata/apply/text_fragment_insert_end.out b/gitdiff/testdata/apply/text_fragment_insert_end.out new file mode 100644 index 0000000..648fd44 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_end.out @@ -0,0 +1,5 @@ +line 1 +line 2 +line 3 +new line a +new line b diff --git a/gitdiff/testdata/apply/text_fragment_insert_end.patch b/gitdiff/testdata/apply/text_fragment_insert_end.patch new file mode 100644 index 0000000..5fd8e5c --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_end.patch @@ -0,0 +1,6 @@ +diff --git a/gitdiff/testdata/apply/fragment_insert_end.src b/gitdiff/testdata/apply/fragment_insert_end.src +--- a/gitdiff/testdata/apply/fragment_insert_end.src ++++ b/gitdiff/testdata/apply/fragment_insert_end.src +@@ -3,0 +4,2 @@ line 3 ++new line a ++new line b diff --git a/gitdiff/testdata/apply/text_fragment_insert_end.src b/gitdiff/testdata/apply/text_fragment_insert_end.src new file mode 100644 index 0000000..a92d664 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_end.src @@ -0,0 +1,3 @@ +line 1 +line 2 +line 3 diff --git a/gitdiff/testdata/apply/text_fragment_insert_middle.out b/gitdiff/testdata/apply/text_fragment_insert_middle.out new file mode 100644 index 0000000..3c21168 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_middle.out @@ -0,0 +1,3 @@ +line 1 +line 2 +new line a diff --git a/gitdiff/testdata/apply/text_fragment_insert_middle.patch b/gitdiff/testdata/apply/text_fragment_insert_middle.patch new file mode 100644 index 0000000..9aaa829 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_middle.patch @@ -0,0 +1,5 @@ +diff --git a/gitdiff/testdata/apply/fragment_insert_middle.src b/gitdiff/testdata/apply/fragment_insert_middle.src +--- a/gitdiff/testdata/apply/fragment_insert_middle.src ++++ b/gitdiff/testdata/apply/fragment_insert_middle.src +@@ -2,0 +3 @@ line 2 ++new line a diff --git a/gitdiff/testdata/apply/text_fragment_insert_middle.src b/gitdiff/testdata/apply/text_fragment_insert_middle.src new file mode 100644 index 0000000..a92d664 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_middle.src @@ -0,0 +1,3 @@ +line 1 +line 2 +line 3 diff --git a/gitdiff/testdata/apply/text_fragment_insert_noeol.out b/gitdiff/testdata/apply/text_fragment_insert_noeol.out new file mode 100644 index 0000000..384d599 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_noeol.out @@ -0,0 +1,2 @@ +line 1 +new line a diff --git a/gitdiff/testdata/apply/text_fragment_insert_noeol.patch b/gitdiff/testdata/apply/text_fragment_insert_noeol.patch new file mode 100644 index 0000000..9424675 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_noeol.patch @@ -0,0 +1,5 @@ +diff --git a/gitdiff/testdata/apply/fragment_insert_noeol.src b/gitdiff/testdata/apply/fragment_insert_noeol.src +--- a/gitdiff/testdata/apply/fragment_insert_noeol.src ++++ b/gitdiff/testdata/apply/fragment_insert_noeol.src +@@ -1,0 +2 @@ line 1 ++new line a diff --git a/gitdiff/testdata/apply/text_fragment_insert_noeol.src b/gitdiff/testdata/apply/text_fragment_insert_noeol.src new file mode 100644 index 0000000..8cf2f17 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_noeol.src @@ -0,0 +1,3 @@ +line 1 +line 2 +line 3 \ No newline at end of file diff --git a/gitdiff/testdata/apply/text_fragment_insert_start.out b/gitdiff/testdata/apply/text_fragment_insert_start.out new file mode 100644 index 0000000..b202b92 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_start.out @@ -0,0 +1,2 @@ +new line a +new line b diff --git a/gitdiff/testdata/apply/text_fragment_insert_start.patch b/gitdiff/testdata/apply/text_fragment_insert_start.patch new file mode 100644 index 0000000..b7f9a7a --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_start.patch @@ -0,0 +1,6 @@ +diff --git a/gitdiff/testdata/apply/fragment_insert_start.src b/gitdiff/testdata/apply/fragment_insert_start.src +--- a/gitdiff/testdata/apply/fragment_insert_start.src ++++ b/gitdiff/testdata/apply/fragment_insert_start.src +@@ -0,0 +1,2 @@ ++new line a ++new line b diff --git a/gitdiff/testdata/apply/text_fragment_insert_start.src b/gitdiff/testdata/apply/text_fragment_insert_start.src new file mode 100644 index 0000000..a92d664 --- /dev/null +++ b/gitdiff/testdata/apply/text_fragment_insert_start.src @@ -0,0 +1,3 @@ +line 1 +line 2 +line 3