diff --git a/config.kdl b/config.kdl index 6fa9e53..71950a9 100644 --- a/config.kdl +++ b/config.kdl @@ -44,12 +44,16 @@ // // height 10 -// Select the repositories remote default branch if multiple worktrees are found. If the default -// worktree cannot be found the fallback will be to select the correct one. +// Specifies the handling of git worktrees. There are three available modes: // -// Default: false +// - 'ask': Lists all worktrees and prompts the user for selection. +// - 'default': For bare repositories, uses the worktree related to the default branch; for non-bare repositories +// uses the default working directory. +// - 'all': Opens a separate window for each worktree. // -// default_worktree true +// Default "ask" +// +// worktree "default" // Workspace directory crawler will prune the paths containing any of these components. // Options: diff --git a/doc/configuration.adoc b/doc/configuration.adoc index 6a00fe9..148ab84 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -64,18 +64,22 @@ Default: `50%` height 10 ---- -=== default_worktree +=== worktree -Select the repositories remote default branch if multiple worktrees are found. If the default -worktree cannot be found the fallback will be to select the correct one. +Specifies the handling of git worktrees. There are three available modes + +`ask`:: Lists all worktrees and prompts the user for selection. +`default`:: For bare repositories, uses the worktree related to the default branch; for non-bare repositories + uses the default working directory. +`all`:: Opens a separate window for each worktree. [%hardbreaks] -Type: `boolean` -Default: `false` +Type: `string` +Default: `ask` [source,javascript] ---- -default_worktree true +worktree "ask" ---- === exclude_paths diff --git a/readme.adoc b/readme.adoc index 9b852ff..c82292d 100644 --- a/readme.adoc +++ b/readme.adoc @@ -199,18 +199,22 @@ Default: `50%` height 10 ---- -=== default_worktree +=== worktree -Select the repositories remote default branch if multiple worktrees are found. If the default -worktree cannot be found the fallback will be to select the correct one. +Specifies the handling of git worktrees. There are three available modes + +`ask`:: Lists all worktrees and prompts the user for selection. +`default`:: For bare repositories, uses the worktree related to the default branch; for non-bare repositories + uses the default working directory. +`all`:: Opens a separate window for each worktree. [%hardbreaks] -Type: `boolean` -Default: `false` +Type: `string` +Default: `ask` [source,javascript] ---- -default_worktree true +worktree "ask" ---- === exclude_paths diff --git a/src/cmd/attach.rs b/src/cmd/attach.rs index 50846fb..3de1423 100644 --- a/src/cmd/attach.rs +++ b/src/cmd/attach.rs @@ -5,7 +5,7 @@ use std::{ use crate::{ cmd::cli::Attach, - config::Config, + config::{Config, WorktreeMode}, finder::{self, FinderOptions}, util, walker::Walker, @@ -16,7 +16,7 @@ use dialoguer::{ theme::ColorfulTheme, FuzzySelect, }; -use gix::{bstr::ByteSlice, Repository}; +use gix::bstr::ByteSlice; use itertools::Itertools; use miette::{miette, IntoDiagnostic, Result}; use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; @@ -121,14 +121,96 @@ impl Attach { } let repo = gix::open(selected).ok(); - let worktree = self.get_worktree(repo.as_ref(), config); - let branch = repo.as_ref().and_then(head_branch); - mux.create_session(&name, selected.to_str().unwrap(), branch.as_deref())?; - - if let Some(worktree) = worktree { - mux.send_command(&name, &format!("cd {}", worktree.display()))?; + // Check worktree mode + if let Some(repo) = repo { + let worktrees = repo.worktrees().into_diagnostic()?; + let is_bare = is_bare(&repo); + + if config.worktree_mode == WorktreeMode::All || self.all { + let mut iter = worktrees.iter(); + + // A bare repo that contains worktrees only uses worktrees and does not contain + // a normally contain a valid work_dir. When creating the session use the first + // worktree as the window name. + if is_bare && !worktrees.is_empty() { + let first = iter.next(); + let path = first.and_then(|tree| tree.base().ok()); + let window_name = first.map(|f| f.id().to_string()); + mux.create_session( + &name, + path.expect("worktrees is not empty"), + window_name.as_deref(), + )?; + } else { + let head_branch = head_branch(&repo); + mux.create_session( + &name, + selected.to_string_lossy().as_ref(), + head_branch.as_deref(), + )?; + }; + + for tree in iter { + let path = tree.base().ok(); + let window_name = tree.id().to_str_lossy(); + mux.create_window(&window_name, path.as_deref())?; + } + + return mux.attach_session(&name); + } else if config.worktree_mode == WorktreeMode::Default || self.default { + let len = worktrees.len(); + // If the repo is not bare then the default would be the work_dir of the repo. + // This will be used instead of any worktree + if !is_bare || len == 0 { + mux.create_session(&name, selected.to_string_lossy().as_ref(), None)?; + return mux.attach_session(&name); + } + + // If there is only one worktree and it is a bare repo it is the only option + if len == 1 { + let proxy = worktrees.first().expect("worktree contains values"); + let path = proxy.base().into_diagnostic()?; + let proxy_repo = proxy + .clone() + .into_repo_with_possibly_inaccessible_worktree() + .into_diagnostic()?; + let window_name = head_branch(&proxy_repo); + mux.create_session(&name, path, window_name.as_deref())?; + return mux.attach_session(&name); + } + + if let Some(default_branch) = default_branch(&repo) { + if let Some(default_worktree) = + worktrees.iter().find(|tree| tree.id() == default_branch) + { + let path = default_worktree.base().into_diagnostic()?; + mux.create_session(&name, path, Some(&default_branch))?; + return mux.attach_session(&name); + } + } + + return Err(miette!("Could not find default branch / worktree")); + } else { + let items = worktrees + .iter() + .map(|tree| tree.id().to_string()) + .collect_vec(); + + if let Some(choice) = fuzzy(&items, "Worktree") { + let tree = worktrees + .get(choice) + .expect("choice value comes from worktree index"); + let path = tree.base().into_diagnostic()?; + let window_name = tree.id(); + mux.create_session(&name, path, Some(window_name.to_str_lossy().as_ref()))?; + return mux.attach_session(&name); + } + } + } else { + // This is not a git repo so just create and attach. + mux.create_session(&name, selected.to_str().unwrap(), None)?; + return mux.attach_session(&name); } - mux.attach_session(&name)?; Ok(()) } @@ -136,56 +218,6 @@ impl Attach { pub fn use_cwd(&self, config: &Config) -> Result<()> { self.execute_selected(&std::env::current_dir().into_diagnostic()?, config) } - - fn get_worktree(&self, repo: Option<&Repository>, config: &Config) -> Option { - let repo = repo?; - let worktrees = repo.worktrees().ok()?; - let use_default = self.default || config.default_worktree; - let worktree_length = worktrees.len(); - let bare = is_bare(repo); - - if worktree_length == 0 { - return None; - } - - // If the repository is not bare then worktree's are in addition to the main default - // worktree. If we are to use 'default' we should not use any worktrees - if !bare && use_default { - return None; - } - - // NOTE: A worktree's id() (name) can be different then it's branch name. To get the branch - // name you have to get the proxy repo and get the head branch of that. - let items = worktrees.iter().map(|t| t.id().to_string()).collect_vec(); - if worktree_length == 1 { - // If the repo is a bare repo then there is only one valid working tree - if bare { - return worktrees[0].base().ok(); - } - - let default_branch = head_branch(repo)?; - let mut choices = vec![default_branch]; - choices.extend(items); - let choice = fuzzy(&choices, "Worktree")?; - if choice == 0 { - return None; - } - - return worktrees[choice - 1].base().ok(); - } - - if use_default { - return default_branch(repo) - .and_then(|name| { - let s = name.as_str(); - items.iter().position(|x| x == s) - }) - .and_then(|index| worktrees[index].base().ok()); - } - - let choice = fuzzy(&items, "Worktree")?; - worktrees[choice].base().ok() - } } fn default_branch(repo: &gix::Repository) -> Option { diff --git a/src/cmd/cli.rs b/src/cmd/cli.rs index f09b654..ed015bf 100644 --- a/src/cmd/cli.rs +++ b/src/cmd/cli.rs @@ -100,6 +100,10 @@ pub struct Attach { #[arg(short, long, default_value_t = false)] pub default: bool, + /// Create mux window for each worktree + #[arg(short, long, default_value_t = false)] + pub all: bool, + /// Exact path to either attach to existing session or create a new one if /// none exist #[arg(short, long, default_value = None)] diff --git a/src/cmd/wcmd.rs b/src/cmd/wcmd.rs index c441c8a..bcaaf62 100644 --- a/src/cmd/wcmd.rs +++ b/src/cmd/wcmd.rs @@ -23,7 +23,7 @@ impl Run for Wcmd { let target = format!("{}:{}", session_name.trim(), name); if !mux.session_exists(&target) { - mux.create_window(name)?; + mux.create_window(name, None)?; } let cmd: String = intersperse(self.cmds.iter().map(|f| f.as_str()), " ").collect(); diff --git a/src/config/error.rs b/src/config/error.rs index 4e3bd91..61006b5 100644 --- a/src/config/error.rs +++ b/src/config/error.rs @@ -96,6 +96,35 @@ pub enum ParseError { #[label("expected a percentage from 1-100%")] SourceSpan, ), + #[error("Invalid worktree mode")] + #[diagnostic( + code("tm::invalid_worktree_mode"), + help("possible values ['all', 'deafult', 'ask']") + )] + InvalidWorktreeMode( + #[source_code] Source, + #[label("unknown worktree mode")] SourceSpan, + ), + + #[error("Unknown configuration option")] + #[diagnostic(code("tm::unknown_configuration_option"))] + UnknownConfigurationOption( + /// Name of unknown option + String, + #[source_code] Source, + #[label("Unknown option '{0}'")] SourceSpan, + ), + + #[error("Deprecated option 'worktree'")] + #[diagnostic( + code("tm::deprecated_default_worktree"), + help("Replaced with 'worktree'") + )] + DeprecatedDefaultWorktree( + #[source_code] Source, + #[label("Deprecated option 'default_worktree'")] SourceSpan, + ), + #[error(transparent)] #[diagnostic(transparent)] Kdl(#[from] KdlError), diff --git a/src/config/mod.rs b/src/config/mod.rs index d91332d..571e115 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -34,7 +34,7 @@ pub struct Config { pub exclude_path: IndexSet, pub depth: usize, pub mode: Mode, - pub default_worktree: bool, + pub worktree_mode: WorktreeMode, pub mux: Mux, } @@ -54,12 +54,20 @@ impl Default for Config { exclude_path: indexset! { "node_modules".to_string(), ".direnv".to_string(), ".cache".to_string(), ".local".to_string()}, depth: 5, mode: Mode::default(), - default_worktree: false, + worktree_mode: WorktreeMode::default(), mux: Mux::default(), } } } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum WorktreeMode { + All, + Default, + #[default] + Ask, +} + impl Config { pub fn load() -> Result { let mut config = Config::default(); diff --git a/src/config/parser.rs b/src/config/parser.rs index efcc2fa..ae4dc79 100644 --- a/src/config/parser.rs +++ b/src/config/parser.rs @@ -1,7 +1,7 @@ use itertools::Itertools; use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue}; -use super::{error::ParseError, source::Source, Config, Mode}; +use super::{error::ParseError, source::Source, Config, Mode, WorktreeMode}; #[derive(Debug)] pub struct Parser { @@ -45,80 +45,118 @@ impl Parser { fn inner_parse(self, mut config: Config) -> Result { let doc: KdlDocument = self.src.raw.parse()?; - if let Some(exclude_node) = doc.get("exclude_path") { - let default = self.get_default_optional(exclude_node)?; - let paths = self.try_get_dash_values_as_string(&doc, "exclude_path")?; + for node in doc.nodes() { + match node.name().value() { + "paths" => { + if let Some(doc) = node.children() { + if let Some(workspace_node) = doc.get("workspace") { + let default = self.get_default_optional(workspace_node)?; + let mut workspaces = + self.try_get_dash_values_as_valid_paths(doc, "workspace")?; - if default { - config.exclude_path.extend(paths); - } else { - config.exclude_path = paths.into_iter().collect(); - } - } + if default { + config.search.workspace.append(&mut workspaces); + } else { + config.search.workspace = workspaces; + } + } - if let Some(doc) = doc.get("paths").and_then(|node| node.children()) { - if let Some(workspace_node) = doc.get("workspace") { - let default = self.get_default_optional(workspace_node)?; - let mut workspaces = self.try_get_dash_values_as_valid_paths(doc, "workspace")?; + if let Some(single_node) = doc.get("single") { + let default = self.get_default_optional(single_node)?; + let mut singles = + self.try_get_dash_values_as_valid_paths(doc, "single")?; - if default { - config.search.workspace.append(&mut workspaces); - } else { - config.search.workspace = workspaces; + if default { + config.search.single.append(&mut singles); + } else { + config.search.single = singles; + } + } + } } - } + "exclude_path" => { + let default = self.get_default_optional(node)?; + let paths = self.try_get_dash_values_as_string(&doc, "exclude_path")?; - if let Some(single_node) = doc.get("single") { - let default = self.get_default_optional(single_node)?; - let mut singles = self.try_get_dash_values_as_valid_paths(doc, "single")?; - - if default { - config.search.single.append(&mut singles); - } else { - config.search.single = singles; + if default { + config.exclude_path.extend(paths); + } else { + config.exclude_path = paths.into_iter().collect(); + } } - } - } - - if let Some(node) = doc.get("depth") { - config.depth = usize::try_from(self.first_entry_as_i64(node)?).unwrap_or(0); - } - - if let Some(node) = doc.get("default_worktree") { - config.default_worktree = self.first_entry_as_bool(node).unwrap_or(false); - } - - if let Some(node) = doc.get("height") { - let entry = self.first_entry(node)?; - let value = entry.value(); - if let Some(n) = value.as_i64() { - if n > 0 { - config.mode = Mode::Lines(n as u16); + "depth" => { + config.depth = usize::try_from(self.first_entry_as_i64(node)?).unwrap_or(0); + } + "height" => { + let entry = self.first_entry(node)?; + let value = entry.value(); + if let Some(n) = value.as_i64() { + if n > 0 { + config.mode = Mode::Lines(n as u16); + } + } else if let Some(s) = value.as_string() { + if let Some(numeric) = s.strip_suffix('%') { + let per = numeric.parse::()?; + match per { + 100 => config.mode = Mode::Full, + 1..=99 => config.mode = Mode::Percentage(per as f32 / 100.0), + _ => { + return Err(ParseError::InvalidPercentage( + self.src.clone(), + *entry.span(), + )) + } + } + } else if let Some(n) = value.as_i64() { + config.mode = Mode::Lines(n as u16); + } else if let Some(s) = value.as_string() { + if s == "full" { + config.mode = Mode::Full; + } else { + return Err(ParseError::InvalidHeightString( + self.src.clone(), + *entry.span(), + )); + } + } + } } - } else if let Some(s) = value.as_string() { - if let Some(numeric) = s.strip_suffix('%') { - let per = numeric.parse::()?; - match per { - 100 => config.mode = Mode::Full, - 1..=99 => config.mode = Mode::Percentage(per as f32 / 100.0), + "worktree" => { + let entry = self.first_entry(node)?; + let value = entry.value(); + let mode = self.first_entry(node)?.value().as_string().ok_or( + ParseError::TypeMismatch( + "string", + type_from_value(value), + self.src.clone(), + *entry.span(), + ), + )?; + + config.worktree_mode = match mode { + "all" => WorktreeMode::All, + "default" => WorktreeMode::Default, + "ask" => WorktreeMode::Ask, _ => { - return Err(ParseError::InvalidPercentage( + return Err(ParseError::InvalidWorktreeMode( self.src.clone(), *entry.span(), )) } - } - } else if let Some(n) = value.as_i64() { - config.mode = Mode::Lines(n as u16); - } else if let Some(s) = value.as_string() { - if s == "full" { - config.mode = Mode::Full; - } else { - return Err(ParseError::InvalidHeightString( - self.src.clone(), - *entry.span(), - )); - } + }; + } + "default_worktree" => { + return Err(ParseError::DeprecatedDefaultWorktree( + self.src.clone(), + *node.name().span(), + )) + } + option => { + return Err(ParseError::UnknownConfigurationOption( + option.to_owned(), + self.src.clone(), + *node.name().span(), + )); } } } @@ -155,16 +193,16 @@ impl Parser { }) } - fn first_entry_as_bool<'a>(&'a self, node: &'a KdlNode) -> Result { - self.first_entry(node).and_then(|entry| { - entry.value().as_bool().ok_or(ParseError::TypeMismatch( - "bool", - type_from_value(entry.value()), - self.src.clone(), - *entry.span(), - )) - }) - } + // fn first_entry_as_bool<'a>(&'a self, node: &'a KdlNode) -> Result { + // self.first_entry(node).and_then(|entry| { + // entry.value().as_bool().ok_or(ParseError::TypeMismatch( + // "bool", + // type_from_value(entry.value()), + // self.src.clone(), + // *entry.span(), + // )) + // }) + // } fn get_default_optional(&self, node: &KdlNode) -> Result { match node.get("default") { diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 1b92011..44c3bca 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -36,8 +36,8 @@ impl Mux { tmux::kill_session(name) } - pub fn create_window(&self, name: &str) -> Result<()> { - tmux::create_window(name) + pub fn create_window(&self, name: &str, path: Option<&Path>) -> Result<()> { + tmux::create_window(name, path) } pub fn send_command(&self, name: &str, command: &str) -> Result<()> { diff --git a/src/mux/tmux.rs b/src/mux/tmux.rs index 44be1b4..d8401d0 100644 --- a/src/mux/tmux.rs +++ b/src/mux/tmux.rs @@ -55,10 +55,14 @@ pub fn kill_session(name: &str) -> Result<()> { Ok(()) } -pub fn create_window(name: &str) -> Result<()> { - Tmux::with_command(NewWindow::new().window_name(name)) - .output() - .into_diagnostic()?; +pub fn create_window(name: &str, path: Option<&Path>) -> Result<()> { + let window = match path { + Some(path) => NewWindow::new() + .window_name(name) + .start_directory(path.to_string_lossy()), + None => NewWindow::new().window_name(name), + }; + Tmux::with_command(window).output().into_diagnostic()?; Ok(()) }