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
19 changes: 17 additions & 2 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,13 @@ type binaryPatch struct {
Content string
}

// fileDiff Source of truth: https://github.com/git/git/blob/master/diffcore.h#L106
// Implemented in https://github.com/git/git/blob/master/diff.c#L3496
// fileDiff models the subset of Git patch metadata exposed by this parser.
//
// Git represents generated diff pairs as diff_filepair:
// https://github.com/git/git/blob/aec3f587505a472db67e9462d0702e7d463a449d/diffcore.h#L107-L130
//
// Git emits unified patch file headers in builtin_diff:
// https://github.com/git/git/blob/aec3f587505a472db67e9462d0702e7d463a449d/diff.c#L3838-L3930
type fileDiff struct {
FromFile string `json:"from_file"`
ToFile string `json:"to_file"`
Expand All @@ -138,6 +143,16 @@ type fileDiff struct {
CopyTo string `json:"copy_to,omitempty"`
Hunks []hunk `json:"hunks"`
BinaryPatch []binaryPatch `json:"binary_patch"`

// Parser-only paths from the "---" and "+++" file header lines. Git apply
// validates these against the diff --git/copy/rename names in apply.c:
// https://github.com/git/git/blob/aec3f587505a472db67e9462d0702e7d463a449d/apply.c#L929-L966
// https://github.com/git/git/blob/aec3f587505a472db67e9462d0702e7d463a449d/apply.c#L1330-L1453
//
// They are intentionally unexported and omitted from JSON because they are
// input syntax used for validation, not semantic diff metadata.
oldFileHeaderPath string
newFileHeaderPath string
}

func (fd *fileDiff) GoString() string {
Expand Down
92 changes: 92 additions & 0 deletions parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,47 @@ func TestApplyFile_ParityCorpus(t *testing.T) {
}
}

func TestApplyPatchOperations_ParityCorpus(t *testing.T) {
if testing.Short() {
t.Skip("parity corpus is an integration test stream")
}

requireGitBinary(t)

cases := loadParityCases(t)
require.NotEmpty(t, cases)

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

if !supportsPatchOperationParity(tc.fixture) {
t.Skip("fixture exercises git apply behavior outside the content-tree operation API")
}

oracles := runGitApplyOracles(t, tc)
require.NoError(t, oracles.exitErr)

operations, err := ParsePatchOperations(tc.patch)
require.NoError(t, err)

applied, err := ApplyPatchOperations(contentMap(tc.srcTree), operations)
require.NoError(t, err)
assertContentMap(t, oracles.tree, applied)
})
}
}

func supportsPatchOperationParity(fixture parityFixture) bool {
return !fixture.ExpectConflict &&
!fixture.ExpectGitError &&
!fixture.IgnoreWhitespace &&
len(fixture.GitArgs) == 0 &&
len(fixture.SrcModes) == 0 &&
len(fixture.OutModes) == 0
}

func runLibraryApply(t *testing.T, tc parityCase, rejectMode bool) (applyResult, error) {
t.Helper()

Expand Down Expand Up @@ -305,6 +346,10 @@ func loadParityTree(t *testing.T, legacyPath string, files map[string]string, mo
return tree
}

if info, err := os.Stat(legacyPath); err == nil && info.IsDir() {
return collectFixtureTree(t, legacyPath)
}

legacy := readParityFileMaybe(t, legacyPath)
if legacy == nil {
return nil
Expand All @@ -314,6 +359,30 @@ func loadParityTree(t *testing.T, legacyPath string, files map[string]string, mo
}
}

func collectFixtureTree(t *testing.T, root string) parityTree {
t.Helper()

tree := make(parityTree)
require.NoError(t, filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
require.NoError(t, err)
if path == root || d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
require.NoError(t, err)
content, err := os.ReadFile(path)
require.NoError(t, err)
info, err := d.Info()
require.NoError(t, err)
tree[filepath.ToSlash(rel)] = parityFile{
content: content,
mode: info.Mode().Perm(),
}
return nil
}))
return tree
}

func parseParityMode(raw string) fs.FileMode {
if raw == "" {
return 0
Expand Down Expand Up @@ -406,6 +475,29 @@ func assertParityTree(t *testing.T, want, got parityTree) {
}
}

func contentMap(tree parityTree) map[string][]byte {
content := make(map[string][]byte, len(tree))
for path, file := range tree {
content[path] = append([]byte(nil), file.content...)
}
return content
}

func assertContentMap(t *testing.T, want parityTree, got map[string][]byte) {
t.Helper()

require.Len(t, got, len(want))
for path, expected := range want {
actual, ok := got[path]
require.True(t, ok, "missing file %s", path)
assert.Equal(t, expected.content, actual, "content mismatch for %s", path)
}
for path := range got {
_, ok := want[path]
assert.True(t, ok, "unexpected file %s", path)
}
}

func requireGitBinary(t *testing.T) {
t.Helper()

Expand Down
42 changes: 40 additions & 2 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,12 @@ func (p *parser) tryVisitHeader(diff string) bool {
return false
}

if strings.HasPrefix(diff, "+++ ") || strings.HasPrefix(diff, "--- ") {
// ignore -- we're still in the FileDiff and we've already captured the file names
if strings.HasPrefix(diff, "--- ") {
p.diff.FileDiff[fileHEAD].oldFileHeaderPath = parseFileHeaderPath(diff, "--- ")
return true
}
if strings.HasPrefix(diff, "+++ ") {
p.diff.FileDiff[fileHEAD].newFileHeaderPath = parseFileHeaderPath(diff, "+++ ")
return true
}
if strings.HasPrefix(diff, "index ") {
Expand Down Expand Up @@ -416,6 +420,40 @@ func (p *parser) parseDiffLine(line string) fileDiff {
}
}

func parseFileHeaderPath(line, prefix string) string {
path := firstFileHeaderToken(strings.TrimPrefix(line, prefix))
if path == "/dev/null" {
return ""
}
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
return path[2:]
}
return path
}

func firstFileHeaderToken(header string) string {
if !strings.HasPrefix(header, `"`) {
if fields := strings.Fields(header); len(fields) > 0 {
return fields[0]
}
return header
}

var escaped bool
for i := 1; i < len(header); i++ {
switch {
case escaped:
escaped = false
case header[i] == '\\':
escaped = true
case header[i] == '"':
return header[1:i]
}
}

return strings.Trim(header, `"`)
}

func parsePercentValue(raw string) int {
raw = strings.TrimSuffix(raw, "%")
value, err := strconv.Atoi(raw)
Expand Down
133 changes: 133 additions & 0 deletions patch_file_operations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package git_diff_parser

import "fmt"

type PatchOperationType string

const (
PatchOperationTypeModify PatchOperationType = "modify"
PatchOperationTypeCreate PatchOperationType = "create"
PatchOperationTypeDelete PatchOperationType = "delete"
PatchOperationTypeRename PatchOperationType = "rename"
PatchOperationTypeCopy PatchOperationType = "copy"
PatchOperationTypeModeChange PatchOperationType = "mode_change"
PatchOperationTypeBinary PatchOperationType = "binary"
)

// PatchOperation describes one file-level operation in a git patchset.
//
// Values returned by ParsePatchOperations can be passed to ApplyPatchOperations.
type PatchOperation struct {
Type PatchOperationType
SourcePath string
TargetPath string
OldMode string
NewMode string
IndexMode string
IsBinary bool
Patch []byte

file *patchsetFile
}

// MutatesFileSet reports whether this operation adds, removes, or moves a file.
func (op *PatchOperation) MutatesFileSet() bool {
switch op.Type {
case PatchOperationTypeCreate, PatchOperationTypeDelete, PatchOperationTypeRename, PatchOperationTypeCopy:
return true
default:
return false
}
}

// ParsePatchOperations parses patchData into ordered file-level operations.
func ParsePatchOperations(patchData []byte) ([]PatchOperation, error) {
patchset, errs := parsePatchset(patchData)
if len(errs) > 0 {
return nil, fmt.Errorf("unsupported patch syntax: %w", errs[0])
}

operations := make([]PatchOperation, 0, len(patchset.Files))
for i := range patchset.Files {
operation, err := patchOperationFromFile(&patchset.Files[i])
if err != nil {
return nil, err
}
operations = append(operations, operation)
}

return operations, nil
}

// ApplyPatchOperations applies ordered patch operations to a copy of tree.
func ApplyPatchOperations(tree map[string][]byte, operations []PatchOperation) (map[string][]byte, error) {
files, err := patchsetFilesFromOperations(operations)
if err != nil {
return nil, err
}
return applyPatchsetFiles(tree, files)
}

func patchsetFilesFromOperations(operations []PatchOperation) ([]*patchsetFile, error) {
files := make([]*patchsetFile, 0, len(operations))
for i := range operations {
file, err := operations[i].patchsetFile()
if err != nil {
return nil, err
}
files = append(files, file)
}

return files, nil
}

func patchOperationFromFile(file *patchsetFile) (PatchOperation, error) {
return PatchOperation{
Type: publicPatchOperationType(file.Operation),
SourcePath: file.SourcePath,
TargetPath: file.TargetPath,
OldMode: file.Diff.OldMode,
NewMode: file.Diff.NewMode,
IndexMode: file.Diff.IndexMode,
IsBinary: file.Diff.IsBinary,
Patch: append([]byte(nil), file.Patch...),
file: file,
}, nil
}

func publicPatchOperationType(op patchsetOperation) PatchOperationType {
switch op {
case patchsetOperationCreate:
return PatchOperationTypeCreate
case patchsetOperationDelete:
return PatchOperationTypeDelete
case patchsetOperationRename:
return PatchOperationTypeRename
case patchsetOperationCopy:
return PatchOperationTypeCopy
case patchsetOperationModeChange:
return PatchOperationTypeModeChange
case patchsetOperationBinary:
return PatchOperationTypeBinary
default:
return PatchOperationTypeModify
}
}

func (op *PatchOperation) patchsetFile() (*patchsetFile, error) {
if op.file != nil {
return op.file, nil
}
if len(op.Patch) > 0 {
patchset, errs := parsePatchset(op.Patch)
if len(errs) > 0 {
return nil, fmt.Errorf("unsupported patch syntax: %w", errs[0])
}
if len(patchset.Files) != 1 {
return nil, fmt.Errorf("patch operation contains %d file diffs, expected 1", len(patchset.Files))
}
return &patchset.Files[0], nil
}

return nil, fmt.Errorf("patch operation has no patch data")
}
Loading
Loading