Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixed

- **`wt merge` no longer runs `post-commit` in a worktree the user never chose**: the merge removes the worktree that hook is anchored on before the hook starts, so the pipeline was spawned into an emptied path. Where the worktree sat inside the repository, git discovery walked up from there and the hook's commands ran in the primary worktree; anywhere else they didn't run at all, and the only record was a log file nothing reads. The merge now prints `▲ Skipped post-commit: …` naming that worktree, and points at `pre-remove` for work that must finish there. `--no-remove`, merging on the target branch, `wt step commit`, and `wt step squash` still run it. ([#4049](https://github.com/max-sixty/worktrunk/pull/4049))

## 0.77.0

### Improved
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Why: wt installs a `signal_hook` SIGINT/SIGTERM handler so it can forward signal

### Background Hook Pipelines Run Concurrently, Per Source

A `post-*` batch spawns one detached pipeline per source, and they run at once. Don't serialize them: `post-start` is documented for dev servers and `wt step tether`, so a user hook that never exits would block the project's forever, and `wt merge`'s batch spans two worktrees (`post-commit` anchors on the invoking worktree, the rest on the destination) so chaining also drops hooks behind a head whose worktree is already removed. The cost is that two hooks touching one worktree's git state can collide — two `git pull`s there append to each other's `FETCH_HEAD` and both fail — which `wt hook --help` tells users to avoid by keeping dependent commands in one source.
A `post-*` batch spawns one detached pipeline per source, and they run at once. Don't serialize them: `post-start` is documented for dev servers and `wt step tether`, so a user hook that never exits would block the project's forever, and `wt merge`'s batch spans two worktrees (`post-commit` anchors on the invoking worktree, the rest on the destination) so chaining also drops hooks behind a head whose worktree is already removed. That span is also why the flush can reach a pipeline whose anchor is gone: the merge removes `post-commit`'s worktree first, so `run_hooks_background` drops that pipeline and reports it rather than spawning it into a path whose git discovery would resolve to the primary worktree. The cost is that two hooks touching one worktree's git state can collide — two `git pull`s there append to each other's `FETCH_HEAD` and both fail — which `wt hook --help` tells users to avoid by keeping dependent commands in one source.

## Hook Output Logs

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The most common creation hook is `post-start` — it runs background tasks (dev
| `pre-remove` | Cleanup before worktree deletion: saving test artifacts, backing up state. Runs in the worktree being removed |
| `post-remove` | Stopping dev servers, removing containers, notifying external systems. Template variables reference the removed worktree |

During `wt merge`, the blocking hooks run in this order: pre-commit → pre-merge → pre-remove. The `post-*` hooks all start together once the merge finishes, each in the worktree it is anchored on — post-merge, post-switch and post-remove in the destination, post-commit in the worktree the commit was made in. So `post-commit` can't be relied on for a merge that removes that worktree — the worktree is gone by the time the hook would start. Use `pre-remove` for work that must finish there, or `--no-remove` to keep the worktree. See [`wt merge`](/merge/#pipeline) for the complete pipeline.
During `wt merge`, the blocking hooks run in this order: pre-commit → pre-merge → pre-remove. The `post-*` hooks all start together once the merge finishes, each in the worktree it is anchored on — post-merge, post-switch and post-remove in the destination, post-commit in the worktree the commit was made in. A merge that removes that worktree reports `post-commit` as skipped instead of running it — the worktree is gone by the time the hook would start. Use `pre-remove` for work that must finish there, or `--no-remove` to keep the worktree. See [`wt merge`](/merge/#pipeline) for the complete pipeline.

# Security

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ $ wt merge --no-commit --no-rebase

`wt merge` runs these steps:

1. **Commit** — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in the background, and can't be relied on when step 7 removes the worktree they are anchored on. This step is skipped when squashing (the default) — changes are staged during the squash step instead. With `--no-squash`, this is the only commit step.
1. **Commit** — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in the background; when step 7 removes the worktree they are anchored on, the merge reports them rather than running them. This step is skipped when squashing (the default) — changes are staged during the squash step instead. With `--no-squash`, this is the only commit step.
2. **Squash** — Combines all commits since target into one (like GitHub's "Squash and merge"). Use `--stage` to control what gets staged: `all` (default), `tracked`, or `none`. Working-tree changes swept into the squash are backed up first to `refs/wt-backup/<branch>`. With `--no-squash`, individual commits are preserved.
3. **Rebase** — Rebases onto target, skipping when nothing needs replaying ([`wt step rebase`](/step/#wt-step-rebase) gives the conditions). A conflict stops the merge with the rebase left open in the worktree, to resolve or abort. With `--no-rebase`, the graph produced by earlier commit/squash steps is preserved and the target must be able to fast-forward to its tip.
4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](/hook/).
Expand Down
2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/hook.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/merge.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills/worktrunk/reference/hook.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills/worktrunk/reference/merge.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1487,7 +1487,7 @@ $ wt merge --no-commit --no-rebase

`wt merge` runs these steps:

1. **Commit** — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in the background, and can't be relied on when step 7 removes the worktree they are anchored on. This step is skipped when squashing (the default) — changes are staged during the squash step instead. With `--no-squash`, this is the only commit step.
1. **Commit** — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in the background; when step 7 removes the worktree they are anchored on, the merge reports them rather than running them. This step is skipped when squashing (the default) — changes are staged during the squash step instead. With `--no-squash`, this is the only commit step.
2. **Squash** — Combines all commits since target into one (like GitHub's "Squash and merge"). Use `--stage` to control what gets staged: `all` (default), `tracked`, or `none`. Working-tree changes swept into the squash are backed up first to `refs/wt-backup/<branch>`. With `--no-squash`, individual commits are preserved.
3. **Rebase** — Rebases onto target, skipping when nothing needs replaying ([`wt step rebase`](/step/#wt-step-rebase) gives the conditions). A conflict stops the merge with the rebase left open in the worktree, to resolve or abort. With `--no-rebase`, the graph produced by earlier commit/squash steps is preserved and the target must be able to fast-forward to its tip.
4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](/hook/).
Expand Down Expand Up @@ -1634,7 +1634,7 @@ The most common creation hook is `post-start` — it runs background tasks (dev
| `pre-remove` | Cleanup before worktree deletion: saving test artifacts, backing up state. Runs in the worktree being removed |
| `post-remove` | Stopping dev servers, removing containers, notifying external systems. Template variables reference the removed worktree |

During `wt merge`, the blocking hooks run in this order: pre-commit → pre-merge → pre-remove. The `post-*` hooks all start together once the merge finishes, each in the worktree it is anchored on — post-merge, post-switch and post-remove in the destination, post-commit in the worktree the commit was made in. So `post-commit` can't be relied on for a merge that removes that worktree — the worktree is gone by the time the hook would start. Use `pre-remove` for work that must finish there, or `--no-remove` to keep the worktree. See [`wt merge`](/merge/#pipeline) for the complete pipeline.
During `wt merge`, the blocking hooks run in this order: pre-commit → pre-merge → pre-remove. The `post-*` hooks all start together once the merge finishes, each in the worktree it is anchored on — post-merge, post-switch and post-remove in the destination, post-commit in the worktree the commit was made in. A merge that removes that worktree reports `post-commit` as skipped instead of running it — the worktree is gone by the time the hook would start. Use `pre-remove` for work that must finish there, or `--no-remove` to keep the worktree. See [`wt merge`](/merge/#pipeline) for the complete pipeline.

# Security

Expand Down
60 changes: 57 additions & 3 deletions src/commands/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@
//!
//! "Runs in" is the *anchor* — the executor's plan lookup key and render root,
//! not a config source. A `pre-start`'s new worktree need not exist when the
//! gate runs; the config came from the invoking worktree regardless.
//! gate runs; the config came from the invoking worktree regardless. A
//! background pipeline whose anchor no longer holds git data by the time the
//! announcer flushes is dropped and reported rather than spawned (see
//! [`report_orphaned_pipelines`]); `wt merge` is what reaches that, since it
//! removes `post-commit`'s anchor before the flush.
//!
//! **Invocation-resolved (no gate→exec mutation):** `pre-commit`,
//! `post-commit`, `pre-switch`, `wt hook <type>`, aliases. They resolve config
Expand Down Expand Up @@ -68,10 +72,11 @@ use anyhow::Context;
use color_print::cformat;
use worktrunk::HookType;
use worktrunk::config::{CommandConfig, format_hook_variables};
use worktrunk::git::{Repository, add_hook_skip_hint};
use worktrunk::git::{Repository, add_hook_skip_hint, holds_git_data};
use worktrunk::path::format_path_for_display;
use worktrunk::styling::{
eprintln, format_with_gutter, info_message, progress_message, verbosity, warning_message,
eprintln, format_with_gutter, hint_message, info_message, progress_message, verbosity,
warning_message,
};

use super::command_executor::{
Expand Down Expand Up @@ -411,11 +416,22 @@ impl Drop for HookAnnouncer<'_> {
/// When `show_branch` is true, the announce includes the branch name for
/// disambiguation in batch contexts (e.g., prune removing multiple worktrees):
/// `Running post-remove for feature: docs (user)`.
///
/// Pipelines whose anchor no longer holds git data are dropped first, so the
/// announce describes only what starts — see [`report_orphaned_pipelines`].
fn run_hooks_background(
repo: &Repository,
pipelines: Vec<PendingPipeline>,
show_branch: bool,
) -> anyhow::Result<()> {
let (pipelines, orphaned): (Vec<_>, Vec<_>) = pipelines
.into_iter()
.partition(|p| holds_git_data(&p.worktree_path));
report_orphaned_pipelines(&orphaned);
if pipelines.is_empty() {
return Ok(());
}

// Merge per-source summaries by hook type so user+project for the same
// type render as one clause: `post-merge: sync, push (user); build (project)`.
// Pull `display_path` off the first pipeline that has one — every hook
Expand Down Expand Up @@ -476,6 +492,44 @@ fn run_hooks_background(
Ok(())
}

/// Report background hook pipelines dropped because their anchor is gone.
///
/// A pipeline runs with its anchor as cwd, and the runner
/// ([`run_pipeline`](super::run_pipeline::run_pipeline)) opens the repository
/// by discovery from there. Once the anchor stops holding git data, discovery
/// walks up instead of stopping — so a worktree nested inside its repository
/// resolves to the *primary* worktree, and the steps (arbitrary project code)
/// would run in a worktree the user never chose. Outside the repository there
/// is nothing to walk up to and the runner only writes `failed to open
/// repository for pipeline` to a log nothing reads back. Both are silent, so
/// the drop is reported here instead.
///
/// Only `wt merge` reaches this. `post-commit` is anchored on the feature
/// worktree, and the removal renames that worktree into `.git/wt/trash/`
/// before the announcer flushes; every other background hook anchors on a
/// worktree its command keeps.
fn report_orphaned_pipelines(orphaned: &[PendingPipeline]) {
for pipeline in orphaned {
let hook_type = pipeline.hook_type;
let summary = format_pipeline_summary(&pipeline.steps);
let path = format_path_for_display(&pipeline.worktree_path);
eprintln!(
"{}",
warning_message(cformat!(
"Skipped {hook_type}: {summary} — worktree removed @ <bold>{path}</>"
))
);
}
if !orphaned.is_empty() {
eprintln!(
"{}",
hint_message(cformat!(
"To run commands in a worktree before it is removed, use <underline>pre-remove</>"
))
);
}
}

/// Group sourced steps into one Vec per source, preserving insertion order.
///
/// Used by background callers that want one pipeline per source (the canonical
Expand Down
Loading
Loading