Files
openwrt-build-helper/prepare-openwrt.sh
Zhe Yuan de7f3f12fd Rewrite the prepare scripts for current OpenWrt and more distros
prepare-openwrt-env.sh
- Dispatch on the package manager instead of guessing from the distro
  version: apt, dnf/yum, pacman, zypper and apk are all supported.
- Probe every package against the running release and skip the ones that
  no longer exist. Previously a single dropped package (on Ubuntu 26.04
  arm64, gcc-multilib-s390x-linux-gnu) failed the whole apt command and
  set -e aborted the script, so nothing was installed at all.
- Align the package lists with the official build system guide, adding
  libelf-dev, python3-dev, bc, xsltproc, zstd, u-boot-tools and others.
- Make Go opt-in via --with-go rather than forced on arm64.
- Add -y/--yes, -n/--dry-run and -h/--help; allow running as root.

prepare-openwrt.sh
- Split read-only discovery from mutation. Versions are resolved over
  git ls-remote before anything is moved, so aborting at the prompt
  leaves the helper checkout untouched.
- Resume an interrupted first run instead of failing forever: a root
  with .git but no source tree was previously unrecoverable without a
  manual rm -rf. Also restore the worktree when HEAD already points at
  the target ref, where checkout is a no-op.
- Detect the relocated layout by content rather than directory name, so
  renaming the root no longer triggers a second, nested relocation.
- Replace the staged in-place relocation with a resumable content move,
  removing the window that could strand the checkout in a hidden dir.
- Filter release candidates before sorting; sort -V orders v25.12.0-rc1
  after v25.12.0, so the newest stable tag could be an rc.
- Follow upstream: snapshots use main (master as fallback) and the dead
  -SNAPSHOT tag lookup is gone.
- Use a partial clone (--filter=blob:none) by default.
- Guard version switches on a dirty tree and back up .config first.
- Add /helper/ to .git/info/exclude and warn that git clean -xdff still
  deletes it, since -x bypasses ignore rules by design.
- Leave a symlink at the old checkout path so shells and editors whose
  working directory still points there keep working.
- Add --stable/--branch/--snapshot plus -y, -n, --force, --full-clone,
  --allow-rc, --root, --skip-feeds, --skip-defconfig, --repo-url and
  --no-compat-link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:31:26 -04:00

1025 lines
31 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# Prepare an OpenWrt build tree.
#
# Layout produced by this script:
# before <parent>/openwrt-build-helper/... this helper checkout
# first run <parent>/openwrt/ the OpenWrt source root
# <parent>/openwrt/helper/... this checkout, moved in
# later runs the same <parent>/openwrt/ is updated in place
#
# Nothing on disk is touched before the plan is printed and confirmed, so
# aborting at the prompt leaves the helper checkout exactly where it was.
#
# Usage: ./prepare-openwrt.sh [options]
#
# Version selection (mutually exclusive; omit all for an interactive menu):
# --stable [<ver>] stable release tag, e.g. 25.12.5 or v25.12.5
# without a value: newest stable tag (rc tags excluded)
# --branch <name> release branch: 24.10, openwrt-24.10, main, ...
# --snapshot newest snapshot (the 'main' branch)
#
# Behaviour:
# -y, --yes non-interactive, accept defaults, never prompt
# -n, --dry-run print the plan and every action, change nothing
# -f, --force override the safety guards (dirty tree, stray files)
# --full-clone fetch full history (default: --filter=blob:none)
# --allow-rc let --stable with no value pick a release candidate
# --root <dir> use <dir> as the OpenWrt root instead of <parent>/openwrt
# --skip-feeds do not run scripts/feeds update/install
# --skip-defconfig do not run make defconfig
# --repo-url <url> override the upstream URL (advanced / testing)
# --no-compat-link do not leave a symlink at the old checkout path
# -h, --help show this help
#
# After the first run the checkout lives at <parent>/openwrt/helper. A symlink
# is left behind at the original path so that shells, editors and agents whose
# working directory still points at <parent>/openwrt-build-helper keep working.
#
# Note: with the default partial clone, switching to another tag or branch
# later fetches the missing blobs on demand and therefore needs network.
#
# Author: Zhe Yuan
set -euo pipefail
umask 022
# --- Configuration ---
REPO_URL="https://github.com/openwrt/openwrt.git"
SNAPSHOT_BRANCH="main"
SNAPSHOT_FALLBACK="master"
# --- Options ---
VERSION_MODE=""
VERSION_INPUT=""
ASSUME_YES=false
DRY_RUN=false
FORCE=false
FULL_CLONE=false
ALLOW_RC=false
ROOT_OVERRIDE=""
SKIP_FEEDS=false
SKIP_DEFCONFIG=false
COMPAT_LINK=true
# --- Discovered state ---
SCRIPT_INVOKED_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# Physical path: layout decisions must see through a compatibility symlink,
# otherwise re-running through the old path would relocate an already
# relocated checkout.
SCRIPT_DIR="$(cd -- "$SCRIPT_INVOKED_DIR" && pwd -P)"
COMPAT_LINK_PATH=""
WORK_ROOT=""
HELPER_DIR=""
RELOCATE_MODE="none" # none | move | inplace
ROOT_STATE=""
TARGET_KIND="" # tag | branch
TARGET_REF=""
CURRENT_REF=""
REMOTE_TAGS=""
REMOTE_HEADS=""
LOCK_DIR=""
CONFIG_BACKUP=""
# --- Output helpers ---
info() { echo "[INFO] $*"; }
warn() { echo "[WARN] $*"; }
err() { echo "[ERROR] $*" >&2; }
# Wrap every mutating command so that --dry-run prints instead of running.
run() {
if [ "$DRY_RUN" = true ]; then
printf '[DRY-RUN]'
printf ' %q' "$@"
echo
return 0
fi
"$@"
}
usage() {
sed -n '2,42p' "$0" | sed 's/^# \{0,1\}//;s/^#$//'
}
set_version_mode() {
if [ -n "$VERSION_MODE" ]; then
err "--stable, --branch and --snapshot are mutually exclusive."
exit 1
fi
VERSION_MODE="$1"
}
# --- Argument parsing ---
while [ $# -gt 0 ]; do
case "$1" in
--stable)
set_version_mode stable
# Optional value: only consume $2 when it is not another option.
if [ $# -gt 1 ] && [ -n "${2:-}" ] && [ "${2#-}" = "$2" ]; then
VERSION_INPUT="$2"
shift
fi
;;
--branch)
set_version_mode branch
if [ $# -lt 2 ] || [ -z "${2:-}" ]; then
err "--branch requires a branch name."
exit 1
fi
VERSION_INPUT="$2"
shift
;;
--snapshot) set_version_mode snapshot ;;
-y|--yes) ASSUME_YES=true ;;
-n|--dry-run) DRY_RUN=true ;;
-f|--force) FORCE=true ;;
--full-clone) FULL_CLONE=true ;;
--allow-rc) ALLOW_RC=true ;;
--skip-feeds) SKIP_FEEDS=true ;;
--skip-defconfig) SKIP_DEFCONFIG=true ;;
--no-compat-link) COMPAT_LINK=false ;;
--root)
if [ $# -lt 2 ] || [ -z "${2:-}" ]; then
err "--root requires a directory."
exit 1
fi
ROOT_OVERRIDE="$2"
shift
;;
--repo-url)
if [ $# -lt 2 ] || [ -z "${2:-}" ]; then
err "--repo-url requires a URL."
exit 1
fi
REPO_URL="$2"
shift
;;
-h|--help) usage; exit 0 ;;
*)
err "Unknown option: $1"
echo "Run '$0 --help' for usage." >&2
exit 1
;;
esac
shift
done
# A closed stdin must not make `read` spin or hang.
if [ ! -t 0 ]; then
ASSUME_YES=true
fi
echo "========================================"
echo " OpenWrt Source Preparation"
echo "========================================"
# ---------------------------------------------------------------------------
# Generic helpers
# ---------------------------------------------------------------------------
# Resolve a directory to its physical path, returning non-zero instead of
# letting a bare `cd` abort the whole script under `set -e`.
abspath() {
local p="$1"
[ -d "$p" ] || return 1
( cd -- "$p" 2>/dev/null && pwd -P ) || return 1
}
is_git_root() {
local d="$1" top resolved
[ -e "$d/.git" ] || return 1
top="$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" || return 1
resolved="$(abspath "$d")" || return 1
[ "$top" = "$resolved" ]
}
# Content-only check: does not require the directory to be a git repository.
is_openwrt_tree() {
[ -f "$1/feeds.conf.default" ] && [ -f "$1/Makefile" ] && [ -d "$1/scripts" ]
}
is_openwrt_repo() {
is_git_root "$1" && is_openwrt_tree "$1"
}
has_openwrt_origin() {
local u
u="$(git -C "$1" remote get-url origin 2>/dev/null)" || return 1
case "$u" in
*openwrt/openwrt*|"$REPO_URL") return 0 ;;
*) return 1 ;;
esac
}
is_helper_checkout() {
local d="$1"
[ -f "$d/prepare-openwrt.sh" ] || return 1
# Complete checkout.
if [ -f "$d/prepare-openwrt-env.sh" ] && [ -f "$d/add-external-repos.sh" ]; then
return 0
fi
# Half-finished in-place move: some scripts already sit in helper/, so the
# full signature is gone. Recognizing this is what makes the move resumable.
if [ -d "$d/helper" ] &&
{ [ -f "$d/helper/prepare-openwrt-env.sh" ] ||
[ -f "$d/helper/add-external-repos.sh" ]; }; then
return 0
fi
[ -f "$d/prepare-openwrt-env.sh" ] || [ -f "$d/add-external-repos.sh" ]
}
require_sort_v() {
if [ "$(printf 'v1.10\nv1.9\n' | sort -V | head -n 1)" != "v1.9" ]; then
err "This script needs GNU 'sort -V' for version ordering."
exit 1
fi
}
confirm() {
# confirm "<question>" <default: yes|no>
local question="$1" default="$2" reply=""
if [ "$ASSUME_YES" = true ]; then
return 0
fi
if [ "$default" = "yes" ]; then
read -r -p "$question [Y/n]: " reply || reply=""
case "${reply:-}" in
[nN]|[nN][oO]) return 1 ;;
*) return 0 ;;
esac
else
read -r -p "$question [y/N]: " reply || reply=""
case "${reply:-}" in
[yY]|[yY][eE][sS]) return 0 ;;
*) return 1 ;;
esac
fi
}
release_lock() {
if [ -n "$LOCK_DIR" ] && [ -d "$LOCK_DIR" ]; then
rmdir "$LOCK_DIR" 2>/dev/null || true
fi
}
acquire_lock() {
[ "$DRY_RUN" = true ] && return 0
LOCK_DIR="$WORK_ROOT/.prepare-openwrt.lock"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
LOCK_DIR=""
err "Another prepare-openwrt.sh seems to be running in '$WORK_ROOT'."
err "If that is wrong, remove '$WORK_ROOT/.prepare-openwrt.lock'."
exit 1
fi
trap release_lock EXIT
}
# ---------------------------------------------------------------------------
# Phase A: layout discovery (read-only)
# ---------------------------------------------------------------------------
# Echoes one of:
# absent empty openwrt-complete openwrt-partial foreign-git nonempty
# notdir unreadable
classify_root() {
local d="$1" entry name
[ -e "$d" ] || { echo absent; return; }
[ -d "$d" ] || { echo notdir; return; }
{ [ -r "$d" ] && [ -x "$d" ]; } || { echo unreadable; return; }
if is_openwrt_repo "$d"; then echo openwrt-complete; return; fi
if is_git_root "$d"; then
if has_openwrt_origin "$d"; then
echo openwrt-partial
else
echo foreign-git
fi
return
fi
# Any real content other than the helper checkout and well-known junk?
while IFS= read -r entry; do
name="$(basename "$entry")"
case "$name" in
helper|.DS_Store|Thumbs.db|.directory|.prepare-openwrt.lock) continue ;;
*) echo nonempty; return ;;
esac
done < <(find "$d" -mindepth 1 -maxdepth 1)
echo empty
}
list_root_clutter() {
local d="$1" entry name
while IFS= read -r entry; do
name="$(basename "$entry")"
case "$name" in
helper|.DS_Store|Thumbs.db|.directory|.prepare-openwrt.lock) continue ;;
*) echo " $name" ;;
esac
done < <(find "$d" -mindepth 1 -maxdepth 1)
}
check_helper_target() {
local t="$HELPER_DIR" existing source
[ -e "$t" ] || return 0
if [ ! -d "$t" ]; then
err "Helper destination '$t' exists but is not a directory."
err "Move or remove it, then re-run."
exit 1
fi
existing="$(abspath "$t")" || { err "Cannot resolve '$t'."; exit 1; }
source="$(abspath "$SCRIPT_DIR")" || { err "Cannot resolve '$SCRIPT_DIR'."; exit 1; }
[ "$existing" = "$source" ] && return 0
if is_helper_checkout "$t"; then
err "A different helper checkout already exists at '$t'."
else
err "'$t' exists and is not a helper checkout."
fi
err "Move or remove it, then re-run."
exit 1
}
discover_layout() {
local parent
parent="$(dirname -- "$SCRIPT_DIR")"
if [ -n "$ROOT_OVERRIDE" ]; then
# Escape hatch for every ambiguous case.
if [ -d "$ROOT_OVERRIDE" ]; then
WORK_ROOT="$(abspath "$ROOT_OVERRIDE")" ||
{ err "Cannot resolve --root '$ROOT_OVERRIDE'."; exit 1; }
else
case "$ROOT_OVERRIDE" in
/*) WORK_ROOT="$ROOT_OVERRIDE" ;;
*) WORK_ROOT="$PWD/$ROOT_OVERRIDE" ;;
esac
fi
if [ "$SCRIPT_DIR" = "$WORK_ROOT/helper" ] || ! is_helper_checkout "$SCRIPT_DIR"; then
HELPER_DIR="$WORK_ROOT/helper"
RELOCATE_MODE="none"
elif [ "$SCRIPT_DIR" = "$WORK_ROOT" ]; then
HELPER_DIR="$WORK_ROOT/helper"
RELOCATE_MODE="inplace"
else
HELPER_DIR="$WORK_ROOT/helper"
RELOCATE_MODE="move"
fi
elif is_helper_checkout "$SCRIPT_DIR" && root_is_parent "$parent"; then
# Already normalized. Detected by CONTENT first, so renaming the parent
# to e.g. openwrt-24.10 does not trigger a second relocation.
WORK_ROOT="$parent"
HELPER_DIR="$SCRIPT_DIR"
RELOCATE_MODE="none"
info "Reusing the existing layout at '$WORK_ROOT'."
elif is_openwrt_repo "$SCRIPT_DIR"; then
WORK_ROOT="$SCRIPT_DIR"
HELPER_DIR="$SCRIPT_DIR/helper"
RELOCATE_MODE="none"
warn "Running from inside an OpenWrt source root; not relocating anything."
elif is_helper_checkout "$SCRIPT_DIR"; then
WORK_ROOT="$parent/openwrt"
HELPER_DIR="$WORK_ROOT/helper"
if [ "$SCRIPT_DIR" = "$WORK_ROOT" ]; then
RELOCATE_MODE="inplace"
else
RELOCATE_MODE="move"
fi
else
err "'$SCRIPT_DIR' does not look like a helper checkout."
err "Refusing to relocate an unexpected directory. Use --root to override."
exit 1
fi
if [ "$RELOCATE_MODE" = "inplace" ]; then
# The root currently IS the helper checkout (its own git repo included).
# Classifying it would reject it as a foreign repository; after the
# contents move down into helper/ the root is empty by construction.
ROOT_STATE="inplace-helper"
else
ROOT_STATE="$(classify_root "$WORK_ROOT")"
fi
case "$ROOT_STATE" in
notdir)
err "OpenWrt root '$WORK_ROOT' exists but is not a directory."
exit 1
;;
unreadable)
err "OpenWrt root '$WORK_ROOT' exists but cannot be read (permissions)."
exit 1
;;
foreign-git)
err "'$WORK_ROOT' is a Git repository, but its origin is not OpenWrt."
err "Choose another root with --root, or move that repository away."
exit 1
;;
openwrt-partial)
warn "'$WORK_ROOT' has a Git repository but no source tree."
warn "A previous run was interrupted; resuming it."
;;
nonempty)
err "OpenWrt root '$WORK_ROOT' contains unexpected files:"
list_root_clutter "$WORK_ROOT" >&2
if [ "$FORCE" != true ]; then
err "Refusing to use it. Re-run with --force to proceed anyway."
exit 1
fi
warn "--force given; proceeding despite the files above."
;;
esac
case "$RELOCATE_MODE" in
move)
check_helper_target
;;
inplace)
# helper/ is created by the move itself and may be partially populated
# from an interrupted run, so only rule out a non-directory here.
if [ -e "$HELPER_DIR" ] && [ ! -d "$HELPER_DIR" ]; then
err "Helper destination '$HELPER_DIR' exists but is not a directory."
exit 1
fi
;;
esac
# A repo created inside another worktree confuses the outer repository.
local outer=""
if outer="$(git -C "$(dirname -- "$WORK_ROOT")" rev-parse --show-toplevel 2>/dev/null)"; then
if [ -n "$outer" ] && [ "$outer" != "$WORK_ROOT" ]; then
warn "'$WORK_ROOT' will live inside the Git worktree at '$outer'."
fi
fi
}
# True when $1 is already the OpenWrt root for this helper checkout.
root_is_parent() {
local p="$1"
is_openwrt_repo "$p" ||
has_openwrt_origin "$p" ||
is_openwrt_tree "$p" ||
[ "$(basename -- "$p")" = "openwrt" ]
}
# ---------------------------------------------------------------------------
# Phase B: remote ref discovery (read-only, network, no local repo needed)
# ---------------------------------------------------------------------------
discover_remote_refs() {
info "Reading refs from $REPO_URL ..."
if ! REMOTE_TAGS="$(git ls-remote --tags --refs "$REPO_URL" 'v*' 2>&1 |
awk '{print $2}' | sed 's#refs/tags/##')"; then
err "Cannot read tags from '$REPO_URL'."
err "$REMOTE_TAGS"
exit 1
fi
if ! REMOTE_HEADS="$(git ls-remote --heads "$REPO_URL" 2>&1 |
awk '{print $2}' | sed 's#refs/heads/##')"; then
err "Cannot read branches from '$REPO_URL'."
err "$REMOTE_HEADS"
exit 1
fi
if [ -z "$REMOTE_HEADS" ]; then
err "'$REPO_URL' reports no branches; is the URL correct?"
exit 1
fi
}
remote_has_head() {
printf '%s\n' "$REMOTE_HEADS" | grep -qxF "$1"
}
remote_has_tag() {
printf '%s\n' "$REMOTE_TAGS" | grep -qxF "$1"
}
latest_stable_tag() {
local pattern='^v[0-9]+\.[0-9]+\.[0-9]+$'
[ "$ALLOW_RC" = true ] && pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$'
# Filter release candidates BEFORE sorting: `sort -V` orders v25.12.0-rc1
# after v25.12.0, so filtering afterwards would still let an rc win.
printf '%s\n' "$REMOTE_TAGS" | grep -E "$pattern" | sort -V | tail -n 1
}
list_release_branches() {
printf '%s\n' "$REMOTE_HEADS" | grep -E '^openwrt-[0-9]+\.[0-9]+$' | sort -V
}
# ---------------------------------------------------------------------------
# Phase C: version selection and ref resolution
# ---------------------------------------------------------------------------
choose_version_interactive() {
local choice="" newest branches
newest="$(latest_stable_tag)"
branches="$(list_release_branches | tr '\n' ' ')"
echo
echo "Select the OpenWrt version to build:"
echo " 1) Stable release (newest: ${newest:-none found})"
echo " 2) Release branch (${branches:-none found}main)"
echo " 3) Snapshot (the '$SNAPSHOT_BRANCH' branch)"
read -r -p "Enter choice [1-3, default=1]: " choice || choice=""
case "${choice:-}" in
2)
VERSION_MODE="branch"
read -r -p "Enter branch (e.g. 25.12 or openwrt-25.12): " VERSION_INPUT || VERSION_INPUT=""
if [ -z "${VERSION_INPUT:-}" ]; then
err "No branch given."
exit 1
fi
;;
3)
VERSION_MODE="snapshot"
;;
*)
VERSION_MODE="stable"
read -r -p "Enter version (e.g. 25.12.5, empty for newest): " VERSION_INPUT || VERSION_INPUT=""
;;
esac
}
resolve_target_ref() {
local v candidate
case "$VERSION_MODE" in
snapshot)
TARGET_KIND="branch"
if remote_has_head "$SNAPSHOT_BRANCH"; then
TARGET_REF="$SNAPSHOT_BRANCH"
elif remote_has_head "$SNAPSHOT_FALLBACK"; then
warn "Branch '$SNAPSHOT_BRANCH' not found; using '$SNAPSHOT_FALLBACK'."
TARGET_REF="$SNAPSHOT_FALLBACK"
else
err "Neither '$SNAPSHOT_BRANCH' nor '$SNAPSHOT_FALLBACK' exists upstream."
exit 1
fi
;;
branch)
TARGET_KIND="branch"
v="$VERSION_INPUT"
case "$v" in
main|master|openwrt-*) candidate="$v" ;;
[0-9]*) candidate="openwrt-$v" ;;
*) candidate="$v" ;;
esac
if remote_has_head "$candidate"; then
TARGET_REF="$candidate"
elif remote_has_head "openwrt-$v"; then
TARGET_REF="openwrt-$v"
else
err "Branch not found upstream for '$v'."
err "Available release branches:"
list_release_branches | sed 's/^/ /' >&2
err " main"
exit 1
fi
;;
stable)
TARGET_KIND="tag"
if [ -z "${VERSION_INPUT:-}" ]; then
TARGET_REF="$(latest_stable_tag)"
if [ -z "$TARGET_REF" ]; then
err "No stable tag found upstream."
exit 1
fi
info "No version given; using the newest stable tag: $TARGET_REF"
else
v="$VERSION_INPUT"
case "$v" in v*) ;; *) v="v$v" ;; esac
if ! remote_has_tag "$v"; then
err "Version tag '$v' not found upstream."
err "Recent stable tags:"
printf '%s\n' "$REMOTE_TAGS" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sort -V | tail -n 5 | sed 's/^/ /' >&2
exit 1
fi
TARGET_REF="$v"
fi
;;
*)
err "Internal error: no version mode selected."
exit 1
;;
esac
}
# ---------------------------------------------------------------------------
# Phase C': switch safety (read-only)
# ---------------------------------------------------------------------------
current_ref_of() {
local d="$1"
git -C "$d" describe --tags --exact-match 2>/dev/null ||
git -C "$d" symbolic-ref --short HEAD 2>/dev/null ||
git -C "$d" rev-parse --short HEAD 2>/dev/null ||
true
}
has_build_state() {
local d="$1" p
for p in .config build_dir staging_dir bin tmp feeds; do
[ -e "$d/$p" ] && return 0
done
return 1
}
check_switch_safety() {
local dirty=""
is_git_root "$WORK_ROOT" || return 0
CURRENT_REF="$(current_ref_of "$WORK_ROOT")"
# Same ref means no switch, hence no risk. This is what keeps the guard
# from firing on every routine re-run.
[ -n "$CURRENT_REF" ] && [ "$CURRENT_REF" = "$TARGET_REF" ] && return 0
is_openwrt_tree "$WORK_ROOT" || return 0
# Untracked files are excluded on purpose: a built OpenWrt tree always has
# feeds/, build_dir/, staging_dir/, bin/, .config and helper/ untracked.
dirty="$(git -C "$WORK_ROOT" status --porcelain --untracked-files=no 2>/dev/null || true)"
if [ -n "$dirty" ]; then
warn "Tracked files in '$WORK_ROOT' have local modifications:"
git -C "$WORK_ROOT" status --short --untracked-files=no | sed 's/^/ /'
if [ "$FORCE" = true ]; then
warn "--force given; switching anyway."
elif [ "$ASSUME_YES" = true ]; then
err "Refusing to switch versions with local modifications under -y."
err "Commit or stash them, or re-run with --force."
exit 1
elif ! confirm "Switching keeps your changes only if they apply cleanly. Continue?" no; then
info "Aborted by user."
exit 0
fi
elif has_build_state "$WORK_ROOT"; then
warn "'$WORK_ROOT' contains build state from '$CURRENT_REF'."
if [ "$ASSUME_YES" != true ] &&
! confirm "Build state is kept and .config is backed up. Continue?" yes; then
info "Aborted by user."
exit 0
fi
fi
if [ -f "$WORK_ROOT/.config" ]; then
CONFIG_BACKUP="$WORK_ROOT/.config.${CURRENT_REF//\//-}.bak"
fi
}
# ---------------------------------------------------------------------------
# Phase D: plan and confirmation
# ---------------------------------------------------------------------------
print_plan() {
local root_note helper_note clone_note
case "$ROOT_STATE" in
absent) root_note="(create)" ;;
empty) root_note="(use empty directory)" ;;
inplace-helper) root_note="(reuse this directory)" ;;
openwrt-complete) root_note="(update existing)" ;;
openwrt-partial) root_note="(resume interrupted run)" ;;
nonempty) root_note="(forced, contains other files)" ;;
*) root_note="" ;;
esac
case "$RELOCATE_MODE" in
none) helper_note="(already in place)" ;;
move) helper_note="(move from $SCRIPT_DIR)" ;;
inplace) helper_note="(move contents of $SCRIPT_DIR down one level)" ;;
esac
if [ "$FULL_CLONE" = true ]; then
clone_note="full history"
else
clone_note="partial (--filter=blob:none)"
fi
echo
echo "========================================"
echo " Planned actions"
echo "========================================"
echo " OpenWrt root : $WORK_ROOT $root_note"
echo " Helper : $HELPER_DIR $helper_note"
echo " Version : $TARGET_REF ($TARGET_KIND)"
echo " Currently at : ${CURRENT_REF:-(new checkout)}"
echo " Clone mode : $clone_note"
[ -n "$CONFIG_BACKUP" ] && echo " .config : back up to $(basename "$CONFIG_BACKUP")"
if [ "$RELOCATE_MODE" = "move" ] && [ "$COMPAT_LINK" = true ]; then
echo " Old path : $SCRIPT_DIR -> symlink to the new helper location"
fi
if [ "$SKIP_FEEDS" = true ]; then
echo " Feeds : skipped (--skip-feeds)"
else
echo " Feeds : update -a, install -a"
fi
if [ "$SKIP_DEFCONFIG" = true ]; then
echo " Post : skipped (--skip-defconfig)"
else
echo " Post : make defconfig"
fi
echo "========================================"
}
# ---------------------------------------------------------------------------
# Phase E: mutation
# ---------------------------------------------------------------------------
relocate_helper() {
local entry name
case "$RELOCATE_MODE" in
none)
return 0
;;
move)
# Single rename inside one parent directory: atomic, no crash window.
info "Moving the helper checkout to '$HELPER_DIR'."
run mkdir -p "$WORK_ROOT"
run mv -- "$SCRIPT_DIR" "$HELPER_DIR"
# Anything still pointing at the old path -- a shell, an editor, an
# agent session -- would otherwise be left on a dangling directory.
if [ "$COMPAT_LINK" = true ]; then
if [ "$DRY_RUN" = true ] || [ ! -e "$SCRIPT_DIR" ]; then
info "Leaving a compatibility symlink at '$SCRIPT_DIR'."
run ln -s -- "$HELPER_DIR" "$SCRIPT_DIR"
COMPAT_LINK_PATH="$SCRIPT_DIR"
else
warn "'$SCRIPT_DIR' still exists; skipping the compatibility symlink."
fi
fi
;;
inplace)
# The checkout is itself named 'openwrt'. Move its contents down one
# level instead of staging through a temporary sibling, so that an
# interrupt never strands anything outside the root.
if [ -z "$WORK_ROOT" ] || [ "$WORK_ROOT" = "/" ]; then
err "Refusing to reorganize '$WORK_ROOT'."
exit 1
fi
info "Moving the checkout contents into '$HELPER_DIR'."
run mkdir -p "$HELPER_DIR"
shopt -s dotglob nullglob
for entry in "$WORK_ROOT"/*; do
name="$(basename -- "$entry")"
[ "$name" = "helper" ] && continue
[ "$name" = ".prepare-openwrt.lock" ] && continue
if [ -e "$HELPER_DIR/$name" ]; then
warn "Skipping '$name': already present in helper/."
continue
fi
run mv -- "$entry" "$HELPER_DIR/"
done
shopt -u dotglob nullglob
;;
esac
}
ensure_origin_remote() {
local current=""
if current="$(git -C "$WORK_ROOT" remote get-url origin 2>/dev/null)"; then
if [ "$current" != "$REPO_URL" ]; then
info "Pointing remote 'origin' at '$REPO_URL'."
run git -C "$WORK_ROOT" remote set-url origin "$REPO_URL"
fi
else
run git -C "$WORK_ROOT" remote add origin "$REPO_URL"
fi
}
supports_partial_clone() {
local v
v="$(git version | awk '{print $3}')"
[ "$(printf '%s\n2.19.0\n' "$v" | sort -V | head -n 1)" = "2.19.0" ]
}
ensure_repo() {
# Fresh, resumed and complete roots all take this one path, so there is no
# separate resume branch that can rot.
if ! is_git_root "$WORK_ROOT"; then
info "Initializing a Git repository in '$WORK_ROOT'."
run mkdir -p "$WORK_ROOT"
run git -C "$WORK_ROOT" init -q
fi
ensure_origin_remote
if [ "$FULL_CLONE" != true ] && supports_partial_clone; then
info "Fetching (partial clone, blobs on demand)..."
run git -C "$WORK_ROOT" config remote.origin.promisor true
run git -C "$WORK_ROOT" config remote.origin.partialclonefilter blob:none
if ! run git -C "$WORK_ROOT" fetch --tags --prune --prune-tags \
--filter=blob:none origin; then
warn "Partial fetch failed; retrying with a full fetch."
run git -C "$WORK_ROOT" config --unset remote.origin.promisor || true
run git -C "$WORK_ROOT" config --unset remote.origin.partialclonefilter || true
run git -C "$WORK_ROOT" fetch --tags --prune --prune-tags origin
fi
else
if [ "$FULL_CLONE" != true ]; then
warn "git $(git version | awk '{print $3}') has no partial clone; fetching everything."
fi
info "Fetching (full history)..."
run git -C "$WORK_ROOT" fetch --tags --prune --prune-tags origin
fi
}
ensure_helper_excluded() {
local gitdir ex
[ "$DRY_RUN" = true ] && { echo "[DRY-RUN] append '/helper/' to .git/info/exclude"; return 0; }
gitdir="$(git -C "$WORK_ROOT" rev-parse --git-dir)"
case "$gitdir" in
/*) ;;
*) gitdir="$WORK_ROOT/$gitdir" ;;
esac
ex="$gitdir/info/exclude"
if [ -f "$ex" ] && grep -qxF '/helper/' "$ex"; then
return 0
fi
info "Marking /helper/ as ignored in $ex"
mkdir -p "$gitdir/info"
printf '\n# Added by prepare-openwrt.sh: the build-helper checkout lives here.\n/helper/\n' >> "$ex"
}
checkout_target() {
if [ -n "$CONFIG_BACKUP" ]; then
info "Backing up .config to $(basename "$CONFIG_BACKUP")"
run cp -a "$WORK_ROOT/.config" "$CONFIG_BACKUP"
fi
# One-line insurance in case upstream ever ships a top-level 'helper' path.
if [ "$DRY_RUN" != true ] &&
[ -n "$(git -C "$WORK_ROOT" ls-tree --name-only "$TARGET_REF" helper 2>/dev/null)" ]; then
err "The target ref '$TARGET_REF' contains a tracked 'helper' path."
err "It would collide with the helper checkout. Use --root for a different layout."
exit 1
fi
if [ "$TARGET_KIND" = "tag" ]; then
info "Checking out tag $TARGET_REF ..."
run git -C "$WORK_ROOT" checkout --detach "refs/tags/$TARGET_REF"
else
info "Checking out branch $TARGET_REF ..."
if git -C "$WORK_ROOT" show-ref --verify --quiet "refs/heads/$TARGET_REF"; then
run git -C "$WORK_ROOT" checkout "$TARGET_REF"
run git -C "$WORK_ROOT" merge --ff-only "origin/$TARGET_REF"
else
run git -C "$WORK_ROOT" checkout -b "$TARGET_REF" --track "origin/$TARGET_REF"
fi
fi
restore_worktree_if_missing
}
# Checking out the ref that HEAD already points at is a no-op, so it does not
# bring back tracked files that were deleted by an interrupted run. Only ever
# runs when the source tree is actually missing, so it cannot clobber edits.
restore_worktree_if_missing() {
[ "$DRY_RUN" = true ] && return 0
is_openwrt_tree "$WORK_ROOT" && return 0
warn "Source tree is incomplete; restoring tracked files from $TARGET_REF."
run git -C "$WORK_ROOT" checkout --force HEAD -- .
if ! is_openwrt_tree "$WORK_ROOT"; then
err "'$WORK_ROOT' still has no OpenWrt source tree after restoring."
exit 1
fi
}
# ---------------------------------------------------------------------------
# Phase F: feeds and defconfig
# ---------------------------------------------------------------------------
update_feeds() {
[ "$SKIP_FEEDS" = true ] && { info "Skipping feeds (--skip-feeds)."; return 0; }
if [ "$DRY_RUN" != true ] && ! is_openwrt_tree "$WORK_ROOT"; then
err "'$WORK_ROOT' has no OpenWrt source tree."
exit 1
fi
info "Updating and installing all feeds..."
run env -C "$WORK_ROOT" ./scripts/feeds update -a
run env -C "$WORK_ROOT" ./scripts/feeds install -a
}
run_defconfig() {
[ "$SKIP_DEFCONFIG" = true ] && { info "Skipping defconfig (--skip-defconfig)."; return 0; }
info "Running make defconfig..."
run env -C "$WORK_ROOT" make defconfig
}
# ---------------------------------------------------------------------------
# Phase G: summary
# ---------------------------------------------------------------------------
print_summary() {
local version="$TARGET_REF"
if [ "$DRY_RUN" != true ]; then
version="$(git -C "$WORK_ROOT" describe --tags 2>/dev/null ||
git -C "$WORK_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "$TARGET_REF")"
fi
echo
echo "========================================"
echo "✅ OpenWrt source is ready."
echo " Version: $version"
echo " Root: $WORK_ROOT"
echo " Helper: $HELPER_DIR"
[ -n "$COMPAT_LINK_PATH" ] && echo " Old path: $COMPAT_LINK_PATH (symlink)"
echo "========================================"
echo
echo "⚠ helper/ lives inside the OpenWrt Git tree but is not tracked by it."
echo " 'git clean -xdff' in the OpenWrt root WILL DELETE it, .git and all."
echo " Use instead: git clean -xdf -e /helper"
echo
if [ -n "$COMPAT_LINK_PATH" ]; then
echo " '$COMPAT_LINK_PATH' is now a symlink to '$HELPER_DIR',"
echo " so shells and tools still sitting in the old directory keep working."
echo " Run 'cd .' there to refresh a stale shell."
echo
fi
echo "👉 Next steps:"
echo " cd $WORK_ROOT"
echo " ./helper/download-config.sh # optional: import a device config"
echo " make menuconfig"
echo " make -j\$(nproc) download world"
echo
}
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
require_sort_v
discover_layout
discover_remote_refs
if [ -z "$VERSION_MODE" ]; then
if [ "$ASSUME_YES" = true ]; then
VERSION_MODE="stable"
else
choose_version_interactive
fi
fi
resolve_target_ref
check_switch_safety
print_plan
if [ "$DRY_RUN" = true ]; then
echo
relocate_helper
ensure_repo
ensure_helper_excluded
echo "[DRY-RUN] checkout $TARGET_REF"
echo
info "Dry run complete; nothing was changed."
exit 0
fi
echo
if ! confirm "Proceed?" yes; then
info "Aborted by user."
exit 0
fi
echo
# Everything below this line mutates the filesystem.
relocate_helper
acquire_lock
ensure_repo
ensure_helper_excluded
checkout_target
update_feeds
run_defconfig
print_summary