branch: let --delete-merged find squash merged branches
PATCH 1/1 branch: let --delete-merged find squash merged branches
From: Harald Nordgren <[email protected]>
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by "git branch --delete-merged". The upstream holds a rewritten copy of their work, so their tips are not reachable from it and they look unmerged forever.
Treat such a branch as merged when some upstream commit since the fork point contains all of its changes, so that merging the branch into that commit would change nothing. Name that commit in the output so the user can see where the work went:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
The first upstream commit that contains the changes is used, so the branch is deleted even if upstream later reverted or reworked them. Nothing is lost, since that commit keeps them in the upstream history. A branch whose changes only partly landed is kept.
---
branch: let --delete-merged find squash merged branches
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
After the release of 2.56, I saw people liking the --delete-merged feature, but asking for this. A lot of people, me included prefer squash-merge and it currently doesn't work with --delete-merged.
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v1
Pull-Request: https://github.com/git/git/pull/2425
Documentation/git-branch.adoc | 15 +-- builtin/branch.c | 183 ++++++++++++++++++++++++++++++++-- t/t3200-branch.sh | 74 ++++++++++++++ 3 files changed, 259 insertions(+), 13 deletions(-)
Documentation/git-branch.adoc +9 -6
@@ -204,12 +204,15 @@ This option is only applicable in non-verbose mode. `--delete-merged <pattern>`:: Delete local branches whose configured upstream matches - _<pattern>_, but only when their tip is reachable from that - upstream. In other words, the work on the branch has already - landed on the upstream it tracks, so the local copy is no longer - needed. _<pattern>_ may name a ref, a remote (using the branch its - `HEAD` points at), or a shell-style glob. The option can be - repeated to widen the upstream match. + _<pattern>_, but only when their work has already landed on that + upstream, so the local copy is no longer needed. This is the case + when their tip is reachable from the upstream, or when some + upstream commit contains all of their changes, as happens after + a squash or rebase merge, even if those changes were later + reverted. The message for such a branch names the first upstream + commit that contains its changes. _<pattern>_ may name a ref, a + remote (using the branch its `HEAD` points at), or a shell-style + glob. The option can be repeated to widen the upstream match. Optional _<branch-pattern>_ arguments limit which local branches are considered, e.g. `git branch --delete-merged 'origin/*' 'topic-*'`.
builtin/branch.c +176 -7
262 lines not on this page · raw message
t/t3200-branch.sh +74 -0
80 lines not on this page · raw message
base-commit: a018953688f1b10bddf91bff8747068f5f4746a4
Signature · 1 line
gitgitgadget
Quoted 6 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by "git branch --delete-merged". The upstream holds a rewritten copy of their work, so their tips are not reachable from it and they look unmerged forever.
An example closer to git(1)’s home:
git merge --squash
git commitQuoted 1 line
[snip]
Quoted 9 lines · Kristoffer Haugsbakk
Quoted 6 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by "git branch --delete-merged". The upstream holds a rewritten copy of their work, so their tips are not reachable from it and they look unmerged forever.
Quoted 4 lines
An example closer to git(1)’s home:
git merge --squash git commit
True. But likely it opens up the question of _why_ would anyone on upstream be doing such destructive actions? Well, then the answer is of course that millions of users (including) me do that via GitHub all the time.
Maybe I should include both examples in my text.
Harald
Quoted 16 lines · Harald Nordgren
Quoted 9 lines · Kristoffer Haugsbakk
Quoted 6 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by "git branch --delete-merged". The upstream holds a rewritten copy of their work, so their tips are not reachable from it and they look unmerged forever.
Quoted 4 lines
An example closer to git(1)’s home:
git merge --squash git commit
Quoted 6 lines
True. But likely it opens up the question of _why_ would anyone on upstream be doing such destructive actions? Well, then the answer is of course that millions of users (including) me do that via GitHub all the time.
Maybe I should include both examples in my text.
My *guess* is that git merge --squash inspired the forge squashes. But the forges popularized it.
The apparent git merge --squash approach of using git log for concatenating the commit messages isn’t that nice in my opinion. So I wonder how much it is used.
Quoted 23 lines · Kristoffer Haugsbakk
Quoted 21 lines · Harald Nordgren
Quoted 9 lines · Kristoffer Haugsbakk
Quoted 6 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by "git branch --delete-merged". The upstream holds a rewritten copy of their work, so their tips are not reachable from it and they look unmerged forever.
Quoted 4 lines
An example closer to git(1)’s home:
git merge --squash git commitTrue. But likely it opens up the question of _why_ would anyone on upstream be doing such destructive actions? Well, then the answer is of course that millions of users (including) me do that via GitHub all the time.
Quoted 8 lines
Quoted 1 line
Maybe I should include both examples in my text.
My *guess* is that
git merge --squashinspired the forge squashes. But the forges popularized it.The apparent
git merge --squashapproach of usinggit logfor concatenating the commit messages isn’t that nice in my opinion. So I wonder how much it is used.
The same behavior is present in GitHub's default squash merge message, and almost no one I work with bothers to edit it.
It's really sad to lose the opportunity to have good commit messages when using squash-and-merge on a forge---not because we *cannot*, but because the defaults do not *encourage* it (and we all know how defaults affect user behavior!).
Anyway, see https://benknoble.github.io/blog/2024/08/02/github-squash/
for a distillation of my thoughts from working around folks that squash carelessly.
So, anecdotally: it is used widely due to defaults. Blech.
Signature · 1 line
D. Ben Knoble
Quoted 11 lines · D. Ben Knoble
Quoted 9 lines · Kristoffer Haugsbakk
Quoted 3 lines · Harald Nordgren
[snip]
Maybe I should include both examples in my text.
My *guess* is that
git merge --squashinspired the forge squashes. But the forges popularized it.
Quoted 6 lines
Quoted 3 lines
The apparent
git merge --squashapproach of usinggit logfor concatenating the commit messages isn’t that nice in my opinion. So I wonder how much it is used.The same behavior is present in GitHub's default squash merge message, and almost no one I work with bothers to edit it.
The asterisk bullet points on GitHub are better than git merge --squash:
Squashed commit of the following:
[just git log of the commits in the range]
Quoted 4 lines
It's really sad to lose the opportunity to have good commit messages when using squash-and-merge on a forge---not because we *cannot*, but because the defaults do not *encourage* it (and we all know how defaults affect user behavior!).
In my opinion squash merges cannot be implemented in a good way, in a way that leads to good commits. Fundamentally not. It’s the button to both squash “oops” and the incremental, valuable commits, resulting in a blob where even a manually written commit message cannot document all the changes properly. And the reasons why are laid out in your article, I think...
Quoted 6 lines
Anyway, see https://benknoble.github.io/blog/2024/08/02/github-squash/
for a distillation of my thoughts from working around folks that squash carelessly.So, anecdotally: it is used widely due to defaults. Blech.
Which is excellent. Thanks for writing it.
Without looking too much further…
Quoted 13 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by "git branch --delete-merged". The upstream holds a rewritten copy of their work, so their tips are not reachable from it and they look unmerged forever.
Treat such a branch as merged when some upstream commit since the fork point contains all of its changes, so that merging the branch into that commit would change nothing. Name that commit in the output so the user can see where the work went:
Quoted 8 lines
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
The first upstream commit that contains the changes is used, so the branch is deleted even if upstream later reverted or reworked them. Nothing is lost, since that commit keeps them in the upstream history. A branch whose changes only partly landed is kept.
Signed-off-by Harald Nordgren <[email protected]>
…in the rebase case, I would expect something like git-log's --cherry-mark option (or really the algorithm behind it, git-cherry, and git-range-diff) to be useful for identifying rebased branches. But of course even rebase-merged branches can end up with minor differences (say, a commit was made upstream before that branch was rebased with an identical change; no conflict occurs, but the new commit differs from the old by not having that change).
In the squash case, I suppose the best we can do is check that all our changes were applied at some point between the merge-base and the tip. There probably won't be any tree-same commits, though maybe a (premature?) optimization can return early if the trees match exactly.
It looked like you don't distinguish the 2 cases in the code, and I think that's reasonable: we wouldn't know a priori whether to check for a rebased series or a squashed commit, so we'd have to run both checks, and the latter presumably subsumes the former.
Anyway, I can see how this would all be fairly expensive---on one repo I work in, git-range-diff can be somewhat slow depending on how many commits are in the range, I think. I don't know if it's worth trying to state that for folks, though? If we ever make improvements to performance, we'd have to remember to remove the "this may be slow" text.
Quoted 3 lines
After the release of 2.56, I saw people liking the --delete-merged
feature, but asking for this. A lot of people, me included prefer squash-merge and it currently doesn't work with --delete-merged.
Btw, I wonder if you can share where you saw this? 2.56 was released so recently I'm (pleasantly) surprised there's already feedback on this!
Signature · 1 line
D. Ben Knoble
Quoted 7 lines
Quoted 3 lines
After the release of 2.56, I saw people liking the --delete-merged
feature, but asking for this. A lot of people, me included prefer squash-merge and it currently doesn't work with --delete-merged.Btw, I wonder if you can share where you saw this? 2.56 was released so recently I'm (pleasantly) surprised there's already feedback on this!
Reddit thread: https://www.reddit.com/r/git/comments/1wsnrl9/comment/pcncf5t
There was only one person asking to clean up squashed branches. But I also started thinking about it the other day, when 2.56 drew closer, and I realized that friends that work in companies using squash merge won't get any benefit from this.
Blog posts that is drawing attention to this new feature (not necessary feedbacking on it):
- https://github.blog/open-source/git/highlights-from-git-2-56/
- https://about.gitlab.com/blog/whats-new-in-git-2-56-0/
- https://9to5linux.com/git-2-56-adds-new-options-for-cleaning-up-branches-and-resolving-conflicts
- https://linuxiac.com/git-2-56-released-with-safer-conflict-resolution-and-performance-gains/
Btw, I love squash merge!
Harald
Quoted 6 lines
Anyway, I can see how this would all be fairly expensive---on one repo I work in, git-range-diff can be somewhat slow depending on how many commits are in the range, I think. I don't know if it's worth trying to state that for folks, though? If we ever make improvements to performance, we'd have to remember to remove the "this may be slow" text.
It was much slower in my first iterations, so running this on my local Git repo now does not feel painfully slow. Although slower than without this feature.
We might hide it behind a feature flag?
Harald
Quoted 8 lines · Harald Nordgren
Quoted 7 lines
Anyway, I can see how this would all be fairly expensive---on one repo I work in, git-range-diff can be somewhat slow depending on how many commits are in the range, I think. I don't know if it's worth trying to state that for folks, though? If we ever make improvements to performance, we'd have to remember to remove the "this may be slow" text.
Quoted 5 lines
It was much slower in my first iterations, so running this on my local Git repo now does not feel painfully slow. Although slower than without this feature.
We might hide it behind a feature flag?
I could imagine wanting a CSV-style value for the option, like "--delete-merged=squashed,rebased" vs. "--delete-merged=merged" (current default), or something. But I'd have to think about whether I'm suggesting that only to work around performance or to also support actual use cases. Mostly I think people just want to go "gah, delete merged branches" and not think about it further… hm.
Signature · 1 line
D. Ben Knoble
Quoted 8 lines
Quoted 1 line
We might hide it behind a feature flag?
I could imagine wanting a CSV-style value for the option, like "--delete-merged=squashed,rebased" vs. "--delete-merged=merged" (current default), or something. But I'd have to think about whether I'm suggesting that only to work around performance or to also support actual use cases. Mostly I think people just want to go "gah, delete merged branches" and not think about it further… hm.
For me, the only reason to hide this would be for performance.
Yes, I think most people (me included) would rather have the simplest possible command. I'd rather not have to specify the pattern so that these are to the same:
git branch --delete-merged
git branch --delete-merged '*/*'Harald
Quoted 6 lines · D. Ben Knoble
On Tue, Sep 29, 2026 at 3:33 AM Harald Nordgren via GitGitGadget <[email protected]> wrote:
…in the rebase case, I would expect something like git-log's --cherry-mark option (or really the algorithm behind it, git-cherry, and git-range-diff)
That's what I was expecting as well. It would be worth carefully studying the implementation of git-cherry. "git cherry A...B" precalculates the patch-ids from the side of the merge base that has the fewest commits and then walks the other side to compare them. While it is walking the other side I think it also looks at which paths were changed to avoid calculating the patch-id for commits that cannot match. It also batches fetches the blobs it needs in partial clones.
As far as I can see the implementation here makes a separate upstream revision walk for each branch, and recalculates the upstream diffs each time which seems less efficient than it could be.
Quoted 5 lines
to be useful for identifying rebased branches. But
of course even rebase-merged branches can end up with minor differences (say, a commit was made upstream before that branch was rebased with an identical change; no conflict occurs, but the new commit differs from the old by not having that change).
Yes if a branch has been rebased before it is merged it may be altered such that we cannot detect it.
Quoted 4 lines
In the squash case, I suppose the best we can do is check that all our changes were applied at some point between the merge-base and the tip. There probably won't be any tree-same commits, though maybe a (premature?) optimization can return early if the trees match exactly.
If we have
(topic) D - C - B - A
\
(main) M - Q - P - O -
\ /
- - S - -
where M is a squashed merge of topic I think we have
M^2^{tree} == topic^{tree}
Merge-base(M^1, M^2) == Merge-base(topic, topic@{upstream})
$(git rev-list --count --right-only M^1...M^2) == 1If you know your repository only has squash merges that were not rebased it would be a lot more efficient to just look at the trees and merge-bases, especially in a blobless clone. Having an option to turn off the patch-id based detection would probably be useful in that case.
I think detecting branches that have been squashed and/or rebased is a useful improvement, but it needs careful implementation to be efficient enough that it is practical in large repositories and I'm unlikely to have time to closely review it.
Thanks
Phillip
Quoted 20 lines
It looked like you don't distinguish the 2 cases in the code, and I think that's reasonable: we wouldn't know a priori whether to check for a rebased series or a squashed commit, so we'd have to run both checks, and the latter presumably subsumes the former.
Anyway, I can see how this would all be fairly expensive---on one repo I work in, git-range-diff can be somewhat slow depending on how many commits are in the range, I think. I don't know if it's worth trying to state that for folks, though? If we ever make improvements to performance, we'd have to remember to remove the "this may be slow" text.
Quoted 3 lines
After the release of 2.56, I saw people liking the --delete-merged
feature, but asking for this. A lot of people, me included prefer squash-merge and it currently doesn't work with --delete-merged.Btw, I wonder if you can share where you saw this? 2.56 was released so recently I'm (pleasantly) surprised there's already feedback on this!
Quoted 3 lines
As far as I can see the implementation here makes a separate upstream revision walk for each branch, and recalculates the upstream diffs each time which seems less efficient than it could be.
Yes, that can be improved!
Quoted 4 lines
Quoted 4 lines
In the squash case, I suppose the best we can do is check that all our changes were applied at some point between the merge-base and the tip. There probably won't be any tree-same commits, though maybe a (premature?) optimization can return early if the trees match exactly.
...
Quoted 4 lines
If you know your repository only has squash merges that were not rebased it would be a lot more efficient to just look at the trees and merge-bases, especially in a blobless clone. Having an option to turn off the patch-id based detection would probably be useful in that case.
Maybe yes, but for users I imagine they want the interface to be as simple as possible.
I'm iterating on the code on my side (sharing logic between branches, etc) and it became fast on my local Git repo. If there are no performance concerns then would we still want the option to turn it off?
Harald
Hi Harald
Quoted 8 lines · Harald Nordgren
...
Quoted 4 lines
If you know your repository only has squash merges that were not rebased it would be a lot more efficient to just look at the trees and merge-bases, especially in a blobless clone. Having an option to turn off the patch-id based detection would probably be useful in that case.
Maybe yes, but for users I imagine they want the interface to be as simple as possible.
Agreed, they also don't want to wait to find squashed merges they know they do not have.
Quoted 4 lines
I'm iterating on the code on my side (sharing logic between branches, etc) and it became fast on my local Git repo. If there are no performance concerns then would we still want the option to turn it off?
Even an efficient implementation is going to be a lot slower when it is trying to find branches that have been squashed, so I think we probably do want a way to turn it off. That's especially true in partial clones where we'll have to download a bunch of blobs to do the squash detection. So long as it isn't diabolically slow enabling it by default is probably fine.
Thanks
Phillip
Quoted 6 lines
Even an efficient implementation is going to be a lot slower when it is trying to find branches that have been squashed, so I think we probably do want a way to turn it off. That's especially true in partial clones where we'll have to download a bunch of blobs to do the squash detection. So long as it isn't diabolically slow enabling it by default is probably fine.
A bit slower (depends on how much!) could be worth it for improved usability. This is not a command that users will run multiple times a day.
I would hope we could have it on by default and add a flag to turn it off instead.
For partial or shallow clones, we should probably warn the user that detection won't be able to search the full history, and offer them to unshallow, etc. It's very reasonable that some parts are turned off, when the user doesn't want to hold the complete repo.
Harald
Quoted 10 lines · Harald Nordgren
Quoted 6 lines
Even an efficient implementation is going to be a lot slower when it is trying to find branches that have been squashed, so I think we probably do want a way to turn it off. That's especially true in partial clones where we'll have to download a bunch of blobs to do the squash detection. So long as it isn't diabolically slow enabling it by default is probably fine.
A bit slower (depends on how much!) could be worth it for improved usability. This is not a command that users will run multiple times a day.
I would really like a solid delete operation that I can run, say, overnight. I have better hygiene practices today but I didn’t back then, so things have piled up.
One big cleanup is worth a nightly run. So I don’t care about how slow it is.
Quoted 1 line
[snip]
Quoted 13 lines · Kristoffer Haugsbakk
Quoted 10 lines · Harald Nordgren
Quoted 6 lines
Even an efficient implementation is going to be a lot slower when it is trying to find branches that have been squashed, so I think we probably do want a way to turn it off. That's especially true in partial clones where we'll have to download a bunch of blobs to do the squash detection. So long as it isn't diabolically slow enabling it by default is probably fine.
A bit slower (depends on how much!) could be worth it for improved usability. This is not a command that users will run multiple times a day.
Quoted 6 lines
I would really like a solid delete operation that I can run, say, overnight. I have better hygiene practices today but I didn’t back then, so things have piled up.
One big cleanup is worth a nightly run. So I don’t care about how slow it is.
I would love to have a full-clean command that bundles deleted-merged and all prune commands, and uses sensible defaults for each one. (I have a hard time believing that it would need to run overnight anyway).
I have known people who just delete the whole repo on any signs of trouble. People out there are so scared of Git, so let's give them sensible defaults to work with.
Harald
Interesting to note is that merged branches inside our repo change when Junio signs off on them. So without this functionality they are not cleaned either.
Harald
Hi Harald
Quoted 3 lines · Harald Nordgren
Interesting to note is that merged branches inside our repo change when Junio signs off on them. So without this functionality they are not cleaned either.
That's true for a patched based workflow like the one on this list whether or not the maintainer signs off the commits. It is slightly different from the squash merge case because you need to look for the individual commits upstream, rather than the combined changes from the branch.
Thanks
Phillip
Hi Harald
Quoted 7 lines · Harald Nordgren
Quoted 6 lines
Even an efficient implementation is going to be a lot slower when it is trying to find branches that have been squashed, so I think we probably do want a way to turn it off. That's especially true in partial clones where we'll have to download a bunch of blobs to do the squash detection. So long as it isn't diabolically slow enabling it by default is probably fine.
Quoted 6 lines
A bit slower (depends on how much!) could be worth it for improved usability. This is not a command that users will run multiple times a day.
I would hope we could have it on by default and add a flag to turn it off instead.
That's my hope too
Quoted 4 lines
For partial or shallow clones, we should probably warn the user that detection won't be able to search the full history, and offer them to unshallow, etc. It's very reasonable that some parts are turned off, when the user doesn't want to hold the complete repo.
I've not really though much about shallow clones, but for partial clones I think it is perfectly reasonable to go and fetch the blobs we need to calculate the patch ids and warn in the documentation that it can be slow.
Thanks
Phillip
Quoted 3 lines
I've not really though much about shallow clones, but for partial clones I think it is perfectly reasonable to go and fetch the blobs we need to calculate the patch ids and warn in the documentation that it can be slow.
Would you say the blobless case should be handled now, or can be left as a follow-up topic?
Harald
Quoted 17 lines · Phillip Wood
Quoted 6 lines · D. Ben Knoble
On Tue, Sep 29, 2026 at 3:33 AM Harald Nordgren via GitGitGadget <[email protected]> wrote:
…in the rebase case, I would expect something like git-log's --cherry-mark option (or really the algorithm behind it, git-cherry, and git-range-diff)
That's what I was expecting as well. It would be worth carefully studying the implementation of git-cherry. "git cherry A...B" precalculates the patch-ids from the side of the merge base that has the fewest commits and then walks the other side to compare them. While it is walking the other side I think it also looks at which paths were changed to avoid calculating the patch-id for commits that cannot match. It also batches fetches the blobs it needs in partial clones.
Quoted 3 lines
As far as I can see the implementation here makes a separate upstream revision walk for each branch, and recalculates the upstream diffs each time which seems less efficient than it could be.
Thanks for spelling that out!
Quoted 8 lines
Quoted 5 lines
to be useful for identifying rebased branches. But
of course even rebase-merged branches can end up with minor differences (say, a commit was made upstream before that branch was rebased with an identical change; no conflict occurs, but the new commit differs from the old by not having that change).Yes if a branch has been rebased before it is merged it may be altered such that we cannot detect it.
Yep. I'm not sure what Harald (or we) would want to do here. git-range-diff has trouble detecting these scenarios today, so maybe matching that and later finding a way to improve is ok.
Quoted 13 lines
Quoted 4 lines
In the squash case, I suppose the best we can do is check that all our changes were applied at some point between the merge-base and the tip. There probably won't be any tree-same commits, though maybe a (premature?) optimization can return early if the trees match exactly.
If we have
(topic) D - C - B - A
\(main) M - Q - P - O -
\ /
- - S - -
Quoted 5 lines
where M is a squashed merge of topic I think we have
M^2^{tree} == topic^{tree} Merge-base(M^1, M^2) == Merge-base(topic, topic@{upstream}) $(git rev-list --count --right-only M^1...M^2) == 1
Perhaps we are thinking of 2 different things? In practice when I see a merge created using GitHub's squash and merge option (and, I think, also when using merge --squash), there is no second parent. You could instead just get
(main) S - Q - P - O
where S is A+B+C+D applied to Q (i.e., closer to a cherry-pick with --no-commit).
And we know that S is not necessarily tree-same to topic's D, so…?
Signature · 1 line
D. Ben Knoble
Hi Ben
Quoted 21 lines · D. Ben Knoble
Quoted 9 lines · Phillip Wood
If we have
(topic) D - C - B - A
\(main) M - Q - P - O -
\ /
- - S - -Quoted 5 lines
where M is a squashed merge of topic I think we have
M^2^{tree} == topic^{tree} Merge-base(M^1, M^2) == Merge-base(topic, topic@{upstream}) $(git rev-list --count --right-only M^1...M^2) == 1Perhaps we are thinking of 2 different things? In practice when I see a merge created using GitHub's squash and merge option (and, I think, also when using
merge --squash), there is no second parent. You could instead just get
Quoted 4 lines
(main) S - Q - P - O
where S is A+B+C+D applied to Q (i.e., closer to a cherry-pick with --no-commit).
Oh, you're right, I'd misremembered how "merge --squash" worked.
Quoted 1 line
And we know that S is not necessarily tree-same to topic's D, so…?
I agree we need to use patch ids rather than trees in that case.
Thanks
Phillip
v2 cover branch: let --delete-merged find squash merged branches
In reply to Harald Nordgren via GitGitGadgetBranches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
Changes in v2:
* Diff each upstream commit once and share the result between all branches, faster than v1.
* Fix a branch merged with a merge commit being reported as "landed as" the merge when another branch was searched first, and add a test for it. Show the topic commit itself in "landed as" for work that arrived through a merge.
* Use git merge --squash as the example in the commit message and mention GitHub's squash and rebase buttons.
* Add --no-squashed to skip the squash search.
Harald Nordgren (2):
branch: let --delete-merged find squash merged branches
branch: add --no-squashed to skip the squash search
Documentation/git-branch.adoc | 23 ++- builtin/branch.c | 342 ++++++++++++++++++++++++++++++++-- t/t3200-branch.sh | 170 +++++++++++++++++ 3 files changed, 509 insertions(+), 26 deletions(-)
base-commit: 6de20f6092dcf9bdb1c8efe03db4b70c82b423dd
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v2
Pull-Request: https://github.com/git/git/pull/2425
Range-diff vs v1:
1: c9ab56d8b2 ! 1: 94a6d23f8b branch: let --delete-merged find squash merged branches
@@ Metadata
## Commit message ##
branch: let --delete-merged find squash merged branches
- Branches merged on GitHub with "Squash and merge" or "Rebase and
- merge" are never deleted by "git branch --delete-merged". The upstream
- holds a rewritten copy of their work, so their tips are not reachable
- from it and they look unmerged forever.
+ A branch that upstream integrated with
+
+ git merge --squash topic
+ git commit
+
+ is never deleted by "git branch --delete-merged". The same goes for a
+ branch that was rebased or cherry-picked onto upstream. The upstream
+ holds a rewritten copy of its work, so its tip is not reachable from
+ it and it looks unmerged forever.
+
+ This happens all the time on hosting services. The "Squash and merge"
+ and "Rebase and merge" buttons on GitHub land pull requests this way,
+ so contributors to such projects collect local branches that
+ "--delete-merged" never cleans up.
Treat such a branch as merged when some upstream commit since the fork
point contains all of its changes, so that merging the branch into
@@ builtin/branch.c
#include "help.h"
#include "advice.h"
#include "commit-reach.h"
++#include "commit-slab.h"
+#include "diff.h"
+#include "diffcore.h"
+#include "hex.h"
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
return ret;
}
++enum change_state {
++ CHANGE_MISSING,
++ CHANGE_LANDED,
++ CHANGE_NEEDS_MERGE,
++};
++
+struct branch_change {
+ char *path;
-+ struct object_id base_oid, branch_oid;
++ struct object_id base_oid, branch_oid, upstream_oid;
+ unsigned short branch_mode;
++ enum change_state state;
++};
++
++struct landing_search {
++ const char *branch_name;
++ struct commit *rev;
++ struct commit *upstream;
++ struct branch_change *changes;
++ size_t changes_nr, changes_alloc;
+};
+
-+static void collect_branch_changes(struct commit *base, struct commit *rev,
-+ struct branch_change **changes,
-+ size_t *nr, size_t *alloc)
++define_commit_slab(changed_paths, struct string_list *);
++
++static void collect_branch_changes(struct landing_search *search,
++ struct commit *base)
+{
+ struct diff_options opt;
+
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ opt.flags.recursive = 1;
+ opt.output_format = DIFF_FORMAT_NO_OUTPUT;
+ diff_setup_done(&opt);
-+ diff_tree_oid(get_commit_tree_oid(base), get_commit_tree_oid(rev),
-+ "", &opt);
++ diff_tree_oid(get_commit_tree_oid(base),
++ get_commit_tree_oid(search->rev), "", &opt);
+ for (int i = 0; i < diff_queued_diff.nr; i++) {
+ struct diff_filepair *p = diff_queued_diff.queue[i];
+ struct branch_change *change;
+
-+ ALLOC_GROW(*changes, *nr + 1, *alloc);
-+ change = &(*changes)[(*nr)++];
++ ALLOC_GROW(search->changes, search->changes_nr + 1,
++ search->changes_alloc);
++ change = &search->changes[search->changes_nr++];
+ change->path = xstrdup(p->two->path);
+ oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
+ &p->one->oid : null_oid(the_hash_algo));
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ diff_flush(&opt);
+}
+
-+static int merge_keeps_upstream(const struct branch_change *change,
-+ const struct object_id *upstream_oid)
++static int merge_keeps_upstream(const struct branch_change *change)
+{
+ mmfile_t base, upstream, branch;
+ mmbuffer_t result = { 0 };
+ int ret;
+
+ read_mmblob(&base, the_repository->objects, &change->base_oid);
-+ read_mmblob(&upstream, the_repository->objects, upstream_oid);
++ read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
+ read_mmblob(&branch, the_repository->objects, &change->branch_oid);
+ ret = ll_merge(&result, change->path, &base, "base",
+ &upstream, "upstream", &branch, "branch",
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ return ret;
+}
+
-+static int change_landed(const struct branch_change *change,
-+ struct commit *commit)
++static enum change_state change_state_at(struct branch_change *change,
++ struct commit *commit)
+{
-+ struct object_id oid;
+ unsigned short mode;
+
+ if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
-+ change->path, &oid, &mode))
-+ return is_null_oid(&change->branch_oid);
-+ if (oideq(&oid, &change->branch_oid))
-+ return mode == change->branch_mode;
++ change->path, &change->upstream_oid, &mode))
++ return is_null_oid(&change->branch_oid) ?
++ CHANGE_LANDED : CHANGE_MISSING;
++ if (oideq(&change->upstream_oid, &change->branch_oid))
++ return mode == change->branch_mode ?
++ CHANGE_LANDED : CHANGE_MISSING;
+ if (is_null_oid(&change->base_oid) ||
+ is_null_oid(&change->branch_oid) ||
-+ oideq(&oid, &change->base_oid) ||
++ oideq(&change->upstream_oid, &change->base_oid) ||
+ mode != change->branch_mode || !S_ISREG(mode))
-+ return 0;
-+ return merge_keeps_upstream(change, &oid);
++ return CHANGE_MISSING;
++ return CHANGE_NEEDS_MERGE;
+}
+
-+static struct commit *find_landed_commit(struct commit *rev,
-+ struct commit *upstream)
++static int changes_landed(struct landing_search *search,
++ struct commit *commit)
++{
++ for (size_t i = 0; i < search->changes_nr; i++) {
++ struct branch_change *change = &search->changes[i];
++
++ change->state = change_state_at(change, commit);
++ if (change->state == CHANGE_MISSING)
++ return 0;
++ }
++ for (size_t i = 0; i < search->changes_nr; i++) {
++ struct branch_change *change = &search->changes[i];
++
++ if (change->state == CHANGE_NEEDS_MERGE &&
++ !merge_keeps_upstream(change))
++ return 0;
++ }
++ return 1;
++}
++
++static struct string_list *changed_paths_of(struct changed_paths *cache,
++ struct diff_options *opt,
++ struct commit *commit)
++{
++ struct string_list **paths = changed_paths_at(cache, commit);
++ struct commit *parent;
++
++ if (*paths)
++ return *paths;
++
++ CALLOC_ARRAY(*paths, 1);
++ string_list_init_dup(*paths);
++ parent = commit->parents ? commit->parents->item : NULL;
++ if (parent && repo_parse_commit(the_repository, parent))
++ die(_("could not parse commit %s"),
++ oid_to_hex(&parent->object.oid));
++ diff_tree_oid(parent ? get_commit_tree_oid(parent) : NULL,
++ get_commit_tree_oid(commit), "", opt);
++ for (int i = 0; i < diff_queued_diff.nr; i++) {
++ struct diff_filepair *p = diff_queued_diff.queue[i];
++
++ string_list_append(*paths, p->two->path);
++ }
++ diff_flush(opt);
++ string_list_sort(*paths);
++ return *paths;
++}
++
++static void free_changed_paths(struct string_list **paths)
++{
++ if (!*paths)
++ return;
++ string_list_clear(*paths, 0);
++ free(*paths);
++}
++
++static int touches_changes(struct string_list *paths,
++ const struct landing_search *search)
++{
++ for (size_t i = 0; i < search->changes_nr; i++)
++ if (string_list_has_string(paths, search->changes[i].path))
++ return 1;
++ return 0;
++}
++
++static struct commit *find_landed_commit(struct landing_search *search,
++ struct changed_paths *cache,
++ struct diff_options *opt)
+{
-+ struct commit_list *merge_bases = NULL;
-+ struct branch_change *changes = NULL;
-+ size_t changes_nr = 0, changes_alloc = 0;
+ struct commit *commit, *landed = NULL;
+ struct strvec args = STRVEC_INIT;
+ struct rev_info revs;
+
-+ if (repo_get_merge_bases(the_repository, upstream, rev,
-+ &merge_bases) < 0)
-+ exit(128);
-+ if (!merge_bases)
-+ return NULL;
-+ collect_branch_changes(merge_bases->item, rev, &changes,
-+ &changes_nr, &changes_alloc);
-+ commit_list_free(merge_bases);
-+ if (!changes_nr)
-+ return NULL;
-+
+ strvec_pushl(&args, "rev-list", "--reverse",
-+ oid_to_hex(&upstream->object.oid), NULL);
-+ strvec_pushf(&args, "^%s", oid_to_hex(&rev->object.oid));
-+ strvec_push(&args, "--");
-+ for (size_t i = 0; i < changes_nr; i++)
-+ strvec_pushf(&args, ":(literal)%s", changes[i].path);
++ oid_to_hex(&search->upstream->object.oid), NULL);
++ strvec_pushf(&args, "^%s", oid_to_hex(&search->rev->object.oid));
+
+ repo_init_revisions(the_repository, &revs, NULL);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (prepare_revision_walk(&revs))
+ die(_("revision walk setup failed"));
-+ while (!landed && (commit = get_revision(&revs))) {
-+ size_t i;
-+
-+ for (i = 0; i < changes_nr; i++)
-+ if (!change_landed(&changes[i], commit))
-+ break;
-+ if (i == changes_nr)
++ while (!landed && (commit = get_revision(&revs)))
++ if (touches_changes(changed_paths_of(cache, opt, commit),
++ search) &&
++ changes_landed(search, commit))
+ landed = commit;
-+ }
+ release_revisions(&revs);
-+ clear_commit_marks(upstream, ALL_REV_FLAGS);
-+ clear_commit_marks(rev, ALL_REV_FLAGS);
++ clear_commit_marks(search->upstream, ALL_REV_FLAGS);
++ clear_commit_marks(search->rev, ALL_REV_FLAGS);
+ strvec_clear(&args);
-+
-+ for (size_t i = 0; i < changes_nr; i++)
-+ free(changes[i].path);
-+ free(changes);
+ return landed;
+}
++
++static void find_landed_commits(struct landing_search *searches,
++ size_t searches_nr,
++ struct strmap *landed_commits)
++{
++ struct changed_paths cache;
++ struct diff_options opt;
++
++ for (size_t i = 0; i < searches_nr; i++) {
++ struct landing_search *search = &searches[i];
++ struct commit_list *merge_bases = NULL;
++
++ if (repo_get_merge_bases(the_repository, search->upstream,
++ search->rev, &merge_bases) < 0)
++ exit(128);
++ if (merge_bases)
++ collect_branch_changes(search, merge_bases->item);
++ commit_list_free(merge_bases);
++ }
++
++ repo_diff_setup(the_repository, &opt);
++ opt.flags.recursive = 1;
++ opt.output_format = DIFF_FORMAT_NO_OUTPUT;
++ opt.no_free = 1;
++ diff_setup_done(&opt);
++ init_changed_paths(&cache);
++
++ for (size_t i = 0; i < searches_nr; i++) {
++ struct commit *landed;
++
++ if (!searches[i].changes_nr)
++ continue;
++ landed = find_landed_commit(&searches[i], &cache, &opt);
++ if (landed)
++ strmap_put(landed_commits, searches[i].branch_name,
++ landed);
++ }
++
++ deep_clear_changed_paths(&cache, free_changed_paths);
++ opt.no_free = 0;
++ diff_free(&opt);
++}
++
++static int branch_opted_out(struct strbuf *key, const char *branch_name,
++ unsigned int flags)
++{
++ int opt_out;
++
++ strbuf_reset(key);
++ strbuf_addf(key, "branch.%s.deletemerged", branch_name);
++ if (repo_config_get_bool(the_repository, key->buf, &opt_out) ||
++ opt_out)
++ return 0;
++ if (!(flags & DELETE_BRANCH_QUIET))
++ fprintf(stderr,
++ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
++ branch_name, branch_name);
++ return 1;
++}
+
static int delete_merged_branches(const struct strvec *upstreams,
const char **argv, unsigned int flags)
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
struct strset deletable_branch_names = STRSET_INIT;
struct strset protected_branch_names = STRSET_INIT;
+ struct strmap landed_commits = STRMAP_INIT;
++ struct landing_search *searches = NULL;
++ size_t searches_nr = 0, searches_alloc = 0;
struct strvec branches_to_delete = STRVEC_INIT;
struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
const char *branch_name;
struct branch *branch;
const char *upstream_refname;
-+ struct commit *landed = NULL;
- int opt_out;
+- int opt_out;
if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
+ BUG("filter returned non-branch ref '%s'", branch_refname);
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
continue;
if (check_branch_commit(branch_name, branch_name,
&candidates.items[i]->objectname, NULL,
- FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
- continue;
+-
+- strbuf_reset(&key);
+- strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
+- if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+- !opt_out) {
+- if (!(flags & DELETE_BRANCH_QUIET))
+- fprintf(stderr,
+- _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+- branch_name, branch_name);
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED)) {
+ struct commit *rev = lookup_commit_reference(
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
+ struct commit *upstream = lookup_commit_reference_by_name(
+ upstream_refname);
+
-+ if (!rev || !upstream ||
-+ !(landed = find_landed_commit(rev, upstream)))
++ if (!rev || !upstream)
+ continue;
-+ }
-
- strbuf_reset(&key);
- strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
-@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
++ ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
++ searches[searches_nr++] = (struct landing_search) {
++ .branch_name = branch_name,
++ .rev = rev,
++ .upstream = upstream,
++ };
+ continue;
}
- strset_add(&deletable_branch_names, branch_name);
-+ if (landed)
-+ strmap_put(&landed_commits, branch_name, landed);
+- strset_add(&deletable_branch_names, branch_name);
++ if (!branch_opted_out(&key, branch_name, flags))
++ strset_add(&deletable_branch_names, branch_name);
++ }
++
++ find_landed_commits(searches, searches_nr, &landed_commits);
++ for (size_t i = 0; i < searches_nr; i++) {
++ const char *branch_name = searches[i].branch_name;
++
++ if (strmap_contains(&landed_commits, branch_name) &&
++ !branch_opted_out(&key, branch_name, flags))
++ strset_add(&deletable_branch_names, branch_name);
}
protect_stacked_branch_bases(refs, &deletable_branch_names,
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
strbuf_release(&key);
strvec_clear(&branches_to_delete);
++ for (size_t i = 0; i < searches_nr; i++) {
++ for (size_t j = 0; j < searches[i].changes_nr; j++)
++ free(searches[i].changes[j].path);
++ free(searches[i].changes);
++ }
++ free(searches);
+ strmap_clear(&landed_commits, 0);
strset_clear(&protected_branch_names);
strset_clear(&deletable_branch_names);
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+test_expect_success '--delete-merged deletes a squash merged branch' '
+ setup_repo_for_delete_merged &&
+ push_topic squashed squashed-one squashed-two &&
-+ push_topic partial partial-landed partial-pending &&
++ push_topic partial lands-upstream stays-local &&
+ squash_merge_upstream partial~1 &&
+ squash_merge_upstream squashed &&
+ squash=$(git -C upstream rev-parse --short next) &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ EOF
+ )
+'
++
++test_expect_success '--delete-merged deletes a squash merged branch after upstream changed the same file' '
++ (
++ cd upstream &&
++ git checkout next &&
++ test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
++ git add shared &&
++ git commit -m "add shared" &&
++ git checkout main
++ ) &&
++ setup_repo_for_delete_merged &&
++ (
++ cd repo &&
++ git checkout -b edits-shared --track origin/next &&
++ test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
++ git commit -a -m "edit line 2" &&
++ git push origin edits-shared &&
++ git checkout --detach
++ ) &&
++ (
++ cd upstream &&
++ git checkout next &&
++ test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
++ git commit -a -m "edit line 5" &&
++ git checkout main
++ ) &&
++ squash_merge_upstream edits-shared &&
++ squash=$(git -C upstream rev-parse --short next) &&
++ (
++ cd repo &&
++ git fetch origin &&
++ sha=$(git rev-parse --short edits-shared) &&
++
++ git branch --delete-merged origin/next >actual 2>&1 &&
++ echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
++ test_cmp expect actual &&
++
++ check_branches <<-\EOF
++ main
++ EOF
++ )
++'
++
++test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
++ setup_repo_for_delete_merged &&
++ push_topic a-unmerged unmerged-work &&
++ push_topic b-merged merged-work &&
++ (
++ cd upstream &&
++ git checkout next &&
++ test_commit upstream-work &&
++ git merge --no-ff b-merged &&
++ git checkout main
++ ) &&
++ (
++ cd repo &&
++ git fetch origin &&
++ sha=$(git rev-parse --short b-merged) &&
++
++ git branch --delete-merged origin/next >actual 2>&1 &&
++ echo "Deleted branch b-merged (was $sha)." >expect &&
++ test_cmp expect actual &&
++
++ check_branches <<-\EOF
++ a-unmerged
++ main
++ EOF
++ )
++'
+
test_expect_success '--delete-merged keeps main despite a different default push remote' '
setup_repo_for_delete_merged && -: ---------- > 2: ec88bde252 branch: add --no-squashed to skip the squash search
Signature · 1 line
gitgitgadget
PATCH v2 1/2 branch: let --delete-merged find squash merged branches
From: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git merge --squash topic
git commitis never deleted by "git branch --delete-merged". The same goes for a branch that was rebased or cherry-picked onto upstream. The upstream holds a rewritten copy of its work, so its tip is not reachable from it and it looks unmerged forever.
This happens all the time on hosting services. The "Squash and merge" and "Rebase and merge" buttons on GitHub land pull requests this way, so contributors to such projects collect local branches that "--delete-merged" never cleans up.
Treat such a branch as merged when some upstream commit since the fork point contains all of its changes, so that merging the branch into that commit would change nothing. Name that commit in the output so the user can see where the work went:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
The first upstream commit that contains the changes is used, so the branch is deleted even if upstream later reverted or reworked them. Nothing is lost, since that commit keeps them in the upstream history. A branch whose changes only partly landed is kept.
--- Documentation/git-branch.adoc | 15 +- builtin/branch.c | 332 ++++++++++++++++++++++++++++++++-- t/t3200-branch.sh | 143 +++++++++++++++ 3 files changed, 466 insertions(+), 24 deletions(-)
Documentation/git-branch.adoc +9 -6
@@ -204,12 +204,15 @@ This option is only applicable in non-verbose mode. `--delete-merged <pattern>`:: Delete local branches whose configured upstream matches - _<pattern>_, but only when their tip is reachable from that - upstream. In other words, the work on the branch has already - landed on the upstream it tracks, so the local copy is no longer - needed. _<pattern>_ may name a ref, a remote (using the branch its - `HEAD` points at), or a shell-style glob. The option can be - repeated to widen the upstream match. + _<pattern>_, but only when their work has already landed on that + upstream, so the local copy is no longer needed. This is the case + when their tip is reachable from the upstream, or when some + upstream commit contains all of their changes, as happens after + a squash or rebase merge, even if those changes were later + reverted. The message for such a branch names the first upstream + commit that contains its changes. _<pattern>_ may name a ref, a + remote (using the branch its `HEAD` points at), or a shell-style + glob. The option can be repeated to widen the upstream match. Optional _<branch-pattern>_ arguments limit which local branches are considered, e.g. `git branch --delete-merged 'origin/*' 'topic-*'`.
builtin/branch.c +314 -18
408 lines not on this page · raw message
t/t3200-branch.sh +143 -0
149 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v2 2/2 branch: add --no-squashed to skip the squash search
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Finding branches that were squash or rebase merged makes "git branch --delete-merged" slower, and in a partial clone it may have to download blobs. Users who do not need it have no way to skip that search.
Add --no-squashed, which only deletes branches whose tip is reachable from their upstream.
--- Documentation/git-branch.adoc | 8 +++++++- builtin/branch.c | 12 ++++++++++-- t/t3200-branch.sh | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-)
Documentation/git-branch.adoc +7 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...] +git branch [--dry-run] [--no-squashed] (--delete-merged <pattern>)... [<branch-pattern>...] DESCRIPTION -----------
@@ -244,6 +244,12 @@ remove it anyway. sanity-checking a wide pattern like `'origin/*'` before committing to the deletion. +`--no-squashed`:: + With `--delete-merged`, only delete branches whose tip is + reachable from their upstream. This skips the search for + branches that were squash or rebase merged, which takes longer + and may have to download blobs in a partial clone. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +10 -2
@@ -209,6 +209,7 @@ enum delete_branch_flags {DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ),DELETE_BRANCH_NO_HEAD_FALLBACK = (1 <<3 ),DELETE_BRANCH_DRY_RUN = (1 <<4 ), + DELETE_BRANCH_NO_SQUASHED = (1 << 5), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -1149,7 +1150,8 @@ static int delete_merged_branches(const struct strvec *upstreams,struct commit *upstream =lookup_commit_reference_by_name ( upstream_refname); -if (!rev || !upstream) +if ((flags &DELETE_BRANCH_NO_SQUASHED ) || + !rev || !upstream)continue ;ALLOC_GROW (searches, searches_nr +1 , searches_alloc); searches[searches_nr++] = (struct landing_search ) {
@@ -1272,6 +1274,7 @@ int cmd_branch(int argc, unset_upstream =0 , show_current =0 , edit_description =0 ;struct strvec delete_merged =STRVEC_INIT ;int dry_run =0 ; +int no_squashed =0 ;const char *new_upstream =NULL ;int noncreate_actions =0 ;/* possible options */
@@ -1330,6 +1333,8 @@ int cmd_branch(int argc, PARSE_OPT_NONEG, parse_opt_strvec),OPT_BOOL (0 ,"dry-run" , &dry_run,N_ ("with --delete-merged, only print which branches would be deleted" )), +OPT_BOOL (0 ,"no-squashed" , &no_squashed, +N_ ("with --delete-merged, skip the search for squash merged branches" )),OPT__FORCE (&force,N_ ("force creation, move/rename, deletion" ),PARSE_OPT_NOCOMPLETE ),OPT_MERGED (&filter,N_ ("print only branches that are merged" )), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1394,6 +1399,8 @@ int cmd_branch(int argc,if (dry_run && !delete_merged.nr)die (_ ("--dry-run requires --delete-merged" )); +if (no_squashed && !delete_merged.nr) +die (_ ("--no-squashed requires --delete-merged" ));if (recurse_submodules_explicit) {if (!submodule_propagate_branches)
@@ -1436,7 +1443,8 @@ int cmd_branch(int argc, }else if (delete_merged.nr) { ret =delete_merged_branches (&delete_merged, argv, (quiet ?DELETE_BRANCH_QUIET :0 ) | - (dry_run ?DELETE_BRANCH_DRY_RUN :0 )); + (dry_run ?DELETE_BRANCH_DRY_RUN :0 ) | + (no_squashed ?DELETE_BRANCH_NO_SQUASHED :0 )); goto out; }else if (show_current) {print_current_branch_name ();
t/t3200-branch.sh +27 -0
@@ -2095,6 +2095,28 @@ test_expect_success '--delete-merged deletes a squash merged branch after upstre ) ' +test_expect_success '--delete-merged --no-squashed deletes only reachable branches' ' + setup_repo_for_delete_merged && + create_merged_branch merged && + push_topic squash-merged squash-merged-work && + squash_merge_upstream squash-merged && + ( + cd repo && + git fetch origin && + sha=$(git rev-parse --short merged) && + + git branch --delete-merged origin/next --no-squashed \ + >actual 2>&1 && + echo "Deleted branch merged (was $sha)." >expect && + test_cmp expect actual && + + check_branches <<-\EOF + main + squash-merged + EOF + ) +' + test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' ' setup_repo_for_delete_merged && push_topic a-unmerged unmerged-work &&
@@ -2316,6 +2338,11 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' ) ' +test_expect_success '--no-squashed without --delete-merged is rejected' ' + test_must_fail git -C forked branch --no-squashed 2>err && + test_grep "requires --delete-merged" err +' + test_expect_success '--dry-run without --delete-merged is rejected' ' test_must_fail git -C forked branch --dry-run 2>err && test_grep "requires --delete-merged" err
Signature · 1 line
gitgitgadget
Quoted 7 lines · Harald Nordgren via GitGitGadget
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
Interesting.
After rebasing, you probably can (re)use the same algorithm as used in range-diff to match the commits and their rewritten counterparts, but I wonder how "squash" would go. Presumably you can locally make a trial squash merge and probe for a similar commit on the upstream commit.
On the other hand, unlike deliberate rebasing done during a patch iteration, rebase done on the upstream to integrate your changes would not rewrite the log messages, so matching the commits based on the log message may work better than a range-diff between iteration vN and v(N+1) in the submitted patches.
Quoted 10 lines
* Diff each upstream commit once and share the result between all branches, faster than v1.
* Fix a branch merged with a merge commit being reported as "landed as" the merge when another branch was searched first, and add a test for it. Show the topic commit itself in "landed as" for work that arrived through a merge.
* Use git merge --squash as the example in the commit message and mention GitHub's squash and rebase buttons.
* Add --no-squashed to skip the squash search.
Quoted 3 lines
Harald Nordgren (2):
branch: let --delete-merged find squash merged branches
branch: add --no-squashed to skip the squash search
This a bit unexpected two-patch organization; I would have expected that the cases where our side and upstream side should correspond one-to-one (i.e., what you call rebase-and-merge) and other cases (i.e., squash merge) to require very different matching algorithm, which would lead to two-patch organization where one adds support for rebase and the other adds support for squash.
Quoted 18 lines · Junio C Hamano
Quoted 7 lines · Harald Nordgren via GitGitGadget
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
Interesting.
After rebasing, you probably can (re)use the same algorithm as used in range-diff to match the commits and their rewritten counterparts, but I wonder how "squash" would go. Presumably you can locally make a trial squash merge and probe for a similar commit on the upstream commit.
Quoted 5 lines
On the other hand, unlike deliberate rebasing done during a patch iteration, rebase done on the upstream to integrate your changes would not rewrite the log messages, so matching the commits based on the log message may work better than a range-diff between iteration vN and v(N+1) in the submitted patches.
I think it would be helpful if the cover letter explained in more detail what this series actually does to detect squashed merges and compared that to other approaches such as using patch ids or test merges. As far as I can see the approach here is, to a first approximation, checking
$(git merge-tree B C) == C^{tree}to see if C is a squashed merge of B, only without using merge-ort but by calling merge_ll() on each path instead. That means we're not checking if C is the same as merging B into C^, C may contain additional changes as well. I think it also means we're missing out on rename detection. It would be good to get a clear idea of what the tradeoffs of the various approaches are before any more code is written.
Thanks
Phillip
Quoted 20 lines
Quoted 10 lines
* Diff each upstream commit once and share the result between all branches, faster than v1.
* Fix a branch merged with a merge commit being reported as "landed as" the merge when another branch was searched first, and add a test for it. Show the topic commit itself in "landed as" for work that arrived through a merge.
* Use git merge --squash as the example in the commit message and mention GitHub's squash and rebase buttons.
* Add --no-squashed to skip the squash search.Quoted 3 lines
Harald Nordgren (2):
branch: let --delete-merged find squash merged branches
branch: add --no-squashed to skip the squash searchThis a bit unexpected two-patch organization; I would have expected that the cases where our side and upstream side should correspond one-to-one (i.e., what you call rebase-and-merge) and other cases (i.e., squash merge) to require very different matching algorithm, which would lead to two-patch organization where one adds support for rebase and the other adds support for squash.
Quoted 3 lines
I think it would be helpful if the cover letter explained in more detail what this series actually does to detect squashed merges and compared that to other approaches such as using patch ids or test merges.
Ok.
Quoted 10 lines
As far
as I can see the approach here is, to a first approximation, checking$(git merge-tree B C) == C^{tree}to see if C is a squashed merge of B, only without using merge-ort but by calling
merge_ll()on each path instead. That means we're not checking if C is the same as merging B into C^, C may contain additional changes as well. I think it also means we're missing out on rename detection.
The check is "does C already contain everything B changed", not "is C exactly the squash of B". For deleting the branch it could be enough since nothing is lost.
But it means "landed as" can point to a commit with other changes in it as well, maybe that's not good.
I think following renames is a bad basis for assuming merged. Sometimes the goal of a topic is a rename, and then it should not be dropped.
Harald
Quoted 2 lines · Phillip Wood
detection. It would be good to get a clear idea of what the tradeoffs of the various approaches are before any more code is written.
It sounds like a very sensible approach.
At least the one-to-one part of the operation should learn from what Dscho did in range-diff, especially the part that does take similarity of the log messages and authorship information, instead of paying attention only to patch IDs. I am not convinced that one-to-one rebase implemented as a subset of squash merge would perform sufficiently well, either. From the user interface angle, it also is not clear to me if a single --delete-merged that enables all variants by default with individual knob to optionally disable one variant (squash) would be a good extensible one, when we may need to deal with the third (and other) kinds of ways to integrate your work without preserving the commit identity, like selective cherry-picks.
Quoted 2 lines
At least the one-to-one part of the operation should learn from what Dscho did in range-diff
Oh, that's great! I'll take a look.
Quoted 3 lines
especially the part that does take
similarity of the log messages and authorship information, instead of paying attention only to patch IDs.
Right, authorship would at least be a good filter to speed things up, can't be the same commit if it has two different authors!
Harald
Quoted 11 lines · Harald Nordgren
Quoted 2 lines
At least the one-to-one part of the operation should learn from what Dscho did in range-diff
Oh, that's great! I'll take a look.
Quoted 3 lines
especially the part that does take
similarity of the log messages and authorship information, instead of paying attention only to patch IDs.Right, authorship would at least be a good filter to speed things up, can't be the same commit if it has two different authors!
After thinking about it a bit more, I no longer am sure.
It eventually boils down to what we are trying to achieve with this option, but "I have these pending works I asked upstream to take a look, I want to see which ones landed and remove them from my local copy of the project" and "I have these pending works, but upstream has been collecting improvements from many other people in addition to me. I want to see which ones are still relevant" are overlapping but different. And I think when you start talking about rebasing and squash merging, you are asking "is my copy still relevant?"
And the latter is strict superset of the former.
You might have instructed your agent to write a two patch series and asked upstream to pull. Somebody else used the same AI-gen and created one patch that solves the same problem, which happened to have landed first. Or that somebody may have sent their solution in two patches similar to yours. In either case, your two patch series is now irrelevant. You would want to drop your branch that is no longer relevant, regardless of whose work made it irrelevant,
They may be textually similar enough that we can tell mechanically that there are already the same or similar code in the place you were trying to touch. "--delete-irrelevant" would remove your local copy. There may be salvageable bits (e.g., yours may have updates to documentation and tests that theirs lacks), so if we can detect a case where only some parts of your work has become irrelevant, that would be very useful, too.
Stepping back a bit, perhaps "--delete-merged" should not be modified to touch anything that was rebased, cherry-picked, or squash merged. It is good to have one stable and strict "if my commits were merged and safely saved there, delete them from here", regardless of how much looser definition of "the same patch" is used for "--delete-irrelevant".
Quoted 8 lines
You might have instructed your agent to write a two patch series and asked upstream to pull. Somebody else used the same AI-gen and created one patch that solves the same problem, which happened to have landed first. Or that somebody may have sent their solution in two patches similar to yours. In either case, your two patch series is now irrelevant. You would want to drop your branch that is no longer relevant, regardless of whose work made it irrelevant,
Quoted 7 lines
They may be textually similar enough that we can tell mechanically that there are already the same or similar code in the place you were trying to touch. "--delete-irrelevant" would remove your local copy. There may be salvageable bits (e.g., yours may have updates to documentation and tests that theirs lacks), so if we can detect a case where only some parts of your work has become irrelevant, that would be very useful, too.
I would be very surprised if my branch gets deleted because someone else wrote *almost* the same code in parallel.
I think we can achieve a lot here just by focusing on the use case of my own work being slightly mangled (squashed or merged).
Quoted 6 lines
Stepping back a bit, perhaps "--delete-merged" should not be modified to touch anything that was rebased, cherry-picked, or squash merged. It is good to have one stable and strict "if my commits were merged and safely saved there, delete them from here", regardless of how much looser definition of "the same patch" is used for "--delete-irrelevant".
If my branch gets picked up by the mainline, my work there is done. I see no reason to keep the branch around anymore. How it gets picked up (squash, merged, or preserved commit hash) is not relevant then anymore.
Besides, I can easily find the work again if I want, since by definition it's now in the mainline :)
Harald
Jasper Mayone
Tested-byQuoted 6 lines · Junio C Hamano
Stepping back a bit, perhaps "--delete-merged" should not be modified to touch anything that was rebased, cherry-picked, or squash merged. It is good to have one stable and strict "if my commits were merged and safely saved there, delete them from here", regardless of how much looser definition of "the same patch" is used for "--delete-irrelevant".
I am in the group of users this series is for. Most repositories I work in land pull requests with GitHub's "Squash and merge". I built v2 (ec88bde252) and tested it on generated repositories. The results support keeping the strict meaning as the default.
v2 did what it says for squash merges, GitHub-style squashes with a new message and committer, rebase merges, renames, and squashes that were later reverted. "landed as" named the correct commit every time. It kept a branch whose work only partly landed.
But it also deleted a branch that was never merged. Another author had made the same change upstream in an unrelated commit, and v2 reported the branch as "landed as" that commit. That is the "irrelevant" case from your message, and today v2 handles it the same way as a squash merge of my own work.
So I agree that the strict meaning should stay the default. I also want to turn the looser check on once per repository and then forget about it. Something close to what Ben suggested earlier would do that:
git branch --delete-merged origin/main --landed=squash
plus a config variable for the default. A third kind of integration then becomes one more value, not one more --no-<kind> option.
On speed: with 3056 commits and 80 branches, v2 took 0.078s against 0.030s for 2.56.0, and --no-squashed brought it back to 0.030s. In a blobless clone the first run took 0.42s and fetched 13 blobs. --no-squashed fetched none.
One more point. A branch checked out in a worktree is skipped with no message, but a branch with deleteMerged=false prints "Skipping". I keep one worktree per pull request, so most of my merged branches are skipped silently. A "Skipping 'topic' (checked out in <path>)" line would help. I plan to send a separate RFC for
"git worktree list --merged", so it would also help if the "has this branch landed?" check were a helper that worktree code can call.
Thanks,
Jasper
Quoted 4 lines
At least the one-to-one part of the operation should learn from what Dscho did in range-diff, especially the part that does take similarity of the log messages and authorship information, instead of paying attention only to patch IDs. I am not convinced that
Quoted 8 lines
one-to-one rebase implemented as a subset of squash merge would perform sufficiently well, either. From the user interface angle, it also is not clear to me if a single --delete-merged that enables all variants by default with individual knob to optionally disable one variant (squash) would be a good extensible one, when we may need to deal with the third (and other) kinds of ways to integrate your work without preserving the commit identity, like selective cherry-picks.
I tried a range-diff solution locally, pairing commits with range-diff for rebases (and moving the squash search behind an opt-in flag). As far as I can tell range-diff pairs commits on the diff alone, and the log message and author only show up in its output, so it didn't help with matching here.
On speed it went the other way. On my clone with 65 branches, v2 takes about 0.5s and finds 14 branches to delete. The range-diff version takes about 10s, finds the same.
Harald
Quoted 11 lines
After rebasing, you probably can (re)use the same algorithm as used in range-diff to match the commits and their rewritten counterparts, but I wonder how "squash" would go. Presumably you can locally make a trial squash merge and probe for a similar commit on the upstream commit.
On the other hand, unlike deliberate rebasing done during a patch iteration, rebase done on the upstream to integrate your changes would not rewrite the log messages, so matching the commits based on the log message may work better than a range-diff between iteration vN and v(N+1) in the submitted patches.
Matching on commit messages sounds very brittle to me (GitHub mangles them on squash), and also unnecessary when we can compare the files themselves.
Quoted 10 lines
Quoted 3 lines
Harald Nordgren (2):
branch: let --delete-merged find squash merged branches
branch: add --no-squashed to skip the squash searchThis a bit unexpected two-patch organization; I would have expected that the cases where our side and upstream side should correspond one-to-one (i.e., what you call rebase-and-merge) and other cases (i.e., squash merge) to require very different matching algorithm, which would lead to two-patch organization where one adds support for rebase and the other adds support for squash.
Finding squashes is a superset of finding rebase merges. Although slower, which is why we need to be able to turn it off. So yes, then we need separate the code paths for squash and merge finding.
Then I'm thinking 3 commits:
branch: let --delete-merged find rebase merged branches
branch: let --delete-merged find squash merged branches
branch: add --no-squashed to skip the squash merge check
Harald
Quoted 13 lines · Harald Nordgren
Quoted 7 lines
After rebasing, you probably can (re)use the same algorithm as used in range-diff to match the commits and their rewritten counterparts, but I wonder how "squash" would go. Presumably you can locally make a trial squash merge and probe for a similar commit on the upstream commit.
Quoted 5 lines
On the other hand, unlike deliberate rebasing done during a patch iteration, rebase done on the upstream to integrate your changes would not rewrite the log messages, so matching the commits based on the log message may work better than a range-diff between iteration vN and v(N+1) in the submitted patches.
Quoted 3 lines
Matching on commit messages sounds very brittle to me (GitHub mangles them on squash), and also unnecessary when we can compare the files themselves.
Well, I think Junio was talking about the rebase cases for log messages, but I have been known to cherry-pick and edit the log message of small contributions that aren't up to my personal standards to help contributors land their changes, in which cases you *would* care more about patch ID than log message.
Signature · 1 line
D. Ben Knoble
v3 cover branch: let --delete-merged find squash merged branches
In reply to Harald Nordgren via GitGitGadgetBranches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
This series finds the first upstream commit since the fork point that has the branch's version of every file the branch changed, comparing only object ids. With --squashed, a file that upstream also changed counts when merging the branch's version into upstream's leaves it unchanged. Timed on my clone with 65 branches, this takes 0.5s.
Other approaches:
* Full merge-ort test merge per upstream commit: about 9s, because it builds a whole tree for every commit.
* range-diff pairing for rebases: about 10s. It pairs on diffs alone, ignoring log messages, so it found nothing extra.
* Patch ids: not tried. They tell whether a commit is the same patch, but deleting a branch only needs a commit that contains its changes. They miss a squash that was touched up and changes next to upstream edits, and they read file contents for every upstream commit touching the branch's files.
Changes in v3:
* Split into multiple commits, one for rebase merges and one for squash merges.
* By default a branch counts as merged when an upstream commit has its exact version of every file it changed. This never reads file contents, so it stays cheap in a partial clone. It finds rebase merges, and squash merges where upstream left those files alone.
* Replace --no-squashed with an opt-in --squashed, which also finds branches merged after upstream changed the same files by merging those files.
* Add a test for a rebase merge, and check that a squash onto changed files is kept without --squashed and deleted with it.
Changes in v2:
* Diff each upstream commit once and share the result between all branches, faster than v1.
* Fix a branch merged with a merge commit being reported as "landed as" the merge when another branch was searched first, and add a test for it. Show the topic commit itself in "landed as" for work that arrived through a merge.
* Use git merge --squash as the example in the commit message and mention GitHub's squash and rebase buttons.
* Add --no-squashed to skip the squash search.
Harald Nordgren (3):
branch: move the deleteMerged opt-out check into a helper
branch: let --delete-merged find rebase merged branches
branch: add --squashed to find squash merged branches
Documentation/git-branch.adoc | 26 ++- builtin/branch.c | 345 ++++++++++++++++++++++++++++++++-- t/t3200-branch.sh | 192 +++++++++++++++++++ 3 files changed, 537 insertions(+), 26 deletions(-)
base-commit: 47fdf2208c5b94c8cc01c106b8f7c85efb91c926
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v3
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v3
Pull-Request: https://github.com/git/git/pull/2425
Range-diff vs v2:
-: ---------- > 1: 82413e5cd7 branch: move the deleteMerged opt-out check into a helper
1: 94a6d23f8b ! 2: 9a7e88ced6 branch: let --delete-merged find squash merged branches
@@ Metadata
Author: Harald Nordgren <[email protected]>
## Commit message ##
- branch: let --delete-merged find squash merged branches
+ branch: let --delete-merged find rebase merged branches
A branch that upstream integrated with
- git merge --squash topic
- git commit
+ git rebase main topic
+ git checkout main
+ git merge --ff-only topic
- is never deleted by "git branch --delete-merged". The same goes for a
- branch that was rebased or cherry-picked onto upstream. The upstream
- holds a rewritten copy of its work, so its tip is not reachable from
- it and it looks unmerged forever.
-
- This happens all the time on hosting services. The "Squash and merge"
- and "Rebase and merge" buttons on GitHub land pull requests this way,
- so contributors to such projects collect local branches that
- "--delete-merged" never cleans up.
+ is never deleted by "git branch --delete-merged". This is what the
+ "Rebase and merge" button on GitHub does. The upstream holds rewritten
+ copies of the branch's commits, so its tip is not reachable from it and
+ it looks unmerged forever.
Treat such a branch as merged when some upstream commit since the fork
- point contains all of its changes, so that merging the branch into
- that commit would change nothing. Name that commit in the output so
- the user can see where the work went:
+ point has the branch's version of every file the branch changed. Name
+ that commit in the output so the user can see where the work went:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
- The first upstream commit that contains the changes is used, so the
- branch is deleted even if upstream later reverted or reworked them.
- Nothing is lost, since that commit keeps them in the upstream history.
- A branch whose changes only partly landed is kept.
+ The first upstream commit that has them is used, so the branch is
+ deleted even if upstream later reverted or reworked them. Nothing is
+ lost, since that commit keeps them in the upstream history. A branch
+ whose changes only partly landed is kept.
+
+ The same check finds a squash merge when upstream did not change the
+ same files between the fork point and the squash. It never reads file
+ contents, so it stays cheap in a partial clone.
Signed-off-by: Harald Nordgren <[email protected]>
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
+ _<pattern>_, but only when their work has already landed on that
+ upstream, so the local copy is no longer needed. This is the case
+ when their tip is reachable from the upstream, or when some
-+ upstream commit contains all of their changes, as happens after
-+ a squash or rebase merge, even if those changes were later
++ upstream commit has their version of every file they changed, as
++ happens after a rebase merge, even if those changes were later
+ reverted. The message for such a branch names the first upstream
-+ commit that contains its changes. _<pattern>_ may name a ref, a
-+ remote (using the branch its `HEAD` points at), or a shell-style
-+ glob. The option can be repeated to widen the upstream match.
++ commit that has them. _<pattern>_ may name a ref, a remote (using
++ the branch its `HEAD` points at), or a shell-style glob. The
++ option can be repeated to widen the upstream match.
Optional _<branch-pattern>_ arguments limit which local branches
are considered, e.g. `git branch --delete-merged 'origin/*'
'topic-*'`.
@@ builtin/branch.c
+#include "diff.h"
+#include "diffcore.h"
+#include "hex.h"
-+#include "merge-ll.h"
+#include "revision.h"
+#include "tree-walk.h"
-+#include "xdiff-interface.h"
static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
return ret;
}
-+enum change_state {
-+ CHANGE_MISSING,
-+ CHANGE_LANDED,
-+ CHANGE_NEEDS_MERGE,
-+};
-+
+struct branch_change {
+ char *path;
-+ struct object_id base_oid, branch_oid, upstream_oid;
++ struct object_id branch_oid;
+ unsigned short branch_mode;
-+ enum change_state state;
+};
+
+struct landing_search {
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ search->changes_alloc);
+ change = &search->changes[search->changes_nr++];
+ change->path = xstrdup(p->two->path);
-+ oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
-+ &p->one->oid : null_oid(the_hash_algo));
+ oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
+ &p->two->oid : null_oid(the_hash_algo));
+ change->branch_mode = p->two->mode;
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ diff_flush(&opt);
+}
+
-+static int merge_keeps_upstream(const struct branch_change *change)
-+{
-+ mmfile_t base, upstream, branch;
-+ mmbuffer_t result = { 0 };
-+ int ret;
-+
-+ read_mmblob(&base, the_repository->objects, &change->base_oid);
-+ read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
-+ read_mmblob(&branch, the_repository->objects, &change->branch_oid);
-+ ret = ll_merge(&result, change->path, &base, "base",
-+ &upstream, "upstream", &branch, "branch",
-+ the_repository->index, NULL) == LL_MERGE_OK &&
-+ result.size == upstream.size &&
-+ !memcmp(result.ptr, upstream.ptr, upstream.size);
-+
-+ free(base.ptr);
-+ free(upstream.ptr);
-+ free(branch.ptr);
-+ free(result.ptr);
-+ return ret;
-+}
-+
-+static enum change_state change_state_at(struct branch_change *change,
-+ struct commit *commit)
++static int change_landed(const struct branch_change *change,
++ struct commit *commit)
+{
++ struct object_id oid;
+ unsigned short mode;
+
+ if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
-+ change->path, &change->upstream_oid, &mode))
-+ return is_null_oid(&change->branch_oid) ?
-+ CHANGE_LANDED : CHANGE_MISSING;
-+ if (oideq(&change->upstream_oid, &change->branch_oid))
-+ return mode == change->branch_mode ?
-+ CHANGE_LANDED : CHANGE_MISSING;
-+ if (is_null_oid(&change->base_oid) ||
-+ is_null_oid(&change->branch_oid) ||
-+ oideq(&change->upstream_oid, &change->base_oid) ||
-+ mode != change->branch_mode || !S_ISREG(mode))
-+ return CHANGE_MISSING;
-+ return CHANGE_NEEDS_MERGE;
++ change->path, &oid, &mode))
++ return is_null_oid(&change->branch_oid);
++ return oideq(&oid, &change->branch_oid) &&
++ mode == change->branch_mode;
+}
+
+static int changes_landed(struct landing_search *search,
+ struct commit *commit)
+{
-+ for (size_t i = 0; i < search->changes_nr; i++) {
-+ struct branch_change *change = &search->changes[i];
-+
-+ change->state = change_state_at(change, commit);
-+ if (change->state == CHANGE_MISSING)
-+ return 0;
-+ }
-+ for (size_t i = 0; i < search->changes_nr; i++) {
-+ struct branch_change *change = &search->changes[i];
-+
-+ if (change->state == CHANGE_NEEDS_MERGE &&
-+ !merge_keeps_upstream(change))
++ for (size_t i = 0; i < search->changes_nr; i++)
++ if (!change_landed(&search->changes[i], commit))
+ return 0;
-+ }
+ return 1;
+}
+
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ diff_free(&opt);
+}
+
-+static int branch_opted_out(struct strbuf *key, const char *branch_name,
-+ unsigned int flags)
-+{
-+ int opt_out;
-+
-+ strbuf_reset(key);
-+ strbuf_addf(key, "branch.%s.deletemerged", branch_name);
-+ if (repo_config_get_bool(the_repository, key->buf, &opt_out) ||
-+ opt_out)
-+ return 0;
-+ if (!(flags & DELETE_BRANCH_QUIET))
-+ fprintf(stderr,
-+ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
-+ branch_name, branch_name);
-+ return 1;
-+}
-+
- static int delete_merged_branches(const struct strvec *upstreams,
- const char **argv, unsigned int flags)
+ static bool branch_opted_out(struct strbuf *key, const char *branch_name,
+ unsigned int flags)
{
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
struct ref_array candidates = { 0 };
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
struct strvec branches_to_delete = STRVEC_INIT;
struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
-@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
- const char *branch_name;
- struct branch *branch;
- const char *upstream_refname;
-- int opt_out;
-
- if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
- BUG("filter returned non-branch ref '%s'", branch_refname);
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
continue;
if (check_branch_commit(branch_name, branch_name,
&candidates.items[i]->objectname, NULL,
- FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
-- continue;
--
-- strbuf_reset(&key);
-- strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
-- if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
-- !opt_out) {
-- if (!(flags & DELETE_BRANCH_QUIET))
-- fprintf(stderr,
-- _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
-- branch_name, branch_name);
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED)) {
+ struct commit *rev = lookup_commit_reference(
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
+ .upstream = upstream,
+ };
continue;
- }
++ }
+
+ if (!branch_opted_out(&key, branch_name, flags))
+ strset_add(&deletable_branch_names, branch_name);
+ }
-- strset_add(&deletable_branch_names, branch_name);
-+ if (!branch_opted_out(&key, branch_name, flags))
-+ strset_add(&deletable_branch_names, branch_name);
-+ }
-+
+ find_landed_commits(searches, searches_nr, &landed_commits);
+ for (size_t i = 0; i < searches_nr; i++) {
+ const char *branch_name = searches[i].branch_name;
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
+ if (strmap_contains(&landed_commits, branch_name) &&
+ !branch_opted_out(&key, branch_name, flags))
+ strset_add(&deletable_branch_names, branch_name);
- }
-
++ }
++
protect_stacked_branch_bases(refs, &deletable_branch_names,
+ &protected_branch_names);
+
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
FILTER_REFS_BRANCHES,
DELETE_BRANCH_SKIP_UNMERGED |
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ )
+}
+
++rebase_merge_upstream () {
++ (
++ cd upstream &&
++ git rebase next "$1" &&
++ git checkout next &&
++ git merge --ff-only "$1" &&
++ git checkout main
++ )
++}
++
++test_expect_success '--delete-merged deletes a rebase merged branch' '
++ setup_repo_for_delete_merged &&
++ push_topic rebased rebased-one rebased-two &&
++ (
++ cd upstream &&
++ git checkout next &&
++ test_commit before-rebase &&
++ git checkout main
++ ) &&
++ rebase_merge_upstream rebased &&
++ landed=$(git -C upstream rev-parse --short next) &&
++ (
++ cd repo &&
++ git fetch origin &&
++ sha=$(git rev-parse --short rebased) &&
++
++ git branch --delete-merged origin/next >actual 2>&1 &&
++ echo "Deleted branch rebased (was $sha, landed as $landed)." >expect &&
++ test_cmp expect actual &&
++
++ check_branches <<-\EOF
++ main
++ EOF
++ )
++'
++
+test_expect_success '--delete-merged deletes a squash merged branch' '
+ setup_repo_for_delete_merged &&
+ push_topic squashed squashed-one squashed-two &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ )
+'
+
-+test_expect_success '--delete-merged deletes a squash merged branch after upstream changed the same file' '
-+ (
-+ cd upstream &&
-+ git checkout next &&
-+ test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
-+ git add shared &&
-+ git commit -m "add shared" &&
-+ git checkout main
-+ ) &&
-+ setup_repo_for_delete_merged &&
-+ (
-+ cd repo &&
-+ git checkout -b edits-shared --track origin/next &&
-+ test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
-+ git commit -a -m "edit line 2" &&
-+ git push origin edits-shared &&
-+ git checkout --detach
-+ ) &&
-+ (
-+ cd upstream &&
-+ git checkout next &&
-+ test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
-+ git commit -a -m "edit line 5" &&
-+ git checkout main
-+ ) &&
-+ squash_merge_upstream edits-shared &&
-+ squash=$(git -C upstream rev-parse --short next) &&
-+ (
-+ cd repo &&
-+ git fetch origin &&
-+ sha=$(git rev-parse --short edits-shared) &&
-+
-+ git branch --delete-merged origin/next >actual 2>&1 &&
-+ echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
-+ test_cmp expect actual &&
-+
-+ check_branches <<-\EOF
-+ main
-+ EOF
-+ )
-+'
-+
+test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
+ setup_repo_for_delete_merged &&
+ push_topic a-unmerged unmerged-work &&
2: ec88bde252 ! 3: 2befb182dc branch: add --no-squashed to skip the squash search
@@ Metadata
Author: Harald Nordgren <[email protected]>
## Commit message ##
- branch: add --no-squashed to skip the squash search
+ branch: add --squashed to find squash merged branches
- Finding branches that were squash or rebase merged makes "git branch
- --delete-merged" slower, and in a partial clone it may have to
- download blobs. Users who do not need it have no way to skip that
- search.
+ A branch that upstream integrated with
- Add --no-squashed, which only deletes branches whose tip is reachable
- from their upstream.
+ git merge --squash topic
+ git commit
+
+ after upstream had also changed the files the branch touches is still
+ kept by "git branch --delete-merged". The squash commit has neither the
+ branch's version of those files nor the fork point's, so the previous
+ check cannot see that the work landed. This happens all the time on
+ hosting services, where the "Squash and merge" button on GitHub lands
+ pull requests this way while the main branch keeps moving. A rebase
+ merge onto such changes looks the same.
+
+ Add --squashed, which for each such file merges the branch's version
+ into the upstream one and treats the change as landed when that leaves
+ the upstream version unchanged. This has to read the files, so it is
+ slower, and in a partial clone it may download blobs. It is therefore
+ only done when asked for.
Signed-off-by: Harald Nordgren <[email protected]>
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
-+git branch [--dry-run] [--no-squashed] (--delete-merged <pattern>)... [<branch-pattern>...]
++git branch [--dry-run] [--squashed] (--delete-merged <pattern>)... [<branch-pattern>...]
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: remove it anyway.
sanity-checking a wide pattern like `'origin/*'` before
committing to the deletion.
-+`--no-squashed`::
-+ With `--delete-merged`, only delete branches whose tip is
-+ reachable from their upstream. This skips the search for
-+ branches that were squash or rebase merged, which takes longer
-+ and may have to download blobs in a partial clone.
++`--squashed`::
++ With `--delete-merged`, also delete a branch that was squash or
++ rebase merged after upstream changed the same files. Upstream
++ then has neither the branch's version of those files nor the fork
++ point's, so the branch's version is merged into upstream's, and
++ the file counts as landed when that leaves upstream's version
++ unchanged. This reads those files, so it takes longer and may
++ download blobs in a partial clone.
+
`-v`::
`-vv`::
`--verbose`::
## builtin/branch.c ##
+@@
+ #include "diff.h"
+ #include "diffcore.h"
+ #include "hex.h"
++#include "merge-ll.h"
+ #include "revision.h"
+ #include "tree-walk.h"
++#include "xdiff-interface.h"
+
+ static const char * const builtin_branch_usage[] = {
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
@@ builtin/branch.c: enum delete_branch_flags {
DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
DELETE_BRANCH_DRY_RUN = (1 << 4),
-+ DELETE_BRANCH_NO_SQUASHED = (1 << 5),
++ DELETE_BRANCH_SQUASHED = (1 << 5),
};
static int check_branch_commit(const char *branchname, const char *refname,
+@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ return ret;
+ }
+
++enum change_state {
++ CHANGE_MISSING,
++ CHANGE_LANDED,
++ CHANGE_NEEDS_MERGE,
++};
++
+ struct branch_change {
+ char *path;
+- struct object_id branch_oid;
++ struct object_id base_oid, branch_oid, upstream_oid;
+ unsigned short branch_mode;
++ enum change_state state;
+ };
+
+ struct landing_search {
+@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *search,
+ search->changes_alloc);
+ change = &search->changes[search->changes_nr++];
+ change->path = xstrdup(p->two->path);
++ oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
++ &p->one->oid : null_oid(the_hash_algo));
+ oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
+ &p->two->oid : null_oid(the_hash_algo));
+ change->branch_mode = p->two->mode;
+@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *search,
+ diff_flush(&opt);
+ }
+
+-static int change_landed(const struct branch_change *change,
+- struct commit *commit)
++static int merge_keeps_upstream(const struct branch_change *change)
++{
++ mmfile_t base, upstream, branch;
++ mmfile_t result = { 0 };
++ int ret;
++
++ read_mmblob(&base, the_repository->objects, &change->base_oid);
++ read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
++ read_mmblob(&branch, the_repository->objects, &change->branch_oid);
++ ret = ll_merge(&result, change->path, &base, "base",
++ &upstream, "upstream", &branch, "branch",
++ the_repository->index, NULL) == LL_MERGE_OK &&
++ result.size == upstream.size &&
++ !memcmp(result.ptr, upstream.ptr, upstream.size);
++
++ free(base.ptr);
++ free(upstream.ptr);
++ free(branch.ptr);
++ free(result.ptr);
++ return ret;
++}
++
++static enum change_state change_state_at(struct branch_change *change,
++ struct commit *commit)
+ {
+- struct object_id oid;
+ unsigned short mode;
+
+ if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
+- change->path, &oid, &mode))
+- return is_null_oid(&change->branch_oid);
+- return oideq(&oid, &change->branch_oid) &&
+- mode == change->branch_mode;
++ change->path, &change->upstream_oid, &mode))
++ return is_null_oid(&change->branch_oid) ?
++ CHANGE_LANDED : CHANGE_MISSING;
++ if (oideq(&change->upstream_oid, &change->branch_oid))
++ return mode == change->branch_mode ?
++ CHANGE_LANDED : CHANGE_MISSING;
++ if (is_null_oid(&change->base_oid) ||
++ is_null_oid(&change->branch_oid) ||
++ oideq(&change->upstream_oid, &change->base_oid) ||
++ mode != change->branch_mode || !S_ISREG(mode))
++ return CHANGE_MISSING;
++ return CHANGE_NEEDS_MERGE;
+ }
+
+ static int changes_landed(struct landing_search *search,
+- struct commit *commit)
++ struct commit *commit, unsigned int flags)
+ {
+- for (size_t i = 0; i < search->changes_nr; i++)
+- if (!change_landed(&search->changes[i], commit))
++ for (size_t i = 0; i < search->changes_nr; i++) {
++ struct branch_change *change = &search->changes[i];
++
++ change->state = change_state_at(change, commit);
++ if (change->state == CHANGE_MISSING ||
++ (change->state == CHANGE_NEEDS_MERGE &&
++ !(flags & DELETE_BRANCH_SQUASHED)))
+ return 0;
++ }
++ for (size_t i = 0; i < search->changes_nr; i++) {
++ struct branch_change *change = &search->changes[i];
++
++ if (change->state == CHANGE_NEEDS_MERGE &&
++ !merge_keeps_upstream(change))
++ return 0;
++ }
+ return 1;
+ }
+
+@@ builtin/branch.c: static int touches_changes(struct string_list *paths,
+
+ static struct commit *find_landed_commit(struct landing_search *search,
+ struct changed_paths *cache,
+- struct diff_options *opt)
++ struct diff_options *opt,
++ unsigned int flags)
+ {
+ struct commit *commit, *landed = NULL;
+ struct strvec args = STRVEC_INIT;
+@@ builtin/branch.c: static struct commit *find_landed_commit(struct landing_search *search,
+ while (!landed && (commit = get_revision(&revs)))
+ if (touches_changes(changed_paths_of(cache, opt, commit),
+ search) &&
+- changes_landed(search, commit))
++ changes_landed(search, commit, flags))
+ landed = commit;
+ release_revisions(&revs);
+ clear_commit_marks(search->upstream, ALL_REV_FLAGS);
+@@ builtin/branch.c: static struct commit *find_landed_commit(struct landing_search *search,
+
+ static void find_landed_commits(struct landing_search *searches,
+ size_t searches_nr,
+- struct strmap *landed_commits)
++ struct strmap *landed_commits,
++ unsigned int flags)
+ {
+ struct changed_paths cache;
+ struct diff_options opt;
+@@ builtin/branch.c: static void find_landed_commits(struct landing_search *searches,
+
+ if (!searches[i].changes_nr)
+ continue;
+- landed = find_landed_commit(&searches[i], &cache, &opt);
++ landed = find_landed_commit(&searches[i], &cache, &opt, flags);
+ if (landed)
+ strmap_put(landed_commits, searches[i].branch_name,
+ landed);
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
- struct commit *upstream = lookup_commit_reference_by_name(
- upstream_refname);
-
-- if (!rev || !upstream)
-+ if ((flags & DELETE_BRANCH_NO_SQUASHED) ||
-+ !rev || !upstream)
- continue;
- ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
- searches[searches_nr++] = (struct landing_search) {
+ strset_add(&deletable_branch_names, branch_name);
+ }
+
+- find_landed_commits(searches, searches_nr, &landed_commits);
++ find_landed_commits(searches, searches_nr, &landed_commits, flags);
+ for (size_t i = 0; i < searches_nr; i++) {
+ const char *branch_name = searches[i].branch_name;
+
@@ builtin/branch.c: int cmd_branch(int argc,
unset_upstream = 0, show_current = 0, edit_description = 0;
struct strvec delete_merged = STRVEC_INIT;
int dry_run = 0;
-+ int no_squashed = 0;
++ int squashed = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
PARSE_OPT_NONEG, parse_opt_strvec),
OPT_BOOL(0, "dry-run", &dry_run,
N_("with --delete-merged, only print which branches would be deleted")),
-+ OPT_BOOL(0, "no-squashed", &no_squashed,
-+ N_("with --delete-merged, skip the search for squash merged branches")),
++ OPT_BOOL(0, "squashed", &squashed,
++ N_("with --delete-merged, also find branches merged after upstream changed their files")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ builtin/branch.c: int cmd_branch(int argc,
if (dry_run && !delete_merged.nr)
die(_("--dry-run requires --delete-merged"));
-+ if (no_squashed && !delete_merged.nr)
-+ die(_("--no-squashed requires --delete-merged"));
++ if (squashed && !delete_merged.nr)
++ die(_("--squashed requires --delete-merged"));
if (recurse_submodules_explicit) {
if (!submodule_propagate_branches)
@@ builtin/branch.c: int cmd_branch(int argc,
(quiet ? DELETE_BRANCH_QUIET : 0) |
- (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
-+ (no_squashed ? DELETE_BRANCH_NO_SQUASHED : 0));
++ (squashed ? DELETE_BRANCH_SQUASHED : 0));
goto out;
} else if (show_current) {
print_current_branch_name();
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged branch after upstre
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged branch that was rev
)
'
-+test_expect_success '--delete-merged --no-squashed deletes only reachable branches' '
++test_expect_success '--delete-merged --squashed deletes a squash merged branch after upstream changed the same file' '
++ (
++ cd upstream &&
++ git checkout next &&
++ test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
++ git add shared &&
++ git commit -m "add shared" &&
++ git checkout main
++ ) &&
+ setup_repo_for_delete_merged &&
-+ create_merged_branch merged &&
-+ push_topic squash-merged squash-merged-work &&
-+ squash_merge_upstream squash-merged &&
++ (
++ cd repo &&
++ git checkout -b edits-shared --track origin/next &&
++ test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
++ git commit -a -m "edit line 2" &&
++ git push origin edits-shared &&
++ git checkout --detach
++ ) &&
++ (
++ cd upstream &&
++ git checkout next &&
++ test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
++ git commit -a -m "edit line 5" &&
++ git checkout main
++ ) &&
++ squash_merge_upstream edits-shared &&
++ squash=$(git -C upstream rev-parse --short next) &&
+ (
+ cd repo &&
+ git fetch origin &&
-+ sha=$(git rev-parse --short merged) &&
++ sha=$(git rev-parse --short edits-shared) &&
++
++ git branch --delete-merged origin/next >actual 2>&1 &&
++ test_must_be_empty actual &&
++ check_branches <<-\EOF &&
++ edits-shared
++ main
++ EOF
+
-+ git branch --delete-merged origin/next --no-squashed \
++ git branch --delete-merged origin/next --squashed \
+ >actual 2>&1 &&
-+ echo "Deleted branch merged (was $sha)." >expect &&
++ echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
+ test_cmp expect actual &&
+
+ check_branches <<-\EOF
+ main
-+ squash-merged
+ EOF
+ )
+'
@@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=f
)
'
-+test_expect_success '--no-squashed without --delete-merged is rejected' '
-+ test_must_fail git -C forked branch --no-squashed 2>err &&
++test_expect_success '--squashed without --delete-merged is rejected' '
++ test_must_fail git -C forked branch --squashed 2>err &&
+ test_grep "requires --delete-merged" err
+'
+Signature · 1 line
gitgitgadget
PATCH v3 1/3 branch: move the deleteMerged opt-out check into a helper
From: Harald Nordgren <[email protected]>
The next commit lets "git branch --delete-merged" delete branches whose tips are not reachable from their upstream, and those must honor branch.<name>.deleteMerged=false as well. Move the check into a helper so it can be used in both places.
No change in behavior.
--- builtin/branch.c | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-)
builtin/branch.c +19 -13
@@ -824,6 +824,23 @@ static int branch_pushes_to_upstream(struct branch *branch,return ret; } +static bool branch_opted_out (struct strbuf *key,const char *branch_name, +unsigned int flags) +{ +int opt_out; + +strbuf_reset (key); +strbuf_addf (key,"branch.%s.deletemerged" , branch_name); +if (repo_config_get_bool (the_repository, key->buf, &opt_out) || + opt_out) +return false; +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +return true; +} +static int delete_merged_branches (const struct strvec *upstreams,const char **argv,unsigned int flags) {
@@ -852,7 +869,6 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; -int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -870,18 +886,8 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; -strbuf_reset (&key); -strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); -if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && - !opt_out) { -if (!(flags &DELETE_BRANCH_QUIET )) -fprintf (stderr, -_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), - branch_name, branch_name); -continue ; - } - -strset_add (&deletable_branch_names, branch_name); +if (!branch_opted_out (&key, branch_name, flags)) +strset_add (&deletable_branch_names, branch_name); } protect_stacked_branch_bases(refs, &deletable_branch_names,
Signature · 1 line
gitgitgadget
PATCH v3 2/3 branch: let --delete-merged find rebase merged branches
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git rebase main topic
git checkout main
git merge --ff-only topic
is never deleted by "git branch --delete-merged". This is what the "Rebase and merge" button on GitHub does. The upstream holds rewritten copies of the branch's commits, so its tip is not reachable from it and it looks unmerged forever.
Treat such a branch as merged when some upstream commit since the fork point has the branch's version of every file the branch changed. Name that commit in the output so the user can see where the work went:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
The first upstream commit that has them is used, so the branch is deleted even if upstream later reverted or reworked them. Nothing is lost, since that commit keeps them in the upstream history. A branch whose changes only partly landed is kept.
The same check finds a squash merge when upstream did not change the same files between the fork point and the squash. It never reads file contents, so it stays cheap in a partial clone.
--- Documentation/git-branch.adoc | 15 +- builtin/branch.c | 251 +++++++++++++++++++++++++++++++++- t/t3200-branch.sh | 137 +++++++++++++++++++ 3 files changed, 391 insertions(+), 12 deletions(-)
Documentation/git-branch.adoc +9 -6
@@ -204,12 +204,15 @@ This option is only applicable in non-verbose mode. `--delete-merged <pattern>`:: Delete local branches whose configured upstream matches - _<pattern>_, but only when their tip is reachable from that - upstream. In other words, the work on the branch has already - landed on the upstream it tracks, so the local copy is no longer - needed. _<pattern>_ may name a ref, a remote (using the branch its - `HEAD` points at), or a shell-style glob. The option can be - repeated to widen the upstream match. + _<pattern>_, but only when their work has already landed on that + upstream, so the local copy is no longer needed. This is the case + when their tip is reachable from the upstream, or when some + upstream commit has their version of every file they changed, as + happens after a rebase merge, even if those changes were later + reverted. The message for such a branch names the first upstream + commit that has them. _<pattern>_ may name a ref, a remote (using + the branch its `HEAD` points at), or a shell-style glob. The + option can be repeated to widen the upstream match. Optional _<branch-pattern>_ arguments limit which local branches are considered, e.g. `git branch --delete-merged 'origin/*' 'topic-*'`.
builtin/branch.c +245 -6
324 lines not on this page · raw message
t/t3200-branch.sh +137 -0
143 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v3 3/3 branch: add --squashed to find squash merged branches
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git merge --squash topic
git commitafter upstream had also changed the files the branch touches is still kept by "git branch --delete-merged". The squash commit has neither the branch's version of those files nor the fork point's, so the previous check cannot see that the work landed. This happens all the time on hosting services, where the "Squash and merge" button on GitHub lands pull requests this way while the main branch keeps moving. A rebase merge onto such changes looks the same.
Add --squashed, which for each such file merges the branch's version into the upstream one and treats the change as landed when that leaves the upstream version unchanged. This has to read the files, so it is slower, and in a partial clone it may download blobs. It is therefore only done when asked for.
--- Documentation/git-branch.adoc | 11 +++- builtin/branch.c | 96 ++++++++++++++++++++++++++++------- t/t3200-branch.sh | 55 ++++++++++++++++++++ 3 files changed, 144 insertions(+), 18 deletions(-)
Documentation/git-branch.adoc +10 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...] +git branch [--dry-run] [--squashed] (--delete-merged <pattern>)... [<branch-pattern>...] DESCRIPTION -----------
@@ -244,6 +244,15 @@ remove it anyway. sanity-checking a wide pattern like `'origin/*'` before committing to the deletion. +`--squashed`:: + With `--delete-merged`, also delete a branch that was squash or + rebase merged after upstream changed the same files. Upstream + then has neither the branch's version of those files nor the fork + point's, so the branch's version is merged into upstream's, and + the file counts as landed when that leaves upstream's version + unchanged. This reads those files, so it takes longer and may + download blobs in a partial clone. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +79 -17
194 lines not on this page · raw message
t/t3200-branch.sh +55 -0
67 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Quoted 7 lines · Harald Nordgren via GitGitGadget
* By default a branch counts as merged when an upstream commit has its exact version of every file it changed. This never reads file contents, so it stays cheap in a partial clone. It finds rebase merges, and squash merges where upstream left those files alone.
* Replace --no-squashed with an opt-in --squashed, which also finds branches merged after upstream changed the same files by merging those files.
Thanks for the new round. I ran v3 (2befb182dc) on the same generated repositories as v2, and added one case for a squash that landed after upstream changed another line of the same file. t3200 passes, and the build is clean with DEVELOPER=1.
The results match the cover letter. Without --squashed, v3 deletes the squash merges, the GitHub-style squash, the rebase merge, the rename, and the squash that was later reverted. It keeps the new case. --squashed also deletes the new case, and "landed as" names the correct commit each time. A branch whose work only partly landed is kept, with or without --squashed.
But the false positive from my v2 report is still there. A branch that was never merged is deleted because another author made the same change in an unrelated commit. In v2 I could turn that off with --no-squashed. In v3 the cheap check finds it, so it happens with no option at all.
That is the case Junio called "irrelevant", and he suggested that "--delete-merged" stay the strict "my commits are in upstream" check. The file contents of such a branch are in upstream, but its own commits and messages are not, and the user did not ask for the looser meaning.
So I would still prefer the whole looser check behind one opt-in, with a config variable for the default. The cheap check and --squashed could then be two levels of that one option, for example:
git branch --delete-merged origin/main --landed=files
git branch --delete-merged origin/main --landed=mergeUsers who land everything through "Squash and merge" set it once in the config, and plain --delete-merged keeps its current meaning.
A branch checked out in a worktree is still skipped with no message, while a branch with deleteMerged=false prints "Skipping". I sent an RFC for "git worktree list --merged" about that case:
https://lore.kernel.org/git/[email protected]/
Thanks,
Jasper
Quoted 9 lines
But the false positive from my v2 report is still there. A branch that was never merged is deleted because another author made the same change in an unrelated commit. In v2 I could turn that off with --no-squashed. In v3 the cheap check finds it, so it happens with no option at all.
That is the case Junio called "irrelevant", and he suggested that "--delete-merged" stay the strict "my commits are in upstream" check. The file contents of such a branch are in upstream, but its own commits and messages are not, and the user did not ask for the looser meaning.
My sincere apology for skipping over your post before.
My branch being pruned because someone else did the same thing would indeed feel weird. And it could be stopped by doing an author check.
But I question if it’s realistic at all. The code has to be exactly the same, and I don’t think I have ever seen that happen, I would call this an extremely rare edge case.
And if it did happen and it was *incorrectly* pruned, it is still not completely lost, because by definition it is in the upstream already. So why would you actually need that branch? (But it won’t happen)
Quoted 9 lines
So I would still prefer the whole looser check behind one opt-in, with a config variable for the default. The cheap check and --squashed could then be two levels of that one option, for example:
git branch --delete-merged origin/main --landed=files git branch --delete-merged origin/main --landed=mergeUsers who land everything through "Squash and merge" set it once in the config, and plain --delete-merged keeps its current meaning.
I think this is pushing it in terms of UX mental load. I will never run this check without specifying the highest level of pruning. I also have another topic open to be able to skip the '**' pattern.
It's about giving users the simplest-to-use tool to work with, not endless configurability for a command that just deletes merged branches.
Quoted 5 lines
A branch checked out in a worktree is still skipped with no message, while a branch with deleteMerged=false prints "Skipping". I sent an RFC for "git worktree list --merged" about that case:
I'll take a look at that after sending this, I haven't yet. Thanks in advance!
Harald
Quoted 6 lines · Harald Nordgren
My branch being pruned because someone else did the same thing would indeed feel weird. And it could be stopped by doing an author check.
But I question if it’s realistic at all. The code has to be exactly the same, and I don’t think I have ever seen that happen, I would call this an extremely rare edge case.
I have seen this happen when 2 PRs contain the same "npm update" commits and are rebased onto main: whoever gets there first wins. If the commits contained additional changes, then the later-rebased commit loses some changes because they were handled prior, but (when doing so via GitHub) the commit message is unchanged. I find this a bit confusing, tbh!
But, I will say it is rare. Perhaps not "extremely" :)
Signature · 1 line
D. Ben Knoble
Quoted 17 lines · D. Ben Knoble
Quoted 6 lines · Harald Nordgren
My branch being pruned because someone else did the same thing would indeed feel weird. And it could be stopped by doing an author check.
But I question if it’s realistic at all. The code has to be exactly the same, and I don’t think I have ever seen that happen, I would call this an extremely rare edge case.
I have seen this happen when 2 PRs contain the same "npm update" commits and are rebased onto main: whoever gets there first wins. If the commits contained additional changes, then the later-rebased commit loses some changes because they were handled prior, but (when doing so via GitHub) the commit message is unchanged. I find this a bit confusing, tbh!
Quoted 4 lines
But, I will say it is rare. Perhaps not "extremely" :)
--
D. Ben Knoble
Good counter example!
But it would have to be something like that then: an automated deterministic script.
Harald
Quoted 5 lines · Harald Nordgren via GitGitGadget
This series finds the first upstream commit since the fork point that has the branch's version of every file the branch changed, comparing only object ids. With --squashed, a file that upstream also changed counts when merging the branch's version into upstream's leaves it unchanged. Timed on my clone with 65 branches, this takes 0.5s.
It would be very surprising for the unsuspecting users to loosen the behaviour of "--delete-merged" like this series does. As requested elsewhere [*], do not change the behaviour of the "--delete-merged" when no other "please make it looser" option requests otherwise.
* https://lore.kernel.org/git/[email protected]/
The attached new test illustrates the extent of damage. We add a file on a private branch while the upstream adds two files, one of which has an identical contents to our file, under author identity different from ours. If we run "--delete-merged" in our repository, we should not lose our branch that we never pushed, or asked them to pull, or anything. But when applied on top of this series, we do.
With the following applied on top of the series, with all changes to builtin/branch.c reverted, it passes. Of course, later tests that assumes the looser criteria is used without an explicit opt-in will fail with your code changes reverted, but that is to be expected.
t/t3200-branch.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+)
t/t3200-branch.sh +33 -0
@@ -1925,6 +1925,39 @@ check_branches () { test_cmp expect actual } +test_expect_success '--delete-merged does not delete unrelated branches' ' + setup_repo_for_delete_merged && + ( + cd repo && + git checkout -b unrelated-topic --track origin/next && + test_write_lines 1 2 3 4 >file && + test_write_lines 5 6 7 >elif && + git add file elif && + git commit -m "add file and elif" && + git push origin unrelated-topic && + git checkout --detach + ) && + ( + cd upstream && + git checkout next && + test_write_lines 1 2 3 4 >file && + git add file && + git commit --author="unrelated user <[email protected]>" \ + -m "unrelated commit adding an identical file" && + git checkout main + ) && + ( + cd repo && + git fetch origin && + git branch --delete-merged origin/next >actual 2>&1 && + test_must_be_empty actual && + check_branches <<-\EOF + main + unrelated-topic + EOF + ) +' + test_expect_success '--delete-merged keeps cloned main without explicit push configuration' ' setup_repo_for_delete_merged && (
Quoted 5 lines
It would be very surprising for the unsuspecting users to loosen the behaviour of "--delete-merged" like this series does. As requested elsewhere [*], do not change the behaviour of the "--delete-merged" when no other "please make it looser" option requests otherwise.
Quoted 8 lines
* https://lore.kernel.org/git/[email protected]/
The attached new test illustrates the extent of damage. We add a file on a private branch while the upstream adds two files, one of which has an identical contents to our file, under author identity different from ours. If we run "--delete-merged" in our repository, we should not lose our branch that we never pushed, or asked them to pull, or anything. But when applied on top of this series, we do.
Ok.
So do we go with --delete-irrelevant or --delete-irrelevant=squashed,rebased to give us two levels here? I'd rather do the former.
Harald
Quoted 3 lines · Harald Nordgren
So do we go with
--delete-irrelevantor--delete-irrelevant=squashed,rebasedto give us two levels here? I'd rather do the former.
Two comments.
* Naming
I would avoid a negative-sounding word like "irrelevant". Your work is almost always relevant, at least to you.
The purpose of the new feature is to find branches that the user no longer needs to keep because they have been practically merged. Perhaps --delete-integrated? I do not know. The naming matters, and I hope somebody watching from the sidelines can suggest a better alternative.
For lack of a better alternative that I can suggest, I will use "--delete-foo" for the remainder of this message.
* Option value
Another point is that different kinds of detection should probably form subset-superset relationships, rather than a Venn diagram, to make them easy to understand and useful.
When you specify --delete-foo=squashed,rebased, do you expect that a branch that would be removed by --delete-merged (i.e., merged as identical commits) would be kept because it was not squash-merged or rebased onto the upstream? I doubt users would want to make such a distinction.
I do not personally care about the distinction between rebasing and squash-merging, but users probably do. I suspect they expect that a mode catching squash-merged branches would also recognize a branch that was straight cherry-picked (or rebased) upstream.
Perhaps the options could be structured like this to be as easy to understand as possible:
--delete-foo=merged Same as --delete-merged.
--delete-foo=rebased Above, plus rebased.
--delete-foo=squashed Above, plus squash-merged.Would this be a clearer way to present the feature to users?
Thanks.
Quoted 11 lines · Junio C Hamano
I would avoid a negative-sounding word like "irrelevant". Your work is almost always relevant, at least to you.
The purpose of the new feature is to find branches that the user no longer needs to keep because they have been practically merged. Perhaps --delete-integrated? I do not know. The naming matters, and I hope somebody watching from the sidelines can suggest a better alternative.
For lack of a better alternative that I can suggest, I will use "--delete-foo" for the remainder of this message.
Here is a short list of candidates for "foo" I came up with.
merged
integrated
subsumed
absorbed
included
done
Reusing the existing --delete-merged option by extending it as --delete-merged[=<level>] is somewhat tempting. However, we frown upon options with optional values because our options by default allow option-arguments to be given as separate arguments. If we can, I would prefer to avoid doing so.
Our documentation uses "integrated" to describe practically merging your work upstream, whether by merging, rebasing, or squash-merging. However, that word is a tad too long. "subsumed", "absorbed", and "included" are shorter alternatives, but none of them is familiar in our parlance.
The word "done" may be a direct match to the mindset of our users. Once your branch is integrated upstream and you no longer need to keep pushing for its inclusion, you are "done" with it.
I dunno. I hope others will suggest something much better than any of the above ;-)
Quoted 25 lines · Junio C Hamano
Quoted 11 lines · Junio C Hamano
I would avoid a negative-sounding word like "irrelevant". Your work is almost always relevant, at least to you.
The purpose of the new feature is to find branches that the user no longer needs to keep because they have been practically merged. Perhaps --delete-integrated? I do not know. The naming matters, and I hope somebody watching from the sidelines can suggest a better alternative.
For lack of a better alternative that I can suggest, I will use "--delete-foo" for the remainder of this message.
Here is a short list of candidates for "foo" I came up with.
merged
integrated
subsumed
absorbed
included
done
Quoted 5 lines
Reusing the existing --delete-merged option by extending it as --delete-merged[=<level>] is somewhat tempting. However, we frown upon options with optional values because our options by default allow option-arguments to be given as separate arguments. If we can, I would prefer to avoid doing so.
I'm leaning toward --delete-merged[=<level>]. But it's good that you are pointing this out. It's tempting to unify it like that.
Quoted 5 lines
Our documentation uses "integrated" to describe practically merging your work upstream, whether by merging, rebasing, or squash-merging. However, that word is a tad too long. "subsumed", "absorbed", and "included" are shorter alternatives, but none of them is familiar in our parlance.
I like integrated a lot.
Quoted 3 lines
The word "done" may be a direct match to the mindset of our users. Once your branch is integrated upstream and you no longer need to keep pushing for its inclusion, you are "done" with it.
done sounds like something else, like some script that finished. Not clear to me that it will be removed.
Whereas integrated is non-ambiguous, and it's a beautiful word. Getting your work integrated is the goal.
Harald
Quoted 2 lines · Harald Nordgren
I'm leaning toward
--delete-merged[=<level>]. But it's good that you are pointing this out. It's tempting to unify it like that.
The value of --delete-merged is already taken by the upstream <pattern>, and it is required. So "--delete-merged=squashed" already means "branches whose upstream is a ref called squashed" today, and it cannot also carry a level without breaking that.
Quoted 1 line
I like
integrateda lot.
+1 from a user who lands most work with "Squash and merge". It is the word our documentation already uses, and it does not say how the work got there, which fits every level of the ladder.
Since the value slot holds the pattern, the level needs its own place. One shape that keeps each command line simple:
git branch --delete-merged <pattern> # strict, as today
git branch --delete-integrated <pattern> # rebased and squashed too
with a config variable if someone wants the middle step of Junio's ladder (merged, then rebased, then squashed) as the meaning of --delete-integrated.
On the false positive: Ben's "npm update" case, Junio's new test and my case are the same thing. The upstream commit has the right contents but was written by someone else. The squash and rebase buttons keep the author of the pull request on the upstream commit, so requiring that the landing commit's author also authored a commit on the branch would reject all three. Work that a maintainer applied with "git am" would still count, because "git am" keeps the author too. I have not measured this, so take it as an idea to try.
Thanks,
Jasper
Quoted 9 lines · Jasper Mayone
Since the value slot holds the pattern, the level needs its own place. One shape that keeps each command line simple:
git branch --delete-merged <pattern> # strict, as today
git branch --delete-integrated <pattern> # rebased and squashed toowith a config variable if someone wants the middle step of Junio's ladder (merged, then rebased, then squashed) as the meaning of --delete-integrated.
I like that without configuration. Simplicity is the ultimate sophistication ;-).
Quoted 7 lines
Quoted 2 lines
I'm leaning toward
--delete-merged[=<level>]. But it's good that you are pointing this out. It's tempting to unify it like that.The value of --delete-merged is already taken by the upstream <pattern>, and it is required. So "--delete-merged=squashed" already means "branches whose upstream is a ref called squashed" today, and it cannot also carry a level without breaking that.
This is a good point and stops the --delete-merged=squashed, and likely also --delete-integrated=<level> to keep it uniform.
Harald
Quoted 2 lines
I would avoid a negative-sounding word like "irrelevant". Your work is almost always relevant, at least to you.
Yes :-D
Quoted 9 lines
Another point is that different kinds of detection should probably form subset-superset relationships, rather than a Venn diagram, to make them easy to understand and useful.
When you specify --delete-foo=squashed,rebased, do you expect that a branch that would be removed by --delete-merged (i.e., merged as identical commits) would be kept because it was not squash-merged or rebased onto the upstream? I doubt users would want to make such a distinction.
Very important point regarding the design.
Quoted 5 lines
I do not personally care about the distinction between rebasing and squash-merging, but users probably do. I suspect they expect that a mode catching squash-merged branches would also recognize a branch that was straight cherry-picked (or rebased) upstream.
Quoted 8 lines
Perhaps the options could be structured like this to be as easy to understand as possible:
--delete-foo=merged Same as --delete-merged. --delete-foo=rebased Above, plus rebased. --delete-foo=squashed Above, plus squash-merged.Would this be a clearer way to present the feature to users?
Yes, but we can do better on naming, something in this direction?
--delete-foo=match-hash #Same as --delete-merged.
--delete-foo=match-commits #Above, plus rebased.
--delete-foo=match-content #Above, plus squash-merged.This comes to mind
(https://git-scm.com/docs/git-push#Documentation/git-push.txt-pushdefault),
different push.default options can overlap or subsume each other, and it's also clear that only one will be used at a time.
Harald
Quoted 16 lines · Harald Nordgren via GitGitGadget
Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
This series finds the first upstream commit since the fork point that has the branch's version of every file the branch changed, comparing only object ids. With --squashed, a file that upstream also changed counts when merging the branch's version into upstream's leaves it unchanged. Timed on my clone with 65 branches, this takes 0.5s.
Quoted 4 lines
Harald Nordgren (3):
branch: move the deleteMerged opt-out check into a helper
branch: let --delete-merged find rebase merged branches
branch: add --squashed to find squash merged branches
I hope you'll have a moment to consider my reply in
<https://lore.kernel.org/git/CALnO6CAsCvJRywkw0WpO1nEhL+yU2-r_4Z2AHdJch1m=NK8=TA@mail.gmail.com/T/#m5244d4a2781578b3058c4cef272a2014291ab85e>
wrt the CLI. Perhaps a modifier in --delete-merged's value would be better than another --option that modifies how it behaves?
v4 cover branch: let --delete-merged find squash merged branches
In reply to Harald Nordgren via GitGitGadgetBranches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
This series finds the first upstream commit since the fork point that has the branch's version of every file the branch changed, comparing only object ids. With --squashed, a file that upstream also changed counts when merging the branch's version into upstream's leaves it unchanged.
Changes in v4:
* --delete-merged works as it does on master again: it only deletes branches whose tip is reachable from their upstream
* New --delete-irrelevant <pattern> also deletes branches that were rebase or squash merged, including those squashed after upstream changed the same files. It replaces --squashed
* New prep commit lets delete_branches() take a map of where each branch landed, so "landed as" is printed there
* Added Junio's test that --delete-merged keeps a branch when an unrelated upstream commit adds an identical file
* --dry-run works with either option, and the two options can't be combined
Changes in v3:
* Split into multiple commits, one for rebase merges and one for squash merges.
* By default a branch counts as merged when an upstream commit has its exact version of every file it changed. This never reads file contents, so it stays cheap in a partial clone. It finds rebase merges, and squash merges where upstream left those files alone.
* Replace --no-squashed with an opt-in --squashed, which also finds branches merged after upstream changed the same files by merging those files.
* Add a test for a rebase merge, and check that a squash onto changed files is kept without --squashed and deleted with it.
Changes in v2:
* Diff each upstream commit once and share the result between all branches, faster than v1.
* Fix a branch merged with a merge commit being reported as "landed as" the merge when another branch was searched first, and add a test for it. Show the topic commit itself in "landed as" for work that arrived through a merge.
* Use git merge --squash as the example in the commit message and mention GitHub's squash and rebase buttons.
* Add --no-squashed to skip the squash search.
Harald Nordgren (4):
branch: move the deleteMerged opt-out check into a helper
branch: let delete_branches() name where a branch landed
branch: add --delete-integrated to delete rebase and squash merged
branches
branch: let --delete-integrated find branches merged onto changed
files
Documentation/config/branch.adoc | 8 +- Documentation/git-branch.adoc | 22 +- builtin/branch.c | 355 +++++++++++++++++++++++++++++-- t/t3200-branch.sh | 217 +++++++++++++++++++ 4 files changed, 572 insertions(+), 30 deletions(-)
base-commit: 7e34d8174dcda3435d1404c9a30bef46afe6c13e
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v4
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v4
Pull-Request: https://github.com/git/git/pull/2425
Range-diff vs v3:
1: 82413e5cd7 ! 1: 1d01085b75 branch: move the deleteMerged opt-out check into a helper
@@ Metadata
## Commit message ##
branch: move the deleteMerged opt-out check into a helper
- The next commit lets "git branch --delete-merged" delete branches whose
- tips are not reachable from their upstream, and those must honor
- branch.<name>.deleteMerged=false as well. Move the check into a helper
- so it can be used in both places.
+ A later commit adds "git branch --delete-integrated", which deletes
+ branches whose tips are not reachable from their upstream. Those must
+ honor branch.<name>.deleteMerged=false as well. Move the check into a
+ helper so it can be used in both places.
No change in behavior.
-: ---------- > 2: 592562d7e7 branch: let delete_branches() name where a branch landed 2: 9a7e88ced6 ! 3: deb1e649cf branch: let --delete-merged find rebase merged branches
@@ Metadata
Author: Harald Nordgren <[email protected]>
## Commit message ##
- branch: let --delete-merged find rebase merged branches
+ branch: add --delete-integrated to delete rebase and squash merged branches
A branch that upstream integrated with
@@ Commit message
git checkout main
git merge --ff-only topic
+ or with
+
+ git merge --squash topic
+ git commit
+
is never deleted by "git branch --delete-merged". This is what the
- "Rebase and merge" button on GitHub does. The upstream holds rewritten
- copies of the branch's commits, so its tip is not reachable from it and
- it looks unmerged forever.
+ "Rebase and merge" and "Squash and merge" buttons on GitHub do. The
+ upstream holds rewritten copies of the branch's work, so its tip is not
+ reachable from it and it looks unmerged forever.
- Treat such a branch as merged when some upstream commit since the fork
- point has the branch's version of every file the branch changed. Name
- that commit in the output so the user can see where the work went:
+ Add --delete-integrated, which does what --delete-merged does and also
+ deletes a branch when some upstream commit since the fork point has the
+ branch's version of every file the branch changed. That commit is named
+ in the output so the user can see where the work went:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
- The first upstream commit that has them is used, so the branch is
- deleted even if upstream later reverted or reworked them. Nothing is
- lost, since that commit keeps them in the upstream history. A branch
- whose changes only partly landed is kept.
+ The first such commit is used, so the branch is deleted even if
+ upstream later reverted or reworked the changes. Nothing is lost, since
+ that commit keeps them in the upstream history. Who made that commit
+ does not matter, since the local branch is no longer needed either way.
+ A branch whose changes only partly landed is kept.
- The same check finds a squash merge when upstream did not change the
- same files between the fork point and the squash. It never reads file
- contents, so it stays cheap in a partial clone.
+ --delete-merged keeps deleting only branches whose own commits are in
+ the upstream, so it never removes a branch because somebody else made
+ the same change.
+
+ The check never reads file contents, so it stays cheap in a partial
+ clone.
+ ## Documentation/config/branch.adoc ##
+@@ Documentation/config/branch.adoc: for details).
+
+ branch.<name>.deleteMerged::
+ If set to false, branch _<name>_ is exempt from
+- git branch --delete-merged. Useful for a topic branch you
+- intend to develop further after an initial round has been
+- merged upstream. Defaults to true. Explicit deletion via
+- git branch -d is unaffected.
++ git branch --delete-merged and --delete-integrated. Useful
++ for a topic branch you intend to develop further after an
++ initial round has been merged upstream. Defaults to true.
++ Explicit deletion via git branch -d is unaffected.
+
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode. +@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
+ git branch (-d|-D) [-r] <branch-name>...
+ git branch --edit-description [<branch-name>]
+ git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...] ++git branch [--dry-run] (--delete-integrated <pattern>)... [<branch-pattern>...] +
+ DESCRIPTION
+ -----------
+@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is
+ silently skipped. Delete it with git branch -D if you want to
+ remove it anyway.
- --delete-merged <pattern>::
- Delete local branches whose configured upstream matches
-- _<pattern>_, but only when their tip is reachable from that
-- upstream. In other words, the work on the branch has already
-- landed on the upstream it tracks, so the local copy is no longer
-- needed. _<pattern>_ may name a ref, a remote (using the branch its
-- HEAD points at), or a shell-style glob. The option can be
-- repeated to widen the upstream match.
-+ _<pattern>_, but only when their work has already landed on that
-+ upstream, so the local copy is no longer needed. This is the case
-+ when their tip is reachable from the upstream, or when some
-+ upstream commit has their version of every file they changed, as
-+ happens after a rebase merge, even if those changes were later
-+ reverted. The message for such a branch names the first upstream
-+ commit that has them. _<pattern>_ may name a ref, a remote (using
-+ the branch its HEAD points at), or a shell-style glob. The
-+ option can be repeated to widen the upstream match.
- Optional _<branch-pattern>_ arguments limit which local branches
- are considered, e.g. `git branch --delete-merged 'origin/*'
- 'topic-*'`.
++--delete-integrated <pattern>::
++ Like --delete-merged, but also delete a branch whose changes
++ the upstream already has even though its commits are not there,
++ as happens after a squash or rebase merge. Such a branch is
++ deleted when some upstream commit since the fork point has its
++ version of every file it changed, even if those changes were
++ later reverted, and the message names the first such commit.
++ It does not matter who made that commit. The same rules for
++ which branches are kept apply as for --delete-merged.
++
+ --dry-run::
+- With --delete-merged, print which branches would be
+- deleted and exit without touching any ref. Useful for
+- sanity-checking a wide pattern like 'origin/*' before
+- committing to the deletion.
++ With --delete-merged or --delete-integrated, print which
++ branches would be deleted and exit without touching any ref.
++ Useful for sanity-checking a wide pattern like 'origin/*'
++ before committing to the deletion.
+
+ -v::
+ -vv::
## builtin/branch.c ##
@@
@@ builtin/branch.c
static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
-@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
- }
+@@ builtin/branch.c: enum delete_branch_flags {
+ DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
+ DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
+ DELETE_BRANCH_DRY_RUN = (1 << 4),
++ DELETE_BRANCH_INTEGRATED = (1 << 5),
+ };
- static int delete_branches(int argc, const char **argv, int kinds,
-- unsigned int flags)
-+ unsigned int flags, struct strmap *landed_commits)
- {
- struct commit *head_rev = NULL;
- struct object_id oid;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- }
-
- if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
-+ !(landed_commits &&
-+ strmap_contains(landed_commits, bname.buf)) &&
- check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- flags)) {
- if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- for_each_string_list_item(item, &refs_to_delete) {
- char *describe_ref = item->util;
- char *name = item->string;
-+ struct commit *landed = landed_commits ?
-+ strmap_get(landed_commits, name + branch_name_pos) : NULL;
-+ const char *landed_abbrev = landed ?
-+ repo_find_unique_abbrev(the_repository,
-+ &landed->object.oid,
-+ DEFAULT_ABBREV) : NULL;
-+
- if (flags & DELETE_BRANCH_DRY_RUN) {
-- if (!(flags & DELETE_BRANCH_QUIET))
-+ if (flags & DELETE_BRANCH_QUIET)
-+ ;
-+ else if (landed)
-+ printf(_("Would delete branch %s (was %s, landed as %s).\n"),
-+ name + branch_name_pos, describe_ref,
-+ landed_abbrev);
-+ else
- printf(remote_branch
- ? _("Would delete remote-tracking branch %s (was %s).\n")
- : _("Would delete branch %s (was %s).\n"),
- name + branch_name_pos, describe_ref);
- } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
- char *refname = name + branch_name_pos;
-- if (!(flags & DELETE_BRANCH_QUIET))
-+ if (flags & DELETE_BRANCH_QUIET)
-+ ;
-+ else if (landed)
-+ printf(_("Deleted branch %s (was %s, landed as %s).\n"),
-+ refname, describe_ref, landed_abbrev);
-+ else
- printf(remote_branch
- ? _("Deleted remote-tracking branch %s (was %s).\n")
- : _("Deleted branch %s (was %s).\n"),
+ static int check_branch_commit(const char *branchname, const char *refname, @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, return ret;
}
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
- FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED)) {
-+ struct commit *rev = lookup_commit_reference(
-+ the_repository, &candidates.items[i]->objectname);
-+ struct commit *upstream = lookup_commit_reference_by_name(
-+ upstream_refname);
++ struct commit *rev, *upstream;
+
++ if (!(flags & DELETE_BRANCH_INTEGRATED))
++ continue;
++ rev = lookup_commit_reference(
++ the_repository, &candidates.items[i]->objectname);
++ upstream = lookup_commit_reference_by_name(upstream_refname);
+ if (!rev || !upstream)
+ continue;
+ ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED | DELETE_BRANCH_NO_HEAD_FALLBACK |
-- flags);
+- flags, NULL);
+ flags, &landed_commits);
if (!ret && !(flags & DELETE_BRANCH_DRY_RUN))
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
strset_clear(&deletable_branch_names); ref_array_clear(&candidates);
@@ builtin/branch.c: int cmd_branch(int argc,
- die(_("branch name required"));
- ret = delete_branches(argc, argv, filter.kind,
- (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
-- (quiet ? DELETE_BRANCH_QUIET : 0));
-+ (quiet ? DELETE_BRANCH_QUIET : 0), NULL);
+ int delete = 0, rename = 0, copy = 0, list = 0,
+ unset_upstream = 0, show_current = 0, edit_description = 0;
+ struct strvec delete_merged = STRVEC_INIT;
++ struct strvec delete_integrated = STRVEC_INIT;
+ int dry_run = 0;
+ const char *new_upstream = NULL;
+ int noncreate_actions = 0;
+@@ builtin/branch.c: int cmd_branch(int argc,
+ OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("pattern"),
+ N_("delete merged branches whose upstream matches <pattern> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_strvec),
++ OPT_CALLBACK_F(0, "delete-integrated", &delete_integrated, N_("pattern"),
++ N_("like --delete-merged, but also delete branches whose changes landed upstream (repeatable)"),
++ PARSE_OPT_NONEG, parse_opt_strvec),
+ OPT_BOOL(0, "dry-run", &dry_run,
+- N_("with --delete-merged, only print which branches would be deleted")),
++ N_("with --delete-merged or --delete-integrated, only print which branches would be deleted")),
+ OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
+ OPT_MERGED(&filter, N_("print only branches that are merged")),
+ OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
+@@ builtin/branch.c: int cmd_branch(int argc,
+
+ if (!delete && !rename && !copy && !edit_description && !new_upstream &&
+ !show_current && !unset_upstream && !delete_merged.nr &&
+- argc == 0)
++ !delete_integrated.nr && argc == 0)
+ list = 1;
+
+ if (filter.with_commit || filter.no_commit ||
+@@ builtin/branch.c: int cmd_branch(int argc,
+
+ noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
+ !!show_current + !!list + !!edit_description +
+- !!unset_upstream + !!delete_merged.nr;
++ !!unset_upstream + !!delete_merged.nr +
++ !!delete_integrated.nr;
+ if (noncreate_actions > 1)
+ usage_with_options(builtin_branch_usage, options);
+
+- if (dry_run && !delete_merged.nr)
+- die(_("--dry-run requires --delete-merged"));
++ if (dry_run && !delete_merged.nr && !delete_integrated.nr)
++ die(_("--dry-run requires --delete-merged or --delete-integrated"));
+
+ if (recurse_submodules_explicit) {
+ if (!submodule_propagate_branches)
+@@ builtin/branch.c: int cmd_branch(int argc,
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
goto out;
- } else if (delete_merged.nr) {
- ret = delete_merged_branches(&delete_merged, argv,
++ } else if (delete_integrated.nr) {
++ ret = delete_merged_branches(&delete_integrated, argv,
++ (quiet ? DELETE_BRANCH_QUIET : 0) |
++ (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
++ DELETE_BRANCH_INTEGRATED);
++ goto out;
+ } else if (show_current) {
+ print_current_branch_name();
+ ret = 0;
+@@ builtin/branch.c: int cmd_branch(int argc,
+
+ out:
+ strvec_clear(&delete_merged);
++ strvec_clear(&delete_integrated);
+ string_list_clear(&sorting_options, 0);
+ return ret;
+ }
## t/t3200-branch.sh ##
+@@ t/t3200-branch.sh: check_branches () {
+ test_cmp expect actual
+ }
+
++test_expect_success '--delete-merged does not delete unrelated branches' '
++ setup_repo_for_delete_merged &&
++ (
++ cd repo &&
++ git checkout -b unrelated-topic --track origin/next &&
++ test_write_lines 1 2 3 4 >file &&
++ git add file &&
++ git commit -m "add file" &&
++ git push origin unrelated-topic &&
++ git checkout --detach
++ ) &&
++ (
++ cd upstream &&
++ git checkout next &&
++ test_write_lines 1 2 3 4 >file &&
++ test_write_lines 5 6 7 >elif &&
++ git add file elif &&
++ git commit --author="unrelated user <[email protected]>" \
++ -m "unrelated commit adding an identical file" &&
++ git checkout main
++ ) &&
++ (
++ cd repo &&
++ git fetch origin &&
++ git branch --delete-merged origin/next >actual 2>&1 &&
++ test_must_be_empty actual &&
++ check_branches <<-\EOF
++ main
++ unrelated-topic
++ EOF
++ )
++'
++
+ test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '
+ setup_repo_for_delete_merged &&
+ (
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected merged branches' '
)
'
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ )
+}
+
-+test_expect_success '--delete-merged deletes a rebase merged branch' ' ++test_expect_success '--delete-integrated deletes a rebase merged branch' '
+ setup_repo_for_delete_merged &&
+ push_topic rebased rebased-one rebased-two &&
+ (
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ git fetch origin &&
+ sha=$(git rev-parse --short rebased) &&
+
-+ git branch --delete-merged origin/next >actual 2>&1 &&
++ git branch --delete-integrated origin/next >actual 2>&1 &&
+ echo "Deleted branch rebased (was $sha, landed as $landed)." >expect &&
+ test_cmp expect actual &&
+
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ )
+'
+
-+test_expect_success '--delete-merged deletes a squash merged branch' ' ++test_expect_success '--delete-integrated deletes a squash merged branch' '
+ setup_repo_for_delete_merged &&
+ push_topic squashed squashed-one squashed-two &&
+ push_topic partial lands-upstream stays-local &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ git fetch origin &&
+ sha=$(git rev-parse --short squashed) &&
+
-+ git branch --delete-merged origin/next >actual 2>&1 &&
++ git branch --delete-integrated origin/next >actual 2>&1 &&
+ echo "Deleted branch squashed (was $sha, landed as $squash)." >expect &&
+ test_cmp expect actual &&
+
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ )
+'
+
-+test_expect_success '--delete-merged deletes a squash merged branch that was reverted' ' ++test_expect_success '--delete-integrated deletes a squash merged branch that was reverted' '
+ setup_repo_for_delete_merged &&
+ push_topic reverted reverted-work &&
+ squash_merge_upstream reverted &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ git fetch origin &&
+ sha=$(git rev-parse --short reverted) &&
+
-+ git branch --delete-merged origin/next >actual 2>&1 &&
++ git branch --delete-integrated origin/next >actual 2>&1 &&
+ echo "Deleted branch reverted (was $sha, landed as $squash)." >expect &&
+ test_cmp expect actual &&
+
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ )
+'
+
-+test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' ' ++test_expect_success '--delete-integrated reports a merge plainly next to an unmerged branch' '
+ setup_repo_for_delete_merged &&
+ push_topic a-unmerged unmerged-work &&
+ push_topic b-merged merged-work &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
+ git fetch origin &&
+ sha=$(git rev-parse --short b-merged) &&
+
-+ git branch --delete-merged origin/next >actual 2>&1 &&
++ git branch --delete-integrated origin/next >actual 2>&1 &&
+ echo "Deleted branch b-merged (was $sha)." >expect &&
+ test_cmp expect actual &&
+
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
test_expect_success '--delete-merged keeps main despite a different default push remote' '
setup_repo_for_delete_merged &&
create_merged_branch on-next &&
+@@ t/t3200-branch.sh: test_expect_success '--dry-run without --delete-merged is rejected' '
+ test_grep "requires --delete-merged" err
+ '
+
++test_expect_success '--delete-integrated cannot be combined with --delete-merged' '
++ test_must_fail git -C forked branch --delete-merged origin/next \
++ --delete-integrated origin/next
++'
++
+ test_done
3: 2befb182dc ! 4: 19a9630c13 branch: add --squashed to find squash merged branches
@@ Metadata
Author: Harald Nordgren <[email protected]>
## Commit message ##
- branch: add --squashed to find squash merged branches
+ branch: let --delete-integrated find branches merged onto changed files
A branch that upstream integrated with
@@ Commit message
git commit
after upstream had also changed the files the branch touches is still
- kept by "git branch --delete-merged". The squash commit has neither the
- branch's version of those files nor the fork point's, so the previous
- check cannot see that the work landed. This happens all the time on
- hosting services, where the "Squash and merge" button on GitHub lands
- pull requests this way while the main branch keeps moving. A rebase
- merge onto such changes looks the same.
+ kept by "git branch --delete-integrated". The squash commit has neither
+ the branch's version of those files nor the fork point's, so the
+ previous check cannot see that the work landed. This happens all the
+ time on hosting services, where the "Squash and merge" button on GitHub
+ lands pull requests this way while the main branch keeps moving. A
+ rebase merge onto such changes looks the same.
- Add --squashed, which for each such file merges the branch's version
- into the upstream one and treats the change as landed when that leaves
- the upstream version unchanged. This has to read the files, so it is
- slower, and in a partial clone it may download blobs. It is therefore
- only done when asked for.
+ For each such file, merge the branch's version into the upstream one
+ and treat the change as landed when that leaves the upstream version
+ unchanged. This has to read the files, so in a partial clone it may
+ download blobs.
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
- git branch (-c|-C) [<old-branch>] <new-branch>
- git branch (-d|-D) [-r] <branch-name>...
- git branch --edit-description [<branch-name>]
--git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...] -+git branch [--dry-run] [--squashed] (--delete-merged <pattern>)... [<branch-pattern>...] -
- DESCRIPTION
- -----------
@@ Documentation/git-branch.adoc: remove it anyway.
- sanity-checking a wide pattern like 'origin/*' before
- committing to the deletion.
-
-+--squashed::
-+ With --delete-merged, also delete a branch that was squash or
-+ rebase merged after upstream changed the same files. Upstream
-+ then has neither the branch's version of those files nor the fork
-+ point's, so the branch's version is merged into upstream's, and
-+ the file counts as landed when that leaves upstream's version
-+ unchanged. This reads those files, so it takes longer and may
-+ download blobs in a partial clone.
-+
- -v::
- -vv::
- --verbose::
+ Like --delete-merged, but also delete a branch whose changes
+ the upstream already has even though its commits are not there,
+ as happens after a squash or rebase merge. Such a branch is
+- deleted when some upstream commit since the fork point has its
+- version of every file it changed, even if those changes were
+- later reverted, and the message names the first such commit.
+- It does not matter who made that commit. The same rules for
+- which branches are kept apply as for --delete-merged.
++ deleted when some upstream commit since the fork point has all
++ of its changes, even if they were later reverted, and the
++ message names the first such commit. It does not matter who made
++ that commit. A file counts when the commit has the branch's
++ version of it, or when merging the branch's version into the
++ commit's leaves it unchanged, which reads the file and may
++ download blobs in a partial clone. The same rules for which
++ branches are kept apply as for --delete-merged.
+
+ --dry-run::
+ With --delete-merged or --delete-integrated, print which
## builtin/branch.c ##
@@
@@ builtin/branch.c
static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
-@@ builtin/branch.c: enum delete_branch_flags {
- DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
- DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
- DELETE_BRANCH_DRY_RUN = (1 << 4),
-+ DELETE_BRANCH_SQUASHED = (1 << 5),
- };
-
- static int check_branch_commit(const char *branchname, const char *refname, @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, return ret;
}
@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear
}
static int changes_landed(struct landing_search *search,
-- struct commit *commit)
-+ struct commit *commit, unsigned int flags)
+ struct commit *commit)
{
- for (size_t i = 0; i < search->changes_nr; i++)
- if (!change_landed(&search->changes[i], commit))
@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear
+ struct branch_change *change = &search->changes[i];
+
+ change->state = change_state_at(change, commit);
-+ if (change->state == CHANGE_MISSING ||
-+ (change->state == CHANGE_NEEDS_MERGE &&
-+ !(flags & DELETE_BRANCH_SQUASHED)))
++ if (change->state == CHANGE_MISSING)
return 0;
+ }
+ for (size_t i = 0; i < search->changes_nr; i++) {
@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear
return 1;
}
-@@ builtin/branch.c: static int touches_changes(struct string_list *paths,
-
- static struct commit *find_landed_commit(struct landing_search *search,
- struct changed_paths *cache,
-- struct diff_options *opt)
-+ struct diff_options *opt,
-+ unsigned int flags)
- {
- struct commit *commit, *landed = NULL;
- struct strvec args = STRVEC_INIT;
-@@ builtin/branch.c: static struct commit *find_landed_commit(struct landing_search *search,
- while (!landed && (commit = get_revision(&revs)))
- if (touches_changes(changed_paths_of(cache, opt, commit),
- search) &&
-- changes_landed(search, commit))
-+ changes_landed(search, commit, flags))
- landed = commit;
- release_revisions(&revs);
- clear_commit_marks(search->upstream, ALL_REV_FLAGS);
-@@ builtin/branch.c: static struct commit *find_landed_commit(struct landing_search *search, -
- static void find_landed_commits(struct landing_search *searches,
- size_t searches_nr,
-- struct strmap *landed_commits)
-+ struct strmap *landed_commits,
-+ unsigned int flags)
- {
- struct changed_paths cache;
- struct diff_options opt;
-@@ builtin/branch.c: static void find_landed_commits(struct landing_search *searches, -
- if (!searches[i].changes_nr)
- continue;
-- landed = find_landed_commit(&searches[i], &cache, &opt);
-+ landed = find_landed_commit(&searches[i], &cache, &opt, flags);
- if (landed)
- strmap_put(landed_commits, searches[i].branch_name,
- landed);
-@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
- strset_add(&deletable_branch_names, branch_name);
- }
-
-- find_landed_commits(searches, searches_nr, &landed_commits);
-+ find_landed_commits(searches, searches_nr, &landed_commits, flags);
- for (size_t i = 0; i < searches_nr; i++) {
- const char *branch_name = searches[i].branch_name;
-
-@@ builtin/branch.c: int cmd_branch(int argc,
- unset_upstream = 0, show_current = 0, edit_description = 0;
- struct strvec delete_merged = STRVEC_INIT;
- int dry_run = 0;
-+ int squashed = 0;
- const char *new_upstream = NULL;
- int noncreate_actions = 0;
- /* possible options */
-@@ builtin/branch.c: int cmd_branch(int argc,
- PARSE_OPT_NONEG, parse_opt_strvec),
- OPT_BOOL(0, "dry-run", &dry_run,
- N_("with --delete-merged, only print which branches would be deleted")),
-+ OPT_BOOL(0, "squashed", &squashed,
-+ N_("with --delete-merged, also find branches merged after upstream changed their files")),
- OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
- OPT_MERGED(&filter, N_("print only branches that are merged")),
- OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
-@@ builtin/branch.c: int cmd_branch(int argc,
-
- if (dry_run && !delete_merged.nr)
- die(_("--dry-run requires --delete-merged"));
-+ if (squashed && !delete_merged.nr)
-+ die(_("--squashed requires --delete-merged"));
-
- if (recurse_submodules_explicit) {
- if (!submodule_propagate_branches)
-@@ builtin/branch.c: int cmd_branch(int argc,
- } else if (delete_merged.nr) {
- ret = delete_merged_branches(&delete_merged, argv,
- (quiet ? DELETE_BRANCH_QUIET : 0) |
-- (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
-+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
-+ (squashed ? DELETE_BRANCH_SQUASHED : 0));
- goto out;
- } else if (show_current) {
- print_current_branch_name();
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged branch that was rev +@@ t/t3200-branch.sh: test_expect_success '--delete-integrated deletes a squash merged branch that was
)
'
-+test_expect_success '--delete-merged --squashed deletes a squash merged branch after upstream changed the same file' ' ++test_expect_success '--delete-integrated deletes a squash merged branch after upstream changed the same file' '
+ (
+ cd upstream &&
+ git checkout next &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged
+ git fetch origin &&
+ sha=$(git rev-parse --short edits-shared) &&
+
-+ git branch --delete-merged origin/next >actual 2>&1 &&
-+ test_must_be_empty actual &&
-+ check_branches <<-\EOF &&
-+ edits-shared
-+ main
-+ EOF
-+
-+ git branch --delete-merged origin/next --squashed \
-+ >actual 2>&1 &&
++ git branch --delete-integrated origin/next >actual 2>&1 &&
+ echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
+ test_cmp expect actual &&
+
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged
+ )
+'
+
- test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
+ test_expect_success '--delete-integrated reports a merge plainly next to an unmerged branch' ' setup_repo_for_delete_merged &&
push_topic a-unmerged unmerged-work &&
-@@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" '
- )
- '
-
-+test_expect_success '--squashed without --delete-merged is rejected' '
-+ test_must_fail git -C forked branch --squashed 2>err &&
-+ test_grep "requires --delete-merged" err
-+'
-+
- test_expect_success '--dry-run without --delete-merged is rejected' '
- test_must_fail git -C forked branch --dry-run 2>err &&
- test_grep "requires --delete-merged" err
Signature · 1 line
gitgitgadget
PATCH v4 1/4 branch: move the deleteMerged opt-out check into a helper
From: Harald Nordgren <[email protected]>
A later commit adds "git branch --delete-integrated", which deletes branches whose tips are not reachable from their upstream. Those must honor branch.<name>.deleteMerged=false as well. Move the check into a helper so it can be used in both places.
No change in behavior.
--- builtin/branch.c | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-)
builtin/branch.c +19 -13
@@ -824,6 +824,23 @@ static int branch_pushes_to_upstream(struct branch *branch,return ret; } +static bool branch_opted_out (struct strbuf *key,const char *branch_name, +unsigned int flags) +{ +int opt_out; + +strbuf_reset (key); +strbuf_addf (key,"branch.%s.deletemerged" , branch_name); +if (repo_config_get_bool (the_repository, key->buf, &opt_out) || + opt_out) +return false; +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +return true; +} +static int delete_merged_branches (const struct strvec *upstreams,const char **argv,unsigned int flags) {
@@ -852,7 +869,6 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; -int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -870,18 +886,8 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; -strbuf_reset (&key); -strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); -if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && - !opt_out) { -if (!(flags &DELETE_BRANCH_QUIET )) -fprintf (stderr, -_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), - branch_name, branch_name); -continue ; - } - -strset_add (&deletable_branch_names, branch_name); +if (!branch_opted_out (&key, branch_name, flags)) +strset_add (&deletable_branch_names, branch_name); } protect_stacked_branch_bases(refs, &deletable_branch_names,
Signature · 1 line
gitgitgadget
PATCH v4 2/4 branch: let delete_branches() name where a branch landed
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A later commit lets "git branch" delete branches whose commits are not in their upstream but whose changes are, such as after a squash merge. Their tips are not reachable from the upstream, so the merge check in delete_branches() would refuse to delete them, and the output should say where their changes went.
Let delete_branches() take a map from branch names to the upstream commit where each branch landed. A branch in the map skips the merge check and is reported as
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
No caller passes a map yet, so there is no change in behavior.
--- builtin/branch.c | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-)
builtin/branch.c +25 -5
@@ -236,7 +236,7 @@ static void delete_branch_config(const char *branchname) }static int delete_branches (int argc,const char **argv,int kinds, -unsigned int flags) +unsigned int flags,struct strmap *landed_commits) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -334,6 +334,8 @@ static int delete_branches(int argc, const char **argv, int kinds, }if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) && + !(landed_commits && +strmap_contains (landed_commits, bname.buf)) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) {if (!(flags &DELETE_BRANCH_SKIP_UNMERGED ))
@@ -357,15 +359,33 @@ static int delete_branches(int argc, const char **argv, int kinds,for_each_string_list_item (item, &refs_to_delete) {char *describe_ref = item->util;char *name = item->string; +struct commit *landed = landed_commits ? +strmap_get (landed_commits, name + branch_name_pos) :NULL ; +const char *landed_abbrev = landed ? +repo_find_unique_abbrev (the_repository, + &landed->object.oid, +DEFAULT_ABBREV ) :NULL ; +if (flags &DELETE_BRANCH_DRY_RUN ) { -if (!(flags &DELETE_BRANCH_QUIET )) +if (flags &DELETE_BRANCH_QUIET ) + ; +else if (landed) +printf (_ ("Would delete branch %s (was %s, landed as %s).\n" ), + name + branch_name_pos, describe_ref, + landed_abbrev); +else printf (remote_branch ?_ ("Would delete remote-tracking branch %s (was %s).\n" ) :_ ("Would delete branch %s (was %s).\n" ), name + branch_name_pos, describe_ref); }else if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!(flags &DELETE_BRANCH_QUIET )) +if (flags &DELETE_BRANCH_QUIET ) + ; +else if (landed) +printf (_ ("Deleted branch %s (was %s, landed as %s).\n" ), + refname, describe_ref, landed_abbrev); +else printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -901,7 +921,7 @@ static int delete_merged_branches(const struct strvec *upstreams,FILTER_REFS_BRANCHES ,DELETE_BRANCH_SKIP_UNMERGED |DELETE_BRANCH_NO_HEAD_FALLBACK | - flags); + flags, NULL);if (!ret && !(flags &DELETE_BRANCH_DRY_RUN )) clear_deleted_upstreams(&protected_branch_names,
@@ -1141,7 +1161,7 @@ int cmd_branch(int argc,die (_ ("branch name required" )); ret =delete_branches (argc, argv, filter.kind, (delete >1 ?DELETE_BRANCH_FORCE :0 ) | - (quiet ?DELETE_BRANCH_QUIET :0 )); + (quiet ?DELETE_BRANCH_QUIET :0 ),NULL ); goto out; }else if (delete_merged.nr) { ret = delete_merged_branches(&delete_merged, argv,
Signature · 1 line
gitgitgadget
PATCH v4 3/4 branch: add --delete-integrated to delete rebase and squash merged branches
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git rebase main topic
git checkout main
git merge --ff-only topic
or with
git merge --squash topic
git commitis never deleted by "git branch --delete-merged". This is what the "Rebase and merge" and "Squash and merge" buttons on GitHub do. The upstream holds rewritten copies of the branch's work, so its tip is not reachable from it and it looks unmerged forever.
Add --delete-integrated, which does what --delete-merged does and also deletes a branch when some upstream commit since the fork point has the branch's version of every file the branch changed. That commit is named in the output so the user can see where the work went:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
The first such commit is used, so the branch is deleted even if upstream later reverted or reworked the changes. Nothing is lost, since that commit keeps them in the upstream history. Who made that commit does not matter, since the local branch is no longer needed either way. A branch whose changes only partly landed is kept.
--delete-merged keeps deleting only branches whose own commits are in the upstream, so it never removes a branch because somebody else made the same change.
The check never reads file contents, so it stays cheap in a partial clone.
--- Documentation/config/branch.adoc | 8 +- Documentation/git-branch.adoc | 19 ++- builtin/branch.c | 248 ++++++++++++++++++++++++++++++- t/t3200-branch.sh | 175 ++++++++++++++++++++++ 4 files changed, 435 insertions(+), 15 deletions(-)
Documentation/config/branch.adoc +4 -4
@@ -106,7 +106,7 @@ for details). `branch.<name>.deleteMerged`:: If set to `false`, branch _<name>_ is exempt from - `git branch --delete-merged`. Useful for a topic branch you - intend to develop further after an initial round has been - merged upstream. Defaults to true. Explicit deletion via - `git branch -d` is unaffected. + `git branch --delete-merged` and `--delete-integrated`. Useful + for a topic branch you intend to develop further after an + initial round has been merged upstream. Defaults to true. + Explicit deletion via `git branch -d` is unaffected.
Documentation/git-branch.adoc +15 -4
@@ -26,6 +26,7 @@ git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...] +git branch [--dry-run] (--delete-integrated <pattern>)... [<branch-pattern>...] DESCRIPTION -----------
@@ -235,11 +236,21 @@ A branch whose work has not yet been merged into its upstream is silently skipped. Delete it with `git branch -D` if you want to remove it anyway. +`--delete-integrated <pattern>`:: + Like `--delete-merged`, but also delete a branch whose changes + the upstream already has even though its commits are not there, + as happens after a squash or rebase merge. Such a branch is + deleted when some upstream commit since the fork point has its + version of every file it changed, even if those changes were + later reverted, and the message names the first such commit. + It does not matter who made that commit. The same rules for + which branches are kept apply as for `--delete-merged`. + `--dry-run`:: - With `--delete-merged`, print which branches would be - deleted and exit without touching any ref. Useful for - sanity-checking a wide pattern like `'origin/*'` before - committing to the deletion. + With `--delete-merged` or `--delete-integrated`, print which + branches would be deleted and exit without touching any ref. + Useful for sanity-checking a wide pattern like `'origin/*'` + before committing to the deletion. `-v`:: `-vv`::
builtin/branch.c +241 -7
336 lines not on this page · raw message
t/t3200-branch.sh +175 -0
191 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v4 4/4 branch: let --delete-integrated find branches merged onto changed files
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git merge --squash topic
git commitafter upstream had also changed the files the branch touches is still kept by "git branch --delete-integrated". The squash commit has neither the branch's version of those files nor the fork point's, so the previous check cannot see that the work landed. This happens all the time on hosting services, where the "Squash and merge" button on GitHub lands pull requests this way while the main branch keeps moving. A rebase merge onto such changes looks the same.
For each such file, merge the branch's version into the upstream one and treat the change as landed when that leaves the upstream version unchanged. This has to read the files, so in a partial clone it may download blobs.
--- Documentation/git-branch.adoc | 13 ++++--- builtin/branch.c | 71 ++++++++++++++++++++++++++++++----- t/t3200-branch.sh | 42 +++++++++++++++++++++ 3 files changed, 111 insertions(+), 15 deletions(-)
Documentation/git-branch.adoc +8 -5
@@ -240,11 +240,14 @@ remove it anyway. Like `--delete-merged`, but also delete a branch whose changes the upstream already has even though its commits are not there, as happens after a squash or rebase merge. Such a branch is - deleted when some upstream commit since the fork point has its - version of every file it changed, even if those changes were - later reverted, and the message names the first such commit. - It does not matter who made that commit. The same rules for - which branches are kept apply as for `--delete-merged`. + deleted when some upstream commit since the fork point has all + of its changes, even if they were later reverted, and the + message names the first such commit. It does not matter who made + that commit. A file counts when the commit has the branch's + version of it, or when merging the branch's version into the + commit's leaves it unchanged, which reads the file and may + download blobs in a partial clone. The same rules for which + branches are kept apply as for `--delete-merged`. `--dry-run`:: With `--delete-merged` or `--delete-integrated`, print which
builtin/branch.c +61 -10
110 lines not on this page · raw message
t/t3200-branch.sh +42 -0
48 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Quoted 4 lines · Harald Nordgren via GitGitGadget
Changes in v4:
* --delete-merged works as it does on master again: it only deletes branches whose tip is reachable from their upstream
Thanks. I ran v4 (19a9630c13) on the same generated repositories as before, against master (7e34d8174d). t3200 passes.
--delete-merged now gives exactly the same result as master on every case, including the branch that was never merged but whose change another author made upstream. That one is kept.
--delete-integrated deletes the squash merges, the GitHub-style squash, the rebase merge, the rename, the squash that was later reverted, and a squash that landed after upstream changed another line of the same file. "landed as" names the correct commit each time. It keeps a branch whose work only partly landed, a cherry-pick whose conflict was resolved differently, and the never-merged branches with an empty or net-zero diff. As the documentation says, it deletes the branch whose change another author made, which is fine now that the user has to ask for it.
Three small things:
* "git branch -h" does not show --delete-integrated in the usage lines, although the SYNOPSIS in git-branch.adoc has it.
* Giving both --delete-merged and --delete-integrated dies with the full usage text. A short "cannot be used together" message would be clearer.
* The cover letter still says "--squashed" in the second paragraph, and "--delete-irrelevant" in the list of changes.
A branch checked out in a worktree is still skipped with no message, with both options.
Thanks,
Jasper
Quoted 2 lines
* "git branch -h" does not show --delete-integrated in the usage lines, although the SYNOPSIS in git-branch.adoc has it.
Good point.
Quoted 3 lines
* Giving both --delete-merged and --delete-integrated dies with the full usage text. A short "cannot be used together" message would be clearer.
Thanks, I will fix this.
Harald
v5 cover branch: let --delete-merged find squash merged branches
In reply to Harald Nordgren via GitGitGadgetBranches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
Changes in v5:
* git branch -h lists --delete-integrated in the usage lines
* Giving both --delete-merged and --delete-integrated dies with a short "cannot be used together" message instead of the full usage text
Changes in v4:
* --delete-merged works as it does on master again: it only deletes branches whose tip is reachable from their upstream
* New --delete-integrated <pattern> also deletes branches that were rebase or squash merged, including those squashed after upstream changed the same files. It replaces --squashed
* New prep commit lets delete_branches() take a map of where each branch landed, so "landed as" is printed there
* Added Junio's test that --delete-merged keeps a branch when an unrelated upstream commit adds an identical file
* --dry-run works with either option, and the two options can't be combined
Changes in v3:
* Split into multiple commits, one for rebase merges and one for squash merges.
* By default a branch counts as merged when an upstream commit has its exact version of every file it changed. This never reads file contents, so it stays cheap in a partial clone. It finds rebase merges, and squash merges where upstream left those files alone.
* Replace --no-squashed with an opt-in --squashed, which also finds branches merged after upstream changed the same files by merging those files.
* Add a test for a rebase merge, and check that a squash onto changed files is kept without --squashed and deleted with it.
Changes in v2:
* Diff each upstream commit once and share the result between all branches, faster than v1.
* Fix a branch merged with a merge commit being reported as "landed as" the merge when another branch was searched first, and add a test for it. Show the topic commit itself in "landed as" for work that arrived through a merge.
* Use git merge --squash as the example in the commit message and mention GitHub's squash and rebase buttons.
* Add --no-squashed to skip the squash search.
Harald Nordgren (4):
branch: move the deleteMerged opt-out check into a helper
branch: let delete_branches() name where a branch landed
branch: add --delete-integrated to delete rebase and squash merged
branches
branch: let --delete-integrated find branches merged onto changed
files
Documentation/config/branch.adoc | 8 +- Documentation/git-branch.adoc | 22 +- builtin/branch.c | 360 +++++++++++++++++++++++++++++-- t/t3200-branch.sh | 218 +++++++++++++++++++ 4 files changed, 578 insertions(+), 30 deletions(-)
base-commit: 8d1b215c01512b9658d4525c54ff9b9245f9a7e8
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v5
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v5
Pull-Request: https://github.com/git/git/pull/2425
Range-diff vs v4:
1: 1d01085b75 = 1: 090aec5725 branch: move the deleteMerged opt-out check into a helper
2: 592562d7e7 = 2: 24180bf4a2 branch: let delete_branches() name where a branch landed
3: deb1e649cf ! 3: bf30f57ceb branch: add --delete-integrated to delete rebase and squash merged branches
@@ builtin/branch.c
static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
+@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
+ N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] (--delete-merged <pattern>)... "
+ "[<branch-pattern>...]"),
++ N_("git branch [<options>] (--delete-integrated <pattern>)... "
++ "[<branch-pattern>...]"),
+ NULL
+ };
+
@@ builtin/branch.c: enum delete_branch_flags {
DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
@@ builtin/branch.c: int cmd_branch(int argc,
if (filter.with_commit || filter.no_commit ||
@@ builtin/branch.c: int cmd_branch(int argc,
+ filter.points_at.nr || filter.forked.nr)
+ list = 1;
++ die_for_incompatible_opt2(!!delete_merged.nr, "--delete-merged",
++ !!delete_integrated.nr, "--delete-integrated");
++
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream + !!delete_merged.nr;
@@ t/t3200-branch.sh: test_expect_success '--dry-run without --delete-merged is rej
+test_expect_success '--delete-integrated cannot be combined with --delete-merged' '
+ test_must_fail git -C forked branch --delete-merged origin/next \
-+ --delete-integrated origin/next
++ --delete-integrated origin/next 2>err &&
++ test_grep "cannot be used together" err
+'
+
test_done 4: 19a9630c13 = 4: 9443055242 branch: let --delete-integrated find branches merged onto changed files
Signature · 1 line
gitgitgadget
PATCH v5 1/4 branch: move the deleteMerged opt-out check into a helper
From: Harald Nordgren <[email protected]>
A later commit adds "git branch --delete-integrated", which deletes branches whose tips are not reachable from their upstream. Those must honor branch.<name>.deleteMerged=false as well. Move the check into a helper so it can be used in both places.
No change in behavior.
--- builtin/branch.c | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-)
builtin/branch.c +19 -13
@@ -824,6 +824,23 @@ static int branch_pushes_to_upstream(struct branch *branch,return ret; } +static bool branch_opted_out (struct strbuf *key,const char *branch_name, +unsigned int flags) +{ +int opt_out; + +strbuf_reset (key); +strbuf_addf (key,"branch.%s.deletemerged" , branch_name); +if (repo_config_get_bool (the_repository, key->buf, &opt_out) || + opt_out) +return false; +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +return true; +} +static int delete_merged_branches (const struct strvec *upstreams,const char **argv,unsigned int flags) {
@@ -852,7 +869,6 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; -int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -870,18 +886,8 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; -strbuf_reset (&key); -strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); -if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && - !opt_out) { -if (!(flags &DELETE_BRANCH_QUIET )) -fprintf (stderr, -_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), - branch_name, branch_name); -continue ; - } - -strset_add (&deletable_branch_names, branch_name); +if (!branch_opted_out (&key, branch_name, flags)) +strset_add (&deletable_branch_names, branch_name); } protect_stacked_branch_bases(refs, &deletable_branch_names,
Signature · 1 line
gitgitgadget
PATCH v5 2/4 branch: let delete_branches() name where a branch landed
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A later commit lets "git branch" delete branches whose commits are not in their upstream but whose changes are, such as after a squash merge. Their tips are not reachable from the upstream, so the merge check in delete_branches() would refuse to delete them, and the output should say where their changes went.
Let delete_branches() take a map from branch names to the upstream commit where each branch landed. A branch in the map skips the merge check and is reported as
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
No caller passes a map yet, so there is no change in behavior.
--- builtin/branch.c | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-)
builtin/branch.c +25 -5
@@ -236,7 +236,7 @@ static void delete_branch_config(const char *branchname) }static int delete_branches (int argc,const char **argv,int kinds, -unsigned int flags) +unsigned int flags,struct strmap *landed_commits) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -334,6 +334,8 @@ static int delete_branches(int argc, const char **argv, int kinds, }if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) && + !(landed_commits && +strmap_contains (landed_commits, bname.buf)) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) {if (!(flags &DELETE_BRANCH_SKIP_UNMERGED ))
@@ -357,15 +359,33 @@ static int delete_branches(int argc, const char **argv, int kinds,for_each_string_list_item (item, &refs_to_delete) {char *describe_ref = item->util;char *name = item->string; +struct commit *landed = landed_commits ? +strmap_get (landed_commits, name + branch_name_pos) :NULL ; +const char *landed_abbrev = landed ? +repo_find_unique_abbrev (the_repository, + &landed->object.oid, +DEFAULT_ABBREV ) :NULL ; +if (flags &DELETE_BRANCH_DRY_RUN ) { -if (!(flags &DELETE_BRANCH_QUIET )) +if (flags &DELETE_BRANCH_QUIET ) + ; +else if (landed) +printf (_ ("Would delete branch %s (was %s, landed as %s).\n" ), + name + branch_name_pos, describe_ref, + landed_abbrev); +else printf (remote_branch ?_ ("Would delete remote-tracking branch %s (was %s).\n" ) :_ ("Would delete branch %s (was %s).\n" ), name + branch_name_pos, describe_ref); }else if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!(flags &DELETE_BRANCH_QUIET )) +if (flags &DELETE_BRANCH_QUIET ) + ; +else if (landed) +printf (_ ("Deleted branch %s (was %s, landed as %s).\n" ), + refname, describe_ref, landed_abbrev); +else printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -901,7 +921,7 @@ static int delete_merged_branches(const struct strvec *upstreams,FILTER_REFS_BRANCHES ,DELETE_BRANCH_SKIP_UNMERGED |DELETE_BRANCH_NO_HEAD_FALLBACK | - flags); + flags, NULL);if (!ret && !(flags &DELETE_BRANCH_DRY_RUN )) clear_deleted_upstreams(&protected_branch_names,
@@ -1141,7 +1161,7 @@ int cmd_branch(int argc,die (_ ("branch name required" )); ret =delete_branches (argc, argv, filter.kind, (delete >1 ?DELETE_BRANCH_FORCE :0 ) | - (quiet ?DELETE_BRANCH_QUIET :0 )); + (quiet ?DELETE_BRANCH_QUIET :0 ),NULL ); goto out; }else if (delete_merged.nr) { ret = delete_merged_branches(&delete_merged, argv,
Signature · 1 line
gitgitgadget
PATCH v5 3/4 branch: add --delete-integrated to delete rebase and squash merged branches
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git rebase main topic
git checkout main
git merge --ff-only topic
or with
git merge --squash topic
git commitis never deleted by "git branch --delete-merged". This is what the "Rebase and merge" and "Squash and merge" buttons on GitHub do. The upstream holds rewritten copies of the branch's work, so its tip is not reachable from it and it looks unmerged forever.
Add --delete-integrated, which does what --delete-merged does and also deletes a branch when some upstream commit since the fork point has the branch's version of every file the branch changed. That commit is named in the output so the user can see where the work went:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
The first such commit is used, so the branch is deleted even if upstream later reverted or reworked the changes. Nothing is lost, since that commit keeps them in the upstream history. Who made that commit does not matter, since the local branch is no longer needed either way. A branch whose changes only partly landed is kept.
--delete-merged keeps deleting only branches whose own commits are in the upstream, so it never removes a branch because somebody else made the same change.
The check never reads file contents, so it stays cheap in a partial clone.
--- Documentation/config/branch.adoc | 8 +- Documentation/git-branch.adoc | 19 ++- builtin/branch.c | 253 ++++++++++++++++++++++++++++++- t/t3200-branch.sh | 176 +++++++++++++++++++++ 4 files changed, 441 insertions(+), 15 deletions(-)
Documentation/config/branch.adoc +4 -4
@@ -106,7 +106,7 @@ for details). `branch.<name>.deleteMerged`:: If set to `false`, branch _<name>_ is exempt from - `git branch --delete-merged`. Useful for a topic branch you - intend to develop further after an initial round has been - merged upstream. Defaults to true. Explicit deletion via - `git branch -d` is unaffected. + `git branch --delete-merged` and `--delete-integrated`. Useful + for a topic branch you intend to develop further after an + initial round has been merged upstream. Defaults to true. + Explicit deletion via `git branch -d` is unaffected.
Documentation/git-branch.adoc +15 -4
@@ -26,6 +26,7 @@ git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...] +git branch [--dry-run] (--delete-integrated <pattern>)... [<branch-pattern>...] DESCRIPTION -----------
@@ -235,11 +236,21 @@ A branch whose work has not yet been merged into its upstream is silently skipped. Delete it with `git branch -D` if you want to remove it anyway. +`--delete-integrated <pattern>`:: + Like `--delete-merged`, but also delete a branch whose changes + the upstream already has even though its commits are not there, + as happens after a squash or rebase merge. Such a branch is + deleted when some upstream commit since the fork point has its + version of every file it changed, even if those changes were + later reverted, and the message names the first such commit. + It does not matter who made that commit. The same rules for + which branches are kept apply as for `--delete-merged`. + `--dry-run`:: - With `--delete-merged`, print which branches would be - deleted and exit without touching any ref. Useful for - sanity-checking a wide pattern like `'origin/*'` before - committing to the deletion. + With `--delete-merged` or `--delete-integrated`, print which + branches would be deleted and exit without touching any ref. + Useful for sanity-checking a wide pattern like `'origin/*'` + before committing to the deletion. `-v`:: `-vv`::
builtin/branch.c +246 -7
349 lines not on this page · raw message
t/t3200-branch.sh +176 -0
192 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Re: [PATCH v5 3/4] branch: add --delete-integrated to delete rebase and squash merged branches
builtin/branch.c static int branch_pushes_to_upstream(struct branch *branch, · line 998
Quoted 6 lines · Harald Nordgren via GitGitGadget
builtin/branch.c +6
-0+static void find_landed_commits (struct landing_search *searches, +size_t searches_nr, +struct strmap *landed_commits) +{ +struct changed_paths cache; +struct diff_options opt;
A tiny nit, but because so many things are called x_option in our codebase, I am used to seeing the variable also carry "x" to match, or the use of mere "opt" down below would make us go back to see what kind of option it refers to while reading the code. I'd liked to see this called "diffopt" instead.
builtin/branch.c static int branch_pushes_to_upstream(struct branch *branch, · line 1017
Quoted 6 lines
builtin/branch.c +6
-0+repo_diff_setup (the_repository, &opt); + opt.flags.recursive =1 ; + opt.output_format =DIFF_FORMAT_NO_OUTPUT ; + opt.no_free =1 ; +diff_setup_done (&opt); +init_changed_paths (&cache);
The .no_free member is set to avoid the diff_free() call made in changed_path_of(), which in turn is called by find_landed_commit(). Everytime I see manipulation of .no_free member, it makes me look at the code twice but this seems to be a good use of the feature. And ...
Quoted 4 lines
builtin/branch.c +4
-0+for (size_t i =0 ; i < searches_nr; i++) { +struct commit *landed; + +if (!searches[i].changes_nr)
builtin/branch.c static int branch_pushes_to_upstream(struct branch *branch, · line 1032
Quoted 10 lines
builtin/branch.c +10
-0+continue ; + landed =find_landed_commit (&searches[i], &cache, &opt); +if (landed) +strmap_put (landed_commits, searches[i].branch_name, + landed); + } + +deep_clear_changed_paths (&cache, free_changed_paths); + opt.no_free =0 ; +diff_free (&opt);
... clearing the member before calling diff_free() to release the resources is also in line with the established practice.
I didn't have time to look at other parts of this patch (or other patches in the series), but the use of .no_free in this function looked OK.
Thanks.
Re: [PATCH v5 3/4] branch: add --delete-integrated to delete rebase and squash merged branches
Quoted 4 lines
Quoted 4 lines
builtin/branch.c +4
-0+static void find_landed_commits (struct landing_search *searches, +size_t searches_nr, +struct strmap *landed_commits) +{
Quoted 8 lines
builtin/branch.c static int branch_pushes_to_upstream(struct branch *branch, · line 998
Quoted 2 lines
builtin/branch.c +2
-0+struct changed_paths cache; +struct diff_options opt;A tiny nit, but because so many things are called
x_optionin our codebase, I am used to seeing the variable also carry "x" to match, or the use of mere "opt" down below would make us go back to see what kind of option it refers to while reading the code. I'd liked to see this called "diffopt" instead.
Ok.
Harald
PATCH v5 4/4 branch: let --delete-integrated find branches merged onto changed files
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git merge --squash topic
git commitafter upstream had also changed the files the branch touches is still kept by "git branch --delete-integrated". The squash commit has neither the branch's version of those files nor the fork point's, so the previous check cannot see that the work landed. This happens all the time on hosting services, where the "Squash and merge" button on GitHub lands pull requests this way while the main branch keeps moving. A rebase merge onto such changes looks the same.
For each such file, merge the branch's version into the upstream one and treat the change as landed when that leaves the upstream version unchanged. This has to read the files, so in a partial clone it may download blobs.
--- Documentation/git-branch.adoc | 13 ++++--- builtin/branch.c | 71 ++++++++++++++++++++++++++++++----- t/t3200-branch.sh | 42 +++++++++++++++++++++ 3 files changed, 111 insertions(+), 15 deletions(-)
Documentation/git-branch.adoc +8 -5
@@ -240,11 +240,14 @@ remove it anyway. Like `--delete-merged`, but also delete a branch whose changes the upstream already has even though its commits are not there, as happens after a squash or rebase merge. Such a branch is - deleted when some upstream commit since the fork point has its - version of every file it changed, even if those changes were - later reverted, and the message names the first such commit. - It does not matter who made that commit. The same rules for - which branches are kept apply as for `--delete-merged`. + deleted when some upstream commit since the fork point has all + of its changes, even if they were later reverted, and the + message names the first such commit. It does not matter who made + that commit. A file counts when the commit has the branch's + version of it, or when merging the branch's version into the + commit's leaves it unchanged, which reads the file and may + download blobs in a partial clone. The same rules for which + branches are kept apply as for `--delete-merged`. `--dry-run`:: With `--delete-merged` or `--delete-integrated`, print which
builtin/branch.c +61 -10
110 lines not on this page · raw message
t/t3200-branch.sh +42 -0
48 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Re: [PATCH v5 4/4] branch: let --delete-integrated find branches merged onto changed files
Quoted 11 lines · Harald Nordgren via GitGitGadget
builtin/branch.c +11
-0+static int merge_keeps_upstream (const struct branch_change *change) +{ +mmfile_t base, upstream, branch; +mmfile_t result = {0 }; +int ret; + +read_mmblob (&base, the_repository->objects, &change->base_oid); +read_mmblob (&upstream, the_repository->objects, &change->upstream_oid); +read_mmblob (&branch, the_repository->objects, &change->branch_oid); + ret = ll_merge(&result, change->path, &base, "base", + &upstream, "upstream", &branch, "branch",
builtin/branch.c static void collect_branch_changes(struct landing_search *search, · line 929
Quoted 10 lines
builtin/branch.c +10
-0+ the_repository->index, NULL) == LL_MERGE_OK && + result.size == upstream.size && + !memcmp (result.ptr, upstream.ptr, upstream.size); + +free (base.ptr); +free (upstream.ptr); +free (branch.ptr); +free (result.ptr); +return ret; +}
The function calls ll_merge() and passes the istate associated with the_repository global, but have we read the index before the control reaches this point? ll_merge() would want to consult custom low level merge from the attribute subsystem, and with istate that is not populated, it would fail to learn what the in-index attributes say, no?
If there is a reason why we want to ignore the attributes, perhaps we should explicitly say so.
Re: [PATCH v5 4/4] branch: let --delete-integrated find branches merged onto changed files
Quoted 29 lines
Quoted 11 lines
builtin/branch.c +11
-0+static int merge_keeps_upstream (const struct branch_change *change) +{ +mmfile_t base, upstream, branch; +mmfile_t result = {0 }; +int ret; + +read_mmblob (&base, the_repository->objects, &change->base_oid); +read_mmblob (&upstream, the_repository->objects, &change->upstream_oid); +read_mmblob (&branch, the_repository->objects, &change->branch_oid); + ret = ll_merge(&result, change->path, &base, "base", + &upstream, "upstream", &branch, "branch",builtin/branch.c static void collect_branch_changes(struct landing_search *search, · line 929
Quoted 10 lines
builtin/branch.c +10
-0+ the_repository->index, NULL) == LL_MERGE_OK && + result.size == upstream.size && + !memcmp (result.ptr, upstream.ptr, upstream.size); + +free (base.ptr); +free (upstream.ptr); +free (branch.ptr); +free (result.ptr); +return ret; +}The function calls
ll_merge()and passes the istate associated withthe_repositoryglobal, but have we read the index before the control reaches this point?ll_merge()would want to consult custom low level merge from the attribute subsystem, and with istate that is not populated, it would fail to learn what the in-index attributes say, no?
Quoted 2 lines
If there is a reason why we want to ignore the attributes, perhaps we should explicitly say so.
I'll take a look.
Harald
v6 cover branch: let --delete-merged find squash merged branches
In reply to Harald Nordgren via GitGitGadgetBranches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
Changes in v6:
* Read the index before the file merge, so .gitattributes that are only in the index are honored too.
* Renamed the diff_options variables from opt to diffopt.
Changes in v5:
* git branch -h lists --delete-integrated in the usage lines
* Giving both --delete-merged and --delete-integrated dies with a short "cannot be used together" message instead of the full usage text
Changes in v4:
* --delete-merged works as it does on master again: it only deletes branches whose tip is reachable from their upstream
* New --delete-integrated <pattern> also deletes branches that were rebase or squash merged, including those squashed after upstream changed the same files. It replaces --squashed
* New prep commit lets delete_branches() take a map of where each branch landed, so "landed as" is printed there
* Added Junio's test that --delete-merged keeps a branch when an unrelated upstream commit adds an identical file
* --dry-run works with either option, and the two options can't be combined
Changes in v3:
* Split into multiple commits, one for rebase merges and one for squash merges.
* By default a branch counts as merged when an upstream commit has its exact version of every file it changed. This never reads file contents, so it stays cheap in a partial clone. It finds rebase merges, and squash merges where upstream left those files alone.
* Replace --no-squashed with an opt-in --squashed, which also finds branches merged after upstream changed the same files by merging those files.
* Add a test for a rebase merge, and check that a squash onto changed files is kept without --squashed and deleted with it.
Changes in v2:
* Diff each upstream commit once and share the result between all branches, faster than v1.
* Fix a branch merged with a merge commit being reported as "landed as" the merge when another branch was searched first, and add a test for it. Show the topic commit itself in "landed as" for work that arrived through a merge.
* Use git merge --squash as the example in the commit message and mention GitHub's squash and rebase buttons.
* Add --no-squashed to skip the squash search.
Harald Nordgren (4):
branch: move the deleteMerged opt-out check into a helper
branch: let delete_branches() name where a branch landed
branch: add --delete-integrated to delete rebase and squash merged
branches
branch: let --delete-integrated find branches merged onto changed
files
Documentation/config/branch.adoc | 8 +- Documentation/git-branch.adoc | 22 +- builtin/branch.c | 361 +++++++++++++++++++++++++++++-- t/t3200-branch.sh | 218 +++++++++++++++++++ 4 files changed, 579 insertions(+), 30 deletions(-)
base-commit: 50fe5cc63f9cd6e89b87a2c9ccf96a444f4cfdb7
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v6
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v6
Pull-Request: https://github.com/git/git/pull/2425
Range-diff vs v5:
1: 090aec5725 = 1: e728b4ed0d branch: move the deleteMerged opt-out check into a helper
2: 24180bf4a2 = 2: 9858c8c81e branch: let delete_branches() name where a branch landed
3: bf30f57ceb ! 3: 92bd9afda4 branch: add --delete-integrated to delete rebase and squash merged branches
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+static void collect_branch_changes(struct landing_search *search,
+ struct commit *base)
+{
-+ struct diff_options opt;
++ struct diff_options diffopt;
+
-+ repo_diff_setup(the_repository, &opt);
-+ opt.flags.recursive = 1;
-+ opt.output_format = DIFF_FORMAT_NO_OUTPUT;
-+ diff_setup_done(&opt);
++ repo_diff_setup(the_repository, &diffopt);
++ diffopt.flags.recursive = 1;
++ diffopt.output_format = DIFF_FORMAT_NO_OUTPUT;
++ diff_setup_done(&diffopt);
+ diff_tree_oid(get_commit_tree_oid(base),
-+ get_commit_tree_oid(search->rev), "", &opt);
++ get_commit_tree_oid(search->rev), "", &diffopt);
+ for (int i = 0; i < diff_queued_diff.nr; i++) {
+ struct diff_filepair *p = diff_queued_diff.queue[i];
+ struct branch_change *change;
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ &p->two->oid : null_oid(the_hash_algo));
+ change->branch_mode = p->two->mode;
+ }
-+ diff_flush(&opt);
++ diff_flush(&diffopt);
+}
+
+static int change_landed(const struct branch_change *change,
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+}
+
+static struct string_list *changed_paths_of(struct changed_paths *cache,
-+ struct diff_options *opt,
++ struct diff_options *diffopt,
+ struct commit *commit)
+{
+ struct string_list **paths = changed_paths_at(cache, commit);
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ die(_("could not parse commit %s"),
+ oid_to_hex(&parent->object.oid));
+ diff_tree_oid(parent ? get_commit_tree_oid(parent) : NULL,
-+ get_commit_tree_oid(commit), "", opt);
++ get_commit_tree_oid(commit), "", diffopt);
+ for (int i = 0; i < diff_queued_diff.nr; i++) {
+ struct diff_filepair *p = diff_queued_diff.queue[i];
+
+ string_list_append(*paths, p->two->path);
+ }
-+ diff_flush(opt);
++ diff_flush(diffopt);
+ string_list_sort(*paths);
+ return *paths;
+}
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+
+static struct commit *find_landed_commit(struct landing_search *search,
+ struct changed_paths *cache,
-+ struct diff_options *opt)
++ struct diff_options *diffopt)
+{
+ struct commit *commit, *landed = NULL;
+ struct strvec args = STRVEC_INIT;
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ if (prepare_revision_walk(&revs))
+ die(_("revision walk setup failed"));
+ while (!landed && (commit = get_revision(&revs)))
-+ if (touches_changes(changed_paths_of(cache, opt, commit),
++ if (touches_changes(changed_paths_of(cache, diffopt, commit),
+ search) &&
+ changes_landed(search, commit))
+ landed = commit;
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ struct strmap *landed_commits)
+{
+ struct changed_paths cache;
-+ struct diff_options opt;
++ struct diff_options diffopt;
+
+ for (size_t i = 0; i < searches_nr; i++) {
+ struct landing_search *search = &searches[i];
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+ commit_list_free(merge_bases);
+ }
+
-+ repo_diff_setup(the_repository, &opt);
-+ opt.flags.recursive = 1;
-+ opt.output_format = DIFF_FORMAT_NO_OUTPUT;
-+ opt.no_free = 1;
-+ diff_setup_done(&opt);
++ repo_diff_setup(the_repository, &diffopt);
++ diffopt.flags.recursive = 1;
++ diffopt.output_format = DIFF_FORMAT_NO_OUTPUT;
++ diffopt.no_free = 1;
++ diff_setup_done(&diffopt);
+ init_changed_paths(&cache);
+
+ for (size_t i = 0; i < searches_nr; i++) {
@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
+
+ if (!searches[i].changes_nr)
+ continue;
-+ landed = find_landed_commit(&searches[i], &cache, &opt);
++ landed = find_landed_commit(&searches[i], &cache, &diffopt);
+ if (landed)
+ strmap_put(landed_commits, searches[i].branch_name,
+ landed);
+ }
+
+ deep_clear_changed_paths(&cache, free_changed_paths);
-+ opt.no_free = 0;
-+ diff_free(&opt);
++ diffopt.no_free = 0;
++ diff_free(&diffopt);
+}
+
static bool branch_opted_out(struct strbuf *key, const char *branch_name,
4: 9443055242 ! 4: 006a57d7d1 branch: let --delete-integrated find branches merged onto changed files
@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear
&p->two->oid : null_oid(the_hash_algo));
change->branch_mode = p->two->mode;
@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *search,
- diff_flush(&opt);
+ diff_flush(&diffopt);
}
-static int change_landed(const struct branch_change *change,
@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear
+ mmfile_t result = { 0 };
+ int ret;
+
++ repo_read_index(the_repository);
+ read_mmblob(&base, the_repository->objects, &change->base_oid);
+ read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
+ read_mmblob(&branch, the_repository->objects, &change->branch_oid);Signature · 1 line
gitgitgadget
PATCH v6 1/4 branch: move the deleteMerged opt-out check into a helper
From: Harald Nordgren <[email protected]>
A later commit adds "git branch --delete-integrated", which deletes branches whose tips are not reachable from their upstream. Those must honor branch.<name>.deleteMerged=false as well. Move the check into a helper so it can be used in both places.
No change in behavior.
--- builtin/branch.c | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-)
builtin/branch.c +19 -13
@@ -824,6 +824,23 @@ static int branch_pushes_to_upstream(struct branch *branch,return ret; } +static bool branch_opted_out (struct strbuf *key,const char *branch_name, +unsigned int flags) +{ +int opt_out; + +strbuf_reset (key); +strbuf_addf (key,"branch.%s.deletemerged" , branch_name); +if (repo_config_get_bool (the_repository, key->buf, &opt_out) || + opt_out) +return false; +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +return true; +} +static int delete_merged_branches (const struct strvec *upstreams,const char **argv,unsigned int flags) {
@@ -852,7 +869,6 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; -int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -870,18 +886,8 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; -strbuf_reset (&key); -strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); -if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && - !opt_out) { -if (!(flags &DELETE_BRANCH_QUIET )) -fprintf (stderr, -_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), - branch_name, branch_name); -continue ; - } - -strset_add (&deletable_branch_names, branch_name); +if (!branch_opted_out (&key, branch_name, flags)) +strset_add (&deletable_branch_names, branch_name); } protect_stacked_branch_bases(refs, &deletable_branch_names,
Signature · 1 line
gitgitgadget
PATCH v6 2/4 branch: let delete_branches() name where a branch landed
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A later commit lets "git branch" delete branches whose commits are not in their upstream but whose changes are, such as after a squash merge. Their tips are not reachable from the upstream, so the merge check in delete_branches() would refuse to delete them, and the output should say where their changes went.
Let delete_branches() take a map from branch names to the upstream commit where each branch landed. A branch in the map skips the merge check and is reported as
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
No caller passes a map yet, so there is no change in behavior.
--- builtin/branch.c | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-)
builtin/branch.c +25 -5
@@ -236,7 +236,7 @@ static void delete_branch_config(const char *branchname) }static int delete_branches (int argc,const char **argv,int kinds, -unsigned int flags) +unsigned int flags,struct strmap *landed_commits) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -334,6 +334,8 @@ static int delete_branches(int argc, const char **argv, int kinds, }if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) && + !(landed_commits && +strmap_contains (landed_commits, bname.buf)) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) {if (!(flags &DELETE_BRANCH_SKIP_UNMERGED ))
@@ -357,15 +359,33 @@ static int delete_branches(int argc, const char **argv, int kinds,for_each_string_list_item (item, &refs_to_delete) {char *describe_ref = item->util;char *name = item->string; +struct commit *landed = landed_commits ? +strmap_get (landed_commits, name + branch_name_pos) :NULL ; +const char *landed_abbrev = landed ? +repo_find_unique_abbrev (the_repository, + &landed->object.oid, +DEFAULT_ABBREV ) :NULL ; +if (flags &DELETE_BRANCH_DRY_RUN ) { -if (!(flags &DELETE_BRANCH_QUIET )) +if (flags &DELETE_BRANCH_QUIET ) + ; +else if (landed) +printf (_ ("Would delete branch %s (was %s, landed as %s).\n" ), + name + branch_name_pos, describe_ref, + landed_abbrev); +else printf (remote_branch ?_ ("Would delete remote-tracking branch %s (was %s).\n" ) :_ ("Would delete branch %s (was %s).\n" ), name + branch_name_pos, describe_ref); }else if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!(flags &DELETE_BRANCH_QUIET )) +if (flags &DELETE_BRANCH_QUIET ) + ; +else if (landed) +printf (_ ("Deleted branch %s (was %s, landed as %s).\n" ), + refname, describe_ref, landed_abbrev); +else printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -901,7 +921,7 @@ static int delete_merged_branches(const struct strvec *upstreams,FILTER_REFS_BRANCHES ,DELETE_BRANCH_SKIP_UNMERGED |DELETE_BRANCH_NO_HEAD_FALLBACK | - flags); + flags, NULL);if (!ret && !(flags &DELETE_BRANCH_DRY_RUN )) clear_deleted_upstreams(&protected_branch_names,
@@ -1141,7 +1161,7 @@ int cmd_branch(int argc,die (_ ("branch name required" )); ret =delete_branches (argc, argv, filter.kind, (delete >1 ?DELETE_BRANCH_FORCE :0 ) | - (quiet ?DELETE_BRANCH_QUIET :0 )); + (quiet ?DELETE_BRANCH_QUIET :0 ),NULL ); goto out; }else if (delete_merged.nr) { ret = delete_merged_branches(&delete_merged, argv,
Signature · 1 line
gitgitgadget
PATCH v6 3/4 branch: add --delete-integrated to delete rebase and squash merged branches
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git rebase main topic
git checkout main
git merge --ff-only topic
or with
git merge --squash topic
git commitis never deleted by "git branch --delete-merged". This is what the "Rebase and merge" and "Squash and merge" buttons on GitHub do. The upstream holds rewritten copies of the branch's work, so its tip is not reachable from it and it looks unmerged forever.
Add --delete-integrated, which does what --delete-merged does and also deletes a branch when some upstream commit since the fork point has the branch's version of every file the branch changed. That commit is named in the output so the user can see where the work went:
Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
The first such commit is used, so the branch is deleted even if upstream later reverted or reworked the changes. Nothing is lost, since that commit keeps them in the upstream history. Who made that commit does not matter, since the local branch is no longer needed either way. A branch whose changes only partly landed is kept.
--delete-merged keeps deleting only branches whose own commits are in the upstream, so it never removes a branch because somebody else made the same change.
The check never reads file contents, so it stays cheap in a partial clone.
--- Documentation/config/branch.adoc | 8 +- Documentation/git-branch.adoc | 19 ++- builtin/branch.c | 253 ++++++++++++++++++++++++++++++- t/t3200-branch.sh | 176 +++++++++++++++++++++ 4 files changed, 441 insertions(+), 15 deletions(-)
Documentation/config/branch.adoc +4 -4
@@ -106,7 +106,7 @@ for details). `branch.<name>.deleteMerged`:: If set to `false`, branch _<name>_ is exempt from - `git branch --delete-merged`. Useful for a topic branch you - intend to develop further after an initial round has been - merged upstream. Defaults to true. Explicit deletion via - `git branch -d` is unaffected. + `git branch --delete-merged` and `--delete-integrated`. Useful + for a topic branch you intend to develop further after an + initial round has been merged upstream. Defaults to true. + Explicit deletion via `git branch -d` is unaffected.
Documentation/git-branch.adoc +15 -4
@@ -26,6 +26,7 @@ git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...] +git branch [--dry-run] (--delete-integrated <pattern>)... [<branch-pattern>...] DESCRIPTION -----------
@@ -235,11 +236,21 @@ A branch whose work has not yet been merged into its upstream is silently skipped. Delete it with `git branch -D` if you want to remove it anyway. +`--delete-integrated <pattern>`:: + Like `--delete-merged`, but also delete a branch whose changes + the upstream already has even though its commits are not there, + as happens after a squash or rebase merge. Such a branch is + deleted when some upstream commit since the fork point has its + version of every file it changed, even if those changes were + later reverted, and the message names the first such commit. + It does not matter who made that commit. The same rules for + which branches are kept apply as for `--delete-merged`. + `--dry-run`:: - With `--delete-merged`, print which branches would be - deleted and exit without touching any ref. Useful for - sanity-checking a wide pattern like `'origin/*'` before - committing to the deletion. + With `--delete-merged` or `--delete-integrated`, print which + branches would be deleted and exit without touching any ref. + Useful for sanity-checking a wide pattern like `'origin/*'` + before committing to the deletion. `-v`:: `-vv`::
builtin/branch.c +246 -7
349 lines not on this page · raw message
t/t3200-branch.sh +176 -0
192 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v6 4/4 branch: let --delete-integrated find branches merged onto changed files
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
A branch that upstream integrated with
git merge --squash topic
git commitafter upstream had also changed the files the branch touches is still kept by "git branch --delete-integrated". The squash commit has neither the branch's version of those files nor the fork point's, so the previous check cannot see that the work landed. This happens all the time on hosting services, where the "Squash and merge" button on GitHub lands pull requests this way while the main branch keeps moving. A rebase merge onto such changes looks the same.
For each such file, merge the branch's version into the upstream one and treat the change as landed when that leaves the upstream version unchanged. This has to read the files, so in a partial clone it may download blobs.
--- Documentation/git-branch.adoc | 13 ++++--- builtin/branch.c | 72 ++++++++++++++++++++++++++++++----- t/t3200-branch.sh | 42 ++++++++++++++++++++ 3 files changed, 112 insertions(+), 15 deletions(-)
Documentation/git-branch.adoc +8 -5
@@ -240,11 +240,14 @@ remove it anyway. Like `--delete-merged`, but also delete a branch whose changes the upstream already has even though its commits are not there, as happens after a squash or rebase merge. Such a branch is - deleted when some upstream commit since the fork point has its - version of every file it changed, even if those changes were - later reverted, and the message names the first such commit. - It does not matter who made that commit. The same rules for - which branches are kept apply as for `--delete-merged`. + deleted when some upstream commit since the fork point has all + of its changes, even if they were later reverted, and the + message names the first such commit. It does not matter who made + that commit. A file counts when the commit has the branch's + version of it, or when merging the branch's version into the + commit's leaves it unchanged, which reads the file and may + download blobs in a partial clone. The same rules for which + branches are kept apply as for `--delete-merged`. `--dry-run`:: With `--delete-merged` or `--delete-integrated`, print which
builtin/branch.c +62 -10
111 lines not on this page · raw message
t/t3200-branch.sh +42 -0
48 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Re: [PATCH v6 4/4] branch: let --delete-integrated find branches merged onto changed files
Could I get some review here please?
Harald