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
@@ -0,0 +1,2796 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ RepoKit: Repository Template Generator
4
+
5
+ Core repository manager that handles repository initialization,
6
+ branch creation, worktree setup, and template generation.
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+ import json
12
+ import shutil
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Dict, List, Any, Optional, Tuple
16
+
17
+ from .template_engine import TemplateEngine
18
+ from .defaults import (
19
+ DEFAULT_PRIVATE_DIRS, DEFAULT_SENSITIVE_FILES, DEFAULT_SENSITIVE_PATTERNS,
20
+ DEFAULT_PRIVATE_BRANCHES, DEFAULT_PUBLIC_BRANCHES,
21
+ DEFAULT_PRIVATE_PATTERNS, DEFAULT_EXCLUDE_FROM_PUBLIC
22
+ )
23
+
24
+
25
+ class RepoManager:
26
+ """Manages repository setup and configuration."""
27
+
28
+ def __init__(
29
+ self,
30
+ config: Dict[str, Any],
31
+ templates_dir: Optional[str] = None,
32
+ verbose: int = 0,
33
+ ):
34
+ """
35
+ Initialize the repository manager.
36
+
37
+ Args:
38
+ config: Dictionary containing repository configuration
39
+ templates_dir: Optional path to custom templates directory
40
+ verbose: Verbosity level (0=normal, 1=info, 2=debug)
41
+ """
42
+ self.config = config
43
+ self.verbose = verbose
44
+ self.project_name = config.get("name")
45
+ self.project_root = os.path.abspath(self.project_name)
46
+
47
+ # The private branch, or None when the strategy has none (main-only).
48
+ # Only a missing key falls back to the legacy "private".
49
+ self.private_branch = config.get("private_branch", "private")
50
+
51
+ # How private work is kept: "nested" (private/ is its own repository)
52
+ # or "branch" (on the private branch only). The default follows the
53
+ # strategy, so a private-branch strategy alone behaves as it always did.
54
+ self.private_mode = config.get("private_mode") or (
55
+ "nested" if not self.private_branch else "branch"
56
+ )
57
+
58
+ # Vendor git-repokit-common (hooks and tools) into the project: on by
59
+ # default for main-only, opt-in for the private-branch strategies.
60
+ with_common = config.get("with_common")
61
+ self.with_common = (not self.private_branch) if with_common is None else bool(with_common)
62
+ self.common_vendored = False
63
+
64
+ if self.private_branch is None and not config.get("worktrees"):
65
+ # Flat layout: the repository is <name>/ itself, one branch,
66
+ # no worktrees beside it.
67
+ self.repo_root = self.project_root
68
+ self.github_root = None
69
+ else:
70
+ # Legacy layout: <name>/local is the repository (on the private
71
+ # branch) and <name>/github holds the main worktree.
72
+ self.repo_root = os.path.join(self.project_root, "local")
73
+ self.github_root = os.path.join(self.project_root, "github")
74
+
75
+ # Get logger for repokit
76
+ self.logger = logging.getLogger("repokit")
77
+
78
+ # Initialize template engine with same verbosity
79
+ self.template_engine = TemplateEngine(
80
+ templates_dir=templates_dir, verbose=verbose
81
+ )
82
+
83
+ def run_git(
84
+ self, args: List[str], cwd: Optional[str] = None, check: bool = True
85
+ ) -> Optional[str]:
86
+ """
87
+ Run a git command and return its output.
88
+
89
+ Args:
90
+ args: List of git command arguments
91
+ cwd: Working directory for the command
92
+ check: Whether to check for command success
93
+
94
+ Returns:
95
+ Command output as string
96
+ """
97
+ cwd = cwd or self.repo_root
98
+ cmd = ["git"] + args
99
+
100
+ if self.verbose >= 2:
101
+ self.logger.debug(f"Running: {' '.join(cmd)} in {cwd}")
102
+
103
+ try:
104
+ # Set environment for proper encoding on Windows
105
+ env = os.environ.copy()
106
+ if os.name == 'nt': # Windows
107
+ env['LANG'] = 'C.UTF-8'
108
+ env['LC_ALL'] = 'C.UTF-8'
109
+
110
+ result = subprocess.run(
111
+ cmd, cwd=cwd, check=check, capture_output=True, text=True,
112
+ encoding='utf-8', errors='replace', env=env
113
+ )
114
+ return result.stdout.strip()
115
+ except subprocess.CalledProcessError as e:
116
+ self.logger.error(f"Git command failed: {e.stderr}")
117
+ if check:
118
+ raise
119
+ return None
120
+
121
+ def setup_repository(self) -> bool:
122
+ """
123
+ Set up the repository structure according to configuration.
124
+
125
+ Returns:
126
+ True if successful, False otherwise
127
+ """
128
+ try:
129
+ self.logger.info(f"Setting up repository: {self.project_name}")
130
+
131
+ # The licence is a parameter and is required: settle it before a
132
+ # single file is written, so a refusal leaves nothing behind.
133
+ self._license = self._resolve_license()
134
+ # The extras too: an unknown name is refused before anything is written
135
+ self.config["extras"] = self.extras_names(self.config.get("extras"))
136
+
137
+ # Create project directory structure
138
+ self._create_directory_structure()
139
+
140
+ # Initialize git repositories
141
+ self._init_git_repos()
142
+
143
+ # Set up branches
144
+ self._setup_branches()
145
+
146
+ # git-repokit-common: vendored while the tree is still clean (one
147
+ # commit), so in the private-branch model the public branches cut
148
+ # later inherit it. Its hooks are installed at the end.
149
+ if self.with_common and not self.config.get("is_adoption", False):
150
+ self._vendor_repokit_common()
151
+
152
+ # Set up private content protection BEFORE any commits
153
+ # This is critical to prevent private content from reaching public branches
154
+ self._setup_protection()
155
+
156
+ # Nested mode: private/ becomes its own repository, ignored by this one
157
+ if self.private_mode == "nested":
158
+ from .vault import init_private_repo
159
+ if not init_private_repo(self.repo_root, self.project_name, self.logger):
160
+ raise RuntimeError("could not create the private repository in private/")
161
+
162
+ # Generate template files
163
+ self._generate_template_files()
164
+
165
+ # The language templates rewrite .gitignore; put the vault's lines back
166
+ if self.private_mode == "nested":
167
+ from .vault import ensure_parent_ignores
168
+ ensure_parent_ignores(self.repo_root)
169
+
170
+ # The record, before the commit that carries it (0.9.6): it holds
171
+ # only what is settled by now, and written after the last commit it
172
+ # was left untracked in every project and never reached GitHub
173
+ self._generate_repokit_config()
174
+
175
+ # Create initial commit
176
+ self._create_initial_commit()
177
+
178
+ # The vault's AI door (0.9.4): the oracle's first prompt, written
179
+ # once the project's files exist to describe; left uncommitted in
180
+ # the vault for the oracle session to read and run.
181
+ if self.private_mode == "nested" and not self.config.get("is_adoption", False):
182
+ from .vault import bootstrap
183
+ prompt = bootstrap(self.repo_root, self.project_name, self.config.get("vault_init") or "prompt-file")
184
+ if prompt:
185
+ self.logger.info(f"Vault: the oracle's first prompt is {prompt}")
186
+
187
+ # Generate AI integration files if requested (only in private branch, after initial commit)
188
+ if self.config.get("ai_integration"):
189
+ self._generate_ai_templates()
190
+
191
+ # Manage .gitkeep files for adopted repositories (but don't cleanup sensitive files here)
192
+ # Sensitive file cleanup now happens during public branch creation in _create_clean_public_branch
193
+ if self.config.get("is_adoption", False):
194
+ gitkeep_results = self.manage_gitkeep_files()
195
+ self.logger.info(f"Managed .gitkeep files: {len(gitkeep_results.get('added', []))} added, {len(gitkeep_results.get('removed', []))} removed")
196
+ # Note: Sensitive file cleanup now happens during public branch creation
197
+
198
+ # Set up worktrees AFTER initial commits and branch updates
199
+ # This is the key change - moved worktree setup to the end
200
+ self._setup_worktrees()
201
+
202
+ # Nested mode: every worktree reaches the real private/ through a link
203
+ if self.private_mode == "nested":
204
+ self._link_private_into_worktrees()
205
+
206
+ # Set up local exclude patterns for branch-conditional ignoring
207
+ self._setup_local_excludes()
208
+
209
+ # Set up GitHub integration (copy files to GitHub worktree)
210
+ self._setup_github_integration()
211
+
212
+ # repokit-common's hooks, last, so create's own commits never run them
213
+ if self.common_vendored:
214
+ from .common_kit import install_hooks
215
+ problem = install_hooks(self.repo_root)
216
+ if problem:
217
+ self.logger.warning(f"repokit-common hooks not installed: {problem}")
218
+ self.logger.warning("Run: sh scripts/repokit-common/install-hooks.sh")
219
+
220
+ self.logger.info(f"Repository setup complete: {self.project_name}")
221
+ self._print_next_steps()
222
+
223
+ return True
224
+ except Exception as e:
225
+ self.logger.error(f"Error setting up repository: {str(e)}")
226
+ if self.verbose >= 2:
227
+ import traceback
228
+
229
+ traceback.print_exc()
230
+ return False
231
+
232
+ def _create_directory_structure(self) -> None:
233
+ """Create the basic directory structure."""
234
+ self.logger.info("Creating directory structure")
235
+
236
+ # Create main project directory
237
+ os.makedirs(self.project_root, exist_ok=True)
238
+
239
+ # Create local repository directory
240
+ os.makedirs(self.repo_root, exist_ok=True)
241
+
242
+ # Create github directory for worktree (legacy layout only)
243
+ if self.github_root:
244
+ os.makedirs(self.github_root, exist_ok=True)
245
+
246
+ # Create standard subdirectories
247
+ standard_dirs = self.config.get(
248
+ "directories",
249
+ ["convos", "docs", "logs", "private", "revisions", "scripts", "tests"],
250
+ )
251
+
252
+ for directory in standard_dirs:
253
+ dir_path = os.path.join(self.repo_root, directory)
254
+ os.makedirs(dir_path, exist_ok=True)
255
+
256
+ # .gitkeep files will be managed properly by manage_gitkeep_files() method
257
+ # which checks if directories are actually empty before adding .gitkeep
258
+
259
+ def manage_gitkeep_files(self, recursive: bool = True) -> Dict[str, List[str]]:
260
+ """
261
+ Manage .gitkeep files in the repository.
262
+
263
+ Args:
264
+ recursive: Whether to process subdirectories recursively
265
+
266
+ Returns:
267
+ Dictionary with added and removed .gitkeep files
268
+ """
269
+ from .directory_profiles import DirectoryProfileManager
270
+
271
+ profile_manager = DirectoryProfileManager(verbose=self.verbose)
272
+
273
+ # Manage .gitkeep files in the local repo
274
+ result = profile_manager.manage_gitkeep_files(self.repo_root, recursive)
275
+
276
+ self.logger.info(f"Managed .gitkeep files: {len(result['added'])} added, {len(result['removed'])} removed")
277
+ return result
278
+
279
+ def _get_git_version(self):
280
+ """Get git version for feature compatibility."""
281
+ try:
282
+ import re
283
+ import subprocess
284
+ version_output = subprocess.check_output(['git', '--version'], text=True, encoding='utf-8', errors='replace')
285
+ # Parse "git version 2.34.1" -> (2, 34, 1)
286
+ match = re.search(r'git version (\d+)\.(\d+)\.(\d+)', version_output)
287
+ if match:
288
+ return tuple(map(int, match.groups()))
289
+ return (0, 0, 0) # Fallback for unparseable versions
290
+ except Exception:
291
+ return (0, 0, 0) # Fallback for missing git
292
+
293
+ def _supports_initial_branch(self):
294
+ """Check if git supports --initial-branch flag (Git 2.28+)."""
295
+ version = self._get_git_version()
296
+ return version >= (2, 28, 0)
297
+
298
+ def _init_git_repos(self) -> None:
299
+ """Initialize git repositories."""
300
+ self.logger.info("Initializing git repositories")
301
+
302
+ # Check if git is already initialized
303
+ git_dir = os.path.join(self.repo_root, ".git")
304
+ if os.path.exists(git_dir):
305
+ self.logger.info("Git repository already initialized")
306
+ else:
307
+ # For adoptions, start with the private branch to avoid untracked file conflicts
308
+ is_adoption = self.config.get("is_adoption", False)
309
+ if is_adoption:
310
+ private_branch = self.config.get("private_branch", "private")
311
+
312
+ if self._supports_initial_branch():
313
+ # Modern Git: use --initial-branch
314
+ self.run_git(["init", "--initial-branch", private_branch], cwd=self.repo_root)
315
+ self.logger.info(f"Initialized git with initial branch: {private_branch}")
316
+ else:
317
+ # Older Git: use symbolic-ref to set HEAD before first commit
318
+ self.run_git(["init"], cwd=self.repo_root)
319
+ self.run_git(["symbolic-ref", "HEAD", f"refs/heads/{private_branch}"], cwd=self.repo_root)
320
+ self.logger.info(f"Initialized git and set HEAD to: {private_branch}")
321
+ else:
322
+ # Regular project creation
323
+ self.run_git(["init"], cwd=self.repo_root)
324
+
325
+ # Git for Windows keeps to MAX_PATH (260 characters for the whole
326
+ # absolute path) unless told otherwise; repokit-common's deepest
327
+ # vendored path is 133, so a project a hundred-odd characters deep
328
+ # failed `git subtree add` with "Filename too long".
329
+ if os.name == "nt":
330
+ self.run_git(["config", "core.longpaths", "true"], cwd=self.repo_root)
331
+
332
+ # Configure git user information using enhanced GitConfigManager
333
+ from .git_config import GitConfigManager
334
+ git_config_manager = GitConfigManager(
335
+ repo_path=self.repo_root,
336
+ verbose=self.verbose
337
+ )
338
+
339
+ # Get comprehensive user info, falling back to config
340
+ user_info = git_config_manager.get_comprehensive_user_info(interactive=False)
341
+
342
+ # Override with explicit config values if provided
343
+ user = self.config.get("user", {})
344
+ if isinstance(user, dict):
345
+ if user.get("name"):
346
+ user_info["name"] = user["name"]
347
+ if user.get("email"):
348
+ user_info["email"] = user["email"]
349
+
350
+ # Apply GitHub privacy protection if enabled
351
+ if user_info.get("email"):
352
+ email = user_info["email"]
353
+
354
+ if self.config.get("use_github_noreply", True) and self.config.get("github", True):
355
+ # If email isn't already a no-reply format
356
+ if "@users.noreply.github.com" not in email:
357
+ # Extract username from email or use first part
358
+ username = email.split("@")[0]
359
+
360
+ # Create GitHub no-reply email
361
+ github_email = f"{username}@users.noreply.github.com"
362
+ self.logger.info(f"Using GitHub no-reply email format: {github_email}")
363
+ user_info["email"] = github_email
364
+
365
+ # Configure git user settings in repository
366
+ git_config_manager.configure_repo_git_user(user_info)
367
+
368
+ def _setup_branches(self) -> None:
369
+ """Set up repository branches."""
370
+ self.logger.info("Setting up branches")
371
+
372
+ # Check if this is an adoption (existing repo) or new repo
373
+ is_adoption = self.config.get("is_adoption", False)
374
+
375
+ # For adoptions, check if we need to create an initial commit
376
+ if is_adoption:
377
+ # Check if there are any commits in the repository
378
+ try:
379
+ self.run_git(["rev-parse", "HEAD"], cwd=self.repo_root)
380
+ has_commits = True
381
+ self.logger.info("Found existing commits in repository")
382
+ except Exception:
383
+ has_commits = False
384
+ self.logger.info("No commits found in repository - will create initial commit")
385
+
386
+ # If no commits exist, we need to create one before we can create branches
387
+ if not has_commits:
388
+ # First, create a .gitignore to handle sensitive files before any commits
389
+ gitignore_path = os.path.join(self.repo_root, ".gitignore")
390
+
391
+ # Generate the base .gitignore from template
392
+ language = self.config.get("language", "generic")
393
+ context = {
394
+ "project_name": self.project_name,
395
+ "description": self.config.get("description", ""),
396
+ "language": language,
397
+ }
398
+
399
+ # Use template engine to render the base gitignore
400
+ self.template_engine.render_template_to_file(
401
+ "gitignore",
402
+ gitignore_path,
403
+ context,
404
+ category="common", # Use common template
405
+ preserve_existing=False # We want to create fresh
406
+ )
407
+
408
+ # Now append adoption-specific patterns
409
+ adoption_patterns = []
410
+
411
+ # Add sensitive patterns from config
412
+ sensitive_patterns = self.config.get('sensitive_patterns', [])
413
+ self.logger.debug(f"Found sensitive_patterns in config: {sensitive_patterns}")
414
+ if sensitive_patterns:
415
+ adoption_patterns.append("\n# Adoption-specific sensitive files")
416
+ # Handle both comma-separated strings and lists
417
+ if isinstance(sensitive_patterns, str):
418
+ patterns = [p.strip() for p in sensitive_patterns.split(',')]
419
+ else:
420
+ patterns = sensitive_patterns
421
+ adoption_patterns.extend(patterns)
422
+ adoption_patterns.append("")
423
+
424
+ # Add private directories from config
425
+ private_dirs = self.config.get('private_dirs', [])
426
+ if private_dirs:
427
+ adoption_patterns.append("# Adoption-specific private directories")
428
+ # Handle both comma-separated strings and lists
429
+ if isinstance(private_dirs, str):
430
+ dirs = [d.strip() for d in private_dirs.split(',')]
431
+ else:
432
+ dirs = private_dirs
433
+ for dir_name in dirs:
434
+ adoption_patterns.append(f"{dir_name}/")
435
+ adoption_patterns.append("")
436
+
437
+ # Append adoption patterns to the template-generated .gitignore
438
+ self.logger.debug(f"Final adoption_patterns list: {adoption_patterns}")
439
+ if adoption_patterns:
440
+ with open(gitignore_path, "a") as f:
441
+ f.write("\n".join(adoption_patterns))
442
+ self.logger.info(f"Added {len(adoption_patterns)} adoption-specific patterns to .gitignore")
443
+ else:
444
+ self.logger.warning("No adoption patterns to add to .gitignore")
445
+
446
+ # Create a minimal .gitkeep file with RepoKit adoption metadata
447
+ gitkeep_path = os.path.join(self.repo_root, ".gitkeep")
448
+
449
+ # Create adoption metadata
450
+ import json
451
+ from datetime import datetime
452
+
453
+ adoption_metadata = {
454
+ "repokit_adoption": {
455
+ "timestamp": datetime.now().isoformat(),
456
+ "repokit_version": "0.4.0", # TODO: Get from version
457
+ "project_name": self.project_name,
458
+ "description": self.config.get('description', ''),
459
+ "branch_strategy": self.config.get('branch_strategy', 'standard'),
460
+ "migration_strategy": self.config.get('migration_strategy', 'safe'),
461
+ "note": "This file was created to establish git history during RepoKit adoption. It can be safely removed after setup is complete."
462
+ }
463
+ }
464
+
465
+ with open(gitkeep_path, "w") as f:
466
+ f.write("# RepoKit Adoption Bootstrap File\n")
467
+ f.write("# This file establishes initial git history for RepoKit adoption\n")
468
+ f.write("# It can be safely removed after project setup is complete\n\n")
469
+ f.write(json.dumps(adoption_metadata, indent=2))
470
+
471
+ # Add the bootstrap files and respect the new .gitignore
472
+ self.run_git(["add", ".gitignore", ".gitkeep"], cwd=self.repo_root)
473
+ # Add other files that should be tracked (respecting .gitignore)
474
+ self.run_git(["add", "."], cwd=self.repo_root)
475
+ self.run_git(["commit", "-m", "Initial commit for RepoKit adoption\n\nBootstrap commit to establish git history.\nThis commit can be squashed or removed after adoption is complete."], cwd=self.repo_root)
476
+ self.logger.info("Created initial bootstrap commit for adoption")
477
+
478
+ # For new repos, create an initial commit
479
+ if not is_adoption:
480
+ # Create an initial commit on main to allow branch creation
481
+ readme_path = os.path.join(self.repo_root, "README.md")
482
+
483
+ # Generate README from template
484
+ # Get enhanced git user information using GitConfigManager
485
+ from .git_config import GitConfigManager
486
+ git_config_manager = GitConfigManager(
487
+ repo_path=self.repo_root,
488
+ verbose=self.verbose
489
+ )
490
+
491
+ # The README's $badges were never filled here (the template carried
492
+ # the literal word into every project since the template was made):
493
+ # the badges need the resolved language, so resolve the layers first.
494
+ self._resolve_layers()
495
+ language = self.config.get("language", "generic")
496
+ base_context = {
497
+ "project_name": self.project_name,
498
+ "description": self.config.get("description", "A new project"),
499
+ "language": language,
500
+ "badges": self._generate_badges(language),
501
+ **self._docs_context(),
502
+ }
503
+
504
+ # Enhance context with comprehensive git user information
505
+ context = git_config_manager.get_template_context(base_context)
506
+
507
+ # Check if we should preserve existing README
508
+ preserve_existing = self.config.get("preserve_existing", False)
509
+
510
+ # Try to render from template, fall back to simple content
511
+ if not self.template_engine.render_template_to_file(
512
+ "README.md", readme_path, context, preserve_existing=preserve_existing
513
+ ):
514
+ # Only create a new README if one doesn't exist
515
+ if not os.path.exists(readme_path):
516
+ with open(readme_path, "w") as f:
517
+ f.write(f"# {self.project_name}\n\n")
518
+ f.write(f"{self.config.get('description', 'A new project')}\n")
519
+
520
+ # Only commit if there are changes to commit
521
+ if os.path.exists(readme_path):
522
+ self.run_git(["add", "README.md"], cwd=self.repo_root)
523
+ try:
524
+ self.run_git(["commit", "-m", "Initial commit"], cwd=self.repo_root)
525
+ except Exception as e:
526
+ # If commit fails (e.g., nothing to commit), continue
527
+ self.logger.debug("Initial commit skipped (possibly nothing to commit)")
528
+ else:
529
+ # For adoptions, we already have commits
530
+ self.logger.info("Skipping initial commit for adoption - using existing repository history")
531
+
532
+ # Create branches
533
+ branches = self.config.get(
534
+ "branches", ["main", "dev", "staging", "test", "live"]
535
+ )
536
+
537
+ # For adoption, handle existing branch structure differently
538
+ is_adoption = self.config.get("is_adoption", False)
539
+
540
+ if is_adoption:
541
+ # For adoption, we should already be on the private branch from _init_git_repos
542
+ private_branch = self.config.get("private_branch", "private")
543
+ current_branch = self.run_git(["branch", "--show-current"], cwd=self.repo_root)
544
+
545
+ # Verify we're on the private branch (should be true from git init)
546
+ if current_branch != private_branch:
547
+ self.logger.warning(f"Expected to be on {private_branch} branch, but on {current_branch}")
548
+ # Create private branch if it doesn't exist and switch to it
549
+ existing_branches = self.run_git(["branch", "--list"], cwd=self.repo_root).splitlines()
550
+ branch_names = [b.strip().lstrip('* ') for b in existing_branches]
551
+
552
+ if private_branch not in branch_names:
553
+ self.run_git(["branch", private_branch], cwd=self.repo_root)
554
+
555
+ # Switch to private branch (this should now work since we committed files earlier)
556
+ self.run_git(["checkout", private_branch], cwd=self.repo_root)
557
+
558
+ self.logger.info(f"Working on private branch: {private_branch}")
559
+ # Note: Public branches (main, dev) will be created in _create_initial_commit
560
+ else:
561
+ # For new repos, follow original logic
562
+ default_branch = self.config.get("default_branch", "main")
563
+ current_branch = self.run_git(["branch", "--show-current"], cwd=self.repo_root)
564
+
565
+ if current_branch != default_branch:
566
+ if default_branch not in branches:
567
+ branches.append(default_branch)
568
+ self.run_git(["branch", "-m", default_branch], cwd=self.repo_root)
569
+
570
+ # Create other branches
571
+ for branch in branches:
572
+ if branch != default_branch: # Skip default branch which already exists
573
+ self.run_git(["branch", branch], cwd=self.repo_root)
574
+
575
+ # No private branch (main-only): stay on the default branch
576
+ if not self.private_branch:
577
+ return
578
+
579
+ # Create private branch for personal work
580
+ private_branch = self.private_branch
581
+ if private_branch not in branches:
582
+ self.run_git(["branch", private_branch], cwd=self.repo_root)
583
+
584
+ # Switch to private branch (we stay on private in the main repo)
585
+ self.run_git(["checkout", private_branch], cwd=self.repo_root)
586
+
587
+ def _setup_worktrees(self) -> None:
588
+ """Set up git worktrees for branches."""
589
+ self.logger.info("Setting up worktrees")
590
+
591
+ # Get branches to create worktrees for
592
+ worktree_branches = self.config.get(
593
+ "worktrees", [self.config.get("default_branch", "main"), "dev"]
594
+ )
595
+
596
+ # Always add GitHub directory as worktree with the default branch if it�s in the list
597
+ main_branch = self.config.get("default_branch", "main")
598
+ if main_branch in worktree_branches:
599
+ # Get directory name for main branch from branch_config
600
+ branch_config = self.config.get("branch_config", {})
601
+ if not isinstance(branch_config, dict):
602
+ branch_config = {}
603
+ branch_directories = branch_config.get("branch_directories", {})
604
+ if not isinstance(branch_directories, dict):
605
+ branch_directories = {}
606
+ github_dir = branch_directories.get(main_branch, "github")
607
+ github_path = os.path.join(os.path.dirname(self.repo_root), github_dir)
608
+
609
+ try:
610
+ self.run_git(
611
+ ["worktree", "add", github_path, main_branch], cwd=self.repo_root
612
+ )
613
+ self.logger.info(
614
+ f"Created worktree for branch '{main_branch}' at {github_path}"
615
+ )
616
+ except Exception as e:
617
+ self.logger.error(
618
+ f"Failed to create worktree for branch '{main_branch}': {str(e)}"
619
+ )
620
+
621
+ # Configure Git for the GitHub worktree if user information is provided
622
+ user = self.config.get("user", {})
623
+ if user:
624
+ if user.get("name"):
625
+ self.run_git(["config", "user.name", user["name"]], cwd=github_path)
626
+
627
+ if user.get("email"):
628
+ self.run_git(
629
+ ["config", "user.email", user["email"]], cwd=github_path
630
+ )
631
+
632
+ # Add additional worktrees as configured
633
+ worktree_base = os.path.dirname(self.repo_root)
634
+ for branch in worktree_branches:
635
+ if branch == main_branch and main_branch in branch_directories:
636
+ # Skip main branch if it has a custom directory mapping (already handled above)
637
+ continue
638
+
639
+ # Get directory name for this branch
640
+ branch_dir = branch_directories.get(branch, branch)
641
+ worktree_path = os.path.join(worktree_base, branch_dir)
642
+
643
+ # Skip if already exists (might be the main branch worktree we already created)
644
+ if os.path.exists(worktree_path):
645
+ continue
646
+
647
+ try:
648
+ self.run_git(
649
+ ["worktree", "add", worktree_path, branch], cwd=self.repo_root
650
+ )
651
+ self.logger.info(
652
+ f"Created worktree for branch '{branch}' at {worktree_path}"
653
+ )
654
+
655
+ # Configure Git for this worktree if user information is provided
656
+ if user:
657
+ if user.get("name"):
658
+ self.run_git(
659
+ ["config", "user.name", user["name"]], cwd=worktree_path
660
+ )
661
+
662
+ if user.get("email"):
663
+ self.run_git(
664
+ ["config", "user.email", user["email"]], cwd=worktree_path
665
+ )
666
+ except Exception as e:
667
+ self.logger.warning(
668
+ f"Failed to create worktree for branch '{branch}': {str(e)}"
669
+ )
670
+
671
+ def _generate_template_files(self) -> None:
672
+ """Generate template files for the repository."""
673
+ self.logger.info("Generating template files")
674
+
675
+ # Get language to determine specific templates
676
+ language = self.config.get("language", "generic")
677
+
678
+ # Check if we should preserve existing files (for adoption)
679
+ preserve_existing = self.config.get("preserve_existing", False)
680
+
681
+ # Helper function to render templates with preserve_existing
682
+ def render_template(template_name, output_path, context_dict=None, category="common"):
683
+ ctx = context_dict or context
684
+ return self.template_engine.render_template_to_file(
685
+ template_name, output_path, ctx, category=category, preserve_existing=preserve_existing
686
+ )
687
+
688
+ # Get enhanced git user information using GitConfigManager
689
+ from .git_config import GitConfigManager
690
+ git_config_manager = GitConfigManager(
691
+ repo_path=self.repo_root,
692
+ verbose=self.verbose
693
+ )
694
+
695
+ # The code-type resolution first: it settles the language, the archetype
696
+ # and so which CI workflow the README's badge can point at.
697
+ self._resolve_layers()
698
+ language = self.config.get("language", "generic")
699
+
700
+ github_user, github_owner = self._github_names()
701
+ import datetime
702
+
703
+ # Context for template rendering with enhanced git config
704
+ base_context = {
705
+ "project_name": self.project_name,
706
+ "language": language,
707
+ "description": self.config.get("description", ""),
708
+ "badges": self._generate_badges(language),
709
+ # the person's GitHub login (CODEOWNERS, FUNDING)
710
+ "github_user": github_user,
711
+ # the repository's owner (every URL): the organisation when one is
712
+ # named, else the person
713
+ "github_org": github_owner,
714
+ "package_name": self.project_name.replace("-", "_"),
715
+ "date": datetime.date.today().isoformat(),
716
+ **self._docs_context(),
717
+ }
718
+
719
+ # Enhance context with comprehensive git user information
720
+ context = git_config_manager.get_template_context(base_context)
721
+
722
+ # Create .github directory
723
+ github_dir = os.path.join(self.repo_root, ".github")
724
+ os.makedirs(github_dir, exist_ok=True)
725
+
726
+ # Create ISSUE_TEMPLATE directory
727
+ issue_dir = os.path.join(github_dir, "ISSUE_TEMPLATE")
728
+ os.makedirs(issue_dir, exist_ok=True)
729
+
730
+ # CI comes from the code-type layers (ci-<language>.yml), not from the
731
+ # structure: git-repokit's own workflow used to be written here.
732
+
733
+ # Generate issue templates
734
+ render_template(
735
+ "bug-report.md",
736
+ os.path.join(issue_dir, "bug-report.md"),
737
+ category="github/ISSUE_TEMPLATE"
738
+ )
739
+
740
+ render_template(
741
+ "feature-request.md",
742
+ os.path.join(issue_dir, "feature-request.md"),
743
+ category="github/ISSUE_TEMPLATE"
744
+ )
745
+
746
+ # Generate CODEOWNERS file
747
+ render_template(
748
+ "CODEOWNERS",
749
+ os.path.join(github_dir, "CODEOWNERS"),
750
+ category="github"
751
+ )
752
+
753
+ # Generate other common files
754
+ render_template(
755
+ "CONTRIBUTING.md",
756
+ os.path.join(self.repo_root, "CONTRIBUTING.md"),
757
+ category="common"
758
+ )
759
+
760
+ # LICENSE: the parameter settled in setup_repository (0.9.0)
761
+ self._write_license(context)
762
+
763
+ # The optional structure files the global config or --extras named (0.9.0)
764
+ self._write_extras(context, render_template)
765
+
766
+ # Line-ending normalisation and the vendored-tooling attribute
767
+ render_template(
768
+ "gitattributes",
769
+ os.path.join(self.repo_root, ".gitattributes"),
770
+ category="common"
771
+ )
772
+
773
+ # A changelog with the first version's heading, so git-repokit-common's
774
+ # version sync finds its section and compare link from the first commit
775
+ render_template(
776
+ "CHANGELOG.md",
777
+ os.path.join(self.repo_root, "CHANGELOG.md"),
778
+ category="common"
779
+ )
780
+
781
+ # The structure's .gitignore, then the code type on top (launch.json is a layer file since 0.9.2)
782
+ self._write_gitignore_and_launch(context)
783
+ self._apply_layers(context)
784
+
785
+ def _resolve_license(self):
786
+ """What LICENSE this project gets: ("catalogue", id), ("file", path) or
787
+ ("none", None). --license wins; else the config's `license_private` for
788
+ a private repository, else `license`; nothing named is a refusal
789
+ (repokit.licenses.resolve), unless an adopted project already has one."""
790
+ from . import licenses
791
+ existing = os.path.join(self.repo_root, "LICENSE")
792
+ if self.config.get("preserve_existing") and os.path.exists(existing) and not self.config.get("license_cli"):
793
+ return ("existing", None)
794
+ spec = self.config.get("license") if self.config.get("license_cli") else None
795
+ return licenses.resolve(spec, self.config.get("license"),
796
+ bool(self.config.get("private_repo")), self.config.get("license_private"))
797
+
798
+ # The extras: name -> the files it writes, as (template, destination). The
799
+ # templates are git-repokit-template's own files, copied in with the OS on
800
+ # 2026-10-23 (`$GITHUB_USER` became `$github_user` and so on); ROADMAP.md and
801
+ # platform-support.md are authored here. A gitkeep has no template.
802
+ EXTRAS: Dict[str, List[Tuple[Optional[str], str]]] = {
803
+ "funding": [("FUNDING.yml", ".github/FUNDING.yml")],
804
+ "dependabot": [("dependabot.yml", ".github/dependabot.yml")],
805
+ "stale": [("stale.yml", ".github/stale.yml")],
806
+ "pr-template": [("pull_request_template.md", ".github/pull_request_template.md")],
807
+ "release-template": [("release-template.md", ".github/release-template.md")],
808
+ "roadmap": [("ROADMAP.md", "ROADMAP.md")],
809
+ "platform-support": [("platform-support.md", "docs/platform-support.md")],
810
+ "vscode-settings": [("settings.json", ".vscode/settings.json")],
811
+ "placeholders": [(None, "docs/.gitkeep"), ("one-offs-README.md", "tests/one-offs/README.md"), (None, "tests/output/.gitkeep")],
812
+ }
813
+
814
+ @classmethod
815
+ def extras_names(cls, requested) -> List[str]:
816
+ """The extras to write, in the table's order; `all` means every one;
817
+ an unknown name is refused naming the table."""
818
+ wanted = list(requested or [])
819
+ if "all" in wanted:
820
+ return list(cls.EXTRAS)
821
+ unknown = [w for w in wanted if w not in cls.EXTRAS]
822
+ if unknown:
823
+ raise ValueError(f"unknown extras {', '.join(unknown)}; known: {', '.join(cls.EXTRAS)}, all")
824
+ return [name for name in cls.EXTRAS if name in wanted]
825
+
826
+ def _write_extras(self, context: Dict[str, Any], render_template) -> None:
827
+ names = self.extras_names(self.config.get("extras"))
828
+ self.config["extras"] = names # what .repokit.json records
829
+ for name in names:
830
+ for template, rel in self.EXTRAS[name]:
831
+ full = os.path.join(self.repo_root, rel)
832
+ os.makedirs(os.path.dirname(full), exist_ok=True)
833
+ if template is None:
834
+ if not os.path.exists(full):
835
+ open(full, "wb").close()
836
+ else:
837
+ render_template(template, full, category="extras")
838
+ if names:
839
+ self.logger.info("Extras written: " + ", ".join(names))
840
+
841
+ def _write_license(self, context: Dict[str, Any]) -> None:
842
+ """Write LICENSE from the settled choice; bytes as-is for a file, the
843
+ catalogue text with holder and year otherwise."""
844
+ from . import licenses
845
+ kind, value = getattr(self, "_license", None) or self._resolve_license()
846
+ path = os.path.join(self.repo_root, "LICENSE")
847
+ if kind in ("none", "existing"):
848
+ self.logger.info("LICENSE: " + ("none asked" if kind == "none" else "keeping the project's own"))
849
+ return
850
+ if kind == "file":
851
+ with open(value, "rb") as src, open(path, "wb") as dst:
852
+ dst.write(src.read())
853
+ self.logger.info(f"LICENSE copied from {value}")
854
+ return
855
+ holder = (self.config.get("user") or {}).get("name") or context.get("author") or "the copyright holder"
856
+ text = licenses.render(value, holder)
857
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
858
+ f.write(text)
859
+ self.logger.info(f"LICENSE written: {value} ({holder})")
860
+
861
+ def _write_gitignore_and_launch(self, context: Dict[str, Any]) -> None:
862
+ """The common .gitignore (keeping an adopted project's own patterns) and
863
+ VS Code's launch.json. The language files that used to be written here
864
+ come from the code-type layers now (_apply_layers)."""
865
+ gitignore_path = os.path.join(self.repo_root, ".gitignore")
866
+
867
+ # An adopted repository's own patterns sit after "# Add custom patterns
868
+ # below"; keep them across the rewrite.
869
+ existing_adoption_patterns = ""
870
+ if os.path.exists(gitignore_path):
871
+ with open(gitignore_path, 'r') as f:
872
+ content = f.read()
873
+ if "Adoption-specific" in content:
874
+ lines = content.split('\n')
875
+ add_custom_found = False
876
+ adoption_lines = []
877
+ for line in lines:
878
+ if "# Add custom patterns below" in line:
879
+ add_custom_found = True
880
+ continue
881
+ if add_custom_found:
882
+ adoption_lines.append(line)
883
+ if adoption_lines:
884
+ existing_adoption_patterns = '\n'.join(adoption_lines)
885
+ self.logger.debug(f"Found existing adoption patterns to preserve: {len(adoption_lines)} lines")
886
+
887
+ self.template_engine.render_template_to_file(
888
+ "gitignore",
889
+ gitignore_path,
890
+ context,
891
+ category="common",
892
+ )
893
+
894
+ if existing_adoption_patterns.strip():
895
+ with open(gitignore_path, "a") as f:
896
+ f.write(existing_adoption_patterns)
897
+ self.logger.info("Preserved adoption-specific patterns in .gitignore")
898
+
899
+ # .vscode/launch.json (and tasks.json) come from the code-type layers
900
+ # since 0.9.2, one per preset; the structure's old template was
901
+ # Python-only and was written for every language.
902
+
903
+ def _resolve_layers(self):
904
+ """Resolve the code-type layers once (repokit/layers.py): returns
905
+ (root, resolution) or None when the package has no layer tree, and
906
+ settles config's `languages`, `archetype` and single-value `language`
907
+ (an archetype implies its language; .repokit.json, the badges and the
908
+ build-command map read them). The CLI has validated the names already,
909
+ so an error here is a broken layer tree and stops the create."""
910
+ from . import layers
911
+
912
+ if getattr(self, "_layer_resolution", None) is not None:
913
+ return self._layer_resolution
914
+ languages = self.config.get("languages") or [self.config.get("language") or layers.GENERIC]
915
+ with_tokens = self.config.get("with")
916
+ root = layers.layers_root(self.template_engine)
917
+ if root is None:
918
+ if [l for l in layers.parse_languages(languages) if l != layers.GENERIC] or with_tokens:
919
+ self.logger.warning("No code-type layers found with the templates; only the structure was written")
920
+ return None
921
+ resolution = layers.resolve(layers.load_manifest(root), languages, with_tokens)
922
+ self.config["languages"] = resolution.languages
923
+ self.config["archetype"] = resolution.archetype # the preset the request equals, or None
924
+ self.config["layers"] = list(resolution.names) # every layer, qualified, in apply order
925
+ self.config["language"] = resolution.languages[0] if resolution.languages else layers.GENERIC
926
+ self._layer_resolution = (root, resolution)
927
+ return self._layer_resolution
928
+
929
+ def _ci_workflow(self) -> Optional[str]:
930
+ """The CI workflow file the resolved layers will write, for the README's
931
+ badge: ci-<language>.yml from the first language whose layer or
932
+ archetype ships one; None when none does (then no workflow badge)."""
933
+ resolved = self._resolve_layers()
934
+ if resolved is None:
935
+ return None
936
+ root, resolution = resolved
937
+ for layer in reversed(resolution.layers):
938
+ folder = root / layer / ".github" / "workflows"
939
+ if folder.is_dir():
940
+ names = sorted(p.name for p in folder.iterdir() if p.name.startswith("ci-") and p.suffix == ".yml")
941
+ if names:
942
+ return names[0]
943
+ return None
944
+
945
+ def _apply_layers(self, context: Dict[str, Any]) -> None:
946
+ """Lay the code-type layers (the languages' and the archetype's) over the
947
+ structure, from the resolution _resolve_layers settled."""
948
+ from . import layers
949
+ from .template_engine import token_context
950
+
951
+ resolved = self._resolve_layers()
952
+ if resolved is None:
953
+ return
954
+ root, resolution = resolved
955
+ if not resolution.layers:
956
+ return
957
+ ctx = token_context(context, resolution.archetype)
958
+ skipped: List[str] = []
959
+ written = layers.assemble(
960
+ root, resolution.layers, self.repo_root, ctx,
961
+ engine=self.template_engine,
962
+ preserve_existing=self.config.get("preserve_existing", False),
963
+ logger=self.logger,
964
+ # two or more languages: the debugger files are the first language's
965
+ primary_language=resolution.languages[0] if len(resolution.languages) > 1 else None,
966
+ skipped=skipped,
967
+ )
968
+ if skipped:
969
+ others = sorted({s.split(":", 1)[0] for s in skipped})
970
+ print(f"Note: .vscode/ holds {resolution.languages[0]}'s debug configurations; "
971
+ f"{', '.join(others)}'s were not merged in (add them to .vscode/launch.json by hand)")
972
+ self.logger.info(f"Code-type layers {', '.join(resolution.layers)}: {len(written)} files")
973
+ for note in resolution.notes:
974
+ # Once, to the person: a composition no preset vouches for (with
975
+ # the tokens it was assembled from), or a language left bare.
976
+ tokens = f" ({', '.join(resolution.tokens)})" if resolution.tokens and note == layers.NOT_A_PRESET else ""
977
+ print(f"Note: {note}{tokens}")
978
+
979
+ # A layer may ship .repokit-common.toml (or a pyproject table) of its
980
+ # own; in the private-branch model the settings written at vendoring
981
+ # time must still be there, so merge them back (never overwriting).
982
+ if self.private_branch and getattr(self, "common_vendored", False):
983
+ from . import common_kit
984
+ private_paths = ["CLAUDE.md"]
985
+ private_paths += [f"{d.rstrip('/')}/" for d in self.config.get("private_dirs", [])]
986
+ private_paths += list(self.config.get("sensitive_files", []))
987
+ common_kit.write_settings(
988
+ self.repo_root, common_kit.private_branch_settings(self.private_branch, private_paths))
989
+
990
+ def _vendor_repokit_common(self) -> None:
991
+ """Add git-repokit-common at scripts/repokit-common/ and commit its settings.
992
+
993
+ A failure (offline, no git subtree) is a warning with the commands to
994
+ run by hand, never a failed create; the project then keeps git-repokit's
995
+ own pre-commit hook as its guard (_setup_protection).
996
+ """
997
+ from . import common_kit
998
+
999
+ src = common_kit.source(self.config)
1000
+ if src is None:
1001
+ self.logger.info("git-repokit-common: vendoring switched off")
1002
+ return
1003
+ url, ref = src
1004
+ problem = common_kit.vendor(self.repo_root, url, ref)
1005
+ if problem:
1006
+ self.logger.warning(f"git-repokit-common not added: {problem}")
1007
+ self.logger.warning(
1008
+ f"Run: git subtree add --prefix={common_kit.PREFIX} {url} {ref} --squash"
1009
+ )
1010
+ return
1011
+ self.common_vendored = True
1012
+ self.logger.info(f"Vendored git-repokit-common at {common_kit.PREFIX}/")
1013
+
1014
+ # The private-branch model: repokit-common's pre-commit must protect the
1015
+ # paths git-repokit's guard did, and know a custom private branch.
1016
+ if self.private_branch:
1017
+ private_paths = ["CLAUDE.md"]
1018
+ private_paths += [f"{d.rstrip('/')}/" for d in self.config.get("private_dirs", [])]
1019
+ private_paths += list(self.config.get("sensitive_files", []))
1020
+ written = common_kit.write_settings(
1021
+ self.repo_root,
1022
+ common_kit.private_branch_settings(self.private_branch, private_paths),
1023
+ )
1024
+ if written:
1025
+ self.run_git(["add", written], cwd=self.repo_root)
1026
+ self.run_git(["commit", "-m", "Configure git-repokit-common for the private-branch model"],
1027
+ cwd=self.repo_root)
1028
+
1029
+ def _link_private_into_worktrees(self) -> None:
1030
+ """Link <worktree>/private to the real vault in every worktree but the main one."""
1031
+ from .vault import link_private
1032
+
1033
+ private_path = os.path.join(self.repo_root, "private")
1034
+
1035
+ # Every worktree reads the repository's shared info/exclude. "/private"
1036
+ # (no trailing slash) also matches the link itself, which git sees as a
1037
+ # symlink, not a directory; and public branches may carry no .gitignore.
1038
+ common_dir = self.run_git(["rev-parse", "--git-common-dir"], cwd=self.repo_root, check=False) or ".git"
1039
+ if not os.path.isabs(common_dir):
1040
+ common_dir = os.path.join(self.repo_root, common_dir)
1041
+ exclude = os.path.join(common_dir, "info", "exclude")
1042
+ os.makedirs(os.path.dirname(exclude), exist_ok=True)
1043
+ existing = open(exclude, encoding="utf-8").read() if os.path.exists(exclude) else ""
1044
+ if "/private" not in existing.splitlines():
1045
+ with open(exclude, "a", encoding="utf-8") as f:
1046
+ f.write(("\n" if existing and not existing.endswith("\n") else "")
1047
+ + "# private/ in a worktree is a link to the private repository\n/private\n")
1048
+
1049
+ output = self.run_git(["worktree", "list", "--porcelain"], cwd=self.repo_root, check=False) or ""
1050
+ main = os.path.normcase(os.path.abspath(self.repo_root))
1051
+ for line in output.splitlines():
1052
+ if not line.startswith("worktree "):
1053
+ continue
1054
+ path = os.path.abspath(line[len("worktree "):].strip())
1055
+ if os.path.normcase(path) == main:
1056
+ continue
1057
+ problem = link_private(path, private_path)
1058
+ if problem:
1059
+ self.logger.warning(f"Could not link private/ into {path}: {problem}")
1060
+ else:
1061
+ self.logger.info(f"Linked {path}/private to the private repository")
1062
+
1063
+ def _setup_github_integration(self) -> None:
1064
+ """Set up GitHub integration."""
1065
+ self.logger.info("Setting up GitHub integration")
1066
+
1067
+ # Skip if GitHub integration is disabled
1068
+ if not self.config.get("github", True):
1069
+ self.logger.info("GitHub integration is disabled, skipping")
1070
+ return
1071
+
1072
+ # Flat layout: .github was generated in the repository itself and
1073
+ # committed with everything else
1074
+ if not self.github_root:
1075
+ return
1076
+
1077
+ # Only proceed if the GitHub worktree exists
1078
+ if not os.path.exists(self.github_root):
1079
+ self.logger.warning(
1080
+ "GitHub worktree not found, skipping GitHub integration"
1081
+ )
1082
+ return
1083
+
1084
+ # Copy .github directory to github worktree
1085
+ github_src = os.path.join(self.repo_root, ".github")
1086
+ github_dst = os.path.join(self.github_root, ".github")
1087
+
1088
+ if os.path.exists(github_src):
1089
+ if os.path.exists(github_dst):
1090
+ shutil.rmtree(github_dst)
1091
+ shutil.copytree(github_src, github_dst)
1092
+
1093
+ # Check if there are changes to commit before trying
1094
+ if self._has_changes(self.github_root):
1095
+ try:
1096
+ self.run_git(["add", ".github"], cwd=self.github_root)
1097
+ self.run_git(
1098
+ ["commit", "-m", "Add GitHub configuration files"],
1099
+ cwd=self.github_root,
1100
+ )
1101
+ self.logger.info("Committed GitHub configuration files")
1102
+ except Exception as e:
1103
+ self.logger.warning(f"Failed to commit GitHub files: {str(e)}")
1104
+ else:
1105
+ self.logger.info("No GitHub files to commit")
1106
+ else:
1107
+ self.logger.warning(
1108
+ "No .github directory found in repository, skipping GitHub integration"
1109
+ )
1110
+
1111
+ def _setup_protection(self) -> None:
1112
+ """Set up protection for private content."""
1113
+ self.logger.info("Setting up private content protection")
1114
+
1115
+ # Check if .gitignore exists and read its content
1116
+ gitignore_path = os.path.join(self.repo_root, ".gitignore")
1117
+ existing_content = ""
1118
+ if os.path.exists(gitignore_path):
1119
+ with open(gitignore_path, "r") as f:
1120
+ existing_content = f.read()
1121
+
1122
+ # Get private directories from config
1123
+ private_dirs = self.config.get(
1124
+ "private_dirs", DEFAULT_PRIVATE_DIRS
1125
+ )
1126
+
1127
+ # Only add entries that don't already exist
1128
+ entries_to_add = []
1129
+ for private_dir in private_dirs:
1130
+ pattern = f"/{private_dir}/"
1131
+ if pattern not in existing_content:
1132
+ entries_to_add.append(pattern)
1133
+
1134
+ # Only append if we have new entries to add
1135
+ if entries_to_add:
1136
+ with open(gitignore_path, "a") as f:
1137
+ f.write("\n# Private content (auto-added)\n")
1138
+ for entry in entries_to_add:
1139
+ f.write(f"{entry}\n")
1140
+
1141
+ # Add __private__ pattern if not already present
1142
+ if "**/__private__*" not in existing_content:
1143
+ f.write("**/__private__*\n")
1144
+ f.write("\n")
1145
+
1146
+ # With git-repokit-common vendored, its hooks are the project's hooks
1147
+ # (installed at the end of setup_repository); git-repokit writes none.
1148
+ if self.common_vendored:
1149
+ return
1150
+
1151
+ # Fallback guard when git-repokit-common is not vendored (--no-common,
1152
+ # or vendoring failed): git-repokit's own pre-commit hook.
1153
+ hooks_dir = os.path.join(self.repo_root, ".git", "hooks")
1154
+ os.makedirs(hooks_dir, exist_ok=True)
1155
+
1156
+ # Import BranchContext to get consistent patterns
1157
+ from .branch_utils import BranchContext
1158
+
1159
+ # Create context with configuration
1160
+ branch_context = BranchContext(self.repo_root, self._get_branch_config())
1161
+
1162
+ # Generate branch patterns for template
1163
+ private_branch_patterns = "|".join(branch_context.PRIVATE_BRANCHES)
1164
+ public_branch_patterns = "|".join(branch_context.PUBLIC_BRANCHES)
1165
+
1166
+ # Add feature branch patterns (they're considered private in BranchContext)
1167
+ private_branch_patterns += "|feature/*|feat/*|prototype/*|experiment/*|spike/*"
1168
+
1169
+ # Generate private content patterns for template
1170
+ # Start with BranchContext patterns and add config-based patterns
1171
+ all_patterns = set()
1172
+
1173
+ # Add BranchContext patterns
1174
+ all_patterns.update(branch_context.PRIVATE_PATTERNS)
1175
+ all_patterns.update(branch_context.EXCLUDE_FROM_PUBLIC)
1176
+
1177
+ # Add config-based sensitive patterns
1178
+ config_sensitive_patterns = self.config.get('sensitive_patterns', [])
1179
+ if config_sensitive_patterns:
1180
+ all_patterns.update(config_sensitive_patterns)
1181
+ self.logger.debug(f"Added {len(config_sensitive_patterns)} config-based sensitive patterns to hook")
1182
+
1183
+ # Add config-based sensitive files
1184
+ config_sensitive_files = self.config.get('sensitive_files', [])
1185
+ if config_sensitive_files:
1186
+ all_patterns.update(config_sensitive_files)
1187
+ self.logger.debug(f"Added {len(config_sensitive_files)} config-based sensitive files to hook")
1188
+
1189
+ # Convert all patterns to shell regex patterns (escaped: `*.log` used
1190
+ # to become `^.*.log` and refuse catalog.py or search_sesslog.py)
1191
+ from .utils import glob_to_ere
1192
+ patterns = [glob_to_ere(pattern) for pattern in sorted(all_patterns)]
1193
+
1194
+ # Join all patterns with | for shell regex
1195
+ private_content_regex = "|".join(patterns)
1196
+
1197
+ # Render pre-commit hook from template using BranchContext patterns
1198
+ context = {
1199
+ "private_branch_patterns": private_branch_patterns,
1200
+ "public_branch_patterns": public_branch_patterns,
1201
+ "private_content_regex": private_content_regex
1202
+ }
1203
+
1204
+ pre_commit_path = os.path.join(hooks_dir, "pre-commit")
1205
+ if not self.template_engine.render_template_to_file(
1206
+ "pre-commit", pre_commit_path, context, category="hooks"
1207
+ ):
1208
+ # Fallback to creating a simple hook
1209
+ with open(pre_commit_path, "w") as f:
1210
+ f.write(
1211
+ """#!/bin/sh
1212
+ # Pre-commit hook to prevent committing private content
1213
+
1214
+ # Check for private directories
1215
+ for file in $(git diff --cached --name-only); do
1216
+ if echo "$file" | grep -q -E "^private/|^convos/|^logs/|__private__"; then
1217
+ echo "ERROR: Attempting to commit private content: $file"
1218
+ echo "Private content should not be committed to the repository."
1219
+ exit 1
1220
+ fi
1221
+ done
1222
+
1223
+ exit 0
1224
+ """
1225
+ )
1226
+
1227
+ # Make hook executable on Unix-like systems
1228
+ if os.name != "nt": # not Windows
1229
+ os.chmod(pre_commit_path, 0o755)
1230
+
1231
+ def _setup_local_excludes(self) -> None:
1232
+ """Set up local exclude patterns for branch-conditional file ignoring."""
1233
+ self.logger.info("Setting up local exclude patterns")
1234
+
1235
+ # Path to local exclude file
1236
+ exclude_file = os.path.join(self.repo_root, ".git", "info", "exclude")
1237
+
1238
+ # Ensure the info directory exists
1239
+ os.makedirs(os.path.dirname(exclude_file), exist_ok=True)
1240
+
1241
+ # Define patterns for files that should be tracked in private but ignored in public branches
1242
+ exclude_patterns = [
1243
+ "# RepoKit: Branch-conditional excludes",
1244
+ "# These files are tracked in 'private' branch but ignored in public branches",
1245
+ "",
1246
+ "# AI integration files",
1247
+ "CLAUDE.md",
1248
+ "",
1249
+ "# Development conversation logs",
1250
+ "private/claude/",
1251
+ "",
1252
+ "# Private development documentation",
1253
+ "private/docs/",
1254
+ "",
1255
+ "# Development notes and temporary files",
1256
+ "private/notes/",
1257
+ "private/temp/",
1258
+ "",
1259
+ "# Revision history (private branch only)",
1260
+ "revisions/",
1261
+ "",
1262
+ ]
1263
+
1264
+ # Read existing exclude file if it exists
1265
+ existing_content = []
1266
+ if os.path.exists(exclude_file):
1267
+ with open(exclude_file, "r") as f:
1268
+ existing_content = f.read().splitlines()
1269
+
1270
+ # Check if our patterns are already present
1271
+ repokit_marker = "# RepoKit: Branch-conditional excludes"
1272
+ if repokit_marker not in existing_content:
1273
+ # Append our patterns
1274
+ with open(exclude_file, "a") as f:
1275
+ if existing_content and not existing_content[-1].strip() == "":
1276
+ f.write("\n") # Add spacing if file has content
1277
+ f.write("\n".join(exclude_patterns))
1278
+
1279
+ self.logger.info("Added branch-conditional exclude patterns")
1280
+ else:
1281
+ self.logger.debug("Local exclude patterns already configured")
1282
+
1283
+ # Set up local excludes for worktrees as well
1284
+ self._setup_worktree_excludes()
1285
+
1286
+ def _setup_worktree_excludes(self) -> None:
1287
+ """Set up local exclude patterns for all worktrees."""
1288
+ # Get list of worktrees
1289
+ try:
1290
+ worktree_output = self.run_git(
1291
+ ["worktree", "list"], cwd=self.repo_root, check=False
1292
+ )
1293
+ if not worktree_output:
1294
+ return
1295
+
1296
+ # Parse worktree paths
1297
+ for line in worktree_output.split("\n"):
1298
+ if not line.strip():
1299
+ continue
1300
+
1301
+ # Parse worktree line (format: "/path/to/worktree abcd1234 [branch]")
1302
+ parts = line.split()
1303
+ if len(parts) >= 3 and "[" in parts[2] and "]" in parts[2]:
1304
+ worktree_path = parts[0]
1305
+ branch = parts[2].strip("[]")
1306
+
1307
+ # Skip private branch worktree
1308
+ if branch == self.config.get("private_branch", "private"):
1309
+ continue
1310
+
1311
+ # Set up exclude file for this worktree
1312
+ exclude_file = os.path.join(
1313
+ worktree_path, ".git", "info", "exclude"
1314
+ )
1315
+
1316
+ # Worktrees have .git as a file, not directory
1317
+ git_dir_file = os.path.join(worktree_path, ".git")
1318
+ if os.path.isfile(git_dir_file):
1319
+ # Read the git directory path from .git file
1320
+ with open(git_dir_file, "r") as f:
1321
+ git_dir_line = f.read().strip()
1322
+ if git_dir_line.startswith("gitdir: "):
1323
+ git_dir = git_dir_line[8:] # Remove 'gitdir: ' prefix
1324
+ exclude_file = os.path.join(git_dir, "info", "exclude")
1325
+
1326
+ # Ensure directory exists
1327
+ os.makedirs(os.path.dirname(exclude_file), exist_ok=True)
1328
+
1329
+ # Same exclude patterns for worktrees
1330
+ exclude_patterns = [
1331
+ "# RepoKit: Branch-conditional excludes for worktree",
1332
+ "# These files are tracked in 'private' branch but ignored in public branches",
1333
+ "",
1334
+ "# AI integration files",
1335
+ "CLAUDE.md",
1336
+ "",
1337
+ "# Development conversation logs",
1338
+ "private/claude/",
1339
+ "",
1340
+ "# Private development documentation",
1341
+ "private/docs/",
1342
+ "",
1343
+ "# Development notes and temporary files",
1344
+ "private/notes/",
1345
+ "private/temp/",
1346
+ "",
1347
+ "# Revision history (private branch only)",
1348
+ "revisions/",
1349
+ "",
1350
+ ]
1351
+
1352
+ # Check if already configured
1353
+ existing_content = []
1354
+ if os.path.exists(exclude_file):
1355
+ with open(exclude_file, "r") as f:
1356
+ existing_content = f.read().splitlines()
1357
+
1358
+ repokit_marker = (
1359
+ "# RepoKit: Branch-conditional excludes for worktree"
1360
+ )
1361
+ if repokit_marker not in existing_content:
1362
+ with open(exclude_file, "a") as f:
1363
+ if (
1364
+ existing_content
1365
+ and not existing_content[-1].strip() == ""
1366
+ ):
1367
+ f.write("\n")
1368
+ f.write("\n".join(exclude_patterns))
1369
+
1370
+ self.logger.debug(
1371
+ f"Added exclude patterns for worktree: {worktree_path}"
1372
+ )
1373
+
1374
+ except Exception as e:
1375
+ self.logger.warning(f"Failed to set up worktree excludes: {str(e)}")
1376
+
1377
+ def _create_initial_commit(self) -> None:
1378
+ """Create initial commit in repository."""
1379
+ self.logger.info("Creating initial commit")
1380
+
1381
+ # Add all files
1382
+ self.run_git(["add", "."], cwd=self.repo_root)
1383
+
1384
+ # Check if there's anything to commit
1385
+ try:
1386
+ # Use diff --cached to see if there are staged changes
1387
+ diff_output = self.run_git(["diff", "--cached", "--name-only"], cwd=self.repo_root)
1388
+ if diff_output.strip():
1389
+ # Create commit only if there are changes
1390
+ self.run_git(
1391
+ ["commit", "-m", "Initial repository setup with RepoKit"],
1392
+ cwd=self.repo_root,
1393
+ )
1394
+ else:
1395
+ self.logger.info("No changes to commit in initial setup")
1396
+ except Exception as e:
1397
+ # An adoption may have nothing new to commit; anything else (a
1398
+ # hook refusing the commit) must be seen, not buried at debug level
1399
+ # under the success banner.
1400
+ if self.config.get("is_adoption", False):
1401
+ self.logger.debug(f"Initial commit skipped: {str(e)}")
1402
+ else:
1403
+ self.logger.error(
1404
+ f"Initial commit failed; the files are staged but not committed: {str(e)}"
1405
+ )
1406
+
1407
+ # No private branch (main-only): the commit above is on main and
1408
+ # private paths were never added (.gitignore), so there is nothing
1409
+ # to cut public branches from.
1410
+ if not self.private_branch:
1411
+ return
1412
+
1413
+ # Create public branches with safe content migration using safe-merge-dev
1414
+ # This prevents private content from reaching public branches
1415
+ branches = self.config.get(
1416
+ "branches", ["main", "dev", "staging", "test", "live"]
1417
+ )
1418
+ private_branch = self.private_branch
1419
+
1420
+ for branch in branches:
1421
+ if branch != private_branch: # Skip private branch
1422
+ try:
1423
+ # Choose approach based on whether this is an adoption
1424
+ is_adoption = self.config.get("is_adoption", False)
1425
+
1426
+ if is_adoption:
1427
+ # Use selective approach for adoptions (privacy-first, no deletion commits)
1428
+ self.logger.info(f"Using selective branch creation for adoption (privacy-first)")
1429
+ self._create_clean_public_branch_selective(branch, private_branch)
1430
+ else:
1431
+ # Use existing approach for new repositories
1432
+ self.logger.info(f"Using standard branch creation for new repository")
1433
+ self._create_clean_public_branch(branch, private_branch)
1434
+
1435
+ # Verify the branch is actually clean
1436
+ if self._verify_clean_branch(branch):
1437
+ self.logger.info(f"Successfully created and verified clean public branch '{branch}'")
1438
+ else:
1439
+ self.logger.error(f"Branch '{branch}' failed verification - contains private content!")
1440
+
1441
+ if self.verbose >= 2:
1442
+ approach = "selective" if is_adoption else "standard"
1443
+ self.logger.debug(
1444
+ f"Created clean public branch '{branch}' using {approach} approach"
1445
+ )
1446
+ except Exception as e:
1447
+ self.logger.warning(f"Failed to create clean public branch '{branch}': {str(e)}")
1448
+
1449
+ # Switch back to private branch for continued development
1450
+ self.run_git(["checkout", private_branch], cwd=self.repo_root)
1451
+
1452
+ def _get_working_directory_files(self) -> List[str]:
1453
+ """
1454
+ Get all files from working directory (not git index).
1455
+
1456
+ This is used for selective branch creation to examine filesystem
1457
+ rather than git-tracked files.
1458
+
1459
+ Returns:
1460
+ List of relative file paths from repo root
1461
+ """
1462
+ all_files = []
1463
+ for root, dirs, files in os.walk(self.repo_root):
1464
+ # Skip .git directory
1465
+ if ".git" in root.split(os.sep):
1466
+ continue
1467
+ for file in files:
1468
+ file_path = os.path.join(root, file)
1469
+ relative_path = os.path.relpath(file_path, self.repo_root)
1470
+ all_files.append(relative_path)
1471
+ return all_files
1472
+
1473
+ @staticmethod
1474
+ def _is_vendored(path: str) -> bool:
1475
+ """True for files of the vendored git-repokit-common copy.
1476
+
1477
+ Upstream code is never private: its own file names (e.g. tests about
1478
+ private-patterns) must not be cut from public branches by the
1479
+ private-name patterns.
1480
+ """
1481
+ from .common_kit import PREFIX
1482
+ normalized = str(path).replace("\\", "/").lstrip("./")
1483
+ return normalized == PREFIX or normalized.startswith(PREFIX + "/")
1484
+
1485
+ def _filter_files_for_public_branch(self, file_list: List[str]) -> List[str]:
1486
+ """
1487
+ Filter out sensitive files using existing pattern infrastructure.
1488
+
1489
+ Args:
1490
+ file_list: List of file paths to filter
1491
+
1492
+ Returns:
1493
+ List of non-sensitive file paths safe for public branches
1494
+ """
1495
+ import fnmatch
1496
+ from pathlib import Path
1497
+
1498
+ clean_files = []
1499
+ patterns_to_exclude = DEFAULT_SENSITIVE_PATTERNS.copy()
1500
+
1501
+ # Add any additional patterns from config
1502
+ config_patterns = self.config.get("sensitive_patterns", [])
1503
+ if config_patterns:
1504
+ patterns_to_exclude.extend(config_patterns)
1505
+
1506
+ # Use existing BranchContext for additional pattern checking
1507
+ from .branch_utils import BranchContext
1508
+ context = BranchContext(self.repo_root, self._get_branch_config())
1509
+
1510
+ for file_path in file_list:
1511
+ if self._is_vendored(file_path):
1512
+ clean_files.append(file_path) # vendored upstream code is never private
1513
+ continue
1514
+ is_sensitive = False
1515
+ matching_pattern = None
1516
+
1517
+ # Check against DEFAULT_SENSITIVE_PATTERNS
1518
+ for pattern in patterns_to_exclude:
1519
+ # Use same cross-platform pattern matching as cleanup
1520
+ normalized_file = str(Path(file_path)).replace('\\', '/')
1521
+ normalized_pattern = pattern.replace('\\', '/')
1522
+
1523
+ matches = [
1524
+ fnmatch.fnmatch(normalized_file, normalized_pattern),
1525
+ fnmatch.fnmatch(os.path.basename(file_path), normalized_pattern),
1526
+ fnmatch.fnmatch(str(Path(file_path)), normalized_pattern),
1527
+ ]
1528
+
1529
+ if any(matches):
1530
+ is_sensitive = True
1531
+ matching_pattern = pattern
1532
+ break
1533
+
1534
+ # Check against BranchContext patterns if not already flagged
1535
+ if not is_sensitive:
1536
+ # Use BranchContext to check if file should be excluded from public
1537
+ private_files = context.check_private_files([file_path])
1538
+ if private_files:
1539
+ is_sensitive = True
1540
+ matching_pattern = f"BranchContext: {private_files[0][1]}"
1541
+
1542
+ if is_sensitive:
1543
+ self.logger.debug(f"Excluding sensitive file from public branch: {file_path} (pattern: {matching_pattern})")
1544
+ else:
1545
+ clean_files.append(file_path)
1546
+
1547
+ return clean_files
1548
+
1549
+ def _create_clean_public_branch_selective(self, branch_name: str, source_branch: str) -> None:
1550
+ """
1551
+ Create public branch by selectively adding only non-sensitive files.
1552
+
1553
+ This approach creates a branch with shared history (for merging) but ensures
1554
+ sensitive files are excluded from the initial commit on public branches.
1555
+ The guardrails and hooks prevent future commits of private content.
1556
+
1557
+ Args:
1558
+ branch_name: Name of the public branch to create
1559
+ source_branch: Source branch (typically 'private')
1560
+ """
1561
+ try:
1562
+ self.logger.info(f"Creating clean public branch '{branch_name}' using selective approach")
1563
+
1564
+ # Get current branch to restore if something goes wrong
1565
+ current_branch = self.run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=self.repo_root).strip()
1566
+
1567
+ # Check if branch already exists
1568
+ existing_branches = self.run_git(["branch", "--list"], cwd=self.repo_root).splitlines()
1569
+ branch_exists = any(branch_name in branch.strip() for branch in existing_branches)
1570
+
1571
+ if branch_exists:
1572
+ self.logger.info(f"Branch '{branch_name}' already exists, will recreate with selective approach")
1573
+ # Delete existing branch to recreate cleanly
1574
+ self.run_git(["branch", "-D", branch_name], cwd=self.repo_root, check=False)
1575
+
1576
+ # Create branch from source (maintains history for merging)
1577
+ self.run_git(["checkout", "-b", branch_name], cwd=self.repo_root)
1578
+
1579
+ # Clear git index to rebuild with only clean files
1580
+ self.run_git(["rm", "-r", "--cached", "."], cwd=self.repo_root)
1581
+
1582
+ # Get all files from working directory (not git index)
1583
+ all_files = self._get_working_directory_files()
1584
+ self.logger.info(f"Found {len(all_files)} files in working directory")
1585
+
1586
+ # Filter out sensitive files using existing patterns
1587
+ clean_files = self._filter_files_for_public_branch(all_files)
1588
+ excluded_count = len(all_files) - len(clean_files)
1589
+ self.logger.info(f"Filtered to {len(clean_files)} clean files ({excluded_count} sensitive files excluded)")
1590
+
1591
+ # Add only clean files to git index one by one
1592
+ for file_path in clean_files:
1593
+ try:
1594
+ self.run_git(["add", file_path], cwd=self.repo_root)
1595
+ if self.verbose >= 3:
1596
+ self.logger.debug(f"Added clean file to git index: {file_path}")
1597
+ except Exception as e:
1598
+ self.logger.warning(f"Failed to add file {file_path}: {str(e)}")
1599
+
1600
+ # Update .gitignore to ensure future privacy protection
1601
+ from .branch_utils import BranchContext
1602
+ context = BranchContext(self.repo_root, self._get_branch_config())
1603
+ self._update_gitignore_for_public_branch(context)
1604
+
1605
+ # Create initial commit with only clean files (no deletion history)
1606
+ try:
1607
+ staged_changes = self.run_git(["diff", "--cached", "--name-status"], cwd=self.repo_root)
1608
+ if staged_changes.strip():
1609
+ commit_message = f"Initial {branch_name} branch setup\n\nCreated with selective file addition to exclude sensitive content.\nFiles included: {len(clean_files)}\nExcluded: {excluded_count} sensitive files"
1610
+
1611
+ self.run_git(["commit", "-m", commit_message], cwd=self.repo_root)
1612
+ self.logger.info(f"Created clean public branch '{branch_name}' with {len(clean_files)} files")
1613
+ else:
1614
+ self.logger.warning(f"No clean files to commit for branch '{branch_name}'")
1615
+ except Exception as e:
1616
+ self.logger.error(f"Failed to commit clean branch '{branch_name}': {str(e)}")
1617
+ raise
1618
+
1619
+ self.logger.info(f"Successfully created selective public branch '{branch_name}' with shared history")
1620
+
1621
+ except Exception as e:
1622
+ self.logger.error(f"Failed to create selective public branch '{branch_name}': {str(e)}")
1623
+ # Try to switch back to original branch
1624
+ try:
1625
+ self.run_git(["checkout", current_branch], cwd=self.repo_root, check=False)
1626
+ except:
1627
+ pass
1628
+ raise
1629
+
1630
+ def _create_clean_public_branch(self, branch_name: str, source_branch: str) -> None:
1631
+ """
1632
+ Create a clean public branch without private content using existing guardrails.
1633
+
1634
+ This method creates a new branch and uses the existing BranchContext infrastructure
1635
+ to identify and remove private content, ensuring consistency with other guardrails.
1636
+
1637
+ Args:
1638
+ branch_name: Name of the public branch to create
1639
+ source_branch: Source branch (typically 'private')
1640
+ """
1641
+ # Import here to avoid circular dependencies
1642
+ from .branch_utils import BranchContext
1643
+
1644
+ try:
1645
+ # First, ensure we're in a git repository
1646
+ if not os.path.exists(os.path.join(self.repo_root, ".git")):
1647
+ raise ValueError(f"Not a git repository: {self.repo_root}")
1648
+
1649
+ # Get current branch to restore if something goes wrong
1650
+ current_branch = self.run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=self.repo_root).strip()
1651
+
1652
+ # Check if branch already exists
1653
+ existing_branches = self.run_git(["branch", "--list"], cwd=self.repo_root).splitlines()
1654
+ branch_exists = any(branch_name in branch.strip() for branch in existing_branches)
1655
+
1656
+ if branch_exists:
1657
+ # Branch exists, check it out and reset it
1658
+ self.logger.info(f"Branch '{branch_name}' already exists, resetting it")
1659
+ self.run_git(["checkout", branch_name], cwd=self.repo_root)
1660
+ self.run_git(["reset", "--hard", source_branch], cwd=self.repo_root)
1661
+ else:
1662
+ # Create the new branch from the source branch
1663
+ self.run_git(["checkout", "-b", branch_name, source_branch], cwd=self.repo_root)
1664
+
1665
+ # Use existing BranchContext with configuration to identify private content
1666
+ context = BranchContext(self.repo_root, self._get_branch_config())
1667
+
1668
+ # Get all files tracked by git (check git index, not working directory)
1669
+ tracked_files = self.run_git(["ls-files"], cwd=self.repo_root).splitlines()
1670
+
1671
+ # Use existing guardrails logic to identify private files
1672
+ # (vendored upstream code is never private)
1673
+ private_files = [(f, r) for f, r in context.check_private_files(tracked_files)
1674
+ if not self._is_vendored(f)]
1675
+
1676
+ files_removed = []
1677
+ if private_files:
1678
+ self.logger.info(f"Removing {len(private_files)} private files from branch '{branch_name}'")
1679
+
1680
+ # Remove private files from git index
1681
+ for file_path, reason in private_files:
1682
+ try:
1683
+ # Remove from git index with proper error checking
1684
+ self.run_git(["rm", "--cached", "--ignore-unmatch", file_path],
1685
+ cwd=self.repo_root, check=True)
1686
+ files_removed.append(file_path)
1687
+ self.logger.debug(f"Removed {file_path} from git index: {reason}")
1688
+ except Exception as e:
1689
+ self.logger.error(f"CRITICAL: Failed to remove {file_path} from git index: {e}")
1690
+ raise
1691
+
1692
+ # CRITICAL FIX: Perform additional sensitive file cleanup for public branch
1693
+ # This ensures working directory files are also cleaned
1694
+ self.logger.info(f"Performing comprehensive sensitive file cleanup for public branch '{branch_name}'")
1695
+ cleanup_results = self._cleanup_sensitive_files(branch_context=branch_name)
1696
+
1697
+ # Add cleanup results to files_removed for commit message
1698
+ if cleanup_results.get("working_dir"):
1699
+ files_removed.extend(cleanup_results["working_dir"])
1700
+ if cleanup_results.get("git_index"):
1701
+ files_removed.extend(cleanup_results["git_index"])
1702
+
1703
+ # Update .gitignore to ensure private content stays ignored
1704
+ self._update_gitignore_for_public_branch(context)
1705
+
1706
+ # Commit the changes (both file removals and .gitignore updates)
1707
+ try:
1708
+ staged_changes = self.run_git(["diff", "--cached", "--name-status"], cwd=self.repo_root)
1709
+ if staged_changes.strip():
1710
+ # Deduplicate files_removed list and create commit message
1711
+ unique_files_removed = list(set(files_removed))
1712
+ commit_message = f"Remove private content from {branch_name} branch\n\nRemoved {len(unique_files_removed)} files:\n"
1713
+ for file_path in unique_files_removed[:10]: # Show first 10 files
1714
+ commit_message += f"- {file_path}\n"
1715
+ if len(unique_files_removed) > 10:
1716
+ commit_message += f"... and {len(unique_files_removed) - 10} more files\n"
1717
+ commit_message += "\nUpdated .gitignore for public branch protection"
1718
+
1719
+ self.run_git(["commit", "-m", commit_message], cwd=self.repo_root)
1720
+ self.logger.info(f"Committed removal of {len(unique_files_removed)} private files from {branch_name}")
1721
+ else:
1722
+ self.logger.info(f"No private content found to remove from {branch_name}")
1723
+ except Exception as e:
1724
+ self.logger.error(f"CRITICAL: Failed to commit private content removal: {e}")
1725
+ raise
1726
+
1727
+ self.logger.info(f"Successfully created clean public branch '{branch_name}'")
1728
+
1729
+ except Exception as e:
1730
+ self.logger.error(f"Failed to create clean public branch '{branch_name}': {str(e)}")
1731
+ # Try to switch back to original branch
1732
+ try:
1733
+ self.run_git(["checkout", current_branch], cwd=self.repo_root, check=False)
1734
+ except:
1735
+ pass
1736
+ raise
1737
+
1738
+ def _verify_clean_branch(self, branch_name: str) -> bool:
1739
+ """
1740
+ Verify that a branch does not contain private content using existing guardrails.
1741
+
1742
+ This method uses the existing BranchContext infrastructure to check for private
1743
+ content by examining what git actually tracks, not just the working directory.
1744
+
1745
+ Args:
1746
+ branch_name: Name of the branch to verify
1747
+
1748
+ Returns:
1749
+ True if branch is clean, False if private content found
1750
+ """
1751
+ # Import here to avoid circular dependencies
1752
+ from .branch_utils import BranchContext
1753
+
1754
+ try:
1755
+ # Check current branch
1756
+ current_branch = self.run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=self.repo_root).strip()
1757
+
1758
+ # Switch to target branch for verification
1759
+ self.run_git(["checkout", branch_name], cwd=self.repo_root)
1760
+
1761
+ # Use existing BranchContext with configuration for verification
1762
+ context = BranchContext(self.repo_root, self._get_branch_config())
1763
+
1764
+ # Check what git actually tracks (git index), not working directory
1765
+ tracked_files = self.run_git(["ls-files"], cwd=self.repo_root).splitlines()
1766
+
1767
+ # Use existing guardrails logic to identify private files
1768
+ # (vendored upstream code is never private)
1769
+ private_files = [(f, r) for f, r in context.check_private_files(tracked_files)
1770
+ if not self._is_vendored(f)]
1771
+
1772
+ # Switch back to original branch
1773
+ self.run_git(["checkout", current_branch], cwd=self.repo_root)
1774
+
1775
+ if private_files:
1776
+ self.logger.error(f"Branch '{branch_name}' contains {len(private_files)} private files in git index:")
1777
+ for file_path, reason in private_files:
1778
+ self.logger.error(f" {file_path}: {reason}")
1779
+ return False
1780
+
1781
+ self.logger.info(f"Branch '{branch_name}' verification passed - no private content found")
1782
+ return True
1783
+
1784
+ except Exception as e:
1785
+ self.logger.error(f"Failed to verify branch '{branch_name}': {str(e)}")
1786
+ return False
1787
+
1788
+ def _update_gitignore_for_public_branch(self, context) -> None:
1789
+ """
1790
+ Update .gitignore with private content patterns using BranchContext.
1791
+
1792
+ Args:
1793
+ context: BranchContext instance to get patterns from
1794
+ """
1795
+ gitignore_path = os.path.join(self.repo_root, ".gitignore")
1796
+ gitignore_content = []
1797
+
1798
+ # Read existing .gitignore if it exists
1799
+ if os.path.exists(gitignore_path):
1800
+ with open(gitignore_path, "r") as f:
1801
+ gitignore_content = f.readlines()
1802
+
1803
+ # Add private content protection section using BranchContext patterns
1804
+ protection_marker = "# RepoKit Private Content Protection (DO NOT REMOVE)\n"
1805
+ if protection_marker not in "".join(gitignore_content):
1806
+ gitignore_content.append("\n")
1807
+ gitignore_content.append(protection_marker)
1808
+
1809
+ # Add patterns from BranchContext
1810
+ for pattern in context.PRIVATE_PATTERNS:
1811
+ if pattern not in "".join(gitignore_content):
1812
+ gitignore_content.append(f"{pattern}\n")
1813
+
1814
+ for pattern in context.EXCLUDE_FROM_PUBLIC:
1815
+ if pattern not in "".join(gitignore_content):
1816
+ gitignore_content.append(f"{pattern}\n")
1817
+
1818
+ # Write updated .gitignore
1819
+ with open(gitignore_path, "w") as f:
1820
+ f.writelines(gitignore_content)
1821
+
1822
+ # Stage .gitignore changes
1823
+ self.run_git(["add", ".gitignore"], cwd=self.repo_root)
1824
+ self.logger.debug("Updated .gitignore with private content protection patterns")
1825
+
1826
+ def _get_branch_config(self) -> Dict:
1827
+ """
1828
+ Get branch configuration for BranchContext from repo configuration.
1829
+
1830
+ Returns:
1831
+ Dictionary with branch and pattern configuration
1832
+ """
1833
+ private_branches = list(self.config.get('private_branches', DEFAULT_PRIVATE_BRANCHES))
1834
+ # This repository's own private branch (a strategy's, or
1835
+ # --private-branch) must count as private, or the guard treats a
1836
+ # custom name as public and refuses the private branch's own commits.
1837
+ if self.private_branch and self.private_branch not in private_branches:
1838
+ private_branches.append(self.private_branch)
1839
+
1840
+ return {
1841
+ 'private_branches': private_branches,
1842
+ 'public_branches': self.config.get('public_branches', DEFAULT_PUBLIC_BRANCHES),
1843
+ 'private_patterns': self.config.get('private_patterns', DEFAULT_PRIVATE_PATTERNS),
1844
+ 'exclude_from_public': self.config.get('exclude_from_public', DEFAULT_EXCLUDE_FROM_PUBLIC)
1845
+ }
1846
+
1847
+ def _has_changes(self, directory: str) -> bool:
1848
+ """
1849
+ Check if there are uncommitted changes in the repository.
1850
+
1851
+ Args:
1852
+ directory: Directory to check
1853
+
1854
+ Returns:
1855
+ True if there are changes, False otherwise
1856
+ """
1857
+ try:
1858
+ # Check for any changes (staged or unstaged)
1859
+ output = self.run_git(["status", "--porcelain"], cwd=directory, check=False)
1860
+ return bool(output and output.strip())
1861
+ except Exception:
1862
+ # In case of error, assume there are changes
1863
+ return True
1864
+
1865
+ def _print_next_steps(self) -> None:
1866
+ """Print next steps for the user."""
1867
+ print("\n" + "=" * 80)
1868
+ print(f"Repository {self.project_name} has been set up successfully!")
1869
+
1870
+ if not self.private_branch:
1871
+ # Flat layout (main-only)
1872
+ print(f"\nRepository: {self.repo_root} (branch: main)")
1873
+ print("Private work goes in private/, which git ignores.")
1874
+ _, owner = self._github_names()
1875
+ if self.config.get("github_create"):
1876
+ # --github: the repository is made and pushed next (0.9.6;
1877
+ # before, this said to set up the remote --github was about to make)
1878
+ print(f"\nGitHub: https://github.com/{owner}/{self.project_name} is created and pushed next.")
1879
+ else:
1880
+ print("\nNext steps:")
1881
+ print("1. Create the repository on GitHub (or rerun with --github)")
1882
+ print("2. Add it as a remote and push:")
1883
+ print(f" cd {self.repo_root}")
1884
+ print(f" git remote add origin https://github.com/{owner}/{self.project_name}.git")
1885
+ print(" git push -u origin main")
1886
+ print("=" * 80)
1887
+ return
1888
+
1889
+ print("\nDirectory structure:")
1890
+ print(f" {self.project_name}/")
1891
+
1892
+ # Use ASCII characters instead of Unicode box-drawing characters
1893
+ # This avoids encoding errors on Windows command prompt
1894
+ print(
1895
+ f" |-- local/ # Main repository ({self.config.get('private_branch', 'private')} branch)"
1896
+ )
1897
+
1898
+ if self.config.get("github", True):
1899
+ print(f" |-- github/ # GitHub worktree (main branch)")
1900
+
1901
+ worktree_branches = self.config.get("worktrees", ["main", "dev"])
1902
+ for branch in worktree_branches:
1903
+ if branch != "main" or not self.config.get(
1904
+ "github", True
1905
+ ): # Skip main if github is enabled
1906
+ print(f" |-- {branch}/ # {branch} branch worktree")
1907
+
1908
+ print("\nNext steps:")
1909
+ print("1. Set up a remote GitHub repository")
1910
+ print("2. Add it as a remote:")
1911
+ print(f" cd {self.repo_root}")
1912
+ print(" git remote add origin https://github.com/username/repo.git")
1913
+ print("3. Push your branches:")
1914
+ print(" git push -u origin main")
1915
+ print(" git push -u origin dev")
1916
+
1917
+ private_branch = self.config.get("private_branch", "private")
1918
+ print(
1919
+ f"\nRemember: The '{private_branch}' branch is for your local work only and should not be pushed!"
1920
+ )
1921
+ print("=" * 80)
1922
+
1923
+ def bootstrap_repository(self, source_dir: Optional[str] = None) -> bool:
1924
+ """
1925
+ Bootstrap a repository with essential files from a source directory.
1926
+
1927
+ This is used to set up the repository with existing code, typically
1928
+ used to bootstrap RepoKit itself or to convert an existing project
1929
+ to use RepoKit's structure.
1930
+
1931
+ Args:
1932
+ source_dir: Source directory containing the files to copy
1933
+ (default: current directory)
1934
+
1935
+ Returns:
1936
+ True if successful, False otherwise
1937
+ """
1938
+ try:
1939
+ self.logger.info(f"Bootstrapping repository from {source_dir}")
1940
+
1941
+ # Default to current directory if not specified
1942
+ if not source_dir:
1943
+ source_dir = os.getcwd()
1944
+
1945
+ # First set up the repository structure
1946
+ if not self.setup_repository():
1947
+ self.logger.error("Failed to set up repository structure")
1948
+ return False
1949
+
1950
+ # Import utils module (avoid circular imports)
1951
+ from .utils import copy_files, get_essential_file_patterns
1952
+
1953
+ # Get essential file patterns
1954
+ patterns = get_essential_file_patterns()
1955
+
1956
+ # Copy essential files to repository
1957
+ copied = copy_files(
1958
+ source_dir,
1959
+ self.repo_root,
1960
+ include_patterns=patterns["include"],
1961
+ exclude_patterns=patterns["exclude"],
1962
+ )
1963
+
1964
+ if copied == 0:
1965
+ self.logger.warning("No files were copied during bootstrap")
1966
+ return False
1967
+
1968
+ # Create a commit with the copied files
1969
+ try:
1970
+ self.run_git(["add", "."], cwd=self.repo_root)
1971
+ self.run_git(
1972
+ ["commit", "-m", "Bootstrap repository with source files"],
1973
+ cwd=self.repo_root,
1974
+ )
1975
+ self.logger.info("Committed bootstrap files")
1976
+
1977
+ # Update branches from private
1978
+ self._update_branches_from_private()
1979
+
1980
+ # Update worktrees to match their branches
1981
+ self._update_worktrees()
1982
+ except Exception as e:
1983
+ self.logger.error(f"Error committing bootstrap files: {str(e)}")
1984
+ return False
1985
+
1986
+ self.logger.info("Repository bootstrap complete")
1987
+ return True
1988
+ except Exception as e:
1989
+ self.logger.error(f"Error bootstrapping repository: {str(e)}")
1990
+ return False
1991
+
1992
+ def _update_branches_from_private(self) -> None:
1993
+ """
1994
+ Update all branches from the private branch.
1995
+
1996
+ This is used after committing bootstrap files to ensure all branches
1997
+ have the same content.
1998
+ """
1999
+ private_branch = self.config.get("private_branch", "private")
2000
+ branches = self.config.get(
2001
+ "branches", ["main", "dev", "staging", "test", "live"]
2002
+ )
2003
+
2004
+ # Get current branch
2005
+ current_branch = self.run_git(["branch", "--show-current"], cwd=self.repo_root)
2006
+
2007
+ # Get the commit hash of the private branch for reset operations
2008
+ private_hash = self.run_git(["rev-parse", private_branch], cwd=self.repo_root)
2009
+
2010
+ for branch in branches:
2011
+ if branch != private_branch:
2012
+ try:
2013
+ # Check if the branch is checked out in a worktree
2014
+ if self._is_branch_in_worktree(branch):
2015
+ # For branches in worktrees, we need to update them directly in the worktree
2016
+ worktree_path = self._get_worktree_path(branch)
2017
+ if worktree_path:
2018
+ # Use a more forceful update approach
2019
+ self.logger.info(
2020
+ f"Updating branch '{branch}' in worktree {worktree_path}"
2021
+ )
2022
+
2023
+ # First fetch the changes from the main repository
2024
+ self.run_git(
2025
+ ["fetch", "..", private_branch],
2026
+ cwd=worktree_path,
2027
+ check=False,
2028
+ )
2029
+
2030
+ # Then hard reset to the private branch commit
2031
+ self.run_git(
2032
+ ["reset", "--hard", private_hash],
2033
+ cwd=worktree_path,
2034
+ check=False,
2035
+ )
2036
+
2037
+ # Verify the update worked
2038
+ current_hash = self.run_git(
2039
+ ["rev-parse", "HEAD"], cwd=worktree_path, check=False
2040
+ )
2041
+ if current_hash == private_hash:
2042
+ self.logger.info(
2043
+ f"Updated branch '{branch}' from {private_branch}"
2044
+ )
2045
+ else:
2046
+ self.logger.warning(
2047
+ f"Failed to update branch '{branch}' to match {private_branch}"
2048
+ )
2049
+ else:
2050
+ # For branches not in worktrees, we can update them directly
2051
+ self.run_git(["checkout", branch], cwd=self.repo_root)
2052
+ self.run_git(
2053
+ ["reset", "--hard", private_branch], cwd=self.repo_root
2054
+ )
2055
+ self.logger.info(
2056
+ f"Updated branch '{branch}' from {private_branch}"
2057
+ )
2058
+ except Exception as e:
2059
+ self.logger.warning(f"Failed to update branch '{branch}': {str(e)}")
2060
+
2061
+ # Return to original branch
2062
+ if current_branch != self.run_git(
2063
+ ["branch", "--show-current"], cwd=self.repo_root
2064
+ ):
2065
+ self.run_git(["checkout", current_branch], cwd=self.repo_root)
2066
+
2067
+ def _update_worktrees(self) -> None:
2068
+ """
2069
+ Update all worktrees to match their corresponding branches.
2070
+
2071
+ This ensures that all worktree directories reflect the latest state
2072
+ of their branches.
2073
+ """
2074
+ # Check if we have worktrees
2075
+ worktree_output = self.run_git(
2076
+ ["worktree", "list"], cwd=self.repo_root, check=False
2077
+ )
2078
+ if not worktree_output:
2079
+ return
2080
+
2081
+ # Parse worktree list
2082
+ worktrees = {}
2083
+ for line in worktree_output.split("\n"):
2084
+ if not line.strip():
2085
+ continue
2086
+
2087
+ # Parse worktree line (format: "/path/to/worktree abcd1234 [branch]")
2088
+ parts = line.split()
2089
+ if len(parts) >= 3 and "[" in parts[2] and "]" in parts[2]:
2090
+ path = parts[0]
2091
+ branch = parts[2].strip("[]")
2092
+ worktrees[branch] = path
2093
+
2094
+ # Update each worktree
2095
+ for branch, path in worktrees.items():
2096
+ if branch == self.config.get("private_branch", "private"):
2097
+ # Skip private branch
2098
+ continue
2099
+
2100
+ try:
2101
+ # Force synchronization with the branch from the main repository
2102
+ branch_hash = self.run_git(["rev-parse", branch], cwd=self.repo_root)
2103
+
2104
+ # Make sure worktree has latest changes from its branch
2105
+ self.run_git(["reset", "--hard", branch_hash], cwd=path, check=False)
2106
+ self.run_git(["clean", "-fd"], cwd=path, check=False)
2107
+
2108
+ # Verify the update worked
2109
+ current_hash = self.run_git(
2110
+ ["rev-parse", "HEAD"], cwd=path, check=False
2111
+ )
2112
+ if current_hash == branch_hash:
2113
+ self.logger.info(
2114
+ f"Updated worktree for branch '{branch}' at {path}"
2115
+ )
2116
+ else:
2117
+ self.logger.warning(
2118
+ f"Failed to update worktree for branch '{branch}' at {path}"
2119
+ )
2120
+ except Exception as e:
2121
+ self.logger.warning(
2122
+ f"Failed to update worktree for branch '{branch}': {str(e)}"
2123
+ )
2124
+
2125
+ def _is_branch_in_worktree(self, branch: str) -> bool:
2126
+ """
2127
+ Check if a branch is currently checked out in a worktree.
2128
+
2129
+ Args:
2130
+ branch: Branch name to check
2131
+
2132
+ Returns:
2133
+ True if the branch is checked out in a worktree, False otherwise
2134
+ """
2135
+ try:
2136
+ # Get worktree list
2137
+ worktree_output = self.run_git(
2138
+ ["worktree", "list"], cwd=self.repo_root, check=False
2139
+ )
2140
+
2141
+ # Check if branch is in any worktree
2142
+ for line in worktree_output.split("\n"):
2143
+ if f"[{branch}]" in line:
2144
+ return True
2145
+
2146
+ return False
2147
+ except Exception:
2148
+ # If we can't determine, assume it's not in a worktree
2149
+ return False
2150
+
2151
+ def _get_worktree_path(self, branch: str) -> Optional[str]:
2152
+ """
2153
+ Get the path to the worktree containing a branch.
2154
+
2155
+ Args:
2156
+ branch: Branch name
2157
+
2158
+ Returns:
2159
+ Path to the worktree or None if the branch is not in a worktree
2160
+ """
2161
+ try:
2162
+ # Get worktree list
2163
+ worktree_output = self.run_git(
2164
+ ["worktree", "list"], cwd=self.repo_root, check=False
2165
+ )
2166
+
2167
+ # Find worktree with this branch
2168
+ for line in worktree_output.split("\n"):
2169
+ if f"[{branch}]" in line:
2170
+ # Extract path from line (it's the first part)
2171
+ return line.split()[0]
2172
+
2173
+ return None
2174
+ except Exception:
2175
+ # If we can't determine, return None
2176
+ return None
2177
+
2178
+ def publish_repository(
2179
+ self,
2180
+ service: str = "github",
2181
+ remote_name: str = "origin",
2182
+ private: bool = False,
2183
+ push_branches: bool = True,
2184
+ organization: Optional[str] = None,
2185
+ token_command: Optional[str] = None,
2186
+ credentials_file: Optional[str] = None,
2187
+ ) -> bool:
2188
+ """
2189
+ Set up remote repository and push branches.
2190
+
2191
+ This is a convenience method that wraps RemoteIntegration functionality
2192
+ directly in the RepoManager class.
2193
+
2194
+ Args:
2195
+ service: Remote service ("github" or "gitlab")
2196
+ remote_name: Name for the remote
2197
+ private: Whether the repository should be private
2198
+ push_branches: Whether to push branches
2199
+ organization: Organization or group name
2200
+ token_command: Command to retrieve token
2201
+ credentials_file: Path to credentials file
2202
+
2203
+ Returns:
2204
+ True if successful, False otherwise
2205
+ """
2206
+ try:
2207
+ # Import RemoteIntegration (avoid circular imports)
2208
+ from .remote_integration import RemoteIntegration
2209
+
2210
+ # Initialize remote integration
2211
+ remote_integration = RemoteIntegration(
2212
+ self, credentials_file=credentials_file, verbose=self.verbose
2213
+ )
2214
+
2215
+ # Set up remote repository
2216
+ success = remote_integration.setup_remote_repository(
2217
+ service=service,
2218
+ remote_name=remote_name,
2219
+ private=private,
2220
+ push_branches=push_branches,
2221
+ organization=organization,
2222
+ token_command=token_command,
2223
+ )
2224
+
2225
+ if not success:
2226
+ self.logger.error(f"Failed to publish repository to {service}")
2227
+ return False
2228
+
2229
+ self.logger.info(f"Successfully published repository to {service}")
2230
+ return True
2231
+ except Exception as e:
2232
+ self.logger.error(f"Error publishing repository: {str(e)}")
2233
+ return False
2234
+
2235
+ def _generate_ai_templates(self) -> None:
2236
+ """Generate AI integration templates (only in private branch)."""
2237
+ ai_provider = self.config.get("ai_integration", "").lower()
2238
+ if not ai_provider or ai_provider == "none":
2239
+ return
2240
+
2241
+ self.logger.info(f"Generating AI integration files for: {ai_provider}")
2242
+
2243
+ # Create private/claude directory structure
2244
+ if ai_provider == "claude":
2245
+ claude_dir = os.path.join(self.repo_root, "private", "claude")
2246
+ instructions_dir = os.path.join(claude_dir, "instructions")
2247
+ os.makedirs(claude_dir, exist_ok=True)
2248
+
2249
+ # Context for AI templates
2250
+ import datetime
2251
+
2252
+ context = {
2253
+ "project_name": self.project_name,
2254
+ "description": self.config.get("description", "A new project"),
2255
+ "language": self.config.get("language", "generic"),
2256
+ "date": datetime.datetime.now().strftime("%Y-%m-%d"),
2257
+ "test_command": self._get_test_command(),
2258
+ "lint_command": self._get_lint_command(),
2259
+ "build_command": self._get_build_command(),
2260
+ **self._docs_context(),
2261
+ }
2262
+
2263
+ # Generate CLAUDE.md. Nested mode (the default) gets a short pointer
2264
+ # to the vault's own CLAUDE.md with the project's real commands
2265
+ # (0.9.6); the long template describes the private-branch model.
2266
+ nested = self.private_mode == "nested"
2267
+ self.template_engine.render_template_to_file(
2268
+ "CLAUDE-nested.md" if nested else "CLAUDE.md",
2269
+ os.path.join(self.repo_root, "CLAUDE.md"),
2270
+ context,
2271
+ category=f"ai/{ai_provider}",
2272
+ )
2273
+
2274
+ # Copy instruction files (the private-branch model's; the nested
2275
+ # vault carries its own guidance)
2276
+ instruction_files = [] if nested else [
2277
+ "step1_context_rebuilder.md",
2278
+ "step2_dev_workflow_process.md",
2279
+ "step3_context_bridge.md",
2280
+ ]
2281
+
2282
+ for instruction_file in instruction_files:
2283
+ # Try to get template path - for instruction files, try without .template extension first
2284
+ src_path = os.path.join(
2285
+ self.template_engine.templates_dir,
2286
+ f"ai/{ai_provider}/instructions",
2287
+ instruction_file,
2288
+ )
2289
+ if not os.path.exists(src_path):
2290
+ # Try with template engine's path finding
2291
+ src_path = self.template_engine.get_template_path(
2292
+ instruction_file, category=f"ai/{ai_provider}/instructions"
2293
+ )
2294
+
2295
+ if src_path and os.path.exists(src_path):
2296
+ os.makedirs(instructions_dir, exist_ok=True)
2297
+ dest_path = os.path.join(instructions_dir, instruction_file)
2298
+ shutil.copy2(src_path, dest_path)
2299
+ self.logger.debug(f"Copied instruction file: {instruction_file}")
2300
+
2301
+ # With no private branch these files would be committed straight to
2302
+ # main, the public branch: leave them on disk, uncommitted.
2303
+ if not self.private_branch:
2304
+ self.logger.info(
2305
+ "AI integration files written but not committed (no private branch)"
2306
+ )
2307
+ return
2308
+
2309
+ # Add files to git (use -f for private directory since it's in .gitignore).
2310
+ # In nested mode private/ is its own repository: its files are the
2311
+ # vault's to commit, never this repository's.
2312
+ self.run_git(["add", "CLAUDE.md"], cwd=self.repo_root)
2313
+ if self.private_mode != "nested":
2314
+ self.run_git(["add", "-f", "private/claude/"], cwd=self.repo_root)
2315
+
2316
+ # Only commit if not in adoption mode (in adoption, user commits manually)
2317
+ if not self.config.get("is_adoption", False):
2318
+ # Commit AI integration files
2319
+ self.run_git(
2320
+ ["commit", "-m", "Add Claude AI integration files"], cwd=self.repo_root
2321
+ )
2322
+ else:
2323
+ self.logger.info("AI files staged for adoption - commit manually when ready")
2324
+
2325
+ self.logger.info("AI integration files generated successfully")
2326
+
2327
+ def _get_test_command(self) -> str:
2328
+ """Get the appropriate test command for the language."""
2329
+ language = self.config.get("language", "generic")
2330
+ commands = {
2331
+ "python": "pytest",
2332
+ "javascript": "npm test",
2333
+ "generic": "# Add your test command here",
2334
+ }
2335
+ return commands.get(language, commands["generic"])
2336
+
2337
+ def _get_lint_command(self) -> str:
2338
+ """Get the appropriate lint command for the language."""
2339
+ language = self.config.get("language", "generic")
2340
+ commands = {
2341
+ "python": "black . && flake8",
2342
+ "javascript": "npm run lint",
2343
+ "generic": "# Add your lint command here",
2344
+ }
2345
+ return commands.get(language, commands["generic"])
2346
+
2347
+ def _get_build_command(self) -> str:
2348
+ """Get the appropriate build command for the language."""
2349
+ language = self.config.get("language", "generic")
2350
+ commands = {
2351
+ "python": "python setup.py build",
2352
+ "javascript": "npm run build",
2353
+ "generic": "# Add your build command here",
2354
+ }
2355
+ return commands.get(language, commands["generic"])
2356
+
2357
+ def _cleanup_sensitive_files(self, branch_context: Optional[str] = None, dry_run: bool = False) -> Dict[str, List[str]]:
2358
+ """
2359
+ Branch-aware cleanup of sensitive files during repository adoption.
2360
+
2361
+ This method performs intelligent cleanup based on branch context:
2362
+ - For private branches: Only clean git index, preserve working directory
2363
+ - For public branches: Clean both working directory and git index
2364
+
2365
+ Args:
2366
+ branch_context: Branch we're cleaning for (e.g. 'private', 'dev', 'main')
2367
+ dry_run: If True, only report what would be cleaned
2368
+
2369
+ Returns:
2370
+ Dictionary with 'working_dir' and 'git_index' lists of cleaned files
2371
+ """
2372
+ import fnmatch
2373
+ from pathlib import Path
2374
+
2375
+ # Set up detailed logging for debugging
2376
+ cleanup_logger = logging.getLogger("repokit.cleanup")
2377
+
2378
+ cleanup_logger.info(f"Starting sensitive file cleanup (branch: {branch_context}, dry_run: {dry_run})")
2379
+
2380
+ cleanup_results = {
2381
+ "working_dir": [],
2382
+ "git_index": []
2383
+ }
2384
+
2385
+ # Get patterns to clean - use DEFAULT_SENSITIVE_PATTERNS for consistency
2386
+ patterns_to_clean = DEFAULT_SENSITIVE_PATTERNS.copy()
2387
+
2388
+ # Add any additional patterns from config
2389
+ config_patterns = self.config.get("sensitive_patterns", [])
2390
+ if config_patterns:
2391
+ patterns_to_clean.extend(config_patterns)
2392
+
2393
+ # Determine if this is a private branch (this repository's own private
2394
+ # branch counts, whatever its name -- e.g. --private-branch scratch)
2395
+ private_branches = DEFAULT_PRIVATE_BRANCHES + self.config.get("private_branches", [])
2396
+ if self.private_branch and self.private_branch not in private_branches:
2397
+ private_branches = private_branches + [self.private_branch]
2398
+ is_private_branch = branch_context in private_branches
2399
+
2400
+ cleanup_logger.info(f"Branch context: {branch_context}")
2401
+ cleanup_logger.info(f"Is private branch: {is_private_branch}")
2402
+ cleanup_logger.info(f"Using {len(patterns_to_clean)} sensitive patterns: {patterns_to_clean}")
2403
+
2404
+ # For private branches, we preserve working directory files but clean git index
2405
+ # For public branches, we clean both working directory and git index
2406
+ clean_working_dir = not is_private_branch
2407
+ clean_git_index = True # Always clean git index
2408
+
2409
+ cleanup_logger.info(f"Will clean working directory: {clean_working_dir}")
2410
+ cleanup_logger.info(f"Will clean git index: {clean_git_index}")
2411
+
2412
+ # Clean working directory files (only for public branches)
2413
+ if clean_working_dir:
2414
+ cleanup_logger.debug("Scanning working directory for sensitive files")
2415
+ for root, dirs, files in os.walk(self.repo_root):
2416
+ # Skip .git directory
2417
+ if ".git" in root.split(os.sep):
2418
+ continue
2419
+
2420
+ for file in files:
2421
+ file_path = os.path.join(root, file)
2422
+ relative_path = os.path.relpath(file_path, self.repo_root)
2423
+ if self._is_vendored(relative_path):
2424
+ continue
2425
+
2426
+ # Check if file matches any sensitive pattern
2427
+ should_remove = False
2428
+ matching_pattern = None
2429
+
2430
+ for pattern in patterns_to_clean:
2431
+ # Cross-platform pattern matching using normalized paths
2432
+ normalized_relative = str(Path(relative_path)).replace('\\', '/')
2433
+ normalized_pattern = pattern.replace('\\', '/')
2434
+
2435
+ # Check multiple match approaches for cross-platform compatibility
2436
+ matches = [
2437
+ fnmatch.fnmatch(normalized_relative, normalized_pattern), # Full path
2438
+ fnmatch.fnmatch(file, normalized_pattern), # Filename only
2439
+ fnmatch.fnmatch(str(Path(file)), normalized_pattern), # Filename as Path
2440
+ ]
2441
+
2442
+ if any(matches):
2443
+ should_remove = True
2444
+ matching_pattern = pattern
2445
+ if self.verbose >= 3:
2446
+ cleanup_logger.debug(f"Pattern '{pattern}' matched file '{relative_path}' (normalized: '{normalized_relative}')")
2447
+ break
2448
+
2449
+ if should_remove:
2450
+ cleanup_results["working_dir"].append(relative_path)
2451
+
2452
+ if dry_run:
2453
+ cleanup_logger.info(f"Would remove (working dir): {relative_path} (pattern: {matching_pattern})")
2454
+ else:
2455
+ try:
2456
+ os.remove(file_path)
2457
+ cleanup_logger.debug(f"Removed (working dir): {relative_path} (pattern: {matching_pattern})")
2458
+ except Exception as e:
2459
+ cleanup_logger.warning(f"Failed to remove {relative_path}: {str(e)}")
2460
+ else:
2461
+ cleanup_logger.info("Skipping working directory cleanup (private branch - files preserved)")
2462
+
2463
+ # Clean git index files (always done)
2464
+ if clean_git_index:
2465
+ cleanup_logger.debug("Scanning git index for sensitive files")
2466
+ try:
2467
+ # Get list of files tracked by git
2468
+ tracked_files = self.run_git(["ls-files"], cwd=self.repo_root).splitlines()
2469
+
2470
+ files_to_remove_from_index = []
2471
+
2472
+ for tracked_file in tracked_files:
2473
+ if self._is_vendored(tracked_file):
2474
+ continue
2475
+ # Check if tracked file matches any sensitive pattern
2476
+ should_remove = False
2477
+ matching_pattern = None
2478
+
2479
+ for pattern in patterns_to_clean:
2480
+ # Cross-platform pattern matching using normalized paths (same as working directory)
2481
+ normalized_tracked = str(Path(tracked_file)).replace('\\', '/')
2482
+ normalized_pattern = pattern.replace('\\', '/')
2483
+
2484
+ # Check multiple match approaches for cross-platform compatibility
2485
+ matches = [
2486
+ fnmatch.fnmatch(normalized_tracked, normalized_pattern), # Full path
2487
+ fnmatch.fnmatch(os.path.basename(tracked_file), normalized_pattern), # Filename only
2488
+ fnmatch.fnmatch(str(Path(tracked_file)), normalized_pattern), # Filename as Path
2489
+ ]
2490
+
2491
+ if any(matches):
2492
+ should_remove = True
2493
+ matching_pattern = pattern
2494
+ if self.verbose >= 3:
2495
+ cleanup_logger.debug(f"Pattern '{pattern}' matched tracked file '{tracked_file}' (normalized: '{normalized_tracked}')")
2496
+ break
2497
+
2498
+ if should_remove:
2499
+ cleanup_results["git_index"].append(tracked_file)
2500
+ files_to_remove_from_index.append(tracked_file)
2501
+
2502
+ if dry_run:
2503
+ cleanup_logger.info(f"Would remove (git index): {tracked_file} (pattern: {matching_pattern})")
2504
+ else:
2505
+ cleanup_logger.debug(f"Marking for git index removal: {tracked_file} (pattern: {matching_pattern})")
2506
+
2507
+ # Remove files from git index in batch
2508
+ if files_to_remove_from_index and not dry_run:
2509
+ try:
2510
+ for file_to_remove in files_to_remove_from_index:
2511
+ file_full_path = os.path.join(self.repo_root, file_to_remove)
2512
+ if not os.path.exists(file_full_path) or clean_working_dir:
2513
+ # File was removed from working dir or we don't want to preserve it
2514
+ self.run_git(["rm", "--cached", "--ignore-unmatch", file_to_remove], cwd=self.repo_root)
2515
+ cleanup_logger.debug(f"Removed from git index: {file_to_remove}")
2516
+ else:
2517
+ # File still exists in working dir and we want to preserve it
2518
+ self.run_git(["rm", "--cached", file_to_remove], cwd=self.repo_root)
2519
+ cleanup_logger.debug(f"Removed from git index (preserved in working dir): {file_to_remove}")
2520
+
2521
+ cleanup_logger.info(f"Removed {len(files_to_remove_from_index)} files from git index")
2522
+ except Exception as e:
2523
+ cleanup_logger.error(f"Failed to remove files from git index: {str(e)}")
2524
+
2525
+ except Exception as e:
2526
+ cleanup_logger.warning(f"Failed to scan git index: {str(e)}")
2527
+
2528
+ # Log summary
2529
+ total_cleaned = len(cleanup_results["working_dir"]) + len(cleanup_results["git_index"])
2530
+ action_word = "Would clean" if dry_run else "Cleaned"
2531
+ cleanup_logger.info(f"{action_word} {total_cleaned} sensitive files total")
2532
+ cleanup_logger.info(f" Working directory: {len(cleanup_results['working_dir'])} files")
2533
+ cleanup_logger.info(f" Git index: {len(cleanup_results['git_index'])} files")
2534
+
2535
+ # Show some example files cleaned (for debugging)
2536
+ if self.verbose >= 2 and not dry_run:
2537
+ all_cleaned = cleanup_results["working_dir"] + cleanup_results["git_index"]
2538
+ if all_cleaned:
2539
+ cleanup_logger.info("Example files cleaned:")
2540
+ for file_path in all_cleaned[:5]:
2541
+ cleanup_logger.info(f" - {file_path}")
2542
+ if len(all_cleaned) > 5:
2543
+ cleanup_logger.info(f" ... and {len(all_cleaned) - 5} more")
2544
+
2545
+ return cleanup_results
2546
+
2547
+ def _log_cleanup_summary(self, cleanup_results: Dict[str, List[str]], gitkeep_results: Dict[str, List[str]]) -> None:
2548
+ """
2549
+ Log comprehensive cleanup summary for adoption process.
2550
+
2551
+ Args:
2552
+ cleanup_results: Results from _cleanup_sensitive_files()
2553
+ gitkeep_results: Results from manage_gitkeep_files()
2554
+ """
2555
+ self.logger.info("=== REPOSITORY ADOPTION CLEANUP SUMMARY ===")
2556
+
2557
+ # Sensitive files cleanup summary
2558
+ total_sensitive = len(cleanup_results.get("working_dir", [])) + len(cleanup_results.get("git_index", []))
2559
+ if total_sensitive > 0:
2560
+ self.logger.info(f"Cleaned {total_sensitive} sensitive files:")
2561
+
2562
+ if cleanup_results.get("working_dir"):
2563
+ self.logger.info(f" Working directory: {len(cleanup_results['working_dir'])} files")
2564
+ if self.verbose >= 2:
2565
+ for file_path in cleanup_results["working_dir"][:5]:
2566
+ self.logger.info(f" - {file_path}")
2567
+ if len(cleanup_results["working_dir"]) > 5:
2568
+ self.logger.info(f" ... and {len(cleanup_results['working_dir']) - 5} more")
2569
+
2570
+ if cleanup_results.get("git_index"):
2571
+ self.logger.info(f" Git index: {len(cleanup_results['git_index'])} files")
2572
+ if self.verbose >= 2:
2573
+ for file_path in cleanup_results["git_index"][:5]:
2574
+ self.logger.info(f" - {file_path}")
2575
+ if len(cleanup_results["git_index"]) > 5:
2576
+ self.logger.info(f" ... and {len(cleanup_results['git_index']) - 5} more")
2577
+ else:
2578
+ self.logger.info("No sensitive files found to clean")
2579
+
2580
+ # .gitkeep management summary
2581
+ total_gitkeep = len(gitkeep_results.get("added", [])) + len(gitkeep_results.get("removed", []))
2582
+ if total_gitkeep > 0:
2583
+ self.logger.info(f"Managed {total_gitkeep} .gitkeep files:")
2584
+
2585
+ if gitkeep_results.get("added"):
2586
+ self.logger.info(f" Added to empty directories: {len(gitkeep_results['added'])} files")
2587
+ if self.verbose >= 2:
2588
+ for file_path in gitkeep_results["added"][:3]:
2589
+ self.logger.info(f" + {file_path}")
2590
+ if len(gitkeep_results["added"]) > 3:
2591
+ self.logger.info(f" ... and {len(gitkeep_results['added']) - 3} more")
2592
+
2593
+ if gitkeep_results.get("removed"):
2594
+ self.logger.info(f" Removed from non-empty directories: {len(gitkeep_results['removed'])} files")
2595
+ if self.verbose >= 2:
2596
+ for file_path in gitkeep_results["removed"][:3]:
2597
+ self.logger.info(f" - {file_path}")
2598
+ if len(gitkeep_results["removed"]) > 3:
2599
+ self.logger.info(f" ... and {len(gitkeep_results['removed']) - 3} more")
2600
+ else:
2601
+ self.logger.info("No .gitkeep file changes needed")
2602
+
2603
+ self.logger.info("=== END CLEANUP SUMMARY ===")
2604
+
2605
+ def _generate_repokit_config(self) -> None:
2606
+ """
2607
+ Generate .repokit.json configuration file with RepoKit signature.
2608
+
2609
+ This file serves as the definitive marker that a project is RepoKit-managed
2610
+ and contains metadata about the RepoKit setup.
2611
+ """
2612
+ import datetime
2613
+
2614
+ config_path = os.path.join(self.repo_root, ".repokit.json")
2615
+
2616
+ # Build configuration with RepoKit signature
2617
+ from . import __version__
2618
+ repokit_config = {
2619
+ "repokit_managed": True,
2620
+ "generated_by": "repokit",
2621
+ "version": __version__, # Current RepoKit version
2622
+ "created_at": datetime.datetime.now().isoformat() + "Z",
2623
+ "project_name": self.project_name,
2624
+ "language": self.config.get("language", "generic"),
2625
+ "languages": self.config.get("languages") or [self.config.get("language", "generic")],
2626
+ "archetype": self.config.get("archetype"),
2627
+ "layers": self.config.get("layers") or [],
2628
+ "branch_strategy": self.config.get("branch_strategy", "standard"),
2629
+ "private_branch": self.config.get("private_branch", "private"),
2630
+ "private_mode": self.private_mode,
2631
+ "repokit_common": self.common_vendored,
2632
+ "license": self._license_record(),
2633
+ "extras": list(self.config.get("extras") or []),
2634
+ }
2635
+
2636
+ # Add additional config if available
2637
+ if self.config.get("description"):
2638
+ repokit_config["description"] = self.config["description"]
2639
+
2640
+ # The config's user, else the git identity the LICENSE and the
2641
+ # manifests were filled from (0.9.6: it used to be recorded empty)
2642
+ user = dict(self.config.get("user") or {})
2643
+ if not user.get("name") or not user.get("email"):
2644
+ # the identity this repository's commits are made with: every
2645
+ # scope git reads, the environment's overrides included
2646
+ def effective(key: str) -> str:
2647
+ r = subprocess.run(["git", "config", key], cwd=self.repo_root, capture_output=True,
2648
+ text=True, encoding="utf-8", errors="replace")
2649
+ return r.stdout.strip() if r.returncode == 0 else ""
2650
+ user["name"] = user.get("name") or effective("user.name")
2651
+ user["email"] = user.get("email") or effective("user.email")
2652
+ if user.get("name") or user.get("email"):
2653
+ repokit_config["author"] = {"name": user.get("name", ""), "email": user.get("email", "")}
2654
+
2655
+ # Write configuration file
2656
+ try:
2657
+ with open(config_path, "w", encoding="utf-8") as f:
2658
+ json.dump(repokit_config, f, indent=2)
2659
+
2660
+ self.logger.info(f"Generated RepoKit configuration: {config_path}")
2661
+
2662
+ except Exception as e:
2663
+ self.logger.warning(f"Failed to generate RepoKit configuration: {str(e)}")
2664
+
2665
+ def _license_record(self) -> str:
2666
+ """What .repokit.json says about the licence: the id, `file:<name>`, `none` or `existing`."""
2667
+ kind, value = getattr(self, "_license", None) or ("none", None)
2668
+ if kind == "catalogue":
2669
+ return value
2670
+ if kind == "file":
2671
+ return "file:" + os.path.basename(value)
2672
+ return kind
2673
+
2674
+ GITHUB_PLACEHOLDER = "YOUR_GITHUB_USERNAME"
2675
+
2676
+ def _github_names(self) -> Tuple[str, str]:
2677
+ """(the person's GitHub login, the repository's owner), one answer for
2678
+ every file (0.9.6).
2679
+
2680
+ The login: the one gh reported for --github (`github_login`, read by
2681
+ the CLI before anything is written), else the config's
2682
+ github.username. The owner: --org, else github.organization, else the
2683
+ login. With neither known the files get YOUR_GITHUB_USERNAME, and the
2684
+ create says so once.
2685
+ """
2686
+ gh_cfg = self.config.get("github") if isinstance(self.config.get("github"), dict) else {}
2687
+ login = self.config.get("github_login") or gh_cfg.get("username")
2688
+ owner = self.config.get("organization") or gh_cfg.get("organization") or login
2689
+ if not login and not getattr(self, "_warned_github_names", False):
2690
+ self._warned_github_names = True
2691
+ where = "CODEOWNERS and FUNDING" if owner else "the URLs, CODEOWNERS and FUNDING"
2692
+ self.logger.warning(
2693
+ f"No GitHub login known: {where} say {self.GITHUB_PLACEHOLDER}. "
2694
+ "Use --github, or set github.username in ~/.repokit/config.json")
2695
+ return (login or self.GITHUB_PLACEHOLDER, owner or self.GITHUB_PLACEHOLDER)
2696
+
2697
+ def _generate_badges(self, language: str) -> str:
2698
+ """The README's badge line (0.9.7): the house's badges, in the house's
2699
+ order, from the template the preset or the person picks
2700
+ (repokit/badges.py)."""
2701
+ from . import badges as rk_badges
2702
+ from .readme_blocks import floors
2703
+ login, owner = self._github_names()
2704
+ kind, lid = getattr(self, "_license", None) or ("none", None)
2705
+ floor = floors(self.config.get("archetype"))
2706
+ extras = self.config.get("extras") or []
2707
+ values = {
2708
+ "owner": owner, "repo": self.project_name, "repo_lower": self.project_name.lower(),
2709
+ "dist": self.project_name, "python": floor.get("python", ""), "node": floor.get("node", ""),
2710
+ "ci": self._ci_workflow() or "", "gist_user": login,
2711
+ "gist": self.config.get("traffic_gist_id") or "PLACEHOLDER", # ghtraf fills it
2712
+ "pages": owner.lower(),
2713
+ "platform_link": ("docs/platform-support.md" if "platform-support" in extras
2714
+ else f"https://github.com/{owner}/{self.project_name}"),
2715
+ **rk_badges.license_values(lid if kind == "catalogue" else None, self._license_spdx()),
2716
+ }
2717
+ names = rk_badges.resolve(self.config.get("badges"), self.config.get("archetype"),
2718
+ self.config.get("badge_templates"), self.config.get("badge_types"))
2719
+ return rk_badges.render(names, values, ci=self.config.get("badges_ci") or "auto",
2720
+ types=self.config.get("badge_types"))
2721
+
2722
+ def _license_spdx(self) -> Optional[str]:
2723
+ """The SPDX id of the catalogue licence this project got, else None."""
2724
+ kind, value = getattr(self, "_license", None) or ("none", None)
2725
+ if kind != "catalogue" or not value:
2726
+ return None
2727
+ from . import licenses
2728
+ return licenses.spdx(value)
2729
+
2730
+ def _docs_context(self) -> Dict[str, str]:
2731
+ """What README.md and CONTRIBUTING.md place beyond the badges (0.9.6):
2732
+ the preset's install, usage, setup and test commands, the licence line,
2733
+ and the support line when FUNDING is written."""
2734
+ from .readme_blocks import sections
2735
+ login, owner = self._github_names()
2736
+ docs = sections(self.config.get("archetype"), self.config.get("language", "generic"),
2737
+ self.project_name, owner,
2738
+ bool(getattr(self, "with_common", False)) and not self.config.get("is_adoption", False))
2739
+ from . import licenses
2740
+ import datetime
2741
+ kind, lid = getattr(self, "_license", None) or ("none", None)
2742
+ spdx = self._license_spdx()
2743
+ # The License section, last in the README, in the house projects'
2744
+ # words (claude-bookmarks, Claude-Session-Backup): the copyright line,
2745
+ # then the licence by name. There is no Acknowledgements section: a
2746
+ # project does not credit its own author (djdarcy, 2026-10-24).
2747
+ notice = f"{self.project_name}, Copyright (C) {datetime.date.today().year} {self._holder()}"
2748
+ if spdx and spdx.startswith("LicenseRef"):
2749
+ docs["license_line"] = f"{notice}. All rights reserved -- see [LICENSE](LICENSE)."
2750
+ elif spdx:
2751
+ docs["license_line"] = (f"{notice}\n\nLicensed under the {licenses.title(lid) or spdx} ({spdx}) "
2752
+ "-- see [LICENSE](LICENSE).")
2753
+ elif kind in ("file", "existing"):
2754
+ docs["license_line"] = "See [LICENSE](LICENSE)."
2755
+ else:
2756
+ docs["license_line"] = "No licence has been chosen yet."
2757
+ # what the layers' manifests say about the licence and the keywords
2758
+ docs["license_expression"] = (spdx if spdx else
2759
+ "SEE LICENSE IN LICENSE" if kind in ("file", "existing") else "UNLICENSED")
2760
+ # PEP 639 (0.9.8): the SPDX id only for a catalogue licence (a file has
2761
+ # none), and whether a LICENSE file is written at all
2762
+ docs["license_spdx"] = spdx or ""
2763
+ docs["license_file_written"] = kind in ("catalogue", "file", "existing")
2764
+ docs["topics"] = list(self.config.get("topics") or [])
2765
+ # The house projects' support button, with the handle FUNDING.yml names
2766
+ funding = "funding" in (self.config.get("extras") or []) and login != self.GITHUB_PLACEHOLDER
2767
+ docs["support_line"] = (
2768
+ "Like the project?\n\n"
2769
+ '[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)]'
2770
+ f"(https://www.buymeacoffee.com/{login})\n\n" if funding else "")
2771
+ # What CONTRIBUTING.md covers, listed in the README
2772
+ covers = ["Development setup", "Running the tests"]
2773
+ docs["version_section"] = ""
2774
+ if bool(getattr(self, "with_common", False)) and not self.config.get("is_adoption", False):
2775
+ covers.append("Version management with `sync-versions.py`")
2776
+ docs["version_section"] = (
2777
+ "## Version Management\n\n"
2778
+ "The version lives in one place and the pre-commit hook keeps every other copy in step. "
2779
+ "To bump it:\n\n"
2780
+ "```bash\n"
2781
+ "python scripts/repokit-common/sync-versions.py --bump patch # or minor, major\n"
2782
+ "python scripts/repokit-common/sync-versions.py --check # every copy agrees?\n"
2783
+ "```\n\n")
2784
+ covers.append("Pull request checklist")
2785
+ docs["contributing_covers"] = "\n".join(f"- {c}" for c in covers)
2786
+ return docs
2787
+
2788
+ def _holder(self) -> str:
2789
+ """Who the LICENSE names: the config's user, else the git identity
2790
+ this repository's commits are made with."""
2791
+ name = (self.config.get("user") or {}).get("name")
2792
+ if name:
2793
+ return name
2794
+ r = subprocess.run(["git", "config", "user.name"], cwd=self.repo_root, capture_output=True,
2795
+ text=True, encoding="utf-8", errors="replace")
2796
+ return (r.stdout.strip() if r.returncode == 0 else "") or "the copyright holder"