Files
pad/internal/diff/diff.go
T
xarmian 7ba823004c feat(history): version rows carry their writer, line counts and create marker, plus a per-version diff endpoint (PLAN-2348 U2) (#1691)
* feat(history): version rows carry their writer, line counts and create marker; a diff endpoint pairs each row with its own change (PLAN-2348 U2)

* test(history): server legs, export/import leg; web type and API method for the version diff (PLAN-2348 U2)

* docs(history): the version diff endpoint and row fields in CLAUDE.md; FormatDiffSummary names what it counts (PLAN-2348 U2)

* fix(history): user_id already existed on item_versions (migration 012); a diff refuses a body the chain could not rebuild (PLAN-2348 U2, codex r1)
2026-09-30 10:02:41 -04:00

102 lines
3.4 KiB
Go

// Package diff provides compact text diffing for version storage.
// It uses the Myers diff algorithm via go-diff to create and apply patches.
package diff
import (
"fmt"
"strings"
difflib "github.com/sergi/go-diff/diffmatchpatch"
)
var dmp = difflib.New()
// CreatePatch computes a compact patch that transforms oldText into newText.
// The patch can be applied with ApplyPatch to recover newText from oldText.
func CreatePatch(oldText, newText string) string {
diffs := dmp.DiffMain(oldText, newText, true)
patches := dmp.PatchMake(oldText, diffs)
return dmp.PatchToText(patches)
}
// ApplyPatch applies a patch to baseText to produce the target text.
// Returns an error if the patch cannot be applied cleanly.
func ApplyPatch(baseText, patchText string) (string, error) {
if patchText == "" {
return baseText, nil
}
patches, err := dmp.PatchFromText(patchText)
if err != nil {
return "", fmt.Errorf("parse patch: %w", err)
}
result, applied := dmp.PatchApply(patches, baseText)
// Check that all hunks applied
for i, ok := range applied {
if !ok {
return "", fmt.Errorf("patch hunk %d failed to apply", i)
}
}
return result, nil
}
// IsDiffSmaller returns true if storing a diff would be meaningfully smaller
// than storing the full content. Returns false for near-complete rewrites
// where the diff is >= 80% of the full content size.
func IsDiffSmaller(patch, fullContent string) bool {
if len(fullContent) == 0 {
return false
}
return len(patch) < (len(fullContent) * 80 / 100)
}
// CreateReversePatch computes a patch that transforms newText back into oldText.
// Used for reverse-diff version storage: current doc has latest content,
// versions store patches to go backwards.
func CreateReversePatch(oldText, newText string) string {
return CreatePatch(newText, oldText)
}
// FormatDiffSummary returns a human-readable summary of changes. Its "lines"
// are character-diff CHUNKS (a one-character edit reads +1/-1); LineCounts is
// the line-level count the version rows store (PLAN-2348 U2).
func FormatDiffSummary(oldText, newText string) string {
oldLines := strings.Count(oldText, "\n")
newLines := strings.Count(newText, "\n")
added := 0
removed := 0
diffs := dmp.DiffMain(oldText, newText, false)
for _, d := range diffs {
switch d.Type {
case difflib.DiffInsert:
added += strings.Count(d.Text, "\n") + 1
case difflib.DiffDelete:
removed += strings.Count(d.Text, "\n") + 1
}
}
_ = oldLines
_ = newLines
return fmt.Sprintf("+%d/-%d lines", added, removed)
}
// LineCounts returns how many lines a line-level diff of oldText → newText
// adds and removes (PLAN-2348 U2). It counts the way the web DiffView renders
// jsdiff's diffLines: each changed run is split on newlines after dropping one
// trailing newline, so a run "a\nb\n" is two lines. Both sides are Myers
// line diffs; diffmatchpatch's speed-up heuristics can choose a different,
// equally valid split on unusual inputs, in which case a count can differ from
// the rendered diff by the lines those splits trade.
func LineCounts(oldText, newText string) (added, removed int) {
a, b, lines := dmp.DiffLinesToChars(oldText, newText)
diffs := dmp.DiffCharsToLines(dmp.DiffMain(a, b, false), lines)
for _, d := range diffs {
n := strings.Count(strings.TrimSuffix(d.Text, "\n"), "\n") + 1
switch d.Type {
case difflib.DiffInsert:
added += n
case difflib.DiffDelete:
removed += n
}
}
return added, removed
}