diff --git a/model.go b/model.go index 6da10a6..3d888fa 100644 --- a/model.go +++ b/model.go @@ -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"` @@ -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 { diff --git a/parity_test.go b/parity_test.go index 3c99c56..a451271 100644 --- a/parity_test.go +++ b/parity_test.go @@ -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() @@ -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 @@ -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 @@ -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() diff --git a/parser.go b/parser.go index cca261a..ec7cb38 100644 --- a/parser.go +++ b/parser.go @@ -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 ") { @@ -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) diff --git a/patch_file_operations.go b/patch_file_operations.go new file mode 100644 index 0000000..7882d0c --- /dev/null +++ b/patch_file_operations.go @@ -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") +} diff --git a/patchset.go b/patchset.go index 0ba09a8..6c66abb 100644 --- a/patchset.go +++ b/patchset.go @@ -87,8 +87,11 @@ type patchset struct { } type patchsetFile struct { - Diff fileDiff - Patch []byte + Diff fileDiff + Patch []byte + Operation patchsetOperation + SourcePath string + TargetPath string } func parsePatchset(patchData []byte) (patchset, []error) { @@ -105,24 +108,25 @@ func parsePatchset(patchData []byte) (patchset, []error) { } files := make([]patchsetFile, len(chunks)) + var operationErrs []error for i := range chunks { files[i] = patchsetFile{ Diff: parsed.FileDiff[i], Patch: chunks[i], } + if err := classifyPatchsetFile(&files[i]); err != nil { + operationErrs = append(operationErrs, err) + } + } + if len(operationErrs) > 0 { + return patchset{}, operationErrs } return patchset{Files: files}, nil } func (p patchset) apply(tree map[string][]byte) (map[string][]byte, error) { - out := cloneTree(tree) - for i := range p.Files { - if err := applyPatchsetFile(out, &p.Files[i]); err != nil { - return nil, err - } - } - return out, nil + return applyPatchsetFiles(tree, p.filePointers()) } func applyPatchset(tree map[string][]byte, patchData []byte) (map[string][]byte, error) { @@ -137,6 +141,14 @@ func ApplyPatchset(tree map[string][]byte, patchData []byte) (map[string][]byte, return applyPatchset(tree, patchData) } +func (p patchset) filePointers() []*patchsetFile { + files := make([]*patchsetFile, len(p.Files)) + for i := range p.Files { + files[i] = &p.Files[i] + } + return files +} + func cloneTree(tree map[string][]byte) map[string][]byte { out := make(map[string][]byte, len(tree)) for path, content := range tree { diff --git a/patchset_apply.go b/patchset_apply.go index e4767b6..f788cce 100644 --- a/patchset_apply.go +++ b/patchset_apply.go @@ -4,114 +4,86 @@ import "fmt" const patchsetOperationModify patchsetOperation = "modify" -func applyPatchsetFile(tree map[string][]byte, file *patchsetFile) error { - if file.Diff.IsBinary { - return &unsupportedPatchError{ - Operation: patchsetOperationBinary, - Path: firstNonEmpty(file.Diff.ToFile, file.Diff.FromFile), - } - } - - op, sourcePath, targetPath, err := determinePatchsetOperation(tree, &file.Diff) - if err != nil { +func classifyPatchsetFile(file *patchsetFile) error { + if err := validatePatchsetFileHeaders(&file.Diff); err != nil { return err } - switch op { - case patchsetOperationCreate: - if _, exists := tree[targetPath]; exists { - return fmt.Errorf("cannot create existing file %q", targetPath) - } - content, err := applyPatchsetContent(nil, file) - if err != nil { - return err - } - tree[targetPath] = append([]byte(nil), content...) - return nil - case patchsetOperationDelete: - content, exists := tree[sourcePath] - if !exists { - return fmt.Errorf("cannot delete missing file %q", sourcePath) - } - if len(file.Diff.Hunks) > 0 { - if _, err := applyPatchsetContent(content, file); err != nil { - return err - } - } - delete(tree, sourcePath) - return nil - case patchsetOperationRename: - content, exists := tree[sourcePath] - if !exists { - return fmt.Errorf("cannot rename missing file %q", sourcePath) - } - if targetPath != sourcePath { - if _, exists := tree[targetPath]; exists { - return fmt.Errorf("cannot rename %q to existing file %q", sourcePath, targetPath) - } - } - applied, err := applyPatchsetContent(content, file) - if err != nil { - return err - } - delete(tree, sourcePath) - tree[targetPath] = append([]byte(nil), applied...) - return nil - case patchsetOperationCopy: - content, exists := tree[sourcePath] - if !exists { - return fmt.Errorf("cannot copy missing file %q", sourcePath) - } - if _, exists := tree[targetPath]; exists { - return fmt.Errorf("cannot copy to existing file %q", targetPath) - } - applied, err := applyPatchsetContent(content, file) - if err != nil { - return err - } - tree[targetPath] = append([]byte(nil), applied...) - return nil - case patchsetOperationModeChange, patchsetOperationModify: - content, exists := tree[targetPath] - if !exists { - return fmt.Errorf("cannot modify missing file %q", targetPath) - } - applied, err := applyPatchsetContent(content, file) - if err != nil { - return err - } - tree[targetPath] = append([]byte(nil), applied...) + if file.Diff.IsBinary { + file.Operation = patchsetOperationBinary + file.SourcePath, file.TargetPath = patchsetPaths(&file.Diff) return nil - default: - return fmt.Errorf("unsupported patch operation") } + + operation, sourcePath, targetPath := determinePatchsetOperation(&file.Diff) + file.Operation = operation + file.SourcePath = sourcePath + file.TargetPath = targetPath + return nil } -func determinePatchsetOperation(tree map[string][]byte, fileDiff *fileDiff) (op patchsetOperation, sourcePath, targetPath string, err error) { +func determinePatchsetOperation(fileDiff *fileDiff) (op patchsetOperation, sourcePath, targetPath string) { sourcePath, targetPath = patchsetPaths(fileDiff) switch { case fileDiff.RenameFrom != "" || fileDiff.RenameTo != "": - return patchsetOperationRename, sourcePath, targetPath, nil + return patchsetOperationRename, sourcePath, targetPath case fileDiff.CopyFrom != "" || fileDiff.CopyTo != "": - return patchsetOperationCopy, sourcePath, targetPath, nil + return patchsetOperationCopy, sourcePath, targetPath case fileDiff.Type == fileDiffTypeAdded: - return patchsetOperationCreate, "", targetPath, nil + return patchsetOperationCreate, "", targetPath case fileDiff.Type == fileDiffTypeDeleted: - return patchsetOperationDelete, sourcePath, "", nil + return patchsetOperationDelete, sourcePath, "" } if fileDiff.NewMode != "" && fileDiff.OldMode == "" { - if _, exists := tree[targetPath]; exists { - return "", "", "", fmt.Errorf("cannot create existing file %q", targetPath) - } - return patchsetOperationCreate, "", targetPath, nil + return patchsetOperationCreate, "", targetPath } if fileDiff.OldMode != "" || fileDiff.NewMode != "" { - return patchsetOperationModeChange, sourcePath, targetPath, nil + return patchsetOperationModeChange, sourcePath, targetPath } - return patchsetOperationModify, sourcePath, targetPath, nil + return patchsetOperationModify, sourcePath, targetPath +} + +func validatePatchsetFileHeaders(fileDiff *fileDiff) error { + hasCopy := fileDiff.CopyFrom != "" || fileDiff.CopyTo != "" + hasRename := fileDiff.RenameFrom != "" || fileDiff.RenameTo != "" + hasCreate := fileDiff.Type == fileDiffTypeAdded || (fileDiff.NewMode != "" && fileDiff.OldMode == "") + hasDelete := fileDiff.Type == fileDiffTypeDeleted + + switch { + case hasCopy && hasRename: + return fmt.Errorf("invalid patch operation: copy and rename cannot be combined") + case hasCreate && hasCopy: + return fmt.Errorf("invalid patch operation: create and copy cannot be combined") + case hasCreate && hasRename: + return fmt.Errorf("invalid patch operation: create and rename cannot be combined") + case hasDelete && hasCopy: + return fmt.Errorf("invalid patch operation: delete and copy cannot be combined") + case hasDelete && hasRename: + return fmt.Errorf("invalid patch operation: delete and rename cannot be combined") + } + + if len(fileDiff.Hunks) == 0 { + return nil + } + + sourcePath, targetPath := patchsetPaths(fileDiff) + if !hasCreate && fileDiff.oldFileHeaderPath == "" { + return fmt.Errorf("patch lacks old filename information for %q", sourcePath) + } + if !hasDelete && fileDiff.newFileHeaderPath == "" { + return fmt.Errorf("patch lacks new filename information for %q", targetPath) + } + if fileDiff.oldFileHeaderPath != "" && sourcePath != "" && fileDiff.oldFileHeaderPath != sourcePath { + return fmt.Errorf("inconsistent old filename: %q != %q", fileDiff.oldFileHeaderPath, sourcePath) + } + if fileDiff.newFileHeaderPath != "" && targetPath != "" && fileDiff.newFileHeaderPath != targetPath { + return fmt.Errorf("inconsistent new filename: %q != %q", fileDiff.newFileHeaderPath, targetPath) + } + + return nil } func patchsetPaths(fileDiff *fileDiff) (sourcePath, targetPath string) { @@ -120,6 +92,124 @@ func patchsetPaths(fileDiff *fileDiff) (sourcePath, targetPath string) { return sourcePath, targetPath } +func applyPatchsetFiles(tree map[string][]byte, files []*patchsetFile) (map[string][]byte, error) { + base := cloneTree(tree) + current := cloneTree(tree) + willDelete := patchsetDeletes(files) + + type pendingWrite struct { + path string + content []byte + } + + var deletes []string + var writes []pendingWrite + + for _, file := range files { + switch file.Operation { + case patchsetOperationBinary: + return nil, &unsupportedPatchError{ + Operation: patchsetOperationBinary, + Path: firstNonEmpty(file.TargetPath, file.SourcePath), + } + case patchsetOperationCreate: + if err := ensureCanCreate(current, file.TargetPath, willDelete); err != nil { + return nil, err + } + content, err := applyPatchsetContent(nil, file) + if err != nil { + return nil, err + } + current[file.TargetPath] = append([]byte(nil), content...) + writes = append(writes, pendingWrite{path: file.TargetPath, content: content}) + case patchsetOperationDelete: + content, exists := current[file.SourcePath] + if !exists { + return nil, fmt.Errorf("cannot delete missing file %q", file.SourcePath) + } + if len(file.Diff.Hunks) > 0 { + if _, err := applyPatchsetContent(content, file); err != nil { + return nil, err + } + } + delete(current, file.SourcePath) + deletes = append(deletes, file.SourcePath) + case patchsetOperationRename: + content, exists := base[file.SourcePath] + if !exists { + return nil, fmt.Errorf("cannot rename missing file %q", file.SourcePath) + } + if file.TargetPath != file.SourcePath { + if err := ensureCanCreate(current, file.TargetPath, willDelete); err != nil { + return nil, err + } + } + applied, err := applyPatchsetContent(content, file) + if err != nil { + return nil, err + } + delete(current, file.SourcePath) + current[file.TargetPath] = append([]byte(nil), applied...) + deletes = append(deletes, file.SourcePath) + writes = append(writes, pendingWrite{path: file.TargetPath, content: applied}) + case patchsetOperationCopy: + content, exists := base[file.SourcePath] + if !exists { + return nil, fmt.Errorf("cannot copy missing file %q", file.SourcePath) + } + if err := ensureCanCreate(current, file.TargetPath, willDelete); err != nil { + return nil, err + } + applied, err := applyPatchsetContent(content, file) + if err != nil { + return nil, err + } + current[file.TargetPath] = append([]byte(nil), applied...) + writes = append(writes, pendingWrite{path: file.TargetPath, content: applied}) + case patchsetOperationModeChange, patchsetOperationModify: + content, exists := current[file.TargetPath] + if !exists { + return nil, fmt.Errorf("cannot modify missing file %q", file.TargetPath) + } + applied, err := applyPatchsetContent(content, file) + if err != nil { + return nil, err + } + current[file.TargetPath] = append([]byte(nil), applied...) + writes = append(writes, pendingWrite{path: file.TargetPath, content: applied}) + default: + return nil, fmt.Errorf("unsupported patch operation") + } + } + + out := cloneTree(tree) + for _, path := range deletes { + delete(out, path) + } + for _, write := range writes { + out[write.path] = append([]byte(nil), write.content...) + } + return out, nil +} + +func patchsetDeletes(files []*patchsetFile) map[string]bool { + deletes := make(map[string]bool) + for _, file := range files { + switch file.Operation { + case patchsetOperationDelete, patchsetOperationRename: + deletes[file.SourcePath] = true + } + } + return deletes +} + +func ensureCanCreate(tree map[string][]byte, path string, willDelete map[string]bool) error { + if _, exists := tree[path]; exists && !willDelete[path] { + return fmt.Errorf("cannot create existing file %q", path) + } + return nil +} + func applyPatchsetContent(pristine []byte, file *patchsetFile) ([]byte, error) { if len(file.Diff.Hunks) == 0 { return append([]byte(nil), pristine...), nil diff --git a/patchset_test.go b/patchset_test.go index 382fa85..055e5f7 100644 --- a/patchset_test.go +++ b/patchset_test.go @@ -134,6 +134,232 @@ new mode 100755 } } +func TestParsePatchOperations(t *testing.T) { + t.Parallel() + + patchData := []byte(`diff --git a/a.go b/a.go +deleted file mode 100644 +index 1111111..0000000 +--- a/a.go ++++ /dev/null +@@ -1 +0,0 @@ +-content a +diff --git a/c.go b/c.go +new file mode 100755 +index 0000000..3333333 +--- /dev/null ++++ b/c.go +@@ -0,0 +1 @@ ++content c +diff --git a/e.go b/f.go +similarity index 100% +rename from e.go +rename to f.go +diff --git a/mode.go b/mode.go +old mode 100644 +new mode 100755 +--- a/mode.go ++++ b/mode.go +`) + + operations, err := ParsePatchOperations(patchData) + require.NoError(t, err) + require.Len(t, operations, 4) + + assert.Equal(t, PatchOperationTypeDelete, operations[0].Type) + assert.Equal(t, "a.go", operations[0].SourcePath) + assert.Empty(t, operations[0].TargetPath) + assert.True(t, operations[0].MutatesFileSet()) + + assert.Equal(t, PatchOperationTypeCreate, operations[1].Type) + assert.Empty(t, operations[1].SourcePath) + assert.Equal(t, "c.go", operations[1].TargetPath) + assert.Equal(t, "100755", operations[1].NewMode) + assert.True(t, operations[1].MutatesFileSet()) + + assert.Equal(t, PatchOperationTypeRename, operations[2].Type) + assert.Equal(t, "e.go", operations[2].SourcePath) + assert.Equal(t, "f.go", operations[2].TargetPath) + assert.True(t, operations[2].MutatesFileSet()) + + assert.Equal(t, PatchOperationTypeModeChange, operations[3].Type) + assert.Equal(t, "mode.go", operations[3].SourcePath) + assert.Equal(t, "mode.go", operations[3].TargetPath) + assert.False(t, operations[3].MutatesFileSet()) +} + +func TestApplyPatchOperations(t *testing.T) { + t.Parallel() + + patchData := []byte(`diff --git a/a.go b/a.go +deleted file mode 100644 +index 1111111..0000000 +--- a/a.go ++++ /dev/null +@@ -1 +0,0 @@ +-content a +diff --git a/c.go b/c.go +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/c.go +@@ -0,0 +1 @@ ++content c +diff --git a/e.go b/f.go +similarity index 100% +rename from e.go +rename to f.go +`) + tree := map[string][]byte{ + "a.go": []byte("content a\n"), + "e.go": []byte("content e\n"), + } + original := cloneTestTree(tree) + + operations, err := ParsePatchOperations(patchData) + require.NoError(t, err) + + applied, err := ApplyPatchOperations(tree, operations) + require.NoError(t, err) + assert.Equal(t, map[string][]byte{ + "c.go": []byte("content c\n"), + "f.go": []byte("content e\n"), + }, applied) + assert.Equal(t, original, tree) +} + +func TestParsePatchOperations_ClassifiesBinaryPatch(t *testing.T) { + t.Parallel() + + operations, err := ParsePatchOperations(mustReadFile(t, filepath.Join("testdata", "significant", "binary-delta.diff"))) + require.NoError(t, err) + require.Len(t, operations, 1) + assert.Equal(t, PatchOperationTypeBinary, operations[0].Type) + assert.True(t, operations[0].IsBinary) +} + +func TestParsePatchOperations_RejectsGitApplyInvalidHeaderCombinations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + patch []byte + wantErr string + }{ + { + name: "create and copy", + patch: []byte(`diff --git a/1 b/2 +new file mode 100644 +copy from 1 +copy to 2 +`), + wantErr: "create and copy cannot be combined", + }, + { + name: "create and rename", + patch: []byte(`diff --git a/1 b/2 +new file mode 100644 +rename from 1 +rename to 2 +`), + wantErr: "create and rename cannot be combined", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := ParsePatchOperations(test.patch) + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +func TestParsePatchOperations_RejectsGitApplyInconsistentFilenames(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + patch []byte + wantErr string + }{ + { + name: "inconsistent new filename", + patch: []byte(`diff --git a/f b/f +new file mode 100644 +index 0000000..d00491f +--- /dev/null ++++ b/f-blah +@@ -0,0 +1 @@ ++1 +`), + wantErr: "inconsistent new filename", + }, + { + name: "inconsistent old filename", + patch: []byte(`diff --git a/f b/f +deleted file mode 100644 +index d00491f..0000000 +--- b/f-blah ++++ /dev/null +@@ -1 +0,0 @@ +-1 +`), + wantErr: "inconsistent old filename", + }, + { + name: "missing new filename", + patch: []byte(`diff --git a/f b/f +index 0000000..d00491f +--- a/f +@@ -0,0 +1 @@ ++1 +`), + wantErr: "lacks new filename information", + }, + { + name: "missing old filename", + patch: []byte(`diff --git a/f b/f +index d00491f..0000000 ++++ b/f +@@ -1 +0,0 @@ +-1 +`), + wantErr: "lacks old filename information", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := ParsePatchOperations(test.patch) + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErr) + }) + } +} + +func TestParsePatchOperations_AcceptsQuotedFilenamesWithSpaces(t *testing.T) { + t.Parallel() + + operations, err := ParsePatchOperations([]byte(`diff --git "a/foo bar.txt" "b/foo bar.txt" +--- "a/foo bar.txt" ++++ "b/foo bar.txt" +@@ -1 +1 @@ +-old ++new +`)) + require.NoError(t, err) + require.Len(t, operations, 1) + assert.Equal(t, "foo bar.txt", operations[0].SourcePath) + assert.Equal(t, "foo bar.txt", operations[0].TargetPath) +} + func TestPatchsetApply_AtomicOnFailure(t *testing.T) { t.Parallel() diff --git a/testdata/parity/git-t4102-copy-edit/fixture.json b/testdata/parity/git-t4102-copy-edit/fixture.json new file mode 100644 index 0000000..0afe6d1 --- /dev/null +++ b/testdata/parity/git-t4102-copy-edit/fixture.json @@ -0,0 +1,5 @@ +{ + "skipLibrary": true, + "upstream": "git.git t/t4102-apply-rename.sh", + "description": "copy with content edit" +} diff --git a/testdata/parity/git-t4102-copy-edit/out/bar b/testdata/parity/git-t4102-copy-edit/out/bar new file mode 100644 index 0000000..b089dd9 --- /dev/null +++ b/testdata/parity/git-t4102-copy-edit/out/bar @@ -0,0 +1 @@ +This is bar diff --git a/testdata/parity/git-t4102-copy-edit/out/foo b/testdata/parity/git-t4102-copy-edit/out/foo new file mode 100644 index 0000000..a86c18f --- /dev/null +++ b/testdata/parity/git-t4102-copy-edit/out/foo @@ -0,0 +1 @@ +This is foo diff --git a/testdata/parity/git-t4102-copy-edit/patch b/testdata/parity/git-t4102-copy-edit/patch new file mode 100644 index 0000000..238ea21 --- /dev/null +++ b/testdata/parity/git-t4102-copy-edit/patch @@ -0,0 +1,9 @@ +diff --git a/foo b/bar +similarity index 47% +copy from foo +copy to bar +--- a/foo ++++ b/bar +@@ -1 +1 @@ +-This is foo ++This is bar diff --git a/testdata/parity/git-t4102-copy-edit/src/foo b/testdata/parity/git-t4102-copy-edit/src/foo new file mode 100644 index 0000000..a86c18f --- /dev/null +++ b/testdata/parity/git-t4102-copy-edit/src/foo @@ -0,0 +1 @@ +This is foo diff --git a/testdata/parity/git-t4102-rename-edit/fixture.json b/testdata/parity/git-t4102-rename-edit/fixture.json new file mode 100644 index 0000000..6bfea46 --- /dev/null +++ b/testdata/parity/git-t4102-rename-edit/fixture.json @@ -0,0 +1,5 @@ +{ + "skipLibrary": true, + "upstream": "git.git t/t4102-apply-rename.sh", + "description": "rename with content edit" +} diff --git a/testdata/parity/git-t4102-rename-edit/out/bar b/testdata/parity/git-t4102-rename-edit/out/bar new file mode 100644 index 0000000..b089dd9 --- /dev/null +++ b/testdata/parity/git-t4102-rename-edit/out/bar @@ -0,0 +1 @@ +This is bar diff --git a/testdata/parity/git-t4102-rename-edit/patch b/testdata/parity/git-t4102-rename-edit/patch new file mode 100644 index 0000000..2f7d29b --- /dev/null +++ b/testdata/parity/git-t4102-rename-edit/patch @@ -0,0 +1,9 @@ +diff --git a/foo b/bar +similarity index 47% +rename from foo +rename to bar +--- a/foo ++++ b/bar +@@ -1 +1 @@ +-This is foo ++This is bar diff --git a/testdata/parity/git-t4102-rename-edit/src/foo b/testdata/parity/git-t4102-rename-edit/src/foo new file mode 100644 index 0000000..a86c18f --- /dev/null +++ b/testdata/parity/git-t4102-rename-edit/src/foo @@ -0,0 +1 @@ +This is foo diff --git a/testdata/parity/git-t4112-rename-copy-combined/fixture.json b/testdata/parity/git-t4112-rename-copy-combined/fixture.json new file mode 100644 index 0000000..eaf234c --- /dev/null +++ b/testdata/parity/git-t4112-rename-copy-combined/fixture.json @@ -0,0 +1,5 @@ +{ + "skipLibrary": true, + "upstream": "git.git t/t4112-apply-renames.sh", + "description": "combined copy, rename, and source-file modification" +} diff --git a/testdata/parity/git-t4112-rename-copy-combined/out/include/arch/cris/klibc/archsetjmp.h b/testdata/parity/git-t4112-rename-copy-combined/out/include/arch/cris/klibc/archsetjmp.h new file mode 100644 index 0000000..8d20800 --- /dev/null +++ b/testdata/parity/git-t4112-rename-copy-combined/out/include/arch/cris/klibc/archsetjmp.h @@ -0,0 +1,24 @@ +/* + * arch/cris/include/klibc/archsetjmp.h + */ + +#ifndef _KLIBC_ARCHSETJMP_H +#define _KLIBC_ARCHSETJMP_H + +struct __jmp_buf { + unsigned long __r0; + unsigned long __r1; + unsigned long __r2; + unsigned long __r3; + unsigned long __r4; + unsigned long __r5; + unsigned long __r6; + unsigned long __r7; + unsigned long __r8; + unsigned long __sp; + unsigned long __srp; +}; + +typedef struct __jmp_buf jmp_buf[1]; + +#endif /* _KLIBC_ARCHSETJMP_H */ diff --git a/testdata/parity/git-t4112-rename-copy-combined/out/include/arch/m32r/klibc/archsetjmp.h b/testdata/parity/git-t4112-rename-copy-combined/out/include/arch/m32r/klibc/archsetjmp.h new file mode 100644 index 0000000..e16a835 --- /dev/null +++ b/testdata/parity/git-t4112-rename-copy-combined/out/include/arch/m32r/klibc/archsetjmp.h @@ -0,0 +1,21 @@ +/* + * arch/m32r/include/klibc/archsetjmp.h + */ + +#ifndef _KLIBC_ARCHSETJMP_H +#define _KLIBC_ARCHSETJMP_H + +struct __jmp_buf { + unsigned long __r8; + unsigned long __r9; + unsigned long __r10; + unsigned long __r11; + unsigned long __r12; + unsigned long __r13; + unsigned long __r14; + unsigned long __r15; +}; + +typedef struct __jmp_buf jmp_buf[1]; + +#endif /* _KLIBC_ARCHSETJMP_H */ diff --git a/testdata/parity/git-t4112-rename-copy-combined/out/klibc/README b/testdata/parity/git-t4112-rename-copy-combined/out/klibc/README new file mode 100644 index 0000000..6e7e74d --- /dev/null +++ b/testdata/parity/git-t4112-rename-copy-combined/out/klibc/README @@ -0,0 +1,4 @@ +This is a simple readme file. +And we add a few +lines at the +end of it. diff --git a/testdata/parity/git-t4112-rename-copy-combined/out/klibc/arch/README b/testdata/parity/git-t4112-rename-copy-combined/out/klibc/arch/README new file mode 100644 index 0000000..a1010a9 --- /dev/null +++ b/testdata/parity/git-t4112-rename-copy-combined/out/klibc/arch/README @@ -0,0 +1,3 @@ +This is a simple readme file. +And we copy it to one level down, and +add a few lines at the end of it. diff --git a/testdata/parity/git-t4112-rename-copy-combined/patch b/testdata/parity/git-t4112-rename-copy-combined/patch new file mode 100644 index 0000000..80860a5 --- /dev/null +++ b/testdata/parity/git-t4112-rename-copy-combined/patch @@ -0,0 +1,92 @@ +diff --git a/klibc/arch/x86_64/include/klibc/archsetjmp.h b/include/arch/cris/klibc/archsetjmp.h +similarity index 76% +copy from klibc/arch/x86_64/include/klibc/archsetjmp.h +copy to include/arch/cris/klibc/archsetjmp.h +--- a/klibc/arch/x86_64/include/klibc/archsetjmp.h ++++ b/include/arch/cris/klibc/archsetjmp.h +@@ -1,21 +1,24 @@ + /* +- * arch/x86_64/include/klibc/archsetjmp.h ++ * arch/cris/include/klibc/archsetjmp.h + */ + + #ifndef _KLIBC_ARCHSETJMP_H + #define _KLIBC_ARCHSETJMP_H + + struct __jmp_buf { +- unsigned long __rbx; +- unsigned long __rsp; +- unsigned long __rbp; +- unsigned long __r12; +- unsigned long __r13; +- unsigned long __r14; +- unsigned long __r15; +- unsigned long __rip; ++ unsigned long __r0; ++ unsigned long __r1; ++ unsigned long __r2; ++ unsigned long __r3; ++ unsigned long __r4; ++ unsigned long __r5; ++ unsigned long __r6; ++ unsigned long __r7; ++ unsigned long __r8; ++ unsigned long __sp; ++ unsigned long __srp; + }; + + typedef struct __jmp_buf jmp_buf[1]; + +-#endif /* _SETJMP_H */ ++#endif /* _KLIBC_ARCHSETJMP_H */ +diff --git a/klibc/arch/x86_64/include/klibc/archsetjmp.h b/include/arch/m32r/klibc/archsetjmp.h +similarity index 66% +rename from klibc/arch/x86_64/include/klibc/archsetjmp.h +rename to include/arch/m32r/klibc/archsetjmp.h +--- a/klibc/arch/x86_64/include/klibc/archsetjmp.h ++++ b/include/arch/m32r/klibc/archsetjmp.h +@@ -1,21 +1,21 @@ + /* +- * arch/x86_64/include/klibc/archsetjmp.h ++ * arch/m32r/include/klibc/archsetjmp.h + */ + + #ifndef _KLIBC_ARCHSETJMP_H + #define _KLIBC_ARCHSETJMP_H + + struct __jmp_buf { +- unsigned long __rbx; +- unsigned long __rsp; +- unsigned long __rbp; ++ unsigned long __r8; ++ unsigned long __r9; ++ unsigned long __r10; ++ unsigned long __r11; + unsigned long __r12; + unsigned long __r13; + unsigned long __r14; + unsigned long __r15; +- unsigned long __rip; + }; + + typedef struct __jmp_buf jmp_buf[1]; + +-#endif /* _SETJMP_H */ ++#endif /* _KLIBC_ARCHSETJMP_H */ +diff --git a/klibc/README b/klibc/README +--- a/klibc/README ++++ b/klibc/README +@@ -1,1 +1,4 @@ + This is a simple readme file. ++And we add a few ++lines at the ++end of it. +diff --git a/klibc/README b/klibc/arch/README +copy from klibc/README +copy to klibc/arch/README +--- a/klibc/README ++++ b/klibc/arch/README +@@ -1,1 +1,3 @@ + This is a simple readme file. ++And we copy it to one level down, and ++add a few lines at the end of it. diff --git a/testdata/parity/git-t4112-rename-copy-combined/src/klibc/README b/testdata/parity/git-t4112-rename-copy-combined/src/klibc/README new file mode 100644 index 0000000..ce99fa3 --- /dev/null +++ b/testdata/parity/git-t4112-rename-copy-combined/src/klibc/README @@ -0,0 +1 @@ +This is a simple readme file. diff --git a/testdata/parity/git-t4112-rename-copy-combined/src/klibc/arch/x86_64/include/klibc/archsetjmp.h b/testdata/parity/git-t4112-rename-copy-combined/src/klibc/arch/x86_64/include/klibc/archsetjmp.h new file mode 100644 index 0000000..90d0a0d --- /dev/null +++ b/testdata/parity/git-t4112-rename-copy-combined/src/klibc/arch/x86_64/include/klibc/archsetjmp.h @@ -0,0 +1,21 @@ +/* + * arch/x86_64/include/klibc/archsetjmp.h + */ + +#ifndef _KLIBC_ARCHSETJMP_H +#define _KLIBC_ARCHSETJMP_H + +struct __jmp_buf { + unsigned long __rbx; + unsigned long __rsp; + unsigned long __rbp; + unsigned long __r12; + unsigned long __r13; + unsigned long __r14; + unsigned long __r15; + unsigned long __rip; +}; + +typedef struct __jmp_buf jmp_buf[1]; + +#endif /* _SETJMP_H */