mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
4485312c50
Nightly sweep finding. The `wt switch` first-run shell-integration offer could delete a deprecated wrapper file it never named in the confirmation prompt — the same unpreviewed-deletion defect [#3644](https://github.com/max-sixty/worktrunk/issues/3644) fixed for `wt config shell install`, left on the sibling first-run path. ## The gap When `wt switch` shows *"Install shell integration?"* on first run, [`prompt_shell_integration`](https://github.com/max-sixty/worktrunk/blob/c4e439409445b300c052a7761450511fa1ada0a6/src/output/shell_integration.rs#L474) passed an **empty** legacy-cleanups list to the prompt, then called `handle_configure_shell(None, /* skip_confirmation */ true, …)`. That call removes any deprecated wrapper file as a side effect — the fish `conf.d/wt.fish` superseded by `functions/wt.fish` (#566), or a nushell wrapper stranded at a legacy autoload path (#2878). So a user on an unconfigured bash who also had a stale, worktrunk-managed `~/.config/fish/conf.d/wt.fish` would accept *"Install shell integration?"* and have that fish file deleted — reported only after the fact. This is exactly what `wt config shell install` now previews (#3644, merged in #3648); the first-run offer was the one path still deleting without naming the file first. It runs against [CLAUDE.md's data-safety rule](https://github.com/max-sixty/worktrunk/blob/c4e439409445b300c052a7761450511fa1ada0a6/CLAUDE.md): *"No implicit destructive side effects — never silently delete/overwrite as a side effect of an unrelated operation."* ## The fix Compute the same dry-run legacy-cleanup list `handle_configure_shell` re-derives internally (both come from the same `scan_shell_configs(None, true, …)` scan, so the lists are identical) and pass it to the offer's prompt. The deletion is now named before consent. The change is **purely additive to the prompt** — it does not change what gets removed, only what the prompt discloses. ## Test A PTY regression test drives the first-run offer with bash unconfigured (so the offer fires) and a deprecated fish `conf.d/wt.fish` present, requests the preview (`?`), and asserts it names the removal. Verified it **fails without the fix** (the preview shows only bash's *"Will add"* line) and **passes with it**. ## Note for review The prior code carried a comment documenting the empty-list behavior as deliberate (*"resolves no legacy cleanups of its own … reports removal after the fact, as before"*). Reading it as *"#3644's fix wasn't extended here"* rather than *"the first-run offer should delete without previewing"*, this completes that fix — but flagging it so the call is explicit. If the boundary was intentional, this is a safe no-op to close. Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
879 lines
32 KiB
Rust
879 lines
32 KiB
Rust
//! Tests for the shell integration first-run prompt
|
|
//!
|
|
//! These tests verify that `prompt_shell_integration` behaves correctly across scenarios:
|
|
//! - Skips when shell integration is active (WORKTRUNK_DIRECTIVE_CD_FILE set)
|
|
//! - Skips when already prompted (config flag true)
|
|
//! - Skips when already installed (config line exists in shell config)
|
|
//! - Shows hint when not a TTY (non-interactive)
|
|
//! - Prompts and respects user's choice in interactive mode
|
|
|
|
use crate::common::{TestRepo, repo};
|
|
use rstest::rstest;
|
|
use std::fs;
|
|
use worktrunk::config::UserConfig;
|
|
|
|
///
|
|
/// When WORKTRUNK_DIRECTIVE_CD_FILE is set (shell integration active), we should:
|
|
/// 1. Never call prompt_shell_integration()
|
|
/// 2. Have zero overhead from the prompt feature
|
|
#[rstest]
|
|
fn test_switch_with_active_shell_integration_no_prompt(repo: TestRepo) {
|
|
// Create a worktree first
|
|
let create_output = repo
|
|
.wt_command()
|
|
.args(["switch", "--create", "feature"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
create_output.status.success(),
|
|
"First switch should succeed: {}",
|
|
String::from_utf8_lossy(&create_output.stderr)
|
|
);
|
|
|
|
// Now switch with shell integration "active" (CD directive file set)
|
|
// The file must exist (shell wrapper creates it before calling wt)
|
|
let cd_file = repo.root_path().join("directive_cd.txt");
|
|
let exec_file = repo.root_path().join("directive_exec.txt");
|
|
fs::write(&cd_file, "").unwrap();
|
|
fs::write(&exec_file, "").unwrap();
|
|
let mut cmd = repo.wt_command();
|
|
cmd.env("WORKTRUNK_DIRECTIVE_CD_FILE", &cd_file);
|
|
cmd.env("WORKTRUNK_DIRECTIVE_EXEC_FILE", &exec_file);
|
|
|
|
let output = cmd.args(["switch", "feature"]).output().unwrap();
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
output.status.success(),
|
|
"Switch should succeed.\nstderr: {stderr}\nstdout: {stdout}"
|
|
);
|
|
|
|
// The CD file should have a path (shell integration active)
|
|
let cd_content = fs::read_to_string(&cd_file).unwrap_or_default();
|
|
assert!(
|
|
!cd_content.trim().is_empty(),
|
|
"CD file should contain a path when shell integration active"
|
|
);
|
|
|
|
// No install prompt in output (would contain "Install shell integration")
|
|
assert!(
|
|
!stderr.contains("Install shell integration"),
|
|
"Should not show install prompt when shell integration active: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_switch_with_skip_prompt_flag(repo: TestRepo) {
|
|
// Set the skip flag in config
|
|
let config_path = repo.test_config_path();
|
|
let config = UserConfig {
|
|
skip_shell_integration_prompt: true,
|
|
..Default::default()
|
|
};
|
|
config.save_to(config_path).unwrap();
|
|
|
|
let output = repo
|
|
.wt_command()
|
|
.args(["switch", "--create", "feature"])
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success(), "Switch should succeed");
|
|
|
|
// No install prompt in output
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
!stderr.contains("Install shell integration"),
|
|
"Should not show install prompt when already prompted: {stderr}"
|
|
);
|
|
}
|
|
|
|
///
|
|
/// When stdin is not a TTY (e.g., piped input), we should:
|
|
/// - Skip the prompt (can't interact)
|
|
/// - Always show the hint (not just first run)
|
|
/// - NOT mark as prompted (hints are not prompts)
|
|
#[rstest]
|
|
fn test_switch_non_tty_shows_hint(repo: TestRepo) {
|
|
use std::process::Stdio;
|
|
|
|
// Run with piped stdin (not a TTY)
|
|
let output = repo
|
|
.wt_command()
|
|
.args(["switch", "--create", "feature"])
|
|
.stdin(Stdio::piped())
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success(), "Switch should succeed");
|
|
|
|
// Verify the switch succeeded without prompting
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("Created branch") && stderr.contains("and worktree"),
|
|
"Should create worktree: {stderr}"
|
|
);
|
|
|
|
// Should show hint
|
|
assert!(
|
|
stderr.contains("wt config shell install"),
|
|
"Should show install hint: {stderr}"
|
|
);
|
|
|
|
// Config should NOT have skip_shell_integration_prompt set (hints are not prompts)
|
|
let config_content = fs::read_to_string(repo.test_config_path()).unwrap_or_default();
|
|
assert!(
|
|
!config_content.contains("skip-shell-integration-prompt"),
|
|
"Should not mark as prompted for non-TTY (hints are not prompts): {config_content}"
|
|
);
|
|
|
|
// Second non-TTY run should also show hint
|
|
let output2 = repo
|
|
.wt_command()
|
|
.args(["switch", "--create", "feature2"])
|
|
.stdin(Stdio::piped())
|
|
.output()
|
|
.unwrap();
|
|
|
|
let stderr2 = String::from_utf8_lossy(&output2.stderr);
|
|
assert!(
|
|
stderr2.contains("wt config shell install"),
|
|
"Should show hint on every non-TTY run: {stderr2}"
|
|
);
|
|
}
|
|
|
|
///
|
|
/// When SHELL is set to an unsupported shell (like tcsh), we should:
|
|
/// - Show a hint that the shell is not supported
|
|
/// - List the supported shells
|
|
#[rstest]
|
|
fn test_switch_unsupported_shell_shows_hint(repo: TestRepo) {
|
|
use std::process::Stdio;
|
|
|
|
// Run with an unsupported shell
|
|
let mut cmd = repo.wt_command();
|
|
cmd.env("SHELL", "/bin/tcsh");
|
|
|
|
let output = cmd
|
|
.args(["switch", "--create", "feature"])
|
|
.stdin(Stdio::piped())
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success(), "Switch should succeed");
|
|
|
|
// Should show unsupported shell message
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("not yet supported for tcsh"),
|
|
"Should show unsupported shell message: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("bash, zsh, fish, nu, PowerShell"),
|
|
"Should list supported shells: {stderr}"
|
|
);
|
|
}
|
|
|
|
///
|
|
/// When SHELL is not set (unusual Unix setup or Windows), we should:
|
|
/// - Show the standard install hint
|
|
#[rstest]
|
|
fn test_switch_no_shell_env_shows_hint(repo: TestRepo) {
|
|
use std::process::Stdio;
|
|
|
|
// Run without SHELL set
|
|
let mut cmd = repo.wt_command();
|
|
cmd.env_remove("SHELL");
|
|
|
|
let output = cmd
|
|
.args(["switch", "--create", "feature"])
|
|
.stdin(Stdio::piped())
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success(), "Switch should succeed");
|
|
|
|
// Should show install hint
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("wt config shell install"),
|
|
"Should show install hint when SHELL not set: {stderr}"
|
|
);
|
|
}
|
|
|
|
///
|
|
/// $SHELL names the login shell (zsh, with integration installed), but the
|
|
/// process tree says wt is really running under fish. Restart advice would be
|
|
/// wrong — restarting fish activates nothing — so the warning must take the
|
|
/// not-installed shape for the shell actually in use.
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_login_shell_mismatch_uses_process_tree_shell(repo: TestRepo) {
|
|
use std::os::unix::process::CommandExt;
|
|
use std::process::Stdio;
|
|
|
|
repo.configure_shell_integration(); // writes the zsh eval line to ~/.zshrc
|
|
|
|
let mut cmd = repo.wt_command();
|
|
cmd.env("SHELL", "/bin/zsh");
|
|
cmd.env("WORKTRUNK_TEST_PARENT_SHELL", "fish");
|
|
cmd.arg0("wt"); // PATH-style invocation, not the explicit-path warning
|
|
|
|
let output = cmd
|
|
.args(["switch", "--create", "feature"])
|
|
.stdin(Stdio::piped())
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success(), "Switch should succeed");
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("shell integration not installed"),
|
|
"The actual shell (fish) has no integration: {stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("restart"),
|
|
"Restart advice targets the wrong shell here: {stderr}"
|
|
);
|
|
}
|
|
|
|
///
|
|
/// The process tree names a supported shell whose integration IS installed;
|
|
/// $SHELL points at a different, unconfigured shell. The process tree wins:
|
|
/// the warning takes the installed-but-not-active shape with the hedged
|
|
/// restart hint.
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_process_tree_shell_configured_shows_restart_hint(repo: TestRepo) {
|
|
use std::os::unix::process::CommandExt;
|
|
use std::process::Stdio;
|
|
|
|
repo.configure_shell_integration(); // writes the zsh eval line to ~/.zshrc
|
|
|
|
let mut cmd = repo.wt_command();
|
|
cmd.env("SHELL", "/bin/bash"); // login shell without integration
|
|
cmd.env("WORKTRUNK_TEST_PARENT_SHELL", "zsh");
|
|
cmd.arg0("wt");
|
|
|
|
let output = cmd
|
|
.args(["switch", "--create", "feature"])
|
|
.stdin(Stdio::piped())
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success(), "Switch should succeed");
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("shell integration installed but not active"),
|
|
"zsh (from the process tree) has integration: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("A shell restart usually activates shell integration"),
|
|
"Should show the hedged restart hint: {stderr}"
|
|
);
|
|
}
|
|
|
|
///
|
|
/// The process tree names a known-but-unsupported shell (tcsh); $SHELL points
|
|
/// at a supported one. The unsupported-shell hint must name the shell actually
|
|
/// in use rather than trusting $SHELL.
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_process_tree_unsupported_shell_overrides_shell_env(repo: TestRepo) {
|
|
use std::process::Stdio;
|
|
|
|
let mut cmd = repo.wt_command();
|
|
cmd.env("SHELL", "/bin/zsh");
|
|
cmd.env("WORKTRUNK_TEST_PARENT_SHELL", "tcsh");
|
|
|
|
let output = cmd
|
|
.args(["switch", "--create", "feature"])
|
|
.stdin(Stdio::piped())
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(output.status.success(), "Switch should succeed");
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("not yet supported for tcsh"),
|
|
"Should name the shell from the process tree: {stderr}"
|
|
);
|
|
}
|
|
|
|
// PTY-based tests for interactive scenarios
|
|
#[cfg(all(unix, feature = "shell-integration-tests"))]
|
|
mod pty_tests {
|
|
use super::*;
|
|
use crate::common::pty::{build_pty_command, exec_cmd_in_pty, exec_cmd_in_pty_prompted};
|
|
use crate::common::{add_pty_filters, setup_snapshot_settings, wt_bin};
|
|
use insta::assert_snapshot;
|
|
use std::path::Path;
|
|
use tempfile::TempDir;
|
|
|
|
/// Create insta settings for shell integration prompt PTY tests.
|
|
///
|
|
/// Combines:
|
|
/// - Standard repo path filters (from setup_snapshot_settings)
|
|
/// - PTY-specific filters (^D, ANSI resets)
|
|
/// - Home directory filter (for isolated temp home)
|
|
fn prompt_pty_settings(repo: &TestRepo, home_dir: &Path) -> insta::Settings {
|
|
let mut settings = setup_snapshot_settings(repo);
|
|
add_pty_filters(&mut settings);
|
|
|
|
// Replace temp home directory with [HOME]
|
|
settings.add_filter(®ex::escape(&home_dir.to_string_lossy()), "[HOME]");
|
|
|
|
settings
|
|
}
|
|
|
|
/// Test: Already installed (config line exists) → skip prompt
|
|
///
|
|
/// This covers the "installed but shell not restarted" scenario where:
|
|
/// - Shell integration is not active (no directive env vars)
|
|
/// - But the config line is already in shell config files
|
|
/// - We should detect this and skip the prompt (not show interactive prompt)
|
|
/// - We should NOT mark as prompted (no interactive prompt shown)
|
|
///
|
|
/// Note: Since tests run via `cargo test`, argv[0] contains a path (`target/debug/wt`),
|
|
/// so the "restart shell" hint is suppressed. Shell integration won't intercept explicit
|
|
/// paths, so restarting wouldn't help. In production (PATH lookup), users see a restart hint.
|
|
#[rstest]
|
|
fn test_already_installed_skips_prompt(repo: TestRepo) {
|
|
// Create isolated HOME with shell config that already has integration
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bashrc = temp_home.path().join(".bashrc");
|
|
let config_line = "if command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init bash)\"; fi";
|
|
fs::write(&bashrc, format!("{config_line}\n")).unwrap();
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
// Set SHELL to bash since we're testing with .bashrc
|
|
env_vars.push(("SHELL".to_string(), "/bin/bash".to_string()));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty(cmd, "");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
|
|
// Should NOT contain prompt (detected already installed)
|
|
assert!(
|
|
!output.contains("Install shell integration"),
|
|
"Should not prompt when already installed: {output}"
|
|
);
|
|
|
|
// Should have created the worktree
|
|
assert!(
|
|
output.contains("Created branch") && output.contains("and worktree"),
|
|
"Should create worktree: {output}"
|
|
);
|
|
|
|
// Config should NOT have skip-shell-integration-prompt = true
|
|
// (no interactive prompt shown, just a hint)
|
|
let config_content = fs::read_to_string(repo.test_config_path()).unwrap_or_default();
|
|
assert!(
|
|
!config_content.contains("skip-shell-integration-prompt"),
|
|
"Should NOT mark as prompted when just showing hint: {config_content}"
|
|
);
|
|
}
|
|
|
|
/// Test: Not installed, user declines → mark prompted, no install
|
|
#[rstest]
|
|
fn test_user_declines_prompt(repo: TestRepo) {
|
|
// Create isolated HOME with empty shell config
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bashrc = temp_home.path().join(".bashrc");
|
|
fs::write(&bashrc, "# empty bashrc\n").unwrap();
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
// Set SHELL to bash since we're testing with .bashrc
|
|
env_vars.push(("SHELL".to_string(), "/bin/bash".to_string()));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["n\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
|
|
// Should contain the prompt
|
|
assert!(
|
|
output.contains("Install shell integration"),
|
|
"Should show prompt: {output}"
|
|
);
|
|
|
|
// Should have created the worktree
|
|
assert!(
|
|
output.contains("Created branch") && output.contains("and worktree"),
|
|
"Should create worktree: {output}"
|
|
);
|
|
|
|
// Config should have skip-shell-integration-prompt = true
|
|
let config_content = fs::read_to_string(repo.test_config_path()).unwrap_or_default();
|
|
assert!(
|
|
config_content.contains("skip-shell-integration-prompt = true"),
|
|
"Should mark as prompted after decline: {config_content}"
|
|
);
|
|
|
|
// Shell config should NOT have the integration line
|
|
let bashrc_content = fs::read_to_string(&bashrc).unwrap();
|
|
assert!(
|
|
!bashrc_content.contains("eval \"$(command wt"),
|
|
"Should not install when declined: {bashrc_content}"
|
|
);
|
|
|
|
// Snapshot the output (filters applied via settings)
|
|
prompt_pty_settings(&repo, temp_home.path()).bind(|| {
|
|
assert_snapshot!("prompt_decline", &output);
|
|
});
|
|
}
|
|
|
|
/// Test: Not installed, user accepts → install and show success
|
|
#[rstest]
|
|
fn test_user_accepts_prompt(repo: TestRepo) {
|
|
// Create isolated HOME with empty shell config
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bashrc = temp_home.path().join(".bashrc");
|
|
fs::write(&bashrc, "# empty bashrc\n").unwrap();
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
// Set SHELL to bash since we're testing with .bashrc
|
|
env_vars.push(("SHELL".to_string(), "/bin/bash".to_string()));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["y\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
|
|
// Should contain the prompt
|
|
assert!(
|
|
output.contains("Install shell integration"),
|
|
"Should show prompt: {output}"
|
|
);
|
|
|
|
// Should show success message for configuration
|
|
assert!(
|
|
output.contains("Configured") && output.contains("bash"),
|
|
"Should show configured message: {output}"
|
|
);
|
|
|
|
// Config should NOT have skip-shell-integration-prompt after accept
|
|
// (only set after explicit decline - if they uninstall, they can be prompted again)
|
|
let config_content = fs::read_to_string(repo.test_config_path()).unwrap_or_default();
|
|
assert!(
|
|
!config_content.contains("skip-shell-integration-prompt = true"),
|
|
"Should not set skip flag after accept (installation itself prevents future prompts): {config_content}"
|
|
);
|
|
|
|
// Shell config SHOULD have the integration line
|
|
let bashrc_content = fs::read_to_string(&bashrc).unwrap();
|
|
assert!(
|
|
bashrc_content.contains("eval \"$(command wt"),
|
|
"Should install when accepted: {bashrc_content}"
|
|
);
|
|
|
|
// Snapshot the output (filters applied via settings)
|
|
prompt_pty_settings(&repo, temp_home.path()).bind(|| {
|
|
assert_snapshot!("prompt_accept", &output);
|
|
});
|
|
}
|
|
|
|
/// Test: User requests preview with ? then declines
|
|
#[rstest]
|
|
fn test_user_requests_preview_then_declines(repo: TestRepo) {
|
|
// Create isolated HOME with empty shell config
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bashrc = temp_home.path().join(".bashrc");
|
|
fs::write(&bashrc, "# empty bashrc\n").unwrap();
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
// Set SHELL to bash since we're testing with .bashrc
|
|
env_vars.push(("SHELL".to_string(), "/bin/bash".to_string()));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
// User requests preview, then declines
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["?\n", "n\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
|
|
// Should contain the prompt (shown twice - before and after preview)
|
|
assert!(
|
|
output.contains("Install shell integration"),
|
|
"Should show prompt: {output}"
|
|
);
|
|
|
|
// Should show preview content (gutter with config line)
|
|
assert!(
|
|
output.contains("Will add") && output.contains("bash"),
|
|
"Should show preview: {output}"
|
|
);
|
|
|
|
// Should show the config line in preview
|
|
assert!(
|
|
output.contains("eval") && output.contains("wt config shell init"),
|
|
"Should show config line in preview: {output}"
|
|
);
|
|
|
|
// Shell config should NOT have the integration line (user declined)
|
|
let bashrc_content = fs::read_to_string(&bashrc).unwrap();
|
|
assert!(
|
|
!bashrc_content.contains("eval \"$(command wt"),
|
|
"Should not install when declined after preview: {bashrc_content}"
|
|
);
|
|
|
|
// Snapshot the output (filters applied via settings)
|
|
prompt_pty_settings(&repo, temp_home.path()).bind(|| {
|
|
assert_snapshot!("prompt_preview_decline", &output);
|
|
});
|
|
}
|
|
|
|
/// Test: the first-run offer names a legacy file it will delete before the
|
|
/// user consents.
|
|
///
|
|
/// Regression for the `wt config shell install` preview (issue #3644) not
|
|
/// reaching the `wt switch` first-run offer. Setup: bash is unconfigured, so
|
|
/// the offer fires; fish is already configured at the canonical
|
|
/// `functions/wt.fish` while a deprecated `conf.d/wt.fish` lingers. Accepting
|
|
/// the offer would remove that legacy file, so its preview must name the
|
|
/// removal — otherwise `wt switch` deletes a file the prompt never mentioned.
|
|
#[rstest]
|
|
fn test_first_run_offer_previews_legacy_removal(repo: TestRepo) {
|
|
let temp_home = TempDir::new().unwrap();
|
|
|
|
// bash: unconfigured (empty rc) → current shell not installed → offer fires.
|
|
let bashrc = temp_home.path().join(".bashrc");
|
|
fs::write(&bashrc, "# empty bashrc\n").unwrap();
|
|
|
|
// fish: already configured at the canonical functions/wt.fish location.
|
|
let functions = temp_home.path().join(".config/fish/functions");
|
|
fs::create_dir_all(&functions).unwrap();
|
|
let wrapper = worktrunk::shell::ShellInit::with_prefix(
|
|
worktrunk::shell::Shell::Fish,
|
|
"wt".to_string(),
|
|
)
|
|
.generate_fish_wrapper()
|
|
.unwrap();
|
|
fs::write(functions.join("wt.fish"), format!("{wrapper}\n")).unwrap();
|
|
|
|
// fish: a deprecated conf.d/wt.fish that accepting the offer would delete.
|
|
let conf_d = temp_home.path().join(".config/fish/conf.d");
|
|
fs::create_dir_all(&conf_d).unwrap();
|
|
let legacy_file = conf_d.join("wt.fish");
|
|
fs::write(&legacy_file, "wt config shell init fish | source").unwrap();
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
env_vars.retain(|(k, _)| k != "WORKTRUNK_DIRECTIVE_FILE");
|
|
env_vars.push(("SHELL".to_string(), "/bin/bash".to_string()));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
// Request the preview, then decline so nothing is actually removed.
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["?\n", "n\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
assert!(
|
|
output.contains("Install shell integration"),
|
|
"Should show the first-run offer: {output}"
|
|
);
|
|
// The preview must name the deprecated fish file the install would remove.
|
|
assert!(
|
|
output.contains("Will remove") && output.contains("conf.d/wt.fish"),
|
|
"First-run offer preview must name the legacy fish removal: {output}"
|
|
);
|
|
|
|
// Declining leaves the legacy file in place — the preview did not delete it.
|
|
assert!(
|
|
legacy_file.exists(),
|
|
"Declining should preserve the legacy file: {legacy_file:?}"
|
|
);
|
|
}
|
|
|
|
/// Test: Second switch after first prompt → no prompt
|
|
#[rstest]
|
|
fn test_no_prompt_after_first_prompt(repo: TestRepo) {
|
|
// Create isolated HOME with empty shell config
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bashrc = temp_home.path().join(".bashrc");
|
|
fs::write(&bashrc, "# empty bashrc\n").unwrap();
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
// Set SHELL to bash since we're testing with .bashrc
|
|
env_vars.push(("SHELL".to_string(), "/bin/bash".to_string()));
|
|
|
|
// First switch - decline the prompt
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature1"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (_, _) = exec_cmd_in_pty_prompted(cmd, &["n\n"], "[y/N");
|
|
|
|
// Second switch - should NOT prompt again
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["switch", "--create", "feature2"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty(cmd, "");
|
|
|
|
assert_eq!(exit_code, 0);
|
|
|
|
assert!(
|
|
!output.contains("Install shell integration"),
|
|
"Should not prompt on second switch: {output}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Tests for commit generation prompt (similar to shell integration prompt)
|
|
#[cfg(all(unix, feature = "shell-integration-tests"))]
|
|
mod commit_generation_prompt_tests {
|
|
use super::*;
|
|
use crate::common::pty::{build_pty_command, exec_cmd_in_pty, exec_cmd_in_pty_prompted};
|
|
use crate::common::wt_bin;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::{Path, PathBuf};
|
|
use tempfile::TempDir;
|
|
|
|
fn setup_fake_claude(temp_home: &Path) -> PathBuf {
|
|
// Create a fake claude executable that does nothing
|
|
let bin_dir = temp_home.join("bin");
|
|
fs::create_dir_all(&bin_dir).unwrap();
|
|
let claude_path = bin_dir.join("claude");
|
|
fs::write(&claude_path, "#!/bin/sh\nexit 0\n").unwrap();
|
|
// Make executable
|
|
let mut perms = fs::metadata(&claude_path).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&claude_path, perms).unwrap();
|
|
bin_dir
|
|
}
|
|
|
|
/// Test: No LLM tool available, prompt is skipped and skip flag is set
|
|
#[rstest]
|
|
fn test_no_llm_tool_sets_skip_flag(repo: TestRepo) {
|
|
let temp_home = TempDir::new().unwrap();
|
|
|
|
// Stage a change so commit has something to do
|
|
let test_file = repo.root_path().join("test.txt");
|
|
fs::write(&test_file, "test content\n").unwrap();
|
|
repo.run_git(&["add", "test.txt"]);
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
// Use minimal PATH to ensure claude/codex aren't found
|
|
env_vars.push(("PATH".to_string(), "/usr/bin:/bin".to_string()));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["step", "commit"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty(cmd, "");
|
|
|
|
// Should succeed (using fallback commit message)
|
|
assert_eq!(exit_code, 0, "Command should succeed: {output}");
|
|
|
|
// Config should have skip-commit-generation-prompt = true (no tool found)
|
|
let config_content = fs::read_to_string(repo.test_config_path()).unwrap_or_default();
|
|
assert!(
|
|
config_content.contains("skip-commit-generation-prompt = true"),
|
|
"Should set skip flag when no tool found: {config_content}"
|
|
);
|
|
}
|
|
|
|
/// Test: LLM tool available, user declines prompt
|
|
#[rstest]
|
|
fn test_user_declines_llm_prompt(repo: TestRepo) {
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bin_dir = setup_fake_claude(temp_home.path());
|
|
|
|
// Stage a change
|
|
let test_file = repo.root_path().join("test.txt");
|
|
fs::write(&test_file, "test content\n").unwrap();
|
|
repo.run_git(&["add", "test.txt"]);
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
// Add our fake claude to PATH
|
|
let path = format!("{}:/usr/bin:/bin", bin_dir.display());
|
|
env_vars.push(("PATH".to_string(), path));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["step", "commit"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["n\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0, "Command should succeed: {output}");
|
|
|
|
// Should show the prompt
|
|
assert!(
|
|
output.contains("Configure") && output.contains("claude"),
|
|
"Should show LLM config prompt: {output}"
|
|
);
|
|
|
|
// Config should have skip-commit-generation-prompt = true
|
|
let config_content = fs::read_to_string(repo.test_config_path()).unwrap_or_default();
|
|
assert!(
|
|
config_content.contains("skip-commit-generation-prompt = true"),
|
|
"Should set skip flag when declined: {config_content}"
|
|
);
|
|
}
|
|
|
|
/// Test: LLM tool available, user accepts prompt
|
|
#[rstest]
|
|
fn test_user_accepts_llm_prompt(repo: TestRepo) {
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bin_dir = setup_fake_claude(temp_home.path());
|
|
|
|
// Stage a change
|
|
let test_file = repo.root_path().join("test.txt");
|
|
fs::write(&test_file, "test content\n").unwrap();
|
|
repo.run_git(&["add", "test.txt"]);
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
let path = format!("{}:/usr/bin:/bin", bin_dir.display());
|
|
env_vars.push(("PATH".to_string(), path));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["step", "commit"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (output, _exit_code) = exec_cmd_in_pty_prompted(cmd, &["y\n"], "[y/N");
|
|
|
|
// Note: exit_code may be non-zero because our fake claude doesn't generate
|
|
// a real commit message. We're testing the prompt flow, not the LLM result.
|
|
|
|
// Should show success message for config save
|
|
assert!(
|
|
output.contains("Added to user config"),
|
|
"Should show config added message: {output}"
|
|
);
|
|
|
|
// Config should have the command configured
|
|
let config_content = fs::read_to_string(repo.test_config_path()).unwrap_or_default();
|
|
assert!(
|
|
config_content.contains("[commit.generation]") && config_content.contains("command"),
|
|
"Should add commit generation config: {config_content}"
|
|
);
|
|
}
|
|
|
|
/// Test: User requests preview (?)
|
|
#[rstest]
|
|
fn test_user_requests_preview(repo: TestRepo) {
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bin_dir = setup_fake_claude(temp_home.path());
|
|
|
|
// Stage a change
|
|
let test_file = repo.root_path().join("test.txt");
|
|
fs::write(&test_file, "test content\n").unwrap();
|
|
repo.run_git(&["add", "test.txt"]);
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
let path = format!("{}:/usr/bin:/bin", bin_dir.display());
|
|
env_vars.push(("PATH".to_string(), path));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["step", "commit"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
// Request preview, then decline
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["?\n", "n\n"], "[y/N");
|
|
|
|
assert_eq!(exit_code, 0, "Command should succeed: {output}");
|
|
|
|
// Should show the preview
|
|
assert!(
|
|
output.contains("Would add to") && output.contains("[commit.generation]"),
|
|
"Should show preview: {output}"
|
|
);
|
|
}
|
|
|
|
/// Test: LLM tool available, user accepts, but the config save fails
|
|
///
|
|
/// Points WORKTRUNK_CONFIG_PATH at a path whose parent is a regular file,
|
|
/// so the write fails with ENOTDIR — exercising the save-failure hint,
|
|
/// which names the resolved config path via `config_path_for_display`.
|
|
#[rstest]
|
|
fn test_user_accepts_but_save_fails_shows_manual_hint(repo: TestRepo) {
|
|
let temp_home = TempDir::new().unwrap();
|
|
let bin_dir = setup_fake_claude(temp_home.path());
|
|
|
|
// Stage a change
|
|
let test_file = repo.root_path().join("test.txt");
|
|
fs::write(&test_file, "test content\n").unwrap();
|
|
repo.run_git(&["add", "test.txt"]);
|
|
|
|
// A regular file standing in for the config's parent directory makes
|
|
// every write under it fail with ENOTDIR — root-proof, unlike chmod.
|
|
let blocker = temp_home.path().join("blocker");
|
|
fs::write(&blocker, "x").unwrap();
|
|
let unwritable_config = blocker.join("config.toml");
|
|
|
|
let mut env_vars = repo.test_env_vars();
|
|
let path = format!("{}:/usr/bin:/bin", bin_dir.display());
|
|
env_vars.push(("PATH".to_string(), path));
|
|
// Overrides the WORKTRUNK_CONFIG_PATH from test_env_vars (last wins).
|
|
env_vars.push((
|
|
"WORKTRUNK_CONFIG_PATH".to_string(),
|
|
unwritable_config.to_string_lossy().to_string(),
|
|
));
|
|
|
|
let cmd = build_pty_command(
|
|
wt_bin().to_str().unwrap(),
|
|
&["step", "commit"],
|
|
repo.root_path(),
|
|
&env_vars,
|
|
Some(temp_home.path()),
|
|
);
|
|
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["y\n"], "[y/N");
|
|
|
|
// The commit still completes with a fallback message; only the config
|
|
// save fails, so the manual-add hint fires.
|
|
assert_eq!(exit_code, 0, "Command should succeed: {output}");
|
|
assert!(
|
|
output.contains("Config save failed"),
|
|
"Should show manual-add hint on save failure: {output}"
|
|
);
|
|
}
|
|
}
|