use anyhow::{bail, Context, Result};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm};
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
const INTEGRATION_MARKER: &str = "stax shell-setup";
const POSIX_INTEGRATION_FILENAME: &str = "shell-setup.sh";
const FISH_INTEGRATION_FILENAME: &str = "shell-setup.fish";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShellKind {
Posix,
Fish,
}
fn shell_snippet(shell_kind: ShellKind) -> &'static str {
match shell_kind {
ShellKind::Posix => posix_shell_snippet(),
ShellKind::Fish => fish_shell_snippet(),
}
}
fn posix_shell_snippet() -> &'static str {
r#"# Generated by stax shell-setup
# Existing aliases break zsh function definitions, so clear them first.
unalias stax 2>/dev/null || true
unalias st 2>/dev/null || true
unalias sw 2>/dev/null || true
export STAX_SHELL_INTEGRATION=1
__STAX_BIN=""
__stax_lookup_path() {
local name="$1"
local resolved=""
if [[ -n "${ZSH_VERSION:-}" ]]; then
resolved=$(whence -p "$name" 2>/dev/null)
else
resolved=$(type -P "$name" 2>/dev/null)
fi
if [[ -n "$resolved" ]]; then
printf '%s\n' "$resolved"
return 0
fi
return 1
}
__stax_resolve_bin() {
if [[ -n "${__STAX_BIN:-}" && -x "$__STAX_BIN" ]]; then
printf '%s\n' "$__STAX_BIN"
return 0
fi
if __STAX_BIN=$(__stax_lookup_path stax); then
printf '%s\n' "$__STAX_BIN"
return 0
fi
if __STAX_BIN=$(__stax_lookup_path st); then
printf '%s\n' "$__STAX_BIN"
return 0
fi
echo "stax: no 'stax' or 'st' binary found on PATH" >&2
return 127
}
__stax_exec() {
local bin
bin=$(__stax_resolve_bin) || return $?
"$bin" "$@"
}
__stax_insert_shell_output() {
local out=()
local inserted=0
local arg
for arg in "$@"; do
if [[ $inserted -eq 0 && "$arg" == "--" ]]; then
out+=("--shell-output")
inserted=1
fi
out+=("$arg")
done
if [[ $inserted -eq 0 ]]; then
out+=("--shell-output")
fi
printf '%s\0' "${out[@]}"
}
__stax_run_worktree_shell() {
local raw
local target_path=""
local launch=""
local message=""
local passthrough=()
local cmd=()
local item
while IFS= read -r -d '' item; do
cmd+=("$item")
done < <(__stax_insert_shell_output "$@")
raw=$(__stax_exec "${cmd[@]}") || return $?
while IFS= read -r line; do
case "$line" in
STAX_SHELL_PATH=*) target_path="${line#STAX_SHELL_PATH=}" ;;
STAX_SHELL_LAUNCH=*) launch="${line#STAX_SHELL_LAUNCH=}" ;;
STAX_SHELL_MESSAGE=*) message="${line#STAX_SHELL_MESSAGE=}" ;;
*) passthrough+=("$line") ;;
esac
done <<< "$raw"
if [[ -n "$target_path" ]]; then
builtin cd "$target_path" || return 1
fi
if [[ ${#passthrough[@]} -gt 0 ]]; then
printf '%s\n' "${passthrough[@]}"
fi
if [[ -n "$message" ]]; then
echo "$message"
fi
if [[ -n "$launch" ]]; then
eval "$launch"
elif [[ -n "$target_path" && -z "$message" ]]; then
echo "$(tput bold)$(tput setaf 6)~$(tput sgr0) $(basename "$target_path")"
fi
}
__stax_remove_current_worktree() {
local current
local main
current=$(pwd -P)
main=$(git worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | head -n1)
if [[ -n "$main" && -d "$main" ]]; then
builtin cd "$main" || return 1
fi
__stax_exec "$@" "$current"
}
__stax_dispatch() {
case "$1" in
checkout|co|bco)
__stax_run_worktree_shell "$@" ;;
branch|b)
case "$2" in
checkout|co)
__stax_run_worktree_shell "$@" ;;
*)
__stax_exec "$@" ;;
esac ;;
wtgo)
__stax_run_worktree_shell worktree go "${@:2}" ;;
wtc)
__stax_run_worktree_shell worktree create "${@:2}" ;;
lane)
__stax_run_worktree_shell "$@" ;;
wtrm)
if [[ -z "$2" || "$2" == -* ]]; then
__stax_remove_current_worktree worktree remove "${@:2}"
else
__stax_exec "$@"
fi ;;
worktree|wt)
case "$2" in
go|create|c)
__stax_run_worktree_shell "$@" ;;
remove|rm)
if [[ -z "$3" || "$3" == -* ]]; then
__stax_remove_current_worktree "$@"
else
__stax_exec "$@"
fi ;;
*)
__stax_exec "$@" ;;
esac ;;
*)
__stax_exec "$@" ;;
esac
}
stax() { __stax_dispatch "$@"; }
st() { __stax_dispatch "$@"; }
sw() { st wt go "$@"; }"#
}
fn fish_shell_snippet() -> &'static str {
r#"# Generated by stax shell-setup
set -gx STAX_SHELL_INTEGRATION 1
set -g __STAX_BIN ""
function __stax_resolve_bin
if test -n "$__STAX_BIN"; and test -x "$__STAX_BIN"
printf '%s\n' "$__STAX_BIN"
return 0
end
set -l resolved (type -p stax 2>/dev/null)
if test -n "$resolved"
set -g __STAX_BIN "$resolved"
printf '%s\n' "$resolved"
return 0
end
set resolved (type -p st 2>/dev/null)
if test -n "$resolved"
set -g __STAX_BIN "$resolved"
printf '%s\n' "$resolved"
return 0
end
printf '%s\n' "stax: no 'stax' or 'st' binary found on PATH" >&2
return 127
end
function __stax_exec
set -l bin (__stax_resolve_bin)
or return $status
"$bin" $argv
end
function __stax_insert_shell_output
set -l inserted 0
for arg in $argv
if test $inserted -eq 0; and test "$arg" = "--"
printf '%s\n' --shell-output
set inserted 1
end
printf '%s\n' "$arg"
end
if test $inserted -eq 0
printf '%s\n' --shell-output
end
end
function __stax_run_worktree_shell
set -l path ""
set -l launch ""
set -l message ""
set -l passthrough
set -l cmd (__stax_insert_shell_output $argv)
set -l raw (__stax_exec $cmd)
or return $status
# Split on newlines only: `for line in $raw` would split paths/commands on spaces.
for line in (string split \n -- $raw)
if test -z "$line"
continue
end
switch $line
case 'STAX_SHELL_PATH=*'
set path (string replace -- 'STAX_SHELL_PATH=' '' $line)
case 'STAX_SHELL_LAUNCH=*'
set launch (string replace -- 'STAX_SHELL_LAUNCH=' '' $line)
case 'STAX_SHELL_MESSAGE=*'
set message (string replace -- 'STAX_SHELL_MESSAGE=' '' $line)
case '*'
set -a passthrough $line
end
end
if test -n "$path"
cd "$path"; or return 1
end
if test (count $passthrough) -gt 0
printf '%s\n' $passthrough
end
if test -n "$message"
printf '%s\n' "$message"
end
if test -n "$launch"
eval "$launch"
else if test -n "$path"; and test -z "$message"
printf '~ %s\n' (basename "$path")
end
end
function __stax_remove_current_worktree
set -l current (pwd -P)
set -l main (git worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | head -n1)
if test -n "$main"; and test -d "$main"
cd "$main"; or return 1
end
__stax_exec $argv "$current"
end
function __stax_dispatch
switch "$argv[1]"
case checkout co bco
__stax_run_worktree_shell $argv
case branch b
switch "$argv[2]"
case checkout co
__stax_run_worktree_shell $argv
case '*'
__stax_exec $argv
end
case wtgo
__stax_run_worktree_shell worktree go $argv[2..-1]
case wtc
__stax_run_worktree_shell worktree create $argv[2..-1]
case lane
__stax_run_worktree_shell $argv
case wtrm
if test (count $argv) -lt 2; or string match -qr '^-' -- "$argv[2]"
__stax_remove_current_worktree worktree remove $argv[2..-1]
else
__stax_exec $argv
end
case worktree wt
switch "$argv[2]"
case go create c
__stax_run_worktree_shell $argv
case remove rm
if test (count $argv) -lt 3; or string match -qr '^-' -- "$argv[3]"
__stax_remove_current_worktree $argv
else
__stax_exec $argv
end
case '*'
__stax_exec $argv
end
case '*'
__stax_exec $argv
end
end
functions -e stax 2>/dev/null
functions -e st 2>/dev/null
functions -e sw 2>/dev/null
function stax
__stax_dispatch $argv
end
function st
__stax_dispatch $argv
end
function sw
st wt go $argv
end"#
}
pub fn run(install: bool, refresh: bool) -> Result<()> {
if refresh {
return refresh_installed_snippets();
}
if cfg!(target_os = "windows") {
bail!(
"Shell integration is not yet available on Windows.\n\
Workaround: use WSL or Git Bash, where bash/zsh/fish shells are available.\n\
All stax commands still work on Windows — only the transparent `cd` on \
`stax wt create` / `stax wt go` requires shell integration."
);
}
if install {
install_to_shell_config()
} else {
println!("{}", shell_snippet(detect_shell_kind()));
Ok(())
}
}
pub fn refresh_installed_snippets() -> Result<()> {
if cfg!(target_os = "windows") {
return Ok(());
}
refresh_snippet_if_installed(ShellKind::Posix)?;
refresh_snippet_if_installed(ShellKind::Fish)?;
Ok(())
}
pub fn is_installed() -> bool {
std::env::var("STAX_SHELL_INTEGRATION").is_ok()
}
#[allow(dead_code)]
pub fn prompt_if_missing() -> Result<()> {
if cfg!(target_os = "windows") || is_installed() {
return Ok(());
}
if !std::io::stdin().is_terminal() {
return Ok(());
}
eprintln!("{} Shell integration not detected.", "stax:".cyan().bold());
eprintln!(
" Run {} for transparent worktree navigation (cd).",
"stax shell-setup --install".cyan()
);
eprintln!();
let install = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Install shell integration now?")
.default(true)
.interact()?;
if install {
install_to_shell_config()?;
eprintln!();
eprintln!(
"{} Restart your shell or run: {}",
"Done.".green().bold(),
shell_source_cmd().cyan()
);
eprintln!();
}
Ok(())
}
fn install_to_shell_config() -> Result<()> {
let shell_kind = detect_shell_kind();
let config_path = detect_shell_config()?;
let integration_path = shell_integration_path(shell_kind)?;
let source_line = shell_source_line(&integration_path);
let existing = if config_path.exists() {
fs::read_to_string(&config_path)?
} else {
String::new()
};
let (updated_config, config_changed) = update_shell_config_contents(&existing, &source_line);
if !config_changed {
write_integration_file(&integration_path, shell_snippet(shell_kind))?;
println!(
"{} Shell integration already configured in {}",
"OK".green().bold(),
config_path.display()
);
println!(" Refreshed {}", integration_path.display());
return Ok(());
}
println!(
"Will write shell integration to {}.",
integration_path.display()
);
println!("Will update {}:", config_path.display());
println!();
println!(" {}", source_line.cyan());
println!();
let proceed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Proceed?")
.default(true)
.interact()?;
if !proceed {
println!("{}", "Aborted.".dimmed());
return Ok(());
}
write_integration_file(&integration_path, shell_snippet(shell_kind))?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&config_path, updated_config)?;
println!(
"{} Added to {}",
"Done.".green().bold(),
config_path.display()
);
println!(" Wrote {}", integration_path.display());
println!(" Restart your shell or run: {}", shell_source_cmd().cyan());
Ok(())
}
fn detect_shell_kind() -> ShellKind {
let shell = std::env::var("SHELL").unwrap_or_default();
if shell.ends_with("fish") {
ShellKind::Fish
} else {
ShellKind::Posix
}
}
fn detect_shell_config() -> Result<PathBuf> {
let shell = std::env::var("SHELL").unwrap_or_default();
let home = dirs::home_dir().context("Could not find home directory")?;
let path = if shell.ends_with("zsh") {
home.join(".zshrc")
} else if shell.ends_with("bash") {
let profile = home.join(".bash_profile");
if cfg!(target_os = "macos") && !profile.exists() {
home.join(".bashrc")
} else if cfg!(target_os = "macos") {
profile
} else {
home.join(".bashrc")
}
} else if shell.ends_with("fish") {
home.join(".config").join("fish").join("config.fish")
} else {
home.join(".profile")
};
Ok(path)
}
fn shell_integration_path(shell_kind: ShellKind) -> Result<PathBuf> {
let config_dir = crate::config::Config::dir()?;
let filename = match shell_kind {
ShellKind::Posix => POSIX_INTEGRATION_FILENAME,
ShellKind::Fish => FISH_INTEGRATION_FILENAME,
};
Ok(config_dir.join(filename))
}
fn refresh_snippet_if_installed(shell_kind: ShellKind) -> Result<bool> {
let path = shell_integration_path(shell_kind)?;
refresh_generated_integration_file(&path, shell_snippet(shell_kind))
}
fn shell_source_line(integration_path: &Path) -> String {
format!(
"source {} # {}",
shell_quote_path(integration_path),
INTEGRATION_MARKER
)
}
fn shell_quote_path(path: &Path) -> String {
let path = path.display().to_string();
let escaped = path
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`");
format!("\"{}\"", escaped)
}
fn update_shell_config_contents(existing: &str, source_line: &str) -> (String, bool) {
if let Some(updated) = replace_source_marker_lines(existing, source_line) {
let changed = updated != existing;
return (updated, changed);
}
if let Some(updated) = replace_legacy_eval_line(existing, source_line) {
return (updated, true);
}
if let Some(updated) = replace_inline_shell_block(existing, source_line) {
return (updated, true);
}
let suffix = if existing.is_empty() || existing.ends_with('\n') {
format!("{}\n", source_line)
} else {
format!("\n{}\n", source_line)
};
let updated = format!("{}{}", existing, suffix);
(updated, true)
}
fn refresh_generated_integration_file(path: &Path, desired: &str) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let existing = fs::read_to_string(path)?;
if existing == desired || !is_generated_snippet(&existing) {
return Ok(false);
}
write_integration_file(path, desired)?;
Ok(true)
}
fn is_generated_snippet(contents: &str) -> bool {
contents.contains("# Generated by stax shell-setup")
}
fn replace_source_marker_lines(existing: &str, source_line: &str) -> Option<String> {
let marker_lines = existing
.lines()
.filter(|line| is_source_marker_line(line))
.count();
if marker_lines == 0 {
return None;
}
let mut updated_lines = Vec::new();
let mut inserted = false;
for line in existing.lines() {
if is_source_marker_line(line) {
if !inserted {
updated_lines.push(source_line.to_string());
inserted = true;
}
continue;
}
updated_lines.push(line.to_string());
}
let mut updated = updated_lines.join("\n");
if existing.ends_with('\n') {
updated.push('\n');
}
Some(updated)
}
fn is_source_marker_line(line: &str) -> bool {
line.contains(INTEGRATION_MARKER) && line.contains("source ")
}
fn replace_legacy_eval_line(existing: &str, source_line: &str) -> Option<String> {
let lines: Vec<&str> = existing.lines().collect();
let idx = lines.iter().position(|line| {
line.contains("eval \"$(stax shell-setup)\"")
|| line.contains("eval \"$(st shell-setup)\"")
|| line.contains("source <(stax shell-setup)")
|| line.contains("source <(st shell-setup)")
})?;
Some(replace_line_range(existing, idx, idx + 1, source_line))
}
fn replace_inline_shell_block(existing: &str, source_line: &str) -> Option<String> {
let lines: Vec<&str> = existing.lines().collect();
if let Some((start, end)) = find_inline_posix_shell_block(&lines) {
return Some(replace_line_range(existing, start, end, source_line));
}
if let Some((start, end)) = find_inline_fish_shell_block(&lines) {
return Some(replace_line_range(existing, start, end, source_line));
}
None
}
fn find_inline_posix_shell_block(lines: &[&str]) -> Option<(usize, usize)> {
let end = lines
.iter()
.position(|line| line.contains(r#"sw() { st wt go "$@"; }"#))?;
let start = posix_block_start(lines, end)?;
let mut block_end = end + 1;
if lines.get(block_end).is_some_and(|line| line.trim() == "fi") {
block_end += 1;
}
Some((start, block_end))
}
fn posix_block_start(lines: &[&str], end: usize) -> Option<usize> {
let start = lines
.iter()
.enumerate()
.take(end + 1)
.find(|(_, line)| {
line.contains("__stax_insert_shell_output()")
|| line.contains("__stax_dispatch()")
|| line.contains("# Generated by stax shell-setup")
|| line.contains("export STAX_SHELL_INTEGRATION=1")
})?
.0;
if let Some(if_idx) = (0..=start)
.rev()
.find(|&idx| lines[idx].contains("if (( $+commands[stax] ))"))
{
let mut block_start = if_idx;
if block_start > 0 && lines[block_start - 1].contains("# Static stax shell integration") {
block_start -= 1;
}
return Some(block_start);
}
if start > 0 && lines[start - 1].contains("# Static stax shell integration") {
return Some(start - 1);
}
Some(start)
}
fn find_inline_fish_shell_block(lines: &[&str]) -> Option<(usize, usize)> {
let start = lines.iter().position(|line| {
line.contains("function __stax_insert_shell_output")
|| line.contains("# Generated by stax shell-setup")
|| line.contains("set -gx STAX_SHELL_INTEGRATION 1")
})?;
let sw_idx = lines.iter().position(|line| line.trim() == "function sw")?;
let end = lines
.iter()
.enumerate()
.skip(sw_idx + 1)
.find(|(_, line)| line.trim() == "end")?
.0
+ 1;
Some((start, end))
}
fn replace_line_range(existing: &str, start: usize, end: usize, replacement: &str) -> String {
let lines: Vec<&str> = existing.lines().collect();
let mut updated_lines = Vec::new();
updated_lines.extend(lines[..start].iter().copied().map(str::to_string));
updated_lines.push(replacement.to_string());
updated_lines.extend(lines[end..].iter().copied().map(str::to_string));
let mut updated = updated_lines.join("\n");
if existing.ends_with('\n') {
updated.push('\n');
}
updated
}
fn write_integration_file(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
Ok(())
}
fn shell_source_cmd() -> String {
if let Ok(path) = shell_integration_path(detect_shell_kind()) {
return format!("source {}", shell_quote_path(&path));
}
"source ~/.config/stax/shell-setup.sh".to_string()
}
#[cfg(test)]
mod tests {
use super::{
refresh_generated_integration_file, shell_snippet, shell_source_line,
update_shell_config_contents, ShellKind, INTEGRATION_MARKER,
};
use std::{fs, io::ErrorKind, path::Path, process::Command};
use tempfile::tempdir;
#[test]
fn posix_shell_snippet_clears_aliases_before_function_definitions() {
let snippet = shell_snippet(ShellKind::Posix);
let unalias_stax = snippet.find("unalias stax").expect("missing stax unalias");
let unalias_st = snippet.find("unalias st ").expect("missing st unalias");
let unalias_sw = snippet.find("unalias sw").expect("missing sw unalias");
let stax_fn = snippet.find("stax()").expect("missing stax function");
let st_fn = snippet.find("st()").expect("missing st function");
let sw_fn = snippet.find("sw()").expect("missing sw function");
assert!(unalias_stax < stax_fn);
assert!(unalias_st < st_fn);
assert!(unalias_sw < sw_fn);
}
#[test]
fn posix_shell_snippet_resolves_stax_or_st_binary() {
let snippet = shell_snippet(ShellKind::Posix);
assert!(snippet.contains("__stax_lookup_path()"));
assert!(snippet.contains("whence -p"));
assert!(snippet.contains("type -P"));
assert!(snippet.contains("__stax_exec()"));
}
#[cfg(unix)]
#[test]
fn posix_shell_snippet_resolves_real_binary_in_zsh() {
use std::os::unix::fs::PermissionsExt;
if let Err(err) = Command::new("zsh").arg("-lc").arg("exit 0").output() {
if err.kind() == ErrorKind::NotFound {
return;
}
panic!("failed to probe zsh: {err}");
}
let dir = tempdir().expect("tempdir");
let bin_dir = dir.path().join("bin");
fs::create_dir_all(&bin_dir).expect("create bin dir");
let fake_stax = bin_dir.join("stax");
fs::write(
&fake_stax,
"#!/bin/sh\nprintf 'resolved:%s\\n' \"$0\"\nprintf 'args:%s\\n' \"$*\"\n",
)
.expect("write fake stax");
let mut perms = fs::metadata(&fake_stax)
.expect("fake stax metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_stax, perms).expect("chmod fake stax");
let snippet_path = dir.path().join("shell-setup.sh");
fs::write(&snippet_path, shell_snippet(ShellKind::Posix)).expect("write snippet");
let original_path = std::env::var("PATH").unwrap_or_default();
let path_env = format!("{}:{original_path}", bin_dir.display());
let command = format!("source \"{}\"; st config", snippet_path.display());
let output = Command::new("zsh")
.arg("-lc")
.arg(&command)
.env("PATH", path_env)
.output()
.expect("run zsh shell snippet");
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("resolved:{}", fake_stax.display())),
"expected zsh wrapper to resolve fake stax binary, got:\n{}",
stdout
);
assert!(
stdout.contains("args:config"),
"expected wrapper to forward command args, got:\n{}",
stdout
);
}
#[cfg(unix)]
#[test]
fn posix_shell_snippet_keeps_path_for_worktree_shell_commands_in_zsh() {
use std::os::unix::fs::PermissionsExt;
if let Err(err) = Command::new("zsh").arg("-lc").arg("exit 0").output() {
if err.kind() == ErrorKind::NotFound {
return;
}
panic!("failed to probe zsh: {err}");
}
let dir = tempdir().expect("tempdir");
let bin_dir = dir.path().join("bin");
fs::create_dir_all(&bin_dir).expect("create bin dir");
let fake_stax = bin_dir.join("stax");
fs::write(
&fake_stax,
"#!/bin/sh\nprintf 'resolved:%s\\n' \"$0\"\nprintf 'args:%s\\n' \"$*\"\n",
)
.expect("write fake stax");
let mut perms = fs::metadata(&fake_stax)
.expect("fake stax metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_stax, perms).expect("chmod fake stax");
let snippet_path = dir.path().join("shell-setup.sh");
fs::write(&snippet_path, shell_snippet(ShellKind::Posix)).expect("write snippet");
let original_path = std::env::var("PATH").unwrap_or_default();
let path_env = format!("{}:{original_path}", bin_dir.display());
let command = format!("source \"{}\"; st wtgo demo-lane", snippet_path.display());
let output = Command::new("zsh")
.arg("-lc")
.arg(&command)
.env("PATH", path_env)
.output()
.expect("run zsh shell snippet");
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("resolved:{}", fake_stax.display())),
"expected zsh wrapper to resolve fake stax binary for worktree shell commands, got:\n{}",
stdout
);
assert!(
stdout.contains("args:worktree go demo-lane --shell-output"),
"expected worktree shell command to preserve PATH and inject --shell-output, got:\n{}",
stdout
);
}
#[cfg(unix)]
#[test]
fn posix_shell_snippet_wraps_lane_commands_in_zsh() {
use std::os::unix::fs::PermissionsExt;
if let Err(err) = Command::new("zsh").arg("-lc").arg("exit 0").output() {
if err.kind() == ErrorKind::NotFound {
return;
}
panic!("failed to probe zsh: {err}");
}
let dir = tempdir().expect("tempdir");
let bin_dir = dir.path().join("bin");
fs::create_dir_all(&bin_dir).expect("create bin dir");
let fake_stax = bin_dir.join("stax");
fs::write(
&fake_stax,
"#!/bin/sh\nprintf 'resolved:%s\\n' \"$0\"\nprintf 'args:%s\\n' \"$*\"\n",
)
.expect("write fake stax");
let mut perms = fs::metadata(&fake_stax)
.expect("fake stax metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_stax, perms).expect("chmod fake stax");
let snippet_path = dir.path().join("shell-setup.sh");
fs::write(&snippet_path, shell_snippet(ShellKind::Posix)).expect("write snippet");
let original_path = std::env::var("PATH").unwrap_or_default();
let path_env = format!("{}:{original_path}", bin_dir.display());
let command = format!("source \"{}\"; st lane review-pass", snippet_path.display());
let output = Command::new("zsh")
.arg("-lc")
.arg(&command)
.env("PATH", path_env)
.output()
.expect("run zsh shell snippet");
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("resolved:{}", fake_stax.display())),
"expected zsh wrapper to resolve fake stax binary for lane commands, got:\n{}",
stdout
);
assert!(
stdout.contains("args:lane review-pass --shell-output"),
"expected lane command to preserve PATH and inject --shell-output, got:\n{}",
stdout
);
}
#[cfg(unix)]
#[test]
fn posix_shell_snippet_wraps_checkout_commands_in_zsh() {
use std::os::unix::fs::PermissionsExt;
if let Err(err) = Command::new("zsh").arg("-lc").arg("exit 0").output() {
if err.kind() == ErrorKind::NotFound {
return;
}
panic!("failed to probe zsh: {err}");
}
let dir = tempdir().expect("tempdir");
let bin_dir = dir.path().join("bin");
fs::create_dir_all(&bin_dir).expect("create bin dir");
let fake_stax = bin_dir.join("stax");
fs::write(
&fake_stax,
"#!/bin/sh\nprintf 'resolved:%s\\n' \"$0\"\nprintf 'args:%s\\n' \"$*\"\n",
)
.expect("write fake stax");
let mut perms = fs::metadata(&fake_stax)
.expect("fake stax metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_stax, perms).expect("chmod fake stax");
let snippet_path = dir.path().join("shell-setup.sh");
fs::write(&snippet_path, shell_snippet(ShellKind::Posix)).expect("write snippet");
let original_path = std::env::var("PATH").unwrap_or_default();
let path_env = format!("{}:{original_path}", bin_dir.display());
let command = format!("source \"{}\"; st bco feature", snippet_path.display());
let output = Command::new("zsh")
.arg("-lc")
.arg(&command)
.env("PATH", path_env)
.output()
.expect("run zsh shell snippet");
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("resolved:{}", fake_stax.display())),
"expected zsh wrapper to resolve fake stax binary for checkout commands, got:\n{}",
stdout
);
assert!(
stdout.contains("args:bco feature --shell-output"),
"expected checkout command to be wrapped with --shell-output, got:\n{}",
stdout
);
}
#[test]
fn shell_source_line_uses_static_integration_file() {
let line = shell_source_line(Path::new("/tmp/stax/shell-setup.sh"));
assert_eq!(
line,
format!(
"source \"/tmp/stax/shell-setup.sh\" # {}",
INTEGRATION_MARKER
)
);
}
#[test]
fn update_shell_config_replaces_legacy_eval_line() {
let source_line = shell_source_line(Path::new("/tmp/stax/shell-setup.sh"));
let existing = "export PATH=/usr/local/bin:$PATH\neval \"$(stax shell-setup)\"\n";
let (updated, changed) = update_shell_config_contents(existing, &source_line);
assert!(changed);
assert_eq!(
updated,
format!("export PATH=/usr/local/bin:$PATH\n{}\n", source_line)
);
}
#[test]
fn update_shell_config_replaces_inline_posix_shell_block() {
let source_line = shell_source_line(Path::new("/tmp/stax/shell-setup.sh"));
let existing = r#"export PATH=/usr/local/bin:$PATH
# Static stax shell integration; avoid running `stax shell-setup` on every shell open.
if (( $+commands[stax] )); then
export STAX_SHELL_INTEGRATION=1
__stax_insert_shell_output() {
printf '%s\0' "$@"
}
__stax_run_worktree_shell() {
local raw
raw=$(command stax "$@") || return $?
}
__stax_remove_current_worktree() {
command stax "$@" "$current"
}
__stax_dispatch() {
command stax "$@"
}
unalias stax 2>/dev/null || true
unalias st 2>/dev/null || true
unalias sw 2>/dev/null || true
stax() { __stax_dispatch "$@"; }
st() { __stax_dispatch "$@"; }
sw() { st wt go "$@"; }
fi
"#;
let (updated, changed) = update_shell_config_contents(existing, &source_line);
assert!(changed);
assert_eq!(
updated,
format!("export PATH=/usr/local/bin:$PATH\n{}\n", source_line)
);
}
#[test]
fn update_shell_config_is_idempotent_for_existing_source_line() {
let source_line = shell_source_line(Path::new("/tmp/stax/shell-setup.sh"));
let existing = format!("export PATH=/usr/local/bin:$PATH\n{}\n", source_line);
let (updated, changed) = update_shell_config_contents(&existing, &source_line);
assert!(!changed);
assert_eq!(updated, existing);
}
#[test]
fn refresh_generated_integration_file_rewrites_stale_generated_snippet() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("shell-setup.sh");
fs::write(
&path,
"# Generated by stax shell-setup\ncommand stax old-wrapper\n",
)
.expect("write stale snippet");
let refreshed = refresh_generated_integration_file(&path, shell_snippet(ShellKind::Posix))
.expect("refresh snippet");
assert!(refreshed);
let updated = fs::read_to_string(&path).expect("read refreshed snippet");
assert!(updated.contains("__stax_resolve_bin()"));
assert!(!updated.contains("command stax old-wrapper"));
}
#[test]
fn refresh_generated_integration_file_skips_non_generated_files() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("shell-setup.sh");
fs::write(&path, "echo custom shell setup\n").expect("write custom snippet");
let refreshed = refresh_generated_integration_file(&path, shell_snippet(ShellKind::Posix))
.expect("refresh snippet");
assert!(!refreshed);
let updated = fs::read_to_string(&path).expect("read custom snippet");
assert_eq!(updated, "echo custom shell setup\n");
}
}