package main import ( "encoding/base64" "fmt" "regexp" "sort" "strconv" "strings" "unicode/utf8" ) // A unified diff applied by the tool itself (novox/hq issue 1604). The forge's diffpatch answers 500 with an empty // message when an unchanged context line holds a non-ASCII character, so a patch the forge did not take is applied // here: each touched file is read at the commit the commit goes on, its hunks are applied, and the resulting // files are committed through the same call a files commit uses. The forge's files call carries no file mode, so // a patch that creates an executable file, changes a mode or touches a symlink is not applied here: the tool says so. // fileSource reads a file at the commit the patch is applied to; ok is false when there is no such file. type fileSource func(path string) (content []byte, ok bool, err error) // patchFile is one file section of a patch. type patchFile struct { oldPath, newPath string // "" when /dev/null create, remove bool renamed bool oldSeen bool hunks []hunk modeBlock string // why the files call cannot carry this section, "" when it can } type hunk struct { oldStart int old, new []string oldNoNL, newNoNL bool } var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) // gitPath reads a path of a diff header: git quotes one holding unusual bytes in C style. func gitPath(s string) string { s = strings.TrimRight(s, "\t ") if strings.HasPrefix(s, `"`) { if u, err := strconv.Unquote(s); err == nil { return u } } else if i := strings.IndexByte(s, '\t'); i >= 0 { s = s[:i] } return s } func stripPrefix(p string) string { if p == "/dev/null" { return "" } if i := strings.IndexByte(p, '/'); i >= 0 && (p[:i] == "a" || p[:i] == "b") { return p[i+1:] } return p } func nonRegular(mode string) bool { return mode != "" && mode != "100644" } // parsePatch reads a unified diff into its file sections. func parsePatch(text string) ([]*patchFile, error) { lines := strings.Split(text, "\n") if n := len(lines); n > 0 && lines[n-1] == "" { lines = lines[:n-1] } var files []*patchFile var cur *patchFile block := func(f *patchFile, what string) { if f.modeBlock == "" { f.modeBlock = what } } for i := 0; i < len(lines); i++ { l := strings.TrimSuffix(lines[i], "\r") switch { case strings.HasPrefix(l, "diff --git "): cur = &patchFile{} files = append(files, cur) rest := strings.TrimPrefix(l, "diff --git ") if k := strings.Index(rest, " b/"); k > 0 && !strings.HasPrefix(rest, `"`) { cur.oldPath, cur.newPath = stripPrefix(rest[:k]), stripPrefix(rest[k+1:]) } case cur != nil && strings.HasPrefix(l, "new file mode "): cur.create = true if m := strings.TrimPrefix(l, "new file mode "); nonRegular(m) { block(cur, "creates a file of mode "+m) } case cur != nil && strings.HasPrefix(l, "deleted file mode "): cur.remove = true case cur != nil && strings.HasPrefix(l, "old mode "): block(cur, "changes the file mode ("+l+")") case cur != nil && strings.HasPrefix(l, "new mode "): block(cur, "changes the file mode ("+l+")") case cur != nil && strings.HasPrefix(l, "index "): if f := strings.Fields(l); len(f) == 3 && nonRegular(f[2]) { block(cur, "touches a file of mode "+f[2]) } case cur != nil && strings.HasPrefix(l, "rename from "): cur.oldPath, cur.renamed = gitPath(strings.TrimPrefix(l, "rename from ")), true case cur != nil && strings.HasPrefix(l, "rename to "): cur.newPath, cur.renamed = gitPath(strings.TrimPrefix(l, "rename to ")), true case strings.HasPrefix(l, "copy from "), strings.HasPrefix(l, "copy to "): return nil, fmt.Errorf("a copy in a patch (%s) is not applied by the tool", l) case strings.HasPrefix(l, "GIT binary patch"), strings.HasPrefix(l, "Binary files "): return nil, fmt.Errorf("a binary patch is not applied by the tool") case strings.HasPrefix(l, "--- "): if cur == nil || cur.oldSeen { cur = &patchFile{} files = append(files, cur) } p := stripPrefix(gitPath(strings.TrimPrefix(l, "--- "))) cur.oldPath = p if p == "" { cur.create = true } cur.oldSeen = true if i+1 >= len(lines) || !strings.HasPrefix(lines[i+1], "+++ ") { return nil, fmt.Errorf("the line %d, %q, is not followed by a +++ line", i+1, l) } i++ q := stripPrefix(gitPath(strings.TrimPrefix(strings.TrimSuffix(lines[i], "\r"), "+++ "))) cur.newPath = q if q == "" { cur.remove = true } case cur != nil && hunkHeader.MatchString(l): m := hunkHeader.FindStringSubmatch(l) atoi := func(s string, def int) int { if s == "" { return def } n, _ := strconv.Atoi(s) return n } h := hunk{oldStart: atoi(m[1], 0)} oldN, newN := atoi(m[2], 1), atoi(m[4], 1) startLine := i + 1 noNL := func() { // "\ No newline at end of file" is about the line before it prev := lines[i-1] switch { case prev != "" && prev[0] == '-': h.oldNoNL = true case prev != "" && prev[0] == '+': h.newNoNL = true default: h.oldNoNL, h.newNoNL = true, true } } for oldN > 0 || newN > 0 { i++ if i >= len(lines) { return nil, fmt.Errorf("the hunk of %s at patch line %d ends before its %d old and %d new lines are all there", cur.name(), startLine, oldN, newN) } b := strings.TrimSuffix(lines[i], "\r") if lines[i] == "" { b = " " // a blank context line some tools strip to nothing } switch b[0] { case '\\': noNL() case ' ': h.old, h.new = append(h.old, lines[i][min(1, len(lines[i])):]), append(h.new, lines[i][min(1, len(lines[i])):]) oldN-- newN-- case '-': h.old = append(h.old, lines[i][1:]) oldN-- case '+': h.new = append(h.new, lines[i][1:]) newN-- default: return nil, fmt.Errorf("the hunk of %s at patch line %d has a line %d that is neither context, - nor +: %q", cur.name(), startLine, i+1, lines[i]) } } for i+1 < len(lines) && strings.HasPrefix(lines[i+1], `\`) { i++ noNL() } cur.hunks = append(cur.hunks, h) } } for _, f := range files { if f.newPath == "" && f.oldPath == "" { return nil, fmt.Errorf("a file section of the patch names no file") } } if len(files) == 0 { return nil, fmt.Errorf("the patch holds no file section") } return files, nil } func (f *patchFile) name() string { if f.newPath != "" { return f.newPath } return f.oldPath } // textFile is a file's content as lines, and whether its last line ends with a newline. type textFile struct { lines []string endNL bool } func splitText(b []byte) textFile { if len(b) == 0 { return textFile{endNL: true} } s := string(b) endNL := strings.HasSuffix(s, "\n") s = strings.TrimSuffix(s, "\n") return textFile{lines: strings.Split(s, "\n"), endNL: endNL} } func (t textFile) bytes() []byte { if len(t.lines) == 0 { return nil } s := strings.Join(t.lines, "\n") if t.endNL { s += "\n" } return []byte(s) } // apply puts one hunk on the file, at where the hunk says or, as git apply does, at the nearest place its old // lines are found exactly; delta is how far earlier hunks of this file moved lines. func (t textFile) apply(h hunk, delta int) (textFile, int, error) { want := h.oldStart - 1 + delta if len(h.old) == 0 && h.oldStart > 0 { want = h.oldStart + delta // a pure insertion is "after" the line it names } if want < 0 { want = 0 } matches := func(at int) bool { if at < 0 || at+len(h.old) > len(t.lines) { return false } for k, l := range h.old { if t.lines[at+k] != l { return false } } // the old side's last line lacks a newline only when the file's does if at+len(h.old) == len(t.lines) && len(h.old) > 0 && h.oldNoNL == t.endNL { return false } return true } at := -1 for d := 0; d <= len(t.lines)+1 && at < 0; d++ { switch { case matches(want - d): at = want - d case matches(want + d): at = want + d } } if at < 0 { return t, delta, fmt.Errorf("the hunk at line %d does not apply: its context and removed lines are not in the file (first of them: %q)", h.oldStart, firstOf(h.old)) } out := append(append(append([]string{}, t.lines[:at]...), h.new...), t.lines[at+len(h.old):]...) endNL := t.endNL if at+len(h.old) == len(t.lines) && (len(h.new) > 0 || len(h.old) > 0) { endNL = !h.newNoNL } return textFile{lines: out, endNL: endNL}, delta + len(h.new) - len(h.old), nil } func firstOf(l []string) string { if len(l) == 0 { return "" } return l[0] } // patchAsFiles applies the patch to the files read through read, and says the files to commit. func patchAsFiles(text string, read fileSource) ([]FileChange, error) { sections, err := parsePatch(text) if err != nil { return nil, err } for _, s := range sections { if s.modeBlock != "" { return nil, fmt.Errorf("%s %s, and the forge's files call carries no file mode", s.name(), s.modeBlock) } } work := map[string][]byte{} // the files as they are being changed; nil means absent orig := map[string]bool{} // which paths existed load := func(p string) error { if _, seen := work[p]; seen { return nil } b, ok, err := read(p) if err != nil { return err } orig[p] = ok if ok { if b == nil { b = []byte{} } work[p] = b } else { work[p] = nil } return nil } for _, s := range sections { from, to := s.oldPath, s.newPath if s.create { from = "" } var t textFile if from != "" { if err := load(from); err != nil { return nil, err } if work[from] == nil { return nil, fmt.Errorf("%s: the file is not there at the commit the patch goes on, so the patch does not apply", from) } if !utf8.Valid(work[from]) && len(s.hunks) > 0 { return nil, fmt.Errorf("%s is not UTF-8 text: the tool applies text patches only", from) } t = splitText(work[from]) } else { if err := load(to); err != nil { return nil, err } if work[to] != nil { return nil, fmt.Errorf("%s: the patch creates it, but it is already there at the commit the patch goes on", to) } t = textFile{endNL: true} } delta := 0 for _, h := range s.hunks { if t, delta, err = t.apply(h, delta); err != nil { return nil, fmt.Errorf("%s: %w", s.name(), err) } } switch { case s.remove: if len(t.lines) != 0 { return nil, fmt.Errorf("%s: the patch deletes the file, but lines are left after its hunks", from) } work[from] = nil default: if s.renamed && from != to { work[from] = nil if err := load(to); err != nil { return nil, err } } body := t.bytes() if body == nil { body = []byte{} } work[to] = body } } var paths []string for p := range work { paths = append(paths, p) } sort.Strings(paths) var out []FileChange for _, p := range paths { switch { case work[p] == nil && !orig[p]: case work[p] == nil: out = append(out, FileChange{Path: p, Delete: true}) default: fc := FileChange{Path: p, size: len(work[p])} if utf8.Valid(work[p]) { s := string(work[p]) fc.Content = &s } else { s := base64Std(work[p]) fc.ContentBase64 = &s } out = append(out, fc) } } if len(out) == 0 { return nil, fmt.Errorf("the patch changes no file") } return out, nil } func base64Std(b []byte) string { return base64.StdEncoding.EncodeToString(b) }