svn_git_sync 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. #!/usr/bin/env bash
  2. # svn_git_sync — sync current SVN working copy into a Git mirror
  3. # Usage:
  4. # svn_git_sync init [--history=N] [mirror_name]
  5. set -euo pipefail
  6. show_usage() {
  7. echo "Usage: $0 init [--history=N] [mirror_name]" >&2
  8. exit 1
  9. }
  10. subcommand="${1:-}"
  11. [[ -n "$subcommand" ]] || show_usage
  12. shift
  13. history_count=0
  14. mirror_arg=""
  15. parse_opts() {
  16. while [[ $# -gt 0 ]]; do
  17. case "$1" in
  18. --history=*)
  19. history_count="${1#*=}"
  20. shift
  21. ;;
  22. --history)
  23. history_count="${2:-}"
  24. shift 2
  25. ;;
  26. -h|--help)
  27. show_usage
  28. ;;
  29. *)
  30. mirror_arg="$1"
  31. shift
  32. break
  33. ;;
  34. esac
  35. done
  36. if [[ -n "$mirror_arg" && $# -gt 0 ]]; then
  37. echo "Unexpected extra arguments: $*" >&2
  38. show_usage
  39. fi
  40. }
  41. case "$subcommand" in
  42. init)
  43. parse_opts "$@"
  44. ;;
  45. -h|--help|help)
  46. show_usage
  47. ;;
  48. *)
  49. echo "Unknown command: $subcommand" >&2
  50. show_usage
  51. ;;
  52. esac
  53. SRC_DIR="$(pwd)"
  54. MIRROR_ROOT="${GIT_SVN_MIRROR_ROOT:-$HOME/git_svn_mirror}"
  55. # Derive a collision-resistant mirror name from the full path (unless overridden).
  56. raw_name="${mirror_arg:-$(pwd -P)}"
  57. BASE_NAME="$(printf "%s" "$raw_name" | sed 's#^[\\/]*##; s#[\\/]#_#g')"
  58. DEST="${MIRROR_ROOT}/${BASE_NAME}"
  59. echo "Exporting SVN working copy to $DEST (versioned files only)..."
  60. rm -rf -- "$DEST"
  61. mkdir -p "$MIRROR_ROOT"
  62. start_ts="$(date +%s.%N)"
  63. elapsed() {
  64. local now
  65. now="${1:-$(date +%s.%N)}"
  66. 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}'
  67. }
  68. simple_export() {
  69. # Optionally compute total items upfront for a rough percentage
  70. local total_items=""
  71. if total=$(svn list -R . 2>/dev/null | wc -l || true); then
  72. total_items="$total"
  73. fi
  74. svn export --quiet . "$DEST" &
  75. export_pid=$!
  76. local last_reported=-1
  77. while kill -0 "$export_pid" 2>/dev/null; do
  78. if [[ -d "$DEST" ]]; then
  79. current=$(find "$DEST" -mindepth 1 -print | wc -l)
  80. else
  81. current=0
  82. fi
  83. if [[ "$current" -ne "$last_reported" ]]; then
  84. elapsed_val="$(elapsed)"
  85. pct=""
  86. if [[ -n "$total_items" && "$total_items" -gt 0 ]]; then
  87. pct=$(awk -v c="$current" -v t="$total_items" 'BEGIN{printf(" (%.1f%%)", (c*100)/t)}')
  88. fi
  89. printf "[%ss] Exported %d items%s\n" "$elapsed_val" "$current" "$pct"
  90. last_reported="$current"
  91. fi
  92. sleep 1
  93. done
  94. wait "$export_pid"
  95. final_count=$(find "$DEST" -mindepth 1 -print | wc -l 2>/dev/null || echo 0)
  96. printf "[%ss] Exported %d items (done)\n" "$(elapsed)" "$final_count"
  97. }
  98. init_git_repo() {
  99. echo "[$(elapsed) s] Initializing git repository..."
  100. GIT_QUIET=1 git init >/dev/null
  101. git config init.defaultBranch trunk || true
  102. git config core.fileMode false || true
  103. git config core.autocrlf true || true
  104. git config core.safecrlf false || true
  105. # Seed ignores for common noise (pyc/cache)
  106. if [[ ! -e .gitignore ]]; then
  107. echo "[$(elapsed) s] Adding default ignores (.pyc/__pycache__)..."
  108. printf "*.pyc\n__pycache__/\n" > .gitignore
  109. fi
  110. }
  111. link_git_to_source() {
  112. echo "[$(elapsed) s] Linking git metadata to source working tree..."
  113. DEST_ABS="$(pwd -P)"
  114. cd "$SRC_DIR"
  115. if [[ -e .git || -L .git ]]; then
  116. echo "[$(elapsed)s] Replacing existing .git in $SRC_DIR..."
  117. rm -rf -- .git
  118. fi
  119. printf "gitdir: %s/.git\n" "$DEST_ABS" > .git
  120. echo "[$(elapsed)s] Linked .git to $DEST_ABS/.git"
  121. echo "[$(elapsed) s] Syncing working tree to repo content..."
  122. GIT_QUIET=1 git checkout -q -- .
  123. }
  124. stage_and_commit() {
  125. local subject="${1:-}"
  126. local body="${2:-}"
  127. local allow_empty="${3:-0}"
  128. local display="${4:-$subject}"
  129. [[ -z "$subject" ]] && subject="SVN snapshot"
  130. git add -A
  131. if (( allow_empty )); then
  132. if [[ -n "$body" ]]; then
  133. GIT_QUIET=1 git commit -q --allow-empty -m "$subject" -m "$body"
  134. else
  135. GIT_QUIET=1 git commit -q --allow-empty -m "$subject"
  136. fi
  137. else
  138. if [[ -n "$body" ]]; then
  139. GIT_QUIET=1 git commit -q -m "$subject" -m "$body"
  140. else
  141. GIT_QUIET=1 git commit -q -m "$subject"
  142. fi
  143. fi
  144. }
  145. # Validate history_count is numeric (default 0)
  146. if [[ -z "${history_count:-}" ]]; then
  147. history_count=0
  148. elif ! [[ "$history_count" =~ ^[0-9]+$ ]]; then
  149. echo "--history must be a non-negative integer" >&2
  150. exit 1
  151. fi
  152. if (( history_count == 0 )); then
  153. simple_export
  154. cd "$DEST"
  155. init_git_repo
  156. stage_and_commit "Import SVN snapshot"
  157. link_git_to_source
  158. exit 0
  159. fi
  160. # History mode: pull last N revisions into Git.
  161. SRC_URL="$(svn info --show-item url 2>/dev/null || svn info | awk -F': ' '/^URL:/ {print $2; exit}')"
  162. if [[ -z "$SRC_URL" ]]; then
  163. echo "Unable to determine SVN URL" >&2
  164. exit 1
  165. fi
  166. REPO_ROOT="$(svn info --show-item repos-root-url 2>/dev/null || svn info | awk -F': ' '/^Repository Root:/ {print $2; exit}')"
  167. 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)
  168. if (( ${#REV_LIST[@]} == 0 )); then
  169. echo "No SVN revisions found for path $SRC_URL" >&2
  170. exit 1
  171. fi
  172. start_rev="${REV_LIST[0]}"
  173. head_rev="${REV_LIST[-1]}"
  174. echo "Including last ${#REV_LIST[@]} revisions affecting this path (r${start_rev}..r${head_rev})..."
  175. WC_DIR="$(mktemp -d "${MIRROR_ROOT}/.svn_git_sync_wc.XXXXXX")"
  176. mkdir -p "$DEST"
  177. sync_wc_to_dest() {
  178. rsync -a --delete --exclude '.svn' --exclude '.git' "$WC_DIR"/ "$DEST"/
  179. if [[ ! -e "$DEST/.gitignore" ]]; then
  180. printf "*.pyc\n__pycache__/\n" > "$DEST/.gitignore"
  181. fi
  182. }
  183. echo "[$(elapsed) s] Checking out r${start_rev}..."
  184. svn checkout -q -r "$start_rev" "$SRC_URL" "$WC_DIR"
  185. sync_wc_to_dest
  186. cd "$DEST"
  187. init_git_repo
  188. build_commit_msg() {
  189. local rev="$1"
  190. local svn_log author msg_body
  191. svn_log="$(svn log -r "$rev" -l 1 "$SRC_URL")"
  192. author="$(printf "%s\n" "$svn_log" | awk 'NR==2{split($0,a,"|"); gsub(/^ +| +$/,"",a[2]); print a[2]}')"
  193. msg_body="$(printf "%s\n" "$svn_log" | sed '1,2d;/^---/,$d')"
  194. if { [[ -z "$author" ]] || [[ -z "$msg_body" ]]; } && [[ -n "$REPO_ROOT" ]]; then
  195. svn_log="$(svn log -r "$rev" -l 1 "$REPO_ROOT" 2>/dev/null || true)"
  196. author="$(printf "%s\n" "$svn_log" | awk 'NR==2{split($0,a,"|"); gsub(/^ +| +$/,"",a[2]); print a[2]}')"
  197. msg_body="$(printf "%s\n" "$svn_log" | sed '1,2d;/^---/,$d')"
  198. fi
  199. msg_body="$(printf "%s" "$msg_body" | perl -0777 -pe 's/\A\s+//; s/\s+\z//; s/\n{2,}/\n\n/g')"
  200. COMMIT_BODY="$msg_body"
  201. COMMIT_BODY_FIRST="$(printf "%s" "$msg_body" | sed -n '1p')"
  202. COMMIT_SUBJECT="SVN r${rev}"
  203. [[ -n "$author" ]] && COMMIT_SUBJECT+=" by $author"
  204. COMMIT_PROGRESS="$COMMIT_SUBJECT"
  205. [[ -n "$COMMIT_BODY_FIRST" ]] && COMMIT_PROGRESS+=" — $COMMIT_BODY_FIRST"
  206. }
  207. build_commit_msg "$start_rev"
  208. echo "[$(elapsed) s] Preparing commit: $COMMIT_PROGRESS"
  209. stage_and_commit "$COMMIT_SUBJECT" "$COMMIT_BODY" 0 "$COMMIT_PROGRESS"
  210. if (( ${#REV_LIST[@]} > 1 )); then
  211. for rev in "${REV_LIST[@]:1}"; do
  212. svn update -q -r "$rev" "$WC_DIR"
  213. sync_wc_to_dest
  214. build_commit_msg "$rev"
  215. echo "[$(elapsed) s] Preparing commit: $COMMIT_PROGRESS"
  216. stage_and_commit "$COMMIT_SUBJECT" "$COMMIT_BODY" 1 "$COMMIT_PROGRESS"
  217. done
  218. fi
  219. rm -rf -- "$WC_DIR"
  220. link_git_to_source