Skip to content

Commit a91358f

Browse files
committed
feat: expose patch operation API
1 parent 5c335de commit a91358f

24 files changed

Lines changed: 910 additions & 100 deletions

File tree

model.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,13 @@ type binaryPatch struct {
118118
Content string
119119
}
120120

121-
// fileDiff Source of truth: https://github.com/git/git/blob/master/diffcore.h#L106
122-
// Implemented in https://github.com/git/git/blob/master/diff.c#L3496
121+
// fileDiff models the subset of Git patch metadata exposed by this parser.
122+
//
123+
// Git represents generated diff pairs as diff_filepair:
124+
// https://github.com/git/git/blob/aec3f587505a472db67e9462d0702e7d463a449d/diffcore.h#L107-L130
125+
//
126+
// Git emits unified patch file headers in builtin_diff:
127+
// https://github.com/git/git/blob/aec3f587505a472db67e9462d0702e7d463a449d/diff.c#L3838-L3930
123128
type fileDiff struct {
124129
FromFile string `json:"from_file"`
125130
ToFile string `json:"to_file"`
@@ -138,6 +143,16 @@ type fileDiff struct {
138143
CopyTo string `json:"copy_to,omitempty"`
139144
Hunks []hunk `json:"hunks"`
140145
BinaryPatch []binaryPatch `json:"binary_patch"`
146+
147+
// Parser-only paths from the "---" and "+++" file header lines. Git apply
148+
// validates these against the diff --git/copy/rename names in apply.c:
149+
// https://github.com/git/git/blob/aec3f587505a472db67e9462d0702e7d463a449d/apply.c#L929-L966
150+
// https://github.com/git/git/blob/aec3f587505a472db67e9462d0702e7d463a449d/apply.c#L1330-L1453
151+
//
152+
// They are intentionally unexported and omitted from JSON because they are
153+
// input syntax used for validation, not semantic diff metadata.
154+
oldFileHeaderPath string
155+
newFileHeaderPath string
141156
}
142157

143158
func (fd *fileDiff) GoString() string {

parity_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,47 @@ func TestApplyFile_ParityCorpus(t *testing.T) {
128128
}
129129
}
130130

131+
func TestApplyPatchOperations_ParityCorpus(t *testing.T) {
132+
if testing.Short() {
133+
t.Skip("parity corpus is an integration test stream")
134+
}
135+
136+
requireGitBinary(t)
137+
138+
cases := loadParityCases(t)
139+
require.NotEmpty(t, cases)
140+
141+
for _, tc := range cases {
142+
tc := tc
143+
t.Run(tc.name, func(t *testing.T) {
144+
t.Parallel()
145+
146+
if !supportsPatchOperationParity(tc.fixture) {
147+
t.Skip("fixture exercises git apply behavior outside the content-tree operation API")
148+
}
149+
150+
oracles := runGitApplyOracles(t, tc)
151+
require.NoError(t, oracles.exitErr)
152+
153+
operations, err := ParsePatchOperations(tc.patch)
154+
require.NoError(t, err)
155+
156+
applied, err := ApplyPatchOperations(contentMap(tc.srcTree), operations)
157+
require.NoError(t, err)
158+
assertContentMap(t, oracles.tree, applied)
159+
})
160+
}
161+
}
162+
163+
func supportsPatchOperationParity(fixture parityFixture) bool {
164+
return !fixture.ExpectConflict &&
165+
!fixture.ExpectGitError &&
166+
!fixture.IgnoreWhitespace &&
167+
len(fixture.GitArgs) == 0 &&
168+
len(fixture.SrcModes) == 0 &&
169+
len(fixture.OutModes) == 0
170+
}
171+
131172
func runLibraryApply(t *testing.T, tc parityCase, rejectMode bool) (applyResult, error) {
132173
t.Helper()
133174

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

349+
if info, err := os.Stat(legacyPath); err == nil && info.IsDir() {
350+
return collectFixtureTree(t, legacyPath)
351+
}
352+
308353
legacy := readParityFileMaybe(t, legacyPath)
309354
if legacy == nil {
310355
return nil
@@ -314,6 +359,30 @@ func loadParityTree(t *testing.T, legacyPath string, files map[string]string, mo
314359
}
315360
}
316361

362+
func collectFixtureTree(t *testing.T, root string) parityTree {
363+
t.Helper()
364+
365+
tree := make(parityTree)
366+
require.NoError(t, filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
367+
require.NoError(t, err)
368+
if path == root || d.IsDir() {
369+
return nil
370+
}
371+
rel, err := filepath.Rel(root, path)
372+
require.NoError(t, err)
373+
content, err := os.ReadFile(path)
374+
require.NoError(t, err)
375+
info, err := d.Info()
376+
require.NoError(t, err)
377+
tree[filepath.ToSlash(rel)] = parityFile{
378+
content: content,
379+
mode: info.Mode().Perm(),
380+
}
381+
return nil
382+
}))
383+
return tree
384+
}
385+
317386
func parseParityMode(raw string) fs.FileMode {
318387
if raw == "" {
319388
return 0
@@ -406,6 +475,29 @@ func assertParityTree(t *testing.T, want, got parityTree) {
406475
}
407476
}
408477

478+
func contentMap(tree parityTree) map[string][]byte {
479+
content := make(map[string][]byte, len(tree))
480+
for path, file := range tree {
481+
content[path] = append([]byte(nil), file.content...)
482+
}
483+
return content
484+
}
485+
486+
func assertContentMap(t *testing.T, want parityTree, got map[string][]byte) {
487+
t.Helper()
488+
489+
require.Len(t, got, len(want))
490+
for path, expected := range want {
491+
actual, ok := got[path]
492+
require.True(t, ok, "missing file %s", path)
493+
assert.Equal(t, expected.content, actual, "content mismatch for %s", path)
494+
}
495+
for path := range got {
496+
_, ok := want[path]
497+
assert.True(t, ok, "unexpected file %s", path)
498+
}
499+
}
500+
409501
func requireGitBinary(t *testing.T) {
410502
t.Helper()
411503

parser.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,12 @@ func (p *parser) tryVisitHeader(diff string) bool {
206206
return false
207207
}
208208

209-
if strings.HasPrefix(diff, "+++ ") || strings.HasPrefix(diff, "--- ") {
210-
// ignore -- we're still in the FileDiff and we've already captured the file names
209+
if strings.HasPrefix(diff, "--- ") {
210+
p.diff.FileDiff[fileHEAD].oldFileHeaderPath = parseFileHeaderPath(diff, "--- ")
211+
return true
212+
}
213+
if strings.HasPrefix(diff, "+++ ") {
214+
p.diff.FileDiff[fileHEAD].newFileHeaderPath = parseFileHeaderPath(diff, "+++ ")
211215
return true
212216
}
213217
if strings.HasPrefix(diff, "index ") {
@@ -416,6 +420,40 @@ func (p *parser) parseDiffLine(line string) fileDiff {
416420
}
417421
}
418422

423+
func parseFileHeaderPath(line, prefix string) string {
424+
path := firstFileHeaderToken(strings.TrimPrefix(line, prefix))
425+
if path == "/dev/null" {
426+
return ""
427+
}
428+
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
429+
return path[2:]
430+
}
431+
return path
432+
}
433+
434+
func firstFileHeaderToken(header string) string {
435+
if !strings.HasPrefix(header, `"`) {
436+
if fields := strings.Fields(header); len(fields) > 0 {
437+
return fields[0]
438+
}
439+
return header
440+
}
441+
442+
var escaped bool
443+
for i := 1; i < len(header); i++ {
444+
switch {
445+
case escaped:
446+
escaped = false
447+
case header[i] == '\\':
448+
escaped = true
449+
case header[i] == '"':
450+
return header[1:i]
451+
}
452+
}
453+
454+
return strings.Trim(header, `"`)
455+
}
456+
419457
func parsePercentValue(raw string) int {
420458
raw = strings.TrimSuffix(raw, "%")
421459
value, err := strconv.Atoi(raw)

patch_file_operations.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package git_diff_parser
2+
3+
import "fmt"
4+
5+
type PatchOperationType string
6+
7+
const (
8+
PatchOperationTypeModify PatchOperationType = "modify"
9+
PatchOperationTypeCreate PatchOperationType = "create"
10+
PatchOperationTypeDelete PatchOperationType = "delete"
11+
PatchOperationTypeRename PatchOperationType = "rename"
12+
PatchOperationTypeCopy PatchOperationType = "copy"
13+
PatchOperationTypeModeChange PatchOperationType = "mode_change"
14+
PatchOperationTypeBinary PatchOperationType = "binary"
15+
)
16+
17+
// PatchOperation describes one file-level operation in a git patchset.
18+
//
19+
// Values returned by ParsePatchOperations can be passed to ApplyPatchOperations.
20+
type PatchOperation struct {
21+
Type PatchOperationType
22+
SourcePath string
23+
TargetPath string
24+
OldMode string
25+
NewMode string
26+
IndexMode string
27+
IsBinary bool
28+
Patch []byte
29+
30+
file *patchsetFile
31+
}
32+
33+
// MutatesFileSet reports whether this operation adds, removes, or moves a file.
34+
func (op *PatchOperation) MutatesFileSet() bool {
35+
switch op.Type {
36+
case PatchOperationTypeCreate, PatchOperationTypeDelete, PatchOperationTypeRename, PatchOperationTypeCopy:
37+
return true
38+
default:
39+
return false
40+
}
41+
}
42+
43+
// ParsePatchOperations parses patchData into ordered file-level operations.
44+
func ParsePatchOperations(patchData []byte) ([]PatchOperation, error) {
45+
patchset, errs := parsePatchset(patchData)
46+
if len(errs) > 0 {
47+
return nil, fmt.Errorf("unsupported patch syntax: %w", errs[0])
48+
}
49+
50+
operations := make([]PatchOperation, 0, len(patchset.Files))
51+
for i := range patchset.Files {
52+
operation, err := patchOperationFromFile(&patchset.Files[i])
53+
if err != nil {
54+
return nil, err
55+
}
56+
operations = append(operations, operation)
57+
}
58+
59+
return operations, nil
60+
}
61+
62+
// ApplyPatchOperations applies ordered patch operations to a copy of tree.
63+
func ApplyPatchOperations(tree map[string][]byte, operations []PatchOperation) (map[string][]byte, error) {
64+
files, err := patchsetFilesFromOperations(operations)
65+
if err != nil {
66+
return nil, err
67+
}
68+
return applyPatchsetFiles(tree, files)
69+
}
70+
71+
func patchsetFilesFromOperations(operations []PatchOperation) ([]*patchsetFile, error) {
72+
files := make([]*patchsetFile, 0, len(operations))
73+
for i := range operations {
74+
file, err := operations[i].patchsetFile()
75+
if err != nil {
76+
return nil, err
77+
}
78+
files = append(files, file)
79+
}
80+
81+
return files, nil
82+
}
83+
84+
func patchOperationFromFile(file *patchsetFile) (PatchOperation, error) {
85+
return PatchOperation{
86+
Type: publicPatchOperationType(file.Operation),
87+
SourcePath: file.SourcePath,
88+
TargetPath: file.TargetPath,
89+
OldMode: file.Diff.OldMode,
90+
NewMode: file.Diff.NewMode,
91+
IndexMode: file.Diff.IndexMode,
92+
IsBinary: file.Diff.IsBinary,
93+
Patch: append([]byte(nil), file.Patch...),
94+
file: file,
95+
}, nil
96+
}
97+
98+
func publicPatchOperationType(op patchsetOperation) PatchOperationType {
99+
switch op {
100+
case patchsetOperationCreate:
101+
return PatchOperationTypeCreate
102+
case patchsetOperationDelete:
103+
return PatchOperationTypeDelete
104+
case patchsetOperationRename:
105+
return PatchOperationTypeRename
106+
case patchsetOperationCopy:
107+
return PatchOperationTypeCopy
108+
case patchsetOperationModeChange:
109+
return PatchOperationTypeModeChange
110+
case patchsetOperationBinary:
111+
return PatchOperationTypeBinary
112+
default:
113+
return PatchOperationTypeModify
114+
}
115+
}
116+
117+
func (op *PatchOperation) patchsetFile() (*patchsetFile, error) {
118+
if op.file != nil {
119+
return op.file, nil
120+
}
121+
if len(op.Patch) > 0 {
122+
patchset, errs := parsePatchset(op.Patch)
123+
if len(errs) > 0 {
124+
return nil, fmt.Errorf("unsupported patch syntax: %w", errs[0])
125+
}
126+
if len(patchset.Files) != 1 {
127+
return nil, fmt.Errorf("patch operation contains %d file diffs, expected 1", len(patchset.Files))
128+
}
129+
return &patchset.Files[0], nil
130+
}
131+
132+
return nil, fmt.Errorf("patch operation has no patch data")
133+
}

0 commit comments

Comments
 (0)