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}