Files
max-sixty__worktrunk/tests/integration_tests/hook_show.rs
Maximilian Roos 0e7454890e fix(hooks): an empty command list no longer leaves a bare heading (#3641)
`wt hook show` decided whether a section had printed anything from
whether the config held an entry for each hook type, not from whether
any command was rendered. A hook type declared with an empty command
list has an entry but no commands, so the section printed its heading
and then stopped:

```console
$ cat .config/wt.toml
post-switch = []

$ wt hook show
PROJECT HOOKS @ /path/.config/wt.toml
```

Both sections carried it, since the loop and the `(none configured)`
fallback were duplicated in `render_user_hooks` and
`render_project_hooks`. `render_hook_commands` now reports whether it
wrote any rows, and the loop plus fallback live once in
`render_hook_section`, which both callers delegate to. The flag means
"something was printed", which is what the `(none configured)` line
claims.

The execution path was already correct: an empty list announces nothing,
and the JSON output omits it. Verified against the built binary across
four cases (project-only empty, user-only empty, an empty list alongside
a real hook, and a filter naming the empty type), and pinned by
`test_hook_show_empty_command_lists`, which covers both the user and
project halves. Reverting the flag to its old meaning fails the test on
both.

Also corrects a docstring in the same file that still described the
preview's pre-#3638 behavior ("shows a `vars.*` template raw"), the same
drift #3639 fixed for `wt config alias dry-run`.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:13:27 -07:00

702 lines
22 KiB
Rust

//! Integration tests for `wt hook show` command
use crate::common::{
TestRepo, repo, set_temp_home_env, setup_home_snapshot_settings,
setup_snapshot_settings_with_home, temp_home, wt_command,
};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use tempfile::TempDir;
#[rstest]
fn test_hook_show_with_both_configs(repo: TestRepo, temp_home: TempDir) {
// Create user config with hooks
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[pre-commit]
user-lint = "pre-commit run --all-files"
"#,
)
.unwrap();
// Create project config with hooks
repo.write_project_config(
r#"pre-merge = [
{build = "cargo build"},
{test = "cargo test"},
]
[post-start]
deps = "npm install"
"#,
);
repo.commit("Add project config");
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_no_hooks(repo: TestRepo, temp_home: TempDir) {
// Create user config without hooks
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
"#,
)
.unwrap();
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);
});
}
/// Helper to set up a repo with all hook types configured for filter tests
fn setup_all_hook_types(repo: &TestRepo, temp_home: &TempDir) {
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
"#,
)
.unwrap();
repo.write_project_config(
r#"pre-merge = [
{build = "cargo build"},
{test = "cargo test"},
]
[post-start]
deps = "npm install"
[post-merge]
deploy = "scripts/deploy.sh"
[pre-remove]
cleanup = "echo cleanup"
[post-remove]
notify = "echo removed"
"#,
);
repo.commit("Add project config");
}
#[rstest]
fn test_hook_show_filter_by_type(repo: TestRepo, temp_home: TempDir) {
setup_all_hook_types(&repo, &temp_home);
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")
.arg("pre-merge")
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_hook_show_filter_post_merge(repo: TestRepo, temp_home: TempDir) {
setup_all_hook_types(&repo, &temp_home);
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")
.arg("post-merge")
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_hook_show_filter_pre_remove(repo: TestRepo, temp_home: TempDir) {
setup_all_hook_types(&repo, &temp_home);
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")
.arg("pre-remove")
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_hook_show_filter_post_remove(repo: TestRepo, temp_home: TempDir) {
setup_all_hook_types(&repo, &temp_home);
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")
.arg("post-remove")
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_hook_show_approval_status(repo: TestRepo, temp_home: TempDir) {
// Remove origin so project_identifier uses full canonical path
repo.run_git(&["remote", "remove", "origin"]);
// Get the canonical path for the project identifier (escaped for TOML)
let project_id_str = repo.project_id();
// Create user config at XDG path with one approved command
// Use canonical path to handle macOS /var -> /private/var symlinks
let canonical_home = crate::common::canonicalize(temp_home.path())
.unwrap_or_else(|_| temp_home.path().to_path_buf());
let global_config_dir = canonical_home.join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
let config_path = global_config_dir.join("config.toml");
fs::write(
&config_path,
r#"worktree-path = "../{{ repo }}.{{ branch }}"
"#,
)
.unwrap();
let approvals_path = global_config_dir.join("approvals.toml");
fs::write(
&approvals_path,
format!(
r#"[projects.'{project_id_str}']
approved-commands = ["cargo build"]
"#
),
)
.unwrap();
// Create project config with approved and unapproved hooks
repo.write_project_config(
r#"pre-merge = [
{build = "cargo build"},
{test = "cargo test"},
]
"#,
);
repo.commit("Add project config");
let settings = setup_snapshot_settings_with_home(&repo, &temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
// Override config and approvals paths to point to our test files
cmd.env("WORKTRUNK_CONFIG_PATH", &config_path);
cmd.env("WORKTRUNK_APPROVALS_PATH", &approvals_path);
cmd.arg("hook").arg("show").current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
/// Hooks defined under `[projects."<id>"]` in the user config must show up
/// in `wt hook show` alongside global user hooks, matching the merged set
/// that the execution path actually runs.
#[rstest]
fn test_hook_show_merges_user_project_hooks(repo: TestRepo, temp_home: TempDir) {
// Remove origin so project_identifier falls back to the canonical path
repo.run_git(&["remote", "remove", "origin"]);
let project_id_str = repo.project_id();
let canonical_home = crate::common::canonicalize(temp_home.path())
.unwrap_or_else(|_| temp_home.path().to_path_buf());
let global_config_dir = canonical_home.join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
let config_path = global_config_dir.join("config.toml");
fs::write(
&config_path,
format!(
r#"worktree-path = "../{{{{ repo }}}}.{{{{ branch }}}}"
[post-start]
global-hook = "echo global"
[projects.'{project_id_str}'.post-start]
project-hook = "echo per-project"
"#
),
)
.unwrap();
let settings = setup_snapshot_settings_with_home(&repo, &temp_home);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.env("WORKTRUNK_CONFIG_PATH", &config_path);
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_error_with_context_formatting(temp_home: TempDir) {
let temp_dir = tempfile::tempdir().unwrap();
// Run wt remove outside a git repo - should show "Failed to remove worktree" context
let settings = setup_home_snapshot_settings(&temp_home);
settings.bind(|| {
let mut cmd = wt_command();
cmd.arg("remove").current_dir(temp_dir.path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
#[rstest]
fn test_hook_show_project_config_no_hooks(repo: TestRepo, temp_home: TempDir) {
// Create user config without hooks
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
"#,
)
.unwrap();
// Create project config without any hook sections
repo.write_project_config(
r#"# Project config with no hooks
[list]
url = "http://localhost:8080"
"#,
);
repo.commit("Add project config without hooks");
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);
});
}
/// 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();
// Create user config
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[pre-commit]
lint = "pre-commit run"
"#,
)
.unwrap();
let mut settings = setup_home_snapshot_settings(&temp_home);
// Replace temp home path with ~ for stable snapshots (override the [TEMP_HOME] filter)
// Canonicalize to handle macOS /var -> /private/var symlinks
let canonical_home = crate::common::canonicalize(temp_home.path())
.unwrap_or_else(|_| temp_home.path().to_path_buf());
settings.add_filter(&regex::escape(&canonical_home.to_string_lossy()), "~");
// Normalize thread IDs in panic messages (e.g., "thread 'main' (1234567)")
settings.add_filter(r"thread '([^']+)' \(\d+\)", "thread '$1' ([THREAD_ID])");
settings.bind(|| {
let mut cmd = wt_command();
cmd.arg("hook").arg("show").current_dir(temp_dir.path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
/// Test that syntax errors in templates are shown (not swallowed) with --expanded.
#[rstest]
fn test_hook_show_expanded_syntax_error(repo: TestRepo, temp_home: TempDir) {
// Create user config without hooks
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
"#,
)
.unwrap();
// Create project config with broken template syntax (unclosed brace)
repo.write_project_config(
r#"[pre-commit]
broken = "echo {{ branch"
"#,
);
repo.commit("Add project config with broken template");
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")
.arg("pre-commit")
.arg("--expanded")
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
/// Test that undefined variable errors show both template and error with --expanded.
/// The `base` variable is only defined for pre-start hooks, so using it in pre-commit
/// will trigger an undefined variable error that shows both the error and raw template.
#[rstest]
fn test_hook_show_expanded_undefined_var(repo: TestRepo, temp_home: TempDir) {
// Create user config without hooks
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
"#,
)
.unwrap();
// Create project config with `base` variable (only defined for pre-start hooks)
// In pre-commit context, this will be undefined and should show error + template
repo.write_project_config(
r#"[pre-commit]
optional-var = "echo {{ base }}"
"#,
);
repo.commit("Add project config with optional variable");
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")
.arg("pre-commit")
.arg("--expanded")
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
/// `hook show --format=json` emits one record per configured command, with
/// type / source / template / approval status, plus `expanded` when requested.
#[rstest]
fn test_hook_show_json(repo: TestRepo, temp_home: TempDir) {
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[pre-commit]
user-lint = "pre-commit run --all-files"
"#,
)
.unwrap();
repo.write_project_config(
r#"pre-merge = [
{build = "cargo build"},
]
[post-start]
deps = "npm install"
"#,
);
repo.commit("Add project config");
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.env(
"WORKTRUNK_CONFIG_PATH",
global_config_dir.join("config.toml"),
);
cmd.args(["hook", "show", "--format=json"])
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
let output = cmd.output().unwrap();
assert!(output.status.success(), "hook show --format=json failed");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let entries = parsed.as_array().expect("array");
assert_eq!(
entries.len(),
3,
"user pre-commit + project pre-merge + project post-start"
);
// User entry
let user_lint = entries
.iter()
.find(|e| e["name"] == "user-lint")
.expect("user-lint present");
assert_eq!(user_lint["type"], "pre-commit");
assert_eq!(user_lint["source"], "user");
assert_eq!(user_lint["template"], "pre-commit run --all-files");
assert_eq!(user_lint["needs_approval"], false);
// Project entry — project hooks need approval until approved
let build = entries
.iter()
.find(|e| e["name"] == "build")
.expect("build present");
assert_eq!(build["type"], "pre-merge");
assert_eq!(build["source"], "project");
assert_eq!(build["needs_approval"], true);
}
/// `hook show <type> --expanded --format=json` filters by hook type across
/// both user and project hooks, and includes the rendered `expanded` field
/// for each surviving entry.
#[rstest]
fn test_hook_show_filtered_expanded_json(repo: TestRepo, temp_home: TempDir) {
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[pre-commit]
user-lint = "echo {{ branch }}"
[post-start]
user-greet = "echo hi"
"#,
)
.unwrap();
repo.write_project_config(
r#"[pre-commit]
project-fmt = "echo fmt {{ branch }}"
[post-start]
project-deps = "echo deps"
"#,
);
repo.commit("Add project config");
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.env(
"WORKTRUNK_CONFIG_PATH",
global_config_dir.join("config.toml"),
);
cmd.args(["hook", "show", "pre-commit", "--expanded", "--format=json"])
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"hook show --expanded --format=json failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let entries = parsed.as_array().expect("array");
// Filter dropped the post-start hooks from both user and project.
let names: Vec<&str> = entries
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect();
assert_eq!(entries.len(), 2, "only pre-commit hooks survive: {names:?}");
assert!(names.contains(&"user-lint"));
assert!(names.contains(&"project-fmt"));
for entry in entries {
assert_eq!(entry["type"], "pre-commit");
let expanded = entry["expanded"].as_str().expect("expanded field present");
assert!(
expanded.starts_with("echo "),
"expanded should render template: {expanded}"
);
}
}
/// The `--expanded` listing and the pipeline agree on the template context.
///
/// `wt hook show --expanded` prepares its commands through the same function
/// the execution path uses, so the pipeline-infrastructure keys — `hook_type`,
/// the per-command `hook_name`, and the `args` sequence a listing leaves empty
/// — render in the listing exactly as they do in `wt hook <type> --dry-run`,
/// which previews what would actually run.
///
/// The `varsy` command pins the one variable a preview deliberately leaves
/// alone: `vars.*` is read from git config when the step runs, so it renders
/// back as itself even though a value is set here, while `{{ branch }}` beside
/// it still expands.
#[rstest]
fn test_hook_show_expanded_matches_dry_run(repo: TestRepo, temp_home: TempDir) {
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
let config_path = global_config_dir.join("config.toml");
fs::write(
&config_path,
r#"worktree-path = "../{{ repo }}.{{ branch }}"
[pre-commit]
context = "echo type={{ hook_type }} name={{ hook_name }} args=[{{ args }}]"
varsy = "deploy --branch={{ branch }} --env={{ vars.env }}"
"#,
)
.unwrap();
repo.run_git(&["config", "worktrunk.state.main.vars.env", "staging"]);
let expected = "echo type=pre-commit name=context args=[]";
let expected_varsy = "deploy --branch=main --env={{ vars.env }}";
let mut show = wt_command();
repo.configure_wt_cmd(&mut show);
show.env("WORKTRUNK_CONFIG_PATH", &config_path);
show.args(["hook", "show", "pre-commit", "--expanded", "--format=json"])
.current_dir(repo.root_path());
set_temp_home_env(&mut show, temp_home.path());
let output = show.output().unwrap();
assert!(
output.status.success(),
"hook show failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).expect("valid JSON");
assert_eq!(parsed[0]["expanded"], expected);
assert_eq!(parsed[1]["expanded"], expected_varsy);
let mut dry_run = wt_command();
repo.configure_wt_cmd(&mut dry_run);
dry_run.env("WORKTRUNK_CONFIG_PATH", &config_path);
// Plain output so the rendered command is one contiguous substring rather
// than a run of syntax-highlighting spans.
dry_run.env_remove("CLICOLOR_FORCE").env("NO_COLOR", "1");
dry_run
.args(["hook", "pre-commit", "--dry-run"])
.current_dir(repo.root_path());
set_temp_home_env(&mut dry_run, temp_home.path());
let output = dry_run.output().unwrap();
assert!(
output.status.success(),
"hook --dry-run failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
for want in [expected, expected_varsy] {
assert!(
stdout.contains(want),
"dry-run should render the listing's command `{want}`: {stdout}"
);
}
}
/// Test that valid templates expand correctly with --expanded.
#[rstest]
fn test_hook_show_expanded_valid_template(repo: TestRepo, temp_home: TempDir) {
// Create user config without hooks
let global_config_dir = temp_home.path().join(".config").join("worktrunk");
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"worktree-path = "../{{ repo }}.{{ branch }}"
"#,
)
.unwrap();
// Create project config with valid template using defined variables
repo.write_project_config(
r#"[pre-commit]
valid = "echo branch={{ branch }} repo={{ repo }}"
"#,
);
repo.commit("Add project config with valid template");
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")
.arg("pre-commit")
.arg("--expanded")
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}