How do I manage multiple dependent Pull Requests in the least chaotic way possible?
Often my work is multi faceted and requires several incremental steps to complete. In efforts to keep the scope of work — and the resultant Pull Requests — to a reasonable size I would want to split the work up. Unfortunately, this introduces a maintenance nightmare as each subsequent branch will quickly diverge as I address feedback on each pull request individually — often catastrophically so.
How can I manage multiple dependent pull requests in a less chaotic way while addressing feedback and maintaining possibly frequent rebasing on the main branch?
This is a complex topic but there is hope.
The latest Git versions have several helper options to manage this better.
You will need a fairly new version of Git.
I’ve tested this with git version 2.41.0.28.
Add the following config options:
git config --global rebase.autosquash true
git config --global rebase.updateRefs true
git config --global rebase.rebaseMerges trueIn this example I will build up a PR set. The first PR will be featureA.
* 3d477ad (HEAD -> featureA) A2
* f0751af A1
* 1304eb2 (main) M1Make your usual Pull Request
and base it off main.
While that is waiting
I'll move on to featureB.
* eb9af14 (HEAD -> featureB) B1
* 3d477ad (HEAD -> featureA) A2
* f0751af A1
* 1304eb2 (main) M1But while working on featureB I realized I need to fix a bug in another module. The fix is isolated on its own and deserves its own pull request but featureB also depends on the fix. To make it easy I will work on it here and rebase/cherry-pick the changes to their own branch. This will temporarily look like:
* 2fcb9e3 (HEAD -> featureB) B2
* 947f7a7 fix1
* eb9af14 B1
* 3d477ad (featureA) A2
* f0751af A1
* 1304eb2 (main) M1Then I'll run the following:
# Make a new branch off main
git checkout -b my-fix main # merge base
# Pull in the temp changes from featureB
git cherry-pick 947f7a7
# ... Create pull request ...
git checkout featureBWe will rebase just featureB'on its own.
git rebase -i featureA
and edit the todo list
to remove the no longer needed fix commit(s)
and merge the fix branch into this one.
Change it from:
label onto
reset onto
pick eb9af14 B1
pick 947f7a7 fix1
pick 2fcb9e3 B2
# Rebase 3d477ad..2fcb9e3 onto 3d477ad (5 commands)To this:
label onto
reset onto
merge my-fix
pick eb9af14 B1
pick 2fcb9e3 B2
# Rebase 3d477ad..2fcb9e3 onto 3d477ad (5 commands)* 6843025 (HEAD -> featureB) B2
* 139e024 B1
* b30d28a Merge branch 'my-fix'
|\
| * e4544ef (my-fix) fix1
* | 3d477ad (featureA) A2
* | f0751af A1
|/
* 1304eb2 (main) M1Now I'm ready for another pull request. When creating a pull request for featureB make sure to set the base branch in your Pull Request to featureA not main. This is important.
While working on to featureC
we get some feed back on featureA.
To keep things easy
we address the feed back in featureC
but we make the commits as fixup commits
so that when we rebase the set
Git will apply them in the right place.
To do this easily when you commit
add the --fixup argument with the SHA of the commit these changes will fix-up.
git commit --fixup f0751af* 389ccd3 (HEAD -> featureC) fixup! A1
* 76ff438 C1
* 282d2f0 (featureB) Merge branch 'my-fix' into featureB
|\
| * ed6674a (my-fix) fix2
| * d48e7e3 fix1
* | c49ef70 B2
* | fbe9abb B1
* | 9a06f61 (featureA) A2
* | 59358d3 A1
|/
* 08971e5 M1Time to get our changes up-to-date with main.
Do your usual checkout main and pull.
git checkout main
git pull
git checkout -* 6a9e9e7 (main) M2
| * b7111e3 (HEAD -> featureC) fixup! A1
| * 84f29fe C1
| * 6843025 (featureB) B2
| * 139e024 B1
| * b30d28a Merge branch 'my-fix'
| |\
| | * e4544ef (my-fix) fix1
| |/
|/|
| * 3d477ad (featureA) A2
| * f0751af A1
|/
* 1304eb2 M1Then we interactively rebase the entire chain from
(in this case)
featureC:
git rebase -i main which will produce the following todo in your editor:
label onto
# Branch my-fix
reset onto
pick e4544ef fix1
update-ref refs/heads/my-fix
label my-fix
reset onto
pick f0751af A1
fixup b7111e3 fixup! A1
pick 3d477ad A2
update-ref refs/heads/featureA
merge -C b30d28a my-fix # Merge branch 'my-fix'
pick 139e024 B1
pick 6843025 B2
update-ref refs/heads/featureB
pick 84f29fe C1
# Rebase 6a9e9e7..b7111e3 onto 6a9e9e7 (15 commands)It is recreating the branching structure as well as preserving the merges we’ve done. You may also notice that the fixup was moved to the correct location. Save this off and allow Git to perform all the steps. You’ll get an output that looks like this.
Successfully rebased and updated refs/heads/featureC.
Updated the following refs with --update-refs:
refs/heads/featureA
refs/heads/featureB
refs/heads/my-fixSome things to note is that your current branch (featureC) is not in the list as a rebase presumes the current branch is the focus of the rebase unlike the others which were side-effects of the rebase. I find it helpful to copy the current branch name and this output for the next step.
These branches are setup locally but we need to push them out to our cloud service for CI and review use. There is no easy way to do this through git but you can write a command to loop over each and push them up. I typically use an editor to do this step. In default Bash you can press CTRL-X+CTRL-E or for Vi bindings ESC+v.
for branch in featureA featureB my-fix featureC; do
git checkout $branch && git push --force-with-lease
done* 1be7b9c (HEAD -> featureC) C1
* 783c35b (featureB) B2
* 3120777 B1
* a9685fe Merge branch 'my-fix'
|\
| * 345b14e (my-fix) fix1
* | 739ca89 (featureA) A2
* | 66f72bf A1
|/
* 6a9e9e7 (main) M2
* 1304eb2 M1Now my-fix was merged in our cloud service
and you did a pull for main.
You will need to rebase everything on that.
You can git branch -d my-fix
since that is no longer needed locally.
* 5c24663 (main) Merge branch 'my-fix'
|\
| | * 1be7b9c (HEAD -> featureC) C1
| | * 783c35b (featureB) B2
| | * 3120777 B1
| | * a9685fe Merge branch 'my-fix'
| | |\
| | |/
| |/|
| * | 345b14e (my-fix) fix1
|/ /
| * 739ca89 (featureA) A2
| * 66f72bf A1
|/
* 6a9e9e7 M2
* 1304eb2 M1You may need to update the todo list
in this case
to remove the merge of my-fix.
Also remove the first reset line.
pick 66f72bf A1
pick 739ca89 A2
update-ref refs/heads/featureA
pick 3120777 B1
pick 783c35b B2
update-ref refs/heads/featureB
pick 1be7b9c C1
# Rebase 2e5faef..1be7b9c onto 2e5faef (10 commands)And again
update your cloud service
(without the my-fix branch).
for branch in featureA featureB featureC; do
git checkout $branch && git push --force-with-lease
done* bfafaef (HEAD -> featureC) C1
* 726034d (featureB) B2
* 4e4d6fb B1
* 4bbc781 (featureA) A2
* 2d55b55 A1
* 2e5faef (main) Merge branch 'my-fix'
|\
| * 345b14e (my-fix) fix1
|/
* 6a9e9e7 M2
* 1304eb2 M1Finally,
featureA is ready to merge.
Do so via your cloud service.
It should update the subsequent pull requests for you.
But once you have,
perform another git pull
and you can
again
rebase to update everything.
* a63253c (main) Merge branch 'featureA'
|\
| | * bfafaef (HEAD -> featureC) C1
| | * 726034d (featureB) B2
| | * 4e4d6fb B1
| |/
| * 4bbc781 (featureA) A2
| * 2d55b55 A1
|/
* 2e5faef Merge branch 'my-fix'
|\
| * 345b14e (my-fix) fix1
|/
* 6a9e9e7 M2
* 1304eb2 M1git checkout main
git pull
git branch -d featureA
git co -
git rebase -i mainlabel onto
reset onto
pick 4e4d6fb B1
pick 726034d B2
update-ref refs/heads/featureB
pick bfafaef C1
# Rebase a63253c..bfafaef onto a63253c (6 commands)* cb76d36 (HEAD -> featureC) C1
* f4ee99c (featureB) B2
* 2d608ce B1
* a63253c (main) Merge branch 'featureA'
|\
| * 4bbc781 (featureA) A2
| * 2d55b55 A1
|/
* 2e5faef Merge branch 'my-fix'
|\
| * 345b14e (my-fix) fix1
|/
* 6a9e9e7 M2
* 1304eb2 M1Continue till all pull requests in the chain have been exhausted. I know it is complicated but this is the best way I've figured out how to manage such a complex situation. Not sure how the tech industry arrived at this place but here we are.