"""Synchronization functionality for vcspull."""

from __future__ import annotations

import argparse
import asyncio
import contextlib
import logging
import os
import pathlib
import re
import shlex
import signal
import subprocess
import sys
import threading
import typing as t
from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass
from datetime import datetime
from io import StringIO
from time import monotonic, perf_counter

from libvcs._internal.shortcuts import create_project
from libvcs._internal.types import VCSLiteral
from libvcs.sync.git import GitSync
from libvcs.sync.hg import HgSync
from libvcs.sync.svn import SvnSync
from libvcs.url import registry as url_tools

from vcspull import exc
from vcspull._internal.private_path import PrivatePath
from vcspull._internal.worktree_sync import (
    WorktreeAction,
    plan_worktree_sync,
    sync_all_worktrees,
)
from vcspull.config import expand_dir, filter_repos, find_config_files, load_configs
from vcspull.log import default_debug_log_path, setup_file_logger, teardown_file_logger
from vcspull.types import ConfigDict

from ._colors import Colors, get_color_mode
from ._output import (
    OutputFormatter,
    OutputMode,
    PlanAction,
    PlanEntry,
    PlanRenderOptions,
    PlanResult,
    PlanSummary,
    get_output_mode,
)
from ._progress import SyncStatusIndicator, build_indicator
from ._workspaces import filter_by_workspace
from .status import check_repo_status

log = logging.getLogger(__name__)

ProgressCallback = Callable[[str, datetime], None]


PLAN_SYMBOLS: dict[PlanAction, str] = {
    PlanAction.CLONE: "+",
    PlanAction.UPDATE: "~",
    PlanAction.UNCHANGED: "✓",
    PlanAction.BLOCKED: "⚠",
    PlanAction.ERROR: "✗",
}

PLAN_ORDER: dict[PlanAction, int] = {
    PlanAction.ERROR: 0,
    PlanAction.BLOCKED: 1,
    PlanAction.CLONE: 2,
    PlanAction.UPDATE: 3,
    PlanAction.UNCHANGED: 4,
}

PLAN_TIP_MESSAGE = (
    "Tip: run without --dry-run to apply. Use --show-unchanged to include ✓ rows."
)

DEFAULT_PLAN_CONCURRENCY = max(1, min(32, (os.cpu_count() or 4) * 2))
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")


@dataclass
class SyncPlanConfig:
    """Configuration options for building sync plans."""

    fetch: bool
    offline: bool


def _visible_length(text: str) -> int:
    """Return the printable length of string stripped of ANSI codes."""
    return len(ANSI_ESCAPE_RE.sub("", text))


class PlanProgressPrinter:
    """Render incremental plan progress for human-readable dry runs."""

    def __init__(self, total: int, colors: Colors, enabled: bool) -> None:
        self.total = total
        self._colors = colors
        self._enabled = enabled and total > 0
        self._stream = sys.stdout
        self._last_render_len = 0

    def update(self, summary: PlanSummary, processed: int) -> None:
        """Update the progress line with the latest summary counts."""
        if not self._enabled:
            return

        line = " ".join(
            (
                f"Progress: {processed}/{self.total}",
                self._colors.success(f"+:{summary.clone}"),
                self._colors.warning(f"~:{summary.update}"),
                self._colors.muted(f"✓:{summary.unchanged}"),
                self._colors.warning(f"⚠:{summary.blocked}"),
                self._colors.error(f"✗:{summary.errors}"),
            ),
        )
        clean_len = _visible_length(line)
        padding = max(self._last_render_len - clean_len, 0)
        self._stream.write("\r" + line + " " * padding)
        self._stream.flush()
        self._last_render_len = clean_len

    def finish(self) -> None:
        """Ensure the progress line is terminated with a newline."""
        if not self._enabled:
            return
        self._stream.write("\n")
        self._stream.flush()


def _extract_repo_url(repo: ConfigDict) -> str | None:
    """Extract the primary repository URL from a config dictionary."""
    url = repo.get("url")
    if isinstance(url, str):
        return url
    pip_url = repo.get("pip_url")
    if isinstance(pip_url, str):
        return pip_url
    return None


def _get_repo_path(repo: ConfigDict) -> pathlib.Path:
    """Return the resolved filesystem path for a repository entry."""
    raw_path = repo.get("path")
    if raw_path is None:
        return pathlib.Path.cwd()
    return pathlib.Path(str(raw_path)).expanduser()


def clamp(n: int, _min: int, _max: int) -> int:
    """Clamp a number between a min and max value."""
    return max(_min, min(n, _max))


EXIT_ON_ERROR_MSG = "Exiting via error (--exit-on-error passed)"
NO_REPOS_FOR_TERM_MSG = 'No repo found in config(s) for "{name}"'


_FETCH_TIMEOUT_SECONDS = 120

#: Default wall-clock deadline for each repository in ``vcspull sync``.
#:
#: Kept aggressive on purpose -- a healthy fetch/pull against a warm remote is
#: almost always under 10 seconds. Anything over that is "suspect" and should
#: surface as an actionable timeout rather than a silent hang. Callers can
#: override via ``--timeout`` or the ``VCSPULL_SYNC_TIMEOUT_SECONDS`` env var.
_DEFAULT_REPO_TIMEOUT_SECONDS = 10


def _get_no_prompt_env() -> dict[str, str]:
    """Return an environment dict that prevents git from prompting on stdin.

    Built fresh per call rather than cached at module scope: a cache
    snapshots ``os.environ`` at first access, which means
    ``monkeypatch.setenv`` calls in tests after that point never reach
    the subprocess. A dict copy on every fetch is cheap.
    """
    return {**os.environ, "GIT_TERMINAL_PROMPT": "0"}


def _maybe_fetch(
    repo_path: pathlib.Path,
    *,
    config: SyncPlanConfig,
) -> tuple[bool, str | None]:
    """Optionally fetch remote refs to provide accurate status."""
    if config.offline or not config.fetch:
        return True, None
    if not (repo_path / ".git").exists():
        return True, None

    try:
        result = subprocess.run(
            ["git", "fetch", "--prune"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            check=False,
            timeout=_FETCH_TIMEOUT_SECONDS,
            env=_get_no_prompt_env(),
        )
    except subprocess.TimeoutExpired:
        return False, f"git fetch timed out after {_FETCH_TIMEOUT_SECONDS}s"
    except FileNotFoundError:
        return False, "git executable not found"
    except OSError as exc:
        return False, str(exc)

    if result.returncode != 0:
        message = result.stderr.strip() or result.stdout.strip()
        if not message:
            message = f"git fetch failed with exit code {result.returncode}"
        return False, message

    return True, None


def _determine_plan_action(
    status: dict[str, t.Any],
    *,
    config: SyncPlanConfig,
) -> tuple[PlanAction, str | None]:
    """Decide which plan action applies to a repository."""
    if not status.get("exists"):
        return PlanAction.CLONE, "missing"

    if not status.get("is_git"):
        return PlanAction.UPDATE, "non-git VCS (detailed plan not available)"

    clean_state = status.get("clean")
    if clean_state is False:
        return PlanAction.BLOCKED, "working tree has local changes"

    ahead = status.get("ahead")
    behind = status.get("behind")

    if isinstance(ahead, int) and isinstance(behind, int):
        if ahead > 0 and behind > 0:
            return PlanAction.BLOCKED, f"diverged (ahead {ahead}, behind {behind})"
        if behind > 0:
            return PlanAction.UPDATE, f"behind {behind}"
        if ahead > 0:
            return PlanAction.BLOCKED, f"ahead by {ahead}"
        return PlanAction.UNCHANGED, "up to date"

    if config.offline:
        return PlanAction.UPDATE, "remote state unknown (offline)"

    return PlanAction.UPDATE, "remote state unknown; use --fetch"


def _update_summary(summary: PlanSummary, action: PlanAction) -> None:
    """Update summary counters for the given plan action."""
    if action is PlanAction.CLONE:
        summary.clone += 1
    elif action is PlanAction.UPDATE:
        summary.update += 1
    elif action is PlanAction.UNCHANGED:
        summary.unchanged += 1
    elif action is PlanAction.BLOCKED:
        summary.blocked += 1
    elif action is PlanAction.ERROR:
        summary.errors += 1


def _build_plan_entry(
    repo: ConfigDict,
    *,
    config: SyncPlanConfig,
) -> PlanEntry:
    """Construct a plan entry for a repository configuration."""
    repo_path = _get_repo_path(repo)
    workspace_root = str(repo.get("workspace_root", ""))

    fetch_ok = True
    fetch_error: str | None = None
    if repo_path.exists() and (repo_path / ".git").exists():
        fetch_ok, fetch_error = _maybe_fetch(repo_path, config=config)

    status = check_repo_status(repo, detailed=True)

    action: PlanAction
    detail: str | None
    if not fetch_ok:
        action = PlanAction.ERROR
        detail = fetch_error or "failed to refresh remotes"
    else:
        action, detail = _determine_plan_action(status, config=config)

    return PlanEntry(
        name=str(repo.get("name", "unknown")),
        path=str(PrivatePath(repo_path)),
        workspace_root=workspace_root,
        action=action,
        detail=detail,
        url=_extract_repo_url(repo),
        branch=status.get("branch"),
        remote_branch=None,
        current_rev=None,
        target_rev=None,
        ahead=status.get("ahead"),
        behind=status.get("behind"),
        dirty=status.get("clean") is False if status.get("clean") is not None else None,
        error=fetch_error if not fetch_ok else None,
    )


async def _build_plan_result_async(
    repos: list[ConfigDict],
    *,
    config: SyncPlanConfig,
    progress: PlanProgressPrinter | None,
) -> PlanResult:
    """Build a plan asynchronously while updating progress output."""
    if not repos:
        return PlanResult(entries=[], summary=PlanSummary())

    semaphore = asyncio.Semaphore(min(DEFAULT_PLAN_CONCURRENCY, len(repos)))
    entries: list[PlanEntry] = []
    summary = PlanSummary()

    async def evaluate(repo: ConfigDict) -> PlanEntry:
        async with semaphore:
            return await asyncio.to_thread(_build_plan_entry, repo=repo, config=config)

    tasks = [asyncio.create_task(evaluate(repo)) for repo in repos]

    for index, task in enumerate(asyncio.as_completed(tasks), start=1):
        entry = await task
        entries.append(entry)
        _update_summary(summary, entry.action)
        if progress is not None:
            progress.update(summary, index)

    return PlanResult(entries=entries, summary=summary)


def _filter_entries_for_display(
    entries: list[PlanEntry],
    *,
    show_unchanged: bool,
) -> list[PlanEntry]:
    """Filter entries based on whether unchanged repos should be rendered."""
    if show_unchanged:
        return list(entries)
    return [entry for entry in entries if entry.action is not PlanAction.UNCHANGED]


def _format_detail_text(
    entry: PlanEntry,
    *,
    colors: Colors,
    include_extras: bool,
) -> str:
    """Generate the detail text for a plan entry."""
    detail = entry.detail or ""
    extra_bits: list[str] = []

    if include_extras:
        if entry.action is PlanAction.UPDATE and entry.behind:
            extra_bits.append(f"behind {entry.behind}")
        if entry.action is PlanAction.CLONE and entry.url:
            extra_bits.append(entry.url)
        if entry.action is PlanAction.BLOCKED and entry.error:
            extra_bits.append(entry.error)

    if extra_bits:
        detail = f"{detail} {'; '.join(extra_bits)}".strip()

    color_map: dict[PlanAction, t.Callable[[str], str]] = {
        PlanAction.CLONE: colors.success,
        PlanAction.UPDATE: colors.warning,
        PlanAction.UNCHANGED: colors.muted,
        PlanAction.BLOCKED: colors.warning,
        PlanAction.ERROR: colors.error,
    }

    formatter = color_map.get(entry.action, colors.info)
    return formatter(detail) if detail else ""


def _render_plan(
    formatter: OutputFormatter,
    colors: Colors,
    plan: PlanResult,
    render_options: PlanRenderOptions,
    *,
    dry_run: bool,
    total_repos: int,
) -> None:
    """Render the plan in human-readable format."""
    summary = plan.summary
    summary_line = (
        f"Plan: "
        f"{colors.success(str(summary.clone))} to clone (+), "
        f"{colors.warning(str(summary.update))} to update (~), "
        f"{colors.muted(str(summary.unchanged))} unchanged (✓), "
        f"{colors.warning(str(summary.blocked))} blocked (⚠), "
        f"{colors.error(str(summary.errors))} errors (✗)"
    )
    formatter.emit_text(summary_line)

    if total_repos == 0:
        formatter.emit_text(colors.warning("No repositories matched the criteria."))
        return

    if render_options.summary_only:
        if dry_run:
            formatter.emit_text(colors.muted(PLAN_TIP_MESSAGE))
        return

    display_entries = _filter_entries_for_display(
        sorted(
            plan.entries,
            key=lambda entry: (
                PLAN_ORDER.get(entry.action, 99),
                entry.workspace_root or "",
                entry.name.lower(),
            ),
        ),
        show_unchanged=render_options.show_unchanged,
    )

    if not display_entries:
        formatter.emit_text(colors.muted("All repositories are up to date."))
        if dry_run:
            formatter.emit_text(colors.muted(PLAN_TIP_MESSAGE))
        return

    formatter.emit_text("")

    grouped: dict[str, list[PlanEntry]] = {}
    for entry in display_entries:
        key = entry.workspace_root or "(no workspace)"
        grouped.setdefault(key, []).append(entry)

    for idx, (workspace, group_entries) in enumerate(grouped.items()):
        if idx > 0:
            formatter.emit_text("")
        formatter.emit_text(colors.highlight(workspace))
        name_width = max(len(entry.name) for entry in group_entries)

        for entry in group_entries:
            symbol = PLAN_SYMBOLS.get(entry.action, "?")
            color_map: dict[PlanAction, t.Callable[[str], str]] = {
                PlanAction.CLONE: colors.success,
                PlanAction.UPDATE: colors.warning,
                PlanAction.UNCHANGED: colors.muted,
                PlanAction.BLOCKED: colors.warning,
                PlanAction.ERROR: colors.error,
            }
            symbol_text = color_map.get(entry.action, colors.info)(symbol)

            display_path = entry.path
            if render_options.relative_paths and entry.workspace_root:
                workspace_path = pathlib.Path(entry.workspace_root).expanduser()
                try:
                    rel_path = pathlib.Path(entry.path).relative_to(workspace_path)
                    display_path = str(rel_path)
                except ValueError:
                    display_path = entry.path
            else:
                # Contract home directory for privacy/brevity in human output
                display_path = str(PrivatePath(display_path))

            detail_text = _format_detail_text(
                entry,
                colors=colors,
                include_extras=render_options.verbosity > 0 or render_options.long,
            )

            line = (
                f"  {symbol_text} {colors.info(entry.name.ljust(name_width))}  "
                f"{colors.muted(display_path)}"
            )
            if detail_text:
                line = f"{line}  {detail_text}"
            formatter.emit_text(line.rstrip())

            if render_options.long or render_options.verbosity > 1:
                extra_lines: list[str] = []
                if entry.url:
                    extra_lines.append(f"url: {entry.url}")
                if entry.ahead is not None or entry.behind is not None:
                    extra_lines.append(
                        f"ahead/behind: {entry.ahead or 0}/{entry.behind or 0}",
                    )
                if entry.error:
                    extra_lines.append(f"error: {entry.error}")
                for msg in extra_lines:
                    formatter.emit_text(f"    {colors.muted(msg)}")

    if dry_run:
        formatter.emit_text(colors.muted(PLAN_TIP_MESSAGE))


def _emit_plan_output(
    formatter: OutputFormatter,
    colors: Colors,
    plan: PlanResult,
    render_options: PlanRenderOptions,
    *,
    dry_run: bool,
    total_repos: int,
) -> None:
    """Emit plan output for the requested format."""
    if formatter.mode == OutputMode.HUMAN:
        _render_plan(
            formatter=formatter,
            colors=colors,
            plan=plan,
            render_options=render_options,
            dry_run=dry_run,
            total_repos=total_repos,
        )
        return

    display_entries = _filter_entries_for_display(
        plan.entries,
        show_unchanged=render_options.show_unchanged,
    )

    for entry in display_entries:
        formatter.emit(entry)
    formatter.emit(plan.summary)


def create_sync_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
    """Create ``vcspull sync`` argument subparser."""
    config_file = parser.add_argument(
        "-f",
        "--file",
        dest="config",
        metavar="FILE",
        help="path to config file (default: ~/.vcspull.yaml or ./.vcspull.yaml)",
    )
    parser.add_argument(
        "-w",
        "--workspace",
        "--workspace-root",
        dest="workspace_root",
        metavar="DIR",
        help="filter by workspace root directory",
    )
    parser.add_argument(
        "repo_patterns",
        metavar="pattern",
        nargs="*",
        help="patterns / terms of repos, accepts globs / fnmatch(3)",
    )
    parser.add_argument(
        "--dry-run",
        "-n",
        action="store_true",
        help="preview what would be synced without making changes",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        dest="output_json",
        help="output as JSON",
    )
    parser.add_argument(
        "--ndjson",
        action="store_true",
        dest="output_ndjson",
        help="output as NDJSON (one JSON per line)",
    )
    parser.add_argument(
        "--color",
        choices=["auto", "always", "never"],
        default="auto",
        help="when to use colors (default: auto)",
    )
    parser.add_argument(
        "--exit-on-error",
        "-x",
        action="store_true",
        dest="exit_on_error",
        help="exit immediately encountering error (when syncing multiple repos)",
    )
    parser.add_argument(
        "--show-unchanged",
        action="store_true",
        help="include repositories that are already up to date",
    )
    parser.add_argument(
        "--summary-only",
        action="store_true",
        dest="summary_only",
        help="print only the plan summary line",
    )
    parser.add_argument(
        "--long",
        action="store_true",
        dest="long_view",
        help="show extended details for each repository",
    )
    parser.add_argument(
        "--relative-paths",
        action="store_true",
        dest="relative_paths",
        help="display repository paths relative to the workspace root",
    )
    parser.add_argument(
        "--fetch",
        action="store_true",
        help="refresh remote tracking information before planning",
    )
    parser.add_argument(
        "--offline",
        action="store_true",
        help="skip network access while planning (overrides --fetch)",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        dest="verbosity",
        default=0,
        help=(
            "increase verbosity. -v opens libvcs INFO ('Updating to ...'); "
            "-vv opens libvcs DEBUG (full per-repo trace) plus extra "
            "dry-run plan detail. Default keeps libvcs at WARNING+."
        ),
    )
    parser.add_argument(
        "--all",
        "-a",
        action="store_true",
        dest="sync_all",
        help="sync all configured repositories",
    )
    parser.add_argument(
        "--include-worktrees",
        action="store_true",
        dest="include_worktrees",
        help="also sync configured worktrees for each repository",
    )
    parser.add_argument(
        "--timeout",
        dest="timeout",
        type=_positive_int_arg,
        default=None,
        metavar="SECONDS",
        help=(
            "per-repository wall-clock deadline in seconds "
            f"(default: {_DEFAULT_REPO_TIMEOUT_SECONDS}; env: "
            "VCSPULL_SYNC_TIMEOUT_SECONDS). Repos that exceed the deadline "
            "are skipped and the rest of the batch continues."
        ),
    )
    parser.add_argument(
        "--panel-lines",
        dest="panel_lines",
        type=_panel_lines_arg,
        default=None,
        metavar="N",
        help=(
            "live-trail panel height for streaming subprocess output "
            "above the spinner (default: 3; 0 hides; -1 unbounded; env: "
            "VCSPULL_PROGRESS_LINES). The panel collapses when each repo "
            "finishes, leaving only the permanent ✓ Synced line."
        ),
    )
    parser.add_argument(
        "--log-file",
        dest="log_file",
        metavar="PATH",
        default=None,
        help=(
            "write a debug log to PATH (default: $TMPDIR/vcspull/debug-<ts>-"
            "<pid>.log; $TMPDIR/vcspull-test/... under pytest). The path is "
            "only printed to the terminal when a sync fails or times out, "
            "matching npm/pnpm/yarn."
        ),
    )
    parser.add_argument(
        "--no-log-file",
        dest="no_log_file",
        action="store_true",
        help="disable the debug log file entirely",
    )

    try:
        import shtab

        config_file.complete = shtab.FILE  # type: ignore
    except ImportError:
        pass
    return parser


@dataclass
class _TimedOutRepo:
    """Metadata about a repository that exceeded its per-repo timeout."""

    name: str
    path: str
    workspace_root: str
    duration: float


@dataclass
class _SyncOutcome:
    """Result of attempting to sync a single repository."""

    status: t.Literal["synced", "failed", "timed_out"]
    captured_output: str | None = None
    error: BaseException | None = None
    duration: float = 0.0


def _positive_int_arg(value: str) -> int:
    """Validate ``--timeout`` accepts only positive integers.

    A timeout of zero or negative seconds is meaningless: the watchdog
    would either return immediately (timed out) or never (logically
    invalid). The earlier permissive ``_resolve_repo_timeout`` silently
    fell back to the default on these inputs, which masked typos like
    ``--timeout -10`` (intended ``10``) where the user assumed the
    flag had taken effect.

    Examples
    --------
    >>> _positive_int_arg("60")
    60
    >>> _positive_int_arg("0")
    Traceback (most recent call last):
    ...
    argparse.ArgumentTypeError: --timeout must be a positive integer (got 0)
    >>> _positive_int_arg("-5")
    Traceback (most recent call last):
    ...
    argparse.ArgumentTypeError: --timeout must be a positive integer (got -5)
    >>> _positive_int_arg("abc")
    Traceback (most recent call last):
    ...
    argparse.ArgumentTypeError: --timeout must be an integer (got 'abc')
    """
    try:
        parsed = int(value)
    except ValueError:
        msg = f"--timeout must be an integer (got {value!r})"
        raise argparse.ArgumentTypeError(msg) from None
    if parsed <= 0:
        msg = f"--timeout must be a positive integer (got {parsed})"
        raise argparse.ArgumentTypeError(msg)
    return parsed


def _resolve_repo_timeout(cli_timeout: int | None) -> int:
    """Resolve the repo timeout from CLI flag / env var / built-in default.

    Programmatic callers passing non-positive values still fall back to
    the env/default ladder; the CLI surface enforces positive-int
    semantics via :func:`_positive_int_arg` at parse time.

    Examples
    --------
    >>> import os
    >>> _ = os.environ.pop("VCSPULL_SYNC_TIMEOUT_SECONDS", None)
    >>> _resolve_repo_timeout(None)
    10
    >>> _resolve_repo_timeout(60)
    60
    >>> _resolve_repo_timeout(0)
    10
    >>> _resolve_repo_timeout(-5)
    10
    """
    if cli_timeout is not None and cli_timeout > 0:
        return cli_timeout
    env_value = os.environ.get("VCSPULL_SYNC_TIMEOUT_SECONDS")
    if env_value:
        try:
            parsed = int(env_value)
        except ValueError:
            log.warning(
                "Ignoring non-integer VCSPULL_SYNC_TIMEOUT_SECONDS=%s",
                env_value,
            )
        else:
            if parsed > 0:
                return parsed
    return _DEFAULT_REPO_TIMEOUT_SECONDS


#: Mirrors the default in ``vcspull.cli._progress`` -- duplicated here so
#: the ``--help`` text can interpolate the real number without importing
#: the private ``_DEFAULT_OUTPUT_LINES`` symbol.
_DEFAULT_PANEL_LINES = 3


def _panel_lines_arg(value: str) -> int:
    """Validate ``--panel-lines`` accepts ``-1``, ``0``, or any positive int.

    The flag uses ``-1`` as the "unbounded panel" sentinel and ``0`` as
    the "hide panel" sentinel. Any other negative value (``-2``, ``-7``,
    …) is meaningless; ``argparse`` would silently coerce them to
    "unbounded" via :func:`_resolve_panel_lines`'s permissive branch,
    which masks user typos. Reject at parse time with a typer-style
    ``ArgumentTypeError`` instead.

    Examples
    --------
    >>> _panel_lines_arg("0")
    0
    >>> _panel_lines_arg("-1")
    -1
    >>> _panel_lines_arg("5")
    5
    >>> _panel_lines_arg("-2")
    Traceback (most recent call last):
    ...
    argparse.ArgumentTypeError: --panel-lines must be -1, 0, or positive (got -2)
    >>> _panel_lines_arg("abc")
    Traceback (most recent call last):
    ...
    argparse.ArgumentTypeError: --panel-lines must be an integer (got 'abc')
    """
    try:
        parsed = int(value)
    except ValueError:
        msg = f"--panel-lines must be an integer (got {value!r})"
        raise argparse.ArgumentTypeError(msg) from None
    if parsed < -1:
        msg = f"--panel-lines must be -1, 0, or positive (got {parsed})"
        raise argparse.ArgumentTypeError(msg)
    return parsed


def _resolve_panel_lines(cli_value: int | None) -> int:
    """Resolve the live-trail panel height: CLI flag > env > default.

    The flag accepts ``0`` (hide panel) and ``-1`` (unbounded), so we can't
    short-circuit on ``> 0`` like :func:`_resolve_repo_timeout`. We do
    accept any integer the user passes via the CLI; only the env var
    override is validated, with a warning on garbage values.

    Examples
    --------
    >>> import os
    >>> _ = os.environ.pop("VCSPULL_PROGRESS_LINES", None)
    >>> _resolve_panel_lines(None)
    3
    >>> _resolve_panel_lines(0)
    0
    >>> _resolve_panel_lines(-1)
    -1
    >>> _resolve_panel_lines(5)
    5
    """
    if cli_value is not None:
        return cli_value
    env_value = os.environ.get("VCSPULL_PROGRESS_LINES")
    if env_value:
        try:
            return int(env_value)
        except ValueError:
            log.warning(
                "Ignoring non-integer VCSPULL_PROGRESS_LINES=%s",
                env_value,
            )
    return _DEFAULT_PANEL_LINES


class _SyncInterruptedAfterSummary(KeyboardInterrupt):
    """Internal marker: ``_sync_impl`` already emitted the interrupt summary.

    Lets the outer ``sync()`` handler skip the plain-stderr fallback when the
    inner layer has already printed a coloured partial summary through the
    formatter. Subclassing ``KeyboardInterrupt`` keeps the exception in the
    same taxonomy for the outer ``except KeyboardInterrupt`` catch.

    Invariant: the only ``except KeyboardInterrupt`` between the inner
    raise and process exit is :func:`sync`'s own handler, which
    dispatches on ``isinstance(err, _SyncInterruptedAfterSummary)``. A
    future broader catch higher up the stack must either re-raise the
    marker unchanged or perform the same isinstance check -- otherwise
    the inner partial-summary print AND the outer plain-stderr fallback
    both fire, restoring the double-print artefact this marker exists
    to suppress.
    """


def _exit_on_sigint() -> t.NoReturn:
    """Terminate so the parent shell sees ``WIFSIGNALED(SIGINT)``.

    Interactive shells (bash, zsh) abort ``cmd1; cmd2`` sequential lists
    only when the child was killed by a signal -- a clean
    ``SystemExit(130)`` leaves the shell no reason to stop. Match git's
    pattern (``sigchain.h:20-34``; ``builtin/clone.c:416``): cleanup first,
    install ``SIG_DFL``, self-deliver SIGINT so the kernel reports
    ``WIFSIGNALED`` to the parent.

    Callers MUST complete cleanup before invoking this. Python ``atexit``
    hooks and ``finally`` blocks do NOT run once ``SIG_DFL`` terminates the
    process at the C level.

    Windows has no ``WIFSIGNALED`` analogue and its ``raise_signal(SIGINT)``
    under ``SIG_DFL`` raises ``KeyboardInterrupt`` back at the caller
    instead of exiting -- fall back to the conventional ``SystemExit(130)``.

    Precondition: must be called from the main thread of the main
    interpreter. ``signal.signal`` raises ``ValueError`` otherwise. The CLI
    dispatcher at ``vcspull/cli/__init__.py`` always invokes ``sync()`` on
    the main thread, so this is a callsite contract rather than a runtime
    check -- a silent non-main-thread fallback would only kill that thread,
    not the process, and bash would still see a clean exit and continue
    the ``;`` chain, defeating the whole change.
    """
    if sys.platform == "win32":
        raise SystemExit(130)
    # The two-line swap here is deliberate: a SIGINT delivered during the
    # gap either hits SIG_IGN (dropped) or SIG_DFL (terminates) -- both
    # outcomes let bash see WIFSIGNALED(SIGINT), so we don't need the
    # heavier ``pthread_sigmask`` route.
    signal.signal(signal.SIGINT, signal.SIG_IGN)
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    signal.raise_signal(signal.SIGINT)
    # raise_signal does not return under SIG_DFL on POSIX; the SystemExit
    # is defence-in-depth only, satisfying ``NoReturn`` and covering exotic
    # kernels where the default disposition doesn't terminate.
    raise SystemExit(130)  # pragma: no cover


class _IndicatorStreamProxy:
    r"""File-like adapter that routes writes through a ``SyncStatusIndicator``.

    Installed on the ``libvcs`` / ``vcspull`` logger ``StreamHandler`` while
    the spinner is active so logged lines clear the spinner before they
    print, instead of appending to its in-flight ``\r``-redrawn line.
    """

    def __init__(self, indicator: SyncStatusIndicator) -> None:
        self._indicator = indicator

    def write(self, text: str) -> int:
        self._indicator.write(text)
        return len(text)

    def flush(self) -> None:
        # ``SyncStatusIndicator.write`` flushes on every call; nothing extra
        # to do here, but ``logging.StreamHandler.emit`` calls ``flush()``
        # unconditionally so we must accept the no-op.
        return


def _install_indicator_log_diverter(
    indicator: SyncStatusIndicator,
) -> Callable[[], None]:
    """Rebind the ``libvcs`` / ``vcspull`` stream handlers through ``indicator``.

    Returns a callable that restores the original streams. Only touches
    handlers that are :class:`logging.StreamHandler` but not
    :class:`logging.FileHandler` -- the debug log file keeps writing
    straight to disk regardless of the spinner.

    Both the install and the restore swap ``handler.stream`` while
    holding ``handler.lock`` -- the same lock ``Handler.handle`` (stdlib
    ``logging/__init__.py:1011``) takes around every ``emit()``. Direct
    ``handler.stream = X`` without the lock technically violates that
    contract even if CPython's GIL hides the race in practice.

    We don't go through :meth:`logging.StreamHandler.setStream` because
    its first action is to ``flush()`` the old stream. Under pytest, the
    handler may already point at a finalized ``capsys``/``CaptureIO``
    from a prior test (``setup_logger`` set ``handler.stream = sys.stdout``
    when stdout *was* the live capture); flushing a closed file there
    raises ``ValueError``. Skipping the flush is safe -- we are not
    *closing* the stream, only redirecting future writes.

    On top of the stream swap we also raise the *libvcs* StreamHandler
    level above ``CRITICAL`` for the duration of the sync, but only when
    the user kept the default verbosity (handler at WARNING). vcspull's
    own ``✗ Failed syncing rye: Command failed with code 128: git
    symbolic-ref HEAD --short`` line carries the same content as
    libvcs's ``|git| (rye) Failed to determine current branch`` warning
    that fires immediately before; printing both breaks the
    ``✓ Synced X / ✗ Failed X / - Timed out X`` rhythm. The
    debug-log :class:`~logging.FileHandler` keeps DEBUG, so a
    post-mortem still has the libvcs line for context. ``-v`` / ``-vv``
    users opted into INFO / DEBUG and keep their explicit level.
    """
    proxy = _IndicatorStreamProxy(indicator)
    patched: list[tuple[logging.StreamHandler[t.Any], t.Any]] = []
    raised_levels: list[tuple[logging.StreamHandler[t.Any], int]] = []
    for logger_name in ("libvcs", "vcspull"):
        logger = logging.getLogger(logger_name)
        for handler in logger.handlers:
            if not isinstance(handler, logging.StreamHandler):
                continue
            if isinstance(handler, logging.FileHandler):
                continue
            handler.acquire()
            try:
                previous = handler.stream
                handler.stream = proxy
            finally:
                handler.release()
            patched.append((handler, previous))
            if logger_name == "libvcs" and handler.level == logging.WARNING:
                raised_levels.append((handler, handler.level))
                # 51 sits one above ``logging.CRITICAL`` -- standard log
                # records can't reach the handler at this level, so libvcs
                # noise (WARNING + ERROR + CRITICAL) is dropped from the
                # terminal stream. The file handler is untouched and still
                # captures everything at DEBUG.
                handler.setLevel(logging.CRITICAL + 1)

    def _restore() -> None:
        for handler, original in patched:
            handler.acquire()
            try:
                handler.stream = original
            finally:
                handler.release()
        for handler, level in raised_levels:
            handler.setLevel(level)

    return _restore


def _sync_repo_with_watchdog(
    repo: ConfigDict,
    *,
    progress_callback: ProgressCallback,
    timeout: int,
    is_human: bool,
) -> _SyncOutcome:
    """Run :func:`update_repo` under a wall-clock watchdog.

    The libvcs call runs on a daemon :class:`threading.Thread`; the main
    thread uses a completion :class:`threading.Event` as its deadline. Raw
    threads are deliberate -- :class:`concurrent.futures.ThreadPoolExecutor`
    registers its workers in ``concurrent.futures.thread._threads_queues``,
    whose ``atexit`` hook ``_python_exit`` joins every worker on interpreter
    shutdown. If the user hits Ctrl-C while a libvcs subprocess is wedged,
    that join hangs the process forever. Daemon threads skip the join
    entirely: they're forcibly terminated at shutdown.
    """
    buffer: StringIO | None = None if is_human else StringIO()
    done = threading.Event()
    worker_error: list[BaseException] = []

    def _run() -> None:
        try:
            if buffer is None:
                update_repo(repo, progress_callback=progress_callback)
                return
            # Non-human output modes capture everything so the NDJSON/JSON
            # payload contains the per-repo details without polluting stdout.
            with (
                contextlib.redirect_stdout(buffer),
                contextlib.redirect_stderr(buffer),
            ):
                update_repo(repo, progress_callback=progress_callback)
        except BaseException as exc_obj:
            # Keep ``BaseException`` here so a worker-side KeyboardInterrupt
            # (rare but possible via ``PyThreadState_SetAsyncExc``) is still
            # reported back up. The main thread decides how to handle it.
            worker_error.append(exc_obj)
        finally:
            done.set()

    started = monotonic()
    worker = threading.Thread(
        target=_run,
        name=f"vcspull-sync-{repo.get('name', 'repo')}",
        daemon=True,
    )
    worker.start()

    finished = done.wait(timeout=timeout)
    if not finished:
        # The worker is still busy; abandon it. Because it's a daemon it'll
        # die with the interpreter or when libvcs's subprocess finally exits.
        return _SyncOutcome(
            status="timed_out",
            captured_output=buffer.getvalue() if buffer else None,
            duration=monotonic() - started,
        )

    if worker_error:
        err = worker_error[0]
        if isinstance(err, Exception):
            return _SyncOutcome(
                status="failed",
                captured_output=buffer.getvalue() if buffer else None,
                error=err,
                duration=monotonic() - started,
            )
        # ``BaseException`` that is not ``Exception`` (KeyboardInterrupt,
        # SystemExit): propagate -- main thread will tear the batch down.
        raise err

    return _SyncOutcome(
        status="synced",
        captured_output=buffer.getvalue() if buffer else None,
        duration=monotonic() - started,
    )


def _emit_rerun_recipe(
    formatter: OutputFormatter,
    colors: Colors,
    *,
    timed_out_repos: list[_TimedOutRepo],
    timeout: int,
) -> None:
    """Print a copyable recipe for rerunning just the timed-out repositories.

    Modelled on the way ``cargo``/``npm`` surface actionable next steps after
    a failure. The suggested timeout is ``max(120, timeout * 10)`` so a user
    who kept the aggressive default still gets a meaningful headroom when
    they retry.
    """
    if not timed_out_repos:
        return

    suggested_timeout = max(120, timeout * 10)

    # Group by workspace so the user can paste one line per workspace.
    grouped: dict[str, list[_TimedOutRepo]] = {}
    for repo in timed_out_repos:
        grouped.setdefault(repo.workspace_root, []).append(repo)

    formatter.emit_text("")
    formatter.emit_text(
        f"{colors.warning('Timed out:')} "
        f"{colors.warning(str(len(timed_out_repos)))} "
        f"repositor{'y' if len(timed_out_repos) == 1 else 'ies'} "
        f"exceeded {timeout}s.",
    )
    for repo in timed_out_repos:
        display_path = str(PrivatePath(repo.path))
        formatter.emit_text(
            f"  {colors.warning('-')} {colors.info(repo.name):<32} "
            f"{colors.muted('→')} {display_path}  "
            f"({colors.muted(f'{repo.duration:.1f}s elapsed')})",
        )

    formatter.emit_text("")
    formatter.emit_text(
        f"{colors.info('→')} Rerun just the affected repositories with a "
        f"larger timeout:",
    )
    for workspace_root, repos in grouped.items():
        names = " ".join(shlex.quote(r.name) for r in repos)
        workspace_arg = f"--workspace {shlex.quote(str(PrivatePath(workspace_root)))}"
        formatter.emit_text(
            f"    vcspull sync {workspace_arg} --timeout {suggested_timeout} {names}",
        )

    formatter.emit_text(
        f"{colors.info('→')} Or with verbose logging to diagnose the hang:",
    )
    for workspace_root, repos in grouped.items():
        names = " ".join(shlex.quote(r.name) for r in repos)
        workspace_arg = f"--workspace {shlex.quote(str(PrivatePath(workspace_root)))}"
        formatter.emit_text(
            f"    vcspull sync {workspace_arg} "
            f"--timeout {suggested_timeout} -vv {names}",
        )

    formatter.emit_text(
        f"{colors.info('→')} Probe each repository manually "
        f"({colors.muted('these should return in under 10s')}):",
    )
    for repo in timed_out_repos:
        display_path = str(PrivatePath(repo.path))
        formatter.emit_text(
            f"    GIT_TERMINAL_PROMPT=0 timeout 10 "
            f"git -C {shlex.quote(display_path)} fetch --dry-run",
        )


_BRANCH_ERROR_SIGNATURES = (
    "invalid upstream",
    "no upstream",
    "ambiguous argument",
    "did not match any file",
    "couldn't find remote ref",
    "unknown revision",
)


def _looks_like_branch_error(err_msg: str, *, has_rev: bool) -> bool:
    """Return True when a sync failure looks branch/revision-related.

    A failed ``git checkout`` is only treated as a branch problem when the
    repository pins a ``rev`` -- otherwise it may be an unrelated checkout
    failure we should not second-guess.

    Parameters
    ----------
    err_msg : str
        The failure message surfaced by the sync.
    has_rev : bool
        Whether the repository configures an ``options.rev``.

    Returns
    -------
    bool
        True if the failure resembles a missing branch or revision.
    """
    lowered = err_msg.lower()
    if has_rev and "git checkout" in lowered:
        return True
    return any(signature in lowered for signature in _BRANCH_ERROR_SIGNATURES)


def _list_remote_branches(repo_path: pathlib.Path) -> list[str]:
    """Return a repository's remote-tracking branch names (best-effort).

    Parameters
    ----------
    repo_path : pathlib.Path
        Local path to the repository.

    Returns
    -------
    list of str
        Remote-tracking branch names (e.g. ``origin/main``), empty on failure.
    """
    try:
        result = subprocess.run(
            [
                "git",
                "-C",
                str(repo_path),
                "for-each-ref",
                "--format=%(refname:short)",
                "refs/remotes",
            ],
            capture_output=True,
            text=True,
            timeout=5,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return []
    if result.returncode != 0:
        return []
    branches = []
    for line in result.stdout.splitlines():
        # A remote-tracking branch is always remote-qualified (``origin/main``);
        # the bare remote name comes from the ``refs/remotes/<remote>/HEAD``
        # symref and is not a branch.
        name = line.strip()
        if name and "/" in name and not name.endswith("/HEAD"):
            branches.append(name)
    return branches


def _emit_branch_error_guidance(
    formatter: OutputFormatter,
    colors: Colors,
    *,
    repo: ConfigDict,
    err_msg: str,
) -> None:
    """Emit actionable guidance when a sync fails on a branch/revision error.

    Reacts to an actual failure rather than pre-checking, so healthy
    repositories never see a spurious warning.

    Parameters
    ----------
    formatter : OutputFormatter
        Sink for human-facing text.
    colors : Colors
        Palette used to style the guidance.
    repo : ConfigDict
        The repository whose sync failed.
    err_msg : str
        The failure message surfaced by the sync.
    """
    rev = repo.get("rev")
    if not _looks_like_branch_error(err_msg, has_rev=bool(rev)):
        return

    repo_path = pathlib.Path(str(repo.get("path", "")))
    display_path = str(PrivatePath(repo_path))

    formatter.emit_text("")
    if rev:
        formatter.emit_text(
            f"{colors.info('→')} Revision {colors.warning(str(rev))} could not be "
            f"checked out; it may not exist on the remote.",
        )
    else:
        formatter.emit_text(
            f"{colors.info('→')} This branch has no matching remote branch.",
        )
    formatter.emit_text(
        f"    Pin an existing branch with {colors.muted('options.rev')}, or run "
        f"{colors.muted(f'git -C {shlex.quote(display_path)} checkout <branch>')}",
    )

    branches = _list_remote_branches(repo_path)
    if branches:
        formatter.emit_text(
            f"{colors.info('→')} Available remote branches: "
            f"{colors.muted(', '.join(branches[:10]))}",
        )


def sync(
    repo_patterns: list[str],
    config: pathlib.Path | None,
    workspace_root: str | None,
    dry_run: bool,
    output_json: bool,
    output_ndjson: bool,
    color: str,
    exit_on_error: bool,
    show_unchanged: bool,
    summary_only: bool,
    long_view: bool,
    relative_paths: bool,
    fetch: bool,
    offline: bool,
    verbosity: int,
    sync_all: bool = False,
    parser: argparse.ArgumentParser
    | None = None,  # optional so sync can be unit tested
    include_worktrees: bool = False,
    timeout: int | None = None,
    log_file: str | pathlib.Path | None = None,
    no_log_file: bool = False,
    panel_lines: int | None = None,
) -> None:
    """Entry point for ``vcspull sync``."""
    # Prevent git from blocking on credential prompts during batch sync
    os.environ.setdefault("GIT_TERMINAL_PROMPT", "0")

    repo_timeout = _resolve_repo_timeout(timeout)
    resolved_panel_lines = _resolve_panel_lines(panel_lines)

    # Set up the debug log file early so libvcs's per-repo activity is also
    # captured. The path is surfaced to the user only when something failed
    # (or on an explicit path override), matching npm/pnpm/yarn UX.
    log_file_path: pathlib.Path | None = None
    log_file_handler: logging.FileHandler | None = None
    if not no_log_file:
        log_file_path = (
            pathlib.Path(log_file).expanduser()
            if log_file is not None
            else default_debug_log_path()
        )
        try:
            log_file_handler = setup_file_logger(log_file_path)
        except OSError as exc_obj:
            log.warning(
                "Could not open debug log at %s: %s",
                log_file_path,
                exc_obj,
            )
            log_file_path = None
            log_file_handler = None

    try:
        _sync_impl(
            repo_patterns=repo_patterns,
            config=config,
            workspace_root=workspace_root,
            output_json=output_json,
            output_ndjson=output_ndjson,
            color=color,
            exit_on_error=exit_on_error,
            show_unchanged=show_unchanged,
            summary_only=summary_only,
            long_view=long_view,
            relative_paths=relative_paths,
            fetch=fetch,
            offline=offline,
            verbosity=verbosity,
            sync_all=sync_all,
            parser=parser,
            include_worktrees=include_worktrees,
            repo_timeout=repo_timeout,
            log_file_path=log_file_path,
            dry_run=dry_run,
            panel_lines=resolved_panel_lines,
        )
    except KeyboardInterrupt as err:
        # Catch Ctrl-C from ANY phase of the sync -- the repo loop (where
        # ``_sync_impl`` handles it with a partial summary and re-raises
        # ``_SyncInterruptedAfterSummary``) and also long pre-loop work
        # like ``load_configs`` (YAML parse on big vcspull configs) and
        # post-loop emission, where the formatter may not be built yet.
        # The plain-stderr fallback below is the only safe surface in the
        # pre-loop case; skip it when the inner layer already printed a
        # coloured summary through the formatter.
        if not isinstance(err, _SyncInterruptedAfterSummary):
            sys.stderr.write("\nInterrupted by user.\n")
            sys.stderr.flush()
            if log_file_path is not None:
                sys.stderr.write(f"Full debug log: {log_file_path}\n")
                sys.stderr.flush()
        # Python ``atexit`` and ``finally`` do NOT run once
        # ``_exit_on_sigint`` terminates via ``SIG_DFL`` at the C level, so
        # close the debug log inline. Nulling the handle makes the outer
        # ``finally`` a no-op for the POSIX path AND a correct single-close
        # for the Windows fallback path (where ``_exit_on_sigint`` does
        # raise a regular ``SystemExit`` and the ``finally`` does run).
        if log_file_handler is not None:
            teardown_file_logger(log_file_handler)
            log_file_handler = None
        _exit_on_sigint()
    finally:
        if log_file_handler is not None:
            teardown_file_logger(log_file_handler)


def _sync_impl(
    *,
    repo_patterns: list[str],
    config: pathlib.Path | None,
    workspace_root: str | None,
    dry_run: bool,
    output_json: bool,
    output_ndjson: bool,
    color: str,
    exit_on_error: bool,
    show_unchanged: bool,
    summary_only: bool,
    long_view: bool,
    relative_paths: bool,
    fetch: bool,
    offline: bool,
    verbosity: int,
    sync_all: bool,
    parser: argparse.ArgumentParser | None,
    include_worktrees: bool,
    repo_timeout: int,
    log_file_path: pathlib.Path | None,
    panel_lines: int,
) -> None:
    """Run the core body of :func:`sync`.

    Kept separate so log-file teardown runs through ``finally`` regardless of
    where the caller exits the sync.
    """
    # Show help if no patterns and --all not specified
    if not repo_patterns and not sync_all:
        if parser is not None:
            parser.print_help()
        else:
            log.warning(
                "sync() called with no repo patterns and no --all flag; nothing to do",
            )
        return

    output_mode = get_output_mode(output_json, output_ndjson)
    formatter = OutputFormatter(output_mode)
    colors = Colors(get_color_mode(color))

    verbosity_level = clamp(verbosity, 0, 2)
    render_options = PlanRenderOptions(
        show_unchanged=show_unchanged,
        summary_only=summary_only,
        long=long_view,
        verbosity=verbosity_level,
        relative_paths=relative_paths,
    )
    plan_config = SyncPlanConfig(fetch=bool(fetch and not offline), offline=offline)

    if config:
        configs = load_configs([config], warn_legacy_options=True)
    else:
        configs = load_configs(
            find_config_files(include_home=True),
            warn_legacy_options=True,
        )
    found_repos: list[ConfigDict] = []
    unmatched_count = 0

    if sync_all:
        if repo_patterns:
            msg = "--all cannot be combined with positional patterns"
            if parser is not None:
                parser.error(msg)
            else:
                raise SystemExit(msg)
        # Load all repos when --all is specified
        found_repos = list(configs)
    else:
        for repo_pattern in repo_patterns:
            path, vcs_url, name = None, None, None
            if any(repo_pattern.startswith(n) for n in ["./", "/", "~", "$HOME"]):
                path = repo_pattern
            elif any(repo_pattern.startswith(n) for n in ["http", "git", "svn", "hg"]):
                vcs_url = repo_pattern
            else:
                name = repo_pattern

            found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name)
            if not found:
                search_term = name or path or vcs_url or repo_pattern
                log.debug('No repo found in config(s) for "%s"', search_term)
                if not summary_only:
                    formatter.emit_text(
                        f"{colors.error('✗')} "
                        f"{NO_REPOS_FOR_TERM_MSG.format(name=search_term)}",
                    )
                unmatched_count += 1
            found_repos.extend(found)

        # Deduplicate repos matched by multiple patterns
        seen_paths: set[str] = set()
        deduped: list[ConfigDict] = []
        for repo in found_repos:
            key = str(repo.get("path", ""))
            if key not in seen_paths:
                seen_paths.add(key)
                deduped.append(repo)
        found_repos = deduped

    if workspace_root:
        found_repos = filter_by_workspace(found_repos, workspace_root)

    total_repos = len(found_repos)

    if dry_run:
        progress_enabled = formatter.mode == OutputMode.HUMAN and sys.stdout.isatty()
        progress_printer = PlanProgressPrinter(total_repos, colors, progress_enabled)
        start_time = perf_counter()
        plan_result = asyncio.run(
            _build_plan_result_async(
                found_repos,
                config=plan_config,
                progress=progress_printer if progress_enabled else None,
            ),
        )
        plan_result.summary.duration_ms = int((perf_counter() - start_time) * 1000)
        if progress_enabled:
            progress_printer.finish()
        _emit_plan_output(
            formatter=formatter,
            colors=colors,
            plan=plan_result,
            render_options=render_options,
            dry_run=True,
            total_repos=total_repos,
        )

        # Show worktree plans if --include-worktrees is set
        if include_worktrees:
            for repo in found_repos:
                worktrees_config = repo.get("worktrees")
                if not worktrees_config:
                    continue

                repo_name = str(repo.get("name", "unknown"))
                repo_path = _get_repo_path(repo)
                workspace_label = str(repo.get("workspace_root", ""))
                workspace_path = expand_dir(pathlib.Path(workspace_label))

                wt_entries = plan_worktree_sync(
                    repo_path, worktrees_config, workspace_path
                )

                for entry in wt_entries:
                    ref_display = f"{entry.ref_type}:{entry.ref_value}"
                    wt_path_display = str(PrivatePath(entry.worktree_path))

                    action_symbols = {
                        WorktreeAction.CREATE: colors.success("+"),
                        WorktreeAction.UPDATE: colors.warning("~"),
                        WorktreeAction.UNCHANGED: colors.muted("✓"),
                        WorktreeAction.BLOCKED: colors.warning("⚠"),
                        WorktreeAction.ERROR: colors.error("✗"),
                    }
                    sym = action_symbols.get(entry.action, "?")
                    ref = colors.info(ref_display)
                    detail = entry.detail or entry.error or ""

                    formatter.emit_text(
                        f"  {sym} worktree {colors.info(repo_name)} "
                        f"{ref} {colors.muted('→')} {wt_path_display}"
                        f"  {colors.muted(detail)}".rstrip(),
                    )

        formatter.finalize()
        return

    if total_repos == 0:
        if unmatched_count > 0:
            summary = {
                "total": 0,
                "synced": 0,
                "previewed": 0,
                "failed": 0,
                "unmatched": unmatched_count,
            }
            _emit_summary(formatter, colors, summary)
            if exit_on_error:
                formatter.finalize()
                if parser is not None:
                    parser.exit(status=1, message=EXIT_ON_ERROR_MSG)
                raise SystemExit(EXIT_ON_ERROR_MSG)
        else:
            formatter.emit_text(
                colors.warning("No repositories matched the criteria."),
            )
        formatter.finalize()
        return

    is_human = formatter.mode == OutputMode.HUMAN

    summary = {
        "total": 0,
        "synced": 0,
        "previewed": 0,
        "failed": 0,
        "timed_out": 0,
        "unmatched": unmatched_count,
    }
    timed_out_repos: list[_TimedOutRepo] = []

    indicator = build_indicator(
        human=is_human,
        color=color,
        colors=colors,
        output_lines=panel_lines,
    )

    progress_callback: ProgressCallback
    if is_human and indicator.enabled:
        # Route libvcs's streaming subprocess output into the indicator's
        # live-trail panel: ``add_output_line`` appends to a bounded deque
        # that the spinner thread renders above the spinner row, then
        # collapses on ``stop_repo``. With panel_lines=0 the indicator
        # falls back to plain ``write()`` semantics (no panel, no
        # collapse).
        def _indicator_progress(output: str, timestamp: datetime) -> None:
            indicator.add_output_line(output)

        progress_callback = _indicator_progress
    elif is_human:
        progress_callback = progress_cb
    else:

        def silent_progress(output: str, timestamp: datetime) -> None:
            """Suppress progress for machine-readable output."""
            return

        progress_callback = silent_progress

    restore_log_streams: Callable[[], None] | None = None
    if indicator.enabled:
        restore_log_streams = _install_indicator_log_diverter(indicator)

    interrupted = False
    try:
        _run_sync_loop(
            found_repos=found_repos,
            formatter=formatter,
            colors=colors,
            summary=summary,
            timed_out_repos=timed_out_repos,
            progress_callback=progress_callback,
            is_human=is_human,
            repo_timeout=repo_timeout,
            exit_on_error=exit_on_error,
            include_worktrees=include_worktrees,
            dry_run=dry_run,
            parser=parser,
            log_file_path=log_file_path,
            indicator=indicator,
        )
    except KeyboardInterrupt:
        # Ctrl-C during the loop: stop the indicator cleanly, print a
        # partial summary via the formatter, then hand termination
        # semantics to the outer ``sync()`` catch. We re-raise a typed
        # marker so the outer layer doesn't double-print.
        interrupted = True
    finally:
        if restore_log_streams is not None:
            restore_log_streams()
        indicator.close()

    if interrupted:
        # Shield the summary emission against late-breaking ``OSError``
        # (e.g. ``BrokenPipeError`` when stdout is piped to ``head``)
        # so the marker exception *always* propagates. If an OSError
        # escaped here the outer layer would see a non-KeyboardInterrupt
        # and bash would see a plain exit 1, letting the ``;`` chain
        # continue -- exactly what this change is designed to prevent.
        try:
            formatter.emit_text("")
            formatter.emit_text(colors.warning("Interrupted by user."))
            _emit_summary(formatter, colors, summary)
            if log_file_path is not None:
                formatter.emit_text(
                    f"{colors.info('→')} Full debug log: "
                    f"{colors.muted(str(log_file_path))}",
                )
            formatter.finalize()
        except (OSError, ValueError):
            pass
        # The ``from None`` chain-suppresses the original ``KeyboardInterrupt``
        # so the marker reaches the outer ``except KeyboardInterrupt as err``
        # in :func:`sync` cleanly. See ``_SyncInterruptedAfterSummary`` for
        # the no-double-print invariant this raise relies on.
        raise _SyncInterruptedAfterSummary from None

    _emit_summary(formatter, colors, summary)
    _emit_rerun_recipe(
        formatter,
        colors,
        timed_out_repos=timed_out_repos,
        timeout=repo_timeout,
    )

    # Surface the debug log path once, only when something went wrong, so the
    # user has a single post-mortem trail regardless of the failure mode.
    if summary.get("failed", 0) > 0 and log_file_path is not None:
        formatter.emit_text(
            f"{colors.info('→')} Full debug log: {colors.muted(str(log_file_path))}",
        )

    if exit_on_error and unmatched_count > 0:
        formatter.finalize()
        if parser is not None:
            parser.exit(status=1, message=EXIT_ON_ERROR_MSG)
        raise SystemExit(EXIT_ON_ERROR_MSG)

    formatter.finalize()


def _run_sync_loop(
    *,
    found_repos: list[ConfigDict],
    formatter: OutputFormatter,
    colors: Colors,
    summary: dict[str, int],
    timed_out_repos: list[_TimedOutRepo],
    progress_callback: ProgressCallback,
    is_human: bool,
    repo_timeout: int,
    exit_on_error: bool,
    include_worktrees: bool,
    dry_run: bool,
    parser: argparse.ArgumentParser | None,
    log_file_path: pathlib.Path | None,
    indicator: SyncStatusIndicator,
) -> None:
    """Iterate the repositories and drive the watchdog + indicator."""
    for repo in found_repos:
        repo_name = repo.get("name", "unknown")
        repo_path = repo.get("path", "unknown")
        workspace_label = repo.get("workspace_root", "")
        display_repo_path = str(PrivatePath(repo_path))

        summary["total"] += 1
        indicator.heartbeat()

        event: dict[str, t.Any] = {
            "reason": "sync",
            "name": repo_name,
            "path": display_repo_path,
            "workspace_root": str(workspace_label),
        }

        # Manual ``start_repo`` / ``stop_repo`` instead of the
        # ``with indicator.repo(...)`` context manager: we want
        # ``stop_repo`` to receive the permanent line so the spinner
        # collapse + completion print happen as ONE atomic ANSI write
        # under the lock. The ``with`` form fires ``stop_repo()`` (no
        # args) on exit, before we know the outcome -- which means the
        # spinner clears, then the formatter writes the permanent line
        # in a separate stream call. That two-step is the source of the
        # flicker reporters have called out.
        indicator.start_repo(repo_name)
        try:
            outcome = _sync_repo_with_watchdog(
                repo,
                progress_callback=progress_callback,
                timeout=repo_timeout,
                is_human=is_human,
            )
        except BaseException:
            # Any exception (KeyboardInterrupt, runtime crash) tears the
            # indicator down with no replacement line; the surrounding
            # ``except KeyboardInterrupt`` in ``_sync_impl`` still owns
            # the partial-summary print.
            indicator.stop_repo()
            raise

        if outcome.status == "timed_out":
            summary["timed_out"] += 1
            summary["failed"] += 1
            timed_out_repos.append(
                _TimedOutRepo(
                    name=repo_name,
                    path=str(repo_path),
                    workspace_root=str(workspace_label),
                    duration=outcome.duration,
                ),
            )
            event["status"] = "timed_out"
            event["duration_ms"] = int(outcome.duration * 1000)
            if outcome.captured_output:
                event["details"] = outcome.captured_output.strip()
            permanent = (
                f"{colors.warning('-')} Timed out {colors.info(repo_name)} "
                f"after {colors.warning(f'{outcome.duration:.1f}s')} "
                f"{colors.muted('→')} {display_repo_path}"
            )
            wrote_final = indicator.stop_repo(
                final_line=permanent if is_human else None,
            )
            formatter.emit(event)
            if not wrote_final:
                formatter.emit_text(permanent)
            if exit_on_error:
                _emit_rerun_recipe(
                    formatter,
                    colors,
                    timed_out_repos=timed_out_repos,
                    timeout=repo_timeout,
                )
                _emit_summary(formatter, colors, summary)
                if log_file_path is not None:
                    formatter.emit_text(
                        f"{colors.info('→')} Full debug log: "
                        f"{colors.muted(str(log_file_path))}",
                    )
                formatter.finalize()
                if parser is not None:
                    parser.exit(status=1, message=EXIT_ON_ERROR_MSG)
                raise SystemExit(EXIT_ON_ERROR_MSG)
            continue

        if outcome.status == "failed":
            summary["failed"] += 1
            err = outcome.error
            err_msg = str(err) if err is not None else "unknown error"
            event["status"] = "error"
            event["error"] = err_msg
            if outcome.captured_output:
                event["details"] = outcome.captured_output.strip()
            permanent = (
                f"{colors.error('✗')} Failed syncing {colors.info(repo_name)}: "
                f"{colors.error(err_msg)}"
            )
            wrote_final = indicator.stop_repo(
                final_line=permanent if is_human else None,
            )
            formatter.emit(event)
            if is_human:
                log.debug("Failed syncing %s", repo_name)
            if log.isEnabledFor(logging.DEBUG) and err is not None:
                import traceback

                traceback.print_exception(type(err), err, err.__traceback__)
            if not wrote_final:
                formatter.emit_text(permanent)
            if is_human:
                _emit_branch_error_guidance(
                    formatter,
                    colors,
                    repo=repo,
                    err_msg=err_msg,
                )
            if exit_on_error:
                _emit_summary(formatter, colors, summary)
                formatter.finalize()
                if parser is not None:
                    parser.exit(status=1, message=EXIT_ON_ERROR_MSG)
                raise SystemExit(EXIT_ON_ERROR_MSG) from err
            continue

        summary["synced"] += 1
        event["status"] = "synced"
        permanent = (
            f"{colors.success('✓')} Synced {colors.info(repo_name)} "
            f"{colors.muted('→')} {display_repo_path}"
        )
        wrote_final = indicator.stop_repo(
            final_line=permanent if is_human else None,
        )
        formatter.emit(event)
        if not wrote_final:
            formatter.emit_text(permanent)

        # Sync worktrees if enabled and configured
        worktrees_config = repo.get("worktrees")
        if include_worktrees and worktrees_config:
            workspace_path = expand_dir(pathlib.Path(str(workspace_label)))
            repo_path_obj = pathlib.Path(str(repo_path))

            wt_result = sync_all_worktrees(
                repo_path_obj,
                worktrees_config,
                workspace_path,
                dry_run=dry_run,
            )

            for entry in wt_result.entries:
                ref_display = f"{entry.ref_type}:{entry.ref_value}"
                wt_path_display = str(PrivatePath(entry.worktree_path))

                if entry.action == WorktreeAction.CREATE:
                    sym = colors.success("+")
                    ref = colors.info(ref_display)
                    arrow = colors.muted("→")
                    formatter.emit_text(
                        f"    {sym} worktree {ref} {arrow} {wt_path_display}",
                    )
                elif entry.action == WorktreeAction.UPDATE:
                    sym = colors.warning("~")
                    ref = colors.info(ref_display)
                    arrow = colors.muted("→")
                    formatter.emit_text(
                        f"    {sym} worktree {ref} {arrow} {wt_path_display}",
                    )
                elif entry.action == WorktreeAction.BLOCKED:
                    sym = colors.warning("⚠")
                    ref = colors.info(ref_display)
                    formatter.emit_text(
                        f"    {sym} worktree {ref} blocked: {entry.detail}",
                    )
                elif entry.action == WorktreeAction.ERROR:
                    formatter.emit_text(
                        f"    {colors.error('✗')} worktree {colors.info(ref_display)} "
                        f"error: {entry.error}",
                    )

            # Tally worktree results into summary
            summary["worktree_created"] = (
                summary.get("worktree_created", 0) + wt_result.created
            )
            summary["worktree_updated"] = (
                summary.get("worktree_updated", 0) + wt_result.updated
            )
            summary["worktree_failed"] = (
                summary.get("worktree_failed", 0) + wt_result.errors
            )
            # Count worktree errors as failures for exit code
            summary["failed"] += wt_result.errors

            if exit_on_error and wt_result.errors > 0:
                _emit_summary(formatter, colors, summary)
                formatter.finalize()
                if parser is not None:
                    parser.exit(status=1, message=EXIT_ON_ERROR_MSG)
                raise SystemExit(EXIT_ON_ERROR_MSG)


def _emit_summary(
    formatter: OutputFormatter,
    colors: Colors,
    summary: dict[str, int],
) -> None:
    """Emit the structured summary event and optional human-readable text."""
    formatter.emit({"reason": "summary", **summary})
    if formatter.mode == OutputMode.HUMAN:
        previewed = summary.get("previewed", 0)
        unmatched = summary.get("unmatched", 0)
        timed_out = summary.get("timed_out", 0)
        parts = [
            f"\n{colors.info('Summary:')} "
            f"{colors.info(str(summary['total']))} repos, "
            f"{colors.success(str(summary['synced']))} synced, "
            f"{colors.error(str(summary['failed']))} failed",
        ]
        if timed_out > 0:
            parts.append(
                f", {colors.warning(str(timed_out))} timed out",
            )
        if previewed > 0:
            parts.append(
                f", {colors.warning(str(previewed))} previewed",
            )
        if unmatched > 0:
            parts.append(
                f", {colors.warning(str(unmatched))} unmatched",
            )
        wt_created = summary.get("worktree_created", 0)
        wt_updated = summary.get("worktree_updated", 0)
        wt_failed = summary.get("worktree_failed", 0)
        if wt_created or wt_updated or wt_failed:
            wt_parts = []
            if wt_created:
                wt_parts.append(f"{colors.success(str(wt_created))} created")
            if wt_updated:
                wt_parts.append(f"{colors.warning(str(wt_updated))} updated")
            if wt_failed:
                wt_parts.append(f"{colors.error(str(wt_failed))} failed")
            parts.append(
                f"; worktrees: {', '.join(wt_parts)}",
            )
        formatter.emit_text("".join(parts))


def progress_cb(output: str, timestamp: datetime) -> None:
    """CLI Progress callback for command."""
    sys.stdout.write(output)
    sys.stdout.flush()


def guess_vcs(url: str) -> VCSLiteral | None:
    """Guess the VCS from a URL."""
    vcs_matches = url_tools.registry.match(url=url, is_explicit=True)

    if len(vcs_matches) == 0:
        log.warning("No vcs found for %s", url)
        return None
    if len(vcs_matches) > 1:
        log.warning("No exact matches for %s", url)
        return None

    return t.cast("VCSLiteral", vcs_matches[0].vcs)


class CouldNotGuessVCSFromURL(exc.VCSPullException):
    """Raised when no VCS could be guessed from a URL."""

    def __init__(self, repo_url: str, *args: object, **kwargs: object) -> None:
        return super().__init__(f"Could not automatically determine VCS for {repo_url}")


class SyncFailedError(exc.VCSPullException):
    """Raised when a sync operation completes but with errors."""

    def __init__(
        self,
        repo_name: str,
        errors: str,
        *args: object,
        **kwargs: object,
    ) -> None:
        self.repo_name = repo_name
        self.errors = errors
        message = f"Sync failed for {repo_name}"
        if errors:
            message = f"{message}: {errors}"
        super().__init__(message)


def update_repo(
    repo_dict: t.Any,
    progress_callback: ProgressCallback | None = None,
    # repo_dict: Dict[str, Union[str, Dict[str, GitRemote], pathlib.Path]]
) -> GitSync | HgSync | SvnSync:
    """Synchronize a single repository."""
    repo_dict = deepcopy(repo_dict)
    if "pip_url" not in repo_dict:
        repo_dict["pip_url"] = repo_dict.pop("url")
    if "url" not in repo_dict:
        repo_dict["url"] = repo_dict.pop("pip_url")

    repo_dict["progress_callback"] = progress_callback or progress_cb

    # The ConfigDict carries options.shallow/options.depth as flat ``shallow``/
    # ``depth`` keys. libvcs's GitSync names the former ``git_shallow``, so
    # translate it; apply both as attributes after construction and only for
    # git. ``obtain()`` then resolves precedence (an explicit depth wins over
    # ``git_shallow``).
    git_shallow = bool(repo_dict.pop("shallow", False))
    git_depth = repo_dict.pop("depth", None)

    if repo_dict.get("vcs") is None:
        vcs = guess_vcs(url=repo_dict["url"])
        if vcs is None:
            raise CouldNotGuessVCSFromURL(repo_url=repo_dict["url"])

        repo_dict["vcs"] = vcs

    r: GitSync | HgSync | SvnSync = create_project(**repo_dict)
    if isinstance(r, GitSync):
        if git_shallow:
            r.git_shallow = True
        if git_depth is not None:
            r.depth = git_depth
    if repo_dict.get("vcs") == "git":
        result = r.update_repo(set_remotes=True)
    else:
        result = r.update_repo()

    if result is not None and not result.ok:
        error_messages = "; ".join(e.message for e in result.errors)
        repo_name = str(repo_dict.get("name", repo_dict.get("url", "unknown")))
        raise SyncFailedError(repo_name=repo_name, errors=error_messages)

    return r
