diff mbox series

branch: introduce --(no-)has-upstream and --(no-)gone options

Message ID 20230216041432.1668365-1-alexhenrie24@gmail.com (mailing list archive)
State New, archived
Headers show
Series branch: introduce --(no-)has-upstream and --(no-)gone options | expand

Commit Message

Alex Henrie Feb. 16, 2023, 4:14 a.m. UTC
GitHub and GitLab have features to create a branch using the web
interface, then delete the branch after it is merged. That results in a
lot of "gone" branches in my local clone, and I frequently find myself
typing `git branch -v | grep gone`. I don't want `git branch --merged`
because that would include branches that have been created for future
work but do not yet have any commits.

To avoid having to do error-prone string parsing, add options to filter
branches by tracking status. The --has-upstream option lists branches
that would be shown with a tracked branch in `git branch -vv` and the
--gone option further restricts the list to branches that would be shown
as [gone] in `git branch -v`. The --no-has-upstream and --no-gone
options are their inverses.

The new options could be used, for example, to create an alias for
deleting all branches that are both merged and gone:

git config alias.branch-prune '!git branch -d `git branch --gone --format="%(refname:short)"`'

In the future, an optional argument could be added to --has-upstream and
--no-has-upstream to show or hide branches that track branches on a
particular remote.

Signed-off-by: Alex Henrie <alexhenrie24@gmail.com>
---
 Documentation/git-branch.txt      | 15 ++++++
 builtin/branch.c                  | 11 ++++-
 ref-filter.c                      | 39 ++++++++++++++++
 ref-filter.h                      |  3 ++
 t/t3208-branch-tracking-filter.sh | 76 +++++++++++++++++++++++++++++++
 5 files changed, 143 insertions(+), 1 deletion(-)
 create mode 100755 t/t3208-branch-tracking-filter.sh

Comments

Junio C Hamano Feb. 16, 2023, 7 p.m. UTC | #1
Alex Henrie <alexhenrie24@gmail.com> writes:

> GitHub and GitLab have features to create a branch using the web
> interface, then delete the branch after it is merged. That results in a
> lot of "gone" branches in my local clone, and I frequently find myself
> typing `git branch -v | grep gone`. I don't want `git branch --merged`
> because that would include branches that have been created for future
> work but do not yet have any commits.

I can see why it is a useful feature to filter or group branches by
its remote tracking status, but I do not know if the design
presented here is what we want.  "--has-upstream" (yes/no) is
understandable, but "--no-has-upstream" is quite a mouthful and an
awkward way to say "no configured upstream" ("--has-no-upstream"
might be more palatable).  "--gone" does not even hint it is about
the precense or absense of upstream ("Are we looking for a branch
that is gone?  Perhaps in a future we may have logs of branches that
have been deleted?") and will not "click" in readers' mind that it
is about branches configured to track some branch at the remote that
has been removed.

Perhaps something like

	--upstream=(configured|unconfigured|gone)

may be easier to explain, understand, and possibly more extensible
but I dunno.

If most people use a single remote and track branches from the
single remote, then --upstream=origin to select branches with
upstream configured somewhere in origin would allow users who
interact with multiple remotes to further limit by remote.  Or we
could even go --upstream=refs/remotes/origin/* using ref matching
rules to specify that chosen branches must have upstream configured
to refs that match the pattern (your "--has-upstream" becomes a mere
special case of doing "--upstream=*"), with a special token, e.g.
"--upstream=no", that never matches a real ref, to select ones
without any upstream configured.

I do not know offhand how that line of UI design that allows future
enhancement would mesh with the concept of "configured upstream no
longer exists", but whatever UI we pick that is understandable,
explainable and extensible, it should be made to work well with
"gone", too.
Konstantin Khomoutov Feb. 16, 2023, 7:32 p.m. UTC | #2
Alex Henrie <alexhenrie24@gmail.com> writes:

> GitHub and GitLab have features to create a branch using the web
> interface, then delete the branch after it is merged. That results in a
> lot of "gone" branches in my local clone, and I frequently find myself
> typing `git branch -v | grep gone`. I don't want `git branch --merged`
> because that would include branches that have been created for future
> work but do not yet have any commits.

Possibly a rather silly remark, but you could make a habit of periodically
running

  git remote prune <remotename>

or fetching with "--prune".

At my $dayjob, we use GitLab, and I routinely fetch with "--prune" because
most of the time there's no sense in seeing stale (merged in and deleted)
branches, and if it's really needed, their then-tips can be figured out from
the merged commits which have integrated those branches.
Junio C Hamano Feb. 16, 2023, 10:35 p.m. UTC | #3
Konstantin Khomoutov <kostix@bswap.ru> writes:

> Alex Henrie <alexhenrie24@gmail.com> writes:
>
>> GitHub and GitLab have features to create a branch using the web
>> interface, then delete the branch after it is merged. That results in a
>> lot of "gone" branches in my local clone, and I frequently find myself
>> typing `git branch -v | grep gone`. I don't want `git branch --merged`
>> because that would include branches that have been created for future
>> work but do not yet have any commits.
>
> Possibly a rather silly remark, but you could make a habit of periodically
> running
>
>   git remote prune <remotename>
>
> or fetching with "--prune".

Likely to be a silly question, but isn't doing that, to actively
remove the remote tracking branches that correspond to branches that
no longer exist at the remote, exactly what gives Alex many local
branches that are marked as "gone" (i.e. forked from some upstream
sometime in the past, but the upstream no longer exists)?

> At my $dayjob, we use GitLab, and I routinely fetch with "--prune" because
> most of the time there's no sense in seeing stale (merged in and deleted)
> branches, and if it's really needed, their then-tips can be figured out from
> the merged commits which have integrated those branches.

Yes, as a workflow, it may make sense to aggressively prune remote
tracking branches (especially if you have good backups). But I think
the feature is more about the local branches you grow with your
commits, and not about the local copies of remote branches that went
stale.

If local topic Y forked from a remote topic X, depended on what X
did, and after a while X graduated to the primary integration branch
'main' and removed at the remote, after pulling the updated 'main',
your 'log main..Y' would still exclude the work done in 'X' and show
only your work on topic 'Y'.  You could rebase 'Y' on 'main' if you
wanted to (but I strongly discourage people from doing so in _this_
project) and a tool to see which local topics like Y lost the base X
that was work-in-progress would be a way to find which ones to rebase.
Alex Henrie Feb. 17, 2023, 3:07 a.m. UTC | #4
On Thu, Feb 16, 2023 at 12:00 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> Alex Henrie <alexhenrie24@gmail.com> writes:
>
> > GitHub and GitLab have features to create a branch using the web
> > interface, then delete the branch after it is merged. That results in a
> > lot of "gone" branches in my local clone, and I frequently find myself
> > typing `git branch -v | grep gone`. I don't want `git branch --merged`
> > because that would include branches that have been created for future
> > work but do not yet have any commits.
>
> I can see why it is a useful feature to filter or group branches by
> its remote tracking status, but I do not know if the design
> presented here is what we want.  "--has-upstream" (yes/no) is
> understandable, but "--no-has-upstream" is quite a mouthful and an
> awkward way to say "no configured upstream" ("--has-no-upstream"
> might be more palatable).  "--gone" does not even hint it is about
> the precense or absense of upstream ("Are we looking for a branch
> that is gone?  Perhaps in a future we may have logs of branches that
> have been deleted?") and will not "click" in readers' mind that it
> is about branches configured to track some branch at the remote that
> has been removed.
>
> Perhaps something like
>
>         --upstream=(configured|unconfigured|gone)
>
> may be easier to explain, understand, and possibly more extensible
> but I dunno.
>
> If most people use a single remote and track branches from the
> single remote, then --upstream=origin to select branches with
> upstream configured somewhere in origin would allow users who
> interact with multiple remotes to further limit by remote.  Or we
> could even go --upstream=refs/remotes/origin/* using ref matching
> rules to specify that chosen branches must have upstream configured
> to refs that match the pattern (your "--has-upstream" becomes a mere
> special case of doing "--upstream=*"), with a special token, e.g.
> "--upstream=no", that never matches a real ref, to select ones
> without any upstream configured.
>
> I do not know offhand how that line of UI design that allows future
> enhancement would mesh with the concept of "configured upstream no
> longer exists", but whatever UI we pick that is understandable,
> explainable and extensible, it should be made to work well with
> "gone", too.

Hi Junio, thank you for the feedback.

I intentionally avoided naming the new option --upstream to avoid
confusion with the -u and --set-upstream-to options. And as you
pointed out, --upstream=(configured|unconfigured|gone) would preclude
adding an optional argument to search for branches with a particular
upstream.

I don't know how we could make the negative options sound better. The
inverses of --merged and --contains are --no-merged and --no-contains
(which also sound a little weird, but are perfectly understandable),
and I think there's value in following the same pattern.

You have a good point that --gone makes it sound like the option
searches for locally deleted branches. How about --upstream-gone
instead?

-Alex
Alex Henrie Feb. 17, 2023, 3:12 a.m. UTC | #5
On Thu, Feb 16, 2023 at 3:40 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> Konstantin Khomoutov <kostix@bswap.ru> writes:
>
> > Alex Henrie <alexhenrie24@gmail.com> writes:
> >
> >> GitHub and GitLab have features to create a branch using the web
> >> interface, then delete the branch after it is merged. That results in a
> >> lot of "gone" branches in my local clone, and I frequently find myself
> >> typing `git branch -v | grep gone`. I don't want `git branch --merged`
> >> because that would include branches that have been created for future
> >> work but do not yet have any commits.
> >
> > Possibly a rather silly remark, but you could make a habit of periodically
> > running
> >
> >   git remote prune <remotename>
> >
> > or fetching with "--prune".
>
> Likely to be a silly question, but isn't doing that, to actively
> remove the remote tracking branches that correspond to branches that
> no longer exist at the remote, exactly what gives Alex many local
> branches that are marked as "gone" (i.e. forked from some upstream
> sometime in the past, but the upstream no longer exists)?

Yes, the branches are marked [gone] precisely because I configured
fetch.prune to true. So fetching automatically deletes the local
copies of the upstream branches, but the local branches that track
them are still there.

-Alex
Phillip Wood Feb. 17, 2023, 10:50 a.m. UTC | #6
Hi Alex

On 16/02/2023 04:14, Alex Henrie wrote:
> GitHub and GitLab have features to create a branch using the web
> interface, then delete the branch after it is merged. That results in a
> lot of "gone" branches in my local clone, and I frequently find myself
> typing `git branch -v | grep gone`. I don't want `git branch --merged`
> because that would include branches that have been created for future
> work but do not yet have any commits.
> 
> To avoid having to do error-prone string parsing, add options to filter
> branches by tracking status. The --has-upstream option lists branches
> that would be shown with a tracked branch in `git branch -vv` and the
> --gone option further restricts the list to branches that would be shown
> as [gone] in `git branch -v`. The --no-has-upstream and --no-gone
> options are their inverses.
> 
> The new options could be used, for example, to create an alias for
> deleting all branches that are both merged and gone:
> 
> git config alias.branch-prune '!git branch -d `git branch --gone --format="%(refname:short)"`'
> 
> In the future, an optional argument could be added to --has-upstream and
> --no-has-upstream to show or hide branches that track branches on a
> particular remote.

Rather than adding several new options with hard to understand names I 
wonder if it would be better to add a --filter option that can be 
extended in the future.

--filter upstream[=<pattern>]
   Limit the output to branches whose configured upstream matches
   <pattern>. If the optional pattern is omitted list all branches
   with a configured upstream. To list branches with no configured
   upstream use an empty pattern i.e. "upstream="

--filter pruneable[=<remote>]
   Limit the output to branches whose upstream has been removed by
   "git fetch --prune". If <remote> is given only list those branches
   whose upstream matches that remote.

We could allow --filter with --delete so one could run
     git branch --delete --filter pruneable=origin
to delete all the branches with a missing upstream on the remote origin.

If we wanted we could add "--filter contains=<commit>", "--filter 
merged=<commit>" and "--filter points-at=<commit>" and say the existing 
options are alias for those filters.

I'm not a heavy user of "git branch -v" but I found the talk of "gone" 
branches quite confusing, it might be clearer to say the the upstream 
branch is missing or that the branch is pruneable.

Best Wishes

Phillip


> Signed-off-by: Alex Henrie <alexhenrie24@gmail.com>
> ---
>   Documentation/git-branch.txt      | 15 ++++++
>   builtin/branch.c                  | 11 ++++-
>   ref-filter.c                      | 39 ++++++++++++++++
>   ref-filter.h                      |  3 ++
>   t/t3208-branch-tracking-filter.sh | 76 +++++++++++++++++++++++++++++++
>   5 files changed, 143 insertions(+), 1 deletion(-)
>   create mode 100755 t/t3208-branch-tracking-filter.sh
> 
> diff --git a/Documentation/git-branch.txt b/Documentation/git-branch.txt
> index d382ac69f7..99cd0486dc 100644
> --- a/Documentation/git-branch.txt
> +++ b/Documentation/git-branch.txt
> @@ -13,6 +13,8 @@ SYNOPSIS
>   	[--column[=<options>] | --no-column] [--sort=<key>]
>   	[--merged [<commit>]] [--no-merged [<commit>]]
>   	[--contains [<commit>]] [--no-contains [<commit>]]
> +	[--has-upstream | --no-has-upstream]
> +	[--gone | --no-gone]
>   	[--points-at <object>] [--format=<format>]
>   	[(-r | --remotes) | (-a | --all)]
>   	[--list] [<pattern>...]
> @@ -325,6 +327,19 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
>   	detached HEAD (if present) first, then local branches and
>   	finally remote-tracking branches. See linkgit:git-config[1].
>   
> +--has-upstream::
> +	Only list branches that track an upstream branch. Implies `--list`.
> +
> +--no-has-upstream::
> +	Only list branches that do not track an upstream branch. Implies `--list`.
> +
> +--gone::
> +	Only list branches that track a gone upstream branch. Implies `--list` and
> +	`--has-upstream`.
> +
> +--no-gone::
> +	Only list branches that do not track a gone upstream branch. Implies
> +	`--list`.
>   
>   --points-at <object>::
>   	Only list branches of the given object.
> diff --git a/builtin/branch.c b/builtin/branch.c
> index f63fd45edb..5cac6dc3c6 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -680,6 +680,14 @@ int cmd_branch(int argc, const char **argv, const char *prefix)
>   		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")),
> +		OPT_SET_INT_F(0, "has-upstream", &filter.has_upstream,
> +			      N_("print only branches that track an upstream branch"), 1, PARSE_OPT_NONEG),
> +		OPT_SET_INT_F(0, "no-has-upstream", &filter.has_upstream,
> +			      N_("print only branches that do not track an upstream branch"), -1, PARSE_OPT_NONEG),
> +		OPT_SET_INT_F(0, "gone", &filter.upstream_gone,
> +			      N_("print only branches that track a gone upstream branch"), 1, PARSE_OPT_NONEG),
> +		OPT_SET_INT_F(0, "no-gone", &filter.upstream_gone,
> +			      N_("print only branches that do not track a gone upstream branch"), -1, PARSE_OPT_NONEG),
>   		OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
>   		OPT_REF_SORT(&sorting_options),
>   		OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
> @@ -719,7 +727,8 @@ int cmd_branch(int argc, const char **argv, const char *prefix)
>   		list = 1;
>   
>   	if (filter.with_commit || filter.no_commit ||
> -	    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
> +	    filter.reachable_from || filter.unreachable_from ||
> +	    filter.points_at.nr || filter.has_upstream || filter.upstream_gone)
>   		list = 1;
>   
>   	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> diff --git a/ref-filter.c b/ref-filter.c
> index f8203c6b05..a0f629bbf7 100644
> --- a/ref-filter.c
> +++ b/ref-filter.c
> @@ -2358,6 +2358,42 @@ void ref_array_clear(struct ref_array *array)
>   	}
>   }
>   
> +static void do_tracking_filter(struct ref_filter_cbdata *ref_cbdata)
> +{
> +	struct ref_filter *filter = ref_cbdata->filter;
> +	struct ref_array *array = ref_cbdata->array;
> +	int i, old_nr;
> +
> +	old_nr = array->nr;
> +	array->nr = 0;
> +
> +	for (i = 0; i < old_nr; i++) {
> +		struct ref_array_item *item = array->items[i];
> +		const char *branch_name = item->refname;
> +		struct branch *branch;
> +		int num_ours, num_theirs, gone;
> +		const char *base;
> +
> +		skip_prefix(branch_name, "refs/heads/", &branch_name);
> +		branch = branch_get(branch_name);
> +		gone = stat_tracking_info(branch, &num_ours, &num_theirs,
> +					  &base, 0, AHEAD_BEHIND_QUICK) < 0;
> +
> +		if (filter->has_upstream == 1 && !base)
> +			goto remove;
> +		if (filter->has_upstream == -1 && base)
> +			goto remove;
> +		if (filter->upstream_gone == 1 && (!base || !gone))
> +			goto remove;
> +		if (filter->upstream_gone == -1 && base && gone)
> +			goto remove;
> +		array->items[array->nr++] = array->items[i];
> +		continue;
> +remove:
> +		free_array_item(item);
> +	}
> +}
> +
>   #define EXCLUDE_REACHED 0
>   #define INCLUDE_REACHED 1
>   static void reach_filter(struct ref_array *array,
> @@ -2466,6 +2502,9 @@ int filter_refs(struct ref_array *array, struct ref_filter *filter, unsigned int
>   	clear_contains_cache(&ref_cbdata.contains_cache);
>   	clear_contains_cache(&ref_cbdata.no_contains_cache);
>   
> +	if (filter->has_upstream || filter->upstream_gone)
> +		do_tracking_filter(&ref_cbdata);
> +
>   	/*  Filters that need revision walking */
>   	reach_filter(array, filter->reachable_from, INCLUDE_REACHED);
>   	reach_filter(array, filter->unreachable_from, EXCLUDE_REACHED);
> diff --git a/ref-filter.h b/ref-filter.h
> index aa0eea4ecf..3d0b321a16 100644
> --- a/ref-filter.h
> +++ b/ref-filter.h
> @@ -57,6 +57,9 @@ struct ref_filter {
>   	struct commit_list *reachable_from;
>   	struct commit_list *unreachable_from;
>   
> +	int has_upstream;
> +	int upstream_gone;
> +
>   	unsigned int with_commit_tag_algo : 1,
>   		match_as_path : 1,
>   		ignore_case : 1,
> diff --git a/t/t3208-branch-tracking-filter.sh b/t/t3208-branch-tracking-filter.sh
> new file mode 100755
> index 0000000000..51e9453ffb
> --- /dev/null
> +++ b/t/t3208-branch-tracking-filter.sh
> @@ -0,0 +1,76 @@
> +#!/bin/sh
> +
> +test_description='branch tracking filter options'
> +
> +. ./test-lib.sh
> +
> +test_expect_success setup '
> +	git init --initial-branch=tracked-present r1 &&
> +	git -C r1 commit --allow-empty -m "Initial commit" &&
> +	git -C r1 branch upstream-only &&
> +	git -C r1 branch untracked &&
> +	git clone r1 r2 &&
> +	cd r2 &&
> +	git checkout -b tracked-gone &&
> +	git push --set-upstream origin tracked-gone &&
> +	git push origin :tracked-gone &&
> +	git branch --no-track untracked &&
> +	git branch downstream-only
> +'
> +
> +test_expect_success 'all local branches' '
> +	git branch >actual &&
> +	cat >expect <<-\EOF &&
> +	  downstream-only
> +	* tracked-gone
> +	  tracked-present
> +	  untracked
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'branch --has-upstream' '
> +	git branch --has-upstream >actual &&
> +	cat >expect <<-\EOF &&
> +	* tracked-gone
> +	  tracked-present
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'branch --no-has-upstream' '
> +	git branch --no-has-upstream >actual &&
> +	cat >expect <<-\EOF &&
> +	  downstream-only
> +	  untracked
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'branch --gone' '
> +	git branch --gone >actual &&
> +	cat >expect <<-\EOF &&
> +	* tracked-gone
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'branch --no-gone' '
> +	git branch --no-gone >actual &&
> +	cat >expect <<-\EOF &&
> +	  downstream-only
> +	  tracked-present
> +	  untracked
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'branch --has-upstream --no-gone' '
> +	git branch --has-upstream --no-gone >actual &&
> +	cat >expect <<-\EOF &&
> +	  tracked-present
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_done
Konstantin Khomoutov Feb. 17, 2023, 11:10 a.m. UTC | #7
On Thu, Feb 16, 2023 at 08:12:08PM -0700, Alex Henrie wrote:


> > >> GitHub and GitLab have features to create a branch using the web
> > >> interface, then delete the branch after it is merged. That results in a
> > >> lot of "gone" branches in my local clone, and I frequently find myself
> > >> typing `git branch -v | grep gone`. I don't want `git branch --merged`
> > >> because that would include branches that have been created for future
> > >> work but do not yet have any commits.
> > >
> > > Possibly a rather silly remark, but you could make a habit of periodically
> > > running
> > >
> > >   git remote prune <remotename>
> > >
> > > or fetching with "--prune".
> >
> > Likely to be a silly question, but isn't doing that, to actively
> > remove the remote tracking branches that correspond to branches that
> > no longer exist at the remote, exactly what gives Alex many local
> > branches that are marked as "gone" (i.e. forked from some upstream
> > sometime in the past, but the upstream no longer exists)?
> 
> Yes, the branches are marked [gone] precisely because I configured
> fetch.prune to true. So fetching automatically deletes the local
> copies of the upstream branches, but the local branches that track
> them are still there.

Ah, thanks, I see now.

I have a habit of always checking out remote branches directly when doing any
work on them (they end up in a detached HEAD state), so I have sort of
automagically evaded your problem not being aware of the fact.
Junio C Hamano Feb. 17, 2023, 7:44 p.m. UTC | #8
Phillip Wood <phillip.wood123@gmail.com> writes:

> Rather than adding several new options with hard to understand names I
> wonder if it would be better to add a --filter option that can be
> extended in the future.
>
> --filter upstream[=<pattern>]
>   Limit the output to branches whose configured upstream matches
>   <pattern>. If the optional pattern is omitted list all branches
>   with a configured upstream. To list branches with no configured
>   upstream use an empty pattern i.e. "upstream="
> ...
> If we wanted we could add "--filter contains=<commit>", "--filter
> merged=<commit>" and "--filter points-at=<commit>" and say the
> existing options are alias for those filters.

I do find the above approach much easier to explain and understand,
especially the part that makes it clear to users that the option is
about filtering the list of branches with their upstream status.

> --filter pruneable[=<remote>]
>   Limit the output to branches whose upstream has been removed by
>   "git fetch --prune". If <remote> is given only list those branches
>   whose upstream matches that remote.
>
> We could allow --filter with --delete so one could run
>     git branch --delete --filter pruneable=origin
> to delete all the branches with a missing upstream on the remote origin.

I however do not think "prunable" is an appropriate phrase for the
"gone" stuff.  Your topic may have started by forking from a remote
topic some time ago, and you have accumulated some work on the topic
branch.

    $ git fetch
    $ git checkout -b -t my-topic-Y remotes/origin/their-topic-X
    $ work work work 
    $ git commit

Back when you started your topic, their-topic-X was still work in
progress and was not merged to the mainline of the project, but it
recently graduated to the mainline.  The branch their-topic-X has
become unnecessary at the origin, and they removed the branch.  Then
you updated from the origin:

    $ git fetch --prune

Now, you no longer have remotes/origin/their-topic-X which my-topic-Y
was based on.  Did it make the my-topic-Y branch "prunable"?  Can we
discard it?  I doubt it.

If on the other hand the reason why their-topic-X disappeared as a
remote tracking branch was because it turned out to be a bad idea,
and got rejected without having been merged to anywhere, then, yes,
any new work you made on my-topic-Y is based on the faulty foundation
and may need to be redone completely or discarded.  But still I do
not think "prunable" is a good way to describe it.
diff mbox series

Patch

diff --git a/Documentation/git-branch.txt b/Documentation/git-branch.txt
index d382ac69f7..99cd0486dc 100644
--- a/Documentation/git-branch.txt
+++ b/Documentation/git-branch.txt
@@ -13,6 +13,8 @@  SYNOPSIS
 	[--column[=<options>] | --no-column] [--sort=<key>]
 	[--merged [<commit>]] [--no-merged [<commit>]]
 	[--contains [<commit>]] [--no-contains [<commit>]]
+	[--has-upstream | --no-has-upstream]
+	[--gone | --no-gone]
 	[--points-at <object>] [--format=<format>]
 	[(-r | --remotes) | (-a | --all)]
 	[--list] [<pattern>...]
@@ -325,6 +327,19 @@  superproject's "origin/main", but tracks the submodule's "origin/main".
 	detached HEAD (if present) first, then local branches and
 	finally remote-tracking branches. See linkgit:git-config[1].
 
+--has-upstream::
+	Only list branches that track an upstream branch. Implies `--list`.
+
+--no-has-upstream::
+	Only list branches that do not track an upstream branch. Implies `--list`.
+
+--gone::
+	Only list branches that track a gone upstream branch. Implies `--list` and
+	`--has-upstream`.
+
+--no-gone::
+	Only list branches that do not track a gone upstream branch. Implies
+	`--list`.
 
 --points-at <object>::
 	Only list branches of the given object.
diff --git a/builtin/branch.c b/builtin/branch.c
index f63fd45edb..5cac6dc3c6 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -680,6 +680,14 @@  int cmd_branch(int argc, const char **argv, const char *prefix)
 		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")),
+		OPT_SET_INT_F(0, "has-upstream", &filter.has_upstream,
+			      N_("print only branches that track an upstream branch"), 1, PARSE_OPT_NONEG),
+		OPT_SET_INT_F(0, "no-has-upstream", &filter.has_upstream,
+			      N_("print only branches that do not track an upstream branch"), -1, PARSE_OPT_NONEG),
+		OPT_SET_INT_F(0, "gone", &filter.upstream_gone,
+			      N_("print only branches that track a gone upstream branch"), 1, PARSE_OPT_NONEG),
+		OPT_SET_INT_F(0, "no-gone", &filter.upstream_gone,
+			      N_("print only branches that do not track a gone upstream branch"), -1, PARSE_OPT_NONEG),
 		OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
 		OPT_REF_SORT(&sorting_options),
 		OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -719,7 +727,8 @@  int cmd_branch(int argc, const char **argv, const char *prefix)
 		list = 1;
 
 	if (filter.with_commit || filter.no_commit ||
-	    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
+	    filter.reachable_from || filter.unreachable_from ||
+	    filter.points_at.nr || filter.has_upstream || filter.upstream_gone)
 		list = 1;
 
 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
diff --git a/ref-filter.c b/ref-filter.c
index f8203c6b05..a0f629bbf7 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -2358,6 +2358,42 @@  void ref_array_clear(struct ref_array *array)
 	}
 }
 
+static void do_tracking_filter(struct ref_filter_cbdata *ref_cbdata)
+{
+	struct ref_filter *filter = ref_cbdata->filter;
+	struct ref_array *array = ref_cbdata->array;
+	int i, old_nr;
+
+	old_nr = array->nr;
+	array->nr = 0;
+
+	for (i = 0; i < old_nr; i++) {
+		struct ref_array_item *item = array->items[i];
+		const char *branch_name = item->refname;
+		struct branch *branch;
+		int num_ours, num_theirs, gone;
+		const char *base;
+
+		skip_prefix(branch_name, "refs/heads/", &branch_name);
+		branch = branch_get(branch_name);
+		gone = stat_tracking_info(branch, &num_ours, &num_theirs,
+					  &base, 0, AHEAD_BEHIND_QUICK) < 0;
+
+		if (filter->has_upstream == 1 && !base)
+			goto remove;
+		if (filter->has_upstream == -1 && base)
+			goto remove;
+		if (filter->upstream_gone == 1 && (!base || !gone))
+			goto remove;
+		if (filter->upstream_gone == -1 && base && gone)
+			goto remove;
+		array->items[array->nr++] = array->items[i];
+		continue;
+remove:
+		free_array_item(item);
+	}
+}
+
 #define EXCLUDE_REACHED 0
 #define INCLUDE_REACHED 1
 static void reach_filter(struct ref_array *array,
@@ -2466,6 +2502,9 @@  int filter_refs(struct ref_array *array, struct ref_filter *filter, unsigned int
 	clear_contains_cache(&ref_cbdata.contains_cache);
 	clear_contains_cache(&ref_cbdata.no_contains_cache);
 
+	if (filter->has_upstream || filter->upstream_gone)
+		do_tracking_filter(&ref_cbdata);
+
 	/*  Filters that need revision walking */
 	reach_filter(array, filter->reachable_from, INCLUDE_REACHED);
 	reach_filter(array, filter->unreachable_from, EXCLUDE_REACHED);
diff --git a/ref-filter.h b/ref-filter.h
index aa0eea4ecf..3d0b321a16 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -57,6 +57,9 @@  struct ref_filter {
 	struct commit_list *reachable_from;
 	struct commit_list *unreachable_from;
 
+	int has_upstream;
+	int upstream_gone;
+
 	unsigned int with_commit_tag_algo : 1,
 		match_as_path : 1,
 		ignore_case : 1,
diff --git a/t/t3208-branch-tracking-filter.sh b/t/t3208-branch-tracking-filter.sh
new file mode 100755
index 0000000000..51e9453ffb
--- /dev/null
+++ b/t/t3208-branch-tracking-filter.sh
@@ -0,0 +1,76 @@ 
+#!/bin/sh
+
+test_description='branch tracking filter options'
+
+. ./test-lib.sh
+
+test_expect_success setup '
+	git init --initial-branch=tracked-present r1 &&
+	git -C r1 commit --allow-empty -m "Initial commit" &&
+	git -C r1 branch upstream-only &&
+	git -C r1 branch untracked &&
+	git clone r1 r2 &&
+	cd r2 &&
+	git checkout -b tracked-gone &&
+	git push --set-upstream origin tracked-gone &&
+	git push origin :tracked-gone &&
+	git branch --no-track untracked &&
+	git branch downstream-only
+'
+
+test_expect_success 'all local branches' '
+	git branch >actual &&
+	cat >expect <<-\EOF &&
+	  downstream-only
+	* tracked-gone
+	  tracked-present
+	  untracked
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success 'branch --has-upstream' '
+	git branch --has-upstream >actual &&
+	cat >expect <<-\EOF &&
+	* tracked-gone
+	  tracked-present
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success 'branch --no-has-upstream' '
+	git branch --no-has-upstream >actual &&
+	cat >expect <<-\EOF &&
+	  downstream-only
+	  untracked
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success 'branch --gone' '
+	git branch --gone >actual &&
+	cat >expect <<-\EOF &&
+	* tracked-gone
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success 'branch --no-gone' '
+	git branch --no-gone >actual &&
+	cat >expect <<-\EOF &&
+	  downstream-only
+	  tracked-present
+	  untracked
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success 'branch --has-upstream --no-gone' '
+	git branch --has-upstream --no-gone >actual &&
+	cat >expect <<-\EOF &&
+	  tracked-present
+	EOF
+	test_cmp expect actual
+'
+
+test_done