diff --git a/README.md b/README.md index aff64c8..d8c99e7 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Git worktrees solve this—but the commands are verbose, cleanup is manual, and ## Installation ```bash -curl -fsSL https://raw.githubusercontent.com/pld/wt/main/install.sh | bash -s -- --from-release && exec $SHELL +curl -fsSL https://raw.githubusercontent.com/pld/wt/main/install.sh | bash -s -- --from-release ``` Or build from source: @@ -36,6 +36,15 @@ Or build from source: git clone https://github.com/pld/wt.git && cd wt && ./install.sh ``` +The binary is installed to `~/.local/bin/wt`, which is on `PATH` by default on modern Linux. On macOS, if `~/.local/bin` is not yet on your `PATH`, the installer will print a one-line instruction. + +For a system-wide install (requires `sudo`): +```bash +./install.sh --system # installs to /usr/local/bin/wt +``` + +**Migrating from an older install**: re-running the installer automatically moves `~/.wt/config.toml` → `~/.config/wt/config.toml`, `~/.wt/sessions.json` → `~/.local/state/wt/sessions.json`, removes the legacy binary and the shell alias that the old installer added. Until you re-run the installer, `wt` will still read from the legacy locations (with a one-line notice on stderr). + ## Usage ### Create a workspace @@ -243,9 +252,9 @@ Session names default to `wt-` and are configurable with `session_prefix`. Discovery in windows mode is state-backed: `wt` records sessions created via -`wt session add` in `~/.wt/sessions.json`, and `wt session`, `wt session ls`, and -`wt session rm` operate from that stored state. Stale entries are pruned when the -corresponding tmux session no longer exists. +`wt session add` in `~/.local/state/wt/sessions.json` (respects `$XDG_STATE_HOME`), +and `wt session`, `wt session ls`, and `wt session rm` operate from that stored +state. Stale entries are pruned when the corresponding tmux session no longer exists. Because discovery is state-backed, `session_prefix = ""` only changes naming. It does not cause `wt` to pick up unrelated tmux sessions. @@ -260,7 +269,7 @@ current command. ### Configuration -Create `~/.wt/config.toml` for global settings or `.wt.toml` in repo root for per-repo settings: +Create `~/.config/wt/config.toml` for global settings or `.wt.toml` in repo root for per-repo settings. The global path respects `$XDG_CONFIG_HOME` (default `~/.config`): ```toml [session] @@ -271,7 +280,9 @@ agent_cmd = "claude" # command for agent pane/window editor_cmd = "nvim" # command for editor pane/window (when panes=3) ``` -Precedence: `--mode` / `--panes` / `--agent-cmd` flags > `.wt.toml` > `~/.wt/config.toml` > defaults +Precedence: `--mode` / `--panes` / `--agent-cmd` flags > `.wt.toml` > `~/.config/wt/config.toml` > defaults + +The legacy `~/.wt/config.toml` is still read as a fallback (with a one-line notice) until you rerun the installer to migrate. ### Navigation diff --git a/install.sh b/install.sh index 8b97403..1e40d4c 100755 --- a/install.sh +++ b/install.sh @@ -1,19 +1,40 @@ #!/bin/bash set -e -INSTALL_DIR="$HOME/.wt" -BIN_PATH="$INSTALL_DIR/wt" +# Determine install directory +SYSTEM_INSTALL=false +FROM_RELEASE=false +for arg in "$@"; do + case "$arg" in + --system) SYSTEM_INSTALL=true ;; + --from-release) FROM_RELEASE=true ;; + esac +done -echo "Installing wt..." +if [ "$SYSTEM_INSTALL" = "true" ]; then + INSTALL_DIR="/usr/local/bin" +else + INSTALL_DIR="$HOME/.local/bin" +fi -mkdir -p "$INSTALL_DIR" +BIN_PATH="$INSTALL_DIR/wt" -if [ -f "$BIN_PATH" ]; then - echo "Removing existing installation..." - rm "$BIN_PATH" -fi +echo "Installing wt to $BIN_PATH..." + +do_install() { + local src="$1" + if [ "$SYSTEM_INSTALL" = "true" ] && [ "$(id -u)" -ne 0 ]; then + sudo mkdir -p "$INSTALL_DIR" + sudo cp "$src" "$BIN_PATH" + sudo chmod +x "$BIN_PATH" + else + mkdir -p "$INSTALL_DIR" + cp "$src" "$BIN_PATH" + chmod +x "$BIN_PATH" + fi +} -if [ "$1" = "--from-release" ]; then +if [ "$FROM_RELEASE" = "true" ]; then echo "Downloading latest release from GitHub..." OS=$(uname -s | tr '[:upper:]' '[:lower:]') @@ -38,41 +59,19 @@ if [ "$1" = "--from-release" ]; then DOWNLOAD_URL="https://github.com/pld/wt/releases/latest/download/${BINARY_NAME}" echo "Downloading from: $DOWNLOAD_URL" - curl -L "$DOWNLOAD_URL" -o "$BIN_PATH" - chmod +x "$BIN_PATH" + TMP_BIN=$(mktemp) + trap 'rm -f "$TMP_BIN"' EXIT + curl -L "$DOWNLOAD_URL" -o "$TMP_BIN" + chmod +x "$TMP_BIN" + do_install "$TMP_BIN" + rm -f "$TMP_BIN" + trap - EXIT else echo "Building from source..." cargo build --release - cp target/release/wt "$BIN_PATH" + do_install "target/release/wt" fi -CURRENT_SHELL=$(basename "$SHELL") - -setup_shell_config() { - case "$CURRENT_SHELL" in - bash) - if [ -f "$HOME/.bash_profile" ] && [ "$(uname)" = "Darwin" ]; then - echo "$HOME/.bash_profile" - else - echo "$HOME/.bashrc" - fi - ;; - zsh) - echo "$HOME/.zshrc" - ;; - fish) - echo "$HOME/.config/fish/config.fish" - ;; - *) - echo "" - ;; - esac -} - -SHELL_CONFIG=$(setup_shell_config) -ALIAS_LINE="alias wt='$BIN_PATH'" -FISH_ALIAS="alias wt '$BIN_PATH'" - # Install CLI agent skills (only if user has the tool configured) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" GITHUB_RAW="https://raw.githubusercontent.com/pld/wt/main/commands" @@ -106,35 +105,94 @@ install_gemini_skill() { install_claude_skill install_gemini_skill -if [ -n "$SHELL_CONFIG" ]; then - mkdir -p "$(dirname "$SHELL_CONFIG")" - if [ "$CURRENT_SHELL" = "fish" ]; then - if ! grep -q "alias wt " "$SHELL_CONFIG" 2>/dev/null; then - echo "" >> "$SHELL_CONFIG" - echo "# wt - Git worktree orchestrator" >> "$SHELL_CONFIG" - echo "$FISH_ALIAS" >> "$SHELL_CONFIG" - echo "Added alias to $SHELL_CONFIG" - else - echo "Alias already exists in $SHELL_CONFIG" +# Migrate legacy ~/.wt/ layout to XDG locations. +# Mirror the Rust helpers: only accept absolute XDG paths; fall back to defaults +# for empty or relative values so migration never writes to cwd-relative locations. +xdg_abs() { + local val="$1" default="$2" + case "$val" in + /*) echo "$val" ;; + *) echo "$default" ;; + esac +} +XDG_CONFIG_HOME=$(xdg_abs "${XDG_CONFIG_HOME:-}" "$HOME/.config") +XDG_STATE_HOME=$(xdg_abs "${XDG_STATE_HOME:-}" "$HOME/.local/state") + +# Remove the exact comment+alias pair the old installer wrote from a shell rc file. +# Only the two-line block is removed: +# # wt - Git worktree orchestrator +# alias wt=... (or: alias wt '...' for fish) +# Standalone alias lines the user may have written themselves are left alone. +# awk is used instead of sed -i because BSD sed (macOS) requires -i '' +# while GNU sed requires -i, with no portable common form. +remove_wt_alias() { + local rc="$1" + local tmp + tmp=$(mktemp) || return 1 + awk ' + /^# wt - Git worktree orchestrator$/ { + if ((getline next_line) > 0 && next_line ~ /^alias wt[= '"'"']/) { + next + } + print + print next_line + next + } + { print } + ' "$rc" > "$tmp" && mv "$tmp" "$rc" || rm -f "$tmp" +} + +migrate_legacy() { + for rc in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do + if [ ! -f "$rc" ]; then + continue fi - else - if ! grep -q "alias wt=" "$SHELL_CONFIG" 2>/dev/null; then - echo "" >> "$SHELL_CONFIG" - echo "# wt - Git worktree orchestrator" >> "$SHELL_CONFIG" - echo "$ALIAS_LINE" >> "$SHELL_CONFIG" - echo "Added alias to $SHELL_CONFIG" - else - echo "Alias already exists in $SHELL_CONFIG" + if grep -q "^# wt - Git worktree orchestrator$" "$rc" 2>/dev/null; then + echo "Removing legacy wt alias from $rc..." + remove_wt_alias "$rc" fi + done + + # Remove the legacy binary now that the new one is in place. + if [ -f "$HOME/.wt/wt" ]; then + echo "Removing legacy binary ~/.wt/wt..." + rm "$HOME/.wt/wt" fi + + # Migrate global config if only the legacy location exists. + LEGACY_CONFIG="$HOME/.wt/config.toml" + NEW_CONFIG="$XDG_CONFIG_HOME/wt/config.toml" + if [ -f "$LEGACY_CONFIG" ] && [ ! -f "$NEW_CONFIG" ]; then + echo "Migrating $LEGACY_CONFIG -> $NEW_CONFIG..." + mkdir -p "$(dirname "$NEW_CONFIG")" + mv "$LEGACY_CONFIG" "$NEW_CONFIG" + fi + + # Migrate session state if only the legacy location exists. + LEGACY_STATE="$HOME/.wt/sessions.json" + NEW_STATE="$XDG_STATE_HOME/wt/sessions.json" + if [ -f "$LEGACY_STATE" ] && [ ! -f "$NEW_STATE" ]; then + echo "Migrating $LEGACY_STATE -> $NEW_STATE..." + mkdir -p "$(dirname "$NEW_STATE")" + mv "$LEGACY_STATE" "$NEW_STATE" + fi + + # Remove ~/.wt/ if it is now empty. + if [ -d "$HOME/.wt" ] && [ -z "$(ls -A "$HOME/.wt")" ]; then + echo "Removing empty ~/.wt/ directory..." + rmdir "$HOME/.wt" + fi +} + +migrate_legacy + +echo "" +echo "Installed wt to $BIN_PATH" + +# Warn if the install directory is not on PATH. +if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then echo "" - echo "Installed to $BIN_PATH" -else - echo "" - echo "Installed to $BIN_PATH" - echo "" - echo "Add this to your shell config:" - echo " $ALIAS_LINE" + echo "Note: $INSTALL_DIR is not on your PATH." + echo "Add this line to your shell config to make 'wt' available:" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" fi - -exec $SHELL diff --git a/src/config.rs b/src/config.rs index 4a35abd..247967b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::ValueEnum; use serde::{Deserialize, Serialize}; -use std::path::Path; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] #[serde(rename_all = "lowercase")] @@ -78,16 +78,53 @@ impl SessionConfig { } } +/// Returns `$XDG_CONFIG_HOME` if set, non-empty, and absolute; otherwise `~/.config`. +/// Relative values are rejected per the XDG Base Directory spec. +pub fn xdg_config_home() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) +} + +/// Returns `$XDG_STATE_HOME` if set, non-empty, and absolute; otherwise `~/.local/state`. +/// Relative values are rejected per the XDG Base Directory spec. +pub fn xdg_state_home() -> Option { + std::env::var_os("XDG_STATE_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| dirs::home_dir().map(|h| h.join(".local/state"))) +} + +/// Resolves the global config path: XDG first, falling back to legacy `~/.wt/config.toml` +/// with a one-time stderr notice if only the legacy location exists. +fn resolve_global_config_path() -> Option { + let xdg = xdg_config_home().map(|d| d.join("wt/config.toml")); + let legacy = dirs::home_dir().map(|h| h.join(".wt/config.toml")); + match (&xdg, &legacy) { + (Some(x), Some(l)) if !x.exists() && l.exists() => { + eprintln!( + "wt: notice: reading config from legacy ~/.wt/config.toml; rerun ./install.sh to migrate" + ); + legacy + } + (Some(_), _) => xdg, + (None, _) => legacy, + } +} + impl Config { - /// Load config with precedence: .wt.toml > ~/.wt/config.toml > defaults + /// Load config with precedence: .wt.toml > $XDG_CONFIG_HOME/wt/config.toml > defaults pub fn load() -> Self { - let global = dirs::home_dir().map(|home| home.join(".wt").join("config.toml")); + let global = resolve_global_config_path(); Self::load_layered(global.as_deref(), Some(Path::new(".wt.toml"))) } /// Load config for a specific repo path pub fn load_for_repo(repo_path: &Path) -> Self { - let global = dirs::home_dir().map(|home| home.join(".wt").join("config.toml")); + let global = resolve_global_config_path(); let local = repo_path.join(".wt.toml"); Self::load_layered(global.as_deref(), Some(&local)) } @@ -118,13 +155,13 @@ impl Config { flag_override.unwrap_or(self.session.panes).clamp(2, 3) } - /// Ensure ~/.wt directory exists - pub fn ensure_wt_dir() -> Result { - let home = - dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; - let wt_dir = home.join(".wt"); - std::fs::create_dir_all(&wt_dir)?; - Ok(wt_dir) + /// Ensure `$XDG_STATE_HOME/wt/` exists and return it. + pub fn ensure_state_dir() -> Result { + let dir = xdg_state_home() + .ok_or_else(|| anyhow::anyhow!("Could not determine XDG_STATE_HOME"))? + .join("wt"); + std::fs::create_dir_all(&dir)?; + Ok(dir) } } @@ -550,6 +587,94 @@ panes = 3 assert_eq!(config.session.mode, SessionMode::Panes); } + #[test] + fn test_xdg_config_home_uses_env_var_when_set() { + let _e = crate::ENV_MUTEX.lock().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", "/tmp/test_xdg_config"); + let result = xdg_config_home(); + std::env::remove_var("XDG_CONFIG_HOME"); + assert_eq!( + result, + Some(std::path::PathBuf::from("/tmp/test_xdg_config")) + ); + } + + #[test] + fn test_xdg_config_home_ignores_empty_env_var() { + let _e = crate::ENV_MUTEX.lock().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", ""); + let result = xdg_config_home(); + std::env::remove_var("XDG_CONFIG_HOME"); + if let Some(home) = dirs::home_dir() { + assert_eq!(result, Some(home.join(".config"))); + } + } + + #[test] + fn test_xdg_state_home_uses_env_var_when_set() { + let _e = crate::ENV_MUTEX.lock().unwrap(); + std::env::set_var("XDG_STATE_HOME", "/tmp/test_xdg_state"); + let result = xdg_state_home(); + std::env::remove_var("XDG_STATE_HOME"); + assert_eq!( + result, + Some(std::path::PathBuf::from("/tmp/test_xdg_state")) + ); + } + + #[test] + fn test_xdg_state_home_ignores_empty_env_var() { + let _e = crate::ENV_MUTEX.lock().unwrap(); + std::env::set_var("XDG_STATE_HOME", ""); + let result = xdg_state_home(); + std::env::remove_var("XDG_STATE_HOME"); + if let Some(home) = dirs::home_dir() { + assert_eq!(result, Some(home.join(".local/state"))); + } + } + + #[test] + fn test_xdg_config_home_falls_back_to_home_config() { + let _e = crate::ENV_MUTEX.lock().unwrap(); + std::env::remove_var("XDG_CONFIG_HOME"); + let result = xdg_config_home(); + if let Some(home) = dirs::home_dir() { + assert_eq!(result, Some(home.join(".config"))); + } + } + + #[test] + fn test_xdg_state_home_falls_back_to_home_local_state() { + let _e = crate::ENV_MUTEX.lock().unwrap(); + std::env::remove_var("XDG_STATE_HOME"); + let result = xdg_state_home(); + if let Some(home) = dirs::home_dir() { + assert_eq!(result, Some(home.join(".local/state"))); + } + } + + #[test] + fn test_xdg_config_home_rejects_relative_path() { + let _e = crate::ENV_MUTEX.lock().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", "relative/path"); + let result = xdg_config_home(); + std::env::remove_var("XDG_CONFIG_HOME"); + if let Some(home) = dirs::home_dir() { + assert_eq!(result, Some(home.join(".config"))); + } + } + + #[test] + fn test_xdg_state_home_rejects_relative_path() { + let _e = crate::ENV_MUTEX.lock().unwrap(); + std::env::set_var("XDG_STATE_HOME", "relative/path"); + let result = xdg_state_home(); + std::env::remove_var("XDG_STATE_HOME"); + if let Some(home) = dirs::home_dir() { + assert_eq!(result, Some(home.join(".local/state"))); + } + } + #[test] fn test_load_layered_both_invalid_returns_defaults() { use std::io::Write; diff --git a/src/lib.rs b/src/lib.rs index 9d1ba91..3b844bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,9 @@ pub mod session; pub mod shell; pub mod tmux_manager; pub mod worktree_manager; + +/// Serialises tests that mutate process-global env vars (XDG_*, HOME). +/// Tests run in parallel by default; grabbing this lock prevents races on +/// std::env between any two tests in the lib crate. +#[cfg(test)] +pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/src/session.rs b/src/session.rs index 60a8200..85c5246 100644 --- a/src/session.rs +++ b/src/session.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use crate::config::Config; +use crate::config::{xdg_state_home, Config}; use crate::tmux_manager::TmuxManager; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,14 +39,33 @@ impl SessionState { } } - fn state_file_path() -> Result { - let wt_dir = Config::ensure_wt_dir()?; - Ok(wt_dir.join("sessions.json")) + /// Path for reading state: new XDG location, falling back to legacy `~/.wt/sessions.json` + /// with a one-time stderr notice if only the legacy path exists. + fn state_read_path() -> Result { + let new_path = xdg_state_home() + .ok_or_else(|| anyhow::anyhow!("Could not determine XDG_STATE_HOME"))? + .join("wt/sessions.json"); + if !new_path.exists() { + if let Some(legacy) = dirs::home_dir().map(|h| h.join(".wt/sessions.json")) { + if legacy.exists() { + eprintln!( + "wt: notice: reading session state from legacy ~/.wt/sessions.json; rerun ./install.sh to migrate" + ); + return Ok(legacy); + } + } + } + Ok(new_path) + } + + /// Path for writing state: always the new XDG location (creates the directory). + fn state_write_path() -> Result { + Ok(Config::ensure_state_dir()?.join("sessions.json")) } - /// Load session state from ~/.wt/sessions.json + /// Load session state from the XDG state location (or legacy fallback). pub fn load() -> Result> { - let path = Self::state_file_path()?; + let path = Self::state_read_path()?; if !path.exists() { return Ok(None); } @@ -58,9 +77,9 @@ impl SessionState { Ok(Some(state)) } - /// Save session state to ~/.wt/sessions.json + /// Save session state to the XDG state location. pub fn save(&self) -> Result<()> { - let path = Self::state_file_path()?; + let path = Self::state_write_path()?; let contents = serde_json::to_string_pretty(self).context("Failed to serialize session state")?; @@ -113,7 +132,7 @@ impl SessionState { /// Clear the session state file. pub fn clear() -> Result<()> { - let path = Self::state_file_path()?; + let path = Self::state_read_path()?; if path.exists() { std::fs::remove_file(&path).context("Failed to remove sessions.json")?; } @@ -308,4 +327,32 @@ mod tests { retain_live_sessions(&mut entries, &HashSet::new()); assert!(entries.is_empty()); } + + #[test] + fn test_legacy_state_fallback_is_read_when_xdg_path_absent() { + use std::io::Write; + + let _e = crate::ENV_MUTEX.lock().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let legacy_dir = dir.path().join(".wt"); + std::fs::create_dir_all(&legacy_dir).unwrap(); + let legacy_path = legacy_dir.join("sessions.json"); + + let state = SessionState::new("wt-legacy"); + let json = serde_json::to_string(&state).unwrap(); + std::fs::File::create(&legacy_path) + .unwrap() + .write_all(json.as_bytes()) + .unwrap(); + + std::env::set_var("HOME", dir.path()); + std::env::remove_var("XDG_STATE_HOME"); + + let loaded = SessionState::load().unwrap(); + std::env::remove_var("HOME"); + + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().session_name, "wt-legacy"); + } }