Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,27 @@ alias tb := template-backport
template-backport-all *args:
bash scripts/template_backport_all.sh {{args}}
alias tba := template-backport-all

# ---------------------------------------------------------------------------- #
# COUSINS #
# ---------------------------------------------------------------------------- #

# Review one cousin repo (by name from scripts/cousins.json) for template-sourced improvements (dry-run by default; --execute to run)
cousin-review *args:
bash scripts/cousin_review.sh {{args}}
alias cr := cousin-review

# Review every cousin in scripts/cousins.json in parallel (dry-run by default; --execute to run)
cousin-review-all *args:
bash scripts/cousin_review_all.sh {{args}}
alias cra := cousin-review-all

# Apply template-sourced changes to a cousin's opted-in paths and open a PR (dry-run by default; --execute to run)
cousin-apply *args:
bash scripts/cousin_apply.sh {{args}}
alias ca := cousin-apply

# Apply changes to every cousin, one PR each (dry-run by default; --execute to run)
cousin-apply-all *args:
bash scripts/cousin_apply_all.sh {{args}}
alias caa := cousin-apply-all
137 changes: 137 additions & 0 deletions scripts/cousin_apply.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROMPT_DIR="${SCRIPT_DIR}/cousin_apply"
DEFAULT_TEMPLATE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"

# shellcheck source=lib/cousins.sh
source "${SCRIPT_DIR}/lib/cousins.sh"
# shellcheck source=lib/pr_prompt.sh
source "${SCRIPT_DIR}/lib/pr_prompt.sh"
cousins__require_jq

# --- Locate a JS package runner (pnpm dlx preferred, npx fallback) ----------
find_runner() {
if command -v pnpm &>/dev/null; then
echo "pnpm dlx"
elif command -v npx &>/dev/null; then
echo "npx"
else
echo "Error: neither pnpm nor npx found. Install one of them first." >&2
exit 1
fi
}
RUNNER="$(find_runner)"

# --- Parse arguments --------------------------------------------------------
EXECUTE=false
COUSIN_NAME=""

usage() {
cat >&2 <<EOF
Usage: $0 [--execute] --cousin <name>

Applies high-confidence template-sourced changes to a cousin's opted-in paths,
runs per-target checks, and opens a single PR against the cousin repo.

This script ALWAYS clones the cousin fresh into a tempdir so your local
working copies are never touched. There is no local-path override.
EOF
}

while [[ $# -gt 0 ]]; do
case "$1" in
--execute) EXECUTE=true; shift ;;
--cousin) COUSIN_NAME="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done

if [[ -z "$COUSIN_NAME" ]]; then
usage
exit 2
fi

CONFIG_PATH="$(cousins_config_path "$SCRIPT_DIR")"
if [[ ! -f "$CONFIG_PATH" ]]; then
echo "Error: cousins config not found at ${CONFIG_PATH}" >&2
exit 1
fi

COUSIN_CONFIG_JSON="$(cousins_get_by_name "$CONFIG_PATH" "$COUSIN_NAME")"
if [[ -z "$COUSIN_CONFIG_JSON" ]]; then
echo "Error: no cousin named '${COUSIN_NAME}' in ${CONFIG_PATH}" >&2
exit 1
fi

# Refuse to run if the cousin has no opted-in targets — this is a no-op.
TARGET_COUNT="$(jq '(.targets // []) | length + ((.global_applies // []) | length)' <<<"$COUSIN_CONFIG_JSON")"
if [[ "$TARGET_COUNT" -eq 0 ]]; then
echo "Cousin '${COUSIN_NAME}' has no targets or global_applies — nothing to do."
exit 0
fi

COUSIN_URL="$(jq -r '.url' <<<"$COUSIN_CONFIG_JSON")"
TEMPLATE_DIR="${TEMPLATE_DIR:-$DEFAULT_TEMPLATE_DIR}"

compose_prompt() {
local prompt=""
if [[ -f "${PROMPT_DIR}/role.md" ]]; then
prompt+="$(cat "${PROMPT_DIR}/role.md")"
prompt+=$'\n\n'
fi
for f in "${PROMPT_DIR}"/*.md; do
[[ "$(basename "$f")" == "role.md" ]] && continue
prompt+="$(cat "$f")"
prompt+=$'\n\n'
done
echo "$prompt"
}
PROMPT="$(compose_prompt)"

# Apply mode: Claude edits files, runs checks, and creates a PR via gh.
ALLOWED_TOOLS="Read Edit Write Bash"

if [[ "$EXECUTE" == true ]]; then
WORK_DIR="$(mktemp -d)"
CLONE_PATH="${WORK_DIR}/${COUSIN_NAME}"
echo "Runner: ${RUNNER}"
echo "Cousin: ${COUSIN_NAME} (${COUSIN_URL})"
echo "Clone path: ${CLONE_PATH}"
echo "Template: ${TEMPLATE_DIR}"
echo "Prompt length: ${#PROMPT} chars"
echo "Allowed tools: ${ALLOWED_TOOLS}"
echo "---"

git clone --quiet "$COUSIN_URL" "$CLONE_PATH"

cd "$CLONE_PATH"
export TEMPLATE_DIR COUSIN_CONFIG_JSON COUSIN_NAME
OUTPUT="$(echo "${PROMPT}" | ${RUNNER} @anthropic-ai/claude-code --print \
--allowed-tools ${ALLOWED_TOOLS})"
echo "$OUTPUT"

PR_URL="$(pr_prompt_extract_url "$OUTPUT")"
if [[ -n "$PR_URL" ]]; then
pr_prompt_finalize "$PR_URL"
else
echo "No PR opened (no changes survived, or run was a no-op)."
fi

echo "Clone left at: ${CLONE_PATH}"
else
echo "=== DRY RUN ==="
echo
echo "${PROMPT}"
echo "---"
echo "Runner: ${RUNNER}"
echo "Cousin: ${COUSIN_NAME} (${COUSIN_URL})"
echo "Template: ${TEMPLATE_DIR}"
echo "Config: ${CONFIG_PATH}"
echo "Prompt length: ${#PROMPT} chars"
echo "Allowed tools: ${ALLOWED_TOOLS}"
echo
echo "Pass --execute to actually run (clones the cousin fresh into a tempdir)."
fi
3 changes: 3 additions & 0 deletions scripts/cousin_apply/role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
You are a pragmatic senior Rust engineer applying a small number of template-sourced improvements to explicitly-whitelisted Rust subpaths inside a third-party "cousin" repository.

The cousin is NOT a fork of the template. It is its own project with its own conventions. You are a guest here. You may only touch files that the cousin's config file has explicitly opted-in, and you must never touch anything outside those slots. When in doubt, SKIP — it is much better to open a tiny, obviously-correct PR than a large or ambiguous one.
108 changes: 108 additions & 0 deletions scripts/cousin_apply/task.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
## Context

You are running inside a **clone of a cousin repository** (your current working directory). Your job is to apply a small set of high-confidence, template-sourced improvements — limited strictly to the opted-in slots in `COUSIN_CONFIG_JSON` — and open a single PR for this cousin containing all surviving changes.

Environment variables:

- `TEMPLATE_DIR` — absolute path to a local checkout of the template repo (the source of bytes to copy).
- `COUSIN_CONFIG_JSON` — full JSON object describing THIS cousin. Schema documented in `scripts/cousin_review/task.md`.
- `COUSIN_NAME` — short name, safe for branch names and PR titles.

## Hard safety rules

1. **Opt-in only.** The only file slots you may read, diff, or modify:
- For each `targets[i]`: files listed in `targets[i].applies`, resolved at path `targets[i].path/<file>` inside the cousin.
- Files in `global_applies`, resolved at the cousin repo root.
Anything else is off-limits. Do not edit, delete, rename, or create anything outside these slots.

2. **Forbidden paths win.** If a candidate path matches any pattern in `forbidden_paths`, drop it immediately.

3. **Never retype bytes.** Propagate changes by `cp "$TEMPLATE_DIR/<file>" "<cousin target path>/<file>"`. Never use the `Write` tool to reconstruct a template file from memory — that produces bogus full-file diffs from subtle formatting drift.

4. **One PR per cousin, total.** All surviving changes across all targets go in a single branch and a single PR.

5. **If a check fails, revert the offending change.** Do not try to "fix up" the cousin's surrounding code. Your mandate is limited to the opted-in slots.

## Task

### 1. Parse the config and verify constraints

```
echo "$COUSIN_CONFIG_JSON" | jq .
```

Build two flat lists:
- `SLOTS` — the full set of allowed file slots from rule 1 above, as absolute paths inside the cousin.
- `FORBIDDEN` — the `forbidden_paths` globs.

Remove any SLOT whose path matches any FORBIDDEN glob. If the SLOTS list is now empty, print `No opted-in slots for ${COUSIN_NAME}.` and exit without branching or committing.

### 2. Diff each slot against the template

For each slot, run:

```
diff -u "<cousin slot path>" "$TEMPLATE_DIR/<template file>"
```

Decide per slot:

- **APPLY** — the template version is clearly better and low-risk for this cousin. ~95% confidence bar.
- **SKIP** — anything else. Log the reason.

Use the cousin's `notes` field as context for judging applicability. If notes say "Bazel-managed, no Cargo", do not apply template files that assume Cargo/just tooling.

### 3. Apply surviving changes

For every APPLY slot, copy bytes verbatim:

```
mkdir -p "$(dirname "<cousin slot path>")"
cp "$TEMPLATE_DIR/<template file>" "<cousin slot path>"
```

Use `cp` for full-file replacements. Use surgical `Edit` hunks ONLY if the cousin's existing file has project-specific content you must preserve — and even then, change as few lines as possible.

### 4. Run per-target checks

For each `targets[i]` whose files you modified, determine the check command using this priority order:

1. If `targets[i].check` is set in the JSON, run exactly that command from the cousin repo root.
2. Otherwise, if a `Cargo.toml` declares a package at `targets[i].path`, run `cargo check --manifest-path "targets[i].path/Cargo.toml"`.
3. Otherwise, skip checks for that target and note it in the PR body as "unchecked".

If a check fails, revert every change you made inside that target (`git checkout -- <slot path>`) and record the target under "reverted" in your notes. Do NOT attempt to fix the cousin's surrounding code.

`global_applies` changes are not checked here — the cousin's CI will catch anything broken when the PR lands.

### 5. Decide whether to PR

After reverts, run `git status`. If nothing is modified, print `No cousin changes survived checks for ${COUSIN_NAME}.` and exit without branching or committing.

### 6. Branch, commit, push, single PR

```
git checkout -b chore/template-sync-$(date +%Y-%m-%d)
git add -A
git commit -m "$(cat <<'MSG'
chore: sync opted-in Rust files from shared template

Applied the following template-sourced changes to opted-in paths:
- <slot> — <what changed, one line>

Skipped (with reason):
- <slot> — <reason>

Reverted after failed checks:
- <target> — <reason>, or "None"
MSG
)"
git push -u origin HEAD
```

Open a single PR with:

- Title: `chore: sync opted-in Rust files from shared template`
- Body: a markdown summary grouping applied changes by target, plus a "Skipped" section explaining what was considered but not applied, and a "Checks" section listing which command ran against which target (or "unchecked").

Print the PR URL on the final line of your output.
81 changes: 81 additions & 0 deletions scripts/cousin_apply_all.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COUSIN_APPLY="${SCRIPT_DIR}/cousin_apply.sh"

# shellcheck source=lib/cousins.sh
source "${SCRIPT_DIR}/lib/cousins.sh"
# shellcheck source=lib/pr_prompt.sh
source "${SCRIPT_DIR}/lib/pr_prompt.sh"
cousins__require_jq

# Children must not prompt — only this driver does, once, at the end.
export PR_PROMPT_SUPPRESS=1

CONFIG_PATH="$(cousins_config_path "$SCRIPT_DIR")"
if [[ ! -f "$CONFIG_PATH" ]]; then
echo "Error: cousins config not found at ${CONFIG_PATH}" >&2
exit 1
fi

ARGS=("$@")

NAMES=()
while IFS= read -r name; do
[[ -n "$name" ]] && NAMES+=("$name")
done < <(cousins_list_names "$CONFIG_PATH")
if [[ ${#NAMES[@]} -eq 0 ]]; then
echo "No cousins defined in ${CONFIG_PATH}."
exit 0
fi

WORK_DIR="$(mktemp -d)"
echo "Work dir: ${WORK_DIR}"
echo "Cousins: ${NAMES[*]}"

PIDS=()
LOG_FILES=()

for name in "${NAMES[@]}"; do
LOG_FILE="${WORK_DIR}/${name}.log"
echo "Applying to cousin: ${name}"
(
bash "$COUSIN_APPLY" --cousin "$name" ${ARGS[@]+"${ARGS[@]}"} \
>"$LOG_FILE" 2>&1
) &
PIDS+=($!)
LOG_FILES+=("$LOG_FILE")
done

FAILED=0
for i in "${!PIDS[@]}"; do
echo
echo "════════════════════════════════════════════════════════════════"
echo " ${NAMES[$i]}"
echo "════════════════════════════════════════════════════════════════"
if wait "${PIDS[$i]}"; then
cat "${LOG_FILES[$i]}"
else
echo "FAILED:" >&2
cat "${LOG_FILES[$i]}" >&2 || true
FAILED=$((FAILED + 1))
fi
done

echo
echo "---"
echo "Finished: $((${#PIDS[@]} - FAILED))/${#PIDS[@]} succeeded"
echo "Work dir left at: ${WORK_DIR}"

# Collect PR URLs from each cousin's log and prompt once at the end.
PR_URLS=()
for LOG_FILE in "${LOG_FILES[@]}"; do
[[ -f "$LOG_FILE" ]] || continue
URL="$(pr_prompt_extract_url "$(cat "$LOG_FILE")")"
[[ -n "$URL" ]] && PR_URLS+=("$URL")
done
unset PR_PROMPT_SUPPRESS
pr_prompt_finalize "${PR_URLS[@]}"

exit $FAILED
Loading
Loading