Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
234 lines
8.6 KiB
Bash
Executable File
234 lines
8.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# sync-upstream: move both forks onto a new upstream release.
|
|
#
|
|
# For each repo it fetches upstream, cuts a fresh v<version>-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 <<EOF
|
|
|
|
Resolve the conflict in ${repo}, then:
|
|
git -C ${repo} add <files>
|
|
$(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 <<EOF
|
|
|
|
Next steps:
|
|
1. Verify the build: scripts/build-image
|
|
2. Review the history: git -C ${UI_REPO} log --oneline ${TARGET}..HEAD
|
|
git -C ${BACKEND_REPO} log --oneline ${TARGET}..HEAD
|
|
3. Push both repos: git -C ${UI_REPO} push -u origin ${NEW_BRANCH}
|
|
git -C ${BACKEND_REPO} push -u origin ${NEW_BRANCH}
|
|
4. Update the version table in AGENTS.md
|
|
EOF
|