Skip to content

Commit abc4dd2

Browse files
max-sixtyclaude
andauthored
Unify background hook output into single line (#908)
* feat(output): unify background hook output into single line Combine contiguous post-switch and post-start hooks into a single output line when both are present (common in `wt switch --create`). Old format (2 lines): Running post-switch hooks @ ~/repo.feature: user:foo Running post-start hooks @ ~/repo.feature: project:bar New format (1 line): Running post-switch: user:foo; post-start: project:bar @ ~/repo.feature Changes: - Rename spawn_hook_commands_background → spawn_background_hooks - Add prepare_background_hooks() to collect hooks before spawning - Add group_commands_by_hook_type() to group commands by type - Update handle_switch.rs to batch hooks from both types - Remove unused spawn_post_start_commands/spawn_post_switch_commands Co-Authored-By: Claude <noreply@anthropic.com> * chore: address review feedback - Update doc comments in global.rs to use new API functions - Update demo snapshot to use new output format - Make prepare_background_hooks pub(crate) (internal only) Co-Authored-By: Claude <noreply@anthropic.com> * test: add coverage for multiple unnamed hooks of same type Add test_user_and_project_unnamed_post_start to exercise the unnamed index tracking code when both user and project have unnamed hooks for the same hook type (post-start). Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f1c7555 commit abc4dd2

File tree

30 files changed

+296
-73
lines changed

30 files changed

+296
-73
lines changed

docs/demos/snapshots/wt-core.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ $ wt switch alpha
1313

1414
$ wt switch --create api
1515
✓ Created branch api from main and worktree @ <DEMO_DIR>/.demo-wt-core/w/acme.api
16-
◎ Running post-start hooks: project:dev
16+
◎ Running post-start: project:dev
1717

1818
$ wt list --full
1919
Branch Status HEAD± main↕ main…± Remote⇅ CI Commit Age

src/commands/handle_switch.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,16 +133,26 @@ pub fn handle_switch(
133133
// Spawn background hooks after success message
134134
// - post-switch: runs on ALL switches (shows "@ path" when shell won't be there)
135135
// - post-start: runs only when creating a NEW worktree
136+
// Batch hooks into a single message when both types are present
136137
if !skip_hooks {
137138
let ctx = CommandContext::new(&repo, config, Some(&branch_info.branch), result.path(), yes);
138139

139-
// Post-switch runs first (immediate "I'm here" signal)
140-
ctx.spawn_post_switch_commands(&extra_vars, hooks_display_path.as_deref())?;
141-
142-
// Post-start runs only on creation (setup tasks)
140+
// Collect hooks from both types, then spawn as a single batch
141+
let mut hooks = super::hooks::prepare_background_hooks(
142+
&ctx,
143+
HookType::PostSwitch,
144+
&extra_vars,
145+
hooks_display_path.as_deref(),
146+
)?;
143147
if matches!(&result, SwitchResult::Created { .. }) {
144-
ctx.spawn_post_start_commands(&extra_vars, hooks_display_path.as_deref())?;
148+
hooks.extend(super::hooks::prepare_background_hooks(
149+
&ctx,
150+
HookType::PostStart,
151+
&extra_vars,
152+
hooks_display_path.as_deref(),
153+
)?);
145154
}
155+
super::hooks::spawn_background_hooks(&ctx, hooks)?;
146156
}
147157

148158
// Execute user command after post-start hooks have been spawned

src/commands/hook_commands.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use super::context::CommandEnv;
2929
use super::hooks::execute_hook;
3030
use super::hooks::{
3131
HookFailureStrategy, check_name_filter_matched, prepare_hook_commands, run_hook_with_filter,
32-
spawn_hook_commands_background,
32+
spawn_background_hooks,
3333
};
3434
use super::project_config::collect_commands_for_hooks;
3535

@@ -141,7 +141,7 @@ pub fn run_hook(
141141
user_config,
142142
project_config,
143143
)?;
144-
spawn_hook_commands_background(&ctx, commands, hook_type)
144+
spawn_background_hooks(&ctx, commands)
145145
} else {
146146
run_hook_with_filter(
147147
&ctx,
@@ -180,7 +180,7 @@ pub fn run_hook(
180180
user_config,
181181
project_config,
182182
)?;
183-
spawn_hook_commands_background(&ctx, commands, hook_type)
183+
spawn_background_hooks(&ctx, commands)
184184
} else {
185185
run_hook_with_filter(
186186
&ctx,
@@ -283,7 +283,7 @@ pub fn run_hook(
283283
user_config,
284284
project_config,
285285
)?;
286-
spawn_hook_commands_background(&ctx, commands, hook_type)
286+
spawn_background_hooks(&ctx, commands)
287287
} else {
288288
run_hook_with_filter(
289289
&ctx,

src/commands/hooks.rs

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,12 @@ fn filter_by_name(
148148
/// Used for post-start and post-switch hooks during normal worktree operations.
149149
/// Commands are spawned and immediately detached - we don't wait for them.
150150
///
151-
/// By default, shows a single-line summary of all hooks being run.
151+
/// By default, shows a single-line summary of all hooks being run, with support
152+
/// for multiple hook types in a single message (e.g., "Running post-switch: user:foo; post-start: project:bar").
152153
/// With `-v`, shows verbose per-hook output with command details.
153-
pub fn spawn_hook_commands_background(
154+
pub fn spawn_background_hooks(
154155
ctx: &CommandContext,
155156
commands: Vec<SourcedCommand>,
156-
hook_type: HookType,
157157
) -> anyhow::Result<()> {
158158
if commands.is_empty() {
159159
return Ok(());
@@ -162,29 +162,39 @@ pub fn spawn_hook_commands_background(
162162
let verbose = verbosity();
163163

164164
if verbose == 0 {
165-
// Single-line summary: "Running post-start hooks @ path: user:bg, project"
166-
let names: Vec<String> = commands
167-
.iter()
168-
.map(|c| cformat!("<bold>{}</>", c.summary_name()))
169-
.collect();
165+
// Group commands by hook type, preserving insertion order
166+
let groups = group_commands_by_hook_type(&commands);
170167
// All commands in a batch share the same display_path (set by prepare_hook_commands)
171168
let display_path = commands.first().and_then(|c| c.display_path.as_ref());
172169

170+
// Format: "Running {type}: {names}[; {type}: {names}]... [@ {path}]"
171+
let type_segments: Vec<String> = groups
172+
.iter()
173+
.map(|(hook_type, cmds)| {
174+
let names: Vec<String> = cmds
175+
.iter()
176+
.map(|c| cformat!("<bold>{}</>", c.summary_name()))
177+
.collect();
178+
format!("{hook_type}: {}", names.join(", "))
179+
})
180+
.collect();
181+
173182
let message = match display_path {
174183
Some(path) => {
175184
let path_display = format_path_for_display(path);
176185
cformat!(
177-
"Running {hook_type} hooks @ <bold>{path_display}</>: {}",
178-
names.join(", ")
186+
"Running {} @ <bold>{path_display}</>",
187+
type_segments.join("; ")
179188
)
180189
}
181-
None => format!("Running {hook_type} hooks: {}", names.join(", ")),
190+
None => format!("Running {}", type_segments.join("; ")),
182191
};
183192
eprintln!("{}", progress_message(message));
184193
}
185194

186-
// Track index for unnamed commands to prevent log collisions
187-
let mut unnamed_index = 0usize;
195+
// Track index for unnamed commands to prevent log collisions (per hook type)
196+
// Use a Vec since HookType doesn't implement Hash
197+
let mut unnamed_indices: Vec<(HookType, usize)> = Vec::new();
188198

189199
for cmd in &commands {
190200
if verbose >= 1 {
@@ -194,13 +204,22 @@ pub fn spawn_hook_commands_background(
194204
let name = match &cmd.prepared.name {
195205
Some(n) => n.clone(),
196206
None => {
197-
let idx = unnamed_index;
198-
unnamed_index += 1;
207+
let idx = if let Some((_, count)) = unnamed_indices
208+
.iter_mut()
209+
.find(|(t, _)| *t == cmd.hook_type)
210+
{
211+
let result = *count;
212+
*count += 1;
213+
result
214+
} else {
215+
unnamed_indices.push((cmd.hook_type, 1));
216+
0
217+
};
199218
format!("cmd-{idx}")
200219
}
201220
};
202-
// Use HookLog for consistent log file naming
203-
let hook_log = HookLog::hook(cmd.source, hook_type, &name);
221+
// Use HookLog with the command's own hook_type for consistent log file naming
222+
let hook_log = HookLog::hook(cmd.source, cmd.hook_type, &name);
204223

205224
if let Err(err) = spawn_detached(
206225
ctx.repo,
@@ -222,6 +241,25 @@ pub fn spawn_hook_commands_background(
222241
Ok(())
223242
}
224243

244+
/// Group commands by hook type, preserving insertion order.
245+
///
246+
/// Returns a vector of (HookType, Vec<&SourcedCommand>) tuples.
247+
/// This preserves the order in which hook types were first encountered
248+
/// (e.g., post-switch before post-start).
249+
fn group_commands_by_hook_type(
250+
commands: &[SourcedCommand],
251+
) -> Vec<(HookType, Vec<&SourcedCommand>)> {
252+
let mut groups: Vec<(HookType, Vec<&SourcedCommand>)> = Vec::new();
253+
for cmd in commands {
254+
if let Some((_, vec)) = groups.iter_mut().find(|(t, _)| *t == cmd.hook_type) {
255+
vec.push(cmd);
256+
} else {
257+
groups.push((cmd.hook_type, vec![cmd]));
258+
}
259+
}
260+
groups
261+
}
262+
225263
/// Check if a name filter was provided but no commands matched.
226264
/// Returns an error listing available command names if so.
227265
pub(crate) fn check_name_filter_matched(
@@ -404,12 +442,46 @@ pub fn execute_hook(
404442
.map_err(worktrunk::git::add_hook_skip_hint)
405443
}
406444

445+
/// Prepare background hooks with automatic config lookup.
446+
///
447+
/// This is a convenience wrapper that:
448+
/// 1. Loads project config from the repository
449+
/// 2. Looks up user hooks from the config
450+
/// 3. Prepares commands ready for spawning
451+
///
452+
/// Use this to collect hooks from multiple types, then call `spawn_background_hooks`
453+
/// once to spawn them all with a unified message.
454+
pub(crate) fn prepare_background_hooks(
455+
ctx: &CommandContext,
456+
hook_type: HookType,
457+
extra_vars: &[(&str, &str)],
458+
display_path: Option<&Path>,
459+
) -> anyhow::Result<Vec<SourcedCommand>> {
460+
let project_config = ctx.repo.load_project_config()?;
461+
let user_hooks = ctx.config.hooks(ctx.project_id().as_deref());
462+
let (user_config, proj_config) =
463+
lookup_hook_configs(&user_hooks, project_config.as_ref(), hook_type);
464+
465+
prepare_hook_commands(
466+
ctx,
467+
user_config,
468+
proj_config,
469+
hook_type,
470+
extra_vars,
471+
None, // no filter for automatic background hooks
472+
display_path,
473+
)
474+
}
475+
407476
/// Spawn hook commands as background processes with automatic config lookup.
408477
///
409478
/// This is a convenience wrapper that:
410479
/// 1. Loads project config from the repository
411480
/// 2. Looks up user hooks from the config
412481
/// 3. Prepares and spawns background commands
482+
///
483+
/// For spawning multiple hook types in a single message, use `prepare_background_hooks`
484+
/// to collect hooks, then `spawn_background_hooks` to spawn them.
413485
pub fn spawn_hook_background(
414486
ctx: &CommandContext,
415487
hook_type: HookType,
@@ -432,7 +504,7 @@ pub fn spawn_hook_background(
432504
display_path,
433505
)?;
434506

435-
spawn_hook_commands_background(ctx, commands, hook_type)
507+
spawn_background_hooks(ctx, commands)
436508
}
437509

438510
#[cfg(test)]

src/commands/worktree/hooks.rs

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Hook execution for worktree operations.
22
//!
3-
//! CommandContext implementations for post-create, post-start, post-switch, and post-remove hooks.
3+
//! CommandContext implementations for post-create and post-remove hooks.
44
55
use std::path::Path;
66

@@ -9,8 +9,7 @@ use worktrunk::path::to_posix_path;
99

1010
use crate::commands::command_executor::CommandContext;
1111
use crate::commands::hooks::{
12-
HookFailureStrategy, execute_hook, prepare_hook_commands, spawn_hook_background,
13-
spawn_hook_commands_background,
12+
HookFailureStrategy, execute_hook, prepare_hook_commands, spawn_background_hooks,
1413
};
1514

1615
impl<'a> CommandContext<'a> {
@@ -32,24 +31,6 @@ impl<'a> CommandContext<'a> {
3231
)
3332
}
3433

35-
/// Spawn post-start commands in parallel as background processes (non-blocking)
36-
pub fn spawn_post_start_commands(
37-
&self,
38-
extra_vars: &[(&str, &str)],
39-
display_path: Option<&Path>,
40-
) -> anyhow::Result<()> {
41-
spawn_hook_background(self, HookType::PostStart, extra_vars, None, display_path)
42-
}
43-
44-
/// Spawn post-switch commands in parallel as background processes (non-blocking)
45-
pub fn spawn_post_switch_commands(
46-
&self,
47-
extra_vars: &[(&str, &str)],
48-
display_path: Option<&Path>,
49-
) -> anyhow::Result<()> {
50-
spawn_hook_background(self, HookType::PostSwitch, extra_vars, None, display_path)
51-
}
52-
5334
/// Spawn post-remove commands in parallel as background processes (non-blocking)
5435
///
5536
/// Runs after worktree removal. Commands execute from the invoking worktree (where
@@ -108,6 +89,6 @@ impl<'a> CommandContext<'a> {
10889
display_path,
10990
)?;
11091

111-
spawn_hook_commands_background(self, commands, HookType::PostRemove)
92+
spawn_background_hooks(self, commands)
11293
}
11394
}

src/output/global.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,12 @@ pub fn pre_hook_display_path(hooks_run_at: &std::path::Path) -> Option<&std::pat
302302
/// # Examples
303303
///
304304
/// ```ignore
305-
/// // In post-create, post-start, post-switch hooks (creation path):
306-
/// ctx.spawn_post_start_commands(&extra_vars, post_hook_display_path(&destination))?;
305+
/// // Prepare and spawn hooks with display path:
306+
/// let hooks = prepare_background_hooks(&ctx, HookType::PostStart, &extra_vars, post_hook_display_path(&destination))?;
307+
/// spawn_background_hooks(&ctx, hooks)?;
307308
///
308309
/// // After remove when switching to main:
309-
/// ctx.spawn_post_switch_commands(&[], post_hook_display_path(main_path))?;
310+
/// spawn_hook_background(&ctx, HookType::PostSwitch, &[], None, post_hook_display_path(main_path))?;
310311
/// ```
311312
pub fn post_hook_display_path(destination: &std::path::Path) -> Option<&std::path::Path> {
312313
if is_shell_integration_active() {

tests/integration_tests/switch.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,32 @@ fn test_switch_post_hook_no_path_with_shell_integration(repo: TestRepo) {
12971297
);
12981298
}
12991299

1300+
/// When both post-switch and post-start hooks are configured, they should be combined
1301+
/// into a single output line with format: "Running post-switch: {names}; post-start: {names} @ path"
1302+
#[rstest]
1303+
fn test_switch_combined_post_switch_and_post_start_hooks(repo: TestRepo) {
1304+
// Create project config with both post-switch and post-start hooks
1305+
let config_dir = repo.root_path().join(".config");
1306+
fs::create_dir_all(&config_dir).unwrap();
1307+
fs::write(
1308+
config_dir.join("wt.toml"),
1309+
r#"post-switch = "echo switched"
1310+
post-start = "echo started"
1311+
"#,
1312+
)
1313+
.unwrap();
1314+
1315+
repo.commit("Add config");
1316+
1317+
// Run switch --create (triggers both post-switch and post-start)
1318+
// Should show a single combined line: "Running post-switch: project; post-start: project @ path"
1319+
snapshot_switch(
1320+
"switch_combined_hooks",
1321+
&repo,
1322+
&["--create", "combined-hooks-test", "--yes"],
1323+
);
1324+
}
1325+
13001326
#[rstest]
13011327
fn test_switch_clobber_path_with_extension(repo: TestRepo) {
13021328
// Calculate where the worktree would be created

tests/integration_tests/user_hooks.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,46 @@ vars = "echo 'repo={{ repo }} branch={{ branch }}' > template_vars.txt"
927927
// Combined User and Project Hooks Tests
928928
// ============================================================================
929929

930+
/// Test that both user and project unnamed hooks of the same type run and get unique log names.
931+
/// This exercises the unnamed index tracking when multiple unnamed hooks share the same hook type.
932+
#[rstest]
933+
fn test_user_and_project_unnamed_post_start(repo: TestRepo) {
934+
// Create project config with unnamed post-start hook
935+
repo.write_project_config(r#"post-start = "echo 'PROJECT_POST_START' > project_bg.txt""#);
936+
repo.commit("Add project config");
937+
938+
// Write user config with unnamed hook AND pre-approve project command
939+
repo.write_test_config(
940+
r#"post-start = "echo 'USER_POST_START' > user_bg.txt"
941+
942+
[projects."../origin"]
943+
approved-commands = ["echo 'PROJECT_POST_START' > project_bg.txt"]
944+
"#,
945+
);
946+
947+
snapshot_switch(
948+
"user_and_project_unnamed_post_start",
949+
&repo,
950+
&["--create", "feature"],
951+
);
952+
953+
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
954+
955+
// Wait for both background commands
956+
wait_for_file(&worktree_path.join("user_bg.txt"));
957+
wait_for_file(&worktree_path.join("project_bg.txt"));
958+
959+
// Both should have run
960+
assert!(
961+
worktree_path.join("user_bg.txt").exists(),
962+
"User post-start should have run"
963+
);
964+
assert!(
965+
worktree_path.join("project_bg.txt").exists(),
966+
"Project post-start should have run"
967+
);
968+
}
969+
930970
#[rstest]
931971
fn test_user_and_project_post_start_both_run(repo: TestRepo) {
932972
// Create project config with post-start hook

0 commit comments

Comments
 (0)