-
-
Save sideshowbarker/eb40d5c8d0bbaf15c3eed835d2fae4e9 to your computer and use it in GitHub Desktop.
Convert git submodules to git subtrees
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
| #!/bin/bash | |
| # Converts all git submodules in a repository to subtrees, and writes | |
| # information about their corresponding remotes out to a .gitsubtrees file. | |
| # | |
| # ------------------------------------------------------------------------- | |
| # IMPORTANT: Save this script somewhere OUTSIDE YOUR REPO CLONE DIRECTORY | |
| # ------------------------------------------------------------------------- | |
| # | |
| # 1. Save the script in, for example, your home directory, as ~/subtrees.sh. | |
| # | |
| # 2. cd into your repo clone directory. | |
| # | |
| # 3. Now call your ~/subtrees.sh script. | |
| # | |
| # Placing the script outside the clone directory — or else adding the | |
| # script’s filename to the .gitignore file in the clone directory — ensures | |
| # that the presence of script file as an untracked file in the repo doesn’t | |
| # interfere with script’s ability to make the commits it needs to make. | |
| # ------------------------------------------------------------------------- | |
| set -euo pipefail | |
| GITSUBTREES_FILE=".gitsubtrees.yaml" | |
| declare -A subtrees | |
| add-prefix-to-git-subtree-generated-commit-messages() { | |
| local prefix="${1:-chore:}" # Optional prefix argument, default "chore:" | |
| # Identify commits and trees | |
| local merge_commit=HEAD | |
| local base_parent | |
| base_parent=$(git rev-parse "$merge_commit^1") | |
| local squash_commit | |
| squash_commit=$(git rev-parse "$merge_commit^2") | |
| local merge_tree | |
| merge_tree=$(git rev-parse "$merge_commit^{tree}") | |
| local squash_tree | |
| squash_tree=$(git rev-parse "$squash_commit^{tree}") | |
| # Read original commit messages | |
| local orig_squash_msg | |
| orig_squash_msg=$(git log -1 --pretty=%B "$squash_commit") | |
| local orig_merge_msg | |
| orig_merge_msg=$(git log -1 --pretty=%B "$merge_commit") | |
| # Helper to prefix subject | |
| prefix_subject() { | |
| local msg="$1" | |
| awk -v pre="$prefix" 'NR==1 {print pre " " $0; next} {print}' <<< "$msg" | |
| } | |
| local new_squash_msg new_merge_msg | |
| new_squash_msg=$(prefix_subject "$orig_squash_msg") | |
| new_merge_msg=$(prefix_subject "$orig_merge_msg") | |
| # Create new squashed commit | |
| local new_squash_commit | |
| new_squash_commit=$(printf '%s\n' "$new_squash_msg" | git commit-tree "$squash_tree" -p "$base_parent") | |
| # Create new merge commit | |
| local new_merge_commit | |
| new_merge_commit=$(printf '%s\n' "$new_merge_msg" | git commit-tree "$merge_tree" -p "$base_parent" -p "$new_squash_commit") | |
| git reset --hard "$new_merge_commit" | |
| } | |
| # Make sure we actually have some submodules | |
| if [ ! -f ".gitmodules" ]; then | |
| echo "❌ No .gitmodules found. Are you in a git repo directory? And does the repo have submodules?" | |
| exit 1 | |
| fi | |
| # Make sure the working directory is clean | |
| if ! git diff --quiet || ! git diff --cached --quiet; then | |
| echo "❌ Uncommitted changes present — commit or stash first." | |
| return 1 | |
| fi | |
| echo "Converting submodules into subtrees..." | |
| process_submodule() { | |
| local mpath="$1" | |
| local murl="$2" | |
| local mbranch="$3" | |
| local mname | |
| mname=$(basename "$mpath") | |
| echo "Processing $mname at $mpath from branch $mbranch..." | |
| # get current submodule commit | |
| local mcommit | |
| mcommit=$(git submodule status "$mpath" | awk '{print $1}') | |
| echo " Submodule commit: $mcommit" | |
| # deinit and remove submodule | |
| git submodule deinit -f "$mpath" | |
| git rm -r --cached "$mpath" | |
| rm -rf "$mpath" | |
| git commit -m 'chore: Remove “'"$mpath"'” submodule at commit '"$mcommit" | |
| # add remote | |
| git remote add -f "$mname" "$murl" 2>/dev/null || true | |
| # add subtree | |
| git subtree add --prefix "$mpath" "$mname" "$mbranch" --squash | |
| add-prefix-to-git-subtree-generated-commit-messages "chore:" | |
| # last local merge commit | |
| local last_merge | |
| last_merge=$(git log -1 --format=%H -- "$mpath") | |
| echo " Local merge commit: $last_merge" | |
| # store in memory | |
| subtrees["$mpath"]="url: $murl | |
| name: $mname | |
| branch: $mbranch | |
| last_local_merge: $last_merge | |
| commit: $mcommit" | |
| # fetch remote branch (optional) | |
| git fetch "$murl" "$mbranch" || true | |
| } | |
| mpath="" | |
| murl="" | |
| mbranch="" | |
| while IFS= read -r line; do | |
| if [[ $line =~ \[submodule\ \"(.+)\"\] ]]; then | |
| # process previous submodule if exists | |
| if [[ -n "$mpath" && -n "$murl" ]]; then | |
| : "${mbranch:=master}" | |
| process_submodule "$mpath" "$murl" "$mbranch" | |
| fi | |
| # reset variables for new submodule | |
| mpath="" | |
| murl="" | |
| mbranch="" | |
| elif [[ $line =~ path\ =\ (.+) ]]; then | |
| mpath="${BASH_REMATCH[1]}" | |
| elif [[ $line =~ url\ =\ (.+) ]]; then | |
| murl="${BASH_REMATCH[1]}" | |
| elif [[ $line =~ branch\ =\ (.+) ]]; then | |
| mbranch="${BASH_REMATCH[1]}" | |
| fi | |
| done < .gitmodules | |
| # process last submodule | |
| if [[ -n "$mpath" && -n "$murl" ]]; then | |
| : "${mbranch:=master}" | |
| process_submodule "$mpath" "$murl" "$mbranch" | |
| fi | |
| # write .gitsubtrees at the end | |
| echo "Writing $GITSUBTREES_FILE..." | |
| { | |
| for prefix in "${!subtrees[@]}"; do | |
| echo "$prefix:" | |
| echo " ${subtrees[$prefix]//$'\n'/$'\n '}" | |
| done | |
| } > "$GITSUBTREES_FILE" | |
| # remove .gitmodules | |
| git rm .gitmodules | |
| git commit -a -m "chore: Remove .gitmodules" | |
| echo "Done. All submodules converted to subtrees successfully." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment