Skip to content

Instantly share code, notes, and snippets.

@wagenet
Created April 29, 2026 20:10
Show Gist options
  • Select an option

  • Save wagenet/4ed42f15bc84e85347717d191fc13042 to your computer and use it in GitHub Desktop.

Select an option

Save wagenet/4ed42f15bc84e85347717d191fc13042 to your computer and use it in GitHub Desktop.
#!/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