"""Bootstrap helpers for creating user-global and project-local config files.
Auto-creates the user-global Ralph config set on first run, including
~/.config/ralph-workflow.toml, ~/.config/ralph-workflow-mcp.toml,
~/.config/ralph-workflow-pipeline.toml, and
~/.config/ralph-workflow-artifacts.toml from bundled templates.
Also supports regenerating configs with .bak backups via --regenerate-config.
Bootstrap creates the standard first-run config set:
- User-global: ~/.config/ralph-workflow.toml, ~/.config/ralph-workflow-mcp.toml,
~/.config/ralph-workflow-pipeline.toml,
~/.config/ralph-workflow-artifacts.toml
- Project-local: .agent/ralph-workflow.toml, .agent/mcp.toml,
.agent/pipeline.toml, .agent/artifacts.toml
- Advanced optional: .agent/agents.toml (only regenerated when already present)
- Batteries-included .gitignore: Ralph-local, Python, Node, Rust, Go, Ruby,
PHP, Java/Kotlin, .NET, Dart/Flutter, Elixir, Scala, Terraform, IDE,
and OS metadata patterns (see _DEFAULT_GITIGNORE_PATTERNS)
"""
from __future__ import annotations
import importlib.util
import os
import shutil
from collections.abc import Mapping
from dataclasses import dataclass
from importlib import import_module
from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast
from git import InvalidGitRepositoryError, NoSuchPathError, Repo
from ralph.config.loader import load_toml
from ralph.git.operations import _atomic_append_text, append_to_gitignore
if TYPE_CHECKING:
from types import ModuleType
_GLOBAL_CONFIG_FILENAME = "ralph-workflow.toml"
_GLOBAL_MCP_FILENAME = "ralph-workflow-mcp.toml"
_GLOBAL_PIPELINE_FILENAME = "ralph-workflow-pipeline.toml"
_GLOBAL_ARTIFACTS_FILENAME = "ralph-workflow-artifacts.toml"
_LOCAL_CONFIG_FILENAME = "ralph-workflow.toml"
_LOCAL_MCP_FILENAME = "mcp.toml"
_LOCAL_POLICY_FILENAMES = ("pipeline.toml", "artifacts.toml")
_GLOBAL_POLICY_FILENAME_MAP = {
"pipeline.toml": _GLOBAL_PIPELINE_FILENAME,
"artifacts.toml": _GLOBAL_ARTIFACTS_FILENAME,
}
_ADVANCED_LOCAL_POLICY_FILENAMES = ("agents.toml",)
_LOCAL_CONFIG_SOURCE = "ralph-workflow-local.toml"
_DEFAULT_GITIGNORE_PATTERNS: tuple[str, ...] = (
# Ralph Workflow local artifacts (existing — DO NOT REORDER)
".agent/",
"/PROMPT*",
"wt-*/",
"/checkpoint.json",
# Python
"__pycache__/",
"*.py[codz]",
"*$py.class",
".venv/",
"venv/",
".pytest_cache/",
".mypy_cache/",
".ruff_cache/",
".coverage",
"htmlcov/",
"dist/",
"build/*",
"!build/.gitkeep",
"*.egg-info/",
# Python extras
".tox/",
".nox/",
".pdm-build/",
"*.pyo",
".ipynb_checkpoints/",
"pip-wheel-metadata/",
# Node
"node_modules/",
".next/",
".nuxt/",
# Node extras
"coverage/",
"*.tsbuildinfo",
".parcel-cache/",
".cache/",
".env",
".env.local",
".env.*.local",
# Rust (Cargo.lock intentionally NOT ignored — Rust apps check it in)
"target/*",
"!target/.gitkeep",
"**/*.rs.bk",
# Go (vendor/ is opt-in; go.sum intentionally NOT ignored)
"vendor/*",
# Ruby (note: vendor/bundle/.gitkeep cannot be allowlisted here because
# the vendor/* pattern above also matches it. A user who tracks
# vendor/bundle/.gitkeep can add a one-line !vendor/bundle/.gitkeep
# to their project-local gitignore — the appended default cannot honor
# both the Go "ignore all of vendor/" and the Ruby "track this
# marker in vendor/bundle/" semantics simultaneously).
"vendor/bundle/*",
".bundle/",
"log/",
"tmp/",
"*.gem",
# PHP
".phpunit.cache/",
"/storage/*.key",
"composer.phar",
# Java/Kotlin extras (paired with existing .idea/ below)
".gradle/*",
"!.gradle/.gitkeep",
"*.class",
"*.jar",
# .NET / Visual Studio
"bin/*",
"!bin/.gitkeep",
"obj/*",
"!obj/.gitkeep",
"*.user",
"*.suo",
"*.userosscache",
"*.sln.docstates",
"[Dd]ebug/",
"[Rr]elease/",
"x64/",
"x86/",
"*.dll",
"*.exe",
"*.pdb",
# Dart / Flutter
".dart_tool/",
".flutter-plugins",
".flutter-plugins-dependencies",
".packages",
".pub-cache/",
".pub/",
# Elixir
"_build/*",
"!_build/.gitkeep",
"deps/*",
"!deps/.gitkeep",
".elixir_ls/",
"cover/",
"doc/",
"fetch.*.exs",
# Scala / Metals / BSP
".bsp/",
".metals/",
"project/target/",
"project/project/",
# Terraform (note: *.tfvars is in the positive list; *.tfvars.example
# is intentionally NOT allowlisted — the user must explicitly add
# tracked example files).
".terraform/",
"*.tfstate",
"*.tfstate.*",
".terraform.lock.hcl",
"terraform.tfvars",
"crash.log",
"crash.*.log",
"*.tfvars",
# Editors / IDEs extra
".fleet/",
".cursor/",
".windsurf/",
".idea_modules/",
"*.iml",
# Editors / IDEs (NOTE: .vscode/ intentionally NOT included —
# the upstream repository has tracked files under .vscode/ that
# a blanket ignore would hide.)
".idea/",
"*.swp",
"*.swo",
# OS metadata
".DS_Store",
"Thumbs.db",
# OS metadata extras
"Desktop.ini",
"ehthumbs.db",
"$RECYCLE.BIN/",
)
# Machine-local exclude patterns for ``.git/info/exclude``. These never enter
# the repo -- they keep the user-level state out of every clone.
#
# Every pattern here is the canonical on-disk form (NOT the Python abstraction
# identifier). The completion-sentinel glob is ``completion_seen_*.json``
# (the canonical on-disk filename pattern -- confirmed against
# ``COMPLETION_SENTINEL_RELPATHFMT`` in ``ralph.mcp.tools.coordination``).
# Root-anchored ``/checkpoint.json`` is used (NOT bare ``checkpoint.json``
# which would silently match every nested directory).
#
# NOTE: ``_agent_internal_paths.py`` is loaded via ``importlib`` rather than
# a normal ``from ... import`` so this module never transitively triggers
# ``ralph.phases.__init__.py``. A normal import would create a cycle
# (``ralph.config`` -> ``bootstrap`` -> ``ralph.phases.__init__`` ->
# ``ralph.policy.loader`` -> ``ralph.phases``), so the leaf module is loaded
# directly by file path. The audit
# (``ralph/testing/audit_agent_internal_paths.py``) uses the same pattern.
def _load_agent_internal_paths_module() -> ModuleType:
"""Load ``_agent_internal_paths`` directly without triggering ``ralph.phases.__init__``.
Returns:
The loaded ``_agent_internal_paths`` module.
"""
module_path = Path(__file__).resolve().parent.parent / "phases" / "_agent_internal_paths.py"
spec = importlib.util.spec_from_file_location(
"_ralph_agent_internal_paths_bootstrap_target", module_path
)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot build import spec for {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_agent_internal_paths_module = _load_agent_internal_paths_module()
_AGENT_INTERNAL_COMPLETION_SENTINEL_GLOB: str = (
_agent_internal_paths_module._AGENT_INTERNAL_COMPLETION_SENTINEL_GLOB
)
AGENT_INTERNAL_DIR_GLOBS: frozenset[str] = _agent_internal_paths_module.AGENT_INTERNAL_DIR_GLOBS
AGENT_INTERNAL_ROOT_BASENAMES: frozenset[str] = (
_agent_internal_paths_module.AGENT_INTERNAL_ROOT_BASENAMES
)
AGENT_INTERNAL_TOP_LEVEL_BASENAMES: frozenset[str] = (
_agent_internal_paths_module.AGENT_INTERNAL_TOP_LEVEL_BASENAMES
)
_DEFAULT_GIT_EXCLUDE_PATTERNS: tuple[str, ...] = (
# Engine-internal directories under .agent/ -- everything inside is engine-owned.
*tuple(f".agent/{dir_name}/" for dir_name in sorted(AGENT_INTERNAL_DIR_GLOBS)),
# Completion sentinels -- on-disk filename glob, NOT Python abstraction identifier.
f".agent/{_AGENT_INTERNAL_COMPLETION_SENTINEL_GLOB}",
# Engine-internal top-level files under .agent/.
*tuple(f".agent/{name}" for name in sorted(AGENT_INTERNAL_TOP_LEVEL_BASENAMES)),
# Root-anchored root basenames (only checkpoint.json today). The leading
# slash matches only at the repo root -- bare ``checkpoint.json`` would
# silently match every nested directory (PA-002).
*tuple(f"/{name}" for name in sorted(AGENT_INTERNAL_ROOT_BASENAMES)),
)
def _module_attr_or_none(module: ModuleType, attribute: str) -> object | None:
namespace = cast("dict[str, object]", module.__dict__)
return namespace.get(attribute)
def _get_bundled_defaults_dir() -> Path:
"""Return the path to the bundled default policy files.
Computed lazily to avoid circular import: ralph.policy.loader imports
ralph.phases which imports ralph.pipeline which imports ralph.config.
"""
policy_module = import_module("ralph.policy")
policy_file = _module_attr_or_none(policy_module, "__file__")
if not isinstance(policy_file, str):
raise RuntimeError("ralph.policy module has no __file__")
return Path(policy_file).parent / "defaults"
[docs]
@dataclass(frozen=True)
class BootstrapResult:
"""Result of a bootstrap operation.
Attributes:
path: Target file path that was acted on.
action: What happened: created, skipped, or regenerated.
backup: Path to the .bak file if the original was backed up, else None.
"""
path: Path
action: Literal["created", "skipped", "regenerated"]
backup: Path | None = None
[docs]
def resolve_global_config_dir(env: Mapping[str, str] | None = None) -> Path:
"""Resolve the user-global config directory.
Honors XDG_CONFIG_HOME when set; falls back to ~/.config.
Args:
env: Environment mapping to read from. Uses os.environ when None.
Returns:
Path to the config directory.
"""
env_map: Mapping[str, str] = os.environ if env is None else env
xdg = env_map.get("XDG_CONFIG_HOME", "")
if xdg:
return Path(xdg)
return Path.home() / ".config"
[docs]
def ensure_global_config(global_dir: Path | None = None, *, force: bool = False) -> BootstrapResult:
"""Ensure ~/.config/ralph-workflow.toml exists, creating it from the bundled template.
Args:
global_dir: Override the global config directory. Defaults to resolve_global_config_dir().
force: When True, overwrite an existing file (backs it up to <name>.bak first).
Returns:
BootstrapResult describing the action taken.
"""
if global_dir is None:
global_dir = resolve_global_config_dir()
target = global_dir / _GLOBAL_CONFIG_FILENAME
source = _get_bundled_defaults_dir() / _GLOBAL_CONFIG_FILENAME
result = _copy_with_backup(source, target, force)
if result.action == "skipped":
migrated = _migrate_legacy_global_config(target)
if migrated is not None:
return migrated
return result
[docs]
def ensure_global_mcp_config(
global_dir: Path | None = None, *, force: bool = False
) -> BootstrapResult:
"""Ensure ~/.config/ralph-workflow-mcp.toml exists, creating it from the bundled template.
Args:
global_dir: Override the global config directory. Defaults to resolve_global_config_dir().
force: When True, overwrite an existing file (backs it up to <name>.bak first).
Returns:
BootstrapResult describing the action taken.
"""
if global_dir is None:
global_dir = resolve_global_config_dir()
target = global_dir / _GLOBAL_MCP_FILENAME
source = _get_bundled_defaults_dir() / "mcp.toml"
return _copy_with_backup(source, target, force)
[docs]
def ensure_global_policy_configs(
global_dir: Path | None = None, *, force: bool = False
) -> list[BootstrapResult]:
"""Ensure the user-global policy defaults exist.
Args:
global_dir: Override the global config directory. Defaults to resolve_global_config_dir().
force: When True, overwrite existing files (backs them up first).
Returns:
List of BootstrapResult, one per global policy file.
"""
if global_dir is None:
global_dir = resolve_global_config_dir()
return [
_copy_with_backup(
_resolve_global_policy_source(global_dir, policy_filename, force=force),
global_dir / _global_policy_target_name(policy_filename),
force,
)
for policy_filename in _LOCAL_POLICY_FILENAMES
]
[docs]
def ensure_local_main_config(agent_dir: Path, *, force: bool = False) -> BootstrapResult:
"""Ensure the project-local main override exists.
Args:
agent_dir: The .agent directory to write configs into.
force: When True, overwrite an existing file (backing it up first).
Returns:
BootstrapResult describing the action taken for `.agent/ralph-workflow.toml`.
"""
agent_dir.mkdir(parents=True, exist_ok=True)
global_source = resolve_global_config_dir() / _GLOBAL_CONFIG_FILENAME
source = (
global_source
if global_source.exists()
else _get_bundled_defaults_dir() / _LOCAL_CONFIG_SOURCE
)
return _copy_with_backup(
source,
agent_dir / _LOCAL_CONFIG_FILENAME,
force,
)
[docs]
def ensure_local_support_configs(agent_dir: Path, *, force: bool = False) -> list[BootstrapResult]:
"""Ensure the standard project-local policy and MCP files exist.
This scaffolds the `.agent/` files Ralph needs for project-local runtime behavior
without creating the optional project-local main override.
Args:
agent_dir: The .agent directory to write configs into.
force: When True, overwrite existing files (backs them up first).
Returns:
List of BootstrapResult, one per support file.
"""
agent_dir.mkdir(parents=True, exist_ok=True)
global_dir = resolve_global_config_dir()
global_mcp_source = global_dir / _GLOBAL_MCP_FILENAME
mcp_source = (
global_mcp_source
if global_mcp_source.exists()
else _get_bundled_defaults_dir() / "mcp.toml"
)
results: list[BootstrapResult] = [
_copy_with_backup(
mcp_source,
agent_dir / _LOCAL_MCP_FILENAME,
force,
)
]
results.extend(
_copy_with_backup(
_resolve_global_policy_source(global_dir, policy_filename, force=False),
agent_dir / policy_filename,
force,
)
for policy_filename in _LOCAL_POLICY_FILENAMES
)
_ensure_default_gitignore(agent_dir.parent)
_ensure_default_git_exclude(agent_dir.parent)
return results
[docs]
def ensure_local_configs(agent_dir: Path, *, force: bool = False) -> list[BootstrapResult]:
"""Ensure the full project-local config set exists.
Args:
agent_dir: The .agent directory to write configs into.
force: When True, overwrite existing files (backs them up first).
Returns:
List of BootstrapResult, one per config file.
"""
return [
ensure_local_main_config(agent_dir, force=force),
*ensure_local_support_configs(agent_dir, force=force),
]
def _ensure_default_gitignore(repo_root: Path) -> None:
append_to_gitignore(repo_root, list(_DEFAULT_GITIGNORE_PATTERNS))
def _ensure_default_git_exclude(repo_root: Path) -> None:
"""Append the canonical engine-internal patterns to ``.git/info/exclude``.
Uses ``add_to_git_exclude`` from ``ralph.git.commit_cleanup`` which is
idempotent and preserves user-added entries. Idempotent on its own --
the helper will simply return ``[]`` when every default pattern is
already present.
Graceful no-op when ``repo_root`` is not a git repository: the
underlying ``add_to_git_exclude`` calls ``Repo(repo_root)`` which
raises ``InvalidGitRepositoryError`` if there is no ``.git`` directory.
Bootstrap is called from non-git working trees (e.g. ``ensure_local_configs``
in unit tests that pass a bare ``tmp_path``); failing here would break
those tests for no real benefit.
Raises:
OSError: When the underlying filesystem operation fails.
"""
from git import InvalidGitRepositoryError
from ralph.git.commit_cleanup import add_to_git_exclude
try:
add_to_git_exclude(repo_root, list(_DEFAULT_GIT_EXCLUDE_PATTERNS))
except InvalidGitRepositoryError:
# Non-git working tree -- nothing to seed. The gitignore path
# (``_ensure_default_gitignore``) also does not require a git repo.
return
def _resolve_git_exclude_path(repo_root: Path) -> Path | None:
"""Return the resolved path to ``info/exclude`` for ``repo_root``.
Works for normal repositories AND git worktrees / separate-git-dir
layouts. In a worktree the top-level ``.git`` is a *file* containing
``gitdir: <real-gitdir>``; the real ``info/exclude`` lives in
``<real-gitdir>/info/exclude``, NOT in ``repo_root/.git/info/exclude``.
Resolving via GitPython's ``Repo.git_dir`` works for both layouts.
Returns ``None`` when ``repo_root`` is not a git repository. In that
case the caller falls back to the simple ``repo_root/.git/info/exclude``
path so the helper still seeds the file when invoked in a project
that has not yet been ``git init``-ed (Ralph creates the project-local
config set on every invocation).
"""
try:
repo = Repo(repo_root, search_parent_directories=False)
except (InvalidGitRepositoryError, NoSuchPathError):
return None
try:
git_dir = Path(repo.git_dir)
finally:
repo.close()
return git_dir / "info" / "exclude"
[docs]
def auto_seed_default_git_exclude(repo_root: Path) -> list[str]:
"""Auto-seed ``.git/info/exclude`` on a normal ``ralph`` run.
Mirrors ``auto_seed_default_gitignore`` but for the per-user
``.git/info/exclude`` file. Reads the existing file (if any), computes
the patterns from ``_DEFAULT_GIT_EXCLUDE_PATTERNS`` that are not
already present, appends the missing patterns to the resolved
exclude file, and returns the list of patterns that were actually
appended.
The git directory is resolved via ``Repo(repo_root).git_dir`` so the
helper works for normal repositories, git worktrees, and
separate-git-dir layouts. In a worktree the top-level ``.git`` is a
gitfile pointing at the real gitdir; blindly using
``repo_root / '.git' / 'info' / 'exclude'`` would call ``mkdir`` on a
file and fail with ``NotADirectoryError``. Falls back to the
repo-root layout only when ``repo_root`` is not a git repository.
Idempotent: a second call with the same ``repo_root`` returns ``[]``
when every default pattern is already present. Does NOT clobber
user-added entries.
Tolerates a missing git dir: when ``repo_root`` is not a git
repository (e.g. first-run bootstrap before ``git init``), the helper
writes ``.git/info/exclude`` directly into the filesystem, creating
the parent dirs as needed.
Args:
repo_root: Path to the repository (or project) root.
Returns:
List of patterns that were appended on this call. Empty when the
existing file already covered every default pattern.
"""
exclude_path = _resolve_git_exclude_path(repo_root)
if exclude_path is None:
# Non-git working tree -- seed the conventional repo_root/.git/info/exclude.
exclude_path = repo_root / ".git" / "info" / "exclude"
existing: set[str] = set()
file_existed = exclude_path.exists()
if file_existed:
try:
existing = set(exclude_path.read_text(encoding="utf-8").splitlines())
except OSError:
existing = set()
missing = [p for p in _DEFAULT_GIT_EXCLUDE_PATTERNS if p not in existing]
if missing:
exclude_path.parent.mkdir(parents=True, exist_ok=True)
payload = "\n".join(missing) + "\n"
_atomic_append_text(exclude_path, payload)
return list(missing)
[docs]
def auto_seed_default_gitignore(repo_root: Path) -> list[str]:
"""Auto-seed the batteries-included .gitignore on a normal `ralph` run.
Reads the existing ``.gitignore`` (if any), computes the patterns from
``_DEFAULT_GITIGNORE_PATTERNS`` that are not already present, appends
them via ``append_to_gitignore`` (which filters duplicates by line), and
returns the list of patterns that were actually appended.
Idempotent: a second call with the same ``repo_root`` returns ``[]``
when every default pattern is already present (the underlying
``append_to_gitignore`` short-circuits when nothing is missing). Does
NOT clobber user-added lines — user-customized patterns are preserved.
Handles the no-git case: the helper just touches ``.gitignore`` in
``repo_root``; it does not require a ``.git`` directory to exist.
Args:
repo_root: Path to the repository (or project) root.
Returns:
List of patterns that were appended on this call. Empty when the
existing ``.gitignore`` already covered every default pattern.
"""
gitignore_path = repo_root / ".gitignore"
existing: set[str] = set()
if gitignore_path.exists():
try:
existing = set(gitignore_path.read_text(encoding="utf-8").splitlines())
except OSError:
existing = set()
missing = [p for p in _DEFAULT_GITIGNORE_PATTERNS if p not in existing]
if missing:
gitignore_path = repo_root / ".gitignore"
payload = "\n".join(missing) + "\n"
_atomic_append_text(gitignore_path, payload)
return list(missing)
def _regenerate_existing_advanced_local_configs(agent_dir: Path) -> list[BootstrapResult]:
"""Regenerate advanced local configs only when they already exist."""
results: list[BootstrapResult] = []
for policy_filename in _ADVANCED_LOCAL_POLICY_FILENAMES:
target = agent_dir / policy_filename
if target.exists():
results.append(
_copy_with_backup(
_get_bundled_defaults_dir() / policy_filename,
target,
True,
)
)
return results
[docs]
def regenerate_all(
*,
global_dir: Path | None = None,
agent_dir: Path | None = None,
) -> list[BootstrapResult]:
"""Regenerate all configs from bundled defaults, backing up existing files.
Args:
global_dir: Override the global config directory. Defaults to resolve_global_config_dir().
agent_dir: The .agent directory to regenerate local configs in. Skipped when None.
Returns:
Flat list of BootstrapResult for every file touched.
"""
results: list[BootstrapResult] = [
ensure_global_config(global_dir, force=True),
ensure_global_mcp_config(global_dir, force=True),
*ensure_global_policy_configs(global_dir, force=True),
]
if agent_dir is not None:
results.extend(ensure_local_configs(agent_dir, force=True))
results.extend(_regenerate_existing_advanced_local_configs(agent_dir))
return results
def _backup_path(target: Path) -> Path:
return target.with_suffix(target.suffix + ".bak")
def _global_policy_target_name(local_policy_filename: str) -> str:
return _GLOBAL_POLICY_FILENAME_MAP.get(local_policy_filename, local_policy_filename)
def _resolve_global_policy_source(
global_dir: Path, local_policy_filename: str, *, force: bool
) -> Path:
if force:
return _get_bundled_defaults_dir() / local_policy_filename
preferred_global_path = global_dir / _global_policy_target_name(local_policy_filename)
if preferred_global_path.exists():
return preferred_global_path
legacy_global_path = global_dir / local_policy_filename
if legacy_global_path.exists():
return _get_bundled_defaults_dir() / local_policy_filename
return _get_bundled_defaults_dir() / local_policy_filename
def _migrate_legacy_global_config(target: Path) -> BootstrapResult | None:
try:
text = target.read_text(encoding="utf-8")
except OSError:
return None
data = cast("Mapping[str, object]", load_toml(target))
raw_drains_obj: object = data.get("agent_drains")
if not isinstance(raw_drains_obj, Mapping):
return None
raw_drains = cast("Mapping[str, object]", raw_drains_obj)
drains: dict[str, object] = {
key: value for key, value in raw_drains.items() if isinstance(key, str)
}
missing: list[tuple[str, str]] = []
analysis_chain: object = drains.get("analysis")
commit_chain: object = drains.get("commit")
if isinstance(analysis_chain, str):
missing.extend(
(drain_name, analysis_chain)
for drain_name in ("planning_analysis", "development_analysis")
if drain_name not in drains
)
if isinstance(commit_chain, str) and "development_commit" not in drains:
missing.append(("development_commit", commit_chain))
section_start = text.find("[agent_drains]")
if not missing or section_start == -1:
return None
next_section = text.find("\n[", section_start + len("[agent_drains]"))
insert_at = len(text) if next_section == -1 else next_section + 1
insert_lines = "".join(f'{name} = "{chain}"\n' for name, chain in missing)
if insert_at == len(text) and not text.endswith("\n"):
insert_lines = "\n" + insert_lines
backup = _backup_path(target)
if backup.exists():
backup.unlink()
shutil.copy2(str(target), str(backup))
target.write_text(text[:insert_at] + insert_lines + text[insert_at:], encoding="utf-8")
return BootstrapResult(target, "regenerated", backup)
def _copy_with_backup(source: Path, target: Path, force: bool) -> BootstrapResult:
"""Copy source to target, optionally backing up an existing target first.
The backup (.bak) is always in the same directory as target, so a
cross-device move is impossible and shutil.move is safe.
Args:
source: Bundled template to copy.
target: Destination path.
force: When True, overwrite target (with backup). When False, skip if target exists.
Returns:
BootstrapResult describing what happened.
"""
target.parent.mkdir(parents=True, exist_ok=True)
pre_existed = target.exists()
if pre_existed and not force:
return BootstrapResult(target, "skipped", None)
backup: Path | None = None
if pre_existed and force:
backup = _backup_path(target)
if backup.exists():
backup.unlink()
shutil.move(str(target), str(backup))
shutil.copy2(str(source), str(target))
action: Literal["created", "skipped", "regenerated"] = (
"regenerated" if pre_existed else "created"
)
return BootstrapResult(target, action, backup)