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}