- 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
181 lines
5.9 KiB
Go
181 lines
5.9 KiB
Go
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")
|
|
}
|
|
}
|