1use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum WorktreeMode {
10 Local,
12 Global,
14}
15
16impl Default for WorktreeMode {
17 fn default() -> Self {
18 Self::Local
19 }
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct WorktreeConfig {
25 #[serde(default)]
27 pub mode: WorktreeMode,
28
29 pub base_dir: PathBuf,
33
34 pub prefix: String,
36
37 pub auto_gitignore: bool,
39
40 pub default_editor: String,
42
43 pub cleanup: WorktreeCleanupConfig,
45
46 pub merge_detection: WorktreeMergeDetectionConfig,
48
49 pub status: WorktreeStatusConfig,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct WorktreeCleanupConfig {
56 pub age_threshold_hours: u64,
58
59 pub verify_remote: bool,
61
62 pub auto_delete_branch: bool,
64
65 pub require_confirmation: bool,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct WorktreeMergeDetectionConfig {
72 pub use_github_cli: bool,
74
75 pub methods: Vec<String>,
77
78 pub main_branches: Vec<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct WorktreeStatusConfig {
85 pub show_files: bool,
87
88 pub max_files_shown: usize,
90
91 pub show_commit_messages: bool,
93
94 pub max_commits_shown: usize,
96}
97
98impl Default for WorktreeConfig {
99 fn default() -> Self {
100 Self {
101 mode: WorktreeMode::default(),
102 base_dir: PathBuf::from(".worktrees"),
103 prefix: "vibe-ws/".to_string(),
104 auto_gitignore: true,
105 default_editor: "code".to_string(),
106 cleanup: WorktreeCleanupConfig::default(),
107 merge_detection: WorktreeMergeDetectionConfig::default(),
108 status: WorktreeStatusConfig::default(),
109 }
110 }
111}
112
113impl Default for WorktreeCleanupConfig {
114 fn default() -> Self {
115 Self {
116 age_threshold_hours: 24,
117 verify_remote: true,
118 auto_delete_branch: false,
119 require_confirmation: true,
120 }
121 }
122}
123
124impl Default for WorktreeMergeDetectionConfig {
125 fn default() -> Self {
126 Self {
127 use_github_cli: true,
128 methods: vec![
129 "standard".to_string(),
130 "squash".to_string(),
131 "github_pr".to_string(),
132 "file_content".to_string(),
133 ],
134 main_branches: vec!["main".to_string(), "master".to_string()],
135 }
136 }
137}
138
139impl Default for WorktreeStatusConfig {
140 fn default() -> Self {
141 Self {
142 show_files: true,
143 max_files_shown: 10,
144 show_commit_messages: true,
145 max_commits_shown: 5,
146 }
147 }
148}
149
150impl WorktreeConfig {
151 pub fn get_resolved_base_dir(&self, repo_root: Option<&std::path::Path>) -> PathBuf {
153 match self.mode {
154 WorktreeMode::Local => {
155 if self.base_dir.is_absolute() {
156 self.base_dir.clone()
157 } else if let Some(root) = repo_root {
158 root.join(&self.base_dir)
159 } else {
160 self.base_dir.clone() }
162 }
163 WorktreeMode::Global => {
164 if self.base_dir.is_absolute() {
165 self.base_dir.clone()
166 } else {
167 if let Some(home) = dirs::home_dir() {
169 home.join(".toolprint")
170 .join("vibe-workspace")
171 .join("worktrees")
172 } else {
173 std::env::temp_dir().join("vibe-worktrees")
174 }
175 }
176 }
177 }
178 }
179
180 pub fn from_env() -> Self {
182 let mut config = Self::default();
183
184 if let Ok(mode) = std::env::var("VIBE_WORKTREE_MODE") {
185 config.mode = match mode.to_lowercase().as_str() {
186 "global" => WorktreeMode::Global,
187 "local" => WorktreeMode::Local,
188 _ => WorktreeMode::Local,
189 };
190 }
191
192 if let Ok(base_dir) = std::env::var("VIBE_WORKTREE_BASE") {
193 config.base_dir = PathBuf::from(base_dir);
194 }
195
196 if let Ok(prefix) = std::env::var("VIBE_WORKTREE_PREFIX") {
197 config.prefix = prefix;
198 }
199
200 if let Ok(editor) = std::env::var("VIBE_WORKTREE_EDITOR") {
201 config.default_editor = editor;
202 }
203
204 config
205 }
206
207 pub fn load_with_overrides() -> Result<Self, String> {
209 let mut config = Self::from_env();
210
211 if let Ok(auto_gitignore) = std::env::var("VIBE_WORKTREE_AUTO_GITIGNORE") {
213 config.auto_gitignore = auto_gitignore.parse().unwrap_or(config.auto_gitignore);
214 }
215
216 if let Ok(age_threshold) = std::env::var("VIBE_WORKTREE_AGE_THRESHOLD") {
218 if let Ok(hours) = age_threshold.parse::<u64>() {
219 config.cleanup.age_threshold_hours = hours;
220 }
221 }
222
223 if let Ok(verify_remote) = std::env::var("VIBE_WORKTREE_VERIFY_REMOTE") {
224 config.cleanup.verify_remote = verify_remote
225 .parse()
226 .unwrap_or(config.cleanup.verify_remote);
227 }
228
229 if let Ok(auto_delete) = std::env::var("VIBE_WORKTREE_AUTO_DELETE_BRANCH") {
230 config.cleanup.auto_delete_branch = auto_delete
231 .parse()
232 .unwrap_or(config.cleanup.auto_delete_branch);
233 }
234
235 if let Ok(use_github) = std::env::var("VIBE_WORKTREE_USE_GITHUB_CLI") {
237 config.merge_detection.use_github_cli = use_github
238 .parse()
239 .unwrap_or(config.merge_detection.use_github_cli);
240 }
241
242 if let Ok(methods) = std::env::var("VIBE_WORKTREE_MERGE_METHODS") {
243 config.merge_detection.methods =
244 methods.split(',').map(|s| s.trim().to_string()).collect();
245 }
246
247 if let Ok(main_branches) = std::env::var("VIBE_WORKTREE_MAIN_BRANCHES") {
248 config.merge_detection.main_branches = main_branches
249 .split(',')
250 .map(|s| s.trim().to_string())
251 .collect();
252 }
253
254 if let Ok(show_files) = std::env::var("VIBE_WORKTREE_SHOW_FILES") {
256 config.status.show_files = show_files.parse().unwrap_or(config.status.show_files);
257 }
258
259 if let Ok(max_files) = std::env::var("VIBE_WORKTREE_MAX_FILES_SHOWN") {
260 if let Ok(count) = max_files.parse::<usize>() {
261 config.status.max_files_shown = count;
262 }
263 }
264
265 config.validate()?;
267 Ok(config)
268 }
269
270 pub fn validate(&self) -> Result<(), String> {
272 if self.prefix.is_empty() {
274 return Err("Worktree prefix cannot be empty".to_string());
275 }
276
277 if self.base_dir.to_string_lossy().is_empty() {
278 return Err("Base directory cannot be empty".to_string());
279 }
280
281 if self.cleanup.age_threshold_hours == 0 {
282 return Err("Age threshold must be greater than 0".to_string());
283 }
284
285 if self.prefix.contains("..") || self.prefix.contains('\0') {
287 return Err("Worktree prefix contains invalid characters".to_string());
288 }
289
290 if self.prefix.len() > 50 {
291 return Err("Worktree prefix is too long (max 50 characters)".to_string());
292 }
293
294 if self.merge_detection.methods.is_empty() {
295 return Err("At least one merge detection method must be configured".to_string());
296 }
297
298 if self.merge_detection.main_branches.is_empty() {
299 return Err("At least one main branch must be configured".to_string());
300 }
301
302 if self.cleanup.age_threshold_hours > 24 * 365 {
303 return Err("Age threshold is too high (max 1 year)".to_string());
304 }
305
306 if self.status.max_files_shown == 0 || self.status.max_files_shown > 100 {
307 return Err("Max files shown must be between 1 and 100".to_string());
308 }
309
310 if self.status.max_commits_shown == 0 || self.status.max_commits_shown > 50 {
311 return Err("Max commits shown must be between 1 and 50".to_string());
312 }
313
314 if self.default_editor.is_empty() {
316 return Err("Default editor cannot be empty".to_string());
317 }
318
319 if self.base_dir.to_string_lossy().trim().is_empty() {
321 return Err("Base directory cannot be empty or whitespace".to_string());
322 }
323
324 Ok(())
325 }
326
327 pub fn get_help_text() -> &'static str {
329 r#"Worktree Configuration Options:
330
331Environment Variables:
332 VIBE_WORKTREE_MODE Storage mode: local or global (default: local)
333 VIBE_WORKTREE_BASE Base directory for worktrees (default: .worktrees)
334 VIBE_WORKTREE_PREFIX Branch prefix for managed worktrees (default: vibe-ws/)
335 VIBE_WORKTREE_EDITOR Default editor command (default: code)
336 VIBE_WORKTREE_AUTO_GITIGNORE Auto-manage .gitignore (default: true)
337 VIBE_WORKTREE_AGE_THRESHOLD Minimum age in hours for cleanup (default: 24)
338 VIBE_WORKTREE_VERIFY_REMOTE Verify remote branch before cleanup (default: true)
339 VIBE_WORKTREE_AUTO_DELETE_BRANCH Auto-delete branch after cleanup (default: false)
340 VIBE_WORKTREE_USE_GITHUB_CLI Use GitHub CLI for merge detection (default: true)
341 VIBE_WORKTREE_MERGE_METHODS Comma-separated merge detection methods
342 VIBE_WORKTREE_MAIN_BRANCHES Comma-separated main branch names
343 VIBE_WORKTREE_SHOW_FILES Show file lists in status (default: true)
344 VIBE_WORKTREE_MAX_FILES_SHOWN Max files to show in status (default: 10)
345
346Configuration File:
347 The worktree configuration is stored in ~/.toolprint/vibe-workspace/config.yaml
348 under the 'worktree' section. Repository-specific overrides can be configured
349 in the 'repositories[].worktree_config' section.
350"#
351 }
352
353 pub fn sample_config_yaml() -> String {
355 serde_yaml::to_string(&Self::default())
356 .unwrap_or_else(|_| "# Error generating sample config".to_string())
357 }
358}
359
360pub const WORKTREE_ENV_VARS: &[(&str, &str, &str)] = &[
362 (
363 "VIBE_WORKTREE_MODE",
364 "local",
365 "Worktree storage mode (local or global)",
366 ),
367 (
368 "VIBE_WORKTREE_BASE",
369 ".worktrees",
370 "Base directory for worktrees",
371 ),
372 (
373 "VIBE_WORKTREE_PREFIX",
374 "vibe-ws/",
375 "Branch prefix for managed worktrees",
376 ),
377 ("VIBE_WORKTREE_EDITOR", "code", "Default editor command"),
378 (
379 "VIBE_WORKTREE_AUTO_GITIGNORE",
380 "true",
381 "Auto-manage .gitignore entries",
382 ),
383 (
384 "VIBE_WORKTREE_AGE_THRESHOLD",
385 "24",
386 "Minimum age in hours for cleanup eligibility",
387 ),
388 (
389 "VIBE_WORKTREE_VERIFY_REMOTE",
390 "true",
391 "Verify remote branch exists before cleanup",
392 ),
393 (
394 "VIBE_WORKTREE_AUTO_DELETE_BRANCH",
395 "false",
396 "Auto-delete branch after worktree removal",
397 ),
398 (
399 "VIBE_WORKTREE_USE_GITHUB_CLI",
400 "true",
401 "Use GitHub CLI for merge detection",
402 ),
403 (
404 "VIBE_WORKTREE_MERGE_METHODS",
405 "standard,squash,github_pr",
406 "Merge detection methods",
407 ),
408 (
409 "VIBE_WORKTREE_MAIN_BRANCHES",
410 "main,master",
411 "Main branches for merge detection",
412 ),
413 (
414 "VIBE_WORKTREE_SHOW_FILES",
415 "true",
416 "Show file lists in status output",
417 ),
418 (
419 "VIBE_WORKTREE_MAX_FILES_SHOWN",
420 "10",
421 "Maximum files to show in status",
422 ),
423];
424
425#[cfg(test)]
426mod config_tests {
427 use super::*;
428 use std::env;
429
430 #[test]
431 fn test_enhanced_validation() {
432 let mut config = WorktreeConfig::default();
433
434 let result = config.validate();
436 if let Err(err) = &result {
437 eprintln!("Default config validation failed: {}", err);
438 }
439 assert!(result.is_ok());
440
441 config.prefix = "..".to_string();
443 assert!(config.validate().is_err());
444 assert!(config
445 .validate()
446 .unwrap_err()
447 .contains("invalid characters"));
448
449 config.prefix = "x".repeat(60); assert!(config.validate().is_err());
451 assert!(config.validate().unwrap_err().contains("too long"));
452
453 config.prefix = "test/".to_string();
455 assert!(config.validate().is_ok());
456
457 config.merge_detection.methods.clear();
459 assert!(config.validate().is_err());
460 assert!(config
461 .validate()
462 .unwrap_err()
463 .contains("merge detection method"));
464
465 config.merge_detection.methods = vec!["standard".to_string()];
467 config.merge_detection.main_branches.clear();
468 assert!(config.validate().is_err());
469 assert!(config.validate().unwrap_err().contains("main branch"));
470
471 config.merge_detection.main_branches = vec!["main".to_string()];
473 config.status.max_files_shown = 0;
474 assert!(config.validate().is_err());
475 assert!(config.validate().unwrap_err().contains("Max files shown"));
476
477 config.status.max_files_shown = 200;
478 assert!(config.validate().is_err());
479 assert!(config.validate().unwrap_err().contains("Max files shown"));
480 }
481
482 #[test]
483 fn test_load_with_overrides() {
484 env::set_var("VIBE_WORKTREE_PREFIX", "env-prefix/");
486 env::set_var("VIBE_WORKTREE_BASE", "/tmp/worktrees");
487 env::set_var("VIBE_WORKTREE_AGE_THRESHOLD", "48");
488 env::set_var("VIBE_WORKTREE_AUTO_GITIGNORE", "false");
489 env::set_var("VIBE_WORKTREE_MERGE_METHODS", "standard,custom");
490 env::set_var("VIBE_WORKTREE_MAIN_BRANCHES", "main,dev");
491 env::set_var("VIBE_WORKTREE_MAX_FILES_SHOWN", "20");
492
493 let config = WorktreeConfig::load_with_overrides().unwrap();
494
495 assert_eq!(config.prefix, "env-prefix/");
496 assert_eq!(config.base_dir, PathBuf::from("/tmp/worktrees"));
497 assert_eq!(config.cleanup.age_threshold_hours, 48);
498 assert_eq!(config.auto_gitignore, false);
499 assert_eq!(config.merge_detection.methods, vec!["standard", "custom"]);
500 assert_eq!(config.merge_detection.main_branches, vec!["main", "dev"]);
501 assert_eq!(config.status.max_files_shown, 20);
502
503 env::remove_var("VIBE_WORKTREE_PREFIX");
505 env::remove_var("VIBE_WORKTREE_BASE");
506 env::remove_var("VIBE_WORKTREE_AGE_THRESHOLD");
507 env::remove_var("VIBE_WORKTREE_AUTO_GITIGNORE");
508 env::remove_var("VIBE_WORKTREE_MERGE_METHODS");
509 env::remove_var("VIBE_WORKTREE_MAIN_BRANCHES");
510 env::remove_var("VIBE_WORKTREE_MAX_FILES_SHOWN");
511 }
512
513 #[test]
514 fn test_sample_config_generation() {
515 let yaml = WorktreeConfig::sample_config_yaml();
516 assert!(!yaml.is_empty());
517 assert!(yaml.contains("prefix"));
518 assert!(yaml.contains("base_dir"));
519 }
520
521 #[test]
522 fn test_help_text() {
523 let help = WorktreeConfig::get_help_text();
524 assert!(!help.is_empty());
525 assert!(help.contains("Environment Variables"));
526 assert!(help.contains("VIBE_WORKTREE_PREFIX"));
527 assert!(help.contains("Configuration File"));
528 }
529
530 #[test]
531 fn test_environment_variable_documentation() {
532 for (env_var, default_value, description) in WORKTREE_ENV_VARS {
534 assert!(!env_var.is_empty());
535 assert!(!default_value.is_empty());
536 assert!(!description.is_empty());
537 assert!(env_var.starts_with("VIBE_WORKTREE_"));
538 }
539
540 assert!(WORKTREE_ENV_VARS.len() > 10); }
542
543 #[test]
544 fn test_worktree_mode() {
545 let config = WorktreeConfig::default();
547 assert_eq!(config.mode, WorktreeMode::Local);
548
549 let local_config = WorktreeConfig {
551 mode: WorktreeMode::Local,
552 ..Default::default()
553 };
554
555 let global_config = WorktreeConfig {
556 mode: WorktreeMode::Global,
557 ..Default::default()
558 };
559
560 let local_yaml = serde_yaml::to_string(&local_config).unwrap();
562 let global_yaml = serde_yaml::to_string(&global_config).unwrap();
563
564 assert!(local_yaml.contains("mode: local"));
565 assert!(global_yaml.contains("mode: global"));
566
567 let deserialized_local: WorktreeConfig = serde_yaml::from_str(&local_yaml).unwrap();
569 let deserialized_global: WorktreeConfig = serde_yaml::from_str(&global_yaml).unwrap();
570
571 assert_eq!(deserialized_local.mode, WorktreeMode::Local);
572 assert_eq!(deserialized_global.mode, WorktreeMode::Global);
573 }
574
575 #[test]
576 fn test_environment_variable_mode_override() {
577 use std::env;
578
579 env::set_var("VIBE_WORKTREE_MODE", "local");
581 let config = WorktreeConfig::from_env();
582 assert_eq!(config.mode, WorktreeMode::Local);
583
584 env::set_var("VIBE_WORKTREE_MODE", "global");
586 let config = WorktreeConfig::from_env();
587 assert_eq!(config.mode, WorktreeMode::Global);
588
589 env::set_var("VIBE_WORKTREE_MODE", "invalid");
591 let config = WorktreeConfig::from_env();
592 assert_eq!(config.mode, WorktreeMode::Local);
593
594 env::remove_var("VIBE_WORKTREE_MODE");
596 }
597}