aboutsummaryrefslogtreecommitdiff
path: root/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-11 17:05:03 -0500
committerCraig Jennings <c@cjennings.net>2026-06-11 17:05:03 -0500
commitda93ffd91dea133963ffceaff24d41bc76b8ff93 (patch)
tree6aac57d5eb712463a852c74e75150331be2298b1 /scripts/tests
parent61e37f55c044ff7bbd41cb142ce9dfe232934216 (diff)
downloadrulesets-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')
-rw-r--r--scripts/tests/update-skills.bats299
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"* ]]
+}