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    /// Get whether to include gone-upstream worktrees as prune candidates by default.
212    ///
213    /// Precedence: CLI override > workon.pruneGone config > false
214    ///
215    /// When true, `prune` treats worktrees with a gone upstream tracking branch as
216    /// eligible for removal without requiring `--gone`. Equivalent to always passing
217    /// `--gone`.
218    pub fn prune_gone(&self, cli_override: Option<bool>) -> Result<bool> {
219        if let Some(override_val) = cli_override {
220            return Ok(override_val);
221        }
222        let config = self.repo.config()?;
223        match config.get_bool("workon.pruneGone") {
224            Ok(val) => Ok(val),
225            Err(_) => Ok(false),
226        }
227    }
228
229    /// Get whether to run a prune-fetch before evaluating gone-upstream status.
230    ///
231    /// Precedence: CLI override > workon.pruneFetch config > false
232    ///
233    /// When true, `prune` fetches from all remotes tracked by worktree branches
234    /// (with `--prune`, deleting stale remote-tracking refs) before evaluating
235    /// gone-upstream status. This makes `--gone` accurate even when local refs
236    /// are stale. Equivalent to always passing `--fetch`.
237    pub fn prune_fetch(&self, cli_override: Option<bool>) -> Result<bool> {
238        if let Some(override_val) = cli_override {
239            return Ok(override_val);
240        }
241        let config = self.repo.config()?;
242        match config.get_bool("workon.pruneFetch") {
243            Ok(val) => Ok(val),
244            Err(_) => Ok(false),
245        }
246    }
247
248    /// Check if a given branch name is protected from pruning.
249    ///
250    /// Returns true if the branch name matches any of the protected patterns.
251    pub fn is_protected(&self, branch_name: &str) -> bool {
252        let patterns = match self.prune_protected_branches() {
253            Ok(p) => p,
254            Err(_) => return false,
255        };
256        // Same logic as prune command
257        for pattern in patterns {
258            if pattern == branch_name {
259                return true;
260            }
261            if pattern == "*" {
262                return true;
263            }
264            if let Some(prefix) = pattern.strip_suffix("/*") {
265                if branch_name.starts_with(&format!("{}/", prefix)) {
266                    return true;
267                }
268            }
269        }
270        false
271    }
272
273    /// Get the timeout duration for hook execution.
274    ///
275    /// Reads from workon.hookTimeout config (integer seconds).
276    /// Default: 300 seconds (5 minutes). A value of 0 disables the timeout.
277    pub fn hook_timeout(&self) -> Result<Duration> {
278        let config = self.repo.config()?;
279        let seconds = match config.get_i64("workon.hookTimeout") {
280            Ok(val) => val.max(0) as u64,
281            Err(_) => 300,
282        };
283        Ok(Duration::from_secs(seconds))
284    }
285
286    /// Get the active stack model.
287    ///
288    /// Precedence: CLI override > workon.stackModel config > auto-detect.
289    ///
290    /// Auto-detection: returns `Graphite` when `gt` is on PATH and the repo has been
291    /// `gt init`-ed (`.graphite_repo_config` exists). Otherwise returns `None`.
292    ///
293    /// Accepted config values: `"graphite"`, `"none"`, `"auto"` (re-runs detection).
294    /// Anything else returns an error.
295    pub fn stack_model(&self, cli_override: Option<&str>) -> Result<StackModel> {
296        let raw = if let Some(val) = cli_override {
297            Some(val.to_string())
298        } else {
299            let config = self.repo.config()?;
300            config.get_string("workon.stackModel").ok()
301        };
302
303        match raw.as_deref() {
304            None | Some("auto") => Ok(StackModel::detect(self.repo)),
305            Some("none") => Ok(StackModel::None),
306            Some("graphite") => Ok(StackModel::Graphite),
307            Some(other) if matches!(other, "branchless" | "sapling" | "spr") => {
308                Err(StackError::UnsupportedModel {
309                    model: other.to_string(),
310                }
311                .into())
312            }
313            Some(other) => Err(StackError::UnknownModel {
314                value: other.to_string(),
315            }
316            .into()),
317        }
318    }
319
320    /// Get the worktree granularity for stacked diff workflows.
321    ///
322    /// Precedence: CLI override > workon.stackWorktreeGranularity config > `Stack`.
323    ///
324    /// Only `"stack"` is implemented in v1. `"diff"` (one worktree per branch) is planned.
325    pub fn stack_worktree_granularity(&self, cli_override: Option<&str>) -> Result<Granularity> {
326        let raw = if let Some(val) = cli_override {
327            Some(val.to_string())
328        } else {
329            let config = self.repo.config()?;
330            config.get_string("workon.stackWorktreeGranularity").ok()
331        };
332
333        match raw.as_deref() {
334            None | Some("stack") => Ok(Granularity::Stack),
335            Some("diff") => Err(StackError::UnsupportedGranularity.into()),
336            Some(other) => Err(StackError::UnknownGranularity {
337                value: other.to_string(),
338            }
339            .into()),
340        }
341    }
342
343    /// Get whether to automatically register new branches with Graphite after `workon new`.
344    ///
345    /// Precedence: CLI override > workon.gtAutoTrack config > `true`.
346    ///
347    /// When `true` and `stackModel == Graphite`, `workon new` invokes `gt track --parent <base>`
348    /// inside the new worktree so the branch appears in `gt log` / `gt sync`. Failures are
349    /// non-fatal warnings.
350    pub fn gt_auto_track(&self, cli_override: Option<bool>) -> Result<bool> {
351        if let Some(val) = cli_override {
352            return Ok(val);
353        }
354        let config = self.repo.config()?;
355        match config.get_bool("workon.gtAutoTrack") {
356            Ok(val) => Ok(val),
357            Err(_) => Ok(true),
358        }
359    }
360
361    /// Helper to read multi-value config entries.
362    ///
363    /// Returns an empty Vec if the key doesn't exist.
364    fn read_multivar(&self, key: &str) -> Result<Vec<String>> {
365        let config = self.repo.config()?;
366        let mut values = Vec::new();
367
368        // Key doesn't exist, return empty vec
369        if let Ok(mut entries) = config.multivar(key, None) {
370            while let Some(entry) = entries.next() {
371                let entry = entry?;
372                if let Ok(value) = entry.value() {
373                    values.push(value.to_string());
374                }
375            }
376        }
377
378        Ok(values)
379    }
380}