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.
- git_repokit-0.9.10.dist-info/METADATA +422 -0
- git_repokit-0.9.10.dist-info/RECORD +174 -0
- git_repokit-0.9.10.dist-info/WHEEL +5 -0
- git_repokit-0.9.10.dist-info/entry_points.txt +2 -0
- git_repokit-0.9.10.dist-info/licenses/LICENSE +9 -0
- git_repokit-0.9.10.dist-info/top_level.txt +1 -0
- repokit/__init__.py +8 -0
- repokit/__main__.py +12 -0
- repokit/__version__.py +4 -0
- repokit/_vendor/__init__.py +0 -0
- repokit/_vendor/ailib/__init__.py +1 -0
- repokit/_vendor/ailib/analyzer.py +177 -0
- repokit/_vendor/ailib/backend.py +95 -0
- repokit/_vendor/ailib/cache.py +97 -0
- repokit/_vendor/ailib/parsers.py +52 -0
- repokit/_vendor/ailib/transports/__init__.py +16 -0
- repokit/_vendor/ailib/transports/cli.py +312 -0
- repokit/_vendor/ailib/transports/openai_compat.py +383 -0
- repokit/_vendor/ailib/transports/prompt_file.py +41 -0
- repokit/_vendor/ailib/types.py +224 -0
- repokit/auth_integration.py +244 -0
- repokit/badges.py +183 -0
- repokit/branch_utils.py +364 -0
- repokit/cli.py +2997 -0
- repokit/common_kit.py +202 -0
- repokit/config.py +686 -0
- repokit/defaults.py +220 -0
- repokit/directory_analyzer.py +1244 -0
- repokit/directory_profiles.py +423 -0
- repokit/git_config.py +370 -0
- repokit/github_create.py +142 -0
- repokit/github_setup.py +248 -0
- repokit/guardrails.py +707 -0
- repokit/history_cleaner.py +449 -0
- repokit/history_protection.py +434 -0
- repokit/hooks/pre-commit-guardrails +122 -0
- repokit/hooks/pre-commit-template +28 -0
- repokit/layers.py +510 -0
- repokit/licenses/0bsd.txt +14 -0
- repokit/licenses/LICENSES.md +27 -0
- repokit/licenses/__init__.py +142 -0
- repokit/licenses/agpl-3.0.txt +661 -0
- repokit/licenses/all-rights-reserved.txt +10 -0
- repokit/licenses/apache-2.0.txt +201 -0
- repokit/licenses/bsd-2-clause.txt +24 -0
- repokit/licenses/bsd-3-clause.txt +28 -0
- repokit/licenses/bsl-1.0.txt +23 -0
- repokit/licenses/cc-by-4.0.txt +395 -0
- repokit/licenses/cc-by-sa-4.0.txt +427 -0
- repokit/licenses/cc0-1.0.txt +121 -0
- repokit/licenses/epl-2.0.txt +277 -0
- repokit/licenses/gpl-2.0.txt +339 -0
- repokit/licenses/gpl-3.0.txt +674 -0
- repokit/licenses/isc.txt +15 -0
- repokit/licenses/lgpl-2.1.txt +504 -0
- repokit/licenses/lgpl-3.0.txt +165 -0
- repokit/licenses/mit.txt +21 -0
- repokit/licenses/mpl-2.0.txt +373 -0
- repokit/licenses/unlicense.txt +24 -0
- repokit/readme_blocks.py +112 -0
- repokit/remote_integration.py +627 -0
- repokit/repo_manager.py +2796 -0
- repokit/template_engine.py +391 -0
- repokit/templates/ai/claude/CLAUDE-nested.md.template +25 -0
- repokit/templates/ai/claude/CLAUDE.md.template +98 -0
- repokit/templates/ai/claude/instructions/step1_context_rebuilder.md +33 -0
- repokit/templates/ai/claude/instructions/step2_dev_workflow_process.md +67 -0
- repokit/templates/ai/claude/instructions/step3_context_bridge.md +52 -0
- repokit/templates/common/CHANGELOG.md.template +15 -0
- repokit/templates/common/CONTRIBUTING.md.template +31 -0
- repokit/templates/common/README.md.template +30 -0
- repokit/templates/common/gitattributes.template +17 -0
- repokit/templates/common/gitignore.template +141 -0
- repokit/templates/extras/FUNDING.yml.template +13 -0
- repokit/templates/extras/ROADMAP.md.template +17 -0
- repokit/templates/extras/dependabot.yml.template +22 -0
- repokit/templates/extras/one-offs-README.md.template +28 -0
- repokit/templates/extras/platform-support.md.template +11 -0
- repokit/templates/extras/pull_request_template.md.template +25 -0
- repokit/templates/extras/release-template.md.template +34 -0
- repokit/templates/extras/settings.json.template +11 -0
- repokit/templates/extras/stale.yml.template +14 -0
- repokit/templates/github/CODEOWNERS.template +11 -0
- repokit/templates/github/ISSUE_TEMPLATE/bug-report.md.template +29 -0
- repokit/templates/github/ISSUE_TEMPLATE/feature-request.md.template +19 -0
- repokit/templates/github/issues/notes.md.template +18 -0
- repokit/templates/github/issues/roadmap.md.template +15 -0
- repokit/templates/hooks/pre-commit.template +64 -0
- repokit/templates/layers/archetypes/any/claude-plugin/.claude-plugin/marketplace.json +20 -0
- repokit/templates/layers/archetypes/any/claude-plugin/plugin/.claude-plugin/plugin.json +15 -0
- repokit/templates/layers/archetypes/any/claude-plugin/plugin/README.md +28 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.github/workflows/ci-c-cpp.yml +24 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.repokit-common.toml.rkappend +4 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/launch.json +29 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/tasks.json +26 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakeLists.txt +47 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakePresets.json +23 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/cmake/GenerateStamp.cmake +45 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/src/main.cpp +27 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/tests/test_version.cpp +20 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/vcpkg.json +5 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/.vscode/launch.json +27 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/Makefile +10 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/build.bat +7 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/src/__RK_SNAKE__.c +26 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/launch.json +17 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/tasks.json +20 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.sln +19 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.vcxproj +41 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/src/main.cpp +26 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/.repokit-common.toml +28 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/.vscode/launch.json +34 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/hooks.json +15 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/scripts/session-start.mjs +23 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/tests/manifest.test.js +30 -0
- repokit/templates/layers/archetypes/js-ts/node/.repokit-common.toml.rkappend +6 -0
- repokit/templates/layers/archetypes/js-ts/node/package.json +13 -0
- repokit/templates/layers/archetypes/js-ts/node-package/.github/workflows/release.yml +39 -0
- repokit/templates/layers/archetypes/js-ts/node-package/package.json +32 -0
- repokit/templates/layers/archetypes/js-ts/node-package/src/index.ts +13 -0
- repokit/templates/layers/archetypes/js-ts/node-package/tests/index.test.ts +11 -0
- repokit/templates/layers/archetypes/js-ts/script/.vscode/launch.json +34 -0
- repokit/templates/layers/archetypes/js-ts/script/__RK_PROJECT__.js +37 -0
- repokit/templates/layers/archetypes/js-ts/script/tests/version.test.js +26 -0
- repokit/templates/layers/archetypes/js-ts/ts/package.json +18 -0
- repokit/templates/layers/archetypes/js-ts/ts/tsconfig.json +15 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.github/workflows/ci-python.yml +44 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.repokit-common.toml +21 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.vscode/launch.json +33 -0
- repokit/templates/layers/archetypes/python/claude-plugin/VERSION +1 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/hooks.json +15 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/run-hook.mjs +42 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/session_start.py +31 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/tests/test_manifest.py +42 -0
- repokit/templates/layers/archetypes/python/pypi/.github/workflows/release.yml +63 -0
- repokit/templates/layers/archetypes/python/pypi/.vscode/launch.json +43 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__init__.py +5 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__main__.py +6 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/_version.py +96 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/cli.py +18 -0
- repokit/templates/layers/archetypes/python/pypi/pyproject.toml +71 -0
- repokit/templates/layers/archetypes/python/pypi/tests/conftest.py +11 -0
- repokit/templates/layers/archetypes/python/pypi/tests/test_version.py +50 -0
- repokit/templates/layers/archetypes/python/script/.github/workflows/ci-python.yml +43 -0
- repokit/templates/layers/archetypes/python/script/.repokit-common.toml +14 -0
- repokit/templates/layers/archetypes/python/script/.vscode/launch.json +33 -0
- repokit/templates/layers/archetypes/python/script/VERSION +1 -0
- repokit/templates/layers/archetypes/python/script/__RK_PROJECT__.py +40 -0
- repokit/templates/layers/archetypes/python/script/tests/test_version.py +25 -0
- repokit/templates/layers/archetypes/python/setuppy/requirements.txt +8 -0
- repokit/templates/layers/archetypes/python/setuppy/setup.py +23 -0
- repokit/templates/layers/languages/c-cpp/.gitignore.rkappend +20 -0
- repokit/templates/layers/languages/c-cpp/.repokit-common.toml +7 -0
- repokit/templates/layers/languages/c-cpp/include/__RK_SNAKE__/version.h +20 -0
- repokit/templates/layers/languages/c-cpp/src/app.rc +25 -0
- repokit/templates/layers/languages/js-ts/.editorconfig +15 -0
- repokit/templates/layers/languages/js-ts/.github/workflows/ci-js-ts.yml +41 -0
- repokit/templates/layers/languages/js-ts/.gitignore.rkappend +13 -0
- repokit/templates/layers/languages/js-ts/.repokit-common.toml +19 -0
- repokit/templates/layers/languages/js-ts/.vscode/launch.json +24 -0
- repokit/templates/layers/languages/js-ts/VERSION +1 -0
- repokit/templates/layers/languages/python/.github/workflows/ci-python.yml +76 -0
- repokit/templates/layers/languages/python/.gitignore.rkappend +15 -0
- repokit/templates/layers/languages/python/.vscode/launch.json +33 -0
- repokit/templates/layers/manifest.toml +302 -0
- repokit/templates/vault/CLAUDE.md +29 -0
- repokit/templates/vault/claude/_maps/MOC-project.md +15 -0
- repokit/templates/vault/claude/_oracle/backlinks.md +3 -0
- repokit/templates/vault/claude/_oracle/concepts.md +12 -0
- repokit/templates/vault/claude/_oracle/manifest.md +10 -0
- repokit/templates/vault/claude/_todo/ACTIVE-WORK.md +18 -0
- repokit/templates/vault/claude/_todo/OPEN-LOOPS.md +14 -0
- repokit/utils.py +316 -0
- repokit/vault.py +248 -0
repokit/repo_manager.py
ADDED
|
@@ -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
|
+
'[]'
|
|
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"
|