* [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* 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
* [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