Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- **Validator cache no longer freezes transient engine failures.** `rector-mcp`'s warm daemon intermittently trips rector's own `System error: "ClassReflection must be resolved for class X"` reflection bug on test classes — warm-process-state dependent, not file dependent (a clean re-run and plain `rector` CLI pass the same file). That failure was cached keyed on file content and replayed on every later run, including its frozen `duration_ms`; 2100 entries were poisoned this way across a test suite. Two-layer fix: (1) `validators/rector-mcp/rector-mcp.py` drops `System error:` results at the source — an engine glitch is not a code finding; (2) the validator cache now excludes non-deterministic engine failures (MCP transport errors, non-zero exits, `System error:` messages) from being written. Real findings (PHPStan types, `rector.refactor`) stay cached.
- **`git-merge` now fetches before merging a local branch — no more silent stale merges.** The docstring promised "fetch + merge" but there was no fetch: `git-merge master` merged the *local* `master` ref, which is stale the moment origin moves ahead. Real-world bite: a fix landed on origin/master, but merging `master` into a feature branch silently used the lagging local ref and the fix "wasn't there" — the failing job kept failing. Now `_fresh_merge_ref()` resolves the ref's upstream, fetches it, and if the local branch is behind, redirects the merge to the upstream (e.g. `origin/master`) with a loud note: `local master was N behind origin/master — merging origin/master (latest) instead`. Offline / fetch-fail / no-upstream cases warn and fall back to the local ref (no hard failure). Remote-tracking refs like `origin/master` are refreshed directly.

## [0.14.0] — 2026-05-23

Expand Down
70 changes: 66 additions & 4 deletions presets/git/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,60 @@ def _count_blocks(path: str) -> int:
return 0


def _is_local_branch(ref: str) -> bool:
return _git(["rev-parse", "--verify", "--quiet", f"refs/heads/{ref}"]).returncode == 0


def _upstream(ref: str) -> str:
res = _git(["rev-parse", "--abbrev-ref", f"{ref}@{{upstream}}"])
return res.stdout.strip() if res.returncode == 0 else ""


def _behind_count(local: str, remote: str) -> int:
res = _git(["rev-list", "--count", f"{local}..{remote}"])
try:
return int(res.stdout.strip()) if res.returncode == 0 else 0
except ValueError:
return 0


def _fresh_merge_ref(ref: str) -> tuple[str, str | None]:
"""Fetch the freshest version of REF before merging.

Merging a bare local branch (e.g. `master`) silently uses the local ref,
which is stale the moment origin moves ahead — a real footgun (see the
asset-stats timeout fix that "wasn't there" because local master lagged).

Returns (merge_ref, note). When the local branch is behind its upstream we
redirect the merge to the upstream so the caller gets the latest commits.
Offline / no-upstream cases fall back to REF unchanged.
"""
# Determine which remote ref to refresh.
upstream = ""
if _is_local_branch(ref):
upstream = _upstream(ref)
elif "/" in ref:
# Already a remote-tracking ref like origin/master — refresh it directly.
upstream = ref

if not upstream or "/" not in upstream:
return ref, None

remote, rbranch = upstream.split("/", 1)
fetch = _git(["fetch", remote, rbranch], timeout=45)
if fetch.returncode != 0:
msg = fetch.stderr.strip().splitlines()[-1] if fetch.stderr.strip() else "unknown error"
return ref, f"WARN: fetch {upstream} failed ({msg}) — merging local {ref}, may be stale"

if ref == upstream:
return ref, None # already merging the freshly-fetched remote ref

behind = _behind_count(ref, upstream)
if behind > 0:
return upstream, f"local {ref} was {behind} behind {upstream} — merging {upstream} (latest) instead"
return ref, None


def main() -> int:
if len(sys.argv) < 2:
print("ERROR: usage: merge.py REF")
Expand All @@ -82,16 +136,24 @@ def main() -> int:
print(f"ERROR: ref {ref!r} not found. Try `git fetch` first.")
return 1

merge_ref, note = _fresh_merge_ref(ref)
if note:
print(note)

if _git(["rev-parse", "--verify", "--quiet", merge_ref]).returncode != 0:
print(f"ERROR: ref {merge_ref!r} not found after fetch.")
return 1

head_before = _git(["rev-parse", "--short", "HEAD"]).stdout.strip()
branch = _git(["rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
their_sha = _git(["rev-parse", "--short", ref]).stdout.strip()
mb_res = _git(["merge-base", "HEAD", ref])
their_sha = _git(["rev-parse", "--short", merge_ref]).stdout.strip()
mb_res = _git(["merge-base", "HEAD", merge_ref])
merge_base = mb_res.stdout.strip()[:12] if mb_res.returncode == 0 else "?"

print(f"# git-merge: {ref}@{their_sha} into {branch}@{head_before}")
print(f"# git-merge: {merge_ref}@{their_sha} into {branch}@{head_before}")
print(f"Merge-base: {merge_base}")

result = _git(["merge", "--no-edit", ref])
result = _git(["merge", "--no-edit", merge_ref])
head_after = _git(["rev-parse", "--short", "HEAD"]).stdout.strip()

stdout = result.stdout.strip()
Expand Down
83 changes: 83 additions & 0 deletions tests/test_git_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from __future__ import annotations

import importlib.util
import subprocess
import sys
from pathlib import Path


Expand All @@ -12,6 +14,87 @@
_spec.loader.exec_module(merge)


def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["git"] + list(args), cwd=repo, capture_output=True, text=True, check=True
)


def _make_clones_with_stale_master(tmp_path: Path) -> tuple[Path, Path]:
"""Bare remote + two clones. Advance master via clone B, push.

Clone A's local master is now stale (behind origin/master). Returns
(clone_a, remote). A feature branch 'feat' exists in clone A, branched
from the original master, so merging 'master' into it should pull the
new commit only if the fetch+staleness redirect works.
"""
remote = tmp_path / "remote.git"
_git(tmp_path, "init", "--bare", "-b", "master", str(remote))

a = tmp_path / "a"
_git(tmp_path, "clone", str(remote), str(a))
_git(a, "config", "user.email", "a@test.com")
_git(a, "config", "user.name", "A")
(a / "README.md").write_text("hello\n")
_git(a, "add", "README.md")
_git(a, "commit", "-m", "init")
_git(a, "push", "origin", "master")
# feature branch off the original master
_git(a, "checkout", "-b", "feat")
(a / "feat.txt").write_text("feat\n")
_git(a, "add", "feat.txt")
_git(a, "commit", "-m", "feat work")

# Clone B advances master and pushes — origin/master is now ahead.
b = tmp_path / "b"
_git(tmp_path, "clone", str(remote), str(b))
_git(b, "config", "user.email", "b@test.com")
_git(b, "config", "user.name", "B")
(b / "fix.txt").write_text("the fix\n")
_git(b, "add", "fix.txt")
_git(b, "commit", "-m", "the fix that wasnt there")
_git(b, "push", "origin", "master")

return a, remote


def test_merge_redirects_to_upstream_when_local_stale(tmp_path, monkeypatch, capsys):
a, _ = _make_clones_with_stale_master(tmp_path)
monkeypatch.chdir(a)
# On 'feat', local 'master' is stale; origin/master has fix.txt.
monkeypatch.setattr(sys, "argv", ["merge.py", "master"])
rc = merge.main()
out = capsys.readouterr().out
assert rc == 0, out
assert "behind origin/master" in out
assert "merging origin/master" in out
# The fix commit must now be present on feat.
assert (a / "fix.txt").exists(), "stale merge missed the upstream fix"


def test_merge_local_only_branch_no_fetch(tmp_path, monkeypatch, capsys):
"""A branch with no upstream merges as-is (offline / local-only safe)."""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-b", "master")
_git(repo, "config", "user.email", "t@test.com")
_git(repo, "config", "user.name", "T")
(repo / "a.txt").write_text("a\n")
_git(repo, "add", "a.txt")
_git(repo, "commit", "-m", "init")
_git(repo, "checkout", "-b", "side")
(repo / "b.txt").write_text("b\n")
_git(repo, "add", "b.txt")
_git(repo, "commit", "-m", "side work")
_git(repo, "checkout", "master")
monkeypatch.chdir(repo)
monkeypatch.setattr(sys, "argv", ["merge.py", "side"])
rc = merge.main()
out = capsys.readouterr().out
assert rc == 0, out
assert (repo / "b.txt").exists()


def test_first_block_extracts_markers(tmp_path: Path) -> None:
f = tmp_path / "x.py"
f.write_text(
Expand Down
Loading