git/list[1] front-page[2] threads[3] people[4] search[5] about
 

[PATCH v3 2/2] worktree: notify post-worktree hook when pruning

From
Domen Kožar <domen@cachix.org>
Date
Oct 4, 2026, 23:09 UTC
Message-ID
<f7ead9bc-fe6e-49c1-bb7d-6efd14eb6766@mtasv.net>
In-Reply-To
<cover.1791152172.git.domen@cachix.org>

A worktree can disappear without git worktree remove, for example when its directory is deleted manually. Tools maintaining per-worktree state need to observe its later deregistration by git worktree prune as well. Git knows which entries it prunes, including duplicates, whereas a wrapper comparing worktree listings can race concurrent operations and has limited information about damaged entries.

Emit a post-worktree remove event for each pruned administrative entry, with its identifier and former absolute path. Return the recorded .git path from should_prune_worktree() even when it points to a missing location, so the hook can receive the former worktree path. If the path cannot be determined, pass an empty string instead.

Do not invoke the hook during a dry run. Reflect hook failures in the command's exit status while continuing to process the remaining entries. Document pruning and test missing paths, duplicate entries, relative paths, dry runs, and failures that must not suppress other notifications.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Domen Kožar <domen@cachix.org>
---
 Documentation/githooks.adoc |   7 ++-
 builtin/worktree.c          |  49 ++++++++++++-----
 t/t2401-worktree-prune.sh   | 102 ++++++++++++++++++++++++++++++++++++
 worktree.c                  |   1 -
 worktree.h                  |   6 +--
 5 files changed, 146 insertions(+), 19 deletions(-)
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 3e25f769c5..a625e95eff 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -219,7 +219,8 @@ post-worktree
 ~~~~~~~~~~~~~
 
 This hook is invoked by linkgit:git-worktree[1] after a working tree is
-added, moved, or removed. It takes four parameters: the event (`add`, `move`,
+added, moved, or removed, and once for each entry removed by
+`git worktree prune`. It takes four parameters: the event (`add`, `move`,
 or `remove`), the worktree identifier (the name of its administrative
 directory in `$GIT_COMMON_DIR/worktrees/`), the old absolute path, and the
 new absolute path.
@@ -231,7 +232,9 @@ The parameters for each event are:
     post-worktree remove <id> <old-path> ""
 
 The empty strings are passed as arguments, so all events have exactly
-four parameters.
+four parameters. For entries pruned by `git worktree prune`, the old path
+may also be empty if it cannot be determined from the administrative
+files. No hook is run for `git worktree prune --dry-run`.
 
 The hook runs in the repository where the command was invoked, following
 the working directory and environment rules described above. It does not
diff --git a/builtin/worktree.c b/builtin/worktree.c
index 0f2748080c..d2319a1991 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -177,12 +177,27 @@ static int run_post_worktree_hook(const char *event, const char *id,
 	return run_hooks_opt(the_repository, "post-worktree", &hook_opt);
 }
 
-static void prune_worktree(const char *id, const char *reason)
+static int prune_worktree(const char *id, const char *dotgit,
+			  const char *reason)
 {
+	struct strbuf path = STRBUF_INIT;
+	int ret;
+
 	if (show_only || verbose)
 		fprintf_ln(stderr, _("Removing %s/%s: %s"), "worktrees", id, reason);
-	if (!show_only)
-		delete_git_dir(id);
+	if (show_only)
+		return 0;
+
+	delete_git_dir(id);
+
+	/* path stays empty when the worktree path cannot be determined */
+	if (dotgit) {
+		strbuf_addstr(&path, dotgit);
+		strbuf_strip_suffix(&path, "/.git");
+	}
+	ret = run_post_worktree_hook("remove", id, path.buf, "");
+	strbuf_release(&path);
+	return ret;
 }
 
 static int prune_cmp(const void *a, const void *b)
@@ -207,18 +222,22 @@ static int prune_cmp(const void *a, const void *b)
 	return strcmp(x->util, y->util);
 }
 
-static void prune_dups(struct string_list *l)
+static int prune_dups(struct string_list *l)
 {
 	int i;
+	int ret = 0;
 
 	QSORT(l->items, l->nr, prune_cmp);
 	for (i = 1; i < l->nr; i++) {
 		if (!fspathcmp(l->items[i].string, l->items[i - 1].string))
-			prune_worktree(l->items[i].util, "duplicate entry");
+			ret |= prune_worktree(l->items[i].util,
+					      l->items[i].string,
+					      "duplicate entry");
 	}
+	return ret;
 }
 
-static void prune_worktrees(void)
+static int prune_worktrees(void)
 {
 	struct strbuf reason = STRBUF_INIT;
 	struct strbuf main_path = STRBUF_INIT;
@@ -226,19 +245,23 @@ static void prune_worktrees(void)
 	char *path;
 	DIR *dir;
 	struct dirent *d;
+	int ret = 0;
 
 	path = repo_git_path(the_repository, "worktrees");
 	dir = opendir(path);
 	free(path);
 	if (!dir)
-		return;
+		return 0;
 	while ((d = readdir_skip_dot_and_dotdot(dir)) != NULL) {
 		char *path;
 		strbuf_reset(&reason);
-		if (should_prune_worktree(the_repository, d->d_name, &reason, &path, expire))
-			prune_worktree(d->d_name, reason.buf);
-		else if (path)
+		if (should_prune_worktree(the_repository, d->d_name,
+					  &reason, &path, expire)) {
+			ret |= prune_worktree(d->d_name, path, reason.buf);
+			free(path);
+		} else if (path) {
 			string_list_append_nodup(&kept, path)->util = xstrdup(d->d_name);
+		}
 	}
 	closedir(dir);
 
@@ -246,12 +269,13 @@ static void prune_worktrees(void)
 	/* massage main worktree absolute path to match 'gitdir' content */
 	strbuf_strip_suffix(&main_path, "/.");
 	string_list_append_nodup(&kept, strbuf_detach(&main_path, NULL));
-	prune_dups(&kept);
+	ret |= prune_dups(&kept);
 	string_list_clear(&kept, 1);
 
 	if (!show_only)
 		delete_worktrees_dir_if_empty();
 	strbuf_release(&reason);
+	return ret;
 }
 
 static int prune(int ac, const char **av, const char *prefix,
@@ -270,8 +294,7 @@ static int prune(int ac, const char **av, const char *prefix,
 			   0);
 	if (ac)
 		usage_with_options(git_worktree_prune_usage, options);
-	prune_worktrees();
-	return 0;
+	return prune_worktrees();
 }
 
 static char *junk_work_tree;
diff --git a/t/t2401-worktree-prune.sh b/t/t2401-worktree-prune.sh
index f8f28c76ee..c863575b2f 100755
--- a/t/t2401-worktree-prune.sh
+++ b/t/t2401-worktree-prune.sh
@@ -119,6 +119,108 @@ test_expect_success 'prune duplicate (main/linked)' '
 	test_path_is_missing .git/worktrees/wt
 '
 
+test_expect_success 'prune invokes post-worktree remove event' '
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 || exit 1
+	test "$1" = remove || exit 0
+	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
+	EOF
+	git worktree add --detach flushed &&
+	rm -rf flushed &&
+	git worktree prune &&
+	printf "[remove][flushed][%s][]\n" "$(pwd)/flushed" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'prune invokes post-worktree once per worktree' '
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 || exit 1
+	test "$1" = remove || exit 0
+	printf "[%s][%s][%s][%s]\n" "$@" >>hook.actual
+	EOF
+	git worktree add --detach first &&
+	git worktree add --detach second &&
+	rm -rf first second hook.actual &&
+	git worktree prune &&
+	{
+		printf "[remove][first][%s][]\n" "$(pwd)/first" &&
+		printf "[remove][second][%s][]\n" "$(pwd)/second"
+	} >hook.expect &&
+	sort hook.actual >hook.sorted &&
+	test_cmp hook.expect hook.sorted
+'
+
+test_expect_success 'prune --dry-run does not invoke post-worktree hook' '
+	git worktree add --detach dry &&
+	rm -rf dry &&
+	test_when_finished "git worktree prune" &&
+	test_hook post-worktree <<-\EOF &&
+	>hook.ran
+	EOF
+	git worktree prune --dry-run &&
+	test_path_is_missing hook.ran
+'
+
+test_expect_success 'pruned entry with unknown path gives empty hook argument' '
+	test_hook post-worktree <<-\EOF &&
+	test "$#" = 4 &&
+	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
+	EOF
+	mkdir -p .git/worktrees/broken &&
+	: >.git/worktrees/broken/gitdir &&
+	git worktree prune &&
+	echo "[remove][broken][][]" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'failing post-worktree hook does not skip other pruned entries' '
+	test_hook post-worktree <<-\EOF &&
+	test "$1" = remove || exit 0
+	echo "$2" >>hook.actual
+	exit 1
+	EOF
+	git worktree add --detach doomed &&
+	git worktree add --detach doomed2 &&
+	rm -rf doomed doomed2 hook.actual &&
+	test_must_fail git worktree prune &&
+	test_path_is_missing .git/worktrees/doomed &&
+	test_path_is_missing .git/worktrees/doomed2 &&
+	test_write_lines doomed doomed2 >hook.expect &&
+	sort hook.actual >hook.sorted &&
+	test_cmp hook.expect hook.sorted
+'
+
+test_expect_success 'prune duplicate invokes post-worktree remove event' '
+	test_when_finished rm -fr .git/worktrees w1 w2 &&
+	test_hook post-worktree <<-\EOF &&
+	test "$1" = remove || exit 0
+	printf "[%s][%s][%s][%s]\n" "$@" >>hook.actual
+	EOF
+	rm -f hook.actual &&
+	git worktree add --detach w1 &&
+	git worktree add --detach w2 &&
+	sed "s/w2/w1/" .git/worktrees/w2/gitdir >.git/worktrees/w2/gitdir.new &&
+	mv .git/worktrees/w2/gitdir.new .git/worktrees/w2/gitdir &&
+	git worktree prune &&
+	printf "[remove][w2][%s][]\n" "$(pwd)/w1" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'post-worktree remove gets absolute path with relative worktrees' '
+	test_when_finished "rm -rf relhook" &&
+	git init relhook &&
+	test_commit -C relhook base &&
+	test_hook -C relhook post-worktree <<-\EOF &&
+	test "$1" = remove || exit 0
+	printf "[%s][%s][%s][%s]\n" "$@" >hook.actual
+	EOF
+	git -C relhook worktree add --relative-paths --detach wt &&
+	rm -rf relhook/wt &&
+	git -C relhook worktree prune &&
+	printf "[remove][wt][%s][]\n" "$(pwd)/relhook/wt" >hook.expect &&
+	test_cmp hook.expect relhook/hook.actual
+'
+
 test_expect_success 'not prune proper worktrees inside linked worktree with relative paths' '
 	test_when_finished rm -rf repo wt_ext &&
 	git init repo &&
diff --git a/worktree.c b/worktree.c
index 8cb8637b18..f6abad62a7 100644
--- a/worktree.c
+++ b/worktree.c
@@ -1016,7 +1016,6 @@ int should_prune_worktree(struct repository *repo,
 		if (stat(file.buf, &st) || st.st_mtime <= expire) {
 			strbuf_addstr(reason, _("gitdir file points to non-existent location"));
 			rc = 1;
-			goto done;
 		}
 	}
 	*wtpath = strbuf_detach(&dotgit, NULL);
diff --git a/worktree.h b/worktree.h
index fbb2757f5b..30699b2998 100644
--- a/worktree.h
+++ b/worktree.h
@@ -106,9 +106,9 @@ const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire);
 
 /*
  * Return true if worktree entry should be pruned, along with the reason for
- * pruning. Otherwise, return false and the worktree's path in `wtpath`, or
- * NULL if it cannot be determined. Caller is responsible for freeing
- * returned path.
+ * pruning. Otherwise, return false. In both cases the path of the
+ * worktree's `.git` file is returned in `wtpath`, or NULL if it cannot
+ * be determined. Caller is responsible for freeing returned path.
  *
  * `expire` defines a grace period to prune the worktree when its path
  * does not exist.
-- 
2.54.0
Previous: Phillip Wood
Message 21 of 21 in “worktree: add post-worktree-add and post-worktree-remove hooks”
  1. 0/3 worktree: add post-worktree-add and post-worktree-remove hooksDomen Kožar, Jul 9, 2026
  2. Phillip WoodJul 10, 2026
  3. 0/4 worktree: add lifecycle hooksDomen Kožar, Aug 4, 2026
  4. Caleb WhiteAug 4, 2026
  5. Junio C HamanoAug 4, 2026
  6. Domen KožarAug 30, 2026
  7. Domen KožarSep 7, 2026
  8. Kristoffer HaugsbakkSep 7, 2026
  9. Domen KožarSep 7, 2026
  10. Maciej CiemborowiczOct 3, 2026
  11. 0/2 worktree: add post-worktree lifecycle hookDomen Kožar, Oct 4, 2026
  12. Junio C HamanoOct 5, 2026
  13. Phillip WoodJul 13, 2026
  14. 1/4 worktree: add post-worktree-add hookDomen Kožar, Aug 4, 2026
  15. Caleb WhiteAug 4, 2026
  16. 4/4 worktree: add post-worktree-move hookDomen Kožar, Aug 4, 2026
  17. 2/4 worktree: add post-worktree-remove hookDomen Kožar, Aug 4, 2026
  18. 3/4 worktree: run post-worktree-remove hook when pruningDomen Kožar, Aug 4, 2026
  19. 1/2 worktree: add post-worktree lifecycle hookDomen Kožar, Oct 4, 2026
  20. Phillip WoodOct 6, 2026
  21. 2/2 worktree: notify post-worktree hook when pruningDomen Kožar, Oct 4, 2026

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.