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