feat: three-state sync planner with warning semantics

- Plan is a pure function (inventory, manifest, disk facts, injected path
  mapping) emitting exactly six actions: deploy, update, skip+warn,
  leave+warn, remove, keep — deterministic order, no filesystem access
- full 3x3 matrix (disk unmodified/user-modified/never-managed x upstream
  added/changed/removed) table-tested plus missing-disk restore cases,
  a mixed fixture and a purity/determinism guarantee test
- executor applies plans item by item (atomic deploy/remove per item),
  rewrites the manifest once, appends timestamped SKIP/LEAVE lines with
  item name and reason to the sync log
- sync now plans before mutating: unmanaged occupants block new deploys,
  upstream deletions remove only unmodified copies, local modifications
  always win and are warned about
This commit is contained in:
2026-08-23 10:36:44 +02:00
parent 49dbcf469e
commit 63619b90a4
7 changed files with 751 additions and 101 deletions
+21 -101
View File
@@ -1,15 +1,12 @@
package client
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/m3tam3re/agent-lib/internal/deploy"
"github.com/m3tam3re/agent-lib/internal/gitsource"
"github.com/m3tam3re/agent-lib/internal/lockfile"
"github.com/m3tam3re/agent-lib/internal/target"
)
@@ -18,18 +15,10 @@ type SyncOptions struct {
ConfigPath string
}
// SyncReport summarizes one sync run.
type SyncReport struct {
Revision string
Deployed int
Updated int
Kept int
Manifest string
}
// Sync pulls the work repository and deploys its content into the OpenCode
// target. Everything is validated and hashed before the first mutation;
// unreachable repositories fail cleanly with the previous state intact.
// Sync pulls the work repository, plans the sync as a pure function over
// (inventory, manifest, disk facts) and executes the plan. Validation and
// hashing happen before the first mutation; unreachable repositories fail
// cleanly with the previous state intact.
func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
cfg, err := LoadClientConfig(opts.ConfigPath)
if err != nil {
@@ -60,72 +49,30 @@ func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
return nil, err
}
type prepared struct {
item InventoryItem
hash string
dst string
}
var plan []prepared
items := make([]PlannedItem, 0, len(inv.Items))
for _, it := range inv.Deployable() {
dst, ok := oc.DeployPath(it.Type, it.Name)
if !ok {
if _, ok := oc.DeployPath(it.Type, it.Name); !ok {
continue
}
hash, err := HashItem(pull.Tree, it)
if err != nil {
return nil, fmt.Errorf("hashing %s/%s: %w", it.Type, it.Name, err)
}
if !safePath(dst) {
return nil, fmt.Errorf("refusing unsafe deploy path %q", dst)
}
plan = append(plan, prepared{item: it, hash: hash, dst: dst})
items = append(items, PlannedItem{Type: it.Type, Name: it.Name, Origin: it.Origin, RelPath: it.RelPath, Hash: hash})
}
report := &SyncReport{Revision: pull.Rev, Manifest: paths.Manifest}
for _, p := range plan {
key := manifestKey(p.item.Type, p.item.Name)
record := manifest.Items[key]
if record != nil && record.Hash == p.hash {
if diskHash, err := HashDiskPath(p.item.Type, p.dst); err == nil && diskHash == p.hash {
report.Kept++
continue
}
}
if err := deployItem(pull.Tree, p.item, p.dst); err != nil {
return nil, fmt.Errorf("deploying %s/%s: %w", p.item.Type, p.item.Name, err)
}
manifest.Items[key] = &ManifestItem{
Name: p.item.Name, Type: p.item.Type, Origin: p.item.Origin,
Revision: pull.Rev, Hash: p.hash,
}
if record == nil {
report.Deployed++
} else {
report.Updated++
pathFor := func(typ, name string) string {
path, _ := oc.DeployPath(typ, name)
return path
}
plan := Plan(items, manifest, diskFacts(oc, manifest, items), pathFor)
for _, e := range plan {
if !safePath(e.DstPath) {
return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath)
}
}
manifest.Version = ManifestVersion
manifest.Revision = pull.Rev
unchanged, err := manifestUnchanged(manifest, paths.Manifest)
if err != nil {
return nil, err
}
if !unchanged {
if err := manifest.Save(paths.Manifest); err != nil {
return nil, err
}
}
printSyncReport(stdout, report, len(inv.Items))
return report, nil
}
func deployItem(tree *gitsource.GitTree, it InventoryItem, dst string) error {
if it.Type == lockfile.TypeSkill {
return deploy.Dir(tree, it.RelPath, dst)
}
return deploy.File(tree, it.RelPath, dst)
return ExecutePlan(pull.Tree, plan, manifest, paths, pull.Rev, stdout)
}
// safePath rejects destinations that escape the user's home directory.
@@ -134,39 +81,12 @@ func safePath(dst string) bool {
if err != nil {
return false
}
if dst == home {
return false
}
rel, err := filepath.Rel(home, dst)
if err != nil {
return false
}
return rel != ".." && !filepath.IsAbs(rel)
}
func manifestUnchanged(m *Manifest, path string) (bool, error) {
existing, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
fresh, err := marshalManifest(m)
if err != nil {
return false, err
}
return bytes.Equal(existing, fresh), nil
}
func printSyncReport(w io.Writer, r *SyncReport, totalItems int) {
fmt.Fprintf(w, "synced to revision %s\n", shortRev(r.Revision))
fmt.Fprintf(w, " deployed: %d\n", r.Deployed)
fmt.Fprintf(w, " updated: %d\n", r.Updated)
fmt.Fprintf(w, " kept: %d\n", r.Kept)
fmt.Fprintf(w, " manifest: %s\n", r.Manifest)
}
func shortRev(rev string) string {
if len(rev) > 12 {
return rev[:12]
}
return rev
return rel != ".." && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}