From da93ffd91dea133963ffceaff24d41bc76b8ff93 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 11 Jun 2026 17:05:03 -0500 Subject: 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// 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. --- scripts/tests/update-skills.bats | 299 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 scripts/tests/update-skills.bats (limited to 'scripts/tests') 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//manifest.json), a committed baseline +# snapshot (upstreams//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" < "$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" < "$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"* ]] +} -- cgit v1.2.3