diff --git a/distgit/tasks.py b/distgit/tasks.py
index b988053..e81b030 100644
--- a/distgit/tasks.py
+++ b/distgit/tasks.py
@@ -12,6 +12,14 @@ GIT_OBJECT_TAG = 4
 GIT_OBJECT_TREE = 2
 EMPTY_TREE_OID_HEX = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
 
+# All task/board/comment refs live under refs/tags/ so git treats them as real
+# references for reachability (git gc, fetch, push). Without the refs/ prefix
+# pygit2 will happily create loose files outside of refs/, which git itself
+# then ignores -- making tag objects unreachable and subject to pruning.
+BOARD_REF_PREFIX: str = "refs/tags/boards/"
+TASK_REF_PREFIX: str = "refs/tags/tasks/"
+COMMENT_REF_PREFIX: str = "refs/tags/comments/"
+
 
 def _ensure_empty_tree(repo: Repository) -> None:
 	"""Ensure the well-known empty tree object exists in the repo ODB (so tags can point to it)."""
@@ -70,7 +78,7 @@ def _read_tag_raw(repo: Repository, oid: Oid) -> tuple[Oid, str, str, str]:
 A Task is an annotated tag (a full tag object, although it will get a ref as well).
 The usual fields of a tag (target, name, tagger) are present, but the "message" is a JSON string.
 All the fields seen below are supported, but arbitrary JSON keys may be added. Text fields are usually Markdown.
-The "name" must be a valid Git ref name (usually "tasks/<task-id>").
+The "name" must be a fully-qualified Git ref name (usually "refs/tags/tasks/<task-id>").
 Because the ODB is immutable, the tag will always point to the latest version of the task.
 """
 
@@ -102,7 +110,7 @@ class Task:
 		due_date: datetime | None = None,
 	):
 		if name is None:
-			name = f"tasks/{title.lower().replace(' ', '_')}"
+			name = f"{TASK_REF_PREFIX}{title.lower().replace(' ', '_')}"
 
 		if not reference_is_valid_name(name):
 			raise ValueError(f"Invalid task backend name: '{name}'")
@@ -206,9 +214,9 @@ def get_task_by_oid(repo: Repository, oid: Oid) -> Task | None:
 
 
 """
-A Comment is an annotated tag (a full tag object). Unlike tasks it does not get a ref by default.
+A Comment is an annotated tag (a full tag object) that also gets a ref under refs/tags/comments/.
 The tagger is the author of the comment and the target is the task or parent comment.
-Todo: this will not play nice with gc...
+The ref keeps the comment reachable so git gc will not prune it.
 """
 
 
@@ -218,10 +226,13 @@ class Comment:
 		target: Oid,  # Task or parent comment OID
 		tagger: str,  # Author of the comment
 		content: str,
-		name: str | None = None,  # "comments/<comment-id>" - Note this will NOT get a ref by default
+		name: str | None = None,  # Fully-qualified ref, e.g. "refs/tags/comments/<comment-id>"
 	):
 		if name is None:
-			name = f"comments/{randint(1, 2147483647)}"
+			name = f"{COMMENT_REF_PREFIX}{randint(1, 2147483647)}"
+
+		if not reference_is_valid_name(name):
+			raise ValueError(f"Invalid comment backend name: '{name}'")
 
 		self.target = target
 		self.tagger = tagger or ""
@@ -254,7 +265,9 @@ class Comment:
 			else:
 				object_type = "commit"
 		raw = _build_tag_raw(self.target, self.name, self.tagger, self.message, object_type=object_type)
-		return repo.odb.write(GIT_OBJECT_TAG, raw)
+		oid = repo.odb.write(GIT_OBJECT_TAG, raw)
+		repo.references.create(self.name, oid, force=True)
+		return oid
 
 
 def get_comment(repo: Repository, oid: Oid) -> Comment | None:
diff --git a/distgit/test_tasks.py b/distgit/test_tasks.py
new file mode 100644
index 0000000..820ee29
--- /dev/null
+++ b/distgit/test_tasks.py
@@ -0,0 +1,203 @@
+import subprocess
+from pathlib import Path
+
+import pytest
+from pygit2 import Oid, Repository, init_repository
+
+from distgit.tasks import (
+	BOARD_REF_PREFIX,
+	EMPTY_TREE_OID_HEX,
+	TASK_REF_PREFIX,
+	Board,
+	Comment,
+	Task,
+	get_board,
+	get_comment,
+	get_task,
+	get_task_by_oid,
+)
+
+TAGGER: str = "alice <alice@example.com>"
+BOARD_MAIN: str = f"{BOARD_REF_PREFIX}main"
+
+
+@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 _empty_tree() -> Oid:
+	return Oid(hex=EMPTY_TREE_OID_HEX)
+
+
+def _task_ref(slug: str) -> str:
+	return f"{TASK_REF_PREFIX}{slug}"
+
+
+def _run_git_gc(repo: Repository) -> None:
+	subprocess.run(
+		["git", "gc", "--prune=now"],
+		cwd=repo.workdir,
+		capture_output=True,
+		text=True,
+		check=True,
+	)
+
+
+def test_board_roundtrip(repo: Repository) -> None:
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main board")
+	board.write(repo)
+
+	loaded = get_board(repo, BOARD_MAIN)
+	assert loaded is not None
+	assert loaded.name == BOARD_MAIN
+	assert loaded.description == "Main board"
+	assert loaded.tasks == []
+
+
+def test_task_roundtrip_by_ref_and_oid(repo: Repository) -> None:
+	task = Task(
+		_empty_tree(),
+		_task_ref("alpha"),
+		TAGGER,
+		title="Alpha",
+		description="First task",
+		status=Task.Status.TODO,
+		priority=Task.Priority.HIGH,
+		assignee="bob",
+	)
+	oid = task.write(repo)
+
+	by_ref = get_task(repo, _task_ref("alpha"))
+	assert by_ref is not None
+	assert by_ref.title == "Alpha"
+	assert by_ref.description == "First task"
+	assert by_ref.status == Task.Status.TODO
+	assert by_ref.priority == Task.Priority.HIGH
+	assert by_ref.assignee == "bob"
+
+	by_oid = get_task_by_oid(repo, oid)
+	assert by_oid is not None
+	assert by_oid.title == "Alpha"
+	assert by_oid.status == Task.Status.TODO
+
+
+def test_comment_roundtrip(repo: Repository) -> None:
+	task = Task(_empty_tree(), _task_ref("with-comment"), TAGGER, title="WithComment")
+	task_oid = task.write(repo)
+
+	comment = Comment(task_oid, "bob <bob@example.com>", content="Hello there")
+	c_oid = comment.write(repo)
+
+	loaded = get_comment(repo, c_oid)
+	assert loaded is not None
+	assert loaded.content == "Hello there"
+	assert loaded.target == task_oid
+
+
+def test_full_board_flow(repo: Repository) -> None:
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
+
+	task_a = Task(
+		_empty_tree(),
+		_task_ref("a"),
+		TAGGER,
+		title="Task A",
+		description="Desc A",
+		status=Task.Status.TODO,
+		priority=Task.Priority.HIGH,
+	)
+	task_a_oid = task_a.write(repo)
+
+	task_b = Task(
+		_empty_tree(),
+		_task_ref("b"),
+		TAGGER,
+		title="Task B",
+		description="Desc B",
+		status=Task.Status.IN_PROGRESS,
+		priority=Task.Priority.MEDIUM,
+	)
+	task_b_oid = task_b.write(repo)
+
+	comment_a1 = Comment(task_a_oid, "bob <bob@example.com>", content="A1")
+	a1_oid = comment_a1.write(repo)
+	comment_a2 = Comment(task_a_oid, "carol <carol@example.com>", content="A2")
+	a2_oid = comment_a2.write(repo)
+
+	task_a.comments = [str(a1_oid), str(a2_oid)]
+	task_a.update_message()
+	task_a_oid = task_a.write(repo)
+
+	comment_b1 = Comment(task_b_oid, "dave <dave@example.com>", content="B1")
+	b1_oid = comment_b1.write(repo)
+
+	task_b.comments = [str(b1_oid)]
+	task_b.update_message()
+	task_b_oid = task_b.write(repo)
+
+	board.tasks = [str(task_a_oid), str(task_b_oid)]
+	board.update_message()
+	board.write(repo)
+
+	loaded_board = get_board(repo, BOARD_MAIN)
+	assert loaded_board is not None
+	assert len(loaded_board.tasks) == 2
+
+	loaded_a = get_task_by_oid(repo, Oid(hex=loaded_board.tasks[0]))
+	assert loaded_a is not None
+	assert loaded_a.title == "Task A"
+	assert loaded_a.priority == Task.Priority.HIGH
+	assert [get_comment(repo, Oid(hex=c)).content for c in loaded_a.comments] == ["A1", "A2"]
+
+	loaded_b = get_task_by_oid(repo, Oid(hex=loaded_board.tasks[1]))
+	assert loaded_b is not None
+	assert loaded_b.title == "Task B"
+	assert loaded_b.status == Task.Status.IN_PROGRESS
+	assert [get_comment(repo, Oid(hex=c)).content for c in loaded_b.comments] == ["B1"]
+
+
+def test_full_board_flow_survives_git_gc(repo: Repository, tmp_path: Path) -> None:
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
+
+	task = Task(
+		_empty_tree(),
+		_task_ref("gc-test"),
+		TAGGER,
+		title="GC Task",
+		description="persists across gc",
+		status=Task.Status.TODO,
+		priority=Task.Priority.CRITICAL,
+	)
+	task_oid = task.write(repo)
+
+	c1_oid = Comment(task_oid, "bob <bob@example.com>", content="first").write(repo)
+	c2_oid = Comment(task_oid, "carol <carol@example.com>", content="second").write(repo)
+
+	task.comments = [str(c1_oid), str(c2_oid)]
+	task.update_message()
+	task_oid = task.write(repo)
+
+	board.tasks = [str(task_oid)]
+	board.update_message()
+	board.write(repo)
+
+	_run_git_gc(repo)
+
+	# Re-open repo to avoid any in-memory odb caches from the pre-gc instance.
+	reopened = Repository(repo.path)
+
+	loaded_board = get_board(reopened, BOARD_MAIN)
+	assert loaded_board is not None
+	assert len(loaded_board.tasks) == 1
+
+	loaded_task = get_task_by_oid(reopened, Oid(hex=loaded_board.tasks[0]))
+	assert loaded_task is not None
+	assert loaded_task.title == "GC Task"
+	assert loaded_task.priority == Task.Priority.CRITICAL
+	assert len(loaded_task.comments) == 2
+
+	contents = [get_comment(reopened, Oid(hex=c)).content for c in loaded_task.comments]
+	assert contents == ["first", "second"]
diff --git a/pygitweb/main.py b/pygitweb/main.py
index f4ac2cf..c4eebdf 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -217,7 +217,7 @@ def git_project_list(
 		path = pr.get("path", "")
 		path_enc = quote(path, safe="/")
 		try:
-			board_refs = git_get_references(path, "refs/boards")
+			board_refs = git_get_references(path, "refs/tags/boards")
 			has_boards = len(board_refs) > 0
 		except Exception:
 			has_boards = False
@@ -285,7 +285,7 @@ def board_create_page(
 	request: Request,
 	project: Annotated[str | None, Query(alias="p")] = None,
 ) -> RedirectResponse:
-	"""Create a board named 'Tasks' (refs/boards/Tasks) and redirect to the project summary."""
+	"""Create a board named 'Tasks' (refs/tags/boards/Tasks) and redirect to the project summary."""
 	if DISTGIT_AUTH != "None":
 		raise HTTPException(status_code=401, detail="Authentication required")
 	p = project or request.query_params.get("project")
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index f2cc8bc..77d31d7 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -13,6 +13,8 @@ from fastapi import APIRouter, HTTPException, Query, Request
 from fastapi.responses import JSONResponse
 
 from distgit.tasks import (
+	BOARD_REF_PREFIX,
+	TASK_REF_PREFIX,
 	Board,
 	Comment,
 	Task,
@@ -26,8 +28,6 @@ from pygitweb.git_helpers import git_get_references, open_repo
 from pygitweb.projects import git_get_projects_list
 from pygitweb.validation import is_valid_project
 
-BOARD_REF_PREFIX = "refs/boards/"
-TASK_REF_PREFIX = "refs/tasks/"
 # Well-known empty tree OID for boards/tasks when repo has no HEAD
 EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
 
@@ -105,7 +105,7 @@ def boards_list(
 	_require_auth_disabled()
 	_validate_project(project)
 	repo = open_repo(project)
-	refs = git_get_references(project, "refs/boards")
+	refs = git_get_references(project, BOARD_REF_PREFIX.rstrip("/"))
 	boards = []
 	for ref_name, _ in refs:
 		try:
@@ -304,7 +304,7 @@ async def task_update(
 	request: Request,
 	project: str = Query(..., description="Project path"),
 	board: str = Query(..., description="Board name"),
-	task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tasks/task_123)"),
+	task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
 	"""Update a task (body: optional title, description, status, priority, assignee, due_date)."""
 	_require_auth_disabled()
@@ -396,7 +396,7 @@ def comment_list(
 	request: Request,
 	project: str = Query(..., description="Project path"),
 	board: str = Query(..., description="Board name"),
-	task: str = Query(..., description="Task ref (e.g. refs/tasks/task_123)"),
+	task: str = Query(..., description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
 	"""List all comments for a task."""
 	_require_auth_disabled()
diff --git a/pyproject.toml b/pyproject.toml
index f38f3e4..79a50e3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ requires-python = ">=3.11"
 dependencies = []
 
 [dependency-groups]
-dev = ["ruff"]
+dev = ["ruff", "pytest"]
 
 [tool.uv.workspace]
 members = ["postgit", "distgit", "pygitweb"]
diff --git a/uv.lock b/uv.lock
index d0bc605..94073bf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -159,13 +159,17 @@ source = { virtual = "." }
 
 [package.dev-dependencies]
 dev = [
+    { name = "pytest" },
     { name = "ruff" },
 ]
 
 [package.metadata]
 
 [package.metadata.requires-dev]
-dev = [{ name = "ruff" }]
+dev = [
+    { name = "pytest" },
+    { name = "ruff" },
+]
 
 [[package]]
 name = "dnspython"
@@ -319,6 +323,15 @@ wheels = [
 ]
 
 [[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
 name = "jinja2"
 version = "3.1.6"
 source = { registry = "https://pypi.org/simple" }
@@ -494,6 +507,24 @@ wheels = [
 ]
 
 [[package]]
+name = "packaging"
+version = "26.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
 name = "postgit"
 version = "0.1.0"
 source = { editable = "postgit" }
@@ -826,6 +857,22 @@ wheels = [
 ]
 
 [[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
 name = "python-dotenv"
 version = "1.2.2"
 source = { registry = "https://pypi.org/simple" }
