#!/usr/bin/env bash # git-smart-checkout: clone/reuse a repo and force-checkout a given ref (branch/tag/commit), # with mirror URLs, minimal transfer, and automatic retries. # Robustness set -euo pipefail IFS=$'\n\t' # ---- Logging helpers -------------------------------------------------------- log_info() { printf '[INFO] %s\n' "$*" >&2; } log_warn() { printf '[WARN] %s\n' "$*" >&2; } log_error() { printf '[ERROR] %s\n' "$*" >&2; } # ---- Usage ------------------------------------------------------------------ usage() { cat >&2 <<'USAGE' Usage: git-smart-checkout.sh -n -r -u [-u ...] [options] Required: -n, --name local target directory (repo / path). -r, --ref branch, tag or Commit-SHA. -u, --url repo URL (multiple for mirror fallbacks) Options: --retries amount of retries (default: 3). --sleep wait seconds between retries (default: 3). --no-clean no hard working dir cleanup after checkout --full-if-needed if commit not reachable with shallow fetch, retry again without --depth --keep-remote dont change remote config (otherwise set 'origin' to first URL). --depth shallow depth for fetch (default: 1). only useful for branch/tag --no-partial disable partial clone/filter (otherwise load blobs on-demand). --no-tags dont fetch tags by default (default). --allow-tags fetch tags (overwrites --no-tags). Examples: git-smart-checkout.sh -n myrepo -r main -u https://github.com/org/proj.git -u https://gitmirror/org/proj.git git-smart-checkout.sh -n myrepo -r v2.1.0 -u ssh://git@example.com/proj.git git-smart-checkout.sh -n myrepo -r 1a2b3c4d5e --full-if-needed -u https://github.com/org/proj.git USAGE exit 2 } # ---- Defaults --------------------------------------------------------------- NAME="" REF="" URLS=() RETRIES=3 SLEEP_SEC=3 CLEAN=1 FULL_IF_NEEDED=0 KEEP_REMOTE=0 DEPTH=1 USE_PARTIAL=1 FETCH_TAGS=0 # 0 => --no-tags, 1 => allow tags # ---- Parse args ------------------------------------------------------------- if [[ $# -eq 0 ]]; then usage; fi while (( "$#" )); do case "$1" in -n|--name) NAME="${2:-}"; shift 2 ;; -r|--ref) REF="${2:-}"; shift 2 ;; -u|--url) URLS+=("${2:-}"); shift 2 ;; --retries) RETRIES="${2:-}"; shift 2 ;; --sleep) SLEEP_SEC="${2:-}"; shift 2 ;; --no-clean) CLEAN=0; shift ;; --full-if-needed) FULL_IF_NEEDED=1; shift ;; --keep-remote) KEEP_REMOTE=1; shift ;; --depth) DEPTH="${2:-}"; shift 2 ;; --no-partial) USE_PARTIAL=0; shift ;; --no-tags) FETCH_TAGS=0; shift ;; --allow-tags) FETCH_TAGS=1; shift ;; -h|--help) usage ;; *) log_error "Unknown option: $1"; usage ;; esac done # ---- Validate args ---------------------------------------------------------- if [[ -z "$NAME" ]]; then log_error "Missing --name"; usage; fi if [[ -z "$REF" ]]; then log_error "Missing --ref"; usage; fi if [[ ${#URLS[@]} -eq 0 ]]; then log_error "At least one --url is required"; usage; fi if ! command -v git >/dev/null 2>&1; then log_error "'git' not found"; exit 127; fi # ---- Helpers ---------------------------------------------------------------- is_git_dir() { [[ -d "$1/.git" ]] || git -C "$1" rev-parse --git-dir >/dev/null 2>&1 } is_sha_like() { [[ "$1" =~ ^[0-9a-fA-F]{7,40}$ ]] } sleep_backoff() { local attempt="$1" local base="$SLEEP_SEC" local delay=$(( base * attempt )) [[ $delay -lt 1 ]] && delay=1 sleep "$delay" } git_retry() { # git_retry local desc="$1"; shift local attempt=1 local rc=0 while :; do if "$@"; then return 0 fi rc=$? if (( attempt >= RETRIES )); then log_error "$desc: failed after $RETRIES tries (rc=$rc)" return "$rc" fi log_warn "$desc: try $attempt failed (rc=$rc). retrying ..." sleep_backoff "$attempt" attempt=$(( attempt + 1 )) done } # Compose common fetch flags fetch_flags_common=() (( USE_PARTIAL )) && fetch_flags_common+=( "--filter=blob:none" ) (( FETCH_TAGS == 0 )) && fetch_flags_common+=( "--no-tags" ) fetch_flags_common+=( "--prune" "--prune-tags" "--update-head-ok" "--force" ) # With or without depth fetch_flags_shallow=( "${fetch_flags_common[@]}" "--depth=$DEPTH" ) fetch_flags_full=( "${fetch_flags_common[@]}" ) # ---- Clone or reuse existing ------------------------------------------------ if [[ -d "$NAME" ]]; then if is_git_dir "$NAME"; then log_info "detected existing git repository: $NAME" if (( KEEP_REMOTE == 0 )); then log_info "setting 'origin' to first URL: ${URLS[0]}" if git -C "$NAME" remote get-url origin >/dev/null 2>&1; then git -C "$NAME" remote set-url origin "${URLS[0]}" else git -C "$NAME" remote add origin "${URLS[0]}" fi else log_info "--keep-remote: remote config unchanged." fi else log_error "directory '$NAME' exists, but not a git repository." exit 1 fi else # fresh clone: minimal & no-checkout mkdir -p "$(dirname -- "$NAME")" clone_ok=0 for u in "${URLS[@]}"; do log_info "Clone von $u -> $NAME (minimaler Transfer, no-checkout)" clone_cmd=( git clone "--recurse-submodules" "--no-checkout" "--origin=origin" "$u" "$NAME" ) (( USE_PARTIAL )) && clone_cmd+=( "--filter=blob:none" ) (( FETCH_TAGS == 0 )) && clone_cmd+=( "--no-tags" ) if git_retry "clone $u" "${clone_cmd[@]}"; then clone_ok=1 break fi done if (( clone_ok == 0 )); then log_error "clone failed on all URLs." exit 1 fi fi # ---- Prepare partial clone config (for init/clone without --filter at fetch time) ---- if (( USE_PARTIAL )); then # Best effort: ensure repo knows it's a partial clone (helps späteres lazy-fetch) git -C "$NAME" config --local remote.origin.promisor true || true git -C "$NAME" config --local remote.origin.partialclonefilter "blob:none" || true git -C "$NAME" config --local extensions.partialClone "origin" || true fi # ---- Fetch target ref with minimal transfer --------------------------------- # We'll try URLs in order; for each, attempt specific refspecs. # Strategy: # - If SHA-like: try shallow fetch of the object; fallback to full if enabled. # - Else (name): try heads/, tags/, and plain shallow; fallback to full if needed. enter_repo() { cd "$NAME"; } # for readability enter_repo fetch_from_url_for_ref() { # fetch_from_url_for_ref local url="$1" ref="$2" mode="$3" ; shift 3 local -a flags=() if [[ "$mode" == "shallow" ]]; then flags=( "${fetch_flags_shallow[@]}" ) else flags=( "${fetch_flags_full[@]}" ) fi if is_sha_like "$ref"; then # Try fetching the specific commit object # FETCH_HEAD will point to the fetched object on success. git fetch "${flags[@]}" "$url" "$ref" return $? else # Try specific namespaces to avoid extra refs local try=( "refs/heads/$ref:refs/tmp/$ref" "refs/tags/$ref:refs/tmp/$ref" "$ref:refs/tmp/$ref" ) local t for t in "${try[@]}"; do if git fetch "${flags[@]}" "$url" "$t"; then return 0 fi done return 1 fi } fetch_ok=0 for u in "${URLS[@]}"; do # 1) Try shallow if git_retry "fetch url: $u $REF" fetch_from_url_for_ref "$u" "$REF" "shallow"; then fetch_ok=1 break fi # 2) Optional fallback to full (still filtered blobs) if (( FULL_IF_NEEDED )); then log_warn "shallow fetch insufficient. trying without --depth from $u ..." if git_retry "fetch url: $u $REF" fetch_from_url_for_ref "$u" "$REF" "full"; then fetch_ok=1 break fi fi done if (( fetch_ok == 0 )); then log_error "failed fetching reference '$REF' on all URLs." exit 1 fi # Resolve fetched commit TARGET_COMMIT="$(git rev-parse --verify --quiet FETCH_HEAD || true)" if [[ -z "$TARGET_COMMIT" ]]; then log_error "couldnt resolve FETCH_HEAD." exit 1 fi log_info "fetched: $TARGET_COMMIT" # ---- Forced checkout -------------------------------------------------------- # We checkout detached at the exact commit to be fully deterministic. # (No implicit branch tracking; avoids surprises if remote refs move.) if (( CLEAN )); then log_info "cleaning working dir (reset --hard, clean -fdx)" git reset --hard git clean -fdx fi log_info "forced checkout (detached) auf $TARGET_COMMIT" # Ensure no stray temp ref prevents checkout git update-ref -d "refs/tmp/$REF" >/dev/null 2>&1 || true git checkout --force --detach "$TARGET_COMMIT" git submodule update --init # Small summary log_info "working dir:" git --no-pager log -1 --oneline git --no-pager status --short --branch log_info "Done."