diff --git a/gitdiff/file_header.go b/gitdiff/file_header.go index 7ae4bc9..403cbd8 100644 --- a/gitdiff/file_header.go +++ b/gitdiff/file_header.go @@ -39,6 +39,16 @@ func (p *parser) ParseNextFileHeader() (*File, string, error) { return file, preamble.String(), nil } + // A diff command names two files. Keep both paths. A header that is + // only --- and +++ still uses the traditional one-name heuristic. + file, err = p.ParseDiffCommandFileHeader() + if err != nil { + return nil, "", err + } + if file != nil { + return file, preamble.String(), nil + } + // check for a "traditional" patch file, err = p.ParseTraditionalFileHeader() if err != nil { @@ -166,6 +176,60 @@ func (p *parser) ParseTraditionalFileHeader() (*File, error) { return f, nil } +// ParseDiffCommandFileHeader parses a unified diff that starts with a diff +// command (diff -u, diff -ruN, and similar) followed by --- and +++ lines. +// The command names two files, so both paths are kept. Patches that are only +// --- and +++ still use ParseTraditionalFileHeader. +func (p *parser) ParseDiffCommandFileHeader() (*File, error) { + const ( + oldPrefix = "--- " + newPrefix = "+++ " + minFragmentLen = len("@@ -1 +1 @@\n") + ) + + line := p.Line(0) + if !strings.HasPrefix(line, "diff ") || strings.HasPrefix(line, "diff --git ") { + return nil, nil + } + + oldLine, newLine := p.Line(1), p.Line(2) + if !strings.HasPrefix(oldLine, oldPrefix) || !strings.HasPrefix(newLine, newPrefix) { + return nil, nil + } + if len(p.Line(3)) < minFragmentLen || !strings.HasPrefix(p.Line(3), "@@ -") { + return nil, nil + } + + oldName, _, err := parseName(oldLine[len(oldPrefix):], '\t', 0) + if err != nil { + return nil, p.Errorf(1, "file header: %v", err) + } + newName, _, err := parseName(newLine[len(newPrefix):], '\t', 0) + if err != nil { + return nil, p.Errorf(2, "file header: %v", err) + } + + for i := 0; i < 3; i++ { + if err := p.Next(); err != nil { + return nil, err + } + } + + f := &File{} + switch { + case oldName == devNull || hasEpochTimestamp(oldLine): + f.IsNew = true + f.NewName = newName + case newName == devNull || hasEpochTimestamp(newLine): + f.IsDelete = true + f.OldName = oldName + default: + f.OldName = oldName + f.NewName = newName + } + return f, nil +} + // parseGitHeaderName extracts a default file name from the Git file header // line. This is required for mode-only changes and creation/deletion of empty // files. Other types of patch include the file name(s) in the header data. diff --git a/gitdiff/file_header_test.go b/gitdiff/file_header_test.go index ef29833..5d5e387 100644 --- a/gitdiff/file_header_test.go +++ b/gitdiff/file_header_test.go @@ -4,6 +4,7 @@ import ( "io" "os" "reflect" + "strings" "testing" ) @@ -274,6 +275,39 @@ context line } } +func TestParseDiffCommandKeepsDistinctNames(t *testing.T) { + input := "" + + "diff -u /old/rendered-1/cluster.yaml /new/rendered-2/cluster.yaml\n" + + "--- /old/rendered-1/cluster.yaml\t2026-01-14 20:44:05\n" + + "+++ /new/rendered-2/cluster.yaml\t2026-01-14 20:44:05\n" + + "@@ -1 +1 @@\n" + + "-a\n" + + "+b\n" + + "diff -u /old/rendered-1/other.yaml /new/rendered-2/other.yaml\n" + + "--- /old/rendered-1/other.yaml\t2026-01-14 20:44:05\n" + + "+++ /new/rendered-2/other.yaml\t2026-01-14 20:44:05\n" + + "@@ -1 +1 @@\n" + + "-a\n" + + "+b\n" + + files, _, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Fatalf("got %d files, want 2", len(files)) + } + want := [][2]string{ + {"/old/rendered-1/cluster.yaml", "/new/rendered-2/cluster.yaml"}, + {"/old/rendered-1/other.yaml", "/new/rendered-2/other.yaml"}, + } + for i, pair := range want { + if files[i].OldName != pair[0] || files[i].NewName != pair[1] { + t.Fatalf("file %d: old %q new %q, want old %q new %q", i, files[i].OldName, files[i].NewName, pair[0], pair[1]) + } + } +} + func TestCleanName(t *testing.T) { tests := map[string]struct { Input string diff --git a/gitdiff/parser.go b/gitdiff/parser.go index d5986e1..9d37732 100644 --- a/gitdiff/parser.go +++ b/gitdiff/parser.go @@ -80,7 +80,7 @@ type parser struct { eof bool lineno int64 - lines [3]string + lines [4]string } func newParser(r io.Reader) *parser {