diff --git a/pygittools/main.py b/pygittools/main.py
index b80e5a7..e60299c 100644
--- a/pygittools/main.py
+++ b/pygittools/main.py
@@ -7,6 +7,8 @@ import sys
 from pathlib import Path
 
 import pygittools
+from pygittools.tags_sync import format_sync_result, sync_board_tags
+from pygittools.tasks_query import open_repository, resolve_repo_location
 from pygittools.tui import run_tui
 
 
@@ -25,9 +27,36 @@ def build_parser() -> argparse.ArgumentParser:
 		action="version",
 		version=f"%(prog)s {pygittools.__version__}",
 	)
+
+	subparsers = parser.add_subparsers(dest="command")
+	tags_parser = subparsers.add_parser("tags", help="Task tag operations")
+	tags_subparsers = tags_parser.add_subparsers(dest="tags_command", required=True)
+	sync_parser = tags_subparsers.add_parser("sync", help="Pull and push board task tags with a remote")
+	sync_parser.add_argument(
+		"--remote",
+		default="origin",
+		help="Remote name to sync with (for example origin)",
+	)
+	sync_parser.add_argument(
+		"--board",
+		default="Tasks",
+		help="Board name under refs/tags/boards/ (default: Tasks)",
+	)
 	return parser
 
 
+def _run_tags_sync(repo_path: Path | None, remote: str, board: str) -> int:
+	location = resolve_repo_location(repo_path=repo_path)
+	repo = open_repository(location)
+	try:
+		result = sync_board_tags(repo, remote, board)
+	except (KeyError, RuntimeError, ValueError) as exc:
+		print(exc, file=sys.stderr)
+		return 1
+	print(format_sync_result(result))
+	return 0
+
+
 def main(argv: list[str] | None = None) -> int:
 	args_list = sys.argv[1:] if argv is None else argv
 	if not args_list:
@@ -35,6 +64,10 @@ def main(argv: list[str] | None = None) -> int:
 
 	parser = build_parser()
 	args = parser.parse_args(args_list)
+
+	if args.command == "tags" and args.tags_command == "sync":
+		return _run_tags_sync(args.repo, args.remote, args.board)
+
 	return run_tui(args.repo)
 
 
diff --git a/pygittools/mcp_server.py b/pygittools/mcp_server.py
index c64ec1b..a6df3e5 100644
--- a/pygittools/mcp_server.py
+++ b/pygittools/mcp_server.py
@@ -4,7 +4,6 @@ from __future__ import annotations
 
 import json
 import os
-from pathlib import Path
 
 from mcp.server.fastmcp import FastMCP
 
diff --git a/pygittools/tags_sync.py b/pygittools/tags_sync.py
new file mode 100644
index 0000000..c5dd374
--- /dev/null
+++ b/pygittools/tags_sync.py
@@ -0,0 +1,365 @@
+"""Sync task/board/comment annotated tags with a Git remote."""
+
+from __future__ import annotations
+
+import subprocess
+from dataclasses import dataclass
+from datetime import datetime
+
+from pygit2 import Oid, Repository
+
+from pygittools.hooks_push import git_executable
+from pygittools.tasks import (
+	BOARD_REF_PREFIX,
+	COMMENT_REF_PREFIX,
+	EMPTY_TREE_OID_HEX,
+	TASK_REF_PREFIX,
+	Board,
+	Task,
+	get_board,
+	get_comment,
+	get_task,
+	get_task_by_oid,
+)
+from pygittools.tasks_query import board_ref, resolve_task_from_board_entry
+
+SYNC_REMOTE_PREFIX = "refs/remotes/pgt-sync/"
+
+
+@dataclass(frozen=True, slots=True)
+class TagSyncResult:
+	board: str
+	remote: str
+	pulled_tasks: tuple[str, ...]
+	pushed_tasks: tuple[str, ...]
+	pushed_board: bool
+
+
+def sync_tracking_prefix(remote_name: str) -> str:
+	return f"{SYNC_REMOTE_PREFIX}{remote_name}/"
+
+
+def _tracking_ref(remote_name: str, local_ref: str) -> str:
+	if local_ref.startswith(BOARD_REF_PREFIX):
+		suffix = local_ref.removeprefix(BOARD_REF_PREFIX)
+		return f"{sync_tracking_prefix(remote_name)}boards/{suffix}"
+	if local_ref.startswith(TASK_REF_PREFIX):
+		suffix = local_ref.removeprefix(TASK_REF_PREFIX)
+		return f"{sync_tracking_prefix(remote_name)}tasks/{suffix}"
+	if local_ref.startswith(COMMENT_REF_PREFIX):
+		suffix = local_ref.removeprefix(COMMENT_REF_PREFIX)
+		return f"{sync_tracking_prefix(remote_name)}comments/{suffix}"
+	raise ValueError(f"Unsupported tag ref: {local_ref}")
+
+
+def _git_dir_args(repo: Repository) -> list[str]:
+	workdir = repo.workdir
+	if workdir is not None:
+		return ["-C", workdir]
+	return ["--git-dir", repo.path]
+
+
+def _run_git(
+	repo: Repository,
+	*args: str,
+	git: str | None = None,
+) -> subprocess.CompletedProcess[str]:
+	binary = git or git_executable()
+	proc = subprocess.run(
+		[binary, *_git_dir_args(repo), *args],
+		capture_output=True,
+		text=True,
+	)
+	return proc
+
+
+def _require_git_ok(proc: subprocess.CompletedProcess[str], action: str) -> None:
+	if proc.returncode == 0:
+		return
+	message = (proc.stderr or proc.stdout or f"{action} failed").strip()
+	if "couldn't find remote ref" in message:
+		return
+	raise RuntimeError(message)
+
+
+def _fetch_tag_namespace(
+	repo: Repository,
+	remote_name: str,
+	src_suffix: str,
+	dst_suffix: str,
+	*,
+	git: str | None = None,
+) -> None:
+	tracking = sync_tracking_prefix(remote_name)
+	refspec = f"+refs/tags/{src_suffix}:{tracking}{dst_suffix}"
+	proc = _run_git(repo, "fetch", remote_name, refspec, git=git)
+	_require_git_ok(proc, f"fetch {refspec}")
+
+
+def fetch_board_tags(
+	repo: Repository,
+	remote_name: str,
+	board_name: str,
+	*,
+	git: str | None = None,
+) -> None:
+	_fetch_tag_namespace(repo, remote_name, f"boards/{board_name}", f"boards/{board_name}", git=git)
+	_fetch_tag_namespace(repo, remote_name, "tasks/*", "tasks/*", git=git)
+	_fetch_tag_namespace(repo, remote_name, "comments/*", "comments/*", git=git)
+
+
+def _ref_oid(repo: Repository, ref_name: str) -> str | None:
+	try:
+		if ref_name not in repo.references:
+			return None
+		return str(repo.references[ref_name].resolve().target)
+	except KeyError:
+		return None
+
+
+def _task_updated_at(task: Task) -> datetime:
+	value = task.updated_at or task.created_at
+	return value if isinstance(value, datetime) else datetime.min.replace(tzinfo=None)
+
+
+def _load_task_at_ref(repo: Repository, ref_name: str) -> Task | None:
+	try:
+		return get_task(repo, ref_name)
+	except (ValueError, KeyError):
+		return None
+
+
+def _load_remote_board_task(
+	repo: Repository,
+	remote_name: str,
+	oid_hex: str | Oid,
+) -> Task | None:
+	oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+	try:
+		task = get_task_by_oid(repo, oid)
+	except KeyError:
+		return None
+	if task is None or not task.name:
+		return task
+	tracking_ref = _tracking_ref(remote_name, task.name)
+	tracked = _load_task_at_ref(repo, tracking_ref)
+	return tracked or task
+
+
+def _promote_tracking_ref(repo: Repository, tracking_ref: str, local_ref: str) -> None:
+	oid = repo.references[tracking_ref].resolve().target
+	repo.references.create(local_ref, oid, force=True)
+
+
+def _pull_task(repo: Repository, remote_name: str, task_name: str) -> None:
+	tracking_ref = _tracking_ref(remote_name, task_name)
+	if tracking_ref not in repo.references:
+		raise KeyError(f"Remote task not fetched: {task_name}")
+	_promote_tracking_ref(repo, tracking_ref, task_name)
+	task = get_task(repo, task_name)
+	if task is None:
+		return
+	for comment_oid_hex in getattr(task, "comments", []) or []:
+		try:
+			comment = get_comment(repo, Oid(hex=comment_oid_hex))
+		except (ValueError, KeyError, TypeError):
+			continue
+		tracking_comment = _tracking_ref(remote_name, comment.name)
+		if tracking_comment in repo.references:
+			_promote_tracking_ref(repo, tracking_comment, comment.name)
+
+
+def _board_task_entries(
+	repo: Repository,
+	board: Board,
+	*,
+	remote_name: str | None = None,
+) -> tuple[list[str], dict[str, str]]:
+	order: list[str] = []
+	by_ref: dict[str, str] = {}
+	for oid_hex in getattr(board, "tasks", []) or []:
+		if remote_name is None:
+			task = resolve_task_from_board_entry(repo, oid_hex)
+		else:
+			task = _load_remote_board_task(repo, remote_name, oid_hex)
+		if task is None or not task.name:
+			continue
+		if remote_name is None:
+			current_oid = _ref_oid(repo, task.name) or str(oid_hex)
+		else:
+			current_oid = _ref_oid(repo, _tracking_ref(remote_name, task.name)) or str(oid_hex)
+		if task.name in by_ref:
+			by_ref[task.name] = current_oid
+			continue
+		order.append(task.name)
+		by_ref[task.name] = current_oid
+	return order, by_ref
+
+
+def _ensure_local_board(repo: Repository, board_name: str) -> Board:
+	ref = board_ref(board_name)
+	if ref in repo.references:
+		existing = get_board(repo, ref)
+		if existing is not None:
+			return existing
+	board = Board(Oid(hex=EMPTY_TREE_OID_HEX), ref, tagger="", description="")
+	board.write(repo)
+	return board
+
+
+def _merge_board_task_list(
+	local_order: list[str],
+	remote_order: list[str],
+	oids_by_ref: dict[str, str],
+) -> list[str]:
+	merged: list[str] = []
+	seen: set[str] = set()
+	for task_name in remote_order:
+		if task_name in oids_by_ref and task_name not in seen:
+			merged.append(oids_by_ref[task_name])
+			seen.add(task_name)
+	for task_name in local_order:
+		if task_name in oids_by_ref and task_name not in seen:
+			merged.append(oids_by_ref[task_name])
+			seen.add(task_name)
+	return merged
+
+
+def pull_board_tags(
+	repo: Repository,
+	remote_name: str,
+	board_name: str,
+) -> tuple[Board, tuple[str, ...]]:
+	local_board = _ensure_local_board(repo, board_name)
+	local_order, local_by_ref = _board_task_entries(repo, local_board)
+
+	remote_board_ref = _tracking_ref(remote_name, board_ref(board_name))
+	remote_board = get_board(repo, remote_board_ref) if remote_board_ref in repo.references else None
+	if remote_board is None:
+		return local_board, ()
+
+	remote_order, remote_by_ref = _board_task_entries(repo, remote_board, remote_name=remote_name)
+	merged_by_ref = dict(local_by_ref)
+	pulled: list[str] = []
+
+	for task_name in remote_order:
+		remote_oid = remote_by_ref[task_name]
+		local_oid = local_by_ref.get(task_name)
+		if local_oid is None:
+			_pull_task(repo, remote_name, task_name)
+			merged_by_ref[task_name] = _ref_oid(repo, task_name) or remote_oid
+			pulled.append(task_name)
+			continue
+		if remote_oid == local_oid:
+			continue
+		remote_task = _load_task_at_ref(repo, _tracking_ref(remote_name, task_name))
+		local_task = _load_task_at_ref(repo, task_name)
+		if remote_task is None:
+			continue
+		if local_task is None or _task_updated_at(remote_task) >= _task_updated_at(local_task):
+			_pull_task(repo, remote_name, task_name)
+			merged_by_ref[task_name] = _ref_oid(repo, task_name) or remote_oid
+			pulled.append(task_name)
+
+	local_board.tasks = _merge_board_task_list(local_order, remote_order, merged_by_ref)
+	local_board.update_message()
+	local_board.write(repo)
+	return local_board, tuple(pulled)
+
+
+def _push_refs(
+	repo: Repository,
+	remote_name: str,
+	refs: list[str],
+	*,
+	git: str | None = None,
+) -> None:
+	if not refs:
+		return
+	refspecs = [f"{ref}:{ref}" for ref in refs]
+	proc = _run_git(repo, "push", "--force", remote_name, *refspecs, git=git)
+	_require_git_ok(proc, f"push {', '.join(refs)}")
+
+
+def push_board_tags(
+	repo: Repository,
+	remote_name: str,
+	board_name: str,
+) -> tuple[tuple[str, ...], bool]:
+	local_board = get_board(repo, board_ref(board_name)) if board_ref(board_name) in repo.references else None
+	if local_board is None:
+		raise KeyError(f"Board not found: {board_name}")
+
+	local_order, local_by_ref = _board_task_entries(repo, local_board)
+	pushed: list[str] = []
+
+	for task_name in local_order:
+		local_oid = local_by_ref[task_name]
+		tracking_ref = _tracking_ref(remote_name, task_name)
+		remote_oid = _ref_oid(repo, tracking_ref)
+		if remote_oid == local_oid:
+			continue
+		if remote_oid is None:
+			should_push = True
+		else:
+			local_task = _load_task_at_ref(repo, task_name)
+			remote_task = _load_task_at_ref(repo, tracking_ref)
+			should_push = local_task is not None and (
+				remote_task is None or _task_updated_at(local_task) > _task_updated_at(remote_task)
+			)
+		if not should_push:
+			continue
+		refs = [task_name]
+		task = get_task(repo, task_name)
+		for comment_oid_hex in getattr(task, "comments", []) or []:
+			try:
+				comment = get_comment(repo, Oid(hex=comment_oid_hex))
+			except (ValueError, KeyError, TypeError):
+				continue
+			refs.append(comment.name)
+		_push_refs(repo, remote_name, refs)
+		pushed.append(task_name)
+
+	board_local_ref = board_ref(board_name)
+	_push_refs(repo, remote_name, [board_local_ref])
+	return tuple(pushed), True
+
+
+def sync_board_tags(
+	repo: Repository,
+	remote_name: str,
+	board_name: str,
+	*,
+	git: str | None = None,
+) -> TagSyncResult:
+	if remote_name not in repo.remotes.names():
+		raise ValueError(f"remote {remote_name!r} not configured")
+
+	fetch_board_tags(repo, remote_name, board_name, git=git)
+	_, pulled = pull_board_tags(repo, remote_name, board_name)
+	pushed, pushed_board = push_board_tags(repo, remote_name, board_name)
+
+	return TagSyncResult(
+		board=board_name,
+		remote=remote_name,
+		pulled_tasks=pulled,
+		pushed_tasks=pushed,
+		pushed_board=pushed_board,
+	)
+
+
+def format_sync_result(result: TagSyncResult) -> str:
+	lines = [
+		f"Synced board {result.board!r} with remote {result.remote!r}.",
+	]
+	if result.pulled_tasks:
+		lines.append(f"Pulled {len(result.pulled_tasks)} task(s): {', '.join(result.pulled_tasks)}")
+	else:
+		lines.append("Pulled 0 tasks.")
+	if result.pushed_tasks:
+		lines.append(f"Pushed {len(result.pushed_tasks)} task(s): {', '.join(result.pushed_tasks)}")
+	else:
+		lines.append("Pushed 0 tasks.")
+	if result.pushed_board:
+		lines.append("Pushed board tag.")
+	return "\n".join(lines)
diff --git a/pygittools/tags_sync_test.py b/pygittools/tags_sync_test.py
new file mode 100644
index 0000000..502830a
--- /dev/null
+++ b/pygittools/tags_sync_test.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pygit2
+from pygit2 import Oid, Repository, init_repository
+
+from pygittools.tags_sync import sync_board_tags
+from pygittools.tasks import (
+	EMPTY_TREE_OID_HEX,
+	TASK_REF_PREFIX,
+	Board,
+	Comment,
+	Task,
+	get_board,
+	get_task,
+)
+from pygittools.tasks_query import board_ref, list_tasks
+
+
+def _empty_tree() -> Oid:
+	return Oid(hex=EMPTY_TREE_OID_HEX)
+
+
+def _init_client_with_remote(tmp_path: Path) -> tuple[Repository, Path, Path]:
+	bare_path = tmp_path / "remote.git"
+	bare_path.mkdir()
+	init_repository(str(bare_path), bare=True)
+	client_path = tmp_path / "client"
+	client = init_repository(str(client_path), bare=False)
+	client.remotes.create("origin", str(bare_path))
+	return client, client_path, bare_path
+
+
+def _write_board_with_task(
+	repo: Repository,
+	board_name: str,
+	task_slug: str,
+	*,
+	title: str,
+	status: Task.Status,
+	comment_text: str | None = None,
+) -> str:
+	task_ref = f"{TASK_REF_PREFIX}{task_slug}"
+	task = Task(_empty_tree(), task_ref, "alice <alice@example.com>", title=title, status=status)
+	task_oid = task.write(repo)
+	if comment_text is not None:
+		comment = Comment(task_oid, "bob <bob@example.com>", content=comment_text)
+		comment_oid = comment.write(repo)
+		task.comments = [str(comment_oid)]
+		task.update_message()
+		task_oid = task.write(repo)
+
+	board_ref_name = board_ref(board_name)
+	board = get_board(repo, board_ref_name) if board_ref_name in repo.references else None
+	if board is None:
+		board = Board(_empty_tree(), board_ref_name, tagger="", description="Main")
+	board.tasks = [str(task_oid)]
+	board.update_message()
+	board.write(repo)
+	return task_ref
+
+
+def test_sync_pulls_new_remote_task_and_drops_stale_board_oid(tmp_path: Path) -> None:
+	client, client_path, bare_path = _init_client_with_remote(tmp_path)
+	local = pygit2.Repository(str(client_path))
+	remote = pygit2.Repository(str(bare_path))
+
+	remote_task_ref = _write_board_with_task(
+		remote,
+		"Tasks",
+		"alpha",
+		title="Remote Alpha",
+		status=Task.Status.TODO,
+		comment_text="from remote",
+	)
+	remote_board = get_board(remote, board_ref("Tasks"))
+	assert remote_board is not None
+	stale_oid = remote_board.tasks[0]
+
+	task = get_task(remote, remote_task_ref)
+	assert task is not None
+	task.title = "Remote Alpha v2"
+	task.status = Task.Status.DONE
+	task.update_message()
+	current_oid = task.write(remote)
+	remote_board.tasks = [str(stale_oid), str(current_oid)]
+	remote_board.update_message()
+	remote_board.write(remote)
+
+	local_board = Board(_empty_tree(), board_ref("Tasks"), tagger="", description="")
+	local_board.write(local)
+
+	result = sync_board_tags(local, "origin", "Tasks")
+
+	assert result.pulled_tasks == (remote_task_ref,)
+	loaded_board = get_board(local, board_ref("Tasks"))
+	assert loaded_board is not None
+	assert loaded_board.tasks == [str(current_oid)]
+	tasks = list_tasks(local, "Tasks", include_comments=True)
+	assert len(tasks) == 1
+	assert tasks[0]["title"] == "Remote Alpha v2"
+	assert tasks[0]["status"] == "DONE"
+	assert tasks[0]["comments"][0]["content"] == "from remote"
+
+
+def test_sync_pushes_new_local_task_to_remote(tmp_path: Path) -> None:
+	client, client_path, bare_path = _init_client_with_remote(tmp_path)
+	local = pygit2.Repository(str(client_path))
+	remote = pygit2.Repository(str(bare_path))
+
+	local_task_ref = _write_board_with_task(
+		local,
+		"Tasks",
+		"local-only",
+		title="Local Task",
+		status=Task.Status.IN_PROGRESS,
+	)
+
+	result = sync_board_tags(local, "origin", "Tasks")
+
+	assert result.pushed_tasks == (local_task_ref,)
+	assert result.pushed_board is True
+	remote_board = get_board(remote, board_ref("Tasks"))
+	assert remote_board is not None
+	assert len(remote_board.tasks) == 1
+	remote_task = get_task(remote, local_task_ref)
+	assert remote_task is not None
+	assert remote_task.title == "Local Task"
+
+
+def test_sync_merges_local_and_remote_boards(tmp_path: Path) -> None:
+	client, client_path, bare_path = _init_client_with_remote(tmp_path)
+	local = pygit2.Repository(str(client_path))
+	remote = pygit2.Repository(str(bare_path))
+
+	remote_task_ref = _write_board_with_task(
+		remote,
+		"Tasks",
+		"remote-task",
+		title="On Remote",
+		status=Task.Status.TODO,
+	)
+	local_task_ref = _write_board_with_task(
+		local,
+		"Tasks",
+		"local-task",
+		title="On Local",
+		status=Task.Status.IN_REVIEW,
+	)
+
+	result = sync_board_tags(local, "origin", "Tasks")
+
+	assert remote_task_ref in result.pulled_tasks
+	assert local_task_ref in result.pushed_tasks
+	local_board = get_board(local, board_ref("Tasks"))
+	remote_board = get_board(remote, board_ref("Tasks"))
+	assert local_board is not None and remote_board is not None
+	assert len(local_board.tasks) == 2
+	assert len(remote_board.tasks) == 2
+	assert get_task(remote, local_task_ref) is not None
+	assert get_task(local, remote_task_ref) is not None
diff --git a/pygittools/tasks_query.py b/pygittools/tasks_query.py
index 8288e50..44d9ed4 100644
--- a/pygittools/tasks_query.py
+++ b/pygittools/tasks_query.py
@@ -7,7 +7,6 @@ from dataclasses import dataclass
 from pathlib import Path
 from typing import TypedDict
 
-import pygit2
 from pygit2 import Oid, Repository
 
 from pygittools.tasks import (
diff --git a/pygitweb/hooks_install.py b/pygitweb/hooks_install.py
index e65b6b9..75a8d1d 100644
--- a/pygitweb/hooks_install.py
+++ b/pygitweb/hooks_install.py
@@ -9,6 +9,7 @@ the repo (`<repo>/.git/hooks` for non-bare, `<repo>/hooks` for bare).
 
 from __future__ import annotations
 
+import contextlib
 import hashlib
 import os
 import re
@@ -132,10 +133,8 @@ def list_installed_hooks(project: str) -> list[InstalledHookInfo]:
 			version = read_hook_version(target_path)
 		installed_hash: str | None = None
 		if target_path.is_file():
-			try:
+			with contextlib.suppress(OSError):
 				installed_hash = content_hash(target_path)
-			except OSError:
-				pass
 		installed.append({
 			"name": sample["name"],
 			"label": sample["label"],
