From 5b4e0cd27259284ae8518f3d33d436f8cc8a25c7 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Fri, 29 May 2026 17:38:43 +0000 Subject: [PATCH] fix(plugin): drop -D from WorktreeRemove hook to preserve unmerged branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code auto-fires the plugin's WorktreeRemove hook on session exit for any worktree with a clean working tree, including ones whose branch holds committed-but-unpushed work. The hook command passed `-D` (`--force-delete`), which routes straight to `git branch -D` and bypasses the safe-delete machinery — so unmerged branches were silently destroyed, recoverable only via `git fsck`. Dropping `-D` lets the safe default fire: merged branches are still removed cleanly, and unmerged branches are retained with a `wt remove -D ` hint surfaced to the user. Closes #2939 Co-Authored-By: Claude --- plugins/worktrunk/hooks/hooks.json | 2 +- skills/wt-switch-create/SKILL.md | 6 ++++-- tests/integration_tests/config_show.rs | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/plugins/worktrunk/hooks/hooks.json b/plugins/worktrunk/hooks/hooks.json index 9eb033da4c..322b1cec48 100644 --- a/plugins/worktrunk/hooks/hooks.json +++ b/plugins/worktrunk/hooks/hooks.json @@ -47,7 +47,7 @@ "hooks": [ { "type": "command", - "command": "jq -r '.worktree_path' | xargs bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wt.sh\" remove -D --foreground" + "command": "jq -r '.worktree_path' | xargs bash \"${CLAUDE_PLUGIN_ROOT}/hooks/wt.sh\" remove --foreground" } ] } diff --git a/skills/wt-switch-create/SKILL.md b/skills/wt-switch-create/SKILL.md index 16f498f3f2..939ffb9fc4 100644 --- a/skills/wt-switch-create/SKILL.md +++ b/skills/wt-switch-create/SKILL.md @@ -58,8 +58,10 @@ after it. Otherwise the task starts at the second token. Don't remove the worktree yourself. `ExitWorktree({action: "remove"})` (if the user asks to leave) or the session-exit prompt routes through this plugin's -`WorktreeRemove` hook → `wt remove -D --foreground`. A worktree with uncommitted -changes won't be auto-removed without confirmation — that's intended. +`WorktreeRemove` hook → `wt remove --foreground`. A worktree with uncommitted +changes won't be auto-removed without confirmation — that's intended. A branch +with committed-but-unmerged work is retained (with a `wt remove -D ` +hint) instead of being silently force-deleted. ## Scope diff --git a/tests/integration_tests/config_show.rs b/tests/integration_tests/config_show.rs index 172ae3ae1a..950edce7cc 100644 --- a/tests/integration_tests/config_show.rs +++ b/tests/integration_tests/config_show.rs @@ -3979,6 +3979,24 @@ fn test_plugin_layout_is_consolidated() { } } + // The WorktreeRemove hook must not force-delete unmerged branches (#2939). + // Claude Code auto-fires WorktreeRemove on session exit for any worktree + // with a clean working tree, so a hook command containing `-D` / + // `--force-delete` silently discards committed-but-unpushed work — the only + // recovery path is `git fsck`. The safe default retains unmerged branches + // and prints a `wt remove -D ` hint for the user to act on. + let hooks = read("plugins/worktrunk/hooks/hooks.json"); + let hooks_json = json("plugins/worktrunk/hooks/hooks.json"); + let worktree_remove_cmd = hooks_json["hooks"]["WorktreeRemove"][0]["hooks"][0]["command"] + .as_str() + .expect("WorktreeRemove hook must define a command"); + assert!( + !worktree_remove_cmd.contains(" -D") && !worktree_remove_cmd.contains("--force-delete"), + "WorktreeRemove hook must not pass -D / --force-delete (silently destroys \ + committed-but-unpushed work on session-exit auto-remove; see #2939). \ + hooks.json:\n{hooks}" + ); + // The product description must not drift across tools. Byte-identical is // schema-impossible (Codex omits the activity clause, Gemini says // "extension"), but every manifest shares the canonical opening sentence.