use serde::Deserialize;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CleanBranchMode {
Auto,
#[default]
Ask,
Never,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CopyIgnoredFilesConfig {
#[serde(default = "default_copy_enabled")]
pub enabled: bool,
#[serde(default = "default_copy_patterns")]
pub patterns: Vec<String>,
#[serde(default)]
pub exclude_patterns: Vec<String>,
}
impl Default for CopyIgnoredFilesConfig {
fn default() -> Self {
Self {
enabled: default_copy_enabled(),
patterns: default_copy_patterns(),
exclude_patterns: default_exclude_patterns(),
}
}
}
fn default_exclude_patterns() -> Vec<String> {
vec![".env.example".to_string(), ".env.sample".to_string()]
}
fn default_copy_enabled() -> bool {
true
}
fn default_copy_patterns() -> Vec<String> {
vec![
".env".to_string(),
".env.*".to_string(),
".env.local".to_string(),
".env.*.local".to_string(),
]
}
#[derive(Debug, Clone, Deserialize)]
pub struct CustomVirtualEnvPattern {
pub language: String,
pub patterns: Vec<String>,
#[serde(default)]
pub commands: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct VirtualEnvConfig {
#[serde(default)]
pub isolate_virtual_envs: Option<bool>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub custom_patterns: Vec<CustomVirtualEnvPattern>,
#[serde(default)]
pub max_file_size_mb: Option<i64>,
#[serde(default)]
pub max_dir_size_mb: Option<i64>,
#[serde(default)]
pub max_scan_depth: Option<i32>,
#[serde(default)]
pub copy_parallelism: Option<u32>,
#[serde(default)]
pub max_copy_size_mb: Option<i64>,
}
mod virtual_env_defaults {
pub const ISOLATE_VIRTUAL_ENVS: bool = false;
pub const MAX_FILE_SIZE_MB: i64 = 100;
pub const MAX_DIR_SIZE_MB: i64 = 500;
pub const MAX_SCAN_DEPTH: i32 = 5;
pub const COPY_PARALLELISM: u32 = 4;
}
impl VirtualEnvConfig {
pub fn should_isolate(&self) -> bool {
if let Some(isolate) = self.isolate_virtual_envs {
return isolate;
}
if let Some(ref mode) = self.mode {
return mode == "skip";
}
virtual_env_defaults::ISOLATE_VIRTUAL_ENVS
}
pub fn effective_max_file_size_mb(&self) -> i64 {
self.max_file_size_mb
.or(self.max_copy_size_mb)
.unwrap_or(virtual_env_defaults::MAX_FILE_SIZE_MB)
}
pub fn effective_max_dir_size_mb(&self) -> i64 {
self.max_dir_size_mb
.unwrap_or(virtual_env_defaults::MAX_DIR_SIZE_MB)
}
pub fn effective_max_scan_depth(&self) -> i32 {
self.max_scan_depth
.unwrap_or(virtual_env_defaults::MAX_SCAN_DEPTH)
}
pub fn effective_copy_parallelism(&self) -> u32 {
self.copy_parallelism
.unwrap_or(virtual_env_defaults::COPY_PARALLELISM)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HookConfig {
#[serde(default = "default_hook_enabled")]
pub enabled: bool,
#[serde(default)]
pub commands: Vec<String>,
}
impl Default for HookConfig {
fn default() -> Self {
Self {
enabled: true,
commands: Vec::new(),
}
}
}
fn default_hook_enabled() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
pub struct HooksConfig {
#[serde(default)]
pub post_create: Option<HookConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default = "default_worktree_base_path")]
pub worktree_base_path: String,
#[serde(default = "default_main_branches")]
pub main_branches: Vec<String>,
#[serde(default)]
pub clean_branch: CleanBranchMode,
#[serde(default)]
pub copy_ignored_files: Option<CopyIgnoredFilesConfig>,
#[serde(default)]
pub virtual_env_handling: Option<VirtualEnvConfig>,
#[serde(default)]
pub hooks: Option<HooksConfig>,
}
fn default_worktree_base_path() -> String {
"~/git-worktrees".to_string()
}
fn default_main_branches() -> Vec<String> {
vec![
"main".to_string(),
"master".to_string(),
"develop".to_string(),
]
}
impl Default for Config {
fn default() -> Self {
Self {
worktree_base_path: default_worktree_base_path(),
main_branches: default_main_branches(),
clean_branch: CleanBranchMode::default(),
copy_ignored_files: Some(CopyIgnoredFilesConfig::default()),
virtual_env_handling: Some(VirtualEnvConfig::default()),
hooks: Some(HooksConfig::default()),
}
}
}
impl Default for HooksConfig {
fn default() -> Self {
Self {
post_create: Some(HookConfig::default()),
}
}
}
impl Config {
pub fn expanded_worktree_base_path(&self) -> Option<std::path::PathBuf> {
let path = &self.worktree_base_path;
if path.starts_with("~/") {
dirs::home_dir().map(|home| home.join(&path[2..]))
} else if path == "~" {
dirs::home_dir()
} else {
Some(std::path::PathBuf::from(path))
}
}
pub fn is_main_branch(&self, branch: &str) -> bool {
self.main_branches.iter().any(|b| b == branch)
}
pub fn post_create_commands(&self) -> Option<&[String]> {
self.hooks
.as_ref()
.and_then(|h| h.post_create.as_ref())
.filter(|h| h.enabled && !h.commands.is_empty())
.map(|h| h.commands.as_slice())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.worktree_base_path, "~/git-worktrees");
assert_eq!(config.main_branches, vec!["main", "master", "develop"]);
assert_eq!(config.clean_branch, CleanBranchMode::Ask);
assert!(config.hooks.is_some());
let hooks = config.hooks.as_ref().unwrap();
assert!(hooks.post_create.is_some());
let post_create = hooks.post_create.as_ref().unwrap();
assert!(post_create.enabled);
assert!(post_create.commands.is_empty());
let copy_config = config.copy_ignored_files.as_ref().unwrap();
assert!(copy_config.enabled);
assert_eq!(
copy_config.exclude_patterns,
vec![".env.example", ".env.sample"]
);
}
#[test]
fn test_is_main_branch() {
let config = Config::default();
assert!(config.is_main_branch("main"));
assert!(config.is_main_branch("master"));
assert!(config.is_main_branch("develop"));
assert!(!config.is_main_branch("feature/test"));
}
#[test]
fn test_expanded_path() {
let config = Config::default();
let path = config.expanded_worktree_base_path();
assert!(path.is_some());
let path = path.unwrap();
assert!(!path.to_string_lossy().contains('~'));
}
#[test]
fn test_virtual_env_backward_compat() {
let config = VirtualEnvConfig {
mode: Some("skip".to_string()),
isolate_virtual_envs: None,
..Default::default()
};
assert!(config.should_isolate());
let config = VirtualEnvConfig {
mode: Some("ignore".to_string()),
isolate_virtual_envs: None,
..Default::default()
};
assert!(!config.should_isolate());
let config = VirtualEnvConfig {
mode: Some("skip".to_string()),
isolate_virtual_envs: Some(false),
..Default::default()
};
assert!(!config.should_isolate());
}
#[test]
fn test_clean_branch_mode_deserialize() {
let toml = r#"clean_branch = "auto""#;
#[derive(Deserialize)]
struct Test {
clean_branch: CleanBranchMode,
}
let t: Test = toml::from_str(toml).unwrap();
assert_eq!(t.clean_branch, CleanBranchMode::Auto);
}
}