From e6053262e44cad06c9776a2060a094980bcfa4d7 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:20:25 +0000 Subject: [PATCH] Apply a unified diff in the forge tool itself, for the patches the forge's diffpatch refuses (issue 1604) --- modules/gitea/cmd/forge/applypatch.go | 397 ++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 modules/gitea/cmd/forge/applypatch.go diff --git a/modules/gitea/cmd/forge/applypatch.go b/modules/gitea/cmd/forge/applypatch.go new file mode 100644 index 00000000..fe01015d --- /dev/null +++ b/modules/gitea/cmd/forge/applypatch.go @@ -0,0 +1,397 @@ +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) }