Git development
 help / color / mirror / Atom feed
From: "Domen Kožar" <domen@cachix.org>
To: git@vger.kernel.org
Cc: gitster@pobox.com, cdwhite3@pm.me, phillip.wood123@gmail.com,
	sunshine@sunshineco.com, ps@pks.im, avarab@gmail.com,
	test35965@gmail.com, kristofferhaugsbakk@fastmail.com,
	maciej.ciemborowicz@gmail.com, "Domen Kožar" <domen@cachix.org>,
	"Claude Fable 5" <noreply@anthropic.com>
Subject: [PATCH v3 2/2] worktree: notify post-worktree hook when pruning
Date: Sun, 04 Oct 2026 23:09:08 +0000	[thread overview]
Message-ID: <f7ead9bc-fe6e-49c1-bb7d-6efd14eb6766@mtasv.net> (raw)
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

  parent reply	other threads:[~2026-10-04 23:12 UTC|newest]

Thread overview: 21+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-09 23:36 [PATCH v1 0/3] worktree: add post-worktree-add and post-worktree-remove hooks Domen Kožar
2026-07-10  9:34 ` Phillip Wood
     [not found]   ` <CAMvcdZS=ZYbLmjKaGJvjQ_fWYhVbOzwMvYq+MMENWPYi_RiqvQ@mail.gmail.com>
2026-07-13 13:19     ` Phillip Wood
2026-08-04 18:14 ` [PATCH v2 0/4] worktree: add lifecycle hooks Domen Kožar
2026-08-04 19:03   ` Caleb White
2026-08-04 20:28     ` Junio C Hamano
2026-08-30 17:21       ` Domen Kožar
2026-09-07 14:30         ` Domen Kožar
2026-09-07 18:18           ` Kristoffer Haugsbakk
2026-09-07 19:34             ` Domen Kožar
2026-10-03 18:47       ` Maciej Ciemborowicz
2026-10-04 23:09   ` [PATCH v3 0/2] worktree: add post-worktree lifecycle hook Domen Kožar
2026-10-05 16:47     ` Junio C Hamano
     [not found]   ` <cover.1791152172.git.domen@cachix.org>
2026-10-04 23:09     ` [PATCH v3 1/2] " Domen Kožar
2026-10-06  9:23       ` Phillip Wood
2026-10-04 23:09     ` Domen Kožar [this message]
     [not found] ` <20260804181358.532970-1-domen@cachix.org>
2026-08-04 18:14   ` [PATCH v2 1/4] worktree: add post-worktree-add hook Domen Kožar
2026-08-04 20:03     ` Caleb White
2026-08-04 18:14   ` [PATCH v2 2/4] worktree: add post-worktree-remove hook Domen Kožar
2026-08-04 18:14   ` [PATCH v2 3/4] worktree: run post-worktree-remove hook when pruning Domen Kožar
2026-08-04 18:14   ` [PATCH v2 4/4] worktree: add post-worktree-move hook Domen Kožar

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=f7ead9bc-fe6e-49c1-bb7d-6efd14eb6766@mtasv.net \
    --to=domen@cachix.org \
    --cc=avarab@gmail.com \
    --cc=cdwhite3@pm.me \
    --cc=git@vger.kernel.org \
    --cc=gitster@pobox.com \
    --cc=kristofferhaugsbakk@fastmail.com \
    --cc=maciej.ciemborowicz@gmail.com \
    --cc=noreply@anthropic.com \
    --cc=phillip.wood123@gmail.com \
    --cc=ps@pks.im \
    --cc=sunshine@sunshineco.com \
    --cc=test35965@gmail.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox