"""Git cleanup operations for commit hardening.
This module provides deterministic git operations for the commit cleanup phase,
handling file deletion, gitignore updates, and git exclude patterns.
"""
from __future__ import annotations
from contextlib import suppress
from pathlib import Path, PurePath
from typing import TYPE_CHECKING
from git import InvalidGitRepositoryError, Repo
from loguru import logger
from ralph.git.operations import _atomic_append_text
if TYPE_CHECKING:
from collections.abc import Callable
[docs]
def ensure_git_initialized(repo_root: Path | str) -> None:
"""Ensure the directory is a git repository, initializing if necessary.
Args:
repo_root: Path to the repository root.
"""
with suppress(InvalidGitRepositoryError):
repo = Repo(repo_root, search_parent_directories=False)
repo.close()
return
new_repo: Repo = Repo.init(repo_root) # type: ignore[misc] # reason: external library has no type support, see docs/agents/type-ignore-policy.md#external-library
try:
logger.info("Initialized git repository at {}", repo_root)
finally:
new_repo.close()
[docs]
def delete_file_from_repo(repo_root: Path | str, relative_path: str) -> None:
"""Remove a file from the repository, unstaging if necessary.
Args:
repo_root: Path to the repository root.
relative_path: Path relative to repo_root of the file to delete.
"""
repo_root_path = Path(repo_root).resolve()
path = PurePath(relative_path)
if path.is_absolute() or any(part == ".." for part in path.parts):
raise ValueError(f"Refusing to delete path outside repository root: {relative_path!r}")
unresolved = (repo_root_path / path)
if unresolved.is_symlink():
raise ValueError(
f"Refusing to delete symlink path during commit cleanup: {relative_path!r}"
)
target = unresolved.resolve(strict=False)
try:
target.relative_to(repo_root_path)
except ValueError as exc:
raise ValueError(
f"Refusing to delete path outside repository root: {relative_path!r}"
) from exc
if not target.exists():
logger.debug("File {} does not exist, nothing to delete", relative_path)
return
with suppress(InvalidGitRepositoryError):
repo = Repo(repo_root_path)
try:
tracked_in_index = any(
entry_path == relative_path for entry_path, _stage in repo.index.entries
)
if tracked_in_index:
repo.git.rm("-f", "--cached", "--", relative_path)
finally:
repo.close()
target.unlink(missing_ok=True)
logger.debug("Deleted file {}", relative_path)
[docs]
def add_to_git_exclude(repo_root: Path | str, patterns: list[str]) -> None:
"""Append patterns to .git/info/exclude for machine-local excludes.
Args:
repo_root: Path to the repository root.
patterns: List of patterns to add to exclude.
"""
repo = Repo(repo_root, search_parent_directories=False)
try:
exclude_path = Path(repo.git_dir) / "info" / "exclude"
exclude_path.parent.mkdir(parents=True, exist_ok=True)
existing: set[str] = set()
if exclude_path.exists():
existing = set(exclude_path.read_text().splitlines())
new_patterns = [p for p in patterns if p not in existing]
if new_patterns:
payload = "\n".join(new_patterns) + "\n"
_atomic_append_text(exclude_path, payload)
logger.debug("Added {} patterns to .git/info/exclude", len(new_patterns))
finally:
repo.close()
[docs]
def untrack_engine_internal_files(
repo_root: Path | str,
is_internal_path: Callable[[str], bool],
) -> list[str]:
"""Pre-emptively ``git rm --cached`` every tracked engine-internal file.
This is the rock-solid safety net that the ``commit_cleanup`` phase
calls BEFORE the agent runs: any tracked file that matches
``is_internal_path`` (the canonical ``is_agent_internal_path``
predicate) is removed from the index so it cannot enter the diff
the agent sees. Without this step, a ``delete_file`` action for a
tracked engine-internal file is rejected by ``_is_safe_to_delete``
(the prior failure mode -- the safety check would otherwise
surface a hard fail for tracked engine-owned paths even though
the path is engine-owned).
Contract:
* ``git rm --cached`` is used (NOT ``git rm``) -- the working-tree
files remain on disk so the agent can decide whether to follow
up with a separate ``delete_file`` action.
* Symlinks are rejected BEFORE ``git rm --cached`` by checking
``Path(repo_root / path).is_symlink()``. ``git rm`` follows
symlinks, so a symlink under ``.agent/`` could stage the
symlink target (which may live outside the repo).
* The FIVE canonical project-scope skill-root prefixes
(``.opencode/skills/``, ``.agents/skills/``, ``.claude/skills/``,
``.codex/skills/``, ``.gemini/antigravity-cli/skills/``) are
early-skipped BEFORE the symlink-WARNING block. Skills are tracked
by design (see commit ``e4b47d2fb``), so a symlink under any of
those roots is intentional and the WARNING is noise. This does
NOT widen the safety surface: the canonical
``is_agent_internal_path`` predicate is unchanged and
``is_internal_path`` still gates the ``git rm --cached`` call.
* ``Repo`` is opened in a ``try/finally`` and closed on every
exit path, mirroring ``delete_file_from_repo``.
* Per-path failures are wrapped in ``try/except Exception`` so a
single bad entry does NOT abort the batch -- the helper is
best-effort by design.
* The function returns the list of paths actually untracked so
the caller (``handle_commit_cleanup_phase``) can log the result.
The placement of this helper's call in
``handle_commit_cleanup_phase`` is pinned by the
``_check_pre_emptive_untrack_placement`` AST helper in
``ralph/testing/audit_agent_internal_paths.py`` -- any future
refactor that moves the call behind the artifact load, or that
widens the deletion surface, fails that audit.
Args:
repo_root: Path to the repository root. Accepts both ``Path``
and ``str`` for parity with the rest of this module's API.
is_internal_path: Predicate that returns True when a tracked
path is a Ralph runtime artifact (the canonical
``is_agent_internal_path`` from
``ralph.phases._agent_internal_paths``). Passed as a
positional argument to keep the helper decoupled from
the leaf module and avoid circular-import risk.
Returns:
List of repository-relative paths that were removed from the
index. Empty when no paths matched the predicate, when the
repository has no tracked files, when ``repo_root`` is not a
git repository, or when every match was a symlink and got
rejected before ``git rm --cached``.
"""
repo_root_path = Path(repo_root)
try:
repo = Repo(repo_root_path)
except (InvalidGitRepositoryError, Exception):
logger.debug(
"untrack_engine_internal_files: cannot open repo at {}; skipping pre-emptive untrack",
repo_root_path,
)
return []
untracked: list[str] = []
try:
# Lazy import to avoid circular dependency; mirrors the pattern in
# ``ralph.phases._agent_internal_paths``. The FIVE canonical skill
# roots are tracked by design (see commit ``e4b47d2fb``), so we
# early-skip them BEFORE the symlink-WARNING block: a tracked
# ``.agents/skills/<name>`` symlink is intentional, not a leak.
from ralph.skills._agent_paths import ( # noqa: PLC0415
_SKILL_ROOT_PREFIXES,
)
for entry_key in list(repo.index.entries.keys()):
entry_path = entry_key[0] if isinstance(entry_key, tuple) else entry_key
entry_path_str = str(entry_path)
try:
working_tree_path = repo_root_path / entry_path_str
except TypeError:
continue
if any(
entry_path_str.startswith(prefix) for prefix in _SKILL_ROOT_PREFIXES
):
logger.debug(
"Skipping tracked skill-root path (canonical project-scope skills): {}",
entry_path_str,
)
continue
if working_tree_path.is_symlink():
logger.warning(
"Refusing to git rm --cached symlink under tracked engine-internal path: {}",
entry_path_str,
)
continue
if not is_internal_path(entry_path_str):
continue
try:
repo.git.rm("--cached", "--", entry_path_str)
untracked.append(entry_path_str)
logger.debug(
"Pre-emptively untracked tracked engine-internal file: {}",
entry_path_str,
)
except Exception as exc:
logger.warning(
"Pre-emptive git rm --cached failed for {} (continuing batch): {}",
entry_path_str,
exc,
)
finally:
repo.close()
return untracked