From: "Jason Newton via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: "brian m. carlson" <sandals@crustytoothpaste.net>,
Jason Newton <nevion@gmail.com>, Jason Newton <nevion@gmail.com>
Subject: [PATCH v2 2/2] worktree: reap ephemeral branches created by "worktree add"
Date: Tue, 20 Oct 2026 16:04:09 +0000 [thread overview]
Message-ID: <6e2acdc5e2578da4e915fd2ac541a776349fb23e.1792512249.git.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2317.v2.git.git.1792512249.gitgitgadget@gmail.com>
From: Jason Newton <nevion@gmail.com>
When "git worktree add <path>" is given no branch to check out, it
auto-creates one named after the path. Such a branch exists only to back
the new worktree, yet it outlives it: removing the worktree leaves the
branch behind, so a fleet of throwaway worktrees fills refs/heads/ with
dead branches that have to be cleaned up by hand.
Treat an auto-created branch as owned by the worktree. Record it in the
worktree's administrative directory (worktrees/<id>/owned-branch) at
creation time -- the one moment we can know for certain the branch is
worktree-private, since we are the one that created it. "git worktree
remove" then deletes the owned branch once the worktree is gone and it
is no longer checked out.
Ownership is recorded only when "worktree add" itself creates the branch
via DWIM auto-naming; a branch named explicitly with -b/-B, or one that
already existed, is never owned and never reaped. Deletion is
unconditional (even for unmerged commits) because an ephemeral branch is
disposable by contract: to keep the work, pass --keep-branch, or anchor
the commits with an ordinary branch beforehand -- a normal branch can
point at the same commits, after which reaping the ephemeral ref loses
nothing. "git branch -m" updates the marker so ownership follows a
rename, and the branch stays ephemeral.
The marker is just another per-worktree administrative file, so older
versions of Git simply ignore it (they keep the branch on removal, as
today).
Signed-off-by: Jason Newton <nevion@gmail.com>
---
Documentation/git-worktree.adoc | 22 ++++++++--
builtin/branch.c | 28 +++++++++++++
builtin/worktree.c | 74 ++++++++++++++++++++++++++++++++-
t/t2400-worktree-add.sh | 55 ++++++++++++++++++++++++
t/t3200-branch.sh | 2 +-
t/t3400-rebase.sh | 2 +-
t/t5516-fetch-push.sh | 2 +-
7 files changed, 178 insertions(+), 7 deletions(-)
diff --git a/Documentation/git-worktree.adoc b/Documentation/git-worktree.adoc
index 4264ee753a..99e6e55be4 100644
--- a/Documentation/git-worktree.adoc
+++ b/Documentation/git-worktree.adoc
@@ -15,7 +15,7 @@ git worktree list [-v | --porcelain [-z]]
git worktree lock [--reason <string>] <worktree>
git worktree move <worktree> <new-path>
git worktree prune [-n] [-v] [--expire <expire>]
-git worktree remove [-f] <worktree>
+git worktree remove [-f] [--keep-branch] <worktree>
git worktree repair [<path>...]
git worktree unlock <worktree>
@@ -40,8 +40,11 @@ In its simplest form, `git worktree add <path>` automatically creates a
new branch whose name is the final component of _<path>_, which is
convenient if you plan to work on a new topic. For instance, `git
worktree add ../hotfix` creates new branch `hotfix` and checks it out at
-path `../hotfix`. To instead work on an existing branch in a new worktree,
-use `git worktree add <path> <branch>`. On the other hand, if you just
+path `../hotfix`. Such an automatically-created branch is _ephemeral_: it
+is owned by the worktree and is deleted again when the worktree is removed
+with `git worktree remove` (see that command and `--keep-branch`). To
+instead work on an existing branch in a new worktree, use
+`git worktree add <path> <branch>`. On the other hand, if you just
plan to make some experimental changes or do testing without disturbing
existing development, it is often convenient to create a 'throwaway'
worktree not associated with any branch. For instance,
@@ -145,6 +148,14 @@ Remove a worktree. Only clean worktrees (no untracked files and no
modification in tracked files) can be removed. Unclean worktrees or ones
with submodules can be removed with `--force`. The main worktree cannot be
removed.
++
+If the worktree owns an _ephemeral_ branch -- one that `git worktree add`
+created itself because no branch was named (the auto-naming case above) --
+that branch is deleted along with the worktree, even if it has unmerged
+commits, since it was created solely to back this worktree. To keep it, use
+`--keep-branch`, or anchor the commits with an ordinary branch
+(`git branch <name>`) beforehand. A branch you named explicitly (`-b`/`-B`)
+or one that existed before the worktree is never deleted.
`repair [<path>...]`::
@@ -192,6 +203,11 @@ allows the move to proceed; use `--force` twice if the destination is locked.
`remove` refuses to remove an unclean worktree unless `--force` is used.
To remove a locked worktree, specify `--force` twice.
+`--keep-branch`::
+ With `remove`, do not delete the worktree's ephemeral (owned) branch;
+ keep it as an ordinary branch. Has no effect on worktrees that do not
+ own a branch.
+
`-b <new-branch>`::
`-B <new-branch>`::
With `add`, create a new branch named _<new-branch>_ starting at
diff --git a/builtin/branch.c b/builtin/branch.c
index a613148fc7..ca2e25df03 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -607,6 +607,32 @@ static int replace_each_worktree_head_symref(struct worktree **worktrees,
#define IS_HEAD 1
#define IS_ORPHAN 2
+/*
+ * When a branch that a worktree owns (its ephemeral branch, recorded in
+ * worktrees/<id>/owned-branch) is renamed, update the marker so ownership
+ * follows the new name -- it stays ephemeral.
+ */
+static void rename_owned_branch_markers(struct worktree **worktrees,
+ const char *oldref, const char *newref)
+{
+ for (int i = 0; worktrees[i]; i++) {
+ struct strbuf path = STRBUF_INIT, buf = STRBUF_INIT;
+
+ if (!worktrees[i]->id)
+ continue; /* the main worktree keeps no marker */
+ repo_common_path_append(the_repository, &path,
+ "worktrees/%s/owned-branch",
+ worktrees[i]->id);
+ if (strbuf_read_file(&buf, path.buf, 64) > 0) {
+ strbuf_trim(&buf);
+ if (!strcmp(buf.buf, oldref))
+ write_file(path.buf, "%s", newref);
+ }
+ strbuf_release(&path);
+ strbuf_release(&buf);
+ }
+}
+
static void copy_or_rename_branch(const char *oldname, const char *newname, int copy, int force)
{
struct strbuf oldref = STRBUF_INIT, newref = STRBUF_INIT, logmsg = STRBUF_INIT;
@@ -675,6 +701,8 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
if (!copy && !(oldref_usage & IS_ORPHAN) &&
refs_rename_ref(get_main_ref_store(the_repository), oldref.buf, newref.buf, logmsg.buf))
die(_("branch rename failed"));
+ if (!copy && !(oldref_usage & IS_ORPHAN))
+ rename_owned_branch_markers(worktrees, oldref.buf, newref.buf);
if (copy && refs_copy_existing_ref(get_main_ref_store(the_repository), oldref.buf, newref.buf, logmsg.buf))
die(_("branch copy failed"));
diff --git a/builtin/worktree.c b/builtin/worktree.c
index 9d4b51305e..1059429cbb 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -41,7 +41,7 @@
#define BUILTIN_WORKTREE_PRUNE_USAGE \
N_("git worktree prune [-n] [-v] [--expire <expire>]")
#define BUILTIN_WORKTREE_REMOVE_USAGE \
- N_("git worktree remove [-f] <worktree>")
+ N_("git worktree remove [-f] [--keep-branch] <worktree>")
#define BUILTIN_WORKTREE_REPAIR_USAGE \
N_("git worktree repair [<path>...]")
#define BUILTIN_WORKTREE_UNLOCK_USAGE \
@@ -130,8 +130,34 @@ struct add_opts {
int relative_paths;
int reflink;
const char *keep_locked;
+ const char *owned_branch;
};
+/*
+ * An "owned" (ephemeral) branch is one that "git worktree add" created itself
+ * because the user did not name a branch (the DWIM auto-name case). Its
+ * lifetime is bound to the worktree: it is recorded in the worktree's
+ * administrative directory at creation and reaped when the worktree is
+ * removed. A pre-existing branch, or one named explicitly with -b/-B, is never
+ * owned.
+ */
+static char *read_owned_branch(const char *id)
+{
+ struct strbuf path = STRBUF_INIT, buf = STRBUF_INIT;
+ char *ret = NULL;
+
+ repo_common_path_append(the_repository, &path,
+ "worktrees/%s/owned-branch", id);
+ if (strbuf_read_file(&buf, path.buf, 64) > 0) {
+ strbuf_trim(&buf);
+ if (buf.len)
+ ret = strbuf_detach(&buf, NULL);
+ }
+ strbuf_release(&path);
+ strbuf_release(&buf);
+ return ret;
+}
+
static int show_only;
static int verbose;
static int guess_remote;
@@ -771,6 +797,16 @@ static int add_worktree(const char *path, const char *refname,
if (the_repository->repository_format_worktree_config)
copy_filtered_worktree_config(sb_repo.buf);
+ /*
+ * Record an owned (ephemeral) branch so that "git worktree remove" can
+ * reap it. Only DWIM-created branches are owned (see read_owned_branch()).
+ */
+ if (opts->owned_branch) {
+ strbuf_reset(&sb);
+ strbuf_addf(&sb, "%s/owned-branch", sb_repo.buf);
+ write_file(sb.buf, "refs/heads/%s", opts->owned_branch);
+ }
+
strvec_pushf(&child_env, "%s=%s", GIT_DIR_ENVIRONMENT, sb_git.buf);
strvec_pushf(&child_env, "%s=%s", GIT_WORK_TREE_ENVIRONMENT, path);
@@ -1148,6 +1184,13 @@ static int add(int ac, const char **av, const char *prefix,
branch = branch_to_free = s;
new_branch = new_branch_to_free;
+ /*
+ * A branch auto-created from the path (the user named no
+ * branch) is owned by this worktree and reaped on removal.
+ */
+ if (new_branch_to_free)
+ opts.owned_branch = new_branch_to_free;
+
/* DWIM: Infer --orphan when repo has no refs. */
opts.orphan = (!s) && dwim_orphan(&opts, !!opt_track, 1);
} else if (ac == 2 && new_branch) {
@@ -1651,15 +1694,19 @@ static int remove_worktree(int ac, const char **av, const char *prefix,
struct repository *repo UNUSED)
{
int force = 0;
+ int keep_branch = 0;
struct option options[] = {
OPT__FORCE(&force,
N_("force removal even if worktree is dirty or locked"),
PARSE_OPT_NOCOMPLETE),
+ OPT_BOOL(0, "keep-branch", &keep_branch,
+ N_("keep the worktree's owned (ephemeral) branch")),
OPT_END()
};
struct worktree **worktrees, *wt;
struct strbuf errmsg = STRBUF_INIT;
const char *reason = NULL;
+ char *owned = NULL;
int ret = 0;
ac = parse_options(ac, av, prefix, options, git_worktree_remove_usage, 0);
@@ -1685,6 +1732,10 @@ static int remove_worktree(int ac, const char **av, const char *prefix,
errmsg.buf);
strbuf_release(&errmsg);
+ /* read the owned-branch marker before the admin dir is deleted */
+ if (!keep_branch)
+ owned = read_owned_branch(wt->id);
+
if (file_exists(wt->path)) {
if (!force)
check_clean_worktree(wt, av[0]);
@@ -1698,6 +1749,27 @@ static int remove_worktree(int ac, const char **av, const char *prefix,
ret |= delete_git_dir(wt->id);
delete_worktrees_dir_if_empty();
+ /*
+ * Reap the worktree's owned (ephemeral) branch now that the worktree
+ * is gone and the branch is no longer checked out. This is
+ * unconditional by design: the branch was created for this worktree
+ * and is disposable. To keep the work, anchor it with an ordinary
+ * branch (or pass --keep-branch) before removing the worktree.
+ */
+ if (owned) {
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ if (refs_ref_exists(refs, owned)) {
+ if (refs_delete_ref(refs, NULL, owned, NULL, 0))
+ ret = error(_("failed to reap branch '%s'"), owned);
+ else {
+ const char *name = owned;
+ skip_prefix(owned, "refs/heads/", &name);
+ fprintf_ln(stderr, _("Removed branch '%s'"), name);
+ }
+ }
+ free(owned);
+ }
+
free_worktrees(worktrees);
return ret;
}
diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
index 4f62360a78..d083a871eb 100755
--- a/t/t2400-worktree-add.sh
+++ b/t/t2400-worktree-add.sh
@@ -1376,4 +1376,59 @@ test_expect_success REFLINK '--reflink carries untracked files and reconciles ch
)
'
+test_expect_success 'ephemeral: DWIM-created branch is owned and reaped on remove' '
+ test_when_finished "git worktree remove -f eph || :; git branch -D eph 2>/dev/null || :" &&
+ git worktree add eph &&
+ echo refs/heads/eph >expect &&
+ test_cmp expect .git/worktrees/eph/owned-branch &&
+ git rev-parse --verify refs/heads/eph &&
+ git worktree remove eph &&
+ test_must_fail git rev-parse --verify --quiet refs/heads/eph
+'
+
+test_expect_success 'ephemeral: -b branch is not owned and is kept on remove' '
+ test_when_finished "git worktree remove -f nb || :; git branch -D named 2>/dev/null || :" &&
+ git worktree add -b named nb &&
+ test_path_is_missing .git/worktrees/nb/owned-branch &&
+ git worktree remove nb &&
+ git rev-parse --verify refs/heads/named
+'
+
+test_expect_success 'ephemeral: pre-existing branch is not owned' '
+ test_when_finished "git worktree remove -f pe || :; git branch -D pre 2>/dev/null || :" &&
+ git branch pre &&
+ git worktree add pe pre &&
+ test_path_is_missing .git/worktrees/pe/owned-branch &&
+ git worktree remove pe &&
+ git rev-parse --verify refs/heads/pre
+'
+
+test_expect_success 'ephemeral: --keep-branch keeps an owned branch' '
+ test_when_finished "git branch -D keepme 2>/dev/null || :" &&
+ git worktree add keepme &&
+ git worktree remove --keep-branch keepme &&
+ git rev-parse --verify refs/heads/keepme
+'
+
+test_expect_success 'ephemeral: branch -m follows the marker and stays ephemeral' '
+ test_when_finished "git worktree remove -f mv || :; git branch -D mv2 2>/dev/null || :" &&
+ git worktree add mv &&
+ git -C mv branch -m mv mv2 &&
+ echo refs/heads/mv2 >expect &&
+ test_cmp expect .git/worktrees/mv/owned-branch &&
+ git worktree remove mv &&
+ test_must_fail git rev-parse --verify --quiet refs/heads/mv2
+'
+
+test_expect_success 'ephemeral: commits survive the reap when anchored by a normal branch' '
+ test_when_finished "git worktree remove -f anc || :; git branch -D anchored 2>/dev/null || :" &&
+ git worktree add anc &&
+ test_commit -C anc agentwork &&
+ tip=$(git -C anc rev-parse HEAD) &&
+ git branch anchored "$tip" &&
+ git worktree remove anc &&
+ test_must_fail git rev-parse --verify --quiet refs/heads/anc &&
+ test "$(git rev-parse anchored)" = "$tip"
+'
+
test_done
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index cdb6c6a634..67c7c67853 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -190,7 +190,7 @@ test_expect_success 'git branch -M foo bar should fail when bar is checked out'
test_expect_success 'git branch -M foo bar should fail when bar is checked out in worktree' '
git branch -f bar &&
- test_when_finished "git worktree remove wt && git branch -D wt" &&
+ test_when_finished "git worktree remove --keep-branch wt && git branch -D wt" &&
git worktree add wt &&
test_must_fail git branch -M bar wt
'
diff --git a/t/t3400-rebase.sh b/t/t3400-rebase.sh
index accaaba4d4..9c0c80f113 100755
--- a/t/t3400-rebase.sh
+++ b/t/t3400-rebase.sh
@@ -471,7 +471,7 @@ test_expect_success 'git rebase --update-ref with core.commentChar and branch on
test_when_finished git branch -D base topic2 &&
test_when_finished git checkout main &&
test_when_finished git branch -D wt-topic &&
- test_when_finished git worktree remove wt-topic &&
+ test_when_finished git worktree remove --keep-branch wt-topic &&
git checkout main &&
git checkout -b base &&
git checkout -b topic2 &&
diff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh
index b982b209bf..6ffeb064fa 100755
--- a/t/t5516-fetch-push.sh
+++ b/t/t5516-fetch-push.sh
@@ -1873,7 +1873,7 @@ test_expect_success 'updateInstead with bare repository worktree and unborn bare
'
test_expect_success 'refuse fetch to current branch of worktree' '
- test_when_finished "git worktree remove --force wt && git branch -D wt" &&
+ test_when_finished "git worktree remove --force --keep-branch wt && git branch -D wt" &&
git worktree add wt &&
test_commit apple &&
test_must_fail git fetch . HEAD:wt &&
--
gitgitgadget
prev parent reply other threads:[~2026-10-20 16:04 UTC|newest]
Thread overview: 9+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-05 18:49 [PATCH 0/2] worktree: copy-on-write creation and shared-branch worktrees Jason Newton via GitGitGadget
2026-06-05 18:49 ` [PATCH 1/2] worktree: add --reflink for copy-on-write worktree creation Jason Newton via GitGitGadget
2026-06-05 18:49 ` [PATCH 2/2] worktree: allow sharing a checked-out branch across worktrees Jason Newton via GitGitGadget
2026-06-05 19:59 ` [PATCH 0/2] worktree: copy-on-write creation and shared-branch worktrees brian m. carlson
2026-06-08 14:36 ` Junio C Hamano
2026-10-20 16:04 ` [PATCH v2 0/2] worktree: copy-on-write creation and ephemeral branches Jason Newton via GitGitGadget
2026-10-20 16:04 ` [PATCH v2 1/2] worktree: add --reflink for copy-on-write worktree creation Jason Newton via GitGitGadget
2026-10-20 21:20 ` brian m. carlson
2026-10-20 16:04 ` Jason Newton via GitGitGadget [this message]
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=6e2acdc5e2578da4e915fd2ac541a776349fb23e.1792512249.git.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=git@vger.kernel.org \
--cc=nevion@gmail.com \
--cc=sandals@crustytoothpaste.net \
/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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.