From: Domen Kožar Date: Tue, 04 Aug 2026 18:14:00 GMT Subject: [PATCH v2 0/4] worktree: add lifecycle hooks Message-ID: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net> In-Reply-To: <7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net> Hi everyone, First, apologies that my earlier reply reached the list as a separate message rather than as part of this thread. This is my first patch series submitted by email, and I am still getting the threading details right. I have made sure this reroll is plain text and correctly threaded. I maintain devenv, a developer environment manager, and lately the workflow we see most is people letting AI coding agents loose on a repository, one linked worktree per task, created and discarded at a pace no human would type. Each of those worktrees expects a working environment: processes, sockets, and stateful services such as a database seeded from a dump. Today there is no reliable trigger to set that up when a worktree appears: post-checkout does not fire for --no-checkout or --orphan and cannot be told apart from a plain checkout. Nothing fires when a worktree is moved or removed, so external registrations become stale and databases and services can pile up after "git worktree remove" or a manual rm followed by "git worktree prune". Wrapping the worktree commands only helps when every tool, human or agent, uses the wrapper. Patch 1 adds a post-worktree-add hook that fires after the working tree is fully set up. Patch 2 adds post-worktree-remove for "git worktree remove". Patch 3 extends the remove hook to "git worktree prune" so that manually deleted worktrees are also observed. Patch 4 adds post-worktree-move so tools can update their path mapping. Changes since v1: * Run post-worktree-add after post-checkout even if post-checkout fails, because the populated worktree remains present. * Make post-worktree-add take no arguments. Its working directory is the new worktree, so its path and identifier can be queried with git. This also lets a configured command shared across the hooks distinguish add, move, and remove by their argument counts. * Add post-worktree-move. It runs in the new location and receives the old absolute path as its sole argument. * Document the new hooks among those that always run serially. Thanks to Phillip Wood for the review that prompted these changes. Thanks, Domen Domen Kožar (4): worktree: add post-worktree-add hook worktree: add post-worktree-remove hook worktree: run post-worktree-remove hook when pruning worktree: add post-worktree-move hook Documentation/config/hook.adoc | 3 + Documentation/githooks.adoc | 56 +++++++++++++++ builtin/worktree.c | 123 +++++++++++++++++++++++++-------- t/t2400-worktree-add.sh | 111 +++++++++++++++++++++++++++++ t/t2401-worktree-prune.sh | 88 +++++++++++++++++++++++ t/t2403-worktree-move.sh | 73 +++++++++++++++++++ worktree.c | 1 - worktree.h | 6 +- 8 files changed, 428 insertions(+), 33 deletions(-) Range-diff against v1: 1: 98f06e55c8 ! 1: 73e36c179e worktree: add post-worktree-add hook @@ Commit message Introduce a post-worktree-add hook that runs after the working tree has been fully set up, including with --no-checkout and --orphan. The hook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE - cleared, mirroring the existing post-checkout invocation, and is given - the absolute path of the new working tree and its identifier as - arguments. Anything else, such as the checked-out branch, can be - queried by running git from the hook's working directory. + cleared, mirroring the existing post-checkout invocation, and takes no + arguments. Details such as the absolute path, worktree identifier, and + checked-out branch can be queried by running git from the hook's working + directory. Taking no arguments also lets a configured command shared + with post-worktree-remove distinguish the events by argument count. Like post-checkout, the hook cannot affect the outcome of the command: a failing hook does not delete the already-created working tree, but its exit status becomes the exit status of "git worktree add". The - hook runs after post-checkout and is skipped if that hook fails. + hook runs after post-checkout, even when post-checkout fails, because + the worktree has still been populated and remains present. Documenting the new hook in githooks(5) also registers its name in the generated hook-list.h, so "git hook run" and hook.*.event recognize it @@ Commit message Signed-off-by: Domen Kožar Co-Authored-By: Claude Fable 5 + ## 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 Co-Authored-By: Claude Fable 5 + ## 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