Skip to content

Instantly share code, notes, and snippets.

@ierceg
Last active December 29, 2023 20:46
Show Gist options
  • Save ierceg/0954512137489c9b46cb9e99c4adbf82 to your computer and use it in GitHub Desktop.
Save ierceg/0954512137489c9b46cb9e99c4adbf82 to your computer and use it in GitHub Desktop.
Split branch into multiple PRs based on selected files, not commits
#!/bin/sh
# TODO:
# - Allow for splitting the same file into multiple branches.
# - If there are manual commits apart from the automatic merges and (split-branch) commits, <do something>. `git merge-base` is useful here.
# Design:
# - The script should be run from the branch that is being split.
# - The script accepts the target branch as an argument.
# - The script will automatically merge the target branch into the source branch if needed.
# - The script will automatically create a pull request for each split branch.
if [ -z "$(command -v git)" ]; then
echo "You must have git installed to run this script."
exit 1
fi
if [ -z "$(command -v fzf)" ]; then
echo "You must have fzf installed to run this script."
exit 1
fi
if [ -z "$(command -v gh)" ]; then
echo "You must have gh installed to run this script."
exit 1
fi
if [ -z "$(command -v jq)" ]; then
echo "You must have jq installed to run this script."
exit 1
fi
REPO_ROOT_DIR=$(git rev-parse --show-toplevel)
if [ -z "$REPO_ROOT_DIR" ]; then
echo "You must be in a git repository to run this script."
exit 1
fi
TARGET_BRANCH=$1
if [ -z "$TARGET_BRANCH" ]; then
echo "Usage: $0 <target-branch>"
exit 1
fi
# Check that the target branch exists.
if [ -z "$(git branch --list $TARGET_BRANCH)" ]; then
echo "The target branch $TARGET_BRANCH does not exist."
exit 1
fi
# Source branch is the current branch.
SOURCE_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$TARGET_BRANCH" == "$SOURCE_BRANCH" ]; then
echo "You must be on the target branch to run this script."
exit 1
fi
# Warning if this is the first time the branch is being split.
if [ -z "git branch --list $SOURCE_BRANCH---split---*" ];
then
read -p "This is the first time $SOURCE_BRANCH is being split. Are you CERTAIN that you want to continue? [Y/n] " answer
# Default to Yes if the user just presses enter
answer=${answer:-Y}
# Check if the response starts with Y or y
if [[ $answer =~ ^[Yy]$ ]] || [[ -z $answer ]]; then
echo "Continuing..."
else
echo "Exiting."
exit 1
fi
fi
if [ "$REPO_ROOT_DIR" != "$PWD" ]; then
echo "You are in $PWD which is not the root directory $REPO_ROOT_DIR of this git repository."
read -p "Do you want to change to the root directory before proceeding? [Y/n] " answer
# Default to Yes if the user just presses enter
answer=${answer:-Y}
# Check if the response starts with Y or y
if [[ $answer =~ ^[Yy]$ ]] || [[ -z $answer ]]; then
cd "$REPO_ROOT_DIR" || exit
echo "Changed to the repository root: $REPO_ROOT_DIR"
else
echo "Fair. Please change to the root directory manually."
exit 1
fi
fi
# Check that the source branch is up to date with the target branch.
if [ $(git merge-base $TARGET_BRANCH $SOURCE_BRANCH) = $(git rev-parse $TARGET_BRANCH) ]; then
echo "$SOURCE_BRANCH is up-to-date with $TARGET_BRANCH"
else
read -p "$SOURCE_BRANCH is not up-to-date with $TARGET_BRANCH and a merge is needed. Do you want to merge now? [Y/n] " answer
answer=${answer:-Y}
if [[ $answer =~ ^[Yy]$ ]] || [[ -z $answer ]]; then
git checkout $SOURCE_BRANCH
RESULT=`git merge $TARGET_BRANCH --no-edit`
if [ $? -ne 0 ]; then
echo "$RESULT"
echo "Failed to automatically merge $TARGET_BRANCH into $SOURCE_BRANCH. Please merge manually. Exiting and leaving the repository in a dirty state."
exit 1
fi
git checkout $TARGET_BRANCH
else
echo "Fair. Please merge manually."
exit 1
fi
fi
# Create temporary files
split_files_file=$(mktemp)
added_files_file=$(mktemp)
deleted_files_file=$(mktemp)
# Define a cleanup function
EXIT_CODE=1 # Default error code
cleanup() {
echo "Cleaning up..."
rm -f "$split_files_file"
rm -f "$added_files_file"
rm -f "$deleted_files_file"
echo "Checking out $SOURCE_BRANCH..."
git checkout $SOURCE_BRANCH
exit $EXIT_CODE
}
# Set the trap to call cleanup on script exit, error, or interruption
trap cleanup EXIT INT TERM
# Recalculates the split_files_file. We need to do this after changes to any split branch.
sync_branches_and_recalculate_split_files_file() {
# Check if split branches already exist for the source branch. Remove leading * if it exists.
SPLIT_BRANCHES=`git branch --list $SOURCE_BRANCH---split---* | sed 's/^\*[[:space:]]*//'`
if [ -n "$SPLIT_BRANCHES" ]; then
# Reset the split_files_file correctly (no empty lines to not confuse grep -v)
: > "$split_files_file"
echo "Split branches already exist for $SOURCE_BRANCH."
echo "$SPLIT_BRANCHES"
echo "Collecting files that have already been split..."
for SPLIT_BRANCH in $SPLIT_BRANCHES; do
# Skip the branch completely if it has a PR that has been merged.
PR_STATE=`gh pr list --state all --base $TARGET_BRANCH --head $SPLIT_BRANCH --json state | jq -r ".[0].state"`
# If PR is closed or merged, the branch should be deleted to avoid confusion.
if [ "$PR_STATE" == "CLOSED" ] || [ "$PR_STATE" == "MERGED" ]; then
echo "Pull request for split branch $SPLIT_BRANCH has been merged or closed so the branch is ignored."
echo "If files that were in it have been updated, a new split branch should be created."
fi
echo "Checking out $SPLIT_BRANCH..."
git checkout $SPLIT_BRANCH
# Extract added files in order to synchronize the split branch with the source branch.
# We do merging with target branch *after* we synchronize because it should minimize
# the number of conflicts.
# TODO: Consider "rebasing" by undoing all the chages (all the additions) and then
# TODO: merging the target branch and then reapplying the changes.
git log --pretty=format:"%s" | \
grep "^(split-branch): +++" | \
sed 's/^(split-branch): +++ //' | \
sort | uniq > "$added_files_file"
# If any of the added files has also been removed and the count is the same, then
# the file is no longer part of the split branch.
# If any of the added files that have been split no longer exists in the source branch,
# remove it from the split branch.
# If any of the added files that have been split has changed in the source branch,
# update it in the split branch.
for FILE in $(cat $added_files_file); do
# Count additions of the specific file
count_additions=$(git log --pretty=format:"%s" | grep -c "^(split-branch): +++ $FILE")
# Count deletions of the specific file
count_deletions=$(git log --pretty=format:"%s" | grep -c "^(split-branch): --- $FILE")
net_additions=$((count_additions - count_deletions))
if [ "$net_additions" -eq 1 ]; then
# The file belongs to the split branch.
echo "$FILE" >> "$split_files_file"
elif [ "$net_additions" -eq 0 ]; then
# The file no longer belongs to the split branch.
continue
else
echo "Error: Inconsistent file history for $FILE, net additions $net_additions. Aborting."
EXIT_CODE=100
exit $EXIT_CODE
fi
# Skip synchronization if the branch has been marked to be in the manual mode.
# TODO: Count the +manual/-manual.
if [ -n "$(git log --pretty=format:"%s" | grep "^(split-branch): +manual")" ]; then
echo "Split branch $SPLIT_BRANCH has been marked as in manual mode. Skipping synchronization..."
continue
fi
# Synchronize the file with the source branch.
if [ -z "$(git ls-tree -r --name-only "$SOURCE_BRANCH" | grep "^$FILE$")" ]; then
echo "File $FILE has been deleted in source branch. Removing from $SPLIT_BRANCH..."
git rm $FILE
git commit -m "(split-branch): --- $FILE"
else
if git diff --quiet "$SOURCE_BRANCH" "$SPLIT_BRANCH" -- "$FILE"; then
echo "File $FILE has not changed in source branch. Skipping..."
else
echo "File $FILE has changed in source branch. Updating in $SPLIT_BRANCH..."
git checkout "$SOURCE_BRANCH" -- "$FILE"
git add "$FILE"
# Mark this operation with === as it's not adding a new file, just updating an existing one.
git commit -m "(split-branch): === $FILE"
fi
fi
done
# After syncing, automatically merge the target branch into the split branch if needed.
# The user already gave us permission to merge the source branch into the target branch so we don't ask again.
if [ $(git merge-base "$TARGET_BRANCH" "$SPLIT_BRANCH") = $(git rev-parse "$TARGET_BRANCH") ]; then
echo "$SPLIT_BRANCH is up-to-date with $TARGET_BRANCH"
else
echo "$SPLIT_BRANCH is not up-to-date with $TARGET_BRANCH and a merge is needed. Merging now..."
RESULT=`git merge $TARGET_BRANCH --no-edit`
if [ $? -ne 0 ]; then
echo "$RESULT"
echo "Failed to automatically merge $TARGET_BRANCH into $SPLIT_BRANCH. Please merge manually. Exiting and leaving the repository in a dirty state."
exit 1
fi
fi
done
echo "The following files have already been split:"
cat "$split_files_file"
fi
}
echo "Splitting $SOURCE_BRANCH from $TARGET_BRANCH..."
# Main interactive loop:
# - Collect all the files that have already been split into different branches.
# - Check the difference between the $TARGET_BRANCH and $SOURCE_BRANCH.
# - Distribute the files that have not been split yet into different branches or remove the files
# from the split branches if so desired.
while [ 1 ]; do
RESULT=`sync_branches_and_recalculate_split_files_file`
if [ $? -ne 0 ]; then
echo "$RESULT"
echo "Failed to synchronize branches. Exiting and leaving the repository in a dirty state."
EXIT_CODE=3
exit $EXIT_CODE
fi
git checkout $TARGET_BRANCH
DIFF_FILES=`git diff --name-only $SOURCE_BRANCH $TARGET_BRANCH | grep -v -f "$split_files_file"`
echo "changed files: $(git diff --name-only $SOURCE_BRANCH $TARGET_BRANCH)"
echo "diff files: $DIFF_FILES"
read -p "Do you want to make any changes to the split branches? [N/y] " answer
answer=${answer:-N}
if [[ $answer =~ ^[Nn]$ ]]; then
break
fi
# Get the suffix for the split branch.
EXIT_CODE=2
# Get the split branches and allow the user to choose which one to add to.
SPLIT_BRANCH=$(echo "<new branch>\n$(git branch --list "$SOURCE_BRANCH---split---*" | sed 's/^\*[[:space:]]*//')" | fzf --cycle --header-first --header "Choose the split branch to add to or a new branch option." --preview "git diff --color=always $TARGET_BRANCH {}")
echo $SPLIT_BRANCH
if [[ -z "$SPLIT_BRANCH" ]] || [[ "$SPLIT_BRANCH" == "<new branch>" ]]; then
read -p "Enter the suffix for the new branch: [cancel] " SPLIT_SUFFIX
if [ -z "$SPLIT_SUFFIX" ]; then
# Go back to the beginning of the loop.
continue
fi
else
read -p "Do you want to add new files or remove files from this branch? [y/N] " answer
answer=${answer:-N}
if [[ $answer =~ ^[Nn]$ ]]; then
continue
fi
SPLIT_SUFFIX=`echo $SPLIT_BRANCH | sed 's/.*---//'`
fi
FILES_TO_ADD=`echo "$DIFF_FILES" | fzf -m --cycle --header-first --header "Choose files to add to branch $SPLIT_SUFFIX. Esc if nothing to select." --preview "git diff --color=always $TARGET_BRANCH $SOURCE_BRANCH -- {}"`
FILES_TO_REMOVE=`echo "$SPLIT_FILES" | fzf -m --cycle --header-first --header "Choose files to remove from $SPLIT_SUFFIX. Esc if nothing to select." --preview "git diff --color=always $TARGET_BRANCH $SOURCE_BRANCH -- {}"`
if [ -z "$FILES_TO_ADD" ] && [ -z "$FILES_TO_REMOVE" ]; then
echo "No files were selected to either be added or removed. Repeating the process."
continue
fi
# Checkout the branch and add/remove the files.
if [ -n "$(git branch --list $SOURCE_BRANCH---split---$SPLIT_SUFFIX)" ]; then
git checkout $SOURCE_BRANCH---split---$SPLIT_SUFFIX
else
git checkout -b $SOURCE_BRANCH---split---$SPLIT_SUFFIX
fi
# Add the files to the split branch from the source branch.
for FILE in $FILES_TO_ADD; do
git checkout $SOURCE_BRANCH -- "$FILE"
git add "$FILE"
git commit -m "(split-branch): +++ $FILE"
done
# Remove the files from the split branch by checking them out from the target branch or deleting them (if they are new files)
for FILE in $FILES_TO_REMOVE; do
if [ -n "$(git ls-tree -r --name-only "$TARGET_BRANCH" | grep "^$FILE$")" ]; then
git checkout $TARGET_BRANCH -- "$FILE"
else
git rm "$FILE"
fi
git commit -m "(split-branch): --- $FILE"
done
done;
# Now go over each branch and push it to the remote repository.
for SPLIT_BRANCH in `git branch --list $SOURCE_BRANCH---split---* | sed 's/^\*[[:space:]]*//'`; do
echo "Pushing $SPLIT_BRANCH..."
git checkout $SPLIT_BRANCH
git push --no-verify --set-upstream origin $SPLIT_BRANCH
# Check if the pull request already exists for this branch.
PR=`gh pr list --state all --base $TARGET_BRANCH --head $SPLIT_BRANCH --json url | jq -r ".[0].url"`
if [[ -n "$PR" ]] && [[ "$PR" != "null" ]]; then
echo "Pull request already exists for $SPLIT_BRANCH into $TARGET_BRANCH: $PR"
continue
fi
# Ask the user if they want to create a pull request assuming GitHub.
read -p "Do you want to create a pull request for $SPLIT_BRANCH? [Y/n] " answer
# Default to Yes if the user just presses enter
answer=${answer:-Y}
# Check if the response starts with Y or y
if [[ $answer =~ ^[Yy]$ ]] || [[ -z $answer ]]; then
# Create a pull request with suffix being all the characters after the last `---` in the branch name.
SUFFIX=`echo $SPLIT_BRANCH | sed 's/.*---//'`
gh pr create -B $TARGET_BRANCH -H $SPLIT_BRANCH -t "Split $SOURCE_BRANCH for $SUFFIX"
PR=`gh pr list --state all --base $TARGET_BRANCH --head $SPLIT_BRANCH --json url | jq -r ".[0].url"`
echo "Pull request created for $SPLIT_BRANCH into $TARGET_BRANCH: $PR"
fi
done
# Signal that we are exiting cleanly.
EXIT_CODE=0
@ierceg
Copy link
Author

ierceg commented Dec 29, 2023

Ok now the main interactive loop works with files rather than a set of branches (branches are still there of course, but secondary to files)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment