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