Skip to content

Commit

Permalink
Add branch_replacements
Browse files Browse the repository at this point in the history
  • Loading branch information
9999years committed Oct 21, 2024
1 parent 7a5fd22 commit aca8b37
Show file tree
Hide file tree
Showing 14 changed files with 328 additions and 34 deletions.
32 changes: 32 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
10 changes: 5 additions & 5 deletions src/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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)? {
Expand Down
73 changes: 61 additions & 12 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,22 +88,19 @@ fn config_file_path(dirs: &BaseDirectories) -> miette::Result<Utf8PathBuf> {
/// 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<String>,

#[serde(default)]
default_branches: Vec<String>,

#[serde(default)]
copy_untracked: Option<bool>,

#[serde(default)]
enable_gh: Option<bool>,

#[serde(default)]
commands: Vec<ShellCommand>,
branch_replacements: Vec<BranchReplacement>,
}

impl ConfigFile {
Expand Down Expand Up @@ -134,8 +132,12 @@ impl ConfigFile {
self.enable_gh.unwrap_or(false)
}

pub fn commands(&self) -> Vec<ShellCommand> {
self.commands.clone()
pub fn commands(&self) -> &[ShellCommand] {
&self.commands
}

pub fn branch_replacements(&self) -> &[BranchReplacement] {
&self.branch_replacements
}
}

Expand Down Expand Up @@ -194,21 +196,68 @@ 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<usize>,
}

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<Regex, D::Error>
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::*;
use pretty_assertions::assert_eq;

#[test]
fn test_default_config_file_parse() {
let default_config = toml::from_str::<ConfigFile>(Config::DEFAULT).unwrap();
assert_eq!(
toml::from_str::<ConfigFile>(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::<ConfigFile>("").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()
}
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/final_component.rs
Original file line number Diff line number Diff line change
@@ -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,
}
}
30 changes: 23 additions & 7 deletions src/git/worktree/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::ffi::OsStr;
use std::fmt::Debug;
use std::process::Command;
Expand All @@ -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;
Expand Down Expand Up @@ -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()
}
}

Expand All @@ -267,7 +283,7 @@ where
pub fn path_for(&self, branch: &str) -> miette::Result<Utf8PathBuf> {
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.
Expand Down
2 changes: 1 addition & 1 deletion src/git/worktree/resolve_unique_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ where
self.worktree
.head
.branch()
.map(|branch| self.git.worktree().dirname_for(branch.branch_name()).into())
.map(|branch| self.git.worktree().dirname_for(branch.branch_name()))
.into_iter()
}

Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
23 changes: 15 additions & 8 deletions test-harness/src/repo_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<FxHashMap<_, _>>();

Expand Down
39 changes: 39 additions & 0 deletions tests/config_branch_replacements.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
39 changes: 39 additions & 0 deletions tests/config_branch_replacements_add_by_name.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
Loading

0 comments on commit aca8b37

Please sign in to comment.