Bladeren bron

Use committed Codex plugin files in sync script

- commit .codex-plugin/plugin.json and brand assets in this repo
- sync tracked Codex plugin files instead of generating or seeding them
- honor upstream gitignored files during rsync
- cover the new sync behavior with regression tests
Jesse Vincent 4 maanden geleden
bovenliggende
commit
6efe32c9e2

+ 44 - 0
.codex-plugin/plugin.json

@@ -0,0 +1,44 @@
+{
+  "name": "superpowers",
+  "version": "5.0.7",
+  "description": "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.",
+  "author": {
+    "name": "Jesse Vincent",
+    "email": "jesse@fsck.com",
+    "url": "https://github.com/obra"
+  },
+  "homepage": "https://github.com/obra/superpowers",
+  "repository": "https://github.com/obra/superpowers",
+  "license": "MIT",
+  "keywords": [
+    "brainstorming",
+    "subagent-driven-development",
+    "skills",
+    "planning",
+    "tdd",
+    "debugging",
+    "code-review",
+    "workflow"
+  ],
+  "skills": "./skills/",
+  "interface": {
+    "displayName": "Superpowers",
+    "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents",
+    "longDescription": "Use Superpowers to guide agent work through brainstorming, implementation planning, test-driven development, systematic debugging, parallel execution, code review, and finish-the-branch workflows.",
+    "developerName": "Jesse Vincent",
+    "category": "Coding",
+    "capabilities": [
+      "Interactive",
+      "Read",
+      "Write"
+    ],
+    "defaultPrompt": [
+      "I've got an idea for something I'd like to build.",
+      "Let's add a feature to this project."
+    ],
+    "brandColor": "#F59E0B",
+    "composerIcon": "./assets/superpowers-small.svg",
+    "logo": "./assets/app-icon.png",
+    "screenshots": []
+  }
+}

+ 1 - 0
.version-bump.json

@@ -3,6 +3,7 @@
     { "path": "package.json", "field": "version" },
     { "path": ".claude-plugin/plugin.json", "field": "version" },
     { "path": ".cursor-plugin/plugin.json", "field": "version" },
+    { "path": ".codex-plugin/plugin.json", "field": "version" },
     { "path": ".claude-plugin/marketplace.json", "field": "plugins.0.version" },
     { "path": "gemini-extension.json", "field": "version" }
   ],

BIN
assets/app-icon.png


+ 1 - 0
assets/superpowers-small.svg

@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M394.28,207.8c.81,2.41,1.39,4.78,1.8,7.07,1.61,9.03-.93,17.78-5.99,21.74-22.6,17.7-49.85,29.35-75.34,38.6-.59.22-1.09.28-1.4.34-2.22.47-4.95,1.04-7.25,0-1.46-.66-2.25-1.74-2.66-2.3-1.56-2.1-1.59-4.31-1.56-5.13.1-2.67-.01-4.69,0-4.82.45-3.52.91-10.66,1.41-21.28.6-3.87,2.16-9.63,6.94-13.96,4.01-3.62,8.33-4.6,14.59-5.87,10.76-2.19,37.21-8.22,47.42-16.56,1.63-1.33,2.97-2.65,4.19-3.96,3.72-3.99,6.39-7.92,7.93-10.36,3.22,3.22,7.25,8.48,9.92,16.47Z"/><path d="M428.67,185.28c-2.33,11.99-8.91,22.32-15.88,30.38.27-5.5-.05-12.11-1.86-19.08-5.04-19.36-19.74-34.7-37.78-37.78-32.21-9.74-70.59,3.79-99.08,18.29-3.87,1.95-9.52-2.77-11.84-8.16-3.32-7.71-1.63-6.28,2.61-8.49,38.31-20.03,82.01-39.61,123.91-29.7,8.26,1.95,15.96,5.26,23.48,10.54,11.32,7.96,20.21,24.74,16.44,44Z"/><path d="M117.72,304.2c-.81-2.41-1.39-4.78-1.8-7.07-1.61-9.03.93-17.78,5.99-21.74,22.6-17.7,49.85-29.35,75.34-38.6.59-.22,1.09-.28,1.4-.34,2.22-.47,4.95-1.04,7.25,0,1.46.66,2.25,1.74,2.66,2.3,1.56,2.1,1.59,4.31,1.56,5.13-.1,2.67.01,4.69,0,4.82-.45,3.52-.91,10.66-1.41,21.28-.6,3.87-2.16,9.63-6.94,13.96-4.01,3.62-8.33,4.6-14.59,5.87-10.76,2.19-37.21,8.22-47.42,16.56-1.63,1.33-2.97,2.65-4.19,3.96-3.72,3.99-6.39,7.92-7.93,10.36-3.22-3.22-7.25-8.48-9.92-16.47Z"/><path d="M83.33,326.72c2.33-11.99,8.91-22.32,15.88-30.38-.27,5.5.05,12.11,1.86,19.08,5.04,19.36,19.74,34.7,37.78,37.78,32.21,9.74,70.59-3.79,99.08-18.29,3.87-1.95,9.52,2.77,11.84,8.16,3.32,7.71,1.63,6.28-2.61,8.49-38.31,20.03-82.01,39.61-123.91,29.7-8.26-1.95-15.96-5.26-23.48-10.54-11.32-7.96-20.21-24.74-16.44-44Z"/><ellipse cx="255.16" cy="258.86" rx="28.95" ry="28.76"/></svg>

+ 162 - 120
scripts/sync-to-codex-plugin.sh

@@ -3,9 +3,9 @@
 # sync-to-codex-plugin.sh
 #
 # Sync this superpowers checkout → prime-radiant-inc/openai-codex-plugins.
-# Clones the fork fresh into a temp dir, rsyncs upstream content, regenerates
-# the Codex overlay file (.codex-plugin/plugin.json) inline, commits, pushes a
-# sync branch, and opens a PR.
+# Clones the fork fresh into a temp dir, rsyncs tracked upstream plugin content
+# (including committed Codex files under .codex-plugin/ and assets/), commits,
+# pushes a sync branch, and opens a PR.
 # Path/user agnostic — auto-detects upstream from script location.
 #
 # Deterministic: running twice against the same upstream SHA produces PRs with
@@ -17,13 +17,11 @@
 #   ./scripts/sync-to-codex-plugin.sh -y                           # skip confirm
 #   ./scripts/sync-to-codex-plugin.sh --local PATH                 # existing checkout
 #   ./scripts/sync-to-codex-plugin.sh --base BRANCH                # default: main
-#   ./scripts/sync-to-codex-plugin.sh --bootstrap --assets-src DIR # create initial plugin
+#   ./scripts/sync-to-codex-plugin.sh --bootstrap                  # create plugin dir if missing
 #
-# Bootstrap mode: skips the "plugin must exist on base" check and seeds
-# plugins/superpowers/assets/ from --assets-src <dir> which must contain
-# PrimeRadiant_Favicon.svg and PrimeRadiant_Favicon.png. Run once by one
-# team member to create the initial PR; every subsequent run is a normal
-# (non-bootstrap) sync.
+# Bootstrap mode: skips the "plugin must exist on base" requirement and creates
+# plugins/superpowers/ when absent, then copies the tracked plugin files from
+# upstream just like a normal sync.
 #
 # Requires: bash, rsync, git, gh (authenticated), python3.
 
@@ -38,9 +36,6 @@ DEFAULT_BASE="main"
 DEST_REL="plugins/superpowers"
 
 # Paths in upstream that should NOT land in the embedded plugin.
-# The Codex-only paths are here too — they're managed by generate/bootstrap
-# steps, not by rsync.
-#
 # All patterns use a leading "/" to anchor them to the source root.
 # Unanchored patterns like "scripts/" would match any directory named
 # "scripts" at any depth — including legitimate nested dirs like
@@ -78,68 +73,57 @@ EXCLUDES=(
   "/scripts/"
   "/tests/"
   "/tmp/"
-
-  # Codex-only paths — managed outside rsync
-  "/.codex-plugin/"
-  "/assets/"
 )
 
 # =============================================================================
-# Generated overlay file
+# Ignored-path helpers
 # =============================================================================
 
-# Writes the Codex plugin manifest to "$1" with the given upstream version.
-# Args: dest_path, version
-generate_plugin_json() {
-  local dest="$1"
-  local version="$2"
-  mkdir -p "$(dirname "$dest")"
-  cat > "$dest" <<EOF
-{
-  "name": "superpowers",
-  "version": "$version",
-  "description": "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.",
-  "author": {
-    "name": "Jesse Vincent",
-    "email": "jesse@fsck.com",
-    "url": "https://github.com/obra"
-  },
-  "homepage": "https://github.com/obra/superpowers",
-  "repository": "https://github.com/obra/superpowers",
-  "license": "MIT",
-  "keywords": [
-    "brainstorming",
-    "subagent-driven-development",
-    "skills",
-    "planning",
-    "tdd",
-    "debugging",
-    "code-review",
-    "workflow"
-  ],
-  "skills": "./skills/",
-  "interface": {
-    "displayName": "Superpowers",
-    "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents",
-    "longDescription": "Use Superpowers to guide agent work through brainstorming, implementation planning, test-driven development, systematic debugging, parallel execution, code review, and finish-the-branch workflows.",
-    "developerName": "Jesse Vincent",
-    "category": "Coding",
-    "capabilities": [
-      "Interactive",
-      "Read",
-      "Write"
-    ],
-    "defaultPrompt": [
-      "I've got an idea for something I'd like to build.",
-      "Let's add a feature to this project."
-    ],
-    "brandColor": "#F59E0B",
-    "composerIcon": "./assets/superpowers-small.svg",
-    "logo": "./assets/app-icon.png",
-    "screenshots": []
-  }
+IGNORED_DIR_EXCLUDES=()
+
+path_has_directory_exclude() {
+  local path="$1"
+  local dir
+
+  if [[ ${#IGNORED_DIR_EXCLUDES[@]} -eq 0 ]]; then
+    return 1
+  fi
+
+  for dir in "${IGNORED_DIR_EXCLUDES[@]}"; do
+    [[ "$path" == "$dir"* ]] && return 0
+  done
+
+  return 1
+}
+
+ignored_directory_has_tracked_descendants() {
+  local path="$1"
+
+  [[ -n "$(git -C "$UPSTREAM" ls-files --cached -- "$path/")" ]]
+}
+
+append_git_ignored_directory_excludes() {
+  local path
+  local lookup_path
+
+  while IFS= read -r -d '' path; do
+    [[ "$path" == */ ]] || continue
+
+    lookup_path="${path%/}"
+    if ! ignored_directory_has_tracked_descendants "$lookup_path"; then
+      IGNORED_DIR_EXCLUDES+=("$path")
+      RSYNC_ARGS+=(--exclude="/$path")
+    fi
+  done < <(git -C "$UPSTREAM" ls-files --others --ignored --exclude-standard --directory -z)
 }
-EOF
+
+append_git_ignored_file_excludes() {
+  local path
+
+  while IFS= read -r -d '' path; do
+    path_has_directory_exclude "$path" && continue
+    RSYNC_ARGS+=(--exclude="/$path")
+  done < <(git -C "$UPSTREAM" ls-files --others --ignored --exclude-standard -z)
 }
 
 # =============================================================================
@@ -153,10 +137,9 @@ DRY_RUN=0
 YES=0
 LOCAL_CHECKOUT=""
 BOOTSTRAP=0
-ASSETS_SRC=""
 
 usage() {
-  sed -n 's/^# \{0,1\}//;2,27p' "$0"
+  sed -n '/^# Usage:/,/^# Requires:/s/^# \{0,1\}//p' "$0"
   exit "${1:-0}"
 }
 
@@ -167,7 +150,6 @@ while [[ $# -gt 0 ]]; do
     --local)       LOCAL_CHECKOUT="$2"; shift 2 ;;
     --base)        BASE="$2"; shift 2 ;;
     --bootstrap)   BOOTSTRAP=1; shift ;;
-    --assets-src)  ASSETS_SRC="$2"; shift 2 ;;
     -h|--help)     usage 0 ;;
     *)             echo "Unknown arg: $1" >&2; usage 2 ;;
   esac
@@ -187,19 +169,11 @@ command -v python3 >/dev/null || die "python3 not found in PATH"
 gh auth status >/dev/null 2>&1 || die "gh not authenticated — run 'gh auth login'"
 
 [[ -d "$UPSTREAM/.git" ]]         || die "upstream '$UPSTREAM' is not a git checkout"
-[[ -f "$UPSTREAM/package.json" ]] || die "upstream has no package.json — cannot read version"
+[[ -f "$UPSTREAM/.codex-plugin/plugin.json" ]] || die "committed Codex manifest missing at $UPSTREAM/.codex-plugin/plugin.json"
 
-# Bootstrap-mode validation
-if [[ $BOOTSTRAP -eq 1 ]]; then
-  [[ -n "$ASSETS_SRC" ]] || die "--bootstrap requires --assets-src <path>"
-  ASSETS_SRC="$(cd "$ASSETS_SRC" 2>/dev/null && pwd)" || die "assets source '$ASSETS_SRC' is not a directory"
-  [[ -f "$ASSETS_SRC/PrimeRadiant_Favicon.svg" ]] || die "assets source missing PrimeRadiant_Favicon.svg"
-  [[ -f "$ASSETS_SRC/PrimeRadiant_Favicon.png" ]] || die "assets source missing PrimeRadiant_Favicon.png"
-fi
-
-# Read the upstream version from package.json
-UPSTREAM_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$UPSTREAM/package.json")"
-[[ -n "$UPSTREAM_VERSION" ]] || die "could not read 'version' from upstream package.json"
+# Read the upstream version from the committed Codex manifest.
+UPSTREAM_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$UPSTREAM/.codex-plugin/plugin.json")"
+[[ -n "$UPSTREAM_VERSION" ]] || die "could not read 'version' from committed Codex manifest"
 
 UPSTREAM_BRANCH="$(cd "$UPSTREAM" && git branch --show-current)"
 UPSTREAM_SHA="$(cd "$UPSTREAM" && git rev-parse HEAD)"
@@ -230,7 +204,9 @@ fi
 
 CLEANUP_DIR=""
 cleanup() {
-  [[ -n "$CLEANUP_DIR" ]] && rm -rf "$CLEANUP_DIR"
+  if [[ -n "$CLEANUP_DIR" ]]; then
+    rm -rf "$CLEANUP_DIR"
+  fi
 }
 trap cleanup EXIT
 
@@ -245,22 +221,84 @@ else
 fi
 
 DEST="$DEST_REPO/$DEST_REL"
+PREVIEW_REPO="$DEST_REPO"
+PREVIEW_DEST="$DEST"
+
+overlay_destination_paths() {
+  local repo="$1"
+  local path
+  local source_path
+  local preview_path
+
+  while IFS= read -r -d '' path; do
+    source_path="$repo/$path"
+    preview_path="$PREVIEW_REPO/$path"
+
+    if [[ -e "$source_path" ]]; then
+      mkdir -p "$(dirname "$preview_path")"
+      cp -R "$source_path" "$preview_path"
+    else
+      rm -rf "$preview_path"
+    fi
+  done
+}
 
-# Checkout base branch
-cd "$DEST_REPO"
-git checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK"
+copy_local_destination_overlay() {
+  overlay_destination_paths "$DEST_REPO" < <(
+    git -C "$DEST_REPO" diff --name-only -z -- "$DEST_REL"
+  )
+  overlay_destination_paths "$DEST_REPO" < <(
+    git -C "$DEST_REPO" diff --cached --name-only -z -- "$DEST_REL"
+  )
+  overlay_destination_paths "$DEST_REPO" < <(
+    git -C "$DEST_REPO" ls-files --others --exclude-standard -z -- "$DEST_REL"
+  )
+  overlay_destination_paths "$DEST_REPO" < <(
+    git -C "$DEST_REPO" ls-files --others --ignored --exclude-standard -z -- "$DEST_REL"
+  )
+}
 
-# Plugin-existence check depends on mode
-if [[ $BOOTSTRAP -eq 1 ]]; then
-  [[ ! -d "$DEST" ]] || die "--bootstrap but base branch '$BASE' already has '$DEST_REL/' — use normal sync instead"
-  mkdir -p "$DEST"
-else
-  [[ -d "$DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap + --assets-src, or pass --base <branch>"
-fi
+local_checkout_has_uncommitted_destination_changes() {
+  [[ -n "$(git -C "$DEST_REPO" status --porcelain=1 --untracked-files=all --ignored=matching -- "$DEST_REL")" ]]
+}
 
-# =============================================================================
-# Create sync branch
-# =============================================================================
+prepare_preview_checkout() {
+  if [[ -n "$LOCAL_CHECKOUT" ]]; then
+    [[ -n "$CLEANUP_DIR" ]] || CLEANUP_DIR="$(mktemp -d)"
+    PREVIEW_REPO="$CLEANUP_DIR/preview"
+    git clone -q --no-local "$DEST_REPO" "$PREVIEW_REPO"
+    PREVIEW_DEST="$PREVIEW_REPO/$DEST_REL"
+  fi
+
+  git -C "$PREVIEW_REPO" checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK"
+  if [[ -n "$LOCAL_CHECKOUT" ]]; then
+    copy_local_destination_overlay
+  fi
+  if [[ $BOOTSTRAP -ne 1 ]]; then
+    [[ -d "$PREVIEW_DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap, or pass --base <branch>"
+  fi
+}
+
+prepare_apply_checkout() {
+  git -C "$DEST_REPO" checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK"
+  if [[ $BOOTSTRAP -ne 1 ]]; then
+    [[ -d "$DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap, or pass --base <branch>"
+  fi
+}
+
+apply_to_preview_checkout() {
+  if [[ $BOOTSTRAP -eq 1 ]]; then
+    mkdir -p "$PREVIEW_DEST"
+  fi
+
+  rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$PREVIEW_DEST/"
+}
+
+preview_checkout_has_changes() {
+  [[ -n "$(git -C "$PREVIEW_REPO" status --porcelain "$DEST_REL")" ]]
+}
+
+prepare_preview_checkout
 
 TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
 if [[ $BOOTSTRAP -eq 1 ]]; then
@@ -268,14 +306,15 @@ if [[ $BOOTSTRAP -eq 1 ]]; then
 else
   SYNC_BRANCH="sync/superpowers-${UPSTREAM_SHORT}-${TIMESTAMP}"
 fi
-git checkout -q -b "$SYNC_BRANCH"
 
 # =============================================================================
 # Build rsync args
 # =============================================================================
 
-RSYNC_ARGS=(-av --delete)
+RSYNC_ARGS=(-av --delete --delete-excluded)
 for pat in "${EXCLUDES[@]}"; do RSYNC_ARGS+=(--exclude="$pat"); done
+append_git_ignored_directory_excludes
+append_git_ignored_file_excludes
 
 # =============================================================================
 # Dry run preview (always shown)
@@ -288,20 +327,13 @@ echo "Fork:     $FORK"
 echo "Base:     $BASE"
 echo "Branch:   $SYNC_BRANCH"
 if [[ $BOOTSTRAP -eq 1 ]]; then
-  echo "Mode:     BOOTSTRAP (creating initial plugin from scratch)"
-  echo "Assets:   $ASSETS_SRC"
+  echo "Mode:     BOOTSTRAP (creating plugins/superpowers/ when absent)"
 fi
 echo ""
 echo "=== Preview (rsync --dry-run) ==="
-rsync "${RSYNC_ARGS[@]}" --dry-run --itemize-changes "$UPSTREAM/" "$DEST/"
+rsync "${RSYNC_ARGS[@]}" --dry-run --itemize-changes "$UPSTREAM/" "$PREVIEW_DEST/"
 echo "=== End preview ==="
 echo ""
-echo "Overlay file (.codex-plugin/plugin.json) will be regenerated with"
-echo "version $UPSTREAM_VERSION regardless of rsync output."
-if [[ $BOOTSTRAP -eq 1 ]]; then
-  echo "Assets (superpowers-small.svg, app-icon.png) will be seeded from:"
-  echo "  $ASSETS_SRC"
-fi
 
 if [[ $DRY_RUN -eq 1 ]]; then
   echo ""
@@ -317,18 +349,26 @@ echo ""
 confirm "Apply changes, push branch, and open PR?" || { echo "Aborted."; exit 1; }
 
 echo ""
-echo "Syncing upstream content..."
-rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$DEST/"
+if [[ -n "$LOCAL_CHECKOUT" ]]; then
+  if local_checkout_has_uncommitted_destination_changes; then
+    die "local checkout has uncommitted changes under '$DEST_REL' — commit, stash, or discard them before syncing"
+  fi
+
+  apply_to_preview_checkout
+  if ! preview_checkout_has_changes; then
+    echo "No changes — embedded plugin was already in sync with upstream $UPSTREAM_SHORT (v$UPSTREAM_VERSION)."
+    exit 0
+  fi
+fi
 
+prepare_apply_checkout
+cd "$DEST_REPO"
+git checkout -q -b "$SYNC_BRANCH"
+echo "Syncing upstream content..."
 if [[ $BOOTSTRAP -eq 1 ]]; then
-  echo "Seeding brand assets..."
-  mkdir -p "$DEST/assets"
-  cp "$ASSETS_SRC/PrimeRadiant_Favicon.svg" "$DEST/assets/superpowers-small.svg"
-  cp "$ASSETS_SRC/PrimeRadiant_Favicon.png" "$DEST/assets/app-icon.png"
+  mkdir -p "$DEST"
 fi
-
-echo "Regenerating overlay file..."
-generate_plugin_json "$DEST/.codex-plugin/plugin.json" "$UPSTREAM_VERSION"
+rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$DEST/"
 
 # Bail early if nothing actually changed
 cd "$DEST_REPO"
@@ -347,16 +387,18 @@ if [[ $BOOTSTRAP -eq 1 ]]; then
   COMMIT_TITLE="bootstrap superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
   PR_BODY="Initial bootstrap of the superpowers plugin from upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
 
-Creates \`plugins/superpowers/\` from scratch: upstream content via rsync, \`.codex-plugin/plugin.json\` regenerated inline, brand assets seeded from a local Brand Assets directory.
+Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstream, including \`.codex-plugin/plugin.json\` and \`assets/\`.
 
-Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap --assets-src <path>\`
+Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap\`
 Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
 
-This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs and will not touch the \`assets/\` directory."
+This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files."
 else
   COMMIT_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
   PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
 
+Copies the tracked plugin files from upstream, including the committed Codex manifest and assets.
+
 Run via: \`scripts/sync-to-codex-plugin.sh\`
 Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA
 

+ 571 - 0
tests/codex-plugin-sync/test-sync-to-codex-plugin.sh

@@ -0,0 +1,571 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+SYNC_SCRIPT_SOURCE="$REPO_ROOT/scripts/sync-to-codex-plugin.sh"
+BASH_UNDER_TEST="/bin/bash"
+PACKAGE_VERSION="1.2.3"
+MANIFEST_VERSION="9.8.7"
+
+FAILURES=0
+TEST_ROOT=""
+
+pass() {
+    echo "  [PASS] $1"
+}
+
+fail() {
+    echo "  [FAIL] $1"
+    FAILURES=$((FAILURES + 1))
+}
+
+assert_equals() {
+    local actual="$1"
+    local expected="$2"
+    local description="$3"
+
+    if [[ "$actual" == "$expected" ]]; then
+        pass "$description"
+    else
+        fail "$description"
+        echo "    expected: $expected"
+        echo "    actual:   $actual"
+    fi
+}
+
+assert_contains() {
+    local haystack="$1"
+    local needle="$2"
+    local description="$3"
+
+    if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
+        pass "$description"
+    else
+        fail "$description"
+        echo "    expected to find: $needle"
+    fi
+}
+
+assert_not_contains() {
+    local haystack="$1"
+    local needle="$2"
+    local description="$3"
+
+    if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
+        fail "$description"
+        echo "    did not expect to find: $needle"
+    else
+        pass "$description"
+    fi
+}
+
+assert_matches() {
+    local haystack="$1"
+    local pattern="$2"
+    local description="$3"
+
+    if printf '%s' "$haystack" | grep -Eq -- "$pattern"; then
+        pass "$description"
+    else
+        fail "$description"
+        echo "    expected to match: $pattern"
+    fi
+}
+
+assert_path_absent() {
+    local path="$1"
+    local description="$2"
+
+    if [[ ! -e "$path" ]]; then
+        pass "$description"
+    else
+        fail "$description"
+        echo "    did not expect path to exist: $path"
+    fi
+}
+
+assert_branch_absent() {
+    local repo="$1"
+    local pattern="$2"
+    local description="$3"
+    local branches
+
+    branches="$(git -C "$repo" branch --list "$pattern")"
+
+    if [[ -z "$branches" ]]; then
+        pass "$description"
+    else
+        fail "$description"
+        echo "    did not expect matching branches:"
+        echo "$branches" | sed 's/^/      /'
+    fi
+}
+
+assert_current_branch() {
+    local repo="$1"
+    local expected="$2"
+    local description="$3"
+    local actual
+
+    actual="$(git -C "$repo" branch --show-current)"
+    assert_equals "$actual" "$expected" "$description"
+}
+
+assert_file_equals() {
+    local path="$1"
+    local expected="$2"
+    local description="$3"
+    local actual
+
+    actual="$(cat "$path")"
+    assert_equals "$actual" "$expected" "$description"
+}
+
+cleanup() {
+    if [[ -n "$TEST_ROOT" && -d "$TEST_ROOT" ]]; then
+        rm -rf "$TEST_ROOT"
+    fi
+}
+
+configure_git_identity() {
+    local repo="$1"
+
+    git -C "$repo" config user.name "Test Bot"
+    git -C "$repo" config user.email "test@example.com"
+}
+
+init_repo() {
+    local repo="$1"
+
+    git init -q -b main "$repo"
+    configure_git_identity "$repo"
+}
+
+commit_fixture() {
+    local repo="$1"
+    local message="$2"
+
+    git -C "$repo" commit -q -m "$message"
+}
+
+checkout_fixture_branch() {
+    local repo="$1"
+    local branch="$2"
+
+    git -C "$repo" checkout -q -b "$branch"
+}
+
+write_upstream_fixture() {
+    local repo="$1"
+    local with_pure_ignored="${2:-1}"
+
+    mkdir -p \
+        "$repo/.codex-plugin" \
+        "$repo/.private-journal" \
+        "$repo/assets" \
+        "$repo/scripts" \
+        "$repo/skills/example"
+
+    if [[ "$with_pure_ignored" == "1" ]]; then
+        mkdir -p "$repo/ignored-cache/tmp"
+    fi
+
+    cp "$SYNC_SCRIPT_SOURCE" "$repo/scripts/sync-to-codex-plugin.sh"
+
+    cat > "$repo/package.json" <<EOF
+{
+  "name": "fixture-upstream",
+  "version": "$PACKAGE_VERSION"
+}
+EOF
+
+    cat > "$repo/.gitignore" <<'EOF'
+.private-journal/
+EOF
+
+    if [[ "$with_pure_ignored" == "1" ]]; then
+        cat >> "$repo/.gitignore" <<'EOF'
+ignored-cache/
+EOF
+    fi
+
+    cat > "$repo/.codex-plugin/plugin.json" <<EOF
+{
+  "name": "superpowers",
+  "version": "$MANIFEST_VERSION"
+}
+EOF
+
+    cat > "$repo/assets/superpowers-small.svg" <<'EOF'
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>
+EOF
+
+    printf 'png fixture\n' > "$repo/assets/app-icon.png"
+
+    cat > "$repo/skills/example/SKILL.md" <<'EOF'
+# Example Skill
+
+Fixture content.
+EOF
+
+    printf 'tracked keep\n' > "$repo/.private-journal/keep.txt"
+    printf 'ignored leak\n' > "$repo/.private-journal/leak.txt"
+    if [[ "$with_pure_ignored" == "1" ]]; then
+        printf 'ignored cache state\n' > "$repo/ignored-cache/tmp/state.json"
+    fi
+
+    git -C "$repo" add \
+        .codex-plugin/plugin.json \
+        .gitignore \
+        assets/app-icon.png \
+        assets/superpowers-small.svg \
+        package.json \
+        scripts/sync-to-codex-plugin.sh \
+        skills/example/SKILL.md
+    git -C "$repo" add -f .private-journal/keep.txt
+
+    commit_fixture "$repo" "Initial upstream fixture"
+}
+
+write_destination_fixture() {
+    local repo="$1"
+
+    mkdir -p "$repo/plugins/superpowers/skills/example"
+    printf 'fixture keep\n' > "$repo/plugins/superpowers/.fixture-keep"
+    cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF'
+# Example Skill
+
+Fixture content.
+EOF
+    git -C "$repo" add plugins/superpowers/.fixture-keep
+    git -C "$repo" add plugins/superpowers/skills/example/SKILL.md
+
+    commit_fixture "$repo" "Initial destination fixture"
+}
+
+dirty_tracked_destination_skill() {
+    local repo="$1"
+
+    cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF'
+# Example Skill
+
+Locally modified fixture content.
+EOF
+}
+
+write_synced_destination_fixture() {
+    local repo="$1"
+
+    mkdir -p \
+        "$repo/plugins/superpowers/.codex-plugin" \
+        "$repo/plugins/superpowers/.private-journal" \
+        "$repo/plugins/superpowers/assets" \
+        "$repo/plugins/superpowers/skills/example"
+
+    cat > "$repo/plugins/superpowers/.codex-plugin/plugin.json" <<EOF
+{
+  "name": "superpowers",
+  "version": "$MANIFEST_VERSION"
+}
+EOF
+
+    cat > "$repo/plugins/superpowers/assets/superpowers-small.svg" <<'EOF'
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>
+EOF
+
+    printf 'png fixture\n' > "$repo/plugins/superpowers/assets/app-icon.png"
+
+    cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF'
+# Example Skill
+
+Fixture content.
+EOF
+
+    printf 'tracked keep\n' > "$repo/plugins/superpowers/.private-journal/keep.txt"
+
+    git -C "$repo" add \
+        plugins/superpowers/.codex-plugin/plugin.json \
+        plugins/superpowers/assets/app-icon.png \
+        plugins/superpowers/assets/superpowers-small.svg \
+        plugins/superpowers/skills/example/SKILL.md \
+        plugins/superpowers/.private-journal/keep.txt
+
+    commit_fixture "$repo" "Initial synced destination fixture"
+}
+
+write_stale_ignored_destination_fixture() {
+    local repo="$1"
+
+    mkdir -p "$repo/plugins/superpowers/.private-journal"
+    printf 'fixture keep\n' > "$repo/plugins/superpowers/.fixture-keep"
+    printf 'stale ignored leak\n' > "$repo/plugins/superpowers/.private-journal/leak.txt"
+    git -C "$repo" add plugins/superpowers/.fixture-keep
+
+    commit_fixture "$repo" "Initial stale ignored destination fixture"
+}
+
+write_fake_gh() {
+    local bin_dir="$1"
+
+    mkdir -p "$bin_dir"
+
+    cat > "$bin_dir/gh" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "${1:-}" == "auth" && "${2:-}" == "status" ]]; then
+    exit 0
+fi
+
+echo "unexpected gh invocation: $*" >&2
+exit 1
+EOF
+
+    chmod +x "$bin_dir/gh"
+}
+
+run_preview() {
+    local upstream="$1"
+    local dest="$2"
+    local fake_bin="$3"
+
+    PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1
+}
+
+run_bootstrap_preview() {
+    local upstream="$1"
+    local dest="$2"
+    local fake_bin="$3"
+
+    PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --bootstrap --local "$dest" 2>&1
+}
+
+run_preview_without_manifest() {
+    local upstream="$1"
+    local dest="$2"
+    local fake_bin="$3"
+
+    rm -f "$upstream/.codex-plugin/plugin.json"
+    PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1
+}
+
+run_preview_with_stale_ignored_destination() {
+    local upstream="$1"
+    local dest="$2"
+    local fake_bin="$3"
+
+    PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1
+}
+
+run_apply() {
+    local upstream="$1"
+    local dest="$2"
+    local fake_bin="$3"
+
+    PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -y --local "$dest" 2>&1
+}
+
+run_help() {
+    local upstream="$1"
+    local fake_bin="$2"
+
+    PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" --help 2>&1
+}
+
+write_bootstrap_destination_fixture() {
+    local repo="$1"
+
+    printf 'bootstrap fixture\n' > "$repo/README.md"
+    git -C "$repo" add README.md
+
+    commit_fixture "$repo" "Initial bootstrap destination fixture"
+}
+
+main() {
+    local upstream
+    local mixed_only_upstream
+    local dest
+    local dest_branch
+    local mixed_only_dest
+    local stale_dest
+    local dirty_apply_dest
+    local dirty_apply_dest_branch
+    local noop_apply_dest
+    local noop_apply_dest_branch
+    local fake_bin
+    local bootstrap_dest
+    local bootstrap_dest_branch
+    local preview_status
+    local preview_output
+    local preview_section
+    local bootstrap_status
+    local bootstrap_output
+    local missing_manifest_status
+    local missing_manifest_output
+    local mixed_only_status
+    local mixed_only_output
+    local stale_preview_status
+    local stale_preview_output
+    local stale_preview_section
+    local dirty_apply_status
+    local dirty_apply_output
+    local noop_apply_status
+    local noop_apply_output
+    local help_output
+    local script_source
+    local dirty_skill_path
+
+    echo "=== Test: sync-to-codex-plugin dry-run regression ==="
+
+    TEST_ROOT="$(mktemp -d)"
+    trap cleanup EXIT
+
+    upstream="$TEST_ROOT/upstream"
+    mixed_only_upstream="$TEST_ROOT/mixed-only-upstream"
+    dest="$TEST_ROOT/destination"
+    mixed_only_dest="$TEST_ROOT/mixed-only-destination"
+    stale_dest="$TEST_ROOT/stale-destination"
+    dirty_apply_dest="$TEST_ROOT/dirty-apply-destination"
+    dirty_apply_dest_branch="fixture/dirty-apply-target"
+    noop_apply_dest="$TEST_ROOT/noop-apply-destination"
+    noop_apply_dest_branch="fixture/noop-apply-target"
+    bootstrap_dest="$TEST_ROOT/bootstrap-destination"
+    dest_branch="fixture/preview-target"
+    bootstrap_dest_branch="fixture/bootstrap-preview-target"
+    fake_bin="$TEST_ROOT/bin"
+
+    init_repo "$upstream"
+    write_upstream_fixture "$upstream"
+
+    init_repo "$mixed_only_upstream"
+    write_upstream_fixture "$mixed_only_upstream" 0
+
+    init_repo "$dest"
+    write_destination_fixture "$dest"
+    checkout_fixture_branch "$dest" "$dest_branch"
+    dirty_tracked_destination_skill "$dest"
+
+    init_repo "$mixed_only_dest"
+    write_destination_fixture "$mixed_only_dest"
+
+    init_repo "$stale_dest"
+    write_stale_ignored_destination_fixture "$stale_dest"
+
+    init_repo "$dirty_apply_dest"
+    write_synced_destination_fixture "$dirty_apply_dest"
+    checkout_fixture_branch "$dirty_apply_dest" "$dirty_apply_dest_branch"
+    dirty_tracked_destination_skill "$dirty_apply_dest"
+
+    init_repo "$noop_apply_dest"
+    write_synced_destination_fixture "$noop_apply_dest"
+    checkout_fixture_branch "$noop_apply_dest" "$noop_apply_dest_branch"
+
+    init_repo "$bootstrap_dest"
+    write_bootstrap_destination_fixture "$bootstrap_dest"
+    checkout_fixture_branch "$bootstrap_dest" "$bootstrap_dest_branch"
+
+    write_fake_gh "$fake_bin"
+
+    # This regression test is about dry-run content, so capture the preview
+    # output even if the current script exits nonzero in --local mode.
+    set +e
+    preview_output="$(run_preview "$upstream" "$dest" "$fake_bin")"
+    preview_status=$?
+    bootstrap_output="$(run_bootstrap_preview "$upstream" "$bootstrap_dest" "$fake_bin")"
+    bootstrap_status=$?
+    mixed_only_output="$(run_preview "$mixed_only_upstream" "$mixed_only_dest" "$fake_bin")"
+    mixed_only_status=$?
+    stale_preview_output="$(run_preview_with_stale_ignored_destination "$upstream" "$stale_dest" "$fake_bin")"
+    stale_preview_status=$?
+    dirty_apply_output="$(run_apply "$upstream" "$dirty_apply_dest" "$fake_bin")"
+    dirty_apply_status=$?
+    noop_apply_output="$(run_apply "$upstream" "$noop_apply_dest" "$fake_bin")"
+    noop_apply_status=$?
+    missing_manifest_output="$(run_preview_without_manifest "$upstream" "$dest" "$fake_bin")"
+    missing_manifest_status=$?
+    set -e
+    help_output="$(run_help "$upstream" "$fake_bin")"
+    script_source="$(cat "$upstream/scripts/sync-to-codex-plugin.sh")"
+    preview_section="$(printf '%s\n' "$preview_output" | sed -n '/^=== Preview (rsync --dry-run) ===$/,/^=== End preview ===$/p')"
+    stale_preview_section="$(printf '%s\n' "$stale_preview_output" | sed -n '/^=== Preview (rsync --dry-run) ===$/,/^=== End preview ===$/p')"
+    dirty_skill_path="$dirty_apply_dest/plugins/superpowers/skills/example/SKILL.md"
+
+    echo ""
+    echo "Preview assertions..."
+    assert_equals "$preview_status" "0" "Preview exits successfully"
+    assert_contains "$preview_output" "Version:  $MANIFEST_VERSION" "Preview uses manifest version"
+    assert_not_contains "$preview_output" "Version:  $PACKAGE_VERSION" "Preview does not use package.json version"
+    assert_contains "$preview_section" ".codex-plugin/plugin.json" "Preview includes manifest path"
+    assert_contains "$preview_section" "assets/superpowers-small.svg" "Preview includes SVG asset"
+    assert_contains "$preview_section" "assets/app-icon.png" "Preview includes PNG asset"
+    assert_contains "$preview_section" ".private-journal/keep.txt" "Preview includes tracked ignored file"
+    assert_not_contains "$preview_section" ".private-journal/leak.txt" "Preview excludes ignored untracked file"
+    assert_not_contains "$preview_section" "ignored-cache/" "Preview excludes pure ignored directories"
+    assert_not_contains "$preview_output" "Overlay file (.codex-plugin/plugin.json) will be regenerated" "Preview omits overlay regeneration note"
+    assert_not_contains "$preview_output" "Assets (superpowers-small.svg, app-icon.png) will be seeded from" "Preview omits assets seeding note"
+    assert_contains "$preview_section" "skills/example/SKILL.md" "Preview reflects dirty tracked destination file"
+    assert_current_branch "$dest" "$dest_branch" "Preview leaves destination checkout on its original branch"
+    assert_branch_absent "$dest" "sync/superpowers-*" "Preview does not create sync branch in destination checkout"
+
+    echo ""
+    echo "Mixed-directory assertions..."
+    assert_equals "$mixed_only_status" "0" "Mixed ignored directory preview exits successfully under /bin/bash"
+    assert_contains "$mixed_only_output" ".private-journal/keep.txt" "Mixed ignored directory preview still includes tracked ignored file"
+    assert_not_contains "$mixed_only_output" "ignored-cache/" "Mixed ignored directory preview has no pure ignored directory fixture"
+
+    echo ""
+    echo "Convergence assertions..."
+    assert_equals "$stale_preview_status" "0" "Stale ignored destination preview exits successfully"
+    assert_matches "$stale_preview_section" "\\*deleting +\\.private-journal/leak\\.txt" "Preview deletes stale ignored destination file"
+
+    echo ""
+    echo "Bootstrap assertions..."
+    assert_equals "$bootstrap_status" "0" "Bootstrap preview exits successfully"
+    assert_contains "$bootstrap_output" "Mode:     BOOTSTRAP (creating plugins/superpowers/ when absent)" "Bootstrap preview describes directory creation"
+    assert_not_contains "$bootstrap_output" "Assets:" "Bootstrap preview omits external assets path"
+    assert_contains "$bootstrap_output" "Dry run only. Nothing was changed or pushed." "Bootstrap preview remains dry-run only"
+    assert_path_absent "$bootstrap_dest/plugins/superpowers" "Bootstrap preview does not create destination plugin directory"
+    assert_current_branch "$bootstrap_dest" "$bootstrap_dest_branch" "Bootstrap preview leaves destination checkout on its original branch"
+    assert_branch_absent "$bootstrap_dest" "bootstrap/superpowers-*" "Bootstrap preview does not create bootstrap branch in destination checkout"
+
+    echo ""
+    echo "Apply assertions..."
+    assert_equals "$dirty_apply_status" "1" "Dirty local apply exits with failure"
+    assert_contains "$dirty_apply_output" "ERROR: local checkout has uncommitted changes under 'plugins/superpowers'" "Dirty local apply reports protected destination path"
+    assert_current_branch "$dirty_apply_dest" "$dirty_apply_dest_branch" "Dirty local apply leaves destination checkout on its original branch"
+    assert_branch_absent "$dirty_apply_dest" "sync/superpowers-*" "Dirty local apply does not create sync branch in destination checkout"
+    assert_file_equals "$dirty_skill_path" "# Example Skill
+
+Locally modified fixture content." "Dirty local apply preserves tracked working-tree file content"
+    assert_equals "$noop_apply_status" "0" "Clean no-op local apply exits successfully"
+    assert_contains "$noop_apply_output" "No changes — embedded plugin was already in sync with upstream" "Clean no-op local apply reports no changes"
+    assert_current_branch "$noop_apply_dest" "$noop_apply_dest_branch" "Clean no-op local apply leaves destination checkout on its original branch"
+    assert_branch_absent "$noop_apply_dest" "sync/superpowers-*" "Clean no-op local apply does not create sync branch in destination checkout"
+
+    echo ""
+    echo "Missing manifest assertions..."
+    assert_equals "$missing_manifest_status" "1" "Missing manifest exits with failure"
+    assert_contains "$missing_manifest_output" "ERROR: committed Codex manifest missing at" "Missing manifest reports committed manifest path"
+
+    echo ""
+    echo "Help assertions..."
+    assert_not_contains "$help_output" "--assets-src" "Help omits --assets-src"
+
+    echo ""
+    echo "Source assertions..."
+    assert_not_contains "$script_source" "regenerated inline" "Source drops regenerated inline phrasing"
+    assert_not_contains "$script_source" "Brand Assets directory" "Source drops Brand Assets directory phrasing"
+    assert_not_contains "$script_source" "--assets-src" "Source drops --assets-src"
+
+    if [[ $FAILURES -ne 0 ]]; then
+        echo ""
+        echo "FAILED: $FAILURES assertion(s) failed."
+        exit 1
+    fi
+
+    echo ""
+    echo "PASS"
+}
+
+main "$@"