Skip to main content

workon/
config.rs

1//! Configuration system for git-workon.
2//!
3//! This module provides the foundation for all git-workon configuration through git's
4//! native config system (.git/config, ~/.gitconfig, /etc/gitconfig).
5//!
6//! **Multi-value support**: Git config naturally supports multi-value entries, perfect for
7//! patterns, hooks, and other list-based configuration:
8//!
9//! ```bash
10//! git config --add workon.copyPattern '.env*'
11//! git config --add workon.copyPattern '.vscode/'
12//! git config --get-all workon.copyPattern
13//! ```
14//!
15//! **Precedence**: CLI arguments > local config (.git/config) > global config (~/.gitconfig) > defaults
16//!
17//! ## Configuration Keys
18//!
19//! This module supports the following configuration keys:
20//!
21//! - **workon.defaultBranch** - Default base branch for new worktrees (string, default: None)
22//! - **workon.postCreateHook** - Commands to run after worktree creation (multi-value, default: [])
23//! - **workon.copyPattern** - Glob patterns for automatic file copying (multi-value, default: [])
24//! - **workon.copyExclude** - Patterns to exclude from copying (multi-value, default: [])
25//! - **workon.autoCopyUntracked** - Enable automatic file copying in new command (bool, default: false)
26//! - **workon.pruneProtectedBranches** - Branches protected from pruning (multi-value, default: [])
27//! - **workon.prFormat** - Format string for PR-based worktree names (string, default: "pr-{number}")
28//! - **workon.hookTimeout** - Timeout in seconds for hook execution (integer, default: 300, 0 = no timeout)
29//!
30//! ## Example Configuration
31//!
32//! ```gitconfig
33//! # Global config (~/.gitconfig) - personal preferences
34//! [workon]
35//!   defaultBranch = main
36//!
37//! # Per-repo config (.git/config) - project-specific
38//! [workon]
39//!   postCreateHook = npm install
40//!   postCreateHook = cp ../.env .env
41//!   copyPattern = .env.local
42//!   copyPattern = .vscode/
43//!   copyExclude = .env.production
44//!   autoCopyUntracked = true
45//!   pruneProtectedBranches = main
46//!   pruneProtectedBranches = develop
47//!   pruneProtectedBranches = release/*
48//!   prFormat = pr-{number}
49//! ```
50
51use std::time::Duration;
52
53use git2::Repository;
54
55use crate::error::{ConfigError, Result};
56
57/// Configuration reader for workon settings stored in git config.
58///
59/// This struct provides access to workon-specific configuration keys,
60/// handling precedence between CLI arguments, local config, and global config.
61pub struct WorkonConfig<'repo> {
62    repo: &'repo Repository,
63}
64
65impl<'repo> WorkonConfig<'repo> {
66    /// Create a new config reader for the given repository.
67    ///
68    /// This opens the repository's git config, which automatically handles
69    /// precedence: local config (.git/config) > global config (~/.gitconfig) > system config.
70    pub fn new(repo: &'repo Repository) -> Result<Self> {
71        Ok(Self { repo })
72    }
73
74    /// Get the default branch to use when creating new worktrees.
75    ///
76    /// Precedence: CLI override > workon.defaultBranch config > None
77    ///
78    /// Returns None if not configured. Callers can fall back to init.defaultBranch or "main".
79    pub fn default_branch(&self, cli_override: Option<&str>) -> Result<Option<String>> {
80        // CLI takes precedence
81        if let Some(override_val) = cli_override {
82            return Ok(Some(override_val.to_string()));
83        }
84
85        // Read from git config
86        let config = self.repo.config()?;
87        match config.get_string("workon.defaultBranch") {
88            Ok(val) => Ok(Some(val)),
89            Err(_) => Ok(None), // Not configured
90        }
91    }
92
93    /// Get the format string for PR-based worktree names.
94    ///
95    /// Precedence: CLI override > workon.prFormat config > "pr-{number}"
96    ///
97    /// The format string must contain `{number}` placeholder for the PR number.
98    /// Returns an error if the format is invalid.
99    pub fn pr_format(&self, cli_override: Option<&str>) -> Result<String> {
100        let format = if let Some(override_val) = cli_override {
101            override_val.to_string()
102        } else {
103            let config = self.repo.config()?;
104            config
105                .get_string("workon.prFormat")
106                .unwrap_or_else(|_| "pr-{number}".to_string())
107        };
108
109        // Validate format contains {number} placeholder
110        if !format.contains("{number}") {
111            return Err(ConfigError::InvalidPrFormat {
112                format: format.clone(),
113                reason: "Format must contain {number} placeholder".to_string(),
114            }
115            .into());
116        }
117
118        // Valid placeholders: {number}, {title}, {author}, {branch}
119        let valid_placeholders = ["{number}", "{title}", "{author}", "{branch}"];
120        let mut remaining = format.clone();
121        for placeholder in &valid_placeholders {
122            remaining = remaining.replace(placeholder, "");
123        }
124
125        // Check for invalid placeholders (anything still matching {.*})
126        if remaining.contains('{') {
127            return Err(ConfigError::InvalidPrFormat {
128                format: format.clone(),
129                reason: format!(
130                    "Invalid placeholder found. Valid placeholders: {}",
131                    valid_placeholders.join(", ")
132                ),
133            }
134            .into());
135        }
136
137        Ok(format)
138    }
139
140    /// Get the list of post-create hook commands to run after worktree creation.
141    ///
142    /// Reads from multi-value workon.postCreateHook config.
143    /// Returns empty Vec if not configured.
144    pub fn post_create_hooks(&self) -> Result<Vec<String>> {
145        self.read_multivar("workon.postCreateHook")
146    }
147
148    /// Get the list of glob patterns for files to copy between worktrees.
149    ///
150    /// Reads from multi-value workon.copyPattern config.
151    /// Returns empty Vec if not configured.
152    pub fn copy_patterns(&self) -> Result<Vec<String>> {
153        self.read_multivar("workon.copyPattern")
154    }
155
156    /// Get the list of glob patterns for files to exclude from copying.
157    ///
158    /// Reads from multi-value workon.copyExclude config.
159    /// Returns empty Vec if not configured.
160    pub fn copy_excludes(&self) -> Result<Vec<String>> {
161        self.read_multivar("workon.copyExclude")
162    }
163
164    /// Get whether to include git-ignored files when copying untracked files.
165    ///
166    /// Precedence: CLI override > workon.copyIncludeIgnored config > false
167    ///
168    /// When enabled, files matching .gitignore (e.g., `.env.local`, `node_modules/`)
169    /// will also be included as copy candidates.
170    pub fn copy_include_ignored(&self, cli_override: Option<bool>) -> Result<bool> {
171        if let Some(override_val) = cli_override {
172            return Ok(override_val);
173        }
174
175        let config = self.repo.config()?;
176        match config.get_bool("workon.copyIncludeIgnored") {
177            Ok(val) => Ok(val),
178            Err(_) => Ok(false),
179        }
180    }
181
182    /// Get whether to automatically copy untracked files when creating new worktrees.
183    ///
184    /// Precedence: CLI override > workon.autoCopyUntracked config > false
185    ///
186    /// When enabled, files matching workon.copyPattern (excluding workon.copyExclude)
187    /// will be automatically copied from the base worktree to the new worktree.
188    pub fn auto_copy_untracked(&self, cli_override: Option<bool>) -> Result<bool> {
189        // CLI takes precedence
190        if let Some(override_val) = cli_override {
191            return Ok(override_val);
192        }
193
194        // Read from git config
195        let config = self.repo.config()?;
196        match config.get_bool("workon.autoCopyUntracked") {
197            Ok(val) => Ok(val),
198            Err(_) => Ok(false), // Default to false
199        }
200    }
201
202    /// Get the list of branch patterns to protect from pruning.
203    ///
204    /// Reads from multi-value workon.pruneProtectedBranches config.
205    /// Patterns support simple glob matching (* and ?).
206    /// Returns empty Vec if not configured.
207    pub fn prune_protected_branches(&self) -> Result<Vec<String>> {
208        self.read_multivar("workon.pruneProtectedBranches")
209    }
210
211    /// Check if a given branch name is protected from pruning.
212    ///
213    /// Returns true if the branch name matches any of the protected patterns.
214    pub fn is_protected(&self, branch_name: &str) -> bool {
215        let patterns = match self.prune_protected_branches() {
216            Ok(p) => p,
217            Err(_) => return false,
218        };
219        // Same logic as prune command
220        for pattern in patterns {
221            if pattern == branch_name {
222                return true;
223            }
224            if pattern == "*" {
225                return true;
226            }
227            if let Some(prefix) = pattern.strip_suffix("/*") {
228                if branch_name.starts_with(&format!("{}/", prefix)) {
229                    return true;
230                }
231            }
232        }
233        false
234    }
235
236    /// Get the timeout duration for hook execution.
237    ///
238    /// Reads from workon.hookTimeout config (integer seconds).
239    /// Default: 300 seconds (5 minutes). A value of 0 disables the timeout.
240    pub fn hook_timeout(&self) -> Result<Duration> {
241        let config = self.repo.config()?;
242        let seconds = match config.get_i64("workon.hookTimeout") {
243            Ok(val) => val.max(0) as u64,
244            Err(_) => 300,
245        };
246        Ok(Duration::from_secs(seconds))
247    }
248
249    /// Helper to read multi-value config entries.
250    ///
251    /// Returns an empty Vec if the key doesn't exist.
252    fn read_multivar(&self, key: &str) -> Result<Vec<String>> {
253        let config = self.repo.config()?;
254        let mut values = Vec::new();
255
256        // Key doesn't exist, return empty vec
257        if let Ok(mut entries) = config.multivar(key, None) {
258            while let Some(entry) = entries.next() {
259                let entry = entry?;
260                if let Some(value) = entry.value() {
261                    values.push(value.to_string());
262                }
263            }
264        }
265
266        Ok(values)
267    }
268}