Created
April 29, 2026 20:10
-
-
Save wagenet/4ed42f15bc84e85347717d191fc13042 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # git-migrate-reftable.sh | |
| # Migrates a git repo from files backend to reftable, handling worktrees. | |
| # | |
| # Usage: ./git-migrate-reftable.sh [--dry-run] | |
| # | |
| # Requirements: git >= 2.46 | |
| # Caveats: | |
| # - Reflogs are pruned (lost) — required by git refs migrate | |
| # - No concurrent git operations should run during migration | |
| # - Worktree directories are left on disk; all files (including ignored/untracked) are safe | |
| # - Detached-HEAD worktrees are restored to their commit (not a branch) | |
| # - Aborts if any worktree has uncommitted tracked-file changes | |
| set -euo pipefail | |
| DRY_RUN=false | |
| [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true && echo "[dry-run] No changes will be made." | |
| run() { $DRY_RUN && echo "[dry-run]" "$@" || "$@"; } | |
| die() { echo "Error: $*" >&2; exit 1; } | |
| # ── Preflight ───────────────────────────────────────────────────────────────── | |
| [[ -d ".git" ]] || die "Run from the main worktree root (where .git/ is a directory)." | |
| min_version="2.46" | |
| git_version=$(git --version | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1) | |
| IFS='.' read -r gmaj gmin _ <<< "$git_version" | |
| IFS='.' read -r rmaj rmin <<< "$min_version" | |
| (( gmaj > rmaj || (gmaj == rmaj && gmin >= rmin) )) || \ | |
| die "git >= $min_version required (found $git_version)." | |
| current_format=$(git config --local extensions.refstorage 2>/dev/null || echo "files") | |
| [[ "$current_format" != "reftable" ]] || { echo "Already reftable. Nothing to do."; exit 0; } | |
| git_dir=$(git rev-parse --git-dir) | |
| # ── Capture worktree state ──────────────────────────────────────────────────── | |
| declare -a WT_PATHS WT_BRANCHES WT_COMMITS | |
| main_path=$(pwd) | |
| cur_path="" cur_branch="" cur_commit="" | |
| while IFS= read -r line; do | |
| if [[ "$line" =~ ^worktree[[:space:]](.+) ]]; then cur_path="${BASH_REMATCH[1]}" | |
| elif [[ "$line" =~ ^branch[[:space:]]refs/heads/(.+) ]]; then cur_branch="${BASH_REMATCH[1]}" | |
| elif [[ "$line" =~ ^HEAD[[:space:]]([a-f0-9]+) ]]; then cur_commit="${BASH_REMATCH[1]}" | |
| elif [[ -z "$line" && -n "$cur_path" && "$cur_path" != "$main_path" ]]; then | |
| WT_PATHS+=("$cur_path") | |
| WT_BRANCHES+=("$cur_branch") | |
| WT_COMMITS+=("$cur_commit") | |
| cur_path="" cur_branch="" cur_commit="" | |
| fi | |
| done < <(git worktree list --porcelain; echo "") | |
| echo "Found ${#WT_PATHS[@]} linked worktree(s)." | |
| for i in "${!WT_PATHS[@]}"; do | |
| ref="${WT_BRANCHES[$i]:-detached@${WT_COMMITS[$i]:0:8}}" | |
| echo " [$i] ${WT_PATHS[$i]} -> $ref" | |
| done | |
| # ── Check for stale worktrees (directory missing on disk) ──────────────────── | |
| stale_list="" | |
| stale_count=0 | |
| for i in "${!WT_PATHS[@]}"; do | |
| if [[ ! -d "${WT_PATHS[$i]}" ]]; then | |
| stale_list+=" ${WT_PATHS[$i]}"$'\n' | |
| stale_count=$((stale_count + 1)) | |
| fi | |
| done | |
| if (( stale_count > 0 )); then | |
| echo "Error: $stale_count worktree(s) registered but missing on disk:" >&2 | |
| printf "%s" "$stale_list" >&2 | |
| echo "Run 'git worktree prune' to clean them up, then re-run this script." >&2 | |
| exit 1 | |
| fi | |
| # ── Check for dirty worktrees ───────────────────────────────────────────────── | |
| for i in "${!WT_PATHS[@]}"; do | |
| path="${WT_PATHS[$i]}" | |
| if ! git -C "$path" diff --quiet || ! git -C "$path" diff --cached --quiet; then | |
| die "Worktree '$path' has uncommitted changes. Stash or commit before migrating." | |
| fi | |
| done | |
| # ── Backup ──────────────────────────────────────────────────────────────────── | |
| backup_file="$(pwd)/reftable-migration-backup-$(date +%Y%m%d%H%M%S).bundle" | |
| echo "Creating backup bundle: $backup_file" | |
| run git bundle create "$backup_file" --all | |
| echo "Backup complete." | |
| # ── Deregister linked worktrees (leave directories intact) ─────────────────── | |
| # Deleting .git/worktrees/<name> deregisters the worktree without touching the | |
| # working directory. git worktree prune would do the same but only for missing | |
| # directories; we need to force-deregister directories that still exist. | |
| for i in "${!WT_PATHS[@]}"; do | |
| path="${WT_PATHS[$i]}" | |
| # Find the admin entry whose 'gitdir' points back to this worktree path | |
| admin_entry="" | |
| for entry in "$git_dir/worktrees"/*/; do | |
| gitdir_file="$entry/gitdir" | |
| if [[ -f "$gitdir_file" ]]; then | |
| # gitdir contains the path to the .git file inside the worktree | |
| linked_path=$(dirname "$(cat "$gitdir_file")") | |
| if [[ "$linked_path" == "$path" ]]; then | |
| admin_entry="$entry" | |
| break | |
| fi | |
| fi | |
| done | |
| if [[ -n "$admin_entry" ]]; then | |
| echo "Deregistering worktree admin entry: $admin_entry" | |
| run rm -rf "$admin_entry" | |
| # Remove the .git file inside the worktree directory so git doesn't think | |
| # it's still a linked worktree when we re-add it later | |
| run rm -f "$path/.git" | |
| else | |
| echo "Warning: no admin entry found for $path — skipping deregister." | |
| fi | |
| done | |
| # ── Prune reflogs (required by git refs migrate) ────────────────────────────── | |
| echo "Pruning reflogs..." | |
| run git reflog expire --expire=now --all | |
| run git gc --prune=now --quiet | |
| # ── Migrate ─────────────────────────────────────────────────────────────────── | |
| echo "Migrating to reftable..." | |
| run git refs migrate --ref-format=reftable | |
| if ! $DRY_RUN; then | |
| new_format=$(git config --local extensions.refstorage 2>/dev/null || echo "unknown") | |
| [[ "$new_format" == "reftable" ]] || die "Migration may have failed -- extensions.refstorage = $new_format" | |
| echo "Migration confirmed: extensions.refstorage = reftable" | |
| fi | |
| # ── Restore worktrees ───────────────────────────────────────────────────────── | |
| for i in "${!WT_PATHS[@]}"; do | |
| path="${WT_PATHS[$i]}" | |
| branch="${WT_BRANCHES[$i]}" | |
| commit="${WT_COMMITS[$i]}" | |
| # Worktree directories were preserved on disk during deregister. git worktree add | |
| # refuses non-empty existing directories, so move aside, let git create the .git | |
| # linkage in a fresh dir, then swap the .git file into the preserved directory. | |
| tmp_path="${path}.reftable-migrate-$$" | |
| preserved=false | |
| if [[ -d "$path" ]]; then | |
| run mv "$path" "$tmp_path" | |
| preserved=true | |
| fi | |
| if [[ -n "$branch" ]]; then | |
| echo "Restoring worktree: $path -> branch $branch" | |
| run git worktree add --no-checkout "$path" "$branch" | |
| else | |
| echo "Restoring worktree: $path -> detached commit ${commit:0:8}" | |
| run git worktree add --no-checkout --detach "$path" "$commit" | |
| fi | |
| if $preserved; then | |
| run mv "$path/.git" "$tmp_path/.git" | |
| run rmdir "$path" | |
| run mv "$tmp_path" "$path" | |
| # Index is empty after --no-checkout. Sync it from HEAD so the preserved | |
| # working files match the index without overwriting anything on disk. | |
| run git -C "$path" reset --mixed HEAD | |
| else | |
| if [[ -n "$branch" ]]; then | |
| run git -C "$path" checkout "$branch" | |
| else | |
| run git -C "$path" checkout "$commit" | |
| fi | |
| fi | |
| done | |
| echo "Done." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment