Last active
December 29, 2023 20:46
-
-
Save ierceg/0954512137489c9b46cb9e99c4adbf82 to your computer and use it in GitHub Desktop.
Split branch into multiple PRs based on selected files, not commits
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/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 |
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
This is the last version where the main interaction loop is based on branch management. The next one will have main loop managing files with branches just being a byproduct. The sync loop and PR loop will remain the same.