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:
+21
-101
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user