diff --git a/config.toml b/config.toml index 28adf29..89c9f74 100644 --- a/config.toml +++ b/config.toml @@ -58,3 +58,35 @@ commands = [ # fi # ''' }, ] + +# A list of regex replacements which are applied to branch names to determine +# directory names. +# +# By default, when you create a worktree for a branch with a `/` in it, `git +# prole` will use the last component of the name; e.g., `git prole add +# -b puppy/doggy` creates a directory named `doggy` for a new branch +# `puppy/doggy`. +# +# However, this might not be the most convenient behavior for you, so you can +# substitute this behavior with a series of regex replacements instead. +# +# For example, my ticket tracker at work auto-generates branch names like +# `rebeccat/team-1234-some-ticket-title-which-is-way-too-long`. With +# configuration like this: +# +# [[branch_replacements]] +# find = '''\w+/\w{1,4}-\d{1,5}-(\w+(?:-\w+){0,2}).*''' +# replace = '''$1''' +# +# `git prole add -b ...` will create a directory named `some-ticket-title`. +# (The branch name will still be unchanged and way too long.) +# +# For completeness, you can also specify how many replacements are performed: +# +# [[branch_replacements]] +# find = '''puppy''' +# replace = '''doggy''' +# count = 1 +# +# See: https://docs.rs/regex/latest/regex/#syntax +branch_replacements = [] diff --git a/src/add.rs b/src/add.rs index e1e3d8a..d8eba23 100644 --- a/src/add.rs +++ b/src/add.rs @@ -11,11 +11,11 @@ use miette::Context; use miette::IntoDiagnostic; use owo_colors::OwoColorize; use owo_colors::Stream; -use tap::Tap; use tracing::instrument; use crate::app_git::AppGit; use crate::cli::AddArgs; +use crate::final_component; use crate::format_bulleted_list::format_bulleted_list; use crate::git::BranchRef; use crate::git::GitLike; @@ -87,9 +87,7 @@ impl<'a> WorktreePlan<'a> { .into_diagnostic()? } else { // Test case: `add_by_name_new_local`. - git.worktree() - .container()? - .tap_mut(|p| p.push(name_or_path)) + git.worktree().path_for(name_or_path)? } } None => { @@ -368,7 +366,9 @@ impl BranchStartPointPlan { .name_or_path .as_deref() .expect("If `--branch` is not given, `NAME_OR_PATH` must be given"); - let dirname = git.worktree().dirname_for(name_or_path); + // TODO: It would be nice if there was a set of regexes for the + // branch name itself, as well. + let dirname = final_component(name_or_path); match &args.commitish { Some(commitish) => match Self::from_commitish(git, commitish)? { diff --git a/src/config.rs b/src/config.rs index 06b1450..2dfb65e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,7 @@ use camino::Utf8PathBuf; use clap::Parser; use miette::Context; use miette::IntoDiagnostic; +use regex::Regex; use serde::de::Error; use serde::Deserialize; use unindent::unindent; @@ -87,22 +88,19 @@ fn config_file_path(dirs: &BaseDirectories) -> miette::Result { /// Each configuration key should have two test cases: /// - `config_{key}` for setting the value. /// - `config_{key}_default` for the default value. +/// +/// For documentation, see the default configuration file (`../config.toml`). +/// +/// The default configuration file is accessible as [`Config::DEFAULT`]. #[derive(Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] pub struct ConfigFile { - #[serde(default)] remotes: Vec, - - #[serde(default)] default_branches: Vec, - - #[serde(default)] copy_untracked: Option, - - #[serde(default)] enable_gh: Option, - - #[serde(default)] commands: Vec, + branch_replacements: Vec, } impl ConfigFile { @@ -134,8 +132,12 @@ impl ConfigFile { self.enable_gh.unwrap_or(false) } - pub fn commands(&self) -> Vec { - self.commands.clone() + pub fn commands(&self) -> &[ShellCommand] { + &self.commands + } + + pub fn branch_replacements(&self) -> &[BranchReplacement] { + &self.branch_replacements } } @@ -194,6 +196,30 @@ impl<'de> Deserialize<'de> for ShellArgs { } } +#[derive(Clone, Debug, Deserialize)] +pub struct BranchReplacement { + #[serde(deserialize_with = "deserialize_regex")] + pub find: Regex, + pub replace: String, + pub count: Option, +} + +impl PartialEq for BranchReplacement { + fn eq(&self, other: &Self) -> bool { + self.replace == other.replace && self.find.as_str() == other.find.as_str() + } +} + +impl Eq for BranchReplacement {} + +fn deserialize_regex<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let input: String = Deserialize::deserialize(deserializer)?; + Regex::new(&input).map_err(D::Error::custom) +} + #[cfg(test)] mod tests { use super::*; @@ -201,14 +227,37 @@ mod tests { #[test] fn test_default_config_file_parse() { + let default_config = toml::from_str::(Config::DEFAULT).unwrap(); assert_eq!( - toml::from_str::(Config::DEFAULT).unwrap(), + default_config, ConfigFile { remotes: vec!["upstream".to_owned(), "origin".to_owned(),], default_branches: vec!["main".to_owned(), "master".to_owned(), "trunk".to_owned(),], copy_untracked: Some(true), enable_gh: Some(false), commands: vec![], + branch_replacements: vec![], + } + ); + + let empty_config = toml::from_str::("").unwrap(); + assert_eq!( + default_config, + ConfigFile { + remotes: empty_config.remotes(), + default_branches: empty_config.default_branches(), + copy_untracked: Some(empty_config.copy_untracked()), + enable_gh: Some(empty_config.enable_gh()), + commands: empty_config + .commands() + .iter() + .map(|command| command.to_owned()) + .collect(), + branch_replacements: empty_config + .branch_replacements() + .iter() + .map(|replacement| replacement.to_owned()) + .collect() } ); } diff --git a/src/convert.rs b/src/convert.rs index 52621e3..0cfdee1 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -257,7 +257,7 @@ where let name = git .worktree() .dirname_for(default_branch.branch_name()) - .to_owned(); + .into_owned(); // If we're creating a worktree for a default branch from a // remote, we may not have a corresponding local branch diff --git a/src/final_component.rs b/src/final_component.rs new file mode 100644 index 0000000..28a82fa --- /dev/null +++ b/src/final_component.rs @@ -0,0 +1,7 @@ +/// Get the final component of a path-like value. +pub fn final_component(path_ish: &str) -> &str { + match path_ish.rsplit_once('/') { + Some((_left, right)) => right, + None => path_ish, + } +} diff --git a/src/git/worktree/mod.rs b/src/git/worktree/mod.rs index 88b91ce..cef6068 100644 --- a/src/git/worktree/mod.rs +++ b/src/git/worktree/mod.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::ffi::OsStr; use std::fmt::Debug; use std::process::Command; @@ -13,6 +14,8 @@ use tap::Tap; use tracing::instrument; use utf8_command::Utf8Output; +use crate::config::BranchReplacement; +use crate::final_component; use crate::AppGit; use super::BranchRef; @@ -251,12 +254,25 @@ where /// /// E.g. to convert a repo `~/puppy` with default branch `main`, this will return `main`, /// to indicate a worktree to be placed in `~/puppy/main`. - /// - /// TODO: Should support some configurable regex filtering or other logic? - pub fn dirname_for<'b>(&self, branch: &'b str) -> &'b str { - match branch.rsplit_once('/') { - Some((_left, right)) => right, - None => branch, + pub fn dirname_for<'b>(&self, branch: &'b str) -> Cow<'b, str> { + let branch_replacements = self.0.config.file.branch_replacements(); + if branch_replacements.is_empty() { + Cow::Borrowed(final_component(branch)) + } else { + let mut dirname = branch.to_owned(); + for BranchReplacement { + find, + replace, + count, + } in branch_replacements + { + dirname = match count { + Some(count) => find.replacen(&dirname, *count, replace), + None => find.replace_all(&dirname, replace), + } + .into_owned(); + } + dirname.into() } } @@ -267,7 +283,7 @@ where pub fn path_for(&self, branch: &str) -> miette::Result { Ok(self .container()? - .tap_mut(|p| p.push(self.dirname_for(branch)))) + .tap_mut(|p| p.push(&*self.dirname_for(branch)))) } /// Resolves a set of worktrees into a map from worktree paths to unique names. diff --git a/src/lib.rs b/src/lib.rs index 2a2e488..2d1c59d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ mod config; mod convert; mod copy_dir; mod current_dir; +mod final_component; mod format_bulleted_list; pub mod fs; mod gh; @@ -29,6 +30,7 @@ mod utf8tempdir; pub use app::App; pub use app_git::AppGit; pub use config::Config; +pub use final_component::final_component; pub use format_bulleted_list::format_bulleted_list; pub use format_bulleted_list::format_bulleted_list_multiline; pub use git::repository_url_destination; diff --git a/test-harness/src/repo_state.rs b/test-harness/src/repo_state.rs index aa88508..33e5580 100644 --- a/test-harness/src/repo_state.rs +++ b/test-harness/src/repo_state.rs @@ -83,14 +83,21 @@ impl RepoState { let mut expected_worktrees = worktrees .iter() .map(|worktree| { - ( - self.root() - .join(&worktree.path) - .canonicalize_utf8() - .map_err(|err| format!("{err}: {}", worktree.path)) - .expect("Worktree path should be canonicalize-able"), - worktree, - ) + let path = self.root().join(&worktree.path); + + if !path.exists() { + panic!( + "Worktree {} doesn't exist. Worktrees:\n{}", + worktree.path, actual_worktrees + ); + } + + let path = path + .canonicalize_utf8() + .map_err(|err| format!("{err}: {}", worktree.path)) + .expect("Worktree path should be canonicalize-able"); + + (path, worktree) }) .collect::>(); diff --git a/tests/config_branch_replacements.rs b/tests/config_branch_replacements.rs new file mode 100644 index 0000000..d78b459 --- /dev/null +++ b/tests/config_branch_replacements.rs @@ -0,0 +1,39 @@ +use command_error::CommandExt; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn config_branch_replacements() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_worktree_repo("my-repo")?; + prole.write_config( + r#" + [[branch_replacements]] + find = '''\w+/\w{1,4}-\d{1,5}-(\w+(?:-\w+){0,2}).*''' + replace = '''$1''' + "#, + )?; + + prole + .cd_cmd("my-repo/main") + .args([ + "add", + "-b", + "doggy/pup-1234-my-cool-feature-with-very-very-very-long-name", + ]) + .status_checked() + .unwrap(); + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("my-cool-feature") + .branch("doggy/pup-1234-my-cool-feature-with-very-very-very-long-name") + .upstream("main"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/config_branch_replacements_add_by_name.rs b/tests/config_branch_replacements_add_by_name.rs new file mode 100644 index 0000000..b93dc18 --- /dev/null +++ b/tests/config_branch_replacements_add_by_name.rs @@ -0,0 +1,39 @@ +use command_error::CommandExt; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn config_branch_replacements_multiple() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_worktree_repo("my-repo")?; + prole.write_config( + r#" + [[branch_replacements]] + find = '''puppy''' + replace = '''doggy''' + + [[branch_replacements]] + find = '''doggy''' + replace = '''cutie''' + "#, + )?; + + prole + .cd_cmd("my-repo/main") + .args(["add", "silly-puppy"]) + .status_checked() + .unwrap(); + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("silly-cutie") + .branch("silly-puppy") + .upstream("main"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/config_branch_replacements_count.rs b/tests/config_branch_replacements_count.rs new file mode 100644 index 0000000..931e869 --- /dev/null +++ b/tests/config_branch_replacements_count.rs @@ -0,0 +1,36 @@ +use command_error::CommandExt; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn config_branch_replacements_count() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_worktree_repo("my-repo")?; + prole.write_config( + r#" + [[branch_replacements]] + find = '''puppy''' + replace = '''doggy''' + count = 1 + "#, + )?; + + prole + .cd_cmd("my-repo/main") + .args(["add", "-b", "puppypuppypuppy"]) + .status_checked() + .unwrap(); + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("doggypuppypuppy") + .branch("puppypuppypuppy") + .upstream("main"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/config_branch_replacements_default.rs b/tests/config_branch_replacements_default.rs new file mode 100644 index 0000000..bc302be --- /dev/null +++ b/tests/config_branch_replacements_default.rs @@ -0,0 +1,28 @@ +use command_error::CommandExt; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn config_branch_replacements_default() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_worktree_repo("my-repo")?; + + prole + .cd_cmd("my-repo/main") + .args(["add", "-b", "doggy/puppy"]) + .status_checked() + .unwrap(); + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy") + .branch("doggy/puppy") + .upstream("main"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/config_branch_replacements_multiple.rs b/tests/config_branch_replacements_multiple.rs new file mode 100644 index 0000000..f30faeb --- /dev/null +++ b/tests/config_branch_replacements_multiple.rs @@ -0,0 +1,39 @@ +use command_error::CommandExt; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn config_branch_replacements_multiple() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_worktree_repo("my-repo")?; + prole.write_config( + r#" + [[branch_replacements]] + find = '''puppy''' + replace = '''doggy''' + + [[branch_replacements]] + find = '''doggy''' + replace = '''cutie''' + "#, + )?; + + prole + .cd_cmd("my-repo/main") + .args(["add", "-b", "silly-puppy"]) + .status_checked() + .unwrap(); + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("silly-cutie") + .branch("silly-puppy") + .upstream("main"), + ]) + .assert(); + + Ok(()) +}