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.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}