Tools that manage per-worktree development environments need to observe worktrees created, moved, or removed by other programs. Wrapping git worktree only helps when every caller uses the wrapper. post-checkout does not run for add --no-checkout or --orphan, and Git has no lifecycle notification for moving, removing, or pruning worktrees.
The motivating use case is devenv provisioning and cleaning up processes and services for worktrees created by an IDE or agent. The creator and environment manager are separate tools. Ownership identifiers can help coordinate creators, but do not notify the environment manager about changes made by another tool or directly by the user.
Add one post-worktree hook with four arguments:
post-worktree add <id> "" <new-path>
post-worktree move <id> <old-path> <new-path>
post-worktree remove <id> <old-path> ""The hook runs in the invoking repository with the usual hook working directory and environment. Paths are absolute; empty strings are passed as arguments for paths that do not apply or cannot be determined during pruning. The worktree identifier is the name of its administrative entry under the common Git directory.
The add event runs after post-checkout, even if post-checkout fails, and also covers --no-checkout and --orphan. Prune emits a remove event per pruned entry, including duplicates, and none for --dry-run. Hook failures affect the command exit status without undoing completed operations; pruning continues to notify the remaining entries after a hook failure.
The first commit adds the hook for add, move, and remove. The second adds pruning notifications. Both include documentation and regression tests.
Changes since v2:
* Replace the three separate hook names with the single post-worktree
interface proposed in the mailing-list discussion.
* Pass an explicit event, identifier, and both paths for every event,
instead of using argument count to distinguish operations.
* Keep the execution context in the invoking repository for all events.
* Cover configured hooks, paths with spaces, linked and bare callers,
relative paths, failure status precedence, and continued pruning
notifications after a hook failure.
* Rebase onto current master and use its repository argument in
should_prune_worktree().
Earlier discussion: https://lore.kernel.org/git/7c8b4673-37ac-45fa-ad8c-a1dc09afe5fe@mtasv.net/ https://lore.kernel.org/git/8bd3a684-51a0-4a2a-b70d-3981cfe10e9a@mtasv.net/
AI assistance: the commits retain the original Claude coauthor credit. Codex assisted with consolidating the interface, adapting the tests, updating documentation and commit messages, and validating this revision.
Validation:
* Developer build and test lint passed with DEVELOPER=1.
* All nine hook and worktree suites passed in the final full run. A
focused run after the interface change passed 489 tests, and the
lifecycle commit also passed independently (381 tests).
* git diff --check and the repository clang-format check passed.
* Trial merges into next and seen apply without conflicts.
* githooks.html and git-config.html render correctly with Asciidoctor;
only post-worktree is registered in the generated hook list.
* The final full local run completed: 1,062 test files, 33,881 tests.
Four tests failed with a 128 KiB stack: t0003-attributes.sh test 55,
t6120-describe.sh tests 85-86, and t7004-tag.sh test 212. These are
the same four failures reproduced on unmodified upstream master at
8103b446517e0c44e67561b9d0ccce56efa60a71 using the same compiler and
environment. Optional tests without available prerequisites skipped.
* All enabled platform CI checks passed, including macOS, Windows,
Linux variants, Meson, address/undefined-behavior sanitizers, and leak
checks. CI initially caught a Windows shell/native path mismatch in
two new working-directory assertions. These now use the path-utils
test helper; all 289 add/move tests also pass locally after the fix.
https://github.com/git/git/actions/runs/37239319553
PR and platform checks: https://github.com/git/git/pull/2442
Domen Kožar (2):
worktree: add post-worktree lifecycle hook
worktree: notify post-worktree hook when pruning
Documentation/config/hook.adoc | 1 +
Documentation/githooks.adoc | 46 ++++++++++++
builtin/worktree.c | 105 ++++++++++++++++++--------
t/t2400-worktree-add.sh | 132 +++++++++++++++++++++++++++++++++
t/t2401-worktree-prune.sh | 102 +++++++++++++++++++++++++
t/t2403-worktree-move.sh | 113 ++++++++++++++++++++++++++++
worktree.c | 1 -
worktree.h | 6 +-
8 files changed, 473 insertions(+), 33 deletions(-)
Range-diff against v2:
1: 73e36c179e < -: ---------- worktree: add post-worktree-add hook
2: 3de87064c0 < -: ---------- worktree: add post-worktree-remove hook
3: 7989a1d6a2 < -: ---------- worktree: run post-worktree-remove hook when pruning
-: ---------- > 1: c37f12fcba worktree: add post-worktree lifecycle hook
4: 95ab61e377 ! 2: e5855a1491 worktree: add post-worktree-move hook
@@ Metadata
Author: Domen Kožar <domen@cachix.org>
## Commit message ##
- worktree: add post-worktree-move hook
+ worktree: notify post-worktree hook when pruning
- Tools that record worktree paths can keep their state up to date when a
- worktree is added or removed, but the mapping becomes stale when the
- worktree is moved. Services or other per-worktree state tied to the old
- path may also need to be relocated.
+ 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.
- Introduce a post-worktree-move hook that runs after the working tree and
- its administrative files have been moved. The hook runs inside the new
- working tree with GIT_DIR and GIT_WORK_TREE cleared and receives the old
- absolute path as its sole argument. The new path and worktree identifier
- can be queried by running git from the hook's working directory.
+ 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.
- This signature also lets one configured command handle all three
- worktree lifecycle hooks by argument count: post-worktree-add takes no
- arguments, post-worktree-move takes one, and post-worktree-remove takes
- two.
-
- A failing hook does not undo the completed move, but its exit status
- becomes the exit status of "git worktree move".
+ 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>
- Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
-
- ## Documentation/config/hook.adoc ##
-@@ Documentation/config/hook.adoc: hook.jobs::
- `pre-commit`;;
- `post-checkout`;;
- `post-worktree-add`;;
-+`post-worktree-move`;;
- `post-worktree-remove`;;
- `push-to-checkout`;;
- `post-commit`;;
## Documentation/githooks.adoc ##
-@@ Documentation/githooks.adoc: runs after the `post-checkout` hook, even if that hook fails.
- This hook can be used to set up per-worktree development environments
- or to register the new working tree with external tools.
+@@ Documentation/githooks.adoc: post-worktree
+ ~~~~~~~~~~~~~
-+post-worktree-move
-+~~~~~~~~~~~~~~~~~~
-+
-+This hook is invoked by linkgit:git-worktree[1] after `git worktree move`
-+has moved a working tree and updated its administrative files. It is given
-+one parameter: the absolute path of the working tree before it was moved.
-+
-+The hook's current working directory is the new working tree, so its new
-+absolute path and identifier can be queried by running `git`.
-+
-+This hook cannot affect the outcome of `git worktree move`, other than
-+that the hook's exit status becomes the exit status of the command. A
-+failing hook does not undo the move.
-+
-+This hook can be used to update per-worktree development environments or
-+registrations with external tools after their working tree has moved.
-+
- post-worktree-remove
- ~~~~~~~~~~~~~~~~~~~~
+ 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.
+@@ Documentation/githooks.adoc: 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
## builtin/worktree.c ##
-@@ builtin/worktree.c: static int run_post_worktree_remove_hook(const char *path, const char *id)
- return run_hooks_opt(the_repository, "post-worktree-remove", &hook_opt);
+@@ builtin/worktree.c: static int run_post_worktree_hook(const char *event, const char *id,
+ return run_hooks_opt(the_repository, "post-worktree", &hook_opt);
}
-+static int run_post_worktree_move_hook(const char *old_path,
-+ const char *new_path)
-+{
-+ struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+-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;
+
-+ strvec_pushl(&hook_opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
-+ strvec_push(&hook_opt.args, old_path);
-+ hook_opt.dir = new_path;
-+ return run_hooks_opt(the_repository, "post-worktree-move", &hook_opt);
-+}
+ 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;
+
- static int prune_worktree(const char *id, const char *dotgit,
- const char *reason)
++ 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)
+@@ builtin/worktree.c: 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)
{
-@@ builtin/worktree.c: static int move_worktree(int ac, const char **av, const char *prefix,
- struct strbuf dst = STRBUF_INIT;
- struct strbuf errmsg = STRBUF_INIT;
- const char *reason = NULL;
-- char *path;
-+ char *old_path, *path;
-+ int ret;
+ int i;
++ int ret = 0;
- ac = parse_options(ac, av, prefix, options, git_worktree_move_usage,
- 0);
-@@ builtin/worktree.c: static int move_worktree(int ac, const char **av, const char *prefix,
- errmsg.buf);
- strbuf_release(&errmsg);
+ 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;
+ }
-+ old_path = xstrdup(wt->path);
- if (rename(wt->path, dst.buf) == -1)
- die_errno(_("failed to move '%s' to '%s'"), wt->path, dst.buf);
+-static void prune_worktrees(void)
++static int prune_worktrees(void)
+ {
+ struct strbuf reason = STRBUF_INIT;
+ struct strbuf main_path = STRBUF_INIT;
+@@ builtin/worktree.c: static void prune_worktrees(void)
+ char *path;
+ DIR *dir;
+ struct dirent *d;
++ int ret = 0;
- update_worktree_location(wt, dst.buf, use_relative_paths);
-+ ret = run_post_worktree_move_hook(old_path, wt->path);
+ 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);
-+ free(old_path);
- strbuf_release(&dst);
- free_worktrees(worktrees);
-- return 0;
+@@ builtin/worktree.c: 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,
+@@ builtin/worktree.c: 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;
- ## t/t2403-worktree-move.sh ##
-@@ t/t2403-worktree-move.sh: test_expect_success 'move worktree' '
- test_cmp expected2 actual2
+ ## t/t2401-worktree-prune.sh ##
+@@ t/t2401-worktree-prune.sh: test_expect_success 'prune duplicate (main/linked)' '
+ test_path_is_missing .git/worktrees/wt
'
-+test_expect_success '"move" invokes post-worktree-move hook' '
-+ test_hook post-worktree-move <<-\EOF &&
-+ test "$#" = 1 &&
-+ {
-+ echo "$1" &&
-+ git rev-parse --git-dir --show-toplevel
-+ } >hook.actual
++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 hook-source &&
-+ git worktree move hook-source hook-destination &&
++ git worktree add --detach first &&
++ git worktree add --detach second &&
++ rm -rf first second hook.actual &&
++ git worktree prune &&
+ {
-+ echo "$(pwd)/hook-source" &&
-+ echo "$(pwd)/.git/worktrees/hook-source" &&
-+ echo "$(pwd)/hook-destination"
++ printf "[remove][first][%s][]\n" "$(pwd)/first" &&
++ printf "[remove][second][%s][]\n" "$(pwd)/second"
+ } >hook.expect &&
-+ test_cmp hook.expect hook-destination/hook.actual
++ 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-move hook leaves worktree moved' '
-+ test_hook post-worktree-move <<-\EOF &&
++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 hook-failing-source &&
-+ test_must_fail git worktree move hook-failing-source hook-failing-destination &&
-+ test_path_is_missing hook-failing-source &&
-+ git -C hook-failing-destination status --porcelain >actual &&
-+ test_must_be_empty actual
++ 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 'move main worktree' '
- test_must_fail git worktree move . def
- '
++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 &&
+
+ ## worktree.c ##
+@@ worktree.c: 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);
+
+ ## worktree.h ##
+@@ worktree.h: 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.