// 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) } }