From 2d2028b62dbf1eeab4d750f5f9a6a0b58ec01c77 Mon Sep 17 00:00:00 2001 From: Emma <817422+Pajn@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:45:26 +0200 Subject: [PATCH 1/2] Allow creating kindra temp worktrees --- Cargo.lock | 10 + Cargo.toml | 2 + README.md | 5 + crates/wisp-bin/Cargo.toml | 1 + crates/wisp-bin/src/git.rs | 56 +++++ crates/wisp-bin/src/main.rs | 262 +++++++++++++++++++ crates/wisp-core/src/view.rs | 11 +- crates/wisp-kindra/Cargo.toml | 16 ++ crates/wisp-kindra/src/lib.rs | 321 ++++++++++++++++++++++++ crates/wisp-kindra/tests/integration.rs | 104 ++++++++ crates/wisp-ui/src/lib.rs | 4 +- scripts/release.sh | 76 +++++- 12 files changed, 849 insertions(+), 19 deletions(-) create mode 100644 crates/wisp-kindra/Cargo.toml create mode 100644 crates/wisp-kindra/src/lib.rs create mode 100644 crates/wisp-kindra/tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index dc9f66f..99d4580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1824,6 +1824,7 @@ dependencies = [ "wisp-core", "wisp-embers", "wisp-fuzzy", + "wisp-kindra", "wisp-preview", "wisp-status", "wisp-tmux", @@ -1874,6 +1875,15 @@ dependencies = [ name = "wisp-fuzzy" version = "0.2.0" +[[package]] +name = "wisp-kindra" +version = "0.2.0" +dependencies = [ + "serde", + "thiserror", + "toml", +] + [[package]] name = "wisp-preview" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 4e8d98a..5a9dc48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/wisp-config", "crates/wisp-core", "crates/wisp-fuzzy", + "crates/wisp-kindra", "crates/wisp-preview", "crates/wisp-status", "crates/wisp-tmux", "crates/wisp-ui", @@ -44,6 +45,7 @@ wisp-config = { version = "0.2.0", path = "crates/wisp-config" } wisp-core = { version = "0.2.0", path = "crates/wisp-core" } wisp-embers = { version = "0.2.0", path = "crates/wisp-embers" } wisp-fuzzy = { version = "0.2.0", path = "crates/wisp-fuzzy" } +wisp-kindra = { version = "0.2.0", path = "crates/wisp-kindra" } wisp-preview = { version = "0.2.0", path = "crates/wisp-preview" } wisp-status = { version = "0.2.0", path = "crates/wisp-status" } wisp-tmux = { version = "0.2.0", path = "crates/wisp-tmux" } diff --git a/README.md b/README.md index 40665f7..eef20de 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Wisp is a native Rust multiplexer navigation tool inspired by `tmux-sessionx`. I - tmux session discovery, switching, and attachment, with optional Embers backend support - sidebar pane and sidebar popup surfaces in addition to the main picker - git worktree-aware picker: see only sessions for the current repo, or browse all worktrees +- [Kindra](https://github.com/Pajn/kindra) integration: spin up a fresh temporary worktree (branched off trunk) straight from the worktree picker - zoxide-backed directory discovery - fuzzy filtering and session previews - configurable behavior through TOML config plus environment overrides @@ -19,6 +20,7 @@ Wisp is a native Rust multiplexer navigation tool inspired by `tmux-sessionx`. I - `wisp-tmux`: tmux snapshot/actions backend plus polling fallback - `wisp-embers`: optional Embers snapshot/actions adapter and subscription bridge - `wisp-zoxide`: zoxide provider and normalization +- `wisp-kindra`: Kindra (`kin`) temp-worktree detection and creation - `wisp-preview`: preview generation and cache - `wisp-fuzzy`: matcher abstraction - `wisp-ui`: shared ratatui renderers and key translation @@ -33,6 +35,7 @@ Requirements: - `tmux` for tmux-backed flows - an Embers checkout at `../embers` only when building with `--features embers` - `zoxide` for directory candidates +- [Kindra](https://github.com/Pajn/kindra) (the `kin` binary) only for the temporary-worktree option in the worktree picker - Rust toolchain new enough for edition 2024 Install the CLI: @@ -84,6 +87,8 @@ Current Embers support covers the main picker, session actions, previews, live r Use `--worktree` (or `-w`) to start the picker in worktree mode, which shows only sessions belonging to worktrees of the current repo alongside worktrees that don't yet have sessions. +When the current repo has [Kindra](https://github.com/Pajn/kindra) temporary worktrees configured (a `[worktrees]` section in `kindra.toml` with the `temp` role enabled), worktree mode appends a `+` row as you type. Your filter text is normalized into a branch slug — whitespace runs become dashes, and text that can't form a valid git branch name hides the row. Selecting it runs `kin wt temp -b ` to create a new temporary worktree branched off the repo's trunk, then creates and switches to a session in it. The trunk is resolved from the remote default branch (`origin/HEAD`), falling back to a local `main`/`master`. + Example tmux binding: Add this to `~/.tmux.conf` to open Wisp with `prefix + o`: diff --git a/crates/wisp-bin/Cargo.toml b/crates/wisp-bin/Cargo.toml index e7fe7ea..778e12b 100644 --- a/crates/wisp-bin/Cargo.toml +++ b/crates/wisp-bin/Cargo.toml @@ -29,6 +29,7 @@ wisp-config.workspace = true wisp-core.workspace = true wisp-embers = { workspace = true, optional = true } wisp-fuzzy.workspace = true +wisp-kindra.workspace = true wisp-preview.workspace = true wisp-status.workspace = true wisp-tmux.workspace = true diff --git a/crates/wisp-bin/src/git.rs b/crates/wisp-bin/src/git.rs index 4811fd4..fa0282a 100644 --- a/crates/wisp-bin/src/git.rs +++ b/crates/wisp-bin/src/git.rs @@ -177,6 +177,62 @@ pub fn branch_status_for_directory(path: &Path) -> Option<(GitBranchSync, bool)> Some((sync, dirty)) } +/// Resolves the trunk branch name (e.g. `main` or `master`) for a repository. +/// +/// Prefers the remote's default branch (`origin/HEAD`), then falls back to a +/// local `main`/`master`, and finally defaults to `main` so callers always have +/// a usable start point. +pub fn trunk_branch(repo_root: &Path) -> String { + if let Some(branch) = remote_default_branch(repo_root) { + return branch; + } + + for candidate in ["main", "master"] { + if local_branch_exists(repo_root, candidate) { + return candidate.to_string(); + } + } + + "main".to_string() +} + +fn remote_default_branch(repo_root: &Path) -> Option { + let output = Command::new("git") + .current_dir(repo_root) + .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let raw = String::from_utf8_lossy(&output.stdout); + let trimmed = raw.trim(); + // `origin/main` -> `main` + let branch = trimmed.strip_prefix("origin/").unwrap_or(trimmed); + if branch.is_empty() { + None + } else { + Some(branch.to_string()) + } +} + +fn local_branch_exists(repo_root: &Path, branch: &str) -> bool { + // Capture output rather than inheriting the terminal: `status()` would let any + // git warning/hint leak onto the raw-mode picker and corrupt the display. + Command::new("git") + .current_dir(repo_root) + .args([ + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + /// Gets the git repository root based on the current tmux state. /// Finds the current session's focused window path and resolves it to a git repo root. pub fn worktree_repo_root(state: &DomainState, client_id: Option<&str>) -> Option { diff --git a/crates/wisp-bin/src/main.rs b/crates/wisp-bin/src/main.rs index 00f987e..2d7fbca 100644 --- a/crates/wisp-bin/src/main.rs +++ b/crates/wisp-bin/src/main.rs @@ -35,6 +35,7 @@ use wisp_core::{ #[cfg(feature = "embers")] use wisp_embers::{EmbersClient, EmbersJoinPlacement}; use wisp_fuzzy::{MatchItem, Matcher, SimpleMatcher}; +use wisp_kindra::{CommandKindraProvider, KindraProvider}; use wisp_preview::{ ActivePanePreviewProvider, FilesystemPreviewProvider, PreviewProvider, SessionDetailsPreviewProvider, @@ -1062,9 +1063,62 @@ fn apply_reloaded_state( deferred_branch_status.clear(); } +/// A worktree-mode repository that has Kindra temporary worktrees configured, +/// together with the trunk branch new temp worktrees should branch off of. +#[derive(Debug, Clone)] +struct KindraTempContext { + repo_root: PathBuf, + trunk: String, +} + +/// Derives a git branch name from the picker query for a new temp worktree. +/// +/// Whitespace runs collapse to single dashes and surrounding dashes are trimmed, +/// keeping slashes so users can still type `feature/foo`. Returns `None` when the +/// query is empty or would produce a name git would reject (e.g. `fix: crash`, +/// `foo..bar`, or `foo.lock`), so the invalid row never reaches `kin wt temp`. +fn kindra_temp_branch_name(query: &str) -> Option { + let collapsed = query.split_whitespace().collect::>().join("-"); + let trimmed = collapsed.trim_matches('-'); + if trimmed.is_empty() || !is_valid_git_branch_name(trimmed) { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Returns whether `name` is a valid git branch name per `git check-ref-format`. +/// +/// Covers the subset of rules a normalized picker slug can still violate: a stray +/// `:`/`~`/`^` from the query, `..`, a leading/trailing dot, or a `.lock` suffix. +fn is_valid_git_branch_name(name: &str) -> bool { + if name.is_empty() || name == "@" { + return false; + } + if name.starts_with('/') || name.ends_with('/') || name.contains("//") { + return false; + } + if name.starts_with('.') || name.ends_with('.') { + return false; + } + if name.contains("..") || name.contains("@{") { + return false; + } + if name.chars().any(|c| { + c.is_ascii_control() || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') + }) { + return false; + } + name.split('/').all(|component| { + !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock") + }) +} + +#[allow(clippy::too_many_arguments)] fn activate_filter_selection( launcher: impl SessionLauncher, zoxide: &impl ZoxideProvider, + kindra: &impl KindraProvider, filtered: &[SessionListItem], selected: usize, query: &str, @@ -1100,6 +1154,27 @@ fn activate_filter_selection( } return Ok(false); } + wisp_core::SessionListItemKind::CreateTempWorktree => { + // `worktree_path` carries the repo root to run `kin` in and + // `worktree_branch` the new branch name derived from the query. + if let (Some(repo_root), Some(branch)) = + (&item.worktree_path, &item.worktree_branch) + { + // Reuse the trunk resolved when the row was built (stashed in + // command_hint); only recompute if it is somehow absent. + let trunk = item + .command_hint + .clone() + .unwrap_or_else(|| git::trunk_branch(repo_root)); + let worktree_path = kindra.create_temp_worktree(repo_root, branch, &trunk)?; + let basename = worktree_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(branch.as_str()); + return create_session_with_basename(launcher, basename, &worktree_path); + } + return Ok(false); + } _ => {} } @@ -1381,6 +1456,7 @@ fn run_surface( let filesystem_preview_provider = FilesystemPreviewProvider::default(); let tmux = CommandTmuxClient::new(); let zoxide = CommandZoxideProvider::new(); + let kindra = CommandKindraProvider::new(); let current_directory = env::current_dir()?; let sidebar_command = sidebar_surface_command(env::current_exe()?); let mut sidebar_runtime = match backend { @@ -1434,6 +1510,11 @@ fn run_surface( let mut deferred_branch_status = BTreeMap::new(); let mut last_zoxide_query: String = String::new(); let mut last_zoxide_match: Option = None; + // Cache of the worktree-mode repo root we last probed for Kindra temp-worktree + // support, plus the resolved trunk. Detection shells out to git/kin, so we only + // recompute it when the repo root under the cursor changes. + let mut last_kindra_repo_root: Option> = None; + let mut kindra_temp_context: Option = None; enable_raw_mode()?; execute!(stdout(), EnterAlternateScreen)?; @@ -1563,6 +1644,47 @@ fn run_surface( last_zoxide_query.clear(); last_zoxide_match = None; } + if matches!(input_mode, InputMode::Filter) && picker_mode == PickerMode::Worktree { + let repo_root = git::worktree_repo_root( + &details_preview_provider.state, + Some(active_client_id.as_str()), + ); + if last_kindra_repo_root.as_ref() != Some(&repo_root) { + last_kindra_repo_root = Some(repo_root.clone()); + kindra_temp_context = repo_root + .filter(|root| kindra.temp_worktrees_configured(root)) + .map(|root| { + let trunk = git::trunk_branch(&root); + KindraTempContext { + repo_root: root, + trunk, + } + }); + } + if let Some(context) = &kindra_temp_context + && let Some(branch) = kindra_temp_branch_name(&query) + { + filtered.push(SessionListItem { + session_id: format!("kindra-temp:{branch}"), + label: branch.clone(), + kind: wisp_core::SessionListItemKind::CreateTempWorktree, + is_current: false, + is_previous: false, + last_activity: None, + attached: false, + attention: wisp_core::AttentionBadge::None, + attention_count: 0, + active_window_label: None, + path_hint: Some(format!("new temp worktree from {}", context.trunk)), + // Carry the already-resolved trunk so activation reuses it + // instead of recomputing (and possibly diverging from) it. + command_hint: Some(context.trunk.clone()), + git_branch: None, + worktree_path: Some(context.repo_root.clone()), + worktree_branch: Some(branch), + }); + } + } if selected >= filtered.len() { selected = filtered.len().saturating_sub(1); } @@ -1810,6 +1932,7 @@ fn run_surface( RuntimeBackend::Tmux => activate_filter_selection( &tmux, &zoxide, + &kindra, &filtered, selected, &query, @@ -1820,6 +1943,7 @@ fn run_surface( RuntimeBackend::Embers(client) => activate_filter_selection( Arc::clone(client), &zoxide, + &kindra, &filtered, selected, &query, @@ -2145,6 +2269,13 @@ fn generate_preview(provider: &dyn PreviewProvider, item: &SessionListItem) -> V .map(|content| content.body) .unwrap_or_else(|error| vec![error.to_string()]) } + wisp_core::SessionListItemKind::CreateTempWorktree => { + let mut lines = vec![format!("Create temporary worktree '{}'", item.label)]; + if let Some(hint) = &item.path_hint { + lines.push(hint.clone()); + } + lines + } wisp_core::SessionListItemKind::Zoxide => { // For zoxide matches, show directory preview using worktree_path if let Some(path) = &item.worktree_path { @@ -2787,6 +2918,7 @@ mod tests { }; use wisp_config::{KeyAction, ResolvedConfig, SessionSortMode}; + use wisp_kindra::{KindraError, KindraProvider}; use wisp_status::StatusRenderMode; use wisp_tmux::{ PopupCommand, PopupOptions, SidebarPaneSpec, TmuxCapabilities, TmuxClient, TmuxContext, @@ -3035,6 +3167,43 @@ mod tests { } } + #[derive(Default)] + struct StubKindraProvider { + configured: bool, + created: RefCell>, + result_path: Option, + } + + impl StubKindraProvider { + fn configured_with(path: &Path) -> Self { + Self { + configured: true, + created: RefCell::new(Vec::new()), + result_path: Some(path.to_path_buf()), + } + } + } + + impl KindraProvider for StubKindraProvider { + fn temp_worktrees_configured(&self, _repo_root: &Path) -> bool { + self.configured + } + + fn create_temp_worktree( + &self, + repo_root: &Path, + new_branch: &str, + start_point: &str, + ) -> Result { + self.created.borrow_mut().push(( + repo_root.to_path_buf(), + new_branch.to_string(), + start_point.to_string(), + )); + self.result_path.clone().ok_or(KindraError::MissingPath) + } + } + #[test] fn reconcile_sidebar_keeps_only_the_active_window_sidebar() { let tmux = StubTmuxClient::default() @@ -3412,12 +3581,14 @@ mod tests { fn activate_filter_selection_switches_or_creates_as_needed() { let tmux = StubTmuxClient::default(); let zoxide = StubZoxideProvider::default().with_match("new session", Path::new("/tmp/new")); + let kindra = StubKindraProvider::default(); let filtered = vec![session_item("alpha")]; assert!( activate_filter_selection( &tmux, &zoxide, + &kindra, &filtered, 0, "ignored", @@ -3432,6 +3603,7 @@ mod tests { activate_filter_selection( &tmux, &zoxide, + &kindra, &filtered, 0, "new session", @@ -3450,6 +3622,7 @@ mod tests { activate_filter_selection( &tmux, &zoxide, + &kindra, &no_matches, 0, "new session", @@ -3465,6 +3638,7 @@ mod tests { fn activate_filter_selection_creates_session_for_worktree_rows() { let tmux = StubTmuxClient::default(); let zoxide = StubZoxideProvider::default(); + let kindra = StubKindraProvider::default(); let filtered = vec![wisp_core::SessionListItem { session_id: "worktree:/tmp/project".to_string(), label: "project".to_string(), @@ -3487,6 +3661,7 @@ mod tests { activate_filter_selection( &tmux, &zoxide, + &kindra, &filtered, 0, "", @@ -3503,10 +3678,92 @@ mod tests { assert_eq!(created[0].1, PathBuf::from("/tmp/project")); } + #[test] + fn activate_filter_selection_creates_kindra_temp_worktree() { + let worktree_path = PathBuf::from("/tmp/repo/.git/kindra-worktrees/temp/my-feature"); + let tmux = StubTmuxClient::default(); + let zoxide = StubZoxideProvider::default(); + let kindra = StubKindraProvider::configured_with(&worktree_path); + let filtered = vec![wisp_core::SessionListItem { + session_id: "kindra-temp:my-feature".to_string(), + label: "my-feature".to_string(), + kind: wisp_core::SessionListItemKind::CreateTempWorktree, + is_current: false, + is_previous: false, + last_activity: None, + attached: false, + attention: wisp_core::AttentionBadge::None, + attention_count: 0, + active_window_label: None, + path_hint: Some("new temp worktree from trunk-branch".to_string()), + // The row carries the pre-resolved trunk; activation must use this + // rather than recomputing it from the (nonexistent) repo path. + command_hint: Some("trunk-branch".to_string()), + git_branch: None, + // repo root to run `kin` in, plus the branch name from the query. + worktree_path: Some(PathBuf::from("/tmp/repo")), + worktree_branch: Some("my-feature".to_string()), + }]; + + assert!( + activate_filter_selection( + &tmux, + &zoxide, + &kindra, + &filtered, + 0, + "my-feature", + Path::new("/fallback"), + false, + ) + .expect("temp worktree selection should create the worktree and session") + ); + + // The worktree is created off the resolved trunk in the repo root. + let created_worktrees = kindra.created.borrow(); + assert_eq!(created_worktrees.len(), 1); + assert_eq!(created_worktrees[0].0, PathBuf::from("/tmp/repo")); + assert_eq!(created_worktrees[0].1, "my-feature"); + // Uses the trunk threaded through the row, not a recomputed fallback. + assert_eq!(created_worktrees[0].2, "trunk-branch"); + + // A session is created in the freshly created worktree path. + assert!(tmux.switched_sessions.borrow().is_empty()); + let created_sessions = tmux.created_sessions.borrow(); + assert_eq!(created_sessions.len(), 1); + assert_eq!(created_sessions[0].0, "my-feature"); + assert_eq!(created_sessions[0].1, worktree_path); + } + + #[test] + fn kindra_temp_branch_name_normalizes_and_rejects_invalid_names() { + use super::kindra_temp_branch_name; + + // Whitespace collapses to dashes; slashes are preserved. + assert_eq!( + kindra_temp_branch_name(" my feature ").as_deref(), + Some("my-feature") + ); + assert_eq!( + kindra_temp_branch_name("feature/foo").as_deref(), + Some("feature/foo") + ); + + // Empty or whitespace-only queries yield no row. + assert_eq!(kindra_temp_branch_name(" "), None); + + // Names git would reject must not reach the temp-worktree flow. + assert_eq!(kindra_temp_branch_name("fix: crash"), None); + assert_eq!(kindra_temp_branch_name("foo..bar"), None); + assert_eq!(kindra_temp_branch_name("foo.lock"), None); + assert_eq!(kindra_temp_branch_name("~weird^"), None); + } + #[test] fn activate_filter_selection_creates_session_for_zoxide_row() { let tmux = StubTmuxClient::default(); let zoxide = StubZoxideProvider::default(); + let kindra = StubKindraProvider::default(); let filtered = vec![wisp_core::SessionListItem { session_id: "zoxide:/path/to/myproject".to_string(), label: "myproject".to_string(), @@ -3530,6 +3787,7 @@ mod tests { activate_filter_selection( &tmux, &zoxide, + &kindra, &filtered, 0, "myproject", @@ -3550,6 +3808,7 @@ mod tests { fn activate_filter_selection_zoxide_row_at_end_of_list() { let tmux = StubTmuxClient::default(); let zoxide = StubZoxideProvider::default(); + let kindra = StubKindraProvider::default(); // Simulate filtered list with existing sessions PLUS zoxide at the end let filtered = vec![ wisp_core::SessionListItem { @@ -3593,6 +3852,7 @@ mod tests { activate_filter_selection( &tmux, &zoxide, + &kindra, &filtered, 1, // selected = 1, pointing to zoxide item "mlm", @@ -3620,6 +3880,7 @@ mod tests { last_activity: None, }]); let zoxide = StubZoxideProvider::default(); + let kindra = StubKindraProvider::default(); let filtered = vec![wisp_core::SessionListItem { session_id: "zoxide:/path/to/myproject".to_string(), label: "myproject".to_string(), @@ -3642,6 +3903,7 @@ mod tests { activate_filter_selection( &tmux, &zoxide, + &kindra, &filtered, 0, "myproject", diff --git a/crates/wisp-core/src/view.rs b/crates/wisp-core/src/view.rs index ab984ab..fed712f 100644 --- a/crates/wisp-core/src/view.rs +++ b/crates/wisp-core/src/view.rs @@ -77,11 +77,12 @@ pub enum GitBranchSync { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionListItemKind { - Info, // informational row, not an actionable session - Session, // regular tmux session (not in a worktree) - WorktreeSession, // session running in a worktree - Worktree, // worktree with no session - Zoxide, // zoxide directory match + Info, // informational row, not an actionable session + Session, // regular tmux session (not in a worktree) + WorktreeSession, // session running in a worktree + Worktree, // worktree with no session + Zoxide, // zoxide directory match + CreateTempWorktree, // action: create a new Kindra temporary worktree } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/wisp-kindra/Cargo.toml b/crates/wisp-kindra/Cargo.toml new file mode 100644 index 0000000..a7ad914 --- /dev/null +++ b/crates/wisp-kindra/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wisp-kindra" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Kindra worktree integration for Wisp" + +[dependencies] +serde.workspace = true +thiserror.workspace = true +toml.workspace = true + +[lints] +workspace = true diff --git a/crates/wisp-kindra/src/lib.rs b/crates/wisp-kindra/src/lib.rs new file mode 100644 index 0000000..cc9dbba --- /dev/null +++ b/crates/wisp-kindra/src/lib.rs @@ -0,0 +1,321 @@ +//! Integration with [Kindra](https://github.com/Pajn/kindra), a CLI for managing +//! stacked branches and managed git worktrees (the `kin` binary). +//! +//! Wisp uses this crate to detect whether the repository under the cursor has +//! Kindra *temporary* worktrees configured and, when it does, to create a fresh +//! temporary worktree on demand and report back the path it landed on. + +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +use serde::Deserialize; +use thiserror::Error; + +/// Reads Kindra state and drives the `kin` CLI for a repository. +pub trait KindraProvider { + /// Returns `true` when the repository rooted at `repo_root` declares Kindra + /// temporary worktrees (a `[worktrees]` section with the `temp` role enabled). + fn temp_worktrees_configured(&self, repo_root: &Path) -> bool; + + /// Creates a new temporary worktree for a brand new branch `new_branch` + /// based on `start_point`, returning the path of the created worktree. + fn create_temp_worktree( + &self, + repo_root: &Path, + new_branch: &str, + start_point: &str, + ) -> Result; +} + +/// Drives Kindra through the `kin` command-line binary. +#[derive(Debug, Clone)] +pub struct CommandKindraProvider { + binary: PathBuf, + git_binary: PathBuf, +} + +impl Default for CommandKindraProvider { + fn default() -> Self { + Self::new() + } +} + +impl CommandKindraProvider { + #[must_use] + pub fn new() -> Self { + Self { + binary: PathBuf::from("kin"), + git_binary: PathBuf::from("git"), + } + } + + #[must_use] + pub fn with_binary(mut self, binary: impl Into) -> Self { + self.binary = binary.into(); + self + } + + #[must_use] + pub fn with_git_binary(mut self, git_binary: impl Into) -> Self { + self.git_binary = git_binary.into(); + self + } + + /// Resolves the absolute git common directory for `repo_root`, where shared + /// repository state (including `kindra.toml`) lives for linked worktrees. + fn git_common_dir(&self, repo_root: &Path) -> Option { + let output = Command::new(&self.git_binary) + .current_dir(repo_root) + .args(["rev-parse", "--git-common-dir"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let raw = String::from_utf8_lossy(&output.stdout); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let common_dir = PathBuf::from(trimmed); + Some(if common_dir.is_absolute() { + common_dir + } else { + repo_root.join(common_dir) + }) + } +} + +impl KindraProvider for CommandKindraProvider { + fn temp_worktrees_configured(&self, repo_root: &Path) -> bool { + let Some(common_dir) = self.git_common_dir(repo_root) else { + return false; + }; + temp_worktrees_configured_in(&common_dir.join("kindra.toml")) + } + + fn create_temp_worktree( + &self, + repo_root: &Path, + new_branch: &str, + start_point: &str, + ) -> Result { + let new_branch = new_branch.trim(); + // Reject empty and dash-prefixed names: a leading `-` would be parsed as + // a flag by the `kin`/clap argument path rather than as the branch value. + if new_branch.is_empty() || new_branch.starts_with('-') { + return Err(KindraError::InvalidBranch { + branch: new_branch.to_string(), + }); + } + + let args = vec![ + "wt".to_string(), + "temp".to_string(), + "-b".to_string(), + new_branch.to_string(), + start_point.to_string(), + ]; + + let output = Command::new(&self.binary) + .current_dir(repo_root) + .args(&args) + .output() + .map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + KindraError::Unavailable { + message: source.to_string(), + } + } else { + // The process never ran, so there is no command stderr here; + // CommandFailed is reserved for real execution failures below. + KindraError::SpawnFailed { + command: self.command_for_args(&args), + message: source.to_string(), + } + } + })?; + + if !output.status.success() { + return Err(KindraError::CommandFailed { + command: self.command_for_args(&args), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + status: output.status.code(), + }); + } + + parse_worktree_path(&String::from_utf8_lossy(&output.stdout)) + } +} + +impl CommandKindraProvider { + fn command_for_args(&self, args: &[String]) -> Vec { + std::iter::once(self.binary.display().to_string()) + .chain(args.iter().cloned()) + .collect() + } +} + +#[derive(Debug, Error)] +pub enum KindraError { + #[error("kin is unavailable: {message}")] + Unavailable { message: String }, + #[error("failed to spawn kin: {command:?}: {message}")] + SpawnFailed { + command: Vec, + message: String, + }, + #[error("kin command failed: {command:?} (status {status:?}): {stderr}")] + CommandFailed { + command: Vec, + status: Option, + stderr: String, + }, + #[error("invalid branch name: {branch:?}")] + InvalidBranch { branch: String }, + #[error("kin did not report a worktree path")] + MissingPath, +} + +/// Subset of `kindra.toml` Wisp needs to decide whether temp worktrees are on. +/// +/// Unknown keys are ignored, so the rest of Kindra's schema can evolve freely. +#[derive(Debug, Default, Deserialize)] +struct KindraConfigFile { + worktrees: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct WorktreesConfig { + temp: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct TempConfig { + enabled: Option, +} + +/// Returns whether the `kindra.toml` at `config_path` enables temporary worktrees. +/// +/// Temp worktrees require a `[worktrees]` section to exist; within it the `temp` +/// role defaults to enabled, so it counts as configured unless explicitly +/// disabled with `temp.enabled = false`. +#[must_use] +pub fn temp_worktrees_configured_in(config_path: &Path) -> bool { + let Ok(raw) = std::fs::read_to_string(config_path) else { + return false; + }; + let Ok(config) = toml::from_str::(&raw) else { + return false; + }; + match config.worktrees { + Some(worktrees) => worktrees.temp.and_then(|temp| temp.enabled).unwrap_or(true), + None => false, + } +} + +/// Extracts the worktree path Kindra prints on stdout after creating a worktree. +/// +/// `kin wt temp` prints the resulting worktree path on its own line; we use the +/// last non-empty line so any leading diagnostics are ignored. +fn parse_worktree_path(stdout: &str) -> Result { + stdout + .lines() + .map(str::trim) + .rfind(|line| !line.is_empty()) + .map(PathBuf::from) + .ok_or(KindraError::MissingPath) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::{parse_worktree_path, temp_worktrees_configured_in}; + + fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wisp-kindra-{}-{name}", std::process::id())); + fs::create_dir_all(&dir).expect("temp dir"); + dir + } + + #[test] + fn create_temp_worktree_rejects_empty_and_dash_prefixed_branches() { + use super::{CommandKindraProvider, KindraError, KindraProvider}; + + let provider = CommandKindraProvider::new(); + // Validation happens before any `kin` spawn, so these need no binary. + for name in ["", " ", "-foo", "--force"] { + let result = provider.create_temp_worktree(std::path::Path::new("/tmp"), name, "main"); + assert!( + matches!(result, Err(KindraError::InvalidBranch { .. })), + "expected InvalidBranch for {name:?}, got {result:?}" + ); + } + } + + #[test] + fn detects_temp_worktrees_when_section_present() { + let dir = temp_dir("enabled"); + let path = dir.join("kindra.toml"); + fs::write( + &path, + "[worktrees]\nroot = \".git/kindra-worktrees\"\ntrunk = \"main\"\n", + ) + .expect("write config"); + + assert!(temp_worktrees_configured_in(&path)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn honors_explicit_temp_disable() { + let dir = temp_dir("disabled"); + let path = dir.join("kindra.toml"); + fs::write(&path, "[worktrees]\n\n[worktrees.temp]\nenabled = false\n") + .expect("write config"); + + assert!(!temp_worktrees_configured_in(&path)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn ignores_repos_without_a_worktrees_section() { + let dir = temp_dir("no-section"); + let path = dir.join("kindra.toml"); + fs::write(&path, "upstream_branch = \"main\"\n").expect("write config"); + + assert!(!temp_worktrees_configured_in(&path)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn missing_config_is_not_configured() { + let dir = temp_dir("missing"); + assert!(!temp_worktrees_configured_in(&dir.join("kindra.toml"))); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn parses_trailing_worktree_path_from_output() { + let path = + parse_worktree_path("Creating worktree...\n/repo/.git/kindra-worktrees/temp/feature\n") + .expect("path"); + assert_eq!( + path, + std::path::PathBuf::from("/repo/.git/kindra-worktrees/temp/feature") + ); + } + + #[test] + fn empty_output_has_no_path() { + assert!(parse_worktree_path("\n \n").is_err()); + } +} diff --git a/crates/wisp-kindra/tests/integration.rs b/crates/wisp-kindra/tests/integration.rs new file mode 100644 index 0000000..bcd1444 --- /dev/null +++ b/crates/wisp-kindra/tests/integration.rs @@ -0,0 +1,104 @@ +use std::{ + path::{Path, PathBuf}, + process::Command, + sync::atomic::{AtomicU64, Ordering}, +}; + +use wisp_kindra::{CommandKindraProvider, KindraProvider}; + +static UNIQUE_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn unique_root() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "wisp-kindra-it-{}-{}", + std::process::id(), + UNIQUE_ROOT_COUNTER.fetch_add(1, Ordering::SeqCst) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp root"); + dir +} + +fn run_git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .expect("git command"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn init_repo(root: &Path) { + run_git(root, &["init", "-q"]); + run_git(root, &["config", "user.name", "Wisp Tests"]); + run_git(root, &["config", "user.email", "wisp-tests@example.com"]); + std::fs::write(root.join("README.md"), "seed\n").expect("seed file"); + run_git(root, &["add", "README.md"]); + run_git(root, &["commit", "-q", "-m", "seed"]); +} + +#[test] +fn detects_temp_worktrees_via_git_common_dir() { + let root = unique_root(); + let repo = root.join("repo"); + std::fs::create_dir_all(&repo).expect("repo dir"); + init_repo(&repo); + std::fs::write( + repo.join(".git/kindra.toml"), + "[worktrees]\ntrunk = \"main\"\n", + ) + .expect("write kindra.toml"); + + let provider = CommandKindraProvider::new(); + assert!(provider.temp_worktrees_configured(&repo)); + + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn linked_worktree_resolves_config_from_common_dir() { + let root = unique_root(); + let repo = root.join("repo"); + std::fs::create_dir_all(&repo).expect("repo dir"); + init_repo(&repo); + std::fs::write( + repo.join(".git/kindra.toml"), + "[worktrees]\ntrunk = \"main\"\n", + ) + .expect("write kindra.toml"); + + // A linked worktree only has a `.git` *file* pointing at the common dir, so + // detection must follow `--git-common-dir` to find the shared kindra.toml. + let linked = root.join("linked"); + run_git( + &repo, + &[ + "worktree", + "add", + "-q", + linked.to_str().expect("linked path"), + ], + ); + + let provider = CommandKindraProvider::new(); + assert!(provider.temp_worktrees_configured(&linked)); + + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn repo_without_config_is_not_configured() { + let root = unique_root(); + let repo = root.join("repo"); + std::fs::create_dir_all(&repo).expect("repo dir"); + init_repo(&repo); + + let provider = CommandKindraProvider::new(); + assert!(!provider.temp_worktrees_configured(&repo)); + + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/wisp-ui/src/lib.rs b/crates/wisp-ui/src/lib.rs index a37b1c8..c053ad1 100644 --- a/crates/wisp-ui/src/lib.rs +++ b/crates/wisp-ui/src/lib.rs @@ -266,7 +266,9 @@ fn render_list(area: Rect, buffer: &mut Buffer, model: &SurfaceModel, compact: b .iter() .enumerate() .map(|(index, item)| { - let marker = if matches!(item.kind, SessionListItemKind::Zoxide) { + let marker = if matches!(item.kind, SessionListItemKind::CreateTempWorktree) { + "+" + } else if matches!(item.kind, SessionListItemKind::Zoxide) { "Z" } else if matches!(item.kind, SessionListItemKind::Worktree) { "W" diff --git a/scripts/release.sh b/scripts/release.sh index 662ee6f..313d765 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -4,6 +4,9 @@ set -euo pipefail readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" readonly CARGO_TOML="${ROOT_DIR}/Cargo.toml" +# wisp-embers is excluded from the workspace, so it can't inherit +# version.workspace and must be bumped explicitly during a release. +readonly EMBERS_CARGO_TOML="${ROOT_DIR}/crates/wisp-embers/Cargo.toml" readonly PUBLISH_PACKAGES=( wisp-core wisp-config @@ -73,9 +76,50 @@ require_clean_tree() { [[ -z "${status}" ]] || die "git tree must be clean before preparing a release" } +# Bump the `version = "..."` field inside a TOML section header (e.g. +# `workspace.package` or `package`), asserting the expected number of matches so +# a manifest layout change fails the release instead of silently no-op'ing. +bump_section_version() { + local file="$1" + local section="$2" + local version="$3" + local expected_matches="$4" + + python3 - "${file}" "${section}" "${version}" "${expected_matches}" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +section = sys.argv[2] +version = sys.argv[3] +expected = int(sys.argv[4]) +text = path.read_text() +# Match the version field only within the target section: after the header, +# consume lines lazily but never cross into the next `[section]` header, so a +# section without a version fails here instead of bumping a later section's. +pattern = ( + r'(?m)^(\[' + re.escape(section) + r'\]\n' + r'(?:(?!\[)[^\n]*\n)*?' + r'version = ")([^"]+)(")$' +) +updated, count = re.subn(pattern, rf'\g<1>{version}\3', text, count=1) +if count != expected: + raise SystemExit( + f"failed to update [{section}].version in {path} " + f"(matched {count}, expected {expected})" + ) +path.write_text(updated) +PY +} + bump_workspace_versions() { local version="$1" + bump_section_version "${CARGO_TOML}" "workspace.package" "${version}" 1 + + # Bump the internal path-dependency requirements (unique to the workspace + # manifest, so this stays outside the shared section-version helper). python3 - "${CARGO_TOML}" "${version}" <<'PY' import pathlib import re @@ -84,25 +128,27 @@ import sys path = pathlib.Path(sys.argv[1]) version = sys.argv[2] text = path.read_text() -updated, count = re.subn( - r'(?ms)^(\[workspace\.package\]\n.*?^version = ")([^"]+)(")$', - rf'\g<1>{version}\3', - text, - count=1, -) -if count != 1: - raise SystemExit("failed to update workspace.package.version") - +# Independently count every internal wisp path dependency, then require the +# strict version-bump regex to match all of them. This fails the release on a +# reformatted or missed dependency instead of silently leaving an old version. +expected = len(re.findall(r'(?m)^wisp(?:-[a-z]+)? = \{.*path = "crates/', text)) updated, count = re.subn( r'(?m)^((?:wisp(?:-[a-z]+)?) = \{ version = ")([^"]+)(", path = "crates/[^"]+" \})$', rf'\g<1>{version}\3', - updated, + text, ) -if count == 0: - raise SystemExit("failed to update internal workspace dependency versions") - +if count != expected: + raise SystemExit( + f"failed to update internal workspace dependency versions " + f"(matched {count}, expected {expected})" + ) path.write_text(updated) PY + + # The wisp-embers requirement above is bumped to the new version, but the + # excluded crate's own [package] version is not part of the workspace, so bump + # it here in lockstep or `cargo metadata` fails to resolve the path dependency. + bump_section_version "${EMBERS_CARGO_TOML}" "package" "${version}" 1 } run_validation() { @@ -116,6 +162,10 @@ run_validation() { refresh_lockfile() { echo "+ cargo metadata --format-version 1 >/dev/null" (cd "${ROOT_DIR}" && cargo metadata --format-version 1 >/dev/null) + # Refresh the excluded crate's own lockfile too so its committed Cargo.lock + # reflects the bumped wisp-embers version. + echo "+ cargo metadata --manifest-path ${EMBERS_CARGO_TOML} --format-version 1 >/dev/null" + (cd "${ROOT_DIR}" && cargo metadata --manifest-path "${EMBERS_CARGO_TOML}" --format-version 1 >/dev/null) } tag_to_version() { From e300261f47e93144a9755c429ccd37ceab4c66d4 Mon Sep 17 00:00:00 2001 From: Emma <817422+Pajn@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:36:53 +0200 Subject: [PATCH 2/2] Allow deleting kindra temp worktrees --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 2 + crates/wisp-bin/src/main.rs | 239 +++++++++++++++++++++++++++++----- crates/wisp-kindra/Cargo.toml | 1 + crates/wisp-kindra/src/lib.rs | 142 ++++++++++++++++++-- 6 files changed, 338 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99d4580..005afd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1880,6 +1880,7 @@ name = "wisp-kindra" version = "0.2.0" dependencies = [ "serde", + "serde_json", "thiserror", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index 5a9dc48..a33f390 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ crossterm = "0.28" ratatui = "0.29" serde = { version = "1", features = ["derive"] } serde_ignored = "0.1" +serde_json = "1" thiserror = "2" toml = "0.8" wisp-app = { version = "0.2.0", path = "crates/wisp-app" } diff --git a/README.md b/README.md index eef20de..e9d1909 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ Use `--worktree` (or `-w`) to start the picker in worktree mode, which shows onl When the current repo has [Kindra](https://github.com/Pajn/kindra) temporary worktrees configured (a `[worktrees]` section in `kindra.toml` with the `temp` role enabled), worktree mode appends a `+` row as you type. Your filter text is normalized into a branch slug — whitespace runs become dashes, and text that can't form a valid git branch name hides the row. Selecting it runs `kin wt temp -b ` to create a new temporary worktree branched off the repo's trunk, then creates and switches to a session in it. The trunk is resolved from the remote default branch (`origin/HEAD`), falling back to a local `main`/`master`. +To remove a temporary worktree, first close its session (the close-session key), which leaves a session-less worktree row. Pressing close again on that row — when it is a Kindra temp worktree — prompts for confirmation and then runs `kin wt remove` to delete the worktree. Deletion never forces, so a worktree with uncommitted changes is left intact and Kindra reports the error. + Example tmux binding: Add this to `~/.tmux.conf` to open Wisp with `prefix + o`: diff --git a/crates/wisp-bin/src/main.rs b/crates/wisp-bin/src/main.rs index 2d7fbca..2ef0e69 100644 --- a/crates/wisp-bin/src/main.rs +++ b/crates/wisp-bin/src/main.rs @@ -346,6 +346,13 @@ enum InputMode { session_id: String, filter_query: String, }, + /// Confirming deletion of a Kindra temporary worktree that no longer has a + /// session. `filter_query` restores the prior filter when the prompt closes. + ConfirmDeleteWorktree { + branch: String, + repo_root: PathBuf, + filter_query: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1114,6 +1121,29 @@ fn is_valid_git_branch_name(name: &str) -> bool { }) } +/// Resolves the delete target when the close action lands on a worktree row. +/// +/// Returns the branch and the repo root to run `kin` in only when the row is a +/// session-less worktree whose branch is a Kindra-managed temp worktree; any +/// other row (regular git worktree, main/review, no configured temp support) +/// yields `None` so the close action stays a no-op there. +fn kindra_delete_target( + kindra: &impl KindraProvider, + context: Option<&KindraTempContext>, + item: &SessionListItem, +) -> Option<(String, PathBuf)> { + let context = context?; + if item.kind != wisp_core::SessionListItemKind::Worktree { + return None; + } + let branch = item.worktree_branch.as_ref()?; + kindra + .temp_worktree_branches(&context.repo_root) + .iter() + .any(|candidate| candidate == branch) + .then(|| (branch.clone(), context.repo_root.clone())) +} + #[allow(clippy::too_many_arguments)] fn activate_filter_selection( launcher: impl SessionLauncher, @@ -1603,7 +1633,9 @@ fn run_surface( let mut filtered = match input_mode { InputMode::Filter => filter_items(&session_items, &query), - InputMode::Rename { .. } => session_items.clone(), + InputMode::Rename { .. } | InputMode::ConfirmDeleteWorktree { .. } => { + session_items.clone() + } }; if matches!(input_mode, InputMode::Filter) && !query.trim().is_empty() @@ -1747,6 +1779,9 @@ fn run_surface( let model = SurfaceModel { title: match (&surface_kind, &input_mode) { (SurfaceKind::Picker, InputMode::Rename { .. }) => "Rename Session".to_string(), + (SurfaceKind::Picker, InputMode::ConfirmDeleteWorktree { branch, .. }) => { + format!("Delete temp worktree '{branch}'? [Enter] confirm [Esc] cancel") + } (SurfaceKind::Picker, InputMode::Filter) => "Wisp Picker".to_string(), (SurfaceKind::SidebarCompact, _) => "Wisp Sidebar".to_string(), (SurfaceKind::SidebarExpanded, _) => "Wisp Sidebar+".to_string(), @@ -1851,15 +1886,21 @@ fn run_surface( } } UiIntent::FilterChanged(fragment) => { - query.push_str(&fragment); - if matches!(input_mode, InputMode::Filter) { - selected = 0; + // The confirm prompt takes no text input; leave the stored + // filter untouched so it can be restored on cancel. + if !matches!(input_mode, InputMode::ConfirmDeleteWorktree { .. }) { + query.push_str(&fragment); + if matches!(input_mode, InputMode::Filter) { + selected = 0; + } } } UiIntent::Backspace => { - query.pop(); - if matches!(input_mode, InputMode::Filter) { - selected = 0; + if !matches!(input_mode, InputMode::ConfirmDeleteWorktree { .. }) { + query.pop(); + if matches!(input_mode, InputMode::Filter) { + selected = 0; + } } } UiIntent::ToggleCompactSidebar => { @@ -2058,6 +2099,39 @@ fn run_surface( runtime.rename_session(new_name); } } + InputMode::ConfirmDeleteWorktree { + branch, + repo_root, + filter_query, + } => { + let branch = branch.clone(); + let repo_root = repo_root.clone(); + let filter_query = filter_query.clone(); + + // Never forces: a dirty worktree makes kin fail and the + // error propagates rather than discarding uncommitted work. + kindra.remove_temp_worktree(&repo_root, &branch)?; + + let reloaded_state = load_domain_state(backend)?; + apply_reloaded_state( + reloaded_state, + picker_mode, + session_sort, + &mut active_client_id, + &mut session_items, + &mut pending_branch_names, + &mut branch_status_updates, + &mut details_preview_provider, + &mut deferred_branch_status, + ); + query = filter_query; + input_mode = InputMode::Filter; + preview_session_id = None; + preview_refreshed_at = None; + if preview_enabled { + preview = Some(Vec::new()); + } + } }, UiIntent::RenameSession => { if matches!(input_mode, InputMode::Filter) @@ -2080,34 +2154,54 @@ fn run_surface( UiIntent::CloseSession => { if matches!(input_mode, InputMode::Filter) && let Some(item) = filtered.get(selected) - && matches!( - item.kind, - wisp_core::SessionListItemKind::Session - | wisp_core::SessionListItemKind::WorktreeSession - ) { - let session_id = item.session_id.clone(); - match backend { - RuntimeBackend::Tmux => tmux.kill_session(&session_id)?, - #[cfg(feature = "embers")] - RuntimeBackend::Embers(client) => client.kill_session(&session_id)?, - } - let reloaded_state = load_domain_state(backend)?; - apply_reloaded_state( - reloaded_state, - picker_mode, - session_sort, - &mut active_client_id, - &mut session_items, - &mut pending_branch_names, - &mut branch_status_updates, - &mut details_preview_provider, - &mut deferred_branch_status, - ); - preview_session_id = None; - preview_refreshed_at = None; - if preview_enabled { - preview = Some(Vec::new()); + match item.kind { + wisp_core::SessionListItemKind::Session + | wisp_core::SessionListItemKind::WorktreeSession => { + let session_id = item.session_id.clone(); + match backend { + RuntimeBackend::Tmux => tmux.kill_session(&session_id)?, + #[cfg(feature = "embers")] + RuntimeBackend::Embers(client) => { + client.kill_session(&session_id)? + } + } + let reloaded_state = load_domain_state(backend)?; + apply_reloaded_state( + reloaded_state, + picker_mode, + session_sort, + &mut active_client_id, + &mut session_items, + &mut pending_branch_names, + &mut branch_status_updates, + &mut details_preview_provider, + &mut deferred_branch_status, + ); + preview_session_id = None; + preview_refreshed_at = None; + if preview_enabled { + preview = Some(Vec::new()); + } + } + // A worktree row with no session: if it's a Kindra temp + // worktree, closing it offers to delete the worktree. + wisp_core::SessionListItemKind::Worktree => { + if let Some((branch, repo_root)) = kindra_delete_target( + &kindra, + kindra_temp_context.as_ref(), + item, + ) { + input_mode = InputMode::ConfirmDeleteWorktree { + branch, + repo_root, + filter_query: query.clone(), + }; + preview_session_id = None; + preview_refreshed_at = None; + } + } + _ => {} } } } @@ -2136,7 +2230,8 @@ fn run_surface( } break Ok(()); } - InputMode::Rename { filter_query, .. } => { + InputMode::Rename { filter_query, .. } + | InputMode::ConfirmDeleteWorktree { filter_query, .. } => { query = filter_query.clone(); input_mode = InputMode::Filter; preview_session_id = None; @@ -3172,6 +3267,8 @@ mod tests { configured: bool, created: RefCell>, result_path: Option, + temp_branches: Vec, + removed: RefCell>, } impl StubKindraProvider { @@ -3180,8 +3277,16 @@ mod tests { configured: true, created: RefCell::new(Vec::new()), result_path: Some(path.to_path_buf()), + temp_branches: Vec::new(), + removed: RefCell::new(Vec::new()), } } + + fn with_temp_branches(mut self, branches: &[&str]) -> Self { + self.configured = true; + self.temp_branches = branches.iter().map(|b| (*b).to_string()).collect(); + self + } } impl KindraProvider for StubKindraProvider { @@ -3202,6 +3307,17 @@ mod tests { )); self.result_path.clone().ok_or(KindraError::MissingPath) } + + fn temp_worktree_branches(&self, _repo_root: &Path) -> Vec { + self.temp_branches.clone() + } + + fn remove_temp_worktree(&self, repo_root: &Path, branch: &str) -> Result<(), KindraError> { + self.removed + .borrow_mut() + .push((repo_root.to_path_buf(), branch.to_string())); + Ok(()) + } } #[test] @@ -3759,6 +3875,59 @@ mod tests { assert_eq!(kindra_temp_branch_name("~weird^"), None); } + #[test] + fn kindra_delete_target_matches_only_temp_worktree_rows() { + use super::{KindraTempContext, kindra_delete_target}; + + let worktree_row = |branch: &str, kind| wisp_core::SessionListItem { + session_id: format!("worktree:/repo/{branch}"), + label: branch.to_string(), + kind, + is_current: false, + is_previous: false, + last_activity: None, + attached: false, + attention: wisp_core::AttentionBadge::None, + attention_count: 0, + active_window_label: None, + path_hint: None, + command_hint: None, + git_branch: None, + worktree_path: Some(PathBuf::from(format!("/repo/{branch}"))), + worktree_branch: Some(branch.to_string()), + }; + + let context = KindraTempContext { + repo_root: PathBuf::from("/repo"), + trunk: "main".to_string(), + }; + let kindra = StubKindraProvider::default().with_temp_branches(&["feature/spike"]); + + // A session-less worktree row whose branch is a temp worktree is deletable. + let temp_row = worktree_row("feature/spike", wisp_core::SessionListItemKind::Worktree); + assert_eq!( + kindra_delete_target(&kindra, Some(&context), &temp_row), + Some(("feature/spike".to_string(), PathBuf::from("/repo"))) + ); + + // A worktree that Kindra does not track as temp is not deletable. + let other = worktree_row("release/1.0", wisp_core::SessionListItemKind::Worktree); + assert_eq!(kindra_delete_target(&kindra, Some(&context), &other), None); + + // Without configured temp support (no context), nothing is deletable. + assert_eq!(kindra_delete_target(&kindra, None, &temp_row), None); + + // A row that still has a session is closed as a session, not deleted. + let session_row = worktree_row( + "feature/spike", + wisp_core::SessionListItemKind::WorktreeSession, + ); + assert_eq!( + kindra_delete_target(&kindra, Some(&context), &session_row), + None + ); + } + #[test] fn activate_filter_selection_creates_session_for_zoxide_row() { let tmux = StubTmuxClient::default(); diff --git a/crates/wisp-kindra/Cargo.toml b/crates/wisp-kindra/Cargo.toml index a7ad914..27c1445 100644 --- a/crates/wisp-kindra/Cargo.toml +++ b/crates/wisp-kindra/Cargo.toml @@ -9,6 +9,7 @@ description = "Kindra worktree integration for Wisp" [dependencies] serde.workspace = true +serde_json.workspace = true thiserror.workspace = true toml.workspace = true diff --git a/crates/wisp-kindra/src/lib.rs b/crates/wisp-kindra/src/lib.rs index cc9dbba..d3de878 100644 --- a/crates/wisp-kindra/src/lib.rs +++ b/crates/wisp-kindra/src/lib.rs @@ -27,6 +27,14 @@ pub trait KindraProvider { new_branch: &str, start_point: &str, ) -> Result; + + /// Returns the branch names of all Kindra-managed *temporary* worktrees for + /// the repository rooted at `repo_root`. + fn temp_worktree_branches(&self, repo_root: &Path) -> Vec; + + /// Removes the temporary worktree for `branch`. Never passes `--force`, so a + /// worktree with uncommitted changes is left in place and surfaces an error. + fn remove_temp_worktree(&self, repo_root: &Path, branch: &str) -> Result<(), KindraError>; } /// Drives Kindra through the `kin` command-line binary. @@ -121,9 +129,59 @@ impl KindraProvider for CommandKindraProvider { start_point.to_string(), ]; + let output = self.run_kin(repo_root, &args)?; + parse_worktree_path(&String::from_utf8_lossy(&output.stdout)) + } + + fn temp_worktree_branches(&self, repo_root: &Path) -> Vec { + let Some(common_dir) = self.git_common_dir(repo_root) else { + return Vec::new(); + }; + temp_worktree_branches_in(&common_dir.join("kindra_worktrees.json")) + } + + fn remove_temp_worktree(&self, repo_root: &Path, branch: &str) -> Result<(), KindraError> { + let branch = branch.trim(); + if branch.is_empty() { + return Err(KindraError::InvalidBranch { + branch: branch.to_string(), + }); + } + + // `branch:` targets the temp worktree for that branch (never main + // or review). `--yes` skips kin's own prompt since we confirm in the UI. + let args = vec![ + "wt".to_string(), + "remove".to_string(), + format!("branch:{branch}"), + "--yes".to_string(), + ]; + + self.run_kin(repo_root, &args)?; + Ok(()) + } +} + +impl CommandKindraProvider { + fn command_for_args(&self, args: &[String]) -> Vec { + std::iter::once(self.binary.display().to_string()) + .chain(args.iter().cloned()) + .collect() + } + + /// Runs `kin` with `args` in `repo_root`, returning the completed output only + /// when the process both spawned and exited successfully. Spawn failures map + /// to [`KindraError::Unavailable`] (missing binary) or + /// [`KindraError::SpawnFailed`]; a non-zero exit maps to + /// [`KindraError::CommandFailed`] with the process's real stderr. + fn run_kin( + &self, + repo_root: &Path, + args: &[String], + ) -> Result { let output = Command::new(&self.binary) .current_dir(repo_root) - .args(&args) + .args(args) .output() .map_err(|source| { if source.kind() == std::io::ErrorKind::NotFound { @@ -134,7 +192,7 @@ impl KindraProvider for CommandKindraProvider { // The process never ran, so there is no command stderr here; // CommandFailed is reserved for real execution failures below. KindraError::SpawnFailed { - command: self.command_for_args(&args), + command: self.command_for_args(args), message: source.to_string(), } } @@ -142,21 +200,13 @@ impl KindraProvider for CommandKindraProvider { if !output.status.success() { return Err(KindraError::CommandFailed { - command: self.command_for_args(&args), + command: self.command_for_args(args), stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), status: output.status.code(), }); } - parse_worktree_path(&String::from_utf8_lossy(&output.stdout)) - } -} - -impl CommandKindraProvider { - fn command_for_args(&self, args: &[String]) -> Vec { - std::iter::once(self.binary.display().to_string()) - .chain(args.iter().cloned()) - .collect() + Ok(output) } } @@ -218,6 +268,38 @@ pub fn temp_worktrees_configured_in(config_path: &Path) -> bool { } } +/// Subset of Kindra's `kindra_worktrees.json` metadata Wisp needs to identify +/// temp worktrees. Unknown keys (path, timestamps) are ignored. +#[derive(Debug, Default, Deserialize)] +struct WorktreeMetadataFile { + #[serde(default)] + worktrees: Vec, +} + +#[derive(Debug, Deserialize)] +struct ManagedWorktreeRecord { + role: String, + branch: String, +} + +/// Returns the branch names of temp worktrees recorded in the metadata file at +/// `metadata_path`. Missing or unparseable metadata yields an empty list. +#[must_use] +pub fn temp_worktree_branches_in(metadata_path: &Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(metadata_path) else { + return Vec::new(); + }; + let Ok(metadata) = serde_json::from_str::(&raw) else { + return Vec::new(); + }; + metadata + .worktrees + .into_iter() + .filter(|record| record.role == "temp") + .map(|record| record.branch) + .collect() +} + /// Extracts the worktree path Kindra prints on stdout after creating a worktree. /// /// `kin wt temp` prints the resulting worktree path on its own line; we use the @@ -235,7 +317,7 @@ fn parse_worktree_path(stdout: &str) -> Result { mod tests { use std::fs; - use super::{parse_worktree_path, temp_worktrees_configured_in}; + use super::{parse_worktree_path, temp_worktree_branches_in, temp_worktrees_configured_in}; fn temp_dir(name: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("wisp-kindra-{}-{name}", std::process::id())); @@ -243,6 +325,40 @@ mod tests { dir } + #[test] + fn lists_only_temp_worktree_branches_from_metadata() { + let dir = temp_dir("metadata"); + let path = dir.join("kindra_worktrees.json"); + fs::write( + &path, + r#"{ + "version": 1, + "worktrees": [ + {"role": "main", "branch": "main", "path": "p1", "created_at": 1, "last_used_at": 1}, + {"role": "temp", "branch": "feature/spike", "path": "p2", "created_at": 1, "last_used_at": 1}, + {"role": "temp", "branch": "hotfix", "path": "p3", "created_at": 1, "last_used_at": 1} + ] + }"#, + ) + .expect("write metadata"); + + let mut branches = temp_worktree_branches_in(&path); + branches.sort(); + assert_eq!( + branches, + vec!["feature/spike".to_string(), "hotfix".to_string()] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn missing_metadata_yields_no_temp_branches() { + let dir = temp_dir("metadata-missing"); + assert!(temp_worktree_branches_in(&dir.join("kindra_worktrees.json")).is_empty()); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn create_temp_worktree_rejects_empty_and_dash_prefixed_branches() { use super::{CommandKindraProvider, KindraError, KindraProvider};