{"thread":{"id":"65963","subject":"[PATCH v1 2/3] worktree: add post-worktree-remove hook","startedAt":"2026-07-09T23:39:13Z","lastAt":"2026-07-09T23:39:19Z","messageCount":3,"participants":["Domen Kožar"],"isPatch":true,"patchVersion":1,"patchTotal":3},"messages":[{"id":"547654","messageId":"746fccc3-8700-4413-96a6-d98da7cf678a@mtasv.net","threadId":"65963","inReplyTo":"20260709233542.628628-1-domen@cachix.org","subject":"[PATCH v1 2/3] worktree: add post-worktree-remove hook","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-07-09T23:36:10Z","receivedAt":"2026-07-09T23:39:13Z","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/githooks.adoc | 18 +++++++++++++++\n builtin/worktree.c          | 10 +++++++++\n t/t2403-worktree-move.sh    | 44 +++++++++++++++++++++++++++++++++++++\n 3 files changed, 72 insertions(+)\n\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 2778f73f30..22b3263ff7 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -235,6 +235,24 @@ runs after the `post-checkout` hook, and is skipped 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 7b9d337234..01b62ed2fc 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -168,6 +168,14 @@ static void delete_worktrees_dir_if_empty(void)\n \tfree(path);\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@@ -1437,6 +1445,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":"547655","messageId":"4864bcf9-1018-4b36-9239-47df247b8418@mtasv.net","threadId":"65963","inReplyTo":"20260709233542.628628-1-domen@cachix.org","subject":"[PATCH v1 1/3] worktree: add post-worktree-add hook","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-07-09T23:36:09Z","receivedAt":"2026-07-09T23:39:14Z","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 is given\nthe absolute path of the new working tree and its identifier as\narguments. Anything else, such as the checked-out branch, can be\nqueried by running git from the hook's working directory.\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 and is skipped if that hook fails.\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/githooks.adoc |  20 +++++++\n builtin/worktree.c          |  15 ++++-\n t/t2400-worktree-add.sh     | 113 ++++++++++++++++++++++++++++++++++++\n 3 files changed, 146 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..2778f73f30 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -215,6 +215,26 @@ 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. 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+\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+\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+\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..7b9d337234 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -605,8 +605,9 @@ 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@@ -622,6 +623,16 @@ 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) {\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, wt->path, wt->id, NULL);\n+\t\topt.dir = path;\n+\n+\t\tret = run_hooks_opt(the_repository, \"post-worktree-add\", &opt);\n+\t}\n+\n \tstrvec_clear(&child_env);\n \tstrbuf_release(&sb);\n \tstrbuf_release(&symref);\ndiff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh\nindex 58b4445cc4..3754559a98 100755\n--- a/t/t2400-worktree-add.sh\n+++ b/t/t2400-worktree-add.sh\n@@ -1132,6 +1132,119 @@ 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+\t{\n+\t\techo $*\n+\t\tgit rev-parse --git-dir --show-toplevel\n+\t} >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+}\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 suppresses 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_missing 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 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-add <<-\\EOF &&\n+\techo $* >hook.actual\n+\tEOF\n+\tgit -C relhook worktree add --relative-paths --detach wt &&\n+\techo $(pwd)/relhook/wt 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":"547657","messageId":"5a8497f0-f3e3-4e03-9a2b-0d7c1ff79358@mtasv.net","threadId":"65963","inReplyTo":"20260709233542.628628-1-domen@cachix.org","subject":"[PATCH v1 3/3] worktree: run post-worktree-remove hook when pruning","fromName":"Domen Kožar","fromEmail":"domen@cachix.org","sentAt":"2026-07-09T23:36:11Z","receivedAt":"2026-07-09T23:39:19Z","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 22b3263ff7..28fab7ccbe 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -239,16 +239,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 01b62ed2fc..e2cdbef8bb 100644\n--- a/builtin/worktree.c\n+++ b/builtin/worktree.c\n@@ -176,12 +176,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@@ -206,18 +221,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@@ -225,19 +244,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@@ -245,12 +267,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@@ -269,8 +292,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"}]}