* [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks
@ 2026-07-09 23:36 Domen Kožar
2026-07-10 9:34 ` Phillip Wood
` (2 more replies)
0 siblings, 3 replies; 11+ messages in thread
From: Domen Kožar @ 2026-07-09 23:36 UTC (permalink / raw)
To: git
Cc: Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano, Domen Kožar
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
^ permalink raw reply [flat|nested] 11+ messages in thread
* Re: [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks
2026-07-09 23:36 [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks Domen Kožar
@ 2026-07-10 9:34 ` Phillip Wood
[not found] ` <CAMvcdZS=ZYbLmjKaGJvjQ_fWYhVbOzwMvYq+MMENWPYi_RiqvQ@mail.gmail.com>
2026-08-04 18:14 ` [PATCH v2 0/4] worktree: add lifecycle hooks Domen Kožar
[not found] ` <20260804181358.532970-1-domen@cachix.org>
2 siblings, 1 reply; 11+ messages in thread
From: Phillip Wood @ 2026-07-10 9:34 UTC (permalink / raw)
To: domen, git
Cc: Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano
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
^ permalink raw reply [flat|nested] 11+ messages in thread
* Re: [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks
[not found] ` <CAMvcdZS=ZYbLmjKaGJvjQ_fWYhVbOzwMvYq+MMENWPYi_RiqvQ@mail.gmail.com>
@ 2026-07-13 13:19 ` Phillip Wood
0 siblings, 0 replies; 11+ messages in thread
From: Phillip Wood @ 2026-07-13 13:19 UTC (permalink / raw)
To: Domen Kožar, phillip.wood
Cc: git, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano
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
>
>
^ permalink raw reply [flat|nested] 11+ messages in thread
* [PATCH v2 0/4] worktree: add lifecycle hooks
2026-07-09 23:36 [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks Domen Kožar
2026-07-10 9:34 ` Phillip Wood
@ 2026-08-04 18:14 ` Domen Kožar
2026-08-04 19:03 ` Caleb White
[not found] ` <20260804181358.532970-1-domen@cachix.org>
2 siblings, 1 reply; 11+ messages in thread
From: Domen Kožar @ 2026-08-04 18:14 UTC (permalink / raw)
To: git
Cc: Phillip Wood, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano, Domen Kožar
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
^ permalink raw reply [flat|nested] 11+ messages in thread
* [PATCH v2 1/4] worktree: add post-worktree-add hook
[not found] ` <20260804181358.532970-1-domen@cachix.org>
@ 2026-08-04 18:14 ` Domen Kožar
2026-08-04 20:03 ` Caleb White
2026-08-04 18:14 ` [PATCH v2 2/4] worktree: add post-worktree-remove hook Domen Kožar
` (2 subsequent siblings)
3 siblings, 1 reply; 11+ messages in thread
From: Domen Kožar @ 2026-08-04 18:14 UTC (permalink / raw)
To: git
Cc: Phillip Wood, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano, Domen Kožar
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
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [PATCH v2 2/4] worktree: add post-worktree-remove hook
[not found] ` <20260804181358.532970-1-domen@cachix.org>
2026-08-04 18:14 ` [PATCH v2 1/4] worktree: add post-worktree-add hook Domen Kožar
@ 2026-08-04 18:14 ` Domen Kožar
2026-08-04 18:14 ` [PATCH v2 3/4] worktree: run post-worktree-remove hook when pruning Domen Kožar
2026-08-04 18:14 ` [PATCH v2 4/4] worktree: add post-worktree-move hook Domen Kožar
3 siblings, 0 replies; 11+ messages in thread
From: Domen Kožar @ 2026-08-04 18:14 UTC (permalink / raw)
To: git
Cc: Phillip Wood, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano, Domen Kožar
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
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [PATCH v2 3/4] worktree: run post-worktree-remove hook when pruning
[not found] ` <20260804181358.532970-1-domen@cachix.org>
2026-08-04 18:14 ` [PATCH v2 1/4] worktree: add post-worktree-add hook Domen Kožar
2026-08-04 18:14 ` [PATCH v2 2/4] worktree: add post-worktree-remove hook Domen Kožar
@ 2026-08-04 18:14 ` Domen Kožar
2026-08-04 18:14 ` [PATCH v2 4/4] worktree: add post-worktree-move hook Domen Kožar
3 siblings, 0 replies; 11+ messages in thread
From: Domen Kožar @ 2026-08-04 18:14 UTC (permalink / raw)
To: git
Cc: Phillip Wood, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano, Domen Kožar
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
^ permalink raw reply related [flat|nested] 11+ messages in thread
* [PATCH v2 4/4] worktree: add post-worktree-move hook
[not found] ` <20260804181358.532970-1-domen@cachix.org>
` (2 preceding siblings ...)
2026-08-04 18:14 ` [PATCH v2 3/4] worktree: run post-worktree-remove hook when pruning Domen Kožar
@ 2026-08-04 18:14 ` Domen Kožar
3 siblings, 0 replies; 11+ messages in thread
From: Domen Kožar @ 2026-08-04 18:14 UTC (permalink / raw)
To: git
Cc: Phillip Wood, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano, Domen Kožar
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
^ permalink raw reply related [flat|nested] 11+ messages in thread
* Re: [PATCH v2 0/4] worktree: add lifecycle hooks
2026-08-04 18:14 ` [PATCH v2 0/4] worktree: add lifecycle hooks Domen Kožar
@ 2026-08-04 19:03 ` Caleb White
2026-08-04 20:28 ` Junio C Hamano
0 siblings, 1 reply; 11+ messages in thread
From: Caleb White @ 2026-08-04 19:03 UTC (permalink / raw)
To: domen, git
Cc: Phillip Wood, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano
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
^ permalink raw reply [flat|nested] 11+ messages in thread
* Re: [PATCH v2 1/4] worktree: add post-worktree-add hook
2026-08-04 18:14 ` [PATCH v2 1/4] worktree: add post-worktree-add hook Domen Kožar
@ 2026-08-04 20:03 ` Caleb White
0 siblings, 0 replies; 11+ messages in thread
From: Caleb White @ 2026-08-04 20:03 UTC (permalink / raw)
To: domen, git
Cc: Phillip Wood, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason, Caleb White,
Junio C Hamano
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
^ permalink raw reply [flat|nested] 11+ messages in thread
* Re: [PATCH v2 0/4] worktree: add lifecycle hooks
2026-08-04 19:03 ` Caleb White
@ 2026-08-04 20:28 ` Junio C Hamano
0 siblings, 0 replies; 11+ messages in thread
From: Junio C Hamano @ 2026-08-04 20:28 UTC (permalink / raw)
To: Caleb White
Cc: domen, git, Phillip Wood, Eric Sunshine, Patrick Steinhardt,
Ævar Arnfjörð Bjarmason
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.
^ permalink raw reply [flat|nested] 11+ messages in thread
end of thread, other threads:[~2026-08-04 20:28 UTC | newest]
Thread overview: 11+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-09 23:36 [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks Domen Kožar
2026-07-10 9:34 ` Phillip Wood
[not found] ` <CAMvcdZS=ZYbLmjKaGJvjQ_fWYhVbOzwMvYq+MMENWPYi_RiqvQ@mail.gmail.com>
2026-07-13 13:19 ` Phillip Wood
2026-08-04 18:14 ` [PATCH v2 0/4] worktree: add lifecycle hooks Domen Kožar
2026-08-04 19:03 ` Caleb White
2026-08-04 20:28 ` Junio C Hamano
[not found] ` <20260804181358.532970-1-domen@cachix.org>
2026-08-04 18:14 ` [PATCH v2 1/4] worktree: add post-worktree-add hook Domen Kožar
2026-08-04 20:03 ` Caleb White
2026-08-04 18:14 ` [PATCH v2 2/4] worktree: add post-worktree-remove hook Domen Kožar
2026-08-04 18:14 ` [PATCH v2 3/4] worktree: run post-worktree-remove hook when pruning Domen Kožar
2026-08-04 18:14 ` [PATCH v2 4/4] worktree: add post-worktree-move hook Domen Kožar
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox