git-repokit 0.9.10__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.
Files changed (174) hide show
  1. git_repokit-0.9.10.dist-info/METADATA +422 -0
  2. git_repokit-0.9.10.dist-info/RECORD +174 -0
  3. git_repokit-0.9.10.dist-info/WHEEL +5 -0
  4. git_repokit-0.9.10.dist-info/entry_points.txt +2 -0
  5. git_repokit-0.9.10.dist-info/licenses/LICENSE +9 -0
  6. git_repokit-0.9.10.dist-info/top_level.txt +1 -0
  7. repokit/__init__.py +8 -0
  8. repokit/__main__.py +12 -0
  9. repokit/__version__.py +4 -0
  10. repokit/_vendor/__init__.py +0 -0
  11. repokit/_vendor/ailib/__init__.py +1 -0
  12. repokit/_vendor/ailib/analyzer.py +177 -0
  13. repokit/_vendor/ailib/backend.py +95 -0
  14. repokit/_vendor/ailib/cache.py +97 -0
  15. repokit/_vendor/ailib/parsers.py +52 -0
  16. repokit/_vendor/ailib/transports/__init__.py +16 -0
  17. repokit/_vendor/ailib/transports/cli.py +312 -0
  18. repokit/_vendor/ailib/transports/openai_compat.py +383 -0
  19. repokit/_vendor/ailib/transports/prompt_file.py +41 -0
  20. repokit/_vendor/ailib/types.py +224 -0
  21. repokit/auth_integration.py +244 -0
  22. repokit/badges.py +183 -0
  23. repokit/branch_utils.py +364 -0
  24. repokit/cli.py +2997 -0
  25. repokit/common_kit.py +202 -0
  26. repokit/config.py +686 -0
  27. repokit/defaults.py +220 -0
  28. repokit/directory_analyzer.py +1244 -0
  29. repokit/directory_profiles.py +423 -0
  30. repokit/git_config.py +370 -0
  31. repokit/github_create.py +142 -0
  32. repokit/github_setup.py +248 -0
  33. repokit/guardrails.py +707 -0
  34. repokit/history_cleaner.py +449 -0
  35. repokit/history_protection.py +434 -0
  36. repokit/hooks/pre-commit-guardrails +122 -0
  37. repokit/hooks/pre-commit-template +28 -0
  38. repokit/layers.py +510 -0
  39. repokit/licenses/0bsd.txt +14 -0
  40. repokit/licenses/LICENSES.md +27 -0
  41. repokit/licenses/__init__.py +142 -0
  42. repokit/licenses/agpl-3.0.txt +661 -0
  43. repokit/licenses/all-rights-reserved.txt +10 -0
  44. repokit/licenses/apache-2.0.txt +201 -0
  45. repokit/licenses/bsd-2-clause.txt +24 -0
  46. repokit/licenses/bsd-3-clause.txt +28 -0
  47. repokit/licenses/bsl-1.0.txt +23 -0
  48. repokit/licenses/cc-by-4.0.txt +395 -0
  49. repokit/licenses/cc-by-sa-4.0.txt +427 -0
  50. repokit/licenses/cc0-1.0.txt +121 -0
  51. repokit/licenses/epl-2.0.txt +277 -0
  52. repokit/licenses/gpl-2.0.txt +339 -0
  53. repokit/licenses/gpl-3.0.txt +674 -0
  54. repokit/licenses/isc.txt +15 -0
  55. repokit/licenses/lgpl-2.1.txt +504 -0
  56. repokit/licenses/lgpl-3.0.txt +165 -0
  57. repokit/licenses/mit.txt +21 -0
  58. repokit/licenses/mpl-2.0.txt +373 -0
  59. repokit/licenses/unlicense.txt +24 -0
  60. repokit/readme_blocks.py +112 -0
  61. repokit/remote_integration.py +627 -0
  62. repokit/repo_manager.py +2796 -0
  63. repokit/template_engine.py +391 -0
  64. repokit/templates/ai/claude/CLAUDE-nested.md.template +25 -0
  65. repokit/templates/ai/claude/CLAUDE.md.template +98 -0
  66. repokit/templates/ai/claude/instructions/step1_context_rebuilder.md +33 -0
  67. repokit/templates/ai/claude/instructions/step2_dev_workflow_process.md +67 -0
  68. repokit/templates/ai/claude/instructions/step3_context_bridge.md +52 -0
  69. repokit/templates/common/CHANGELOG.md.template +15 -0
  70. repokit/templates/common/CONTRIBUTING.md.template +31 -0
  71. repokit/templates/common/README.md.template +30 -0
  72. repokit/templates/common/gitattributes.template +17 -0
  73. repokit/templates/common/gitignore.template +141 -0
  74. repokit/templates/extras/FUNDING.yml.template +13 -0
  75. repokit/templates/extras/ROADMAP.md.template +17 -0
  76. repokit/templates/extras/dependabot.yml.template +22 -0
  77. repokit/templates/extras/one-offs-README.md.template +28 -0
  78. repokit/templates/extras/platform-support.md.template +11 -0
  79. repokit/templates/extras/pull_request_template.md.template +25 -0
  80. repokit/templates/extras/release-template.md.template +34 -0
  81. repokit/templates/extras/settings.json.template +11 -0
  82. repokit/templates/extras/stale.yml.template +14 -0
  83. repokit/templates/github/CODEOWNERS.template +11 -0
  84. repokit/templates/github/ISSUE_TEMPLATE/bug-report.md.template +29 -0
  85. repokit/templates/github/ISSUE_TEMPLATE/feature-request.md.template +19 -0
  86. repokit/templates/github/issues/notes.md.template +18 -0
  87. repokit/templates/github/issues/roadmap.md.template +15 -0
  88. repokit/templates/hooks/pre-commit.template +64 -0
  89. repokit/templates/layers/archetypes/any/claude-plugin/.claude-plugin/marketplace.json +20 -0
  90. repokit/templates/layers/archetypes/any/claude-plugin/plugin/.claude-plugin/plugin.json +15 -0
  91. repokit/templates/layers/archetypes/any/claude-plugin/plugin/README.md +28 -0
  92. repokit/templates/layers/archetypes/c-cpp/cmake-app/.github/workflows/ci-c-cpp.yml +24 -0
  93. repokit/templates/layers/archetypes/c-cpp/cmake-app/.repokit-common.toml.rkappend +4 -0
  94. repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/launch.json +29 -0
  95. repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/tasks.json +26 -0
  96. repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakeLists.txt +47 -0
  97. repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakePresets.json +23 -0
  98. repokit/templates/layers/archetypes/c-cpp/cmake-app/cmake/GenerateStamp.cmake +45 -0
  99. repokit/templates/layers/archetypes/c-cpp/cmake-app/src/main.cpp +27 -0
  100. repokit/templates/layers/archetypes/c-cpp/cmake-app/tests/test_version.cpp +20 -0
  101. repokit/templates/layers/archetypes/c-cpp/cmake-app/vcpkg.json +5 -0
  102. repokit/templates/layers/archetypes/c-cpp/minimal/.vscode/launch.json +27 -0
  103. repokit/templates/layers/archetypes/c-cpp/minimal/Makefile +10 -0
  104. repokit/templates/layers/archetypes/c-cpp/minimal/build.bat +7 -0
  105. repokit/templates/layers/archetypes/c-cpp/minimal/src/__RK_SNAKE__.c +26 -0
  106. repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/launch.json +17 -0
  107. repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/tasks.json +20 -0
  108. repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.sln +19 -0
  109. repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.vcxproj +41 -0
  110. repokit/templates/layers/archetypes/c-cpp/msbuild/src/main.cpp +26 -0
  111. repokit/templates/layers/archetypes/js-ts/claude-plugin/.repokit-common.toml +28 -0
  112. repokit/templates/layers/archetypes/js-ts/claude-plugin/.vscode/launch.json +34 -0
  113. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/hooks.json +15 -0
  114. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/scripts/session-start.mjs +23 -0
  115. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/tests/manifest.test.js +30 -0
  116. repokit/templates/layers/archetypes/js-ts/node/.repokit-common.toml.rkappend +6 -0
  117. repokit/templates/layers/archetypes/js-ts/node/package.json +13 -0
  118. repokit/templates/layers/archetypes/js-ts/node-package/.github/workflows/release.yml +39 -0
  119. repokit/templates/layers/archetypes/js-ts/node-package/package.json +32 -0
  120. repokit/templates/layers/archetypes/js-ts/node-package/src/index.ts +13 -0
  121. repokit/templates/layers/archetypes/js-ts/node-package/tests/index.test.ts +11 -0
  122. repokit/templates/layers/archetypes/js-ts/script/.vscode/launch.json +34 -0
  123. repokit/templates/layers/archetypes/js-ts/script/__RK_PROJECT__.js +37 -0
  124. repokit/templates/layers/archetypes/js-ts/script/tests/version.test.js +26 -0
  125. repokit/templates/layers/archetypes/js-ts/ts/package.json +18 -0
  126. repokit/templates/layers/archetypes/js-ts/ts/tsconfig.json +15 -0
  127. repokit/templates/layers/archetypes/python/claude-plugin/.github/workflows/ci-python.yml +44 -0
  128. repokit/templates/layers/archetypes/python/claude-plugin/.repokit-common.toml +21 -0
  129. repokit/templates/layers/archetypes/python/claude-plugin/.vscode/launch.json +33 -0
  130. repokit/templates/layers/archetypes/python/claude-plugin/VERSION +1 -0
  131. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/hooks.json +15 -0
  132. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/run-hook.mjs +42 -0
  133. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/session_start.py +31 -0
  134. repokit/templates/layers/archetypes/python/claude-plugin/plugin/tests/test_manifest.py +42 -0
  135. repokit/templates/layers/archetypes/python/pypi/.github/workflows/release.yml +63 -0
  136. repokit/templates/layers/archetypes/python/pypi/.vscode/launch.json +43 -0
  137. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__init__.py +5 -0
  138. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__main__.py +6 -0
  139. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/_version.py +96 -0
  140. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/cli.py +18 -0
  141. repokit/templates/layers/archetypes/python/pypi/pyproject.toml +71 -0
  142. repokit/templates/layers/archetypes/python/pypi/tests/conftest.py +11 -0
  143. repokit/templates/layers/archetypes/python/pypi/tests/test_version.py +50 -0
  144. repokit/templates/layers/archetypes/python/script/.github/workflows/ci-python.yml +43 -0
  145. repokit/templates/layers/archetypes/python/script/.repokit-common.toml +14 -0
  146. repokit/templates/layers/archetypes/python/script/.vscode/launch.json +33 -0
  147. repokit/templates/layers/archetypes/python/script/VERSION +1 -0
  148. repokit/templates/layers/archetypes/python/script/__RK_PROJECT__.py +40 -0
  149. repokit/templates/layers/archetypes/python/script/tests/test_version.py +25 -0
  150. repokit/templates/layers/archetypes/python/setuppy/requirements.txt +8 -0
  151. repokit/templates/layers/archetypes/python/setuppy/setup.py +23 -0
  152. repokit/templates/layers/languages/c-cpp/.gitignore.rkappend +20 -0
  153. repokit/templates/layers/languages/c-cpp/.repokit-common.toml +7 -0
  154. repokit/templates/layers/languages/c-cpp/include/__RK_SNAKE__/version.h +20 -0
  155. repokit/templates/layers/languages/c-cpp/src/app.rc +25 -0
  156. repokit/templates/layers/languages/js-ts/.editorconfig +15 -0
  157. repokit/templates/layers/languages/js-ts/.github/workflows/ci-js-ts.yml +41 -0
  158. repokit/templates/layers/languages/js-ts/.gitignore.rkappend +13 -0
  159. repokit/templates/layers/languages/js-ts/.repokit-common.toml +19 -0
  160. repokit/templates/layers/languages/js-ts/.vscode/launch.json +24 -0
  161. repokit/templates/layers/languages/js-ts/VERSION +1 -0
  162. repokit/templates/layers/languages/python/.github/workflows/ci-python.yml +76 -0
  163. repokit/templates/layers/languages/python/.gitignore.rkappend +15 -0
  164. repokit/templates/layers/languages/python/.vscode/launch.json +33 -0
  165. repokit/templates/layers/manifest.toml +302 -0
  166. repokit/templates/vault/CLAUDE.md +29 -0
  167. repokit/templates/vault/claude/_maps/MOC-project.md +15 -0
  168. repokit/templates/vault/claude/_oracle/backlinks.md +3 -0
  169. repokit/templates/vault/claude/_oracle/concepts.md +12 -0
  170. repokit/templates/vault/claude/_oracle/manifest.md +10 -0
  171. repokit/templates/vault/claude/_todo/ACTIVE-WORK.md +18 -0
  172. repokit/templates/vault/claude/_todo/OPEN-LOOPS.md +14 -0
  173. repokit/utils.py +316 -0
  174. repokit/vault.py +248 -0
repokit/config.py ADDED
@@ -0,0 +1,686 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Configuration management for RepoKit.
4
+
5
+ Handles loading, validating, and merging configurations from
6
+ multiple sources (defaults, config files, environment, CLI).
7
+ """
8
+
9
+ import copy
10
+ import os
11
+ import json
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Dict, Any, Optional, List, Union
15
+
16
+ from .directory_profiles import DirectoryProfileManager
17
+ from .defaults import (
18
+ DEFAULT_PRIVATE_DIRS,
19
+ DEFAULT_SENSITIVE_FILES,
20
+ DEFAULT_SENSITIVE_PATTERNS,
21
+ BRANCH_SPECIFIC_EXCLUDES,
22
+ BRANCH_STRATEGIES,
23
+ DEFAULT_BRANCH_STRATEGY,
24
+ )
25
+
26
+
27
+ class ConfigManager:
28
+ """
29
+ Manages configuration loading and validation for RepoKit.
30
+
31
+ The configuration hierarchy (from lowest to highest precedence):
32
+ 1. Default values
33
+ 2. Global config file (~/.repokit/config.json)
34
+ 3. Project config file (./.repokit.json)
35
+ 4. Environment variables (REPOKIT_*)
36
+ 5. Command line arguments
37
+ """
38
+
39
+ def __init__(self, verbose: int = 0):
40
+ """
41
+ Initialize the configuration manager.
42
+
43
+ Args:
44
+ verbose: Verbosity level (0=normal, 1=info, 2=debug)
45
+ """
46
+ self.verbose = verbose
47
+ self.logger = logging.getLogger("repokit.config")
48
+
49
+ # Initialize directory profile manager
50
+ self.directory_manager = DirectoryProfileManager(verbose=verbose)
51
+
52
+ # Default configuration
53
+ self.default_config = {
54
+ "name": "my-project",
55
+ "description": "A new project repository",
56
+ "language": "generic",
57
+ "default_branch": "main",
58
+ # Base defaults match the default strategy; apply_branch_strategy
59
+ # replaces them with the chosen strategy's values
60
+ "branches": list(BRANCH_STRATEGIES[DEFAULT_BRANCH_STRATEGY]["branches"]),
61
+ "worktrees": list(BRANCH_STRATEGIES[DEFAULT_BRANCH_STRATEGY]["worktrees"]),
62
+ "directories": [
63
+ "convos",
64
+ "docs",
65
+ "logs",
66
+ "private",
67
+ "revisions",
68
+ "scripts",
69
+ "tests",
70
+ ],
71
+ "private_dirs": DEFAULT_PRIVATE_DIRS.copy(),
72
+ "sensitive_files": DEFAULT_SENSITIVE_FILES.copy(),
73
+ "sensitive_patterns": DEFAULT_SENSITIVE_PATTERNS.copy(),
74
+ "private_branch": BRANCH_STRATEGIES[DEFAULT_BRANCH_STRATEGY]["private_branch"],
75
+ "github": True,
76
+ # The licence is REQUIRED: --license, else `license` here (or in the
77
+ # global file), else `license_private` for a private repository;
78
+ # with none, create refuses and names the catalogue (0.9.0).
79
+ "license": None,
80
+ "license_private": None,
81
+ # The structure's optional files, by name (0.9.0): funding,
82
+ # dependabot, stale, pr-template, release-template, roadmap,
83
+ # platform-support, vscode-settings, placeholders. Off unless
84
+ # the global config or --extras names them.
85
+ "extras": [],
86
+ # What --github sets up on the new repository (0.9.3): labels,
87
+ # issues, pin, discussions, topics, pypi-environment, traffic, all.
88
+ # Off unless the global config or --github-setup names them.
89
+ "github_setup": [],
90
+ "topics": [],
91
+ # README badges (0.9.7): a template name or a list; None picks the
92
+ # preset's (pypi for python/pypi, else github). badge_templates
93
+ # and badge_types hold a person's own; badges_ci is auto, on, off.
94
+ "badges": None,
95
+ "badge_templates": {},
96
+ "badge_types": {},
97
+ "badges_ci": "auto",
98
+ # The vault's AI door (0.9.4): "prompt-file" writes the oracle's
99
+ # first prompt to private/claude/_oracle/; "none" writes nothing.
100
+ "vault_init": "prompt-file",
101
+ "use_github_noreply": True, # Add GitHub no-reply setting
102
+ "user": {"name": "", "email": ""},
103
+ # Branch configuration enhancements
104
+ "branch_config": {
105
+ # Default mapping between branch names and directory names
106
+ "branch_directories": {
107
+ "main": "github", # main branch maps to github directory by default
108
+ "dev": "dev", # dev branch maps to dev directory
109
+ # Other branches use the branch name as directory name by default
110
+ },
111
+ # Define branch purposes and relationships
112
+ "branch_roles": {
113
+ "main": "production", # Main production branch
114
+ "dev": "development", # Development branch
115
+ "staging": "pre-release", # Pre-production testing
116
+ "test": "testing", # Testing branch
117
+ "live": "live", # Live deployment branch
118
+ "private": "personal", # Personal/local development
119
+ },
120
+ # Define branch flow (which branches can be merged to which)
121
+ "branch_flow": {
122
+ "private": ["dev"], # Private can merge to dev
123
+ "dev": ["staging", "test"], # Dev can merge to staging or test
124
+ "test": ["staging"], # Test can merge to staging
125
+ "staging": ["main"], # Staging can merge to main
126
+ "main": ["live"], # Main can merge to live
127
+ },
128
+ },
129
+ # Branch strategy templates
130
+ "branch_strategy": DEFAULT_BRANCH_STRATEGY,
131
+ # Defined once in defaults.BRANCH_STRATEGIES; copied so a config
132
+ # file can extend or override strategies without touching it.
133
+ "branch_strategies": copy.deepcopy(BRANCH_STRATEGIES),
134
+ }
135
+
136
+ # Load configurations
137
+ self.global_config = self._load_global_config()
138
+ self.project_config = self._load_project_config()
139
+ self.env_config = self._load_env_config()
140
+ self.cli_config = {} # Will be set later
141
+
142
+ # The merged, final configuration
143
+ self.config = self._merge_configs()
144
+
145
+ def apply_branch_strategy(self) -> None:
146
+ """
147
+ Apply the selected branch strategy to the configuration.
148
+
149
+ This updates branches, worktrees, and other settings based on the
150
+ selected branch strategy.
151
+ """
152
+ # Get the selected strategy
153
+ strategy_name = self.config.get("branch_strategy", "standard")
154
+ strategies = self.config.get("branch_strategies", {})
155
+
156
+ if strategy_name in strategies:
157
+ strategy = strategies[strategy_name]
158
+
159
+ # Only update if not explicitly set in config
160
+ if "branches" not in self.cli_config:
161
+ self.config["branches"] = strategy.get("branches", ["main", "dev"])
162
+
163
+ if "worktrees" not in self.cli_config:
164
+ self.config["worktrees"] = strategy.get("worktrees", ["main"])
165
+
166
+ # A strategy's private branch wins unless --private-branch was
167
+ # given; None means the strategy has no private branch at all.
168
+ if "private_branch" not in self.cli_config:
169
+ self.config["private_branch"] = strategy.get(
170
+ "private_branch", "private"
171
+ )
172
+
173
+ # Ensure default_branch aligns
174
+ if "default_branch" not in self.cli_config:
175
+ self.config["default_branch"] = self.config.get(
176
+ "default_branch", "main"
177
+ )
178
+
179
+ if self.verbose >= 1:
180
+ self.logger.info(f"Applied branch strategy '{strategy_name}'")
181
+
182
+ def get_branch_directory(self, branch_name: str) -> str:
183
+ """
184
+ Get the directory name for a branch.
185
+
186
+ Args:
187
+ branch_name: Name of the branch
188
+
189
+ Returns:
190
+ Directory name for the branch
191
+ """
192
+ # Check branch_directories mapping first
193
+ branch_dirs = self.config.get("branch_config", {}).get("branch_directories", {})
194
+
195
+ if branch_name in branch_dirs:
196
+ return branch_dirs[branch_name]
197
+
198
+ # Default: use branch name as directory name
199
+ return branch_name
200
+
201
+ def load_branch_config_file(self, path: str) -> bool:
202
+ """
203
+ Load branch configuration from a file.
204
+
205
+ Args:
206
+ path: Path to branch configuration file
207
+
208
+ Returns:
209
+ True if loaded successfully, False otherwise
210
+ """
211
+ try:
212
+ with open(path, "r") as f:
213
+ branch_config = json.load(f)
214
+
215
+ # Update branch configuration
216
+ if "branch_config" in branch_config:
217
+ self.project_config["branch_config"] = branch_config["branch_config"]
218
+
219
+ if "branch_strategy" in branch_config:
220
+ self.project_config["branch_strategy"] = branch_config[
221
+ "branch_strategy"
222
+ ]
223
+
224
+ if "branch_strategies" in branch_config:
225
+ self.project_config["branch_strategies"] = branch_config[
226
+ "branch_strategies"
227
+ ]
228
+
229
+ # Remerge configs
230
+ self.config = self._merge_configs()
231
+
232
+ # Apply branch strategy
233
+ self.apply_branch_strategy()
234
+
235
+ if self.verbose >= 1:
236
+ self.logger.info(f"Loaded branch configuration from {path}")
237
+
238
+ return True
239
+ except Exception as e:
240
+ self.logger.error(
241
+ f"Failed to load branch configuration from {path}: {str(e)}"
242
+ )
243
+ return False
244
+
245
+ def _load_global_config(self) -> Dict[str, Any]:
246
+ """
247
+ Load global configuration from ~/.repokit/config.json.
248
+
249
+ Returns:
250
+ Global configuration or empty dict if not found
251
+ """
252
+ home_dir = os.path.expanduser("~")
253
+ global_config_path = os.path.join(home_dir, ".repokit", "config.json")
254
+
255
+ if os.path.exists(global_config_path):
256
+ try:
257
+ with open(global_config_path, "r") as f:
258
+ config = json.load(f)
259
+ if self.verbose >= 1:
260
+ self.logger.info(f"Loaded global config from {global_config_path}")
261
+ return config
262
+ except Exception as e:
263
+ self.logger.warning(f"Error loading global config: {str(e)}")
264
+
265
+ return {}
266
+
267
+ def _load_project_config(self) -> Dict[str, Any]:
268
+ """
269
+ Load project-specific configuration from ./.repokit.json.
270
+
271
+ Returns:
272
+ Project configuration or empty dict if not found
273
+ """
274
+ project_config_path = os.path.join(os.getcwd(), ".repokit.json")
275
+
276
+ if os.path.exists(project_config_path):
277
+ try:
278
+ with open(project_config_path, "r") as f:
279
+ config = json.load(f)
280
+ if self.verbose >= 1:
281
+ self.logger.info(
282
+ f"Loaded project config from {project_config_path}"
283
+ )
284
+ return config
285
+ except Exception as e:
286
+ self.logger.warning(f"Error loading project config: {str(e)}")
287
+
288
+ return {}
289
+
290
+ def _load_env_config(self) -> Dict[str, Any]:
291
+ """
292
+ Load configuration from environment variables (REPOKIT_*).
293
+
294
+ Returns:
295
+ Environment configuration
296
+ """
297
+ config = {}
298
+
299
+ # Look for environment variables with REPOKIT_ prefix
300
+ for key, value in os.environ.items():
301
+ if key.startswith("REPOKIT_"):
302
+ # Convert REPOKIT_SNAKE_CASE to camelCase or underscore
303
+ config_key = key[8:].lower() # Remove REPOKIT_ and lowercase
304
+
305
+ # Handle nested keys (e.g., REPOKIT_USER_NAME -> user.name)
306
+ parts = config_key.split("_")
307
+ current = config
308
+
309
+ for i, part in enumerate(parts):
310
+ if i == len(parts) - 1: # Last part
311
+ current[part] = value
312
+ else:
313
+ if part not in current:
314
+ current[part] = {}
315
+ current = current[part]
316
+
317
+ # Special handling for list-type values
318
+ for key in ["branches", "worktrees", "directories", "private_dirs"]:
319
+ env_key = f"REPOKIT_{key.upper()}"
320
+ if env_key in os.environ:
321
+ config[key] = os.environ[env_key].split(",")
322
+
323
+ if config and self.verbose >= 1:
324
+ self.logger.info(f"Loaded configuration from environment variables")
325
+
326
+ return config
327
+
328
+ def set_cli_config(self, cli_config: Dict[str, Any]) -> None:
329
+ """
330
+ Set the command-line configuration.
331
+
332
+ Args:
333
+ cli_config: Configuration from command-line arguments
334
+ """
335
+ self.cli_config = cli_config
336
+
337
+ # Remerge configs with the new CLI config
338
+ self.config = self._merge_configs()
339
+
340
+ # Apply branch strategy
341
+ self.apply_branch_strategy()
342
+
343
+ # Resolve directory profiles
344
+ self.resolve_directory_profiles()
345
+
346
+ def _merge_configs(self) -> Dict[str, Any]:
347
+ """
348
+ Merge configurations from all sources.
349
+
350
+ Returns:
351
+ Merged configuration
352
+ """
353
+ # Start with default config
354
+ config = self.default_config.copy()
355
+
356
+ # Recursively update with each config source in order of precedence
357
+ self._recursive_update(config, self.global_config)
358
+ self._recursive_update(config, self.project_config)
359
+ self._recursive_update(config, self.env_config)
360
+ self._recursive_update(config, self.cli_config)
361
+
362
+ return config
363
+
364
+ def _recursive_update(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
365
+ """
366
+ Recursively update a target dictionary with values from a source dictionary.
367
+
368
+ Args:
369
+ target: Target dictionary to update
370
+ source: Source dictionary with new values
371
+ """
372
+ for key, value in source.items():
373
+ if (
374
+ key in target
375
+ and isinstance(target[key], dict)
376
+ and isinstance(value, dict)
377
+ ):
378
+ # Recursively update nested dictionaries
379
+ self._recursive_update(target[key], value)
380
+ elif value is not None: # Only update if value is not None
381
+ target[key] = value
382
+
383
+ def get_config(self) -> Dict[str, Any]:
384
+ """
385
+ Get the merged configuration.
386
+
387
+ Returns:
388
+ Merged configuration
389
+ """
390
+ return self.config
391
+
392
+ def save_config(self, path: str) -> bool:
393
+ """
394
+ Save the current configuration to a file.
395
+
396
+ Args:
397
+ path: Path to save the configuration
398
+
399
+ Returns:
400
+ True if successful, False otherwise
401
+ """
402
+ try:
403
+ # Create directory if it doesn't exist
404
+ os.makedirs(os.path.dirname(path), exist_ok=True)
405
+
406
+ with open(path, "w") as f:
407
+ json.dump(self.config, f, indent=4)
408
+
409
+ if self.verbose >= 1:
410
+ self.logger.info(f"Saved configuration to {path}")
411
+
412
+ return True
413
+ except Exception as e:
414
+ self.logger.error(f"Failed to save configuration to {path}: {str(e)}")
415
+ return False
416
+
417
+ def load_config_file(self, path: str) -> bool:
418
+ """
419
+ Load configuration from a file.
420
+
421
+ Args:
422
+ path: Path to the configuration file
423
+
424
+ Returns:
425
+ True if successful, False otherwise
426
+ """
427
+ try:
428
+ with open(path, "r") as f:
429
+ config = json.load(f)
430
+
431
+ # Update project_config with the loaded config
432
+ self.project_config = config
433
+
434
+ # Remerge configs with the new project config
435
+ self.config = self._merge_configs()
436
+
437
+ if self.verbose >= 1:
438
+ self.logger.info(f"Loaded configuration from {path}")
439
+
440
+ return True
441
+ except Exception as e:
442
+ self.logger.error(f"Failed to load configuration from {path}: {str(e)}")
443
+ return False
444
+
445
+ def validate_config(self) -> List[str]:
446
+ """
447
+ Validate the configuration.
448
+
449
+ Returns:
450
+ List of validation errors (empty if valid)
451
+ """
452
+ errors = []
453
+
454
+ # Basic validation
455
+ if not self.config.get("name"):
456
+ errors.append("Project name is required")
457
+
458
+ # More validation rules can be added here
459
+
460
+ return errors
461
+
462
+ def create_bootstrap_config(
463
+ self,
464
+ name: str,
465
+ description: str = None,
466
+ language: str = "python",
467
+ user_info: Dict[str, str] = None,
468
+ ) -> Dict[str, Any]:
469
+ """
470
+ Create a configuration specifically for bootstrapping a project.
471
+
472
+ Args:
473
+ name: Project name
474
+ description: Project description
475
+ language: Programming language
476
+ user_info: Dictionary with user name and email
477
+
478
+ Returns:
479
+ Bootstrap configuration dictionary
480
+ """
481
+ # Start with default config
482
+ config = self.default_config.copy()
483
+
484
+ # Update with provided values
485
+ config["name"] = name
486
+
487
+ if description:
488
+ config["description"] = description
489
+
490
+ if language:
491
+ config["language"] = language
492
+
493
+ # Handle user information
494
+ if user_info and isinstance(user_info, dict):
495
+ if "name" in user_info or "email" in user_info:
496
+ config["user"] = user_info.copy()
497
+
498
+ return config
499
+
500
+ def create_config_from_environment(self) -> Dict[str, Any]:
501
+ """
502
+ Create a configuration from environment variables.
503
+
504
+ Environment variables:
505
+ REPOKIT_NAME: Project name
506
+ REPOKIT_DESCRIPTION: Project description
507
+ REPOKIT_LANGUAGE: Programming language
508
+ GIT_USER_NAME: Git user name
509
+ GIT_USER_EMAIL: Git user email
510
+
511
+ Returns:
512
+ Configuration dictionary from environment
513
+ """
514
+ config = {}
515
+
516
+ # Get basic project info
517
+ name = os.environ.get("REPOKIT_NAME")
518
+ if name:
519
+ config["name"] = name
520
+
521
+ description = os.environ.get("REPOKIT_DESCRIPTION")
522
+ if description:
523
+ config["description"] = description
524
+
525
+ language = os.environ.get("REPOKIT_LANGUAGE")
526
+ if language:
527
+ config["language"] = language
528
+
529
+ # Get user info
530
+ user_name = os.environ.get("GIT_USER_NAME")
531
+ user_email = os.environ.get("GIT_USER_EMAIL")
532
+
533
+ if user_name or user_email:
534
+ config["user"] = {}
535
+ if user_name:
536
+ config["user"]["name"] = user_name
537
+ if user_email:
538
+ config["user"]["email"] = user_email
539
+
540
+ return config
541
+
542
+ def get_git_user_info(self) -> Dict[str, str]:
543
+ """
544
+ Get Git user information from environment or global Git config.
545
+
546
+ Returns:
547
+ Dictionary with user name and email
548
+ """
549
+ user_info = {"name": "", "email": ""}
550
+
551
+ # Try environment variables first
552
+ user_name = os.environ.get("GIT_USER_NAME")
553
+ user_email = os.environ.get("GIT_USER_EMAIL")
554
+
555
+ if user_name:
556
+ user_info["name"] = user_name
557
+
558
+ if user_email:
559
+ user_info["email"] = user_email
560
+
561
+ # If not in environment, try Git global config
562
+ if not user_name or not user_email:
563
+ try:
564
+ import subprocess
565
+
566
+ if not user_name:
567
+ try:
568
+ result = subprocess.run(
569
+ ["git", "config", "--global", "user.name"],
570
+ capture_output=True,
571
+ text=True,
572
+ check=False,
573
+ )
574
+ if result.returncode == 0 and result.stdout.strip():
575
+ user_info["name"] = result.stdout.strip()
576
+ except Exception:
577
+ pass
578
+
579
+ if not user_email:
580
+ try:
581
+ result = subprocess.run(
582
+ ["git", "config", "--global", "user.email"],
583
+ capture_output=True,
584
+ text=True,
585
+ check=False,
586
+ )
587
+ if result.returncode == 0 and result.stdout.strip():
588
+ user_info["email"] = result.stdout.strip()
589
+ except Exception:
590
+ pass
591
+ except Exception as e:
592
+ self.logger.warning(f"Could not get Git user info: {str(e)}")
593
+
594
+ return user_info
595
+
596
+ def apply_github_email_privacy(self, user_info: Dict[str, str]) -> Dict[str, str]:
597
+ """
598
+ Apply GitHub email privacy protection to user email.
599
+
600
+ Args:
601
+ user_info: Dictionary with user name and email
602
+
603
+ Returns:
604
+ Updated user info dictionary
605
+ """
606
+ updated_info = user_info.copy()
607
+
608
+ # Apply GitHub privacy protection if enabled and pushing to GitHub
609
+ if self.config.get("use_github_noreply", True) and self.config.get(
610
+ "github", True
611
+ ):
612
+ email = updated_info.get("email", "")
613
+
614
+ # If email isn't already a no-reply format
615
+ if email and "@users.noreply.github.com" not in email:
616
+ # Extract username from email or use first part
617
+ username = email.split("@")[0]
618
+
619
+ # If no username from email, try to use name
620
+ if not username and "name" in updated_info:
621
+ # Convert name to a username-like format
622
+ username = updated_info["name"].lower().replace(" ", "-")
623
+
624
+ # Create GitHub no-reply email if we have a username
625
+ if username:
626
+ github_email = f"{username}@users.noreply.github.com"
627
+ self.logger.info(
628
+ f"Using GitHub no-reply email format: {github_email}"
629
+ )
630
+ updated_info["email"] = github_email
631
+
632
+ return updated_info
633
+
634
+ def resolve_directory_profiles(self) -> None:
635
+ """
636
+ Resolve directory profiles into actual directory lists.
637
+
638
+ This method converts directory profile names, groups, and explicit directories
639
+ into a final list of directories to create.
640
+ """
641
+ # Load custom directory profiles from config if available
642
+ if "directory_profiles" in self.config:
643
+ self.directory_manager.profiles.update(self.config["directory_profiles"])
644
+
645
+ if "directory_groups" in self.config and isinstance(self.config["directory_groups"], dict):
646
+ self.directory_manager.groups.update(self.config["directory_groups"])
647
+
648
+ if "directory_types" in self.config:
649
+ self.directory_manager.type_mapping.update(self.config["directory_types"])
650
+
651
+ # Check if we're using a directory profile
652
+ profile = self.config.get("directory_profile")
653
+ groups = self.config.get("directory_groups", [])
654
+ private_set = self.config.get("private_set", "standard")
655
+
656
+ # If we have a profile or groups, use the directory manager
657
+ if profile or groups:
658
+ # Get all directories from the profile manager
659
+ all_dirs = self.directory_manager.get_all_directories(
660
+ profile=profile or "standard",
661
+ groups=groups,
662
+ private_set=private_set,
663
+ package_name=self.config.get("name")
664
+ )
665
+
666
+ # Convert to flat lists
667
+ directories = all_dirs["standard"]
668
+ private_dirs = all_dirs["private"]
669
+
670
+ # Only add explicit additional directories from CLI, not from default config
671
+ if "directories" in self.cli_config:
672
+ existing_dirs = set(directories)
673
+ for d in self.cli_config["directories"]:
674
+ if d not in existing_dirs:
675
+ directories.append(d)
676
+
677
+ # Update config with resolved directories
678
+ self.config["directories"] = directories
679
+ self.config["private_dirs"] = private_dirs
680
+
681
+ if self.verbose >= 1:
682
+ self.logger.info(f"Resolved directory profile '{profile}' to {len(directories)} directories")
683
+
684
+ # Ensure private_dirs is always set
685
+ if "private_dirs" not in self.config:
686
+ self.config["private_dirs"] = DEFAULT_PRIVATE_DIRS.copy()