Skip to content

Commit 02a6982

Browse files
branch: add --squashed to find squash merged branches
A branch that upstream integrated with git merge --squash topic git commit is still kept by "git branch --delete-merged". A single squash commit is not a counterpart of each of the branch's commits, so it is not found as a rebase. The "Squash and merge" button on GitHub lands pull requests this way. Add --squashed, which searches the upstream since the fork point for the first commit that contains everything the branch changed, and names it in the output. 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. The first such commit is used, so the branch is deleted even if upstream later reverted the changes. Nothing is lost, since that commit keeps them in the upstream history. This looks into every upstream commit that touches the branch's files and may have to read them, 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 <haraldnordgren@gmail.com>
1 parent 10edf36 commit 02a6982

3 files changed

Lines changed: 245 additions & 4 deletions

File tree

‎Documentation/git-branch.adoc‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
2525
git branch (-c|-C) [<old-branch>] <new-branch>
2626
git branch (-d|-D) [-r] <branch-name>...
2727
git branch --edit-description [<branch-name>]
28-
git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
28+
git branch [--dry-run] [--squashed] (--delete-merged <pattern>)... [<branch-pattern>...]
2929

3030
DESCRIPTION
3131
-----------
@@ -246,6 +246,15 @@ remove it anyway.
246246
sanity-checking a wide pattern like `'origin/*'` before
247247
committing to the deletion.
248248

249+
`--squashed`::
250+
With `--delete-merged`, also delete branches that were squash
251+
merged. For such a branch, search the upstream since the fork
252+
point for the first commit that contains all of the branch's
253+
changes, even if they were later reverted, and name it in the
254+
message. This looks at every upstream commit that touches the
255+
branch's files and may have to read them, so it takes longer and
256+
may download blobs in a partial clone.
257+
249258
`-v`::
250259
`-vv`::
251260
`--verbose`::

‎builtin/branch.c‎

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@
2929
#include "help.h"
3030
#include "advice.h"
3131
#include "commit-reach.h"
32+
#include "commit-slab.h"
3233
#include "diff.h"
3334
#include "diffcore.h"
3435
#include "hex.h"
3536
#include "merge-ll.h"
3637
#include "oid-array.h"
3738
#include "range-diff.h"
39+
#include "revision.h"
3840
#include "tree-walk.h"
3941
#include "xdiff-interface.h"
4042

@@ -209,6 +211,7 @@ enum delete_branch_flags {
209211
DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
210212
DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
211213
DELETE_BRANCH_DRY_RUN = (1 << 4),
214+
DELETE_BRANCH_SQUASHED = (1 << 5),
212215
};
213216

214217
static int check_branch_commit(const char *branchname, const char *refname,
@@ -873,6 +876,8 @@ struct landing_search {
873876
size_t changes_nr, changes_alloc;
874877
};
875878

879+
define_commit_slab(changed_paths, struct string_list *);
880+
876881
static void collect_branch_changes(struct landing_search *search,
877882
struct commit *base)
878883
{
@@ -1008,10 +1013,94 @@ static struct commit *find_rebased_commit(struct landing_search *search,
10081013
return landed;
10091014
}
10101015

1016+
static struct string_list *changed_paths_of(struct changed_paths *cache,
1017+
struct diff_options *opt,
1018+
struct commit *commit)
1019+
{
1020+
struct string_list **paths = changed_paths_at(cache, commit);
1021+
struct commit *parent;
1022+
1023+
if (*paths)
1024+
return *paths;
1025+
1026+
CALLOC_ARRAY(*paths, 1);
1027+
string_list_init_dup(*paths);
1028+
parent = commit->parents ? commit->parents->item : NULL;
1029+
if (parent && repo_parse_commit(the_repository, parent))
1030+
die(_("could not parse commit %s"),
1031+
oid_to_hex(&parent->object.oid));
1032+
diff_tree_oid(parent ? get_commit_tree_oid(parent) : NULL,
1033+
get_commit_tree_oid(commit), "", opt);
1034+
for (int i = 0; i < diff_queued_diff.nr; i++) {
1035+
struct diff_filepair *p = diff_queued_diff.queue[i];
1036+
1037+
string_list_append(*paths, p->two->path);
1038+
}
1039+
diff_flush(opt);
1040+
string_list_sort(*paths);
1041+
return *paths;
1042+
}
1043+
1044+
static void free_changed_paths(struct string_list **paths)
1045+
{
1046+
if (!*paths)
1047+
return;
1048+
string_list_clear(*paths, 0);
1049+
free(*paths);
1050+
}
1051+
1052+
static int touches_changes(struct string_list *paths,
1053+
const struct landing_search *search)
1054+
{
1055+
for (size_t i = 0; i < search->changes_nr; i++)
1056+
if (string_list_has_string(paths, search->changes[i].path))
1057+
return 1;
1058+
return 0;
1059+
}
1060+
1061+
static struct commit *find_squashed_commit(struct landing_search *search,
1062+
struct changed_paths *cache,
1063+
struct diff_options *opt)
1064+
{
1065+
struct commit *commit, *landed = NULL;
1066+
struct strvec args = STRVEC_INIT;
1067+
struct rev_info revs;
1068+
1069+
strvec_pushl(&args, "rev-list", "--reverse",
1070+
oid_to_hex(&search->upstream->object.oid), NULL);
1071+
strvec_pushf(&args, "^%s", oid_to_hex(&search->rev->object.oid));
1072+
1073+
repo_init_revisions(the_repository, &revs, NULL);
1074+
setup_revisions_from_strvec(&args, &revs, NULL);
1075+
if (prepare_revision_walk(&revs))
1076+
die(_("revision walk setup failed"));
1077+
while (!landed && (commit = get_revision(&revs)))
1078+
if (touches_changes(changed_paths_of(cache, opt, commit),
1079+
search) &&
1080+
changes_landed(search, commit))
1081+
landed = commit;
1082+
release_revisions(&revs);
1083+
clear_commit_marks(search->upstream, ALL_REV_FLAGS);
1084+
clear_commit_marks(search->rev, ALL_REV_FLAGS);
1085+
strvec_clear(&args);
1086+
return landed;
1087+
}
1088+
10111089
static void find_landed_commits(struct landing_search *searches,
10121090
size_t searches_nr,
1013-
struct strmap *landed_commits)
1091+
struct strmap *landed_commits,
1092+
unsigned int flags)
10141093
{
1094+
struct changed_paths cache;
1095+
struct diff_options opt;
1096+
1097+
repo_diff_setup(the_repository, &opt);
1098+
opt.flags.recursive = 1;
1099+
opt.output_format = DIFF_FORMAT_NO_OUTPUT;
1100+
opt.no_free = 1;
1101+
diff_setup_done(&opt);
1102+
init_changed_paths(&cache);
1103+
10151104
for (size_t i = 0; i < searches_nr; i++) {
10161105
struct landing_search *search = &searches[i];
10171106
struct commit_list *merge_bases = NULL;
@@ -1027,9 +1116,16 @@ static void find_landed_commits(struct landing_search *searches,
10271116
merge_bases->item);
10281117
}
10291118
commit_list_free(merge_bases);
1119+
if (!landed && search->changes_nr &&
1120+
(flags & DELETE_BRANCH_SQUASHED))
1121+
landed = find_squashed_commit(search, &cache, &opt);
10301122
if (landed)
10311123
strmap_put(landed_commits, search->branch_name, landed);
10321124
}
1125+
1126+
deep_clear_changed_paths(&cache, free_changed_paths);
1127+
opt.no_free = 0;
1128+
diff_free(&opt);
10331129
}
10341130

10351131
static int branch_opted_out(struct strbuf *key, const char *branch_name,
@@ -1116,7 +1212,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
11161212
strset_add(&deletable_branch_names, branch_name);
11171213
}
11181214

1119-
find_landed_commits(searches, searches_nr, &landed_commits);
1215+
find_landed_commits(searches, searches_nr, &landed_commits, flags);
11201216
for (size_t i = 0; i < searches_nr; i++) {
11211217
const char *branch_name = searches[i].branch_name;
11221218

@@ -1224,6 +1320,7 @@ int cmd_branch(int argc,
12241320
unset_upstream = 0, show_current = 0, edit_description = 0;
12251321
struct strvec delete_merged = STRVEC_INIT;
12261322
int dry_run = 0;
1323+
int squashed = 0;
12271324
const char *new_upstream = NULL;
12281325
int noncreate_actions = 0;
12291326
/* possible options */
@@ -1282,6 +1379,8 @@ int cmd_branch(int argc,
12821379
PARSE_OPT_NONEG, parse_opt_strvec),
12831380
OPT_BOOL(0, "dry-run", &dry_run,
12841381
N_("with --delete-merged, only print which branches would be deleted")),
1382+
OPT_BOOL(0, "squashed", &squashed,
1383+
N_("with --delete-merged, also delete squash merged branches")),
12851384
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
12861385
OPT_MERGED(&filter, N_("print only branches that are merged")),
12871386
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1346,6 +1445,8 @@ int cmd_branch(int argc,
13461445

13471446
if (dry_run && !delete_merged.nr)
13481447
die(_("--dry-run requires --delete-merged"));
1448+
if (squashed && !delete_merged.nr)
1449+
die(_("--squashed requires --delete-merged"));
13491450

13501451
if (recurse_submodules_explicit) {
13511452
if (!submodule_propagate_branches)
@@ -1388,7 +1489,8 @@ int cmd_branch(int argc,
13881489
} else if (delete_merged.nr) {
13891490
ret = delete_merged_branches(&delete_merged, argv,
13901491
(quiet ? DELETE_BRANCH_QUIET : 0) |
1391-
(dry_run ? DELETE_BRANCH_DRY_RUN : 0));
1492+
(dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
1493+
(squashed ? DELETE_BRANCH_SQUASHED : 0));
13921494
goto out;
13931495
} else if (show_current) {
13941496
print_current_branch_name();

‎t/t3200-branch.sh‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1994,6 +1994,16 @@ push_topic () {
19941994
)
19951995
}
19961996

1997+
squash_merge_upstream () {
1998+
(
1999+
cd upstream &&
2000+
git checkout next &&
2001+
git merge --squash "$1" &&
2002+
git commit -m "Squash merge of $1" &&
2003+
git checkout main
2004+
)
2005+
}
2006+
19972007
rebase_merge_upstream () {
19982008
(
19992009
cd upstream &&
@@ -2073,6 +2083,121 @@ test_expect_success '--delete-merged deletes a branch rebased past nearby upstre
20732083
)
20742084
'
20752085

2086+
test_expect_success '--delete-merged deletes a squash merged branch' '
2087+
setup_repo_for_delete_merged &&
2088+
push_topic squashed squashed-one squashed-two &&
2089+
push_topic partial lands-upstream stays-local &&
2090+
squash_merge_upstream partial~1 &&
2091+
squash_merge_upstream squashed &&
2092+
squash=$(git -C upstream rev-parse --short next) &&
2093+
(
2094+
cd repo &&
2095+
git fetch origin &&
2096+
sha=$(git rev-parse --short squashed) &&
2097+
2098+
git branch --delete-merged origin/next --squashed \
2099+
>actual 2>&1 &&
2100+
echo "Deleted branch squashed (was $sha, landed as $squash)." >expect &&
2101+
test_cmp expect actual &&
2102+
2103+
check_branches <<-\EOF
2104+
main
2105+
partial
2106+
EOF
2107+
)
2108+
'
2109+
2110+
test_expect_success '--delete-merged deletes a squash merged branch that was reverted' '
2111+
setup_repo_for_delete_merged &&
2112+
push_topic reverted reverted-work &&
2113+
squash_merge_upstream reverted &&
2114+
squash=$(git -C upstream rev-parse --short next) &&
2115+
(
2116+
cd upstream &&
2117+
git checkout next &&
2118+
git revert --no-edit HEAD &&
2119+
git checkout main
2120+
) &&
2121+
(
2122+
cd repo &&
2123+
git fetch origin &&
2124+
sha=$(git rev-parse --short reverted) &&
2125+
2126+
git branch --delete-merged origin/next --squashed \
2127+
>actual 2>&1 &&
2128+
echo "Deleted branch reverted (was $sha, landed as $squash)." >expect &&
2129+
test_cmp expect actual &&
2130+
2131+
check_branches <<-\EOF
2132+
main
2133+
EOF
2134+
)
2135+
'
2136+
2137+
test_expect_success '--delete-merged deletes a squash merged branch after upstream changed the same file' '
2138+
(
2139+
cd upstream &&
2140+
git checkout next &&
2141+
test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
2142+
git add shared &&
2143+
git commit -m "add shared" &&
2144+
git checkout main
2145+
) &&
2146+
setup_repo_for_delete_merged &&
2147+
(
2148+
cd repo &&
2149+
git checkout -b edits-shared --track origin/next &&
2150+
test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
2151+
git commit -a -m "edit line 2" &&
2152+
git push origin edits-shared &&
2153+
git checkout --detach
2154+
) &&
2155+
(
2156+
cd upstream &&
2157+
git checkout next &&
2158+
test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
2159+
git commit -a -m "edit line 5" &&
2160+
git checkout main
2161+
) &&
2162+
squash_merge_upstream edits-shared &&
2163+
squash=$(git -C upstream rev-parse --short next) &&
2164+
(
2165+
cd repo &&
2166+
git fetch origin &&
2167+
sha=$(git rev-parse --short edits-shared) &&
2168+
2169+
git branch --delete-merged origin/next --squashed \
2170+
>actual 2>&1 &&
2171+
echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
2172+
test_cmp expect actual &&
2173+
2174+
check_branches <<-\EOF
2175+
main
2176+
EOF
2177+
)
2178+
'
2179+
2180+
test_expect_success '--delete-merged keeps a squash merged branch without --squashed' '
2181+
setup_repo_for_delete_merged &&
2182+
create_merged_branch merged &&
2183+
push_topic squash-merged squash-merged-one squash-merged-two &&
2184+
squash_merge_upstream squash-merged &&
2185+
(
2186+
cd repo &&
2187+
git fetch origin &&
2188+
sha=$(git rev-parse --short merged) &&
2189+
2190+
git branch --delete-merged origin/next >actual 2>&1 &&
2191+
echo "Deleted branch merged (was $sha)." >expect &&
2192+
test_cmp expect actual &&
2193+
2194+
check_branches <<-\EOF
2195+
main
2196+
squash-merged
2197+
EOF
2198+
)
2199+
'
2200+
20762201
test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
20772202
setup_repo_for_delete_merged &&
20782203
push_topic a-unmerged unmerged-work &&
@@ -2294,6 +2419,11 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" '
22942419
)
22952420
'
22962421

2422+
test_expect_success '--squashed without --delete-merged is rejected' '
2423+
test_must_fail git -C forked branch --squashed 2>err &&
2424+
test_grep "requires --delete-merged" err
2425+
'
2426+
22972427
test_expect_success '--dry-run without --delete-merged is rejected' '
22982428
test_must_fail git -C forked branch --dry-run 2>err &&
22992429
test_grep "requires --delete-merged" err

0 commit comments

Comments
 (0)