#!/usr/bin/env bash # svn_git_sync — sync current SVN working copy into a Git mirror # Usage: # svn_git_sync init [--history=N] [mirror_name] set -euo pipefail show_usage() { echo "Usage: $0 init [--history=N] [mirror_name]" >&2 exit 1 } subcommand="${1:-}" [[ -n "$subcommand" ]] || show_usage shift history_count=0 mirror_arg="" parse_opts() { while [[ $# -gt 0 ]]; do case "$1" in --history=*) history_count="${1#*=}" shift ;; --history) history_count="${2:-}" shift 2 ;; -h|--help) show_usage ;; *) mirror_arg="$1" shift break ;; esac done if [[ -n "$mirror_arg" && $# -gt 0 ]]; then echo "Unexpected extra arguments: $*" >&2 show_usage fi } case "$subcommand" in init) parse_opts "$@" ;; -h|--help|help) show_usage ;; *) echo "Unknown command: $subcommand" >&2 show_usage ;; esac SRC_DIR="$(pwd)" MIRROR_ROOT="${GIT_SVN_MIRROR_ROOT:-$HOME/git_svn_mirror}" # Derive a collision-resistant mirror name from the full path (unless overridden). raw_name="${mirror_arg:-$(pwd -P)}" BASE_NAME="$(printf "%s" "$raw_name" | sed 's#^[\\/]*##; s#[\\/]#_#g')" DEST="${MIRROR_ROOT}/${BASE_NAME}" echo "Exporting SVN working copy to $DEST (versioned files only)..." rm -rf -- "$DEST" mkdir -p "$MIRROR_ROOT" start_ts="$(date +%s.%N)" elapsed() { local now now="${1:-$(date +%s.%N)}" awk -v s="$start_ts" -v n="$now" 'BEGIN{split(s,sa,".");split(n,na,".");printf "%.1f", (na[1]-sa[1]) + (na[2]-sa[2])/1e9}' } simple_export() { # Optionally compute total items upfront for a rough percentage local total_items="" if total=$(svn list -R . 2>/dev/null | wc -l || true); then total_items="$total" fi svn export --quiet . "$DEST" & export_pid=$! local last_reported=-1 while kill -0 "$export_pid" 2>/dev/null; do if [[ -d "$DEST" ]]; then current=$(find "$DEST" -mindepth 1 -print | wc -l) else current=0 fi if [[ "$current" -ne "$last_reported" ]]; then elapsed_val="$(elapsed)" pct="" if [[ -n "$total_items" && "$total_items" -gt 0 ]]; then pct=$(awk -v c="$current" -v t="$total_items" 'BEGIN{printf(" (%.1f%%)", (c*100)/t)}') fi printf "[%ss] Exported %d items%s\n" "$elapsed_val" "$current" "$pct" last_reported="$current" fi sleep 1 done wait "$export_pid" final_count=$(find "$DEST" -mindepth 1 -print | wc -l 2>/dev/null || echo 0) printf "[%ss] Exported %d items (done)\n" "$(elapsed)" "$final_count" } init_git_repo() { echo "[$(elapsed) s] Initializing git repository..." GIT_QUIET=1 git init >/dev/null git config init.defaultBranch trunk || true git config core.fileMode false || true git config core.autocrlf true || true git config core.safecrlf false || true # Seed ignores for common noise (pyc/cache) if [[ ! -e .gitignore ]]; then echo "[$(elapsed) s] Adding default ignores (.pyc/__pycache__)..." printf "*.pyc\n__pycache__/\n" > .gitignore fi } link_git_to_source() { echo "[$(elapsed) s] Linking git metadata to source working tree..." DEST_ABS="$(pwd -P)" cd "$SRC_DIR" if [[ -e .git || -L .git ]]; then echo "[$(elapsed)s] Replacing existing .git in $SRC_DIR..." rm -rf -- .git fi printf "gitdir: %s/.git\n" "$DEST_ABS" > .git echo "[$(elapsed)s] Linked .git to $DEST_ABS/.git" echo "[$(elapsed) s] Syncing working tree to repo content..." GIT_QUIET=1 git checkout -q -- . } stage_and_commit() { local subject="${1:-}" local body="${2:-}" local allow_empty="${3:-0}" local display="${4:-$subject}" [[ -z "$subject" ]] && subject="SVN snapshot" git add -A if (( allow_empty )); then if [[ -n "$body" ]]; then GIT_QUIET=1 git commit -q --allow-empty -m "$subject" -m "$body" else GIT_QUIET=1 git commit -q --allow-empty -m "$subject" fi else if [[ -n "$body" ]]; then GIT_QUIET=1 git commit -q -m "$subject" -m "$body" else GIT_QUIET=1 git commit -q -m "$subject" fi fi } # Validate history_count is numeric (default 0) if [[ -z "${history_count:-}" ]]; then history_count=0 elif ! [[ "$history_count" =~ ^[0-9]+$ ]]; then echo "--history must be a non-negative integer" >&2 exit 1 fi if (( history_count == 0 )); then simple_export cd "$DEST" init_git_repo stage_and_commit "Import SVN snapshot" link_git_to_source exit 0 fi # History mode: pull last N revisions into Git. SRC_URL="$(svn info --show-item url 2>/dev/null || svn info | awk -F': ' '/^URL:/ {print $2; exit}')" if [[ -z "$SRC_URL" ]]; then echo "Unable to determine SVN URL" >&2 exit 1 fi REPO_ROOT="$(svn info --show-item repos-root-url 2>/dev/null || svn info | awk -F': ' '/^Repository Root:/ {print $2; exit}')" mapfile -t REV_LIST < <(svn log -q -r HEAD:1 --limit "$history_count" "$SRC_URL" | awk '/^r[0-9]+/ {sub(/^r/,"",$1); print $1}' | tac) if (( ${#REV_LIST[@]} == 0 )); then echo "No SVN revisions found for path $SRC_URL" >&2 exit 1 fi start_rev="${REV_LIST[0]}" head_rev="${REV_LIST[-1]}" echo "Including last ${#REV_LIST[@]} revisions affecting this path (r${start_rev}..r${head_rev})..." WC_DIR="$(mktemp -d "${MIRROR_ROOT}/.svn_git_sync_wc.XXXXXX")" mkdir -p "$DEST" sync_wc_to_dest() { rsync -a --delete --exclude '.svn' --exclude '.git' "$WC_DIR"/ "$DEST"/ if [[ ! -e "$DEST/.gitignore" ]]; then printf "*.pyc\n__pycache__/\n" > "$DEST/.gitignore" fi } checkout_with_progress() { local rev="$1" echo "[$(elapsed) s] Checking out r${rev}..." rm -rf -- "$WC_DIR" mkdir -p "$WC_DIR" svn checkout -q -r "$rev" "$SRC_URL" "$WC_DIR" & local co_pid=$! local last_report=-1 while kill -0 "$co_pid" 2>/dev/null; do local current=0 if [[ -d "$WC_DIR" ]]; then current=$(find "$WC_DIR" -path "$WC_DIR/.svn" -prune -o -print | wc -l) fi if [[ "$current" -ne "$last_report" ]]; then echo "[$(elapsed)s] Checked out $current items..." last_report="$current" fi sleep 1 done wait "$co_pid" local final_count=0 if [[ -d "$WC_DIR" ]]; then final_count=$(find "$WC_DIR" -path "$WC_DIR/.svn" -prune -o -print | wc -l) fi echo "[$(elapsed)s] Checked out $final_count items (done)" } checkout_with_progress "$start_rev" sync_wc_to_dest cd "$DEST" init_git_repo build_commit_msg() { local rev="$1" local svn_log author msg_body commit_date svn_log="$(svn log -r "$rev" -l 1 "$SRC_URL")" author="$(printf "%s\n" "$svn_log" | awk 'NR==2{split($0,a,"|"); gsub(/^ +| +$/,"",a[2]); print a[2]}')" commit_date="$(printf "%s\n" "$svn_log" | awk 'NR==2{split($0,a,"|"); gsub(/^ +| +$/,"",a[3]); split(a[3],b," "); print b[1] " " b[2]}')" msg_body="$(printf "%s\n" "$svn_log" | sed '1,2d;/^---/,$d')" if { [[ -z "$author" ]] || [[ -z "$msg_body" ]]; } && [[ -n "$REPO_ROOT" ]]; then svn_log="$(svn log -r "$rev" -l 1 "$REPO_ROOT" 2>/dev/null || true)" author="$(printf "%s\n" "$svn_log" | awk 'NR==2{split($0,a,"|"); gsub(/^ +| +$/,"",a[2]); print a[2]}')" commit_date="$(printf "%s\n" "$svn_log" | awk 'NR==2{split($0,a,"|"); gsub(/^ +| +$/,"",a[3]); split(a[3],b," "); print b[1] " " b[2]}')" msg_body="$(printf "%s\n" "$svn_log" | sed '1,2d;/^---/,$d')" fi msg_body="$(printf "%s" "$msg_body" | perl -0777 -pe 's/\A\s+//; s/\s+\z//; s/\n{2,}/\n\n/g')" COMMIT_BODY="$msg_body" COMMIT_BODY_FIRST="$(printf "%s" "$msg_body" | sed -n '1p')" COMMIT_SUBJECT="SVN r${rev}" [[ -n "$author" ]] && COMMIT_SUBJECT+=" by $author" COMMIT_DATE="$commit_date" COMMIT_PROGRESS="$COMMIT_SUBJECT" [[ -n "$COMMIT_DATE" ]] && COMMIT_PROGRESS="[$COMMIT_DATE] $COMMIT_PROGRESS" [[ -n "$COMMIT_BODY_FIRST" ]] && COMMIT_PROGRESS+=" — $COMMIT_BODY_FIRST" } build_commit_msg "$start_rev" echo "[$(elapsed) s] Preparing commit: $COMMIT_PROGRESS" stage_and_commit "$COMMIT_SUBJECT" "$COMMIT_BODY" 0 "$COMMIT_PROGRESS" if (( ${#REV_LIST[@]} > 1 )); then for rev in "${REV_LIST[@]:1}"; do svn update -q -r "$rev" "$WC_DIR" sync_wc_to_dest build_commit_msg "$rev" echo "[$(elapsed) s] Preparing commit: $COMMIT_PROGRESS" stage_and_commit "$COMMIT_SUBJECT" "$COMMIT_BODY" 1 "$COMMIT_PROGRESS" done fi rm -rf -- "$WC_DIR" link_git_to_source