diff --git a/distgit/README.md b/distgit/README.md
deleted file mode 100644
index b5a5002..0000000
--- a/distgit/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# DistGit (Core)
-
-This library is all you need to run a distgit server to authenticate and communicate with the universe of servers.
-
-Running distgit alone will not provide a web viewer for your projects.
diff --git a/distgit/auth.py b/distgit/auth.py
deleted file mode 100644
index 0cf786a..0000000
--- a/distgit/auth.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-Root auth provider: base settings and interface common to all auth methods.
-"""
-
-from __future__ import annotations
-
-import hmac
-import secrets
-import time
-from datetime import timedelta
-
-
-class RootAuthProvider:
-	"""
-	Base auth provider with admin credentials, session timeout, and session create/validate.
-	Subclass or use as-is for simple admin user/password auth.
-	"""
-
-	def __init__(
-		self,
-		*,
-		admin_user: bytes | None = None,
-		admin_password: bytes | None = None,
-		session_timeout: timedelta | float | None = None,  # duration; None = no expiry
-	) -> None:
-		"""
-		admin_user: optional admin username (bytes). If None, no admin login is accepted.
-		admin_password: optional admin password (bytes). If None, no admin login is accepted.
-		session_timeout: optional duration in seconds (float) or timedelta. If None, sessions do not expire.
-		"""
-		self.admin_user = admin_user
-		self.admin_password = admin_password
-		if session_timeout is not None and hasattr(session_timeout, "total_seconds"):
-			self._timeout_seconds = session_timeout.total_seconds()
-		else:
-			self._timeout_seconds = session_timeout  # float or None
-		self._sessions: dict[str, float] = {}  # token -> created_at (monotonic or epoch)
-
-	def create_session(
-		self,
-		*,
-		user: bytes | None = None,
-		password: bytes | None = None,
-	) -> str | None:
-		"""
-		Authenticate with user/password and create a session if valid.
-		Returns a session token or None if credentials are missing or invalid.
-		"""
-		if self.admin_user is None or self.admin_password is None:
-			return None
-		if user is None or password is None:
-			return None
-		if not hmac.compare_digest(user, self.admin_user) or not hmac.compare_digest(password, self.admin_password):
-			return None
-		token = secrets.token_urlsafe(32)
-		self._sessions[token] = time.monotonic()
-		return token
-
-	def validate_session(self, session_token: str) -> bool:
-		"""
-		Return True if the session token exists and (when session_timeout is set) is not expired.
-		"""
-		if not session_token:
-			return False
-		created = self._sessions.get(session_token)
-		if created is None:
-			return False
-		if self._timeout_seconds is not None and time.monotonic() - created > self._timeout_seconds:
-			del self._sessions[session_token]
-			return False
-		return True
diff --git a/distgit/hook_samples/README.md b/distgit/hook_samples/README.md
deleted file mode 100644
index c8f3d9f..0000000
--- a/distgit/hook_samples/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Sample Hooks
-
-Place these as desired in .git/hooks/ WITHOUT the suffix.
-
-You will just need UV with distgit in your virtual environment.
diff --git a/distgit/hook_samples/commit-msg.pattern b/distgit/hook_samples/commit-msg.pattern
deleted file mode 100644
index 642afbb..0000000
--- a/distgit/hook_samples/commit-msg.pattern
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env -S uv run python
-
-from __future__ import annotations
-import sys
-import pygit2
-from distgit.hooks import HookResult
-from distgit.hooks_patterns import CommitMsgPattern
-
-PATTERN = r"^(\S+): (.+)"
-
-
-def main() -> int:
-    repo = pygit2.Repository(".")
-    result = CommitMsgPattern(repo, PATTERN).run(sys.argv[1])
-    if result.value == HookResult.FAILURE:
-        print("Commit message does not match pattern:", PATTERN, file=sys.stderr)
-    return int(result.value)
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())
diff --git a/distgit/hook_samples/pre-commit.ruff b/distgit/hook_samples/pre-commit.ruff
deleted file mode 100644
index 4739e3c..0000000
--- a/distgit/hook_samples/pre-commit.ruff
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/env -S uv run python
-
-from __future__ import annotations
-import pygit2
-from distgit.hooks_ruff import PreCommitRuff
-
-
-def main() -> int:
-    repo = pygit2.Repository(".")
-    result = PreCommitRuff(repo).run()
-    return int(result.value)
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())
diff --git a/distgit/hooks.py b/distgit/hooks.py
deleted file mode 100644
index 108af4c..0000000
--- a/distgit/hooks.py
+++ /dev/null
@@ -1,284 +0,0 @@
-from enum import Enum
-
-import pygit2
-
-
-class HookResult(Enum):
-	SUCCESS = 0
-	FAILURE = 1
-
-
-class Hook:
-	def __init__(self, repo: pygit2.Repository):
-		self.repo = repo
-
-
-"""
-Pre-Commit Hook (Client-side)
-Runs before a commit is made, before a commit message is written (if not supplied by -m).
-Use this hook to:
-- Check for uncommitted changes
-- Run tests, lints, security checks, etc.
-This can be bypassed with --no-verify by the user.
-"""
-
-
-class PreCommit(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Prepare-Commit-Msg Hook (Client-side)
-Runs right after the default log message is prepared and before the editor is started.
-Use this hook to:
-- Edit the message file in place (e.g. strip template comments)
-- Insert a standard prefix/suffix (e.g. branch name, ticket ID)
-- Add Signed-off-by from a template
-Takes 1–3 parameters: message file path, source (message|template|merge|squash|commit),
-and optionally commit hash for amend.
-"""
-
-
-class PrepareCommitMsg(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(
-		self,
-		message_file: str,
-		source: str = "",
-		commit_hash: str | None = None,
-	) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Commit-Msg Hook (Client-side)
-Runs after the commit message is prepared; can be bypassed with --no-verify.
-Use this hook to:
-- Enforce a project standard format (e.g. conventional commits)
-- Validate or normalize the message in place
-- Reject the commit (e.g. duplicate Signed-off-by, missing ticket reference)
-Takes one parameter: the path to the file holding the proposed commit log message.
-"""
-
-
-class CommitMsg(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self, message_file: str) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Post-Commit Hook (Client-side)
-Runs after a commit is made. Cannot affect the outcome of git commit.
-Use this hook to:
-- Notify (e.g. log, webhook, chat)
-- Run post-commit checks or backups
-- Update external metadata or caches
-"""
-
-
-class PostCommit(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Pre-Merge-Commit Hook (Client-side)
-Runs after a merge has been carried out successfully and before the merge commit
-message is finalized; can be bypassed with --no-verify.
-Use this hook to:
-- Validate the merged tree (e.g. run tests on the result)
-- Inspect or adjust the merge commit message
-- Abort the merge commit if checks fail
-Takes no parameters.
-"""
-
-
-class PreMergeCommit(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Pre-Rebase Hook (Client-side)
-Called by git rebase; can be used to prevent a branch from being rebased.
-Use this hook to:
-- Block rebasing certain branches (e.g. main)
-- Run checks before rewriting history
-Takes one or two parameters: upstream ref, and optionally the branch being rebased
-(absent when rebasing the current branch).
-"""
-
-
-class PreRebase(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self, upstream: str, branch: str | None = None) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Post-Checkout Hook (Client-side)
-Runs after git checkout, git switch, or git clone (when a worktree is updated).
-Use this hook to:
-- Restore working tree metadata (e.g. permissions, ACLs)
-- Auto-display differences from the previous HEAD
-- Run repository validity checks or refresh generated files
-Takes three parameters: previous HEAD ref, new HEAD ref, and a flag (1 = branch checkout, 0 = file checkout).
-"""
-
-
-class PostCheckout(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self, prev_head: str, new_head: str, branch_checkout: str) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Post-Merge Hook (Client-side)
-Runs after a successful git merge (e.g. after git pull). Cannot affect the outcome.
-Use this hook to:
-- Restore working tree metadata in conjunction with pre-commit
-- Run post-merge checks or notifications
-Takes one parameter: a status flag indicating whether the merge was a squash merge.
-"""
-
-
-class PostMerge(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self, squash: str) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Pre-Push Hook (Client-side)
-Called by git push; can be used to prevent a push.
-Use this hook to:
-- Run tests or lint before pushing
-- Enforce branch naming or ref permissions
-- Validate commits being pushed
-Takes two parameters: remote name and remote URL. Ref updates are provided on stdin.
-"""
-
-
-class PrePush(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self, remote_name: str, remote_url: str) -> HookResult:
-		return HookResult.SUCCESS
-
-
-# -----------------------------------------------------------------------------
-# Server-side hooks (run in $GIT_DIR on receive-pack / push)
-# -----------------------------------------------------------------------------
-
-"""
-Update Hook (Server-side)
-Invoked by git-receive-pack once per ref being updated, before the ref is updated.
-Use this hook to:
-- Enforce fast-forward only (reject non-FF updates)
-- Implement per-ref access control
-- Log or validate old → new for specific refs
-Takes three parameters: ref name, old object name, new object name.
-"""
-
-
-class Update(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Post-Update Hook (Server-side)
-Invoked by git-receive-pack once after all refs have been updated.
-Use this hook to:
-- Notify or trigger CI for updated refs
-- Run git update-server-info for dumb transports (e.g. HTTP)
-- Update caches or derived data
-Takes a variable number of parameters: the name of each ref that was updated.
-"""
-
-
-class PostUpdate(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self, *ref_names: str) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Push-To-Checkout Hook (Server-side)
-Invoked when a push updates the currently checked-out branch and receive.denyCurrentBranch is updateInstead.
-Use this hook to:
-- Override how the working tree and index are updated to match the new commit
-- Run git read-tree -u -m to emulate a reverse fetch
-- Refuse the push by exiting non-zero (without modifying index or worktree)
-Takes one parameter: the commit object name the tip of the current branch will be updated to.
-"""
-
-
-class PushToCheckout(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self, new_commit: str) -> HookResult:
-		return HookResult.SUCCESS
-
-
-"""
-Pre-Auto-GC Hook
-Invoked by git gc --auto before automatic garbage collection runs.
-Use this hook to:
-- Prevent or delay gc when the repo is busy (e.g. long-running operations)
-- Run housekeeping or consistency checks before gc
-- Notify or log that auto-gc is about to run
-Takes no parameters. Exiting with non-zero status prevents gc from running.
-"""
-
-
-class PreAutoGc(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
-
-	def run(self) -> HookResult:
-		return HookResult.SUCCESS
-
-
-# -----------------------------------------------------------------------------
-# Hooks skipped (not implemented in this module)
-# -----------------------------------------------------------------------------
-#
-# E-mail / git-am hooks (skipped by design):
-#   - applypatch-msg   (message file; used by git am)
-#   - pre-applypatch   (no params; used by git am)
-#   - post-applypatch  (no params; used by git am)
-#
-# Stdin-only or protocol hooks (skipped: no string parameters to pass to run()):
-#   - pre-receive         (no args; ref updates on stdin)
-#   - post-receive        (no args; ref updates on stdin)
-#   - reference-transaction (state string + ref updates on stdin)
-#   - proc-receive        (pkt-line protocol on stdin/stdout)
diff --git a/distgit/hooks_patterns.py b/distgit/hooks_patterns.py
deleted file mode 100644
index b88b90c..0000000
--- a/distgit/hooks_patterns.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import re
-from sys import stderr
-
-import pygit2
-
-from distgit.hooks import CommitMsg, Hook, HookResult
-
-"""
-Commit-Msg Pattern Hook (Client-side)
-Validates the commit message against a regular expression.
-"""
-
-
-class CommitMsgPattern(CommitMsg):
-	def __init__(self, repo: pygit2.Repository, pattern: str):
-		super().__init__(repo)
-		self.exp = re.compile(pattern)
-
-	def run(self, message_file: str) -> HookResult:
-		with open(message_file) as f:
-			message = f.read()
-		if not self.exp.match(message):
-			stderr.write(f"Commit message does not match pattern: {self.exp.pattern}\n")
-			return HookResult.FAILURE
-		return HookResult.SUCCESS
-
-
-"""
-Update Pattern Hook (Server-side)
-Invoked by git-receive-pack once per ref being updated, before the ref is updated.
-Use this hook to:
-- Enforce fast-forward only (reject non-FF updates)
-- Implement per-ref access control
-- Log or validate old → new for specific refs
-Takes three parameters: ref name, old object name, new object name.
-"""
-
-
-class UpdatePattern(Hook):
-	def __init__(self, repo: pygit2.Repository, ref_pattern: str, msg_pattern: str):
-		super().__init__(repo)
-		self.ref_exp = re.compile(ref_pattern)
-		self.msg_exp = re.compile(msg_pattern)
-
-	def run(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
-		if self.ref_exp.match(ref_name):
-			commit = self.repo.revparse_single(new_oid).peel(pygit2.Commit)
-			if not self.msg_exp.match(commit.message):
-				stderr.write(f"Commit message does not match pattern: {self.msg_exp.pattern}\n")
-				return HookResult.FAILURE
-		return HookResult.SUCCESS
diff --git a/distgit/hooks_ruff.py b/distgit/hooks_ruff.py
deleted file mode 100644
index a2be5f4..0000000
--- a/distgit/hooks_ruff.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import json
-import subprocess
-import sys
-from pathlib import Path
-from sys import stderr
-from typing import Any
-
-import pygit2
-
-from distgit.hooks import HookResult, PreCommit
-
-"""
-Pre-commit hook: run Ruff lint and format checks with machine-readable JSON on stdout.
-
-Uses ``ruff check --output-format=json`` and
-``ruff format --check --preview --output-format=json`` (preview is required for JSON on ``format``).
-Parses JSON and prints a short summary to stderr on failure.
-"""
-
-
-def _ruff_base() -> list[str]:
-	return [sys.executable, "-m", "ruff"]
-
-
-def _parse_diagnostic_list(raw: str) -> list[dict[str, Any]]:
-	s = raw.strip()
-	if not s:
-		return []
-	data = json.loads(s)
-	if isinstance(data, list):
-		return [x for x in data if isinstance(x, dict)]
-	return []
-
-
-def _format_location(loc: object) -> str:
-	if not isinstance(loc, dict):
-		return "?:?"
-	row = loc.get("row", "?")
-	col = loc.get("column", "?")
-	return f"{row}:{col}"
-
-
-def _summarize_diagnostics(label: str, rows: list[dict[str, Any]]) -> str:
-	lines = [f"{label}:"]
-	for row in rows:
-		fn = row.get("filename", "?")
-		msg = row.get("message", row.get("code", "?"))
-		loc = row.get("location")
-		lines.append(f"  {fn}:{_format_location(loc)}: {msg}")
-	return "\n".join(lines)
-
-
-def _run_ruff_json(
-	args: list[str],
-	cwd: Path,
-) -> tuple[int, list[dict[str, Any]], str]:
-	proc = subprocess.run(
-		_ruff_base() + args,
-		cwd=cwd,
-		capture_output=True,
-		text=True,
-	)
-	stderr_extra = proc.stderr or ""
-	try:
-		rows = _parse_diagnostic_list(proc.stdout)
-	except json.JSONDecodeError:
-		return (
-			1,
-			[],
-			f"invalid JSON from ruff (stdout):\n{proc.stdout!r}\nstderr:\n{stderr_extra}",
-		)
-	return proc.returncode, rows, stderr_extra
-
-
-class PreCommitRuff(PreCommit):
-	def __init__(self, repo: pygit2.Repository, paths: list[str] | None = None):
-		super().__init__(repo)
-		self.paths = paths if paths is not None else ["."]
-
-	def run(self) -> HookResult:
-		workdir = self.repo.workdir
-		if workdir is None:
-			stderr.write("PreCommitRuff: bare repository has no working tree; skipping ruff.\n")
-			return HookResult.SUCCESS
-		root = Path(workdir)
-
-		rc, rows, extra = _run_ruff_json(["check", "--output-format=json", *self.paths], root)
-		if rc != 0:
-			stderr.write(_summarize_diagnostics("ruff check", rows) + "\n")
-			if extra.strip():
-				stderr.write(extra)
-			return HookResult.FAILURE
-
-		rc, rows, extra = _run_ruff_json(
-			["format", "--check", "--preview", "--output-format=json", *self.paths],
-			root,
-		)
-		if rc != 0:
-			stderr.write(_summarize_diagnostics("ruff format", rows) + "\n")
-			if extra.strip():
-				stderr.write(extra)
-			return HookResult.FAILURE
-
-		return HookResult.SUCCESS
diff --git a/distgit/pyproject.toml b/distgit/pyproject.toml
deleted file mode 100644
index 1124dcf..0000000
--- a/distgit/pyproject.toml
+++ /dev/null
@@ -1,19 +0,0 @@
-[build-system]
-requires = ["setuptools>=61", "wheel"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "distgit"
-version = "0.1.0"
-description = "DistGit hooks, auth, and task storage in Git"
-readme = "README.md"
-requires-python = ">=3.11"
-dependencies = [
-    "pygit2>=1.12.0",
-]
-
-[tool.setuptools]
-packages = ["distgit"]
-
-[tool.setuptools.package-dir]
-distgit = "."
diff --git a/distgit/tasks.py b/distgit/tasks.py
index 7cd5512..a0efc54 100644
--- a/distgit/tasks.py
+++ b/distgit/tasks.py
@@ -45,8 +45,8 @@ def _tagger_str(tagger: str | Signature | None) -> str:
 	if isinstance(tagger, str):
 		if tagger and " <" in tagger and ">" in tagger:
 			return tagger + " 0 +0000"
-		return (tagger or "distgit <distgit@local>") + " 0 +0000"
-	return "distgit <distgit@local> 0 +0000"
+		return (tagger or "pygittools <pygittools@local>") + " 0 +0000"
+	return "pygittools <pygittools@local> 0 +0000"
 
 
 def _build_tag_raw(
diff --git a/distgit/test_tasks.py b/distgit/test_tasks.py
deleted file mode 100644
index 820ee29..0000000
--- a/distgit/test_tasks.py
+++ /dev/null
@@ -1,203 +0,0 @@
-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/postgit/README.md b/postgit/README.md
index 8f73dbe..9e31641 100644
--- a/postgit/README.md
+++ b/postgit/README.md
@@ -3,7 +3,7 @@
 Tool for transferring data between Git (Git's Object DB) and Postgres.
 - Use git history to populate tables and audit tables
 - Use audit tables to populate git history
-- Repo settings, notes, and other misc. info can be stored (and read by distgit)
+- Repo settings, notes, and other misc. info can be stored (and read by pygittools)
 
 Fundamentally, a git repo unpacks into a single schema with the following tables:
 - objects
diff --git a/postgit/__init__.py b/postgit/__init__.py
index 567b9b4..807704e 100644
--- a/postgit/__init__.py
+++ b/postgit/__init__.py
@@ -29,3 +29,14 @@ __all__ = [
 	"register_adapters_async",
 	"regtype_git_object_type",
 ]
+
+import postgit.__meta__
+
+"""
+Postgres serializaion and object backing for git repos
+"""
+__version__ = postgit.__meta__.__version__
+__author__ = postgit.__meta__.__author__
+__description__ = postgit.__meta__.__description__
+__url__ = postgit.__meta__.__url__
+__license__ = postgit.__meta__.__license__
diff --git a/postgit/__meta__.py b/postgit/__meta__.py
new file mode 100644
index 0000000..96f8eda
--- /dev/null
+++ b/postgit/__meta__.py
@@ -0,0 +1,9 @@
+"""
+Metadata for PostGit - this is the canonical source of all information below.
+"""
+
+__version__ = "0.1.0"
+__author__ = "Will Bowers"
+__license__ = "Apache 2.0"  # This may change before being distributed.
+__description__ = "Postgres serializaion and object backing for git repos"
+__url__ = "https://pygitweb.com"
diff --git a/pygittools/README.md b/pygittools/README.md
new file mode 100644
index 0000000..84e5e18
--- /dev/null
+++ b/pygittools/README.md
@@ -0,0 +1,3 @@
+# PyGitTools
+
+Authentication, hooks, and pygit2 workflows used by PyGitWeb. Can be used headless for many purposes.
diff --git a/pygittools/__init__.py b/pygittools/__init__.py
new file mode 100644
index 0000000..72f8f6d
--- /dev/null
+++ b/pygittools/__init__.py
@@ -0,0 +1,10 @@
+import pygittools.__meta__
+
+"""
+Python hooks, auth, and metadata storage in Git
+"""
+__version__ = pygittools.__meta__.__version__
+__author__ = pygittools.__meta__.__author__
+__description__ = pygittools.__meta__.__description__
+__url__ = pygittools.__meta__.__url__
+__license__ = pygittools.__meta__.__license__
diff --git a/pygittools/__meta__.py b/pygittools/__meta__.py
new file mode 100644
index 0000000..48cc8d5
--- /dev/null
+++ b/pygittools/__meta__.py
@@ -0,0 +1,9 @@
+"""
+Metadata for PyGitTools - this is the canonical source of all information below.
+"""
+
+__version__ = "0.1.0"
+__author__ = "Will Bowers"
+__license__ = "Apache 2.0"  # This may change before being distributed.
+__description__ = "Python hooks, auth, and metadata storage in Git"
+__url__ = "https://pygitweb.com"
diff --git a/pygittools/auth.py b/pygittools/auth.py
new file mode 100644
index 0000000..0cf786a
--- /dev/null
+++ b/pygittools/auth.py
@@ -0,0 +1,71 @@
+"""
+Root auth provider: base settings and interface common to all auth methods.
+"""
+
+from __future__ import annotations
+
+import hmac
+import secrets
+import time
+from datetime import timedelta
+
+
+class RootAuthProvider:
+	"""
+	Base auth provider with admin credentials, session timeout, and session create/validate.
+	Subclass or use as-is for simple admin user/password auth.
+	"""
+
+	def __init__(
+		self,
+		*,
+		admin_user: bytes | None = None,
+		admin_password: bytes | None = None,
+		session_timeout: timedelta | float | None = None,  # duration; None = no expiry
+	) -> None:
+		"""
+		admin_user: optional admin username (bytes). If None, no admin login is accepted.
+		admin_password: optional admin password (bytes). If None, no admin login is accepted.
+		session_timeout: optional duration in seconds (float) or timedelta. If None, sessions do not expire.
+		"""
+		self.admin_user = admin_user
+		self.admin_password = admin_password
+		if session_timeout is not None and hasattr(session_timeout, "total_seconds"):
+			self._timeout_seconds = session_timeout.total_seconds()
+		else:
+			self._timeout_seconds = session_timeout  # float or None
+		self._sessions: dict[str, float] = {}  # token -> created_at (monotonic or epoch)
+
+	def create_session(
+		self,
+		*,
+		user: bytes | None = None,
+		password: bytes | None = None,
+	) -> str | None:
+		"""
+		Authenticate with user/password and create a session if valid.
+		Returns a session token or None if credentials are missing or invalid.
+		"""
+		if self.admin_user is None or self.admin_password is None:
+			return None
+		if user is None or password is None:
+			return None
+		if not hmac.compare_digest(user, self.admin_user) or not hmac.compare_digest(password, self.admin_password):
+			return None
+		token = secrets.token_urlsafe(32)
+		self._sessions[token] = time.monotonic()
+		return token
+
+	def validate_session(self, session_token: str) -> bool:
+		"""
+		Return True if the session token exists and (when session_timeout is set) is not expired.
+		"""
+		if not session_token:
+			return False
+		created = self._sessions.get(session_token)
+		if created is None:
+			return False
+		if self._timeout_seconds is not None and time.monotonic() - created > self._timeout_seconds:
+			del self._sessions[session_token]
+			return False
+		return True
diff --git a/pygittools/hook_samples/README.md b/pygittools/hook_samples/README.md
new file mode 100644
index 0000000..250ed5b
--- /dev/null
+++ b/pygittools/hook_samples/README.md
@@ -0,0 +1,5 @@
+# Sample Hooks
+
+Place these as desired in .git/hooks/ WITHOUT the suffix.
+
+You will just need UV with pygittools in your virtual environment.
diff --git a/pygittools/hook_samples/commit-msg.pattern b/pygittools/hook_samples/commit-msg.pattern
new file mode 100644
index 0000000..0380472
--- /dev/null
+++ b/pygittools/hook_samples/commit-msg.pattern
@@ -0,0 +1,21 @@
+#!/usr/bin/env -S uv run python
+
+from __future__ import annotations
+import sys
+import pygit2
+from pygittools.hooks import HookResult
+from pygittools.hooks_patterns import CommitMsgPattern
+
+PATTERN = r"^(\S+): (.+)"
+
+
+def main() -> int:
+    repo = pygit2.Repository(".")
+    result = CommitMsgPattern(repo, PATTERN).run(sys.argv[1])
+    if result.value == HookResult.FAILURE:
+        print("Commit message does not match pattern:", PATTERN, file=sys.stderr)
+    return int(result.value)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/pygittools/hook_samples/pre-commit.ruff b/pygittools/hook_samples/pre-commit.ruff
new file mode 100644
index 0000000..033f70f
--- /dev/null
+++ b/pygittools/hook_samples/pre-commit.ruff
@@ -0,0 +1,15 @@
+#!/usr/bin/env -S uv run python
+
+from __future__ import annotations
+import pygit2
+from pygittools.hooks_ruff import PreCommitRuff
+
+
+def main() -> int:
+    repo = pygit2.Repository(".")
+    result = PreCommitRuff(repo).run()
+    return int(result.value)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/pygittools/hooks.py b/pygittools/hooks.py
new file mode 100644
index 0000000..53ebf0e
--- /dev/null
+++ b/pygittools/hooks.py
@@ -0,0 +1,285 @@
+from dataclasses import dataclass
+from enum import Enum
+
+import pygit2
+
+
+class HookResult(Enum):
+	SUCCESS = 0
+	FAILURE = 1
+
+
+@dataclass
+class Hook:
+	repo: pygit2.Repository
+
+
+"""
+Pre-Commit Hook (Client-side)
+Runs before a commit is made, before a commit message is written (if not supplied by -m).
+Use this hook to:
+- Check for uncommitted changes
+- Run tests, lints, security checks, etc.
+This can be bypassed with --no-verify by the user.
+"""
+
+
+class PreCommit(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Prepare-Commit-Msg Hook (Client-side)
+Runs right after the default log message is prepared and before the editor is started.
+Use this hook to:
+- Edit the message file in place (e.g. strip template comments)
+- Insert a standard prefix/suffix (e.g. branch name, ticket ID)
+- Add Signed-off-by from a template
+Takes 1–3 parameters: message file path, source (message|template|merge|squash|commit),
+and optionally commit hash for amend.
+"""
+
+
+class PrepareCommitMsg(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(
+		self,
+		message_file: str,
+		source: str = "",
+		commit_hash: str | None = None,
+	) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Commit-Msg Hook (Client-side)
+Runs after the commit message is prepared; can be bypassed with --no-verify.
+Use this hook to:
+- Enforce a project standard format (e.g. conventional commits)
+- Validate or normalize the message in place
+- Reject the commit (e.g. duplicate Signed-off-by, missing ticket reference)
+Takes one parameter: the path to the file holding the proposed commit log message.
+"""
+
+
+class CommitMsg(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, message_file: str) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Post-Commit Hook (Client-side)
+Runs after a commit is made. Cannot affect the outcome of git commit.
+Use this hook to:
+- Notify (e.g. log, webhook, chat)
+- Run post-commit checks or backups
+- Update external metadata or caches
+"""
+
+
+class PostCommit(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Pre-Merge-Commit Hook (Client-side)
+Runs after a merge has been carried out successfully and before the merge commit
+message is finalized; can be bypassed with --no-verify.
+Use this hook to:
+- Validate the merged tree (e.g. run tests on the result)
+- Inspect or adjust the merge commit message
+- Abort the merge commit if checks fail
+Takes no parameters.
+"""
+
+
+class PreMergeCommit(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Pre-Rebase Hook (Client-side)
+Called by git rebase; can be used to prevent a branch from being rebased.
+Use this hook to:
+- Block rebasing certain branches (e.g. main)
+- Run checks before rewriting history
+Takes one or two parameters: upstream ref, and optionally the branch being rebased
+(absent when rebasing the current branch).
+"""
+
+
+class PreRebase(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, upstream: str, branch: str | None = None) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Post-Checkout Hook (Client-side)
+Runs after git checkout, git switch, or git clone (when a worktree is updated).
+Use this hook to:
+- Restore working tree metadata (e.g. permissions, ACLs)
+- Auto-display differences from the previous HEAD
+- Run repository validity checks or refresh generated files
+Takes three parameters: previous HEAD ref, new HEAD ref, and a flag (1 = branch checkout, 0 = file checkout).
+"""
+
+
+class PostCheckout(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, prev_head: str, new_head: str, branch_checkout: str) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Post-Merge Hook (Client-side)
+Runs after a successful git merge (e.g. after git pull). Cannot affect the outcome.
+Use this hook to:
+- Restore working tree metadata in conjunction with pre-commit
+- Run post-merge checks or notifications
+Takes one parameter: a status flag indicating whether the merge was a squash merge.
+"""
+
+
+class PostMerge(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, squash: str) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Pre-Push Hook (Client-side)
+Called by git push; can be used to prevent a push.
+Use this hook to:
+- Run tests or lint before pushing
+- Enforce branch naming or ref permissions
+- Validate commits being pushed
+Takes two parameters: remote name and remote URL. Ref updates are provided on stdin.
+"""
+
+
+class PrePush(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, remote_name: str, remote_url: str) -> HookResult:
+		return HookResult.SUCCESS
+
+
+# -----------------------------------------------------------------------------
+# Server-side hooks (run in $GIT_DIR on receive-pack / push)
+# -----------------------------------------------------------------------------
+
+"""
+Update Hook (Server-side)
+Invoked by git-receive-pack once per ref being updated, before the ref is updated.
+Use this hook to:
+- Enforce fast-forward only (reject non-FF updates)
+- Implement per-ref access control
+- Log or validate old → new for specific refs
+Takes three parameters: ref name, old object name, new object name.
+"""
+
+
+class Update(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Post-Update Hook (Server-side)
+Invoked by git-receive-pack once after all refs have been updated.
+Use this hook to:
+- Notify or trigger CI for updated refs
+- Run git update-server-info for dumb transports (e.g. HTTP)
+- Update caches or derived data
+Takes a variable number of parameters: the name of each ref that was updated.
+"""
+
+
+class PostUpdate(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, *ref_names: str) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Push-To-Checkout Hook (Server-side)
+Invoked when a push updates the currently checked-out branch and receive.denyCurrentBranch is updateInstead.
+Use this hook to:
+- Override how the working tree and index are updated to match the new commit
+- Run git read-tree -u -m to emulate a reverse fetch
+- Refuse the push by exiting non-zero (without modifying index or worktree)
+Takes one parameter: the commit object name the tip of the current branch will be updated to.
+"""
+
+
+class PushToCheckout(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self, new_commit: str) -> HookResult:
+		return HookResult.SUCCESS
+
+
+"""
+Pre-Auto-GC Hook
+Invoked by git gc --auto before automatic garbage collection runs.
+Use this hook to:
+- Prevent or delay gc when the repo is busy (e.g. long-running operations)
+- Run housekeeping or consistency checks before gc
+- Notify or log that auto-gc is about to run
+Takes no parameters. Exiting with non-zero status prevents gc from running.
+"""
+
+
+class PreAutoGc(Hook):
+	def __init__(self, repo: pygit2.Repository):
+		super().__init__(repo)
+
+	def run(self) -> HookResult:
+		return HookResult.SUCCESS
+
+
+# -----------------------------------------------------------------------------
+# Hooks skipped (not implemented in this module)
+# -----------------------------------------------------------------------------
+#
+# E-mail / git-am hooks (skipped by design):
+#   - applypatch-msg   (message file; used by git am)
+#   - pre-applypatch   (no params; used by git am)
+#   - post-applypatch  (no params; used by git am)
+#
+# Stdin-only or protocol hooks (skipped: no string parameters to pass to run()):
+#   - pre-receive         (no args; ref updates on stdin)
+#   - post-receive        (no args; ref updates on stdin)
+#   - reference-transaction (state string + ref updates on stdin)
+#   - proc-receive        (pkt-line protocol on stdin/stdout)
diff --git a/pygittools/hooks_patterns.py b/pygittools/hooks_patterns.py
new file mode 100644
index 0000000..e35f30c
--- /dev/null
+++ b/pygittools/hooks_patterns.py
@@ -0,0 +1,51 @@
+import re
+from sys import stderr
+
+import pygit2
+
+from pygittools.hooks import CommitMsg, Hook, HookResult
+
+"""
+Commit-Msg Pattern Hook (Client-side)
+Validates the commit message against a regular expression.
+"""
+
+
+class CommitMsgPattern(CommitMsg):
+	def __init__(self, repo: pygit2.Repository, pattern: str):
+		super().__init__(repo)
+		self.exp = re.compile(pattern)
+
+	def run(self, message_file: str) -> HookResult:
+		with open(message_file) as f:
+			message = f.read()
+		if not self.exp.match(message):
+			stderr.write(f"Commit message does not match pattern: {self.exp.pattern}\n")
+			return HookResult.FAILURE
+		return HookResult.SUCCESS
+
+
+"""
+Update Pattern Hook (Server-side)
+Invoked by git-receive-pack once per ref being updated, before the ref is updated.
+Use this hook to:
+- Enforce fast-forward only (reject non-FF updates)
+- Implement per-ref access control
+- Log or validate old → new for specific refs
+Takes three parameters: ref name, old object name, new object name.
+"""
+
+
+class UpdatePattern(Hook):
+	def __init__(self, repo: pygit2.Repository, ref_pattern: str, msg_pattern: str):
+		super().__init__(repo)
+		self.ref_exp = re.compile(ref_pattern)
+		self.msg_exp = re.compile(msg_pattern)
+
+	def run(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
+		if self.ref_exp.match(ref_name):
+			commit = self.repo.revparse_single(new_oid).peel(pygit2.Commit)
+			if not self.msg_exp.match(commit.message):
+				stderr.write(f"Commit message does not match pattern: {self.msg_exp.pattern}\n")
+				return HookResult.FAILURE
+		return HookResult.SUCCESS
diff --git a/pygittools/hooks_ruff.py b/pygittools/hooks_ruff.py
new file mode 100644
index 0000000..bce53f2
--- /dev/null
+++ b/pygittools/hooks_ruff.py
@@ -0,0 +1,104 @@
+import json
+import subprocess
+import sys
+from pathlib import Path
+from sys import stderr
+from typing import Any
+
+import pygit2
+
+from pygittools.hooks import HookResult, PreCommit
+
+"""
+Pre-commit hook: run Ruff lint and format checks with machine-readable JSON on stdout.
+
+Uses ``ruff check --output-format=json`` and
+``ruff format --check --preview --output-format=json`` (preview is required for JSON on ``format``).
+Parses JSON and prints a short summary to stderr on failure.
+"""
+
+
+def _ruff_base() -> list[str]:
+	return [sys.executable, "-m", "ruff"]
+
+
+def _parse_diagnostic_list(raw: str) -> list[dict[str, Any]]:
+	s = raw.strip()
+	if not s:
+		return []
+	data = json.loads(s)
+	if isinstance(data, list):
+		return [x for x in data if isinstance(x, dict)]
+	return []
+
+
+def _format_location(loc: object) -> str:
+	if not isinstance(loc, dict):
+		return "?:?"
+	row = loc.get("row", "?")
+	col = loc.get("column", "?")
+	return f"{row}:{col}"
+
+
+def _summarize_diagnostics(label: str, rows: list[dict[str, Any]]) -> str:
+	lines = [f"{label}:"]
+	for row in rows:
+		fn = row.get("filename", "?")
+		msg = row.get("message", row.get("code", "?"))
+		loc = row.get("location")
+		lines.append(f"  {fn}:{_format_location(loc)}: {msg}")
+	return "\n".join(lines)
+
+
+def _run_ruff_json(
+	args: list[str],
+	cwd: Path,
+) -> tuple[int, list[dict[str, Any]], str]:
+	proc = subprocess.run(
+		_ruff_base() + args,
+		cwd=cwd,
+		capture_output=True,
+		text=True,
+	)
+	stderr_extra = proc.stderr or ""
+	try:
+		rows = _parse_diagnostic_list(proc.stdout)
+	except json.JSONDecodeError:
+		return (
+			1,
+			[],
+			f"invalid JSON from ruff (stdout):\n{proc.stdout!r}\nstderr:\n{stderr_extra}",
+		)
+	return proc.returncode, rows, stderr_extra
+
+
+class PreCommitRuff(PreCommit):
+	def __init__(self, repo: pygit2.Repository, paths: list[str] | None = None):
+		super().__init__(repo)
+		self.paths = paths if paths is not None else ["."]
+
+	def run(self) -> HookResult:
+		workdir = self.repo.workdir
+		if workdir is None:
+			stderr.write("PreCommitRuff: bare repository has no working tree; skipping ruff.\n")
+			return HookResult.SUCCESS
+		root = Path(workdir)
+
+		rc, rows, extra = _run_ruff_json(["check", "--output-format=json", *self.paths], root)
+		if rc != 0:
+			stderr.write(_summarize_diagnostics("ruff check", rows) + "\n")
+			if extra.strip():
+				stderr.write(extra)
+			return HookResult.FAILURE
+
+		rc, rows, extra = _run_ruff_json(
+			["format", "--check", "--preview", "--output-format=json", *self.paths],
+			root,
+		)
+		if rc != 0:
+			stderr.write(_summarize_diagnostics("ruff format", rows) + "\n")
+			if extra.strip():
+				stderr.write(extra)
+			return HookResult.FAILURE
+
+		return HookResult.SUCCESS
diff --git a/pygittools/pyproject.toml b/pygittools/pyproject.toml
new file mode 100644
index 0000000..4e597c2
--- /dev/null
+++ b/pygittools/pyproject.toml
@@ -0,0 +1,19 @@
+[build-system]
+requires = ["setuptools>=61", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "pygittools"
+version = "0.1.0"
+description = "Hooks, auth, and task storage in Git"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+    "pygit2>=1.12.0",
+]
+
+[tool.setuptools]
+packages = ["pygittools"]
+
+[tool.setuptools.package-dir]
+pygittools = "."
diff --git a/pygittools/tasks.py b/pygittools/tasks.py
new file mode 100644
index 0000000..a0efc54
--- /dev/null
+++ b/pygittools/tasks.py
@@ -0,0 +1,338 @@
+import json
+from datetime import datetime
+from enum import Enum
+from json import JSONEncoder
+from random import randint
+from typing import Any
+
+from pygit2 import Oid, Repository, Signature, Tag, reference_is_valid_name
+
+# GIT_OBJECT_TAG = 4, GIT_OBJECT_TREE = 2
+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)."""
+	try:
+		repo[Oid(hex=EMPTY_TREE_OID_HEX)]
+	except KeyError:
+		repo.odb.write(GIT_OBJECT_TREE, b"tree 0\0")
+
+
+class _DateTimeEncoder(JSONEncoder):
+	def default(self, o: Any) -> Any:
+		if hasattr(o, "isoformat"):
+			return o.isoformat()
+		if isinstance(o, Enum):
+			return o.value
+		return super().default(o)
+
+
+def _tagger_str(tagger: str | Signature | None) -> str:
+	"""Produce a tagger line for git tag object (name <email> timestamp +tz). Accepts str or pygit2.Signature."""
+	if isinstance(tagger, Signature):
+		return f"{tagger.name} <{tagger.email}> {tagger.time} {tagger.offset}"
+	if isinstance(tagger, str):
+		if tagger and " <" in tagger and ">" in tagger:
+			return tagger + " 0 +0000"
+		return (tagger or "pygittools <pygittools@local>") + " 0 +0000"
+	return "pygittools <pygittools@local> 0 +0000"
+
+
+def _build_tag_raw(
+	target: Oid | str,
+	name: str,
+	tagger: str,
+	message: str,
+	object_type: str = "commit",
+) -> bytes:
+	"""Build tag object body (content only) for odb.write(4, data).
+
+	libgit2 prepends 'tag <len>\\0'. object_type must match the target (e.g. 'commit' or 'tree').
+	"""
+	hex_str = str(target) if isinstance(target, Oid) else target
+	content = f"object {hex_str}\ntype {object_type}\ntag {name}\ntagger {_tagger_str(tagger)}\n\n{message}"
+	return content.encode("utf-8")
+
+
+def _read_tag_raw(repo: Repository, oid: Oid) -> tuple[Oid, str, str, str]:
+	"""Read a tag from ODB; return (target, name, tagger_str, message)."""
+	obj = repo[oid]
+	if not isinstance(obj, Tag):
+		raise ValueError(f"Not a tag: {oid}")
+	tagger = obj.tagger
+	tagger_str = f"{tagger.name} <{tagger.email}> {tagger.time} {tagger.offset}" if tagger else ""
+	return (obj.target, obj.name, tagger_str, obj.message)
+
+
+"""
+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 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.
+"""
+
+
+class Task:
+	class Status(Enum):
+		TODO = "TODO"
+		IN_PROGRESS = "IN_PROGRESS"
+		IN_REVIEW = "IN_REVIEW"
+		DONE = "DONE"
+		CANCELLED = "CANCELLED"
+
+	class Priority(Enum):
+		LOW = "LOW"
+		MEDIUM = "MEDIUM"
+		HIGH = "HIGH"
+		CRITICAL = "CRITICAL"
+
+	def __init__(
+		self,
+		target: Oid | str,
+		name: str,
+		tagger: str,
+		title: str,
+		description: str = "",
+		status: Status | None = None,
+		priority: Priority | None = None,
+		assignee: str | None = None,
+		due_date: datetime | None = None,
+	):
+		if name is None:
+			name = f"{TASK_REF_PREFIX}{title.lower().replace(' ', '_')}"
+
+		if not reference_is_valid_name(name):
+			raise ValueError(f"Invalid task backend name: '{name}'")
+
+		self.target = target
+		self.tagger = tagger or ""
+		self.name = name
+		self.title = title
+		self.description = description
+		self.status = status
+		self.priority = priority
+		self.assignee = assignee
+		self.due_date = due_date
+		self.created_at = datetime.now()
+		self.comments: list[Oid | str] = []  # Comment OIDs
+		self.update_message()
+
+	def update_message(self) -> None:
+		self.updated_at = datetime.now()
+		self.message = _DateTimeEncoder().encode({
+			k: v for k, v in self.__dict__.items() if k not in ["target", "tagger", "name", "message"]
+		})
+
+	"""
+    Write the task to the repository.
+    @param repo: The repository to write to.
+    @return: The OID of the written task.
+    """
+
+	def write(self, repo: Repository) -> Oid:
+		try:
+			obj = repo[self.target]
+			object_type = (getattr(obj, "type_str", None) or "commit").lower()
+		except KeyError:
+			if str(self.target) == EMPTY_TREE_OID_HEX:
+				_ensure_empty_tree(repo)
+				object_type = "tree"
+			else:
+				object_type = "commit"
+		raw = _build_tag_raw(self.target, self.name, self.tagger, 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 _parse_dt(s):
+	if not s:
+		return None
+	if hasattr(s, "isoformat"):
+		return s
+	return datetime.fromisoformat(str(s).replace("Z", "+00:00")) if s else None
+
+
+def get_task(repo: Repository, ref: str) -> Task | None:
+	tag = repo.revparse_single(ref)
+	if not isinstance(tag, Tag):
+		raise ValueError(f"Requested task is not a tag: {ref}")
+	j = json.loads(tag.message)
+	status = Task.Status(j["status"]) if j.get("status") else None
+	priority = Task.Priority(j["priority"]) if j.get("priority") else None
+	t = Task(
+		tag.target,
+		tag.name,
+		str(tag.tagger),
+		j.get("title", "Untitled"),
+		description=j.get("description", ""),
+		status=status,
+		priority=priority,
+		assignee=j.get("assignee"),
+		due_date=_parse_dt(j.get("due_date")),
+	)
+	t.comments = j.get("comments", [])
+	t.created_at = _parse_dt(j.get("created_at")) or t.created_at
+	t.updated_at = _parse_dt(j.get("updated_at")) or t.updated_at
+	return t
+
+
+def get_task_by_oid(repo: Repository, oid: Oid) -> Task | None:
+	"""Load a task by its tag OID (e.g. from board.tasks)."""
+	obj = repo[oid]
+	if not isinstance(obj, Tag):
+		raise ValueError(f"Object is not a task tag: {oid}")
+	j = json.loads(obj.message)
+	status = Task.Status(j["status"]) if j.get("status") else None
+	priority = Task.Priority(j["priority"]) if j.get("priority") else None
+	t = Task(
+		obj.target,
+		obj.name,
+		str(obj.tagger),
+		j.get("title", "Untitled"),
+		description=j.get("description", ""),
+		status=status,
+		priority=priority,
+		assignee=j.get("assignee"),
+		due_date=_parse_dt(j.get("due_date")),
+	)
+	t.comments = j.get("comments", [])
+	t.created_at = _parse_dt(j.get("created_at")) or t.created_at
+	t.updated_at = _parse_dt(j.get("updated_at")) or t.updated_at
+	return t
+
+
+"""
+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.
+The ref keeps the comment reachable so git gc will not prune it.
+"""
+
+
+class Comment:
+	def __init__(
+		self,
+		target: Oid,  # Task or parent comment OID
+		tagger: str,  # Author of the comment
+		content: str,
+		name: str | None = None,  # Fully-qualified ref, e.g. "refs/tags/comments/<comment-id>"
+	):
+		if name is None:
+			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 ""
+		self.name = name
+		self.content = content
+		self.created_at = datetime.now()
+		self.edited_at = self.created_at
+		self.update_message()
+
+	def update_message(self) -> None:
+		self.updated_at = datetime.now()
+		self.message = _DateTimeEncoder().encode({
+			k: v for k, v in self.__dict__.items() if k not in ["target", "tagger", "name", "message"]
+		})
+
+	"""
+    Write the comment to the repository.
+    @param repo: The repository to write to.
+    @return: The OID of the written comment.
+    """
+
+	def write(self, repo: Repository) -> Oid:
+		try:
+			obj = repo[self.target]
+			object_type = (getattr(obj, "type_str", None) or "commit").lower()
+		except KeyError:
+			if str(self.target) == EMPTY_TREE_OID_HEX:
+				_ensure_empty_tree(repo)
+				object_type = "tree"
+			else:
+				object_type = "commit"
+		raw = _build_tag_raw(self.target, self.name, self.tagger, 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 get_comment(repo: Repository, oid: Oid) -> Comment | None:
+	obj = repo[oid]
+	if not isinstance(obj, Tag):
+		raise ValueError(f"Requested comment is not a tag: {oid}")
+	j = json.loads(obj.message)
+	c = Comment(obj.target, str(obj.tagger), j["content"], obj.name)
+	c.created_at = _parse_dt(j.get("created_at")) or c.created_at
+	c.edited_at = _parse_dt(j.get("edited_at"))
+	return c
+
+
+"""
+A Board is an annotated tag (a full tag object, although it will get a ref as well).
+The only used field is "message" which is a JSON string.
+The "tasks" field is an array of task OIDs.
+"""
+
+
+class Board:
+	def __init__(
+		self,
+		target: Oid | str,
+		name: str,
+		tagger: str,
+		description: str = "",
+	):
+		self.target = target
+		self.name = name
+		self.tagger = tagger or ""
+		self.description = description
+		self.created_at = datetime.now()
+		self.updated_at = datetime.now()
+		self.tasks: list[Oid | str] = []  # Task OIDs
+		self.update_message()
+
+	def update_message(self) -> None:
+		self.updated_at = datetime.now()
+		self.message = _DateTimeEncoder().encode({
+			k: v for k, v in self.__dict__.items() if k not in ["target", "name", "tagger", "message"]
+		})
+
+	def write(self, repo: Repository) -> Oid:
+		if str(self.target) == EMPTY_TREE_OID_HEX:
+			_ensure_empty_tree(repo)
+		try:
+			obj = repo[self.target]
+			object_type = (getattr(obj, "type_str", None) or "commit").lower()
+		except KeyError:
+			object_type = "tree" if str(self.target) == EMPTY_TREE_OID_HEX else "commit"
+		raw = _build_tag_raw(self.target, self.name, self.tagger, 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 get_board(repo: Repository, ref: str) -> Board | None:
+	tag = repo.revparse_single(ref)
+	if not isinstance(tag, Tag):
+		return None
+	j = json.loads(tag.message)
+	b = Board(tag.target, tag.name, str(tag.tagger), j.get("description", ""))
+	b.tasks = j.get("tasks", [])
+	b.created_at = _parse_dt(j.get("created_at")) or b.created_at
+	b.updated_at = _parse_dt(j.get("updated_at")) or b.updated_at
+	return b
diff --git a/pygittools/test_tasks.py b/pygittools/test_tasks.py
new file mode 100644
index 0000000..57e63c5
--- /dev/null
+++ b/pygittools/test_tasks.py
@@ -0,0 +1,203 @@
+import subprocess
+from pathlib import Path
+
+import pytest
+from pygit2 import Oid, Repository, init_repository
+
+from pygittools.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/README.md b/pygitweb/README.md
index b52eb94..56c4873 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -4,8 +4,6 @@ Gitweb reimplementation using **Python**, **FastAPI**, and **Pygit2**, ported fr
 
 ## Setup
 
-Pygitweb is part of the repo-root **uv** workspace (with `distgit`). From the repository root:
-
 ```bash
 uv sync --package pygitweb
 ```
diff --git a/pygitweb/__meta__.py b/pygitweb/__meta__.py
index 08dcd7b..0817999 100644
--- a/pygitweb/__meta__.py
+++ b/pygitweb/__meta__.py
@@ -2,8 +2,8 @@
 Metadata for PyGitWeb - this is the canonical source of all information below.
 """
 
-__version__ = "1.0.0"
+__version__ = "0.1.0"
 __author__ = "Will Bowers"
 __license__ = "Apache 2.0"  # This may change before being distributed.
 __description__ = "FastAPI + Pygit2 Repo Browser"
-__url__ = "https://github.com/willbowers/pygitweb"
+__url__ = "https://pygitweb.com"
diff --git a/pygitweb/config.py b/pygitweb/config.py
index 758ef9e..d0dfb88 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -85,13 +85,13 @@ BLOB_LANG = {
 }
 
 # Defaults (equivalent to @GITWEB_*@ in gitweb.perl)
-PROJECTROOT = os.getenv("GITWEB_PROJECTROOT", str(Path.home()))
-PROJECT_MAXDEPTH = int(os.getenv("GITWEB_PROJECT_MAXDEPTH", "1"))
-PROJECTS_LIST = os.getenv("GITWEB_LIST", PROJECTROOT)
-SITE_NAME = os.getenv("GITWEB_SITENAME", "") or "DistGit"
-EXPORT_OK = os.getenv("GITWEB_EXPORT_OK", "")
+PROJECTROOT = os.getenv("PYGITWEB_PROJECTROOT", str(Path.home()))
+PROJECT_MAXDEPTH = int(os.getenv("PYGITWEB_PROJECT_MAXDEPTH", "1"))
+PROJECTS_LIST = os.getenv("PYGITWEB_LIST", PROJECTROOT)
+SITE_NAME = os.getenv("PYGITWEB_SITENAME", "") or "PyGitWeb"
+EXPORT_OK = os.getenv("PYGITWEB_EXPORT_OK", "")
 # When True, list all directories under project root without repo/export_ok checks (default on for now).
-LIST_ALL = os.getenv("GITWEB_LIST_ALL", "1").lower() in ("1", "true", "yes")
+LIST_ALL = os.getenv("PYGITWEB_LIST_ALL", "1").lower() in ("1", "true", "yes")
 STRICT_EXPORT = os.getenv("GITWEB_STRICT_EXPORT", "0").lower() in (
 	"1",
 	"true",
@@ -102,7 +102,7 @@ GIT = (GIT_BINDIR + "/git") if GIT_BINDIR else "git"
 MAXLOAD: float | None = None  # 300 in perl; None = disabled
 
 """
-The auth provider to use. "None" disables authentication entirely, bypassing distgit. USE WITH CAUTION.
+The auth provider to use. "None" disables authentication entirely.
 RootProvider: Stub which provides admin login with a user/password. Parent class for other providers.
 SSHProvider: TODO - Will authenticate using existing SSH keys. Easiest coming from raw git-daemon.
 OAuth2Provider: TODO - Will authenticate against an OAuth2 server.
diff --git a/pygitweb/pyproject.toml b/pygitweb/pyproject.toml
index 5c9a416..d585e1c 100644
--- a/pygitweb/pyproject.toml
+++ b/pygitweb/pyproject.toml
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "pygitweb"
-version = "1.0.0"
+version = "0.1.0"
 description = "Gitweb reimplementation with FastAPI and Pygit2"
 readme = "README.md"
 requires-python = ">=3.11"
 dependencies = [
-    "distgit",
+    "pygittools",
     "fastapi[standard-no-fastapi-cloud-cli]>=0.104.0",
     "uvicorn[standard]>=0.24.0",
     "python-multipart>=0.0.6",
@@ -28,4 +28,4 @@ pygitweb = "."
 pygitweb = ["templates/**/*", "static/**/*"]
 
 [tool.uv.sources]
-distgit = { workspace = true }
+pygittools = { workspace = true }
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index c3cdecf..dc43048 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -1,6 +1,6 @@
 """
 Task-related routes: Task, comment, board creation, listing, and management.
-Data is stored in the repo ODB/RefDB via distgit.tasks (Board, Task, Comment).
+Data is stored in the repo ODB/RefDB via pygittools.tasks (Board, Task, Comment).
 """
 
 from __future__ import annotations
@@ -12,7 +12,7 @@ import pygit2
 from fastapi import APIRouter, HTTPException, Query, Request
 from fastapi.responses import JSONResponse
 
-from distgit.tasks import (
+from pygittools.tasks import (
 	BOARD_REF_PREFIX,
 	TASK_REF_PREFIX,
 	Board,
diff --git a/pygitweb/templates/preamble.html b/pygitweb/templates/preamble.html
index 2b22dc8..df4cce6 100644
--- a/pygitweb/templates/preamble.html
+++ b/pygitweb/templates/preamble.html
@@ -1,4 +1,4 @@
-<!DOCTYPE html><html><head><title>{{title | default('DistGit')}}</title>
+<!DOCTYPE html><html><head><title>{{title | default('PyGitWeb')}}</title>
 <script>document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e){ document.documentElement.setAttribute('data-bs-theme', e.matches ? 'dark' : 'light'); });</script>
 <link rel="icon" href="/static/logo.svg" type="image/svg+xml">
 <script id="Highlight-script" async src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
@@ -24,7 +24,7 @@
       <h1 class="navbar-brand navbar-brand-autodark py-3">
         <a href="/" class="d-flex align-items-center text-reset">
           <img src="/static/logo.svg" width="32" height="32" alt="Logo" class="navbar-brand-image me-2" style="object-fit: contain;">
-          <span class="nav-link-title">{{ site_name | default('DistGit') }}</span>
+          <span class="nav-link-title">{{ site_name | default('PyGitWeb') }}</span>
         </a>
       </h1>
       <div class="collapse navbar-collapse flex-grow-1" id="sidebar-menu">
diff --git a/pyproject.toml b/pyproject.toml
index 31b1a4a..8471c75 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
 [project]
-name = "distgit-workspace"
+name = "pygitweb-workspace"
 version = "0.0.0"
-description = "Workspace root for postgit, distgit, and pygitweb"
+description = "Workspace root for postgit, pygittools, and pygitweb"
 requires-python = ">=3.11"
 dependencies = []
 
@@ -13,7 +13,7 @@ dev = [
 ]
 
 [tool.uv.workspace]
-members = ["postgit", "distgit", "pygitweb"]
+members = ["postgit", "pygittools", "pygitweb"]
 
 [tool.uv]
 package = false
diff --git a/uv.lock b/uv.lock
index 2c3b1be..4cab041 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4,10 +4,10 @@ requires-python = ">=3.11"
 
 [manifest]
 members = [
-    "distgit",
-    "distgit-workspace",
     "postgit",
+    "pygittools",
     "pygitweb",
+    "pygitweb-workspace",
 ]
 
 [[package]]
@@ -142,38 +142,6 @@ wheels = [
 ]
 
 [[package]]
-name = "distgit"
-version = "0.1.0"
-source = { editable = "distgit" }
-dependencies = [
-    { name = "pygit2" },
-]
-
-[package.metadata]
-requires-dist = [{ name = "pygit2", specifier = ">=1.12.0" }]
-
-[[package]]
-name = "distgit-workspace"
-version = "0.0.0"
-source = { virtual = "." }
-
-[package.dev-dependencies]
-dev = [
-    { name = "mypy" },
-    { name = "pytest" },
-    { name = "ruff" },
-]
-
-[package.metadata]
-
-[package.metadata.requires-dev]
-dev = [
-    { name = "mypy", specifier = ">=1.20.1" },
-    { name = "pytest" },
-    { name = "ruff" },
-]
-
-[[package]]
 name = "dnspython"
 version = "2.8.0"
 source = { registry = "https://pypi.org/simple" }
@@ -966,31 +934,63 @@ wheels = [
 ]
 
 [[package]]
+name = "pygittools"
+version = "0.1.0"
+source = { editable = "pygittools" }
+dependencies = [
+    { name = "pygit2" },
+]
+
+[package.metadata]
+requires-dist = [{ name = "pygit2", specifier = ">=1.12.0" }]
+
+[[package]]
 name = "pygitweb"
-version = "1.0.0"
+version = "0.1.0"
 source = { editable = "pygitweb" }
 dependencies = [
-    { name = "distgit" },
     { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] },
     { name = "jinja2" },
     { name = "orjson" },
     { name = "pygit2" },
+    { name = "pygittools" },
     { name = "python-multipart" },
     { name = "uvicorn", extra = ["standard"] },
 ]
 
 [package.metadata]
 requires-dist = [
-    { name = "distgit", editable = "distgit" },
     { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.104.0" },
     { name = "jinja2", specifier = ">=3.1.0" },
     { name = "orjson", specifier = ">=0.19.0" },
     { name = "pygit2", specifier = ">=1.12.0" },
+    { name = "pygittools", editable = "pygittools" },
     { name = "python-multipart", specifier = ">=0.0.6" },
     { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
 ]
 
 [[package]]
+name = "pygitweb-workspace"
+version = "0.0.0"
+source = { virtual = "." }
+
+[package.dev-dependencies]
+dev = [
+    { name = "mypy" },
+    { name = "pytest" },
+    { name = "ruff" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "mypy", specifier = ">=1.20.1" },
+    { name = "pytest" },
+    { name = "ruff" },
+]
+
+[[package]]
 name = "pygments"
 version = "2.20.0"
 source = { registry = "https://pypi.org/simple" }
