# [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks

21 messages from 2026-07-09 to 2026-10-06. Participants: Domen Kožar, Phillip Wood, Caleb White, Junio C Hamano, Kristoffer Haugsbakk, Maciej Ciemborowicz.
Thread: https://gitlist.dev/t/65964

## Domen Kožar, 2026-07-09 23:36

Subject: [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks
Message-ID: <7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net>

```
Hi everyone,

I maintain devenv, a developer environment manager, and lately the
workflow we see most is people letting AI coding agents loose on a
repository, one linked worktree per task, created and discarded at a
pace no human would type. Each of those worktrees expects a working
environment: processes, sockets, and stateful services such as a
database seeded from a dump.

Today there is no reliable trigger to set that up when a worktree
appears: post-checkout does not fire for --no-checkout or --orphan
and cannot be told apart from a plain checkout. Nothing at all fires
when a worktree goes away, so stale databases and services pile up
after "git worktree remove" or a manual rm followed by "git worktree
prune". Wrapping the worktree commands only helps when every tool,
human or agent, goes through the wrapper.

Patch 1 adds a post-worktree-add hook that fires after the working
tree is fully set up. Patch 2 adds post-worktree-remove for "git
worktree remove". Patch 3 extends it to "git worktree prune" so that
manually deleted worktrees are also observed.

Two design points I would especially appreciate feedback on:

 * post-worktree-add runs after post-checkout and is skipped when
   post-checkout fails. An argument could be made that it should run
   whenever the worktree was created, regardless of the earlier
   hook's exit status, since tooling registering worktrees would
   otherwise miss one that does exist.

 * for entries pruned because their gitdir file points to a location
   that no longer exists, the hook receives the recorded path; when
   the path cannot be determined at all (missing or corrupt gitdir
   file) it receives an empty string.

Thanks,
Domen

Domen Kožar (3):
  worktree: add post-worktree-add hook
  worktree: add post-worktree-remove hook
  worktree: run post-worktree-remove hook when pruning

 Documentation/githooks.adoc |  41 +++++++++++++
 builtin/worktree.c          |  73 ++++++++++++++++++-----
 t/t2400-worktree-add.sh     | 113 ++++++++++++++++++++++++++++++++++++
 t/t2401-worktree-prune.sh   |  88 ++++++++++++++++++++++++++++
 t/t2403-worktree-move.sh    |  44 ++++++++++++++
 worktree.c                  |   1 -
 worktree.h                  |   6 +-
 7 files changed, 347 insertions(+), 19 deletions(-)


base-commit: f85a7e662054a7b0d9070e432508831afa214b47
-- 
2.54.0

```

## Phillip Wood, 2026-07-10 09:34

Subject: Re: [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks
Message-ID: <0f37a01d-c39e-47b3-b8e9-48cdd42672df@gmail.com>
In-Reply-To: <7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net>

```
Hi Domen

On 10/07/2026 00:36, Domen Kožar wrote:
> 
> Today there is no reliable trigger to set that up when a worktree
> appears: post-checkout does not fire for --no-checkout or --orphan
> and cannot be told apart from a plain checkout. Nothing at all fires
> when a worktree goes away, so stale databases and services pile up
> after "git worktree remove" or a manual rm followed by "git worktree
> prune". Wrapping the worktree commands only helps when every tool,
> human or agent, goes through the wrapper.

I agree a hook that's run after the worktree is added is useful (I have 
a patch for it that I've never got round to cleaning up and sending so 
thank you for working on this). It is useful for copying across 
untracked files to the new worktree like "config.mak".

> Patch 1 adds a post-worktree-add hook that fires after the working
> tree is fully set up. Patch 2 adds post-worktree-remove for "git
> worktree remove". Patch 3 extends it to "git worktree prune" so that
> manually deleted worktrees are also observed.

I don't have a strong opinion on a hook running when a worktree is 
removed - an IDE that cares about that could set up a filesystem watch 
on the directory but I guess adding a hook doesn't do any harm.
> Two design points I would especially appreciate feedback on:
> 
>   * post-worktree-add runs after post-checkout and is skipped when
>     post-checkout fails. An argument could be made that it should run
>     whenever the worktree was created, regardless of the earlier
>     hook's exit status, since tooling registering worktrees would
>     otherwise miss one that does exist.

Looking at the existing code, if the checkout fails then we remove the 
worktree because "is_junk == 1" when remove_junk() is called via 
atexit() so I think it is correct to skip the new hook in that case.

The new hook is run after the checkout, but before the post-checkout 
hook - we should document their relative order. I see the hook is run in 
the new worktree and passed the absolute directory and worktree id. I'm 
wondering if either of those is useful if we're running the hook in the 
new worktree.

>   * for entries pruned because their gitdir file points to a location
>     that no longer exists, the hook receives the recorded path; when
>     the path cannot be determined at all (missing or corrupt gitdir
>     file) it receives an empty string.

So the hook knows a worktree was removed but not which one?

Thanks

Phillip

> Thanks,
> Domen
> 
> Domen Kožar (3):
>    worktree: add post-worktree-add hook
>    worktree: add post-worktree-remove hook
>    worktree: run post-worktree-remove hook when pruning
> 
>   Documentation/githooks.adoc |  41 +++++++++++++
>   builtin/worktree.c          |  73 ++++++++++++++++++-----
>   t/t2400-worktree-add.sh     | 113 ++++++++++++++++++++++++++++++++++++
>   t/t2401-worktree-prune.sh   |  88 ++++++++++++++++++++++++++++
>   t/t2403-worktree-move.sh    |  44 ++++++++++++++
>   worktree.c                  |   1 -
>   worktree.h                  |   6 +-
>   7 files changed, 347 insertions(+), 19 deletions(-)
> 
> 
> base-commit: f85a7e662054a7b0d9070e432508831afa214b47


```

## Phillip Wood, 2026-07-13 13:19

Subject: Re: [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks
Message-ID: <04051fe2-c54c-45e9-8773-92d40e84c765@gmail.com>
In-Reply-To: <CAMvcdZS=ZYbLmjKaGJvjQ_fWYhVbOzwMvYq+MMENWPYi_RiqvQ@mail.gmail.com>

```
Hi Domen

Unfortunately it doesn't look like your message appeared on the list, 
sadly I'm not sure it accepts multipart/alternative messages even when 
they contain a plain-text version of the message.

On 10/07/2026 18:20, Domen Kožar wrote:
> Hi Phillip,
> 
> thanks for the quick and careful read.
> 
>  > It is useful for copying across untracked files to the new worktree
>  > like "config.mak".
> 
> That is a nice example, and it needs the hook to also fire for
> --no-checkout and --orphan, which post-checkout does not cover.
> 
>  > Looking at the existing code, if the checkout fails then we remove
>  > the worktree because "is_junk == 1" when remove_junk() is called via
>  > atexit() so I think it is correct to skip the new hook in that case.
> 
> Right, when the checkout itself fails the worktree is removed as junk
> and neither hook runs; no disagreement there. The case I was asking
> about is the post-checkout hook itself failing: that runs after
> is_junk is cleared, so the worktree survives, but post-worktree-add
> is currently skipped and tooling that registers worktrees would miss
> one that exists. I kept the skip because a failing post-checkout
> already signals a broken setup, but I am happy to run
> post-worktree-add whenever the worktree was created, regardless of
> the earlier hook's exit status, if that is preferred.

Oh sorry I'd misunderstood the question. I think I'd lean towards 
running the hook anyway because we've still populated a new worktree, 
even if the post-checkout hooks fails.

>  > The new hook is run after the checkout, but before the post-checkout
>  > hook - we should document their relative order.
> 
> Unless I am misreading my own series, it is the other way around:
> add_worktree() invokes post-checkout first and post-worktree-add
> after it, t2400 has a test pinning that order ('"add" runs
> post-worktree-add after post-checkout'), and githooks.adoc says "It
> runs after the post-checkout hook, and is skipped if that hook
> fails." If that did not come across I am happy to reword the
> documentation.

Oops, when I wrote that I was looking at the wrong branch - I had my 
"add-worktree-hook" checked out and confused it with this patch. It's 
great to see that there is a test and documentation for this.

>  > I'm wondering if either of those is useful if we're running the
>  > hook in the new worktree.
> 
> Strictly they are derivable from inside, --show-toplevel for the path
> and the basename of --git-dir for the id.

Isn't the worktree path the current working directory of the hook script?

> I passed them anyway so
> that one script can serve both hooks: post-worktree-remove has to
> receive them as arguments because the worktree is gone by the time it
> runs, and keeping the two signatures identical makes shared hook code
> simpler. I can drop them from post-worktree-add if the symmetry is
> not considered worth it.

Oh right, as a counter argument I wonder if having a different  argument 
count for the two hooks makes it easier for a script that's shared 
between the two hooks to determine which hook has invoked it. Is the 
worktree id useful for anything apart from accessing on worktree's local 
refs from another worktree?

>  > So the hook knows a worktree was removed but not which one?
> 
> It always gets the worktree id as $2; only the path in $1 can be
> empty, and only for entries whose gitdir file is missing or
> unreadable, where git itself no longer knows the path either. Tooling
> that recorded the id at post-worktree-add time can still match the
> removal.

That answers why you want the id.

One thought I had after I wrote my mail was that worktrees can be 
renamed - do we want a hook for that so that external tools can move any 
services they've started and update their id -> path mapping.

Thanks

Phillip

> Thanks,
> Domen
> 
> On Fri, Jul 10, 2026 at 3:34 AM Phillip Wood <phillip.wood123@gmail.com 
> <mailto:phillip.wood123@gmail.com>> wrote:
> 
>     Hi Domen
> 
>     On 10/07/2026 00:36, Domen Kožar wrote:
>      >
>      > Today there is no reliable trigger to set that up when a worktree
>      > appears: post-checkout does not fire for --no-checkout or --orphan
>      > and cannot be told apart from a plain checkout. Nothing at all fires
>      > when a worktree goes away, so stale databases and services pile up
>      > after "git worktree remove" or a manual rm followed by "git worktree
>      > prune". Wrapping the worktree commands only helps when every tool,
>      > human or agent, goes through the wrapper.
> 
>     I agree a hook that's run after the worktree is added is useful (I have
>     a patch for it that I've never got round to cleaning up and sending so
>     thank you for working on this). It is useful for copying across
>     untracked files to the new worktree like "config.mak".
> 
>      > Patch 1 adds a post-worktree-add hook that fires after the working
>      > tree is fully set up. Patch 2 adds post-worktree-remove for "git
>      > worktree remove". Patch 3 extends it to "git worktree prune" so that
>      > manually deleted worktrees are also observed.
> 
>     I don't have a strong opinion on a hook running when a worktree is
>     removed - an IDE that cares about that could set up a filesystem watch
>     on the directory but I guess adding a hook doesn't do any harm.
>      > Two design points I would especially appreciate feedback on:
>      >
>      >   * post-worktree-add runs after post-checkout and is skipped when
>      >     post-checkout fails. An argument could be made that it should run
>      >     whenever the worktree was created, regardless of the earlier
>      >     hook's exit status, since tooling registering worktrees would
>      >     otherwise miss one that does exist.
> 
>     Looking at the existing code, if the checkout fails then we remove the
>     worktree because "is_junk == 1" when remove_junk() is called via
>     atexit() so I think it is correct to skip the new hook in that case.
> 
>     The new hook is run after the checkout, but before the post-checkout
>     hook - we should document their relative order. I see the hook is
>     run in
>     the new worktree and passed the absolute directory and worktree id. I'm
>     wondering if either of those is useful if we're running the hook in the
>     new worktree.
> 
>      >   * for entries pruned because their gitdir file points to a location
>      >     that no longer exists, the hook receives the recorded path; when
>      >     the path cannot be determined at all (missing or corrupt gitdir
>      >     file) it receives an empty string.
> 
>     So the hook knows a worktree was removed but not which one?
> 
>     Thanks
> 
>     Phillip
> 
>      > Thanks,
>      > Domen
>      >
>      > Domen Kožar (3):
>      >    worktree: add post-worktree-add hook
>      >    worktree: add post-worktree-remove hook
>      >    worktree: run post-worktree-remove hook when pruning
>      >
>      >   Documentation/githooks.adoc |  41 +++++++++++++
>      >   builtin/worktree.c          |  73 ++++++++++++++++++-----
>      >   t/t2400-worktree-add.sh     | 113 +++++++++++++++++++++++++++++
>     +++++++
>      >   t/t2401-worktree-prune.sh   |  88 ++++++++++++++++++++++++++++
>      >   t/t2403-worktree-move.sh    |  44 ++++++++++++++
>      >   worktree.c                  |   1 -
>      >   worktree.h                  |   6 +-
>      >   7 files changed, 347 insertions(+), 19 deletions(-)
>      >
>      >
>      > base-commit: f85a7e662054a7b0d9070e432508831afa214b47
> 
> 


```

## Domen Kožar, 2026-08-04 18:14

Subject: [PATCH v2 1/4] worktree: add post-worktree-add hook
Message-ID: <9f6bb2ec-afe3-4a1c-ade2-addf73f8681d@mtasv.net>
In-Reply-To: <20260804181358.532970-1-domen@cachix.org>

```
Tools that manage per-worktree state, such as development environment
managers or IDEs, have no way to react when a new working tree is
created. The only hook that fires during "git worktree add" is
post-checkout, which is skipped when --no-checkout or --orphan is used
and cannot be distinguished from a plain checkout.

Introduce a post-worktree-add hook that runs after the working tree
has been fully set up, including with --no-checkout and --orphan. The
hook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE
cleared, mirroring the existing post-checkout invocation, and takes no
arguments. Details such as the absolute path, worktree identifier, and
checked-out branch can be queried by running git from the hook's working
directory. Taking no arguments also lets a configured command shared
with post-worktree-remove distinguish the events by argument count.

Like post-checkout, the hook cannot affect the outcome of the command:
a failing hook does not delete the already-created working tree, but
its exit status becomes the exit status of "git worktree add". The
hook runs after post-checkout, even when post-checkout fails, because
the worktree has still been populated and remains present.

Documenting the new hook in githooks(5) also registers its name in the
generated hook-list.h, so "git hook run" and hook.*.event recognize it
without further changes.

Signed-off-by: Domen Kožar <domen@cachix.org>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 Documentation/config/hook.adoc |   1 +
 Documentation/githooks.adoc    |  18 ++++++
 builtin/worktree.c             |  46 +++++++++-----
 t/t2400-worktree-add.sh        | 111 +++++++++++++++++++++++++++++++++
 4 files changed, 162 insertions(+), 14 deletions(-)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index 083dc60a13..81afb4a919 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -94,6 +94,7 @@ hook.jobs::
 	Receive a commit message file and may rewrite it in place.
 `pre-commit`;;
 `post-checkout`;;
+`post-worktree-add`;;
 `push-to-checkout`;;
 `post-commit`;;
 	Access the working tree, index, or repository state.
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index ed045940d1..5a2955ee2f 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -215,6 +215,24 @@ This hook can be used to perform repository validity checks, auto-display
 differences from the previous HEAD if different, or set working dir metadata
 properties.
 
+post-worktree-add
+~~~~~~~~~~~~~~~~~
+
+This hook is invoked by linkgit:git-worktree[1] after `git worktree add`
+has created and set up a new working tree. It takes no parameters.
+
+The hook's current working directory is the new working tree, so further
+details, such as its absolute path, identifier, and checked-out branch,
+can be queried by running `git`. Unlike the `post-checkout` hook, it is
+also run when `--no-checkout` or `--orphan` is used.
+
+This hook cannot affect the outcome of `git worktree add`, other than
+that the hook's exit status becomes the exit status of the command. It
+runs after the `post-checkout` hook, even if that hook fails.
+
+This hook can be used to set up per-worktree development environments
+or to register the new working tree with external tools.
+
 post-merge
 ~~~~~~~~~~
 
diff --git a/builtin/worktree.c b/builtin/worktree.c
index d21c43fde3..cc3299bca9 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -168,6 +168,15 @@ static void delete_worktrees_dir_if_empty(void)
 	free(path);
 }
 
+static int run_post_worktree_add_hook(const char *path)
+{
+	struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
+	strvec_pushl(&hook_opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
+	hook_opt.dir = path;
+	return run_hooks_opt(the_repository, "post-worktree-add", &hook_opt);
+}
+
 static void prune_worktree(const char *id, const char *reason)
 {
 	if (show_only || verbose)
@@ -605,21 +614,30 @@ static int add_worktree(const char *path, const char *refname,
 	}
 
 	/*
-	 * Hook failure does not warrant worktree deletion, so run hook after
-	 * is_junk is cleared, but do return appropriate code when hook fails.
+	 * Hook failures do not warrant worktree deletion, so run hooks after
+	 * is_junk is cleared, but do return appropriate code when a hook
+	 * fails.
 	 */
-	if (!ret && opts->checkout && !opts->orphan) {
-		struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
-
-		strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
-		strvec_pushl(&opt.args,
-			     oid_to_hex(null_oid(the_hash_algo)),
-			     oid_to_hex(&commit->object.oid),
-			     "1",
-			     NULL);
-		opt.dir = path;
-
-		ret = run_hooks_opt(the_repository, "post-checkout", &opt);
+	if (!ret) {
+		int hook_ret;
+
+		if (opts->checkout && !opts->orphan) {
+			struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
+			strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
+			strvec_pushl(&opt.args,
+				     oid_to_hex(null_oid(the_hash_algo)),
+				     oid_to_hex(&commit->object.oid),
+				     "1",
+				     NULL);
+			opt.dir = path;
+
+			ret = run_hooks_opt(the_repository, "post-checkout", &opt);
+		}
+
+		hook_ret = run_post_worktree_add_hook(wt->path);
+		if (!ret)
+			ret = hook_ret;
 	}
 
 	strvec_clear(&child_env);
diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
index 58b4445cc4..bcdd555ce9 100755
--- a/t/t2400-worktree-add.sh
+++ b/t/t2400-worktree-add.sh
@@ -1132,6 +1132,117 @@ test_expect_success '"add" in bare repo invokes post-checkout hook' '
 	test_cmp hook.expect goozy/hook.actual
 '
 
+# Install a post-worktree-add hook and write the output expected for
+# adding worktree $1; the hook is installed in repo $2 (default ".git").
+post_worktree_add_hook () {
+	test_when_finished "rm -rf .git/hooks" &&
+	mkdir .git/hooks &&
+	test_hook -C "$2" post-worktree-add <<-\EOF &&
+	test "$#" = 0 &&
+	git rev-parse --git-dir --show-toplevel >hook.actual
+	EOF
+	{
+		echo $(pwd)/${2:-.git}/worktrees/$1 &&
+		echo $(pwd)/$1
+	} >hook.expect
+}
+
+test_expect_success '"add" invokes post-worktree-add hook' '
+	post_worktree_add_hook wanda &&
+	git worktree add wanda &&
+	test_cmp hook.expect wanda/hook.actual
+'
+
+test_expect_success '"add" in other worktree invokes post-worktree-add hook' '
+	post_worktree_add_hook wilbur &&
+	git -C wanda worktree add ../wilbur &&
+	test_cmp hook.expect wilbur/hook.actual
+'
+
+test_expect_success '"add --no-checkout" still invokes post-worktree-add hook' '
+	post_worktree_add_hook wendy &&
+	git worktree add --no-checkout wendy &&
+	test_cmp hook.expect wendy/hook.actual
+'
+
+test_expect_success '"add --orphan" invokes post-worktree-add hook' '
+	post_worktree_add_hook winnie &&
+	git worktree add --orphan winnie &&
+	test_cmp hook.expect winnie/hook.actual
+'
+
+test_expect_success '"add" in bare repo invokes post-worktree-add hook' '
+	rm -rf bare2 &&
+	git clone --bare . bare2 &&
+	post_worktree_add_hook willow bare2 &&
+	git -C bare2 worktree add --detach ../willow &&
+	test_cmp hook.expect willow/hook.actual
+'
+
+test_expect_success '"add" runs post-worktree-add after post-checkout' '
+	test_when_finished "rm -rf .git/hooks" &&
+	mkdir .git/hooks &&
+	test_hook post-checkout <<-\EOF &&
+	echo post-checkout >>hooks.actual
+	EOF
+	test_hook post-worktree-add <<-\EOF &&
+	echo post-worktree-add >>hooks.actual
+	EOF
+	test_write_lines post-checkout post-worktree-add >hooks.expect &&
+	git worktree add wobble &&
+	test_cmp hooks.expect wobble/hooks.actual
+'
+
+test_expect_success 'failing post-checkout hook does not suppress post-worktree-add hook' '
+	test_when_finished "rm -rf .git/hooks" &&
+	mkdir .git/hooks &&
+	test_hook post-checkout <<-\EOF &&
+	exit 1
+	EOF
+	test_hook post-worktree-add <<-\EOF &&
+	>post-worktree-add.ran
+	EOF
+	test_must_fail git worktree add wozzle &&
+	test_path_is_file wozzle/post-worktree-add.ran
+'
+
+test_expect_success 'failing post-worktree-add hook leaves worktree in place' '
+	test_when_finished "rm -rf .git/hooks" &&
+	mkdir .git/hooks &&
+	test_hook post-worktree-add <<-\EOF &&
+	exit 1
+	EOF
+	test_must_fail git worktree add wilma &&
+	git worktree list --porcelain >out &&
+	grep -F "worktree $(pwd)/wilma" out
+'
+
+test_expect_success 'failed "add" does not invoke post-worktree-add hook' '
+	test_when_finished "rm -rf .git/hooks occupied" &&
+	mkdir .git/hooks &&
+	test_hook post-worktree-add <<-\EOF &&
+	>hook.ran
+	EOF
+	mkdir occupied &&
+	: >occupied/blocker &&
+	test_must_fail git worktree add occupied &&
+	test_path_is_missing occupied/hook.ran &&
+	test_path_is_missing hook.ran
+'
+
+test_expect_success 'post-worktree-add hook can derive path with relative worktrees' '
+	test_when_finished "rm -rf relhook" &&
+	git init relhook &&
+	test_commit -C relhook base &&
+	test_hook -C relhook post-worktree-add <<-\EOF &&
+	test "$#" = 0 &&
+	git rev-parse --show-toplevel >hook.actual
+	EOF
+	git -C relhook worktree add --relative-paths --detach wt &&
+	echo $(pwd)/relhook/wt >hook.expect &&
+	test_cmp hook.expect relhook/wt/hook.actual
+'
+
 test_expect_success '"add" an existing but missing worktree' '
 	git worktree add --detach pneu &&
 	test_must_fail git worktree add --detach pneu &&
-- 
2.54.0

```

## Domen Kožar, 2026-08-04 18:14

Subject: [PATCH v2 4/4] worktree: add post-worktree-move hook
Message-ID: <625cef65-0197-4da3-81e0-de786f34a1cb@mtasv.net>
In-Reply-To: <20260804181358.532970-1-domen@cachix.org>

```
Tools that record worktree paths can keep their state up to date when a
worktree is added or removed, but the mapping becomes stale when the
worktree is moved. Services or other per-worktree state tied to the old
path may also need to be relocated.

Introduce a post-worktree-move hook that runs after the working tree and
its administrative files have been moved. The hook runs inside the new
working tree with GIT_DIR and GIT_WORK_TREE cleared and receives the old
absolute path as its sole argument. The new path and worktree identifier
can be queried by running git from the hook's working directory.

This signature also lets one configured command handle all three
worktree lifecycle hooks by argument count: post-worktree-add takes no
arguments, post-worktree-move takes one, and post-worktree-remove takes
two.

A failing hook does not undo the completed move, but its exit status
becomes the exit status of "git worktree move".

Signed-off-by: Domen Kožar <domen@cachix.org>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 Documentation/config/hook.adoc |  1 +
 Documentation/githooks.adoc    | 17 +++++++++++++++++
 builtin/worktree.c             | 19 +++++++++++++++++--
 t/t2403-worktree-move.sh       | 29 +++++++++++++++++++++++++++++
 4 files changed, 64 insertions(+), 2 deletions(-)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index e013bc1e40..32511b56fd 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -95,6 +95,7 @@ hook.jobs::
 `pre-commit`;;
 `post-checkout`;;
 `post-worktree-add`;;
+`post-worktree-move`;;
 `post-worktree-remove`;;
 `push-to-checkout`;;
 `post-commit`;;
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index fdf697b12f..0392454756 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -233,6 +233,23 @@ runs after the `post-checkout` hook, even if that hook fails.
 This hook can be used to set up per-worktree development environments
 or to register the new working tree with external tools.
 
+post-worktree-move
+~~~~~~~~~~~~~~~~~~
+
+This hook is invoked by linkgit:git-worktree[1] after `git worktree move`
+has moved a working tree and updated its administrative files. It is given
+one parameter: the absolute path of the working tree before it was moved.
+
+The hook's current working directory is the new working tree, so its new
+absolute path and identifier can be queried by running `git`.
+
+This hook cannot affect the outcome of `git worktree move`, other than
+that the hook's exit status becomes the exit status of the command. A
+failing hook does not undo the move.
+
+This hook can be used to update per-worktree development environments or
+registrations with external tools after their working tree has moved.
+
 post-worktree-remove
 ~~~~~~~~~~~~~~~~~~~~
 
diff --git a/builtin/worktree.c b/builtin/worktree.c
index e0c37039ac..55df3c6a8f 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -185,6 +185,17 @@ static int run_post_worktree_remove_hook(const char *path, const char *id)
 	return run_hooks_opt(the_repository, "post-worktree-remove", &hook_opt);
 }
 
+static int run_post_worktree_move_hook(const char *old_path,
+				       const char *new_path)
+{
+	struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
+	strvec_pushl(&hook_opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
+	strvec_push(&hook_opt.args, old_path);
+	hook_opt.dir = new_path;
+	return run_hooks_opt(the_repository, "post-worktree-move", &hook_opt);
+}
+
 static int prune_worktree(const char *id, const char *dotgit,
 			  const char *reason)
 {
@@ -1306,7 +1317,8 @@ static int move_worktree(int ac, const char **av, const char *prefix,
 	struct strbuf dst = STRBUF_INIT;
 	struct strbuf errmsg = STRBUF_INIT;
 	const char *reason = NULL;
-	char *path;
+	char *old_path, *path;
+	int ret;
 
 	ac = parse_options(ac, av, prefix, options, git_worktree_move_usage,
 			   0);
@@ -1349,14 +1361,17 @@ static int move_worktree(int ac, const char **av, const char *prefix,
 		    errmsg.buf);
 	strbuf_release(&errmsg);
 
+	old_path = xstrdup(wt->path);
 	if (rename(wt->path, dst.buf) == -1)
 		die_errno(_("failed to move '%s' to '%s'"), wt->path, dst.buf);
 
 	update_worktree_location(wt, dst.buf, use_relative_paths);
+	ret = run_post_worktree_move_hook(old_path, wt->path);
 
+	free(old_path);
 	strbuf_release(&dst);
 	free_worktrees(worktrees);
-	return 0;
+	return ret;
 }
 
 /*
diff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh
index b94f00e426..0ffcfe88f7 100755
--- a/t/t2403-worktree-move.sh
+++ b/t/t2403-worktree-move.sh
@@ -82,6 +82,35 @@ test_expect_success 'move worktree' '
 	test_cmp expected2 actual2
 '
 
+test_expect_success '"move" invokes post-worktree-move hook' '
+	test_hook post-worktree-move <<-\EOF &&
+	test "$#" = 1 &&
+	{
+		echo "$1" &&
+		git rev-parse --git-dir --show-toplevel
+	} >hook.actual
+	EOF
+	git worktree add --detach hook-source &&
+	git worktree move hook-source hook-destination &&
+	{
+		echo "$(pwd)/hook-source" &&
+		echo "$(pwd)/.git/worktrees/hook-source" &&
+		echo "$(pwd)/hook-destination"
+	} >hook.expect &&
+	test_cmp hook.expect hook-destination/hook.actual
+'
+
+test_expect_success 'failing post-worktree-move hook leaves worktree moved' '
+	test_hook post-worktree-move <<-\EOF &&
+	exit 1
+	EOF
+	git worktree add --detach hook-failing-source &&
+	test_must_fail git worktree move hook-failing-source hook-failing-destination &&
+	test_path_is_missing hook-failing-source &&
+	git -C hook-failing-destination status --porcelain >actual &&
+	test_must_be_empty actual
+'
+
 test_expect_success 'move main worktree' '
 	test_must_fail git worktree move . def
 '
-- 
2.54.0

```

## Domen Kožar, 2026-08-04 18:14

Subject: [PATCH v2 2/4] worktree: add post-worktree-remove hook
Message-ID: <b9a1cf0c-1cf3-4c36-bf98-1923f9559b61@mtasv.net>
In-Reply-To: <20260804181358.532970-1-domen@cachix.org>

```
External tooling has no way to learn that a working tree is gone:
"git worktree remove" deletes both the working tree and its
administrative directory without running any hook.

Introduce a post-worktree-remove hook that runs after "git worktree
remove" has deleted a working tree. It is given the former absolute
path of the working tree and its identifier as arguments. The hook
also runs when only the administrative entry is deleted because the
working tree directory itself had already disappeared, since the
worktree is deregistered either way.

Because the working tree no longer exists at that point, no special
working directory or environment is set up; the hook runs wherever
the command ran, like other post-command hooks.

The hook runs once deletion is underway even if parts of it fail,
since there is no going back at that point, but it does not run when
the removal is refused (locked or dirty working tree, failed
validation). It cannot affect the outcome of the command other than
its exit status being reflected in the exit status of "git worktree
remove".

Signed-off-by: Domen Kožar <domen@cachix.org>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 Documentation/config/hook.adoc |  1 +
 Documentation/githooks.adoc    | 18 ++++++++++++++
 builtin/worktree.c             | 10 ++++++++
 t/t2403-worktree-move.sh       | 44 ++++++++++++++++++++++++++++++++++
 4 files changed, 73 insertions(+)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index 81afb4a919..e013bc1e40 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -95,6 +95,7 @@ hook.jobs::
 `pre-commit`;;
 `post-checkout`;;
 `post-worktree-add`;;
+`post-worktree-remove`;;
 `push-to-checkout`;;
 `post-commit`;;
 	Access the working tree, index, or repository state.
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 5a2955ee2f..9573b8c1f5 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -233,6 +233,24 @@ runs after the `post-checkout` hook, even if that hook fails.
 This hook can be used to set up per-worktree development environments
 or to register the new working tree with external tools.
 
+post-worktree-remove
+~~~~~~~~~~~~~~~~~~~~
+
+This hook is invoked by linkgit:git-worktree[1] after a working tree
+has been deleted by `git worktree remove`. The hook is given two
+parameters: the absolute path of the removed working tree and its
+identifier (the name of its former administrative directory in
+`$GIT_DIR/worktrees/`).
+
+The working tree no longer exists when the hook runs.
+
+This hook cannot affect the outcome of `git worktree remove`, other
+than that the hook's exit status becomes the exit status of the
+command.
+
+This hook can be used to tear down per-worktree development
+environments or to unregister the working tree from external tools.
+
 post-merge
 ~~~~~~~~~~
 
diff --git a/builtin/worktree.c b/builtin/worktree.c
index cc3299bca9..dc456fcac7 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -177,6 +177,14 @@ static int run_post_worktree_add_hook(const char *path)
 	return run_hooks_opt(the_repository, "post-worktree-add", &hook_opt);
 }
 
+static int run_post_worktree_remove_hook(const char *path, const char *id)
+{
+	struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
+	strvec_pushl(&hook_opt.args, path, id, NULL);
+	return run_hooks_opt(the_repository, "post-worktree-remove", &hook_opt);
+}
+
 static void prune_worktree(const char *id, const char *reason)
 {
 	if (show_only || verbose)
@@ -1444,6 +1452,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix,
 	ret |= delete_git_dir(wt->id);
 	delete_worktrees_dir_if_empty();
 
+	ret |= run_post_worktree_remove_hook(wt->path, wt->id);
+
 	free_worktrees(worktrees);
 	return ret;
 }
diff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh
index 0bb33e8b1b..b94f00e426 100755
--- a/t/t2403-worktree-move.sh
+++ b/t/t2403-worktree-move.sh
@@ -246,6 +246,50 @@ test_expect_success 'not remove a repo with initialized submodule' '
 	)
 '
 
+test_expect_success '"remove" invokes post-worktree-remove hook' '
+	test_hook post-worktree-remove <<-\EOF &&
+	echo $* >hook.actual
+	EOF
+	git worktree add --detach wt-hooked &&
+	git worktree remove wt-hooked &&
+	echo $(pwd)/wt-hooked wt-hooked >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"remove" of missing worktree invokes post-worktree-remove hook' '
+	test_when_finished "rm -rf wt-moved-away" &&
+	test_hook post-worktree-remove <<-\EOF &&
+	echo $* >hook.actual
+	EOF
+	rm -f hook.actual &&
+	git worktree add --detach wt-elsewhere &&
+	mv wt-elsewhere wt-moved-away &&
+	git worktree remove wt-elsewhere &&
+	echo $(pwd)/wt-elsewhere wt-elsewhere >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'refused "remove" does not invoke post-worktree-remove hook' '
+	git worktree add --detach wt-kept &&
+	test_when_finished "git worktree remove --force --force wt-kept || :" &&
+	test_hook post-worktree-remove <<-\EOF &&
+	>hook.ran
+	EOF
+	git worktree lock wt-kept &&
+	test_must_fail git worktree remove wt-kept &&
+	test_path_is_missing hook.ran
+'
+
+test_expect_success 'failing post-worktree-remove hook fails "remove", worktree is gone' '
+	test_hook post-worktree-remove <<-\EOF &&
+	exit 1
+	EOF
+	git worktree add --detach wt-doomed &&
+	test_must_fail git worktree remove wt-doomed &&
+	test_path_is_missing wt-doomed &&
+	test_path_is_missing .git/worktrees/wt-doomed
+'
+
 test_expect_success 'move worktree with absolute path to relative path' '
 	test_config worktree.useRelativePaths false &&
 	git worktree add ./absolute &&
-- 
2.54.0

```

## Domen Kožar, 2026-08-04 18:14

Subject: [PATCH v2 0/4] worktree: add lifecycle hooks
Message-ID: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>
In-Reply-To: <7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net>

```
Hi everyone,

First, apologies that my earlier reply reached the list as a separate
message rather than as part of this thread. This is my first patch series
submitted by email, and I am still getting the threading details right. I
have made sure this reroll is plain text and correctly threaded.

I maintain devenv, a developer environment manager, and lately the
workflow we see most is people letting AI coding agents loose on a
repository, one linked worktree per task, created and discarded at a
pace no human would type. Each of those worktrees expects a working
environment: processes, sockets, and stateful services such as a
database seeded from a dump.

Today there is no reliable trigger to set that up when a worktree
appears: post-checkout does not fire for --no-checkout or --orphan
and cannot be told apart from a plain checkout. Nothing fires when a
worktree is moved or removed, so external registrations become stale
and databases and services can pile up after "git worktree remove" or
a manual rm followed by "git worktree prune". Wrapping the worktree
commands only helps when every tool, human or agent, uses the wrapper.

Patch 1 adds a post-worktree-add hook that fires after the working
tree is fully set up. Patch 2 adds post-worktree-remove for "git
worktree remove". Patch 3 extends the remove hook to "git worktree
prune" so that manually deleted worktrees are also observed. Patch 4
adds post-worktree-move so tools can update their path mapping.

Changes since v1:

 * Run post-worktree-add after post-checkout even if post-checkout
   fails, because the populated worktree remains present.

 * Make post-worktree-add take no arguments. Its working directory is
   the new worktree, so its path and identifier can be queried with
   git. This also lets a configured command shared across the hooks
   distinguish add, move, and remove by their argument counts.

 * Add post-worktree-move. It runs in the new location and receives
   the old absolute path as its sole argument.

 * Document the new hooks among those that always run serially.

Thanks to Phillip Wood for the review that prompted these changes.

Thanks,
Domen

Domen Kožar (4):
  worktree: add post-worktree-add hook
  worktree: add post-worktree-remove hook
  worktree: run post-worktree-remove hook when pruning
  worktree: add post-worktree-move hook

 Documentation/config/hook.adoc |   3 +
 Documentation/githooks.adoc    |  56 +++++++++++++++
 builtin/worktree.c             | 123 +++++++++++++++++++++++++--------
 t/t2400-worktree-add.sh        | 111 +++++++++++++++++++++++++++++
 t/t2401-worktree-prune.sh      |  88 +++++++++++++++++++++++
 t/t2403-worktree-move.sh       |  73 +++++++++++++++++++
 worktree.c                     |   1 -
 worktree.h                     |   6 +-
 8 files changed, 428 insertions(+), 33 deletions(-)

Range-diff against v1:
1:  98f06e55c8 ! 1:  73e36c179e worktree: add post-worktree-add hook
    @@ Commit message
         Introduce a post-worktree-add hook that runs after the working tree
         has been fully set up, including with --no-checkout and --orphan. The
         hook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE
    -    cleared, mirroring the existing post-checkout invocation, and is given
    -    the absolute path of the new working tree and its identifier as
    -    arguments. Anything else, such as the checked-out branch, can be
    -    queried by running git from the hook's working directory.
    +    cleared, mirroring the existing post-checkout invocation, and takes no
    +    arguments. Details such as the absolute path, worktree identifier, and
    +    checked-out branch can be queried by running git from the hook's working
    +    directory. Taking no arguments also lets a configured command shared
    +    with post-worktree-remove distinguish the events by argument count.
     
         Like post-checkout, the hook cannot affect the outcome of the command:
         a failing hook does not delete the already-created working tree, but
         its exit status becomes the exit status of "git worktree add". The
    -    hook runs after post-checkout and is skipped if that hook fails.
    +    hook runs after post-checkout, even when post-checkout fails, because
    +    the worktree has still been populated and remains present.
     
         Documenting the new hook in githooks(5) also registers its name in the
         generated hook-list.h, so "git hook run" and hook.*.event recognize it
    @@ Commit message
         Signed-off-by: Domen Kožar <domen@cachix.org>
         Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
     
    + ## Documentation/config/hook.adoc ##
    +@@ Documentation/config/hook.adoc: hook.jobs::
    + 	Receive a commit message file and may rewrite it in place.
    + `pre-commit`;;
    + `post-checkout`;;
    ++`post-worktree-add`;;
    + `push-to-checkout`;;
    + `post-commit`;;
    + 	Access the working tree, index, or repository state.
    +
      ## Documentation/githooks.adoc ##
     @@ Documentation/githooks.adoc: This hook can be used to perform repository validity checks, auto-display
      differences from the previous HEAD if different, or set working dir metadata
    @@ Documentation/githooks.adoc: This hook can be used to perform repository validit
     +~~~~~~~~~~~~~~~~~
     +
     +This hook is invoked by linkgit:git-worktree[1] after `git worktree add`
    -+has created and set up a new working tree. The hook is given two
    -+parameters: the absolute path of the new working tree and its identifier
    -+(the name of its administrative directory in `$GIT_DIR/worktrees/`).
    ++has created and set up a new working tree. It takes no parameters.
     +
    -+The hook runs inside the new working tree, so further details, such as
    -+the checked-out branch, can be queried by running `git` from the hook's
    -+current directory. Unlike the `post-checkout` hook, it is also run when
    -+`--no-checkout` or `--orphan` is used.
    ++The hook's current working directory is the new working tree, so further
    ++details, such as its absolute path, identifier, and checked-out branch,
    ++can be queried by running `git`. Unlike the `post-checkout` hook, it is
    ++also run when `--no-checkout` or `--orphan` is used.
     +
     +This hook cannot affect the outcome of `git worktree add`, other than
     +that the hook's exit status becomes the exit status of the command. It
    -+runs after the `post-checkout` hook, and is skipped if that hook fails.
    ++runs after the `post-checkout` hook, even if that hook fails.
     +
     +This hook can be used to set up per-worktree development environments
     +or to register the new working tree with external tools.
    @@ Documentation/githooks.adoc: This hook can be used to perform repository validit
      
     
      ## builtin/worktree.c ##
    +@@ builtin/worktree.c: static void delete_worktrees_dir_if_empty(void)
    + 	free(path);
    + }
    + 
    ++static int run_post_worktree_add_hook(const char *path)
    ++{
    ++	struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
    ++
    ++	strvec_pushl(&hook_opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
    ++	hook_opt.dir = path;
    ++	return run_hooks_opt(the_repository, "post-worktree-add", &hook_opt);
    ++}
    ++
    + static void prune_worktree(const char *id, const char *reason)
    + {
    + 	if (show_only || verbose)
     @@ builtin/worktree.c: static int add_worktree(const char *path, const char *refname,
      	}
      
    @@ builtin/worktree.c: static int add_worktree(const char *path, const char *refnam
     +	 * is_junk is cleared, but do return appropriate code when a hook
     +	 * fails.
      	 */
    - 	if (!ret && opts->checkout && !opts->orphan) {
    - 		struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
    -@@ builtin/worktree.c: static int add_worktree(const char *path, const char *refname,
    - 		ret = run_hooks_opt(the_repository, "post-checkout", &opt);
    - 	}
    - 
    +-	if (!ret && opts->checkout && !opts->orphan) {
    +-		struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
    +-
    +-		strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
    +-		strvec_pushl(&opt.args,
    +-			     oid_to_hex(null_oid(the_hash_algo)),
    +-			     oid_to_hex(&commit->object.oid),
    +-			     "1",
    +-			     NULL);
    +-		opt.dir = path;
    +-
    +-		ret = run_hooks_opt(the_repository, "post-checkout", &opt);
     +	if (!ret) {
    -+		struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
    ++		int hook_ret;
    ++
    ++		if (opts->checkout && !opts->orphan) {
    ++			struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
     +
    -+		strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
    -+		strvec_pushl(&opt.args, wt->path, wt->id, NULL);
    -+		opt.dir = path;
    ++			strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
    ++			strvec_pushl(&opt.args,
    ++				     oid_to_hex(null_oid(the_hash_algo)),
    ++				     oid_to_hex(&commit->object.oid),
    ++				     "1",
    ++				     NULL);
    ++			opt.dir = path;
     +
    -+		ret = run_hooks_opt(the_repository, "post-worktree-add", &opt);
    -+	}
    ++			ret = run_hooks_opt(the_repository, "post-checkout", &opt);
    ++		}
     +
    ++		hook_ret = run_post_worktree_add_hook(wt->path);
    ++		if (!ret)
    ++			ret = hook_ret;
    + 	}
    + 
      	strvec_clear(&child_env);
    - 	strbuf_release(&sb);
    - 	strbuf_release(&symref);
     
      ## t/t2400-worktree-add.sh ##
     @@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-checkout hook' '
    @@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-ch
     +	test_when_finished "rm -rf .git/hooks" &&
     +	mkdir .git/hooks &&
     +	test_hook -C "$2" post-worktree-add <<-\EOF &&
    -+	{
    -+		echo $*
    -+		git rev-parse --git-dir --show-toplevel
    -+	} >hook.actual
    ++	test "$#" = 0 &&
    ++	git rev-parse --git-dir --show-toplevel >hook.actual
     +	EOF
     +	{
    -+		echo $(pwd)/$1 $1 &&
     +		echo $(pwd)/${2:-.git}/worktrees/$1 &&
     +		echo $(pwd)/$1
     +	} >hook.expect
    @@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-ch
     +	test_cmp hooks.expect wobble/hooks.actual
     +'
     +
    -+test_expect_success 'failing post-checkout hook suppresses post-worktree-add hook' '
    ++test_expect_success 'failing post-checkout hook does not suppress post-worktree-add hook' '
     +	test_when_finished "rm -rf .git/hooks" &&
     +	mkdir .git/hooks &&
     +	test_hook post-checkout <<-\EOF &&
    @@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-ch
     +	>post-worktree-add.ran
     +	EOF
     +	test_must_fail git worktree add wozzle &&
    -+	test_path_is_missing wozzle/post-worktree-add.ran
    ++	test_path_is_file wozzle/post-worktree-add.ran
     +'
     +
     +test_expect_success 'failing post-worktree-add hook leaves worktree in place' '
    @@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-ch
     +	test_path_is_missing hook.ran
     +'
     +
    -+test_expect_success 'post-worktree-add hook gets absolute path with relative worktrees' '
    ++test_expect_success 'post-worktree-add hook can derive path with relative worktrees' '
     +	test_when_finished "rm -rf relhook" &&
     +	git init relhook &&
     +	test_commit -C relhook base &&
     +	test_hook -C relhook post-worktree-add <<-\EOF &&
    -+	echo $* >hook.actual
    ++	test "$#" = 0 &&
    ++	git rev-parse --show-toplevel >hook.actual
     +	EOF
     +	git -C relhook worktree add --relative-paths --detach wt &&
    -+	echo $(pwd)/relhook/wt wt >hook.expect &&
    ++	echo $(pwd)/relhook/wt >hook.expect &&
     +	test_cmp hook.expect relhook/wt/hook.actual
     +'
     +
2:  7e109ece23 ! 2:  3de87064c0 worktree: add post-worktree-remove hook
    @@ Commit message
         Signed-off-by: Domen Kožar <domen@cachix.org>
         Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
     
    + ## Documentation/config/hook.adoc ##
    +@@ Documentation/config/hook.adoc: hook.jobs::
    + `pre-commit`;;
    + `post-checkout`;;
    + `post-worktree-add`;;
    ++`post-worktree-remove`;;
    + `push-to-checkout`;;
    + `post-commit`;;
    + 	Access the working tree, index, or repository state.
    +
      ## Documentation/githooks.adoc ##
    -@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, and is skipped if that hook fails.
    +@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, even if that hook fails.
      This hook can be used to set up per-worktree development environments
      or to register the new working tree with external tools.
      
    @@ Documentation/githooks.adoc: runs after the `post-checkout` hook, and is skipped
      
     
      ## builtin/worktree.c ##
    -@@ builtin/worktree.c: static void delete_worktrees_dir_if_empty(void)
    - 	free(path);
    +@@ builtin/worktree.c: static int run_post_worktree_add_hook(const char *path)
    + 	return run_hooks_opt(the_repository, "post-worktree-add", &hook_opt);
      }
      
     +static int run_post_worktree_remove_hook(const char *path, const char *id)
3:  143da548e4 = 3:  7989a1d6a2 worktree: run post-worktree-remove hook when pruning
-:  ---------- > 4:  95ab61e377 worktree: add post-worktree-move hook
-- 
2.54.0

```

## Domen Kožar, 2026-08-04 18:14

Subject: [PATCH v2 3/4] worktree: run post-worktree-remove hook when pruning
Message-ID: <6d4b2080-d7d9-4a87-a295-9c001d8a0290@mtasv.net>
In-Reply-To: <20260804181358.532970-1-domen@cachix.org>

```
A working tree can also disappear via "git worktree prune", e.g.
after the user deleted the working tree directory manually. Tooling
that tears down per-worktree state wants to observe those deletions
the same way as an explicit "git worktree remove".

Run the post-worktree-remove hook once for each administrative entry
that "git worktree prune" removes, including duplicate entries pruned
during deduplication. The hook is not run with --dry-run, and a
failing hook is reflected in the exit status of the command.

should_prune_worktree() so far returned the path of the worktree's
.git file only for entries that are kept. Also return it when pruning
an entry whose gitdir file points to a location that no longer
exists, which is the common case of a manually deleted working tree,
so that the hook can be given the path. For entries whose path cannot
be determined at all (missing or corrupt gitdir file), the hook
receives an empty string instead. The one other caller of
should_prune_worktree() already frees the path unconditionally.

Signed-off-by: Domen Kožar <domen@cachix.org>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 Documentation/githooks.adoc | 23 +++++-----
 builtin/worktree.c          | 48 ++++++++++++++------
 t/t2401-worktree-prune.sh   | 88 +++++++++++++++++++++++++++++++++++++
 worktree.c                  |  1 -
 worktree.h                  |  6 +--
 5 files changed, 139 insertions(+), 27 deletions(-)

diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 9573b8c1f5..fdf697b12f 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -237,16 +237,19 @@ post-worktree-remove
 ~~~~~~~~~~~~~~~~~~~~
 
 This hook is invoked by linkgit:git-worktree[1] after a working tree
-has been deleted by `git worktree remove`. The hook is given two
-parameters: the absolute path of the removed working tree and its
-identifier (the name of its former administrative directory in
-`$GIT_DIR/worktrees/`).
-
-The working tree no longer exists when the hook runs.
-
-This hook cannot affect the outcome of `git worktree remove`, other
-than that the hook's exit status becomes the exit status of the
-command.
+has been deleted by `git worktree remove`, and once for each working
+tree pruned by `git worktree prune`. The hook is given two parameters:
+the absolute path of the removed working tree and its identifier (the
+name of its former administrative directory in `$GIT_DIR/worktrees/`).
+
+The working tree no longer exists when the hook runs. For working
+trees pruned by `git worktree prune`, the first parameter may be the
+empty string if the path could not be determined from the leftover
+administrative files.
+
+This hook cannot affect the outcome of `git worktree remove` or
+`git worktree prune`, other than that the hook's exit status becomes
+the exit status of the command.
 
 This hook can be used to tear down per-worktree development
 environments or to unregister the working tree from external tools.
diff --git a/builtin/worktree.c b/builtin/worktree.c
index dc456fcac7..e0c37039ac 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -185,12 +185,27 @@ static int run_post_worktree_remove_hook(const char *path, const char *id)
 	return run_hooks_opt(the_repository, "post-worktree-remove", &hook_opt);
 }
 
-static void prune_worktree(const char *id, const char *reason)
+static int prune_worktree(const char *id, const char *dotgit,
+			  const char *reason)
 {
+	struct strbuf path = STRBUF_INIT;
+	int ret;
+
 	if (show_only || verbose)
 		fprintf_ln(stderr, _("Removing %s/%s: %s"), "worktrees", id, reason);
-	if (!show_only)
-		delete_git_dir(id);
+	if (show_only)
+		return 0;
+
+	delete_git_dir(id);
+
+	/* path stays empty when the worktree path cannot be determined */
+	if (dotgit) {
+		strbuf_addstr(&path, dotgit);
+		strbuf_strip_suffix(&path, "/.git");
+	}
+	ret = run_post_worktree_remove_hook(path.buf, id);
+	strbuf_release(&path);
+	return ret;
 }
 
 static int prune_cmp(const void *a, const void *b)
@@ -215,18 +230,22 @@ static int prune_cmp(const void *a, const void *b)
 	return strcmp(x->util, y->util);
 }
 
-static void prune_dups(struct string_list *l)
+static int prune_dups(struct string_list *l)
 {
 	int i;
+	int ret = 0;
 
 	QSORT(l->items, l->nr, prune_cmp);
 	for (i = 1; i < l->nr; i++) {
 		if (!fspathcmp(l->items[i].string, l->items[i - 1].string))
-			prune_worktree(l->items[i].util, "duplicate entry");
+			ret |= prune_worktree(l->items[i].util,
+					      l->items[i].string,
+					      "duplicate entry");
 	}
+	return ret;
 }
 
-static void prune_worktrees(void)
+static int prune_worktrees(void)
 {
 	struct strbuf reason = STRBUF_INIT;
 	struct strbuf main_path = STRBUF_INIT;
@@ -234,19 +253,22 @@ static void prune_worktrees(void)
 	char *path;
 	DIR *dir;
 	struct dirent *d;
+	int ret = 0;
 
 	path = repo_git_path(the_repository, "worktrees");
 	dir = opendir(path);
 	free(path);
 	if (!dir)
-		return;
+		return 0;
 	while ((d = readdir_skip_dot_and_dotdot(dir)) != NULL) {
 		char *path;
 		strbuf_reset(&reason);
-		if (should_prune_worktree(d->d_name, &reason, &path, expire))
-			prune_worktree(d->d_name, reason.buf);
-		else if (path)
+		if (should_prune_worktree(d->d_name, &reason, &path, expire)) {
+			ret |= prune_worktree(d->d_name, path, reason.buf);
+			free(path);
+		} else if (path) {
 			string_list_append_nodup(&kept, path)->util = xstrdup(d->d_name);
+		}
 	}
 	closedir(dir);
 
@@ -254,12 +276,13 @@ static void prune_worktrees(void)
 	/* massage main worktree absolute path to match 'gitdir' content */
 	strbuf_strip_suffix(&main_path, "/.");
 	string_list_append_nodup(&kept, strbuf_detach(&main_path, NULL));
-	prune_dups(&kept);
+	ret |= prune_dups(&kept);
 	string_list_clear(&kept, 1);
 
 	if (!show_only)
 		delete_worktrees_dir_if_empty();
 	strbuf_release(&reason);
+	return ret;
 }
 
 static int prune(int ac, const char **av, const char *prefix,
@@ -278,8 +301,7 @@ static int prune(int ac, const char **av, const char *prefix,
 			   0);
 	if (ac)
 		usage_with_options(git_worktree_prune_usage, options);
-	prune_worktrees();
-	return 0;
+	return prune_worktrees();
 }
 
 static char *junk_work_tree;
diff --git a/t/t2401-worktree-prune.sh b/t/t2401-worktree-prune.sh
index f8f28c76ee..74a80c1a8d 100755
--- a/t/t2401-worktree-prune.sh
+++ b/t/t2401-worktree-prune.sh
@@ -119,6 +119,94 @@ test_expect_success 'prune duplicate (main/linked)' '
 	test_path_is_missing .git/worktrees/wt
 '
 
+test_expect_success 'prune invokes post-worktree-remove hook' '
+	test_hook post-worktree-remove <<-\EOF &&
+	echo $* >hook.actual
+	EOF
+	git worktree add --detach flushed &&
+	rm -rf flushed &&
+	git worktree prune &&
+	echo $(pwd)/flushed flushed >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'prune invokes post-worktree-remove hook once per worktree' '
+	test_hook post-worktree-remove <<-\EOF &&
+	echo $* >>hook.actual
+	EOF
+	git worktree add --detach first &&
+	git worktree add --detach second &&
+	rm -rf first second hook.actual &&
+	git worktree prune &&
+	{
+		echo $(pwd)/first first &&
+		echo $(pwd)/second second
+	} >hook.expect &&
+	sort hook.actual >hook.sorted &&
+	test_cmp hook.expect hook.sorted
+'
+
+test_expect_success 'prune --dry-run does not invoke post-worktree-remove hook' '
+	git worktree add --detach dry &&
+	rm -rf dry &&
+	test_when_finished "git worktree prune" &&
+	test_hook post-worktree-remove <<-\EOF &&
+	>hook.ran
+	EOF
+	git worktree prune --dry-run &&
+	test_path_is_missing hook.ran
+'
+
+test_expect_success 'pruned entry with unknown path gives empty hook argument' '
+	test_hook post-worktree-remove <<-\EOF &&
+	echo "[$1][$2]" >hook.actual
+	EOF
+	mkdir -p .git/worktrees/broken &&
+	: >.git/worktrees/broken/gitdir &&
+	git worktree prune &&
+	echo "[][broken]" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'failing post-worktree-remove hook fails prune' '
+	test_hook post-worktree-remove <<-\EOF &&
+	exit 1
+	EOF
+	git worktree add --detach doomed &&
+	rm -rf doomed &&
+	test_must_fail git worktree prune &&
+	test_path_is_missing .git/worktrees/doomed
+'
+
+test_expect_success 'prune duplicate invokes post-worktree-remove hook' '
+	test_when_finished rm -fr .git/worktrees w1 w2 &&
+	test_hook post-worktree-remove <<-\EOF &&
+	echo $* >>hook.actual
+	EOF
+	rm -f hook.actual &&
+	git worktree add --detach w1 &&
+	git worktree add --detach w2 &&
+	sed "s/w2/w1/" .git/worktrees/w2/gitdir >.git/worktrees/w2/gitdir.new &&
+	mv .git/worktrees/w2/gitdir.new .git/worktrees/w2/gitdir &&
+	git worktree prune &&
+	echo $(pwd)/w1 w2 >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'post-worktree-remove hook gets absolute path with relative worktrees' '
+	test_when_finished "rm -rf relhook" &&
+	git init relhook &&
+	test_commit -C relhook base &&
+	test_hook -C relhook post-worktree-remove <<-\EOF &&
+	echo $* >hook.actual
+	EOF
+	git -C relhook worktree add --relative-paths --detach wt &&
+	rm -rf relhook/wt &&
+	git -C relhook worktree prune &&
+	echo $(pwd)/relhook/wt wt >hook.expect &&
+	test_cmp hook.expect relhook/hook.actual
+'
+
 test_expect_success 'not prune proper worktrees inside linked worktree with relative paths' '
 	test_when_finished rm -rf repo wt_ext &&
 	git init repo &&
diff --git a/worktree.c b/worktree.c
index 30125827fd..6a9d943874 100644
--- a/worktree.c
+++ b/worktree.c
@@ -1004,7 +1004,6 @@ int should_prune_worktree(const char *id, struct strbuf *reason, char **wtpath,
 		if (stat(file.buf, &st) || st.st_mtime <= expire) {
 			strbuf_addstr(reason, _("gitdir file points to non-existent location"));
 			rc = 1;
-			goto done;
 		}
 	}
 	*wtpath = strbuf_detach(&dotgit, NULL);
diff --git a/worktree.h b/worktree.h
index 1075409f9a..dde8fc2be4 100644
--- a/worktree.h
+++ b/worktree.h
@@ -105,9 +105,9 @@ const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire);
 
 /*
  * Return true if worktree entry should be pruned, along with the reason for
- * pruning. Otherwise, return false and the worktree's path in `wtpath`, or
- * NULL if it cannot be determined. Caller is responsible for freeing
- * returned path.
+ * pruning. Otherwise, return false. In both cases the path of the
+ * worktree's `.git` file is returned in `wtpath`, or NULL if it cannot
+ * be determined. Caller is responsible for freeing returned path.
  *
  * `expire` defines a grace period to prune the worktree when its path
  * does not exist.
-- 
2.54.0

```

## Caleb White, 2026-08-04 19:03

Subject: Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Message-ID: <DKGE5DORETW5.1S9NXEX8KMQHH@pm.me>
In-Reply-To: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>

```
On Tue Aug 4, 2026 at 1:14 PM CDT, Domen Kožar wrote:
> Hi everyone,
>
> First, apologies that my earlier reply reached the list as a separate
> message rather than as part of this thread. This is my first patch series
> submitted by email, and I am still getting the threading details right. I
> have made sure this reroll is plain text and correctly threaded.
>
> Thanks,
> Domen

Hi Domen,

I love the idea of having hooks for worktrees, especially now that
they are becoming more popular for having agents work on tasks in
parallel.

I'll try to set aside some time to dive into the individual patches,
but I would encourage you to take a look at b4[1][2] (if you haven't
already) as it greatly simplifies working with patch series.

Best,
Caleb

[1]: https://b4.docs.kernel.org/en/latest/
[2]: https://github.com/mricon/b4


```

## Caleb White, 2026-08-04 20:03

Subject: Re: [PATCH v2 1/4] worktree: add post-worktree-add hook
Message-ID: <DKGFFRLXG2GY.VYU49MX8QE8Q@pm.me>
In-Reply-To: <9f6bb2ec-afe3-4a1c-ade2-addf73f8681d@mtasv.net>

```
On Tue Aug 4, 2026 at 1:14 PM CDT, Domen Kožar wrote:
> Introduce a post-worktree-add hook that runs after the working tree
> has been fully set up, including with --no-checkout and --orphan. The
> hook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE
> cleared, mirroring the existing post-checkout invocation, and takes no
> arguments. Details such as the absolute path, worktree identifier, and
> checked-out branch can be queried by running git from the hook's working
> directory. Taking no arguments also lets a configured command shared
> with post-worktree-remove distinguish the events by argument count.

It looks like the `post-worktree-remove` and `post-worktree-move` hooks
both receive arguments but this hook does not. While the hook can
certainly use git to query the path and identifier, if you already have
that information I'm not sure why you can't and shouldn't just pass it
through to the hook (same thing goes for the new path and identifier on
the move hook).


Best,
Caleb


```

## Junio C Hamano, 2026-08-04 20:28

Subject: Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Message-ID: <xmqqtsp9tyu0.fsf@gitster.g>
In-Reply-To: <DKGE5DORETW5.1S9NXEX8KMQHH@pm.me>

```
Caleb White <cdwhite3@pm.me> writes:

> On Tue Aug 4, 2026 at 1:14 PM CDT, Domen Kožar wrote:
>> Hi everyone,
>>
>> First, apologies that my earlier reply reached the list as a separate
>> message rather than as part of this thread. This is my first patch series
>> submitted by email, and I am still getting the threading details right. I
>> have made sure this reroll is plain text and correctly threaded.
>>
>> Thanks,
>> Domen
>
> Hi Domen,
>
> I love the idea of having hooks for worktrees, especially now that
> they are becoming more popular for having agents work on tasks in
> parallel.

Before going there, we need to consider if these hooks are necessary
in the first place.  If you _always_ want to perform something
before or after running "git worktree add" or "git worktree remove",
you can instruct your agents to use "git wt" script when they want
to run "git worktree", and install a "git-wt" script on their $PATH,
which essentially would be something like

	#!/bin/sh
	# git worktree [add/remove] ...
	case "$1" in
	add)
		... do whatever you want to do before add ...
		;;
	remove)
		... do whatever you want to do before remove ...
		;;
	esac

	git worktree "$@"

	case "$1" in
	add)
		... do whatever you want to do after add ...
		;;
	remove)
		... do whatever you want to do after remove ...
		;;
	esac

The users would need to write the "... do whatever you want to do"
part as the hook script _anyway_, and unless there are compelling
reason why these _must_ be implemented as hooks, you should resist
the temptation to pile more hooks on the system.

Having said all that.

There are five valid reasons you might still want to have a hook in
a Git command or operation:

 (1) A hook that countermands the normal decision made by the
     underlying command.  Examples of this class are the 'update'
     hook and the 'pre-commit' hook.

 (2) A hook that operates on data generated after the command starts
     to run.  The ability to munge the commit log message via the
     'commit-msg' hook is an example.  You cannot easily prepare
     what the 'commit-msg' hook may produce before you run
     'git commit'.

 (3) A hook that operates on the remote end of the connection that
     you may not otherwise have access to, other than over the Git
     protocol.  An example is the 'post-update' hook that runs
     update-server-info().

 (4) A hook that runs under a lock acquired by the command for
     mutual exclusion.  Currently there is no example, but if we
     allowed the 'update' hook to modify the commit that was pushed
     through a send-pack and receive-pack pair (which was discussed on
     the list a while ago), it would be a good example of this.

 (5) A hook that is run differently depending on the outcome of the
     command.  The 'post-merge' hook conditionally run by 'git pull' is
     an example of this (it is not run if no merge takes place).
     Another example is the 'post-checkout' hook that gets
     information that is otherwise harder to get (namely, whether it
     was a branch checkout or a file checkout -- you can figure it
     out by examining the command line, but that is already part of the
     processing 'git checkout' does anyway, so there is no need to
     force duplication of that code in userland).

If you cannot do an equivalent operation from outside the Git command
for the above classes of operations, you need hooks for them.

On the other hand, if you want to always trigger an action before or
after running a Git operation locally, you do not need a hook.  This
is true even if the action you perform after running a Git operation
depends on what happened (class (5) above), provided the result is
easily observable after the fact.

Of course, one very valid exception to the above policy is when an
action is common enough that the policy effectively forces everyone
to reinvent the same wrapper.  We may be better off adding it as an
officially supported hook in such a case.

But for the hooks proposed in this topic, I do not think such an
exception applies.

Thanks.

```

## Domen Kožar, 2026-08-30 17:21

Subject: Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Message-ID: <8bd3a684-51a0-4a2a-b70d-3981cfe10e9a@mtasv.net>
In-Reply-To: <xmqqtsp9tyu0.fsf@gitster.g>

```
Hi Junio,

Thanks for laying out the criteria for adding hooks.

> But for the hooks proposed in this topic, I do not think such an
> exception applies.

I agree that a wrapper is sufficient when all callers are under the
user's control. The problem I am trying to solve is that the component
which needs the notification does not control the component invoking
Git.

For example, devenv may register lifecycle handling for a repository,
but worktrees can subsequently be created or removed by an IDE, a
coding agent, another worktree tool, a script, or the user directly.
Requiring each of those callers to discover and use the same wrapper
makes the notification optional in practice. A repository hook provides
one place where that lifecycle behavior can be registered regardless of
which caller invokes Git.

"git worktree prune" is also difficult to reproduce reliably in a
wrapper. One invocation can remove zero or many administrative entries,
and Git knows exactly which entries it actually removes. A wrapper could
compare "git worktree list" before and after the command, but that is
not an authoritative event stream, can race another worktree operation,
and has limited information when an entry is already damaged.

Alexander provided another concrete example later in the thread: Xcode
and several related tools keep substantial path-keyed state outside the
worktree. Agents invoking "git worktree remove" or "git worktree prune"
directly leave many gigabytes of state behind even though a cleanup
wrapper exists. Phillip also mentioned having an unpublished add-hook
patch for copying per-worktree files such as config.mak.

That said, I take the point about avoiding a proliferation of hooks.
Instead of adding three separate hook names, would a single
"post-worktree" hook address that concern? It could use a fixed
interface such as:

    post-worktree add    <id> ""         <new-path>
    post-worktree move   <id> <old-path> <new-path>
    post-worktree remove <id> <old-path> ""

All paths would be absolute. Pruning would issue one "remove" event for
each entry actually pruned, and none under --dry-run. As with the
current series, the hook would only report an operation that has taken
effect and could not undo it.

This would also address Caleb's comment about passing information Git
already has rather than requiring the hook to query it, and it avoids
using the argument count to distinguish events.

Would that narrower interface, together with the need to observe
operations from callers that cannot be required to use a wrapper, meet
the bar for a native hook? If not, I would appreciate guidance before
spending time on a reroll.

Thanks,
Domen

```

## Domen Kožar, 2026-09-07 14:30

Subject: Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Message-ID: <b10f2706-67bb-4657-8d7c-819a29dff890@mtasv.net>
In-Reply-To: <8bd3a684-51a0-4a2a-b70d-3981cfe10e9a@mtasv.net>

```
Hi Junio and everyone,

A gentle ping on the single "post-worktree" hook proposal below.

As coding agents increasingly create and discard worktrees, reliable
lifecycle notifications matter more because tools managing environments
and cleanup cannot control every caller invoking Git.

Would this interface address your concerns? I'm happy to prepare v3 if
we can agree on the direction.

Thanks,
Domen

```

## Kristoffer Haugsbakk, 2026-09-07 18:18

Subject: Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Message-ID: <de83b791-92ae-41a1-8ecf-5abce7893998@app.fastmail.com>
In-Reply-To: <b10f2706-67bb-4657-8d7c-819a29dff890@mtasv.net>

```
On Mon, Sep 7, 2026, at 16:30, Domen Kožar wrote:
> A gentle ping on the single "post-worktree" hook proposal below.
>
> As coding agents increasingly create and discard worktrees, reliable
> lifecycle notifications matter more because tools managing environments
> and cleanup cannot control every caller invoking Git.
>
> Would this interface address your concerns? I'm happy to prepare v3 if
> we can agree on the direction.

The problem is multiple callers invoking Git with different
intentions. What if `git worktree add` learned an option `--id=<string>`
which is then stored in the worktree? This would communicate that
whoever calls git-worktree(1) commands with this ID is the one who owns
it.[1] git-worktree(1) commands which do not get an `--id` (and
specifically that ID) does nothing to owned worktrees.

[1]: You know, among friendly and cooperating processes.

Then you don’t need hooks for adding, removing, pruning
and whatever else.

I could imagine an agent tool that uses it’s own ID to manage a pool
of worktrees.

I could imagine more metadata, like recording a timestamp for worktree
expiry... but you don’t need anything more in git-worktree(1) itself if
you have this ID concept. Then you can let tools implement it
themselves. They won’t have their worktrees bothered/clobbered by other
tools, so they can manage the whole lifecycle in peace.

```

## Domen Kožar, 2026-09-07 19:34

Subject: Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Message-ID: <8fda00ab-a7ac-4ccb-95bb-9d2e28163bb7@mtasv.net>
In-Reply-To: <de83b791-92ae-41a1-8ecf-5abce7893998@app.fastmail.com>

```
Hi Kristoffer,

Thanks for the suggestion. An ownership ID could help tools avoid
interfering with each other's worktrees.

The case I'm trying to support has two separate participants: an IDE
or agent creates and removes worktrees, while devenv provisions and
cleans up the development environment associated with each one.

devenv doesn't own those worktrees or control the commands that create
them. It needs to observe their lifecycle regardless of which tool
invokes Git. Even if the creator supplies an ownership ID, how would
devenv learn that a worktree was added or removed?

Requiring the creator to explicitly notify devenv would bring back the
per-tool integration requirement that motivates the hook. The same
applies to worktrees created or removed directly by the user.

Pruning is another useful case: Git knows which administrative entries
it actually removes, including entries whose directories are already
gone. A hook could report each removal directly to interested tooling.

Would ownership IDs and lifecycle notifications serve complementary
purposes here?

Thanks,
Domen

```

## Maciej Ciemborowicz, 2026-10-03 18:47

Subject: Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Message-ID: <20261003184725.29917-1-maciej.ciemborowicz@gmail.com>
In-Reply-To: <xmqqtsp9tyu0.fsf@gitster.g>

```
Hi Junio,

Thank you for the detailed explanation. I agree that a wrapper is the
right answer when the user controls the invocation of `git worktree`.

I tried that approach for a tool I am building: it creates an isolated
container environment for each worktree. This matters in particular for
projects with databases and migrations, where two agents working in
parallel must not share an environment.

The problem is that, in this case, the worktree is often created by the
IDE rather than by the user or the agent. For example, Codex in VS Code
creates worktrees itself when it starts parallel agent sessions. A
`git-wt` wrapper is therefore bypassed, and asking agents to use one does
not help: by the time an agent begins work, the worktree may already
exist and its environment needs to have been provisioned.

It is possible to approximate this with polling or instructions in an
AGENTS.md file, but neither provides a reliable lifecycle boundary.
There is a race between worktree creation and the agent beginning work,
and cleanup on removal has the same issue.

So I wanted to report a concrete case where wrappers do not reach the
actor that performs the operation. This has also been the most consistent
feedback I have heard from people using parallel AI agents: they do not
want to replace every worktree caller with a wrapper; they need a
repository-local way to react when Git creates, moves, or removes a
worktree.

Best regards,
Maciej Ciemborowicz

```

## Domen Kožar, 2026-10-04 23:09

Subject: [PATCH v3 1/2] worktree: add post-worktree lifecycle hook
Message-ID: <2c1c1f06-05e7-4d8c-bd29-c2a9708b443d@mtasv.net>
In-Reply-To: <cover.1791152172.git.domen@cachix.org>

```
Tools that manage per-worktree development environments need to observe
worktrees created, moved, or removed by other programs. Wrapping the
worktree command only helps when every caller uses the wrapper, and
post-checkout does not run for add --no-checkout or --orphan. There is
no notification for moving or removing a worktree.

Add one post-worktree hook for these operations. Pass the event name,
worktree identifier, old absolute path, and new absolute path as four
arguments, using an empty string for a path that does not apply. An
explicit event name lets one handler manage the whole lifecycle without
using argument count to distinguish operations, as the earlier series
with three separate hooks did.

Run the hook in the invoking repository with its normal environment,
rather than changing to the affected worktree. Passing both paths lets
handlers target the new worktree when needed and keeps the execution
context consistent when a worktree has been removed.

Run the add event after post-checkout even when that hook fails, because
the worktree remains present. A failing lifecycle hook affects the
command's exit status without undoing the completed operation. Preserve
post-checkout's failure status if both hooks fail.

Document the interface and cover ordinary, bare, and linked callers,
no-checkout and orphan worktrees, relative paths, paths with spaces,
configured hooks, and hook failures.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Domen Kožar <domen@cachix.org>
---
 Documentation/config/hook.adoc |   1 +
 Documentation/githooks.adoc    |  43 +++++++++++
 builtin/worktree.c             |  56 ++++++++++----
 t/t2400-worktree-add.sh        | 132 +++++++++++++++++++++++++++++++++
 t/t2403-worktree-move.sh       | 113 ++++++++++++++++++++++++++++
 5 files changed, 329 insertions(+), 16 deletions(-)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index 083dc60a13..501bb006f5 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -94,6 +94,7 @@ hook.jobs::
 	Receive a commit message file and may rewrite it in place.
 `pre-commit`;;
 `post-checkout`;;
+`post-worktree`;;
 `push-to-checkout`;;
 `post-commit`;;
 	Access the working tree, index, or repository state.
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 145642bf05..3e25f769c5 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -215,6 +215,49 @@ This hook can be used to perform repository validity checks, auto-display
 differences from the previous HEAD if different, or set working dir metadata
 properties.
 
+post-worktree
+~~~~~~~~~~~~~
+
+This hook is invoked by linkgit:git-worktree[1] after a working tree is
+added, moved, or removed. It takes four parameters: the event (`add`, `move`,
+or `remove`), the worktree identifier (the name of its administrative
+directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the
+new absolute path.
+
+The parameters for each event are:
+
+    post-worktree add    <id> ""         <new-path>
+    post-worktree move   <id> <old-path> <new-path>
+    post-worktree remove <id> <old-path> ""
+
+The empty strings are passed as arguments, so all events have exactly
+four parameters.
+
+The hook runs in the repository where the command was invoked, following
+the working directory and environment rules described above. It does not
+change to the added or moved working tree. To run Git commands there,
+clear the repository environment variables and use the new path, for
+example:
+
+------------
+(unset $(git rev-parse --local-env-vars); git -C "$4" status)
+------------
+
+The `add` event runs after the new working tree has been set up, including
+with `--no-checkout` and `--orphan`. It runs after `post-checkout`, even
+if that hook fails. The `move` event runs after the working tree and its
+administrative files have been moved. The `remove` event runs after the
+working tree has been deleted or its administrative entry removed.
+
+The hook cannot undo the worktree operation. A non-zero exit status is
+reflected in the command's exit status, but leaves the completed operation
+in place. If `post-checkout` fails during `git worktree add`, its exit
+status takes precedence over that of `post-worktree`.
+
+This hook can be used to set up, relocate, or tear down per-worktree
+development environments, or to maintain registrations with external
+tools. Hook scripts should ignore events they do not handle.
+
 post-merge
 ~~~~~~~~~~
 
diff --git a/builtin/worktree.c b/builtin/worktree.c
index 77ecd0f71f..0f2748080c 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -168,6 +168,15 @@ static void delete_worktrees_dir_if_empty(void)
 	free(path);
 }
 
+static int run_post_worktree_hook(const char *event, const char *id,
+				  const char *old_path, const char *new_path)
+{
+	struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
+	strvec_pushl(&hook_opt.args, event, id, old_path, new_path, NULL);
+	return run_hooks_opt(the_repository, "post-worktree", &hook_opt);
+}
+
 static void prune_worktree(const char *id, const char *reason)
 {
 	if (show_only || verbose)
@@ -604,21 +613,30 @@ static int add_worktree(const char *path, const char *refname,
 	}
 
 	/*
-	 * Hook failure does not warrant worktree deletion, so run hook after
-	 * is_junk is cleared, but do return appropriate code when hook fails.
+	 * Hook failures do not warrant worktree deletion, so run hooks after
+	 * is_junk is cleared, but do return appropriate code when a hook
+	 * fails.
 	 */
-	if (!ret && opts->checkout && !opts->orphan) {
-		struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
-
-		strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
-		strvec_pushl(&opt.args,
-			     oid_to_hex(null_oid(the_hash_algo)),
-			     oid_to_hex(&commit->object.oid),
-			     "1",
-			     NULL);
-		opt.dir = path;
-
-		ret = run_hooks_opt(the_repository, "post-checkout", &opt);
+	if (!ret) {
+		int hook_ret;
+
+		if (opts->checkout && !opts->orphan) {
+			struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
+			strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
+			strvec_pushl(&opt.args,
+				     oid_to_hex(null_oid(the_hash_algo)),
+				     oid_to_hex(&commit->object.oid),
+				     "1",
+				     NULL);
+			opt.dir = path;
+
+			ret = run_hooks_opt(the_repository, "post-checkout", &opt);
+		}
+
+		hook_ret = run_post_worktree_hook("add", wt->id, "", wt->path);
+		if (!ret)
+			ret = hook_ret;
 	}
 
 	strvec_clear(&child_env);
@@ -1305,7 +1323,8 @@ static int move_worktree(int ac, const char **av, const char *prefix,
 	struct strbuf dst = STRBUF_INIT;
 	struct strbuf errmsg = STRBUF_INIT;
 	const char *reason = NULL;
-	char *path;
+	char *old_path, *path;
+	int ret;
 
 	ac = parse_options(ac, av, prefix, options, git_worktree_move_usage,
 			   0);
@@ -1348,14 +1367,17 @@ static int move_worktree(int ac, const char **av, const char *prefix,
 		    errmsg.buf);
 	strbuf_release(&errmsg);
 
+	old_path = xstrdup(wt->path);
 	if (rename(wt->path, dst.buf) == -1)
 		die_errno(_("failed to move '%s' to '%s'"), wt->path, dst.buf);
 
 	update_worktree_location(wt, dst.buf, use_relative_paths);
+	ret = run_post_worktree_hook("move", wt->id, old_path, wt->path);
 
+	free(old_path);
 	strbuf_release(&dst);
 	free_worktrees(worktrees);
-	return 0;
+	return ret;
 }
 
 /*
@@ -1473,6 +1495,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix,
 	ret |= delete_git_dir(wt->id);
 	delete_worktrees_dir_if_empty();
 
+	ret |= run_post_worktree_hook("remove", wt->id, wt->path, "");
+
 	free_worktrees(worktrees);
 	return ret;
 }
diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
index bdcca97633..65fec976b5 100755
--- a/t/t2400-worktree-add.sh
+++ b/t/t2400-worktree-add.sh
@@ -1172,6 +1172,138 @@ test_expect_success '"add" in bare repo invokes post-checkout hook' '
 	test_cmp hook.expect goozy/hook.actual
 '
 
+# Install a post-worktree hook and write the output expected for adding
+# worktree $1. Repo $2 defaults to "."; the caller worktree is $3.
+post_worktree_add_hook () {
+	test_when_finished "rm -rf .git/hooks" &&
+	mkdir .git/hooks &&
+	test_hook -C "$2" post-worktree <<-\EOF &&
+	test "$#" = 4 &&
+	{
+		printf "%s\n" "$@" &&
+		test-tool path-utils real_path . &&
+		git rev-parse --absolute-git-dir
+	} >hook.actual
+	EOF
+	{
+		test_write_lines add "$1" "" "$(pwd)/$1" &&
+		(cd "${3:-${2:-.}}" && test-tool path-utils real_path .) &&
+		git -C "${3:-${2:-.}}" rev-parse --absolute-git-dir
+	} >hook.expect
+}
+
+test_expect_success '"add" invokes post-worktree hook' '
+	post_worktree_add_hook wanda &&
+	git worktree add wanda &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"add" in other worktree invokes post-worktree hook there' '
+	post_worktree_add_hook wilbur "" wanda &&
+	git -C wanda worktree add ../wilbur &&
+	test_cmp hook.expect wanda/hook.actual
+'
+
+test_expect_success '"add --no-checkout" still invokes post-worktree hook' '
+	post_worktree_add_hook wendy &&
+	git worktree add --no-checkout wendy &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"add --orphan" invokes post-worktree hook' '
+	post_worktree_add_hook winnie &&
+	git worktree add --orphan winnie &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"add" in bare repo invokes post-worktree hook there' '
+	rm -rf bare2 &&
+	git clone --bare . bare2 &&
+	post_worktree_add_hook willow bare2 &&
+	git -C bare2 worktree add --detach ../willow &&
+	test_cmp hook.expect bare2/hook.actual
+'
+
+test_expect_success '"add" runs post-worktree after post-checkout' '
+	test_when_finished "rm -rf .git/hooks" &&
+	mkdir .git/hooks &&
+	test_hook post-checkout <<-\EOF &&
+	echo post-checkout >>"$(git rev-parse --git-common-dir)/hooks.actual"
+	EOF
+	test_hook post-worktree <<-\EOF &&
+	echo post-worktree >>"$(git rev-parse --git-common-dir)/hooks.actual"
+	EOF
+	test_write_lines post-checkout post-worktree >hooks.expect &&
+	git worktree add wobble &&
+	test_cmp hooks.expect .git/hooks.actual
+'
+
+test_expect_success 'failing post-checkout hook does not suppress post-worktree hook' '
+	test_when_finished "rm -rf .git/hooks" &&
+	mkdir .git/hooks &&
+	test_hook post-checkout <<-\EOF &&
+	exit 2
+	EOF
+	test_hook post-worktree <<-\EOF &&
+	>post-worktree.ran &&
+	exit 3
+	EOF
+	test_expect_code 2 git worktree add wozzle &&
+	test_path_is_file post-worktree.ran
+'
+
+test_expect_success 'failing post-worktree hook leaves worktree in place' '
+	test_when_finished "rm -rf .git/hooks" &&
+	mkdir .git/hooks &&
+	test_hook post-worktree <<-\EOF &&
+	exit 1
+	EOF
+	test_expect_code 1 git worktree add wilma &&
+	git worktree list --porcelain >out &&
+	test_grep -F "worktree $(pwd)/wilma" out
+'
+
+test_expect_success 'failed "add" does not invoke post-worktree hook' '
+	test_when_finished "rm -rf .git/hooks occupied" &&
+	mkdir .git/hooks &&
+	test_hook post-worktree <<-\EOF &&
+	>hook.ran
+	EOF
+	mkdir occupied &&
+	: >occupied/blocker &&
+	test_must_fail git worktree add occupied &&
+	test_path_is_missing hook.ran
+'
+
+test_expect_success 'post-worktree add gets absolute path with relative worktrees' '
+	test_when_finished "rm -rf relhook" &&
+	git init relhook &&
+	test_commit -C relhook base &&
+	test_hook -C relhook post-worktree <<-\EOF &&
+	test "$#" = 4 &&
+	printf "%s\n" "$@" >hook.actual
+	EOF
+	git -C relhook worktree add --relative-paths --detach wt &&
+	test_write_lines add wt "" "$(pwd)/relhook/wt" >hook.expect &&
+	test_cmp hook.expect relhook/hook.actual
+'
+
+test_expect_success 'configured post-worktree hook preserves paths with spaces' '
+	test_when_finished "rm -rf confighook" &&
+	git init confighook &&
+	test_commit -C confighook base &&
+	write_script confighook/record-hook <<-\EOF &&
+	test "$#" = 4 &&
+	printf "%s\n" "$@" >hook.actual
+	EOF
+	git -C confighook config hook.lifecycle.command ./record-hook &&
+	git -C confighook config hook.lifecycle.event post-worktree &&
+	git -C confighook worktree add --detach "wt with spaces" &&
+	id=$(basename "$(git -C "confighook/wt with spaces" rev-parse --absolute-git-dir)") &&
+	test_write_lines add "$id" "" "$(pwd)/confighook/wt with spaces" >hook.expect &&
+	test_cmp hook.expect confighook/hook.actual
+'
+
 test_expect_success '"add" an existing but missing worktree' '
 	git worktree add --detach pneu &&
 	test_must_fail git worktree add --detach pneu &&
diff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh
index 69768c1207..11ef81dce8 100755
--- a/t/t2403-worktree-move.sh
+++ b/t/t2403-worktree-move.sh
@@ -82,6 +82,59 @@ test_expect_success 'move worktree' '
 	test_cmp expected2 actual2
 '
 
+test_expect_success '"move" invokes post-worktree hook in the calling repository' '
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 || exit 1
+	test "$1" = move || exit 0
+	{
+		printf "%s\n" "$@" &&
+		test-tool path-utils real_path . &&
+		git rev-parse --absolute-git-dir
+	} >hook.actual
+	EOF
+	git worktree add --detach hook-source &&
+	git worktree move hook-source hook-destination &&
+	{
+		test_write_lines move hook-source "$(pwd)/hook-source" "$(pwd)/hook-destination" &&
+		test-tool path-utils real_path . &&
+		git rev-parse --absolute-git-dir
+	} >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'failing post-worktree move event leaves worktree moved' '
+	test_hook post-worktree <<-\EOF &&
+	test "$1" = move || exit 0
+	exit 1
+	EOF
+	git worktree add --detach hook-failing-source &&
+	test_must_fail git worktree move hook-failing-source hook-failing-destination &&
+	test_path_is_missing hook-failing-source &&
+	git -C hook-failing-destination status --porcelain >actual &&
+	test_must_be_empty actual
+'
+
+test_expect_success 'post-worktree move keeps the ID and passes absolute paths with spaces' '
+	test_when_finished "rm -rf movehook" &&
+	git init movehook &&
+	test_commit -C movehook base &&
+	git -C movehook worktree add --relative-paths --detach "source tree" &&
+	git -C movehook worktree add --detach caller &&
+	id=$(basename "$(git -C "movehook/source tree" rev-parse --absolute-git-dir)") &&
+	test_hook -C movehook post-worktree <<-\EOF &&
+	test "$#" = 4 &&
+	{
+		printf "%s\n" "$@" &&
+		git rev-parse --show-toplevel
+	} >hook.actual
+	EOF
+	git -C movehook/caller worktree move --relative-paths "../source tree" "../destination tree" &&
+	test_write_lines move "$id" "$(pwd)/movehook/source tree" \
+		"$(pwd)/movehook/destination tree" "$(pwd)/movehook/caller" >hook.expect &&
+	test_cmp hook.expect movehook/caller/hook.actual &&
+	test_path_is_dir "movehook/destination tree"
+'
+
 test_expect_success 'move main worktree' '
 	test_must_fail git worktree move . def
 '
@@ -246,6 +299,66 @@ test_expect_success 'not remove a repo with initialized submodule' '
 	)
 '
 
+test_expect_success '"remove" invokes post-worktree remove event' '
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 || exit 1
+	test "$1" = remove || exit 0
+	printf "%s\n" "$@" >hook.actual
+	EOF
+	git worktree add --detach wt-hooked &&
+	git worktree remove wt-hooked &&
+	test_write_lines remove wt-hooked "$(pwd)/wt-hooked" "" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"remove" of missing worktree invokes post-worktree hook' '
+	test_when_finished "rm -rf wt-moved-away" &&
+	test_hook post-worktree <<-\EOF &&
+	test "$1" = remove || exit 0
+	printf "%s\n" "$@" >hook.actual
+	EOF
+	rm -f hook.actual &&
+	git worktree add --detach wt-elsewhere &&
+	mv wt-elsewhere wt-moved-away &&
+	git worktree remove wt-elsewhere &&
+	test_write_lines remove wt-elsewhere "$(pwd)/wt-elsewhere" "" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'refused "remove" does not invoke post-worktree hook' '
+	git worktree add --detach wt-kept &&
+	test_when_finished "git worktree remove --force --force wt-kept || :" &&
+	test_hook post-worktree <<-\EOF &&
+	>hook.ran
+	EOF
+	git worktree lock wt-kept &&
+	test_must_fail git worktree remove wt-kept &&
+	test_path_is_missing hook.ran
+'
+
+test_expect_success 'failing post-worktree remove event fails "remove", worktree is gone' '
+	test_hook post-worktree <<-\EOF &&
+	test "$1" = remove || exit 0
+	exit 1
+	EOF
+	git worktree add --detach wt-doomed &&
+	test_must_fail git worktree remove wt-doomed &&
+	test_path_is_missing wt-doomed &&
+	test_path_is_missing .git/worktrees/wt-doomed
+'
+
+test_expect_success 'post-worktree remove preserves paths with spaces' '
+	git worktree add --detach "remove tree" &&
+	id=$(basename "$(git -C "remove tree" rev-parse --absolute-git-dir)") &&
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 &&
+	printf "%s\n" "$@" >hook.actual
+	EOF
+	git worktree remove "remove tree" &&
+	test_write_lines remove "$id" "$(pwd)/remove tree" "" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
 test_expect_success 'move worktree with absolute path to relative path' '
 	test_config worktree.useRelativePaths false &&
 	git worktree add ./absolute &&
-- 
2.54.0

```

## Domen Kožar, 2026-10-04 23:09

Subject: [PATCH v3 0/2] worktree: add post-worktree lifecycle hook
Message-ID: <d550ede0-6a33-4eea-a6dd-051d110b68e5@mtasv.net>
In-Reply-To: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>

```
Tools that manage per-worktree development environments need to observe
worktrees created, moved, or removed by other programs. Wrapping git
worktree only helps when every caller uses the wrapper. post-checkout
does not run for add --no-checkout or --orphan, and Git has no lifecycle
notification for moving, removing, or pruning worktrees.

The motivating use case is devenv provisioning and cleaning up processes
and services for worktrees created by an IDE or agent. The creator and
environment manager are separate tools. Ownership identifiers can help
coordinate creators, but do not notify the environment manager about
changes made by another tool or directly by the user.

Add one post-worktree hook with four arguments:

    post-worktree add    <id> ""         <new-path>
    post-worktree move   <id> <old-path> <new-path>
    post-worktree remove <id> <old-path> ""

The hook runs in the invoking repository with the usual hook working
directory and environment. Paths are absolute; empty strings are passed
as arguments for paths that do not apply or cannot be determined during
pruning. The worktree identifier is the name of its administrative entry
under the common Git directory.

The add event runs after post-checkout, even if post-checkout fails, and
also covers --no-checkout and --orphan. Prune emits a remove event per
pruned entry, including duplicates, and none for --dry-run. Hook failures
affect the command exit status without undoing completed operations;
pruning continues to notify the remaining entries after a hook failure.

The first commit adds the hook for add, move, and remove. The second adds
pruning notifications. Both include documentation and regression tests.

Changes since v2:

 * Replace the three separate hook names with the single post-worktree
   interface proposed in the mailing-list discussion.
 * Pass an explicit event, identifier, and both paths for every event,
   instead of using argument count to distinguish operations.
 * Keep the execution context in the invoking repository for all events.
 * Cover configured hooks, paths with spaces, linked and bare callers,
   relative paths, failure status precedence, and continued pruning
   notifications after a hook failure.
 * Rebase onto current master and use its repository argument in
   should_prune_worktree().

Earlier discussion:
https://lore.kernel.org/git/7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net/
https://lore.kernel.org/git/8bd3a684-51a0-4a2a-b70d-3981cfe10e9a@mtasv.net/

AI assistance: the commits retain the original Claude coauthor credit.
Codex assisted with consolidating the interface, adapting the tests,
updating documentation and commit messages, and validating this revision.

Validation:

 * Developer build and test lint passed with DEVELOPER=1.
 * All nine hook and worktree suites passed in the final full run. A
   focused run after the interface change passed 489 tests, and the
   lifecycle commit also passed independently (381 tests).
 * git diff --check and the repository clang-format check passed.
 * Trial merges into next and seen apply without conflicts.
 * githooks.html and git-config.html render correctly with Asciidoctor;
   only post-worktree is registered in the generated hook list.
 * The final full local run completed: 1,062 test files, 33,881 tests.
   Four tests failed with a 128 KiB stack: t0003-attributes.sh test 55,
   t6120-describe.sh tests 85-86, and t7004-tag.sh test 212. These are
   the same four failures reproduced on unmodified upstream master at
   8103b446517e0c44e67561b9d0ccce56efa60a71 using the same compiler and
   environment. Optional tests without available prerequisites skipped.
 * All enabled platform CI checks passed, including macOS, Windows,
   Linux variants, Meson, address/undefined-behavior sanitizers, and leak
   checks. CI initially caught a Windows shell/native path mismatch in
   two new working-directory assertions. These now use the path-utils
   test helper; all 289 add/move tests also pass locally after the fix.
   https://github.com/git/git/actions/runs/37239319553

PR and platform checks:
https://github.com/git/git/pull/2442

Domen Kožar (2):
  worktree: add post-worktree lifecycle hook
  worktree: notify post-worktree hook when pruning

 Documentation/config/hook.adoc |   1 +
 Documentation/githooks.adoc    |  46 ++++++++++++
 builtin/worktree.c             | 105 ++++++++++++++++++--------
 t/t2400-worktree-add.sh        | 132 +++++++++++++++++++++++++++++++++
 t/t2401-worktree-prune.sh      | 102 +++++++++++++++++++++++++
 t/t2403-worktree-move.sh       | 113 ++++++++++++++++++++++++++++
 worktree.c                     |   1 -
 worktree.h                     |   6 +-
 8 files changed, 473 insertions(+), 33 deletions(-)

Range-diff against v2:
1:  73e36c179e < -:  ---------- worktree: add post-worktree-add hook
2:  3de87064c0 < -:  ---------- worktree: add post-worktree-remove hook
3:  7989a1d6a2 < -:  ---------- worktree: run post-worktree-remove hook when pruning
-:  ---------- > 1:  c37f12fcba worktree: add post-worktree lifecycle hook
4:  95ab61e377 ! 2:  e5855a1491 worktree: add post-worktree-move hook
    @@ Metadata
     Author: Domen Kožar <domen@cachix.org>
     
      ## Commit message ##
    -    worktree: add post-worktree-move hook
    +    worktree: notify post-worktree hook when pruning
     
    -    Tools that record worktree paths can keep their state up to date when a
    -    worktree is added or removed, but the mapping becomes stale when the
    -    worktree is moved. Services or other per-worktree state tied to the old
    -    path may also need to be relocated.
    +    A worktree can disappear without git worktree remove, for example when
    +    its directory is deleted manually. Tools maintaining per-worktree state
    +    need to observe its later deregistration by git worktree prune as well.
    +    Git knows which entries it prunes, including duplicates, whereas a
    +    wrapper comparing worktree listings can race concurrent operations and
    +    has limited information about damaged entries.
     
    -    Introduce a post-worktree-move hook that runs after the working tree and
    -    its administrative files have been moved. The hook runs inside the new
    -    working tree with GIT_DIR and GIT_WORK_TREE cleared and receives the old
    -    absolute path as its sole argument. The new path and worktree identifier
    -    can be queried by running git from the hook's working directory.
    +    Emit a post-worktree remove event for each pruned administrative entry,
    +    with its identifier and former absolute path. Return the recorded .git
    +    path from should_prune_worktree() even when it points to a missing
    +    location, so the hook can receive the former worktree path. If the path
    +    cannot be determined, pass an empty string instead.
     
    -    This signature also lets one configured command handle all three
    -    worktree lifecycle hooks by argument count: post-worktree-add takes no
    -    arguments, post-worktree-move takes one, and post-worktree-remove takes
    -    two.
    -
    -    A failing hook does not undo the completed move, but its exit status
    -    becomes the exit status of "git worktree move".
    +    Do not invoke the hook during a dry run. Reflect hook failures in the
    +    command's exit status while continuing to process the remaining entries.
    +    Document pruning and test missing paths, duplicate entries, relative
    +    paths, dry runs, and failures that must not suppress other notifications.
     
    +    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
         Signed-off-by: Domen Kožar <domen@cachix.org>
    -    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    -
    - ## Documentation/config/hook.adoc ##
    -@@ Documentation/config/hook.adoc: hook.jobs::
    - `pre-commit`;;
    - `post-checkout`;;
    - `post-worktree-add`;;
    -+`post-worktree-move`;;
    - `post-worktree-remove`;;
    - `push-to-checkout`;;
    - `post-commit`;;
     
      ## Documentation/githooks.adoc ##
    -@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, even if that hook fails.
    - This hook can be used to set up per-worktree development environments
    - or to register the new working tree with external tools.
    +@@ Documentation/githooks.adoc: post-worktree
    + ~~~~~~~~~~~~~
      
    -+post-worktree-move
    -+~~~~~~~~~~~~~~~~~~
    -+
    -+This hook is invoked by linkgit:git-worktree[1] after `git worktree move`
    -+has moved a working tree and updated its administrative files. It is given
    -+one parameter: the absolute path of the working tree before it was moved.
    -+
    -+The hook's current working directory is the new working tree, so its new
    -+absolute path and identifier can be queried by running `git`.
    -+
    -+This hook cannot affect the outcome of `git worktree move`, other than
    -+that the hook's exit status becomes the exit status of the command. A
    -+failing hook does not undo the move.
    -+
    -+This hook can be used to update per-worktree development environments or
    -+registrations with external tools after their working tree has moved.
    -+
    - post-worktree-remove
    - ~~~~~~~~~~~~~~~~~~~~
    + This hook is invoked by linkgit:git-worktree[1] after a working tree is
    +-added, moved, or removed. It takes four parameters: the event (`add`, `move`,
    ++added, moved, or removed, and once for each entry removed by
    ++`git worktree prune`. It takes four parameters: the event (`add`, `move`,
    + or `remove`), the worktree identifier (the name of its administrative
    + directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the
    + new absolute path.
    +@@ Documentation/githooks.adoc: The parameters for each event are:
    +     post-worktree remove <id> <old-path> ""
      
    + The empty strings are passed as arguments, so all events have exactly
    +-four parameters.
    ++four parameters. For entries pruned by `git worktree prune`, the old path
    ++may also be empty if it cannot be determined from the administrative
    ++files. No hook is run for `git worktree prune --dry-run`.
    + 
    + The hook runs in the repository where the command was invoked, following
    + the working directory and environment rules described above. It does not
     
      ## builtin/worktree.c ##
    -@@ builtin/worktree.c: static int run_post_worktree_remove_hook(const char *path, const char *id)
    - 	return run_hooks_opt(the_repository, "post-worktree-remove", &hook_opt);
    +@@ builtin/worktree.c: static int run_post_worktree_hook(const char *event, const char *id,
    + 	return run_hooks_opt(the_repository, "post-worktree", &hook_opt);
      }
      
    -+static int run_post_worktree_move_hook(const char *old_path,
    -+				       const char *new_path)
    -+{
    -+	struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
    +-static void prune_worktree(const char *id, const char *reason)
    ++static int prune_worktree(const char *id, const char *dotgit,
    ++			  const char *reason)
    + {
    ++	struct strbuf path = STRBUF_INIT;
    ++	int ret;
     +
    -+	strvec_pushl(&hook_opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
    -+	strvec_push(&hook_opt.args, old_path);
    -+	hook_opt.dir = new_path;
    -+	return run_hooks_opt(the_repository, "post-worktree-move", &hook_opt);
    -+}
    + 	if (show_only || verbose)
    + 		fprintf_ln(stderr, _("Removing %s/%s: %s"), "worktrees", id, reason);
    +-	if (!show_only)
    +-		delete_git_dir(id);
    ++	if (show_only)
    ++		return 0;
     +
    - static int prune_worktree(const char *id, const char *dotgit,
    - 			  const char *reason)
    ++	delete_git_dir(id);
    ++
    ++	/* path stays empty when the worktree path cannot be determined */
    ++	if (dotgit) {
    ++		strbuf_addstr(&path, dotgit);
    ++		strbuf_strip_suffix(&path, "/.git");
    ++	}
    ++	ret = run_post_worktree_hook("remove", id, path.buf, "");
    ++	strbuf_release(&path);
    ++	return ret;
    + }
    + 
    + static int prune_cmp(const void *a, const void *b)
    +@@ builtin/worktree.c: static int prune_cmp(const void *a, const void *b)
    + 	return strcmp(x->util, y->util);
    + }
    + 
    +-static void prune_dups(struct string_list *l)
    ++static int prune_dups(struct string_list *l)
      {
    -@@ builtin/worktree.c: static int move_worktree(int ac, const char **av, const char *prefix,
    - 	struct strbuf dst = STRBUF_INIT;
    - 	struct strbuf errmsg = STRBUF_INIT;
    - 	const char *reason = NULL;
    --	char *path;
    -+	char *old_path, *path;
    -+	int ret;
    + 	int i;
    ++	int ret = 0;
      
    - 	ac = parse_options(ac, av, prefix, options, git_worktree_move_usage,
    - 			   0);
    -@@ builtin/worktree.c: static int move_worktree(int ac, const char **av, const char *prefix,
    - 		    errmsg.buf);
    - 	strbuf_release(&errmsg);
    + 	QSORT(l->items, l->nr, prune_cmp);
    + 	for (i = 1; i < l->nr; i++) {
    + 		if (!fspathcmp(l->items[i].string, l->items[i - 1].string))
    +-			prune_worktree(l->items[i].util, "duplicate entry");
    ++			ret |= prune_worktree(l->items[i].util,
    ++					      l->items[i].string,
    ++					      "duplicate entry");
    + 	}
    ++	return ret;
    + }
      
    -+	old_path = xstrdup(wt->path);
    - 	if (rename(wt->path, dst.buf) == -1)
    - 		die_errno(_("failed to move '%s' to '%s'"), wt->path, dst.buf);
    +-static void prune_worktrees(void)
    ++static int prune_worktrees(void)
    + {
    + 	struct strbuf reason = STRBUF_INIT;
    + 	struct strbuf main_path = STRBUF_INIT;
    +@@ builtin/worktree.c: static void prune_worktrees(void)
    + 	char *path;
    + 	DIR *dir;
    + 	struct dirent *d;
    ++	int ret = 0;
      
    - 	update_worktree_location(wt, dst.buf, use_relative_paths);
    -+	ret = run_post_worktree_move_hook(old_path, wt->path);
    + 	path = repo_git_path(the_repository, "worktrees");
    + 	dir = opendir(path);
    + 	free(path);
    + 	if (!dir)
    +-		return;
    ++		return 0;
    + 	while ((d = readdir_skip_dot_and_dotdot(dir)) != NULL) {
    + 		char *path;
    + 		strbuf_reset(&reason);
    +-		if (should_prune_worktree(the_repository, d->d_name, &reason, &path, expire))
    +-			prune_worktree(d->d_name, reason.buf);
    +-		else if (path)
    ++		if (should_prune_worktree(the_repository, d->d_name,
    ++					  &reason, &path, expire)) {
    ++			ret |= prune_worktree(d->d_name, path, reason.buf);
    ++			free(path);
    ++		} else if (path) {
    + 			string_list_append_nodup(&kept, path)->util = xstrdup(d->d_name);
    ++		}
    + 	}
    + 	closedir(dir);
      
    -+	free(old_path);
    - 	strbuf_release(&dst);
    - 	free_worktrees(worktrees);
    --	return 0;
    +@@ builtin/worktree.c: static void prune_worktrees(void)
    + 	/* massage main worktree absolute path to match 'gitdir' content */
    + 	strbuf_strip_suffix(&main_path, "/.");
    + 	string_list_append_nodup(&kept, strbuf_detach(&main_path, NULL));
    +-	prune_dups(&kept);
    ++	ret |= prune_dups(&kept);
    + 	string_list_clear(&kept, 1);
    + 
    + 	if (!show_only)
    + 		delete_worktrees_dir_if_empty();
    + 	strbuf_release(&reason);
     +	return ret;
      }
      
    - /*
    + static int prune(int ac, const char **av, const char *prefix,
    +@@ builtin/worktree.c: static int prune(int ac, const char **av, const char *prefix,
    + 			   0);
    + 	if (ac)
    + 		usage_with_options(git_worktree_prune_usage, options);
    +-	prune_worktrees();
    +-	return 0;
    ++	return prune_worktrees();
    + }
    + 
    + static char *junk_work_tree;
     
    - ## t/t2403-worktree-move.sh ##
    -@@ t/t2403-worktree-move.sh: test_expect_success 'move worktree' '
    - 	test_cmp expected2 actual2
    + ## t/t2401-worktree-prune.sh ##
    +@@ t/t2401-worktree-prune.sh: test_expect_success 'prune duplicate (main/linked)' '
    + 	test_path_is_missing .git/worktrees/wt
      '
      
    -+test_expect_success '"move" invokes post-worktree-move hook' '
    -+	test_hook post-worktree-move <<-\EOF &&
    -+	test "$#" = 1 &&
    -+	{
    -+		echo "$1" &&
    -+		git rev-parse --git-dir --show-toplevel
    -+	} >hook.actual
    ++test_expect_success 'prune invokes post-worktree remove event' '
    ++	test_hook post-worktree <<-\EOF &&
    ++	test "$#" = 4 || exit 1
    ++	test "$1" = remove || exit 0
    ++	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
    ++	EOF
    ++	git worktree add --detach flushed &&
    ++	rm -rf flushed &&
    ++	git worktree prune &&
    ++	printf "[remove][flushed][%s][]\n" "$(pwd)/flushed" >hook.expect &&
    ++	test_cmp hook.expect hook.actual
    ++'
    ++
    ++test_expect_success 'prune invokes post-worktree once per worktree' '
    ++	test_hook post-worktree <<-\EOF &&
    ++	test "$#" = 4 || exit 1
    ++	test "$1" = remove || exit 0
    ++	printf "[%s][%s][%s][%s]\n" "$@" >>hook.actual
     +	EOF
    -+	git worktree add --detach hook-source &&
    -+	git worktree move hook-source hook-destination &&
    ++	git worktree add --detach first &&
    ++	git worktree add --detach second &&
    ++	rm -rf first second hook.actual &&
    ++	git worktree prune &&
     +	{
    -+		echo "$(pwd)/hook-source" &&
    -+		echo "$(pwd)/.git/worktrees/hook-source" &&
    -+		echo "$(pwd)/hook-destination"
    ++		printf "[remove][first][%s][]\n" "$(pwd)/first" &&
    ++		printf "[remove][second][%s][]\n" "$(pwd)/second"
     +	} >hook.expect &&
    -+	test_cmp hook.expect hook-destination/hook.actual
    ++	sort hook.actual >hook.sorted &&
    ++	test_cmp hook.expect hook.sorted
    ++'
    ++
    ++test_expect_success 'prune --dry-run does not invoke post-worktree hook' '
    ++	git worktree add --detach dry &&
    ++	rm -rf dry &&
    ++	test_when_finished "git worktree prune" &&
    ++	test_hook post-worktree <<-\EOF &&
    ++	>hook.ran
    ++	EOF
    ++	git worktree prune --dry-run &&
    ++	test_path_is_missing hook.ran
    ++'
    ++
    ++test_expect_success 'pruned entry with unknown path gives empty hook argument' '
    ++	test_hook post-worktree <<-\EOF &&
    ++	test "$#" = 4 &&
    ++	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
    ++	EOF
    ++	mkdir -p .git/worktrees/broken &&
    ++	: >.git/worktrees/broken/gitdir &&
    ++	git worktree prune &&
    ++	echo "[remove][broken][][]" >hook.expect &&
    ++	test_cmp hook.expect hook.actual
     +'
     +
    -+test_expect_success 'failing post-worktree-move hook leaves worktree moved' '
    -+	test_hook post-worktree-move <<-\EOF &&
    ++test_expect_success 'failing post-worktree hook does not skip other pruned entries' '
    ++	test_hook post-worktree <<-\EOF &&
    ++	test "$1" = remove || exit 0
    ++	echo "$2" >>hook.actual
     +	exit 1
     +	EOF
    -+	git worktree add --detach hook-failing-source &&
    -+	test_must_fail git worktree move hook-failing-source hook-failing-destination &&
    -+	test_path_is_missing hook-failing-source &&
    -+	git -C hook-failing-destination status --porcelain >actual &&
    -+	test_must_be_empty actual
    ++	git worktree add --detach doomed &&
    ++	git worktree add --detach doomed2 &&
    ++	rm -rf doomed doomed2 hook.actual &&
    ++	test_must_fail git worktree prune &&
    ++	test_path_is_missing .git/worktrees/doomed &&
    ++	test_path_is_missing .git/worktrees/doomed2 &&
    ++	test_write_lines doomed doomed2 >hook.expect &&
    ++	sort hook.actual >hook.sorted &&
    ++	test_cmp hook.expect hook.sorted
     +'
     +
    - test_expect_success 'move main worktree' '
    - 	test_must_fail git worktree move . def
    - '
    ++test_expect_success 'prune duplicate invokes post-worktree remove event' '
    ++	test_when_finished rm -fr .git/worktrees w1 w2 &&
    ++	test_hook post-worktree <<-\EOF &&
    ++	test "$1" = remove || exit 0
    ++	printf "[%s][%s][%s][%s]\n" "$@" >>hook.actual
    ++	EOF
    ++	rm -f hook.actual &&
    ++	git worktree add --detach w1 &&
    ++	git worktree add --detach w2 &&
    ++	sed "s/w2/w1/" .git/worktrees/w2/gitdir >.git/worktrees/w2/gitdir.new &&
    ++	mv .git/worktrees/w2/gitdir.new .git/worktrees/w2/gitdir &&
    ++	git worktree prune &&
    ++	printf "[remove][w2][%s][]\n" "$(pwd)/w1" >hook.expect &&
    ++	test_cmp hook.expect hook.actual
    ++'
    ++
    ++test_expect_success 'post-worktree remove gets absolute path with relative worktrees' '
    ++	test_when_finished "rm -rf relhook" &&
    ++	git init relhook &&
    ++	test_commit -C relhook base &&
    ++	test_hook -C relhook post-worktree <<-\EOF &&
    ++	test "$1" = remove || exit 0
    ++	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
    ++	EOF
    ++	git -C relhook worktree add --relative-paths --detach wt &&
    ++	rm -rf relhook/wt &&
    ++	git -C relhook worktree prune &&
    ++	printf "[remove][wt][%s][]\n" "$(pwd)/relhook/wt" >hook.expect &&
    ++	test_cmp hook.expect relhook/hook.actual
    ++'
    ++
    + test_expect_success 'not prune proper worktrees inside linked worktree with relative paths' '
    + 	test_when_finished rm -rf repo wt_ext &&
    + 	git init repo &&
    +
    + ## worktree.c ##
    +@@ worktree.c: int should_prune_worktree(struct repository *repo,
    + 		if (stat(file.buf, &st) || st.st_mtime <= expire) {
    + 			strbuf_addstr(reason, _("gitdir file points to non-existent location"));
    + 			rc = 1;
    +-			goto done;
    + 		}
    + 	}
    + 	*wtpath = strbuf_detach(&dotgit, NULL);
    +
    + ## worktree.h ##
    +@@ worktree.h: const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire);
    + 
    + /*
    +  * Return true if worktree entry should be pruned, along with the reason for
    +- * pruning. Otherwise, return false and the worktree's path in `wtpath`, or
    +- * NULL if it cannot be determined. Caller is responsible for freeing
    +- * returned path.
    ++ * pruning. Otherwise, return false. In both cases the path of the
    ++ * worktree's `.git` file is returned in `wtpath`, or NULL if it cannot
    ++ * be determined. Caller is responsible for freeing returned path.
    +  *
    +  * `expire` defines a grace period to prune the worktree when its path
    +  * does not exist.
-- 
2.54.0

```

## Domen Kožar, 2026-10-04 23:09

Subject: [PATCH v3 2/2] worktree: notify post-worktree hook when pruning
Message-ID: <f7ead9bc-fe6e-49c1-bb7d-6efd14eb6766@mtasv.net>
In-Reply-To: <cover.1791152172.git.domen@cachix.org>

```
A worktree can disappear without git worktree remove, for example when
its directory is deleted manually. Tools maintaining per-worktree state
need to observe its later deregistration by git worktree prune as well.
Git knows which entries it prunes, including duplicates, whereas a
wrapper comparing worktree listings can race concurrent operations and
has limited information about damaged entries.

Emit a post-worktree remove event for each pruned administrative entry,
with its identifier and former absolute path. Return the recorded .git
path from should_prune_worktree() even when it points to a missing
location, so the hook can receive the former worktree path. If the path
cannot be determined, pass an empty string instead.

Do not invoke the hook during a dry run. Reflect hook failures in the
command's exit status while continuing to process the remaining entries.
Document pruning and test missing paths, duplicate entries, relative
paths, dry runs, and failures that must not suppress other notifications.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Domen Kožar <domen@cachix.org>
---
 Documentation/githooks.adoc |   7 ++-
 builtin/worktree.c          |  49 ++++++++++++-----
 t/t2401-worktree-prune.sh   | 102 ++++++++++++++++++++++++++++++++++++
 worktree.c                  |   1 -
 worktree.h                  |   6 +--
 5 files changed, 146 insertions(+), 19 deletions(-)

diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 3e25f769c5..a625e95eff 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -219,7 +219,8 @@ post-worktree
 ~~~~~~~~~~~~~
 
 This hook is invoked by linkgit:git-worktree[1] after a working tree is
-added, moved, or removed. It takes four parameters: the event (`add`, `move`,
+added, moved, or removed, and once for each entry removed by
+`git worktree prune`. It takes four parameters: the event (`add`, `move`,
 or `remove`), the worktree identifier (the name of its administrative
 directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the
 new absolute path.
@@ -231,7 +232,9 @@ The parameters for each event are:
     post-worktree remove <id> <old-path> ""
 
 The empty strings are passed as arguments, so all events have exactly
-four parameters.
+four parameters. For entries pruned by `git worktree prune`, the old path
+may also be empty if it cannot be determined from the administrative
+files. No hook is run for `git worktree prune --dry-run`.
 
 The hook runs in the repository where the command was invoked, following
 the working directory and environment rules described above. It does not
diff --git a/builtin/worktree.c b/builtin/worktree.c
index 0f2748080c..d2319a1991 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -177,12 +177,27 @@ static int run_post_worktree_hook(const char *event, const char *id,
 	return run_hooks_opt(the_repository, "post-worktree", &hook_opt);
 }
 
-static void prune_worktree(const char *id, const char *reason)
+static int prune_worktree(const char *id, const char *dotgit,
+			  const char *reason)
 {
+	struct strbuf path = STRBUF_INIT;
+	int ret;
+
 	if (show_only || verbose)
 		fprintf_ln(stderr, _("Removing %s/%s: %s"), "worktrees", id, reason);
-	if (!show_only)
-		delete_git_dir(id);
+	if (show_only)
+		return 0;
+
+	delete_git_dir(id);
+
+	/* path stays empty when the worktree path cannot be determined */
+	if (dotgit) {
+		strbuf_addstr(&path, dotgit);
+		strbuf_strip_suffix(&path, "/.git");
+	}
+	ret = run_post_worktree_hook("remove", id, path.buf, "");
+	strbuf_release(&path);
+	return ret;
 }
 
 static int prune_cmp(const void *a, const void *b)
@@ -207,18 +222,22 @@ static int prune_cmp(const void *a, const void *b)
 	return strcmp(x->util, y->util);
 }
 
-static void prune_dups(struct string_list *l)
+static int prune_dups(struct string_list *l)
 {
 	int i;
+	int ret = 0;
 
 	QSORT(l->items, l->nr, prune_cmp);
 	for (i = 1; i < l->nr; i++) {
 		if (!fspathcmp(l->items[i].string, l->items[i - 1].string))
-			prune_worktree(l->items[i].util, "duplicate entry");
+			ret |= prune_worktree(l->items[i].util,
+					      l->items[i].string,
+					      "duplicate entry");
 	}
+	return ret;
 }
 
-static void prune_worktrees(void)
+static int prune_worktrees(void)
 {
 	struct strbuf reason = STRBUF_INIT;
 	struct strbuf main_path = STRBUF_INIT;
@@ -226,19 +245,23 @@ static void prune_worktrees(void)
 	char *path;
 	DIR *dir;
 	struct dirent *d;
+	int ret = 0;
 
 	path = repo_git_path(the_repository, "worktrees");
 	dir = opendir(path);
 	free(path);
 	if (!dir)
-		return;
+		return 0;
 	while ((d = readdir_skip_dot_and_dotdot(dir)) != NULL) {
 		char *path;
 		strbuf_reset(&reason);
-		if (should_prune_worktree(the_repository, d->d_name, &reason, &path, expire))
-			prune_worktree(d->d_name, reason.buf);
-		else if (path)
+		if (should_prune_worktree(the_repository, d->d_name,
+					  &reason, &path, expire)) {
+			ret |= prune_worktree(d->d_name, path, reason.buf);
+			free(path);
+		} else if (path) {
 			string_list_append_nodup(&kept, path)->util = xstrdup(d->d_name);
+		}
 	}
 	closedir(dir);
 
@@ -246,12 +269,13 @@ static void prune_worktrees(void)
 	/* massage main worktree absolute path to match 'gitdir' content */
 	strbuf_strip_suffix(&main_path, "/.");
 	string_list_append_nodup(&kept, strbuf_detach(&main_path, NULL));
-	prune_dups(&kept);
+	ret |= prune_dups(&kept);
 	string_list_clear(&kept, 1);
 
 	if (!show_only)
 		delete_worktrees_dir_if_empty();
 	strbuf_release(&reason);
+	return ret;
 }
 
 static int prune(int ac, const char **av, const char *prefix,
@@ -270,8 +294,7 @@ static int prune(int ac, const char **av, const char *prefix,
 			   0);
 	if (ac)
 		usage_with_options(git_worktree_prune_usage, options);
-	prune_worktrees();
-	return 0;
+	return prune_worktrees();
 }
 
 static char *junk_work_tree;
diff --git a/t/t2401-worktree-prune.sh b/t/t2401-worktree-prune.sh
index f8f28c76ee..c863575b2f 100755
--- a/t/t2401-worktree-prune.sh
+++ b/t/t2401-worktree-prune.sh
@@ -119,6 +119,108 @@ test_expect_success 'prune duplicate (main/linked)' '
 	test_path_is_missing .git/worktrees/wt
 '
 
+test_expect_success 'prune invokes post-worktree remove event' '
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 || exit 1
+	test "$1" = remove || exit 0
+	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
+	EOF
+	git worktree add --detach flushed &&
+	rm -rf flushed &&
+	git worktree prune &&
+	printf "[remove][flushed][%s][]\n" "$(pwd)/flushed" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'prune invokes post-worktree once per worktree' '
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 || exit 1
+	test "$1" = remove || exit 0
+	printf "[%s][%s][%s][%s]\n" "$@" >>hook.actual
+	EOF
+	git worktree add --detach first &&
+	git worktree add --detach second &&
+	rm -rf first second hook.actual &&
+	git worktree prune &&
+	{
+		printf "[remove][first][%s][]\n" "$(pwd)/first" &&
+		printf "[remove][second][%s][]\n" "$(pwd)/second"
+	} >hook.expect &&
+	sort hook.actual >hook.sorted &&
+	test_cmp hook.expect hook.sorted
+'
+
+test_expect_success 'prune --dry-run does not invoke post-worktree hook' '
+	git worktree add --detach dry &&
+	rm -rf dry &&
+	test_when_finished "git worktree prune" &&
+	test_hook post-worktree <<-\EOF &&
+	>hook.ran
+	EOF
+	git worktree prune --dry-run &&
+	test_path_is_missing hook.ran
+'
+
+test_expect_success 'pruned entry with unknown path gives empty hook argument' '
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 &&
+	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
+	EOF
+	mkdir -p .git/worktrees/broken &&
+	: >.git/worktrees/broken/gitdir &&
+	git worktree prune &&
+	echo "[remove][broken][][]" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'failing post-worktree hook does not skip other pruned entries' '
+	test_hook post-worktree <<-\EOF &&
+	test "$1" = remove || exit 0
+	echo "$2" >>hook.actual
+	exit 1
+	EOF
+	git worktree add --detach doomed &&
+	git worktree add --detach doomed2 &&
+	rm -rf doomed doomed2 hook.actual &&
+	test_must_fail git worktree prune &&
+	test_path_is_missing .git/worktrees/doomed &&
+	test_path_is_missing .git/worktrees/doomed2 &&
+	test_write_lines doomed doomed2 >hook.expect &&
+	sort hook.actual >hook.sorted &&
+	test_cmp hook.expect hook.sorted
+'
+
+test_expect_success 'prune duplicate invokes post-worktree remove event' '
+	test_when_finished rm -fr .git/worktrees w1 w2 &&
+	test_hook post-worktree <<-\EOF &&
+	test "$1" = remove || exit 0
+	printf "[%s][%s][%s][%s]\n" "$@" >>hook.actual
+	EOF
+	rm -f hook.actual &&
+	git worktree add --detach w1 &&
+	git worktree add --detach w2 &&
+	sed "s/w2/w1/" .git/worktrees/w2/gitdir >.git/worktrees/w2/gitdir.new &&
+	mv .git/worktrees/w2/gitdir.new .git/worktrees/w2/gitdir &&
+	git worktree prune &&
+	printf "[remove][w2][%s][]\n" "$(pwd)/w1" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'post-worktree remove gets absolute path with relative worktrees' '
+	test_when_finished "rm -rf relhook" &&
+	git init relhook &&
+	test_commit -C relhook base &&
+	test_hook -C relhook post-worktree <<-\EOF &&
+	test "$1" = remove || exit 0
+	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
+	EOF
+	git -C relhook worktree add --relative-paths --detach wt &&
+	rm -rf relhook/wt &&
+	git -C relhook worktree prune &&
+	printf "[remove][wt][%s][]\n" "$(pwd)/relhook/wt" >hook.expect &&
+	test_cmp hook.expect relhook/hook.actual
+'
+
 test_expect_success 'not prune proper worktrees inside linked worktree with relative paths' '
 	test_when_finished rm -rf repo wt_ext &&
 	git init repo &&
diff --git a/worktree.c b/worktree.c
index 8cb8637b18..f6abad62a7 100644
--- a/worktree.c
+++ b/worktree.c
@@ -1016,7 +1016,6 @@ int should_prune_worktree(struct repository *repo,
 		if (stat(file.buf, &st) || st.st_mtime <= expire) {
 			strbuf_addstr(reason, _("gitdir file points to non-existent location"));
 			rc = 1;
-			goto done;
 		}
 	}
 	*wtpath = strbuf_detach(&dotgit, NULL);
diff --git a/worktree.h b/worktree.h
index fbb2757f5b..30699b2998 100644
--- a/worktree.h
+++ b/worktree.h
@@ -106,9 +106,9 @@ const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire);
 
 /*
  * Return true if worktree entry should be pruned, along with the reason for
- * pruning. Otherwise, return false and the worktree's path in `wtpath`, or
- * NULL if it cannot be determined. Caller is responsible for freeing
- * returned path.
+ * pruning. Otherwise, return false. In both cases the path of the
+ * worktree's `.git` file is returned in `wtpath`, or NULL if it cannot
+ * be determined. Caller is responsible for freeing returned path.
  *
  * `expire` defines a grace period to prune the worktree when its path
  * does not exist.
-- 
2.54.0

```

## Junio C Hamano, 2026-10-05 16:47

Subject: Re: [PATCH v3 0/2] worktree: add post-worktree lifecycle hook
Message-ID: <xmqqwlrwjdr4.fsf@gitster.g>
In-Reply-To: <d550ede0-6a33-4eea-a6dd-051d110b68e5@mtasv.net>

```
The topic _claims_ to have been sent with

    X-Mailer: git-send-email 2.54.0

but it seems that the thread structure is so screwed up that some
automation machinery I use seems to be having trouble locating its
thread.

Here is what I see:

* Cover letter
  Subject: [PATCH v3 0/2] worktree: add post-worktree lifecycle hook
  Message-Id: <d550ede0-6a33-4eea-a6dd-051d110b68e5@mtasv.net>
  In-Reply-To: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>
  References: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>

* First patch
  Subject: [PATCH v3 1/2] worktree: add post-worktree lifecycle hook
  Message-Id: <2c1c1f06-05e7-4d8c-bd29-c2a9708b443d@mtasv.net>
  In-Reply-To: <cover.1791152172.git.domen@cachix.org>
  References: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>
   <cover.1791152172.git.domen@cachix.org>

* Second patch
  Subject: [PATCH v3 2/2] worktree: notify post-worktree hook when pruning
  Message-Id: <f7ead9bc-fe6e-49c1-bb7d-6efd14eb6766@mtasv.net>
  In-Reply-To: <cover.1791152172.git.domen@cachix.org>
  References: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>
   <cover.1791152172.git.domen@cachix.org>

Notice that the "cover letter" that are named on the In-Reply-To:
header of the two patches do not match the cover letter message at
all?  I actually doubt the <cover.1791152172.git.domen@cachix.org>
message appears anywhere in the list archive.

    ... goes and visits the URL and gets "not found" ...
    https://lore.kernel.org/git/cover.1791152172.git.domen@cachix.org/

Somebody should find out how send-email is misused to produce such a
broken threading, and add some documentation to the send-email
manual describing what _not_ to do, and/or update send-email code to
detect such misconfiguration that caused it.

Thanks.


```

## Phillip Wood, 2026-10-06 09:23

Subject: Re: [PATCH v3 1/2] worktree: add post-worktree lifecycle hook
Message-ID: <71131749-b624-4c81-bae2-c32a7d3f1a15@gmail.com>
In-Reply-To: <2c1c1f06-05e7-4d8c-bd29-c2a9708b443d@mtasv.net>

```
Hi Domen

On 05/10/2026 00:09, Domen Kožar wrote:
> Tools that manage per-worktree development environments need to observe
> worktrees created, moved, or removed by other programs. Wrapping the
> worktree command only helps when every caller uses the wrapper, and
> post-checkout does not run for add --no-checkout or --orphan.

There is quite a lot of implicit context in that sentance - it assumes the reader has read the previous discussions about this hook. It would be clearer if it was explicit that it was talking about (a) using a wrapper script to perform additional operations around "git worktree" and (b) using the post-checkout hook as a proxy for detecting when a new worktree is added.

> There is no notification for moving or removing a worktree.

A wrapper can do that though?

> Add one post-worktree hook for these operations. Pass the event name,
> worktree identifier, old absolute path, and new absolute path as four
> arguments, using an empty string for a path that does not apply.

As I've said before, I don't think the worktree id is very useful because git commands do not take it as an argument. Someone mentioned wanting to use it as a key to look up other information about the worktree, but different repositories can have worktrees with the same id so I think we'd be better passing the worktree's absolute git-dir.

There was also a comment from someone pointing out that notifying callers after the worktree is removed maybe too late for them to clean up the resources associated with that worktree. If we had a "worktree-event" hook we could run it before the worktree is removed, but after a worktree is added or moved.

Having four arguments for add and remove seems to me to be unecessarily complicated - why not just pass the paths that are relavent, rather than padding the arguments with empty strings?

I see the hook is run serially, is that really necessary?

> An
> explicit event name lets one handler manage the whole lifecycle without
> using argument count to distinguish operations, as the earlier series
> with three separate hooks did.
> > Run the hook in the invoking repository with its normal environment,
> rather than changing to the affected worktree. Passing both paths lets
> handlers target the new worktree when needed and keeps the execution
> context consistent when a worktree has been removed.

It also means that GIT_DIR and GIT_WORK_TREE are consistent with the directory that the hook is run in. The downside for adding a worktree is that the hook needs to clear those variables if it is going to run git in the new worktree.

> Run the add event after post-checkout even when that hook fails, because
> the worktree remains present. A failing lifecycle hook affects the
> command's exit status without undoing the completed operation.

Ok, that matches what we do with a failing post-checkout hook
> Preserve post-checkout's failure status if both hooks fail.

Ok

> Document the interface and cover ordinary, bare, and linked callers,
> no-checkout and orphan worktrees, relative paths, paths with spaces,
> configured hooks, and hook failures.

I'm not sure why we need separate tests for paths with spaces or orphan worktrees

I think adding a hook for worktree lifecycle events is a useful addition. Using a wrapper script is a pain because it is easy to forget to use it and it is hard to make it work if you have several different entities that want to be notified about worktrees being added or removed.

Thanks

Phillip

> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
> Signed-off-by: Domen Kožar <domen@cachix.org>
> ---
>   Documentation/config/hook.adoc |   1 +
>   Documentation/githooks.adoc    |  43 +++++++++++
>   builtin/worktree.c             |  56 ++++++++++----
>   t/t2400-worktree-add.sh        | 132 +++++++++++++++++++++++++++++++++
>   t/t2403-worktree-move.sh       | 113 ++++++++++++++++++++++++++++
>   5 files changed, 329 insertions(+), 16 deletions(-)
> > diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
> index 083dc60a13..501bb006f5 100644
> --- a/Documentation/config/hook.adoc
> +++ b/Documentation/config/hook.adoc
> @@ -94,6 +94,7 @@ hook.jobs::
>   	Receive a commit message file and may rewrite it in place.
>   `pre-commit`;;
>   `post-checkout`;;
> +`post-worktree`;;
>   `push-to-checkout`;;
>   `post-commit`;;
>   	Access the working tree, index, or repository state.
> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
> index 145642bf05..3e25f769c5 100644
> --- a/Documentation/githooks.adoc
> +++ b/Documentation/githooks.adoc
> @@ -215,6 +215,49 @@ This hook can be used to perform repository validity checks, auto-display
>   differences from the previous HEAD if different, or set working dir metadata
>   properties.
>   > +post-worktree
> +~~~~~~~~~~~~~
> +
> +This hook is invoked by linkgit:git-worktree[1] after a working tree is
> +added, moved, or removed. It takes four parameters: the event (`add`, `move`,
> +or `remove`), the worktree identifier (the name of its administrative
> +directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the
> +new absolute path.
> +
> +The parameters for each event are:
> +
> +    post-worktree add    <id> ""         <new-path>
> +    post-worktree move   <id> <old-path> <new-path>
> +    post-worktree remove <id> <old-path> ""
> +
> +The empty strings are passed as arguments, so all events have exactly
> +four parameters.
> +
> +The hook runs in the repository where the command was invoked, following
> +the working directory and environment rules described above. It does not
> +change to the added or moved working tree. To run Git commands there,
> +clear the repository environment variables and use the new path, for
> +example:
> +
> +------------
> +(unset $(git rev-parse --local-env-vars); git -C "$4" status)
> +------------
> +
> +The `add` event runs after the new working tree has been set up, including
> +with `--no-checkout` and `--orphan`. It runs after `post-checkout`, even
> +if that hook fails. The `move` event runs after the working tree and its
> +administrative files have been moved. The `remove` event runs after the
> +working tree has been deleted or its administrative entry removed.
> +
> +The hook cannot undo the worktree operation. A non-zero exit status is
> +reflected in the command's exit status, but leaves the completed operation
> +in place. If `post-checkout` fails during `git worktree add`, its exit
> +status takes precedence over that of `post-worktree`.
> +
> +This hook can be used to set up, relocate, or tear down per-worktree
> +development environments, or to maintain registrations with external
> +tools. Hook scripts should ignore events they do not handle.
> +
>   post-merge
>   ~~~~~~~~~~
>   > diff --git a/builtin/worktree.c b/builtin/worktree.c
> index 77ecd0f71f..0f2748080c 100644
> --- a/builtin/worktree.c
> +++ b/builtin/worktree.c
> @@ -168,6 +168,15 @@ static void delete_worktrees_dir_if_empty(void)
>   	free(path);
>   }
>   > +static int run_post_worktree_hook(const char *event, const char *id,
> +				  const char *old_path, const char *new_path)
> +{
> +	struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
> +
> +	strvec_pushl(&hook_opt.args, event, id, old_path, new_path, NULL);
> +	return run_hooks_opt(the_repository, "post-worktree", &hook_opt);
> +}
> +
>   static void prune_worktree(const char *id, const char *reason)
>   {
>   	if (show_only || verbose)
> @@ -604,21 +613,30 @@ static int add_worktree(const char *path, const char *refname,
>   	}
>   >   	/*
> -	 * Hook failure does not warrant worktree deletion, so run hook after
> -	 * is_junk is cleared, but do return appropriate code when hook fails.
> +	 * Hook failures do not warrant worktree deletion, so run hooks after
> +	 * is_junk is cleared, but do return appropriate code when a hook
> +	 * fails.
>   	 */
> -	if (!ret && opts->checkout && !opts->orphan) {
> -		struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
> -
> -		strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
> -		strvec_pushl(&opt.args,
> -			     oid_to_hex(null_oid(the_hash_algo)),
> -			     oid_to_hex(&commit->object.oid),
> -			     "1",
> -			     NULL);
> -		opt.dir = path;
> -
> -		ret = run_hooks_opt(the_repository, "post-checkout", &opt);
> +	if (!ret) {
> +		int hook_ret;
> +
> +		if (opts->checkout && !opts->orphan) {
> +			struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
> +
> +			strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
> +			strvec_pushl(&opt.args,
> +				     oid_to_hex(null_oid(the_hash_algo)),
> +				     oid_to_hex(&commit->object.oid),
> +				     "1",
> +				     NULL);
> +			opt.dir = path;
> +
> +			ret = run_hooks_opt(the_repository, "post-checkout", &opt);
> +		}
> +
> +		hook_ret = run_post_worktree_hook("add", wt->id, "", wt->path);
> +		if (!ret)
> +			ret = hook_ret;
>   	}
>   >   	strvec_clear(&child_env);
> @@ -1305,7 +1323,8 @@ static int move_worktree(int ac, const char **av, const char *prefix,
>   	struct strbuf dst = STRBUF_INIT;
>   	struct strbuf errmsg = STRBUF_INIT;
>   	const char *reason = NULL;
> -	char *path;
> +	char *old_path, *path;
> +	int ret;
>   >   	ac = parse_options(ac, av, prefix, options, git_worktree_move_usage,
>   			   0);
> @@ -1348,14 +1367,17 @@ static int move_worktree(int ac, const char **av, const char *prefix,
>   		    errmsg.buf);
>   	strbuf_release(&errmsg);
>   > +	old_path = xstrdup(wt->path);
>   	if (rename(wt->path, dst.buf) == -1)
>   		die_errno(_("failed to move '%s' to '%s'"), wt->path, dst.buf);
>   >   	update_worktree_location(wt, dst.buf, use_relative_paths);
> +	ret = run_post_worktree_hook("move", wt->id, old_path, wt->path);
>   > +	free(old_path);
>   	strbuf_release(&dst);
>   	free_worktrees(worktrees);
> -	return 0;
> +	return ret;
>   }
>   >   /*
> @@ -1473,6 +1495,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix,
>   	ret |= delete_git_dir(wt->id);
>   	delete_worktrees_dir_if_empty();
>   > +	ret |= run_post_worktree_hook("remove", wt->id, wt->path, "");
> +
>   	free_worktrees(worktrees);
>   	return ret;
>   }
> diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
> index bdcca97633..65fec976b5 100755
> --- a/t/t2400-worktree-add.sh
> +++ b/t/t2400-worktree-add.sh
> @@ -1172,6 +1172,138 @@ test_expect_success '"add" in bare repo invokes post-checkout hook' '
>   	test_cmp hook.expect goozy/hook.actual
>   '
>   > +# Install a post-worktree hook and write the output expected for adding
> +# worktree $1. Repo $2 defaults to "."; the caller worktree is $3.
> +post_worktree_add_hook () {
> +	test_when_finished "rm -rf .git/hooks" &&
> +	mkdir .git/hooks &&
> +	test_hook -C "$2" post-worktree <<-\EOF &&
> +	test "$#" = 4 &&
> +	{
> +		printf "%s\n" "$@" &&
> +		test-tool path-utils real_path . &&
> +		git rev-parse --absolute-git-dir
> +	} >hook.actual
> +	EOF
> +	{
> +		test_write_lines add "$1" "" "$(pwd)/$1" &&
> +		(cd "${3:-${2:-.}}" && test-tool path-utils real_path .) &&
> +		git -C "${3:-${2:-.}}" rev-parse --absolute-git-dir
> +	} >hook.expect
> +}
> +
> +test_expect_success '"add" invokes post-worktree hook' '
> +	post_worktree_add_hook wanda &&
> +	git worktree add wanda &&
> +	test_cmp hook.expect hook.actual
> +'
> +
> +test_expect_success '"add" in other worktree invokes post-worktree hook there' '
> +	post_worktree_add_hook wilbur "" wanda &&
> +	git -C wanda worktree add ../wilbur &&
> +	test_cmp hook.expect wanda/hook.actual
> +'
> +
> +test_expect_success '"add --no-checkout" still invokes post-worktree hook' '
> +	post_worktree_add_hook wendy &&
> +	git worktree add --no-checkout wendy &&
> +	test_cmp hook.expect hook.actual
> +'
> +
> +test_expect_success '"add --orphan" invokes post-worktree hook' '
> +	post_worktree_add_hook winnie &&
> +	git worktree add --orphan winnie &&
> +	test_cmp hook.expect hook.actual
> +'
> +
> +test_expect_success '"add" in bare repo invokes post-worktree hook there' '
> +	rm -rf bare2 &&
> +	git clone --bare . bare2 &&
> +	post_worktree_add_hook willow bare2 &&
> +	git -C bare2 worktree add --detach ../willow &&
> +	test_cmp hook.expect bare2/hook.actual
> +'
> +
> +test_expect_success '"add" runs post-worktree after post-checkout' '
> +	test_when_finished "rm -rf .git/hooks" &&
> +	mkdir .git/hooks &&
> +	test_hook post-checkout <<-\EOF &&
> +	echo post-checkout >>"$(git rev-parse --git-common-dir)/hooks.actual"
> +	EOF
> +	test_hook post-worktree <<-\EOF &&
> +	echo post-worktree >>"$(git rev-parse --git-common-dir)/hooks.actual"
> +	EOF
> +	test_write_lines post-checkout post-worktree >hooks.expect &&
> +	git worktree add wobble &&
> +	test_cmp hooks.expect .git/hooks.actual
> +'
> +
> +test_expect_success 'failing post-checkout hook does not suppress post-worktree hook' '
> +	test_when_finished "rm -rf .git/hooks" &&
> +	mkdir .git/hooks &&
> +	test_hook post-checkout <<-\EOF &&
> +	exit 2
> +	EOF
> +	test_hook post-worktree <<-\EOF &&
> +	>post-worktree.ran &&
> +	exit 3
> +	EOF
> +	test_expect_code 2 git worktree add wozzle &&
> +	test_path_is_file post-worktree.ran
> +'
> +
> +test_expect_success 'failing post-worktree hook leaves worktree in place' '
> +	test_when_finished "rm -rf .git/hooks" &&
> +	mkdir .git/hooks &&
> +	test_hook post-worktree <<-\EOF &&
> +	exit 1
> +	EOF
> +	test_expect_code 1 git worktree add wilma &&
> +	git worktree list --porcelain >out &&
> +	test_grep -F "worktree $(pwd)/wilma" out
> +'
> +
> +test_expect_success 'failed "add" does not invoke post-worktree hook' '
> +	test_when_finished "rm -rf .git/hooks occupied" &&
> +	mkdir .git/hooks &&
> +	test_hook post-worktree <<-\EOF &&
> +	>hook.ran
> +	EOF
> +	mkdir occupied &&
> +	: >occupied/blocker &&
> +	test_must_fail git worktree add occupied &&
> +	test_path_is_missing hook.ran
> +'
> +
> +test_expect_success 'post-worktree add gets absolute path with relative worktrees' '
> +	test_when_finished "rm -rf relhook" &&
> +	git init relhook &&
> +	test_commit -C relhook base &&
> +	test_hook -C relhook post-worktree <<-\EOF &&
> +	test "$#" = 4 &&
> +	printf "%s\n" "$@" >hook.actual
> +	EOF
> +	git -C relhook worktree add --relative-paths --detach wt &&
> +	test_write_lines add wt "" "$(pwd)/relhook/wt" >hook.expect &&
> +	test_cmp hook.expect relhook/hook.actual
> +'
> +
> +test_expect_success 'configured post-worktree hook preserves paths with spaces' '
> +	test_when_finished "rm -rf confighook" &&
> +	git init confighook &&
> +	test_commit -C confighook base &&
> +	write_script confighook/record-hook <<-\EOF &&
> +	test "$#" = 4 &&
> +	printf "%s\n" "$@" >hook.actual
> +	EOF
> +	git -C confighook config hook.lifecycle.command ./record-hook &&
> +	git -C confighook config hook.lifecycle.event post-worktree &&
> +	git -C confighook worktree add --detach "wt with spaces" &&
> +	id=$(basename "$(git -C "confighook/wt with spaces" rev-parse --absolute-git-dir)") &&
> +	test_write_lines add "$id" "" "$(pwd)/confighook/wt with spaces" >hook.expect &&
> +	test_cmp hook.expect confighook/hook.actual
> +'
> +
>   test_expect_success '"add" an existing but missing worktree' '
>   	git worktree add --detach pneu &&
>   	test_must_fail git worktree add --detach pneu &&
> diff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh
> index 69768c1207..11ef81dce8 100755
> --- a/t/t2403-worktree-move.sh
> +++ b/t/t2403-worktree-move.sh
> @@ -82,6 +82,59 @@ test_expect_success 'move worktree' '
>   	test_cmp expected2 actual2
>   '
>   > +test_expect_success '"move" invokes post-worktree hook in the calling repository' '
> +	test_hook post-worktree <<-\EOF &&
> +	test "$#" = 4 || exit 1
> +	test "$1" = move || exit 0
> +	{
> +		printf "%s\n" "$@" &&
> +		test-tool path-utils real_path . &&
> +		git rev-parse --absolute-git-dir
> +	} >hook.actual
> +	EOF
> +	git worktree add --detach hook-source &&
> +	git worktree move hook-source hook-destination &&
> +	{
> +		test_write_lines move hook-source "$(pwd)/hook-source" "$(pwd)/hook-destination" &&
> +		test-tool path-utils real_path . &&
> +		git rev-parse --absolute-git-dir
> +	} >hook.expect &&
> +	test_cmp hook.expect hook.actual
> +'
> +
> +test_expect_success 'failing post-worktree move event leaves worktree moved' '
> +	test_hook post-worktree <<-\EOF &&
> +	test "$1" = move || exit 0
> +	exit 1
> +	EOF
> +	git worktree add --detach hook-failing-source &&
> +	test_must_fail git worktree move hook-failing-source hook-failing-destination &&
> +	test_path_is_missing hook-failing-source &&
> +	git -C hook-failing-destination status --porcelain >actual &&
> +	test_must_be_empty actual
> +'
> +
> +test_expect_success 'post-worktree move keeps the ID and passes absolute paths with spaces' '
> +	test_when_finished "rm -rf movehook" &&
> +	git init movehook &&
> +	test_commit -C movehook base &&
> +	git -C movehook worktree add --relative-paths --detach "source tree" &&
> +	git -C movehook worktree add --detach caller &&
> +	id=$(basename "$(git -C "movehook/source tree" rev-parse --absolute-git-dir)") &&
> +	test_hook -C movehook post-worktree <<-\EOF &&
> +	test "$#" = 4 &&
> +	{
> +		printf "%s\n" "$@" &&
> +		git rev-parse --show-toplevel
> +	} >hook.actual
> +	EOF
> +	git -C movehook/caller worktree move --relative-paths "../source tree" "../destination tree" &&
> +	test_write_lines move "$id" "$(pwd)/movehook/source tree" \
> +		"$(pwd)/movehook/destination tree" "$(pwd)/movehook/caller" >hook.expect &&
> +	test_cmp hook.expect movehook/caller/hook.actual &&
> +	test_path_is_dir "movehook/destination tree"
> +'
> +
>   test_expect_success 'move main worktree' '
>   	test_must_fail git worktree move . def
>   '
> @@ -246,6 +299,66 @@ test_expect_success 'not remove a repo with initialized submodule' '
>   	)
>   '
>   > +test_expect_success '"remove" invokes post-worktree remove event' '
> +	test_hook post-worktree <<-\EOF &&
> +	test "$#" = 4 || exit 1
> +	test "$1" = remove || exit 0
> +	printf "%s\n" "$@" >hook.actual
> +	EOF
> +	git worktree add --detach wt-hooked &&
> +	git worktree remove wt-hooked &&
> +	test_write_lines remove wt-hooked "$(pwd)/wt-hooked" "" >hook.expect &&
> +	test_cmp hook.expect hook.actual
> +'
> +
> +test_expect_success '"remove" of missing worktree invokes post-worktree hook' '
> +	test_when_finished "rm -rf wt-moved-away" &&
> +	test_hook post-worktree <<-\EOF &&
> +	test "$1" = remove || exit 0
> +	printf "%s\n" "$@" >hook.actual
> +	EOF
> +	rm -f hook.actual &&
> +	git worktree add --detach wt-elsewhere &&
> +	mv wt-elsewhere wt-moved-away &&
> +	git worktree remove wt-elsewhere &&
> +	test_write_lines remove wt-elsewhere "$(pwd)/wt-elsewhere" "" >hook.expect &&
> +	test_cmp hook.expect hook.actual
> +'
> +
> +test_expect_success 'refused "remove" does not invoke post-worktree hook' '
> +	git worktree add --detach wt-kept &&
> +	test_when_finished "git worktree remove --force --force wt-kept || :" &&
> +	test_hook post-worktree <<-\EOF &&
> +	>hook.ran
> +	EOF
> +	git worktree lock wt-kept &&
> +	test_must_fail git worktree remove wt-kept &&
> +	test_path_is_missing hook.ran
> +'
> +
> +test_expect_success 'failing post-worktree remove event fails "remove", worktree is gone' '
> +	test_hook post-worktree <<-\EOF &&
> +	test "$1" = remove || exit 0
> +	exit 1
> +	EOF
> +	git worktree add --detach wt-doomed &&
> +	test_must_fail git worktree remove wt-doomed &&
> +	test_path_is_missing wt-doomed &&
> +	test_path_is_missing .git/worktrees/wt-doomed
> +'
> +
> +test_expect_success 'post-worktree remove preserves paths with spaces' '
> +	git worktree add --detach "remove tree" &&
> +	id=$(basename "$(git -C "remove tree" rev-parse --absolute-git-dir)") &&
> +	test_hook post-worktree <<-\EOF &&
> +	test "$#" = 4 &&
> +	printf "%s\n" "$@" >hook.actual
> +	EOF
> +	git worktree remove "remove tree" &&
> +	test_write_lines remove "$id" "$(pwd)/remove tree" "" >hook.expect &&
> +	test_cmp hook.expect hook.actual
> +'
> +
>   test_expect_success 'move worktree with absolute path to relative path' '
>   	test_config worktree.useRelativePaths false &&
>   	git worktree add ./absolute &&



```
