"""Git state management for elspais.

Provides functions to query git status and detect changes to requirement files,
enabling detection of:
- Uncommitted changes to spec files
- New (untracked) requirement files
- Files changed vs main/master branch
- Moved requirements (comparing current location to committed state)
"""

from __future__ import annotations

import os
import subprocess
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

_GIT_ENV_VARS_TO_STRIP = (
    "GIT_DIR",
    "GIT_WORK_TREE",
    "GIT_INDEX_FILE",
    "GIT_AUTHOR_NAME",
    "GIT_AUTHOR_EMAIL",
    "GIT_AUTHOR_DATE",
    "GIT_COMMITTER_NAME",
    "GIT_COMMITTER_EMAIL",
    "GIT_COMMITTER_DATE",
)


def _clean_git_env() -> dict[str, str]:
    """Return environment with git hook context vars removed.

    Use when running git commands with explicit cwd to prevent
    inherited git context from overriding the provided path.

    Strips: GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE (cause subprocess
    git to target the wrong repo/index), and GIT_AUTHOR_*/GIT_COMMITTER_*
    (override local repo user config, leak from pre-commit/pre-push hooks).
    """
    env = os.environ.copy()
    for var in _GIT_ENV_VARS_TO_STRIP:
        env.pop(var, None)
    return env


# Implements: REQ-p00004-B
@contextmanager
def temporary_worktree(repo_root: Path, ref: str = "HEAD") -> Iterator[Path]:
    """Create a temporary git worktree for a ref.

    Creates a detached worktree at the specified ref, yields its path,
    then cleans up the worktree automatically on exit.

    Usage:
        with temporary_worktree(repo_root, "HEAD") as worktree_path:
            committed_graph = build_graph(repo_root=worktree_path)
            # work with committed state...

    Args:
        repo_root: Path to the repository root.
        ref: Git ref to checkout (default: HEAD).

    Yields:
        Path to the temporary worktree.

    Raises:
        subprocess.CalledProcessError: If git worktree commands fail.
    """
    with tempfile.TemporaryDirectory() as tmp:
        worktree_path = Path(tmp) / "worktree"

        subprocess.run(
            ["git", "worktree", "add", "--detach", str(worktree_path), ref],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            check=True,
        )

        try:
            yield worktree_path
        finally:
            subprocess.run(
                ["git", "worktree", "remove", "--force", str(worktree_path)],
                cwd=repo_root,
                env=_clean_git_env(),
                capture_output=True,
            )


@dataclass
class GitChangeInfo:
    """Information about git changes to requirement files.

    Attributes:
        modified_files: Files with uncommitted modifications (staged or unstaged).
        untracked_files: New files not yet tracked by git.
        branch_changed_files: Files changed between current branch and main/master.
        committed_req_locations: REQ ID -> file path mapping from committed state (HEAD).
    """

    modified_files: set[str] = field(default_factory=set)
    untracked_files: set[str] = field(default_factory=set)
    branch_changed_files: set[str] = field(default_factory=set)
    committed_req_locations: dict[str, str] = field(default_factory=dict)

    @property
    def all_changed_files(self) -> set[str]:
        """Get all files with any kind of change."""
        return self.modified_files | self.untracked_files | self.branch_changed_files

    @property
    def uncommitted_files(self) -> set[str]:
        """Get all files with uncommitted changes (modified or untracked)."""
        return self.modified_files | self.untracked_files


@dataclass
class MovedRequirement:
    """Information about a requirement that was moved between files.

    Attributes:
        req_id: The requirement ID (e.g., 'd00001').
        old_path: Path in the committed state.
        new_path: Path in the current working directory.
    """

    req_id: str
    old_path: str
    new_path: str


# Implements: REQ-p00004-B
def get_repo_root(start_path: Path | None = None) -> Path | None:
    """Find the git repository root.

    Args:
        start_path: Path to start searching from (default: current directory)

    Returns:
        Path to repository root, or None if not in a git repository
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            cwd=start_path or Path.cwd(),
            env=_clean_git_env() if start_path else None,
            capture_output=True,
            text=True,
            check=True,
        )
        return Path(result.stdout.strip())
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None


# Implements: REQ-p00004-B
def get_modified_files(repo_root: Path) -> tuple[set[str], set[str]]:
    """Get sets of modified and untracked files according to git status.

    Args:
        repo_root: Path to repository root

    Returns:
        Tuple of (modified_files, untracked_files):
        - modified_files: Tracked files with changes (M, A, R, etc.)
        - untracked_files: New files not yet tracked (??)
    """
    try:
        result = subprocess.run(
            ["git", "status", "--porcelain", "--untracked-files=all"],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
        modified_files: set[str] = set()
        untracked_files: set[str] = set()

        for line in result.stdout.split("\n"):
            if line and len(line) >= 3:
                # Format: "XY filename" or "XY orig -> renamed"
                # XY = two-letter status (e.g., " M", "??", "A ", "R ")
                status_code = line[:2]
                file_path = line[3:].strip()

                # Handle renames: "orig -> new"
                if " -> " in file_path:
                    file_path = file_path.split(" -> ")[1]

                if file_path:
                    if status_code == "??":
                        untracked_files.add(file_path)
                    else:
                        modified_files.add(file_path)

        return modified_files, untracked_files
    except (subprocess.CalledProcessError, FileNotFoundError):
        return set(), set()


# Implements: REQ-p00004-B
def get_changed_vs_branch(repo_root: Path, base_branch: str = "main") -> set[str]:
    """Get set of files changed between current branch and base branch.

    Args:
        repo_root: Path to repository root
        base_branch: Name of base branch (default: 'main')

    Returns:
        Set of file paths changed vs base branch
    """
    # Try local branch first, then remote
    for branch_ref in [base_branch, f"origin/{base_branch}"]:
        try:
            result = subprocess.run(
                ["git", "diff", "--name-only", f"{branch_ref}...HEAD"],
                cwd=repo_root,
                env=_clean_git_env(),
                capture_output=True,
                text=True,
                check=True,
            )
            changed_files: set[str] = set()
            for line in result.stdout.split("\n"):
                if line.strip():
                    changed_files.add(line.strip())
            return changed_files
        except subprocess.CalledProcessError:
            continue
        except FileNotFoundError:
            return set()

    return set()


# Implements: REQ-p00004-B
def _extract_req_locations_from_graph(graph: Any, repo_root: Path | None = None) -> dict[str, str]:
    """Extract REQ ID -> file path mapping from a TraceGraph.

    This is the graph-based replacement for the old regex-based extraction.
    Uses the same parsing logic that build_graph() uses.

    Args:
        graph: A TraceGraph instance.
        repo_root: Repository root for relativizing paths (uses graph.repo_root if None).

    Returns:
        Dict mapping full canonical REQ ID (e.g., 'REQ-d00001') to relative file path.
    """
    from elspais.graph.GraphNode import NodeKind

    req_locations: dict[str, str] = {}

    # Get repo_root for path relativization
    if repo_root is None:
        repo_root = getattr(graph, "repo_root", None)

    # Implements: REQ-d00129-D
    for node in graph.all_nodes():
        if node.kind == NodeKind.REQUIREMENT:
            fn = node.file_node()
            if not fn:
                continue
            # Use the full canonical node ID as the key.
            # This avoids hardcoded prefix stripping and works with any
            # namespace/type pattern configured via [id-patterns].
            req_id = node.id

            # Get source path from FILE node and make it relative if needed
            source_path = fn.get_field("relative_path") or ""
            if repo_root:
                try:
                    # If path is absolute, make it relative to repo_root
                    path_obj = Path(source_path)
                    if path_obj.is_absolute():
                        source_path = str(path_obj.relative_to(repo_root))
                except ValueError:
                    # Path is not relative to repo_root, keep as-is
                    pass

            req_locations[req_id] = source_path

    return req_locations


# Implements: REQ-p00004-B
def get_req_locations_from_graph(
    repo_root: Path,
) -> dict[str, str]:
    """Get REQ ID -> file path mapping from a graph built at the given path.

    This is the graph-based approach that uses build_graph() to parse
    requirements using the project's configuration.

    Args:
        repo_root: Path to repository root (or worktree path).

    Returns:
        Dict mapping full canonical REQ ID (e.g., 'REQ-d00001') to relative file path.
    """
    from elspais.graph.factory import build_graph

    # Build graph with minimal scanning (we only need requirements)
    graph = build_graph(
        repo_root=repo_root,
        scan_code=False,
        scan_tests=False,
    )

    return _extract_req_locations_from_graph(graph, repo_root)


# Implements: REQ-p00004-B
def detect_moved_requirements(
    committed_locations: dict[str, str],
    current_locations: dict[str, str],
) -> list[MovedRequirement]:
    """Detect requirements that have been moved between files.

    Args:
        committed_locations: REQ ID -> path mapping from committed state
        current_locations: REQ ID -> path mapping from current state

    Returns:
        List of MovedRequirement objects for requirements whose location changed
    """
    moved = []
    for req_id, old_path in committed_locations.items():
        if req_id in current_locations:
            new_path = current_locations[req_id]
            if old_path != new_path:
                moved.append(
                    MovedRequirement(
                        req_id=req_id,
                        old_path=old_path,
                        new_path=new_path,
                    )
                )
    return moved


# Implements: REQ-p00004-B
def get_git_changes(
    repo_root: Path | None = None,
    spec_dir: str = "spec",
    base_branch: str = "main",
    base_ref: str = "HEAD",
) -> GitChangeInfo:
    """Get comprehensive git change information for requirement files.

    This is the main entry point for git change detection. It gathers:
    - Modified files (uncommitted changes to tracked files)
    - Untracked files (new files not yet in git)
    - Branch changed files (files changed vs main/master)
    - Committed REQ locations (for move detection via graph-based comparison)

    Uses git worktree + build_graph() to properly parse committed state,
    respecting project configuration rather than hardcoded regex patterns.

    Args:
        repo_root: Path to repository root (auto-detected if None)
        spec_dir: Spec directory relative to repo root (deprecated, ignored)
        base_branch: Base branch for comparison (default: 'main')
        base_ref: Git ref for committed state comparison (default: 'HEAD')

    Returns:
        GitChangeInfo with all change information
    """
    if repo_root is None:
        repo_root = get_repo_root()
        if repo_root is None:
            return GitChangeInfo()

    modified, untracked = get_modified_files(repo_root)
    branch_changed = get_changed_vs_branch(repo_root, base_branch)

    # Get committed locations using graph-based approach via git worktree
    committed_locations: dict[str, str] = {}
    try:
        with temporary_worktree(repo_root, base_ref) as worktree_path:
            committed_locations = get_req_locations_from_graph(worktree_path)
    except subprocess.CalledProcessError:
        # Worktree creation failed (e.g., no commits yet), fall back to empty
        pass

    return GitChangeInfo(
        modified_files=modified,
        untracked_files=untracked,
        branch_changed_files=branch_changed,
        committed_req_locations=committed_locations,
    )


# Implements: REQ-p00004-B
def filter_spec_files(files: set[str], spec_dir: str = "spec") -> set[str]:
    """Filter a set of files to only include spec directory files.

    Args:
        files: Set of file paths
        spec_dir: Spec directory prefix

    Returns:
        Set of files that are in the spec directory
    """
    prefix = f"{spec_dir}/"
    return {f for f in files if f.startswith(prefix) and f.endswith(".md")}


# ─────────────────────────────────────────────────────────────────────────────
# Safety Branch Utilities (REQ-o00063)
# ─────────────────────────────────────────────────────────────────────────────


# Implements: REQ-o00063-D
def get_current_branch(repo_root: Path) -> str | None:
    """Get the name of the current git branch.

    Args:
        repo_root: Path to repository root

    Returns:
        Branch name, or None if not on a branch (detached HEAD)
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--abbrev-ref", "HEAD"],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
        branch = result.stdout.strip()
        return branch if branch != "HEAD" else None
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None


# Implements: REQ-d00128-C
def get_current_commit(repo_root: Path) -> str | None:
    """Get the current git commit hash (short form).

    Args:
        repo_root: Path to repository root

    Returns:
        Short commit hash, or None if not in a git repo
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip() or None
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None


# Implements: REQ-p00004-C
def git_status_summary(
    repo_root: Path,
    spec_dir: str = "spec",
    main_branches: tuple[str, ...] = ("main", "master"),
) -> dict[str, Any]:
    """Get git status summary for the viewer UI.

    Returns a dict with:
        branch: Current branch name (or None if detached)
        is_main: Whether current branch is a main/protected branch
        dirty_spec_files: List of modified/untracked spec files (relative paths)
        remote_diverged: Whether the remote tracking branch has diverged
        fast_forward_possible: Whether a fast-forward pull is possible
    """
    branch = get_current_branch(repo_root)
    is_main = branch in main_branches if branch else False

    # Get dirty spec files
    modified, untracked = get_modified_files(repo_root)
    all_dirty = modified | untracked
    prefix = f"{spec_dir}/"
    dirty_spec = sorted(f for f in all_dirty if f.startswith(prefix))

    # Check remote divergence
    remote_diverged = False
    fast_forward_possible = False
    main_diverged = False
    local_ahead_count = 0
    env = _clean_git_env()
    if branch:
        try:
            # Fetch without modifying working tree
            subprocess.run(
                ["git", "fetch", "--quiet"],
                cwd=repo_root,
                env=env,
                capture_output=True,
                timeout=10,
            )
            # Check if remote tracking branch has diverged from local
            remote_ref = f"origin/{branch}"
            result = subprocess.run(
                ["git", "rev-list", "--left-right", "--count", f"{branch}...{remote_ref}"],
                cwd=repo_root,
                env=env,
                capture_output=True,
                text=True,
            )
            if result.returncode == 0:
                parts = result.stdout.strip().split()
                if len(parts) == 2:
                    local_ahead = int(parts[0])
                    remote_ahead = int(parts[1])
                    local_ahead_count = local_ahead
                    remote_diverged = remote_ahead > 0
                    fast_forward_possible = remote_ahead > 0 and local_ahead == 0

            # Check if main has diverged since branch point (informational)
            if not is_main:
                for main_name in main_branches:
                    remote_main = f"origin/{main_name}"
                    # Find merge-base between this branch and remote main
                    mb = subprocess.run(
                        ["git", "merge-base", branch, remote_main],
                        cwd=repo_root,
                        env=env,
                        capture_output=True,
                        text=True,
                    )
                    if mb.returncode != 0:
                        continue
                    merge_base = mb.stdout.strip()
                    # Check if remote main has moved past the merge-base
                    tip = subprocess.run(
                        ["git", "rev-parse", remote_main],
                        cwd=repo_root,
                        env=env,
                        capture_output=True,
                        text=True,
                    )
                    if tip.returncode == 0 and tip.stdout.strip() != merge_base:
                        main_diverged = True
                    break  # Only check the first matching main branch
        except (
            subprocess.CalledProcessError,
            subprocess.TimeoutExpired,
            FileNotFoundError,
            ValueError,
        ):
            pass  # No remote or fetch failed — treat as not diverged

    # All dirty files (not just spec) for checkpoint file picker
    dirty_other = sorted(f for f in all_dirty if not f.startswith(prefix))

    return {
        "branch": branch,
        "is_main": is_main,
        "dirty_spec_files": dirty_spec,
        "dirty_other_files": dirty_other,
        "local_ahead": local_ahead_count,
        "remote_diverged": remote_diverged,
        "fast_forward_possible": fast_forward_possible,
        "main_diverged": main_diverged,
    }


# Implements: REQ-p00004-H
def list_branches(repo_root: Path) -> dict[str, Any]:
    """List local and remote git branches.

    Runs ``git branch --list`` for local branches and ``git branch -r --list``
    for remote branches.  Strips whitespace, ``*`` prefix, and
    ``origin/HEAD -> ...`` entries.  Remote branch names have the ``origin/``
    prefix removed and are deduplicated against local branches.

    Args:
        repo_root: Path to repository root.

    Returns:
        Dict with ``local`` (list[str]), ``remote`` (list[str]),
        and ``current`` (str | None).
    """
    env = _clean_git_env()
    current: str | None = None

    # --- local branches ---
    try:
        local_out = subprocess.run(
            ["git", "branch", "--list", "--sort=-committerdate", "--no-color"],
            cwd=repo_root,
            env=env,
            capture_output=True,
            text=True,
            check=True,
        ).stdout
    except (subprocess.CalledProcessError, FileNotFoundError):
        return {"local": [], "remote": [], "current": None}

    local_names: list[str] = []
    for line in local_out.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith("* "):
            name = stripped[2:].strip()
            current = name
        else:
            name = stripped
        local_names.append(name)

    local_set = set(local_names)

    # --- remote branches ---
    try:
        remote_out = subprocess.run(
            ["git", "branch", "-r", "--list", "--sort=-committerdate", "--no-color"],
            cwd=repo_root,
            env=env,
            capture_output=True,
            text=True,
            check=True,
        ).stdout
    except (subprocess.CalledProcessError, FileNotFoundError):
        return {"local": local_names, "remote": [], "current": current}

    remote_names: list[str] = []
    for line in remote_out.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        # Skip HEAD pointer entries like "origin/HEAD -> origin/main"
        if "->" in stripped:
            continue
        # Only include origin/ branches (checkout hardcodes origin/)
        if not stripped.startswith("origin/"):
            continue
        name = stripped[len("origin/") :]
        # Deduplicate: skip if already in local
        if name not in local_set:
            remote_names.append(name)

    return {"local": local_names, "remote": remote_names, "current": current}


# Implements: REQ-p00004-D
def create_and_switch_branch(
    repo_root: Path,
    branch_name: str,
) -> dict[str, Any]:
    """Create a new git branch and switch to it, preserving dirty working tree.

    If the working tree has uncommitted changes, they are stashed before the
    branch switch and popped on the new branch.  If branch creation fails and
    changes were stashed, the stash is popped to restore the original state.

    Args:
        repo_root: Path to repository root.
        branch_name: Name for the new branch.

    Returns:
        Dict with ``success`` (bool) and either ``branch`` (str) or ``error`` (str).
    """
    # Validate branch name
    if not branch_name or not branch_name.strip():
        return {"success": False, "error": "Branch name must not be empty"}

    # git check-ref-format to validate
    try:
        subprocess.run(
            ["git", "check-ref-format", "--branch", branch_name],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError:
        return {"success": False, "error": f"Invalid branch name: {branch_name}"}
    except FileNotFoundError:
        return {"success": False, "error": "git not found"}

    # Check if dirty
    modified, untracked = get_modified_files(repo_root)
    is_dirty = bool(modified or untracked)

    # Stash if dirty
    if is_dirty:
        try:
            subprocess.run(
                [
                    "git",
                    "stash",
                    "push",
                    "--include-untracked",
                    "-m",
                    f"elspais: switching to {branch_name}",
                ],
                cwd=repo_root,
                env=_clean_git_env(),
                capture_output=True,
                text=True,
                check=True,
            )
        except subprocess.CalledProcessError as e:
            return {"success": False, "error": f"Failed to stash changes: {e.stderr}"}
        except FileNotFoundError:
            return {"success": False, "error": "git not found"}

    # Create and switch to the new branch
    try:
        subprocess.run(
            ["git", "checkout", "-b", branch_name],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        # Restore stash if we stashed
        if is_dirty:
            subprocess.run(
                ["git", "stash", "pop"],
                cwd=repo_root,
                env=_clean_git_env(),
                capture_output=True,
            )
        return {"success": False, "error": f"Failed to create branch: {e.stderr}"}
    except FileNotFoundError:
        if is_dirty:
            subprocess.run(
                ["git", "stash", "pop"],
                cwd=repo_root,
                env=_clean_git_env(),
                capture_output=True,
            )
        return {"success": False, "error": "git not found"}

    # Pop stash on new branch
    if is_dirty:
        try:
            subprocess.run(
                ["git", "stash", "pop"],
                cwd=repo_root,
                env=_clean_git_env(),
                capture_output=True,
                text=True,
                check=True,
            )
        except subprocess.CalledProcessError as e:
            return {
                "success": True,
                "branch": branch_name,
                "warning": f"Branch created but stash pop failed: {e.stderr}",
            }

    return {"success": True, "branch": branch_name}


# Implements: REQ-p00004-E
def commit_and_push_spec_files(
    repo_root: Path,
    message: str,
    spec_dir: str = "spec",
    push: bool = True,
    main_branches: tuple[str, ...] = ("main", "master"),
) -> dict[str, Any]:
    """Stage all modified spec files, commit with message, and optionally push.

    Refuses to operate on main/master (or other protected) branches.

    Args:
        repo_root: Path to repository root.
        message: Commit message.
        spec_dir: Spec directory relative to repo root (default: ``"spec"``).
        push: Whether to push after committing (default: ``True``).
        main_branches: Branch names considered protected.

    Returns:
        Dict with ``success`` (bool), and either ``files_committed`` (list[str])
        or ``error`` (str).  If push fails the commit is still reported as
        successful with a ``push_error`` field.
    """
    # Refuse on protected branches
    branch = get_current_branch(repo_root)
    if branch in main_branches:
        return {
            "success": False,
            "error": f"Refusing to commit on protected branch '{branch}'",
        }

    # Find dirty spec files (modified + untracked)
    modified, untracked = get_modified_files(repo_root)
    all_dirty = modified | untracked
    prefix = f"{spec_dir}/"
    dirty_spec = sorted(f for f in all_dirty if f.startswith(prefix))

    if not dirty_spec:
        return {"success": False, "error": "Nothing to commit — no dirty spec files"}

    # Stage spec files
    try:
        subprocess.run(
            ["git", "add", "--"] + dirty_spec,
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        return {"success": False, "error": f"Failed to stage files: {e.stderr}"}
    except FileNotFoundError:
        return {"success": False, "error": "git not found"}

    # Commit
    try:
        subprocess.run(
            ["git", "commit", "-m", message],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        import re as _re

        clean = _re.sub(r"\x1b\[[0-9;]*m", "", e.stderr or "").strip()
        return {"success": False, "error": f"Failed to commit: {clean}"}
    except FileNotFoundError:
        return {"success": False, "error": "git not found"}

    result: dict[str, Any] = {
        "success": True,
        "files_committed": dirty_spec,
    }

    # Optionally push (non-fatal if push fails)
    if push and branch:
        try:
            subprocess.run(
                ["git", "push", "-u", "origin", branch],
                cwd=repo_root,
                env=_clean_git_env(),
                capture_output=True,
                text=True,
                check=True,
            )
        except (subprocess.CalledProcessError, FileNotFoundError) as e:
            err_msg = e.stderr if hasattr(e, "stderr") else str(e)
            result["push_error"] = f"Commit succeeded but push failed: {err_msg}"

    return result


# Implements: REQ-p00004-F
def sync_branch(
    repo_root: Path,
    main_branches: tuple[str, ...] = ("main", "master"),
) -> dict[str, Any]:
    """Sync the current branch with its remote and with main.

    Performs up to two safe operations:

    1. **Merge remote tracking branch** — if ``origin/<branch>`` is ahead,
       attempt ``git merge --ff-only``.  If that fails (diverged), try
       ``git merge --no-edit`` and abort on conflict.
    2. **Rebase on updated main** — if ``origin/main`` has moved past the
       merge-base, attempt ``git rebase origin/main`` and abort on conflict.

    Both operations abort cleanly if conflicts are detected — the working
    tree is always left in a usable state.

    Args:
        repo_root: Path to repository root.
        main_branches: Branch names to try rebasing onto.

    Returns:
        Dict with ``success`` (bool), ``actions`` (list of descriptions),
        and optional ``error`` (str).
    """
    env = _clean_git_env()
    actions: list[str] = []
    branch = get_current_branch(repo_root)

    if not branch:
        return {"success": False, "error": "Not on a branch (detached HEAD)"}

    # 1. Fetch
    try:
        subprocess.run(
            ["git", "fetch"],
            cwd=repo_root,
            env=env,
            capture_output=True,
            text=True,
            check=True,
            timeout=30,
        )
    except subprocess.TimeoutExpired:
        return {"success": False, "error": "Fetch timed out", "actions": actions}
    except subprocess.CalledProcessError as e:
        return {
            "success": False,
            "error": f"Fetch failed: {(e.stderr or '').strip()}",
            "actions": actions,
        }
    except FileNotFoundError:
        return {"success": False, "error": "git not found", "actions": actions}

    # 2. Merge remote tracking branch if ahead
    remote_ref = f"origin/{branch}"
    try:
        rev = subprocess.run(
            ["git", "rev-list", "--left-right", "--count", f"{branch}...{remote_ref}"],
            cwd=repo_root,
            env=env,
            capture_output=True,
            text=True,
        )
        if rev.returncode == 0:
            parts = rev.stdout.strip().split()
            if len(parts) == 2:
                remote_ahead = int(parts[1])
                if remote_ahead > 0:
                    # Try ff-only first
                    ff = subprocess.run(
                        ["git", "merge", "--ff-only", remote_ref],
                        cwd=repo_root,
                        env=env,
                        capture_output=True,
                        text=True,
                    )
                    if ff.returncode == 0:
                        actions.append(f"Fast-forwarded from {remote_ref}")
                    else:
                        # Try regular merge (auto-resolve)
                        mg = subprocess.run(
                            ["git", "merge", "--no-edit", remote_ref],
                            cwd=repo_root,
                            env=env,
                            capture_output=True,
                            text=True,
                        )
                        if mg.returncode == 0:
                            actions.append(f"Merged {remote_ref}")
                        else:
                            # Conflict — abort
                            subprocess.run(
                                ["git", "merge", "--abort"],
                                cwd=repo_root,
                                env=env,
                                capture_output=True,
                            )
                            return {
                                "success": False,
                                "error": f"Merge conflict with {remote_ref} — aborted",
                                "actions": actions,
                            }
    except (subprocess.CalledProcessError, ValueError):
        pass  # No remote tracking branch — skip merge step

    # 3. Rebase on updated main if diverged
    if branch not in main_branches:
        for main_name in main_branches:
            remote_main = f"origin/{main_name}"
            try:
                mb = subprocess.run(
                    ["git", "merge-base", branch, remote_main],
                    cwd=repo_root,
                    env=env,
                    capture_output=True,
                    text=True,
                )
                if mb.returncode != 0:
                    continue
                merge_base = mb.stdout.strip()
                tip = subprocess.run(
                    ["git", "rev-parse", remote_main],
                    cwd=repo_root,
                    env=env,
                    capture_output=True,
                    text=True,
                )
                if tip.returncode != 0 or tip.stdout.strip() == merge_base:
                    break  # Main hasn't moved — nothing to do

                # Main has moved — try rebase
                rb = subprocess.run(
                    ["git", "rebase", remote_main],
                    cwd=repo_root,
                    env=env,
                    capture_output=True,
                    text=True,
                )
                if rb.returncode == 0:
                    actions.append(f"Rebased on {remote_main}")
                else:
                    # Conflict — abort
                    subprocess.run(
                        ["git", "rebase", "--abort"],
                        cwd=repo_root,
                        env=env,
                        capture_output=True,
                    )
                    return {
                        "success": False,
                        "error": f"Rebase conflict on {remote_main} — aborted",
                        "actions": actions,
                    }
                break
            except (subprocess.CalledProcessError, ValueError):
                continue

    if not actions:
        return {"success": True, "message": "Already up to date", "actions": actions}

    return {"success": True, "message": "; ".join(actions), "actions": actions}


# Implements: REQ-o00063-D
def create_safety_branch(
    repo_root: Path,
    req_id: str,
) -> dict[str, Any]:
    """Create a safety branch with timestamped name before file mutations.

    Safety branches allow reverting file mutations by preserving the pre-mutation
    state of spec files.

    Args:
        repo_root: Path to repository root
        req_id: Requirement ID being modified (used in branch name)

    Returns:
        Dict with 'success', 'branch_name', and optional 'error'
    """
    from datetime import datetime

    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    branch_name = f"safety/{req_id}-{timestamp}"

    try:
        # Create the branch at current HEAD
        subprocess.run(
            ["git", "branch", branch_name],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
        return {"success": True, "branch_name": branch_name}
    except subprocess.CalledProcessError as e:
        return {"success": False, "error": f"Failed to create branch: {e.stderr}"}
    except FileNotFoundError:
        return {"success": False, "error": "git not found"}


# Implements: REQ-o00063-D
def list_safety_branches(repo_root: Path) -> list[str]:
    """List all safety branches in the repository.

    Args:
        repo_root: Path to repository root

    Returns:
        List of branch names starting with 'safety/'
    """
    try:
        result = subprocess.run(
            ["git", "branch", "--list", "safety/*"],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
        branches = []
        for line in result.stdout.strip().split("\n"):
            if line:
                # Remove leading '* ' or '  ' from branch name
                branch = line.strip().lstrip("* ")
                if branch:
                    branches.append(branch)
        return branches
    except (subprocess.CalledProcessError, FileNotFoundError):
        return []


# Implements: REQ-o00063-E
def restore_from_safety_branch(
    repo_root: Path,
    branch_name: str,
    spec_dir: str = "spec",
) -> dict[str, Any]:
    """Restore spec files from a safety branch.

    This checks out the spec directory from the safety branch, effectively
    reverting any file mutations made after the safety branch was created.

    Args:
        repo_root: Path to repository root
        branch_name: Name of the safety branch to restore from
        spec_dir: Spec directory relative to repo root

    Returns:
        Dict with 'success', 'files_restored', and optional 'error'
    """
    # Verify branch exists
    branches = list_safety_branches(repo_root)
    if branch_name not in branches:
        return {"success": False, "error": f"Branch '{branch_name}' not found"}

    try:
        # Checkout spec directory from safety branch
        subprocess.run(
            ["git", "checkout", branch_name, "--", f"{spec_dir}/"],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )

        # Get list of restored files
        status_result = subprocess.run(
            ["git", "diff", "--name-only", "--cached"],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
        files_restored = [
            f for f in status_result.stdout.strip().split("\n") if f.startswith(spec_dir)
        ]

        # Reset staging area (we only want working directory changes)
        subprocess.run(
            ["git", "reset", "HEAD", f"{spec_dir}/"],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            check=True,
        )

        return {"success": True, "files_restored": files_restored}
    except subprocess.CalledProcessError as e:
        return {"success": False, "error": f"Failed to restore: {e.stderr}"}
    except FileNotFoundError:
        return {"success": False, "error": "git not found"}


# Implements: REQ-o00063-D
def delete_safety_branch(
    repo_root: Path,
    branch_name: str,
) -> dict[str, Any]:
    """Delete a safety branch.

    Args:
        repo_root: Path to repository root
        branch_name: Name of the branch to delete

    Returns:
        Dict with 'success' and optional 'error'
    """
    # Only allow deleting safety branches
    if not branch_name.startswith("safety/"):
        return {"success": False, "error": "Can only delete safety/ branches"}

    try:
        subprocess.run(
            ["git", "branch", "-D", branch_name],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
            check=True,
        )
        return {"success": True}
    except subprocess.CalledProcessError as e:
        return {"success": False, "error": f"Failed to delete branch: {e.stderr}"}
    except FileNotFoundError:
        return {"success": False, "error": "git not found"}


# ─────────────────────────────────────────────────────────────────────────────
# Author Identity Resolution
# ─────────────────────────────────────────────────────────────────────────────


def get_author_info(id_source: str = "gh") -> dict[str, str]:
    """Resolve author name and identity for changelog entries.

    Args:
        id_source: Identity source — "gh" (GitHub CLI, falls back to git)
                   or "git" (git config only).

    Returns:
        Dict with "name" and "id" keys.

    Raises:
        ValueError: If required author info is unavailable or empty.
    """
    if id_source == "gh":
        try:
            import json as _json

            result = subprocess.run(
                ["gh", "api", "user"],
                capture_output=True,
                text=True,
                check=True,
            )
            data = _json.loads(result.stdout)
            name = data.get("name") or ""
            email = data.get("email") or ""
            if name and email:
                return {"name": name, "id": email}
        except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
            pass
        # Fall through to git fallback

    # Git config fallback
    name = _git_config("user.name")
    email = _git_config("user.email")

    if not name:
        raise ValueError("Author name not available from git config user.name")
    if not email:
        raise ValueError("Author ID not available from git config user.email")

    return {"name": name, "id": email}


def _git_config(key: str) -> str:
    """Read a git config value."""
    try:
        result = subprocess.run(
            ["git", "config", key],
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        return ""


# ─────────────────────────────────────────────────────────────────────────────
# Checkpoint and History Utilities
# ─────────────────────────────────────────────────────────────────────────────


def list_commits(
    repo_root: Path,
    branch: str = "HEAD",
    limit: int = 20,
) -> list[dict[str, str]]:
    """List commits in reverse chronological order.

    Args:
        repo_root: Path to repository root.
        branch: Branch or ref to list commits for (default: ``"HEAD"``).
        limit: Maximum number of commits to return (default: 20).

    Returns:
        List of dicts with ``hash``, ``message``, ``date``, and ``author`` keys,
        in reverse chronological order.  Returns ``[]`` on error or empty repo.
    """
    # Unit separator (ASCII 31) as field delimiter — safe against special chars
    fmt = "%H%x1f%s%x1f%ci%x1f%an"
    try:
        result = subprocess.run(
            ["git", "log", f"-{limit}", f"--format={fmt}", branch],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
        )
    except FileNotFoundError:
        return []

    if result.returncode != 0:
        return []

    commits: list[dict[str, str]] = []
    for line in result.stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split("\x1f")
        if len(parts) != 4:
            continue
        commits.append(
            {
                "hash": parts[0],
                "message": parts[1],
                "date": parts[2],
                "author": parts[3],
            }
        )
    return commits


def checkout_commit(repo_root: Path, commit_hash: str) -> dict[str, Any]:
    """Check out a specific commit by hash, entering detached HEAD state.

    Args:
        repo_root: Path to repository root.
        commit_hash: Full or abbreviated commit hash.

    Returns:
        ``{"success": True}`` on success, or ``{"success": False, "error": "..."}``
        on failure.
    """
    try:
        result = subprocess.run(
            ["git", "checkout", "--detach", commit_hash],
            cwd=repo_root,
            env=_clean_git_env(),
            capture_output=True,
            text=True,
        )
    except FileNotFoundError:
        return {"success": False, "error": "git not found"}

    if result.returncode != 0:
        err = (result.stderr or result.stdout or "").strip()
        return {"success": False, "error": err}

    return {"success": True}


def commit_spec_files(
    repo_root: Path,
    message: str,
    spec_dir: str = "spec",
    main_branches: tuple[str, ...] = ("main", "master"),
    files: list[str] | None = None,
) -> dict[str, Any]:
    """Stage and commit files locally without pushing.

    When *files* is ``None``, delegates to ``commit_and_push_spec_files``
    with ``push=False`` (commits all dirty spec files).  When *files* is
    an explicit list of repo-relative paths, stages and commits exactly
    those files.

    Args:
        repo_root: Path to repository root.
        message: Commit message.
        spec_dir: Spec directory relative to repo root (used when *files* is None).
        main_branches: Branch names considered protected.
        files: Explicit list of repo-relative file paths to commit.
            When provided, *spec_dir* is ignored.
    """
    if files is None:
        return commit_and_push_spec_files(
            repo_root,
            message,
            spec_dir=spec_dir,
            push=False,
            main_branches=main_branches,
        )

    # Explicit file list provided
    branch = get_current_branch(repo_root)
    if branch in main_branches:
        return {
            "success": False,
            "error": f"Refusing to commit on protected branch '{branch}'",
        }
    if not files:
        return {"success": False, "error": "No files selected"}

    env = _clean_git_env()
    try:
        subprocess.run(
            ["git", "add", "--"] + files,
            cwd=repo_root,
            env=env,
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        return {"success": False, "error": f"Failed to stage files: {e.stderr}"}
    except FileNotFoundError:
        return {"success": False, "error": "git not found"}

    try:
        subprocess.run(
            ["git", "commit", "-m", message],
            cwd=repo_root,
            env=env,
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        return {"success": False, "error": f"Commit failed: {e.stderr}"}

    return {"success": True, "files_committed": files}


def suggest_branch_name(repo_root: Path, base_name: str) -> str:
    """Suggest a branch name that does not collide with existing local branches.

    If ``base_name`` is not already a local branch, returns it as-is.
    Otherwise appends ``-v2``, ``-v3``, etc. until a free name is found.

    Args:
        repo_root: Path to repository root.
        base_name: Desired base branch name.

    Returns:
        A branch name that does not exist locally.
    """
    branches_info = list_branches(repo_root)
    existing = set(branches_info.get("local", [])) | set(branches_info.get("remote", []))

    if base_name not in existing:
        return base_name

    counter = 2
    while True:
        candidate = f"{base_name}-v{counter}"
        if candidate not in existing:
            return candidate
        counter += 1


def generate_checkpoint_message(repo_root: Path, spec_dir: str = "spec") -> str:
    """Generate a commit message summarising dirty spec file changes.

    Uses ``git diff`` to find changed lines, then identifies which
    requirement sections were affected.  For tracked files the diff
    shows exactly which requirement blocks changed.  For untracked
    (new) files, all requirements in the file are listed.

    Args:
        repo_root: Path to repository root.
        spec_dir: Spec directory relative to repo root (default: ``"spec"``).

    Returns:
        Multi-line string (one ``REQ-ID Title`` per line), or empty string
        if no dirty spec files or no requirement headers found.
    """
    import re as _re

    modified, untracked = get_modified_files(repo_root)
    prefix = f"{spec_dir}/"
    dirty_tracked = sorted(f for f in modified if f.startswith(prefix))
    dirty_untracked = sorted(f for f in untracked if f.startswith(prefix))

    if not dirty_tracked and not dirty_untracked:
        return ""

    _header_re = _re.compile(r"^#\s+(REQ-\S+)\s+(.*)")
    env = _clean_git_env()
    results: list[str] = []
    seen: set[str] = set()

    # For tracked files: use git diff to find changed line numbers,
    # then walk the file to find which requirement section each changed
    # line belongs to.
    for rel_path in dirty_tracked:
        try:
            diff_out = subprocess.run(
                ["git", "diff", "--unified=0", "--", rel_path],
                cwd=repo_root,
                env=env,
                capture_output=True,
                text=True,
            )
        except (FileNotFoundError, subprocess.CalledProcessError):
            continue

        # Parse @@ hunks to get changed line numbers in the new file
        changed_lines: set[int] = set()
        for hunk_line in diff_out.stdout.splitlines():
            if hunk_line.startswith("@@"):
                # Format: @@ -old,count +new,count @@
                parts = hunk_line.split("+")
                if len(parts) >= 2:
                    range_str = parts[1].split("@@")[0].strip()
                    if "," in range_str:
                        start, count = range_str.split(",", 1)
                        start, count = int(start), int(count)
                    else:
                        start, count = int(range_str), 1
                    for ln in range(start, start + count):
                        changed_lines.add(ln)

        if not changed_lines:
            continue

        # Walk the file; track current requirement header
        abs_path = repo_root / rel_path
        try:
            file_lines = abs_path.read_text(encoding="utf-8", errors="replace").splitlines()
        except OSError:
            continue

        current_req: tuple[str, str] | None = None
        for i, line in enumerate(file_lines, 1):
            m = _header_re.match(line)
            if m:
                current_req = (m.group(1), m.group(2).strip())
            if i in changed_lines and current_req:
                req_id, title = current_req
                if req_id not in seen:
                    seen.add(req_id)
                    results.append(f"{req_id} {title}")

    # For untracked (new) files: list all requirements
    for rel_path in dirty_untracked:
        abs_path = repo_root / rel_path
        try:
            content = abs_path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        for line in content.splitlines():
            m = _header_re.match(line)
            if m:
                req_id, title = m.group(1), m.group(2).strip()
                if req_id not in seen:
                    seen.add(req_id)
                    results.append(f"{req_id} {title}")

    body = "\n".join(results)
    if not body:
        return ""

    # Attempt to carry forward the ticket prefix from the last commit
    # (e.g. "[CUR-1082]") so the message passes commit-msg hooks.
    try:
        last = subprocess.run(
            ["git", "log", "-1", "--format=%s"],
            cwd=repo_root,
            env=env,
            capture_output=True,
            text=True,
        )
        if last.returncode == 0:
            last_msg = last.stdout.strip()
            bracket_end = last_msg.find("]")
            if last_msg.startswith("[") and bracket_end > 0:
                prefix = last_msg[: bracket_end + 1]
                return f"{prefix} checkpoint: {body}"
    except (FileNotFoundError, subprocess.CalledProcessError):
        pass

    return body


# ─────────────────────────────────────────────────────────────────────────────
# Monorepo eligibility check (REQ-d00201-B)
# ─────────────────────────────────────────────────────────────────────────────

_ancestor_cache: dict[tuple[str, str, str], bool] = {}


def invalidate_ancestor_cache() -> None:
    """Clear the ancestor check cache."""
    _ancestor_cache.clear()


def _find_main_branch(
    repo_root: Path,
    main_branches: list[str],
    env: dict[str, str],
) -> str | None:
    """Return the first main branch name that exists locally in repo_root, or None."""
    for branch in main_branches:
        result = subprocess.run(
            ["git", "rev-parse", "--verify", branch],
            cwd=repo_root,
            env=env,
            capture_output=True,
            text=True,
        )
        if result.returncode == 0:
            return branch
    return None


def _count_commits_since(
    repo_root: Path,
    base_ref: str,
    env: dict[str, str],
) -> int | None:
    """Count commits on HEAD since divergence from base_ref.

    Returns the number of commits reachable from HEAD but not from base_ref,
    or None if the count cannot be determined.
    """
    result = subprocess.run(
        ["git", "rev-list", "--count", f"{base_ref}..HEAD"],
        cwd=repo_root,
        env=env,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        return None
    try:
        return int(result.stdout.strip())
    except ValueError:
        return None


def _is_ancestor(
    repo_root: Path,
    ancestor: str,
    descendant: str,
    env: dict[str, str],
) -> bool:
    """Return True if ancestor is a git ancestor of descendant (or equal).

    Results are cached by (repo_root, ancestor, descendant).
    """
    key = (str(repo_root), ancestor, descendant)
    if key in _ancestor_cache:
        return _ancestor_cache[key]
    result = subprocess.run(
        ["git", "merge-base", "--is-ancestor", ancestor, descendant],
        cwd=repo_root,
        env=env,
        capture_output=True,
    )
    value = result.returncode == 0
    _ancestor_cache[key] = value
    return value


# Implements: REQ-d00201-B
def check_monorepo_eligible(
    repos: list[tuple[str, Path]],
    main_branches: list[str] | None = None,
) -> tuple[bool, list[str]]:
    """Check whether all repos satisfy monorepo-mode sync conditions.

    Conditions:
    1. All repos are on the same branch name.
    2. All repos have the same number of commits since their respective main branch.

    Single-repo lists are trivially eligible.

    Args:
        repos: List of (name, repo_root) pairs.
        main_branches: Ordered list of main branch names to try (default: ["main", "master"]).

    Returns:
        (eligible, reasons) where eligible is True iff all conditions hold,
        and reasons is a list of human-readable failure strings.
    """
    if main_branches is None:
        main_branches = ["main", "master"]

    if len(repos) <= 1:
        return (True, [])

    env = _clean_git_env()
    reasons: list[str] = []

    # Gather branch names for each repo
    branch_names: dict[str, str | None] = {}
    for name, root in repos:
        branch_names[name] = get_current_branch(root)

    # Check all branch names are the same
    unique_branches = set(branch_names.values())
    if len(unique_branches) != 1:
        branch_info = ", ".join(f"{n}={b!r}" for n, b in branch_names.items())
        reasons.append(f"repos are on different branch names: {branch_info}")

    # Check commit counts since main branch (or total if no main branch exists)
    commit_counts: dict[str, int | None] = {}
    for name, root in repos:
        main_branch = _find_main_branch(root, list(main_branches), env)
        if main_branch is None:
            # No main branch found — use total commit count as proxy
            result = subprocess.run(
                ["git", "rev-list", "--count", "HEAD"],
                cwd=root,
                env=env,
                capture_output=True,
                text=True,
            )
            try:
                commit_counts[name] = int(result.stdout.strip()) if result.returncode == 0 else None
            except ValueError:
                commit_counts[name] = None
        else:
            current = branch_names.get(name)
            if current in (None, main_branch):
                # On main itself — 0 commits since main
                commit_counts[name] = 0
            else:
                count = _count_commits_since(root, main_branch, env)
                commit_counts[name] = count

    unique_counts = set(commit_counts.values())
    if len(unique_counts) != 1:
        count_info = ", ".join(f"{n}={c}" for n, c in commit_counts.items())
        reasons.append(f"repos have different commit counts since main: {count_info}")

    return (len(reasons) == 0, reasons)


# Implements: REQ-p00004-I
def check_dirty_repos(repos: list[tuple[str, Path]]) -> list[str]:
    """Check which repos have dirty working trees.

    Args:
        repos: List of (name, repo_root) pairs.

    Returns:
        List of repo names that have uncommitted changes.
    """
    env = _clean_git_env()
    dirty: list[str] = []
    for name, root in repos:
        rv = subprocess.run(
            ["git", "status", "--porcelain"],
            cwd=root,
            env=env,
            capture_output=True,
            text=True,
        )
        if rv.returncode == 0 and rv.stdout.strip():
            dirty.append(name)
    return dirty


# Implements: REQ-p00004-I
def create_sync_commit(
    repo_root: Path,
    spec_dir: str = "spec",
    aligned_with: str = "",
    message: str = "sync checkpoint",
) -> dict[str, Any]:
    """Create a sync commit via .elspais-sync file.

    Used in monorepo mode to keep repos without real changes aligned
    with repos that had real commits.

    Args:
        repo_root: Path to repository root.
        spec_dir: Spec directory relative to repo root.
        aligned_with: Human-readable label for what this sync aligns with.
        message: Commit message.

    Returns:
        dict with 'success' bool and optional 'error' string.
    """
    from datetime import datetime, timezone

    env = _clean_git_env()
    sync_path = repo_root / spec_dir / ".elspais-sync"
    timestamp = datetime.now(timezone.utc).isoformat()
    sync_path.write_text(f"sync: {timestamp} aligned with {aligned_with}\n")
    subprocess.run(["git", "add", str(sync_path)], cwd=repo_root, env=env, capture_output=True)
    rv = subprocess.run(
        ["git", "commit", "-m", message], cwd=repo_root, env=env, capture_output=True, text=True
    )
    if rv.returncode != 0:
        return {"success": False, "error": rv.stderr.strip()}
    return {"success": True}


# Implements: REQ-p00004-I
def remove_sync_file(repo_root: Path, spec_dir: str = "spec") -> None:
    """Remove .elspais-sync file if it exists (before real commits).

    Args:
        repo_root: Path to repository root.
        spec_dir: Spec directory relative to repo root.
    """
    sync_path = repo_root / spec_dir / ".elspais-sync"
    if sync_path.exists():
        sync_path.unlink()


__all__ = [
    "GitChangeInfo",
    "MovedRequirement",
    "get_repo_root",
    "get_modified_files",
    "get_changed_vs_branch",
    "detect_moved_requirements",
    "get_git_changes",
    "filter_spec_files",
    # Graph-based location extraction
    "temporary_worktree",
    "get_req_locations_from_graph",
    # Git status summary (REQ-p00004-C)
    "git_status_summary",
    # Branch creation (REQ-p00004-D)
    "create_and_switch_branch",
    # Commit and push (REQ-p00004-E)
    "commit_and_push_spec_files",
    # Pull fast-forward (REQ-p00004-F)
    "sync_branch",
    # Safety branch utilities (REQ-o00063)
    "get_current_branch",
    "create_safety_branch",
    "list_safety_branches",
    "restore_from_safety_branch",
    "delete_safety_branch",
    # Checkpoint and history utilities
    "list_commits",
    "checkout_commit",
    "commit_spec_files",
    "suggest_branch_name",
    "generate_checkpoint_message",
    # Author identity
    "get_author_info",
    # Monorepo eligibility (REQ-d00201-B)
    "check_monorepo_eligible",
    "invalidate_ancestor_cache",
    # Dirty tree detection (REQ-p00004-I)
    "check_dirty_repos",
    # Sync commit helpers (REQ-p00004-I)
    "create_sync_commit",
    "remove_sync_file",
]
