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