diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-11 17:05:03 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-11 17:05:03 -0500 |
| commit | da93ffd91dea133963ffceaff24d41bc76b8ff93 (patch) | |
| tree | 6aac57d5eb712463a852c74e75150331be2298b1 /scripts/tests/update-skills.bats | |
| parent | 61e37f55c044ff7bbd41cb142ce9dfe232934216 (diff) | |
| download | rulesets-da93ffd91dea133963ffceaff24d41bc76b8ff93.tar.gz rulesets-da93ffd91dea133963ffceaff24d41bc76b8ff93.zip | |
feat(commands): /update-skills syncs forks with upstream via 3-way merge
Upstream releases fixes worth pulling into the forks (arch-decide, playwright-js, playwright-py) without losing our local modifications. Each fork now has a manifest at upstreams/<name>/ plus a committed baseline snapshot that is the 3-way merge base. scripts/update-skills.py classifies each file's drift and merges to stdout. The command owns per-file confirmation, per-hunk conflict prompts, and every target write.
I centralized manifests under upstreams/ instead of per-skill dotfile dirs because arch-decide is now two flat files in commands/ and can't carry one. A "files" map in its manifest handles the upstream rename of SKILL.md to arch-decide.md.
I seeded baselines from today's upstream HEADs, so pre-existing local modifications classify as local-only from here on. git merge-file signals hard errors as exit 255, which subprocess reports as positive. The guard treats anything 128 and up as an error so a binary-file failure isn't misread as a conflict.
Diffstat (limited to 'scripts/tests/update-skills.bats')
| -rw-r--r-- | scripts/tests/update-skills.bats | 299 |
1 files changed, 299 insertions, 0 deletions
diff --git a/scripts/tests/update-skills.bats b/scripts/tests/update-skills.bats new file mode 100644 index 0000000..d74da1c --- /dev/null +++ b/scripts/tests/update-skills.bats @@ -0,0 +1,299 @@ +#!/usr/bin/env bats +# update-skills.py keeps forked skills/commands in sync with their upstreams +# via per-fork manifests (upstreams/<name>/manifest.json), a committed baseline +# snapshot (upstreams/<name>/baseline/), and 3-way merges against it. The +# script is read-only against fork targets: check classifies, merge-file +# merges to stdout; only bootstrap and mark-synced write (manifest + baseline). + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + SCRIPT="$REPO_ROOT/scripts/update-skills.py" + TMP="$(mktemp -d)" + CACHE="$TMP/cache" +} + +teardown() { + rm -rf "$TMP" +} + +# --- fixture helpers ------------------------------------------------------- + +git_up() { + git -C "$TMP/up" -c user.name=test -c user.email=test@test "$@" +} + +make_upstream() { + mkdir -p "$TMP/up/skills/demo" + git -C "$TMP/up" init -q -b main + printf 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n' \ + > "$TMP/up/skills/demo/SKILL.md" + printf 'def helper():\n return 1\n' > "$TMP/up/skills/demo/helper.py" + git_up add -A + git_up commit -qm "initial" +} + +make_repo() { + mkdir -p "$TMP/root/upstreams/demo" "$TMP/root/demo-skill" + cp "$TMP/up/skills/demo/SKILL.md" "$TMP/up/skills/demo/helper.py" \ + "$TMP/root/demo-skill/" + cat > "$TMP/root/upstreams/demo/manifest.json" <<EOF +{ + "name": "demo", + "url": "file://$TMP/up", + "ref": "main", + "subpath": "skills/demo", + "target": "demo-skill", + "license": "MIT", + "last_synced_commit": null +} +EOF +} + +run_us() { + run python3 "$SCRIPT" --root "$TMP/root" --cache "$CACHE" "$@" +} + +bootstrap_demo() { + python3 "$SCRIPT" --root "$TMP/root" --cache "$CACHE" bootstrap demo +} + +# --- list ------------------------------------------------------------------ + +@test "list shows fork with never-synced marker" { + make_upstream + make_repo + run_us list + [ "$status" -eq 0 ] + [[ "$output" == *"demo"* ]] + [[ "$output" == *"never synced"* ]] +} + +# --- bootstrap ------------------------------------------------------------- + +@test "bootstrap snapshots baseline and records upstream commit" { + make_upstream + make_repo + run_us bootstrap demo + [ "$status" -eq 0 ] + [ -f "$TMP/root/upstreams/demo/baseline/SKILL.md" ] + [ -f "$TMP/root/upstreams/demo/baseline/helper.py" ] + sha=$(git_up rev-parse HEAD) + grep -q "$sha" "$TMP/root/upstreams/demo/manifest.json" +} + +# --- check classification -------------------------------------------------- + +@test "check classifies identical tree as unchanged" { + make_upstream + make_repo + bootstrap_demo + run_us check demo + [ "$status" -eq 0 ] + [[ "$output" == *"unchanged"* ]] + [[ "$output" != *"both-changed"* ]] + [[ "$output" != *"local-only"* ]] + [[ "$output" != *"upstream-changed"* ]] +} + +@test "check classifies upstream-changed after upstream edit" { + make_upstream + make_repo + bootstrap_demo + sed -i 's/line1/line1 upstream edit/' "$TMP/up/skills/demo/SKILL.md" + git_up commit -qam "upstream edit" + run_us check demo + [ "$status" -eq 0 ] + [[ "$output" == *"upstream-changed"*"SKILL.md"* ]] +} + +@test "check classifies local-only after target edit" { + make_upstream + make_repo + bootstrap_demo + sed -i 's/line8/line8 local edit/' "$TMP/root/demo-skill/SKILL.md" + run_us check demo + [ "$status" -eq 0 ] + [[ "$output" == *"local-only"*"SKILL.md"* ]] +} + +@test "check classifies both-changed when both sides edited" { + make_upstream + make_repo + bootstrap_demo + sed -i 's/line1/line1 upstream edit/' "$TMP/up/skills/demo/SKILL.md" + git_up commit -qam "upstream edit" + sed -i 's/line8/line8 local edit/' "$TMP/root/demo-skill/SKILL.md" + run_us check demo + [ "$status" -eq 0 ] + [[ "$output" == *"both-changed"*"SKILL.md"* ]] +} + +@test "check classifies upstream-new, local-new, and upstream-deleted" { + make_upstream + make_repo + bootstrap_demo + echo "new upstream file" > "$TMP/up/skills/demo/new-up.md" + git_up rm -q skills/demo/helper.py + git_up add -A + git_up commit -qm "add one, delete one" + echo "new local file" > "$TMP/root/demo-skill/new-local.md" + run_us check demo + [ "$status" -eq 0 ] + [[ "$output" == *"upstream-new"*"new-up.md"* ]] + [[ "$output" == *"local-new"*"new-local.md"* ]] + [[ "$output" == *"upstream-deleted"*"helper.py"* ]] +} + +@test "check --json emits valid JSON with upstream commit and files" { + make_upstream + make_repo + bootstrap_demo + run_us check demo --json + [ "$status" -eq 0 ] + echo "$output" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +assert d["name"] == "demo" +assert len(d["upstream_commit"]) == 40 +assert any(f["path"] == "SKILL.md" for f in d["files"]) +' +} + +@test "check without baseline degrades to no-baseline statuses" { + make_upstream + make_repo + sed -i 's/line8/line8 local edit/' "$TMP/root/demo-skill/SKILL.md" + run_us check demo + [ "$status" -eq 0 ] + [[ "$output" == *"no-baseline"* ]] +} + +# --- merge-file ------------------------------------------------------------ + +@test "merge-file merges non-overlapping edits cleanly" { + make_upstream + make_repo + bootstrap_demo + sed -i 's/line1/line1 upstream edit/' "$TMP/up/skills/demo/SKILL.md" + git_up commit -qam "upstream edit" + sed -i 's/line8/line8 local edit/' "$TMP/root/demo-skill/SKILL.md" + python3 "$SCRIPT" --root "$TMP/root" --cache "$CACHE" check demo > /dev/null + run_us merge-file demo SKILL.md + [ "$status" -eq 0 ] + [[ "$output" == *"line1 upstream edit"* ]] + [[ "$output" == *"line8 local edit"* ]] + [[ "$output" != *"<<<<<<<"* ]] +} + +@test "merge-file emits conflict markers on overlapping edits" { + make_upstream + make_repo + bootstrap_demo + sed -i 's/line4/line4 upstream edit/' "$TMP/up/skills/demo/SKILL.md" + git_up commit -qam "upstream edit" + sed -i 's/line4/line4 local edit/' "$TMP/root/demo-skill/SKILL.md" + python3 "$SCRIPT" --root "$TMP/root" --cache "$CACHE" check demo > /dev/null + run_us merge-file demo SKILL.md + [ "$status" -eq 1 ] + [[ "$output" == *"<<<<<<<"* ]] + [[ "$output" == *"line4 upstream edit"* ]] + [[ "$output" == *"line4 local edit"* ]] +} + +@test "merge-file reports a hard git error instead of masking it as a conflict" { + make_upstream + make_repo + bootstrap_demo + printf 'up\x00stream' > "$TMP/up/skills/demo/SKILL.md" + git_up commit -qam "binary upstream" + printf 'lo\x00cal' > "$TMP/root/demo-skill/SKILL.md" + python3 "$SCRIPT" --root "$TMP/root" --cache "$CACHE" check demo > /dev/null + run_us merge-file demo SKILL.md + [ "$status" -eq 2 ] + [[ "$output" == *"merge-file failed"* ]] +} + +# --- mark-synced ----------------------------------------------------------- + +@test "mark-synced refreshes baseline and last_synced_commit" { + make_upstream + make_repo + bootstrap_demo + sed -i 's/line1/line1 upstream edit/' "$TMP/up/skills/demo/SKILL.md" + git_up commit -qam "upstream edit" + sha=$(git_up rev-parse HEAD) + python3 "$SCRIPT" --root "$TMP/root" --cache "$CACHE" check demo > /dev/null + run_us mark-synced demo + [ "$status" -eq 0 ] + grep -q "line1 upstream edit" "$TMP/root/upstreams/demo/baseline/SKILL.md" + grep -q "$sha" "$TMP/root/upstreams/demo/manifest.json" +} + +# --- files map (the arch-decide shape) -------------------------------------- + +@test "files map restricts tracking to mapped files under target paths" { + make_upstream + make_repo + mkdir -p "$TMP/root/commands" + cp "$TMP/up/skills/demo/SKILL.md" "$TMP/root/commands/demo.md" + cat > "$TMP/root/upstreams/demo/manifest.json" <<EOF +{ + "name": "demo", + "url": "file://$TMP/up", + "ref": "main", + "subpath": "skills/demo", + "target": "commands", + "files": {"SKILL.md": "demo.md"}, + "license": "MIT", + "last_synced_commit": null +} +EOF + bootstrap_demo + [ -f "$TMP/root/upstreams/demo/baseline/demo.md" ] + run_us check demo + [ "$status" -eq 0 ] + [[ "$output" == *"demo.md"* ]] + # helper.py exists upstream but is unmapped — must not be tracked + [[ "$output" != *"helper.py"* ]] +} + +# --- exclusions -------------------------------------------------------------- + +@test "dependency and cache dirs excluded from classification" { + make_upstream + make_repo + bootstrap_demo + mkdir -p "$TMP/root/demo-skill/node_modules/x" "$TMP/root/demo-skill/__pycache__" + echo "x" > "$TMP/root/demo-skill/node_modules/x/x.js" + echo "x" > "$TMP/root/demo-skill/__pycache__/y.pyc" + run_us check demo + [ "$status" -eq 0 ] + [[ "$output" != *"node_modules"* ]] + [[ "$output" != *"__pycache__"* ]] +} + +# --- errors ------------------------------------------------------------------ + +@test "unknown fork errors and names it" { + make_upstream + make_repo + run_us check nosuchfork + [ "$status" -ne 0 ] + [[ "$output" == *"nosuchfork"* ]] +} + +@test "unreachable upstream degrades with a clear error" { + make_upstream + make_repo + python3 - "$TMP/root/upstreams/demo/manifest.json" <<'EOF' +import json, sys +p = sys.argv[1] +d = json.load(open(p)) +d["url"] = "file:///nonexistent/upstream/repo" +json.dump(d, open(p, "w"), indent=2) +EOF + run_us check demo + [ "$status" -ne 0 ] + [[ "$output" == *"demo"* ]] + [[ "$output" == *"clone"* ]] +} |
