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.prFormat** - Format string for PR-based worktree names (string, default: "pr-{number}")
23//! - **workon.postCreateHook** - Commands to run after worktree creation (multi-value, default: [])
24//! - **workon.hookTimeout** - Timeout in seconds for hook execution (integer, default: 300, 0 = no timeout)
25//! - **workon.copyPattern** - Glob patterns for automatic file copying (multi-value, default: [])
26//! - **workon.copyExclude** - Patterns to exclude from copying (multi-value, default: [])
27//! - **workon.copyIncludeIgnored** - Include git-ignored files when copying (bool, default: true)
28//! - **workon.autoCopy** - Enable automatic file copying in new command (bool, default: false)
29//! - **workon.pruneProtectedBranches** - Branches protected from pruning (multi-value, default: [])
30//! - **workon.pruneGone** - Treat gone-upstream worktrees as prune candidates by default (bool, default: false)
31//! - **workon.pruneFetch** - Fetch from tracked remotes before evaluating gone status (bool, default: false)
32//! - **workon.stackModel** - Active stack model: "auto", "graphite", or "none" (string, default: "auto")
33//! - **workon.stackWorktreeGranularity** - Worktree granularity for stacked diffs: "stack" (string, default: "stack")
34//! - **workon.gtAutoTrack** - Auto-run `gt track` after `workon new` (bool, default: true)
35//!
36//! ## Example Configuration
37//!
38//! ```gitconfig
39//! # Global config (~/.gitconfig) - personal preferences
40//! [workon]
41//! defaultBranch = main
42//!
43//! # Per-repo config (.git/config) - project-specific
44//! [workon]
45//! postCreateHook = npm install
46//! postCreateHook = cp ../.env .env
47//! copyPattern = .env.local
48//! copyPattern = .vscode/
49//! copyExclude = .env.production
50//! autoCopy = true
51//! pruneProtectedBranches = main
52//! pruneProtectedBranches = develop
53//! pruneProtectedBranches = release/*
54//! prFormat = pr-{number}
55//! ```
56
57use std::time::Duration;
58
59use git2::Repository;
60
61use crate::error::{ConfigError, Result, StackError};
62use crate::stack::{Granularity, StackModel};
63
64/// Configuration reader for workon settings stored in git config.
65///
66/// This struct provides access to workon-specific configuration keys,
67/// handling precedence between CLI arguments, local config, and global config.
68pub struct WorkonConfig<'repo> {
69 repo: &'repo Repository,
70}
71
72impl<'repo> WorkonConfig<'repo> {
73 /// Create a new config reader for the given repository.
74 ///
75 /// This opens the repository's git config, which automatically handles
76 /// precedence: local config (.git/config) > global config (~/.gitconfig) > system config.
77 pub fn new(repo: &'repo Repository) -> Result<Self> {
78 Ok(Self { repo })
79 }
80
81 /// Get the default branch to use when creating new worktrees.
82 ///
83 /// Precedence: CLI override > workon.defaultBranch config > None
84 ///
85 /// Returns None if not configured. Callers can fall back to init.defaultBranch or "main".
86 pub fn default_branch(&self, cli_override: Option<&str>) -> Result<Option<String>> {
87 // CLI takes precedence
88 if let Some(override_val) = cli_override {
89 return Ok(Some(override_val.to_string()));
90 }
91
92 // Read from git config
93 let config = self.repo.config()?;
94 match config.get_string("workon.defaultBranch") {
95 Ok(val) => Ok(Some(val)),
96 Err(_) => Ok(None), // Not configured
97 }
98 }
99
100 /// Get the format string for PR-based worktree names.
101 ///
102 /// Precedence: CLI override > workon.prFormat config > "pr-{number}"
103 ///
104 /// The format string must contain `{number}` placeholder for the PR number.
105 /// Returns an error if the format is invalid.
106 pub fn pr_format(&self, cli_override: Option<&str>) -> Result<String> {
107 let format = if let Some(override_val) = cli_override {
108 override_val.to_string()
109 } else {
110 let config = self.repo.config()?;
111 config
112 .get_string("workon.prFormat")
113 .unwrap_or_else(|_| "pr-{number}".to_string())
114 };
115
116 // Validate format contains {number} placeholder
117 if !format.contains("{number}") {
118 return Err(ConfigError::InvalidPrFormat {
119 format: format.clone(),
120 reason: "Format must contain {number} placeholder".to_string(),
121 }
122 .into());
123 }
124
125 // Valid placeholders: {number}, {title}, {author}, {branch}
126 let valid_placeholders = ["{number}", "{title}", "{author}", "{branch}"];
127 let mut remaining = format.clone();
128 for placeholder in &valid_placeholders {
129 remaining = remaining.replace(placeholder, "");
130 }
131
132 // Check for invalid placeholders (anything still matching {.*})
133 if remaining.contains('{') {
134 return Err(ConfigError::InvalidPrFormat {
135 format: format.clone(),
136 reason: format!(
137 "Invalid placeholder found. Valid placeholders: {}",
138 valid_placeholders.join(", ")
139 ),
140 }
141 .into());
142 }
143
144 Ok(format)
145 }
146
147 /// Get the list of post-create hook commands to run after worktree creation.
148 ///
149 /// Reads from multi-value workon.postCreateHook config.
150 /// Returns empty Vec if not configured.
151 pub fn post_create_hooks(&self) -> Result<Vec<String>> {
152 self.read_multivar("workon.postCreateHook")
153 }
154
155 /// Get the list of glob patterns for files to copy between worktrees.
156 ///
157 /// Reads from multi-value workon.copyPattern config.
158 /// Returns empty Vec if not configured.
159 pub fn copy_patterns(&self) -> Result<Vec<String>> {
160 self.read_multivar("workon.copyPattern")
161 }
162
163 /// Get the list of glob patterns for files to exclude from copying.
164 ///
165 /// Reads from multi-value workon.copyExclude config.
166 /// Returns empty Vec if not configured.
167 pub fn copy_excludes(&self) -> Result<Vec<String>> {
168 self.read_multivar("workon.copyExclude")
169 }
170
171 /// Get whether to include git-ignored files when copying.
172 ///
173 /// Precedence: CLI override > workon.copyIncludeIgnored config > true
174 ///
175 /// Ignored files (e.g., `.env.local`, `node_modules/`) are included by default
176 /// since they are the primary use case for copying between worktrees.
177 /// Set `workon.copyIncludeIgnored = false` to opt out.
178 pub fn copy_include_ignored(&self, cli_override: Option<bool>) -> Result<bool> {
179 if let Some(override_val) = cli_override {
180 return Ok(override_val);
181 }
182
183 let config = self.repo.config()?;
184 match config.get_bool("workon.copyIncludeIgnored") {
185 Ok(val) => Ok(val),
186 Err(_) => Ok(true),
187 }
188 }
189
190 /// Get whether to automatically copy local files when creating new worktrees.
191 ///
192 /// Precedence: CLI override > workon.autoCopy config > false
193 ///
194 /// When enabled, files matching workon.copyPattern (excluding workon.copyExclude)
195 /// will be automatically copied from the base worktree to the new worktree.
196 pub fn auto_copy(&self, cli_override: Option<bool>) -> Result<bool> {
197 if let Some(override_val) = cli_override {
198 return Ok(override_val);
199 }
200
201 let config = self.repo.config()?;
202 match config.get_bool("workon.autoCopy") {
203 Ok(val) => Ok(val),
204 Err(_) => Ok(false),
205 }
206 }
207
208 /// Get the list of branch patterns to protect from pruning.
209 ///
210 /// Reads from multi-value workon.pruneProtectedBranches config.
211 /// Patterns support simple glob matching (* and ?).
212 /// Returns empty Vec if not configured.
213 pub fn prune_protected_branches(&self) -> Result<Vec<String>> {
214 self.read_multivar("workon.pruneProtectedBranches")
215 }
216
217 /// Get whether to include gone-upstream worktrees as prune candidates by default.
218 ///
219 /// Precedence: CLI override > workon.pruneGone config > false
220 ///
221 /// When true, `prune` treats worktrees with a gone upstream tracking branch as
222 /// eligible for removal without requiring `--gone`. Equivalent to always passing
223 /// `--gone`.
224 pub fn prune_gone(&self, cli_override: Option<bool>) -> Result<bool> {
225 if let Some(override_val) = cli_override {
226 return Ok(override_val);
227 }
228 let config = self.repo.config()?;
229 match config.get_bool("workon.pruneGone") {
230 Ok(val) => Ok(val),
231 Err(_) => Ok(false),
232 }
233 }
234
235 /// Get whether to run a prune-fetch before evaluating gone-upstream status.
236 ///
237 /// Precedence: CLI override > workon.pruneFetch config > false
238 ///
239 /// When true, `prune` fetches from all remotes tracked by worktree branches
240 /// (with `--prune`, deleting stale remote-tracking refs) before evaluating
241 /// gone-upstream status. This makes `--gone` accurate even when local refs
242 /// are stale. Equivalent to always passing `--fetch`.
243 pub fn prune_fetch(&self, cli_override: Option<bool>) -> Result<bool> {
244 if let Some(override_val) = cli_override {
245 return Ok(override_val);
246 }
247 let config = self.repo.config()?;
248 match config.get_bool("workon.pruneFetch") {
249 Ok(val) => Ok(val),
250 Err(_) => Ok(false),
251 }
252 }
253
254 /// Check if a given branch name is protected from pruning.
255 ///
256 /// Returns true if the branch name matches any of the protected patterns.
257 pub fn is_protected(&self, branch_name: &str) -> bool {
258 let patterns = match self.prune_protected_branches() {
259 Ok(p) => p,
260 Err(_) => return false,
261 };
262 // Same logic as prune command
263 for pattern in patterns {
264 if pattern == branch_name {
265 return true;
266 }
267 if pattern == "*" {
268 return true;
269 }
270 if let Some(prefix) = pattern.strip_suffix("/*") {
271 if branch_name.starts_with(&format!("{}/", prefix)) {
272 return true;
273 }
274 }
275 }
276 false
277 }
278
279 /// Get the timeout duration for hook execution.
280 ///
281 /// Reads from workon.hookTimeout config (integer seconds).
282 /// Default: 300 seconds (5 minutes). A value of 0 disables the timeout.
283 pub fn hook_timeout(&self) -> Result<Duration> {
284 let config = self.repo.config()?;
285 let seconds = match config.get_i64("workon.hookTimeout") {
286 Ok(val) => val.max(0) as u64,
287 Err(_) => 300,
288 };
289 Ok(Duration::from_secs(seconds))
290 }
291
292 /// Get the active stack model.
293 ///
294 /// Precedence: CLI override > workon.stackModel config > auto-detect.
295 ///
296 /// Auto-detection: returns `Graphite` when `gt` is on PATH and the repo has been
297 /// `gt init`-ed (`.graphite_repo_config` exists). Otherwise returns `None`.
298 ///
299 /// Accepted config values: `"graphite"`, `"none"`, `"auto"` (re-runs detection).
300 /// Anything else returns an error.
301 pub fn stack_model(&self, cli_override: Option<&str>) -> Result<StackModel> {
302 let raw = if let Some(val) = cli_override {
303 Some(val.to_string())
304 } else {
305 let config = self.repo.config()?;
306 config.get_string("workon.stackModel").ok()
307 };
308
309 match raw.as_deref() {
310 None | Some("auto") => Ok(StackModel::detect(self.repo)),
311 Some("none") => Ok(StackModel::None),
312 Some("graphite") => Ok(StackModel::Graphite),
313 Some(other) if matches!(other, "branchless" | "sapling" | "spr") => {
314 Err(StackError::UnsupportedModel {
315 model: other.to_string(),
316 }
317 .into())
318 }
319 Some(other) => Err(StackError::UnknownModel {
320 value: other.to_string(),
321 }
322 .into()),
323 }
324 }
325
326 /// Get the worktree granularity for stacked diff workflows.
327 ///
328 /// Precedence: CLI override > workon.stackWorktreeGranularity config > `Stack`.
329 ///
330 /// Only `"stack"` is implemented in v1. `"diff"` (one worktree per branch) is planned.
331 pub fn stack_worktree_granularity(&self, cli_override: Option<&str>) -> Result<Granularity> {
332 let raw = if let Some(val) = cli_override {
333 Some(val.to_string())
334 } else {
335 let config = self.repo.config()?;
336 config.get_string("workon.stackWorktreeGranularity").ok()
337 };
338
339 match raw.as_deref() {
340 None | Some("stack") => Ok(Granularity::Stack),
341 Some("diff") => Err(StackError::UnsupportedGranularity.into()),
342 Some(other) => Err(StackError::UnknownGranularity {
343 value: other.to_string(),
344 }
345 .into()),
346 }
347 }
348
349 /// Get whether to automatically register new branches with Graphite after `workon new`.
350 ///
351 /// Precedence: CLI override > workon.gtAutoTrack config > `true`.
352 ///
353 /// When `true` and `stackModel == Graphite`, `workon new` invokes `gt track --parent <base>`
354 /// inside the new worktree so the branch appears in `gt log` / `gt sync`. Failures are
355 /// non-fatal warnings.
356 pub fn gt_auto_track(&self, cli_override: Option<bool>) -> Result<bool> {
357 if let Some(val) = cli_override {
358 return Ok(val);
359 }
360 let config = self.repo.config()?;
361 match config.get_bool("workon.gtAutoTrack") {
362 Ok(val) => Ok(val),
363 Err(_) => Ok(true),
364 }
365 }
366
367 /// Helper to read multi-value config entries.
368 ///
369 /// Returns an empty Vec if the key doesn't exist.
370 fn read_multivar(&self, key: &str) -> Result<Vec<String>> {
371 let config = self.repo.config()?;
372 let mut values = Vec::new();
373
374 // Key doesn't exist, return empty vec
375 if let Ok(mut entries) = config.multivar(key, None) {
376 while let Some(entry) = entries.next() {
377 let entry = entry?;
378 if let Ok(value) = entry.value() {
379 values.push(value.to_string());
380 }
381 }
382 }
383
384 Ok(values)
385 }
386}