feat(client): sync --force to escape the locally-modified skip

Managed items with local modifications were kept (skip + warn) with no
escape hatch other than deleting the deployed file by hand — after an
upstream change the deployed content silently stayed old, which reads
exactly like a stale cache.

--force turns the two skip cases into update (repository version wins)
and remove (upstream deleted + modified). Unmanaged items and never-
managed path occupancies stay untouched even with --force; the default
behavior is unchanged.
This commit is contained in:
m3ta-chiron committed 2026-10-02 12:34:57 +02:00
1 parent 2017329015
commit 1ab97bff43
6 files changed
+164 -14

No files matched your search

+12 -5
View File
@@ -47,8 +47,11 @@ type PlanEntry struct {
// Plan is a pure function from (inventory, manifest, on-disk facts) to an
// action plan. pathFor injects the target path mapping so the planner never
// touches the filesystem or the environment. The plan is sorted by
// (type, name) for deterministic execution and reporting.
func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pathFor func(typ, name string) string) []PlanEntry {
// (type, name) for deterministic execution and reporting. With force set,
// locally modified managed items are overwritten with the upstream version
// (and removed after upstream deletion) instead of being skipped; items
// agent-lib never managed are never touched, even with force.
func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pathFor func(typ, name string) string, force bool) []PlanEntry {
invByKey := map[string]PlannedItem{}
for _, it := range items {
invByKey[manifestKey(it.Type, it.Name)] = it
@@ -93,13 +96,15 @@ func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pat
emit(ActDeploy, it, "")
}
case inInv && it.Hash == rec.Hash:
decideAgainstDisk(it, rec, fact, emit, "unchanged")
decideAgainstDisk(it, rec, fact, emit, "unchanged", force)
case inInv:
decideAgainstDisk(it, rec, fact, emit, "changed")
decideAgainstDisk(it, rec, fact, emit, "changed", force)
case rec != nil && !fact.Exists:
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "already gone from disk; dropping manifest record")
case rec != nil && fact.Hash == rec.Hash:
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "upstream deleted")
case rec != nil && force:
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "upstream deleted; forced removal of the locally modified copy")
case rec != nil:
emit(ActSkip, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin},
"upstream deleted but the local copy was modified; keeping it")
@@ -111,7 +116,7 @@ func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pat
return plan
}
func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit func(Action, PlannedItem, string), upstreamState string) {
func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit func(Action, PlannedItem, string), upstreamState string, force bool) {
switch {
case !fact.Exists:
emit(ActUpdate, it, "managed item missing on disk; restoring")
@@ -121,6 +126,8 @@ func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit fu
} else {
emit(ActUpdate, it, "")
}
case force:
emit(ActUpdate, it, "forced: locally modified copy overwritten")
default:
emit(ActSkip, it, "locally modified; keeping the local version (upstream "+upstreamState+")")
}