Files
ffx/tools/merge_dev_into_main.sh
Javanaut 1bead05d19 ff
2026-04-16 19:36:40 +02:00

449 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
DEV_BRANCH="dev"
MAIN_BRANCH="main"
ORIGIN_REMOTE="origin"
DEFAULT_AGENT_DEVELOPMENT_PATHS=(
"AGENTS.md"
"SCRATCHPAD.md"
"guidance"
"requirements"
"prompts"
"process"
"tools/merge_dev_into_main.sh"
)
AGENT_DEVELOPMENT_PATHS=("${DEFAULT_AGENT_DEVELOPMENT_PATHS[@]}")
CURRENT_BRANCH="${DEV_BRANCH}"
ASSUME_YES=0
DRY_RUN=0
SKIP_TESTS=0
usage() {
cat <<EOF
Usage: $(basename "$0") [--yes] [--dry-run] [--skip-tests] [--help]
Merge the local ${DEV_BRANCH} branch into ${MAIN_BRANCH}, remove agent-development files
from ${MAIN_BRANCH}, auto-resolve merge conflicts limited to those cleanup paths,
create a release merge commit and tag, push to ${ORIGIN_REMOTE}/${MAIN_BRANCH}, and
switch back to ${DEV_BRANCH}.
Options:
--yes Skip the interactive confirmation prompt.
--dry-run Print the validated release plan without changing git state.
--skip-tests Skip the default pre-release test gate (./tools/test.sh).
--help Show this help text.
Environment overrides:
FFX_RELEASE_CLEAN_PATHS Colon-separated path list to remove from ${MAIN_BRANCH}
after merging ${DEV_BRANCH}. Defaults to:
${DEFAULT_AGENT_DEVELOPMENT_PATHS[*]}
EOF
}
fail() {
printf '%s\n' "$*" >&2
exit 1
}
cleanup() {
local exit_code="$1"
trap - EXIT
if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
printf 'Merge is incomplete; aborting merge on %s...\n' "${CURRENT_BRANCH}" >&2
git merge --abort >/dev/null 2>&1 || true
fi
if [ "${CURRENT_BRANCH}" != "${DEV_BRANCH}" ]; then
printf 'Switching back to %s...\n' "${DEV_BRANCH}" >&2
git switch "${DEV_BRANCH}" >/dev/null 2>&1 || true
CURRENT_BRANCH="${DEV_BRANCH}"
fi
exit "${exit_code}"
}
load_cleanup_paths() {
if [ -n "${FFX_RELEASE_CLEAN_PATHS:-}" ]; then
IFS=':' read -r -a AGENT_DEVELOPMENT_PATHS <<< "${FFX_RELEASE_CLEAN_PATHS}"
fi
if [ "${#AGENT_DEVELOPMENT_PATHS[@]}" -eq 0 ]; then
fail "Release cleanup path list is empty."
fi
}
path_is_cleanup_target() {
local candidate_path="$1"
local cleanup_path=""
for cleanup_path in "${AGENT_DEVELOPMENT_PATHS[@]}"; do
case "${candidate_path}" in
"${cleanup_path}"|"${cleanup_path}"/*)
return 0
;;
esac
done
return 1
}
auto_resolve_cleanup_conflicts() {
local unmerged_paths=()
local non_cleanup_conflicts=()
local remaining_conflicts=()
local conflicted_path=""
mapfile -t unmerged_paths < <(git diff --name-only --diff-filter=U)
if [ "${#unmerged_paths[@]}" -eq 0 ]; then
return 1
fi
for conflicted_path in "${unmerged_paths[@]}"; do
if ! path_is_cleanup_target "${conflicted_path}"; then
non_cleanup_conflicts+=("${conflicted_path}")
fi
done
if [ "${#non_cleanup_conflicts[@]}" -ne 0 ]; then
printf 'Merge produced non-cleanup conflicts:\n' >&2
for conflicted_path in "${non_cleanup_conflicts[@]}"; do
printf ' - %s\n' "${conflicted_path}" >&2
done
return 1
fi
printf 'Auto-resolving merge conflicts for release-cleanup paths:\n'
for conflicted_path in "${unmerged_paths[@]}"; do
printf ' - %s\n' "${conflicted_path}"
done
git rm -r -f --ignore-unmatch "${AGENT_DEVELOPMENT_PATHS[@]}" >/dev/null
mapfile -t remaining_conflicts < <(git diff --name-only --diff-filter=U)
if [ "${#remaining_conflicts[@]}" -ne 0 ]; then
printf 'Cleanup conflict auto-resolution left unresolved paths:\n' >&2
for conflicted_path in "${remaining_conflicts[@]}"; do
printf ' - %s\n' "${conflicted_path}" >&2
done
return 1
fi
return 0
}
require_repo_state() {
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
fail "This helper must be run inside a git repository."
fi
if ! git show-ref --verify --quiet "refs/heads/${DEV_BRANCH}"; then
fail "Local branch '${DEV_BRANCH}' does not exist."
fi
if ! git show-ref --verify --quiet "refs/heads/${MAIN_BRANCH}"; then
fail "Local branch '${MAIN_BRANCH}' does not exist."
fi
if ! git remote get-url "${ORIGIN_REMOTE}" >/dev/null 2>&1; then
fail "Remote '${ORIGIN_REMOTE}' is not configured."
fi
}
require_dev_checkout() {
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "${CURRENT_BRANCH}" != "${DEV_BRANCH}" ]; then
fail "Current branch is '${CURRENT_BRANCH}', but '${DEV_BRANCH}' is required."
fi
}
require_clean_worktree() {
if [ -n "$(git status --porcelain)" ]; then
fail "Local '${DEV_BRANCH}' branch is dirty. Commit, stash, or clean changes first."
fi
}
fetch_remote_state() {
printf 'Fetching %s branch and tag state...\n' "${ORIGIN_REMOTE}"
git fetch "${ORIGIN_REMOTE}" "${DEV_BRANCH}" "${MAIN_BRANCH}" --tags >/dev/null
}
branch_divergence_counts() {
local branch="$1"
local remote_only=""
local local_only=""
if ! git show-ref --verify --quiet "refs/remotes/${ORIGIN_REMOTE}/${branch}"; then
fail "Remote branch '${ORIGIN_REMOTE}/${branch}' does not exist."
fi
read -r remote_only local_only < <(
git rev-list --left-right --count \
"refs/remotes/${ORIGIN_REMOTE}/${branch}...refs/heads/${branch}"
)
printf '%s %s\n' "${remote_only}" "${local_only}"
}
fast_forward_branch_to_remote() {
local branch="$1"
local remote_ref="refs/remotes/${ORIGIN_REMOTE}/${branch}"
local current_head=""
current_head="$(git rev-parse --abbrev-ref HEAD)"
printf "Fast-forwarding local branch '%s' to '%s/%s'...\n" \
"${branch}" \
"${ORIGIN_REMOTE}" \
"${branch}"
if [ "${current_head}" = "${branch}" ]; then
git merge --ff-only "${remote_ref}" >/dev/null
return 0
fi
git branch -f "${branch}" "${remote_ref}" >/dev/null
}
sync_release_source_branch() {
local branch="$1"
local remote_only=""
local local_only=""
read -r remote_only local_only < <(branch_divergence_counts "${branch}")
if [ "${remote_only}" -ne 0 ] && [ "${local_only}" -ne 0 ]; then
fail "Local branch '${branch}' has diverged from '${ORIGIN_REMOTE}/${branch}' (${local_only} local-only commit(s), ${remote_only} remote-only commit(s)). Reconcile the branches first."
fi
if [ "${remote_only}" -ne 0 ]; then
fast_forward_branch_to_remote "${branch}"
fi
if [ "${local_only}" -ne 0 ]; then
printf "Notice: local branch '%s' is ahead of '%s/%s' by %s commit(s); release will use the local tip.\n" \
"${branch}" \
"${ORIGIN_REMOTE}" \
"${branch}" \
"${local_only}"
fi
}
sync_release_target_branch() {
local branch="$1"
local remote_only=""
local local_only=""
read -r remote_only local_only < <(branch_divergence_counts "${branch}")
if [ "${remote_only}" -ne 0 ] && [ "${local_only}" -ne 0 ]; then
fail "Local branch '${branch}' has diverged from '${ORIGIN_REMOTE}/${branch}' (${local_only} local-only commit(s), ${remote_only} remote-only commit(s)). Reconcile the branches first."
fi
if [ "${local_only}" -ne 0 ]; then
fail "Local branch '${branch}' is ahead of '${ORIGIN_REMOTE}/${branch}' by ${local_only} commit(s). Push or reconcile first so the release starts from the published ${branch} tip."
fi
if [ "${remote_only}" -ne 0 ]; then
fast_forward_branch_to_remote "${branch}"
fi
}
resolve_release_version() {
local version_from_pyproject=""
local version_from_constants=""
version_from_pyproject="$(
sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1
)"
version_from_constants="$(
sed -n "s/^VERSION='\(.*\)'$/\1/p" src/ffx/constants.py | head -n 1
)"
if [ -z "${version_from_pyproject}" ]; then
fail "Could not resolve release version from pyproject.toml."
fi
if [ -z "${version_from_constants}" ]; then
fail "Could not resolve release version from src/ffx/constants.py."
fi
if [ "${version_from_pyproject}" != "${version_from_constants}" ]; then
fail "Version mismatch: pyproject.toml=${version_from_pyproject}, src/ffx/constants.py=${version_from_constants}."
fi
printf '%s\n' "${version_from_pyproject}"
}
require_release_tag_available() {
local release_version="$1"
local release_tag="v${release_version}"
if git rev-parse -q --verify "refs/tags/${release_tag}" >/dev/null 2>&1; then
fail "Tag '${release_tag}' already exists."
fi
if git rev-parse -q --verify "refs/tags/${release_version}" >/dev/null 2>&1; then
fail "Bare tag '${release_version}' already exists; refusing to create ambiguous release tags."
fi
}
run_pre_release_tests() {
if [ "${SKIP_TESTS}" -eq 1 ]; then
printf 'Skipping pre-release tests.\n'
return 0
fi
if [ ! -x "./tools/test.sh" ]; then
fail "Missing executable test runner at ./tools/test.sh."
fi
printf 'Running pre-release tests via ./tools/test.sh...\n'
./tools/test.sh
}
print_release_plan() {
local release_version="$1"
local release_tag="v${release_version}"
local release_commit_message="Release ${release_tag}"
printf 'Dry run only. Planned steps:\n'
printf '1. Ensure current branch is %s and the worktree is clean.\n' "${DEV_BRANCH}"
printf '2. Fetch %s, fast-forward local %s and %s from %s when safe, and fail on divergence or unpublished local %s commits.\n' \
"${ORIGIN_REMOTE}" \
"${DEV_BRANCH}" \
"${MAIN_BRANCH}" \
"${ORIGIN_REMOTE}" \
"${MAIN_BRANCH}"
if [ "${SKIP_TESTS}" -eq 1 ]; then
printf '3. Skip the pre-release test gate.\n'
else
printf '3. Run ./tools/test.sh as the pre-release test gate.\n'
fi
printf '4. Switch to %s and merge %s with --no-ff --no-commit.\n' "${MAIN_BRANCH}" "${DEV_BRANCH}"
printf '5. Auto-resolve merge conflicts limited to release-cleanup paths and remove them from %s:\n' "${MAIN_BRANCH}"
local cleanup_path=""
for cleanup_path in "${AGENT_DEVELOPMENT_PATHS[@]}"; do
printf ' - %s\n' "${cleanup_path}"
done
printf '6. Create merge commit: %s\n' "${release_commit_message}"
printf '7. Create annotated tag: %s\n' "${release_tag}"
printf '8. Push %s to %s/%s with --follow-tags.\n' "${MAIN_BRANCH}" "${ORIGIN_REMOTE}" "${MAIN_BRANCH}"
printf '9. Switch back to %s.\n' "${DEV_BRANCH}"
}
trap 'cleanup $?' EXIT
while [ "$#" -gt 0 ]; do
case "$1" in
--yes)
ASSUME_YES=1
;;
--dry-run)
DRY_RUN=1
;;
--skip-tests)
SKIP_TESTS=1
;;
--help|-h)
usage
exit 0
;;
*)
usage >&2
fail "Unknown option: $1"
;;
esac
shift
done
load_cleanup_paths
require_repo_state
require_dev_checkout
require_clean_worktree
fetch_remote_state
sync_release_source_branch "${DEV_BRANCH}"
sync_release_target_branch "${MAIN_BRANCH}"
RELEASE_VERSION="$(resolve_release_version)"
RELEASE_TAG="v${RELEASE_VERSION}"
RELEASE_COMMIT_MESSAGE="Release ${RELEASE_TAG}"
require_release_tag_available "${RELEASE_VERSION}"
printf 'This will merge %s into %s, remove agent-development files on %s,\n' "${DEV_BRANCH}" "${MAIN_BRANCH}" "${MAIN_BRANCH}"
printf 'auto-resolve cleanup-path conflicts, run the pre-release gate%s, create %s,\n' \
"$([ "${SKIP_TESTS}" -eq 1 ] && printf ' (skipped)' || printf '')" \
"${RELEASE_TAG}"
printf 'push to %s/%s, and switch back to %s.\n' \
"${ORIGIN_REMOTE}" \
"${MAIN_BRANCH}" \
"${DEV_BRANCH}"
if [ "${ASSUME_YES}" -ne 1 ]; then
printf 'Are you sure? [y/N] '
read -r confirmation
case "${confirmation}" in
y|Y|yes|YES)
;;
*)
fail "Aborted by user."
;;
esac
fi
if [ "${DRY_RUN}" -eq 1 ]; then
print_release_plan "${RELEASE_VERSION}"
exit 0
fi
run_pre_release_tests
require_clean_worktree
fetch_remote_state
sync_release_source_branch "${DEV_BRANCH}"
sync_release_target_branch "${MAIN_BRANCH}"
require_release_tag_available "${RELEASE_VERSION}"
git switch "${MAIN_BRANCH}" >/dev/null
CURRENT_BRANCH="${MAIN_BRANCH}"
printf 'Merging %s into %s...\n' "${DEV_BRANCH}" "${MAIN_BRANCH}"
if ! git merge --no-ff --no-commit "${DEV_BRANCH}"; then
if ! auto_resolve_cleanup_conflicts; then
fail "Merge from '${DEV_BRANCH}' into '${MAIN_BRANCH}' failed."
fi
fi
if ! git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
fail "'${MAIN_BRANCH}' is already up to date with '${DEV_BRANCH}'. Nothing to merge."
fi
printf 'Removing agent-development files from %s...\n' "${MAIN_BRANCH}"
git rm -r -f --ignore-unmatch "${AGENT_DEVELOPMENT_PATHS[@]}" >/dev/null
if git diff --cached --quiet; then
fail "No staged changes are present after merging '${DEV_BRANCH}' into '${MAIN_BRANCH}'."
fi
printf 'Creating release merge commit: %s\n' "${RELEASE_COMMIT_MESSAGE}"
git commit -m "${RELEASE_COMMIT_MESSAGE}"
printf 'Creating annotated tag: %s\n' "${RELEASE_TAG}"
git tag -a "${RELEASE_TAG}" -m "FFX ${RELEASE_VERSION}"
printf 'Pushing %s and annotated tags to %s...\n' "${MAIN_BRANCH}" "${ORIGIN_REMOTE}"
git push "${ORIGIN_REMOTE}" "${MAIN_BRANCH}" --follow-tags
printf 'Switching back to %s...\n' "${DEV_BRANCH}"
git switch "${DEV_BRANCH}" >/dev/null
CURRENT_BRANCH="${DEV_BRANCH}"
printf 'Release merge complete: %s pushed to %s/%s and tagged as %s.\n' \
"${RELEASE_COMMIT_MESSAGE}" \
"${ORIGIN_REMOTE}" \
"${MAIN_BRANCH}" \
"${RELEASE_TAG}"