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
14 changes: 14 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,17 @@ alias tr := template-review
template-review-all *args:
bash scripts/template_review_all.sh {{args}}
alias tra := template-review-all

# ---------------------------------------------------------------------------- #
# TEMPLATE BACKPORT #
# ---------------------------------------------------------------------------- #

# Backport improvements from a downstream project into a fresh template clone and open a PR (dry-run by default; --execute to run)
template-backport *args:
bash scripts/template_backport.sh {{args}}
alias tb := template-backport

# Backport improvements from all downstream projects, one PR each (dry-run by default; --execute to run)
template-backport-all *args:
bash scripts/template_backport_all.sh {{args}}
alias tba := template-backport-all
118 changes: 118 additions & 0 deletions scripts/template_backport.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
set -euo pipefail

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

# --- 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
DOWNSTREAM_DIR=""

for arg in "$@"; do
case "$arg" in
--execute) EXECUTE=true ;;
*) DOWNSTREAM_DIR="$arg" ;;
esac
done

if [[ -z "$DOWNSTREAM_DIR" ]]; then
echo "Usage: $0 [--execute] <downstream_repo_path>" >&2
exit 2
fi
DOWNSTREAM_DIR="$(cd "$DOWNSTREAM_DIR" && pwd)"
DOWNSTREAM_NAME="$(basename "$DOWNSTREAM_DIR")"

# --- Determine the template's git origin URL (so we can clone fresh) --------
TEMPLATE_ORIGIN_URL="${TEMPLATE_ORIGIN_URL:-$(git -C "$TEMPLATE_REPO_ROOT" remote get-url origin)}"

# --- Compose the prompt -----------------------------------------------------
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)"

# --- Open a URL in the default browser (best-effort) -----------------------
open_url() {
local url="$1"
case "$(uname -s)" in
Darwin) open "$url" ;;
Linux) xdg-open "$url" ;;
MINGW*|MSYS*|CYGWIN*) cmd.exe /c start "$url" ;;
esac 2>/dev/null || true
}

# --- Allowed tools ----------------------------------------------------------
# Backport mode: Claude must edit files, run checks, and create a PR.
ALLOWED_TOOLS="Read Edit Write Bash"

# --- Execute or dry-run -----------------------------------------------------
if [[ "$EXECUTE" == true ]]; then
# Fresh clone of the template into a temp dir — never edit the user's working copy.
WORK_DIR="$(mktemp -d)"
TEMPLATE_CLONE="${WORK_DIR}/template"
echo "Runner: ${RUNNER}"
echo "Downstream: ${DOWNSTREAM_DIR} (${DOWNSTREAM_NAME})"
echo "Template origin: ${TEMPLATE_ORIGIN_URL}"
echo "Template clone: ${TEMPLATE_CLONE}"
echo "Prompt length: ${#PROMPT} chars"
echo "Allowed tools: ${ALLOWED_TOOLS}"
echo "---"

git clone --quiet "$TEMPLATE_ORIGIN_URL" "$TEMPLATE_CLONE"

cd "$TEMPLATE_CLONE"
export DOWNSTREAM_DIR DOWNSTREAM_NAME
OUTPUT="$(echo "${PROMPT}" | ${RUNNER} @anthropic-ai/claude-code --print \
--allowed-tools ${ALLOWED_TOOLS})"
echo "$OUTPUT"

PR_URL="$(echo "$OUTPUT" | grep -oE 'https://github\.com/[^ ]+/pull/[0-9]+' | head -1 || true)"
if [[ -n "$PR_URL" ]]; then
echo "Opening PR: ${PR_URL}"
open_url "$PR_URL"
else
echo "No PR opened (no candidates, or run was a no-op)."
fi

echo "Template clone left at: ${TEMPLATE_CLONE}"
else
echo "=== DRY RUN ==="
echo
echo "${PROMPT}"
echo "---"
echo "Runner: ${RUNNER}"
echo "Downstream: ${DOWNSTREAM_DIR} (${DOWNSTREAM_NAME})"
echo "Template origin: ${TEMPLATE_ORIGIN_URL}"
echo "Prompt length: ${#PROMPT} chars"
echo "Allowed tools: ${ALLOWED_TOOLS}"
echo
echo "Would clone the template fresh, set DOWNSTREAM_DIR/DOWNSTREAM_NAME,"
echo "and run Claude to apply backports + open a PR."
echo
echo "Pass --execute to actually run."
fi
3 changes: 3 additions & 0 deletions scripts/template_backport/role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
You are an experienced, pragmatic software engineer backporting improvements from a downstream Rust project into the shared template it was forked from.
Your job is to identify generalizable improvements in the downstream project, apply only the safe, high-confidence ones to a clean clone of the template, run the template's checks, and open a PR.
You are conservative: when in doubt, skip a change. It is much better to open a small, obviously-correct PR than a large, ambiguous one.
93 changes: 93 additions & 0 deletions scripts/template_backport/task.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
## Context

You are running inside a **fresh clone of the template repo**, which is your current working directory. Do NOT `cd` away from it for edits — this is the tree you will commit and push.

Environment variables:

- `DOWNSTREAM_DIR` — absolute path to a local clone of the downstream project you are harvesting from.
- `DOWNSTREAM_NAME` — short name of the downstream project (e.g. `rust-path-opener`), safe for branch names and PR titles.

Shared infrastructure files (candidates for backport):

- `.editorconfig`, `.gitignore`, `.prettierrc.yml`
- `.github/workflows/**`
- `.vscode/settings.json`
- `CLAUDE.md`, `cliff.toml`, `justfile`, `release.toml`, `rust-toolchain.toml`, `rustfmt.toml`
- `scripts/**` (except `scripts/downstream.txt`, which is template-only and MUST NEVER be backported)

Files that are ALWAYS off-limits (project-specific — never backport):

- `Cargo.toml`, `Cargo.lock`
- `README.md`
- `src/**`
- `LICENSE.md`
- `scripts/downstream.txt`

## Task

Follow these steps in order.

### 1. Diff shared files

For each shared-infrastructure path above, diff the downstream copy against the template copy (your cwd):

```
diff -u ./<file> "$DOWNSTREAM_DIR/<file>"
```

For directories, use `diff -ruN ./<dir> "$DOWNSTREAM_DIR/<dir>"`.

### 2. Classify every difference

For each hunk, decide:

- **BACKPORT** — a clear improvement (bug fix, better default, new reusable script, CI fix, etc.) that would benefit every downstream project. Must be high confidence.
- **SKIP** — project-specific, stale drift, stylistic noise, or anything you are not ~95% sure is an improvement.

Check commit history in the downstream for intent:

```
cd "$DOWNSTREAM_DIR" && git log --oneline -10 -- <file> && cd -
```

A commit like "fix ci flake on macos" is a strong BACKPORT signal. "tweak for acme deployment" is SKIP. When in doubt, SKIP.

### 3. Decide whether to proceed

If there are ZERO backport candidates after classification, print:

```
No backport candidates from ${DOWNSTREAM_NAME}.
```

…and exit. Do NOT create a branch, commit, or PR.

### 4. Apply BACKPORT changes

For each BACKPORT candidate, copy/merge the change from `$DOWNSTREAM_DIR` into the template (your cwd). Prefer `cp "$DOWNSTREAM_DIR/<file>" ./<file>` when the entire file should be replaced; use `Edit` for surgical hunks. Never touch off-limits files.

### 5. Run checks

```
just full-check
```

If formatting fails, run `just full-write` and re-check. If clippy or other errors remain, either fix them yourself or **revert the offending backport** (`git checkout -- <file>`) and remove it from your candidate list. Re-run until `just full-check` passes.

If this leaves you with zero applied changes, print `No backport candidates from ${DOWNSTREAM_NAME} survived checks.` and exit without branching/committing/pushing.

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

```
git checkout -b chore/backport-from-${DOWNSTREAM_NAME}-$(date +%Y-%m-%d)
git add -A
git commit -m "chore: backport improvements from ${DOWNSTREAM_NAME}"
git push -u origin HEAD
```

Create the PR with `gh pr create`:

- Title: `chore: backport improvements from ${DOWNSTREAM_NAME}`
- Body: a markdown summary listing each backported file, one sentence per file explaining the improvement and citing the downstream commit sha (short form) and subject line when available. End with a "Skipped" section briefly noting anything you classified SKIP, so the reviewer can cross-check your judgment.

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

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOWNSTREAM_FILE="${SCRIPT_DIR}/downstream.txt"
TEMPLATE_BACKPORT="${SCRIPT_DIR}/template_backport.sh"

if [[ ! -f "$DOWNSTREAM_FILE" ]]; then
echo "Error: ${DOWNSTREAM_FILE} not found" >&2
exit 1
fi

ARGS=("$@")

WORK_DIR="$(mktemp -d)"
echo "Clone directory: ${WORK_DIR}"

PIDS=()
REPOS=()
LOG_FILES=()

while IFS= read -r repo_url; do
[[ -z "$repo_url" || "$repo_url" == \#* ]] && continue

REPO_NAME="$(basename "${repo_url%/}" .git)"
CLONE_PATH="${WORK_DIR}/${REPO_NAME}"
LOG_FILE="${WORK_DIR}/${REPO_NAME}.log"

echo "Cloning downstream: ${repo_url} -> ${CLONE_PATH}"
(
{
git clone --quiet "$repo_url" "$CLONE_PATH"
bash "$TEMPLATE_BACKPORT" ${ARGS[@]+"${ARGS[@]}"} "$CLONE_PATH"
} >"$LOG_FILE" 2>&1
) &
PIDS+=($!)
REPOS+=("$repo_url")
LOG_FILES+=("$LOG_FILE")
done < "$DOWNSTREAM_FILE"

FAILED=0
for i in "${!PIDS[@]}"; do
echo
echo "════════════════════════════════════════════════════════════════"
echo " ${REPOS[$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}"
exit $FAILED
Loading