Files
iasysadmin/internal/patch/patch.go
T
2026-09-27 16:04:02 -06:00

279 lines
7.4 KiB
Go

// Package patch aplica y genera diffs unificados (formato "diff -u" / "git diff").
//
// Existe para que el flujo de parches (§40) no dependa de que "patch" o "git"
// estén instalados (una instalación mínima de Debian no trae "patch") y para
// poder aplicar un parche de forma atómica: primero se calcula el resultado
// de todos los archivos en memoria y solo si todos aplican se escribe algo.
//
// El aplicador es estricto con el contenido (sin "fuzz": las líneas de
// contexto deben coincidir exactamente) pero tolera desplazamientos de
// posición, como hace patch(1) cuando el archivo cambió por encima o por
// debajo del hunk.
package patch
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// FilePatch son los cambios de un archivo.
type FilePatch struct {
OldPath string // tal como aparece tras "--- " (sin marca de tiempo)
NewPath string // tal como aparece tras "+++ "
Hunks []Hunk
IsNew bool // --- /dev/null
IsDelete bool // +++ /dev/null
}
// Hunk es un bloque "@@ -a,b +c,d @@".
type Hunk struct {
OldStart, OldLines int
NewStart, NewLines int
// Lines con prefijo ' ', '-' o '+'.
Lines []string
// OldNoEOL / NewNoEOL: "\ No newline at end of file" tras la última
// línea del lado antiguo / nuevo.
OldNoEOL, NewNoEOL bool
}
var hunkRe = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
// Parse interpreta un diff unificado con uno o varios archivos.
func Parse(diff string) ([]FilePatch, error) {
lines := strings.Split(strings.ReplaceAll(diff, "\r\n", "\n"), "\n")
var out []FilePatch
var cur *FilePatch
for i := 0; i < len(lines); i++ {
line := lines[i]
switch {
case strings.HasPrefix(line, "--- ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "+++ "):
out = append(out, FilePatch{OldPath: headerPath(line[4:]), NewPath: headerPath(lines[i+1][4:])})
cur = &out[len(out)-1]
cur.IsNew = cur.OldPath == "/dev/null"
cur.IsDelete = cur.NewPath == "/dev/null"
i++
case strings.HasPrefix(line, "@@ "):
if cur == nil {
return nil, fmt.Errorf("line %d: hunk without file header (--- / +++)", i+1)
}
m := hunkRe.FindStringSubmatch(line)
if m == nil {
return nil, fmt.Errorf("line %d: malformed hunk header %q", i+1, line)
}
h := Hunk{OldStart: atoi(m[1]), OldLines: atoiDefault(m[2], 1), NewStart: atoi(m[3]), NewLines: atoiDefault(m[4], 1)}
oldSeen, newSeen := 0, 0
j := i + 1
for ; j < len(lines) && (oldSeen < h.OldLines || newSeen < h.NewLines); j++ {
l := lines[j]
if l == "" && j == len(lines)-1 {
break // fin del texto: el hunk está incompleto
}
if l == "" {
// Algunos editores eliminan el espacio de las líneas de
// contexto vacías: se interpreta como contexto vacío.
l = " "
}
switch l[0] {
case ' ':
oldSeen++
newSeen++
case '-':
oldSeen++
case '+':
newSeen++
case '\\':
markNoEOL(&h)
continue
default:
return nil, fmt.Errorf("line %d: unexpected line in hunk: %q", j+1, l)
}
h.Lines = append(h.Lines, l)
}
if oldSeen != h.OldLines || newSeen != h.NewLines {
return nil, fmt.Errorf("line %d: hunk is truncated (expected -%d +%d lines, got -%d +%d)", i+1, h.OldLines, h.NewLines, oldSeen, newSeen)
}
if j < len(lines) && strings.HasPrefix(lines[j], `\`) {
markNoEOL(&h)
j++
}
cur.Hunks = append(cur.Hunks, h)
i = j - 1
}
}
if len(out) == 0 {
return nil, fmt.Errorf("no file headers found (expected unified diff with ---/+++ lines)")
}
for _, fp := range out {
if len(fp.Hunks) == 0 {
return nil, fmt.Errorf("%s: no hunks", fp.NewPath)
}
}
return out, nil
}
// markNoEOL asigna "\ No newline" al lado de la última línea del hunk.
func markNoEOL(h *Hunk) {
if len(h.Lines) == 0 {
return
}
switch h.Lines[len(h.Lines)-1][0] {
case '-':
h.OldNoEOL = true
case '+':
h.NewNoEOL = true
default:
h.OldNoEOL, h.NewNoEOL = true, true
}
}
// headerPath quita la marca de tiempo ("\t2026-...") de una cabecera.
func headerPath(s string) string {
if i := strings.IndexByte(s, '\t'); i >= 0 {
s = s[:i]
}
return strings.TrimSpace(s)
}
// Target devuelve la ruta a modificar quitando strip componentes (como -pN).
// Con strip < 0 se detecta: 1 si las rutas empiezan por a/ y b/ (git), 0 si no.
func (fp FilePatch) Target(strip int) string {
p := fp.NewPath
if fp.IsDelete {
p = fp.OldPath
}
if strip < 0 {
strip = 0
if (strings.HasPrefix(fp.OldPath, "a/") || fp.IsNew) && (strings.HasPrefix(fp.NewPath, "b/") || fp.IsDelete) {
strip = 1
}
}
parts := strings.Split(p, "/")
if strip >= len(parts) {
return parts[len(parts)-1]
}
return strings.Join(parts[strip:], "/")
}
// Apply aplica los hunks a old y devuelve el contenido nuevo.
func (fp FilePatch) Apply(old string) (string, error) {
if fp.IsNew && old != "" {
return "", fmt.Errorf("patch creates the file but it already exists")
}
src, srcEOL := splitLines(old)
var out []string
pos := 0 // siguiente línea de src sin copiar
offset := 0 // desplazamiento acumulado entre posición declarada y real
newEOL := srcEOL
for hi, h := range fp.Hunks {
var want, repl []string
for _, l := range h.Lines {
switch l[0] {
case ' ':
want = append(want, l[1:])
repl = append(repl, l[1:])
case '-':
want = append(want, l[1:])
case '+':
repl = append(repl, l[1:])
}
}
start := h.OldStart - 1 + offset
if h.OldLines == 0 {
start = h.OldStart + offset // inserción: OldStart es la línea anterior
}
at := findBlock(src, want, max(start, pos), pos)
if at < 0 {
return "", fmt.Errorf("hunk %d (@@ -%d,%d) does not apply: context not found", hi+1, h.OldStart, h.OldLines)
}
out = append(out, src[pos:at]...)
out = append(out, repl...)
pos = at + len(want)
offset = at - (h.OldStart - 1)
if h.OldLines == 0 {
offset = at - h.OldStart
}
if pos == len(src) {
// El hunk llega al final: su marca de EOL decide el final del archivo.
newEOL = !h.NewNoEOL
}
}
out = append(out, src[pos:]...)
if fp.IsDelete {
if len(out) != 0 {
return "", fmt.Errorf("patch deletes the file but content remains after applying it")
}
return "", nil
}
return joinLines(out, newEOL), nil
}
// findBlock busca want en src empezando cerca de hint y alejándose en ambas
// direcciones, sin retroceder antes de minPos. Devuelve -1 si no está.
func findBlock(src, want []string, hint, minPos int) int {
if len(want) == 0 {
return min(max(hint, minPos), len(src))
}
for d := 0; d <= len(src); d++ {
for _, p := range []int{hint + d, hint - d} {
if d == 0 && p != hint {
continue
}
if p < minPos || p+len(want) > len(src) {
continue
}
if equal(src[p:p+len(want)], want) {
return p
}
}
if hint+d > len(src) && hint-d < minPos {
break
}
}
return -1
}
func equal(a, b []string) bool {
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// splitLines divide en líneas e informa si el texto terminaba en "\n".
func splitLines(s string) ([]string, bool) {
if s == "" {
return nil, true
}
eol := strings.HasSuffix(s, "\n")
s = strings.TrimSuffix(s, "\n")
return strings.Split(s, "\n"), eol
}
func joinLines(lines []string, eol bool) string {
if len(lines) == 0 {
return ""
}
s := strings.Join(lines, "\n")
if eol {
s += "\n"
}
return s
}
func atoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
return atoi(s)
}