[PATCH v2 0/4] worktree: add lifecycle hooks
Domen Kožar <[email protected]> Tue, 04 Aug 2026 18:14:00 +0000
| Newsgroups | org.kernel.vger.git |
|---|---|
| Message-ID | <[email protected]> |
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=C5=BEar (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 tre=
e
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_TR=
EE
- cleared, mirroring the existing post-checkout invocation, and is g=
iven
- 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 take=
s no
+ arguments. Details such as the absolute path, worktree identifier,=
and
+ checked-out branch can be queried by running git from the hook's w=
orking
+ directory. Taking no arguments also lets a configured command shar=
ed
+ with post-worktree-remove distinguish the events by argument count=
.
=20
Like post-checkout, the hook cannot affect the outcome of the comm=
and:
a failing hook does not delete the already-created working tree, b=
ut
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, beca=
use
+ the worktree has still been populated and remains present.
=20
Documenting the new hook in githooks(5) also registers its name in=
the
generated hook-list.h, so "git hook run" and hook.*.event recogniz=
e it
@@ Commit message
Signed-off-by: Domen Ko=C5=BEar <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
=20
+ ## 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 repos=
itory validity checks, auto-display
differences from the previous HEAD if different, or set working dir m=
etadata
@@ Documentation/githooks.adoc: This hook can be used to perform reposi=
tory validit
+~~~~~~~~~~~~~~~~~
+
+This hook is invoked by linkgit:git-worktree[1] after `git worktree a=
dd`
-+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 identif=
ier
-+(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 a=
s
-+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 wh=
en
-+`--no-checkout` or `--orphan` is used.
++The hook's current working directory is the new working tree, so furt=
her
++details, such as its absolute path, identifier, and checked-out branc=
h,
++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. I=
t
-+runs after the `post-checkout` hook, and is skipped if that hook fail=
s.
++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 reposi=
tory validit
=20
=20
## builtin/worktree.c ##
+@@ builtin/worktree.c: static void delete_worktrees_dir_if_empty(void)
+ free(path);
+ }
+=20
++static int run_post_worktree_add_hook(const char *path)
++{
++ struct run_hooks_opt hook_opt =3D RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
++
++ strvec_pushl(&hook_opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
++ hook_opt.dir =3D 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,
}
=20
@@ 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 =3D RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
-@@ builtin/worktree.c: static int add_worktree(const char *path, const=
char *refname,
- ret =3D run_hooks_opt(the_repository, "post-checkout", &opt);
- }
-=20
+- if (!ret && opts->checkout && !opts->orphan) {
+- struct run_hooks_opt opt =3D 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 =3D path;
+-
+- ret =3D run_hooks_opt(the_repository, "post-checkout", &opt);
+ if (!ret) {
-+ struct run_hooks_opt opt =3D RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
++ int hook_ret;
++
++ if (opts->checkout && !opts->orphan) {
++ struct run_hooks_opt opt =3D 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 =3D 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 =3D path;
+
-+ ret =3D run_hooks_opt(the_repository, "post-worktree-add", &opt);
-+ }
++ ret =3D run_hooks_opt(the_repository, "post-checkout", &opt);
++ }
+
++ hook_ret =3D run_post_worktree_add_hook(wt->path);
++ if (!ret)
++ ret =3D hook_ret;
+ }
+=20
strvec_clear(&child_env);
- strbuf_release(&sb);
- strbuf_release(&symref);
=20
## t/t2400-worktree-add.sh ##
@@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo in=
vokes post-checkout hook' '
@@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo inv=
okes 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 "$#" =3D 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 inv=
okes post-ch
+ test_cmp hooks.expect wobble/hooks.actual
+'
+
-+test_expect_success 'failing post-checkout hook suppresses post-workt=
ree-add hook' '
++test_expect_success 'failing post-checkout hook does not suppress pos=
t-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 inv=
okes 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 i=
n place' '
@@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo inv=
okes post-ch
+ test_path_is_missing hook.ran
+'
+
-+test_expect_success 'post-worktree-add hook gets absolute path with r=
elative worktrees' '
++test_expect_success 'post-worktree-add hook can derive path with rela=
tive 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 "$#" =3D 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=C5=BEar <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
=20
+ ## 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, a=
nd is skipped if that hook fails.
+@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, e=
ven 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.
=20
@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, an=
d is skipped
=20
=20
## 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 cha=
r *path)
+ return run_hooks_opt(the_repository, "post-worktree-add", &hook_opt)=
;
}
=20
+static int run_post_worktree_remove_hook(const char *path, const char=
*id)
3: 143da548e4 =3D 3: 7989a1d6a2 worktree: run post-worktree-remove hook w=
hen pruning
-: ---------- > 4: 95ab61e377 worktree: add post-worktree-move hook
--=20
2.54.0