cl-preset 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,478 @@
1
+ """
2
+ Git strategy models and management for cl-preset package.
3
+ """
4
+
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+
14
+ class GitStrategyType(str, Enum):
15
+ """Types of git strategies."""
16
+ DUAL_REPO = "dual-repo"
17
+ GITHUB_FLOW = "github-flow"
18
+ GIT_FLOW = "git-flow"
19
+ CUSTOM = "custom"
20
+
21
+
22
+ class BranchPattern(BaseModel):
23
+ """Branch naming pattern."""
24
+ feature: str = Field(default="feature/*", description="Feature branch pattern")
25
+ bugfix: str = Field(default="bugfix/*", description="Bugfix branch pattern")
26
+ release: str = Field(default="release/*", description="Release branch pattern")
27
+ hotfix: str = Field(default="hotfix/*", description="Hotfix branch pattern")
28
+
29
+
30
+ class MergeRule(BaseModel):
31
+ """Merge rules for branches."""
32
+ require_pr: bool = Field(default=True, description="Require pull request")
33
+ require_approval: bool = Field(default=True, description="Require approval")
34
+ allow_squash: bool = Field(default=True, description="Allow squash merge")
35
+ allow_merge_commit: bool = Field(default=False, description="Allow merge commit")
36
+ allow_rebase: bool = Field(default=False, description="Allow rebase merge")
37
+
38
+
39
+ class ProtectionRule(BaseModel):
40
+ """Branch protection rules."""
41
+ enabled: bool = Field(default=True, description="Enable protection")
42
+ require_status_checks: bool = Field(default=True, description="Require status checks")
43
+ require_branch_up_to_date: bool = Field(default=True, description="Require branch up to date")
44
+ required_check_names: list[str] = Field(
45
+ default_factory=list,
46
+ description="Required status check names"
47
+ )
48
+ allow_force_pushes: bool = Field(default=False, description="Allow force pushes")
49
+ allow_deletions: bool = Field(default=False, description="Allow branch deletions")
50
+
51
+
52
+ class GitStrategyConfig(BaseModel):
53
+ """Git strategy configuration."""
54
+ name: str = Field(..., description="Strategy name")
55
+ strategy_type: GitStrategyType = Field(..., description="Strategy type")
56
+ description: str = Field(..., description="Strategy description")
57
+
58
+ # Branch configuration
59
+ main_branch: str = Field(default="main", description="Main branch name")
60
+ develop_branch: str = Field(default="develop", description="Develop branch name (for git-flow)")
61
+ branch_patterns: BranchPattern = Field(
62
+ default_factory=BranchPattern,
63
+ description="Branch naming patterns"
64
+ )
65
+
66
+ # Merge and protection rules
67
+ merge_rules: MergeRule = Field(
68
+ default_factory=MergeRule,
69
+ description="Merge rules"
70
+ )
71
+ protection_rules: dict[str, ProtectionRule] = Field(
72
+ default_factory=dict,
73
+ description="Protection rules per branch"
74
+ )
75
+
76
+ # Dual-repo specific - use dict[str, Any] to allow flexible structures
77
+ dev_repository: dict[str, Any] | None = Field(
78
+ default=None,
79
+ description="Dev repository configuration (for dual-repo)"
80
+ )
81
+ release_repository: dict[str, Any] | None = Field(
82
+ default=None,
83
+ description="Release repository configuration (for dual-repo)"
84
+ )
85
+
86
+ model_config = ConfigDict(use_enum_values=True)
87
+
88
+
89
+ class GitStrategyManager:
90
+ """Manager for git strategy operations."""
91
+
92
+ def __init__(self, base_path: Path | None = None):
93
+ """
94
+ Initialize the git strategy manager.
95
+
96
+ Args:
97
+ base_path: Base path for strategy templates. Defaults to package templates
98
+ """
99
+ self.console = Console()
100
+ self.base_path = base_path or (Path(__file__).parent / "templates" / "git_strategies")
101
+ self.strategies_path = Path.home() / ".cl-preset" / "git-strategies"
102
+ self.strategies_path.mkdir(parents=True, exist_ok=True)
103
+
104
+ def get_builtin_strategies(self) -> dict[str, GitStrategyConfig]:
105
+ """Get built-in git strategy definitions."""
106
+ return {
107
+ "dual-repo": self._get_dual_repo_strategy(),
108
+ "github-flow": self._get_github_flow_strategy(),
109
+ "git-flow": self._get_git_flow_strategy(),
110
+ }
111
+
112
+ def _get_dual_repo_strategy(self) -> GitStrategyConfig:
113
+ """Get dual-repo strategy configuration."""
114
+ protection_rules = {
115
+ "main": ProtectionRule(
116
+ enabled=True,
117
+ require_status_checks=True,
118
+ require_branch_up_to_date=True,
119
+ required_check_names=["ci", "tests", "lint"],
120
+ allow_force_pushes=False,
121
+ allow_deletions=False,
122
+ ),
123
+ "release/*": ProtectionRule(
124
+ enabled=True,
125
+ require_status_checks=True,
126
+ require_branch_up_to_date=True,
127
+ required_check_names=["ci", "tests"],
128
+ allow_force_pushes=False,
129
+ allow_deletions=True,
130
+ ),
131
+ }
132
+
133
+ return GitStrategyConfig(
134
+ name="dual-repo",
135
+ strategy_type=GitStrategyType.DUAL_REPO,
136
+ description="Dev/Release repository separation workflow. Dev repo for active development, release repo for stable releases.",
137
+ main_branch="main",
138
+ develop_branch="develop",
139
+ branch_patterns=BranchPattern(
140
+ feature="feature/*",
141
+ bugfix="bugfix/*",
142
+ release="release/*",
143
+ hotfix="hotfix/*",
144
+ ),
145
+ merge_rules=MergeRule(
146
+ require_pr=True,
147
+ require_approval=True,
148
+ allow_squash=True,
149
+ allow_merge_commit=False,
150
+ allow_rebase=False,
151
+ ),
152
+ protection_rules=protection_rules,
153
+ dev_repository={
154
+ "remote": "origin",
155
+ "branch_pattern": "feature/*",
156
+ "description": "Active development, feature branches, experimental work",
157
+ },
158
+ release_repository={
159
+ "remote": "release",
160
+ "protected_branches": ["main"],
161
+ "description": "Production-ready code, stable releases, published documentation",
162
+ },
163
+ )
164
+
165
+ def _get_github_flow_strategy(self) -> GitStrategyConfig:
166
+ """Get GitHub flow strategy configuration."""
167
+ protection_rules = {
168
+ "main": ProtectionRule(
169
+ enabled=True,
170
+ require_status_checks=True,
171
+ require_branch_up_to_date=True,
172
+ required_check_names=["ci"],
173
+ allow_force_pushes=False,
174
+ allow_deletions=False,
175
+ ),
176
+ }
177
+
178
+ return GitStrategyConfig(
179
+ name="github-flow",
180
+ strategy_type=GitStrategyType.GITHUB_FLOW,
181
+ description="Simple branch-based workflow. Feature branches from main, PR for review, merge after approval.",
182
+ main_branch="main",
183
+ branch_patterns=BranchPattern(
184
+ feature="feature/*",
185
+ bugfix="bugfix/*",
186
+ release="",
187
+ hotfix="hotfix/*",
188
+ ),
189
+ merge_rules=MergeRule(
190
+ require_pr=True,
191
+ require_approval=True,
192
+ allow_squash=True,
193
+ allow_merge_commit=True,
194
+ allow_rebase=True,
195
+ ),
196
+ protection_rules=protection_rules,
197
+ )
198
+
199
+ def _get_git_flow_strategy(self) -> GitStrategyConfig:
200
+ """Get Git flow strategy configuration."""
201
+ protection_rules = {
202
+ "master": ProtectionRule(
203
+ enabled=True,
204
+ require_status_checks=True,
205
+ require_branch_up_to_date=True,
206
+ required_check_names=["ci", "tests"],
207
+ allow_force_pushes=False,
208
+ allow_deletions=False,
209
+ ),
210
+ "develop": ProtectionRule(
211
+ enabled=True,
212
+ require_status_checks=True,
213
+ require_branch_up_to_date=False,
214
+ required_check_names=["ci"],
215
+ allow_force_pushes=False,
216
+ allow_deletions=False,
217
+ ),
218
+ }
219
+
220
+ return GitStrategyConfig(
221
+ name="git-flow",
222
+ strategy_type=GitStrategyType.GIT_FLOW,
223
+ description="Feature/release/hotfix branching model. Master, develop, feature, release, hotfix branches with strict merge rules.",
224
+ main_branch="master",
225
+ develop_branch="develop",
226
+ branch_patterns=BranchPattern(
227
+ feature="feature/*",
228
+ bugfix="bugfix/*",
229
+ release="release/*",
230
+ hotfix="hotfix/*",
231
+ ),
232
+ merge_rules=MergeRule(
233
+ require_pr=True,
234
+ require_approval=True,
235
+ allow_squash=True,
236
+ allow_merge_commit=True,
237
+ allow_rebase=False,
238
+ ),
239
+ protection_rules=protection_rules,
240
+ )
241
+
242
+ def list_strategies(self, include_builtin: bool = True) -> list[dict]:
243
+ """
244
+ List available strategies.
245
+
246
+ Args:
247
+ include_builtin: Include built-in strategies
248
+
249
+ Returns:
250
+ List of strategy information
251
+ """
252
+ strategies = []
253
+
254
+ if include_builtin:
255
+ for name, config in self.get_builtin_strategies().items():
256
+ # strategy_type is already a string due to use_enum_values=True
257
+ strategy_type_value = (
258
+ config.strategy_type if isinstance(config.strategy_type, str)
259
+ else config.strategy_type.value
260
+ )
261
+ strategies.append({
262
+ "name": name,
263
+ "type": strategy_type_value,
264
+ "description": config.description,
265
+ "source": "builtin",
266
+ })
267
+
268
+ # List custom strategies
269
+ for strategy_file in self.strategies_path.glob("*.yaml"):
270
+ try:
271
+ import yaml
272
+ data = yaml.safe_load(strategy_file.read_text())
273
+ strategies.append({
274
+ "name": data.get("name", strategy_file.stem),
275
+ "type": data.get("strategy_type", "custom"),
276
+ "description": data.get("description", ""),
277
+ "source": "custom",
278
+ })
279
+ except Exception:
280
+ pass
281
+
282
+ return strategies
283
+
284
+ def print_list(self, include_builtin: bool = True) -> None:
285
+ """Print a formatted table of available strategies."""
286
+ strategies = self.list_strategies(include_builtin)
287
+
288
+ if not strategies:
289
+ self.console.print("[yellow]No strategies found.[/yellow]")
290
+ return
291
+
292
+ table = Table(title="Available Git Strategies")
293
+ table.add_column("Name", style="cyan")
294
+ table.add_column("Type", style="green")
295
+ table.add_column("Description", style="white")
296
+ table.add_column("Source", style="yellow")
297
+
298
+ for strategy in strategies:
299
+ table.add_row(
300
+ strategy["name"],
301
+ strategy["type"],
302
+ strategy["description"][:60] + "..." if len(strategy["description"]) > 60 else strategy["description"],
303
+ strategy["source"],
304
+ )
305
+
306
+ self.console.print(table)
307
+
308
+ def get_strategy(self, name: str) -> GitStrategyConfig | None:
309
+ """
310
+ Get a strategy by name.
311
+
312
+ Args:
313
+ name: Strategy name
314
+
315
+ Returns:
316
+ GitStrategyConfig if found, None otherwise
317
+ """
318
+ builtin = self.get_builtin_strategies()
319
+ if name in builtin:
320
+ return builtin[name]
321
+
322
+ # Try to load custom strategy
323
+ strategy_file = self.strategies_path / f"{name}.yaml"
324
+ if strategy_file.exists():
325
+ try:
326
+ import yaml
327
+ data = yaml.safe_load(strategy_file.read_text())
328
+ return GitStrategyConfig(**data)
329
+ except Exception as e:
330
+ self.console.print(f"[red]Error loading strategy '{name}': {e}[/red]")
331
+ return None
332
+
333
+ return None
334
+
335
+ def save_strategy(self, strategy: GitStrategyConfig, name: str) -> bool:
336
+ """
337
+ Save a custom strategy.
338
+
339
+ Args:
340
+ strategy: Strategy configuration to save
341
+ name: Name for the strategy
342
+
343
+ Returns:
344
+ True if successful, False otherwise
345
+ """
346
+ try:
347
+ import yaml
348
+ strategy_file = self.strategies_path / f"{name}.yaml"
349
+ strategy_file.write_text(
350
+ yaml.dump(strategy.model_dump(mode="json"), default_flow_style=False)
351
+ )
352
+ self.console.print(f"[green]Strategy '{name}' saved successfully.[/green]")
353
+ return True
354
+ except Exception as e:
355
+ self.console.print(f"[red]Error saving strategy: {e}[/red]")
356
+ return False
357
+
358
+ def export_strategy_yaml(self, name: str, output_path: Path) -> bool:
359
+ """
360
+ Export a strategy to YAML file.
361
+
362
+ Args:
363
+ name: Strategy name
364
+ output_path: Output file path
365
+
366
+ Returns:
367
+ True if successful, False otherwise
368
+ """
369
+ strategy = self.get_strategy(name)
370
+ if strategy is None:
371
+ self.console.print(f"[red]Strategy '{name}' not found.[/red]")
372
+ return False
373
+
374
+ try:
375
+ import yaml
376
+ output_path.parent.mkdir(parents=True, exist_ok=True)
377
+ output_path.write_text(
378
+ yaml.dump(strategy.model_dump(mode="json"), default_flow_style=False, sort_keys=False)
379
+ )
380
+ self.console.print(f"[green]Strategy exported to {output_path}[/green]")
381
+ return True
382
+ except Exception as e:
383
+ self.console.print(f"[red]Error exporting strategy: {e}[/red]")
384
+ return False
385
+
386
+ def validate_current_setup(self, expected_strategy: str) -> dict:
387
+ """
388
+ Validate current git setup against a strategy.
389
+
390
+ Args:
391
+ expected_strategy: Name of the expected strategy
392
+
393
+ Returns:
394
+ Validation results
395
+ """
396
+ import subprocess
397
+
398
+ strategy = self.get_strategy(expected_strategy)
399
+ if strategy is None:
400
+ return {"valid": False, "errors": [f"Strategy '{expected_strategy}' not found"]}
401
+
402
+ errors = []
403
+ warnings = []
404
+ info = {}
405
+
406
+ try:
407
+ # Check if we're in a git repository
408
+ result = subprocess.run(
409
+ ["git", "rev-parse", "--git-dir"],
410
+ capture_output=True,
411
+ text=True,
412
+ cwd=Path.cwd()
413
+ )
414
+ if result.returncode != 0:
415
+ errors.append("Not in a git repository")
416
+ return {"valid": False, "errors": errors}
417
+
418
+ # Check current branch
419
+ result = subprocess.run(
420
+ ["git", "branch", "--show-current"],
421
+ capture_output=True,
422
+ text=True,
423
+ cwd=Path.cwd()
424
+ )
425
+ current_branch = result.stdout.strip()
426
+ info["current_branch"] = current_branch
427
+
428
+ # Check remotes
429
+ result = subprocess.run(
430
+ ["git", "remote", "-v"],
431
+ capture_output=True,
432
+ text=True,
433
+ cwd=Path.cwd()
434
+ )
435
+ remotes = result.stdout.strip()
436
+ info["remotes"] = remotes
437
+
438
+ # Check if main branch exists
439
+ result = subprocess.run(
440
+ ["git", "branch", "-a", "--list", strategy.main_branch],
441
+ capture_output=True,
442
+ text=True,
443
+ cwd=Path.cwd()
444
+ )
445
+ if strategy.main_branch not in result.stdout:
446
+ warnings.append(f"Main branch '{strategy.main_branch}' not found")
447
+
448
+ # For git-flow, check develop branch
449
+ if strategy.strategy_type == GitStrategyType.GIT_FLOW:
450
+ result = subprocess.run(
451
+ ["git", "branch", "-a", "--list", strategy.develop_branch],
452
+ capture_output=True,
453
+ text=True,
454
+ cwd=Path.cwd()
455
+ )
456
+ if strategy.develop_branch not in result.stdout:
457
+ warnings.append(f"Develop branch '{strategy.develop_branch}' not found")
458
+
459
+ # For dual-repo, check remotes
460
+ if strategy.strategy_type == GitStrategyType.DUAL_REPO:
461
+ if strategy.dev_repository:
462
+ dev_remote = strategy.dev_repository.get("remote", "origin")
463
+ if dev_remote not in remotes:
464
+ warnings.append(f"Dev remote '{dev_remote}' not configured")
465
+ if strategy.release_repository:
466
+ release_remote = strategy.release_repository.get("remote", "release")
467
+ if release_remote not in remotes:
468
+ warnings.append(f"Release remote '{release_remote}' not configured")
469
+
470
+ except Exception as e:
471
+ errors.append(f"Validation error: {e}")
472
+
473
+ return {
474
+ "valid": len(errors) == 0,
475
+ "errors": errors,
476
+ "warnings": warnings,
477
+ "info": info,
478
+ }
cl_preset/manager.py ADDED
@@ -0,0 +1,199 @@
1
+ """
2
+ Preset manager for handling installation, listing, and removal of presets.
3
+ """
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from cl_preset.models import Preset, PresetConfig, PresetMetadata, PresetScope
13
+
14
+
15
+ class PresetManager:
16
+ """Manager for preset operations."""
17
+
18
+ def __init__(self, base_path: Path | None = None):
19
+ """
20
+ Initialize the preset manager.
21
+
22
+ Args:
23
+ base_path: Base path for global installations. Defaults to ~/.claude/presets
24
+ """
25
+ self.console = Console()
26
+ self.base_path = base_path or (Path.home() / ".claude" / "presets")
27
+ self.registry_path = self.base_path / "registry.json"
28
+ self._ensure_registry()
29
+
30
+ def _ensure_registry(self) -> None:
31
+ """Ensure the registry file exists."""
32
+ self.base_path.mkdir(parents=True, exist_ok=True)
33
+ if not self.registry_path.exists():
34
+ self.registry_path.write_text(json.dumps({"installed": []}, indent=2))
35
+
36
+ def _load_registry(self) -> dict:
37
+ """Load the installed presets registry."""
38
+ return json.loads(self.registry_path.read_text())
39
+
40
+ def _save_registry(self, registry: dict) -> None:
41
+ """Save the registry to disk."""
42
+ self.registry_path.write_text(json.dumps(registry, indent=2))
43
+
44
+ def install(self, preset: Preset) -> bool:
45
+ """
46
+ Install a preset.
47
+
48
+ Args:
49
+ preset: The preset to install
50
+
51
+ Returns:
52
+ True if successful, False otherwise
53
+ """
54
+ install_path = preset.config.get_install_path(self.base_path)
55
+
56
+ # Check if already installed
57
+ registry = self._load_registry()
58
+ installed_names = [p["name"] for p in registry["installed"]]
59
+
60
+ if preset.config.metadata.name in installed_names:
61
+ self.console.print(f"[yellow]Preset '{preset.config.metadata.name}' is already installed.[/yellow]")
62
+ return False
63
+
64
+ # Create installation directory
65
+ install_path.mkdir(parents=True, exist_ok=True)
66
+
67
+ # Copy files
68
+ source_path = preset.config.source_path
69
+ if source_path.exists():
70
+ if source_path.is_dir():
71
+ shutil.copytree(source_path, install_path, dirs_exist_ok=True)
72
+ else:
73
+ shutil.copy2(source_path, install_path / source_path.name)
74
+
75
+ # Write metadata
76
+ metadata_path = install_path / "preset.json"
77
+ metadata_path.write_text(
78
+ json.dumps(preset.config.metadata.model_dump(mode="json"), indent=2)
79
+ )
80
+
81
+ # Update registry
82
+ registry["installed"].append({
83
+ "name": preset.config.metadata.name,
84
+ "version": preset.config.metadata.version,
85
+ "scope": preset.config.scope.value,
86
+ "path": str(install_path),
87
+ })
88
+ self._save_registry(registry)
89
+
90
+ self.console.print(f"[green]Successfully installed '{preset.config.metadata.name}'[/green]")
91
+ return True
92
+
93
+ def uninstall(self, name: str, scope: PresetScope | None = None) -> bool:
94
+ """
95
+ Uninstall a preset.
96
+
97
+ Args:
98
+ name: Name of the preset to uninstall
99
+ scope: Optional scope to filter by
100
+
101
+ Returns:
102
+ True if successful, False otherwise
103
+ """
104
+ registry = self._load_registry()
105
+
106
+ # Find the preset
107
+ installed = registry.get("installed", [])
108
+ preset_entry = None
109
+ preset_index = -1
110
+
111
+ for i, preset in enumerate(installed):
112
+ if preset["name"] == name:
113
+ if scope is None or preset["scope"] == scope.value:
114
+ preset_entry = preset
115
+ preset_index = i
116
+ break
117
+
118
+ if preset_entry is None:
119
+ self.console.print(f"[red]Preset '{name}' not found.[/red]")
120
+ return False
121
+
122
+ # Remove files
123
+ preset_path = Path(preset_entry["path"])
124
+ if preset_path.exists():
125
+ shutil.rmtree(preset_path)
126
+
127
+ # Update registry
128
+ installed.pop(preset_index)
129
+ registry["installed"] = installed
130
+ self._save_registry(registry)
131
+
132
+ self.console.print(f"[green]Successfully uninstalled '{name}'[/green]")
133
+ return True
134
+
135
+ def list(self, scope: PresetScope | None = None) -> list[dict]:
136
+ """
137
+ List installed presets.
138
+
139
+ Args:
140
+ scope: Optional scope to filter by
141
+
142
+ Returns:
143
+ List of installed preset information
144
+ """
145
+ registry = self._load_registry()
146
+ installed = registry.get("installed", [])
147
+
148
+ if scope:
149
+ installed = [p for p in installed if p["scope"] == scope.value]
150
+
151
+ return installed
152
+
153
+ def print_list(self, scope: PresetScope | None = None) -> None:
154
+ """Print a formatted table of installed presets."""
155
+ presets = self.list(scope)
156
+
157
+ if not presets:
158
+ self.console.print("[yellow]No presets installed.[/yellow]")
159
+ return
160
+
161
+ table = Table(title="Installed Presets")
162
+ table.add_column("Name", style="cyan")
163
+ table.add_column("Version", style="green")
164
+ table.add_column("Scope", style="yellow")
165
+ table.add_column("Path", style="blue")
166
+
167
+ for preset in presets:
168
+ table.add_row(
169
+ preset["name"],
170
+ preset["version"],
171
+ preset["scope"],
172
+ preset["path"][:50] + "..." if len(preset["path"]) > 50 else preset["path"]
173
+ )
174
+
175
+ self.console.print(table)
176
+
177
+ def create_from_directory(
178
+ self,
179
+ source_path: Path,
180
+ metadata: PresetMetadata,
181
+ scope: PresetScope = PresetScope.USER
182
+ ) -> Preset:
183
+ """
184
+ Create a preset from a directory.
185
+
186
+ Args:
187
+ source_path: Path to the source directory
188
+ metadata: Metadata for the preset
189
+ scope: Installation scope
190
+
191
+ Returns:
192
+ A Preset object
193
+ """
194
+ config = PresetConfig(
195
+ metadata=metadata,
196
+ source_path=source_path,
197
+ scope=scope
198
+ )
199
+ return Preset(config=config)