|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Migrate one or more directories or files from the source repo into this repo, |
| 3 | +# rewriting Go import paths throughout. |
| 4 | +# |
| 5 | +# Usage: |
| 6 | +# ./scripts/migrate-gaie-paths.sh [--since <ref>] <src1> <dest1> [<src2> <dest2> ...] |
| 7 | +# |
| 8 | +# --since <ref> tag or commit SHA marking the last sync point; when given, |
| 9 | +# only commits after <ref> are cherry-picked (incremental sync). |
| 10 | +# The ref to pass next time is printed in the PR title. |
| 11 | +# Omit for the initial full migration. |
| 12 | +# |
| 13 | +# Requires: git filter-repo (pip/pipx install git-filter-repo) |
| 14 | +# |
| 15 | +# NOTE |
| 16 | +# Must merge the initial migration PR via "Create a merge commit" to preserve history. |
| 17 | +# Incremental migrations can use "merge commit" (cleanest audit trail) or |
| 18 | +# "rebase and merge" (for a slightly cleaner linear history). |
| 19 | + |
| 20 | +set -euo pipefail |
| 21 | + |
| 22 | +# Configuration |
| 23 | +LOCAL_PATH="/tmp" # override as needed |
| 24 | + |
| 25 | +SOURCE_ORG="kubernetes-sigs" |
| 26 | +SOURCE_REPO_NAME="gateway-api-inference-extension" |
| 27 | +SOURCE_UPSTREAM_URL="git@github.com:${SOURCE_ORG}/${SOURCE_REPO_NAME}.git" |
| 28 | + |
| 29 | +DEST_ORG="llm-d" |
| 30 | +DEST_REPO_NAME="llm-d-inference-payload-processor" |
| 31 | +DEST_UPSTREAM_URL="git@github.com:${DEST_ORG}/${DEST_REPO_NAME}.git" |
| 32 | + |
| 33 | +UPSTREAM_REMOTE="upstream" |
| 34 | +ORIGIN_REMOTE="origin" |
| 35 | +MAIN_BRANCH="main" |
| 36 | + |
| 37 | +SOURCE_DIR="${LOCAL_PATH}/${SOURCE_REPO_NAME}" |
| 38 | +DEST_DIR="${LOCAL_PATH}/${DEST_REPO_NAME}" |
| 39 | +FILTER_WORK_DIR="${LOCAL_PATH}/${SOURCE_REPO_NAME}-filter-work" |
| 40 | + |
| 41 | +usage() { |
| 42 | + cat <<EOF |
| 43 | +Usage: $0 [--since <ref>] <src1> <dest1> [<src2> <dest2> ...] |
| 44 | +
|
| 45 | + --since <ref> tag or commit SHA of the last migration/sync (from the PR title) |
| 46 | + src path relative to source repo root (e.g. pkg/common) |
| 47 | + dest path relative to dest repo root (e.g. pkg/common/igw) |
| 48 | +
|
| 49 | +Repos are cloned to ${LOCAL_PATH} if not present, otherwise updated to ${UPSTREAM_REMOTE}/${MAIN_BRANCH}. |
| 50 | +EOF |
| 51 | + exit 1 |
| 52 | +} |
| 53 | + |
| 54 | +# Parse --since flag |
| 55 | +SINCE_REF="" |
| 56 | +if [[ "${1:-}" == "--since" ]]; then |
| 57 | + [[ $# -ge 2 ]] || usage |
| 58 | + SINCE_REF="$2" |
| 59 | + shift 2 |
| 60 | +fi |
| 61 | + |
| 62 | +[[ $# -ge 2 && $(( $# % 2 )) -eq 0 ]] || usage |
| 63 | + |
| 64 | +declare -a SRC_PATHS DEST_PATHS |
| 65 | +while [[ $# -gt 0 ]]; do |
| 66 | + SRC_PATHS+=("${1%/}") |
| 67 | + DEST_PATHS+=("${2%/}") |
| 68 | + shift 2 |
| 69 | +done |
| 70 | + |
| 71 | +# Preflight checks |
| 72 | +if ! command -v git-filter-repo &>/dev/null && ! git filter-repo --version &>/dev/null 2>&1; then |
| 73 | + echo "error: git filter-repo not installed (pip install git-filter-repo)" |
| 74 | + exit 1 |
| 75 | +fi |
| 76 | + |
| 77 | +GITHUB_USER="${GITHUB_USER:-$(gh api user --jq .login 2>/dev/null || git config github.user 2>/dev/null || echo "")}" |
| 78 | +if [[ -z "${GITHUB_USER}" ]]; then |
| 79 | + echo "error: cannot determine GitHub username; set GITHUB_USER=<handle>" |
| 80 | + exit 1 |
| 81 | +fi |
| 82 | +DEST_ORIGIN_URL="git@github.com:${GITHUB_USER}/${DEST_REPO_NAME}.git" |
| 83 | + |
| 84 | +ensure_repo() { |
| 85 | + local dir="$1" upstream_url="$2" origin_url="${3:-}" |
| 86 | + if [[ ! -d "${dir}/.git" ]]; then |
| 87 | + git clone --origin "${UPSTREAM_REMOTE}" "${upstream_url}" "${dir}" |
| 88 | + [[ -n "${origin_url}" ]] && git -C "${dir}" remote add "${ORIGIN_REMOTE}" "${origin_url}" |
| 89 | + else |
| 90 | + git -C "${dir}" fetch "${UPSTREAM_REMOTE}" |
| 91 | + git -C "${dir}" checkout "${MAIN_BRANCH}" |
| 92 | + git -C "${dir}" merge --ff-only "${UPSTREAM_REMOTE}/${MAIN_BRANCH}" |
| 93 | + fi |
| 94 | +} |
| 95 | + |
| 96 | +ensure_repo "${SOURCE_DIR}" "${SOURCE_UPSTREAM_URL}" |
| 97 | +ensure_repo "${DEST_DIR}" "${DEST_UPSTREAM_URL}" "${DEST_ORIGIN_URL}" |
| 98 | + |
| 99 | +# Validate --since ref exists in source |
| 100 | +if [[ -n "${SINCE_REF}" ]]; then |
| 101 | + if ! git -C "${SOURCE_DIR}" rev-parse --verify "${SINCE_REF}^{commit}" &>/dev/null; then |
| 102 | + echo "error: '${SINCE_REF}' not found in source repo" |
| 103 | + exit 1 |
| 104 | + fi |
| 105 | +fi |
| 106 | + |
| 107 | +SOURCE_SHA=$(git -C "${SOURCE_DIR}" rev-parse --short HEAD) |
| 108 | +SOURCE_MODULE=$(grep -m1 '^module ' "${SOURCE_DIR}/go.mod" | awk '{print $2}') |
| 109 | +DEST_MODULE=$(grep -m1 '^module ' "${DEST_DIR}/go.mod" | awk '{print $2}') |
| 110 | + |
| 111 | +BRANCH_SLUG=$(IFS='-'; echo "${DEST_PATHS[*]}" | tr '/' '-') |
| 112 | +BRANCH_NAME="migrate/${BRANCH_SLUG}" |
| 113 | +[[ -n "${SINCE_REF}" ]] && BRANCH_NAME="migrate/since-${SINCE_REF}-${BRANCH_SLUG}" |
| 114 | + |
| 115 | +echo "source: ${SOURCE_DIR} (${SOURCE_MODULE})" |
| 116 | +echo "destination: ${DEST_DIR} (${DEST_MODULE})" |
| 117 | +[[ -n "${SINCE_REF}" ]] && echo "since: ${SINCE_REF}" |
| 118 | +for i in "${!SRC_PATHS[@]}"; do |
| 119 | + echo "migrating: ${SRC_PATHS[$i]} -> ${DEST_PATHS[$i]}" |
| 120 | +done |
| 121 | +echo "branch: ${BRANCH_NAME}" |
| 122 | +echo |
| 123 | + |
| 124 | +# Validate all pairs before touching anything |
| 125 | +for i in "${!SRC_PATHS[@]}"; do |
| 126 | + [[ -e "${SOURCE_DIR}/${SRC_PATHS[$i]}" ]] || { echo "error: '${SRC_PATHS[$i]}' not found in source repo"; exit 1; } |
| 127 | + if [[ -z "${SINCE_REF}" ]]; then |
| 128 | + while IFS= read -r -d '' src_dir; do |
| 129 | + rel="${src_dir#"${SOURCE_DIR}/${SRC_PATHS[$i]}"}" |
| 130 | + dest_dir="${DEST_DIR}/${DEST_PATHS[$i]}${rel}" |
| 131 | + if [[ -d "${dest_dir}" ]] && \ |
| 132 | + find "${src_dir}" -maxdepth 1 -name "*.go" -print -quit 2>/dev/null | grep -q . && \ |
| 133 | + find "${dest_dir}" -maxdepth 1 -name "*.go" -print -quit 2>/dev/null | grep -q .; then |
| 134 | + echo "error: Go package conflict at '${DEST_PATHS[$i]}${rel}': both source and destination contain Go files" |
| 135 | + exit 1 |
| 136 | + fi |
| 137 | + done < <(find "${SOURCE_DIR}/${SRC_PATHS[$i]}" -type d -print0) |
| 138 | + fi |
| 139 | +done |
| 140 | + |
| 141 | +if git -C "${DEST_DIR}" rev-parse --verify "${BRANCH_NAME}" &>/dev/null; then |
| 142 | + echo "error: branch '${BRANCH_NAME}' already exists; delete it first:" |
| 143 | + echo " git -C ${DEST_DIR} branch -D ${BRANCH_NAME}" |
| 144 | + exit 1 |
| 145 | +fi |
| 146 | + |
| 147 | +if ! git -C "${DEST_DIR}" diff --quiet || ! git -C "${DEST_DIR}" diff --cached --quiet; then |
| 148 | + echo "error: destination repo has uncommitted changes" |
| 149 | + exit 1 |
| 150 | +fi |
| 151 | + |
| 152 | +# Build filter-repo args for all pairs |
| 153 | +FILTER_ARGS=() |
| 154 | +for i in "${!SRC_PATHS[@]}"; do |
| 155 | + FILTER_ARGS+=(--path "${SRC_PATHS[$i]}") |
| 156 | + [[ "${SRC_PATHS[$i]}" != "${DEST_PATHS[$i]}" ]] && FILTER_ARGS+=(--path-rename "${SRC_PATHS[$i]}:${DEST_PATHS[$i]}") |
| 157 | +done |
| 158 | + |
| 159 | +# Clone source, filter to target paths, and rewrite bare #NNN issue references to |
| 160 | +# SOURCE_ORG/SOURCE_REPO_NAME#NNN so they link to the original repo rather than the |
| 161 | +# destination. Matches only #NNN preceded by start-of-line, space, '(' or ',' to |
| 162 | +# avoid hex literals, code-block references, and already-qualified org/repo#NNN refs. |
| 163 | +MSG_CALLBACK="import re |
| 164 | +return re.sub(rb'(?m)(^|[ (,])#(\\d+)', lambda m: m.group(1) + b'${SOURCE_ORG}/${SOURCE_REPO_NAME}#' + m.group(2), message)" |
| 165 | + |
| 166 | +rm -rf "${FILTER_WORK_DIR}" |
| 167 | +git clone "file://${SOURCE_DIR}" "${FILTER_WORK_DIR}" |
| 168 | +git -C "${FILTER_WORK_DIR}" filter-repo "${FILTER_ARGS[@]}" \ |
| 169 | + --message-callback "${MSG_CALLBACK}" \ |
| 170 | + --force |
| 171 | + |
| 172 | +if [[ -n "${SINCE_REF}" ]]; then |
| 173 | + # Use filter-repo's commit-map to translate SINCE_REF into the filtered history. |
| 174 | + # Find the latest source commit at or before SINCE_REF that touched any of the paths. |
| 175 | + SINCE_SRC_SHA=$(git -C "${SOURCE_DIR}" rev-list -1 "${SINCE_REF}" -- "${SRC_PATHS[@]}") |
| 176 | + if [[ -z "${SINCE_SRC_SHA}" ]]; then |
| 177 | + echo "no commits in source before ${SINCE_REF} touching the specified paths; nothing to sync" |
| 178 | + exit 0 |
| 179 | + fi |
| 180 | + COMMIT_MAP="${FILTER_WORK_DIR}/.git/filter-repo/commit-map" |
| 181 | + SINCE_FILTERED=$(awk -v sha="${SINCE_SRC_SHA}" 'NR>1 && $1==sha {print $2; exit}' "${COMMIT_MAP}") |
| 182 | + if [[ -z "${SINCE_FILTERED}" || "${SINCE_FILTERED}" == "0000000000000000000000000000000000000000" ]]; then |
| 183 | + echo "error: could not map ${SINCE_SRC_SHA} to filter-work; the commit may not touch the specified paths" |
| 184 | + exit 1 |
| 185 | + fi |
| 186 | + COMMITS=() |
| 187 | + while IFS= read -r line; do COMMITS+=("${line}"); done \ |
| 188 | + < <(git -C "${FILTER_WORK_DIR}" rev-list --reverse "${SINCE_FILTERED}..HEAD" 2>/dev/null || true) |
| 189 | +else |
| 190 | + COMMITS=() |
| 191 | + while IFS= read -r line; do COMMITS+=("${line}"); done \ |
| 192 | + < <(git -C "${FILTER_WORK_DIR}" rev-list --reverse HEAD 2>/dev/null || true) |
| 193 | +fi |
| 194 | + |
| 195 | +if [[ ${#COMMITS[@]} -eq 0 ]]; then |
| 196 | + [[ -n "${SINCE_REF}" ]] && { echo "no new commits since ${SINCE_REF} affecting the specified paths"; exit 0; } |
| 197 | + echo "error: no commits found for given paths; check the paths" |
| 198 | + exit 1 |
| 199 | +fi |
| 200 | +echo "${#COMMITS[@]} commits found" |
| 201 | + |
| 202 | +# Apply filtered commits to a new branch in destination |
| 203 | +git -C "${DEST_DIR}" checkout -b "${BRANCH_NAME}" |
| 204 | +trap 'git -C "${DEST_DIR}" remote remove _migration 2>/dev/null || true' EXIT |
| 205 | +git -C "${DEST_DIR}" remote add _migration "${FILTER_WORK_DIR}" |
| 206 | +git -C "${DEST_DIR}" fetch _migration |
| 207 | + |
| 208 | +if [[ -n "${SINCE_REF}" ]]; then |
| 209 | + if ! git -C "${DEST_DIR}" -c merge.directoryRenames=false cherry-pick --signoff -S "${COMMITS[@]}"; then |
| 210 | + echo |
| 211 | + echo "error: cherry-pick stopped due to conflicts" |
| 212 | + echo " resolve, then: git -C ${DEST_DIR} cherry-pick --continue" |
| 213 | + exit 1 |
| 214 | + fi |
| 215 | +else |
| 216 | + MERGE_MSG="migrate: import from ${SOURCE_MODULE}"$'\n' |
| 217 | + for i in "${!SRC_PATHS[@]}"; do |
| 218 | + MERGE_MSG+=$'\n'" ${SRC_PATHS[$i]} -> ${DEST_PATHS[$i]}" |
| 219 | + done |
| 220 | + git -C "${DEST_DIR}" merge --allow-unrelated-histories --signoff -S "_migration/${MAIN_BRANCH}" \ |
| 221 | + --no-edit -m "${MERGE_MSG}" |
| 222 | +fi |
| 223 | + |
| 224 | +# Rewrite imports - single pass, all pairs applied per file |
| 225 | +declare -a OLD_IMPORTS NEW_IMPORTS |
| 226 | +for i in "${!SRC_PATHS[@]}"; do |
| 227 | + OLD_IMPORTS+=("${SOURCE_MODULE}/${SRC_PATHS[$i]}") |
| 228 | + NEW_IMPORTS+=("${DEST_MODULE}/${DEST_PATHS[$i]}") |
| 229 | + echo "rewriting imports: ${SOURCE_MODULE}/${SRC_PATHS[$i]} -> ${DEST_MODULE}/${DEST_PATHS[$i]}" |
| 230 | +done |
| 231 | + |
| 232 | +CHANGED=0 |
| 233 | +while IFS= read -r -d '' f; do |
| 234 | + for i in "${!OLD_IMPORTS[@]}"; do |
| 235 | + if grep -qF "\"${OLD_IMPORTS[$i]}" "${f}"; then |
| 236 | + SED_ARGS=() |
| 237 | + for j in "${!OLD_IMPORTS[@]}"; do |
| 238 | + SED_ARGS+=(-e "s|\"${OLD_IMPORTS[$j]}/|\"${NEW_IMPORTS[$j]}/|g") |
| 239 | + SED_ARGS+=(-e "s|\"${OLD_IMPORTS[$j]}\"|\"${NEW_IMPORTS[$j]}\"|g") |
| 240 | + done |
| 241 | + sed -i "${SED_ARGS[@]}" "${f}" |
| 242 | + echo " ${f#"${DEST_DIR}/"}" |
| 243 | + CHANGED=$((CHANGED + 1)) |
| 244 | + break |
| 245 | + fi |
| 246 | + done |
| 247 | +done < <(find "${DEST_DIR}" -name "*.go" -not -path "*/.git/*" -print0) |
| 248 | +echo "${CHANGED} file(s) updated" |
| 249 | + |
| 250 | +cd "${DEST_DIR}" |
| 251 | +go mod tidy || echo "warning: go mod tidy has errors (expected for partial migrations with unresolved cross-package deps)" |
| 252 | +go build ./... || echo "warning: go build has errors (expected for partial migrations with unresolved cross-package deps)" |
| 253 | + |
| 254 | +if ! git diff --quiet || ! git diff --cached --quiet; then |
| 255 | + IMPORT_MSG="chore: rewrite imports from ${SOURCE_MODULE}"$'\n' |
| 256 | + for i in "${!SRC_PATHS[@]}"; do |
| 257 | + IMPORT_MSG+=$'\n'" ${SOURCE_MODULE}/${SRC_PATHS[$i]} -> ${DEST_MODULE}/${DEST_PATHS[$i]}" |
| 258 | + done |
| 259 | + git add -A |
| 260 | + git commit --signoff -S -m "${IMPORT_MSG}" |
| 261 | +fi |
| 262 | + |
| 263 | +# Open PR |
| 264 | +if [[ -n "${SINCE_REF}" ]]; then |
| 265 | + PR_TITLE="sync: ${SOURCE_MODULE} since ${SINCE_REF} @ ${SOURCE_SHA}" |
| 266 | + PR_INTRO="Picks up ${#COMMITS[@]} commit(s) to \`${SOURCE_MODULE}\` since \`${SINCE_REF}\` (now @ ${SOURCE_SHA}):" |
| 267 | +else |
| 268 | + PR_TITLE="migrate: ${SOURCE_MODULE} @ ${SOURCE_SHA}" |
| 269 | + PR_INTRO="Migrates the following paths from \`${SOURCE_MODULE}\` (@ ${SOURCE_SHA}) with full git history:" |
| 270 | +fi |
| 271 | + |
| 272 | +PR_BODY="${PR_INTRO}"$'\n' |
| 273 | +for i in "${!SRC_PATHS[@]}"; do |
| 274 | + PR_BODY+=$'\n'"- \`${SRC_PATHS[$i]}\` → \`${DEST_PATHS[$i]}\`" |
| 275 | +done |
| 276 | +PR_BODY+=$'\n\n'"To pick up future upstream changes: \`migrate-gaie-paths.sh --since ${SOURCE_SHA}" |
| 277 | +for i in "${!SRC_PATHS[@]}"; do |
| 278 | + PR_BODY+=" ${SRC_PATHS[$i]} ${DEST_PATHS[$i]}" |
| 279 | +done |
| 280 | +PR_BODY+="\`" |
| 281 | +[[ -z "${SINCE_REF}" ]] && PR_BODY+=$'\n\n'"**Merge via \"Create a merge commit\"** — squash or rebase discards the migrated history." |
| 282 | + |
| 283 | +if [[ "${NO_PUSH:-0}" == "1" ]]; then |
| 284 | + echo |
| 285 | + echo "NO_PUSH=1 — skipping git push and PR creation." |
| 286 | + echo "PR title would be: ${PR_TITLE}" |
| 287 | + echo "To push manually: git -C ${DEST_DIR} push ${ORIGIN_REMOTE} ${BRANCH_NAME}" |
| 288 | +else |
| 289 | + git push "${ORIGIN_REMOTE}" "${BRANCH_NAME}" |
| 290 | + gh pr create \ |
| 291 | + --repo "${DEST_ORG}/${DEST_REPO_NAME}" \ |
| 292 | + --head "${GITHUB_USER}:${BRANCH_NAME}" \ |
| 293 | + --base "${MAIN_BRANCH}" \ |
| 294 | + --title "${PR_TITLE}" \ |
| 295 | + --body "${PR_BODY}" |
| 296 | +fi |
0 commit comments