Thread (1 message) 1 message, 1 author, 5d ago
DORMANTno replies

[PATCH v1 3/3] worktree: run post-worktree-remove hook when pruning

From: Domen Kožar <hidden>
Date: 2026-07-09 23:39:19
Subsystem: documentation, the rest · Maintainers: Jonathan Corbet, Linus Torvalds

A working tree can also disappear via "git worktree prune", e.g.
after the user deleted the working tree directory manually. Tooling
that tears down per-worktree state wants to observe those deletions
the same way as an explicit "git worktree remove".

Run the post-worktree-remove hook once for each administrative entry
that "git worktree prune" removes, including duplicate entries pruned
during deduplication. The hook is not run with --dry-run, and a
failing hook is reflected in the exit status of the command.

should_prune_worktree() so far returned the path of the worktree's
.git file only for entries that are kept. Also return it when pruning
an entry whose gitdir file points to a location that no longer
exists, which is the common case of a manually deleted working tree,
so that the hook can be given the path. For entries whose path cannot
be determined at all (missing or corrupt gitdir file), the hook
receives an empty string instead. The one other caller of
should_prune_worktree() already frees the path unconditionally.

Signed-off-by: Domen Kožar <redacted>
Co-Authored-By: Claude Fable 5 [off-list ref]
---
 Documentation/githooks.adoc | 23 +++++-----
 builtin/worktree.c          | 48 ++++++++++++++------
 t/t2401-worktree-prune.sh   | 88 +++++++++++++++++++++++++++++++++++++
 worktree.c                  |  1 -
 worktree.h                  |  6 +--
 5 files changed, 139 insertions(+), 27 deletions(-)
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 22b3263ff7..28fab7ccbe 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -239,16 +239,19 @@ post-worktree-remove
 ~~~~~~~~~~~~~~~~~~~~
 
 This hook is invoked by linkgit:git-worktree[1] after a working tree
-has been deleted by `git worktree remove`. The hook is given two
-parameters: the absolute path of the removed working tree and its
-identifier (the name of its former administrative directory in
-`$GIT_DIR/worktrees/`).
-
-The working tree no longer exists when the hook runs.
-
-This hook cannot affect the outcome of `git worktree remove`, other
-than that the hook's exit status becomes the exit status of the
-command.
+has been deleted by `git worktree remove`, and once for each working
+tree pruned by `git worktree prune`. The hook is given two parameters:
+the absolute path of the removed working tree and its identifier (the
+name of its former administrative directory in `$GIT_DIR/worktrees/`).
+
+The working tree no longer exists when the hook runs. For working
+trees pruned by `git worktree prune`, the first parameter may be the
+empty string if the path could not be determined from the leftover
+administrative files.
+
+This hook cannot affect the outcome of `git worktree remove` or
+`git worktree prune`, other than that the hook's exit status becomes
+the exit status of the command.
 
 This hook can be used to tear down per-worktree development
 environments or to unregister the working tree from external tools.
diff --git a/builtin/worktree.c b/builtin/worktree.c
index 01b62ed2fc..e2cdbef8bb 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -176,12 +176,27 @@ static int run_post_worktree_remove_hook(const char *path, const char *id)
 	return run_hooks_opt(the_repository, "post-worktree-remove", &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_remove_hook(path.buf, id);
+	strbuf_release(&path);
+	return ret;
 }
 
 static int prune_cmp(const void *a, const void *b)
@@ -206,18 +221,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;
@@ -225,19 +244,22 @@ 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(d->d_name, &reason, &path, expire))
-			prune_worktree(d->d_name, reason.buf);
-		else if (path)
+		if (should_prune_worktree(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);
 
@@ -245,12 +267,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,
@@ -269,8 +292,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..74a80c1a8d 100755
--- a/t/t2401-worktree-prune.sh
+++ b/t/t2401-worktree-prune.sh
@@ -119,6 +119,94 @@ test_expect_success 'prune duplicate (main/linked)' '
 	test_path_is_missing .git/worktrees/wt
 '
 
+test_expect_success 'prune invokes post-worktree-remove hook' '
+	test_hook post-worktree-remove <<-\EOF &&
+	echo $* >hook.actual
+	EOF
+	git worktree add --detach flushed &&
+	rm -rf flushed &&
+	git worktree prune &&
+	echo $(pwd)/flushed flushed >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'prune invokes post-worktree-remove hook once per worktree' '
+	test_hook post-worktree-remove <<-\EOF &&
+	echo $* >>hook.actual
+	EOF
+	git worktree add --detach first &&
+	git worktree add --detach second &&
+	rm -rf first second hook.actual &&
+	git worktree prune &&
+	{
+		echo $(pwd)/first first &&
+		echo $(pwd)/second 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-remove hook' '
+	git worktree add --detach dry &&
+	rm -rf dry &&
+	test_when_finished "git worktree prune" &&
+	test_hook post-worktree-remove <<-\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-remove <<-\EOF &&
+	echo "[$1][$2]" >hook.actual
+	EOF
+	mkdir -p .git/worktrees/broken &&
+	: >.git/worktrees/broken/gitdir &&
+	git worktree prune &&
+	echo "[][broken]" >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'failing post-worktree-remove hook fails prune' '
+	test_hook post-worktree-remove <<-\EOF &&
+	exit 1
+	EOF
+	git worktree add --detach doomed &&
+	rm -rf doomed &&
+	test_must_fail git worktree prune &&
+	test_path_is_missing .git/worktrees/doomed
+'
+
+test_expect_success 'prune duplicate invokes post-worktree-remove hook' '
+	test_when_finished rm -fr .git/worktrees w1 w2 &&
+	test_hook post-worktree-remove <<-\EOF &&
+	echo $* >>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 &&
+	echo $(pwd)/w1 w2 >hook.expect &&
+	test_cmp hook.expect hook.actual
+'
+
+test_expect_success 'post-worktree-remove hook 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-remove <<-\EOF &&
+	echo $* >hook.actual
+	EOF
+	git -C relhook worktree add --relative-paths --detach wt &&
+	rm -rf relhook/wt &&
+	git -C relhook worktree prune &&
+	echo $(pwd)/relhook/wt 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 30125827fd..6a9d943874 100644
--- a/worktree.c
+++ b/worktree.c
@@ -1004,7 +1004,6 @@ int should_prune_worktree(const char *id, struct strbuf *reason, char **wtpath,
 		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 1075409f9a..dde8fc2be4 100644
--- a/worktree.h
+++ b/worktree.h
@@ -105,9 +105,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
Keyboard shortcuts
hback out one level
jnext message in thread
kprevious message in thread
ldrill in
Escclose help / fold thread tree
?toggle this help