523 lines
14 KiB
Go
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)
|
|
}
|
|
}
|