Skip to content

Commit 7bb8db6

Browse files
fetch: add fetch.pruneLocalBranches config
Introduce a tri-state config option that, when --prune (or fetch.prune / remote.<name>.prune) removes a remote-tracking ref, also deletes local branches whose configured upstream is that ref. Values: - false (default): no change in behavior. - safe / true: delete only if the local tip is reachable from the upstream's last-known OID, preserving any unpushed work. - force: delete unconditionally; recoverable only via reflog. The currently checked-out branch is always preserved. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
1 parent 94f0577 commit 7bb8db6

4 files changed

Lines changed: 294 additions & 4 deletions

File tree

Documentation/config/fetch.adoc

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,44 @@
5050
refs. See also `remote.<name>.pruneTags` and the PRUNING
5151
section of linkgit:git-fetch[1].
5252

53+
`fetch.pruneLocalBranches`::
54+
When set in addition to `fetch.prune` (or `--prune`), also
55+
delete local branches whose configured upstream
56+
(`branch.<name>.merge`) is one of the remote-tracking refs
57+
just removed by pruning. This is useful for cleaning up local
58+
branches whose pull request has been merged and whose remote
59+
branch was then deleted (for example, by GitHub's "Delete
60+
branch" button after a squash merge).
61+
+
62+
The currently checked-out branch (in any worktree) is never
63+
deleted. The value is one of:
64+
+
65+
--
66+
`false` (the default);;
67+
Do not delete any local branches. Equivalent to leaving
68+
the option unset.
69+
`safe` (or `true`);;
70+
Delete a local branch only if its tip is an ancestor of
71+
the upstream remote-tracking ref's last-known position.
72+
In other words, only delete the branch if it contains no
73+
commits that the upstream did not also have at the moment
74+
it was deleted. This catches the common case of a branch
75+
that was pushed and then squash- or rebase-merged
76+
upstream (the local branch has no extra commits beyond
77+
what was pushed), but preserves any branch with unpushed
78+
local work.
79+
`force`;;
80+
Delete the local branch unconditionally, even if it
81+
contains unpushed commits. Use with care: if a remote
82+
branch is deleted for any reason other than that its
83+
contents were merged, the corresponding local commits
84+
will only be retrievable through the reflog.
85+
--
86+
+
87+
This option has no effect unless pruning is also enabled, since
88+
local branches are only considered for deletion when their
89+
upstream remote-tracking ref is being pruned in the same fetch.
90+
5391
`fetch.all`::
5492
If true, fetch will attempt to update all available remotes.
5593
This behavior can be overridden by passing `--no-all` or by

Documentation/git-fetch.adoc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ It's reasonable to e.g. configure `fetch.pruneTags=true` in
179179
run, without making every invocation of `git fetch` without `--prune`
180180
an error.
181181

182+
Local branches whose upstream remote-tracking ref is being pruned can
183+
also be deleted automatically by setting the `fetch.pruneLocalBranches`
184+
config option to `safe` or `force`. See linkgit:git-config[1] for the
185+
data-loss tradeoff between the two.
186+
182187
Pruning tags with `--prune-tags` also works when fetching a URL
183188
instead of a named remote. These will all prune tags not found on
184189
origin:

builtin/fetch.c

Lines changed: 178 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ enum display_format {
6464
DISPLAY_FORMAT_PORCELAIN,
6565
};
6666

67+
enum prune_local_mode {
68+
PRUNE_LOCAL_OFF = 0,
69+
PRUNE_LOCAL_SAFE,
70+
PRUNE_LOCAL_FORCE,
71+
};
72+
6773
struct display_state {
6874
struct strbuf buf;
6975

@@ -105,12 +111,31 @@ struct fetch_config {
105111
int all;
106112
int prune;
107113
int prune_tags;
114+
enum prune_local_mode prune_local;
108115
int show_forced_updates;
109116
int recurse_submodules;
110117
int parallel;
111118
int submodule_fetch_jobs;
112119
};
113120

121+
static enum prune_local_mode parse_prune_local(const char *k, const char *v)
122+
{
123+
if (v) {
124+
if (!strcasecmp(v, "safe"))
125+
return PRUNE_LOCAL_SAFE;
126+
if (!strcasecmp(v, "force") || !strcasecmp(v, "unsafe"))
127+
return PRUNE_LOCAL_FORCE;
128+
}
129+
switch (git_parse_maybe_bool(v)) {
130+
case 1:
131+
return PRUNE_LOCAL_SAFE;
132+
case 0:
133+
return PRUNE_LOCAL_OFF;
134+
default:
135+
die(_("invalid value for '%s': '%s'"), k, v);
136+
}
137+
}
138+
114139
static int git_fetch_config(const char *k, const char *v,
115140
const struct config_context *ctx, void *cb)
116141
{
@@ -131,6 +156,11 @@ static int git_fetch_config(const char *k, const char *v,
131156
return 0;
132157
}
133158

159+
if (!strcmp(k, "fetch.prunelocalbranches")) {
160+
fetch_config->prune_local = parse_prune_local(k, v);
161+
return 0;
162+
}
163+
134164
if (!strcmp(k, "fetch.showforcedupdates")) {
135165
fetch_config->show_forced_updates = git_config_bool(k, v);
136166
return 0;
@@ -1445,7 +1475,8 @@ static int fetch_and_consume_refs(struct display_state *display_state,
14451475
static int prune_refs(struct display_state *display_state,
14461476
struct refspec *rs,
14471477
struct ref_transaction *transaction,
1448-
struct ref *ref_map)
1478+
struct ref *ref_map,
1479+
struct ref **stale_refs_out)
14491480
{
14501481
int result = 0;
14511482
struct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);
@@ -1487,7 +1518,140 @@ static int prune_refs(struct display_state *display_state,
14871518
cleanup:
14881519
string_list_clear(&refnames, 0);
14891520
strbuf_release(&err);
1490-
free_refs(stale_refs);
1521+
if (!result && stale_refs_out)
1522+
*stale_refs_out = stale_refs;
1523+
else
1524+
free_refs(stale_refs);
1525+
return result;
1526+
}
1527+
1528+
struct prune_local_cb {
1529+
struct string_list *pruned_refs;
1530+
struct string_list *to_delete;
1531+
struct string_list *skipped_unmerged;
1532+
enum prune_local_mode mode;
1533+
};
1534+
1535+
static int collect_local_to_prune(const struct reference *ref, void *cb_data)
1536+
{
1537+
struct prune_local_cb *cb = cb_data;
1538+
const char *short_name = ref->name;
1539+
struct strbuf full_ref = STRBUF_INIT;
1540+
struct branch *branch;
1541+
const char *upstream;
1542+
struct string_list_item *pruned;
1543+
int result = 0;
1544+
1545+
if (ref->flags & REF_ISSYMREF)
1546+
return 0;
1547+
1548+
strbuf_addf(&full_ref, "refs/heads/%s", short_name);
1549+
if (branch_checked_out(full_ref.buf))
1550+
goto cleanup;
1551+
1552+
branch = branch_get(short_name);
1553+
upstream = branch_get_upstream(branch, NULL);
1554+
if (!upstream)
1555+
goto cleanup;
1556+
1557+
pruned = string_list_lookup(cb->pruned_refs, upstream);
1558+
if (!pruned)
1559+
goto cleanup;
1560+
1561+
if (cb->mode == PRUNE_LOCAL_SAFE) {
1562+
struct commit *local_commit, *upstream_commit;
1563+
const struct object_id *upstream_oid = pruned->util;
1564+
int reachable;
1565+
1566+
local_commit = lookup_commit_reference(the_repository, ref->oid);
1567+
if (!local_commit)
1568+
goto cleanup;
1569+
1570+
upstream_commit = lookup_commit_reference(the_repository,
1571+
upstream_oid);
1572+
if (!upstream_commit) {
1573+
string_list_append(cb->skipped_unmerged, short_name);
1574+
goto cleanup;
1575+
}
1576+
1577+
reachable = repo_in_merge_bases(the_repository, local_commit,
1578+
upstream_commit);
1579+
if (reachable < 0) {
1580+
result = -1;
1581+
goto cleanup;
1582+
}
1583+
if (!reachable) {
1584+
string_list_append(cb->skipped_unmerged, short_name);
1585+
goto cleanup;
1586+
}
1587+
}
1588+
1589+
string_list_append(cb->to_delete, full_ref.buf);
1590+
1591+
cleanup:
1592+
strbuf_release(&full_ref);
1593+
return result;
1594+
}
1595+
1596+
static int prune_local_branches(struct display_state *display_state,
1597+
struct ref *stale_refs,
1598+
enum prune_local_mode mode)
1599+
{
1600+
struct string_list pruned_refs = STRING_LIST_INIT_NODUP;
1601+
struct string_list to_delete = STRING_LIST_INIT_DUP;
1602+
struct string_list skipped_unmerged = STRING_LIST_INIT_DUP;
1603+
struct prune_local_cb cb = {
1604+
.pruned_refs = &pruned_refs,
1605+
.to_delete = &to_delete,
1606+
.skipped_unmerged = &skipped_unmerged,
1607+
.mode = mode,
1608+
};
1609+
struct ref *ref;
1610+
struct string_list_item *item;
1611+
int result = 0;
1612+
1613+
if (!stale_refs)
1614+
return 0;
1615+
1616+
for (ref = stale_refs; ref; ref = ref->next)
1617+
string_list_append(&pruned_refs, ref->name)->util = &ref->new_oid;
1618+
string_list_sort(&pruned_refs);
1619+
1620+
if (refs_for_each_branch_ref(get_main_ref_store(the_repository),
1621+
collect_local_to_prune, &cb)) {
1622+
result = -1;
1623+
goto cleanup;
1624+
}
1625+
1626+
if (!dry_run && to_delete.nr)
1627+
result = refs_delete_refs(get_main_ref_store(the_repository),
1628+
"fetch: prune local branches",
1629+
&to_delete, REF_NO_DEREF);
1630+
1631+
if (verbosity >= 0) {
1632+
const struct object_id *zero = null_oid(the_repository->hash_algo);
1633+
for_each_string_list_item(item, &to_delete) {
1634+
const char *short_name;
1635+
if (skip_prefix(item->string, "refs/heads/", &short_name))
1636+
display_ref_update(display_state, '-',
1637+
_("[deleted local]"), NULL,
1638+
_("(none)"), short_name,
1639+
zero, zero,
1640+
transport_summary_width(NULL));
1641+
}
1642+
for_each_string_list_item(item, &skipped_unmerged)
1643+
warning(_("not deleting local branch '%s' that is not "
1644+
"fully merged into its upstream;\n"
1645+
" set fetch.pruneLocalBranches=force to "
1646+
"delete anyway, or delete manually with "
1647+
"'git branch -D %s'"),
1648+
item->string, item->string);
1649+
}
1650+
1651+
cleanup:
1652+
string_list_clear(&pruned_refs, 0);
1653+
string_list_clear(&to_delete, 0);
1654+
string_list_clear(&skipped_unmerged, 0);
14911655
return result;
14921656
}
14931657

@@ -1945,19 +2109,28 @@ static int do_fetch(struct transport *transport,
19452109
if (tags == TAGS_DEFAULT && autotags)
19462110
transport_set_option(transport, TRANS_OPT_FOLLOWTAGS, "1");
19472111
if (prune) {
2112+
struct ref *stale_refs = NULL;
2113+
struct ref **stale_refs_out = config->prune_local != PRUNE_LOCAL_OFF
2114+
? &stale_refs : NULL;
19482115
/*
19492116
* We only prune based on refspecs specified
19502117
* explicitly (via command line or configuration); we
19512118
* don't care whether --tags was specified.
19522119
*/
19532120
if (rs->nr) {
1954-
retcode = prune_refs(&display_state, rs, transaction, ref_map);
2121+
retcode = prune_refs(&display_state, rs, transaction,
2122+
ref_map, stale_refs_out);
19552123
} else {
19562124
retcode = prune_refs(&display_state, &transport->remote->fetch,
1957-
transaction, ref_map);
2125+
transaction, ref_map, stale_refs_out);
19582126
}
19592127
if (retcode != 0)
19602128
retcode = 1;
2129+
else if (stale_refs &&
2130+
prune_local_branches(&display_state, stale_refs,
2131+
config->prune_local))
2132+
retcode = 1;
2133+
free_refs(stale_refs);
19612134
}
19622135

19632136
/*
@@ -2469,6 +2642,7 @@ int cmd_fetch(int argc,
24692642
.display_format = DISPLAY_FORMAT_FULL,
24702643
.prune = -1,
24712644
.prune_tags = -1,
2645+
.prune_local = PRUNE_LOCAL_OFF,
24722646
.show_forced_updates = 1,
24732647
.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,
24742648
.parallel = 1,

t/t5510-fetch.sh

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,79 @@ test_expect_success REFFILES 'fetch --prune fails to delete branches' '
386386
)
387387
'
388388

389+
test_expect_success 'fetch.pruneLocalBranches: setup parent with doomed branch' '
390+
git init -b main pl-parent &&
391+
test_commit -C pl-parent base &&
392+
git -C pl-parent branch doomed
393+
'
394+
395+
test_expect_success 'fetch.pruneLocalBranches: off (default) leaves local branch' '
396+
git clone pl-parent pl-off &&
397+
git -C pl-off checkout -b doomed --track origin/doomed &&
398+
git -C pl-off checkout -b stay &&
399+
git -C pl-parent branch -D doomed &&
400+
git -C pl-off fetch --prune origin &&
401+
test_must_fail git -C pl-off rev-parse refs/remotes/origin/doomed &&
402+
git -C pl-off rev-parse refs/heads/doomed
403+
'
404+
405+
test_expect_success 'fetch.pruneLocalBranches=safe deletes merged local branch' '
406+
git -C pl-parent branch doomed base &&
407+
git clone pl-parent pl-safe &&
408+
git -C pl-safe checkout -b doomed --track origin/doomed &&
409+
git -C pl-safe checkout -b stay &&
410+
git -C pl-parent branch -D doomed &&
411+
git -C pl-safe -c fetch.pruneLocalBranches=safe fetch --prune origin &&
412+
test_must_fail git -C pl-safe rev-parse refs/remotes/origin/doomed &&
413+
test_must_fail git -C pl-safe rev-parse refs/heads/doomed
414+
'
415+
416+
test_expect_success 'fetch.pruneLocalBranches=safe keeps unmerged local branch' '
417+
git -C pl-parent branch doomed base &&
418+
git clone pl-parent pl-safe-unmerged &&
419+
git -C pl-safe-unmerged checkout -b doomed --track origin/doomed &&
420+
test_commit -C pl-safe-unmerged local-only &&
421+
git -C pl-safe-unmerged checkout -b stay &&
422+
git -C pl-parent branch -D doomed &&
423+
git -C pl-safe-unmerged -c fetch.pruneLocalBranches=safe fetch --prune origin 2>err &&
424+
test_must_fail git -C pl-safe-unmerged rev-parse refs/remotes/origin/doomed &&
425+
git -C pl-safe-unmerged rev-parse refs/heads/doomed &&
426+
test_grep "not fully merged" err
427+
'
428+
429+
test_expect_success 'fetch.pruneLocalBranches=force deletes unmerged local branch' '
430+
git -C pl-parent branch doomed base &&
431+
git clone pl-parent pl-force &&
432+
git -C pl-force checkout -b doomed --track origin/doomed &&
433+
test_commit -C pl-force local-only-force &&
434+
git -C pl-force checkout -b stay &&
435+
git -C pl-parent branch -D doomed &&
436+
git -C pl-force -c fetch.pruneLocalBranches=force fetch --prune origin &&
437+
test_must_fail git -C pl-force rev-parse refs/remotes/origin/doomed &&
438+
test_must_fail git -C pl-force rev-parse refs/heads/doomed
439+
'
440+
441+
test_expect_success 'fetch.pruneLocalBranches=force never deletes checked-out branch' '
442+
git -C pl-parent branch doomed base &&
443+
git clone pl-parent pl-co &&
444+
git -C pl-co checkout -b doomed --track origin/doomed &&
445+
git -C pl-parent branch -D doomed &&
446+
git -C pl-co -c fetch.pruneLocalBranches=force fetch --prune origin &&
447+
test_must_fail git -C pl-co rev-parse refs/remotes/origin/doomed &&
448+
git -C pl-co rev-parse refs/heads/doomed
449+
'
450+
451+
test_expect_success 'fetch.pruneLocalBranches has no effect without prune' '
452+
git -C pl-parent branch doomed base &&
453+
git clone pl-parent pl-noprune &&
454+
git -C pl-noprune checkout -b doomed --track origin/doomed &&
455+
git -C pl-noprune checkout -b stay &&
456+
git -C pl-parent branch -D doomed &&
457+
git -C pl-noprune -c fetch.pruneLocalBranches=force fetch origin &&
458+
git -C pl-noprune rev-parse refs/remotes/origin/doomed &&
459+
git -C pl-noprune rev-parse refs/heads/doomed
460+
'
461+
389462
test_expect_success 'fetch --atomic works with a single branch' '
390463
test_when_finished "rm -rf atomic" &&
391464

0 commit comments

Comments
 (0)