diff --git a/src/git/worktree.rs b/src/git/worktree.rs index f37add4..8f291f3 100644 --- a/src/git/worktree.rs +++ b/src/git/worktree.rs @@ -67,6 +67,108 @@ impl GitCommands { Ok(worktrees) } + pub fn resolve_worktree_sibling_path(&self, branch_name: &str) -> std::path::PathBuf { + let clean_branch = branch_name.trim().trim_matches('/'); + let folder_name = clean_branch.replace('/', "-"); + if let Some(parent) = self.repo_path.parent() { + parent.join(folder_name) + } else { + self.repo_path.join(folder_name) + } + } + + pub fn add_worktree( + &self, + branch: &str, + base: Option<&str>, + path_override: Option<&std::path::Path>, + ) -> Result { + let clean_branch = branch.trim().trim_matches('/'); + if clean_branch.is_empty() { + return Err(anyhow::anyhow!("Branch name cannot be empty")); + } + + let target_path = path_override + .map(std::path::PathBuf::from) + .unwrap_or_else(|| self.resolve_worktree_sibling_path(clean_branch)); + + if target_path.exists() { + return Err(anyhow::anyhow!( + "Target path '{}' already exists", + target_path.display() + )); + } + + let path_str = target_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid target path unicode"))?; + + // Check if the branch exists locally + let branch_exists = self + .git() + .args(&[ + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{}", clean_branch), + ]) + .run() + .map(|r| r.success) + .unwrap_or(false); + + if branch_exists { + self.git() + .args(&["worktree", "add", path_str, clean_branch]) + .run_expecting_success()?; + } else { + let base_ref = base.unwrap_or("HEAD"); + self.git() + .args(&["worktree", "add", "-b", clean_branch, path_str, base_ref]) + .run_expecting_success()?; + + // Set branch..base configuration for AWT interoperability + let _ = self + .git() + .cwd_path(&target_path) + .args(&["config", &format!("branch.{}.base", clean_branch), base_ref]) + .run(); + } + + // Propagate .env or .env.example from sibling main worktree if available + if let Some(parent) = target_path.parent() { + let main_env = parent.join("main").join(".env"); + let target_env = target_path.join(".env"); + if main_env.is_file() && !target_env.exists() { + let _ = std::fs::copy(&main_env, &target_env); + } else { + let main_env_example = parent.join("main").join(".env.example"); + if main_env_example.is_file() && !target_env.exists() { + let _ = std::fs::copy(&main_env_example, &target_env); + } + } + } + + // Run user lifecycle post-create hook if present (~/.config/lazygitrs/hooks/post-create.sh or ~/.config/matchmaker/hooks/post-create.sh) + if let Some(home) = std::env::var_os("HOME") { + let home_path = std::path::PathBuf::from(home); + for hook in [ + home_path.join(".config/lazygitrs/hooks/post-create.sh"), + home_path.join(".config/matchmaker/hooks/post-create.sh"), + ] { + if hook.is_file() { + let _ = std::process::Command::new(&hook) + .arg(&target_path) + .arg(clean_branch) + .arg(base.unwrap_or("main")) + .spawn(); + break; + } + } + } + + Ok(target_path) + } + pub fn create_worktree(&self, path: &str, branch: &str) -> Result<()> { self.git() .args(&["worktree", "add", path, branch]) @@ -91,3 +193,130 @@ impl GitCommands { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_resolve_worktree_sibling_path() { + let dummy = GitCommands { + repo_path: PathBuf::from("/home/user/dev/project/fix-worktree"), + repo_name: "project".to_string(), + }; + + let sibling = dummy.resolve_worktree_sibling_path("feat/login-page"); + assert_eq!( + sibling, + PathBuf::from("/home/user/dev/project/feat-login-page") + ); + + let nested = dummy.resolve_worktree_sibling_path("fix/ui/buttons"); + assert_eq!( + nested, + PathBuf::from("/home/user/dev/project/fix-ui-buttons") + ); + } + + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new(prefix: &str) -> Self { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "lazygitrs-{prefix}-{unique}-{}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[test] + fn test_add_worktree_sibling_creation() { + use std::process::Command; + + let temp = TempDir::new("add-wt"); + let main_repo = temp.path().join("main-worktree"); + std::fs::create_dir_all(&main_repo).expect("create repo dir"); + + assert!( + Command::new("git") + .args(["init", "-b", "main"]) + .arg(&main_repo) + .status() + .expect("init git") + .success() + ); + assert!( + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&main_repo) + .status() + .expect("config email") + .success() + ); + assert!( + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&main_repo) + .status() + .expect("config name") + .success() + ); + std::fs::write(main_repo.join("file.txt"), "hello").expect("write file"); + assert!( + Command::new("git") + .args(["add", "file.txt"]) + .current_dir(&main_repo) + .status() + .expect("git add") + .success() + ); + assert!( + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(&main_repo) + .status() + .expect("git commit") + .success() + ); + + let git = GitCommands::new(&main_repo).expect("create GitCommands"); + let created = git + .add_worktree("feat/oauth-login", Some("main"), None) + .expect("add worktree"); + + let expected_path = temp.path().join("feat-oauth-login"); + assert_eq!(created, expected_path); + assert!(created.is_dir()); + assert!(created.join("file.txt").is_file()); + + // Verify base config + let base_config = git + .git() + .cwd_path(&created) + .args(&["config", "branch.feat/oauth-login.base"]) + .run() + .expect("run git config") + .stdout_trimmed() + .to_string(); + assert_eq!(base_config, "main"); + } +} diff --git a/src/gui/controller/worktrees.rs b/src/gui/controller/worktrees.rs index ae4f824..4c41214 100644 --- a/src/gui/controller/worktrees.rs +++ b/src/gui/controller/worktrees.rs @@ -3,14 +3,19 @@ use crossterm::event::{KeyCode, KeyEvent}; use crate::config::KeybindingConfig; use crate::gui::Gui; -use crate::gui::popup::{PopupState, make_textarea}; +use crate::gui::popup::{MenuItem, MessageKind, PopupState, make_textarea}; pub fn handle_key(gui: &mut Gui, key: KeyEvent, _keybindings: &KeybindingConfig) -> Result<()> { - // Switch to worktree + // Switch to worktree (action menu) if key.code == KeyCode::Char(' ') { return switch_worktree(gui); } + // Direct open in Tmux session + if key.code == KeyCode::Char('t') { + return open_selected_in_tmux(gui); + } + // Create new worktree if key.code == KeyCode::Char('n') { return create_worktree(gui); @@ -24,6 +29,93 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, _keybindings: &KeybindingConfig) Ok(()) } +pub fn open_in_tmux_session(path: &str) -> Result<()> { + let target_path = path.to_string(); + let session_name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "worktree".to_string()); + + // 1. Try wm connect or sesh connect if available + for cmd in ["wm", "sesh"] { + if let Ok(status) = std::process::Command::new(cmd) + .arg("connect") + .arg(&target_path) + .status() + { + if status.success() { + return Ok(()); + } + } + } + + // 2. Fallback to tmux commands + if std::env::var_os("TMUX").is_some() { + let _ = std::process::Command::new("tmux") + .args(["new-session", "-d", "-s", &session_name, "-c", &target_path]) + .status(); + let _ = std::process::Command::new("tmux") + .args(["switch-client", "-t", &session_name]) + .status(); + } else { + let _ = std::process::Command::new("tmux") + .args(["new-session", "-A", "-s", &session_name, "-c", &target_path]) + .spawn(); + } + Ok(()) +} + +pub fn show_worktree_action_menu(gui: &mut Gui, branch_name: &str, path: &str, title_prefix: &str) { + let b_name = branch_name.to_string(); + let p_for_tmux = path.to_string(); + let p_for_exe = path.to_string(); + + let items = vec![ + MenuItem { + label: "Open in new Tmux session".to_string(), + description: format!("Create/switch Tmux session for {}", path), + key: Some("t".to_string()), + action: Some(Box::new(move |gui| { + let _ = open_in_tmux_session(&p_for_tmux); + gui.popup = PopupState::None; + gui.needs_refresh = true; + Ok(()) + })), + }, + MenuItem { + label: "Open lazygitrs in current terminal".to_string(), + description: "Launch new lazygitrs instance here".to_string(), + key: Some("o".to_string()), + action: Some(Box::new(move |gui| { + let exe = std::env::current_exe().unwrap_or_else(|_| "lazygitrs".into()); + std::process::Command::new(exe) + .arg("--path") + .arg(&p_for_exe) + .spawn()?; + gui.should_quit = true; + Ok(()) + })), + }, + MenuItem { + label: "Stay in current worktree".to_string(), + description: "Keep current session active".to_string(), + key: Some("s".to_string()), + action: Some(Box::new(move |gui| { + gui.popup = PopupState::None; + gui.needs_refresh = true; + Ok(()) + })), + }, + ]; + + gui.popup = PopupState::Menu { + title: format!("{}: {}", title_prefix, b_name), + items, + selected: 0, + loading_index: None, + }; +} + fn switch_worktree(gui: &mut Gui) -> Result<()> { let selected = gui.context_mgr.selected_active(); let model = gui.model.lock().unwrap(); @@ -35,46 +127,57 @@ fn switch_worktree(gui: &mut Gui) -> Result<()> { let branch = wt.branch.clone(); drop(model); - gui.popup = PopupState::Confirm { - title: "Switch worktree".to_string(), - message: format!( - "Open lazygitrs in worktree '{}' ({})?\nThis will launch a new instance.", - branch, path - ), - on_confirm: Box::new(move |gui| { - // Spawn a new lazygitrs instance in the worktree directory - let exe = std::env::current_exe().unwrap_or_else(|_| "lazygitrs".into()); - std::process::Command::new(exe) - .arg("--path") - .arg(&path) - .spawn()?; - gui.should_quit = true; - Ok(()) - }), - }; + show_worktree_action_menu(gui, &branch, &path, "Switch worktree"); + } + Ok(()) +} + +fn open_selected_in_tmux(gui: &mut Gui) -> Result<()> { + let selected = gui.context_mgr.selected_active(); + let model = gui.model.lock().unwrap(); + if let Some(wt) = model.worktrees.get(selected) { + let path = wt.path.clone(); + drop(model); + + open_in_tmux_session(&path)?; + gui.needs_refresh = true; } Ok(()) } fn create_worktree(gui: &mut Gui) -> Result<()> { gui.popup = PopupState::Input { - title: "New worktree (path branch)".to_string(), - textarea: make_textarea("path branch-name"), + title: "New worktree branch name".to_string(), + textarea: make_textarea(""), on_confirm: Box::new(|gui, input| { let parts: Vec<&str> = input.split_whitespace().collect(); - if parts.len() >= 2 { - gui.git.create_worktree(parts[0], parts[1])?; - gui.needs_refresh = true; - } else if parts.len() == 1 { - // If only path given, create with new branch based on dir name - let path = parts[0]; - let branch = std::path::Path::new(path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "worktree".to_string()); - - gui.git.create_worktree_new_branch(path, &branch)?; - gui.needs_refresh = true; + if parts.is_empty() { + return Ok(()); + } + + let branch = parts[0]; + let base = if parts.len() >= 2 { + Some(parts[1]) + } else { + None + }; + + match gui.git.add_worktree(branch, base, None) { + Ok(target_path) => { + gui.needs_refresh = true; + let path_display = target_path.display().to_string(); + let branch_name = branch.to_string(); + + // Prompt action menu with Tmux option + show_worktree_action_menu(gui, &branch_name, &path_display, "Worktree created"); + } + Err(e) => { + gui.popup = PopupState::Message { + title: "Create worktree error".to_string(), + message: format!("{}", e), + kind: MessageKind::Error, + }; + } } Ok(()) }),