scripts/merge-release-branch.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Merge a rel-* branch back into the current branch with the conventions
# this repo relies on. Handles a few release-presentation differences that
# would otherwise either silently corrupt the working tree or require
# manual conflict resolution after every release-branch merge.
#
# Specifically:
#
# 1. Pass `-X no-renames` so git's rename detection does not conflate the
# rel branch's NEWS.md rename (NEWS.md -> version/news/os/NEWS-<ver>.md)
# with main's own rotation of NEWS.md. Without this, `--theirs` on the
# archived NEWS path returns git's three-way merged result rather than
# the rel branch's actual content, silently corrupting the release notes.
#
# 2. Auto-resolve the expected conflicts:
# - NEWS.md (deleted-by-them): keep main's rotated copy.
# - version/news/os/NEWS-<ver>-<codename>.md (both-added): take the rel
# branch's content, which has any bug-fix entries that landed after
# the rotation.
# - Any conflict under a release-only directory (e.g. the whats-new
# assets): keep main's version, matching the reset policy below.
#
# 3. Reset release-only paths (version/BUILDTYPE, the whats-new assets
# directory, etc.) to HEAD's value. Git's trivial-merge fast path
# would otherwise silently take the rel branch's content (e.g.
# flipping BUILDTYPE from "Daily" to "Release", or pulling in
# release-finalized whats-new updates) because main has not modified
# the file since the merge base. The `merge=ours` driver attribute
# does not help here: it is only consulted when both sides have
# changed the file, not in the one-sided trivial-merge case.
#
# The branch argument is resolved to a remote-tracking branch (e.g.
# "rel-golden-wattle" becomes "<remote>/rel-golden-wattle") so the merge
# always reflects what is published rather than a possibly-stale local
# copy. Pass a qualified ref like "origin/rel-golden-wattle" to skip the
# inference. The script also fetches the resolved branch before merging.
#
# The script stages a merge commit but does not commit it. Review with
# `git diff --staged` and then `git commit` once satisfied.
set -euo pipefail
if [ $# -lt 1 ]; then
echo "usage: $0 <branch>" >&2
echo "" >&2
echo "Examples:" >&2
echo " $0 rel-golden-wattle # resolves to <remote>/rel-golden-wattle" >&2
echo " $0 origin/rel-golden-wattle # use an already-qualified ref" >&2
exit 64
fi
BRANCH="$1"
# Move to the repo root.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
# Working tree must be clean so the merge state is unambiguous.
if ! git diff-index --quiet HEAD --; then
echo "error: working tree has uncommitted changes; commit or stash first." >&2
exit 1
fi
# Files that should always retain main's value after a rel-branch merge,
# regardless of what the rel branch did to them.
RELEASE_ONLY_FILES=(
version/BUILDTYPE
version/RELEASE
version/CALENDAR_VERSION
version/PATCH
)
# Directories whose contents should always retain main's value after a
# rel-branch merge. Any file under these paths that the merge modified
# (or added on the rel branch side) is reverted to HEAD.
RELEASE_ONLY_DIRS=(
src/node/desktop/src/assets/whats-new
)
# Always resolve to a remote-tracking branch so the merge picks up what's
# actually published, not a possibly-stale local copy. If the user passes
# a qualified ref (e.g. "origin/rel-golden-wattle"), use it as-is. If they
# pass a bare branch name, prefix it with the remote tracked by the
# current branch (falling back to "origin", then to the first configured
# remote).
ORIGINAL_INPUT="$BRANCH"
REMOTE=""
if [[ "$BRANCH" != refs/* ]]; then
while IFS= read -r r; do
[ -n "$r" ] || continue
if [[ "$BRANCH" == "$r/"* ]]; then
REMOTE="$r"
break
fi
done < <(git remote)
if [ -z "$REMOTE" ]; then
if upstream="$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null)" \
&& [[ "$upstream" == */* ]] \
&& git remote get-url "${upstream%%/*}" >/dev/null 2>&1; then
REMOTE="${upstream%%/*}"
elif git remote get-url origin >/dev/null 2>&1; then
REMOTE="origin"
else
REMOTE="$(git remote | head -n 1)"
fi
if [ -z "$REMOTE" ]; then
echo "error: no remotes configured; cannot resolve '$BRANCH'." >&2
exit 1
fi
BRANCH="$REMOTE/$BRANCH"
echo "Resolved '$ORIGINAL_INPUT' -> '$BRANCH'"
fi
fi
# Refresh the remote-tracking branch so the merge reflects the latest
# published state of the rel branch.
if [ -n "$REMOTE" ]; then
REMOTE_BRANCH="${BRANCH#$REMOTE/}"
echo "Fetching $REMOTE $REMOTE_BRANCH..."
git fetch "$REMOTE" "$REMOTE_BRANCH"
fi
# Do the merge.
echo "Merging $BRANCH (-X no-renames, --no-ff, --no-commit)..."
git merge --no-commit --no-ff -X no-renames "$BRANCH" || true
# Auto-resolve the two expected conflicts.
# (1) NEWS.md "deleted by them": keep main's version. `git add` on a file
# with a UD stage tells git we want our version (the working copy).
if git status --porcelain | grep -Eq '^UD NEWS\.md$'; then
git add NEWS.md
echo " resolved NEWS.md: kept main's version"
fi
# (2) version/news/os/NEWS-<ver>-<codename>.md "both added": take theirs.
while IFS= read -r path; do
case "$path" in
version/news/os/NEWS-*.md)
git checkout --theirs -- "$path"
git add -- "$path"
echo " resolved $path: took rel branch's content"
;;
esac
done < <(git diff --name-only --diff-filter=U)
# (3) Conflicts under release-only directories: main's version always
# wins, same policy the reset pass below applies to paths that merged
# cleanly. If the path exists in HEAD, restore HEAD's content into the
# index and working tree; otherwise remove it. Note `git checkout --ours`
# does not work for all conflict types (e.g. delete/modify, where our
# stage is absent), so prefer the HEAD-based form.
while IFS= read -r path; do
for d in "${RELEASE_ONLY_DIRS[@]}"; do
case "$path" in
"$d"/*)
if git cat-file -e "HEAD:$path" 2>/dev/null; then
git checkout HEAD -- "$path"
echo " resolved $path: kept main's version"
else
git rm -f -- "$path" >/dev/null
echo " resolved $path: removed (not present on main)"
fi
break
;;
esac
done
done < <(git diff --name-only --diff-filter=U)
# Report any remaining (unexpected) conflicts.
remaining="$(git diff --name-only --diff-filter=U)"
if [ -n "$remaining" ]; then
echo "" >&2
echo "Unresolved conflicts remain in:" >&2
echo "$remaining" | sed 's/^/ /' >&2
echo "" >&2
echo "Resolve manually, then 'git add' the file(s) and 'git commit'." >&2
exit 2
fi
# A clean merge with no conflicts means nothing was staged for merging --
# bail out rather than pretend.
MERGE_HEAD_PATH="$(git rev-parse --git-path MERGE_HEAD)"
if [ ! -f "$MERGE_HEAD_PATH" ]; then
echo "" >&2
echo "No merge in progress; nothing was changed." >&2
exit 1
fi
# Restore release-only files to their pre-merge (HEAD) values where the
# merge wandered. During a merge, `git checkout HEAD -- <path>` resets
# both the index and working tree to the pre-merge commit's content.
echo ""
for f in "${RELEASE_ONLY_FILES[@]}"; do
expected="$(git show "HEAD:$f")"
actual="$(cat "$f")"
if [ "$expected" != "$actual" ]; then
git checkout HEAD -- "$f"
echo " reset $f: '$actual' -> '$expected'"
fi
done
for d in "${RELEASE_ONLY_DIRS[@]}"; do
while IFS= read -r f; do
[ -z "$f" ] && continue
if git cat-file -e "HEAD:$f" 2>/dev/null; then
git checkout HEAD -- "$f"
echo " reset $f"
else
git rm -f -- "$f" >/dev/null
echo " removed $f (not present on main)"
fi
done < <(git diff --cached --name-only HEAD -- "$d")
done
# Summary so a human can eyeball that release-only files are correct.
echo ""
echo "Merge staged. Verify these values look right for main, then commit:"
for f in "${RELEASE_ONLY_FILES[@]}"; do
printf " %-32s = %s\n" "$f" "$(cat "$f")"
done
echo ""
echo "Then: git diff --staged && git commit"