#!/usr/bin/env bash # # sync-upstream: move both forks onto a new upstream release. # # For each repo it fetches upstream, cuts a fresh v-lofyer branch from # the target tag, and replays our fork-only commits onto it. Conflicts are left # in the worktree for you to resolve (upstream frequently reflows docs or # deletes files we keep), then re-run with --continue. # # Usage: # scripts/sync-upstream --list # show available upstream versions # scripts/sync-upstream v2.2.3 # bump both forks to v2.2.3 # scripts/sync-upstream v2.2.3 --dry-run # show the plan only # scripts/sync-upstream --continue # resume after resolving conflicts # # Notes: # - Refuses to run with dirty worktrees (except untracked files, which are # yours and are left alone). # - The tag must exist in BOTH upstreams, so frontend and backend never end up # on different releases. # - Nothing is pushed. Review, then push each repo yourself. # set -o errexit set -o nounset set -o pipefail source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" TARGET="" LIST=0 DRY_RUN=0 CONTINUE=0 while [[ $# -gt 0 ]]; do case "${1}" in --list) LIST=1 ;; --dry-run) DRY_RUN=1 ;; --continue) CONTINUE=1 ;; -h|--help) sed -n '2,24p' "${0}"; exit 0 ;; v*) TARGET="${1}" ;; *) fail "unknown argument: ${1}" ;; esac shift done require_cmd git require_repos # Ensure an 'upstream' remote exists and points at GitHub. ensure_upstream_remote() { local repo="${1}" url="${2}" if git -C "${repo}" remote get-url upstream >/dev/null 2>&1; then local current current="$(git -C "${repo}" remote get-url upstream)" if [[ "${current}" != "${url}" ]]; then log " updating upstream remote: ${current} -> ${url}" git -C "${repo}" remote set-url upstream "${url}" fi else log " adding upstream remote -> ${url}" git -C "${repo}" remote add upstream "${url}" fi } fetch_upstream() { local repo="${1}" url="${2}" name="${3}" log "fetching upstream for ${name}" ensure_upstream_remote "${repo}" "${url}" git -C "${repo}" fetch --tags --prune upstream >/dev/null 2>&1 } # Stable release tags only (drop rc/alpha/beta), newest last. list_versions() { git -C "${1}" tag --list 'v*' \ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ | sort -V } # --- resume path ------------------------------------------------------------- if [[ "${CONTINUE}" == "1" ]]; then for spec in "frontend:${UI_REPO}" "backend:${BACKEND_REPO}"; do name="${spec%%:*}"; repo="${spec#*:}" if [[ -d "${repo}/.git/sequencer" ]] || [[ -f "${repo}/.git/CHERRY_PICK_HEAD" ]]; then step "Resuming cherry-pick in ${name}" if git -C "${repo}" diff --name-only --diff-filter=U | grep -q .; then git -C "${repo}" diff --name-only --diff-filter=U | sed 's/^/ conflict: /' fail "${name} still has unresolved conflicts; resolve and 'git add' them first" fi GIT_EDITOR=true git -C "${repo}" cherry-pick --continue || true # The repo's lint hooks can stash/restore and upset the sequencer; if the # commit landed, clear the leftover state so the next pick can proceed. if [[ -d "${repo}/.git/sequencer" ]] && ! git -C "${repo}" diff --name-only --diff-filter=U | grep -q .; then git -C "${repo}" cherry-pick --quit 2>/dev/null || true fi log "${name}: resumed; re-run sync-upstream with the target version to replay remaining commits" fi done exit 0 fi # --- list path --------------------------------------------------------------- fetch_upstream "${UI_REPO}" "${UI_UPSTREAM_URL}" "frontend" fetch_upstream "${BACKEND_REPO}" "${BACKEND_UPSTREAM_URL}" "backend" if [[ "${LIST}" == "1" ]]; then step "Upstream stable releases present in BOTH repos" comm -12 <(list_versions "${UI_REPO}") <(list_versions "${BACKEND_REPO}") | tail -15 step "Current" log "frontend: $(repo_branch "${UI_REPO}")" log "backend : $(repo_branch "${BACKEND_REPO}")" exit 0 fi [[ -n "${TARGET}" ]] || fail "no target version given (try --list)" # --- preflight --------------------------------------------------------------- step "Preflight" CURRENT_VERSION="$(resolve_workspace_version)" log "current version: ${CURRENT_VERSION}" log "target version : ${TARGET}" [[ "${TARGET}" != "${CURRENT_VERSION}" ]] || fail "already on ${TARGET}" NEW_BRANCH="${TARGET}-lofyer" for spec in "frontend:${UI_REPO}" "backend:${BACKEND_REPO}"; do name="${spec%%:*}"; repo="${spec#*:}" git -C "${repo}" rev-parse -q --verify "refs/tags/${TARGET}" >/dev/null \ || fail "${name}: tag ${TARGET} not found upstream" # Tracked modifications block a clean branch cut; untracked files are the # user's own work and are preserved across checkout. if ! git -C "${repo}" diff-index --quiet HEAD -- 2>/dev/null; then git -C "${repo}" status --short | grep -v '^??' | sed 's/^/ /' fail "${name}: worktree has uncommitted changes; commit or stash them first" fi git -C "${repo}" rev-parse -q --verify "refs/heads/${NEW_BRANCH}" >/dev/null \ && fail "${name}: branch ${NEW_BRANCH} already exists" done log "both repos are clean and ${TARGET} exists in both upstreams" # Fork-only commits = on our branch but not in the new upstream tag, oldest first. # Fork-only commits: on our branch, not reachable from the new tag, and not # present on any upstream ref. # # `--cherry-pick --right-only` alone is not enough: upstream commits that were # rebased or squashed into the new release get a different patch-id and would be # replayed again. So we additionally drop anything that any upstream branch or # tag contains, which is what actually distinguishes our work from upstream's. fork_commits() { local repo="${1}" old_branch="${2}" c while read -r c; do [[ -n "${c}" ]] || continue # Skip commits reachable from any upstream remote branch or upstream tag. if git -C "${repo}" branch -r --contains "${c}" 2>/dev/null | grep -q '^\s*upstream/'; then continue fi if git -C "${repo}" tag --contains "${c}" 2>/dev/null | grep -q .; then continue fi echo "${c}" done < <(git -C "${repo}" log --reverse --no-merges --format='%H' \ "${TARGET}..${old_branch}" --cherry-pick --right-only) } # --- plan -------------------------------------------------------------------- step "Plan" declare -A PICKS for spec in "frontend:${UI_REPO}" "backend:${BACKEND_REPO}"; do name="${spec%%:*}"; repo="${spec#*:}" old_branch="$(repo_branch "${repo}")" mapfile -t commits < <(fork_commits "${repo}" "${old_branch}") PICKS["${name}"]="${commits[*]:-}" log "${name}: ${old_branch} -> ${NEW_BRANCH}, replaying ${#commits[@]} fork commit(s)" for c in "${commits[@]:-}"; do [[ -n "${c}" ]] && echo " $(git -C "${repo}" log --format='%h %s' -1 "${c}")" done done if [[ "${DRY_RUN}" == "1" ]]; then step "Dry run" log "no changes made" exit 0 fi # --- execute ----------------------------------------------------------------- for spec in "frontend:${UI_REPO}" "backend:${BACKEND_REPO}"; do name="${spec%%:*}"; repo="${spec#*:}" step "Updating ${name}" git -C "${repo}" checkout -b "${NEW_BRANCH}" "${TARGET}" log " cut ${NEW_BRANCH} from ${TARGET}" read -r -a commits <<< "${PICKS[${name}]}" for c in "${commits[@]:-}"; do [[ -n "${c}" ]] || continue subject="$(git -C "${repo}" log --format='%h %s' -1 "${c}")" if git -C "${repo}" cherry-pick "${c}" >/dev/null 2>&1; then log " picked ${subject}" else # A lint/pre-commit hook may have committed successfully while still # returning non-zero; treat "no conflicts left" as success. if git -C "${repo}" diff --name-only --diff-filter=U | grep -q .; then warn " CONFLICT while picking ${subject}" git -C "${repo}" diff --name-only --diff-filter=U | sed 's/^/ /' cat >&2 < $(dirname "${BASH_SOURCE[0]}")/sync-upstream --continue $(dirname "${BASH_SOURCE[0]}")/sync-upstream ${TARGET} # replay the rest EOF exit 1 fi git -C "${repo}" cherry-pick --quit 2>/dev/null || true log " picked ${subject} (hook noise ignored)" fi done done step "Done" log "both forks are on ${NEW_BRANCH}" cat <