{"thread":{"id":"65964","subject":"[PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks","startedAt":"2026-07-09T23:39:17Z","lastAt":"2026-10-06T09:23:19Z","messageCount":21,"participants":["Domen Kožar","Phillip Wood","Caleb White","Junio C Hamano","Kristoffer Haugsbakk","Maciej Ciemborowicz"],"isPatch":true,"patchVersion":1,"patchTotal":3},"messages":[{"id":"547656","messageId":"7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net","threadId":"65964","inReplyTo":null,"subject":"[PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-07-09T23:36:08Z","receivedAt":"2026-07-09T23:39:17Z","isPatch":true,"body":"Hi everyone,\n\nI maintain devenv, a developer environment manager, and lately the\nworkflow we see most is people letting AI coding agents loose on a\nrepository, one linked worktree per task, created and discarded at a\npace no human would type. Each of those worktrees expects a working\nenvironment: processes, sockets, and stateful services such as a\ndatabase seeded from a dump.\n\nToday there is no reliable trigger to set that up when a worktree\nappears: post-checkout does not fire for --no-checkout or --orphan\nand cannot be told apart from a plain checkout. Nothing at all fires\nwhen a worktree goes away, so stale databases and services pile up\nafter \"git worktree remove\" or a manual rm followed by \"git worktree\nprune\". Wrapping the worktree commands only helps when every tool,\nhuman or agent, goes through the wrapper.\n\nPatch 1 adds a post-worktree-add hook that fires after the working\ntree is fully set up. Patch 2 adds post-worktree-remove for \"git\nworktree remove\". Patch 3 extends it to \"git worktree prune\" so that\nmanually deleted worktrees are also observed.\n\nTwo design points I would especially appreciate feedback on:\n\n * post-worktree-add runs after post-checkout and is skipped when\n   post-checkout fails. An argument could be made that it should run\n   whenever the worktree was created, regardless of the earlier\n   hook's exit status, since tooling registering worktrees would\n   otherwise miss one that does exist.\n\n * for entries pruned because their gitdir file points to a location\n   that no longer exists, the hook receives the recorded path; when\n   the path cannot be determined at all (missing or corrupt gitdir\n   file) it receives an empty string.\n\nThanks,\nDomen\n\nDomen Kožar (3):\n  worktree: add post-worktree-add hook\n  worktree: add post-worktree-remove hook\n  worktree: run post-worktree-remove hook when pruning\n\n Documentation/githooks.adoc |  41 +++++++++++++\n builtin/worktree.c          |  73 ++++++++++++++++++-----\n t/t2400-worktree-add.sh     | 113 ++++++++++++++++++++++++++++++++++++\n t/t2401-worktree-prune.sh   |  88 ++++++++++++++++++++++++++++\n t/t2403-worktree-move.sh    |  44 ++++++++++++++\n worktree.c                  |   1 -\n worktree.h                  |   6 +-\n 7 files changed, 347 insertions(+), 19 deletions(-)\n\n\nbase-commit: f85a7e662054a7b0d9070e432508831afa214b47\n-- \n2.54.0\n"},{"id":"547707","messageId":"0f37a01d-c39e-47b3-b8e9-48cdd42672df@gmail.com","threadId":"65964","inReplyTo":"7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net","subject":"Re: [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-10T09:34:05Z","receivedAt":"2026-07-10T09:34:14Z","isPatch":true,"body":"Hi Domen\n\nOn 10/07/2026 00:36, Domen Kožar wrote:\n> \n> Today there is no reliable trigger to set that up when a worktree\n> appears: post-checkout does not fire for --no-checkout or --orphan\n> and cannot be told apart from a plain checkout. Nothing at all fires\n> when a worktree goes away, so stale databases and services pile up\n> after \"git worktree remove\" or a manual rm followed by \"git worktree\n> prune\". Wrapping the worktree commands only helps when every tool,\n> human or agent, goes through the wrapper.\n\nI agree a hook that's run after the worktree is added is useful (I have \na patch for it that I've never got round to cleaning up and sending so \nthank you for working on this). It is useful for copying across \nuntracked files to the new worktree like \"config.mak\".\n\n> Patch 1 adds a post-worktree-add hook that fires after the working\n> tree is fully set up. Patch 2 adds post-worktree-remove for \"git\n> worktree remove\". Patch 3 extends it to \"git worktree prune\" so that\n> manually deleted worktrees are also observed.\n\nI don't have a strong opinion on a hook running when a worktree is \nremoved - an IDE that cares about that could set up a filesystem watch \non the directory but I guess adding a hook doesn't do any harm.\n> Two design points I would especially appreciate feedback on:\n> \n>   * post-worktree-add runs after post-checkout and is skipped when\n>     post-checkout fails. An argument could be made that it should run\n>     whenever the worktree was created, regardless of the earlier\n>     hook's exit status, since tooling registering worktrees would\n>     otherwise miss one that does exist.\n\nLooking at the existing code, if the checkout fails then we remove the \nworktree because \"is_junk == 1\" when remove_junk() is called via \natexit() so I think it is correct to skip the new hook in that case.\n\nThe new hook is run after the checkout, but before the post-checkout \nhook - we should document their relative order. I see the hook is run in \nthe new worktree and passed the absolute directory and worktree id. I'm \nwondering if either of those is useful if we're running the hook in the \nnew worktree.\n\n>   * for entries pruned because their gitdir file points to a location\n>     that no longer exists, the hook receives the recorded path; when\n>     the path cannot be determined at all (missing or corrupt gitdir\n>     file) it receives an empty string.\n\nSo the hook knows a worktree was removed but not which one?\n\nThanks\n\nPhillip\n\n> Thanks,\n> Domen\n> \n> Domen Kožar (3):\n>    worktree: add post-worktree-add hook\n>    worktree: add post-worktree-remove hook\n>    worktree: run post-worktree-remove hook when pruning\n> \n>   Documentation/githooks.adoc |  41 +++++++++++++\n>   builtin/worktree.c          |  73 ++++++++++++++++++-----\n>   t/t2400-worktree-add.sh     | 113 ++++++++++++++++++++++++++++++++++++\n>   t/t2401-worktree-prune.sh   |  88 ++++++++++++++++++++++++++++\n>   t/t2403-worktree-move.sh    |  44 ++++++++++++++\n>   worktree.c                  |   1 -\n>   worktree.h                  |   6 +-\n>   7 files changed, 347 insertions(+), 19 deletions(-)\n> \n> \n> base-commit: f85a7e662054a7b0d9070e432508831afa214b47\n\n"},{"id":"547990","messageId":"04051fe2-c54c-45e9-8773-92d40e84c765@gmail.com","threadId":"65964","inReplyTo":"CAMvcdZS=ZYbLmjKaGJvjQ_fWYhVbOzwMvYq+MMENWPYi_RiqvQ@mail.gmail.com","subject":"Re: [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-13T13:19:45Z","receivedAt":"2026-07-13T13:19:52Z","isPatch":true,"body":"Hi Domen\n\nUnfortunately it doesn't look like your message appeared on the list, \nsadly I'm not sure it accepts multipart/alternative messages even when \nthey contain a plain-text version of the message.\n\nOn 10/07/2026 18:20, Domen Kožar wrote:\n> Hi Phillip,\n> \n> thanks for the quick and careful read.\n> \n>  > It is useful for copying across untracked files to the new worktree\n>  > like \"config.mak\".\n> \n> That is a nice example, and it needs the hook to also fire for\n> --no-checkout and --orphan, which post-checkout does not cover.\n> \n>  > Looking at the existing code, if the checkout fails then we remove\n>  > the worktree because \"is_junk == 1\" when remove_junk() is called via\n>  > atexit() so I think it is correct to skip the new hook in that case.\n> \n> Right, when the checkout itself fails the worktree is removed as junk\n> and neither hook runs; no disagreement there. The case I was asking\n> about is the post-checkout hook itself failing: that runs after\n> is_junk is cleared, so the worktree survives, but post-worktree-add\n> is currently skipped and tooling that registers worktrees would miss\n> one that exists. I kept the skip because a failing post-checkout\n> already signals a broken setup, but I am happy to run\n> post-worktree-add whenever the worktree was created, regardless of\n> the earlier hook's exit status, if that is preferred.\n\nOh sorry I'd misunderstood the question. I think I'd lean towards \nrunning the hook anyway because we've still populated a new worktree, \neven if the post-checkout hooks fails.\n\n>  > The new hook is run after the checkout, but before the post-checkout\n>  > hook - we should document their relative order.\n> \n> Unless I am misreading my own series, it is the other way around:\n> add_worktree() invokes post-checkout first and post-worktree-add\n> after it, t2400 has a test pinning that order ('\"add\" runs\n> post-worktree-add after post-checkout'), and githooks.adoc says \"It\n> runs after the post-checkout hook, and is skipped if that hook\n> fails.\" If that did not come across I am happy to reword the\n> documentation.\n\nOops, when I wrote that I was looking at the wrong branch - I had my \n\"add-worktree-hook\" checked out and confused it with this patch. It's \ngreat to see that there is a test and documentation for this.\n\n>  > I'm wondering if either of those is useful if we're running the\n>  > hook in the new worktree.\n> \n> Strictly they are derivable from inside, --show-toplevel for the path\n> and the basename of --git-dir for the id.\n\nIsn't the worktree path the current working directory of the hook script?\n\n> I passed them anyway so\n> that one script can serve both hooks: post-worktree-remove has to\n> receive them as arguments because the worktree is gone by the time it\n> runs, and keeping the two signatures identical makes shared hook code\n> simpler. I can drop them from post-worktree-add if the symmetry is\n> not considered worth it.\n\nOh right, as a counter argument I wonder if having a different  argument \ncount for the two hooks makes it easier for a script that's shared \nbetween the two hooks to determine which hook has invoked it. Is the \nworktree id useful for anything apart from accessing on worktree's local \nrefs from another worktree?\n\n>  > So the hook knows a worktree was removed but not which one?\n> \n> It always gets the worktree id as $2; only the path in $1 can be\n> empty, and only for entries whose gitdir file is missing or\n> unreadable, where git itself no longer knows the path either. Tooling\n> that recorded the id at post-worktree-add time can still match the\n> removal.\n\nThat answers why you want the id.\n\nOne thought I had after I wrote my mail was that worktrees can be \nrenamed - do we want a hook for that so that external tools can move any \nservices they've started and update their id -> path mapping.\n\nThanks\n\nPhillip\n\n> Thanks,\n> Domen\n> \n> On Fri, Jul 10, 2026 at 3:34 AM Phillip Wood <phillip.wood123@gmail.com \n> <mailto:phillip.wood123@gmail.com>> wrote:\n> \n>     Hi Domen\n> \n>     On 10/07/2026 00:36, Domen Kožar wrote:\n>      >\n>      > Today there is no reliable trigger to set that up when a worktree\n>      > appears: post-checkout does not fire for --no-checkout or --orphan\n>      > and cannot be told apart from a plain checkout. Nothing at all fires\n>      > when a worktree goes away, so stale databases and services pile up\n>      > after \"git worktree remove\" or a manual rm followed by \"git worktree\n>      > prune\". Wrapping the worktree commands only helps when every tool,\n>      > human or agent, goes through the wrapper.\n> \n>     I agree a hook that's run after the worktree is added is useful (I have\n>     a patch for it that I've never got round to cleaning up and sending so\n>     thank you for working on this). It is useful for copying across\n>     untracked files to the new worktree like \"config.mak\".\n> \n>      > Patch 1 adds a post-worktree-add hook that fires after the working\n>      > tree is fully set up. Patch 2 adds post-worktree-remove for \"git\n>      > worktree remove\". Patch 3 extends it to \"git worktree prune\" so that\n>      > manually deleted worktrees are also observed.\n> \n>     I don't have a strong opinion on a hook running when a worktree is\n>     removed - an IDE that cares about that could set up a filesystem watch\n>     on the directory but I guess adding a hook doesn't do any harm.\n>      > Two design points I would especially appreciate feedback on:\n>      >\n>      >   * post-worktree-add runs after post-checkout and is skipped when\n>      >     post-checkout fails. An argument could be made that it should run\n>      >     whenever the worktree was created, regardless of the earlier\n>      >     hook's exit status, since tooling registering worktrees would\n>      >     otherwise miss one that does exist.\n> \n>     Looking at the existing code, if the checkout fails then we remove the\n>     worktree because \"is_junk == 1\" when remove_junk() is called via\n>     atexit() so I think it is correct to skip the new hook in that case.\n> \n>     The new hook is run after the checkout, but before the post-checkout\n>     hook - we should document their relative order. I see the hook is\n>     run in\n>     the new worktree and passed the absolute directory and worktree id. I'm\n>     wondering if either of those is useful if we're running the hook in the\n>     new worktree.\n> \n>      >   * for entries pruned because their gitdir file points to a location\n>      >     that no longer exists, the hook receives the recorded path; when\n>      >     the path cannot be determined at all (missing or corrupt gitdir\n>      >     file) it receives an empty string.\n> \n>     So the hook knows a worktree was removed but not which one?\n> \n>     Thanks\n> \n>     Phillip\n> \n>      > Thanks,\n>      > Domen\n>      >\n>      > Domen Kožar (3):\n>      >    worktree: add post-worktree-add hook\n>      >    worktree: add post-worktree-remove hook\n>      >    worktree: run post-worktree-remove hook when pruning\n>      >\n>      >   Documentation/githooks.adoc |  41 +++++++++++++\n>      >   builtin/worktree.c          |  73 ++++++++++++++++++-----\n>      >   t/t2400-worktree-add.sh     | 113 +++++++++++++++++++++++++++++\n>     +++++++\n>      >   t/t2401-worktree-prune.sh   |  88 ++++++++++++++++++++++++++++\n>      >   t/t2403-worktree-move.sh    |  44 ++++++++++++++\n>      >   worktree.c                  |   1 -\n>      >   worktree.h                  |   6 +-\n>      >   7 files changed, 347 insertions(+), 19 deletions(-)\n>      >\n>      >\n>      > base-commit: f85a7e662054a7b0d9070e432508831afa214b47\n> \n> \n\n"},{"id":"549583","messageId":"9f6bb2ec-afe3-4a1c-ade2-addf73f8681d@mtasv.net","threadId":"65964","inReplyTo":"20260804181358.532970-1-domen@cachix.org","subject":"[PATCH v2 1/4] worktree: add post-worktree-add hook","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-08-04T18:14:01Z","receivedAt":"2026-08-04T18:17:05Z","isPatch":true,"body":"Tools that manage per-worktree state, such as development environment\nmanagers or IDEs, have no way to react when a new working tree is\ncreated. The only hook that fires during \"git worktree add\" is\npost-checkout, which is skipped when --no-checkout or --orphan is used\nand cannot be distinguished from a plain checkout.\n\nIntroduce a post-worktree-add hook that runs after the working tree\nhas been fully set up, including with --no-checkout and --orphan. The\nhook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE\ncleared, mirroring the existing post-checkout invocation, and takes no\narguments. Details such as the absolute path, worktree identifier, and\nchecked-out branch can be queried by running git from the hook's working\ndirectory. Taking no arguments also lets a configured command shared\nwith post-worktree-remove distinguish the events by argument count.\n\nLike post-checkout, the hook cannot affect the outcome of the command:\na failing hook does not delete the already-created working tree, but\nits exit status becomes the exit status of \"git worktree add\". The\nhook runs after post-checkout, even when post-checkout fails, because\nthe worktree has still been populated and remains present.\n\nDocumenting the new hook in githooks(5) also registers its name in the\ngenerated hook-list.h, so \"git hook run\" and hook.*.event recognize it\nwithout further changes.\n\nSigned-off-by: Domen Kožar <domen@cachix.org>\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\n---\n Documentation/config/hook.adoc |   1 +\n Documentation/githooks.adoc    |  18 ++++++\n builtin/worktree.c             |  46 +++++++++-----\n t/t2400-worktree-add.sh        | 111 +++++++++++++++++++++++++++++++++\n 4 files changed, 162 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 083dc60a13..81afb4a919 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -94,6 +94,7 @@ hook.jobs::\n \tReceive a commit message file and may rewrite it in place.\n `pre-commit`;;\n `post-checkout`;;\n+`post-worktree-add`;;\n `push-to-checkout`;;\n `post-commit`;;\n \tAccess the working tree, index, or repository state.\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..5a2955ee2f 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -215,6 +215,24 @@ This hook can be used to perform repository validity checks, auto-display\n differences from the previous HEAD if different, or set working dir metadata\n properties.\n \n+post-worktree-add\n+~~~~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-worktree[1] after `git worktree add`\n+has created and set up a new working tree. It takes no parameters.\n+\n+The hook's current working directory is the new working tree, so further\n+details, such as its absolute path, identifier, and checked-out branch,\n+can be queried by running `git`. Unlike the `post-checkout` hook, it is\n+also run when `--no-checkout` or `--orphan` is used.\n+\n+This hook cannot affect the outcome of `git worktree add`, other than\n+that the hook's exit status becomes the exit status of the command. It\n+runs after the `post-checkout` hook, even if that hook fails.\n+\n+This hook can be used to set up per-worktree development environments\n+or to register the new working tree with external tools.\n+\n post-merge\n ~~~~~~~~~~\n \ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex d21c43fde3..cc3299bca9 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -168,6 +168,15 @@ static void delete_worktrees_dir_if_empty(void)\n \tfree(path);\n }\n \n+static int run_post_worktree_add_hook(const char *path)\n+{\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n+\tstrvec_pushl(&hook_opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n+\thook_opt.dir = path;\n+\treturn run_hooks_opt(the_repository, \"post-worktree-add\", &hook_opt);\n+}\n+\n static void prune_worktree(const char *id, const char *reason)\n {\n \tif (show_only || verbose)\n@@ -605,21 +614,30 @@ static int add_worktree(const char *path, const char *refname,\n \t}\n \n \t/*\n-\t * Hook failure does not warrant worktree deletion, so run hook after\n-\t * is_junk is cleared, but do return appropriate code when hook fails.\n+\t * Hook failures do not warrant worktree deletion, so run hooks after\n+\t * is_junk is cleared, but do return appropriate code when a hook\n+\t * fails.\n \t */\n-\tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n-\n-\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n-\t\tstrvec_pushl(&opt.args,\n-\t\t\t     oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t     oid_to_hex(&commit->object.oid),\n-\t\t\t     \"1\",\n-\t\t\t     NULL);\n-\t\topt.dir = path;\n-\n-\t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n+\tif (!ret) {\n+\t\tint hook_ret;\n+\n+\t\tif (opts->checkout && !opts->orphan) {\n+\t\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n+\t\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n+\t\t\tstrvec_pushl(&opt.args,\n+\t\t\t\t     oid_to_hex(null_oid(the_hash_algo)),\n+\t\t\t\t     oid_to_hex(&commit->object.oid),\n+\t\t\t\t     \"1\",\n+\t\t\t\t     NULL);\n+\t\t\topt.dir = path;\n+\n+\t\t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n+\t\t}\n+\n+\t\thook_ret = run_post_worktree_add_hook(wt->path);\n+\t\tif (!ret)\n+\t\t\tret = hook_ret;\n \t}\n \n \tstrvec_clear(&child_env);\ndiff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh\nindex 58b4445cc4..bcdd555ce9 100755\n--- a/t/t2400-worktree-add.sh\n+++ b/t/t2400-worktree-add.sh\n@@ -1132,6 +1132,117 @@ test_expect_success '\"add\" in bare repo invokes post-checkout hook' '\n \ttest_cmp hook.expect goozy/hook.actual\n '\n \n+# Install a post-worktree-add hook and write the output expected for\n+# adding worktree $1; the hook is installed in repo $2 (default \".git\").\n+post_worktree_add_hook () {\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook -C \"$2\" post-worktree-add <<-\\EOF &&\n+\ttest \"$#\" = 0 &&\n+\tgit rev-parse --git-dir --show-toplevel >hook.actual\n+\tEOF\n+\t{\n+\t\techo $(pwd)/${2:-.git}/worktrees/$1 &&\n+\t\techo $(pwd)/$1\n+\t} >hook.expect\n+}\n+\n+test_expect_success '\"add\" invokes post-worktree-add hook' '\n+\tpost_worktree_add_hook wanda &&\n+\tgit worktree add wanda &&\n+\ttest_cmp hook.expect wanda/hook.actual\n+'\n+\n+test_expect_success '\"add\" in other worktree invokes post-worktree-add hook' '\n+\tpost_worktree_add_hook wilbur &&\n+\tgit -C wanda worktree add ../wilbur &&\n+\ttest_cmp hook.expect wilbur/hook.actual\n+'\n+\n+test_expect_success '\"add --no-checkout\" still invokes post-worktree-add hook' '\n+\tpost_worktree_add_hook wendy &&\n+\tgit worktree add --no-checkout wendy &&\n+\ttest_cmp hook.expect wendy/hook.actual\n+'\n+\n+test_expect_success '\"add --orphan\" invokes post-worktree-add hook' '\n+\tpost_worktree_add_hook winnie &&\n+\tgit worktree add --orphan winnie &&\n+\ttest_cmp hook.expect winnie/hook.actual\n+'\n+\n+test_expect_success '\"add\" in bare repo invokes post-worktree-add hook' '\n+\trm -rf bare2 &&\n+\tgit clone --bare . bare2 &&\n+\tpost_worktree_add_hook willow bare2 &&\n+\tgit -C bare2 worktree add --detach ../willow &&\n+\ttest_cmp hook.expect willow/hook.actual\n+'\n+\n+test_expect_success '\"add\" runs post-worktree-add after post-checkout' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook post-checkout <<-\\EOF &&\n+\techo post-checkout >>hooks.actual\n+\tEOF\n+\ttest_hook post-worktree-add <<-\\EOF &&\n+\techo post-worktree-add >>hooks.actual\n+\tEOF\n+\ttest_write_lines post-checkout post-worktree-add >hooks.expect &&\n+\tgit worktree add wobble &&\n+\ttest_cmp hooks.expect wobble/hooks.actual\n+'\n+\n+test_expect_success 'failing post-checkout hook does not suppress post-worktree-add hook' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook post-checkout <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\ttest_hook post-worktree-add <<-\\EOF &&\n+\t>post-worktree-add.ran\n+\tEOF\n+\ttest_must_fail git worktree add wozzle &&\n+\ttest_path_is_file wozzle/post-worktree-add.ran\n+'\n+\n+test_expect_success 'failing post-worktree-add hook leaves worktree in place' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook post-worktree-add <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\ttest_must_fail git worktree add wilma &&\n+\tgit worktree list --porcelain >out &&\n+\tgrep -F \"worktree $(pwd)/wilma\" out\n+'\n+\n+test_expect_success 'failed \"add\" does not invoke post-worktree-add hook' '\n+\ttest_when_finished \"rm -rf .git/hooks occupied\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook post-worktree-add <<-\\EOF &&\n+\t>hook.ran\n+\tEOF\n+\tmkdir occupied &&\n+\t: >occupied/blocker &&\n+\ttest_must_fail git worktree add occupied &&\n+\ttest_path_is_missing occupied/hook.ran &&\n+\ttest_path_is_missing hook.ran\n+'\n+\n+test_expect_success 'post-worktree-add hook can derive path with relative worktrees' '\n+\ttest_when_finished \"rm -rf relhook\" &&\n+\tgit init relhook &&\n+\ttest_commit -C relhook base &&\n+\ttest_hook -C relhook post-worktree-add <<-\\EOF &&\n+\ttest \"$#\" = 0 &&\n+\tgit rev-parse --show-toplevel >hook.actual\n+\tEOF\n+\tgit -C relhook worktree add --relative-paths --detach wt &&\n+\techo $(pwd)/relhook/wt >hook.expect &&\n+\ttest_cmp hook.expect relhook/wt/hook.actual\n+'\n+\n test_expect_success '\"add\" an existing but missing worktree' '\n \tgit worktree add --detach pneu &&\n \ttest_must_fail git worktree add --detach pneu &&\n-- \n2.54.0\n"},{"id":"549584","messageId":"625cef65-0197-4da3-81e0-de786f34a1cb@mtasv.net","threadId":"65964","inReplyTo":"20260804181358.532970-1-domen@cachix.org","subject":"[PATCH v2 4/4] worktree: add post-worktree-move hook","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-08-04T18:14:04Z","receivedAt":"2026-08-04T18:17:11Z","isPatch":true,"body":"Tools that record worktree paths can keep their state up to date when a\nworktree is added or removed, but the mapping becomes stale when the\nworktree is moved. Services or other per-worktree state tied to the old\npath may also need to be relocated.\n\nIntroduce a post-worktree-move hook that runs after the working tree and\nits administrative files have been moved. The hook runs inside the new\nworking tree with GIT_DIR and GIT_WORK_TREE cleared and receives the old\nabsolute path as its sole argument. The new path and worktree identifier\ncan be queried by running git from the hook's working directory.\n\nThis signature also lets one configured command handle all three\nworktree lifecycle hooks by argument count: post-worktree-add takes no\narguments, post-worktree-move takes one, and post-worktree-remove takes\ntwo.\n\nA failing hook does not undo the completed move, but its exit status\nbecomes the exit status of \"git worktree move\".\n\nSigned-off-by: Domen Kožar <domen@cachix.org>\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\n---\n Documentation/config/hook.adoc |  1 +\n Documentation/githooks.adoc    | 17 +++++++++++++++++\n builtin/worktree.c             | 19 +++++++++++++++++--\n t/t2403-worktree-move.sh       | 29 +++++++++++++++++++++++++++++\n 4 files changed, 64 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex e013bc1e40..32511b56fd 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -95,6 +95,7 @@ hook.jobs::\n `pre-commit`;;\n `post-checkout`;;\n `post-worktree-add`;;\n+`post-worktree-move`;;\n `post-worktree-remove`;;\n `push-to-checkout`;;\n `post-commit`;;\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex fdf697b12f..0392454756 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -233,6 +233,23 @@ runs after the `post-checkout` hook, even if that hook fails.\n This hook can be used to set up per-worktree development environments\n or to register the new working tree with external tools.\n \n+post-worktree-move\n+~~~~~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-worktree[1] after `git worktree move`\n+has moved a working tree and updated its administrative files. It is given\n+one parameter: the absolute path of the working tree before it was moved.\n+\n+The hook's current working directory is the new working tree, so its new\n+absolute path and identifier can be queried by running `git`.\n+\n+This hook cannot affect the outcome of `git worktree move`, other than\n+that the hook's exit status becomes the exit status of the command. A\n+failing hook does not undo the move.\n+\n+This hook can be used to update per-worktree development environments or\n+registrations with external tools after their working tree has moved.\n+\n post-worktree-remove\n ~~~~~~~~~~~~~~~~~~~~\n \ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex e0c37039ac..55df3c6a8f 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -185,6 +185,17 @@ static int run_post_worktree_remove_hook(const char *path, const char *id)\n \treturn run_hooks_opt(the_repository, \"post-worktree-remove\", &hook_opt);\n }\n \n+static int run_post_worktree_move_hook(const char *old_path,\n+\t\t\t\t       const char *new_path)\n+{\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n+\tstrvec_pushl(&hook_opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n+\tstrvec_push(&hook_opt.args, old_path);\n+\thook_opt.dir = new_path;\n+\treturn run_hooks_opt(the_repository, \"post-worktree-move\", &hook_opt);\n+}\n+\n static int prune_worktree(const char *id, const char *dotgit,\n \t\t\t  const char *reason)\n {\n@@ -1306,7 +1317,8 @@ static int move_worktree(int ac, const char **av, const char *prefix,\n \tstruct strbuf dst = STRBUF_INIT;\n \tstruct strbuf errmsg = STRBUF_INIT;\n \tconst char *reason = NULL;\n-\tchar *path;\n+\tchar *old_path, *path;\n+\tint ret;\n \n \tac = parse_options(ac, av, prefix, options, git_worktree_move_usage,\n \t\t\t   0);\n@@ -1349,14 +1361,17 @@ static int move_worktree(int ac, const char **av, const char *prefix,\n \t\t    errmsg.buf);\n \tstrbuf_release(&errmsg);\n \n+\told_path = xstrdup(wt->path);\n \tif (rename(wt->path, dst.buf) == -1)\n \t\tdie_errno(_(\"failed to move '%s' to '%s'\"), wt->path, dst.buf);\n \n \tupdate_worktree_location(wt, dst.buf, use_relative_paths);\n+\tret = run_post_worktree_move_hook(old_path, wt->path);\n \n+\tfree(old_path);\n \tstrbuf_release(&dst);\n \tfree_worktrees(worktrees);\n-\treturn 0;\n+\treturn ret;\n }\n \n /*\ndiff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh\nindex b94f00e426..0ffcfe88f7 100755\n--- a/t/t2403-worktree-move.sh\n+++ b/t/t2403-worktree-move.sh\n@@ -82,6 +82,35 @@ test_expect_success 'move worktree' '\n \ttest_cmp expected2 actual2\n '\n \n+test_expect_success '\"move\" invokes post-worktree-move hook' '\n+\ttest_hook post-worktree-move <<-\\EOF &&\n+\ttest \"$#\" = 1 &&\n+\t{\n+\t\techo \"$1\" &&\n+\t\tgit rev-parse --git-dir --show-toplevel\n+\t} >hook.actual\n+\tEOF\n+\tgit worktree add --detach hook-source &&\n+\tgit worktree move hook-source hook-destination &&\n+\t{\n+\t\techo \"$(pwd)/hook-source\" &&\n+\t\techo \"$(pwd)/.git/worktrees/hook-source\" &&\n+\t\techo \"$(pwd)/hook-destination\"\n+\t} >hook.expect &&\n+\ttest_cmp hook.expect hook-destination/hook.actual\n+'\n+\n+test_expect_success 'failing post-worktree-move hook leaves worktree moved' '\n+\ttest_hook post-worktree-move <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\tgit worktree add --detach hook-failing-source &&\n+\ttest_must_fail git worktree move hook-failing-source hook-failing-destination &&\n+\ttest_path_is_missing hook-failing-source &&\n+\tgit -C hook-failing-destination status --porcelain >actual &&\n+\ttest_must_be_empty actual\n+'\n+\n test_expect_success 'move main worktree' '\n \ttest_must_fail git worktree move . def\n '\n-- \n2.54.0\n"},{"id":"549585","messageId":"b9a1cf0c-1cf3-4c36-bf98-1923f9559b61@mtasv.net","threadId":"65964","inReplyTo":"20260804181358.532970-1-domen@cachix.org","subject":"[PATCH v2 2/4] worktree: add post-worktree-remove hook","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-08-04T18:14:02Z","receivedAt":"2026-08-04T18:17:11Z","isPatch":true,"body":"External tooling has no way to learn that a working tree is gone:\n\"git worktree remove\" deletes both the working tree and its\nadministrative directory without running any hook.\n\nIntroduce a post-worktree-remove hook that runs after \"git worktree\nremove\" has deleted a working tree. It is given the former absolute\npath of the working tree and its identifier as arguments. The hook\nalso runs when only the administrative entry is deleted because the\nworking tree directory itself had already disappeared, since the\nworktree is deregistered either way.\n\nBecause the working tree no longer exists at that point, no special\nworking directory or environment is set up; the hook runs wherever\nthe command ran, like other post-command hooks.\n\nThe hook runs once deletion is underway even if parts of it fail,\nsince there is no going back at that point, but it does not run when\nthe removal is refused (locked or dirty working tree, failed\nvalidation). It cannot affect the outcome of the command other than\nits exit status being reflected in the exit status of \"git worktree\nremove\".\n\nSigned-off-by: Domen Kožar <domen@cachix.org>\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\n---\n Documentation/config/hook.adoc |  1 +\n Documentation/githooks.adoc    | 18 ++++++++++++++\n builtin/worktree.c             | 10 ++++++++\n t/t2403-worktree-move.sh       | 44 ++++++++++++++++++++++++++++++++++\n 4 files changed, 73 insertions(+)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 81afb4a919..e013bc1e40 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -95,6 +95,7 @@ hook.jobs::\n `pre-commit`;;\n `post-checkout`;;\n `post-worktree-add`;;\n+`post-worktree-remove`;;\n `push-to-checkout`;;\n `post-commit`;;\n \tAccess the working tree, index, or repository state.\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 5a2955ee2f..9573b8c1f5 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -233,6 +233,24 @@ runs after the `post-checkout` hook, even if that hook fails.\n This hook can be used to set up per-worktree development environments\n or to register the new working tree with external tools.\n \n+post-worktree-remove\n+~~~~~~~~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-worktree[1] after a working tree\n+has been deleted by `git worktree remove`. The hook is given two\n+parameters: the absolute path of the removed working tree and its\n+identifier (the name of its former administrative directory in\n+`$GIT_DIR/worktrees/`).\n+\n+The working tree no longer exists when the hook runs.\n+\n+This hook cannot affect the outcome of `git worktree remove`, other\n+than that the hook's exit status becomes the exit status of the\n+command.\n+\n+This hook can be used to tear down per-worktree development\n+environments or to unregister the working tree from external tools.\n+\n post-merge\n ~~~~~~~~~~\n \ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex cc3299bca9..dc456fcac7 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -177,6 +177,14 @@ static int run_post_worktree_add_hook(const char *path)\n \treturn run_hooks_opt(the_repository, \"post-worktree-add\", &hook_opt);\n }\n \n+static int run_post_worktree_remove_hook(const char *path, const char *id)\n+{\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n+\tstrvec_pushl(&hook_opt.args, path, id, NULL);\n+\treturn run_hooks_opt(the_repository, \"post-worktree-remove\", &hook_opt);\n+}\n+\n static void prune_worktree(const char *id, const char *reason)\n {\n \tif (show_only || verbose)\n@@ -1444,6 +1452,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix,\n \tret |= delete_git_dir(wt->id);\n \tdelete_worktrees_dir_if_empty();\n \n+\tret |= run_post_worktree_remove_hook(wt->path, wt->id);\n+\n \tfree_worktrees(worktrees);\n \treturn ret;\n }\ndiff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh\nindex 0bb33e8b1b..b94f00e426 100755\n--- a/t/t2403-worktree-move.sh\n+++ b/t/t2403-worktree-move.sh\n@@ -246,6 +246,50 @@ test_expect_success 'not remove a repo with initialized submodule' '\n \t)\n '\n \n+test_expect_success '\"remove\" invokes post-worktree-remove hook' '\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\techo $* >hook.actual\n+\tEOF\n+\tgit worktree add --detach wt-hooked &&\n+\tgit worktree remove wt-hooked &&\n+\techo $(pwd)/wt-hooked wt-hooked >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success '\"remove\" of missing worktree invokes post-worktree-remove hook' '\n+\ttest_when_finished \"rm -rf wt-moved-away\" &&\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\techo $* >hook.actual\n+\tEOF\n+\trm -f hook.actual &&\n+\tgit worktree add --detach wt-elsewhere &&\n+\tmv wt-elsewhere wt-moved-away &&\n+\tgit worktree remove wt-elsewhere &&\n+\techo $(pwd)/wt-elsewhere wt-elsewhere >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'refused \"remove\" does not invoke post-worktree-remove hook' '\n+\tgit worktree add --detach wt-kept &&\n+\ttest_when_finished \"git worktree remove --force --force wt-kept || :\" &&\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\t>hook.ran\n+\tEOF\n+\tgit worktree lock wt-kept &&\n+\ttest_must_fail git worktree remove wt-kept &&\n+\ttest_path_is_missing hook.ran\n+'\n+\n+test_expect_success 'failing post-worktree-remove hook fails \"remove\", worktree is gone' '\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\tgit worktree add --detach wt-doomed &&\n+\ttest_must_fail git worktree remove wt-doomed &&\n+\ttest_path_is_missing wt-doomed &&\n+\ttest_path_is_missing .git/worktrees/wt-doomed\n+'\n+\n test_expect_success 'move worktree with absolute path to relative path' '\n \ttest_config worktree.useRelativePaths false &&\n \tgit worktree add ./absolute &&\n-- \n2.54.0\n"},{"id":"549586","messageId":"371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net","threadId":"65964","inReplyTo":"7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net","subject":"[PATCH v2 0/4] worktree: add lifecycle hooks","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-08-04T18:14:00Z","receivedAt":"2026-08-04T18:17:11Z","isPatch":true,"body":"Hi everyone,\n\nFirst, apologies that my earlier reply reached the list as a separate\nmessage rather than as part of this thread. This is my first patch series\nsubmitted by email, and I am still getting the threading details right. I\nhave made sure this reroll is plain text and correctly threaded.\n\nI maintain devenv, a developer environment manager, and lately the\nworkflow we see most is people letting AI coding agents loose on a\nrepository, one linked worktree per task, created and discarded at a\npace no human would type. Each of those worktrees expects a working\nenvironment: processes, sockets, and stateful services such as a\ndatabase seeded from a dump.\n\nToday there is no reliable trigger to set that up when a worktree\nappears: post-checkout does not fire for --no-checkout or --orphan\nand cannot be told apart from a plain checkout. Nothing fires when a\nworktree is moved or removed, so external registrations become stale\nand databases and services can pile up after \"git worktree remove\" or\na manual rm followed by \"git worktree prune\". Wrapping the worktree\ncommands only helps when every tool, human or agent, uses the wrapper.\n\nPatch 1 adds a post-worktree-add hook that fires after the working\ntree is fully set up. Patch 2 adds post-worktree-remove for \"git\nworktree remove\". Patch 3 extends the remove hook to \"git worktree\nprune\" so that manually deleted worktrees are also observed. Patch 4\nadds post-worktree-move so tools can update their path mapping.\n\nChanges since v1:\n\n * Run post-worktree-add after post-checkout even if post-checkout\n   fails, because the populated worktree remains present.\n\n * Make post-worktree-add take no arguments. Its working directory is\n   the new worktree, so its path and identifier can be queried with\n   git. This also lets a configured command shared across the hooks\n   distinguish add, move, and remove by their argument counts.\n\n * Add post-worktree-move. It runs in the new location and receives\n   the old absolute path as its sole argument.\n\n * Document the new hooks among those that always run serially.\n\nThanks to Phillip Wood for the review that prompted these changes.\n\nThanks,\nDomen\n\nDomen Kožar (4):\n  worktree: add post-worktree-add hook\n  worktree: add post-worktree-remove hook\n  worktree: run post-worktree-remove hook when pruning\n  worktree: add post-worktree-move hook\n\n Documentation/config/hook.adoc |   3 +\n Documentation/githooks.adoc    |  56 +++++++++++++++\n builtin/worktree.c             | 123 +++++++++++++++++++++++++--------\n t/t2400-worktree-add.sh        | 111 +++++++++++++++++++++++++++++\n t/t2401-worktree-prune.sh      |  88 +++++++++++++++++++++++\n t/t2403-worktree-move.sh       |  73 +++++++++++++++++++\n worktree.c                     |   1 -\n worktree.h                     |   6 +-\n 8 files changed, 428 insertions(+), 33 deletions(-)\n\nRange-diff against v1:\n1:  98f06e55c8 ! 1:  73e36c179e worktree: add post-worktree-add hook\n    @@ Commit message\n         Introduce a post-worktree-add hook that runs after the working tree\n         has been fully set up, including with --no-checkout and --orphan. The\n         hook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE\n    -    cleared, mirroring the existing post-checkout invocation, and is given\n    -    the absolute path of the new working tree and its identifier as\n    -    arguments. Anything else, such as the checked-out branch, can be\n    -    queried by running git from the hook's working directory.\n    +    cleared, mirroring the existing post-checkout invocation, and takes no\n    +    arguments. Details such as the absolute path, worktree identifier, and\n    +    checked-out branch can be queried by running git from the hook's working\n    +    directory. Taking no arguments also lets a configured command shared\n    +    with post-worktree-remove distinguish the events by argument count.\n     \n         Like post-checkout, the hook cannot affect the outcome of the command:\n         a failing hook does not delete the already-created working tree, but\n         its exit status becomes the exit status of \"git worktree add\". The\n    -    hook runs after post-checkout and is skipped if that hook fails.\n    +    hook runs after post-checkout, even when post-checkout fails, because\n    +    the worktree has still been populated and remains present.\n     \n         Documenting the new hook in githooks(5) also registers its name in the\n         generated hook-list.h, so \"git hook run\" and hook.*.event recognize it\n    @@ Commit message\n         Signed-off-by: Domen Kožar <domen@cachix.org>\n         Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>\n     \n    + ## Documentation/config/hook.adoc ##\n    +@@ Documentation/config/hook.adoc: hook.jobs::\n    + \tReceive a commit message file and may rewrite it in place.\n    + `pre-commit`;;\n    + `post-checkout`;;\n    ++`post-worktree-add`;;\n    + `push-to-checkout`;;\n    + `post-commit`;;\n    + \tAccess the working tree, index, or repository state.\n    +\n      ## Documentation/githooks.adoc ##\n     @@ Documentation/githooks.adoc: This hook can be used to perform repository validity checks, auto-display\n      differences from the previous HEAD if different, or set working dir metadata\n    @@ Documentation/githooks.adoc: This hook can be used to perform repository validit\n     +~~~~~~~~~~~~~~~~~\n     +\n     +This hook is invoked by linkgit:git-worktree[1] after `git worktree add`\n    -+has created and set up a new working tree. The hook is given two\n    -+parameters: the absolute path of the new working tree and its identifier\n    -+(the name of its administrative directory in `$GIT_DIR/worktrees/`).\n    ++has created and set up a new working tree. It takes no parameters.\n     +\n    -+The hook runs inside the new working tree, so further details, such as\n    -+the checked-out branch, can be queried by running `git` from the hook's\n    -+current directory. Unlike the `post-checkout` hook, it is also run when\n    -+`--no-checkout` or `--orphan` is used.\n    ++The hook's current working directory is the new working tree, so further\n    ++details, such as its absolute path, identifier, and checked-out branch,\n    ++can be queried by running `git`. Unlike the `post-checkout` hook, it is\n    ++also run when `--no-checkout` or `--orphan` is used.\n     +\n     +This hook cannot affect the outcome of `git worktree add`, other than\n     +that the hook's exit status becomes the exit status of the command. It\n    -+runs after the `post-checkout` hook, and is skipped if that hook fails.\n    ++runs after the `post-checkout` hook, even if that hook fails.\n     +\n     +This hook can be used to set up per-worktree development environments\n     +or to register the new working tree with external tools.\n    @@ Documentation/githooks.adoc: This hook can be used to perform repository validit\n      \n     \n      ## builtin/worktree.c ##\n    +@@ builtin/worktree.c: static void delete_worktrees_dir_if_empty(void)\n    + \tfree(path);\n    + }\n    + \n    ++static int run_post_worktree_add_hook(const char *path)\n    ++{\n    ++\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n    ++\n    ++\tstrvec_pushl(&hook_opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n    ++\thook_opt.dir = path;\n    ++\treturn run_hooks_opt(the_repository, \"post-worktree-add\", &hook_opt);\n    ++}\n    ++\n    + static void prune_worktree(const char *id, const char *reason)\n    + {\n    + \tif (show_only || verbose)\n     @@ builtin/worktree.c: static int add_worktree(const char *path, const char *refname,\n      \t}\n      \n    @@ builtin/worktree.c: static int add_worktree(const char *path, const char *refnam\n     +\t * is_junk is cleared, but do return appropriate code when a hook\n     +\t * fails.\n      \t */\n    - \tif (!ret && opts->checkout && !opts->orphan) {\n    - \t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n    -@@ builtin/worktree.c: static int add_worktree(const char *path, const char *refname,\n    - \t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n    - \t}\n    - \n    +-\tif (!ret && opts->checkout && !opts->orphan) {\n    +-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n    +-\n    +-\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n    +-\t\tstrvec_pushl(&opt.args,\n    +-\t\t\t     oid_to_hex(null_oid(the_hash_algo)),\n    +-\t\t\t     oid_to_hex(&commit->object.oid),\n    +-\t\t\t     \"1\",\n    +-\t\t\t     NULL);\n    +-\t\topt.dir = path;\n    +-\n    +-\t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n     +\tif (!ret) {\n    -+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n    ++\t\tint hook_ret;\n    ++\n    ++\t\tif (opts->checkout && !opts->orphan) {\n    ++\t\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n     +\n    -+\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n    -+\t\tstrvec_pushl(&opt.args, wt->path, wt->id, NULL);\n    -+\t\topt.dir = path;\n    ++\t\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n    ++\t\t\tstrvec_pushl(&opt.args,\n    ++\t\t\t\t     oid_to_hex(null_oid(the_hash_algo)),\n    ++\t\t\t\t     oid_to_hex(&commit->object.oid),\n    ++\t\t\t\t     \"1\",\n    ++\t\t\t\t     NULL);\n    ++\t\t\topt.dir = path;\n     +\n    -+\t\tret = run_hooks_opt(the_repository, \"post-worktree-add\", &opt);\n    -+\t}\n    ++\t\t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n    ++\t\t}\n     +\n    ++\t\thook_ret = run_post_worktree_add_hook(wt->path);\n    ++\t\tif (!ret)\n    ++\t\t\tret = hook_ret;\n    + \t}\n    + \n      \tstrvec_clear(&child_env);\n    - \tstrbuf_release(&sb);\n    - \tstrbuf_release(&symref);\n     \n      ## t/t2400-worktree-add.sh ##\n     @@ t/t2400-worktree-add.sh: test_expect_success '\"add\" in bare repo invokes post-checkout hook' '\n    @@ t/t2400-worktree-add.sh: test_expect_success '\"add\" in bare repo invokes post-ch\n     +\ttest_when_finished \"rm -rf .git/hooks\" &&\n     +\tmkdir .git/hooks &&\n     +\ttest_hook -C \"$2\" post-worktree-add <<-\\EOF &&\n    -+\t{\n    -+\t\techo $*\n    -+\t\tgit rev-parse --git-dir --show-toplevel\n    -+\t} >hook.actual\n    ++\ttest \"$#\" = 0 &&\n    ++\tgit rev-parse --git-dir --show-toplevel >hook.actual\n     +\tEOF\n     +\t{\n    -+\t\techo $(pwd)/$1 $1 &&\n     +\t\techo $(pwd)/${2:-.git}/worktrees/$1 &&\n     +\t\techo $(pwd)/$1\n     +\t} >hook.expect\n    @@ t/t2400-worktree-add.sh: test_expect_success '\"add\" in bare repo invokes post-ch\n     +\ttest_cmp hooks.expect wobble/hooks.actual\n     +'\n     +\n    -+test_expect_success 'failing post-checkout hook suppresses post-worktree-add hook' '\n    ++test_expect_success 'failing post-checkout hook does not suppress post-worktree-add hook' '\n     +\ttest_when_finished \"rm -rf .git/hooks\" &&\n     +\tmkdir .git/hooks &&\n     +\ttest_hook post-checkout <<-\\EOF &&\n    @@ t/t2400-worktree-add.sh: test_expect_success '\"add\" in bare repo invokes post-ch\n     +\t>post-worktree-add.ran\n     +\tEOF\n     +\ttest_must_fail git worktree add wozzle &&\n    -+\ttest_path_is_missing wozzle/post-worktree-add.ran\n    ++\ttest_path_is_file wozzle/post-worktree-add.ran\n     +'\n     +\n     +test_expect_success 'failing post-worktree-add hook leaves worktree in place' '\n    @@ t/t2400-worktree-add.sh: test_expect_success '\"add\" in bare repo invokes post-ch\n     +\ttest_path_is_missing hook.ran\n     +'\n     +\n    -+test_expect_success 'post-worktree-add hook gets absolute path with relative worktrees' '\n    ++test_expect_success 'post-worktree-add hook can derive path with relative worktrees' '\n     +\ttest_when_finished \"rm -rf relhook\" &&\n     +\tgit init relhook &&\n     +\ttest_commit -C relhook base &&\n     +\ttest_hook -C relhook post-worktree-add <<-\\EOF &&\n    -+\techo $* >hook.actual\n    ++\ttest \"$#\" = 0 &&\n    ++\tgit rev-parse --show-toplevel >hook.actual\n     +\tEOF\n     +\tgit -C relhook worktree add --relative-paths --detach wt &&\n    -+\techo $(pwd)/relhook/wt wt >hook.expect &&\n    ++\techo $(pwd)/relhook/wt >hook.expect &&\n     +\ttest_cmp hook.expect relhook/wt/hook.actual\n     +'\n     +\n2:  7e109ece23 ! 2:  3de87064c0 worktree: add post-worktree-remove hook\n    @@ Commit message\n         Signed-off-by: Domen Kožar <domen@cachix.org>\n         Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>\n     \n    + ## Documentation/config/hook.adoc ##\n    +@@ Documentation/config/hook.adoc: hook.jobs::\n    + `pre-commit`;;\n    + `post-checkout`;;\n    + `post-worktree-add`;;\n    ++`post-worktree-remove`;;\n    + `push-to-checkout`;;\n    + `post-commit`;;\n    + \tAccess the working tree, index, or repository state.\n    +\n      ## Documentation/githooks.adoc ##\n    -@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, and is skipped if that hook fails.\n    +@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, even if that hook fails.\n      This hook can be used to set up per-worktree development environments\n      or to register the new working tree with external tools.\n      \n    @@ Documentation/githooks.adoc: runs after the `post-checkout` hook, and is skipped\n      \n     \n      ## builtin/worktree.c ##\n    -@@ builtin/worktree.c: static void delete_worktrees_dir_if_empty(void)\n    - \tfree(path);\n    +@@ builtin/worktree.c: static int run_post_worktree_add_hook(const char *path)\n    + \treturn run_hooks_opt(the_repository, \"post-worktree-add\", &hook_opt);\n      }\n      \n     +static int run_post_worktree_remove_hook(const char *path, const char *id)\n3:  143da548e4 = 3:  7989a1d6a2 worktree: run post-worktree-remove hook when pruning\n-:  ---------- > 4:  95ab61e377 worktree: add post-worktree-move hook\n-- \n2.54.0\n"},{"id":"549587","messageId":"6d4b2080-d7d9-4a87-a295-9c001d8a0290@mtasv.net","threadId":"65964","inReplyTo":"20260804181358.532970-1-domen@cachix.org","subject":"[PATCH v2 3/4] worktree: run post-worktree-remove hook when pruning","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-08-04T18:14:03Z","receivedAt":"2026-08-04T18:17:15Z","isPatch":true,"body":"A working tree can also disappear via \"git worktree prune\", e.g.\nafter the user deleted the working tree directory manually. Tooling\nthat tears down per-worktree state wants to observe those deletions\nthe same way as an explicit \"git worktree remove\".\n\nRun the post-worktree-remove hook once for each administrative entry\nthat \"git worktree prune\" removes, including duplicate entries pruned\nduring deduplication. The hook is not run with --dry-run, and a\nfailing hook is reflected in the exit status of the command.\n\nshould_prune_worktree() so far returned the path of the worktree's\n.git file only for entries that are kept. Also return it when pruning\nan entry whose gitdir file points to a location that no longer\nexists, which is the common case of a manually deleted working tree,\nso that the hook can be given the path. For entries whose path cannot\nbe determined at all (missing or corrupt gitdir file), the hook\nreceives an empty string instead. The one other caller of\nshould_prune_worktree() already frees the path unconditionally.\n\nSigned-off-by: Domen Kožar <domen@cachix.org>\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\n---\n Documentation/githooks.adoc | 23 +++++-----\n builtin/worktree.c          | 48 ++++++++++++++------\n t/t2401-worktree-prune.sh   | 88 +++++++++++++++++++++++++++++++++++++\n worktree.c                  |  1 -\n worktree.h                  |  6 +--\n 5 files changed, 139 insertions(+), 27 deletions(-)\n\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 9573b8c1f5..fdf697b12f 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -237,16 +237,19 @@ post-worktree-remove\n ~~~~~~~~~~~~~~~~~~~~\n \n This hook is invoked by linkgit:git-worktree[1] after a working tree\n-has been deleted by `git worktree remove`. The hook is given two\n-parameters: the absolute path of the removed working tree and its\n-identifier (the name of its former administrative directory in\n-`$GIT_DIR/worktrees/`).\n-\n-The working tree no longer exists when the hook runs.\n-\n-This hook cannot affect the outcome of `git worktree remove`, other\n-than that the hook's exit status becomes the exit status of the\n-command.\n+has been deleted by `git worktree remove`, and once for each working\n+tree pruned by `git worktree prune`. The hook is given two parameters:\n+the absolute path of the removed working tree and its identifier (the\n+name of its former administrative directory in `$GIT_DIR/worktrees/`).\n+\n+The working tree no longer exists when the hook runs. For working\n+trees pruned by `git worktree prune`, the first parameter may be the\n+empty string if the path could not be determined from the leftover\n+administrative files.\n+\n+This hook cannot affect the outcome of `git worktree remove` or\n+`git worktree prune`, other than that the hook's exit status becomes\n+the exit status of the command.\n \n This hook can be used to tear down per-worktree development\n environments or to unregister the working tree from external tools.\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex dc456fcac7..e0c37039ac 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -185,12 +185,27 @@ static int run_post_worktree_remove_hook(const char *path, const char *id)\n \treturn run_hooks_opt(the_repository, \"post-worktree-remove\", &hook_opt);\n }\n \n-static void prune_worktree(const char *id, const char *reason)\n+static int prune_worktree(const char *id, const char *dotgit,\n+\t\t\t  const char *reason)\n {\n+\tstruct strbuf path = STRBUF_INIT;\n+\tint ret;\n+\n \tif (show_only || verbose)\n \t\tfprintf_ln(stderr, _(\"Removing %s/%s: %s\"), \"worktrees\", id, reason);\n-\tif (!show_only)\n-\t\tdelete_git_dir(id);\n+\tif (show_only)\n+\t\treturn 0;\n+\n+\tdelete_git_dir(id);\n+\n+\t/* path stays empty when the worktree path cannot be determined */\n+\tif (dotgit) {\n+\t\tstrbuf_addstr(&path, dotgit);\n+\t\tstrbuf_strip_suffix(&path, \"/.git\");\n+\t}\n+\tret = run_post_worktree_remove_hook(path.buf, id);\n+\tstrbuf_release(&path);\n+\treturn ret;\n }\n \n static int prune_cmp(const void *a, const void *b)\n@@ -215,18 +230,22 @@ static int prune_cmp(const void *a, const void *b)\n \treturn strcmp(x->util, y->util);\n }\n \n-static void prune_dups(struct string_list *l)\n+static int prune_dups(struct string_list *l)\n {\n \tint i;\n+\tint ret = 0;\n \n \tQSORT(l->items, l->nr, prune_cmp);\n \tfor (i = 1; i < l->nr; i++) {\n \t\tif (!fspathcmp(l->items[i].string, l->items[i - 1].string))\n-\t\t\tprune_worktree(l->items[i].util, \"duplicate entry\");\n+\t\t\tret |= prune_worktree(l->items[i].util,\n+\t\t\t\t\t      l->items[i].string,\n+\t\t\t\t\t      \"duplicate entry\");\n \t}\n+\treturn ret;\n }\n \n-static void prune_worktrees(void)\n+static int prune_worktrees(void)\n {\n \tstruct strbuf reason = STRBUF_INIT;\n \tstruct strbuf main_path = STRBUF_INIT;\n@@ -234,19 +253,22 @@ static void prune_worktrees(void)\n \tchar *path;\n \tDIR *dir;\n \tstruct dirent *d;\n+\tint ret = 0;\n \n \tpath = repo_git_path(the_repository, \"worktrees\");\n \tdir = opendir(path);\n \tfree(path);\n \tif (!dir)\n-\t\treturn;\n+\t\treturn 0;\n \twhile ((d = readdir_skip_dot_and_dotdot(dir)) != NULL) {\n \t\tchar *path;\n \t\tstrbuf_reset(&reason);\n-\t\tif (should_prune_worktree(d->d_name, &reason, &path, expire))\n-\t\t\tprune_worktree(d->d_name, reason.buf);\n-\t\telse if (path)\n+\t\tif (should_prune_worktree(d->d_name, &reason, &path, expire)) {\n+\t\t\tret |= prune_worktree(d->d_name, path, reason.buf);\n+\t\t\tfree(path);\n+\t\t} else if (path) {\n \t\t\tstring_list_append_nodup(&kept, path)->util = xstrdup(d->d_name);\n+\t\t}\n \t}\n \tclosedir(dir);\n \n@@ -254,12 +276,13 @@ static void prune_worktrees(void)\n \t/* massage main worktree absolute path to match 'gitdir' content */\n \tstrbuf_strip_suffix(&main_path, \"/.\");\n \tstring_list_append_nodup(&kept, strbuf_detach(&main_path, NULL));\n-\tprune_dups(&kept);\n+\tret |= prune_dups(&kept);\n \tstring_list_clear(&kept, 1);\n \n \tif (!show_only)\n \t\tdelete_worktrees_dir_if_empty();\n \tstrbuf_release(&reason);\n+\treturn ret;\n }\n \n static int prune(int ac, const char **av, const char *prefix,\n@@ -278,8 +301,7 @@ static int prune(int ac, const char **av, const char *prefix,\n \t\t\t   0);\n \tif (ac)\n \t\tusage_with_options(git_worktree_prune_usage, options);\n-\tprune_worktrees();\n-\treturn 0;\n+\treturn prune_worktrees();\n }\n \n static char *junk_work_tree;\ndiff --git a/t/t2401-worktree-prune.sh b/t/t2401-worktree-prune.sh\nindex f8f28c76ee..74a80c1a8d 100755\n--- a/t/t2401-worktree-prune.sh\n+++ b/t/t2401-worktree-prune.sh\n@@ -119,6 +119,94 @@ test_expect_success 'prune duplicate (main/linked)' '\n \ttest_path_is_missing .git/worktrees/wt\n '\n \n+test_expect_success 'prune invokes post-worktree-remove hook' '\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\techo $* >hook.actual\n+\tEOF\n+\tgit worktree add --detach flushed &&\n+\trm -rf flushed &&\n+\tgit worktree prune &&\n+\techo $(pwd)/flushed flushed >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'prune invokes post-worktree-remove hook once per worktree' '\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\techo $* >>hook.actual\n+\tEOF\n+\tgit worktree add --detach first &&\n+\tgit worktree add --detach second &&\n+\trm -rf first second hook.actual &&\n+\tgit worktree prune &&\n+\t{\n+\t\techo $(pwd)/first first &&\n+\t\techo $(pwd)/second second\n+\t} >hook.expect &&\n+\tsort hook.actual >hook.sorted &&\n+\ttest_cmp hook.expect hook.sorted\n+'\n+\n+test_expect_success 'prune --dry-run does not invoke post-worktree-remove hook' '\n+\tgit worktree add --detach dry &&\n+\trm -rf dry &&\n+\ttest_when_finished \"git worktree prune\" &&\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\t>hook.ran\n+\tEOF\n+\tgit worktree prune --dry-run &&\n+\ttest_path_is_missing hook.ran\n+'\n+\n+test_expect_success 'pruned entry with unknown path gives empty hook argument' '\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\techo \"[$1][$2]\" >hook.actual\n+\tEOF\n+\tmkdir -p .git/worktrees/broken &&\n+\t: >.git/worktrees/broken/gitdir &&\n+\tgit worktree prune &&\n+\techo \"[][broken]\" >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'failing post-worktree-remove hook fails prune' '\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\tgit worktree add --detach doomed &&\n+\trm -rf doomed &&\n+\ttest_must_fail git worktree prune &&\n+\ttest_path_is_missing .git/worktrees/doomed\n+'\n+\n+test_expect_success 'prune duplicate invokes post-worktree-remove hook' '\n+\ttest_when_finished rm -fr .git/worktrees w1 w2 &&\n+\ttest_hook post-worktree-remove <<-\\EOF &&\n+\techo $* >>hook.actual\n+\tEOF\n+\trm -f hook.actual &&\n+\tgit worktree add --detach w1 &&\n+\tgit worktree add --detach w2 &&\n+\tsed \"s/w2/w1/\" .git/worktrees/w2/gitdir >.git/worktrees/w2/gitdir.new &&\n+\tmv .git/worktrees/w2/gitdir.new .git/worktrees/w2/gitdir &&\n+\tgit worktree prune &&\n+\techo $(pwd)/w1 w2 >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'post-worktree-remove hook gets absolute path with relative worktrees' '\n+\ttest_when_finished \"rm -rf relhook\" &&\n+\tgit init relhook &&\n+\ttest_commit -C relhook base &&\n+\ttest_hook -C relhook post-worktree-remove <<-\\EOF &&\n+\techo $* >hook.actual\n+\tEOF\n+\tgit -C relhook worktree add --relative-paths --detach wt &&\n+\trm -rf relhook/wt &&\n+\tgit -C relhook worktree prune &&\n+\techo $(pwd)/relhook/wt wt >hook.expect &&\n+\ttest_cmp hook.expect relhook/hook.actual\n+'\n+\n test_expect_success 'not prune proper worktrees inside linked worktree with relative paths' '\n \ttest_when_finished rm -rf repo wt_ext &&\n \tgit init repo &&\ndiff --git a/worktree.c b/worktree.c\nindex 30125827fd..6a9d943874 100644\n--- a/worktree.c\n+++ b/worktree.c\n@@ -1004,7 +1004,6 @@ int should_prune_worktree(const char *id, struct strbuf *reason, char **wtpath,\n \t\tif (stat(file.buf, &st) || st.st_mtime <= expire) {\n \t\t\tstrbuf_addstr(reason, _(\"gitdir file points to non-existent location\"));\n \t\t\trc = 1;\n-\t\t\tgoto done;\n \t\t}\n \t}\n \t*wtpath = strbuf_detach(&dotgit, NULL);\ndiff --git a/worktree.h b/worktree.h\nindex 1075409f9a..dde8fc2be4 100644\n--- a/worktree.h\n+++ b/worktree.h\n@@ -105,9 +105,9 @@ const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire);\n \n /*\n  * Return true if worktree entry should be pruned, along with the reason for\n- * pruning. Otherwise, return false and the worktree's path in `wtpath`, or\n- * NULL if it cannot be determined. Caller is responsible for freeing\n- * returned path.\n+ * pruning. Otherwise, return false. In both cases the path of the\n+ * worktree's `.git` file is returned in `wtpath`, or NULL if it cannot\n+ * be determined. Caller is responsible for freeing returned path.\n  *\n  * `expire` defines a grace period to prune the worktree when its path\n  * does not exist.\n-- \n2.54.0\n"},{"id":"549604","messageId":"DKGE5DORETW5.1S9NXEX8KMQHH@pm.me","threadId":"65964","inReplyTo":"371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net","subject":"Re: [PATCH v2 0/4] worktree: add lifecycle hooks","fromName":"Caleb White","fromEmail":"cdwhite3@pm.me","sentAt":"2026-08-04T19:03:19Z","receivedAt":"2026-08-04T19:03:29Z","isPatch":true,"body":"On Tue Aug 4, 2026 at 1:14 PM CDT, Domen Kožar wrote:\n> Hi everyone,\n>\n> First, apologies that my earlier reply reached the list as a separate\n> message rather than as part of this thread. This is my first patch series\n> submitted by email, and I am still getting the threading details right. I\n> have made sure this reroll is plain text and correctly threaded.\n>\n> Thanks,\n> Domen\n\nHi Domen,\n\nI love the idea of having hooks for worktrees, especially now that\nthey are becoming more popular for having agents work on tasks in\nparallel.\n\nI'll try to set aside some time to dive into the individual patches,\nbut I would encourage you to take a look at b4[1][2] (if you haven't\nalready) as it greatly simplifies working with patch series.\n\nBest,\nCaleb\n\n[1]: https://b4.docs.kernel.org/en/latest/\n[2]: https://github.com/mricon/b4\n\n"},{"id":"549609","messageId":"DKGFFRLXG2GY.VYU49MX8QE8Q@pm.me","threadId":"65964","inReplyTo":"9f6bb2ec-afe3-4a1c-ade2-addf73f8681d@mtasv.net","subject":"Re: [PATCH v2 1/4] worktree: add post-worktree-add hook","fromName":"Caleb White","fromEmail":"cdwhite3@pm.me","sentAt":"2026-08-04T20:03:57Z","receivedAt":"2026-08-04T20:04:11Z","isPatch":true,"body":"On Tue Aug 4, 2026 at 1:14 PM CDT, Domen Kožar wrote:\n> Introduce a post-worktree-add hook that runs after the working tree\n> has been fully set up, including with --no-checkout and --orphan. The\n> hook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE\n> cleared, mirroring the existing post-checkout invocation, and takes no\n> arguments. Details such as the absolute path, worktree identifier, and\n> checked-out branch can be queried by running git from the hook's working\n> directory. Taking no arguments also lets a configured command shared\n> with post-worktree-remove distinguish the events by argument count.\n\nIt looks like the `post-worktree-remove` and `post-worktree-move` hooks\nboth receive arguments but this hook does not. While the hook can\ncertainly use git to query the path and identifier, if you already have\nthat information I'm not sure why you can't and shouldn't just pass it\nthrough to the hook (same thing goes for the new path and identifier on\nthe move hook).\n\n\nBest,\nCaleb\n\n"},{"id":"549611","messageId":"xmqqtsp9tyu0.fsf@gitster.g","threadId":"65964","inReplyTo":"DKGE5DORETW5.1S9NXEX8KMQHH@pm.me","subject":"Re: [PATCH v2 0/4] worktree: add lifecycle hooks","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-04T20:28:23Z","receivedAt":"2026-08-04T20:28:26Z","isPatch":true,"body":"Caleb White <cdwhite3@pm.me> writes:\n\n> On Tue Aug 4, 2026 at 1:14 PM CDT, Domen Kožar wrote:\n>> Hi everyone,\n>>\n>> First, apologies that my earlier reply reached the list as a separate\n>> message rather than as part of this thread. This is my first patch series\n>> submitted by email, and I am still getting the threading details right. I\n>> have made sure this reroll is plain text and correctly threaded.\n>>\n>> Thanks,\n>> Domen\n>\n> Hi Domen,\n>\n> I love the idea of having hooks for worktrees, especially now that\n> they are becoming more popular for having agents work on tasks in\n> parallel.\n\nBefore going there, we need to consider if these hooks are necessary\nin the first place.  If you _always_ want to perform something\nbefore or after running \"git worktree add\" or \"git worktree remove\",\nyou can instruct your agents to use \"git wt\" script when they want\nto run \"git worktree\", and install a \"git-wt\" script on their $PATH,\nwhich essentially would be something like\n\n\t#!/bin/sh\n\t# git worktree [add/remove] ...\n\tcase \"$1\" in\n\tadd)\n\t\t... do whatever you want to do before add ...\n\t\t;;\n\tremove)\n\t\t... do whatever you want to do before remove ...\n\t\t;;\n\tesac\n\n\tgit worktree \"$@\"\n\n\tcase \"$1\" in\n\tadd)\n\t\t... do whatever you want to do after add ...\n\t\t;;\n\tremove)\n\t\t... do whatever you want to do after remove ...\n\t\t;;\n\tesac\n\nThe users would need to write the \"... do whatever you want to do\"\npart as the hook script _anyway_, and unless there are compelling\nreason why these _must_ be implemented as hooks, you should resist\nthe temptation to pile more hooks on the system.\n\nHaving said all that.\n\nThere are five valid reasons you might still want to have a hook in\na Git command or operation:\n\n (1) A hook that countermands the normal decision made by the\n     underlying command.  Examples of this class are the 'update'\n     hook and the 'pre-commit' hook.\n\n (2) A hook that operates on data generated after the command starts\n     to run.  The ability to munge the commit log message via the\n     'commit-msg' hook is an example.  You cannot easily prepare\n     what the 'commit-msg' hook may produce before you run\n     'git commit'.\n\n (3) A hook that operates on the remote end of the connection that\n     you may not otherwise have access to, other than over the Git\n     protocol.  An example is the 'post-update' hook that runs\n     update-server-info().\n\n (4) A hook that runs under a lock acquired by the command for\n     mutual exclusion.  Currently there is no example, but if we\n     allowed the 'update' hook to modify the commit that was pushed\n     through a send-pack and receive-pack pair (which was discussed on\n     the list a while ago), it would be a good example of this.\n\n (5) A hook that is run differently depending on the outcome of the\n     command.  The 'post-merge' hook conditionally run by 'git pull' is\n     an example of this (it is not run if no merge takes place).\n     Another example is the 'post-checkout' hook that gets\n     information that is otherwise harder to get (namely, whether it\n     was a branch checkout or a file checkout -- you can figure it\n     out by examining the command line, but that is already part of the\n     processing 'git checkout' does anyway, so there is no need to\n     force duplication of that code in userland).\n\nIf you cannot do an equivalent operation from outside the Git command\nfor the above classes of operations, you need hooks for them.\n\nOn the other hand, if you want to always trigger an action before or\nafter running a Git operation locally, you do not need a hook.  This\nis true even if the action you perform after running a Git operation\ndepends on what happened (class (5) above), provided the result is\neasily observable after the fact.\n\nOf course, one very valid exception to the above policy is when an\naction is common enough that the policy effectively forces everyone\nto reinvent the same wrapper.  We may be better off adding it as an\nofficially supported hook in such a case.\n\nBut for the hooks proposed in this topic, I do not think such an\nexception applies.\n\nThanks.\n"},{"id":"551475","messageId":"8bd3a684-51a0-4a2a-b70d-3981cfe10e9a@mtasv.net","threadId":"65964","inReplyTo":"xmqqtsp9tyu0.fsf@gitster.g","subject":"Re: [PATCH v2 0/4] worktree: add lifecycle hooks","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-08-30T17:21:41Z","receivedAt":"2026-08-30T17:24:49Z","isPatch":true,"body":"Hi Junio,\n\nThanks for laying out the criteria for adding hooks.\n\n> But for the hooks proposed in this topic, I do not think such an\n> exception applies.\n\nI agree that a wrapper is sufficient when all callers are under the\nuser's control. The problem I am trying to solve is that the component\nwhich needs the notification does not control the component invoking\nGit.\n\nFor example, devenv may register lifecycle handling for a repository,\nbut worktrees can subsequently be created or removed by an IDE, a\ncoding agent, another worktree tool, a script, or the user directly.\nRequiring each of those callers to discover and use the same wrapper\nmakes the notification optional in practice. A repository hook provides\none place where that lifecycle behavior can be registered regardless of\nwhich caller invokes Git.\n\n\"git worktree prune\" is also difficult to reproduce reliably in a\nwrapper. One invocation can remove zero or many administrative entries,\nand Git knows exactly which entries it actually removes. A wrapper could\ncompare \"git worktree list\" before and after the command, but that is\nnot an authoritative event stream, can race another worktree operation,\nand has limited information when an entry is already damaged.\n\nAlexander provided another concrete example later in the thread: Xcode\nand several related tools keep substantial path-keyed state outside the\nworktree. Agents invoking \"git worktree remove\" or \"git worktree prune\"\ndirectly leave many gigabytes of state behind even though a cleanup\nwrapper exists. Phillip also mentioned having an unpublished add-hook\npatch for copying per-worktree files such as config.mak.\n\nThat said, I take the point about avoiding a proliferation of hooks.\nInstead of adding three separate hook names, would a single\n\"post-worktree\" hook address that concern? It could use a fixed\ninterface such as:\n\n    post-worktree add    <id> \"\"         <new-path>\n    post-worktree move   <id> <old-path> <new-path>\n    post-worktree remove <id> <old-path> \"\"\n\nAll paths would be absolute. Pruning would issue one \"remove\" event for\neach entry actually pruned, and none under --dry-run. As with the\ncurrent series, the hook would only report an operation that has taken\neffect and could not undo it.\n\nThis would also address Caleb's comment about passing information Git\nalready has rather than requiring the hook to query it, and it avoids\nusing the argument count to distinguish events.\n\nWould that narrower interface, together with the need to observe\noperations from callers that cannot be required to use a wrapper, meet\nthe bar for a native hook? If not, I would appreciate guidance before\nspending time on a reroll.\n\nThanks,\nDomen\n"},{"id":"552143","messageId":"b10f2706-67bb-4657-8d7c-819a29dff890@mtasv.net","threadId":"65964","inReplyTo":"8bd3a684-51a0-4a2a-b70d-3981cfe10e9a@mtasv.net","subject":"Re: [PATCH v2 0/4] worktree: add lifecycle hooks","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-09-07T14:30:13Z","receivedAt":"2026-09-07T14:33:25Z","isPatch":true,"body":"Hi Junio and everyone,\n\nA gentle ping on the single \"post-worktree\" hook proposal below.\n\nAs coding agents increasingly create and discard worktrees, reliable\nlifecycle notifications matter more because tools managing environments\nand cleanup cannot control every caller invoking Git.\n\nWould this interface address your concerns? I'm happy to prepare v3 if\nwe can agree on the direction.\n\nThanks,\nDomen\n"},{"id":"552153","messageId":"de83b791-92ae-41a1-8ecf-5abce7893998@app.fastmail.com","threadId":"65964","inReplyTo":"b10f2706-67bb-4657-8d7c-819a29dff890@mtasv.net","subject":"Re: [PATCH v2 0/4] worktree: add lifecycle hooks","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-09-07T18:18:47Z","receivedAt":"2026-09-07T18:19:15Z","isPatch":true,"body":"On Mon, Sep 7, 2026, at 16:30, Domen Kožar wrote:\n> A gentle ping on the single \"post-worktree\" hook proposal below.\n>\n> As coding agents increasingly create and discard worktrees, reliable\n> lifecycle notifications matter more because tools managing environments\n> and cleanup cannot control every caller invoking Git.\n>\n> Would this interface address your concerns? I'm happy to prepare v3 if\n> we can agree on the direction.\n\nThe problem is multiple callers invoking Git with different\nintentions. What if `git worktree add` learned an option `--id=<string>`\nwhich is then stored in the worktree? This would communicate that\nwhoever calls git-worktree(1) commands with this ID is the one who owns\nit.[1] git-worktree(1) commands which do not get an `--id` (and\nspecifically that ID) does nothing to owned worktrees.\n\n[1]: You know, among friendly and cooperating processes.\n\nThen you don’t need hooks for adding, removing, pruning\nand whatever else.\n\nI could imagine an agent tool that uses it’s own ID to manage a pool\nof worktrees.\n\nI could imagine more metadata, like recording a timestamp for worktree\nexpiry... but you don’t need anything more in git-worktree(1) itself if\nyou have this ID concept. Then you can let tools implement it\nthemselves. They won’t have their worktrees bothered/clobbered by other\ntools, so they can manage the whole lifecycle in peace.\n"},{"id":"552155","messageId":"8fda00ab-a7ac-4ccb-95bb-9d2e28163bb7@mtasv.net","threadId":"65964","inReplyTo":"de83b791-92ae-41a1-8ecf-5abce7893998@app.fastmail.com","subject":"Re: [PATCH v2 0/4] worktree: add lifecycle hooks","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-09-07T19:34:43Z","receivedAt":"2026-09-07T19:37:51Z","isPatch":true,"body":"Hi Kristoffer,\n\nThanks for the suggestion. An ownership ID could help tools avoid\ninterfering with each other's worktrees.\n\nThe case I'm trying to support has two separate participants: an IDE\nor agent creates and removes worktrees, while devenv provisions and\ncleans up the development environment associated with each one.\n\ndevenv doesn't own those worktrees or control the commands that create\nthem. It needs to observe their lifecycle regardless of which tool\ninvokes Git. Even if the creator supplies an ownership ID, how would\ndevenv learn that a worktree was added or removed?\n\nRequiring the creator to explicitly notify devenv would bring back the\nper-tool integration requirement that motivates the hook. The same\napplies to worktrees created or removed directly by the user.\n\nPruning is another useful case: Git knows which administrative entries\nit actually removes, including entries whose directories are already\ngone. A hook could report each removal directly to interested tooling.\n\nWould ownership IDs and lifecycle notifications serve complementary\npurposes here?\n\nThanks,\nDomen\n"},{"id":"554073","messageId":"20261003184725.29917-1-maciej.ciemborowicz@gmail.com","threadId":"65964","inReplyTo":"xmqqtsp9tyu0.fsf@gitster.g","subject":"Re: [PATCH v2 0/4] worktree: add lifecycle hooks","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-10-03T18:47:25Z","receivedAt":"2026-10-03T18:47:50Z","isPatch":true,"body":"Hi Junio,\n\nThank you for the detailed explanation. I agree that a wrapper is the\nright answer when the user controls the invocation of `git worktree`.\n\nI tried that approach for a tool I am building: it creates an isolated\ncontainer environment for each worktree. This matters in particular for\nprojects with databases and migrations, where two agents working in\nparallel must not share an environment.\n\nThe problem is that, in this case, the worktree is often created by the\nIDE rather than by the user or the agent. For example, Codex in VS Code\ncreates worktrees itself when it starts parallel agent sessions. A\n`git-wt` wrapper is therefore bypassed, and asking agents to use one does\nnot help: by the time an agent begins work, the worktree may already\nexist and its environment needs to have been provisioned.\n\nIt is possible to approximate this with polling or instructions in an\nAGENTS.md file, but neither provides a reliable lifecycle boundary.\nThere is a race between worktree creation and the agent beginning work,\nand cleanup on removal has the same issue.\n\nSo I wanted to report a concrete case where wrappers do not reach the\nactor that performs the operation. This has also been the most consistent\nfeedback I have heard from people using parallel AI agents: they do not\nwant to replace every worktree caller with a wrapper; they need a\nrepository-local way to react when Git creates, moves, or removes a\nworktree.\n\nBest regards,\nMaciej Ciemborowicz\n"},{"id":"554132","messageId":"2c1c1f06-05e7-4d8c-bd29-c2a9708b443d@mtasv.net","threadId":"65964","inReplyTo":"cover.1791152172.git.domen@cachix.org","subject":"[PATCH v3 1/2] worktree: add post-worktree lifecycle hook","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-10-04T23:09:07Z","receivedAt":"2026-10-04T23:12:15Z","isPatch":true,"body":"Tools that manage per-worktree development environments need to observe\nworktrees created, moved, or removed by other programs. Wrapping the\nworktree command only helps when every caller uses the wrapper, and\npost-checkout does not run for add --no-checkout or --orphan. There is\nno notification for moving or removing a worktree.\n\nAdd one post-worktree hook for these operations. Pass the event name,\nworktree identifier, old absolute path, and new absolute path as four\narguments, using an empty string for a path that does not apply. An\nexplicit event name lets one handler manage the whole lifecycle without\nusing argument count to distinguish operations, as the earlier series\nwith three separate hooks did.\n\nRun the hook in the invoking repository with its normal environment,\nrather than changing to the affected worktree. Passing both paths lets\nhandlers target the new worktree when needed and keeps the execution\ncontext consistent when a worktree has been removed.\n\nRun the add event after post-checkout even when that hook fails, because\nthe worktree remains present. A failing lifecycle hook affects the\ncommand's exit status without undoing the completed operation. Preserve\npost-checkout's failure status if both hooks fail.\n\nDocument the interface and cover ordinary, bare, and linked callers,\nno-checkout and orphan worktrees, relative paths, paths with spaces,\nconfigured hooks, and hook failures.\n\nCo-authored-by: Claude Fable 5 <noreply@anthropic.com>\nSigned-off-by: Domen Kožar <domen@cachix.org>\n---\n Documentation/config/hook.adoc |   1 +\n Documentation/githooks.adoc    |  43 +++++++++++\n builtin/worktree.c             |  56 ++++++++++----\n t/t2400-worktree-add.sh        | 132 +++++++++++++++++++++++++++++++++\n t/t2403-worktree-move.sh       | 113 ++++++++++++++++++++++++++++\n 5 files changed, 329 insertions(+), 16 deletions(-)\n\ndiff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\nindex 083dc60a13..501bb006f5 100644\n--- a/Documentation/config/hook.adoc\n+++ b/Documentation/config/hook.adoc\n@@ -94,6 +94,7 @@ hook.jobs::\n \tReceive a commit message file and may rewrite it in place.\n `pre-commit`;;\n `post-checkout`;;\n+`post-worktree`;;\n `push-to-checkout`;;\n `post-commit`;;\n \tAccess the working tree, index, or repository state.\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 145642bf05..3e25f769c5 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -215,6 +215,49 @@ This hook can be used to perform repository validity checks, auto-display\n differences from the previous HEAD if different, or set working dir metadata\n properties.\n \n+post-worktree\n+~~~~~~~~~~~~~\n+\n+This hook is invoked by linkgit:git-worktree[1] after a working tree is\n+added, moved, or removed. It takes four parameters: the event (`add`, `move`,\n+or `remove`), the worktree identifier (the name of its administrative\n+directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the\n+new absolute path.\n+\n+The parameters for each event are:\n+\n+    post-worktree add    <id> \"\"         <new-path>\n+    post-worktree move   <id> <old-path> <new-path>\n+    post-worktree remove <id> <old-path> \"\"\n+\n+The empty strings are passed as arguments, so all events have exactly\n+four parameters.\n+\n+The hook runs in the repository where the command was invoked, following\n+the working directory and environment rules described above. It does not\n+change to the added or moved working tree. To run Git commands there,\n+clear the repository environment variables and use the new path, for\n+example:\n+\n+------------\n+(unset $(git rev-parse --local-env-vars); git -C \"$4\" status)\n+------------\n+\n+The `add` event runs after the new working tree has been set up, including\n+with `--no-checkout` and `--orphan`. It runs after `post-checkout`, even\n+if that hook fails. The `move` event runs after the working tree and its\n+administrative files have been moved. The `remove` event runs after the\n+working tree has been deleted or its administrative entry removed.\n+\n+The hook cannot undo the worktree operation. A non-zero exit status is\n+reflected in the command's exit status, but leaves the completed operation\n+in place. If `post-checkout` fails during `git worktree add`, its exit\n+status takes precedence over that of `post-worktree`.\n+\n+This hook can be used to set up, relocate, or tear down per-worktree\n+development environments, or to maintain registrations with external\n+tools. Hook scripts should ignore events they do not handle.\n+\n post-merge\n ~~~~~~~~~~\n \ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex 77ecd0f71f..0f2748080c 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -168,6 +168,15 @@ static void delete_worktrees_dir_if_empty(void)\n \tfree(path);\n }\n \n+static int run_post_worktree_hook(const char *event, const char *id,\n+\t\t\t\t  const char *old_path, const char *new_path)\n+{\n+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n+\tstrvec_pushl(&hook_opt.args, event, id, old_path, new_path, NULL);\n+\treturn run_hooks_opt(the_repository, \"post-worktree\", &hook_opt);\n+}\n+\n static void prune_worktree(const char *id, const char *reason)\n {\n \tif (show_only || verbose)\n@@ -604,21 +613,30 @@ static int add_worktree(const char *path, const char *refname,\n \t}\n \n \t/*\n-\t * Hook failure does not warrant worktree deletion, so run hook after\n-\t * is_junk is cleared, but do return appropriate code when hook fails.\n+\t * Hook failures do not warrant worktree deletion, so run hooks after\n+\t * is_junk is cleared, but do return appropriate code when a hook\n+\t * fails.\n \t */\n-\tif (!ret && opts->checkout && !opts->orphan) {\n-\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n-\n-\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n-\t\tstrvec_pushl(&opt.args,\n-\t\t\t     oid_to_hex(null_oid(the_hash_algo)),\n-\t\t\t     oid_to_hex(&commit->object.oid),\n-\t\t\t     \"1\",\n-\t\t\t     NULL);\n-\t\topt.dir = path;\n-\n-\t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n+\tif (!ret) {\n+\t\tint hook_ret;\n+\n+\t\tif (opts->checkout && !opts->orphan) {\n+\t\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n+\n+\t\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n+\t\t\tstrvec_pushl(&opt.args,\n+\t\t\t\t     oid_to_hex(null_oid(the_hash_algo)),\n+\t\t\t\t     oid_to_hex(&commit->object.oid),\n+\t\t\t\t     \"1\",\n+\t\t\t\t     NULL);\n+\t\t\topt.dir = path;\n+\n+\t\t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n+\t\t}\n+\n+\t\thook_ret = run_post_worktree_hook(\"add\", wt->id, \"\", wt->path);\n+\t\tif (!ret)\n+\t\t\tret = hook_ret;\n \t}\n \n \tstrvec_clear(&child_env);\n@@ -1305,7 +1323,8 @@ static int move_worktree(int ac, const char **av, const char *prefix,\n \tstruct strbuf dst = STRBUF_INIT;\n \tstruct strbuf errmsg = STRBUF_INIT;\n \tconst char *reason = NULL;\n-\tchar *path;\n+\tchar *old_path, *path;\n+\tint ret;\n \n \tac = parse_options(ac, av, prefix, options, git_worktree_move_usage,\n \t\t\t   0);\n@@ -1348,14 +1367,17 @@ static int move_worktree(int ac, const char **av, const char *prefix,\n \t\t    errmsg.buf);\n \tstrbuf_release(&errmsg);\n \n+\told_path = xstrdup(wt->path);\n \tif (rename(wt->path, dst.buf) == -1)\n \t\tdie_errno(_(\"failed to move '%s' to '%s'\"), wt->path, dst.buf);\n \n \tupdate_worktree_location(wt, dst.buf, use_relative_paths);\n+\tret = run_post_worktree_hook(\"move\", wt->id, old_path, wt->path);\n \n+\tfree(old_path);\n \tstrbuf_release(&dst);\n \tfree_worktrees(worktrees);\n-\treturn 0;\n+\treturn ret;\n }\n \n /*\n@@ -1473,6 +1495,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix,\n \tret |= delete_git_dir(wt->id);\n \tdelete_worktrees_dir_if_empty();\n \n+\tret |= run_post_worktree_hook(\"remove\", wt->id, wt->path, \"\");\n+\n \tfree_worktrees(worktrees);\n \treturn ret;\n }\ndiff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh\nindex bdcca97633..65fec976b5 100755\n--- a/t/t2400-worktree-add.sh\n+++ b/t/t2400-worktree-add.sh\n@@ -1172,6 +1172,138 @@ test_expect_success '\"add\" in bare repo invokes post-checkout hook' '\n \ttest_cmp hook.expect goozy/hook.actual\n '\n \n+# Install a post-worktree hook and write the output expected for adding\n+# worktree $1. Repo $2 defaults to \".\"; the caller worktree is $3.\n+post_worktree_add_hook () {\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook -C \"$2\" post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 &&\n+\t{\n+\t\tprintf \"%s\\n\" \"$@\" &&\n+\t\ttest-tool path-utils real_path . &&\n+\t\tgit rev-parse --absolute-git-dir\n+\t} >hook.actual\n+\tEOF\n+\t{\n+\t\ttest_write_lines add \"$1\" \"\" \"$(pwd)/$1\" &&\n+\t\t(cd \"${3:-${2:-.}}\" && test-tool path-utils real_path .) &&\n+\t\tgit -C \"${3:-${2:-.}}\" rev-parse --absolute-git-dir\n+\t} >hook.expect\n+}\n+\n+test_expect_success '\"add\" invokes post-worktree hook' '\n+\tpost_worktree_add_hook wanda &&\n+\tgit worktree add wanda &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success '\"add\" in other worktree invokes post-worktree hook there' '\n+\tpost_worktree_add_hook wilbur \"\" wanda &&\n+\tgit -C wanda worktree add ../wilbur &&\n+\ttest_cmp hook.expect wanda/hook.actual\n+'\n+\n+test_expect_success '\"add --no-checkout\" still invokes post-worktree hook' '\n+\tpost_worktree_add_hook wendy &&\n+\tgit worktree add --no-checkout wendy &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success '\"add --orphan\" invokes post-worktree hook' '\n+\tpost_worktree_add_hook winnie &&\n+\tgit worktree add --orphan winnie &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success '\"add\" in bare repo invokes post-worktree hook there' '\n+\trm -rf bare2 &&\n+\tgit clone --bare . bare2 &&\n+\tpost_worktree_add_hook willow bare2 &&\n+\tgit -C bare2 worktree add --detach ../willow &&\n+\ttest_cmp hook.expect bare2/hook.actual\n+'\n+\n+test_expect_success '\"add\" runs post-worktree after post-checkout' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook post-checkout <<-\\EOF &&\n+\techo post-checkout >>\"$(git rev-parse --git-common-dir)/hooks.actual\"\n+\tEOF\n+\ttest_hook post-worktree <<-\\EOF &&\n+\techo post-worktree >>\"$(git rev-parse --git-common-dir)/hooks.actual\"\n+\tEOF\n+\ttest_write_lines post-checkout post-worktree >hooks.expect &&\n+\tgit worktree add wobble &&\n+\ttest_cmp hooks.expect .git/hooks.actual\n+'\n+\n+test_expect_success 'failing post-checkout hook does not suppress post-worktree hook' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook post-checkout <<-\\EOF &&\n+\texit 2\n+\tEOF\n+\ttest_hook post-worktree <<-\\EOF &&\n+\t>post-worktree.ran &&\n+\texit 3\n+\tEOF\n+\ttest_expect_code 2 git worktree add wozzle &&\n+\ttest_path_is_file post-worktree.ran\n+'\n+\n+test_expect_success 'failing post-worktree hook leaves worktree in place' '\n+\ttest_when_finished \"rm -rf .git/hooks\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook post-worktree <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\ttest_expect_code 1 git worktree add wilma &&\n+\tgit worktree list --porcelain >out &&\n+\ttest_grep -F \"worktree $(pwd)/wilma\" out\n+'\n+\n+test_expect_success 'failed \"add\" does not invoke post-worktree hook' '\n+\ttest_when_finished \"rm -rf .git/hooks occupied\" &&\n+\tmkdir .git/hooks &&\n+\ttest_hook post-worktree <<-\\EOF &&\n+\t>hook.ran\n+\tEOF\n+\tmkdir occupied &&\n+\t: >occupied/blocker &&\n+\ttest_must_fail git worktree add occupied &&\n+\ttest_path_is_missing hook.ran\n+'\n+\n+test_expect_success 'post-worktree add gets absolute path with relative worktrees' '\n+\ttest_when_finished \"rm -rf relhook\" &&\n+\tgit init relhook &&\n+\ttest_commit -C relhook base &&\n+\ttest_hook -C relhook post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 &&\n+\tprintf \"%s\\n\" \"$@\" >hook.actual\n+\tEOF\n+\tgit -C relhook worktree add --relative-paths --detach wt &&\n+\ttest_write_lines add wt \"\" \"$(pwd)/relhook/wt\" >hook.expect &&\n+\ttest_cmp hook.expect relhook/hook.actual\n+'\n+\n+test_expect_success 'configured post-worktree hook preserves paths with spaces' '\n+\ttest_when_finished \"rm -rf confighook\" &&\n+\tgit init confighook &&\n+\ttest_commit -C confighook base &&\n+\twrite_script confighook/record-hook <<-\\EOF &&\n+\ttest \"$#\" = 4 &&\n+\tprintf \"%s\\n\" \"$@\" >hook.actual\n+\tEOF\n+\tgit -C confighook config hook.lifecycle.command ./record-hook &&\n+\tgit -C confighook config hook.lifecycle.event post-worktree &&\n+\tgit -C confighook worktree add --detach \"wt with spaces\" &&\n+\tid=$(basename \"$(git -C \"confighook/wt with spaces\" rev-parse --absolute-git-dir)\") &&\n+\ttest_write_lines add \"$id\" \"\" \"$(pwd)/confighook/wt with spaces\" >hook.expect &&\n+\ttest_cmp hook.expect confighook/hook.actual\n+'\n+\n test_expect_success '\"add\" an existing but missing worktree' '\n \tgit worktree add --detach pneu &&\n \ttest_must_fail git worktree add --detach pneu &&\ndiff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh\nindex 69768c1207..11ef81dce8 100755\n--- a/t/t2403-worktree-move.sh\n+++ b/t/t2403-worktree-move.sh\n@@ -82,6 +82,59 @@ test_expect_success 'move worktree' '\n \ttest_cmp expected2 actual2\n '\n \n+test_expect_success '\"move\" invokes post-worktree hook in the calling repository' '\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 || exit 1\n+\ttest \"$1\" = move || exit 0\n+\t{\n+\t\tprintf \"%s\\n\" \"$@\" &&\n+\t\ttest-tool path-utils real_path . &&\n+\t\tgit rev-parse --absolute-git-dir\n+\t} >hook.actual\n+\tEOF\n+\tgit worktree add --detach hook-source &&\n+\tgit worktree move hook-source hook-destination &&\n+\t{\n+\t\ttest_write_lines move hook-source \"$(pwd)/hook-source\" \"$(pwd)/hook-destination\" &&\n+\t\ttest-tool path-utils real_path . &&\n+\t\tgit rev-parse --absolute-git-dir\n+\t} >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'failing post-worktree move event leaves worktree moved' '\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$1\" = move || exit 0\n+\texit 1\n+\tEOF\n+\tgit worktree add --detach hook-failing-source &&\n+\ttest_must_fail git worktree move hook-failing-source hook-failing-destination &&\n+\ttest_path_is_missing hook-failing-source &&\n+\tgit -C hook-failing-destination status --porcelain >actual &&\n+\ttest_must_be_empty actual\n+'\n+\n+test_expect_success 'post-worktree move keeps the ID and passes absolute paths with spaces' '\n+\ttest_when_finished \"rm -rf movehook\" &&\n+\tgit init movehook &&\n+\ttest_commit -C movehook base &&\n+\tgit -C movehook worktree add --relative-paths --detach \"source tree\" &&\n+\tgit -C movehook worktree add --detach caller &&\n+\tid=$(basename \"$(git -C \"movehook/source tree\" rev-parse --absolute-git-dir)\") &&\n+\ttest_hook -C movehook post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 &&\n+\t{\n+\t\tprintf \"%s\\n\" \"$@\" &&\n+\t\tgit rev-parse --show-toplevel\n+\t} >hook.actual\n+\tEOF\n+\tgit -C movehook/caller worktree move --relative-paths \"../source tree\" \"../destination tree\" &&\n+\ttest_write_lines move \"$id\" \"$(pwd)/movehook/source tree\" \\\n+\t\t\"$(pwd)/movehook/destination tree\" \"$(pwd)/movehook/caller\" >hook.expect &&\n+\ttest_cmp hook.expect movehook/caller/hook.actual &&\n+\ttest_path_is_dir \"movehook/destination tree\"\n+'\n+\n test_expect_success 'move main worktree' '\n \ttest_must_fail git worktree move . def\n '\n@@ -246,6 +299,66 @@ test_expect_success 'not remove a repo with initialized submodule' '\n \t)\n '\n \n+test_expect_success '\"remove\" invokes post-worktree remove event' '\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 || exit 1\n+\ttest \"$1\" = remove || exit 0\n+\tprintf \"%s\\n\" \"$@\" >hook.actual\n+\tEOF\n+\tgit worktree add --detach wt-hooked &&\n+\tgit worktree remove wt-hooked &&\n+\ttest_write_lines remove wt-hooked \"$(pwd)/wt-hooked\" \"\" >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success '\"remove\" of missing worktree invokes post-worktree hook' '\n+\ttest_when_finished \"rm -rf wt-moved-away\" &&\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$1\" = remove || exit 0\n+\tprintf \"%s\\n\" \"$@\" >hook.actual\n+\tEOF\n+\trm -f hook.actual &&\n+\tgit worktree add --detach wt-elsewhere &&\n+\tmv wt-elsewhere wt-moved-away &&\n+\tgit worktree remove wt-elsewhere &&\n+\ttest_write_lines remove wt-elsewhere \"$(pwd)/wt-elsewhere\" \"\" >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'refused \"remove\" does not invoke post-worktree hook' '\n+\tgit worktree add --detach wt-kept &&\n+\ttest_when_finished \"git worktree remove --force --force wt-kept || :\" &&\n+\ttest_hook post-worktree <<-\\EOF &&\n+\t>hook.ran\n+\tEOF\n+\tgit worktree lock wt-kept &&\n+\ttest_must_fail git worktree remove wt-kept &&\n+\ttest_path_is_missing hook.ran\n+'\n+\n+test_expect_success 'failing post-worktree remove event fails \"remove\", worktree is gone' '\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$1\" = remove || exit 0\n+\texit 1\n+\tEOF\n+\tgit worktree add --detach wt-doomed &&\n+\ttest_must_fail git worktree remove wt-doomed &&\n+\ttest_path_is_missing wt-doomed &&\n+\ttest_path_is_missing .git/worktrees/wt-doomed\n+'\n+\n+test_expect_success 'post-worktree remove preserves paths with spaces' '\n+\tgit worktree add --detach \"remove tree\" &&\n+\tid=$(basename \"$(git -C \"remove tree\" rev-parse --absolute-git-dir)\") &&\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 &&\n+\tprintf \"%s\\n\" \"$@\" >hook.actual\n+\tEOF\n+\tgit worktree remove \"remove tree\" &&\n+\ttest_write_lines remove \"$id\" \"$(pwd)/remove tree\" \"\" >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n test_expect_success 'move worktree with absolute path to relative path' '\n \ttest_config worktree.useRelativePaths false &&\n \tgit worktree add ./absolute &&\n-- \n2.54.0\n"},{"id":"554133","messageId":"d550ede0-6a33-4eea-a6dd-051d110b68e5@mtasv.net","threadId":"65964","inReplyTo":"371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net","subject":"[PATCH v3 0/2] worktree: add post-worktree lifecycle hook","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-10-04T23:09:07Z","receivedAt":"2026-10-04T23:12:17Z","isPatch":true,"body":"Tools that manage per-worktree development environments need to observe\nworktrees created, moved, or removed by other programs. Wrapping git\nworktree only helps when every caller uses the wrapper. post-checkout\ndoes not run for add --no-checkout or --orphan, and Git has no lifecycle\nnotification for moving, removing, or pruning worktrees.\n\nThe motivating use case is devenv provisioning and cleaning up processes\nand services for worktrees created by an IDE or agent. The creator and\nenvironment manager are separate tools. Ownership identifiers can help\ncoordinate creators, but do not notify the environment manager about\nchanges made by another tool or directly by the user.\n\nAdd one post-worktree hook with four arguments:\n\n    post-worktree add    <id> \"\"         <new-path>\n    post-worktree move   <id> <old-path> <new-path>\n    post-worktree remove <id> <old-path> \"\"\n\nThe hook runs in the invoking repository with the usual hook working\ndirectory and environment. Paths are absolute; empty strings are passed\nas arguments for paths that do not apply or cannot be determined during\npruning. The worktree identifier is the name of its administrative entry\nunder the common Git directory.\n\nThe add event runs after post-checkout, even if post-checkout fails, and\nalso covers --no-checkout and --orphan. Prune emits a remove event per\npruned entry, including duplicates, and none for --dry-run. Hook failures\naffect the command exit status without undoing completed operations;\npruning continues to notify the remaining entries after a hook failure.\n\nThe first commit adds the hook for add, move, and remove. The second adds\npruning notifications. Both include documentation and regression tests.\n\nChanges since v2:\n\n * Replace the three separate hook names with the single post-worktree\n   interface proposed in the mailing-list discussion.\n * Pass an explicit event, identifier, and both paths for every event,\n   instead of using argument count to distinguish operations.\n * Keep the execution context in the invoking repository for all events.\n * Cover configured hooks, paths with spaces, linked and bare callers,\n   relative paths, failure status precedence, and continued pruning\n   notifications after a hook failure.\n * Rebase onto current master and use its repository argument in\n   should_prune_worktree().\n\nEarlier discussion:\nhttps://lore.kernel.org/git/7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net/\nhttps://lore.kernel.org/git/8bd3a684-51a0-4a2a-b70d-3981cfe10e9a@mtasv.net/\n\nAI assistance: the commits retain the original Claude coauthor credit.\nCodex assisted with consolidating the interface, adapting the tests,\nupdating documentation and commit messages, and validating this revision.\n\nValidation:\n\n * Developer build and test lint passed with DEVELOPER=1.\n * All nine hook and worktree suites passed in the final full run. A\n   focused run after the interface change passed 489 tests, and the\n   lifecycle commit also passed independently (381 tests).\n * git diff --check and the repository clang-format check passed.\n * Trial merges into next and seen apply without conflicts.\n * githooks.html and git-config.html render correctly with Asciidoctor;\n   only post-worktree is registered in the generated hook list.\n * The final full local run completed: 1,062 test files, 33,881 tests.\n   Four tests failed with a 128 KiB stack: t0003-attributes.sh test 55,\n   t6120-describe.sh tests 85-86, and t7004-tag.sh test 212. These are\n   the same four failures reproduced on unmodified upstream master at\n   8103b446517e0c44e67561b9d0ccce56efa60a71 using the same compiler and\n   environment. Optional tests without available prerequisites skipped.\n * All enabled platform CI checks passed, including macOS, Windows,\n   Linux variants, Meson, address/undefined-behavior sanitizers, and leak\n   checks. CI initially caught a Windows shell/native path mismatch in\n   two new working-directory assertions. These now use the path-utils\n   test helper; all 289 add/move tests also pass locally after the fix.\n   https://github.com/git/git/actions/runs/37239319553\n\nPR and platform checks:\nhttps://github.com/git/git/pull/2442\n\nDomen Kožar (2):\n  worktree: add post-worktree lifecycle hook\n  worktree: notify post-worktree hook when pruning\n\n Documentation/config/hook.adoc |   1 +\n Documentation/githooks.adoc    |  46 ++++++++++++\n builtin/worktree.c             | 105 ++++++++++++++++++--------\n t/t2400-worktree-add.sh        | 132 +++++++++++++++++++++++++++++++++\n t/t2401-worktree-prune.sh      | 102 +++++++++++++++++++++++++\n t/t2403-worktree-move.sh       | 113 ++++++++++++++++++++++++++++\n worktree.c                     |   1 -\n worktree.h                     |   6 +-\n 8 files changed, 473 insertions(+), 33 deletions(-)\n\nRange-diff against v2:\n1:  73e36c179e < -:  ---------- worktree: add post-worktree-add hook\n2:  3de87064c0 < -:  ---------- worktree: add post-worktree-remove hook\n3:  7989a1d6a2 < -:  ---------- worktree: run post-worktree-remove hook when pruning\n-:  ---------- > 1:  c37f12fcba worktree: add post-worktree lifecycle hook\n4:  95ab61e377 ! 2:  e5855a1491 worktree: add post-worktree-move hook\n    @@ Metadata\n     Author: Domen Kožar <domen@cachix.org>\n     \n      ## Commit message ##\n    -    worktree: add post-worktree-move hook\n    +    worktree: notify post-worktree hook when pruning\n     \n    -    Tools that record worktree paths can keep their state up to date when a\n    -    worktree is added or removed, but the mapping becomes stale when the\n    -    worktree is moved. Services or other per-worktree state tied to the old\n    -    path may also need to be relocated.\n    +    A worktree can disappear without git worktree remove, for example when\n    +    its directory is deleted manually. Tools maintaining per-worktree state\n    +    need to observe its later deregistration by git worktree prune as well.\n    +    Git knows which entries it prunes, including duplicates, whereas a\n    +    wrapper comparing worktree listings can race concurrent operations and\n    +    has limited information about damaged entries.\n     \n    -    Introduce a post-worktree-move hook that runs after the working tree and\n    -    its administrative files have been moved. The hook runs inside the new\n    -    working tree with GIT_DIR and GIT_WORK_TREE cleared and receives the old\n    -    absolute path as its sole argument. The new path and worktree identifier\n    -    can be queried by running git from the hook's working directory.\n    +    Emit a post-worktree remove event for each pruned administrative entry,\n    +    with its identifier and former absolute path. Return the recorded .git\n    +    path from should_prune_worktree() even when it points to a missing\n    +    location, so the hook can receive the former worktree path. If the path\n    +    cannot be determined, pass an empty string instead.\n     \n    -    This signature also lets one configured command handle all three\n    -    worktree lifecycle hooks by argument count: post-worktree-add takes no\n    -    arguments, post-worktree-move takes one, and post-worktree-remove takes\n    -    two.\n    -\n    -    A failing hook does not undo the completed move, but its exit status\n    -    becomes the exit status of \"git worktree move\".\n    +    Do not invoke the hook during a dry run. Reflect hook failures in the\n    +    command's exit status while continuing to process the remaining entries.\n    +    Document pruning and test missing paths, duplicate entries, relative\n    +    paths, dry runs, and failures that must not suppress other notifications.\n     \n    +    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>\n         Signed-off-by: Domen Kožar <domen@cachix.org>\n    -    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>\n    -\n    - ## Documentation/config/hook.adoc ##\n    -@@ Documentation/config/hook.adoc: hook.jobs::\n    - `pre-commit`;;\n    - `post-checkout`;;\n    - `post-worktree-add`;;\n    -+`post-worktree-move`;;\n    - `post-worktree-remove`;;\n    - `push-to-checkout`;;\n    - `post-commit`;;\n     \n      ## Documentation/githooks.adoc ##\n    -@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, even if that hook fails.\n    - This hook can be used to set up per-worktree development environments\n    - or to register the new working tree with external tools.\n    +@@ Documentation/githooks.adoc: post-worktree\n    + ~~~~~~~~~~~~~\n      \n    -+post-worktree-move\n    -+~~~~~~~~~~~~~~~~~~\n    -+\n    -+This hook is invoked by linkgit:git-worktree[1] after `git worktree move`\n    -+has moved a working tree and updated its administrative files. It is given\n    -+one parameter: the absolute path of the working tree before it was moved.\n    -+\n    -+The hook's current working directory is the new working tree, so its new\n    -+absolute path and identifier can be queried by running `git`.\n    -+\n    -+This hook cannot affect the outcome of `git worktree move`, other than\n    -+that the hook's exit status becomes the exit status of the command. A\n    -+failing hook does not undo the move.\n    -+\n    -+This hook can be used to update per-worktree development environments or\n    -+registrations with external tools after their working tree has moved.\n    -+\n    - post-worktree-remove\n    - ~~~~~~~~~~~~~~~~~~~~\n    + This hook is invoked by linkgit:git-worktree[1] after a working tree is\n    +-added, moved, or removed. It takes four parameters: the event (`add`, `move`,\n    ++added, moved, or removed, and once for each entry removed by\n    ++`git worktree prune`. It takes four parameters: the event (`add`, `move`,\n    + or `remove`), the worktree identifier (the name of its administrative\n    + directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the\n    + new absolute path.\n    +@@ Documentation/githooks.adoc: The parameters for each event are:\n    +     post-worktree remove <id> <old-path> \"\"\n      \n    + The empty strings are passed as arguments, so all events have exactly\n    +-four parameters.\n    ++four parameters. For entries pruned by `git worktree prune`, the old path\n    ++may also be empty if it cannot be determined from the administrative\n    ++files. No hook is run for `git worktree prune --dry-run`.\n    + \n    + The hook runs in the repository where the command was invoked, following\n    + the working directory and environment rules described above. It does not\n     \n      ## builtin/worktree.c ##\n    -@@ builtin/worktree.c: static int run_post_worktree_remove_hook(const char *path, const char *id)\n    - \treturn run_hooks_opt(the_repository, \"post-worktree-remove\", &hook_opt);\n    +@@ builtin/worktree.c: static int run_post_worktree_hook(const char *event, const char *id,\n    + \treturn run_hooks_opt(the_repository, \"post-worktree\", &hook_opt);\n      }\n      \n    -+static int run_post_worktree_move_hook(const char *old_path,\n    -+\t\t\t\t       const char *new_path)\n    -+{\n    -+\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n    +-static void prune_worktree(const char *id, const char *reason)\n    ++static int prune_worktree(const char *id, const char *dotgit,\n    ++\t\t\t  const char *reason)\n    + {\n    ++\tstruct strbuf path = STRBUF_INIT;\n    ++\tint ret;\n     +\n    -+\tstrvec_pushl(&hook_opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n    -+\tstrvec_push(&hook_opt.args, old_path);\n    -+\thook_opt.dir = new_path;\n    -+\treturn run_hooks_opt(the_repository, \"post-worktree-move\", &hook_opt);\n    -+}\n    + \tif (show_only || verbose)\n    + \t\tfprintf_ln(stderr, _(\"Removing %s/%s: %s\"), \"worktrees\", id, reason);\n    +-\tif (!show_only)\n    +-\t\tdelete_git_dir(id);\n    ++\tif (show_only)\n    ++\t\treturn 0;\n     +\n    - static int prune_worktree(const char *id, const char *dotgit,\n    - \t\t\t  const char *reason)\n    ++\tdelete_git_dir(id);\n    ++\n    ++\t/* path stays empty when the worktree path cannot be determined */\n    ++\tif (dotgit) {\n    ++\t\tstrbuf_addstr(&path, dotgit);\n    ++\t\tstrbuf_strip_suffix(&path, \"/.git\");\n    ++\t}\n    ++\tret = run_post_worktree_hook(\"remove\", id, path.buf, \"\");\n    ++\tstrbuf_release(&path);\n    ++\treturn ret;\n    + }\n    + \n    + static int prune_cmp(const void *a, const void *b)\n    +@@ builtin/worktree.c: static int prune_cmp(const void *a, const void *b)\n    + \treturn strcmp(x->util, y->util);\n    + }\n    + \n    +-static void prune_dups(struct string_list *l)\n    ++static int prune_dups(struct string_list *l)\n      {\n    -@@ builtin/worktree.c: static int move_worktree(int ac, const char **av, const char *prefix,\n    - \tstruct strbuf dst = STRBUF_INIT;\n    - \tstruct strbuf errmsg = STRBUF_INIT;\n    - \tconst char *reason = NULL;\n    --\tchar *path;\n    -+\tchar *old_path, *path;\n    -+\tint ret;\n    + \tint i;\n    ++\tint ret = 0;\n      \n    - \tac = parse_options(ac, av, prefix, options, git_worktree_move_usage,\n    - \t\t\t   0);\n    -@@ builtin/worktree.c: static int move_worktree(int ac, const char **av, const char *prefix,\n    - \t\t    errmsg.buf);\n    - \tstrbuf_release(&errmsg);\n    + \tQSORT(l->items, l->nr, prune_cmp);\n    + \tfor (i = 1; i < l->nr; i++) {\n    + \t\tif (!fspathcmp(l->items[i].string, l->items[i - 1].string))\n    +-\t\t\tprune_worktree(l->items[i].util, \"duplicate entry\");\n    ++\t\t\tret |= prune_worktree(l->items[i].util,\n    ++\t\t\t\t\t      l->items[i].string,\n    ++\t\t\t\t\t      \"duplicate entry\");\n    + \t}\n    ++\treturn ret;\n    + }\n      \n    -+\told_path = xstrdup(wt->path);\n    - \tif (rename(wt->path, dst.buf) == -1)\n    - \t\tdie_errno(_(\"failed to move '%s' to '%s'\"), wt->path, dst.buf);\n    +-static void prune_worktrees(void)\n    ++static int prune_worktrees(void)\n    + {\n    + \tstruct strbuf reason = STRBUF_INIT;\n    + \tstruct strbuf main_path = STRBUF_INIT;\n    +@@ builtin/worktree.c: static void prune_worktrees(void)\n    + \tchar *path;\n    + \tDIR *dir;\n    + \tstruct dirent *d;\n    ++\tint ret = 0;\n      \n    - \tupdate_worktree_location(wt, dst.buf, use_relative_paths);\n    -+\tret = run_post_worktree_move_hook(old_path, wt->path);\n    + \tpath = repo_git_path(the_repository, \"worktrees\");\n    + \tdir = opendir(path);\n    + \tfree(path);\n    + \tif (!dir)\n    +-\t\treturn;\n    ++\t\treturn 0;\n    + \twhile ((d = readdir_skip_dot_and_dotdot(dir)) != NULL) {\n    + \t\tchar *path;\n    + \t\tstrbuf_reset(&reason);\n    +-\t\tif (should_prune_worktree(the_repository, d->d_name, &reason, &path, expire))\n    +-\t\t\tprune_worktree(d->d_name, reason.buf);\n    +-\t\telse if (path)\n    ++\t\tif (should_prune_worktree(the_repository, d->d_name,\n    ++\t\t\t\t\t  &reason, &path, expire)) {\n    ++\t\t\tret |= prune_worktree(d->d_name, path, reason.buf);\n    ++\t\t\tfree(path);\n    ++\t\t} else if (path) {\n    + \t\t\tstring_list_append_nodup(&kept, path)->util = xstrdup(d->d_name);\n    ++\t\t}\n    + \t}\n    + \tclosedir(dir);\n      \n    -+\tfree(old_path);\n    - \tstrbuf_release(&dst);\n    - \tfree_worktrees(worktrees);\n    --\treturn 0;\n    +@@ builtin/worktree.c: static void prune_worktrees(void)\n    + \t/* massage main worktree absolute path to match 'gitdir' content */\n    + \tstrbuf_strip_suffix(&main_path, \"/.\");\n    + \tstring_list_append_nodup(&kept, strbuf_detach(&main_path, NULL));\n    +-\tprune_dups(&kept);\n    ++\tret |= prune_dups(&kept);\n    + \tstring_list_clear(&kept, 1);\n    + \n    + \tif (!show_only)\n    + \t\tdelete_worktrees_dir_if_empty();\n    + \tstrbuf_release(&reason);\n     +\treturn ret;\n      }\n      \n    - /*\n    + static int prune(int ac, const char **av, const char *prefix,\n    +@@ builtin/worktree.c: static int prune(int ac, const char **av, const char *prefix,\n    + \t\t\t   0);\n    + \tif (ac)\n    + \t\tusage_with_options(git_worktree_prune_usage, options);\n    +-\tprune_worktrees();\n    +-\treturn 0;\n    ++\treturn prune_worktrees();\n    + }\n    + \n    + static char *junk_work_tree;\n     \n    - ## t/t2403-worktree-move.sh ##\n    -@@ t/t2403-worktree-move.sh: test_expect_success 'move worktree' '\n    - \ttest_cmp expected2 actual2\n    + ## t/t2401-worktree-prune.sh ##\n    +@@ t/t2401-worktree-prune.sh: test_expect_success 'prune duplicate (main/linked)' '\n    + \ttest_path_is_missing .git/worktrees/wt\n      '\n      \n    -+test_expect_success '\"move\" invokes post-worktree-move hook' '\n    -+\ttest_hook post-worktree-move <<-\\EOF &&\n    -+\ttest \"$#\" = 1 &&\n    -+\t{\n    -+\t\techo \"$1\" &&\n    -+\t\tgit rev-parse --git-dir --show-toplevel\n    -+\t} >hook.actual\n    ++test_expect_success 'prune invokes post-worktree remove event' '\n    ++\ttest_hook post-worktree <<-\\EOF &&\n    ++\ttest \"$#\" = 4 || exit 1\n    ++\ttest \"$1\" = remove || exit 0\n    ++\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >hook.actual\n    ++\tEOF\n    ++\tgit worktree add --detach flushed &&\n    ++\trm -rf flushed &&\n    ++\tgit worktree prune &&\n    ++\tprintf \"[remove][flushed][%s][]\\n\" \"$(pwd)/flushed\" >hook.expect &&\n    ++\ttest_cmp hook.expect hook.actual\n    ++'\n    ++\n    ++test_expect_success 'prune invokes post-worktree once per worktree' '\n    ++\ttest_hook post-worktree <<-\\EOF &&\n    ++\ttest \"$#\" = 4 || exit 1\n    ++\ttest \"$1\" = remove || exit 0\n    ++\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >>hook.actual\n     +\tEOF\n    -+\tgit worktree add --detach hook-source &&\n    -+\tgit worktree move hook-source hook-destination &&\n    ++\tgit worktree add --detach first &&\n    ++\tgit worktree add --detach second &&\n    ++\trm -rf first second hook.actual &&\n    ++\tgit worktree prune &&\n     +\t{\n    -+\t\techo \"$(pwd)/hook-source\" &&\n    -+\t\techo \"$(pwd)/.git/worktrees/hook-source\" &&\n    -+\t\techo \"$(pwd)/hook-destination\"\n    ++\t\tprintf \"[remove][first][%s][]\\n\" \"$(pwd)/first\" &&\n    ++\t\tprintf \"[remove][second][%s][]\\n\" \"$(pwd)/second\"\n     +\t} >hook.expect &&\n    -+\ttest_cmp hook.expect hook-destination/hook.actual\n    ++\tsort hook.actual >hook.sorted &&\n    ++\ttest_cmp hook.expect hook.sorted\n    ++'\n    ++\n    ++test_expect_success 'prune --dry-run does not invoke post-worktree hook' '\n    ++\tgit worktree add --detach dry &&\n    ++\trm -rf dry &&\n    ++\ttest_when_finished \"git worktree prune\" &&\n    ++\ttest_hook post-worktree <<-\\EOF &&\n    ++\t>hook.ran\n    ++\tEOF\n    ++\tgit worktree prune --dry-run &&\n    ++\ttest_path_is_missing hook.ran\n    ++'\n    ++\n    ++test_expect_success 'pruned entry with unknown path gives empty hook argument' '\n    ++\ttest_hook post-worktree <<-\\EOF &&\n    ++\ttest \"$#\" = 4 &&\n    ++\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >hook.actual\n    ++\tEOF\n    ++\tmkdir -p .git/worktrees/broken &&\n    ++\t: >.git/worktrees/broken/gitdir &&\n    ++\tgit worktree prune &&\n    ++\techo \"[remove][broken][][]\" >hook.expect &&\n    ++\ttest_cmp hook.expect hook.actual\n     +'\n     +\n    -+test_expect_success 'failing post-worktree-move hook leaves worktree moved' '\n    -+\ttest_hook post-worktree-move <<-\\EOF &&\n    ++test_expect_success 'failing post-worktree hook does not skip other pruned entries' '\n    ++\ttest_hook post-worktree <<-\\EOF &&\n    ++\ttest \"$1\" = remove || exit 0\n    ++\techo \"$2\" >>hook.actual\n     +\texit 1\n     +\tEOF\n    -+\tgit worktree add --detach hook-failing-source &&\n    -+\ttest_must_fail git worktree move hook-failing-source hook-failing-destination &&\n    -+\ttest_path_is_missing hook-failing-source &&\n    -+\tgit -C hook-failing-destination status --porcelain >actual &&\n    -+\ttest_must_be_empty actual\n    ++\tgit worktree add --detach doomed &&\n    ++\tgit worktree add --detach doomed2 &&\n    ++\trm -rf doomed doomed2 hook.actual &&\n    ++\ttest_must_fail git worktree prune &&\n    ++\ttest_path_is_missing .git/worktrees/doomed &&\n    ++\ttest_path_is_missing .git/worktrees/doomed2 &&\n    ++\ttest_write_lines doomed doomed2 >hook.expect &&\n    ++\tsort hook.actual >hook.sorted &&\n    ++\ttest_cmp hook.expect hook.sorted\n     +'\n     +\n    - test_expect_success 'move main worktree' '\n    - \ttest_must_fail git worktree move . def\n    - '\n    ++test_expect_success 'prune duplicate invokes post-worktree remove event' '\n    ++\ttest_when_finished rm -fr .git/worktrees w1 w2 &&\n    ++\ttest_hook post-worktree <<-\\EOF &&\n    ++\ttest \"$1\" = remove || exit 0\n    ++\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >>hook.actual\n    ++\tEOF\n    ++\trm -f hook.actual &&\n    ++\tgit worktree add --detach w1 &&\n    ++\tgit worktree add --detach w2 &&\n    ++\tsed \"s/w2/w1/\" .git/worktrees/w2/gitdir >.git/worktrees/w2/gitdir.new &&\n    ++\tmv .git/worktrees/w2/gitdir.new .git/worktrees/w2/gitdir &&\n    ++\tgit worktree prune &&\n    ++\tprintf \"[remove][w2][%s][]\\n\" \"$(pwd)/w1\" >hook.expect &&\n    ++\ttest_cmp hook.expect hook.actual\n    ++'\n    ++\n    ++test_expect_success 'post-worktree remove gets absolute path with relative worktrees' '\n    ++\ttest_when_finished \"rm -rf relhook\" &&\n    ++\tgit init relhook &&\n    ++\ttest_commit -C relhook base &&\n    ++\ttest_hook -C relhook post-worktree <<-\\EOF &&\n    ++\ttest \"$1\" = remove || exit 0\n    ++\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >hook.actual\n    ++\tEOF\n    ++\tgit -C relhook worktree add --relative-paths --detach wt &&\n    ++\trm -rf relhook/wt &&\n    ++\tgit -C relhook worktree prune &&\n    ++\tprintf \"[remove][wt][%s][]\\n\" \"$(pwd)/relhook/wt\" >hook.expect &&\n    ++\ttest_cmp hook.expect relhook/hook.actual\n    ++'\n    ++\n    + test_expect_success 'not prune proper worktrees inside linked worktree with relative paths' '\n    + \ttest_when_finished rm -rf repo wt_ext &&\n    + \tgit init repo &&\n    +\n    + ## worktree.c ##\n    +@@ worktree.c: int should_prune_worktree(struct repository *repo,\n    + \t\tif (stat(file.buf, &st) || st.st_mtime <= expire) {\n    + \t\t\tstrbuf_addstr(reason, _(\"gitdir file points to non-existent location\"));\n    + \t\t\trc = 1;\n    +-\t\t\tgoto done;\n    + \t\t}\n    + \t}\n    + \t*wtpath = strbuf_detach(&dotgit, NULL);\n    +\n    + ## worktree.h ##\n    +@@ worktree.h: const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire);\n    + \n    + /*\n    +  * Return true if worktree entry should be pruned, along with the reason for\n    +- * pruning. Otherwise, return false and the worktree's path in `wtpath`, or\n    +- * NULL if it cannot be determined. Caller is responsible for freeing\n    +- * returned path.\n    ++ * pruning. Otherwise, return false. In both cases the path of the\n    ++ * worktree's `.git` file is returned in `wtpath`, or NULL if it cannot\n    ++ * be determined. Caller is responsible for freeing returned path.\n    +  *\n    +  * `expire` defines a grace period to prune the worktree when its path\n    +  * does not exist.\n-- \n2.54.0\n"},{"id":"554134","messageId":"f7ead9bc-fe6e-49c1-bb7d-6efd14eb6766@mtasv.net","threadId":"65964","inReplyTo":"cover.1791152172.git.domen@cachix.org","subject":"[PATCH v3 2/2] worktree: notify post-worktree hook when pruning","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-10-04T23:09:08Z","receivedAt":"2026-10-04T23:12:23Z","isPatch":true,"body":"A worktree can disappear without git worktree remove, for example when\nits directory is deleted manually. Tools maintaining per-worktree state\nneed to observe its later deregistration by git worktree prune as well.\nGit knows which entries it prunes, including duplicates, whereas a\nwrapper comparing worktree listings can race concurrent operations and\nhas limited information about damaged entries.\n\nEmit a post-worktree remove event for each pruned administrative entry,\nwith its identifier and former absolute path. Return the recorded .git\npath from should_prune_worktree() even when it points to a missing\nlocation, so the hook can receive the former worktree path. If the path\ncannot be determined, pass an empty string instead.\n\nDo not invoke the hook during a dry run. Reflect hook failures in the\ncommand's exit status while continuing to process the remaining entries.\nDocument pruning and test missing paths, duplicate entries, relative\npaths, dry runs, and failures that must not suppress other notifications.\n\nCo-authored-by: Claude Fable 5 <noreply@anthropic.com>\nSigned-off-by: Domen Kožar <domen@cachix.org>\n---\n Documentation/githooks.adoc |   7 ++-\n builtin/worktree.c          |  49 ++++++++++++-----\n t/t2401-worktree-prune.sh   | 102 ++++++++++++++++++++++++++++++++++++\n worktree.c                  |   1 -\n worktree.h                  |   6 +--\n 5 files changed, 146 insertions(+), 19 deletions(-)\n\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 3e25f769c5..a625e95eff 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -219,7 +219,8 @@ post-worktree\n ~~~~~~~~~~~~~\n \n This hook is invoked by linkgit:git-worktree[1] after a working tree is\n-added, moved, or removed. It takes four parameters: the event (`add`, `move`,\n+added, moved, or removed, and once for each entry removed by\n+`git worktree prune`. It takes four parameters: the event (`add`, `move`,\n or `remove`), the worktree identifier (the name of its administrative\n directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the\n new absolute path.\n@@ -231,7 +232,9 @@ The parameters for each event are:\n     post-worktree remove <id> <old-path> \"\"\n \n The empty strings are passed as arguments, so all events have exactly\n-four parameters.\n+four parameters. For entries pruned by `git worktree prune`, the old path\n+may also be empty if it cannot be determined from the administrative\n+files. No hook is run for `git worktree prune --dry-run`.\n \n The hook runs in the repository where the command was invoked, following\n the working directory and environment rules described above. It does not\ndiff --git a/builtin/worktree.c b/builtin/worktree.c\nindex 0f2748080c..d2319a1991 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -177,12 +177,27 @@ static int run_post_worktree_hook(const char *event, const char *id,\n \treturn run_hooks_opt(the_repository, \"post-worktree\", &hook_opt);\n }\n \n-static void prune_worktree(const char *id, const char *reason)\n+static int prune_worktree(const char *id, const char *dotgit,\n+\t\t\t  const char *reason)\n {\n+\tstruct strbuf path = STRBUF_INIT;\n+\tint ret;\n+\n \tif (show_only || verbose)\n \t\tfprintf_ln(stderr, _(\"Removing %s/%s: %s\"), \"worktrees\", id, reason);\n-\tif (!show_only)\n-\t\tdelete_git_dir(id);\n+\tif (show_only)\n+\t\treturn 0;\n+\n+\tdelete_git_dir(id);\n+\n+\t/* path stays empty when the worktree path cannot be determined */\n+\tif (dotgit) {\n+\t\tstrbuf_addstr(&path, dotgit);\n+\t\tstrbuf_strip_suffix(&path, \"/.git\");\n+\t}\n+\tret = run_post_worktree_hook(\"remove\", id, path.buf, \"\");\n+\tstrbuf_release(&path);\n+\treturn ret;\n }\n \n static int prune_cmp(const void *a, const void *b)\n@@ -207,18 +222,22 @@ static int prune_cmp(const void *a, const void *b)\n \treturn strcmp(x->util, y->util);\n }\n \n-static void prune_dups(struct string_list *l)\n+static int prune_dups(struct string_list *l)\n {\n \tint i;\n+\tint ret = 0;\n \n \tQSORT(l->items, l->nr, prune_cmp);\n \tfor (i = 1; i < l->nr; i++) {\n \t\tif (!fspathcmp(l->items[i].string, l->items[i - 1].string))\n-\t\t\tprune_worktree(l->items[i].util, \"duplicate entry\");\n+\t\t\tret |= prune_worktree(l->items[i].util,\n+\t\t\t\t\t      l->items[i].string,\n+\t\t\t\t\t      \"duplicate entry\");\n \t}\n+\treturn ret;\n }\n \n-static void prune_worktrees(void)\n+static int prune_worktrees(void)\n {\n \tstruct strbuf reason = STRBUF_INIT;\n \tstruct strbuf main_path = STRBUF_INIT;\n@@ -226,19 +245,23 @@ static void prune_worktrees(void)\n \tchar *path;\n \tDIR *dir;\n \tstruct dirent *d;\n+\tint ret = 0;\n \n \tpath = repo_git_path(the_repository, \"worktrees\");\n \tdir = opendir(path);\n \tfree(path);\n \tif (!dir)\n-\t\treturn;\n+\t\treturn 0;\n \twhile ((d = readdir_skip_dot_and_dotdot(dir)) != NULL) {\n \t\tchar *path;\n \t\tstrbuf_reset(&reason);\n-\t\tif (should_prune_worktree(the_repository, d->d_name, &reason, &path, expire))\n-\t\t\tprune_worktree(d->d_name, reason.buf);\n-\t\telse if (path)\n+\t\tif (should_prune_worktree(the_repository, d->d_name,\n+\t\t\t\t\t  &reason, &path, expire)) {\n+\t\t\tret |= prune_worktree(d->d_name, path, reason.buf);\n+\t\t\tfree(path);\n+\t\t} else if (path) {\n \t\t\tstring_list_append_nodup(&kept, path)->util = xstrdup(d->d_name);\n+\t\t}\n \t}\n \tclosedir(dir);\n \n@@ -246,12 +269,13 @@ static void prune_worktrees(void)\n \t/* massage main worktree absolute path to match 'gitdir' content */\n \tstrbuf_strip_suffix(&main_path, \"/.\");\n \tstring_list_append_nodup(&kept, strbuf_detach(&main_path, NULL));\n-\tprune_dups(&kept);\n+\tret |= prune_dups(&kept);\n \tstring_list_clear(&kept, 1);\n \n \tif (!show_only)\n \t\tdelete_worktrees_dir_if_empty();\n \tstrbuf_release(&reason);\n+\treturn ret;\n }\n \n static int prune(int ac, const char **av, const char *prefix,\n@@ -270,8 +294,7 @@ static int prune(int ac, const char **av, const char *prefix,\n \t\t\t   0);\n \tif (ac)\n \t\tusage_with_options(git_worktree_prune_usage, options);\n-\tprune_worktrees();\n-\treturn 0;\n+\treturn prune_worktrees();\n }\n \n static char *junk_work_tree;\ndiff --git a/t/t2401-worktree-prune.sh b/t/t2401-worktree-prune.sh\nindex f8f28c76ee..c863575b2f 100755\n--- a/t/t2401-worktree-prune.sh\n+++ b/t/t2401-worktree-prune.sh\n@@ -119,6 +119,108 @@ test_expect_success 'prune duplicate (main/linked)' '\n \ttest_path_is_missing .git/worktrees/wt\n '\n \n+test_expect_success 'prune invokes post-worktree remove event' '\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 || exit 1\n+\ttest \"$1\" = remove || exit 0\n+\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >hook.actual\n+\tEOF\n+\tgit worktree add --detach flushed &&\n+\trm -rf flushed &&\n+\tgit worktree prune &&\n+\tprintf \"[remove][flushed][%s][]\\n\" \"$(pwd)/flushed\" >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'prune invokes post-worktree once per worktree' '\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 || exit 1\n+\ttest \"$1\" = remove || exit 0\n+\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >>hook.actual\n+\tEOF\n+\tgit worktree add --detach first &&\n+\tgit worktree add --detach second &&\n+\trm -rf first second hook.actual &&\n+\tgit worktree prune &&\n+\t{\n+\t\tprintf \"[remove][first][%s][]\\n\" \"$(pwd)/first\" &&\n+\t\tprintf \"[remove][second][%s][]\\n\" \"$(pwd)/second\"\n+\t} >hook.expect &&\n+\tsort hook.actual >hook.sorted &&\n+\ttest_cmp hook.expect hook.sorted\n+'\n+\n+test_expect_success 'prune --dry-run does not invoke post-worktree hook' '\n+\tgit worktree add --detach dry &&\n+\trm -rf dry &&\n+\ttest_when_finished \"git worktree prune\" &&\n+\ttest_hook post-worktree <<-\\EOF &&\n+\t>hook.ran\n+\tEOF\n+\tgit worktree prune --dry-run &&\n+\ttest_path_is_missing hook.ran\n+'\n+\n+test_expect_success 'pruned entry with unknown path gives empty hook argument' '\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$#\" = 4 &&\n+\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >hook.actual\n+\tEOF\n+\tmkdir -p .git/worktrees/broken &&\n+\t: >.git/worktrees/broken/gitdir &&\n+\tgit worktree prune &&\n+\techo \"[remove][broken][][]\" >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'failing post-worktree hook does not skip other pruned entries' '\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$1\" = remove || exit 0\n+\techo \"$2\" >>hook.actual\n+\texit 1\n+\tEOF\n+\tgit worktree add --detach doomed &&\n+\tgit worktree add --detach doomed2 &&\n+\trm -rf doomed doomed2 hook.actual &&\n+\ttest_must_fail git worktree prune &&\n+\ttest_path_is_missing .git/worktrees/doomed &&\n+\ttest_path_is_missing .git/worktrees/doomed2 &&\n+\ttest_write_lines doomed doomed2 >hook.expect &&\n+\tsort hook.actual >hook.sorted &&\n+\ttest_cmp hook.expect hook.sorted\n+'\n+\n+test_expect_success 'prune duplicate invokes post-worktree remove event' '\n+\ttest_when_finished rm -fr .git/worktrees w1 w2 &&\n+\ttest_hook post-worktree <<-\\EOF &&\n+\ttest \"$1\" = remove || exit 0\n+\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >>hook.actual\n+\tEOF\n+\trm -f hook.actual &&\n+\tgit worktree add --detach w1 &&\n+\tgit worktree add --detach w2 &&\n+\tsed \"s/w2/w1/\" .git/worktrees/w2/gitdir >.git/worktrees/w2/gitdir.new &&\n+\tmv .git/worktrees/w2/gitdir.new .git/worktrees/w2/gitdir &&\n+\tgit worktree prune &&\n+\tprintf \"[remove][w2][%s][]\\n\" \"$(pwd)/w1\" >hook.expect &&\n+\ttest_cmp hook.expect hook.actual\n+'\n+\n+test_expect_success 'post-worktree remove gets absolute path with relative worktrees' '\n+\ttest_when_finished \"rm -rf relhook\" &&\n+\tgit init relhook &&\n+\ttest_commit -C relhook base &&\n+\ttest_hook -C relhook post-worktree <<-\\EOF &&\n+\ttest \"$1\" = remove || exit 0\n+\tprintf \"[%s][%s][%s][%s]\\n\" \"$@\" >hook.actual\n+\tEOF\n+\tgit -C relhook worktree add --relative-paths --detach wt &&\n+\trm -rf relhook/wt &&\n+\tgit -C relhook worktree prune &&\n+\tprintf \"[remove][wt][%s][]\\n\" \"$(pwd)/relhook/wt\" >hook.expect &&\n+\ttest_cmp hook.expect relhook/hook.actual\n+'\n+\n test_expect_success 'not prune proper worktrees inside linked worktree with relative paths' '\n \ttest_when_finished rm -rf repo wt_ext &&\n \tgit init repo &&\ndiff --git a/worktree.c b/worktree.c\nindex 8cb8637b18..f6abad62a7 100644\n--- a/worktree.c\n+++ b/worktree.c\n@@ -1016,7 +1016,6 @@ int should_prune_worktree(struct repository *repo,\n \t\tif (stat(file.buf, &st) || st.st_mtime <= expire) {\n \t\t\tstrbuf_addstr(reason, _(\"gitdir file points to non-existent location\"));\n \t\t\trc = 1;\n-\t\t\tgoto done;\n \t\t}\n \t}\n \t*wtpath = strbuf_detach(&dotgit, NULL);\ndiff --git a/worktree.h b/worktree.h\nindex fbb2757f5b..30699b2998 100644\n--- a/worktree.h\n+++ b/worktree.h\n@@ -106,9 +106,9 @@ const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire);\n \n /*\n  * Return true if worktree entry should be pruned, along with the reason for\n- * pruning. Otherwise, return false and the worktree's path in `wtpath`, or\n- * NULL if it cannot be determined. Caller is responsible for freeing\n- * returned path.\n+ * pruning. Otherwise, return false. In both cases the path of the\n+ * worktree's `.git` file is returned in `wtpath`, or NULL if it cannot\n+ * be determined. Caller is responsible for freeing returned path.\n  *\n  * `expire` defines a grace period to prune the worktree when its path\n  * does not exist.\n-- \n2.54.0\n"},{"id":"554201","messageId":"xmqqwlrwjdr4.fsf@gitster.g","threadId":"65964","inReplyTo":"d550ede0-6a33-4eea-a6dd-051d110b68e5@mtasv.net","subject":"Re: [PATCH v3 0/2] worktree: add post-worktree lifecycle hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-10-05T16:47:27Z","receivedAt":"2026-10-05T16:47:27Z","isPatch":true,"body":"The topic _claims_ to have been sent with\n\n    X-Mailer: git-send-email 2.54.0\n\nbut it seems that the thread structure is so screwed up that some\nautomation machinery I use seems to be having trouble locating its\nthread.\n\nHere is what I see:\n\n* Cover letter\n  Subject: [PATCH v3 0/2] worktree: add post-worktree lifecycle hook\n  Message-Id: <d550ede0-6a33-4eea-a6dd-051d110b68e5@mtasv.net>\n  In-Reply-To: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>\n  References: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>\n\n* First patch\n  Subject: [PATCH v3 1/2] worktree: add post-worktree lifecycle hook\n  Message-Id: <2c1c1f06-05e7-4d8c-bd29-c2a9708b443d@mtasv.net>\n  In-Reply-To: <cover.1791152172.git.domen@cachix.org>\n  References: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>\n   <cover.1791152172.git.domen@cachix.org>\n\n* Second patch\n  Subject: [PATCH v3 2/2] worktree: notify post-worktree hook when pruning\n  Message-Id: <f7ead9bc-fe6e-49c1-bb7d-6efd14eb6766@mtasv.net>\n  In-Reply-To: <cover.1791152172.git.domen@cachix.org>\n  References: <371a01cf-2765-4cf5-b1fd-414d1b55a325@mtasv.net>\n   <cover.1791152172.git.domen@cachix.org>\n\nNotice that the \"cover letter\" that are named on the In-Reply-To:\nheader of the two patches do not match the cover letter message at\nall?  I actually doubt the <cover.1791152172.git.domen@cachix.org>\nmessage appears anywhere in the list archive.\n\n    ... goes and visits the URL and gets \"not found\" ...\n    https://lore.kernel.org/git/cover.1791152172.git.domen@cachix.org/\n\nSomebody should find out how send-email is misused to produce such a\nbroken threading, and add some documentation to the send-email\nmanual describing what _not_ to do, and/or update send-email code to\ndetect such misconfiguration that caused it.\n\nThanks.\n\n"},{"id":"554252","messageId":"71131749-b624-4c81-bae2-c32a7d3f1a15@gmail.com","threadId":"65964","inReplyTo":"2c1c1f06-05e7-4d8c-bd29-c2a9708b443d@mtasv.net","subject":"Re: [PATCH v3 1/2] worktree: add post-worktree lifecycle hook","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-10-06T09:23:19Z","receivedAt":"2026-10-06T09:23:19Z","isPatch":true,"body":"Hi Domen\n\nOn 05/10/2026 00:09, Domen Kožar wrote:\n> Tools that manage per-worktree development environments need to observe\n> worktrees created, moved, or removed by other programs. Wrapping the\n> worktree command only helps when every caller uses the wrapper, and\n> post-checkout does not run for add --no-checkout or --orphan.\n\nThere is quite a lot of implicit context in that sentance - it assumes the reader has read the previous discussions about this hook. It would be clearer if it was explicit that it was talking about (a) using a wrapper script to perform additional operations around \"git worktree\" and (b) using the post-checkout hook as a proxy for detecting when a new worktree is added.\n\n> There is no notification for moving or removing a worktree.\n\nA wrapper can do that though?\n\n> Add one post-worktree hook for these operations. Pass the event name,\n> worktree identifier, old absolute path, and new absolute path as four\n> arguments, using an empty string for a path that does not apply.\n\nAs I've said before, I don't think the worktree id is very useful because git commands do not take it as an argument. Someone mentioned wanting to use it as a key to look up other information about the worktree, but different repositories can have worktrees with the same id so I think we'd be better passing the worktree's absolute git-dir.\n\nThere was also a comment from someone pointing out that notifying callers after the worktree is removed maybe too late for them to clean up the resources associated with that worktree. If we had a \"worktree-event\" hook we could run it before the worktree is removed, but after a worktree is added or moved.\n\nHaving four arguments for add and remove seems to me to be unecessarily complicated - why not just pass the paths that are relavent, rather than padding the arguments with empty strings?\n\nI see the hook is run serially, is that really necessary?\n\n> An\n> explicit event name lets one handler manage the whole lifecycle without\n> using argument count to distinguish operations, as the earlier series\n> with three separate hooks did.\n> > Run the hook in the invoking repository with its normal environment,\n> rather than changing to the affected worktree. Passing both paths lets\n> handlers target the new worktree when needed and keeps the execution\n> context consistent when a worktree has been removed.\n\nIt also means that GIT_DIR and GIT_WORK_TREE are consistent with the directory that the hook is run in. The downside for adding a worktree is that the hook needs to clear those variables if it is going to run git in the new worktree.\n\n> Run the add event after post-checkout even when that hook fails, because\n> the worktree remains present. A failing lifecycle hook affects the\n> command's exit status without undoing the completed operation.\n\nOk, that matches what we do with a failing post-checkout hook\n> Preserve post-checkout's failure status if both hooks fail.\n\nOk\n\n> Document the interface and cover ordinary, bare, and linked callers,\n> no-checkout and orphan worktrees, relative paths, paths with spaces,\n> configured hooks, and hook failures.\n\nI'm not sure why we need separate tests for paths with spaces or orphan worktrees\n\nI think adding a hook for worktree lifecycle events is a useful addition. Using a wrapper script is a pain because it is easy to forget to use it and it is hard to make it work if you have several different entities that want to be notified about worktrees being added or removed.\n\nThanks\n\nPhillip\n\n> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>\n> Signed-off-by: Domen Kožar <domen@cachix.org>\n> ---\n>   Documentation/config/hook.adoc |   1 +\n>   Documentation/githooks.adoc    |  43 +++++++++++\n>   builtin/worktree.c             |  56 ++++++++++----\n>   t/t2400-worktree-add.sh        | 132 +++++++++++++++++++++++++++++++++\n>   t/t2403-worktree-move.sh       | 113 ++++++++++++++++++++++++++++\n>   5 files changed, 329 insertions(+), 16 deletions(-)\n> > diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc\n> index 083dc60a13..501bb006f5 100644\n> --- a/Documentation/config/hook.adoc\n> +++ b/Documentation/config/hook.adoc\n> @@ -94,6 +94,7 @@ hook.jobs::\n>   \tReceive a commit message file and may rewrite it in place.\n>   `pre-commit`;;\n>   `post-checkout`;;\n> +`post-worktree`;;\n>   `push-to-checkout`;;\n>   `post-commit`;;\n>   \tAccess the working tree, index, or repository state.\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index 145642bf05..3e25f769c5 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -215,6 +215,49 @@ This hook can be used to perform repository validity checks, auto-display\n>   differences from the previous HEAD if different, or set working dir metadata\n>   properties.\n>   > +post-worktree\n> +~~~~~~~~~~~~~\n> +\n> +This hook is invoked by linkgit:git-worktree[1] after a working tree is\n> +added, moved, or removed. It takes four parameters: the event (`add`, `move`,\n> +or `remove`), the worktree identifier (the name of its administrative\n> +directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the\n> +new absolute path.\n> +\n> +The parameters for each event are:\n> +\n> +    post-worktree add    <id> \"\"         <new-path>\n> +    post-worktree move   <id> <old-path> <new-path>\n> +    post-worktree remove <id> <old-path> \"\"\n> +\n> +The empty strings are passed as arguments, so all events have exactly\n> +four parameters.\n> +\n> +The hook runs in the repository where the command was invoked, following\n> +the working directory and environment rules described above. It does not\n> +change to the added or moved working tree. To run Git commands there,\n> +clear the repository environment variables and use the new path, for\n> +example:\n> +\n> +------------\n> +(unset $(git rev-parse --local-env-vars); git -C \"$4\" status)\n> +------------\n> +\n> +The `add` event runs after the new working tree has been set up, including\n> +with `--no-checkout` and `--orphan`. It runs after `post-checkout`, even\n> +if that hook fails. The `move` event runs after the working tree and its\n> +administrative files have been moved. The `remove` event runs after the\n> +working tree has been deleted or its administrative entry removed.\n> +\n> +The hook cannot undo the worktree operation. A non-zero exit status is\n> +reflected in the command's exit status, but leaves the completed operation\n> +in place. If `post-checkout` fails during `git worktree add`, its exit\n> +status takes precedence over that of `post-worktree`.\n> +\n> +This hook can be used to set up, relocate, or tear down per-worktree\n> +development environments, or to maintain registrations with external\n> +tools. Hook scripts should ignore events they do not handle.\n> +\n>   post-merge\n>   ~~~~~~~~~~\n>   > diff --git a/builtin/worktree.c b/builtin/worktree.c\n> index 77ecd0f71f..0f2748080c 100644\n> --- a/builtin/worktree.c\n> +++ b/builtin/worktree.c\n> @@ -168,6 +168,15 @@ static void delete_worktrees_dir_if_empty(void)\n>   \tfree(path);\n>   }\n>   > +static int run_post_worktree_hook(const char *event, const char *id,\n> +\t\t\t\t  const char *old_path, const char *new_path)\n> +{\n> +\tstruct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n> +\n> +\tstrvec_pushl(&hook_opt.args, event, id, old_path, new_path, NULL);\n> +\treturn run_hooks_opt(the_repository, \"post-worktree\", &hook_opt);\n> +}\n> +\n>   static void prune_worktree(const char *id, const char *reason)\n>   {\n>   \tif (show_only || verbose)\n> @@ -604,21 +613,30 @@ static int add_worktree(const char *path, const char *refname,\n>   \t}\n>   >   \t/*\n> -\t * Hook failure does not warrant worktree deletion, so run hook after\n> -\t * is_junk is cleared, but do return appropriate code when hook fails.\n> +\t * Hook failures do not warrant worktree deletion, so run hooks after\n> +\t * is_junk is cleared, but do return appropriate code when a hook\n> +\t * fails.\n>   \t */\n> -\tif (!ret && opts->checkout && !opts->orphan) {\n> -\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n> -\n> -\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n> -\t\tstrvec_pushl(&opt.args,\n> -\t\t\t     oid_to_hex(null_oid(the_hash_algo)),\n> -\t\t\t     oid_to_hex(&commit->object.oid),\n> -\t\t\t     \"1\",\n> -\t\t\t     NULL);\n> -\t\topt.dir = path;\n> -\n> -\t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n> +\tif (!ret) {\n> +\t\tint hook_ret;\n> +\n> +\t\tif (opts->checkout && !opts->orphan) {\n> +\t\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;\n> +\n> +\t\t\tstrvec_pushl(&opt.env, \"GIT_DIR\", \"GIT_WORK_TREE\", NULL);\n> +\t\t\tstrvec_pushl(&opt.args,\n> +\t\t\t\t     oid_to_hex(null_oid(the_hash_algo)),\n> +\t\t\t\t     oid_to_hex(&commit->object.oid),\n> +\t\t\t\t     \"1\",\n> +\t\t\t\t     NULL);\n> +\t\t\topt.dir = path;\n> +\n> +\t\t\tret = run_hooks_opt(the_repository, \"post-checkout\", &opt);\n> +\t\t}\n> +\n> +\t\thook_ret = run_post_worktree_hook(\"add\", wt->id, \"\", wt->path);\n> +\t\tif (!ret)\n> +\t\t\tret = hook_ret;\n>   \t}\n>   >   \tstrvec_clear(&child_env);\n> @@ -1305,7 +1323,8 @@ static int move_worktree(int ac, const char **av, const char *prefix,\n>   \tstruct strbuf dst = STRBUF_INIT;\n>   \tstruct strbuf errmsg = STRBUF_INIT;\n>   \tconst char *reason = NULL;\n> -\tchar *path;\n> +\tchar *old_path, *path;\n> +\tint ret;\n>   >   \tac = parse_options(ac, av, prefix, options, git_worktree_move_usage,\n>   \t\t\t   0);\n> @@ -1348,14 +1367,17 @@ static int move_worktree(int ac, const char **av, const char *prefix,\n>   \t\t    errmsg.buf);\n>   \tstrbuf_release(&errmsg);\n>   > +\told_path = xstrdup(wt->path);\n>   \tif (rename(wt->path, dst.buf) == -1)\n>   \t\tdie_errno(_(\"failed to move '%s' to '%s'\"), wt->path, dst.buf);\n>   >   \tupdate_worktree_location(wt, dst.buf, use_relative_paths);\n> +\tret = run_post_worktree_hook(\"move\", wt->id, old_path, wt->path);\n>   > +\tfree(old_path);\n>   \tstrbuf_release(&dst);\n>   \tfree_worktrees(worktrees);\n> -\treturn 0;\n> +\treturn ret;\n>   }\n>   >   /*\n> @@ -1473,6 +1495,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix,\n>   \tret |= delete_git_dir(wt->id);\n>   \tdelete_worktrees_dir_if_empty();\n>   > +\tret |= run_post_worktree_hook(\"remove\", wt->id, wt->path, \"\");\n> +\n>   \tfree_worktrees(worktrees);\n>   \treturn ret;\n>   }\n> diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh\n> index bdcca97633..65fec976b5 100755\n> --- a/t/t2400-worktree-add.sh\n> +++ b/t/t2400-worktree-add.sh\n> @@ -1172,6 +1172,138 @@ test_expect_success '\"add\" in bare repo invokes post-checkout hook' '\n>   \ttest_cmp hook.expect goozy/hook.actual\n>   '\n>   > +# Install a post-worktree hook and write the output expected for adding\n> +# worktree $1. Repo $2 defaults to \".\"; the caller worktree is $3.\n> +post_worktree_add_hook () {\n> +\ttest_when_finished \"rm -rf .git/hooks\" &&\n> +\tmkdir .git/hooks &&\n> +\ttest_hook -C \"$2\" post-worktree <<-\\EOF &&\n> +\ttest \"$#\" = 4 &&\n> +\t{\n> +\t\tprintf \"%s\\n\" \"$@\" &&\n> +\t\ttest-tool path-utils real_path . &&\n> +\t\tgit rev-parse --absolute-git-dir\n> +\t} >hook.actual\n> +\tEOF\n> +\t{\n> +\t\ttest_write_lines add \"$1\" \"\" \"$(pwd)/$1\" &&\n> +\t\t(cd \"${3:-${2:-.}}\" && test-tool path-utils real_path .) &&\n> +\t\tgit -C \"${3:-${2:-.}}\" rev-parse --absolute-git-dir\n> +\t} >hook.expect\n> +}\n> +\n> +test_expect_success '\"add\" invokes post-worktree hook' '\n> +\tpost_worktree_add_hook wanda &&\n> +\tgit worktree add wanda &&\n> +\ttest_cmp hook.expect hook.actual\n> +'\n> +\n> +test_expect_success '\"add\" in other worktree invokes post-worktree hook there' '\n> +\tpost_worktree_add_hook wilbur \"\" wanda &&\n> +\tgit -C wanda worktree add ../wilbur &&\n> +\ttest_cmp hook.expect wanda/hook.actual\n> +'\n> +\n> +test_expect_success '\"add --no-checkout\" still invokes post-worktree hook' '\n> +\tpost_worktree_add_hook wendy &&\n> +\tgit worktree add --no-checkout wendy &&\n> +\ttest_cmp hook.expect hook.actual\n> +'\n> +\n> +test_expect_success '\"add --orphan\" invokes post-worktree hook' '\n> +\tpost_worktree_add_hook winnie &&\n> +\tgit worktree add --orphan winnie &&\n> +\ttest_cmp hook.expect hook.actual\n> +'\n> +\n> +test_expect_success '\"add\" in bare repo invokes post-worktree hook there' '\n> +\trm -rf bare2 &&\n> +\tgit clone --bare . bare2 &&\n> +\tpost_worktree_add_hook willow bare2 &&\n> +\tgit -C bare2 worktree add --detach ../willow &&\n> +\ttest_cmp hook.expect bare2/hook.actual\n> +'\n> +\n> +test_expect_success '\"add\" runs post-worktree after post-checkout' '\n> +\ttest_when_finished \"rm -rf .git/hooks\" &&\n> +\tmkdir .git/hooks &&\n> +\ttest_hook post-checkout <<-\\EOF &&\n> +\techo post-checkout >>\"$(git rev-parse --git-common-dir)/hooks.actual\"\n> +\tEOF\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\techo post-worktree >>\"$(git rev-parse --git-common-dir)/hooks.actual\"\n> +\tEOF\n> +\ttest_write_lines post-checkout post-worktree >hooks.expect &&\n> +\tgit worktree add wobble &&\n> +\ttest_cmp hooks.expect .git/hooks.actual\n> +'\n> +\n> +test_expect_success 'failing post-checkout hook does not suppress post-worktree hook' '\n> +\ttest_when_finished \"rm -rf .git/hooks\" &&\n> +\tmkdir .git/hooks &&\n> +\ttest_hook post-checkout <<-\\EOF &&\n> +\texit 2\n> +\tEOF\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\t>post-worktree.ran &&\n> +\texit 3\n> +\tEOF\n> +\ttest_expect_code 2 git worktree add wozzle &&\n> +\ttest_path_is_file post-worktree.ran\n> +'\n> +\n> +test_expect_success 'failing post-worktree hook leaves worktree in place' '\n> +\ttest_when_finished \"rm -rf .git/hooks\" &&\n> +\tmkdir .git/hooks &&\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\texit 1\n> +\tEOF\n> +\ttest_expect_code 1 git worktree add wilma &&\n> +\tgit worktree list --porcelain >out &&\n> +\ttest_grep -F \"worktree $(pwd)/wilma\" out\n> +'\n> +\n> +test_expect_success 'failed \"add\" does not invoke post-worktree hook' '\n> +\ttest_when_finished \"rm -rf .git/hooks occupied\" &&\n> +\tmkdir .git/hooks &&\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\t>hook.ran\n> +\tEOF\n> +\tmkdir occupied &&\n> +\t: >occupied/blocker &&\n> +\ttest_must_fail git worktree add occupied &&\n> +\ttest_path_is_missing hook.ran\n> +'\n> +\n> +test_expect_success 'post-worktree add gets absolute path with relative worktrees' '\n> +\ttest_when_finished \"rm -rf relhook\" &&\n> +\tgit init relhook &&\n> +\ttest_commit -C relhook base &&\n> +\ttest_hook -C relhook post-worktree <<-\\EOF &&\n> +\ttest \"$#\" = 4 &&\n> +\tprintf \"%s\\n\" \"$@\" >hook.actual\n> +\tEOF\n> +\tgit -C relhook worktree add --relative-paths --detach wt &&\n> +\ttest_write_lines add wt \"\" \"$(pwd)/relhook/wt\" >hook.expect &&\n> +\ttest_cmp hook.expect relhook/hook.actual\n> +'\n> +\n> +test_expect_success 'configured post-worktree hook preserves paths with spaces' '\n> +\ttest_when_finished \"rm -rf confighook\" &&\n> +\tgit init confighook &&\n> +\ttest_commit -C confighook base &&\n> +\twrite_script confighook/record-hook <<-\\EOF &&\n> +\ttest \"$#\" = 4 &&\n> +\tprintf \"%s\\n\" \"$@\" >hook.actual\n> +\tEOF\n> +\tgit -C confighook config hook.lifecycle.command ./record-hook &&\n> +\tgit -C confighook config hook.lifecycle.event post-worktree &&\n> +\tgit -C confighook worktree add --detach \"wt with spaces\" &&\n> +\tid=$(basename \"$(git -C \"confighook/wt with spaces\" rev-parse --absolute-git-dir)\") &&\n> +\ttest_write_lines add \"$id\" \"\" \"$(pwd)/confighook/wt with spaces\" >hook.expect &&\n> +\ttest_cmp hook.expect confighook/hook.actual\n> +'\n> +\n>   test_expect_success '\"add\" an existing but missing worktree' '\n>   \tgit worktree add --detach pneu &&\n>   \ttest_must_fail git worktree add --detach pneu &&\n> diff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh\n> index 69768c1207..11ef81dce8 100755\n> --- a/t/t2403-worktree-move.sh\n> +++ b/t/t2403-worktree-move.sh\n> @@ -82,6 +82,59 @@ test_expect_success 'move worktree' '\n>   \ttest_cmp expected2 actual2\n>   '\n>   > +test_expect_success '\"move\" invokes post-worktree hook in the calling repository' '\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\ttest \"$#\" = 4 || exit 1\n> +\ttest \"$1\" = move || exit 0\n> +\t{\n> +\t\tprintf \"%s\\n\" \"$@\" &&\n> +\t\ttest-tool path-utils real_path . &&\n> +\t\tgit rev-parse --absolute-git-dir\n> +\t} >hook.actual\n> +\tEOF\n> +\tgit worktree add --detach hook-source &&\n> +\tgit worktree move hook-source hook-destination &&\n> +\t{\n> +\t\ttest_write_lines move hook-source \"$(pwd)/hook-source\" \"$(pwd)/hook-destination\" &&\n> +\t\ttest-tool path-utils real_path . &&\n> +\t\tgit rev-parse --absolute-git-dir\n> +\t} >hook.expect &&\n> +\ttest_cmp hook.expect hook.actual\n> +'\n> +\n> +test_expect_success 'failing post-worktree move event leaves worktree moved' '\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\ttest \"$1\" = move || exit 0\n> +\texit 1\n> +\tEOF\n> +\tgit worktree add --detach hook-failing-source &&\n> +\ttest_must_fail git worktree move hook-failing-source hook-failing-destination &&\n> +\ttest_path_is_missing hook-failing-source &&\n> +\tgit -C hook-failing-destination status --porcelain >actual &&\n> +\ttest_must_be_empty actual\n> +'\n> +\n> +test_expect_success 'post-worktree move keeps the ID and passes absolute paths with spaces' '\n> +\ttest_when_finished \"rm -rf movehook\" &&\n> +\tgit init movehook &&\n> +\ttest_commit -C movehook base &&\n> +\tgit -C movehook worktree add --relative-paths --detach \"source tree\" &&\n> +\tgit -C movehook worktree add --detach caller &&\n> +\tid=$(basename \"$(git -C \"movehook/source tree\" rev-parse --absolute-git-dir)\") &&\n> +\ttest_hook -C movehook post-worktree <<-\\EOF &&\n> +\ttest \"$#\" = 4 &&\n> +\t{\n> +\t\tprintf \"%s\\n\" \"$@\" &&\n> +\t\tgit rev-parse --show-toplevel\n> +\t} >hook.actual\n> +\tEOF\n> +\tgit -C movehook/caller worktree move --relative-paths \"../source tree\" \"../destination tree\" &&\n> +\ttest_write_lines move \"$id\" \"$(pwd)/movehook/source tree\" \\\n> +\t\t\"$(pwd)/movehook/destination tree\" \"$(pwd)/movehook/caller\" >hook.expect &&\n> +\ttest_cmp hook.expect movehook/caller/hook.actual &&\n> +\ttest_path_is_dir \"movehook/destination tree\"\n> +'\n> +\n>   test_expect_success 'move main worktree' '\n>   \ttest_must_fail git worktree move . def\n>   '\n> @@ -246,6 +299,66 @@ test_expect_success 'not remove a repo with initialized submodule' '\n>   \t)\n>   '\n>   > +test_expect_success '\"remove\" invokes post-worktree remove event' '\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\ttest \"$#\" = 4 || exit 1\n> +\ttest \"$1\" = remove || exit 0\n> +\tprintf \"%s\\n\" \"$@\" >hook.actual\n> +\tEOF\n> +\tgit worktree add --detach wt-hooked &&\n> +\tgit worktree remove wt-hooked &&\n> +\ttest_write_lines remove wt-hooked \"$(pwd)/wt-hooked\" \"\" >hook.expect &&\n> +\ttest_cmp hook.expect hook.actual\n> +'\n> +\n> +test_expect_success '\"remove\" of missing worktree invokes post-worktree hook' '\n> +\ttest_when_finished \"rm -rf wt-moved-away\" &&\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\ttest \"$1\" = remove || exit 0\n> +\tprintf \"%s\\n\" \"$@\" >hook.actual\n> +\tEOF\n> +\trm -f hook.actual &&\n> +\tgit worktree add --detach wt-elsewhere &&\n> +\tmv wt-elsewhere wt-moved-away &&\n> +\tgit worktree remove wt-elsewhere &&\n> +\ttest_write_lines remove wt-elsewhere \"$(pwd)/wt-elsewhere\" \"\" >hook.expect &&\n> +\ttest_cmp hook.expect hook.actual\n> +'\n> +\n> +test_expect_success 'refused \"remove\" does not invoke post-worktree hook' '\n> +\tgit worktree add --detach wt-kept &&\n> +\ttest_when_finished \"git worktree remove --force --force wt-kept || :\" &&\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\t>hook.ran\n> +\tEOF\n> +\tgit worktree lock wt-kept &&\n> +\ttest_must_fail git worktree remove wt-kept &&\n> +\ttest_path_is_missing hook.ran\n> +'\n> +\n> +test_expect_success 'failing post-worktree remove event fails \"remove\", worktree is gone' '\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\ttest \"$1\" = remove || exit 0\n> +\texit 1\n> +\tEOF\n> +\tgit worktree add --detach wt-doomed &&\n> +\ttest_must_fail git worktree remove wt-doomed &&\n> +\ttest_path_is_missing wt-doomed &&\n> +\ttest_path_is_missing .git/worktrees/wt-doomed\n> +'\n> +\n> +test_expect_success 'post-worktree remove preserves paths with spaces' '\n> +\tgit worktree add --detach \"remove tree\" &&\n> +\tid=$(basename \"$(git -C \"remove tree\" rev-parse --absolute-git-dir)\") &&\n> +\ttest_hook post-worktree <<-\\EOF &&\n> +\ttest \"$#\" = 4 &&\n> +\tprintf \"%s\\n\" \"$@\" >hook.actual\n> +\tEOF\n> +\tgit worktree remove \"remove tree\" &&\n> +\ttest_write_lines remove \"$id\" \"$(pwd)/remove tree\" \"\" >hook.expect &&\n> +\ttest_cmp hook.expect hook.actual\n> +'\n> +\n>   test_expect_success 'move worktree with absolute path to relative path' '\n>   \ttest_config worktree.useRelativePaths false &&\n>   \tgit worktree add ./absolute &&\n\n\n"}]}