diff --git a/pygittools/merge.py b/pygittools/merge.py
new file mode 100644
index 0000000..a182784
--- /dev/null
+++ b/pygittools/merge.py
@@ -0,0 +1,245 @@
+"""Merge requests as annotated tags under refs/tags/mr/."""
+
+from __future__ import annotations
+
+import json
+import re
+import secrets
+from datetime import UTC, datetime
+from enum import Enum
+from typing import Any
+
+from pygit2 import Commit, GitError, Oid, Repository, Signature, Tag, reference_is_valid_name
+from pygit2.enums import MergeAnalysis
+
+from pygittools.tasks import GIT_OBJECT_TAG, _build_tag_raw, _DateTimeEncoder
+
+MR_REF_PREFIX: str = "refs/tags/mr/"
+
+
+def generate_mr_ref() -> str:
+	"""Return ``refs/tags/mr/<16 hex chars>`` for distributed-safe ref names."""
+	return f"{MR_REF_PREFIX}{secrets.token_hex(8)}"
+
+
+def _parse_dt(value: Any) -> datetime | None:
+	if not value:
+		return None
+	if isinstance(value, datetime):
+		return value
+	return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+
+
+def _identity_from_opener(opener: Signature | str) -> tuple[str, str]:
+	if isinstance(opener, Signature):
+		return opener.name, opener.email
+	line = (opener or "").strip()
+	match = re.match(r"^(?P<name>.+) <(?P<email>[^>]+)>", line)
+	if match:
+		return match.group("name"), match.group("email")
+	if line:
+		return line, "unknown@local"
+	return "unknown", "unknown@local"
+
+
+def tagger_signature_at(opener: Signature | str, when: datetime) -> Signature:
+	"""Tagger line identity from ``opener`` with timestamp from ``when`` (UTC offset +0000)."""
+	if when.tzinfo is None:
+		when = when.replace(tzinfo=UTC)
+	name, email = _identity_from_opener(opener)
+	t = int(when.timestamp())
+	return Signature(name, email, t, 0)
+
+
+def assert_merge_head_object(repo: Repository, head: Oid | str) -> str:
+	"""Require the merge-request tag target to be a commit or tag object (not tree/blob)."""
+	oid = head if isinstance(head, Oid) else Oid(hex=head)
+	obj = repo[oid]
+	type_str = (getattr(obj, "type_str", None) or "").lower()
+	if type_str in ("blob", "tree"):
+		raise ValueError(f"Merge request head must be a commit or tag, not {type_str}: {oid}")
+	if type_str not in ("commit", "tag"):
+		raise ValueError(f"Merge request head has unsupported type {type_str!r}: {oid}")
+	return type_str
+
+
+def peel_mr_head_to_commit_oid(repo: Repository, head: Oid | str) -> Oid:
+	"""Follow annotated tags until a commit OID (the revision to merge into ``ours``)."""
+	oid = head if isinstance(head, Oid) else Oid(hex=head)
+	cur = oid
+	while True:
+		try:
+			obj = repo[cur]
+		except KeyError as exc:
+			raise KeyError(cur) from exc
+		if isinstance(obj, Tag):
+			cur = obj.target
+			continue
+		if isinstance(obj, Commit):
+			return obj.id
+		type_str = getattr(obj, "type_str", None) or type(obj).__name__
+		raise TypeError(f"Merge request head peeled to non-commit ({type_str}): {cur}")
+
+
+class MergeRequestStatus(Enum):
+	OURS_REF_MISSING = "ours_ref_missing"
+	MR_HEAD_MISSING = "mr_head_missing"
+	CAN_FAST_FORWARD = "can_fast_forward"
+	UP_TO_DATE = "up_to_date"
+	BLOCKED_DIVERGED = "blocked_diverged"
+
+
+"""
+A MergeRequest is an annotated tag (and ref under refs/tags/mr/).
+The tag points at the commit (or tag for merge-train) being merged.
+The tag message is JSON with ours (branch receiving the merge), optional theirs (tracked branch tip),
+title, description, comments, and timestamps.
+The tagger line uses the opener's identity and the last-modified time from ``updated_at``.
+"""
+
+
+class MergeRequest:
+	def __init__(
+		self,
+		target: Oid | str,
+		opener: Signature | str,
+		ours: str,
+		title: str,
+		description: str = "",
+		theirs: str | None = None,
+		name: str | None = None,
+		comments: list[Oid | str] | None = None,
+	):
+		ref_name = name if name is not None else generate_mr_ref()
+		if not reference_is_valid_name(ref_name):
+			raise ValueError(f"Invalid merge request ref name: {ref_name!r}")
+		if not ours.strip():
+			raise ValueError("ours must be a non-empty ref name")
+
+		self.target = target
+		self.opener = opener
+		self.ours = ours.strip()
+		self.theirs = theirs.strip() if theirs and str(theirs).strip() else None
+		self.title = title
+		self.description = description
+		self.name = ref_name
+		self.comments: list[Oid | str] = list(comments) if comments is not None else []
+		self.created_at = datetime.now(UTC)
+		self.updated_at = self.created_at
+		self.message: str = ""
+		self.update_message()
+
+	def update_message(self) -> None:
+		self.updated_at = datetime.now(UTC)
+		payload: dict[str, Any] = {
+			"title": self.title,
+			"description": self.description,
+			"ours": self.ours,
+			"theirs": self.theirs,
+			"comments": [str(c) for c in self.comments],
+			"created_at": self.created_at,
+			"updated_at": self.updated_at,
+		}
+		self.message = _DateTimeEncoder().encode(payload)
+
+	def write(self, repo: Repository) -> Oid:
+		object_type = assert_merge_head_object(repo, self.target)
+		tagger_sig = tagger_signature_at(self.opener, self.updated_at)
+		raw = _build_tag_raw(self.target, self.name, tagger_sig, self.message, object_type=object_type)
+		oid = repo.odb.write(GIT_OBJECT_TAG, raw)
+		repo.references.create(self.name, oid, force=True)
+		return oid
+
+	def refresh_from_theirs(self, repo: Repository) -> Oid | None:
+		"""If ``theirs`` is set, move tag target to that ref's peeled commit tip when it differs. Returns new tag OID if rewritten."""
+		if not self.theirs:
+			return None
+		try:
+			theirs_tip = repo.revparse_single(self.theirs).peel(Commit).id
+		except (KeyError, ValueError, GitError):
+			return None
+		try:
+			head_commit = peel_mr_head_to_commit_oid(repo, self.target)
+		except KeyError:
+			return None
+		if head_commit == theirs_tip:
+			return None
+		self.target = theirs_tip
+		self.update_message()
+		return self.write(repo)
+
+	def check_status(self, repo: Repository) -> MergeRequestStatus:
+		try:
+			repo.lookup_reference(self.ours)
+		except KeyError:
+			return MergeRequestStatus.OURS_REF_MISSING
+		try:
+			mr_commit = peel_mr_head_to_commit_oid(repo, self.target)
+		except KeyError:
+			return MergeRequestStatus.MR_HEAD_MISSING
+
+		try:
+			analysis, _pref = repo.merge_analysis(mr_commit, self.ours)
+		except GitError as exc:
+			raise ValueError(f"merge analysis failed for {self.ours!r}: {exc}") from exc
+
+		if analysis & MergeAnalysis.FASTFORWARD:
+			return MergeRequestStatus.CAN_FAST_FORWARD
+		if analysis & MergeAnalysis.UP_TO_DATE:
+			return MergeRequestStatus.UP_TO_DATE
+		return MergeRequestStatus.BLOCKED_DIVERGED
+
+	def merge(self, repo: Repository) -> MergeRequestStatus:
+		status = self.check_status(repo)
+		if status == MergeRequestStatus.OURS_REF_MISSING:
+			raise ValueError(f"ours ref does not exist: {self.ours!r}")
+		if status == MergeRequestStatus.MR_HEAD_MISSING:
+			raise ValueError(f"merge request head object missing in repository: {self.target!r}")
+		if status == MergeRequestStatus.BLOCKED_DIVERGED:
+			raise ValueError(
+				f"cannot fast-forward {self.ours!r} to merge request head {self.target!r}: histories diverged"
+			)
+		if status == MergeRequestStatus.UP_TO_DATE:
+			return status
+
+		mr_commit = peel_mr_head_to_commit_oid(repo, self.target)
+		target_ref = repo.lookup_reference(self.ours).resolve()
+		target_ref.set_target(mr_commit)
+		return status
+
+
+def _merge_request_from_tag(tag: Tag) -> MergeRequest:
+	body = json.loads(tag.message)
+	raw_ours = body.get("ours") or body.get("target")
+	if raw_ours is None or not str(raw_ours).strip():
+		raise ValueError("merge request payload missing non-empty ours (or legacy target)")
+	theirs_raw = body.get("theirs")
+	theirs = str(theirs_raw).strip() if theirs_raw is not None and str(theirs_raw).strip() else None
+	mr = MergeRequest(
+		tag.target,
+		str(tag.tagger) if tag.tagger else "",
+		ours=str(raw_ours).strip(),
+		title=body.get("title", "Untitled"),
+		description=body.get("description", ""),
+		theirs=theirs,
+		name=tag.name,
+		comments=list(body.get("comments", [])),
+	)
+	mr.created_at = _parse_dt(body.get("created_at")) or mr.created_at
+	mr.updated_at = _parse_dt(body.get("updated_at")) or mr.updated_at
+	mr.message = tag.message
+	return mr
+
+
+def get_merge_request(repo: Repository, ref: str) -> MergeRequest:
+	obj = repo.revparse_single(ref)
+	if not isinstance(obj, Tag):
+		raise ValueError(f"Requested merge request is not a tag: {ref}")
+	return _merge_request_from_tag(obj)
+
+
+def get_merge_request_by_oid(repo: Repository, oid: Oid) -> MergeRequest:
+	obj = repo[oid]
+	if not isinstance(obj, Tag):
+		raise ValueError(f"Object is not a merge request tag: {oid}")
+	return _merge_request_from_tag(obj)
diff --git a/pygittools/merge_test.py b/pygittools/merge_test.py
new file mode 100644
index 0000000..d882834
--- /dev/null
+++ b/pygittools/merge_test.py
@@ -0,0 +1,165 @@
+from pathlib import Path
+
+import pytest
+from pygit2 import GIT_OBJECT_BLOB, Oid, Repository, Signature, init_repository
+
+from pygittools.merge import (
+	MR_REF_PREFIX,
+	MergeRequest,
+	MergeRequestStatus,
+	assert_merge_head_object,
+	generate_mr_ref,
+	get_merge_request,
+	get_merge_request_by_oid,
+	peel_mr_head_to_commit_oid,
+)
+from pygittools.tasks import EMPTY_TREE_OID_HEX, _ensure_empty_tree
+
+SIG = Signature("alice", "alice@example.com")
+
+
+@pytest.fixture
+def repo(tmp_path: Path) -> Repository:
+	repo_path = tmp_path / "repo"
+	repo_path.mkdir()
+	return init_repository(str(repo_path), bare=False)
+
+
+def _commit(repo: Repository) -> Oid:
+	tb = repo.TreeBuilder()
+	tree = tb.write()
+	return repo.create_commit("HEAD", SIG, SIG, "init", tree, [])
+
+
+def _linear_ab(repo: Repository) -> tuple[Oid, Oid]:
+	tb = repo.TreeBuilder()
+	tree = tb.write()
+	a = repo.create_commit(None, SIG, SIG, "a", tree, [])
+	b = repo.create_commit(None, SIG, SIG, "b", tree, [a])
+	repo.create_reference("refs/heads/main", a)
+	return a, b
+
+
+def test_generate_mr_ref_shape() -> None:
+	ref = generate_mr_ref()
+	assert ref.startswith(MR_REF_PREFIX)
+	assert len(ref) == len(MR_REF_PREFIX) + 16
+
+
+def test_mr_roundtrip_commit(repo: Repository) -> None:
+	c = _commit(repo)
+	ref = f"{MR_REF_PREFIX}deadbeefcafebabe"
+	mr = MergeRequest(c, SIG, ours="refs/heads/main", title="Add feature", description="Details", name=ref)
+	oid = mr.write(repo)
+
+	loaded = get_merge_request(repo, ref)
+	assert loaded.title == "Add feature"
+	assert loaded.description == "Details"
+	assert loaded.ours == "refs/heads/main"
+	assert loaded.target == c
+	assert loaded.name == ref
+
+	loaded_oid = get_merge_request_by_oid(repo, oid)
+	assert loaded_oid.title == "Add feature"
+
+
+def test_mr_rejects_tree(repo: Repository) -> None:
+	_ensure_empty_tree(repo)
+	tree_oid = Oid(hex=EMPTY_TREE_OID_HEX)
+	ref = f"{MR_REF_PREFIX}aaaaaaaaaaaaaaaa"
+	mr = MergeRequest(tree_oid, SIG, ours="refs/heads/main", title="bad", name=ref)
+	with pytest.raises(ValueError, match="tree"):
+		mr.write(repo)
+
+
+def test_mr_rejects_blob(repo: Repository) -> None:
+	blob_oid = repo.odb.write(GIT_OBJECT_BLOB, b"hello")
+	ref = f"{MR_REF_PREFIX}bbbbbbbbbbbbbbbb"
+	mr = MergeRequest(blob_oid, SIG, ours="refs/heads/main", title="bad", name=ref)
+	with pytest.raises(ValueError, match="blob"):
+		mr.write(repo)
+
+
+def test_mr_allows_tag_head(repo: Repository) -> None:
+	c = _commit(repo)
+	ref_base = f"{MR_REF_PREFIX}1111111111111111"
+	mr1 = MergeRequest(c, SIG, ours="refs/heads/main", title="first", name=ref_base)
+	tag_oid = mr1.write(repo)
+
+	ref_second = f"{MR_REF_PREFIX}2222222222222222"
+	mr2 = MergeRequest(tag_oid, SIG, ours="refs/heads/main", title="train", name=ref_second)
+	mr2.write(repo)
+
+	loaded = get_merge_request(repo, ref_second)
+	assert loaded.title == "train"
+	assert loaded.target == tag_oid
+
+
+def test_assert_merge_head_object(repo: Repository) -> None:
+	_ensure_empty_tree(repo)
+	c = _commit(repo)
+	assert assert_merge_head_object(repo, c) == "commit"
+	tree_oid = Oid(hex=EMPTY_TREE_OID_HEX)
+	with pytest.raises(ValueError, match="tree"):
+		assert_merge_head_object(repo, tree_oid)
+
+
+def test_merge_ff_advances_branch(repo: Repository) -> None:
+	_a, b = _linear_ab(repo)
+	mr_ref = f"{MR_REF_PREFIX}feedfeedfeedfeed"
+	mr = MergeRequest(b, SIG, ours="refs/heads/main", title="ff", name=mr_ref)
+	mr.write(repo)
+	assert mr.check_status(repo) == MergeRequestStatus.CAN_FAST_FORWARD
+	assert mr.merge(repo) == MergeRequestStatus.CAN_FAST_FORWARD
+	assert repo.references["refs/heads/main"].resolve().target == b
+
+
+def test_merge_diverged_blocked(repo: Repository) -> None:
+	tb = repo.TreeBuilder()
+	tree = tb.write()
+	root = repo.create_commit(None, SIG, SIG, "root", tree, [])
+	c1 = repo.create_commit(None, SIG, SIG, "c1", tree, [root])
+	c2 = repo.create_commit(None, SIG, SIG, "c2", tree, [root])
+	repo.create_reference("refs/heads/main", c1)
+	mr_ref = f"{MR_REF_PREFIX}ddddeeeeffffffff"
+	mr = MergeRequest(c2, SIG, ours="refs/heads/main", title="div", name=mr_ref)
+	mr.write(repo)
+	assert mr.check_status(repo) == MergeRequestStatus.BLOCKED_DIVERGED
+	with pytest.raises(ValueError, match="fast-forward"):
+		mr.merge(repo)
+	assert repo.references["refs/heads/main"].resolve().target == c1
+
+
+def test_merge_up_to_date_noop(repo: Repository) -> None:
+	a, b = _linear_ab(repo)
+	repo.references["refs/heads/main"].resolve().set_target(b)
+	mr_ref = f"{MR_REF_PREFIX}aaaabbbbccccdddd"
+	mr = MergeRequest(a, SIG, ours="refs/heads/main", title="behind", name=mr_ref)
+	mr.write(repo)
+	assert mr.check_status(repo) == MergeRequestStatus.UP_TO_DATE
+	assert mr.merge(repo) == MergeRequestStatus.UP_TO_DATE
+	assert repo.references["refs/heads/main"].resolve().target == b
+
+
+def test_check_status_missing_branch(repo: Repository) -> None:
+	c = _commit(repo)
+	mr_ref = f"{MR_REF_PREFIX}eeeeffff00001111"
+	mr = MergeRequest(c, SIG, ours="refs/heads/does-not-exist", title="x", name=mr_ref)
+	mr.write(repo)
+	assert mr.check_status(repo) == MergeRequestStatus.OURS_REF_MISSING
+
+
+def test_refresh_from_theirs_advances_mr_head(repo: Repository) -> None:
+	tb = repo.TreeBuilder()
+	tree = tb.write()
+	a = repo.create_commit(None, SIG, SIG, "a", tree, [])
+	repo.create_reference("refs/heads/main", a)
+	b = repo.create_commit(None, SIG, SIG, "b", tree, [a])
+	repo.create_reference("refs/heads/feature", b)
+	mr_ref = f"{MR_REF_PREFIX}0123456789abcdef"
+	mr = MergeRequest(a, SIG, ours="refs/heads/main", title="track", name=mr_ref, theirs="refs/heads/feature")
+	mr.write(repo)
+	assert peel_mr_head_to_commit_oid(repo, mr.target) == a
+	new_oid = mr.refresh_from_theirs(repo)
+	assert new_oid is not None
+	assert peel_mr_head_to_commit_oid(repo, mr.target) == b
