mesh/merge-gate head 324e331184da merged into trunk 0f2cee1bea4e — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 324e331184da merged into trunk 0f2cee1bea4e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@e023f69989ab (merged as b5dbfbb7 into main, walk plan-17925769516…
433 lines
12 KiB
Go
433 lines
12 KiB
Go
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, mode string, 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
|
|
leading int // unchanged lines before the first change
|
|
trailing int // unchanged lines after the last change
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
changed := false
|
|
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 b == "" {
|
|
b = " " // a blank context line some tools strip to nothing, or a bare CR
|
|
}
|
|
switch b[0] {
|
|
case '\\':
|
|
noNL()
|
|
case ' ':
|
|
text := lines[i]
|
|
if strings.HasPrefix(text, " ") {
|
|
text = text[1:] // a bare "" or "\r" is the whole blank line, its leading space stripped
|
|
}
|
|
h.old, h.new = append(h.old, text), append(h.new, text)
|
|
oldN--
|
|
newN--
|
|
if changed {
|
|
h.trailing++
|
|
} else {
|
|
h.leading++
|
|
}
|
|
case '-':
|
|
h.old = append(h.old, lines[i][1:])
|
|
oldN--
|
|
changed, h.trailing = true, 0
|
|
case '+':
|
|
h.new = append(h.new, lines[i][1:])
|
|
newN--
|
|
changed, h.trailing = true, 0
|
|
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
|
|
}
|
|
// As git apply does: a hunk that starts at line 1 (or 0) begins the file, and a hunk with context but none
|
|
// after its last change ends it; a hunk whose old side lacks the final newline ends it too.
|
|
atStart := h.oldStart <= 1
|
|
atEnd := (h.trailing == 0 && h.leading > 0) || h.oldNoNL
|
|
at := -1
|
|
switch {
|
|
case atStart && atEnd:
|
|
if len(t.lines) == len(h.old) && matches(0) {
|
|
at = 0
|
|
}
|
|
case atStart:
|
|
if matches(0) {
|
|
at = 0
|
|
}
|
|
case atEnd:
|
|
if e := len(t.lines) - len(h.old); matches(e) {
|
|
at = e
|
|
}
|
|
}
|
|
for d := 0; !atStart && !atEnd && 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, mode, ok, err := read(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ok && mode != "" && mode != "100644" {
|
|
return fmt.Errorf("%s is a file of mode %s, and the forge's files call carries no file mode, so committing it that way would make it 100644", p, mode)
|
|
}
|
|
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) }
|