Git development
 help / color / mirror / Atom feed
From: Jasper Mayone <me@jaspermayone.com>
To: git@vger.kernel.org
Cc: gitster@pobox.com, sunshine@sunshineco.com,
	haraldnordgren@gmail.com, ben.knoble@gmail.com,
	Jasper Mayone <me@jaspermayone.com>
Subject: [PATCH v2] worktree list: add --branch to select worktrees by branch
Date: Sat, 24 Oct 2026 12:53:11 -0400	[thread overview]
Message-ID: <20261024165311.57387-1-me@jaspermayone.com> (raw)
In-Reply-To: <CAPig+cTZEcn_5TG0iMVJJym8Zhu1KYP+y9Oc1J=JJgDYSzNSdg@mail.gmail.com>

There is no direct way to ask which worktree has a given branch
checked out. "git worktree list --porcelain" prints the branch of
every worktree, so a script has to parse the whole list and match the
"branch" lines itself.

Teach "git worktree list" a "--branch=<branch>" option that lists
only the worktrees that have <branch> checked out. It can be given
more than once. A worktree that is rebasing or bisecting a branch
counts as having it checked out, as it does for "git branch -d" and
"git worktree add". <branch> is a branch name, or a full name under
refs/heads/.

This keeps the choice of branches in "git branch", which already has
--merged, --contains, --points-at and patterns. For example, to list
the worktrees whose branch is merged into origin/main:

    git worktree list \
	$(git branch --format='--branch=%(refname)' --merged origin/main)

The --format is needed because the default output of "git branch"
marks the current branch with "*" and the branches checked out in
other worktrees with "+". It uses the full name because
%(refname:short) gives "heads/topic" when a tag is also called
"topic".

Each worktree's branch is found once and looked up in a set of the
requested branches, so feeding it every branch of a large repository
stays cheap.

A detached worktree has no branch, so it is never listed.

Suggested-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Jasper Mayone <me@jaspermayone.com>
---
Changes since the RFC [1]:

 * Junio pointed out that --merged and --no-merged narrow the problem
   to one workflow, and suggested selecting worktrees by branch
   instead, with "git branch" doing the choosing. Eric had the same
   reaction. This version does that and drops --merged, --no-merged
   and the reachability code.

 * Junio's example piped "git branch" through sed. Its default output
   prefixes the current branch with "*" and branches checked out in
   other worktrees with "+", which are the branches this is for, so
   the words come out as "--branch=+" and "topic". The documented form
   uses --format='--branch=%(refname)'. The full name also avoids
   "heads/topic" from %(refname:short) when a tag has the same name.

 * <branch> is an exact name, not a pattern, because "git branch"
   already has --list <pattern>, --merged, --contains and friends.
   An invalid name dies, as for "git branch -d". A valid name that no
   worktree has checked out lists nothing.

 * Eric, a "git worktree foreach" would be nice, and it could select
   worktrees with this same --branch. I left it out of this patch.

[1] https://lore.kernel.org/git/20261021173350.67854-1-me@jaspermayone.com/

 Documentation/git-worktree.adoc |  21 ++++-
 builtin/worktree.c              |  79 ++++++++++++++++++-
 t/t2402-worktree-list.sh        | 131 ++++++++++++++++++++++++++++++++
 3 files changed, 228 insertions(+), 3 deletions(-)

diff --git a/Documentation/git-worktree.adoc b/Documentation/git-worktree.adoc
index e6e77252ab..4db11eab88 100644
--- a/Documentation/git-worktree.adoc
+++ b/Documentation/git-worktree.adoc
@@ -11,7 +11,7 @@ SYNOPSIS
 [synopsis]
 git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]
 		 [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]
-git worktree list [-v | --porcelain [-z]]
+git worktree list [-v | --porcelain [-z]] [--branch=<branch>...]
 git worktree lock [--reason <string>] <worktree>
 git worktree move <worktree> <new-path>
 git worktree prune [-n] [-v] [--expire <expire>]
@@ -114,6 +114,9 @@ whether the worktree is bare, the revision currently checked out, the
 branch currently checked out (or "detached HEAD" if none), "locked" if
 the worktree is locked, "prunable" if the worktree can be pruned by the
 `prune` command.
++
+With `--branch`, only the worktrees that have one of the given branches
+checked out are listed.
 
 `lock`::
 
@@ -268,6 +271,22 @@ mismatch, even if the links are correct.
 	to parse the output when a worktree path contains a newline
 	character.
 
+`--branch=<branch>`::
+	With `list`, only list worktrees that have _<branch>_ checked
+	out, including a worktree that is rebasing or bisecting
+	_<branch>_. _<branch>_ is a branch name, such as `topic` or
+	`@{-1}`, or a full name under `refs/heads/`. The option can be
+	given more than once to list the worktrees of several branches.
+	A detached worktree is never listed.
++
+Use linkgit:git-branch[1] to choose the branches. For example, to list
+the worktrees whose branch is merged into `origin/main`:
++
+------------
+$ git worktree list \
+	$(git branch --format='--branch=%(refname)' --merged origin/main)
+------------
+
 `-q`::
 `--quiet`::
 	With `add`, suppress feedback messages.
diff --git a/builtin/worktree.c b/builtin/worktree.c
index 77ecd0f71f..1d7db5f7fc 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -15,6 +15,7 @@
 #include "object-name.h"
 #include "parse-options.h"
 #include "path.h"
+#include "strmap.h"
 #include "strvec.h"
 #include "branch.h"
 #include "read-cache-ll.h"
@@ -26,6 +27,7 @@
 #include "submodule.h"
 #include "utf8.h"
 #include "worktree.h"
+#include "wt-status.h"
 #include "quote.h"
 
 #define BUILTIN_WORKTREE_ADD_USAGE \
@@ -33,7 +35,7 @@
 	   "                 [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]")
 
 #define BUILTIN_WORKTREE_LIST_USAGE \
-	N_("git worktree list [-v | --porcelain [-z]]")
+	N_("git worktree list [-v | --porcelain [-z]] [--branch=<branch>...]")
 #define BUILTIN_WORKTREE_LOCK_USAGE \
 	N_("git worktree lock [--reason <string>] <worktree>")
 #define BUILTIN_WORKTREE_MOVE_USAGE \
@@ -1125,11 +1127,78 @@ static void pathsort(struct worktree **wt)
 	QSORT(wt, n, pathcmp);
 }
 
+/*
+ * Return the full name of the branch that "wt" has checked out, or
+ * NULL. A worktree that is rebasing or bisecting a branch has it
+ * checked out, too, as for is_shared_symref().
+ */
+static char *checked_out_branch(const struct worktree *wt)
+{
+	struct wt_status_state state = { 0 };
+	char *ref = NULL;
+
+	if (wt->is_bare)
+		return NULL;
+	if (!wt->is_detached)
+		return xstrdup_or_null(wt->head_ref);
+
+	if (wt_status_check_rebase(wt, &state) &&
+	    (state.rebase_in_progress || state.rebase_interactive_in_progress) &&
+	    state.branch)
+		ref = xstrfmt("refs/heads/%s", state.branch);
+	else if (wt_status_check_bisect(wt, &state) && state.bisecting_from)
+		ref = xstrfmt("refs/heads/%s", state.bisecting_from);
+	wt_status_state_free_buffers(&state);
+	return ref;
+}
+
+/*
+ * Keep only the worktrees that have one of the "--branch" branches
+ * checked out. A branch is given by its name, or by its full name
+ * under refs/heads/.
+ */
+static void filter_by_branch(struct repository *repo, struct worktree **wt,
+			     const struct string_list *branches)
+{
+	struct strset refnames = STRSET_INIT;
+	struct string_list_item *item;
+	struct strbuf ref = STRBUF_INIT;
+	size_t kept = 0;
+
+	for_each_string_list_item(item, branches) {
+		strbuf_reset(&ref);
+		if (starts_with(item->string, "refs/heads/")) {
+			strbuf_addstr(&ref, item->string);
+			if (check_refname_format(ref.buf, 0))
+				die(_("'%s' is not a valid branch name"),
+				    item->string);
+		} else if (check_branch_ref(repo, &ref, item->string)) {
+			die(_("'%s' is not a valid branch name"), item->string);
+		}
+		strset_add(&refnames, ref.buf);
+	}
+
+	for (size_t i = 0; wt[i]; i++) {
+		char *branch = checked_out_branch(wt[i]);
+
+		if (branch && strset_contains(&refnames, branch))
+			wt[kept++] = wt[i];
+		else
+			free_worktree(wt[i]);
+		free(branch);
+	}
+	wt[kept] = NULL;
+
+	strbuf_release(&ref);
+	strset_clear(&refnames);
+}
+
 static int list(int ac, const char **av, const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	int porcelain = 0;
 	int line_terminator = '\n';
+	struct string_list branches = STRING_LIST_INIT_DUP;
 
 	struct option options[] = {
 		OPT_BOOL(0, "porcelain", &porcelain, N_("machine-readable output")),
@@ -1138,6 +1207,8 @@ static int list(int ac, const char **av, const char *prefix,
 				N_("add 'prunable' annotation to missing worktrees older than <time>")),
 		OPT_SET_INT('z', NULL, &line_terminator,
 			    N_("terminate records with a NUL character"), '\0'),
+		OPT_STRING_LIST(0, "branch", &branches, N_("branch"),
+				N_("only show worktrees that have <branch> checked out")),
 		OPT_END()
 	};
 
@@ -1157,6 +1228,9 @@ static int list(int ac, const char **av, const char *prefix,
 		/* sort worktrees by path but keep main worktree at top */
 		pathsort(worktrees + 1);
 
+		if (branches.nr)
+			filter_by_branch(repo, worktrees, &branches);
+
 		if (!porcelain)
 			measure_widths(worktrees, &abbrev,
 				       &display, &path_maxwidth);
@@ -1174,6 +1248,7 @@ static int list(int ac, const char **av, const char *prefix,
 		free(display);
 		free_worktrees(worktrees);
 	}
+	string_list_clear(&branches, 0);
 	return 0;
 }
 
diff --git a/t/t2402-worktree-list.sh b/t/t2402-worktree-list.sh
index eeaf800d74..ea08bf79fa 100755
--- a/t/t2402-worktree-list.sh
+++ b/t/t2402-worktree-list.sh
@@ -318,4 +318,135 @@ test_expect_success 'worktree path when called in .git directory' '
 	test_cmp list1 list2
 '
 
+test_expect_success 'setup for "list --branch"' '
+	git init by-branch &&
+	(
+		cd by-branch &&
+		test_commit base &&
+		git branch topic-a &&
+		git branch topic-b &&
+		git branch unused &&
+		git worktree add ../wt-a topic-a &&
+		git worktree add ../wt-b topic-b &&
+		git worktree add --detach ../wt-detached main &&
+		git -C ../wt-a commit --allow-empty -m a &&
+		git -C ../wt-b commit --allow-empty -m b &&
+		git merge --ff-only topic-a
+	)
+'
+
+list_paths () {
+	git -C by-branch worktree list --porcelain "$@" >out &&
+	sed -n "s/^worktree //p" out >actual
+}
+
+test_expect_success '"list --branch" shows the worktree of that branch' '
+	list_paths --branch=topic-a &&
+	echo "$(pwd)/wt-a" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" can be repeated' '
+	list_paths --branch=topic-b --branch=main --branch=topic-a &&
+	cat >expect <<-EOF &&
+	$(pwd)/by-branch
+	$(pwd)/wt-a
+	$(pwd)/wt-b
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" with a branch that is not checked out' '
+	list_paths --branch=unused --branch=no-such-branch &&
+	test_must_be_empty actual
+'
+
+test_expect_success '"list --branch" never shows a detached worktree' '
+	list_paths --branch=main &&
+	echo "$(pwd)/by-branch" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" rejects an invalid branch name' '
+	test_must_fail git -C by-branch worktree list --branch=-bad 2>err &&
+	test_grep "is not a valid branch name" err
+'
+
+test_expect_success '"list --branch" keeps the normal output format' '
+	git -C by-branch worktree list --branch=topic-a >out &&
+	sed "s/  */ /g" <out >actual &&
+	echo "$(pwd)/wt-a $(git -C by-branch rev-parse --short topic-a) [topic-a]" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" works with -z' '
+	git -C by-branch worktree list --porcelain -z --branch=topic-b >out &&
+	tr "\0" "\n" <out >actual &&
+	cat >expect <<-EOF &&
+	worktree $(pwd)/wt-b
+	HEAD $(git -C by-branch rev-parse topic-b)
+	branch refs/heads/topic-b
+
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" shows a worktree bisecting that branch' '
+	test_when_finished "git -C wt-b bisect reset && git -C wt-b reset --hard HEAD~2" &&
+	git -C wt-b commit --allow-empty -m b1 &&
+	git -C wt-b commit --allow-empty -m b2 &&
+	git -C wt-b bisect start topic-b topic-b~2 &&
+	test_must_fail git -C wt-b symbolic-ref -q HEAD &&
+	list_paths --branch=topic-b &&
+	echo "$(pwd)/wt-b" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" shows a worktree rebasing that branch' '
+	test_when_finished "git -C wt-b rebase --abort" &&
+	test_must_fail git -C wt-b rebase -f -x false base &&
+	test_must_fail git -C wt-b symbolic-ref -q HEAD &&
+	list_paths --branch=topic-b &&
+	echo "$(pwd)/wt-b" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" accepts a full branch name' '
+	list_paths --branch=refs/heads/topic-a &&
+	echo "$(pwd)/wt-a" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" with a tag of the same name' '
+	test_when_finished "git -C by-branch tag -d topic-a" &&
+	git -C by-branch tag topic-a base &&
+	list_paths --branch=topic-a &&
+	echo "$(pwd)/wt-a" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '"list --branch" shows unborn and prunable worktrees' '
+	test_when_finished "rm -rf wt-unborn wt-gone && git -C by-branch worktree prune" &&
+	git -C by-branch worktree add --orphan -b unborn ../wt-unborn &&
+	git -C by-branch branch gone &&
+	git -C by-branch worktree add ../wt-gone gone &&
+	rm -rf wt-gone &&
+	git -C by-branch worktree list -v --branch=unborn --branch=gone >out &&
+	test_grep "^$(pwd)/wt-unborn .*\[unborn\]$" out &&
+	test_grep "^$(pwd)/wt-gone .*\[gone\]$" out &&
+	test_grep "prunable: gitdir file points to non-existent location" out
+'
+
+test_expect_success '"list --branch" with "git branch --merged"' '
+	git -C by-branch worktree list --porcelain \
+		$(git -C by-branch branch --format="--branch=%(refname)" \
+			--merged main) >out &&
+	sed -n "s/^worktree //p" out >actual &&
+	cat >expect <<-EOF &&
+	$(pwd)/by-branch
+	$(pwd)/wt-a
+	EOF
+	test_cmp expect actual
+'
+
 test_done

base-commit: 7e34d8174dcda3435d1404c9a30bef46afe6c13e
-- 
2.56.0


  reply	other threads:[~2026-10-24 16:53 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-21 17:33 [RFC PATCH 0/1] worktree list: add --merged and --no-merged Jasper Mayone
2026-10-21 17:33 ` [RFC PATCH 1/1] " Jasper Mayone
2026-10-23 18:30   ` Junio C Hamano
2026-10-24  6:25     ` Eric Sunshine
2026-10-24 16:53       ` Jasper Mayone [this message]
2026-10-25 14:47         ` [PATCH v2] worktree list: add --branch to select worktrees by branch Phillip Wood
2026-10-26 11:02           ` Phillip Wood
2026-10-21 21:36 ` [RFC PATCH 0/1] worktree list: add --merged and --no-merged Harald Nordgren
2026-10-21 22:12   ` Junio C Hamano
2026-10-22  7:21     ` Harald Nordgren
2026-10-22 17:58   ` Kristoffer Haugsbakk

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261024165311.57387-1-me@jaspermayone.com \
    --to=me@jaspermayone.com \
    --cc=ben.knoble@gmail.com \
    --cc=git@vger.kernel.org \
    --cc=gitster@pobox.com \
    --cc=haraldnordgren@gmail.com \
    --cc=sunshine@sunshineco.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox