hugin/cli/internal/reconcile/reconcile.go
2026-10-27 08:33:21 +01:00

523 lines
14 KiB
Go

// Package abgleich wendet ein Bundle auf ein Repo an (→ KONZEPT.md 11.4, 11.5).
//
// Ablauf in zwei Schritten: Planen liest das Repo und berechnet alle neuen
// Inhalte im Speicher, ohne etwas zu schreiben. Erst Run schreibt, und
// nur, wenn der Plan keine offenen Befunde hat. So entsteht kein halber Zustand,
// wenn eine Operation scheitert.
package reconcile
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"sort"
"strings"
"hugin/cli/internal/bundle"
"hugin/cli/internal/lock"
"hugin/cli/internal/paths"
)
type FindingKind string
const (
// ManualChange: eine verwaltete Datei, ein Schlüssel oder Block wurde
// seit dem letzten sync geändert. Der Wert erscheint in der Ausgabe.
ManualChange FindingKind = "Handänderung"
// Conflict: Datei oder Schlüssel gibt es schon, aber nicht von Hugin.
Conflict FindingKind = "Konflikt"
)
type Finding struct {
Kind FindingKind
Location string
Text string
}
func (b Finding) String() string { return fmt.Sprintf("%s: %s (%s)", b.Location, b.Text, b.Kind) }
type Options struct {
// Overwrite: Hugin gewinnt bei Handänderungen und Konflikten.
Overwrite bool
}
// Plan ist das Ergebnis von Planen: was geschrieben und gelöscht würde.
type Plan struct {
Findings []Finding
Write map[string][]byte // Pfad relativ zum Repo → neuer Inhalt
Delete []string
New []string // Pfade, die es vorher nicht gab
Lock *lock.Lock
options Options
unchanged int
}
// Open sagt, ob Befunde ein Ausführen verhindern.
func (p *Plan) Open() bool { return len(p.Findings) > 0 && !p.options.Overwrite }
// Changed sagt, ob der Plan überhaupt etwas im Repo ändert.
func (p *Plan) Changed() bool { return len(p.Write) > 0 || len(p.Delete) > 0 }
type file struct {
before []byte // nil: gab es nicht
content []byte // nil: soll gelöscht werden bzw. bleibt fehlen
loaded bool
json *jsonFile
}
type planner struct {
repo string
bundle *bundle.Bundle
old *lock.Lock
next *lock.Lock
files map[string]*file
findings []Finding
}
// MakePlan berechnet, was das Bundle im Repo ändern würde.
func MakePlan(repo string, b *bundle.Bundle, old *lock.Lock, opt Options) (*Plan, error) {
p := &planner{repo: repo, bundle: b, old: old, next: lock.New(), files: map[string]*file{}}
p.next.Common.Created = slices.Clone(old.Common.Created)
for _, pk := range b.Instructions.Packages {
p.next.Packages[pk.ID] = &lock.Package{Version: pk.Version, Checksum: pk.Checksum}
}
for _, op := range b.Instructions.Operations {
var err error
switch op.Op {
case "file":
err = p.file(op)
case "remove":
err = p.remove(op.Path)
case "json":
err = p.json(op)
case "json_remove":
err = p.removeJson(op)
case "block":
err = p.block(op)
}
if err != nil {
return nil, fmt.Errorf("%s %s: %w", op.Op, op.Path+op.File, err)
}
}
if err := p.removeOrphaned(); err != nil {
return nil, err
}
if err := p.removeOrphanedKeys(); err != nil {
return nil, err
}
if err := p.removeOrphanedBlocks(); err != nil {
return nil, err
}
return p.result(opt)
}
func (p *planner) load(rel string) (*file, error) {
if d, ok := p.files[rel]; ok {
return d, nil
}
abs, err := paths.InRepo(p.repo, rel)
if err != nil {
return nil, err
}
d := &file{loaded: true}
raw, err := os.ReadFile(abs)
switch {
case errors.Is(err, fs.ErrNotExist):
case err != nil:
return nil, err
default:
d.before, d.content = raw, raw
}
p.files[rel] = d
return d, nil
}
func (p *planner) finding(kind FindingKind, location, text string) {
p.findings = append(p.findings, Finding{kind, location, text})
}
func (p *planner) pkg(id string) *lock.Package {
pk, ok := p.next.Packages[id]
if !ok {
// Operation für ein Paket ohne Eintrag in pakete (z. B. entfernen)
pk = &lock.Package{}
p.next.Packages[id] = pk
}
return pk
}
func (p *planner) file(op bundle.Operation) error {
content, err := p.bundle.File(op.Source)
if err != nil {
return err
}
d, err := p.load(op.Path)
if err != nil {
return err
}
if d.before != nil && !bytes.Equal(d.before, content) {
oldSha, managed := p.old.Files()[op.Path]
switch {
case !managed:
p.finding(Conflict, op.Path, "Datei gibt es schon, sie stammt nicht von Hugin")
case bundle.Sha256(d.before) != oldSha:
p.finding(ManualChange, op.Path, "seit dem letzten sync von Hand geändert")
}
}
d.content = content
pk := p.pkg(op.Package)
if pk.Files == nil {
pk.Files = map[string]string{}
}
pk.Files[op.Path] = op.Sha256
return nil
}
func (p *planner) remove(rel string) error {
d, err := p.load(rel)
if err != nil || d.before == nil {
return err
}
oldSha, managed := p.old.Files()[rel]
if !managed {
// Nicht von Hugin: stehen lassen
return nil
}
if bundle.Sha256(d.before) != oldSha {
p.finding(ManualChange, rel, "soll entfernt werden, wurde aber von Hand geändert")
}
d.content = nil
return nil
}
// removeOrphaned räumt Dateien ab, die laut alter Lock Hugin gehören, im
// neuen Stand aber fehlen, auch wenn das Bundle kein entfernen dafür enthält.
func (p *planner) removeOrphaned() error {
next := p.next.Files()
old := p.old.Files()
pathList := make([]string, 0, len(old))
for relPath := range old {
pathList = append(pathList, relPath)
}
sort.Strings(pathList)
for _, relPath := range pathList {
if _, stays := next[relPath]; !stays {
if err := p.remove(relPath); err != nil {
return err
}
}
}
return nil
}
// removeOrphanedKeys entfernt JSON-Schlüssel und Listeneinträge, die
// laut alter Lock Hugin gehören, im neuen Stand aber fehlen.
func (p *planner) removeOrphanedKeys() error {
next := p.next.AllKeys()
for _, s := range p.old.AllKeys() {
var value any
if s.Mode == "list_item" {
value = s.Value
}
if lock.Find(next, s.File, s.Path, s.Mode, value) != nil {
continue
}
op := bundle.Operation{Op: "json_remove", File: s.File, Key: s.Path, Mode: s.Mode}
if s.Mode == "list_item" {
raw, err := json.Marshal(s.Value)
if err != nil {
return err
}
op.Value = raw
}
if err := p.removeJson(op); err != nil {
return err
}
}
return nil
}
// removeOrphanedBlocks entfernt verwaltete Blöcke, die im neuen Stand fehlen.
func (p *planner) removeOrphanedBlocks() error {
for key := range p.old.Common.Blocks {
if _, stays := p.next.Common.Blocks[key]; stays {
continue
}
file, block, _ := strings.Cut(key, "#")
d, err := p.load(file)
if err != nil {
return err
}
if _, exists := readBlock(string(d.content), block); !exists {
continue
}
empty := ""
if err := p.block(bundle.Operation{Op: "block", File: file, Block: block, Content: &empty}); err != nil {
return err
}
}
return nil
}
func (p *planner) jsonFile(rel, create string) (*jsonFile, error) {
d, err := p.load(rel)
if err != nil {
return nil, err
}
if d.json != nil {
return d.json, nil
}
raw, next := d.before, d.before == nil
if next {
if create != "" {
if raw, err = p.bundle.File(create); err != nil {
return nil, err
}
} else if path.Base(rel) == "opencode.json" {
// Von OpenCode empfohlen: Editoren prüfen die Datei dann beim Schreiben
raw = []byte("{\n \"$schema\": \"" + OpenCodeSchema + "\"\n}\n")
}
p.created(rel)
}
if d.json, err = readJson(raw, next); err != nil {
return nil, fmt.Errorf("%s ist kein gültiges JSON: %w", rel, err)
}
return d.json, nil
}
func (p *planner) created(rel string) {
if !slices.Contains(p.next.Common.Created, rel) {
p.next.Common.Created = append(p.next.Common.Created, rel)
}
}
func (p *planner) json(op bundle.Operation) error {
j, err := p.jsonFile(op.File, op.Create)
if err != nil {
return err
}
var value any
if err := json.Unmarshal(op.Value, &value); err != nil {
return err
}
location := op.File + " " + pointer(op.Key)
entry := lock.Key{File: op.File, Path: op.Key, Mode: op.Mode}
if op.ListItem() {
entry.Value = value
if err := j.addToList(op.Key, op.Value); err != nil {
p.finding(Conflict, location, err.Error())
return nil
}
} else {
entry.Sha256 = bundle.Sha256([]byte(canonical(value)))
if existing, ok := j.read(op.Key); ok && canonical(existing) != canonical(value) {
old := lock.Find(p.old.AllKeys(), op.File, op.Key, "", nil)
switch {
case old == nil:
p.finding(Conflict, location, "Schlüssel gibt es schon, er stammt nicht von Hugin")
case bundle.Sha256([]byte(canonical(existing))) != old.Sha256:
p.finding(ManualChange, location, "seit dem letzten sync von Hand geändert")
}
}
if err := j.set(op.Key, op.Value); err != nil {
p.finding(Conflict, location, err.Error())
return nil
}
}
if op.Package == "" {
p.next.Common.Keys = append(p.next.Common.Keys, entry)
} else {
pk := p.pkg(op.Package)
pk.Keys = append(pk.Keys, entry)
}
return nil
}
func (p *planner) removeJson(op bundle.Operation) error {
var value any
if len(op.Value) > 0 {
if err := json.Unmarshal(op.Value, &value); err != nil {
return err
}
}
old := lock.Find(p.old.AllKeys(), op.File, op.Key, op.Mode, value)
if old == nil {
return nil // nicht von Hugin
}
d, err := p.load(op.File)
if err != nil || d.before == nil {
return err
}
j, err := p.jsonFile(op.File, "")
if err != nil {
return err
}
if op.ListItem() {
return j.removeFromList(op.Key, op.Value)
}
if existing, ok := j.read(op.Key); ok && bundle.Sha256([]byte(canonical(existing))) != old.Sha256 {
p.finding(ManualChange, op.File+" "+pointer(op.Key), "soll entfernt werden, wurde aber von Hand geändert")
}
return j.remove(op.Key)
}
func (p *planner) block(op bundle.Operation) error {
d, err := p.load(op.File)
if err != nil {
return err
}
text := string(d.content)
if d.before == nil && *op.Content != "" {
if op.Create != "" {
raw, err := p.bundle.File(op.Create)
if err != nil {
return err
}
text = string(raw)
}
p.created(op.File)
}
key := op.File + "#" + op.Block
if existing, ok := readBlock(text, op.Block); ok {
if oldSha := p.old.Common.Blocks[key]; oldSha != "" &&
bundle.Sha256([]byte(existing)) != oldSha && existing != *op.Content {
p.finding(ManualChange, key, "Block seit dem letzten sync von Hand geändert")
}
}
// Leer heißt nicht löschen: ergebnis löscht nur Dateien, die Hugin angelegt hat.
d.content = append([]byte{}, setBlock(text, op.Block, *op.Content)...)
if *op.Content != "" {
if p.next.Common.Blocks == nil {
p.next.Common.Blocks = map[string]string{}
}
p.next.Common.Blocks[key] = bundle.Sha256([]byte(*op.Content))
}
return nil
}
func (p *planner) result(opt Options) (*Plan, error) {
plan := &Plan{Findings: p.findings, Write: map[string][]byte{}, Lock: p.next, options: opt}
names := make([]string, 0, len(p.files))
for rel := range p.files {
names = append(names, rel)
}
sort.Strings(names)
for _, rel := range names {
d := p.files[rel]
if d.json != nil {
d.content = d.json.bytes()
if d.json.empty() && p.old.Created(rel) {
d.content = nil
}
}
if len(d.content) == 0 && p.old.Created(rel) {
d.content = nil
}
switch {
case d.content == nil && d.before != nil:
plan.Delete = append(plan.Delete, rel)
p.forgotten(rel)
case d.content != nil && !bytes.Equal(d.content, d.before):
plan.Write[rel] = d.content
if d.before == nil {
plan.New = append(plan.New, rel)
}
default:
plan.unchanged++
}
}
// Pakete ohne eigene Dateien und Schlüssel (nur entfernen) gehören nicht in die Lock
for id, pk := range p.next.Packages {
if pk.Version == "" {
delete(p.next.Packages, id)
}
}
return plan, nil
}
func (p *planner) forgotten(rel string) {
p.next.Common.Created = slices.DeleteFunc(p.next.Common.Created, func(s string) bool { return s == rel })
}
// Run schreibt den Plan: erst alle neuen Inhalte in Hilfsdateien, dann
// umbenennen, dann löschen, zuletzt hugin.lock.
func (p *Plan) Run(repo string) error {
return p.RunWithLock(repo, filepath.Join(repo, lock.Filename))
}
// RunWithLock schreibt wie Run, die Lock-Datei aber nach lockPath
// (hugin try führt eine eigene, damit hugin.lock unberührt bleibt).
func (p *Plan) RunWithLock(repo, lockPath string) error {
if p.Open() {
return errors.New("offene Befunde; mit --overwrite setzt Hugin die Dateien zurück")
}
var helperFiles [][2]string
cleanUp := func() {
for _, h := range helperFiles {
os.Remove(h[0])
}
}
names := make([]string, 0, len(p.Write))
for rel := range p.Write {
names = append(names, rel)
}
sort.Strings(names)
for _, rel := range names {
abs, err := paths.InRepo(repo, rel)
if err != nil {
cleanUp()
return err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
cleanUp()
return err
}
tmp := abs + ".hugin-new"
if err := os.WriteFile(tmp, p.Write[rel], 0o644); err != nil {
cleanUp()
return err
}
helperFiles = append(helperFiles, [2]string{tmp, abs})
}
for _, h := range helperFiles {
if err := os.Rename(h[0], h[1]); err != nil {
cleanUp()
return err
}
}
for _, rel := range p.Delete {
abs, err := paths.InRepo(repo, rel)
if err != nil {
return err
}
if err := os.Remove(abs); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
removeEmptyFolders(repo, path.Dir(rel))
}
raw, err := p.Lock.Bytes()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
return err
}
return os.WriteFile(lockPath, raw, 0o644)
}
// removeEmptyFolders löscht rel und seine Eltern, solange sie leer sind.
func removeEmptyFolders(repo, rel string) {
for rel != "." && rel != "" && !strings.HasPrefix(rel, "..") {
if err := os.Remove(filepath.Join(repo, filepath.FromSlash(rel))); err != nil {
return
}
rel = path.Dir(rel)
}
}