diff --git a/src/commands/hook_commands.rs b/src/commands/hook_commands.rs index bd39eed45..8763e49f7 100644 --- a/src/commands/hook_commands.rs +++ b/src/commands/hook_commands.rs @@ -511,32 +511,15 @@ fn render_user_hooks( .filter_map(|ht| user_hooks.get(ht).map(|cfg| (ht, cfg))) .collect(); - let mut has_any = false; - for (hook_type, cfg) in &hooks { - // Apply filter if specified - if let Some(f) = filter - && f != *hook_type - { - continue; - } - - has_any = true; - render_hook_commands( - out, - *hook_type, - cfg, - HookSource::User, - approvals, - project_id, - ctx, - )?; - } - - if !has_any { - writeln!(out, "{}", hint_message("(none configured)"))?; - } - - Ok(()) + render_hook_section( + out, + &hooks, + HookSource::User, + approvals, + project_id, + filter, + ctx, + ) } /// Render project hooks section @@ -572,25 +555,42 @@ fn render_project_hooks( .filter_map(|ht| config.hooks.get(ht).map(|cfg| (ht, cfg))) .collect(); + render_hook_section( + out, + &hooks, + HookSource::Project, + approvals, + project_id, + filter, + ctx, + ) +} + +/// Render a section's body: every hook that survives `filter`, or +/// `(none configured)` when that leaves the section with nothing. +/// +/// The fallback keys off what was printed, not off what the config declared — +/// a hook type carrying an empty command list (`post-switch = []`) has an entry +/// but no commands to show, and a section holding only those is empty. +fn render_hook_section( + out: &mut String, + hooks: &[(HookType, &CommandConfig)], + source: HookSource, + approvals: &Approvals, + project_id: Option<&str>, + filter: Option, + ctx: Option<&CommandContext>, +) -> anyhow::Result<()> { let mut has_any = false; - for (hook_type, cfg) in &hooks { - // Apply filter if specified + for (hook_type, config) in hooks { if let Some(f) = filter && f != *hook_type { continue; } - has_any = true; - render_hook_commands( - out, - *hook_type, - cfg, - HookSource::Project, - approvals, - project_id, - ctx, - )?; + has_any |= + render_hook_commands(out, *hook_type, config, source, approvals, project_id, ctx)?; } if !has_any { @@ -600,7 +600,7 @@ fn render_project_hooks( Ok(()) } -/// Render commands for a single hook type +/// Render commands for a single hook type, reporting whether it wrote any. fn render_hook_commands( out: &mut String, hook_type: HookType, @@ -609,8 +609,10 @@ fn render_hook_commands( approvals: &Approvals, project_id: Option<&str>, ctx: Option<&CommandContext>, -) -> anyhow::Result<()> { +) -> anyhow::Result { + let mut wrote_any = false; for row in hook_command_rows(config, ctx, hook_type, source)? { + wrote_any = true; let label = command_label(hook_type, row.name.as_deref()); let needs_approval = needs_approval(source, approvals, project_id, &row.template); @@ -627,7 +629,7 @@ fn render_hook_commands( writeln!(out, "{}", format_bash_with_gutter(shown))?; } - Ok(()) + Ok(wrote_any) } /// Whether a listed command still needs the user's approval to run. @@ -666,8 +668,9 @@ struct HookCommandRow { /// builds what actually executes, so every context key the execution path /// gains reaches this preview with no second edit here. Rendering then goes /// through [`render_template_preview`], shared with `wt hook -/// --dry-run`, which shows a `vars.*` template raw — its values resolve from -/// git config when the step runs, possibly written by an earlier step. +/// --dry-run`, which renders each `{{ vars. }}` as itself while the rest +/// of the template expands — those values resolve from git config when the +/// step runs, possibly written by an earlier step. /// /// A manual invocation has no source or destination worktree, so the /// directional vars come from [`build_manual_hook_template_vars`], exactly as diff --git a/tests/integration_tests/hook_show.rs b/tests/integration_tests/hook_show.rs index 58103cd29..993d74769 100644 --- a/tests/integration_tests/hook_show.rs +++ b/tests/integration_tests/hook_show.rs @@ -320,6 +320,32 @@ url = "http://localhost:8080" }); } +/// A hook type declared with an empty command list contributes no commands, so +/// both sections read as `(none configured)` rather than a bare heading. +#[rstest] +fn test_hook_show_empty_command_lists(repo: TestRepo, temp_home: TempDir) { + repo.write_test_config( + r#"post-switch = [] +"#, + ); + + repo.write_project_config( + r#"pre-merge = [] +"#, + ); + repo.commit("Add project config with an empty hook list"); + + let settings = setup_snapshot_settings_with_home(&repo, &temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.arg("hook").arg("show").current_dir(repo.root_path()); + set_temp_home_env(&mut cmd, temp_home.path()); + + assert_cmd_snapshot!(cmd); + }); +} + #[rstest] fn test_hook_show_outside_git_repo(temp_home: TempDir) { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/tests/snapshots/integration__integration_tests__hook_show__hook_show_empty_command_lists.snap b/tests/snapshots/integration__integration_tests__hook_show__hook_show_empty_command_lists.snap new file mode 100644 index 000000000..de916c776 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__hook_show__hook_show_empty_command_lists.snap @@ -0,0 +1,56 @@ +--- +source: tests/integration_tests/hook_show.rs +info: + program: wt + args: + - hook + - show + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- +USER HOOKS @ [TEST_CONFIG] +↳ (none configured) + +PROJECT HOOKS @ _REPO_/.config/wt.toml +↳ (none configured) + +----- stderr -----