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:
@@ -0,0 +1,180 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func syncOnce(t *testing.T, env *syncEnv) {
|
||||
t.Helper()
|
||||
if out, err := env.run(t); err != nil {
|
||||
t.Fatalf("sync failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func commitWorkRepo(t *testing.T, h *harness, dir, msg string) {
|
||||
t.Helper()
|
||||
h.git(t, dir, "add", "-A")
|
||||
h.git(t, dir, "commit", "-m", msg)
|
||||
}
|
||||
|
||||
func TestSyncSkipsLocallyModifiedItem(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newSyncEnv(t, h)
|
||||
buildWorkRepo(t, h, env.workRepo)
|
||||
env.writeConfig(t, env.workRepo, "")
|
||||
syncOnce(t, env)
|
||||
|
||||
skillPath := filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md")
|
||||
os.WriteFile(skillPath, []byte("---\nname: Own Skill\n---\n# MY local edits\n"), 0o644)
|
||||
|
||||
p := filepath.Join(env.workRepo, "skills/own-skill/SKILL.md")
|
||||
os.WriteFile(p, []byte("---\nname: Own Skill\ndescription: changed upstream\n---\n# Upstream v2\n"), 0o644)
|
||||
commitWorkRepo(t, h, env.workRepo, "change own-skill upstream")
|
||||
|
||||
out, err := env.run(t)
|
||||
if err != nil {
|
||||
t.Fatalf("sync with local mods must not fail: %v\n%s", err, out)
|
||||
}
|
||||
if !contains(out, "skipped (local modifications kept): 1") {
|
||||
t.Errorf("modified item must be skipped with a warning:\n%s", out)
|
||||
}
|
||||
|
||||
kept := readFile(t, skillPath)
|
||||
if !contains(kept, "MY local edits") {
|
||||
t.Error("local version must be kept, not overwritten")
|
||||
}
|
||||
|
||||
log := readFile(t, filepath.Join(env.stateDir, "sync.log"))
|
||||
if !contains(log, "SKIP skills/own-skill") || !contains(log, "locally modified") {
|
||||
t.Errorf("warning log must record item and reason:\n%s", log)
|
||||
}
|
||||
|
||||
manifest := readFile(t, filepath.Join(env.stateDir, "manifest.json"))
|
||||
if !contains(manifest, "own-skill") {
|
||||
t.Error("skipped item must keep its manifest record for future syncs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLeavesSelfCreatedItemsUntouched(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newSyncEnv(t, h)
|
||||
buildWorkRepo(t, h, env.workRepo)
|
||||
env.writeConfig(t, env.workRepo, "")
|
||||
syncOnce(t, env)
|
||||
|
||||
mineDir := filepath.Join(env.home, ".agents/skills/my-secret-skill")
|
||||
os.MkdirAll(mineDir, 0o755)
|
||||
os.WriteFile(filepath.Join(mineDir, "SKILL.md"), []byte("---\nname: Mine\n---\n# private\n"), 0o644)
|
||||
|
||||
out, err := env.run(t)
|
||||
if err != nil {
|
||||
t.Fatalf("sync must not fail on unmanaged items: %v\n%s", err, out)
|
||||
}
|
||||
if !contains(out, "unmanaged items left untouched: 1") {
|
||||
t.Errorf("self-created item must be reported as left:\n%s", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(mineDir, "SKILL.md")); err != nil {
|
||||
t.Error("self-created item must survive untouched")
|
||||
}
|
||||
|
||||
log := readFile(t, filepath.Join(env.stateDir, "sync.log"))
|
||||
if !contains(log, "LEAVE skills/my-secret-skill") || !contains(log, "unmanaged local item") {
|
||||
t.Errorf("leave warning must be logged with reason:\n%s", log)
|
||||
}
|
||||
|
||||
manifest := readFile(t, filepath.Join(env.stateDir, "manifest.json"))
|
||||
if contains(manifest, "my-secret-skill") {
|
||||
t.Error("unmanaged items must never enter the manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRemovesUpstreamDeletedItems(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newSyncEnv(t, h)
|
||||
buildWorkRepo(t, h, env.workRepo)
|
||||
env.writeConfig(t, env.workRepo, "")
|
||||
syncOnce(t, env)
|
||||
|
||||
os.RemoveAll(filepath.Join(env.workRepo, "skills/own-skill"))
|
||||
os.Remove(filepath.Join(env.workRepo, "agents/helper.md"))
|
||||
os.Remove(filepath.Join(env.workRepo, "commands/deploy.md"))
|
||||
commitWorkRepo(t, h, env.workRepo, "remove everything deployable")
|
||||
|
||||
modifiedAgent := filepath.Join(env.home, ".config/opencode/agents/helper.md")
|
||||
os.WriteFile(modifiedAgent, []byte("---\nname: Helper\n---\n# my tweaks\n"), 0o644)
|
||||
|
||||
out, err := env.run(t)
|
||||
if err != nil {
|
||||
t.Fatalf("sync with removals failed: %v\n%s", err, out)
|
||||
}
|
||||
if !contains(out, "removed: 2") {
|
||||
t.Errorf("unmodified deleted items must be removed:\n%s", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(env.home, ".agents/skills/own-skill")); err == nil {
|
||||
t.Error("unmodified skill must be removed after upstream deletion")
|
||||
}
|
||||
if _, err := os.Stat(modifiedAgent); err != nil {
|
||||
t.Error("locally modified agent must survive the upstream deletion")
|
||||
}
|
||||
if !contains(out, "skipped (local modifications kept): 1") {
|
||||
t.Errorf("modified+deleted item must be skipped with warning:\n%s", out)
|
||||
}
|
||||
|
||||
log := readFile(t, filepath.Join(env.stateDir, "sync.log"))
|
||||
if !contains(log, "upstream deleted but the local copy was modified") {
|
||||
t.Errorf("log must explain the kept deletion:\n%s", log)
|
||||
}
|
||||
|
||||
manifest := readFile(t, filepath.Join(env.stateDir, "manifest.json"))
|
||||
if contains(manifest, "own-skill") || contains(manifest, "deploy") {
|
||||
t.Errorf("removed items must leave the manifest:\n%s", manifest)
|
||||
}
|
||||
if !contains(manifest, "helper") {
|
||||
t.Error("kept modified item must keep its manifest record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRestoresDeletedManagedItem(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newSyncEnv(t, h)
|
||||
buildWorkRepo(t, h, env.workRepo)
|
||||
env.writeConfig(t, env.workRepo, "")
|
||||
syncOnce(t, env)
|
||||
|
||||
os.RemoveAll(filepath.Join(env.home, ".agents/skills/own-skill"))
|
||||
out, err := env.run(t)
|
||||
if err != nil {
|
||||
t.Fatalf("restore sync failed: %v\n%s", err, out)
|
||||
}
|
||||
if !contains(out, "updated: 1") {
|
||||
t.Errorf("locally deleted managed item must be restored:\n%s", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md")); err != nil {
|
||||
t.Error("item must be back on disk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncNewUpstreamItemBlockedByUnmanagedOccupant(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newSyncEnv(t, h)
|
||||
buildWorkRepo(t, h, env.workRepo)
|
||||
|
||||
mineDir := filepath.Join(env.home, ".agents/skills/own-skill")
|
||||
os.MkdirAll(mineDir, 0o755)
|
||||
os.WriteFile(filepath.Join(mineDir, "SKILL.md"), []byte("---\nname: My Own\n---\n# private version\n"), 0o644)
|
||||
|
||||
env.writeConfig(t, env.workRepo, "")
|
||||
out, err := env.run(t)
|
||||
if err != nil {
|
||||
t.Fatalf("sync must not fail on occupant conflict: %v\n%s", err, out)
|
||||
}
|
||||
if !contains(out, "skipped (local modifications kept): 1") {
|
||||
t.Errorf("new item blocked by unmanaged occupant must be skipped:\n%s", out)
|
||||
}
|
||||
kept := readFile(t, filepath.Join(mineDir, "SKILL.md"))
|
||||
if !contains(kept, "private version") {
|
||||
t.Error("occupant content must be preserved")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user