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}