From: "Domen Kožar" <domen@cachix.org>
To: git@vger.kernel.org
Cc: gitster@pobox.com, cdwhite3@pm.me, phillip.wood123@gmail.com,
sunshine@sunshineco.com, ps@pks.im, avarab@gmail.com,
test35965@gmail.com, kristofferhaugsbakk@fastmail.com,
maciej.ciemborowicz@gmail.com, "Domen Kožar" <domen@cachix.org>,
"Claude Fable 5" <noreply@anthropic.com>
Subject: [PATCH v3 1/2] worktree: add post-worktree lifecycle hook
Date: Sun, 04 Oct 2026 23:09:07 +0000 [thread overview]
Message-ID: <2c1c1f06-05e7-4d8c-bd29-c2a9708b443d@mtasv.net> (raw)
In-Reply-To: <cover.1791152172.git.domen@cachix.org>
Tools that manage per-worktree development environments need to observe
worktrees created, moved, or removed by other programs. Wrapping the
worktree command only helps when every caller uses the wrapper, and
post-checkout does not run for add --no-checkout or --orphan. There is
no notification for moving or removing a worktree.
Add one post-worktree hook for these operations. Pass the event name,
worktree identifier, old absolute path, and new absolute path as four
arguments, using an empty string for a path that does not apply. An
explicit event name lets one handler manage the whole lifecycle without
using argument count to distinguish operations, as the earlier series
with three separate hooks did.
Run the hook in the invoking repository with its normal environment,
rather than changing to the affected worktree. Passing both paths lets
handlers target the new worktree when needed and keeps the execution
context consistent when a worktree has been removed.
Run the add event after post-checkout even when that hook fails, because
the worktree remains present. A failing lifecycle hook affects the
command's exit status without undoing the completed operation. Preserve
post-checkout's failure status if both hooks fail.
Document the interface and cover ordinary, bare, and linked callers,
no-checkout and orphan worktrees, relative paths, paths with spaces,
configured hooks, and hook failures.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Domen Kožar <domen@cachix.org>
---
Documentation/config/hook.adoc | 1 +
Documentation/githooks.adoc | 43 +++++++++++
builtin/worktree.c | 56 ++++++++++----
t/t2400-worktree-add.sh | 132 +++++++++++++++++++++++++++++++++
t/t2403-worktree-move.sh | 113 ++++++++++++++++++++++++++++
5 files changed, 329 insertions(+), 16 deletions(-)
diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index 083dc60a13..501bb006f5 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -94,6 +94,7 @@ hook.jobs::
Receive a commit message file and may rewrite it in place.
`pre-commit`;;
`post-checkout`;;
+`post-worktree`;;
`push-to-checkout`;;
`post-commit`;;
Access the working tree, index, or repository state.
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 145642bf05..3e25f769c5 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -215,6 +215,49 @@ This hook can be used to perform repository validity checks, auto-display
differences from the previous HEAD if different, or set working dir metadata
properties.
+post-worktree
+~~~~~~~~~~~~~
+
+This hook is invoked by linkgit:git-worktree[1] after a working tree is
+added, moved, or removed. It takes four parameters: the event (`add`, `move`,
+or `remove`), the worktree identifier (the name of its administrative
+directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the
+new absolute path.
+
+The parameters for each event are:
+
+ post-worktree add <id> "" <new-path>
+ post-worktree move <id> <old-path> <new-path>
+ post-worktree remove <id> <old-path> ""
+
+The empty strings are passed as arguments, so all events have exactly
+four parameters.
+
+The hook runs in the repository where the command was invoked, following
+the working directory and environment rules described above. It does not
+change to the added or moved working tree. To run Git commands there,
+clear the repository environment variables and use the new path, for
+example:
+
+------------
+(unset $(git rev-parse --local-env-vars); git -C "$4" status)
+------------
+
+The `add` event runs after the new working tree has been set up, including
+with `--no-checkout` and `--orphan`. It runs after `post-checkout`, even
+if that hook fails. The `move` event runs after the working tree and its
+administrative files have been moved. The `remove` event runs after the
+working tree has been deleted or its administrative entry removed.
+
+The hook cannot undo the worktree operation. A non-zero exit status is
+reflected in the command's exit status, but leaves the completed operation
+in place. If `post-checkout` fails during `git worktree add`, its exit
+status takes precedence over that of `post-worktree`.
+
+This hook can be used to set up, relocate, or tear down per-worktree
+development environments, or to maintain registrations with external
+tools. Hook scripts should ignore events they do not handle.
+
post-merge
~~~~~~~~~~
diff --git a/builtin/worktree.c b/builtin/worktree.c
index 77ecd0f71f..0f2748080c 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -168,6 +168,15 @@ static void delete_worktrees_dir_if_empty(void)
free(path);
}
+static int run_post_worktree_hook(const char *event, const char *id,
+ const char *old_path, const char *new_path)
+{
+ struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
+ strvec_pushl(&hook_opt.args, event, id, old_path, new_path, NULL);
+ return run_hooks_opt(the_repository, "post-worktree", &hook_opt);
+}
+
static void prune_worktree(const char *id, const char *reason)
{
if (show_only || verbose)
@@ -604,21 +613,30 @@ static int add_worktree(const char *path, const char *refname,
}
/*
- * Hook failure does not warrant worktree deletion, so run hook after
- * is_junk is cleared, but do return appropriate code when hook fails.
+ * Hook failures do not warrant worktree deletion, so run hooks after
+ * is_junk is cleared, but do return appropriate code when a hook
+ * fails.
*/
- if (!ret && opts->checkout && !opts->orphan) {
- struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
-
- strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
- strvec_pushl(&opt.args,
- oid_to_hex(null_oid(the_hash_algo)),
- oid_to_hex(&commit->object.oid),
- "1",
- NULL);
- opt.dir = path;
-
- ret = run_hooks_opt(the_repository, "post-checkout", &opt);
+ if (!ret) {
+ int hook_ret;
+
+ if (opts->checkout && !opts->orphan) {
+ struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
+ strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
+ strvec_pushl(&opt.args,
+ oid_to_hex(null_oid(the_hash_algo)),
+ oid_to_hex(&commit->object.oid),
+ "1",
+ NULL);
+ opt.dir = path;
+
+ ret = run_hooks_opt(the_repository, "post-checkout", &opt);
+ }
+
+ hook_ret = run_post_worktree_hook("add", wt->id, "", wt->path);
+ if (!ret)
+ ret = hook_ret;
}
strvec_clear(&child_env);
@@ -1305,7 +1323,8 @@ static int move_worktree(int ac, const char **av, const char *prefix,
struct strbuf dst = STRBUF_INIT;
struct strbuf errmsg = STRBUF_INIT;
const char *reason = NULL;
- char *path;
+ char *old_path, *path;
+ int ret;
ac = parse_options(ac, av, prefix, options, git_worktree_move_usage,
0);
@@ -1348,14 +1367,17 @@ static int move_worktree(int ac, const char **av, const char *prefix,
errmsg.buf);
strbuf_release(&errmsg);
+ old_path = xstrdup(wt->path);
if (rename(wt->path, dst.buf) == -1)
die_errno(_("failed to move '%s' to '%s'"), wt->path, dst.buf);
update_worktree_location(wt, dst.buf, use_relative_paths);
+ ret = run_post_worktree_hook("move", wt->id, old_path, wt->path);
+ free(old_path);
strbuf_release(&dst);
free_worktrees(worktrees);
- return 0;
+ return ret;
}
/*
@@ -1473,6 +1495,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix,
ret |= delete_git_dir(wt->id);
delete_worktrees_dir_if_empty();
+ ret |= run_post_worktree_hook("remove", wt->id, wt->path, "");
+
free_worktrees(worktrees);
return ret;
}
diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
index bdcca97633..65fec976b5 100755
--- a/t/t2400-worktree-add.sh
+++ b/t/t2400-worktree-add.sh
@@ -1172,6 +1172,138 @@ test_expect_success '"add" in bare repo invokes post-checkout hook' '
test_cmp hook.expect goozy/hook.actual
'
+# Install a post-worktree hook and write the output expected for adding
+# worktree $1. Repo $2 defaults to "."; the caller worktree is $3.
+post_worktree_add_hook () {
+ test_when_finished "rm -rf .git/hooks" &&
+ mkdir .git/hooks &&
+ test_hook -C "$2" post-worktree <<-\EOF &&
+ test "$#" = 4 &&
+ {
+ printf "%s\n" "$@" &&
+ test-tool path-utils real_path . &&
+ git rev-parse --absolute-git-dir
+ } >hook.actual
+ EOF
+ {
+ test_write_lines add "$1" "" "$(pwd)/$1" &&
+ (cd "${3:-${2:-.}}" && test-tool path-utils real_path .) &&
+ git -C "${3:-${2:-.}}" rev-parse --absolute-git-dir
+ } >hook.expect
+}
+
+test_expect_success '"add" invokes post-worktree hook' '
+ post_worktree_add_hook wanda &&
+ git worktree add wanda &&
+ test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"add" in other worktree invokes post-worktree hook there' '
+ post_worktree_add_hook wilbur "" wanda &&
+ git -C wanda worktree add ../wilbur &&
+ test_cmp hook.expect wanda/hook.actual
+'
+
+test_expect_success '"add --no-checkout" still invokes post-worktree hook' '
+ post_worktree_add_hook wendy &&
+ git worktree add --no-checkout wendy &&
+ test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"add --orphan" invokes post-worktree hook' '
+ post_worktree_add_hook winnie &&
+ git worktree add --orphan winnie &&
+ test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"add" in bare repo invokes post-worktree hook there' '
+ rm -rf bare2 &&
+ git clone --bare . bare2 &&
+ post_worktree_add_hook willow bare2 &&
+ git -C bare2 worktree add --detach ../willow &&
+ test_cmp hook.expect bare2/hook.actual
+'
+
+test_expect_success '"add" runs post-worktree after post-checkout' '
+ test_when_finished "rm -rf .git/hooks" &&
+ mkdir .git/hooks &&
+ test_hook post-checkout <<-\EOF &&
+ echo post-checkout >>"$(git rev-parse --git-common-dir)/hooks.actual"
+ EOF
+ test_hook post-worktree <<-\EOF &&
+ echo post-worktree >>"$(git rev-parse --git-common-dir)/hooks.actual"
+ EOF
+ test_write_lines post-checkout post-worktree >hooks.expect &&
+ git worktree add wobble &&
+ test_cmp hooks.expect .git/hooks.actual
+'
+
+test_expect_success 'failing post-checkout hook does not suppress post-worktree hook' '
+ test_when_finished "rm -rf .git/hooks" &&
+ mkdir .git/hooks &&
+ test_hook post-checkout <<-\EOF &&
+ exit 2
+ EOF
+ test_hook post-worktree <<-\EOF &&
+ >post-worktree.ran &&
+ exit 3
+ EOF
+ test_expect_code 2 git worktree add wozzle &&
+ test_path_is_file post-worktree.ran
+'
+
+test_expect_success 'failing post-worktree hook leaves worktree in place' '
+ test_when_finished "rm -rf .git/hooks" &&
+ mkdir .git/hooks &&
+ test_hook post-worktree <<-\EOF &&
+ exit 1
+ EOF
+ test_expect_code 1 git worktree add wilma &&
+ git worktree list --porcelain >out &&
+ test_grep -F "worktree $(pwd)/wilma" out
+'
+
+test_expect_success 'failed "add" does not invoke post-worktree hook' '
+ test_when_finished "rm -rf .git/hooks occupied" &&
+ mkdir .git/hooks &&
+ test_hook post-worktree <<-\EOF &&
+ >hook.ran
+ EOF
+ mkdir occupied &&
+ : >occupied/blocker &&
+ test_must_fail git worktree add occupied &&
+ test_path_is_missing hook.ran
+'
+
+test_expect_success 'post-worktree add gets absolute path with relative worktrees' '
+ test_when_finished "rm -rf relhook" &&
+ git init relhook &&
+ test_commit -C relhook base &&
+ test_hook -C relhook post-worktree <<-\EOF &&
+ test "$#" = 4 &&
+ printf "%s\n" "$@" >hook.actual
+ EOF
+ git -C relhook worktree add --relative-paths --detach wt &&
+ test_write_lines add wt "" "$(pwd)/relhook/wt" >hook.expect &&
+ test_cmp hook.expect relhook/hook.actual
+'
+
+test_expect_success 'configured post-worktree hook preserves paths with spaces' '
+ test_when_finished "rm -rf confighook" &&
+ git init confighook &&
+ test_commit -C confighook base &&
+ write_script confighook/record-hook <<-\EOF &&
+ test "$#" = 4 &&
+ printf "%s\n" "$@" >hook.actual
+ EOF
+ git -C confighook config hook.lifecycle.command ./record-hook &&
+ git -C confighook config hook.lifecycle.event post-worktree &&
+ git -C confighook worktree add --detach "wt with spaces" &&
+ id=$(basename "$(git -C "confighook/wt with spaces" rev-parse --absolute-git-dir)") &&
+ test_write_lines add "$id" "" "$(pwd)/confighook/wt with spaces" >hook.expect &&
+ test_cmp hook.expect confighook/hook.actual
+'
+
test_expect_success '"add" an existing but missing worktree' '
git worktree add --detach pneu &&
test_must_fail git worktree add --detach pneu &&
diff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh
index 69768c1207..11ef81dce8 100755
--- a/t/t2403-worktree-move.sh
+++ b/t/t2403-worktree-move.sh
@@ -82,6 +82,59 @@ test_expect_success 'move worktree' '
test_cmp expected2 actual2
'
+test_expect_success '"move" invokes post-worktree hook in the calling repository' '
+ test_hook post-worktree <<-\EOF &&
+ test "$#" = 4 || exit 1
+ test "$1" = move || exit 0
+ {
+ printf "%s\n" "$@" &&
+ test-tool path-utils real_path . &&
+ git rev-parse --absolute-git-dir
+ } >hook.actual
+ EOF
+ git worktree add --detach hook-source &&
+ git worktree move hook-source hook-destination &&
+ {
+ test_write_lines move hook-source "$(pwd)/hook-source" "$(pwd)/hook-destination" &&
+ test-tool path-utils real_path . &&
+ git rev-parse --absolute-git-dir
+ } >hook.expect &&
+ test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'failing post-worktree move event leaves worktree moved' '
+ test_hook post-worktree <<-\EOF &&
+ test "$1" = move || exit 0
+ exit 1
+ EOF
+ git worktree add --detach hook-failing-source &&
+ test_must_fail git worktree move hook-failing-source hook-failing-destination &&
+ test_path_is_missing hook-failing-source &&
+ git -C hook-failing-destination status --porcelain >actual &&
+ test_must_be_empty actual
+'
+
+test_expect_success 'post-worktree move keeps the ID and passes absolute paths with spaces' '
+ test_when_finished "rm -rf movehook" &&
+ git init movehook &&
+ test_commit -C movehook base &&
+ git -C movehook worktree add --relative-paths --detach "source tree" &&
+ git -C movehook worktree add --detach caller &&
+ id=$(basename "$(git -C "movehook/source tree" rev-parse --absolute-git-dir)") &&
+ test_hook -C movehook post-worktree <<-\EOF &&
+ test "$#" = 4 &&
+ {
+ printf "%s\n" "$@" &&
+ git rev-parse --show-toplevel
+ } >hook.actual
+ EOF
+ git -C movehook/caller worktree move --relative-paths "../source tree" "../destination tree" &&
+ test_write_lines move "$id" "$(pwd)/movehook/source tree" \
+ "$(pwd)/movehook/destination tree" "$(pwd)/movehook/caller" >hook.expect &&
+ test_cmp hook.expect movehook/caller/hook.actual &&
+ test_path_is_dir "movehook/destination tree"
+'
+
test_expect_success 'move main worktree' '
test_must_fail git worktree move . def
'
@@ -246,6 +299,66 @@ test_expect_success 'not remove a repo with initialized submodule' '
)
'
+test_expect_success '"remove" invokes post-worktree remove event' '
+ test_hook post-worktree <<-\EOF &&
+ test "$#" = 4 || exit 1
+ test "$1" = remove || exit 0
+ printf "%s\n" "$@" >hook.actual
+ EOF
+ git worktree add --detach wt-hooked &&
+ git worktree remove wt-hooked &&
+ test_write_lines remove wt-hooked "$(pwd)/wt-hooked" "" >hook.expect &&
+ test_cmp hook.expect hook.actual
+'
+
+test_expect_success '"remove" of missing worktree invokes post-worktree hook' '
+ test_when_finished "rm -rf wt-moved-away" &&
+ test_hook post-worktree <<-\EOF &&
+ test "$1" = remove || exit 0
+ printf "%s\n" "$@" >hook.actual
+ EOF
+ rm -f hook.actual &&
+ git worktree add --detach wt-elsewhere &&
+ mv wt-elsewhere wt-moved-away &&
+ git worktree remove wt-elsewhere &&
+ test_write_lines remove wt-elsewhere "$(pwd)/wt-elsewhere" "" >hook.expect &&
+ test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'refused "remove" does not invoke post-worktree hook' '
+ git worktree add --detach wt-kept &&
+ test_when_finished "git worktree remove --force --force wt-kept || :" &&
+ test_hook post-worktree <<-\EOF &&
+ >hook.ran
+ EOF
+ git worktree lock wt-kept &&
+ test_must_fail git worktree remove wt-kept &&
+ test_path_is_missing hook.ran
+'
+
+test_expect_success 'failing post-worktree remove event fails "remove", worktree is gone' '
+ test_hook post-worktree <<-\EOF &&
+ test "$1" = remove || exit 0
+ exit 1
+ EOF
+ git worktree add --detach wt-doomed &&
+ test_must_fail git worktree remove wt-doomed &&
+ test_path_is_missing wt-doomed &&
+ test_path_is_missing .git/worktrees/wt-doomed
+'
+
+test_expect_success 'post-worktree remove preserves paths with spaces' '
+ git worktree add --detach "remove tree" &&
+ id=$(basename "$(git -C "remove tree" rev-parse --absolute-git-dir)") &&
+ test_hook post-worktree <<-\EOF &&
+ test "$#" = 4 &&
+ printf "%s\n" "$@" >hook.actual
+ EOF
+ git worktree remove "remove tree" &&
+ test_write_lines remove "$id" "$(pwd)/remove tree" "" >hook.expect &&
+ test_cmp hook.expect hook.actual
+'
+
test_expect_success 'move worktree with absolute path to relative path' '
test_config worktree.useRelativePaths false &&
git worktree add ./absolute &&
--
2.54.0
next prev parent reply other threads:[~2026-10-04 23:12 UTC|newest]
Thread overview: 21+ messages / expand[flat|nested] mbox.gz Atom feed top
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
2026-08-30 17:21 ` Domen Kožar
2026-09-07 14:30 ` Domen Kožar
2026-09-07 18:18 ` Kristoffer Haugsbakk
2026-09-07 19:34 ` Domen Kožar
2026-10-03 18:47 ` Maciej Ciemborowicz
2026-10-04 23:09 ` [PATCH v3 0/2] worktree: add post-worktree lifecycle hook Domen Kožar
2026-10-05 16:47 ` Junio C Hamano
[not found] ` <cover.1791152172.git.domen@cachix.org>
2026-10-04 23:09 ` Domen Kožar [this message]
2026-10-06 9:23 ` [PATCH v3 1/2] " Phillip Wood
2026-10-04 23:09 ` [PATCH v3 2/2] worktree: notify post-worktree hook when pruning Domen Kožar
[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
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=2c1c1f06-05e7-4d8c-bd29-c2a9708b443d@mtasv.net \
--to=domen@cachix.org \
--cc=avarab@gmail.com \
--cc=cdwhite3@pm.me \
--cc=git@vger.kernel.org \
--cc=gitster@pobox.com \
--cc=kristofferhaugsbakk@fastmail.com \
--cc=maciej.ciemborowicz@gmail.com \
--cc=noreply@anthropic.com \
--cc=phillip.wood123@gmail.com \
--cc=ps@pks.im \
--cc=sunshine@sunshineco.com \
--cc=test35965@gmail.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox