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

+52 -4
View File
@@ -98,7 +98,7 @@ func TestPlanMatrix(t *testing.T) {
diskFacts[key] = DiskFact{Exists: true, Hash: hashMod}
}
plan := Plan(items, manifest, diskFacts, testPathFor)
plan := Plan(items, manifest, diskFacts, testPathFor, false)
if tc.disk == diskNone && tc.up == upAbsent {
if len(plan) != 0 {
t.Fatalf("a key present nowhere must produce no entry, got %+v", plan)
@@ -123,6 +123,54 @@ func TestPlanMatrix(t *testing.T) {
}
}
// TestPlanForceMatrix asserts how --force changes the skip cases: locally
// modified managed items become updates (or removes after upstream
// deletion), while never-managed occupancies stay skipped even with force.
func TestPlanForceMatrix(t *testing.T) {
cases := []struct {
name string
up string // "changed", "unchanged", "gone"
want Action
}{
{"modified/changed", "changed", ActUpdate},
{"modified/unchanged", "unchanged", ActUpdate},
{"modified/gone", "gone", ActRemove},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
key := manifestKey("skills", "alpha")
var items []PlannedItem
manifest := NewManifest()
manifest.Items[key] = &ManifestItem{Name: "alpha", Type: "skills", Hash: hashV1}
if tc.up != "gone" {
hash := hashV2
if tc.up == "unchanged" {
hash = hashV1
}
items = append(items, PlannedItem{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hash})
}
disk := map[string]DiskFact{key: {Exists: true, Hash: hashMod}}
plan := Plan(items, manifest, disk, testPathFor, true)
if len(plan) != 1 {
t.Fatalf("want exactly 1 entry, got %d: %+v", len(plan), plan)
}
if plan[0].Action != tc.want {
t.Errorf("action = %s, want %s (reason: %s)", plan[0].Action, tc.want, plan[0].Reason)
}
})
}
t.Run("never-managed occupancy stays skipped", func(t *testing.T) {
items := []PlannedItem{{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hashV1}}
disk := map[string]DiskFact{manifestKey("skills", "alpha"): {Exists: true, Hash: hashMod}}
plan := Plan(items, NewManifest(), disk, testPathFor, true)
if len(plan) != 1 || plan[0].Action != ActSkip {
t.Fatalf("force must not deploy over a never-managed item: %+v", plan)
}
})
}
// TestPlanMixedFixture combines several states in one plan and asserts the
// full deterministic output.
func TestPlanMixedFixture(t *testing.T) {
@@ -151,7 +199,7 @@ func TestPlanMixedFixture(t *testing.T) {
"agents/helper": {Exists: true, Hash: hashV1}, // unmodified × unchanged
}
plan := Plan(items, manifest, disk, testPathFor)
plan := Plan(items, manifest, disk, testPathFor, false)
got := map[string]Action{}
for _, e := range plan {
got[e.Type+"/"+e.Name] = e.Action
@@ -195,8 +243,8 @@ func TestPlanPureGuarantee(t *testing.T) {
manifest := manifestWith(map[string]*ManifestItem{"skills/b": {Name: "b", Type: "skills", Hash: hashV1}})
disk := map[string]DiskFact{"skills/b": {Exists: true, Hash: hashV1}}
p1 := Plan(items, manifest, disk, testPathFor)
p2 := Plan(items, manifest, disk, testPathFor)
p1 := Plan(items, manifest, disk, testPathFor, false)
p2 := Plan(items, manifest, disk, testPathFor, false)
if len(p1) != len(p2) {
t.Fatal("same inputs must yield same plan length")
}