# PR #444 — `git_sync` force-trigger tool

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a `git_sync(direction="pull"|"push"|"both", dry_run=False)` MCP tool that runs an immediate, synchronous pull/push cycle and returns structured state — so the LLM doesn't have to wait up to 600s for the periodic loops to fire.

**Architecture:** Extend `GitWriteStrategy` with `force_pull` / `force_push` methods returning `PullResult` / `PushResult` dataclasses. The MCP tool layer composes them into a single dict response per the spec's schema. Tool tagged `{"write", "git-managed"}`; a second `mcp.disable(tags={"git-managed"})` pass in `server.py` hides the tool when git mode isn't `managed`.

**Tech Stack:** Python stdlib + existing `GitWriteStrategy` (in `src/markdown_vault_mcp/git.py`); test fixture creates a bare-repo "remote" + local clone via `git init --bare` + `git clone`.

**Spec:** `docs/superpowers/specs/2026-05-10-out-of-band-file-and-git-ops-design.md` § 4.

---

## File Structure

| File | Responsibility | Action |
|---|---|---|
| `src/markdown_vault_mcp/git.py` | Add `PullResult` / `PushResult` dataclasses + `force_pull` / `force_push` methods on `GitWriteStrategy` | Modify |
| `src/markdown_vault_mcp/_server_tools.py` | Register the new `git_sync` tool with `tags={"write", "git-managed"}` | Modify |
| `src/markdown_vault_mcp/server.py` | After all registrations, second `mcp.disable(tags={"git-managed"})` pass when git strategy isn't managed | Modify |
| `tests/fixtures/git.py` | NEW: bare-repo + local-clone pytest fixture (reusable for any future git-tool tests) | Create |
| `tests/test_git_sync.py` | Tool behaviour tests (clean both, pull conflict, push rejection, dry-run, direction filter, visibility) | Create |
| `tests/test_git_force_methods.py` | `GitWriteStrategy` unit tests for the new methods | Create |
| `README.md` | Add `git_sync` row to MCP Tools table | Modify |
| `docs/tools/index.md` | Full `git_sync` tool entry | Modify |
| `docs/guides/git-integration.md` | Manual-sync flow worked example + comparison to periodic loops | Modify |

---

## Task 1: Add `PullResult` / `PushResult` dataclasses + `force_pull` method

**Files:**
- Modify: `src/markdown_vault_mcp/git.py`
- Create: `tests/fixtures/git.py`
- Create: `tests/test_git_force_methods.py`

- [ ] **Step 1: Create the bare-repo fixture**

Create `tests/fixtures/git.py`:

```python
"""Pytest fixtures for tests that need a real git repository.

Provides:
- `bare_remote`: a bare repository on disk (acts as the upstream).
- `git_clone`: a fresh clone of `bare_remote` with one initial commit
  pushed back so HEAD exists.

Both fixtures are function-scoped (one fresh setup per test) using
tmp_path to avoid cross-test pollution.
"""

from __future__ import annotations

import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple

import pytest

if TYPE_CHECKING:
    pass


class GitRepoPair(NamedTuple):
    """A bare 'remote' repo + a local clone of it."""

    remote_path: Path
    local_path: Path


def _run_git(cwd: Path, *args: str) -> str:
    """Run a git command synchronously, return stdout."""
    result = subprocess.run(
        ["git", *args],
        cwd=cwd,
        check=True,
        capture_output=True,
        text=True,
    )
    return result.stdout


@pytest.fixture
def git_repo_pair(tmp_path: Path) -> GitRepoPair:
    """Set up a bare 'remote' + local clone with one initial commit on main."""
    remote = tmp_path / "remote.git"
    local = tmp_path / "local"
    remote.mkdir()
    _run_git(remote, "init", "--bare", "--initial-branch=main")

    local.mkdir()
    _run_git(local, "init", "--initial-branch=main")
    _run_git(local, "config", "user.email", "test@example.com")
    _run_git(local, "config", "user.name", "Test User")
    _run_git(local, "remote", "add", "origin", str(remote))

    # Initial commit so HEAD exists on both sides
    (local / "README.md").write_text("# Test\n")
    _run_git(local, "add", "README.md")
    _run_git(local, "commit", "-m", "initial")
    _run_git(local, "push", "-u", "origin", "main")

    return GitRepoPair(remote_path=remote, local_path=local)
```

- [ ] **Step 2: Write failing tests for `force_pull`**

Create `tests/test_git_force_methods.py`:

```python
"""Unit tests for GitWriteStrategy.force_pull / force_push (added in #444)."""

from __future__ import annotations

import subprocess
from typing import TYPE_CHECKING

import pytest

from tests.fixtures.git import git_repo_pair  # noqa: F401  (pytest fixture import)

if TYPE_CHECKING:
    from tests.fixtures.git import GitRepoPair


def _run_git(cwd, *args: str) -> str:
    return subprocess.run(
        ["git", *args],
        cwd=cwd, check=True, capture_output=True, text=True,
    ).stdout


class TestForcePull:
    """GitWriteStrategy.force_pull pulls from origin synchronously."""

    def test_clean_fast_forward_returns_applied(
        self, git_repo_pair: GitRepoPair
    ) -> None:
        from markdown_vault_mcp.git import GitWriteStrategy

        # Make a remote commit that the local doesn't have
        clone2 = git_repo_pair.remote_path.parent / "clone2"
        clone2.mkdir()
        _run_git(clone2, "init", "--initial-branch=main")
        _run_git(clone2, "config", "user.email", "other@example.com")
        _run_git(clone2, "config", "user.name", "Other")
        _run_git(clone2, "remote", "add", "origin", str(git_repo_pair.remote_path))
        _run_git(clone2, "pull", "origin", "main")
        (clone2 / "new.md").write_text("from remote\n")
        _run_git(clone2, "add", "new.md")
        _run_git(clone2, "commit", "-m", "remote commit")
        _run_git(clone2, "push", "origin", "main")

        # Now force_pull on the original local should fast-forward
        strategy = GitWriteStrategy(
            managed=True,
            enable_pull=True,
            enable_push=False,
            repo_path=git_repo_pair.local_path,
        )
        result = strategy.force_pull()

        assert result.applied is True
        assert result.fast_forward is True
        assert result.commits_pulled == 1
        assert (git_repo_pair.local_path / "new.md").exists()

    def test_already_up_to_date_returns_zero_commits(
        self, git_repo_pair: GitRepoPair
    ) -> None:
        from markdown_vault_mcp.git import GitWriteStrategy

        strategy = GitWriteStrategy(
            managed=True,
            enable_pull=True,
            enable_push=False,
            repo_path=git_repo_pair.local_path,
        )
        result = strategy.force_pull()

        assert result.applied is True  # ran successfully
        assert result.commits_pulled == 0

    def test_dry_run_does_not_modify_head(
        self, git_repo_pair: GitRepoPair
    ) -> None:
        from markdown_vault_mcp.git import GitWriteStrategy

        # Set up a remote-only commit (same setup as test 1, abbreviated)
        clone2 = git_repo_pair.remote_path.parent / "clone2dry"
        clone2.mkdir()
        _run_git(clone2, "init", "--initial-branch=main")
        _run_git(clone2, "config", "user.email", "o@example.com")
        _run_git(clone2, "config", "user.name", "O")
        _run_git(clone2, "remote", "add", "origin", str(git_repo_pair.remote_path))
        _run_git(clone2, "pull", "origin", "main")
        (clone2 / "drynew.md").write_text("dry\n")
        _run_git(clone2, "add", "drynew.md")
        _run_git(clone2, "commit", "-m", "dry remote commit")
        _run_git(clone2, "push", "origin", "main")

        strategy = GitWriteStrategy(
            managed=True,
            enable_pull=True,
            enable_push=False,
            repo_path=git_repo_pair.local_path,
        )
        head_before = _run_git(git_repo_pair.local_path, "rev-parse", "HEAD").strip()
        result = strategy.force_pull(dry_run=True)
        head_after = _run_git(git_repo_pair.local_path, "rev-parse", "HEAD").strip()

        assert head_before == head_after
        assert result.applied is False  # dry-run reports prediction only
        assert result.commits_pulled == 1  # would pull 1 commit
        assert not (git_repo_pair.local_path / "drynew.md").exists()
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `uv run pytest tests/test_git_force_methods.py::TestForcePull -v`
Expected: FAIL — `GitWriteStrategy` doesn't accept `repo_path` directly (or doesn't have `force_pull`).

If `GitWriteStrategy.__init__` requires more arguments than shown above, adjust the test setup. The point is: construct a strategy that points at `git_repo_pair.local_path` and call `force_pull()`.

- [ ] **Step 4: Add the dataclasses**

In `src/markdown_vault_mcp/git.py`, near the top (after imports, before `class GitWriteStrategy`), add:

```python
@dataclass(frozen=True)
class PullResult:
    """Result of a force_pull operation.

    Attributes:
        applied: True if the pull was actually executed (False for dry_run
            and for failures that didn't change HEAD).
        fast_forward: True if the pull was a clean fast-forward; False if
            it merged or failed.
        commits_pulled: Count of commits brought in.  In dry_run mode,
            this is the count that *would* have been pulled.
        from_sha: HEAD SHA before the pull.
        to_sha: HEAD SHA after the pull (in dry_run, the SHA HEAD would
            have moved to).
        reason: Set when ``applied=False`` for non-dry-run reasons —
            ``"non_fast_forward_with_conflicts"``, ``"no_remote"``, etc.
        conflict_files: Vault-relative paths of files with merge conflicts
            (Syncthing-style ``.conflict-<sha>.md`` siblings).  Empty when
            ``applied=True``.
    """

    applied: bool
    fast_forward: bool
    commits_pulled: int
    from_sha: str
    to_sha: str
    reason: str | None = None
    conflict_files: tuple[str, ...] = ()


@dataclass(frozen=True)
class PushResult:
    """Result of a force_push operation.

    Attributes:
        applied: True if the push succeeded.
        commits_pushed: Count of commits sent to the remote.  0 when there
            was nothing to push.
        remote_sha_before: Remote ref SHA before the push.
        remote_sha_after: Remote ref SHA after the push (== local HEAD on
            success).
        reason: Set when ``applied=False`` — ``"non_fast_forward"``,
            ``"network_error"``, etc.
        hint: Operator-facing suggestion when ``applied=False``.  Surfaced
            verbatim in the MCP tool response.
    """

    applied: bool
    commits_pushed: int
    remote_sha_before: str
    remote_sha_after: str
    reason: str | None = None
    hint: str | None = None
```

(If `from dataclasses import dataclass` isn't already imported, add it.)

- [ ] **Step 5: Add the `force_pull` method**

In `src/markdown_vault_mcp/git.py`, on `GitWriteStrategy` (around the existing `_do_push` / `sync_once` methods), add:

```python
    def force_pull(self, *, dry_run: bool = False) -> PullResult:
        """Pull from origin synchronously.

        Args:
            dry_run: When True, runs ``git fetch`` + computes the would-be
                pull but doesn't modify HEAD.  Returns ``applied=False``
                with ``commits_pulled`` set to the count that *would* have
                been pulled.

        Raises:
            RuntimeError: No remote configured (managed mode but the local
                repo has no ``origin`` — should not happen in well-set-up
                deployments).
        """
        from_sha = self._head_sha()

        # Always fetch first — both dry-run and real-pull need it.
        self._git("fetch", "origin")

        remote_sha = self._git("rev-parse", "origin/HEAD").strip()

        if remote_sha == from_sha:
            # Nothing to do
            return PullResult(
                applied=True,
                fast_forward=True,
                commits_pulled=0,
                from_sha=from_sha,
                to_sha=from_sha,
            )

        # Count commits between local and remote
        commits_ahead = self._git(
            "rev-list", "--count", f"{from_sha}..{remote_sha}"
        ).strip()
        commits_pulled = int(commits_ahead)

        if dry_run:
            return PullResult(
                applied=False,
                fast_forward=True,  # heuristic — actual ff-ness only known after merge attempt
                commits_pulled=commits_pulled,
                from_sha=from_sha,
                to_sha=remote_sha,
            )

        # Attempt fast-forward merge
        try:
            self._git("merge", "--ff-only", "origin/HEAD")
            return PullResult(
                applied=True,
                fast_forward=True,
                commits_pulled=commits_pulled,
                from_sha=from_sha,
                to_sha=remote_sha,
            )
        except subprocess.CalledProcessError:
            # Fast-forward failed.  Use existing Syncthing-style conflict
            # handling (per #232) — inspect repo state for conflict files.
            conflict_files = self._collect_conflict_files()
            return PullResult(
                applied=False,
                fast_forward=False,
                commits_pulled=0,
                from_sha=from_sha,
                to_sha=from_sha,  # unchanged
                reason="non_fast_forward_with_conflicts",
                conflict_files=tuple(conflict_files),
            )
```

The helpers `_head_sha()`, `_git(*args)`, and `_collect_conflict_files()` may or may not already exist. If they don't:

```python
    def _head_sha(self) -> str:
        return self._git("rev-parse", "HEAD").strip()

    def _git(self, *args: str) -> str:
        result = subprocess.run(
            ["git", *args],
            cwd=self._repo_path,
            check=True,
            capture_output=True,
            text=True,
        )
        return result.stdout

    def _collect_conflict_files(self) -> list[str]:
        """Return vault-relative paths of files in conflict state."""
        try:
            output = self._git("diff", "--name-only", "--diff-filter=U")
        except subprocess.CalledProcessError:
            return []
        return [line.strip() for line in output.splitlines() if line.strip()]
```

If MV's existing `GitWriteStrategy` already has different conventions (e.g. uses an `_run_git` helper or a `_repo_path` attribute named differently), conform to those. The test assertions don't depend on internal naming — only on the public `force_pull` returning the right `PullResult`.

- [ ] **Step 6: Run tests**

Run: `uv run pytest tests/test_git_force_methods.py::TestForcePull -v`
Expected: 3 passed.

- [ ] **Step 7: Commit**

```bash
git add src/markdown_vault_mcp/git.py tests/fixtures/git.py tests/test_git_force_methods.py
git commit -m "feat(git): GitWriteStrategy.force_pull + PullResult dataclass

Synchronous pull-from-origin with structured result.  Supports dry_run
(fetch + count, no merge).  Returns conflict file paths when fast-
forward fails (Syncthing-style per #232).

Adds tests/fixtures/git.py with a bare-remote + local-clone fixture
reusable for any future git-tool tests.

Refs #444"
```

---

## Task 2: Add `force_push` method

**Files:**
- Modify: `src/markdown_vault_mcp/git.py`
- Modify: `tests/test_git_force_methods.py`

- [ ] **Step 1: Write failing tests**

Append to `tests/test_git_force_methods.py`:

```python
class TestForcePush:
    """GitWriteStrategy.force_push pushes to origin synchronously."""

    def test_clean_push_returns_applied(self, git_repo_pair: GitRepoPair) -> None:
        from markdown_vault_mcp.git import GitWriteStrategy

        # Make a local commit
        (git_repo_pair.local_path / "x.md").write_text("local\n")
        _run_git(git_repo_pair.local_path, "add", "x.md")
        _run_git(git_repo_pair.local_path, "commit", "-m", "local commit")

        strategy = GitWriteStrategy(
            managed=True,
            enable_pull=False,
            enable_push=True,
            repo_path=git_repo_pair.local_path,
        )
        result = strategy.force_push()

        assert result.applied is True
        assert result.commits_pushed == 1

    def test_nothing_to_push_returns_zero(self, git_repo_pair: GitRepoPair) -> None:
        from markdown_vault_mcp.git import GitWriteStrategy

        strategy = GitWriteStrategy(
            managed=True,
            enable_pull=False,
            enable_push=True,
            repo_path=git_repo_pair.local_path,
        )
        result = strategy.force_push()

        assert result.applied is True
        assert result.commits_pushed == 0

    def test_non_fast_forward_returns_hint(self, git_repo_pair: GitRepoPair) -> None:
        from markdown_vault_mcp.git import GitWriteStrategy

        # Push a remote commit from a second clone so origin moves ahead
        clone2 = git_repo_pair.remote_path.parent / "clone2push"
        clone2.mkdir()
        _run_git(clone2, "init", "--initial-branch=main")
        _run_git(clone2, "config", "user.email", "o@example.com")
        _run_git(clone2, "config", "user.name", "O")
        _run_git(clone2, "remote", "add", "origin", str(git_repo_pair.remote_path))
        _run_git(clone2, "pull", "origin", "main")
        (clone2 / "ahead.md").write_text("ahead\n")
        _run_git(clone2, "add", "ahead.md")
        _run_git(clone2, "commit", "-m", "ahead commit")
        _run_git(clone2, "push", "origin", "main")

        # Now make a divergent local commit
        (git_repo_pair.local_path / "div.md").write_text("div\n")
        _run_git(git_repo_pair.local_path, "add", "div.md")
        _run_git(git_repo_pair.local_path, "commit", "-m", "divergent commit")

        strategy = GitWriteStrategy(
            managed=True,
            enable_pull=False,
            enable_push=True,
            repo_path=git_repo_pair.local_path,
        )
        result = strategy.force_push()

        assert result.applied is False
        assert result.reason == "non_fast_forward"
        assert result.hint and "git_sync" in result.hint
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_git_force_methods.py::TestForcePush -v`
Expected: FAIL — `force_push` doesn't exist.

- [ ] **Step 3: Add the method**

Append to `GitWriteStrategy` in `src/markdown_vault_mcp/git.py`:

```python
    def force_push(self, *, dry_run: bool = False) -> PushResult:
        """Push to origin synchronously.

        Args:
            dry_run: When True, no-op — there's no safe way to probe push
                acceptance without trying.  Returns ``applied=False`` with
                ``reason="dry_run_unsupported"``.

        Note:
            Never attempts ``--force``.  On non-fast-forward rejection,
            returns ``applied=False`` with a hint pointing the LLM at
            ``git_sync(direction="pull")`` to integrate first.
        """
        local_sha = self._head_sha()
        # Fetch first so remote_sha_before reflects the latest known remote
        self._git("fetch", "origin")
        try:
            remote_sha_before = self._git("rev-parse", "origin/HEAD").strip()
        except subprocess.CalledProcessError:
            remote_sha_before = ""

        if dry_run:
            return PushResult(
                applied=False,
                commits_pushed=0,
                remote_sha_before=remote_sha_before,
                remote_sha_after=remote_sha_before,
                reason="dry_run_unsupported",
                hint="dry_run cannot probe push acceptance; remove dry_run to attempt.",
            )

        if remote_sha_before == local_sha:
            return PushResult(
                applied=True,
                commits_pushed=0,
                remote_sha_before=remote_sha_before,
                remote_sha_after=remote_sha_before,
            )

        # Count commits we're about to push
        try:
            commits_ahead_str = self._git(
                "rev-list", "--count", f"{remote_sha_before}..{local_sha}"
            ).strip()
            commits_ahead = int(commits_ahead_str)
        except (subprocess.CalledProcessError, ValueError):
            commits_ahead = 0

        try:
            self._git("push", "origin", "HEAD")
        except subprocess.CalledProcessError as exc:
            stderr = exc.stderr or ""
            if "non-fast-forward" in stderr or "rejected" in stderr:
                return PushResult(
                    applied=False,
                    commits_pushed=0,
                    remote_sha_before=remote_sha_before,
                    remote_sha_after=remote_sha_before,
                    reason="non_fast_forward",
                    hint=(
                        "Run git_sync(direction='pull') first to integrate "
                        "remote commits, then retry push."
                    ),
                )
            return PushResult(
                applied=False,
                commits_pushed=0,
                remote_sha_before=remote_sha_before,
                remote_sha_after=remote_sha_before,
                reason="push_failed",
                hint=stderr.strip()[:200],
            )

        return PushResult(
            applied=True,
            commits_pushed=commits_ahead,
            remote_sha_before=remote_sha_before,
            remote_sha_after=local_sha,
        )
```

- [ ] **Step 4: Run tests**

Run: `uv run pytest tests/test_git_force_methods.py::TestForcePush -v`
Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/markdown_vault_mcp/git.py tests/test_git_force_methods.py
git commit -m "feat(git): GitWriteStrategy.force_push + PushResult dataclass

Synchronous push-to-origin with structured result.  Never force-pushes
— on non-fast-forward, returns applied=False with a hint pointing the
LLM at git_sync(direction='pull').  dry_run is a no-op (no safe probe
for push acceptance).

Refs #444"
```

---

## Task 3: Register `git_sync` MCP tool

**Files:**
- Modify: `src/markdown_vault_mcp/_server_tools.py`
- Create: `tests/test_git_sync.py`

- [ ] **Step 1: Write failing test**

Create `tests/test_git_sync.py`:

```python
"""Integration tests for the git_sync MCP tool (added in #444)."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from fastmcp import Client

from tests.fixtures.git import git_repo_pair  # noqa: F401

if TYPE_CHECKING:
    from pathlib import Path

    from tests.fixtures.git import GitRepoPair


class TestGitSyncTool:
    """git_sync tool dispatches to GitWriteStrategy.force_pull / force_push."""

    def _setup_managed_env(
        self,
        monkeypatch: pytest.MonkeyPatch,
        repo_pair: GitRepoPair,
    ) -> None:
        monkeypatch.setenv(
            "MARKDOWN_VAULT_MCP_SOURCE_DIR", str(repo_pair.local_path)
        )
        monkeypatch.setenv(
            "MARKDOWN_VAULT_MCP_GIT_REPO_URL", str(repo_pair.remote_path)
        )
        monkeypatch.setenv("MARKDOWN_VAULT_MCP_READ_ONLY", "false")
        for var in (
            "MARKDOWN_VAULT_MCP_INDEX_PATH",
            "MARKDOWN_VAULT_MCP_EMBEDDINGS_PATH",
            "MARKDOWN_VAULT_MCP_BEARER_TOKEN",
            "MARKDOWN_VAULT_MCP_OIDC_CONFIG_URL",
        ):
            monkeypatch.delenv(var, raising=False)

    @pytest.mark.asyncio
    async def test_clean_both_returns_zero_commits(
        self,
        git_repo_pair: GitRepoPair,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from markdown_vault_mcp.server import make_server

        self._setup_managed_env(monkeypatch, git_repo_pair)
        server = make_server()

        async with Client(server) as client:
            result = await client.call_tool("git_sync", {"direction": "both"})
            data = result.data if hasattr(result, "data") else result
            assert data["direction"] == "both"
            assert data["pull"]["commits_pulled"] == 0
            assert data["push"]["commits_pushed"] == 0

    @pytest.mark.asyncio
    async def test_pull_only_skips_push(
        self,
        git_repo_pair: GitRepoPair,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from markdown_vault_mcp.server import make_server

        self._setup_managed_env(monkeypatch, git_repo_pair)
        server = make_server()

        async with Client(server) as client:
            result = await client.call_tool("git_sync", {"direction": "pull"})
            data = result.data if hasattr(result, "data") else result
            assert data["direction"] == "pull"
            assert data["pull"] is not None
            assert data["push"] is None

    @pytest.mark.asyncio
    async def test_dry_run_pull_does_not_change_head(
        self,
        git_repo_pair: GitRepoPair,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        import subprocess

        from markdown_vault_mcp.server import make_server

        # Make a remote commit so dry-run has something to predict
        clone2 = git_repo_pair.remote_path.parent / "clone2dry-tool"
        clone2.mkdir()
        for cmd in (
            ("init", "--initial-branch=main"),
            ("config", "user.email", "o@example.com"),
            ("config", "user.name", "O"),
            ("remote", "add", "origin", str(git_repo_pair.remote_path)),
            ("pull", "origin", "main"),
        ):
            subprocess.run(["git", *cmd], cwd=clone2, check=True, capture_output=True)
        (clone2 / "drycommit.md").write_text("dry\n")
        for cmd in (
            ("add", "drycommit.md"),
            ("commit", "-m", "dry"),
            ("push", "origin", "main"),
        ):
            subprocess.run(["git", *cmd], cwd=clone2, check=True, capture_output=True)

        head_before = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=git_repo_pair.local_path, check=True, capture_output=True, text=True,
        ).stdout.strip()

        self._setup_managed_env(monkeypatch, git_repo_pair)
        server = make_server()

        async with Client(server) as client:
            result = await client.call_tool(
                "git_sync", {"direction": "pull", "dry_run": True}
            )
            data = result.data if hasattr(result, "data") else result
            assert data["dry_run"] is True
            assert data["pull"]["would_apply"] is True

        head_after = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=git_repo_pair.local_path, check=True, capture_output=True, text=True,
        ).stdout.strip()
        assert head_before == head_after
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/test_git_sync.py -v`
Expected: FAIL — `git_sync` tool not registered.

- [ ] **Step 3: Register the tool**

In `src/markdown_vault_mcp/_server_tools.py`, find where the existing tools are registered (e.g. after `delete` or `rename`). Add:

```python
    @mcp.tool(
        icons=_TOOL_ICONS.get("git_sync"),  # icon may not exist yet — fall back to None
        annotations={
            "readOnlyHint": False,
            "destructiveHint": False,
            "idempotentHint": True,
        },
        tags={"write", "git-managed"},
    )
    async def git_sync(
        direction: Literal["pull", "push", "both"] = "both",
        dry_run: bool = False,
        collection: Collection = Depends(get_collection),
    ) -> dict[str, Any]:
        """Force an immediate git pull / push / both, bypassing the periodic loops.

        Today the only sync triggers are time-based
        (MARKDOWN_VAULT_MCP_GIT_PULL_INTERVAL_S, default 600s; and
        MARKDOWN_VAULT_MCP_GIT_PUSH_DELAY_S, default 30s after last write).
        Use this tool to run the same operations synchronously and confirm
        a multi-write workflow has been pushed before telling the user to
        check another device.

        **Context cost:** small — returns a structured dict with SHAs,
        conflict file paths if any, and a per-direction status.  No file
        bytes ever returned.

        Args:
            direction: Which leg(s) to run.  ``"both"`` runs pull then push;
                pull failure short-circuits push.  ``"pull"`` and ``"push"``
                each run only that leg.
            dry_run: When True, runs ``git fetch`` + computes the would-be
                pull but doesn't modify HEAD.  ``"push"`` direction with
                dry_run is a no-op (no safe probe for push acceptance).

        Returns:
            Dict with ``direction``, ``head_sha``, ``branch``, ``pull``
            (or null), ``push`` (or null), and ``dry_run`` (when set).
            Failure cases include a ``hint`` field pointing at the right
            next step.

        Raises:
            ValueError: When the vault is not in managed-git mode.  This
                shouldn't happen in practice — the tool is hidden from
                non-managed deployments via tag-based disable — but handle
                gracefully if reached.
        """
        from markdown_vault_mcp.git import GitWriteStrategy

        strategy = collection._git_strategy  # type: ignore[attr-defined]
        if not isinstance(strategy, GitWriteStrategy) or not strategy.managed:
            raise ValueError(
                "git_sync requires managed git mode "
                "(MARKDOWN_VAULT_MCP_GIT_REPO_URL must be set)."
            )

        head_sha_fn = lambda: strategy._head_sha()  # noqa: E731
        branch_fn = lambda: strategy._git("rev-parse", "--abbrev-ref", "HEAD").strip()  # noqa: E731

        result: dict[str, Any] = {
            "direction": direction,
            "head_sha": head_sha_fn(),
            "branch": branch_fn(),
            "pull": None,
            "push": None,
        }
        if dry_run:
            result["dry_run"] = True

        if direction in ("pull", "both"):
            pull_result = await asyncio.to_thread(
                strategy.force_pull, dry_run=dry_run
            )
            if dry_run:
                result["pull"] = {
                    "would_apply": pull_result.commits_pulled > 0,
                    "commits_pulled": pull_result.commits_pulled,
                    "from_sha": pull_result.from_sha,
                    "to_sha": pull_result.to_sha,
                }
            else:
                pull_dict: dict[str, Any] = {
                    "applied": pull_result.applied,
                    "fast_forward": pull_result.fast_forward,
                    "commits_pulled": pull_result.commits_pulled,
                    "from_sha": pull_result.from_sha,
                    "to_sha": pull_result.to_sha,
                }
                if pull_result.reason:
                    pull_dict["reason"] = pull_result.reason
                if pull_result.conflict_files:
                    pull_dict["conflict_files"] = list(pull_result.conflict_files)
                result["pull"] = pull_dict

            # Pull failure short-circuits push (when direction="both")
            if not pull_result.applied and direction == "both":
                # update head_sha (might still be unchanged)
                result["head_sha"] = head_sha_fn()
                return result

        if direction in ("push", "both"):
            push_result = await asyncio.to_thread(
                strategy.force_push, dry_run=dry_run
            )
            push_dict: dict[str, Any] = {
                "applied": push_result.applied,
                "commits_pushed": push_result.commits_pushed,
                "remote_sha_before": push_result.remote_sha_before,
                "remote_sha_after": push_result.remote_sha_after,
            }
            if push_result.reason:
                push_dict["reason"] = push_result.reason
            if push_result.hint:
                push_dict["hint"] = push_result.hint
            result["push"] = push_dict

        result["head_sha"] = head_sha_fn()
        return result
```

(If `Collection` doesn't expose `_git_strategy` as an attribute, find the right attribute name via `git grep -n "git_strategy\|_git_strategy" src/markdown_vault_mcp/collection.py` and adjust.)

- [ ] **Step 4: Run tests**

Run: `uv run pytest tests/test_git_sync.py -v`
Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/markdown_vault_mcp/_server_tools.py tests/test_git_sync.py
git commit -m "feat(tools): git_sync MCP tool

Composes GitWriteStrategy.force_pull + force_push behind a single MCP
tool with direction= param.  Returns structured dict per the spec
schema; failure cases include hint pointing at the next step.  Tagged
{write, git-managed} — visibility wiring lands in next commit.

Refs #444"
```

---

## Task 4: Hide `git_sync` in non-managed git modes

**Files:**
- Modify: `src/markdown_vault_mcp/server.py`
- Modify: `tests/test_git_sync.py`

- [ ] **Step 1: Write failing visibility test**

Append to `tests/test_git_sync.py`:

```python
class TestGitSyncVisibility:
    """git_sync is hidden from list_tools when git mode isn't managed."""

    @pytest.mark.asyncio
    async def test_visible_in_managed_mode(
        self,
        git_repo_pair: GitRepoPair,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from markdown_vault_mcp.server import make_server

        monkeypatch.setenv(
            "MARKDOWN_VAULT_MCP_SOURCE_DIR", str(git_repo_pair.local_path)
        )
        monkeypatch.setenv(
            "MARKDOWN_VAULT_MCP_GIT_REPO_URL", str(git_repo_pair.remote_path)
        )
        monkeypatch.setenv("MARKDOWN_VAULT_MCP_READ_ONLY", "false")
        for var in (
            "MARKDOWN_VAULT_MCP_INDEX_PATH",
            "MARKDOWN_VAULT_MCP_EMBEDDINGS_PATH",
        ):
            monkeypatch.delenv(var, raising=False)

        server = make_server()
        async with Client(server) as client:
            tools = await client.list_tools()
            names = [t.name for t in tools]
            assert "git_sync" in names

    @pytest.mark.asyncio
    async def test_hidden_when_no_git_repo_url(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from markdown_vault_mcp.server import make_server

        monkeypatch.setenv("MARKDOWN_VAULT_MCP_SOURCE_DIR", str(tmp_path))
        monkeypatch.delenv("MARKDOWN_VAULT_MCP_GIT_REPO_URL", raising=False)
        monkeypatch.delenv("MARKDOWN_VAULT_MCP_GIT_TOKEN", raising=False)
        monkeypatch.setenv("MARKDOWN_VAULT_MCP_READ_ONLY", "false")
        for var in (
            "MARKDOWN_VAULT_MCP_INDEX_PATH",
            "MARKDOWN_VAULT_MCP_EMBEDDINGS_PATH",
        ):
            monkeypatch.delenv(var, raising=False)

        server = make_server()
        async with Client(server) as client:
            tools = await client.list_tools()
            names = [t.name for t in tools]
            assert "git_sync" not in names

    @pytest.mark.asyncio
    async def test_hidden_in_read_only_mode(
        self,
        git_repo_pair: GitRepoPair,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from markdown_vault_mcp.server import make_server

        monkeypatch.setenv(
            "MARKDOWN_VAULT_MCP_SOURCE_DIR", str(git_repo_pair.local_path)
        )
        monkeypatch.setenv(
            "MARKDOWN_VAULT_MCP_GIT_REPO_URL", str(git_repo_pair.remote_path)
        )
        monkeypatch.setenv("MARKDOWN_VAULT_MCP_READ_ONLY", "true")
        for var in (
            "MARKDOWN_VAULT_MCP_INDEX_PATH",
            "MARKDOWN_VAULT_MCP_EMBEDDINGS_PATH",
        ):
            monkeypatch.delenv(var, raising=False)

        server = make_server()
        async with Client(server) as client:
            tools = await client.list_tools()
            names = [t.name for t in tools]
            # Tagged both {write, git-managed} — write disable hides it too
            assert "git_sync" not in names
```

- [ ] **Step 2: Run tests to verify the no-managed and read-only ones fail**

Run: `uv run pytest tests/test_git_sync.py::TestGitSyncVisibility -v`
Expected: `test_visible_in_managed_mode` passes (the tool is registered); the other two fail (no disable wired yet).

- [ ] **Step 3: Add the disable pass in `server.py`**

In `src/markdown_vault_mcp/server.py`, find the existing `if is_read_only: mcp.disable(tags={"write"})` line (read the existing read-only handling). Add an analogous block for `git-managed`:

```python
    # Hide git-managed tools (e.g. git_sync) when not in managed git mode.
    git_strategy = config.to_collection_kwargs().get("git_strategy")
    if git_strategy is None or not getattr(git_strategy, "managed", False):
        mcp.disable(tags={"git-managed"})
```

Place this block alongside (or immediately after) the existing `mcp.disable(tags={"write"})` block. The two disables compose — a tool tagged `{"write", "git-managed"}` is hidden if either condition fires.

- [ ] **Step 4: Run tests**

Run: `uv run pytest tests/test_git_sync.py::TestGitSyncVisibility -v`
Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/markdown_vault_mcp/server.py tests/test_git_sync.py
git commit -m "feat(server): hide git_sync when git mode isn't managed

Second mcp.disable(tags={'git-managed'}) pass in make_server, composes
with the existing read-only disable.  A tool tagged {write, git-managed}
is hidden if either condition fires (set-union).

Refs #444"
```

---

## Task 5: Add conflict-file pull test (real conflict, not mocked)

**Files:**
- Modify: `tests/test_git_sync.py`

- [ ] **Step 1: Write the failing test**

Append to `TestGitSyncTool` in `tests/test_git_sync.py`:

```python
    @pytest.mark.asyncio
    async def test_pull_conflict_returns_conflict_files(
        self,
        git_repo_pair: GitRepoPair,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        import subprocess

        from markdown_vault_mcp.server import make_server

        # Modify README.md on both sides so a fast-forward pull fails.
        # Remote side via second clone:
        clone2 = git_repo_pair.remote_path.parent / "clone2conflict"
        clone2.mkdir()
        for cmd in (
            ("init", "--initial-branch=main"),
            ("config", "user.email", "o@example.com"),
            ("config", "user.name", "O"),
            ("remote", "add", "origin", str(git_repo_pair.remote_path)),
            ("pull", "origin", "main"),
        ):
            subprocess.run(["git", *cmd], cwd=clone2, check=True, capture_output=True)
        (clone2 / "README.md").write_text("# remote-edited\n")
        for cmd in (
            ("add", "README.md"),
            ("commit", "-m", "remote edit"),
            ("push", "origin", "main"),
        ):
            subprocess.run(["git", *cmd], cwd=clone2, check=True, capture_output=True)

        # Local side: edit the same line differently
        (git_repo_pair.local_path / "README.md").write_text("# local-edited\n")
        for cmd in (
            ("add", "README.md"),
            ("commit", "-m", "local edit"),
        ):
            subprocess.run(
                ["git", *cmd],
                cwd=git_repo_pair.local_path, check=True, capture_output=True,
            )

        self._setup_managed_env(monkeypatch, git_repo_pair)
        server = make_server()

        async with Client(server) as client:
            result = await client.call_tool("git_sync", {"direction": "pull"})
            data = result.data if hasattr(result, "data") else result
            assert data["pull"]["applied"] is False
            assert data["pull"]["reason"] == "non_fast_forward_with_conflicts"
            # The exact conflict-file behaviour depends on #232's Syncthing-style
            # implementation — verify the field exists and contains README.md
            conflict_files = data["pull"].get("conflict_files", [])
            assert any("README.md" in f for f in conflict_files), conflict_files
```

- [ ] **Step 2: Run the test**

Run: `uv run pytest tests/test_git_sync.py::TestGitSyncTool::test_pull_conflict_returns_conflict_files -v`
Expected: PASS if `force_pull`'s `_collect_conflict_files()` correctly returns the conflicted README.md path. If not, debug by inspecting what `git diff --name-only --diff-filter=U` returns mid-merge.

If the test fails because the merge attempt was already aborted by `--ff-only` (which doesn't enter conflict state — it just refuses the merge), `_collect_conflict_files()` may need to attempt a non-ff merge first to detect the conflicts. Adjust accordingly:

```python
        # Attempt fast-forward; if it fails, do a non-ff merge to surface
        # the actual conflict file list, then abort.
        try:
            self._git("merge", "--ff-only", "origin/HEAD")
            return PullResult(applied=True, fast_forward=True, ...)
        except subprocess.CalledProcessError:
            try:
                self._git("merge", "--no-ff", "--no-commit", "origin/HEAD")
                conflict_files = self._collect_conflict_files()
                self._git("merge", "--abort")
            except subprocess.CalledProcessError:
                conflict_files = self._collect_conflict_files()
                try:
                    self._git("merge", "--abort")
                except subprocess.CalledProcessError:
                    pass
            return PullResult(
                applied=False, fast_forward=False, ...,
                reason="non_fast_forward_with_conflicts",
                conflict_files=tuple(conflict_files),
            )
```

(If MV's existing PR #232 implements Syncthing-style conflict files differently — e.g. writes `.conflict-<sha>.md` siblings rather than reporting unmerged paths — adjust both the `force_pull` logic and the test assertion to match that convention. Find the existing logic via `git grep -n "conflict" src/markdown_vault_mcp/git.py`.)

- [ ] **Step 3: Commit**

```bash
git add src/markdown_vault_mcp/git.py tests/test_git_sync.py
git commit -m "test(git_sync): pull conflict surfaces conflict_files in response

Real two-clone conflict scenario; verifies the tool returns
applied=false + reason=non_fast_forward_with_conflicts + a non-empty
conflict_files list pointing at README.md.

Refs #444"
```

---

## Task 6: Documentation

**Files:**
- Modify: `README.md`
- Modify: `docs/tools/index.md`
- Modify: `docs/guides/git-integration.md`

- [ ] **Step 1: README MCP Tools table row**

```markdown
| `git_sync` | Force an immediate git pull / push / both, bypassing the periodic loops. Returns structured state (SHAs, commit counts, conflict file paths if any). Hidden when `MARKDOWN_VAULT_MCP_GIT_REPO_URL` isn't set or `READ_ONLY=true`. |
```

- [ ] **Step 2: Full tool entry in `docs/tools/index.md`**

```markdown
### `git_sync`

Force an immediate git pull / push / both, bypassing the periodic loops.

**Parameters:**

- `direction` (str, optional, default `"both"`): `"pull"`, `"push"`, or `"both"`. `"both"` runs pull then push; pull failure short-circuits push.
- `dry_run` (bool, optional, default `false`): When true, runs `git fetch` and reports what *would* happen without applying. Push direction with `dry_run` is a no-op.

**Returns:** Structured dict per direction. See examples.

**Examples:**

Successful both-direction sync:

```json
{
  "direction": "both",
  "head_sha": "abc1234",
  "branch": "main",
  "pull": {
    "applied": true,
    "fast_forward": true,
    "commits_pulled": 3,
    "from_sha": "9999999",
    "to_sha": "abc1234"
  },
  "push": {
    "applied": true,
    "commits_pushed": 5,
    "remote_sha_before": "8888888",
    "remote_sha_after": "abc1234"
  }
}
```

Pull conflict:

```json
{
  "direction": "pull",
  "head_sha": "abc1234",
  "branch": "main",
  "pull": {
    "applied": false,
    "reason": "non_fast_forward_with_conflicts",
    "conflict_files": ["Notes/2026-05-09.md"]
  },
  "push": null
}
```

Push rejected:

```json
{
  "direction": "push",
  "push": {
    "applied": false,
    "reason": "non_fast_forward",
    "hint": "Run git_sync(direction='pull') first to integrate remote commits, then retry push."
  }
}
```

**Context cost:** small — structured dict only, no file bytes.

**Tag:** `{write, git-managed}`. Hidden when `MARKDOWN_VAULT_MCP_READ_ONLY=true` or `GIT_REPO_URL` isn't set.

**Errors:** `ValueError` if called when not in managed git mode (shouldn't happen due to visibility tags, but handled gracefully).
```

- [ ] **Step 3: Worked example in `docs/guides/git-integration.md`**

Find the section on the periodic pull/push loops. Add a new section:

```markdown
## Manual sync — `git_sync` tool

The periodic loops are time-based: pull every `MARKDOWN_VAULT_MCP_GIT_PULL_INTERVAL_S` seconds (default 600), push `MARKDOWN_VAULT_MCP_GIT_PUSH_DELAY_S` seconds after the last write (default 30). For workflows where the LLM needs to confirm "your changes are now on the remote" before telling the user to check another device, call `git_sync` directly:

```
git_sync(direction="both")
```

Use `direction="pull"` or `direction="push"` to skip a leg.

`dry_run=True` previews what a pull *would* do — useful for "is there anything new on origin?" without risking a merge conflict mid-conversation.

When pull fails with `reason="non_fast_forward_with_conflicts"`, the response includes `conflict_files` — read those (per [#232's Syncthing-style conflict handling](#)) and propose a resolution.
```

- [ ] **Step 4: Commit**

```bash
git add README.md docs/tools/index.md docs/guides/git-integration.md
git commit -m "docs(git_sync): tool entry + manual-sync flow guide

Refs #444"
```

---

## Task 7: Run all gates and prepare PR

- [ ] **Step 1: Pre-commit hooks**

Run: `uv run pre-commit run --all-files`
Expected: all green.

- [ ] **Step 2: Patch coverage check**

Run:
```bash
uv run pytest --cov=src/markdown_vault_mcp --cov-report=xml --cov-fail-under=0 -q
uv run diff-cover coverage.xml --compare-branch=origin/main --fail-under=80
```
Expected: ≥80% on the patch.

- [ ] **Step 3: Lint + format + mypy**

Run:
```bash
uv run ruff check --fix .
uv run ruff format .
uv run ruff format --check .
uv run mypy src/ tests/
```
Expected: all green.

- [ ] **Step 4: Local review circus**

Dispatch in parallel:

```
Agent: pr-review-toolkit:code-reviewer
  Prompt: Review cumulative diff vs main on branch <branch>.  PR #444 —
  git_sync force-trigger tool.  Focus on: GitWriteStrategy.force_pull /
  force_push correctness (especially the conflict-file detection path —
  does it correctly use --no-ff merge then abort, or follow #232's
  pattern?), the visibility wiring (tag-based disable composing with the
  existing read-only disable), and the integration with the periodic
  pull/push loop (does git_sync race with it via the existing
  _write_lock?).

Agent: pr-review-toolkit:silent-failure-hunter
  Prompt: Same diff.  Focus on: does force_push silently swallow
  CalledProcessError stderr output (the hint field truncates to 200
  chars — is that enough to debug a real failure?)?  Does the dry_run
  path on push (no-op) return enough information for the LLM to
  understand why it didn't run?  Does the tool catch ValueError from
  Collection internals in a way that hides actionable errors?
```

Address findings; re-run until clean.

- [ ] **Step 5: Open the PR (draft)**

```bash
git push -u origin <branch-name>
gh pr create --draft \
  --title "feat(git): git_sync force-trigger tool (#444)" \
  --body "Closes #444

## Summary
- New GitWriteStrategy.force_pull / force_push methods returning PullResult / PushResult dataclasses
- New git_sync MCP tool composing them; direction='pull'|'push'|'both', dry_run support
- Tagged {write, git-managed}; second mcp.disable(tags={'git-managed'}) pass in server.py hides the tool when GIT_REPO_URL isn't set or READ_ONLY=true
- Pull-conflict path returns conflict_files (Syncthing-style per #232)
- Push-rejection returns hint pointing at git_sync(direction='pull')
- Reusable bare-repo + local-clone fixture in tests/fixtures/git.py
- Docs: README tool row, full entry in docs/tools/index.md with example responses, manual-sync section in docs/guides/git-integration.md

## Test plan
- [x] uv run pytest -x -q (all green)
- [x] uv run mypy src/ tests/ (clean)
- [x] uv run pre-commit run --all-files (clean)
- [x] diff-cover ≥80% on patch
- [x] local review circus clean (both subagents)
- [ ] CI green
- [ ] bot reviewers LGTM"
```

- [ ] **Step 6: Babysit + flip ready**

After CI runs and bot reviewers settle, address findings (re-running local circus before each push). Flip ready when clean:

```bash
gh pr ready <PR-number>
```
