Files
max-sixty__worktrunk/tests/integration_tests/diagnostic.rs
Maximilian Roos 0aca156ccd fix: use @ before path in -vv startup pointer message (#3543)
The \`-vv\` startup pointer (\`announce_trace_destination\` in
\`src/logging.rs\`) printed "Verbose logging to \<dir\>/", using "to"
before the path. The codebase convention (see the
\`writing-user-outputs\` skill) is \`@\` before paths in user-facing
output — #3521 already applied this to the \`-vv\` end-of-run block but
left this start-of-run pointer as the one remaining inconsistency.

Changes "Verbose logging to \<dir\>/" to "Verbose logging @ \<dir\>/"
and updates the matching test assertions/doc comment in
\`tests/integration_tests/diagnostic.rs\`. The historical wording quoted
in \`CHANGELOG.md\` is left as-is since it documents a past release.

> _This was written by Claude Code on behalf of max_
2026-07-22 02:46:21 -07:00

1128 lines
42 KiB
Rust

//! Tests for diagnostic report generation.
//!
//! These tests verify the markdown structure and content of diagnostic reports
//! to ensure they're suitable for GitHub issue filing.
//!
//! # Test Coverage
//!
//! - `test_diagnostic_report_file_format`: Snapshot of full diagnostic structure
//! - `test_diagnostic_not_created_without_vv`: No file without -vv
//! - `test_diagnostic_hint_without_vv`: Hint tells user to use -vv
//! - `test_diagnostic_contains_required_sections`: All sections present
//! - `test_diagnostic_context_has_no_ansi_codes`: ANSI stripped for GitHub
//! - `test_diagnostic_trace_log_contains_git_commands`: Log has useful data
//! - `test_diagnostic_saved_message_with_vv`: -vv announces the saved report
//! - `test_diagnostic_leads_with_profile`: Profile is the bundle's first section
//! - `test_diagnostic_written_to_correct_location`: File in .git/wt/logs/
//! - `test_diagnostic_gh_hint_with_vv`: Hint shows gist and issue URL when gh installed
use std::fs;
use std::path::PathBuf;
use insta::assert_snapshot;
use rstest::rstest;
use crate::common::{TestRepo, repo, setup_snapshot_settings};
/// Helper to corrupt a worktree's HEAD file to trigger git errors.
///
/// Writes a non-null invalid SHA so the worktree is treated as "valid but
/// broken" (errors surface) rather than the NULL_OID-keyed unborn path,
/// which now skips commit-dependent tasks silently (see #2936).
fn corrupt_worktree_head(repo: &TestRepo, worktree_name: &str) -> PathBuf {
let feature_path = repo.worktrees.get(worktree_name).unwrap();
let git_dir = feature_path.join(".git");
let git_content = fs::read_to_string(&git_dir).unwrap();
let actual_git_dir = git_content
.strip_prefix("gitdir: ")
.unwrap()
.trim()
.to_string();
let head_path = PathBuf::from(&actual_git_dir).join("HEAD");
fs::write(&head_path, "0000000000000000000000000000000000000001\n").unwrap();
head_path
}
/// Snapshot the diagnostic report file generated with -vv.
///
/// This test triggers a git error (invalid HEAD) and runs `wt list -vv`
/// to generate a diagnostic report file. We then read and snapshot the file
/// to verify its structure.
///
/// Note: Diagnostic files are only generated when -vv is used.
#[rstest]
fn test_diagnostic_report_file_format(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
let output = repo.wt_command().args(["list", "-vv"]).output().unwrap();
let diagnostic_path = repo
.root_path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md");
assert!(
diagnostic_path.exists(),
"Diagnostic file should be generated at {:?}",
diagnostic_path
);
let content = fs::read_to_string(&diagnostic_path).unwrap();
// Verify verbose log section is present (requires -v or higher)
assert!(
content.contains("<summary>Trace log</summary>"),
"Diagnostic should include trace log section when run with -vv"
);
// The rendered performance profile rides alongside the raw trace.
assert!(
content.contains("<summary>Performance profile</summary>"),
"Diagnostic should include a performance profile section when run with -vv"
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
assert_snapshot!("diagnostic_file_format", normalize_report(&content));
});
// Verify the stderr mentions the diagnostic was saved
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Diagnostics and performance profile saved"),
"Output should mention diagnostic was saved. stderr: {}",
stderr
);
}
/// Without -vv, no diagnostic file should be created.
///
/// The diagnostic file is only created when -vv is used. Running without
/// any verbose flag or with just -v does not create a diagnostic file.
#[rstest]
fn test_diagnostic_not_created_without_vv(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
// Run WITHOUT -vv
repo.wt_command().args(["list"]).output().unwrap();
// Diagnostic file should NOT exist
let diagnostic_path = repo
.root_path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md");
assert!(
!diagnostic_path.exists(),
"Diagnostic file should NOT be created without -vv"
);
}
/// Without -vv, the hint should tell users to re-run with -vv.
#[rstest]
fn test_diagnostic_hint_without_vv(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
let output = repo.wt_command().args(["list"]).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("To create a diagnostic file, run with"),
"Should hint to use -vv. stderr: {}",
stderr
);
assert!(
stderr.contains("-vv"),
"Hint should mention -vv flag. stderr: {}",
stderr
);
}
/// Diagnostic file must contain all required sections for GitHub issues.
#[rstest]
fn test_diagnostic_contains_required_sections(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
repo.wt_command().args(["list", "-vv"]).output().unwrap();
let content = fs::read_to_string(
repo.root_path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md"),
)
.unwrap();
// Header section
assert!(
content.contains("## Diagnostic Report"),
"Should have header"
);
assert!(content.contains("**Generated:**"), "Should have timestamp");
assert!(content.contains("**Command:**"), "Should have command");
assert!(content.contains("**Result:**"), "Should have result");
// Environment section
assert!(
content.contains("<summary>Environment</summary>"),
"Should have environment section"
);
assert!(content.contains("wt "), "Should have wt version");
assert!(content.contains("git "), "Should have git version");
assert!(
content.contains("Shell integration:"),
"Should have shell integration status"
);
// Worktrees section
assert!(
content.contains("<summary>Worktrees</summary>"),
"Should have worktrees section"
);
assert!(
content.contains("refs/heads/"),
"Should have branch refs in worktree list"
);
// Config section
assert!(
content.contains("<summary>Config</summary>"),
"Should have config section"
);
// Trace log section
assert!(
content.contains("<summary>Trace log</summary>"),
"Should have trace log section"
);
}
/// The diagnostic surfaces the curated pager/terminal env vars and git's
/// resolved `core.pager`, so a rendering bug like #3322 (a pager interaction
/// suspending `wt config show`) can be diagnosed from the report alone. A set
/// `PAGER` is echoed verbatim; an unset allowlisted var shows `(unset)`.
#[rstest]
fn test_diagnostic_includes_environment_variables(mut repo: TestRepo) {
repo.add_worktree("feature");
// Configure git's core.pager so its resolved value appears in the report.
repo.run_git(&["config", "core.pager", "my-test-pager --opt"]);
repo.wt_command()
.args(["list", "-vv"])
.env("PAGER", "my-test-pager")
.env_remove("GIT_PAGER")
.output()
.unwrap();
let content = fs::read_to_string(
repo.root_path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md"),
)
.unwrap();
assert!(
content.contains("<summary>Environment variables</summary>"),
"Should have an environment-variables section. content: {content}"
);
assert!(
content.contains("PAGER=my-test-pager"),
"A set PAGER should be echoed verbatim. content: {content}"
);
assert!(
content.contains("GIT_PAGER=(unset)"),
"An unset allowlisted var should read (unset). content: {content}"
);
assert!(
content.contains("core.pager=my-test-pager --opt"),
"git's resolved core.pager should be surfaced. content: {content}"
);
}
/// The context field should have ANSI codes stripped for clean GitHub display.
#[rstest]
fn test_diagnostic_context_has_no_ansi_codes(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
repo.wt_command().args(["list", "-vv"]).output().unwrap();
let content = fs::read_to_string(
repo.root_path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md"),
)
.unwrap();
// ANSI escape codes start with \x1b[ or \033[
assert!(
!content.contains("\x1b["),
"Diagnostic file should not contain ANSI escape codes"
);
assert!(
!content.contains("\u{001b}"),
"Diagnostic file should not contain ANSI escape codes (unicode)"
);
}
/// Trace log should contain git command traces for debugging.
#[rstest]
fn test_diagnostic_trace_log_contains_git_commands(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
repo.wt_command().args(["list", "-vv"]).output().unwrap();
let content = fs::read_to_string(
repo.root_path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md"),
)
.unwrap();
// Extract trace log section
let trace_start = content
.find("<summary>Trace log</summary>")
.expect("Should have trace log");
let trace_section = &content[trace_start..];
// Should contain git command traces
assert!(
trace_section.contains("git worktree list"),
"Trace log should contain git worktree list command"
);
// Records are humanized: a ✓/✗ glyph reports each command's outcome with a
// compact duration, replacing the old `[wt-trace] … dur_us=… ok=…` grammar.
assert!(
trace_section.contains("✓ git worktree list")
|| trace_section.contains("✗ git worktree list"),
"Trace log should contain the humanized git worktree list completion record"
);
// In-process spans are humanized too (◷ glyph + duration).
assert!(
trace_section.contains("◷ init_logging"),
"Trace log should contain the init_logging span record"
);
}
/// The `-vv` end block names what the run captured and saved
/// (`diagnostic.md`, the single human-facing doc). It doesn't re-list the
/// raw companion logs (`trace.jsonl`, `subprocess.log`) — the start-of-run
/// pointer (`Verbose logging @ <dir>/`) already names the log directory,
/// and the report body points at the companions directly.
#[rstest]
fn test_diagnostic_saved_message_with_vv(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
let output = repo.wt_command().args(["list", "-vv"]).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
// Headline names the diagnostics and performance profile.
assert!(
stderr.contains("Diagnostics and performance profile saved"),
"end block should name what was captured. stderr: {stderr}"
);
// The raw companion logs are not re-listed in the end block.
assert!(
!stderr.contains("wt/logs/trace.jsonl") && !stderr.contains("wt/logs/subprocess.log"),
"end block should not re-list the raw companion logs: {stderr}"
);
}
/// Diagnostic file should be written to .git/wt/logs/diagnostic.md
#[rstest]
fn test_diagnostic_written_to_correct_location(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
repo.wt_command().args(["list", "-vv"]).output().unwrap();
// Should be in .git/wt/logs/ directory
let wt_logs_dir = repo.root_path().join(".git").join("wt/logs");
assert!(wt_logs_dir.exists());
let diagnostic_path = wt_logs_dir.join("diagnostic.md");
assert!(
diagnostic_path.exists(),
"diagnostic.md should be in wt/logs"
);
// Should be a markdown file
let content = fs::read_to_string(&diagnostic_path).unwrap();
assert!(
content.starts_with("## "),
"Should be a markdown file starting with header"
);
}
/// Both log files (`trace.log` and `subprocess.log`) should be created at `-vv`.
#[rstest]
fn test_log_files_created(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
repo.wt_command().args(["list", "-vv"]).output().unwrap();
let logs_dir = repo.root_path().join(".git").join("wt/logs");
let trace_log = logs_dir.join("trace.log");
let subprocess_log = logs_dir.join("subprocess.log");
assert!(trace_log.exists(), "trace.log should be created with -vv");
assert!(
subprocess_log.exists(),
"subprocess.log should be created with -vv"
);
let trace = fs::read_to_string(&trace_log).unwrap();
assert!(!trace.is_empty(), "trace.log should not be empty");
assert!(
trace.contains("◷ init_logging"),
"trace.log should contain humanized trace records"
);
}
/// At `-vv` the performance profile is the diagnostic bundle's lead section —
/// promoted above the raw Environment/Worktrees/Config dumps — and there is no
/// standalone `profile.txt` (the bundle is the single human-facing doc).
#[rstest]
fn test_diagnostic_leads_with_profile(repo: TestRepo) {
let output = repo.wt_command().args(["list", "-vv"]).output().unwrap();
let logs_dir = repo.root_path().join(".git").join("wt/logs");
// The standalone profile.txt is gone — the profile lives in the bundle.
assert!(
!logs_dir.join("profile.txt").exists(),
"profile.txt should not be written; the profile rides inside diagnostic.md"
);
// The bundle leads with the profile: its section precedes Environment.
let content = fs::read_to_string(logs_dir.join("diagnostic.md")).unwrap();
let profile_at = content
.find("<summary>Performance profile</summary>")
.expect("diagnostic.md should contain the performance profile section");
let env_at = content
.find("<summary>Environment</summary>")
.expect("diagnostic.md should contain the Environment section");
assert!(
profile_at < env_at,
"profile section should precede Environment in diagnostic.md: {content}"
);
// The profile is no longer announced separately — only diagnostic.md is.
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("Performance profile saved"),
"the profile is folded into diagnostic.md, not announced separately: {stderr}"
);
assert!(
stderr.contains("Diagnostics and performance profile saved"),
"stderr should announce the diagnostic bundle. stderr: {stderr}"
);
}
/// At `-vv`, the full (uncapped) subprocess stdout/stderr should land in
/// `subprocess.log` via `shell_exec::SUBPROCESS_FULL_TARGET` — not in `trace.log`.
/// `trace.log` gets the bounded preview alongside trace records.
#[rstest]
fn test_vv_splits_full_and_bounded_output(repo: TestRepo) {
repo.wt_command().args(["list", "-vv"]).output().unwrap();
let logs_dir = repo.root_path().join(".git").join("wt/logs");
let trace = fs::read_to_string(logs_dir.join("trace.log")).unwrap();
let output = fs::read_to_string(logs_dir.join("subprocess.log")).unwrap();
assert!(
trace.contains("◷ init_logging"),
"trace.log should contain humanized trace records at -vv"
);
// Captured stdout is emitted line-by-line with the ` ` / ` ! `
// continuation prefix used by log_output. Full subprocess output lives
// in subprocess.log.
assert!(
output.lines().any(|l| l.contains(" worktree ")),
"subprocess.log should contain `git worktree list --porcelain` stdout lines at -vv"
);
// Trace records stay in trace.log only, not subprocess.log (which holds
// raw bodies under their `$ cmd … [seq=N tid=T]` headers).
assert!(
!output.contains("◷ init_logging"),
"subprocess.log should not contain trace records"
);
}
/// Each command's block in `subprocess.log` is introduced by a
/// `$ cmd … [seq=N tid=T]` header, and that `seq` also tags the command's
/// record in the machine `trace.jsonl` — so an otherwise-undelimited raw output
/// block can be segmented and joined back to its timed command record. (The
/// humanized `trace.log` drops `seq`; the join lives in `trace.jsonl`.) Guards
/// the segmentation + correlation contract the files share.
#[rstest]
fn test_vv_subprocess_log_headers_join_trace(repo: TestRepo) {
repo.wt_command().args(["list", "-vv"]).output().unwrap();
let logs_dir = repo.root_path().join(".git").join("wt/logs");
let subprocess = fs::read_to_string(logs_dir.join("subprocess.log")).unwrap();
let trace = fs::read_to_string(logs_dir.join("trace.jsonl")).unwrap();
// Pick a known captured command (`wt list` runs `git worktree list
// --porcelain`) so the join can be checked by command identity, not just by
// a seq value that — being process-global and dense — exists on *some*
// record regardless. A header starts with `$ ` (body lines start with ` `,
// so blocks self-segment) and carries the `seq` join key.
let header = subprocess
.lines()
.find(|l| l.starts_with("$ ") && l.contains("git worktree list") && l.contains("[seq="))
.expect("subprocess.log should head the `git worktree list` block with `$ … [seq=N …]`");
let seq: u64 = header
.split("[seq=")
.nth(1)
.and_then(|s| s.split([' ', ']']).next())
.and_then(|s| s.parse().ok())
.expect("the subprocess.log header should carry a numeric seq");
// The trace.jsonl record carrying that seq must be the SAME command —
// asserting both `seq` and `cmd` closes the gap where a dense seq coincides
// with an unrelated record. (serde_json emits compact `"key":value`.)
assert!(
trace.lines().any(|l| {
l.contains(&format!(r#""seq":{seq},"#)) && l.contains(r#""cmd":"git worktree list"#)
}),
"trace.jsonl should carry the record for `git worktree list` with seq={seq} to join the subprocess.log block:\n{header}"
);
}
/// A command that pipes stdin has it captured in its `subprocess.log` block
/// under a ` < ` prefix, so the deep log shows what was fed in, not just what
/// came out. `wt list --full` counts each worktree's diff including untracked
/// files, staging them via `git add --pathspec-from-file=-` (stdin), which
/// exercises the path.
#[rstest]
fn test_vv_subprocess_log_captures_stdin(repo: TestRepo) {
for path in repo.worktrees.values() {
std::fs::write(path.join("untracked-probe.txt"), "stdin probe\n").unwrap();
}
repo.wt_command()
.args(["list", "--full", "-vv"])
.output()
.unwrap();
let subprocess =
fs::read_to_string(repo.root_path().join(".git").join("wt/logs/subprocess.log")).unwrap();
assert!(
subprocess.lines().any(|l| l.starts_with(" < ")),
"subprocess.log should capture piped stdin under a ` < ` prefix:\n{subprocess}"
);
}
/// Control bytes in captured subprocess output must be escaped on the
/// human-facing routes (stderr + `trace.log`) but kept verbatim in
/// `subprocess.log`. `wt list` runs `git for-each-ref --format=…%00…`
/// (`LOCAL_BRANCH_FORMAT`), so its captured stdout carries NUL bytes between
/// fields — a real instance of the bytes that, left raw, make `diagnostic.md`
/// sniff as binary and break its `gh gist create` upload (issue #2988).
#[rstest]
fn test_vv_escapes_control_bytes_in_trace_not_subprocess(repo: TestRepo) {
repo.wt_command().args(["list", "-vv"]).output().unwrap();
let logs_dir = repo.root_path().join(".git").join("wt/logs");
let trace = fs::read_to_string(logs_dir.join("trace.log")).unwrap();
let subprocess = fs::read_to_string(logs_dir.join("subprocess.log")).unwrap();
let diagnostic = fs::read_to_string(logs_dir.join("diagnostic.md")).unwrap();
// subprocess.log is the raw byte stream — NUL survives verbatim.
assert!(
subprocess.contains('\u{0}'),
"subprocess.log should keep raw NUL bytes from for-each-ref output"
);
// The bounded preview in trace.log is escaped: visible `\0`, no raw NUL.
assert!(
trace.contains(r"\0"),
"trace.log should render NUL as the escaped `\\0`"
);
assert!(
!trace.contains('\u{0}'),
"trace.log must not carry raw NUL bytes (would sniff as binary)"
);
// diagnostic.md inlines trace.log plus config / worktree list — the upload
// boundary must be free of raw control bytes so the gist upload succeeds.
assert!(
!diagnostic.contains('\u{0}'),
"diagnostic.md must not carry raw NUL bytes (binary file → gist upload fails)"
);
}
/// At `-vv`, Debug-level records (the noisy ones) stay out of stderr —
/// the bounded subprocess preview lands in `trace.log` (not stderr), and
/// `subprocess.log` still holds the unbounded body. Info-level routing
/// from `-v` still applies at `-vv` (it's a superset), so the "Writing
/// to ..." pointer and similar status lines DO appear on stderr; they're
/// asserted at the end of this test. Guards against a regression that
/// re-routes the debug stream to stderr and floods the terminal.
#[rstest]
fn test_vv_debug_pipeline_silent_on_stderr(repo: TestRepo) {
// `wt list` runs `git for-each-ref refs/heads/` (captured via `Cmd::run`),
// so its output flows through `log_output` and exercises the split.
// Populate packed-refs with 250 fake branches pointing at HEAD — far more
// than LOG_OUTPUT_MAX_LINES (200), which would have tripped elision on
// stderr under the old routing.
let head_out = repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap();
let head = String::from_utf8(head_out.stdout)
.unwrap()
.trim()
.to_string();
let mut content = String::from("# pack-refs with: peeled fully-peeled sorted\n");
for i in 0..250 {
use std::fmt::Write as _;
writeln!(&mut content, "{head} refs/heads/many-branch-{i:03}").unwrap();
}
std::fs::write(repo.root_path().join(".git/packed-refs"), content).unwrap();
let output = repo
.wt_command()
.args(["list", "-vv"])
.env("NO_COLOR", "1")
.output()
.expect("wt list");
let stderr = String::from_utf8_lossy(&output.stderr);
let logs_dir = repo.root_path().join(".git").join("wt/logs");
let trace = fs::read_to_string(logs_dir.join("trace.log")).unwrap();
let raw = fs::read_to_string(logs_dir.join("subprocess.log")).unwrap();
// Elision marker belongs to the bounded preview, which now lives in
// trace.log only — never on stderr.
let marker = "more lines, ";
assert!(
trace.contains(marker),
"trace.log should hold the bounded preview's elision marker"
);
assert!(
!stderr.contains(marker),
"stderr must not receive the bounded preview at -vv: {stderr}"
);
assert!(
!raw.contains(marker),
"subprocess.log holds full output and must not contain an elision marker"
);
// The tail refs only appear in the full log.
let tail_ref = "many-branch-249";
assert!(
raw.contains(tail_ref),
"subprocess.log should contain the full for-each-ref stdout (last ref)"
);
assert!(
!stderr.contains(tail_ref),
"stderr must not see the per-line subprocess output at -vv"
);
assert!(
!trace.contains(tail_ref),
"trace.log holds the bounded preview, which is capped before the last ref"
);
// The full subprocess stdout still lands in subprocess.log, and trace.log
// still captures the humanized trace records — confirm both so a
// regression that disables the file sinks fails loudly here too.
assert!(
raw.lines().any(|l| l.contains("refs/heads/many-branch-")),
"subprocess.log should contain the captured for-each-ref stdout"
);
assert!(
trace.contains("◷ init_logging"),
"trace.log should contain humanized trace records at -vv"
);
// Stderr at -vv opens with a one-line pointer to the log directory (the
// per-file rundown is deferred to the end-of-run diagnostic announcement).
assert!(
stderr.contains("Verbose logging @") && stderr.contains("wt/logs"),
"stderr should point at the log directory at startup: {stderr}"
);
}
/// `RUST_LOG` is honored at every verbosity level — including `-v` — and
/// can raise the filter above the flag's baseline. A user who runs
/// `RUST_LOG=debug wt -v` gets Debug, not Info: the flag sets a baseline,
/// and `RUST_LOG` refines it on top via `tracing_subscriber::EnvFilter`'s
/// directive grammar (env wins when set). Guards against the regression
/// where `-v`/`-vv` hardcoded `filter_level(...)` and silently dropped
/// `RUST_LOG`.
///
/// The probe is the `◷ init_logging` span record — emitted at `log::debug!`,
/// unique to the trace stream (so it can't be confused with normal output),
/// and present on every run. It's suppressed at the Info baseline `-v` selects
/// and surfaces when `RUST_LOG=debug` raises it. (At `-v 0` the same
/// `RUST_LOG=debug` path is already covered by
/// `test_rust_log_debug_fallback_without_vv`; at `-vv` the baseline is
/// already Debug so there's no flag/env conflict to assert.)
#[rstest]
fn test_rust_log_overrides_verbose_flag(repo: TestRepo) {
// Baseline: -v alone caps at Info, so debug-level trace records are
// dropped from stderr.
let info_only = repo
.wt_command()
.args(["list", "-v"])
.env_remove("RUST_LOG")
.env("NO_COLOR", "1")
.output()
.expect("wt list -v");
let info_stderr = String::from_utf8_lossy(&info_only.stderr);
assert!(
!info_stderr.contains("init_logging"),
"-v alone (Info baseline) should not surface trace records: {info_stderr}"
);
// RUST_LOG=debug + -v raises the level: trace records appear.
let with_env = repo
.wt_command()
.args(["list", "-v"])
.env("RUST_LOG", "debug")
.env("NO_COLOR", "1")
.output()
.expect("wt list -v");
let env_stderr = String::from_utf8_lossy(&with_env.stderr);
assert!(
env_stderr.contains("init_logging"),
"RUST_LOG=debug at -v should surface trace records on stderr: {env_stderr}"
);
}
/// `RUST_LOG=debug` at `-v 0` activates Debug logging without creating the
/// on-disk log files. Stderr receives the **bounded** preview
/// (`SUBPROCESS_BOUNDED_TARGET`); the uncapped `SUBPROCESS_FULL_TARGET`
/// records are dropped so raw bodies don't flood the terminal.
#[rstest]
fn test_rust_log_debug_fallback_without_vv(repo: TestRepo) {
let output = repo
.wt_command()
.args(["list"])
.env("RUST_LOG", "debug")
.env("NO_COLOR", "1")
.output()
.expect("wt list");
let stderr = String::from_utf8_lossy(&output.stderr);
// No log files were created — `-vv` is what opens them.
let logs_dir = repo.root_path().join(".git").join("wt/logs");
assert!(
!logs_dir.join("trace.log").exists(),
"trace.log should NOT be created without -vv"
);
assert!(
!logs_dir.join("subprocess.log").exists(),
"subprocess.log should NOT be created without -vv"
);
// Subprocess stdout still reaches stderr via the bounded preview —
// look for captured `git worktree list --porcelain` stdout lines.
assert!(
stderr.lines().any(|l| l.contains(" worktree ")),
"stderr should contain subprocess stdout via bounded preview: {stderr}"
);
// For small outputs (git worktree list --porcelain is a handful of lines)
// the bounded preview fits under the cap without an elision marker.
assert!(
!stderr.contains("more lines, "),
"short subprocess output should not trip the elision marker: {stderr}"
);
}
/// `WORKTRUNK_VERBOSE` sets the verbosity level from the environment, mirroring
/// the `-v`/`-vv` flags so logging can be turned on with no CLI flag — the only
/// way to reach shell completion, which has nowhere to pass `-vv` (see the
/// completion suite). The env and the flag combine via `max`: the env is a
/// baseline the flags raise but never lower. Level 2 opens the on-disk trace
/// files exactly like `-vv`; level 1 does not, proving the count is threaded
/// rather than collapsed to a boolean "verbose on".
#[rstest]
fn test_worktrunk_verbose_env_sets_level(repo: TestRepo) {
let logs_dir = repo.root_path().join(".git").join("wt/logs");
let trace_log = logs_dir.join("trace.log");
// Level 2 with no flag opens the trace files, just like `-vv`.
let _ = fs::remove_dir_all(&logs_dir);
let out = repo
.wt_command()
.args(["list"])
.env("WORKTRUNK_VERBOSE", "2")
.env("NO_COLOR", "1")
.output()
.expect("wt list");
assert!(out.status.success());
assert!(
trace_log.exists(),
"WORKTRUNK_VERBOSE=2 should open trace.log like -vv"
);
// Level 1 with no flag does NOT — the trace files are a level-2 artifact,
// so the env must carry the count, not just a boolean.
let _ = fs::remove_dir_all(&logs_dir);
let out = repo
.wt_command()
.args(["list"])
.env("WORKTRUNK_VERBOSE", "1")
.env("NO_COLOR", "1")
.output()
.expect("wt list");
assert!(out.status.success());
assert!(
!trace_log.exists(),
"WORKTRUNK_VERBOSE=1 should not open the -vv trace files"
);
// An explicit `-vv` wins over a lower env baseline (`max`, not env-wins):
// env 0 + `-vv` still writes the files.
let _ = fs::remove_dir_all(&logs_dir);
let out = repo
.wt_command()
.args(["list", "-vv"])
.env("WORKTRUNK_VERBOSE", "0")
.env("NO_COLOR", "1")
.output()
.expect("wt list -vv");
assert!(out.status.success());
assert!(
trace_log.exists(),
"an explicit -vv must win over WORKTRUNK_VERBOSE=0"
);
}
// =============================================================================
// Tests for -vv verbosity level (always write diagnostic)
// =============================================================================
/// With -vv, diagnostic file should be written even on successful commands.
#[rstest]
fn test_vv_writes_diagnostic_on_success(repo: TestRepo) {
// Run a successful command with -vv
let output = repo.wt_command().args(["list", "-vv"]).output().unwrap();
assert!(output.status.success(), "Command should succeed");
// Diagnostic file should exist
let diagnostic_path = repo
.root_path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md");
assert!(
diagnostic_path.exists(),
"Diagnostic file should be created with -vv even on success"
);
// Content should indicate success
let content = fs::read_to_string(&diagnostic_path).unwrap();
assert!(
content.contains("Command completed successfully"),
"Result should indicate success. Content: {}",
content
);
// stderr should mention diagnostic was saved
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Diagnostics and performance profile saved"),
"stderr should mention diagnostic was saved. stderr: {}",
stderr
);
}
/// With -vv, diagnostic file should be written on error (same as success case).
#[rstest]
fn test_vv_writes_diagnostic_on_error(mut repo: TestRepo) {
repo.add_worktree("feature");
corrupt_worktree_head(&repo, "feature");
// Run a command that will hit git errors with -vv
let output = repo.wt_command().args(["list", "-vv"]).output().unwrap();
// Diagnostic file should exist
let diagnostic_path = repo
.root_path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md");
assert!(
diagnostic_path.exists(),
"Diagnostic file should be created with -vv on error"
);
// stderr should mention diagnostic was saved
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Diagnostics and performance profile saved"),
"stderr should mention diagnostic was saved. stderr: {}",
stderr
);
}
/// If one of the `-vv` log files can't be opened (here: subprocess.log's path
/// is occupied by a directory), the startup pointer still appears — it's
/// anchored on the log directory, which trace.log's open makes available — and
/// trace.log is still written.
#[rstest]
fn test_vv_pointer_handles_split_init(repo: TestRepo) {
let logs_dir = repo.root_path().join(".git").join("wt/logs");
std::fs::create_dir_all(&logs_dir).unwrap();
// Block subprocess.log open by occupying the path with a directory.
std::fs::create_dir(logs_dir.join("subprocess.log")).unwrap();
let output = repo
.wt_command()
.args(["list", "-vv"])
.env("NO_COLOR", "1")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Verbose logging @") && stderr.contains("wt/logs"),
"startup pointer should still name the log directory when subprocess.log can't open: {stderr}"
);
assert!(
logs_dir.join("trace.log").exists(),
"trace.log should still be created"
);
}
/// With just -v, info-level logging goes to stderr but no log files are written.
/// `-vv` is the threshold for `trace.log`, `trace.jsonl`, `subprocess.log`, and
/// `diagnostic.md`.
#[rstest]
fn test_v_does_not_write_log_files(repo: TestRepo) {
// Run a successful command with just -v
let output = repo.wt_command().args(["list", "-v"]).output().unwrap();
assert!(output.status.success(), "Command should succeed");
// None of the -vv diagnostic files should exist with just -v
let wt_logs = repo.root_path().join(".git").join("wt/logs");
for name in [
"diagnostic.md",
"trace.log",
"trace.jsonl",
"subprocess.log",
] {
assert!(
!wt_logs.join(name).exists(),
"{name} should NOT be created with just -v (requires -vv)"
);
}
}
/// With -vv outside a git repo, command should still work (no crash), no
/// log files are written, and `announce_trace_destination` takes its silent
/// early-return path (TRACE.path() is None).
#[test]
fn test_vv_outside_repo_no_crash() {
use crate::common::wt_command;
// Create a temp directory that is NOT a git repo
let temp_dir = tempfile::tempdir().unwrap();
// Use `list` (not `--version`) so `init_logging` actually runs — clap
// short-circuits `--version` before logging is set up, which would
// skip the announce_trace_destination path this test exists to cover.
let output = wt_command()
.args(["list", "-vv"])
.current_dir(temp_dir.path())
.output()
.unwrap();
// `wt list` exits non-zero outside a git repo, but that's fine —
// init_logging still ran before the command failed.
assert!(
!output.status.success(),
"wt list outside a repo should fail"
);
// No diagnostic file should be created (not in a git repo)
let diagnostic_path = temp_dir
.path()
.join(".git")
.join("wt/logs")
.join("diagnostic.md");
assert!(
!diagnostic_path.exists(),
"Diagnostic file should NOT be created outside a git repo"
);
// Startup pointer should NOT appear: TRACE failed to open, so
// announce_trace_destination took its early-return path.
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("Verbose logging @"),
"no startup pointer when trace.log can't open: {stderr}"
);
}
/// When gh is installed, the hint should show gist creation and issue URL.
#[rstest]
fn test_diagnostic_gh_hint_with_vv(mut repo: TestRepo) {
// Setup mock gh so it appears installed
repo.setup_mock_gh();
let output = repo.wt_command().args(["list", "-vv"]).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
// Extract the hint line (starts with ↳)
let hint_line = stderr
.lines()
.find(|line| line.contains("report a bug"))
.expect("Should have hint about reporting a bug");
// Normalize the path in the hint. The path may be:
// - Quoted on Windows (drive letter colon requires POSIX escaping): 'D:/a/.../diagnostic.md'
// - Unquoted on Unix (no special chars): _REPO_/.git/wt/logs/diagnostic.md
let normalized = regex::Regex::new(r"--web '?[^' \x1b]*diagnostic\.md'?")
.unwrap()
.replace(hint_line, "--web [DIAGNOSTIC_PATH]");
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
assert_snapshot!("diagnostic_gh_hint", normalized);
});
}
/// Normalize the report for snapshot comparison.
///
/// Replaces variable content (versions, paths, timestamps) with placeholders.
fn normalize_report(content: &str) -> String {
let mut result = content.to_string();
// Normalize timestamp (e.g., "2025-01-01T00:00:00Z" -> "[TIMESTAMP]")
result = regex::Regex::new(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
.unwrap()
.replace_all(&result, "[TIMESTAMP]")
.to_string();
// Normalize command line path (binary path varies between environments)
// Matches: `target/debug/wt ...` or `/path/to/wt ...`
result = regex::Regex::new(r"\*\*Command:\*\* `[^`]+`")
.unwrap()
.replace_all(
&result,
"**Command:** `[PROJECT_ROOT]/target/[BUILD_MODE]/wt list -vv`",
)
.to_string();
// Normalize wt version line (e.g., "wt v0.9.3-dirty (macos aarch64)" or "wt be89089 (linux x86_64)")
// CI builds use commit hashes instead of version numbers
result = regex::Regex::new(r"wt [^ ]+ \([^)]+\)")
.unwrap()
.replace_all(&result, "wt [VERSION] ([OS] [ARCH])")
.to_string();
// Normalize git version line
result = regex::Regex::new(r"git \d+\.\d+[^\n]*")
.unwrap()
.replace_all(&result, "git [GIT_VERSION]")
.to_string();
// Normalize worktree paths in porcelain output (Unix and Windows absolute paths)
result = regex::Regex::new(r"worktree (?:/|[A-Za-z]:)[^\n]+")
.unwrap()
.replace_all(&result, "worktree [PATH]")
.to_string();
// Normalize commit hashes (40 hex chars) - in "HEAD xxx" format
result = regex::Regex::new(r"HEAD [a-f0-9]{40}")
.unwrap()
.replace_all(&result, "HEAD [COMMIT]")
.to_string();
// Normalize all other commit hashes (40 hex chars standalone)
result = regex::Regex::new(r"\b[a-f0-9]{40}\b")
.unwrap()
.replace_all(&result, "[HASH]")
.to_string();
// Normalize user config path (must come BEFORE generic repo path normalization)
result = regex::Regex::new(r"User config: [^\n]+")
.unwrap()
.replace_all(&result, "User config: [TEST_CONFIG]")
.to_string();
// Normalize project config path (must come BEFORE generic repo path normalization)
// Handle both Unix (/) and Windows (\) path separators
result = regex::Regex::new(r"Project config: (?:/|[A-Za-z]:)[^\n]+\.config[/\\]wt\.toml")
.unwrap()
.replace_all(&result, "Project config: _REPO_/.config/wt.toml")
.to_string();
// Normalize worktree temp paths (paths that contain `/repo.<name>` or
// `\repo.<name>` — the convention used by TestRepo for linked worktrees).
// Both branches require the `repo.` segment so the main-worktree path
// (which is `.../repo` without a dot) falls through to insta's prefix
// filter instead. Stop at whitespace, `)`, or a backtick so paths inlined
// in markdown code spans don't eat the closing backtick.
result = regex::Regex::new(r"([A-Z]:[^\s)`]+[\\/]repo\.[^\s)`]+|/[^\s)`]+/repo\.[^\s)`]+)")
.unwrap()
.replace_all(&result, "[REPO_PATH]")
.to_string();
// Normalize line breaks in git error messages (cross-platform consistency)
// Some platforms wrap "fatal: not a git repository:\n /path" on two lines,
// others keep it on one line. Normalize to single-line format.
result = regex::Regex::new(r"(fatal: not a git repository:)\s*\n\s*(\[REPO_PATH\])")
.unwrap()
.replace_all(&result, "$1 $2")
.to_string();
// Truncate the environment-variables section - its values (TERM, SHELL,
// LANG, PAGER, …) are inherited from the host and differ per machine, so
// exact comparison is non-deterministic. We assert specific lines appear in
// a dedicated test instead.
truncate_details_section(&mut result, "Environment variables", "[ENV_VARS_CONTENT]");
// Truncate performance profile section - its durations and by-type/slowest
// ordering depend on the run's real timing, so exact comparison is flaky.
// We verify the section exists separately in the test.
truncate_details_section(
&mut result,
"Performance profile",
"[PERFORMANCE_PROFILE_CONTENT]",
);
// Truncate trace log section - it has parallel git commands that interleave
// in different orders, making exact snapshot comparison flaky.
// We verify the section exists separately in the test.
truncate_details_section(&mut result, "Trace log", "[TRACE_LOG_CONTENT]");
result
}
/// Replace a `<details>` section's body with a fixed placeholder so
/// host-specific or run-specific content (env values, timings, interleaved
/// trace output) doesn't make the snapshot non-deterministic. The section is
/// located by its `<summary>{title}</summary>` line and truncated up to the
/// next `</details>`. A no-op when the section isn't present.
fn truncate_details_section(result: &mut String, title: &str, placeholder: &str) {
let summary = format!("<summary>{title}</summary>");
if let Some(start) = result.find(&summary)
&& let Some(end_offset) = result[start..].find("</details>")
{
let end = start + end_offset + "</details>".len();
*result = format!(
"{}{summary}\n\n{placeholder}\n</details>{}",
&result[..start],
&result[end..],
);
}
}