Skip to content

Instantly share code, notes, and snippets.

@sideshowbarker
Forked from nikita240/subtrees.sh
Last active November 2, 2025 06:27
Show Gist options
  • Select an option

  • Save sideshowbarker/eb40d5c8d0bbaf15c3eed835d2fae4e9 to your computer and use it in GitHub Desktop.

Select an option

Save sideshowbarker/eb40d5c8d0bbaf15c3eed835d2fae4e9 to your computer and use it in GitHub Desktop.
Convert git submodules to git subtrees
#!/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