worktree: add post-worktree-add and post-worktree-remove hooks
cover worktree: add post-worktree-add and post-worktree-remove hooks
Hi everyone,
I maintain devenv, a developer environment manager, and lately the workflow we see most is people letting AI coding agents loose on a repository, one linked worktree per task, created and discarded at a pace no human would type. Each of those worktrees expects a working
environment: processes, sockets, and stateful services such as a database seeded from a dump.
Today there is no reliable trigger to set that up when a worktree
appears: post-checkout does not fire for --no-checkout or --orphan and cannot be told apart from a plain checkout. Nothing at all fires when a worktree goes away, so stale databases and services pile up after "git worktree remove" or a manual rm followed by "git worktree prune". Wrapping the worktree commands only helps when every tool, human or agent, goes through the wrapper.
Patch 1 adds a post-worktree-add hook that fires after the working tree is fully set up. Patch 2 adds post-worktree-remove for "git worktree remove". Patch 3 extends it to "git worktree prune" so that manually deleted worktrees are also observed.
Two design points I would especially appreciate feedback on:
* post-worktree-add runs after post-checkout and is skipped when post-checkout fails. An argument could be made that it should run whenever the worktree was created, regardless of the earlier hook's exit status, since tooling registering worktrees would otherwise miss one that does exist.
* for entries pruned because their gitdir file points to a location that no longer exists, the hook receives the recorded path; when the path cannot be determined at all (missing or corrupt gitdir file) it receives an empty string.
Thanks,
Domen
Domen Kožar (3):
worktree: add post-worktree-add hook
worktree: add post-worktree-remove hook
worktree: run post-worktree-remove hook when pruning
Documentation/githooks.adoc | 41 +++++++++++++ builtin/worktree.c | 73 ++++++++++++++++++----- t/t2400-worktree-add.sh | 113 ++++++++++++++++++++++++++++++++++++ t/t2401-worktree-prune.sh | 88 ++++++++++++++++++++++++++++ t/t2403-worktree-move.sh | 44 ++++++++++++++ worktree.c | 1 - worktree.h | 6 +- 7 files changed, 347 insertions(+), 19 deletions(-)
base-commit: f85a7e662054a7b0d9070e432508831afa214b47
Signature · 1 line
2.54.0
Hi Domen
Quoted 8 lines · Domen Kožar
Today there is no reliable trigger to set that up when a worktree
appears: post-checkout does not fire for --no-checkout or --orphan and cannot be told apart from a plain checkout. Nothing at all fires when a worktree goes away, so stale databases and services pile up after "git worktree remove" or a manual rm followed by "git worktree prune". Wrapping the worktree commands only helps when every tool, human or agent, goes through the wrapper.
I agree a hook that's run after the worktree is added is useful (I have a patch for it that I've never got round to cleaning up and sending so thank you for working on this). It is useful for copying across untracked files to the new worktree like "config.mak".
Quoted 4 lines
Patch 1 adds a post-worktree-add hook that fires after the working tree is fully set up. Patch 2 adds post-worktree-remove for "git worktree remove". Patch 3 extends it to "git worktree prune" so that manually deleted worktrees are also observed.
I don't have a strong opinion on a hook running when a worktree is removed - an IDE that cares about that could set up a filesystem watch on the directory but I guess adding a hook doesn't do any harm.
Quoted 7 lines
Two design points I would especially appreciate feedback on:
* post-worktree-add runs after post-checkout and is skipped when post-checkout fails. An argument could be made that it should run whenever the worktree was created, regardless of the earlier hook's exit status, since tooling registering worktrees would otherwise miss one that does exist.
Looking at the existing code, if the checkout fails then we remove the worktree because "is_junk == 1" when remove_junk() is called via atexit() so I think it is correct to skip the new hook in that case.
The new hook is run after the checkout, but before the post-checkout hook - we should document their relative order. I see the hook is run in the new worktree and passed the absolute directory and worktree id. I'm wondering if either of those is useful if we're running the hook in the new worktree.
Quoted 4 lines
* for entries pruned because their gitdir file points to a location that no longer exists, the hook receives the recorded path; when the path cannot be determined at all (missing or corrupt gitdir file) it receives an empty string.
So the hook knows a worktree was removed but not which one?
Thanks
Phillip
Quoted 19 lines
Thanks,
DomenDomen Kožar (3):
worktree: add post-worktree-add hook
worktree: add post-worktree-remove hook
worktree: run post-worktree-remove hook when pruningDocumentation/githooks.adoc | 41 +++++++++++++ builtin/worktree.c | 73 ++++++++++++++++++----- t/t2400-worktree-add.sh | 113 ++++++++++++++++++++++++++++++++++++ t/t2401-worktree-prune.sh | 88 ++++++++++++++++++++++++++++ t/t2403-worktree-move.sh | 44 ++++++++++++++ worktree.c | 1 - worktree.h | 6 +- 7 files changed, 347 insertions(+), 19 deletions(-)base-commit:
f85a7e662054a7b0d9070e432508831afa214b47
A message that is not in the archive (sent off-list or not yet received): CAMvcdZS=ZYbLmjKaGJvjQ_fWYhVbOzwMvYq+MMENWPYi_RiqvQ@mail.gmail.com
Hi Domen
Unfortunately it doesn't look like your message appeared on the list, sadly I'm not sure it accepts multipart/alternative messages even when they contain a plain-text version of the message.
Quoted 14 lines · Domen Kožar
Hi Phillip,
thanks for the quick and careful read.
Quoted 2 lines
It is useful for copying across untracked files to the new worktree like "config.mak".
That is a nice example, and it needs the hook to also fire for --no-checkout and --orphan, which post-checkout does not cover.
Quoted 3 lines
Looking at the existing code, if the checkout fails then we remove the worktree because "
is_junk== 1" whenremove_junk()is called viaatexit()so I think it is correct to skip the new hook in that case.
Quoted 9 lines
Right, when the checkout itself fails the worktree is removed as junk and neither hook runs; no disagreement there. The case I was asking about is the post-checkout hook itself failing: that runs after
is_junkis cleared, so the worktree survives, but post-worktree-add is currently skipped and tooling that registers worktrees would miss one that exists. I kept the skip because a failing post-checkout already signals a broken setup, but I am happy to run post-worktree-add whenever the worktree was created, regardless of the earlier hook's exit status, if that is preferred.
Oh sorry I'd misunderstood the question. I think I'd lean towards running the hook anyway because we've still populated a new worktree, even if the post-checkout hooks fails.
Quoted 10 lines
Quoted 2 lines
The new hook is run after the checkout, but before the post-checkout hook - we should document their relative order.
Unless I am misreading my own series, it is the other way around:
add_worktree()invokes post-checkout first and post-worktree-add after it, t2400 has a test pinning that order ('"add" runs post-worktree-add after post-checkout'), and githooks.adoc says "It runs after the post-checkout hook, and is skipped if that hook fails." If that did not come across I am happy to reword the documentation.
Oops, when I wrote that I was looking at the wrong branch - I had my "add-worktree-hook" checked out and confused it with this patch. It's great to see that there is a test and documentation for this.
Quoted 5 lines
Quoted 2 lines
I'm wondering if either of those is useful if we're running the hook in the new worktree.
Strictly they are derivable from inside, --show-toplevel for the path and the basename of --git-dir for the id.
Isn't the worktree path the current working directory of the hook script?
Quoted 6 lines
I passed them anyway so
that one script can serve both hooks: post-worktree-remove has to receive them as arguments because the worktree is gone by the time it runs, and keeping the two signatures identical makes shared hook code simpler. I can drop them from post-worktree-add if the symmetry is not considered worth it.
Oh right, as a counter argument I wonder if having a different argument count for the two hooks makes it easier for a script that's shared between the two hooks to determine which hook has invoked it. Is the worktree id useful for anything apart from accessing on worktree's local refs from another worktree?
Quoted 7 lines
Quoted 1 line
So the hook knows a worktree was removed but not which one?
It always gets the worktree id as $2; only the path in $1 can be empty, and only for entries whose gitdir file is missing or unreadable, where git itself no longer knows the path either. Tooling that recorded the id at post-worktree-add time can still match the removal.
That answers why you want the id.
One thought I had after I wrote my mail was that worktrees can be renamed - do we want a hook for that so that external tools can move any services they've started and update their id -> path mapping.
Thanks
Phillip
Quoted 82 lines
Thanks,
DomenOn Fri, Jul 10, 2026 at 3:34 AM Phillip Wood <[email protected] <mailto:[email protected]>> wrote:
Hi Domen
On 10/07/2026 00:36, Domen Kožar wrote:
>
> Today there is no reliable trigger to set that up when a worktree > appears: post-checkout does not fire for --no-checkout or --orphan > and cannot be told apart from a plain checkout. Nothing at all fires > when a worktree goes away, so stale databases and services pile up > after "git worktree remove" or a manual rm followed by "git worktree > prune". Wrapping the worktree commands only helps when every tool, > human or agent, goes through the wrapper.I agree a hook that's run after the worktree is added is useful (I have a patch for it that I've never got round to cleaning up and sending so thank you for working on this). It is useful for copying across untracked files to the new worktree like "config.mak".
> Patch 1 adds a post-worktree-add hook that fires after the working > tree is fully set up. Patch 2 adds post-worktree-remove for "git > worktree remove". Patch 3 extends it to "git worktree prune" so that > manually deleted worktrees are also observed.
I don't have a strong opinion on a hook running when a worktree is removed - an IDE that cares about that could set up a filesystem watch on the directory but I guess adding a hook doesn't do any harm.
> Two design points I would especially appreciate feedback on:
>
> * post-worktree-add runs after post-checkout and is skipped when > post-checkout fails. An argument could be made that it should run > whenever the worktree was created, regardless of the earlier > hook's exit status, since tooling registering worktrees would > otherwise miss one that does exist.Looking at the existing code, if the checkout fails then we remove the worktree because "
is_junk== 1" whenremove_junk()is called viaatexit()so I think it is correct to skip the new hook in that case.The new hook is run after the checkout, but before the post-checkout hook - we should document their relative order. I see the hook is run in
the new worktree and passed the absolute directory and worktree id. I'm wondering if either of those is useful if we're running the hook in the new worktree.> * for entries pruned because their gitdir file points to a location > that no longer exists, the hook receives the recorded path; when > the path cannot be determined at all (missing or corrupt gitdir > file) it receives an empty string.
So the hook knows a worktree was removed but not which one?
Thanks
Phillip
> Thanks,
> Domen
>
> Domen Kožar (3):
> worktree: add post-worktree-add hook
> worktree: add post-worktree-remove hook
> worktree: run post-worktree-remove hook when pruning >
> Documentation/githooks.adoc | 41 +++++++++++++
> builtin/worktree.c | 73 ++++++++++++++++++----- > t/t2400-worktree-add.sh | 113 +++++++++++++++++++++++++++++
+++++++
> t/t2401-worktree-prune.sh | 88 ++++++++++++++++++++++++++++ > t/t2403-worktree-move.sh | 44 ++++++++++++++
> worktree.c | 1 -
> worktree.h | 6 +-
> 7 files changed, 347 insertions(+), 19 deletions(-) >
>
> base-commit:f85a7e662054a7b0d9070e432508831afa214b47
A message that is not in the archive (sent off-list or not yet received): [email protected]
PATCH v2 1/4 worktree: add post-worktree-add hook
Tools that manage per-worktree state, such as development environment managers or IDEs, have no way to react when a new working tree is created. The only hook that fires during "git worktree add" is post-checkout, which is skipped when --no-checkout or --orphan is used and cannot be distinguished from a plain checkout.
Introduce a post-worktree-add hook that runs after the working tree has been fully set up, including with --no-checkout and --orphan. The hook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE cleared, mirroring the existing post-checkout invocation, and takes no arguments. Details such as the absolute path, worktree identifier, and checked-out branch can be queried by running git from the hook's working directory. Taking no arguments also lets a configured command shared with post-worktree-remove distinguish the events by argument count.
Like post-checkout, the hook cannot affect the outcome of the command:
a failing hook does not delete the already-created working tree, but its exit status becomes the exit status of "git worktree add". The hook runs after post-checkout, even when post-checkout fails, because the worktree has still been populated and remains present.
Documenting the new hook in githooks(5) also registers its name in the generated hook-list.h, so "git hook run" and hook.*.event recognize it without further changes.
--- Documentation/config/hook.adoc | 1 + Documentation/githooks.adoc | 18 ++++++ builtin/worktree.c | 46 +++++++++----- t/t2400-worktree-add.sh | 111 +++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 14 deletions(-)
Documentation/config/hook.adoc +1 -0
@@ -94,6 +94,7 @@ hook.jobs:: Receive a commit message file and may rewrite it in place. `pre-commit`;; `post-checkout`;; +`post-worktree-add`;; `push-to-checkout`;; `post-commit`;; Access the working tree, index, or repository state.
Documentation/githooks.adoc +18 -0
@@ -215,6 +215,24 @@ This hook can be used to perform repository validity checks, auto-display differences from the previous HEAD if different, or set working dir metadata properties. +post-worktree-add +~~~~~~~~~~~~~~~~~ + +This hook is invoked by linkgit:git-worktree[1] after `git worktree add` +has created and set up a new working tree. It takes no parameters. + +The hook's current working directory is the new working tree, so further +details, such as its absolute path, identifier, and checked-out branch, +can be queried by running `git`. Unlike the `post-checkout` hook, it is +also run when `--no-checkout` or `--orphan` is used. + +This hook cannot affect the outcome of `git worktree add`, other than +that the hook's exit status becomes the exit status of the command. It +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. + post-merge ~~~~~~~~~~
builtin/worktree.c +32 -14
59 lines not on this page · raw message
t/t2400-worktree-add.sh +111 -0
117 lines not on this page · raw message
Signature · 1 line
2.54.0
Re: [PATCH v2 1/4] worktree: add post-worktree-add hook
Quoted 8 lines · Domen Kožar
Introduce a post-worktree-add hook that runs after the working tree has been fully set up, including with --no-checkout and --orphan. The hook runs inside the new working tree with
GIT_DIRandGIT_WORK_TREEcleared, mirroring the existing post-checkout invocation, and takes no arguments. Details such as the absolute path, worktree identifier, and checked-out branch can be queried by running git from the hook's working directory. Taking no arguments also lets a configured command shared with post-worktree-remove distinguish the events by argument count.
It looks like the post-worktree-remove and post-worktree-move hooks both receive arguments but this hook does not. While the hook can certainly use git to query the path and identifier, if you already have that information I'm not sure why you can't and shouldn't just pass it through to the hook (same thing goes for the new path and identifier on the move hook).
Best,
Caleb
PATCH v2 2/4 worktree: add post-worktree-remove hook
In reply to a message not in the archiveExternal tooling has no way to learn that a working tree is gone:
"git worktree remove" deletes both the working tree and its administrative directory without running any hook.
Introduce a post-worktree-remove hook that runs after "git worktree remove" has deleted a working tree. It is given the former absolute path of the working tree and its identifier as arguments. The hook also runs when only the administrative entry is deleted because the working tree directory itself had already disappeared, since the worktree is deregistered either way.
Because the working tree no longer exists at that point, no special working directory or environment is set up; the hook runs wherever the command ran, like other post-command hooks.
The hook runs once deletion is underway even if parts of it fail, since there is no going back at that point, but it does not run when the removal is refused (locked or dirty working tree, failed validation). It cannot affect the outcome of the command other than its exit status being reflected in the exit status of "git worktree remove".
--- Documentation/config/hook.adoc | 1 + Documentation/githooks.adoc | 18 ++++++++++++++ builtin/worktree.c | 10 ++++++++ t/t2403-worktree-move.sh | 44 ++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+)
Documentation/config/hook.adoc +1 -0
@@ -95,6 +95,7 @@ hook.jobs:: `pre-commit`;; `post-checkout`;; `post-worktree-add`;; +`post-worktree-remove`;; `push-to-checkout`;; `post-commit`;; Access the working tree, index, or repository state.
Documentation/githooks.adoc +18 -0
@@ -233,6 +233,24 @@ 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. +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. + +This hook can be used to tear down per-worktree development +environments or to unregister the working tree from external tools. + post-merge ~~~~~~~~~~
builtin/worktree.c +10 -0
@@ -177,6 +177,14 @@ static int run_post_worktree_add_hook(const char *path)return run_hooks_opt (the_repository,"post-worktree-add" , &hook_opt); } +static int run_post_worktree_remove_hook (const char *path,const char *id) +{ +struct run_hooks_opt hook_opt =RUN_HOOKS_OPT_INIT_FORCE_SERIAL ; + +strvec_pushl (&hook_opt.args, path, id,NULL ); +return run_hooks_opt (the_repository,"post-worktree-remove" , &hook_opt); +} +static void prune_worktree (const char *id,const char *reason) {if (show_only || verbose)
@@ -1444,6 +1452,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix, ret |=delete_git_dir (wt->id);delete_worktrees_dir_if_empty (); + ret |=run_post_worktree_remove_hook (wt->path, wt->id); +free_worktrees (worktrees);return ret; }
t/t2403-worktree-move.sh +44 -0
50 lines not on this page · raw message
Signature · 1 line
2.54.0
PATCH v2 3/4 worktree: run post-worktree-remove hook when pruning
In reply to a message not in the archiveA 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.
--- 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(-)
Documentation/githooks.adoc +13 -10
@@ -237,16 +237,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.
builtin/worktree.c +35 -13
104 lines not on this page · raw message
t/t2401-worktree-prune.sh +88 -0
94 lines not on this page · raw message
worktree.c +0 -1
@@ -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 );
worktree.h +3 -3
@@ -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.
Signature · 1 line
2.54.0
PATCH v2 4/4 worktree: add post-worktree-move hook
In reply to a message not in the archiveTools 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.
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.
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".
--- Documentation/config/hook.adoc | 1 + Documentation/githooks.adoc | 17 +++++++++++++++++ builtin/worktree.c | 19 +++++++++++++++++-- t/t2403-worktree-move.sh | 29 +++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-)
Documentation/config/hook.adoc +1 -0
@@ -95,6 +95,7 @@ hook.jobs:: `pre-commit`;; `post-checkout`;; `post-worktree-add`;; +`post-worktree-move`;; `post-worktree-remove`;; `push-to-checkout`;; `post-commit`;;
Documentation/githooks.adoc +17 -0
@@ -233,6 +233,23 @@ 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. +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 ~~~~~~~~~~~~~~~~~~~~
builtin/worktree.c +17 -2
@@ -185,6 +185,17 @@ 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 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 ; + +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); +} +static int prune_worktree (const char *id,const char *dotgit,const char *reason) {
@@ -1306,7 +1317,8 @@ 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; ac =parse_options (ac, av, prefix, options, git_worktree_move_usage,0 );
@@ -1349,14 +1361,17 @@ static int move_worktree(int ac, const char **av, const char *prefix, errmsg.buf);strbuf_release (&errmsg); + old_path =xstrdup (wt->path);if (rename (wt->path, dst.buf) ==-1 )die_errno (_ ("failed to move '%s' to '%s'" ), wt->path, dst.buf);update_worktree_location (wt, dst.buf, use_relative_paths); + ret =run_post_worktree_move_hook (old_path, wt->path); +free (old_path);strbuf_release (&dst);free_worktrees (worktrees); -return 0 ; +return ret; }/*
t/t2403-worktree-move.sh +29 -0
@@ -82,6 +82,35 @@ test_expect_success 'move worktree' ' test_cmp expected2 actual2 ' +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 + EOF + git worktree add --detach hook-source && + git worktree move hook-source hook-destination && + { + echo "$(pwd)/hook-source" && + echo "$(pwd)/.git/worktrees/hook-source" && + echo "$(pwd)/hook-destination" + } >hook.expect && + test_cmp hook.expect hook-destination/hook.actual +' + +test_expect_success 'failing post-worktree-move hook leaves worktree moved' ' + test_hook post-worktree-move <<-\EOF && + 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 +' + test_expect_success 'move main worktree' ' test_must_fail git worktree move . def '
Signature · 1 line
2.54.0
v2 cover worktree: add lifecycle hooks
In reply to Domen KožarHi everyone,
First, apologies that my earlier reply reached the list as a separate message rather than as part of this thread. This is my first patch series submitted by email, and I am still getting the threading details right. I have made sure this reroll is plain text and correctly threaded.
I maintain devenv, a developer environment manager, and lately the workflow we see most is people letting AI coding agents loose on a repository, one linked worktree per task, created and discarded at a pace no human would type. Each of those worktrees expects a working
environment: processes, sockets, and stateful services such as a database seeded from a dump.
Today there is no reliable trigger to set that up when a worktree
appears: post-checkout does not fire for --no-checkout or --orphan and cannot be told apart from a plain checkout. Nothing fires when a worktree is moved or removed, so external registrations become stale and databases and services can pile up after "git worktree remove" or a manual rm followed by "git worktree prune". Wrapping the worktree commands only helps when every tool, human or agent, uses the wrapper.
Patch 1 adds a post-worktree-add hook that fires after the working tree is fully set up. Patch 2 adds post-worktree-remove for "git worktree remove". Patch 3 extends the remove hook to "git worktree prune" so that manually deleted worktrees are also observed. Patch 4 adds post-worktree-move so tools can update their path mapping.
Changes since v1:
* Run post-worktree-add after post-checkout even if post-checkout fails, because the populated worktree remains present.
* Make post-worktree-add take no arguments. Its working directory is the new worktree, so its path and identifier can be queried with git. This also lets a configured command shared across the hooks distinguish add, move, and remove by their argument counts.
* Add post-worktree-move. It runs in the new location and receives the old absolute path as its sole argument.
* Document the new hooks among those that always run serially.
Thanks to Phillip Wood for the review that prompted these changes.
Thanks,
Domen
Domen Kožar (4):
worktree: add post-worktree-add hook
worktree: add post-worktree-remove hook
worktree: run post-worktree-remove hook when pruning
worktree: add post-worktree-move hook
Documentation/config/hook.adoc | 3 + Documentation/githooks.adoc | 56 +++++++++++++++ builtin/worktree.c | 123 +++++++++++++++++++++++++-------- t/t2400-worktree-add.sh | 111 +++++++++++++++++++++++++++++ t/t2401-worktree-prune.sh | 88 +++++++++++++++++++++++ t/t2403-worktree-move.sh | 73 +++++++++++++++++++ worktree.c | 1 - worktree.h | 6 +- 8 files changed, 428 insertions(+), 33 deletions(-)
Range-diff against v1:
1: 98f06e55c8 ! 1: 73e36c179e worktree: add post-worktree-add hook
@@ Commit message
Introduce a post-worktree-add hook that runs after the working tree
has been fully set up, including with --no-checkout and --orphan. The
hook runs inside the new working tree with GIT_DIR and GIT_WORK_TREE
- cleared, mirroring the existing post-checkout invocation, and is given
- the absolute path of the new working tree and its identifier as
- arguments. Anything else, such as the checked-out branch, can be
- queried by running git from the hook's working directory.
+ cleared, mirroring the existing post-checkout invocation, and takes no
+ arguments. Details such as the absolute path, worktree identifier, and
+ checked-out branch can be queried by running git from the hook's working
+ directory. Taking no arguments also lets a configured command shared
+ with post-worktree-remove distinguish the events by argument count.
Like post-checkout, the hook cannot affect the outcome of the command:
a failing hook does not delete the already-created working tree, but
its exit status becomes the exit status of "git worktree add". The
- hook runs after post-checkout and is skipped if that hook fails.
+ hook runs after post-checkout, even when post-checkout fails, because
+ the worktree has still been populated and remains present.
Documenting the new hook in githooks(5) also registers its name in the
generated hook-list.h, so "git hook run" and hook.*.event recognize it
@@ Commit message
Signed-off-by: Domen Kožar <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
+ ## Documentation/config/hook.adoc ##
+@@ Documentation/config/hook.adoc: hook.jobs::
+ Receive a commit message file and may rewrite it in place.
+ `pre-commit`;;
+ `post-checkout`;;
++`post-worktree-add`;;
+ `push-to-checkout`;;
+ `post-commit`;;
+ Access the working tree, index, or repository state.
+
## Documentation/githooks.adoc ##
@@ Documentation/githooks.adoc: This hook can be used to perform repository validity checks, auto-display
differences from the previous HEAD if different, or set working dir metadata
@@ Documentation/githooks.adoc: This hook can be used to perform repository validit
+~~~~~~~~~~~~~~~~~
+
+This hook is invoked by linkgit:git-worktree[1] after `git worktree add`
-+has created and set up a new working tree. The hook is given two
-+parameters: the absolute path of the new working tree and its identifier
-+(the name of its administrative directory in `$GIT_DIR/worktrees/`).
++has created and set up a new working tree. It takes no parameters.
+
-+The hook runs inside the new working tree, so further details, such as
-+the checked-out branch, can be queried by running `git` from the hook's
-+current directory. Unlike the `post-checkout` hook, it is also run when
-+`--no-checkout` or `--orphan` is used.
++The hook's current working directory is the new working tree, so further
++details, such as its absolute path, identifier, and checked-out branch,
++can be queried by running `git`. Unlike the `post-checkout` hook, it is
++also run when `--no-checkout` or `--orphan` is used.
+
+This hook cannot affect the outcome of `git worktree add`, other than
+that the hook's exit status becomes the exit status of the command. It
-+runs after the `post-checkout` hook, and is skipped if that hook fails.
++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: This hook can be used to perform repository validit
## builtin/worktree.c ##
+@@ builtin/worktree.c: static void delete_worktrees_dir_if_empty(void)
+ free(path);
+ }
+
++static int run_post_worktree_add_hook(const char *path)
++{
++ struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
++
++ strvec_pushl(&hook_opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
++ hook_opt.dir = path;
++ return run_hooks_opt(the_repository, "post-worktree-add", &hook_opt);
++}
++
+ static void prune_worktree(const char *id, const char *reason)
+ {
+ if (show_only || verbose)
@@ builtin/worktree.c: static int add_worktree(const char *path, const char *refname,
}
@@ builtin/worktree.c: static int add_worktree(const char *path, const char *refnam
+ * is_junk is cleared, but do return appropriate code when a hook
+ * fails.
*/
- if (!ret && opts->checkout && !opts->orphan) {
- struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
-@@ builtin/worktree.c: static int add_worktree(const char *path, const char *refname,
- ret = run_hooks_opt(the_repository, "post-checkout", &opt);
- }
-
+- if (!ret && opts->checkout && !opts->orphan) {
+- struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+-
+- strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
+- strvec_pushl(&opt.args,
+- oid_to_hex(null_oid(the_hash_algo)),
+- oid_to_hex(&commit->object.oid),
+- "1",
+- NULL);
+- opt.dir = path;
+-
+- ret = run_hooks_opt(the_repository, "post-checkout", &opt);
+ if (!ret) {
-+ struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
++ int hook_ret;
++
++ if (opts->checkout && !opts->orphan) {
++ struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT_FORCE_SERIAL;
+
-+ strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
-+ strvec_pushl(&opt.args, wt->path, wt->id, NULL);
-+ opt.dir = path;
++ strvec_pushl(&opt.env, "GIT_DIR", "GIT_WORK_TREE", NULL);
++ strvec_pushl(&opt.args,
++ oid_to_hex(null_oid(the_hash_algo)),
++ oid_to_hex(&commit->object.oid),
++ "1",
++ NULL);
++ opt.dir = path;
+
-+ ret = run_hooks_opt(the_repository, "post-worktree-add", &opt);
-+ }
++ ret = run_hooks_opt(the_repository, "post-checkout", &opt);
++ }
+
++ hook_ret = run_post_worktree_add_hook(wt->path);
++ if (!ret)
++ ret = hook_ret;
+ }
+
strvec_clear(&child_env);
- strbuf_release(&sb);
- strbuf_release(&symref);
## t/t2400-worktree-add.sh ##
@@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-checkout hook' '
@@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-ch
+ test_when_finished "rm -rf .git/hooks" &&
+ mkdir .git/hooks &&
+ test_hook -C "$2" post-worktree-add <<-\EOF &&
-+ {
-+ echo $*
-+ git rev-parse --git-dir --show-toplevel
-+ } >hook.actual
++ test "$#" = 0 &&
++ git rev-parse --git-dir --show-toplevel >hook.actual
+ EOF
+ {
-+ echo $(pwd)/$1 $1 &&
+ echo $(pwd)/${2:-.git}/worktrees/$1 &&
+ echo $(pwd)/$1
+ } >hook.expect
@@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-ch
+ test_cmp hooks.expect wobble/hooks.actual
+'
+
-+test_expect_success 'failing post-checkout hook suppresses post-worktree-add hook' '
++test_expect_success 'failing post-checkout hook does not suppress post-worktree-add hook' '
+ test_when_finished "rm -rf .git/hooks" &&
+ mkdir .git/hooks &&
+ test_hook post-checkout <<-\EOF &&
@@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-ch
+ >post-worktree-add.ran
+ EOF
+ test_must_fail git worktree add wozzle &&
-+ test_path_is_missing wozzle/post-worktree-add.ran
++ test_path_is_file wozzle/post-worktree-add.ran
+'
+
+test_expect_success 'failing post-worktree-add hook leaves worktree in place' '
@@ t/t2400-worktree-add.sh: test_expect_success '"add" in bare repo invokes post-ch
+ test_path_is_missing hook.ran
+'
+
-+test_expect_success 'post-worktree-add hook gets absolute path with relative worktrees' '
++test_expect_success 'post-worktree-add hook can derive path with relative worktrees' '
+ test_when_finished "rm -rf relhook" &&
+ git init relhook &&
+ test_commit -C relhook base &&
+ test_hook -C relhook post-worktree-add <<-\EOF &&
-+ echo $* >hook.actual
++ test "$#" = 0 &&
++ git rev-parse --show-toplevel >hook.actual
+ EOF
+ git -C relhook worktree add --relative-paths --detach wt &&
-+ echo $(pwd)/relhook/wt wt >hook.expect &&
++ echo $(pwd)/relhook/wt >hook.expect &&
+ test_cmp hook.expect relhook/wt/hook.actual
+'
+2: 7e109ece23 ! 2: 3de87064c0 worktree: add post-worktree-remove hook
@@ Commit message
+ ## Documentation/config/hook.adoc ##
+@@ Documentation/config/hook.adoc: hook.jobs::
+ pre-commit;;
+ post-checkout;;
+ post-worktree-add;;
++post-worktree-remove;;
+ push-to-checkout;;
+ post-commit;;
+ Access the working tree, index, or repository state.
+
## Documentation/githooks.adoc ##
-@@ Documentation/githooks.adoc: runs after the post-checkout hook, and is skipped if that hook fails. +@@ 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: runs after the post-checkout hook, and is skipped
## builtin/worktree.c ##
-@@ builtin/worktree.c: static void delete_worktrees_dir_if_empty(void)
- free(path);
+@@ builtin/worktree.c: static int run_post_worktree_add_hook(const char *path)
+ return run_hooks_opt(the_repository, "post-worktree-add", &hook_opt);
}
+static int run_post_worktree_remove_hook(const char *path, const char *id)
3: 143da548e4 = 3: 7989a1d6a2 worktree: run post-worktree-remove hook when pruning -: ---------- > 4: 95ab61e377 worktree: add post-worktree-move hook
Signature · 1 line
2.54.0
Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Quoted 9 lines · Domen Kožar
Hi everyone,
First, apologies that my earlier reply reached the list as a separate message rather than as part of this thread. This is my first patch series submitted by email, and I am still getting the threading details right. I have made sure this reroll is plain text and correctly threaded.
Thanks,
Domen
Hi Domen,
I love the idea of having hooks for worktrees, especially now that they are becoming more popular for having agents work on tasks in parallel.
I'll try to set aside some time to dive into the individual patches, but I would encourage you to take a look at b4[1][2] (if you haven't already) as it greatly simplifies working with patch series.
Best,
Caleb
[1]: https://b4.docs.kernel.org/en/latest/
[2]: https://github.com/mricon/b4
Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Quoted 8 lines · Caleb White
Quoted 7 lines · Domen Kožar
Hi everyone,
First, apologies that my earlier reply reached the list as a separate message rather than as part of this thread. This is my first patch series submitted by email, and I am still getting the threading details right. I have made sure this reroll is plain text and correctly threaded.
Quoted 8 lines
Quoted 2 lines
Thanks,
DomenHi Domen,
I love the idea of having hooks for worktrees, especially now that they are becoming more popular for having agents work on tasks in parallel.
Before going there, we need to consider if these hooks are necessary in the first place. If you _always_ want to perform something before or after running "git worktree add" or "git worktree remove", you can instruct your agents to use "git wt" script when they want to run "git worktree", and install a "git-wt" script on their $PATH, which essentially would be something like
#!/bin/sh # git worktree [add/remove] ... case "$1" in add) ... do whatever you want to do before add ... ;; remove) ... do whatever you want to do before remove ... ;; esac git worktree "$@" case "$1" in add) ... do whatever you want to do after add ... ;; remove) ... do whatever you want to do after remove ... ;; esac
The users would need to write the "... do whatever you want to do" part as the hook script _anyway_, and unless there are compelling reason why these _must_ be implemented as hooks, you should resist the temptation to pile more hooks on the system.
Having said all that.
There are five valid reasons you might still want to have a hook in a Git command or operation:
(1) A hook that countermands the normal decision made by the underlying command. Examples of this class are the 'update' hook and the 'pre-commit' hook.
(2) A hook that operates on data generated after the command starts to run. The ability to munge the commit log message via the 'commit-msg' hook is an example. You cannot easily prepare what the 'commit-msg' hook may produce before you run 'git commit'.
(3) A hook that operates on the remote end of the connection that you may not otherwise have access to, other than over the Git protocol. An example is the 'post-update' hook that runs update-server-info().
(4) A hook that runs under a lock acquired by the command for mutual exclusion. Currently there is no example, but if we allowed the 'update' hook to modify the commit that was pushed through a send-pack and receive-pack pair (which was discussed on the list a while ago), it would be a good example of this.
(5) A hook that is run differently depending on the outcome of the command. The 'post-merge' hook conditionally run by 'git pull' is an example of this (it is not run if no merge takes place). Another example is the 'post-checkout' hook that gets information that is otherwise harder to get (namely, whether it was a branch checkout or a file checkout -- you can figure it out by examining the command line, but that is already part of the processing 'git checkout' does anyway, so there is no need to force duplication of that code in userland).
If you cannot do an equivalent operation from outside the Git command for the above classes of operations, you need hooks for them.
On the other hand, if you want to always trigger an action before or after running a Git operation locally, you do not need a hook. This is true even if the action you perform after running a Git operation depends on what happened (class (5) above), provided the result is easily observable after the fact.
Of course, one very valid exception to the above policy is when an action is common enough that the policy effectively forces everyone to reinvent the same wrapper. We may be better off adding it as an officially supported hook in such a case.
But for the hooks proposed in this topic, I do not think such an exception applies.
Thanks.
Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Hi Junio,
Thanks for laying out the criteria for adding hooks.
Quoted 2 lines
But for the hooks proposed in this topic, I do not think such an exception applies.
I agree that a wrapper is sufficient when all callers are under the user's control. The problem I am trying to solve is that the component which needs the notification does not control the component invoking Git.
For example, devenv may register lifecycle handling for a repository, but worktrees can subsequently be created or removed by an IDE, a coding agent, another worktree tool, a script, or the user directly. Requiring each of those callers to discover and use the same wrapper makes the notification optional in practice. A repository hook provides one place where that lifecycle behavior can be registered regardless of which caller invokes Git.
"git worktree prune" is also difficult to reproduce reliably in a wrapper. One invocation can remove zero or many administrative entries, and Git knows exactly which entries it actually removes. A wrapper could compare "git worktree list" before and after the command, but that is not an authoritative event stream, can race another worktree operation, and has limited information when an entry is already damaged.
Alexander provided another concrete example later in the thread: Xcode and several related tools keep substantial path-keyed state outside the worktree. Agents invoking "git worktree remove" or "git worktree prune" directly leave many gigabytes of state behind even though a cleanup wrapper exists. Phillip also mentioned having an unpublished add-hook patch for copying per-worktree files such as config.mak.
That said, I take the point about avoiding a proliferation of hooks. Instead of adding three separate hook names, would a single "post-worktree" hook address that concern? It could use a fixed interface such as:
post-worktree add <id> "" <new-path>
post-worktree move <id> <old-path> <new-path>
post-worktree remove <id> <old-path> ""
All paths would be absolute. Pruning would issue one "remove" event for each entry actually pruned, and none under --dry-run. As with the current series, the hook would only report an operation that has taken effect and could not undo it.
This would also address Caleb's comment about passing information Git already has rather than requiring the hook to query it, and it avoids using the argument count to distinguish events.
Would that narrower interface, together with the need to observe operations from callers that cannot be required to use a wrapper, meet the bar for a native hook? If not, I would appreciate guidance before spending time on a reroll.
Thanks,
Domen
Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Hi Junio and everyone,
A gentle ping on the single "post-worktree" hook proposal below.
As coding agents increasingly create and discard worktrees, reliable lifecycle notifications matter more because tools managing environments and cleanup cannot control every caller invoking Git.
Would this interface address your concerns? I'm happy to prepare v3 if we can agree on the direction.
Thanks,
Domen
Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Quoted 8 lines · Domen Kožar
A gentle ping on the single "post-worktree" hook proposal below.
As coding agents increasingly create and discard worktrees, reliable lifecycle notifications matter more because tools managing environments and cleanup cannot control every caller invoking Git.
Would this interface address your concerns? I'm happy to prepare v3 if we can agree on the direction.
The problem is multiple callers invoking Git with different intentions. What if git worktree add learned an option --id=<string> which is then stored in the worktree? This would communicate that whoever calls git-worktree(1) commands with this ID is the one who owns it.[1] git-worktree(1) commands which do not get an --id (and specifically that ID) does nothing to owned worktrees.
[1]: You know, among friendly and cooperating processes.
Then you don’t need hooks for adding, removing, pruning and whatever else.
I could imagine an agent tool that uses it’s own ID to manage a pool of worktrees.
I could imagine more metadata, like recording a timestamp for worktree expiry... but you don’t need anything more in git-worktree(1) itself if you have this ID concept. Then you can let tools implement it themselves. They won’t have their worktrees bothered/clobbered by other tools, so they can manage the whole lifecycle in peace.
Re: [PATCH v2 0/4] worktree: add lifecycle hooks
Hi Kristoffer,
Thanks for the suggestion. An ownership ID could help tools avoid interfering with each other's worktrees.
The case I'm trying to support has two separate participants: an IDE or agent creates and removes worktrees, while devenv provisions and cleans up the development environment associated with each one.
devenv doesn't own those worktrees or control the commands that create them. It needs to observe their lifecycle regardless of which tool invokes Git. Even if the creator supplies an ownership ID, how would devenv learn that a worktree was added or removed?
Requiring the creator to explicitly notify devenv would bring back the per-tool integration requirement that motivates the hook. The same applies to worktrees created or removed directly by the user.
Pruning is another useful case: Git knows which administrative entries it actually removes, including entries whose directories are already gone. A hook could report each removal directly to interested tooling.
Would ownership IDs and lifecycle notifications serve complementary purposes here?
Thanks,
Domen
Re: [PATCH v2 0/4] worktree: add lifecycle hooks
In reply to Junio C HamanoHi Junio,
Thank you for the detailed explanation. I agree that a wrapper is the right answer when the user controls the invocation of git worktree.
I tried that approach for a tool I am building: it creates an isolated container environment for each worktree. This matters in particular for projects with databases and migrations, where two agents working in parallel must not share an environment.
The problem is that, in this case, the worktree is often created by the IDE rather than by the user or the agent. For example, Codex in VS Code creates worktrees itself when it starts parallel agent sessions. A git-wt wrapper is therefore bypassed, and asking agents to use one does not help: by the time an agent begins work, the worktree may already exist and its environment needs to have been provisioned.
It is possible to approximate this with polling or instructions in an AGENTS.md file, but neither provides a reliable lifecycle boundary. There is a race between worktree creation and the agent beginning work, and cleanup on removal has the same issue.
So I wanted to report a concrete case where wrappers do not reach the actor that performs the operation. This has also been the most consistent feedback I have heard from people using parallel AI agents: they do not want to replace every worktree caller with a wrapper; they need a repository-local way to react when Git creates, moves, or removes a worktree.
Best regards,
Maciej Ciemborowicz
A message that is not in the archive (sent off-list or not yet received): [email protected]
PATCH v3 1/2 worktree: add post-worktree lifecycle hook
Tools that manage per-worktree development environments need to observe worktrees created, moved, or removed by other programs. Wrapping the worktree command only helps when every caller uses the wrapper, and post-checkout does not run for add --no-checkout or --orphan. There is no notification for moving or removing a worktree.
Add one post-worktree hook for these operations. Pass the event name, worktree identifier, old absolute path, and new absolute path as four arguments, using an empty string for a path that does not apply. An explicit event name lets one handler manage the whole lifecycle without using argument count to distinguish operations, as the earlier series with three separate hooks did.
Run the hook in the invoking repository with its normal environment, rather than changing to the affected worktree. Passing both paths lets handlers target the new worktree when needed and keeps the execution context consistent when a worktree has been removed.
Run the add event after post-checkout even when that hook fails, because the worktree remains present. A failing lifecycle hook affects the command's exit status without undoing the completed operation. Preserve post-checkout's failure status if both hooks fail.
Document the interface and cover ordinary, bare, and linked callers, no-checkout and orphan worktrees, relative paths, paths with spaces, configured hooks, and hook failures.
--- Documentation/config/hook.adoc | 1 + Documentation/githooks.adoc | 43 +++++++++++ builtin/worktree.c | 56 ++++++++++---- t/t2400-worktree-add.sh | 132 +++++++++++++++++++++++++++++++++ t/t2403-worktree-move.sh | 113 ++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 16 deletions(-)
Documentation/config/hook.adoc +1 -0
@@ -94,6 +94,7 @@ hook.jobs:: Receive a commit message file and may rewrite it in place. `pre-commit`;; `post-checkout`;; +`post-worktree`;; `push-to-checkout`;; `post-commit`;; Access the working tree, index, or repository state.
Documentation/githooks.adoc +43 -0
49 lines not on this page · raw message
builtin/worktree.c +40 -16
94 lines not on this page · raw message
t/t2400-worktree-add.sh +132 -0
138 lines not on this page · raw message
t/t2403-worktree-move.sh +113 -0
125 lines not on this page · raw message
Signature · 1 line
2.54.0
Re: [PATCH v3 1/2] worktree: add post-worktree lifecycle hook
Hi Domen
Quoted 4 lines · Domen Kožar
Tools that manage per-worktree development environments need to observe worktrees created, moved, or removed by other programs. Wrapping the worktree command only helps when every caller uses the wrapper, and post-checkout does not run for add --no-checkout or --orphan.
There is quite a lot of implicit context in that sentance - it assumes the reader has read the previous discussions about this hook. It would be clearer if it was explicit that it was talking about (a) using a wrapper script to perform additional operations around "git worktree" and (b) using the post-checkout hook as a proxy for detecting when a new worktree is added.
Quoted 1 line
There is no notification for moving or removing a worktree.
A wrapper can do that though?
Quoted 3 lines
Add one post-worktree hook for these operations. Pass the event name, worktree identifier, old absolute path, and new absolute path as four arguments, using an empty string for a path that does not apply.
As I've said before, I don't think the worktree id is very useful because git commands do not take it as an argument. Someone mentioned wanting to use it as a key to look up other information about the worktree, but different repositories can have worktrees with the same id so I think we'd be better passing the worktree's absolute git-dir.
There was also a comment from someone pointing out that notifying callers after the worktree is removed maybe too late for them to clean up the resources associated with that worktree. If we had a "worktree-event" hook we could run it before the worktree is removed, but after a worktree is added or moved.
Having four arguments for add and remove seems to me to be unecessarily complicated - why not just pass the paths that are relavent, rather than padding the arguments with empty strings?
I see the hook is run serially, is that really necessary?
Quoted 9 lines
An
explicit event name lets one handler manage the whole lifecycle without using argument count to distinguish operations, as the earlier series with three separate hooks did.Run the hook in the invoking repository with its normal environment, rather than changing to the affected worktree. Passing both paths lets handlers target the new worktree when needed and keeps the execution context consistent when a worktree has been removed.
It also means that GIT_DIR and GIT_WORK_TREE are consistent with the directory that the hook is run in. The downside for adding a worktree is that the hook needs to clear those variables if it is going to run git in the new worktree.
Quoted 3 lines
Run the add event after post-checkout even when that hook fails, because the worktree remains present. A failing lifecycle hook affects the command's exit status without undoing the completed operation.
Ok, that matches what we do with a failing post-checkout hook
Quoted 1 line
Preserve post-checkout's failure status if both hooks fail.
Ok
Quoted 3 lines
Document the interface and cover ordinary, bare, and linked callers, no-checkout and orphan worktrees, relative paths, paths with spaces, configured hooks, and hook failures.
I'm not sure why we need separate tests for paths with spaces or orphan worktrees
I think adding a hook for worktree lifecycle events is a useful addition. Using a wrapper script is a pain because it is easy to forget to use it and it is hard to make it work if you have several different entities that want to be notified about worktrees being added or removed.
Thanks
Phillip
Quoted 453 lines
Co-authored-by Claude Fable 5 <[email protected]>Signed-off-by Domen Kožar <[email protected]>--- Documentation/config/hook.adoc | 1 + Documentation/githooks.adoc | 43 +++++++++++ builtin/worktree.c | 56 ++++++++++---- t/t2400-worktree-add.sh | 132 +++++++++++++++++++++++++++++++++ t/t2403-worktree-move.sh | 113 ++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 16 deletions(-)Documentation/config/hook.adoc +1
-0@@ -94,6 +94,7 @@ hook.jobs:: Receive a commit message file and may rewrite it in place. `pre-commit`;; `post-checkout`;; +`post-worktree`;; `push-to-checkout`;; `post-commit`;; Access the working tree, index, or repository state.Documentation/githooks.adoc +43
-0@@ -215,6 +215,49 @@ This hook can be used to perform repository validity checks, auto-display differences from the previous HEAD if different, or set working dir metadata properties. +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`, +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. + +The parameters for each event are: + + post-worktree add <id> "" <new-path> + post-worktree move <id> <old-path> <new-path> + post-worktree remove <id> <old-path> "" + +The empty strings are passed as arguments, so all events have exactly +four parameters. + +The hook runs in the repository where the command was invoked, following +the working directory and environment rules described above. It does not +change to the added or moved working tree. To run Git commands there, +clear the repository environment variables and use the new path, for +example: + +------------ +(unset $(git rev-parse --local-env-vars); git -C "$4" status) +------------ + +The `add` event runs after the new working tree has been set up, including +with `--no-checkout` and `--orphan`. It runs after `post-checkout`, even +if that hook fails. The `move` event runs after the working tree and its +administrative files have been moved. The `remove` event runs after the +working tree has been deleted or its administrative entry removed. + +The hook cannot undo the worktree operation. A non-zero exit status is +reflected in the command's exit status, but leaves the completed operation +in place. If `post-checkout` fails during `git worktree add`, its exit +status takes precedence over that of `post-worktree`. + +This hook can be used to set up, relocate, or tear down per-worktree +development environments, or to maintain registrations with external +tools. Hook scripts should ignore events they do not handle. + post-merge ~~~~~~~~~~builtin/worktree.c +40
-16@@ -168,6 +168,15 @@ static void delete_worktrees_dir_if_empty(void)free (path); } +static int run_post_worktree_hook (const char *event,const char *id, +const char *old_path,const char *new_path) +{ +struct run_hooks_opt hook_opt =RUN_HOOKS_OPT_INIT_FORCE_SERIAL ; + +strvec_pushl (&hook_opt.args, event, id, old_path, new_path,NULL ); +return run_hooks_opt (the_repository,"post-worktree" , &hook_opt); +} +static void prune_worktree (const char *id,const char *reason) {if (show_only || verbose)@@ -604,21 +613,30 @@ static int add_worktree(const char *path, const char *refname, }/* -* Hook failure does not warrant worktree deletion, so run hook after -* is_junk is cleared, but do return appropriate code when hook fails. +* Hook failures do not warrant worktree deletion, so run hooks after +* is_junk is cleared, but do return appropriate code when a hook +* fails. */ -if (!ret && opts->checkout && !opts->orphan) { -struct run_hooks_opt opt =RUN_HOOKS_OPT_INIT_FORCE_SERIAL ; - -strvec_pushl (&opt.env,"GIT_DIR" ,"GIT_WORK_TREE" ,NULL ); -strvec_pushl (&opt.args, -oid_to_hex (null_oid (the_hash_algo)), -oid_to_hex (&commit->object.oid), -"1" , -NULL ); - opt.dir = path; - - ret =run_hooks_opt (the_repository,"post-checkout" , &opt); +if (!ret) { +int hook_ret; + +if (opts->checkout && !opts->orphan) { +struct run_hooks_opt opt =RUN_HOOKS_OPT_INIT_FORCE_SERIAL ; + +strvec_pushl (&opt.env,"GIT_DIR" ,"GIT_WORK_TREE" ,NULL ); +strvec_pushl (&opt.args, +oid_to_hex (null_oid (the_hash_algo)), +oid_to_hex (&commit->object.oid), +"1" , +NULL ); + opt.dir = path; + + ret =run_hooks_opt (the_repository,"post-checkout" , &opt); + } + + hook_ret =run_post_worktree_hook ("add" , wt->id,"" , wt->path); +if (!ret) + ret = hook_ret; }strvec_clear (&child_env);@@ -1305,7 +1323,8 @@ 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; ac =parse_options (ac, av, prefix, options, git_worktree_move_usage,0 );@@ -1348,14 +1367,17 @@ static int move_worktree(int ac, const char **av, const char *prefix, errmsg.buf);strbuf_release (&errmsg); + old_path =xstrdup (wt->path);if (rename (wt->path, dst.buf) ==-1 )die_errno (_ ("failed to move '%s' to '%s'" ), wt->path, dst.buf);update_worktree_location (wt, dst.buf, use_relative_paths); + ret =run_post_worktree_hook ("move" , wt->id, old_path, wt->path); +free (old_path);strbuf_release (&dst);free_worktrees (worktrees); -return 0 ; +return ret; }/* @@ -1473,6 +1495,8 @@ static int remove_worktree(int ac, const char **av, const char *prefix, ret |=delete_git_dir (wt->id);delete_worktrees_dir_if_empty (); + ret |=run_post_worktree_hook ("remove" , wt->id, wt->path,"" ); +free_worktrees (worktrees);return ret; }t/t2400-worktree-add.sh +132
-0@@ -1172,6 +1172,138 @@ test_expect_success '"add" in bare repo invokes post-checkout hook' ' test_cmp hook.expect goozy/hook.actual ' +# Install a post-worktree hook and write the output expected for adding +# worktree $1. Repo $2 defaults to "."; the caller worktree is $3. +post_worktree_add_hook () { + test_when_finished "rm -rf .git/hooks" && + mkdir .git/hooks && + test_hook -C "$2" post-worktree <<-\EOF && + test "$#" = 4 && + { + printf "%s\n" "$@" && + test-tool path-utils real_path . && + git rev-parse --absolute-git-dir + } >hook.actual + EOF + { + test_write_lines add "$1" "" "$(pwd)/$1" && + (cd "${3:-${2:-.}}" && test-tool path-utils real_path .) && + git -C "${3:-${2:-.}}" rev-parse --absolute-git-dir + } >hook.expect +} + +test_expect_success '"add" invokes post-worktree hook' ' + post_worktree_add_hook wanda && + git worktree add wanda && + test_cmp hook.expect hook.actual +' + +test_expect_success '"add" in other worktree invokes post-worktree hook there' ' + post_worktree_add_hook wilbur "" wanda && + git -C wanda worktree add ../wilbur && + test_cmp hook.expect wanda/hook.actual +' + +test_expect_success '"add --no-checkout" still invokes post-worktree hook' ' + post_worktree_add_hook wendy && + git worktree add --no-checkout wendy && + test_cmp hook.expect hook.actual +' + +test_expect_success '"add --orphan" invokes post-worktree hook' ' + post_worktree_add_hook winnie && + git worktree add --orphan winnie && + test_cmp hook.expect hook.actual +' + +test_expect_success '"add" in bare repo invokes post-worktree hook there' ' + rm -rf bare2 && + git clone --bare . bare2 && + post_worktree_add_hook willow bare2 && + git -C bare2 worktree add --detach ../willow && + test_cmp hook.expect bare2/hook.actual +' + +test_expect_success '"add" runs post-worktree after post-checkout' ' + test_when_finished "rm -rf .git/hooks" && + mkdir .git/hooks && + test_hook post-checkout <<-\EOF && + echo post-checkout >>"$(git rev-parse --git-common-dir)/hooks.actual" + EOF + test_hook post-worktree <<-\EOF && + echo post-worktree >>"$(git rev-parse --git-common-dir)/hooks.actual" + EOF + test_write_lines post-checkout post-worktree >hooks.expect && + git worktree add wobble && + test_cmp hooks.expect .git/hooks.actual +' + +test_expect_success 'failing post-checkout hook does not suppress post-worktree hook' ' + test_when_finished "rm -rf .git/hooks" && + mkdir .git/hooks && + test_hook post-checkout <<-\EOF && + exit 2 + EOF + test_hook post-worktree <<-\EOF && + >post-worktree.ran && + exit 3 + EOF + test_expect_code 2 git worktree add wozzle && + test_path_is_file post-worktree.ran +' + +test_expect_success 'failing post-worktree hook leaves worktree in place' ' + test_when_finished "rm -rf .git/hooks" && + mkdir .git/hooks && + test_hook post-worktree <<-\EOF && + exit 1 + EOF + test_expect_code 1 git worktree add wilma && + git worktree list --porcelain >out && + test_grep -F "worktree $(pwd)/wilma" out +' + +test_expect_success 'failed "add" does not invoke post-worktree hook' ' + test_when_finished "rm -rf .git/hooks occupied" && + mkdir .git/hooks && + test_hook post-worktree <<-\EOF && + >hook.ran + EOF + mkdir occupied && + : >occupied/blocker && + test_must_fail git worktree add occupied && + test_path_is_missing hook.ran +' + +test_expect_success 'post-worktree add 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 "$#" = 4 && + printf "%s\n" "$@" >hook.actual + EOF + git -C relhook worktree add --relative-paths --detach wt && + test_write_lines add wt "" "$(pwd)/relhook/wt" >hook.expect && + test_cmp hook.expect relhook/hook.actual +' + +test_expect_success 'configured post-worktree hook preserves paths with spaces' ' + test_when_finished "rm -rf confighook" && + git init confighook && + test_commit -C confighook base && + write_script confighook/record-hook <<-\EOF && + test "$#" = 4 && + printf "%s\n" "$@" >hook.actual + EOF + git -C confighook config hook.lifecycle.command ./record-hook && + git -C confighook config hook.lifecycle.event post-worktree && + git -C confighook worktree add --detach "wt with spaces" && + id=$(basename "$(git -C "confighook/wt with spaces" rev-parse --absolute-git-dir)") && + test_write_lines add "$id" "" "$(pwd)/confighook/wt with spaces" >hook.expect && + test_cmp hook.expect confighook/hook.actual +' + test_expect_success '"add" an existing but missing worktree' ' git worktree add --detach pneu && test_must_fail git worktree add --detach pneu &&t/t2403-worktree-move.sh +113
-0@@ -82,6 +82,59 @@ test_expect_success 'move worktree' ' test_cmp expected2 actual2 ' +test_expect_success '"move" invokes post-worktree hook in the calling repository' ' + test_hook post-worktree <<-\EOF && + test "$#" = 4 || exit 1 + test "$1" = move || exit 0 + { + printf "%s\n" "$@" && + test-tool path-utils real_path . && + git rev-parse --absolute-git-dir + } >hook.actual + EOF + git worktree add --detach hook-source && + git worktree move hook-source hook-destination && + { + test_write_lines move hook-source "$(pwd)/hook-source" "$(pwd)/hook-destination" && + test-tool path-utils real_path . && + git rev-parse --absolute-git-dir + } >hook.expect && + test_cmp hook.expect hook.actual +' + +test_expect_success 'failing post-worktree move event leaves worktree moved' ' + test_hook post-worktree <<-\EOF && + test "$1" = move || exit 0 + 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 +' + +test_expect_success 'post-worktree move keeps the ID and passes absolute paths with spaces' ' + test_when_finished "rm -rf movehook" && + git init movehook && + test_commit -C movehook base && + git -C movehook worktree add --relative-paths --detach "source tree" && + git -C movehook worktree add --detach caller && + id=$(basename "$(git -C "movehook/source tree" rev-parse --absolute-git-dir)") && + test_hook -C movehook post-worktree <<-\EOF && + test "$#" = 4 && + { + printf "%s\n" "$@" && + git rev-parse --show-toplevel + } >hook.actual + EOF + git -C movehook/caller worktree move --relative-paths "../source tree" "../destination tree" && + test_write_lines move "$id" "$(pwd)/movehook/source tree" \ + "$(pwd)/movehook/destination tree" "$(pwd)/movehook/caller" >hook.expect && + test_cmp hook.expect movehook/caller/hook.actual && + test_path_is_dir "movehook/destination tree" +' + test_expect_success 'move main worktree' ' test_must_fail git worktree move . def '@@ -246,6 +299,66 @@ test_expect_success 'not remove a repo with initialized submodule' ' ) ' +test_expect_success '"remove" invokes post-worktree remove event' ' + test_hook post-worktree <<-\EOF && + test "$#" = 4 || exit 1 + test "$1" = remove || exit 0 + printf "%s\n" "$@" >hook.actual + EOF + git worktree add --detach wt-hooked && + git worktree remove wt-hooked && + test_write_lines remove wt-hooked "$(pwd)/wt-hooked" "" >hook.expect && + test_cmp hook.expect hook.actual +' + +test_expect_success '"remove" of missing worktree invokes post-worktree hook' ' + test_when_finished "rm -rf wt-moved-away" && + test_hook post-worktree <<-\EOF && + test "$1" = remove || exit 0 + printf "%s\n" "$@" >hook.actual + EOF + rm -f hook.actual && + git worktree add --detach wt-elsewhere && + mv wt-elsewhere wt-moved-away && + git worktree remove wt-elsewhere && + test_write_lines remove wt-elsewhere "$(pwd)/wt-elsewhere" "" >hook.expect && + test_cmp hook.expect hook.actual +' + +test_expect_success 'refused "remove" does not invoke post-worktree hook' ' + git worktree add --detach wt-kept && + test_when_finished "git worktree remove --force --force wt-kept || :" && + test_hook post-worktree <<-\EOF && + >hook.ran + EOF + git worktree lock wt-kept && + test_must_fail git worktree remove wt-kept && + test_path_is_missing hook.ran +' + +test_expect_success 'failing post-worktree remove event fails "remove", worktree is gone' ' + test_hook post-worktree <<-\EOF && + test "$1" = remove || exit 0 + exit 1 + EOF + git worktree add --detach wt-doomed && + test_must_fail git worktree remove wt-doomed && + test_path_is_missing wt-doomed && + test_path_is_missing .git/worktrees/wt-doomed +' + +test_expect_success 'post-worktree remove preserves paths with spaces' ' + git worktree add --detach "remove tree" && + id=$(basename "$(git -C "remove tree" rev-parse --absolute-git-dir)") && + test_hook post-worktree <<-\EOF && + test "$#" = 4 && + printf "%s\n" "$@" >hook.actual + EOF + git worktree remove "remove tree" && + test_write_lines remove "$id" "$(pwd)/remove tree" "" >hook.expect && + test_cmp hook.expect hook.actual +' + test_expect_success 'move worktree with absolute path to relative path' ' test_config worktree.useRelativePaths false && git worktree add ./absolute &&
PATCH v3 2/2 worktree: notify post-worktree hook when pruning
In reply to a message not in the archiveA 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.
--- 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(-)
Documentation/githooks.adoc +5 -2
@@ -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
builtin/worktree.c +36 -13
105 lines not on this page · raw message
t/t2401-worktree-prune.sh +102 -0
108 lines not on this page · raw message
worktree.c +0 -1
@@ -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 );
worktree.h +3 -3
@@ -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.
Signature · 1 line
2.54.0
Re: [PATCH v3 2/2] worktree: notify post-worktree hook when pruning
t/t2401-worktree-prune.sh test_expect_success 'prune duplicate (main/linked)' ' · line 203
Quoted 2 lines · Domen Kožar
t/t2401-worktree-prune.sh +2
-0+ sed "s/w2/w1/" .git/worktrees/w2/gitdir >.git/worktrees/w2/gitdir.new && + mv .git/worktrees/w2/gitdir.new .git/worktrees/w2/gitdir &&
This substitution misfires if we’re inside a path containing another ‘w2’ as a substring. How about
cp .git/worktrees/w1/gitdir .git/worktrees/w2/gitdir &&
(There’s already another instance of this bug in t2401-worktree-prune.sh, and I observed that failing in https://launchpad.net/~git-core/+archive/ubuntu/candidate/+build/33960210.)
Anders
v3 cover worktree: add post-worktree lifecycle hook
In reply to Domen KožarTools 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/[email protected]/
https://lore.kernel.org/git/[email protected]/
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 <[email protected]>
## 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 <[email protected]>
- Co-Authored-By: Claude Fable 5 <[email protected]>
-
- ## 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.
Signature · 1 line
2.54.0
Re: [PATCH v3 0/2] worktree: add post-worktree lifecycle hook
The topic _claims_ to have been sent with
X-Mailer: git-send-email 2.54.0
but it seems that the thread structure is so screwed up that some automation machinery I use seems to be having trouble locating its thread.
Here is what I see:
* Cover letter
Subject: [PATCH v3 0/2] worktree: add post-worktree lifecycle hook
Message-Id: <[email protected]>
In-Reply-To: <[email protected]>
References: <[email protected]>
* First patch
Subject: [PATCH v3 1/2] worktree: add post-worktree lifecycle hook
Message-Id: <[email protected]>
In-Reply-To: <[email protected]>
References: <[email protected]>
<[email protected]>
* Second patch
Subject: [PATCH v3 2/2] worktree: notify post-worktree hook when pruning
Message-Id: <[email protected]>
In-Reply-To: <[email protected]>
References: <[email protected]>
<[email protected]>
Notice that the "cover letter" that are named on the In-Reply-To:
header of the two patches do not match the cover letter message at all? I actually doubt the <[email protected]> message appears anywhere in the list archive.
... goes and visits the URL and gets "not found" ...
https://lore.kernel.org/git/[email protected]/
Somebody should find out how send-email is misused to produce such a broken threading, and add some documentation to the send-email manual describing what _not_ to do, and/or update send-email code to detect such misconfiguration that caused it.
Thanks.