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.autoCopy** - 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//! autoCopy = 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, StackError};
56use crate::stack::{Granularity, StackModel};
57
58/// Configuration reader for workon settings stored in git config.
59///
60/// This struct provides access to workon-specific configuration keys,
61/// handling precedence between CLI arguments, local config, and global config.
62pub struct WorkonConfig<'repo> {
63 repo: &'repo Repository,
64}
65
66impl<'repo> WorkonConfig<'repo> {
67 /// Create a new config reader for the given repository.
68 ///
69 /// This opens the repository's git config, which automatically handles
70 /// precedence: local config (.git/config) > global config (~/.gitconfig) > system config.
71 pub fn new(repo: &'repo Repository) -> Result<Self> {
72 Ok(Self { repo })
73 }
74
75 /// Get the default branch to use when creating new worktrees.
76 ///
77 /// Precedence: CLI override > workon.defaultBranch config > None
78 ///
79 /// Returns None if not configured. Callers can fall back to init.defaultBranch or "main".
80 pub fn default_branch(&self, cli_override: Option<&str>) -> Result<Option<String>> {
81 // CLI takes precedence
82 if let Some(override_val) = cli_override {
83 return Ok(Some(override_val.to_string()));
84 }
85
86 // Read from git config
87 let config = self.repo.config()?;
88 match config.get_string("workon.defaultBranch") {
89 Ok(val) => Ok(Some(val)),
90 Err(_) => Ok(None), // Not configured
91 }
92 }
93
94 /// Get the format string for PR-based worktree names.
95 ///
96 /// Precedence: CLI override > workon.prFormat config > "pr-{number}"
97 ///
98 /// The format string must contain `{number}` placeholder for the PR number.
99 /// Returns an error if the format is invalid.
100 pub fn pr_format(&self, cli_override: Option<&str>) -> Result<String> {
101 let format = if let Some(override_val) = cli_override {
102 override_val.to_string()
103 } else {
104 let config = self.repo.config()?;
105 config
106 .get_string("workon.prFormat")
107 .unwrap_or_else(|_| "pr-{number}".to_string())
108 };
109
110 // Validate format contains {number} placeholder
111 if !format.contains("{number}") {
112 return Err(ConfigError::InvalidPrFormat {
113 format: format.clone(),
114 reason: "Format must contain {number} placeholder".to_string(),
115 }
116 .into());
117 }
118
119 // Valid placeholders: {number}, {title}, {author}, {branch}
120 let valid_placeholders = ["{number}", "{title}", "{author}", "{branch}"];
121 let mut remaining = format.clone();
122 for placeholder in &valid_placeholders {
123 remaining = remaining.replace(placeholder, "");
124 }
125
126 // Check for invalid placeholders (anything still matching {.*})
127 if remaining.contains('{') {
128 return Err(ConfigError::InvalidPrFormat {
129 format: format.clone(),
130 reason: format!(
131 "Invalid placeholder found. Valid placeholders: {}",
132 valid_placeholders.join(", ")
133 ),
134 }
135 .into());
136 }
137
138 Ok(format)
139 }
140
141 /// Get the list of post-create hook commands to run after worktree creation.
142 ///
143 /// Reads from multi-value workon.postCreateHook config.
144 /// Returns empty Vec if not configured.
145 pub fn post_create_hooks(&self) -> Result<Vec<String>> {
146 self.read_multivar("workon.postCreateHook")
147 }
148
149 /// Get the list of glob patterns for files to copy between worktrees.
150 ///
151 /// Reads from multi-value workon.copyPattern config.
152 /// Returns empty Vec if not configured.
153 pub fn copy_patterns(&self) -> Result<Vec<String>> {
154 self.read_multivar("workon.copyPattern")
155 }
156
157 /// Get the list of glob patterns for files to exclude from copying.
158 ///
159 /// Reads from multi-value workon.copyExclude config.
160 /// Returns empty Vec if not configured.
161 pub fn copy_excludes(&self) -> Result<Vec<String>> {
162 self.read_multivar("workon.copyExclude")
163 }
164
165 /// Get whether to include git-ignored files when copying.
166 ///
167 /// Precedence: CLI override > workon.copyIncludeIgnored config > true
168 ///
169 /// Ignored files (e.g., `.env.local`, `node_modules/`) are included by default
170 /// since they are the primary use case for copying between worktrees.
171 /// Set `workon.copyIncludeIgnored = false` to opt out.
172 pub fn copy_include_ignored(&self, cli_override: Option<bool>) -> Result<bool> {
173 if let Some(override_val) = cli_override {
174 return Ok(override_val);
175 }
176
177 let config = self.repo.config()?;
178 match config.get_bool("workon.copyIncludeIgnored") {
179 Ok(val) => Ok(val),
180 Err(_) => Ok(true),
181 }
182 }
183
184 /// Get whether to automatically copy local files when creating new worktrees.
185 ///
186 /// Precedence: CLI override > workon.autoCopy config > false
187 ///
188 /// When enabled, files matching workon.copyPattern (excluding workon.copyExclude)
189 /// will be automatically copied from the base worktree to the new worktree.
190 pub fn auto_copy(&self, cli_override: Option<bool>) -> Result<bool> {
191 if let Some(override_val) = cli_override {
192 return Ok(override_val);
193 }
194
195 let config = self.repo.config()?;
196 match config.get_bool("workon.autoCopy") {
197 Ok(val) => Ok(val),
198 Err(_) => Ok(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 /// Get the active stack model.
250 ///
251 /// Precedence: CLI override > workon.stackModel config > auto-detect.
252 ///
253 /// Auto-detection: returns `Graphite` when `gt` is on PATH and the repo has been
254 /// `gt init`-ed (`.graphite_repo_config` exists). Otherwise returns `None`.
255 ///
256 /// Accepted config values: `"graphite"`, `"none"`, `"auto"` (re-runs detection).
257 /// Anything else returns an error.
258 pub fn stack_model(&self, cli_override: Option<&str>) -> Result<StackModel> {
259 let raw = if let Some(val) = cli_override {
260 Some(val.to_string())
261 } else {
262 let config = self.repo.config()?;
263 config.get_string("workon.stackModel").ok()
264 };
265
266 match raw.as_deref() {
267 None | Some("auto") => Ok(StackModel::detect(self.repo)),
268 Some("none") => Ok(StackModel::None),
269 Some("graphite") => Ok(StackModel::Graphite),
270 Some(other) if matches!(other, "branchless" | "sapling" | "spr") => {
271 Err(StackError::UnsupportedModel {
272 model: other.to_string(),
273 }
274 .into())
275 }
276 Some(other) => Err(StackError::UnknownModel {
277 value: other.to_string(),
278 }
279 .into()),
280 }
281 }
282
283 /// Get the worktree granularity for stacked diff workflows.
284 ///
285 /// Precedence: CLI override > workon.stackWorktreeGranularity config > `Stack`.
286 ///
287 /// Only `"stack"` is implemented in v1. `"diff"` (one worktree per branch) is planned.
288 pub fn stack_worktree_granularity(&self, cli_override: Option<&str>) -> Result<Granularity> {
289 let raw = if let Some(val) = cli_override {
290 Some(val.to_string())
291 } else {
292 let config = self.repo.config()?;
293 config.get_string("workon.stackWorktreeGranularity").ok()
294 };
295
296 match raw.as_deref() {
297 None | Some("stack") => Ok(Granularity::Stack),
298 Some("diff") => Err(StackError::UnsupportedGranularity.into()),
299 Some(other) => Err(StackError::UnknownGranularity {
300 value: other.to_string(),
301 }
302 .into()),
303 }
304 }
305
306 /// Get whether to automatically register new branches with Graphite after `workon new`.
307 ///
308 /// Precedence: CLI override > workon.gtAutoTrack config > `true`.
309 ///
310 /// When `true` and `stackModel == Graphite`, `workon new` invokes `gt track --parent <base>`
311 /// inside the new worktree so the branch appears in `gt log` / `gt sync`. Failures are
312 /// non-fatal warnings.
313 pub fn gt_auto_track(&self, cli_override: Option<bool>) -> Result<bool> {
314 if let Some(val) = cli_override {
315 return Ok(val);
316 }
317 let config = self.repo.config()?;
318 match config.get_bool("workon.gtAutoTrack") {
319 Ok(val) => Ok(val),
320 Err(_) => Ok(true),
321 }
322 }
323
324 /// Helper to read multi-value config entries.
325 ///
326 /// Returns an empty Vec if the key doesn't exist.
327 fn read_multivar(&self, key: &str) -> Result<Vec<String>> {
328 let config = self.repo.config()?;
329 let mut values = Vec::new();
330
331 // Key doesn't exist, return empty vec
332 if let Ok(mut entries) = config.multivar(key, None) {
333 while let Some(entry) = entries.next() {
334 let entry = entry?;
335 if let Some(value) = entry.value() {
336 values.push(value.to_string());
337 }
338 }
339 }
340
341 Ok(values)
342 }
343}