diff --git a/.editorconfig b/.editorconfig
index 80fecdf..74d6498 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,3 +1,3 @@
 [*.py]
-indent_style = tab
+indent_style = space
 indent_size = 4
diff --git a/conftest.py b/conftest.py
index 1b85a6f..0d85e32 100644
--- a/conftest.py
+++ b/conftest.py
@@ -17,38 +17,38 @@ os.environ.setdefault("PYGITWEB_SETTINGS_CONFIG", str(_test_settings_config))
 
 
 def pytest_addoption(parser: pytest.Parser) -> None:
-	parser.addoption(
-		"--js-tests",
-		action="store_true",
-		default=False,
-		help="Run JavaScript unit tests (requires the playwright group and Chromium)",
-	)
-	parser.addoption(
-		"--playwright",
-		action="store_true",
-		default=False,
-		help="Run browser integration tests (requires the playwright group)",
-	)
+    parser.addoption(
+        "--js-tests",
+        action="store_true",
+        default=False,
+        help="Run JavaScript unit tests (requires the playwright group and Chromium)",
+    )
+    parser.addoption(
+        "--playwright",
+        action="store_true",
+        default=False,
+        help="Run browser integration tests (requires the playwright group)",
+    )
 
 
 def pytest_configure(config: pytest.Config) -> None:
-	if not config.getoption("--playwright"):
-		return
-	subprocess.run(
-		[sys.executable, "-m", "playwright", "install", "chromium"],
-		check=True,
-	)
+    if not config.getoption("--playwright"):
+        return
+    subprocess.run(
+        [sys.executable, "-m", "playwright", "install", "chromium"],
+        check=True,
+    )
 
 
 def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
-	skip_js = None
-	if not config.getoption("--js-tests"):
-		skip_js = pytest.mark.skip(reason="JS tests are off by default; pass --js-tests to run them")
-	skip_playwright = None
-	if not config.getoption("--playwright"):
-		skip_playwright = pytest.mark.skip(reason="Playwright tests are off by default; pass --playwright to run them")
-	for item in items:
-		if skip_js is not None and "static_tests" in item.path.parts:
-			item.add_marker(skip_js)
-		if skip_playwright is not None and "integration" in item.path.parts:
-			item.add_marker(skip_playwright)
+    skip_js = None
+    if not config.getoption("--js-tests"):
+        skip_js = pytest.mark.skip(reason="JS tests are off by default; pass --js-tests to run them")
+    skip_playwright = None
+    if not config.getoption("--playwright"):
+        skip_playwright = pytest.mark.skip(reason="Playwright tests are off by default; pass --playwright to run them")
+    for item in items:
+        if skip_js is not None and "static_tests" in item.path.parts:
+            item.add_marker(skip_js)
+        if skip_playwright is not None and "integration" in item.path.parts:
+            item.add_marker(skip_playwright)
diff --git a/integration/projects_page_test.py b/integration/projects_page_test.py
index 9003820..ddfd976 100644
--- a/integration/projects_page_test.py
+++ b/integration/projects_page_test.py
@@ -18,124 +18,124 @@ sync_api = pytest.importorskip("playwright.sync_api")
 
 _REPO_ROOT = Path(__file__).resolve().parents[1]
 _KEPT_ENV = (
-	"PATH",
-	"HOME",
-	"USER",
-	"LOGNAME",
-	"LANG",
-	"LC_ALL",
-	"LC_CTYPE",
-	"TMPDIR",
-	"TMP",
-	"TEMP",
-	"SYSTEMROOT",
+    "PATH",
+    "HOME",
+    "USER",
+    "LOGNAME",
+    "LANG",
+    "LC_ALL",
+    "LC_CTYPE",
+    "TMPDIR",
+    "TMP",
+    "TEMP",
+    "SYSTEMROOT",
 )
 
 
 def _fresh_env(overrides: Mapping[str, str] | None = None) -> dict[str, str]:
-	env = {key: os.environ[key] for key in _KEPT_ENV if key in os.environ}
-	if overrides:
-		env.update(overrides)
-	return env
+    env = {key: os.environ[key] for key in _KEPT_ENV if key in os.environ}
+    if overrides:
+        env.update(overrides)
+    return env
 
 
 def _venv_python(venv: Path) -> Path:
-	if sys.platform == "win32":
-		return venv / "Scripts" / "python.exe"
-	return venv / "bin" / "python"
+    if sys.platform == "win32":
+        return venv / "Scripts" / "python.exe"
+    return venv / "bin" / "python"
 
 
 def _free_port() -> int:
-	with socket.socket() as sock:
-		sock.bind(("127.0.0.1", 0))
-		return int(sock.getsockname()[1])
+    with socket.socket() as sock:
+        sock.bind(("127.0.0.1", 0))
+        return int(sock.getsockname()[1])
 
 
 def _wait_until_ready(url: str, proc: subprocess.Popen[str], log_path: Path, timeout: float) -> None:
-	deadline = time.monotonic() + timeout
-	while time.monotonic() < deadline:
-		if proc.poll() is not None:
-			log = log_path.read_text(encoding="utf-8") if log_path.is_file() else ""
-			raise RuntimeError(f"server exited with {proc.returncode}\n{log}")
-		try:
-			with urllib.request.urlopen(url, timeout=1) as response:
-				if response.status == 200:
-					return
-		except (urllib.error.URLError, TimeoutError, OSError):
-			time.sleep(0.1)
-	log = log_path.read_text(encoding="utf-8") if log_path.is_file() else ""
-	raise TimeoutError(f"server did not become ready: {url}\n{log}")
+    deadline = time.monotonic() + timeout
+    while time.monotonic() < deadline:
+        if proc.poll() is not None:
+            log = log_path.read_text(encoding="utf-8") if log_path.is_file() else ""
+            raise RuntimeError(f"server exited with {proc.returncode}\n{log}")
+        try:
+            with urllib.request.urlopen(url, timeout=1) as response:
+                if response.status == 200:
+                    return
+        except (urllib.error.URLError, TimeoutError, OSError):
+            time.sleep(0.1)
+    log = log_path.read_text(encoding="utf-8") if log_path.is_file() else ""
+    raise TimeoutError(f"server did not become ready: {url}\n{log}")
 
 
 @pytest.mark.playwright
 def test_projects_page_loads_without_auth(tmp_path: Path) -> None:
-	projects = tmp_path / "projects"
-	repo = projects / "demo"
-	repo.mkdir(parents=True)
-	subprocess.run(["git", "init", "-b", "main", str(repo)], check=True, capture_output=True)
-
-	venv = tmp_path / "venv"
-	subprocess.run(
-		["uv", "sync", "--all-packages", "--frozen", "--no-dev"],
-		cwd=_REPO_ROOT,
-		env=_fresh_env({"UV_PROJECT_ENVIRONMENT": str(venv)}),
-		check=True,
-	)
-
-	port = _free_port()
-	base = f"http://127.0.0.1:{port}/"
-	home = tmp_path / "home"
-	home.mkdir()
-	# Windows Path.home() reads USERPROFILE.
-	server_env = _fresh_env({
-		"HOME": str(home),
-		"USERPROFILE": str(home),
-		"PYGITWEB_AUTH": "0",
-		"PYGITWEB_PROJECTROOT": str(projects),
-		"PYGITWEB_PROJECTS_LIST": str(projects),
-		"PYGITWEB_SETTINGS_CONFIG": str(tmp_path / "settings.json"),
-		"PYGITWEB_AUTH_CONFIG": str(tmp_path / "auth.json"),
-		"PYTHONUNBUFFERED": "1",
-	})
-	log_out_path = tmp_path / "server.out"
-	log_err_path = tmp_path / "server.err"
-	log_out = log_out_path.open("w", encoding="utf-8")
-	log_err = log_err_path.open("w", encoding="utf-8")
-	proc = subprocess.Popen(
-		[
-			str(_venv_python(venv)),
-			"-m",
-			"uvicorn",
-			"pygitweb.main:app",
-			"--host",
-			"127.0.0.1",
-			"--port",
-			str(port),
-		],
-		cwd=tmp_path,
-		env=server_env,
-		stdout=log_out,
-		stderr=log_err,
-	)
-	try:
-		_wait_until_ready(base, proc, log_err_path, timeout=30)
-		with sync_api.sync_playwright() as playwright:
-			browser = playwright.chromium.launch(headless=True)
-			try:
-				page = browser.new_page()
-				page.goto(base, wait_until="domcontentloaded")
-				sync_api.expect(page).to_have_title("PyGitWeb - Projects")
-				sync_api.expect(page.get_by_role("heading", name="Projects")).to_be_visible()
-				sync_api.expect(page.get_by_role("link", name="demo", exact=True)).to_be_visible()
-			finally:
-				browser.close()
-	finally:
-		if proc.poll() is None:
-			proc.terminate()
-			try:
-				proc.wait(timeout=10)
-			except subprocess.TimeoutExpired:
-				proc.kill()
-				proc.wait(timeout=10)
-		log_out.close()
-		log_err.close()
+    projects = tmp_path / "projects"
+    repo = projects / "demo"
+    repo.mkdir(parents=True)
+    subprocess.run(["git", "init", "-b", "main", str(repo)], check=True, capture_output=True)
+
+    venv = tmp_path / "venv"
+    subprocess.run(
+        ["uv", "sync", "--all-packages", "--frozen", "--no-dev"],
+        cwd=_REPO_ROOT,
+        env=_fresh_env({"UV_PROJECT_ENVIRONMENT": str(venv)}),
+        check=True,
+    )
+
+    port = _free_port()
+    base = f"http://127.0.0.1:{port}/"
+    home = tmp_path / "home"
+    home.mkdir()
+    # Windows Path.home() reads USERPROFILE.
+    server_env = _fresh_env({
+        "HOME": str(home),
+        "USERPROFILE": str(home),
+        "PYGITWEB_AUTH": "0",
+        "PYGITWEB_PROJECTROOT": str(projects),
+        "PYGITWEB_PROJECTS_LIST": str(projects),
+        "PYGITWEB_SETTINGS_CONFIG": str(tmp_path / "settings.json"),
+        "PYGITWEB_AUTH_CONFIG": str(tmp_path / "auth.json"),
+        "PYTHONUNBUFFERED": "1",
+    })
+    log_out_path = tmp_path / "server.out"
+    log_err_path = tmp_path / "server.err"
+    log_out = log_out_path.open("w", encoding="utf-8")
+    log_err = log_err_path.open("w", encoding="utf-8")
+    proc = subprocess.Popen(
+        [
+            str(_venv_python(venv)),
+            "-m",
+            "uvicorn",
+            "pygitweb.main:app",
+            "--host",
+            "127.0.0.1",
+            "--port",
+            str(port),
+        ],
+        cwd=tmp_path,
+        env=server_env,
+        stdout=log_out,
+        stderr=log_err,
+    )
+    try:
+        _wait_until_ready(base, proc, log_err_path, timeout=30)
+        with sync_api.sync_playwright() as playwright:
+            browser = playwright.chromium.launch(headless=True)
+            try:
+                page = browser.new_page()
+                page.goto(base, wait_until="domcontentloaded")
+                sync_api.expect(page).to_have_title("PyGitWeb - Projects")
+                sync_api.expect(page.get_by_role("heading", name="Projects")).to_be_visible()
+                sync_api.expect(page.get_by_role("link", name="demo", exact=True)).to_be_visible()
+            finally:
+                browser.close()
+    finally:
+        if proc.poll() is None:
+            proc.terminate()
+            try:
+                proc.wait(timeout=10)
+            except subprocess.TimeoutExpired:
+                proc.kill()
+                proc.wait(timeout=10)
+        log_out.close()
+        log_err.close()
diff --git a/postgit/src/postgit/__init__.py b/postgit/src/postgit/__init__.py
index 807704e..2eb9285 100644
--- a/postgit/src/postgit/__init__.py
+++ b/postgit/src/postgit/__init__.py
@@ -1,33 +1,33 @@
 from .adapters import (
-	GitObjectType,
-	OidBinaryDumper,
-	OidDumper,
-	object_data_from_pygit2,
-	object_type_from_pygit2,
-	objects_row_insert,
-	oid_from_db,
-	postgit_dict_row,
-	postgit_tuple_row,
-	ref_storage_columns,
-	register_adapters,
-	register_adapters_async,
-	regtype_git_object_type,
+    GitObjectType,
+    OidBinaryDumper,
+    OidDumper,
+    object_data_from_pygit2,
+    object_type_from_pygit2,
+    objects_row_insert,
+    oid_from_db,
+    postgit_dict_row,
+    postgit_tuple_row,
+    ref_storage_columns,
+    register_adapters,
+    register_adapters_async,
+    regtype_git_object_type,
 )
 
 __all__ = [
-	"GitObjectType",
-	"OidBinaryDumper",
-	"OidDumper",
-	"object_data_from_pygit2",
-	"object_type_from_pygit2",
-	"objects_row_insert",
-	"oid_from_db",
-	"postgit_dict_row",
-	"postgit_tuple_row",
-	"ref_storage_columns",
-	"register_adapters",
-	"register_adapters_async",
-	"regtype_git_object_type",
+    "GitObjectType",
+    "OidBinaryDumper",
+    "OidDumper",
+    "object_data_from_pygit2",
+    "object_type_from_pygit2",
+    "objects_row_insert",
+    "oid_from_db",
+    "postgit_dict_row",
+    "postgit_tuple_row",
+    "ref_storage_columns",
+    "register_adapters",
+    "register_adapters_async",
+    "regtype_git_object_type",
 ]
 
 import postgit.__meta__
diff --git a/postgit/src/postgit/adapters.py b/postgit/src/postgit/adapters.py
index 14e65de..948378a 100644
--- a/postgit/src/postgit/adapters.py
+++ b/postgit/src/postgit/adapters.py
@@ -34,184 +34,184 @@ _OID_COLUMN_NAMES = frozenset({"oid", "target_oid"})
 
 
 class GitObjectType(IntEnum):
-	"""Matches ``git_object_type`` in PostgreSQL and ``pygit2.GIT_OBJECT_*``."""
+    """Matches ``git_object_type`` in PostgreSQL and ``pygit2.GIT_OBJECT_*``."""
 
-	COMMIT = 1
-	TREE = 2
-	BLOB = 3
-	TAG = 4
+    COMMIT = 1
+    TREE = 2
+    BLOB = 3
+    TAG = 4
 
 
 _GIT_OBJECT_TYPE_PG_MAP = {
-	GitObjectType.COMMIT: "commit",
-	GitObjectType.TREE: "tree",
-	GitObjectType.BLOB: "blob",
-	GitObjectType.TAG: "tag",
+    GitObjectType.COMMIT: "commit",
+    GitObjectType.TREE: "tree",
+    GitObjectType.BLOB: "blob",
+    GitObjectType.TAG: "tag",
 }
 
 
 class OidDumper(Dumper):
-	"""Encode ``pygit2.Oid`` as PostgreSQL ``bytea`` (text parameter format)."""
+    """Encode ``pygit2.Oid`` as PostgreSQL ``bytea`` (text parameter format)."""
 
-	oid = _oids.BYTEA_OID
+    oid = _oids.BYTEA_OID
 
-	def __init__(self, cls: type, context: AdaptContext | None = None):
-		super().__init__(cls, context)
-		self._bytes = BytesDumper(bytes, context)
+    def __init__(self, cls: type, context: AdaptContext | None = None):
+        super().__init__(cls, context)
+        self._bytes = BytesDumper(bytes, context)
 
-	def dump(self, obj: pygit2.Oid) -> Buffer | None:
-		return self._bytes.dump(obj.raw)
+    def dump(self, obj: pygit2.Oid) -> Buffer | None:
+        return self._bytes.dump(obj.raw)
 
 
 class OidBinaryDumper(Dumper):
-	"""Encode ``pygit2.Oid`` as PostgreSQL ``bytea`` (binary parameter format)."""
+    """Encode ``pygit2.Oid`` as PostgreSQL ``bytea`` (binary parameter format)."""
 
-	format = Format.BINARY
-	oid = _oids.BYTEA_OID
+    format = Format.BINARY
+    oid = _oids.BYTEA_OID
 
-	def dump(self, obj: pygit2.Oid) -> Buffer | None:
-		return obj.raw
+    def dump(self, obj: pygit2.Oid) -> Buffer | None:
+        return obj.raw
 
 
 def register_adapters(
-	conn: Connection[Any],
-	*,
-	git_object_type_regtype: str = "git_object_type",
+    conn: Connection[Any],
+    *,
+    git_object_type_regtype: str = "git_object_type",
 ) -> None:
-	"""
-	Register dumpers/loaders on ``conn`` for ``pygit2.Oid`` and ``git_object_type``.
-
-	Call once per sync connection after the schema exists (enum type present).
-
-	:param git_object_type_regtype: argument to :meth:`EnumInfo.fetch` (e.g.
-	:func:`regtype_git_object_type` if the type is not on ``search_path``).
-	"""
-	info = EnumInfo.fetch(conn, git_object_type_regtype)
-	if info is None:
-		raise LookupError(
-			"PostgreSQL type git_object_type not found; apply schema.sql first "
-			"(use the regtype your search_path resolves, e.g. public.git_object_type)."
-		)
-	register_enum(
-		info,
-		conn,
-		enum=GitObjectType,
-		mapping=_GIT_OBJECT_TYPE_PG_MAP,
-	)
-	conn.adapters.register_dumper(pygit2.Oid, OidDumper)
-	conn.adapters.register_dumper(pygit2.Oid, OidBinaryDumper)
+    """
+    Register dumpers/loaders on ``conn`` for ``pygit2.Oid`` and ``git_object_type``.
+
+    Call once per sync connection after the schema exists (enum type present).
+
+    :param git_object_type_regtype: argument to :meth:`EnumInfo.fetch` (e.g.
+    :func:`regtype_git_object_type` if the type is not on ``search_path``).
+    """
+    info = EnumInfo.fetch(conn, git_object_type_regtype)
+    if info is None:
+        raise LookupError(
+            "PostgreSQL type git_object_type not found; apply schema.sql first "
+            "(use the regtype your search_path resolves, e.g. public.git_object_type)."
+        )
+    register_enum(
+        info,
+        conn,
+        enum=GitObjectType,
+        mapping=_GIT_OBJECT_TYPE_PG_MAP,
+    )
+    conn.adapters.register_dumper(pygit2.Oid, OidDumper)
+    conn.adapters.register_dumper(pygit2.Oid, OidBinaryDumper)
 
 
 async def register_adapters_async(
-	conn: AsyncConnection[Any],
-	*,
-	git_object_type_regtype: str = "git_object_type",
+    conn: AsyncConnection[Any],
+    *,
+    git_object_type_regtype: str = "git_object_type",
 ) -> None:
-	"""Async variant of :func:`register_adapters`."""
-	info = await EnumInfo.fetch(conn, git_object_type_regtype)
-	if info is None:
-		raise LookupError("PostgreSQL type git_object_type not found; apply schema.sql first.")
-	register_enum(
-		info,
-		conn,
-		enum=GitObjectType,
-		mapping=_GIT_OBJECT_TYPE_PG_MAP,
-	)
-	conn.adapters.register_dumper(pygit2.Oid, OidDumper)
-	conn.adapters.register_dumper(pygit2.Oid, OidBinaryDumper)
+    """Async variant of :func:`register_adapters`."""
+    info = await EnumInfo.fetch(conn, git_object_type_regtype)
+    if info is None:
+        raise LookupError("PostgreSQL type git_object_type not found; apply schema.sql first.")
+    register_enum(
+        info,
+        conn,
+        enum=GitObjectType,
+        mapping=_GIT_OBJECT_TYPE_PG_MAP,
+    )
+    conn.adapters.register_dumper(pygit2.Oid, OidDumper)
+    conn.adapters.register_dumper(pygit2.Oid, OidBinaryDumper)
 
 
 def oid_from_db(value: bytes | memoryview) -> pygit2.Oid:
-	"""Build ``Oid`` from a ``bytea`` value (e.g. manual handling without a row factory)."""
-	return pygit2.Oid(raw=bytes(value))
+    """Build ``Oid`` from a ``bytea`` value (e.g. manual handling without a row factory)."""
+    return pygit2.Oid(raw=bytes(value))
 
 
 def postgit_tuple_row(cursor: BaseCursor[Any, Any]) -> RowMaker[tuple[Any, ...]]:
-	"""
-	Like :func:`psycopg.rows.tuple_row`, but turns ``oid`` and ``target_oid``
-	columns into ``pygit2.Oid``.
-	"""
-	if not cursor.description:
-		return tuple_row(cursor)
-	names = [d.name for d in cursor.description]
-	idxs = [i for i, n in enumerate(names) if n in _OID_COLUMN_NAMES]
-	if not idxs:
-		return tuple_row(cursor)
-
-	def rowmaker(values: Sequence[Any]) -> tuple[Any, ...]:
-		out = list(values)
-		for i in idxs:
-			v = out[i]
-			if isinstance(v, (bytes, memoryview)):
-				out[i] = oid_from_db(v)
-		return tuple(out)
-
-	return rowmaker
+    """
+    Like :func:`psycopg.rows.tuple_row`, but turns ``oid`` and ``target_oid``
+    columns into ``pygit2.Oid``.
+    """
+    if not cursor.description:
+        return tuple_row(cursor)
+    names = [d.name for d in cursor.description]
+    idxs = [i for i, n in enumerate(names) if n in _OID_COLUMN_NAMES]
+    if not idxs:
+        return tuple_row(cursor)
+
+    def rowmaker(values: Sequence[Any]) -> tuple[Any, ...]:
+        out = list(values)
+        for i in idxs:
+            v = out[i]
+            if isinstance(v, (bytes, memoryview)):
+                out[i] = oid_from_db(v)
+        return tuple(out)
+
+    return rowmaker
 
 
 def postgit_dict_row(cursor: BaseCursor[Any, Any]) -> RowMaker[dict[str, Any]]:
-	"""
-	Like :func:`psycopg.rows.dict_row`, but turns ``oid`` and ``target_oid``
-	values into ``pygit2.Oid``.
-	"""
-	inner = dict_row(cursor)
-	if cursor.description is None:
-		return inner
-	names = [d.name for d in cursor.description]
-	idxs = [i for i, n in enumerate(names) if n in _OID_COLUMN_NAMES]
-	if not idxs:
-		return inner
-
-	def rowmaker(values: Sequence[Any]) -> dict[str, Any]:
-		d = dict(zip(names, values, strict=True))
-		for k in _OID_COLUMN_NAMES & d.keys():
-			v = d[k]
-			if isinstance(v, (bytes, memoryview)):
-				d[k] = oid_from_db(v)
-		return d
-
-	return rowmaker
+    """
+    Like :func:`psycopg.rows.dict_row`, but turns ``oid`` and ``target_oid``
+    values into ``pygit2.Oid``.
+    """
+    inner = dict_row(cursor)
+    if cursor.description is None:
+        return inner
+    names = [d.name for d in cursor.description]
+    idxs = [i for i, n in enumerate(names) if n in _OID_COLUMN_NAMES]
+    if not idxs:
+        return inner
+
+    def rowmaker(values: Sequence[Any]) -> dict[str, Any]:
+        d = dict(zip(names, values, strict=True))
+        for k in _OID_COLUMN_NAMES & d.keys():
+            v = d[k]
+            if isinstance(v, (bytes, memoryview)):
+                d[k] = oid_from_db(v)
+        return d
+
+    return rowmaker
 
 
 def ref_storage_columns(
-	ref: pygit2.Reference,
+    ref: pygit2.Reference,
 ) -> tuple[pygit2.Oid | None, str | None]:
-	"""
-	Values for ``(target_oid, symbolic_target)`` on the ``refs`` table.
-
-	Matches :func:`register_adapters` expectations: direct refs supply ``Oid``;
-	symbolic refs supply the target ref name as ``str``.
-	"""
-	if ref.type == _GIT_REFERENCE_SYMBOLIC:
-		t = ref.target
-		if not isinstance(t, str):
-			t = str(t)
-		return None, t
-	if ref.type == _GIT_REFERENCE_OID:
-		tgt = ref.target
-		if not isinstance(tgt, pygit2.Oid):
-			tgt = pygit2.Oid(hex=str(tgt))
-		return tgt, None
-	raise TypeError(f"unsupported reference type: {ref.type!r}")
+    """
+    Values for ``(target_oid, symbolic_target)`` on the ``refs`` table.
+
+    Matches :func:`register_adapters` expectations: direct refs supply ``Oid``;
+    symbolic refs supply the target ref name as ``str``.
+    """
+    if ref.type == _GIT_REFERENCE_SYMBOLIC:
+        t = ref.target
+        if not isinstance(t, str):
+            t = str(t)
+        return None, t
+    if ref.type == _GIT_REFERENCE_OID:
+        tgt = ref.target
+        if not isinstance(tgt, pygit2.Oid):
+            tgt = pygit2.Oid(hex=str(tgt))
+        return tgt, None
+    raise TypeError(f"unsupported reference type: {ref.type!r}")
 
 
 def object_type_from_pygit2(obj: pygit2.Object) -> GitObjectType:
-	"""Map ``Object.type`` (libgit2 kind int) to :class:`GitObjectType`."""
-	return GitObjectType(obj.type)
+    """Map ``Object.type`` (libgit2 kind int) to :class:`GitObjectType`."""
+    return GitObjectType(obj.type)
 
 
 def object_data_from_pygit2(obj: pygit2.Object) -> bytes:
-	"""Object payload for ``objects.object_data`` (same as ``Object.read_raw()``)."""
-	return obj.read_raw()
+    """Object payload for ``objects.object_data`` (same as ``Object.read_raw()``)."""
+    return obj.read_raw()
 
 
 def objects_row_insert(
-	obj: pygit2.Object,
+    obj: pygit2.Object,
 ) -> tuple[pygit2.Oid, GitObjectType, bytes]:
-	"""``(oid, object_type, object_data)`` for inserting into ``objects``."""
-	return obj.id, object_type_from_pygit2(obj), object_data_from_pygit2(obj)
+    """``(oid, object_type, object_data)`` for inserting into ``objects``."""
+    return obj.id, object_type_from_pygit2(obj), object_data_from_pygit2(obj)
 
 
 def regtype_git_object_type(schema: str = "public") -> str:
-	"""Qualified type name for :func:`EnumInfo.fetch` if ``search_path`` is not set."""
-	return f"{schema}.git_object_type"
+    """Qualified type name for :func:`EnumInfo.fetch` if ``search_path`` is not set."""
+    return f"{schema}.git_object_type"
diff --git a/pygittools-mcp/src/pygittools_mcp/exceptions.py b/pygittools-mcp/src/pygittools_mcp/exceptions.py
index 6315b69..728ecc4 100644
--- a/pygittools-mcp/src/pygittools_mcp/exceptions.py
+++ b/pygittools-mcp/src/pygittools_mcp/exceptions.py
@@ -3,35 +3,35 @@ from pygittools.tasks import Task
 
 
 class BoardNotFoundError(ToolError):
-	def __init__(self, board_name: str):
-		self.board_name = board_name
-		super().__init__(f"Board {board_name} not found")
+    def __init__(self, board_name: str):
+        self.board_name = board_name
+        super().__init__(f"Board {board_name} not found")
 
 
 class TaskNotFoundError(ToolError):
-	def __init__(self, task_ref: str):
-		self.task_ref = task_ref
-		super().__init__(f"Task {task_ref} not found")
+    def __init__(self, task_ref: str):
+        self.task_ref = task_ref
+        super().__init__(f"Task {task_ref} not found")
 
 
 class InvalidTaskStatusError(ToolError):
-	def __init__(self, value: str):
-		self.value = value
-		self.valid_values = [item.value for item in Task.Status]
-		super().__init__(
-			f"Invalid task status value, {value}. Valid task status values: ({','.join(self.valid_values)})"
-		)
+    def __init__(self, value: str):
+        self.value = value
+        self.valid_values = [item.value for item in Task.Status]
+        super().__init__(
+            f"Invalid task status value, {value}. Valid task status values: ({','.join(self.valid_values)})"
+        )
 
 
 class InvalidTaskPriorityError(ToolError):
-	def __init__(self, value: str):
-		self.value = value
-		self.valid_values = [item.value for item in Task.Priority]
-		super().__init__(
-			f"Invalid task priority value, {value}. Valid task priority values: ({','.join(self.valid_values)})"
-		)
+    def __init__(self, value: str):
+        self.value = value
+        self.valid_values = [item.value for item in Task.Priority]
+        super().__init__(
+            f"Invalid task priority value, {value}. Valid task priority values: ({','.join(self.valid_values)})"
+        )
 
 
 class InvalidTaskDueDateError(ToolError):
-	def __init__(self, dt: str):
-		super().__init__(f"Invalid due date string, {dt}. Due dates must me an iso8601 timestamp")
+    def __init__(self, dt: str):
+        super().__init__(f"Invalid due date string, {dt}. Due dates must me an iso8601 timestamp")
diff --git a/pygittools-mcp/src/pygittools_mcp/mcp_server.py b/pygittools-mcp/src/pygittools_mcp/mcp_server.py
index f891b3d..cfa8437 100644
--- a/pygittools-mcp/src/pygittools_mcp/mcp_server.py
+++ b/pygittools-mcp/src/pygittools_mcp/mcp_server.py
@@ -8,179 +8,179 @@ import os
 from mcp.server import MCPServer
 from pygittools.tasks import Task, _parse_dt, create_task
 from pygittools.tasks_query import (
-	BoardColumnView,
-	BoardView,
-	RepoLocation,
-	TaskView,
-	board_ref,
-	discover_repo_path,
-	get_board,
-	get_board_tasks_grouped,
-	get_task_view,
-	list_boards,
-	list_tasks,
-	open_repository,
-	resolve_repo_location,
+    BoardColumnView,
+    BoardView,
+    RepoLocation,
+    TaskView,
+    board_ref,
+    discover_repo_path,
+    get_board,
+    get_board_tasks_grouped,
+    get_task_view,
+    list_boards,
+    list_tasks,
+    open_repository,
+    resolve_repo_location,
 )
 
 from pygittools_mcp.exceptions import (
-	BoardNotFoundError,
-	InvalidTaskDueDateError,
-	InvalidTaskPriorityError,
-	InvalidTaskStatusError,
-	TaskNotFoundError,
+    BoardNotFoundError,
+    InvalidTaskDueDateError,
+    InvalidTaskPriorityError,
+    InvalidTaskStatusError,
+    TaskNotFoundError,
 )
 from pygittools_mcp.models import CreateTaskToolResult
 
 mcp = MCPServer(
-	"pygittools-tasks",
-	instructions=(
-		"Read task boards stored as Git annotated tags. "
-		"Boards live under refs/tags/boards/, tasks under refs/tags/tasks/, "
-		"and comments under refs/tags/comments/. "
-		"Set PYGITTOOLS_REPO to a repository path, or PYGITWEB_PROJECTROOT plus a project name."
-	),
+    "pygittools-tasks",
+    instructions=(
+        "Read task boards stored as Git annotated tags. "
+        "Boards live under refs/tags/boards/, tasks under refs/tags/tasks/, "
+        "and comments under refs/tags/comments/. "
+        "Set PYGITTOOLS_REPO to a repository path, or PYGITWEB_PROJECTROOT plus a project name."
+    ),
 )
 
 
 def _repo_for_tool(project: str | None = None) -> RepoLocation:
-	return resolve_repo_location(project=project)
+    return resolve_repo_location(project=project)
 
 
 @mcp.tool(title="List boards")
 def list_boards_tool(project: str | None = None) -> list[BoardView]:
-	"""List all task boards in the configured repository."""
-	location = _repo_for_tool(project)
-	repo = open_repository(location)
-	return list_boards(repo)
+    """List all task boards in the configured repository."""
+    location = _repo_for_tool(project)
+    repo = open_repository(location)
+    return list_boards(repo)
 
 
 @mcp.tool(title="List tasks")
 def list_tasks_tool(
-	board: str = "Tasks",
-	project: str | None = None,
-	include_comments: bool = True,
+    board: str = "Tasks",
+    project: str | None = None,
+    include_comments: bool = True,
 ) -> list[TaskView]:
-	"""List tasks on a board, optionally including comment bodies."""
-	location = _repo_for_tool(project)
-	repo = open_repository(location)
-	try:
-		return list_tasks(repo, board, include_comments=include_comments)
-	except KeyError as exc:
-		raise BoardNotFoundError(board) from exc
+    """List tasks on a board, optionally including comment bodies."""
+    location = _repo_for_tool(project)
+    repo = open_repository(location)
+    try:
+        return list_tasks(repo, board, include_comments=include_comments)
+    except KeyError as exc:
+        raise BoardNotFoundError(board) from exc
 
 
 @mcp.tool(title="Get task")
 def get_task_tool(task_ref: str, project: str | None = None) -> TaskView:
-	"""Get one task by ref slug (task_123) or full ref (refs/tags/tasks/task_123)."""
-	location = _repo_for_tool(project)
-	repo = open_repository(location)
-	try:
-		return get_task_view(repo, task_ref, include_comments=True)
-	except KeyError as exc:
-		raise TaskNotFoundError(task_ref) from exc
+    """Get one task by ref slug (task_123) or full ref (refs/tags/tasks/task_123)."""
+    location = _repo_for_tool(project)
+    repo = open_repository(location)
+    try:
+        return get_task_view(repo, task_ref, include_comments=True)
+    except KeyError as exc:
+        raise TaskNotFoundError(task_ref) from exc
 
 
 @mcp.tool(title="Create task")
 def create_task_tool(
-	title: str,
-	description: str,
-	board: str = "Tasks",
-	status: str = "TODO",
-	priority: str = "LOW",
-	assignee: str = "",
-	due_date: str | None = None,
-	tags: str | None = None,
-	points: int | None = None,
-	project: str | None = None,
+    title: str,
+    description: str,
+    board: str = "Tasks",
+    status: str = "TODO",
+    priority: str = "LOW",
+    assignee: str = "",
+    due_date: str | None = None,
+    tags: str | None = None,
+    points: int | None = None,
+    project: str | None = None,
 ) -> CreateTaskToolResult:
-	"""Create a new task on a board."""
-	location = _repo_for_tool(project)
-	repo = open_repository(location)
-	try:
-		b = get_board(repo, board_ref(board))
-		if b is None:
-			raise BoardNotFoundError(board)
-	except KeyError as e:
-		raise BoardNotFoundError(board) from e
-
-	try:
-		s = Task.Status(status)
-	except ValueError as e:
-		raise InvalidTaskStatusError(status) from e
-
-	try:
-		p = Task.Priority(priority)
-	except ValueError as e:
-		raise InvalidTaskPriorityError(priority) from e
-
-	try:
-		d = _parse_dt(due_date)
-	except ValueError as e:
-		raise InvalidTaskDueDateError(due_date or "") from e
-
-	try:
-		result = create_task(
-			repo=repo,
-			board=b,
-			title=title,
-			description=description,
-			status=s,
-			priority=p,
-			assignee=assignee,
-			due_date=d,
-			tags=tags or "",
-			points=points,
-		)
-		return {"task_id": result["id"], "ref": result["ref"], "oid": str(result["oid"])}
-	except Exception:
-		raise
+    """Create a new task on a board."""
+    location = _repo_for_tool(project)
+    repo = open_repository(location)
+    try:
+        b = get_board(repo, board_ref(board))
+        if b is None:
+            raise BoardNotFoundError(board)
+    except KeyError as e:
+        raise BoardNotFoundError(board) from e
+
+    try:
+        s = Task.Status(status)
+    except ValueError as e:
+        raise InvalidTaskStatusError(status) from e
+
+    try:
+        p = Task.Priority(priority)
+    except ValueError as e:
+        raise InvalidTaskPriorityError(priority) from e
+
+    try:
+        d = _parse_dt(due_date)
+    except ValueError as e:
+        raise InvalidTaskDueDateError(due_date or "") from e
+
+    try:
+        result = create_task(
+            repo=repo,
+            board=b,
+            title=title,
+            description=description,
+            status=s,
+            priority=p,
+            assignee=assignee,
+            due_date=d,
+            tags=tags or "",
+            points=points,
+        )
+        return {"task_id": result["id"], "ref": result["ref"], "oid": str(result["oid"])}
+    except Exception:
+        raise
 
 
 @mcp.tool(title="Board grouped by status")
 def board_by_status_tool(
-	board: str = "Tasks",
-	project: str | None = None,
-	include_comments: bool = True,
+    board: str = "Tasks",
+    project: str | None = None,
+    include_comments: bool = True,
 ) -> list[BoardColumnView]:
-	"""Return board tasks grouped into status columns (TODO, IN_PROGRESS, etc.)."""
-	location = _repo_for_tool(project)
-	repo = open_repository(location)
-	try:
-		return get_board_tasks_grouped(repo, board, include_comments=include_comments)
-	except KeyError as exc:
-		raise ValueError(str(exc)) from exc
+    """Return board tasks grouped into status columns (TODO, IN_PROGRESS, etc.)."""
+    location = _repo_for_tool(project)
+    repo = open_repository(location)
+    try:
+        return get_board_tasks_grouped(repo, board, include_comments=include_comments)
+    except KeyError as exc:
+        raise ValueError(str(exc)) from exc
 
 
 @mcp.resource("board://{board_name}")
 def board_resource(board_name: str) -> str:
-	"""JSON snapshot of a board grouped by status."""
-	location = resolve_repo_location()
-	repo = open_repository(location)
-	columns = get_board_tasks_grouped(repo, board_name, include_comments=True)
-	return json.dumps(columns, indent=2)
+    """JSON snapshot of a board grouped by status."""
+    location = resolve_repo_location()
+    repo = open_repository(location)
+    columns = get_board_tasks_grouped(repo, board_name, include_comments=True)
+    return json.dumps(columns, indent=2)
 
 
 @mcp.resource("task://{task_ref}")
 def task_resource(task_ref: str) -> str:
-	"""JSON snapshot of a single task with comments."""
-	location = resolve_repo_location()
-	repo = open_repository(location)
-	task = get_task_view(repo, task_ref, include_comments=True)
-	return json.dumps(task, indent=2)
+    """JSON snapshot of a single task with comments."""
+    location = resolve_repo_location()
+    repo = open_repository(location)
+    task = get_task_view(repo, task_ref, include_comments=True)
+    return json.dumps(task, indent=2)
 
 
 def main() -> None:
-	repo_env = os.environ.get("PYGITTOOLS_REPO", "").strip()
-	if not repo_env:
-		try:
-			discovered = discover_repo_path()
-		except FileNotFoundError:
-			discovered = None
-		if discovered is not None:
-			os.environ["PYGITTOOLS_REPO"] = str(discovered)
-	mcp.run(transport="stdio")
+    repo_env = os.environ.get("PYGITTOOLS_REPO", "").strip()
+    if not repo_env:
+        try:
+            discovered = discover_repo_path()
+        except FileNotFoundError:
+            discovered = None
+        if discovered is not None:
+            os.environ["PYGITTOOLS_REPO"] = str(discovered)
+    mcp.run(transport="stdio")
 
 
 if __name__ == "__main__":
-	main()
+    main()
diff --git a/pygittools-mcp/src/pygittools_mcp/mcp_test.py b/pygittools-mcp/src/pygittools_mcp/mcp_test.py
index cbf71c9..2fe58f6 100644
--- a/pygittools-mcp/src/pygittools_mcp/mcp_test.py
+++ b/pygittools-mcp/src/pygittools_mcp/mcp_test.py
@@ -13,10 +13,10 @@ from pygittools.tasks import EMPTY_TREE_OID_HEX, Board, CreateTaskResult, Task, 
 from pygittools.tasks_query import TaskView, board_ref
 
 from pygittools_mcp.exceptions import (
-	BoardNotFoundError,
-	InvalidTaskDueDateError,
-	InvalidTaskPriorityError,
-	InvalidTaskStatusError,
+    BoardNotFoundError,
+    InvalidTaskDueDateError,
+    InvalidTaskPriorityError,
+    InvalidTaskStatusError,
 )
 from pygittools_mcp.mcp_server import mcp
 from pygittools_mcp.models import CreateTaskToolResult
@@ -26,208 +26,208 @@ TAGGER: str = "alice <alice@example.com>"
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	"""Create a temp repo with a Tasks board so create_task_tool can find it."""
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-
-	repo = init_repository(str(repo_path), bare=False)
-
-	# Set up initial commit on main
-	import subprocess
-
-	os_repo_path = str(repo_path)
-	subprocess.run(
-		["git", "config", "user.email", "test@test.com"],
-		cwd=os_repo_path,
-		check=True,
-	)
-	subprocess.run(
-		["git", "config", "user.name", "Test"],
-		cwd=os_repo_path,
-		check=True,
-	)
-	with open(os.path.join(os_repo_path, "README.md"), "w") as f:
-		f.write("# initial\n")
-	subprocess.run(["git", "add", "README.md"], cwd=os_repo_path, check=True)
-	subprocess.run(
-		["git", "commit", "-m", "initial"],
-		cwd=os_repo_path,
-		check=True,
-		capture_output=True,
-		text=True,
-	)
-
-	# Create the Tasks board ref so create_task_tool.get_board() can find it
-	eoi = Oid(hex=EMPTY_TREE_OID_HEX)
-	tasks_board = Board(eoi, "refs/tags/boards/Tasks", TAGGER, description="Tasks")
-	tasks_board.write(repo)
-	return repo
+    """Create a temp repo with a Tasks board so create_task_tool can find it."""
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+
+    repo = init_repository(str(repo_path), bare=False)
+
+    # Set up initial commit on main
+    import subprocess
+
+    os_repo_path = str(repo_path)
+    subprocess.run(
+        ["git", "config", "user.email", "test@test.com"],
+        cwd=os_repo_path,
+        check=True,
+    )
+    subprocess.run(
+        ["git", "config", "user.name", "Test"],
+        cwd=os_repo_path,
+        check=True,
+    )
+    with open(os.path.join(os_repo_path, "README.md"), "w") as f:
+        f.write("# initial\n")
+    subprocess.run(["git", "add", "README.md"], cwd=os_repo_path, check=True)
+    subprocess.run(
+        ["git", "commit", "-m", "initial"],
+        cwd=os_repo_path,
+        check=True,
+        capture_output=True,
+        text=True,
+    )
+
+    # Create the Tasks board ref so create_task_tool.get_board() can find it
+    eoi = Oid(hex=EMPTY_TREE_OID_HEX)
+    tasks_board = Board(eoi, "refs/tags/boards/Tasks", TAGGER, description="Tasks")
+    tasks_board.write(repo)
+    return repo
 
 
 @pytest.fixture
 def test_task(repo: Repository) -> CreateTaskResult:
-	board = get_board(repo, board_ref("Tasks"))
-	assert board is not None
-	return create_task(repo, board, title="Test Task")
+    board = get_board(repo, board_ref("Tasks"))
+    assert board is not None
+    return create_task(repo, board, title="Test Task")
 
 
 @pytest.fixture
 async def mcp_client(repo: Repository, monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[MCPClient, None]:
-	monkeypatch.setenv("PYGITTOOLS_REPO", repo.path)
-	async with MCPClient(mcp) as client:
-		yield client
+    monkeypatch.setenv("PYGITTOOLS_REPO", repo.path)
+    async with MCPClient(mcp) as client:
+        yield client
 
 
 def assert_default_board(board: dict[str, Any]):
-	assert board["name"] == "Tasks"
-	assert board["description"] == "Tasks"
-	assert board["task_count"] == 0
+    assert board["name"] == "Tasks"
+    assert board["description"] == "Tasks"
+    assert board["task_count"] == 0
 
 
 def assert_is_test_task(view: TaskView, task: CreateTaskResult):
-	assert view.get("ref") == task["ref"]
-	assert view.get("title") == task["task"].title
-	assert view.get("description") == task["task"].description
-	view_status = view.get("status")
-	task_status = task["task"].status
-	assert view_status == task_status.value if task_status else None
-	view_priority = view.get("priority")
-	task_priority = task["task"].priority
-	assert view_priority == task_priority.value if task_priority else None
-	view_due_date = view.get("due_date")
-	task_due_date = task["task"].due_date
-	if view_due_date:
-		assert view_due_date == task_due_date.isoformat() if task_due_date else None
-	if t := view.get("created_at"):
-		assert t == task["task"].created_at.isoformat()
-	if t := view.get("updated_at"):
-		assert t == task["task"].updated_at.isoformat()
+    assert view.get("ref") == task["ref"]
+    assert view.get("title") == task["task"].title
+    assert view.get("description") == task["task"].description
+    view_status = view.get("status")
+    task_status = task["task"].status
+    assert view_status == task_status.value if task_status else None
+    view_priority = view.get("priority")
+    task_priority = task["task"].priority
+    assert view_priority == task_priority.value if task_priority else None
+    view_due_date = view.get("due_date")
+    task_due_date = task["task"].due_date
+    if view_due_date:
+        assert view_due_date == task_due_date.isoformat() if task_due_date else None
+    if t := view.get("created_at"):
+        assert t == task["task"].created_at.isoformat()
+    if t := view.get("updated_at"):
+        assert t == task["task"].updated_at.isoformat()
 
 
 @pytest.mark.anyio
 async def test_list_boards_tool(mcp_client: MCPClient) -> None:
-	result = await mcp_client.call_tool("list_boards_tool", {"project": None})
-	assert not result.is_error, result.content
-	content = result.structured_content["result"]
-	assert isinstance(content, list)
-	assert len(content) == 1
-	assert_default_board(content[0])
+    result = await mcp_client.call_tool("list_boards_tool", {"project": None})
+    assert not result.is_error, result.content
+    content = result.structured_content["result"]
+    assert isinstance(content, list)
+    assert len(content) == 1
+    assert_default_board(content[0])
 
 
 @pytest.mark.anyio
 async def test_list_tasks_tool(mcp_client: MCPClient, test_task: CreateTaskResult) -> None:
-	result = await mcp_client.call_tool("list_tasks_tool", {})
-	assert not result.is_error, result.content
-	content = result.structured_content["result"]
-	assert isinstance(content, list)
-	assert len(content) == 1
-	assert_is_test_task(content[0], test_task)
+    result = await mcp_client.call_tool("list_tasks_tool", {})
+    assert not result.is_error, result.content
+    content = result.structured_content["result"]
+    assert isinstance(content, list)
+    assert len(content) == 1
+    assert_is_test_task(content[0], test_task)
 
 
 @pytest.mark.anyio
 async def test_get_task_tool(mcp_client: MCPClient, test_task: CreateTaskResult) -> None:
-	result = await mcp_client.call_tool("get_task_tool", {"task_ref": test_task["ref"]})
-	assert not result.is_error, result.content
-	assert_is_test_task(result.structured_content, test_task)
+    result = await mcp_client.call_tool("get_task_tool", {"task_ref": test_task["ref"]})
+    assert not result.is_error, result.content
+    assert_is_test_task(result.structured_content, test_task)
 
 
 @pytest.mark.anyio
 async def test_get_task_tool_not_found(mcp_client: MCPClient) -> None:
-	result = await mcp_client.call_tool("get_task_tool", {"task_ref": "fake-task-ref"})
-	assert result.is_error
+    result = await mcp_client.call_tool("get_task_tool", {"task_ref": "fake-task-ref"})
+    assert result.is_error
 
 
 @pytest.mark.anyio
 async def test_create_task_tool_success(mcp_client: MCPClient) -> None:
-	title = "Test Task"
-	description = "A test task"
-	create_result = await mcp_client.call_tool("create_task_tool", {"title": title, "description": description})
-	assert not create_result.is_error
-	task: CreateTaskToolResult = create_result.structured_content
-	task_ref = task["ref"]
+    title = "Test Task"
+    description = "A test task"
+    create_result = await mcp_client.call_tool("create_task_tool", {"title": title, "description": description})
+    assert not create_result.is_error
+    task: CreateTaskToolResult = create_result.structured_content
+    task_ref = task["ref"]
 
-	get_result = await mcp_client.call_tool("get_task_tool", {"task_ref": task_ref})
-	assert not get_result.is_error
-	view: TaskView = get_result.structured_content
-	assert view.get("ref") == task["ref"]
-	assert view.get("title") == title
-	assert view.get("description") == description
+    get_result = await mcp_client.call_tool("get_task_tool", {"task_ref": task_ref})
+    assert not get_result.is_error
+    view: TaskView = get_result.structured_content
+    assert view.get("ref") == task["ref"]
+    assert view.get("title") == title
+    assert view.get("description") == description
 
 
 @pytest.mark.anyio
 async def test_create_task_invalid_board(mcp_client: MCPClient) -> None:
-	title = "Test Task"
-	description = "A test task"
-	board = "Fake-Board"
-	create_result = await mcp_client.call_tool(
-		"create_task_tool", {"title": title, "description": description, "board": board}
-	)
-	assert create_result.is_error, create_result.content
-	assert str(BoardNotFoundError(board)) in create_result.content[0].text
+    title = "Test Task"
+    description = "A test task"
+    board = "Fake-Board"
+    create_result = await mcp_client.call_tool(
+        "create_task_tool", {"title": title, "description": description, "board": board}
+    )
+    assert create_result.is_error, create_result.content
+    assert str(BoardNotFoundError(board)) in create_result.content[0].text
 
 
 @pytest.mark.anyio
 async def test_create_task_invalid_status(mcp_client: MCPClient) -> None:
-	title = "Test Task"
-	description = "A test task"
-	status = "invalid"
-	create_result = await mcp_client.call_tool(
-		"create_task_tool", {"title": title, "description": description, "status": status}
-	)
-	assert create_result.is_error
-	assert str(InvalidTaskStatusError(status)) in create_result.content[0].text
+    title = "Test Task"
+    description = "A test task"
+    status = "invalid"
+    create_result = await mcp_client.call_tool(
+        "create_task_tool", {"title": title, "description": description, "status": status}
+    )
+    assert create_result.is_error
+    assert str(InvalidTaskStatusError(status)) in create_result.content[0].text
 
 
 @pytest.mark.anyio
 async def test_create_task_invalid_priority(mcp_client: MCPClient) -> None:
-	title = "Test Task"
-	description = "A test task"
-	priority = "invalid"
-	create_result = await mcp_client.call_tool(
-		"create_task_tool", {"title": title, "description": description, "priority": priority}
-	)
-	assert create_result.is_error
-	assert str(InvalidTaskPriorityError(priority)) in create_result.content[0].text
+    title = "Test Task"
+    description = "A test task"
+    priority = "invalid"
+    create_result = await mcp_client.call_tool(
+        "create_task_tool", {"title": title, "description": description, "priority": priority}
+    )
+    assert create_result.is_error
+    assert str(InvalidTaskPriorityError(priority)) in create_result.content[0].text
 
 
 @pytest.mark.anyio
 async def test_create_task_invalid_due_date(mcp_client: MCPClient):
-	title = "Test Task"
-	description = "A test task"
-	due_date = "soon"
-	create_result = await mcp_client.call_tool(
-		"create_task_tool", {"title": title, "description": description, "due_date": due_date}
-	)
-	assert create_result.is_error
-	assert str(InvalidTaskDueDateError(due_date)) in create_result.content[0].text
+    title = "Test Task"
+    description = "A test task"
+    due_date = "soon"
+    create_result = await mcp_client.call_tool(
+        "create_task_tool", {"title": title, "description": description, "due_date": due_date}
+    )
+    assert create_result.is_error
+    assert str(InvalidTaskDueDateError(due_date)) in create_result.content[0].text
 
 
 @pytest.mark.anyio
 async def test_create_full_task(mcp_client: MCPClient):
-	call_args = {
-		"title": "Test Task",
-		"description": "Test task description",
-		"board": "Tasks",
-		"status": Task.Status.IN_PROGRESS.value,
-		"priority": Task.Priority.HIGH.value,
-		"assignee": "ahoyt41",
-		"due_duate": (datetime.now() + timedelta(days=5)).isoformat(),
-		"tags": '("foo"="bar")',
-		"points": 3,
-	}
-	create_result = await mcp_client.call_tool("create_task_tool", call_args)
-	assert not create_result.is_error, create_result.content
-	assert create_result.structured_content
-	task: CreateTaskToolResult = create_result.structured_content
-
-	get_result = await mcp_client.call_tool("get_task_tool", {"task_ref": task["ref"]})
-	assert not get_result.is_error
-	assert get_result.structured_content
-	view: TaskView = get_result.structured_content
-
-	assert view.get("assignee") == call_args["assignee"]
-	assert view.get("description") == call_args["description"]
-	assert view.get("status") == call_args["status"]
-	assert view.get("priority") == call_args["priority"]
-	assert view.get("title") == call_args["title"]
-	assert view.get("ref") == task["ref"]
+    call_args = {
+        "title": "Test Task",
+        "description": "Test task description",
+        "board": "Tasks",
+        "status": Task.Status.IN_PROGRESS.value,
+        "priority": Task.Priority.HIGH.value,
+        "assignee": "ahoyt41",
+        "due_duate": (datetime.now() + timedelta(days=5)).isoformat(),
+        "tags": '("foo"="bar")',
+        "points": 3,
+    }
+    create_result = await mcp_client.call_tool("create_task_tool", call_args)
+    assert not create_result.is_error, create_result.content
+    assert create_result.structured_content
+    task: CreateTaskToolResult = create_result.structured_content
+
+    get_result = await mcp_client.call_tool("get_task_tool", {"task_ref": task["ref"]})
+    assert not get_result.is_error
+    assert get_result.structured_content
+    view: TaskView = get_result.structured_content
+
+    assert view.get("assignee") == call_args["assignee"]
+    assert view.get("description") == call_args["description"]
+    assert view.get("status") == call_args["status"]
+    assert view.get("priority") == call_args["priority"]
+    assert view.get("title") == call_args["title"]
+    assert view.get("ref") == task["ref"]
diff --git a/pygittools-mcp/src/pygittools_mcp/models.py b/pygittools-mcp/src/pygittools_mcp/models.py
index 439efd6..a045170 100644
--- a/pygittools-mcp/src/pygittools_mcp/models.py
+++ b/pygittools-mcp/src/pygittools_mcp/models.py
@@ -2,6 +2,6 @@ from typing import TypedDict
 
 
 class CreateTaskToolResult(TypedDict, total=True):
-	task_id: str
-	ref: str
-	oid: str
+    task_id: str
+    ref: str
+    oid: str
diff --git a/pygittools/src/pygittools/actions.py b/pygittools/src/pygittools/actions.py
index 73184a1..9c3757b 100644
--- a/pygittools/src/pygittools/actions.py
+++ b/pygittools/src/pygittools/actions.py
@@ -20,303 +20,303 @@ _FLAT_FILE_SET_NAME = "actions"
 
 @dataclass(frozen=True, slots=True)
 class ActionSpec:
-	name: str
-	script: str
-	args: tuple[str, ...] = ()
+    name: str
+    script: str
+    args: tuple[str, ...] = ()
 
 
 @dataclass(frozen=True, slots=True)
 class ActionSet:
-	"""A named group of actions the TUI can cycle through."""
+    """A named group of actions the TUI can cycle through."""
 
-	name: str
-	specs: tuple[ActionSpec, ...]
+    name: str
+    specs: tuple[ActionSpec, ...]
 
 
 class Action(AnnotatedABC):
-	"""Run a named project action and return ``(ok, message)``."""
+    """Run a named project action and return ``(ok, message)``."""
 
-	@abstractmethod
-	def action(self, name: str) -> tuple[bool, str]: ...
+    @abstractmethod
+    def action(self, name: str) -> tuple[bool, str]: ...
 
 
 def find_project_root(start: Path) -> Path | None:
-	"""Walk upward for ``pyproject.toml``, else the nearest ``actions.toml``."""
-	current = start.resolve()
-	actions_root: Path | None = None
-	for path in [current, *current.parents]:
-		if (path / "pyproject.toml").is_file():
-			return path
-		if actions_root is None and (path / _ACTIONS_FILE).is_file():
-			actions_root = path
-	return actions_root
+    """Walk upward for ``pyproject.toml``, else the nearest ``actions.toml``."""
+    current = start.resolve()
+    actions_root: Path | None = None
+    for path in [current, *current.parents]:
+        if (path / "pyproject.toml").is_file():
+            return path
+        if actions_root is None and (path / _ACTIONS_FILE).is_file():
+            actions_root = path
+    return actions_root
 
 
 def load_action_specs(project_root: Path) -> list[ActionSpec]:
-	"""Read ``[tool.pgt].actions`` from the project's ``pyproject.toml``."""
-	pgt = _pyproject_pgt(project_root)
-	if pgt is None:
-		return []
-	raw = pgt.get(_ACTIONS_KEY)
-	if raw is None:
-		return []
-	return _parse_actions(raw, where="[tool.pgt].actions")
+    """Read ``[tool.pgt].actions`` from the project's ``pyproject.toml``."""
+    pgt = _pyproject_pgt(project_root)
+    if pgt is None:
+        return []
+    raw = pgt.get(_ACTIONS_KEY)
+    if raw is None:
+        return []
+    return _parse_actions(raw, where="[tool.pgt].actions")
 
 
 def load_action_sets(project_root: Path) -> list[ActionSet]:
-	"""Load action sets from ``pyproject.toml`` and a root ``actions.toml``.
+    """Load action sets from ``pyproject.toml`` and a root ``actions.toml``.
 
-	``[tool.pgt].actions`` is one set (named ``default``, or ``[tool.pgt].name``).
-	``[tool.pgt.sets.<name>]`` tables are further sets. ``actions.toml`` may repeat
-	that shape, and each other top-level table is also a set:
+    ``[tool.pgt].actions`` is one set (named ``default``, or ``[tool.pgt].name``).
+    ``[tool.pgt.sets.<name>]`` tables are further sets. ``actions.toml`` may repeat
+    that shape, and each other top-level table is also a set:
 
-	.. code-block:: toml
+    .. code-block:: toml
 
-	[quality]
-	actions = [
-		{ name = "lint", script = "scripts/lint.py" },
-	]
-	"""
-	sets = [*_pyproject_sets(project_root), *_actions_file_sets(project_root)]
-	seen: set[str] = set()
-	for action_set in sets:
-		if action_set.name in seen:
-			raise ValueError(f"Duplicate action set name: {action_set.name}")
-		seen.add(action_set.name)
-	return sets
+    [quality]
+    actions = [
+            { name = "lint", script = "scripts/lint.py" },
+    ]
+    """
+    sets = [*_pyproject_sets(project_root), *_actions_file_sets(project_root)]
+    seen: set[str] = set()
+    for action_set in sets:
+        if action_set.name in seen:
+            raise ValueError(f"Duplicate action set name: {action_set.name}")
+        seen.add(action_set.name)
+    return sets
 
 
 def _pyproject_pgt(project_root: Path) -> dict[str, object] | None:
-	pyproject = project_root / "pyproject.toml"
-	if not pyproject.is_file():
-		return None
-	data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
-	tool = data.get("tool")
-	if not isinstance(tool, dict):
-		return None
-	pgt = tool.get(_TOOL_TABLE)
-	if not isinstance(pgt, dict):
-		return None
-	return pgt
+    pyproject = project_root / "pyproject.toml"
+    if not pyproject.is_file():
+        return None
+    data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
+    tool = data.get("tool")
+    if not isinstance(tool, dict):
+        return None
+    pgt = tool.get(_TOOL_TABLE)
+    if not isinstance(pgt, dict):
+        return None
+    return pgt
 
 
 def _pyproject_sets(project_root: Path) -> list[ActionSet]:
-	pgt = _pyproject_pgt(project_root)
-	if pgt is None:
-		return []
-	sets: list[ActionSet] = []
-	flat = _parse_flat_set(
-		pgt,
-		default_name=_DEFAULT_SET_NAME,
-		actions_where="[tool.pgt].actions",
-		name_where="[tool.pgt].name",
-	)
-	if flat is not None:
-		sets.append(flat)
-	raw_sets = pgt.get("sets")
-	if raw_sets is not None:
-		sets.extend(_parse_named_tables(raw_sets, where="[tool.pgt].sets"))
-	return sets
+    pgt = _pyproject_pgt(project_root)
+    if pgt is None:
+        return []
+    sets: list[ActionSet] = []
+    flat = _parse_flat_set(
+        pgt,
+        default_name=_DEFAULT_SET_NAME,
+        actions_where="[tool.pgt].actions",
+        name_where="[tool.pgt].name",
+    )
+    if flat is not None:
+        sets.append(flat)
+    raw_sets = pgt.get("sets")
+    if raw_sets is not None:
+        sets.extend(_parse_named_tables(raw_sets, where="[tool.pgt].sets"))
+    return sets
 
 
 def _actions_file_sets(project_root: Path) -> list[ActionSet]:
-	path = project_root / _ACTIONS_FILE
-	if not path.is_file():
-		return []
-	data = tomllib.loads(path.read_text(encoding="utf-8"))
-	if not isinstance(data, dict):
-		raise ValueError("actions.toml must be a table")
-	sets: list[ActionSet] = []
-	actions_value = data.get(_ACTIONS_KEY)
-	consumed = {"name"}
-	if isinstance(actions_value, list):
-		consumed.add(_ACTIONS_KEY)
-		flat = _parse_flat_set(
-			data,
-			default_name=_FLAT_FILE_SET_NAME,
-			actions_where="actions.toml actions",
-			name_where="actions.toml name",
-		)
-		if flat is not None:
-			sets.append(flat)
-	elif "name" in data:
-		raise ValueError("actions.toml 'name' requires an 'actions' array")
-	for key, value in data.items():
-		if key in consumed:
-			continue
-		if not isinstance(key, str) or not key.strip():
-			raise ValueError("actions.toml set name must be a non-empty string")
-		if not isinstance(value, dict):
-			raise ValueError(f"actions.toml unexpected key {key!r}")
-		specs = _specs_from_set_table(value, where=f"actions.toml [{key.strip()}]")
-		if specs:
-			sets.append(ActionSet(name=key.strip(), specs=specs))
-	return sets
+    path = project_root / _ACTIONS_FILE
+    if not path.is_file():
+        return []
+    data = tomllib.loads(path.read_text(encoding="utf-8"))
+    if not isinstance(data, dict):
+        raise ValueError("actions.toml must be a table")
+    sets: list[ActionSet] = []
+    actions_value = data.get(_ACTIONS_KEY)
+    consumed = {"name"}
+    if isinstance(actions_value, list):
+        consumed.add(_ACTIONS_KEY)
+        flat = _parse_flat_set(
+            data,
+            default_name=_FLAT_FILE_SET_NAME,
+            actions_where="actions.toml actions",
+            name_where="actions.toml name",
+        )
+        if flat is not None:
+            sets.append(flat)
+    elif "name" in data:
+        raise ValueError("actions.toml 'name' requires an 'actions' array")
+    for key, value in data.items():
+        if key in consumed:
+            continue
+        if not isinstance(key, str) or not key.strip():
+            raise ValueError("actions.toml set name must be a non-empty string")
+        if not isinstance(value, dict):
+            raise ValueError(f"actions.toml unexpected key {key!r}")
+        specs = _specs_from_set_table(value, where=f"actions.toml [{key.strip()}]")
+        if specs:
+            sets.append(ActionSet(name=key.strip(), specs=specs))
+    return sets
 
 
 def _parse_flat_set(
-	data: dict[str, object],
-	*,
-	default_name: str,
-	actions_where: str,
-	name_where: str,
+    data: dict[str, object],
+    *,
+    default_name: str,
+    actions_where: str,
+    name_where: str,
 ) -> ActionSet | None:
-	if _ACTIONS_KEY not in data:
-		if "name" in data:
-			raise ValueError(f"{name_where} requires 'actions'")
-		return None
-	raw_name = data.get("name", default_name)
-	if not isinstance(raw_name, str) or not raw_name.strip():
-		raise ValueError(f"{name_where} must be a non-empty string")
-	specs = tuple(_parse_actions(data[_ACTIONS_KEY], where=actions_where))
-	if not specs:
-		return None
-	return ActionSet(name=raw_name.strip(), specs=specs)
+    if _ACTIONS_KEY not in data:
+        if "name" in data:
+            raise ValueError(f"{name_where} requires 'actions'")
+        return None
+    raw_name = data.get("name", default_name)
+    if not isinstance(raw_name, str) or not raw_name.strip():
+        raise ValueError(f"{name_where} must be a non-empty string")
+    specs = tuple(_parse_actions(data[_ACTIONS_KEY], where=actions_where))
+    if not specs:
+        return None
+    return ActionSet(name=raw_name.strip(), specs=specs)
 
 
 def _parse_named_tables(raw: object, *, where: str) -> list[ActionSet]:
-	if not isinstance(raw, dict):
-		raise ValueError(f"{where} must be a table of action sets")
-	sets: list[ActionSet] = []
-	for key, value in raw.items():
-		if not isinstance(key, str) or not key.strip():
-			raise ValueError(f"{where} set name must be a non-empty string")
-		if not isinstance(value, dict):
-			raise ValueError(f"{where}.{key} must be a table")
-		name = key.strip()
-		specs = _specs_from_set_table(value, where=f"{where}.{name}")
-		if specs:
-			sets.append(ActionSet(name=name, specs=specs))
-	return sets
+    if not isinstance(raw, dict):
+        raise ValueError(f"{where} must be a table of action sets")
+    sets: list[ActionSet] = []
+    for key, value in raw.items():
+        if not isinstance(key, str) or not key.strip():
+            raise ValueError(f"{where} set name must be a non-empty string")
+        if not isinstance(value, dict):
+            raise ValueError(f"{where}.{key} must be a table")
+        name = key.strip()
+        specs = _specs_from_set_table(value, where=f"{where}.{name}")
+        if specs:
+            sets.append(ActionSet(name=name, specs=specs))
+    return sets
 
 
 def _specs_from_set_table(value: dict[str, object], *, where: str) -> tuple[ActionSpec, ...]:
-	if _ACTIONS_KEY not in value:
-		raise ValueError(f"{where} missing 'actions'")
-	return tuple(_parse_actions(value[_ACTIONS_KEY], where=f"{where}.actions"))
+    if _ACTIONS_KEY not in value:
+        raise ValueError(f"{where} missing 'actions'")
+    return tuple(_parse_actions(value[_ACTIONS_KEY], where=f"{where}.actions"))
 
 
 def _parse_actions(raw: object, *, where: str) -> list[ActionSpec]:
-	if not isinstance(raw, list):
-		raise ValueError(f"{where} must be an array of name/script entries")
-	specs: list[ActionSpec] = []
-	for index, entry in enumerate(raw):
-		specs.append(_parse_action_entry(entry, where=f"{where}[{index}]"))
-	return specs
+    if not isinstance(raw, list):
+        raise ValueError(f"{where} must be an array of name/script entries")
+    specs: list[ActionSpec] = []
+    for index, entry in enumerate(raw):
+        specs.append(_parse_action_entry(entry, where=f"{where}[{index}]"))
+    return specs
 
 
 def _parse_action_entry(entry: object, *, where: str) -> ActionSpec:
-	if isinstance(entry, dict):
-		name = entry.get("name")
-		script = entry.get("script")
-		if not isinstance(name, str) or not name.strip():
-			raise ValueError(f"{where} missing string 'name'")
-		if not isinstance(script, str) or not script.strip():
-			raise ValueError(f"{where} missing string 'script'")
-		return ActionSpec(name=name.strip(), script=script.strip(), args=_parse_args(entry.get("args"), where=where))
-	if isinstance(entry, list) and len(entry) == 2:
-		name, script = entry
-		if not isinstance(name, str) or not name.strip():
-			raise ValueError(f"{where} name must be a non-empty string")
-		if not isinstance(script, str) or not script.strip():
-			raise ValueError(f"{where} script must be a non-empty string")
-		return ActionSpec(name=name.strip(), script=script.strip())
-	raise ValueError(f"{where} must be {{name=..., script=...}} or [name, script]")
+    if isinstance(entry, dict):
+        name = entry.get("name")
+        script = entry.get("script")
+        if not isinstance(name, str) or not name.strip():
+            raise ValueError(f"{where} missing string 'name'")
+        if not isinstance(script, str) or not script.strip():
+            raise ValueError(f"{where} missing string 'script'")
+        return ActionSpec(name=name.strip(), script=script.strip(), args=_parse_args(entry.get("args"), where=where))
+    if isinstance(entry, list) and len(entry) == 2:
+        name, script = entry
+        if not isinstance(name, str) or not name.strip():
+            raise ValueError(f"{where} name must be a non-empty string")
+        if not isinstance(script, str) or not script.strip():
+            raise ValueError(f"{where} script must be a non-empty string")
+        return ActionSpec(name=name.strip(), script=script.strip())
+    raise ValueError(f"{where} must be {{name=..., script=...}} or [name, script]")
 
 
 def _parse_args(raw: object, *, where: str) -> tuple[str, ...]:
-	if raw is None:
-		return ()
-	if not isinstance(raw, list):
-		raise ValueError(f"{where} 'args' must be an array of strings")
-	args: list[str] = []
-	for index, item in enumerate(raw):
-		if not isinstance(item, str) or item == "":
-			raise ValueError(f"{where} args[{index}] must be a non-empty string")
-		args.append(item)
-	return tuple(args)
+    if raw is None:
+        return ()
+    if not isinstance(raw, list):
+        raise ValueError(f"{where} 'args' must be an array of strings")
+    args: list[str] = []
+    for index, item in enumerate(raw):
+        if not isinstance(item, str) or item == "":
+            raise ValueError(f"{where} args[{index}] must be a non-empty string")
+        args.append(item)
+    return tuple(args)
 
 
 def project_venv_python(project_root: Path) -> Path | None:
-	candidate = project_root / ".venv" / "bin" / "python"
-	if candidate.is_file():
-		return candidate
-	windows = project_root / ".venv" / "Scripts" / "python.exe"
-	if windows.is_file():
-		return windows
-	return None
+    candidate = project_root / ".venv" / "bin" / "python"
+    if candidate.is_file():
+        return candidate
+    windows = project_root / ".venv" / "Scripts" / "python.exe"
+    if windows.is_file():
+        return windows
+    return None
 
 
 def run_action_script(project_root: Path, script: str, args: tuple[str, ...] = ()) -> tuple[bool, str]:
-	"""Execute ``script`` inside the project's ``.venv`` and return ``(ok, message)``."""
-	raw_script = Path(script)
-	script_path = raw_script.resolve() if raw_script.is_absolute() else (project_root / raw_script).resolve()
-	if not script_path.is_file():
-		return False, f"Script not found: {script_path}"
-
-	venv_python = project_venv_python(project_root)
-	if venv_python is None:
-		return False, f"Project venv not found under {project_root / '.venv'}"
-
-	venv_dir = venv_python.parent.parent
-	venv_bin = venv_python.parent
-	env = os.environ.copy()
-	env["VIRTUAL_ENV"] = str(venv_dir)
-	env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
-	env.pop("PYTHONHOME", None)
-
-	if script_path.suffix == ".py":
-		cmd = [str(venv_python), str(script_path), *args]
-	elif os.access(script_path, os.X_OK):
-		cmd = [str(script_path), *args]
-	else:
-		cmd = ["bash", str(script_path), *args]
-
-	proc = subprocess.run(
-		cmd,
-		cwd=project_root,
-		capture_output=True,
-		text=True,
-		env=env,
-		check=False,
-	)
-	output = _result_message(proc.stdout, proc.stderr)
-	if proc.returncode == 0:
-		return True, output or "ok"
-	if output:
-		return False, output
-	return False, f"exit {proc.returncode}"
+    """Execute ``script`` inside the project's ``.venv`` and return ``(ok, message)``."""
+    raw_script = Path(script)
+    script_path = raw_script.resolve() if raw_script.is_absolute() else (project_root / raw_script).resolve()
+    if not script_path.is_file():
+        return False, f"Script not found: {script_path}"
+
+    venv_python = project_venv_python(project_root)
+    if venv_python is None:
+        return False, f"Project venv not found under {project_root / '.venv'}"
+
+    venv_dir = venv_python.parent.parent
+    venv_bin = venv_python.parent
+    env = os.environ.copy()
+    env["VIRTUAL_ENV"] = str(venv_dir)
+    env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
+    env.pop("PYTHONHOME", None)
+
+    if script_path.suffix == ".py":
+        cmd = [str(venv_python), str(script_path), *args]
+    elif os.access(script_path, os.X_OK):
+        cmd = [str(script_path), *args]
+    else:
+        cmd = ["bash", str(script_path), *args]
+
+    proc = subprocess.run(
+        cmd,
+        cwd=project_root,
+        capture_output=True,
+        text=True,
+        env=env,
+        check=False,
+    )
+    output = _result_message(proc.stdout, proc.stderr)
+    if proc.returncode == 0:
+        return True, output or "ok"
+    if output:
+        return False, output
+    return False, f"exit {proc.returncode}"
 
 
 def _result_message(stdout: str, stderr: str) -> str:
-	text = (stdout or "").strip() or (stderr or "").strip()
-	if not text:
-		return ""
-	lines = text.splitlines()
-	return lines[-1] if lines else text
+    text = (stdout or "").strip() or (stderr or "").strip()
+    if not text:
+        return ""
+    lines = text.splitlines()
+    return lines[-1] if lines else text
 
 
 @Action.register
 class ProjectActions:
-	"""Default action runner backed by ``[tool.pgt].actions``.
+    """Default action runner backed by ``[tool.pgt].actions``.
 
-	Pass ``specs`` to run one set loaded by :func:`load_action_sets`.
-	"""
+    Pass ``specs`` to run one set loaded by :func:`load_action_sets`.
+    """
 
-	def __init__(self, project_root: Path, specs: list[ActionSpec] | None = None) -> None:
-		self._root = project_root.resolve()
-		self._specs = {spec.name: spec for spec in (specs if specs is not None else load_action_specs(self._root))}
+    def __init__(self, project_root: Path, specs: list[ActionSpec] | None = None) -> None:
+        self._root = project_root.resolve()
+        self._specs = {spec.name: spec for spec in (specs if specs is not None else load_action_specs(self._root))}
 
-	@property
-	def specs(self) -> list[ActionSpec]:
-		return list(self._specs.values())
+    @property
+    def specs(self) -> list[ActionSpec]:
+        return list(self._specs.values())
 
-	def action(self, name: str) -> tuple[bool, str]:
-		spec = self._specs.get(name)
-		if spec is None:
-			return False, f"Unknown action: {name}"
-		return run_action_script(self._root, spec.script, spec.args)
+    def action(self, name: str) -> tuple[bool, str]:
+        spec = self._specs.get(name)
+        if spec is None:
+            return False, f"Unknown action: {name}"
+        return run_action_script(self._root, spec.script, spec.args)
 
 
 def project_actions(project_root: Path) -> ProjectActions:
-	return ProjectActions(project_root)
+    return ProjectActions(project_root)
diff --git a/pygittools/src/pygittools/actions_test.py b/pygittools/src/pygittools/actions_test.py
index c4ddafb..cdd3f6d 100644
--- a/pygittools/src/pygittools/actions_test.py
+++ b/pygittools/src/pygittools/actions_test.py
@@ -9,149 +9,149 @@ from pathlib import Path
 import pytest
 
 from pygittools.actions import (
-	Action,
-	ActionSet,
-	ActionSpec,
-	ProjectActions,
-	find_project_root,
-	load_action_sets,
-	load_action_specs,
-	project_actions,
-	project_venv_python,
-	run_action_script,
+    Action,
+    ActionSet,
+    ActionSpec,
+    ProjectActions,
+    find_project_root,
+    load_action_sets,
+    load_action_specs,
+    project_actions,
+    project_venv_python,
+    run_action_script,
 )
 
 
 def _write_pyproject(root: Path, actions_toml: str) -> None:
-	(root / "pyproject.toml").write_text(
-		f'[project]\nname = "demo"\nversion = "0"\n\n[tool.pgt]\nactions = {actions_toml}\n',
-		encoding="utf-8",
-	)
+    (root / "pyproject.toml").write_text(
+        f'[project]\nname = "demo"\nversion = "0"\n\n[tool.pgt]\nactions = {actions_toml}\n',
+        encoding="utf-8",
+    )
 
 
 def _make_venv(root: Path) -> Path:
-	venv = root / ".venv"
-	bin_dir = venv / "bin"
-	bin_dir.mkdir(parents=True)
-	python = bin_dir / "python"
-	python.symlink_to(sys.executable)
-	return python
+    venv = root / ".venv"
+    bin_dir = venv / "bin"
+    bin_dir.mkdir(parents=True)
+    python = bin_dir / "python"
+    python.symlink_to(sys.executable)
+    return python
 
 
 def test_find_project_root(tmp_path: Path) -> None:
-	nested = tmp_path / "a" / "b"
-	nested.mkdir(parents=True)
-	(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8")
-	(nested / "actions.toml").write_text("[local]\nactions = []\n", encoding="utf-8")
-	assert find_project_root(nested) == tmp_path.resolve()
-	assert find_project_root(tmp_path) == tmp_path.resolve()
+    nested = tmp_path / "a" / "b"
+    nested.mkdir(parents=True)
+    (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8")
+    (nested / "actions.toml").write_text("[local]\nactions = []\n", encoding="utf-8")
+    assert find_project_root(nested) == tmp_path.resolve()
+    assert find_project_root(tmp_path) == tmp_path.resolve()
 
 
 def test_find_project_root_actions_toml_only(tmp_path: Path) -> None:
-	nested = tmp_path / "a" / "b"
-	nested.mkdir(parents=True)
-	(tmp_path / "actions.toml").write_text("[quality]\nactions = []\n", encoding="utf-8")
-	assert find_project_root(nested) == tmp_path.resolve()
+    nested = tmp_path / "a" / "b"
+    nested.mkdir(parents=True)
+    (tmp_path / "actions.toml").write_text("[quality]\nactions = []\n", encoding="utf-8")
+    assert find_project_root(nested) == tmp_path.resolve()
 
 
 def test_load_action_specs_inline_tables(tmp_path: Path) -> None:
-	_write_pyproject(
-		tmp_path,
-		"""[
+    _write_pyproject(
+        tmp_path,
+        """[
   { name = "lint", script = "scripts/lint.py" },
   { name = "test", script = "scripts/test.py" },
 ]""",
-	)
-	specs = load_action_specs(tmp_path)
-	assert specs == [
-		ActionSpec(name="lint", script="scripts/lint.py"),
-		ActionSpec(name="test", script="scripts/test.py"),
-	]
+    )
+    specs = load_action_specs(tmp_path)
+    assert specs == [
+        ActionSpec(name="lint", script="scripts/lint.py"),
+        ActionSpec(name="test", script="scripts/test.py"),
+    ]
 
 
 def test_load_action_specs_pair_arrays(tmp_path: Path) -> None:
-	_write_pyproject(tmp_path, '[["lint", "scripts/lint.py"], ["test", "scripts/test.sh"]]')
-	specs = load_action_specs(tmp_path)
-	assert [s.name for s in specs] == ["lint", "test"]
+    _write_pyproject(tmp_path, '[["lint", "scripts/lint.py"], ["test", "scripts/test.sh"]]')
+    specs = load_action_specs(tmp_path)
+    assert [s.name for s in specs] == ["lint", "test"]
 
 
 def test_load_action_specs_args(tmp_path: Path) -> None:
-	_write_pyproject(
-		tmp_path,
-		'[{ name = "py", script = "scripts/pytest_version.py", args = ["3.12", "-q"] }]',
-	)
-	specs = load_action_specs(tmp_path)
-	assert specs == [
-		ActionSpec(name="py", script="scripts/pytest_version.py", args=("3.12", "-q")),
-	]
+    _write_pyproject(
+        tmp_path,
+        '[{ name = "py", script = "scripts/pytest_version.py", args = ["3.12", "-q"] }]',
+    )
+    specs = load_action_specs(tmp_path)
+    assert specs == [
+        ActionSpec(name="py", script="scripts/pytest_version.py", args=("3.12", "-q")),
+    ]
 
 
 def test_workspace_python_version_set() -> None:
-	root = Path(__file__).resolve().parents[3]
-	sets = {item.name: item for item in load_action_sets(root)}
-	versions = sets["testversions"]
-	assert [spec.args for spec in versions.specs] == [("3.12",), ("3.13",), ("3.14",), ("3.15",)]
-	assert {spec.script for spec in versions.specs} == {"scripts/pytest_version.py"}
+    root = Path(__file__).resolve().parents[3]
+    sets = {item.name: item for item in load_action_sets(root)}
+    versions = sets["testversions"]
+    assert [spec.args for spec in versions.specs] == [("3.12",), ("3.13",), ("3.14",), ("3.15",)]
+    assert {spec.script for spec in versions.specs} == {"scripts/pytest_version.py"}
 
 
 def test_load_action_specs_invalid_args(tmp_path: Path) -> None:
-	_write_pyproject(tmp_path, '[{ name = "py", script = "x.py", args = "3.12" }]')
-	with pytest.raises(ValueError, match="args"):
-		load_action_specs(tmp_path)
+    _write_pyproject(tmp_path, '[{ name = "py", script = "x.py", args = "3.12" }]')
+    with pytest.raises(ValueError, match="args"):
+        load_action_specs(tmp_path)
 
 
 def test_run_action_script_passes_args(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	script = tmp_path / "echo_args.py"
-	script.write_text("print('unused')\n", encoding="utf-8")
-	bin_dir = tmp_path / ".venv" / "bin"
-	win_dir = tmp_path / ".venv" / "Scripts"
-	bin_dir.mkdir(parents=True)
-	win_dir.mkdir()
-	(bin_dir / "python").write_text("", encoding="utf-8")
-	(win_dir / "python.exe").write_text("", encoding="utf-8")
-	captured: list[list[str]] = []
-
-	class _Proc:
-		returncode = 0
-		stdout = "ok"
-		stderr = ""
-
-	def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
-		del kwargs
-		captured.append(list(cmd))
-		return _Proc()
-
-	monkeypatch.setattr("pygittools.actions.subprocess.run", fake_run)
-	spec = ActionSpec(name="py", script="echo_args.py", args=("3.12", "-q"))
-	ok, message = ProjectActions(tmp_path, [spec]).action("py")
-	python = project_venv_python(tmp_path)
-	assert python is not None
-	assert ok is True
-	assert message == "ok"
-	assert captured == [[str(python), str(script.resolve()), "3.12", "-q"]]
+    script = tmp_path / "echo_args.py"
+    script.write_text("print('unused')\n", encoding="utf-8")
+    bin_dir = tmp_path / ".venv" / "bin"
+    win_dir = tmp_path / ".venv" / "Scripts"
+    bin_dir.mkdir(parents=True)
+    win_dir.mkdir()
+    (bin_dir / "python").write_text("", encoding="utf-8")
+    (win_dir / "python.exe").write_text("", encoding="utf-8")
+    captured: list[list[str]] = []
+
+    class _Proc:
+        returncode = 0
+        stdout = "ok"
+        stderr = ""
+
+    def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
+        del kwargs
+        captured.append(list(cmd))
+        return _Proc()
+
+    monkeypatch.setattr("pygittools.actions.subprocess.run", fake_run)
+    spec = ActionSpec(name="py", script="echo_args.py", args=("3.12", "-q"))
+    ok, message = ProjectActions(tmp_path, [spec]).action("py")
+    python = project_venv_python(tmp_path)
+    assert python is not None
+    assert ok is True
+    assert message == "ok"
+    assert captured == [[str(python), str(script.resolve()), "3.12", "-q"]]
 
 
 def test_load_action_specs_invalid(tmp_path: Path) -> None:
-	_write_pyproject(tmp_path, '{ name = "lint" }')
-	with pytest.raises(ValueError, match="must be an array"):
-		load_action_specs(tmp_path)
+    _write_pyproject(tmp_path, '{ name = "lint" }')
+    with pytest.raises(ValueError, match="must be an array"):
+        load_action_specs(tmp_path)
 
 
 def test_load_action_sets_from_pyproject_and_actions_toml(tmp_path: Path) -> None:
-	_write_pyproject(tmp_path, '[{ name = "lint", script = "scripts/lint.py" }]')
-	pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
-	(tmp_path / "pyproject.toml").write_text(
-		pyproject
-		+ """
+    _write_pyproject(tmp_path, '[{ name = "lint", script = "scripts/lint.py" }]')
+    pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
+    (tmp_path / "pyproject.toml").write_text(
+        pyproject
+        + """
 [tool.pgt.sets.release]
 actions = [
   ["build", "scripts/build.py"],
 ]
 """,
-		encoding="utf-8",
-	)
-	(tmp_path / "actions.toml").write_text(
-		"""
+        encoding="utf-8",
+    )
+    (tmp_path / "actions.toml").write_text(
+        """
 name = "tools"
 
 [[actions]]
@@ -163,104 +163,104 @@ actions = [
   { name = "test", script = "scripts/test.py" },
 ]
 """,
-		encoding="utf-8",
-	)
-	assert load_action_sets(tmp_path) == [
-		ActionSet(name="default", specs=(ActionSpec(name="lint", script="scripts/lint.py"),)),
-		ActionSet(name="release", specs=(ActionSpec(name="build", script="scripts/build.py"),)),
-		ActionSet(name="tools", specs=(ActionSpec(name="fmt", script="scripts/fmt.py"),)),
-		ActionSet(name="quality", specs=(ActionSpec(name="test", script="scripts/test.py"),)),
-	]
+        encoding="utf-8",
+    )
+    assert load_action_sets(tmp_path) == [
+        ActionSet(name="default", specs=(ActionSpec(name="lint", script="scripts/lint.py"),)),
+        ActionSet(name="release", specs=(ActionSpec(name="build", script="scripts/build.py"),)),
+        ActionSet(name="tools", specs=(ActionSpec(name="fmt", script="scripts/fmt.py"),)),
+        ActionSet(name="quality", specs=(ActionSpec(name="test", script="scripts/test.py"),)),
+    ]
 
 
 def test_load_action_sets_named_pyproject_flat_set(tmp_path: Path) -> None:
-	(tmp_path / "pyproject.toml").write_text(
-		"""
+    (tmp_path / "pyproject.toml").write_text(
+        """
 [tool.pgt]
 name = "checks"
 actions = [{ name = "lint", script = "lint.py" }]
 """,
-		encoding="utf-8",
-	)
-	sets = load_action_sets(tmp_path)
-	assert [item.name for item in sets] == ["checks"]
+        encoding="utf-8",
+    )
+    sets = load_action_sets(tmp_path)
+    assert [item.name for item in sets] == ["checks"]
 
 
 def test_load_action_sets_duplicate_name(tmp_path: Path) -> None:
-	_write_pyproject(tmp_path, '[{ name = "lint", script = "lint.py" }]')
-	(tmp_path / "actions.toml").write_text(
-		'[default]\nactions = [{ name = "other", script = "other.py" }]\n',
-		encoding="utf-8",
-	)
-	with pytest.raises(ValueError, match="Duplicate action set name: default"):
-		load_action_sets(tmp_path)
+    _write_pyproject(tmp_path, '[{ name = "lint", script = "lint.py" }]')
+    (tmp_path / "actions.toml").write_text(
+        '[default]\nactions = [{ name = "other", script = "other.py" }]\n',
+        encoding="utf-8",
+    )
+    with pytest.raises(ValueError, match="Duplicate action set name: default"):
+        load_action_sets(tmp_path)
 
 
 def test_load_action_sets_invalid_table(tmp_path: Path) -> None:
-	(tmp_path / "actions.toml").write_text('quality = "nope"\n', encoding="utf-8")
-	with pytest.raises(ValueError, match="unexpected key"):
-		load_action_sets(tmp_path)
+    (tmp_path / "actions.toml").write_text('quality = "nope"\n', encoding="utf-8")
+    with pytest.raises(ValueError, match="unexpected key"):
+        load_action_sets(tmp_path)
 
 
 def test_project_actions_is_action(tmp_path: Path) -> None:
-	_write_pyproject(tmp_path, '[{ name = "ok", script = "ok.py" }]')
-	runner = project_actions(tmp_path)
-	assert isinstance(runner, Action)
-	assert issubclass(ProjectActions, Action)
+    _write_pyproject(tmp_path, '[{ name = "ok", script = "ok.py" }]')
+    runner = project_actions(tmp_path)
+    assert isinstance(runner, Action)
+    assert issubclass(ProjectActions, Action)
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
 def test_run_action_script_python_success(tmp_path: Path) -> None:
-	_make_venv(tmp_path)
-	script = tmp_path / "ok.py"
-	script.write_text("print('hello-ok')\n", encoding="utf-8")
-	ok, message = run_action_script(tmp_path, "ok.py")
-	assert ok is True
-	assert message == "hello-ok"
+    _make_venv(tmp_path)
+    script = tmp_path / "ok.py"
+    script.write_text("print('hello-ok')\n", encoding="utf-8")
+    ok, message = run_action_script(tmp_path, "ok.py")
+    assert ok is True
+    assert message == "hello-ok"
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
 def test_run_action_script_python_failure(tmp_path: Path) -> None:
-	_make_venv(tmp_path)
-	script = tmp_path / "fail.py"
-	script.write_text("import sys\nprint('boom', file=sys.stderr)\nsys.exit(2)\n", encoding="utf-8")
-	ok, message = run_action_script(tmp_path, "fail.py")
-	assert ok is False
-	assert "boom" in message
+    _make_venv(tmp_path)
+    script = tmp_path / "fail.py"
+    script.write_text("import sys\nprint('boom', file=sys.stderr)\nsys.exit(2)\n", encoding="utf-8")
+    ok, message = run_action_script(tmp_path, "fail.py")
+    assert ok is False
+    assert "boom" in message
 
 
 def test_run_action_script_missing_venv(tmp_path: Path) -> None:
-	script = tmp_path / "ok.py"
-	script.write_text("print('x')\n", encoding="utf-8")
-	ok, message = run_action_script(tmp_path, "ok.py")
-	assert ok is False
-	assert "venv not found" in message
+    script = tmp_path / "ok.py"
+    script.write_text("print('x')\n", encoding="utf-8")
+    ok, message = run_action_script(tmp_path, "ok.py")
+    assert ok is False
+    assert "venv not found" in message
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
 def test_run_action_script_shell(tmp_path: Path) -> None:
-	_make_venv(tmp_path)
-	script = tmp_path / "hi.sh"
-	script.write_text("#!/bin/bash\necho shell-ok\n", encoding="utf-8")
-	script.chmod(script.stat().st_mode | stat.S_IXUSR)
-	ok, message = run_action_script(tmp_path, "hi.sh")
-	assert ok is True
-	assert message == "shell-ok"
-	assert project_venv_python(tmp_path) is not None
+    _make_venv(tmp_path)
+    script = tmp_path / "hi.sh"
+    script.write_text("#!/bin/bash\necho shell-ok\n", encoding="utf-8")
+    script.chmod(script.stat().st_mode | stat.S_IXUSR)
+    ok, message = run_action_script(tmp_path, "hi.sh")
+    assert ok is True
+    assert message == "shell-ok"
+    assert project_venv_python(tmp_path) is not None
 
 
 def test_project_actions_unknown(tmp_path: Path) -> None:
-	_write_pyproject(tmp_path, "[]")
-	ok, message = ProjectActions(tmp_path).action("missing")
-	assert ok is False
-	assert "Unknown action" in message
+    _write_pyproject(tmp_path, "[]")
+    ok, message = ProjectActions(tmp_path).action("missing")
+    assert ok is False
+    assert "Unknown action" in message
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
 def test_project_actions_runs_named(tmp_path: Path) -> None:
-	_make_venv(tmp_path)
-	_write_pyproject(tmp_path, '[{ name = "greet", script = "greet.py" }]')
-	(tmp_path / "greet.py").write_text("print('hi')\n", encoding="utf-8")
-	ok, message = ProjectActions(tmp_path).action("greet")
-	assert ok is True
-	assert message == "hi"
+    _make_venv(tmp_path)
+    _write_pyproject(tmp_path, '[{ name = "greet", script = "greet.py" }]')
+    (tmp_path / "greet.py").write_text("print('hi')\n", encoding="utf-8")
+    ok, message = ProjectActions(tmp_path).action("greet")
+    assert ok is True
+    assert message == "hi"
diff --git a/pygittools/src/pygittools/hooks.py b/pygittools/src/pygittools/hooks.py
index fd36380..8441ee9 100644
--- a/pygittools/src/pygittools/hooks.py
+++ b/pygittools/src/pygittools/hooks.py
@@ -7,27 +7,27 @@ import pygit2
 
 
 class HookResult(Enum):
-	SUCCESS = 0
-	FAILURE = 1
+    SUCCESS = 0
+    FAILURE = 1
 
 
 @dataclass
 class Hook:
-	repo: pygit2.Repository
+    repo: pygit2.Repository
 
 
 RefUpdate = tuple[str, str, str]
 
 
 def parse_ref_updates(stream: IO[str] | None = None) -> list[RefUpdate]:
-	"""Parse `<old-oid> <new-oid> <ref-name>` lines (one per ref) from a receive-pack hook's stdin."""
-	source: IO[str] = stream if stream is not None else sys.stdin
-	updates: list[RefUpdate] = []
-	for raw in source:
-		parts: list[str] = raw.strip().split(maxsplit=2)
-		if len(parts) == 3:
-			updates.append((parts[0], parts[1], parts[2]))
-	return updates
+    """Parse `<old-oid> <new-oid> <ref-name>` lines (one per ref) from a receive-pack hook's stdin."""
+    source: IO[str] = stream if stream is not None else sys.stdin
+    updates: list[RefUpdate] = []
+    for raw in source:
+        parts: list[str] = raw.strip().split(maxsplit=2)
+        if len(parts) == 3:
+            updates.append((parts[0], parts[1], parts[2]))
+    return updates
 
 
 """
@@ -41,11 +41,11 @@ This can be bypassed with --no-verify by the user.
 
 
 class PreCommit(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -61,16 +61,16 @@ and optionally commit hash for amend.
 
 
 class PrepareCommitMsg(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    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
+    def run(
+        self,
+        message_file: str,
+        source: str = "",
+        commit_hash: str | None = None,
+    ) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -85,11 +85,11 @@ Takes one parameter: the path to the file holding the proposed commit log messag
 
 
 class CommitMsg(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self, message_file: str) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self, message_file: str) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -103,11 +103,11 @@ Use this hook to:
 
 
 class PostCommit(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -123,11 +123,11 @@ Takes no parameters.
 
 
 class PreMergeCommit(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -142,11 +142,11 @@ Takes one or two parameters: upstream ref, and optionally the branch being rebas
 
 
 class PreRebase(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self, upstream: str, branch: str | None = None) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self, upstream: str, branch: str | None = None) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -161,11 +161,11 @@ Takes three parameters: previous HEAD ref, new HEAD ref, and a flag (1 = branch 
 
 
 class PostCheckout(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    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
+    def run(self, prev_head: str, new_head: str, branch_checkout: str) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -179,11 +179,11 @@ Takes one parameter: a status flag indicating whether the merge was a squash mer
 
 
 class PostMerge(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self, squash: str) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self, squash: str) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -198,11 +198,11 @@ Takes two parameters: remote name and remote URL. Ref updates are provided on st
 
 
 class PrePush(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self, remote_name: str, remote_url: str) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self, remote_name: str, remote_url: str) -> HookResult:
+        return HookResult.SUCCESS
 
 
 # -----------------------------------------------------------------------------
@@ -221,11 +221,11 @@ Takes three parameters: ref name, old object name, new object name.
 
 
 class Update(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    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
+    def run(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -240,11 +240,11 @@ 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 __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self, *ref_names: str) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self, *ref_names: str) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -259,11 +259,11 @@ Takes one parameter: the commit object name the tip of the current branch will b
 
 
 class PushToCheckout(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self, new_commit: str) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self, new_commit: str) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -278,11 +278,11 @@ 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 __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -297,11 +297,11 @@ Use this hook to:
 
 
 class PreReceive(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+        return HookResult.SUCCESS
 
 
 """
@@ -315,11 +315,11 @@ Use this hook to:
 
 
 class PostReceive(Hook):
-	def __init__(self, repo: pygit2.Repository):
-		super().__init__(repo)
+    def __init__(self, repo: pygit2.Repository):
+        super().__init__(repo)
 
-	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
-		return HookResult.SUCCESS
+    def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+        return HookResult.SUCCESS
 
 
 # -----------------------------------------------------------------------------
diff --git a/pygittools/src/pygittools/hooks_notify.py b/pygittools/src/pygittools/hooks_notify.py
index eee53ed..4410583 100644
--- a/pygittools/src/pygittools/hooks_notify.py
+++ b/pygittools/src/pygittools/hooks_notify.py
@@ -18,84 +18,84 @@ DEFAULT_NOTIFY_TIMEOUT_SECONDS: float = 5.0
 
 
 def _post_notify(notify_url: str, project: str, timeout: float) -> bool:
-	url: str = f"{notify_url.rstrip('/')}?{urllib.parse.urlencode({'project': project})}"
-	req: urllib.request.Request = urllib.request.Request(url, data=b"", method="POST")
-	try:
-		with urllib.request.urlopen(req, timeout=timeout) as resp:
-			return 200 <= int(resp.status) < 300
-	except (urllib.error.URLError, OSError, ValueError) as exc:
-		stderr.write(f"notify {url!r} failed: {exc}\n")
-		return False
+    url: str = f"{notify_url.rstrip('/')}?{urllib.parse.urlencode({'project': project})}"
+    req: urllib.request.Request = urllib.request.Request(url, data=b"", method="POST")
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return 200 <= int(resp.status) < 300
+    except (urllib.error.URLError, OSError, ValueError) as exc:
+        stderr.write(f"notify {url!r} failed: {exc}\n")
+        return False
 
 
 class PreReceiveNotify(PreReceive):
-	"""Pre-receive hook variant that POSTs `<notify_url>?project=<project>` to wake subscribers.
-
-	Notification failures never reject the push: they are reported on stderr only.
-	Refs are not yet visible at pre-receive time, so subscribers may briefly see stale data.
-	Prefer PostReceiveNotify for change-queue use cases.
-	"""
-
-	def __init__(
-		self,
-		repo: pygit2.Repository,
-		notify_url: str,
-		project: str,
-		timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
-	) -> None:
-		super().__init__(repo)
-		self.notify_url: str = notify_url
-		self.project: str = project
-		self.timeout: float = timeout
-
-	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
-		if not ref_updates:
-			return HookResult.SUCCESS
-		_post_notify(self.notify_url, self.project, self.timeout)
-		return HookResult.SUCCESS
+    """Pre-receive hook variant that POSTs `<notify_url>?project=<project>` to wake subscribers.
+
+    Notification failures never reject the push: they are reported on stderr only.
+    Refs are not yet visible at pre-receive time, so subscribers may briefly see stale data.
+    Prefer PostReceiveNotify for change-queue use cases.
+    """
+
+    def __init__(
+        self,
+        repo: pygit2.Repository,
+        notify_url: str,
+        project: str,
+        timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
+    ) -> None:
+        super().__init__(repo)
+        self.notify_url: str = notify_url
+        self.project: str = project
+        self.timeout: float = timeout
+
+    def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+        if not ref_updates:
+            return HookResult.SUCCESS
+        _post_notify(self.notify_url, self.project, self.timeout)
+        return HookResult.SUCCESS
 
 
 class PostReceiveNotify(PostReceive):
-	"""Post-receive hook variant that POSTs `<notify_url>?project=<project>` after refs update."""
-
-	def __init__(
-		self,
-		repo: pygit2.Repository,
-		notify_url: str,
-		project: str,
-		timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
-	) -> None:
-		super().__init__(repo)
-		self.notify_url: str = notify_url
-		self.project: str = project
-		self.timeout: float = timeout
-
-	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
-		if not ref_updates:
-			return HookResult.SUCCESS
-		_post_notify(self.notify_url, self.project, self.timeout)
-		return HookResult.SUCCESS
+    """Post-receive hook variant that POSTs `<notify_url>?project=<project>` after refs update."""
+
+    def __init__(
+        self,
+        repo: pygit2.Repository,
+        notify_url: str,
+        project: str,
+        timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
+    ) -> None:
+        super().__init__(repo)
+        self.notify_url: str = notify_url
+        self.project: str = project
+        self.timeout: float = timeout
+
+    def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+        if not ref_updates:
+            return HookResult.SUCCESS
+        _post_notify(self.notify_url, self.project, self.timeout)
+        return HookResult.SUCCESS
 
 
 class PostCommitNotify(PostCommit):
-	"""Post-commit hook variant for local clients: POSTs to a pygitweb instance after each commit.
-
-	Pair with PostReceiveNotify on the server so long-poll subscribers wake on both push events
-	and local commits made in working clones served by the same pygitweb instance.
-	"""
-
-	def __init__(
-		self,
-		repo: pygit2.Repository,
-		notify_url: str,
-		project: str,
-		timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
-	) -> None:
-		super().__init__(repo)
-		self.notify_url: str = notify_url
-		self.project: str = project
-		self.timeout: float = timeout
-
-	def run(self) -> HookResult:
-		_post_notify(self.notify_url, self.project, self.timeout)
-		return HookResult.SUCCESS
+    """Post-commit hook variant for local clients: POSTs to a pygitweb instance after each commit.
+
+    Pair with PostReceiveNotify on the server so long-poll subscribers wake on both push events
+    and local commits made in working clones served by the same pygitweb instance.
+    """
+
+    def __init__(
+        self,
+        repo: pygit2.Repository,
+        notify_url: str,
+        project: str,
+        timeout: float = DEFAULT_NOTIFY_TIMEOUT_SECONDS,
+    ) -> None:
+        super().__init__(repo)
+        self.notify_url: str = notify_url
+        self.project: str = project
+        self.timeout: float = timeout
+
+    def run(self) -> HookResult:
+        _post_notify(self.notify_url, self.project, self.timeout)
+        return HookResult.SUCCESS
diff --git a/pygittools/src/pygittools/hooks_patterns.py b/pygittools/src/pygittools/hooks_patterns.py
index 15982ce..840cee3 100644
--- a/pygittools/src/pygittools/hooks_patterns.py
+++ b/pygittools/src/pygittools/hooks_patterns.py
@@ -14,17 +14,17 @@ 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 __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
+    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
 
 
 """
@@ -39,18 +39,18 @@ 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 __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
+    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
 
 
 """
@@ -63,72 +63,72 @@ default; set reject_merge_commits to refuse merge commits and require squash/reb
 
 
 class PreReceiveProtectedPattern(PreReceive):
-	def __init__(
-		self,
-		repo: pygit2.Repository,
-		ref_pattern: str,
-		msg_pattern: str,
-		*,
-		reject_merge_commits: bool = False,
-	) -> None:
-		super().__init__(repo)
-		self.ref_exp: re.Pattern[str] = re.compile(ref_pattern)
-		self.msg_exp: re.Pattern[str] = re.compile(msg_pattern)
-		self.reject_merge_commits: bool = reject_merge_commits
-
-	def run(self, ref_updates: list[RefUpdate]) -> HookResult:
-		for ref_name, old_oid, new_oid in ref_updates:
-			if not self.ref_exp.match(ref_name):
-				continue
-			result: HookResult = self._validate_ref_update(ref_name, old_oid, new_oid)
-			if result != HookResult.SUCCESS:
-				return result
-		return HookResult.SUCCESS
-
-	def _validate_ref_update(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
-		if new_oid == NULL_OID:
-			return HookResult.SUCCESS
-		try:
-			new_commit: pygit2.Commit = self.repo.revparse_single(new_oid).peel(pygit2.Commit)
-		except (KeyError, pygit2.GitError):
-			stderr.write(f"Invalid commit {new_oid} for {ref_name}\n")
-			return HookResult.FAILURE
-
-		old_commit: pygit2.Commit | None = None
-		if old_oid != NULL_OID:
-			try:
-				old_commit = self.repo.revparse_single(old_oid).peel(pygit2.Commit)
-			except (KeyError, pygit2.GitError):
-				old_commit = None
-
-		for commit in self._commits_in_range(new_commit, old_commit):
-			if len(commit.parents) > 1:
-				if self.reject_merge_commits:
-					stderr.write(
-						f"Merge commit {commit.id} rejected on protected ref {ref_name}; "
-						"use squash or rebase instead.\n",
-					)
-					return HookResult.FAILURE
-				continue
-			if not self.msg_exp.match(commit.message):
-				stderr.write(
-					f"Commit {commit.id} on {ref_name} does not match pattern: {self.msg_exp.pattern}\n",
-				)
-				return HookResult.FAILURE
-		return HookResult.SUCCESS
-
-	def _commits_in_range(
-		self,
-		new_commit: pygit2.Commit,
-		old_commit: pygit2.Commit | None,
-	) -> list[pygit2.Commit]:
-		excluded: set[pygit2.Oid] = set()
-		if old_commit is not None:
-			for ancestor in self.repo.walk(old_commit.id):
-				excluded.add(ancestor.id)
-		commits: list[pygit2.Commit] = []
-		for commit in self.repo.walk(new_commit.id):
-			if commit.id in excluded:
-				continue
-			commits.append(commit)
-		return commits
+    def __init__(
+        self,
+        repo: pygit2.Repository,
+        ref_pattern: str,
+        msg_pattern: str,
+        *,
+        reject_merge_commits: bool = False,
+    ) -> None:
+        super().__init__(repo)
+        self.ref_exp: re.Pattern[str] = re.compile(ref_pattern)
+        self.msg_exp: re.Pattern[str] = re.compile(msg_pattern)
+        self.reject_merge_commits: bool = reject_merge_commits
+
+    def run(self, ref_updates: list[RefUpdate]) -> HookResult:
+        for ref_name, old_oid, new_oid in ref_updates:
+            if not self.ref_exp.match(ref_name):
+                continue
+            result: HookResult = self._validate_ref_update(ref_name, old_oid, new_oid)
+            if result != HookResult.SUCCESS:
+                return result
+        return HookResult.SUCCESS
+
+    def _validate_ref_update(self, ref_name: str, old_oid: str, new_oid: str) -> HookResult:
+        if new_oid == NULL_OID:
+            return HookResult.SUCCESS
+        try:
+            new_commit: pygit2.Commit = self.repo.revparse_single(new_oid).peel(pygit2.Commit)
+        except (KeyError, pygit2.GitError):
+            stderr.write(f"Invalid commit {new_oid} for {ref_name}\n")
+            return HookResult.FAILURE
+
+        old_commit: pygit2.Commit | None = None
+        if old_oid != NULL_OID:
+            try:
+                old_commit = self.repo.revparse_single(old_oid).peel(pygit2.Commit)
+            except (KeyError, pygit2.GitError):
+                old_commit = None
+
+        for commit in self._commits_in_range(new_commit, old_commit):
+            if len(commit.parents) > 1:
+                if self.reject_merge_commits:
+                    stderr.write(
+                        f"Merge commit {commit.id} rejected on protected ref {ref_name}; "
+                        "use squash or rebase instead.\n",
+                    )
+                    return HookResult.FAILURE
+                continue
+            if not self.msg_exp.match(commit.message):
+                stderr.write(
+                    f"Commit {commit.id} on {ref_name} does not match pattern: {self.msg_exp.pattern}\n",
+                )
+                return HookResult.FAILURE
+        return HookResult.SUCCESS
+
+    def _commits_in_range(
+        self,
+        new_commit: pygit2.Commit,
+        old_commit: pygit2.Commit | None,
+    ) -> list[pygit2.Commit]:
+        excluded: set[pygit2.Oid] = set()
+        if old_commit is not None:
+            for ancestor in self.repo.walk(old_commit.id):
+                excluded.add(ancestor.id)
+        commits: list[pygit2.Commit] = []
+        for commit in self.repo.walk(new_commit.id):
+            if commit.id in excluded:
+                continue
+            commits.append(commit)
+        return commits
diff --git a/pygittools/src/pygittools/hooks_patterns_test.py b/pygittools/src/pygittools/hooks_patterns_test.py
index eb3c94d..39a9943 100644
--- a/pygittools/src/pygittools/hooks_patterns_test.py
+++ b/pygittools/src/pygittools/hooks_patterns_test.py
@@ -15,77 +15,77 @@ SIG = Signature("test", "test@example.com")
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	repo_path = tmp_path / "repo.git"
-	repo_path.mkdir()
-	return init_repository(str(repo_path), bare=True)
+    repo_path = tmp_path / "repo.git"
+    repo_path.mkdir()
+    return init_repository(str(repo_path), bare=True)
 
 
 def _commit(repo: Repository, message: str, parents: list[Oid] | None = None) -> Oid:
-	tb = repo.TreeBuilder()
-	tree = tb.write()
-	return repo.create_commit(None, SIG, SIG, message, tree, parents or [])
+    tb = repo.TreeBuilder()
+    tree = tb.write()
+    return repo.create_commit(None, SIG, SIG, message, tree, parents or [])
 
 
 def _run(
-	repo: Repository,
-	ref: str,
-	old_oid: str,
-	new_oid: str,
-	*,
-	reject_merge_commits: bool = False,
+    repo: Repository,
+    ref: str,
+    old_oid: str,
+    new_oid: str,
+    *,
+    reject_merge_commits: bool = False,
 ) -> HookResult:
-	hook = PreReceiveProtectedPattern(
-		repo,
-		PROTECTED_MAIN,
-		MSG_PATTERN,
-		reject_merge_commits=reject_merge_commits,
-	)
-	return hook.run([(ref, old_oid, new_oid)])
+    hook = PreReceiveProtectedPattern(
+        repo,
+        PROTECTED_MAIN,
+        MSG_PATTERN,
+        reject_merge_commits=reject_merge_commits,
+    )
+    return hook.run([(ref, old_oid, new_oid)])
 
 
 def test_ignores_unprotected_ref(repo: Repository) -> None:
-	bad = _commit(repo, "not conventional")
-	assert _run(repo, "refs/heads/feature", NULL_OID, str(bad)) == HookResult.SUCCESS
+    bad = _commit(repo, "not conventional")
+    assert _run(repo, "refs/heads/feature", NULL_OID, str(bad)) == HookResult.SUCCESS
 
 
 def test_accepts_valid_commit_on_protected_ref(repo: Repository) -> None:
-	good = _commit(repo, "feat: add thing")
-	assert _run(repo, "refs/heads/main", NULL_OID, str(good)) == HookResult.SUCCESS
+    good = _commit(repo, "feat: add thing")
+    assert _run(repo, "refs/heads/main", NULL_OID, str(good)) == HookResult.SUCCESS
 
 
 def test_rejects_invalid_commit_on_protected_ref(repo: Repository) -> None:
-	bad = _commit(repo, "not conventional")
-	assert _run(repo, "refs/heads/main", NULL_OID, str(bad)) == HookResult.FAILURE
+    bad = _commit(repo, "not conventional")
+    assert _run(repo, "refs/heads/main", NULL_OID, str(bad)) == HookResult.FAILURE
 
 
 def test_walks_every_commit_in_fast_forward_push(repo: Repository) -> None:
-	good = _commit(repo, "feat: first")
-	bad = _commit(repo, "not conventional", [good])
-	assert _run(repo, "refs/heads/main", str(good), str(bad)) == HookResult.FAILURE
+    good = _commit(repo, "feat: first")
+    bad = _commit(repo, "not conventional", [good])
+    assert _run(repo, "refs/heads/main", str(good), str(bad)) == HookResult.FAILURE
 
 
 def test_skips_merge_commit_message_by_default(repo: Repository) -> None:
-	base = _commit(repo, "feat: base")
-	feature = _commit(repo, "feat: side", [base])
-	merge = _commit(repo, "Merge branch 'feature'", [base, feature])
-	assert _run(repo, "refs/heads/main", str(base), str(merge)) == HookResult.SUCCESS
+    base = _commit(repo, "feat: base")
+    feature = _commit(repo, "feat: side", [base])
+    merge = _commit(repo, "Merge branch 'feature'", [base, feature])
+    assert _run(repo, "refs/heads/main", str(base), str(merge)) == HookResult.SUCCESS
 
 
 def test_rejects_merge_commit_when_configured(repo: Repository) -> None:
-	base = _commit(repo, "feat: base")
-	feature = _commit(repo, "feat: side", [base])
-	merge = _commit(repo, "Merge branch 'feature'", [base, feature])
-	result = _run(repo, "refs/heads/main", str(base), str(merge), reject_merge_commits=True)
-	assert result == HookResult.FAILURE
+    base = _commit(repo, "feat: base")
+    feature = _commit(repo, "feat: side", [base])
+    merge = _commit(repo, "Merge branch 'feature'", [base, feature])
+    result = _run(repo, "refs/heads/main", str(base), str(merge), reject_merge_commits=True)
+    assert result == HookResult.FAILURE
 
 
 def test_validates_non_merge_commits_introduced_by_merge(repo: Repository) -> None:
-	base = _commit(repo, "feat: base")
-	bad = _commit(repo, "bad message on feature", [base])
-	merge = _commit(repo, "Merge branch 'feature'", [base, bad])
-	assert _run(repo, "refs/heads/main", str(base), str(merge)) == HookResult.FAILURE
+    base = _commit(repo, "feat: base")
+    bad = _commit(repo, "bad message on feature", [base])
+    merge = _commit(repo, "Merge branch 'feature'", [base, bad])
+    assert _run(repo, "refs/heads/main", str(base), str(merge)) == HookResult.FAILURE
 
 
 def test_allows_ref_deletion(repo: Repository) -> None:
-	base = _commit(repo, "feat: base")
-	assert _run(repo, "refs/heads/main", str(base), NULL_OID) == HookResult.SUCCESS
+    base = _commit(repo, "feat: base")
+    assert _run(repo, "refs/heads/main", str(base), NULL_OID) == HookResult.SUCCESS
diff --git a/pygittools/src/pygittools/hooks_push.py b/pygittools/src/pygittools/hooks_push.py
index 3fcb092..0d48a05 100644
--- a/pygittools/src/pygittools/hooks_push.py
+++ b/pygittools/src/pygittools/hooks_push.py
@@ -16,62 +16,62 @@ from pygittools.hooks import HookResult, PostCommit
 
 
 def git_executable() -> str:
-	"""Return the git binary to invoke (``GIT`` env, then ``PATH``, else ``git``)."""
-	return os.environ.get("GIT") or shutil.which("git") or "git"
+    """Return the git binary to invoke (``GIT`` env, then ``PATH``, else ``git``)."""
+    return os.environ.get("GIT") or shutil.which("git") or "git"
 
 
 def force_with_lease_push(
-	repo: pygit2.Repository,
-	remote_name: str,
-	branch: str,
-	*,
-	git: str | None = None,
+    repo: pygit2.Repository,
+    remote_name: str,
+    branch: str,
+    *,
+    git: str | None = None,
 ) -> str | None:
-	"""Force-with-lease push ``branch`` to ``remote_name``. Returns an error string or None."""
-	workdir: str | None = repo.workdir
-	if workdir is None:
-		return "bare repository has no working tree"
-	if branch not in repo.branches.local:
-		return f"branch {branch!r} does not exist locally"
-	if remote_name not in repo.remotes.names():
-		return f"remote {remote_name!r} not configured"
-	binary: str = git or git_executable()
-	proc: subprocess.CompletedProcess[str] = subprocess.run(
-		[binary, "-C", workdir, "push", "--force-with-lease", remote_name, branch],
-		capture_output=True,
-		text=True,
-	)
-	if proc.returncode != 0:
-		return (proc.stderr or proc.stdout or "push failed").strip()
-	return None
+    """Force-with-lease push ``branch`` to ``remote_name``. Returns an error string or None."""
+    workdir: str | None = repo.workdir
+    if workdir is None:
+        return "bare repository has no working tree"
+    if branch not in repo.branches.local:
+        return f"branch {branch!r} does not exist locally"
+    if remote_name not in repo.remotes.names():
+        return f"remote {remote_name!r} not configured"
+    binary: str = git or git_executable()
+    proc: subprocess.CompletedProcess[str] = subprocess.run(
+        [binary, "-C", workdir, "push", "--force-with-lease", remote_name, branch],
+        capture_output=True,
+        text=True,
+    )
+    if proc.returncode != 0:
+        return (proc.stderr or proc.stdout or "push failed").strip()
+    return None
 
 
 class PostCommitPushRemotes(PostCommit):
-	"""After each commit, force-with-lease push the current branch to every remote if it matches a pattern."""
+    """After each commit, force-with-lease push the current branch to every remote if it matches a pattern."""
 
-	def __init__(self, repo: pygit2.Repository, branch_pattern: str) -> None:
-		super().__init__(repo)
-		self._branch_exp: re.Pattern[str] = re.compile(branch_pattern)
+    def __init__(self, repo: pygit2.Repository, branch_pattern: str) -> None:
+        super().__init__(repo)
+        self._branch_exp: re.Pattern[str] = re.compile(branch_pattern)
 
-	def run(self) -> HookResult:
-		if self.repo.head_is_detached:
-			return HookResult.SUCCESS
-		branch: str = self.repo.head.shorthand
-		if not self._branch_exp.match(branch):
-			return HookResult.SUCCESS
-		if self.repo.workdir is None:
-			stderr.write("PostCommitPushRemotes: bare repository; skipping push.\n")
-			return HookResult.SUCCESS
-		remote_names: list[str] = [name for name in self.repo.remotes.names() if name]
-		if not remote_names:
-			return HookResult.SUCCESS
-		failures: list[str] = []
-		for remote_name in remote_names:
-			error: str | None = force_with_lease_push(self.repo, remote_name, branch)
-			if error is not None:
-				failures.append(f"{remote_name}: {error}")
-		if failures:
-			stderr.write(f"PostCommitPushRemotes ({branch}): push failed:\n")
-			for line in failures:
-				stderr.write(f"  {line}\n")
-		return HookResult.SUCCESS
+    def run(self) -> HookResult:
+        if self.repo.head_is_detached:
+            return HookResult.SUCCESS
+        branch: str = self.repo.head.shorthand
+        if not self._branch_exp.match(branch):
+            return HookResult.SUCCESS
+        if self.repo.workdir is None:
+            stderr.write("PostCommitPushRemotes: bare repository; skipping push.\n")
+            return HookResult.SUCCESS
+        remote_names: list[str] = [name for name in self.repo.remotes.names() if name]
+        if not remote_names:
+            return HookResult.SUCCESS
+        failures: list[str] = []
+        for remote_name in remote_names:
+            error: str | None = force_with_lease_push(self.repo, remote_name, branch)
+            if error is not None:
+                failures.append(f"{remote_name}: {error}")
+        if failures:
+            stderr.write(f"PostCommitPushRemotes ({branch}): push failed:\n")
+            for line in failures:
+                stderr.write(f"  {line}\n")
+        return HookResult.SUCCESS
diff --git a/pygittools/src/pygittools/hooks_push_test.py b/pygittools/src/pygittools/hooks_push_test.py
index 90cb7ce..e86794a 100644
--- a/pygittools/src/pygittools/hooks_push_test.py
+++ b/pygittools/src/pygittools/hooks_push_test.py
@@ -12,71 +12,71 @@ SIG = Signature("test", "test@example.com")
 
 
 def _init_client_with_remote(tmp_path: Path) -> tuple[Repository, Path, Path, Path]:
-	bare_path: Path = tmp_path / "remote.git"
-	bare_path.mkdir()
-	init_repository(str(bare_path), bare=True)
-	backup_path: Path = tmp_path / "backup.git"
-	backup_path.mkdir()
-	init_repository(str(backup_path), bare=True)
-	client_path: Path = tmp_path / "client"
-	client: Repository = init_repository(str(client_path), bare=False)
-	client.remotes.create("origin", str(bare_path))
-	client.remotes.create("backup", str(backup_path))
-	(client_path / "f").write_text("a\n", encoding="utf-8")
-	index = client.index
-	index.add("f")
-	index.write()
-	tree = index.write_tree()
-	client.create_commit("HEAD", SIG, SIG, "init", tree, [])
-	client.references.create("refs/heads/wip/topic", client.head.target, force=True)
-	client.checkout("refs/heads/wip/topic")
-	return client, client_path, bare_path, backup_path
+    bare_path: Path = tmp_path / "remote.git"
+    bare_path.mkdir()
+    init_repository(str(bare_path), bare=True)
+    backup_path: Path = tmp_path / "backup.git"
+    backup_path.mkdir()
+    init_repository(str(backup_path), bare=True)
+    client_path: Path = tmp_path / "client"
+    client: Repository = init_repository(str(client_path), bare=False)
+    client.remotes.create("origin", str(bare_path))
+    client.remotes.create("backup", str(backup_path))
+    (client_path / "f").write_text("a\n", encoding="utf-8")
+    index = client.index
+    index.add("f")
+    index.write()
+    tree = index.write_tree()
+    client.create_commit("HEAD", SIG, SIG, "init", tree, [])
+    client.references.create("refs/heads/wip/topic", client.head.target, force=True)
+    client.checkout("refs/heads/wip/topic")
+    return client, client_path, bare_path, backup_path
 
 
 def test_skips_non_matching_branch(tmp_path: Path) -> None:
-	client, client_path, _bare, _backup = _init_client_with_remote(tmp_path)
-	client.references.create("refs/heads/main", client.head.target, force=True)
-	client.checkout("refs/heads/main")
-	repo = pygit2.Repository(str(client_path))
-	assert PostCommitPushRemotes(repo, r"^wip/").run() == HookResult.SUCCESS
-	assert repo.references.get("refs/remotes/origin/main") is None
+    client, client_path, _bare, _backup = _init_client_with_remote(tmp_path)
+    client.references.create("refs/heads/main", client.head.target, force=True)
+    client.checkout("refs/heads/main")
+    repo = pygit2.Repository(str(client_path))
+    assert PostCommitPushRemotes(repo, r"^wip/").run() == HookResult.SUCCESS
+    assert repo.references.get("refs/remotes/origin/main") is None
 
 
 def test_pushes_matching_branch_to_all_remotes(tmp_path: Path) -> None:
-	client, client_path, bare_path, backup_path = _init_client_with_remote(tmp_path)
-	repo = pygit2.Repository(str(client_path))
-	assert PostCommitPushRemotes(repo, r"^wip/").run() == HookResult.SUCCESS
-	local_target = client.references["refs/heads/wip/topic"].target
-	assert pygit2.Repository(str(bare_path)).references["refs/heads/wip/topic"].target == local_target
-	assert pygit2.Repository(str(backup_path)).references["refs/heads/wip/topic"].target == local_target
+    client, client_path, bare_path, backup_path = _init_client_with_remote(tmp_path)
+    repo = pygit2.Repository(str(client_path))
+    assert PostCommitPushRemotes(repo, r"^wip/").run() == HookResult.SUCCESS
+    local_target = client.references["refs/heads/wip/topic"].target
+    assert pygit2.Repository(str(bare_path)).references["refs/heads/wip/topic"].target == local_target
+    assert pygit2.Repository(str(backup_path)).references["refs/heads/wip/topic"].target == local_target
 
 
 def test_force_with_lease_rejects_stale_tracking_ref(tmp_path: Path) -> None:
-	client, client_path, bare_path, _backup = _init_client_with_remote(tmp_path)
-	repo = pygit2.Repository(str(client_path))
-	assert force_with_lease_push(repo, "origin", "wip/topic") is None
-	(client_path / "f").write_text("b\n", encoding="utf-8")
-	index = repo.index
-	index.add("f")
-	index.write()
-	tree = index.write_tree()
-	parent = repo.head.target
-	repo.create_commit("refs/heads/wip/topic", SIG, SIG, "second", tree, [parent])
-	repo.checkout("refs/heads/wip/topic")
-	bare = pygit2.Repository(str(bare_path))
-	tb = bare.TreeBuilder()
-	tree = tb.write()
-	current = bare.references["refs/heads/wip/topic"].target
-	other = bare.create_commit("refs/heads/wip/topic", SIG, SIG, "other", tree, [current])
-	assert other != repo.references["refs/heads/wip/topic"].target
-	error = force_with_lease_push(repo, "origin", "wip/topic")
-	assert error is not None
-	assert "rejected" in error.lower() or "failed" in error.lower()
+    client, client_path, bare_path, _backup = _init_client_with_remote(tmp_path)
+    repo = pygit2.Repository(str(client_path))
+    assert force_with_lease_push(repo, "origin", "wip/topic") is None
+    (client_path / "f").write_text("b\n", encoding="utf-8")
+    index = repo.index
+    index.add("f")
+    index.write()
+    tree = index.write_tree()
+    parent = repo.head.target
+    repo.create_commit("refs/heads/wip/topic", SIG, SIG, "second", tree, [parent])
+    repo.checkout("refs/heads/wip/topic")
+    bare = pygit2.Repository(str(bare_path))
+    tb = bare.TreeBuilder()
+    tree = tb.write()
+    current = bare.references["refs/heads/wip/topic"].target
+    other = bare.create_commit("refs/heads/wip/topic", SIG, SIG, "other", tree, [current])
+    assert other != repo.references["refs/heads/wip/topic"].target
+    error = force_with_lease_push(repo, "origin", "wip/topic")
+    assert error is not None
+    assert "rejected" in error.lower() or "failed" in error.lower()
 
 
 def test_skips_detached_head(tmp_path: Path) -> None:
-	client, client_path, _bare, _backup = _init_client_with_remote(tmp_path)
-	client.set_head(client.head.target)
-	repo = pygit2.Repository(str(client_path))
-	assert repo.head_is_detached is True
-	assert PostCommitPushRemotes(repo, r".*").run() == HookResult.SUCCESS
+    client, client_path, _bare, _backup = _init_client_with_remote(tmp_path)
+    client.set_head(client.head.target)
+    repo = pygit2.Repository(str(client_path))
+    assert repo.head_is_detached is True
+    assert PostCommitPushRemotes(repo, r".*").run() == HookResult.SUCCESS
diff --git a/pygittools/src/pygittools/hooks_ruff.py b/pygittools/src/pygittools/hooks_ruff.py
index bce53f2..37078a6 100644
--- a/pygittools/src/pygittools/hooks_ruff.py
+++ b/pygittools/src/pygittools/hooks_ruff.py
@@ -19,86 +19,86 @@ Parses JSON and prints a short summary to stderr on failure.
 
 
 def _ruff_base() -> list[str]:
-	return [sys.executable, "-m", "ruff"]
+    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 []
+    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}"
+    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)
+    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,
+    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
+    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
+    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/src/pygittools/main.py b/pygittools/src/pygittools/main.py
index 6bbcf3c..a64567c 100644
--- a/pygittools/src/pygittools/main.py
+++ b/pygittools/src/pygittools/main.py
@@ -14,96 +14,96 @@ from pygittools.tui import run_tui
 
 
 def build_parser() -> argparse.ArgumentParser:
-	parser = argparse.ArgumentParser(
-		prog="pgt",
-		description=pygittools.__description__,
-	)
-	parser.add_argument(
-		"--repo",
-		type=Path,
-		help="Path to a git repository (default: discover from the current directory)",
-	)
-	parser.add_argument(
-		"--version",
-		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)",
-	)
-	delete_parser = tags_subparsers.add_parser("delete-task", help="Delete a task tag locally and on a remote")
-	delete_parser.add_argument(
-		"task",
-		help="Task ref or ID (for example refs/tags/tasks/alpha or alpha)",
-	)
-	delete_parser.add_argument(
-		"--remote",
-		default="origin",
-		help="Remote name to delete from (for example origin)",
-	)
-	return parser
+    parser = argparse.ArgumentParser(
+        prog="pgt",
+        description=pygittools.__description__,
+    )
+    parser.add_argument(
+        "--repo",
+        type=Path,
+        help="Path to a git repository (default: discover from the current directory)",
+    )
+    parser.add_argument(
+        "--version",
+        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)",
+    )
+    delete_parser = tags_subparsers.add_parser("delete-task", help="Delete a task tag locally and on a remote")
+    delete_parser.add_argument(
+        "task",
+        help="Task ref or ID (for example refs/tags/tasks/alpha or alpha)",
+    )
+    delete_parser.add_argument(
+        "--remote",
+        default="origin",
+        help="Remote name to delete from (for example origin)",
+    )
+    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
+    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 _normalize_task_ref(task: str) -> str:
-	if task.startswith("refs/"):
-		return task
-	return f"{TASK_REF_PREFIX}{task}"
+    if task.startswith("refs/"):
+        return task
+    return f"{TASK_REF_PREFIX}{task}"
 
 
 def _run_task_delete(repo_path: Path | None, remote: str | None, task: str) -> int:
-	location = resolve_repo_location(repo_path=repo_path)
-	repo = open_repository(location)
-	task_ref = _normalize_task_ref(task)
-	delete_task_local(repo, task_ref)
-	if not remote:
-		return 0
-	try:
-		delete_task_remote(repo, remote, task_ref)
-	except (KeyError, RuntimeError, ValueError) as exc:
-		print(exc, file=sys.stderr)
-		return 1
-	return 0
+    location = resolve_repo_location(repo_path=repo_path)
+    repo = open_repository(location)
+    task_ref = _normalize_task_ref(task)
+    delete_task_local(repo, task_ref)
+    if not remote:
+        return 0
+    try:
+        delete_task_remote(repo, remote, task_ref)
+    except (KeyError, RuntimeError, ValueError) as exc:
+        print(exc, file=sys.stderr)
+        return 1
+    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:
-		return run_tui()
+    args_list = sys.argv[1:] if argv is None else argv
+    if not args_list:
+        return run_tui()
 
-	parser = build_parser()
-	args = parser.parse_args(args_list)
+    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)
-	if args.command == "tags" and args.tags_command == "delete-task":
-		return _run_task_delete(args.repo, args.remote, args.task)
+    if args.command == "tags" and args.tags_command == "sync":
+        return _run_tags_sync(args.repo, args.remote, args.board)
+    if args.command == "tags" and args.tags_command == "delete-task":
+        return _run_task_delete(args.repo, args.remote, args.task)
 
-	return run_tui(args.repo)
+    return run_tui(args.repo)
 
 
 if __name__ == "__main__":
-	raise SystemExit(main())
+    raise SystemExit(main())
diff --git a/pygittools/src/pygittools/merge.py b/pygittools/src/pygittools/merge.py
index 1552e7c..2d8a039 100644
--- a/pygittools/src/pygittools/merge.py
+++ b/pygittools/src/pygittools/merge.py
@@ -18,75 +18,75 @@ MR_REF_PREFIX: str = "refs/tags/mr/"
 
 
 def generate_mr_ref() -> str:
-	"""Return ``refs/tags/mr/<16 hex chars>`` for distributed-safe ref names."""
-	return f"{MR_REF_PREFIX}{secrets.token_hex(8)}"
+    """Return ``refs/tags/mr/<16 hex chars>`` for distributed-safe ref names."""
+    return f"{MR_REF_PREFIX}{secrets.token_hex(8)}"
 
 
 def _parse_dt(value: Any) -> datetime | None:
-	if not value:
-		return None
-	if isinstance(value, datetime):
-		return value
-	return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+    if not value:
+        return None
+    if isinstance(value, datetime):
+        return value
+    return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
 
 
 def _identity_from_opener(opener: Signature | str) -> tuple[str, str]:
-	if isinstance(opener, Signature):
-		return opener.name, opener.email
-	line = (opener or "").strip()
-	match = re.match(r"^(?P<name>.+) <(?P<email>[^>]+)>", line)
-	if match:
-		return match.group("name"), match.group("email")
-	if line:
-		return line, "unknown@local"
-	return "unknown", "unknown@local"
+    if isinstance(opener, Signature):
+        return opener.name, opener.email
+    line = (opener or "").strip()
+    match = re.match(r"^(?P<name>.+) <(?P<email>[^>]+)>", line)
+    if match:
+        return match.group("name"), match.group("email")
+    if line:
+        return line, "unknown@local"
+    return "unknown", "unknown@local"
 
 
 def tagger_signature_at(opener: Signature | str, when: datetime) -> Signature:
-	"""Tagger line identity from ``opener`` with timestamp from ``when`` (UTC offset +0000)."""
-	if when.tzinfo is None:
-		when = when.replace(tzinfo=UTC)
-	name, email = _identity_from_opener(opener)
-	t = int(when.timestamp())
-	return Signature(name, email, t, 0)
+    """Tagger line identity from ``opener`` with timestamp from ``when`` (UTC offset +0000)."""
+    if when.tzinfo is None:
+        when = when.replace(tzinfo=UTC)
+    name, email = _identity_from_opener(opener)
+    t = int(when.timestamp())
+    return Signature(name, email, t, 0)
 
 
 def assert_merge_head_object(repo: Repository, head: Oid | str) -> str:
-	"""Require the merge-request tag target to be a commit or tag object (not tree/blob)."""
-	oid = head if isinstance(head, Oid) else Oid(hex=head)
-	obj = repo[oid]
-	type_str = (getattr(obj, "type_str", None) or "").lower()
-	if type_str in ("blob", "tree"):
-		raise ValueError(f"Merge request head must be a commit or tag, not {type_str}: {oid}")
-	if type_str not in ("commit", "tag"):
-		raise ValueError(f"Merge request head has unsupported type {type_str!r}: {oid}")
-	return type_str
+    """Require the merge-request tag target to be a commit or tag object (not tree/blob)."""
+    oid = head if isinstance(head, Oid) else Oid(hex=head)
+    obj = repo[oid]
+    type_str = (getattr(obj, "type_str", None) or "").lower()
+    if type_str in ("blob", "tree"):
+        raise ValueError(f"Merge request head must be a commit or tag, not {type_str}: {oid}")
+    if type_str not in ("commit", "tag"):
+        raise ValueError(f"Merge request head has unsupported type {type_str!r}: {oid}")
+    return type_str
 
 
 def peel_mr_head_to_commit_oid(repo: Repository, head: Oid | str) -> Oid:
-	"""Follow annotated tags until a commit OID (the revision to merge into ``ours``)."""
-	oid = head if isinstance(head, Oid) else Oid(hex=head)
-	cur = oid
-	while True:
-		try:
-			obj = repo[cur]
-		except KeyError as exc:
-			raise KeyError(cur) from exc
-		if isinstance(obj, Tag):
-			cur = obj.target
-			continue
-		if isinstance(obj, Commit):
-			return obj.id
-		type_str = getattr(obj, "type_str", None) or type(obj).__name__
-		raise TypeError(f"Merge request head peeled to non-commit ({type_str}): {cur}")
+    """Follow annotated tags until a commit OID (the revision to merge into ``ours``)."""
+    oid = head if isinstance(head, Oid) else Oid(hex=head)
+    cur = oid
+    while True:
+        try:
+            obj = repo[cur]
+        except KeyError as exc:
+            raise KeyError(cur) from exc
+        if isinstance(obj, Tag):
+            cur = obj.target
+            continue
+        if isinstance(obj, Commit):
+            return obj.id
+        type_str = getattr(obj, "type_str", None) or type(obj).__name__
+        raise TypeError(f"Merge request head peeled to non-commit ({type_str}): {cur}")
 
 
 class MergeRequestStatus(Enum):
-	OURS_REF_MISSING = "ours_ref_missing"
-	MR_HEAD_MISSING = "mr_head_missing"
-	CAN_FAST_FORWARD = "can_fast_forward"
-	UP_TO_DATE = "up_to_date"
-	BLOCKED_DIVERGED = "blocked_diverged"
+    OURS_REF_MISSING = "ours_ref_missing"
+    MR_HEAD_MISSING = "mr_head_missing"
+    CAN_FAST_FORWARD = "can_fast_forward"
+    UP_TO_DATE = "up_to_date"
+    BLOCKED_DIVERGED = "blocked_diverged"
 
 
 """
@@ -99,149 +99,149 @@ The tagger line uses the opener's identity and the last-modified time from ``upd
 
 
 class MergeRequest:
-	def __init__(
-		self,
-		target: Oid | str,
-		opener: Signature | str,
-		ours: str,
-		title: str,
-		description: str = "",
-		theirs: str | None = None,
-		name: str | None = None,
-		comments: list[Oid | str] | None = None,
-	):
-		ref_name = name if name is not None else generate_mr_ref()
-		if not reference_is_valid_name(ref_name):
-			raise ValueError(f"Invalid merge request ref name: {ref_name!r}")
-		if not ours.strip():
-			raise ValueError("ours must be a non-empty ref name")
-
-		self.target = target
-		self.opener = opener
-		self.ours = ours.strip()
-		self.theirs = theirs.strip() if theirs and str(theirs).strip() else None
-		self.title = title
-		self.description = description
-		self.name = ref_name
-		self.comments: list[Oid | str] = list(comments) if comments is not None else []
-		self.created_at = datetime.now(UTC)
-		self.updated_at = self.created_at
-		self.message: str = ""
-		self.update_message()
-
-	def update_message(self) -> None:
-		self.updated_at = datetime.now(UTC)
-		payload: dict[str, Any] = {
-			"title": self.title,
-			"description": self.description,
-			"ours": self.ours,
-			"theirs": self.theirs,
-			"comments": [str(c) for c in self.comments],
-			"created_at": self.created_at,
-			"updated_at": self.updated_at,
-		}
-		self.message = _DateTimeEncoder().encode(payload)
-
-	def write(self, repo: Repository) -> Oid:
-		object_type = assert_merge_head_object(repo, self.target)
-		tagger_sig = tagger_signature_at(self.opener, self.updated_at)
-		raw = _build_tag_raw(self.target, self.name, str(tagger_sig), self.message, object_type=object_type)
-		oid = repo.odb.write(GIT_OBJECT_TAG, raw)
-		repo.references.create(self.name, oid, force=True)
-		return oid
-
-	def refresh_from_theirs(self, repo: Repository) -> Oid | None:
-		"""If ``theirs`` is set, move tag target to that ref's peeled commit tip when it differs.
-		Returns new tag OID if rewritten.
-		"""
-		if not self.theirs:
-			return None
-		try:
-			theirs_tip = repo.revparse_single(self.theirs).peel(Commit).id
-		except (KeyError, ValueError, GitError):
-			return None
-		try:
-			head_commit = peel_mr_head_to_commit_oid(repo, self.target)
-		except KeyError:
-			return None
-		if head_commit == theirs_tip:
-			return None
-		self.target = theirs_tip
-		self.update_message()
-		return self.write(repo)
-
-	def check_status(self, repo: Repository) -> MergeRequestStatus:
-		try:
-			repo.lookup_reference(self.ours)
-		except KeyError:
-			return MergeRequestStatus.OURS_REF_MISSING
-		try:
-			mr_commit = peel_mr_head_to_commit_oid(repo, self.target)
-		except KeyError:
-			return MergeRequestStatus.MR_HEAD_MISSING
-
-		try:
-			analysis, _pref = repo.merge_analysis(mr_commit, self.ours)
-		except GitError as exc:
-			raise ValueError(f"merge analysis failed for {self.ours!r}: {exc}") from exc
-
-		if analysis & MergeAnalysis.FASTFORWARD:
-			return MergeRequestStatus.CAN_FAST_FORWARD
-		if analysis & MergeAnalysis.UP_TO_DATE:
-			return MergeRequestStatus.UP_TO_DATE
-		return MergeRequestStatus.BLOCKED_DIVERGED
-
-	def merge(self, repo: Repository) -> MergeRequestStatus:
-		status = self.check_status(repo)
-		if status == MergeRequestStatus.OURS_REF_MISSING:
-			raise ValueError(f"ours ref does not exist: {self.ours!r}")
-		if status == MergeRequestStatus.MR_HEAD_MISSING:
-			raise ValueError(f"merge request head object missing in repository: {self.target!r}")
-		if status == MergeRequestStatus.BLOCKED_DIVERGED:
-			raise ValueError(
-				f"cannot fast-forward {self.ours!r} to merge request head {self.target!r}: histories diverged"
-			)
-		if status == MergeRequestStatus.UP_TO_DATE:
-			return status
-
-		mr_commit = peel_mr_head_to_commit_oid(repo, self.target)
-		target_ref = repo.lookup_reference(self.ours).resolve()
-		target_ref.set_target(mr_commit)
-		return status
+    def __init__(
+        self,
+        target: Oid | str,
+        opener: Signature | str,
+        ours: str,
+        title: str,
+        description: str = "",
+        theirs: str | None = None,
+        name: str | None = None,
+        comments: list[Oid | str] | None = None,
+    ):
+        ref_name = name if name is not None else generate_mr_ref()
+        if not reference_is_valid_name(ref_name):
+            raise ValueError(f"Invalid merge request ref name: {ref_name!r}")
+        if not ours.strip():
+            raise ValueError("ours must be a non-empty ref name")
+
+        self.target = target
+        self.opener = opener
+        self.ours = ours.strip()
+        self.theirs = theirs.strip() if theirs and str(theirs).strip() else None
+        self.title = title
+        self.description = description
+        self.name = ref_name
+        self.comments: list[Oid | str] = list(comments) if comments is not None else []
+        self.created_at = datetime.now(UTC)
+        self.updated_at = self.created_at
+        self.message: str = ""
+        self.update_message()
+
+    def update_message(self) -> None:
+        self.updated_at = datetime.now(UTC)
+        payload: dict[str, Any] = {
+            "title": self.title,
+            "description": self.description,
+            "ours": self.ours,
+            "theirs": self.theirs,
+            "comments": [str(c) for c in self.comments],
+            "created_at": self.created_at,
+            "updated_at": self.updated_at,
+        }
+        self.message = _DateTimeEncoder().encode(payload)
+
+    def write(self, repo: Repository) -> Oid:
+        object_type = assert_merge_head_object(repo, self.target)
+        tagger_sig = tagger_signature_at(self.opener, self.updated_at)
+        raw = _build_tag_raw(self.target, self.name, str(tagger_sig), self.message, object_type=object_type)
+        oid = repo.odb.write(GIT_OBJECT_TAG, raw)
+        repo.references.create(self.name, oid, force=True)
+        return oid
+
+    def refresh_from_theirs(self, repo: Repository) -> Oid | None:
+        """If ``theirs`` is set, move tag target to that ref's peeled commit tip when it differs.
+        Returns new tag OID if rewritten.
+        """
+        if not self.theirs:
+            return None
+        try:
+            theirs_tip = repo.revparse_single(self.theirs).peel(Commit).id
+        except (KeyError, ValueError, GitError):
+            return None
+        try:
+            head_commit = peel_mr_head_to_commit_oid(repo, self.target)
+        except KeyError:
+            return None
+        if head_commit == theirs_tip:
+            return None
+        self.target = theirs_tip
+        self.update_message()
+        return self.write(repo)
+
+    def check_status(self, repo: Repository) -> MergeRequestStatus:
+        try:
+            repo.lookup_reference(self.ours)
+        except KeyError:
+            return MergeRequestStatus.OURS_REF_MISSING
+        try:
+            mr_commit = peel_mr_head_to_commit_oid(repo, self.target)
+        except KeyError:
+            return MergeRequestStatus.MR_HEAD_MISSING
+
+        try:
+            analysis, _pref = repo.merge_analysis(mr_commit, self.ours)
+        except GitError as exc:
+            raise ValueError(f"merge analysis failed for {self.ours!r}: {exc}") from exc
+
+        if analysis & MergeAnalysis.FASTFORWARD:
+            return MergeRequestStatus.CAN_FAST_FORWARD
+        if analysis & MergeAnalysis.UP_TO_DATE:
+            return MergeRequestStatus.UP_TO_DATE
+        return MergeRequestStatus.BLOCKED_DIVERGED
+
+    def merge(self, repo: Repository) -> MergeRequestStatus:
+        status = self.check_status(repo)
+        if status == MergeRequestStatus.OURS_REF_MISSING:
+            raise ValueError(f"ours ref does not exist: {self.ours!r}")
+        if status == MergeRequestStatus.MR_HEAD_MISSING:
+            raise ValueError(f"merge request head object missing in repository: {self.target!r}")
+        if status == MergeRequestStatus.BLOCKED_DIVERGED:
+            raise ValueError(
+                f"cannot fast-forward {self.ours!r} to merge request head {self.target!r}: histories diverged"
+            )
+        if status == MergeRequestStatus.UP_TO_DATE:
+            return status
+
+        mr_commit = peel_mr_head_to_commit_oid(repo, self.target)
+        target_ref = repo.lookup_reference(self.ours).resolve()
+        target_ref.set_target(mr_commit)
+        return status
 
 
 def _merge_request_from_tag(tag: Tag) -> MergeRequest:
-	body = json.loads(tag.message)
-	raw_ours = body.get("ours") or body.get("target")
-	if raw_ours is None or not str(raw_ours).strip():
-		raise ValueError("merge request payload missing non-empty ours (or legacy target)")
-	theirs_raw = body.get("theirs")
-	theirs = str(theirs_raw).strip() if theirs_raw is not None and str(theirs_raw).strip() else None
-	mr = MergeRequest(
-		tag.target,
-		str(tag.tagger) if tag.tagger else "",
-		ours=str(raw_ours).strip(),
-		title=body.get("title", "Untitled"),
-		description=body.get("description", ""),
-		theirs=theirs,
-		name=tag.name,
-		comments=list(body.get("comments", [])),
-	)
-	mr.created_at = _parse_dt(body.get("created_at")) or mr.created_at
-	mr.updated_at = _parse_dt(body.get("updated_at")) or mr.updated_at
-	mr.message = tag.message
-	return mr
+    body = json.loads(tag.message)
+    raw_ours = body.get("ours") or body.get("target")
+    if raw_ours is None or not str(raw_ours).strip():
+        raise ValueError("merge request payload missing non-empty ours (or legacy target)")
+    theirs_raw = body.get("theirs")
+    theirs = str(theirs_raw).strip() if theirs_raw is not None and str(theirs_raw).strip() else None
+    mr = MergeRequest(
+        tag.target,
+        str(tag.tagger) if tag.tagger else "",
+        ours=str(raw_ours).strip(),
+        title=body.get("title", "Untitled"),
+        description=body.get("description", ""),
+        theirs=theirs,
+        name=tag.name,
+        comments=list(body.get("comments", [])),
+    )
+    mr.created_at = _parse_dt(body.get("created_at")) or mr.created_at
+    mr.updated_at = _parse_dt(body.get("updated_at")) or mr.updated_at
+    mr.message = tag.message
+    return mr
 
 
 def get_merge_request(repo: Repository, ref: str) -> MergeRequest:
-	obj = repo.revparse_single(ref)
-	if not isinstance(obj, Tag):
-		raise ValueError(f"Requested merge request is not a tag: {ref}")
-	return _merge_request_from_tag(obj)
+    obj = repo.revparse_single(ref)
+    if not isinstance(obj, Tag):
+        raise ValueError(f"Requested merge request is not a tag: {ref}")
+    return _merge_request_from_tag(obj)
 
 
 def get_merge_request_by_oid(repo: Repository, oid: Oid) -> MergeRequest:
-	obj = repo[oid]
-	if not isinstance(obj, Tag):
-		raise ValueError(f"Object is not a merge request tag: {oid}")
-	return _merge_request_from_tag(obj)
+    obj = repo[oid]
+    if not isinstance(obj, Tag):
+        raise ValueError(f"Object is not a merge request tag: {oid}")
+    return _merge_request_from_tag(obj)
diff --git a/pygittools/src/pygittools/merge_test.py b/pygittools/src/pygittools/merge_test.py
index d882834..e117361 100644
--- a/pygittools/src/pygittools/merge_test.py
+++ b/pygittools/src/pygittools/merge_test.py
@@ -4,14 +4,14 @@ import pytest
 from pygit2 import GIT_OBJECT_BLOB, Oid, Repository, Signature, init_repository
 
 from pygittools.merge import (
-	MR_REF_PREFIX,
-	MergeRequest,
-	MergeRequestStatus,
-	assert_merge_head_object,
-	generate_mr_ref,
-	get_merge_request,
-	get_merge_request_by_oid,
-	peel_mr_head_to_commit_oid,
+    MR_REF_PREFIX,
+    MergeRequest,
+    MergeRequestStatus,
+    assert_merge_head_object,
+    generate_mr_ref,
+    get_merge_request,
+    get_merge_request_by_oid,
+    peel_mr_head_to_commit_oid,
 )
 from pygittools.tasks import EMPTY_TREE_OID_HEX, _ensure_empty_tree
 
@@ -20,146 +20,146 @@ SIG = Signature("alice", "alice@example.com")
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-	return init_repository(str(repo_path), bare=False)
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+    return init_repository(str(repo_path), bare=False)
 
 
 def _commit(repo: Repository) -> Oid:
-	tb = repo.TreeBuilder()
-	tree = tb.write()
-	return repo.create_commit("HEAD", SIG, SIG, "init", tree, [])
+    tb = repo.TreeBuilder()
+    tree = tb.write()
+    return repo.create_commit("HEAD", SIG, SIG, "init", tree, [])
 
 
 def _linear_ab(repo: Repository) -> tuple[Oid, Oid]:
-	tb = repo.TreeBuilder()
-	tree = tb.write()
-	a = repo.create_commit(None, SIG, SIG, "a", tree, [])
-	b = repo.create_commit(None, SIG, SIG, "b", tree, [a])
-	repo.create_reference("refs/heads/main", a)
-	return a, b
+    tb = repo.TreeBuilder()
+    tree = tb.write()
+    a = repo.create_commit(None, SIG, SIG, "a", tree, [])
+    b = repo.create_commit(None, SIG, SIG, "b", tree, [a])
+    repo.create_reference("refs/heads/main", a)
+    return a, b
 
 
 def test_generate_mr_ref_shape() -> None:
-	ref = generate_mr_ref()
-	assert ref.startswith(MR_REF_PREFIX)
-	assert len(ref) == len(MR_REF_PREFIX) + 16
+    ref = generate_mr_ref()
+    assert ref.startswith(MR_REF_PREFIX)
+    assert len(ref) == len(MR_REF_PREFIX) + 16
 
 
 def test_mr_roundtrip_commit(repo: Repository) -> None:
-	c = _commit(repo)
-	ref = f"{MR_REF_PREFIX}deadbeefcafebabe"
-	mr = MergeRequest(c, SIG, ours="refs/heads/main", title="Add feature", description="Details", name=ref)
-	oid = mr.write(repo)
+    c = _commit(repo)
+    ref = f"{MR_REF_PREFIX}deadbeefcafebabe"
+    mr = MergeRequest(c, SIG, ours="refs/heads/main", title="Add feature", description="Details", name=ref)
+    oid = mr.write(repo)
 
-	loaded = get_merge_request(repo, ref)
-	assert loaded.title == "Add feature"
-	assert loaded.description == "Details"
-	assert loaded.ours == "refs/heads/main"
-	assert loaded.target == c
-	assert loaded.name == ref
+    loaded = get_merge_request(repo, ref)
+    assert loaded.title == "Add feature"
+    assert loaded.description == "Details"
+    assert loaded.ours == "refs/heads/main"
+    assert loaded.target == c
+    assert loaded.name == ref
 
-	loaded_oid = get_merge_request_by_oid(repo, oid)
-	assert loaded_oid.title == "Add feature"
+    loaded_oid = get_merge_request_by_oid(repo, oid)
+    assert loaded_oid.title == "Add feature"
 
 
 def test_mr_rejects_tree(repo: Repository) -> None:
-	_ensure_empty_tree(repo)
-	tree_oid = Oid(hex=EMPTY_TREE_OID_HEX)
-	ref = f"{MR_REF_PREFIX}aaaaaaaaaaaaaaaa"
-	mr = MergeRequest(tree_oid, SIG, ours="refs/heads/main", title="bad", name=ref)
-	with pytest.raises(ValueError, match="tree"):
-		mr.write(repo)
+    _ensure_empty_tree(repo)
+    tree_oid = Oid(hex=EMPTY_TREE_OID_HEX)
+    ref = f"{MR_REF_PREFIX}aaaaaaaaaaaaaaaa"
+    mr = MergeRequest(tree_oid, SIG, ours="refs/heads/main", title="bad", name=ref)
+    with pytest.raises(ValueError, match="tree"):
+        mr.write(repo)
 
 
 def test_mr_rejects_blob(repo: Repository) -> None:
-	blob_oid = repo.odb.write(GIT_OBJECT_BLOB, b"hello")
-	ref = f"{MR_REF_PREFIX}bbbbbbbbbbbbbbbb"
-	mr = MergeRequest(blob_oid, SIG, ours="refs/heads/main", title="bad", name=ref)
-	with pytest.raises(ValueError, match="blob"):
-		mr.write(repo)
+    blob_oid = repo.odb.write(GIT_OBJECT_BLOB, b"hello")
+    ref = f"{MR_REF_PREFIX}bbbbbbbbbbbbbbbb"
+    mr = MergeRequest(blob_oid, SIG, ours="refs/heads/main", title="bad", name=ref)
+    with pytest.raises(ValueError, match="blob"):
+        mr.write(repo)
 
 
 def test_mr_allows_tag_head(repo: Repository) -> None:
-	c = _commit(repo)
-	ref_base = f"{MR_REF_PREFIX}1111111111111111"
-	mr1 = MergeRequest(c, SIG, ours="refs/heads/main", title="first", name=ref_base)
-	tag_oid = mr1.write(repo)
+    c = _commit(repo)
+    ref_base = f"{MR_REF_PREFIX}1111111111111111"
+    mr1 = MergeRequest(c, SIG, ours="refs/heads/main", title="first", name=ref_base)
+    tag_oid = mr1.write(repo)
 
-	ref_second = f"{MR_REF_PREFIX}2222222222222222"
-	mr2 = MergeRequest(tag_oid, SIG, ours="refs/heads/main", title="train", name=ref_second)
-	mr2.write(repo)
+    ref_second = f"{MR_REF_PREFIX}2222222222222222"
+    mr2 = MergeRequest(tag_oid, SIG, ours="refs/heads/main", title="train", name=ref_second)
+    mr2.write(repo)
 
-	loaded = get_merge_request(repo, ref_second)
-	assert loaded.title == "train"
-	assert loaded.target == tag_oid
+    loaded = get_merge_request(repo, ref_second)
+    assert loaded.title == "train"
+    assert loaded.target == tag_oid
 
 
 def test_assert_merge_head_object(repo: Repository) -> None:
-	_ensure_empty_tree(repo)
-	c = _commit(repo)
-	assert assert_merge_head_object(repo, c) == "commit"
-	tree_oid = Oid(hex=EMPTY_TREE_OID_HEX)
-	with pytest.raises(ValueError, match="tree"):
-		assert_merge_head_object(repo, tree_oid)
+    _ensure_empty_tree(repo)
+    c = _commit(repo)
+    assert assert_merge_head_object(repo, c) == "commit"
+    tree_oid = Oid(hex=EMPTY_TREE_OID_HEX)
+    with pytest.raises(ValueError, match="tree"):
+        assert_merge_head_object(repo, tree_oid)
 
 
 def test_merge_ff_advances_branch(repo: Repository) -> None:
-	_a, b = _linear_ab(repo)
-	mr_ref = f"{MR_REF_PREFIX}feedfeedfeedfeed"
-	mr = MergeRequest(b, SIG, ours="refs/heads/main", title="ff", name=mr_ref)
-	mr.write(repo)
-	assert mr.check_status(repo) == MergeRequestStatus.CAN_FAST_FORWARD
-	assert mr.merge(repo) == MergeRequestStatus.CAN_FAST_FORWARD
-	assert repo.references["refs/heads/main"].resolve().target == b
+    _a, b = _linear_ab(repo)
+    mr_ref = f"{MR_REF_PREFIX}feedfeedfeedfeed"
+    mr = MergeRequest(b, SIG, ours="refs/heads/main", title="ff", name=mr_ref)
+    mr.write(repo)
+    assert mr.check_status(repo) == MergeRequestStatus.CAN_FAST_FORWARD
+    assert mr.merge(repo) == MergeRequestStatus.CAN_FAST_FORWARD
+    assert repo.references["refs/heads/main"].resolve().target == b
 
 
 def test_merge_diverged_blocked(repo: Repository) -> None:
-	tb = repo.TreeBuilder()
-	tree = tb.write()
-	root = repo.create_commit(None, SIG, SIG, "root", tree, [])
-	c1 = repo.create_commit(None, SIG, SIG, "c1", tree, [root])
-	c2 = repo.create_commit(None, SIG, SIG, "c2", tree, [root])
-	repo.create_reference("refs/heads/main", c1)
-	mr_ref = f"{MR_REF_PREFIX}ddddeeeeffffffff"
-	mr = MergeRequest(c2, SIG, ours="refs/heads/main", title="div", name=mr_ref)
-	mr.write(repo)
-	assert mr.check_status(repo) == MergeRequestStatus.BLOCKED_DIVERGED
-	with pytest.raises(ValueError, match="fast-forward"):
-		mr.merge(repo)
-	assert repo.references["refs/heads/main"].resolve().target == c1
+    tb = repo.TreeBuilder()
+    tree = tb.write()
+    root = repo.create_commit(None, SIG, SIG, "root", tree, [])
+    c1 = repo.create_commit(None, SIG, SIG, "c1", tree, [root])
+    c2 = repo.create_commit(None, SIG, SIG, "c2", tree, [root])
+    repo.create_reference("refs/heads/main", c1)
+    mr_ref = f"{MR_REF_PREFIX}ddddeeeeffffffff"
+    mr = MergeRequest(c2, SIG, ours="refs/heads/main", title="div", name=mr_ref)
+    mr.write(repo)
+    assert mr.check_status(repo) == MergeRequestStatus.BLOCKED_DIVERGED
+    with pytest.raises(ValueError, match="fast-forward"):
+        mr.merge(repo)
+    assert repo.references["refs/heads/main"].resolve().target == c1
 
 
 def test_merge_up_to_date_noop(repo: Repository) -> None:
-	a, b = _linear_ab(repo)
-	repo.references["refs/heads/main"].resolve().set_target(b)
-	mr_ref = f"{MR_REF_PREFIX}aaaabbbbccccdddd"
-	mr = MergeRequest(a, SIG, ours="refs/heads/main", title="behind", name=mr_ref)
-	mr.write(repo)
-	assert mr.check_status(repo) == MergeRequestStatus.UP_TO_DATE
-	assert mr.merge(repo) == MergeRequestStatus.UP_TO_DATE
-	assert repo.references["refs/heads/main"].resolve().target == b
+    a, b = _linear_ab(repo)
+    repo.references["refs/heads/main"].resolve().set_target(b)
+    mr_ref = f"{MR_REF_PREFIX}aaaabbbbccccdddd"
+    mr = MergeRequest(a, SIG, ours="refs/heads/main", title="behind", name=mr_ref)
+    mr.write(repo)
+    assert mr.check_status(repo) == MergeRequestStatus.UP_TO_DATE
+    assert mr.merge(repo) == MergeRequestStatus.UP_TO_DATE
+    assert repo.references["refs/heads/main"].resolve().target == b
 
 
 def test_check_status_missing_branch(repo: Repository) -> None:
-	c = _commit(repo)
-	mr_ref = f"{MR_REF_PREFIX}eeeeffff00001111"
-	mr = MergeRequest(c, SIG, ours="refs/heads/does-not-exist", title="x", name=mr_ref)
-	mr.write(repo)
-	assert mr.check_status(repo) == MergeRequestStatus.OURS_REF_MISSING
+    c = _commit(repo)
+    mr_ref = f"{MR_REF_PREFIX}eeeeffff00001111"
+    mr = MergeRequest(c, SIG, ours="refs/heads/does-not-exist", title="x", name=mr_ref)
+    mr.write(repo)
+    assert mr.check_status(repo) == MergeRequestStatus.OURS_REF_MISSING
 
 
 def test_refresh_from_theirs_advances_mr_head(repo: Repository) -> None:
-	tb = repo.TreeBuilder()
-	tree = tb.write()
-	a = repo.create_commit(None, SIG, SIG, "a", tree, [])
-	repo.create_reference("refs/heads/main", a)
-	b = repo.create_commit(None, SIG, SIG, "b", tree, [a])
-	repo.create_reference("refs/heads/feature", b)
-	mr_ref = f"{MR_REF_PREFIX}0123456789abcdef"
-	mr = MergeRequest(a, SIG, ours="refs/heads/main", title="track", name=mr_ref, theirs="refs/heads/feature")
-	mr.write(repo)
-	assert peel_mr_head_to_commit_oid(repo, mr.target) == a
-	new_oid = mr.refresh_from_theirs(repo)
-	assert new_oid is not None
-	assert peel_mr_head_to_commit_oid(repo, mr.target) == b
+    tb = repo.TreeBuilder()
+    tree = tb.write()
+    a = repo.create_commit(None, SIG, SIG, "a", tree, [])
+    repo.create_reference("refs/heads/main", a)
+    b = repo.create_commit(None, SIG, SIG, "b", tree, [a])
+    repo.create_reference("refs/heads/feature", b)
+    mr_ref = f"{MR_REF_PREFIX}0123456789abcdef"
+    mr = MergeRequest(a, SIG, ours="refs/heads/main", title="track", name=mr_ref, theirs="refs/heads/feature")
+    mr.write(repo)
+    assert peel_mr_head_to_commit_oid(repo, mr.target) == a
+    new_oid = mr.refresh_from_theirs(repo)
+    assert new_oid is not None
+    assert peel_mr_head_to_commit_oid(repo, mr.target) == b
diff --git a/pygittools/src/pygittools/tags_sync.py b/pygittools/src/pygittools/tags_sync.py
index 19a39e2..d4faff4 100644
--- a/pygittools/src/pygittools/tags_sync.py
+++ b/pygittools/src/pygittools/tags_sync.py
@@ -10,16 +10,16 @@ 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,
+    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
 
@@ -28,367 +28,367 @@ 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
+    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}/"
+    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}")
+    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]
+    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,
+    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
+    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)
+    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,
+    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}")
+    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,
+    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)
+    _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
+    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)
+    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
+    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,
+    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
+    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)
+    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)
+    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,
+    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
+    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
+    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],
+    local_order: list[str],
+    remote_order: list[str],
+    oids_by_ref: dict[str, str],
 ) -> list[Oid | str]:
-	merged: list[Oid | 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
+    merged: list[Oid | 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,
+    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)
+    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,
+    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)}")
+    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,
+    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
+    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 delete_task_remote(
-	repo: Repository,
-	remote_name: str,
-	task_ref: str,
-	*,
-	git: str | None = None,
+    repo: Repository,
+    remote_name: str,
+    task_ref: str,
+    *,
+    git: str | None = None,
 ) -> None:
-	if remote_name not in repo.remotes.names():
-		raise ValueError(f"remote {remote_name!r} not configured")
+    if remote_name not in repo.remotes.names():
+        raise ValueError(f"remote {remote_name!r} not configured")
 
-	task_name = task_ref
-	if not task_name.startswith("refs/") and not task_name.startswith(TASK_REF_PREFIX):
-		task_name = f"{TASK_REF_PREFIX}{task_name}"
+    task_name = task_ref
+    if not task_name.startswith("refs/") and not task_name.startswith(TASK_REF_PREFIX):
+        task_name = f"{TASK_REF_PREFIX}{task_name}"
 
-	refspecs: list[str] = [f":{task_name}"]
-	task = get_task(repo, task_name)
-	if task is not None:
-		for oid_hex in getattr(task, "comments", []) or []:
-			try:
-				oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
-				comment = get_comment(repo, oid)
-			except (ValueError, KeyError, TypeError):
-				continue
-			refspecs.append(f":{comment.name}")
+    refspecs: list[str] = [f":{task_name}"]
+    task = get_task(repo, task_name)
+    if task is not None:
+        for oid_hex in getattr(task, "comments", []) or []:
+            try:
+                oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+                comment = get_comment(repo, oid)
+            except (ValueError, KeyError, TypeError):
+                continue
+            refspecs.append(f":{comment.name}")
 
-	proc = _run_git(repo, "push", remote_name, *refspecs, git=git)
-	_require_git_ok(proc, f"delete {task_name}")
+    proc = _run_git(repo, "push", remote_name, *refspecs, git=git)
+    _require_git_ok(proc, f"delete {task_name}")
 
 
 def sync_board_tags(
-	repo: Repository,
-	remote_name: str,
-	board_name: str,
-	*,
-	git: str | None = None,
+    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")
+    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)
+    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,
-	)
+    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)
+    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/src/pygittools/tags_sync_test.py b/pygittools/src/pygittools/tags_sync_test.py
index 502830a..3abe524 100644
--- a/pygittools/src/pygittools/tags_sync_test.py
+++ b/pygittools/src/pygittools/tags_sync_test.py
@@ -7,156 +7,156 @@ 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,
+    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)
+    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
+    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,
+    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
+    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"
+    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"
+    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
+    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/src/pygittools/tasks.py b/pygittools/src/pygittools/tasks.py
index d1e0518..fc2915c 100644
--- a/pygittools/src/pygittools/tasks.py
+++ b/pygittools/src/pygittools/tasks.py
@@ -33,519 +33,519 @@ SPRINT_POINTS: tuple[int, ...] = (0, 1, 2, 3, 5, 8, 13, 21)
 
 
 def get_board_ref(name: str) -> str:
-	return f"{BOARD_REF_PREFIX}{name}"
+    return f"{BOARD_REF_PREFIX}{name}"
 
 
 def get_task_ref(task_id: str) -> str:
-	return f"{TASK_REF_PREFIX}{task_id}"
+    return f"{TASK_REF_PREFIX}{task_id}"
 
 
 def get_repo_head_or_empty(repo: Repository) -> Oid | str:
-	try:
-		return repo.head.target
-	except KeyError:
-		return EMPTY_TREE_OID
+    try:
+        return repo.head.target
+    except KeyError:
+        return EMPTY_TREE_OID
 
 
 def parse_points(raw: Any) -> int | None:
-	"""Parse a story points value, keeping only values on the Fibonacci scale."""
-	if raw is None or raw == "":
-		return None
-	try:
-		value = int(raw)
-	except (TypeError, ValueError):
-		return None
-	return value if value in SPRINT_POINTS else None
+    """Parse a story points value, keeping only values on the Fibonacci scale."""
+    if raw is None or raw == "":
+        return None
+    try:
+        value = int(raw)
+    except (TypeError, ValueError):
+        return None
+    return value if value in SPRINT_POINTS else None
 
 
 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")
+    """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 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"
+    """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",
+    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).
+    """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")
+    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)
+    """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)
 
 
 class Task:
-	"""
-	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 Status(StrEnum):
-		TODO = "TODO"
-		IN_PROGRESS = "IN_PROGRESS"
-		IN_REVIEW = "IN_REVIEW"
-		DONE = "DONE"
-		CANCELLED = "CANCELLED"
-
-	class Priority(StrEnum):
-		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,
-		tags: str = "",
-		points: int | 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.tags = tags
-		self.points = points  # Sprint story points (Fibonacci scale), None = unestimated
-		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"]
-		})
-
-	def write(self, repo: Repository) -> Oid:
-		"""
-		Write the task to the repository.
-		@param repo: The repository to write to.
-		@return: The OID of the written task.
-		"""
-		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
+    """
+    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 Status(StrEnum):
+        TODO = "TODO"
+        IN_PROGRESS = "IN_PROGRESS"
+        IN_REVIEW = "IN_REVIEW"
+        DONE = "DONE"
+        CANCELLED = "CANCELLED"
+
+    class Priority(StrEnum):
+        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,
+        tags: str = "",
+        points: int | 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.tags = tags
+        self.points = points  # Sprint story points (Fibonacci scale), None = unestimated
+        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"]
+        })
+
+    def write(self, repo: Repository) -> Oid:
+        """
+        Write the task to the repository.
+        @param repo: The repository to write to.
+        @return: The OID of the written task.
+        """
+        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: str | datetime | None) -> datetime | None:
-	if not s:
-		return None
-	if isinstance(s, datetime):
-		return s
-	return datetime.fromisoformat(s.replace("Z", "+00:00"))
+    if not s:
+        return None
+    if isinstance(s, datetime):
+        return s
+    return datetime.fromisoformat(s.replace("Z", "+00:00"))
 
 
 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")),
-		tags=j.get("tags", "") or "",
-		points=j.get("points"),
-	)
-	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
+    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")),
+        tags=j.get("tags", "") or "",
+        points=j.get("points"),
+    )
+    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")),
-		tags=j.get("tags", "") or "",
-		points=j.get("points"),
-	)
-	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
+    """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")),
+        tags=j.get("tags", "") or "",
+        points=j.get("points"),
+    )
+    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
 
 
 class Comment:
-	"""
-	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.
-	"""
-
-	def __init__(
-		self,
-		target: Oid | str,  # 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"]
-		})
-
-	def write(self, repo: Repository) -> Oid:
-		"""
-		Write the comment to the repository.
-		@param repo: The repository to write to.
-		@return: The OID of the written comment.
-		"""
-		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
+    """
+    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.
+    """
+
+    def __init__(
+        self,
+        target: Oid | str,  # 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"]
+        })
+
+    def write(self, repo: Repository) -> Oid:
+        """
+        Write the comment to the repository.
+        @param repo: The repository to write to.
+        @return: The OID of the written comment.
+        """
+        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:
-	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)
-	created = _parse_dt(j.get("created_at"))
-	if not created:
-		raise ValueError(f"Invalid creation time for oid: {oid}")
-	c.created_at = created
-	edited = _parse_dt(j.get("edited_at"))
-	if not edited:
-		raise ValueError(f"Invalid edit time for oid: {oid}")
-	c.edited_at = edited
-	return c
+    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)
+    created = _parse_dt(j.get("created_at"))
+    if not created:
+        raise ValueError(f"Invalid creation time for oid: {oid}")
+    c.created_at = created
+    edited = _parse_dt(j.get("edited_at"))
+    if not edited:
+        raise ValueError(f"Invalid edit time for oid: {oid}")
+    c.edited_at = edited
+    return c
 
 
 class Board:
-	"""
-	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.
-	"""
-
-	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
+    """
+    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.
+    """
+
+    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
+    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
 
 
 def delete_task_local(repo: Repository, task_ref: str) -> None:
-	try:
-		tag_obj = repo.revparse_single(task_ref)
-	except (KeyError, ValueError):
-		return
-	if not isinstance(tag_obj, Tag):
-		return
-	task = get_task(repo, task_ref)
-	task_name = tag_obj.name
-
-	for ref_name in list(repo.references):
-		if not ref_name.startswith(BOARD_REF_PREFIX):
-			continue
-		board = get_board(repo, ref_name)
-		if board is None:
-			continue
-		original = list(getattr(board, "tasks", []) or [])
-		filtered: list[Oid | str] = []
-		removed: list[str] = []
-		for entry in original:
-			try:
-				oid = Oid(hex=entry) if isinstance(entry, str) else entry
-				board_task = get_task_by_oid(repo, oid)
-			except (ValueError, KeyError, TypeError):
-				filtered.append(entry)
-				continue
-			if board_task is None or board_task.name != task_name:
-				filtered.append(entry)
-			else:
-				removed.append(str(oid))
-		if not removed:
-			continue
-		board.tasks = filtered
-		board.update_message()
-		board.write(repo)
-
-	if task is not None:
-		for oid_hex in getattr(task, "comments", []) or []:
-			try:
-				oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
-				comment = get_comment(repo, oid)
-			except (ValueError, KeyError, TypeError):
-				continue
-			if comment.name in repo.references:
-				repo.references.delete(comment.name)
-
-	if task_ref in repo.references:
-		repo.references.delete(task_ref)
+    try:
+        tag_obj = repo.revparse_single(task_ref)
+    except (KeyError, ValueError):
+        return
+    if not isinstance(tag_obj, Tag):
+        return
+    task = get_task(repo, task_ref)
+    task_name = tag_obj.name
+
+    for ref_name in list(repo.references):
+        if not ref_name.startswith(BOARD_REF_PREFIX):
+            continue
+        board = get_board(repo, ref_name)
+        if board is None:
+            continue
+        original = list(getattr(board, "tasks", []) or [])
+        filtered: list[Oid | str] = []
+        removed: list[str] = []
+        for entry in original:
+            try:
+                oid = Oid(hex=entry) if isinstance(entry, str) else entry
+                board_task = get_task_by_oid(repo, oid)
+            except (ValueError, KeyError, TypeError):
+                filtered.append(entry)
+                continue
+            if board_task is None or board_task.name != task_name:
+                filtered.append(entry)
+            else:
+                removed.append(str(oid))
+        if not removed:
+            continue
+        board.tasks = filtered
+        board.update_message()
+        board.write(repo)
+
+    if task is not None:
+        for oid_hex in getattr(task, "comments", []) or []:
+            try:
+                oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+                comment = get_comment(repo, oid)
+            except (ValueError, KeyError, TypeError):
+                continue
+            if comment.name in repo.references:
+                repo.references.delete(comment.name)
+
+    if task_ref in repo.references:
+        repo.references.delete(task_ref)
 
 
 def _remove_task_from_board(repo: Repository, board_ref: str, task_name: str) -> bool:
-	board = get_board(repo, board_ref)
-	if board is None:
-		return False
-	original = list(getattr(board, "tasks", []) or [])
-	filtered: list[Oid | str] = []
-	removed = False
-	for entry in original:
-		try:
-			oid = Oid(hex=entry) if isinstance(entry, str) else entry
-			board_task = get_task_by_oid(repo, oid)
-		except (ValueError, KeyError, TypeError):
-			filtered.append(entry)
-			continue
-		if board_task is None or board_task.name != task_name:
-			filtered.append(entry)
-		else:
-			removed = True
-	if not removed:
-		return False
-	board.tasks = filtered
-	board.update_message()
-	board.write(repo)
-	return True
+    board = get_board(repo, board_ref)
+    if board is None:
+        return False
+    original = list(getattr(board, "tasks", []) or [])
+    filtered: list[Oid | str] = []
+    removed = False
+    for entry in original:
+        try:
+            oid = Oid(hex=entry) if isinstance(entry, str) else entry
+            board_task = get_task_by_oid(repo, oid)
+        except (ValueError, KeyError, TypeError):
+            filtered.append(entry)
+            continue
+        if board_task is None or board_task.name != task_name:
+            filtered.append(entry)
+        else:
+            removed = True
+    if not removed:
+        return False
+    board.tasks = filtered
+    board.update_message()
+    board.write(repo)
+    return True
 
 
 def archive_task_local(
-	repo: Repository,
-	task_ref: str,
-	source_board_name: str,
-	*,
-	archive_board_name: str = ARCHIVE_BOARD_NAME,
+    repo: Repository,
+    task_ref: str,
+    source_board_name: str,
+    *,
+    archive_board_name: str = ARCHIVE_BOARD_NAME,
 ) -> str:
-	"""Move a task from a board to the archive board, creating the archive board if needed."""
-	if task_ref not in repo.references:
-		raise KeyError(f"Task not found: {task_ref}")
-	task = get_task(repo, task_ref)
-	if task is None:
-		raise KeyError(f"Task not found: {task_ref}")
-	tag_obj = repo.revparse_single(task_ref)
-	if not isinstance(tag_obj, Tag):
-		raise KeyError(f"Task not found: {task_ref}")
-
-	source_ref = f"{BOARD_REF_PREFIX}{source_board_name}"
-	if source_ref not in repo.references:
-		raise KeyError(f"Board not found: {source_board_name}")
-
-	_remove_task_from_board(repo, source_ref, tag_obj.name)
-
-	oid_hex = str(repo.references[task_ref].resolve().target)
-	archive_ref = f"{BOARD_REF_PREFIX}{archive_board_name}"
-	if archive_ref in repo.references:
-		archive_board = get_board(repo, archive_ref)
-		assert archive_board is not None
-		tasks = list(getattr(archive_board, "tasks", []) or [])
-		if oid_hex not in tasks:
-			tasks.append(oid_hex)
-		archive_board.tasks = tasks
-		archive_board.update_message()
-		archive_board.write(repo)
-		return archive_ref
-
-	archive_board = Board(
-		Oid(hex=EMPTY_TREE_OID_HEX),
-		archive_ref,
-		tagger="",
-		description="Archived tasks",
-	)
-	archive_board.tasks = [oid_hex]
-	archive_board.update_message()
-	archive_board.write(repo)
-	return archive_ref
+    """Move a task from a board to the archive board, creating the archive board if needed."""
+    if task_ref not in repo.references:
+        raise KeyError(f"Task not found: {task_ref}")
+    task = get_task(repo, task_ref)
+    if task is None:
+        raise KeyError(f"Task not found: {task_ref}")
+    tag_obj = repo.revparse_single(task_ref)
+    if not isinstance(tag_obj, Tag):
+        raise KeyError(f"Task not found: {task_ref}")
+
+    source_ref = f"{BOARD_REF_PREFIX}{source_board_name}"
+    if source_ref not in repo.references:
+        raise KeyError(f"Board not found: {source_board_name}")
+
+    _remove_task_from_board(repo, source_ref, tag_obj.name)
+
+    oid_hex = str(repo.references[task_ref].resolve().target)
+    archive_ref = f"{BOARD_REF_PREFIX}{archive_board_name}"
+    if archive_ref in repo.references:
+        archive_board = get_board(repo, archive_ref)
+        assert archive_board is not None
+        tasks = list(getattr(archive_board, "tasks", []) or [])
+        if oid_hex not in tasks:
+            tasks.append(oid_hex)
+        archive_board.tasks = tasks
+        archive_board.update_message()
+        archive_board.write(repo)
+        return archive_ref
+
+    archive_board = Board(
+        Oid(hex=EMPTY_TREE_OID_HEX),
+        archive_ref,
+        tagger="",
+        description="Archived tasks",
+    )
+    archive_board.tasks = [oid_hex]
+    archive_board.update_message()
+    archive_board.write(repo)
+    return archive_ref
 
 
 class CreateTaskResult(TypedDict, total=True):
-	id: str
-	ref: str
-	oid: Oid
-	task: Task
+    id: str
+    ref: str
+    oid: Oid
+    task: Task
 
 
 def create_task(
-	repo: Repository,
-	board: Board,
-	title: str,
-	description: str = "",
-	status: Task.Status = Task.Status.TODO,
-	priority: Task.Priority = Task.Priority.LOW,
-	assignee: str = "",
-	due_date: datetime | None = None,
-	tags: str = "",
-	points: int | None = None,
+    repo: Repository,
+    board: Board,
+    title: str,
+    description: str = "",
+    status: Task.Status = Task.Status.TODO,
+    priority: Task.Priority = Task.Priority.LOW,
+    assignee: str = "",
+    due_date: datetime | None = None,
+    tags: str = "",
+    points: int | None = None,
 ) -> CreateTaskResult:
-	task_id = f"task_{int(time.time() * 1000)}"
-	target = get_repo_head_or_empty(repo)
-	ref = get_task_ref(task_id)
-	task = Task(
-		target=target,
-		name=ref,
-		tagger="",
-		title=title,
-		description=description,
-		status=status,
-		priority=priority,
-		assignee=assignee or None,
-		due_date=due_date,
-		tags=tags or "",
-		points=parse_points(points),
-	)
-	oid = task.write(repo)
-	board.tasks = getattr(board, "tasks", []) or []
-	board.tasks.append(str(oid))
-	board.update_message()
-	board.write(repo)
-	return {"id": task_id, "ref": ref, "oid": oid, "task": task}
+    task_id = f"task_{int(time.time() * 1000)}"
+    target = get_repo_head_or_empty(repo)
+    ref = get_task_ref(task_id)
+    task = Task(
+        target=target,
+        name=ref,
+        tagger="",
+        title=title,
+        description=description,
+        status=status,
+        priority=priority,
+        assignee=assignee or None,
+        due_date=due_date,
+        tags=tags or "",
+        points=parse_points(points),
+    )
+    oid = task.write(repo)
+    board.tasks = getattr(board, "tasks", []) or []
+    board.tasks.append(str(oid))
+    board.update_message()
+    board.write(repo)
+    return {"id": task_id, "ref": ref, "oid": oid, "task": task}
diff --git a/pygittools/src/pygittools/tasks_query.py b/pygittools/src/pygittools/tasks_query.py
index 44d9ed4..9e5aaeb 100644
--- a/pygittools/src/pygittools/tasks_query.py
+++ b/pygittools/src/pygittools/tasks_query.py
@@ -10,242 +10,242 @@ from typing import TypedDict
 from pygit2 import Oid, Repository
 
 from pygittools.tasks import (
-	BOARD_REF_PREFIX,
-	TASK_REF_PREFIX,
-	Comment,
-	Task,
-	get_board,
-	get_comment,
-	get_task,
-	get_task_by_oid,
+    BOARD_REF_PREFIX,
+    TASK_REF_PREFIX,
+    Comment,
+    Task,
+    get_board,
+    get_comment,
+    get_task,
+    get_task_by_oid,
 )
 
 BOARD_STATUS_ORDER: tuple[str, ...] = ("TODO", "IN_PROGRESS", "IN_REVIEW", "DONE", "CANCELLED")
 
 
 class CommentView(TypedDict):
-	content: str
-	author: str
-	created_at: str | None
-	edited_at: str | None
+    content: str
+    author: str
+    created_at: str | None
+    edited_at: str | None
 
 
 class TaskView(TypedDict, total=False):
-	ref: str
-	title: str
-	description: str
-	status: str | None
-	priority: str | None
-	assignee: str | None
-	due_date: str | None
-	created_at: str | None
-	updated_at: str | None
-	comments_count: int
-	comments: list[CommentView]
+    ref: str
+    title: str
+    description: str
+    status: str | None
+    priority: str | None
+    assignee: str | None
+    due_date: str | None
+    created_at: str | None
+    updated_at: str | None
+    comments_count: int
+    comments: list[CommentView]
 
 
 class BoardView(TypedDict):
-	name: str
-	description: str
-	task_count: int
+    name: str
+    description: str
+    task_count: int
 
 
 class BoardColumnView(TypedDict):
-	status: str
-	label: str
-	tasks: list[TaskView]
+    status: str
+    label: str
+    tasks: list[TaskView]
 
 
 @dataclass(frozen=True, slots=True)
 class RepoLocation:
-	"""Where to open a repository for task queries."""
+    """Where to open a repository for task queries."""
 
-	path: Path
-	project: str | None = None
+    path: Path
+    project: str | None = None
 
 
 def board_ref(name: str) -> str:
-	return f"{BOARD_REF_PREFIX}{name}"
+    return f"{BOARD_REF_PREFIX}{name}"
 
 
 def task_ref(task_id: str) -> str:
-	if task_id.startswith("refs/"):
-		return task_id
-	return f"{TASK_REF_PREFIX}{task_id}"
+    if task_id.startswith("refs/"):
+        return task_id
+    return f"{TASK_REF_PREFIX}{task_id}"
 
 
 def discover_repo_path(start: Path | None = None) -> Path:
-	"""Find a git repository by walking up from ``start`` (default: cwd)."""
-	current = (start or Path.cwd()).resolve()
-	for candidate in (current, *current.parents):
-		if (candidate / ".git").exists():
-			return candidate
-	raise FileNotFoundError(f"No git repository found from {current}")
+    """Find a git repository by walking up from ``start`` (default: cwd)."""
+    current = (start or Path.cwd()).resolve()
+    for candidate in (current, *current.parents):
+        if (candidate / ".git").exists():
+            return candidate
+    raise FileNotFoundError(f"No git repository found from {current}")
 
 
 def resolve_repo_location(
-	*,
-	repo_path: str | Path | None = None,
-	project: str | None = None,
+    *,
+    repo_path: str | Path | None = None,
+    project: str | None = None,
 ) -> RepoLocation:
-	"""Resolve a repository path from explicit args or environment."""
-	if repo_path is not None:
-		return RepoLocation(path=Path(repo_path).resolve(), project=project)
+    """Resolve a repository path from explicit args or environment."""
+    if repo_path is not None:
+        return RepoLocation(path=Path(repo_path).resolve(), project=project)
 
-	env_repo = os.environ.get("PYGITTOOLS_REPO", "").strip()
-	if env_repo and project is None:
-		return RepoLocation(path=Path(env_repo).resolve())
+    env_repo = os.environ.get("PYGITTOOLS_REPO", "").strip()
+    if env_repo and project is None:
+        return RepoLocation(path=Path(env_repo).resolve())
 
-	project_root = os.environ.get("PYGITWEB_PROJECTROOT", "").strip()
-	if project:
-		if not project_root:
-			raise ValueError("project requires PYGITWEB_PROJECTROOT")
-		return RepoLocation(path=(Path(project_root) / project).resolve(), project=project)
+    project_root = os.environ.get("PYGITWEB_PROJECTROOT", "").strip()
+    if project:
+        if not project_root:
+            raise ValueError("project requires PYGITWEB_PROJECTROOT")
+        return RepoLocation(path=(Path(project_root) / project).resolve(), project=project)
 
-	if env_repo:
-		return RepoLocation(path=Path(env_repo).resolve())
+    if env_repo:
+        return RepoLocation(path=Path(env_repo).resolve())
 
-	return RepoLocation(path=discover_repo_path())
+    return RepoLocation(path=discover_repo_path())
 
 
 def open_repository(location: RepoLocation | None = None) -> Repository:
-	loc = location or resolve_repo_location()
-	return Repository(str(loc.path))
+    loc = location or resolve_repo_location()
+    return Repository(str(loc.path))
 
 
 def _comment_to_view(comment: Comment) -> CommentView:
-	return {
-		"content": comment.content,
-		"author": comment.tagger,
-		"created_at": comment.created_at.isoformat() if comment.created_at else None,
-		"edited_at": comment.edited_at.isoformat() if comment.edited_at else None,
-	}
+    return {
+        "content": comment.content,
+        "author": comment.tagger,
+        "created_at": comment.created_at.isoformat() if comment.created_at else None,
+        "edited_at": comment.edited_at.isoformat() if comment.edited_at else None,
+    }
 
 
 def _load_task_comments(repo: Repository, task: Task) -> list[CommentView]:
-	comments: list[CommentView] = []
-	for oid_hex in getattr(task, "comments", []) or []:
-		try:
-			oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
-			comment = get_comment(repo, oid)
-		except (ValueError, KeyError, TypeError):
-			continue
-		comments.append(_comment_to_view(comment))
-	comments.sort(key=lambda entry: entry.get("edited_at") or entry.get("created_at") or "")
-	return comments
+    comments: list[CommentView] = []
+    for oid_hex in getattr(task, "comments", []) or []:
+        try:
+            oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+            comment = get_comment(repo, oid)
+        except (ValueError, KeyError, TypeError):
+            continue
+        comments.append(_comment_to_view(comment))
+    comments.sort(key=lambda entry: entry.get("edited_at") or entry.get("created_at") or "")
+    return comments
 
 
 def _task_to_view(task: Task, *, comments: list[CommentView] | None = None) -> TaskView:
-	view: TaskView = {
-		"ref": task.name,
-		"title": task.title,
-		"description": getattr(task, "description", "") or "",
-		"status": task.status.value if task.status else None,
-		"priority": task.priority.value if task.priority else None,
-		"assignee": task.assignee,
-		"due_date": task.due_date.isoformat() if task.due_date else None,
-		"created_at": task.created_at.isoformat() if task.created_at else None,
-		"updated_at": task.updated_at.isoformat() if task.updated_at else None,
-	}
-	if comments is None:
-		view["comments_count"] = len(getattr(task, "comments", []) or [])
-	else:
-		view["comments"] = comments
-	return view
+    view: TaskView = {
+        "ref": task.name,
+        "title": task.title,
+        "description": getattr(task, "description", "") or "",
+        "status": task.status.value if task.status else None,
+        "priority": task.priority.value if task.priority else None,
+        "assignee": task.assignee,
+        "due_date": task.due_date.isoformat() if task.due_date else None,
+        "created_at": task.created_at.isoformat() if task.created_at else None,
+        "updated_at": task.updated_at.isoformat() if task.updated_at else None,
+    }
+    if comments is None:
+        view["comments_count"] = len(getattr(task, "comments", []) or [])
+    else:
+        view["comments"] = comments
+    return view
 
 
 def resolve_task_from_board_entry(repo: Repository, oid_hex: str | Oid) -> Task | None:
-	"""Load a task listed on a board, following the ref when the board OID is stale."""
-	oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
-	task = get_task_by_oid(repo, oid)
-	if not task or not task.name:
-		return task
-	try:
-		if task.name not in repo.references:
-			return task
-		current_oid = repo.references[task.name].resolve().target
-	except KeyError:
-		return task
-	if str(current_oid) == str(oid):
-		return task
-	return get_task(repo, task.name) or task
+    """Load a task listed on a board, following the ref when the board OID is stale."""
+    oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+    task = get_task_by_oid(repo, oid)
+    if not task or not task.name:
+        return task
+    try:
+        if task.name not in repo.references:
+            return task
+        current_oid = repo.references[task.name].resolve().target
+    except KeyError:
+        return task
+    if str(current_oid) == str(oid):
+        return task
+    return get_task(repo, task.name) or task
 
 
 def list_boards(repo: Repository) -> list[BoardView]:
-	boards: list[BoardView] = []
-	for ref_name in repo.references:
-		if not ref_name.startswith(BOARD_REF_PREFIX):
-			continue
-		try:
-			board = get_board(repo, ref_name)
-		except (ValueError, KeyError):
-			continue
-		if board is None:
-			continue
-		boards.append({
-			"name": ref_name.removeprefix(BOARD_REF_PREFIX),
-			"description": getattr(board, "description", "") or "",
-			"task_count": len(getattr(board, "tasks", []) or []),
-		})
-	boards.sort(key=lambda entry: entry["name"].lower())
-	return boards
+    boards: list[BoardView] = []
+    for ref_name in repo.references:
+        if not ref_name.startswith(BOARD_REF_PREFIX):
+            continue
+        try:
+            board = get_board(repo, ref_name)
+        except (ValueError, KeyError):
+            continue
+        if board is None:
+            continue
+        boards.append({
+            "name": ref_name.removeprefix(BOARD_REF_PREFIX),
+            "description": getattr(board, "description", "") or "",
+            "task_count": len(getattr(board, "tasks", []) or []),
+        })
+    boards.sort(key=lambda entry: entry["name"].lower())
+    return boards
 
 
 def list_tasks(
-	repo: Repository,
-	board_name: str,
-	*,
-	include_comments: bool = False,
+    repo: Repository,
+    board_name: str,
+    *,
+    include_comments: bool = False,
 ) -> list[TaskView]:
-	ref = board_ref(board_name)
-	board = get_board(repo, ref)
-	if board is None:
-		raise KeyError(f"Board not found: {board_name}")
-
-	tasks: list[TaskView] = []
-	for oid_hex in getattr(board, "tasks", []) or []:
-		try:
-			task = resolve_task_from_board_entry(repo, oid_hex)
-		except (ValueError, KeyError, TypeError):
-			continue
-		if task is None:
-			continue
-		comments = _load_task_comments(repo, task) if include_comments else None
-		tasks.append(_task_to_view(task, comments=comments))
-	return tasks
+    ref = board_ref(board_name)
+    board = get_board(repo, ref)
+    if board is None:
+        raise KeyError(f"Board not found: {board_name}")
+
+    tasks: list[TaskView] = []
+    for oid_hex in getattr(board, "tasks", []) or []:
+        try:
+            task = resolve_task_from_board_entry(repo, oid_hex)
+        except (ValueError, KeyError, TypeError):
+            continue
+        if task is None:
+            continue
+        comments = _load_task_comments(repo, task) if include_comments else None
+        tasks.append(_task_to_view(task, comments=comments))
+    return tasks
 
 
 def get_task_view(
-	repo: Repository,
-	task_id: str,
-	*,
-	include_comments: bool = True,
+    repo: Repository,
+    task_id: str,
+    *,
+    include_comments: bool = True,
 ) -> TaskView:
-	ref = task_ref(task_id)
-	task = get_task(repo, ref)
-	if task is None:
-		raise KeyError(f"Task not found: {task_id}")
-	comments = _load_task_comments(repo, task) if include_comments else None
-	return _task_to_view(task, comments=comments)
+    ref = task_ref(task_id)
+    task = get_task(repo, ref)
+    if task is None:
+        raise KeyError(f"Task not found: {task_id}")
+    comments = _load_task_comments(repo, task) if include_comments else None
+    return _task_to_view(task, comments=comments)
 
 
 def get_board_tasks_grouped(
-	repo: Repository,
-	board_name: str,
-	*,
-	include_comments: bool = False,
+    repo: Repository,
+    board_name: str,
+    *,
+    include_comments: bool = False,
 ) -> list[BoardColumnView]:
-	by_status: dict[str, list[TaskView]] = {status: [] for status in BOARD_STATUS_ORDER}
-	for task_view in list_tasks(repo, board_name, include_comments=include_comments):
-		status_key = task_view.get("status") or "TODO"
-		if status_key not in by_status:
-			by_status[status_key] = []
-		by_status[status_key].append(task_view)
-	return [
-		{
-			"status": status,
-			"label": status.replace("_", " ").title(),
-			"tasks": by_status.get(status, []),
-		}
-		for status in BOARD_STATUS_ORDER
-	]
+    by_status: dict[str, list[TaskView]] = {status: [] for status in BOARD_STATUS_ORDER}
+    for task_view in list_tasks(repo, board_name, include_comments=include_comments):
+        status_key = task_view.get("status") or "TODO"
+        if status_key not in by_status:
+            by_status[status_key] = []
+        by_status[status_key].append(task_view)
+    return [
+        {
+            "status": status,
+            "label": status.replace("_", " ").title(),
+            "tasks": by_status.get(status, []),
+        }
+        for status in BOARD_STATUS_ORDER
+    ]
diff --git a/pygittools/src/pygittools/tasks_query_test.py b/pygittools/src/pygittools/tasks_query_test.py
index 0dc3b00..8e2a8a2 100644
--- a/pygittools/src/pygittools/tasks_query_test.py
+++ b/pygittools/src/pygittools/tasks_query_test.py
@@ -4,93 +4,93 @@ import pytest
 from pygit2 import Oid, Repository, init_repository
 
 from pygittools.tasks import (
-	BOARD_REF_PREFIX,
-	TASK_REF_PREFIX,
-	Board,
-	Comment,
-	Task,
+    BOARD_REF_PREFIX,
+    TASK_REF_PREFIX,
+    Board,
+    Comment,
+    Task,
 )
 from pygittools.tasks_query import (
-	get_board_tasks_grouped,
-	get_task_view,
-	list_boards,
-	list_tasks,
-	resolve_task_from_board_entry,
+    get_board_tasks_grouped,
+    get_task_view,
+    list_boards,
+    list_tasks,
+    resolve_task_from_board_entry,
 )
 
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-	return init_repository(str(repo_path), bare=False)
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+    return init_repository(str(repo_path), bare=False)
 
 
 def _empty_tree() -> Oid:
-	return Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
+    return Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
 
 
 def test_list_boards_and_tasks_with_comments(repo: Repository) -> None:
-	board_ref = f"{BOARD_REF_PREFIX}Tasks"
-	board = Board(_empty_tree(), board_ref, tagger="", description="Main")
-	task = Task(
-		_empty_tree(),
-		f"{TASK_REF_PREFIX}alpha",
-		"alice <alice@example.com>",
-		title="Alpha",
-		description="Do alpha",
-		status=Task.Status.IN_PROGRESS,
-	)
-	task_oid = task.write(repo)
-	comment = Comment(target=task_oid, tagger="alice <alice@example.com>", content="Looks good")
-	comment_oid = comment.write(repo)
-	task.comments = [str(comment_oid)]
-	task.update_message()
-	current_task_oid = task.write(repo)
-	board.tasks = [str(current_task_oid)]
-	board.update_message()
-	board.write(repo)
+    board_ref = f"{BOARD_REF_PREFIX}Tasks"
+    board = Board(_empty_tree(), board_ref, tagger="", description="Main")
+    task = Task(
+        _empty_tree(),
+        f"{TASK_REF_PREFIX}alpha",
+        "alice <alice@example.com>",
+        title="Alpha",
+        description="Do alpha",
+        status=Task.Status.IN_PROGRESS,
+    )
+    task_oid = task.write(repo)
+    comment = Comment(target=task_oid, tagger="alice <alice@example.com>", content="Looks good")
+    comment_oid = comment.write(repo)
+    task.comments = [str(comment_oid)]
+    task.update_message()
+    current_task_oid = task.write(repo)
+    board.tasks = [str(current_task_oid)]
+    board.update_message()
+    board.write(repo)
 
-	boards = list_boards(repo)
-	assert boards == [{"name": "Tasks", "description": "Main", "task_count": 1}]
+    boards = list_boards(repo)
+    assert boards == [{"name": "Tasks", "description": "Main", "task_count": 1}]
 
-	tasks = list_tasks(repo, "Tasks", include_comments=True)
-	assert len(tasks) == 1
-	assert tasks[0]["title"] == "Alpha"
-	assert tasks[0]["status"] == "IN_PROGRESS"
-	assert tasks[0]["comments"] == [
-		{
-			"content": "Looks good",
-			"author": "alice <alice@example.com>",
-			"created_at": tasks[0]["comments"][0]["created_at"],
-			"edited_at": tasks[0]["comments"][0]["edited_at"],
-		}
-	]
+    tasks = list_tasks(repo, "Tasks", include_comments=True)
+    assert len(tasks) == 1
+    assert tasks[0]["title"] == "Alpha"
+    assert tasks[0]["status"] == "IN_PROGRESS"
+    assert tasks[0]["comments"] == [
+        {
+            "content": "Looks good",
+            "author": "alice <alice@example.com>",
+            "created_at": tasks[0]["comments"][0]["created_at"],
+            "edited_at": tasks[0]["comments"][0]["edited_at"],
+        }
+    ]
 
-	detail = get_task_view(repo, "alpha")
-	assert detail["comments"][0]["content"] == "Looks good"
+    detail = get_task_view(repo, "alpha")
+    assert detail["comments"][0]["content"] == "Looks good"
 
-	columns = get_board_tasks_grouped(repo, "Tasks")
-	assert columns[1]["status"] == "IN_PROGRESS"
-	assert columns[1]["tasks"][0]["title"] == "Alpha"
+    columns = get_board_tasks_grouped(repo, "Tasks")
+    assert columns[1]["status"] == "IN_PROGRESS"
+    assert columns[1]["tasks"][0]["title"] == "Alpha"
 
 
 def test_resolve_task_from_board_entry_follows_current_ref(repo: Repository) -> None:
-	task = Task(
-		_empty_tree(),
-		f"{TASK_REF_PREFIX}stale",
-		"alice <alice@example.com>",
-		title="Stale board entry",
-		status=Task.Status.TODO,
-	)
-	stale_oid = task.write(repo)
-	task.title = "Current title"
-	task.status = Task.Status.DONE
-	task.update_message()
-	current_oid = task.write(repo)
+    task = Task(
+        _empty_tree(),
+        f"{TASK_REF_PREFIX}stale",
+        "alice <alice@example.com>",
+        title="Stale board entry",
+        status=Task.Status.TODO,
+    )
+    stale_oid = task.write(repo)
+    task.title = "Current title"
+    task.status = Task.Status.DONE
+    task.update_message()
+    current_oid = task.write(repo)
 
-	loaded = resolve_task_from_board_entry(repo, stale_oid)
-	assert loaded is not None
-	assert loaded.title == "Current title"
-	assert loaded.status == Task.Status.DONE
-	assert str(current_oid) != str(stale_oid)
+    loaded = resolve_task_from_board_entry(repo, stale_oid)
+    assert loaded is not None
+    assert loaded.title == "Current title"
+    assert loaded.status == Task.Status.DONE
+    assert str(current_oid) != str(stale_oid)
diff --git a/pygittools/src/pygittools/tasks_test.py b/pygittools/src/pygittools/tasks_test.py
index ceea5fd..72c02d3 100644
--- a/pygittools/src/pygittools/tasks_test.py
+++ b/pygittools/src/pygittools/tasks_test.py
@@ -6,19 +6,19 @@ import pytest
 from pygit2 import Oid, Repository, init_repository
 
 from pygittools.tasks import (
-	ARCHIVE_BOARD_NAME,
-	BOARD_REF_PREFIX,
-	EMPTY_TREE_OID_HEX,
-	TASK_REF_PREFIX,
-	Board,
-	Comment,
-	Task,
-	archive_task_local,
-	delete_task_local,
-	get_board,
-	get_comment,
-	get_task,
-	get_task_by_oid,
+    ARCHIVE_BOARD_NAME,
+    BOARD_REF_PREFIX,
+    EMPTY_TREE_OID_HEX,
+    TASK_REF_PREFIX,
+    Board,
+    Comment,
+    Task,
+    archive_task_local,
+    delete_task_local,
+    get_board,
+    get_comment,
+    get_task,
+    get_task_by_oid,
 )
 
 TAGGER: str = "alice <alice@example.com>"
@@ -27,390 +27,390 @@ BOARD_MAIN: str = f"{BOARD_REF_PREFIX}main"
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-
-	repo = init_repository(str(repo_path), bare=False)
-
-	# Set up initial commit on main to satisfy repo_head_or_empty()
-	import subprocess
-
-	os_repo_path = str(repo_path)
-	subprocess.run(
-		["git", "config", "user.email", "test@test.com"],
-		cwd=os_repo_path,
-		check=True,
-	)
-	subprocess.run(
-		["git", "config", "user.name", "Test"],
-		cwd=os_repo_path,
-		check=True,
-	)
-	with open(os.path.join(os_repo_path, "README.md"), "w") as f:
-		f.write("# initial\n")
-	subprocess.run(["git", "add", "README.md"], cwd=os_repo_path, check=True)
-	subprocess.run(
-		["git", "commit", "-m", "initial"],
-		cwd=os_repo_path,
-		check=True,
-		capture_output=True,
-		text=True,
-	)
-
-	return repo
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+
+    repo = init_repository(str(repo_path), bare=False)
+
+    # Set up initial commit on main to satisfy repo_head_or_empty()
+    import subprocess
+
+    os_repo_path = str(repo_path)
+    subprocess.run(
+        ["git", "config", "user.email", "test@test.com"],
+        cwd=os_repo_path,
+        check=True,
+    )
+    subprocess.run(
+        ["git", "config", "user.name", "Test"],
+        cwd=os_repo_path,
+        check=True,
+    )
+    with open(os.path.join(os_repo_path, "README.md"), "w") as f:
+        f.write("# initial\n")
+    subprocess.run(["git", "add", "README.md"], cwd=os_repo_path, check=True)
+    subprocess.run(
+        ["git", "commit", "-m", "initial"],
+        cwd=os_repo_path,
+        check=True,
+        capture_output=True,
+        text=True,
+    )
+
+    return repo
 
 
 def _empty_tree() -> Oid:
-	return Oid(hex=EMPTY_TREE_OID_HEX)
+    return Oid(hex=EMPTY_TREE_OID_HEX)
 
 
 def _task_ref(slug: str) -> str:
-	return f"{TASK_REF_PREFIX}{slug}"
+    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,
-	)
+    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)
+    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 == []
+    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
+    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)
+    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)
+    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
+    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=str(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=str(c))).content for c in loaded_a.comments] == ["A1", "A2"]
-
-	loaded_b = get_task_by_oid(repo, Oid(hex=str(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=str(c))).content for c in loaded_b.comments] == ["B1"]
+    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=str(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=str(c))).content for c in loaded_a.comments] == ["A1", "A2"]
+
+    loaded_b = get_task_by_oid(repo, Oid(hex=str(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=str(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")
+    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)
+    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)
+    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)
+    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)
+    board.tasks = [str(task_oid)]
+    board.update_message()
+    board.write(repo)
 
-	_run_git_gc(repo)
+    _run_git_gc(repo)
 
-	# Re-open repo to avoid any in-memory odb caches from the pre-gc instance.
-	reopened = Repository(repo.path)
+    # 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_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=str(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
+    loaded_task = get_task_by_oid(reopened, Oid(hex=str(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=str(c))).content for c in loaded_task.comments]
-	assert contents == ["first", "second"]
+    contents = [get_comment(reopened, Oid(hex=str(c))).content for c in loaded_task.comments]
+    assert contents == ["first", "second"]
 
 
 def test_delete_task_local_removes_board_and_comment_refs(repo: Repository) -> None:
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
 
-	task = Task(
-		_empty_tree(),
-		_task_ref("to-delete"),
-		TAGGER,
-		title="To Delete",
-		description="remove me",
-		status=Task.Status.TODO,
-	)
-	task_oid = task.write(repo)
+    task = Task(
+        _empty_tree(),
+        _task_ref("to-delete"),
+        TAGGER,
+        title="To Delete",
+        description="remove me",
+        status=Task.Status.TODO,
+    )
+    task_oid = task.write(repo)
 
-	comment = Comment(task_oid, "bob <bob@example.com>", content="bye")
-	comment_oid = comment.write(repo)
+    comment = Comment(task_oid, "bob <bob@example.com>", content="bye")
+    comment_oid = comment.write(repo)
 
-	task.comments = [str(comment_oid)]
-	task.update_message()
-	task_oid = task.write(repo)
+    task.comments = [str(comment_oid)]
+    task.update_message()
+    task_oid = task.write(repo)
 
-	board.tasks = [str(task_oid)]
-	board.update_message()
-	board.write(repo)
+    board.tasks = [str(task_oid)]
+    board.update_message()
+    board.write(repo)
 
-	comment_ref = get_comment(repo, comment_oid).name
+    comment_ref = get_comment(repo, comment_oid).name
 
-	delete_task_local(repo, _task_ref("to-delete"))
+    delete_task_local(repo, _task_ref("to-delete"))
 
-	assert _task_ref("to-delete") not in repo.references
-	assert comment_ref not in repo.references
+    assert _task_ref("to-delete") not in repo.references
+    assert comment_ref not in repo.references
 
-	loaded_board = get_board(repo, BOARD_MAIN)
-	assert loaded_board is not None
-	assert loaded_board.tasks == []
+    loaded_board = get_board(repo, BOARD_MAIN)
+    assert loaded_board is not None
+    assert loaded_board.tasks == []
 
 
 def test_archive_task_local_moves_to_archive_board(repo: Repository) -> None:
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
-	task = Task(
-		_empty_tree(),
-		_task_ref("to-archive"),
-		TAGGER,
-		title="To Archive",
-		description="move me",
-		status=Task.Status.DONE,
-	)
-	task_oid = task.write(repo)
-	board.tasks = [str(task_oid)]
-	board.update_message()
-	board.write(repo)
-
-	archive_ref = archive_task_local(repo, _task_ref("to-archive"), "main")
-
-	assert _task_ref("to-archive") in repo.references
-	main_board = get_board(repo, BOARD_MAIN)
-	assert main_board is not None
-	assert main_board.tasks == []
-	archive_board = get_board(repo, archive_ref)
-	assert archive_board is not None
-	assert archive_board.tasks == [str(task_oid)]
-	assert archive_ref == f"{BOARD_REF_PREFIX}{ARCHIVE_BOARD_NAME}"
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
+    task = Task(
+        _empty_tree(),
+        _task_ref("to-archive"),
+        TAGGER,
+        title="To Archive",
+        description="move me",
+        status=Task.Status.DONE,
+    )
+    task_oid = task.write(repo)
+    board.tasks = [str(task_oid)]
+    board.update_message()
+    board.write(repo)
+
+    archive_ref = archive_task_local(repo, _task_ref("to-archive"), "main")
+
+    assert _task_ref("to-archive") in repo.references
+    main_board = get_board(repo, BOARD_MAIN)
+    assert main_board is not None
+    assert main_board.tasks == []
+    archive_board = get_board(repo, archive_ref)
+    assert archive_board is not None
+    assert archive_board.tasks == [str(task_oid)]
+    assert archive_ref == f"{BOARD_REF_PREFIX}{ARCHIVE_BOARD_NAME}"
 
 
 def test_create_task_returns_tuple(repo: Repository) -> None:
-	"""Shared create_task returns CreateTaskResult with task_id and oid fields"""
-	from pygittools.tasks import create_task
+    """Shared create_task returns CreateTaskResult with task_id and oid fields"""
+    from pygittools.tasks import create_task
 
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Test board")
-	result = create_task(repo, board, "Test Title")
-	oide_hex = str(result["oid"])
-	assert isinstance(result["id"], str)
-	assert isinstance(oide_hex, str)
-	assert len(oide_hex) == 40  # valid git SHA
-	assert result["id"].startswith("task_")
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Test board")
+    result = create_task(repo, board, "Test Title")
+    oide_hex = str(result["oid"])
+    assert isinstance(result["id"], str)
+    assert isinstance(oide_hex, str)
+    assert len(oide_hex) == 40  # valid git SHA
+    assert result["id"].startswith("task_")
 
 
 def test_task_tag_persistence(repo: Repository) -> None:
-	"""Task tag created at refs/tags/tasks/{task_id} in repo ODB"""
-	from pygittools.tasks import create_task
+    """Task tag created at refs/tags/tasks/{task_id} in repo ODB"""
+    from pygittools.tasks import create_task
 
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Tag test board")
-	result = create_task(repo, board, "Tag Test")
-	task_id = result["id"]
-	ref = f"refs/tags/tasks/{task_id}"
-	assert ref in repo.references
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Tag test board")
+    result = create_task(repo, board, "Tag Test")
+    task_id = result["id"]
+    ref = f"refs/tags/tasks/{task_id}"
+    assert ref in repo.references
 
 
 def test_board_updated(repo: Repository) -> None:
-	"""Board task list gains new OID after create_task"""
-	from pygittools.tasks import create_task
+    """Board task list gains new OID after create_task"""
+    from pygittools.tasks import create_task
 
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Board test")
-	original_len = len(board.tasks)
-	result = create_task(repo, board, "Board Test")
-	assert len(board.tasks) == original_len + 1
-	assert result["oid"] in board.tasks
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Board test")
+    original_len = len(board.tasks)
+    result = create_task(repo, board, "Board Test")
+    assert len(board.tasks) == original_len + 1
+    assert result["oid"] in board.tasks
 
 
 def test_default_parameters(repo: Repository) -> None:
-	"""Default params: TODO status, LOW priority"""
-	from pygittools.tasks import Task, create_task
+    """Default params: TODO status, LOW priority"""
+    from pygittools.tasks import Task, create_task
 
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Defaults board")
-	result = create_task(repo, board, "Defaults")
-	task = result["task"]
-	assert task.status == Task.Status.TODO
-	assert task.priority == Task.Priority.LOW
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Defaults board")
+    result = create_task(repo, board, "Defaults")
+    task = result["task"]
+    assert task.status == Task.Status.TODO
+    assert task.priority == Task.Priority.LOW
 
 
 def test_custom_parameters(repo: Repository) -> None:
-	"""Custom status, priority, assignee, due_date, tags, points"""
-	from datetime import datetime
-
-	from pygittools.tasks import Task, create_task
-
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Custom board")
-	result = create_task(
-		repo,
-		board,
-		"Custom",
-		status=Task.Status.IN_PROGRESS,
-		priority=Task.Priority.HIGH,
-		assignee="alice",
-		due_date=datetime(2024, 1, 15),
-		tags="urgent",
-		points=5,
-	)
-	task = result["task"]
-	assert task.assignee == "alice"
-	assert task.due_date == datetime(2024, 1, 15)
-	assert task.tags == "urgent"
-	assert task.points == 5
+    """Custom status, priority, assignee, due_date, tags, points"""
+    from datetime import datetime
+
+    from pygittools.tasks import Task, create_task
+
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Custom board")
+    result = create_task(
+        repo,
+        board,
+        "Custom",
+        status=Task.Status.IN_PROGRESS,
+        priority=Task.Priority.HIGH,
+        assignee="alice",
+        due_date=datetime(2024, 1, 15),
+        tags="urgent",
+        points=5,
+    )
+    task = result["task"]
+    assert task.assignee == "alice"
+    assert task.due_date == datetime(2024, 1, 15)
+    assert task.tags == "urgent"
+    assert task.points == 5
 
 
 def test_task_id_uniqueness(repo: Repository) -> None:
-	"""Running create_task twice creates distinct tasks (OIDs always unique)"""
-	from pygittools.tasks import create_task
+    """Running create_task twice creates distinct tasks (OIDs always unique)"""
+    from pygittools.tasks import create_task
 
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Uniqueness board")
-	result1 = create_task(repo, board, "Task 1")
-	result2 = create_task(repo, board, "Task 2")
-	# OIDs are always unique since they're derived from Task.write()
-	assert result1["oid"] != result2["oid"]
-	# Both should persist on board
-	assert str(result1["oid"]) in board.tasks
-	assert str(result2["oid"]) in board.tasks
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Uniqueness board")
+    result1 = create_task(repo, board, "Task 1")
+    result2 = create_task(repo, board, "Task 2")
+    # OIDs are always unique since they're derived from Task.write()
+    assert result1["oid"] != result2["oid"]
+    # Both should persist on board
+    assert str(result1["oid"]) in board.tasks
+    assert str(result2["oid"]) in board.tasks
 
 
 def test_board_message_updated(repo: Repository) -> None:
-	"""Board update_message and write are called as side effects"""
-	from pygittools.tasks import create_task
+    """Board update_message and write are called as side effects"""
+    from pygittools.tasks import create_task
 
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Message test board")
-	result = create_task(repo, board, "Message Test")
-	# Verify the new OID appears on the board
-	assert result["oid"] in board.tasks
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Message test board")
+    result = create_task(repo, board, "Message Test")
+    # Verify the new OID appears on the board
+    assert result["oid"] in board.tasks
 
 
 def test_archive_task_local_creates_archive_board_when_missing(repo: Repository) -> None:
-	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
-	task = Task(
-		_empty_tree(),
-		_task_ref("solo"),
-		TAGGER,
-		title="Solo",
-		status=Task.Status.TODO,
-	)
-	task_oid = task.write(repo)
-	board.tasks = [str(task_oid)]
-	board.update_message()
-	board.write(repo)
-
-	archive_ref = archive_task_local(repo, _task_ref("solo"), "main")
-
-	assert archive_ref in repo.references
-	archive_board = get_board(repo, archive_ref)
-	assert archive_board is not None
-	assert getattr(archive_board, "description", "") == "Archived tasks"
-	assert archive_board.tasks == [str(task_oid)]
+    board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
+    task = Task(
+        _empty_tree(),
+        _task_ref("solo"),
+        TAGGER,
+        title="Solo",
+        status=Task.Status.TODO,
+    )
+    task_oid = task.write(repo)
+    board.tasks = [str(task_oid)]
+    board.update_message()
+    board.write(repo)
+
+    archive_ref = archive_task_local(repo, _task_ref("solo"), "main")
+
+    assert archive_ref in repo.references
+    archive_board = get_board(repo, archive_ref)
+    assert archive_board is not None
+    assert getattr(archive_board, "description", "") == "Archived tasks"
+    assert archive_board.tasks == [str(task_oid)]
diff --git a/pygittools/src/pygittools/tui/app.py b/pygittools/src/pygittools/tui/app.py
index 66f1777..a7407e8 100644
--- a/pygittools/src/pygittools/tui/app.py
+++ b/pygittools/src/pygittools/tui/app.py
@@ -17,104 +17,104 @@ from pygittools.tui.types import Page, PageAction, PageContext, PageResult
 
 
 def run_tui(repo_path: Path | None = None) -> int:
-	start = (repo_path or Path.cwd()).resolve()
-	ctx = try_open_page_context(start)
-	initial: Page = home_page() if ctx is not None else NoRepoPage(start)
-	stack: list[Page] = [initial]
-	set_vim_enabled(False)
-
-	with Terminal() as term:
-		term.hide_cursor()
-		try:
-			init_tui_color_theme(ctx.repo if ctx is not None else None)
-			init_tab_colors()
-
-			if ctx is not None:
-				stack[0].on_enter(ctx)
-
-			while stack:
-				page = stack[-1]
-
-				def ui(frame: Frame, _page: Page = page) -> None:
-					content, status = split_content_status(frame.area)
-					_page.draw(frame, content)
-					render_status_bar(frame, status, _page.status_text(), status_bar_style())
-
-				term.draw(ui)
-
-				try:
-					ev = term.poll_event(timeout_ms=100)
-				except KeyboardInterrupt:
-					break
-				if ev is None:
-					on_idle = getattr(page, "on_idle", None)
-					if callable(on_idle):
-						on_idle()
-					continue
-
-				result = page.handle_key(ev)
-				ctx, stack = _apply_result(stack, ctx, result)
-				if result.action == PageAction.QUIT:
-					break
-		except KeyboardInterrupt:
-			pass
-		finally:
-			_persist_commit_draft(stack, ctx)
-
-	return 0
+    start = (repo_path or Path.cwd()).resolve()
+    ctx = try_open_page_context(start)
+    initial: Page = home_page() if ctx is not None else NoRepoPage(start)
+    stack: list[Page] = [initial]
+    set_vim_enabled(False)
+
+    with Terminal() as term:
+        term.hide_cursor()
+        try:
+            init_tui_color_theme(ctx.repo if ctx is not None else None)
+            init_tab_colors()
+
+            if ctx is not None:
+                stack[0].on_enter(ctx)
+
+            while stack:
+                page = stack[-1]
+
+                def ui(frame: Frame, _page: Page = page) -> None:
+                    content, status = split_content_status(frame.area)
+                    _page.draw(frame, content)
+                    render_status_bar(frame, status, _page.status_text(), status_bar_style())
+
+                term.draw(ui)
+
+                try:
+                    ev = term.poll_event(timeout_ms=100)
+                except KeyboardInterrupt:
+                    break
+                if ev is None:
+                    on_idle = getattr(page, "on_idle", None)
+                    if callable(on_idle):
+                        on_idle()
+                    continue
+
+                result = page.handle_key(ev)
+                ctx, stack = _apply_result(stack, ctx, result)
+                if result.action == PageAction.QUIT:
+                    break
+        except KeyboardInterrupt:
+            pass
+        finally:
+            _persist_commit_draft(stack, ctx)
+
+    return 0
 
 
 def _persist_commit_draft(stack: list[Page], ctx: PageContext | None) -> None:
-	if ctx is None:
-		return
-	for page in reversed(stack):
-		if isinstance(page, HomePage):
-			page.changes.persist_commit_draft(ctx.repo)
-			return
+    if ctx is None:
+        return
+    for page in reversed(stack):
+        if isinstance(page, HomePage):
+            page.changes.persist_commit_draft(ctx.repo)
+            return
 
 
 def _apply_result(
-	stack: list[Page],
-	ctx: PageContext | None,
-	result: PageResult,
+    stack: list[Page],
+    ctx: PageContext | None,
+    result: PageResult,
 ) -> tuple[PageContext | None, list[Page]]:
-	if result.switch_repo is not None:
-		_persist_commit_draft(stack, ctx)
-		new_ctx = try_open_page_context(result.switch_repo)
-		if new_ctx is not None:
-			ctx = new_ctx
-			init_tui_color_theme(ctx.repo)
-			init_tab_colors()
-
-	match result.action:
-		case PageAction.REPLACE:
-			if result.next_page is None:
-				return ctx, stack
-			if ctx is not None:
-				result.next_page.on_enter(ctx)
-			return ctx, [result.next_page]
-		case PageAction.PUSH:
-			if result.next_page is None:
-				return ctx, stack
-			if ctx is not None:
-				result.next_page.on_enter(ctx)
-			return ctx, [*stack, result.next_page]
-		case PageAction.POP:
-			if len(stack) <= 1:
-				return ctx, stack
-			stack = stack[:-1]
-		case _:
-			pass
-
-	if result.switch_repo is not None and ctx is not None and stack:
-		stack[-1].on_enter(ctx)
-	elif (
-		result.repo_changed
-		and result.action == PageAction.POP
-		and ctx is not None
-		and stack
-		and isinstance(stack[-1], HomePage)
-	):
-		stack[-1].refresh()
-
-	return ctx, stack
+    if result.switch_repo is not None:
+        _persist_commit_draft(stack, ctx)
+        new_ctx = try_open_page_context(result.switch_repo)
+        if new_ctx is not None:
+            ctx = new_ctx
+            init_tui_color_theme(ctx.repo)
+            init_tab_colors()
+
+    match result.action:
+        case PageAction.REPLACE:
+            if result.next_page is None:
+                return ctx, stack
+            if ctx is not None:
+                result.next_page.on_enter(ctx)
+            return ctx, [result.next_page]
+        case PageAction.PUSH:
+            if result.next_page is None:
+                return ctx, stack
+            if ctx is not None:
+                result.next_page.on_enter(ctx)
+            return ctx, [*stack, result.next_page]
+        case PageAction.POP:
+            if len(stack) <= 1:
+                return ctx, stack
+            stack = stack[:-1]
+        case _:
+            pass
+
+    if result.switch_repo is not None and ctx is not None and stack:
+        stack[-1].on_enter(ctx)
+    elif (
+        result.repo_changed
+        and result.action == PageAction.POP
+        and ctx is not None
+        and stack
+        and isinstance(stack[-1], HomePage)
+    ):
+        stack[-1].refresh()
+
+    return ctx, stack
diff --git a/pygittools/src/pygittools/tui/app_test.py b/pygittools/src/pygittools/tui/app_test.py
index 67a0204..32b5762 100644
--- a/pygittools/src/pygittools/tui/app_test.py
+++ b/pygittools/src/pygittools/tui/app_test.py
@@ -14,143 +14,143 @@ from pygittools.tui.types import PageAction, PageContext, PageResult
 
 
 def test_persist_commit_draft_skips_without_context() -> None:
-	_persist_commit_draft([], None)
+    _persist_commit_draft([], None)
 
 
 def test_persist_commit_draft_from_home_page(page_ctx: PageContext) -> None:
-	page = home_page()
-	page.on_enter(page_ctx)
-	page.changes.set_commit_message("draft")
-	_persist_commit_draft([page], page_ctx)
-	from pygittools.tui.commit_draft import load_commit_draft
+    page = home_page()
+    page.on_enter(page_ctx)
+    page.changes.set_commit_message("draft")
+    _persist_commit_draft([page], page_ctx)
+    from pygittools.tui.commit_draft import load_commit_draft
 
-	assert load_commit_draft(page_ctx.repo_path, page_ctx.repo) == "draft"
+    assert load_commit_draft(page_ctx.repo_path, page_ctx.repo) == "draft"
 
 
 def test_apply_result_replace(page_ctx: PageContext) -> None:
-	next_page = home_page()
-	stack = [NoRepoPage(Path("/tmp"))]
-	ctx, new_stack = _apply_result(
-		stack,
-		page_ctx,
-		PageResult(action=PageAction.REPLACE, next_page=next_page),
-	)
-	assert ctx is page_ctx
-	assert new_stack == [next_page]
+    next_page = home_page()
+    stack = [NoRepoPage(Path("/tmp"))]
+    ctx, new_stack = _apply_result(
+        stack,
+        page_ctx,
+        PageResult(action=PageAction.REPLACE, next_page=next_page),
+    )
+    assert ctx is page_ctx
+    assert new_stack == [next_page]
 
 
 def test_apply_result_replace_without_page(page_ctx: PageContext) -> None:
-	stack = [home_page()]
-	ctx, new_stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.REPLACE))
-	assert new_stack is stack
+    stack = [home_page()]
+    ctx, new_stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.REPLACE))
+    assert new_stack is stack
 
 
 def test_apply_result_push_and_pop(page_ctx: PageContext) -> None:
-	home = home_page()
-	home.on_enter(page_ctx)
-	stack = [home]
-	overlay = NoRepoPage(Path("/tmp"))
-	ctx, stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.PUSH, next_page=overlay))
-	assert len(stack) == 2
-	ctx, stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.POP))
-	assert len(stack) == 1
+    home = home_page()
+    home.on_enter(page_ctx)
+    stack = [home]
+    overlay = NoRepoPage(Path("/tmp"))
+    ctx, stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.PUSH, next_page=overlay))
+    assert len(stack) == 2
+    ctx, stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.POP))
+    assert len(stack) == 1
 
 
 def test_apply_result_pop_repo_changed_refreshes_home(page_ctx: PageContext) -> None:
-	home = home_page()
-	home.on_enter(page_ctx)
-	overlay = NoRepoPage(Path("/tmp"))
-	with patch.object(home, "refresh") as refresh:
-		ctx, stack = _apply_result(
-			[home, overlay],
-			page_ctx,
-			PageResult(action=PageAction.POP, repo_changed=True),
-		)
-	assert ctx is page_ctx
-	assert stack == [home]
-	refresh.assert_called_once_with()
+    home = home_page()
+    home.on_enter(page_ctx)
+    overlay = NoRepoPage(Path("/tmp"))
+    with patch.object(home, "refresh") as refresh:
+        ctx, stack = _apply_result(
+            [home, overlay],
+            page_ctx,
+            PageResult(action=PageAction.POP, repo_changed=True),
+        )
+    assert ctx is page_ctx
+    assert stack == [home]
+    refresh.assert_called_once_with()
 
 
 def test_apply_result_pop_without_repo_change_skips_refresh(page_ctx: PageContext) -> None:
-	home = home_page()
-	home.on_enter(page_ctx)
-	overlay = NoRepoPage(Path("/tmp"))
-	with patch.object(home, "refresh") as refresh:
-		_apply_result([home, overlay], page_ctx, PageResult(action=PageAction.POP))
-	refresh.assert_not_called()
+    home = home_page()
+    home.on_enter(page_ctx)
+    overlay = NoRepoPage(Path("/tmp"))
+    with patch.object(home, "refresh") as refresh:
+        _apply_result([home, overlay], page_ctx, PageResult(action=PageAction.POP))
+    refresh.assert_not_called()
 
 
 def test_apply_result_pop_keeps_root(page_ctx: PageContext) -> None:
-	home = home_page()
-	ctx, stack = _apply_result([home], page_ctx, PageResult(action=PageAction.POP))
-	assert stack == [home]
+    home = home_page()
+    ctx, stack = _apply_result([home], page_ctx, PageResult(action=PageAction.POP))
+    assert stack == [home]
 
 
 def test_apply_result_switch_repo(page_ctx: PageContext, tmp_path: Path) -> None:
-	repo_dir = tmp_path / "other"
-	repo_dir.mkdir()
-	init_repository(str(repo_dir), bare=False)
-	home = home_page()
-	home.on_enter(page_ctx)
-	ctx, stack = _apply_result(
-		[home],
-		page_ctx,
-		PageResult(action=PageAction.PUSH, switch_repo=repo_dir),
-	)
-	assert ctx is not None
-	assert stack[-1] is home
+    repo_dir = tmp_path / "other"
+    repo_dir.mkdir()
+    init_repository(str(repo_dir), bare=False)
+    home = home_page()
+    home.on_enter(page_ctx)
+    ctx, stack = _apply_result(
+        [home],
+        page_ctx,
+        PageResult(action=PageAction.PUSH, switch_repo=repo_dir),
+    )
+    assert ctx is not None
+    assert stack[-1] is home
 
 
 def test_apply_result_switch_repo_reenters_page(page_ctx: PageContext, tmp_path: Path) -> None:
-	repo_dir = tmp_path / "other"
-	repo_dir.mkdir()
-	init_repository(str(repo_dir), bare=False)
-	home = home_page()
-	home.on_enter(page_ctx)
-	ctx, stack = _apply_result(
-		[home],
-		page_ctx,
-		PageResult(action=PageAction.NONE, switch_repo=repo_dir),
-	)
-	assert ctx is not None
-	assert stack[-1] is home
+    repo_dir = tmp_path / "other"
+    repo_dir.mkdir()
+    init_repository(str(repo_dir), bare=False)
+    home = home_page()
+    home.on_enter(page_ctx)
+    ctx, stack = _apply_result(
+        [home],
+        page_ctx,
+        PageResult(action=PageAction.NONE, switch_repo=repo_dir),
+    )
+    assert ctx is not None
+    assert stack[-1] is home
 
 
 class _FakeTerm:
-	def __init__(self, keys: list[object]) -> None:
-		self._keys = list(keys)
-		self.hide_cursor = MagicMock()
+    def __init__(self, keys: list[object]) -> None:
+        self._keys = list(keys)
+        self.hide_cursor = MagicMock()
 
-	def __enter__(self) -> _FakeTerm:
-		return self
+    def __enter__(self) -> _FakeTerm:
+        return self
 
-	def __exit__(self, *args: object) -> bool:
-		return False
+    def __exit__(self, *args: object) -> bool:
+        return False
 
-	def draw(self, draw_fn: object) -> None:
-		frame = MagicMock()
-		frame.area = Rect(0, 0, 80, 24)
-		draw_fn(frame)  # type: ignore[operator]
+    def draw(self, draw_fn: object) -> None:
+        frame = MagicMock()
+        frame.area = Rect(0, 0, 80, 24)
+        draw_fn(frame)  # type: ignore[operator]
 
-	def poll_event(self, timeout_ms: int = 0) -> object | None:
-		del timeout_ms
-		if not self._keys:
-			return key("q")
-		return self._keys.pop(0)
+    def poll_event(self, timeout_ms: int = 0) -> object | None:
+        del timeout_ms
+        if not self._keys:
+            return key("q")
+        return self._keys.pop(0)
 
 
 def _run_tui_once(repo_dir: Path, keys: list[object]) -> int:
-	with (
-		patch("pygittools.tui.app.Terminal", return_value=_FakeTerm(keys)),
-		patch("pygittools.tui.app.init_tui_color_theme"),
-		patch("pygittools.tui.app.init_tab_colors"),
-	):
-		return run_tui(repo_dir)
+    with (
+        patch("pygittools.tui.app.Terminal", return_value=_FakeTerm(keys)),
+        patch("pygittools.tui.app.init_tui_color_theme"),
+        patch("pygittools.tui.app.init_tab_colors"),
+    ):
+        return run_tui(repo_dir)
 
 
 def test_run_tui_quits_on_q(committed_repo: Repository) -> None:
-	assert _run_tui_once(Path(committed_repo.workdir), [key("q")]) == 0
+    assert _run_tui_once(Path(committed_repo.workdir), [key("q")]) == 0
 
 
 def test_run_tui_no_repo(tmp_path: Path) -> None:
-	assert _run_tui_once(tmp_path, [key("q")]) == 0
+    assert _run_tui_once(tmp_path, [key("q")]) == 0
diff --git a/pygittools/src/pygittools/tui/branches.py b/pygittools/src/pygittools/tui/branches.py
index 2ec9b20..d2247c7 100644
--- a/pygittools/src/pygittools/tui/branches.py
+++ b/pygittools/src/pygittools/tui/branches.py
@@ -6,56 +6,56 @@ from pygit2 import Commit, GitError, Repository, reference_is_valid_name
 
 
 def current_branch_name(repo: Repository) -> str:
-	if repo.head_is_detached:
-		return "(detached)"
-	try:
-		name = repo.head.shorthand
-	except GitError:
-		name = _unborn_branch_name(repo)
-	return name or "(detached)"
+    if repo.head_is_detached:
+        return "(detached)"
+    try:
+        name = repo.head.shorthand
+    except GitError:
+        name = _unborn_branch_name(repo)
+    return name or "(detached)"
 
 
 def _unborn_branch_name(repo: Repository) -> str:
-	try:
-		target = repo.lookup_reference("HEAD").target
-	except GitError:
-		return "(detached)"
-	prefix = "refs/heads/"
-	if isinstance(target, str) and target.startswith(prefix):
-		return target.removeprefix(prefix)
-	return "(detached)"
+    try:
+        target = repo.lookup_reference("HEAD").target
+    except GitError:
+        return "(detached)"
+    prefix = "refs/heads/"
+    if isinstance(target, str) and target.startswith(prefix):
+        return target.removeprefix(prefix)
+    return "(detached)"
 
 
 def list_local_branches(repo: Repository) -> list[str]:
-	return sorted(repo.branches.local)
+    return sorted(repo.branches.local)
 
 
 def checkout_branch(repo: Repository, branch: str) -> tuple[bool, str]:
-	try:
-		repo.checkout(f"refs/heads/{branch}")
-	except (GitError, KeyError) as exc:
-		message = str(exc).strip()
-		return False, message or "Checkout failed"
-	return True, ""
+    try:
+        repo.checkout(f"refs/heads/{branch}")
+    except (GitError, KeyError) as exc:
+        message = str(exc).strip()
+        return False, message or "Checkout failed"
+    return True, ""
 
 
 def create_branch_from_current(repo: Repository, name: str) -> tuple[bool, str]:
-	branch = name.strip()
-	if not branch:
-		return False, "Branch name is empty"
-	ref_name = f"refs/heads/{branch}"
-	if not reference_is_valid_name(ref_name):
-		return False, f"Invalid branch name: {branch}"
-	if branch in repo.branches.local:
-		return False, f"Branch already exists: {branch}"
-	try:
-		if repo.head_is_unborn:
-			repo.set_head(ref_name)
-		else:
-			commit = repo.head.peel(Commit)
-			repo.branches.create(branch, commit)
-			repo.checkout(ref_name)
-	except GitError as exc:
-		message = str(exc).strip()
-		return False, message or "Failed to create branch"
-	return True, ""
+    branch = name.strip()
+    if not branch:
+        return False, "Branch name is empty"
+    ref_name = f"refs/heads/{branch}"
+    if not reference_is_valid_name(ref_name):
+        return False, f"Invalid branch name: {branch}"
+    if branch in repo.branches.local:
+        return False, f"Branch already exists: {branch}"
+    try:
+        if repo.head_is_unborn:
+            repo.set_head(ref_name)
+        else:
+            commit = repo.head.peel(Commit)
+            repo.branches.create(branch, commit)
+            repo.checkout(ref_name)
+    except GitError as exc:
+        message = str(exc).strip()
+        return False, message or "Failed to create branch"
+    return True, ""
diff --git a/pygittools/src/pygittools/tui/branches_test.py b/pygittools/src/pygittools/tui/branches_test.py
index 9456e55..e31dae2 100644
--- a/pygittools/src/pygittools/tui/branches_test.py
+++ b/pygittools/src/pygittools/tui/branches_test.py
@@ -4,105 +4,105 @@ import pytest
 from pygit2 import GitError, Repository
 
 from pygittools.tui.branches import (
-	checkout_branch,
-	create_branch_from_current,
-	current_branch_name,
-	list_local_branches,
+    checkout_branch,
+    create_branch_from_current,
+    current_branch_name,
+    list_local_branches,
 )
 
 
 def test_current_branch_name_on_main(committed_repo: Repository) -> None:
-	name = current_branch_name(committed_repo)
-	assert name in {"main", "master"}
+    name = current_branch_name(committed_repo)
+    assert name in {"main", "master"}
 
 
 def test_current_branch_name_when_detached(committed_repo: Repository) -> None:
-	commit = committed_repo.get(committed_repo.head.target)
-	committed_repo.set_head(commit.id)
-	assert current_branch_name(committed_repo) == "(detached)"
+    commit = committed_repo.get(committed_repo.head.target)
+    committed_repo.set_head(commit.id)
+    assert current_branch_name(committed_repo) == "(detached)"
 
 
 def test_current_branch_name_when_unborn(repo: Repository) -> None:
-	assert repo.head_is_unborn
-	target = repo.lookup_reference("HEAD").target
-	assert isinstance(target, str)
-	assert current_branch_name(repo) == target.removeprefix("refs/heads/")
+    assert repo.head_is_unborn
+    target = repo.lookup_reference("HEAD").target
+    assert isinstance(target, str)
+    assert current_branch_name(repo) == target.removeprefix("refs/heads/")
 
 
 def test_create_branch_from_unborn_head(repo: Repository) -> None:
-	ok, error = create_branch_from_current(repo, "main")
-	assert ok is True
-	assert error == ""
-	assert current_branch_name(repo) == "main"
-	assert repo.head_is_unborn
-	assert "main" not in list_local_branches(repo)
+    ok, error = create_branch_from_current(repo, "main")
+    assert ok is True
+    assert error == ""
+    assert current_branch_name(repo) == "main"
+    assert repo.head_is_unborn
+    assert "main" not in list_local_branches(repo)
 
 
 def test_list_local_branches_sorted(committed_repo: Repository) -> None:
-	commit = committed_repo.get(committed_repo.head.target)
-	committed_repo.branches.create("feature", commit)
-	branches = list_local_branches(committed_repo)
-	assert branches == sorted(branches)
-	assert "feature" in branches
+    commit = committed_repo.get(committed_repo.head.target)
+    committed_repo.branches.create("feature", commit)
+    branches = list_local_branches(committed_repo)
+    assert branches == sorted(branches)
+    assert "feature" in branches
 
 
 def test_checkout_branch_switches(committed_repo: Repository) -> None:
-	commit = committed_repo.get(committed_repo.head.target)
-	committed_repo.branches.create("feature", commit)
-	ok, error = checkout_branch(committed_repo, "feature")
-	assert ok is True
-	assert error == ""
-	assert current_branch_name(committed_repo) == "feature"
+    commit = committed_repo.get(committed_repo.head.target)
+    committed_repo.branches.create("feature", commit)
+    ok, error = checkout_branch(committed_repo, "feature")
+    assert ok is True
+    assert error == ""
+    assert current_branch_name(committed_repo) == "feature"
 
 
 def test_checkout_branch_returns_error_for_missing_branch(committed_repo: Repository) -> None:
-	ok, error = checkout_branch(committed_repo, "missing")
-	assert ok is False
-	assert error
+    ok, error = checkout_branch(committed_repo, "missing")
+    assert ok is False
+    assert error
 
 
 def test_create_branch_from_current_success(committed_repo: Repository) -> None:
-	ok, error = create_branch_from_current(committed_repo, "new-branch")
-	assert ok is True
-	assert error == ""
-	assert "new-branch" in list_local_branches(committed_repo)
+    ok, error = create_branch_from_current(committed_repo, "new-branch")
+    assert ok is True
+    assert error == ""
+    assert "new-branch" in list_local_branches(committed_repo)
 
 
 def test_create_branch_rejects_empty_name(committed_repo: Repository) -> None:
-	ok, error = create_branch_from_current(committed_repo, "   ")
-	assert ok is False
-	assert error == "Branch name is empty"
+    ok, error = create_branch_from_current(committed_repo, "   ")
+    assert ok is False
+    assert error == "Branch name is empty"
 
 
 def test_create_branch_rejects_invalid_name(committed_repo: Repository) -> None:
-	ok, error = create_branch_from_current(committed_repo, "bad..name")
-	assert ok is False
-	assert "Invalid branch name" in error
+    ok, error = create_branch_from_current(committed_repo, "bad..name")
+    assert ok is False
+    assert "Invalid branch name" in error
 
 
 def test_create_branch_rejects_duplicate(committed_repo: Repository) -> None:
-	ok, _ = create_branch_from_current(committed_repo, "dup")
-	assert ok is True
-	ok, error = create_branch_from_current(committed_repo, "dup")
-	assert ok is False
-	assert "already exists" in error
+    ok, _ = create_branch_from_current(committed_repo, "dup")
+    assert ok is True
+    ok, error = create_branch_from_current(committed_repo, "dup")
+    assert ok is False
+    assert "already exists" in error
 
 
 def test_create_branch_surfaces_git_error(monkeypatch: pytest.MonkeyPatch, committed_repo: Repository) -> None:
-	def fail_create(_name: str, _commit: object) -> None:
-		raise GitError("boom")
+    def fail_create(_name: str, _commit: object) -> None:
+        raise GitError("boom")
 
-	monkeypatch.setattr(committed_repo.branches, "create", fail_create)
-	ok, error = create_branch_from_current(committed_repo, "broken")
-	assert ok is False
-	assert error == "boom"
+    monkeypatch.setattr(committed_repo.branches, "create", fail_create)
+    ok, error = create_branch_from_current(committed_repo, "broken")
+    assert ok is False
+    assert error == "boom"
 
 
 def test_checkout_branch_empty_git_error_message(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	def fail_checkout(_ref: str) -> None:
-		raise GitError("   ")
+    def fail_checkout(_ref: str) -> None:
+        raise GitError("   ")
 
-	monkeypatch.setattr(committed_repo, "checkout", fail_checkout)
-	ok, error = checkout_branch(committed_repo, "main")
-	assert ok is False
-	assert error == "Checkout failed"
+    monkeypatch.setattr(committed_repo, "checkout", fail_checkout)
+    ok, error = checkout_branch(committed_repo, "main")
+    assert ok is False
+    assert error == "Checkout failed"
diff --git a/pygittools/src/pygittools/tui/changes_list.py b/pygittools/src/pygittools/tui/changes_list.py
index 73eabbe..c18d15f 100644
--- a/pygittools/src/pygittools/tui/changes_list.py
+++ b/pygittools/src/pygittools/tui/changes_list.py
@@ -9,44 +9,44 @@ from typing import Literal
 
 from pygit2 import Repository
 from pyratatui import (
-	Constraint,
-	Direction,
-	Frame,
-	Layout,
-	Line,
-	List,
-	ListItem,
-	ListState,
-	Paragraph,
-	Rect,
-	Span,
-	Style,
-	Text,
+    Constraint,
+    Direction,
+    Frame,
+    Layout,
+    Line,
+    List,
+    ListItem,
+    ListState,
+    Paragraph,
+    Rect,
+    Span,
+    Style,
+    Text,
 )
 
 from pygittools.tui.commit_draft import save_commit_draft
 from pygittools.tui.git_colors import current_theme, focus_style, row_style_for_section
 from pygittools.tui.keys import (
-	KeyLike,
-	is_backtab,
-	is_down,
-	is_enter,
-	is_left,
-	is_right,
-	is_space,
-	is_tab,
-	is_up,
-	move_hint,
-	vim_enabled,
+    KeyLike,
+    is_backtab,
+    is_down,
+    is_enter,
+    is_left,
+    is_right,
+    is_space,
+    is_tab,
+    is_up,
+    move_hint,
+    vim_enabled,
 )
 from pygittools.tui.multiline_edit import MultilineEdit
 from pygittools.tui.worktree import (
-	categorize_status,
-	commit_staged,
-	stage_path,
-	stage_paths,
-	unstage_path,
-	unstage_paths,
+    categorize_status,
+    commit_staged,
+    stage_path,
+    stage_paths,
+    unstage_path,
+    unstage_paths,
 )
 
 _InputFocus = Literal["message", "commit", "amend"]
@@ -55,348 +55,348 @@ _EDITOR_MAX_HEIGHT = 8
 
 
 class SectionId(Enum):
-	STAGED = "staged"
-	UNSTAGED = "unstaged"
-	UNTRACKED = "untracked"
+    STAGED = "staged"
+    UNSTAGED = "unstaged"
+    UNTRACKED = "untracked"
 
 
 @dataclass(frozen=True, slots=True)
 class _Row:
-	kind: Literal["commit", "header", "file"]
-	section: SectionId | None
-	path: str | None
-	label: str
+    kind: Literal["commit", "header", "file"]
+    section: SectionId | None
+    path: str | None
+    label: str
 
 
 @dataclass(frozen=True, slots=True)
 class NavKeyResult:
-	enter_input: bool = False
-	layout_changed: bool = False
-	committed: bool = False
+    enter_input: bool = False
+    layout_changed: bool = False
+    committed: bool = False
 
 
 @dataclass(frozen=True, slots=True)
 class InputKeyResult:
-	layout_changed: bool = False
-	relinquish: bool = False
-	committed: bool = False
+    layout_changed: bool = False
+    relinquish: bool = False
+    committed: bool = False
 
 
 _SECTION_ORDER: tuple[tuple[SectionId, str], ...] = (
-	(SectionId.STAGED, "Staged"),
-	(SectionId.UNSTAGED, "Unstaged"),
-	(SectionId.UNTRACKED, "Untracked"),
+    (SectionId.STAGED, "Staged"),
+    (SectionId.UNSTAGED, "Unstaged"),
+    (SectionId.UNTRACKED, "Untracked"),
 )
 
 
 class ChangesList:
-	def __init__(self) -> None:
-		self._collapsed: dict[SectionId, bool] = dict.fromkeys(SectionId, False)
-		self._files: dict[SectionId, list[str]] = {section: [] for section in SectionId}
-		self._rows: list[_Row] = []
-		self._list_state = ListState()
-		self._commit_edit = MultilineEdit()
-		self._input_focus: _InputFocus = "message"
-		self._error: str | None = None
-		self._staged_count = 0
-		self._can_amend = False
-
-	@property
-	def commit_message(self) -> str:
-		return self._commit_edit.value()
-
-	def set_commit_message(self, message: str) -> None:
-		self._commit_edit.set_value(message)
-
-	def persist_commit_draft(self, repo: Repository) -> None:
-		save_commit_draft(Path(repo.path), self.commit_message)
-
-	def is_at_top(self) -> bool:
-		return (self._list_state.selected or 0) == 0
-
-	def select_commit_row(self) -> None:
-		self._list_state.select(0)
-
-	def begin_input(self) -> None:
-		self._input_focus = "message"
-		self.select_commit_row()
-
-	def refresh(self, repo: Repository) -> None:
-		staged, unstaged, untracked = categorize_status(repo)
-		self._files = {
-			SectionId.STAGED: staged,
-			SectionId.UNSTAGED: unstaged,
-			SectionId.UNTRACKED: untracked,
-		}
-		self._staged_count = len(staged)
-		self._can_amend = not repo.head_is_unborn
-		self._rebuild_rows()
-
-	def _rebuild_rows(self) -> None:
-		rows: list[_Row] = [
-			_Row(kind="commit", section=None, path=None, label=self._commit_label()),
-		]
-		for section_id, title in _SECTION_ORDER:
-			files = self._files[section_id]
-			marker = ">" if self._collapsed[section_id] else "v"
-			rows.append(
-				_Row(
-					kind="header",
-					section=section_id,
-					path=None,
-					label=f"[{marker}] {title} ({len(files)})",
-				),
-			)
-			if not self._collapsed[section_id]:
-				for path in files:
-					rows.append(_Row(kind="file", section=section_id, path=path, label=f"  {path}"))
-		self._rows = rows
-		selected = self._list_state.selected or 0
-		self._list_state.select(min(selected, max(0, len(self._rows) - 1)))
-
-	def draw(
-		self,
-		frame: Frame,
-		area: Rect,
-		*,
-		highlight: bool = True,
-		commit_input: bool = False,
-	) -> None:
-		if area.height <= 0 or area.width <= 0:
-			return
-		theme = current_theme()
-		list_area = area
-		if commit_input:
-			editor_height = max(_EDITOR_MIN_HEIGHT, min(_EDITOR_MAX_HEIGHT, max(1, area.height // 3)))
-			chunks = (
-				Layout()
-				.direction(Direction.Vertical)
-				.constraints([
-					Constraint.length(editor_height),
-					Constraint.length(1),
-					Constraint.fill(1),
-				])
-				.split(area)
-			)
-			self._draw_editor(frame, chunks[0], focused=self._input_focus == "message")
-			self._draw_buttons(frame, chunks[1])
-			list_area = chunks[2]
-
-		items: list[ListItem] = []
-		selected = self._list_state.selected or 0
-		for index, row in enumerate(self._rows):
-			if commit_input and row.kind == "commit":
-				continue
-			focused = highlight and index == selected and not commit_input
-			if row.kind == "commit":
-				style = focus_style(theme.commit) if focused else theme.commit
-				items.append(ListItem(self._commit_label(), style))
-				continue
-			if row.kind == "header" and row.section is not None:
-				style = row_style_for_section(
-					row.section.value,
-					focused=focused,
-					highlight=highlight,
-					is_header=True,
-				)
-			elif row.section is not None:
-				style = row_style_for_section(
-					row.section.value,
-					focused=focused,
-					highlight=highlight,
-				)
-			else:
-				style = Style()
-			items.append(ListItem(row.label, style))
-
-		if not items:
-			items.append(ListItem(" ", theme.normal))
-		list_state = self._list_state
-		if commit_input:
-			list_state = ListState()
-			list_state.select(None)
-		frame.render_stateful_list(
-			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			list_area,
-			list_state,
-		)
-
-	def _draw_editor(self, frame: Frame, area: Rect, *, focused: bool) -> None:
-		theme = current_theme()
-		style = theme.commit
-		cursor_style = theme.focus if focused else theme.commit
-		text = self._commit_edit.render(
-			max(1, area.width),
-			max(1, area.height),
-			focused=focused,
-			style=style,
-			cursor_style=cursor_style,
-		)
-		frame.render_widget(Paragraph(text), area)
-
-	def _draw_buttons(self, frame: Frame, area: Rect) -> None:
-		theme = current_theme()
-		commit_style = theme.focus if self._input_focus == "commit" else theme.header
-		amend_style = theme.focus if self._input_focus == "amend" else theme.header
-		if not self._can_amend and self._input_focus != "amend":
-			amend_style = theme.log_date.dim()
-		text = Text([
-			Line([
-				Span("[ Commit ]", commit_style),
-				Span("  ", theme.normal),
-				Span("[ Commit (amend) ]", amend_style),
-			]),
-		])
-		frame.render_widget(Paragraph(text), area)
-
-	def handle_nav_key(self, key: KeyLike, repo: Repository) -> NavKeyResult:
-		if self._error is not None:
-			self._error = None
-
-		if is_up(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(max(0, selected - 1))
-			return NavKeyResult()
-		if is_down(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(min(max(0, len(self._rows) - 1), selected + 1))
-			return NavKeyResult()
-		if not self._rows:
-			return NavKeyResult()
-
-		row = self._rows[self._list_state.selected or 0]
-		if row.kind == "commit":
-			if is_enter(key) or (vim_enabled() and key.code in {"i", "I"}):
-				self.begin_input()
-				return NavKeyResult(enter_input=True)
-			return NavKeyResult()
-
-		if (is_left(key) or is_right(key) or is_space(key)) and row.kind == "header" and row.section is not None:
-			self._collapsed[row.section] = not self._collapsed[row.section]
-			self._rebuild_rows()
-			return NavKeyResult(layout_changed=True)
-		if is_enter(key) and row.kind == "header" and row.section is not None:
-			self._toggle_section(repo, row.section)
-			self.refresh(repo)
-			return NavKeyResult(layout_changed=True)
-		if is_enter(key) and row.kind == "file" and row.path is not None and row.section is not None:
-			self._toggle_stage(repo, row.section, row.path)
-			self.refresh(repo)
-			return NavKeyResult(layout_changed=True)
-		return NavKeyResult()
-
-	def handle_input_key(self, key: KeyLike, repo: Repository) -> InputKeyResult:
-		if self._error is not None:
-			self._error = None
-
-		if is_tab(key):
-			self._input_focus = _next_focus(self._input_focus, self._can_amend)
-			return InputKeyResult()
-		if is_backtab(key):
-			self._input_focus = _prev_focus(self._input_focus, self._can_amend)
-			return InputKeyResult()
-
-		if self._input_focus == "message":
-			if key.code == "Down" and self._commit_edit.at_last_visual_line():
-				self._input_focus = "commit"
-				return InputKeyResult()
-			self._commit_edit.handle_key(key)
-			return InputKeyResult()
-
-		# Button row: arrows only so vim letters cannot steal focus while editing.
-		if key.code == "Up":
-			self._input_focus = "message"
-			return InputKeyResult()
-		if key.code == "Left" and self._input_focus == "amend":
-			self._input_focus = "commit"
-			return InputKeyResult()
-		if key.code == "Right" and self._input_focus == "commit" and self._can_amend:
-			self._input_focus = "amend"
-			return InputKeyResult()
-		if is_enter(key):
-			return self._run_commit(repo, amend=self._input_focus == "amend")
-		return InputKeyResult()
-
-	def status_hint(self) -> str:
-		if self._error:
-			return self._error
-		if not self._rows:
-			return "Working tree clean"
-		move = move_hint()
-		row = self._rows[self._list_state.selected or 0]
-		if row.kind == "commit":
-			insert = "Enter/i edit" if vim_enabled() else "Enter edit"
-			return f"{insert} — {move} — / command"
-		if row.kind == "header":
-			if row.section == SectionId.STAGED:
-				if self._staged_count == 0:
-					return f"Enter stage all — Space collapse — {move}"
-				return f"Enter unstage all — Space collapse — {move}"
-			return f"Enter stage all — Space collapse — {move}"
-		if row.section == SectionId.STAGED:
-			return f"Enter unstage — {move}"
-		return f"Enter stage — {move}"
-
-	def input_status_hint(self) -> str:
-		if self._error:
-			return self._error
-		if self._input_focus == "message":
-			return "Esc back — Enter newline — Tab buttons — ↓ Commit"
-		if self._input_focus == "commit":
-			return "Enter commit — ←/→ amend — Esc back"
-		if self._can_amend:
-			return "Enter amend — ←/→ commit — Esc back"
-		return "Nothing to amend — ← commit — Esc back"
-
-	def _run_commit(self, repo: Repository, *, amend: bool) -> InputKeyResult:
-		ok, error = commit_staged(repo, self.commit_message, amend=amend)
-		if ok:
-			self._commit_edit.set_value("")
-			self.persist_commit_draft(repo)
-			self._error = None
-			self.refresh(repo)
-			return InputKeyResult(layout_changed=True, relinquish=True, committed=True)
-		self._error = error
-		return InputKeyResult()
-
-	def _commit_label(self) -> str:
-		message = self.commit_message
-		if not message.strip():
-			return "Commit: (edit message)"
-		lines = message.splitlines()
-		first = lines[0]
-		if len(lines) > 1:
-			return f"Commit: {first}…"
-		return f"Commit: {first}"
-
-	def _toggle_stage(self, repo: Repository, section: SectionId, path: str) -> None:
-		if section == SectionId.STAGED:
-			unstage_path(repo, path)
-		else:
-			stage_path(repo, path)
-
-	def _toggle_section(self, repo: Repository, section: SectionId) -> None:
-		paths = self._files[section]
-		if section == SectionId.STAGED:
-			if paths:
-				unstage_paths(repo, paths)
-			else:
-				stage_paths(repo, self._files[SectionId.UNSTAGED] + self._files[SectionId.UNTRACKED])
-		else:
-			stage_paths(repo, paths)
+    def __init__(self) -> None:
+        self._collapsed: dict[SectionId, bool] = dict.fromkeys(SectionId, False)
+        self._files: dict[SectionId, list[str]] = {section: [] for section in SectionId}
+        self._rows: list[_Row] = []
+        self._list_state = ListState()
+        self._commit_edit = MultilineEdit()
+        self._input_focus: _InputFocus = "message"
+        self._error: str | None = None
+        self._staged_count = 0
+        self._can_amend = False
+
+    @property
+    def commit_message(self) -> str:
+        return self._commit_edit.value()
+
+    def set_commit_message(self, message: str) -> None:
+        self._commit_edit.set_value(message)
+
+    def persist_commit_draft(self, repo: Repository) -> None:
+        save_commit_draft(Path(repo.path), self.commit_message)
+
+    def is_at_top(self) -> bool:
+        return (self._list_state.selected or 0) == 0
+
+    def select_commit_row(self) -> None:
+        self._list_state.select(0)
+
+    def begin_input(self) -> None:
+        self._input_focus = "message"
+        self.select_commit_row()
+
+    def refresh(self, repo: Repository) -> None:
+        staged, unstaged, untracked = categorize_status(repo)
+        self._files = {
+            SectionId.STAGED: staged,
+            SectionId.UNSTAGED: unstaged,
+            SectionId.UNTRACKED: untracked,
+        }
+        self._staged_count = len(staged)
+        self._can_amend = not repo.head_is_unborn
+        self._rebuild_rows()
+
+    def _rebuild_rows(self) -> None:
+        rows: list[_Row] = [
+            _Row(kind="commit", section=None, path=None, label=self._commit_label()),
+        ]
+        for section_id, title in _SECTION_ORDER:
+            files = self._files[section_id]
+            marker = ">" if self._collapsed[section_id] else "v"
+            rows.append(
+                _Row(
+                    kind="header",
+                    section=section_id,
+                    path=None,
+                    label=f"[{marker}] {title} ({len(files)})",
+                ),
+            )
+            if not self._collapsed[section_id]:
+                for path in files:
+                    rows.append(_Row(kind="file", section=section_id, path=path, label=f"  {path}"))
+        self._rows = rows
+        selected = self._list_state.selected or 0
+        self._list_state.select(min(selected, max(0, len(self._rows) - 1)))
+
+    def draw(
+        self,
+        frame: Frame,
+        area: Rect,
+        *,
+        highlight: bool = True,
+        commit_input: bool = False,
+    ) -> None:
+        if area.height <= 0 or area.width <= 0:
+            return
+        theme = current_theme()
+        list_area = area
+        if commit_input:
+            editor_height = max(_EDITOR_MIN_HEIGHT, min(_EDITOR_MAX_HEIGHT, max(1, area.height // 3)))
+            chunks = (
+                Layout()
+                .direction(Direction.Vertical)
+                .constraints([
+                    Constraint.length(editor_height),
+                    Constraint.length(1),
+                    Constraint.fill(1),
+                ])
+                .split(area)
+            )
+            self._draw_editor(frame, chunks[0], focused=self._input_focus == "message")
+            self._draw_buttons(frame, chunks[1])
+            list_area = chunks[2]
+
+        items: list[ListItem] = []
+        selected = self._list_state.selected or 0
+        for index, row in enumerate(self._rows):
+            if commit_input and row.kind == "commit":
+                continue
+            focused = highlight and index == selected and not commit_input
+            if row.kind == "commit":
+                style = focus_style(theme.commit) if focused else theme.commit
+                items.append(ListItem(self._commit_label(), style))
+                continue
+            if row.kind == "header" and row.section is not None:
+                style = row_style_for_section(
+                    row.section.value,
+                    focused=focused,
+                    highlight=highlight,
+                    is_header=True,
+                )
+            elif row.section is not None:
+                style = row_style_for_section(
+                    row.section.value,
+                    focused=focused,
+                    highlight=highlight,
+                )
+            else:
+                style = Style()
+            items.append(ListItem(row.label, style))
+
+        if not items:
+            items.append(ListItem(" ", theme.normal))
+        list_state = self._list_state
+        if commit_input:
+            list_state = ListState()
+            list_state.select(None)
+        frame.render_stateful_list(
+            List(items).highlight_style(theme.focus).highlight_symbol(""),
+            list_area,
+            list_state,
+        )
+
+    def _draw_editor(self, frame: Frame, area: Rect, *, focused: bool) -> None:
+        theme = current_theme()
+        style = theme.commit
+        cursor_style = theme.focus if focused else theme.commit
+        text = self._commit_edit.render(
+            max(1, area.width),
+            max(1, area.height),
+            focused=focused,
+            style=style,
+            cursor_style=cursor_style,
+        )
+        frame.render_widget(Paragraph(text), area)
+
+    def _draw_buttons(self, frame: Frame, area: Rect) -> None:
+        theme = current_theme()
+        commit_style = theme.focus if self._input_focus == "commit" else theme.header
+        amend_style = theme.focus if self._input_focus == "amend" else theme.header
+        if not self._can_amend and self._input_focus != "amend":
+            amend_style = theme.log_date.dim()
+        text = Text([
+            Line([
+                Span("[ Commit ]", commit_style),
+                Span("  ", theme.normal),
+                Span("[ Commit (amend) ]", amend_style),
+            ]),
+        ])
+        frame.render_widget(Paragraph(text), area)
+
+    def handle_nav_key(self, key: KeyLike, repo: Repository) -> NavKeyResult:
+        if self._error is not None:
+            self._error = None
+
+        if is_up(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(max(0, selected - 1))
+            return NavKeyResult()
+        if is_down(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(min(max(0, len(self._rows) - 1), selected + 1))
+            return NavKeyResult()
+        if not self._rows:
+            return NavKeyResult()
+
+        row = self._rows[self._list_state.selected or 0]
+        if row.kind == "commit":
+            if is_enter(key) or (vim_enabled() and key.code in {"i", "I"}):
+                self.begin_input()
+                return NavKeyResult(enter_input=True)
+            return NavKeyResult()
+
+        if (is_left(key) or is_right(key) or is_space(key)) and row.kind == "header" and row.section is not None:
+            self._collapsed[row.section] = not self._collapsed[row.section]
+            self._rebuild_rows()
+            return NavKeyResult(layout_changed=True)
+        if is_enter(key) and row.kind == "header" and row.section is not None:
+            self._toggle_section(repo, row.section)
+            self.refresh(repo)
+            return NavKeyResult(layout_changed=True)
+        if is_enter(key) and row.kind == "file" and row.path is not None and row.section is not None:
+            self._toggle_stage(repo, row.section, row.path)
+            self.refresh(repo)
+            return NavKeyResult(layout_changed=True)
+        return NavKeyResult()
+
+    def handle_input_key(self, key: KeyLike, repo: Repository) -> InputKeyResult:
+        if self._error is not None:
+            self._error = None
+
+        if is_tab(key):
+            self._input_focus = _next_focus(self._input_focus, self._can_amend)
+            return InputKeyResult()
+        if is_backtab(key):
+            self._input_focus = _prev_focus(self._input_focus, self._can_amend)
+            return InputKeyResult()
+
+        if self._input_focus == "message":
+            if key.code == "Down" and self._commit_edit.at_last_visual_line():
+                self._input_focus = "commit"
+                return InputKeyResult()
+            self._commit_edit.handle_key(key)
+            return InputKeyResult()
+
+        # Button row: arrows only so vim letters cannot steal focus while editing.
+        if key.code == "Up":
+            self._input_focus = "message"
+            return InputKeyResult()
+        if key.code == "Left" and self._input_focus == "amend":
+            self._input_focus = "commit"
+            return InputKeyResult()
+        if key.code == "Right" and self._input_focus == "commit" and self._can_amend:
+            self._input_focus = "amend"
+            return InputKeyResult()
+        if is_enter(key):
+            return self._run_commit(repo, amend=self._input_focus == "amend")
+        return InputKeyResult()
+
+    def status_hint(self) -> str:
+        if self._error:
+            return self._error
+        if not self._rows:
+            return "Working tree clean"
+        move = move_hint()
+        row = self._rows[self._list_state.selected or 0]
+        if row.kind == "commit":
+            insert = "Enter/i edit" if vim_enabled() else "Enter edit"
+            return f"{insert} — {move} — / command"
+        if row.kind == "header":
+            if row.section == SectionId.STAGED:
+                if self._staged_count == 0:
+                    return f"Enter stage all — Space collapse — {move}"
+                return f"Enter unstage all — Space collapse — {move}"
+            return f"Enter stage all — Space collapse — {move}"
+        if row.section == SectionId.STAGED:
+            return f"Enter unstage — {move}"
+        return f"Enter stage — {move}"
+
+    def input_status_hint(self) -> str:
+        if self._error:
+            return self._error
+        if self._input_focus == "message":
+            return "Esc back — Enter newline — Tab buttons — ↓ Commit"
+        if self._input_focus == "commit":
+            return "Enter commit — ←/→ amend — Esc back"
+        if self._can_amend:
+            return "Enter amend — ←/→ commit — Esc back"
+        return "Nothing to amend — ← commit — Esc back"
+
+    def _run_commit(self, repo: Repository, *, amend: bool) -> InputKeyResult:
+        ok, error = commit_staged(repo, self.commit_message, amend=amend)
+        if ok:
+            self._commit_edit.set_value("")
+            self.persist_commit_draft(repo)
+            self._error = None
+            self.refresh(repo)
+            return InputKeyResult(layout_changed=True, relinquish=True, committed=True)
+        self._error = error
+        return InputKeyResult()
+
+    def _commit_label(self) -> str:
+        message = self.commit_message
+        if not message.strip():
+            return "Commit: (edit message)"
+        lines = message.splitlines()
+        first = lines[0]
+        if len(lines) > 1:
+            return f"Commit: {first}…"
+        return f"Commit: {first}"
+
+    def _toggle_stage(self, repo: Repository, section: SectionId, path: str) -> None:
+        if section == SectionId.STAGED:
+            unstage_path(repo, path)
+        else:
+            stage_path(repo, path)
+
+    def _toggle_section(self, repo: Repository, section: SectionId) -> None:
+        paths = self._files[section]
+        if section == SectionId.STAGED:
+            if paths:
+                unstage_paths(repo, paths)
+            else:
+                stage_paths(repo, self._files[SectionId.UNSTAGED] + self._files[SectionId.UNTRACKED])
+        else:
+            stage_paths(repo, paths)
 
 
 def _next_focus(current: _InputFocus, can_amend: bool) -> _InputFocus:
-	if current == "message":
-		return "commit"
-	if current == "commit":
-		return "amend" if can_amend else "message"
-	return "message"
+    if current == "message":
+        return "commit"
+    if current == "commit":
+        return "amend" if can_amend else "message"
+    return "message"
 
 
 def _prev_focus(current: _InputFocus, can_amend: bool) -> _InputFocus:
-	if current == "message":
-		return "amend" if can_amend else "commit"
-	if current == "amend":
-		return "commit"
-	return "message"
+    if current == "message":
+        return "amend" if can_amend else "commit"
+    if current == "amend":
+        return "commit"
+    return "message"
diff --git a/pygittools/src/pygittools/tui/changes_list_test.py b/pygittools/src/pygittools/tui/changes_list_test.py
index 8018064..1db3a84 100644
--- a/pygittools/src/pygittools/tui/changes_list_test.py
+++ b/pygittools/src/pygittools/tui/changes_list_test.py
@@ -14,206 +14,206 @@ from pygittools.tui.worktree import categorize_status
 
 
 def test_refresh_builds_sections(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	assert changes.status_hint() != ""
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    assert changes.status_hint() != ""
 
 
 def test_refresh_clean_repo(page_ctx: PageContext) -> None:
-	changes = ChangesList()
-	changes.refresh(page_ctx.repo)
-	assert "Enter edit" in changes.status_hint()
+    changes = ChangesList()
+    changes.refresh(page_ctx.repo)
+    assert "Enter edit" in changes.status_hint()
 
 
 def test_nav_up_down(page_ctx: PageContext) -> None:
-	changes = ChangesList()
-	changes.refresh(page_ctx.repo)
-	changes.handle_nav_key(key("Down"), page_ctx.repo)
-	changes.handle_nav_key(key("Up"), page_ctx.repo)
-	assert changes.is_at_top()
+    changes = ChangesList()
+    changes.refresh(page_ctx.repo)
+    changes.handle_nav_key(key("Down"), page_ctx.repo)
+    changes.handle_nav_key(key("Up"), page_ctx.repo)
+    assert changes.is_at_top()
 
 
 def test_stage_and_unstage_file(committed_repo: Repository) -> None:
-	path = Path(committed_repo.workdir) / "tracked.txt"
-	path.write_text("v2\n", encoding="utf-8")
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	changes._list_state.select(3)
-	changes.handle_nav_key(key("Enter"), committed_repo)
-	changes.refresh(committed_repo)
-	staged, unstaged, _ = categorize_status(committed_repo)
-	assert "tracked.txt" in staged or "tracked.txt" in unstaged
+    path = Path(committed_repo.workdir) / "tracked.txt"
+    path.write_text("v2\n", encoding="utf-8")
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    changes._list_state.select(3)
+    changes.handle_nav_key(key("Enter"), committed_repo)
+    changes.refresh(committed_repo)
+    staged, unstaged, _ = categorize_status(committed_repo)
+    assert "tracked.txt" in staged or "tracked.txt" in unstaged
 
 
 def test_toggle_section_stages_untracked(committed_repo: Repository) -> None:
-	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	for index, row in enumerate(changes._rows):
-		if row.kind == "header" and row.section == SectionId.UNTRACKED:
-			changes._list_state.select(index)
-			break
-	result = changes.handle_nav_key(key("Enter"), committed_repo)
-	assert result.layout_changed is True
+    (Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    for index, row in enumerate(changes._rows):
+        if row.kind == "header" and row.section == SectionId.UNTRACKED:
+            changes._list_state.select(index)
+            break
+    result = changes.handle_nav_key(key("Enter"), committed_repo)
+    assert result.layout_changed is True
 
 
 def test_collapse_section_with_space(committed_repo: Repository) -> None:
-	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	for index, row in enumerate(changes._rows):
-		if row.kind == "header" and row.section == SectionId.UNSTAGED:
-			changes._list_state.select(index)
-			break
-	result = changes.handle_nav_key(key(" "), committed_repo)
-	assert result.layout_changed is True
+    (Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    for index, row in enumerate(changes._rows):
+        if row.kind == "header" and row.section == SectionId.UNSTAGED:
+            changes._list_state.select(index)
+            break
+    result = changes.handle_nav_key(key(" "), committed_repo)
+    assert result.layout_changed is True
 
 
 def test_collapse_does_not_reread_status(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	for index, row in enumerate(changes._rows):
-		if row.kind == "header":
-			changes._list_state.select(index)
-			break
-	with patch("pygittools.tui.changes_list.categorize_status") as status:
-		changes.handle_nav_key(key(" "), committed_repo)
-	status.assert_not_called()
-	assert any(row.kind == "header" and row.label.startswith("[>") for row in changes._rows)
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    for index, row in enumerate(changes._rows):
+        if row.kind == "header":
+            changes._list_state.select(index)
+            break
+    with patch("pygittools.tui.changes_list.categorize_status") as status:
+        changes.handle_nav_key(key(" "), committed_repo)
+    status.assert_not_called()
+    assert any(row.kind == "header" and row.label.startswith("[>") for row in changes._rows)
 
 
 def test_commit_input_flow(committed_repo: Repository) -> None:
-	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
-	committed_repo.index.add("new.txt")
-	committed_repo.index.write()
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	changes._list_state.select(0)
-	assert changes.handle_nav_key(key("Enter"), committed_repo).enter_input is True
-	changes.handle_input_key(key("f"), committed_repo)
-	assert changes.commit_message.endswith("f")
-	changes.handle_input_key(key("Enter"), committed_repo)
-	assert "\n" in changes.commit_message
-	changes.handle_input_key(key("Tab"), committed_repo)
-	result = changes.handle_input_key(key("Enter"), committed_repo)
-	assert result.committed is True
-	assert result.relinquish is True
+    (Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+    committed_repo.index.add("new.txt")
+    committed_repo.index.write()
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    changes._list_state.select(0)
+    assert changes.handle_nav_key(key("Enter"), committed_repo).enter_input is True
+    changes.handle_input_key(key("f"), committed_repo)
+    assert changes.commit_message.endswith("f")
+    changes.handle_input_key(key("Enter"), committed_repo)
+    assert "\n" in changes.commit_message
+    changes.handle_input_key(key("Tab"), committed_repo)
+    result = changes.handle_input_key(key("Enter"), committed_repo)
+    assert result.committed is True
+    assert result.relinquish is True
 
 
 def test_commit_button_with_message(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	changes.set_commit_message("msg")
-	changes.begin_input()
-	monkeypatch.setattr(
-		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg, *, amend=False: (True, ""),
-	)
-	changes.handle_input_key(key("Tab"), committed_repo)
-	result = changes.handle_input_key(key("Enter"), committed_repo)
-	assert result.layout_changed is True
-	assert result.committed is True
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    changes.set_commit_message("msg")
+    changes.begin_input()
+    monkeypatch.setattr(
+        "pygittools.tui.changes_list.commit_staged",
+        lambda _repo, _msg, *, amend=False: (True, ""),
+    )
+    changes.handle_input_key(key("Tab"), committed_repo)
+    result = changes.handle_input_key(key("Enter"), committed_repo)
+    assert result.layout_changed is True
+    assert result.committed is True
 
 
 def test_amend_button(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	changes.set_commit_message("fix")
-	changes.begin_input()
-	seen: dict[str, bool] = {}
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    changes.set_commit_message("fix")
+    changes.begin_input()
+    seen: dict[str, bool] = {}
 
-	def _commit(_repo: Repository, _msg: str, *, amend: bool = False) -> tuple[bool, str]:
-		seen["amend"] = amend
-		return True, ""
+    def _commit(_repo: Repository, _msg: str, *, amend: bool = False) -> tuple[bool, str]:
+        seen["amend"] = amend
+        return True, ""
 
-	monkeypatch.setattr("pygittools.tui.changes_list.commit_staged", _commit)
-	changes.handle_input_key(key("Tab"), committed_repo)
-	changes.handle_input_key(key("Right"), committed_repo)
-	result = changes.handle_input_key(key("Enter"), committed_repo)
-	assert result.committed is True
-	assert seen["amend"] is True
+    monkeypatch.setattr("pygittools.tui.changes_list.commit_staged", _commit)
+    changes.handle_input_key(key("Tab"), committed_repo)
+    changes.handle_input_key(key("Right"), committed_repo)
+    result = changes.handle_input_key(key("Enter"), committed_repo)
+    assert result.committed is True
+    assert seen["amend"] is True
 
 
 def test_commit_button_failure(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	changes.set_commit_message("msg")
-	changes.begin_input()
-	monkeypatch.setattr(
-		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg, *, amend=False: (False, "nope"),
-	)
-	changes.handle_input_key(key("Tab"), committed_repo)
-	changes.handle_input_key(key("Enter"), committed_repo)
-	assert "nope" in changes.input_status_hint()
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    changes.set_commit_message("msg")
+    changes.begin_input()
+    monkeypatch.setattr(
+        "pygittools.tui.changes_list.commit_staged",
+        lambda _repo, _msg, *, amend=False: (False, "nope"),
+    )
+    changes.handle_input_key(key("Tab"), committed_repo)
+    changes.handle_input_key(key("Enter"), committed_repo)
+    assert "nope" in changes.input_status_hint()
 
 
 def test_enter_commit_row_starts_input(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	changes._list_state.select(0)
-	result = changes.handle_nav_key(key("Enter"), committed_repo)
-	assert result.enter_input is True
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    changes._list_state.select(0)
+    result = changes.handle_nav_key(key("Enter"), committed_repo)
+    assert result.enter_input is True
 
 
 def test_vim_i_starts_input(committed_repo: Repository) -> None:
-	set_vim_enabled(True)
-	try:
-		changes = ChangesList()
-		changes.refresh(committed_repo)
-		changes._list_state.select(0)
-		assert "Enter/i" in changes.status_hint()
-		result = changes.handle_nav_key(key("i"), committed_repo)
-		assert result.enter_input is True
-	finally:
-		set_vim_enabled(False)
+    set_vim_enabled(True)
+    try:
+        changes = ChangesList()
+        changes.refresh(committed_repo)
+        changes._list_state.select(0)
+        assert "Enter/i" in changes.status_hint()
+        result = changes.handle_nav_key(key("i"), committed_repo)
+        assert result.enter_input is True
+    finally:
+        set_vim_enabled(False)
 
 
 def test_stage_file_hint(committed_repo: Repository) -> None:
-	path = Path(committed_repo.workdir) / "tracked.txt"
-	path.write_text("v2\n", encoding="utf-8")
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	for index, row in enumerate(changes._rows):
-		if row.kind == "file" and row.section == SectionId.UNSTAGED:
-			changes._list_state.select(index)
-			break
-	assert "Enter stage" in changes.status_hint()
+    path = Path(committed_repo.workdir) / "tracked.txt"
+    path.write_text("v2\n", encoding="utf-8")
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    for index, row in enumerate(changes._rows):
+        if row.kind == "file" and row.section == SectionId.UNSTAGED:
+            changes._list_state.select(index)
+            break
+    assert "Enter stage" in changes.status_hint()
 
 
 def test_draw_renders(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	frame = MagicMock()
-	area = Rect(0, 0, 80, 20)
-	changes.draw(frame, area, highlight=True, commit_input=True)
-	changes.draw(frame, area, highlight=True, commit_input=False)
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    frame = MagicMock()
+    area = Rect(0, 0, 80, 20)
+    changes.draw(frame, area, highlight=True, commit_input=True)
+    changes.draw(frame, area, highlight=True, commit_input=False)
 
 
 def test_draw_commit_input_does_not_highlight_shifted_row(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	changes._list_state.select(0)
-	frame = MagicMock()
-	changes.draw(frame, Rect(0, 0, 80, 20), highlight=True, commit_input=True)
-	assert frame.render_stateful_list.called
-	list_state = frame.render_stateful_list.call_args.args[2]
-	assert list_state.selected is None
-	assert (changes._list_state.selected or 0) == 0
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    changes._list_state.select(0)
+    frame = MagicMock()
+    changes.draw(frame, Rect(0, 0, 80, 20), highlight=True, commit_input=True)
+    assert frame.render_stateful_list.called
+    list_state = frame.render_stateful_list.call_args.args[2]
+    assert list_state.selected is None
+    assert (changes._list_state.selected or 0) == 0
 
 
 def test_persist_commit_draft(page_ctx: PageContext) -> None:
-	changes = ChangesList()
-	changes.set_commit_message("draft")
-	changes.persist_commit_draft(page_ctx.repo)
+    changes = ChangesList()
+    changes.set_commit_message("draft")
+    changes.persist_commit_draft(page_ctx.repo)
 
 
 def test_toggle_staged_header_when_empty(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	for index, row in enumerate(changes._rows):
-		if row.kind == "header" and row.section == SectionId.STAGED:
-			changes._list_state.select(index)
-			break
-	result = changes.handle_nav_key(key("Enter"), committed_repo)
-	assert result.layout_changed is True
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    for index, row in enumerate(changes._rows):
+        if row.kind == "header" and row.section == SectionId.STAGED:
+            changes._list_state.select(index)
+            break
+    result = changes.handle_nav_key(key("Enter"), committed_repo)
+    assert result.layout_changed is True
diff --git a/pygittools/src/pygittools/tui/commit_draft.py b/pygittools/src/pygittools/tui/commit_draft.py
index 15e2bcf..b743fbb 100644
--- a/pygittools/src/pygittools/tui/commit_draft.py
+++ b/pygittools/src/pygittools/tui/commit_draft.py
@@ -8,43 +8,43 @@ from pygit2 import Commit, GitError, Repository
 
 
 def commit_editmsg_path(repo_path: Path) -> Path:
-	return repo_path / "COMMIT_EDITMSG"
+    return repo_path / "COMMIT_EDITMSG"
 
 
 def _read_draft_text(repo_path: Path) -> str:
-	path = commit_editmsg_path(repo_path)
-	if not path.is_file():
-		return ""
-	try:
-		text = path.read_text(encoding="utf-8")
-	except OSError:
-		return ""
-	for line in text.splitlines():
-		stripped = line.strip()
-		if stripped and not stripped.startswith("#"):
-			return stripped
-	return ""
+    path = commit_editmsg_path(repo_path)
+    if not path.is_file():
+        return ""
+    try:
+        text = path.read_text(encoding="utf-8")
+    except OSError:
+        return ""
+    for line in text.splitlines():
+        stripped = line.strip()
+        if stripped and not stripped.startswith("#"):
+            return stripped
+    return ""
 
 
 def load_commit_draft(repo_path: Path, repo: Repository | None = None) -> str:
-	draft = _read_draft_text(repo_path)
-	if not draft or repo is None:
-		return draft
-	try:
-		head_message = repo.head.peel(Commit).message.strip()
-	except GitError:
-		return draft
-	if draft.strip() == head_message:
-		return ""
-	return draft
+    draft = _read_draft_text(repo_path)
+    if not draft or repo is None:
+        return draft
+    try:
+        head_message = repo.head.peel(Commit).message.strip()
+    except GitError:
+        return draft
+    if draft.strip() == head_message:
+        return ""
+    return draft
 
 
 def save_commit_draft(repo_path: Path, message: str) -> None:
-	path = commit_editmsg_path(repo_path)
-	try:
-		if message:
-			path.write_text(f"{message}\n", encoding="utf-8")
-		elif path.is_file():
-			path.unlink()
-	except OSError:
-		return
+    path = commit_editmsg_path(repo_path)
+    try:
+        if message:
+            path.write_text(f"{message}\n", encoding="utf-8")
+        elif path.is_file():
+            path.unlink()
+    except OSError:
+        return
diff --git a/pygittools/src/pygittools/tui/commit_draft_test.py b/pygittools/src/pygittools/tui/commit_draft_test.py
index 630e055..4ba7a05 100644
--- a/pygittools/src/pygittools/tui/commit_draft_test.py
+++ b/pygittools/src/pygittools/tui/commit_draft_test.py
@@ -6,9 +6,9 @@ import pytest
 from pygit2 import Repository, Signature, init_repository
 
 from pygittools.tui.commit_draft import (
-	commit_editmsg_path,
-	load_commit_draft,
-	save_commit_draft,
+    commit_editmsg_path,
+    load_commit_draft,
+    save_commit_draft,
 )
 
 SIG = Signature("alice", "alice@example.com")
@@ -16,73 +16,73 @@ SIG = Signature("alice", "alice@example.com")
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-	return init_repository(str(repo_path), bare=False)
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+    return init_repository(str(repo_path), bare=False)
 
 
 def test_load_returns_empty_when_missing(tmp_path: Path) -> None:
-	assert load_commit_draft(tmp_path) == ""
+    assert load_commit_draft(tmp_path) == ""
 
 
 def test_roundtrip_single_line_message(tmp_path: Path) -> None:
-	save_commit_draft(tmp_path, "feat: add draft persistence")
-	assert load_commit_draft(tmp_path) == "feat: add draft persistence"
-	assert commit_editmsg_path(tmp_path).read_text(encoding="utf-8") == "feat: add draft persistence\n"
+    save_commit_draft(tmp_path, "feat: add draft persistence")
+    assert load_commit_draft(tmp_path) == "feat: add draft persistence"
+    assert commit_editmsg_path(tmp_path).read_text(encoding="utf-8") == "feat: add draft persistence\n"
 
 
 def test_load_ignores_git_comment_lines(tmp_path: Path) -> None:
-	commit_editmsg_path(tmp_path).write_text(
-		"feat: keep me\n\n# Please enter the commit message\n",
-		encoding="utf-8",
-	)
-	assert load_commit_draft(tmp_path) == "feat: keep me"
+    commit_editmsg_path(tmp_path).write_text(
+        "feat: keep me\n\n# Please enter the commit message\n",
+        encoding="utf-8",
+    )
+    assert load_commit_draft(tmp_path) == "feat: keep me"
 
 
 def test_save_empty_removes_existing_file(tmp_path: Path) -> None:
-	save_commit_draft(tmp_path, "wip")
-	path = commit_editmsg_path(tmp_path)
-	assert path.is_file()
+    save_commit_draft(tmp_path, "wip")
+    path = commit_editmsg_path(tmp_path)
+    assert path.is_file()
 
-	save_commit_draft(tmp_path, "")
-	assert not path.exists()
+    save_commit_draft(tmp_path, "")
+    assert not path.exists()
 
 
 def test_load_returns_empty_when_draft_matches_head(repo: Repository) -> None:
-	tree = repo.index.write_tree()
-	repo.create_commit("HEAD", SIG, SIG, "feat: shipped", tree, [])
-	save_commit_draft(Path(repo.path), "feat: shipped")
+    tree = repo.index.write_tree()
+    repo.create_commit("HEAD", SIG, SIG, "feat: shipped", tree, [])
+    save_commit_draft(Path(repo.path), "feat: shipped")
 
-	assert load_commit_draft(Path(repo.path), repo) == ""
+    assert load_commit_draft(Path(repo.path), repo) == ""
 
 
 def test_load_returns_draft_when_it_differs_from_head(repo: Repository) -> None:
-	tree = repo.index.write_tree()
-	repo.create_commit("HEAD", SIG, SIG, "feat: shipped", tree, [])
-	save_commit_draft(Path(repo.path), "feat: next change")
+    tree = repo.index.write_tree()
+    repo.create_commit("HEAD", SIG, SIG, "feat: shipped", tree, [])
+    save_commit_draft(Path(repo.path), "feat: next change")
 
-	assert load_commit_draft(Path(repo.path), repo) == "feat: next change"
+    assert load_commit_draft(Path(repo.path), repo) == "feat: next change"
 
 
 def test_read_draft_ignores_unreadable_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	path = commit_editmsg_path(tmp_path)
-	path.write_text("draft\n", encoding="utf-8")
+    path = commit_editmsg_path(tmp_path)
+    path.write_text("draft\n", encoding="utf-8")
 
-	def fail_read_text(self: Path, encoding: str = "utf-8") -> str:
-		raise OSError("nope")
+    def fail_read_text(self: Path, encoding: str = "utf-8") -> str:
+        raise OSError("nope")
 
-	monkeypatch.setattr(Path, "read_text", fail_read_text)
-	assert load_commit_draft(tmp_path) == ""
+    monkeypatch.setattr(Path, "read_text", fail_read_text)
+    assert load_commit_draft(tmp_path) == ""
 
 
 def test_load_returns_draft_when_head_unavailable(repo: Repository) -> None:
-	save_commit_draft(Path(repo.path), "wip")
-	assert load_commit_draft(Path(repo.path), repo) == "wip"
+    save_commit_draft(Path(repo.path), "wip")
+    assert load_commit_draft(Path(repo.path), repo) == "wip"
 
 
 def test_save_commit_draft_ignores_os_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	def fail_write_text(self: Path, data: str, encoding: str = "utf-8") -> None:
-		raise OSError("disk full")
+    def fail_write_text(self: Path, data: str, encoding: str = "utf-8") -> None:
+        raise OSError("disk full")
 
-	monkeypatch.setattr(Path, "write_text", fail_write_text)
-	save_commit_draft(tmp_path, "ignored")
+    monkeypatch.setattr(Path, "write_text", fail_write_text)
+    save_commit_draft(tmp_path, "ignored")
diff --git a/pygittools/src/pygittools/tui/conftest.py b/pygittools/src/pygittools/tui/conftest.py
index 615f46b..5134001 100644
--- a/pygittools/src/pygittools/tui/conftest.py
+++ b/pygittools/src/pygittools/tui/conftest.py
@@ -18,48 +18,48 @@ SIG = Signature("tester", "tester@example.com", 0)
 
 @pytest.fixture(autouse=True)
 def _drop_unsendable_prompt_state() -> Iterator[None]:
-	# TextState is unsendable. Drop it on this thread once the test and its
-	# fixtures have released the page, before another thread's GC can.
-	yield
-	gc.collect()
+    # TextState is unsendable. Drop it on this thread once the test and its
+    # fixtures have released the page, before another thread's GC can.
+    yield
+    gc.collect()
 
 
 @pytest.fixture(autouse=True)
 def _mock_worktree_list(monkeypatch: pytest.MonkeyPatch) -> None:
-	monkeypatch.setattr(
-		"pygittools.tui.worktrees_list.list_worktrees",
-		lambda _repo: ([], None),
-	)
+    monkeypatch.setattr(
+        "pygittools.tui.worktrees_list.list_worktrees",
+        lambda _repo: ([], None),
+    )
 
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-	return init_repository(str(repo_path), bare=False)
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+    return init_repository(str(repo_path), bare=False)
 
 
 @pytest.fixture
 def page_ctx(repo: Repository) -> PageContext:
-	return PageContext(repo=repo, repo_path=Path(repo.path))
+    return PageContext(repo=repo, repo_path=Path(repo.path))
 
 
 @pytest.fixture
 def committed_repo(repo: Repository) -> Repository:
-	workdir = Path(repo.workdir)
-	(workdir / "tracked.txt").write_text("v1\n", encoding="utf-8")
-	repo.index.add("tracked.txt")
-	repo.index.write()
-	tree = repo.index.write_tree()
-	repo.create_commit("HEAD", SIG, SIG, "initial", tree, [])
-	return repo
+    workdir = Path(repo.workdir)
+    (workdir / "tracked.txt").write_text("v1\n", encoding="utf-8")
+    repo.index.add("tracked.txt")
+    repo.index.write()
+    tree = repo.index.write_tree()
+    repo.create_commit("HEAD", SIG, SIG, "initial", tree, [])
+    return repo
 
 
 @pytest.fixture
 def frame() -> MagicMock:
-	return MagicMock()
+    return MagicMock()
 
 
 @pytest.fixture
 def area() -> Rect:
-	return Rect(0, 0, 80, 24)
+    return Rect(0, 0, 80, 24)
diff --git a/pygittools/src/pygittools/tui/coverage_gaps_test.py b/pygittools/src/pygittools/tui/coverage_gaps_test.py
index bc36fde..92ddde9 100644
--- a/pygittools/src/pygittools/tui/coverage_gaps_test.py
+++ b/pygittools/src/pygittools/tui/coverage_gaps_test.py
@@ -29,280 +29,280 @@ from pygittools.tui.worktrees_list import WorktreesList
 
 
 def test_read_draft_skips_blank_lines(tmp_path: Path) -> None:
-	commit_editmsg_path(tmp_path).write_text("\n\n# comment\n", encoding="utf-8")
-	assert load_commit_draft(tmp_path) == ""
+    commit_editmsg_path(tmp_path).write_text("\n\n# comment\n", encoding="utf-8")
+    assert load_commit_draft(tmp_path) == ""
 
 
 def test_status_bar_tight_left_space() -> None:
-	line = _format_line("left", "hint", 5)
-	assert line == "hint "
+    line = _format_line("left", "hint", 5)
+    assert line == "hint "
 
 
 def test_layout_from_mapping_empty_projects_list() -> None:
-	layout = _layout_from_mapping({"PROJECTROOT": "/tmp", "PROJECTS_LIST": ""})
-	assert layout.projects_list == Path("/tmp")
+    layout = _layout_from_mapping({"PROJECTROOT": "/tmp", "PROJECTS_LIST": ""})
+    assert layout.projects_list == Path("/tmp")
 
 
 def test_int_and_bool_defaults() -> None:
-	assert _int_value(object(), 9) == 9
-	assert _bool_value(object(), False) is False
+    assert _int_value(object(), 9) == 9
+    assert _bool_value(object(), False) is False
 
 
 def test_changes_list_working_tree_clean_hint() -> None:
-	changes = ChangesList()
-	assert changes.status_hint() == "Working tree clean"
+    changes = ChangesList()
+    assert changes.status_hint() == "Working tree clean"
 
 
 def test_changes_list_input_unknown_key(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	assert changes.handle_input_key(key("Null"), committed_repo).layout_changed is False
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    assert changes.handle_input_key(key("Null"), committed_repo).layout_changed is False
 
 
 def test_changes_list_nav_with_no_rows_after_collapse(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	changes._rows = []
-	assert changes.handle_nav_key(key("Up"), committed_repo).layout_changed is False
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    changes._rows = []
+    assert changes.handle_nav_key(key("Up"), committed_repo).layout_changed is False
 
 
 def test_changes_list_commit_hints_and_edit(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.set_commit_message("msg")
-	changes.refresh(committed_repo)
-	changes._list_state.select(0)
-	assert "Enter edit" in changes.status_hint()
-	assert changes.handle_nav_key(key("Enter"), committed_repo).enter_input is True
-	changes.handle_input_key(key("Backspace"), committed_repo)
-	assert changes.commit_message == "ms"
+    changes = ChangesList()
+    changes.set_commit_message("msg")
+    changes.refresh(committed_repo)
+    changes._list_state.select(0)
+    assert "Enter edit" in changes.status_hint()
+    assert changes.handle_nav_key(key("Enter"), committed_repo).enter_input is True
+    changes.handle_input_key(key("Backspace"), committed_repo)
+    assert changes.commit_message == "ms"
 
 
 def test_changes_list_staged_header_unstage_all(committed_repo: Repository) -> None:
-	path = Path(committed_repo.workdir) / "new.txt"
-	path.write_text("x\n", encoding="utf-8")
-	committed_repo.index.add("new.txt")
-	committed_repo.index.write()
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	for index, row in enumerate(changes._rows):
-		if row.kind == "header" and row.section == SectionId.STAGED:
-			changes._list_state.select(index)
-			break
-	changes.handle_nav_key(key("Enter"), committed_repo)
+    path = Path(committed_repo.workdir) / "new.txt"
+    path.write_text("x\n", encoding="utf-8")
+    committed_repo.index.add("new.txt")
+    committed_repo.index.write()
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    for index, row in enumerate(changes._rows):
+        if row.kind == "header" and row.section == SectionId.STAGED:
+            changes._list_state.select(index)
+            break
+    changes.handle_nav_key(key("Enter"), committed_repo)
 
 
 def test_changes_list_unstage_file_row(committed_repo: Repository) -> None:
-	path = Path(committed_repo.workdir) / "new.txt"
-	path.write_text("x\n", encoding="utf-8")
-	committed_repo.index.add("new.txt")
-	committed_repo.index.write()
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	for index, row in enumerate(changes._rows):
-		if row.kind == "file" and row.section == SectionId.STAGED:
-			changes._list_state.select(index)
-			changes.handle_nav_key(key("Enter"), committed_repo)
-			break
+    path = Path(committed_repo.workdir) / "new.txt"
+    path.write_text("x\n", encoding="utf-8")
+    committed_repo.index.add("new.txt")
+    committed_repo.index.write()
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    for index, row in enumerate(changes._rows):
+        if row.kind == "file" and row.section == SectionId.STAGED:
+            changes._list_state.select(index)
+            changes.handle_nav_key(key("Enter"), committed_repo)
+            break
 
 
 def test_changes_list_header_right_key(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.refresh(committed_repo)
-	for index, row in enumerate(changes._rows):
-		if row.kind == "header":
-			changes._list_state.select(index)
-			changes.handle_nav_key(key("Right"), committed_repo)
-			break
+    changes = ChangesList()
+    changes.refresh(committed_repo)
+    for index, row in enumerate(changes._rows):
+        if row.kind == "header":
+            changes._list_state.select(index)
+            changes.handle_nav_key(key("Right"), committed_repo)
+            break
 
 
 def test_changes_list_failed_commit_nav(committed_repo: Repository) -> None:
-	changes = ChangesList()
-	changes.set_commit_message("msg")
-	changes.refresh(committed_repo)
-	changes._list_state.select(0)
-	changes.handle_nav_key(key("Enter"), committed_repo)
-	assert changes.status_hint()
+    changes = ChangesList()
+    changes.set_commit_message("msg")
+    changes.refresh(committed_repo)
+    changes._list_state.select(0)
+    changes.handle_nav_key(key("Enter"), committed_repo)
+    assert changes.status_hint()
 
 
 def test_log_list_scroll_and_refresh(committed_repo: Repository) -> None:
-	log = CommitLogList()
-	log.refresh(committed_repo)
-	log.draw(MagicMock(), Rect(0, 0, 80, 0), highlight=True)
-	log._list_state.select(100)
-	log.draw(MagicMock(), Rect(0, 0, 80, 2), highlight=True)
-	log.handle_nav_key(key("Up"), committed_repo)
+    log = CommitLogList()
+    log.refresh(committed_repo)
+    log.draw(MagicMock(), Rect(0, 0, 80, 0), highlight=True)
+    log._list_state.select(100)
+    log.draw(MagicMock(), Rect(0, 0, 80, 2), highlight=True)
+    log.handle_nav_key(key("Up"), committed_repo)
 
 
 def test_input_context_log_tab_at_top(page_ctx: PageContext) -> None:
-	host = FakeHost(
-		page_context=page_ctx,
-		changes=ChangesList(),
-		log=CommitLogList(),
-		tasks=TasksBoardList(),
-		worktrees=WorktreesList(),
-	)
-	host.active_tab = "log"
-	host.log._list_state.select(0)
-	nav_context().dispatch_key(host, key("Up"))
-	assert host.header_focus == "branch"
+    host = FakeHost(
+        page_context=page_ctx,
+        changes=ChangesList(),
+        log=CommitLogList(),
+        tasks=TasksBoardList(),
+        worktrees=WorktreesList(),
+    )
+    host.active_tab = "log"
+    host.log._list_state.select(0)
+    nav_context().dispatch_key(host, key("Up"))
+    assert host.header_focus == "branch"
 
 
 def test_input_context_input_without_relinquish(page_ctx: PageContext) -> None:
-	host = FakeHost(
-		page_context=page_ctx,
-		changes=ChangesList(),
-		log=CommitLogList(),
-		tasks=TasksBoardList(),
-		worktrees=WorktreesList(),
-	)
-	dispatch = InputContext().dispatch_key(host, key("x"))
-	assert dispatch.next_mode is None
+    host = FakeHost(
+        page_context=page_ctx,
+        changes=ChangesList(),
+        log=CommitLogList(),
+        tasks=TasksBoardList(),
+        worktrees=WorktreesList(),
+    )
+    dispatch = InputContext().dispatch_key(host, key("x"))
+    assert dispatch.next_mode is None
 
 
 def test_branch_picker_error_and_short_list(committed_repo: Repository) -> None:
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = BranchPickerPage()
-	page.on_enter(ctx)
-	page._error = "bad"
-	assert "bad" in page.status_text()
-	page.draw(MagicMock(), Rect(0, 0, 80, 2))
-	page._ctx = None
-	page.draw(MagicMock(), Rect(0, 0, 80, 10))
-	page.handle_key(key("Enter"))
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = BranchPickerPage()
+    page.on_enter(ctx)
+    page._error = "bad"
+    assert "bad" in page.status_text()
+    page.draw(MagicMock(), Rect(0, 0, 80, 2))
+    page._ctx = None
+    page.draw(MagicMock(), Rect(0, 0, 80, 10))
+    page.handle_key(key("Enter"))
 
 
 def test_branch_picker_checkout_error(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = BranchPickerPage()
-	page.on_enter(ctx)
-	page._filtered = ["ghost"]
-	page._focus_filter = False
-	page._list_state.select(0)
-	monkeypatch.setattr(
-		"pygittools.tui.pages.branches.checkout_branch",
-		lambda _repo, _branch: (False, "nope"),
-	)
-	page.handle_key(key("Enter"))
-	assert page.status_text() == "nope"
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = BranchPickerPage()
+    page.on_enter(ctx)
+    page._filtered = ["ghost"]
+    page._focus_filter = False
+    page._list_state.select(0)
+    monkeypatch.setattr(
+        "pygittools.tui.pages.branches.checkout_branch",
+        lambda _repo, _branch: (False, "nope"),
+    )
+    page.handle_key(key("Enter"))
+    assert page.status_text() == "nope"
 
 
 def test_help_page_empty_lines(monkeypatch: pytest.MonkeyPatch) -> None:
-	page = HelpPage()
-	monkeypatch.setattr(page, "_lines", [])
-	assert page.status_text() == "Help"
-	page.draw(MagicMock(), Rect(0, 0, 80, 0))
+    page = HelpPage()
+    monkeypatch.setattr(page, "_lines", [])
+    assert page.status_text() == "Help"
+    page.draw(MagicMock(), Rect(0, 0, 80, 0))
 
 
 def test_help_page_scroll_past_end() -> None:
-	page = HelpPage()
-	page._state.select(999)
-	page.draw(MagicMock(), Rect(0, 0, 80, 3))
-	page.handle_key(key("Null"))
+    page = HelpPage()
+    page._state.select(999)
+    page.draw(MagicMock(), Rect(0, 0, 80, 3))
+    page.handle_key(key("Null"))
 
 
 def test_home_page_relinquish_input(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = home_page()
-	page.on_enter(ctx)
-	page.handle_key(key("/"))
-	for char in "commit":
-		page.handle_key(key(char))
-	page.handle_key(key("Enter"))
-	page.handle_key(key("a"))
-	monkeypatch.setattr(
-		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg, *, amend=False: (True, ""),
-	)
-	page.handle_key(key("Tab"))
-	page.handle_key(key("Enter"))
-	assert page.status_text()
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = home_page()
+    page.on_enter(ctx)
+    page.handle_key(key("/"))
+    for char in "commit":
+        page.handle_key(key(char))
+    page.handle_key(key("Enter"))
+    page.handle_key(key("a"))
+    monkeypatch.setattr(
+        "pygittools.tui.changes_list.commit_staged",
+        lambda _repo, _msg, *, amend=False: (True, ""),
+    )
+    page.handle_key(key("Tab"))
+    page.handle_key(key("Enter"))
+    assert page.status_text()
 
 
 def test_no_repo_page_truncates_lines(tmp_path: Path) -> None:
-	page = NoRepoPage(tmp_path)
-	page.draw(MagicMock(), Rect(0, 0, 80, 1))
-	page.handle_key(key("Null"))
+    page = NoRepoPage(tmp_path)
+    page.draw(MagicMock(), Rect(0, 0, 80, 1))
+    page.handle_key(key("Null"))
 
 
 def test_projects_page_error_and_scroll(tmp_path: Path) -> None:
-	page = ProjectsPage()
-	page._projects = [
-		ProjectEntry(path="x" * 100, worktree=tmp_path, branch="b" * 30),
-	]
-	page._projectroot_label = "root"
-	page._error = "bad"
-	assert page.status_text() == "bad"
-	page.draw(MagicMock(), Rect(0, 0, 40, 5))
-	page.handle_key(key("Up"))
-	page.handle_key(key("q"))
+    page = ProjectsPage()
+    page._projects = [
+        ProjectEntry(path="x" * 100, worktree=tmp_path, branch="b" * 30),
+    ]
+    page._projectroot_label = "root"
+    page._error = "bad"
+    assert page.status_text() == "bad"
+    page.draw(MagicMock(), Rect(0, 0, 40, 5))
+    page.handle_key(key("Up"))
+    page.handle_key(key("q"))
 
 
 def test_unstage_paths_empty(committed_repo: Repository) -> None:
-	unstage_paths(committed_repo, [])
+    unstage_paths(committed_repo, [])
 
 
 def test_commit_staged_no_workdir(tmp_path: Path) -> None:
-	bare = init_repository(str(tmp_path / "bare"), bare=True)
-	ok, error = commit_staged(bare, "msg")
-	assert ok is False
-	assert error == "Repository has no working directory"
+    bare = init_repository(str(tmp_path / "bare"), bare=True)
+    ok, error = commit_staged(bare, "msg")
+    assert ok is False
+    assert error == "Repository has no working directory"
 
 
 def test_apply_result_switch_repo_missing(tmp_path: Path, page_ctx: PageContext) -> None:
-	home = home_page()
-	home.on_enter(page_ctx)
-	ctx, stack = _apply_result(
-		[home],
-		page_ctx,
-		PageResult(action=PageAction.POP, switch_repo=tmp_path / "missing"),
-	)
-	assert ctx is page_ctx
+    home = home_page()
+    home.on_enter(page_ctx)
+    ctx, stack = _apply_result(
+        [home],
+        page_ctx,
+        PageResult(action=PageAction.POP, switch_repo=tmp_path / "missing"),
+    )
+    assert ctx is page_ctx
 
 
 class _FakeTerm:
-	def __init__(self, keys: list[object]) -> None:
-		self._keys = list(keys)
-		self.hide_cursor = MagicMock()
+    def __init__(self, keys: list[object]) -> None:
+        self._keys = list(keys)
+        self.hide_cursor = MagicMock()
 
-	def __enter__(self) -> _FakeTerm:
-		return self
+    def __enter__(self) -> _FakeTerm:
+        return self
 
-	def __exit__(self, *args: object) -> bool:
-		return False
+    def __exit__(self, *args: object) -> bool:
+        return False
 
-	def draw(self, draw_fn: object) -> None:
-		frame = MagicMock()
-		frame.area = Rect(0, 0, 80, 24)
-		draw_fn(frame)  # type: ignore[operator]
+    def draw(self, draw_fn: object) -> None:
+        frame = MagicMock()
+        frame.area = Rect(0, 0, 80, 24)
+        draw_fn(frame)  # type: ignore[operator]
 
-	def poll_event(self, timeout_ms: int = 0) -> object | None:
-		del timeout_ms
-		if not self._keys:
-			return key("q")
-		return self._keys.pop(0)
+    def poll_event(self, timeout_ms: int = 0) -> object | None:
+        del timeout_ms
+        if not self._keys:
+            return key("q")
+        return self._keys.pop(0)
 
 
 def test_run_tui_keyboard_interrupt(committed_repo: Repository) -> None:
-	repo_dir = Path(committed_repo.workdir)
+    repo_dir = Path(committed_repo.workdir)
 
-	class BoomTerm(_FakeTerm):
-		def poll_event(self, timeout_ms: int = 0) -> object | None:
-			del timeout_ms
-			raise KeyboardInterrupt
+    class BoomTerm(_FakeTerm):
+        def poll_event(self, timeout_ms: int = 0) -> object | None:
+            del timeout_ms
+            raise KeyboardInterrupt
 
-	with (
-		patch("pygittools.tui.app.Terminal", return_value=BoomTerm([])),
-		patch("pygittools.tui.app.init_tui_color_theme"),
-		patch("pygittools.tui.app.init_tab_colors"),
-	):
-		assert run_tui(repo_dir) == 0
+    with (
+        patch("pygittools.tui.app.Terminal", return_value=BoomTerm([])),
+        patch("pygittools.tui.app.init_tui_color_theme"),
+        patch("pygittools.tui.app.init_tab_colors"),
+    ):
+        assert run_tui(repo_dir) == 0
 
 
 def test_run_tui_idle_then_quit(committed_repo: Repository) -> None:
-	repo_dir = Path(committed_repo.workdir)
-	with (
-		patch("pygittools.tui.app.Terminal", return_value=_FakeTerm([None, key("q")])),
-		patch("pygittools.tui.app.init_tui_color_theme"),
-		patch("pygittools.tui.app.init_tab_colors"),
-	):
-		assert run_tui(repo_dir) == 0
+    repo_dir = Path(committed_repo.workdir)
+    with (
+        patch("pygittools.tui.app.Terminal", return_value=_FakeTerm([None, key("q")])),
+        patch("pygittools.tui.app.init_tui_color_theme"),
+        patch("pygittools.tui.app.init_tab_colors"),
+    ):
+        assert run_tui(repo_dir) == 0
diff --git a/pygittools/src/pygittools/tui/git_colors.py b/pygittools/src/pygittools/tui/git_colors.py
index 14fed8e..7701707 100644
--- a/pygittools/src/pygittools/tui/git_colors.py
+++ b/pygittools/src/pygittools/tui/git_colors.py
@@ -11,44 +11,44 @@ from pygit2 import Config
 from pyratatui import Color, Modifier, Style
 
 if TYPE_CHECKING:
-	from pygit2 import Repository
+    from pygit2 import Repository
 
 _NAMED_RGB: dict[str, tuple[int, int, int]] = {
-	"normal": (204, 204, 204),
-	"black": (0, 0, 0),
-	"red": (170, 0, 0),
-	"green": (0, 170, 0),
-	"yellow": (170, 170, 0),
-	"blue": (0, 0, 170),
-	"magenta": (170, 0, 170),
-	"cyan": (0, 170, 170),
-	"white": (204, 204, 204),
+    "normal": (204, 204, 204),
+    "black": (0, 0, 0),
+    "red": (170, 0, 0),
+    "green": (0, 170, 0),
+    "yellow": (170, 170, 0),
+    "blue": (0, 0, 170),
+    "magenta": (170, 0, 170),
+    "cyan": (0, 170, 170),
+    "white": (204, 204, 204),
 }
 
 _BRIGHT_NAMED_RGB: dict[str, tuple[int, int, int]] = {
-	"black": (85, 85, 85),
-	"red": (255, 0, 0),
-	"green": (0, 255, 0),
-	"yellow": (255, 255, 0),
-	"blue": (0, 0, 255),
-	"magenta": (255, 0, 255),
-	"cyan": (0, 255, 255),
-	"white": (255, 255, 255),
+    "black": (85, 85, 85),
+    "red": (255, 0, 0),
+    "green": (0, 255, 0),
+    "yellow": (255, 255, 0),
+    "blue": (0, 0, 255),
+    "magenta": (255, 0, 255),
+    "cyan": (0, 255, 255),
+    "white": (255, 255, 255),
 }
 
 _DEFAULT_SPECS: dict[str, str] = {
-	"color.status.added": "green",
-	"color.status.changed": "red",
-	"color.status.untracked": "red",
-	"color.status.header": "cyan",
-	"color.status.branch": "cyan",
-	"color.status.nobranch": "red bold",
-	"color.branch.current": "cyan",
-	"color.branch.local": "cyan",
-	"color.diff.new": "green",
-	"color.diff.old": "red",
-	"color.diff.context": "normal",
-	"color.decorate.commit": "yellow",
+    "color.status.added": "green",
+    "color.status.changed": "red",
+    "color.status.untracked": "red",
+    "color.status.header": "cyan",
+    "color.status.branch": "cyan",
+    "color.status.nobranch": "red bold",
+    "color.branch.current": "cyan",
+    "color.branch.local": "cyan",
+    "color.diff.new": "green",
+    "color.diff.old": "red",
+    "color.diff.context": "normal",
+    "color.decorate.commit": "yellow",
 }
 
 _DEFAULT_SECTION_HEADER_SPEC = "252"
@@ -58,362 +58,362 @@ _FALSE_VALUES = frozenset({"0", "false", "never", "no", "off"})
 
 @dataclass(frozen=True, slots=True)
 class ParsedGitColor:
-	foreground: tuple[int, int, int] | None
-	background: tuple[int, int, int] | None
-	bold: bool = False
-	dim: bool = False
-	reverse: bool = False
-	underline: bool = False
+    foreground: tuple[int, int, int] | None
+    background: tuple[int, int, int] | None
+    bold: bool = False
+    dim: bool = False
+    reverse: bool = False
+    underline: bool = False
 
 
 @dataclass(frozen=True, slots=True)
 class TuiColorTheme:
-	enabled: bool
-	status_bar: Style
-	tab_active: Style
-	tab_inactive: Style
-	focus: Style
-	staged: Style
-	unstaged: Style
-	untracked: Style
-	section_header: Style
-	header: Style
-	branch: Style
-	commit: Style
-	log_hash: Style
-	log_date: Style
-	log_subject: Style
-	normal: Style
+    enabled: bool
+    status_bar: Style
+    tab_active: Style
+    tab_inactive: Style
+    focus: Style
+    staged: Style
+    unstaged: Style
+    untracked: Style
+    section_header: Style
+    header: Style
+    branch: Style
+    commit: Style
+    log_hash: Style
+    log_date: Style
+    log_subject: Style
+    normal: Style
 
 
 def _style() -> Style:
-	return Style()
+    return Style()
 
 
 _theme = TuiColorTheme(
-	enabled=False,
-	status_bar=_style().add_modifier(Modifier.reversed()),
-	tab_active=_style().bold(),
-	tab_inactive=_style(),
-	focus=_style().add_modifier(Modifier.reversed()),
-	staged=_style(),
-	unstaged=_style(),
-	untracked=_style(),
-	section_header=_style(),
-	header=_style().bold(),
-	branch=_style(),
-	commit=_style(),
-	log_hash=_style(),
-	log_date=_style(),
-	log_subject=_style(),
-	normal=_style(),
+    enabled=False,
+    status_bar=_style().add_modifier(Modifier.reversed()),
+    tab_active=_style().bold(),
+    tab_inactive=_style(),
+    focus=_style().add_modifier(Modifier.reversed()),
+    staged=_style(),
+    unstaged=_style(),
+    untracked=_style(),
+    section_header=_style(),
+    header=_style().bold(),
+    branch=_style(),
+    commit=_style(),
+    log_hash=_style(),
+    log_date=_style(),
+    log_subject=_style(),
+    normal=_style(),
 )
 
 
 def current_theme() -> TuiColorTheme:
-	return _theme
+    return _theme
 
 
 def status_bar_style() -> Style:
-	return _theme.status_bar
+    return _theme.status_bar
 
 
 def active_tab_style() -> Style:
-	return _theme.tab_active
+    return _theme.tab_active
 
 
 def init_tui_color_theme(repo: Repository | None) -> TuiColorTheme:
-	"""Read Git color settings and build pyratatui styles."""
-	global _theme
-	config = _load_config(repo)
-	if not _colors_enabled(config):
-		_theme = _monochrome_theme()
-		return _theme
-
-	palette = _load_palette()
-	specs = _load_color_specs(config)
-	header = _style_from_spec(
-		specs.get("color.status.header", _DEFAULT_SPECS["color.status.header"]),
-		palette,
-		fallback_fg=Color.white(),
-		extra_bold=True,
-	)
-	_theme = TuiColorTheme(
-		enabled=True,
-		status_bar=_style_from_spec(
-			specs.get("color.status.header", _DEFAULT_SPECS["color.status.header"]),
-			palette,
-			fallback_fg=Color.white(),
-			fallback_bg=Color.blue(),
-		),
-		tab_active=_style_from_spec(
-			specs.get("color.branch.current", _DEFAULT_SPECS["color.branch.current"]),
-			palette,
-			fallback_fg=Color.indexed(39),
-			fallback_bg=Color.indexed(8),
-			ignore_spec_background=True,
-		),
-		tab_inactive=_style(),
-		focus=_style().add_modifier(Modifier.reversed()),
-		staged=_style_from_spec(
-			specs.get("color.status.added", _DEFAULT_SPECS["color.status.added"]),
-			palette,
-			fallback_fg=Color.green(),
-		),
-		unstaged=_style_from_spec(
-			specs.get("color.status.changed", _DEFAULT_SPECS["color.status.changed"]),
-			palette,
-			fallback_fg=Color.red(),
-		),
-		untracked=_style_from_spec(
-			specs.get("color.status.untracked", _DEFAULT_SPECS["color.status.untracked"]),
-			palette,
-			fallback_fg=Color.red(),
-		),
-		section_header=_style_from_spec(
-			_DEFAULT_SECTION_HEADER_SPEC,
-			palette,
-			fallback_fg=Color.white(),
-		),
-		header=header,
-		branch=_style_from_spec(
-			specs.get("color.status.branch", _DEFAULT_SPECS["color.status.branch"]),
-			palette,
-			fallback_fg=Color.cyan(),
-		),
-		commit=_style_from_spec(
-			specs.get("color.diff.new", _DEFAULT_SPECS["color.diff.new"]),
-			palette,
-			fallback_fg=Color.green(),
-		),
-		log_hash=_style_from_spec(
-			specs.get("color.decorate.commit", _DEFAULT_SPECS["color.decorate.commit"]),
-			palette,
-			fallback_fg=Color.yellow(),
-		),
-		log_date=_style_from_spec(
-			_DEFAULT_LOG_DATE_SPEC,
-			palette,
-			fallback_fg=Color.white(),
-		),
-		log_subject=_style_from_spec(
-			specs.get("color.diff.context", _DEFAULT_SPECS["color.diff.context"]),
-			palette,
-			fallback_fg=Color.white(),
-		),
-		normal=_style(),
-	)
-	return _theme
+    """Read Git color settings and build pyratatui styles."""
+    global _theme
+    config = _load_config(repo)
+    if not _colors_enabled(config):
+        _theme = _monochrome_theme()
+        return _theme
+
+    palette = _load_palette()
+    specs = _load_color_specs(config)
+    header = _style_from_spec(
+        specs.get("color.status.header", _DEFAULT_SPECS["color.status.header"]),
+        palette,
+        fallback_fg=Color.white(),
+        extra_bold=True,
+    )
+    _theme = TuiColorTheme(
+        enabled=True,
+        status_bar=_style_from_spec(
+            specs.get("color.status.header", _DEFAULT_SPECS["color.status.header"]),
+            palette,
+            fallback_fg=Color.white(),
+            fallback_bg=Color.blue(),
+        ),
+        tab_active=_style_from_spec(
+            specs.get("color.branch.current", _DEFAULT_SPECS["color.branch.current"]),
+            palette,
+            fallback_fg=Color.indexed(39),
+            fallback_bg=Color.indexed(8),
+            ignore_spec_background=True,
+        ),
+        tab_inactive=_style(),
+        focus=_style().add_modifier(Modifier.reversed()),
+        staged=_style_from_spec(
+            specs.get("color.status.added", _DEFAULT_SPECS["color.status.added"]),
+            palette,
+            fallback_fg=Color.green(),
+        ),
+        unstaged=_style_from_spec(
+            specs.get("color.status.changed", _DEFAULT_SPECS["color.status.changed"]),
+            palette,
+            fallback_fg=Color.red(),
+        ),
+        untracked=_style_from_spec(
+            specs.get("color.status.untracked", _DEFAULT_SPECS["color.status.untracked"]),
+            palette,
+            fallback_fg=Color.red(),
+        ),
+        section_header=_style_from_spec(
+            _DEFAULT_SECTION_HEADER_SPEC,
+            palette,
+            fallback_fg=Color.white(),
+        ),
+        header=header,
+        branch=_style_from_spec(
+            specs.get("color.status.branch", _DEFAULT_SPECS["color.status.branch"]),
+            palette,
+            fallback_fg=Color.cyan(),
+        ),
+        commit=_style_from_spec(
+            specs.get("color.diff.new", _DEFAULT_SPECS["color.diff.new"]),
+            palette,
+            fallback_fg=Color.green(),
+        ),
+        log_hash=_style_from_spec(
+            specs.get("color.decorate.commit", _DEFAULT_SPECS["color.decorate.commit"]),
+            palette,
+            fallback_fg=Color.yellow(),
+        ),
+        log_date=_style_from_spec(
+            _DEFAULT_LOG_DATE_SPEC,
+            palette,
+            fallback_fg=Color.white(),
+        ),
+        log_subject=_style_from_spec(
+            specs.get("color.diff.context", _DEFAULT_SPECS["color.diff.context"]),
+            palette,
+            fallback_fg=Color.white(),
+        ),
+        normal=_style(),
+    )
+    return _theme
 
 
 def parse_git_color_spec(spec: str | None) -> ParsedGitColor:
-	if spec is None or not spec.strip():
-		return ParsedGitColor(foreground=None, background=None)
-	foreground: tuple[int, int, int] | None = None
-	background: tuple[int, int, int] | None = None
-	bold = False
-	dim = False
-	reverse = False
-	underline = False
-	palette = _load_palette()
-	for raw_token in spec.strip().split():
-		token = raw_token.casefold()
-		if token.startswith("no-"):
-			token = token[3:]
-		if token in {"bold", "standout"}:
-			bold = not raw_token.casefold().startswith("no")
-			continue
-		if token == "dim":
-			dim = not raw_token.casefold().startswith("no")
-			continue
-		if token == "reverse":
-			reverse = not raw_token.casefold().startswith("no")
-			continue
-		if token in {"ul", "underline"}:
-			underline = not raw_token.casefold().startswith("no")
-			continue
-		if token in {"italic", "blink", "strike"}:
-			continue
-		rgb = _token_to_rgb(raw_token, palette)
-		if rgb is None:
-			continue
-		if foreground is None:
-			foreground = rgb
-		elif background is None:
-			background = rgb
-	return ParsedGitColor(
-		foreground=foreground,
-		background=background,
-		bold=bold,
-		dim=dim,
-		reverse=reverse,
-		underline=underline,
-	)
+    if spec is None or not spec.strip():
+        return ParsedGitColor(foreground=None, background=None)
+    foreground: tuple[int, int, int] | None = None
+    background: tuple[int, int, int] | None = None
+    bold = False
+    dim = False
+    reverse = False
+    underline = False
+    palette = _load_palette()
+    for raw_token in spec.strip().split():
+        token = raw_token.casefold()
+        if token.startswith("no-"):
+            token = token[3:]
+        if token in {"bold", "standout"}:
+            bold = not raw_token.casefold().startswith("no")
+            continue
+        if token == "dim":
+            dim = not raw_token.casefold().startswith("no")
+            continue
+        if token == "reverse":
+            reverse = not raw_token.casefold().startswith("no")
+            continue
+        if token in {"ul", "underline"}:
+            underline = not raw_token.casefold().startswith("no")
+            continue
+        if token in {"italic", "blink", "strike"}:
+            continue
+        rgb = _token_to_rgb(raw_token, palette)
+        if rgb is None:
+            continue
+        if foreground is None:
+            foreground = rgb
+        elif background is None:
+            background = rgb
+    return ParsedGitColor(
+        foreground=foreground,
+        background=background,
+        bold=bold,
+        dim=dim,
+        reverse=reverse,
+        underline=underline,
+    )
 
 
 def focus_style(base: Style) -> Style:
-	return base.patch(_theme.focus)
+    return base.patch(_theme.focus)
 
 
 def row_style_for_section(
-	section: str | None,
-	*,
-	focused: bool,
-	highlight: bool,
-	is_header: bool = False,
+    section: str | None,
+    *,
+    focused: bool,
+    highlight: bool,
+    is_header: bool = False,
 ) -> Style:
-	theme = _theme
-	if is_header:
-		base = theme.section_header
-	elif section == "staged":
-		base = theme.staged
-	elif section == "unstaged":
-		base = theme.unstaged
-	elif section == "untracked":
-		base = theme.untracked
-	else:
-		base = theme.normal
-	if focused and highlight:
-		return focus_style(base)
-	return base
+    theme = _theme
+    if is_header:
+        base = theme.section_header
+    elif section == "staged":
+        base = theme.staged
+    elif section == "unstaged":
+        base = theme.unstaged
+    elif section == "untracked":
+        base = theme.untracked
+    else:
+        base = theme.normal
+    if focused and highlight:
+        return focus_style(base)
+    return base
 
 
 def log_entry_styles(*, focused: bool, highlight: bool) -> tuple[Style, Style, Style]:
-	theme = _theme
-	if focused and highlight:
-		return (
-			focus_style(theme.log_hash),
-			focus_style(theme.log_date),
-			focus_style(theme.log_subject),
-		)
-	return (theme.log_hash, theme.log_date, theme.log_subject)
+    theme = _theme
+    if focused and highlight:
+        return (
+            focus_style(theme.log_hash),
+            focus_style(theme.log_date),
+            focus_style(theme.log_subject),
+        )
+    return (theme.log_hash, theme.log_date, theme.log_subject)
 
 
 def _load_config(repo: Repository | None) -> Config:
-	if repo is not None:
-		return repo.config.snapshot()
-	return Config.get_global_config()
+    if repo is not None:
+        return repo.config.snapshot()
+    return Config.get_global_config()
 
 
 def _config_value(config: Config, key: str) -> str | None:
-	try:
-		values = [value for value in config.get_multivar(key) if isinstance(value, str)]
-	except KeyError:
-		return None
-	if not values:
-		return None
-	return values[-1].strip()
+    try:
+        values = [value for value in config.get_multivar(key) if isinstance(value, str)]
+    except KeyError:
+        return None
+    if not values:
+        return None
+    return values[-1].strip()
 
 
 def _colors_enabled(config: Config) -> bool:
-	ui = _config_value(config, "color.ui")
-	if ui is not None and ui.casefold() in _FALSE_VALUES:
-		return False
-	status = _config_value(config, "color.status")
-	return status is None or status.casefold() not in _FALSE_VALUES
+    ui = _config_value(config, "color.ui")
+    if ui is not None and ui.casefold() in _FALSE_VALUES:
+        return False
+    status = _config_value(config, "color.status")
+    return status is None or status.casefold() not in _FALSE_VALUES
 
 
 def _load_color_specs(config: Config) -> dict[str, str]:
-	specs = dict(_DEFAULT_SPECS)
-	for key in _DEFAULT_SPECS:
-		value = _config_value(config, key)
-		if value:
-			specs[key] = value
-	return specs
+    specs = dict(_DEFAULT_SPECS)
+    for key in _DEFAULT_SPECS:
+        value = _config_value(config, key)
+        if value:
+            specs[key] = value
+    return specs
 
 
 def _load_palette() -> tuple[tuple[int, int, int], ...]:
-	raw = json.loads(files("pygittools.tui").joinpath("colors.json").read_text(encoding="utf-8"))
-	palette: list[tuple[int, int, int]] = []
-	for entry in raw:
-		text = str(entry).strip()
-		if not text.startswith("#") or len(text) not in {4, 7}:
-			palette.append((0, 0, 0))
-			continue
-		if len(text) == 4:
-			text = f"#{text[1]}{text[1]}{text[2]}{text[2]}{text[3]}{text[3]}"
-		palette.append((int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16)))
-	return tuple(palette)
+    raw = json.loads(files("pygittools.tui").joinpath("colors.json").read_text(encoding="utf-8"))
+    palette: list[tuple[int, int, int]] = []
+    for entry in raw:
+        text = str(entry).strip()
+        if not text.startswith("#") or len(text) not in {4, 7}:
+            palette.append((0, 0, 0))
+            continue
+        if len(text) == 4:
+            text = f"#{text[1]}{text[1]}{text[2]}{text[2]}{text[3]}{text[3]}"
+        palette.append((int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16)))
+    return tuple(palette)
 
 
 def _token_to_rgb(token: str, palette: tuple[tuple[int, int, int], ...]) -> tuple[int, int, int] | None:
-	lowered = token.casefold()
-	if lowered == "default":
-		return None
-	if token.startswith("#"):
-		text = token
-		if len(text) == 4:
-			text = f"#{text[1]}{text[1]}{text[2]}{text[2]}{text[3]}{text[3]}"
-		if len(text) == 7:
-			return (int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16))
-		return None
-	if token.isdecimal():
-		index = int(token)
-		if 0 <= index < len(palette):
-			return palette[index]
-		return None
-	bright = False
-	name = lowered
-	if name.startswith("bright"):
-		bright = True
-		name = name[6:]
-	if name in _NAMED_RGB:
-		if bright and name in _BRIGHT_NAMED_RGB:
-			return _BRIGHT_NAMED_RGB[name]
-		return _NAMED_RGB[name]
-	return None
+    lowered = token.casefold()
+    if lowered == "default":
+        return None
+    if token.startswith("#"):
+        text = token
+        if len(text) == 4:
+            text = f"#{text[1]}{text[1]}{text[2]}{text[2]}{text[3]}{text[3]}"
+        if len(text) == 7:
+            return (int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16))
+        return None
+    if token.isdecimal():
+        index = int(token)
+        if 0 <= index < len(palette):
+            return palette[index]
+        return None
+    bright = False
+    name = lowered
+    if name.startswith("bright"):
+        bright = True
+        name = name[6:]
+    if name in _NAMED_RGB:
+        if bright and name in _BRIGHT_NAMED_RGB:
+            return _BRIGHT_NAMED_RGB[name]
+        return _NAMED_RGB[name]
+    return None
 
 
 def _color_from_rgb(rgb: tuple[int, int, int] | None, fallback: Color) -> Color:
-	if rgb is None:
-		return fallback
-	return Color.rgb(rgb[0], rgb[1], rgb[2])
+    if rgb is None:
+        return fallback
+    return Color.rgb(rgb[0], rgb[1], rgb[2])
 
 
 def _style_from_spec(
-	spec: str,
-	palette: tuple[tuple[int, int, int], ...],
-	*,
-	fallback_fg: Color,
-	fallback_bg: Color | None = None,
-	extra_bold: bool = False,
-	ignore_spec_background: bool = False,
+    spec: str,
+    palette: tuple[tuple[int, int, int], ...],
+    *,
+    fallback_fg: Color,
+    fallback_bg: Color | None = None,
+    extra_bold: bool = False,
+    ignore_spec_background: bool = False,
 ) -> Style:
-	del palette
-	parsed = parse_git_color_spec(spec)
-	style = _style().fg(_color_from_rgb(parsed.foreground, fallback_fg))
-	bg = None if ignore_spec_background else parsed.background
-	if bg is not None:
-		style = style.bg(Color.rgb(bg[0], bg[1], bg[2]))
-	elif fallback_bg is not None:
-		style = style.bg(fallback_bg)
-	if parsed.bold or extra_bold:
-		style = style.bold()
-	if parsed.dim:
-		style = style.dim()
-	if parsed.underline:
-		style = style.underlined()
-	if parsed.reverse:
-		style = style.add_modifier(Modifier.reversed())
-	return style
+    del palette
+    parsed = parse_git_color_spec(spec)
+    style = _style().fg(_color_from_rgb(parsed.foreground, fallback_fg))
+    bg = None if ignore_spec_background else parsed.background
+    if bg is not None:
+        style = style.bg(Color.rgb(bg[0], bg[1], bg[2]))
+    elif fallback_bg is not None:
+        style = style.bg(fallback_bg)
+    if parsed.bold or extra_bold:
+        style = style.bold()
+    if parsed.dim:
+        style = style.dim()
+    if parsed.underline:
+        style = style.underlined()
+    if parsed.reverse:
+        style = style.add_modifier(Modifier.reversed())
+    return style
 
 
 def _monochrome_theme() -> TuiColorTheme:
-	return TuiColorTheme(
-		enabled=False,
-		status_bar=_style().add_modifier(Modifier.reversed()),
-		tab_active=_style().bold(),
-		tab_inactive=_style(),
-		focus=_style().add_modifier(Modifier.reversed()),
-		staged=_style(),
-		unstaged=_style(),
-		untracked=_style(),
-		section_header=_style(),
-		header=_style().bold(),
-		branch=_style(),
-		commit=_style(),
-		log_hash=_style(),
-		log_date=_style(),
-		log_subject=_style(),
-		normal=_style(),
-	)
+    return TuiColorTheme(
+        enabled=False,
+        status_bar=_style().add_modifier(Modifier.reversed()),
+        tab_active=_style().bold(),
+        tab_inactive=_style(),
+        focus=_style().add_modifier(Modifier.reversed()),
+        staged=_style(),
+        unstaged=_style(),
+        untracked=_style(),
+        section_header=_style(),
+        header=_style().bold(),
+        branch=_style(),
+        commit=_style(),
+        log_hash=_style(),
+        log_date=_style(),
+        log_subject=_style(),
+        normal=_style(),
+    )
diff --git a/pygittools/src/pygittools/tui/git_colors_test.py b/pygittools/src/pygittools/tui/git_colors_test.py
index e2053c4..80f6fdf 100644
--- a/pygittools/src/pygittools/tui/git_colors_test.py
+++ b/pygittools/src/pygittools/tui/git_colors_test.py
@@ -6,83 +6,83 @@ import pygit2
 from pygit2 import Config, Repository
 
 from pygittools.tui.git_colors import (
-	_colors_enabled,
-	_load_color_specs,
-	_load_palette,
-	_token_to_rgb,
-	init_tui_color_theme,
-	log_entry_styles,
-	parse_git_color_spec,
+    _colors_enabled,
+    _load_color_specs,
+    _load_palette,
+    _token_to_rgb,
+    init_tui_color_theme,
+    log_entry_styles,
+    parse_git_color_spec,
 )
 
 
 def test_parse_git_color_spec_256_index() -> None:
-	palette = _load_palette()
-	parsed = parse_git_color_spec("36")
-	assert parsed.foreground == palette[36]
-	assert parsed.bold is False
+    palette = _load_palette()
+    parsed = parse_git_color_spec("36")
+    assert parsed.foreground == palette[36]
+    assert parsed.bold is False
 
 
 def test_parse_git_color_spec_named_bold() -> None:
-	parsed = parse_git_color_spec("cyan bold")
-	assert parsed.foreground == (0, 170, 170)
-	assert parsed.bold is True
+    parsed = parse_git_color_spec("cyan bold")
+    assert parsed.foreground == (0, 170, 170)
+    assert parsed.bold is True
 
 
 def test_parse_git_color_spec_hex_and_background() -> None:
-	parsed = parse_git_color_spec("#ff0000 #0000ff reverse")
-	assert parsed.foreground == (255, 0, 0)
-	assert parsed.background == (0, 0, 255)
-	assert parsed.reverse is True
+    parsed = parse_git_color_spec("#ff0000 #0000ff reverse")
+    assert parsed.foreground == (255, 0, 0)
+    assert parsed.background == (0, 0, 255)
+    assert parsed.reverse is True
 
 
 def test_token_to_rgb_bright_named() -> None:
-	palette = _load_palette()
-	rgb = _token_to_rgb("brightred", palette)
-	assert rgb == (255, 0, 0)
+    palette = _load_palette()
+    rgb = _token_to_rgb("brightred", palette)
+    assert rgb == (255, 0, 0)
 
 
 def test_load_color_specs_from_repo_config(tmp_path: Path) -> None:
-	# Isolated config file so global/system git colors cannot leak into defaults.
-	config_path = tmp_path / "gitconfig"
-	config_path.write_text("", encoding="utf-8")
-	config = Config()
-	config.add_file(str(config_path), pygit2.GIT_CONFIG_LEVEL_LOCAL)
-	config["color.status.added"] = "33"
-	config["color.status.changed"] = "38 bold"
-	specs = _load_color_specs(config)
-	assert specs["color.status.added"] == "33"
-	assert specs["color.status.changed"] == "38 bold"
-	assert specs["color.status.untracked"] == "red"
+    # Isolated config file so global/system git colors cannot leak into defaults.
+    config_path = tmp_path / "gitconfig"
+    config_path.write_text("", encoding="utf-8")
+    config = Config()
+    config.add_file(str(config_path), pygit2.GIT_CONFIG_LEVEL_LOCAL)
+    config["color.status.added"] = "33"
+    config["color.status.changed"] = "38 bold"
+    specs = _load_color_specs(config)
+    assert specs["color.status.added"] == "33"
+    assert specs["color.status.changed"] == "38 bold"
+    assert specs["color.status.untracked"] == "red"
 
 
 def test_colors_disabled_when_ui_false(committed_repo: Repository) -> None:
-	committed_repo.config["color.ui"] = "false"
-	assert _colors_enabled(committed_repo.config.snapshot()) is False
+    committed_repo.config["color.ui"] = "false"
+    assert _colors_enabled(committed_repo.config.snapshot()) is False
 
 
 def test_init_theme_from_repo(committed_repo: Repository) -> None:
-	committed_repo.config["color.status.added"] = "36"
-	committed_repo.config["color.status.changed"] = "38"
-	committed_repo.config["color.status.untracked"] = "7"
-	theme = init_tui_color_theme(committed_repo)
-	assert theme.staged is not None
+    committed_repo.config["color.status.added"] = "36"
+    committed_repo.config["color.status.changed"] = "38"
+    committed_repo.config["color.status.untracked"] = "7"
+    theme = init_tui_color_theme(committed_repo)
+    assert theme.staged is not None
 
 
 def test_init_theme_without_repo() -> None:
-	theme = init_tui_color_theme(None)
-	assert theme.status_bar is not None
+    theme = init_tui_color_theme(None)
+    assert theme.status_bar is not None
 
 
 def test_section_header_defaults_differ_from_file_rows() -> None:
-	theme = init_tui_color_theme(None)
-	assert theme.section_header is not None
-	assert theme.staged is not None
+    theme = init_tui_color_theme(None)
+    assert theme.section_header is not None
+    assert theme.staged is not None
 
 
 def test_log_entry_styles_returns_three_values() -> None:
-	init_tui_color_theme(None)
-	hash_style, date_style, subject_style = log_entry_styles(focused=False, highlight=True)
-	assert hash_style is not None
-	assert date_style is not None
-	assert subject_style is not None
+    init_tui_color_theme(None)
+    hash_style, date_style, subject_style = log_entry_styles(focused=False, highlight=True)
+    assert hash_style is not None
+    assert date_style is not None
+    assert subject_style is not None
diff --git a/pygittools/src/pygittools/tui/git_worktrees.py b/pygittools/src/pygittools/tui/git_worktrees.py
index 49f8651..08fb18c 100644
--- a/pygittools/src/pygittools/tui/git_worktrees.py
+++ b/pygittools/src/pygittools/tui/git_worktrees.py
@@ -13,144 +13,144 @@ from pygittools.hooks_push import git_executable
 
 @dataclass(frozen=True, slots=True)
 class WorktreeInfo:
-	path: Path
-	head: str | None
-	branch: str | None
-	bare: bool
-	detached: bool
-	locked: str | None
-	prunable: str | None
+    path: Path
+    head: str | None
+    branch: str | None
+    bare: bool
+    detached: bool
+    locked: str | None
+    prunable: str | None
 
 
 def list_worktrees(repo: Repository) -> tuple[list[WorktreeInfo], str | None]:
-	workdir = repo.workdir
-	if workdir is None:
-		return [], "Repository has no working directory"
-	result = subprocess.run(
-		[git_executable(), "worktree", "list", "--porcelain"],
-		cwd=workdir,
-		capture_output=True,
-		text=True,
-		check=False,
-	)
-	if result.returncode != 0:
-		output = (result.stderr or result.stdout or "git worktree list failed").strip()
-		return [], output.splitlines()[-1]
-	return parse_worktree_porcelain(result.stdout), None
+    workdir = repo.workdir
+    if workdir is None:
+        return [], "Repository has no working directory"
+    result = subprocess.run(
+        [git_executable(), "worktree", "list", "--porcelain"],
+        cwd=workdir,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if result.returncode != 0:
+        output = (result.stderr or result.stdout or "git worktree list failed").strip()
+        return [], output.splitlines()[-1]
+    return parse_worktree_porcelain(result.stdout), None
 
 
 def parse_worktree_porcelain(text: str) -> list[WorktreeInfo]:
-	entries: list[WorktreeInfo] = []
-	block: list[str] = []
-	for raw_line in text.splitlines():
-		line = raw_line.strip()
-		if not line:
-			if block:
-				entries.append(_block_to_info(block))
-				block = []
-			continue
-		if line.startswith("worktree "):
-			if block:
-				entries.append(_block_to_info(block))
-			block = [line]
-		else:
-			block.append(line)
-	if block:
-		entries.append(_block_to_info(block))
-	return entries
+    entries: list[WorktreeInfo] = []
+    block: list[str] = []
+    for raw_line in text.splitlines():
+        line = raw_line.strip()
+        if not line:
+            if block:
+                entries.append(_block_to_info(block))
+                block = []
+            continue
+        if line.startswith("worktree "):
+            if block:
+                entries.append(_block_to_info(block))
+            block = [line]
+        else:
+            block.append(line)
+    if block:
+        entries.append(_block_to_info(block))
+    return entries
 
 
 def _block_to_info(lines: list[str]) -> WorktreeInfo:
-	path = Path(lines[0].removeprefix("worktree ").strip())
-	head: str | None = None
-	branch: str | None = None
-	bare = False
-	detached = False
-	locked: str | None = None
-	prunable: str | None = None
-	for line in lines[1:]:
-		if line == "bare":
-			bare = True
-		elif line == "detached":
-			detached = True
-		elif line == "locked":
-			locked = ""
-		elif line.startswith("locked "):
-			locked = line.removeprefix("locked ").strip()
-		elif line == "prunable":
-			prunable = ""
-		elif line.startswith("prunable "):
-			prunable = line.removeprefix("prunable ").strip()
-		elif line.startswith("HEAD "):
-			head = line.removeprefix("HEAD ").strip()
-		elif line.startswith("branch "):
-			ref = line.removeprefix("branch ").strip()
-			branch = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
-	return WorktreeInfo(
-		path=path,
-		head=head,
-		branch=branch,
-		bare=bare,
-		detached=detached,
-		locked=locked,
-		prunable=prunable,
-	)
+    path = Path(lines[0].removeprefix("worktree ").strip())
+    head: str | None = None
+    branch: str | None = None
+    bare = False
+    detached = False
+    locked: str | None = None
+    prunable: str | None = None
+    for line in lines[1:]:
+        if line == "bare":
+            bare = True
+        elif line == "detached":
+            detached = True
+        elif line == "locked":
+            locked = ""
+        elif line.startswith("locked "):
+            locked = line.removeprefix("locked ").strip()
+        elif line == "prunable":
+            prunable = ""
+        elif line.startswith("prunable "):
+            prunable = line.removeprefix("prunable ").strip()
+        elif line.startswith("HEAD "):
+            head = line.removeprefix("HEAD ").strip()
+        elif line.startswith("branch "):
+            ref = line.removeprefix("branch ").strip()
+            branch = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
+    return WorktreeInfo(
+        path=path,
+        head=head,
+        branch=branch,
+        bare=bare,
+        detached=detached,
+        locked=locked,
+        prunable=prunable,
+    )
 
 
 def add_worktree(repo: Repository, path: Path, branch: str | None) -> tuple[bool, str]:
-	workdir = repo.workdir
-	if workdir is None:
-		return False, "Repository has no working directory"
-	trimmed_branch = (branch or "").strip()
-	target = _resolve_worktree_path(workdir, path)
-	cmd = [git_executable(), "worktree", "add"]
-	if trimmed_branch:
-		local = {name for name in repo.branches.local}
-		if trimmed_branch in local:
-			cmd.extend([str(target), trimmed_branch])
-		else:
-			cmd.extend(["-b", trimmed_branch, str(target)])
-	else:
-		cmd.append(str(target))
-	result = subprocess.run(
-		cmd,
-		cwd=workdir,
-		capture_output=True,
-		text=True,
-		check=False,
-	)
-	if result.returncode == 0:
-		return True, ""
-	output = (result.stderr or result.stdout or "git worktree add failed").strip()
-	if not output:
-		return False, "git worktree add failed"
-	return False, output.splitlines()[-1]
+    workdir = repo.workdir
+    if workdir is None:
+        return False, "Repository has no working directory"
+    trimmed_branch = (branch or "").strip()
+    target = _resolve_worktree_path(workdir, path)
+    cmd = [git_executable(), "worktree", "add"]
+    if trimmed_branch:
+        local = {name for name in repo.branches.local}
+        if trimmed_branch in local:
+            cmd.extend([str(target), trimmed_branch])
+        else:
+            cmd.extend(["-b", trimmed_branch, str(target)])
+    else:
+        cmd.append(str(target))
+    result = subprocess.run(
+        cmd,
+        cwd=workdir,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if result.returncode == 0:
+        return True, ""
+    output = (result.stderr or result.stdout or "git worktree add failed").strip()
+    if not output:
+        return False, "git worktree add failed"
+    return False, output.splitlines()[-1]
 
 
 def remove_worktree(repo: Repository, path: Path, *, force: bool) -> tuple[bool, str]:
-	workdir = repo.workdir
-	if workdir is None:
-		return False, "Repository has no working directory"
-	cmd = [git_executable(), "worktree", "remove"]
-	if force:
-		cmd.append("--force")
-	cmd.append(str(path.resolve()))
-	result = subprocess.run(
-		cmd,
-		cwd=workdir,
-		capture_output=True,
-		text=True,
-		check=False,
-	)
-	if result.returncode == 0:
-		return True, ""
-	output = (result.stderr or result.stdout or "git worktree remove failed").strip()
-	if not output:
-		return False, "git worktree remove failed"
-	return False, output.splitlines()[-1]
+    workdir = repo.workdir
+    if workdir is None:
+        return False, "Repository has no working directory"
+    cmd = [git_executable(), "worktree", "remove"]
+    if force:
+        cmd.append("--force")
+    cmd.append(str(path.resolve()))
+    result = subprocess.run(
+        cmd,
+        cwd=workdir,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if result.returncode == 0:
+        return True, ""
+    output = (result.stderr or result.stdout or "git worktree remove failed").strip()
+    if not output:
+        return False, "git worktree remove failed"
+    return False, output.splitlines()[-1]
 
 
 def _resolve_worktree_path(workdir: str | Path, path: Path) -> Path:
-	if path.is_absolute():
-		return path
-	return (Path(workdir) / path).resolve()
+    if path.is_absolute():
+        return path
+    return (Path(workdir) / path).resolve()
diff --git a/pygittools/src/pygittools/tui/git_worktrees_test.py b/pygittools/src/pygittools/tui/git_worktrees_test.py
index 3938259..0c01df7 100644
--- a/pygittools/src/pygittools/tui/git_worktrees_test.py
+++ b/pygittools/src/pygittools/tui/git_worktrees_test.py
@@ -7,15 +7,15 @@ import pytest
 from pygit2 import Repository, init_repository
 
 from pygittools.tui.git_worktrees import (
-	add_worktree,
-	list_worktrees,
-	parse_worktree_porcelain,
-	remove_worktree,
+    add_worktree,
+    list_worktrees,
+    parse_worktree_porcelain,
+    remove_worktree,
 )
 
 
 def test_parse_worktree_porcelain() -> None:
-	text = """
+    text = """
 worktree /repo/main
 HEAD abcdef1234567890
 branch refs/heads/main
@@ -29,18 +29,18 @@ worktree /repo/old
 prunable gitdir file points to non-existent location
 
 """.strip()
-	entries = parse_worktree_porcelain(text)
-	assert len(entries) == 3
-	assert entries[0].path == Path("/repo/main")
-	assert entries[0].branch == "main"
-	assert entries[0].head == "abcdef1234567890"
-	assert entries[1].locked == ""
-	assert entries[1].branch == "feature"
-	assert entries[2].prunable == "gitdir file points to non-existent location"
+    entries = parse_worktree_porcelain(text)
+    assert len(entries) == 3
+    assert entries[0].path == Path("/repo/main")
+    assert entries[0].branch == "main"
+    assert entries[0].head == "abcdef1234567890"
+    assert entries[1].locked == ""
+    assert entries[1].branch == "feature"
+    assert entries[2].prunable == "gitdir file points to non-existent location"
 
 
 def test_parse_detached_and_bare() -> None:
-	text = """
+    text = """
 worktree /repo/detached
 HEAD deadbeef
 detached
@@ -49,50 +49,50 @@ worktree /repo/bare.git
 bare
 
 """.strip()
-	entries = parse_worktree_porcelain(text)
-	assert entries[0].detached is True
-	assert entries[0].branch is None
-	assert entries[1].bare is True
+    entries = parse_worktree_porcelain(text)
+    assert entries[0].detached is True
+    assert entries[0].branch is None
+    assert entries[1].bare is True
 
 
 def test_list_worktrees_no_workdir(monkeypatch: pytest.MonkeyPatch) -> None:
-	repo = MagicMock(spec=Repository)
-	repo.workdir = None
-	entries, error = list_worktrees(repo)
-	assert entries == []
-	assert error is not None
+    repo = MagicMock(spec=Repository)
+    repo.workdir = None
+    entries, error = list_worktrees(repo)
+    assert entries == []
+    assert error is not None
 
 
 def test_add_worktree_requires_path(repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	calls: list[list[str]] = []
+    calls: list[list[str]] = []
 
-	def fake_run(cmd: list[str], **kwargs: object) -> MagicMock:
-		calls.append(cmd)
-		return MagicMock(returncode=0, stdout="", stderr="")
+    def fake_run(cmd: list[str], **kwargs: object) -> MagicMock:
+        calls.append(cmd)
+        return MagicMock(returncode=0, stdout="", stderr="")
 
-	monkeypatch.setattr("pygittools.tui.git_worktrees.subprocess.run", fake_run)
-	ok, error = add_worktree(repo, Path("../wt"), "feature")
-	assert ok is True
-	assert error == ""
-	assert calls
-	assert "-b" in calls[0] or "feature" in calls[0]
+    monkeypatch.setattr("pygittools.tui.git_worktrees.subprocess.run", fake_run)
+    ok, error = add_worktree(repo, Path("../wt"), "feature")
+    assert ok is True
+    assert error == ""
+    assert calls
+    assert "-b" in calls[0] or "feature" in calls[0]
 
 
 def test_remove_worktree_force(repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	calls: list[list[str]] = []
+    calls: list[list[str]] = []
 
-	def fake_run(cmd: list[str], **kwargs: object) -> MagicMock:
-		calls.append(cmd)
-		return MagicMock(returncode=0, stdout="", stderr="")
+    def fake_run(cmd: list[str], **kwargs: object) -> MagicMock:
+        calls.append(cmd)
+        return MagicMock(returncode=0, stdout="", stderr="")
 
-	monkeypatch.setattr("pygittools.tui.git_worktrees.subprocess.run", fake_run)
-	ok, error = remove_worktree(repo, Path("/tmp/wt"), force=True)
-	assert ok is True
-	assert "--force" in calls[0]
+    monkeypatch.setattr("pygittools.tui.git_worktrees.subprocess.run", fake_run)
+    ok, error = remove_worktree(repo, Path("/tmp/wt"), force=True)
+    assert ok is True
+    assert "--force" in calls[0]
 
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-	return init_repository(str(repo_path), bare=False)
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+    return init_repository(str(repo_path), bare=False)
diff --git a/pygittools/src/pygittools/tui/input_context.py b/pygittools/src/pygittools/tui/input_context.py
index 6cf1382..60d2f0c 100644
--- a/pygittools/src/pygittools/tui/input_context.py
+++ b/pygittools/src/pygittools/tui/input_context.py
@@ -7,19 +7,19 @@ from typing import TYPE_CHECKING, Literal, Protocol
 
 from pygittools.tui.changes_list import ChangesList
 from pygittools.tui.keys import (
-	KeyLike,
-	is_backspace,
-	is_down,
-	is_enter,
-	is_esc,
-	is_left,
-	is_right,
-	is_slash,
-	is_up,
-	move_hint,
-	printable_char,
-	set_vim_enabled,
-	tab_hint,
+    KeyLike,
+    is_backspace,
+    is_down,
+    is_enter,
+    is_esc,
+    is_left,
+    is_right,
+    is_slash,
+    is_up,
+    move_hint,
+    printable_char,
+    set_vim_enabled,
+    tab_hint,
 )
 from pygittools.tui.log_list import CommitLogList
 from pygittools.tui.pages.actions import actions_page
@@ -30,8 +30,8 @@ from pygittools.tui.pages.worktree_add import worktree_add_page
 from pygittools.tui.types import PageAction, PageContext, PageResult
 
 if TYPE_CHECKING:
-	from pygittools.tui.tasks_list import TasksBoardList
-	from pygittools.tui.worktrees_list import WorktreesList
+    from pygittools.tui.tasks_list import TasksBoardList
+    from pygittools.tui.worktrees_list import WorktreesList
 
 _HeaderFocus = Literal["changes", "repo", "branch", "actions"]
 _HomeTab = Literal["status", "log", "tasks", "worktrees"]
@@ -42,273 +42,273 @@ _COMMAND_HELP = "help actions quit commit vim novim status log tasks worktrees p
 
 @dataclass(frozen=True, slots=True)
 class ContextDispatch:
-	page_result: PageResult | None = None
-	next_mode: _ModeName | None = None
-	refresh_log: bool = False
-	command_error: str | None = None
+    page_result: PageResult | None = None
+    next_mode: _ModeName | None = None
+    refresh_log: bool = False
+    command_error: str | None = None
 
 
 class PageInputHost(Protocol):
-	@property
-	def header_focus(self) -> _HeaderFocus: ...
+    @property
+    def header_focus(self) -> _HeaderFocus: ...
 
-	def set_header_focus(self, focus: _HeaderFocus) -> None: ...
+    def set_header_focus(self, focus: _HeaderFocus) -> None: ...
 
-	@property
-	def page_context(self) -> PageContext | None: ...
+    @property
+    def page_context(self) -> PageContext | None: ...
 
-	@property
-	def changes(self) -> ChangesList: ...
+    @property
+    def changes(self) -> ChangesList: ...
 
-	@property
-	def log(self) -> CommitLogList: ...
+    @property
+    def log(self) -> CommitLogList: ...
 
-	@property
-	def active_tab(self) -> _HomeTab: ...
+    @property
+    def active_tab(self) -> _HomeTab: ...
 
-	def set_active_tab(self, tab: _HomeTab) -> None: ...
+    def set_active_tab(self, tab: _HomeTab) -> None: ...
 
-	@property
-	def tasks(self) -> TasksBoardList: ...
+    @property
+    def tasks(self) -> TasksBoardList: ...
 
-	@property
-	def worktrees(self) -> WorktreesList: ...
+    @property
+    def worktrees(self) -> WorktreesList: ...
 
-	@property
-	def has_actions(self) -> bool: ...
+    @property
+    def has_actions(self) -> bool: ...
 
 
 class NavContext:
-	def status_hint(self, host: PageInputHost) -> str:
-		suffix = f"{move_hint()} — {tab_hint()} — / command"
-		if host.header_focus == "repo":
-			return f"Enter browse projects — {suffix}"
-		if host.header_focus == "branch":
-			if host.active_tab == "tasks":
-				return f"Enter sync tasks — {suffix}"
-			if host.active_tab == "worktrees":
-				return f"Enter add worktree — {suffix}"
-			return f"Enter switch branch — {suffix}"
-		if host.header_focus == "actions":
-			if host.has_actions:
-				return f"Enter open actions — {suffix}"
-			return f"No action groups — {suffix}"
-		if host.active_tab == "log":
-			return host.log.status_hint()
-		if host.active_tab == "tasks":
-			return host.tasks.status_hint()
-		if host.active_tab == "worktrees":
-			return host.worktrees.status_hint()
-		return host.changes.status_hint()
-
-	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
-		if is_slash(key):
-			return ContextDispatch(next_mode="command")
-
-		if _try_switch_tab(host, key):
-			return ContextDispatch()
-
-		if host.header_focus == "repo":
-			if is_down(key):
-				host.set_header_focus("branch")
-			elif is_enter(key):
-				return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=projects_page()))
-			return ContextDispatch()
-
-		if host.header_focus == "branch":
-			if is_up(key):
-				host.set_header_focus("repo")
-			elif is_down(key):
-				host.set_header_focus("actions" if _show_actions_row(host) else "changes")
-			elif is_enter(key):
-				if host.active_tab == "tasks" and host.page_context is not None:
-					host.tasks.sync_with_default_remote(host.page_context.repo)
-					return ContextDispatch()
-				if host.active_tab == "worktrees":
-					return ContextDispatch(
-						page_result=PageResult(action=PageAction.PUSH, next_page=worktree_add_page()),
-					)
-				return ContextDispatch(
-					page_result=PageResult(action=PageAction.PUSH, next_page=branch_picker_page()),
-				)
-			return ContextDispatch()
-
-		if host.header_focus == "actions":
-			if is_up(key):
-				host.set_header_focus("branch")
-			elif is_down(key):
-				host.set_header_focus("changes")
-			elif is_enter(key) and host.has_actions:
-				return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=actions_page()))
-			return ContextDispatch()
-
-		if is_up(key) and _content_list_is_at_top(host):
-			host.set_header_focus("actions" if _show_actions_row(host) else "branch")
-			return ContextDispatch()
-
-		ctx = host.page_context
-		if ctx is None:
-			return ContextDispatch()
-		if host.active_tab == "log":
-			host.log.handle_nav_key(key, ctx.repo)
-			return ContextDispatch()
-		if host.active_tab == "tasks":
-			page_result = host.tasks.handle_nav_key(key, ctx.repo)
-			return ContextDispatch(page_result=page_result)
-		if host.active_tab == "worktrees":
-			page_result = host.worktrees.handle_nav_key(key, ctx.repo)
-			return ContextDispatch(page_result=page_result)
-		nav_result = host.changes.handle_nav_key(key, ctx.repo)
-		if nav_result.enter_input:
-			return ContextDispatch(next_mode="input")
-		return ContextDispatch(refresh_log=nav_result.committed)
+    def status_hint(self, host: PageInputHost) -> str:
+        suffix = f"{move_hint()} — {tab_hint()} — / command"
+        if host.header_focus == "repo":
+            return f"Enter browse projects — {suffix}"
+        if host.header_focus == "branch":
+            if host.active_tab == "tasks":
+                return f"Enter sync tasks — {suffix}"
+            if host.active_tab == "worktrees":
+                return f"Enter add worktree — {suffix}"
+            return f"Enter switch branch — {suffix}"
+        if host.header_focus == "actions":
+            if host.has_actions:
+                return f"Enter open actions — {suffix}"
+            return f"No action groups — {suffix}"
+        if host.active_tab == "log":
+            return host.log.status_hint()
+        if host.active_tab == "tasks":
+            return host.tasks.status_hint()
+        if host.active_tab == "worktrees":
+            return host.worktrees.status_hint()
+        return host.changes.status_hint()
+
+    def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
+        if is_slash(key):
+            return ContextDispatch(next_mode="command")
+
+        if _try_switch_tab(host, key):
+            return ContextDispatch()
+
+        if host.header_focus == "repo":
+            if is_down(key):
+                host.set_header_focus("branch")
+            elif is_enter(key):
+                return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=projects_page()))
+            return ContextDispatch()
+
+        if host.header_focus == "branch":
+            if is_up(key):
+                host.set_header_focus("repo")
+            elif is_down(key):
+                host.set_header_focus("actions" if _show_actions_row(host) else "changes")
+            elif is_enter(key):
+                if host.active_tab == "tasks" and host.page_context is not None:
+                    host.tasks.sync_with_default_remote(host.page_context.repo)
+                    return ContextDispatch()
+                if host.active_tab == "worktrees":
+                    return ContextDispatch(
+                        page_result=PageResult(action=PageAction.PUSH, next_page=worktree_add_page()),
+                    )
+                return ContextDispatch(
+                    page_result=PageResult(action=PageAction.PUSH, next_page=branch_picker_page()),
+                )
+            return ContextDispatch()
+
+        if host.header_focus == "actions":
+            if is_up(key):
+                host.set_header_focus("branch")
+            elif is_down(key):
+                host.set_header_focus("changes")
+            elif is_enter(key) and host.has_actions:
+                return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=actions_page()))
+            return ContextDispatch()
+
+        if is_up(key) and _content_list_is_at_top(host):
+            host.set_header_focus("actions" if _show_actions_row(host) else "branch")
+            return ContextDispatch()
+
+        ctx = host.page_context
+        if ctx is None:
+            return ContextDispatch()
+        if host.active_tab == "log":
+            host.log.handle_nav_key(key, ctx.repo)
+            return ContextDispatch()
+        if host.active_tab == "tasks":
+            page_result = host.tasks.handle_nav_key(key, ctx.repo)
+            return ContextDispatch(page_result=page_result)
+        if host.active_tab == "worktrees":
+            page_result = host.worktrees.handle_nav_key(key, ctx.repo)
+            return ContextDispatch(page_result=page_result)
+        nav_result = host.changes.handle_nav_key(key, ctx.repo)
+        if nav_result.enter_input:
+            return ContextDispatch(next_mode="input")
+        return ContextDispatch(refresh_log=nav_result.committed)
 
 
 class CommandContext:
-	def __init__(self) -> None:
-		self._buffer = ""
-		self._error: str | None = None
-
-	def status_hint(self, host: PageInputHost) -> str:
-		del host
-		if self._error is not None:
-			return self._error
-		if not self._buffer:
-			return f"/ — {_COMMAND_HELP} — Esc cancel"
-		return f"/{self._buffer}"
-
-	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
-		if is_esc(key):
-			return ContextDispatch(next_mode="nav")
-		if is_backspace(key):
-			self._error = None
-			self._buffer = self._buffer[:-1]
-			return ContextDispatch()
-		if is_enter(key):
-			return self._run(host)
-		char = printable_char(key)
-		if char is not None and not char.isspace():
-			self._error = None
-			self._buffer += char
-			return ContextDispatch()
-		return ContextDispatch()
-
-	def _run(self, host: PageInputHost) -> ContextDispatch:
-		raw = self._buffer.strip().casefold()
-		name = raw.lstrip("/")
-		self._buffer = ""
-		if not name:
-			return ContextDispatch(next_mode="nav")
-
-		if name in {"help", "?"}:
-			return ContextDispatch(
-				next_mode="nav",
-				page_result=PageResult(action=PageAction.PUSH, next_page=help_page()),
-			)
-		if name in {"actions", "!"}:
-			return ContextDispatch(
-				next_mode="nav",
-				page_result=PageResult(action=PageAction.PUSH, next_page=actions_page()),
-			)
-		if name in {"quit", "q"}:
-			return ContextDispatch(next_mode="nav", page_result=PageResult(action=PageAction.QUIT))
-		if name == "commit":
-			host.set_active_tab("status")
-			host.set_header_focus("changes")
-			host.changes.begin_input()
-			return ContextDispatch(next_mode="input")
-		if name == "vim":
-			set_vim_enabled(True)
-			return ContextDispatch(next_mode="nav")
-		if name in {"novim", "arrows"}:
-			set_vim_enabled(False)
-			return ContextDispatch(next_mode="nav")
-		tab = _tab_command(name)
-		if tab is not None:
-			host.set_active_tab(tab)
-			host.set_header_focus("changes")
-			return ContextDispatch(next_mode="nav")
-		if name == "projects":
-			return ContextDispatch(
-				next_mode="nav",
-				page_result=PageResult(action=PageAction.PUSH, next_page=projects_page()),
-			)
-		if name == "branch":
-			return ContextDispatch(
-				next_mode="nav",
-				page_result=PageResult(action=PageAction.PUSH, next_page=branch_picker_page()),
-			)
-
-		self._error = f"Unknown command: /{name} — {_COMMAND_HELP}"
-		return ContextDispatch()
+    def __init__(self) -> None:
+        self._buffer = ""
+        self._error: str | None = None
+
+    def status_hint(self, host: PageInputHost) -> str:
+        del host
+        if self._error is not None:
+            return self._error
+        if not self._buffer:
+            return f"/ — {_COMMAND_HELP} — Esc cancel"
+        return f"/{self._buffer}"
+
+    def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
+        if is_esc(key):
+            return ContextDispatch(next_mode="nav")
+        if is_backspace(key):
+            self._error = None
+            self._buffer = self._buffer[:-1]
+            return ContextDispatch()
+        if is_enter(key):
+            return self._run(host)
+        char = printable_char(key)
+        if char is not None and not char.isspace():
+            self._error = None
+            self._buffer += char
+            return ContextDispatch()
+        return ContextDispatch()
+
+    def _run(self, host: PageInputHost) -> ContextDispatch:
+        raw = self._buffer.strip().casefold()
+        name = raw.lstrip("/")
+        self._buffer = ""
+        if not name:
+            return ContextDispatch(next_mode="nav")
+
+        if name in {"help", "?"}:
+            return ContextDispatch(
+                next_mode="nav",
+                page_result=PageResult(action=PageAction.PUSH, next_page=help_page()),
+            )
+        if name in {"actions", "!"}:
+            return ContextDispatch(
+                next_mode="nav",
+                page_result=PageResult(action=PageAction.PUSH, next_page=actions_page()),
+            )
+        if name in {"quit", "q"}:
+            return ContextDispatch(next_mode="nav", page_result=PageResult(action=PageAction.QUIT))
+        if name == "commit":
+            host.set_active_tab("status")
+            host.set_header_focus("changes")
+            host.changes.begin_input()
+            return ContextDispatch(next_mode="input")
+        if name == "vim":
+            set_vim_enabled(True)
+            return ContextDispatch(next_mode="nav")
+        if name in {"novim", "arrows"}:
+            set_vim_enabled(False)
+            return ContextDispatch(next_mode="nav")
+        tab = _tab_command(name)
+        if tab is not None:
+            host.set_active_tab(tab)
+            host.set_header_focus("changes")
+            return ContextDispatch(next_mode="nav")
+        if name == "projects":
+            return ContextDispatch(
+                next_mode="nav",
+                page_result=PageResult(action=PageAction.PUSH, next_page=projects_page()),
+            )
+        if name == "branch":
+            return ContextDispatch(
+                next_mode="nav",
+                page_result=PageResult(action=PageAction.PUSH, next_page=branch_picker_page()),
+            )
+
+        self._error = f"Unknown command: /{name} — {_COMMAND_HELP}"
+        return ContextDispatch()
 
 
 _TAB_COMMANDS: dict[str, _HomeTab] = {
-	"status": "status",
-	"log": "log",
-	"tasks": "tasks",
-	"worktrees": "worktrees",
+    "status": "status",
+    "log": "log",
+    "tasks": "tasks",
+    "worktrees": "worktrees",
 }
 
 
 def _tab_command(name: str) -> _HomeTab | None:
-	return _TAB_COMMANDS.get(name)
+    return _TAB_COMMANDS.get(name)
 
 
 class InputContext:
-	def status_hint(self, host: PageInputHost) -> str:
-		return host.changes.input_status_hint()
+    def status_hint(self, host: PageInputHost) -> str:
+        return host.changes.input_status_hint()
 
-	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
-		if is_esc(key):
-			return ContextDispatch(next_mode="nav")
-		ctx = host.page_context
-		if ctx is not None:
-			result = host.changes.handle_input_key(key, ctx.repo)
-			if result.relinquish:
-				return ContextDispatch(next_mode="nav", refresh_log=result.committed)
-		return ContextDispatch()
+    def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
+        if is_esc(key):
+            return ContextDispatch(next_mode="nav")
+        ctx = host.page_context
+        if ctx is not None:
+            result = host.changes.handle_input_key(key, ctx.repo)
+            if result.relinquish:
+                return ContextDispatch(next_mode="nav", refresh_log=result.committed)
+        return ContextDispatch()
 
 
 InputMode = NavContext | InputContext | CommandContext
 
 
 def nav_context() -> NavContext:
-	return NavContext()
+    return NavContext()
 
 
 def input_context() -> InputContext:
-	return InputContext()
+    return InputContext()
 
 
 def command_context() -> CommandContext:
-	return CommandContext()
+    return CommandContext()
 
 
 def _show_actions_row(host: PageInputHost) -> bool:
-	return host.active_tab == "status"
+    return host.active_tab == "status"
 
 
 def _content_list_is_at_top(host: PageInputHost) -> bool:
-	if host.active_tab == "log":
-		return host.log.is_at_top()
-	if host.active_tab == "tasks":
-		return host.tasks.is_at_top()
-	if host.active_tab == "worktrees":
-		return host.worktrees.is_at_top()
-	return host.changes.is_at_top()
+    if host.active_tab == "log":
+        return host.log.is_at_top()
+    if host.active_tab == "tasks":
+        return host.tasks.is_at_top()
+    if host.active_tab == "worktrees":
+        return host.worktrees.is_at_top()
+    return host.changes.is_at_top()
 
 
 def _try_switch_tab(host: PageInputHost, key: KeyLike) -> bool:
-	tabs: tuple[_HomeTab, ...] = ("status", "log", "tasks", "worktrees")
-	current = host.active_tab
-	index = tabs.index(current)
-	if is_left(key):
-		if index > 0:
-			host.set_active_tab(tabs[index - 1])
-		return True
-	if is_right(key):
-		if index < len(tabs) - 1:
-			host.set_active_tab(tabs[index + 1])
-		return True
-	return False
+    tabs: tuple[_HomeTab, ...] = ("status", "log", "tasks", "worktrees")
+    current = host.active_tab
+    index = tabs.index(current)
+    if is_left(key):
+        if index > 0:
+            host.set_active_tab(tabs[index - 1])
+        return True
+    if is_right(key):
+        if index < len(tabs) - 1:
+            host.set_active_tab(tabs[index + 1])
+        return True
+    return False
diff --git a/pygittools/src/pygittools/tui/input_context_test.py b/pygittools/src/pygittools/tui/input_context_test.py
index 54b4a7e..e32ccf8 100644
--- a/pygittools/src/pygittools/tui/input_context_test.py
+++ b/pygittools/src/pygittools/tui/input_context_test.py
@@ -23,249 +23,249 @@ _HomeTab = Literal["status", "log", "tasks", "worktrees"]
 
 @dataclass
 class FakeHost:
-	page_context: PageContext | None
-	changes: ChangesList
-	log: CommitLogList
-	tasks: TasksBoardList
-	worktrees: WorktreesList
-	header_focus: _HeaderFocus = "changes"
-	active_tab: _HomeTab = "status"
-	has_actions: bool = False
+    page_context: PageContext | None
+    changes: ChangesList
+    log: CommitLogList
+    tasks: TasksBoardList
+    worktrees: WorktreesList
+    header_focus: _HeaderFocus = "changes"
+    active_tab: _HomeTab = "status"
+    has_actions: bool = False
 
-	def set_header_focus(self, focus: _HeaderFocus) -> None:
-		self.header_focus = focus
+    def set_header_focus(self, focus: _HeaderFocus) -> None:
+        self.header_focus = focus
 
-	def set_active_tab(self, tab: _HomeTab) -> None:
-		self.active_tab = tab
+    def set_active_tab(self, tab: _HomeTab) -> None:
+        self.active_tab = tab
 
 
 @pytest.fixture
 def host(page_ctx: PageContext) -> FakeHost:
-	set_vim_enabled(False)
-	changes = ChangesList()
-	changes.refresh(page_ctx.repo)
-	log = CommitLogList()
-	log.refresh(page_ctx.repo)
-	tasks = TasksBoardList()
-	tasks.refresh(page_ctx.repo)
-	worktrees = WorktreesList()
-	worktrees.refresh(page_ctx.repo)
-	return FakeHost(page_context=page_ctx, changes=changes, log=log, tasks=tasks, worktrees=worktrees)
+    set_vim_enabled(False)
+    changes = ChangesList()
+    changes.refresh(page_ctx.repo)
+    log = CommitLogList()
+    log.refresh(page_ctx.repo)
+    tasks = TasksBoardList()
+    tasks.refresh(page_ctx.repo)
+    worktrees = WorktreesList()
+    worktrees.refresh(page_ctx.repo)
+    return FakeHost(page_context=page_ctx, changes=changes, log=log, tasks=tasks, worktrees=worktrees)
 
 
 def test_nav_context_status_hints(host: FakeHost) -> None:
-	nav = nav_context()
-	host.header_focus = "repo"
-	assert "browse projects" in nav.status_hint(host)
-	host.header_focus = "branch"
-	assert "switch branch" in nav.status_hint(host)
-	host.active_tab = "log"
-	host.header_focus = "changes"
-	assert "arrows move" in nav.status_hint(host)
+    nav = nav_context()
+    host.header_focus = "repo"
+    assert "browse projects" in nav.status_hint(host)
+    host.header_focus = "branch"
+    assert "switch branch" in nav.status_hint(host)
+    host.active_tab = "log"
+    host.header_focus = "changes"
+    assert "arrows move" in nav.status_hint(host)
 
 
 def test_nav_context_opens_command_mode(host: FakeHost) -> None:
-	dispatch = nav_context().dispatch_key(host, key("/"))
-	assert dispatch.next_mode == "command"
+    dispatch = nav_context().dispatch_key(host, key("/"))
+    assert dispatch.next_mode == "command"
 
 
 def _run_command(host: FakeHost, command: str):
-	cmd = command_context()
-	for char in command:
-		cmd.dispatch_key(host, key(char))
-	return cmd.dispatch_key(host, key("Enter"))
+    cmd = command_context()
+    for char in command:
+        cmd.dispatch_key(host, key(char))
+    return cmd.dispatch_key(host, key("Enter"))
 
 
 def test_command_opens_help(host: FakeHost) -> None:
-	dispatch = _run_command(host, "help")
-	assert dispatch.page_result is not None
-	assert dispatch.page_result.action == PageAction.PUSH
-	assert isinstance(dispatch.page_result.next_page, HelpPage)
-	assert dispatch.next_mode == "nav"
+    dispatch = _run_command(host, "help")
+    assert dispatch.page_result is not None
+    assert dispatch.page_result.action == PageAction.PUSH
+    assert isinstance(dispatch.page_result.next_page, HelpPage)
+    assert dispatch.next_mode == "nav"
 
 
 def test_command_opens_actions(host: FakeHost) -> None:
-	dispatch = _run_command(host, "actions")
-	assert dispatch.page_result is not None
-	assert dispatch.page_result.action == PageAction.PUSH
-	assert isinstance(dispatch.page_result.next_page, ActionsPage)
+    dispatch = _run_command(host, "actions")
+    assert dispatch.page_result is not None
+    assert dispatch.page_result.action == PageAction.PUSH
+    assert isinstance(dispatch.page_result.next_page, ActionsPage)
 
 
 def test_command_quit(host: FakeHost) -> None:
-	dispatch = _run_command(host, "quit")
-	assert dispatch.page_result is not None
-	assert dispatch.page_result.action == PageAction.QUIT
+    dispatch = _run_command(host, "quit")
+    assert dispatch.page_result is not None
+    assert dispatch.page_result.action == PageAction.QUIT
 
 
 def test_command_commit_enters_input(host: FakeHost) -> None:
-	dispatch = _run_command(host, "commit")
-	assert dispatch.next_mode == "input"
-	assert host.active_tab == "status"
-	assert host.header_focus == "changes"
+    dispatch = _run_command(host, "commit")
+    assert dispatch.next_mode == "input"
+    assert host.active_tab == "status"
+    assert host.header_focus == "changes"
 
 
 def test_command_vim_toggle(host: FakeHost) -> None:
-	assert not vim_enabled()
-	assert _run_command(host, "vim").next_mode == "nav"
-	assert vim_enabled()
-	assert _run_command(host, "novim").next_mode == "nav"
-	assert not vim_enabled()
+    assert not vim_enabled()
+    assert _run_command(host, "vim").next_mode == "nav"
+    assert vim_enabled()
+    assert _run_command(host, "novim").next_mode == "nav"
+    assert not vim_enabled()
 
 
 def test_command_switch_tab(host: FakeHost) -> None:
-	assert _run_command(host, "log").next_mode == "nav"
-	assert host.active_tab == "log"
+    assert _run_command(host, "log").next_mode == "nav"
+    assert host.active_tab == "log"
 
 
 def test_command_unknown(host: FakeHost) -> None:
-	cmd = command_context()
-	for char in "nope":
-		cmd.dispatch_key(host, key(char))
-	dispatch = cmd.dispatch_key(host, key("Enter"))
-	assert dispatch.next_mode is None
-	assert "Unknown command" in cmd.status_hint(host)
+    cmd = command_context()
+    for char in "nope":
+        cmd.dispatch_key(host, key(char))
+    dispatch = cmd.dispatch_key(host, key("Enter"))
+    assert dispatch.next_mode is None
+    assert "Unknown command" in cmd.status_hint(host)
 
 
 def test_command_esc_cancels(host: FakeHost) -> None:
-	cmd = command_context()
-	cmd.dispatch_key(host, key("h"))
-	dispatch = cmd.dispatch_key(host, key("Esc"))
-	assert dispatch.next_mode == "nav"
+    cmd = command_context()
+    cmd.dispatch_key(host, key("h"))
+    dispatch = cmd.dispatch_key(host, key("Esc"))
+    assert dispatch.next_mode == "nav"
 
 
 def test_nav_context_repo_header(host: FakeHost) -> None:
-	host.header_focus = "repo"
-	nav = nav_context()
-	nav.dispatch_key(host, key("Down"))
-	assert host.header_focus == "branch"
-	dispatch = nav.dispatch_key(host, key("Enter"))
-	assert dispatch.page_result is not None
-	assert dispatch.page_result.action == PageAction.PUSH
+    host.header_focus = "repo"
+    nav = nav_context()
+    nav.dispatch_key(host, key("Down"))
+    assert host.header_focus == "branch"
+    dispatch = nav.dispatch_key(host, key("Enter"))
+    assert dispatch.page_result is not None
+    assert dispatch.page_result.action == PageAction.PUSH
 
 
 def test_nav_context_branch_header(host: FakeHost) -> None:
-	host.header_focus = "branch"
-	nav = nav_context()
-	nav.dispatch_key(host, key("Up"))
-	assert host.header_focus == "repo"
-	nav.dispatch_key(host, key("Down"))
-	assert host.header_focus == "branch"
-	nav.dispatch_key(host, key("Down"))
-	assert host.header_focus == "actions"
-	assert "No action groups" in nav.status_hint(host)
-	nav.dispatch_key(host, key("Down"))
-	assert host.header_focus == "changes"
-	host.header_focus = "branch"
-	dispatch = nav.dispatch_key(host, key("Enter"))
-	assert dispatch.page_result is not None
-	assert dispatch.page_result.action == PageAction.PUSH
+    host.header_focus = "branch"
+    nav = nav_context()
+    nav.dispatch_key(host, key("Up"))
+    assert host.header_focus == "repo"
+    nav.dispatch_key(host, key("Down"))
+    assert host.header_focus == "branch"
+    nav.dispatch_key(host, key("Down"))
+    assert host.header_focus == "actions"
+    assert "No action groups" in nav.status_hint(host)
+    nav.dispatch_key(host, key("Down"))
+    assert host.header_focus == "changes"
+    host.header_focus = "branch"
+    dispatch = nav.dispatch_key(host, key("Enter"))
+    assert dispatch.page_result is not None
+    assert dispatch.page_result.action == PageAction.PUSH
 
 
 def test_nav_context_tab_switch(host: FakeHost) -> None:
-	nav = nav_context()
-	host.active_tab = "log"
-	nav.dispatch_key(host, key("Left"))
-	assert host.active_tab == "status"
-	host.active_tab = "status"
-	nav.dispatch_key(host, key("Right"))
-	assert host.active_tab == "log"
+    nav = nav_context()
+    host.active_tab = "log"
+    nav.dispatch_key(host, key("Left"))
+    assert host.active_tab == "status"
+    host.active_tab = "status"
+    nav.dispatch_key(host, key("Right"))
+    assert host.active_tab == "log"
 
 
 def test_nav_context_actions_row(host: FakeHost) -> None:
-	host.has_actions = True
-	nav = nav_context()
-	nav.dispatch_key(host, key("Up"))
-	assert host.header_focus == "actions"
-	assert "open actions" in nav.status_hint(host)
-	nav.dispatch_key(host, key("Up"))
-	assert host.header_focus == "branch"
-	nav.dispatch_key(host, key("Down"))
-	assert host.header_focus == "actions"
-	dispatch = nav.dispatch_key(host, key("Enter"))
-	assert dispatch.page_result is not None
-	assert dispatch.page_result.action == PageAction.PUSH
-	assert isinstance(dispatch.page_result.next_page, ActionsPage)
-	host.header_focus = "actions"
-	nav.dispatch_key(host, key("Down"))
-	assert host.header_focus == "changes"
+    host.has_actions = True
+    nav = nav_context()
+    nav.dispatch_key(host, key("Up"))
+    assert host.header_focus == "actions"
+    assert "open actions" in nav.status_hint(host)
+    nav.dispatch_key(host, key("Up"))
+    assert host.header_focus == "branch"
+    nav.dispatch_key(host, key("Down"))
+    assert host.header_focus == "actions"
+    dispatch = nav.dispatch_key(host, key("Enter"))
+    assert dispatch.page_result is not None
+    assert dispatch.page_result.action == PageAction.PUSH
+    assert isinstance(dispatch.page_result.next_page, ActionsPage)
+    host.header_focus = "actions"
+    nav.dispatch_key(host, key("Down"))
+    assert host.header_focus == "changes"
 
 
 def test_nav_context_moves_to_branch_from_top(host: FakeHost) -> None:
-	host.changes._list_state.select(0)
-	nav = nav_context()
-	nav.dispatch_key(host, key("Up"))
-	assert host.header_focus == "actions"
-	assert nav.dispatch_key(host, key("Enter")).page_result is None
-	nav.dispatch_key(host, key("Up"))
-	assert host.header_focus == "branch"
+    host.changes._list_state.select(0)
+    nav = nav_context()
+    nav.dispatch_key(host, key("Up"))
+    assert host.header_focus == "actions"
+    assert nav.dispatch_key(host, key("Enter")).page_result is None
+    nav.dispatch_key(host, key("Up"))
+    assert host.header_focus == "branch"
 
 
 def test_nav_context_log_tab(host: FakeHost) -> None:
-	host.active_tab = "log"
-	nav_context().dispatch_key(host, key("Down"))
-	assert (host.log._list_state.selected or 0) >= 0
+    host.active_tab = "log"
+    nav_context().dispatch_key(host, key("Down"))
+    assert (host.log._list_state.selected or 0) >= 0
 
 
 def test_nav_context_enters_input_on_enter(host: FakeHost) -> None:
-	host.changes._list_state.select(0)
-	dispatch = nav_context().dispatch_key(host, key("Enter"))
-	assert dispatch.next_mode == "input"
+    host.changes._list_state.select(0)
+    dispatch = nav_context().dispatch_key(host, key("Enter"))
+    assert dispatch.next_mode == "input"
 
 
 def test_nav_context_vim_i_enters_input(host: FakeHost) -> None:
-	set_vim_enabled(True)
-	try:
-		host.changes._list_state.select(0)
-		dispatch = nav_context().dispatch_key(host, key("i"))
-		assert dispatch.next_mode == "input"
-	finally:
-		set_vim_enabled(False)
+    set_vim_enabled(True)
+    try:
+        host.changes._list_state.select(0)
+        dispatch = nav_context().dispatch_key(host, key("i"))
+        assert dispatch.next_mode == "input"
+    finally:
+        set_vim_enabled(False)
 
 
 def test_nav_ignores_letter_keys_without_vim(host: FakeHost) -> None:
-	host.changes._list_state.select(0)
-	selected = host.changes._list_state.selected
-	dispatch = nav_context().dispatch_key(host, key("j"))
-	assert dispatch.next_mode is None
-	assert host.changes._list_state.selected == selected
-	assert host.changes.commit_message == ""
+    host.changes._list_state.select(0)
+    selected = host.changes._list_state.selected
+    dispatch = nav_context().dispatch_key(host, key("j"))
+    assert dispatch.next_mode is None
+    assert host.changes._list_state.selected == selected
+    assert host.changes.commit_message == ""
 
 
 def test_input_context_esc_returns_nav(host: FakeHost) -> None:
-	dispatch = input_context().dispatch_key(host, key("Esc"))
-	assert dispatch.next_mode == "nav"
+    dispatch = input_context().dispatch_key(host, key("Esc"))
+    assert dispatch.next_mode == "nav"
 
 
 def test_input_context_commit_relinquishes(host: FakeHost, monkeypatch: pytest.MonkeyPatch) -> None:
-	host.changes.set_commit_message("msg")
-	host.changes.begin_input()
-	host.changes.refresh(host.page_context.repo)  # type: ignore[union-attr]
-	monkeypatch.setattr(
-		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg, *, amend=False: (True, ""),
-	)
-	input_context().dispatch_key(host, key("Tab"))
-	dispatch = input_context().dispatch_key(host, key("Enter"))
-	assert dispatch.next_mode == "nav"
+    host.changes.set_commit_message("msg")
+    host.changes.begin_input()
+    host.changes.refresh(host.page_context.repo)  # type: ignore[union-attr]
+    monkeypatch.setattr(
+        "pygittools.tui.changes_list.commit_staged",
+        lambda _repo, _msg, *, amend=False: (True, ""),
+    )
+    input_context().dispatch_key(host, key("Tab"))
+    dispatch = input_context().dispatch_key(host, key("Enter"))
+    assert dispatch.next_mode == "nav"
 
 
 def test_input_context_status_hint(host: FakeHost) -> None:
-	host.changes.begin_input()
-	assert "Esc" in InputContext().status_hint(host)
+    host.changes.begin_input()
+    assert "Esc" in InputContext().status_hint(host)
 
 
 def test_nav_context_without_page_context() -> None:
-	host = FakeHost(
-		page_context=None,
-		changes=ChangesList(),
-		log=CommitLogList(),
-		tasks=TasksBoardList(),
-		worktrees=WorktreesList(),
-	)
-	assert nav_context().dispatch_key(host, key("Down")).page_result is None
+    host = FakeHost(
+        page_context=None,
+        changes=ChangesList(),
+        log=CommitLogList(),
+        tasks=TasksBoardList(),
+        worktrees=WorktreesList(),
+    )
+    assert nav_context().dispatch_key(host, key("Down")).page_result is None
 
 
 def test_draw_helpers_unused() -> None:
-	# keep import of Rect available for call sites in other modules
-	assert Rect(0, 0, 1, 1).width == 1
-	assert MagicMock() is not None
+    # keep import of Rect available for call sites in other modules
+    assert Rect(0, 0, 1, 1).width == 1
+    assert MagicMock() is not None
diff --git a/pygittools/src/pygittools/tui/keys.py b/pygittools/src/pygittools/tui/keys.py
index ea061b6..b2db157 100644
--- a/pygittools/src/pygittools/tui/keys.py
+++ b/pygittools/src/pygittools/tui/keys.py
@@ -10,113 +10,113 @@ _vim_enabled: bool = False
 
 @runtime_checkable
 class KeyLike(Protocol):
-	@property
-	def code(self) -> str: ...
+    @property
+    def code(self) -> str: ...
 
-	@property
-	def ctrl(self) -> bool: ...
+    @property
+    def ctrl(self) -> bool: ...
 
-	@property
-	def alt(self) -> bool: ...
+    @property
+    def alt(self) -> bool: ...
 
-	@property
-	def shift(self) -> bool: ...
+    @property
+    def shift(self) -> bool: ...
 
 
 @dataclass(frozen=True, slots=True)
 class Key:
-	"""Test-friendly stand-in for ``pyratatui.KeyEvent``."""
+    """Test-friendly stand-in for ``pyratatui.KeyEvent``."""
 
-	code: str
-	ctrl: bool = False
-	alt: bool = False
-	shift: bool = False
+    code: str
+    ctrl: bool = False
+    alt: bool = False
+    shift: bool = False
 
 
 def key(code: str, *, ctrl: bool = False, alt: bool = False, shift: bool = False) -> Key:
-	return Key(code=code, ctrl=ctrl, alt=alt, shift=shift)
+    return Key(code=code, ctrl=ctrl, alt=alt, shift=shift)
 
 
 def set_vim_enabled(enabled: bool) -> None:
-	global _vim_enabled
-	_vim_enabled = enabled
+    global _vim_enabled
+    _vim_enabled = enabled
 
 
 def vim_enabled() -> bool:
-	return _vim_enabled
+    return _vim_enabled
 
 
 def is_quit(ev: KeyLike) -> bool:
-	return ev.code.casefold() == "q" or (ev.code == "c" and ev.ctrl)
+    return ev.code.casefold() == "q" or (ev.code == "c" and ev.ctrl)
 
 
 def is_up(ev: KeyLike) -> bool:
-	if ev.code == "Up":
-		return True
-	return _vim_enabled and ev.code in {"k", "K"}
+    if ev.code == "Up":
+        return True
+    return _vim_enabled and ev.code in {"k", "K"}
 
 
 def is_down(ev: KeyLike) -> bool:
-	if ev.code == "Down":
-		return True
-	return _vim_enabled and ev.code in {"j", "J"}
+    if ev.code == "Down":
+        return True
+    return _vim_enabled and ev.code in {"j", "J"}
 
 
 def is_left(ev: KeyLike) -> bool:
-	if ev.code == "Left":
-		return True
-	return _vim_enabled and ev.code in {"h", "H"}
+    if ev.code == "Left":
+        return True
+    return _vim_enabled and ev.code in {"h", "H"}
 
 
 def is_right(ev: KeyLike) -> bool:
-	if ev.code == "Right":
-		return True
-	return _vim_enabled and ev.code in {"l", "L"}
+    if ev.code == "Right":
+        return True
+    return _vim_enabled and ev.code in {"l", "L"}
 
 
 def is_enter(ev: KeyLike) -> bool:
-	return ev.code == "Enter"
+    return ev.code == "Enter"
 
 
 def is_esc(ev: KeyLike) -> bool:
-	return ev.code == "Esc"
+    return ev.code == "Esc"
 
 
 def is_tab(ev: KeyLike) -> bool:
-	return ev.code == "Tab" and not ev.shift
+    return ev.code == "Tab" and not ev.shift
 
 
 def is_backtab(ev: KeyLike) -> bool:
-	return ev.code in {"BackTab", "Backtab"} or (ev.code == "Tab" and ev.shift)
+    return ev.code in {"BackTab", "Backtab"} or (ev.code == "Tab" and ev.shift)
 
 
 def is_backspace(ev: KeyLike) -> bool:
-	return ev.code == "Backspace"
+    return ev.code == "Backspace"
 
 
 def is_space(ev: KeyLike) -> bool:
-	return ev.code == " "
+    return ev.code == " "
 
 
 def is_slash(ev: KeyLike) -> bool:
-	return ev.code == "/" and not ev.ctrl and not ev.alt
+    return ev.code == "/" and not ev.ctrl and not ev.alt
 
 
 def printable_char(ev: KeyLike) -> str | None:
-	if ev.ctrl or ev.alt:
-		return None
-	if len(ev.code) == 1 and ev.code.isprintable():
-		return ev.code
-	return None
+    if ev.ctrl or ev.alt:
+        return None
+    if len(ev.code) == 1 and ev.code.isprintable():
+        return ev.code
+    return None
 
 
 def move_hint() -> str:
-	return "j/k move" if _vim_enabled else "arrows move"
+    return "j/k move" if _vim_enabled else "arrows move"
 
 
 def tab_hint() -> str:
-	return "H/L switch tab" if _vim_enabled else "←/→ switch tab"
+    return "H/L switch tab" if _vim_enabled else "←/→ switch tab"
 
 
 def board_hint() -> str:
-	return "H/L switch board" if _vim_enabled else "←/→ switch board"
+    return "H/L switch board" if _vim_enabled else "←/→ switch board"
diff --git a/pygittools/src/pygittools/tui/log_list.py b/pygittools/src/pygittools/tui/log_list.py
index c8e6f69..cc47187 100644
--- a/pygittools/src/pygittools/tui/log_list.py
+++ b/pygittools/src/pygittools/tui/log_list.py
@@ -17,98 +17,98 @@ _MAX_COMMITS = 500
 
 @dataclass(frozen=True, slots=True)
 class _LogEntry:
-	short_id: str
-	date: str
-	subject: str
+    short_id: str
+    date: str
+    subject: str
 
 
 @dataclass(frozen=True, slots=True)
 class LogNavKeyResult:
-	layout_changed: bool = False
+    layout_changed: bool = False
 
 
 class CommitLogList:
-	def __init__(self) -> None:
-		self._entries: list[_LogEntry] = []
-		self._list_state = ListState()
-
-	def is_at_top(self) -> bool:
-		return (self._list_state.selected or 0) == 0
-
-	def refresh(self, repo: Repository) -> None:
-		self._entries = _load_entries(repo)
-		selected = self._list_state.selected or 0
-		self._list_state.select(min(selected, max(0, len(self._entries) - 1)))
-
-	def draw(self, frame: Frame, area: Rect, *, highlight: bool) -> None:
-		if area.height <= 0 or area.width <= 0:
-			return
-		theme = current_theme()
-		if not self._entries:
-			frame.render_stateful_list(
-				List([ListItem("No commits", theme.log_date)]),
-				area,
-				self._list_state,
-			)
-			return
-		selected = self._list_state.selected or 0
-		items: list[ListItem] = []
-		for index, entry in enumerate(self._entries):
-			focused = highlight and index == selected
-			hash_style, date_style, subject_style = log_entry_styles(
-				focused=focused,
-				highlight=highlight,
-			)
-			del date_style, subject_style
-			label = f"{entry.short_id} {entry.date} {entry.subject}"
-			items.append(ListItem(label, hash_style if focused else theme.log_subject))
-		frame.render_stateful_list(
-			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			area,
-			self._list_state,
-		)
-
-	def handle_nav_key(self, key: KeyLike, repo: Repository) -> LogNavKeyResult:
-		del repo
-		if is_up(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(max(0, selected - 1))
-			return LogNavKeyResult()
-		if is_down(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(min(max(0, len(self._entries) - 1), selected + 1))
-			return LogNavKeyResult()
-		return LogNavKeyResult()
-
-	def status_hint(self) -> str:
-		move = move_hint()
-		if not self._entries:
-			return f"No commits — {move}"
-		entry = self._entries[self._list_state.selected or 0]
-		return f"{entry.short_id} {entry.date} — {move}"
+    def __init__(self) -> None:
+        self._entries: list[_LogEntry] = []
+        self._list_state = ListState()
+
+    def is_at_top(self) -> bool:
+        return (self._list_state.selected or 0) == 0
+
+    def refresh(self, repo: Repository) -> None:
+        self._entries = _load_entries(repo)
+        selected = self._list_state.selected or 0
+        self._list_state.select(min(selected, max(0, len(self._entries) - 1)))
+
+    def draw(self, frame: Frame, area: Rect, *, highlight: bool) -> None:
+        if area.height <= 0 or area.width <= 0:
+            return
+        theme = current_theme()
+        if not self._entries:
+            frame.render_stateful_list(
+                List([ListItem("No commits", theme.log_date)]),
+                area,
+                self._list_state,
+            )
+            return
+        selected = self._list_state.selected or 0
+        items: list[ListItem] = []
+        for index, entry in enumerate(self._entries):
+            focused = highlight and index == selected
+            hash_style, date_style, subject_style = log_entry_styles(
+                focused=focused,
+                highlight=highlight,
+            )
+            del date_style, subject_style
+            label = f"{entry.short_id} {entry.date} {entry.subject}"
+            items.append(ListItem(label, hash_style if focused else theme.log_subject))
+        frame.render_stateful_list(
+            List(items).highlight_style(theme.focus).highlight_symbol(""),
+            area,
+            self._list_state,
+        )
+
+    def handle_nav_key(self, key: KeyLike, repo: Repository) -> LogNavKeyResult:
+        del repo
+        if is_up(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(max(0, selected - 1))
+            return LogNavKeyResult()
+        if is_down(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(min(max(0, len(self._entries) - 1), selected + 1))
+            return LogNavKeyResult()
+        return LogNavKeyResult()
+
+    def status_hint(self) -> str:
+        move = move_hint()
+        if not self._entries:
+            return f"No commits — {move}"
+        entry = self._entries[self._list_state.selected or 0]
+        return f"{entry.short_id} {entry.date} — {move}"
 
 
 def _load_entries(repo: Repository) -> list[_LogEntry]:
-	try:
-		head = repo.head.target
-	except GitError:
-		return []
-	entries: list[_LogEntry] = []
-	for commit in repo.walk(head, SortMode.TIME):
-		if not isinstance(commit, Commit):
-			continue
-		subject = commit.message.splitlines()[0] if commit.message else "(no message)"
-		entries.append(
-			_LogEntry(
-				short_id=str(commit.id)[:7],
-				date=_format_commit_date(commit),
-				subject=subject,
-			),
-		)
-		if len(entries) >= _MAX_COMMITS:
-			break
-	return entries
+    try:
+        head = repo.head.target
+    except GitError:
+        return []
+    entries: list[_LogEntry] = []
+    for commit in repo.walk(head, SortMode.TIME):
+        if not isinstance(commit, Commit):
+            continue
+        subject = commit.message.splitlines()[0] if commit.message else "(no message)"
+        entries.append(
+            _LogEntry(
+                short_id=str(commit.id)[:7],
+                date=_format_commit_date(commit),
+                subject=subject,
+            ),
+        )
+        if len(entries) >= _MAX_COMMITS:
+            break
+    return entries
 
 
 def _format_commit_date(commit: Commit) -> str:
-	return datetime.fromtimestamp(commit.commit_time, tz=UTC).strftime("%Y-%m-%d")
+    return datetime.fromtimestamp(commit.commit_time, tz=UTC).strftime("%Y-%m-%d")
diff --git a/pygittools/src/pygittools/tui/log_list_test.py b/pygittools/src/pygittools/tui/log_list_test.py
index 15b92a0..c1ded9d 100644
--- a/pygittools/src/pygittools/tui/log_list_test.py
+++ b/pygittools/src/pygittools/tui/log_list_test.py
@@ -18,88 +18,88 @@ SIG = pygit2.Signature("t", "t@example.com", 0)
 
 
 def test_repo_project_label_falls_back_to_worktree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	settings = tmp_path / "settings.json"
-	settings.write_text(json.dumps({"PROJECTROOT": str(tmp_path / "projects")}), encoding="utf-8")
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
-	repo_dir = tmp_path / "standalone"
-	repo_dir.mkdir()
-	pygit2.init_repository(str(repo_dir), bare=False)
-	assert repo_project_label(repo_dir) == str(repo_dir.resolve())
+    settings = tmp_path / "settings.json"
+    settings.write_text(json.dumps({"PROJECTROOT": str(tmp_path / "projects")}), encoding="utf-8")
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+    repo_dir = tmp_path / "standalone"
+    repo_dir.mkdir()
+    pygit2.init_repository(str(repo_dir), bare=False)
+    assert repo_project_label(repo_dir) == str(repo_dir.resolve())
 
 
 def test_repo_project_label_uses_worktree_for_git_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	settings = tmp_path / "settings.json"
-	settings.write_text(json.dumps({"PROJECTROOT": str(tmp_path / "projects")}), encoding="utf-8")
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
-	repo_dir = tmp_path / "pygitweb"
-	repo_dir.mkdir()
-	repo = pygit2.init_repository(str(repo_dir), bare=False)
-	assert repo_project_label(Path(repo.path)) == str(repo_dir.resolve())
+    settings = tmp_path / "settings.json"
+    settings.write_text(json.dumps({"PROJECTROOT": str(tmp_path / "projects")}), encoding="utf-8")
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+    repo_dir = tmp_path / "pygitweb"
+    repo_dir.mkdir()
+    repo = pygit2.init_repository(str(repo_dir), bare=False)
+    assert repo_project_label(Path(repo.path)) == str(repo_dir.resolve())
 
 
 def test_commit_log_list_loads_head_commit(tmp_path: Path) -> None:
-	repo = pygit2.init_repository(str(tmp_path / "repo"), bare=False)
-	tree = repo.index.write_tree()
-	repo.create_commit("HEAD", SIG, SIG, "feat: first", tree, [])
-	log = CommitLogList()
-	log.refresh(repo)
-	assert str(repo.head.target)[:7] in log.status_hint()
+    repo = pygit2.init_repository(str(tmp_path / "repo"), bare=False)
+    tree = repo.index.write_tree()
+    repo.create_commit("HEAD", SIG, SIG, "feat: first", tree, [])
+    log = CommitLogList()
+    log.refresh(repo)
+    assert str(repo.head.target)[:7] in log.status_hint()
 
 
 def test_commit_log_list_empty_repo(repo: pygit2.Repository) -> None:
-	log = CommitLogList()
-	log.refresh(repo)
-	assert log.status_hint() == "No commits — arrows move"
-	log.draw(MagicMock(), Rect(0, 0, 80, 5), highlight=True)
+    log = CommitLogList()
+    log.refresh(repo)
+    assert log.status_hint() == "No commits — arrows move"
+    log.draw(MagicMock(), Rect(0, 0, 80, 5), highlight=True)
 
 
 def test_commit_log_list_navigation(tmp_path: Path) -> None:
-	repo = pygit2.init_repository(str(tmp_path / "repo"), bare=False)
-	tree = repo.index.write_tree()
-	repo.create_commit("HEAD", SIG, SIG, "one", tree, [])
-	log = CommitLogList()
-	log.refresh(repo)
-	log.handle_nav_key(key("Down"), repo)
-	log.handle_nav_key(key("Up"), repo)
-	with patch.object(log, "refresh") as refresh:
-		log.handle_nav_key(key("x"), repo)
-	refresh.assert_not_called()
+    repo = pygit2.init_repository(str(tmp_path / "repo"), bare=False)
+    tree = repo.index.write_tree()
+    repo.create_commit("HEAD", SIG, SIG, "one", tree, [])
+    log = CommitLogList()
+    log.refresh(repo)
+    log.handle_nav_key(key("Down"), repo)
+    log.handle_nav_key(key("Up"), repo)
+    with patch.object(log, "refresh") as refresh:
+        log.handle_nav_key(key("x"), repo)
+    refresh.assert_not_called()
 
 
 def test_load_entries_without_head(repo: pygit2.Repository) -> None:
-	from pygit2 import init_repository
+    from pygit2 import init_repository
 
-	from pygittools.tui.log_list import _load_entries
+    from pygittools.tui.log_list import _load_entries
 
-	empty = init_repository(str(Path(repo.workdir).parent / "bare"), bare=True)
-	assert _load_entries(empty) == []
+    empty = init_repository(str(Path(repo.workdir).parent / "bare"), bare=True)
+    assert _load_entries(empty) == []
 
 
 def test_repo_project_label_under_projectroot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	monkeypatch.delenv("PYGITWEB_PROJECTROOT", raising=False)
-	root = tmp_path / "root"
-	project = root / "demo"
-	project.mkdir(parents=True)
-	pygit2.init_repository(str(project), bare=False)
-	settings = tmp_path / "settings.json"
-	settings.write_text(json.dumps({"PROJECTROOT": str(root)}), encoding="utf-8")
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
-	assert repo_project_label(project) == "demo"
+    monkeypatch.delenv("PYGITWEB_PROJECTROOT", raising=False)
+    root = tmp_path / "root"
+    project = root / "demo"
+    project.mkdir(parents=True)
+    pygit2.init_repository(str(project), bare=False)
+    settings = tmp_path / "settings.json"
+    settings.write_text(json.dumps({"PROJECTROOT": str(root)}), encoding="utf-8")
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+    assert repo_project_label(project) == "demo"
 
 
 def test_repo_project_label_oserror_falls_back(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	repo_dir = tmp_path / "standalone"
-	repo_dir.mkdir()
-	pygit2.init_repository(str(repo_dir), bare=False)
-	monkeypatch.setattr(
-		"pygittools.tui.project_label.load_pygitweb_layout",
-		lambda: (_ for _ in ()).throw(OSError("missing")),
-	)
-	assert repo_project_label(repo_dir) == str(repo_dir.resolve())
+    repo_dir = tmp_path / "standalone"
+    repo_dir.mkdir()
+    pygit2.init_repository(str(repo_dir), bare=False)
+    monkeypatch.setattr(
+        "pygittools.tui.project_label.load_pygitweb_layout",
+        lambda: (_ for _ in ()).throw(OSError("missing")),
+    )
+    assert repo_project_label(repo_dir) == str(repo_dir.resolve())
 
 
 @pytest.fixture
 def repo(tmp_path: Path) -> pygit2.Repository:
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-	return pygit2.init_repository(str(repo_path), bare=False)
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+    return pygit2.init_repository(str(repo_path), bare=False)
diff --git a/pygittools/src/pygittools/tui/multiline_edit.py b/pygittools/src/pygittools/tui/multiline_edit.py
index 20aede8..b6ad0d1 100644
--- a/pygittools/src/pygittools/tui/multiline_edit.py
+++ b/pygittools/src/pygittools/tui/multiline_edit.py
@@ -11,174 +11,174 @@ from pygittools.tui.keys import KeyLike, is_backspace, is_enter, printable_char
 
 @dataclass(frozen=True, slots=True)
 class _VisualLine:
-	text: str
-	start: int
-	hard_break: bool
+    text: str
+    start: int
+    hard_break: bool
 
 
 class MultilineEdit:
-	def __init__(self, initial: str = "") -> None:
-		self._text = initial
-		self._cursor = len(initial)
-		self._width = 40
-
-	def value(self) -> str:
-		return self._text
-
-	def set_value(self, value: str) -> None:
-		self._text = value
-		self._cursor = len(value)
-
-	@property
-	def cursor(self) -> int:
-		return self._cursor
-
-	def handle_key(self, key: KeyLike) -> bool:
-		# Arrow keys only — vim letters must insert as text while editing.
-		if key.code == "Left":
-			self._cursor = max(0, self._cursor - 1)
-			return True
-		if key.code == "Right":
-			self._cursor = min(len(self._text), self._cursor + 1)
-			return True
-		if key.code == "Up":
-			self._move_vertical(-1)
-			return True
-		if key.code == "Down":
-			self._move_vertical(1)
-			return True
-		if key.code == "Home":
-			line = self._visual_index()
-			self._cursor = self._visual_lines()[line].start
-			return True
-		if key.code == "End":
-			lines = self._visual_lines()
-			line = self._visual_index()
-			visual = lines[line]
-			self._cursor = visual.start + len(visual.text)
-			return True
-		if is_backspace(key):
-			if self._cursor > 0:
-				self._text = self._text[: self._cursor - 1] + self._text[self._cursor :]
-				self._cursor -= 1
-			return True
-		if key.code == "Delete":
-			if self._cursor < len(self._text):
-				self._text = self._text[: self._cursor] + self._text[self._cursor + 1 :]
-			return True
-		if is_enter(key):
-			self._insert("\n")
-			return True
-		char = printable_char(key)
-		if char is not None:
-			self._insert(char)
-			return True
-		return False
-
-	def at_last_visual_line(self) -> bool:
-		lines = self._visual_lines()
-		return self._visual_index() >= len(lines) - 1
-
-	def at_first_visual_line(self) -> bool:
-		return self._visual_index() <= 0
-
-	def render(
-		self,
-		width: int,
-		height: int,
-		*,
-		focused: bool,
-		style: Style,
-		cursor_style: Style,
-	) -> Text:
-		self._width = max(1, width)
-		lines = self._visual_lines()
-		cursor_line = self._visual_index()
-		scroll = 0
-		if height > 0 and cursor_line >= height:
-			scroll = cursor_line - height + 1
-		text = Text()
-		for row in range(height):
-			index = scroll + row
-			if index >= len(lines):
-				text.push_line(Line([Span(" ", style)]))
-				continue
-			visual = lines[index]
-			if focused and index == cursor_line:
-				text.push_line(self._line_with_cursor(visual, style, cursor_style))
-			else:
-				content = visual.text if visual.text else " "
-				text.push_line(Line([Span(content, style)]))
-		return text
-
-	def _insert(self, chunk: str) -> None:
-		self._text = self._text[: self._cursor] + chunk + self._text[self._cursor :]
-		self._cursor += len(chunk)
-
-	def _visual_lines(self) -> list[_VisualLine]:
-		width = max(1, self._width)
-		lines: list[_VisualLine] = []
-		offset = 0
-		parts = self._text.split("\n")
-		for part_index, part in enumerate(parts):
-			hard = part_index < len(parts) - 1
-			if part == "":
-				lines.append(_VisualLine(text="", start=offset, hard_break=hard))
-				offset += 1 if hard else 0
-				continue
-			start = offset
-			while part:
-				chunk = part[:width]
-				part = part[width:]
-				is_last = not part
-				lines.append(
-					_VisualLine(
-						text=chunk,
-						start=start,
-						hard_break=hard and is_last,
-					),
-				)
-				start += len(chunk)
-			offset = start + (1 if hard else 0)
-		if not lines:
-			lines.append(_VisualLine(text="", start=0, hard_break=False))
-		return lines
-
-	def _visual_index(self) -> int:
-		lines = self._visual_lines()
-		for index, visual in enumerate(lines):
-			end = visual.start + len(visual.text)
-			if self._cursor < end:
-				return index
-			if self._cursor == end:
-				return index
-		return len(lines) - 1
-
-	def _move_vertical(self, delta: int) -> None:
-		lines = self._visual_lines()
-		index = self._visual_index()
-		target = max(0, min(len(lines) - 1, index + delta))
-		if target == index:
-			return
-		col = self._cursor - lines[index].start
-		visual = lines[target]
-		self._cursor = visual.start + min(col, len(visual.text))
-
-	def _line_with_cursor(self, visual: _VisualLine, style: Style, cursor_style: Style) -> Line:
-		col = max(0, self._cursor - visual.start)
-		if col < len(visual.text):
-			before = visual.text[:col]
-			cursor_ch = visual.text[col]
-			after = visual.text[col + 1 :]
-			spans = []
-			if before:
-				spans.append(Span(before, style))
-			spans.append(Span(cursor_ch, cursor_style))
-			if after:
-				spans.append(Span(after, style))
-			return Line(spans)
-		spans = []
-		if visual.text:
-			spans.append(Span(visual.text, style))
-		spans.append(Span(" ", cursor_style))
-		return Line(spans)
+    def __init__(self, initial: str = "") -> None:
+        self._text = initial
+        self._cursor = len(initial)
+        self._width = 40
+
+    def value(self) -> str:
+        return self._text
+
+    def set_value(self, value: str) -> None:
+        self._text = value
+        self._cursor = len(value)
+
+    @property
+    def cursor(self) -> int:
+        return self._cursor
+
+    def handle_key(self, key: KeyLike) -> bool:
+        # Arrow keys only — vim letters must insert as text while editing.
+        if key.code == "Left":
+            self._cursor = max(0, self._cursor - 1)
+            return True
+        if key.code == "Right":
+            self._cursor = min(len(self._text), self._cursor + 1)
+            return True
+        if key.code == "Up":
+            self._move_vertical(-1)
+            return True
+        if key.code == "Down":
+            self._move_vertical(1)
+            return True
+        if key.code == "Home":
+            line = self._visual_index()
+            self._cursor = self._visual_lines()[line].start
+            return True
+        if key.code == "End":
+            lines = self._visual_lines()
+            line = self._visual_index()
+            visual = lines[line]
+            self._cursor = visual.start + len(visual.text)
+            return True
+        if is_backspace(key):
+            if self._cursor > 0:
+                self._text = self._text[: self._cursor - 1] + self._text[self._cursor :]
+                self._cursor -= 1
+            return True
+        if key.code == "Delete":
+            if self._cursor < len(self._text):
+                self._text = self._text[: self._cursor] + self._text[self._cursor + 1 :]
+            return True
+        if is_enter(key):
+            self._insert("\n")
+            return True
+        char = printable_char(key)
+        if char is not None:
+            self._insert(char)
+            return True
+        return False
+
+    def at_last_visual_line(self) -> bool:
+        lines = self._visual_lines()
+        return self._visual_index() >= len(lines) - 1
+
+    def at_first_visual_line(self) -> bool:
+        return self._visual_index() <= 0
+
+    def render(
+        self,
+        width: int,
+        height: int,
+        *,
+        focused: bool,
+        style: Style,
+        cursor_style: Style,
+    ) -> Text:
+        self._width = max(1, width)
+        lines = self._visual_lines()
+        cursor_line = self._visual_index()
+        scroll = 0
+        if height > 0 and cursor_line >= height:
+            scroll = cursor_line - height + 1
+        text = Text()
+        for row in range(height):
+            index = scroll + row
+            if index >= len(lines):
+                text.push_line(Line([Span(" ", style)]))
+                continue
+            visual = lines[index]
+            if focused and index == cursor_line:
+                text.push_line(self._line_with_cursor(visual, style, cursor_style))
+            else:
+                content = visual.text if visual.text else " "
+                text.push_line(Line([Span(content, style)]))
+        return text
+
+    def _insert(self, chunk: str) -> None:
+        self._text = self._text[: self._cursor] + chunk + self._text[self._cursor :]
+        self._cursor += len(chunk)
+
+    def _visual_lines(self) -> list[_VisualLine]:
+        width = max(1, self._width)
+        lines: list[_VisualLine] = []
+        offset = 0
+        parts = self._text.split("\n")
+        for part_index, part in enumerate(parts):
+            hard = part_index < len(parts) - 1
+            if part == "":
+                lines.append(_VisualLine(text="", start=offset, hard_break=hard))
+                offset += 1 if hard else 0
+                continue
+            start = offset
+            while part:
+                chunk = part[:width]
+                part = part[width:]
+                is_last = not part
+                lines.append(
+                    _VisualLine(
+                        text=chunk,
+                        start=start,
+                        hard_break=hard and is_last,
+                    ),
+                )
+                start += len(chunk)
+            offset = start + (1 if hard else 0)
+        if not lines:
+            lines.append(_VisualLine(text="", start=0, hard_break=False))
+        return lines
+
+    def _visual_index(self) -> int:
+        lines = self._visual_lines()
+        for index, visual in enumerate(lines):
+            end = visual.start + len(visual.text)
+            if self._cursor < end:
+                return index
+            if self._cursor == end:
+                return index
+        return len(lines) - 1
+
+    def _move_vertical(self, delta: int) -> None:
+        lines = self._visual_lines()
+        index = self._visual_index()
+        target = max(0, min(len(lines) - 1, index + delta))
+        if target == index:
+            return
+        col = self._cursor - lines[index].start
+        visual = lines[target]
+        self._cursor = visual.start + min(col, len(visual.text))
+
+    def _line_with_cursor(self, visual: _VisualLine, style: Style, cursor_style: Style) -> Line:
+        col = max(0, self._cursor - visual.start)
+        if col < len(visual.text):
+            before = visual.text[:col]
+            cursor_ch = visual.text[col]
+            after = visual.text[col + 1 :]
+            spans = []
+            if before:
+                spans.append(Span(before, style))
+            spans.append(Span(cursor_ch, cursor_style))
+            if after:
+                spans.append(Span(after, style))
+            return Line(spans)
+        spans = []
+        if visual.text:
+            spans.append(Span(visual.text, style))
+        spans.append(Span(" ", cursor_style))
+        return Line(spans)
diff --git a/pygittools/src/pygittools/tui/multiline_edit_test.py b/pygittools/src/pygittools/tui/multiline_edit_test.py
index 8d53386..c72e852 100644
--- a/pygittools/src/pygittools/tui/multiline_edit_test.py
+++ b/pygittools/src/pygittools/tui/multiline_edit_test.py
@@ -7,41 +7,41 @@ from pygittools.tui.multiline_edit import MultilineEdit
 
 
 def test_insert_and_newline() -> None:
-	edit = MultilineEdit()
-	edit.handle_key(key("a"))
-	edit.handle_key(key("b"))
-	edit.handle_key(key("Enter"))
-	edit.handle_key(key("c"))
-	assert edit.value() == "ab\nc"
-	assert edit.cursor == 4
+    edit = MultilineEdit()
+    edit.handle_key(key("a"))
+    edit.handle_key(key("b"))
+    edit.handle_key(key("Enter"))
+    edit.handle_key(key("c"))
+    assert edit.value() == "ab\nc"
+    assert edit.cursor == 4
 
 
 def test_arrows_move_cursor() -> None:
-	edit = MultilineEdit("hi")
-	edit.handle_key(key("Left"))
-	assert edit.cursor == 1
-	edit.handle_key(key("Right"))
-	assert edit.cursor == 2
+    edit = MultilineEdit("hi")
+    edit.handle_key(key("Left"))
+    assert edit.cursor == 1
+    edit.handle_key(key("Right"))
+    assert edit.cursor == 2
 
 
 def test_vim_letters_insert_not_move() -> None:
-	edit = MultilineEdit()
-	edit.handle_key(key("j"))
-	edit.handle_key(key("k"))
-	assert edit.value() == "jk"
+    edit = MultilineEdit()
+    edit.handle_key(key("j"))
+    edit.handle_key(key("k"))
+    assert edit.value() == "jk"
 
 
 def test_soft_wrap_render() -> None:
-	edit = MultilineEdit("abcdefgh")
-	edit._width = 3
-	text = edit.render(3, 4, focused=True, style=Style(), cursor_style=Style().reversed())
-	assert text.height >= 3
+    edit = MultilineEdit("abcdefgh")
+    edit._width = 3
+    text = edit.render(3, 4, focused=True, style=Style(), cursor_style=Style().reversed())
+    assert text.height >= 3
 
 
 def test_backspace_and_delete() -> None:
-	edit = MultilineEdit("ab")
-	edit.handle_key(key("Left"))
-	edit.handle_key(key("Delete"))
-	assert edit.value() == "a"
-	edit.handle_key(key("Backspace"))
-	assert edit.value() == ""
+    edit = MultilineEdit("ab")
+    edit.handle_key(key("Left"))
+    edit.handle_key(key("Delete"))
+    assert edit.value() == "a"
+    edit.handle_key(key("Backspace"))
+    assert edit.value() == ""
diff --git a/pygittools/src/pygittools/tui/pages/__init__.py b/pygittools/src/pygittools/tui/pages/__init__.py
index 81bb09b..467e99f 100644
--- a/pygittools/src/pygittools/tui/pages/__init__.py
+++ b/pygittools/src/pygittools/tui/pages/__init__.py
@@ -6,15 +6,15 @@ from pygittools.tui.pages.no_repo import NoRepoPage
 from pygittools.tui.pages.projects import ProjectsPage, projects_page
 
 __all__ = [
-	"ActionsPage",
-	"BranchPickerPage",
-	"HelpPage",
-	"HomePage",
-	"NoRepoPage",
-	"ProjectsPage",
-	"actions_page",
-	"branch_picker_page",
-	"help_page",
-	"home_page",
-	"projects_page",
+    "ActionsPage",
+    "BranchPickerPage",
+    "HelpPage",
+    "HomePage",
+    "NoRepoPage",
+    "ProjectsPage",
+    "actions_page",
+    "branch_picker_page",
+    "help_page",
+    "home_page",
+    "projects_page",
 ]
diff --git a/pygittools/src/pygittools/tui/pages/actions.py b/pygittools/src/pygittools/tui/pages/actions.py
index 2d271dd..5c29d49 100644
--- a/pygittools/src/pygittools/tui/pages/actions.py
+++ b/pygittools/src/pygittools/tui/pages/actions.py
@@ -8,33 +8,33 @@ from enum import Enum, auto
 from pathlib import Path
 
 from pyratatui import (
-	Constraint,
-	Direction,
-	Frame,
-	Layout,
-	List,
-	ListItem,
-	ListState,
-	Paragraph,
-	Rect,
-	Style,
+    Constraint,
+    Direction,
+    Frame,
+    Layout,
+    List,
+    ListItem,
+    ListState,
+    Paragraph,
+    Rect,
+    Style,
 )
 
 from pygittools.actions import ActionSet, ActionSpec, ProjectActions, find_project_root, load_action_sets
 from pygittools.tui.git_colors import current_theme, focus_style
 from pygittools.tui.keys import (
-	KeyLike,
-	is_backtab,
-	is_down,
-	is_enter,
-	is_esc,
-	is_left,
-	is_quit,
-	is_right,
-	is_tab,
-	is_up,
-	move_hint,
-	vim_enabled,
+    KeyLike,
+    is_backtab,
+    is_down,
+    is_enter,
+    is_esc,
+    is_left,
+    is_quit,
+    is_right,
+    is_tab,
+    is_up,
+    move_hint,
+    vim_enabled,
 )
 from pygittools.tui.tabs import render_tab_row
 from pygittools.tui.types import PageAction, PageContext, PageResult
@@ -45,292 +45,292 @@ _SPINNER_INTERVAL_S = 0.08
 
 
 def _worktree_path(repo_path: Path) -> Path:
-	resolved = repo_path.resolve()
-	if resolved.name == ".git":
-		return resolved.parent
-	return resolved
+    resolved = repo_path.resolve()
+    if resolved.name == ".git":
+        return resolved.parent
+    return resolved
 
 
 class _RunState(Enum):
-	IDLE = auto()
-	RUNNING = auto()
-	SUCCESS = auto()
-	FAILURE = auto()
+    IDLE = auto()
+    RUNNING = auto()
+    SUCCESS = auto()
+    FAILURE = auto()
 
 
 class ActionsPage:
-	title = "actions"
-
-	def __init__(self) -> None:
-		self._ctx: PageContext | None = None
-		self._project_root: Path | None = None
-		self._sets: list[ActionSet] = []
-		self._set_index = 0
-		self._list_state = ListState()
-		self._states: dict[tuple[str, str], _RunState] = {}
-		self._messages: dict[tuple[str, str], str] = {}
-		self._active: str | None = None
-		self._active_set: str | None = None
-		self._spinner_index = 0
-		self._last_spin_at = 0.0
-		self._error: str | None = None
-		self._lock = threading.Lock()
-		self._worker: threading.Thread | None = None
-		self._stop = threading.Event()
-
-	def on_enter(self, ctx: PageContext) -> None:
-		self._ctx = ctx
-		self._stop_worker()
-		self._error = None
-		workdir = Path(ctx.repo.workdir) if ctx.repo.workdir else _worktree_path(ctx.repo_path)
-		self._project_root = find_project_root(workdir)
-		self._sets = []
-		self._set_index = 0
-		if self._project_root is not None:
-			try:
-				self._sets = load_action_sets(self._project_root)
-			except (OSError, ValueError) as exc:
-				self._error = str(exc)
-		self._states = {
-			(action_set.name, spec.name): _RunState.IDLE for action_set in self._sets for spec in action_set.specs
-		}
-		self._messages = {}
-		self._active = None
-		self._active_set = None
-		self._spinner_index = 0
-		self._list_state.select(0)
-
-	def on_idle(self) -> None:
-		with self._lock:
-			running = self._active is not None
-		if not running:
-			return
-		now = time.monotonic()
-		if now - self._last_spin_at >= _SPINNER_INTERVAL_S:
-			self._spinner_index = (self._spinner_index + 1) % len(_SPINNER)
-			self._last_spin_at = now
-
-	def status_text(self) -> str:
-		if self._error:
-			return self._error
-		with self._lock:
-			active = self._active
-			active_set = self._active_set
-			if active is not None:
-				msg = self._messages.get((active_set or "", active), "")
-				suffix = f" — {msg}" if msg else ""
-				return f"Running {active}…{suffix}"
-		if self._project_root is None:
-			return "No pyproject.toml or actions.toml found — Esc back"
-		if not self._sets:
-			return "No actions configured — Esc back"
-		move = move_hint()
-		hint = f"Enter run — A run all — {move} — Esc back"
-		if len(self._sets) > 1:
-			set_keys = "H/L or Tab/Shift-Tab" if vim_enabled() else "←/→ or Tab/Shift-Tab"
-			return f"{set_keys} sets — {hint}"
-		return hint
-
-	def draw(self, frame: Frame, area: Rect) -> None:
-		if area.height <= 0:
-			return
-		theme = current_theme()
-		multi = len(self._sets) > 1
-		constraints = [Constraint.length(1), Constraint.fill(1)]
-		if multi:
-			constraints = [Constraint.length(1), Constraint.length(1), Constraint.fill(1)]
-		chunks = Layout().direction(Direction.Vertical).constraints(constraints).split(area)
-		root_label = str(self._project_root) if self._project_root is not None else "(none)"
-		frame.render_widget(Paragraph.from_string(f"Actions ({root_label})").style(theme.header), chunks[0])
-		list_area = chunks[1]
-		if multi:
-			labels = tuple(action_set.name for action_set in self._sets)
-			render_tab_row(frame, chunks[1], "", labels, self._set_index)
-			list_area = chunks[2]
-
-		rows = self._rows()
-		selected = self._list_state.selected or 0
-		items: list[ListItem] = []
-		for index, (key, label) in enumerate(rows):
-			marker = self._marker_for(key)
-			style = self._style_for(key, focused=index == selected)
-			items.append(ListItem(f"{marker} {label}", style))
-		if not items:
-			items.append(ListItem("No actions configured", theme.log_date))
-		frame.render_stateful_list(
-			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			list_area,
-			self._list_state,
-		)
-
-	def handle_key(self, key: KeyLike) -> PageResult:
-		if is_quit(key) or is_esc(key):
-			self._stop_worker()
-			return PageResult(action=PageAction.POP)
-		if self._error is not None:
-			self._error = None
-		with self._lock:
-			busy = self._active is not None
-		if busy:
-			return PageResult()
-
-		if is_backtab(key) or is_left(key):
-			self._cycle_set(-1)
-			return PageResult()
-		if is_tab(key) or is_right(key):
-			self._cycle_set(1)
-			return PageResult()
-
-		rows = self._rows()
-		if not rows:
-			return PageResult()
-
-		if is_up(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(max(0, selected - 1))
-			return PageResult()
-		if is_down(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(min(len(rows) - 1, selected + 1))
-			return PageResult()
-		specs = self._current_specs()
-		if key.code.casefold() == "a" and specs:
-			self._start_run([spec.name for spec in specs], stop_on_failure=True)
-			return PageResult()
-		if is_enter(key):
-			selected = self._list_state.selected or 0
-			row_key, _ = rows[selected]
-			if row_key == "__all__":
-				self._start_run([spec.name for spec in specs], stop_on_failure=True)
-			else:
-				self._start_run([row_key], stop_on_failure=False)
-			return PageResult()
-		return PageResult()
-
-	def _current_specs(self) -> list[ActionSpec]:
-		if not self._sets:
-			return []
-		return list(self._sets[self._set_index].specs)
-
-	def _current_set_name(self) -> str:
-		if not self._sets:
-			return ""
-		return self._sets[self._set_index].name
-
-	def _cycle_set(self, delta: int) -> None:
-		count = len(self._sets)
-		if count < 2:
-			return
-		self._set_index = (self._set_index + delta) % count
-		self._list_state.select(0)
-
-	def _rows(self) -> list[tuple[str, str]]:
-		specs = self._current_specs()
-		if not specs:
-			return []
-		set_name = self._current_set_name()
-		rows: list[tuple[str, str]] = [("__all__", _RUN_ALL_LABEL)]
-		for spec in specs:
-			message = self._messages.get((set_name, spec.name), "")
-			label = spec.name if not message else f"{spec.name} — {message}"
-			rows.append((spec.name, label))
-		return rows
-
-	def _marker_for(self, key: str) -> str:
-		set_name = self._current_set_name()
-		with self._lock:
-			if key == "__all__":
-				if self._active is not None and self._active_set == set_name:
-					return _SPINNER[self._spinner_index]
-				return "▶"
-			state = self._states.get((set_name, key), _RunState.IDLE)
-			if state is _RunState.RUNNING or (key == self._active and self._active_set == set_name):
-				return _SPINNER[self._spinner_index]
-			if state is _RunState.SUCCESS:
-				return "✓"
-			if state is _RunState.FAILURE:
-				return "✗"
-			return "·"
-
-	def _style_for(self, key: str, *, focused: bool) -> Style:
-		theme = current_theme()
-		set_name = self._current_set_name()
-		with self._lock:
-			if key == "__all__":
-				base = theme.header
-			else:
-				state = self._states.get((set_name, key), _RunState.IDLE)
-				running = state is _RunState.RUNNING or (key == self._active and self._active_set == set_name)
-				if state is _RunState.SUCCESS:
-					base = theme.staged
-				elif state is _RunState.FAILURE:
-					base = theme.unstaged
-				elif running:
-					base = theme.log_hash
-				else:
-					base = theme.normal
-		return focus_style(base) if focused else base
-
-	def _start_run(self, names: list[str], *, stop_on_failure: bool) -> None:
-		if not names or self._project_root is None:
-			return
-		set_name = self._current_set_name()
-		specs = self._current_specs()
-		self._stop_worker()
-		with self._lock:
-			for name in names:
-				self._states[(set_name, name)] = _RunState.IDLE
-				self._messages.pop((set_name, name), None)
-			self._active = names[0]
-			self._active_set = set_name
-			self._states[(set_name, names[0])] = _RunState.RUNNING
-			self._spinner_index = 0
-			self._last_spin_at = time.monotonic()
-		root = self._project_root
-		self._stop.clear()
-		self._worker = threading.Thread(
-			target=self._run_worker,
-			args=(root, specs, names, set_name, stop_on_failure),
-			daemon=True,
-		)
-		self._worker.start()
-
-	def _run_worker(
-		self,
-		root: Path,
-		specs: list[ActionSpec],
-		names: list[str],
-		set_name: str,
-		stop_on_failure: bool,
-	) -> None:
-		runner = ProjectActions(root, specs)
-		for name in names:
-			if self._stop.is_set():
-				break
-			with self._lock:
-				self._active = name
-				self._active_set = set_name
-				self._states[(set_name, name)] = _RunState.RUNNING
-			ok, message = runner.action(name)
-			with self._lock:
-				self._states[(set_name, name)] = _RunState.SUCCESS if ok else _RunState.FAILURE
-				self._messages[(set_name, name)] = message
-			if not ok and stop_on_failure:
-				break
-		with self._lock:
-			self._active = None
-			self._active_set = None
-
-	def _stop_worker(self) -> None:
-		self._stop.set()
-		worker = self._worker
-		if worker is not None and worker.is_alive():
-			worker.join(timeout=0.2)
-		self._worker = None
-		with self._lock:
-			self._active = None
-			self._active_set = None
+    title = "actions"
+
+    def __init__(self) -> None:
+        self._ctx: PageContext | None = None
+        self._project_root: Path | None = None
+        self._sets: list[ActionSet] = []
+        self._set_index = 0
+        self._list_state = ListState()
+        self._states: dict[tuple[str, str], _RunState] = {}
+        self._messages: dict[tuple[str, str], str] = {}
+        self._active: str | None = None
+        self._active_set: str | None = None
+        self._spinner_index = 0
+        self._last_spin_at = 0.0
+        self._error: str | None = None
+        self._lock = threading.Lock()
+        self._worker: threading.Thread | None = None
+        self._stop = threading.Event()
+
+    def on_enter(self, ctx: PageContext) -> None:
+        self._ctx = ctx
+        self._stop_worker()
+        self._error = None
+        workdir = Path(ctx.repo.workdir) if ctx.repo.workdir else _worktree_path(ctx.repo_path)
+        self._project_root = find_project_root(workdir)
+        self._sets = []
+        self._set_index = 0
+        if self._project_root is not None:
+            try:
+                self._sets = load_action_sets(self._project_root)
+            except (OSError, ValueError) as exc:
+                self._error = str(exc)
+        self._states = {
+            (action_set.name, spec.name): _RunState.IDLE for action_set in self._sets for spec in action_set.specs
+        }
+        self._messages = {}
+        self._active = None
+        self._active_set = None
+        self._spinner_index = 0
+        self._list_state.select(0)
+
+    def on_idle(self) -> None:
+        with self._lock:
+            running = self._active is not None
+        if not running:
+            return
+        now = time.monotonic()
+        if now - self._last_spin_at >= _SPINNER_INTERVAL_S:
+            self._spinner_index = (self._spinner_index + 1) % len(_SPINNER)
+            self._last_spin_at = now
+
+    def status_text(self) -> str:
+        if self._error:
+            return self._error
+        with self._lock:
+            active = self._active
+            active_set = self._active_set
+            if active is not None:
+                msg = self._messages.get((active_set or "", active), "")
+                suffix = f" — {msg}" if msg else ""
+                return f"Running {active}…{suffix}"
+        if self._project_root is None:
+            return "No pyproject.toml or actions.toml found — Esc back"
+        if not self._sets:
+            return "No actions configured — Esc back"
+        move = move_hint()
+        hint = f"Enter run — A run all — {move} — Esc back"
+        if len(self._sets) > 1:
+            set_keys = "H/L or Tab/Shift-Tab" if vim_enabled() else "←/→ or Tab/Shift-Tab"
+            return f"{set_keys} sets — {hint}"
+        return hint
+
+    def draw(self, frame: Frame, area: Rect) -> None:
+        if area.height <= 0:
+            return
+        theme = current_theme()
+        multi = len(self._sets) > 1
+        constraints = [Constraint.length(1), Constraint.fill(1)]
+        if multi:
+            constraints = [Constraint.length(1), Constraint.length(1), Constraint.fill(1)]
+        chunks = Layout().direction(Direction.Vertical).constraints(constraints).split(area)
+        root_label = str(self._project_root) if self._project_root is not None else "(none)"
+        frame.render_widget(Paragraph.from_string(f"Actions ({root_label})").style(theme.header), chunks[0])
+        list_area = chunks[1]
+        if multi:
+            labels = tuple(action_set.name for action_set in self._sets)
+            render_tab_row(frame, chunks[1], "", labels, self._set_index)
+            list_area = chunks[2]
+
+        rows = self._rows()
+        selected = self._list_state.selected or 0
+        items: list[ListItem] = []
+        for index, (key, label) in enumerate(rows):
+            marker = self._marker_for(key)
+            style = self._style_for(key, focused=index == selected)
+            items.append(ListItem(f"{marker} {label}", style))
+        if not items:
+            items.append(ListItem("No actions configured", theme.log_date))
+        frame.render_stateful_list(
+            List(items).highlight_style(theme.focus).highlight_symbol(""),
+            list_area,
+            self._list_state,
+        )
+
+    def handle_key(self, key: KeyLike) -> PageResult:
+        if is_quit(key) or is_esc(key):
+            self._stop_worker()
+            return PageResult(action=PageAction.POP)
+        if self._error is not None:
+            self._error = None
+        with self._lock:
+            busy = self._active is not None
+        if busy:
+            return PageResult()
+
+        if is_backtab(key) or is_left(key):
+            self._cycle_set(-1)
+            return PageResult()
+        if is_tab(key) or is_right(key):
+            self._cycle_set(1)
+            return PageResult()
+
+        rows = self._rows()
+        if not rows:
+            return PageResult()
+
+        if is_up(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(max(0, selected - 1))
+            return PageResult()
+        if is_down(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(min(len(rows) - 1, selected + 1))
+            return PageResult()
+        specs = self._current_specs()
+        if key.code.casefold() == "a" and specs:
+            self._start_run([spec.name for spec in specs], stop_on_failure=True)
+            return PageResult()
+        if is_enter(key):
+            selected = self._list_state.selected or 0
+            row_key, _ = rows[selected]
+            if row_key == "__all__":
+                self._start_run([spec.name for spec in specs], stop_on_failure=True)
+            else:
+                self._start_run([row_key], stop_on_failure=False)
+            return PageResult()
+        return PageResult()
+
+    def _current_specs(self) -> list[ActionSpec]:
+        if not self._sets:
+            return []
+        return list(self._sets[self._set_index].specs)
+
+    def _current_set_name(self) -> str:
+        if not self._sets:
+            return ""
+        return self._sets[self._set_index].name
+
+    def _cycle_set(self, delta: int) -> None:
+        count = len(self._sets)
+        if count < 2:
+            return
+        self._set_index = (self._set_index + delta) % count
+        self._list_state.select(0)
+
+    def _rows(self) -> list[tuple[str, str]]:
+        specs = self._current_specs()
+        if not specs:
+            return []
+        set_name = self._current_set_name()
+        rows: list[tuple[str, str]] = [("__all__", _RUN_ALL_LABEL)]
+        for spec in specs:
+            message = self._messages.get((set_name, spec.name), "")
+            label = spec.name if not message else f"{spec.name} — {message}"
+            rows.append((spec.name, label))
+        return rows
+
+    def _marker_for(self, key: str) -> str:
+        set_name = self._current_set_name()
+        with self._lock:
+            if key == "__all__":
+                if self._active is not None and self._active_set == set_name:
+                    return _SPINNER[self._spinner_index]
+                return "▶"
+            state = self._states.get((set_name, key), _RunState.IDLE)
+            if state is _RunState.RUNNING or (key == self._active and self._active_set == set_name):
+                return _SPINNER[self._spinner_index]
+            if state is _RunState.SUCCESS:
+                return "✓"
+            if state is _RunState.FAILURE:
+                return "✗"
+            return "·"
+
+    def _style_for(self, key: str, *, focused: bool) -> Style:
+        theme = current_theme()
+        set_name = self._current_set_name()
+        with self._lock:
+            if key == "__all__":
+                base = theme.header
+            else:
+                state = self._states.get((set_name, key), _RunState.IDLE)
+                running = state is _RunState.RUNNING or (key == self._active and self._active_set == set_name)
+                if state is _RunState.SUCCESS:
+                    base = theme.staged
+                elif state is _RunState.FAILURE:
+                    base = theme.unstaged
+                elif running:
+                    base = theme.log_hash
+                else:
+                    base = theme.normal
+        return focus_style(base) if focused else base
+
+    def _start_run(self, names: list[str], *, stop_on_failure: bool) -> None:
+        if not names or self._project_root is None:
+            return
+        set_name = self._current_set_name()
+        specs = self._current_specs()
+        self._stop_worker()
+        with self._lock:
+            for name in names:
+                self._states[(set_name, name)] = _RunState.IDLE
+                self._messages.pop((set_name, name), None)
+            self._active = names[0]
+            self._active_set = set_name
+            self._states[(set_name, names[0])] = _RunState.RUNNING
+            self._spinner_index = 0
+            self._last_spin_at = time.monotonic()
+        root = self._project_root
+        self._stop.clear()
+        self._worker = threading.Thread(
+            target=self._run_worker,
+            args=(root, specs, names, set_name, stop_on_failure),
+            daemon=True,
+        )
+        self._worker.start()
+
+    def _run_worker(
+        self,
+        root: Path,
+        specs: list[ActionSpec],
+        names: list[str],
+        set_name: str,
+        stop_on_failure: bool,
+    ) -> None:
+        runner = ProjectActions(root, specs)
+        for name in names:
+            if self._stop.is_set():
+                break
+            with self._lock:
+                self._active = name
+                self._active_set = set_name
+                self._states[(set_name, name)] = _RunState.RUNNING
+            ok, message = runner.action(name)
+            with self._lock:
+                self._states[(set_name, name)] = _RunState.SUCCESS if ok else _RunState.FAILURE
+                self._messages[(set_name, name)] = message
+            if not ok and stop_on_failure:
+                break
+        with self._lock:
+            self._active = None
+            self._active_set = None
+
+    def _stop_worker(self) -> None:
+        self._stop.set()
+        worker = self._worker
+        if worker is not None and worker.is_alive():
+            worker.join(timeout=0.2)
+        self._worker = None
+        with self._lock:
+            self._active = None
+            self._active_set = None
 
 
 def actions_page() -> ActionsPage:
-	return ActionsPage()
+    return ActionsPage()
diff --git a/pygittools/src/pygittools/tui/pages/actions_test.py b/pygittools/src/pygittools/tui/pages/actions_test.py
index 8a59f48..1c24a3a 100644
--- a/pygittools/src/pygittools/tui/pages/actions_test.py
+++ b/pygittools/src/pygittools/tui/pages/actions_test.py
@@ -18,162 +18,162 @@ from pygittools.tui.types import PageAction, PageContext
 
 
 def _repo_with_actions(tmp_path: Path, actions_body: str) -> PageContext:
-	repo_dir = tmp_path / "repo"
-	repo_dir.mkdir()
-	repo = init_repository(str(repo_dir), bare=False)
-	(repo_dir / "pyproject.toml").write_text(
-		f'[project]\nname = "demo"\nversion = "0"\n\n[tool.pgt]\nactions = {actions_body}\n',
-		encoding="utf-8",
-	)
-	venv_bin = repo_dir / ".venv" / "bin"
-	venv_bin.mkdir(parents=True)
-	(venv_bin / "python").symlink_to(sys.executable)
-	return PageContext(repo=repo, repo_path=Path(repo.path))
+    repo_dir = tmp_path / "repo"
+    repo_dir.mkdir()
+    repo = init_repository(str(repo_dir), bare=False)
+    (repo_dir / "pyproject.toml").write_text(
+        f'[project]\nname = "demo"\nversion = "0"\n\n[tool.pgt]\nactions = {actions_body}\n',
+        encoding="utf-8",
+    )
+    venv_bin = repo_dir / ".venv" / "bin"
+    venv_bin.mkdir(parents=True)
+    (venv_bin / "python").symlink_to(sys.executable)
+    return PageContext(repo=repo, repo_path=Path(repo.path))
 
 
 def test_actions_page_empty(tmp_path: Path) -> None:
-	repo_dir = tmp_path / "repo"
-	repo_dir.mkdir()
-	repo = init_repository(str(repo_dir), bare=False)
-	ctx = PageContext(repo=repo, repo_path=Path(repo.path))
-	page = actions_page()
-	page.on_enter(ctx)
-	assert "No pyproject" in page.status_text() or "No [tool.pgt]" in page.status_text()
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	assert page.handle_key(key("Esc")).action == PageAction.POP
+    repo_dir = tmp_path / "repo"
+    repo_dir.mkdir()
+    repo = init_repository(str(repo_dir), bare=False)
+    ctx = PageContext(repo=repo, repo_path=Path(repo.path))
+    page = actions_page()
+    page.on_enter(ctx)
+    assert "No pyproject" in page.status_text() or "No [tool.pgt]" in page.status_text()
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    assert page.handle_key(key("Esc")).action == PageAction.POP
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
 def test_actions_page_lists_and_run(tmp_path: Path) -> None:
-	ctx = _repo_with_actions(tmp_path, '[{ name = "ok", script = "ok.py" }]')
-	init_tui_color_theme(ctx.repo)
-	workdir = Path(ctx.repo.workdir)
-	(workdir / "ok.py").write_text("print('done')\n", encoding="utf-8")
-	page = ActionsPage()
-	page.on_enter(ctx)
-	assert "Enter run" in page.status_text()
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	# select the action row (index 1; 0 is Run all)
-	page.handle_key(key("Down"))
-	page.handle_key(key("Enter"))
-	deadline = time.monotonic() + 5.0
-	while time.monotonic() < deadline:
-		page.on_idle()
-		with page._lock:
-			if page._active is None and page._states.get(("default", "ok")) is not _RunState.IDLE:
-				break
-		time.sleep(0.05)
-	assert page._states[("default", "ok")].name == "SUCCESS"
-	assert page._messages[("default", "ok")] == "done"
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    ctx = _repo_with_actions(tmp_path, '[{ name = "ok", script = "ok.py" }]')
+    init_tui_color_theme(ctx.repo)
+    workdir = Path(ctx.repo.workdir)
+    (workdir / "ok.py").write_text("print('done')\n", encoding="utf-8")
+    page = ActionsPage()
+    page.on_enter(ctx)
+    assert "Enter run" in page.status_text()
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    # select the action row (index 1; 0 is Run all)
+    page.handle_key(key("Down"))
+    page.handle_key(key("Enter"))
+    deadline = time.monotonic() + 5.0
+    while time.monotonic() < deadline:
+        page.on_idle()
+        with page._lock:
+            if page._active is None and page._states.get(("default", "ok")) is not _RunState.IDLE:
+                break
+        time.sleep(0.05)
+    assert page._states[("default", "ok")].name == "SUCCESS"
+    assert page._messages[("default", "ok")] == "done"
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
 def test_actions_page_run_all_stops_on_failure(tmp_path: Path) -> None:
-	ctx = _repo_with_actions(
-		tmp_path,
-		"""[
+    ctx = _repo_with_actions(
+        tmp_path,
+        """[
   { name = "bad", script = "bad.py" },
   { name = "good", script = "good.py" },
 ]""",
-	)
-	init_tui_color_theme(ctx.repo)
-	workdir = Path(ctx.repo.workdir)
-	(workdir / "bad.py").write_text("import sys\nsys.exit(1)\n", encoding="utf-8")
-	(workdir / "good.py").write_text("print('should-not-run')\n", encoding="utf-8")
-	page = ActionsPage()
-	page.on_enter(ctx)
-	page.handle_key(key("a"))
-	deadline = time.monotonic() + 5.0
-	while time.monotonic() < deadline:
-		page.on_idle()
-		with page._lock:
-			if page._active is None:
-				break
-		time.sleep(0.05)
-	assert page._states[("default", "bad")] is _RunState.FAILURE
-	assert page._states[("default", "good")] is _RunState.IDLE
-	assert ("default", "good") not in page._messages
+    )
+    init_tui_color_theme(ctx.repo)
+    workdir = Path(ctx.repo.workdir)
+    (workdir / "bad.py").write_text("import sys\nsys.exit(1)\n", encoding="utf-8")
+    (workdir / "good.py").write_text("print('should-not-run')\n", encoding="utf-8")
+    page = ActionsPage()
+    page.on_enter(ctx)
+    page.handle_key(key("a"))
+    deadline = time.monotonic() + 5.0
+    while time.monotonic() < deadline:
+        page.on_idle()
+        with page._lock:
+            if page._active is None:
+                break
+        time.sleep(0.05)
+    assert page._states[("default", "bad")] is _RunState.FAILURE
+    assert page._states[("default", "good")] is _RunState.IDLE
+    assert ("default", "good") not in page._messages
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
 def test_actions_page_opens_from_factory(tmp_path: Path) -> None:
-	ctx = _repo_with_actions(tmp_path, "[]")
-	page = actions_page()
-	page.on_enter(ctx)
-	assert page.title == "actions"
-	assert isinstance(ctx.repo, Repository)
-	assert "No actions configured" in page.status_text()
+    ctx = _repo_with_actions(tmp_path, "[]")
+    page = actions_page()
+    page.on_enter(ctx)
+    assert page.title == "actions"
+    assert isinstance(ctx.repo, Repository)
+    assert "No actions configured" in page.status_text()
 
 
 def _repo_dir(tmp_path: Path) -> tuple[PageContext, Path]:
-	repo_dir = tmp_path / "repo"
-	repo_dir.mkdir()
-	repo = init_repository(str(repo_dir), bare=False)
-	return PageContext(repo=repo, repo_path=Path(repo.path)), repo_dir
+    repo_dir = tmp_path / "repo"
+    repo_dir.mkdir()
+    repo = init_repository(str(repo_dir), bare=False)
+    return PageContext(repo=repo, repo_path=Path(repo.path)), repo_dir
 
 
 def test_actions_page_cycles_sets(tmp_path: Path) -> None:
-	ctx, repo_dir = _repo_dir(tmp_path)
-	(repo_dir / "pyproject.toml").write_text(
-		'[project]\nname = "demo"\n\n[tool.pgt]\nactions = [{ name = "lint", script = "lint.py" }]\n',
-		encoding="utf-8",
-	)
-	(repo_dir / "actions.toml").write_text(
-		'[release]\nactions = [{ name = "build", script = "build.py" }]\n',
-		encoding="utf-8",
-	)
-	page = ActionsPage()
-	page.on_enter(ctx)
-	assert "Tab/Shift-Tab" in page.status_text()
-	assert [label for _, label in page._rows()] == ["Run all", "lint"]
-	page.handle_key(key("Down"))
-	page.handle_key(key("Tab"))
-	assert [label for _, label in page._rows()] == ["Run all", "build"]
-	assert page._list_state.selected == 0
-	page.handle_key(key("Tab", shift=True))
-	assert [label for _, label in page._rows()] == ["Run all", "lint"]
-	page.handle_key(key("BackTab"))
-	assert [label for _, label in page._rows()] == ["Run all", "build"]
-	page.handle_key(key("Left"))
-	assert [label for _, label in page._rows()] == ["Run all", "lint"]
-	page.handle_key(key("Right"))
-	assert [label for _, label in page._rows()] == ["Run all", "build"]
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    ctx, repo_dir = _repo_dir(tmp_path)
+    (repo_dir / "pyproject.toml").write_text(
+        '[project]\nname = "demo"\n\n[tool.pgt]\nactions = [{ name = "lint", script = "lint.py" }]\n',
+        encoding="utf-8",
+    )
+    (repo_dir / "actions.toml").write_text(
+        '[release]\nactions = [{ name = "build", script = "build.py" }]\n',
+        encoding="utf-8",
+    )
+    page = ActionsPage()
+    page.on_enter(ctx)
+    assert "Tab/Shift-Tab" in page.status_text()
+    assert [label for _, label in page._rows()] == ["Run all", "lint"]
+    page.handle_key(key("Down"))
+    page.handle_key(key("Tab"))
+    assert [label for _, label in page._rows()] == ["Run all", "build"]
+    assert page._list_state.selected == 0
+    page.handle_key(key("Tab", shift=True))
+    assert [label for _, label in page._rows()] == ["Run all", "lint"]
+    page.handle_key(key("BackTab"))
+    assert [label for _, label in page._rows()] == ["Run all", "build"]
+    page.handle_key(key("Left"))
+    assert [label for _, label in page._rows()] == ["Run all", "lint"]
+    page.handle_key(key("Right"))
+    assert [label for _, label in page._rows()] == ["Run all", "build"]
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
 
 
 def test_actions_page_run_all_uses_current_set(tmp_path: Path) -> None:
-	ctx, repo_dir = _repo_dir(tmp_path)
-	(repo_dir / "actions.toml").write_text(
-		"""
+    ctx, repo_dir = _repo_dir(tmp_path)
+    (repo_dir / "actions.toml").write_text(
+        """
 [first]
 actions = [{ name = "one", script = "one.py" }]
 
 [second]
 actions = [{ name = "two", script = "two.py" }]
 """,
-		encoding="utf-8",
-	)
-	page = ActionsPage()
-	page.on_enter(ctx)
-	page.handle_key(key("Tab"))
-	page.handle_key(key("a"))
-	deadline = time.monotonic() + 5.0
-	while time.monotonic() < deadline:
-		page.on_idle()
-		with page._lock:
-			if page._active is None:
-				break
-		time.sleep(0.05)
-	assert page._states[("first", "one")] is _RunState.IDLE
-	assert page._states[("second", "two")] is _RunState.FAILURE
-	assert "Script not found" in page._messages[("second", "two")]
+        encoding="utf-8",
+    )
+    page = ActionsPage()
+    page.on_enter(ctx)
+    page.handle_key(key("Tab"))
+    page.handle_key(key("a"))
+    deadline = time.monotonic() + 5.0
+    while time.monotonic() < deadline:
+        page.on_idle()
+        with page._lock:
+            if page._active is None:
+                break
+        time.sleep(0.05)
+    assert page._states[("first", "one")] is _RunState.IDLE
+    assert page._states[("second", "two")] is _RunState.FAILURE
+    assert "Script not found" in page._messages[("second", "two")]
 
 
 def test_actions_page_bad_actions_toml(tmp_path: Path) -> None:
-	ctx, repo_dir = _repo_dir(tmp_path)
-	(repo_dir / "actions.toml").write_text("quality = 1\n", encoding="utf-8")
-	page = ActionsPage()
-	page.on_enter(ctx)
-	assert "unexpected key" in page.status_text()
-	assert page.handle_key(key("Esc")).action == PageAction.POP
+    ctx, repo_dir = _repo_dir(tmp_path)
+    (repo_dir / "actions.toml").write_text("quality = 1\n", encoding="utf-8")
+    page = ActionsPage()
+    page.on_enter(ctx)
+    assert "unexpected key" in page.status_text()
+    assert page.handle_key(key("Esc")).action == PageAction.POP
diff --git a/pygittools/src/pygittools/tui/pages/branches.py b/pygittools/src/pygittools/tui/pages/branches.py
index fc1e7a1..91b8236 100644
--- a/pygittools/src/pygittools/tui/pages/branches.py
+++ b/pygittools/src/pygittools/tui/pages/branches.py
@@ -3,24 +3,24 @@
 from __future__ import annotations
 
 from pyratatui import (
-	Constraint,
-	Direction,
-	Frame,
-	Layout,
-	List,
-	ListItem,
-	ListState,
-	Paragraph,
-	Rect,
-	TextPrompt,
-	TextState,
+    Constraint,
+    Direction,
+    Frame,
+    Layout,
+    List,
+    ListItem,
+    ListState,
+    Paragraph,
+    Rect,
+    TextPrompt,
+    TextState,
 )
 
 from pygittools.tui.branches import (
-	checkout_branch,
-	create_branch_from_current,
-	current_branch_name,
-	list_local_branches,
+    checkout_branch,
+    create_branch_from_current,
+    current_branch_name,
+    list_local_branches,
 )
 from pygittools.tui.git_colors import current_theme, focus_style
 from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up, move_hint
@@ -28,144 +28,144 @@ from pygittools.tui.types import PageAction, PageContext, PageResult
 
 
 class BranchPickerPage:
-	title = "branches"
-
-	def __init__(self) -> None:
-		self._ctx: PageContext | None = None
-		self._filter_state = TextState()
-		self._filter_state.focus()
-		self._branches: list[str] = []
-		self._filtered: list[str] = []
-		self._list_state = ListState()
-		self._focus_filter = True
-		self._error: str | None = None
-
-	def on_enter(self, ctx: PageContext) -> None:
-		self._ctx = ctx
-		self._filter_state.set_value("")
-		self._filter_state.focus()
-		self._focus_filter = True
-		self._error = None
-		self._list_state.select(0)
-		self._reload_branches()
-
-	def status_text(self) -> str:
-		if self._error:
-			return self._error
-		if not self._filtered:
-			if self._filter_state.value().strip():
-				return "Enter create branch — type name — Esc back"
-			return "No matching branches — type to filter, Esc back"
-		return f"Enter checkout — type to filter — {move_hint()} — Esc back"
-
-	def draw(self, frame: Frame, area: Rect) -> None:
-		if area.height <= 0:
-			return
-		theme = current_theme()
-		chunks = (
-			Layout()
-			.direction(Direction.Vertical)
-			.constraints([Constraint.length(1), Constraint.length(1), Constraint.fill(1)])
-			.split(area)
-		)
-		frame.render_widget(Paragraph.from_string("Switch branch").style(theme.header), chunks[0])
-		if self._focus_filter:
-			self._filter_state.focus()
-		else:
-			self._filter_state.blur()
-		frame.render_text_prompt(TextPrompt("Filter: "), chunks[1], self._filter_state)
-
-		current = current_branch_name(self._ctx.repo) if self._ctx is not None else ""
-		items: list[ListItem] = []
-		selected = self._list_state.selected or 0
-		for index, branch in enumerate(self._filtered):
-			marker = "* " if branch == current else "  "
-			focused = not self._focus_filter and index == selected
-			style = focus_style(theme.normal) if focused else theme.normal
-			items.append(ListItem(f"{marker}{branch}", style))
-		if not items:
-			items.append(ListItem("(no matches)", theme.log_date))
-		frame.render_stateful_list(
-			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			chunks[2],
-			self._list_state,
-		)
-
-	def handle_key(self, key: KeyLike) -> PageResult:
-		if is_esc(key) or (not self._focus_filter and is_quit(key)):
-			return PageResult(action=PageAction.POP)
-		if self._error is not None:
-			self._error = None
-
-		ctx = self._ctx
-		if ctx is None:
-			return PageResult()
-
-		if self._focus_filter:
-			if is_down(key) and self._filtered:
-				self._focus_filter = False
-				self._list_state.select(0)
-				return PageResult()
-			if is_enter(key):
-				if not self._filtered:
-					return self._try_create_branch(ctx)
-				self._focus_filter = False
-				self._list_state.select(0)
-				return PageResult()
-			self._filter_state.focus()
-			self._filter_state.handle_key(key)
-			self._apply_filter()
-			return PageResult()
-
-		if is_up(key):
-			selected = self._list_state.selected or 0
-			if selected <= 0:
-				self._focus_filter = True
-				return PageResult()
-			self._list_state.select(selected - 1)
-			return PageResult()
-		if is_down(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(min(max(0, len(self._filtered) - 1), selected + 1))
-			return PageResult()
-		if is_enter(key) and self._filtered:
-			branch = self._filtered[self._list_state.selected or 0]
-			ok, error = checkout_branch(ctx.repo, branch)
-			if ok:
-				return PageResult(action=PageAction.POP, repo_changed=True)
-			self._error = error
-			return PageResult()
-		# Typing jumps back to filter.
-		self._focus_filter = True
-		self._filter_state.focus()
-		self._filter_state.handle_key(key)
-		self._apply_filter()
-		return PageResult()
-
-	def _try_create_branch(self, ctx: PageContext) -> PageResult:
-		ok, error = create_branch_from_current(ctx.repo, self._filter_state.value())
-		if ok:
-			return PageResult(action=PageAction.POP, repo_changed=True)
-		self._error = error
-		return PageResult()
-
-	def _reload_branches(self) -> None:
-		if self._ctx is None:
-			self._branches = []
-			self._filtered = []
-			return
-		self._branches = list_local_branches(self._ctx.repo)
-		self._apply_filter()
-
-	def _apply_filter(self) -> None:
-		needle = self._filter_state.value().casefold()
-		if needle:
-			self._filtered = [branch for branch in self._branches if needle in branch.casefold()]
-		else:
-			self._filtered = list(self._branches)
-		selected = self._list_state.selected or 0
-		self._list_state.select(min(selected, max(0, len(self._filtered) - 1)))
+    title = "branches"
+
+    def __init__(self) -> None:
+        self._ctx: PageContext | None = None
+        self._filter_state = TextState()
+        self._filter_state.focus()
+        self._branches: list[str] = []
+        self._filtered: list[str] = []
+        self._list_state = ListState()
+        self._focus_filter = True
+        self._error: str | None = None
+
+    def on_enter(self, ctx: PageContext) -> None:
+        self._ctx = ctx
+        self._filter_state.set_value("")
+        self._filter_state.focus()
+        self._focus_filter = True
+        self._error = None
+        self._list_state.select(0)
+        self._reload_branches()
+
+    def status_text(self) -> str:
+        if self._error:
+            return self._error
+        if not self._filtered:
+            if self._filter_state.value().strip():
+                return "Enter create branch — type name — Esc back"
+            return "No matching branches — type to filter, Esc back"
+        return f"Enter checkout — type to filter — {move_hint()} — Esc back"
+
+    def draw(self, frame: Frame, area: Rect) -> None:
+        if area.height <= 0:
+            return
+        theme = current_theme()
+        chunks = (
+            Layout()
+            .direction(Direction.Vertical)
+            .constraints([Constraint.length(1), Constraint.length(1), Constraint.fill(1)])
+            .split(area)
+        )
+        frame.render_widget(Paragraph.from_string("Switch branch").style(theme.header), chunks[0])
+        if self._focus_filter:
+            self._filter_state.focus()
+        else:
+            self._filter_state.blur()
+        frame.render_text_prompt(TextPrompt("Filter: "), chunks[1], self._filter_state)
+
+        current = current_branch_name(self._ctx.repo) if self._ctx is not None else ""
+        items: list[ListItem] = []
+        selected = self._list_state.selected or 0
+        for index, branch in enumerate(self._filtered):
+            marker = "* " if branch == current else "  "
+            focused = not self._focus_filter and index == selected
+            style = focus_style(theme.normal) if focused else theme.normal
+            items.append(ListItem(f"{marker}{branch}", style))
+        if not items:
+            items.append(ListItem("(no matches)", theme.log_date))
+        frame.render_stateful_list(
+            List(items).highlight_style(theme.focus).highlight_symbol(""),
+            chunks[2],
+            self._list_state,
+        )
+
+    def handle_key(self, key: KeyLike) -> PageResult:
+        if is_esc(key) or (not self._focus_filter and is_quit(key)):
+            return PageResult(action=PageAction.POP)
+        if self._error is not None:
+            self._error = None
+
+        ctx = self._ctx
+        if ctx is None:
+            return PageResult()
+
+        if self._focus_filter:
+            if is_down(key) and self._filtered:
+                self._focus_filter = False
+                self._list_state.select(0)
+                return PageResult()
+            if is_enter(key):
+                if not self._filtered:
+                    return self._try_create_branch(ctx)
+                self._focus_filter = False
+                self._list_state.select(0)
+                return PageResult()
+            self._filter_state.focus()
+            self._filter_state.handle_key(key)
+            self._apply_filter()
+            return PageResult()
+
+        if is_up(key):
+            selected = self._list_state.selected or 0
+            if selected <= 0:
+                self._focus_filter = True
+                return PageResult()
+            self._list_state.select(selected - 1)
+            return PageResult()
+        if is_down(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(min(max(0, len(self._filtered) - 1), selected + 1))
+            return PageResult()
+        if is_enter(key) and self._filtered:
+            branch = self._filtered[self._list_state.selected or 0]
+            ok, error = checkout_branch(ctx.repo, branch)
+            if ok:
+                return PageResult(action=PageAction.POP, repo_changed=True)
+            self._error = error
+            return PageResult()
+        # Typing jumps back to filter.
+        self._focus_filter = True
+        self._filter_state.focus()
+        self._filter_state.handle_key(key)
+        self._apply_filter()
+        return PageResult()
+
+    def _try_create_branch(self, ctx: PageContext) -> PageResult:
+        ok, error = create_branch_from_current(ctx.repo, self._filter_state.value())
+        if ok:
+            return PageResult(action=PageAction.POP, repo_changed=True)
+        self._error = error
+        return PageResult()
+
+    def _reload_branches(self) -> None:
+        if self._ctx is None:
+            self._branches = []
+            self._filtered = []
+            return
+        self._branches = list_local_branches(self._ctx.repo)
+        self._apply_filter()
+
+    def _apply_filter(self) -> None:
+        needle = self._filter_state.value().casefold()
+        if needle:
+            self._filtered = [branch for branch in self._branches if needle in branch.casefold()]
+        else:
+            self._filtered = list(self._branches)
+        selected = self._list_state.selected or 0
+        self._list_state.select(min(selected, max(0, len(self._filtered) - 1)))
 
 
 def branch_picker_page() -> BranchPickerPage:
-	return BranchPickerPage()
+    return BranchPickerPage()
diff --git a/pygittools/src/pygittools/tui/pages/help.py b/pygittools/src/pygittools/tui/pages/help.py
index 98c5389..e60ba9a 100644
--- a/pygittools/src/pygittools/tui/pages/help.py
+++ b/pygittools/src/pygittools/tui/pages/help.py
@@ -11,49 +11,49 @@ from pygittools.tui.types import PageAction, PageContext, PageResult
 
 
 def _load_help_lines() -> list[str]:
-	text = files("pygittools").joinpath("help.md").read_text(encoding="utf-8")
-	return text.splitlines()
+    text = files("pygittools").joinpath("help.md").read_text(encoding="utf-8")
+    return text.splitlines()
 
 
 class HelpPage:
-	title = "help"
-
-	def __init__(self) -> None:
-		self._lines = _load_help_lines()
-		self._state = ListState()
-		if self._lines:
-			self._state.select(0)
-
-	def on_enter(self, ctx: PageContext) -> None:
-		del ctx
-
-	def status_text(self) -> str:
-		total = len(self._lines)
-		if total == 0:
-			return "Help"
-		selected = self._state.selected
-		visible = (selected + 1) if selected is not None else 1
-		return f"Help {visible}/{total} — arrows to scroll, Q or Esc to go back"
-
-	def draw(self, frame: Frame, area: Rect) -> None:
-		if area.height <= 0 or area.width <= 0:
-			return
-		items = [ListItem(line if line else " ") for line in self._lines]
-		frame.render_stateful_list(List(items), area, self._state)
-
-	def handle_key(self, key: KeyLike) -> PageResult:
-		if is_quit(key) or is_esc(key):
-			return PageResult(action=PageAction.POP)
-		if is_up(key):
-			selected = self._state.selected or 0
-			self._state.select(max(0, selected - 1))
-			return PageResult()
-		if is_down(key):
-			selected = self._state.selected or 0
-			self._state.select(min(max(0, len(self._lines) - 1), selected + 1))
-			return PageResult()
-		return PageResult()
+    title = "help"
+
+    def __init__(self) -> None:
+        self._lines = _load_help_lines()
+        self._state = ListState()
+        if self._lines:
+            self._state.select(0)
+
+    def on_enter(self, ctx: PageContext) -> None:
+        del ctx
+
+    def status_text(self) -> str:
+        total = len(self._lines)
+        if total == 0:
+            return "Help"
+        selected = self._state.selected
+        visible = (selected + 1) if selected is not None else 1
+        return f"Help {visible}/{total} — arrows to scroll, Q or Esc to go back"
+
+    def draw(self, frame: Frame, area: Rect) -> None:
+        if area.height <= 0 or area.width <= 0:
+            return
+        items = [ListItem(line if line else " ") for line in self._lines]
+        frame.render_stateful_list(List(items), area, self._state)
+
+    def handle_key(self, key: KeyLike) -> PageResult:
+        if is_quit(key) or is_esc(key):
+            return PageResult(action=PageAction.POP)
+        if is_up(key):
+            selected = self._state.selected or 0
+            self._state.select(max(0, selected - 1))
+            return PageResult()
+        if is_down(key):
+            selected = self._state.selected or 0
+            self._state.select(min(max(0, len(self._lines) - 1), selected + 1))
+            return PageResult()
+        return PageResult()
 
 
 def help_page() -> HelpPage:
-	return HelpPage()
+    return HelpPage()
diff --git a/pygittools/src/pygittools/tui/pages/home.py b/pygittools/src/pygittools/tui/pages/home.py
index c57703b..0e81639 100644
--- a/pygittools/src/pygittools/tui/pages/home.py
+++ b/pygittools/src/pygittools/tui/pages/home.py
@@ -14,12 +14,12 @@ from pygittools.tui.changes_list import ChangesList
 from pygittools.tui.commit_draft import load_commit_draft
 from pygittools.tui.git_colors import current_theme, focus_style
 from pygittools.tui.input_context import (
-	CommandContext,
-	InputContext,
-	InputMode,
-	command_context,
-	input_context,
-	nav_context,
+    CommandContext,
+    InputContext,
+    InputMode,
+    command_context,
+    input_context,
+    nav_context,
 )
 from pygittools.tui.keys import KeyLike, is_quit
 from pygittools.tui.log_list import CommitLogList
@@ -37,188 +37,188 @@ _IDLE_REFRESH_S = 10.0
 
 
 class HomePage:
-	title = "pgt"
-
-	def __init__(self) -> None:
-		self._ctx: PageContext | None = None
-		self._changes = ChangesList()
-		self._log = CommitLogList()
-		self._tasks = TasksBoardList()
-		self._worktrees = WorktreesList()
-		self._active_tab: _HomeTab = "status"
-		self._header_focus: _HeaderFocus = "changes"
-		self._input_mode: InputMode = nav_context()
-		self._action_sets: tuple[str, ...] = ()
-		self._last_refresh_at = 0.0
-
-	@property
-	def header_focus(self) -> _HeaderFocus:
-		return self._header_focus
-
-	def set_header_focus(self, focus: _HeaderFocus) -> None:
-		self._header_focus = focus
-
-	@property
-	def page_context(self) -> PageContext | None:
-		return self._ctx
-
-	@property
-	def changes(self) -> ChangesList:
-		return self._changes
-
-	@property
-	def log(self) -> CommitLogList:
-		return self._log
-
-	@property
-	def tasks(self) -> TasksBoardList:
-		return self._tasks
-
-	@property
-	def worktrees(self) -> WorktreesList:
-		return self._worktrees
-
-	@property
-	def active_tab(self) -> _HomeTab:
-		return self._active_tab
-
-	@property
-	def has_actions(self) -> bool:
-		return bool(self._action_sets)
-
-	def set_active_tab(self, tab: _HomeTab) -> None:
-		self._active_tab = tab
-		if tab != "status":
-			if isinstance(self._input_mode, (InputContext, CommandContext)):
-				self._input_mode = nav_context()
-			if self._header_focus == "actions":
-				self._header_focus = "changes"
-
-	def refresh(self) -> None:
-		self._refresh()
-
-	def _refresh(self) -> None:
-		ctx = self._ctx
-		if ctx is None:
-			return
-		self._changes.refresh(ctx.repo)
-		self._log.refresh(ctx.repo)
-		self._tasks.refresh(ctx.repo)
-		self._worktrees.refresh(ctx.repo)
-		self._action_sets = _action_set_names(ctx)
-		self._last_refresh_at = time.monotonic()
-
-	def on_enter(self, ctx: PageContext) -> None:
-		self._ctx = ctx
-		self._active_tab = "status"
-		self._header_focus = "changes"
-		self._input_mode = nav_context()
-		self._changes.set_commit_message(load_commit_draft(ctx.repo_path, ctx.repo))
-		self._refresh()
-
-	def on_idle(self) -> None:
-		if time.monotonic() - self._last_refresh_at >= _IDLE_REFRESH_S:
-			self._refresh()
-
-	def status_text(self) -> str:
-		return self._input_mode.status_hint(self)
-
-	def draw(self, frame: Frame, area: Rect) -> None:
-		ctx = self._ctx
-		if ctx is None or area.height <= 0:
-			return
-
-		project_label = repo_project_label(ctx.repo_path)
-		branch = current_branch_name(ctx.repo)
-
-		show_actions = self._active_tab == "status"
-		constraints = [Constraint.length(1), Constraint.length(1)]
-		if show_actions:
-			constraints.append(Constraint.length(1))
-		constraints.append(Constraint.fill(1))
-		chunks = Layout().direction(Direction.Vertical).constraints(constraints).split(area)
-
-		try:
-			active_index = _TAB_KEYS.index(self._active_tab)
-		except ValueError:
-			active_index = 0
-		render_tab_row(
-			frame,
-			chunks[0],
-			project_label,
-			_TABS,
-			active_index,
-			prefix_focused=self._header_focus == "repo",
-		)
-
-		theme = current_theme()
-		branch_style = theme.branch
-		if self._header_focus == "branch":
-			branch_style = focus_style(branch_style)
-		branch_label = f"Branch: {branch}"
-		if self._active_tab == "tasks":
-			branch_label = f"{branch_label} — [Sync tasks]"
-		elif self._active_tab == "worktrees":
-			branch_label = f"{branch_label} — [Add worktree]"
-		frame.render_widget(Paragraph.from_string(branch_label).style(branch_style), chunks[1])
-
-		content = chunks[-1]
-		if show_actions:
-			if self._action_sets:
-				actions_label = f"Actions: {', '.join(self._action_sets)}"
-				actions_style = theme.header
-			else:
-				actions_label = "No Action Groups"
-				actions_style = theme.log_date.dim()
-			if self._header_focus == "actions":
-				actions_style = focus_style(actions_style)
-			frame.render_widget(Paragraph.from_string(actions_label).style(actions_style), chunks[2])
-		content_highlight = self._header_focus == "changes"
-		commit_input = isinstance(self._input_mode, InputContext)
-		if self._active_tab == "status":
-			self._changes.draw(
-				frame,
-				content,
-				highlight=content_highlight,
-				commit_input=commit_input,
-			)
-		elif self._active_tab == "log":
-			self._log.draw(frame, content, highlight=content_highlight)
-		elif self._active_tab == "tasks":
-			self._tasks.draw(frame, content, highlight=content_highlight)
-		else:
-			self._worktrees.draw(frame, content, highlight=content_highlight)
-
-	def handle_key(self, key: KeyLike) -> PageResult:
-		if is_quit(key) and not isinstance(self._input_mode, (InputContext, CommandContext)):
-			return PageResult(action=PageAction.QUIT)
-
-		dispatch = self._input_mode.dispatch_key(self, key)
-		if dispatch.next_mode == "input":
-			self._input_mode = input_context()
-		elif dispatch.next_mode == "command":
-			self._input_mode = command_context()
-		elif dispatch.next_mode == "nav":
-			self._input_mode = nav_context()
-		if dispatch.refresh_log and self._ctx is not None:
-			self._log.refresh(self._ctx.repo)
-		if dispatch.page_result is not None:
-			return dispatch.page_result
-		return PageResult()
+    title = "pgt"
+
+    def __init__(self) -> None:
+        self._ctx: PageContext | None = None
+        self._changes = ChangesList()
+        self._log = CommitLogList()
+        self._tasks = TasksBoardList()
+        self._worktrees = WorktreesList()
+        self._active_tab: _HomeTab = "status"
+        self._header_focus: _HeaderFocus = "changes"
+        self._input_mode: InputMode = nav_context()
+        self._action_sets: tuple[str, ...] = ()
+        self._last_refresh_at = 0.0
+
+    @property
+    def header_focus(self) -> _HeaderFocus:
+        return self._header_focus
+
+    def set_header_focus(self, focus: _HeaderFocus) -> None:
+        self._header_focus = focus
+
+    @property
+    def page_context(self) -> PageContext | None:
+        return self._ctx
+
+    @property
+    def changes(self) -> ChangesList:
+        return self._changes
+
+    @property
+    def log(self) -> CommitLogList:
+        return self._log
+
+    @property
+    def tasks(self) -> TasksBoardList:
+        return self._tasks
+
+    @property
+    def worktrees(self) -> WorktreesList:
+        return self._worktrees
+
+    @property
+    def active_tab(self) -> _HomeTab:
+        return self._active_tab
+
+    @property
+    def has_actions(self) -> bool:
+        return bool(self._action_sets)
+
+    def set_active_tab(self, tab: _HomeTab) -> None:
+        self._active_tab = tab
+        if tab != "status":
+            if isinstance(self._input_mode, (InputContext, CommandContext)):
+                self._input_mode = nav_context()
+            if self._header_focus == "actions":
+                self._header_focus = "changes"
+
+    def refresh(self) -> None:
+        self._refresh()
+
+    def _refresh(self) -> None:
+        ctx = self._ctx
+        if ctx is None:
+            return
+        self._changes.refresh(ctx.repo)
+        self._log.refresh(ctx.repo)
+        self._tasks.refresh(ctx.repo)
+        self._worktrees.refresh(ctx.repo)
+        self._action_sets = _action_set_names(ctx)
+        self._last_refresh_at = time.monotonic()
+
+    def on_enter(self, ctx: PageContext) -> None:
+        self._ctx = ctx
+        self._active_tab = "status"
+        self._header_focus = "changes"
+        self._input_mode = nav_context()
+        self._changes.set_commit_message(load_commit_draft(ctx.repo_path, ctx.repo))
+        self._refresh()
+
+    def on_idle(self) -> None:
+        if time.monotonic() - self._last_refresh_at >= _IDLE_REFRESH_S:
+            self._refresh()
+
+    def status_text(self) -> str:
+        return self._input_mode.status_hint(self)
+
+    def draw(self, frame: Frame, area: Rect) -> None:
+        ctx = self._ctx
+        if ctx is None or area.height <= 0:
+            return
+
+        project_label = repo_project_label(ctx.repo_path)
+        branch = current_branch_name(ctx.repo)
+
+        show_actions = self._active_tab == "status"
+        constraints = [Constraint.length(1), Constraint.length(1)]
+        if show_actions:
+            constraints.append(Constraint.length(1))
+        constraints.append(Constraint.fill(1))
+        chunks = Layout().direction(Direction.Vertical).constraints(constraints).split(area)
+
+        try:
+            active_index = _TAB_KEYS.index(self._active_tab)
+        except ValueError:
+            active_index = 0
+        render_tab_row(
+            frame,
+            chunks[0],
+            project_label,
+            _TABS,
+            active_index,
+            prefix_focused=self._header_focus == "repo",
+        )
+
+        theme = current_theme()
+        branch_style = theme.branch
+        if self._header_focus == "branch":
+            branch_style = focus_style(branch_style)
+        branch_label = f"Branch: {branch}"
+        if self._active_tab == "tasks":
+            branch_label = f"{branch_label} — [Sync tasks]"
+        elif self._active_tab == "worktrees":
+            branch_label = f"{branch_label} — [Add worktree]"
+        frame.render_widget(Paragraph.from_string(branch_label).style(branch_style), chunks[1])
+
+        content = chunks[-1]
+        if show_actions:
+            if self._action_sets:
+                actions_label = f"Actions: {', '.join(self._action_sets)}"
+                actions_style = theme.header
+            else:
+                actions_label = "No Action Groups"
+                actions_style = theme.log_date.dim()
+            if self._header_focus == "actions":
+                actions_style = focus_style(actions_style)
+            frame.render_widget(Paragraph.from_string(actions_label).style(actions_style), chunks[2])
+        content_highlight = self._header_focus == "changes"
+        commit_input = isinstance(self._input_mode, InputContext)
+        if self._active_tab == "status":
+            self._changes.draw(
+                frame,
+                content,
+                highlight=content_highlight,
+                commit_input=commit_input,
+            )
+        elif self._active_tab == "log":
+            self._log.draw(frame, content, highlight=content_highlight)
+        elif self._active_tab == "tasks":
+            self._tasks.draw(frame, content, highlight=content_highlight)
+        else:
+            self._worktrees.draw(frame, content, highlight=content_highlight)
+
+    def handle_key(self, key: KeyLike) -> PageResult:
+        if is_quit(key) and not isinstance(self._input_mode, (InputContext, CommandContext)):
+            return PageResult(action=PageAction.QUIT)
+
+        dispatch = self._input_mode.dispatch_key(self, key)
+        if dispatch.next_mode == "input":
+            self._input_mode = input_context()
+        elif dispatch.next_mode == "command":
+            self._input_mode = command_context()
+        elif dispatch.next_mode == "nav":
+            self._input_mode = nav_context()
+        if dispatch.refresh_log and self._ctx is not None:
+            self._log.refresh(self._ctx.repo)
+        if dispatch.page_result is not None:
+            return dispatch.page_result
+        return PageResult()
 
 
 def home_page() -> Page:
-	return HomePage()
+    return HomePage()
 
 
 def _action_set_names(ctx: PageContext) -> tuple[str, ...]:
-	workdir = Path(ctx.repo.workdir) if ctx.repo.workdir else Path(ctx.repo_path)
-	if workdir.name == ".git":
-		workdir = workdir.parent
-	root = find_project_root(workdir)
-	if root is None:
-		return ()
-	try:
-		return tuple(action_set.name for action_set in load_action_sets(root))
-	except (OSError, ValueError):
-		return ()
+    workdir = Path(ctx.repo.workdir) if ctx.repo.workdir else Path(ctx.repo_path)
+    if workdir.name == ".git":
+        workdir = workdir.parent
+    root = find_project_root(workdir)
+    if root is None:
+        return ()
+    try:
+        return tuple(action_set.name for action_set in load_action_sets(root))
+    except (OSError, ValueError):
+        return ()
diff --git a/pygittools/src/pygittools/tui/pages/no_repo.py b/pygittools/src/pygittools/tui/pages/no_repo.py
index 0db966a..a94d8d0 100644
--- a/pygittools/src/pygittools/tui/pages/no_repo.py
+++ b/pygittools/src/pygittools/tui/pages/no_repo.py
@@ -12,71 +12,71 @@ from pygittools.tui.types import PageAction, PageContext, PageResult
 
 
 class NoRepoPage:
-	title = "pgt"
+    title = "pgt"
 
-	def __init__(self, start: Path) -> None:
-		self._start = start
-		self._command: str | None = None
-		self._command_error: str | None = None
+    def __init__(self, start: Path) -> None:
+        self._start = start
+        self._command: str | None = None
+        self._command_error: str | None = None
 
-	def on_enter(self, ctx: PageContext) -> None:
-		del ctx
+    def on_enter(self, ctx: PageContext) -> None:
+        del ctx
 
-	def status_text(self) -> str:
-		if self._command_error is not None:
-			return self._command_error
-		if self._command is not None:
-			if not self._command:
-				return "/ — help quit — Esc cancel"
-			return f"/{self._command}"
-		return "no repository — / command — q quit"
+    def status_text(self) -> str:
+        if self._command_error is not None:
+            return self._command_error
+        if self._command is not None:
+            if not self._command:
+                return "/ — help quit — Esc cancel"
+            return f"/{self._command}"
+        return "no repository — / command — q quit"
 
-	def draw(self, frame: Frame, area: Rect) -> None:
-		lines = "\n".join([
-			"pgt",
-			"",
-			f"No git repository found under {self._start}",
-			"",
-			"Run pgt from inside a repository, or pass --repo PATH.",
-		])
-		frame.render_widget(
-			Paragraph(Text.from_string(lines)).style(Style().bold()),
-			area,
-		)
+    def draw(self, frame: Frame, area: Rect) -> None:
+        lines = "\n".join([
+            "pgt",
+            "",
+            f"No git repository found under {self._start}",
+            "",
+            "Run pgt from inside a repository, or pass --repo PATH.",
+        ])
+        frame.render_widget(
+            Paragraph(Text.from_string(lines)).style(Style().bold()),
+            area,
+        )
 
-	def handle_key(self, key: KeyLike) -> PageResult:
-		if self._command is not None:
-			return self._handle_command_key(key)
-		if is_quit(key):
-			return PageResult(action=PageAction.QUIT)
-		if is_slash(key):
-			self._command = ""
-			self._command_error = None
-			return PageResult()
-		return PageResult()
+    def handle_key(self, key: KeyLike) -> PageResult:
+        if self._command is not None:
+            return self._handle_command_key(key)
+        if is_quit(key):
+            return PageResult(action=PageAction.QUIT)
+        if is_slash(key):
+            self._command = ""
+            self._command_error = None
+            return PageResult()
+        return PageResult()
 
-	def _handle_command_key(self, key: KeyLike) -> PageResult:
-		assert self._command is not None
-		if key.code == "Esc":
-			self._command = None
-			self._command_error = None
-			return PageResult()
-		if key.code == "Backspace":
-			self._command_error = None
-			self._command = self._command[:-1]
-			return PageResult()
-		if key.code == "Enter":
-			name = self._command.strip().casefold().lstrip("/")
-			self._command = None
-			if name in {"help", "?"}:
-				return PageResult(action=PageAction.PUSH, next_page=help_page())
-			if name in {"quit", "q"}:
-				return PageResult(action=PageAction.QUIT)
-			if name:
-				self._command = ""
-				self._command_error = f"Unknown command: /{name} — help quit"
-			return PageResult()
-		if not key.ctrl and not key.alt and len(key.code) == 1 and key.code.isprintable() and not key.code.isspace():
-			self._command_error = None
-			self._command += key.code
-		return PageResult()
+    def _handle_command_key(self, key: KeyLike) -> PageResult:
+        assert self._command is not None
+        if key.code == "Esc":
+            self._command = None
+            self._command_error = None
+            return PageResult()
+        if key.code == "Backspace":
+            self._command_error = None
+            self._command = self._command[:-1]
+            return PageResult()
+        if key.code == "Enter":
+            name = self._command.strip().casefold().lstrip("/")
+            self._command = None
+            if name in {"help", "?"}:
+                return PageResult(action=PageAction.PUSH, next_page=help_page())
+            if name in {"quit", "q"}:
+                return PageResult(action=PageAction.QUIT)
+            if name:
+                self._command = ""
+                self._command_error = f"Unknown command: /{name} — help quit"
+            return PageResult()
+        if not key.ctrl and not key.alt and len(key.code) == 1 and key.code.isprintable() and not key.code.isspace():
+            self._command_error = None
+            self._command += key.code
+        return PageResult()
diff --git a/pygittools/src/pygittools/tui/pages/projects.py b/pygittools/src/pygittools/tui/pages/projects.py
index 2064f2c..34f2dcb 100644
--- a/pygittools/src/pygittools/tui/pages/projects.py
+++ b/pygittools/src/pygittools/tui/pages/projects.py
@@ -14,86 +14,86 @@ _BRANCH_WIDTH = 16
 
 
 class ProjectsPage:
-	title = "projects"
+    title = "projects"
 
-	def __init__(self) -> None:
-		self._projects: list[ProjectEntry] = []
-		self._projectroot_label = ""
-		self._list_state = ListState()
-		self._error: str | None = None
+    def __init__(self) -> None:
+        self._projects: list[ProjectEntry] = []
+        self._projectroot_label = ""
+        self._list_state = ListState()
+        self._error: str | None = None
 
-	def on_enter(self, ctx: PageContext) -> None:
-		del ctx
-		layout = load_pygitweb_layout()
-		self._projectroot_label = str(layout.projectroot)
-		self._projects = list_projects(layout)
-		self._list_state.select(0)
-		self._error = None
+    def on_enter(self, ctx: PageContext) -> None:
+        del ctx
+        layout = load_pygitweb_layout()
+        self._projectroot_label = str(layout.projectroot)
+        self._projects = list_projects(layout)
+        self._list_state.select(0)
+        self._error = None
 
-	def status_text(self) -> str:
-		if self._error:
-			return self._error
-		if not self._projects:
-			return "No projects found — Esc back"
-		return f"Enter open project — {move_hint()} — Esc back"
+    def status_text(self) -> str:
+        if self._error:
+            return self._error
+        if not self._projects:
+            return "No projects found — Esc back"
+        return f"Enter open project — {move_hint()} — Esc back"
 
-	def draw(self, frame: Frame, area: Rect) -> None:
-		if area.height <= 0:
-			return
-		theme = current_theme()
-		chunks = (
-			Layout()
-			.direction(Direction.Vertical)
-			.constraints([Constraint.length(1), Constraint.length(1), Constraint.fill(1)])
-			.split(area)
-		)
-		title = f"Projects ({self._projectroot_label})"
-		frame.render_widget(Paragraph.from_string(title).style(theme.header), chunks[0])
-		name_width = max(8, chunks[1].width - _BRANCH_WIDTH - 2)
-		header = f"{'Name':<{name_width}}  {'Branch':<{_BRANCH_WIDTH}}"
-		frame.render_widget(Paragraph.from_string(header).style(theme.header), chunks[1])
+    def draw(self, frame: Frame, area: Rect) -> None:
+        if area.height <= 0:
+            return
+        theme = current_theme()
+        chunks = (
+            Layout()
+            .direction(Direction.Vertical)
+            .constraints([Constraint.length(1), Constraint.length(1), Constraint.fill(1)])
+            .split(area)
+        )
+        title = f"Projects ({self._projectroot_label})"
+        frame.render_widget(Paragraph.from_string(title).style(theme.header), chunks[0])
+        name_width = max(8, chunks[1].width - _BRANCH_WIDTH - 2)
+        header = f"{'Name':<{name_width}}  {'Branch':<{_BRANCH_WIDTH}}"
+        frame.render_widget(Paragraph.from_string(header).style(theme.header), chunks[1])
 
-		items: list[ListItem] = []
-		selected = self._list_state.selected or 0
-		for index, project in enumerate(self._projects):
-			name = project.path
-			if len(name) > name_width:
-				name = name[: max(0, name_width - 1)] + "…"
-			branch = project.branch
-			if len(branch) > _BRANCH_WIDTH:
-				branch = branch[: max(0, _BRANCH_WIDTH - 1)] + "…"
-			line = f"{name:<{name_width}}  {branch:<{_BRANCH_WIDTH}}"
-			style = focus_style(theme.normal) if index == selected else theme.normal
-			items.append(ListItem(line, style))
-		if not items:
-			items.append(ListItem("No projects", theme.log_date))
-		frame.render_stateful_list(
-			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			chunks[2],
-			self._list_state,
-		)
+        items: list[ListItem] = []
+        selected = self._list_state.selected or 0
+        for index, project in enumerate(self._projects):
+            name = project.path
+            if len(name) > name_width:
+                name = name[: max(0, name_width - 1)] + "…"
+            branch = project.branch
+            if len(branch) > _BRANCH_WIDTH:
+                branch = branch[: max(0, _BRANCH_WIDTH - 1)] + "…"
+            line = f"{name:<{name_width}}  {branch:<{_BRANCH_WIDTH}}"
+            style = focus_style(theme.normal) if index == selected else theme.normal
+            items.append(ListItem(line, style))
+        if not items:
+            items.append(ListItem("No projects", theme.log_date))
+        frame.render_stateful_list(
+            List(items).highlight_style(theme.focus).highlight_symbol(""),
+            chunks[2],
+            self._list_state,
+        )
 
-	def handle_key(self, key: KeyLike) -> PageResult:
-		if is_quit(key) or is_esc(key):
-			return PageResult(action=PageAction.POP)
-		if self._error is not None:
-			self._error = None
-		if not self._projects:
-			return PageResult()
+    def handle_key(self, key: KeyLike) -> PageResult:
+        if is_quit(key) or is_esc(key):
+            return PageResult(action=PageAction.POP)
+        if self._error is not None:
+            self._error = None
+        if not self._projects:
+            return PageResult()
 
-		if is_up(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(max(0, selected - 1))
-			return PageResult()
-		if is_down(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(min(len(self._projects) - 1, selected + 1))
-			return PageResult()
-		if is_enter(key):
-			project = self._projects[self._list_state.selected or 0]
-			return PageResult(action=PageAction.POP, switch_repo=project.worktree)
-		return PageResult()
+        if is_up(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(max(0, selected - 1))
+            return PageResult()
+        if is_down(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(min(len(self._projects) - 1, selected + 1))
+            return PageResult()
+        if is_enter(key):
+            project = self._projects[self._list_state.selected or 0]
+            return PageResult(action=PageAction.POP, switch_repo=project.worktree)
+        return PageResult()
 
 
 def projects_page() -> ProjectsPage:
-	return ProjectsPage()
+    return ProjectsPage()
diff --git a/pygittools/src/pygittools/tui/pages/task_editor.py b/pygittools/src/pygittools/tui/pages/task_editor.py
index 724d5f6..cc201b8 100644
--- a/pygittools/src/pygittools/tui/pages/task_editor.py
+++ b/pygittools/src/pygittools/tui/pages/task_editor.py
@@ -8,28 +8,28 @@ from typing import Literal
 
 from pygit2 import Oid
 from pyratatui import (
-	Constraint,
-	Direction,
-	Frame,
-	Layout,
-	List,
-	ListItem,
-	ListState,
-	Paragraph,
-	Rect,
-	TextPrompt,
-	TextState,
+    Constraint,
+    Direction,
+    Frame,
+    Layout,
+    List,
+    ListItem,
+    ListState,
+    Paragraph,
+    Rect,
+    TextPrompt,
+    TextState,
 )
 
 from pygittools.tags_sync import delete_task_remote, push_board_tags
 from pygittools.tasks import (
-	BOARD_REF_PREFIX,
-	EMPTY_TREE_OID_HEX,
-	TASK_REF_PREFIX,
-	Task,
-	delete_task_local,
-	get_board,
-	get_task,
+    BOARD_REF_PREFIX,
+    EMPTY_TREE_OID_HEX,
+    TASK_REF_PREFIX,
+    Task,
+    delete_task_local,
+    get_board,
+    get_task,
 )
 from pygittools.tasks_query import BOARD_STATUS_ORDER, get_task_view
 from pygittools.tui.git_colors import current_theme, focus_style
@@ -44,374 +44,374 @@ _TEXT_FIELDS = frozenset({"title", "description", "assignee", "due_date"})
 
 @dataclass(frozen=True, slots=True)
 class _EditorState:
-	title: str
-	description: str
-	status: str | None
-	priority: str | None
-	assignee: str
-	due_date: str
+    title: str
+    description: str
+    status: str | None
+    priority: str | None
+    assignee: str
+    due_date: str
 
 
 class TaskEditorPage:
-	title = "task"
-
-	def __init__(self, *, board_name: str, task_ref: str | None, default_status: str | None) -> None:
-		self._ctx: PageContext | None = None
-		self._board_name = board_name
-		self._task_ref = task_ref
-		self._default_status = default_status
-		self._state = _EditorState(
-			title="",
-			description="",
-			status=default_status,
-			priority=None,
-			assignee="",
-			due_date="",
-		)
-		self._list_state = ListState()
-		self._list_state.select(0)
-		self._input_mode: Literal["nav", "input"] = "nav"
-		self._text_state = TextState()
-		self._error: str | None = None
-
-	def on_enter(self, ctx: PageContext) -> None:
-		self._ctx = ctx
-		if self._task_ref is None:
-			return
-		view = get_task_view(ctx.repo, self._task_ref)
-		self._state = _EditorState(
-			title=view.get("title", ""),
-			description=view.get("description", ""),
-			status=view.get("status"),
-			priority=view.get("priority"),
-			assignee=view.get("assignee") or "",
-			due_date=view.get("due_date") or "",
-		)
-
-	def status_text(self) -> str:
-		if self._error:
-			return self._error
-		field_index = self._list_state.selected or 0
-		move = move_hint()
-		if self._task_ref is not None:
-			if field_index == len(_FIELDS):
-				return f"Enter remove task (local) — {move} — q or Esc back"
-			if field_index == len(_FIELDS) + 1:
-				return f"Enter remove task (remote & local) — {move} — q or Esc back"
-		if self._input_mode == "input":
-			field = _FIELDS[field_index] if field_index < len(_FIELDS) else "title"
-			if field in ("status", "priority"):
-				return f"{move} change value — Enter save — Esc stop editing — q back"
-			return "Type to edit — Enter save — Esc stop editing — q back"
-		return f"Enter edit field — {move} field — q or Esc back"
-
-	def draw(self, frame: Frame, area: Rect) -> None:
-		if area.height <= 0:
-			return
-		theme = current_theme()
-		chunks = (
-			Layout().direction(Direction.Vertical).constraints([Constraint.length(1), Constraint.fill(1)]).split(area)
-		)
-		header = f"Task in board {self._board_name}"
-		frame.render_widget(Paragraph.from_string(header).style(theme.header), chunks[0])
-
-		field_index = self._list_state.selected or 0
-		if self._input_mode == "input" and field_index < len(_FIELDS) and _FIELDS[field_index] in _TEXT_FIELDS:
-			field = _FIELDS[field_index]
-			self._text_state.focus()
-			frame.render_text_prompt(TextPrompt(f"{field.replace('_', ' ').title()}: "), chunks[1], self._text_state)
-			return
-
-		rows = self._row_labels()
-		items: list[ListItem] = []
-		for index, text in enumerate(rows):
-			style = theme.unstaged if self._task_ref is not None and index >= len(_FIELDS) else theme.normal
-			if index == field_index:
-				style = focus_style(style)
-				if self._input_mode == "input" and index < len(_FIELDS):
-					style = style.bold()
-			items.append(ListItem(text, style))
-		frame.render_stateful_list(
-			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			chunks[1],
-			self._list_state,
-		)
-
-	def handle_key(self, key: KeyLike) -> PageResult:
-		if is_quit(key) and self._input_mode == "nav":
-			return PageResult(action=PageAction.POP)
-		if is_esc(key):
-			if self._input_mode == "input":
-				self._sync_text_field_from_prompt()
-				self._input_mode = "nav"
-				return PageResult()
-			return PageResult(action=PageAction.POP)
-		if self._error is not None:
-			self._error = None
-
-		field_index = self._list_state.selected or 0
-		field = _FIELDS[field_index] if field_index < len(_FIELDS) else "title"
-
-		if self._input_mode == "input":
-			if field in _TEXT_FIELDS:
-				if is_up(key):
-					self._sync_text_field_from_prompt()
-					self._list_state.select(max(0, field_index - 1))
-					self._begin_field_input()
-					return PageResult()
-				if is_down(key):
-					self._sync_text_field_from_prompt()
-					self._list_state.select(min(len(_FIELDS) - 1, field_index + 1))
-					self._begin_field_input()
-					return PageResult()
-				if is_enter(key):
-					self._sync_text_field_from_prompt()
-					return self._save()
-				self._text_state.focus()
-				self._text_state.handle_key(key)
-				return PageResult()
-			if field == "status":
-				if is_enter(key):
-					return self._save()
-				self._cycle_status(key)
-				return PageResult()
-			if field == "priority":
-				if is_enter(key):
-					return self._save()
-				self._cycle_priority(key)
-				return PageResult()
-			return PageResult()
-
-		if is_up(key):
-			self._list_state.select(max(0, field_index - 1))
-			return PageResult()
-		if is_down(key):
-			max_index = (len(_FIELDS) + 1) if self._task_ref is not None else len(_FIELDS) - 1
-			self._list_state.select(min(max_index, field_index + 1))
-			return PageResult()
-		if is_enter(key):
-			if self._task_ref is not None:
-				if field_index == len(_FIELDS):
-					return self._delete_local()
-				if field_index == len(_FIELDS) + 1:
-					return self._delete_remote_local()
-			self._input_mode = "input"
-			self._begin_field_input()
-			return PageResult()
-		return PageResult()
-
-	def _row_labels(self) -> list[str]:
-		rows = [
-			f"Title: {self._state.title}",
-			f"Description: {self._state.description}",
-			f"Status: {self._state.status or '(none)'}",
-			f"Priority: {self._state.priority or '(none)'}",
-			f"Assignee: {self._state.assignee}",
-			f"Due date (ISO): {self._state.due_date}",
-		]
-		if self._task_ref is not None:
-			rows.append("Remove task")
-			rows.append("Remove task (REMOTE & LOCAL)")
-		return rows
-
-	def _begin_field_input(self) -> None:
-		field_index = self._list_state.selected or 0
-		if field_index >= len(_FIELDS):
-			return
-		field = _FIELDS[field_index]
-		if field in _TEXT_FIELDS:
-			self._text_state.set_value(_text_for_field(self._state, field))
-			self._text_state.focus()
-
-	def _sync_text_field_from_prompt(self) -> None:
-		field_index = self._list_state.selected or 0
-		if field_index >= len(_FIELDS):
-			return
-		field = _FIELDS[field_index]
-		if field in _TEXT_FIELDS:
-			self._state = _replace_state_field(self._state, field, self._text_state.value())
-
-	def _cycle_status(self, key: KeyLike) -> None:
-		if not (is_up(key) or is_down(key)):
-			return
-		current = self._state.status or self._default_status or BOARD_STATUS_ORDER[0]
-		try:
-			index = BOARD_STATUS_ORDER.index(current)
-		except ValueError:
-			index = 0
-		index = (index - 1) % len(BOARD_STATUS_ORDER) if is_up(key) else (index + 1) % len(BOARD_STATUS_ORDER)
-		self._state = _EditorState(
-			title=self._state.title,
-			description=self._state.description,
-			status=BOARD_STATUS_ORDER[index],
-			priority=self._state.priority,
-			assignee=self._state.assignee,
-			due_date=self._state.due_date,
-		)
-
-	def _cycle_priority(self, key: KeyLike) -> None:
-		if not (is_up(key) or is_down(key)):
-			return
-		if not _PRIORITY_ORDER:
-			return
-		current = self._state.priority or _PRIORITY_ORDER[0]
-		try:
-			index = _PRIORITY_ORDER.index(current)
-		except ValueError:
-			index = 0
-		index = (index - 1) % len(_PRIORITY_ORDER) if is_up(key) else (index + 1) % len(_PRIORITY_ORDER)
-		self._state = _EditorState(
-			title=self._state.title,
-			description=self._state.description,
-			status=self._state.status,
-			priority=_PRIORITY_ORDER[index],
-			assignee=self._state.assignee,
-			due_date=self._state.due_date,
-		)
-
-	def _save(self) -> PageResult:
-		ctx = self._ctx
-		if ctx is None:
-			return PageResult(action=PageAction.POP)
-
-		try:
-			due_dt = _parse_due_date(self._state.due_date)
-		except ValueError as exc:
-			self._error = str(exc)
-			return PageResult()
-
-		if self._task_ref is None:
-			try:
-				_create_task(ctx, self._board_name, self._state, due_dt)
-			except Exception as exc:  # noqa: BLE001
-				self._error = str(exc)
-				return PageResult()
-			return PageResult(action=PageAction.POP, repo_changed=True)
-
-		task = get_task(ctx.repo, self._task_ref)
-		if task is None:
-			self._error = f"Task not found: {self._task_ref}"
-			return PageResult()
-		task.title = self._state.title
-		task.description = self._state.description
-		task.status = Task.Status(self._state.status) if self._state.status else None
-		task.priority = Task.Priority(self._state.priority) if self._state.priority else None
-		task.assignee = self._state.assignee or None
-		task.due_date = due_dt
-		task.update_message()
-		task.write(ctx.repo)
-		return PageResult(action=PageAction.POP, repo_changed=True)
-
-	def _delete_local(self) -> PageResult:
-		ctx = self._ctx
-		if ctx is None or self._task_ref is None:
-			return PageResult(action=PageAction.POP)
-		delete_task_local(ctx.repo, self._task_ref)
-		return PageResult(action=PageAction.POP, repo_changed=True)
-
-	def _delete_remote_local(self) -> PageResult:
-		ctx = self._ctx
-		if ctx is None or self._task_ref is None:
-			return PageResult(action=PageAction.POP)
-
-		remote_name = _default_remote_name(ctx)
-		if remote_name is not None:
-			delete_task_remote(ctx.repo, remote_name, self._task_ref)
-		delete_task_local(ctx.repo, self._task_ref)
-		if remote_name is not None:
-			push_board_tags(ctx.repo, remote_name, self._board_name)
-		return PageResult(action=PageAction.POP, repo_changed=True)
+    title = "task"
+
+    def __init__(self, *, board_name: str, task_ref: str | None, default_status: str | None) -> None:
+        self._ctx: PageContext | None = None
+        self._board_name = board_name
+        self._task_ref = task_ref
+        self._default_status = default_status
+        self._state = _EditorState(
+            title="",
+            description="",
+            status=default_status,
+            priority=None,
+            assignee="",
+            due_date="",
+        )
+        self._list_state = ListState()
+        self._list_state.select(0)
+        self._input_mode: Literal["nav", "input"] = "nav"
+        self._text_state = TextState()
+        self._error: str | None = None
+
+    def on_enter(self, ctx: PageContext) -> None:
+        self._ctx = ctx
+        if self._task_ref is None:
+            return
+        view = get_task_view(ctx.repo, self._task_ref)
+        self._state = _EditorState(
+            title=view.get("title", ""),
+            description=view.get("description", ""),
+            status=view.get("status"),
+            priority=view.get("priority"),
+            assignee=view.get("assignee") or "",
+            due_date=view.get("due_date") or "",
+        )
+
+    def status_text(self) -> str:
+        if self._error:
+            return self._error
+        field_index = self._list_state.selected or 0
+        move = move_hint()
+        if self._task_ref is not None:
+            if field_index == len(_FIELDS):
+                return f"Enter remove task (local) — {move} — q or Esc back"
+            if field_index == len(_FIELDS) + 1:
+                return f"Enter remove task (remote & local) — {move} — q or Esc back"
+        if self._input_mode == "input":
+            field = _FIELDS[field_index] if field_index < len(_FIELDS) else "title"
+            if field in ("status", "priority"):
+                return f"{move} change value — Enter save — Esc stop editing — q back"
+            return "Type to edit — Enter save — Esc stop editing — q back"
+        return f"Enter edit field — {move} field — q or Esc back"
+
+    def draw(self, frame: Frame, area: Rect) -> None:
+        if area.height <= 0:
+            return
+        theme = current_theme()
+        chunks = (
+            Layout().direction(Direction.Vertical).constraints([Constraint.length(1), Constraint.fill(1)]).split(area)
+        )
+        header = f"Task in board {self._board_name}"
+        frame.render_widget(Paragraph.from_string(header).style(theme.header), chunks[0])
+
+        field_index = self._list_state.selected or 0
+        if self._input_mode == "input" and field_index < len(_FIELDS) and _FIELDS[field_index] in _TEXT_FIELDS:
+            field = _FIELDS[field_index]
+            self._text_state.focus()
+            frame.render_text_prompt(TextPrompt(f"{field.replace('_', ' ').title()}: "), chunks[1], self._text_state)
+            return
+
+        rows = self._row_labels()
+        items: list[ListItem] = []
+        for index, text in enumerate(rows):
+            style = theme.unstaged if self._task_ref is not None and index >= len(_FIELDS) else theme.normal
+            if index == field_index:
+                style = focus_style(style)
+                if self._input_mode == "input" and index < len(_FIELDS):
+                    style = style.bold()
+            items.append(ListItem(text, style))
+        frame.render_stateful_list(
+            List(items).highlight_style(theme.focus).highlight_symbol(""),
+            chunks[1],
+            self._list_state,
+        )
+
+    def handle_key(self, key: KeyLike) -> PageResult:
+        if is_quit(key) and self._input_mode == "nav":
+            return PageResult(action=PageAction.POP)
+        if is_esc(key):
+            if self._input_mode == "input":
+                self._sync_text_field_from_prompt()
+                self._input_mode = "nav"
+                return PageResult()
+            return PageResult(action=PageAction.POP)
+        if self._error is not None:
+            self._error = None
+
+        field_index = self._list_state.selected or 0
+        field = _FIELDS[field_index] if field_index < len(_FIELDS) else "title"
+
+        if self._input_mode == "input":
+            if field in _TEXT_FIELDS:
+                if is_up(key):
+                    self._sync_text_field_from_prompt()
+                    self._list_state.select(max(0, field_index - 1))
+                    self._begin_field_input()
+                    return PageResult()
+                if is_down(key):
+                    self._sync_text_field_from_prompt()
+                    self._list_state.select(min(len(_FIELDS) - 1, field_index + 1))
+                    self._begin_field_input()
+                    return PageResult()
+                if is_enter(key):
+                    self._sync_text_field_from_prompt()
+                    return self._save()
+                self._text_state.focus()
+                self._text_state.handle_key(key)
+                return PageResult()
+            if field == "status":
+                if is_enter(key):
+                    return self._save()
+                self._cycle_status(key)
+                return PageResult()
+            if field == "priority":
+                if is_enter(key):
+                    return self._save()
+                self._cycle_priority(key)
+                return PageResult()
+            return PageResult()
+
+        if is_up(key):
+            self._list_state.select(max(0, field_index - 1))
+            return PageResult()
+        if is_down(key):
+            max_index = (len(_FIELDS) + 1) if self._task_ref is not None else len(_FIELDS) - 1
+            self._list_state.select(min(max_index, field_index + 1))
+            return PageResult()
+        if is_enter(key):
+            if self._task_ref is not None:
+                if field_index == len(_FIELDS):
+                    return self._delete_local()
+                if field_index == len(_FIELDS) + 1:
+                    return self._delete_remote_local()
+            self._input_mode = "input"
+            self._begin_field_input()
+            return PageResult()
+        return PageResult()
+
+    def _row_labels(self) -> list[str]:
+        rows = [
+            f"Title: {self._state.title}",
+            f"Description: {self._state.description}",
+            f"Status: {self._state.status or '(none)'}",
+            f"Priority: {self._state.priority or '(none)'}",
+            f"Assignee: {self._state.assignee}",
+            f"Due date (ISO): {self._state.due_date}",
+        ]
+        if self._task_ref is not None:
+            rows.append("Remove task")
+            rows.append("Remove task (REMOTE & LOCAL)")
+        return rows
+
+    def _begin_field_input(self) -> None:
+        field_index = self._list_state.selected or 0
+        if field_index >= len(_FIELDS):
+            return
+        field = _FIELDS[field_index]
+        if field in _TEXT_FIELDS:
+            self._text_state.set_value(_text_for_field(self._state, field))
+            self._text_state.focus()
+
+    def _sync_text_field_from_prompt(self) -> None:
+        field_index = self._list_state.selected or 0
+        if field_index >= len(_FIELDS):
+            return
+        field = _FIELDS[field_index]
+        if field in _TEXT_FIELDS:
+            self._state = _replace_state_field(self._state, field, self._text_state.value())
+
+    def _cycle_status(self, key: KeyLike) -> None:
+        if not (is_up(key) or is_down(key)):
+            return
+        current = self._state.status or self._default_status or BOARD_STATUS_ORDER[0]
+        try:
+            index = BOARD_STATUS_ORDER.index(current)
+        except ValueError:
+            index = 0
+        index = (index - 1) % len(BOARD_STATUS_ORDER) if is_up(key) else (index + 1) % len(BOARD_STATUS_ORDER)
+        self._state = _EditorState(
+            title=self._state.title,
+            description=self._state.description,
+            status=BOARD_STATUS_ORDER[index],
+            priority=self._state.priority,
+            assignee=self._state.assignee,
+            due_date=self._state.due_date,
+        )
+
+    def _cycle_priority(self, key: KeyLike) -> None:
+        if not (is_up(key) or is_down(key)):
+            return
+        if not _PRIORITY_ORDER:
+            return
+        current = self._state.priority or _PRIORITY_ORDER[0]
+        try:
+            index = _PRIORITY_ORDER.index(current)
+        except ValueError:
+            index = 0
+        index = (index - 1) % len(_PRIORITY_ORDER) if is_up(key) else (index + 1) % len(_PRIORITY_ORDER)
+        self._state = _EditorState(
+            title=self._state.title,
+            description=self._state.description,
+            status=self._state.status,
+            priority=_PRIORITY_ORDER[index],
+            assignee=self._state.assignee,
+            due_date=self._state.due_date,
+        )
+
+    def _save(self) -> PageResult:
+        ctx = self._ctx
+        if ctx is None:
+            return PageResult(action=PageAction.POP)
+
+        try:
+            due_dt = _parse_due_date(self._state.due_date)
+        except ValueError as exc:
+            self._error = str(exc)
+            return PageResult()
+
+        if self._task_ref is None:
+            try:
+                _create_task(ctx, self._board_name, self._state, due_dt)
+            except Exception as exc:  # noqa: BLE001
+                self._error = str(exc)
+                return PageResult()
+            return PageResult(action=PageAction.POP, repo_changed=True)
+
+        task = get_task(ctx.repo, self._task_ref)
+        if task is None:
+            self._error = f"Task not found: {self._task_ref}"
+            return PageResult()
+        task.title = self._state.title
+        task.description = self._state.description
+        task.status = Task.Status(self._state.status) if self._state.status else None
+        task.priority = Task.Priority(self._state.priority) if self._state.priority else None
+        task.assignee = self._state.assignee or None
+        task.due_date = due_dt
+        task.update_message()
+        task.write(ctx.repo)
+        return PageResult(action=PageAction.POP, repo_changed=True)
+
+    def _delete_local(self) -> PageResult:
+        ctx = self._ctx
+        if ctx is None or self._task_ref is None:
+            return PageResult(action=PageAction.POP)
+        delete_task_local(ctx.repo, self._task_ref)
+        return PageResult(action=PageAction.POP, repo_changed=True)
+
+    def _delete_remote_local(self) -> PageResult:
+        ctx = self._ctx
+        if ctx is None or self._task_ref is None:
+            return PageResult(action=PageAction.POP)
+
+        remote_name = _default_remote_name(ctx)
+        if remote_name is not None:
+            delete_task_remote(ctx.repo, remote_name, self._task_ref)
+        delete_task_local(ctx.repo, self._task_ref)
+        if remote_name is not None:
+            push_board_tags(ctx.repo, remote_name, self._board_name)
+        return PageResult(action=PageAction.POP, repo_changed=True)
 
 
 def _replace_state_field(state: _EditorState, field: _FieldName, value: str) -> _EditorState:
-	if field == "title":
-		return _EditorState(
-			title=value,
-			description=state.description,
-			status=state.status,
-			priority=state.priority,
-			assignee=state.assignee,
-			due_date=state.due_date,
-		)
-	if field == "description":
-		return _EditorState(
-			title=state.title,
-			description=value,
-			status=state.status,
-			priority=state.priority,
-			assignee=state.assignee,
-			due_date=state.due_date,
-		)
-	if field == "assignee":
-		return _EditorState(
-			title=state.title,
-			description=state.description,
-			status=state.status,
-			priority=state.priority,
-			assignee=value,
-			due_date=state.due_date,
-		)
-	return _EditorState(
-		title=state.title,
-		description=state.description,
-		status=state.status,
-		priority=state.priority,
-		assignee=state.assignee,
-		due_date=value,
-	)
+    if field == "title":
+        return _EditorState(
+            title=value,
+            description=state.description,
+            status=state.status,
+            priority=state.priority,
+            assignee=state.assignee,
+            due_date=state.due_date,
+        )
+    if field == "description":
+        return _EditorState(
+            title=state.title,
+            description=value,
+            status=state.status,
+            priority=state.priority,
+            assignee=state.assignee,
+            due_date=state.due_date,
+        )
+    if field == "assignee":
+        return _EditorState(
+            title=state.title,
+            description=state.description,
+            status=state.status,
+            priority=state.priority,
+            assignee=value,
+            due_date=state.due_date,
+        )
+    return _EditorState(
+        title=state.title,
+        description=state.description,
+        status=state.status,
+        priority=state.priority,
+        assignee=state.assignee,
+        due_date=value,
+    )
 
 
 def _text_for_field(state: _EditorState, field: _FieldName) -> str:
-	if field == "title":
-		return state.title
-	if field == "description":
-		return state.description
-	if field == "assignee":
-		return state.assignee
-	return state.due_date
+    if field == "title":
+        return state.title
+    if field == "description":
+        return state.description
+    if field == "assignee":
+        return state.assignee
+    return state.due_date
 
 
 def _parse_due_date(text: str) -> datetime | None:
-	if not text.strip():
-		return None
-	try:
-		return datetime.fromisoformat(text)
-	except ValueError as exc:
-		raise ValueError(f"Invalid due date (expected ISO format): {text}") from exc
+    if not text.strip():
+        return None
+    try:
+        return datetime.fromisoformat(text)
+    except ValueError as exc:
+        raise ValueError(f"Invalid due date (expected ISO format): {text}") from exc
 
 
 def _create_task(ctx: PageContext, board_name: str, state: _EditorState, due_dt: datetime | None) -> None:
-	board_ref = f"{BOARD_REF_PREFIX}{board_name}"
-	board = get_board(ctx.repo, board_ref)
-	if board is None:
-		raise ValueError(f"Board not found: {board_name}")
-	title = state.title or "Untitled"
-	slug = title.lower().replace(" ", "_") or "task"
-	full_ref = f"{TASK_REF_PREFIX}{slug}"
-	target = Oid(hex=EMPTY_TREE_OID_HEX)
-	sig = ctx.repo.default_signature
-	tagger = f"{sig.name} <{sig.email}>"
-	task = Task(
-		target,
-		full_ref,
-		tagger,
-		title=title,
-		description=state.description,
-		status=Task.Status(state.status) if state.status else None,
-		priority=Task.Priority(state.priority) if state.priority else None,
-		assignee=state.assignee or None,
-		due_date=due_dt,
-	)
-	task_oid = task.write(ctx.repo)
-	board.tasks = [*(getattr(board, "tasks", []) or []), str(task_oid)]
-	board.update_message()
-	board.write(ctx.repo)
+    board_ref = f"{BOARD_REF_PREFIX}{board_name}"
+    board = get_board(ctx.repo, board_ref)
+    if board is None:
+        raise ValueError(f"Board not found: {board_name}")
+    title = state.title or "Untitled"
+    slug = title.lower().replace(" ", "_") or "task"
+    full_ref = f"{TASK_REF_PREFIX}{slug}"
+    target = Oid(hex=EMPTY_TREE_OID_HEX)
+    sig = ctx.repo.default_signature
+    tagger = f"{sig.name} <{sig.email}>"
+    task = Task(
+        target,
+        full_ref,
+        tagger,
+        title=title,
+        description=state.description,
+        status=Task.Status(state.status) if state.status else None,
+        priority=Task.Priority(state.priority) if state.priority else None,
+        assignee=state.assignee or None,
+        due_date=due_dt,
+    )
+    task_oid = task.write(ctx.repo)
+    board.tasks = [*(getattr(board, "tasks", []) or []), str(task_oid)]
+    board.update_message()
+    board.write(ctx.repo)
 
 
 def _default_remote_name(ctx: PageContext) -> str | None:
-	names = list(ctx.repo.remotes.names())
-	if not names:
-		return None
-	if "origin" in names:
-		return "origin"
-	return names[0]
+    names = list(ctx.repo.remotes.names())
+    if not names:
+        return None
+    if "origin" in names:
+        return "origin"
+    return names[0]
diff --git a/pygittools/src/pygittools/tui/pages/worktree_add.py b/pygittools/src/pygittools/tui/pages/worktree_add.py
index 0611034..4addc3e 100644
--- a/pygittools/src/pygittools/tui/pages/worktree_add.py
+++ b/pygittools/src/pygittools/tui/pages/worktree_add.py
@@ -6,14 +6,14 @@ from pathlib import Path
 from typing import Literal
 
 from pyratatui import (
-	Constraint,
-	Direction,
-	Frame,
-	Layout,
-	Paragraph,
-	Rect,
-	TextPrompt,
-	TextState,
+    Constraint,
+    Direction,
+    Frame,
+    Layout,
+    Paragraph,
+    Rect,
+    TextPrompt,
+    TextState,
 )
 
 from pygittools.tui.git_colors import current_theme
@@ -25,96 +25,96 @@ _Field = Literal["path", "branch"]
 
 
 class WorktreeAddPage:
-	title = "worktree-add"
-
-	def __init__(self) -> None:
-		self._ctx: PageContext | None = None
-		self._path_state = TextState()
-		self._branch_state = TextState()
-		self._field: _Field = "path"
-		self._error: str | None = None
-
-	def on_enter(self, ctx: PageContext) -> None:
-		self._ctx = ctx
-		self._path_state.set_value("")
-		self._branch_state.set_value("")
-		self._field = "path"
-		self._error = None
-		self._path_state.focus()
-		self._branch_state.blur()
-
-	def status_text(self) -> str:
-		if self._error:
-			return self._error
-		return f"Enter submit — {move_hint()} switch field — Esc back"
-
-	def draw(self, frame: Frame, area: Rect) -> None:
-		if area.height <= 0:
-			return
-		theme = current_theme()
-		chunks = (
-			Layout()
-			.direction(Direction.Vertical)
-			.constraints([
-				Constraint.length(1),
-				Constraint.length(1),
-				Constraint.length(1),
-				Constraint.fill(1),
-			])
-			.split(area)
-		)
-		frame.render_widget(Paragraph.from_string("Add worktree").style(theme.header), chunks[0])
-		self._sync_focus()
-		frame.render_text_prompt(TextPrompt("Path: "), chunks[1], self._path_state)
-		frame.render_text_prompt(
-			TextPrompt("Branch (new or existing, empty = detached): "),
-			chunks[2],
-			self._branch_state,
-		)
-		hint = "Path is relative to the current worktree unless absolute."
-		frame.render_widget(Paragraph.from_string(hint).style(theme.log_date), chunks[3])
-
-	def handle_key(self, key: KeyLike) -> PageResult:
-		if is_esc(key) or is_quit(key):
-			return PageResult(action=PageAction.POP)
-		if self._error is not None:
-			self._error = None
-		ctx = self._ctx
-		if ctx is None:
-			return PageResult()
-		if is_up(key):
-			self._field = "path"
-			return PageResult()
-		if is_down(key):
-			self._field = "branch"
-			return PageResult()
-		if is_enter(key):
-			return self._submit(ctx)
-		state = self._path_state if self._field == "path" else self._branch_state
-		state.focus()
-		state.handle_key(key)
-		return PageResult()
-
-	def _sync_focus(self) -> None:
-		if self._field == "path":
-			self._path_state.focus()
-			self._branch_state.blur()
-		else:
-			self._branch_state.focus()
-			self._path_state.blur()
-
-	def _submit(self, ctx: PageContext) -> PageResult:
-		raw_path = self._path_state.value().strip()
-		if not raw_path:
-			self._error = "Path is required"
-			return PageResult()
-		branch = self._branch_state.value().strip() or None
-		ok, error = add_worktree(ctx.repo, Path(raw_path), branch)
-		if ok:
-			return PageResult(action=PageAction.POP, repo_changed=True)
-		self._error = error
-		return PageResult()
+    title = "worktree-add"
+
+    def __init__(self) -> None:
+        self._ctx: PageContext | None = None
+        self._path_state = TextState()
+        self._branch_state = TextState()
+        self._field: _Field = "path"
+        self._error: str | None = None
+
+    def on_enter(self, ctx: PageContext) -> None:
+        self._ctx = ctx
+        self._path_state.set_value("")
+        self._branch_state.set_value("")
+        self._field = "path"
+        self._error = None
+        self._path_state.focus()
+        self._branch_state.blur()
+
+    def status_text(self) -> str:
+        if self._error:
+            return self._error
+        return f"Enter submit — {move_hint()} switch field — Esc back"
+
+    def draw(self, frame: Frame, area: Rect) -> None:
+        if area.height <= 0:
+            return
+        theme = current_theme()
+        chunks = (
+            Layout()
+            .direction(Direction.Vertical)
+            .constraints([
+                Constraint.length(1),
+                Constraint.length(1),
+                Constraint.length(1),
+                Constraint.fill(1),
+            ])
+            .split(area)
+        )
+        frame.render_widget(Paragraph.from_string("Add worktree").style(theme.header), chunks[0])
+        self._sync_focus()
+        frame.render_text_prompt(TextPrompt("Path: "), chunks[1], self._path_state)
+        frame.render_text_prompt(
+            TextPrompt("Branch (new or existing, empty = detached): "),
+            chunks[2],
+            self._branch_state,
+        )
+        hint = "Path is relative to the current worktree unless absolute."
+        frame.render_widget(Paragraph.from_string(hint).style(theme.log_date), chunks[3])
+
+    def handle_key(self, key: KeyLike) -> PageResult:
+        if is_esc(key) or is_quit(key):
+            return PageResult(action=PageAction.POP)
+        if self._error is not None:
+            self._error = None
+        ctx = self._ctx
+        if ctx is None:
+            return PageResult()
+        if is_up(key):
+            self._field = "path"
+            return PageResult()
+        if is_down(key):
+            self._field = "branch"
+            return PageResult()
+        if is_enter(key):
+            return self._submit(ctx)
+        state = self._path_state if self._field == "path" else self._branch_state
+        state.focus()
+        state.handle_key(key)
+        return PageResult()
+
+    def _sync_focus(self) -> None:
+        if self._field == "path":
+            self._path_state.focus()
+            self._branch_state.blur()
+        else:
+            self._branch_state.focus()
+            self._path_state.blur()
+
+    def _submit(self, ctx: PageContext) -> PageResult:
+        raw_path = self._path_state.value().strip()
+        if not raw_path:
+            self._error = "Path is required"
+            return PageResult()
+        branch = self._branch_state.value().strip() or None
+        ok, error = add_worktree(ctx.repo, Path(raw_path), branch)
+        if ok:
+            return PageResult(action=PageAction.POP, repo_changed=True)
+        self._error = error
+        return PageResult()
 
 
 def worktree_add_page() -> WorktreeAddPage:
-	return WorktreeAddPage()
+    return WorktreeAddPage()
diff --git a/pygittools/src/pygittools/tui/pages_test.py b/pygittools/src/pygittools/tui/pages_test.py
index 0656534..494c6f2 100644
--- a/pygittools/src/pygittools/tui/pages_test.py
+++ b/pygittools/src/pygittools/tui/pages_test.py
@@ -23,461 +23,461 @@ from pygittools.tui.types import PageAction, PageContext, PageResult
 
 
 def test_home_page_on_enter_and_quit(page_ctx: PageContext) -> None:
-	page = home_page()
-	page.on_enter(page_ctx)
-	assert page.status_text()
-	result = page.handle_key(key("q"))
-	assert result.action == PageAction.QUIT
+    page = home_page()
+    page.on_enter(page_ctx)
+    assert page.status_text()
+    result = page.handle_key(key("q"))
+    assert result.action == PageAction.QUIT
 
 
 def test_home_page_draw_skips_refresh(page_ctx: PageContext) -> None:
-	page = HomePage()
-	page.on_enter(page_ctx)
-	with (
-		patch.object(page.changes, "refresh") as changes_refresh,
-		patch.object(page.log, "refresh") as log_refresh,
-		patch.object(page.tasks, "refresh") as tasks_refresh,
-		patch.object(page.worktrees, "refresh") as worktrees_refresh,
-	):
-		page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	changes_refresh.assert_not_called()
-	log_refresh.assert_not_called()
-	tasks_refresh.assert_not_called()
-	worktrees_refresh.assert_not_called()
+    page = HomePage()
+    page.on_enter(page_ctx)
+    with (
+        patch.object(page.changes, "refresh") as changes_refresh,
+        patch.object(page.log, "refresh") as log_refresh,
+        patch.object(page.tasks, "refresh") as tasks_refresh,
+        patch.object(page.worktrees, "refresh") as worktrees_refresh,
+    ):
+        page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    changes_refresh.assert_not_called()
+    log_refresh.assert_not_called()
+    tasks_refresh.assert_not_called()
+    worktrees_refresh.assert_not_called()
 
 
 def test_home_page_idle_refresh_after_interval(page_ctx: PageContext) -> None:
-	page = HomePage()
-	with patch("pygittools.tui.pages.home.time.monotonic", return_value=100.0):
-		page.on_enter(page_ctx)
-	with (
-		patch.object(page.changes, "refresh") as changes_refresh,
-		patch.object(page.log, "refresh") as log_refresh,
-		patch.object(page.tasks, "refresh") as tasks_refresh,
-		patch.object(page.worktrees, "refresh") as worktrees_refresh,
-		patch("pygittools.tui.pages.home.time.monotonic", side_effect=[109.0, 110.0, 110.0]),
-	):
-		page.on_idle()
-		changes_refresh.assert_not_called()
-		page.on_idle()
-	changes_refresh.assert_called_once_with(page_ctx.repo)
-	log_refresh.assert_called_once_with(page_ctx.repo)
-	tasks_refresh.assert_called_once_with(page_ctx.repo)
-	worktrees_refresh.assert_called_once_with(page_ctx.repo)
+    page = HomePage()
+    with patch("pygittools.tui.pages.home.time.monotonic", return_value=100.0):
+        page.on_enter(page_ctx)
+    with (
+        patch.object(page.changes, "refresh") as changes_refresh,
+        patch.object(page.log, "refresh") as log_refresh,
+        patch.object(page.tasks, "refresh") as tasks_refresh,
+        patch.object(page.worktrees, "refresh") as worktrees_refresh,
+        patch("pygittools.tui.pages.home.time.monotonic", side_effect=[109.0, 110.0, 110.0]),
+    ):
+        page.on_idle()
+        changes_refresh.assert_not_called()
+        page.on_idle()
+    changes_refresh.assert_called_once_with(page_ctx.repo)
+    log_refresh.assert_called_once_with(page_ctx.repo)
+    tasks_refresh.assert_called_once_with(page_ctx.repo)
+    worktrees_refresh.assert_called_once_with(page_ctx.repo)
 
 
 def test_home_page_handle_key_skips_refresh(page_ctx: PageContext) -> None:
-	page = HomePage()
-	page.on_enter(page_ctx)
-	with (
-		patch.object(page.changes, "refresh") as changes_refresh,
-		patch.object(page.log, "refresh") as log_refresh,
-		patch.object(page.tasks, "refresh") as tasks_refresh,
-		patch.object(page.worktrees, "refresh") as worktrees_refresh,
-	):
-		page.handle_key(key("Down"))
-	changes_refresh.assert_not_called()
-	log_refresh.assert_not_called()
-	tasks_refresh.assert_not_called()
-	worktrees_refresh.assert_not_called()
+    page = HomePage()
+    page.on_enter(page_ctx)
+    with (
+        patch.object(page.changes, "refresh") as changes_refresh,
+        patch.object(page.log, "refresh") as log_refresh,
+        patch.object(page.tasks, "refresh") as tasks_refresh,
+        patch.object(page.worktrees, "refresh") as worktrees_refresh,
+    ):
+        page.handle_key(key("Down"))
+    changes_refresh.assert_not_called()
+    log_refresh.assert_not_called()
+    tasks_refresh.assert_not_called()
+    worktrees_refresh.assert_not_called()
 
 
 def test_home_page_commit_typing_skips_refresh(page_ctx: PageContext) -> None:
-	page = HomePage()
-	page.on_enter(page_ctx)
-	_slash(page, "commit")
-	page.handle_key(key("a"))
-	with (
-		patch.object(page.changes, "refresh") as changes_refresh,
-		patch.object(page.log, "refresh") as log_refresh,
-		patch.object(page.tasks, "refresh") as tasks_refresh,
-		patch.object(page.worktrees, "refresh") as worktrees_refresh,
-	):
-		page.handle_key(key("b"))
-	changes_refresh.assert_not_called()
-	log_refresh.assert_not_called()
-	tasks_refresh.assert_not_called()
-	worktrees_refresh.assert_not_called()
-	assert page.changes.commit_message == "ab"
+    page = HomePage()
+    page.on_enter(page_ctx)
+    _slash(page, "commit")
+    page.handle_key(key("a"))
+    with (
+        patch.object(page.changes, "refresh") as changes_refresh,
+        patch.object(page.log, "refresh") as log_refresh,
+        patch.object(page.tasks, "refresh") as tasks_refresh,
+        patch.object(page.worktrees, "refresh") as worktrees_refresh,
+    ):
+        page.handle_key(key("b"))
+    changes_refresh.assert_not_called()
+    log_refresh.assert_not_called()
+    tasks_refresh.assert_not_called()
+    worktrees_refresh.assert_not_called()
+    assert page.changes.commit_message == "ab"
 
 
 def test_home_page_commit_refreshes_log(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	monkeypatch.setattr(
-		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg, *, amend=False: (True, ""),
-	)
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = HomePage()
-	page.on_enter(ctx)
-	page.changes.set_commit_message("amend note")
-	page.handle_key(key("Enter"))
-	page.handle_key(key("Tab"))
-	with (
-		patch.object(page.log, "refresh") as log_refresh,
-		patch.object(page.tasks, "refresh") as tasks_refresh,
-		patch.object(page.worktrees, "refresh") as worktrees_refresh,
-	):
-		page.handle_key(key("Enter"))
-	log_refresh.assert_called_once_with(committed_repo)
-	tasks_refresh.assert_not_called()
-	worktrees_refresh.assert_not_called()
+    monkeypatch.setattr(
+        "pygittools.tui.changes_list.commit_staged",
+        lambda _repo, _msg, *, amend=False: (True, ""),
+    )
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = HomePage()
+    page.on_enter(ctx)
+    page.changes.set_commit_message("amend note")
+    page.handle_key(key("Enter"))
+    page.handle_key(key("Tab"))
+    with (
+        patch.object(page.log, "refresh") as log_refresh,
+        patch.object(page.tasks, "refresh") as tasks_refresh,
+        patch.object(page.worktrees, "refresh") as worktrees_refresh,
+    ):
+        page.handle_key(key("Enter"))
+    log_refresh.assert_called_once_with(committed_repo)
+    tasks_refresh.assert_not_called()
+    worktrees_refresh.assert_not_called()
 
 
 def test_home_page_input_commit_refreshes_log(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	monkeypatch.setattr(
-		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg, *, amend=False: (True, ""),
-	)
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = HomePage()
-	page.on_enter(ctx)
-	_slash(page, "commit")
-	page.handle_key(key("m"))
-	page.handle_key(key("Tab"))
-	with (
-		patch.object(page.log, "refresh") as log_refresh,
-		patch.object(page.tasks, "refresh") as tasks_refresh,
-		patch.object(page.worktrees, "refresh") as worktrees_refresh,
-	):
-		page.handle_key(key("Enter"))
-	log_refresh.assert_called_once_with(committed_repo)
-	tasks_refresh.assert_not_called()
-	worktrees_refresh.assert_not_called()
+    monkeypatch.setattr(
+        "pygittools.tui.changes_list.commit_staged",
+        lambda _repo, _msg, *, amend=False: (True, ""),
+    )
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = HomePage()
+    page.on_enter(ctx)
+    _slash(page, "commit")
+    page.handle_key(key("m"))
+    page.handle_key(key("Tab"))
+    with (
+        patch.object(page.log, "refresh") as log_refresh,
+        patch.object(page.tasks, "refresh") as tasks_refresh,
+        patch.object(page.worktrees, "refresh") as worktrees_refresh,
+    ):
+        page.handle_key(key("Enter"))
+    log_refresh.assert_called_once_with(committed_repo)
+    tasks_refresh.assert_not_called()
+    worktrees_refresh.assert_not_called()
 
 
 def test_home_page_draw_and_tabs(committed_repo: Repository) -> None:
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = home_page()
-	page.on_enter(ctx)
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	page.set_active_tab("log")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	page.set_active_tab("tasks")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	page.set_active_tab("worktrees")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	page.set_header_focus("repo")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	page.handle_key(key("Null"))
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = home_page()
+    page.on_enter(ctx)
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    page.set_active_tab("log")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    page.set_active_tab("tasks")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    page.set_active_tab("worktrees")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    page.set_header_focus("repo")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    page.handle_key(key("Null"))
 
 
 def test_home_page_switch_to_input(page_ctx: PageContext) -> None:
-	page = home_page()
-	page.on_enter(page_ctx)
-	_slash(page, "commit")
-	assert "Esc" in page.status_text()
+    page = home_page()
+    page.on_enter(page_ctx)
+    _slash(page, "commit")
+    assert "Esc" in page.status_text()
 
 
 def test_home_page_types_q_in_commit_input(page_ctx: PageContext) -> None:
-	page = HomePage()
-	page.on_enter(page_ctx)
-	_slash(page, "commit")
-	result = page.handle_key(key("q"))
-	assert result.action == PageAction.NONE
-	assert "q" in page.changes.commit_message
-	result = page.handle_key(key("Q"))
-	assert result.action == PageAction.NONE
-	assert "qQ" in page.changes.commit_message
+    page = HomePage()
+    page.on_enter(page_ctx)
+    _slash(page, "commit")
+    result = page.handle_key(key("q"))
+    assert result.action == PageAction.NONE
+    assert "q" in page.changes.commit_message
+    result = page.handle_key(key("Q"))
+    assert result.action == PageAction.NONE
+    assert "qQ" in page.changes.commit_message
 
 
 def test_branch_filter_types_q(committed_repo: Repository) -> None:
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = BranchPickerPage()
-	page.on_enter(ctx)
-	result = page.handle_key(key("q"))
-	assert result.action == PageAction.NONE
-	assert page._filter_state.value() == "q"
-	result = page.handle_key(key("Q"))
-	assert result.action == PageAction.NONE
-	assert page._filter_state.value() == "qQ"
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = BranchPickerPage()
+    page.on_enter(ctx)
+    result = page.handle_key(key("q"))
+    assert result.action == PageAction.NONE
+    assert page._filter_state.value() == "q"
+    result = page.handle_key(key("Q"))
+    assert result.action == PageAction.NONE
+    assert page._filter_state.value() == "qQ"
 
 
 def test_task_editor_types_q_in_input(page_ctx: PageContext) -> None:
-	page = TaskEditorPage(board_name="Tasks", task_ref=None, default_status="todo")
-	page.on_enter(page_ctx)
-	page.handle_key(key("Enter"))
-	result = page.handle_key(key("q"))
-	assert result.action == PageAction.NONE
-	assert page._text_state.value() == "q"
-	result = page.handle_key(key("Q"))
-	assert result.action == PageAction.NONE
-	assert page._text_state.value() == "qQ"
+    page = TaskEditorPage(board_name="Tasks", task_ref=None, default_status="todo")
+    page.on_enter(page_ctx)
+    page.handle_key(key("Enter"))
+    result = page.handle_key(key("q"))
+    assert result.action == PageAction.NONE
+    assert page._text_state.value() == "q"
+    result = page.handle_key(key("Q"))
+    assert result.action == PageAction.NONE
+    assert page._text_state.value() == "qQ"
 
 
 def test_home_page_push_help(page_ctx: PageContext) -> None:
-	page = home_page()
-	page.on_enter(page_ctx)
-	result = _slash(page, "help")
-	assert result.action == PageAction.PUSH
-	assert isinstance(result.next_page, HelpPage)
+    page = home_page()
+    page.on_enter(page_ctx)
+    result = _slash(page, "help")
+    assert result.action == PageAction.PUSH
+    assert isinstance(result.next_page, HelpPage)
 
 
 def test_home_page_actions_row(repo: Repository) -> None:
-	workdir = Path(repo.workdir or repo.path)
-	(workdir / "pyproject.toml").write_text(
-		'[tool.pgt]\nactions = [{ name = "lint", script = "lint.py" }]\n\n'
-		'[tool.pgt.sets.release]\nactions = [{ name = "build", script = "build.py" }]\n',
-		encoding="utf-8",
-	)
-	ctx = PageContext(repo=repo, repo_path=Path(repo.path))
-	page = home_page()
-	assert isinstance(page, HomePage)
-	page.on_enter(ctx)
-	assert page.has_actions
-	page.handle_key(key("Up"))
-	assert page.header_focus == "actions"
-	assert "open actions" in page.status_text()
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	opened = page.handle_key(key("Enter"))
-	assert opened.action == PageAction.PUSH
-	assert isinstance(opened.next_page, ActionsPage)
-	page.set_active_tab("log")
-	assert page.header_focus == "changes"
+    workdir = Path(repo.workdir or repo.path)
+    (workdir / "pyproject.toml").write_text(
+        '[tool.pgt]\nactions = [{ name = "lint", script = "lint.py" }]\n\n'
+        '[tool.pgt.sets.release]\nactions = [{ name = "build", script = "build.py" }]\n',
+        encoding="utf-8",
+    )
+    ctx = PageContext(repo=repo, repo_path=Path(repo.path))
+    page = home_page()
+    assert isinstance(page, HomePage)
+    page.on_enter(ctx)
+    assert page.has_actions
+    page.handle_key(key("Up"))
+    assert page.header_focus == "actions"
+    assert "open actions" in page.status_text()
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    opened = page.handle_key(key("Enter"))
+    assert opened.action == PageAction.PUSH
+    assert isinstance(opened.next_page, ActionsPage)
+    page.set_active_tab("log")
+    assert page.header_focus == "changes"
 
 
 def test_home_page_push_actions(page_ctx: PageContext) -> None:
-	page = home_page()
-	page.on_enter(page_ctx)
-	result = _slash(page, "actions")
-	assert result.action == PageAction.PUSH
-	assert isinstance(result.next_page, ActionsPage)
+    page = home_page()
+    page.on_enter(page_ctx)
+    result = _slash(page, "actions")
+    assert result.action == PageAction.PUSH
+    assert isinstance(result.next_page, ActionsPage)
 
 
 def test_home_page_draws_detached_head(committed_repo: Repository) -> None:
-	commit = committed_repo.get(committed_repo.head.target)
-	committed_repo.set_head(commit.id)
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = home_page()
-	page.on_enter(ctx)
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	assert current_branch_name(committed_repo) == "(detached)"
-	assert "arrows move" in page.status_text()
-	page.set_active_tab("log")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	assert "No commits" not in page.status_text()
+    commit = committed_repo.get(committed_repo.head.target)
+    committed_repo.set_head(commit.id)
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = home_page()
+    page.on_enter(ctx)
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    assert current_branch_name(committed_repo) == "(detached)"
+    assert "arrows move" in page.status_text()
+    page.set_active_tab("log")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    assert "No commits" not in page.status_text()
 
 
 def test_empty_repo_can_create_branch_and_commit(repo: Repository) -> None:
-	assert repo.head_is_unborn
-	assert list(repo.branches.local) == []
-	ctx = PageContext(repo=repo, repo_path=Path(repo.path))
-	page = home_page()
-	assert isinstance(page, HomePage)
-	page.on_enter(ctx)
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	page.set_active_tab("log")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	assert page.status_text() == "No commits — arrows move"
-	page.set_active_tab("status")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-
-	page.handle_key(key("Up"))
-	page.handle_key(key("Up"))
-	opened = page.handle_key(key("Enter"))
-	assert opened.action == PageAction.PUSH
-	assert isinstance(opened.next_page, BranchPickerPage)
-	picker = opened.next_page
-	picker.on_enter(ctx)
-	picker.draw(MagicMock(), Rect(0, 0, 80, 20))
-	for char in "feature":
-		picker.handle_key(key(char))
-	created = picker.handle_key(key("Enter"))
-	assert created.action == PageAction.POP
-	assert current_branch_name(repo) == "feature"
-
-	workdir = repo.workdir
-	assert workdir is not None
-	(Path(workdir) / "hello.txt").write_text("hello\n", encoding="utf-8")
-	page.refresh()
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	page.handle_key(key("Down"))
-	_press_until(page, key("Down"), lambda hint: hint.startswith("Enter stage —"))
-	page.handle_key(key("Enter"))
-	_press_until(page, key("Up"), lambda hint: "edit" in hint.casefold())
-	page.handle_key(key("Enter"))
-	for char in "init":
-		page.handle_key(key(char))
-	page.handle_key(key("Tab"))
-	page.handle_key(key("Enter"))
-
-	assert not repo.head_is_unborn, page.changes.status_hint()
-	assert current_branch_name(repo) == "feature"
-	assert "feature" in repo.branches.local
-	assert repo.head.peel(Commit).message.splitlines()[0] == "init"
-	page.set_active_tab("log")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	assert "No commits" not in page.status_text()
+    assert repo.head_is_unborn
+    assert list(repo.branches.local) == []
+    ctx = PageContext(repo=repo, repo_path=Path(repo.path))
+    page = home_page()
+    assert isinstance(page, HomePage)
+    page.on_enter(ctx)
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    page.set_active_tab("log")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    assert page.status_text() == "No commits — arrows move"
+    page.set_active_tab("status")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+
+    page.handle_key(key("Up"))
+    page.handle_key(key("Up"))
+    opened = page.handle_key(key("Enter"))
+    assert opened.action == PageAction.PUSH
+    assert isinstance(opened.next_page, BranchPickerPage)
+    picker = opened.next_page
+    picker.on_enter(ctx)
+    picker.draw(MagicMock(), Rect(0, 0, 80, 20))
+    for char in "feature":
+        picker.handle_key(key(char))
+    created = picker.handle_key(key("Enter"))
+    assert created.action == PageAction.POP
+    assert current_branch_name(repo) == "feature"
+
+    workdir = repo.workdir
+    assert workdir is not None
+    (Path(workdir) / "hello.txt").write_text("hello\n", encoding="utf-8")
+    page.refresh()
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    page.handle_key(key("Down"))
+    _press_until(page, key("Down"), lambda hint: hint.startswith("Enter stage —"))
+    page.handle_key(key("Enter"))
+    _press_until(page, key("Up"), lambda hint: "edit" in hint.casefold())
+    page.handle_key(key("Enter"))
+    for char in "init":
+        page.handle_key(key(char))
+    page.handle_key(key("Tab"))
+    page.handle_key(key("Enter"))
+
+    assert not repo.head_is_unborn, page.changes.status_hint()
+    assert current_branch_name(repo) == "feature"
+    assert "feature" in repo.branches.local
+    assert repo.head.peel(Commit).message.splitlines()[0] == "init"
+    page.set_active_tab("log")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+    assert "No commits" not in page.status_text()
 
 
 def _press_until(page: HomePage, press: object, ready: Callable[[str], bool]) -> None:
-	for _ in range(12):
-		hint = page.status_text()
-		if ready(hint):
-			return
-		page.handle_key(press)  # type: ignore[arg-type]
-	raise AssertionError(page.status_text())
+    for _ in range(12):
+        hint = page.status_text()
+        if ready(hint):
+            return
+        page.handle_key(press)  # type: ignore[arg-type]
+    raise AssertionError(page.status_text())
 
 
 def _slash(page: HomePage | NoRepoPage, command: str) -> PageResult:
-	page.handle_key(key("/"))
-	for char in command:
-		page.handle_key(key(char))
-	return page.handle_key(key("Enter"))
+    page.handle_key(key("/"))
+    for char in command:
+        page.handle_key(key(char))
+    return page.handle_key(key("Enter"))
 
 
 def test_home_page_draw_without_context() -> None:
-	page = home_page()
-	page.draw(MagicMock(), Rect(0, 0, 80, 10))
+    page = home_page()
+    page.draw(MagicMock(), Rect(0, 0, 80, 10))
 
 
 def test_no_repo_page(tmp_path: Path) -> None:
-	page = NoRepoPage(tmp_path)
-	page.on_enter(PageContext(repo=MagicMock(), repo_path=tmp_path))  # type: ignore[arg-type]
-	page.draw(MagicMock(), Rect(0, 0, 80, 10))
-	assert page.handle_key(key("q")).action == PageAction.QUIT
-	assert _slash(page, "help").action == PageAction.PUSH
-	page.handle_key(key("Null"))
+    page = NoRepoPage(tmp_path)
+    page.on_enter(PageContext(repo=MagicMock(), repo_path=tmp_path))  # type: ignore[arg-type]
+    page.draw(MagicMock(), Rect(0, 0, 80, 10))
+    assert page.handle_key(key("q")).action == PageAction.QUIT
+    assert _slash(page, "help").action == PageAction.PUSH
+    page.handle_key(key("Null"))
 
 
 def test_help_page_scroll_and_pop() -> None:
-	page = help_page()
-	page.on_enter(PageContext(repo=MagicMock(), repo_path=Path(".")))  # type: ignore[arg-type]
-	page.draw(MagicMock(), Rect(0, 0, 80, 5))
-	page.handle_key(key("Down"))
-	page.handle_key(key("Up"))
-	assert page.handle_key(key("q")).action == PageAction.POP
-	page.handle_key(key("Null"))
+    page = help_page()
+    page.on_enter(PageContext(repo=MagicMock(), repo_path=Path(".")))  # type: ignore[arg-type]
+    page.draw(MagicMock(), Rect(0, 0, 80, 5))
+    page.handle_key(key("Down"))
+    page.handle_key(key("Up"))
+    assert page.handle_key(key("q")).action == PageAction.POP
+    page.handle_key(key("Null"))
 
 
 def test_branch_picker_lists_and_filters(committed_repo: Repository) -> None:
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = branch_picker_page()
-	page.on_enter(ctx)
-	page.draw(MagicMock(), Rect(0, 0, 80, 10))
-	page.handle_key(key("f"))
-	page.handle_key(key("Backspace"))
-	page.handle_key(key("Down"))
-	page.handle_key(key("Up"))
-	page.handle_key(key("Enter"))
-	assert page.handle_key(key("q")).action == PageAction.POP
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = branch_picker_page()
+    page.on_enter(ctx)
+    page.draw(MagicMock(), Rect(0, 0, 80, 10))
+    page.handle_key(key("f"))
+    page.handle_key(key("Backspace"))
+    page.handle_key(key("Down"))
+    page.handle_key(key("Up"))
+    page.handle_key(key("Enter"))
+    assert page.handle_key(key("q")).action == PageAction.POP
 
 
 def test_branch_picker_checkout_branch(committed_repo: Repository) -> None:
-	commit = committed_repo.get(committed_repo.head.target)
-	committed_repo.branches.create("feature", commit)
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = branch_picker_page()
-	page.on_enter(ctx)
-	page.handle_key(key("Down"))
-	page.handle_key(key("Enter"))
-	assert page.handle_key(key("Enter")).action == PageAction.POP
+    commit = committed_repo.get(committed_repo.head.target)
+    committed_repo.branches.create("feature", commit)
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = branch_picker_page()
+    page.on_enter(ctx)
+    page.handle_key(key("Down"))
+    page.handle_key(key("Enter"))
+    assert page.handle_key(key("Enter")).action == PageAction.POP
 
 
 def test_branch_picker_create_branch(committed_repo: Repository) -> None:
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = branch_picker_page()
-	page.on_enter(ctx)
-	page.handle_key(key("n"))
-	page.handle_key(key("e"))
-	page.handle_key(key("w"))
-	page.handle_key(key("Enter"))
-	assert "new" in committed_repo.branches.local
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = branch_picker_page()
+    page.on_enter(ctx)
+    page.handle_key(key("n"))
+    page.handle_key(key("e"))
+    page.handle_key(key("w"))
+    page.handle_key(key("Enter"))
+    assert "new" in committed_repo.branches.local
 
 
 def test_branch_picker_create_invalid_shows_error(committed_repo: Repository) -> None:
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = branch_picker_page()
-	page.on_enter(ctx)
-	page.handle_key(key(" "))
-	page.handle_key(key("Enter"))
-	assert page.status_text()
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = branch_picker_page()
+    page.on_enter(ctx)
+    page.handle_key(key(" "))
+    page.handle_key(key("Enter"))
+    assert page.status_text()
 
 
 def test_branch_picker_draw_zero_height() -> None:
-	page = BranchPickerPage()
-	page.draw(MagicMock(), Rect(0, 0, 80, 0))
+    page = BranchPickerPage()
+    page.draw(MagicMock(), Rect(0, 0, 80, 0))
 
 
 def test_projects_page_lists_repos(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	root = tmp_path / "root"
-	project = root / "demo"
-	project.mkdir(parents=True)
-	from pygit2 import init_repository
-
-	repo = init_repository(str(project), bare=False)
-	workdir = Path(repo.workdir)
-	(workdir / "README").write_text("x\n", encoding="utf-8")
-	repo.index.add("README")
-	repo.index.write()
-	tree = repo.index.write_tree()
-	sig = Signature("t", "t@example.com")
-	repo.create_commit("HEAD", sig, sig, "init", tree, [])
-	settings = tmp_path / "settings.json"
-	settings.write_text(json.dumps({"PROJECTROOT": str(root)}), encoding="utf-8")
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
-	ctx = PageContext(repo=Repository(str(project / ".git")), repo_path=project / ".git")
-	page = projects_page()
-	page.on_enter(ctx)
-	page.draw(MagicMock(), Rect(0, 0, 80, 10))
-	page.handle_key(key("Down"))
-	result = page.handle_key(key("Enter"))
-	assert result.switch_repo == project.resolve()
+    root = tmp_path / "root"
+    project = root / "demo"
+    project.mkdir(parents=True)
+    from pygit2 import init_repository
+
+    repo = init_repository(str(project), bare=False)
+    workdir = Path(repo.workdir)
+    (workdir / "README").write_text("x\n", encoding="utf-8")
+    repo.index.add("README")
+    repo.index.write()
+    tree = repo.index.write_tree()
+    sig = Signature("t", "t@example.com")
+    repo.create_commit("HEAD", sig, sig, "init", tree, [])
+    settings = tmp_path / "settings.json"
+    settings.write_text(json.dumps({"PROJECTROOT": str(root)}), encoding="utf-8")
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+    ctx = PageContext(repo=Repository(str(project / ".git")), repo_path=project / ".git")
+    page = projects_page()
+    page.on_enter(ctx)
+    page.draw(MagicMock(), Rect(0, 0, 80, 10))
+    page.handle_key(key("Down"))
+    result = page.handle_key(key("Enter"))
+    assert result.switch_repo == project.resolve()
 
 
 def test_projects_page_empty(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
-	settings = tmp_path / "settings.json"
-	settings.write_text(json.dumps({"PROJECTROOT": str(tmp_path / "empty")}), encoding="utf-8")
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
-	page = projects_page()
-	page.on_enter(PageContext(repo=MagicMock(), repo_path=tmp_path))  # type: ignore[arg-type]
-	assert "No projects" in page.status_text()
-	page.handle_key(key("Enter"))
+    settings = tmp_path / "settings.json"
+    settings.write_text(json.dumps({"PROJECTROOT": str(tmp_path / "empty")}), encoding="utf-8")
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+    page = projects_page()
+    page.on_enter(PageContext(repo=MagicMock(), repo_path=tmp_path))  # type: ignore[arg-type]
+    assert "No projects" in page.status_text()
+    page.handle_key(key("Enter"))
 
 
 def test_projects_page_draw_zero_height() -> None:
-	ProjectsPage().draw(MagicMock(), Rect(0, 0, 80, 0))
+    ProjectsPage().draw(MagicMock(), Rect(0, 0, 80, 0))
 
 
 def _empty_tree() -> Oid:
-	return Oid(hex=EMPTY_TREE_OID_HEX)
+    return Oid(hex=EMPTY_TREE_OID_HEX)
 
 
 def test_home_page_tasks_tab_creates_and_edits_tasks(committed_repo: Repository) -> None:
-	board_ref = f"{BOARD_REF_PREFIX}Tasks"
-	board = Board(_empty_tree(), board_ref, tagger="", description="Main")
-	task = Task(
-		_empty_tree(),
-		f"{TASK_REF_PREFIX}alpha",
-		"alice <alice@example.com>",
-		title="Alpha",
-		description="Do alpha",
-		status=Task.Status.TODO,
-	)
-	task_oid = task.write(committed_repo)
-	board.tasks = [str(task_oid)]
-	board.update_message()
-	board.write(committed_repo)
-
-	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
-	page = home_page()
-	page.on_enter(ctx)
-	page.set_active_tab("tasks")
-	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-
-	# Move to first status header and then to (+ New Task)
-	page.handle_key(key("Down"))
-	page.handle_key(key("Down"))
-	result_new = page.handle_key(key("Enter"))
-	assert result_new.action == PageAction.PUSH
-	assert isinstance(result_new.next_page, TaskEditorPage)
-
-	# Move to existing task row and open editor
-	page = home_page()
-	page.on_enter(ctx)
-	page.set_active_tab("tasks")
-	page.handle_key(key("Down"))
-	page.handle_key(key("Down"))
-	page.handle_key(key("Down"))
-	result_existing = page.handle_key(key("Enter"))
-	assert result_existing.action == PageAction.PUSH
-	assert isinstance(result_existing.next_page, TaskEditorPage)
+    board_ref = f"{BOARD_REF_PREFIX}Tasks"
+    board = Board(_empty_tree(), board_ref, tagger="", description="Main")
+    task = Task(
+        _empty_tree(),
+        f"{TASK_REF_PREFIX}alpha",
+        "alice <alice@example.com>",
+        title="Alpha",
+        description="Do alpha",
+        status=Task.Status.TODO,
+    )
+    task_oid = task.write(committed_repo)
+    board.tasks = [str(task_oid)]
+    board.update_message()
+    board.write(committed_repo)
+
+    ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+    page = home_page()
+    page.on_enter(ctx)
+    page.set_active_tab("tasks")
+    page.draw(MagicMock(), Rect(0, 0, 80, 20))
+
+    # Move to first status header and then to (+ New Task)
+    page.handle_key(key("Down"))
+    page.handle_key(key("Down"))
+    result_new = page.handle_key(key("Enter"))
+    assert result_new.action == PageAction.PUSH
+    assert isinstance(result_new.next_page, TaskEditorPage)
+
+    # Move to existing task row and open editor
+    page = home_page()
+    page.on_enter(ctx)
+    page.set_active_tab("tasks")
+    page.handle_key(key("Down"))
+    page.handle_key(key("Down"))
+    page.handle_key(key("Down"))
+    result_existing = page.handle_key(key("Enter"))
+    assert result_existing.action == PageAction.PUSH
+    assert isinstance(result_existing.next_page, TaskEditorPage)
diff --git a/pygittools/src/pygittools/tui/project_label.py b/pygittools/src/pygittools/tui/project_label.py
index 9a8e0fe..0fdd84e 100644
--- a/pygittools/src/pygittools/tui/project_label.py
+++ b/pygittools/src/pygittools/tui/project_label.py
@@ -8,23 +8,23 @@ from pygittools.tui.pygitweb_layout import load_pygitweb_layout
 
 
 def repo_project_label(repo_path: Path) -> str:
-	"""Return the PyGitWeb project path when under PROJECTROOT, else the worktree path."""
-	worktree = _worktree_path(repo_path)
-	try:
-		layout = load_pygitweb_layout()
-	except OSError:
-		return str(worktree)
-	projectroot = layout.projectroot.resolve()
-	try:
-		relative = worktree.relative_to(projectroot)
-	except ValueError as e:
-		print(e)
-		return str(worktree)
-	return str(relative).replace("\\", "/")
+    """Return the PyGitWeb project path when under PROJECTROOT, else the worktree path."""
+    worktree = _worktree_path(repo_path)
+    try:
+        layout = load_pygitweb_layout()
+    except OSError:
+        return str(worktree)
+    projectroot = layout.projectroot.resolve()
+    try:
+        relative = worktree.relative_to(projectroot)
+    except ValueError as e:
+        print(e)
+        return str(worktree)
+    return str(relative).replace("\\", "/")
 
 
 def _worktree_path(repo_path: Path) -> Path:
-	resolved = repo_path.resolve()
-	if resolved.name == ".git":
-		return resolved.parent
-	return resolved
+    resolved = repo_path.resolve()
+    if resolved.name == ".git":
+        return resolved.parent
+    return resolved
diff --git a/pygittools/src/pygittools/tui/projects.py b/pygittools/src/pygittools/tui/projects.py
index 83c2378..9e30f1a 100644
--- a/pygittools/src/pygittools/tui/projects.py
+++ b/pygittools/src/pygittools/tui/projects.py
@@ -15,97 +15,97 @@ from pygittools.tui.pygitweb_layout import PygitwebLayout
 
 @dataclass(frozen=True, slots=True)
 class ProjectEntry:
-	path: str
-	worktree: Path
-	branch: str
+    path: str
+    worktree: Path
+    branch: str
 
 
 def list_projects(layout: PygitwebLayout) -> list[ProjectEntry]:
-	projects_list = layout.projects_list
-	if projects_list.is_dir():
-		raw = _find_projects_in_dir(layout)
-	elif projects_list.is_file():
-		raw = _projects_from_file(layout)
-	else:
-		raw = []
-	entries: list[ProjectEntry] = []
-	for project_path, worktree in raw:
-		entries.append(
-			ProjectEntry(
-				path=project_path,
-				worktree=worktree,
-				branch=_branch_for_worktree(worktree),
-			),
-		)
-	return sorted(entries, key=lambda entry: entry.path.casefold())
+    projects_list = layout.projects_list
+    if projects_list.is_dir():
+        raw = _find_projects_in_dir(layout)
+    elif projects_list.is_file():
+        raw = _projects_from_file(layout)
+    else:
+        raw = []
+    entries: list[ProjectEntry] = []
+    for project_path, worktree in raw:
+        entries.append(
+            ProjectEntry(
+                path=project_path,
+                worktree=worktree,
+                branch=_branch_for_worktree(worktree),
+            ),
+        )
+    return sorted(entries, key=lambda entry: entry.path.casefold())
 
 
 def _branch_for_worktree(worktree: Path) -> str:
-	try:
-		repo = Repository(str(worktree))
-	except GitError:
-		return "?"
-	return current_branch_name(repo)
+    try:
+        repo = Repository(str(worktree))
+    except GitError:
+        return "?"
+    return current_branch_name(repo)
 
 
 def _find_projects_in_dir(layout: PygitwebLayout) -> list[tuple[str, Path]]:
-	root = layout.projects_list.resolve()
-	projectroot = layout.projectroot.resolve()
-	result: list[tuple[str, Path]] = []
-	for dirpath, dirnames, _ in os.walk(root, topdown=True):
-		rel = os.path.relpath(dirpath, root)
-		depth = 0 if rel == "." else rel.count(os.sep) + 1
-		if depth >= layout.project_maxdepth:
-			dirnames.clear()
-			continue
-		for name in list(dirnames):
-			path = Path(dirpath) / name
-			if not path.is_dir():
-				continue
-			try:
-				if not os.access(path, os.X_OK):
-					continue
-			except OSError:
-				continue
-			discovered = discover_repository(str(path))
-			if discovered is None:
-				continue
-			project_path = os.path.relpath(path, projectroot).replace("\\", "/")
-			if not layout.list_all and not _export_ok(path, layout.export_ok):
-				continue
-			worktree = path.resolve()
-			result.append((project_path, worktree))
-			dirnames.remove(name)
-	return result
+    root = layout.projects_list.resolve()
+    projectroot = layout.projectroot.resolve()
+    result: list[tuple[str, Path]] = []
+    for dirpath, dirnames, _ in os.walk(root, topdown=True):
+        rel = os.path.relpath(dirpath, root)
+        depth = 0 if rel == "." else rel.count(os.sep) + 1
+        if depth >= layout.project_maxdepth:
+            dirnames.clear()
+            continue
+        for name in list(dirnames):
+            path = Path(dirpath) / name
+            if not path.is_dir():
+                continue
+            try:
+                if not os.access(path, os.X_OK):
+                    continue
+            except OSError:
+                continue
+            discovered = discover_repository(str(path))
+            if discovered is None:
+                continue
+            project_path = os.path.relpath(path, projectroot).replace("\\", "/")
+            if not layout.list_all and not _export_ok(path, layout.export_ok):
+                continue
+            worktree = path.resolve()
+            result.append((project_path, worktree))
+            dirnames.remove(name)
+    return result
 
 
 def _projects_from_file(layout: PygitwebLayout) -> list[tuple[str, Path]]:
-	projectroot = layout.projectroot.resolve()
-	result: list[tuple[str, Path]] = []
-	try:
-		lines = layout.projects_list.read_text(encoding="utf-8").splitlines()
-	except OSError:
-		return result
-	for line in lines:
-		line = line.strip()
-		if not line:
-			continue
-		parts = line.split(None, 1)
-		project_path = unquote(parts[0]) if parts else ""
-		if not project_path:
-			continue
-		worktree = (projectroot / project_path).resolve()
-		if not worktree.is_dir():
-			continue
-		if discover_repository(str(worktree)) is None:
-			continue
-		if not layout.list_all and not _export_ok(worktree, layout.export_ok):
-			continue
-		result.append((project_path, worktree))
-	return result
+    projectroot = layout.projectroot.resolve()
+    result: list[tuple[str, Path]] = []
+    try:
+        lines = layout.projects_list.read_text(encoding="utf-8").splitlines()
+    except OSError:
+        return result
+    for line in lines:
+        line = line.strip()
+        if not line:
+            continue
+        parts = line.split(None, 1)
+        project_path = unquote(parts[0]) if parts else ""
+        if not project_path:
+            continue
+        worktree = (projectroot / project_path).resolve()
+        if not worktree.is_dir():
+            continue
+        if discover_repository(str(worktree)) is None:
+            continue
+        if not layout.list_all and not _export_ok(worktree, layout.export_ok):
+            continue
+        result.append((project_path, worktree))
+    return result
 
 
 def _export_ok(worktree: Path, export_ok: str) -> bool:
-	if export_ok and not (worktree / export_ok).is_file():
-		return False
-	return discover_repository(str(worktree)) is not None
+    if export_ok and not (worktree / export_ok).is_file():
+        return False
+    return discover_repository(str(worktree)) is not None
diff --git a/pygittools/src/pygittools/tui/projects_test.py b/pygittools/src/pygittools/tui/projects_test.py
index fb7affe..3f69286 100644
--- a/pygittools/src/pygittools/tui/projects_test.py
+++ b/pygittools/src/pygittools/tui/projects_test.py
@@ -6,157 +6,157 @@ import pytest
 from pygit2 import init_repository
 
 from pygittools.tui.projects import (
-	_branch_for_worktree,
-	_export_ok,
-	_find_projects_in_dir,
-	_projects_from_file,
-	list_projects,
+    _branch_for_worktree,
+    _export_ok,
+    _find_projects_in_dir,
+    _projects_from_file,
+    list_projects,
 )
 from pygittools.tui.pygitweb_layout import PygitwebLayout
 
 
 def _layout(tmp_path: Path, **overrides: object) -> PygitwebLayout:
-	root = tmp_path / "root"
-	root.mkdir()
-	defaults = {
-		"projectroot": root,
-		"projects_list": root,
-		"project_maxdepth": 2,
-		"list_all": True,
-		"export_ok": "",
-	}
-	defaults.update(overrides)
-	return PygitwebLayout(**defaults)  # type: ignore[arg-type]
+    root = tmp_path / "root"
+    root.mkdir()
+    defaults = {
+        "projectroot": root,
+        "projects_list": root,
+        "project_maxdepth": 2,
+        "list_all": True,
+        "export_ok": "",
+    }
+    defaults.update(overrides)
+    return PygitwebLayout(**defaults)  # type: ignore[arg-type]
 
 
 def _init_repo_with_commit(path: Path) -> None:
-	from pygit2 import Signature, init_repository
+    from pygit2 import Signature, init_repository
 
-	repo = init_repository(str(path), bare=False)
-	workdir = Path(repo.workdir)
-	(workdir / "README").write_text("x\n", encoding="utf-8")
-	repo.index.add("README")
-	repo.index.write()
-	tree = repo.index.write_tree()
-	sig = Signature("t", "t@example.com")
-	repo.create_commit("HEAD", sig, sig, "init", tree, [])
+    repo = init_repository(str(path), bare=False)
+    workdir = Path(repo.workdir)
+    (workdir / "README").write_text("x\n", encoding="utf-8")
+    repo.index.add("README")
+    repo.index.write()
+    tree = repo.index.write_tree()
+    sig = Signature("t", "t@example.com")
+    repo.create_commit("HEAD", sig, sig, "init", tree, [])
 
 
 def test_list_projects_from_directory(tmp_path: Path) -> None:
-	layout = _layout(tmp_path)
-	project = layout.projectroot / "alpha"
-	project.mkdir()
-	_init_repo_with_commit(project)
-	entries = list_projects(layout)
-	assert len(entries) == 1
-	assert entries[0].path == "alpha"
-	assert entries[0].branch in {"main", "master"}
+    layout = _layout(tmp_path)
+    project = layout.projectroot / "alpha"
+    project.mkdir()
+    _init_repo_with_commit(project)
+    entries = list_projects(layout)
+    assert len(entries) == 1
+    assert entries[0].path == "alpha"
+    assert entries[0].branch in {"main", "master"}
 
 
 def test_list_projects_from_file(tmp_path: Path) -> None:
-	layout = _layout(tmp_path)
-	project = layout.projectroot / "beta"
-	project.mkdir()
-	_init_repo_with_commit(project)
-	projects_file = tmp_path / "projects.txt"
-	projects_file.write_text("beta\n", encoding="utf-8")
-	layout = PygitwebLayout(
-		projectroot=layout.projectroot,
-		projects_list=projects_file,
-		project_maxdepth=1,
-		list_all=True,
-		export_ok="",
-	)
-	entries = list_projects(layout)
-	assert [entry.path for entry in entries] == ["beta"]
+    layout = _layout(tmp_path)
+    project = layout.projectroot / "beta"
+    project.mkdir()
+    _init_repo_with_commit(project)
+    projects_file = tmp_path / "projects.txt"
+    projects_file.write_text("beta\n", encoding="utf-8")
+    layout = PygitwebLayout(
+        projectroot=layout.projectroot,
+        projects_list=projects_file,
+        project_maxdepth=1,
+        list_all=True,
+        export_ok="",
+    )
+    entries = list_projects(layout)
+    assert [entry.path for entry in entries] == ["beta"]
 
 
 def test_list_projects_respects_export_ok(tmp_path: Path) -> None:
-	layout = _layout(tmp_path, list_all=False, export_ok="git-daemon-export-ok")
-	project = layout.projectroot / "hidden"
-	project.mkdir()
-	_init_repo_with_commit(project)
-	assert list_projects(layout) == []
-	(project / "git-daemon-export-ok").write_text("", encoding="utf-8")
-	assert len(list_projects(layout)) == 1
+    layout = _layout(tmp_path, list_all=False, export_ok="git-daemon-export-ok")
+    project = layout.projectroot / "hidden"
+    project.mkdir()
+    _init_repo_with_commit(project)
+    assert list_projects(layout) == []
+    (project / "git-daemon-export-ok").write_text("", encoding="utf-8")
+    assert len(list_projects(layout)) == 1
 
 
 def test_list_projects_missing_projects_list(tmp_path: Path) -> None:
-	layout = PygitwebLayout(
-		projectroot=tmp_path / "root",
-		projects_list=tmp_path / "missing",
-		project_maxdepth=1,
-		list_all=True,
-		export_ok="",
-	)
-	assert list_projects(layout) == []
+    layout = PygitwebLayout(
+        projectroot=tmp_path / "root",
+        projects_list=tmp_path / "missing",
+        project_maxdepth=1,
+        list_all=True,
+        export_ok="",
+    )
+    assert list_projects(layout) == []
 
 
 def test_projects_from_file_skips_invalid_lines(tmp_path: Path) -> None:
-	layout = _layout(tmp_path)
-	projects_file = tmp_path / "projects.txt"
-	projects_file.write_text("\n  \nmissing\n%20encoded\n", encoding="utf-8")
-	assert (
-		_projects_from_file(
-			PygitwebLayout(
-				projectroot=layout.projectroot,
-				projects_list=projects_file,
-				project_maxdepth=1,
-				list_all=True,
-				export_ok="",
-			),
-		)
-		== []
-	)
+    layout = _layout(tmp_path)
+    projects_file = tmp_path / "projects.txt"
+    projects_file.write_text("\n  \nmissing\n%20encoded\n", encoding="utf-8")
+    assert (
+        _projects_from_file(
+            PygitwebLayout(
+                projectroot=layout.projectroot,
+                projects_list=projects_file,
+                project_maxdepth=1,
+                list_all=True,
+                export_ok="",
+            ),
+        )
+        == []
+    )
 
 
 def test_find_projects_respects_maxdepth(tmp_path: Path) -> None:
-	layout = _layout(tmp_path, project_maxdepth=1)
-	deep = layout.projectroot / "a" / "b"
-	deep.mkdir(parents=True)
-	init_repository(str(deep), bare=False)
-	assert _find_projects_in_dir(layout) == []
+    layout = _layout(tmp_path, project_maxdepth=1)
+    deep = layout.projectroot / "a" / "b"
+    deep.mkdir(parents=True)
+    init_repository(str(deep), bare=False)
+    assert _find_projects_in_dir(layout) == []
 
 
 def test_export_ok_requires_repository(tmp_path: Path) -> None:
-	path = tmp_path / "not-a-repo"
-	path.mkdir()
-	assert _export_ok(path, "") is False
+    path = tmp_path / "not-a-repo"
+    path.mkdir()
+    assert _export_ok(path, "") is False
 
 
 def test_projects_from_file_read_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	layout = _layout(tmp_path)
-	projects_file = tmp_path / "projects.txt"
-	projects_file.write_text("alpha\n", encoding="utf-8")
-
-	def fail_read_text(self: Path, encoding: str = "utf-8") -> str:
-		raise OSError("nope")
-
-	monkeypatch.setattr(Path, "read_text", fail_read_text)
-	assert (
-		_projects_from_file(
-			PygitwebLayout(
-				projectroot=layout.projectroot,
-				projects_list=projects_file,
-				project_maxdepth=1,
-				list_all=True,
-				export_ok="",
-			),
-		)
-		== []
-	)
+    layout = _layout(tmp_path)
+    projects_file = tmp_path / "projects.txt"
+    projects_file.write_text("alpha\n", encoding="utf-8")
+
+    def fail_read_text(self: Path, encoding: str = "utf-8") -> str:
+        raise OSError("nope")
+
+    monkeypatch.setattr(Path, "read_text", fail_read_text)
+    assert (
+        _projects_from_file(
+            PygitwebLayout(
+                projectroot=layout.projectroot,
+                projects_list=projects_file,
+                project_maxdepth=1,
+                list_all=True,
+                export_ok="",
+            ),
+        )
+        == []
+    )
 
 
 def test_find_projects_skips_inaccessible_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	layout = _layout(tmp_path)
-	project = layout.projectroot / "secret"
-	project.mkdir()
-	_init_repo_with_commit(project)
-	monkeypatch.setattr("pygittools.tui.projects.os.access", lambda _path, _mode: False)
-	assert _find_projects_in_dir(layout) == []
+    layout = _layout(tmp_path)
+    project = layout.projectroot / "secret"
+    project.mkdir()
+    _init_repo_with_commit(project)
+    monkeypatch.setattr("pygittools.tui.projects.os.access", lambda _path, _mode: False)
+    assert _find_projects_in_dir(layout) == []
 
 
 def test_branch_for_invalid_worktree(tmp_path: Path) -> None:
-	bad = tmp_path / "bad"
-	bad.mkdir()
-	assert _branch_for_worktree(bad) == "?"
+    bad = tmp_path / "bad"
+    bad.mkdir()
+    assert _branch_for_worktree(bad) == "?"
diff --git a/pygittools/src/pygittools/tui/pygitweb_layout.py b/pygittools/src/pygittools/tui/pygitweb_layout.py
index 853456f..8f50868 100644
--- a/pygittools/src/pygittools/tui/pygitweb_layout.py
+++ b/pygittools/src/pygittools/tui/pygitweb_layout.py
@@ -13,137 +13,137 @@ _ENV_PREFIX = "PYGITWEB_"
 
 @dataclass(frozen=True, slots=True)
 class PygitwebLayout:
-	projectroot: Path
-	projects_list: Path
-	project_maxdepth: int
-	list_all: bool
-	export_ok: str
+    projectroot: Path
+    projects_list: Path
+    project_maxdepth: int
+    list_all: bool
+    export_ok: str
 
 
 def load_pygitweb_layout() -> PygitwebLayout:
-	"""Load project directory settings from ~/.pygitweb/settings.json and PYGITWEB_* env vars."""
-	file_values = _load_settings_file(_settings_file_path())
-	layout = _layout_from_mapping(file_values)
-	return _apply_env_overrides(layout)
+    """Load project directory settings from ~/.pygitweb/settings.json and PYGITWEB_* env vars."""
+    file_values = _load_settings_file(_settings_file_path())
+    layout = _layout_from_mapping(file_values)
+    return _apply_env_overrides(layout)
 
 
 def _settings_file_path() -> Path:
-	explicit = os.environ.get(f"{_ENV_PREFIX}SETTINGS_CONFIG", "").strip()
-	if explicit:
-		return Path(explicit).expanduser()
-	return _DEFAULT_SETTINGS_PATH
+    explicit = os.environ.get(f"{_ENV_PREFIX}SETTINGS_CONFIG", "").strip()
+    if explicit:
+        return Path(explicit).expanduser()
+    return _DEFAULT_SETTINGS_PATH
 
 
 def _load_settings_file(path: Path) -> dict[str, object]:
-	if not path.is_file():
-		return {}
-	try:
-		data = json.loads(path.read_text(encoding="utf-8"))
-	except (OSError, json.JSONDecodeError):
-		return {}
-	return data if isinstance(data, dict) else {}
+    if not path.is_file():
+        return {}
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError):
+        return {}
+    return data if isinstance(data, dict) else {}
 
 
 def _layout_from_mapping(values: dict[str, object]) -> PygitwebLayout:
-	projectroot = _path_value(values.get("PROJECTROOT"), Path.home())
-	projects_list_raw = values.get("PROJECTS_LIST")
-	projects_list = projectroot if projects_list_raw in (None, "") else projectroot
-	return PygitwebLayout(
-		projectroot=projectroot,
-		projects_list=projects_list if projects_list_raw is None else _path_value(projects_list_raw, projectroot),
-		project_maxdepth=_int_value(values.get("PROJECT_MAXDEPTH"), 1),
-		list_all=_bool_value(values.get("LIST_ALL"), True),
-		export_ok=_str_value(values.get("EXPORT_OK"), ""),
-	)
+    projectroot = _path_value(values.get("PROJECTROOT"), Path.home())
+    projects_list_raw = values.get("PROJECTS_LIST")
+    projects_list = projectroot if projects_list_raw in (None, "") else projectroot
+    return PygitwebLayout(
+        projectroot=projectroot,
+        projects_list=projects_list if projects_list_raw is None else _path_value(projects_list_raw, projectroot),
+        project_maxdepth=_int_value(values.get("PROJECT_MAXDEPTH"), 1),
+        list_all=_bool_value(values.get("LIST_ALL"), True),
+        export_ok=_str_value(values.get("EXPORT_OK"), ""),
+    )
 
 
 def _apply_env_overrides(layout: PygitwebLayout) -> PygitwebLayout:
-	projectroot = _env_path(f"{_ENV_PREFIX}PROJECTROOT", layout.projectroot)
-	projects_list = layout.projects_list
-	if os.environ.get(f"{_ENV_PREFIX}PROJECTS_LIST", "").strip():
-		projects_list = _env_path(f"{_ENV_PREFIX}PROJECTS_LIST", layout.projects_list)
-	elif not layout.projects_list:
-		projects_list = projectroot
-	return PygitwebLayout(
-		projectroot=projectroot,
-		projects_list=projects_list,
-		project_maxdepth=_env_int(f"{_ENV_PREFIX}PROJECT_MAXDEPTH", layout.project_maxdepth),
-		list_all=_env_bool(f"{_ENV_PREFIX}LIST_ALL", layout.list_all),
-		export_ok=_env_str(f"{_ENV_PREFIX}EXPORT_OK", layout.export_ok),
-	)
+    projectroot = _env_path(f"{_ENV_PREFIX}PROJECTROOT", layout.projectroot)
+    projects_list = layout.projects_list
+    if os.environ.get(f"{_ENV_PREFIX}PROJECTS_LIST", "").strip():
+        projects_list = _env_path(f"{_ENV_PREFIX}PROJECTS_LIST", layout.projects_list)
+    elif not layout.projects_list:
+        projects_list = projectroot
+    return PygitwebLayout(
+        projectroot=projectroot,
+        projects_list=projects_list,
+        project_maxdepth=_env_int(f"{_ENV_PREFIX}PROJECT_MAXDEPTH", layout.project_maxdepth),
+        list_all=_env_bool(f"{_ENV_PREFIX}LIST_ALL", layout.list_all),
+        export_ok=_env_str(f"{_ENV_PREFIX}EXPORT_OK", layout.export_ok),
+    )
 
 
 def _path_value(value: object, default: Path) -> Path:
-	if value is None or value == "":
-		return default
-	return Path(str(value)).expanduser()
+    if value is None or value == "":
+        return default
+    return Path(str(value)).expanduser()
 
 
 def _str_value(value: object, default: str) -> str:
-	if value is None:
-		return default
-	return str(value)
+    if value is None:
+        return default
+    return str(value)
 
 
 def _int_value(value: object, default: int) -> int:
-	if value is None:
-		return default
-	if isinstance(value, bool):
-		return int(value)
-	if isinstance(value, int):
-		return value
-	if isinstance(value, str):
-		try:
-			return int(value.strip())
-		except ValueError:
-			return default
-	if isinstance(value, float):
-		return int(value)
-	return default
+    if value is None:
+        return default
+    if isinstance(value, bool):
+        return int(value)
+    if isinstance(value, int):
+        return value
+    if isinstance(value, str):
+        try:
+            return int(value.strip())
+        except ValueError:
+            return default
+    if isinstance(value, float):
+        return int(value)
+    return default
 
 
 def _bool_value(value: object, default: bool) -> bool:
-	if value is None:
-		return default
-	if isinstance(value, bool):
-		return value
-	if isinstance(value, int):
-		return value != 0
-	if isinstance(value, str):
-		normalized = value.strip().lower()
-		if normalized in ("false", "0", "no", "off"):
-			return False
-		if normalized in ("true", "1", "yes", "on"):
-			return True
-	return default
+    if value is None:
+        return default
+    if isinstance(value, bool):
+        return value
+    if isinstance(value, int):
+        return value != 0
+    if isinstance(value, str):
+        normalized = value.strip().lower()
+        if normalized in ("false", "0", "no", "off"):
+            return False
+        if normalized in ("true", "1", "yes", "on"):
+            return True
+    return default
 
 
 def _env_str(name: str, default: str) -> str:
-	raw = os.environ.get(name)
-	if raw is None or raw.strip() == "":
-		return default
-	return raw
+    raw = os.environ.get(name)
+    if raw is None or raw.strip() == "":
+        return default
+    return raw
 
 
 def _env_int(name: str, default: int) -> int:
-	raw = os.environ.get(name)
-	if raw is None or raw.strip() == "":
-		return default
-	try:
-		return int(raw.strip())
-	except ValueError:
-		return default
+    raw = os.environ.get(name)
+    if raw is None or raw.strip() == "":
+        return default
+    try:
+        return int(raw.strip())
+    except ValueError:
+        return default
 
 
 def _env_bool(name: str, default: bool) -> bool:
-	raw = os.environ.get(name)
-	if raw is None or raw.strip() == "":
-		return default
-	return _bool_value(raw, default)
+    raw = os.environ.get(name)
+    if raw is None or raw.strip() == "":
+        return default
+    return _bool_value(raw, default)
 
 
 def _env_path(name: str, default: Path) -> Path:
-	raw = os.environ.get(name)
-	if raw is None or raw.strip() == "":
-		return default
-	return Path(raw.strip()).expanduser()
+    raw = os.environ.get(name)
+    if raw is None or raw.strip() == "":
+        return default
+    return Path(raw.strip()).expanduser()
diff --git a/pygittools/src/pygittools/tui/pygitweb_layout_test.py b/pygittools/src/pygittools/tui/pygitweb_layout_test.py
index 3cfc227..81de9ff 100644
--- a/pygittools/src/pygittools/tui/pygitweb_layout_test.py
+++ b/pygittools/src/pygittools/tui/pygitweb_layout_test.py
@@ -6,126 +6,126 @@ from pathlib import Path
 import pytest
 
 from pygittools.tui.pygitweb_layout import (
-	PygitwebLayout,
-	_apply_env_overrides,
-	_bool_value,
-	_env_bool,
-	_env_int,
-	_env_path,
-	_env_str,
-	_int_value,
-	_layout_from_mapping,
-	_load_settings_file,
-	_path_value,
-	_settings_file_path,
-	_str_value,
-	load_pygitweb_layout,
+    PygitwebLayout,
+    _apply_env_overrides,
+    _bool_value,
+    _env_bool,
+    _env_int,
+    _env_path,
+    _env_str,
+    _int_value,
+    _layout_from_mapping,
+    _load_settings_file,
+    _path_value,
+    _settings_file_path,
+    _str_value,
+    load_pygitweb_layout,
 )
 
 
 def test_load_pygitweb_layout_from_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	monkeypatch.delenv("PYGITWEB_PROJECTROOT", raising=False)
-	monkeypatch.delenv("PYGITTOOLS_REPO", raising=False)
-	settings = tmp_path / "settings.json"
-	settings.write_text(
-		json.dumps(
-			{
-				"PROJECTROOT": str(tmp_path / "root"),
-				"PROJECTS_LIST": str(tmp_path / "list"),
-				"PROJECT_MAXDEPTH": 2,
-				"LIST_ALL": False,
-				"EXPORT_OK": "git-daemon-export-ok",
-			},
-		),
-		encoding="utf-8",
-	)
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
-	layout = load_pygitweb_layout()
-	assert layout.projectroot == (tmp_path / "root").resolve()
-	assert layout.projects_list == (tmp_path / "list").resolve()
-	assert layout.project_maxdepth == 2
-	assert layout.list_all is False
-	assert layout.export_ok == "git-daemon-export-ok"
+    monkeypatch.delenv("PYGITWEB_PROJECTROOT", raising=False)
+    monkeypatch.delenv("PYGITTOOLS_REPO", raising=False)
+    settings = tmp_path / "settings.json"
+    settings.write_text(
+        json.dumps(
+            {
+                "PROJECTROOT": str(tmp_path / "root"),
+                "PROJECTS_LIST": str(tmp_path / "list"),
+                "PROJECT_MAXDEPTH": 2,
+                "LIST_ALL": False,
+                "EXPORT_OK": "git-daemon-export-ok",
+            },
+        ),
+        encoding="utf-8",
+    )
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+    layout = load_pygitweb_layout()
+    assert layout.projectroot == (tmp_path / "root").resolve()
+    assert layout.projects_list == (tmp_path / "list").resolve()
+    assert layout.project_maxdepth == 2
+    assert layout.list_all is False
+    assert layout.export_ok == "git-daemon-export-ok"
 
 
 def test_load_pygitweb_layout_env_overrides(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
-	settings = tmp_path / "settings.json"
-	settings.write_text("{}", encoding="utf-8")
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
-	monkeypatch.setenv("PYGITWEB_PROJECTROOT", str(tmp_path / "envroot"))
-	monkeypatch.setenv("PYGITWEB_PROJECTS_LIST", str(tmp_path / "envlist"))
-	monkeypatch.setenv("PYGITWEB_PROJECT_MAXDEPTH", "3")
-	monkeypatch.setenv("PYGITWEB_LIST_ALL", "false")
-	monkeypatch.setenv("PYGITWEB_EXPORT_OK", "ok")
-	layout = load_pygitweb_layout()
-	assert layout.projectroot == (tmp_path / "envroot").resolve()
-	assert layout.projects_list == (tmp_path / "envlist").resolve()
-	assert layout.project_maxdepth == 3
-	assert layout.list_all is False
-	assert layout.export_ok == "ok"
+    settings = tmp_path / "settings.json"
+    settings.write_text("{}", encoding="utf-8")
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+    monkeypatch.setenv("PYGITWEB_PROJECTROOT", str(tmp_path / "envroot"))
+    monkeypatch.setenv("PYGITWEB_PROJECTS_LIST", str(tmp_path / "envlist"))
+    monkeypatch.setenv("PYGITWEB_PROJECT_MAXDEPTH", "3")
+    monkeypatch.setenv("PYGITWEB_LIST_ALL", "false")
+    monkeypatch.setenv("PYGITWEB_EXPORT_OK", "ok")
+    layout = load_pygitweb_layout()
+    assert layout.projectroot == (tmp_path / "envroot").resolve()
+    assert layout.projects_list == (tmp_path / "envlist").resolve()
+    assert layout.project_maxdepth == 3
+    assert layout.list_all is False
+    assert layout.export_ok == "ok"
 
 
 def test_settings_file_path_uses_explicit_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(tmp_path / "custom.json"))
-	assert _settings_file_path() == (tmp_path / "custom.json")
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(tmp_path / "custom.json"))
+    assert _settings_file_path() == (tmp_path / "custom.json")
 
 
 def test_load_settings_file_missing_returns_empty(tmp_path: Path) -> None:
-	assert _load_settings_file(tmp_path / "missing.json") == {}
+    assert _load_settings_file(tmp_path / "missing.json") == {}
 
 
 def test_load_settings_file_invalid_json(tmp_path: Path) -> None:
-	path = tmp_path / "bad.json"
-	path.write_text("{not json", encoding="utf-8")
-	assert _load_settings_file(path) == {}
+    path = tmp_path / "bad.json"
+    path.write_text("{not json", encoding="utf-8")
+    assert _load_settings_file(path) == {}
 
 
 def test_load_settings_file_non_dict(tmp_path: Path) -> None:
-	path = tmp_path / "list.json"
-	path.write_text("[1, 2]", encoding="utf-8")
-	assert _load_settings_file(path) == {}
+    path = tmp_path / "list.json"
+    path.write_text("[1, 2]", encoding="utf-8")
+    assert _load_settings_file(path) == {}
 
 
 def test_layout_from_mapping_defaults() -> None:
-	layout = _layout_from_mapping({})
-	assert layout.list_all is True
-	assert layout.project_maxdepth == 1
+    layout = _layout_from_mapping({})
+    assert layout.list_all is True
+    assert layout.project_maxdepth == 1
 
 
 def test_apply_env_overrides_empty_projects_list_uses_projectroot() -> None:
-	base = PygitwebLayout(
-		projectroot=Path("/root"),
-		projects_list=Path("/root"),
-		project_maxdepth=1,
-		list_all=True,
-		export_ok="",
-	)
-	layout = _apply_env_overrides(base)
-	assert layout.projects_list == Path("/root")
+    base = PygitwebLayout(
+        projectroot=Path("/root"),
+        projects_list=Path("/root"),
+        project_maxdepth=1,
+        list_all=True,
+        export_ok="",
+    )
+    layout = _apply_env_overrides(base)
+    assert layout.projects_list == Path("/root")
 
 
 def test_coercion_helpers() -> None:
-	assert _path_value(None, Path("/d")) == Path("/d")
-	assert _str_value(None, "x") == "x"
-	assert _int_value(True, 0) == 1
-	assert _int_value("5", 0) == 5
-	assert _int_value("bad", 0) == 0
-	assert _int_value(2.9, 0) == 2
-	assert _bool_value("off", True) is False
-	assert _bool_value("yes", False) is True
-	assert _bool_value("maybe", True) is True
-	assert _env_str("MISSING", "d") == "d"
-	assert _env_int("MISSING", 7) == 7
-	assert _env_int("PYGITWEB_TEST_INT", 7) == 7 or True
-	assert _env_bool("MISSING", True) is True
-	assert _env_path("MISSING", Path("/d")) == Path("/d")
+    assert _path_value(None, Path("/d")) == Path("/d")
+    assert _str_value(None, "x") == "x"
+    assert _int_value(True, 0) == 1
+    assert _int_value("5", 0) == 5
+    assert _int_value("bad", 0) == 0
+    assert _int_value(2.9, 0) == 2
+    assert _bool_value("off", True) is False
+    assert _bool_value("yes", False) is True
+    assert _bool_value("maybe", True) is True
+    assert _env_str("MISSING", "d") == "d"
+    assert _env_int("MISSING", 7) == 7
+    assert _env_int("PYGITWEB_TEST_INT", 7) == 7 or True
+    assert _env_bool("MISSING", True) is True
+    assert _env_path("MISSING", Path("/d")) == Path("/d")
 
 
 def test_env_int_invalid_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
-	monkeypatch.setenv("PYGITWEB_TEST_INT_BAD", "nope")
-	assert _env_int("PYGITWEB_TEST_INT_BAD", 4) == 4
+    monkeypatch.setenv("PYGITWEB_TEST_INT_BAD", "nope")
+    assert _env_int("PYGITWEB_TEST_INT_BAD", 4) == 4
 
 
 def test_bool_value_from_int() -> None:
-	assert _bool_value(0, True) is False
-	assert _bool_value(2, False) is True
+    assert _bool_value(0, True) is False
+    assert _bool_value(2, False) is True
diff --git a/pygittools/src/pygittools/tui/repo.py b/pygittools/src/pygittools/tui/repo.py
index a2f91a6..4ec816b 100644
--- a/pygittools/src/pygittools/tui/repo.py
+++ b/pygittools/src/pygittools/tui/repo.py
@@ -10,17 +10,17 @@ from pygittools.tui.types import PageContext
 
 
 def open_page_context(start: Path | None = None) -> PageContext:
-	"""Discover a git repository from ``start`` (or the current directory)."""
-	root = (start or Path.cwd()).resolve()
-	discovered = discover_repository(str(root))
-	if discovered is None:
-		raise GitError(f"No git repository found under {root}")
-	repo_path = Path(discovered)
-	return PageContext(repo=Repository(str(repo_path)), repo_path=repo_path)
+    """Discover a git repository from ``start`` (or the current directory)."""
+    root = (start or Path.cwd()).resolve()
+    discovered = discover_repository(str(root))
+    if discovered is None:
+        raise GitError(f"No git repository found under {root}")
+    repo_path = Path(discovered)
+    return PageContext(repo=Repository(str(repo_path)), repo_path=repo_path)
 
 
 def try_open_page_context(start: Path | None = None) -> PageContext | None:
-	try:
-		return open_page_context(start)
-	except GitError:
-		return None
+    try:
+        return open_page_context(start)
+    except GitError:
+        return None
diff --git a/pygittools/src/pygittools/tui/repo_test.py b/pygittools/src/pygittools/tui/repo_test.py
index 7125d68..7c0f037 100644
--- a/pygittools/src/pygittools/tui/repo_test.py
+++ b/pygittools/src/pygittools/tui/repo_test.py
@@ -9,18 +9,18 @@ from pygittools.tui.repo import open_page_context, try_open_page_context
 
 
 def test_open_page_context_discovers_repo(tmp_path: Path) -> None:
-	repo_dir = tmp_path / "project"
-	repo_dir.mkdir()
-	init_repository(str(repo_dir), bare=False)
-	ctx = open_page_context(repo_dir)
-	assert ctx.repo_path.name == ".git"
-	assert isinstance(ctx.repo, Repository)
+    repo_dir = tmp_path / "project"
+    repo_dir.mkdir()
+    init_repository(str(repo_dir), bare=False)
+    ctx = open_page_context(repo_dir)
+    assert ctx.repo_path.name == ".git"
+    assert isinstance(ctx.repo, Repository)
 
 
 def test_open_page_context_raises_without_repo(tmp_path: Path) -> None:
-	with pytest.raises(GitError, match="No git repository"):
-		open_page_context(tmp_path)
+    with pytest.raises(GitError, match="No git repository"):
+        open_page_context(tmp_path)
 
 
 def test_try_open_page_context_returns_none_without_repo(tmp_path: Path) -> None:
-	assert try_open_page_context(tmp_path) is None
+    assert try_open_page_context(tmp_path) is None
diff --git a/pygittools/src/pygittools/tui/status_bar.py b/pygittools/src/pygittools/tui/status_bar.py
index 05de78c..51d0b11 100644
--- a/pygittools/src/pygittools/tui/status_bar.py
+++ b/pygittools/src/pygittools/tui/status_bar.py
@@ -8,32 +8,32 @@ _DEFAULT_HINT = "q quit"
 
 
 def split_content_status(area: Rect) -> tuple[Rect, Rect]:
-	"""Split the frame into content above a one-row status bar."""
-	chunks = Layout().direction(Direction.Vertical).constraints([Constraint.fill(1), Constraint.length(1)]).split(area)
-	return chunks[0], chunks[1]
+    """Split the frame into content above a one-row status bar."""
+    chunks = Layout().direction(Direction.Vertical).constraints([Constraint.fill(1), Constraint.length(1)]).split(area)
+    return chunks[0], chunks[1]
 
 
 def render_status_bar(
-	frame: Frame,
-	area: Rect,
-	text: str,
-	style: Style,
-	hint: str = _DEFAULT_HINT,
+    frame: Frame,
+    area: Rect,
+    text: str,
+    style: Style,
+    hint: str = _DEFAULT_HINT,
 ) -> None:
-	"""Draw the status bar into ``area``."""
-	if area.width <= 0 or area.height <= 0:
-		return
-	line = _format_line(text, hint, area.width)
-	frame.render_widget(Paragraph.from_string(line).style(style), area)
+    """Draw the status bar into ``area``."""
+    if area.width <= 0 or area.height <= 0:
+        return
+    line = _format_line(text, hint, area.width)
+    frame.render_widget(Paragraph.from_string(line).style(style), area)
 
 
 def _format_line(left: str, right: str, width: int) -> str:
-	if not right:
-		return left[:width].ljust(width)
-	if len(right) >= width:
-		return right[:width].ljust(width)
-	space = width - len(right)
-	if space <= 1:
-		return right[:width].ljust(width)
-	clipped_left = left[: space - 1]
-	return f"{clipped_left:<{space - 1}} {right}"[:width].ljust(width)
+    if not right:
+        return left[:width].ljust(width)
+    if len(right) >= width:
+        return right[:width].ljust(width)
+    space = width - len(right)
+    if space <= 1:
+        return right[:width].ljust(width)
+    clipped_left = left[: space - 1]
+    return f"{clipped_left:<{space - 1}} {right}"[:width].ljust(width)
diff --git a/pygittools/src/pygittools/tui/status_bar_test.py b/pygittools/src/pygittools/tui/status_bar_test.py
index 7ca8b34..891d3a3 100644
--- a/pygittools/src/pygittools/tui/status_bar_test.py
+++ b/pygittools/src/pygittools/tui/status_bar_test.py
@@ -9,37 +9,37 @@ from pygittools.tui.status_bar import _format_line, render_status_bar, split_con
 
 
 def test_split_content_status_reserves_row() -> None:
-	content, status = split_content_status(Rect(0, 0, 80, 10))
-	assert content.height == 9
-	assert status.height == 1
+    content, status = split_content_status(Rect(0, 0, 80, 10))
+    assert content.height == 9
+    assert status.height == 1
 
 
 def test_format_line_with_hint() -> None:
-	line = _format_line("left text", "q quit", 40)
-	assert line.endswith("q quit")
-	assert "left" in line
+    line = _format_line("left text", "q quit", 40)
+    assert line.endswith("q quit")
+    assert "left" in line
 
 
 def test_format_line_without_hint() -> None:
-	assert _format_line("hello", "", 10) == "hello     "
+    assert _format_line("hello", "", 10) == "hello     "
 
 
 def test_format_line_hint_wider_than_width() -> None:
-	line = _format_line("ignored", "longhint", 4)
-	assert line.strip() == "long"
+    line = _format_line("ignored", "longhint", 4)
+    assert line.strip() == "long"
 
 
 def test_format_line_tight_width() -> None:
-	line = _format_line("left", "hint", 4)
-	assert len(line) == 4
+    line = _format_line("left", "hint", 4)
+    assert len(line) == 4
 
 
 def test_render_status_bar_skips_zero_width() -> None:
-	frame = MagicMock()
-	render_status_bar(frame, Rect(0, 0, 0, 1), "x", Style())
-	frame.render_widget.assert_not_called()
+    frame = MagicMock()
+    render_status_bar(frame, Rect(0, 0, 0, 1), "x", Style())
+    frame.render_widget.assert_not_called()
 
 
 def test_status_bar_style_after_init() -> None:
-	init_tui_color_theme(None)
-	assert status_bar_style() is not None
+    init_tui_color_theme(None)
+    assert status_bar_style() is not None
diff --git a/pygittools/src/pygittools/tui/tabs.py b/pygittools/src/pygittools/tui/tabs.py
index c6e9f20..b0301ae 100644
--- a/pygittools/src/pygittools/tui/tabs.py
+++ b/pygittools/src/pygittools/tui/tabs.py
@@ -8,37 +8,37 @@ from pygittools.tui.git_colors import active_tab_style, current_theme, focus_sty
 
 
 def init_tab_colors() -> None:
-	"""Refresh cached tab attributes from the active Git color theme."""
-	return None
+    """Refresh cached tab attributes from the active Git color theme."""
+    return None
 
 
 def render_tab_row(
-	frame: Frame,
-	area: Rect,
-	prefix: str,
-	tab_labels: tuple[str, ...],
-	active_index: int,
-	*,
-	prefix_focused: bool = False,
+    frame: Frame,
+    area: Rect,
+    prefix: str,
+    tab_labels: tuple[str, ...],
+    active_index: int,
+    *,
+    prefix_focused: bool = False,
 ) -> None:
-	"""Draw project prefix and selectable tabs into ``area``."""
-	if area.height <= 0 or area.width <= 0:
-		return
-	theme = current_theme()
-	prefix_style = focus_style(theme.branch) if prefix_focused else theme.branch
-	prefix_width = min(area.width, len(prefix) + 2)
-	chunks = (
-		Layout()
-		.direction(Direction.Horizontal)
-		.constraints([Constraint.length(prefix_width), Constraint.fill(1)])
-		.split(area)
-	)
-	frame.render_widget(Paragraph.from_string(prefix).style(prefix_style), chunks[0])
-	frame.render_widget(
-		Tabs(list(tab_labels))
-		.select(active_index)
-		.highlight_style(active_tab_style())
-		.style(theme.tab_inactive)
-		.divider("  "),
-		chunks[1],
-	)
+    """Draw project prefix and selectable tabs into ``area``."""
+    if area.height <= 0 or area.width <= 0:
+        return
+    theme = current_theme()
+    prefix_style = focus_style(theme.branch) if prefix_focused else theme.branch
+    prefix_width = min(area.width, len(prefix) + 2)
+    chunks = (
+        Layout()
+        .direction(Direction.Horizontal)
+        .constraints([Constraint.length(prefix_width), Constraint.fill(1)])
+        .split(area)
+    )
+    frame.render_widget(Paragraph.from_string(prefix).style(prefix_style), chunks[0])
+    frame.render_widget(
+        Tabs(list(tab_labels))
+        .select(active_index)
+        .highlight_style(active_tab_style())
+        .style(theme.tab_inactive)
+        .divider("  "),
+        chunks[1],
+    )
diff --git a/pygittools/src/pygittools/tui/tabs_test.py b/pygittools/src/pygittools/tui/tabs_test.py
index f459b3c..9943877 100644
--- a/pygittools/src/pygittools/tui/tabs_test.py
+++ b/pygittools/src/pygittools/tui/tabs_test.py
@@ -11,18 +11,18 @@ from pygittools.tui.tabs import init_tab_colors, render_tab_row
 
 
 def test_init_tab_colors_is_noop() -> None:
-	init_tui_color_theme(None)
-	init_tab_colors()
+    init_tui_color_theme(None)
+    init_tab_colors()
 
 
 def test_render_tab_row() -> None:
-	init_tui_color_theme(None)
-	frame = MagicMock()
-	render_tab_row(frame, Rect(0, 0, 80, 1), "demo", ("Status", "Log", "Tasks", "Worktrees"), 1)
-	assert frame.render_widget.call_count >= 2
+    init_tui_color_theme(None)
+    frame = MagicMock()
+    render_tab_row(frame, Rect(0, 0, 80, 1), "demo", ("Status", "Log", "Tasks", "Worktrees"), 1)
+    assert frame.render_widget.call_count >= 2
 
 
 def test_render_tab_row_zero_area() -> None:
-	frame = MagicMock()
-	render_tab_row(frame, Rect(0, 0, 0, 0), "demo", ("Status",), 0)
-	frame.render_widget.assert_not_called()
+    frame = MagicMock()
+    render_tab_row(frame, Rect(0, 0, 0, 0), "demo", ("Status",), 0)
+    frame.render_widget.assert_not_called()
diff --git a/pygittools/src/pygittools/tui/tasks_list.py b/pygittools/src/pygittools/tui/tasks_list.py
index 1ba9710..8867505 100644
--- a/pygittools/src/pygittools/tui/tasks_list.py
+++ b/pygittools/src/pygittools/tui/tasks_list.py
@@ -10,269 +10,269 @@ from pyratatui import Frame, List, ListItem, ListState, Rect, Style
 
 from pygittools.tags_sync import format_sync_result, sync_board_tags
 from pygittools.tasks_query import (
-	BOARD_STATUS_ORDER,
-	BoardColumnView,
-	BoardView,
-	get_board_tasks_grouped,
-	list_boards,
+    BOARD_STATUS_ORDER,
+    BoardColumnView,
+    BoardView,
+    get_board_tasks_grouped,
+    list_boards,
 )
 from pygittools.tui.git_colors import current_theme, focus_style
 from pygittools.tui.keys import KeyLike, board_hint, is_down, is_enter, is_left, is_right, is_space, is_up, move_hint
 from pygittools.tui.types import PageAction, PageResult
 
 if TYPE_CHECKING:
-	from pygittools.tui.pages.task_editor import TaskEditorPage
+    from pygittools.tui.pages.task_editor import TaskEditorPage
 
 
 @dataclass(frozen=True, slots=True)
 class _Row:
-	kind: Literal["board", "status_header", "new_task", "task"]
-	status: str | None
-	task_ref: str | None
-	label: str
+    kind: Literal["board", "status_header", "new_task", "task"]
+    status: str | None
+    task_ref: str | None
+    label: str
 
 
 class TasksBoardList:
-	def __init__(self) -> None:
-		self._boards: list[BoardView] = []
-		self._board_index = 0
-		self._board_name: str | None = None
-		self._columns: list[BoardColumnView] = []
-		self._collapsed: dict[str, bool] = {status: False for status in BOARD_STATUS_ORDER}
-		self._rows: list[_Row] = []
-		self._list_state = ListState()
-		self._error: str | None = None
-
-	@property
-	def board_name(self) -> str | None:
-		return self._board_name
-
-	def is_at_top(self) -> bool:
-		return (self._list_state.selected or 0) == 0
-
-	def refresh(self, repo: Repository) -> None:
-		self._boards = list_boards(repo)
-		if not self._boards:
-			self._columns = []
-			self._rows = []
-			self._board_name = None
-			self._list_state.select(0)
-			return
-
-		if self._board_name is None:
-			self._board_index = _default_board_index(self._boards)
-		else:
-			self._board_index = _board_index_for_name(self._boards, self._board_name)
-		self._board_index = max(0, min(self._board_index, len(self._boards) - 1))
-		self._board_name = self._boards[self._board_index]["name"]
-
-		try:
-			self._columns = get_board_tasks_grouped(repo, self._board_name or "")
-		except KeyError as exc:
-			self._error = str(exc)
-			self._columns = []
-		self._rebuild_rows()
-
-	def _rebuild_rows(self) -> None:
-		if not self._boards:
-			self._rows = []
-			self._list_state.select(0)
-			return
-
-		board = self._boards[self._board_index]
-		rows: list[_Row] = [
-			_Row(
-				kind="board",
-				status=None,
-				task_ref=None,
-				label=f"Board: {board['name']} ({board['task_count']} tasks)",
-			),
-		]
-		for column in self._columns:
-			status = column["status"]
-			label = column["label"]
-			tasks = column["tasks"]
-			if status not in self._collapsed:
-				self._collapsed[status] = False
-			marker = ">" if self._collapsed[status] else "v"
-			header = f"[{marker}] {label} ({len(tasks)})"
-			rows.append(_Row(kind="status_header", status=status, task_ref=None, label=header))
-			if self._collapsed[status]:
-				continue
-			rows.append(
-				_Row(
-					kind="new_task",
-					status=status,
-					task_ref=None,
-					label="(+ New Task)",
-				),
-			)
-			for task in tasks:
-				title = task.get("title") or "(untitled)"
-				rows.append(
-					_Row(
-						kind="task",
-						status=status,
-						task_ref=task.get("ref"),
-						label=f"  {title}",
-					),
-				)
-
-		self._rows = rows
-		selected = self._list_state.selected or 0
-		self._list_state.select(min(selected, max(0, len(self._rows) - 1)))
-
-	def draw(self, frame: Frame, area: Rect, *, highlight: bool) -> None:
-		if area.height <= 0 or area.width <= 0:
-			return
-		theme = current_theme()
-		if not self._rows and not self._error:
-			frame.render_stateful_list(
-				List([ListItem("No task boards — use pygittools tasks to create one", theme.log_date)]),
-				area,
-				self._list_state,
-			)
-			return
-
-		items: list[ListItem] = []
-		error_offset = 0
-		if self._error:
-			items.append(ListItem(self._error, theme.log_date))
-			error_offset = 1
-		selected = self._list_state.selected or 0
-		for index, row in enumerate(self._rows):
-			focused = highlight and index == selected
-			style = _row_style(row, focused=focused, highlight=highlight)
-			items.append(ListItem(row.label, style))
-		list_state = self._list_state
-		if error_offset:
-			list_state = ListState()
-			row_selected = self._list_state.selected
-			if row_selected is not None:
-				list_state.select(row_selected + error_offset)
-		frame.render_stateful_list(
-			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			area,
-			list_state,
-		)
-
-	def sync_with_default_remote(self, repo: Repository) -> None:
-		remote_names = [name for name in repo.remotes.names() if name]
-		if not remote_names:
-			self._error = "No remotes configured for task sync"
-			return
-		remote_name = "origin" if "origin" in remote_names else remote_names[0]
-		board_name = self._board_name or "Tasks"
-		try:
-			result = sync_board_tags(repo, remote_name, board_name)
-		except (KeyError, RuntimeError, ValueError) as exc:
-			self._error = str(exc)
-			return
-		self._error = format_sync_result(result)
-		self.refresh(repo)
-
-	def handle_nav_key(self, key: KeyLike, repo: Repository) -> PageResult | None:
-		if self._error is not None:
-			self._error = None
-		if not self._rows and not self._boards:
-			self.refresh(repo)
-			return None
-
-		if is_up(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(max(0, selected - 1))
-			return None
-		if is_down(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(min(max(0, len(self._rows) - 1), selected + 1))
-			return None
-		if not self._rows:
-			return None
-
-		row = self._rows[self._list_state.selected or 0]
-
-		if row.kind == "board" and self._boards:
-			if is_left(key) and self._board_index > 0:
-				self._board_index -= 1
-				self._board_name = self._boards[self._board_index]["name"]
-				self._list_state.select(0)
-				self.refresh(repo)
-				return None
-			if is_right(key) and self._board_index < len(self._boards) - 1:
-				self._board_index += 1
-				self._board_name = self._boards[self._board_index]["name"]
-				self._list_state.select(0)
-				self.refresh(repo)
-				return None
-			return None
-
-		if row.kind == "status_header" and row.status is not None:
-			if is_left(key) or is_right(key) or is_space(key) or is_enter(key):
-				self._collapsed[row.status] = not self._collapsed.get(row.status, False)
-				self._rebuild_rows()
-			return None
-
-		if is_enter(key):
-			if row.kind == "new_task" and row.status is not None and self._board_name is not None:
-				return PageResult(
-					action=PageAction.PUSH,
-					next_page=_task_editor_page(self._board_name, None, default_status=row.status),
-				)
-			if row.kind == "task" and row.task_ref is not None and self._board_name is not None:
-				return PageResult(
-					action=PageAction.PUSH,
-					next_page=_task_editor_page(self._board_name, row.task_ref, default_status=row.status),
-				)
-		return None
-
-	def status_hint(self) -> str:
-		if self._error:
-			return self._error
-		if not self._boards:
-			return "No task boards — use pygittools tasks to create one"
-		if not self._rows:
-			return "No tasks — use (+ New Task) to create one"
-		move = move_hint()
-		row = self._rows[self._list_state.selected or 0]
-		if row.kind == "board":
-			return f"{board_hint()} — {move}"
-		if row.kind == "status_header":
-			return f"Enter collapse — Space collapse — {move}"
-		if row.kind == "new_task":
-			return f"Enter create task — {move}"
-		return f"Enter edit task — {move}"
+    def __init__(self) -> None:
+        self._boards: list[BoardView] = []
+        self._board_index = 0
+        self._board_name: str | None = None
+        self._columns: list[BoardColumnView] = []
+        self._collapsed: dict[str, bool] = {status: False for status in BOARD_STATUS_ORDER}
+        self._rows: list[_Row] = []
+        self._list_state = ListState()
+        self._error: str | None = None
+
+    @property
+    def board_name(self) -> str | None:
+        return self._board_name
+
+    def is_at_top(self) -> bool:
+        return (self._list_state.selected or 0) == 0
+
+    def refresh(self, repo: Repository) -> None:
+        self._boards = list_boards(repo)
+        if not self._boards:
+            self._columns = []
+            self._rows = []
+            self._board_name = None
+            self._list_state.select(0)
+            return
+
+        if self._board_name is None:
+            self._board_index = _default_board_index(self._boards)
+        else:
+            self._board_index = _board_index_for_name(self._boards, self._board_name)
+        self._board_index = max(0, min(self._board_index, len(self._boards) - 1))
+        self._board_name = self._boards[self._board_index]["name"]
+
+        try:
+            self._columns = get_board_tasks_grouped(repo, self._board_name or "")
+        except KeyError as exc:
+            self._error = str(exc)
+            self._columns = []
+        self._rebuild_rows()
+
+    def _rebuild_rows(self) -> None:
+        if not self._boards:
+            self._rows = []
+            self._list_state.select(0)
+            return
+
+        board = self._boards[self._board_index]
+        rows: list[_Row] = [
+            _Row(
+                kind="board",
+                status=None,
+                task_ref=None,
+                label=f"Board: {board['name']} ({board['task_count']} tasks)",
+            ),
+        ]
+        for column in self._columns:
+            status = column["status"]
+            label = column["label"]
+            tasks = column["tasks"]
+            if status not in self._collapsed:
+                self._collapsed[status] = False
+            marker = ">" if self._collapsed[status] else "v"
+            header = f"[{marker}] {label} ({len(tasks)})"
+            rows.append(_Row(kind="status_header", status=status, task_ref=None, label=header))
+            if self._collapsed[status]:
+                continue
+            rows.append(
+                _Row(
+                    kind="new_task",
+                    status=status,
+                    task_ref=None,
+                    label="(+ New Task)",
+                ),
+            )
+            for task in tasks:
+                title = task.get("title") or "(untitled)"
+                rows.append(
+                    _Row(
+                        kind="task",
+                        status=status,
+                        task_ref=task.get("ref"),
+                        label=f"  {title}",
+                    ),
+                )
+
+        self._rows = rows
+        selected = self._list_state.selected or 0
+        self._list_state.select(min(selected, max(0, len(self._rows) - 1)))
+
+    def draw(self, frame: Frame, area: Rect, *, highlight: bool) -> None:
+        if area.height <= 0 or area.width <= 0:
+            return
+        theme = current_theme()
+        if not self._rows and not self._error:
+            frame.render_stateful_list(
+                List([ListItem("No task boards — use pygittools tasks to create one", theme.log_date)]),
+                area,
+                self._list_state,
+            )
+            return
+
+        items: list[ListItem] = []
+        error_offset = 0
+        if self._error:
+            items.append(ListItem(self._error, theme.log_date))
+            error_offset = 1
+        selected = self._list_state.selected or 0
+        for index, row in enumerate(self._rows):
+            focused = highlight and index == selected
+            style = _row_style(row, focused=focused, highlight=highlight)
+            items.append(ListItem(row.label, style))
+        list_state = self._list_state
+        if error_offset:
+            list_state = ListState()
+            row_selected = self._list_state.selected
+            if row_selected is not None:
+                list_state.select(row_selected + error_offset)
+        frame.render_stateful_list(
+            List(items).highlight_style(theme.focus).highlight_symbol(""),
+            area,
+            list_state,
+        )
+
+    def sync_with_default_remote(self, repo: Repository) -> None:
+        remote_names = [name for name in repo.remotes.names() if name]
+        if not remote_names:
+            self._error = "No remotes configured for task sync"
+            return
+        remote_name = "origin" if "origin" in remote_names else remote_names[0]
+        board_name = self._board_name or "Tasks"
+        try:
+            result = sync_board_tags(repo, remote_name, board_name)
+        except (KeyError, RuntimeError, ValueError) as exc:
+            self._error = str(exc)
+            return
+        self._error = format_sync_result(result)
+        self.refresh(repo)
+
+    def handle_nav_key(self, key: KeyLike, repo: Repository) -> PageResult | None:
+        if self._error is not None:
+            self._error = None
+        if not self._rows and not self._boards:
+            self.refresh(repo)
+            return None
+
+        if is_up(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(max(0, selected - 1))
+            return None
+        if is_down(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(min(max(0, len(self._rows) - 1), selected + 1))
+            return None
+        if not self._rows:
+            return None
+
+        row = self._rows[self._list_state.selected or 0]
+
+        if row.kind == "board" and self._boards:
+            if is_left(key) and self._board_index > 0:
+                self._board_index -= 1
+                self._board_name = self._boards[self._board_index]["name"]
+                self._list_state.select(0)
+                self.refresh(repo)
+                return None
+            if is_right(key) and self._board_index < len(self._boards) - 1:
+                self._board_index += 1
+                self._board_name = self._boards[self._board_index]["name"]
+                self._list_state.select(0)
+                self.refresh(repo)
+                return None
+            return None
+
+        if row.kind == "status_header" and row.status is not None:
+            if is_left(key) or is_right(key) or is_space(key) or is_enter(key):
+                self._collapsed[row.status] = not self._collapsed.get(row.status, False)
+                self._rebuild_rows()
+            return None
+
+        if is_enter(key):
+            if row.kind == "new_task" and row.status is not None and self._board_name is not None:
+                return PageResult(
+                    action=PageAction.PUSH,
+                    next_page=_task_editor_page(self._board_name, None, default_status=row.status),
+                )
+            if row.kind == "task" and row.task_ref is not None and self._board_name is not None:
+                return PageResult(
+                    action=PageAction.PUSH,
+                    next_page=_task_editor_page(self._board_name, row.task_ref, default_status=row.status),
+                )
+        return None
+
+    def status_hint(self) -> str:
+        if self._error:
+            return self._error
+        if not self._boards:
+            return "No task boards — use pygittools tasks to create one"
+        if not self._rows:
+            return "No tasks — use (+ New Task) to create one"
+        move = move_hint()
+        row = self._rows[self._list_state.selected or 0]
+        if row.kind == "board":
+            return f"{board_hint()} — {move}"
+        if row.kind == "status_header":
+            return f"Enter collapse — Space collapse — {move}"
+        if row.kind == "new_task":
+            return f"Enter create task — {move}"
+        return f"Enter edit task — {move}"
 
 
 def _default_board_index(boards: list[BoardView]) -> int:
-	for index, board in enumerate(boards):
-		if board["name"].casefold() == "tasks":
-			return index
-	return 0
+    for index, board in enumerate(boards):
+        if board["name"].casefold() == "tasks":
+            return index
+    return 0
 
 
 def _board_index_for_name(boards: list[BoardView], name: str | None) -> int:
-	if name is None:
-		return _default_board_index(boards)
-	for index, board in enumerate(boards):
-		if board["name"] == name:
-			return index
-	return _default_board_index(boards)
+    if name is None:
+        return _default_board_index(boards)
+    for index, board in enumerate(boards):
+        if board["name"] == name:
+            return index
+    return _default_board_index(boards)
 
 
 def _row_style(row: _Row, *, focused: bool, highlight: bool) -> Style:
-	theme = current_theme()
-	if row.kind == "board":
-		base = theme.header
-	elif row.kind == "status_header":
-		base = theme.section_header
-	else:
-		base = theme.normal
-	if focused and highlight:
-		return focus_style(base)
-	return base
+    theme = current_theme()
+    if row.kind == "board":
+        base = theme.header
+    elif row.kind == "status_header":
+        base = theme.section_header
+    else:
+        base = theme.normal
+    if focused and highlight:
+        return focus_style(base)
+    return base
 
 
 def _task_editor_page(board_name: str, task_ref: str | None, *, default_status: str | None) -> TaskEditorPage:
-	from pygittools.tui.pages.task_editor import TaskEditorPage
+    from pygittools.tui.pages.task_editor import TaskEditorPage
 
-	return TaskEditorPage(board_name=board_name, task_ref=task_ref, default_status=default_status)
+    return TaskEditorPage(board_name=board_name, task_ref=task_ref, default_status=default_status)
diff --git a/pygittools/src/pygittools/tui/tasks_list_test.py b/pygittools/src/pygittools/tui/tasks_list_test.py
index 0e5bf81..6c0bf75 100644
--- a/pygittools/src/pygittools/tui/tasks_list_test.py
+++ b/pygittools/src/pygittools/tui/tasks_list_test.py
@@ -10,41 +10,41 @@ from pygittools.tui.types import PageContext
 
 
 def test_draw_error_offsets_list_selection() -> None:
-	tasks = TasksBoardList()
-	tasks._rows = [
-		_Row(kind="board", status=None, task_ref=None, label="Board: Tasks (0 tasks)"),
-		_Row(kind="status_header", status="todo", task_ref=None, label="[v] Todo (0)"),
-	]
-	tasks._list_state.select(0)
-	tasks._error = "sync failed"
-	frame = MagicMock()
-	tasks.draw(frame, Rect(0, 0, 80, 20), highlight=True)
-	assert frame.render_stateful_list.called
-	list_state = frame.render_stateful_list.call_args.args[2]
-	assert list_state.selected == 1
-	assert (tasks._list_state.selected or 0) == 0
+    tasks = TasksBoardList()
+    tasks._rows = [
+        _Row(kind="board", status=None, task_ref=None, label="Board: Tasks (0 tasks)"),
+        _Row(kind="status_header", status="todo", task_ref=None, label="[v] Todo (0)"),
+    ]
+    tasks._list_state.select(0)
+    tasks._error = "sync failed"
+    frame = MagicMock()
+    tasks.draw(frame, Rect(0, 0, 80, 20), highlight=True)
+    assert frame.render_stateful_list.called
+    list_state = frame.render_stateful_list.call_args.args[2]
+    assert list_state.selected == 1
+    assert (tasks._list_state.selected or 0) == 0
 
 
 def test_collapse_rebuilds_without_git(page_ctx: PageContext) -> None:
-	tasks = TasksBoardList()
-	tasks._boards = [{"name": "Tasks", "description": "", "task_count": 1}]
-	tasks._board_index = 0
-	tasks._board_name = "Tasks"
-	tasks._columns = [
-		{
-			"status": "TODO",
-			"label": "Todo",
-			"tasks": [{"title": "one", "ref": "refs/tasks/one"}],
-		},
-	]
-	tasks._rebuild_rows()
-	header = next(index for index, row in enumerate(tasks._rows) if row.kind == "status_header")
-	tasks._list_state.select(header)
-	with (
-		patch("pygittools.tui.tasks_list.list_boards") as list_boards,
-		patch("pygittools.tui.tasks_list.get_board_tasks_grouped") as grouped,
-	):
-		tasks.handle_nav_key(key(" "), page_ctx.repo)
-	list_boards.assert_not_called()
-	grouped.assert_not_called()
-	assert all(row.kind != "task" for row in tasks._rows)
+    tasks = TasksBoardList()
+    tasks._boards = [{"name": "Tasks", "description": "", "task_count": 1}]
+    tasks._board_index = 0
+    tasks._board_name = "Tasks"
+    tasks._columns = [
+        {
+            "status": "TODO",
+            "label": "Todo",
+            "tasks": [{"title": "one", "ref": "refs/tasks/one"}],
+        },
+    ]
+    tasks._rebuild_rows()
+    header = next(index for index, row in enumerate(tasks._rows) if row.kind == "status_header")
+    tasks._list_state.select(header)
+    with (
+        patch("pygittools.tui.tasks_list.list_boards") as list_boards,
+        patch("pygittools.tui.tasks_list.get_board_tasks_grouped") as grouped,
+    ):
+        tasks.handle_nav_key(key(" "), page_ctx.repo)
+    list_boards.assert_not_called()
+    grouped.assert_not_called()
+    assert all(row.kind != "task" for row in tasks._rows)
diff --git a/pygittools/src/pygittools/tui/types.py b/pygittools/src/pygittools/tui/types.py
index e6aa7aa..4c69174 100644
--- a/pygittools/src/pygittools/tui/types.py
+++ b/pygittools/src/pygittools/tui/types.py
@@ -14,38 +14,38 @@ from pygittools.tui.keys import KeyLike
 
 
 class PageAction(Enum):
-	NONE = auto()
-	QUIT = auto()
-	REPLACE = auto()
-	PUSH = auto()
-	POP = auto()
+    NONE = auto()
+    QUIT = auto()
+    REPLACE = auto()
+    PUSH = auto()
+    POP = auto()
 
 
 @dataclass(frozen=True, slots=True)
 class PageResult:
-	action: PageAction = PageAction.NONE
-	next_page: Page | None = None
-	switch_repo: Path | None = None
-	repo_changed: bool = False
+    action: PageAction = PageAction.NONE
+    next_page: Page | None = None
+    switch_repo: Path | None = None
+    repo_changed: bool = False
 
 
 @dataclass(frozen=True, slots=True)
 class PageContext:
-	repo: Repository
-	repo_path: Path
+    repo: Repository
+    repo_path: Path
 
 
 @runtime_checkable
 class Page(Protocol):
-	"""A full-screen view in the pgt TUI."""
+    """A full-screen view in the pgt TUI."""
 
-	@property
-	def title(self) -> str: ...
+    @property
+    def title(self) -> str: ...
 
-	def on_enter(self, ctx: PageContext) -> None: ...
+    def on_enter(self, ctx: PageContext) -> None: ...
 
-	def draw(self, frame: Frame, area: Rect) -> None: ...
+    def draw(self, frame: Frame, area: Rect) -> None: ...
 
-	def status_text(self) -> str: ...
+    def status_text(self) -> str: ...
 
-	def handle_key(self, key: KeyLike) -> PageResult: ...
+    def handle_key(self, key: KeyLike) -> PageResult: ...
diff --git a/pygittools/src/pygittools/tui/worktree.py b/pygittools/src/pygittools/tui/worktree.py
index 5c1db14..c7f6988 100644
--- a/pygittools/src/pygittools/tui/worktree.py
+++ b/pygittools/src/pygittools/tui/worktree.py
@@ -10,121 +10,121 @@ from pygit2.enums import FileStatus
 from pygittools.hooks_push import git_executable
 
 _INDEX_FLAGS = (
-	FileStatus.INDEX_NEW
-	| FileStatus.INDEX_MODIFIED
-	| FileStatus.INDEX_DELETED
-	| FileStatus.INDEX_RENAMED
-	| FileStatus.INDEX_TYPECHANGE
+    FileStatus.INDEX_NEW
+    | FileStatus.INDEX_MODIFIED
+    | FileStatus.INDEX_DELETED
+    | FileStatus.INDEX_RENAMED
+    | FileStatus.INDEX_TYPECHANGE
 )
 _WT_FLAGS = (
-	FileStatus.WT_MODIFIED
-	| FileStatus.WT_DELETED
-	| FileStatus.WT_TYPECHANGE
-	| FileStatus.WT_RENAMED
-	| FileStatus.WT_UNREADABLE
+    FileStatus.WT_MODIFIED
+    | FileStatus.WT_DELETED
+    | FileStatus.WT_TYPECHANGE
+    | FileStatus.WT_RENAMED
+    | FileStatus.WT_UNREADABLE
 )
 
 
 def categorize_status(repo: Repository) -> tuple[list[str], list[str], list[str]]:
-	"""Return staged, unstaged, and untracked paths from ``repo.status()``."""
-	status = repo.status()
-	staged = sorted(path for path, flags in status.items() if flags & _INDEX_FLAGS)
-	unstaged = sorted(path for path, flags in status.items() if flags & _WT_FLAGS)
-	untracked = sorted(path for path, flags in status.items() if flags & FileStatus.WT_NEW)
-	return staged, unstaged, untracked
+    """Return staged, unstaged, and untracked paths from ``repo.status()``."""
+    status = repo.status()
+    staged = sorted(path for path, flags in status.items() if flags & _INDEX_FLAGS)
+    unstaged = sorted(path for path, flags in status.items() if flags & _WT_FLAGS)
+    untracked = sorted(path for path, flags in status.items() if flags & FileStatus.WT_NEW)
+    return staged, unstaged, untracked
 
 
 def _index_path(path: str) -> str:
-	"""Normalize a path for libgit2 index operations.
+    """Normalize a path for libgit2 index operations.
 
-	pygit2 reports untracked directories with a trailing slash, but
-	``index.add`` rejects those paths.
-	"""
-	return path.removesuffix("/")
+    pygit2 reports untracked directories with a trailing slash, but
+    ``index.add`` rejects those paths.
+    """
+    return path.removesuffix("/")
 
 
 def stage_path(repo: Repository, path: str) -> None:
-	stage_paths(repo, [path])
+    stage_paths(repo, [path])
 
 
 def stage_paths(repo: Repository, paths: list[str]) -> None:
-	if not paths:
-		return
-	index = repo.index
-	for path in paths:
-		status_flags = repo.status().get(path, 0)
-		if status_flags & FileStatus.WT_DELETED:
-			index.remove(path)
-		else:
-			index.add(_index_path(path))
-	index.write()
+    if not paths:
+        return
+    index = repo.index
+    for path in paths:
+        status_flags = repo.status().get(path, 0)
+        if status_flags & FileStatus.WT_DELETED:
+            index.remove(path)
+        else:
+            index.add(_index_path(path))
+    index.write()
 
 
 def unstage_path(repo: Repository, path: str) -> None:
-	index = repo.index
-	_unstage_index_path(index, _head_tree(repo), path)
-	index.write()
+    index = repo.index
+    _unstage_index_path(index, _head_tree(repo), path)
+    index.write()
 
 
 def unstage_paths(repo: Repository, paths: list[str]) -> None:
-	if not paths:
-		return
-	index = repo.index
-	head_tree = _head_tree(repo)
-	for path in paths:
-		_unstage_index_path(index, head_tree, path)
-	index.write()
+    if not paths:
+        return
+    index = repo.index
+    head_tree = _head_tree(repo)
+    for path in paths:
+        _unstage_index_path(index, head_tree, path)
+    index.write()
 
 
 def _head_tree(repo: Repository) -> Tree | None:
-	if repo.head_is_unborn:
-		return None
-	try:
-		return repo.head.peel(Commit).tree
-	except GitError:
-		return None
+    if repo.head_is_unborn:
+        return None
+    try:
+        return repo.head.peel(Commit).tree
+    except GitError:
+        return None
 
 
 def _unstage_index_path(index: Index, head_tree: Tree | None, path: str) -> None:
-	if head_tree is not None and path in head_tree:
-		entry = head_tree[path]
-		index.add(IndexEntry(path, entry.id, entry.filemode))
-	else:
-		index.remove(path)
+    if head_tree is not None and path in head_tree:
+        entry = head_tree[path]
+        index.add(IndexEntry(path, entry.id, entry.filemode))
+    else:
+        index.remove(path)
 
 
 def commit_staged(repo: Repository, message: str, *, amend: bool = False) -> tuple[bool, str]:
-	"""Create or amend a commit via the git CLI so client hooks run. Returns (ok, error)."""
-	trimmed = message.strip()
-	if not trimmed:
-		return False, "Commit message is empty"
-	workdir = repo.workdir
-	if workdir is None:
-		return False, "Repository has no working directory"
-	if amend:
-		if repo.head_is_unborn:
-			return False, "Nothing to amend"
-	else:
-		staged, _, _ = categorize_status(repo)
-		if not staged:
-			return False, "Nothing staged to commit"
-
-	command = [git_executable(), "commit"]
-	if amend:
-		command.append("--amend")
-	command.extend(["-F", "-"])
-	result = subprocess.run(
-		command,
-		cwd=workdir,
-		input=f"{trimmed}\n",
-		capture_output=True,
-		text=True,
-		check=False,
-	)
-	if result.returncode == 0:
-		return True, ""
-
-	output = (result.stderr or result.stdout or "Commit failed").strip()
-	if not output:
-		return False, "Commit failed"
-	return False, output.splitlines()[-1]
+    """Create or amend a commit via the git CLI so client hooks run. Returns (ok, error)."""
+    trimmed = message.strip()
+    if not trimmed:
+        return False, "Commit message is empty"
+    workdir = repo.workdir
+    if workdir is None:
+        return False, "Repository has no working directory"
+    if amend:
+        if repo.head_is_unborn:
+            return False, "Nothing to amend"
+    else:
+        staged, _, _ = categorize_status(repo)
+        if not staged:
+            return False, "Nothing staged to commit"
+
+    command = [git_executable(), "commit"]
+    if amend:
+        command.append("--amend")
+    command.extend(["-F", "-"])
+    result = subprocess.run(
+        command,
+        cwd=workdir,
+        input=f"{trimmed}\n",
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if result.returncode == 0:
+        return True, ""
+
+    output = (result.stderr or result.stdout or "Commit failed").strip()
+    if not output:
+        return False, "Commit failed"
+    return False, output.splitlines()[-1]
diff --git a/pygittools/src/pygittools/tui/worktree_test.py b/pygittools/src/pygittools/tui/worktree_test.py
index 97a5f43..aa777dd 100644
--- a/pygittools/src/pygittools/tui/worktree_test.py
+++ b/pygittools/src/pygittools/tui/worktree_test.py
@@ -9,176 +9,176 @@ from pygit2 import Repository, init_repository
 from pygit2.enums import FileStatus
 
 from pygittools.tui.worktree import (
-	categorize_status,
-	commit_staged,
-	stage_path,
-	stage_paths,
-	unstage_path,
-	unstage_paths,
+    categorize_status,
+    commit_staged,
+    stage_path,
+    stage_paths,
+    unstage_path,
+    unstage_paths,
 )
 
 
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
-	repo_path = tmp_path / "repo"
-	repo_path.mkdir()
-	return init_repository(str(repo_path), bare=False)
+    repo_path = tmp_path / "repo"
+    repo_path.mkdir()
+    return init_repository(str(repo_path), bare=False)
 
 
 def _init_nested_repo(path: Path) -> None:
-	path.mkdir()
-	subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
-	(path / "hello.txt").write_text("hello\n", encoding="utf-8")
-	subprocess.run(["git", "-C", str(path), "add", "hello.txt"], check=True, capture_output=True)
-	subprocess.run(
-		[
-			"git",
-			"-C",
-			str(path),
-			"-c",
-			"user.email=alice@example.com",
-			"-c",
-			"user.name=alice",
-			"commit",
-			"-m",
-			"init",
-		],
-		check=True,
-		capture_output=True,
-	)
+    path.mkdir()
+    subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
+    (path / "hello.txt").write_text("hello\n", encoding="utf-8")
+    subprocess.run(["git", "-C", str(path), "add", "hello.txt"], check=True, capture_output=True)
+    subprocess.run(
+        [
+            "git",
+            "-C",
+            str(path),
+            "-c",
+            "user.email=alice@example.com",
+            "-c",
+            "user.name=alice",
+            "commit",
+            "-m",
+            "init",
+        ],
+        check=True,
+        capture_output=True,
+    )
 
 
 def test_stage_path_accepts_untracked_directory_trailing_slash(repo: Repository) -> None:
-	workdir = Path(repo.workdir)
-	_init_nested_repo(workdir / "nested")
+    workdir = Path(repo.workdir)
+    _init_nested_repo(workdir / "nested")
 
-	_, _, untracked = categorize_status(repo)
-	assert untracked == ["nested/"]
+    _, _, untracked = categorize_status(repo)
+    assert untracked == ["nested/"]
 
-	stage_path(repo, untracked[0])
+    stage_path(repo, untracked[0])
 
-	staged, _, remaining = categorize_status(repo)
-	assert staged == ["nested"]
-	assert remaining == []
+    staged, _, remaining = categorize_status(repo)
+    assert staged == ["nested"]
+    assert remaining == []
 
 
 def test_stage_paths_accepts_untracked_directory_trailing_slash(repo: Repository) -> None:
-	workdir = Path(repo.workdir)
-	_init_nested_repo(workdir / "pkg")
-	(workdir / "loose.txt").write_text("x\n", encoding="utf-8")
+    workdir = Path(repo.workdir)
+    _init_nested_repo(workdir / "pkg")
+    (workdir / "loose.txt").write_text("x\n", encoding="utf-8")
 
-	_, _, untracked = categorize_status(repo)
-	assert set(untracked) == {"pkg/", "loose.txt"}
+    _, _, untracked = categorize_status(repo)
+    assert set(untracked) == {"pkg/", "loose.txt"}
 
-	stage_paths(repo, untracked)
+    stage_paths(repo, untracked)
 
-	status = repo.status()
-	assert status["pkg"] & FileStatus.INDEX_NEW
-	assert status["loose.txt"] & FileStatus.INDEX_NEW
+    status = repo.status()
+    assert status["pkg"] & FileStatus.INDEX_NEW
+    assert status["loose.txt"] & FileStatus.INDEX_NEW
 
 
 def test_stage_paths_stages_deleted_files(committed_repo: Repository) -> None:
-	workdir = Path(committed_repo.workdir)
-	(workdir / "tracked.txt").unlink()
+    workdir = Path(committed_repo.workdir)
+    (workdir / "tracked.txt").unlink()
 
-	_, unstaged, _ = categorize_status(committed_repo)
-	assert "tracked.txt" in unstaged
+    _, unstaged, _ = categorize_status(committed_repo)
+    assert "tracked.txt" in unstaged
 
-	stage_paths(committed_repo, unstaged)
+    stage_paths(committed_repo, unstaged)
 
-	status = committed_repo.status()
-	assert status["tracked.txt"] & FileStatus.INDEX_DELETED
+    status = committed_repo.status()
+    assert status["tracked.txt"] & FileStatus.INDEX_DELETED
 
 
 def test_stage_path_stages_deleted_file(committed_repo: Repository) -> None:
-	workdir = Path(committed_repo.workdir)
-	(workdir / "tracked.txt").unlink()
+    workdir = Path(committed_repo.workdir)
+    (workdir / "tracked.txt").unlink()
 
-	stage_path(committed_repo, "tracked.txt")
+    stage_path(committed_repo, "tracked.txt")
 
-	status = committed_repo.status()
-	assert status["tracked.txt"] & FileStatus.INDEX_DELETED
+    status = committed_repo.status()
+    assert status["tracked.txt"] & FileStatus.INDEX_DELETED
 
 
 def test_stage_paths_noop_for_empty_list(repo: Repository) -> None:
-	stage_paths(repo, [])
+    stage_paths(repo, [])
 
 
 def test_unstage_tracked_file(committed_repo: Repository) -> None:
-	stage_path(committed_repo, "tracked.txt")
-	unstage_path(committed_repo, "tracked.txt")
-	staged, _, _ = categorize_status(committed_repo)
-	assert "tracked.txt" not in staged
+    stage_path(committed_repo, "tracked.txt")
+    unstage_path(committed_repo, "tracked.txt")
+    staged, _, _ = categorize_status(committed_repo)
+    assert "tracked.txt" not in staged
 
 
 def test_unstage_new_file_on_unborn_head(repo: Repository) -> None:
-	path = Path(repo.workdir) / "hello.txt"
-	path.write_text("hello\n", encoding="utf-8")
-	stage_path(repo, "hello.txt")
-	unstage_path(repo, "hello.txt")
-	staged, _, untracked = categorize_status(repo)
-	assert staged == []
-	assert "hello.txt" in untracked
+    path = Path(repo.workdir) / "hello.txt"
+    path.write_text("hello\n", encoding="utf-8")
+    stage_path(repo, "hello.txt")
+    unstage_path(repo, "hello.txt")
+    staged, _, untracked = categorize_status(repo)
+    assert staged == []
+    assert "hello.txt" in untracked
 
 
 def test_unstage_new_file_removes_from_index(committed_repo: Repository) -> None:
-	path = Path(committed_repo.workdir) / "new.txt"
-	path.write_text("x\n", encoding="utf-8")
-	stage_path(committed_repo, "new.txt")
-	unstage_path(committed_repo, "new.txt")
-	staged, _, untracked = categorize_status(committed_repo)
-	assert "new.txt" not in staged
-	assert "new.txt" in untracked
+    path = Path(committed_repo.workdir) / "new.txt"
+    path.write_text("x\n", encoding="utf-8")
+    stage_path(committed_repo, "new.txt")
+    unstage_path(committed_repo, "new.txt")
+    staged, _, untracked = categorize_status(committed_repo)
+    assert "new.txt" not in staged
+    assert "new.txt" in untracked
 
 
 def test_unstage_paths_batch(committed_repo: Repository) -> None:
-	path = Path(committed_repo.workdir) / "new.txt"
-	path.write_text("x\n", encoding="utf-8")
-	stage_path(committed_repo, "new.txt")
-	unstage_paths(committed_repo, ["new.txt", "tracked.txt"])
-	staged, unstaged, _ = categorize_status(committed_repo)
-	assert not staged
+    path = Path(committed_repo.workdir) / "new.txt"
+    path.write_text("x\n", encoding="utf-8")
+    stage_path(committed_repo, "new.txt")
+    unstage_paths(committed_repo, ["new.txt", "tracked.txt"])
+    staged, unstaged, _ = categorize_status(committed_repo)
+    assert not staged
 
 
 def test_commit_staged_validates_message(committed_repo: Repository) -> None:
-	ok, error = commit_staged(committed_repo, "   ")
-	assert ok is False
-	assert error == "Commit message is empty"
+    ok, error = commit_staged(committed_repo, "   ")
+    assert ok is False
+    assert error == "Commit message is empty"
 
 
 def test_commit_staged_requires_staged_files(committed_repo: Repository) -> None:
-	ok, error = commit_staged(committed_repo, "msg")
-	assert ok is False
-	assert error == "Nothing staged to commit"
+    ok, error = commit_staged(committed_repo, "msg")
+    assert ok is False
+    assert error == "Nothing staged to commit"
 
 
 def test_commit_staged_success(committed_repo: Repository) -> None:
-	path = Path(committed_repo.workdir) / "new.txt"
-	path.write_text("x\n", encoding="utf-8")
-	stage_path(committed_repo, "new.txt")
-	ok, error = commit_staged(committed_repo, "add file")
-	assert ok is True
-	assert error == ""
+    path = Path(committed_repo.workdir) / "new.txt"
+    path.write_text("x\n", encoding="utf-8")
+    stage_path(committed_repo, "new.txt")
+    ok, error = commit_staged(committed_repo, "add file")
+    assert ok is True
+    assert error == ""
 
 
 def test_commit_amend_success(committed_repo: Repository) -> None:
-	ok, error = commit_staged(committed_repo, "initial\n\nbody", amend=True)
-	assert ok is True
-	assert error == ""
+    ok, error = commit_staged(committed_repo, "initial\n\nbody", amend=True)
+    assert ok is True
+    assert error == ""
 
 
 def test_commit_amend_requires_head(repo: Repository) -> None:
-	ok, error = commit_staged(repo, "msg", amend=True)
-	assert ok is False
-	assert error == "Nothing to amend"
+    ok, error = commit_staged(repo, "msg", amend=True)
+    assert ok is False
+    assert error == "Nothing to amend"
 
 
 def test_commit_staged_surfaces_git_error(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
-	path = Path(committed_repo.workdir) / "new.txt"
-	path.write_text("x\n", encoding="utf-8")
-	stage_path(committed_repo, "new.txt")
-	result = MagicMock(returncode=1, stderr="", stdout="")
-	monkeypatch.setattr("pygittools.tui.worktree.subprocess.run", lambda *a, **k: result)
-	ok, error = commit_staged(committed_repo, "msg")
-	assert ok is False
-	assert error == "Commit failed"
+    path = Path(committed_repo.workdir) / "new.txt"
+    path.write_text("x\n", encoding="utf-8")
+    stage_path(committed_repo, "new.txt")
+    result = MagicMock(returncode=1, stderr="", stdout="")
+    monkeypatch.setattr("pygittools.tui.worktree.subprocess.run", lambda *a, **k: result)
+    ok, error = commit_staged(committed_repo, "msg")
+    assert ok is False
+    assert error == "Commit failed"
diff --git a/pygittools/src/pygittools/tui/worktrees_list.py b/pygittools/src/pygittools/tui/worktrees_list.py
index 867f53f..c1216dc 100644
--- a/pygittools/src/pygittools/tui/worktrees_list.py
+++ b/pygittools/src/pygittools/tui/worktrees_list.py
@@ -18,144 +18,144 @@ from pygittools.tui.types import PageAction, PageResult
 
 @dataclass(frozen=True, slots=True)
 class _Row:
-	kind: Literal["add", "worktree"]
-	info: WorktreeInfo | None
+    kind: Literal["add", "worktree"]
+    info: WorktreeInfo | None
 
 
 class WorktreesList:
-	def __init__(self) -> None:
-		self._rows: list[_Row] = []
-		self._list_state = ListState()
-		self._error: str | None = None
-		self._current_path: Path | None = None
-
-	def is_at_top(self) -> bool:
-		return (self._list_state.selected or 0) == 0
-
-	def refresh(self, repo: Repository) -> None:
-		workdir = repo.workdir
-		self._current_path = Path(workdir).resolve() if workdir is not None else None
-		entries, error = list_worktrees(repo)
-		self._error = error
-		rows: list[_Row] = [_Row(kind="add", info=None)]
-		for info in entries:
-			rows.append(_Row(kind="worktree", info=info))
-		self._rows = rows
-		selected = self._list_state.selected or 0
-		self._list_state.select(min(selected, max(0, len(self._rows) - 1)))
-
-	def draw(self, frame: Frame, area: Rect, *, highlight: bool) -> None:
-		if area.height <= 0 or area.width <= 0:
-			return
-		theme = current_theme()
-		items: list[ListItem] = []
-		error_offset = 0
-		if self._error:
-			items.append(ListItem(self._error, theme.log_date))
-			error_offset = 1
-		selected = self._list_state.selected or 0
-		for index, row in enumerate(self._rows):
-			label = _row_label(row, current=self._current_path, width=max(10, area.width - 4))
-			focused = highlight and index == selected
-			style = focus_style(theme.normal) if focused and highlight else theme.normal
-			if row.kind == "add":
-				style = focus_style(theme.header) if focused and highlight else theme.header
-			items.append(ListItem(label, style))
-		if not self._rows and not self._error:
-			items.append(ListItem("No worktrees", theme.log_date))
-		list_state = self._list_state
-		if error_offset:
-			list_state = ListState()
-			row_selected = self._list_state.selected
-			if row_selected is not None:
-				list_state.select(row_selected + error_offset)
-		frame.render_stateful_list(
-			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			area,
-			list_state,
-		)
-
-	def handle_nav_key(self, key: KeyLike, repo: Repository) -> PageResult | None:
-		if self._error is not None and not (is_up(key) or is_down(key)):
-			self._error = None
-		if is_up(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(max(0, selected - 1))
-			return None
-		if is_down(key):
-			selected = self._list_state.selected or 0
-			self._list_state.select(min(max(0, len(self._rows) - 1), selected + 1))
-			return None
-		if not self._rows:
-			return None
-		row = self._rows[self._list_state.selected or 0]
-		if is_enter(key):
-			if row.kind == "add":
-				return PageResult(action=PageAction.PUSH, next_page=worktree_add_page())
-			if row.info is not None and self._current_path is not None:
-				target = row.info.path.resolve()
-				if target != self._current_path.resolve():
-					return PageResult(action=PageAction.PUSH, switch_repo=target)
-			return None
-		if key.code in {"d", "D"} and row.kind == "worktree" and row.info is not None:
-			if self._current_path is not None and row.info.path.resolve() == self._current_path.resolve():
-				self._error = "Cannot remove the active worktree"
-				return None
-			ok, error = remove_worktree(repo, row.info.path, force=True)
-			if ok:
-				self.refresh(repo)
-				return None
-			self._error = error
-		return None
-
-	def status_hint(self) -> str:
-		if self._error:
-			return self._error
-		if not self._rows:
-			return "No worktrees"
-		move = move_hint()
-		row = self._rows[self._list_state.selected or 0]
-		if row.kind == "add":
-			return f"Enter add worktree — {move}"
-		if row.info is not None and self._current_path is not None:
-			if row.info.path.resolve() == self._current_path.resolve():
-				return f"Active worktree — d remove other worktrees — {move}"
-			return f"Enter open worktree — d remove — {move}"
-		return move
+    def __init__(self) -> None:
+        self._rows: list[_Row] = []
+        self._list_state = ListState()
+        self._error: str | None = None
+        self._current_path: Path | None = None
+
+    def is_at_top(self) -> bool:
+        return (self._list_state.selected or 0) == 0
+
+    def refresh(self, repo: Repository) -> None:
+        workdir = repo.workdir
+        self._current_path = Path(workdir).resolve() if workdir is not None else None
+        entries, error = list_worktrees(repo)
+        self._error = error
+        rows: list[_Row] = [_Row(kind="add", info=None)]
+        for info in entries:
+            rows.append(_Row(kind="worktree", info=info))
+        self._rows = rows
+        selected = self._list_state.selected or 0
+        self._list_state.select(min(selected, max(0, len(self._rows) - 1)))
+
+    def draw(self, frame: Frame, area: Rect, *, highlight: bool) -> None:
+        if area.height <= 0 or area.width <= 0:
+            return
+        theme = current_theme()
+        items: list[ListItem] = []
+        error_offset = 0
+        if self._error:
+            items.append(ListItem(self._error, theme.log_date))
+            error_offset = 1
+        selected = self._list_state.selected or 0
+        for index, row in enumerate(self._rows):
+            label = _row_label(row, current=self._current_path, width=max(10, area.width - 4))
+            focused = highlight and index == selected
+            style = focus_style(theme.normal) if focused and highlight else theme.normal
+            if row.kind == "add":
+                style = focus_style(theme.header) if focused and highlight else theme.header
+            items.append(ListItem(label, style))
+        if not self._rows and not self._error:
+            items.append(ListItem("No worktrees", theme.log_date))
+        list_state = self._list_state
+        if error_offset:
+            list_state = ListState()
+            row_selected = self._list_state.selected
+            if row_selected is not None:
+                list_state.select(row_selected + error_offset)
+        frame.render_stateful_list(
+            List(items).highlight_style(theme.focus).highlight_symbol(""),
+            area,
+            list_state,
+        )
+
+    def handle_nav_key(self, key: KeyLike, repo: Repository) -> PageResult | None:
+        if self._error is not None and not (is_up(key) or is_down(key)):
+            self._error = None
+        if is_up(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(max(0, selected - 1))
+            return None
+        if is_down(key):
+            selected = self._list_state.selected or 0
+            self._list_state.select(min(max(0, len(self._rows) - 1), selected + 1))
+            return None
+        if not self._rows:
+            return None
+        row = self._rows[self._list_state.selected or 0]
+        if is_enter(key):
+            if row.kind == "add":
+                return PageResult(action=PageAction.PUSH, next_page=worktree_add_page())
+            if row.info is not None and self._current_path is not None:
+                target = row.info.path.resolve()
+                if target != self._current_path.resolve():
+                    return PageResult(action=PageAction.PUSH, switch_repo=target)
+            return None
+        if key.code in {"d", "D"} and row.kind == "worktree" and row.info is not None:
+            if self._current_path is not None and row.info.path.resolve() == self._current_path.resolve():
+                self._error = "Cannot remove the active worktree"
+                return None
+            ok, error = remove_worktree(repo, row.info.path, force=True)
+            if ok:
+                self.refresh(repo)
+                return None
+            self._error = error
+        return None
+
+    def status_hint(self) -> str:
+        if self._error:
+            return self._error
+        if not self._rows:
+            return "No worktrees"
+        move = move_hint()
+        row = self._rows[self._list_state.selected or 0]
+        if row.kind == "add":
+            return f"Enter add worktree — {move}"
+        if row.info is not None and self._current_path is not None:
+            if row.info.path.resolve() == self._current_path.resolve():
+                return f"Active worktree — d remove other worktrees — {move}"
+            return f"Enter open worktree — d remove — {move}"
+        return move
 
 
 def _row_label(row: _Row, *, current: Path | None, width: int) -> str:
-	if row.kind == "add":
-		return "(+ Add worktree)"
-	if row.info is None:
-		return ""
-	info = row.info
-	marker = "* " if current is not None and info.path.resolve() == current.resolve() else "  "
-	if info.bare:
-		branch = "(bare)"
-	elif info.branch:
-		branch = info.branch
-	elif info.detached:
-		branch = "(detached)"
-	else:
-		branch = "?"
-	extras: list[str] = []
-	if info.locked is not None:
-		extras.append("locked")
-	if info.prunable is not None:
-		extras.append("prunable")
-	suffix = f" [{', '.join(extras)}]" if extras else ""
-	path_str = _display_path(info.path, anchor=current)
-	label = f"{marker}{path_str}  {branch}{suffix}"
-	if len(label) > width:
-		return label[: max(0, width - 1)] + "…"
-	return label
+    if row.kind == "add":
+        return "(+ Add worktree)"
+    if row.info is None:
+        return ""
+    info = row.info
+    marker = "* " if current is not None and info.path.resolve() == current.resolve() else "  "
+    if info.bare:
+        branch = "(bare)"
+    elif info.branch:
+        branch = info.branch
+    elif info.detached:
+        branch = "(detached)"
+    else:
+        branch = "?"
+    extras: list[str] = []
+    if info.locked is not None:
+        extras.append("locked")
+    if info.prunable is not None:
+        extras.append("prunable")
+    suffix = f" [{', '.join(extras)}]" if extras else ""
+    path_str = _display_path(info.path, anchor=current)
+    label = f"{marker}{path_str}  {branch}{suffix}"
+    if len(label) > width:
+        return label[: max(0, width - 1)] + "…"
+    return label
 
 
 def _display_path(path: Path, *, anchor: Path | None) -> str:
-	if anchor is not None:
-		try:
-			return str(path.resolve().relative_to(anchor.parent.resolve()))
-		except ValueError:
-			pass
-	return str(path)
+    if anchor is not None:
+        try:
+            return str(path.resolve().relative_to(anchor.parent.resolve()))
+        except ValueError:
+            pass
+    return str(path)
diff --git a/pygittools/src/pygittools/tui/worktrees_list_test.py b/pygittools/src/pygittools/tui/worktrees_list_test.py
index cd4ef5f..4a5e0a1 100644
--- a/pygittools/src/pygittools/tui/worktrees_list_test.py
+++ b/pygittools/src/pygittools/tui/worktrees_list_test.py
@@ -15,69 +15,69 @@ from pygittools.tui.worktrees_list import WorktreesList, _Row, _row_label
 
 
 def test_row_label_marks_current() -> None:
-	info = WorktreeInfo(
-		path=Path("/proj/main"),
-		head="abc",
-		branch="main",
-		bare=False,
-		detached=False,
-		locked=None,
-		prunable=None,
-	)
-	label = _row_label(_Row(kind="worktree", info=info), current=Path("/proj/main"), width=80)
-	assert label.startswith("*")
+    info = WorktreeInfo(
+        path=Path("/proj/main"),
+        head="abc",
+        branch="main",
+        bare=False,
+        detached=False,
+        locked=None,
+        prunable=None,
+    )
+    label = _row_label(_Row(kind="worktree", info=info), current=Path("/proj/main"), width=80)
+    assert label.startswith("*")
 
 
 def test_worktrees_list_open_add(page_ctx: PageContext) -> None:
-	wt = WorktreesList()
-	wt.refresh(page_ctx.repo)
-	result = wt.handle_nav_key(key("Enter"), page_ctx.repo)
-	assert result is not None
-	assert result.action == PageAction.PUSH
-	assert isinstance(result.next_page, WorktreeAddPage)
+    wt = WorktreesList()
+    wt.refresh(page_ctx.repo)
+    result = wt.handle_nav_key(key("Enter"), page_ctx.repo)
+    assert result is not None
+    assert result.action == PageAction.PUSH
+    assert isinstance(result.next_page, WorktreeAddPage)
 
 
 def test_worktrees_list_switch_repo(
-	monkeypatch: pytest.MonkeyPatch,
-	tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
 ) -> None:
-	repo_path = tmp_path / "main"
-	repo_path.mkdir()
-	repo = init_repository(str(repo_path), bare=False)
-	other = tmp_path / "other"
-	entries = [
-		WorktreeInfo(
-			path=repo_path.resolve(),
-			head=None,
-			branch="main",
-			bare=False,
-			detached=False,
-			locked=None,
-			prunable=None,
-		),
-		WorktreeInfo(
-			path=other.resolve(),
-			head=None,
-			branch="feature",
-			bare=False,
-			detached=False,
-			locked=None,
-			prunable=None,
-		),
-	]
-	monkeypatch.setattr(
-		"pygittools.tui.worktrees_list.list_worktrees",
-		lambda _repo: (entries, None),
-	)
-	wt = WorktreesList()
-	wt.refresh(repo)
-	wt.handle_nav_key(key("Down"), repo)
-	wt.handle_nav_key(key("Down"), repo)
-	result = wt.handle_nav_key(key("Enter"), repo)
-	assert result is not None
-	assert result.switch_repo == other.resolve()
+    repo_path = tmp_path / "main"
+    repo_path.mkdir()
+    repo = init_repository(str(repo_path), bare=False)
+    other = tmp_path / "other"
+    entries = [
+        WorktreeInfo(
+            path=repo_path.resolve(),
+            head=None,
+            branch="main",
+            bare=False,
+            detached=False,
+            locked=None,
+            prunable=None,
+        ),
+        WorktreeInfo(
+            path=other.resolve(),
+            head=None,
+            branch="feature",
+            bare=False,
+            detached=False,
+            locked=None,
+            prunable=None,
+        ),
+    ]
+    monkeypatch.setattr(
+        "pygittools.tui.worktrees_list.list_worktrees",
+        lambda _repo: (entries, None),
+    )
+    wt = WorktreesList()
+    wt.refresh(repo)
+    wt.handle_nav_key(key("Down"), repo)
+    wt.handle_nav_key(key("Down"), repo)
+    result = wt.handle_nav_key(key("Enter"), repo)
+    assert result is not None
+    assert result.switch_repo == other.resolve()
 
 
 def test_worktrees_list_draw(frame: MagicMock) -> None:
-	wt = WorktreesList()
-	wt.draw(frame, Rect(0, 0, 80, 10), highlight=True)
+    wt = WorktreesList()
+    wt.draw(frame, Rect(0, 0, 80, 10), highlight=True)
diff --git a/pygitweb/src/pygitweb/__init__.py b/pygitweb/src/pygitweb/__init__.py
index e07991d..c05499d 100644
--- a/pygitweb/src/pygitweb/__init__.py
+++ b/pygitweb/src/pygitweb/__init__.py
@@ -3,11 +3,11 @@ import sys
 from pathlib import Path
 
 if "pytest" in sys.modules:
-	_test_auth_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-auth.json"
-	_test_settings_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-settings.json"
-	os.environ.setdefault("PYGITWEB_AUTH", "0")
-	os.environ.setdefault("PYGITWEB_AUTH_CONFIG", str(_test_auth_config))
-	os.environ.setdefault("PYGITWEB_SETTINGS_CONFIG", str(_test_settings_config))
+    _test_auth_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-auth.json"
+    _test_settings_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-settings.json"
+    os.environ.setdefault("PYGITWEB_AUTH", "0")
+    os.environ.setdefault("PYGITWEB_AUTH_CONFIG", str(_test_auth_config))
+    os.environ.setdefault("PYGITWEB_SETTINGS_CONFIG", str(_test_settings_config))
 
 import pygitweb.__meta__
 from pygitweb.main import app as app
diff --git a/pygitweb/src/pygitweb/actions.py b/pygitweb/src/pygitweb/actions.py
index e098b4a..20a56bf 100644
--- a/pygitweb/src/pygitweb/actions.py
+++ b/pygitweb/src/pygitweb/actions.py
@@ -24,1351 +24,1351 @@ from python_ripgrep import search as rg_search
 from pygitweb.config import BLOB_LANG, settings
 from pygitweb.formatting import age_string, sanitize, to_utf8
 from pygitweb.git_helpers import (
-	get_blob_at_ref_path,
-	get_blob_unified_diff,
-	get_commit_history,
-	get_commit_unified_diff,
-	get_commits_in_range,
-	get_readme_at_ref_path,
-	get_tree_at_ref_path,
-	git_get_heads_list,
-	git_get_project_description,
-	git_get_references,
-	git_get_remotes_info,
-	git_get_tags_list,
-	git_get_type,
-	open_repo,
-	parse_commit,
-	parse_tag,
+    get_blob_at_ref_path,
+    get_blob_unified_diff,
+    get_commit_history,
+    get_commit_unified_diff,
+    get_commits_in_range,
+    get_readme_at_ref_path,
+    get_tree_at_ref_path,
+    git_get_heads_list,
+    git_get_project_description,
+    git_get_references,
+    git_get_remotes_info,
+    git_get_tags_list,
+    git_get_type,
+    open_repo,
+    parse_commit,
+    parse_tag,
 )
 from pygitweb.hooks_install import list_installable_hooks, list_installed_hooks
 from pygitweb.merge_requests import (
-	merge_request_status_fields,
-	merge_request_tag_response,
-	resolve_merge_request_tag_to_tip,
+    merge_request_status_fields,
+    merge_request_tag_response,
+    resolve_merge_request_tag_to_tip,
 )
 from pygitweb.projects import git_get_project_owner
 from pygitweb.templates_env import (
-	Cell,
-	env,
-	html_format,
-	html_join,
-	html_link,
-	render_page,
-	trusted_html,
+    Cell,
+    env,
+    html_format,
+    html_join,
+    html_link,
+    render_page,
+    trusted_html,
 )
 from pygitweb.validation import is_valid_pathname, is_valid_ref_format
 
 
 class BlameHunkInfo(TypedDict):
-	commit_id: str
-	commit_msg: str
-	commit_author: str
-	time: str
-	line_start: int
-	line_end: int
-	author_name: str
-	time_display: str
+    commit_id: str
+    commit_msg: str
+    commit_author: str
+    time: str
+    line_start: int
+    line_end: int
+    author_name: str
+    time_display: str
 
 
 def _split_query_list(values: list[str]) -> list[str]:
-	parts: list[str] = []
-	for v in values:
-		for seg in (v or "").split(","):
-			s = seg.strip()
-			if s:
-				parts.append(s)
-	return parts
+    parts: list[str] = []
+    for v in values:
+        for seg in (v or "").split(","):
+            s = seg.strip()
+            if s:
+                parts.append(s)
+    return parts
 
 
 def _parse_query_bool(value: str | None) -> bool | None:
-	if value is None:
-		return None
-	v = value.strip().lower()
-	if v in ("1", "true", "yes", "on"):
-		return True
-	if v in ("0", "false", "no", "off"):
-		return False
-	raise HTTPException(status_code=400, detail=f"Invalid boolean: {value!r}")
+    if value is None:
+        return None
+    v = value.strip().lower()
+    if v in ("1", "true", "yes", "on"):
+        return True
+    if v in ("0", "false", "no", "off"):
+        return False
+    raise HTTPException(status_code=400, detail=f"Invalid boolean: {value!r}")
 
 
 def _project_worktree_root(project: str) -> Path:
-	root = (Path(settings.PROJECTROOT) / project).resolve()
-	if not root.exists() or not root.is_dir():
-		raise HTTPException(status_code=404, detail="Project directory not found")
-	return root
+    root = (Path(settings.PROJECTROOT) / project).resolve()
+    if not root.exists() or not root.is_dir():
+        raise HTTPException(status_code=404, detail="Project directory not found")
+    return root
 
 
 def _validated_search_paths(project_root: Path, raw_paths: list[str]) -> list[str]:
-	root = project_root.resolve()
-	if not raw_paths:
-		return [str(root)]
-
-	paths: list[str] = []
-	for raw in raw_paths:
-		p = (raw or "").strip()
-		if not p:
-			continue
-		candidate = Path(p)
-		if candidate.is_absolute() or ":" in p:
-			raise HTTPException(status_code=400, detail="path not relative to the project root")
-		abs_path = (root / p).resolve()
-		if not abs_path.is_relative_to(root):
-			raise HTTPException(status_code=400, detail="path not within the project root")
-		if not abs_path.exists():
-			raise HTTPException(status_code=400, detail=f"path entry does not exist: {p}")
-		paths.append(str(abs_path))
-
-	if not paths:
-		return [str(root)]
-	return paths
+    root = project_root.resolve()
+    if not raw_paths:
+        return [str(root)]
+
+    paths: list[str] = []
+    for raw in raw_paths:
+        p = (raw or "").strip()
+        if not p:
+            continue
+        candidate = Path(p)
+        if candidate.is_absolute() or ":" in p:
+            raise HTTPException(status_code=400, detail="path not relative to the project root")
+        abs_path = (root / p).resolve()
+        if not abs_path.is_relative_to(root):
+            raise HTTPException(status_code=400, detail="path not within the project root")
+        if not abs_path.exists():
+            raise HTTPException(status_code=400, detail=f"path entry does not exist: {p}")
+        paths.append(str(abs_path))
+
+    if not paths:
+        return [str(root)]
+    return paths
 
 
 _SORT_KINDS: tuple[str, ...] = ("Path", "LastModified", "LastAccessed", "Created")
 
 SearchSortQuery = Literal[
-	"Path",
-	"-Path",
-	"LastModified",
-	"-LastModified",
-	"LastAccessed",
-	"-LastAccessed",
-	"Created",
-	"-Created",
+    "Path",
+    "-Path",
+    "LastModified",
+    "-LastModified",
+    "LastAccessed",
+    "-LastAccessed",
+    "Created",
+    "-Created",
 ]
 
 SearchFlagQuery = Literal["true", "false"]
 
 
 def _parse_search_sort(value: str | None) -> PySortMode | None:
-	if value is None or not value.strip():
-		return None
-	raw = value.strip()
-	reverse = False
-	if raw.startswith("-"):
-		reverse = True
-		raw = raw[1:].strip()
-	if raw not in _SORT_KINDS:
-		allowed = ", ".join(_SORT_KINDS)
-		raise HTTPException(status_code=400, detail=f"Invalid sort kind; allowed: {allowed}")
-	kind = getattr(PySortModeKind, raw)
-	return PySortMode(kind=kind, reverse=reverse)
+    if value is None or not value.strip():
+        return None
+    raw = value.strip()
+    reverse = False
+    if raw.startswith("-"):
+        reverse = True
+        raw = raw[1:].strip()
+    if raw not in _SORT_KINDS:
+        allowed = ", ".join(_SORT_KINDS)
+        raise HTTPException(status_code=400, detail=f"Invalid sort kind; allowed: {allowed}")
+    kind = getattr(PySortModeKind, raw)
+    return PySortMode(kind=kind, reverse=reverse)
 
 
 def _pagination_url(request: Request, page: int, pagecount: int) -> str:
-	"""Build URL for a pagination page, preserving path and other query params."""
-	params = dict(parse_qsl(request.url.query, keep_blank_values=True))
-	params["page"] = str(page)
-	params["pagecount"] = str(pagecount)
-	return f"{request.url.path}?{urlencode(params)}"
+    """Build URL for a pagination page, preserving path and other query params."""
+    params = dict(parse_qsl(request.url.query, keep_blank_values=True))
+    params["page"] = str(page)
+    params["pagecount"] = str(pagecount)
+    return f"{request.url.path}?{urlencode(params)}"
 
 
 def parse_pagination(
-	page: str | None,
-	pagecount: str | None,
+    page: str | None,
+    pagecount: str | None,
 ) -> tuple[int, int]:
-	"""Parse page and pagecount; default 1 and 25; raise if pagecount > 50."""
-	p = 1
-	pc = 25
-	if page is not None:
-		try:
-			p = int(page)
-		except ValueError as e:
-			raise HTTPException(status_code=400, detail="page must be an integer") from e
-		if p < 1:
-			raise HTTPException(status_code=400, detail="page must be at least 1")
-	if pagecount is not None:
-		try:
-			pc = int(pagecount)
-		except ValueError as e:
-			raise HTTPException(status_code=400, detail="pagecount must be an integer") from e
-		if pc < 1:
-			raise HTTPException(status_code=400, detail="pagecount must be at least 1")
-		if pc > 50:
-			raise HTTPException(status_code=400, detail="pagecount must not exceed 50")
-	return p, pc
+    """Parse page and pagecount; default 1 and 25; raise if pagecount > 50."""
+    p = 1
+    pc = 25
+    if page is not None:
+        try:
+            p = int(page)
+        except ValueError as e:
+            raise HTTPException(status_code=400, detail="page must be an integer") from e
+        if p < 1:
+            raise HTTPException(status_code=400, detail="page must be at least 1")
+    if pagecount is not None:
+        try:
+            pc = int(pagecount)
+        except ValueError as e:
+            raise HTTPException(status_code=400, detail="pagecount must be an integer") from e
+        if pc < 1:
+            raise HTTPException(status_code=400, detail="pagecount must be at least 1")
+        if pc > 50:
+            raise HTTPException(status_code=400, detail="pagecount must not exceed 50")
+    return p, pc
 
 
 def _tree_url(project: str, h: str, f: str | None, a: str = "tree") -> str:
-	"""Build URL for tree or blob: /project/{project}?a=a&h=h&f=f with f quoted."""
-	q = f"a={a}&h={quote(h, safe='')}"
-	if f:
-		q += f"&f={quote(f, safe='/')}"
-	return f"/project/{project}?{q}"
+    """Build URL for tree or blob: /project/{project}?a=a&h=h&f=f with f quoted."""
+    q = f"a={a}&h={quote(h, safe='')}"
+    if f:
+        q += f"&f={quote(f, safe='/')}"
+    return f"/project/{project}?{q}"
 
 
 def _format_date(epoch: int | None, tz_offset: int | None = None) -> str:
-	"""Format epoch timestamp to readable date string.
-	tz_offset is in minutes (as returned by pygit2).
-	"""
-	if epoch is None:
-		return ""
-	try:
-		tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
-		dt = datetime.fromtimestamp(epoch, tz=tz)
-		return dt.strftime("%Y-%m-%d %H:%M:%S")
-	except (ValueError, OSError):
-		return ""
+    """Format epoch timestamp to readable date string.
+    tz_offset is in minutes (as returned by pygit2).
+    """
+    if epoch is None:
+        return ""
+    try:
+        tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
+        dt = datetime.fromtimestamp(epoch, tz=tz)
+        return dt.strftime("%Y-%m-%d %H:%M:%S")
+    except (ValueError, OSError):
+        return ""
 
 
 def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool = False) -> list[Cell]:
-	"""Format a single commit as a table row."""
-	oid = commit.get("oid", "")
-	oid_short = oid[:7] if oid else ""
-	subject = commit.get("subject", "")
-	author = commit.get("author", "")
-	author_epoch = commit.get("author_epoch")
-
-	date_str = _format_date(author_epoch, commit.get("author_tz"))
-	age_sec = None
-	if author_epoch:
-		age_sec = datetime.now(UTC).timestamp() - author_epoch
-		age_str = age_string(age_sec) if age_sec > 0 else "right now"
-	else:
-		age_str = ""
-
-	commit_href = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
-	diff_href = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
-	author_display = author or "unknown"
-	commit_cell = html_link(commit_href, oid_short)
-	diff_cell = html_link(diff_href, "diff")
-
-	if short:
-		return [commit_cell, subject, author_display, age_str, diff_cell]
-	return [commit_cell, subject, author_display, date_str, age_str, diff_cell]
+    """Format a single commit as a table row."""
+    oid = commit.get("oid", "")
+    oid_short = oid[:7] if oid else ""
+    subject = commit.get("subject", "")
+    author = commit.get("author", "")
+    author_epoch = commit.get("author_epoch")
+
+    date_str = _format_date(author_epoch, commit.get("author_tz"))
+    age_sec = None
+    if author_epoch:
+        age_sec = datetime.now(UTC).timestamp() - author_epoch
+        age_str = age_string(age_sec) if age_sec > 0 else "right now"
+    else:
+        age_str = ""
+
+    commit_href = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
+    diff_href = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
+    author_display = author or "unknown"
+    commit_cell = html_link(commit_href, oid_short)
+    diff_cell = html_link(diff_href, "diff")
+
+    if short:
+        return [commit_cell, subject, author_display, age_str, diff_cell]
+    return [commit_cell, subject, author_display, date_str, age_str, diff_cell]
 
 
 def _pagination_context(
-	request: Request,
-	page: int,
-	pagecount: int,
-	has_prev: bool,
-	has_next: bool,
-	total_pages: int | None = None,
+    request: Request,
+    page: int,
+    pagecount: int,
+    has_prev: bool,
+    has_next: bool,
+    total_pages: int | None = None,
 ) -> dict[str, Any]:
-	"""Context for pagination.html."""
-	prev_url = _pagination_url(request, page - 1, pagecount) if has_prev else ""
-	next_url = _pagination_url(request, page + 1, pagecount) if has_next else ""
-	page_links = None
-	if total_pages is not None and total_pages <= 20:
-		page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(1, total_pages + 1)]
-	elif total_pages is not None:
-		start = max(1, page - 2)
-		end = min(total_pages, page + 2)
-		page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(start, end + 1)]
-		if start > 1:
-			page_links = [(1, _pagination_url(request, 1, pagecount))] + [(-1, "")] + page_links
-		if end < total_pages:
-			page_links = page_links + [(-1, "")] + [(total_pages, _pagination_url(request, total_pages, pagecount))]
-	return {
-		"current_page": page,
-		"pagecount": pagecount,
-		"has_prev": has_prev,
-		"has_next": has_next,
-		"prev_url": prev_url,
-		"next_url": next_url,
-		"total_pages": total_pages,
-		"page_links": page_links,
-	}
+    """Context for pagination.html."""
+    prev_url = _pagination_url(request, page - 1, pagecount) if has_prev else ""
+    next_url = _pagination_url(request, page + 1, pagecount) if has_next else ""
+    page_links = None
+    if total_pages is not None and total_pages <= 20:
+        page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(1, total_pages + 1)]
+    elif total_pages is not None:
+        start = max(1, page - 2)
+        end = min(total_pages, page + 2)
+        page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(start, end + 1)]
+        if start > 1:
+            page_links = [(1, _pagination_url(request, 1, pagecount))] + [(-1, "")] + page_links
+        if end < total_pages:
+            page_links = page_links + [(-1, "")] + [(total_pages, _pagination_url(request, total_pages, pagecount))]
+    return {
+        "current_page": page,
+        "pagecount": pagecount,
+        "has_prev": has_prev,
+        "has_next": has_next,
+        "prev_url": prev_url,
+        "next_url": next_url,
+        "total_pages": total_pages,
+        "page_links": page_links,
+    }
 
 
 def _render_tree_table(project: str, ref: str | None, f: str | None) -> str:
-	"""Return an HTML table listing files and directories at ref/path."""
-	if f is not None and not is_valid_pathname(f):
-		return ""
-	result = get_tree_at_ref_path(project, ref, f)
-	if not result:
-		return '<p class="text-muted mb-0">No files found.</p>'
-	tree, ref_oid = result
-	entries = [(obj.name, obj.type_str, str(obj.id)) for obj in tree]
-	dirs = sorted((n, t, o) for n, t, o in entries if t == "tree")
-	blobs = sorted((n, t, o) for n, t, o in entries if t == "blob")
-	rows: list[list[Cell]] = []
-	for name, _, _ in dirs:
-		if name is None:
-			continue
-		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
-		href = _tree_url(project, ref_oid, sub_path)
-		rows.append([html_link(href, f"{name}/"), "tree", ""])
-	for name, _, _ in blobs:
-		if name is None:
-			continue
-		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
-		href = _tree_url(project, ref_oid, sub_path, a="blob")
-		blame_href = _tree_url(project, ref_oid, sub_path, a="blame")
-		rows.append([
-			html_link(href, name),
-			"blob",
-			html_link(blame_href, "blame"),
-		])
-	if not rows:
-		return '<p class="text-muted mb-0">Empty directory.</p>'
-	return env.get_template("table.html").render(cols=["Name", "Type", ""], rows=rows)
+    """Return an HTML table listing files and directories at ref/path."""
+    if f is not None and not is_valid_pathname(f):
+        return ""
+    result = get_tree_at_ref_path(project, ref, f)
+    if not result:
+        return '<p class="text-muted mb-0">No files found.</p>'
+    tree, ref_oid = result
+    entries = [(obj.name, obj.type_str, str(obj.id)) for obj in tree]
+    dirs = sorted((n, t, o) for n, t, o in entries if t == "tree")
+    blobs = sorted((n, t, o) for n, t, o in entries if t == "blob")
+    rows: list[list[Cell]] = []
+    for name, _, _ in dirs:
+        if name is None:
+            continue
+        sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
+        href = _tree_url(project, ref_oid, sub_path)
+        rows.append([html_link(href, f"{name}/"), "tree", ""])
+    for name, _, _ in blobs:
+        if name is None:
+            continue
+        sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
+        href = _tree_url(project, ref_oid, sub_path, a="blob")
+        blame_href = _tree_url(project, ref_oid, sub_path, a="blame")
+        rows.append([
+            html_link(href, name),
+            "blob",
+            html_link(blame_href, "blame"),
+        ])
+    if not rows:
+        return '<p class="text-muted mb-0">Empty directory.</p>'
+    return env.get_template("table.html").render(cols=["Name", "Type", ""], rows=rows)
 
 
 def _table_page(
-	*,
-	title: str,
-	heading: str | None = None,
-	cols: list[str] | None = None,
-	rows: list[list[Cell]] | None = None,
-	project: str | None = None,
-	pagination: dict[str, Any] | None = None,
-	empty_message: str | None = None,
-	empty_muted: bool = False,
-	nav: str | None = None,
+    *,
+    title: str,
+    heading: str | None = None,
+    cols: list[str] | None = None,
+    rows: list[list[Cell]] | None = None,
+    project: str | None = None,
+    pagination: dict[str, Any] | None = None,
+    empty_message: str | None = None,
+    empty_muted: bool = False,
+    nav: str | None = None,
 ) -> HTMLResponse:
-	ctx: dict[str, object] = {
-		"title": title,
-		"heading": title if heading is None else heading,
-		"project": project,
-		"pagination": pagination,
-		"empty_message": empty_message,
-		"empty_muted": empty_muted,
-		"nav": nav or ("projects" if project else None),
-	}
-	if cols is not None:
-		ctx["cols"] = cols
-		ctx["rows"] = rows or []
-	return render_page("table_page.html", **ctx)
+    ctx: dict[str, object] = {
+        "title": title,
+        "heading": title if heading is None else heading,
+        "project": project,
+        "pagination": pagination,
+        "empty_message": empty_message,
+        "empty_muted": empty_muted,
+        "nav": nav or ("projects" if project else None),
+    }
+    if cols is not None:
+        ctx["cols"] = cols
+        ctx["rows"] = rows or []
+    return render_page("table_page.html", **ctx)
 
 
 def _render_readme_card(
-	project: str,
-	ref_oid: str,
-	readme_filename: str,
-	readme_content: str,
-	blob_dir: str = "",
+    project: str,
+    ref_oid: str,
+    readme_filename: str,
+    readme_content: str,
+    blob_dir: str = "",
 ) -> str:
-	"""Return HTML for the README section (and script for markdown).
-
-	blob_dir is the current tree path for relative links.
-	"""
-	is_markdown = readme_filename.lower().endswith(".md")
-	if is_markdown:
-		return env.get_template("readme_card.html").render(
-			markdown=True,
-			readme_b64=base64.b64encode(readme_content.encode("utf-8")).decode("ascii"),
-			blob_base=f"/project/{project}?a=blob&h={quote(ref_oid, safe='')}&f=",
-			blob_dir=blob_dir,
-		)
-	return env.get_template("readme_card.html").render(
-		markdown=False,
-		plain=sanitize(readme_content) or "",
-	)
+    """Return HTML for the README section (and script for markdown).
+
+    blob_dir is the current tree path for relative links.
+    """
+    is_markdown = readme_filename.lower().endswith(".md")
+    if is_markdown:
+        return env.get_template("readme_card.html").render(
+            markdown=True,
+            readme_b64=base64.b64encode(readme_content.encode("utf-8")).decode("ascii"),
+            blob_base=f"/project/{project}?a=blob&h={quote(ref_oid, safe='')}&f=",
+            blob_dir=blob_dir,
+        )
+    return env.get_template("readme_card.html").render(
+        markdown=False,
+        plain=sanitize(readme_content) or "",
+    )
 
 
 def _commit_unified_diff_or_raise(project: str, h: str) -> tuple[str, str]:
-	"""Return (unified_diff_text, oid_short) for commit h. Raises HTTPException on error."""
-	if not h:
-		raise HTTPException(status_code=400, detail="Commit hash (h) required")
-	if not is_valid_ref_format(h):
-		raise HTTPException(status_code=400, detail="Invalid ref or hash")
-	try:
-		return get_commit_unified_diff(project, h)
-	except (KeyError, pygit2.GitError, OSError, ValueError) as e:
-		raise HTTPException(status_code=404, detail="Commit not found") from e
+    """Return (unified_diff_text, oid_short) for commit h. Raises HTTPException on error."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    try:
+        return get_commit_unified_diff(project, h)
+    except (KeyError, pygit2.GitError, OSError, ValueError) as e:
+        raise HTTPException(status_code=404, detail="Commit not found") from e
 
 
 class SummaryRefOption(TypedDict):
-	label: str
-	value: str
-	kind: str
+    label: str
+    value: str
+    kind: str
 
 
 class SummaryRefsPayload(TypedDict):
-	options: list[SummaryRefOption]
+    options: list[SummaryRefOption]
 
 
 class SummaryRefState(TypedDict):
-	ref: str
-	display: str
-	commit_href: str
-	browse_href: str
-	log_href: str
-	shortlog_href: str
-	tree_html: str
-	readme_html: str
+    ref: str
+    display: str
+    commit_href: str
+    browse_href: str
+    log_href: str
+    shortlog_href: str
+    tree_html: str
+    readme_html: str
 
 
 def summary_ref_options(project: str) -> list[SummaryRefOption]:
-	options: list[SummaryRefOption] = []
-	for name, ref, _oid in git_get_heads_list(project):
-		options.append({"label": name, "value": ref, "kind": "branch"})
-	for name, ref, _oid in git_get_tags_list(project):
-		options.append({"label": name, "value": ref, "kind": "tag"})
-	return options
+    options: list[SummaryRefOption] = []
+    for name, ref, _oid in git_get_heads_list(project):
+        options.append({"label": name, "value": ref, "kind": "branch"})
+    for name, ref, _oid in git_get_tags_list(project):
+        options.append({"label": name, "value": ref, "kind": "tag"})
+    return options
 
 
 def summary_ref_state(project: str, requested_ref: str | None) -> SummaryRefState:
-	ref = requested_ref or "HEAD"
-	commit_oid = ""
-	browse_ref = ref
-	try:
-		repo = open_repo(project)
-		obj = repo.revparse_single(ref)
-	except (KeyError, pygit2.GitError, OSError):
-		ref = "HEAD"
-		try:
-			repo = open_repo(project)
-			obj = repo.revparse_single(ref)
-		except (KeyError, pygit2.GitError, OSError):
-			obj = None
-	if isinstance(obj, pygit2.Commit):
-		commit_oid = str(obj.id)
-	elif isinstance(obj, pygit2.Tag):
-		try:
-			target_obj = repo[obj.target]
-		except (KeyError, pygit2.GitError):
-			target_obj = None
-		if isinstance(target_obj, pygit2.Commit):
-			commit_oid = str(target_obj.id)
-		elif target_obj is not None:
-			browse_ref = str(target_obj.id)
-	tree_html = _render_tree_table(project, ref, None)
-	readme_html = ""
-	readme = get_readme_at_ref_path(project, ref, "")
-	if readme:
-		readme_filename, readme_content = readme
-		readme_html = _render_readme_card(project, ref, readme_filename, readme_content, "")
-	log_ref = commit_oid or ref
-	return {
-		"ref": ref,
-		"display": (commit_oid[:7] if commit_oid else ref) or "N/A",
-		"commit_href": f"/project/{project}?a=commit&h={quote(commit_oid or ref, safe='')}",
-		"browse_href": f"/project/{project}?a=tree&h={quote(browse_ref, safe='')}",
-		"log_href": f"/project/{project}?a=log&h={quote(log_ref, safe='')}",
-		"shortlog_href": f"/project/{project}?a=shortlog&h={quote(log_ref, safe='')}",
-		"tree_html": tree_html,
-		"readme_html": readme_html,
-	}
+    ref = requested_ref or "HEAD"
+    commit_oid = ""
+    browse_ref = ref
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(ref)
+    except (KeyError, pygit2.GitError, OSError):
+        ref = "HEAD"
+        try:
+            repo = open_repo(project)
+            obj = repo.revparse_single(ref)
+        except (KeyError, pygit2.GitError, OSError):
+            obj = None
+    if isinstance(obj, pygit2.Commit):
+        commit_oid = str(obj.id)
+    elif isinstance(obj, pygit2.Tag):
+        try:
+            target_obj = repo[obj.target]
+        except (KeyError, pygit2.GitError):
+            target_obj = None
+        if isinstance(target_obj, pygit2.Commit):
+            commit_oid = str(target_obj.id)
+        elif target_obj is not None:
+            browse_ref = str(target_obj.id)
+    tree_html = _render_tree_table(project, ref, None)
+    readme_html = ""
+    readme = get_readme_at_ref_path(project, ref, "")
+    if readme:
+        readme_filename, readme_content = readme
+        readme_html = _render_readme_card(project, ref, readme_filename, readme_content, "")
+    log_ref = commit_oid or ref
+    return {
+        "ref": ref,
+        "display": (commit_oid[:7] if commit_oid else ref) or "N/A",
+        "commit_href": f"/project/{project}?a=commit&h={quote(commit_oid or ref, safe='')}",
+        "browse_href": f"/project/{project}?a=tree&h={quote(browse_ref, safe='')}",
+        "log_href": f"/project/{project}?a=log&h={quote(log_ref, safe='')}",
+        "shortlog_href": f"/project/{project}?a=shortlog&h={quote(log_ref, safe='')}",
+        "tree_html": tree_html,
+        "readme_html": readme_html,
+    }
 
 
 # ---------- Action handlers ----------
 
 
 def git_object(project: str, h: str | None) -> Response:
-	"""Show object by type: commit, tree, tag, or blob. Dispatches to the appropriate view."""
-	if not h:
-		raise HTTPException(status_code=400, detail="Object hash (h) required")
-	if not is_valid_ref_format(h):
-		raise HTTPException(status_code=400, detail="Invalid ref or hash")
-	obj_type = git_get_type(project, h)
-	if not obj_type:
-		raise HTTPException(status_code=404, detail="Object not found")
-	if obj_type == "commit":
-		return git_commit(project, h)
-	if obj_type == "tree":
-		return git_tree(project, h, None)
-	if obj_type == "tag":
-		return git_tag(project, h)
-	if obj_type == "blob":
-		try:
-			repo = open_repo(project)
-			obj = repo.revparse_single(h)
-		except (KeyError, pygit2.GitError, OSError) as e:
-			raise HTTPException(status_code=404, detail="Object not found") from e
-		if not isinstance(obj, pygit2.Blob):
-			raise HTTPException(status_code=404, detail="Not a blob")
-		data = obj.data
-		text = to_utf8(data) or ""
-		lines = text.split("\n")
-		num_lines = max(1, len(lines))
-		oid_short = str(obj.id)[:7]
-		return render_page(
-			"blob.html",
-			title=f"Blob {oid_short} - {project}",
-			heading=f"Blob {oid_short}",
-			project=project,
-			code=sanitize(text) or "",
-			line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
-			lang="",
-			blame=False,
-		)
-	raise HTTPException(status_code=404, detail="Unknown object type")
+    """Show object by type: commit, tree, tag, or blob. Dispatches to the appropriate view."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Object hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    obj_type = git_get_type(project, h)
+    if not obj_type:
+        raise HTTPException(status_code=404, detail="Object not found")
+    if obj_type == "commit":
+        return git_commit(project, h)
+    if obj_type == "tree":
+        return git_tree(project, h, None)
+    if obj_type == "tag":
+        return git_tag(project, h)
+    if obj_type == "blob":
+        try:
+            repo = open_repo(project)
+            obj = repo.revparse_single(h)
+        except (KeyError, pygit2.GitError, OSError) as e:
+            raise HTTPException(status_code=404, detail="Object not found") from e
+        if not isinstance(obj, pygit2.Blob):
+            raise HTTPException(status_code=404, detail="Not a blob")
+        data = obj.data
+        text = to_utf8(data) or ""
+        lines = text.split("\n")
+        num_lines = max(1, len(lines))
+        oid_short = str(obj.id)[:7]
+        return render_page(
+            "blob.html",
+            title=f"Blob {oid_short} - {project}",
+            heading=f"Blob {oid_short}",
+            project=project,
+            code=sanitize(text) or "",
+            line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
+            lang="",
+            blame=False,
+        )
+    raise HTTPException(status_code=404, detail="Unknown object type")
 
 
 def git_search(
-	project: str,
-	search_patterns: str | None,
-	search_paths: str | None,
-	search_globs: str | None,
-	search_heading: SearchFlagQuery | None,
-	search_multiline: SearchFlagQuery | None,
-	search_sort: SearchSortQuery | None,
-	search_max_count: str | None,
+    project: str,
+    search_patterns: str | None,
+    search_paths: str | None,
+    search_globs: str | None,
+    search_heading: SearchFlagQuery | None,
+    search_multiline: SearchFlagQuery | None,
+    search_sort: SearchSortQuery | None,
+    search_max_count: str | None,
 ) -> JSONResponse:
-	patterns = _split_query_list([search_patterns] if search_patterns is not None else [])
-	if not patterns:
-		raise HTTPException(status_code=400, detail="patterns is required (repeat ?patterns= or use comma-separated)")
-
-	project_root = _project_worktree_root(project)
-	paths = _validated_search_paths(project_root, _split_query_list([search_paths] if search_paths is not None else []))
-
-	globs = _split_query_list([search_globs] if search_globs is not None else [])
-	heading = _parse_query_bool(search_heading)
-	multiline = _parse_query_bool(search_multiline)
-	sort = _parse_search_sort(search_sort)
-
-	max_count: int = 25
-	if search_max_count is not None and search_max_count.strip() != "":
-		try:
-			max_count = int(search_max_count)
-		except ValueError as e:
-			raise HTTPException(status_code=400, detail="max_count must be an integer") from e
-		if max_count < 1:
-			raise HTTPException(status_code=400, detail="max_count must be at least 1")
-
-	try:
-		results = rg_search(
-			patterns=patterns,
-			paths=paths,
-			globs=(globs or None),
-			heading=heading,
-			sort=sort,
-			max_count=max_count,
-			multiline=multiline,
-		)
-	except Exception as e:
-		raise HTTPException(status_code=500, detail=f"Search failed: {e}") from e
-
-	root_str = str(project_root.resolve())
-	rel_paths: list[str] = []
-	for p in paths:
-		abs_p = str(Path(p).resolve())
-		rel = os.path.relpath(abs_p, root_str)
-		rel_paths.append("" if rel == "." else rel.replace(os.sep, "/"))
-
-	return JSONResponse({
-		"project": project,
-		"project_root": root_str,
-		"patterns": patterns,
-		"paths": rel_paths,
-		"globs": globs,
-		"heading": heading,
-		"sort": (search_sort or None),
-		"max_count": max_count,
-		"multiline": multiline,
-		"results": results,
-	})
+    patterns = _split_query_list([search_patterns] if search_patterns is not None else [])
+    if not patterns:
+        raise HTTPException(status_code=400, detail="patterns is required (repeat ?patterns= or use comma-separated)")
+
+    project_root = _project_worktree_root(project)
+    paths = _validated_search_paths(project_root, _split_query_list([search_paths] if search_paths is not None else []))
+
+    globs = _split_query_list([search_globs] if search_globs is not None else [])
+    heading = _parse_query_bool(search_heading)
+    multiline = _parse_query_bool(search_multiline)
+    sort = _parse_search_sort(search_sort)
+
+    max_count: int = 25
+    if search_max_count is not None and search_max_count.strip() != "":
+        try:
+            max_count = int(search_max_count)
+        except ValueError as e:
+            raise HTTPException(status_code=400, detail="max_count must be an integer") from e
+        if max_count < 1:
+            raise HTTPException(status_code=400, detail="max_count must be at least 1")
+
+    try:
+        results = rg_search(
+            patterns=patterns,
+            paths=paths,
+            globs=(globs or None),
+            heading=heading,
+            sort=sort,
+            max_count=max_count,
+            multiline=multiline,
+        )
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Search failed: {e}") from e
+
+    root_str = str(project_root.resolve())
+    rel_paths: list[str] = []
+    for p in paths:
+        abs_p = str(Path(p).resolve())
+        rel = os.path.relpath(abs_p, root_str)
+        rel_paths.append("" if rel == "." else rel.replace(os.sep, "/"))
+
+    return JSONResponse({
+        "project": project,
+        "project_root": root_str,
+        "patterns": patterns,
+        "paths": rel_paths,
+        "globs": globs,
+        "heading": heading,
+        "sort": (search_sort or None),
+        "max_count": max_count,
+        "multiline": multiline,
+        "results": results,
+    })
 
 
 def git_search_page(project: str) -> HTMLResponse:
-	"""Per-project search UI page. Calls /project/{project}?a=search via fetch."""
-	project_enc = quote(project, safe="/")
-	return render_page(
-		"search.html",
-		title=f"Search - {project}",
-		project=project,
-		project_enc=project_enc,
-		project_url=f"/project/{project_enc}",
-		search_api_url=f"/project/{project_enc}",
-	)
+    """Per-project search UI page. Calls /project/{project}?a=search via fetch."""
+    project_enc = quote(project, safe="/")
+    return render_page(
+        "search.html",
+        title=f"Search - {project}",
+        project=project,
+        project_enc=project_enc,
+        project_url=f"/project/{project_enc}",
+        search_api_url=f"/project/{project_enc}",
+    )
 
 
 def _hooks_context(project: str, project_enc: str) -> dict[str, Any]:
-	"""Data for hooks_summary_cell.html."""
-	try:
-		installed = list_installed_hooks(project)
-		available = list_installable_hooks(project)
-	except (KeyError, OSError):
-		return {"mode": "unavailable", "project_enc": project_enc, "installed": [], "available": []}
-	return {
-		"mode": "manage",
-		"project_enc": project_enc,
-		"installed": installed,
-		"available": available,
-	}
+    """Data for hooks_summary_cell.html."""
+    try:
+        installed = list_installed_hooks(project)
+        available = list_installable_hooks(project)
+    except (KeyError, OSError):
+        return {"mode": "unavailable", "project_enc": project_enc, "installed": [], "available": []}
+    return {
+        "mode": "manage",
+        "project_enc": project_enc,
+        "installed": installed,
+        "available": available,
+    }
 
 
 _OPEN_MR_BADGE = {
-	"can_fast_forward": "green",
-	"blocked_diverged": "yellow",
-	"ours_ref_missing": "red",
-	"mr_head_missing": "red",
-	"error": "red",
+    "can_fast_forward": "green",
+    "blocked_diverged": "yellow",
+    "ours_ref_missing": "red",
+    "mr_head_missing": "red",
+    "error": "red",
 }
 
 
 def _ref_short(ref: str | None) -> str:
-	if not ref:
-		return ""
-	if ref.startswith("refs/heads/"):
-		return ref.removeprefix("refs/heads/")
-	return ref
+    if not ref:
+        return ""
+    if ref.startswith("refs/heads/"):
+        return ref.removeprefix("refs/heads/")
+    return ref
 
 
 def _open_merge_requests(project: str) -> list[dict[str, str]]:
-	"""Merge requests whose head is not already contained in the destination branch."""
-	try:
-		repo = open_repo(project)
-	except (OSError, pygit2.GitError, ValueError):
-		return []
-	project_enc = quote(project, safe="/")
-	open_mrs: list[dict[str, str]] = []
-	for ref_name in repo.references:
-		if not ref_name.startswith(MR_REF_PREFIX):
-			continue
-		try:
-			oid = repo.references[ref_name].resolve().target
-			obj = repo[oid]
-			if not isinstance(obj, pygit2.Tag):
-				continue
-			mr = get_merge_request_by_oid(repo, pygit2.Oid(hex=str(oid)))
-		except (ValueError, KeyError, TypeError, json.JSONDecodeError, pygit2.GitError):
-			continue
-		try:
-			status = mr.check_status(repo)
-		except ValueError:
-			status = None
-		if status == MergeRequestStatus.UP_TO_DATE:
-			continue
-		fields = merge_request_status_fields(repo, mr)
-		status_code = str(fields["status_code"])
-		open_mrs.append({
-			"title": mr.title or ref_name.removeprefix(MR_REF_PREFIX),
-			"href": f"/project/{project_enc}?a=tag&h={quote(ref_name, safe='')}",
-			"theirs": _ref_short(mr.theirs),
-			"ours": _ref_short(mr.ours),
-			"status_label": str(fields["status_label"]),
-			"badge": _OPEN_MR_BADGE.get(status_code, "secondary"),
-		})
-	open_mrs.sort(key=lambda item: item["title"].lower())
-	return open_mrs
+    """Merge requests whose head is not already contained in the destination branch."""
+    try:
+        repo = open_repo(project)
+    except (OSError, pygit2.GitError, ValueError):
+        return []
+    project_enc = quote(project, safe="/")
+    open_mrs: list[dict[str, str]] = []
+    for ref_name in repo.references:
+        if not ref_name.startswith(MR_REF_PREFIX):
+            continue
+        try:
+            oid = repo.references[ref_name].resolve().target
+            obj = repo[oid]
+            if not isinstance(obj, pygit2.Tag):
+                continue
+            mr = get_merge_request_by_oid(repo, pygit2.Oid(hex=str(oid)))
+        except (ValueError, KeyError, TypeError, json.JSONDecodeError, pygit2.GitError):
+            continue
+        try:
+            status = mr.check_status(repo)
+        except ValueError:
+            status = None
+        if status == MergeRequestStatus.UP_TO_DATE:
+            continue
+        fields = merge_request_status_fields(repo, mr)
+        status_code = str(fields["status_code"])
+        open_mrs.append({
+            "title": mr.title or ref_name.removeprefix(MR_REF_PREFIX),
+            "href": f"/project/{project_enc}?a=tag&h={quote(ref_name, safe='')}",
+            "theirs": _ref_short(mr.theirs),
+            "ours": _ref_short(mr.ours),
+            "status_label": str(fields["status_label"]),
+            "badge": _OPEN_MR_BADGE.get(status_code, "secondary"),
+        })
+    open_mrs.sort(key=lambda item: item["title"].lower())
+    return open_mrs
 
 
 def _merge_request_context(project: str) -> dict[str, Any] | None:
-	heads = git_get_heads_list(project)
-	if not heads:
-		return None
-	refs = [h[1] for h in heads]
-	branches = [{"short": h[0], "ref": h[1]} for h in heads]
-	if "refs/heads/main" in refs:
-		ours_default_ref = "refs/heads/main"
-	elif "refs/heads/master" in refs:
-		ours_default_ref = "refs/heads/master"
-	else:
-		ours_default_ref = refs[0]
-	theirs_default_ref = next((r for r in refs if r != ours_default_ref), refs[0])
-	return {
-		"project": project,
-		"branches": branches,
-		"theirs_default_ref": theirs_default_ref,
-		"ours_default_ref": ours_default_ref,
-	}
+    heads = git_get_heads_list(project)
+    if not heads:
+        return None
+    refs = [h[1] for h in heads]
+    branches = [{"short": h[0], "ref": h[1]} for h in heads]
+    if "refs/heads/main" in refs:
+        ours_default_ref = "refs/heads/main"
+    elif "refs/heads/master" in refs:
+        ours_default_ref = "refs/heads/master"
+    else:
+        ours_default_ref = refs[0]
+    theirs_default_ref = next((r for r in refs if r != ours_default_ref), refs[0])
+    return {
+        "project": project,
+        "branches": branches,
+        "theirs_default_ref": theirs_default_ref,
+        "ours_default_ref": ours_default_ref,
+    }
 
 
 def _project_has_boards(project: str) -> bool:
-	try:
-		return len(git_get_references(project, "refs/tags/boards")) > 0
-	except Exception:
-		return False
+    try:
+        return len(git_get_references(project, "refs/tags/boards")) > 0
+    except Exception:
+        return False
 
 
 BLOG_MANIFEST_PATH: str = "articles/manifest.json"
 
 
 def project_has_blog(project: str) -> bool:
-	try:
-		return get_blob_at_ref_path(project, "HEAD", BLOG_MANIFEST_PATH) is not None
-	except Exception:
-		return False
+    try:
+        return get_blob_at_ref_path(project, "HEAD", BLOG_MANIFEST_PATH) is not None
+    except Exception:
+        return False
 
 
 class BoardLink(TypedDict):
-	href: str
-	text: str
-	style: str | None
+    href: str
+    text: str
+    style: str | None
 
 
 class BlogLink(TypedDict):
-	href: str
-	text: str
-	style: str | None
+    href: str
+    text: str
+    style: str | None
 
 
 def board_link_spec(project: str) -> BoardLink:
-	if _project_has_boards(project):
-		return {
-			"href": f"/project/{quote(project, safe='/')}/board/",
-			"text": "project board",
-			"style": None,
-		}
-	style = "color: #999; cursor: not-allowed;" if settings.AUTH else None
-	return {
-		"href": "/board/create?project=" + quote(project, safe=""),
-		"text": "create board",
-		"style": style,
-	}
+    if _project_has_boards(project):
+        return {
+            "href": f"/project/{quote(project, safe='/')}/board/",
+            "text": "project board",
+            "style": None,
+        }
+    style = "color: #999; cursor: not-allowed;" if settings.AUTH else None
+    return {
+        "href": "/board/create?project=" + quote(project, safe=""),
+        "text": "create board",
+        "style": style,
+    }
 
 
 def blog_link_spec(project: str) -> BlogLink:
-	if project_has_blog(project):
-		return {
-			"href": f"/project/{quote(project, safe='/')}/blog/",
-			"text": "view blog",
-			"style": None,
-		}
-	return {
-		"href": "#",
-		"text": "no blog",
-		"style": "color: #999; cursor: not-allowed;",
-	}
+    if project_has_blog(project):
+        return {
+            "href": f"/project/{quote(project, safe='/')}/blog/",
+            "text": "view blog",
+            "style": None,
+        }
+    return {
+        "href": "#",
+        "text": "no blog",
+        "style": "color: #999; cursor: not-allowed;",
+    }
 
 
 def create_board_link(project: str) -> Cell:
-	href = "/board/create?project=" + quote(project, safe="")
-	if settings.AUTH:
-		return html_link(href, "create board", style="color: #999; cursor: not-allowed;")
-	return html_link(href, "create board")
+    href = "/board/create?project=" + quote(project, safe="")
+    if settings.AUTH:
+        return html_link(href, "create board", style="color: #999; cursor: not-allowed;")
+    return html_link(href, "create board")
 
 
 def project_board_link(project: str) -> Cell:
-	spec = board_link_spec(project)
-	if spec["style"]:
-		return html_link(spec["href"], spec["text"], style=spec["style"])
-	return html_link(spec["href"], spec["text"])
+    spec = board_link_spec(project)
+    if spec["style"]:
+        return html_link(spec["href"], spec["text"], style=spec["style"])
+    return html_link(spec["href"], spec["text"])
 
 
 def git_summary(project: str, extra_rows: list[list[Cell]] | None = None) -> HTMLResponse:
-	"""Project summary page. Port of git_summary."""
-	descr = git_get_project_description(project) or ""
-	owner = git_get_project_owner(project) or ""
-	initial_ref_state = summary_ref_state(project, "HEAD")
-	project_enc = quote(project, safe="/")
-	ref_view: dict[str, object] = dict(initial_ref_state)
-	ref_view["tree_html"] = trusted_html(initial_ref_state["tree_html"])
-	ref_view["readme_html"] = trusted_html(initial_ref_state["readme_html"])
-	return render_page(
-		"summary.html",
-		title=f"{settings.SITE_NAME} - {project}",
-		project=project,
-		descr=descr,
-		owner=owner,
-		ref=ref_view,
-		project_url=f"/project/{project}",
-		project_url_enc=f"/project/{project_enc}",
-		board=board_link_spec(project),
-		blog=blog_link_spec(project),
-		hooks=_hooks_context(project, project_enc),
-		merge_request=_merge_request_context(project),
-		open_merge_requests=_open_merge_requests(project),
-		extra_rows=extra_rows or [],
-		nav="projects",
-	)
+    """Project summary page. Port of git_summary."""
+    descr = git_get_project_description(project) or ""
+    owner = git_get_project_owner(project) or ""
+    initial_ref_state = summary_ref_state(project, "HEAD")
+    project_enc = quote(project, safe="/")
+    ref_view: dict[str, object] = dict(initial_ref_state)
+    ref_view["tree_html"] = trusted_html(initial_ref_state["tree_html"])
+    ref_view["readme_html"] = trusted_html(initial_ref_state["readme_html"])
+    return render_page(
+        "summary.html",
+        title=f"{settings.SITE_NAME} - {project}",
+        project=project,
+        descr=descr,
+        owner=owner,
+        ref=ref_view,
+        project_url=f"/project/{project}",
+        project_url_enc=f"/project/{project_enc}",
+        board=board_link_spec(project),
+        blog=blog_link_spec(project),
+        hooks=_hooks_context(project, project_enc),
+        merge_request=_merge_request_context(project),
+        open_merge_requests=_open_merge_requests(project),
+        extra_rows=extra_rows or [],
+        nav="projects",
+    )
 
 
 def git_remotes(project: str) -> HTMLResponse:
-	"""Remotes page: list configured remotes (name, url, push_url)."""
-	remotes = git_get_remotes_info(project)
-	title = f"Remotes - {project}"
-	if not remotes:
-		return _table_page(title=title, heading="Remotes", empty_message="No remotes configured.")
-	rows = [[r["name"], r["url"] or "—", r["push_url"] or "—"] for r in remotes]
-	return _table_page(title=title, heading="Remotes", cols=["Name", "URL", "Push URL"], rows=rows)
+    """Remotes page: list configured remotes (name, url, push_url)."""
+    remotes = git_get_remotes_info(project)
+    title = f"Remotes - {project}"
+    if not remotes:
+        return _table_page(title=title, heading="Remotes", empty_message="No remotes configured.")
+    rows = [[r["name"], r["url"] or "—", r["push_url"] or "—"] for r in remotes]
+    return _table_page(title=title, heading="Remotes", cols=["Name", "URL", "Push URL"], rows=rows)
 
 
 def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -> Response:
-	"""Return blob content when f points to a file. Raw = bytes; else HTML with escaped content."""
-	if not f:
-		raise HTTPException(status_code=400, detail="File path (f) required")
-	if not is_valid_pathname(f):
-		raise HTTPException(status_code=400, detail="Invalid path")
-	result = get_blob_at_ref_path(project, h, f)
-	if not result:
-		raise HTTPException(status_code=404, detail="File not found")
-	blob, _ = result
-	data = blob.data
-	if raw:
-		media_type, _ = mimetypes.guess_type(f.split("/")[-1])
-		if media_type is None:
-			try:
-				data.decode("utf-8")
-				media_type = "text/plain; charset=utf-8"
-			except UnicodeDecodeError:
-				media_type = "application/octet-stream"
-		return Response(content=data, media_type=media_type)
-	text = to_utf8(data) or ""
-	lines = text.split("\n")
-	num_lines = max(1, len(lines))
-	ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
-	return render_page(
-		"blob.html",
-		title=f"{f} - {project}",
-		code=sanitize(text) or "",
-		line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
-		lang=BLOB_LANG.get(ext, ""),
-		blame=False,
-	)
+    """Return blob content when f points to a file. Raw = bytes; else HTML with escaped content."""
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required")
+    if not is_valid_pathname(f):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    result = get_blob_at_ref_path(project, h, f)
+    if not result:
+        raise HTTPException(status_code=404, detail="File not found")
+    blob, _ = result
+    data = blob.data
+    if raw:
+        media_type, _ = mimetypes.guess_type(f.split("/")[-1])
+        if media_type is None:
+            try:
+                data.decode("utf-8")
+                media_type = "text/plain; charset=utf-8"
+            except UnicodeDecodeError:
+                media_type = "application/octet-stream"
+        return Response(content=data, media_type=media_type)
+    text = to_utf8(data) or ""
+    lines = text.split("\n")
+    num_lines = max(1, len(lines))
+    ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
+    return render_page(
+        "blob.html",
+        title=f"{f} - {project}",
+        code=sanitize(text) or "",
+        line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
+        lang=BLOB_LANG.get(ext, ""),
+        blame=False,
+    )
 
 
 def git_blog_raw(project: str, path: str) -> Response:
-	"""Serve a blob from HEAD for the project blog viewer."""
-	if not is_valid_pathname(path):
-		raise HTTPException(status_code=404, detail="File not found")
-	result = get_blob_at_ref_path(project, "HEAD", path)
-	if not result:
-		raise HTTPException(status_code=404, detail="File not found")
-	blob, _ = result
-	data = blob.data
-	media_type, _ = mimetypes.guess_type(path.split("/")[-1])
-	if media_type is None:
-		try:
-			data.decode("utf-8")
-			media_type = "text/plain; charset=utf-8"
-		except UnicodeDecodeError:
-			media_type = "application/octet-stream"
-	return Response(content=data, media_type=media_type)
+    """Serve a blob from HEAD for the project blog viewer."""
+    if not is_valid_pathname(path):
+        raise HTTPException(status_code=404, detail="File not found")
+    result = get_blob_at_ref_path(project, "HEAD", path)
+    if not result:
+        raise HTTPException(status_code=404, detail="File not found")
+    blob, _ = result
+    data = blob.data
+    media_type, _ = mimetypes.guess_type(path.split("/")[-1])
+    if media_type is None:
+        try:
+            data.decode("utf-8")
+            media_type = "text/plain; charset=utf-8"
+        except UnicodeDecodeError:
+            media_type = "application/octet-stream"
+    return Response(content=data, media_type=media_type)
 
 
 def _blame_hunk_end_line(hunk: pygit2.BlameHunk) -> int:
-	return hunk.final_start_line_number + hunk.lines_in_hunk - 1
+    return hunk.final_start_line_number + hunk.lines_in_hunk - 1
 
 
 def _merge_blame_hunks(repo: pygit2.Repository, blame: pygit2.Blame) -> list[BlameHunkInfo]:
-	out: list[BlameHunkInfo] = []
-	i = 0
-	n = len(blame)
-	while i < n:
-		hunk = blame[i]
-		cid = str(hunk.final_commit_id)
-		line_start = hunk.final_start_line_number
-		line_end = _blame_hunk_end_line(hunk)
-		j = i + 1
-		while j < n:
-			nh = blame[j]
-			if str(nh.final_commit_id) != cid:
-				break
-			if nh.final_start_line_number != line_end + 1:
-				break
-			line_end = _blame_hunk_end_line(nh)
-			j += 1
-		try:
-			bc = repo[cid].peel(pygit2.Commit)
-		except (KeyError, pygit2.GitError, ValueError) as e:
-			raise HTTPException(status_code=500, detail=f"Missing blame commit {cid}") from e
-		auth = bc.author
-		tzinfo = timezone(timedelta(minutes=auth.offset))
-		dt = datetime.fromtimestamp(float(auth.time), tz=tzinfo)
-		out.append({
-			"commit_id": cid,
-			"commit_msg": bc.message.rstrip("\n"),
-			"commit_author": f"{auth.name} <{auth.email}>",
-			"time": dt.isoformat(),
-			"line_start": line_start,
-			"line_end": line_end,
-			"author_name": auth.name,
-			"time_display": _format_date(auth.time, auth.offset),
-		})
-		i = j
-	return out
+    out: list[BlameHunkInfo] = []
+    i = 0
+    n = len(blame)
+    while i < n:
+        hunk = blame[i]
+        cid = str(hunk.final_commit_id)
+        line_start = hunk.final_start_line_number
+        line_end = _blame_hunk_end_line(hunk)
+        j = i + 1
+        while j < n:
+            nh = blame[j]
+            if str(nh.final_commit_id) != cid:
+                break
+            if nh.final_start_line_number != line_end + 1:
+                break
+            line_end = _blame_hunk_end_line(nh)
+            j += 1
+        try:
+            bc = repo[cid].peel(pygit2.Commit)
+        except (KeyError, pygit2.GitError, ValueError) as e:
+            raise HTTPException(status_code=500, detail=f"Missing blame commit {cid}") from e
+        auth = bc.author
+        tzinfo = timezone(timedelta(minutes=auth.offset))
+        dt = datetime.fromtimestamp(float(auth.time), tz=tzinfo)
+        out.append({
+            "commit_id": cid,
+            "commit_msg": bc.message.rstrip("\n"),
+            "commit_author": f"{auth.name} <{auth.email}>",
+            "time": dt.isoformat(),
+            "line_start": line_start,
+            "line_end": line_end,
+            "author_name": auth.name,
+            "time_display": _format_date(auth.time, auth.offset),
+        })
+        i = j
+    return out
 
 
 def _blame_for_file(project: str, h: str | None, f: str) -> list[BlameHunkInfo]:
-	if not is_valid_pathname(f):
-		raise HTTPException(status_code=400, detail="Invalid path")
-	if not get_blob_at_ref_path(project, h, f):
-		raise HTTPException(status_code=404, detail="File not found")
-	repo = open_repo(project)
-	base_ref = h or (str(repo.head.target) if repo.head else None)
-	if not base_ref:
-		raise HTTPException(status_code=404, detail="No revision to blame")
-	try:
-		commit_tip = repo.revparse_single(base_ref).peel(pygit2.Commit)
-	except (KeyError, pygit2.GitError, ValueError) as e:
-		raise HTTPException(status_code=400, detail="Invalid revision for blame") from e
-	path_clean = f.strip("/")
-	try:
-		blame = repo.blame(path_clean, newest_commit=commit_tip.id)
-	except (pygit2.GitError, OSError) as e:
-		raise HTTPException(status_code=400, detail=f"Blame failed: {e}") from e
-	return _merge_blame_hunks(repo, blame)
+    if not is_valid_pathname(f):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    if not get_blob_at_ref_path(project, h, f):
+        raise HTTPException(status_code=404, detail="File not found")
+    repo = open_repo(project)
+    base_ref = h or (str(repo.head.target) if repo.head else None)
+    if not base_ref:
+        raise HTTPException(status_code=404, detail="No revision to blame")
+    try:
+        commit_tip = repo.revparse_single(base_ref).peel(pygit2.Commit)
+    except (KeyError, pygit2.GitError, ValueError) as e:
+        raise HTTPException(status_code=400, detail="Invalid revision for blame") from e
+    path_clean = f.strip("/")
+    try:
+        blame = repo.blame(path_clean, newest_commit=commit_tip.id)
+    except (pygit2.GitError, OSError) as e:
+        raise HTTPException(status_code=400, detail=f"Blame failed: {e}") from e
+    return _merge_blame_hunks(repo, blame)
 
 
 def _blame_line_links(project: str, hunks: list[BlameHunkInfo], num_lines: int) -> list[dict[str, str] | None]:
-	hunk_by_start = {h["line_start"]: h for h in hunks}
-	lines: list[dict[str, str] | None] = []
-	for line_no in range(1, num_lines + 1):
-		hunk = hunk_by_start.get(line_no)
-		if hunk is None:
-			lines.append(None)
-			continue
-		cid = hunk["commit_id"]
-		lines.append({
-			"href": f"/project/{project}?a=commit&h={quote(cid, safe='')}",
-			"title": f"{hunk['author_name']} — {hunk['time_display']}",
-			"oid_short": cid[:7],
-		})
-	return lines
+    hunk_by_start = {h["line_start"]: h for h in hunks}
+    lines: list[dict[str, str] | None] = []
+    for line_no in range(1, num_lines + 1):
+        hunk = hunk_by_start.get(line_no)
+        if hunk is None:
+            lines.append(None)
+            continue
+        cid = hunk["commit_id"]
+        lines.append({
+            "href": f"/project/{project}?a=commit&h={quote(cid, safe='')}",
+            "title": f"{hunk['author_name']} — {hunk['time_display']}",
+            "oid_short": cid[:7],
+        })
+    return lines
 
 
 def git_blame(project: str, h: str | None, f: str | None) -> HTMLResponse:
-	"""Blame page: file content with per-range commit links and line numbers."""
-	if not f:
-		raise HTTPException(status_code=400, detail="File path (f) required")
-	result = get_blob_at_ref_path(project, h, f)
-	if not result:
-		raise HTTPException(status_code=404, detail="File not found")
-	blob, _ref_oid = result
-	hunks = _blame_for_file(project, h, f)
-	text = to_utf8(blob.data) or ""
-	lines = text.split("\n")
-	num_lines = max(1, len(lines))
-	ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
-	return render_page(
-		"blob.html",
-		title=f"Blame {f} - {project}",
-		code=sanitize(text) or "",
-		line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
-		lang=BLOB_LANG.get(ext, ""),
-		blame=True,
-		blame_lines=_blame_line_links(project, hunks, num_lines),
-	)
+    """Blame page: file content with per-range commit links and line numbers."""
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required")
+    result = get_blob_at_ref_path(project, h, f)
+    if not result:
+        raise HTTPException(status_code=404, detail="File not found")
+    blob, _ref_oid = result
+    hunks = _blame_for_file(project, h, f)
+    text = to_utf8(blob.data) or ""
+    lines = text.split("\n")
+    num_lines = max(1, len(lines))
+    ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
+    return render_page(
+        "blob.html",
+        title=f"Blame {f} - {project}",
+        code=sanitize(text) or "",
+        line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
+        lang=BLOB_LANG.get(ext, ""),
+        blame=True,
+        blame_lines=_blame_line_links(project, hunks, num_lines),
+    )
 
 
 def git_blame_raw(project: str, h: str | None, f: str | None) -> JSONResponse:
-	"""Per-file blame as JSON: merged contiguous hunks per commit with line ranges."""
-	if not f:
-		raise HTTPException(status_code=400, detail="File path (f) required")
-	hunks = _blame_for_file(project, h, f)
-	out = [
-		{
-			"commit_id": h["commit_id"],
-			"commit_msg": h["commit_msg"],
-			"commit_author": h["commit_author"],
-			"time": h["time"],
-			"line_range": (
-				str(h["line_start"]) if h["line_start"] == h["line_end"] else f"{h['line_start']}-{h['line_end']}"
-			),
-		}
-		for h in hunks
-	]
-	return JSONResponse(out)
+    """Per-file blame as JSON: merged contiguous hunks per commit with line ranges."""
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required")
+    hunks = _blame_for_file(project, h, f)
+    out = [
+        {
+            "commit_id": h["commit_id"],
+            "commit_msg": h["commit_msg"],
+            "commit_author": h["commit_author"],
+            "time": h["time"],
+            "line_range": (
+                str(h["line_start"]) if h["line_start"] == h["line_end"] else f"{h['line_start']}-{h['line_end']}"
+            ),
+        }
+        for h in hunks
+    ]
+    return JSONResponse(out)
 
 
 def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
-	"""Tree page: list files and directories; directories link to tree with f=path."""
-	if f is not None and not is_valid_pathname(f):
-		raise HTTPException(status_code=400, detail="Invalid path")
-	result = get_tree_at_ref_path(project, h, f)
-	if not result:
-		raise HTTPException(status_code=404, detail="Tree or path not found")
-	_tree, ref_oid = result
-	crumbs: list[dict[str, str]] = []
-	if f:
-		parts = f.strip("/").split("/")
-		for i, seg in enumerate(parts):
-			prefix = "/".join(parts[: i + 1])
-			crumbs.append({"href": _tree_url(project, ref_oid, prefix), "label": seg})
-	title_path = f" / {f}" if f else ""
-	readme_html = ""
-	needs_showdown = False
-	readme = get_readme_at_ref_path(project, ref_oid, f or "")
-	if readme:
-		readme_filename, readme_content = readme
-		needs_showdown = readme_filename.lower().endswith(".md")
-		readme_html = _render_readme_card(project, ref_oid, readme_filename, readme_content, f or "")
-	return render_page(
-		"tree.html",
-		title=f"{project}{title_path} - Tree",
-		project=project,
-		project_href=f"/project/{project}",
-		breadcrumbs=crumbs,
-		path=f or "",
-		tree_html=trusted_html(_render_tree_table(project, h, f)),
-		readme_html=trusted_html(readme_html),
-		needs_showdown=needs_showdown,
-	)
+    """Tree page: list files and directories; directories link to tree with f=path."""
+    if f is not None and not is_valid_pathname(f):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    result = get_tree_at_ref_path(project, h, f)
+    if not result:
+        raise HTTPException(status_code=404, detail="Tree or path not found")
+    _tree, ref_oid = result
+    crumbs: list[dict[str, str]] = []
+    if f:
+        parts = f.strip("/").split("/")
+        for i, seg in enumerate(parts):
+            prefix = "/".join(parts[: i + 1])
+            crumbs.append({"href": _tree_url(project, ref_oid, prefix), "label": seg})
+    title_path = f" / {f}" if f else ""
+    readme_html = ""
+    needs_showdown = False
+    readme = get_readme_at_ref_path(project, ref_oid, f or "")
+    if readme:
+        readme_filename, readme_content = readme
+        needs_showdown = readme_filename.lower().endswith(".md")
+        readme_html = _render_readme_card(project, ref_oid, readme_filename, readme_content, f or "")
+    return render_page(
+        "tree.html",
+        title=f"{project}{title_path} - Tree",
+        project=project,
+        project_href=f"/project/{project}",
+        breadcrumbs=crumbs,
+        path=f or "",
+        tree_html=trusted_html(_render_tree_table(project, h, f)),
+        readme_html=trusted_html(readme_html),
+        needs_showdown=needs_showdown,
+    )
 
 
 def git_log(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
-	"""Commit log page. Port of git_log."""
-	skip = (page - 1) * pagecount
-	commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
-	if not commits:
-		raise HTTPException(status_code=404, detail="No commits found")
-	has_next = len(commits) > pagecount
-	if has_next:
-		commits = commits[:pagecount]
-	rows = []
-	for commit in commits:
-		rows.append(_format_commit_table_row(project, commit, short=False))
-	ref_display = h[:7] if h else "HEAD"
-	title = f"Log - {project} @ {ref_display}"
-	return _table_page(
-		title=title,
-		cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"],
-		rows=rows,
-		pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
-	)
+    """Commit log page. Port of git_log."""
+    skip = (page - 1) * pagecount
+    commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No commits found")
+    has_next = len(commits) > pagecount
+    if has_next:
+        commits = commits[:pagecount]
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=False))
+    ref_display = h[:7] if h else "HEAD"
+    title = f"Log - {project} @ {ref_display}"
+    return _table_page(
+        title=title,
+        cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"],
+        rows=rows,
+        pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
+    )
 
 
 def git_shortlog(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
-	"""Shortlog page. Port of git_shortlog."""
-	skip = (page - 1) * pagecount
-	commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
-	if not commits:
-		raise HTTPException(status_code=404, detail="No commits found")
-	has_next = len(commits) > pagecount
-	if has_next:
-		commits = commits[:pagecount]
-	rows = []
-	for commit in commits:
-		rows.append(_format_commit_table_row(project, commit, short=True))
-	ref_display = h[:7] if h else "HEAD"
-	title = f"Shortlog - {project} @ {ref_display}"
-	return _table_page(
-		title=title,
-		cols=["Commit", "Subject", "Author", "Age", "Diff"],
-		rows=rows,
-		pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
-	)
+    """Shortlog page. Port of git_shortlog."""
+    skip = (page - 1) * pagecount
+    commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No commits found")
+    has_next = len(commits) > pagecount
+    if has_next:
+        commits = commits[:pagecount]
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=True))
+    ref_display = h[:7] if h else "HEAD"
+    title = f"Shortlog - {project} @ {ref_display}"
+    return _table_page(
+        title=title,
+        cols=["Commit", "Subject", "Author", "Age", "Diff"],
+        rows=rows,
+        pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
+    )
 
 
 def git_history(
-	project: str,
-	h: str | None,
-	f: str | None,
-	request: Request,
-	page: int,
-	pagecount: int,
+    project: str,
+    h: str | None,
+    f: str | None,
+    request: Request,
+    page: int,
+    pagecount: int,
 ) -> HTMLResponse:
-	"""History page for a file or directory. Port of git_history."""
-	if not f:
-		raise HTTPException(status_code=400, detail="File path (f) required for history")
-	if not is_valid_pathname(f):
-		raise HTTPException(status_code=400, detail="Invalid path")
-	skip = (page - 1) * pagecount
-	commits = get_commit_history(project, ref=h, path=f, max_count=pagecount + 1, skip=skip)
-	if not commits:
-		raise HTTPException(status_code=404, detail="No history found for this path")
-	has_next = len(commits) > pagecount
-	if has_next:
-		commits = commits[:pagecount]
-	rows = []
-	for commit in commits:
-		rows.append(_format_commit_table_row(project, commit, short=False))
-	ref_display = h[:7] if h else "HEAD"
-	title = f"History - {f} - {project} @ {ref_display}"
-	return _table_page(
-		title=title,
-		project=project,
-		cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"],
-		rows=rows,
-		pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
-	)
+    """History page for a file or directory. Port of git_history."""
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required for history")
+    if not is_valid_pathname(f):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    skip = (page - 1) * pagecount
+    commits = get_commit_history(project, ref=h, path=f, max_count=pagecount + 1, skip=skip)
+    if not commits:
+        raise HTTPException(status_code=404, detail="No history found for this path")
+    has_next = len(commits) > pagecount
+    if has_next:
+        commits = commits[:pagecount]
+    rows = []
+    for commit in commits:
+        rows.append(_format_commit_table_row(project, commit, short=False))
+    ref_display = h[:7] if h else "HEAD"
+    title = f"History - {f} - {project} @ {ref_display}"
+    return _table_page(
+        title=title,
+        project=project,
+        cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"],
+        rows=rows,
+        pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
+    )
 
 
 def git_heads(project: str) -> HTMLResponse:
-	"""Heads (branches) list page. Port of git_heads."""
-	visible_heads = git_get_heads_list(project)
-	if not visible_heads:
-		raise HTTPException(status_code=404, detail="No heads found")
-	rows: list[list[Cell]] = []
-	for name, _ref, oid in visible_heads:
-		oid_short = oid[:7] if oid else ""
-		commit_href = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
-		tree_href = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
-		rows.append([
-			html_link(commit_href, name),
-			html_link(commit_href, oid_short),
-			html_link(tree_href, "tree"),
-		])
-	title = f"Heads - {project}"
-	return _table_page(
-		title=title,
-		project=project,
-		cols=["Head", "Commit", ""],
-		rows=rows,
-	)
+    """Heads (branches) list page. Port of git_heads."""
+    visible_heads = git_get_heads_list(project)
+    if not visible_heads:
+        raise HTTPException(status_code=404, detail="No heads found")
+    rows: list[list[Cell]] = []
+    for name, _ref, oid in visible_heads:
+        oid_short = oid[:7] if oid else ""
+        commit_href = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
+        tree_href = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
+        rows.append([
+            html_link(commit_href, name),
+            html_link(commit_href, oid_short),
+            html_link(tree_href, "tree"),
+        ])
+    title = f"Heads - {project}"
+    return _table_page(
+        title=title,
+        project=project,
+        cols=["Head", "Commit", ""],
+        rows=rows,
+    )
 
 
 def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLResponse:
-	"""Tags list page. Port of git_tags."""
-	tags_list = git_get_tags_list(project)
-	if not tags_list:
-		raise HTTPException(status_code=404, detail="No tags found")
-	total = len(tags_list)
-	total_pages = (total + pagecount - 1) // pagecount if pagecount else 1
-	if page > total_pages and total > 0:
-		raise HTTPException(status_code=404, detail="Page does not exist")
-	skip = (page - 1) * pagecount
-	tags_page = tags_list[skip : skip + pagecount]
-	try:
-		repo = open_repo(project)
-	except Exception as e:
-		raise HTTPException(status_code=404, detail="Repository not found") from e
-	rows = []
-	for name, _ref, oid in tags_page:
-		tag_link = f"/project/{project}?a=tag&h={quote(oid, safe='')}"
-		target_oid = oid
-		target_type = "commit"
-		try:
-			obj = repo.revparse_single(oid)
-			if isinstance(obj, pygit2.Tag):
-				target_oid = str(obj.target)
-				target_type = obj.type_str
-		except (KeyError, pygit2.GitError):
-			pass
-		target_short = target_oid[:7] if target_oid else ""
-		if target_type == "commit":
-			target_link = f"/project/{project}?a=commit&h={quote(target_oid, safe='')}"
-		elif target_type == "tree":
-			target_link = f"/project/{project}?a=tree&h={quote(target_oid, safe='')}"
-		else:
-			target_link = None
-		obj_cell: Cell = html_link(target_link, target_short) if target_link else target_short
-		rows.append([
-			html_link(tag_link, name),
-			obj_cell,
-		])
-	title = f"Tags - {project}"
-	return _table_page(
-		title=title,
-		project=project,
-		cols=["Tag", "Object"],
-		rows=rows,
-		pagination=_pagination_context(request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages),
-	)
+    """Tags list page. Port of git_tags."""
+    tags_list = git_get_tags_list(project)
+    if not tags_list:
+        raise HTTPException(status_code=404, detail="No tags found")
+    total = len(tags_list)
+    total_pages = (total + pagecount - 1) // pagecount if pagecount else 1
+    if page > total_pages and total > 0:
+        raise HTTPException(status_code=404, detail="Page does not exist")
+    skip = (page - 1) * pagecount
+    tags_page = tags_list[skip : skip + pagecount]
+    try:
+        repo = open_repo(project)
+    except Exception as e:
+        raise HTTPException(status_code=404, detail="Repository not found") from e
+    rows = []
+    for name, _ref, oid in tags_page:
+        tag_link = f"/project/{project}?a=tag&h={quote(oid, safe='')}"
+        target_oid = oid
+        target_type = "commit"
+        try:
+            obj = repo.revparse_single(oid)
+            if isinstance(obj, pygit2.Tag):
+                target_oid = str(obj.target)
+                target_type = obj.type_str
+        except (KeyError, pygit2.GitError):
+            pass
+        target_short = target_oid[:7] if target_oid else ""
+        if target_type == "commit":
+            target_link = f"/project/{project}?a=commit&h={quote(target_oid, safe='')}"
+        elif target_type == "tree":
+            target_link = f"/project/{project}?a=tree&h={quote(target_oid, safe='')}"
+        else:
+            target_link = None
+        obj_cell: Cell = html_link(target_link, target_short) if target_link else target_short
+        rows.append([
+            html_link(tag_link, name),
+            obj_cell,
+        ])
+    title = f"Tags - {project}"
+    return _table_page(
+        title=title,
+        project=project,
+        cols=["Tag", "Object"],
+        rows=rows,
+        pagination=_pagination_context(request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages),
+    )
 
 
 def git_tag(project: str, h: str | None, notice: str | None = None) -> HTMLResponse | RedirectResponse:
-	"""Single tag view. Port of git_tag."""
-	if not h:
-		raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
-	if not is_valid_ref_format(h):
-		raise HTTPException(status_code=400, detail="Invalid ref or hash")
-	try:
-		repo = open_repo(project)
-		obj = repo.revparse_single(h)
-	except (KeyError, pygit2.GitError, OSError) as e:
-		raise HTTPException(status_code=404, detail="Tag or object not found") from e
-	if not isinstance(obj, pygit2.Tag):
-		raise HTTPException(status_code=404, detail="Not a tag object")
-	mr_pair = resolve_merge_request_tag_to_tip(repo, obj)
-	if mr_pair is not None:
-		obj, mr = mr_pair
-	else:
-		mr = None
-	tag_oid = str(obj.id)
-	tag_data = parse_tag(project, tag_oid)
-	if not tag_data:
-		raise HTTPException(status_code=404, detail="Tag not found")
-	target_oid = tag_data.get("object", "")
-	target_type = tag_data.get("type", "commit")
-	tagger = tag_data.get("tagger", "")
-	tagger_epoch = tag_data.get("tagger_epoch")
-	tagger_tz = tag_data.get("tagger_tz")
-	message = (tag_data.get("message") or "").strip()
-	tag_name = None
-	for name, _ref, oid in git_get_tags_list(project):
-		if oid == tag_oid:
-			tag_name = name
-			break
-	if tag_name is None:
-		tag_name = tag_oid[:7]
-	if mr is not None:
-		return merge_request_tag_response(
-			project=project,
-			repo=repo,
-			mr=mr,
-			tag_name=tag_name,
-			tag_oid=tag_oid,
-			tagger=tagger,
-			tagger_date=_format_date(tagger_epoch, tagger_tz),
-			notice=notice,
-		)
-	target_short = target_oid[:7] if target_oid else ""
-	if target_type == "commit":
-		object_value: Cell = html_link(
-			f"/project/{project}?a=commit&h={quote(target_oid, safe='')}",
-			target_short,
-		)
-	elif target_type == "tree":
-		object_value = html_link(
-			f"/project/{project}?a=tree&h={quote(target_oid, safe='')}",
-			target_short,
-		)
-	else:
-		object_value = target_short
-	table_rows: list[list[Cell]] = [
-		["Tag", tag_name or ""],
-		["Object", html_join([object_value, " (", target_type, ")"])],
-		["Tagger", tagger],
-		["Date", _format_date(tagger_epoch, tagger_tz)],
-	]
-	if message:
-		table_rows.append(["Message", html_format("<pre class='tag-message'>{}</pre>", message)])
-	return _table_page(
-		title=f"Tag {tag_name} - {project}",
-		heading=f"Tag {tag_name}",
-		project=project,
-		cols=["Field", "Value"],
-		rows=table_rows,
-	)
+    """Single tag view. Port of git_tag."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(h)
+    except (KeyError, pygit2.GitError, OSError) as e:
+        raise HTTPException(status_code=404, detail="Tag or object not found") from e
+    if not isinstance(obj, pygit2.Tag):
+        raise HTTPException(status_code=404, detail="Not a tag object")
+    mr_pair = resolve_merge_request_tag_to_tip(repo, obj)
+    if mr_pair is not None:
+        obj, mr = mr_pair
+    else:
+        mr = None
+    tag_oid = str(obj.id)
+    tag_data = parse_tag(project, tag_oid)
+    if not tag_data:
+        raise HTTPException(status_code=404, detail="Tag not found")
+    target_oid = tag_data.get("object", "")
+    target_type = tag_data.get("type", "commit")
+    tagger = tag_data.get("tagger", "")
+    tagger_epoch = tag_data.get("tagger_epoch")
+    tagger_tz = tag_data.get("tagger_tz")
+    message = (tag_data.get("message") or "").strip()
+    tag_name = None
+    for name, _ref, oid in git_get_tags_list(project):
+        if oid == tag_oid:
+            tag_name = name
+            break
+    if tag_name is None:
+        tag_name = tag_oid[:7]
+    if mr is not None:
+        return merge_request_tag_response(
+            project=project,
+            repo=repo,
+            mr=mr,
+            tag_name=tag_name,
+            tag_oid=tag_oid,
+            tagger=tagger,
+            tagger_date=_format_date(tagger_epoch, tagger_tz),
+            notice=notice,
+        )
+    target_short = target_oid[:7] if target_oid else ""
+    if target_type == "commit":
+        object_value: Cell = html_link(
+            f"/project/{project}?a=commit&h={quote(target_oid, safe='')}",
+            target_short,
+        )
+    elif target_type == "tree":
+        object_value = html_link(
+            f"/project/{project}?a=tree&h={quote(target_oid, safe='')}",
+            target_short,
+        )
+    else:
+        object_value = target_short
+    table_rows: list[list[Cell]] = [
+        ["Tag", tag_name or ""],
+        ["Object", html_join([object_value, " (", target_type, ")"])],
+        ["Tagger", tagger],
+        ["Date", _format_date(tagger_epoch, tagger_tz)],
+    ]
+    if message:
+        table_rows.append(["Message", html_format("<pre class='tag-message'>{}</pre>", message)])
+    return _table_page(
+        title=f"Tag {tag_name} - {project}",
+        heading=f"Tag {tag_name}",
+        project=project,
+        cols=["Field", "Value"],
+        rows=table_rows,
+    )
 
 
 def git_commit(project: str, h: str | None) -> HTMLResponse:
-	"""Single commit view. Port of git_commit (git show)."""
-	if not h:
-		raise HTTPException(status_code=400, detail="Commit hash (h) required")
-	if not is_valid_ref_format(h):
-		raise HTTPException(status_code=400, detail="Invalid ref or hash")
-	try:
-		repo = open_repo(project)
-		obj = repo.revparse_single(h)
-	except (KeyError, pygit2.GitError, OSError) as e:
-		raise HTTPException(status_code=404, detail="Commit not found") from e
-	if not isinstance(obj, pygit2.Commit):
-		raise HTTPException(status_code=404, detail="Not a commit object")
-	commit = obj
-	oid = str(commit.id)
-	oid_short = oid[:7]
-	data = parse_commit(project, oid)
-	author = data.get("author", "")
-	author_email = data.get("author_email", "")
-	author_date = _format_date(data.get("author_epoch"), data.get("author_tz"))
-	tree_oid = data.get("tree", "")
-	message = (data.get("body") or "").strip()
-	commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
-	tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
-	diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
-	patch_link = f"/project/{project}?a=patch&h={quote(oid, safe='')}"
-	table_rows: list[list[Cell]] = [
-		["commit", html_link(commit_link, oid)],
-		["Author", html_join([author, " <", author_email, ">"])],
-		["Date", author_date],
-		["tree", html_link(tree_link, tree_oid[:7])],
-	]
-	for i, parent_id in enumerate(commit.parent_ids):
-		parent_oid = str(parent_id)
-		parent_short = parent_oid[:7]
-		parent_href = f"/project/{project}?a=commit&h={quote(parent_oid, safe='')}"
-		label = "parent" if len(commit.parent_ids) == 1 else f"parent ({i + 1})"
-		table_rows.append([label, html_link(parent_href, parent_short)])
-	if message:
-		table_rows.append(["", html_format("<pre class='commit-message'>{}</pre>", message)])
-	table_rows.append(["", html_join([html_link(diff_link, "diff"), " · ", html_link(patch_link, "patch")])])
-	return _table_page(
-		title=f"Commit {oid_short} - {project}",
-		heading=f"Commit {oid_short}",
-		project=project,
-		cols=["Field", "Value"],
-		rows=table_rows,
-	)
+    """Single commit view. Port of git_commit (git show)."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(h)
+    except (KeyError, pygit2.GitError, OSError) as e:
+        raise HTTPException(status_code=404, detail="Commit not found") from e
+    if not isinstance(obj, pygit2.Commit):
+        raise HTTPException(status_code=404, detail="Not a commit object")
+    commit = obj
+    oid = str(commit.id)
+    oid_short = oid[:7]
+    data = parse_commit(project, oid)
+    author = data.get("author", "")
+    author_email = data.get("author_email", "")
+    author_date = _format_date(data.get("author_epoch"), data.get("author_tz"))
+    tree_oid = data.get("tree", "")
+    message = (data.get("body") or "").strip()
+    commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
+    tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
+    diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
+    patch_link = f"/project/{project}?a=patch&h={quote(oid, safe='')}"
+    table_rows: list[list[Cell]] = [
+        ["commit", html_link(commit_link, oid)],
+        ["Author", html_join([author, " <", author_email, ">"])],
+        ["Date", author_date],
+        ["tree", html_link(tree_link, tree_oid[:7])],
+    ]
+    for i, parent_id in enumerate(commit.parent_ids):
+        parent_oid = str(parent_id)
+        parent_short = parent_oid[:7]
+        parent_href = f"/project/{project}?a=commit&h={quote(parent_oid, safe='')}"
+        label = "parent" if len(commit.parent_ids) == 1 else f"parent ({i + 1})"
+        table_rows.append([label, html_link(parent_href, parent_short)])
+    if message:
+        table_rows.append(["", html_format("<pre class='commit-message'>{}</pre>", message)])
+    table_rows.append(["", html_join([html_link(diff_link, "diff"), " · ", html_link(patch_link, "patch")])])
+    return _table_page(
+        title=f"Commit {oid_short} - {project}",
+        heading=f"Commit {oid_short}",
+        project=project,
+        cols=["Field", "Value"],
+        rows=table_rows,
+    )
 
 
 def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
-	"""Commit diff page: unified diff rendered with diff2html."""
-	diff_text, oid_short = _commit_unified_diff_or_raise(project, h or "")
-	diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
-	return render_page(
-		"commitdiff.html",
-		title=f"Commit diff {oid_short} - {project}",
-		project=project,
-		oid_short=oid_short,
-		diff_b64=diff_b64,
-		commit_link=f"/project/{project}?a=commit&h={quote(h or '', safe='')}",
-	)
+    """Commit diff page: unified diff rendered with diff2html."""
+    diff_text, oid_short = _commit_unified_diff_or_raise(project, h or "")
+    diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
+    return render_page(
+        "commitdiff.html",
+        title=f"Commit diff {oid_short} - {project}",
+        project=project,
+        oid_short=oid_short,
+        diff_b64=diff_b64,
+        commit_link=f"/project/{project}?a=commit&h={quote(h or '', safe='')}",
+    )
 
 
 def git_patch(project: str, h: str | None) -> PlainTextResponse:
-	"""Single-commit patch (plain text unified diff)."""
-	if not h:
-		raise HTTPException(status_code=400, detail="Commit hash (h) required")
-	body, oid_short = _commit_unified_diff_or_raise(project, h)
-	filename = f"{project}-{oid_short}.patch"
-	return PlainTextResponse(
-		body,
-		media_type="text/x-diff; charset=utf-8",
-		headers={"Content-Disposition": f'inline; filename="{filename}"'},
-	)
+    """Single-commit patch (plain text unified diff)."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    body, oid_short = _commit_unified_diff_or_raise(project, h)
+    filename = f"{project}-{oid_short}.patch"
+    return PlainTextResponse(
+        body,
+        media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+    )
 
 
 def git_patches(project: str, h: str | None, hb: str | None) -> PlainTextResponse:
-	"""Multi-commit patches (plain text). Range hb..h if hb given, else single commit."""
-	if not h:
-		raise HTTPException(status_code=400, detail="Commit hash (h) required")
-	if hb:
-		oids = get_commits_in_range(project, h, hb)
-		if not oids:
-			raise HTTPException(status_code=404, detail="No commits in range")
-		parts = []
-		for oid in oids:
-			try:
-				diff, _ = get_commit_unified_diff(project, oid)
-				if diff:
-					parts.append(diff)
-			except (KeyError, pygit2.GitError, OSError, ValueError):
-				pass
-		body = "\n".join(parts)
-		filename = f"{project}-{h[:7]}-patches.patch"
-	else:
-		body, oid_short = _commit_unified_diff_or_raise(project, h)
-		filename = f"{project}-{oid_short}.patch"
-	return PlainTextResponse(
-		body,
-		media_type="text/x-diff; charset=utf-8",
-		headers={"Content-Disposition": f'inline; filename="{filename}"'},
-	)
+    """Multi-commit patches (plain text). Range hb..h if hb given, else single commit."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    if hb:
+        oids = get_commits_in_range(project, h, hb)
+        if not oids:
+            raise HTTPException(status_code=404, detail="No commits in range")
+        parts = []
+        for oid in oids:
+            try:
+                diff, _ = get_commit_unified_diff(project, oid)
+                if diff:
+                    parts.append(diff)
+            except (KeyError, pygit2.GitError, OSError, ValueError):
+                pass
+        body = "\n".join(parts)
+        filename = f"{project}-{h[:7]}-patches.patch"
+    else:
+        body, oid_short = _commit_unified_diff_or_raise(project, h)
+        filename = f"{project}-{oid_short}.patch"
+    return PlainTextResponse(
+        body,
+        media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+    )
 
 
 def git_blobdiff(
-	project: str,
-	h: str | None,
-	hb: str | None,
-	f: str | None,
-	fp: str | None,
+    project: str,
+    h: str | None,
+    hb: str | None,
+    f: str | None,
+    fp: str | None,
 ) -> HTMLResponse:
-	"""Blob diff page: diff between two blob versions, rendered with diff2html."""
-	if not h or not hb:
-		raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
-	if not f:
-		raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
-	if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
-		raise HTTPException(status_code=400, detail="Invalid ref or hash")
-	path_new = f or ""
-	path_old = fp if fp is not None else path_new
-	if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
-		raise HTTPException(status_code=400, detail="Invalid path")
-	diff_text = get_blob_unified_diff(project, hb, path_old, h, path_new)
-	if diff_text is None:
-		raise HTTPException(status_code=404, detail="Blob not found")
-	diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
-	return render_page(
-		"blobdiff.html",
-		title=f"Blob diff - {path_new} - {project}",
-		project=project,
-		path_new=path_new,
-		diff_b64=diff_b64,
-		blob_link=f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}",
-	)
+    """Blob diff page: diff between two blob versions, rendered with diff2html."""
+    if not h or not hb:
+        raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
+    if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    path_new = f or ""
+    path_old = fp if fp is not None else path_new
+    if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    diff_text = get_blob_unified_diff(project, hb, path_old, h, path_new)
+    if diff_text is None:
+        raise HTTPException(status_code=404, detail="Blob not found")
+    diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
+    return render_page(
+        "blobdiff.html",
+        title=f"Blob diff - {path_new} - {project}",
+        project=project,
+        path_new=path_new,
+        diff_b64=diff_b64,
+        blob_link=f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}",
+    )
 
 
 def git_blobpatch(
-	project: str,
-	h: str | None,
-	hb: str | None,
-	f: str | None,
-	fp: str | None,
+    project: str,
+    h: str | None,
+    hb: str | None,
+    f: str | None,
+    fp: str | None,
 ) -> PlainTextResponse:
-	"""Blob diff as plain unified diff."""
-	if not h or not hb:
-		raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
-	if not f:
-		raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
-	if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
-		raise HTTPException(status_code=400, detail="Invalid ref or hash")
-	path_new = f or ""
-	path_old = fp if fp is not None else path_new
-	if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
-		raise HTTPException(status_code=400, detail="Invalid path")
-	body = get_blob_unified_diff(project, hb, path_old, h, path_new)
-	if body is None:
-		raise HTTPException(status_code=404, detail="Blob not found")
-	filename = f"{path_new.split('/')[-1] or 'blob'}.patch"
-	return PlainTextResponse(
-		body,
-		media_type="text/x-diff; charset=utf-8",
-		headers={"Content-Disposition": f'inline; filename="{filename}"'},
-	)
+    """Blob diff as plain unified diff."""
+    if not h or not hb:
+        raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
+    if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    path_new = f or ""
+    path_old = fp if fp is not None else path_new
+    if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    body = get_blob_unified_diff(project, hb, path_old, h, path_new)
+    if body is None:
+        raise HTTPException(status_code=404, detail="Blob not found")
+    filename = f"{path_new.split('/')[-1] or 'blob'}.patch"
+    return PlainTextResponse(
+        body,
+        media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+    )
diff --git a/pygitweb/src/pygitweb/addproject_permissions_test.py b/pygitweb/src/pygitweb/addproject_permissions_test.py
index 0529460..5bad196 100644
--- a/pygitweb/src/pygitweb/addproject_permissions_test.py
+++ b/pygitweb/src/pygitweb/addproject_permissions_test.py
@@ -9,105 +9,105 @@ from pygitweb.main import app
 from pygitweb.permissions import PERMISSION_ADD_PROJECTS, permissions_with_defaults
 
 _ADDPROJECTS_AUTH = AuthConfig(
-	auth_mode="local",
-	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=permissions_with_defaults({
-		PERMISSION_ADD_PROJECTS: ["admin"],
-	}),
+    auth_mode="local",
+    local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
+    oauth_permissions=permissions_with_defaults({
+        PERMISSION_ADD_PROJECTS: ["admin"],
+    }),
 )
 
 
 @pytest.fixture
 def client() -> Generator[TestClient, None, None]:
-	with TestClient(app) as c:
-		yield c
+    with TestClient(app) as c:
+        yield c
 
 
 @pytest.fixture
 def addprojects_auth() -> Generator[AuthConfig, None, None]:
-	with (
-		patch("pygitweb.auth_config.auth_config", _ADDPROJECTS_AUTH),
-		patch("pygitweb.auth.auth_config", _ADDPROJECTS_AUTH),
-		patch("pygitweb.main.auth_config", _ADDPROJECTS_AUTH),
-	):
-		yield _ADDPROJECTS_AUTH
+    with (
+        patch("pygitweb.auth_config.auth_config", _ADDPROJECTS_AUTH),
+        patch("pygitweb.auth.auth_config", _ADDPROJECTS_AUTH),
+        patch("pygitweb.main.auth_config", _ADDPROJECTS_AUTH),
+    ):
+        yield _ADDPROJECTS_AUTH
 
 
 def _login(client: TestClient, username: str) -> None:
-	client.post(
-		"/login",
-		data={"username": username, "password": "secret", "next": "/"},
-		follow_redirects=False,
-	)
+    client.post(
+        "/login",
+        data={"username": username, "password": "secret", "next": "/"},
+        follow_redirects=False,
+    )
 
 
 def test_projectnamevalid_open_when_auth_disabled(client: TestClient) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with (
-		patch.object(settings, "AUTH", False),
-		patch("pygitweb.main.project_visible_in_list", return_value=False),
-	):
-		r = client.get("/projectnamevalid", params={"name": "newproj"})
-	assert r.status_code == 200
+    with (
+        patch.object(settings, "AUTH", False),
+        patch("pygitweb.main.project_visible_in_list", return_value=False),
+    ):
+        r = client.get("/projectnamevalid", params={"name": "newproj"})
+    assert r.status_code == 200
 
 
 def test_addproject_page_open_when_auth_disabled(client: TestClient) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with patch.object(settings, "AUTH", False):
-		r = client.get("/addproject")
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", False):
+        r = client.get("/addproject")
+    assert r.status_code == 200
 
 
 def test_projectnamevalid_401_without_login(client: TestClient, addprojects_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with patch.object(settings, "AUTH", True):
-		r = client.get("/projectnamevalid", params={"name": "newproj"})
-	assert r.status_code == 401
+    with patch.object(settings, "AUTH", True):
+        r = client.get("/projectnamevalid", params={"name": "newproj"})
+    assert r.status_code == 401
 
 
 def test_projectnamevalid_allowed_with_grant(client: TestClient, addprojects_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.main.project_visible_in_list", return_value=False),
-	):
-		_login(client, "admin")
-		r = client.get("/projectnamevalid", params={"name": "newproj"})
-	assert r.status_code == 200
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.main.project_visible_in_list", return_value=False),
+    ):
+        _login(client, "admin")
+        r = client.get("/projectnamevalid", params={"name": "newproj"})
+    assert r.status_code == 200
 
 
 def test_projectnamevalid_forbidden_without_grant(client: TestClient, addprojects_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.main.project_visible_in_list", return_value=False),
-	):
-		_login(client, "viewer")
-		r = client.get("/projectnamevalid", params={"name": "newproj"})
-	assert r.status_code == 403
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.main.project_visible_in_list", return_value=False),
+    ):
+        _login(client, "viewer")
+        r = client.get("/projectnamevalid", params={"name": "newproj"})
+    assert r.status_code == 403
 
 
 def test_addproject_page_readable_without_grant(client: TestClient, addprojects_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with patch.object(settings, "AUTH", True):
-		_login(client, "viewer")
-		r = client.get("/addproject")
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", True):
+        _login(client, "viewer")
+        r = client.get("/addproject")
+    assert r.status_code == 200
 
 
 def test_addproject_post_forbidden_without_grant(client: TestClient, addprojects_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
-
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.main.project_visible_in_list", return_value=False),
-	):
-		_login(client, "viewer")
-		r = client.post("/addproject", data={"project_name": "newproj"}, follow_redirects=False)
-	assert r.status_code == 403
+    from pygitweb.config import settings
+
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.main.project_visible_in_list", return_value=False),
+    ):
+        _login(client, "viewer")
+        r = client.post("/addproject", data={"project_name": "newproj"}, follow_redirects=False)
+    assert r.status_code == 403
diff --git a/pygitweb/src/pygitweb/api/action.py b/pygitweb/src/pygitweb/api/action.py
index 7b3c257..73e8cb1 100644
--- a/pygitweb/src/pygitweb/api/action.py
+++ b/pygitweb/src/pygitweb/api/action.py
@@ -9,13 +9,13 @@ from starlette.responses import Response
 
 @runtime_checkable
 class Action(Protocol, metaclass=AnnotatedProtocolMeta):
-	"""
-	Action: A per-project action provided by a plugin.
+    """
+    Action: A per-project action provided by a plugin.
 
-	``project`` is the project path/slug as used in URLs (under PROJECTROOT).
-	Subclass this protocol, or register a virtual subclass with ``@Action.register``.
-	"""
+    ``project`` is the project path/slug as used in URLs (under PROJECTROOT).
+    Subclass this protocol, or register a virtual subclass with ``@Action.register``.
+    """
 
-	def action_name(self) -> str: ...
+    def action_name(self) -> str: ...
 
-	def action(self, project: str, req: Request | None = None) -> Response | None: ...
+    def action(self, project: str, req: Request | None = None) -> Response | None: ...
diff --git a/pygitweb/src/pygitweb/api/plugins/project_zip.py b/pygitweb/src/pygitweb/api/plugins/project_zip.py
index f5c4335..4fa1957 100644
--- a/pygitweb/src/pygitweb/api/plugins/project_zip.py
+++ b/pygitweb/src/pygitweb/api/plugins/project_zip.py
@@ -10,62 +10,62 @@ from pygitweb.tasks import create_board_for_project
 
 
 class ProjectZip(Project):
-	def __init__(
-		self,
-		path: os.PathLike[str] | str,
-		zip_content: bytes,
-		create_task_board: bool = False,
-		do_maintenance: bool = False,
-	) -> None:
-		self.path: str = str(Path(path))
-		with tempfile.TemporaryDirectory() as tmpdir:
-			zip_path = os.path.join(tmpdir, "repo.zip")
-			with open(zip_path, "wb") as file_obj:
-				file_obj.write(zip_content)
-			with zipfile.ZipFile(zip_path, "r") as zip_file:
-				zip_file.extractall(tmpdir)
-			repo_root: str = self._discover_repo_root(tmpdir)
-			super().__init__(
-				path=self.path,
-				remote_url=repo_root,
-				create_task_board=False,
-				do_maintenance=do_maintenance,
-			)
-		if create_task_board:
-			create_board_for_project(Path(self.path).name, name="Tasks", description="")
+    def __init__(
+        self,
+        path: os.PathLike[str] | str,
+        zip_content: bytes,
+        create_task_board: bool = False,
+        do_maintenance: bool = False,
+    ) -> None:
+        self.path: str = str(Path(path))
+        with tempfile.TemporaryDirectory() as tmpdir:
+            zip_path = os.path.join(tmpdir, "repo.zip")
+            with open(zip_path, "wb") as file_obj:
+                file_obj.write(zip_content)
+            with zipfile.ZipFile(zip_path, "r") as zip_file:
+                zip_file.extractall(tmpdir)
+            repo_root: str = self._discover_repo_root(tmpdir)
+            super().__init__(
+                path=self.path,
+                remote_url=repo_root,
+                create_task_board=False,
+                do_maintenance=do_maintenance,
+            )
+        if create_task_board:
+            create_board_for_project(Path(self.path).name, name="Tasks", description="")
 
-	@staticmethod
-	def _discover_repo_root(tmpdir: str) -> str:
-		for name in os.listdir(tmpdir):
-			if name == "repo.zip":
-				continue
-			candidate = os.path.join(tmpdir, name)
-			if os.path.isdir(candidate) and os.path.isdir(os.path.join(candidate, ".git")):
-				return candidate
-		if os.path.isdir(os.path.join(tmpdir, ".git")):
-			return tmpdir
-		subdirs: list[str] = [
-			name for name in os.listdir(tmpdir) if name != "repo.zip" and os.path.isdir(os.path.join(tmpdir, name))
-		]
-		if len(subdirs) == 1:
-			return os.path.join(tmpdir, subdirs[0])
-		for name in os.listdir(tmpdir):
-			if name != "repo.zip":
-				candidate = os.path.join(tmpdir, name)
-				if os.path.isdir(candidate):
-					return candidate
-		return tmpdir
+    @staticmethod
+    def _discover_repo_root(tmpdir: str) -> str:
+        for name in os.listdir(tmpdir):
+            if name == "repo.zip":
+                continue
+            candidate = os.path.join(tmpdir, name)
+            if os.path.isdir(candidate) and os.path.isdir(os.path.join(candidate, ".git")):
+                return candidate
+        if os.path.isdir(os.path.join(tmpdir, ".git")):
+            return tmpdir
+        subdirs: list[str] = [
+            name for name in os.listdir(tmpdir) if name != "repo.zip" and os.path.isdir(os.path.join(tmpdir, name))
+        ]
+        if len(subdirs) == 1:
+            return os.path.join(tmpdir, subdirs[0])
+        for name in os.listdir(tmpdir):
+            if name != "repo.zip":
+                candidate = os.path.join(tmpdir, name)
+                if os.path.isdir(candidate):
+                    return candidate
+        return tmpdir
 
-	@classmethod
-	def form_content(cls) -> str:
-		return (
-			'<label class="form-label" for="repo_zip">Upload .zip (git repo)</label>'
-			'<div class="dropzone border rounded p-4 text-center" id="dropzone" style="min-height: 120px;">'
-			'<input type="file" class="form-control d-none" id="repo_zip" name="repo_zip" accept=".zip" '
-			"data-dropzone-target>"
-			'<div class="dropzone-placeholder text-muted" data-dropzone-placeholder>'
-			"Drag a .zip file here or click to choose."
-			"</div>"
-			'<div class="dropzone-filename d-none text-start small mt-2" data-dropzone-filename></div>'
-			"</div>"
-		)
+    @classmethod
+    def form_content(cls) -> str:
+        return (
+            '<label class="form-label" for="repo_zip">Upload .zip (git repo)</label>'
+            '<div class="dropzone border rounded p-4 text-center" id="dropzone" style="min-height: 120px;">'
+            '<input type="file" class="form-control d-none" id="repo_zip" name="repo_zip" accept=".zip" '
+            "data-dropzone-target>"
+            '<div class="dropzone-placeholder text-muted" data-dropzone-placeholder>'
+            "Drag a .zip file here or click to choose."
+            "</div>"
+            '<div class="dropzone-filename d-none text-start small mt-2" data-dropzone-filename></div>'
+            "</div>"
+        )
diff --git a/pygitweb/src/pygitweb/api/project.py b/pygitweb/src/pygitweb/api/project.py
index 9600cbe..f9d299d 100644
--- a/pygitweb/src/pygitweb/api/project.py
+++ b/pygitweb/src/pygitweb/api/project.py
@@ -13,55 +13,55 @@ from pygitweb.tasks import create_board_for_project
 
 
 class Project:
-	"""
-	Plugin API: Project - the root class for a project which is always a directory, and _may_ contain a git repository.
-	"""
+    """
+    Plugin API: Project - the root class for a project which is always a directory, and _may_ contain a git repository.
+    """
 
-	def __init__(
-		self,
-		path: os.PathLike[str] | str,
-		remote_url: str | None = None,
-		create_task_board: bool = False,
-		do_maintenance: bool = False,
-	) -> None:
-		self.path: str = str(Path(path))
-		self.remote_url: str | None = remote_url
-		os.makedirs(self.path)
-		if self.remote_url:
-			pygit2.clone_repository(self.remote_url, self.path, bare=True)
-		else:
-			pygit2.init_repository(self.path, bare=True)
-		self.repo: pygit2.Repository = pygit2.Repository(self.path)
-		if create_task_board:
-			create_board_for_project(Path(self.path).name, name="Tasks", description="")
-		if do_maintenance:
-			with contextlib.suppress(subprocess.SubprocessError, FileNotFoundError):
-				subprocess.run(
-					[settings.GIT, "-C", self.path, "maintenance", "start"],
-					capture_output=True,
-					timeout=60,
-				)
+    def __init__(
+        self,
+        path: os.PathLike[str] | str,
+        remote_url: str | None = None,
+        create_task_board: bool = False,
+        do_maintenance: bool = False,
+    ) -> None:
+        self.path: str = str(Path(path))
+        self.remote_url: str | None = remote_url
+        os.makedirs(self.path)
+        if self.remote_url:
+            pygit2.clone_repository(self.remote_url, self.path, bare=True)
+        else:
+            pygit2.init_repository(self.path, bare=True)
+        self.repo: pygit2.Repository = pygit2.Repository(self.path)
+        if create_task_board:
+            create_board_for_project(Path(self.path).name, name="Tasks", description="")
+        if do_maintenance:
+            with contextlib.suppress(subprocess.SubprocessError, FileNotFoundError):
+                subprocess.run(
+                    [settings.GIT, "-C", self.path, "maintenance", "start"],
+                    capture_output=True,
+                    timeout=60,
+                )
 
-	def __enter__(self) -> Project:
-		return self
+    def __enter__(self) -> Project:
+        return self
 
-	def __exit__(
-		self,
-		exc_type: type[BaseException] | None,
-		exc_value: BaseException | None,
-		traceback: TracebackType | None,
-	) -> None:
-		self.repo.free()
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_value: BaseException | None,
+        traceback: TracebackType | None,
+    ) -> None:
+        self.repo.free()
 
-	def __del__(self) -> None:
-		self.repo.free()
+    def __del__(self) -> None:
+        self.repo.free()
 
-	@classmethod
-	def form_content(cls) -> str:
-		return (
-			'<label class="form-label" for="pull_from_remote">Pull from remote</label>'
-			'<input type="url" class="form-control" id="pull_from_remote" name="pull_from_remote" '
-			'placeholder="https://example.com/repo.git or ssh://git@host/repo.git or git://host/repo.git">'
-			'<small class="form-hint">Optional. Use an <code>ssh://</code>, <code>git://</code>, '
-			"or <code>https://</code> URL to clone an existing repository.</small>"
-		)
+    @classmethod
+    def form_content(cls) -> str:
+        return (
+            '<label class="form-label" for="pull_from_remote">Pull from remote</label>'
+            '<input type="url" class="form-control" id="pull_from_remote" name="pull_from_remote" '
+            'placeholder="https://example.com/repo.git or ssh://git@host/repo.git or git://host/repo.git">'
+            '<small class="form-hint">Optional. Use an <code>ssh://</code>, <code>git://</code>, '
+            "or <code>https://</code> URL to clone an existing repository.</small>"
+        )
diff --git a/pygitweb/src/pygitweb/api/subpage.py b/pygitweb/src/pygitweb/api/subpage.py
index 8c6850a..52394d0 100644
--- a/pygitweb/src/pygitweb/api/subpage.py
+++ b/pygitweb/src/pygitweb/api/subpage.py
@@ -8,56 +8,56 @@ _DEFAULT_METHODS = ("link_name", "subpage_html", "summary_value_suffix_html")
 
 
 def _install_default_methods(interface: type, impl: type) -> None:
-	"""Give implementations the protocol's optional methods before signature checks."""
-	if getattr(impl, "_is_protocol", False):
-		return
-	skip = set(interface.__mro__)
-	for name in _DEFAULT_METHODS:
-		if any(name in base.__dict__ for base in impl.__mro__ if base not in skip):
-			continue
-		for base in interface.__mro__:
-			default = base.__dict__.get(name)
-			if callable(default):
-				setattr(impl, name, default)
-				break
+    """Give implementations the protocol's optional methods before signature checks."""
+    if getattr(impl, "_is_protocol", False):
+        return
+    skip = set(interface.__mro__)
+    for name in _DEFAULT_METHODS:
+        if any(name in base.__dict__ for base in impl.__mro__ if base not in skip):
+            continue
+        for base in interface.__mro__:
+            default = base.__dict__.get(name)
+            if callable(default):
+                setattr(impl, name, default)
+                break
 
 
 class _SubpageMeta(AnnotatedProtocolMeta):
-	def __init__(
-		cls,
-		name: str,
-		bases: tuple[type, ...],
-		namespace: dict[str, object],
-		**kwargs: object,
-	) -> None:
-		if not getattr(cls, "_is_protocol", False):
-			for base in bases:
-				if isinstance(base, _SubpageMeta) and getattr(base, "_is_protocol", False):
-					_install_default_methods(base, cls)
-		super().__init__(name, bases, namespace, **kwargs)
-
-	def register(cls, subclass: type) -> type:
-		_install_default_methods(cls, subclass)
-		super().register(subclass)
-		return subclass
+    def __init__(
+        cls,
+        name: str,
+        bases: tuple[type, ...],
+        namespace: dict[str, object],
+        **kwargs: object,
+    ) -> None:
+        if not getattr(cls, "_is_protocol", False):
+            for base in bases:
+                if isinstance(base, _SubpageMeta) and getattr(base, "_is_protocol", False):
+                    _install_default_methods(base, cls)
+        super().__init__(name, bases, namespace, **kwargs)
+
+    def register(cls, subclass: type) -> type:
+        _install_default_methods(cls, subclass)
+        super().register(subclass)
+        return subclass
 
 
 @runtime_checkable
 class Subpage(Protocol, metaclass=_SubpageMeta):
-	"""
-	Subpage: A per-project subpage provided by a plugin.
+    """
+    Subpage: A per-project subpage provided by a plugin.
 
-	Subclass this protocol, or register a virtual subclass with ``@Subpage.register``.
-	"""
+    Subclass this protocol, or register a virtual subclass with ``@Subpage.register``.
+    """
 
-	def subpage_name(self) -> str: ...
+    def subpage_name(self) -> str: ...
 
-	def link_name(self, project: str) -> str | None:
-		return None
+    def link_name(self, project: str) -> str | None:
+        return None
 
-	def subpage_html(self, project: str) -> str | None:
-		return None
+    def subpage_html(self, project: str) -> str | None:
+        return None
 
-	def summary_value_suffix_html(self, project: str, project_enc: str) -> str | None:
-		"""Optional HTML appended after the subpage \"view\" link on the project summary table."""
-		return None
+    def summary_value_suffix_html(self, project: str, project_enc: str) -> str | None:
+        """Optional HTML appended after the subpage \"view\" link on the project summary table."""
+        return None
diff --git a/pygitweb/src/pygitweb/auth.py b/pygitweb/src/pygitweb/auth.py
index d46fff7..69ed68e 100644
--- a/pygitweb/src/pygitweb/auth.py
+++ b/pygitweb/src/pygitweb/auth.py
@@ -30,22 +30,22 @@ from fastapi.security import APIKeyCookie, OAuth2PasswordBearer, OAuth2PasswordR
 from pydantic import BaseModel, Field, field_validator
 
 from pygitweb.auth_config import (
-	HttpKey,
-	LocalUser,
-	auth_config_path,
-	is_auth_configured,
-	is_local_auth_available,
-	is_oauth_auth_available,
-	write_auth_config,
+    HttpKey,
+    LocalUser,
+    auth_config_path,
+    is_auth_configured,
+    is_local_auth_available,
+    is_oauth_auth_available,
+    write_auth_config,
 )
 from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, oauth_router
 from pygitweb.config import auth_config, settings
 from pygitweb.gravatar import gravatar_url
 from pygitweb.password_hash import hash_password, verify_password
 from pygitweb.permissions import (
-	Permission,
-	PermissionPrincipal,
-	has_ref_write_permission,
+    Permission,
+    PermissionPrincipal,
+    has_ref_write_permission,
 )
 from pygitweb.projects import git_get_project_owner
 from pygitweb.sessions import AuthMethod, create_session, get_session, revoke_session
@@ -66,8 +66,8 @@ auth_router.include_router(oauth_router)
 
 
 class User(BaseModel):
-	username: str
-	email: str | None = None
+    username: str
+    email: str | None = None
 
 
 SmartHttpAuthError = Literal["unauthorized", "unavailable"]
@@ -76,890 +76,890 @@ _INVALID_KEY_ACTOR = "INVALID_KEY"
 
 @dataclass(frozen=True, slots=True)
 class SmartHttpCredentials:
-	required: bool
-	principal: PermissionPrincipal | None = None
-	readonly: bool = False
-	actor: str = ""
-	error: SmartHttpAuthError | None = None
+    required: bool
+    principal: PermissionPrincipal | None = None
+    readonly: bool = False
+    actor: str = ""
+    error: SmartHttpAuthError | None = None
 
 
 @dataclass(frozen=True, slots=True)
 class PresentedHttpKey:
-	principal: PermissionPrincipal
-	readonly: bool
-	key_id: str
-	expired: bool
+    principal: PermissionPrincipal
+    readonly: bool
+    key_id: str
+    expired: bool
 
 
 _SMART_HTTP_CHALLENGE = {"WWW-Authenticate": 'Basic realm="pygitweb", Bearer'}
 
 
 def principal_from_session(token: str | None) -> PermissionPrincipal | None:
-	if not token:
-		return None
-	rec = get_session(token)
-	if rec is None:
-		return None
-	return PermissionPrincipal(username=rec.username, email=rec.email)
+    if not token:
+        return None
+    rec = get_session(token)
+    if rec is None:
+        return None
+    return PermissionPrincipal(username=rec.username, email=rec.email)
 
 
 def _anonymous_principal_for_permissions() -> PermissionPrincipal | None:
-	for entry in auth_config.local_users:
-		if getattr(entry, "assume_anonymous", False):
-			return PermissionPrincipal(username=entry.user, email=entry.email)
-	return None
+    for entry in auth_config.local_users:
+        if getattr(entry, "assume_anonymous", False):
+            return PermissionPrincipal(username=entry.user, email=entry.email)
+    return None
 
 
 def _principal_identity_for_permissions(token: str | None) -> str | None:
-	principal = principal_from_session(token)
-	if principal is not None:
-		return principal.identity
-	anonymous = _anonymous_principal_for_permissions()
-	if anonymous is not None:
-		return anonymous.identity
-	return None
+    principal = principal_from_session(token)
+    if principal is not None:
+        return principal.identity
+    anonymous = _anonymous_principal_for_permissions()
+    if anonymous is not None:
+        return anonymous.identity
+    return None
 
 
 def has_permission(
-	permission: Permission,
-	project: str | None = None,
-	*,
-	token: str | None = None,
+    permission: Permission,
+    project: str | None = None,
+    *,
+    token: str | None = None,
 ) -> bool:
-	if not settings.AUTH:
-		return True
-	identity = _principal_identity_for_permissions(token)
-	if identity is None:
-		return False
-	return auth_config.oauth_permissions.has_permission(identity, permission, project)
+    if not settings.AUTH:
+        return True
+    identity = _principal_identity_for_permissions(token)
+    if identity is None:
+        return False
+    return auth_config.oauth_permissions.has_permission(identity, permission, project)
 
 
 def _owner_matches_principal(project: str, principal: PermissionPrincipal) -> bool:
-	if not settings.AUTH:
-		return False
-	owner = git_get_project_owner(project)
-	if not owner or not owner.strip():
-		return False
-	owner_norm = owner.strip()
-	identity = principal.identity
-	if hmac.compare_digest(owner_norm, identity):
-		return True
-	return owner_norm.lower() == identity.lower()
+    if not settings.AUTH:
+        return False
+    owner = git_get_project_owner(project)
+    if not owner or not owner.strip():
+        return False
+    owner_norm = owner.strip()
+    identity = principal.identity
+    if hmac.compare_digest(owner_norm, identity):
+        return True
+    return owner_norm.lower() == identity.lower()
 
 
 def _owner_matches(project: str, token: str | None) -> bool:
-	if not token:
-		return False
-	principal = principal_from_session(token)
-	if principal is None:
-		return False
-	return _owner_matches_principal(project, principal)
+    if not token:
+        return False
+    principal = principal_from_session(token)
+    if principal is None:
+        return False
+    return _owner_matches_principal(project, principal)
 
 
 def principal_can_read_project(project: str, principal: PermissionPrincipal) -> bool:
-	if not settings.AUTH:
-		return True
-	if _owner_matches_principal(project, principal):
-		return True
-	return auth_config.oauth_permissions.has_permission(principal.identity, Permission.READ, project)
+    if not settings.AUTH:
+        return True
+    if _owner_matches_principal(project, principal):
+        return True
+    return auth_config.oauth_permissions.has_permission(principal.identity, Permission.READ, project)
 
 
 def principal_can_write_project(project: str, principal: PermissionPrincipal) -> bool:
-	if not settings.AUTH:
-		return True
-	if _owner_matches_principal(project, principal):
-		return True
-	return auth_config.oauth_permissions.has_permission(principal.identity, Permission.WRITE, project)
+    if not settings.AUTH:
+        return True
+    if _owner_matches_principal(project, principal):
+        return True
+    return auth_config.oauth_permissions.has_permission(principal.identity, Permission.WRITE, project)
 
 
 def can_read_project(project: str, token: str | None) -> bool:
-	if not settings.AUTH:
-		return True
-	if _owner_matches(project, token):
-		return True
-	return has_permission(Permission.READ, project, token=token)
+    if not settings.AUTH:
+        return True
+    if _owner_matches(project, token):
+        return True
+    return has_permission(Permission.READ, project, token=token)
 
 
 def can_write_project(project: str, token: str | None) -> bool:
-	if not settings.AUTH:
-		return True
-	if _owner_matches(project, token):
-		return True
-	return has_permission(Permission.WRITE, project, token=token)
+    if not settings.AUTH:
+        return True
+    if _owner_matches(project, token):
+        return True
+    return has_permission(Permission.WRITE, project, token=token)
 
 
 def can_push_ref(project: str, principal: PermissionPrincipal, ref_name: str) -> bool:
-	"""Per-ref push check.
-
-	Project owners may push any ref. Otherwise, if any ``pgw.write_ref`` rule
-	applies to the project, only matching ``write_ref`` grants allow the push
-	(whitelist). With no ``write_ref`` rules for the project, project write is
-	enough. Names outside ``refs/`` are rejected.
-	"""
-	if not settings.AUTH:
-		return True
-	if _owner_matches_principal(project, principal):
-		return True
-	return has_ref_write_permission(
-		auth_config.oauth_permissions,
-		principal.identity,
-		project,
-		ref_name,
-	)
+    """Per-ref push check.
+
+    Project owners may push any ref. Otherwise, if any ``pgw.write_ref`` rule
+    applies to the project, only matching ``write_ref`` grants allow the push
+    (whitelist). With no ``write_ref`` rules for the project, project write is
+    enough. Names outside ``refs/`` are rejected.
+    """
+    if not settings.AUTH:
+        return True
+    if _owner_matches_principal(project, principal):
+        return True
+    return has_ref_write_permission(
+        auth_config.oauth_permissions,
+        principal.identity,
+        project,
+        ref_name,
+    )
 
 
 def ensure_read_project(project: str, token: str | None) -> None:
-	if can_read_project(project, token):
-		return
-	raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
+    if can_read_project(project, token):
+        return
+    raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
 
 
 def ensure_write_project(project: str, token: str | None) -> None:
-	if not settings.AUTH:
-		return
-	if not is_auth_configured(auth_config):
-		raise HTTPException(
-			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
-			detail="Authentication enabled but auth.json is not configured",
-		)
-	if not token:
-		raise HTTPException(
-			status_code=status.HTTP_401_UNAUTHORIZED,
-			detail="Not authenticated",
-			headers={"WWW-Authenticate": "Bearer"},
-		)
-	if decode_access_token(token) is None:
-		raise HTTPException(
-			status_code=status.HTTP_401_UNAUTHORIZED,
-			detail="Not authenticated",
-			headers={"WWW-Authenticate": "Bearer"},
-		)
-	if can_write_project(project, token):
-		return
-	raise HTTPException(
-		status_code=status.HTTP_403_FORBIDDEN,
-		detail="Insufficient permissions",
-	)
+    if not settings.AUTH:
+        return
+    if not is_auth_configured(auth_config):
+        raise HTTPException(
+            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+            detail="Authentication enabled but auth.json is not configured",
+        )
+    if not token:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    if decode_access_token(token) is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    if can_write_project(project, token):
+        return
+    raise HTTPException(
+        status_code=status.HTTP_403_FORBIDDEN,
+        detail="Insufficient permissions",
+    )
 
 
 def ensure_permission(
-	permission: Permission,
-	project: str | None = None,
-	*,
-	token: str | None,
+    permission: Permission,
+    project: str | None = None,
+    *,
+    token: str | None,
 ) -> None:
-	ensure_active_user_if_auth_enabled(token)
-	if project is not None:
-		ensure_read_project(project, token)
-	if has_permission(permission, project, token=token):
-		return
-	raise HTTPException(
-		status_code=status.HTTP_403_FORBIDDEN,
-		detail="Insufficient permissions",
-	)
+    ensure_active_user_if_auth_enabled(token)
+    if project is not None:
+        ensure_read_project(project, token)
+    if has_permission(permission, project, token=token):
+        return
+    raise HTTPException(
+        status_code=status.HTTP_403_FORBIDDEN,
+        detail="Insufficient permissions",
+    )
 
 
 def require_permission(
-	permission: Permission,
-	*,
-	project_from_query: bool = False,
-	project_from_form: bool = False,
+    permission: Permission,
+    *,
+    project_from_query: bool = False,
+    project_from_form: bool = False,
 ) -> Callable[..., None]:
-	"""Return a FastAPI dependency that enforces ``permission`` when auth is enabled."""
+    """Return a FastAPI dependency that enforces ``permission`` when auth is enabled."""
 
-	if project_from_query:
+    if project_from_query:
 
-		def _dep_query_project(
-			token: Annotated[str | None, Depends(access_token_from_request)],
-			project: Annotated[str, Query()],
-		) -> None:
-			ensure_permission(permission, project, token=token)
+        def _dep_query_project(
+            token: Annotated[str | None, Depends(access_token_from_request)],
+            project: Annotated[str, Query()],
+        ) -> None:
+            ensure_permission(permission, project, token=token)
 
-		return _dep_query_project
+        return _dep_query_project
 
-	if project_from_form:
+    if project_from_form:
 
-		def _dep_form_project(
-			token: Annotated[str | None, Depends(access_token_from_request)],
-			project: Annotated[str, Form()],
-		) -> None:
-			ensure_permission(permission, project, token=token)
+        def _dep_form_project(
+            token: Annotated[str | None, Depends(access_token_from_request)],
+            project: Annotated[str, Form()],
+        ) -> None:
+            ensure_permission(permission, project, token=token)
 
-		return _dep_form_project
+        return _dep_form_project
 
-	def _dep(
-		token: Annotated[str | None, Depends(access_token_from_request)],
-	) -> None:
-		ensure_permission(permission, None, token=token)
+    def _dep(
+        token: Annotated[str | None, Depends(access_token_from_request)],
+    ) -> None:
+        ensure_permission(permission, None, token=token)
 
-	return _dep
+    return _dep
 
 
 def get_local_user(username: str) -> User | None:
-	if not settings.AUTH or not is_local_auth_available(auth_config):
-		return None
-	for entry in auth_config.local_users:
-		if not entry.pass_hash and not entry.password:
-			continue
-		if hmac.compare_digest(entry.user, username):
-			return User(username=entry.user, email=entry.email)
-	return None
+    if not settings.AUTH or not is_local_auth_available(auth_config):
+        return None
+    for entry in auth_config.local_users:
+        if not entry.pass_hash and not entry.password:
+            continue
+        if hmac.compare_digest(entry.user, username):
+            return User(username=entry.user, email=entry.email)
+    return None
 
 
 def authenticate_local_user(username: str, password: str) -> User | None:
-	if auth_config.auth_mode not in ("local", "both"):
-		return None
-	for entry in auth_config.local_users:
-		if not hmac.compare_digest(entry.user, username):
-			continue
-		if entry.pass_hash:
-			if not verify_password(password, entry.pass_hash, config_salt=auth_config.salt):
-				return None
-			return User(username=entry.user, email=entry.email)
-		if entry.password is not None and hmac.compare_digest(entry.password, password):
-			return User(username=entry.user, email=entry.email)
-		return None
-	return None
+    if auth_config.auth_mode not in ("local", "both"):
+        return None
+    for entry in auth_config.local_users:
+        if not hmac.compare_digest(entry.user, username):
+            continue
+        if entry.pass_hash:
+            if not verify_password(password, entry.pass_hash, config_salt=auth_config.salt):
+                return None
+            return User(username=entry.user, email=entry.email)
+        if entry.password is not None and hmac.compare_digest(entry.password, password):
+            return User(username=entry.user, email=entry.email)
+        return None
+    return None
 
 
 def _const_eq(left: str, right: str) -> bool:
-	if len(left) != len(right):
-		return False
-	return hmac.compare_digest(left, right)
+    if len(left) != len(right):
+        return False
+    return hmac.compare_digest(left, right)
 
 
 def _http_key_username_matches(user: str, email: str | None, username: str) -> bool:
-	if _const_eq(user, username):
-		return True
-	if not email:
-		return False
-	return _const_eq(email.casefold(), username.casefold())
+    if _const_eq(user, username):
+        return True
+    if not email:
+        return False
+    return _const_eq(email.casefold(), username.casefold())
 
 
 def _key_owner_name(principal: PermissionPrincipal) -> str:
-	email = principal.email.strip() if principal.email else ""
-	return email or principal.username
+    email = principal.email.strip() if principal.email else ""
+    return email or principal.username
 
 
 def _http_key_actor(matched: PresentedHttpKey) -> str:
-	owner = _key_owner_name(matched.principal)
-	if matched.expired:
-		return f"{owner} {matched.key_id} (EXPIRED)"
-	return f"{owner} {matched.key_id}"
+    owner = _key_owner_name(matched.principal)
+    if matched.expired:
+        return f"{owner} {matched.key_id} (EXPIRED)"
+    return f"{owner} {matched.key_id}"
 
 
 def match_presented_http_key(username: str, password: str) -> PresentedHttpKey | None:
-	presented_user = username.strip()
-	presented_secret = password.strip()
-	if not presented_user or not presented_secret:
-		return None
-	now = datetime.now(UTC)
-	expired_match: PresentedHttpKey | None = None
-	for entry in auth_config.local_users:
-		if not _http_key_username_matches(entry.user, entry.email, presented_user):
-			continue
-		principal = PermissionPrincipal(username=entry.user, email=entry.email)
-		for key in entry.keys:
-			if not verify_password(presented_secret, key.secret, config_salt=auth_config.salt):
-				continue
-			if key.is_expired(now):
-				if expired_match is None:
-					expired_match = PresentedHttpKey(
-						principal=principal,
-						readonly=key.readonly,
-						key_id=key.id,
-						expired=True,
-					)
-				continue
-			return PresentedHttpKey(
-				principal=principal,
-				readonly=key.readonly,
-				key_id=key.id,
-				expired=False,
-			)
-	return expired_match
+    presented_user = username.strip()
+    presented_secret = password.strip()
+    if not presented_user or not presented_secret:
+        return None
+    now = datetime.now(UTC)
+    expired_match: PresentedHttpKey | None = None
+    for entry in auth_config.local_users:
+        if not _http_key_username_matches(entry.user, entry.email, presented_user):
+            continue
+        principal = PermissionPrincipal(username=entry.user, email=entry.email)
+        for key in entry.keys:
+            if not verify_password(presented_secret, key.secret, config_salt=auth_config.salt):
+                continue
+            if key.is_expired(now):
+                if expired_match is None:
+                    expired_match = PresentedHttpKey(
+                        principal=principal,
+                        readonly=key.readonly,
+                        key_id=key.id,
+                        expired=True,
+                    )
+                continue
+            return PresentedHttpKey(
+                principal=principal,
+                readonly=key.readonly,
+                key_id=key.id,
+                expired=False,
+            )
+    return expired_match
 
 
 def authenticate_http_key(username: str, password: str) -> tuple[PermissionPrincipal, bool] | None:
-	matched = match_presented_http_key(username, password)
-	if matched is None or matched.expired:
-		return None
-	return matched.principal, matched.readonly
+    matched = match_presented_http_key(username, password)
+    if matched is None or matched.expired:
+        return None
+    return matched.principal, matched.readonly
 
 
 def _parse_basic_authorization(value: str) -> tuple[str, str] | None:
-	scheme, _, param = value.strip().partition(" ")
-	if scheme.lower() != "basic" or not param.strip():
-		return None
-	try:
-		decoded = base64.b64decode(param.strip(), validate=True)
-		text = decoded.decode("utf-8")
-	except (binascii.Error, UnicodeDecodeError, ValueError):
-		return None
-	username, sep, password = text.partition(":")
-	if not sep:
-		return None
-	return username, password
+    scheme, _, param = value.strip().partition(" ")
+    if scheme.lower() != "basic" or not param.strip():
+        return None
+    try:
+        decoded = base64.b64decode(param.strip(), validate=True)
+        text = decoded.decode("utf-8")
+    except (binascii.Error, UnicodeDecodeError, ValueError):
+        return None
+    username, sep, password = text.partition(":")
+    if not sep:
+        return None
+    return username, password
 
 
 def unauthorized_smart_http() -> HTTPException:
-	return HTTPException(
-		status_code=status.HTTP_401_UNAUTHORIZED,
-		detail="Not authenticated",
-		headers=_SMART_HTTP_CHALLENGE,
-	)
+    return HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Not authenticated",
+        headers=_SMART_HTTP_CHALLENGE,
+    )
 
 
 def raise_for_smart_http_credentials(creds: SmartHttpCredentials) -> None:
-	if creds.error == "unavailable":
-		raise HTTPException(
-			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
-			detail="Authentication enabled but auth.json is not configured",
-		)
-	if creds.error == "unauthorized":
-		raise unauthorized_smart_http()
+    if creds.error == "unavailable":
+        raise HTTPException(
+            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+            detail="Authentication enabled but auth.json is not configured",
+        )
+    if creds.error == "unauthorized":
+        raise unauthorized_smart_http()
 
 
 def _assumable_public_username(project: str, *, write: bool) -> str:
-	if write:
-		return ""
-	anonymous = _anonymous_principal_for_permissions()
-	if anonymous is None or not can_read_project(project, None):
-		return ""
-	return anonymous.username
+    if write:
+        return ""
+    anonymous = _anonymous_principal_for_permissions()
+    if anonymous is None or not can_read_project(project, None):
+        return ""
+    return anonymous.username
 
 
 def load_smart_http_credentials(
-	authorization: str | None,
-	cookie_token: str | None,
-	*,
-	project: str,
-	write: bool,
+    authorization: str | None,
+    cookie_token: str | None,
+    *,
+    project: str,
+    write: bool,
 ) -> SmartHttpCredentials:
-	if not settings.AUTH:
-		return SmartHttpCredentials(required=False)
-	if not is_auth_configured(auth_config) and not any(user.keys for user in auth_config.local_users):
-		return SmartHttpCredentials(required=True, error="unavailable")
-	if authorization:
-		scheme, _, rest = authorization.strip().partition(" ")
-		scheme_name = scheme.lower()
-		if scheme_name == "basic":
-			parsed = _parse_basic_authorization(authorization)
-			if parsed is None:
-				return SmartHttpCredentials(required=True, actor=_INVALID_KEY_ACTOR, error="unauthorized")
-			matched = match_presented_http_key(parsed[0], parsed[1])
-			if matched is None:
-				return SmartHttpCredentials(required=True, actor=_INVALID_KEY_ACTOR, error="unauthorized")
-			actor = _http_key_actor(matched)
-			if matched.expired:
-				return SmartHttpCredentials(required=True, actor=actor, error="unauthorized")
-			return SmartHttpCredentials(
-				required=True,
-				principal=matched.principal,
-				readonly=matched.readonly,
-				actor=actor,
-			)
-		if scheme_name == "bearer":
-			bearer_principal = principal_from_session(rest.strip())
-			if bearer_principal is None:
-				return SmartHttpCredentials(required=True, error="unauthorized")
-			return SmartHttpCredentials(
-				required=True,
-				principal=bearer_principal,
-				readonly=False,
-				actor=_key_owner_name(bearer_principal),
-			)
-		return SmartHttpCredentials(required=True, error="unauthorized")
-	if cookie_token and cookie_token.strip():
-		cookie_principal = principal_from_session(cookie_token)
-		if cookie_principal is None:
-			return SmartHttpCredentials(required=True, error="unauthorized")
-		return SmartHttpCredentials(
-			required=True,
-			principal=cookie_principal,
-			readonly=False,
-			actor=_key_owner_name(cookie_principal),
-		)
-	return SmartHttpCredentials(required=True, actor=_assumable_public_username(project, write=write))
+    if not settings.AUTH:
+        return SmartHttpCredentials(required=False)
+    if not is_auth_configured(auth_config) and not any(user.keys for user in auth_config.local_users):
+        return SmartHttpCredentials(required=True, error="unavailable")
+    if authorization:
+        scheme, _, rest = authorization.strip().partition(" ")
+        scheme_name = scheme.lower()
+        if scheme_name == "basic":
+            parsed = _parse_basic_authorization(authorization)
+            if parsed is None:
+                return SmartHttpCredentials(required=True, actor=_INVALID_KEY_ACTOR, error="unauthorized")
+            matched = match_presented_http_key(parsed[0], parsed[1])
+            if matched is None:
+                return SmartHttpCredentials(required=True, actor=_INVALID_KEY_ACTOR, error="unauthorized")
+            actor = _http_key_actor(matched)
+            if matched.expired:
+                return SmartHttpCredentials(required=True, actor=actor, error="unauthorized")
+            return SmartHttpCredentials(
+                required=True,
+                principal=matched.principal,
+                readonly=matched.readonly,
+                actor=actor,
+            )
+        if scheme_name == "bearer":
+            bearer_principal = principal_from_session(rest.strip())
+            if bearer_principal is None:
+                return SmartHttpCredentials(required=True, error="unauthorized")
+            return SmartHttpCredentials(
+                required=True,
+                principal=bearer_principal,
+                readonly=False,
+                actor=_key_owner_name(bearer_principal),
+            )
+        return SmartHttpCredentials(required=True, error="unauthorized")
+    if cookie_token and cookie_token.strip():
+        cookie_principal = principal_from_session(cookie_token)
+        if cookie_principal is None:
+            return SmartHttpCredentials(required=True, error="unauthorized")
+        return SmartHttpCredentials(
+            required=True,
+            principal=cookie_principal,
+            readonly=False,
+            actor=_key_owner_name(cookie_principal),
+        )
+    return SmartHttpCredentials(required=True, actor=_assumable_public_username(project, write=write))
 
 
 def decode_access_token(token: str) -> User | None:
-	if not settings.AUTH:
-		return None
-	rec = get_session(token)
-	if rec is None:
-		return None
-	return User(username=rec.username)
+    if not settings.AUTH:
+        return None
+    rec = get_session(token)
+    if rec is None:
+        return None
+    return User(username=rec.username)
 
 
 def safe_next_url(next_raw: str | None) -> str:
-	n = (next_raw or "/").strip()
-	if not n.startswith("/") or n.startswith("//"):
-		return "/"
-	return n
+    n = (next_raw or "/").strip()
+    if not n.startswith("/") or n.startswith("//"):
+        return "/"
+    return n
 
 
 def access_token_from_request(
-	bearer: Annotated[str | None, Depends(oauth2_scheme)],
-	cookie_token: Annotated[str | None, Depends(_access_token_cookie)],
+    bearer: Annotated[str | None, Depends(oauth2_scheme)],
+    cookie_token: Annotated[str | None, Depends(_access_token_cookie)],
 ) -> str | None:
-	b = bearer.strip() if bearer else None
-	c = cookie_token.strip() if cookie_token else None
-	return b or c
+    b = bearer.strip() if bearer else None
+    c = cookie_token.strip() if cookie_token else None
+    return b or c
 
 
 def ensure_active_user_if_auth_enabled(token: str | None) -> None:
-	if not settings.AUTH:
-		return
-	if not is_auth_configured(auth_config):
-		raise HTTPException(
-			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
-			detail="Authentication enabled but auth.json is not configured",
-		)
-	if not token:
-		raise HTTPException(
-			status_code=status.HTTP_401_UNAUTHORIZED,
-			detail="Not authenticated",
-			headers={"WWW-Authenticate": "Bearer"},
-		)
-	if decode_access_token(token) is None:
-		raise HTTPException(
-			status_code=status.HTTP_401_UNAUTHORIZED,
-			detail="Not authenticated",
-			headers={"WWW-Authenticate": "Bearer"},
-		)
+    if not settings.AUTH:
+        return
+    if not is_auth_configured(auth_config):
+        raise HTTPException(
+            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+            detail="Authentication enabled but auth.json is not configured",
+        )
+    if not token:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    if decode_access_token(token) is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
 
 
 def require_active_user_if_auth_enabled(
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> None:
-	ensure_active_user_if_auth_enabled(token)
+    ensure_active_user_if_auth_enabled(token)
 
 
 @auth_router.get(
-	"/login",
-	response_class=HTMLResponse,
-	tags=[TAG_AUTH],
-	summary="Sign-in page",
-	description="HTML sign-in page. Shows local and/or OAuth options depending on auth.json.",
+    "/login",
+    response_class=HTMLResponse,
+    tags=[TAG_AUTH],
+    summary="Sign-in page",
+    description="HTML sign-in page. Shows local and/or OAuth options depending on auth.json.",
 )
 async def login_page(
-	next: Annotated[str, Query()] = "/",
+    next: Annotated[str, Query()] = "/",
 ) -> HTMLResponse:
-	if not settings.AUTH:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	next_safe = safe_next_url(next)
-	return render_page(
-		"login.html",
-		title=f"{settings.SITE_NAME} - Sign in",
-		next=next_safe,
-		error=None,
-		local_available=is_local_auth_available(auth_config),
-		oauth_available=is_oauth_auth_available(auth_config),
-		oauth_provider=str(auth_config.oauth_provider).strip() or "OAuth",
-	)
+    if not settings.AUTH:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+    next_safe = safe_next_url(next)
+    return render_page(
+        "login.html",
+        title=f"{settings.SITE_NAME} - Sign in",
+        next=next_safe,
+        error=None,
+        local_available=is_local_auth_available(auth_config),
+        oauth_available=is_oauth_auth_available(auth_config),
+        oauth_provider=str(auth_config.oauth_provider).strip() or "OAuth",
+    )
 
 
 @auth_router.post(
-	"/login",
-	response_model=None,
-	tags=[TAG_AUTH_LOCAL],
-	summary="Local sign-in (form)",
-	description="Browser form login. Sets the pygitweb_access_token session cookie on success.",
-	responses={303: {"description": "Redirect to next URL with session cookie set"}},
+    "/login",
+    response_model=None,
+    tags=[TAG_AUTH_LOCAL],
+    summary="Local sign-in (form)",
+    description="Browser form login. Sets the pygitweb_access_token session cookie on success.",
+    responses={303: {"description": "Redirect to next URL with session cookie set"}},
 )
 async def login_form_submit(
-	username: Annotated[str, Form()],
-	password: Annotated[str, Form()],
-	next: Annotated[str, Form()] = "/",
+    username: Annotated[str, Form()],
+    password: Annotated[str, Form()],
+    next: Annotated[str, Form()] = "/",
 ) -> HTMLResponse | RedirectResponse:
-	if not settings.AUTH:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	if not is_local_auth_available(auth_config):
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Local sign-in is not enabled")
-	next_safe = safe_next_url(next)
-	user = authenticate_local_user(username.strip(), password)
-	if user is None:
-		return render_page(
-			"login.html",
-			title=f"{settings.SITE_NAME} - Sign in",
-			next=next_safe,
-			error="Incorrect username or password.",
-			local_available=True,
-			oauth_available=is_oauth_auth_available(auth_config),
-			oauth_provider=str(auth_config.oauth_provider).strip() or "OAuth",
-		)
-	sid = create_session(user.username, email=user.email, auth_method="local")
-	resp = RedirectResponse(url=next_safe, status_code=303)
-	resp.set_cookie(
-		key=ACCESS_TOKEN_COOKIE_NAME,
-		value=sid,
-		httponly=True,
-		samesite="lax",
-		max_age=LOGIN_COOKIE_MAX_AGE,
-		path="/",
-	)
-	return resp
+    if not settings.AUTH:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+    if not is_local_auth_available(auth_config):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Local sign-in is not enabled")
+    next_safe = safe_next_url(next)
+    user = authenticate_local_user(username.strip(), password)
+    if user is None:
+        return render_page(
+            "login.html",
+            title=f"{settings.SITE_NAME} - Sign in",
+            next=next_safe,
+            error="Incorrect username or password.",
+            local_available=True,
+            oauth_available=is_oauth_auth_available(auth_config),
+            oauth_provider=str(auth_config.oauth_provider).strip() or "OAuth",
+        )
+    sid = create_session(user.username, email=user.email, auth_method="local")
+    resp = RedirectResponse(url=next_safe, status_code=303)
+    resp.set_cookie(
+        key=ACCESS_TOKEN_COOKIE_NAME,
+        value=sid,
+        httponly=True,
+        samesite="lax",
+        max_age=LOGIN_COOKIE_MAX_AGE,
+        path="/",
+    )
+    return resp
 
 
 @auth_router.get(
-	"/logout",
-	tags=[TAG_AUTH],
-	summary="Sign out",
-	description="Revokes the current session (cookie or Bearer) and clears auth cookies.",
-	responses={303: {"description": "Redirect to next URL"}},
+    "/logout",
+    tags=[TAG_AUTH],
+    summary="Sign out",
+    description="Revokes the current session (cookie or Bearer) and clears auth cookies.",
+    responses={303: {"description": "Redirect to next URL"}},
 )
 async def logout_page(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	next: Annotated[str, Query()] = "/",
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    next: Annotated[str, Query()] = "/",
 ) -> RedirectResponse:
-	revoke_session(token)
-	resp = RedirectResponse(url=safe_next_url(next), status_code=303)
-	resp.delete_cookie(key=ACCESS_TOKEN_COOKIE_NAME, path="/")
-	resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
-	return resp
+    revoke_session(token)
+    resp = RedirectResponse(url=safe_next_url(next), status_code=303)
+    resp.delete_cookie(key=ACCESS_TOKEN_COOKIE_NAME, path="/")
+    resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
+    return resp
 
 
 @auth_router.post(
-	"/token",
-	tags=[TAG_AUTH_LOCAL],
-	summary="Obtain session token (local)",
-	description=(
-		"OAuth2 password flow for API clients. Returns an opaque session id as access_token; "
-		"use Authorization: Bearer on protected routes. Not an identity-provider JWT."
-	),
+    "/token",
+    tags=[TAG_AUTH_LOCAL],
+    summary="Obtain session token (local)",
+    description=(
+        "OAuth2 password flow for API clients. Returns an opaque session id as access_token; "
+        "use Authorization: Bearer on protected routes. Not an identity-provider JWT."
+    ),
 )
 async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]:
-	if not settings.AUTH:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	if not is_local_auth_available(auth_config):
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Local sign-in is not enabled")
-	user = authenticate_local_user(form_data.username, form_data.password)
-	if user is None:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect username or password")
-	sid = create_session(user.username, email=user.email, auth_method="local")
-	return {"access_token": sid, "token_type": "bearer"}
+    if not settings.AUTH:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+    if not is_local_auth_available(auth_config):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Local sign-in is not enabled")
+    user = authenticate_local_user(form_data.username, form_data.password)
+    if user is None:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect username or password")
+    sid = create_session(user.username, email=user.email, auth_method="local")
+    return {"access_token": sid, "token_type": "bearer"}
 
 
 @auth_router.get(
-	"/auth/status",
-	tags=[TAG_AUTH],
-	summary="Auth status (navbar)",
-	description="Lightweight JSON for the navbar script. Does not return 401 when anonymous.",
+    "/auth/status",
+    tags=[TAG_AUTH],
+    summary="Auth status (navbar)",
+    description="Lightweight JSON for the navbar script. Does not return 401 when anonymous.",
 )
 async def auth_status(
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> dict[str, bool | str | None]:
-	if not settings.AUTH:
-		return {"auth_enabled": False, "username": None, "gravatar_url": None}
-	if not token:
-		return {"auth_enabled": True, "username": None, "gravatar_url": None}
-	rec = get_session(token)
-	if rec is None:
-		return {"auth_enabled": True, "username": None, "gravatar_url": None}
-	avatar = gravatar_url(rec.email) if rec.email else None
-	return {"auth_enabled": True, "username": rec.username, "gravatar_url": avatar}
+    if not settings.AUTH:
+        return {"auth_enabled": False, "username": None, "gravatar_url": None}
+    if not token:
+        return {"auth_enabled": True, "username": None, "gravatar_url": None}
+    rec = get_session(token)
+    if rec is None:
+        return {"auth_enabled": True, "username": None, "gravatar_url": None}
+    avatar = gravatar_url(rec.email) if rec.email else None
+    return {"auth_enabled": True, "username": rec.username, "gravatar_url": avatar}
 
 
 _API_KEY_LOCK = threading.Lock()
 
 
 class IssueApiKeyRequest(BaseModel):
-	id: str = Field(min_length=1)
-	readonly: bool = False
+    id: str = Field(min_length=1)
+    readonly: bool = False
 
-	@field_validator("id", mode="before")
-	@classmethod
-	def _strip_id(cls, value: object) -> object:
-		if not isinstance(value, str):
-			return value
-		stripped = value.strip()
-		if not stripped:
-			raise ValueError("must be a non-empty string")
-		return stripped
+    @field_validator("id", mode="before")
+    @classmethod
+    def _strip_id(cls, value: object) -> object:
+        if not isinstance(value, str):
+            return value
+        stripped = value.strip()
+        if not stripped:
+            raise ValueError("must be a non-empty string")
+        return stripped
 
 
 class IssuedApiKey(BaseModel):
-	id: str
-	secret: str
-	readonly: bool
-	time_issued: datetime
-	time_expiry: datetime | None = None
+    id: str
+    secret: str
+    readonly: bool
+    time_issued: datetime
+    time_expiry: datetime | None = None
 
 
 def _local_user_by_email(email: str | None) -> LocalUser | None:
-	if not email:
-		return None
-	folded = email.strip().casefold()
-	if not folded:
-		return None
-	for entry in auth_config.local_users:
-		if entry.email and _const_eq(entry.email.casefold(), folded):
-			return entry
-	return None
+    if not email:
+        return None
+    folded = email.strip().casefold()
+    if not folded:
+        return None
+    for entry in auth_config.local_users:
+        if entry.email and _const_eq(entry.email.casefold(), folded):
+            return entry
+    return None
 
 
 def _local_user_for_principal(username: str, email: str | None) -> LocalUser | None:
-	for entry in auth_config.local_users:
-		if _const_eq(entry.user, username):
-			return entry
-	return _local_user_by_email(email)
+    for entry in auth_config.local_users:
+        if _const_eq(entry.user, username):
+            return entry
+    return _local_user_by_email(email)
 
 
 def _key_owner(username: str, email: str | None, auth_method: AuthMethod) -> LocalUser | None:
-	if auth_method == "oauth":
-		return _local_user_by_email(email)
-	return _local_user_for_principal(username, email)
+    if auth_method == "oauth":
+        return _local_user_by_email(email)
+    return _local_user_for_principal(username, email)
 
 
 def _new_passwordless_user(email: str | None, auth_method: AuthMethod) -> LocalUser:
-	if auth_method != "oauth":
-		raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No local account for API keys")
-	stored = email.strip() if email else ""
-	if len(stored) < 2:
-		raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="An email is required to store API keys")
-	for entry in auth_config.local_users:
-		if _const_eq(entry.user.casefold(), stored.casefold()):
-			raise HTTPException(
-				status_code=status.HTTP_409_CONFLICT,
-				detail="A local account already uses this name",
-			)
-	return LocalUser(user=stored, email=stored)
+    if auth_method != "oauth":
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No local account for API keys")
+    stored = email.strip() if email else ""
+    if len(stored) < 2:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="An email is required to store API keys")
+    for entry in auth_config.local_users:
+        if _const_eq(entry.user.casefold(), stored.casefold()):
+            raise HTTPException(
+                status_code=status.HTTP_409_CONFLICT,
+                detail="A local account already uses this name",
+            )
+    return LocalUser(user=stored, email=stored)
 
 
 def _drop_local_user(user: LocalUser) -> None:
-	for index, entry in enumerate(auth_config.local_users):
-		if entry is user:
-			del auth_config.local_users[index]
-			return
+    for index, entry in enumerate(auth_config.local_users):
+        if entry is user:
+            del auth_config.local_users[index]
+            return
 
 
 def _key_expiry(issued: datetime) -> datetime | None:
-	days = settings.API_KEY_EXPIRY_DAYS
-	if days <= 0:
-		return None
-	return issued + timedelta(days=days)
+    days = settings.API_KEY_EXPIRY_DAYS
+    if days <= 0:
+        return None
+    return issued + timedelta(days=days)
 
 
 def _persist_auth_config() -> None:
-	write_auth_config(auth_config_path(settings), auth_config)
+    write_auth_config(auth_config_path(settings), auth_config)
 
 
 def _public_key_rows(user: LocalUser) -> list[dict[str, str | bool | None]]:
-	rows: list[dict[str, str | bool | None]] = []
-	for key in user.keys:
-		rows.append({
-			"id": key.id,
-			"readonly": key.readonly,
-			"time_issued": key.time_issued.isoformat(),
-			"time_expiry": key.time_expiry.isoformat() if key.time_expiry else None,
-		})
-	return rows
+    rows: list[dict[str, str | bool | None]] = []
+    for key in user.keys:
+        rows.append({
+            "id": key.id,
+            "readonly": key.readonly,
+            "time_issued": key.time_issued.isoformat(),
+            "time_expiry": key.time_expiry.isoformat() if key.time_expiry else None,
+        })
+    return rows
 
 
 def issue_api_key(
-	username: str,
-	email: str | None,
-	*,
-	key_id: str,
-	readonly: bool,
-	auth_method: AuthMethod = "local",
+    username: str,
+    email: str | None,
+    *,
+    key_id: str,
+    readonly: bool,
+    auth_method: AuthMethod = "local",
 ) -> IssuedApiKey:
-	with _API_KEY_LOCK:
-		user = _key_owner(username, email, auth_method)
-		created = False
-		if user is None:
-			user = _new_passwordless_user(email, auth_method)
-			created = True
-		if len(user.keys) >= settings.API_KEY_MAX_COUNT:
-			raise HTTPException(
-				status_code=status.HTTP_409_CONFLICT,
-				detail=f"API key limit reached ({settings.API_KEY_MAX_COUNT})",
-			)
-		if any(_const_eq(existing.id, key_id) for existing in user.keys):
-			raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An API key with this id already exists")
-		issued = datetime.now(UTC)
-		plaintext = secrets.token_urlsafe(32)
-		stored = HttpKey(
-			id=key_id,
-			secret=hash_password(plaintext, config_salt=auth_config.salt),
-			readonly=readonly,
-			time_issued=issued,
-			time_expiry=_key_expiry(issued),
-		)
-		if created:
-			auth_config.local_users.append(user)
-		previous = list(user.keys)
-		user.keys = [*previous, stored]
-		try:
-			_persist_auth_config()
-		except Exception:
-			if created:
-				_drop_local_user(user)
-			else:
-				user.keys = previous
-			raise
-		return IssuedApiKey(
-			id=stored.id,
-			secret=plaintext,
-			readonly=stored.readonly,
-			time_issued=stored.time_issued,
-			time_expiry=stored.time_expiry,
-		)
+    with _API_KEY_LOCK:
+        user = _key_owner(username, email, auth_method)
+        created = False
+        if user is None:
+            user = _new_passwordless_user(email, auth_method)
+            created = True
+        if len(user.keys) >= settings.API_KEY_MAX_COUNT:
+            raise HTTPException(
+                status_code=status.HTTP_409_CONFLICT,
+                detail=f"API key limit reached ({settings.API_KEY_MAX_COUNT})",
+            )
+        if any(_const_eq(existing.id, key_id) for existing in user.keys):
+            raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An API key with this id already exists")
+        issued = datetime.now(UTC)
+        plaintext = secrets.token_urlsafe(32)
+        stored = HttpKey(
+            id=key_id,
+            secret=hash_password(plaintext, config_salt=auth_config.salt),
+            readonly=readonly,
+            time_issued=issued,
+            time_expiry=_key_expiry(issued),
+        )
+        if created:
+            auth_config.local_users.append(user)
+        previous = list(user.keys)
+        user.keys = [*previous, stored]
+        try:
+            _persist_auth_config()
+        except Exception:
+            if created:
+                _drop_local_user(user)
+            else:
+                user.keys = previous
+            raise
+        return IssuedApiKey(
+            id=stored.id,
+            secret=plaintext,
+            readonly=stored.readonly,
+            time_issued=stored.time_issued,
+            time_expiry=stored.time_expiry,
+        )
 
 
 def revoke_api_key(
-	username: str,
-	email: str | None,
-	key_id: str,
-	*,
-	auth_method: AuthMethod = "local",
+    username: str,
+    email: str | None,
+    key_id: str,
+    *,
+    auth_method: AuthMethod = "local",
 ) -> None:
-	with _API_KEY_LOCK:
-		user = _key_owner(username, email, auth_method)
-		if user is None:
-			raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No local account for API keys")
-		remaining = [key for key in user.keys if not _const_eq(key.id, key_id)]
-		if len(remaining) == len(user.keys):
-			raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such API key")
-		previous = list(user.keys)
-		user.keys = remaining
-		try:
-			_persist_auth_config()
-		except Exception:
-			user.keys = previous
-			raise
+    with _API_KEY_LOCK:
+        user = _key_owner(username, email, auth_method)
+        if user is None:
+            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No local account for API keys")
+        remaining = [key for key in user.keys if not _const_eq(key.id, key_id)]
+        if len(remaining) == len(user.keys):
+            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such API key")
+        previous = list(user.keys)
+        user.keys = remaining
+        try:
+            _persist_auth_config()
+        except Exception:
+            user.keys = previous
+            raise
 
 
 def _account_keys(
-	username: str,
-	email: str | None,
-	auth_method: AuthMethod,
+    username: str,
+    email: str | None,
+    auth_method: AuthMethod,
 ) -> tuple[list[dict[str, str | bool | None]] | None, str | None]:
-	if auth_method == "oauth":
-		user = _local_user_by_email(email)
-		if user is not None:
-			return _public_key_rows(user), user.user
-		stored = email.strip() if email else ""
-		if len(stored) < 2:
-			return None, None
-		return [], stored
-	user = _local_user_for_principal(username, email)
-	if user is None:
-		return None, None
-	return _public_key_rows(user), user.user
+    if auth_method == "oauth":
+        user = _local_user_by_email(email)
+        if user is not None:
+            return _public_key_rows(user), user.user
+        stored = email.strip() if email else ""
+        if len(stored) < 2:
+            return None, None
+        return [], stored
+    user = _local_user_for_principal(username, email)
+    if user is None:
+        return None, None
+    return _public_key_rows(user), user.user
 
 
 def _session_identity(token: str | None) -> tuple[str, str | None, AuthMethod]:
-	ensure_active_user_if_auth_enabled(token)
-	rec = get_session(token or "")
-	if rec is None:
-		raise HTTPException(
-			status_code=status.HTTP_401_UNAUTHORIZED,
-			detail="Not authenticated",
-			headers={"WWW-Authenticate": "Bearer"},
-		)
-	return rec.username, rec.email, rec.auth_method
+    ensure_active_user_if_auth_enabled(token)
+    rec = get_session(token or "")
+    if rec is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    return rec.username, rec.email, rec.auth_method
 
 
 @auth_router.get(
-	"/user",
-	response_class=HTMLResponse,
-	response_model=None,
-	tags=[TAG_AUTH],
-	summary="Account page",
-	description="HTML account page for the signed-in user.",
+    "/user",
+    response_class=HTMLResponse,
+    response_model=None,
+    tags=[TAG_AUTH],
+    summary="Account page",
+    description="HTML account page for the signed-in user.",
 )
 async def user_account_page(
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> HTMLResponse | RedirectResponse:
-	if not settings.AUTH:
-		return render_page(
-			"message.html",
-			title=f"{settings.SITE_NAME} - Account",
-			paragraphs=["Authentication is not enabled on this server."],
-			muted=True,
-		)
-	if not is_auth_configured(auth_config):
-		raise HTTPException(
-			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
-			detail="Authentication is misconfigured",
-		)
-	rec = get_session(token or "")
-	if rec is None:
-		return RedirectResponse(url="/login?next=/user", status_code=303)
-	api_keys, api_key_user = _account_keys(rec.username, rec.email, rec.auth_method)
-	if api_key_user is not None and _const_eq(api_key_user, rec.username):
-		api_key_user = None
-	return render_page(
-		"user_profile.html",
-		title=f"{settings.SITE_NAME} - Account",
-		username=rec.username,
-		api_keys=api_keys,
-		api_key_user=api_key_user,
-		api_key_max=settings.API_KEY_MAX_COUNT,
-		api_key_expiry_days=settings.API_KEY_EXPIRY_DAYS,
-	)
+    if not settings.AUTH:
+        return render_page(
+            "message.html",
+            title=f"{settings.SITE_NAME} - Account",
+            paragraphs=["Authentication is not enabled on this server."],
+            muted=True,
+        )
+    if not is_auth_configured(auth_config):
+        raise HTTPException(
+            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+            detail="Authentication is misconfigured",
+        )
+    rec = get_session(token or "")
+    if rec is None:
+        return RedirectResponse(url="/login?next=/user", status_code=303)
+    api_keys, api_key_user = _account_keys(rec.username, rec.email, rec.auth_method)
+    if api_key_user is not None and _const_eq(api_key_user, rec.username):
+        api_key_user = None
+    return render_page(
+        "user_profile.html",
+        title=f"{settings.SITE_NAME} - Account",
+        username=rec.username,
+        api_keys=api_keys,
+        api_key_user=api_key_user,
+        api_key_max=settings.API_KEY_MAX_COUNT,
+        api_key_expiry_days=settings.API_KEY_EXPIRY_DAYS,
+    )
 
 
 @auth_router.post(
-	"/user/keys",
-	response_model=IssuedApiKey,
-	tags=[TAG_AUTH],
-	summary="Issue an API key",
-	description=(
-		"Creates a Smart HTTP API key for the signed-in user. "
-		"An OAuth session is stored as a passwordless local account, matched by email, "
-		"the first time it issues a key. The plaintext secret is returned once. "
-		"Only its scrypt hash is stored."
-	),
+    "/user/keys",
+    response_model=IssuedApiKey,
+    tags=[TAG_AUTH],
+    summary="Issue an API key",
+    description=(
+        "Creates a Smart HTTP API key for the signed-in user. "
+        "An OAuth session is stored as a passwordless local account, matched by email, "
+        "the first time it issues a key. The plaintext secret is returned once. "
+        "Only its scrypt hash is stored."
+    ),
 )
 async def issue_user_api_key(
-	body: IssueApiKeyRequest,
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    body: IssueApiKeyRequest,
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> IssuedApiKey:
-	if not settings.AUTH:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	username, email, auth_method = _session_identity(token)
-	return issue_api_key(username, email, key_id=body.id, readonly=body.readonly, auth_method=auth_method)
+    if not settings.AUTH:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+    username, email, auth_method = _session_identity(token)
+    return issue_api_key(username, email, key_id=body.id, readonly=body.readonly, auth_method=auth_method)
 
 
 @auth_router.delete(
-	"/user/keys",
-	status_code=status.HTTP_204_NO_CONTENT,
-	tags=[TAG_AUTH],
-	summary="Revoke an API key",
-	description="Removes one Smart HTTP API key from the signed-in user.",
+    "/user/keys",
+    status_code=status.HTTP_204_NO_CONTENT,
+    tags=[TAG_AUTH],
+    summary="Revoke an API key",
+    description="Removes one Smart HTTP API key from the signed-in user.",
 )
 async def revoke_user_api_key(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	id: Annotated[str, Query(min_length=1)],
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    id: Annotated[str, Query(min_length=1)],
 ) -> Response:
-	if not settings.AUTH:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	username, email, auth_method = _session_identity(token)
-	revoke_api_key(username, email, id.strip(), auth_method=auth_method)
-	return Response(status_code=status.HTTP_204_NO_CONTENT)
+    if not settings.AUTH:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+    username, email, auth_method = _session_identity(token)
+    revoke_api_key(username, email, id.strip(), auth_method=auth_method)
+    return Response(status_code=status.HTTP_204_NO_CONTENT)
 
 
 @auth_router.get(
-	"/users/me",
-	response_model=User,
-	tags=[TAG_AUTH],
-	summary="Current user",
-	description="Returns the username for the current session (cookie or Bearer).",
+    "/users/me",
+    response_model=User,
+    tags=[TAG_AUTH],
+    summary="Current user",
+    description="Returns the username for the current session (cookie or Bearer).",
 )
 async def read_users_me(
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> User:
-	if not settings.AUTH:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	ensure_active_user_if_auth_enabled(token)
-	u = decode_access_token(token or "")
-	if u is None:
-		raise HTTPException(
-			status_code=status.HTTP_401_UNAUTHORIZED,
-			detail="Not authenticated",
-			headers={"WWW-Authenticate": "Bearer"},
-		)
-	return u
+    if not settings.AUTH:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+    ensure_active_user_if_auth_enabled(token)
+    u = decode_access_token(token or "")
+    if u is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    return u
diff --git a/pygitweb/src/pygitweb/auth_config.py b/pygitweb/src/pygitweb/auth_config.py
index a6c3253..b639267 100644
--- a/pygitweb/src/pygitweb/auth_config.py
+++ b/pygitweb/src/pygitweb/auth_config.py
@@ -21,38 +21,38 @@ from pygitweb.password_hash import hash_password, is_password_hash
 from pygitweb.permissions import PermissionsMap
 
 if TYPE_CHECKING:
-	from pygitweb.config import Settings
+    from pygitweb.config import Settings
 
 DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
 _AUTH_CONFIG_MODE = 0o600
 
 
 def _is_windows() -> bool:
-	return sys.platform == "win32"
+    return sys.platform == "win32"
 
 
 def _auth_config_mode(path: Path) -> int:
-	return stat.S_IMODE(path.stat().st_mode)
+    return stat.S_IMODE(path.stat().st_mode)
 
 
 def _set_auth_config_mode(path: Path) -> None:
-	if _is_windows():
-		return
-	os.chmod(path, _AUTH_CONFIG_MODE)
+    if _is_windows():
+        return
+    os.chmod(path, _AUTH_CONFIG_MODE)
 
 
 class AuthConfigPermissionError(RuntimeError):
-	"""Auth config file exists but is not mode 0600 (non-Windows only)."""
+    """Auth config file exists but is not mode 0600 (non-Windows only)."""
 
 
 def _require_auth_config_mode(path: Path) -> None:
-	if _is_windows():
-		return
-	mode = _auth_config_mode(path)
-	if mode != _AUTH_CONFIG_MODE:
-		raise AuthConfigPermissionError(
-			f"PyGitWeb: auth config {path} must have mode 0600 (found {mode:#04o}). Run: chmod 600 {path}"
-		)
+    if _is_windows():
+        return
+    mode = _auth_config_mode(path)
+    if mode != _AUTH_CONFIG_MODE:
+        raise AuthConfigPermissionError(
+            f"PyGitWeb: auth config {path} must have mode 0600 (found {mode:#04o}). Run: chmod 600 {path}"
+        )
 
 
 AuthMode = Literal["oauth", "local", "both"]
@@ -63,239 +63,239 @@ _AUTH_SALT_HEX_LEN = 16
 
 
 def generate_auth_salt() -> str:
-	return secrets.token_hex(_AUTH_SALT_HEX_LEN // 2)
+    return secrets.token_hex(_AUTH_SALT_HEX_LEN // 2)
 
 
 def _as_utc(value: datetime) -> datetime:
-	if value.tzinfo is None:
-		return value.replace(tzinfo=UTC)
-	return value.astimezone(UTC)
+    if value.tzinfo is None:
+        return value.replace(tzinfo=UTC)
+    return value.astimezone(UTC)
 
 
 class HttpKey(BaseModel):
-	id: str = Field(min_length=1)
-	secret: str = Field(min_length=1)
-	readonly: bool = False
-	time_issued: datetime
-	time_expiry: datetime | None = None
-
-	@field_validator("id", "secret", mode="before")
-	@classmethod
-	def _strip_required_text(cls, value: object) -> object:
-		if not isinstance(value, str):
-			return value
-		stripped = value.strip()
-		if not stripped:
-			raise ValueError("must be a non-empty string")
-		return stripped
-
-	@field_validator("secret")
-	@classmethod
-	def _require_secret_hash(cls, value: str) -> str:
-		if not is_password_hash(value):
-			raise ValueError("secret must be a scrypt password hash")
-		return value
-
-	@model_validator(mode="after")
-	def _expiry_not_before_issue(self) -> Self:
-		if self.time_expiry is not None and _as_utc(self.time_expiry) < _as_utc(self.time_issued):
-			raise ValueError("time_expiry is before time_issued")
-		return self
-
-	def is_expired(self, now: datetime) -> bool:
-		if self.time_expiry is None:
-			return False
-		return _as_utc(now) >= _as_utc(self.time_expiry)
+    id: str = Field(min_length=1)
+    secret: str = Field(min_length=1)
+    readonly: bool = False
+    time_issued: datetime
+    time_expiry: datetime | None = None
+
+    @field_validator("id", "secret", mode="before")
+    @classmethod
+    def _strip_required_text(cls, value: object) -> object:
+        if not isinstance(value, str):
+            return value
+        stripped = value.strip()
+        if not stripped:
+            raise ValueError("must be a non-empty string")
+        return stripped
+
+    @field_validator("secret")
+    @classmethod
+    def _require_secret_hash(cls, value: str) -> str:
+        if not is_password_hash(value):
+            raise ValueError("secret must be a scrypt password hash")
+        return value
+
+    @model_validator(mode="after")
+    def _expiry_not_before_issue(self) -> Self:
+        if self.time_expiry is not None and _as_utc(self.time_expiry) < _as_utc(self.time_issued):
+            raise ValueError("time_expiry is before time_issued")
+        return self
+
+    def is_expired(self, now: datetime) -> bool:
+        if self.time_expiry is None:
+            return False
+        return _as_utc(now) >= _as_utc(self.time_expiry)
 
 
 class LocalUser(BaseModel):
-	model_config = ConfigDict(populate_by_name=True)
-
-	user: str = Field(min_length=2)
-	password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
-	pass_hash: str = ""
-	email: str | None = None
-	assume_anonymous: bool = False
-	keys: list[HttpKey] = Field(default_factory=list)
-
-	@field_validator("keys")
-	@classmethod
-	def _unique_key_ids(cls, keys: list[HttpKey]) -> list[HttpKey]:
-		seen: set[str] = set()
-		for key in keys:
-			if key.id in seen:
-				raise ValueError(f"duplicate http key id: {key.id}")
-			seen.add(key.id)
-		return keys
-
-	@field_validator("email", mode="before")
-	@classmethod
-	def _normalize_email(cls, value: object) -> str | None:
-		if value is None or value == "":
-			return None
-		if isinstance(value, str):
-			stripped = value.strip()
-			return stripped or None
-		return str(value).strip() or None
+    model_config = ConfigDict(populate_by_name=True)
+
+    user: str = Field(min_length=2)
+    password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
+    pass_hash: str = ""
+    email: str | None = None
+    assume_anonymous: bool = False
+    keys: list[HttpKey] = Field(default_factory=list)
+
+    @field_validator("keys")
+    @classmethod
+    def _unique_key_ids(cls, keys: list[HttpKey]) -> list[HttpKey]:
+        seen: set[str] = set()
+        for key in keys:
+            if key.id in seen:
+                raise ValueError(f"duplicate http key id: {key.id}")
+            seen.add(key.id)
+        return keys
+
+    @field_validator("email", mode="before")
+    @classmethod
+    def _normalize_email(cls, value: object) -> str | None:
+        if value is None or value == "":
+            return None
+        if isinstance(value, str):
+            stripped = value.strip()
+            return stripped or None
+        return str(value).strip() or None
 
 
 class AuthConfig(BaseModel):
-	model_config = ConfigDict(populate_by_name=True)
-
-	salt: str = ""
-	auth_mode: AuthMode = "local"
-	oauth_provider: OAuthProvider | str = ""
-	oauth_client_id: str = ""
-	oauth_client_secret: str = ""
-	oauth_redirect_uri: str = ""
-	oauth_username_from: OAuthUsernameFrom = "preffered_username"
-	oauth_allowed_emails: list[str] = Field(default_factory=list)
-	oauth_permissions: PermissionsMap = Field(default_factory=PermissionsMap.empty)
-	local_users: list[LocalUser] = Field(default_factory=list)
-
-	@field_validator("salt", mode="before")
-	@classmethod
-	def _validate_salt(cls, value: object) -> str:
-		if value is None or value == "":
-			return ""
-		salt = str(value).strip().lower()
-		if len(salt) != _AUTH_SALT_HEX_LEN or not all(c in "0123456789abcdef" for c in salt):
-			raise ValueError(f"salt must be a {_AUTH_SALT_HEX_LEN}-character hex string")
-		return salt
-
-	@field_validator("oauth_permissions", mode="before")
-	@classmethod
-	def _validate_oauth_permissions(cls, v: object) -> PermissionsMap | dict[str, list[str]]:
-		if isinstance(v, PermissionsMap):
-			return v
-		return PermissionsMap.model_validate(v)
-
-	@field_serializer("oauth_permissions")
-	def _serialize_oauth_permissions(self, value: PermissionsMap) -> dict[str, list[str]]:
-		return value.root
+    model_config = ConfigDict(populate_by_name=True)
+
+    salt: str = ""
+    auth_mode: AuthMode = "local"
+    oauth_provider: OAuthProvider | str = ""
+    oauth_client_id: str = ""
+    oauth_client_secret: str = ""
+    oauth_redirect_uri: str = ""
+    oauth_username_from: OAuthUsernameFrom = "preffered_username"
+    oauth_allowed_emails: list[str] = Field(default_factory=list)
+    oauth_permissions: PermissionsMap = Field(default_factory=PermissionsMap.empty)
+    local_users: list[LocalUser] = Field(default_factory=list)
+
+    @field_validator("salt", mode="before")
+    @classmethod
+    def _validate_salt(cls, value: object) -> str:
+        if value is None or value == "":
+            return ""
+        salt = str(value).strip().lower()
+        if len(salt) != _AUTH_SALT_HEX_LEN or not all(c in "0123456789abcdef" for c in salt):
+            raise ValueError(f"salt must be a {_AUTH_SALT_HEX_LEN}-character hex string")
+        return salt
+
+    @field_validator("oauth_permissions", mode="before")
+    @classmethod
+    def _validate_oauth_permissions(cls, v: object) -> PermissionsMap | dict[str, list[str]]:
+        if isinstance(v, PermissionsMap):
+            return v
+        return PermissionsMap.model_validate(v)
+
+    @field_serializer("oauth_permissions")
+    def _serialize_oauth_permissions(self, value: PermissionsMap) -> dict[str, list[str]]:
+        return value.root
 
 
 def auth_config_path(settings: Settings) -> Path:
-	if settings.AUTH_CONFIG:
-		return Path(settings.AUTH_CONFIG).expanduser()
-	return DEFAULT_AUTH_CONFIG_PATH
+    if settings.AUTH_CONFIG:
+        return Path(settings.AUTH_CONFIG).expanduser()
+    return DEFAULT_AUTH_CONFIG_PATH
 
 
 def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
-	return AuthConfig(
-		salt=salt,
-		auth_mode="local",
-		oauth_provider="",
-		oauth_client_id="",
-		oauth_client_secret="",
-		oauth_redirect_uri="",
-		oauth_username_from="preffered_username",
-		oauth_allowed_emails=[],
-		oauth_permissions=PermissionsMap.default_access(),
-		local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
-	)
+    return AuthConfig(
+        salt=salt,
+        auth_mode="local",
+        oauth_provider="",
+        oauth_client_id="",
+        oauth_client_secret="",
+        oauth_redirect_uri="",
+        oauth_username_from="preffered_username",
+        oauth_allowed_emails=[],
+        oauth_permissions=PermissionsMap.default_access(),
+        local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
+    )
 
 
 def write_auth_config(path: Path, config: AuthConfig) -> None:
-	path.parent.mkdir(parents=True, exist_ok=True)
-	path.write_text(
-		json.dumps(config.model_dump(by_alias=True, mode="json", exclude_none=True), indent=2) + "\n",
-		encoding="utf-8",
-	)
-	_set_auth_config_mode(path)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(
+        json.dumps(config.model_dump(by_alias=True, mode="json", exclude_none=True), indent=2) + "\n",
+        encoding="utf-8",
+    )
+    _set_auth_config_mode(path)
 
 
 def _ensure_auth_salt(path: Path, config: AuthConfig) -> AuthConfig:
-	if config.salt:
-		return config
-	salt = generate_auth_salt()
-	updated = config.model_copy(update={"salt": salt})
-	write_auth_config(path, updated)
-	return updated
+    if config.salt:
+        return config
+    salt = generate_auth_salt()
+    updated = config.model_copy(update={"salt": salt})
+    write_auth_config(path, updated)
+    return updated
 
 
 def _migrate_plaintext_passwords(path: Path, config: AuthConfig) -> AuthConfig:
-	updated_users: list[LocalUser] = []
-	changed = False
-	for entry in config.local_users:
-		if not entry.password:
-			updated_users.append(entry)
-			continue
-		updated_users.append(
-			entry.model_copy(
-				update={
-					"password": None,
-					"pass_hash": hash_password(entry.password, config_salt=config.salt),
-				}
-			)
-		)
-		changed = True
-	if not changed:
-		return config
-	migrated = config.model_copy(update={"local_users": updated_users})
-	write_auth_config(path, migrated)
-	print(
-		f"WARNING: hashed plaintext password(s) in {path}.\n",
-		file=sys.stderr,
-	)
-	return migrated
+    updated_users: list[LocalUser] = []
+    changed = False
+    for entry in config.local_users:
+        if not entry.password:
+            updated_users.append(entry)
+            continue
+        updated_users.append(
+            entry.model_copy(
+                update={
+                    "password": None,
+                    "pass_hash": hash_password(entry.password, config_salt=config.salt),
+                }
+            )
+        )
+        changed = True
+    if not changed:
+        return config
+    migrated = config.model_copy(update={"local_users": updated_users})
+    write_auth_config(path, migrated)
+    print(
+        f"WARNING: hashed plaintext password(s) in {path}.\n",
+        file=sys.stderr,
+    )
+    return migrated
 
 
 def load_auth_config_file(path: Path) -> AuthConfig:
-	return AuthConfig.model_validate_json(path.read_text(encoding="utf-8"))
+    return AuthConfig.model_validate_json(path.read_text(encoding="utf-8"))
 
 
 def is_local_auth_available(config: AuthConfig) -> bool:
-	if config.auth_mode not in ("local", "both"):
-		return False
-	return any(u.pass_hash or u.password for u in config.local_users)
+    if config.auth_mode not in ("local", "both"):
+        return False
+    return any(u.pass_hash or u.password for u in config.local_users)
 
 
 def is_oauth_auth_available(config: AuthConfig) -> bool:
-	if config.auth_mode not in ("oauth", "both"):
-		return False
-	provider = str(config.oauth_provider).strip()
-	return bool(
-		provider in ("google", "github")
-		and config.oauth_client_id.strip()
-		and config.oauth_client_secret.strip()
-		and config.oauth_redirect_uri.strip()
-	)
+    if config.auth_mode not in ("oauth", "both"):
+        return False
+    provider = str(config.oauth_provider).strip()
+    return bool(
+        provider in ("google", "github")
+        and config.oauth_client_id.strip()
+        and config.oauth_client_secret.strip()
+        and config.oauth_redirect_uri.strip()
+    )
 
 
 def is_auth_configured(config: AuthConfig) -> bool:
-	if config.auth_mode == "local":
-		return is_local_auth_available(config)
-	if config.auth_mode == "oauth":
-		return is_oauth_auth_available(config)
-	return is_local_auth_available(config) or is_oauth_auth_available(config)
+    if config.auth_mode == "local":
+        return is_local_auth_available(config)
+    if config.auth_mode == "oauth":
+        return is_oauth_auth_available(config)
+    return is_local_auth_available(config) or is_oauth_auth_available(config)
 
 
 def init_auth_config(settings: Settings) -> AuthConfig:
-	global auth_config
-	path = auth_config_path(settings)
-	if path.is_file():
-		if settings.AUTH:
-			_require_auth_config_mode(path)
-		auth_config = _migrate_plaintext_passwords(
-			path,
-			_ensure_auth_salt(path, load_auth_config_file(path)),
-		)
-		return auth_config
-	if not settings.AUTH:
-		auth_config = AuthConfig(auth_mode="local", local_users=[])
-		return auth_config
-	password = secrets.token_urlsafe(16)
-	salt = generate_auth_salt()
-	auth_config = default_auth_config(admin_password=password, salt=salt)
-	write_auth_config(path, auth_config)
-	print(
-		f"WARNING: PyGitWeb: created auth config at {path}\n"
-		"         Set PYGITWEB_AUTH=0 to disable auth, or edit this file for fixed credentials.\n",
-		file=sys.stderr,
-	)
-	print(f"  Username: admin\n  Password: {password}\n", file=sys.stderr)
-	return auth_config
+    global auth_config
+    path = auth_config_path(settings)
+    if path.is_file():
+        if settings.AUTH:
+            _require_auth_config_mode(path)
+        auth_config = _migrate_plaintext_passwords(
+            path,
+            _ensure_auth_salt(path, load_auth_config_file(path)),
+        )
+        return auth_config
+    if not settings.AUTH:
+        auth_config = AuthConfig(auth_mode="local", local_users=[])
+        return auth_config
+    password = secrets.token_urlsafe(16)
+    salt = generate_auth_salt()
+    auth_config = default_auth_config(admin_password=password, salt=salt)
+    write_auth_config(path, auth_config)
+    print(
+        f"WARNING: PyGitWeb: created auth config at {path}\n"
+        "         Set PYGITWEB_AUTH=0 to disable auth, or edit this file for fixed credentials.\n",
+        file=sys.stderr,
+    )
+    print(f"  Username: admin\n  Password: {password}\n", file=sys.stderr)
+    return auth_config
 
 
 auth_config: AuthConfig = AuthConfig(auth_mode="local", local_users=[])
diff --git a/pygitweb/src/pygitweb/auth_keys_test.py b/pygitweb/src/pygitweb/auth_keys_test.py
index f096f3f..99cb93d 100644
--- a/pygitweb/src/pygitweb/auth_keys_test.py
+++ b/pygitweb/src/pygitweb/auth_keys_test.py
@@ -19,291 +19,291 @@ _SALT = "0123456789abcdef"
 
 
 def _config() -> AuthConfig:
-	return AuthConfig(
-		auth_mode="local",
-		salt=_SALT,
-		local_users=[LocalUser(user="admin", password="secret", email="admin@example.com")],
-	)
+    return AuthConfig(
+        auth_mode="local",
+        salt=_SALT,
+        local_users=[LocalUser(user="admin", password="secret", email="admin@example.com")],
+    )
 
 
 def test_api_key_settings_default_to_two_keys_without_expiry() -> None:
-	cfg = Settings(AUTH=False)
-	assert cfg.API_KEY_MAX_COUNT == 2
-	assert cfg.API_KEY_EXPIRY_DAYS == 0
-	with pytest.raises(ValidationError):
-		Settings(AUTH=False, API_KEY_MAX_COUNT=-1)
+    cfg = Settings(AUTH=False)
+    assert cfg.API_KEY_MAX_COUNT == 2
+    assert cfg.API_KEY_EXPIRY_DAYS == 0
+    with pytest.raises(ValidationError):
+        Settings(AUTH=False, API_KEY_MAX_COUNT=-1)
 
 
 def test_issue_api_key_returns_plaintext_and_stores_hash(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		set_client_session(client, "admin")
-		page = client.get("/user")
-		assert page.status_code == 200
-		assert "API keys" in page.text
-		assert "Create key" in page.text
-		issued = client.post("/user/keys", json={"id": "laptop", "readonly": True})
-		assert issued.status_code == 200, issued.text
-		body = issued.json()
-		assert body["id"] == "laptop"
-		assert body["readonly"] is True
-		plaintext = body["secret"]
-		assert isinstance(plaintext, str)
-		assert not plaintext.startswith("scrypt$")
-		assert body["time_expiry"] is None
-		stored = cfg.local_users[0].keys[0]
-		assert stored.secret != plaintext
-		assert is_password_hash(stored.secret)
-		assert verify_password(plaintext, stored.secret, config_salt=_SALT)
-		on_disk = json.loads(path.read_text(encoding="utf-8"))
-		assert on_disk["local_users"][0]["keys"][0]["secret"] == stored.secret
-		assert plaintext not in path.read_text(encoding="utf-8")
-		matched = authenticate_http_key("admin", plaintext)
-		assert matched is not None
-		assert matched[1] is True
-		again = client.get("/user")
-		assert "laptop" in again.text
-		assert plaintext not in again.text
+    cfg = _config()
+    path = tmp_path / "auth.json"
+    client = TestClient(app)
+    with (
+        patch.object(settings, "AUTH", True),
+        patch.object(settings, "AUTH_CONFIG", str(path)),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        set_client_session(client, "admin")
+        page = client.get("/user")
+        assert page.status_code == 200
+        assert "API keys" in page.text
+        assert "Create key" in page.text
+        issued = client.post("/user/keys", json={"id": "laptop", "readonly": True})
+        assert issued.status_code == 200, issued.text
+        body = issued.json()
+        assert body["id"] == "laptop"
+        assert body["readonly"] is True
+        plaintext = body["secret"]
+        assert isinstance(plaintext, str)
+        assert not plaintext.startswith("scrypt$")
+        assert body["time_expiry"] is None
+        stored = cfg.local_users[0].keys[0]
+        assert stored.secret != plaintext
+        assert is_password_hash(stored.secret)
+        assert verify_password(plaintext, stored.secret, config_salt=_SALT)
+        on_disk = json.loads(path.read_text(encoding="utf-8"))
+        assert on_disk["local_users"][0]["keys"][0]["secret"] == stored.secret
+        assert plaintext not in path.read_text(encoding="utf-8")
+        matched = authenticate_http_key("admin", plaintext)
+        assert matched is not None
+        assert matched[1] is True
+        again = client.get("/user")
+        assert "laptop" in again.text
+        assert plaintext not in again.text
 
 
 def test_issue_api_key_enforces_limit_expiry_and_duplicate_id(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch.object(settings, "API_KEY_MAX_COUNT", 2),
-		patch.object(settings, "API_KEY_EXPIRY_DAYS", 7),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		set_client_session(client, "admin")
-		before = datetime.now(UTC)
-		first = client.post("/user/keys", json={"id": "alpha-key"})
-		assert first.status_code == 200, first.text
-		duplicate = client.post("/user/keys", json={"id": "alpha-key"})
-		second = client.post("/user/keys", json={"id": "beta-key"})
-		assert second.status_code == 200, second.text
-		third = client.post("/user/keys", json={"id": "gamma-key"})
-		assert duplicate.status_code == 409
-		assert third.status_code == 409
-		assert len(cfg.local_users[0].keys) == 2
-		expiry = datetime.fromisoformat(first.json()["time_expiry"])
-		assert before + timedelta(days=7) <= expiry <= datetime.now(UTC) + timedelta(days=7)
-		assert second.json()["time_expiry"] is not None
-		revoked = client.delete("/user/keys", params={"id": "alpha-key"})
-		assert revoked.status_code == 204
-		assert [key.id for key in cfg.local_users[0].keys] == ["beta-key"]
-		page = client.get("/user")
-		assert "alpha-key" not in page.text
-		assert "beta-key" in page.text
-		replaced = client.post("/user/keys", json={"id": "alpha-key"})
-		assert replaced.status_code == 200
-		assert replaced.json()["id"] == "alpha-key"
+    cfg = _config()
+    path = tmp_path / "auth.json"
+    client = TestClient(app)
+    with (
+        patch.object(settings, "AUTH", True),
+        patch.object(settings, "AUTH_CONFIG", str(path)),
+        patch.object(settings, "API_KEY_MAX_COUNT", 2),
+        patch.object(settings, "API_KEY_EXPIRY_DAYS", 7),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        set_client_session(client, "admin")
+        before = datetime.now(UTC)
+        first = client.post("/user/keys", json={"id": "alpha-key"})
+        assert first.status_code == 200, first.text
+        duplicate = client.post("/user/keys", json={"id": "alpha-key"})
+        second = client.post("/user/keys", json={"id": "beta-key"})
+        assert second.status_code == 200, second.text
+        third = client.post("/user/keys", json={"id": "gamma-key"})
+        assert duplicate.status_code == 409
+        assert third.status_code == 409
+        assert len(cfg.local_users[0].keys) == 2
+        expiry = datetime.fromisoformat(first.json()["time_expiry"])
+        assert before + timedelta(days=7) <= expiry <= datetime.now(UTC) + timedelta(days=7)
+        assert second.json()["time_expiry"] is not None
+        revoked = client.delete("/user/keys", params={"id": "alpha-key"})
+        assert revoked.status_code == 204
+        assert [key.id for key in cfg.local_users[0].keys] == ["beta-key"]
+        page = client.get("/user")
+        assert "alpha-key" not in page.text
+        assert "beta-key" in page.text
+        replaced = client.post("/user/keys", json={"id": "alpha-key"})
+        assert replaced.status_code == 200
+        assert replaced.json()["id"] == "alpha-key"
 
 
 def test_issue_api_key_requires_a_local_account_and_a_session(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		missing = client.post("/user/keys", json={"id": "laptop"})
-		assert missing.status_code == 401
-		token = create_session("nobody", auth_method="local")
-		unknown = client.post(
-			"/user/keys",
-			json={"id": "laptop"},
-			headers={"Authorization": f"Bearer {token}"},
-		)
-		assert unknown.status_code == 404
-		page = client.get("/user", headers={"Authorization": f"Bearer {token}"})
-		assert "cannot be stored" in page.text
-		email_token = create_session("other-name", email="Admin@Example.com", auth_method="oauth")
-		by_email = client.post(
-			"/user/keys",
-			json={"id": "mail-key"},
-			headers={"Authorization": f"Bearer {email_token}"},
-		)
-		assert by_email.status_code == 200, by_email.text
-		assert len(cfg.local_users) == 1
-		assert cfg.local_users[0].keys[0].id == "mail-key"
-		missing_key = client.delete(
-			"/user/keys", params={"id": "missing"}, headers={"Authorization": f"Bearer {email_token}"}
-		)
-		assert missing_key.status_code == 404
-		revoke_unknown = client.delete(
-			"/user/keys", params={"id": "mail-key"}, headers={"Authorization": f"Bearer {token}"}
-		)
-		assert revoke_unknown.status_code == 404
-	with patch.object(settings, "AUTH", False):
-		disabled = client.post("/user/keys", json={"id": "laptop"})
-		assert disabled.status_code == 400
-		disabled_revoke = client.delete("/user/keys", params={"id": "laptop"})
-		assert disabled_revoke.status_code == 400
+    cfg = _config()
+    path = tmp_path / "auth.json"
+    client = TestClient(app)
+    with (
+        patch.object(settings, "AUTH", True),
+        patch.object(settings, "AUTH_CONFIG", str(path)),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        missing = client.post("/user/keys", json={"id": "laptop"})
+        assert missing.status_code == 401
+        token = create_session("nobody", auth_method="local")
+        unknown = client.post(
+            "/user/keys",
+            json={"id": "laptop"},
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        assert unknown.status_code == 404
+        page = client.get("/user", headers={"Authorization": f"Bearer {token}"})
+        assert "cannot be stored" in page.text
+        email_token = create_session("other-name", email="Admin@Example.com", auth_method="oauth")
+        by_email = client.post(
+            "/user/keys",
+            json={"id": "mail-key"},
+            headers={"Authorization": f"Bearer {email_token}"},
+        )
+        assert by_email.status_code == 200, by_email.text
+        assert len(cfg.local_users) == 1
+        assert cfg.local_users[0].keys[0].id == "mail-key"
+        missing_key = client.delete(
+            "/user/keys", params={"id": "missing"}, headers={"Authorization": f"Bearer {email_token}"}
+        )
+        assert missing_key.status_code == 404
+        revoke_unknown = client.delete(
+            "/user/keys", params={"id": "mail-key"}, headers={"Authorization": f"Bearer {token}"}
+        )
+        assert revoke_unknown.status_code == 404
+    with patch.object(settings, "AUTH", False):
+        disabled = client.post("/user/keys", json={"id": "laptop"})
+        assert disabled.status_code == 400
+        disabled_revoke = client.delete("/user/keys", params={"id": "laptop"})
+        assert disabled_revoke.status_code == 400
 
 
 def test_issue_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app, raise_server_exceptions=False)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-		patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
-	):
-		set_client_session(client, "admin")
-		response = client.post("/user/keys", json={"id": "laptop"})
-		assert response.status_code == 500
-		assert cfg.local_users[0].keys == []
+    cfg = _config()
+    path = tmp_path / "auth.json"
+    client = TestClient(app, raise_server_exceptions=False)
+    with (
+        patch.object(settings, "AUTH", True),
+        patch.object(settings, "AUTH_CONFIG", str(path)),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+        patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
+    ):
+        set_client_session(client, "admin")
+        response = client.post("/user/keys", json={"id": "laptop"})
+        assert response.status_code == 500
+        assert cfg.local_users[0].keys == []
 
 
 def test_revoke_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app, raise_server_exceptions=False)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		set_client_session(client, "admin")
-		issued = client.post("/user/keys", json={"id": "laptop"})
-		assert issued.status_code == 200, issued.text
-		with patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")):
-			response = client.delete("/user/keys", params={"id": "laptop"})
-		assert response.status_code == 500
-		assert [key.id for key in cfg.local_users[0].keys] == ["laptop"]
+    cfg = _config()
+    path = tmp_path / "auth.json"
+    client = TestClient(app, raise_server_exceptions=False)
+    with (
+        patch.object(settings, "AUTH", True),
+        patch.object(settings, "AUTH_CONFIG", str(path)),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        set_client_session(client, "admin")
+        issued = client.post("/user/keys", json={"id": "laptop"})
+        assert issued.status_code == 200, issued.text
+        with patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")):
+            response = client.delete("/user/keys", params={"id": "laptop"})
+        assert response.status_code == 500
+        assert [key.id for key in cfg.local_users[0].keys] == ["laptop"]
 
 
 def test_oauth_session_issues_a_passwordless_local_user(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		token = create_session("admin", email="bob@example.com", auth_method="oauth")
-		headers = {"Authorization": f"Bearer {token}"}
-		page = client.get("/user", headers=headers)
-		assert page.status_code == 200
-		assert "Create key" in page.text
-		assert "Smart HTTP username for these keys: <code>bob@example.com</code>" in page.text
-		assert "cannot be stored" not in page.text
-		assert cfg.local_users[0].keys == []
-		issued = client.post("/user/keys", json={"id": "laptop", "readonly": True}, headers=headers)
-		assert issued.status_code == 200, issued.text
-		plaintext = issued.json()["secret"]
-		assert len(cfg.local_users) == 2
-		created = cfg.local_users[1]
-		assert created.user == "bob@example.com"
-		assert created.email == "bob@example.com"
-		assert created.password is None
-		assert created.pass_hash == ""
-		assert created.assume_anonymous is False
-		assert [key.id for key in cfg.local_users[0].keys] == []
-		assert created.keys[0].readonly is True
-		assert verify_password(plaintext, created.keys[0].secret, config_salt=_SALT)
-		on_disk = json.loads(path.read_text(encoding="utf-8"))
-		stored = on_disk["local_users"][1]
-		assert "pass" not in stored
-		assert stored["pass_hash"] == ""
-		assert plaintext not in path.read_text(encoding="utf-8")
-		assert authenticate_local_user("bob@example.com", plaintext) is None
-		matched = authenticate_http_key("bob@example.com", plaintext)
-		assert matched is not None
-		assert matched[0].email == "bob@example.com"
-		assert matched[1] is True
-		assert authenticate_http_key("BOB@example.com", plaintext) == matched
-		assert authenticate_http_key("admin", plaintext) is None
-		again = client.post("/user/keys", json={"id": "phone"}, headers=headers)
-		assert again.status_code == 200, again.text
-		assert len(cfg.local_users) == 2
-		assert [key.id for key in created.keys] == ["laptop", "phone"]
-		revoked = client.delete("/user/keys", params={"id": "laptop"}, headers=headers)
-		assert revoked.status_code == 204
-		assert [key.id for key in created.keys] == ["phone"]
-		no_email = create_session("admin", auth_method="oauth")
-		missing = client.post(
-			"/user/keys",
-			json={"id": "laptop"},
-			headers={"Authorization": f"Bearer {no_email}"},
-		)
-		assert missing.status_code == 404
-		assert missing.json()["detail"] == "An email is required to store API keys"
-		blocked = client.get("/user", headers={"Authorization": f"Bearer {no_email}"})
-		assert "cannot be stored" in blocked.text
+    cfg = _config()
+    path = tmp_path / "auth.json"
+    client = TestClient(app)
+    with (
+        patch.object(settings, "AUTH", True),
+        patch.object(settings, "AUTH_CONFIG", str(path)),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        token = create_session("admin", email="bob@example.com", auth_method="oauth")
+        headers = {"Authorization": f"Bearer {token}"}
+        page = client.get("/user", headers=headers)
+        assert page.status_code == 200
+        assert "Create key" in page.text
+        assert "Smart HTTP username for these keys: <code>bob@example.com</code>" in page.text
+        assert "cannot be stored" not in page.text
+        assert cfg.local_users[0].keys == []
+        issued = client.post("/user/keys", json={"id": "laptop", "readonly": True}, headers=headers)
+        assert issued.status_code == 200, issued.text
+        plaintext = issued.json()["secret"]
+        assert len(cfg.local_users) == 2
+        created = cfg.local_users[1]
+        assert created.user == "bob@example.com"
+        assert created.email == "bob@example.com"
+        assert created.password is None
+        assert created.pass_hash == ""
+        assert created.assume_anonymous is False
+        assert [key.id for key in cfg.local_users[0].keys] == []
+        assert created.keys[0].readonly is True
+        assert verify_password(plaintext, created.keys[0].secret, config_salt=_SALT)
+        on_disk = json.loads(path.read_text(encoding="utf-8"))
+        stored = on_disk["local_users"][1]
+        assert "pass" not in stored
+        assert stored["pass_hash"] == ""
+        assert plaintext not in path.read_text(encoding="utf-8")
+        assert authenticate_local_user("bob@example.com", plaintext) is None
+        matched = authenticate_http_key("bob@example.com", plaintext)
+        assert matched is not None
+        assert matched[0].email == "bob@example.com"
+        assert matched[1] is True
+        assert authenticate_http_key("BOB@example.com", plaintext) == matched
+        assert authenticate_http_key("admin", plaintext) is None
+        again = client.post("/user/keys", json={"id": "phone"}, headers=headers)
+        assert again.status_code == 200, again.text
+        assert len(cfg.local_users) == 2
+        assert [key.id for key in created.keys] == ["laptop", "phone"]
+        revoked = client.delete("/user/keys", params={"id": "laptop"}, headers=headers)
+        assert revoked.status_code == 204
+        assert [key.id for key in created.keys] == ["phone"]
+        no_email = create_session("admin", auth_method="oauth")
+        missing = client.post(
+            "/user/keys",
+            json={"id": "laptop"},
+            headers={"Authorization": f"Bearer {no_email}"},
+        )
+        assert missing.status_code == 404
+        assert missing.json()["detail"] == "An email is required to store API keys"
+        blocked = client.get("/user", headers={"Authorization": f"Bearer {no_email}"})
+        assert "cannot be stored" in blocked.text
 
 
 def test_oauth_key_name_collision_does_not_attach_to_the_other_account(tmp_path: Path) -> None:
-	cfg = AuthConfig(
-		auth_mode="local",
-		salt=_SALT,
-		local_users=[LocalUser(user="taken@example.com", password="secret", email="other@example.com")],
-	)
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		token = create_session("taken@example.com", email="taken@example.com", auth_method="oauth")
-		response = client.post(
-			"/user/keys",
-			json={"id": "laptop"},
-			headers={"Authorization": f"Bearer {token}"},
-		)
-		assert response.status_code == 409
-		assert response.json()["detail"] == "A local account already uses this name"
-		assert len(cfg.local_users) == 1
-		assert cfg.local_users[0].keys == []
+    cfg = AuthConfig(
+        auth_mode="local",
+        salt=_SALT,
+        local_users=[LocalUser(user="taken@example.com", password="secret", email="other@example.com")],
+    )
+    path = tmp_path / "auth.json"
+    client = TestClient(app)
+    with (
+        patch.object(settings, "AUTH", True),
+        patch.object(settings, "AUTH_CONFIG", str(path)),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        token = create_session("taken@example.com", email="taken@example.com", auth_method="oauth")
+        response = client.post(
+            "/user/keys",
+            json={"id": "laptop"},
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        assert response.status_code == 409
+        assert response.json()["detail"] == "A local account already uses this name"
+        assert len(cfg.local_users) == 1
+        assert cfg.local_users[0].keys == []
 
 
 def test_oauth_key_user_is_removed_when_save_fails(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app, raise_server_exceptions=False)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-		patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
-	):
-		token = create_session("admin", email="bob@example.com", auth_method="oauth")
-		response = client.post(
-			"/user/keys",
-			json={"id": "laptop"},
-			headers={"Authorization": f"Bearer {token}"},
-		)
-		assert response.status_code == 500
-		assert [user.user for user in cfg.local_users] == ["admin"]
-		assert cfg.local_users[0].keys == []
+    cfg = _config()
+    path = tmp_path / "auth.json"
+    client = TestClient(app, raise_server_exceptions=False)
+    with (
+        patch.object(settings, "AUTH", True),
+        patch.object(settings, "AUTH_CONFIG", str(path)),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+        patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
+    ):
+        token = create_session("admin", email="bob@example.com", auth_method="oauth")
+        response = client.post(
+            "/user/keys",
+            json={"id": "laptop"},
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        assert response.status_code == 500
+        assert [user.user for user in cfg.local_users] == ["admin"]
+        assert cfg.local_users[0].keys == []
diff --git a/pygitweb/src/pygitweb/auth_oauth.py b/pygitweb/src/pygitweb/auth_oauth.py
index 4d732ef..19b6e8c 100644
--- a/pygitweb/src/pygitweb/auth_oauth.py
+++ b/pygitweb/src/pygitweb/auth_oauth.py
@@ -38,305 +38,305 @@ oauth_router = APIRouter()
 
 
 def _safe_next_url(next_raw: str | None) -> str:
-	n = (next_raw or "/").strip()
-	if not n.startswith("/") or n.startswith("//"):
-		return "/"
-	return n
+    n = (next_raw or "/").strip()
+    if not n.startswith("/") or n.startswith("//"):
+        return "/"
+    return n
 
 
 @dataclass(frozen=True)
 class _OAuthProvider:
-	authorize_url: str
-	token_url: str
-	scopes: str
-	userinfo_url: str | None
+    authorize_url: str
+    token_url: str
+    scopes: str
+    userinfo_url: str | None
 
 
 _PROVIDERS: dict[str, _OAuthProvider] = {
-	"google": _OAuthProvider(
-		authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
-		token_url="https://oauth2.googleapis.com/token",
-		scopes="openid email profile",
-		userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
-	),
-	"github": _OAuthProvider(
-		authorize_url="https://github.com/login/oauth/authorize",
-		token_url="https://github.com/login/oauth/access_token",
-		scopes="read:user user:email",
-		userinfo_url=None,
-	),
+    "google": _OAuthProvider(
+        authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
+        token_url="https://oauth2.googleapis.com/token",
+        scopes="openid email profile",
+        userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
+    ),
+    "github": _OAuthProvider(
+        authorize_url="https://github.com/login/oauth/authorize",
+        token_url="https://github.com/login/oauth/access_token",
+        scopes="read:user user:email",
+        userinfo_url=None,
+    ),
 }
 
 
 def _provider(config: AuthConfig) -> _OAuthProvider:
-	name = str(config.oauth_provider).strip()
-	prov = _PROVIDERS.get(name)
-	if prov is None:
-		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
-	return prov
+    name = str(config.oauth_provider).strip()
+    prov = _PROVIDERS.get(name)
+    if prov is None:
+        raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
+    return prov
 
 
 def _seal_oauth_state(payload: dict[str, Any], secret: str) -> str:
-	body = {**payload, "exp": time.time() + OAUTH_STATE_MAX_AGE}
-	data = json.dumps(body, separators=(",", ":")).encode()
-	sig = hmac.new(secret.encode(), data, hashlib.sha256).digest()
-	return base64.urlsafe_b64encode(data + b"." + base64.urlsafe_b64encode(sig)).decode()
+    body = {**payload, "exp": time.time() + OAUTH_STATE_MAX_AGE}
+    data = json.dumps(body, separators=(",", ":")).encode()
+    sig = hmac.new(secret.encode(), data, hashlib.sha256).digest()
+    return base64.urlsafe_b64encode(data + b"." + base64.urlsafe_b64encode(sig)).decode()
 
 
 def _unseal_oauth_state(token: str, secret: str) -> dict[str, Any]:
-	try:
-		raw = base64.urlsafe_b64decode(token.encode())
-		data, sig_b64 = raw.rsplit(b".", 1)
-		sig = base64.urlsafe_b64decode(sig_b64)
-		expected = hmac.new(secret.encode(), data, hashlib.sha256).digest()
-		if not hmac.compare_digest(sig, expected):
-			raise ValueError("invalid signature")
-		payload: dict[str, Any] = json.loads(data)
-		if float(payload.get("exp", 0)) < time.time():
-			raise ValueError("expired")
-		return payload
-	except (ValueError, json.JSONDecodeError, KeyError) as exc:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") from exc
+    try:
+        raw = base64.urlsafe_b64decode(token.encode())
+        data, sig_b64 = raw.rsplit(b".", 1)
+        sig = base64.urlsafe_b64decode(sig_b64)
+        expected = hmac.new(secret.encode(), data, hashlib.sha256).digest()
+        if not hmac.compare_digest(sig, expected):
+            raise ValueError("invalid signature")
+        payload: dict[str, Any] = json.loads(data)
+        if float(payload.get("exp", 0)) < time.time():
+            raise ValueError("expired")
+        return payload
+    except (ValueError, json.JSONDecodeError, KeyError) as exc:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") from exc
 
 
 def _pkce_pair() -> tuple[str, str]:
-	verifier = secrets.token_urlsafe(48)
-	digest = hashlib.sha256(verifier.encode()).digest()
-	challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
-	return verifier, challenge
+    verifier = secrets.token_urlsafe(48)
+    digest = hashlib.sha256(verifier.encode()).digest()
+    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
+    return verifier, challenge
 
 
 def _claim(profile: dict[str, Any], claim: OAuthUsernameFrom) -> str | None:
-	if claim == "sub":
-		raw = profile.get("sub") or profile.get("id")
-	elif claim == "email":
-		raw = profile.get("email")
-	elif claim == "preffered_username":
-		raw = profile.get("preferred_username") or profile.get("preffered_username")
-	elif claim == "login":
-		raw = profile.get("login")
-	elif claim == "name":
-		raw = profile.get("name")
-	else:
-		raw = None
-	if raw is None:
-		return None
-	return str(raw)
+    if claim == "sub":
+        raw = profile.get("sub") or profile.get("id")
+    elif claim == "email":
+        raw = profile.get("email")
+    elif claim == "preffered_username":
+        raw = profile.get("preferred_username") or profile.get("preffered_username")
+    elif claim == "login":
+        raw = profile.get("login")
+    elif claim == "name":
+        raw = profile.get("name")
+    else:
+        raw = None
+    if raw is None:
+        return None
+    return str(raw)
 
 
 def oauth_username_from_profile(config: AuthConfig, profile: dict[str, Any]) -> str:
-	username = _claim(profile, config.oauth_username_from)
-	if username:
-		return username
-	for fallback in ("sub", "email", "login", "id"):
-		val = profile.get(fallback)
-		if val is not None:
-			return str(val)
-	return "unknown"
+    username = _claim(profile, config.oauth_username_from)
+    if username:
+        return username
+    for fallback in ("sub", "email", "login", "id"):
+        val = profile.get(fallback)
+        if val is not None:
+            return str(val)
+    return "unknown"
 
 
 def is_oauth_email_allowed(config: AuthConfig, email: str | None) -> bool:
-	if not config.oauth_allowed_emails:
-		return True
-	if not email:
-		return False
-	normalized = email.strip().lower()
-	patterns = [p.strip().lower() for p in config.oauth_allowed_emails if p.strip()]
-	return any(fnmatch.fnmatchcase(normalized, pattern) for pattern in patterns)
+    if not config.oauth_allowed_emails:
+        return True
+    if not email:
+        return False
+    normalized = email.strip().lower()
+    patterns = [p.strip().lower() for p in config.oauth_allowed_emails if p.strip()]
+    return any(fnmatch.fnmatchcase(normalized, pattern) for pattern in patterns)
 
 
 async def _exchange_code(
-	config: AuthConfig,
-	*,
-	code: str,
-	code_verifier: str,
+    config: AuthConfig,
+    *,
+    code: str,
+    code_verifier: str,
 ) -> dict[str, Any]:
-	prov = _provider(config)
-	data = {
-		"grant_type": "authorization_code",
-		"code": code,
-		"redirect_uri": config.oauth_redirect_uri,
-		"client_id": config.oauth_client_id,
-		"client_secret": config.oauth_client_secret,
-		"code_verifier": code_verifier,
-	}
-	headers = {"Accept": "application/json"}
-	async with httpx.AsyncClient(timeout=30.0) as client:
-		resp = await client.post(prov.token_url, data=data, headers=headers)
-	resp.raise_for_status()
-	out = resp.json()
-	if not isinstance(out, dict):
-		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid token response from provider")
-	return out
+    prov = _provider(config)
+    data = {
+        "grant_type": "authorization_code",
+        "code": code,
+        "redirect_uri": config.oauth_redirect_uri,
+        "client_id": config.oauth_client_id,
+        "client_secret": config.oauth_client_secret,
+        "code_verifier": code_verifier,
+    }
+    headers = {"Accept": "application/json"}
+    async with httpx.AsyncClient(timeout=30.0) as client:
+        resp = await client.post(prov.token_url, data=data, headers=headers)
+    resp.raise_for_status()
+    out = resp.json()
+    if not isinstance(out, dict):
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid token response from provider")
+    return out
 
 
 async def _fetch_google_profile(access_token: str) -> dict[str, Any]:
-	prov = _provider(auth_config)
-	assert prov.userinfo_url is not None
-	headers = {"Authorization": f"Bearer {access_token}"}
-	async with httpx.AsyncClient(timeout=30.0) as client:
-		resp = await client.get(prov.userinfo_url, headers=headers)
-	resp.raise_for_status()
-	profile = resp.json()
-	if not isinstance(profile, dict):
-		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid userinfo response")
-	return profile
+    prov = _provider(auth_config)
+    assert prov.userinfo_url is not None
+    headers = {"Authorization": f"Bearer {access_token}"}
+    async with httpx.AsyncClient(timeout=30.0) as client:
+        resp = await client.get(prov.userinfo_url, headers=headers)
+    resp.raise_for_status()
+    profile = resp.json()
+    if not isinstance(profile, dict):
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid userinfo response")
+    return profile
 
 
 async def _fetch_github_profile(access_token: str) -> dict[str, Any]:
-	headers = {
-		"Authorization": f"Bearer {access_token}",
-		"Accept": "application/vnd.github+json",
-	}
-	async with httpx.AsyncClient(timeout=30.0) as client:
-		user_resp = await client.get("https://api.github.com/user", headers=headers)
-		user_resp.raise_for_status()
-		profile = user_resp.json()
-		if not isinstance(profile, dict):
-			raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid GitHub user response")
-		if profile.get("email"):
-			return profile
-		email_resp = await client.get("https://api.github.com/user/emails", headers=headers)
-		if email_resp.is_success:
-			emails = email_resp.json()
-			if isinstance(emails, list):
-				for entry in emails:
-					if isinstance(entry, dict) and entry.get("primary") and entry.get("verified"):
-						profile = {**profile, "email": entry.get("email")}
-						break
-	return profile
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Accept": "application/vnd.github+json",
+    }
+    async with httpx.AsyncClient(timeout=30.0) as client:
+        user_resp = await client.get("https://api.github.com/user", headers=headers)
+        user_resp.raise_for_status()
+        profile = user_resp.json()
+        if not isinstance(profile, dict):
+            raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid GitHub user response")
+        if profile.get("email"):
+            return profile
+        email_resp = await client.get("https://api.github.com/user/emails", headers=headers)
+        if email_resp.is_success:
+            emails = email_resp.json()
+            if isinstance(emails, list):
+                for entry in emails:
+                    if isinstance(entry, dict) and entry.get("primary") and entry.get("verified"):
+                        profile = {**profile, "email": entry.get("email")}
+                        break
+    return profile
 
 
 async def _fetch_profile(config: AuthConfig, token_payload: dict[str, Any]) -> dict[str, Any]:
-	access_token = token_payload.get("access_token")
-	if not isinstance(access_token, str) or not access_token:
-		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Provider did not return an access token")
-	provider = str(config.oauth_provider).strip()
-	if provider == "google":
-		return await _fetch_google_profile(access_token)
-	if provider == "github":
-		return await _fetch_github_profile(access_token)
-	raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
+    access_token = token_payload.get("access_token")
+    if not isinstance(access_token, str) or not access_token:
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Provider did not return an access token")
+    provider = str(config.oauth_provider).strip()
+    if provider == "google":
+        return await _fetch_google_profile(access_token)
+    if provider == "github":
+        return await _fetch_github_profile(access_token)
+    raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
 
 
 def _profile_subject(profile: dict[str, Any]) -> str | None:
-	for key in ("sub", "id"):
-		val = profile.get(key)
-		if val is not None:
-			return str(val)
-	return None
+    for key in ("sub", "id"):
+        val = profile.get(key)
+        if val is not None:
+            return str(val)
+    return None
 
 
 def _profile_email(profile: dict[str, Any]) -> str | None:
-	email = profile.get("email")
-	return str(email) if email else None
+    email = profile.get("email")
+    return str(email) if email else None
 
 
 @oauth_router.get(
-	"/auth/oauth/start",
-	tags=[TAG_AUTH_OAUTH],
-	summary="Start external OAuth sign-in",
-	description=(
-		"Browser-only. Redirects to the configured provider (Google or GitHub). "
-		"On success, /auth/oauth/callback sets the same pygitweb_access_token session cookie as local login."
-	),
-	responses={303: {"description": "Redirect to identity provider"}},
+    "/auth/oauth/start",
+    tags=[TAG_AUTH_OAUTH],
+    summary="Start external OAuth sign-in",
+    description=(
+        "Browser-only. Redirects to the configured provider (Google or GitHub). "
+        "On success, /auth/oauth/callback sets the same pygitweb_access_token session cookie as local login."
+    ),
+    responses={303: {"description": "Redirect to identity provider"}},
 )
 async def oauth_start(
-	next: Annotated[str, Query()] = "/",
+    next: Annotated[str, Query()] = "/",
 ) -> RedirectResponse:
-	if not settings.AUTH:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	if not is_oauth_auth_available(auth_config):
-		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
-	prov = _provider(auth_config)
-	verifier, challenge = _pkce_pair()
-	idp_state = secrets.token_urlsafe(32)
-	sealed = _seal_oauth_state(
-		{
-			"state": idp_state,
-			"next": _safe_next_url(next),
-			"code_verifier": verifier,
-		},
-		auth_config.oauth_client_secret,
-	)
-	params = {
-		"response_type": "code",
-		"client_id": auth_config.oauth_client_id,
-		"redirect_uri": auth_config.oauth_redirect_uri,
-		"scope": prov.scopes,
-		"state": idp_state,
-		"code_challenge": challenge,
-		"code_challenge_method": "S256",
-	}
-	url = f"{prov.authorize_url}?{urlencode(params)}"
-	resp = RedirectResponse(url=url, status_code=303)
-	resp.set_cookie(
-		key=OAUTH_STATE_COOKIE_NAME,
-		value=sealed,
-		httponly=True,
-		samesite="lax",
-		max_age=OAUTH_STATE_MAX_AGE,
-		path="/",
-	)
-	return resp
+    if not settings.AUTH:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+    if not is_oauth_auth_available(auth_config):
+        raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
+    prov = _provider(auth_config)
+    verifier, challenge = _pkce_pair()
+    idp_state = secrets.token_urlsafe(32)
+    sealed = _seal_oauth_state(
+        {
+            "state": idp_state,
+            "next": _safe_next_url(next),
+            "code_verifier": verifier,
+        },
+        auth_config.oauth_client_secret,
+    )
+    params = {
+        "response_type": "code",
+        "client_id": auth_config.oauth_client_id,
+        "redirect_uri": auth_config.oauth_redirect_uri,
+        "scope": prov.scopes,
+        "state": idp_state,
+        "code_challenge": challenge,
+        "code_challenge_method": "S256",
+    }
+    url = f"{prov.authorize_url}?{urlencode(params)}"
+    resp = RedirectResponse(url=url, status_code=303)
+    resp.set_cookie(
+        key=OAUTH_STATE_COOKIE_NAME,
+        value=sealed,
+        httponly=True,
+        samesite="lax",
+        max_age=OAUTH_STATE_MAX_AGE,
+        path="/",
+    )
+    return resp
 
 
 @oauth_router.get(
-	"/auth/oauth/callback",
-	tags=[TAG_AUTH_OAUTH],
-	summary="OAuth provider callback",
-	description=(
-		"Browser-only. Called by the identity provider after sign-in. "
-		"Validates state, exchanges the code, and sets the session cookie. Not intended for Try it out."
-	),
-	responses={303: {"description": "Redirect to original next URL with session cookie set"}},
+    "/auth/oauth/callback",
+    tags=[TAG_AUTH_OAUTH],
+    summary="OAuth provider callback",
+    description=(
+        "Browser-only. Called by the identity provider after sign-in. "
+        "Validates state, exchanges the code, and sets the session cookie. Not intended for Try it out."
+    ),
+    responses={303: {"description": "Redirect to original next URL with session cookie set"}},
 )
 async def oauth_callback(
-	request: Request,
-	code: Annotated[str | None, Query()] = None,
-	state: Annotated[str | None, Query()] = None,
-	error: Annotated[str | None, Query()] = None,
+    request: Request,
+    code: Annotated[str | None, Query()] = None,
+    state: Annotated[str | None, Query()] = None,
+    error: Annotated[str | None, Query()] = None,
 ) -> RedirectResponse:
-	if not settings.AUTH:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
-	if not is_oauth_auth_available(auth_config):
-		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
-	if error:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"OAuth provider error: {error}")
-	if not code or not state:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth code or state")
-	sealed = request.cookies.get(OAUTH_STATE_COOKIE_NAME)
-	if not sealed:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth state cookie")
-	payload = _unseal_oauth_state(sealed, auth_config.oauth_client_secret)
-	if not hmac.compare_digest(str(payload.get("state", "")), state):
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth state mismatch")
-	code_verifier = payload.get("code_verifier")
-	if not isinstance(code_verifier, str) or not code_verifier:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state payload")
-	next_url = _safe_next_url(str(payload.get("next") or "/"))
-
-	token_payload = await _exchange_code(auth_config, code=code, code_verifier=code_verifier)
-	profile = await _fetch_profile(auth_config, token_payload)
-	email = _profile_email(profile)
-	if not is_oauth_email_allowed(auth_config, email):
-		raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email is not allowed to sign in")
-	username = oauth_username_from_profile(auth_config, profile)
-	sid = create_session(
-		username,
-		subject=username,
-		email=email,
-		auth_method="oauth",
-	)
-	resp = RedirectResponse(url=next_url, status_code=303)
-	resp.set_cookie(
-		key=ACCESS_TOKEN_COOKIE_NAME,
-		value=sid,
-		httponly=True,
-		samesite="lax",
-		max_age=LOGIN_COOKIE_MAX_AGE,
-		path="/",
-	)
-	resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
-	return resp
+    if not settings.AUTH:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
+    if not is_oauth_auth_available(auth_config):
+        raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
+    if error:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"OAuth provider error: {error}")
+    if not code or not state:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth code or state")
+    sealed = request.cookies.get(OAUTH_STATE_COOKIE_NAME)
+    if not sealed:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth state cookie")
+    payload = _unseal_oauth_state(sealed, auth_config.oauth_client_secret)
+    if not hmac.compare_digest(str(payload.get("state", "")), state):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth state mismatch")
+    code_verifier = payload.get("code_verifier")
+    if not isinstance(code_verifier, str) or not code_verifier:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state payload")
+    next_url = _safe_next_url(str(payload.get("next") or "/"))
+
+    token_payload = await _exchange_code(auth_config, code=code, code_verifier=code_verifier)
+    profile = await _fetch_profile(auth_config, token_payload)
+    email = _profile_email(profile)
+    if not is_oauth_email_allowed(auth_config, email):
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email is not allowed to sign in")
+    username = oauth_username_from_profile(auth_config, profile)
+    sid = create_session(
+        username,
+        subject=username,
+        email=email,
+        auth_method="oauth",
+    )
+    resp = RedirectResponse(url=next_url, status_code=303)
+    resp.set_cookie(
+        key=ACCESS_TOKEN_COOKIE_NAME,
+        value=sid,
+        httponly=True,
+        samesite="lax",
+        max_age=LOGIN_COOKIE_MAX_AGE,
+        path="/",
+    )
+    resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
+    return resp
diff --git a/pygitweb/src/pygitweb/auth_test.py b/pygitweb/src/pygitweb/auth_test.py
index f0c0820..3cb794d 100644
--- a/pygitweb/src/pygitweb/auth_test.py
+++ b/pygitweb/src/pygitweb/auth_test.py
@@ -18,13 +18,13 @@ from pydantic import ValidationError
 from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
 from pygitweb.auth_config import AuthConfig, HttpKey, LocalUser, init_auth_config, write_auth_config
 from pygitweb.auth_oauth import (
-	OAUTH_STATE_COOKIE_NAME,
-	_exchange_code,
-	_fetch_github_profile,
-	_fetch_google_profile,
-	_fetch_profile,
-	_seal_oauth_state,
-	is_oauth_email_allowed,
+    OAUTH_STATE_COOKIE_NAME,
+    _exchange_code,
+    _fetch_github_profile,
+    _fetch_google_profile,
+    _fetch_profile,
+    _seal_oauth_state,
+    is_oauth_email_allowed,
 )
 from pygitweb.config import Settings, settings
 from pygitweb.conftest import clear_client_cookies
@@ -38,786 +38,786 @@ _REAL_ASYNC_CLIENT = httpx.AsyncClient
 
 
 class _DummyResponse:
-	def __init__(self, payload: object, is_success: bool = True) -> None:
-		self._payload = payload
-		self.is_success = is_success
+    def __init__(self, payload: object, is_success: bool = True) -> None:
+        self._payload = payload
+        self.is_success = is_success
 
-	def raise_for_status(self) -> None:
-		return None
+    def raise_for_status(self) -> None:
+        return None
 
-	def json(self) -> object:
-		return self._payload
+    def json(self) -> object:
+        return self._payload
 
 
 _LOCAL_ADMIN = AuthConfig(
-	auth_mode="local",
-	local_users=[LocalUser(user="admin", password="secret")],
+    auth_mode="local",
+    local_users=[LocalUser(user="admin", password="secret")],
 )
 
 
 def _build_test_idp_app(
-	email: str = "user@example.com",
-	preferred_username: str = "oauthuser",
+    email: str = "user@example.com",
+    preferred_username: str = "oauthuser",
 ) -> FastAPI:
-	idp = FastAPI()
-
-	@idp.post("/token")
-	async def google_token(request: Request) -> dict[str, str]:
-		form = await request.form()
-		grant_type = str(form.get("grant_type") or "")
-		code = str(form.get("code") or "")
-		if grant_type != "authorization_code" or code != "auth-code":
-			return {"error": "invalid_grant"}
-		return {"access_token": "idp-access-token"}
-
-	@idp.post("/login/oauth/access_token")
-	async def github_token(request: Request) -> dict[str, str]:
-		form = await request.form()
-		code = str(form.get("code") or "")
-		if code != "auth-code":
-			return {"error": "bad_code"}
-		return {"access_token": "idp-access-token"}
-
-	@idp.get("/v1/userinfo")
-	async def google_userinfo() -> dict[str, str]:
-		return {"sub": "oauth-sub-1", "email": email, "preferred_username": preferred_username}
-
-	@idp.get("/user")
-	async def github_user() -> dict[str, object]:
-		return {"id": "123", "login": preferred_username, "email": None}
-
-	@idp.get("/user/emails")
-	async def github_emails() -> list[dict[str, object]]:
-		return [
-			{"email": email, "primary": True, "verified": True},
-			{"email": "other@example.com", "primary": False, "verified": True},
-		]
-
-	return idp
+    idp = FastAPI()
+
+    @idp.post("/token")
+    async def google_token(request: Request) -> dict[str, str]:
+        form = await request.form()
+        grant_type = str(form.get("grant_type") or "")
+        code = str(form.get("code") or "")
+        if grant_type != "authorization_code" or code != "auth-code":
+            return {"error": "invalid_grant"}
+        return {"access_token": "idp-access-token"}
+
+    @idp.post("/login/oauth/access_token")
+    async def github_token(request: Request) -> dict[str, str]:
+        form = await request.form()
+        code = str(form.get("code") or "")
+        if code != "auth-code":
+            return {"error": "bad_code"}
+        return {"access_token": "idp-access-token"}
+
+    @idp.get("/v1/userinfo")
+    async def google_userinfo() -> dict[str, str]:
+        return {"sub": "oauth-sub-1", "email": email, "preferred_username": preferred_username}
+
+    @idp.get("/user")
+    async def github_user() -> dict[str, object]:
+        return {"id": "123", "login": preferred_username, "email": None}
+
+    @idp.get("/user/emails")
+    async def github_emails() -> list[dict[str, object]]:
+        return [
+            {"email": email, "primary": True, "verified": True},
+            {"email": "other@example.com", "primary": False, "verified": True},
+        ]
+
+    return idp
 
 
 @pytest.fixture
 def client() -> Generator[TestClient, None, None]:
-	with TestClient(app) as c:
-		yield c
+    with TestClient(app) as c:
+        yield c
 
 
 @pytest.fixture
 def local_auth_config() -> Generator[AuthConfig, None, None]:
-	with (
-		patch("pygitweb.auth_config.auth_config", _LOCAL_ADMIN),
-		patch("pygitweb.auth.auth_config", _LOCAL_ADMIN),
-		patch("pygitweb.main.auth_config", _LOCAL_ADMIN),
-	):
-		yield _LOCAL_ADMIN
+    with (
+        patch("pygitweb.auth_config.auth_config", _LOCAL_ADMIN),
+        patch("pygitweb.auth.auth_config", _LOCAL_ADMIN),
+        patch("pygitweb.main.auth_config", _LOCAL_ADMIN),
+    ):
+        yield _LOCAL_ADMIN
 
 
 def test_token_disabled(client: TestClient) -> None:
-	with patch.object(settings, "AUTH", False):
-		r = client.post("/token", data={"username": "a", "password": "b"})
-		assert r.status_code == 400
-		assert r.json()["detail"] == "Authentication is disabled"
+    with patch.object(settings, "AUTH", False):
+        r = client.post("/token", data={"username": "a", "password": "b"})
+        assert r.status_code == 400
+        assert r.json()["detail"] == "Authentication is disabled"
 
 
 def test_token_success(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		r = client.post("/token", data={"username": "admin", "password": "secret"})
-	assert r.status_code == 200
-	data = r.json()
-	assert data["token_type"] == "bearer"
-	assert isinstance(data["access_token"], str)
-	assert len(data["access_token"]) >= 32
-	assert data["access_token"] != "admin"
+    with patch.object(settings, "AUTH", True):
+        r = client.post("/token", data={"username": "admin", "password": "secret"})
+    assert r.status_code == 200
+    data = r.json()
+    assert data["token_type"] == "bearer"
+    assert isinstance(data["access_token"], str)
+    assert len(data["access_token"]) >= 32
+    assert data["access_token"] != "admin"
 
 
 def test_token_wrong_password(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		r = client.post("/token", data={"username": "admin", "password": "wrong"})
-	assert r.status_code == 400
+    with patch.object(settings, "AUTH", True):
+        r = client.post("/token", data={"username": "admin", "password": "wrong"})
+    assert r.status_code == 400
 
 
 def test_users_me_with_bearer(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
-		r = client.get("/users/me", headers={"Authorization": f"Bearer {tok}"})
-		assert r.status_code == 200
-		assert r.json()["username"] == "admin"
+    with patch.object(settings, "AUTH", True):
+        tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
+        r = client.get("/users/me", headers={"Authorization": f"Bearer {tok}"})
+        assert r.status_code == 200
+        assert r.json()["username"] == "admin"
 
 
 def test_login_page_is_a_centered_card(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		page = client.get("/login")
-		failed = client.post(
-			"/login",
-			data={"username": "admin", "password": "wrong", "next": "/"},
-		)
-	assert page.status_code == 200
-	assert 'class="page page-center"' in page.text
-	assert 'class="theme-dark-auto"' in page.text
-	assert 'height="120"' in page.text
-	assert "Login to your account" in page.text
-	assert 'class="btn btn-primary w-100"' in page.text
-	assert "navbar-vertical" not in page.text
-	assert "I forgot password" not in page.text
-	assert "Remember me" not in page.text
-	assert failed.status_code == 200
-	assert "Incorrect username or password." in failed.text
-	assert 'class="page page-center"' in failed.text
+    with patch.object(settings, "AUTH", True):
+        page = client.get("/login")
+        failed = client.post(
+            "/login",
+            data={"username": "admin", "password": "wrong", "next": "/"},
+        )
+    assert page.status_code == 200
+    assert 'class="page page-center"' in page.text
+    assert 'class="theme-dark-auto"' in page.text
+    assert 'height="120"' in page.text
+    assert "Login to your account" in page.text
+    assert 'class="btn btn-primary w-100"' in page.text
+    assert "navbar-vertical" not in page.text
+    assert "I forgot password" not in page.text
+    assert "Remember me" not in page.text
+    assert failed.status_code == 200
+    assert "Incorrect username or password." in failed.text
+    assert 'class="page page-center"' in failed.text
 
 
 def test_login_form_sets_cookie_and_users_me(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		r = client.post(
-			"/login",
-			data={"username": "admin", "password": "secret", "next": "/"},
-			follow_redirects=False,
-		)
-		assert r.status_code == 303
-		r2 = client.get("/users/me")
-		assert r2.status_code == 200
-		assert r2.json()["username"] == "admin"
+    with patch.object(settings, "AUTH", True):
+        r = client.post(
+            "/login",
+            data={"username": "admin", "password": "secret", "next": "/"},
+            follow_redirects=False,
+        )
+        assert r.status_code == 303
+        r2 = client.get("/users/me")
+        assert r2.status_code == 200
+        assert r2.json()["username"] == "admin"
 
 
 def test_logout_revokes_cookie_session(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		client.post(
-			"/login",
-			data={"username": "admin", "password": "secret", "next": "/"},
-			follow_redirects=False,
-		)
-		assert client.get("/users/me").status_code == 200
-		r_out = client.get("/logout", params={"next": "/"}, follow_redirects=False)
-		assert r_out.status_code == 303
-		assert client.get("/users/me").status_code == 401
+    with patch.object(settings, "AUTH", True):
+        client.post(
+            "/login",
+            data={"username": "admin", "password": "secret", "next": "/"},
+            follow_redirects=False,
+        )
+        assert client.get("/users/me").status_code == 200
+        r_out = client.get("/logout", params={"next": "/"}, follow_redirects=False)
+        assert r_out.status_code == 303
+        assert client.get("/users/me").status_code == 401
 
 
 def test_logout_with_bearer_revokes_token(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
-		headers = {"Authorization": f"Bearer {tok}"}
-		assert client.get("/users/me", headers=headers).status_code == 200
-		r_out = client.get("/logout", params={"next": "/"}, headers=headers, follow_redirects=False)
-		assert r_out.status_code == 303
-		assert client.get("/users/me", headers=headers).status_code == 401
+    with patch.object(settings, "AUTH", True):
+        tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
+        headers = {"Authorization": f"Bearer {tok}"}
+        assert client.get("/users/me", headers=headers).status_code == 200
+        r_out = client.get("/logout", params={"next": "/"}, headers=headers, follow_redirects=False)
+        assert r_out.status_code == 303
+        assert client.get("/users/me", headers=headers).status_code == 401
 
 
 def test_projectnamevalid_no_auth_required_when_auth_disabled(client: TestClient) -> None:
-	with patch.object(settings, "AUTH", False), patch("pygitweb.main.project_visible_in_list", return_value=False):
-		r = client.get("/projectnamevalid", params={"name": "newproj"})
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", False), patch("pygitweb.main.project_visible_in_list", return_value=False):
+        r = client.get("/projectnamevalid", params={"name": "newproj"})
+    assert r.status_code == 200
 
 
 def test_projectnamevalid_401_without_credentials_when_auth_enabled(
-	client: TestClient, local_auth_config: AuthConfig
+    client: TestClient, local_auth_config: AuthConfig
 ) -> None:
-	with patch.object(settings, "AUTH", True):
-		r = client.get("/projectnamevalid", params={"name": "newproj"})
-	assert r.status_code == 401
+    with patch.object(settings, "AUTH", True):
+        r = client.get("/projectnamevalid", params={"name": "newproj"})
+    assert r.status_code == 401
 
 
 def test_projectnamevalid_ok_with_bearer_when_auth_enabled(client: TestClient, local_auth_config: AuthConfig) -> None:
-	cfg = local_auth_config.model_copy(
-		update={
-			"oauth_permissions": PermissionsMap.model_validate({
-				"*": [],
-				PERMISSION_ADD_PROJECTS: ["admin"],
-			}),
-		},
-	)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.main.project_visible_in_list", return_value=False),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
-		r = client.get(
-			"/projectnamevalid",
-			params={"name": "newproj"},
-			headers={"Authorization": f"Bearer {tok}"},
-		)
-	assert r.status_code == 200
+    cfg = local_auth_config.model_copy(
+        update={
+            "oauth_permissions": PermissionsMap.model_validate({
+                "*": [],
+                PERMISSION_ADD_PROJECTS: ["admin"],
+            }),
+        },
+    )
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.main.project_visible_in_list", return_value=False),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
+        r = client.get(
+            "/projectnamevalid",
+            params={"name": "newproj"},
+            headers={"Authorization": f"Bearer {tok}"},
+        )
+    assert r.status_code == 200
 
 
 def test_auth_config_init_creates_file_only_when_auth_enabled(tmp_path: Path) -> None:
-	cfg_path = tmp_path / "auth.json"
-	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
-	assert not cfg_path.is_file()
-	cfg = init_auth_config(s)
-	assert cfg_path.is_file()
-	assert cfg.local_users[0].user == "admin"
-	assert len(cfg.salt) == 16
-	assert cfg.local_users[0].pass_hash.startswith("scrypt$")
-	assert cfg.local_users[0].password is None
-	on_disk = json.loads(cfg_path.read_text(encoding="utf-8"))
-	assert len(on_disk["salt"]) == 16
-	user_entry = on_disk["local_users"][0]
-	assert "pass" not in user_entry
-	assert "pass_hash" in user_entry
-	if sys.platform != "win32":
-		assert oct(os.stat(cfg_path).st_mode & 0o777) == "0o600"
+    cfg_path = tmp_path / "auth.json"
+    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+    assert not cfg_path.is_file()
+    cfg = init_auth_config(s)
+    assert cfg_path.is_file()
+    assert cfg.local_users[0].user == "admin"
+    assert len(cfg.salt) == 16
+    assert cfg.local_users[0].pass_hash.startswith("scrypt$")
+    assert cfg.local_users[0].password is None
+    on_disk = json.loads(cfg_path.read_text(encoding="utf-8"))
+    assert len(on_disk["salt"]) == 16
+    user_entry = on_disk["local_users"][0]
+    assert "pass" not in user_entry
+    assert "pass_hash" in user_entry
+    if sys.platform != "win32":
+        assert oct(os.stat(cfg_path).st_mode & 0o777) == "0o600"
 
 
 def test_auth_config_no_file_when_auth_disabled(tmp_path: Path) -> None:
-	from pygitweb.auth_config import init_auth_config
-	from pygitweb.config import Settings
+    from pygitweb.auth_config import init_auth_config
+    from pygitweb.config import Settings
 
-	cfg_path = tmp_path / "auth.json"
-	s = Settings(AUTH=False, AUTH_CONFIG=str(cfg_path))
-	init_auth_config(s)
-	assert not cfg_path.is_file()
+    cfg_path = tmp_path / "auth.json"
+    s = Settings(AUTH=False, AUTH_CONFIG=str(cfg_path))
+    init_auth_config(s)
+    assert not cfg_path.is_file()
 
 
 def test_auth_config_does_not_regenerate_missing_password(tmp_path: Path) -> None:
-	cfg_path = tmp_path / "auth.json"
-	write_auth_config(
-		cfg_path,
-		AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="")]),
-	)
-	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
-	cfg = init_auth_config(s)
-	assert cfg.local_users[0].password == ""
-	assert cfg.local_users[0].pass_hash == ""
+    cfg_path = tmp_path / "auth.json"
+    write_auth_config(
+        cfg_path,
+        AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="")]),
+    )
+    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+    cfg = init_auth_config(s)
+    assert cfg.local_users[0].password == ""
+    assert cfg.local_users[0].pass_hash == ""
 
 
 def test_auth_config_adds_salt_to_existing_file(tmp_path: Path) -> None:
-	cfg_path = tmp_path / "auth.json"
-	write_auth_config(
-		cfg_path,
-		AuthConfig(
-			auth_mode="local",
-			local_users=[LocalUser(user="admin", pass_hash=hash_password("secret"))],
-		),
-	)
-	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
-	cfg = init_auth_config(s)
-	assert len(cfg.salt) == 16
-	assert verify_password("secret", cfg.local_users[0].pass_hash)
+    cfg_path = tmp_path / "auth.json"
+    write_auth_config(
+        cfg_path,
+        AuthConfig(
+            auth_mode="local",
+            local_users=[LocalUser(user="admin", pass_hash=hash_password("secret"))],
+        ),
+    )
+    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+    cfg = init_auth_config(s)
+    assert len(cfg.salt) == 16
+    assert verify_password("secret", cfg.local_users[0].pass_hash)
 
 
 def test_auth_config_migrates_plaintext_pass_to_pass_hash(tmp_path: Path) -> None:
-	cfg_path = tmp_path / "auth.json"
-	cfg_path.write_text(
-		'{"auth_mode": "local", "local_users": [{"user": "admin", "pass": "secret1234"}]}\n',
-		encoding="utf-8",
-	)
-	if sys.platform != "win32":
-		os.chmod(cfg_path, 0o600)
-	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
-	cfg = init_auth_config(s)
-	assert cfg.local_users[0].password is None
-	assert verify_password("secret1234", cfg.local_users[0].pass_hash, config_salt=cfg.salt)
-	on_disk = cfg_path.read_text(encoding="utf-8")
-	root = json.loads(on_disk)
-	assert len(root["salt"]) == 16
-	user_entry = root["local_users"][0]
-	assert "pass" not in user_entry
-	assert "pass_hash" in user_entry
+    cfg_path = tmp_path / "auth.json"
+    cfg_path.write_text(
+        '{"auth_mode": "local", "local_users": [{"user": "admin", "pass": "secret1234"}]}\n',
+        encoding="utf-8",
+    )
+    if sys.platform != "win32":
+        os.chmod(cfg_path, 0o600)
+    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+    cfg = init_auth_config(s)
+    assert cfg.local_users[0].password is None
+    assert verify_password("secret1234", cfg.local_users[0].pass_hash, config_salt=cfg.salt)
+    on_disk = cfg_path.read_text(encoding="utf-8")
+    root = json.loads(on_disk)
+    assert len(root["salt"]) == 16
+    user_entry = root["local_users"][0]
+    assert "pass" not in user_entry
+    assert "pass_hash" in user_entry
 
 
 def test_auth_config_keeps_hashed_http_key(tmp_path: Path) -> None:
-	cfg_path = tmp_path / "auth.json"
-	key_hash = hash_password("plain-key-value")
-	cfg_path.write_text(
-		json.dumps({
-			"auth_mode": "local",
-			"salt": "0123456789abcdef",
-			"local_users": [
-				{
-					"user": "admin",
-					"pass": "secret1234",
-					"email": "admin@example.com",
-					"assume_anonymous": True,
-					"keys": [
-						{
-							"id": "laptop",
-							"secret": key_hash,
-							"readonly": True,
-							"time_issued": "2026-10-08T12:00:00Z",
-							"time_expiry": "2027-10-08T12:00:00Z",
-						}
-					],
-				}
-			],
-		}),
-		encoding="utf-8",
-	)
-	if sys.platform != "win32":
-		os.chmod(cfg_path, 0o600)
-	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
-	cfg = init_auth_config(s)
-	user = cfg.local_users[0]
-	assert user.password is None
-	assert user.assume_anonymous is True
-	assert user.email == "admin@example.com"
-	assert verify_password("secret1234", user.pass_hash, config_salt=cfg.salt)
-	assert len(user.keys) == 1
-	key = user.keys[0]
-	assert key.id == "laptop"
-	assert key.readonly is True
-	assert key.time_issued == datetime(2026, 10, 8, 12, 0, tzinfo=UTC)
-	assert key.time_expiry == datetime(2027, 10, 8, 12, 0, tzinfo=UTC)
-	assert key.secret == key_hash
-	assert verify_password("plain-key-value", key.secret)
-	on_disk = json.loads(cfg_path.read_text(encoding="utf-8"))
-	stored = on_disk["local_users"][0]
-	assert "pass" not in stored
-	assert stored["keys"][0]["secret"] == key_hash
-	assert stored["keys"][0]["readonly"] is True
-	assert stored["keys"][0]["time_expiry"] == "2027-10-08T12:00:00Z"
+    cfg_path = tmp_path / "auth.json"
+    key_hash = hash_password("plain-key-value")
+    cfg_path.write_text(
+        json.dumps({
+            "auth_mode": "local",
+            "salt": "0123456789abcdef",
+            "local_users": [
+                {
+                    "user": "admin",
+                    "pass": "secret1234",
+                    "email": "admin@example.com",
+                    "assume_anonymous": True,
+                    "keys": [
+                        {
+                            "id": "laptop",
+                            "secret": key_hash,
+                            "readonly": True,
+                            "time_issued": "2026-10-08T12:00:00Z",
+                            "time_expiry": "2027-10-08T12:00:00Z",
+                        }
+                    ],
+                }
+            ],
+        }),
+        encoding="utf-8",
+    )
+    if sys.platform != "win32":
+        os.chmod(cfg_path, 0o600)
+    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+    cfg = init_auth_config(s)
+    user = cfg.local_users[0]
+    assert user.password is None
+    assert user.assume_anonymous is True
+    assert user.email == "admin@example.com"
+    assert verify_password("secret1234", user.pass_hash, config_salt=cfg.salt)
+    assert len(user.keys) == 1
+    key = user.keys[0]
+    assert key.id == "laptop"
+    assert key.readonly is True
+    assert key.time_issued == datetime(2026, 10, 8, 12, 0, tzinfo=UTC)
+    assert key.time_expiry == datetime(2027, 10, 8, 12, 0, tzinfo=UTC)
+    assert key.secret == key_hash
+    assert verify_password("plain-key-value", key.secret)
+    on_disk = json.loads(cfg_path.read_text(encoding="utf-8"))
+    stored = on_disk["local_users"][0]
+    assert "pass" not in stored
+    assert stored["keys"][0]["secret"] == key_hash
+    assert stored["keys"][0]["readonly"] is True
+    assert stored["keys"][0]["time_expiry"] == "2027-10-08T12:00:00Z"
 
 
 def test_auth_config_rejects_plaintext_http_key(tmp_path: Path) -> None:
-	cfg_path = tmp_path / "auth.json"
-	original = json.dumps({
-		"auth_mode": "local",
-		"local_users": [
-			{
-				"user": "admin",
-				"pass": "secret1234",
-				"keys": [
-					{
-						"id": "laptop",
-						"secret": "plain-key-value",
-						"time_issued": "2026-10-08T12:00:00Z",
-					}
-				],
-			}
-		],
-	})
-	cfg_path.write_text(original, encoding="utf-8")
-	if sys.platform != "win32":
-		os.chmod(cfg_path, 0o600)
-	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
-	with pytest.raises(ValidationError, match="scrypt password hash"):
-		init_auth_config(s)
-	assert cfg_path.read_text(encoding="utf-8") == original
+    cfg_path = tmp_path / "auth.json"
+    original = json.dumps({
+        "auth_mode": "local",
+        "local_users": [
+            {
+                "user": "admin",
+                "pass": "secret1234",
+                "keys": [
+                    {
+                        "id": "laptop",
+                        "secret": "plain-key-value",
+                        "time_issued": "2026-10-08T12:00:00Z",
+                    }
+                ],
+            }
+        ],
+    })
+    cfg_path.write_text(original, encoding="utf-8")
+    if sys.platform != "win32":
+        os.chmod(cfg_path, 0o600)
+    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+    with pytest.raises(ValidationError, match="scrypt password hash"):
+        init_auth_config(s)
+    assert cfg_path.read_text(encoding="utf-8") == original
 
 
 def test_http_key_rejects_duplicate_ids() -> None:
-	issued = datetime(2026, 10, 8, tzinfo=UTC)
-	with pytest.raises(ValidationError, match="duplicate http key id"):
-		LocalUser(
-			user="admin",
-			password="secret",
-			keys=[
-				HttpKey(id="laptop", secret=hash_password("one"), readonly=False, time_issued=issued),
-				HttpKey(id="laptop", secret=hash_password("two"), readonly=True, time_issued=issued),
-			],
-		)
+    issued = datetime(2026, 10, 8, tzinfo=UTC)
+    with pytest.raises(ValidationError, match="duplicate http key id"):
+        LocalUser(
+            user="admin",
+            password="secret",
+            keys=[
+                HttpKey(id="laptop", secret=hash_password("one"), readonly=False, time_issued=issued),
+                HttpKey(id="laptop", secret=hash_password("two"), readonly=True, time_issued=issued),
+            ],
+        )
 
 
 def test_http_key_strips_id_and_secret_and_rejects_blanks() -> None:
-	issued = datetime(2026, 10, 8, tzinfo=UTC)
-	key_hash = hash_password("secret")
-	key = HttpKey(id=" laptop ", secret=f"  {key_hash}  ", time_issued=issued)
-	assert key.id == "laptop"
-	assert key.secret == key_hash
-	assert key.time_expiry is None
-	with pytest.raises(ValidationError, match="non-empty"):
-		HttpKey(id="   ", secret=key_hash, time_issued=issued)
-	with pytest.raises(ValidationError, match="non-empty"):
-		HttpKey(id="laptop", secret="  ", time_issued=issued)
-	with pytest.raises(ValidationError, match="scrypt password hash"):
-		HttpKey(id="laptop", secret="plaintext", time_issued=issued)
-	with pytest.raises(ValidationError):
-		HttpKey.model_validate({"id": 1, "secret": key_hash, "time_issued": issued.isoformat()})
+    issued = datetime(2026, 10, 8, tzinfo=UTC)
+    key_hash = hash_password("secret")
+    key = HttpKey(id=" laptop ", secret=f"  {key_hash}  ", time_issued=issued)
+    assert key.id == "laptop"
+    assert key.secret == key_hash
+    assert key.time_expiry is None
+    with pytest.raises(ValidationError, match="non-empty"):
+        HttpKey(id="   ", secret=key_hash, time_issued=issued)
+    with pytest.raises(ValidationError, match="non-empty"):
+        HttpKey(id="laptop", secret="  ", time_issued=issued)
+    with pytest.raises(ValidationError, match="scrypt password hash"):
+        HttpKey(id="laptop", secret="plaintext", time_issued=issued)
+    with pytest.raises(ValidationError):
+        HttpKey.model_validate({"id": 1, "secret": key_hash, "time_issued": issued.isoformat()})
 
 
 def test_http_key_rejects_expiry_before_issue() -> None:
-	issued = datetime(2026, 10, 8, tzinfo=UTC)
-	key_hash = hash_password("secret")
-	with pytest.raises(ValidationError, match="time_expiry is before time_issued"):
-		HttpKey(
-			id="laptop",
-			secret=key_hash,
-			time_issued=issued,
-			time_expiry=datetime(2026, 10, 7, tzinfo=UTC),
-		)
-	naive_expiry = datetime(2026, 10, 7, 0, 0)
-	with pytest.raises(ValidationError, match="time_expiry is before time_issued"):
-		HttpKey(id="laptop", secret=key_hash, time_issued=issued, time_expiry=naive_expiry)
-	active = HttpKey(
-		id="laptop",
-		secret=key_hash,
-		time_issued=issued,
-		time_expiry=datetime(2026, 10, 9),
-	)
-	assert active.is_expired(datetime(2026, 10, 8, 12, tzinfo=UTC)) is False
-	assert active.is_expired(datetime(2026, 10, 9)) is True
+    issued = datetime(2026, 10, 8, tzinfo=UTC)
+    key_hash = hash_password("secret")
+    with pytest.raises(ValidationError, match="time_expiry is before time_issued"):
+        HttpKey(
+            id="laptop",
+            secret=key_hash,
+            time_issued=issued,
+            time_expiry=datetime(2026, 10, 7, tzinfo=UTC),
+        )
+    naive_expiry = datetime(2026, 10, 7, 0, 0)
+    with pytest.raises(ValidationError, match="time_expiry is before time_issued"):
+        HttpKey(id="laptop", secret=key_hash, time_issued=issued, time_expiry=naive_expiry)
+    active = HttpKey(
+        id="laptop",
+        secret=key_hash,
+        time_issued=issued,
+        time_expiry=datetime(2026, 10, 9),
+    )
+    assert active.is_expired(datetime(2026, 10, 8, 12, tzinfo=UTC)) is False
+    assert active.is_expired(datetime(2026, 10, 9)) is True
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="Unix file modes")
 def test_auth_config_rejects_insecure_permissions(tmp_path: Path) -> None:
-	from pygitweb.auth_config import AuthConfigPermissionError, init_auth_config, write_auth_config
-	from pygitweb.config import Settings
+    from pygitweb.auth_config import AuthConfigPermissionError, init_auth_config, write_auth_config
+    from pygitweb.config import Settings
 
-	cfg_path = tmp_path / "auth.json"
-	write_auth_config(
-		cfg_path,
-		AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="secret")]),
-	)
-	os.chmod(cfg_path, 0o644)
-	s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
-	with pytest.raises(AuthConfigPermissionError, match="0600"):
-		init_auth_config(s)
+    cfg_path = tmp_path / "auth.json"
+    write_auth_config(
+        cfg_path,
+        AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="secret")]),
+    )
+    os.chmod(cfg_path, 0o644)
+    s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
+    with pytest.raises(AuthConfigPermissionError, match="0600"):
+        init_auth_config(s)
 
 
 def test_local_login_stores_session_audit_fields(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		client.post(
-			"/login",
-			data={"username": "admin", "password": "secret", "next": "/"},
-			follow_redirects=False,
-		)
-		tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
-	rec = get_session(tok)
-	assert rec is not None
-	assert rec.username == "admin"
-	assert rec.auth_method == "local"
-	assert rec.subject is None
-	assert rec.email is None
+    with patch.object(settings, "AUTH", True):
+        client.post(
+            "/login",
+            data={"username": "admin", "password": "secret", "next": "/"},
+            follow_redirects=False,
+        )
+        tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
+    rec = get_session(tok)
+    assert rec is not None
+    assert rec.username == "admin"
+    assert rec.auth_method == "local"
+    assert rec.subject is None
+    assert rec.email is None
 
 
 def test_oauth_allowed_emails_glob() -> None:
-	cfg = AuthConfig(auth_mode="oauth", oauth_allowed_emails=["*@example.com", "guest@corp.org"])
-	assert is_oauth_email_allowed(cfg, "alice@example.com")
-	assert is_oauth_email_allowed(cfg, "Alice@Example.COM")
-	assert is_oauth_email_allowed(cfg, "guest@corp.org")
-	assert not is_oauth_email_allowed(cfg, "alice@other.org")
-	assert not is_oauth_email_allowed(cfg, None)
+    cfg = AuthConfig(auth_mode="oauth", oauth_allowed_emails=["*@example.com", "guest@corp.org"])
+    assert is_oauth_email_allowed(cfg, "alice@example.com")
+    assert is_oauth_email_allowed(cfg, "Alice@Example.COM")
+    assert is_oauth_email_allowed(cfg, "guest@corp.org")
+    assert not is_oauth_email_allowed(cfg, "alice@other.org")
+    assert not is_oauth_email_allowed(cfg, None)
 
 
 def test_oauth_allowed_emails_exact_match() -> None:
-	cfg = AuthConfig(auth_mode="oauth", oauth_allowed_emails=["only@example.com"])
-	assert is_oauth_email_allowed(cfg, "only@example.com")
-	assert not is_oauth_email_allowed(cfg, "other@example.com")
+    cfg = AuthConfig(auth_mode="oauth", oauth_allowed_emails=["only@example.com"])
+    assert is_oauth_email_allowed(cfg, "only@example.com")
+    assert not is_oauth_email_allowed(cfg, "other@example.com")
 
 
 def test_anonymous_user_assumes_local_public_role_for_read_permissions() -> None:
-	from pygitweb.auth import can_read_project
-
-	cfg = AuthConfig(
-		auth_mode="local",
-		local_users=[
-			LocalUser(
-				user="public",
-				password="secret",
-				email="public@example.com",
-				assume_anonymous=True,
-			),
-		],
-		oauth_permissions=PermissionsMap.model_validate({
-			"*": [],
-			"pgw.read.secret": ["public@example.com"],
-		}),
-	)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.auth.auth_config", cfg),
-	):
-		assert can_read_project("secret", None)
-		assert not can_read_project("other", None)
+    from pygitweb.auth import can_read_project
+
+    cfg = AuthConfig(
+        auth_mode="local",
+        local_users=[
+            LocalUser(
+                user="public",
+                password="secret",
+                email="public@example.com",
+                assume_anonymous=True,
+            ),
+        ],
+        oauth_permissions=PermissionsMap.model_validate({
+            "*": [],
+            "pgw.read.secret": ["public@example.com"],
+        }),
+    )
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.auth.auth_config", cfg),
+    ):
+        assert can_read_project("secret", None)
+        assert not can_read_project("other", None)
 
 
 _OAUTH_CFG = AuthConfig(
-	auth_mode="oauth",
-	oauth_provider="google",
-	oauth_client_id="test-client-id",
-	oauth_client_secret="test-client-secret",
-	oauth_redirect_uri="http://testserver/auth/oauth/callback",
-	oauth_allowed_emails=[],
-	local_users=[],
+    auth_mode="oauth",
+    oauth_provider="google",
+    oauth_client_id="test-client-id",
+    oauth_client_secret="test-client-secret",
+    oauth_redirect_uri="http://testserver/auth/oauth/callback",
+    oauth_allowed_emails=[],
+    local_users=[],
 )
 
 
 @pytest.fixture
 def oauth_auth_config() -> Generator[AuthConfig, None, None]:
-	with (
-		patch("pygitweb.auth_config.auth_config", _OAUTH_CFG),
-		patch("pygitweb.auth.auth_config", _OAUTH_CFG),
-		patch("pygitweb.auth_oauth.auth_config", _OAUTH_CFG),
-		patch("pygitweb.main.auth_config", _OAUTH_CFG),
-	):
-		yield _OAUTH_CFG
+    with (
+        patch("pygitweb.auth_config.auth_config", _OAUTH_CFG),
+        patch("pygitweb.auth.auth_config", _OAUTH_CFG),
+        patch("pygitweb.auth_oauth.auth_config", _OAUTH_CFG),
+        patch("pygitweb.main.auth_config", _OAUTH_CFG),
+    ):
+        yield _OAUTH_CFG
 
 
 def test_oauth_start_redirects_to_provider(client: TestClient, oauth_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		r = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
-	assert r.status_code == 303
-	assert "accounts.google.com" in r.headers["location"]
-	assert OAUTH_STATE_COOKIE_NAME in r.cookies
+    with patch.object(settings, "AUTH", True):
+        r = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
+    assert r.status_code == 303
+    assert "accounts.google.com" in r.headers["location"]
+    assert OAUTH_STATE_COOKIE_NAME in r.cookies
 
 
 def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: AuthConfig) -> None:
-	sealed = _seal_oauth_state(
-		{"state": "idp-state", "next": "/", "code_verifier": "verifier"},
-		oauth_auth_config.oauth_client_secret,
-	)
-	token_payload = {"access_token": "provider-token"}
-	profile = {"sub": "oauth-sub-1", "email": "user@example.com", "preferred_username": "oauthuser"}
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.auth_oauth._exchange_code", new_callable=AsyncMock, return_value=token_payload),
-		patch("pygitweb.auth_oauth._fetch_profile", new_callable=AsyncMock, return_value=profile),
-	):
-		client.cookies.set(OAUTH_STATE_COOKIE_NAME, sealed)
-		r = client.get(
-			"/auth/oauth/callback",
-			params={"code": "auth-code", "state": "idp-state"},
-			follow_redirects=False,
-		)
-		assert r.status_code == 303
-		rec = get_session(r.cookies["pygitweb_access_token"])
-		assert rec is not None
-		assert rec.username == "oauthuser"
-		assert rec.subject == "oauthuser"
-		assert rec.email == "user@example.com"
-		assert rec.auth_method == "oauth"
-		assert client.get("/users/me").json()["username"] == "oauthuser"
-		status = client.get("/auth/status").json()
-		assert status["username"] == "oauthuser"
-		assert status["gravatar_url"] == gravatar_url("user@example.com")
-		clear_client_cookies(client)
+    sealed = _seal_oauth_state(
+        {"state": "idp-state", "next": "/", "code_verifier": "verifier"},
+        oauth_auth_config.oauth_client_secret,
+    )
+    token_payload = {"access_token": "provider-token"}
+    profile = {"sub": "oauth-sub-1", "email": "user@example.com", "preferred_username": "oauthuser"}
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.auth_oauth._exchange_code", new_callable=AsyncMock, return_value=token_payload),
+        patch("pygitweb.auth_oauth._fetch_profile", new_callable=AsyncMock, return_value=profile),
+    ):
+        client.cookies.set(OAUTH_STATE_COOKIE_NAME, sealed)
+        r = client.get(
+            "/auth/oauth/callback",
+            params={"code": "auth-code", "state": "idp-state"},
+            follow_redirects=False,
+        )
+        assert r.status_code == 303
+        rec = get_session(r.cookies["pygitweb_access_token"])
+        assert rec is not None
+        assert rec.username == "oauthuser"
+        assert rec.subject == "oauthuser"
+        assert rec.email == "user@example.com"
+        assert rec.auth_method == "oauth"
+        assert client.get("/users/me").json()["username"] == "oauthuser"
+        status = client.get("/auth/status").json()
+        assert status["username"] == "oauthuser"
+        assert status["gravatar_url"] == gravatar_url("user@example.com")
+        clear_client_cookies(client)
 
 
 def test_oauth_flow_with_asgi_idp(client: TestClient, oauth_auth_config: AuthConfig) -> None:
-	idp_app = _build_test_idp_app()
-
-	async def runner() -> None:
-		transport = ASGITransport(app=idp_app)
-
-		def _idp_client_factory(*_: object, **__: object) -> httpx.AsyncClient:
-			return _REAL_ASYNC_CLIENT(transport=transport, base_url="https://idp.test")
-
-		with (
-			patch.object(settings, "AUTH", True),
-			patch("pygitweb.auth_oauth.httpx.AsyncClient", side_effect=_idp_client_factory),
-		):
-			start = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
-			assert start.status_code == 303
-			location = start.headers["location"]
-			state_values = parse_qs(urlparse(location).query).get("state", [])
-			assert state_values
-			state = state_values[0]
-			callback = client.get(
-				"/auth/oauth/callback",
-				params={"code": "auth-code", "state": state},
-				follow_redirects=False,
-			)
-			assert callback.status_code == 303
-			rec = get_session(callback.cookies[ACCESS_TOKEN_COOKIE_NAME])
-			assert rec is not None
-			assert rec.username == "oauthuser"
-			assert rec.subject == "oauthuser"
-			assert rec.email == "user@example.com"
-			assert rec.auth_method == "oauth"
-
-	asyncio.run(runner())
+    idp_app = _build_test_idp_app()
+
+    async def runner() -> None:
+        transport = ASGITransport(app=idp_app)
+
+        def _idp_client_factory(*_: object, **__: object) -> httpx.AsyncClient:
+            return _REAL_ASYNC_CLIENT(transport=transport, base_url="https://idp.test")
+
+        with (
+            patch.object(settings, "AUTH", True),
+            patch("pygitweb.auth_oauth.httpx.AsyncClient", side_effect=_idp_client_factory),
+        ):
+            start = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
+            assert start.status_code == 303
+            location = start.headers["location"]
+            state_values = parse_qs(urlparse(location).query).get("state", [])
+            assert state_values
+            state = state_values[0]
+            callback = client.get(
+                "/auth/oauth/callback",
+                params={"code": "auth-code", "state": state},
+                follow_redirects=False,
+            )
+            assert callback.status_code == 303
+            rec = get_session(callback.cookies[ACCESS_TOKEN_COOKIE_NAME])
+            assert rec is not None
+            assert rec.username == "oauthuser"
+            assert rec.subject == "oauthuser"
+            assert rec.email == "user@example.com"
+            assert rec.auth_method == "oauth"
+
+    asyncio.run(runner())
 
 
 def test_auth_status_no_gravatar_for_local_login(client: TestClient, local_auth_config: AuthConfig) -> None:
-	with patch.object(settings, "AUTH", True):
-		client.post(
-			"/login",
-			data={"username": "admin", "password": "secret", "next": "/"},
-			follow_redirects=False,
-		)
-		status = client.get("/auth/status").json()
-	assert status["username"] == "admin"
-	assert status["gravatar_url"] is None
+    with patch.object(settings, "AUTH", True):
+        client.post(
+            "/login",
+            data={"username": "admin", "password": "secret", "next": "/"},
+            follow_redirects=False,
+        )
+        status = client.get("/auth/status").json()
+    assert status["username"] == "admin"
+    assert status["gravatar_url"] is None
 
 
 def test_local_user_email_gravatar(client: TestClient) -> None:
-	cfg = AuthConfig(
-		auth_mode="local",
-		local_users=[LocalUser(user="admin", password="secret", email="  admin@example.com  ")],
-	)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		client.post(
-			"/login",
-			data={"username": "admin", "password": "secret", "next": "/"},
-			follow_redirects=False,
-		)
-		status = client.get("/auth/status").json()
-	assert status["gravatar_url"] == gravatar_url("admin@example.com")
-	rec = get_session(client.cookies[ACCESS_TOKEN_COOKIE_NAME])
-	assert rec is not None
-	assert rec.email == "admin@example.com"
+    cfg = AuthConfig(
+        auth_mode="local",
+        local_users=[LocalUser(user="admin", password="secret", email="  admin@example.com  ")],
+    )
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        client.post(
+            "/login",
+            data={"username": "admin", "password": "secret", "next": "/"},
+            follow_redirects=False,
+        )
+        status = client.get("/auth/status").json()
+    assert status["gravatar_url"] == gravatar_url("admin@example.com")
+    rec = get_session(client.cookies[ACCESS_TOKEN_COOKIE_NAME])
+    assert rec is not None
+    assert rec.email == "admin@example.com"
 
 
 def test_authenticate_local_user_disabled_in_oauth_only_mode() -> None:
-	from pygitweb.auth import authenticate_local_user
+    from pygitweb.auth import authenticate_local_user
 
-	with patch("pygitweb.auth.auth_config", _OAUTH_CFG):
-		assert authenticate_local_user("admin", "secret") is None
+    with patch("pygitweb.auth.auth_config", _OAUTH_CFG):
+        assert authenticate_local_user("admin", "secret") is None
 
 
 def test_authenticate_local_user_pass_hash() -> None:
-	from pygitweb.auth import authenticate_local_user
+    from pygitweb.auth import authenticate_local_user
 
-	cfg = AuthConfig(
-		auth_mode="local",
-		salt="0123456789abcdef",
-		local_users=[LocalUser(user="admin", pass_hash=hash_password("secret", config_salt="0123456789abcdef"))],
-	)
-	with patch("pygitweb.auth.auth_config", cfg):
-		user = authenticate_local_user("admin", "secret")
-	assert user is not None
-	assert user.username == "admin"
-	assert authenticate_local_user("admin", "wrong") is None
+    cfg = AuthConfig(
+        auth_mode="local",
+        salt="0123456789abcdef",
+        local_users=[LocalUser(user="admin", pass_hash=hash_password("secret", config_salt="0123456789abcdef"))],
+    )
+    with patch("pygitweb.auth.auth_config", cfg):
+        user = authenticate_local_user("admin", "secret")
+    assert user is not None
+    assert user.username == "admin"
+    assert authenticate_local_user("admin", "wrong") is None
 
 
 def test_exchange_code_success(oauth_auth_config: AuthConfig) -> None:
-	resp = _DummyResponse({"access_token": "tok", "other": "x"})
+    resp = _DummyResponse({"access_token": "tok", "other": "x"})
 
-	client = AsyncMock()
-	client.post.return_value = resp
-	client.__aenter__.return_value = client
-	client.__aexit__.return_value = None
+    client = AsyncMock()
+    client.post.return_value = resp
+    client.__aenter__.return_value = client
+    client.__aexit__.return_value = None
 
-	async def runner() -> None:
-		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
-			out = await _exchange_code(
-				oauth_auth_config,
-				code="auth-code",
-				code_verifier="verifier",
-			)
-		assert out["access_token"] == "tok"
+    async def runner() -> None:
+        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
+            out = await _exchange_code(
+                oauth_auth_config,
+                code="auth-code",
+                code_verifier="verifier",
+            )
+        assert out["access_token"] == "tok"
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_exchange_code_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
-	resp = _DummyResponse(["not-a-dict"])
+    resp = _DummyResponse(["not-a-dict"])
 
-	client = AsyncMock()
-	client.post.return_value = resp
-	client.__aenter__.return_value = client
-	client.__aexit__.return_value = None
+    client = AsyncMock()
+    client.post.return_value = resp
+    client.__aenter__.return_value = client
+    client.__aexit__.return_value = None
 
-	async def runner() -> None:
-		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
-			await _exchange_code(
-				oauth_auth_config,
-				code="auth-code",
-				code_verifier="verifier",
-			)
+    async def runner() -> None:
+        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
+            await _exchange_code(
+                oauth_auth_config,
+                code="auth-code",
+                code_verifier="verifier",
+            )
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_fetch_google_profile_success(oauth_auth_config: AuthConfig) -> None:
-	resp = _DummyResponse({"sub": "123", "email": "user@example.com"})
+    resp = _DummyResponse({"sub": "123", "email": "user@example.com"})
 
-	client = AsyncMock()
-	client.get.return_value = resp
-	client.__aenter__.return_value = client
-	client.__aexit__.return_value = None
+    client = AsyncMock()
+    client.get.return_value = resp
+    client.__aenter__.return_value = client
+    client.__aexit__.return_value = None
 
-	async def runner() -> None:
-		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
-			profile = await _fetch_google_profile("access-token")
-		assert profile["email"] == "user@example.com"
+    async def runner() -> None:
+        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
+            profile = await _fetch_google_profile("access-token")
+        assert profile["email"] == "user@example.com"
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_fetch_google_profile_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
-	resp = _DummyResponse("not-a-dict")
+    resp = _DummyResponse("not-a-dict")
 
-	client = AsyncMock()
-	client.get.return_value = resp
-	client.__aenter__.return_value = client
-	client.__aexit__.return_value = None
+    client = AsyncMock()
+    client.get.return_value = resp
+    client.__aenter__.return_value = client
+    client.__aexit__.return_value = None
 
-	async def runner() -> None:
-		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
-			await _fetch_google_profile("access-token")
+    async def runner() -> None:
+        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
+            await _fetch_google_profile("access-token")
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_fetch_github_profile_uses_primary_verified_email() -> None:
-	resp_user = _DummyResponse({"login": "octocat", "email": None})
-	resp_emails = _DummyResponse([
-		{"email": "secondary@example.com", "primary": False, "verified": True},
-		{"email": "primary@example.com", "primary": True, "verified": True},
-	])
+    resp_user = _DummyResponse({"login": "octocat", "email": None})
+    resp_emails = _DummyResponse([
+        {"email": "secondary@example.com", "primary": False, "verified": True},
+        {"email": "primary@example.com", "primary": True, "verified": True},
+    ])
 
-	client = AsyncMock()
-	client.get.side_effect = (resp_user, resp_emails)
-	client.__aenter__.return_value = client
-	client.__aexit__.return_value = None
+    client = AsyncMock()
+    client.get.side_effect = (resp_user, resp_emails)
+    client.__aenter__.return_value = client
+    client.__aexit__.return_value = None
 
-	async def runner() -> None:
-		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
-			profile = await _fetch_github_profile("access-token")
-		assert profile["email"] == "primary@example.com"
+    async def runner() -> None:
+        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
+            profile = await _fetch_github_profile("access-token")
+        assert profile["email"] == "primary@example.com"
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_fetch_github_profile_rejects_non_mapping() -> None:
-	resp_user = _DummyResponse("not-a-dict")
+    resp_user = _DummyResponse("not-a-dict")
 
-	client = AsyncMock()
-	client.get.return_value = resp_user
-	client.__aenter__.return_value = client
-	client.__aexit__.return_value = None
+    client = AsyncMock()
+    client.get.return_value = resp_user
+    client.__aenter__.return_value = client
+    client.__aexit__.return_value = None
 
-	async def runner() -> None:
-		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
-			await _fetch_github_profile("access-token")
+    async def runner() -> None:
+        with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
+            await _fetch_github_profile("access-token")
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_fetch_profile_google(oauth_auth_config: AuthConfig) -> None:
-	token_payload = {"access_token": "tok"}
-	profile: dict[str, object] = {"sub": "123"}
+    token_payload = {"access_token": "tok"}
+    profile: dict[str, object] = {"sub": "123"}
 
-	async def runner() -> None:
-		with patch("pygitweb.auth_oauth._fetch_google_profile", new_callable=AsyncMock, return_value=profile):
-			out = await _fetch_profile(oauth_auth_config, token_payload)
-		assert out is profile
+    async def runner() -> None:
+        with patch("pygitweb.auth_oauth._fetch_google_profile", new_callable=AsyncMock, return_value=profile):
+            out = await _fetch_profile(oauth_auth_config, token_payload)
+        assert out is profile
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_fetch_profile_github() -> None:
-	cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "github"})
-	token_payload = {"access_token": "tok"}
-	profile: dict[str, object] = {"id": "123"}
+    cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "github"})
+    token_payload = {"access_token": "tok"}
+    profile: dict[str, object] = {"id": "123"}
 
-	async def runner() -> None:
-		with patch("pygitweb.auth_oauth._fetch_github_profile", new_callable=AsyncMock, return_value=profile):
-			out = await _fetch_profile(cfg, token_payload)
-		assert out is profile
+    async def runner() -> None:
+        with patch("pygitweb.auth_oauth._fetch_github_profile", new_callable=AsyncMock, return_value=profile):
+            out = await _fetch_profile(cfg, token_payload)
+        assert out is profile
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_fetch_profile_missing_access_token(oauth_auth_config: AuthConfig) -> None:
-	async def runner() -> None:
-		with pytest.raises(HTTPException):
-			await _fetch_profile(oauth_auth_config, {})
+    async def runner() -> None:
+        with pytest.raises(HTTPException):
+            await _fetch_profile(oauth_auth_config, {})
 
-	asyncio.run(runner())
+    asyncio.run(runner())
 
 
 def test_fetch_profile_unknown_provider() -> None:
-	cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "other"})
+    cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "other"})
 
-	async def runner() -> None:
-		with pytest.raises(HTTPException):
-			await _fetch_profile(cfg, {"access_token": "tok"})
+    async def runner() -> None:
+        with pytest.raises(HTTPException):
+            await _fetch_profile(cfg, {"access_token": "tok"})
 
-	asyncio.run(runner())
+    asyncio.run(runner())
diff --git a/pygitweb/src/pygitweb/blog_routes_test.py b/pygitweb/src/pygitweb/blog_routes_test.py
index 102239f..370990b 100644
--- a/pygitweb/src/pygitweb/blog_routes_test.py
+++ b/pygitweb/src/pygitweb/blog_routes_test.py
@@ -17,139 +17,139 @@ SIG = Signature("tester", "tester@example.com")
 
 
 def _commit_tree(repo: pygit2.Repository, repo_dir: Path, files: dict[str, str], message: str) -> None:
-	for rel, content in files.items():
-		path = repo_dir / rel
-		path.parent.mkdir(parents=True, exist_ok=True)
-		path.write_text(content, encoding="utf-8")
-	index = repo.index
-	for rel in files:
-		index.add(rel)
-	index.write()
-	tree = index.write_tree()
-	parents = [repo.head.target] if not repo.head_is_unborn else []
-	repo.create_commit("HEAD", SIG, SIG, message, tree, parents)
+    for rel, content in files.items():
+        path = repo_dir / rel
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text(content, encoding="utf-8")
+    index = repo.index
+    for rel in files:
+        index.add(rel)
+    index.write()
+    tree = index.write_tree()
+    parents = [repo.head.target] if not repo.head_is_unborn else []
+    repo.create_commit("HEAD", SIG, SIG, message, tree, parents)
 
 
 @pytest.fixture
 def blog_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
-	root = tmp_path / "blog-root"
-	repo_dir = root / "demo"
-	repo_dir.mkdir(parents=True)
-	repo = pygit2.init_repository(str(repo_dir), bare=False)
-	manifest = [
-		{
-			"name": "Hello Blog",
-			"url": "?article=articles/2024.01.01/post.md",
-			"tags": "intro test",
-			"created": "Jan 1 2024",
-			"preview": "",
-			"prev": "",
-			"next": "",
-		}
-	]
-	_commit_tree(
-		repo,
-		repo_dir,
-		{
-			"README.md": "seed\n",
-			"articles/manifest.json": json.dumps(manifest),
-			"articles/2024.01.01/post.md": "# Hello\n\nBody text.\n",
-			"assets/note.txt": "asset\n",
-		},
-		"add blog",
-	)
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(root)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "AUTH", False),
-	):
-		yield {"project": "demo", "client": TestClient(app), "root": str(root)}
+    root = tmp_path / "blog-root"
+    repo_dir = root / "demo"
+    repo_dir.mkdir(parents=True)
+    repo = pygit2.init_repository(str(repo_dir), bare=False)
+    manifest = [
+        {
+            "name": "Hello Blog",
+            "url": "?article=articles/2024.01.01/post.md",
+            "tags": "intro test",
+            "created": "Jan 1 2024",
+            "preview": "",
+            "prev": "",
+            "next": "",
+        }
+    ]
+    _commit_tree(
+        repo,
+        repo_dir,
+        {
+            "README.md": "seed\n",
+            "articles/manifest.json": json.dumps(manifest),
+            "articles/2024.01.01/post.md": "# Hello\n\nBody text.\n",
+            "assets/note.txt": "asset\n",
+        },
+        "add blog",
+    )
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(root)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "AUTH", False),
+    ):
+        yield {"project": "demo", "client": TestClient(app), "root": str(root)}
 
 
 @pytest.fixture
 def no_blog_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
-	root = tmp_path / "no-blog-root"
-	repo_dir = root / "plain"
-	repo_dir.mkdir(parents=True)
-	repo = pygit2.init_repository(str(repo_dir), bare=False)
-	_commit_tree(repo, repo_dir, {"README.md": "no blog here\n"}, "initial")
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(root)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "AUTH", False),
-	):
-		yield {"project": "plain", "client": TestClient(app)}
+    root = tmp_path / "no-blog-root"
+    repo_dir = root / "plain"
+    repo_dir.mkdir(parents=True)
+    repo = pygit2.init_repository(str(repo_dir), bare=False)
+    _commit_tree(repo, repo_dir, {"README.md": "no blog here\n"}, "initial")
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(root)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "AUTH", False),
+    ):
+        yield {"project": "plain", "client": TestClient(app)}
 
 
 def test_blog_page_renders_when_manifest_exists(blog_env: dict[str, str | TestClient]) -> None:
-	client = blog_env["client"]
-	assert isinstance(client, TestClient)
-	resp = client.get(f"/project/{blog_env['project']}/blog/")
-	assert resp.status_code == 200
-	assert 'id="blog-root"' in resp.text
-	assert 'data-manifest="articles/manifest.json"' in resp.text
-	assert f'data-content-base="/project/{blog_env["project"]}/blog/raw/"' in resp.text
-	assert "/static/blog.js" in resp.text
-	assert "navbar-vertical" not in resp.text
-	header, _, body = resp.text.partition('<div class="page-wrapper">')
-	assert 'class="navbar navbar-expand-md blog-topbar' in header
-	assert "blog-search" in header
-	assert "blog-search" not in body
-	assert 'id="navbar-user"' in resp.text
-	assert 'href="/"' in resp.text
-	assert f"/login?next=/project/{blog_env['project']}/blog/" in resp.text
+    client = blog_env["client"]
+    assert isinstance(client, TestClient)
+    resp = client.get(f"/project/{blog_env['project']}/blog/")
+    assert resp.status_code == 200
+    assert 'id="blog-root"' in resp.text
+    assert 'data-manifest="articles/manifest.json"' in resp.text
+    assert f'data-content-base="/project/{blog_env["project"]}/blog/raw/"' in resp.text
+    assert "/static/blog.js" in resp.text
+    assert "navbar-vertical" not in resp.text
+    header, _, body = resp.text.partition('<div class="page-wrapper">')
+    assert 'class="navbar navbar-expand-md blog-topbar' in header
+    assert "blog-search" in header
+    assert "blog-search" not in body
+    assert 'id="navbar-user"' in resp.text
+    assert 'href="/"' in resp.text
+    assert f"/login?next=/project/{blog_env['project']}/blog/" in resp.text
 
 
 def test_blog_page_404_without_manifest(no_blog_env: dict[str, str | TestClient]) -> None:
-	client = no_blog_env["client"]
-	assert isinstance(client, TestClient)
-	resp = client.get(f"/project/{no_blog_env['project']}/blog/")
-	assert resp.status_code == 404
+    client = no_blog_env["client"]
+    assert isinstance(client, TestClient)
+    resp = client.get(f"/project/{no_blog_env['project']}/blog/")
+    assert resp.status_code == 404
 
 
 def test_blog_raw_serves_manifest_and_markdown(blog_env: dict[str, str | TestClient]) -> None:
-	client = blog_env["client"]
-	assert isinstance(client, TestClient)
-	project = blog_env["project"]
-	manifest_resp = client.get(f"/project/{project}/blog/raw/articles/manifest.json")
-	assert manifest_resp.status_code == 200
-	payload = manifest_resp.json()
-	assert payload[0]["name"] == "Hello Blog"
+    client = blog_env["client"]
+    assert isinstance(client, TestClient)
+    project = blog_env["project"]
+    manifest_resp = client.get(f"/project/{project}/blog/raw/articles/manifest.json")
+    assert manifest_resp.status_code == 200
+    payload = manifest_resp.json()
+    assert payload[0]["name"] == "Hello Blog"
 
-	md_resp = client.get(f"/project/{project}/blog/raw/articles/2024.01.01/post.md")
-	assert md_resp.status_code == 200
-	assert "# Hello" in md_resp.text
+    md_resp = client.get(f"/project/{project}/blog/raw/articles/2024.01.01/post.md")
+    assert md_resp.status_code == 200
+    assert "# Hello" in md_resp.text
 
-	asset_resp = client.get(f"/project/{project}/blog/raw/assets/note.txt")
-	assert asset_resp.status_code == 200
-	assert asset_resp.text == "asset\n"
+    asset_resp = client.get(f"/project/{project}/blog/raw/assets/note.txt")
+    assert asset_resp.status_code == 200
+    assert asset_resp.text == "asset\n"
 
 
 def test_blog_raw_rejects_missing_and_traversal(blog_env: dict[str, str | TestClient]) -> None:
-	client = blog_env["client"]
-	assert isinstance(client, TestClient)
-	project = blog_env["project"]
-	assert client.get(f"/project/{project}/blog/raw/missing.md").status_code == 404
-	assert client.get(f"/project/{project}/blog/raw/../README.md").status_code == 404
+    client = blog_env["client"]
+    assert isinstance(client, TestClient)
+    project = blog_env["project"]
+    assert client.get(f"/project/{project}/blog/raw/missing.md").status_code == 404
+    assert client.get(f"/project/{project}/blog/raw/../README.md").status_code == 404
 
 
 def test_summary_includes_blog_link(blog_env: dict[str, str | TestClient]) -> None:
-	client = blog_env["client"]
-	assert isinstance(client, TestClient)
-	resp = client.get(f"/project/{blog_env['project']}")
-	assert resp.status_code == 200
-	assert ">Blog</td>" in resp.text or ">Blog<" in resp.text
-	assert f'href="/project/{blog_env["project"]}/blog/">view blog</a>' in resp.text
+    client = blog_env["client"]
+    assert isinstance(client, TestClient)
+    resp = client.get(f"/project/{blog_env['project']}")
+    assert resp.status_code == 200
+    assert ">Blog</td>" in resp.text or ">Blog<" in resp.text
+    assert f'href="/project/{blog_env["project"]}/blog/">view blog</a>' in resp.text
 
 
 def test_summary_shows_no_blog_when_missing(no_blog_env: dict[str, str | TestClient]) -> None:
-	client = no_blog_env["client"]
-	assert isinstance(client, TestClient)
-	resp = client.get(f"/project/{no_blog_env['project']}")
-	assert resp.status_code == 200
-	assert ">no blog</a>" in resp.text
-	assert 'style="color: #999; cursor: not-allowed;"' in resp.text
+    client = no_blog_env["client"]
+    assert isinstance(client, TestClient)
+    resp = client.get(f"/project/{no_blog_env['project']}")
+    assert resp.status_code == 200
+    assert ">no blog</a>" in resp.text
+    assert 'style="color: #999; cursor: not-allowed;"' in resp.text
diff --git a/pygitweb/src/pygitweb/board_refs.py b/pygitweb/src/pygitweb/board_refs.py
index 1d840f5..ce129ca 100644
--- a/pygitweb/src/pygitweb/board_refs.py
+++ b/pygitweb/src/pygitweb/board_refs.py
@@ -18,137 +18,137 @@ BOARD_REF_MANIFEST_COMMIT_LIMIT = 100
 
 
 class BoardRefManifestEntry(TypedDict):
-	name: str
-	insert: str
-	url: str
-	tags: str
-	snippet: str
-	created: str
+    name: str
+    insert: str
+    url: str
+    tags: str
+    snippet: str
+    created: str
 
 
 class BoardRefManifest(TypedDict):
-	commits: list[BoardRefManifestEntry]
-	refs: list[BoardRefManifestEntry]
+    commits: list[BoardRefManifestEntry]
+    refs: list[BoardRefManifestEntry]
 
 
 def _format_epoch(epoch: int | None, tz_offset: int | None = None) -> str:
-	if epoch is None:
-		return ""
-	try:
-		tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
-		return datetime.fromtimestamp(epoch, tz=tz).strftime("%Y-%m-%d %H:%M:%S")
-	except (ValueError, OSError):
-		return ""
+    if epoch is None:
+        return ""
+    try:
+        tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
+        return datetime.fromtimestamp(epoch, tz=tz).strftime("%Y-%m-%d %H:%M:%S")
+    except (ValueError, OSError):
+        return ""
 
 
 def _project_url(project: str) -> str:
-	return f"/project/{quote(project, safe='/')}"
+    return f"/project/{quote(project, safe='/')}"
 
 
 def _board_task_url(project: str, board_name: str, task_ref: str) -> str:
-	base = f"{_project_url(project)}/board/"
-	qs = f"b={quote(board_name, safe='')}&task={quote(task_ref, safe='')}"
-	return f"{base}?{qs}" if board_name != "Tasks" else f"{base}?task={quote(task_ref, safe='')}"
+    base = f"{_project_url(project)}/board/"
+    qs = f"b={quote(board_name, safe='')}&task={quote(task_ref, safe='')}"
+    return f"{base}?{qs}" if board_name != "Tasks" else f"{base}?task={quote(task_ref, safe='')}"
 
 
 def _task_display_id(ref: str) -> str:
-	slug = ref.removeprefix(TASK_REF_PREFIX)
-	if slug.startswith("task_"):
-		return slug.removeprefix("task_")
-	return slug
+    slug = ref.removeprefix(TASK_REF_PREFIX)
+    if slug.startswith("task_"):
+        return slug.removeprefix("task_")
+    return slug
 
 
 def _manifest_commits(project: str) -> list[BoardRefManifestEntry]:
-	project_url = _project_url(project)
-	entries: list[BoardRefManifestEntry] = []
-	for commit in get_commit_history(project, ref=None, max_count=BOARD_REF_MANIFEST_COMMIT_LIMIT):
-		oid = str(commit.get("oid") or "")
-		if not oid:
-			continue
-		short = oid[:7]
-		subject = str(commit.get("subject") or "")
-		author = str(commit.get("author") or "")
-		created = _format_epoch(commit.get("author_epoch"), commit.get("author_tz"))
-		search_tags = " ".join(part.lower() for part in (oid, short, subject, author) if part)
-		entries.append({
-			"name": f"{short} {subject}".strip(),
-			"insert": short,
-			"url": f"{project_url}?a=commit&h={quote(oid, safe='')}",
-			"tags": search_tags,
-			"snippet": author,
-			"created": created,
-		})
-	return entries
+    project_url = _project_url(project)
+    entries: list[BoardRefManifestEntry] = []
+    for commit in get_commit_history(project, ref=None, max_count=BOARD_REF_MANIFEST_COMMIT_LIMIT):
+        oid = str(commit.get("oid") or "")
+        if not oid:
+            continue
+        short = oid[:7]
+        subject = str(commit.get("subject") or "")
+        author = str(commit.get("author") or "")
+        created = _format_epoch(commit.get("author_epoch"), commit.get("author_tz"))
+        search_tags = " ".join(part.lower() for part in (oid, short, subject, author) if part)
+        entries.append({
+            "name": f"{short} {subject}".strip(),
+            "insert": short,
+            "url": f"{project_url}?a=commit&h={quote(oid, safe='')}",
+            "tags": search_tags,
+            "snippet": author,
+            "created": created,
+        })
+    return entries
 
 
 def _manifest_board_tasks(project: str, board_name: str) -> list[BoardRefManifestEntry]:
-	entries: list[BoardRefManifestEntry] = []
-	seen_refs: set[str] = set()
-	try:
-		columns = get_board_tasks_grouped(project, board_name)
-	except KeyError:
-		return entries
-	for column in columns:
-		for task in column["tasks"]:
-			task_ref = str(task.get("ref") or "")
-			if not task_ref or task_ref in seen_refs:
-				continue
-			seen_refs.add(task_ref)
-			task_id = str(task.get("id") or _task_display_id(task_ref))
-			title = str(task.get("title") or "Untitled")
-			slug = task_ref.removeprefix("refs/tags/")
-			created = str(task.get("updated_at") or task.get("created_at") or "")
-			search_tags = " ".join(
-				part.lower() for part in (task_id, slug, task_ref, title, str(task.get("status") or "")) if part
-			)
-			entries.append({
-				"name": title,
-				"insert": task_id,
-				"url": _board_task_url(project, board_name, task_ref),
-				"tags": search_tags,
-				"snippet": f"task {task_id}",
-				"created": created,
-			})
-	return entries
+    entries: list[BoardRefManifestEntry] = []
+    seen_refs: set[str] = set()
+    try:
+        columns = get_board_tasks_grouped(project, board_name)
+    except KeyError:
+        return entries
+    for column in columns:
+        for task in column["tasks"]:
+            task_ref = str(task.get("ref") or "")
+            if not task_ref or task_ref in seen_refs:
+                continue
+            seen_refs.add(task_ref)
+            task_id = str(task.get("id") or _task_display_id(task_ref))
+            title = str(task.get("title") or "Untitled")
+            slug = task_ref.removeprefix("refs/tags/")
+            created = str(task.get("updated_at") or task.get("created_at") or "")
+            search_tags = " ".join(
+                part.lower() for part in (task_id, slug, task_ref, title, str(task.get("status") or "")) if part
+            )
+            entries.append({
+                "name": title,
+                "insert": task_id,
+                "url": _board_task_url(project, board_name, task_ref),
+                "tags": search_tags,
+                "snippet": f"task {task_id}",
+                "created": created,
+            })
+    return entries
 
 
 def _manifest_merge_requests(project: str) -> list[BoardRefManifestEntry]:
-	project_url = _project_url(project)
-	repo = Repository(os.path.join(settings.PROJECTROOT, project))
-	entries: list[BoardRefManifestEntry] = []
-	for ref_name in repo.references:
-		if not ref_name.startswith(MR_REF_PREFIX):
-			continue
-		try:
-			oid = repo.references[ref_name].resolve().target
-			obj = repo[oid]
-			if not isinstance(obj, Tag):
-				continue
-			mr = get_merge_request_by_oid(repo, Oid(hex=str(oid)))
-		except (ValueError, KeyError, TypeError):
-			continue
-		slug = ref_name.removeprefix(MR_REF_PREFIX)
-		title = mr.title or slug
-		created = mr.updated_at.isoformat() if mr.updated_at else ""
-		search_tags = " ".join(
-			part.lower() for part in (slug, ref_name, title, mr.description or "", mr.ours or "") if part
-		)
-		entries.append({
-			"name": title,
-			"insert": slug,
-			"url": f"{project_url}?a=tag&h={quote(ref_name, safe='')}",
-			"tags": search_tags,
-			"snippet": f"merge request {slug}",
-			"created": created,
-		})
-	return entries
+    project_url = _project_url(project)
+    repo = Repository(os.path.join(settings.PROJECTROOT, project))
+    entries: list[BoardRefManifestEntry] = []
+    for ref_name in repo.references:
+        if not ref_name.startswith(MR_REF_PREFIX):
+            continue
+        try:
+            oid = repo.references[ref_name].resolve().target
+            obj = repo[oid]
+            if not isinstance(obj, Tag):
+                continue
+            mr = get_merge_request_by_oid(repo, Oid(hex=str(oid)))
+        except (ValueError, KeyError, TypeError):
+            continue
+        slug = ref_name.removeprefix(MR_REF_PREFIX)
+        title = mr.title or slug
+        created = mr.updated_at.isoformat() if mr.updated_at else ""
+        search_tags = " ".join(
+            part.lower() for part in (slug, ref_name, title, mr.description or "", mr.ours or "") if part
+        )
+        entries.append({
+            "name": title,
+            "insert": slug,
+            "url": f"{project_url}?a=tag&h={quote(ref_name, safe='')}",
+            "tags": search_tags,
+            "snippet": f"merge request {slug}",
+            "created": created,
+        })
+    return entries
 
 
 def board_ref_manifest(project: str, board_name: str) -> BoardRefManifest:
-	refs = _manifest_board_tasks(project, board_name)
-	refs.extend(_manifest_merge_requests(project))
-	refs.sort(key=lambda entry: (entry["created"], entry["name"]), reverse=True)
-	return {
-		"commits": _manifest_commits(project),
-		"refs": refs,
-	}
+    refs = _manifest_board_tasks(project, board_name)
+    refs.extend(_manifest_merge_requests(project))
+    refs.sort(key=lambda entry: (entry["created"], entry["name"]), reverse=True)
+    return {
+        "commits": _manifest_commits(project),
+        "refs": refs,
+    }
diff --git a/pygitweb/src/pygitweb/board_refs_test.py b/pygitweb/src/pygitweb/board_refs_test.py
index 4123624..8291e8e 100644
--- a/pygitweb/src/pygitweb/board_refs_test.py
+++ b/pygitweb/src/pygitweb/board_refs_test.py
@@ -19,88 +19,88 @@ SIG = Signature("tester", "tester@example.com")
 
 @pytest.fixture
 def manifest_env(tmp_path: Path) -> dict[str, str | TestClient]:
-	root = tmp_path / "manifest"
-	repo_dir = root / "demo"
-	repo_dir.mkdir(parents=True)
-	repo = init_repository(str(repo_dir), bare=False)
-
-	index = repo.index
-	(repo_dir / "README").write_text("hello\n", encoding="utf-8")
-	index.add("README")
-	index.write()
-	tree = index.write_tree()
-	first = repo.create_commit("refs/heads/main", SIG, SIG, "Initial commit", tree, [])
-	second_tree = index.write_tree()
-	second = repo.create_commit("refs/heads/main", SIG, SIG, "Second commit", second_tree, [first])
-	repo.set_head(second)
-
-	board_ref = f"{BOARD_REF_PREFIX}Tasks"
-	board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main")
-	task_ref = f"{TASK_REF_PREFIX}task_1782144988054"
-	task = Task(
-		EMPTY_TREE_OID,
-		task_ref,
-		tagger="",
-		title="Ref lookup task",
-		description="See @8054",
-		status=Task.Status.TODO,
-	)
-	task_oid = task.write(repo)
-	board.tasks = [str(task_oid)]
-	board.update_message()
-	board.write(repo)
-
-	mr_ref = f"{MR_REF_PREFIX}aaaabbbbccccdddd"
-	mr = MergeRequest(
-		target=second,
-		opener=SIG,
-		ours="refs/heads/main",
-		title="Test MR",
-		description="Merge me",
-		name=mr_ref,
-	)
-	mr.write(repo)
-
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(root)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "AUTH", False),
-	):
-		yield {"client": TestClient(app), "project": "demo", "task_ref": task_ref, "mr_ref": mr_ref}
+    root = tmp_path / "manifest"
+    repo_dir = root / "demo"
+    repo_dir.mkdir(parents=True)
+    repo = init_repository(str(repo_dir), bare=False)
+
+    index = repo.index
+    (repo_dir / "README").write_text("hello\n", encoding="utf-8")
+    index.add("README")
+    index.write()
+    tree = index.write_tree()
+    first = repo.create_commit("refs/heads/main", SIG, SIG, "Initial commit", tree, [])
+    second_tree = index.write_tree()
+    second = repo.create_commit("refs/heads/main", SIG, SIG, "Second commit", second_tree, [first])
+    repo.set_head(second)
+
+    board_ref = f"{BOARD_REF_PREFIX}Tasks"
+    board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main")
+    task_ref = f"{TASK_REF_PREFIX}task_1782144988054"
+    task = Task(
+        EMPTY_TREE_OID,
+        task_ref,
+        tagger="",
+        title="Ref lookup task",
+        description="See @8054",
+        status=Task.Status.TODO,
+    )
+    task_oid = task.write(repo)
+    board.tasks = [str(task_oid)]
+    board.update_message()
+    board.write(repo)
+
+    mr_ref = f"{MR_REF_PREFIX}aaaabbbbccccdddd"
+    mr = MergeRequest(
+        target=second,
+        opener=SIG,
+        ours="refs/heads/main",
+        title="Test MR",
+        description="Merge me",
+        name=mr_ref,
+    )
+    mr.write(repo)
+
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(root)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "AUTH", False),
+    ):
+        yield {"client": TestClient(app), "project": "demo", "task_ref": task_ref, "mr_ref": mr_ref}
 
 
 def test_board_ref_manifest_includes_commits_tasks_and_merge_requests(
-	manifest_env: dict[str, str | TestClient],
+    manifest_env: dict[str, str | TestClient],
 ) -> None:
-	manifest = board_ref_manifest(str(manifest_env["project"]), "Tasks")
+    manifest = board_ref_manifest(str(manifest_env["project"]), "Tasks")
 
-	assert len(manifest["commits"]) == 2
-	assert "Second commit" in manifest["commits"][0]["name"]
-	assert manifest["commits"][0]["insert"] == manifest["commits"][0]["name"].split()[0]
+    assert len(manifest["commits"]) == 2
+    assert "Second commit" in manifest["commits"][0]["name"]
+    assert manifest["commits"][0]["insert"] == manifest["commits"][0]["name"].split()[0]
 
-	task_entries = [entry for entry in manifest["refs"] if "1782144988054" in entry["tags"]]
-	assert len(task_entries) == 1
-	assert task_entries[0]["insert"] == "1782144988054"
-	assert task_entries[0]["name"] == "Ref lookup task"
-	assert "task=refs" in task_entries[0]["url"]
-	assert "task_1782144988054" in task_entries[0]["url"]
+    task_entries = [entry for entry in manifest["refs"] if "1782144988054" in entry["tags"]]
+    assert len(task_entries) == 1
+    assert task_entries[0]["insert"] == "1782144988054"
+    assert task_entries[0]["name"] == "Ref lookup task"
+    assert "task=refs" in task_entries[0]["url"]
+    assert "task_1782144988054" in task_entries[0]["url"]
 
-	mr_entries = [entry for entry in manifest["refs"] if entry["insert"] == "aaaabbbbccccdddd"]
-	assert len(mr_entries) == 1
-	assert mr_entries[0]["name"] == "Test MR"
-	assert "a=tag" in mr_entries[0]["url"]
+    mr_entries = [entry for entry in manifest["refs"] if entry["insert"] == "aaaabbbbccccdddd"]
+    assert len(mr_entries) == 1
+    assert mr_entries[0]["name"] == "Test MR"
+    assert "a=tag" in mr_entries[0]["url"]
 
 
 def test_board_ref_manifest_route(manifest_env: dict[str, str | TestClient]) -> None:
-	client = manifest_env["client"]
-	assert isinstance(client, TestClient)
-	resp = client.get(
-		f"/project/{manifest_env['project']}/board-ref-manifest",
-		params={"b": "Tasks"},
-	)
-	assert resp.status_code == 200
-	body = resp.json()
-	assert len(body["commits"]) == 2
-	assert any(entry["insert"] == "1782144988054" for entry in body["refs"])
+    client = manifest_env["client"]
+    assert isinstance(client, TestClient)
+    resp = client.get(
+        f"/project/{manifest_env['project']}/board-ref-manifest",
+        params={"b": "Tasks"},
+    )
+    assert resp.status_code == 200
+    body = resp.json()
+    assert len(body["commits"]) == 2
+    assert any(entry["insert"] == "1782144988054" for entry in body["refs"])
diff --git a/pygitweb/src/pygitweb/boards_permissions_test.py b/pygitweb/src/pygitweb/boards_permissions_test.py
index 51fc65b..3ebce27 100644
--- a/pygitweb/src/pygitweb/boards_permissions_test.py
+++ b/pygitweb/src/pygitweb/boards_permissions_test.py
@@ -17,127 +17,127 @@ from pygitweb.permissions import Permission, permission_key, permissions_with_de
 from pygitweb.tasks import board_router
 
 _BOARDS_AUTH = AuthConfig(
-	auth_mode="local",
-	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=permissions_with_defaults({
-		permission_key(Permission.BOARDS, "demo"): ["admin"],
-	}),
+    auth_mode="local",
+    local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
+    oauth_permissions=permissions_with_defaults({
+        permission_key(Permission.BOARDS, "demo"): ["admin"],
+    }),
 )
 
 
 def _build_client() -> TestClient:
-	app = FastAPI()
-	app.include_router(board_router, prefix="/board")
-	return TestClient(app)
+    app = FastAPI()
+    app.include_router(board_router, prefix="/board")
+    return TestClient(app)
 
 
 def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
-	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
-	index = repo.index
-	index.add("README.md")
-	index.write()
-	tree = index.write_tree()
-	sig = pygit2.Signature("tester", "tester@example.com")
-	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+    (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+    index = repo.index
+    index.add("README.md")
+    index.write()
+    tree = index.write_tree()
+    sig = pygit2.Signature("tester", "tester@example.com")
+    repo.create_commit("HEAD", sig, sig, "initial", tree, [])
 
 
 @pytest.fixture
 def board_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
-	repo_dir = tmp_path / "demo"
-	repo = pygit2.init_repository(str(repo_dir), bare=False)
-	_create_initial_commit(repo, repo_dir)
-	with (
-		patch.object(settings, "PROJECTROOT", str(tmp_path)),
-		patch.object(settings, "PROJECTS_LIST", str(tmp_path)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "LIST_ALL", True),
-		patch("pygitweb.auth_config.auth_config", _BOARDS_AUTH),
-		patch("pygitweb.auth.auth_config", _BOARDS_AUTH),
-	):
-		yield {"client": _build_client(), "project": "demo"}
+    repo_dir = tmp_path / "demo"
+    repo = pygit2.init_repository(str(repo_dir), bare=False)
+    _create_initial_commit(repo, repo_dir)
+    with (
+        patch.object(settings, "PROJECTROOT", str(tmp_path)),
+        patch.object(settings, "PROJECTS_LIST", str(tmp_path)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "LIST_ALL", True),
+        patch("pygitweb.auth_config.auth_config", _BOARDS_AUTH),
+        patch("pygitweb.auth.auth_config", _BOARDS_AUTH),
+    ):
+        yield {"client": _build_client(), "project": "demo"}
 
 
 def test_board_post_open_when_auth_disabled(board_env: dict[str, str | TestClient]) -> None:
-	client = board_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", False):
-		r = client.post(
-			"/board/create",
-			params={"project": board_env["project"], "name": "temp"},
-		)
-	assert r.status_code == 200
+    client = board_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", False):
+        r = client.post(
+            "/board/create",
+            params={"project": board_env["project"], "name": "temp"},
+        )
+    assert r.status_code == 200
 
 
 def test_board_create_401_without_login(board_env: dict[str, str | TestClient]) -> None:
-	client = board_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True):
-		r = client.post(
-			"/board/create",
-			params={"project": board_env["project"], "name": "temp"},
-		)
-	assert r.status_code == 401
+    client = board_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True):
+        r = client.post(
+            "/board/create",
+            params={"project": board_env["project"], "name": "temp"},
+        )
+    assert r.status_code == 401
 
 
 def test_board_create_allowed_with_grant(board_env: dict[str, str | TestClient]) -> None:
-	client = board_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
-		r = client.post(
-			"/board/create",
-			params={"project": board_env["project"], "name": "temp-board"},
-		)
-	assert r.status_code == 200
+    client = board_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
+        r = client.post(
+            "/board/create",
+            params={"project": board_env["project"], "name": "temp-board"},
+        )
+    assert r.status_code == 200
 
 
 def test_board_create_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
-	client = board_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			"/board/create",
-			params={"project": board_env["project"], "name": "temp-board"},
-		)
-	assert r.status_code == 403
+    client = board_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            "/board/create",
+            params={"project": board_env["project"], "name": "temp-board"},
+        )
+    assert r.status_code == 403
 
 
 def test_board_create_get_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.main.auth_config", _BOARDS_AUTH),
-	):
-		client = TestClient(app)
-		with client_as_user(client, "viewer"):
-			r = client.get(
-				"/board/create",
-				params={"project": board_env["project"]},
-				follow_redirects=False,
-			)
-	assert r.status_code == 403
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.main.auth_config", _BOARDS_AUTH),
+    ):
+        client = TestClient(app)
+        with client_as_user(client, "viewer"):
+            r = client.get(
+                "/board/create",
+                params={"project": board_env["project"]},
+                follow_redirects=False,
+            )
+    assert r.status_code == 403
 
 
 def test_board_create_get_allowed_with_grant(board_env: dict[str, str | TestClient]) -> None:
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.main.auth_config", _BOARDS_AUTH),
-	):
-		client = TestClient(app)
-		with client_as_user(client, "admin"):
-			r = client.get(
-				"/board/create",
-				params={"project": board_env["project"]},
-				follow_redirects=False,
-			)
-	assert r.status_code == 303
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.main.auth_config", _BOARDS_AUTH),
+    ):
+        client = TestClient(app)
+        with client_as_user(client, "admin"):
+            r = client.get(
+                "/board/create",
+                params={"project": board_env["project"]},
+                follow_redirects=False,
+            )
+    assert r.status_code == 303
 
 
 def test_board_delete_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
-	client = board_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			"/board/delete",
-			params={"project": board_env["project"], "name": "Tasks"},
-		)
-	assert r.status_code == 403
+    client = board_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            "/board/delete",
+            params={"project": board_env["project"], "name": "Tasks"},
+        )
+    assert r.status_code == 403
diff --git a/pygitweb/src/pygitweb/capacity_config.py b/pygitweb/src/pygitweb/capacity_config.py
index 878db26..e3f006c 100644
--- a/pygitweb/src/pygitweb/capacity_config.py
+++ b/pygitweb/src/pygitweb/capacity_config.py
@@ -20,60 +20,60 @@ DEFAULT_WEEKLY_CAPACITY_POINTS = 20
 
 
 def capacity_config_path() -> Path:
-	explicit = os.environ.get("PYGITWEB_CAPACITY_CONFIG", "").strip()
-	if explicit:
-		return Path(explicit).expanduser()
-	return DEFAULT_CAPACITY_CONFIG_PATH
+    explicit = os.environ.get("PYGITWEB_CAPACITY_CONFIG", "").strip()
+    if explicit:
+        return Path(explicit).expanduser()
+    return DEFAULT_CAPACITY_CONFIG_PATH
 
 
 def iso_week_key(d: date) -> str:
-	"""ISO week identifier, e.g. 2026-W38."""
-	year, week, _ = d.isocalendar()
-	return f"{year:04d}-W{week:02d}"
+    """ISO week identifier, e.g. 2026-W38."""
+    year, week, _ = d.isocalendar()
+    return f"{year:04d}-W{week:02d}"
 
 
 def current_iso_week() -> str:
-	return iso_week_key(date.today())
+    return iso_week_key(date.today())
 
 
 class UserCapacity(BaseModel):
-	default_points: int = DEFAULT_WEEKLY_CAPACITY_POINTS
-	weeks: dict[str, int] = Field(default_factory=dict)
+    default_points: int = DEFAULT_WEEKLY_CAPACITY_POINTS
+    weeks: dict[str, int] = Field(default_factory=dict)
 
 
 class CapacityConfig(BaseModel):
-	users: dict[str, UserCapacity] = Field(default_factory=dict)
+    users: dict[str, UserCapacity] = Field(default_factory=dict)
 
 
 def load_capacity_config() -> CapacityConfig:
-	path = capacity_config_path()
-	if not path.is_file():
-		return CapacityConfig()
-	try:
-		return CapacityConfig.model_validate_json(path.read_text(encoding="utf-8"))
-	except ValueError:
-		return CapacityConfig()
+    path = capacity_config_path()
+    if not path.is_file():
+        return CapacityConfig()
+    try:
+        return CapacityConfig.model_validate_json(path.read_text(encoding="utf-8"))
+    except ValueError:
+        return CapacityConfig()
 
 
 def write_capacity_config(config: CapacityConfig) -> None:
-	path = capacity_config_path()
-	path.parent.mkdir(parents=True, exist_ok=True)
-	path.write_text(json.dumps(config.model_dump(mode="json"), indent=2) + "\n", encoding="utf-8")
+    path = capacity_config_path()
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(config.model_dump(mode="json"), indent=2) + "\n", encoding="utf-8")
 
 
 def get_user_week_capacity(username: str, iso_week: str) -> int:
-	"""Points capacity for a user in a given ISO week (falls back to their default, then the global default)."""
-	config = load_capacity_config()
-	user = config.users.get(username)
-	if user is None:
-		return DEFAULT_WEEKLY_CAPACITY_POINTS
-	return user.weeks.get(iso_week, user.default_points)
+    """Points capacity for a user in a given ISO week (falls back to their default, then the global default)."""
+    config = load_capacity_config()
+    user = config.users.get(username)
+    if user is None:
+        return DEFAULT_WEEKLY_CAPACITY_POINTS
+    return user.weeks.get(iso_week, user.default_points)
 
 
 def set_user_week_capacity(username: str, iso_week: str, points: int) -> CapacityConfig:
-	config = load_capacity_config()
-	user = config.users.get(username) or UserCapacity()
-	user.weeks[iso_week] = points
-	config.users[username] = user
-	write_capacity_config(config)
-	return config
+    config = load_capacity_config()
+    user = config.users.get(username) or UserCapacity()
+    user.weeks[iso_week] = points
+    config.users[username] = user
+    write_capacity_config(config)
+    return config
diff --git a/pygitweb/src/pygitweb/change_queue.py b/pygitweb/src/pygitweb/change_queue.py
index 9eac96d..8823286 100644
--- a/pygitweb/src/pygitweb/change_queue.py
+++ b/pygitweb/src/pygitweb/change_queue.py
@@ -14,117 +14,117 @@ from typing import Literal
 
 
 class ChangeQueue:
-	"""Per-project notification fan-out using one-shot asyncio.Events per waiter."""
-
-	DEFAULT_TIMEOUT_SECONDS: float = 30.0
-
-	def __init__(self) -> None:
-		self._waiters: dict[str, list[asyncio.Event]] = {}
-		self._lock: asyncio.Lock = asyncio.Lock()
-
-	async def wait_for_changes(
-		self,
-		projects: list[str],
-		timeout: float = DEFAULT_TIMEOUT_SECONDS,
-		shutdown_event: asyncio.Event | None = None,
-	) -> str | None:
-		"""Park until any of `projects` is notified, `shutdown_event` is set, or `timeout` elapses.
-
-		Returns the notified project name, or None on timeout/shutdown.
-		"""
-		if shutdown_event and shutdown_event.is_set():
-			return None
-
-		if not projects:
-			if shutdown_event:
-				t_sleep: asyncio.Task[object] = asyncio.create_task(asyncio.sleep(timeout))
-				t_sd: asyncio.Task[object] = asyncio.create_task(shutdown_event.wait())
-				try:
-					done, pending = await asyncio.wait(
-						{t_sleep, t_sd},
-						timeout=timeout,
-						return_when=asyncio.FIRST_COMPLETED,
-					)
-				finally:
-					for t in pending:
-						t.cancel()
-					await asyncio.gather(t_sleep, t_sd, return_exceptions=True)
-				return None
-			await asyncio.sleep(timeout)
-			return None
-
-		event: asyncio.Event = asyncio.Event()
-		async with self._lock:
-			for project in projects:
-				self._waiters.setdefault(project, []).append(event)
-		try:
-			if shutdown_event:
-				t_notify: asyncio.Task[Literal[True]] = asyncio.create_task(event.wait())
-				t_shutdown: asyncio.Task[Literal[True]] = asyncio.create_task(shutdown_event.wait())
-				try:
-					done, pending = await asyncio.wait(
-						{t_notify, t_shutdown},
-						timeout=timeout,
-						return_when=asyncio.FIRST_COMPLETED,
-					)
-				finally:
-					for t in pending:
-						t.cancel()
-					await asyncio.gather(t_notify, t_shutdown, return_exceptions=True)
-				if not done:
-					return None
-				if t_shutdown in done:
-					return None
-				return getattr(event, "_change_queue_project", None)
-
-			try:
-				await asyncio.wait_for(event.wait(), timeout=timeout)
-			except TimeoutError:
-				return None
-			return getattr(event, "_change_queue_project", None)
-		finally:
-			async with self._lock:
-				for project in projects:
-					bucket: list[asyncio.Event] = self._waiters.get(project, [])
-					if event in bucket:
-						bucket.remove(event)
-					if not bucket:
-						self._waiters.pop(project, None)
-
-	async def notify(self, project: str) -> int:
-		"""Wake all waiters subscribed to `project`. Returns the number of waiters woken."""
-		async with self._lock:
-			waiters: list[asyncio.Event] = self._waiters.pop(project, [])
-		for event in waiters:
-			event._change_queue_project = project  # type: ignore[attr-defined]
-			event.set()
-		return len(waiters)
-
-	async def notify_all(self) -> int:
-		"""Wake every current waiter. Returns the number of waiters woken."""
-		async with self._lock:
-			snapshot: list[tuple[str, asyncio.Event]] = [
-				(project, event) for project, bucket in self._waiters.items() for event in bucket
-			]
-			self._waiters.clear()
-		seen: set[int] = set()
-		woken = 0
-		for project, event in snapshot:
-			eid = id(event)
-			if eid in seen:
-				continue
-			seen.add(eid)
-			event._change_queue_project = project  # type: ignore[attr-defined]
-			event.set()
-			woken += 1
-		return woken
-
-	async def waiter_count(self, project: str | None = None) -> int:
-		"""Diagnostic: count waiters for one project (or total across all projects)."""
-		async with self._lock:
-			if project is not None:
-				return len(self._waiters.get(project, []))
-			return sum(len(bucket) for bucket in self._waiters.values())
+    """Per-project notification fan-out using one-shot asyncio.Events per waiter."""
+
+    DEFAULT_TIMEOUT_SECONDS: float = 30.0
+
+    def __init__(self) -> None:
+        self._waiters: dict[str, list[asyncio.Event]] = {}
+        self._lock: asyncio.Lock = asyncio.Lock()
+
+    async def wait_for_changes(
+        self,
+        projects: list[str],
+        timeout: float = DEFAULT_TIMEOUT_SECONDS,
+        shutdown_event: asyncio.Event | None = None,
+    ) -> str | None:
+        """Park until any of `projects` is notified, `shutdown_event` is set, or `timeout` elapses.
+
+        Returns the notified project name, or None on timeout/shutdown.
+        """
+        if shutdown_event and shutdown_event.is_set():
+            return None
+
+        if not projects:
+            if shutdown_event:
+                t_sleep: asyncio.Task[object] = asyncio.create_task(asyncio.sleep(timeout))
+                t_sd: asyncio.Task[object] = asyncio.create_task(shutdown_event.wait())
+                try:
+                    done, pending = await asyncio.wait(
+                        {t_sleep, t_sd},
+                        timeout=timeout,
+                        return_when=asyncio.FIRST_COMPLETED,
+                    )
+                finally:
+                    for t in pending:
+                        t.cancel()
+                    await asyncio.gather(t_sleep, t_sd, return_exceptions=True)
+                return None
+            await asyncio.sleep(timeout)
+            return None
+
+        event: asyncio.Event = asyncio.Event()
+        async with self._lock:
+            for project in projects:
+                self._waiters.setdefault(project, []).append(event)
+        try:
+            if shutdown_event:
+                t_notify: asyncio.Task[Literal[True]] = asyncio.create_task(event.wait())
+                t_shutdown: asyncio.Task[Literal[True]] = asyncio.create_task(shutdown_event.wait())
+                try:
+                    done, pending = await asyncio.wait(
+                        {t_notify, t_shutdown},
+                        timeout=timeout,
+                        return_when=asyncio.FIRST_COMPLETED,
+                    )
+                finally:
+                    for t in pending:
+                        t.cancel()
+                    await asyncio.gather(t_notify, t_shutdown, return_exceptions=True)
+                if not done:
+                    return None
+                if t_shutdown in done:
+                    return None
+                return getattr(event, "_change_queue_project", None)
+
+            try:
+                await asyncio.wait_for(event.wait(), timeout=timeout)
+            except TimeoutError:
+                return None
+            return getattr(event, "_change_queue_project", None)
+        finally:
+            async with self._lock:
+                for project in projects:
+                    bucket: list[asyncio.Event] = self._waiters.get(project, [])
+                    if event in bucket:
+                        bucket.remove(event)
+                    if not bucket:
+                        self._waiters.pop(project, None)
+
+    async def notify(self, project: str) -> int:
+        """Wake all waiters subscribed to `project`. Returns the number of waiters woken."""
+        async with self._lock:
+            waiters: list[asyncio.Event] = self._waiters.pop(project, [])
+        for event in waiters:
+            event._change_queue_project = project  # type: ignore[attr-defined]
+            event.set()
+        return len(waiters)
+
+    async def notify_all(self) -> int:
+        """Wake every current waiter. Returns the number of waiters woken."""
+        async with self._lock:
+            snapshot: list[tuple[str, asyncio.Event]] = [
+                (project, event) for project, bucket in self._waiters.items() for event in bucket
+            ]
+            self._waiters.clear()
+        seen: set[int] = set()
+        woken = 0
+        for project, event in snapshot:
+            eid = id(event)
+            if eid in seen:
+                continue
+            seen.add(eid)
+            event._change_queue_project = project  # type: ignore[attr-defined]
+            event.set()
+            woken += 1
+        return woken
+
+    async def waiter_count(self, project: str | None = None) -> int:
+        """Diagnostic: count waiters for one project (or total across all projects)."""
+        async with self._lock:
+            if project is not None:
+                return len(self._waiters.get(project, []))
+            return sum(len(bucket) for bucket in self._waiters.values())
 
 
 CHANGE_QUEUE: ChangeQueue = ChangeQueue()
diff --git a/pygitweb/src/pygitweb/change_queue_routes_test.py b/pygitweb/src/pygitweb/change_queue_routes_test.py
index cf6c691..367f01c 100644
--- a/pygitweb/src/pygitweb/change_queue_routes_test.py
+++ b/pygitweb/src/pygitweb/change_queue_routes_test.py
@@ -17,152 +17,152 @@ from pygitweb.main import app
 
 
 def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
-	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
-	index = repo.index
-	index.add("README.md")
-	index.write()
-	tree = index.write_tree()
-	sig = pygit2.Signature("tester", "tester@example.com")
-	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+    (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+    index = repo.index
+    index.add("README.md")
+    index.write()
+    tree = index.write_tree()
+    sig = pygit2.Signature("tester", "tester@example.com")
+    repo.create_commit("HEAD", sig, sig, "initial", tree, [])
 
 
 @pytest.fixture(scope="module")
 def updates_env(tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
-	root: Path = tmp_path_factory.mktemp("updates-routes")
-	alpha_dir: Path = root / "alpha"
-	alpha_dir.mkdir()
-	_create_initial_commit(pygit2.init_repository(str(alpha_dir), bare=False), alpha_dir)
-	beta_dir: Path = root / "group" / "beta"
-	beta_dir.mkdir(parents=True)
-	_create_initial_commit(pygit2.init_repository(str(beta_dir), bare=False), beta_dir)
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(root)),
-		patch.object(settings, "PROJECT_MAXDEPTH", 3),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "LIST_ALL", True),
-		patch.object(settings, "AUTH", False),
-		patch.object(settings, "MAXLOAD", None),
-	):
-		yield {"root": str(root), "alpha": "alpha", "beta": "group/beta"}
+    root: Path = tmp_path_factory.mktemp("updates-routes")
+    alpha_dir: Path = root / "alpha"
+    alpha_dir.mkdir()
+    _create_initial_commit(pygit2.init_repository(str(alpha_dir), bare=False), alpha_dir)
+    beta_dir: Path = root / "group" / "beta"
+    beta_dir.mkdir(parents=True)
+    _create_initial_commit(pygit2.init_repository(str(beta_dir), bare=False), beta_dir)
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(root)),
+        patch.object(settings, "PROJECT_MAXDEPTH", 3),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "LIST_ALL", True),
+        patch.object(settings, "AUTH", False),
+        patch.object(settings, "MAXLOAD", None),
+    ):
+        yield {"root": str(root), "alpha": "alpha", "beta": "group/beta"}
 
 
 def _client() -> httpx.AsyncClient:
-	return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
+    return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
 
 
 class TestUpdatesLongPoll:
-	def test_updates_returns_200_empty_after_timeout(self, updates_env: dict[str, str]) -> None:
-		async def scenario() -> tuple[int, bytes]:
-			async with _client() as client:
-				with patch.object(CHANGE_QUEUE, "DEFAULT_TIMEOUT_SECONDS", 0.05):
-					resp = await client.get(
-						f"/project/{updates_env['alpha']}",
-						params={"a": "log", "updates": "true"},
-					)
-				return resp.status_code, resp.content
-
-		status, body = asyncio.run(scenario())
-		assert status == 200
-		assert body == b""
-
-	def test_updates_returns_200_empty_when_shutting_down(self, updates_env: dict[str, str]) -> None:
-		async def scenario() -> tuple[int, bytes]:
-			async with _client() as client:
-				app.state.shutting_down = True
-				try:
-					resp = await client.get(
-						f"/project/{updates_env['alpha']}",
-						params={"a": "log", "updates": "true"},
-					)
-					return resp.status_code, resp.content
-				finally:
-					app.state.shutting_down = False
-
-		status, body = asyncio.run(scenario())
-		assert status == 200
-		assert body == b""
-
-	def test_updates_returns_data_after_notify(self, updates_env: dict[str, str]) -> None:
-		async def scenario() -> tuple[int, str]:
-			async with _client() as client:
-				poll: asyncio.Task[httpx.Response] = asyncio.create_task(
-					client.get(
-						f"/project/{updates_env['alpha']}",
-						params={"a": "log", "updates": "true"},
-					)
-				)
-				await asyncio.sleep(0.05)
-				notify_resp = await client.post(
-					"/_internal/notify",
-					params={"project": updates_env["alpha"]},
-				)
-				assert notify_resp.status_code == 200
-				assert notify_resp.json()["waiters_woken"] == 1
-				resp: httpx.Response = await poll
-				return resp.status_code, resp.text
-
-		status, body = asyncio.run(scenario())
-		assert status == 200
-		assert "Log" in body
-
-	def test_updates_with_pf_wakes_on_any_matching_project(self, updates_env: dict[str, str]) -> None:
-		async def scenario() -> int:
-			async with _client() as client:
-				poll: asyncio.Task[httpx.Response] = asyncio.create_task(
-					client.get(
-						f"/project/{updates_env['alpha']}",
-						params={"a": "heads", "updates": "true", "pf": "group"},
-					)
-				)
-				await asyncio.sleep(0.05)
-				notify_resp = await client.post(
-					"/_internal/notify",
-					params={"project": updates_env["beta"]},
-				)
-				assert notify_resp.status_code == 200
-				assert notify_resp.json()["waiters_woken"] == 1
-				return (await poll).status_code
-
-		assert asyncio.run(scenario()) == 200
-
-	def test_updates_rejected_for_unsupported_action(self, updates_env: dict[str, str]) -> None:
-		async def scenario() -> int:
-			async with _client() as client:
-				resp = await client.get(
-					f"/project/{updates_env['alpha']}",
-					params={"a": "tree", "updates": "true"},
-				)
-				return resp.status_code
-
-		assert asyncio.run(scenario()) == 400
-
-	def test_notify_unknown_project_returns_404(self, updates_env: dict[str, str]) -> None:
-		async def scenario() -> int:
-			async with _client() as client:
-				resp = await client.post("/_internal/notify", params={"project": "no-such-project"})
-				return resp.status_code
-
-		assert asyncio.run(scenario()) == 404
-
-	def test_notify_rejects_non_loopback_client(self, updates_env: dict[str, str]) -> None:
-		del updates_env
-
-		async def scenario() -> int:
-			transport = ASGITransport(app=app, client=("203.0.113.1", 999))
-			async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
-				resp = await client.post("/_internal/notify", params={"project": "alpha"})
-				return resp.status_code
-
-		assert asyncio.run(scenario()) == 403
+    def test_updates_returns_200_empty_after_timeout(self, updates_env: dict[str, str]) -> None:
+        async def scenario() -> tuple[int, bytes]:
+            async with _client() as client:
+                with patch.object(CHANGE_QUEUE, "DEFAULT_TIMEOUT_SECONDS", 0.05):
+                    resp = await client.get(
+                        f"/project/{updates_env['alpha']}",
+                        params={"a": "log", "updates": "true"},
+                    )
+                return resp.status_code, resp.content
+
+        status, body = asyncio.run(scenario())
+        assert status == 200
+        assert body == b""
+
+    def test_updates_returns_200_empty_when_shutting_down(self, updates_env: dict[str, str]) -> None:
+        async def scenario() -> tuple[int, bytes]:
+            async with _client() as client:
+                app.state.shutting_down = True
+                try:
+                    resp = await client.get(
+                        f"/project/{updates_env['alpha']}",
+                        params={"a": "log", "updates": "true"},
+                    )
+                    return resp.status_code, resp.content
+                finally:
+                    app.state.shutting_down = False
+
+        status, body = asyncio.run(scenario())
+        assert status == 200
+        assert body == b""
+
+    def test_updates_returns_data_after_notify(self, updates_env: dict[str, str]) -> None:
+        async def scenario() -> tuple[int, str]:
+            async with _client() as client:
+                poll: asyncio.Task[httpx.Response] = asyncio.create_task(
+                    client.get(
+                        f"/project/{updates_env['alpha']}",
+                        params={"a": "log", "updates": "true"},
+                    )
+                )
+                await asyncio.sleep(0.05)
+                notify_resp = await client.post(
+                    "/_internal/notify",
+                    params={"project": updates_env["alpha"]},
+                )
+                assert notify_resp.status_code == 200
+                assert notify_resp.json()["waiters_woken"] == 1
+                resp: httpx.Response = await poll
+                return resp.status_code, resp.text
+
+        status, body = asyncio.run(scenario())
+        assert status == 200
+        assert "Log" in body
+
+    def test_updates_with_pf_wakes_on_any_matching_project(self, updates_env: dict[str, str]) -> None:
+        async def scenario() -> int:
+            async with _client() as client:
+                poll: asyncio.Task[httpx.Response] = asyncio.create_task(
+                    client.get(
+                        f"/project/{updates_env['alpha']}",
+                        params={"a": "heads", "updates": "true", "pf": "group"},
+                    )
+                )
+                await asyncio.sleep(0.05)
+                notify_resp = await client.post(
+                    "/_internal/notify",
+                    params={"project": updates_env["beta"]},
+                )
+                assert notify_resp.status_code == 200
+                assert notify_resp.json()["waiters_woken"] == 1
+                return (await poll).status_code
+
+        assert asyncio.run(scenario()) == 200
+
+    def test_updates_rejected_for_unsupported_action(self, updates_env: dict[str, str]) -> None:
+        async def scenario() -> int:
+            async with _client() as client:
+                resp = await client.get(
+                    f"/project/{updates_env['alpha']}",
+                    params={"a": "tree", "updates": "true"},
+                )
+                return resp.status_code
+
+        assert asyncio.run(scenario()) == 400
+
+    def test_notify_unknown_project_returns_404(self, updates_env: dict[str, str]) -> None:
+        async def scenario() -> int:
+            async with _client() as client:
+                resp = await client.post("/_internal/notify", params={"project": "no-such-project"})
+                return resp.status_code
+
+        assert asyncio.run(scenario()) == 404
+
+    def test_notify_rejects_non_loopback_client(self, updates_env: dict[str, str]) -> None:
+        del updates_env
+
+        async def scenario() -> int:
+            transport = ASGITransport(app=app, client=("203.0.113.1", 999))
+            async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+                resp = await client.post("/_internal/notify", params={"project": "alpha"})
+                return resp.status_code
+
+        assert asyncio.run(scenario()) == 403
 
 
 class TestLoopbackHost:
-	def test_is_loopback_host(self) -> None:
-		assert is_loopback_host("127.0.0.1")
-		assert is_loopback_host("::1")
-		assert is_loopback_host("localhost")
-		assert not is_loopback_host("203.0.113.1")
-		assert not is_loopback_host(None)
-		assert not is_loopback_host("")
+    def test_is_loopback_host(self) -> None:
+        assert is_loopback_host("127.0.0.1")
+        assert is_loopback_host("::1")
+        assert is_loopback_host("localhost")
+        assert not is_loopback_host("203.0.113.1")
+        assert not is_loopback_host(None)
+        assert not is_loopback_host("")
diff --git a/pygitweb/src/pygitweb/change_queue_test.py b/pygitweb/src/pygitweb/change_queue_test.py
index 8f2ff1d..c241478 100644
--- a/pygitweb/src/pygitweb/change_queue_test.py
+++ b/pygitweb/src/pygitweb/change_queue_test.py
@@ -6,121 +6,121 @@ from pygitweb.change_queue import ChangeQueue
 
 
 class TestChangeQueue:
-	def test_notify_wakes_waiter_with_project_name(self) -> None:
-		async def scenario() -> tuple[int, str | None, int]:
-			queue: ChangeQueue = ChangeQueue()
-			waiter: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=2.0))
-			await asyncio.sleep(0)
-			woken: int = await queue.notify("alpha")
-			result: str | None = await waiter
-			leftover: int = await queue.waiter_count()
-			return woken, result, leftover
-
-		woken, result, leftover = asyncio.run(scenario())
-		assert woken == 1
-		assert result == "alpha"
-		assert leftover == 0
-
-	def test_wait_returns_none_on_timeout(self) -> None:
-		async def scenario() -> tuple[str | None, int]:
-			queue: ChangeQueue = ChangeQueue()
-			outcome: str | None = await queue.wait_for_changes(["alpha"], timeout=0.05)
-			leftover: int = await queue.waiter_count("alpha")
-			return outcome, leftover
-
-		outcome, leftover = asyncio.run(scenario())
-		assert outcome is None
-		assert leftover == 0
-
-	def test_notify_only_wakes_subscribed_waiters(self) -> None:
-		async def scenario() -> tuple[int, str | None]:
-			queue: ChangeQueue = ChangeQueue()
-			waiter: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=0.2))
-			await asyncio.sleep(0)
-			woken: int = await queue.notify("beta")
-			outcome: str | None = await waiter
-			return woken, outcome
-
-		woken, outcome = asyncio.run(scenario())
-		assert woken == 0
-		assert outcome is None
-
-	def test_wait_for_multiple_projects_wakes_on_any(self) -> None:
-		async def scenario() -> tuple[str | None, int]:
-			queue: ChangeQueue = ChangeQueue()
-			waiter: asyncio.Task[str | None] = asyncio.create_task(
-				queue.wait_for_changes(["alpha", "group/beta"], timeout=2.0)
-			)
-			await asyncio.sleep(0)
-			await queue.notify("group/beta")
-			outcome: str | None = await waiter
-			leftover: int = await queue.waiter_count()
-			return outcome, leftover
-
-		outcome, leftover = asyncio.run(scenario())
-		assert outcome == "group/beta"
-		assert leftover == 0
-
-	def test_notify_returns_zero_when_no_waiters(self) -> None:
-		async def scenario() -> int:
-			queue: ChangeQueue = ChangeQueue()
-			return await queue.notify("alpha")
-
-		assert asyncio.run(scenario()) == 0
-
-	def test_wait_returns_none_when_shutdown_event_set(self) -> None:
-		async def scenario() -> str | None:
-			queue: ChangeQueue = ChangeQueue()
-			sd: asyncio.Event = asyncio.Event()
-			sd.set()
-			return await queue.wait_for_changes(["alpha"], timeout=2.0, shutdown_event=sd)
-
-		assert asyncio.run(scenario()) is None
-
-	def test_wait_returns_none_when_shutdown_beats_notify(self) -> None:
-		async def scenario() -> str | None:
-			queue: ChangeQueue = ChangeQueue()
-			sd: asyncio.Event = asyncio.Event()
-			waiter: asyncio.Task[str | None] = asyncio.create_task(
-				queue.wait_for_changes(["alpha"], timeout=2.0, shutdown_event=sd)
-			)
-			await asyncio.sleep(0)
-			sd.set()
-			return await waiter
-
-		assert asyncio.run(scenario()) is None
-
-	def test_notify_all_wakes_every_waiter(self) -> None:
-		async def scenario() -> tuple[int, str | None, str | None, int]:
-			queue: ChangeQueue = ChangeQueue()
-			w1: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=2.0))
-			w2: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["beta"], timeout=2.0))
-			await asyncio.sleep(0)
-			woken: int = await queue.notify_all()
-			r1: str | None = await w1
-			r2: str | None = await w2
-			leftover: int = await queue.waiter_count()
-			return woken, r1, r2, leftover
-
-		woken, r1, r2, leftover = asyncio.run(scenario())
-		assert woken == 2
-		assert r1 == "alpha"
-		assert r2 == "beta"
-		assert leftover == 0
-
-	def test_notify_all_dedupes_multi_project_waiter(self) -> None:
-		async def scenario() -> tuple[int, str | None, int]:
-			queue: ChangeQueue = ChangeQueue()
-			waiter: asyncio.Task[str | None] = asyncio.create_task(
-				queue.wait_for_changes(["alpha", "beta"], timeout=2.0)
-			)
-			await asyncio.sleep(0)
-			woken: int = await queue.notify_all()
-			result: str | None = await waiter
-			leftover: int = await queue.waiter_count()
-			return woken, result, leftover
-
-		woken, result, leftover = asyncio.run(scenario())
-		assert woken == 1
-		assert result in {"alpha", "beta"}
-		assert leftover == 0
+    def test_notify_wakes_waiter_with_project_name(self) -> None:
+        async def scenario() -> tuple[int, str | None, int]:
+            queue: ChangeQueue = ChangeQueue()
+            waiter: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=2.0))
+            await asyncio.sleep(0)
+            woken: int = await queue.notify("alpha")
+            result: str | None = await waiter
+            leftover: int = await queue.waiter_count()
+            return woken, result, leftover
+
+        woken, result, leftover = asyncio.run(scenario())
+        assert woken == 1
+        assert result == "alpha"
+        assert leftover == 0
+
+    def test_wait_returns_none_on_timeout(self) -> None:
+        async def scenario() -> tuple[str | None, int]:
+            queue: ChangeQueue = ChangeQueue()
+            outcome: str | None = await queue.wait_for_changes(["alpha"], timeout=0.05)
+            leftover: int = await queue.waiter_count("alpha")
+            return outcome, leftover
+
+        outcome, leftover = asyncio.run(scenario())
+        assert outcome is None
+        assert leftover == 0
+
+    def test_notify_only_wakes_subscribed_waiters(self) -> None:
+        async def scenario() -> tuple[int, str | None]:
+            queue: ChangeQueue = ChangeQueue()
+            waiter: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=0.2))
+            await asyncio.sleep(0)
+            woken: int = await queue.notify("beta")
+            outcome: str | None = await waiter
+            return woken, outcome
+
+        woken, outcome = asyncio.run(scenario())
+        assert woken == 0
+        assert outcome is None
+
+    def test_wait_for_multiple_projects_wakes_on_any(self) -> None:
+        async def scenario() -> tuple[str | None, int]:
+            queue: ChangeQueue = ChangeQueue()
+            waiter: asyncio.Task[str | None] = asyncio.create_task(
+                queue.wait_for_changes(["alpha", "group/beta"], timeout=2.0)
+            )
+            await asyncio.sleep(0)
+            await queue.notify("group/beta")
+            outcome: str | None = await waiter
+            leftover: int = await queue.waiter_count()
+            return outcome, leftover
+
+        outcome, leftover = asyncio.run(scenario())
+        assert outcome == "group/beta"
+        assert leftover == 0
+
+    def test_notify_returns_zero_when_no_waiters(self) -> None:
+        async def scenario() -> int:
+            queue: ChangeQueue = ChangeQueue()
+            return await queue.notify("alpha")
+
+        assert asyncio.run(scenario()) == 0
+
+    def test_wait_returns_none_when_shutdown_event_set(self) -> None:
+        async def scenario() -> str | None:
+            queue: ChangeQueue = ChangeQueue()
+            sd: asyncio.Event = asyncio.Event()
+            sd.set()
+            return await queue.wait_for_changes(["alpha"], timeout=2.0, shutdown_event=sd)
+
+        assert asyncio.run(scenario()) is None
+
+    def test_wait_returns_none_when_shutdown_beats_notify(self) -> None:
+        async def scenario() -> str | None:
+            queue: ChangeQueue = ChangeQueue()
+            sd: asyncio.Event = asyncio.Event()
+            waiter: asyncio.Task[str | None] = asyncio.create_task(
+                queue.wait_for_changes(["alpha"], timeout=2.0, shutdown_event=sd)
+            )
+            await asyncio.sleep(0)
+            sd.set()
+            return await waiter
+
+        assert asyncio.run(scenario()) is None
+
+    def test_notify_all_wakes_every_waiter(self) -> None:
+        async def scenario() -> tuple[int, str | None, str | None, int]:
+            queue: ChangeQueue = ChangeQueue()
+            w1: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=2.0))
+            w2: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["beta"], timeout=2.0))
+            await asyncio.sleep(0)
+            woken: int = await queue.notify_all()
+            r1: str | None = await w1
+            r2: str | None = await w2
+            leftover: int = await queue.waiter_count()
+            return woken, r1, r2, leftover
+
+        woken, r1, r2, leftover = asyncio.run(scenario())
+        assert woken == 2
+        assert r1 == "alpha"
+        assert r2 == "beta"
+        assert leftover == 0
+
+    def test_notify_all_dedupes_multi_project_waiter(self) -> None:
+        async def scenario() -> tuple[int, str | None, int]:
+            queue: ChangeQueue = ChangeQueue()
+            waiter: asyncio.Task[str | None] = asyncio.create_task(
+                queue.wait_for_changes(["alpha", "beta"], timeout=2.0)
+            )
+            await asyncio.sleep(0)
+            woken: int = await queue.notify_all()
+            result: str | None = await waiter
+            leftover: int = await queue.waiter_count()
+            return woken, result, leftover
+
+        woken, result, leftover = asyncio.run(scenario())
+        assert woken == 1
+        assert result in {"alpha", "beta"}
+        assert leftover == 0
diff --git a/pygitweb/src/pygitweb/comments_permissions_test.py b/pygitweb/src/pygitweb/comments_permissions_test.py
index 8b7451c..73d6925 100644
--- a/pygitweb/src/pygitweb/comments_permissions_test.py
+++ b/pygitweb/src/pygitweb/comments_permissions_test.py
@@ -17,216 +17,216 @@ from pygitweb.permissions import Permission, permission_key, permissions_with_de
 from pygitweb.tasks import comment_router
 
 _COMMENTS_AUTH = AuthConfig(
-	auth_mode="local",
-	local_users=[
-		LocalUser(user="admin", password="secret"),
-		LocalUser(user="viewer", password="secret"),
-		LocalUser(user="author", password="secret"),
-	],
-	oauth_permissions=permissions_with_defaults({
-		permission_key(Permission.COMMENTS, "demo"): ["viewer"],
-		permission_key(Permission.TASKS, "demo"): ["admin"],
-	}),
+    auth_mode="local",
+    local_users=[
+        LocalUser(user="admin", password="secret"),
+        LocalUser(user="viewer", password="secret"),
+        LocalUser(user="author", password="secret"),
+    ],
+    oauth_permissions=permissions_with_defaults({
+        permission_key(Permission.COMMENTS, "demo"): ["viewer"],
+        permission_key(Permission.TASKS, "demo"): ["admin"],
+    }),
 )
 
 
 def _build_client() -> TestClient:
-	app = FastAPI()
-	app.include_router(comment_router, prefix="/comments")
-	return TestClient(app)
+    app = FastAPI()
+    app.include_router(comment_router, prefix="/comments")
+    return TestClient(app)
 
 
 def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
-	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
-	index = repo.index
-	index.add("README.md")
-	index.write()
-	tree = index.write_tree()
-	sig = pygit2.Signature("tester", "tester@example.com")
-	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+    (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+    index = repo.index
+    index.add("README.md")
+    index.write()
+    tree = index.write_tree()
+    sig = pygit2.Signature("tester", "tester@example.com")
+    repo.create_commit("HEAD", sig, sig, "initial", tree, [])
 
 
 @pytest.fixture
 def comment_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
-	repo_dir = tmp_path / "demo"
-	repo = pygit2.init_repository(str(repo_dir), bare=False)
-	_create_initial_commit(repo, repo_dir)
-	board_ref = f"{BOARD_REF_PREFIX}Tasks"
-	board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="")
-	task_ref = f"{TASK_REF_PREFIX}task_1"
-	task = Task(EMPTY_TREE_OID, task_ref, tagger="", title="Task", description="")
-	task_oid = task.write(repo)
-	comment = Comment(task_oid, "author <author@local>", "seed note")
-	comment_oid = comment.write(repo)
-	task.comments = [str(comment_oid)]
-	task.update_message()
-	task.write(repo)
-	board.tasks = [str(task_oid)]
-	board.update_message()
-	board.write(repo)
-	with (
-		patch.object(settings, "PROJECTROOT", str(tmp_path)),
-		patch.object(settings, "PROJECTS_LIST", str(tmp_path)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "LIST_ALL", True),
-		patch("pygitweb.auth_config.auth_config", _COMMENTS_AUTH),
-		patch("pygitweb.auth.auth_config", _COMMENTS_AUTH),
-	):
-		client = _build_client()
-		yield {
-			"client": client,
-			"project": "demo",
-			"board": "Tasks",
-			"task": task_ref,
-			"comment_oid": str(comment_oid),
-		}
+    repo_dir = tmp_path / "demo"
+    repo = pygit2.init_repository(str(repo_dir), bare=False)
+    _create_initial_commit(repo, repo_dir)
+    board_ref = f"{BOARD_REF_PREFIX}Tasks"
+    board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="")
+    task_ref = f"{TASK_REF_PREFIX}task_1"
+    task = Task(EMPTY_TREE_OID, task_ref, tagger="", title="Task", description="")
+    task_oid = task.write(repo)
+    comment = Comment(task_oid, "author <author@local>", "seed note")
+    comment_oid = comment.write(repo)
+    task.comments = [str(comment_oid)]
+    task.update_message()
+    task.write(repo)
+    board.tasks = [str(task_oid)]
+    board.update_message()
+    board.write(repo)
+    with (
+        patch.object(settings, "PROJECTROOT", str(tmp_path)),
+        patch.object(settings, "PROJECTS_LIST", str(tmp_path)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "LIST_ALL", True),
+        patch("pygitweb.auth_config.auth_config", _COMMENTS_AUTH),
+        patch("pygitweb.auth.auth_config", _COMMENTS_AUTH),
+    ):
+        client = _build_client()
+        yield {
+            "client": client,
+            "project": "demo",
+            "board": "Tasks",
+            "task": task_ref,
+            "comment_oid": str(comment_oid),
+        }
 
 
 def test_comment_delete_forbidden_without_comments_grant(comment_env: dict[str, str]) -> None:
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
-		r = client.post(
-			"/comments/delete",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"comment": comment_env["comment_oid"],
-			},
-		)
-	assert r.status_code == 403
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
+        r = client.post(
+            "/comments/delete",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "comment": comment_env["comment_oid"],
+            },
+        )
+    assert r.status_code == 403
 
 
 def test_comment_delete_allowed_with_comments_grant(comment_env: dict[str, str]) -> None:
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			"/comments/delete",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"comment": comment_env["comment_oid"],
-			},
-		)
-	assert r.status_code == 200
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            "/comments/delete",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "comment": comment_env["comment_oid"],
+            },
+        )
+    assert r.status_code == 200
 
 
 def test_comment_modify_allowed_for_author_without_comments_grant(comment_env: dict[str, str]) -> None:
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "author"):
-		r = client.post(
-			"/comments/modify",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"task": comment_env["task"],
-				"comment": comment_env["comment_oid"],
-			},
-			json={"content": "edited by author"},
-		)
-	assert r.status_code == 200
-	assert r.json()["content"] == "edited by author"
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "author"):
+        r = client.post(
+            "/comments/modify",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "task": comment_env["task"],
+                "comment": comment_env["comment_oid"],
+            },
+            json={"content": "edited by author"},
+        )
+    assert r.status_code == 200
+    assert r.json()["content"] == "edited by author"
 
 
 def test_comment_modify_forbidden_for_non_author_without_comments_grant(comment_env: dict[str, str]) -> None:
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
-		r = client.post(
-			"/comments/modify",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"task": comment_env["task"],
-				"comment": comment_env["comment_oid"],
-			},
-			json={"content": "edited by admin"},
-		)
-	assert r.status_code == 403
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
+        r = client.post(
+            "/comments/modify",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "task": comment_env["task"],
+                "comment": comment_env["comment_oid"],
+            },
+            json={"content": "edited by admin"},
+        )
+    assert r.status_code == 403
 
 
 def test_comment_modify_allowed_with_comments_grant(comment_env: dict[str, str]) -> None:
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			"/comments/modify",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"task": comment_env["task"],
-				"comment": comment_env["comment_oid"],
-			},
-			json={"content": "edited by moderator"},
-		)
-	assert r.status_code == 200
-	assert r.json()["content"] == "edited by moderator"
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            "/comments/modify",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "task": comment_env["task"],
+                "comment": comment_env["comment_oid"],
+            },
+            json={"content": "edited by moderator"},
+        )
+    assert r.status_code == 200
+    assert r.json()["content"] == "edited by moderator"
 
 
 def test_comment_create_open_when_auth_disabled(comment_env: dict[str, str]) -> None:
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", False):
-		r = client.post(
-			"/comments/create",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"task": comment_env["task"],
-				"content": "note",
-			},
-		)
-	assert r.status_code == 200
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", False):
+        r = client.post(
+            "/comments/create",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "task": comment_env["task"],
+                "content": "note",
+            },
+        )
+    assert r.status_code == 200
 
 
 def test_comment_create_401_without_login(comment_env: dict[str, str]) -> None:
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True):
-		r = client.post(
-			"/comments/create",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"task": comment_env["task"],
-				"content": "note",
-			},
-		)
-	assert r.status_code == 401
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True):
+        r = client.post(
+            "/comments/create",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "task": comment_env["task"],
+                "content": "note",
+            },
+        )
+    assert r.status_code == 401
 
 
 def test_comment_create_allowed_with_comments_grant_only(comment_env: dict[str, str]) -> None:
-	"""User with COMMENTS but not TASKS can add a comment."""
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			"/comments/create",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"task": comment_env["task"],
-				"content": "from viewer",
-			},
-		)
-	assert r.status_code == 200
+    """User with COMMENTS but not TASKS can add a comment."""
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            "/comments/create",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "task": comment_env["task"],
+                "content": "from viewer",
+            },
+        )
+    assert r.status_code == 200
 
 
 def test_comment_create_forbidden_without_comments_grant(comment_env: dict[str, str]) -> None:
-	"""User with TASKS but not COMMENTS cannot add a comment."""
-	client = comment_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
-		r = client.post(
-			"/comments/create",
-			params={
-				"project": comment_env["project"],
-				"board": comment_env["board"],
-				"task": comment_env["task"],
-				"content": "from admin",
-			},
-		)
-	assert r.status_code == 403
+    """User with TASKS but not COMMENTS cannot add a comment."""
+    client = comment_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
+        r = client.post(
+            "/comments/create",
+            params={
+                "project": comment_env["project"],
+                "board": comment_env["board"],
+                "task": comment_env["task"],
+                "content": "from admin",
+            },
+        )
+    assert r.status_code == 403
diff --git a/pygitweb/src/pygitweb/config.py b/pygitweb/src/pygitweb/config.py
index 0636f29..6590f92 100644
--- a/pygitweb/src/pygitweb/config.py
+++ b/pygitweb/src/pygitweb/config.py
@@ -13,161 +13,161 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
 
 # Actions (most identical to gitweb.perl actions)
 ACTIONS: set[str] = {
-	"blame",
-	"blame_incremental",
-	"blame_data",
-	"blame_raw",
-	"blobdiff",
-	"blobpatch",
-	"blob",
-	"blob_plain",
-	"commitdiff",
-	"commit",
-	"heads",
-	"history",
-	"log",
-	"patch",
-	"patches",
-	"remotes",
-	"rss",
-	"atom",
-	"search",
-	"search_help",
-	"shortlog",
-	"summary",
-	"tag",
-	"tags",
-	"tree",
-	"snapshot",
-	"object",
-	"opml",
-	"project_list",
-	"project_index",
+    "blame",
+    "blame_incremental",
+    "blame_data",
+    "blame_raw",
+    "blobdiff",
+    "blobpatch",
+    "blob",
+    "blob_plain",
+    "commitdiff",
+    "commit",
+    "heads",
+    "history",
+    "log",
+    "patch",
+    "patches",
+    "remotes",
+    "rss",
+    "atom",
+    "search",
+    "search_help",
+    "shortlog",
+    "summary",
+    "tag",
+    "tags",
+    "tree",
+    "snapshot",
+    "object",
+    "opml",
+    "project_list",
+    "project_index",
 }
 
 # Map file extension to highlight.js language
 BLOB_LANG: dict[str, str] = {
-	"py": "python",
-	"js": "javascript",
-	"ts": "typescript",
-	"jsx": "javascript",
-	"tsx": "typescript",
-	"html": "html",
-	"htm": "html",
-	"css": "css",
-	"scss": "scss",
-	"json": "json",
-	"md": "markdown",
-	"sh": "bash",
-	"bash": "bash",
-	"yml": "yaml",
-	"yaml": "yaml",
-	"xml": "xml",
-	"go": "go",
-	"rs": "rust",
-	"java": "java",
-	"c": "c",
-	"h": "c",
-	"cpp": "cpp",
-	"cc": "cpp",
-	"cxx": "cpp",
-	"sql": "sql",
-	"r": "r",
-	"rb": "ruby",
-	"php": "php",
-	"swift": "swift",
-	"kt": "kotlin",
-	"vue": "xml",
-	"toml": "toml",
-	"ini": "ini",
-	"cfg": "ini",
-	"dockerfile": "dockerfile",
+    "py": "python",
+    "js": "javascript",
+    "ts": "typescript",
+    "jsx": "javascript",
+    "tsx": "typescript",
+    "html": "html",
+    "htm": "html",
+    "css": "css",
+    "scss": "scss",
+    "json": "json",
+    "md": "markdown",
+    "sh": "bash",
+    "bash": "bash",
+    "yml": "yaml",
+    "yaml": "yaml",
+    "xml": "xml",
+    "go": "go",
+    "rs": "rust",
+    "java": "java",
+    "c": "c",
+    "h": "c",
+    "cpp": "cpp",
+    "cc": "cpp",
+    "cxx": "cpp",
+    "sql": "sql",
+    "r": "r",
+    "rb": "ruby",
+    "php": "php",
+    "swift": "swift",
+    "kt": "kotlin",
+    "vue": "xml",
+    "toml": "toml",
+    "ini": "ini",
+    "cfg": "ini",
+    "dockerfile": "dockerfile",
 }
 
 
 class Settings(BaseSettings):
-	"""
-	Runtime settings from ~/.pygitweb/settings.json (or PYGITWEB_SETTINGS_CONFIG),
-	PYGITWEB_* env vars, and optionally a .env file. On first run the file is created
-	from the environment. When the file exists, env vars override file values (with a warning).
-
-	When AUTH is true, PyGitWeb uses OAuth2 password flow (POST /token), Bearer tokens,
-	and browser login. Local credentials live in the auth JSON file (see PYGITWEB_AUTH_CONFIG).
-
-	If PYGITWEB_AUTH is omitted entirely (or empty), authentication defaults to enabled
-	and a random password is written to the auth config file on first run (see stderr).
-	Set PYGITWEB_AUTH=0 or PYGITWEB_AUTH=1 explicitly as needed.
-	"""
-
-	model_config = SettingsConfigDict(
-		env_prefix="PYGITWEB_",
-		env_file=".env",
-		env_file_encoding="utf-8",
-		case_sensitive=False,
-		validate_assignment=True,
-		extra="ignore",
-	)
-
-	PROJECTROOT: str = Field(default_factory=lambda: str(Path.home()))
-	PROJECTS_LIST: str = ""  # falls back to PROJECTROOT when blank
-	PROJECT_MAXDEPTH: int = 1
-	SITE_NAME: str = "PyGitWeb"
-	EXPORT_OK: str = ""
-	LIST_ALL: bool = True
-	STRICT_EXPORT: bool = False
-	GIT: str = "git"
-	MAXLOAD: float | None = None
-
-	AUTH: bool | None = None
-	AUTH_CONFIG: str = ""
-	SETTINGS_CONFIG: str = ""
-	ENABLED_PLUGINS: list[str] = Field(default_factory=list)
-	API_KEY_MAX_COUNT: int = Field(default=2, ge=0)
-	API_KEY_EXPIRY_DAYS: int = Field(
-		default=0,
-		ge=0,
-		description="Lifetime of a newly issued API key, in days. 0 means the key does not expire.",
-	)
-
-	@field_validator("AUTH", mode="before")
-	@classmethod
-	def _coerce_auth(cls, v: object) -> bool | None:
-		if v is None:
-			return None
-		if isinstance(v, str) and v.strip() == "":
-			return None
-		if isinstance(v, bool):
-			return v
-		if isinstance(v, int):
-			return v != 0
-		if isinstance(v, str):
-			s = v.strip().lower()
-			if s in ("none", "false", "0", "no", "off"):
-				return False
-			if s in ("true", "1", "yes", "on"):
-				return True
-			return None
-		return bool(v)
-
-	@model_validator(mode="after")
-	def _defaults(self) -> Settings:
-		if not self.PROJECTS_LIST:
-			self.PROJECTS_LIST = self.PROJECTROOT
-		return self
+    """
+    Runtime settings from ~/.pygitweb/settings.json (or PYGITWEB_SETTINGS_CONFIG),
+    PYGITWEB_* env vars, and optionally a .env file. On first run the file is created
+    from the environment. When the file exists, env vars override file values (with a warning).
+
+    When AUTH is true, PyGitWeb uses OAuth2 password flow (POST /token), Bearer tokens,
+    and browser login. Local credentials live in the auth JSON file (see PYGITWEB_AUTH_CONFIG).
+
+    If PYGITWEB_AUTH is omitted entirely (or empty), authentication defaults to enabled
+    and a random password is written to the auth config file on first run (see stderr).
+    Set PYGITWEB_AUTH=0 or PYGITWEB_AUTH=1 explicitly as needed.
+    """
+
+    model_config = SettingsConfigDict(
+        env_prefix="PYGITWEB_",
+        env_file=".env",
+        env_file_encoding="utf-8",
+        case_sensitive=False,
+        validate_assignment=True,
+        extra="ignore",
+    )
+
+    PROJECTROOT: str = Field(default_factory=lambda: str(Path.home()))
+    PROJECTS_LIST: str = ""  # falls back to PROJECTROOT when blank
+    PROJECT_MAXDEPTH: int = 1
+    SITE_NAME: str = "PyGitWeb"
+    EXPORT_OK: str = ""
+    LIST_ALL: bool = True
+    STRICT_EXPORT: bool = False
+    GIT: str = "git"
+    MAXLOAD: float | None = None
+
+    AUTH: bool | None = None
+    AUTH_CONFIG: str = ""
+    SETTINGS_CONFIG: str = ""
+    ENABLED_PLUGINS: list[str] = Field(default_factory=list)
+    API_KEY_MAX_COUNT: int = Field(default=2, ge=0)
+    API_KEY_EXPIRY_DAYS: int = Field(
+        default=0,
+        ge=0,
+        description="Lifetime of a newly issued API key, in days. 0 means the key does not expire.",
+    )
+
+    @field_validator("AUTH", mode="before")
+    @classmethod
+    def _coerce_auth(cls, v: object) -> bool | None:
+        if v is None:
+            return None
+        if isinstance(v, str) and v.strip() == "":
+            return None
+        if isinstance(v, bool):
+            return v
+        if isinstance(v, int):
+            return v != 0
+        if isinstance(v, str):
+            s = v.strip().lower()
+            if s in ("none", "false", "0", "no", "off"):
+                return False
+            if s in ("true", "1", "yes", "on"):
+                return True
+            return None
+        return bool(v)
+
+    @model_validator(mode="after")
+    def _defaults(self) -> Settings:
+        if not self.PROJECTS_LIST:
+            self.PROJECTS_LIST = self.PROJECTROOT
+        return self
 
 
 def _finalize_auth_settings(s: Settings) -> None:
-	implicit_auth = s.AUTH is None
-	if implicit_auth:
-		s.AUTH = True
-	if implicit_auth and s.AUTH:
-		cfg_path = Path(s.AUTH_CONFIG).expanduser() if s.AUTH_CONFIG else Path.home() / ".pygitweb" / "auth.json"
-		print(
-			"WARNING: PyGitWeb: PYGITWEB_AUTH was not set; authentication defaults to ON.\n"
-			"         Set PYGITWEB_AUTH=0 to disable, or PYGITWEB_AUTH=1 and configure "
-			f"{cfg_path}.\n",
-			file=sys.stderr,
-		)
+    implicit_auth = s.AUTH is None
+    if implicit_auth:
+        s.AUTH = True
+    if implicit_auth and s.AUTH:
+        cfg_path = Path(s.AUTH_CONFIG).expanduser() if s.AUTH_CONFIG else Path.home() / ".pygitweb" / "auth.json"
+        print(
+            "WARNING: PyGitWeb: PYGITWEB_AUTH was not set; authentication defaults to ON.\n"
+            "         Set PYGITWEB_AUTH=0 to disable, or PYGITWEB_AUTH=1 and configure "
+            f"{cfg_path}.\n",
+            file=sys.stderr,
+        )
 
 
 from pygitweb.settings_config import init_settings  # noqa: E402
@@ -180,14 +180,14 @@ auth_config = init_auth_config(settings)
 
 
 def get_loadavg() -> float:
-	"""First element of load average, or 0 if unavailable. Port of get_loadavg."""
-	if sys.platform != "win32":
-		try:
-			return os.getloadavg()[0]
-		except OSError:
-			pass
-	try:
-		with open("/proc/loadavg") as f:
-			return float(f.read().split()[0])
-	except (OSError, ValueError):
-		return 0.0
+    """First element of load average, or 0 if unavailable. Port of get_loadavg."""
+    if sys.platform != "win32":
+        try:
+            return os.getloadavg()[0]
+        except OSError:
+            pass
+    try:
+        with open("/proc/loadavg") as f:
+            return float(f.read().split()[0])
+    except (OSError, ValueError):
+        return 0.0
diff --git a/pygitweb/src/pygitweb/conftest.py b/pygitweb/src/pygitweb/conftest.py
index 069f0f3..4340a67 100644
--- a/pygitweb/src/pygitweb/conftest.py
+++ b/pygitweb/src/pygitweb/conftest.py
@@ -11,33 +11,33 @@ from fastapi.testclient import TestClient
 
 
 def clear_client_cookies(client: TestClient) -> None:
-	client.cookies.clear()
+    client.cookies.clear()
 
 
 def set_client_session(client: TestClient, username: str) -> None:
-	from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
-	from pygitweb.sessions import create_session
+    from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
+    from pygitweb.sessions import create_session
 
-	client.cookies.set(ACCESS_TOKEN_COOKIE_NAME, create_session(username, auth_method="local"))
+    client.cookies.set(ACCESS_TOKEN_COOKIE_NAME, create_session(username, auth_method="local"))
 
 
 @contextmanager
 def client_as_user(client: TestClient, username: str) -> Generator[None, None, None]:
-	set_client_session(client, username)
-	try:
-		yield
-	finally:
-		clear_client_cookies(client)
+    set_client_session(client, username)
+    try:
+        yield
+    finally:
+        clear_client_cookies(client)
 
 
 @pytest.fixture(autouse=True)
 def _reset_pygitweb_app_lifecycle_state() -> Generator[None]:
-	"""Starlette TestClient lifespan sets shutting_down on exit; httpx ASGITransport never runs lifespan.
+    """Starlette TestClient lifespan sets shutting_down on exit; httpx ASGITransport never runs lifespan.
 
-	Stale state made long-poll tests see ``shutting_down`` and skip ``wait_for_changes``, so notify woke 0 waiters.
-	"""
-	from pygitweb.main import app
+    Stale state made long-poll tests see ``shutting_down`` and skip ``wait_for_changes``, so notify woke 0 waiters.
+    """
+    from pygitweb.main import app
 
-	app.state.shutting_down = False
-	app.state.shutdown_event = asyncio.Event()
-	yield
+    app.state.shutting_down = False
+    app.state.shutdown_event = asyncio.Event()
+    yield
diff --git a/pygitweb/src/pygitweb/dependencies.py b/pygitweb/src/pygitweb/dependencies.py
index 0aae681..2a47dae 100644
--- a/pygitweb/src/pygitweb/dependencies.py
+++ b/pygitweb/src/pygitweb/dependencies.py
@@ -10,9 +10,9 @@ from typing import Annotated, Any
 from fastapi import Depends, Form, HTTPException, Query, Request
 
 from pygitweb.auth import (
-	access_token_from_request,
-	ensure_read_project,
-	ensure_write_project,
+    access_token_from_request,
+    ensure_read_project,
+    ensure_write_project,
 )
 from pygitweb.config import settings
 from pygitweb.projects import git_get_projects_list, project_in_list
@@ -20,137 +20,137 @@ from pygitweb.validation import is_valid_project
 
 
 def _visible_projects_list() -> list[dict[str, Any]]:
-	return git_get_projects_list(
-		filter_path="",
-		paranoid=settings.STRICT_EXPORT,
-		export_ok=settings.EXPORT_OK,
-	)
+    return git_get_projects_list(
+        filter_path="",
+        paranoid=settings.STRICT_EXPORT,
+        export_ok=settings.EXPORT_OK,
+    )
 
 
 def project_visible_in_list(project: str) -> bool:
-	"""True if ``project`` appears in the same listing as the project picker."""
-	return project_in_list(project, _visible_projects_list)
+    """True if ``project`` appears in the same listing as the project picker."""
+    return project_in_list(project, _visible_projects_list)
 
 
 def require_valid_project(project: str) -> str:
-	"""Reject empty or unknown project paths (export_ok, strict list, etc.)."""
-	if not project or project == "":
-		raise HTTPException(status_code=400, detail="Project needed")
-	if not is_valid_project(
-		project,
-		settings.PROJECTROOT,
-		settings.EXPORT_OK,
-		settings.STRICT_EXPORT,
-		project_visible_in_list,
-	):
-		raise HTTPException(status_code=404, detail="No such project")
-	return project
+    """Reject empty or unknown project paths (export_ok, strict list, etc.)."""
+    if not project or project == "":
+        raise HTTPException(status_code=400, detail="Project needed")
+    if not is_valid_project(
+        project,
+        settings.PROJECTROOT,
+        settings.EXPORT_OK,
+        settings.STRICT_EXPORT,
+        project_visible_in_list,
+    ):
+        raise HTTPException(status_code=404, detail="No such project")
+    return project
 
 
 def require_valid_project_name(name: str) -> str:
-	"""Same as ``require_valid_project`` for a path parameter named ``name``."""
-	return require_valid_project(name)
+    """Same as ``require_valid_project`` for a path parameter named ``name``."""
+    return require_valid_project(name)
 
 
 def require_valid_project_from_query(
-	project: Annotated[str, Query(..., description="Project path")],
+    project: Annotated[str, Query(..., description="Project path")],
 ) -> str:
-	return require_valid_project(project)
+    return require_valid_project(project)
 
 
 def require_valid_project_form(project: Annotated[str, Form()]) -> str:
-	return require_valid_project(project)
+    return require_valid_project(project)
 
 
 def require_readable_project(
-	project: str,
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    project: str,
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> str:
-	validated = require_valid_project(project)
-	ensure_read_project(validated, token)
-	return validated
+    validated = require_valid_project(project)
+    ensure_read_project(validated, token)
+    return validated
 
 
 def require_readable_project_from_query(
-	project: Annotated[str, Query(..., description="Project path")],
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    project: Annotated[str, Query(..., description="Project path")],
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> str:
-	return require_readable_project(project, token)
+    return require_readable_project(project, token)
 
 
 def require_readable_project_form(
-	project: Annotated[str, Form()],
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    project: Annotated[str, Form()],
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> str:
-	return require_readable_project(project, token)
+    return require_readable_project(project, token)
 
 
 def require_readable_project_name(
-	name: str,
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    name: str,
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> str:
-	return require_readable_project(name, token)
+    return require_readable_project(name, token)
 
 
 def require_writable_project_name(
-	name: str,
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    name: str,
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> str:
-	validated = require_valid_project(name)
-	ensure_write_project(validated, token)
-	return validated
+    validated = require_valid_project(name)
+    ensure_write_project(validated, token)
+    return validated
 
 
 def is_loopback_host(host: str | None) -> bool:
-	if not host:
-		return False
-	if host == "localhost":
-		return True
-	try:
-		return ipaddress.ip_address(host).is_loopback
-	except ValueError:
-		return False
+    if not host:
+        return False
+    if host == "localhost":
+        return True
+    try:
+        return ipaddress.ip_address(host).is_loopback
+    except ValueError:
+        return False
 
 
 def require_loopback_client(request: Request) -> None:
-	"""Reject non-local callers (hooks should POST from the same host)."""
-	client = request.client
-	if client is not None and is_loopback_host(client.host):
-		return
-	raise HTTPException(status_code=403, detail="Forbidden")
+    """Reject non-local callers (hooks should POST from the same host)."""
+    client = request.client
+    if client is not None and is_loopback_host(client.host):
+        return
+    raise HTTPException(status_code=403, detail="Forbidden")
 
 
 def require_notify_project(
-	project: Annotated[str | None, Query(alias="project", description="Repository path")] = None,
+    project: Annotated[str | None, Query(alias="project", description="Repository path")] = None,
 ) -> str:
-	"""Internal notify: project must appear in the visible list (hook / long-poll contract)."""
-	if not project or not project.strip():
-		raise HTTPException(status_code=400, detail="project query parameter required")
-	p = project.strip()
-	if not project_visible_in_list(p):
-		raise HTTPException(status_code=404, detail="No such project")
-	return p
+    """Internal notify: project must appear in the visible list (hook / long-poll contract)."""
+    if not project or not project.strip():
+        raise HTTPException(status_code=400, detail="project query parameter required")
+    p = project.strip()
+    if not project_visible_in_list(p):
+        raise HTTPException(status_code=404, detail="No such project")
+    return p
 
 
 def board_create_resolved_project(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	p: Annotated[str | None, Query(alias="p")] = None,
-	project: Annotated[str | None, Query(alias="project")] = None,
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    p: Annotated[str | None, Query(alias="p")] = None,
+    project: Annotated[str | None, Query(alias="project")] = None,
 ) -> str:
-	"""Resolve ``?p=`` / ``?project=`` then validate as a normal project path."""
-	chosen = (project or p or "").strip()
-	if not chosen:
-		raise HTTPException(status_code=400, detail="Project needed (use ?project= or ?p=)")
-	return require_readable_project(chosen, token)
+    """Resolve ``?p=`` / ``?project=`` then validate as a normal project path."""
+    chosen = (project or p or "").strip()
+    if not chosen:
+        raise HTTPException(status_code=400, detail="Project needed (use ?project= or ?p=)")
+    return require_readable_project(chosen, token)
 
 
 def filter_projects_by_read_access(
-	projects: list[dict[str, Any]],
-	token: str | None,
+    projects: list[dict[str, Any]],
+    token: str | None,
 ) -> list[dict[str, Any]]:
-	from pygitweb.auth import can_read_project
+    from pygitweb.auth import can_read_project
 
-	return [p for p in projects if can_read_project(p.get("path", ""), token)]
+    return [p for p in projects if can_read_project(p.get("path", ""), token)]
 
 
 ValidatedPathProject = Annotated[str, Depends(require_valid_project)]
diff --git a/pygitweb/src/pygitweb/formatting.py b/pygitweb/src/pygitweb/formatting.py
index 2d0cff1..bf72334 100644
--- a/pygitweb/src/pygitweb/formatting.py
+++ b/pygitweb/src/pygitweb/formatting.py
@@ -15,168 +15,168 @@ FALLBACK_ENCODING = "latin1"
 
 # Control character escape codes (CEC). Port of quot_cec.
 _CEC_MAP = {
-	"\t": r"\t",
-	"\n": r"\n",
-	"\r": r"\r",
-	"\f": r"\f",
-	"\b": r"\b",
-	"\a": r"\a",
-	"\x1b": r"\e",
-	"\v": r"\v",
-	"\0": r"\0",
+    "\t": r"\t",
+    "\n": r"\n",
+    "\r": r"\r",
+    "\f": r"\f",
+    "\b": r"\b",
+    "\a": r"\a",
+    "\x1b": r"\e",
+    "\v": r"\v",
+    "\0": r"\0",
 }
 
 
 def to_utf8(s: str | bytes | None) -> str | None:
-	"""Decode to UTF-8 string; use fallback encoding if not valid UTF-8. Port of to_utf8."""
-	if s is None:
-		return None
-	if isinstance(s, str):
-		return s
-	try:
-		return s.decode("utf-8")
-	except UnicodeDecodeError:
-		return s.decode(FALLBACK_ENCODING, errors="replace")
+    """Decode to UTF-8 string; use fallback encoding if not valid UTF-8. Port of to_utf8."""
+    if s is None:
+        return None
+    if isinstance(s, str):
+        return s
+    try:
+        return s.decode("utf-8")
+    except UnicodeDecodeError:
+        return s.decode(FALLBACK_ENCODING, errors="replace")
 
 
 def esc_param(s: str | None) -> str | None:
-	"""URL-encode for query param; keep / and space as +. Port of esc_param."""
-	if s is None:
-		return None
-	return quote_plus(s, safe="")  # gitweb keeps -_.~()/@: and space→+
+    """URL-encode for query param; keep / and space as +. Port of esc_param."""
+    if s is None:
+        return None
+    return quote_plus(s, safe="")  # gitweb keeps -_.~()/@: and space→+
 
 
 def esc_path_info(s: str | None) -> str | None:
-	"""Path segment encoding; ? must be escaped. Port of esc_path_info."""
-	if s is None:
-		return None
-	# Safe: A-Za-z0-9\-_.~();/;:@&= +
-	return quote(s, safe="-_.~();/:@&= ")
+    """Path segment encoding; ? must be escaped. Port of esc_path_info."""
+    if s is None:
+        return None
+    # Safe: A-Za-z0-9\-_.~();/;:@&= +
+    return quote(s, safe="-_.~();/:@&= ")
 
 
 def esc_url(s: str | None) -> str | None:
-	"""URL encoding for href. Port of esc_url (same idea as esc_param)."""
-	if s is None:
-		return None
-	return quote(s, safe="-_.~()/:@!")
+    """URL encoding for href. Port of esc_url (same idea as esc_param)."""
+    if s is None:
+        return None
+    return quote(s, safe="-_.~()/:@!")
 
 
 def quot_cec(char: str, nohtml: bool = False) -> str:
-	"""Printable representation of control char (CEC). Port of quot_cec."""
-	out = _CEC_MAP.get(char, f"\\{ord(char):02x}")
-	if nohtml:
-		return out
-	return f'<span class="cntrl">{out}</span>'
+    """Printable representation of control char (CEC). Port of quot_cec."""
+    out = _CEC_MAP.get(char, f"\\{ord(char):02x}")
+    if nohtml:
+        return out
+    return f'<span class="cntrl">{out}</span>'
 
 
 def quot_upr(char: str, nohtml: bool = False) -> str:
-	"""Unicode control pictures. Port of quot_upr."""
-	code = 0x2400 + ord(char)
-	out = f"&#{code};"
-	if nohtml:
-		return out
-	return f'<span class="cntrl">{out}</span>'
+    """Unicode control pictures. Port of quot_upr."""
+    code = 0x2400 + ord(char)
+    out = f"&#{code};"
+    if nohtml:
+        return out
+    return f'<span class="cntrl">{out}</span>'
 
 
 def esc_path(s: str | None, nbsp: bool = False) -> str | None:
-	"""UTF-8, HTML-escape, then control chars to quot_cec. Port of esc_path."""
-	if s is None:
-		return None
-	s = to_utf8(s) or s
-	s = html.escape(s, quote=False)
-	if nbsp:
-		s = s.replace(" ", "&nbsp;")
-	result = []
-	for c in s:
-		if ord(c) < 32 or ord(c) == 127:
-			result.append(quot_cec(c))
-		else:
-			result.append(c)
-	return "".join(result)
+    """UTF-8, HTML-escape, then control chars to quot_cec. Port of esc_path."""
+    if s is None:
+        return None
+    s = to_utf8(s) or s
+    s = html.escape(s, quote=False)
+    if nbsp:
+        s = s.replace(" ", "&nbsp;")
+    result = []
+    for c in s:
+        if ord(c) < 32 or ord(c) == 127:
+            result.append(quot_cec(c))
+        else:
+            result.append(c)
+    return "".join(result)
 
 
 def sanitize(s: str | None) -> str | None:
-	"""XHTML-safe: control chars to CEC except tab/lf/cr. Port of sanitize."""
-	if s is None:
-		return None
-	s = to_utf8(s) or s
-	result = []
-	for c in s:
-		if c in "\t\n\r":
-			result.append(c)
-		elif ord(c) < 32 or ord(c) == 127:
-			result.append(quot_cec(c, nohtml=True))
-		else:
-			result.append(c)
-	return "".join(result)
+    """XHTML-safe: control chars to CEC except tab/lf/cr. Port of sanitize."""
+    if s is None:
+        return None
+    s = to_utf8(s) or s
+    result = []
+    for c in s:
+        if c in "\t\n\r":
+            result.append(c)
+        elif ord(c) < 32 or ord(c) == 127:
+            result.append(quot_cec(c, nohtml=True))
+        else:
+            result.append(c)
+    return "".join(result)
 
 
 def unquote(s: str | None) -> str:
-	"""Unescape git-style quoted filenames (C and octal). Port of unquote."""
-	if s is None:
-		return ""
-
-	def unq(seq: str) -> str:
-		es = {
-			"t": "\t",
-			"n": "\n",
-			"r": "\r",
-			"f": "\f",
-			"b": "\b",
-			"a": "\a",
-			"e": "\x1b",
-			"v": "\v",
-		}
-		if re.match(r"^[0-7]{1,3}$", seq):
-			return chr(int(seq, 8))
-		return es.get(seq, seq)
-
-	if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
-		s = s[1:-1]
-		s = re.sub(r"\\([^0-7]|[0-7]{1,3})", lambda m: unq(m.group(1)), s)
-	return s
+    """Unescape git-style quoted filenames (C and octal). Port of unquote."""
+    if s is None:
+        return ""
+
+    def unq(seq: str) -> str:
+        es = {
+            "t": "\t",
+            "n": "\n",
+            "r": "\r",
+            "f": "\f",
+            "b": "\b",
+            "a": "\a",
+            "e": "\x1b",
+            "v": "\v",
+        }
+        if re.match(r"^[0-7]{1,3}$", seq):
+            return chr(int(seq, 8))
+        return es.get(seq, seq)
+
+    if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
+        s = s[1:-1]
+        s = re.sub(r"\\([^0-7]|[0-7]{1,3})", lambda m: unq(m.group(1)), s)
+    return s
 
 
 def untabify(line: str, tabwidth: int = 8) -> str:
-	"""Expand tabs to spaces. Port of untabify."""
-	result = []
-	col = 0
-	for c in line:
-		if c == "\t":
-			n = tabwidth - (col % tabwidth)
-			result.append(" " * n)
-			col += n
-		else:
-			result.append(c)
-			col += 1
-	return "".join(result)
+    """Expand tabs to spaces. Port of untabify."""
+    result = []
+    col = 0
+    for c in line:
+        if c == "\t":
+            n = tabwidth - (col % tabwidth)
+            result.append(" " * n)
+            col += n
+        else:
+            result.append(c)
+            col += 1
+    return "".join(result)
 
 
 def age_class(age_seconds: float | None) -> str:
-	"""CSS class for age. Port of age_class."""
-	if age_seconds is None:
-		return "noage"
-	if age_seconds < 2 * 3600:
-		return "age0"
-	if age_seconds < 2 * 86400:
-		return "age1"
-	return "age2"
+    """CSS class for age. Port of age_class."""
+    if age_seconds is None:
+        return "noage"
+    if age_seconds < 2 * 3600:
+        return "age0"
+    if age_seconds < 2 * 86400:
+        return "age1"
+    return "age2"
 
 
 def age_string(age_seconds: float) -> str:
-	"""Human-readable age. Port of age_string."""
-	if age_seconds > 2 * 365 * 86400:
-		return f"{int(age_seconds / 86400 / 365)} years ago"
-	if age_seconds > 2 * (365 / 12) * 86400:
-		return f"{int(age_seconds / 86400 / (365 / 12))} months ago"
-	if age_seconds > 2 * 7 * 86400:
-		return f"{int(age_seconds / 86400 / 7)} weeks ago"
-	if age_seconds > 2 * 86400:
-		return f"{int(age_seconds / 86400)} days ago"
-	if age_seconds > 2 * 3600:
-		return f"{int(age_seconds / 3600)} hours ago"
-	if age_seconds > 2 * 60:
-		return f"{int(age_seconds / 60)} min ago"
-	if age_seconds > 2:
-		return f"{int(age_seconds)} sec ago"
-	return "right now"
+    """Human-readable age. Port of age_string."""
+    if age_seconds > 2 * 365 * 86400:
+        return f"{int(age_seconds / 86400 / 365)} years ago"
+    if age_seconds > 2 * (365 / 12) * 86400:
+        return f"{int(age_seconds / 86400 / (365 / 12))} months ago"
+    if age_seconds > 2 * 7 * 86400:
+        return f"{int(age_seconds / 86400 / 7)} weeks ago"
+    if age_seconds > 2 * 86400:
+        return f"{int(age_seconds / 86400)} days ago"
+    if age_seconds > 2 * 3600:
+        return f"{int(age_seconds / 3600)} hours ago"
+    if age_seconds > 2 * 60:
+        return f"{int(age_seconds / 60)} min ago"
+    if age_seconds > 2:
+        return f"{int(age_seconds)} sec ago"
+    return "right now"
diff --git a/pygitweb/src/pygitweb/formatting_test.py b/pygitweb/src/pygitweb/formatting_test.py
index 4c5cf6b..b36303b 100644
--- a/pygitweb/src/pygitweb/formatting_test.py
+++ b/pygitweb/src/pygitweb/formatting_test.py
@@ -2,67 +2,67 @@ import pygitweb.formatting as formatting
 
 
 def test_to_utf8_returns_string_input_unchanged() -> None:
-	assert formatting.to_utf8("hello") == "hello"
+    assert formatting.to_utf8("hello") == "hello"
 
 
 def test_to_utf8_decodes_utf8_bytes_successfully() -> None:
-	assert formatting.to_utf8("cafe".encode("utf-8")) == "cafe"  # noqa: UP012
+    assert formatting.to_utf8("cafe".encode("utf-8")) == "cafe"  # noqa: UP012
 
 
 def test_to_utf8_uses_fallback_encoding_when_utf8_fails() -> None:
-	assert formatting.to_utf8(b"\xf1") == "ñ"
+    assert formatting.to_utf8(b"\xf1") == "ñ"
 
 
 def test_to_utf8_fallback_decode_uses_replace_for_invalid_bytes() -> None:
-	original: str = formatting.FALLBACK_ENCODING
-	formatting.FALLBACK_ENCODING = "ascii"
-	try:
-		assert formatting.to_utf8(b"\xffabc") == "\ufffdabc"
-	finally:
-		formatting.FALLBACK_ENCODING = original
+    original: str = formatting.FALLBACK_ENCODING
+    formatting.FALLBACK_ENCODING = "ascii"
+    try:
+        assert formatting.to_utf8(b"\xffabc") == "\ufffdabc"
+    finally:
+        formatting.FALLBACK_ENCODING = original
 
 
 def test_esc_param_encodes_space_and_slash() -> None:
-	assert formatting.esc_param("a b/c") == "a+b%2Fc"
+    assert formatting.esc_param("a b/c") == "a+b%2Fc"
 
 
 def test_esc_path_info_escapes_question_mark() -> None:
-	assert formatting.esc_path_info("dir/file?.txt") == "dir/file%3F.txt"
+    assert formatting.esc_path_info("dir/file?.txt") == "dir/file%3F.txt"
 
 
 def test_esc_url_keeps_url_safe_characters() -> None:
-	assert formatting.esc_url("https://example.com/a b") == "https://example.com/a%20b"
+    assert formatting.esc_url("https://example.com/a b") == "https://example.com/a%20b"
 
 
 def test_quot_cec_formats_html_and_plain() -> None:
-	assert formatting.quot_cec("\n") == '<span class="cntrl">\\n</span>'
-	assert formatting.quot_cec("\n", nohtml=True) == "\\n"
+    assert formatting.quot_cec("\n") == '<span class="cntrl">\\n</span>'
+    assert formatting.quot_cec("\n", nohtml=True) == "\\n"
 
 
 def test_quot_upr_formats_html_and_plain() -> None:
-	assert formatting.quot_upr("\x01") == '<span class="cntrl">&#9217;</span>'
-	assert formatting.quot_upr("\x01", nohtml=True) == "&#9217;"
+    assert formatting.quot_upr("\x01") == '<span class="cntrl">&#9217;</span>'
+    assert formatting.quot_upr("\x01", nohtml=True) == "&#9217;"
 
 
 def test_esc_path_escapes_html_and_control_chars() -> None:
-	assert formatting.esc_path("a<\n") == 'a&lt;<span class="cntrl">\\n</span>'
+    assert formatting.esc_path("a<\n") == 'a&lt;<span class="cntrl">\\n</span>'
 
 
 def test_esc_path_nbsp_replaces_spaces() -> None:
-	assert formatting.esc_path("a b", nbsp=True) == "a&nbsp;b"
+    assert formatting.esc_path("a b", nbsp=True) == "a&nbsp;b"
 
 
 def test_sanitize_preserves_whitespace_controls_and_escapes_others() -> None:
-	assert formatting.sanitize("x\t\n\r\x01") == "x\t\n\r\\01"
+    assert formatting.sanitize("x\t\n\r\x01") == "x\t\n\r\\01"
 
 
 def test_unquote_unescapes_quoted_sequences() -> None:
-	assert formatting.unquote('"a\\tb\\040c"') == "a\tb c"
+    assert formatting.unquote('"a\\tb\\040c"') == "a\tb c"
 
 
 def test_unquote_returns_input_when_not_wrapped_in_quotes() -> None:
-	assert formatting.unquote("plain\\ntext") == "plain\\ntext"
+    assert formatting.unquote("plain\\ntext") == "plain\\ntext"
 
 
 def test_untabify_expands_tabs_to_next_tab_stop() -> None:
-	assert formatting.untabify("a\tb", tabwidth=4) == "a   b"
+    assert formatting.untabify("a\tb", tabwidth=4) == "a   b"
diff --git a/pygitweb/src/pygitweb/git_helpers.py b/pygitweb/src/pygitweb/git_helpers.py
index e8c00b0..e66cd01 100644
--- a/pygitweb/src/pygitweb/git_helpers.py
+++ b/pygitweb/src/pygitweb/git_helpers.py
@@ -27,131 +27,131 @@ README_CANDIDATES = ["README.md", "README", "readme.md", "readme", "Readme.md"]
 
 
 def _repo_path(project: str) -> str:
-	return os.path.join(settings.PROJECTROOT, project)
+    return os.path.join(settings.PROJECTROOT, project)
 
 
 def open_repo(project: str) -> pygit2.Repository:
-	"""Open pygit2.Repository for project. Replaces git_cmd + --git-dir."""
-	path = _repo_path(project)
-	return pygit2.Repository(path)
+    """Open pygit2.Repository for project. Replaces git_cmd + --git-dir."""
+    path = _repo_path(project)
+    return pygit2.Repository(path)
 
 
 def signature_from_principal_email(principal: PermissionPrincipal | None) -> pygit2.Signature | None:
-	if principal is None:
-		return None
-	email = (principal.email or "").strip()
-	if not email:
-		return None
-	name = principal.username.strip() or email
-	return pygit2.Signature(name, email)
+    if principal is None:
+        return None
+    email = (principal.email or "").strip()
+    if not email:
+        return None
+    name = principal.username.strip() or email
+    return pygit2.Signature(name, email)
 
 
 def repo_git_signature(repo: pygit2.Repository) -> pygit2.Signature | None:
-	by_name: dict[str, str] = {}
-	for entry in repo.config:
-		if entry.level not in _REPO_CONFIG_LEVELS:
-			continue
-		by_name[entry.name] = entry.value or ""
-	name = by_name.get("user.name", "").strip()
-	email = by_name.get("user.email", "").strip()
-	if name and email:
-		return pygit2.Signature(name, email)
-	return None
+    by_name: dict[str, str] = {}
+    for entry in repo.config:
+        if entry.level not in _REPO_CONFIG_LEVELS:
+            continue
+        by_name[entry.name] = entry.value or ""
+    name = by_name.get("user.name", "").strip()
+    email = by_name.get("user.email", "").strip()
+    if name and email:
+        return pygit2.Signature(name, email)
+    return None
 
 
 def signature_from_user_credentials(principal: PermissionPrincipal | None) -> pygit2.Signature | None:
-	if principal is None:
-		return None
-	username = principal.username.strip()
-	if not username:
-		return None
-	return pygit2.Signature(username, f"{username}@local")
+    if principal is None:
+        return None
+    username = principal.username.strip()
+    if not username:
+        return None
+    return pygit2.Signature(username, f"{username}@local")
 
 
 def resolve_mr_opener(
-	repo: pygit2.Repository,
-	principal: PermissionPrincipal | None,
+    repo: pygit2.Repository,
+    principal: PermissionPrincipal | None,
 ) -> tuple[pygit2.Signature, str | None]:
-	"""Resolve MR opener: IdP/session email, then repo git user, then logged-in user, then default."""
-	sig = signature_from_principal_email(principal)
-	if sig is not None:
-		return sig, None
-	sig = repo_git_signature(repo)
-	if sig is not None:
-		return sig, None
-	sig = signature_from_user_credentials(principal)
-	if sig is not None:
-		return sig, None
-	default = _DEFAULT_MR_OPENER
-	warning = (
-		"No opener identity was available (signed-in email, repository "
-		"user.name/user.email, or user credentials). Used default: "
-		f"{default.name} <{default.email}>."
-	)
-	return default, warning
+    """Resolve MR opener: IdP/session email, then repo git user, then logged-in user, then default."""
+    sig = signature_from_principal_email(principal)
+    if sig is not None:
+        return sig, None
+    sig = repo_git_signature(repo)
+    if sig is not None:
+        return sig, None
+    sig = signature_from_user_credentials(principal)
+    if sig is not None:
+        return sig, None
+    default = _DEFAULT_MR_OPENER
+    warning = (
+        "No opener identity was available (signed-in email, repository "
+        "user.name/user.email, or user credentials). Used default: "
+        f"{default.name} <{default.email}>."
+    )
+    return default, warning
 
 
 def git_get_type(project: str, ref: str) -> str | None:
-	"""Object type: commit, tree, blob, tag. Port of git_get_type (pygit2 obj.type)."""
-	try:
-		repo = open_repo(project)
-		obj = repo.revparse_single(ref)
-		return obj.type_str if obj else None
-	except (KeyError, pygit2.GitError, OSError):
-		return None
+    """Object type: commit, tree, blob, tag. Port of git_get_type (pygit2 obj.type)."""
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(ref)
+        return obj.type_str if obj else None
+    except (KeyError, pygit2.GitError, OSError):
+        return None
 
 
 def hash_set_multi(d: dict[str, str | list[str]], key: str, value: str) -> None:
-	"""Store multi-value: first value direct, rest in list. Port of hash_set_multi."""
-	existing = d.get(key)
-	if existing is None:
-		d[key] = value
-	elif isinstance(existing, list):
-		existing.append(value)
-	else:
-		d[key] = [existing, value]
+    """Store multi-value: first value direct, rest in list. Port of hash_set_multi."""
+    existing = d.get(key)
+    if existing is None:
+        d[key] = value
+    elif isinstance(existing, list):
+        existing.append(value)
+    else:
+        d[key] = [existing, value]
 
 
 def git_parse_project_config(project: str, section_regexp: str | None = None) -> dict[str, str | list[str]]:
-	"""All config key/values; optionally filter by section. Port of git_parse_project_config."""
-	try:
-		repo = open_repo(project)
-		cfg = repo.config
-		result: dict[str, str | list[str]] = {}
-		for entry in cfg:
-			key = entry.name
-			if section_regexp and not re.search(rf"^(?:{section_regexp})\.", key):
-				continue
-			value = entry.value
-			if value is None:
-				continue
-			hash_set_multi(result, key, value)
-		return result
-	except (pygit2.GitError, OSError):
-		return {}
+    """All config key/values; optionally filter by section. Port of git_parse_project_config."""
+    try:
+        repo = open_repo(project)
+        cfg = repo.config
+        result: dict[str, str | list[str]] = {}
+        for entry in cfg:
+            key = entry.name
+            if section_regexp and not re.search(rf"^(?:{section_regexp})\.", key):
+                continue
+            value = entry.value
+            if value is None:
+                continue
+            hash_set_multi(result, key, value)
+        return result
+    except (pygit2.GitError, OSError):
+        return {}
 
 
 def config_to_bool(val: str | None) -> bool:
-	"""Config value to bool: true/yes/1. Port of config_to_bool."""
-	if val is None:
-		return True
-	val = (val or "").strip()
-	if re.match(r"^\d+$", val):
-		return int(val) != 0
-	return val.lower() in ("true", "yes")
+    """Config value to bool: true/yes/1. Port of config_to_bool."""
+    if val is None:
+        return True
+    val = (val or "").strip()
+    if re.match(r"^\d+$", val):
+        return int(val) != 0
+    return val.lower() in ("true", "yes")
 
 
 def config_to_int(val: str | None) -> int | str:
-	"""Config value to int; k/m/g suffix. Port of config_to_int."""
-	if val is None:
-		return 0
-	val = (val or "").strip()
-	m = re.match(r"^([0-9]*)([kmg])$", val, re.I)
-	if m:
-		num, unit = m.group(1), m.group(2).lower()
-		mult = {"k": 1024, "m": 1048576, "g": 1073741824}.get(unit, 1)
-		return int(num or 0) * mult
-	return val
+    """Config value to int; k/m/g suffix. Port of config_to_int."""
+    if val is None:
+        return 0
+    val = (val or "").strip()
+    m = re.match(r"^([0-9]*)([kmg])$", val, re.I)
+    if m:
+        num, unit = m.group(1), m.group(2).lower()
+        mult = {"k": 1024, "m": 1048576, "g": 1073741824}.get(unit, 1)
+        return int(num or 0) * mult
+    return val
 
 
 # Per-repo cache for gitweb config (git_parse_project_config result)
@@ -159,369 +159,369 @@ _config_cache: dict[str, tuple[str, dict[str, str | list[str]]]] = {}
 
 
 def git_get_project_config(
-	project: str,
-	key: str,
-	config_type: str | None = None,
+    project: str,
+    key: str,
+    config_type: str | None = None,
 ) -> int | str | list[str] | bool | None:
-	"""Single config value; gitweb.* section. Port of git_get_project_config."""
-	key = key.lower().replace("_", "")
-	if key.startswith("gitweb."):
-		key = key[7:]
-	if re.search(r"\W", key):
-		return None
-	full_key = f"gitweb.{key}"
-	git_dir = _repo_path(project)
-	cache_key = git_dir
-	if cache_key not in _config_cache or _config_cache[cache_key][0] != os.path.join(git_dir, "config"):
-		cfg = git_parse_project_config(project, "gitweb")
-		_config_cache[cache_key] = (os.path.join(git_dir, "config"), cfg)
-	_, cfg = _config_cache[cache_key]
-	raw = cfg.get(full_key)
-	if raw is None:
-		return None
-	if config_type == "bool" or config_type == "--bool":
-		return config_to_bool(raw[0] if isinstance(raw, list) else raw)
-	if config_type == "int" or config_type == "--int":
-		return config_to_int(raw[0] if isinstance(raw, list) else raw)
-	if isinstance(raw, list):
-		return raw[0] if len(raw) == 1 else raw
-	return raw
+    """Single config value; gitweb.* section. Port of git_get_project_config."""
+    key = key.lower().replace("_", "")
+    if key.startswith("gitweb."):
+        key = key[7:]
+    if re.search(r"\W", key):
+        return None
+    full_key = f"gitweb.{key}"
+    git_dir = _repo_path(project)
+    cache_key = git_dir
+    if cache_key not in _config_cache or _config_cache[cache_key][0] != os.path.join(git_dir, "config"):
+        cfg = git_parse_project_config(project, "gitweb")
+        _config_cache[cache_key] = (os.path.join(git_dir, "config"), cfg)
+    _, cfg = _config_cache[cache_key]
+    raw = cfg.get(full_key)
+    if raw is None:
+        return None
+    if config_type == "bool" or config_type == "--bool":
+        return config_to_bool(raw[0] if isinstance(raw, list) else raw)
+    if config_type == "int" or config_type == "--int":
+        return config_to_int(raw[0] if isinstance(raw, list) else raw)
+    if isinstance(raw, list):
+        return raw[0] if len(raw) == 1 else raw
+    return raw
 
 
 def get_tree_at_ref_path(project: str, ref: str | None, path: str | None) -> tuple[pygit2.Tree, str] | None:
-	"""
-	Resolve the tree at ref (commit or tree) and optional path.
-	Returns (tree, ref_oid) for listing, or None if not found.
-	ref_oid is the resolved OID to use in URLs (same revision).
-	"""
-	try:
-		repo = open_repo(project)
-		base_ref = ref or (str(repo.head.target) if repo.head else None)
-		if not base_ref:
-			return None
-		obj = repo.revparse_single(base_ref)
-		ref_oid = str(obj.id)
-		base_tree = obj.peel(pygit2.Tree)
-		if not path or not path.strip("/"):
-			return (base_tree, ref_oid)
-		path_clean = path.strip("/")
-		entry = base_tree / path_clean
-		if not entry or entry.type_str != "tree":
-			return None
-		return (repo[entry.id].peel(pygit2.Tree), ref_oid)
-	except (KeyError, pygit2.GitError, OSError):
-		return None
+    """
+    Resolve the tree at ref (commit or tree) and optional path.
+    Returns (tree, ref_oid) for listing, or None if not found.
+    ref_oid is the resolved OID to use in URLs (same revision).
+    """
+    try:
+        repo = open_repo(project)
+        base_ref = ref or (str(repo.head.target) if repo.head else None)
+        if not base_ref:
+            return None
+        obj = repo.revparse_single(base_ref)
+        ref_oid = str(obj.id)
+        base_tree = obj.peel(pygit2.Tree)
+        if not path or not path.strip("/"):
+            return (base_tree, ref_oid)
+        path_clean = path.strip("/")
+        entry = base_tree / path_clean
+        if not entry or entry.type_str != "tree":
+            return None
+        return (repo[entry.id].peel(pygit2.Tree), ref_oid)
+    except (KeyError, pygit2.GitError, OSError):
+        return None
 
 
 def get_blob_at_ref_path(project: str, ref: str | None, path: str | None) -> tuple[pygit2.Blob, str] | None:
-	"""
-	Resolve the blob at ref (commit or tree) and path.
-	Returns (blob, ref_oid) or None if not found or not a blob.
-	"""
-	if not path or not path.strip("/"):
-		return None
-	try:
-		repo = open_repo(project)
-		base_ref = ref or (str(repo.head.target) if repo.head else None)
-		if not base_ref:
-			return None
-		obj = repo.revparse_single(base_ref)
-		ref_oid = str(obj.id)
-		base_tree = obj.peel(pygit2.Tree)
-		path_clean = path.strip("/")
-		entry = base_tree / path_clean
-		if not entry or entry.type_str != "blob":
-			return None
-		return (repo[entry.id].peel(pygit2.Blob), ref_oid)
-	except (KeyError, pygit2.GitError, OSError):
-		return None
+    """
+    Resolve the blob at ref (commit or tree) and path.
+    Returns (blob, ref_oid) or None if not found or not a blob.
+    """
+    if not path or not path.strip("/"):
+        return None
+    try:
+        repo = open_repo(project)
+        base_ref = ref or (str(repo.head.target) if repo.head else None)
+        if not base_ref:
+            return None
+        obj = repo.revparse_single(base_ref)
+        ref_oid = str(obj.id)
+        base_tree = obj.peel(pygit2.Tree)
+        path_clean = path.strip("/")
+        entry = base_tree / path_clean
+        if not entry or entry.type_str != "blob":
+            return None
+        return (repo[entry.id].peel(pygit2.Blob), ref_oid)
+    except (KeyError, pygit2.GitError, OSError):
+        return None
 
 
 def git_get_file_or_project_config(project: str, name: str) -> str | None:
-	"""Value from $GIT_DIR/name file or gitweb.name config. Port of git_get_file_or_project_config."""
-	path = os.path.join(_repo_path(project), name)
-	if os.path.isfile(path):
-		try:
-			with open(path) as f:
-				return f.read().strip()
-		except OSError:
-			pass
-	val = git_get_project_config(project, name)
-	return val[0] if isinstance(val, list) else (val if isinstance(val, str) else None)
+    """Value from $GIT_DIR/name file or gitweb.name config. Port of git_get_file_or_project_config."""
+    path = os.path.join(_repo_path(project), name)
+    if os.path.isfile(path):
+        try:
+            with open(path) as f:
+                return f.read().strip()
+        except OSError:
+            pass
+    val = git_get_project_config(project, name)
+    return val[0] if isinstance(val, list) else (val if isinstance(val, str) else None)
 
 
 def git_get_project_description(project: str) -> str | None:
-	"""Content of description file or config. Port of git_get_project_description."""
-	return git_get_file_or_project_config(project, "description")
+    """Content of description file or config. Port of git_get_project_description."""
+    return git_get_file_or_project_config(project, "description")
 
 
 def git_get_references(project: str, ref_prefix: str = "refs/heads") -> list[tuple[str, str]]:
-	"""List (ref_name, oid) for prefix. Port of git_get_references (pygit2 references)."""
-	try:
-		repo = open_repo(project)
-		prefix = ref_prefix + "/"
-		return [
-			(ref_name, str(repo.references[ref_name].resolve().target))
-			for ref_name in repo.references
-			if ref_name.startswith(prefix)
-		]
-	except (pygit2.GitError, OSError):
-		return []
+    """List (ref_name, oid) for prefix. Port of git_get_references (pygit2 references)."""
+    try:
+        repo = open_repo(project)
+        prefix = ref_prefix + "/"
+        return [
+            (ref_name, str(repo.references[ref_name].resolve().target))
+            for ref_name in repo.references
+            if ref_name.startswith(prefix)
+        ]
+    except (pygit2.GitError, OSError):
+        return []
 
 
 def git_get_heads_list(project: str) -> list[tuple[str, str, str]]:
-	"""List (name, ref, oid) for heads. Port of git_get_heads_list."""
-	refs = git_get_references(project, "refs/heads")
-	return [(ref.replace("refs/heads/", ""), ref, oid) for ref, oid in refs]
+    """List (name, ref, oid) for heads. Port of git_get_heads_list."""
+    refs = git_get_references(project, "refs/heads")
+    return [(ref.replace("refs/heads/", ""), ref, oid) for ref, oid in refs]
 
 
 def _tag_timestamp(repo: pygit2.Repository, oid: str) -> int:
-	"""Return tagger time for an annotated tag, or committer time for a lightweight tag (commit)."""
-	try:
-		obj = repo.revparse_single(oid)
-		if isinstance(obj, pygit2.Tag) and obj.tagger:
-			return obj.tagger.time
-		if isinstance(obj, pygit2.Commit):
-			return obj.committer.time
-	except (KeyError, pygit2.GitError):
-		pass
-	return 0
+    """Return tagger time for an annotated tag, or committer time for a lightweight tag (commit)."""
+    try:
+        obj = repo.revparse_single(oid)
+        if isinstance(obj, pygit2.Tag) and obj.tagger:
+            return obj.tagger.time
+        if isinstance(obj, pygit2.Commit):
+            return obj.committer.time
+    except (KeyError, pygit2.GitError):
+        pass
+    return 0
 
 
 def git_get_tags_list(project: str) -> list[tuple[str, str, str]]:
-	"""List (name, ref, oid) for tags. Sorted by tag creation time descending (newest first)."""
-	try:
-		repo = open_repo(project)
-		result = []
-		for ref in repo.references.iterator(pygit2.enums.ReferenceFilter.TAGS):
-			oid = str(ref.resolve().target)
-			name = ref.name.replace("refs/tags/", "")
-			ts = _tag_timestamp(repo, oid)
-			result.append((name, ref.name, oid, ts))
-		result.sort(key=lambda x: (x[3], x[0]), reverse=True)
-		return [(name, ref_name, oid) for name, ref_name, oid, _ in result]
-	except (pygit2.GitError, OSError):
-		return []
+    """List (name, ref, oid) for tags. Sorted by tag creation time descending (newest first)."""
+    try:
+        repo = open_repo(project)
+        result = []
+        for ref in repo.references.iterator(pygit2.enums.ReferenceFilter.TAGS):
+            oid = str(ref.resolve().target)
+            name = ref.name.replace("refs/tags/", "")
+            ts = _tag_timestamp(repo, oid)
+            result.append((name, ref.name, oid, ts))
+        result.sort(key=lambda x: (x[3], x[0]), reverse=True)
+        return [(name, ref_name, oid) for name, ref_name, oid, _ in result]
+    except (pygit2.GitError, OSError):
+        return []
 
 
 def git_get_remotes_info(project: str) -> list[dict[str, Any]]:
-	"""Remote info: name, url, push_url. Uses pygit2 Remote.url and Remote.push_url."""
-	try:
-		repo = open_repo(project)
-		result = []
-		for name in repo.remotes.names():
-			if not name:
-				continue
-			remote = repo.remotes[name]
-			result.append({
-				"name": name,
-				"url": remote.url or "",
-				"push_url": remote.push_url or remote.url or "",
-			})
-		return result
-	except (pygit2.GitError, OSError):
-		return []
+    """Remote info: name, url, push_url. Uses pygit2 Remote.url and Remote.push_url."""
+    try:
+        repo = open_repo(project)
+        result = []
+        for name in repo.remotes.names():
+            if not name:
+                continue
+            remote = repo.remotes[name]
+            result.append({
+                "name": name,
+                "url": remote.url or "",
+                "push_url": remote.push_url or remote.url or "",
+            })
+        return result
+    except (pygit2.GitError, OSError):
+        return []
 
 
 def parse_commit(project: str, oid: str) -> dict[str, Any]:
-	"""Commit metadata dict. Port of parse_commit (pygit2 commit)."""
-	try:
-		repo = open_repo(project)
-		obj = repo.revparse_single(oid)
-		if not isinstance(obj, pygit2.Commit):
-			return {}
-		commit = obj
-		return {
-			"parent": [str(p) for p in commit.parent_ids],
-			"tree": str(commit.tree_id),
-			"author": commit.author.name,
-			"author_email": commit.author.email,
-			"author_epoch": commit.author.time,
-			"author_tz": commit.author.offset,
-			"committer": commit.committer.name,
-			"committer_email": commit.committer.email,
-			"committer_epoch": commit.committer.time,
-			"committer_tz": commit.committer.offset,
-			"subject": commit.message.split("\n")[0] if commit.message else "",
-			"body": commit.message or "",
-		}
-	except (KeyError, pygit2.GitError, OSError):
-		return {}
+    """Commit metadata dict. Port of parse_commit (pygit2 commit)."""
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(oid)
+        if not isinstance(obj, pygit2.Commit):
+            return {}
+        commit = obj
+        return {
+            "parent": [str(p) for p in commit.parent_ids],
+            "tree": str(commit.tree_id),
+            "author": commit.author.name,
+            "author_email": commit.author.email,
+            "author_epoch": commit.author.time,
+            "author_tz": commit.author.offset,
+            "committer": commit.committer.name,
+            "committer_email": commit.committer.email,
+            "committer_epoch": commit.committer.time,
+            "committer_tz": commit.committer.offset,
+            "subject": commit.message.split("\n")[0] if commit.message else "",
+            "body": commit.message or "",
+        }
+    except (KeyError, pygit2.GitError, OSError):
+        return {}
 
 
 def parse_tag(project: str, oid: str) -> dict[str, Any]:
-	"""Tag metadata. Port of parse_tag (pygit2 tag)."""
-	try:
-		repo = open_repo(project)
-		obj = repo.revparse_single(oid)
-		if not isinstance(obj, pygit2.Tag):
-			return {}
-		tag = obj
-		return {
-			"object": str(tag.target),
-			"type": tag.type_str,
-			"tagger": tag.tagger.name if tag.tagger else "",
-			"tagger_email": tag.tagger.email if tag.tagger else "",
-			"tagger_epoch": tag.tagger.time if tag.tagger else 0,
-			"tagger_tz": tag.tagger.offset if tag.tagger else 0,
-			"message": tag.message or "",
-		}
-	except (KeyError, pygit2.GitError, OSError):
-		return {}
+    """Tag metadata. Port of parse_tag (pygit2 tag)."""
+    try:
+        repo = open_repo(project)
+        obj = repo.revparse_single(oid)
+        if not isinstance(obj, pygit2.Tag):
+            return {}
+        tag = obj
+        return {
+            "object": str(tag.target),
+            "type": tag.type_str,
+            "tagger": tag.tagger.name if tag.tagger else "",
+            "tagger_email": tag.tagger.email if tag.tagger else "",
+            "tagger_epoch": tag.tagger.time if tag.tagger else 0,
+            "tagger_tz": tag.tagger.offset if tag.tagger else 0,
+            "message": tag.message or "",
+        }
+    except (KeyError, pygit2.GitError, OSError):
+        return {}
 
 
 def get_commit_history(
-	project: str,
-	ref: str | None = None,
-	path: str | None = None,
-	max_count: int = 100,
-	skip: int = 0,
+    project: str,
+    ref: str | None = None,
+    path: str | None = None,
+    max_count: int = 100,
+    skip: int = 0,
 ) -> list[dict[str, Any]]:
-	"""
-	Get commit history for a project, optionally filtered by path.
-	Returns list of commit dicts with oid and parsed commit data.
-	Port of git log functionality.
-	skip: number of matching commits to skip (for pagination).
-	"""
-	try:
-		repo = open_repo(project)
-		if ref:
-			try:
-				start_oid: pygit2.Oid | None = repo.revparse_single(ref).peel(pygit2.Commit).id
-			except (KeyError, pygit2.GitError, ValueError):
-				start_oid = None
-		else:
-			try:
-				start_oid = repo.head.peel(pygit2.Commit).id if repo.head else None
-			except (KeyError, pygit2.GitError, ValueError):
-				start_oid = None
-		if not start_oid:
-			return []
-
-		commits: list[dict[str, Any]] = []
-		skipped = 0
-		walker = repo.walk(
-			start_oid,
-			pygit2.enums.SortMode.TIME | pygit2.enums.SortMode.TOPOLOGICAL,
-		)
-
-		if path:
-			# Filter by path: only commits that touched this path (or a file under it)
-			path_clean = path.strip("/")
-			path_prefix = path_clean + "/"
-
-			def touched_path(diff: pygit2.Diff) -> bool:
-				for delta in diff.deltas:
-					old_p, new_p = delta.old_file.path, delta.new_file.path
-					if old_p == path_clean or new_p == path_clean:
-						return True
-					if old_p.startswith(path_prefix) or new_p.startswith(path_prefix):
-						return True
-				return False
-
-			for commit in walker:
-				if len(commits) >= max_count:
-					break
-				try:
-					if commit.parents:
-						diff = repo.diff(commit.parents[0], commit)
-						if not touched_path(diff):
-							continue
-					else:
-						# Root commit: include if path is root or path exists in tree
-						if path_clean:
-							try:
-								commit.tree / path_clean
-							except KeyError:
-								continue
-					if skipped < skip:
-						skipped += 1
-						continue
-					commit_data = parse_commit(project, str(commit.id))
-					commit_data["oid"] = str(commit.id)
-					commits.append(commit_data)
-				except (KeyError, AttributeError, pygit2.GitError):
-					pass
-		else:
-			# No path filter, get all commits
-			for commit in walker:
-				if skipped < skip:
-					skipped += 1
-					continue
-				if len(commits) >= max_count:
-					break
-				commit_data = parse_commit(project, str(commit.id))
-				commit_data["oid"] = str(commit.id)
-				commits.append(commit_data)
-
-		return commits
-	except (KeyError, pygit2.GitError, OSError):
-		return []
+    """
+    Get commit history for a project, optionally filtered by path.
+    Returns list of commit dicts with oid and parsed commit data.
+    Port of git log functionality.
+    skip: number of matching commits to skip (for pagination).
+    """
+    try:
+        repo = open_repo(project)
+        if ref:
+            try:
+                start_oid: pygit2.Oid | None = repo.revparse_single(ref).peel(pygit2.Commit).id
+            except (KeyError, pygit2.GitError, ValueError):
+                start_oid = None
+        else:
+            try:
+                start_oid = repo.head.peel(pygit2.Commit).id if repo.head else None
+            except (KeyError, pygit2.GitError, ValueError):
+                start_oid = None
+        if not start_oid:
+            return []
+
+        commits: list[dict[str, Any]] = []
+        skipped = 0
+        walker = repo.walk(
+            start_oid,
+            pygit2.enums.SortMode.TIME | pygit2.enums.SortMode.TOPOLOGICAL,
+        )
+
+        if path:
+            # Filter by path: only commits that touched this path (or a file under it)
+            path_clean = path.strip("/")
+            path_prefix = path_clean + "/"
+
+            def touched_path(diff: pygit2.Diff) -> bool:
+                for delta in diff.deltas:
+                    old_p, new_p = delta.old_file.path, delta.new_file.path
+                    if old_p == path_clean or new_p == path_clean:
+                        return True
+                    if old_p.startswith(path_prefix) or new_p.startswith(path_prefix):
+                        return True
+                return False
+
+            for commit in walker:
+                if len(commits) >= max_count:
+                    break
+                try:
+                    if commit.parents:
+                        diff = repo.diff(commit.parents[0], commit)
+                        if not touched_path(diff):
+                            continue
+                    else:
+                        # Root commit: include if path is root or path exists in tree
+                        if path_clean:
+                            try:
+                                commit.tree / path_clean
+                            except KeyError:
+                                continue
+                    if skipped < skip:
+                        skipped += 1
+                        continue
+                    commit_data = parse_commit(project, str(commit.id))
+                    commit_data["oid"] = str(commit.id)
+                    commits.append(commit_data)
+                except (KeyError, AttributeError, pygit2.GitError):
+                    pass
+        else:
+            # No path filter, get all commits
+            for commit in walker:
+                if skipped < skip:
+                    skipped += 1
+                    continue
+                if len(commits) >= max_count:
+                    break
+                commit_data = parse_commit(project, str(commit.id))
+                commit_data["oid"] = str(commit.id)
+                commits.append(commit_data)
+
+        return commits
+    except (KeyError, pygit2.GitError, OSError):
+        return []
 
 
 def get_commits_in_range(project: str, tip: str, base: str | None) -> list[str]:
-	"""Return list of commit OIDs from tip back to (but not including) base. Newest first."""
-	try:
-		repo = open_repo(project)
-		tip_commit = repo.revparse_single(tip).peel(pygit2.Commit)
-		walker = repo.walk(
-			tip_commit.id,
-			pygit2.enums.SortMode.TIME | pygit2.enums.SortMode.TOPOLOGICAL,
-		)
-		if base:
-			with suppress(KeyError, pygit2.GitError, ValueError):
-				walker.hide(repo.revparse_single(base).peel(pygit2.Commit).id)
-		return [str(c.id) for c in walker]
-	except (KeyError, pygit2.GitError, OSError, ValueError):
-		return []
+    """Return list of commit OIDs from tip back to (but not including) base. Newest first."""
+    try:
+        repo = open_repo(project)
+        tip_commit = repo.revparse_single(tip).peel(pygit2.Commit)
+        walker = repo.walk(
+            tip_commit.id,
+            pygit2.enums.SortMode.TIME | pygit2.enums.SortMode.TOPOLOGICAL,
+        )
+        if base:
+            with suppress(KeyError, pygit2.GitError, ValueError):
+                walker.hide(repo.revparse_single(base).peel(pygit2.Commit).id)
+        return [str(c.id) for c in walker]
+    except (KeyError, pygit2.GitError, OSError, ValueError):
+        return []
 
 
 def get_blob_unified_diff(
-	project: str,
-	ref_old: str,
-	path_old: str,
-	ref_new: str,
-	path_new: str,
+    project: str,
+    ref_old: str,
+    path_old: str,
+    ref_new: str,
+    path_new: str,
 ) -> str | None:
-	"""
-	Return unified diff between blob at ref_old:path_old and ref_new:path_new using pygit2.
-	Returns None if either blob is not found; otherwise returns the diff string (possibly empty).
-	"""
-	result_old = get_blob_at_ref_path(project, ref_old, path_old)
-	result_new = get_blob_at_ref_path(project, ref_new, path_new)
-	if not result_old or not result_new:
-		return None
-	old_blob, _ = result_old
-	new_blob, _ = result_new
-	patch = old_blob.diff(new_blob, old_as_path=path_old, new_as_path=path_new)
-	return patch.text if patch.text else ""
+    """
+    Return unified diff between blob at ref_old:path_old and ref_new:path_new using pygit2.
+    Returns None if either blob is not found; otherwise returns the diff string (possibly empty).
+    """
+    result_old = get_blob_at_ref_path(project, ref_old, path_old)
+    result_new = get_blob_at_ref_path(project, ref_new, path_new)
+    if not result_old or not result_new:
+        return None
+    old_blob, _ = result_old
+    new_blob, _ = result_new
+    patch = old_blob.diff(new_blob, old_as_path=path_old, new_as_path=path_new)
+    return patch.text if patch.text else ""
 
 
 def get_readme_at_ref_path(project: str, ref: str | None, dir_path: str | None) -> tuple[str, str] | None:
-	"""
-	If a README exists at ref in the given tree (dir_path), return (filename, utf8_content).
-	Otherwise None. dir_path is the tree path (e.g. '' for root, 'docs' for docs/).
-	"""
-	if not ref:
-		return None
-	for name in README_CANDIDATES:
-		path = f"{dir_path.strip('/')}/{name}".strip("/") if dir_path else name
-		result = get_blob_at_ref_path(project, ref, path)
-		if result:
-			blob, _ = result
-			text = to_utf8(blob.data) or ""
-			return (name, text)
-	return None
+    """
+    If a README exists at ref in the given tree (dir_path), return (filename, utf8_content).
+    Otherwise None. dir_path is the tree path (e.g. '' for root, 'docs' for docs/).
+    """
+    if not ref:
+        return None
+    for name in README_CANDIDATES:
+        path = f"{dir_path.strip('/')}/{name}".strip("/") if dir_path else name
+        result = get_blob_at_ref_path(project, ref, path)
+        if result:
+            blob, _ = result
+            text = to_utf8(blob.data) or ""
+            return (name, text)
+    return None
 
 
 def get_commit_unified_diff(project: str, h: str) -> tuple[str, str]:
-	"""
-	Return (unified_diff_text, oid_short) for commit h.
-	Raises KeyError, pygit2.GitError, OSError on error (caller should validate ref and map to HTTPException).
-	"""
-	repo = open_repo(project)
-	commit = repo.revparse_single(h).peel(pygit2.Commit)
-	diff = repo.diff(commit.parents[0], commit) if commit.parents else commit.tree.diff_to_tree(swap=True)
-	body = diff.patch or ""
-	oid_short = commit.short_id[:7] if len(commit.short_id) >= 7 else commit.short_id
-	return body, oid_short
+    """
+    Return (unified_diff_text, oid_short) for commit h.
+    Raises KeyError, pygit2.GitError, OSError on error (caller should validate ref and map to HTTPException).
+    """
+    repo = open_repo(project)
+    commit = repo.revparse_single(h).peel(pygit2.Commit)
+    diff = repo.diff(commit.parents[0], commit) if commit.parents else commit.tree.diff_to_tree(swap=True)
+    body = diff.patch or ""
+    oid_short = commit.short_id[:7] if len(commit.short_id) >= 7 else commit.short_id
+    return body, oid_short
diff --git a/pygitweb/src/pygitweb/git_helpers_test.py b/pygitweb/src/pygitweb/git_helpers_test.py
index 5b568b2..ab7fe89 100644
--- a/pygitweb/src/pygitweb/git_helpers_test.py
+++ b/pygitweb/src/pygitweb/git_helpers_test.py
@@ -10,258 +10,258 @@ import pytest
 
 from pygitweb.config import settings
 from pygitweb.git_helpers import (
-	_config_cache,
-	config_to_bool,
-	config_to_int,
-	get_blob_at_ref_path,
-	get_blob_unified_diff,
-	get_commit_history,
-	get_commit_unified_diff,
-	get_commits_in_range,
-	get_readme_at_ref_path,
-	get_tree_at_ref_path,
-	git_get_file_or_project_config,
-	git_get_heads_list,
-	git_get_project_config,
-	git_get_project_description,
-	git_get_references,
-	git_get_remotes_info,
-	git_get_tags_list,
-	git_get_type,
-	git_parse_project_config,
-	hash_set_multi,
-	parse_commit,
-	parse_tag,
-	resolve_mr_opener,
-	signature_from_principal_email,
+    _config_cache,
+    config_to_bool,
+    config_to_int,
+    get_blob_at_ref_path,
+    get_blob_unified_diff,
+    get_commit_history,
+    get_commit_unified_diff,
+    get_commits_in_range,
+    get_readme_at_ref_path,
+    get_tree_at_ref_path,
+    git_get_file_or_project_config,
+    git_get_heads_list,
+    git_get_project_config,
+    git_get_project_description,
+    git_get_references,
+    git_get_remotes_info,
+    git_get_tags_list,
+    git_get_type,
+    git_parse_project_config,
+    hash_set_multi,
+    parse_commit,
+    parse_tag,
+    resolve_mr_opener,
+    signature_from_principal_email,
 )
 from pygitweb.permissions import PermissionPrincipal
 
 
 def _run_git(repo_dir: Path, *args: str) -> str:
-	completed: subprocess.CompletedProcess[str] = subprocess.run(
-		["git", *args],
-		cwd=repo_dir,
-		capture_output=True,
-		text=True,
-		check=True,
-	)
-	return completed.stdout.strip()
+    completed: subprocess.CompletedProcess[str] = subprocess.run(
+        ["git", *args],
+        cwd=repo_dir,
+        capture_output=True,
+        text=True,
+        check=True,
+    )
+    return completed.stdout.strip()
 
 
 class TestGitHelpers:
-	class TestPure:
-		def test_hash_set_multi_promotes_to_list(self) -> None:
-			data: dict[str, object] = {}
-			hash_set_multi(data, "k", "v1")
-			hash_set_multi(data, "k", "v2")
-			assert data["k"] == ["v1", "v2"]
-
-		@pytest.mark.parametrize(
-			("value", "expected"),
-			[("true", True), ("0", False), (None, True)],
-		)
-		def test_config_to_bool(self, value: str | None, expected: bool) -> None:
-			assert config_to_bool(value) is expected
-
-		@pytest.mark.parametrize(
-			("value", "expected"),
-			[("2k", 2048), ("3M", 3145728), ("123", "123"), (None, 0)],
-		)
-		def test_config_to_int(self, value: str | None, expected: int | str) -> None:
-			assert config_to_int(value) == expected
-
-	class TestRepoReadonly:
-		@pytest.fixture(scope="class")
-		def repo_ctx(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
-			project_root: Path = tmp_path_factory.mktemp("git-helpers")
-			repo_dir: Path = project_root / "sample"
-			repo_dir.mkdir()
-
-			_run_git(repo_dir, "init", "-b", "main")
-			_run_git(repo_dir, "config", "user.name", "Alice")
-			_run_git(repo_dir, "config", "user.email", "alice@example.com")
-			_run_git(repo_dir, "remote", "add", "origin", "https://example.com/repo.git")
-			_run_git(repo_dir, "remote", "set-url", "--push", "origin", "ssh://example.com/repo.git")
-			_run_git(repo_dir, "config", "gitweb.owner", "alice")
-			_run_git(repo_dir, "config", "--add", "gitweb.category", "infra")
-			_run_git(repo_dir, "config", "--add", "gitweb.category", "tools")
-
-			(repo_dir / "README.md").write_text("Root README\n", encoding="utf-8")
-			(repo_dir / "docs").mkdir()
-			(repo_dir / "docs" / "README").write_text("Docs README\n", encoding="utf-8")
-			(repo_dir / "docs" / "guide.txt").write_text("hello\n", encoding="utf-8")
-			(repo_dir / "src").mkdir()
-			(repo_dir / "src" / "app.py").write_text("print('v1')\n", encoding="utf-8")
-			_run_git(repo_dir, "add", ".")
-			_run_git(repo_dir, "commit", "-m", "initial commit")
-
-			first_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
-			(repo_dir / "docs" / "guide.txt").write_text("hello\nworld\n", encoding="utf-8")
-			(repo_dir / "src" / "app.py").write_text("print('v2')\n", encoding="utf-8")
-			_run_git(repo_dir, "add", ".")
-			_run_git(repo_dir, "commit", "-m", "update docs and app")
-			second_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
-
-			_run_git(
-				repo_dir,
-				"tag",
-				"-a",
-				"v1.0",
-				"-m",
-				"release v1.0",
-				second_commit,
-			)
-			_run_git(repo_dir, "tag", "lightweight", first_commit)
-			_run_git(repo_dir, "branch", "feature/readme", second_commit)
-
-			git_dir: Path = repo_dir / ".git"
-			(git_dir / "description").write_text("Repo description\n", encoding="utf-8")
-
-			ctx: dict[str, str] = {
-				"project": "sample/.git",
-				"first_commit": first_commit,
-				"second_commit": second_commit,
-			}
-
-			_config_cache.clear()
-			with patch.object(settings, "PROJECTROOT", str(project_root)):
-				yield ctx
-			_config_cache.clear()
-
-		def test_git_get_type(self, repo_ctx: dict[str, str]) -> None:
-			assert git_get_type(repo_ctx["project"], "HEAD") == "commit"
-			assert git_get_type(repo_ctx["project"], "HEAD^{tree}") == "tree"
-
-		def test_git_parse_and_get_project_config(self, repo_ctx: dict[str, str]) -> None:
-			cfg: dict[str, object] = git_parse_project_config(repo_ctx["project"], "gitweb")
-			assert cfg["gitweb.owner"] == "alice"
-			assert cfg["gitweb.category"] == ["infra", "tools"]
-			assert git_get_project_config(repo_ctx["project"], "owner") == "alice"
-			assert git_get_project_config(repo_ctx["project"], "category") == ["infra", "tools"]
-
-		def test_git_get_file_or_project_config_prefers_file(self, repo_ctx: dict[str, str]) -> None:
-			assert git_get_file_or_project_config(repo_ctx["project"], "description") == "Repo description"
-			assert git_get_project_description(repo_ctx["project"]) == "Repo description"
-
-		def test_references_heads_tags_and_remotes(self, repo_ctx: dict[str, str]) -> None:
-			heads: list[tuple[str, str, str]] = git_get_heads_list(repo_ctx["project"])
-			head_names: set[str] = {name for name, _ref, _oid in heads}
-			assert {"main", "feature/readme"} <= head_names
-
-			refs: list[tuple[str, str]] = git_get_references(repo_ctx["project"])
-			assert any(name == "refs/heads/main" for name, _oid in refs)
-
-			tags: list[tuple[str, str, str]] = git_get_tags_list(repo_ctx["project"])
-			tag_names: set[str] = {name for name, _ref, _oid in tags}
-			assert {"v1.0", "lightweight"} <= tag_names
-
-			remotes: list[dict[str, object]] = git_get_remotes_info(repo_ctx["project"])
-			assert remotes == [
-				{
-					"name": "origin",
-					"url": "https://example.com/repo.git",
-					"push_url": "ssh://example.com/repo.git",
-				}
-			]
-
-		def test_parse_commit_and_parse_tag(self, repo_ctx: dict[str, str]) -> None:
-			commit_data: dict[str, object] = parse_commit(repo_ctx["project"], repo_ctx["second_commit"])
-			assert commit_data["subject"] == "update docs and app"
-			assert commit_data["author"] == "Alice"
-			assert isinstance(commit_data["parent"], list)
-
-			repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / repo_ctx["project"]))
-			tag_oid: str = str(repo.references["refs/tags/v1.0"].target)
-			tag_data: dict[str, object] = parse_tag(repo_ctx["project"], tag_oid)
-			assert tag_data["type"] == "tag"
-			assert tag_data["message"] == "release v1.0\n"
-
-		def test_tree_blob_and_readme_resolution(self, repo_ctx: dict[str, str]) -> None:
-			tree_result = get_tree_at_ref_path(repo_ctx["project"], "HEAD", "docs")
-			assert tree_result is not None
-			tree, ref_oid = tree_result
-			assert ref_oid == repo_ctx["second_commit"]
-			assert tree["guide.txt"].type_str == "blob"
-
-			blob_result = get_blob_at_ref_path(repo_ctx["project"], "HEAD", "docs/guide.txt")
-			assert blob_result is not None
-			blob, _resolved_ref = blob_result
-			assert blob.data.decode("utf-8") == "hello\nworld\n"
-
-			readme_root = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "")
-			assert readme_root == ("README.md", "Root README\n")
-			readme_docs = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "docs")
-			assert readme_docs == ("README", "Docs README\n")
-
-		def test_commit_history_and_ranges(self, repo_ctx: dict[str, str]) -> None:
-			all_history: list[dict[str, object]] = get_commit_history(repo_ctx["project"], max_count=10)
-			assert [entry["oid"] for entry in all_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]
-
-			path_history: list[dict[str, object]] = get_commit_history(
-				repo_ctx["project"],
-				path="docs/guide.txt",
-				max_count=10,
-			)
-			assert [entry["oid"] for entry in path_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]
-
-			in_range: list[str] = get_commits_in_range(repo_ctx["project"], "HEAD", repo_ctx["first_commit"])
-			assert in_range == [repo_ctx["second_commit"]]
-
-		def test_blob_and_commit_unified_diff(self, repo_ctx: dict[str, str]) -> None:
-			blob_diff = get_blob_unified_diff(
-				repo_ctx["project"],
-				repo_ctx["first_commit"],
-				"src/app.py",
-				repo_ctx["second_commit"],
-				"src/app.py",
-			)
-			assert blob_diff is not None
-			assert "-print('v1')" in blob_diff
-			assert "+print('v2')" in blob_diff
-
-			commit_diff, oid_short = get_commit_unified_diff(repo_ctx["project"], "HEAD")
-			assert "docs/guide.txt" in commit_diff
-			assert "src/app.py" in commit_diff
-			assert oid_short == repo_ctx["second_commit"][:7]
+    class TestPure:
+        def test_hash_set_multi_promotes_to_list(self) -> None:
+            data: dict[str, object] = {}
+            hash_set_multi(data, "k", "v1")
+            hash_set_multi(data, "k", "v2")
+            assert data["k"] == ["v1", "v2"]
+
+        @pytest.mark.parametrize(
+            ("value", "expected"),
+            [("true", True), ("0", False), (None, True)],
+        )
+        def test_config_to_bool(self, value: str | None, expected: bool) -> None:
+            assert config_to_bool(value) is expected
+
+        @pytest.mark.parametrize(
+            ("value", "expected"),
+            [("2k", 2048), ("3M", 3145728), ("123", "123"), (None, 0)],
+        )
+        def test_config_to_int(self, value: str | None, expected: int | str) -> None:
+            assert config_to_int(value) == expected
+
+    class TestRepoReadonly:
+        @pytest.fixture(scope="class")
+        def repo_ctx(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
+            project_root: Path = tmp_path_factory.mktemp("git-helpers")
+            repo_dir: Path = project_root / "sample"
+            repo_dir.mkdir()
+
+            _run_git(repo_dir, "init", "-b", "main")
+            _run_git(repo_dir, "config", "user.name", "Alice")
+            _run_git(repo_dir, "config", "user.email", "alice@example.com")
+            _run_git(repo_dir, "remote", "add", "origin", "https://example.com/repo.git")
+            _run_git(repo_dir, "remote", "set-url", "--push", "origin", "ssh://example.com/repo.git")
+            _run_git(repo_dir, "config", "gitweb.owner", "alice")
+            _run_git(repo_dir, "config", "--add", "gitweb.category", "infra")
+            _run_git(repo_dir, "config", "--add", "gitweb.category", "tools")
+
+            (repo_dir / "README.md").write_text("Root README\n", encoding="utf-8")
+            (repo_dir / "docs").mkdir()
+            (repo_dir / "docs" / "README").write_text("Docs README\n", encoding="utf-8")
+            (repo_dir / "docs" / "guide.txt").write_text("hello\n", encoding="utf-8")
+            (repo_dir / "src").mkdir()
+            (repo_dir / "src" / "app.py").write_text("print('v1')\n", encoding="utf-8")
+            _run_git(repo_dir, "add", ".")
+            _run_git(repo_dir, "commit", "-m", "initial commit")
+
+            first_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
+            (repo_dir / "docs" / "guide.txt").write_text("hello\nworld\n", encoding="utf-8")
+            (repo_dir / "src" / "app.py").write_text("print('v2')\n", encoding="utf-8")
+            _run_git(repo_dir, "add", ".")
+            _run_git(repo_dir, "commit", "-m", "update docs and app")
+            second_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
+
+            _run_git(
+                repo_dir,
+                "tag",
+                "-a",
+                "v1.0",
+                "-m",
+                "release v1.0",
+                second_commit,
+            )
+            _run_git(repo_dir, "tag", "lightweight", first_commit)
+            _run_git(repo_dir, "branch", "feature/readme", second_commit)
+
+            git_dir: Path = repo_dir / ".git"
+            (git_dir / "description").write_text("Repo description\n", encoding="utf-8")
+
+            ctx: dict[str, str] = {
+                "project": "sample/.git",
+                "first_commit": first_commit,
+                "second_commit": second_commit,
+            }
+
+            _config_cache.clear()
+            with patch.object(settings, "PROJECTROOT", str(project_root)):
+                yield ctx
+            _config_cache.clear()
+
+        def test_git_get_type(self, repo_ctx: dict[str, str]) -> None:
+            assert git_get_type(repo_ctx["project"], "HEAD") == "commit"
+            assert git_get_type(repo_ctx["project"], "HEAD^{tree}") == "tree"
+
+        def test_git_parse_and_get_project_config(self, repo_ctx: dict[str, str]) -> None:
+            cfg: dict[str, object] = git_parse_project_config(repo_ctx["project"], "gitweb")
+            assert cfg["gitweb.owner"] == "alice"
+            assert cfg["gitweb.category"] == ["infra", "tools"]
+            assert git_get_project_config(repo_ctx["project"], "owner") == "alice"
+            assert git_get_project_config(repo_ctx["project"], "category") == ["infra", "tools"]
+
+        def test_git_get_file_or_project_config_prefers_file(self, repo_ctx: dict[str, str]) -> None:
+            assert git_get_file_or_project_config(repo_ctx["project"], "description") == "Repo description"
+            assert git_get_project_description(repo_ctx["project"]) == "Repo description"
+
+        def test_references_heads_tags_and_remotes(self, repo_ctx: dict[str, str]) -> None:
+            heads: list[tuple[str, str, str]] = git_get_heads_list(repo_ctx["project"])
+            head_names: set[str] = {name for name, _ref, _oid in heads}
+            assert {"main", "feature/readme"} <= head_names
+
+            refs: list[tuple[str, str]] = git_get_references(repo_ctx["project"])
+            assert any(name == "refs/heads/main" for name, _oid in refs)
+
+            tags: list[tuple[str, str, str]] = git_get_tags_list(repo_ctx["project"])
+            tag_names: set[str] = {name for name, _ref, _oid in tags}
+            assert {"v1.0", "lightweight"} <= tag_names
+
+            remotes: list[dict[str, object]] = git_get_remotes_info(repo_ctx["project"])
+            assert remotes == [
+                {
+                    "name": "origin",
+                    "url": "https://example.com/repo.git",
+                    "push_url": "ssh://example.com/repo.git",
+                }
+            ]
+
+        def test_parse_commit_and_parse_tag(self, repo_ctx: dict[str, str]) -> None:
+            commit_data: dict[str, object] = parse_commit(repo_ctx["project"], repo_ctx["second_commit"])
+            assert commit_data["subject"] == "update docs and app"
+            assert commit_data["author"] == "Alice"
+            assert isinstance(commit_data["parent"], list)
+
+            repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / repo_ctx["project"]))
+            tag_oid: str = str(repo.references["refs/tags/v1.0"].target)
+            tag_data: dict[str, object] = parse_tag(repo_ctx["project"], tag_oid)
+            assert tag_data["type"] == "tag"
+            assert tag_data["message"] == "release v1.0\n"
+
+        def test_tree_blob_and_readme_resolution(self, repo_ctx: dict[str, str]) -> None:
+            tree_result = get_tree_at_ref_path(repo_ctx["project"], "HEAD", "docs")
+            assert tree_result is not None
+            tree, ref_oid = tree_result
+            assert ref_oid == repo_ctx["second_commit"]
+            assert tree["guide.txt"].type_str == "blob"
+
+            blob_result = get_blob_at_ref_path(repo_ctx["project"], "HEAD", "docs/guide.txt")
+            assert blob_result is not None
+            blob, _resolved_ref = blob_result
+            assert blob.data.decode("utf-8") == "hello\nworld\n"
+
+            readme_root = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "")
+            assert readme_root == ("README.md", "Root README\n")
+            readme_docs = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "docs")
+            assert readme_docs == ("README", "Docs README\n")
+
+        def test_commit_history_and_ranges(self, repo_ctx: dict[str, str]) -> None:
+            all_history: list[dict[str, object]] = get_commit_history(repo_ctx["project"], max_count=10)
+            assert [entry["oid"] for entry in all_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]
+
+            path_history: list[dict[str, object]] = get_commit_history(
+                repo_ctx["project"],
+                path="docs/guide.txt",
+                max_count=10,
+            )
+            assert [entry["oid"] for entry in path_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]
+
+            in_range: list[str] = get_commits_in_range(repo_ctx["project"], "HEAD", repo_ctx["first_commit"])
+            assert in_range == [repo_ctx["second_commit"]]
+
+        def test_blob_and_commit_unified_diff(self, repo_ctx: dict[str, str]) -> None:
+            blob_diff = get_blob_unified_diff(
+                repo_ctx["project"],
+                repo_ctx["first_commit"],
+                "src/app.py",
+                repo_ctx["second_commit"],
+                "src/app.py",
+            )
+            assert blob_diff is not None
+            assert "-print('v1')" in blob_diff
+            assert "+print('v2')" in blob_diff
+
+            commit_diff, oid_short = get_commit_unified_diff(repo_ctx["project"], "HEAD")
+            assert "docs/guide.txt" in commit_diff
+            assert "src/app.py" in commit_diff
+            assert oid_short == repo_ctx["second_commit"][:7]
 
 
 class TestResolveMrOpener:
-	def test_prefers_principal_email(self, tmp_path: Path) -> None:
-		repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
-		principal = PermissionPrincipal(username="alice", email="alice@idp.example")
-		opener, warning = resolve_mr_opener(repo, principal)
-		assert warning is None
-		assert opener.email == "alice@idp.example"
-		assert opener.name == "alice"
-
-	def test_falls_back_to_repo_git_user(self, tmp_path: Path) -> None:
-		repo_dir = tmp_path / "configured"
-		pygit2.init_repository(str(repo_dir), bare=True)
-		config_path = repo_dir / "config"
-		config_path.write_text(
-			f"{config_path.read_text(encoding='utf-8')}\n[user]\n\tname = Repo Bot\n\temail = bot@example.com\n",
-			encoding="utf-8",
-		)
-		repo = pygit2.Repository(str(repo_dir))
-		opener, warning = resolve_mr_opener(repo, None)
-		assert warning is None
-		assert opener.email == "bot@example.com"
-
-	def test_falls_back_to_user_credentials(self, tmp_path: Path) -> None:
-		repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
-		principal = PermissionPrincipal(username="bob", email=None)
-		opener, warning = resolve_mr_opener(repo, principal)
-		assert warning is None
-		assert opener.name == "bob"
-		assert opener.email == "bob@local"
-
-	def test_uses_default_with_warning(self, tmp_path: Path) -> None:
-		repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
-		opener, warning = resolve_mr_opener(repo, None)
-		assert warning is not None
-		assert opener.email == "noreply@pygitweb.local"
-
-	def test_signature_from_principal_email_requires_email(self) -> None:
-		assert signature_from_principal_email(PermissionPrincipal(username="x", email=None)) is None
-		assert signature_from_principal_email(PermissionPrincipal(username="x", email="  ")) is None
+    def test_prefers_principal_email(self, tmp_path: Path) -> None:
+        repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
+        principal = PermissionPrincipal(username="alice", email="alice@idp.example")
+        opener, warning = resolve_mr_opener(repo, principal)
+        assert warning is None
+        assert opener.email == "alice@idp.example"
+        assert opener.name == "alice"
+
+    def test_falls_back_to_repo_git_user(self, tmp_path: Path) -> None:
+        repo_dir = tmp_path / "configured"
+        pygit2.init_repository(str(repo_dir), bare=True)
+        config_path = repo_dir / "config"
+        config_path.write_text(
+            f"{config_path.read_text(encoding='utf-8')}\n[user]\n\tname = Repo Bot\n\temail = bot@example.com\n",
+            encoding="utf-8",
+        )
+        repo = pygit2.Repository(str(repo_dir))
+        opener, warning = resolve_mr_opener(repo, None)
+        assert warning is None
+        assert opener.email == "bot@example.com"
+
+    def test_falls_back_to_user_credentials(self, tmp_path: Path) -> None:
+        repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
+        principal = PermissionPrincipal(username="bob", email=None)
+        opener, warning = resolve_mr_opener(repo, principal)
+        assert warning is None
+        assert opener.name == "bob"
+        assert opener.email == "bob@local"
+
+    def test_uses_default_with_warning(self, tmp_path: Path) -> None:
+        repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
+        opener, warning = resolve_mr_opener(repo, None)
+        assert warning is not None
+        assert opener.email == "noreply@pygitweb.local"
+
+    def test_signature_from_principal_email_requires_email(self) -> None:
+        assert signature_from_principal_email(PermissionPrincipal(username="x", email=None)) is None
+        assert signature_from_principal_email(PermissionPrincipal(username="x", email="  ")) is None
diff --git a/pygitweb/src/pygitweb/gravatar.py b/pygitweb/src/pygitweb/gravatar.py
index efef065..513f75b 100644
--- a/pygitweb/src/pygitweb/gravatar.py
+++ b/pygitweb/src/pygitweb/gravatar.py
@@ -9,8 +9,8 @@ _GRAVATAR_BASE = "https://www.gravatar.com/avatar"
 
 
 def gravatar_url(email: str, *, size: int = 64) -> str:
-	"""Return a Gravatar image URL for ``email`` (``d=404`` when no profile exists)."""
-	normalized = email.strip().lower().encode("utf-8")
-	digest = hashlib.md5(normalized).hexdigest()  # noqa: S324
-	params = urlencode({"s": str(size), "d": "404"})
-	return f"{_GRAVATAR_BASE}/{digest}?{params}"
+    """Return a Gravatar image URL for ``email`` (``d=404`` when no profile exists)."""
+    normalized = email.strip().lower().encode("utf-8")
+    digest = hashlib.md5(normalized).hexdigest()  # noqa: S324
+    params = urlencode({"s": str(size), "d": "404"})
+    return f"{_GRAVATAR_BASE}/{digest}?{params}"
diff --git a/pygitweb/src/pygitweb/gravatar_test.py b/pygitweb/src/pygitweb/gravatar_test.py
index ef9f9f1..ab4f27d 100644
--- a/pygitweb/src/pygitweb/gravatar_test.py
+++ b/pygitweb/src/pygitweb/gravatar_test.py
@@ -2,9 +2,9 @@ from pygitweb.gravatar import gravatar_url
 
 
 def test_gravatar_url_known_hash() -> None:
-	url = gravatar_url("test@example.com", size=80)
-	assert url == "https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=80&d=404"
+    url = gravatar_url("test@example.com", size=80)
+    assert url == "https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=80&d=404"
 
 
 def test_gravatar_url_normalizes_email() -> None:
-	assert gravatar_url("  Test@Example.COM  ") == gravatar_url("test@example.com")
+    assert gravatar_url("  Test@Example.COM  ") == gravatar_url("test@example.com")
diff --git a/pygitweb/src/pygitweb/hooks_install.py b/pygitweb/src/pygitweb/hooks_install.py
index 2470448..ca73bed 100644
--- a/pygitweb/src/pygitweb/hooks_install.py
+++ b/pygitweb/src/pygitweb/hooks_install.py
@@ -29,295 +29,295 @@ SAMPLES_DIR: Path = Path(pygittools.__file__).parent.parent.parent / "hook_sampl
 _VERSION_RE: re.Pattern[str] = re.compile(r"""^__VERSION__\s*=\s*(['"])(.*?)\1""", re.MULTILINE)
 
 _SAMPLE_LABELS: dict[str, str] = {
-	"post-commit.notify": "Post-commit Notify",
-	"post-receive.notify": "Post-receive Notify",
-	"pre-commit.ruff": "Ruff Pre-commit",
-	"commit-msg.pattern": "Commit Message Pattern",
-	"pre-receive.protected-pattern": "Protected Branch Commit Messages",
-	"post-commit.push-remotes": "Post-commit Push Remotes",
+    "post-commit.notify": "Post-commit Notify",
+    "post-receive.notify": "Post-receive Notify",
+    "pre-commit.ruff": "Ruff Pre-commit",
+    "commit-msg.pattern": "Commit Message Pattern",
+    "pre-receive.protected-pattern": "Protected Branch Commit Messages",
+    "post-commit.push-remotes": "Post-commit Push Remotes",
 }
 
 
 HOOK_BUNDLES: dict[str, list[str]] = {
-	# "Update Hook" wires both server (post-receive on push) and client (post-commit on local
-	# commit) sides into the long-poll change queue, so subscribers wake regardless of where
-	# the commit was made.
-	"update": ["post-commit.notify", "post-receive.notify"],
+    # "Update Hook" wires both server (post-receive on push) and client (post-commit on local
+    # commit) sides into the long-poll change queue, so subscribers wake regardless of where
+    # the commit was made.
+    "update": ["post-commit.notify", "post-receive.notify"],
 }
 
 _BUNDLE_LABELS: dict[str, str] = {
-	"update": "Update Hook",
+    "update": "Update Hook",
 }
 
 
 class HookStatus(StrEnum):
-	INSTALLED = "installed"
-	NOT_INSTALLED = "not_installed"
-	DIFFERENT = "different"
+    INSTALLED = "installed"
+    NOT_INSTALLED = "not_installed"
+    DIFFERENT = "different"
 
 
 class HookSample(TypedDict):
-	name: str
-	target: str
-	source: str
-	label: str
+    name: str
+    target: str
+    source: str
+    label: str
 
 
 class HookBundle(TypedDict):
-	name: str
-	label: str
-	members: list[HookSample]
+    name: str
+    label: str
+    members: list[HookSample]
 
 
 class InstalledHookInfo(TypedDict):
-	name: str
-	label: str
-	target: str
-	status: str
-	version: str | None
-	content_hash: str | None
+    name: str
+    label: str
+    target: str
+    status: str
+    version: str | None
+    content_hash: str | None
 
 
 def content_hash(path: Path) -> str:
-	"""Return the SHA-256 hex digest of a hook file's contents."""
-	return hashlib.sha256(path.read_bytes()).hexdigest()
+    """Return the SHA-256 hex digest of a hook file's contents."""
+    return hashlib.sha256(path.read_bytes()).hexdigest()
 
 
 def sample_content_hash(sample_name: str) -> str:
-	sample: HookSample | None = get_sample(sample_name)
-	if sample is None:
-		raise KeyError(f"Unknown hook sample: {sample_name}")
-	return content_hash(Path(sample["source"]))
+    sample: HookSample | None = get_sample(sample_name)
+    if sample is None:
+        raise KeyError(f"Unknown hook sample: {sample_name}")
+    return content_hash(Path(sample["source"]))
 
 
 def read_hook_version(path: Path) -> str | None:
-	"""Return the __VERSION__ string from a hook file, if present."""
-	try:
-		text: str = path.read_text(encoding="utf-8")
-	except OSError:
-		return None
-	match: re.Match[str] | None = _VERSION_RE.search(text)
-	if match is None:
-		return None
-	return match.group(2)
+    """Return the __VERSION__ string from a hook file, if present."""
+    try:
+        text: str = path.read_text(encoding="utf-8")
+    except OSError:
+        return None
+    match: re.Match[str] | None = _VERSION_RE.search(text)
+    if match is None:
+        return None
+    return match.group(2)
 
 
 def get_installed_version(project: str, sample_name: str) -> str | None:
-	sample: HookSample | None = get_sample(sample_name)
-	if sample is None:
-		raise KeyError(f"Unknown hook sample: {sample_name}")
-	target: Path = _target_path(project, sample)
-	if target.is_file():
-		version: str | None = read_hook_version(target)
-		if version is not None:
-			return version
-	return read_hook_version(Path(sample["source"]))
+    sample: HookSample | None = get_sample(sample_name)
+    if sample is None:
+        raise KeyError(f"Unknown hook sample: {sample_name}")
+    target: Path = _target_path(project, sample)
+    if target.is_file():
+        version: str | None = read_hook_version(target)
+        if version is not None:
+            return version
+    return read_hook_version(Path(sample["source"]))
 
 
 def list_installed_hooks(project: str) -> list[InstalledHookInfo]:
-	"""Return pygittools hook samples that are installed or conflict with a custom hook."""
-	installed: list[InstalledHookInfo] = []
-	for sample in list_samples():
-		st: HookStatus = status(project, sample["name"])
-		if st == HookStatus.NOT_INSTALLED:
-			continue
-		if st == HookStatus.DIFFERENT:
-			owner: str | None = _installed_sample_for_target(project, sample["target"])
-			if owner is not None and owner != sample["name"]:
-				continue
-		target_path: Path = _target_path(project, sample)
-		version: str | None = None
-		if st == HookStatus.INSTALLED:
-			version = get_installed_version(project, sample["name"])
-		else:
-			version = read_hook_version(target_path)
-		installed_hash: str | None = None
-		if target_path.is_file():
-			with contextlib.suppress(OSError):
-				installed_hash = content_hash(target_path)
-		installed.append({
-			"name": sample["name"],
-			"label": sample["label"],
-			"target": sample["target"],
-			"status": st.value,
-			"version": version,
-			"content_hash": installed_hash,
-		})
-	return installed
+    """Return pygittools hook samples that are installed or conflict with a custom hook."""
+    installed: list[InstalledHookInfo] = []
+    for sample in list_samples():
+        st: HookStatus = status(project, sample["name"])
+        if st == HookStatus.NOT_INSTALLED:
+            continue
+        if st == HookStatus.DIFFERENT:
+            owner: str | None = _installed_sample_for_target(project, sample["target"])
+            if owner is not None and owner != sample["name"]:
+                continue
+        target_path: Path = _target_path(project, sample)
+        version: str | None = None
+        if st == HookStatus.INSTALLED:
+            version = get_installed_version(project, sample["name"])
+        else:
+            version = read_hook_version(target_path)
+        installed_hash: str | None = None
+        if target_path.is_file():
+            with contextlib.suppress(OSError):
+                installed_hash = content_hash(target_path)
+        installed.append({
+            "name": sample["name"],
+            "label": sample["label"],
+            "target": sample["target"],
+            "status": st.value,
+            "version": version,
+            "content_hash": installed_hash,
+        })
+    return installed
 
 
 def list_installable_hooks(project: str) -> list[HookSample]:
-	"""Return pygittools hook samples that can still be installed in this project."""
-	installable: list[HookSample] = []
-	for sample in list_samples():
-		if status(project, sample["name"]) == HookStatus.NOT_INSTALLED:
-			installable.append(sample)
-	return installable
+    """Return pygittools hook samples that can still be installed in this project."""
+    installable: list[HookSample] = []
+    for sample in list_samples():
+        if status(project, sample["name"]) == HookStatus.NOT_INSTALLED:
+            installable.append(sample)
+    return installable
 
 
 def list_samples() -> list[HookSample]:
-	if not SAMPLES_DIR.is_dir():
-		return []
-	samples: list[HookSample] = []
-	for entry in sorted(SAMPLES_DIR.iterdir()):
-		if not entry.is_file() or "." not in entry.name or entry.name.lower().endswith(".md"):
-			continue
-		target: str = entry.name.split(".", 1)[0]
-		samples.append({
-			"name": entry.name,
-			"target": target,
-			"source": str(entry),
-			"label": _SAMPLE_LABELS.get(entry.name, entry.name),
-		})
-	return samples
+    if not SAMPLES_DIR.is_dir():
+        return []
+    samples: list[HookSample] = []
+    for entry in sorted(SAMPLES_DIR.iterdir()):
+        if not entry.is_file() or "." not in entry.name or entry.name.lower().endswith(".md"):
+            continue
+        target: str = entry.name.split(".", 1)[0]
+        samples.append({
+            "name": entry.name,
+            "target": target,
+            "source": str(entry),
+            "label": _SAMPLE_LABELS.get(entry.name, entry.name),
+        })
+    return samples
 
 
 def get_sample(name: str) -> HookSample | None:
-	for sample in list_samples():
-		if sample["name"] == name:
-			return sample
-	return None
+    for sample in list_samples():
+        if sample["name"] == name:
+            return sample
+    return None
 
 
 def _hooks_dir(project: str) -> Path:
-	repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	return Path(repo.path) / "hooks"
+    repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    return Path(repo.path) / "hooks"
 
 
 def _target_path(project: str, sample: HookSample) -> Path:
-	return _hooks_dir(project) / sample["target"]
+    return _hooks_dir(project) / sample["target"]
 
 
 def _installed_sample_for_target(project: str, target: str) -> str | None:
-	"""Return the sample name whose content is installed at target, if any."""
-	target_path: Path = _hooks_dir(project) / target
-	if not target_path.is_file():
-		return None
-	try:
-		installed_hash: str = content_hash(target_path)
-	except OSError:
-		return None
-	for sample in list_samples():
-		if sample["target"] != target:
-			continue
-		try:
-			if content_hash(Path(sample["source"])) == installed_hash:
-				return sample["name"]
-		except OSError:
-			continue
-	return None
+    """Return the sample name whose content is installed at target, if any."""
+    target_path: Path = _hooks_dir(project) / target
+    if not target_path.is_file():
+        return None
+    try:
+        installed_hash: str = content_hash(target_path)
+    except OSError:
+        return None
+    for sample in list_samples():
+        if sample["target"] != target:
+            continue
+        try:
+            if content_hash(Path(sample["source"])) == installed_hash:
+                return sample["name"]
+        except OSError:
+            continue
+    return None
 
 
 def status(project: str, sample_name: str) -> HookStatus:
-	sample: HookSample | None = get_sample(sample_name)
-	if sample is None:
-		raise KeyError(f"Unknown hook sample: {sample_name}")
-	target: Path = _target_path(project, sample)
-	if not target.is_file():
-		return HookStatus.NOT_INSTALLED
-	try:
-		installed_bytes: bytes = target.read_bytes()
-		source_bytes: bytes = Path(sample["source"]).read_bytes()
-	except OSError:
-		return HookStatus.NOT_INSTALLED
-	return HookStatus.INSTALLED if installed_bytes == source_bytes else HookStatus.DIFFERENT
+    sample: HookSample | None = get_sample(sample_name)
+    if sample is None:
+        raise KeyError(f"Unknown hook sample: {sample_name}")
+    target: Path = _target_path(project, sample)
+    if not target.is_file():
+        return HookStatus.NOT_INSTALLED
+    try:
+        installed_bytes: bytes = target.read_bytes()
+        source_bytes: bytes = Path(sample["source"]).read_bytes()
+    except OSError:
+        return HookStatus.NOT_INSTALLED
+    return HookStatus.INSTALLED if installed_bytes == source_bytes else HookStatus.DIFFERENT
 
 
 def is_installed(project: str, sample_name: str) -> bool:
-	return status(project, sample_name) == HookStatus.INSTALLED
+    return status(project, sample_name) == HookStatus.INSTALLED
 
 
 def install(project: str, sample_name: str) -> HookStatus:
-	"""Copy the sample to the hooks dir. Refuses to overwrite a file with different content."""
-	sample: HookSample | None = get_sample(sample_name)
-	if sample is None:
-		raise KeyError(f"Unknown hook sample: {sample_name}")
-	current: HookStatus = status(project, sample_name)
-	if current == HookStatus.DIFFERENT:
-		return current
-	target: Path = _target_path(project, sample)
-	target.parent.mkdir(parents=True, exist_ok=True)
-	shutil.copyfile(sample["source"], target)
-	try:
-		mode: int = target.stat().st_mode
-		target.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
-	except OSError:
-		pass
-	return status(project, sample_name)
+    """Copy the sample to the hooks dir. Refuses to overwrite a file with different content."""
+    sample: HookSample | None = get_sample(sample_name)
+    if sample is None:
+        raise KeyError(f"Unknown hook sample: {sample_name}")
+    current: HookStatus = status(project, sample_name)
+    if current == HookStatus.DIFFERENT:
+        return current
+    target: Path = _target_path(project, sample)
+    target.parent.mkdir(parents=True, exist_ok=True)
+    shutil.copyfile(sample["source"], target)
+    try:
+        mode: int = target.stat().st_mode
+        target.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+    except OSError:
+        pass
+    return status(project, sample_name)
 
 
 def remove(project: str, sample_name: str) -> HookStatus:
-	"""Delete the hook file only if its content still matches the sample (preserves custom hooks)."""
-	sample: HookSample | None = get_sample(sample_name)
-	if sample is None:
-		raise KeyError(f"Unknown hook sample: {sample_name}")
-	current: HookStatus = status(project, sample_name)
-	if current == HookStatus.INSTALLED:
-		_target_path(project, sample).unlink(missing_ok=True)
-	return status(project, sample_name)
+    """Delete the hook file only if its content still matches the sample (preserves custom hooks)."""
+    sample: HookSample | None = get_sample(sample_name)
+    if sample is None:
+        raise KeyError(f"Unknown hook sample: {sample_name}")
+    current: HookStatus = status(project, sample_name)
+    if current == HookStatus.INSTALLED:
+        _target_path(project, sample).unlink(missing_ok=True)
+    return status(project, sample_name)
 
 
 def list_bundles() -> list[HookBundle]:
-	bundles: list[HookBundle] = []
-	for name, member_names in HOOK_BUNDLES.items():
-		members: list[HookSample] = []
-		for member_name in member_names:
-			sample: HookSample | None = get_sample(member_name)
-			if sample is not None:
-				members.append(sample)
-		bundles.append({
-			"name": name,
-			"label": _BUNDLE_LABELS.get(name, name),
-			"members": members,
-		})
-	return bundles
+    bundles: list[HookBundle] = []
+    for name, member_names in HOOK_BUNDLES.items():
+        members: list[HookSample] = []
+        for member_name in member_names:
+            sample: HookSample | None = get_sample(member_name)
+            if sample is not None:
+                members.append(sample)
+        bundles.append({
+            "name": name,
+            "label": _BUNDLE_LABELS.get(name, name),
+            "members": members,
+        })
+    return bundles
 
 
 def get_bundle(name: str) -> HookBundle | None:
-	for bundle in list_bundles():
-		if bundle["name"] == name:
-			return bundle
-	return None
+    for bundle in list_bundles():
+        if bundle["name"] == name:
+            return bundle
+    return None
 
 
 def bundle_status(project: str, bundle_name: str) -> HookStatus:
-	"""Aggregate status for a bundle: DIFFERENT if any member conflicts, INSTALLED if all are,
-	otherwise NOT_INSTALLED (treats partial installs as not-installed so re-clicking install
-	completes the bundle)."""
-	bundle: HookBundle | None = get_bundle(bundle_name)
-	if bundle is None:
-		raise KeyError(f"Unknown hook bundle: {bundle_name}")
-	if not bundle["members"]:
-		return HookStatus.NOT_INSTALLED
-	statuses: list[HookStatus] = [status(project, member["name"]) for member in bundle["members"]]
-	if any(s == HookStatus.DIFFERENT for s in statuses):
-		return HookStatus.DIFFERENT
-	if all(s == HookStatus.INSTALLED for s in statuses):
-		return HookStatus.INSTALLED
-	return HookStatus.NOT_INSTALLED
+    """Aggregate status for a bundle: DIFFERENT if any member conflicts, INSTALLED if all are,
+    otherwise NOT_INSTALLED (treats partial installs as not-installed so re-clicking install
+    completes the bundle)."""
+    bundle: HookBundle | None = get_bundle(bundle_name)
+    if bundle is None:
+        raise KeyError(f"Unknown hook bundle: {bundle_name}")
+    if not bundle["members"]:
+        return HookStatus.NOT_INSTALLED
+    statuses: list[HookStatus] = [status(project, member["name"]) for member in bundle["members"]]
+    if any(s == HookStatus.DIFFERENT for s in statuses):
+        return HookStatus.DIFFERENT
+    if all(s == HookStatus.INSTALLED for s in statuses):
+        return HookStatus.INSTALLED
+    return HookStatus.NOT_INSTALLED
 
 
 def install_bundle(project: str, bundle_name: str) -> HookStatus:
-	"""Install every member of the bundle. Refuses (no-op) if any target holds a different file."""
-	bundle: HookBundle | None = get_bundle(bundle_name)
-	if bundle is None:
-		raise KeyError(f"Unknown hook bundle: {bundle_name}")
-	for member in bundle["members"]:
-		if status(project, member["name"]) == HookStatus.DIFFERENT:
-			return HookStatus.DIFFERENT
-	for member in bundle["members"]:
-		install(project, member["name"])
-	return bundle_status(project, bundle_name)
+    """Install every member of the bundle. Refuses (no-op) if any target holds a different file."""
+    bundle: HookBundle | None = get_bundle(bundle_name)
+    if bundle is None:
+        raise KeyError(f"Unknown hook bundle: {bundle_name}")
+    for member in bundle["members"]:
+        if status(project, member["name"]) == HookStatus.DIFFERENT:
+            return HookStatus.DIFFERENT
+    for member in bundle["members"]:
+        install(project, member["name"])
+    return bundle_status(project, bundle_name)
 
 
 def remove_bundle(project: str, bundle_name: str) -> HookStatus:
-	"""Remove every member of the bundle. Refuses (no-op) if any target holds a different file."""
-	bundle: HookBundle | None = get_bundle(bundle_name)
-	if bundle is None:
-		raise KeyError(f"Unknown hook bundle: {bundle_name}")
-	for member in bundle["members"]:
-		if status(project, member["name"]) == HookStatus.DIFFERENT:
-			return HookStatus.DIFFERENT
-	for member in bundle["members"]:
-		remove(project, member["name"])
-	return bundle_status(project, bundle_name)
+    """Remove every member of the bundle. Refuses (no-op) if any target holds a different file."""
+    bundle: HookBundle | None = get_bundle(bundle_name)
+    if bundle is None:
+        raise KeyError(f"Unknown hook bundle: {bundle_name}")
+    for member in bundle["members"]:
+        if status(project, member["name"]) == HookStatus.DIFFERENT:
+            return HookStatus.DIFFERENT
+    for member in bundle["members"]:
+        remove(project, member["name"])
+    return bundle_status(project, bundle_name)
diff --git a/pygitweb/src/pygitweb/hooks_install_test.py b/pygitweb/src/pygitweb/hooks_install_test.py
index 238dd8f..598f830 100644
--- a/pygitweb/src/pygitweb/hooks_install_test.py
+++ b/pygitweb/src/pygitweb/hooks_install_test.py
@@ -10,374 +10,374 @@ from fastapi.testclient import TestClient
 
 from pygitweb.config import settings
 from pygitweb.hooks_install import (
-	HOOK_BUNDLES,
-	HookStatus,
-	bundle_status,
-	content_hash,
-	get_bundle,
-	get_installed_version,
-	get_sample,
-	install,
-	install_bundle,
-	is_installed,
-	list_bundles,
-	list_installable_hooks,
-	list_installed_hooks,
-	list_samples,
-	read_hook_version,
-	remove,
-	remove_bundle,
-	sample_content_hash,
-	status,
+    HOOK_BUNDLES,
+    HookStatus,
+    bundle_status,
+    content_hash,
+    get_bundle,
+    get_installed_version,
+    get_sample,
+    install,
+    install_bundle,
+    is_installed,
+    list_bundles,
+    list_installable_hooks,
+    list_installed_hooks,
+    list_samples,
+    read_hook_version,
+    remove,
+    remove_bundle,
+    sample_content_hash,
+    status,
 )
 from pygitweb.main import app
 
 
 def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
-	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
-	index = repo.index
-	index.add("README.md")
-	index.write()
-	tree = index.write_tree()
-	sig = pygit2.Signature("tester", "tester@example.com")
-	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+    (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+    index = repo.index
+    index.add("README.md")
+    index.write()
+    tree = index.write_tree()
+    sig = pygit2.Signature("tester", "tester@example.com")
+    repo.create_commit("HEAD", sig, sig, "initial", tree, [])
 
 
 @pytest.fixture
 def project_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
-	root: Path = tmp_path / "root"
-	root.mkdir()
-	repo_dir: Path = root / "demo"
-	repo_dir.mkdir()
-	_create_initial_commit(pygit2.init_repository(str(repo_dir), bare=False), repo_dir)
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(root)),
-		patch.object(settings, "PROJECT_MAXDEPTH", 3),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "LIST_ALL", True),
-		patch.object(settings, "AUTH", False),
-		patch.object(settings, "MAXLOAD", None),
-	):
-		yield {"root": str(root), "project": "demo", "hooks_dir": str(repo_dir / ".git" / "hooks")}
+    root: Path = tmp_path / "root"
+    root.mkdir()
+    repo_dir: Path = root / "demo"
+    repo_dir.mkdir()
+    _create_initial_commit(pygit2.init_repository(str(repo_dir), bare=False), repo_dir)
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(root)),
+        patch.object(settings, "PROJECT_MAXDEPTH", 3),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "LIST_ALL", True),
+        patch.object(settings, "AUTH", False),
+        patch.object(settings, "MAXLOAD", None),
+    ):
+        yield {"root": str(root), "project": "demo", "hooks_dir": str(repo_dir / ".git" / "hooks")}
 
 
 class TestRegistry:
-	def test_list_samples_includes_notify_pair(self) -> None:
-		names: set[str] = {s["name"] for s in list_samples()}
-		assert {"post-commit.notify", "post-receive.notify"} <= names
+    def test_list_samples_includes_notify_pair(self) -> None:
+        names: set[str] = {s["name"] for s in list_samples()}
+        assert {"post-commit.notify", "post-receive.notify"} <= names
 
-	def test_get_sample_target_is_filename_prefix(self) -> None:
-		sample = get_sample("post-receive.notify")
-		assert sample is not None
-		assert sample["target"] == "post-receive"
-		assert Path(sample["source"]).is_file()
+    def test_get_sample_target_is_filename_prefix(self) -> None:
+        sample = get_sample("post-receive.notify")
+        assert sample is not None
+        assert sample["target"] == "post-receive"
+        assert Path(sample["source"]).is_file()
 
-	def test_get_sample_unknown_returns_none(self) -> None:
-		assert get_sample("does-not-exist.foo") is None
+    def test_get_sample_unknown_returns_none(self) -> None:
+        assert get_sample("does-not-exist.foo") is None
 
-	def test_update_bundle_includes_both_notify_samples(self) -> None:
-		bundle = get_bundle("update")
-		assert bundle is not None
-		assert bundle["label"] == "Update Hook"
-		assert {m["name"] for m in bundle["members"]} == {"post-commit.notify", "post-receive.notify"}
+    def test_update_bundle_includes_both_notify_samples(self) -> None:
+        bundle = get_bundle("update")
+        assert bundle is not None
+        assert bundle["label"] == "Update Hook"
+        assert {m["name"] for m in bundle["members"]} == {"post-commit.notify", "post-receive.notify"}
 
-	def test_list_bundles_returns_update(self) -> None:
-		assert any(b["name"] == "update" for b in list_bundles())
-		assert "update" in HOOK_BUNDLES
+    def test_list_bundles_returns_update(self) -> None:
+        assert any(b["name"] == "update" for b in list_bundles())
+        assert "update" in HOOK_BUNDLES
 
 
 class TestStatusInstallRemove:
-	def test_status_not_installed_when_no_file(self, project_env: dict[str, str]) -> None:
-		assert status(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
-		assert is_installed(project_env["project"], "post-receive.notify") is False
-
-	def test_install_then_status_installed(self, project_env: dict[str, str]) -> None:
-		assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
-		assert is_installed(project_env["project"], "post-receive.notify") is True
-		target: Path = Path(project_env["hooks_dir"]) / "post-receive"
-		assert target.is_file()
-
-	def test_install_is_idempotent(self, project_env: dict[str, str]) -> None:
-		install(project_env["project"], "post-receive.notify")
-		assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
-
-	def test_install_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
-		hooks_dir: Path = Path(project_env["hooks_dir"])
-		hooks_dir.mkdir(parents=True, exist_ok=True)
-		(hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
-		assert install(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
-		assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"
-
-	def test_remove_uninstalls_only_when_content_matches(self, project_env: dict[str, str]) -> None:
-		install(project_env["project"], "post-receive.notify")
-		assert remove(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
-		assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()
-
-	def test_remove_preserves_custom_hook(self, project_env: dict[str, str]) -> None:
-		hooks_dir: Path = Path(project_env["hooks_dir"])
-		hooks_dir.mkdir(parents=True, exist_ok=True)
-		custom: str = "#!/bin/sh\necho custom\n"
-		(hooks_dir / "post-receive").write_text(custom, encoding="utf-8")
-		assert remove(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
-		assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == custom
-
-	def test_unknown_sample_raises(self, project_env: dict[str, str]) -> None:
-		with pytest.raises(KeyError):
-			status(project_env["project"], "no-such.sample")
-		with pytest.raises(KeyError):
-			install(project_env["project"], "no-such.sample")
-		with pytest.raises(KeyError):
-			remove(project_env["project"], "no-such.sample")
+    def test_status_not_installed_when_no_file(self, project_env: dict[str, str]) -> None:
+        assert status(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
+        assert is_installed(project_env["project"], "post-receive.notify") is False
+
+    def test_install_then_status_installed(self, project_env: dict[str, str]) -> None:
+        assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
+        assert is_installed(project_env["project"], "post-receive.notify") is True
+        target: Path = Path(project_env["hooks_dir"]) / "post-receive"
+        assert target.is_file()
+
+    def test_install_is_idempotent(self, project_env: dict[str, str]) -> None:
+        install(project_env["project"], "post-receive.notify")
+        assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
+
+    def test_install_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
+        hooks_dir: Path = Path(project_env["hooks_dir"])
+        hooks_dir.mkdir(parents=True, exist_ok=True)
+        (hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+        assert install(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
+        assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"
+
+    def test_remove_uninstalls_only_when_content_matches(self, project_env: dict[str, str]) -> None:
+        install(project_env["project"], "post-receive.notify")
+        assert remove(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
+        assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()
+
+    def test_remove_preserves_custom_hook(self, project_env: dict[str, str]) -> None:
+        hooks_dir: Path = Path(project_env["hooks_dir"])
+        hooks_dir.mkdir(parents=True, exist_ok=True)
+        custom: str = "#!/bin/sh\necho custom\n"
+        (hooks_dir / "post-receive").write_text(custom, encoding="utf-8")
+        assert remove(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
+        assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == custom
+
+    def test_unknown_sample_raises(self, project_env: dict[str, str]) -> None:
+        with pytest.raises(KeyError):
+            status(project_env["project"], "no-such.sample")
+        with pytest.raises(KeyError):
+            install(project_env["project"], "no-such.sample")
+        with pytest.raises(KeyError):
+            remove(project_env["project"], "no-such.sample")
 
 
 class TestBundle:
-	def test_bundle_status_starts_not_installed(self, project_env: dict[str, str]) -> None:
-		assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED
-
-	def test_install_bundle_installs_all_members(self, project_env: dict[str, str]) -> None:
-		assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
-		assert is_installed(project_env["project"], "post-commit.notify")
-		assert is_installed(project_env["project"], "post-receive.notify")
-
-	def test_partial_install_reports_not_installed(self, project_env: dict[str, str]) -> None:
-		install(project_env["project"], "post-commit.notify")
-		assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED
-
-	def test_install_bundle_completes_partial(self, project_env: dict[str, str]) -> None:
-		install(project_env["project"], "post-commit.notify")
-		assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
-		assert is_installed(project_env["project"], "post-receive.notify")
-
-	def test_bundle_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
-		hooks_dir: Path = Path(project_env["hooks_dir"])
-		hooks_dir.mkdir(parents=True, exist_ok=True)
-		(hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
-		assert bundle_status(project_env["project"], "update") == HookStatus.DIFFERENT
-		assert install_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
-		assert remove_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
-		assert (hooks_dir / "post-commit").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"
-
-	def test_remove_bundle_uninstalls_only_managed_members(self, project_env: dict[str, str]) -> None:
-		install_bundle(project_env["project"], "update")
-		assert remove_bundle(project_env["project"], "update") == HookStatus.NOT_INSTALLED
-		assert not (Path(project_env["hooks_dir"]) / "post-commit").exists()
-		assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()
-
-	def test_unknown_bundle_raises(self, project_env: dict[str, str]) -> None:
-		with pytest.raises(KeyError):
-			bundle_status(project_env["project"], "no-such-bundle")
-		with pytest.raises(KeyError):
-			install_bundle(project_env["project"], "no-such-bundle")
-		with pytest.raises(KeyError):
-			remove_bundle(project_env["project"], "no-such-bundle")
+    def test_bundle_status_starts_not_installed(self, project_env: dict[str, str]) -> None:
+        assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED
+
+    def test_install_bundle_installs_all_members(self, project_env: dict[str, str]) -> None:
+        assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
+        assert is_installed(project_env["project"], "post-commit.notify")
+        assert is_installed(project_env["project"], "post-receive.notify")
+
+    def test_partial_install_reports_not_installed(self, project_env: dict[str, str]) -> None:
+        install(project_env["project"], "post-commit.notify")
+        assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED
+
+    def test_install_bundle_completes_partial(self, project_env: dict[str, str]) -> None:
+        install(project_env["project"], "post-commit.notify")
+        assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
+        assert is_installed(project_env["project"], "post-receive.notify")
+
+    def test_bundle_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
+        hooks_dir: Path = Path(project_env["hooks_dir"])
+        hooks_dir.mkdir(parents=True, exist_ok=True)
+        (hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+        assert bundle_status(project_env["project"], "update") == HookStatus.DIFFERENT
+        assert install_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
+        assert remove_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
+        assert (hooks_dir / "post-commit").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"
+
+    def test_remove_bundle_uninstalls_only_managed_members(self, project_env: dict[str, str]) -> None:
+        install_bundle(project_env["project"], "update")
+        assert remove_bundle(project_env["project"], "update") == HookStatus.NOT_INSTALLED
+        assert not (Path(project_env["hooks_dir"]) / "post-commit").exists()
+        assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()
+
+    def test_unknown_bundle_raises(self, project_env: dict[str, str]) -> None:
+        with pytest.raises(KeyError):
+            bundle_status(project_env["project"], "no-such-bundle")
+        with pytest.raises(KeyError):
+            install_bundle(project_env["project"], "no-such-bundle")
+        with pytest.raises(KeyError):
+            remove_bundle(project_env["project"], "no-such-bundle")
 
 
 def _client() -> TestClient:
-	return TestClient(app)
+    return TestClient(app)
 
 
 class TestHookRoute:
-	def test_sample_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
-		project: str = project_env["project"]
-		with _client() as client:
-			check_resp = client.post(
-				f"/project/{project}/hook",
-				params={"name": "post-receive.notify", "op": "check"},
-			)
-			assert check_resp.status_code == 200
-			body = check_resp.json()
-			assert body["installed"] is False
-			assert body["kind"] == "sample"
-
-			add_resp = client.post(
-				f"/project/{project}/hook",
-				params={"name": "post-receive.notify", "op": "add"},
-			)
-			assert add_resp.status_code == 200
-			body = add_resp.json()
-			assert body["installed"] is True
-			assert body["target"] == "post-receive"
-			assert body["label"] == "Post-receive Notify"
-
-			remove_resp = client.post(
-				f"/project/{project}/hook",
-				params={"name": "post-receive.notify", "op": "remove"},
-			)
-			assert remove_resp.status_code == 200
-			assert remove_resp.json()["installed"] is False
-
-	def test_bundle_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
-		project: str = project_env["project"]
-		hooks_dir: Path = Path(project_env["hooks_dir"])
-		with _client() as client:
-			check_resp = client.post(
-				f"/project/{project}/hook",
-				params={"name": "update", "op": "check"},
-			)
-			assert check_resp.status_code == 200
-			body = check_resp.json()
-			assert body["installed"] is False
-			assert body["kind"] == "bundle"
-			assert body["label"] == "Update Hook"
-			assert set(body["members"]) == {"post-commit.notify", "post-receive.notify"}
-
-			add_resp = client.post(
-				f"/project/{project}/hook",
-				params={"name": "update", "op": "add"},
-			)
-			assert add_resp.status_code == 200
-			assert add_resp.json()["installed"] is True
-			assert (hooks_dir / "post-commit").is_file()
-			assert (hooks_dir / "post-receive").is_file()
-
-			remove_resp = client.post(
-				f"/project/{project}/hook",
-				params={"name": "update", "op": "remove"},
-			)
-			assert remove_resp.status_code == 200
-			assert remove_resp.json()["installed"] is False
-			assert not (hooks_dir / "post-commit").exists()
-			assert not (hooks_dir / "post-receive").exists()
-
-	def test_bundle_add_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
-		hooks_dir: Path = Path(project_env["hooks_dir"])
-		hooks_dir.mkdir(parents=True, exist_ok=True)
-		(hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
-		with _client() as client:
-			resp = client.post(
-				f"/project/{project_env['project']}/hook",
-				params={"name": "update", "op": "add"},
-			)
-		assert resp.status_code == 409
-
-	def test_invalid_op_returns_422(self, project_env: dict[str, str]) -> None:
-		with _client() as client:
-			resp = client.post(
-				f"/project/{project_env['project']}/hook",
-				params={"name": "post-receive.notify", "op": "noop"},
-			)
-		assert resp.status_code == 422
-
-	def test_unknown_name_returns_404(self, project_env: dict[str, str]) -> None:
-		with _client() as client:
-			resp = client.post(
-				f"/project/{project_env['project']}/hook",
-				params={"name": "no-such.thing", "op": "check"},
-			)
-		assert resp.status_code == 404
-
-	def test_add_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
-		hooks_dir: Path = Path(project_env["hooks_dir"])
-		hooks_dir.mkdir(parents=True, exist_ok=True)
-		(hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
-		with _client() as client:
-			resp = client.post(
-				f"/project/{project_env['project']}/hook",
-				params={"name": "post-receive.notify", "op": "add"},
-			)
-		assert resp.status_code == 409
-
-	def test_hooks_list_endpoint_returns_status_for_samples_and_bundles(self, project_env: dict[str, str]) -> None:
-		with _client() as client:
-			resp = client.get(f"/project/{project_env['project']}/hooks")
-		assert resp.status_code == 200
-		body = resp.json()
-		assert body["project"] == project_env["project"]
-		sample_names: set[str] = {entry["name"] for entry in body["hooks"]}
-		assert {"post-commit.notify", "post-receive.notify"} <= sample_names
-		bundle_names: set[str] = {entry["name"] for entry in body["bundles"]}
-		assert "update" in bundle_names
-
-	def test_summary_renders_install_dropdown_when_no_hooks_installed(self, project_env: dict[str, str]) -> None:
-		with _client() as client:
-			resp = client.get(f"/project/{project_env['project']}")
-		assert resp.status_code == 200
-		assert 'id="hook-install-select"' in resp.text
-		assert "hook-install-btn" in resp.text
-		assert "Post-receive Notify" in resp.text
-		assert "No hooks installed." in resp.text
-
-	def test_summary_renders_uninstall_buttons_for_installed_hooks(self, project_env: dict[str, str]) -> None:
-		install(project_env["project"], "post-receive.notify")
-		install(project_env["project"], "pre-commit.ruff")
-		with _client() as client:
-			resp = client.get(f"/project/{project_env['project']}")
-		assert resp.status_code == 200
-		assert resp.text.count("Uninstall") == 2
-		assert 'data-sample="post-receive.notify"' in resp.text
-		assert 'data-sample="pre-commit.ruff"' in resp.text
-		assert ">v1<" in resp.text or "v1" in resp.text
-
-	def test_summary_hides_installed_hooks_from_install_dropdown(self, project_env: dict[str, str]) -> None:
-		install(project_env["project"], "post-commit.notify")
-		with _client() as client:
-			resp = client.get(f"/project/{project_env['project']}")
-		assert resp.status_code == 200
-		assert 'data-sample="post-commit.notify"' in resp.text
-		assert 'value="post-commit.notify"' not in resp.text
-
-	def test_summary_shows_custom_hook_conflict(self, project_env: dict[str, str]) -> None:
-		hooks_dir: Path = Path(project_env["hooks_dir"])
-		hooks_dir.mkdir(parents=True, exist_ok=True)
-		(hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
-		with _client() as client:
-			resp = client.get(f"/project/{project_env['project']}")
-		assert resp.status_code == 200
-		assert "custom hook installed; refusing to manage" in resp.text
+    def test_sample_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
+        project: str = project_env["project"]
+        with _client() as client:
+            check_resp = client.post(
+                f"/project/{project}/hook",
+                params={"name": "post-receive.notify", "op": "check"},
+            )
+            assert check_resp.status_code == 200
+            body = check_resp.json()
+            assert body["installed"] is False
+            assert body["kind"] == "sample"
+
+            add_resp = client.post(
+                f"/project/{project}/hook",
+                params={"name": "post-receive.notify", "op": "add"},
+            )
+            assert add_resp.status_code == 200
+            body = add_resp.json()
+            assert body["installed"] is True
+            assert body["target"] == "post-receive"
+            assert body["label"] == "Post-receive Notify"
+
+            remove_resp = client.post(
+                f"/project/{project}/hook",
+                params={"name": "post-receive.notify", "op": "remove"},
+            )
+            assert remove_resp.status_code == 200
+            assert remove_resp.json()["installed"] is False
+
+    def test_bundle_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
+        project: str = project_env["project"]
+        hooks_dir: Path = Path(project_env["hooks_dir"])
+        with _client() as client:
+            check_resp = client.post(
+                f"/project/{project}/hook",
+                params={"name": "update", "op": "check"},
+            )
+            assert check_resp.status_code == 200
+            body = check_resp.json()
+            assert body["installed"] is False
+            assert body["kind"] == "bundle"
+            assert body["label"] == "Update Hook"
+            assert set(body["members"]) == {"post-commit.notify", "post-receive.notify"}
+
+            add_resp = client.post(
+                f"/project/{project}/hook",
+                params={"name": "update", "op": "add"},
+            )
+            assert add_resp.status_code == 200
+            assert add_resp.json()["installed"] is True
+            assert (hooks_dir / "post-commit").is_file()
+            assert (hooks_dir / "post-receive").is_file()
+
+            remove_resp = client.post(
+                f"/project/{project}/hook",
+                params={"name": "update", "op": "remove"},
+            )
+            assert remove_resp.status_code == 200
+            assert remove_resp.json()["installed"] is False
+            assert not (hooks_dir / "post-commit").exists()
+            assert not (hooks_dir / "post-receive").exists()
+
+    def test_bundle_add_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
+        hooks_dir: Path = Path(project_env["hooks_dir"])
+        hooks_dir.mkdir(parents=True, exist_ok=True)
+        (hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+        with _client() as client:
+            resp = client.post(
+                f"/project/{project_env['project']}/hook",
+                params={"name": "update", "op": "add"},
+            )
+        assert resp.status_code == 409
+
+    def test_invalid_op_returns_422(self, project_env: dict[str, str]) -> None:
+        with _client() as client:
+            resp = client.post(
+                f"/project/{project_env['project']}/hook",
+                params={"name": "post-receive.notify", "op": "noop"},
+            )
+        assert resp.status_code == 422
+
+    def test_unknown_name_returns_404(self, project_env: dict[str, str]) -> None:
+        with _client() as client:
+            resp = client.post(
+                f"/project/{project_env['project']}/hook",
+                params={"name": "no-such.thing", "op": "check"},
+            )
+        assert resp.status_code == 404
+
+    def test_add_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
+        hooks_dir: Path = Path(project_env["hooks_dir"])
+        hooks_dir.mkdir(parents=True, exist_ok=True)
+        (hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+        with _client() as client:
+            resp = client.post(
+                f"/project/{project_env['project']}/hook",
+                params={"name": "post-receive.notify", "op": "add"},
+            )
+        assert resp.status_code == 409
+
+    def test_hooks_list_endpoint_returns_status_for_samples_and_bundles(self, project_env: dict[str, str]) -> None:
+        with _client() as client:
+            resp = client.get(f"/project/{project_env['project']}/hooks")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["project"] == project_env["project"]
+        sample_names: set[str] = {entry["name"] for entry in body["hooks"]}
+        assert {"post-commit.notify", "post-receive.notify"} <= sample_names
+        bundle_names: set[str] = {entry["name"] for entry in body["bundles"]}
+        assert "update" in bundle_names
+
+    def test_summary_renders_install_dropdown_when_no_hooks_installed(self, project_env: dict[str, str]) -> None:
+        with _client() as client:
+            resp = client.get(f"/project/{project_env['project']}")
+        assert resp.status_code == 200
+        assert 'id="hook-install-select"' in resp.text
+        assert "hook-install-btn" in resp.text
+        assert "Post-receive Notify" in resp.text
+        assert "No hooks installed." in resp.text
+
+    def test_summary_renders_uninstall_buttons_for_installed_hooks(self, project_env: dict[str, str]) -> None:
+        install(project_env["project"], "post-receive.notify")
+        install(project_env["project"], "pre-commit.ruff")
+        with _client() as client:
+            resp = client.get(f"/project/{project_env['project']}")
+        assert resp.status_code == 200
+        assert resp.text.count("Uninstall") == 2
+        assert 'data-sample="post-receive.notify"' in resp.text
+        assert 'data-sample="pre-commit.ruff"' in resp.text
+        assert ">v1<" in resp.text or "v1" in resp.text
+
+    def test_summary_hides_installed_hooks_from_install_dropdown(self, project_env: dict[str, str]) -> None:
+        install(project_env["project"], "post-commit.notify")
+        with _client() as client:
+            resp = client.get(f"/project/{project_env['project']}")
+        assert resp.status_code == 200
+        assert 'data-sample="post-commit.notify"' in resp.text
+        assert 'value="post-commit.notify"' not in resp.text
+
+    def test_summary_shows_custom_hook_conflict(self, project_env: dict[str, str]) -> None:
+        hooks_dir: Path = Path(project_env["hooks_dir"])
+        hooks_dir.mkdir(parents=True, exist_ok=True)
+        (hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+        with _client() as client:
+            resp = client.get(f"/project/{project_env['project']}")
+        assert resp.status_code == 200
+        assert "custom hook installed; refusing to manage" in resp.text
 
 
 class TestHookVersion:
-	def test_read_hook_version_from_sample(self) -> None:
-		sample = get_sample("post-receive.notify")
-		assert sample is not None
-		assert read_hook_version(Path(sample["source"])) == "1"
-
-	def test_get_installed_version_reads_installed_file(self, project_env: dict[str, str]) -> None:
-		install(project_env["project"], "post-receive.notify")
-		assert get_installed_version(project_env["project"], "post-receive.notify") == "1"
-
-	def test_list_installed_hooks_includes_only_present_hooks(self, project_env: dict[str, str]) -> None:
-		notify_sample = get_sample("post-commit.notify")
-		push_sample = get_sample("post-commit.push-remotes")
-		assert notify_sample is not None
-		assert push_sample is not None
-		notify_hash = sample_content_hash("post-commit.notify")
-		push_hash = sample_content_hash("post-commit.push-remotes")
-		assert notify_hash != push_hash
-		assert content_hash(Path(notify_sample["source"])) == notify_hash
-		assert content_hash(Path(push_sample["source"])) == push_hash
-
-		install(project_env["project"], "post-commit.notify")
-		installed = list_installed_hooks(project_env["project"])
-		assert {entry["name"] for entry in installed} == {"post-commit.notify"}
-		assert installed[0]["version"] == "1"
-		assert installed[0]["content_hash"] == notify_hash
-
-		remove(project_env["project"], "post-commit.notify")
-		install(project_env["project"], "post-commit.push-remotes")
-		installed = list_installed_hooks(project_env["project"])
-		assert {entry["name"] for entry in installed} == {"post-commit.push-remotes"}
-		assert installed[0]["version"] == "1"
-		assert installed[0]["content_hash"] == push_hash
-
-	def test_list_installable_hooks_excludes_installed(self, project_env: dict[str, str]) -> None:
-		install(project_env["project"], "post-commit.notify")
-		installable = list_installable_hooks(project_env["project"])
-		assert all(entry["name"] != "post-commit.notify" for entry in installable)
+    def test_read_hook_version_from_sample(self) -> None:
+        sample = get_sample("post-receive.notify")
+        assert sample is not None
+        assert read_hook_version(Path(sample["source"])) == "1"
+
+    def test_get_installed_version_reads_installed_file(self, project_env: dict[str, str]) -> None:
+        install(project_env["project"], "post-receive.notify")
+        assert get_installed_version(project_env["project"], "post-receive.notify") == "1"
+
+    def test_list_installed_hooks_includes_only_present_hooks(self, project_env: dict[str, str]) -> None:
+        notify_sample = get_sample("post-commit.notify")
+        push_sample = get_sample("post-commit.push-remotes")
+        assert notify_sample is not None
+        assert push_sample is not None
+        notify_hash = sample_content_hash("post-commit.notify")
+        push_hash = sample_content_hash("post-commit.push-remotes")
+        assert notify_hash != push_hash
+        assert content_hash(Path(notify_sample["source"])) == notify_hash
+        assert content_hash(Path(push_sample["source"])) == push_hash
+
+        install(project_env["project"], "post-commit.notify")
+        installed = list_installed_hooks(project_env["project"])
+        assert {entry["name"] for entry in installed} == {"post-commit.notify"}
+        assert installed[0]["version"] == "1"
+        assert installed[0]["content_hash"] == notify_hash
+
+        remove(project_env["project"], "post-commit.notify")
+        install(project_env["project"], "post-commit.push-remotes")
+        installed = list_installed_hooks(project_env["project"])
+        assert {entry["name"] for entry in installed} == {"post-commit.push-remotes"}
+        assert installed[0]["version"] == "1"
+        assert installed[0]["content_hash"] == push_hash
+
+    def test_list_installable_hooks_excludes_installed(self, project_env: dict[str, str]) -> None:
+        install(project_env["project"], "post-commit.notify")
+        installable = list_installable_hooks(project_env["project"])
+        assert all(entry["name"] != "post-commit.notify" for entry in installable)
 
 
 class TestSummaryRefSwitcherStaticScript:
-	def test_static_script_subscribes_via_updates_endpoint(self) -> None:
-		with _client() as client:
-			resp = client.get("/static/summary-ref-switcher.js")
-		assert resp.status_code == 200
-		body: str = resp.text
-		assert "a=heads&updates=true" in body
-		assert "subscribeToUpdates" in body
-		assert "AbortController" in body
-		assert "loadOptions" in body
-		assert "loadState" in body
-		assert "summary-tree-container" in body
-		assert "tree_html" in body
+    def test_static_script_subscribes_via_updates_endpoint(self) -> None:
+        with _client() as client:
+            resp = client.get("/static/summary-ref-switcher.js")
+        assert resp.status_code == 200
+        body: str = resp.text
+        assert "a=heads&updates=true" in body
+        assert "subscribeToUpdates" in body
+        assert "AbortController" in body
+        assert "loadOptions" in body
+        assert "loadState" in body
+        assert "summary-tree-container" in body
+        assert "tree_html" in body
diff --git a/pygitweb/src/pygitweb/hooks_permissions_test.py b/pygitweb/src/pygitweb/hooks_permissions_test.py
index 62b7492..2532d35 100644
--- a/pygitweb/src/pygitweb/hooks_permissions_test.py
+++ b/pygitweb/src/pygitweb/hooks_permissions_test.py
@@ -15,101 +15,101 @@ from pygitweb.main import app
 from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
 
 _HOOKS_AUTH = AuthConfig(
-	auth_mode="local",
-	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=permissions_with_defaults({
-		permission_key(Permission.HOOKS, "demo"): ["admin"],
-	}),
+    auth_mode="local",
+    local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
+    oauth_permissions=permissions_with_defaults({
+        permission_key(Permission.HOOKS, "demo"): ["admin"],
+    }),
 )
 
 
 def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
-	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
-	index = repo.index
-	index.add("README.md")
-	index.write()
-	tree = index.write_tree()
-	sig = pygit2.Signature("tester", "tester@example.com")
-	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+    (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+    index = repo.index
+    index.add("README.md")
+    index.write()
+    tree = index.write_tree()
+    sig = pygit2.Signature("tester", "tester@example.com")
+    repo.create_commit("HEAD", sig, sig, "initial", tree, [])
 
 
 @pytest.fixture
 def hook_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
-	root = tmp_path / "root"
-	root.mkdir()
-	repo_dir = root / "demo"
-	repo_dir.mkdir()
-	_create_initial_commit(pygit2.init_repository(str(repo_dir), bare=False), repo_dir)
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(root)),
-		patch.object(settings, "PROJECT_MAXDEPTH", 3),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "LIST_ALL", True),
-		patch.object(settings, "MAXLOAD", None),
-		patch("pygitweb.auth_config.auth_config", _HOOKS_AUTH),
-		patch("pygitweb.auth.auth_config", _HOOKS_AUTH),
-		patch("pygitweb.main.auth_config", _HOOKS_AUTH),
-	):
-		yield {"project": "demo"}
+    root = tmp_path / "root"
+    root.mkdir()
+    repo_dir = root / "demo"
+    repo_dir.mkdir()
+    _create_initial_commit(pygit2.init_repository(str(repo_dir), bare=False), repo_dir)
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(root)),
+        patch.object(settings, "PROJECT_MAXDEPTH", 3),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "LIST_ALL", True),
+        patch.object(settings, "MAXLOAD", None),
+        patch("pygitweb.auth_config.auth_config", _HOOKS_AUTH),
+        patch("pygitweb.auth.auth_config", _HOOKS_AUTH),
+        patch("pygitweb.main.auth_config", _HOOKS_AUTH),
+    ):
+        yield {"project": "demo"}
 
 
 @pytest.fixture
 def client() -> Generator[TestClient, None, None]:
-	with TestClient(app) as c:
-		yield c
+    with TestClient(app) as c:
+        yield c
 
 
 def test_hook_check_open_when_auth_disabled(client: TestClient, hook_env: dict[str, str]) -> None:
-	with patch.object(settings, "AUTH", False):
-		r = client.post(
-			f"/project/{hook_env['project']}/hook",
-			params={"name": "post-receive.notify", "op": "check"},
-		)
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", False):
+        r = client.post(
+            f"/project/{hook_env['project']}/hook",
+            params={"name": "post-receive.notify", "op": "check"},
+        )
+    assert r.status_code == 200
 
 
 def test_hook_add_open_when_auth_disabled(client: TestClient, hook_env: dict[str, str]) -> None:
-	with patch.object(settings, "AUTH", False):
-		r = client.post(
-			f"/project/{hook_env['project']}/hook",
-			params={"name": "post-receive.notify", "op": "add"},
-		)
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", False):
+        r = client.post(
+            f"/project/{hook_env['project']}/hook",
+            params={"name": "post-receive.notify", "op": "add"},
+        )
+    assert r.status_code == 200
 
 
 def test_hook_add_401_without_login(client: TestClient, hook_env: dict[str, str]) -> None:
-	with patch.object(settings, "AUTH", True):
-		r = client.post(
-			f"/project/{hook_env['project']}/hook",
-			params={"name": "post-receive.notify", "op": "add"},
-		)
-	assert r.status_code == 404
+    with patch.object(settings, "AUTH", True):
+        r = client.post(
+            f"/project/{hook_env['project']}/hook",
+            params={"name": "post-receive.notify", "op": "add"},
+        )
+    assert r.status_code == 404
 
 
 def test_hook_add_allowed_with_grant(client: TestClient, hook_env: dict[str, str]) -> None:
-	with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
-		r = client.post(
-			f"/project/{hook_env['project']}/hook",
-			params={"name": "post-receive.notify", "op": "add"},
-		)
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
+        r = client.post(
+            f"/project/{hook_env['project']}/hook",
+            params={"name": "post-receive.notify", "op": "add"},
+        )
+    assert r.status_code == 200
 
 
 def test_hook_add_forbidden_without_grant(client: TestClient, hook_env: dict[str, str]) -> None:
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			f"/project/{hook_env['project']}/hook",
-			params={"name": "post-receive.notify", "op": "add"},
-		)
-	assert r.status_code == 403
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            f"/project/{hook_env['project']}/hook",
+            params={"name": "post-receive.notify", "op": "add"},
+        )
+    assert r.status_code == 403
 
 
 def test_hook_check_allowed_without_hooks_grant(client: TestClient, hook_env: dict[str, str]) -> None:
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			f"/project/{hook_env['project']}/hook",
-			params={"name": "post-receive.notify", "op": "check"},
-		)
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            f"/project/{hook_env['project']}/hook",
+            params={"name": "post-receive.notify", "op": "check"},
+        )
+    assert r.status_code == 200
diff --git a/pygitweb/src/pygitweb/main.py b/pygitweb/src/pygitweb/main.py
index c4ad8ae..93110dd 100644
--- a/pygitweb/src/pygitweb/main.py
+++ b/pygitweb/src/pygitweb/main.py
@@ -21,94 +21,94 @@ from fastapi.staticfiles import StaticFiles
 
 from pygitweb import __meta__
 from pygitweb.actions import (
-	BLOG_MANIFEST_PATH,
-	SearchFlagQuery,
-	SearchSortQuery,
-	SummaryRefsPayload,
-	SummaryRefState,
-	board_link_spec,
-	create_board_link,
-	git_blame,
-	git_blame_raw,
-	git_blob,
-	git_blobdiff,
-	git_blobpatch,
-	git_blog_raw,
-	git_commit,
-	git_commitdiff,
-	git_heads,
-	git_history,
-	git_log,
-	git_object,
-	git_patch,
-	git_patches,
-	git_remotes,
-	git_search,
-	git_search_page,
-	git_shortlog,
-	git_summary,
-	git_tag,
-	git_tags,
-	git_tree,
-	parse_pagination,
-	project_has_blog,
-	summary_ref_options,
-	summary_ref_state,
+    BLOG_MANIFEST_PATH,
+    SearchFlagQuery,
+    SearchSortQuery,
+    SummaryRefsPayload,
+    SummaryRefState,
+    board_link_spec,
+    create_board_link,
+    git_blame,
+    git_blame_raw,
+    git_blob,
+    git_blobdiff,
+    git_blobpatch,
+    git_blog_raw,
+    git_commit,
+    git_commitdiff,
+    git_heads,
+    git_history,
+    git_log,
+    git_object,
+    git_patch,
+    git_patches,
+    git_remotes,
+    git_search,
+    git_search_page,
+    git_shortlog,
+    git_summary,
+    git_tag,
+    git_tags,
+    git_tree,
+    parse_pagination,
+    project_has_blog,
+    summary_ref_options,
+    summary_ref_state,
 )
 from pygitweb.api.plugins.project_zip import ProjectZip
 from pygitweb.api.project import Project
 from pygitweb.auth import (
-	access_token_from_request,
-	auth_router,
-	can_read_project,
-	decode_access_token,
-	ensure_permission,
-	require_permission,
+    access_token_from_request,
+    auth_router,
+    can_read_project,
+    decode_access_token,
+    ensure_permission,
+    require_permission,
 )
 from pygitweb.auth_config import is_auth_configured
 from pygitweb.board_refs import BoardRefManifest, board_ref_manifest
 from pygitweb.change_queue import CHANGE_QUEUE
 from pygitweb.config import auth_config, get_loadavg, settings
 from pygitweb.dependencies import (
-	ValidatedBoardCreateProject,
-	ValidatedNotifyProject,
-	ValidatedReadableProject,
-	filter_projects_by_read_access,
-	project_visible_in_list,
-	require_loopback_client,
+    ValidatedBoardCreateProject,
+    ValidatedNotifyProject,
+    ValidatedReadableProject,
+    filter_projects_by_read_access,
+    project_visible_in_list,
+    require_loopback_client,
 )
 from pygitweb.formatting import age_string
 from pygitweb.git_helpers import git_get_type
 from pygitweb.hooks_install import (
-	HookStatus,
-	bundle_status,
-	get_bundle,
-	get_sample,
-	list_bundles,
-	list_samples,
+    HookStatus,
+    bundle_status,
+    get_bundle,
+    get_sample,
+    list_bundles,
+    list_samples,
 )
 from pygitweb.hooks_install import (
-	install as install_hook,
+    install as install_hook,
 )
 from pygitweb.hooks_install import (
-	install_bundle as install_hook_bundle,
+    install_bundle as install_hook_bundle,
 )
 from pygitweb.hooks_install import (
-	remove as remove_hook,
+    remove as remove_hook,
 )
 from pygitweb.hooks_install import (
-	remove_bundle as remove_hook_bundle,
+    remove_bundle as remove_hook_bundle,
 )
 from pygitweb.hooks_install import (
-	status as hook_status,
+    status as hook_status,
 )
 from pygitweb.merge_requests import merge_router
 from pygitweb.permissions import Permission
 from pygitweb.plugin_loader import (
-	DISPATCH_ACTIONS,
-	PLUGIN_ACTIONS,
-	SUBPAGES,
-	apply_plugin_reload,
+    DISPATCH_ACTIONS,
+    PLUGIN_ACTIONS,
+    SUBPAGES,
+    apply_plugin_reload,
 )
 from pygitweb.projects import git_get_project_owner, git_get_projects_list
 from pygitweb.sessions import clear_all_sessions
@@ -116,13 +116,13 @@ from pygitweb.settings import router as settings_router
 from pygitweb.shutdown import begin_shutdown, install_graceful_shutdown_wakeup
 from pygitweb.smart_http import http_router
 from pygitweb.tasks import (
-	board_router,
-	comment_router,
-	create_board_for_project,
-	get_all_project_tasks,
-	get_board_tasks_grouped,
-	list_project_boards,
-	task_router,
+    board_router,
+    comment_router,
+    create_board_for_project,
+    get_all_project_tasks,
+    get_board_tasks_grouped,
+    list_project_boards,
+    task_router,
 )
 from pygitweb.team import team_router
 from pygitweb.templates_env import Cell, html_join, html_link, jinja_escape, render_page, trusted_html
@@ -137,71 +137,71 @@ HookInstallOp = Literal["add", "remove", "check"]
 
 
 def _parse_updates_flag(value: str | None) -> bool:
-	if value is None:
-		return False
-	return value.strip().lower() in ("1", "true", "yes", "on")
+    if value is None:
+        return False
+    return value.strip().lower() in ("1", "true", "yes", "on")
 
 
 def _resolve_subscribed_projects(project: str, project_filter: str | None) -> list[str]:
-	"""Pick the project set to long-poll. Uses pf prefix if provided, else the URL project."""
-	if not project_filter:
-		return [project]
-	pf = project_filter.strip().strip("/")
-	if not pf:
-		return [project]
-	matches = git_get_projects_list(
-		filter_path=pf,
-		paranoid=settings.STRICT_EXPORT,
-		export_ok=settings.EXPORT_OK,
-	)
-	names: list[str] = [m.get("path", "") for m in matches if m.get("path")]
-	return names or [project]
+    """Pick the project set to long-poll. Uses pf prefix if provided, else the URL project."""
+    if not project_filter:
+        return [project]
+    pf = project_filter.strip().strip("/")
+    if not pf:
+        return [project]
+    matches = git_get_projects_list(
+        filter_path=pf,
+        paranoid=settings.STRICT_EXPORT,
+        export_ok=settings.EXPORT_OK,
+    )
+    names: list[str] = [m.get("path", "") for m in matches if m.get("path")]
+    return names or [project]
 
 
 def _updates_idle_response() -> Response:
-	return Response(status_code=200, content=b"")
+    return Response(status_code=200, content=b"")
 
 
 @asynccontextmanager
 async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
-	app.state.shutting_down = False
-	app.state.shutdown_event = asyncio.Event()
-	app.state.can_signal_shutdown = False
-	install_graceful_shutdown_wakeup(app)
-	warm_timeline_cache_async()
-	try:
-		yield
-	finally:
-		begin_shutdown(app)
-		clear_timeline_cache_async()
-		clear_all_sessions()
+    app.state.shutting_down = False
+    app.state.shutdown_event = asyncio.Event()
+    app.state.can_signal_shutdown = False
+    install_graceful_shutdown_wakeup(app)
+    warm_timeline_cache_async()
+    try:
+        yield
+    finally:
+        begin_shutdown(app)
+        clear_timeline_cache_async()
+        clear_all_sessions()
 
 
 _app_description = Path(__file__).parent.parent.parent.joinpath("README.md").read_text(encoding="utf-8")
 
 app = FastAPI(
-	debug=(settings.AUTH is False),
-	title="PyGitWeb",
-	summary="FastAPI + Pygit2 Repo Browser",
-	description=_app_description,
-	version=__meta__.__version__,
-	lifespan=app_lifespan,
-	openapi_tags=[
-		{
-			"name": "auth",
-			"description": """Session status, account pages, and sign-out.
+    debug=(settings.AUTH is False),
+    title="PyGitWeb",
+    summary="FastAPI + Pygit2 Repo Browser",
+    description=_app_description,
+    version=__meta__.__version__,
+    lifespan=app_lifespan,
+    openapi_tags=[
+        {
+            "name": "auth",
+            "description": """Session status, account pages, and sign-out.
 Sessions are opaque ids (cookie or Bearer), not IdP JWTs.""",
-		},
-		{
-			"name": "auth - local",
-			"description": "Local username/password sign-in via HTML form or POST /token.",
-		},
-		{
-			"name": "auth - OAuth",
-			"description": """Browser OAuth2 authorization-code flow (Google/GitHub).
+        },
+        {
+            "name": "auth - local",
+            "description": "Local username/password sign-in via HTML form or POST /token.",
+        },
+        {
+            "name": "auth - OAuth",
+            "description": """Browser OAuth2 authorization-code flow (Google/GitHub).
 Ends with the same session cookie as local login.""",
-		},
-	],
+        },
+    ],
 )
 
 apply_plugin_reload()
@@ -209,7 +209,7 @@ apply_plugin_reload()
 # Todo handle with nginx route
 _static_dir = Path(__file__).parent / "static"
 if _static_dir.is_dir():
-	app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
+    app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
 
 
 app.include_router(auth_router)
@@ -224,33 +224,33 @@ app.include_router(http_router)
 
 @app.middleware("http")
 async def loadavg_middleware(
-	request: Request,
-	call_next: Callable[[Request], Awaitable[Response]],
+    request: Request,
+    call_next: Callable[[Request], Awaitable[Response]],
 ) -> Response:
-	try:
-		if settings.MAXLOAD is not None and get_loadavg() > settings.MAXLOAD:
-			raise RuntimeError("503:The load average on the server is too high")
-	except RuntimeError as e:
-		msg = str(e)
-		if msg.startswith("503:"):
-			raise HTTPException(status_code=503, detail=msg[4:]) from e
-		raise
-	return await call_next(request)
+    try:
+        if settings.MAXLOAD is not None and get_loadavg() > settings.MAXLOAD:
+            raise RuntimeError("503:The load average on the server is too high")
+    except RuntimeError as e:
+        msg = str(e)
+        if msg.startswith("503:"):
+            raise HTTPException(status_code=503, detail=msg[4:]) from e
+        raise
+    return await call_next(request)
 
 
 def _project_subpage_rows(project: str) -> list[list[Cell]]:
-	project_enc = quote(project, safe="/")
-	rows: list[list[Cell]] = []
-	for subpage in SUBPAGES:
-		link_label = subpage.link_name(project)
-		if not link_label:
-			continue
-		subpage_name = subpage.subpage_name()
-		view = html_link(f"/project/{project_enc}/subpage/{quote(subpage_name, safe='')}", "view")
-		suffix = subpage.summary_value_suffix_html(project, project_enc)
-		value = html_join([view, trusted_html(suffix)]) if suffix else view
-		rows.append([link_label, value])
-	return rows
+    project_enc = quote(project, safe="/")
+    rows: list[list[Cell]] = []
+    for subpage in SUBPAGES:
+        link_label = subpage.link_name(project)
+        if not link_label:
+            continue
+        subpage_name = subpage.subpage_name()
+        view = html_link(f"/project/{project_enc}/subpage/{quote(subpage_name, safe='')}", "view")
+        suffix = subpage.summary_value_suffix_html(project, project_enc)
+        value = html_join([view, trusted_html(suffix)]) if suffix else view
+        rows.append([link_label, value])
+    return rows
 
 
 # ---------- Routes (no project) ----------
@@ -259,258 +259,258 @@ _OPEN_TASK_STATUSES = frozenset({"TODO", "IN_PROGRESS", "IN_REVIEW"})
 
 
 def _home_stats(projects: list[dict[str, object]]) -> dict[str, int]:
-	"""Counts for the project-list dashboard cards."""
-	from pygitweb.capacity_config import current_iso_week, get_user_week_capacity
-
-	visible = {str(pr.get("path") or "") for pr in projects if pr.get("path")}
-	points_by_assignee: dict[str, int] = {}
-	open_tasks = 0
-	for path in visible:
-		try:
-			tasks = get_all_project_tasks(path)
-		except Exception:
-			continue
-		for task in tasks:
-			if task.get("status") not in _OPEN_TASK_STATUSES:
-				continue
-			open_tasks += 1
-			assignee = str(task.get("assignee") or "").strip()
-			if not assignee:
-				continue
-			points = task.get("points") or 0
-			points_by_assignee[assignee] = points_by_assignee.get(assignee, 0) + int(points)
-	week = current_iso_week()
-	over_capacity = 0
-	for assignee, points in points_by_assignee.items():
-		capacity = get_user_week_capacity(assignee, week)
-		if capacity is not None and points > capacity:
-			over_capacity += 1
-	recent_commits = sum(
-		1 for item in get_all_timeline_events() if item["project"] in visible and item["event"]["kind"] == "commit"
-	)
-	return {
-		"projects": len(visible),
-		"open_tasks": open_tasks,
-		"over_capacity": over_capacity,
-		"recent_commits": recent_commits,
-	}
+    """Counts for the project-list dashboard cards."""
+    from pygitweb.capacity_config import current_iso_week, get_user_week_capacity
+
+    visible = {str(pr.get("path") or "") for pr in projects if pr.get("path")}
+    points_by_assignee: dict[str, int] = {}
+    open_tasks = 0
+    for path in visible:
+        try:
+            tasks = get_all_project_tasks(path)
+        except Exception:
+            continue
+        for task in tasks:
+            if task.get("status") not in _OPEN_TASK_STATUSES:
+                continue
+            open_tasks += 1
+            assignee = str(task.get("assignee") or "").strip()
+            if not assignee:
+                continue
+            points = task.get("points") or 0
+            points_by_assignee[assignee] = points_by_assignee.get(assignee, 0) + int(points)
+    week = current_iso_week()
+    over_capacity = 0
+    for assignee, points in points_by_assignee.items():
+        capacity = get_user_week_capacity(assignee, week)
+        if capacity is not None and points > capacity:
+            over_capacity += 1
+    recent_commits = sum(
+        1 for item in get_all_timeline_events() if item["project"] in visible and item["event"]["kind"] == "commit"
+    )
+    return {
+        "projects": len(visible),
+        "open_tasks": open_tasks,
+        "over_capacity": over_capacity,
+        "recent_commits": recent_commits,
+    }
 
 
 @app.get("/", response_class=HTMLResponse)
 def git_project_list(
-	request: Request,
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	_action: Annotated[ProjectListAction | None, Query(alias="a")] = None,
-	pf: Annotated[str | None, Query(alias="pf")] = None,
-	_order: Annotated[ProjectListOrder | None, Query(alias="o")] = None,
+    request: Request,
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    _action: Annotated[ProjectListAction | None, Query(alias="a")] = None,
+    pf: Annotated[str | None, Query(alias="pf")] = None,
+    _order: Annotated[ProjectListOrder | None, Query(alias="o")] = None,
 ) -> HTMLResponse:
-	"""Project list page. Port of git_project_list."""
-	project_filter = pf or ""
-	all_projects = git_get_projects_list(
-		filter_path=project_filter,
-		paranoid=settings.STRICT_EXPORT,
-		export_ok=settings.EXPORT_OK,
-	)
-	list_ = filter_projects_by_read_access(all_projects, token)
-	notice = ""
-	if not list_:
-		if all_projects and settings.AUTH:
-			notice = "hidden"
-		elif not all_projects:
-			notice = "empty"
-	projects = []
-	for pr in list_[:50]:
-		path = pr.get("path", "") or ""
-		board = board_link_spec(path)
-		projects.append({
-			"href": f"/project/{quote(path, safe='/')}",
-			"path": path,
-			"descr": pr.get("descr") or path,
-			"board_href": board["href"],
-			"board_text": board["text"],
-			"board_style": board["style"],
-		})
-	return render_page(
-		"project_list.html",
-		title=f"{settings.SITE_NAME} - Projects",
-		notice=notice,
-		projects=projects,
-		stats=_home_stats(list_),
-		nav="projects",
-	)
+    """Project list page. Port of git_project_list."""
+    project_filter = pf or ""
+    all_projects = git_get_projects_list(
+        filter_path=project_filter,
+        paranoid=settings.STRICT_EXPORT,
+        export_ok=settings.EXPORT_OK,
+    )
+    list_ = filter_projects_by_read_access(all_projects, token)
+    notice = ""
+    if not list_:
+        if all_projects and settings.AUTH:
+            notice = "hidden"
+        elif not all_projects:
+            notice = "empty"
+    projects = []
+    for pr in list_[:50]:
+        path = pr.get("path", "") or ""
+        board = board_link_spec(path)
+        projects.append({
+            "href": f"/project/{quote(path, safe='/')}",
+            "path": path,
+            "descr": pr.get("descr") or path,
+            "board_href": board["href"],
+            "board_text": board["text"],
+            "board_style": board["style"],
+        })
+    return render_page(
+        "project_list.html",
+        title=f"{settings.SITE_NAME} - Projects",
+        notice=notice,
+        projects=projects,
+        stats=_home_stats(list_),
+        nav="projects",
+    )
 
 
 @app.get("/index", response_class=PlainTextResponse)
 def git_project_index(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	pf: Annotated[str | None, Query(alias="pf")] = None,
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    pf: Annotated[str | None, Query(alias="pf")] = None,
 ) -> PlainTextResponse:
-	"""Plain text project index (path owner). Port of git_project_index."""
-	from urllib.parse import quote_plus
-
-	projects = filter_projects_by_read_access(
-		git_get_projects_list(
-			filter_path=pf or "",
-			paranoid=settings.STRICT_EXPORT,
-			export_ok=settings.EXPORT_OK,
-		),
-		token,
-	)
-	if not projects:
-		raise HTTPException(status_code=404, detail="No projects found")
-	lines = []
-	for pr in projects:
-		path = pr.get("path", "")
-		owner = pr.get("owner") or git_get_project_owner(path) or ""
-		path_enc = quote_plus(path, safe="/")
-		owner_enc = quote_plus(owner, safe="/")
-		lines.append(f"{path_enc} {owner_enc}")
-	return PlainTextResponse("\n".join(lines), media_type="text/plain; charset=utf-8")
+    """Plain text project index (path owner). Port of git_project_index."""
+    from urllib.parse import quote_plus
+
+    projects = filter_projects_by_read_access(
+        git_get_projects_list(
+            filter_path=pf or "",
+            paranoid=settings.STRICT_EXPORT,
+            export_ok=settings.EXPORT_OK,
+        ),
+        token,
+    )
+    if not projects:
+        raise HTTPException(status_code=404, detail="No projects found")
+    lines = []
+    for pr in projects:
+        path = pr.get("path", "")
+        owner = pr.get("owner") or git_get_project_owner(path) or ""
+        path_enc = quote_plus(path, safe="/")
+        owner_enc = quote_plus(owner, safe="/")
+        lines.append(f"{path_enc} {owner_enc}")
+    return PlainTextResponse("\n".join(lines), media_type="text/plain; charset=utf-8")
 
 
 @app.get("/activity", response_class=HTMLResponse)
 def activity_page(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	page: Annotated[str | None, Query(alias="page")] = None,
-	pagecount: Annotated[str | None, Query(alias="pagecount")] = None,
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    page: Annotated[str | None, Query(alias="page")] = None,
+    pagecount: Annotated[str | None, Query(alias="pagecount")] = None,
 ) -> HTMLResponse:
-	p, pc = parse_pagination(page, pagecount)
-	all_events = [item for item in get_all_timeline_events() if can_read_project(item["project"], token)]
-	skip = (p - 1) * pc
-	events_page = all_events[skip : skip + pc + 1]
-	has_next = len(events_page) > pc
-	if has_next:
-		events_page = events_page[:pc]
-	events: list[dict[str, str]] = []
-	for item in events_page:
-		project_name = item["project"]
-		event = item["event"]
-		oid = event["oid"]
-		kind = event["kind"] or ""
-		event_href = ""
-		label: Literal["commit", "ref"] | str = kind
-		if kind == "commit":
-			label = oid[:7]
-			event_href = f"/project/{quote(project_name, safe='/')}?a=commit&h={quote(oid, safe='')}"
-		description_raw = event.get("description")
-		if not description_raw:
-			description = ""
-		elif "\n" in description_raw:
-			description = description_raw.splitlines()[0][:80].rstrip() + "..."
-		elif len(description_raw) > 80:
-			description = description_raw[:80].rstrip() + "..."
-		else:
-			description = description_raw
-		try:
-			age_seconds = datetime.now(UTC).timestamp() - float(event["timestamp"])
-			activity_time = "right now" if age_seconds <= 0 else age_string(age_seconds)
-		except (ValueError, OSError):
-			activity_time = ""
-		events.append({
-			"time": activity_time,
-			"project": project_name,
-			"project_href": f"/project/{quote(project_name, safe='/')}",
-			"label": label,
-			"event_href": event_href,
-			"description": description,
-		})
-	prev_url = ""
-	next_url = ""
-	if p > 1:
-		prev_url = f"/activity?{urlencode({'page': str(p - 1), 'pagecount': str(pc)})}"
-	if has_next:
-		next_url = f"/activity?{urlencode({'page': str(p + 1), 'pagecount': str(pc)})}"
-	return render_page(
-		"activity.html",
-		title=f"{settings.SITE_NAME} - Activity",
-		events=events,
-		nav="activity",
-		pagination={
-			"current_page": p,
-			"pagecount": pc,
-			"has_prev": p > 1,
-			"has_next": has_next,
-			"prev_url": prev_url,
-			"next_url": next_url,
-			"total_pages": None,
-			"page_links": None,
-		},
-	)
+    p, pc = parse_pagination(page, pagecount)
+    all_events = [item for item in get_all_timeline_events() if can_read_project(item["project"], token)]
+    skip = (p - 1) * pc
+    events_page = all_events[skip : skip + pc + 1]
+    has_next = len(events_page) > pc
+    if has_next:
+        events_page = events_page[:pc]
+    events: list[dict[str, str]] = []
+    for item in events_page:
+        project_name = item["project"]
+        event = item["event"]
+        oid = event["oid"]
+        kind = event["kind"] or ""
+        event_href = ""
+        label: Literal["commit", "ref"] | str = kind
+        if kind == "commit":
+            label = oid[:7]
+            event_href = f"/project/{quote(project_name, safe='/')}?a=commit&h={quote(oid, safe='')}"
+        description_raw = event.get("description")
+        if not description_raw:
+            description = ""
+        elif "\n" in description_raw:
+            description = description_raw.splitlines()[0][:80].rstrip() + "..."
+        elif len(description_raw) > 80:
+            description = description_raw[:80].rstrip() + "..."
+        else:
+            description = description_raw
+        try:
+            age_seconds = datetime.now(UTC).timestamp() - float(event["timestamp"])
+            activity_time = "right now" if age_seconds <= 0 else age_string(age_seconds)
+        except (ValueError, OSError):
+            activity_time = ""
+        events.append({
+            "time": activity_time,
+            "project": project_name,
+            "project_href": f"/project/{quote(project_name, safe='/')}",
+            "label": label,
+            "event_href": event_href,
+            "description": description,
+        })
+    prev_url = ""
+    next_url = ""
+    if p > 1:
+        prev_url = f"/activity?{urlencode({'page': str(p - 1), 'pagecount': str(pc)})}"
+    if has_next:
+        next_url = f"/activity?{urlencode({'page': str(p + 1), 'pagecount': str(pc)})}"
+    return render_page(
+        "activity.html",
+        title=f"{settings.SITE_NAME} - Activity",
+        events=events,
+        nav="activity",
+        pagination={
+            "current_page": p,
+            "pagecount": pc,
+            "has_prev": p > 1,
+            "has_next": has_next,
+            "prev_url": prev_url,
+            "next_url": next_url,
+            "total_pages": None,
+            "page_links": None,
+        },
+    )
 
 
 @app.get("/boards", response_class=HTMLResponse)
 def boards_overview_page(
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> HTMLResponse:
-	"""Cross-project index of boards, so users can jump straight to any project's board."""
-	projects = filter_projects_by_read_access(
-		git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
-		token,
-	)
-	rows: list[list[Cell]] = []
-	for pr in projects:
-		project = pr.get("path", "")
-		if not project:
-			continue
-		project_url = f"/project/{quote(project, safe='/')}"
-		boards = list_project_boards(project)
-		if not boards:
-			rows.append([
-				html_link(project_url, project),
-				create_board_link(project),
-				"",
-			])
-			continue
-		for b in boards:
-			board_url = f"{project_url}/board/?b={quote(b['name'], safe='')}"
-			rows.append([
-				html_link(project_url, project),
-				html_link(board_url, b["name"]),
-				str(b.get("task_count", 0)),
-			])
-	return render_page(
-		"table_page.html",
-		title=f"{settings.SITE_NAME} - Boards",
-		heading="Boards",
-		cols=["Project", "Board", "Tasks"],
-		rows=rows,
-		empty_message="No projects found." if not rows else None,
-		empty_muted=True,
-		nav="boards",
-	)
+    """Cross-project index of boards, so users can jump straight to any project's board."""
+    projects = filter_projects_by_read_access(
+        git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
+        token,
+    )
+    rows: list[list[Cell]] = []
+    for pr in projects:
+        project = pr.get("path", "")
+        if not project:
+            continue
+        project_url = f"/project/{quote(project, safe='/')}"
+        boards = list_project_boards(project)
+        if not boards:
+            rows.append([
+                html_link(project_url, project),
+                create_board_link(project),
+                "",
+            ])
+            continue
+        for b in boards:
+            board_url = f"{project_url}/board/?b={quote(b['name'], safe='')}"
+            rows.append([
+                html_link(project_url, project),
+                html_link(board_url, b["name"]),
+                str(b.get("task_count", 0)),
+            ])
+    return render_page(
+        "table_page.html",
+        title=f"{settings.SITE_NAME} - Boards",
+        heading="Boards",
+        cols=["Project", "Board", "Tasks"],
+        rows=rows,
+        empty_message="No projects found." if not rows else None,
+        empty_muted=True,
+        nav="boards",
+    )
 
 
 @app.get("/opml", response_class=PlainTextResponse)
 def git_opml(token: Annotated[str | None, Depends(access_token_from_request)]) -> PlainTextResponse:
-	"""OPML feed list. Port of git_opml (stub)."""
-	projects = filter_projects_by_read_access(
-		git_get_projects_list(export_ok=settings.EXPORT_OK),
-		token,
-	)
-	# Minimal OPML
-	lines = ['<?xml version="1.0"?><opml version="1.0"><head><title>Git</title></head><body>']
-	for pr in projects[:100]:
-		path = pr.get("path", "")
-		lines.append(f'<outline text="{jinja_escape(path)}" />')
-	lines.append("</body></opml>")
-	return PlainTextResponse("\n".join(lines), media_type="text/xml; charset=utf-8")
+    """OPML feed list. Port of git_opml (stub)."""
+    projects = filter_projects_by_read_access(
+        git_get_projects_list(export_ok=settings.EXPORT_OK),
+        token,
+    )
+    # Minimal OPML
+    lines = ['<?xml version="1.0"?><opml version="1.0"><head><title>Git</title></head><body>']
+    for pr in projects[:100]:
+        path = pr.get("path", "")
+        lines.append(f'<outline text="{jinja_escape(path)}" />')
+    lines.append("</body></opml>")
+    return PlainTextResponse("\n".join(lines), media_type="text/xml; charset=utf-8")
 
 
 @app.get("/board/create", response_class=RedirectResponse)
 def board_create_page(
-	_: Annotated[None, Depends(require_permission(Permission.BOARDS, project_from_query=True))],
-	project: ValidatedBoardCreateProject,
+    _: Annotated[None, Depends(require_permission(Permission.BOARDS, project_from_query=True))],
+    project: ValidatedBoardCreateProject,
 ) -> RedirectResponse:
-	"""Create a board named 'Tasks' (refs/tags/boards/Tasks) and redirect to the project summary."""
-	try:
-		create_board_for_project(project, name="Tasks", description="")
-	except HTTPException as e:
-		if e.status_code == 409:
-			pass
-		else:
-			raise
-	p_url = quote(project, safe="/")
-	return RedirectResponse(url=f"/project/{p_url}", status_code=303)
+    """Create a board named 'Tasks' (refs/tags/boards/Tasks) and redirect to the project summary."""
+    try:
+        create_board_for_project(project, name="Tasks", description="")
+    except HTTPException as e:
+        if e.status_code == 409:
+            pass
+        else:
+            raise
+    p_url = quote(project, safe="/")
+    return RedirectResponse(url=f"/project/{p_url}", status_code=303)
 
 
 # ---------- Add project ----------
@@ -520,90 +520,90 @@ _OPTIONAL_REPO_ZIP = File(default=None)
 
 @app.get("/projectnamevalid", response_class=HTMLResponse)
 def addproject_namevalid(
-	_: Annotated[None, Depends(require_permission(Permission.ADD_PROJECTS))],
-	name: Annotated[str | None, Query()] = None,
+    _: Annotated[None, Depends(require_permission(Permission.ADD_PROJECTS))],
+    name: Annotated[str | None, Query()] = None,
 ) -> HTMLResponse:
-	"""Check if project name is valid (requires pgw.addprojects when auth is enabled)."""
-	if not name:
-		raise HTTPException(status_code=400, detail="Param 'name' required")
-	if not is_valid_pathname(name):
-		raise HTTPException(status_code=400, detail="Invalid project name (no path segments)")
-	if project_visible_in_list(name):
-		raise HTTPException(status_code=409, detail="Project already exists")
-	return HTMLResponse(status_code=200, content=f"Project name '{name}' is valid")
+    """Check if project name is valid (requires pgw.addprojects when auth is enabled)."""
+    if not name:
+        raise HTTPException(status_code=400, detail="Param 'name' required")
+    if not is_valid_pathname(name):
+        raise HTTPException(status_code=400, detail="Invalid project name (no path segments)")
+    if project_visible_in_list(name):
+        raise HTTPException(status_code=409, detail="Project already exists")
+    return HTMLResponse(status_code=200, content=f"Project name '{name}' is valid")
 
 
 @app.get("/addproject", response_class=HTMLResponse)
 def addproject_page(
-	token: Annotated[str | None, Depends(access_token_from_request)],
+    token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> HTMLResponse:
-	"""Add project form page."""
-	show_sign_in_notice = (
-		settings.AUTH and is_auth_configured(auth_config) and (not token or decode_access_token(token) is None)
-	)
-	return render_page(
-		"addproject.html",
-		title=f"{settings.SITE_NAME} - Add Project",
-		show_sign_in_notice=show_sign_in_notice,
-		empty_repo_form_content=Project.form_content(),
-		upload_zip_form_content=ProjectZip.form_content(),
-		nav="add",
-	)
+    """Add project form page."""
+    show_sign_in_notice = (
+        settings.AUTH and is_auth_configured(auth_config) and (not token or decode_access_token(token) is None)
+    )
+    return render_page(
+        "addproject.html",
+        title=f"{settings.SITE_NAME} - Add Project",
+        show_sign_in_notice=show_sign_in_notice,
+        empty_repo_form_content=Project.form_content(),
+        upload_zip_form_content=ProjectZip.form_content(),
+        nav="add",
+    )
 
 
 @app.post("/addproject", response_class=HTMLResponse)
 async def addproject_submit(
-	_: Annotated[None, Depends(require_permission(Permission.ADD_PROJECTS))],
-	request: Request,
-	project_name: Annotated[str, Form()] = "",
-	pull_from_remote: Annotated[str, Form()] = "",
-	perform_maintenance: Annotated[str, Form()] = "off",
-	create_task_board: Annotated[str, Form()] = "off",
-	repo_zip: UploadFile | None = _OPTIONAL_REPO_ZIP,
+    _: Annotated[None, Depends(require_permission(Permission.ADD_PROJECTS))],
+    request: Request,
+    project_name: Annotated[str, Form()] = "",
+    pull_from_remote: Annotated[str, Form()] = "",
+    perform_maintenance: Annotated[str, Form()] = "off",
+    create_task_board: Annotated[str, Form()] = "off",
+    repo_zip: UploadFile | None = _OPTIONAL_REPO_ZIP,
 ) -> RedirectResponse:
-	"""Create a new project (requires pgw.addprojects when auth is enabled)."""
-	project_name = (project_name or "").strip()
-	if not project_name:
-		raise HTTPException(status_code=400, detail="Project name is required")
-	if not is_valid_pathname(project_name):
-		raise HTTPException(status_code=400, detail="Invalid project name")
-	if project_visible_in_list(project_name):
-		raise HTTPException(status_code=409, detail="Project already exists")
-
-	remote_url = (pull_from_remote or "").strip()
-	do_maintenance = perform_maintenance.lower() in ("on", "1", "true", "yes")
-	do_task_board = create_task_board.lower() in ("on", "1", "true", "yes")
-
-	dest_path = os.path.join(settings.PROJECTROOT, project_name)
-	os.makedirs(settings.PROJECTROOT, exist_ok=True)
-
-	try:
-		if repo_zip and repo_zip.filename and repo_zip.filename.lower().endswith(".zip"):
-			content = await repo_zip.read()
-			with ProjectZip(
-				path=dest_path,
-				zip_content=content,
-				create_task_board=do_task_board,
-				do_maintenance=do_maintenance,
-			):
-				pass
-		else:
-			with Project(
-				path=dest_path,
-				remote_url=remote_url or None,
-				create_task_board=do_task_board,
-				do_maintenance=do_maintenance,
-			):
-				pass
-
-	except pygit2.GitError as e:
-		raise HTTPException(status_code=400, detail=f"Git error: {e}") from e
-	except HTTPException:
-		raise
-	except OSError as e:
-		raise HTTPException(status_code=500, detail=str(e)) from e
-
-	return RedirectResponse(url=f"/project/{project_name}", status_code=303)
+    """Create a new project (requires pgw.addprojects when auth is enabled)."""
+    project_name = (project_name or "").strip()
+    if not project_name:
+        raise HTTPException(status_code=400, detail="Project name is required")
+    if not is_valid_pathname(project_name):
+        raise HTTPException(status_code=400, detail="Invalid project name")
+    if project_visible_in_list(project_name):
+        raise HTTPException(status_code=409, detail="Project already exists")
+
+    remote_url = (pull_from_remote or "").strip()
+    do_maintenance = perform_maintenance.lower() in ("on", "1", "true", "yes")
+    do_task_board = create_task_board.lower() in ("on", "1", "true", "yes")
+
+    dest_path = os.path.join(settings.PROJECTROOT, project_name)
+    os.makedirs(settings.PROJECTROOT, exist_ok=True)
+
+    try:
+        if repo_zip and repo_zip.filename and repo_zip.filename.lower().endswith(".zip"):
+            content = await repo_zip.read()
+            with ProjectZip(
+                path=dest_path,
+                zip_content=content,
+                create_task_board=do_task_board,
+                do_maintenance=do_maintenance,
+            ):
+                pass
+        else:
+            with Project(
+                path=dest_path,
+                remote_url=remote_url or None,
+                create_task_board=do_task_board,
+                do_maintenance=do_maintenance,
+            ):
+                pass
+
+    except pygit2.GitError as e:
+        raise HTTPException(status_code=400, detail=f"Git error: {e}") from e
+    except HTTPException:
+        raise
+    except OSError as e:
+        raise HTTPException(status_code=500, detail=str(e)) from e
+
+    return RedirectResponse(url=f"/project/{project_name}", status_code=303)
 
 
 # ---------- Routes (project required) ----------
@@ -611,391 +611,391 @@ async def addproject_submit(
 
 @app.get("/project/{project:path}/board/", response_class=HTMLResponse)
 def project_board(
-	project: ValidatedReadableProject,
-	board: Annotated[str, Query(alias="b")] = "Tasks",
+    project: ValidatedReadableProject,
+    board: Annotated[str, Query(alias="b")] = "Tasks",
 ) -> HTMLResponse:
-	"""Board view: columns (dropzones) and task cards."""
-	board_name = board
-	project_url = f"/project/{quote(project, safe='/')}"
-	board_url = f"{project_url}/board/"
-	columns = get_board_tasks_grouped(project, board_name)
-	for col in columns:
-		for t in col["tasks"]:
-			t["task_url"] = f"{board_url}?task={quote(t['ref'], safe='')}"
-	return render_page(
-		"board.html",
-		title=f"{project} - Board",
-		project=project,
-		project_url=project_url,
-		board_name=board_name,
-		nav="boards",
-		board_url=board_url,
-		columns=columns,
-	)
+    """Board view: columns (dropzones) and task cards."""
+    board_name = board
+    project_url = f"/project/{quote(project, safe='/')}"
+    board_url = f"{project_url}/board/"
+    columns = get_board_tasks_grouped(project, board_name)
+    for col in columns:
+        for t in col["tasks"]:
+            t["task_url"] = f"{board_url}?task={quote(t['ref'], safe='')}"
+    return render_page(
+        "board.html",
+        title=f"{project} - Board",
+        project=project,
+        project_url=project_url,
+        board_name=board_name,
+        nav="boards",
+        board_url=board_url,
+        columns=columns,
+    )
 
 
 @app.get("/project/{project:path}/blog/", response_class=HTMLResponse)
 def project_blog(project: ValidatedReadableProject) -> HTMLResponse:
-	"""Markdown blog viewer for repos with articles/manifest.json at HEAD."""
-	if not project_has_blog(project):
-		raise HTTPException(status_code=404, detail="Blog not found")
-	project_enc = quote(project, safe="/")
-	project_url = f"/project/{project_enc}"
-	blog_url = f"{project_url}/blog/"
-	return render_page(
-		"blog.html",
-		title=f"{project} - Blog",
-		project=project,
-		project_url=project_url,
-		blog_url=blog_url,
-		content_base=f"{blog_url}raw/",
-		manifest_path=BLOG_MANIFEST_PATH,
-		nav="projects",
-	)
+    """Markdown blog viewer for repos with articles/manifest.json at HEAD."""
+    if not project_has_blog(project):
+        raise HTTPException(status_code=404, detail="Blog not found")
+    project_enc = quote(project, safe="/")
+    project_url = f"/project/{project_enc}"
+    blog_url = f"{project_url}/blog/"
+    return render_page(
+        "blog.html",
+        title=f"{project} - Blog",
+        project=project,
+        project_url=project_url,
+        blog_url=blog_url,
+        content_base=f"{blog_url}raw/",
+        manifest_path=BLOG_MANIFEST_PATH,
+        nav="projects",
+    )
 
 
 @app.get("/project/{project:path}/blog/raw/{path:path}")
 def project_blog_raw(project: ValidatedReadableProject, path: str) -> Response:
-	"""Raw blob from HEAD for the blog viewer (markdown, manifest, assets)."""
-	return git_blog_raw(project, path)
+    """Raw blob from HEAD for the blog viewer (markdown, manifest, assets)."""
+    return git_blog_raw(project, path)
 
 
 @app.get("/project/{project:path}/board-ref-manifest")
 def project_board_ref_manifest(
-	project: ValidatedReadableProject,
-	board: Annotated[str, Query(alias="b")] = "Tasks",
+    project: ValidatedReadableProject,
+    board: Annotated[str, Query(alias="b")] = "Tasks",
 ) -> BoardRefManifest:
-	"""JSON manifest of recent commits and board/MR refs for @ / # autocomplete."""
-	return board_ref_manifest(project, board)
+    """JSON manifest of recent commits and board/MR refs for @ / # autocomplete."""
+    return board_ref_manifest(project, board)
 
 
 @app.get("/project/{project:path}/summary-refs")
 def project_summary_refs(project: ValidatedReadableProject) -> SummaryRefsPayload:
-	return {"options": summary_ref_options(project)}
+    return {"options": summary_ref_options(project)}
 
 
 @app.get("/project/{project:path}/summary-ref-state")
 def project_summary_ref_state(
-	project: ValidatedReadableProject,
-	ref: Annotated[str | None, Query(alias="ref")] = None,
+    project: ValidatedReadableProject,
+    ref: Annotated[str | None, Query(alias="ref")] = None,
 ) -> SummaryRefState:
-	return summary_ref_state(project, ref)
+    return summary_ref_state(project, ref)
 
 
 @app.get("/project/{project:path}/search", response_class=HTMLResponse)
 def project_search_page(project: ValidatedReadableProject) -> HTMLResponse:
-	"""Per-project search page (UI only; results fetched from /project/{project}?a=search)."""
-	return git_search_page(project)
+    """Per-project search page (UI only; results fetched from /project/{project}?a=search)."""
+    return git_search_page(project)
 
 
 @app.get("/project/{project:path}/subpage/{subpage_name}", response_class=HTMLResponse)
 def project_subpage(
-	project: ValidatedReadableProject,
-	subpage_name: str,
-	raw: Annotated[bool, Query()] = False,
+    project: ValidatedReadableProject,
+    subpage_name: str,
+    raw: Annotated[bool, Query()] = False,
 ) -> HTMLResponse:
-	for subpage in SUBPAGES:
-		if subpage.subpage_name() != subpage_name:
-			continue
-		content = subpage.subpage_html(project)
-		if content is None:
-			raise HTTPException(status_code=404, detail="No content for subpage")
-		content_lc = content.lstrip().lower()
-		title = f"{subpage.link_name(project) or subpage_name} - {project}"
-		if content_lc.startswith("<!doctype html") or "<html" in content_lc:
-			if raw:
-				stylesheet_href = "/static/main.css"
-				link_tag = f'<link rel="stylesheet" href="{stylesheet_href}">'
-				if "<head" in content.lower():
-					return HTMLResponse(content.replace("</head>", f"{link_tag}</head>", 1))
-				return HTMLResponse(f"{link_tag}{content}")
-			project_enc = quote(project, safe="/")
-			subpage_enc = quote(subpage_name, safe="")
-			return render_page(
-				"subpage.html",
-				title=title,
-				project=project,
-				subpage_name=subpage_name,
-				iframe_src=f"/project/{project_enc}/subpage/{subpage_enc}?raw=true",
-			)
-		return render_page(
-			"subpage.html",
-			title=title,
-			project=project,
-			subpage_name=subpage_name,
-			content=trusted_html(content),
-		)
-	raise HTTPException(status_code=404, detail="Unknown subpage")
+    for subpage in SUBPAGES:
+        if subpage.subpage_name() != subpage_name:
+            continue
+        content = subpage.subpage_html(project)
+        if content is None:
+            raise HTTPException(status_code=404, detail="No content for subpage")
+        content_lc = content.lstrip().lower()
+        title = f"{subpage.link_name(project) or subpage_name} - {project}"
+        if content_lc.startswith("<!doctype html") or "<html" in content_lc:
+            if raw:
+                stylesheet_href = "/static/main.css"
+                link_tag = f'<link rel="stylesheet" href="{stylesheet_href}">'
+                if "<head" in content.lower():
+                    return HTMLResponse(content.replace("</head>", f"{link_tag}</head>", 1))
+                return HTMLResponse(f"{link_tag}{content}")
+            project_enc = quote(project, safe="/")
+            subpage_enc = quote(subpage_name, safe="")
+            return render_page(
+                "subpage.html",
+                title=title,
+                project=project,
+                subpage_name=subpage_name,
+                iframe_src=f"/project/{project_enc}/subpage/{subpage_enc}?raw=true",
+            )
+        return render_page(
+            "subpage.html",
+            title=title,
+            project=project,
+            subpage_name=subpage_name,
+            content=trusted_html(content),
+        )
+    raise HTTPException(status_code=404, detail="Unknown subpage")
 
 
 @app.post("/project/{project:path}/hook", response_class=JSONResponse)
 def project_hook(
-	project: ValidatedReadableProject,
-	name: Annotated[str, Query(alias="name")],
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	op: Annotated[HookInstallOp, Query(alias="op")] = "check",
+    project: ValidatedReadableProject,
+    name: Annotated[str, Query(alias="name")],
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    op: Annotated[HookInstallOp, Query(alias="op")] = "check",
 ) -> JSONResponse:
-	"""Add, remove, or check a pygittools hook sample (or bundle of samples) for a project.
-
-	`name` may be a sample filename (e.g. `post-receive.notify`) or a bundle name
-	(e.g. `update`, which installs both `post-commit.notify` and `post-receive.notify`).
-	`op` is `add`, `remove`, or `check`.
-	"""
-	if op != "check":
-		ensure_permission(Permission.HOOKS, project, token=token)
-	bundle = get_bundle(name)
-	sample = get_sample(name) if bundle is None else None
-	if bundle is None and sample is None:
-		raise HTTPException(status_code=404, detail=f"Unknown hook sample or bundle: {name}")
-	try:
-		if bundle is not None:
-			if op == "add":
-				result_status: HookStatus = install_hook_bundle(project, name)
-				if result_status == HookStatus.DIFFERENT:
-					raise HTTPException(
-						status_code=409,
-						detail="One or more custom hooks installed; refusing to add",
-					)
-			elif op == "remove":
-				result_status = remove_hook_bundle(project, name)
-				if result_status == HookStatus.DIFFERENT:
-					raise HTTPException(
-						status_code=409,
-						detail="One or more custom hooks installed; refusing to remove",
-					)
-			else:
-				result_status = bundle_status(project, name)
-		else:
-			assert sample is not None
-			if op == "add":
-				result_status = install_hook(project, name)
-				if result_status == HookStatus.DIFFERENT:
-					raise HTTPException(
-						status_code=409,
-						detail="A different hook is already installed at this path; refusing to overwrite",
-					)
-			elif op == "remove":
-				result_status = remove_hook(project, name)
-				if result_status == HookStatus.DIFFERENT:
-					raise HTTPException(
-						status_code=409,
-						detail="Installed hook content differs from the sample; refusing to remove",
-					)
-			else:
-				result_status = hook_status(project, name)
-	except OSError as e:
-		raise HTTPException(status_code=500, detail=str(e)) from e
-	if bundle is not None:
-		body: dict[str, object] = {
-			"project": project,
-			"kind": "bundle",
-			"name": name,
-			"label": bundle["label"],
-			"members": [m["name"] for m in bundle["members"]],
-			"op": op,
-			"status": result_status.value,
-			"installed": result_status == HookStatus.INSTALLED,
-		}
-	else:
-		assert sample is not None
-		body = {
-			"project": project,
-			"kind": "sample",
-			"name": name,
-			"target": sample["target"],
-			"label": sample["label"],
-			"op": op,
-			"status": result_status.value,
-			"installed": result_status == HookStatus.INSTALLED,
-		}
-	return JSONResponse(body)
+    """Add, remove, or check a pygittools hook sample (or bundle of samples) for a project.
+
+    `name` may be a sample filename (e.g. `post-receive.notify`) or a bundle name
+    (e.g. `update`, which installs both `post-commit.notify` and `post-receive.notify`).
+    `op` is `add`, `remove`, or `check`.
+    """
+    if op != "check":
+        ensure_permission(Permission.HOOKS, project, token=token)
+    bundle = get_bundle(name)
+    sample = get_sample(name) if bundle is None else None
+    if bundle is None and sample is None:
+        raise HTTPException(status_code=404, detail=f"Unknown hook sample or bundle: {name}")
+    try:
+        if bundle is not None:
+            if op == "add":
+                result_status: HookStatus = install_hook_bundle(project, name)
+                if result_status == HookStatus.DIFFERENT:
+                    raise HTTPException(
+                        status_code=409,
+                        detail="One or more custom hooks installed; refusing to add",
+                    )
+            elif op == "remove":
+                result_status = remove_hook_bundle(project, name)
+                if result_status == HookStatus.DIFFERENT:
+                    raise HTTPException(
+                        status_code=409,
+                        detail="One or more custom hooks installed; refusing to remove",
+                    )
+            else:
+                result_status = bundle_status(project, name)
+        else:
+            assert sample is not None
+            if op == "add":
+                result_status = install_hook(project, name)
+                if result_status == HookStatus.DIFFERENT:
+                    raise HTTPException(
+                        status_code=409,
+                        detail="A different hook is already installed at this path; refusing to overwrite",
+                    )
+            elif op == "remove":
+                result_status = remove_hook(project, name)
+                if result_status == HookStatus.DIFFERENT:
+                    raise HTTPException(
+                        status_code=409,
+                        detail="Installed hook content differs from the sample; refusing to remove",
+                    )
+            else:
+                result_status = hook_status(project, name)
+    except OSError as e:
+        raise HTTPException(status_code=500, detail=str(e)) from e
+    if bundle is not None:
+        body: dict[str, object] = {
+            "project": project,
+            "kind": "bundle",
+            "name": name,
+            "label": bundle["label"],
+            "members": [m["name"] for m in bundle["members"]],
+            "op": op,
+            "status": result_status.value,
+            "installed": result_status == HookStatus.INSTALLED,
+        }
+    else:
+        assert sample is not None
+        body = {
+            "project": project,
+            "kind": "sample",
+            "name": name,
+            "target": sample["target"],
+            "label": sample["label"],
+            "op": op,
+            "status": result_status.value,
+            "installed": result_status == HookStatus.INSTALLED,
+        }
+    return JSONResponse(body)
 
 
 @app.get("/project/{project:path}/hooks", response_class=JSONResponse)
 def project_hooks_list(project: ValidatedReadableProject) -> JSONResponse:
-	"""List all hook samples and bundles with current status in this project's hooks dir."""
-	samples_out: list[dict[str, object]] = []
-	for sample in list_samples():
-		try:
-			st: HookStatus = hook_status(project, sample["name"])
-		except KeyError:
-			continue
-		samples_out.append({
-			"name": sample["name"],
-			"target": sample["target"],
-			"label": sample["label"],
-			"status": st.value,
-			"installed": st == HookStatus.INSTALLED,
-		})
-	bundles_out: list[dict[str, object]] = []
-	for bundle in list_bundles():
-		try:
-			bst: HookStatus = bundle_status(project, bundle["name"])
-		except KeyError:
-			continue
-		bundles_out.append({
-			"name": bundle["name"],
-			"label": bundle["label"],
-			"members": [m["name"] for m in bundle["members"]],
-			"status": bst.value,
-			"installed": bst == HookStatus.INSTALLED,
-		})
-	return JSONResponse({"project": project, "hooks": samples_out, "bundles": bundles_out})
+    """List all hook samples and bundles with current status in this project's hooks dir."""
+    samples_out: list[dict[str, object]] = []
+    for sample in list_samples():
+        try:
+            st: HookStatus = hook_status(project, sample["name"])
+        except KeyError:
+            continue
+        samples_out.append({
+            "name": sample["name"],
+            "target": sample["target"],
+            "label": sample["label"],
+            "status": st.value,
+            "installed": st == HookStatus.INSTALLED,
+        })
+    bundles_out: list[dict[str, object]] = []
+    for bundle in list_bundles():
+        try:
+            bst: HookStatus = bundle_status(project, bundle["name"])
+        except KeyError:
+            continue
+        bundles_out.append({
+            "name": bundle["name"],
+            "label": bundle["label"],
+            "members": [m["name"] for m in bundle["members"]],
+            "status": bst.value,
+            "installed": bst == HookStatus.INSTALLED,
+        })
+    return JSONResponse({"project": project, "hooks": samples_out, "bundles": bundles_out})
 
 
 @app.post("/_internal/notify", response_class=JSONResponse)
 async def internal_notify(
-	_: Annotated[None, Depends(require_loopback_client)],
-	project: ValidatedNotifyProject,
+    _: Annotated[None, Depends(require_loopback_client)],
+    project: ValidatedNotifyProject,
 ) -> JSONResponse:
-	"""Notify long-polling subscribers that a project's refs changed.
+    """Notify long-polling subscribers that a project's refs changed.
 
-	Intended for server-side hooks (pre-receive / post-receive) running on the same host.
-	"""
-	woken = await CHANGE_QUEUE.notify(project)
-	return JSONResponse({"project": project, "waiters_woken": woken})
+    Intended for server-side hooks (pre-receive / post-receive) running on the same host.
+    """
+    woken = await CHANGE_QUEUE.notify(project)
+    return JSONResponse({"project": project, "waiters_woken": woken})
 
 
 @app.get("/project/{project:path}", response_class=HTMLResponse)
 async def dispatch(
-	request: Request,
-	project: ValidatedReadableProject,
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	a: Annotated[str | None, Query(alias="a")] = None,
-	h: Annotated[str | None, Query(alias="h")] = None,
-	hb: Annotated[str | None, Query(alias="hb")] = None,
-	f: Annotated[str | None, Query(alias="f")] = None,
-	fp: Annotated[str | None, Query(alias="fp")] = None,
-	page: Annotated[str | None, Query(alias="page")] = None,
-	pagecount: Annotated[str | None, Query(alias="pagecount")] = None,
-	updates: Annotated[str | None, Query(alias="updates")] = None,
-	pf: Annotated[str | None, Query(alias="pf")] = None,
-	search_patterns: Annotated[str | None, Query(alias="patterns")] = None,
-	search_paths: Annotated[str | None, Query(alias="paths")] = None,
-	search_globs: Annotated[str | None, Query(alias="globs")] = None,
-	search_heading: Annotated[SearchFlagQuery | None, Query(alias="heading")] = None,
-	search_multiline: Annotated[SearchFlagQuery | None, Query(alias="multiline")] = None,
-	search_sort: Annotated[SearchSortQuery | None, Query(alias="sort")] = None,
-	search_max_count: Annotated[str | None, Query(alias="max_count")] = None,
-	notice: Annotated[str | None, Query(alias="notice")] = None,
+    request: Request,
+    project: ValidatedReadableProject,
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    a: Annotated[str | None, Query(alias="a")] = None,
+    h: Annotated[str | None, Query(alias="h")] = None,
+    hb: Annotated[str | None, Query(alias="hb")] = None,
+    f: Annotated[str | None, Query(alias="f")] = None,
+    fp: Annotated[str | None, Query(alias="fp")] = None,
+    page: Annotated[str | None, Query(alias="page")] = None,
+    pagecount: Annotated[str | None, Query(alias="pagecount")] = None,
+    updates: Annotated[str | None, Query(alias="updates")] = None,
+    pf: Annotated[str | None, Query(alias="pf")] = None,
+    search_patterns: Annotated[str | None, Query(alias="patterns")] = None,
+    search_paths: Annotated[str | None, Query(alias="paths")] = None,
+    search_globs: Annotated[str | None, Query(alias="globs")] = None,
+    search_heading: Annotated[SearchFlagQuery | None, Query(alias="heading")] = None,
+    search_multiline: Annotated[SearchFlagQuery | None, Query(alias="multiline")] = None,
+    search_sort: Annotated[SearchSortQuery | None, Query(alias="sort")] = None,
+    search_max_count: Annotated[str | None, Query(alias="max_count")] = None,
+    notice: Annotated[str | None, Query(alias="notice")] = None,
 ) -> Response:
-	"""
-	Dispatch by path: /project/{project} -> summary; /project/{project}/action/... -> action.
-	Port of dispatch + run_request path handling.
-	"""
-	action = a
-	hash_param = h or hb
-	file_name = f
-	file_parent = fp
-	# If no action, infer: hash only -> object type; project only -> summary
-	if not action:
-		if hash_param and file_name:
-			obj_type = git_get_type(project, f"{hash_param}:{file_name}")
-			if not obj_type:
-				raise HTTPException(status_code=404, detail="File or directory does not exist")
-			action = "tree" if obj_type == "tree" else "blob_plain"
-		elif hash_param:
-			obj_type = git_get_type(project, hash_param)
-			if not obj_type:
-				raise HTTPException(status_code=404, detail="Object does not exist")
-			action = {
-				"commit": "commit",
-				"tree": "tree",
-				"blob": "blob",
-				"tag": "tag",
-			}.get(obj_type, "object")
-		else:
-			action = "summary"
-	if action not in DISPATCH_ACTIONS:
-		raise HTTPException(status_code=400, detail="Unknown action")
-	if action in ("opml", "project_list", "project_index"):
-		raise HTTPException(status_code=400, detail="Project not needed for this action")
-	if _parse_updates_flag(updates):
-		if action not in UPDATES_SUPPORTED_ACTIONS:
-			raise HTTPException(status_code=400, detail="updates not supported for this action")
-		subscribed = _resolve_subscribed_projects(project, pf)
-		if getattr(request.app.state, "shutting_down", False):
-			return _updates_idle_response()
-		shutdown_ev: asyncio.Event | None = getattr(request.app.state, "shutdown_event", None)
-		notified = await CHANGE_QUEUE.wait_for_changes(
-			subscribed,
-			timeout=CHANGE_QUEUE.DEFAULT_TIMEOUT_SECONDS,
-			shutdown_event=shutdown_ev,
-		)
-		if notified is None:
-			return _updates_idle_response()
-	if action in PLUGIN_ACTIONS:
-		plugin = PLUGIN_ACTIONS[action]
-		result = plugin.action(project, request)
-		if result is not None:
-			return result
-		return render_page(
-			"message.html",
-			title=f"{plugin.action_name()} - {project}",
-			paragraphs=["Action completed."],
-		)
-	if action == "summary":
-		return git_summary(project, extra_rows=_project_subpage_rows(project))
-	if action == "tree":
-		return git_tree(project, hash_param, file_name)
-	if action in ("blob", "blob_plain"):
-		return git_blob(project, hash_param, file_name, raw=(action == "blob_plain"))
-	if action == "blame":
-		return git_blame(project, hash_param, file_name)
-	if action == "blame_raw":
-		return git_blame_raw(project, hash_param, file_name)
-	if action == "blobdiff":
-		return git_blobdiff(project, h, hb, file_name, file_parent)
-	if action == "blobpatch":
-		return git_blobpatch(project, h, hb, file_name, file_parent)
-	if action == "log":
-		p, pc = parse_pagination(page, pagecount)
-		return git_log(project, hash_param, request, p, pc)
-	if action == "shortlog":
-		p, pc = parse_pagination(page, pagecount)
-		return git_shortlog(project, hash_param, request, p, pc)
-	if action == "history":
-		p, pc = parse_pagination(page, pagecount)
-		return git_history(project, hash_param, file_name, request, p, pc)
-	if action == "heads":
-		return git_heads(project)
-	if action == "tags":
-		p, pc = parse_pagination(page, pagecount)
-		return git_tags(project, request, p, pc)
-	if action == "tag":
-		return git_tag(project, hash_param, notice=notice)
-	if action == "commit":
-		return git_commit(project, hash_param)
-	if action == "patch":
-		return git_patch(project, h)
-	if action == "patches":
-		return git_patches(project, h, hb)
-	if action == "commitdiff":
-		return git_commitdiff(project, hash_param)
-	if action == "remotes":
-		return git_remotes(project)
-	if action == "object":
-		return git_object(project, hash_param)
-	if action == "search":
-		return git_search(
-			project,
-			search_patterns,
-			search_paths,
-			search_globs,
-			search_heading,
-			search_multiline,
-			search_sort,
-			search_max_count,
-		)
-	# Stub others with minimal response
-	return render_page(
-		"message.html",
-		title=f"{action} - {project}",
-		paragraphs=[f"Action: {action}", f"Project: {project}"],
-	)
+    """
+    Dispatch by path: /project/{project} -> summary; /project/{project}/action/... -> action.
+    Port of dispatch + run_request path handling.
+    """
+    action = a
+    hash_param = h or hb
+    file_name = f
+    file_parent = fp
+    # If no action, infer: hash only -> object type; project only -> summary
+    if not action:
+        if hash_param and file_name:
+            obj_type = git_get_type(project, f"{hash_param}:{file_name}")
+            if not obj_type:
+                raise HTTPException(status_code=404, detail="File or directory does not exist")
+            action = "tree" if obj_type == "tree" else "blob_plain"
+        elif hash_param:
+            obj_type = git_get_type(project, hash_param)
+            if not obj_type:
+                raise HTTPException(status_code=404, detail="Object does not exist")
+            action = {
+                "commit": "commit",
+                "tree": "tree",
+                "blob": "blob",
+                "tag": "tag",
+            }.get(obj_type, "object")
+        else:
+            action = "summary"
+    if action not in DISPATCH_ACTIONS:
+        raise HTTPException(status_code=400, detail="Unknown action")
+    if action in ("opml", "project_list", "project_index"):
+        raise HTTPException(status_code=400, detail="Project not needed for this action")
+    if _parse_updates_flag(updates):
+        if action not in UPDATES_SUPPORTED_ACTIONS:
+            raise HTTPException(status_code=400, detail="updates not supported for this action")
+        subscribed = _resolve_subscribed_projects(project, pf)
+        if getattr(request.app.state, "shutting_down", False):
+            return _updates_idle_response()
+        shutdown_ev: asyncio.Event | None = getattr(request.app.state, "shutdown_event", None)
+        notified = await CHANGE_QUEUE.wait_for_changes(
+            subscribed,
+            timeout=CHANGE_QUEUE.DEFAULT_TIMEOUT_SECONDS,
+            shutdown_event=shutdown_ev,
+        )
+        if notified is None:
+            return _updates_idle_response()
+    if action in PLUGIN_ACTIONS:
+        plugin = PLUGIN_ACTIONS[action]
+        result = plugin.action(project, request)
+        if result is not None:
+            return result
+        return render_page(
+            "message.html",
+            title=f"{plugin.action_name()} - {project}",
+            paragraphs=["Action completed."],
+        )
+    if action == "summary":
+        return git_summary(project, extra_rows=_project_subpage_rows(project))
+    if action == "tree":
+        return git_tree(project, hash_param, file_name)
+    if action in ("blob", "blob_plain"):
+        return git_blob(project, hash_param, file_name, raw=(action == "blob_plain"))
+    if action == "blame":
+        return git_blame(project, hash_param, file_name)
+    if action == "blame_raw":
+        return git_blame_raw(project, hash_param, file_name)
+    if action == "blobdiff":
+        return git_blobdiff(project, h, hb, file_name, file_parent)
+    if action == "blobpatch":
+        return git_blobpatch(project, h, hb, file_name, file_parent)
+    if action == "log":
+        p, pc = parse_pagination(page, pagecount)
+        return git_log(project, hash_param, request, p, pc)
+    if action == "shortlog":
+        p, pc = parse_pagination(page, pagecount)
+        return git_shortlog(project, hash_param, request, p, pc)
+    if action == "history":
+        p, pc = parse_pagination(page, pagecount)
+        return git_history(project, hash_param, file_name, request, p, pc)
+    if action == "heads":
+        return git_heads(project)
+    if action == "tags":
+        p, pc = parse_pagination(page, pagecount)
+        return git_tags(project, request, p, pc)
+    if action == "tag":
+        return git_tag(project, hash_param, notice=notice)
+    if action == "commit":
+        return git_commit(project, hash_param)
+    if action == "patch":
+        return git_patch(project, h)
+    if action == "patches":
+        return git_patches(project, h, hb)
+    if action == "commitdiff":
+        return git_commitdiff(project, hash_param)
+    if action == "remotes":
+        return git_remotes(project)
+    if action == "object":
+        return git_object(project, hash_param)
+    if action == "search":
+        return git_search(
+            project,
+            search_patterns,
+            search_paths,
+            search_globs,
+            search_heading,
+            search_multiline,
+            search_sort,
+            search_max_count,
+        )
+    # Stub others with minimal response
+    return render_page(
+        "message.html",
+        title=f"{action} - {project}",
+        paragraphs=[f"Action: {action}", f"Project: {project}"],
+    )
 
 
 if __name__ == "__main__":
-	import uvicorn
+    import uvicorn
 
-	uvicorn.run(app, host="0.0.0.0", port=8000)
+    uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/pygitweb/src/pygitweb/merge_requests.py b/pygitweb/src/pygitweb/merge_requests.py
index 23b19a4..de02916 100644
--- a/pygitweb/src/pygitweb/merge_requests.py
+++ b/pygitweb/src/pygitweb/merge_requests.py
@@ -20,214 +20,214 @@ from pygitweb.validation import is_valid_ref_format
 
 
 def _branch_short_name(ref: str) -> str:
-	return ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
+    return ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
 
 
 merge_router = APIRouter(tags=["merge_requests"])
 
 _STATUS_LABELS: dict[MergeRequestStatus, str] = {
-	MergeRequestStatus.CAN_FAST_FORWARD: "Can fast-forward",
-	MergeRequestStatus.UP_TO_DATE: "Already up to date",
-	MergeRequestStatus.BLOCKED_DIVERGED: "Blocked: histories diverged (not fast-forward)",
-	MergeRequestStatus.OURS_REF_MISSING: "Blocked: ours ref is missing",
-	MergeRequestStatus.MR_HEAD_MISSING: "Blocked: merge request head is missing from the repository",
+    MergeRequestStatus.CAN_FAST_FORWARD: "Can fast-forward",
+    MergeRequestStatus.UP_TO_DATE: "Already up to date",
+    MergeRequestStatus.BLOCKED_DIVERGED: "Blocked: histories diverged (not fast-forward)",
+    MergeRequestStatus.OURS_REF_MISSING: "Blocked: ours ref is missing",
+    MergeRequestStatus.MR_HEAD_MISSING: "Blocked: merge request head is missing from the repository",
 }
 
 
 def try_merge_request_from_tag(repo: pygit2.Repository, tag: pygit2.Tag) -> MergeRequest | None:
-	try:
-		return get_merge_request_by_oid(repo, tag.id)
-	except (ValueError, json.JSONDecodeError, TypeError, KeyError):
-		return None
+    try:
+        return get_merge_request_by_oid(repo, tag.id)
+    except (ValueError, json.JSONDecodeError, TypeError, KeyError):
+        return None
 
 
 def resolve_merge_request_tag_to_tip(
-	repo: pygit2.Repository, obj: pygit2.Tag
+    repo: pygit2.Repository, obj: pygit2.Tag
 ) -> tuple[pygit2.Tag, MergeRequest] | None:
-	"""If ``obj`` is an MR tag and ``refs/tags/mr/…`` points to a newer tag object, return that tip."""
-	mr = try_merge_request_from_tag(repo, obj)
-	if mr is None:
-		return None
-	if mr.name not in repo.references:
-		return obj, mr
-	tip_id = repo.references[mr.name].resolve().target
-	if tip_id == obj.id:
-		return obj, mr
-	tip_obj = repo[tip_id]
-	if not isinstance(tip_obj, pygit2.Tag):
-		return obj, mr
-	mr_tip = try_merge_request_from_tag(repo, tip_obj)
-	if mr_tip is None:
-		return obj, mr
-	return tip_obj, mr_tip
+    """If ``obj`` is an MR tag and ``refs/tags/mr/…`` points to a newer tag object, return that tip."""
+    mr = try_merge_request_from_tag(repo, obj)
+    if mr is None:
+        return None
+    if mr.name not in repo.references:
+        return obj, mr
+    tip_id = repo.references[mr.name].resolve().target
+    if tip_id == obj.id:
+        return obj, mr
+    tip_obj = repo[tip_id]
+    if not isinstance(tip_obj, pygit2.Tag):
+        return obj, mr
+    mr_tip = try_merge_request_from_tag(repo, tip_obj)
+    if mr_tip is None:
+        return obj, mr
+    return tip_obj, mr_tip
 
 
 def merge_request_status_fields(repo: pygit2.Repository, mr: MergeRequest) -> dict[str, Any]:
-	try:
-		status = mr.check_status(repo)
-	except ValueError as exc:
-		return {
-			"status_code": "error",
-			"status_label": "Unable to analyze merge",
-			"status_detail": str(exc),
-			"show_merge_button": False,
-		}
-	label = _STATUS_LABELS.get(status, status.value)
-	return {
-		"status_code": status.value,
-		"status_label": label,
-		"status_detail": "",
-		"show_merge_button": status == MergeRequestStatus.CAN_FAST_FORWARD,
-	}
+    try:
+        status = mr.check_status(repo)
+    except ValueError as exc:
+        return {
+            "status_code": "error",
+            "status_label": "Unable to analyze merge",
+            "status_detail": str(exc),
+            "show_merge_button": False,
+        }
+    label = _STATUS_LABELS.get(status, status.value)
+    return {
+        "status_code": status.value,
+        "status_label": label,
+        "status_detail": "",
+        "show_merge_button": status == MergeRequestStatus.CAN_FAST_FORWARD,
+    }
 
 
 def _head_view_url(project: str, repo: pygit2.Repository, head_hex: str) -> tuple[str, str]:
-	try:
-		oid = pygit2.Oid(hex=head_hex)
-		obj = repo[oid]
-	except (KeyError, ValueError, TypeError):
-		return head_hex[:7], ""
-	t = getattr(obj, "type_str", "") or ""
-	short = head_hex[:7]
-	if t == "commit":
-		return short, f"/project/{project}?a=commit&h={quote(head_hex, safe='')}"
-	if t == "tag":
-		return short, f"/project/{project}?a=tag&h={quote(head_hex, safe='')}"
-	return short, ""
+    try:
+        oid = pygit2.Oid(hex=head_hex)
+        obj = repo[oid]
+    except (KeyError, ValueError, TypeError):
+        return head_hex[:7], ""
+    t = getattr(obj, "type_str", "") or ""
+    short = head_hex[:7]
+    if t == "commit":
+        return short, f"/project/{project}?a=commit&h={quote(head_hex, safe='')}"
+    if t == "tag":
+        return short, f"/project/{project}?a=tag&h={quote(head_hex, safe='')}"
+    return short, ""
 
 
 def merge_request_tag_response(
-	project: str,
-	repo: pygit2.Repository,
-	mr: MergeRequest,
-	tag_name: str,
-	tag_oid: str,
-	tagger: str,
-	tagger_date: str,
-	notice: str | None = None,
+    project: str,
+    repo: pygit2.Repository,
+    mr: MergeRequest,
+    tag_name: str,
+    tag_oid: str,
+    tagger: str,
+    tagger_date: str,
+    notice: str | None = None,
 ) -> HTMLResponse | RedirectResponse:
-	new_oid = mr.refresh_from_theirs(repo)
-	if new_oid is not None:
-		return RedirectResponse(
-			url=f"/project/{project}?a=tag&h={quote(str(new_oid), safe='')}",
-			status_code=302,
-		)
-
-	status_fields = merge_request_status_fields(repo, mr)
-	head_short, head_url = _head_view_url(project, repo, str(mr.target))
-	ours_ref = mr.ours
-	ours_url = f"/project/{project}?a=log&h={quote(ours_ref, safe='')}"
-	theirs_ref = mr.theirs or ""
-	theirs_url = f"/project/{project}?a=log&h={quote(theirs_ref, safe='')}" if mr.theirs else ""
-
-	comments_rows: list[tuple[str, str]] = []
-	for c in mr.comments:
-		ch = str(c)
-		comments_rows.append((ch[:7], f"/project/{project}?a=tag&h={quote(ch, safe='')}"))
-
-	current_tag_oid = str(repo.references[mr.name].resolve().target)
-
-	ctx = {
-		"project": project,
-		"project_url": f"/project/{project}",
-		"tag_name": tag_name,
-		"tag_oid": current_tag_oid,
-		"tag_view_url": f"/project/{project}?a=tag&h={quote(current_tag_oid, safe='')}",
-		"mr_title": mr.title,
-		"mr_description": mr.description or "",
-		"ours_ref": ours_ref,
-		"ours_url": ours_url,
-		"theirs_ref": theirs_ref,
-		"theirs_url": theirs_url,
-		"head_short": head_short,
-		"head_url": head_url,
-		"head_full": str(mr.target),
-		"tagger": tagger,
-		"tagger_date": tagger_date,
-		"opener": str(mr.opener),
-		"created_at": mr.created_at.isoformat() if mr.created_at else "",
-		"updated_at": mr.updated_at.isoformat() if mr.updated_at else "",
-		"comments": comments_rows,
-		"merge_action_url": f"/mr/ff?project={quote(project, safe='')}&h={quote(current_tag_oid, safe='')}",
-		"notice": notice or "",
-		**status_fields,
-	}
-	title = f"Merge request: {mr.title or ''} — {project or ''}"
-	return render_page("merge_request_tag.html", title=title, **ctx)
+    new_oid = mr.refresh_from_theirs(repo)
+    if new_oid is not None:
+        return RedirectResponse(
+            url=f"/project/{project}?a=tag&h={quote(str(new_oid), safe='')}",
+            status_code=302,
+        )
+
+    status_fields = merge_request_status_fields(repo, mr)
+    head_short, head_url = _head_view_url(project, repo, str(mr.target))
+    ours_ref = mr.ours
+    ours_url = f"/project/{project}?a=log&h={quote(ours_ref, safe='')}"
+    theirs_ref = mr.theirs or ""
+    theirs_url = f"/project/{project}?a=log&h={quote(theirs_ref, safe='')}" if mr.theirs else ""
+
+    comments_rows: list[tuple[str, str]] = []
+    for c in mr.comments:
+        ch = str(c)
+        comments_rows.append((ch[:7], f"/project/{project}?a=tag&h={quote(ch, safe='')}"))
+
+    current_tag_oid = str(repo.references[mr.name].resolve().target)
+
+    ctx = {
+        "project": project,
+        "project_url": f"/project/{project}",
+        "tag_name": tag_name,
+        "tag_oid": current_tag_oid,
+        "tag_view_url": f"/project/{project}?a=tag&h={quote(current_tag_oid, safe='')}",
+        "mr_title": mr.title,
+        "mr_description": mr.description or "",
+        "ours_ref": ours_ref,
+        "ours_url": ours_url,
+        "theirs_ref": theirs_ref,
+        "theirs_url": theirs_url,
+        "head_short": head_short,
+        "head_url": head_url,
+        "head_full": str(mr.target),
+        "tagger": tagger,
+        "tagger_date": tagger_date,
+        "opener": str(mr.opener),
+        "created_at": mr.created_at.isoformat() if mr.created_at else "",
+        "updated_at": mr.updated_at.isoformat() if mr.updated_at else "",
+        "comments": comments_rows,
+        "merge_action_url": f"/mr/ff?project={quote(project, safe='')}&h={quote(current_tag_oid, safe='')}",
+        "notice": notice or "",
+        **status_fields,
+    }
+    title = f"Merge request: {mr.title or ''} — {project or ''}"
+    return render_page("merge_request_tag.html", title=title, **ctx)
 
 
 @merge_router.post(
-	"/create",
-	dependencies=[Depends(require_permission(Permission.MR_CREATE, project_from_form=True))],
+    "/create",
+    dependencies=[Depends(require_permission(Permission.MR_CREATE, project_from_form=True))],
 )
 def merge_request_create(
-	project: ValidatedReadableFormProject,
-	theirs: str = Form(...),
-	ours: str = Form(...),
-	title: str = Form(""),
-	token: Annotated[str | None, Depends(access_token_from_request)] = None,
+    project: ValidatedReadableFormProject,
+    theirs: str = Form(...),
+    ours: str = Form(...),
+    title: str = Form(""),
+    token: Annotated[str | None, Depends(access_token_from_request)] = None,
 ) -> RedirectResponse:
-	theirs_ref = theirs.strip()
-	ours_ref = ours.strip()
-	if not theirs_ref or not ours_ref:
-		raise HTTPException(status_code=400, detail="theirs and ours are required")
-	if not is_valid_ref_format(theirs_ref) or not is_valid_ref_format(ours_ref):
-		raise HTTPException(status_code=400, detail="Invalid ref format")
-	repo = open_repo(project)
-	try:
-		theirs_tip = repo.revparse_single(theirs_ref).peel(pygit2.Commit).id
-	except (KeyError, ValueError, pygit2.GitError) as exc:
-		raise HTTPException(status_code=404, detail=f"Cannot resolve theirs ref {theirs_ref!r}") from exc
-	try:
-		repo.revparse_single(ours_ref)
-	except (KeyError, ValueError, pygit2.GitError) as exc:
-		raise HTTPException(status_code=404, detail=f"Cannot resolve ours ref {ours_ref!r}") from exc
-	opener, opener_warning = resolve_mr_opener(repo, principal_from_session(token))
-	t = title.strip()
-	if not t:
-		t = f"Merge {_branch_short_name(theirs_ref)} into {_branch_short_name(ours_ref)}"
-	mr = MergeRequest(
-		target=theirs_tip,
-		opener=opener,
-		ours=ours_ref,
-		theirs=theirs_ref,
-		title=t,
-		description="",
-	)
-	oid = mr.write(repo)
-	params: dict[str, str] = {"a": "tag", "h": str(oid)}
-	if opener_warning:
-		params["notice"] = opener_warning
-	dest = f"/project/{project}?{urlencode(params)}"
-	return RedirectResponse(url=dest, status_code=303)
+    theirs_ref = theirs.strip()
+    ours_ref = ours.strip()
+    if not theirs_ref or not ours_ref:
+        raise HTTPException(status_code=400, detail="theirs and ours are required")
+    if not is_valid_ref_format(theirs_ref) or not is_valid_ref_format(ours_ref):
+        raise HTTPException(status_code=400, detail="Invalid ref format")
+    repo = open_repo(project)
+    try:
+        theirs_tip = repo.revparse_single(theirs_ref).peel(pygit2.Commit).id
+    except (KeyError, ValueError, pygit2.GitError) as exc:
+        raise HTTPException(status_code=404, detail=f"Cannot resolve theirs ref {theirs_ref!r}") from exc
+    try:
+        repo.revparse_single(ours_ref)
+    except (KeyError, ValueError, pygit2.GitError) as exc:
+        raise HTTPException(status_code=404, detail=f"Cannot resolve ours ref {ours_ref!r}") from exc
+    opener, opener_warning = resolve_mr_opener(repo, principal_from_session(token))
+    t = title.strip()
+    if not t:
+        t = f"Merge {_branch_short_name(theirs_ref)} into {_branch_short_name(ours_ref)}"
+    mr = MergeRequest(
+        target=theirs_tip,
+        opener=opener,
+        ours=ours_ref,
+        theirs=theirs_ref,
+        title=t,
+        description="",
+    )
+    oid = mr.write(repo)
+    params: dict[str, str] = {"a": "tag", "h": str(oid)}
+    if opener_warning:
+        params["notice"] = opener_warning
+    dest = f"/project/{project}?{urlencode(params)}"
+    return RedirectResponse(url=dest, status_code=303)
 
 
 @merge_router.post(
-	"/ff",
-	dependencies=[Depends(require_permission(Permission.MR_MERGE, project_from_query=True))],
+    "/ff",
+    dependencies=[Depends(require_permission(Permission.MR_MERGE, project_from_query=True))],
 )
 def merge_fast_forward(
-	project: ValidatedReadableQueryProject,
-	h: str = Query(..., description="Annotated tag object id (hex)"),
+    project: ValidatedReadableQueryProject,
+    h: str = Query(..., description="Annotated tag object id (hex)"),
 ) -> RedirectResponse:
-	if not h or not is_valid_ref_format(h):
-		raise HTTPException(status_code=400, detail="Invalid tag hash (h)")
-	repo = open_repo(project)
-	try:
-		obj = repo.revparse_single(h)
-	except (KeyError, pygit2.GitError, OSError) as exc:
-		raise HTTPException(status_code=404, detail="Tag not found") from exc
-	if not isinstance(obj, pygit2.Tag):
-		raise HTTPException(status_code=400, detail="Not an annotated tag")
-	resolved = resolve_merge_request_tag_to_tip(repo, obj)
-	if resolved is None:
-		raise HTTPException(status_code=400, detail="Not a merge request tag")
-	obj, mr = resolved
-	mr.refresh_from_theirs(repo)
-	try:
-		mr.merge(repo)
-	except ValueError as exc:
-		raise HTTPException(status_code=400, detail=str(exc)) from exc
-	tag_oid = str(repo.references[mr.name].resolve().target)
-	dest = f"/project/{project}?a=tag&h={quote(tag_oid, safe='')}"
-	return RedirectResponse(url=dest, status_code=303)
+    if not h or not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid tag hash (h)")
+    repo = open_repo(project)
+    try:
+        obj = repo.revparse_single(h)
+    except (KeyError, pygit2.GitError, OSError) as exc:
+        raise HTTPException(status_code=404, detail="Tag not found") from exc
+    if not isinstance(obj, pygit2.Tag):
+        raise HTTPException(status_code=400, detail="Not an annotated tag")
+    resolved = resolve_merge_request_tag_to_tip(repo, obj)
+    if resolved is None:
+        raise HTTPException(status_code=400, detail="Not a merge request tag")
+    obj, mr = resolved
+    mr.refresh_from_theirs(repo)
+    try:
+        mr.merge(repo)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    tag_oid = str(repo.references[mr.name].resolve().target)
+    dest = f"/project/{project}?a=tag&h={quote(tag_oid, safe='')}"
+    return RedirectResponse(url=dest, status_code=303)
diff --git a/pygitweb/src/pygitweb/merge_requests_test.py b/pygitweb/src/pygitweb/merge_requests_test.py
index da0e4f3..14b9b90 100644
--- a/pygitweb/src/pygitweb/merge_requests_test.py
+++ b/pygitweb/src/pygitweb/merge_requests_test.py
@@ -14,90 +14,90 @@ from pygitweb.main import app
 
 
 def _commit_chain(repo: pygit2.Repository) -> tuple[pygit2.Oid, pygit2.Oid]:
-	sig = pygit2.Signature("tester", "tester@example.com")
-	tb = repo.TreeBuilder()
-	tree = tb.write()
-	a = repo.create_commit(None, sig, sig, "a", tree, [])
-	repo.create_reference("refs/heads/main", a)
-	b = repo.create_commit(None, sig, sig, "b", tree, [a])
-	return a, b
+    sig = pygit2.Signature("tester", "tester@example.com")
+    tb = repo.TreeBuilder()
+    tree = tb.write()
+    a = repo.create_commit(None, sig, sig, "a", tree, [])
+    repo.create_reference("refs/heads/main", a)
+    b = repo.create_commit(None, sig, sig, "b", tree, [a])
+    return a, b
 
 
 class TestMergeRequestTagView:
-	@pytest.fixture
-	def mr_env(self, tmp_path: Path) -> Generator[dict[str, str], None, None]:
-		root: Path = tmp_path / "merge-requests-web"
-		root.mkdir()
-		repo_dir: Path = root / "demo"
-		repo_dir.mkdir()
-		repo = pygit2.init_repository(str(repo_dir), bare=False)
-		_a, b = _commit_chain(repo)
-		mr = MergeRequest(
-			b,
-			pygit2.Signature("tester", "tester@example.com"),
-			ours="refs/heads/main",
-			title="Feature MR",
-			description="Desc line",
-			name=f"{MR_REF_PREFIX}aaaabbbbccccdddd",
-		)
-		mr_oid = mr.write(repo)
-		projects_list: Path = root / "projects.list"
-		projects_list.write_text("demo tester\n", encoding="utf-8")
-		with (
-			patch.object(settings, "PROJECTROOT", str(root)),
-			patch.object(settings, "PROJECTS_LIST", str(projects_list)),
-			patch.object(settings, "STRICT_EXPORT", False),
-			patch.object(settings, "EXPORT_OK", ""),
-			patch.object(settings, "AUTH", False),
-		):
-			client = TestClient(app)
-			yield {
-				"client": client,
-				"project": "demo",
-				"mr_tag_oid": str(mr_oid),
-				"mr_ref": mr.name,
-				"tip_b": str(b),
-			}
+    @pytest.fixture
+    def mr_env(self, tmp_path: Path) -> Generator[dict[str, str], None, None]:
+        root: Path = tmp_path / "merge-requests-web"
+        root.mkdir()
+        repo_dir: Path = root / "demo"
+        repo_dir.mkdir()
+        repo = pygit2.init_repository(str(repo_dir), bare=False)
+        _a, b = _commit_chain(repo)
+        mr = MergeRequest(
+            b,
+            pygit2.Signature("tester", "tester@example.com"),
+            ours="refs/heads/main",
+            title="Feature MR",
+            description="Desc line",
+            name=f"{MR_REF_PREFIX}aaaabbbbccccdddd",
+        )
+        mr_oid = mr.write(repo)
+        projects_list: Path = root / "projects.list"
+        projects_list.write_text("demo tester\n", encoding="utf-8")
+        with (
+            patch.object(settings, "PROJECTROOT", str(root)),
+            patch.object(settings, "PROJECTS_LIST", str(projects_list)),
+            patch.object(settings, "STRICT_EXPORT", False),
+            patch.object(settings, "EXPORT_OK", ""),
+            patch.object(settings, "AUTH", False),
+        ):
+            client = TestClient(app)
+            yield {
+                "client": client,
+                "project": "demo",
+                "mr_tag_oid": str(mr_oid),
+                "mr_ref": mr.name,
+                "tip_b": str(b),
+            }
 
-	def test_summary_lists_open_merge_request_until_it_is_merged(self, mr_env: dict[str, str]) -> None:
-		client = mr_env["client"]
-		before = client.get(f"/project/{mr_env['project']}")
-		assert before.status_code == 200
-		assert "Feature MR" in before.text
-		assert "Can fast-forward" in before.text
-		assert "step-item" not in before.text
-		post = client.post(
-			"/mr/ff",
-			params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
-			follow_redirects=False,
-		)
-		assert post.status_code == 303
-		after = client.get(f"/project/{mr_env['project']}")
-		assert after.status_code == 200
-		assert "Feature MR" not in after.text
-		assert "No open merge requests." in after.text
+    def test_summary_lists_open_merge_request_until_it_is_merged(self, mr_env: dict[str, str]) -> None:
+        client = mr_env["client"]
+        before = client.get(f"/project/{mr_env['project']}")
+        assert before.status_code == 200
+        assert "Feature MR" in before.text
+        assert "Can fast-forward" in before.text
+        assert "step-item" not in before.text
+        post = client.post(
+            "/mr/ff",
+            params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
+            follow_redirects=False,
+        )
+        assert post.status_code == 303
+        after = client.get(f"/project/{mr_env['project']}")
+        assert after.status_code == 200
+        assert "Feature MR" not in after.text
+        assert "No open merge requests." in after.text
 
-	def test_tag_action_renders_merge_request_template(self, mr_env: dict[str, str]) -> None:
-		client = mr_env["client"]
-		resp = client.get(
-			f"/project/{mr_env['project']}",
-			params={"a": "tag", "h": mr_env["mr_tag_oid"]},
-		)
-		assert resp.status_code == 200
-		text = resp.text
-		assert "Merge request" in text
-		assert "Feature MR" in text
-		assert "Desc line" in text
-		assert "Can fast-forward" in text
-		assert "refs/heads/main" in text
+    def test_tag_action_renders_merge_request_template(self, mr_env: dict[str, str]) -> None:
+        client = mr_env["client"]
+        resp = client.get(
+            f"/project/{mr_env['project']}",
+            params={"a": "tag", "h": mr_env["mr_tag_oid"]},
+        )
+        assert resp.status_code == 200
+        text = resp.text
+        assert "Merge request" in text
+        assert "Feature MR" in text
+        assert "Desc line" in text
+        assert "Can fast-forward" in text
+        assert "refs/heads/main" in text
 
-	def test_merge_ff_updates_branch(self, mr_env: dict[str, str]) -> None:
-		client = mr_env["client"]
-		post = client.post(
-			"/mr/ff",
-			params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
-			follow_redirects=False,
-		)
-		assert post.status_code == 303
-		repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / mr_env["project"]))
-		assert repo.references["refs/heads/main"].resolve().target == pygit2.Oid(hex=mr_env["tip_b"])
+    def test_merge_ff_updates_branch(self, mr_env: dict[str, str]) -> None:
+        client = mr_env["client"]
+        post = client.post(
+            "/mr/ff",
+            params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
+            follow_redirects=False,
+        )
+        assert post.status_code == 303
+        repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / mr_env["project"]))
+        assert repo.references["refs/heads/main"].resolve().target == pygit2.Oid(hex=mr_env["tip_b"])
diff --git a/pygitweb/src/pygitweb/mr_permissions_test.py b/pygitweb/src/pygitweb/mr_permissions_test.py
index a984c35..fb332a9 100644
--- a/pygitweb/src/pygitweb/mr_permissions_test.py
+++ b/pygitweb/src/pygitweb/mr_permissions_test.py
@@ -20,208 +20,208 @@ from pygitweb.sessions import create_session
 
 
 def _commit_chain(repo: pygit2.Repository) -> tuple[pygit2.Oid, pygit2.Oid]:
-	sig = pygit2.Signature("tester", "tester@example.com")
-	tb = repo.TreeBuilder()
-	tree = tb.write()
-	a = repo.create_commit(None, sig, sig, "a", tree, [])
-	repo.create_reference("refs/heads/main", a)
-	b = repo.create_commit(None, sig, sig, "b", tree, [a])
-	return a, b
+    sig = pygit2.Signature("tester", "tester@example.com")
+    tb = repo.TreeBuilder()
+    tree = tb.write()
+    a = repo.create_commit(None, sig, sig, "a", tree, [])
+    repo.create_reference("refs/heads/main", a)
+    b = repo.create_commit(None, sig, sig, "b", tree, [a])
+    return a, b
 
 
 _MR_AUTH = AuthConfig(
-	auth_mode="local",
-	local_users=[LocalUser(user="creator", password="secret"), LocalUser(user="merger", password="secret")],
-	oauth_permissions=permissions_with_defaults({
-		permission_key(Permission.MR_CREATE, "demo"): ["creator", "creator@idp.example"],
-		permission_key(Permission.MR_MERGE, "demo"): ["merger"],
-	}),
+    auth_mode="local",
+    local_users=[LocalUser(user="creator", password="secret"), LocalUser(user="merger", password="secret")],
+    oauth_permissions=permissions_with_defaults({
+        permission_key(Permission.MR_CREATE, "demo"): ["creator", "creator@idp.example"],
+        permission_key(Permission.MR_MERGE, "demo"): ["merger"],
+    }),
 )
 
 
 def _build_client() -> TestClient:
-	app = FastAPI()
-	app.include_router(merge_router, prefix="/mr")
-	return TestClient(app)
+    app = FastAPI()
+    app.include_router(merge_router, prefix="/mr")
+    return TestClient(app)
 
 
 @pytest.fixture
 def mr_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
-	root = tmp_path / "mr-perms"
-	root.mkdir()
-	repo_dir = root / "demo"
-	repo_dir.mkdir()
-	repo = pygit2.init_repository(str(repo_dir), bare=False)
-	_a, b = _commit_chain(repo)
-	mr = MergeRequest(
-		b,
-		pygit2.Signature("tester", "tester@example.com"),
-		ours="refs/heads/main",
-		title="Feature MR",
-		name=f"{MR_REF_PREFIX}aaaabbbbccccdddd",
-	)
-	mr_oid = mr.write(repo)
-	projects_list = root / "projects.list"
-	projects_list.write_text("demo tester\n", encoding="utf-8")
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(projects_list)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch("pygitweb.auth_config.auth_config", _MR_AUTH),
-		patch("pygitweb.auth.auth_config", _MR_AUTH),
-	):
-		yield {
-			"client": _build_client(),
-			"project": "demo",
-			"mr_tag_oid": str(mr_oid),
-			"tip_b": str(b),
-		}
+    root = tmp_path / "mr-perms"
+    root.mkdir()
+    repo_dir = root / "demo"
+    repo_dir.mkdir()
+    repo = pygit2.init_repository(str(repo_dir), bare=False)
+    _a, b = _commit_chain(repo)
+    mr = MergeRequest(
+        b,
+        pygit2.Signature("tester", "tester@example.com"),
+        ours="refs/heads/main",
+        title="Feature MR",
+        name=f"{MR_REF_PREFIX}aaaabbbbccccdddd",
+    )
+    mr_oid = mr.write(repo)
+    projects_list = root / "projects.list"
+    projects_list.write_text("demo tester\n", encoding="utf-8")
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(projects_list)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch("pygitweb.auth_config.auth_config", _MR_AUTH),
+        patch("pygitweb.auth.auth_config", _MR_AUTH),
+    ):
+        yield {
+            "client": _build_client(),
+            "project": "demo",
+            "mr_tag_oid": str(mr_oid),
+            "tip_b": str(b),
+        }
 
 
 def test_mr_ff_open_when_auth_disabled(mr_env: dict[str, str]) -> None:
-	client = mr_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", False):
-		r = client.post(
-			"/mr/ff",
-			params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
-			follow_redirects=False,
-		)
-	assert r.status_code == 303
+    client = mr_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", False):
+        r = client.post(
+            "/mr/ff",
+            params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
+            follow_redirects=False,
+        )
+    assert r.status_code == 303
 
 
 def test_mr_create_401_without_login(mr_env: dict[str, str]) -> None:
-	client = mr_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True):
-		r = client.post(
-			"/mr/create",
-			data={
-				"project": mr_env["project"],
-				"theirs": "refs/heads/main",
-				"ours": "refs/heads/main",
-				"title": "MR",
-			},
-			follow_redirects=False,
-		)
-	assert r.status_code == 401
+    client = mr_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True):
+        r = client.post(
+            "/mr/create",
+            data={
+                "project": mr_env["project"],
+                "theirs": "refs/heads/main",
+                "ours": "refs/heads/main",
+                "title": "MR",
+            },
+            follow_redirects=False,
+        )
+    assert r.status_code == 401
 
 
 def test_mr_create_forbidden_without_create_grant(mr_env: dict[str, str]) -> None:
-	client = mr_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "merger"):
-		r = client.post(
-			"/mr/create",
-			data={
-				"project": mr_env["project"],
-				"theirs": "refs/heads/main",
-				"ours": "refs/heads/main",
-				"title": "MR",
-			},
-			follow_redirects=False,
-		)
-	assert r.status_code == 403
+    client = mr_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "merger"):
+        r = client.post(
+            "/mr/create",
+            data={
+                "project": mr_env["project"],
+                "theirs": "refs/heads/main",
+                "ours": "refs/heads/main",
+                "title": "MR",
+            },
+            follow_redirects=False,
+        )
+    assert r.status_code == 403
 
 
 def test_mr_ff_forbidden_without_merge_grant(mr_env: dict[str, str]) -> None:
-	client = mr_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "creator"):
-		r = client.post(
-			"/mr/ff",
-			params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
-			follow_redirects=False,
-		)
-	assert r.status_code == 403
+    client = mr_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "creator"):
+        r = client.post(
+            "/mr/ff",
+            params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
+            follow_redirects=False,
+        )
+    assert r.status_code == 403
 
 
 def test_mr_ff_allowed_with_merge_grant(mr_env: dict[str, str]) -> None:
-	client = mr_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "merger"):
-		r = client.post(
-			"/mr/ff",
-			params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
-			follow_redirects=False,
-		)
-	assert r.status_code == 303
+    client = mr_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "merger"):
+        r = client.post(
+            "/mr/ff",
+            params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
+            follow_redirects=False,
+        )
+    assert r.status_code == 303
 
 
 def _bare_repo_with_branch(tmp_path: Path) -> tuple[Path, pygit2.Oid, pygit2.Oid]:
-	root = tmp_path / "bare-mr-create"
-	root.mkdir()
-	repo_dir = root / "demo"
-	repo = pygit2.init_repository(str(repo_dir), bare=True)
-	a, b = _commit_chain(repo)
-	repo.create_reference("refs/heads/feature", b)
-	return root, a, b
+    root = tmp_path / "bare-mr-create"
+    root.mkdir()
+    repo_dir = root / "demo"
+    repo = pygit2.init_repository(str(repo_dir), bare=True)
+    a, b = _commit_chain(repo)
+    repo.create_reference("refs/heads/feature", b)
+    return root, a, b
 
 
 def test_mr_create_bare_repo_uses_principal_email(tmp_path: Path) -> None:
-	root, _a, _b = _bare_repo_with_branch(tmp_path)
-	projects_list = root / "projects.list"
-	projects_list.write_text("demo tester\n", encoding="utf-8")
-	client = _build_client()
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(projects_list)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch("pygitweb.auth_config.auth_config", _MR_AUTH),
-		patch("pygitweb.auth.auth_config", _MR_AUTH),
-		patch.object(settings, "AUTH", True),
-	):
-		client.cookies.set(
-			ACCESS_TOKEN_COOKIE_NAME,
-			create_session("creator", email="creator@idp.example", auth_method="local"),
-		)
-		r = client.post(
-			"/mr/create",
-			data={
-				"project": "demo",
-				"theirs": "refs/heads/feature",
-				"ours": "refs/heads/main",
-				"title": "Bare MR",
-			},
-			follow_redirects=False,
-		)
-	assert r.status_code == 303
-	location = r.headers["location"]
-	assert "notice=" not in location
-	repo = pygit2.Repository(str(root / "demo"))
-	for ref_name in repo.references:
-		if ref_name.startswith("refs/tags/mr/"):
-			tag = repo[repo.references[ref_name].resolve().target]
-			assert isinstance(tag, pygit2.Tag)
-			assert tag.tagger is not None
-			assert tag.tagger.email == "creator@idp.example"
-			return
-	raise AssertionError("merge request tag not created")
+    root, _a, _b = _bare_repo_with_branch(tmp_path)
+    projects_list = root / "projects.list"
+    projects_list.write_text("demo tester\n", encoding="utf-8")
+    client = _build_client()
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(projects_list)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch("pygitweb.auth_config.auth_config", _MR_AUTH),
+        patch("pygitweb.auth.auth_config", _MR_AUTH),
+        patch.object(settings, "AUTH", True),
+    ):
+        client.cookies.set(
+            ACCESS_TOKEN_COOKIE_NAME,
+            create_session("creator", email="creator@idp.example", auth_method="local"),
+        )
+        r = client.post(
+            "/mr/create",
+            data={
+                "project": "demo",
+                "theirs": "refs/heads/feature",
+                "ours": "refs/heads/main",
+                "title": "Bare MR",
+            },
+            follow_redirects=False,
+        )
+    assert r.status_code == 303
+    location = r.headers["location"]
+    assert "notice=" not in location
+    repo = pygit2.Repository(str(root / "demo"))
+    for ref_name in repo.references:
+        if ref_name.startswith("refs/tags/mr/"):
+            tag = repo[repo.references[ref_name].resolve().target]
+            assert isinstance(tag, pygit2.Tag)
+            assert tag.tagger is not None
+            assert tag.tagger.email == "creator@idp.example"
+            return
+    raise AssertionError("merge request tag not created")
 
 
 def test_mr_create_bare_repo_without_identity_shows_notice(tmp_path: Path) -> None:
-	root, _a, _b = _bare_repo_with_branch(tmp_path)
-	projects_list = root / "projects.list"
-	projects_list.write_text("demo tester\n", encoding="utf-8")
-	client = _build_client()
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(projects_list)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "AUTH", False),
-	):
-		r = client.post(
-			"/mr/create",
-			data={
-				"project": "demo",
-				"theirs": "refs/heads/feature",
-				"ours": "refs/heads/main",
-				"title": "Bare MR",
-			},
-			follow_redirects=False,
-		)
-	assert r.status_code == 303
-	assert "notice=" in r.headers["location"]
+    root, _a, _b = _bare_repo_with_branch(tmp_path)
+    projects_list = root / "projects.list"
+    projects_list.write_text("demo tester\n", encoding="utf-8")
+    client = _build_client()
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(projects_list)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "AUTH", False),
+    ):
+        r = client.post(
+            "/mr/create",
+            data={
+                "project": "demo",
+                "theirs": "refs/heads/feature",
+                "ours": "refs/heads/main",
+                "title": "Bare MR",
+            },
+            follow_redirects=False,
+        )
+    assert r.status_code == 303
+    assert "notice=" in r.headers["location"]
diff --git a/pygitweb/src/pygitweb/password_hash.py b/pygitweb/src/pygitweb/password_hash.py
index ce24fe9..d6b9342 100644
--- a/pygitweb/src/pygitweb/password_hash.py
+++ b/pygitweb/src/pygitweb/password_hash.py
@@ -17,73 +17,73 @@ _ALGORITHM = "scrypt"
 
 
 def _password_material(password: str, config_salt: str) -> str:
-	if not config_salt:
-		return password
-	return f"{config_salt}:{password}"
+    if not config_salt:
+        return password
+    return f"{config_salt}:{password}"
 
 
 def _scrypt_key(password: str, *, salt: bytes, n: int, r: int, p: int, dklen: int) -> bytes:
-	return hashlib.scrypt(
-		password.encode("utf-8"),
-		salt=salt,
-		n=n,
-		r=r,
-		p=p,
-		maxmem=max(128 * n * r * (p + 2), _SCRYPT_MAXMEM),
-		dklen=dklen,
-	)
+    return hashlib.scrypt(
+        password.encode("utf-8"),
+        salt=salt,
+        n=n,
+        r=r,
+        p=p,
+        maxmem=max(128 * n * r * (p + 2), _SCRYPT_MAXMEM),
+        dklen=dklen,
+    )
 
 
 def hash_password(password: str, *, config_salt: str = "") -> str:
-	material = _password_material(password, config_salt)
-	salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
-	key = _scrypt_key(material, salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN)
-	salt_b64 = base64.b64encode(salt).decode("ascii")
-	key_b64 = base64.b64encode(key).decode("ascii")
-	return f"{_ALGORITHM}${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt_b64}${key_b64}"
+    material = _password_material(password, config_salt)
+    salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
+    key = _scrypt_key(material, salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN)
+    salt_b64 = base64.b64encode(salt).decode("ascii")
+    key_b64 = base64.b64encode(key).decode("ascii")
+    return f"{_ALGORITHM}${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt_b64}${key_b64}"
 
 
 def is_password_hash(value: str) -> bool:
-	parts = value.split("$")
-	if len(parts) != 6 or parts[0] != _ALGORITHM:
-		return False
-	try:
-		int(parts[1])
-		int(parts[2])
-		int(parts[3])
-		base64.b64decode(parts[4], validate=True)
-		base64.b64decode(parts[5], validate=True)
-	except (ValueError, TypeError):
-		return False
-	return True
+    parts = value.split("$")
+    if len(parts) != 6 or parts[0] != _ALGORITHM:
+        return False
+    try:
+        int(parts[1])
+        int(parts[2])
+        int(parts[3])
+        base64.b64decode(parts[4], validate=True)
+        base64.b64decode(parts[5], validate=True)
+    except (ValueError, TypeError):
+        return False
+    return True
 
 
 def verify_password(password: str, pass_hash: str, *, config_salt: str = "") -> bool:
-	if not pass_hash:
-		return False
-	if config_salt and _verify_password_material(_password_material(password, config_salt), pass_hash):
-		return True
-	return _verify_password_material(password, pass_hash)
+    if not pass_hash:
+        return False
+    if config_salt and _verify_password_material(_password_material(password, config_salt), pass_hash):
+        return True
+    return _verify_password_material(password, pass_hash)
 
 
 def _verify_password_material(material: str, pass_hash: str) -> bool:
-	if not pass_hash:
-		return False
-	parts = pass_hash.split("$")
-	if len(parts) != 6 or parts[0] != _ALGORITHM:
-		return False
-	try:
-		n = int(parts[1])
-		r = int(parts[2])
-		p = int(parts[3])
-		if n < _SCRYPT_N or r < _SCRYPT_R or p < _SCRYPT_P:
-			return False
-		salt = base64.b64decode(parts[4], validate=True)
-		expected = base64.b64decode(parts[5], validate=True)
-	except (ValueError, TypeError):
-		return False
-	try:
-		actual = _scrypt_key(material, salt=salt, n=n, r=r, p=p, dklen=len(expected))
-	except ValueError:
-		return False
-	return hmac.compare_digest(actual, expected)
+    if not pass_hash:
+        return False
+    parts = pass_hash.split("$")
+    if len(parts) != 6 or parts[0] != _ALGORITHM:
+        return False
+    try:
+        n = int(parts[1])
+        r = int(parts[2])
+        p = int(parts[3])
+        if n < _SCRYPT_N or r < _SCRYPT_R or p < _SCRYPT_P:
+            return False
+        salt = base64.b64decode(parts[4], validate=True)
+        expected = base64.b64decode(parts[5], validate=True)
+    except (ValueError, TypeError):
+        return False
+    try:
+        actual = _scrypt_key(material, salt=salt, n=n, r=r, p=p, dklen=len(expected))
+    except ValueError:
+        return False
+    return hmac.compare_digest(actual, expected)
diff --git a/pygitweb/src/pygitweb/password_hash_test.py b/pygitweb/src/pygitweb/password_hash_test.py
index 6c6bc51..134c661 100644
--- a/pygitweb/src/pygitweb/password_hash_test.py
+++ b/pygitweb/src/pygitweb/password_hash_test.py
@@ -2,26 +2,26 @@ from pygitweb.password_hash import hash_password, is_password_hash, verify_passw
 
 
 def test_hash_and_verify_roundtrip() -> None:
-	stored = hash_password("secret-password", config_salt="a1b2c3d4e5f67890")
-	assert stored.startswith("scrypt$131072$8$1$")
-	assert verify_password("secret-password", stored, config_salt="a1b2c3d4e5f67890")
-	assert not verify_password("wrong", stored, config_salt="a1b2c3d4e5f67890")
+    stored = hash_password("secret-password", config_salt="a1b2c3d4e5f67890")
+    assert stored.startswith("scrypt$131072$8$1$")
+    assert verify_password("secret-password", stored, config_salt="a1b2c3d4e5f67890")
+    assert not verify_password("wrong", stored, config_salt="a1b2c3d4e5f67890")
 
 
 def test_verify_legacy_hash_without_config_salt() -> None:
-	stored = hash_password("legacy")
-	assert verify_password("legacy", stored, config_salt="0123456789abcdef")
+    stored = hash_password("legacy")
+    assert verify_password("legacy", stored, config_salt="0123456789abcdef")
 
 
 def test_is_password_hash_rejects_malformed_values() -> None:
-	assert is_password_hash(hash_password("secret"))
-	assert not is_password_hash("plaintext")
-	assert not is_password_hash("scrypt$nope$8$1$aaaa$bbbb")
-	assert not is_password_hash("scrypt$131072$8$1$!!!$!!!")
+    assert is_password_hash(hash_password("secret"))
+    assert not is_password_hash("plaintext")
+    assert not is_password_hash("scrypt$nope$8$1$aaaa$bbbb")
+    assert not is_password_hash("scrypt$131072$8$1$!!!$!!!")
 
 
 def test_verify_rejects_weak_parameters() -> None:
-	stored = hash_password("x")
-	parts = stored.split("$")
-	parts[1] = "65536"
-	assert not verify_password("x", "$".join(parts))
+    stored = hash_password("x")
+    parts = stored.split("$")
+    parts[1] = "65536"
+    assert not verify_password("x", "$".join(parts))
diff --git a/pygitweb/src/pygitweb/permissions.py b/pygitweb/src/pygitweb/permissions.py
index 8cc19e5..4d02aff 100644
--- a/pygitweb/src/pygitweb/permissions.py
+++ b/pygitweb/src/pygitweb/permissions.py
@@ -35,261 +35,261 @@ PERMISSION_ADD_PROJECTS = "pgw.addprojects"
 PERMISSION_SETTINGS = "pgw.settings"
 
 _SCOPED_PREFIX = re.compile(
-	r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write_ref|write)\.(.+)$",
+    r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write_ref|write)\.(.+)$",
 )
 
 _REFS_PREFIX = "refs/"
 
 
 class Permission(StrEnum):
-	"""Logical permission checked by route dependencies."""
-
-	READ = "read"
-	WRITE = "write"
-	WRITE_REF = "write_ref"
-	BOARDS = "boards"
-	TASKS = "tasks"
-	COMMENTS = "comments"
-	HOOKS = "hooks"
-	MR_CREATE = "mr.create"
-	MR_MERGE = "mr.merge"
-	ADD_PROJECTS = "addprojects"
-	SETTINGS = "settings"
-
-	@property
-	def global_key(self) -> str | None:
-		if self is Permission.ADD_PROJECTS:
-			return PERMISSION_ADD_PROJECTS
-		if self is Permission.SETTINGS:
-			return PERMISSION_SETTINGS
-		return None
-
-	@property
-	def scoped_prefix(self) -> str | None:
-		if self in (
-			Permission.READ,
-			Permission.WRITE,
-			Permission.WRITE_REF,
-			Permission.BOARDS,
-			Permission.TASKS,
-			Permission.COMMENTS,
-			Permission.HOOKS,
-			Permission.MR_CREATE,
-			Permission.MR_MERGE,
-		):
-			return f"pgw.{self.value}."
-		return None
+    """Logical permission checked by route dependencies."""
+
+    READ = "read"
+    WRITE = "write"
+    WRITE_REF = "write_ref"
+    BOARDS = "boards"
+    TASKS = "tasks"
+    COMMENTS = "comments"
+    HOOKS = "hooks"
+    MR_CREATE = "mr.create"
+    MR_MERGE = "mr.merge"
+    ADD_PROJECTS = "addprojects"
+    SETTINGS = "settings"
+
+    @property
+    def global_key(self) -> str | None:
+        if self is Permission.ADD_PROJECTS:
+            return PERMISSION_ADD_PROJECTS
+        if self is Permission.SETTINGS:
+            return PERMISSION_SETTINGS
+        return None
+
+    @property
+    def scoped_prefix(self) -> str | None:
+        if self in (
+            Permission.READ,
+            Permission.WRITE,
+            Permission.WRITE_REF,
+            Permission.BOARDS,
+            Permission.TASKS,
+            Permission.COMMENTS,
+            Permission.HOOKS,
+            Permission.MR_CREATE,
+            Permission.MR_MERGE,
+        ):
+            return f"pgw.{self.value}."
+        return None
 
 
 class ParsedPermissionKey(BaseModel):
-	model_config = ConfigDict(frozen=True)
+    model_config = ConfigDict(frozen=True)
 
-	raw: str
-	permission: Permission
-	project_scope: str | None = None
+    raw: str
+    permission: Permission
+    project_scope: str | None = None
 
-	@property
-	def is_global(self) -> bool:
-		return self.project_scope is None
+    @property
+    def is_global(self) -> bool:
+        return self.project_scope is None
 
 
 def parse_permission_key(key: str) -> ParsedPermissionKey | None:
-	"""Return a parsed key, or ``None`` if ``key`` is not a known permission name."""
-	raw = key.strip()
-	if not raw or raw == WILDCARD_PERMISSION_KEY:
-		return None
-	if raw == PERMISSION_ADD_PROJECTS:
-		return ParsedPermissionKey(raw=raw, permission=Permission.ADD_PROJECTS)
-	if raw == PERMISSION_SETTINGS:
-		return ParsedPermissionKey(raw=raw, permission=Permission.SETTINGS)
-	m = _SCOPED_PREFIX.match(raw)
-	if not m:
-		return None
-	kind, scope = m.group(1), m.group(2)
-	try:
-		perm = Permission(kind)
-	except ValueError:
-		return None
-	if not scope:
-		return None
-	return ParsedPermissionKey(raw=raw, permission=perm, project_scope=scope)
+    """Return a parsed key, or ``None`` if ``key`` is not a known permission name."""
+    raw = key.strip()
+    if not raw or raw == WILDCARD_PERMISSION_KEY:
+        return None
+    if raw == PERMISSION_ADD_PROJECTS:
+        return ParsedPermissionKey(raw=raw, permission=Permission.ADD_PROJECTS)
+    if raw == PERMISSION_SETTINGS:
+        return ParsedPermissionKey(raw=raw, permission=Permission.SETTINGS)
+    m = _SCOPED_PREFIX.match(raw)
+    if not m:
+        return None
+    kind, scope = m.group(1), m.group(2)
+    try:
+        perm = Permission(kind)
+    except ValueError:
+        return None
+    if not scope:
+        return None
+    return ParsedPermissionKey(raw=raw, permission=perm, project_scope=scope)
 
 
 def permission_key(permission: Permission, project: str) -> str:
-	"""Build the canonical scoped permission key for a project."""
-	prefix = permission.scoped_prefix
-	if prefix is None:
-		raise ValueError(f"{permission!r} is not project-scoped")
-	return f"{prefix}{project}"
+    """Build the canonical scoped permission key for a project."""
+    prefix = permission.scoped_prefix
+    if prefix is None:
+        raise ValueError(f"{permission!r} is not project-scoped")
+    return f"{prefix}{project}"
 
 
 def project_matches_scope(project: str, scope: str) -> bool:
-	if scope == WILDCARD_PERMISSION_KEY:
-		return True
-	return fnmatch.fnmatchcase(project, scope)
+    if scope == WILDCARD_PERMISSION_KEY:
+        return True
+    return fnmatch.fnmatchcase(project, scope)
 
 
 def write_ref_project_segment(scope: str) -> str:
-	"""Project segment of a ``write_ref`` scope (text before the first ``/``)."""
-	project_part, sep, _rest = scope.partition("/")
-	return project_part if sep else scope
+    """Project segment of a ``write_ref`` scope (text before the first ``/``)."""
+    project_part, sep, _rest = scope.partition("/")
+    return project_part if sep else scope
 
 
 def ref_permission_subject(project: str, ref_name: str) -> str | None:
-	if not ref_name.startswith(_REFS_PREFIX):
-		return None
-	rest = ref_name.removeprefix(_REFS_PREFIX)
-	if not rest:
-		return None
-	return f"{project}/{rest}"
+    if not ref_name.startswith(_REFS_PREFIX):
+        return None
+    rest = ref_name.removeprefix(_REFS_PREFIX)
+    if not rest:
+        return None
+    return f"{project}/{rest}"
 
 
 def project_has_write_ref_rules(permissions: PermissionsMap, project: str) -> bool:
-	"""True when any ``pgw.write_ref`` key's project segment matches ``project``."""
-	for key in permissions.root:
-		if key == WILDCARD_PERMISSION_KEY:
-			continue
-		parsed = parse_permission_key(key)
-		if parsed is None or parsed.permission is not Permission.WRITE_REF or parsed.project_scope is None:
-			continue
-		if project_matches_scope(project, write_ref_project_segment(parsed.project_scope)):
-			return True
-	return False
+    """True when any ``pgw.write_ref`` key's project segment matches ``project``."""
+    for key in permissions.root:
+        if key == WILDCARD_PERMISSION_KEY:
+            continue
+        parsed = parse_permission_key(key)
+        if parsed is None or parsed.permission is not Permission.WRITE_REF or parsed.project_scope is None:
+            continue
+        if project_matches_scope(project, write_ref_project_segment(parsed.project_scope)):
+            return True
+    return False
 
 
 def has_ref_write_permission(
-	permissions: PermissionsMap,
-	principal: str | None,
-	project: str,
-	ref_name: str,
+    permissions: PermissionsMap,
+    principal: str | None,
+    project: str,
+    ref_name: str,
 ) -> bool:
-	"""Whether ``principal`` may push ``ref_name`` in ``project``.
+    """Whether ``principal`` may push ``ref_name`` in ``project``.
 
-	If any ``write_ref`` rule applies to ``project``, only matching ``write_ref``
-	grants allow the push (whitelist). Otherwise project ``write`` is enough.
-	"""
-	subject = ref_permission_subject(project, ref_name)
-	if subject is None:
-		return False
-	if project_has_write_ref_rules(permissions, project):
-		return permissions.has_permission(principal, Permission.WRITE_REF, subject)
-	return permissions.has_permission(principal, Permission.WRITE, project)
+    If any ``write_ref`` rule applies to ``project``, only matching ``write_ref``
+    grants allow the push (whitelist). Otherwise project ``write`` is enough.
+    """
+    subject = ref_permission_subject(project, ref_name)
+    if subject is None:
+        return False
+    if project_has_write_ref_rules(permissions, project):
+        return permissions.has_permission(principal, Permission.WRITE_REF, subject)
+    return permissions.has_permission(principal, Permission.WRITE, project)
 
 
 class PermissionsMap(RootModel[dict[str, list[str]]]):
-	"""Validated ``oauth_permissions`` object from auth.json."""
-
-	root: dict[str, list[str]]
-
-	@model_validator(mode="before")
-	@classmethod
-	def _coerce_root(cls, v: object) -> object:
-		if isinstance(v, PermissionsMap):
-			return v.root
-		if v is None:
-			return {WILDCARD_PERMISSION_KEY: []}
-		if not isinstance(v, dict):
-			raise TypeError("oauth_permissions must be an object")
-		out: dict[str, list[str]] = {}
-		for key, principals in v.items():
-			if not isinstance(key, str):
-				raise TypeError("permission keys must be strings")
-			if key != WILDCARD_PERMISSION_KEY and parse_permission_key(key) is None:
-				raise ValueError(f"unknown permission key: {key!r}")
-			if not isinstance(principals, list):
-				raise TypeError(f"oauth_permissions[{key!r}] must be a list")
-			normalized: list[str] = []
-			for p in principals:
-				if not isinstance(p, str):
-					raise TypeError(f"oauth_permissions[{key!r}] entries must be strings")
-				s = p.strip()
-				if s:
-					normalized.append(s)
-			out[key] = normalized
-		if WILDCARD_PERMISSION_KEY not in out:
-			out = {**out, WILDCARD_PERMISSION_KEY: []}
-		return out
-
-	@classmethod
-	def empty(cls) -> Self:
-		return cls({WILDCARD_PERMISSION_KEY: []})
-
-	@classmethod
-	def default_access(cls) -> Self:
-		"""Default grants: any authenticated principal may read and write every project."""
-		return cls({
-			WILDCARD_PERMISSION_KEY: [],
-			permission_key(Permission.READ, WILDCARD_PERMISSION_KEY): [WILDCARD_PRINCIPAL],
-			permission_key(Permission.WRITE, WILDCARD_PERMISSION_KEY): [WILDCARD_PRINCIPAL],
-		})
-
-	def principals_for_key(self, key: str) -> frozenset[str]:
-		return frozenset(self.root.get(key, []))
-
-	def superuser_principals(self) -> frozenset[str]:
-		return self.principals_for_key(WILDCARD_PERMISSION_KEY)
-
-	def matching_scoped_principals(self, permission: Permission, project: str) -> frozenset[str]:
-		prefix = permission.scoped_prefix
-		if prefix is None:
-			return frozenset()
-		acc: set[str] = set()
-		for key, principals in self.root.items():
-			if key == WILDCARD_PERMISSION_KEY:
-				continue
-			parsed = parse_permission_key(key)
-			if parsed is None or parsed.permission is not permission or parsed.project_scope is None:
-				continue
-			if project_matches_scope(project, parsed.project_scope):
-				acc.update(principals)
-		return frozenset(acc)
-
-	def principals_with_permission(self, permission: Permission, project: str | None = None) -> frozenset[str]:
-		acc = set(self.superuser_principals())
-		global_key = permission.global_key
-		if global_key is not None:
-			acc.update(self.principals_for_key(global_key))
-			return frozenset(acc)
-		if project is None:
-			return frozenset(acc)
-		acc.update(self.matching_scoped_principals(permission, project))
-		return frozenset(acc)
-
-	def has_permission(self, principal: str | None, permission: Permission, project: str | None = None) -> bool:
-		if not principal or not principal.strip():
-			return False
-		normalized = principal.strip()
-		allowed = self.principals_with_permission(permission, project)
-		if WILDCARD_PRINCIPAL in allowed:
-			return True
-		if normalized in allowed:
-			return True
-		# Case-insensitive match for email principals.
-		lower = normalized.lower()
-		return any(p.lower() == lower for p in allowed if p != WILDCARD_PRINCIPAL)
+    """Validated ``oauth_permissions`` object from auth.json."""
+
+    root: dict[str, list[str]]
+
+    @model_validator(mode="before")
+    @classmethod
+    def _coerce_root(cls, v: object) -> object:
+        if isinstance(v, PermissionsMap):
+            return v.root
+        if v is None:
+            return {WILDCARD_PERMISSION_KEY: []}
+        if not isinstance(v, dict):
+            raise TypeError("oauth_permissions must be an object")
+        out: dict[str, list[str]] = {}
+        for key, principals in v.items():
+            if not isinstance(key, str):
+                raise TypeError("permission keys must be strings")
+            if key != WILDCARD_PERMISSION_KEY and parse_permission_key(key) is None:
+                raise ValueError(f"unknown permission key: {key!r}")
+            if not isinstance(principals, list):
+                raise TypeError(f"oauth_permissions[{key!r}] must be a list")
+            normalized: list[str] = []
+            for p in principals:
+                if not isinstance(p, str):
+                    raise TypeError(f"oauth_permissions[{key!r}] entries must be strings")
+                s = p.strip()
+                if s:
+                    normalized.append(s)
+            out[key] = normalized
+        if WILDCARD_PERMISSION_KEY not in out:
+            out = {**out, WILDCARD_PERMISSION_KEY: []}
+        return out
+
+    @classmethod
+    def empty(cls) -> Self:
+        return cls({WILDCARD_PERMISSION_KEY: []})
+
+    @classmethod
+    def default_access(cls) -> Self:
+        """Default grants: any authenticated principal may read and write every project."""
+        return cls({
+            WILDCARD_PERMISSION_KEY: [],
+            permission_key(Permission.READ, WILDCARD_PERMISSION_KEY): [WILDCARD_PRINCIPAL],
+            permission_key(Permission.WRITE, WILDCARD_PERMISSION_KEY): [WILDCARD_PRINCIPAL],
+        })
+
+    def principals_for_key(self, key: str) -> frozenset[str]:
+        return frozenset(self.root.get(key, []))
+
+    def superuser_principals(self) -> frozenset[str]:
+        return self.principals_for_key(WILDCARD_PERMISSION_KEY)
+
+    def matching_scoped_principals(self, permission: Permission, project: str) -> frozenset[str]:
+        prefix = permission.scoped_prefix
+        if prefix is None:
+            return frozenset()
+        acc: set[str] = set()
+        for key, principals in self.root.items():
+            if key == WILDCARD_PERMISSION_KEY:
+                continue
+            parsed = parse_permission_key(key)
+            if parsed is None or parsed.permission is not permission or parsed.project_scope is None:
+                continue
+            if project_matches_scope(project, parsed.project_scope):
+                acc.update(principals)
+        return frozenset(acc)
+
+    def principals_with_permission(self, permission: Permission, project: str | None = None) -> frozenset[str]:
+        acc = set(self.superuser_principals())
+        global_key = permission.global_key
+        if global_key is not None:
+            acc.update(self.principals_for_key(global_key))
+            return frozenset(acc)
+        if project is None:
+            return frozenset(acc)
+        acc.update(self.matching_scoped_principals(permission, project))
+        return frozenset(acc)
+
+    def has_permission(self, principal: str | None, permission: Permission, project: str | None = None) -> bool:
+        if not principal or not principal.strip():
+            return False
+        normalized = principal.strip()
+        allowed = self.principals_with_permission(permission, project)
+        if WILDCARD_PRINCIPAL in allowed:
+            return True
+        if normalized in allowed:
+            return True
+        # Case-insensitive match for email principals.
+        lower = normalized.lower()
+        return any(p.lower() == lower for p in allowed if p != WILDCARD_PRINCIPAL)
 
 
 def normalize_oauth_permissions(raw: object) -> dict[str, list[str]]:
-	return PermissionsMap.model_validate(raw).root
+    return PermissionsMap.model_validate(raw).root
 
 
 def permissions_with_defaults(extra: dict[str, list[str]] | None = None) -> PermissionsMap:
-	"""Default read/write for all authenticated users, merged with test-specific grants."""
-	root = PermissionsMap.default_access().root.copy()
-	if extra:
-		root.update(extra)
-	return PermissionsMap.model_validate(root)
+    """Default read/write for all authenticated users, merged with test-specific grants."""
+    root = PermissionsMap.default_access().root.copy()
+    if extra:
+        root.update(extra)
+    return PermissionsMap.model_validate(root)
 
 
 class PermissionPrincipal(BaseModel):
-	model_config = ConfigDict(frozen=True)
+    model_config = ConfigDict(frozen=True)
 
-	username: str
-	email: str | None = None
+    username: str
+    email: str | None = None
 
-	@property
-	def identity(self) -> str:
-		"""Principal string used for grant lookup (email when present, else username)."""
-		if self.email and self.email.strip():
-			return self.email.strip()
-		return self.username.strip()
+    @property
+    def identity(self) -> str:
+        """Principal string used for grant lookup (email when present, else username)."""
+        if self.email and self.email.strip():
+            return self.email.strip()
+        return self.username.strip()
diff --git a/pygitweb/src/pygitweb/permissions_test.py b/pygitweb/src/pygitweb/permissions_test.py
index ebe061a..026f72c 100644
--- a/pygitweb/src/pygitweb/permissions_test.py
+++ b/pygitweb/src/pygitweb/permissions_test.py
@@ -1,238 +1,238 @@
 import pytest
 
 from pygitweb.permissions import (
-	PERMISSION_ADD_PROJECTS,
-	PERMISSION_SETTINGS,
-	Permission,
-	PermissionsMap,
-	has_ref_write_permission,
-	parse_permission_key,
-	permission_key,
-	project_has_write_ref_rules,
-	project_matches_scope,
-	ref_permission_subject,
-	write_ref_project_segment,
+    PERMISSION_ADD_PROJECTS,
+    PERMISSION_SETTINGS,
+    Permission,
+    PermissionsMap,
+    has_ref_write_permission,
+    parse_permission_key,
+    permission_key,
+    project_has_write_ref_rules,
+    project_matches_scope,
+    ref_permission_subject,
+    write_ref_project_segment,
 )
 
 
 def test_parse_global_keys() -> None:
-	assert parse_permission_key(PERMISSION_ADD_PROJECTS) is not None
-	assert parse_permission_key(PERMISSION_SETTINGS) is not None
-	assert parse_permission_key("pgw.unknown") is None
-	assert parse_permission_key("*") is None
+    assert parse_permission_key(PERMISSION_ADD_PROJECTS) is not None
+    assert parse_permission_key(PERMISSION_SETTINGS) is not None
+    assert parse_permission_key("pgw.unknown") is None
+    assert parse_permission_key("*") is None
 
 
 def test_parse_scoped_key() -> None:
-	parsed = parse_permission_key("pgw.tasks.my/repo")
-	assert parsed is not None
-	assert parsed.permission is Permission.TASKS
-	assert parsed.project_scope == "my/repo"
+    parsed = parse_permission_key("pgw.tasks.my/repo")
+    assert parsed is not None
+    assert parsed.permission is Permission.TASKS
+    assert parsed.project_scope == "my/repo"
 
 
 def test_permission_key_roundtrip() -> None:
-	assert permission_key(Permission.READ, "foo") == "pgw.read.foo"
-	assert permission_key(Permission.WRITE, "foo") == "pgw.write.foo"
-	assert permission_key(Permission.BOARDS, "foo") == "pgw.boards.foo"
-	assert permission_key(Permission.MR_CREATE, "foo") == "pgw.mr.create.foo"
-	assert permission_key(Permission.MR_MERGE, "foo") == "pgw.mr.merge.foo"
-	assert permission_key(Permission.HOOKS, "foo") == "pgw.hooks.foo"
+    assert permission_key(Permission.READ, "foo") == "pgw.read.foo"
+    assert permission_key(Permission.WRITE, "foo") == "pgw.write.foo"
+    assert permission_key(Permission.BOARDS, "foo") == "pgw.boards.foo"
+    assert permission_key(Permission.MR_CREATE, "foo") == "pgw.mr.create.foo"
+    assert permission_key(Permission.MR_MERGE, "foo") == "pgw.mr.merge.foo"
+    assert permission_key(Permission.HOOKS, "foo") == "pgw.hooks.foo"
 
 
 def test_parse_read_write_scoped_keys() -> None:
-	read = parse_permission_key("pgw.read.team/*")
-	assert read is not None
-	assert read.permission is Permission.READ
-	assert read.project_scope == "team/*"
-	write = parse_permission_key("pgw.write.private")
-	assert write is not None
-	assert write.permission is Permission.WRITE
-	assert write.project_scope == "private"
+    read = parse_permission_key("pgw.read.team/*")
+    assert read is not None
+    assert read.permission is Permission.READ
+    assert read.project_scope == "team/*"
+    write = parse_permission_key("pgw.write.private")
+    assert write is not None
+    assert write.permission is Permission.WRITE
+    assert write.project_scope == "private"
 
 
 def test_parse_mr_scoped_keys() -> None:
-	create = parse_permission_key("pgw.mr.create.demo")
-	assert create is not None
-	assert create.permission is Permission.MR_CREATE
-	merge = parse_permission_key("pgw.mr.merge.demo")
-	assert merge is not None
-	assert merge.permission is Permission.MR_MERGE
+    create = parse_permission_key("pgw.mr.create.demo")
+    assert create is not None
+    assert create.permission is Permission.MR_CREATE
+    merge = parse_permission_key("pgw.mr.merge.demo")
+    assert merge is not None
+    assert merge.permission is Permission.MR_MERGE
 
 
 def test_parse_write_ref_scoped_keys() -> None:
-	ref = parse_permission_key("pgw.write_ref.demo/tags/v*")
-	assert ref is not None
-	assert ref.permission is Permission.WRITE_REF
-	assert ref.project_scope == "demo/tags/v*"
-	heads = parse_permission_key("pgw.write_ref.dev/heads/alice.*")
-	assert heads is not None
-	assert heads.permission is Permission.WRITE_REF
-	assert heads.project_scope == "dev/heads/alice.*"
-	assert parse_permission_key("pgw.write_branch.dev/alice.*") is None
-	assert parse_permission_key("pgw.read_branch.team/app-main") is None
+    ref = parse_permission_key("pgw.write_ref.demo/tags/v*")
+    assert ref is not None
+    assert ref.permission is Permission.WRITE_REF
+    assert ref.project_scope == "demo/tags/v*"
+    heads = parse_permission_key("pgw.write_ref.dev/heads/alice.*")
+    assert heads is not None
+    assert heads.permission is Permission.WRITE_REF
+    assert heads.project_scope == "dev/heads/alice.*"
+    assert parse_permission_key("pgw.write_branch.dev/alice.*") is None
+    assert parse_permission_key("pgw.read_branch.team/app-main") is None
 
 
 def test_project_glob() -> None:
-	assert project_matches_scope("team/app", "team/*")
-	assert not project_matches_scope("other/app", "team/*")
-	assert project_matches_scope("anything", "*")
+    assert project_matches_scope("team/app", "team/*")
+    assert not project_matches_scope("other/app", "team/*")
+    assert project_matches_scope("anything", "*")
 
 
 def test_ref_permission_subject() -> None:
-	assert ref_permission_subject("demo", "refs/heads/main") == "demo/heads/main"
-	assert ref_permission_subject("demo", "refs/tags/v1.0.0") == "demo/tags/v1.0.0"
-	assert ref_permission_subject("demo", "refs/notes/commits") == "demo/notes/commits"
-	assert ref_permission_subject("demo", "HEAD") is None
-	assert write_ref_project_segment("demo/heads/main") == "demo"
-	assert write_ref_project_segment("*/tags/v*") == "*"
-	assert write_ref_project_segment("solo") == "solo"
+    assert ref_permission_subject("demo", "refs/heads/main") == "demo/heads/main"
+    assert ref_permission_subject("demo", "refs/tags/v1.0.0") == "demo/tags/v1.0.0"
+    assert ref_permission_subject("demo", "refs/notes/commits") == "demo/notes/commits"
+    assert ref_permission_subject("demo", "HEAD") is None
+    assert write_ref_project_segment("demo/heads/main") == "demo"
+    assert write_ref_project_segment("*/tags/v*") == "*"
+    assert write_ref_project_segment("solo") == "solo"
 
 
 def test_superuser_grants_all() -> None:
-	m = PermissionsMap.model_validate({
-		"*": ["admin@example.com"],
-		"pgw.tasks.secret": ["other@example.com"],
-	})
-	assert m.has_permission("admin@example.com", Permission.TASKS, "secret")
-	assert m.has_permission("admin@example.com", Permission.SETTINGS)
-	assert m.has_permission("other@example.com", Permission.TASKS, "secret")
-	assert not m.has_permission("other@example.com", Permission.SETTINGS)
-	assert not m.has_permission("other@example.com", Permission.TASKS, "public")
+    m = PermissionsMap.model_validate({
+        "*": ["admin@example.com"],
+        "pgw.tasks.secret": ["other@example.com"],
+    })
+    assert m.has_permission("admin@example.com", Permission.TASKS, "secret")
+    assert m.has_permission("admin@example.com", Permission.SETTINGS)
+    assert m.has_permission("other@example.com", Permission.TASKS, "secret")
+    assert not m.has_permission("other@example.com", Permission.SETTINGS)
+    assert not m.has_permission("other@example.com", Permission.TASKS, "public")
 
 
 def test_scoped_grant() -> None:
-	m = PermissionsMap.model_validate({
-		"*": [],
-		"pgw.comments.proj-a": ["reader@example.com"],
-		"pgw.boards.team/*": ["lead@example.com"],
-	})
-	assert m.has_permission("reader@example.com", Permission.COMMENTS, "proj-a")
-	assert not m.has_permission("reader@example.com", Permission.TASKS, "proj-a")
-	assert m.has_permission("lead@example.com", Permission.BOARDS, "team/widget")
-	assert not m.has_permission("lead@example.com", Permission.BOARDS, "other/widget")
+    m = PermissionsMap.model_validate({
+        "*": [],
+        "pgw.comments.proj-a": ["reader@example.com"],
+        "pgw.boards.team/*": ["lead@example.com"],
+    })
+    assert m.has_permission("reader@example.com", Permission.COMMENTS, "proj-a")
+    assert not m.has_permission("reader@example.com", Permission.TASKS, "proj-a")
+    assert m.has_permission("lead@example.com", Permission.BOARDS, "team/widget")
+    assert not m.has_permission("lead@example.com", Permission.BOARDS, "other/widget")
 
 
 def test_email_case_insensitive() -> None:
-	m = PermissionsMap.model_validate({"*": [], PERMISSION_SETTINGS: ["Admin@Example.COM"]})
-	assert m.has_permission("admin@example.com", Permission.SETTINGS)
+    m = PermissionsMap.model_validate({"*": [], PERMISSION_SETTINGS: ["Admin@Example.COM"]})
+    assert m.has_permission("admin@example.com", Permission.SETTINGS)
 
 
 def test_rejects_unknown_key() -> None:
-	with pytest.raises(ValueError, match="unknown permission key"):
-		PermissionsMap.model_validate({"pgw.notreal": ["a@b.com"]})
-	with pytest.raises(ValueError, match="unknown permission key"):
-		PermissionsMap.model_validate({"pgw.write_branch.demo/main": ["a@b.com"]})
+    with pytest.raises(ValueError, match="unknown permission key"):
+        PermissionsMap.model_validate({"pgw.notreal": ["a@b.com"]})
+    with pytest.raises(ValueError, match="unknown permission key"):
+        PermissionsMap.model_validate({"pgw.write_branch.demo/main": ["a@b.com"]})
 
 
 def test_ensures_wildcard_key() -> None:
-	m = PermissionsMap.model_validate({PERMISSION_ADD_PROJECTS: ["u@x.com"]})
-	assert "*" in m.root
+    m = PermissionsMap.model_validate({PERMISSION_ADD_PROJECTS: ["u@x.com"]})
+    assert "*" in m.root
 
 
 def test_default_access_grants_read_write_to_any_authenticated_user() -> None:
-	m = PermissionsMap.default_access()
-	assert m.has_permission("alice", Permission.READ, "secret")
-	assert m.has_permission("bob@example.com", Permission.WRITE, "team/app")
-	assert not m.has_permission("", Permission.READ, "secret")
-	assert not m.has_permission(None, Permission.WRITE, "secret")
+    m = PermissionsMap.default_access()
+    assert m.has_permission("alice", Permission.READ, "secret")
+    assert m.has_permission("bob@example.com", Permission.WRITE, "team/app")
+    assert not m.has_permission("", Permission.READ, "secret")
+    assert not m.has_permission(None, Permission.WRITE, "secret")
 
 
 def test_scoped_read_write_without_wildcard_principal() -> None:
-	m = PermissionsMap.model_validate({
-		"*": [],
-		"pgw.read.public": ["reader@example.com"],
-		"pgw.write.public": ["writer@example.com"],
-	})
-	assert m.has_permission("reader@example.com", Permission.READ, "public")
-	assert not m.has_permission("reader@example.com", Permission.WRITE, "public")
-	assert m.has_permission("writer@example.com", Permission.WRITE, "public")
-	assert not m.has_permission("writer@example.com", Permission.READ, "private")
+    m = PermissionsMap.model_validate({
+        "*": [],
+        "pgw.read.public": ["reader@example.com"],
+        "pgw.write.public": ["writer@example.com"],
+    })
+    assert m.has_permission("reader@example.com", Permission.READ, "public")
+    assert not m.has_permission("reader@example.com", Permission.WRITE, "public")
+    assert m.has_permission("writer@example.com", Permission.WRITE, "public")
+    assert not m.has_permission("writer@example.com", Permission.READ, "private")
 
 
 def test_owner_grants_read_and_write() -> None:
-	from unittest.mock import patch
-
-	from pygitweb.auth import can_read_project, can_write_project
-	from pygitweb.config import settings
-	from pygitweb.sessions import create_session
-
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.auth.git_get_project_owner", return_value="owner@example.com"),
-		patch(
-			"pygitweb.auth.auth_config.oauth_permissions",
-			PermissionsMap.model_validate({"*": []}),
-		),
-	):
-		token = create_session("someone", email="owner@example.com", auth_method="local")
-		assert can_read_project("secret", token)
-		assert can_write_project("secret", token)
-		assert not can_read_project("secret", None)
+    from unittest.mock import patch
+
+    from pygitweb.auth import can_read_project, can_write_project
+    from pygitweb.config import settings
+    from pygitweb.sessions import create_session
+
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.auth.git_get_project_owner", return_value="owner@example.com"),
+        patch(
+            "pygitweb.auth.auth_config.oauth_permissions",
+            PermissionsMap.model_validate({"*": []}),
+        ),
+    ):
+        token = create_session("someone", email="owner@example.com", auth_method="local")
+        assert can_read_project("secret", token)
+        assert can_write_project("secret", token)
+        assert not can_read_project("secret", None)
 
 
 def test_has_ref_write_permission_falls_back_to_project_scope() -> None:
-	m = PermissionsMap.model_validate({
-		"*": [],
-		"pgw.write.demo": ["writer@example.com"],
-	})
-	assert not project_has_write_ref_rules(m, "demo")
-	assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
-	assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/tags/v1")
-	assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/notes/commits")
-	assert not has_ref_write_permission(m, "other@example.com", "demo", "refs/heads/main")
-	assert not has_ref_write_permission(m, "writer@example.com", "demo", "HEAD")
+    m = PermissionsMap.model_validate({
+        "*": [],
+        "pgw.write.demo": ["writer@example.com"],
+    })
+    assert not project_has_write_ref_rules(m, "demo")
+    assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
+    assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/tags/v1")
+    assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/notes/commits")
+    assert not has_ref_write_permission(m, "other@example.com", "demo", "refs/heads/main")
+    assert not has_ref_write_permission(m, "writer@example.com", "demo", "HEAD")
 
 
 def test_write_ref_rules_are_per_project() -> None:
-	m = PermissionsMap.model_validate({
-		"*": [],
-		"pgw.write.demo": ["writer@example.com"],
-		"pgw.write.other": ["writer@example.com"],
-		"pgw.write_ref.other/heads/main": ["ref-writer@example.com"],
-	})
-	assert not project_has_write_ref_rules(m, "demo")
-	assert project_has_write_ref_rules(m, "other")
-	assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
-	assert not has_ref_write_permission(m, "writer@example.com", "other", "refs/heads/main")
-	assert has_ref_write_permission(m, "ref-writer@example.com", "other", "refs/heads/main")
+    m = PermissionsMap.model_validate({
+        "*": [],
+        "pgw.write.demo": ["writer@example.com"],
+        "pgw.write.other": ["writer@example.com"],
+        "pgw.write_ref.other/heads/main": ["ref-writer@example.com"],
+    })
+    assert not project_has_write_ref_rules(m, "demo")
+    assert project_has_write_ref_rules(m, "other")
+    assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
+    assert not has_ref_write_permission(m, "writer@example.com", "other", "refs/heads/main")
+    assert has_ref_write_permission(m, "ref-writer@example.com", "other", "refs/heads/main")
 
 
 def test_has_ref_write_permission_whitelist_when_write_ref_defined() -> None:
-	m = PermissionsMap.model_validate({
-		"*": [],
-		"pgw.write.demo": ["writer@example.com"],
-		"pgw.write_ref.demo/tags/v*": ["release@example.com"],
-		"pgw.write_ref.demo/notes/*": ["alice@example.com"],
-		"pgw.write_ref.demo/heads/main": ["ref-writer@example.com"],
-		"pgw.write_ref.demo/heads/feature*": ["writer@example.com"],
-	})
-	assert project_has_write_ref_rules(m, "demo")
-	assert has_ref_write_permission(m, "release@example.com", "demo", "refs/tags/v1.2")
-	assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/tags/v1.2")
-	assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/tags/nightly")
-	assert has_ref_write_permission(m, "alice@example.com", "demo", "refs/notes/commits")
-	assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/notes/commits")
-	assert has_ref_write_permission(m, "ref-writer@example.com", "demo", "refs/heads/main")
-	assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
-	assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/feature/x")
-	assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/develop")
+    m = PermissionsMap.model_validate({
+        "*": [],
+        "pgw.write.demo": ["writer@example.com"],
+        "pgw.write_ref.demo/tags/v*": ["release@example.com"],
+        "pgw.write_ref.demo/notes/*": ["alice@example.com"],
+        "pgw.write_ref.demo/heads/main": ["ref-writer@example.com"],
+        "pgw.write_ref.demo/heads/feature*": ["writer@example.com"],
+    })
+    assert project_has_write_ref_rules(m, "demo")
+    assert has_ref_write_permission(m, "release@example.com", "demo", "refs/tags/v1.2")
+    assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/tags/v1.2")
+    assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/tags/nightly")
+    assert has_ref_write_permission(m, "alice@example.com", "demo", "refs/notes/commits")
+    assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/notes/commits")
+    assert has_ref_write_permission(m, "ref-writer@example.com", "demo", "refs/heads/main")
+    assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
+    assert has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/feature/x")
+    assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/develop")
 
 
 def test_write_ref_whitelist_empty_grant_list_still_enables_mode() -> None:
-	m = PermissionsMap.model_validate({
-		"*": [],
-		"pgw.write.demo": ["writer@example.com"],
-		"pgw.write_ref.demo/heads/main": [],
-	})
-	assert project_has_write_ref_rules(m, "demo")
-	assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
-	assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/tags/v1")
+    m = PermissionsMap.model_validate({
+        "*": [],
+        "pgw.write.demo": ["writer@example.com"],
+        "pgw.write_ref.demo/heads/main": [],
+    })
+    assert project_has_write_ref_rules(m, "demo")
+    assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
+    assert not has_ref_write_permission(m, "writer@example.com", "demo", "refs/tags/v1")
 
 
 def test_write_ref_superuser_bypasses_whitelist() -> None:
-	m = PermissionsMap.model_validate({
-		"*": ["admin@example.com"],
-		"pgw.write.demo": ["writer@example.com"],
-		"pgw.write_ref.demo/heads/main": ["ref-writer@example.com"],
-	})
-	assert has_ref_write_permission(m, "admin@example.com", "demo", "refs/heads/main")
-	assert has_ref_write_permission(m, "admin@example.com", "demo", "refs/tags/v1")
+    m = PermissionsMap.model_validate({
+        "*": ["admin@example.com"],
+        "pgw.write.demo": ["writer@example.com"],
+        "pgw.write_ref.demo/heads/main": ["ref-writer@example.com"],
+    })
+    assert has_ref_write_permission(m, "admin@example.com", "demo", "refs/heads/main")
+    assert has_ref_write_permission(m, "admin@example.com", "demo", "refs/tags/v1")
diff --git a/pygitweb/src/pygitweb/plugin_loader.py b/pygitweb/src/pygitweb/plugin_loader.py
index 5a58761..f8c2726 100644
--- a/pygitweb/src/pygitweb/plugin_loader.py
+++ b/pygitweb/src/pygitweb/plugin_loader.py
@@ -19,138 +19,138 @@ DISPATCH_ACTIONS: set[str] = set(BUILTIN_ACTIONS)
 
 
 def _entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
-	return importlib.metadata.entry_points().select(group=group)
+    return importlib.metadata.entry_points().select(group=group)
 
 
 def _dist_name(ep: importlib.metadata.EntryPoint) -> str | None:
-	dist = ep.dist
-	if dist is None:
-		return None
-	return dist.name
+    dist = ep.dist
+    if dist is None:
+        return None
+    return dist.name
 
 
 def _enabled_set(enabled: frozenset[str] | None) -> frozenset[str]:
-	if enabled is not None:
-		return enabled
-	from pygitweb.config import settings
+    if enabled is not None:
+        return enabled
+    from pygitweb.config import settings
 
-	return frozenset(settings.ENABLED_PLUGINS)
+    return frozenset(settings.ENABLED_PLUGINS)
 
 
 def list_available_plugins() -> list[str]:
-	"""Unique sorted distribution names that declare pygitweb entry points."""
-	names: set[str] = set()
-	for group in (SUBPAGE_GROUP, ACTION_GROUP):
-		for ep in _entry_points(group):
-			name = _dist_name(ep)
-			if name:
-				names.add(name)
-	return sorted(names)
+    """Unique sorted distribution names that declare pygitweb entry points."""
+    names: set[str] = set()
+    for group in (SUBPAGE_GROUP, ACTION_GROUP):
+        for ep in _entry_points(group):
+            name = _dist_name(ep)
+            if name:
+                names.add(name)
+    return sorted(names)
 
 
 def _register_plugin(
-	register: Callable[[type], type],
-	loaded: object,
-	ep_name: str,
-	kind: str,
+    register: Callable[[type], type],
+    loaded: object,
+    ep_name: str,
+    kind: str,
 ) -> type | None:
-	if not isinstance(loaded, type):
-		logger.error("pygitweb: entry point %r must be a %s class, got %r", ep_name, kind, loaded)
-		return None
-	try:
-		register(loaded)
-	except TypeError as exc:
-		logger.error(
-			"pygitweb: entry point %r must implement %s, got %r (%s)",
-			ep_name,
-			kind,
-			loaded,
-			exc,
-		)
-		return None
-	return loaded
+    if not isinstance(loaded, type):
+        logger.error("pygitweb: entry point %r must be a %s class, got %r", ep_name, kind, loaded)
+        return None
+    try:
+        register(loaded)
+    except TypeError as exc:
+        logger.error(
+            "pygitweb: entry point %r must implement %s, got %r (%s)",
+            ep_name,
+            kind,
+            loaded,
+            exc,
+        )
+        return None
+    return loaded
 
 
 def load_subpages(enabled: frozenset[str] | None = None) -> list[Subpage]:
-	allowed = _enabled_set(enabled)
-	out: list[Subpage] = []
-	seen_names: set[str] = set()
-	for ep in _entry_points(SUBPAGE_GROUP):
-		dist = _dist_name(ep)
-		if dist is None or dist not in allowed:
-			continue
-		try:
-			loaded = ep.load()
-		except Exception:
-			logger.exception("pygitweb: failed to load subpage entry point %r", ep.name)
-			continue
-		plugin_cls = _register_plugin(Subpage.register, loaded, ep.name, "Subpage")
-		if plugin_cls is None:
-			continue
-		try:
-			instance = plugin_cls()
-		except Exception:
-			logger.exception("pygitweb: failed to instantiate subpage %r", ep.name)
-			continue
-		key = instance.subpage_name()
-		if key in seen_names:
-			logger.warning("pygitweb: duplicate subpage %r from %r; skipping", key, ep.name)
-			continue
-		seen_names.add(key)
-		out.append(instance)
-	return out
+    allowed = _enabled_set(enabled)
+    out: list[Subpage] = []
+    seen_names: set[str] = set()
+    for ep in _entry_points(SUBPAGE_GROUP):
+        dist = _dist_name(ep)
+        if dist is None or dist not in allowed:
+            continue
+        try:
+            loaded = ep.load()
+        except Exception:
+            logger.exception("pygitweb: failed to load subpage entry point %r", ep.name)
+            continue
+        plugin_cls = _register_plugin(Subpage.register, loaded, ep.name, "Subpage")
+        if plugin_cls is None:
+            continue
+        try:
+            instance = plugin_cls()
+        except Exception:
+            logger.exception("pygitweb: failed to instantiate subpage %r", ep.name)
+            continue
+        key = instance.subpage_name()
+        if key in seen_names:
+            logger.warning("pygitweb: duplicate subpage %r from %r; skipping", key, ep.name)
+            continue
+        seen_names.add(key)
+        out.append(instance)
+    return out
 
 
 def load_plugin_actions(enabled: frozenset[str] | None = None) -> dict[str, Action]:
-	allowed = _enabled_set(enabled)
-	out: dict[str, Action] = {}
-	for ep in _entry_points(ACTION_GROUP):
-		dist = _dist_name(ep)
-		if dist is None or dist not in allowed:
-			continue
-		try:
-			loaded = ep.load()
-		except Exception:
-			logger.exception("pygitweb: failed to load action entry point %r", ep.name)
-			continue
-		plugin_cls = _register_plugin(Action.register, loaded, ep.name, "Action")
-		if plugin_cls is None:
-			continue
-		try:
-			instance = plugin_cls()
-		except Exception:
-			logger.exception("pygitweb: failed to instantiate action %r", ep.name)
-			continue
-		name = instance.action_name()
-		if name in BUILTIN_ACTIONS:
-			logger.warning(
-				"pygitweb: plugin action %r conflicts with built-in; skipping entry %r",
-				name,
-				ep.name,
-			)
-			continue
-		if name in out:
-			logger.warning("pygitweb: duplicate plugin action %r; skipping entry %r", name, ep.name)
-			continue
-		out[name] = instance
-	return out
+    allowed = _enabled_set(enabled)
+    out: dict[str, Action] = {}
+    for ep in _entry_points(ACTION_GROUP):
+        dist = _dist_name(ep)
+        if dist is None or dist not in allowed:
+            continue
+        try:
+            loaded = ep.load()
+        except Exception:
+            logger.exception("pygitweb: failed to load action entry point %r", ep.name)
+            continue
+        plugin_cls = _register_plugin(Action.register, loaded, ep.name, "Action")
+        if plugin_cls is None:
+            continue
+        try:
+            instance = plugin_cls()
+        except Exception:
+            logger.exception("pygitweb: failed to instantiate action %r", ep.name)
+            continue
+        name = instance.action_name()
+        if name in BUILTIN_ACTIONS:
+            logger.warning(
+                "pygitweb: plugin action %r conflicts with built-in; skipping entry %r",
+                name,
+                ep.name,
+            )
+            continue
+        if name in out:
+            logger.warning("pygitweb: duplicate plugin action %r; skipping entry %r", name, ep.name)
+            continue
+        out[name] = instance
+    return out
 
 
 def reload_plugins() -> tuple[dict[str, Action], list[Subpage]]:
-	"""Re-read ENABLED_PLUGINS and return freshly loaded actions and subpages."""
-	from pygitweb.config import settings
+    """Re-read ENABLED_PLUGINS and return freshly loaded actions and subpages."""
+    from pygitweb.config import settings
 
-	allowed = frozenset(settings.ENABLED_PLUGINS)
-	return load_plugin_actions(allowed), load_subpages(allowed)
+    allowed = frozenset(settings.ENABLED_PLUGINS)
+    return load_plugin_actions(allowed), load_subpages(allowed)
 
 
 def apply_plugin_reload() -> None:
-	"""Reload plugin actions/subpages from ENABLED_PLUGINS into the runtime registry."""
-	actions, subpages = reload_plugins()
-	PLUGIN_ACTIONS.clear()
-	PLUGIN_ACTIONS.update(actions)
-	SUBPAGES.clear()
-	SUBPAGES.extend(subpages)
-	DISPATCH_ACTIONS.clear()
-	DISPATCH_ACTIONS.update(BUILTIN_ACTIONS)
-	DISPATCH_ACTIONS.update(PLUGIN_ACTIONS.keys())
+    """Reload plugin actions/subpages from ENABLED_PLUGINS into the runtime registry."""
+    actions, subpages = reload_plugins()
+    PLUGIN_ACTIONS.clear()
+    PLUGIN_ACTIONS.update(actions)
+    SUBPAGES.clear()
+    SUBPAGES.extend(subpages)
+    DISPATCH_ACTIONS.clear()
+    DISPATCH_ACTIONS.update(BUILTIN_ACTIONS)
+    DISPATCH_ACTIONS.update(PLUGIN_ACTIONS.keys())
diff --git a/pygitweb/src/pygitweb/plugin_loader_test.py b/pygitweb/src/pygitweb/plugin_loader_test.py
index 5de4c27..a7e5bd8 100644
--- a/pygitweb/src/pygitweb/plugin_loader_test.py
+++ b/pygitweb/src/pygitweb/plugin_loader_test.py
@@ -10,145 +10,145 @@ from starlette.responses import PlainTextResponse, Response
 from pygitweb.api.action import Action
 from pygitweb.api.subpage import Subpage
 from pygitweb.plugin_loader import (
-	ACTION_GROUP,
-	DISPATCH_ACTIONS,
-	PLUGIN_ACTIONS,
-	SUBPAGE_GROUP,
-	SUBPAGES,
-	apply_plugin_reload,
-	list_available_plugins,
-	load_plugin_actions,
-	load_subpages,
+    ACTION_GROUP,
+    DISPATCH_ACTIONS,
+    PLUGIN_ACTIONS,
+    SUBPAGE_GROUP,
+    SUBPAGES,
+    apply_plugin_reload,
+    list_available_plugins,
+    load_plugin_actions,
+    load_subpages,
 )
 
 
 class _DemoAction(Action):
-	def action_name(self) -> str:
-		return "demo_action"
+    def action_name(self) -> str:
+        return "demo_action"
 
-	def action(self, project: str, req: Request | None = None) -> Response | None:
-		return PlainTextResponse(project)
+    def action(self, project: str, req: Request | None = None) -> Response | None:
+        return PlainTextResponse(project)
 
 
 class _OtherAction(Action):
-	def action_name(self) -> str:
-		return "other_action"
+    def action_name(self) -> str:
+        return "other_action"
 
-	def action(self, project: str, req: Request | None = None) -> Response | None:
-		return PlainTextResponse(project)
+    def action(self, project: str, req: Request | None = None) -> Response | None:
+        return PlainTextResponse(project)
 
 
 class _DemoSubpage(Subpage):
-	def subpage_name(self) -> str:
-		return "demo_subpage"
+    def subpage_name(self) -> str:
+        return "demo_subpage"
 
 
 def _ep(name: str, dist_name: str, loaded: object) -> SimpleNamespace:
-	return SimpleNamespace(
-		name=name,
-		dist=SimpleNamespace(name=dist_name),
-		load=lambda: loaded,
-	)
+    return SimpleNamespace(
+        name=name,
+        dist=SimpleNamespace(name=dist_name),
+        load=lambda: loaded,
+    )
 
 
 def _fake_entry_points(group: str) -> list[SimpleNamespace]:
-	if group == ACTION_GROUP:
-		return [
-			_ep("demo", "pygitweb_demo", _DemoAction),
-			_ep("other", "pygitweb_other", _OtherAction),
-		]
-	if group == SUBPAGE_GROUP:
-		return [_ep("demo_sp", "pygitweb_demo", _DemoSubpage)]
-	return []
+    if group == ACTION_GROUP:
+        return [
+            _ep("demo", "pygitweb_demo", _DemoAction),
+            _ep("other", "pygitweb_other", _OtherAction),
+        ]
+    if group == SUBPAGE_GROUP:
+        return [_ep("demo_sp", "pygitweb_demo", _DemoSubpage)]
+    return []
 
 
 def test_list_available_plugins_without_enablement() -> None:
-	with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
-		assert list_available_plugins() == ["pygitweb_demo", "pygitweb_other"]
+    with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
+        assert list_available_plugins() == ["pygitweb_demo", "pygitweb_other"]
 
 
 def test_load_skips_disabled_plugins() -> None:
-	with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
-		assert load_plugin_actions(frozenset()) == {}
-		assert load_subpages(frozenset()) == []
-		actions = load_plugin_actions(frozenset({"pygitweb_demo"}))
-		assert set(actions) == {"demo_action"}
-		subpages = load_subpages(frozenset({"pygitweb_demo"}))
-		assert [s.subpage_name() for s in subpages] == ["demo_subpage"]
-		assert subpages[0].link_name("proj") is None
-		assert subpages[0].subpage_html("proj") is None
-		assert subpages[0].summary_value_suffix_html("proj", "proj") is None
+    with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
+        assert load_plugin_actions(frozenset()) == {}
+        assert load_subpages(frozenset()) == []
+        actions = load_plugin_actions(frozenset({"pygitweb_demo"}))
+        assert set(actions) == {"demo_action"}
+        subpages = load_subpages(frozenset({"pygitweb_demo"}))
+        assert [s.subpage_name() for s in subpages] == ["demo_subpage"]
+        assert subpages[0].link_name("proj") is None
+        assert subpages[0].subpage_html("proj") is None
+        assert subpages[0].summary_value_suffix_html("proj", "proj") is None
 
 
 def test_load_plugin_actions_skips_annotation_mismatch() -> None:
-	class _MismatchedAction:
-		def action_name(self) -> int:
-			return 1
+    class _MismatchedAction:
+        def action_name(self) -> int:
+            return 1
 
-		def action(self, project: str, req: Request | None = None) -> Response | None:
-			return None
+        def action(self, project: str, req: Request | None = None) -> Response | None:
+            return None
 
-	eps = [_ep("bad", "pygitweb_bad", _MismatchedAction)]
-	with patch("pygitweb.plugin_loader._entry_points", return_value=eps):
-		assert load_plugin_actions(frozenset({"pygitweb_bad"})) == {}
+    eps = [_ep("bad", "pygitweb_bad", _MismatchedAction)]
+    with patch("pygitweb.plugin_loader._entry_points", return_value=eps):
+        assert load_plugin_actions(frozenset({"pygitweb_bad"})) == {}
 
 
 def test_load_subpages_fills_default_methods() -> None:
-	class _VirtualSubpage:
-		def subpage_name(self) -> str:
-			return "virtual"
+    class _VirtualSubpage:
+        def subpage_name(self) -> str:
+            return "virtual"
 
-	eps = [_ep("virt", "pygitweb_virt", _VirtualSubpage)]
-	with patch("pygitweb.plugin_loader._entry_points", return_value=eps):
-		subpages = load_subpages(frozenset({"pygitweb_virt"}))
-	assert len(subpages) == 1
-	assert subpages[0].subpage_name() == "virtual"
-	assert subpages[0].link_name("p") is None
-	assert subpages[0].subpage_html("p") is None
-	assert subpages[0].summary_value_suffix_html("p", "p") is None
+    eps = [_ep("virt", "pygitweb_virt", _VirtualSubpage)]
+    with patch("pygitweb.plugin_loader._entry_points", return_value=eps):
+        subpages = load_subpages(frozenset({"pygitweb_virt"}))
+    assert len(subpages) == 1
+    assert subpages[0].subpage_name() == "virtual"
+    assert subpages[0].link_name("p") is None
+    assert subpages[0].subpage_html("p") is None
+    assert subpages[0].summary_value_suffix_html("p", "p") is None
 
 
 def test_action_subclass_must_match_annotations() -> None:
-	with pytest.raises(TypeError, match="does not fully implement 'Action'"):
+    with pytest.raises(TypeError, match="does not fully implement 'Action'"):
 
-		class _BadAction(Action):
-			def action_name(self) -> int:
-				return 1
+        class _BadAction(Action):
+            def action_name(self) -> int:
+                return 1
 
-			def action(self, project: str, req: Request | None = None) -> Response | None:
-				return None
+            def action(self, project: str, req: Request | None = None) -> Response | None:
+                return None
 
 
 def test_subpage_override_must_match_annotations() -> None:
-	with pytest.raises(TypeError, match="does not fully implement 'Subpage'"):
+    with pytest.raises(TypeError, match="does not fully implement 'Subpage'"):
 
-		class _BadSubpage(Subpage):
-			def subpage_name(self) -> str:
-				return "bad"
+        class _BadSubpage(Subpage):
+            def subpage_name(self) -> str:
+                return "bad"
 
-			def link_name(self, project: str) -> int:
-				return 1
+            def link_name(self, project: str) -> int:
+                return 1
 
 
 def test_apply_plugin_reload_updates_registry(monkeypatch) -> None:
-	from pygitweb.config import settings
-
-	previous = list(settings.ENABLED_PLUGINS)
-	monkeypatch.setattr(settings, "ENABLED_PLUGINS", ["pygitweb_other"])
-	with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
-		try:
-			apply_plugin_reload()
-			assert set(PLUGIN_ACTIONS) == {"other_action"}
-			assert SUBPAGES == []
-			assert "other_action" in DISPATCH_ACTIONS
-			assert "demo_action" not in DISPATCH_ACTIONS
-
-			monkeypatch.setattr(settings, "ENABLED_PLUGINS", ["pygitweb_demo"])
-			apply_plugin_reload()
-			assert set(PLUGIN_ACTIONS) == {"demo_action"}
-			assert [s.subpage_name() for s in SUBPAGES] == ["demo_subpage"]
-			assert "demo_action" in DISPATCH_ACTIONS
-			assert "other_action" not in DISPATCH_ACTIONS
-		finally:
-			monkeypatch.setattr(settings, "ENABLED_PLUGINS", previous)
-			apply_plugin_reload()
+    from pygitweb.config import settings
+
+    previous = list(settings.ENABLED_PLUGINS)
+    monkeypatch.setattr(settings, "ENABLED_PLUGINS", ["pygitweb_other"])
+    with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
+        try:
+            apply_plugin_reload()
+            assert set(PLUGIN_ACTIONS) == {"other_action"}
+            assert SUBPAGES == []
+            assert "other_action" in DISPATCH_ACTIONS
+            assert "demo_action" not in DISPATCH_ACTIONS
+
+            monkeypatch.setattr(settings, "ENABLED_PLUGINS", ["pygitweb_demo"])
+            apply_plugin_reload()
+            assert set(PLUGIN_ACTIONS) == {"demo_action"}
+            assert [s.subpage_name() for s in SUBPAGES] == ["demo_subpage"]
+            assert "demo_action" in DISPATCH_ACTIONS
+            assert "other_action" not in DISPATCH_ACTIONS
+        finally:
+            monkeypatch.setattr(settings, "ENABLED_PLUGINS", previous)
+            apply_plugin_reload()
diff --git a/pygitweb/src/pygitweb/project_list_routes_test.py b/pygitweb/src/pygitweb/project_list_routes_test.py
index c2f348f..cf0163f 100644
--- a/pygitweb/src/pygitweb/project_list_routes_test.py
+++ b/pygitweb/src/pygitweb/project_list_routes_test.py
@@ -15,44 +15,44 @@ from pygitweb.permissions import PermissionsMap
 
 
 def _seed_repo(repo_dir: Path) -> None:
-	pygit2.init_repository(str(repo_dir), bare=False)
-	(repo_dir / "README.md").write_text("hello\n", encoding="utf-8")
+    pygit2.init_repository(str(repo_dir), bare=False)
+    (repo_dir / "README.md").write_text("hello\n", encoding="utf-8")
 
 
 @pytest.fixture
 def hidden_projects_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
-	root = tmp_path / "project-list"
-	repo_dir = root / "alpha"
-	repo_dir.mkdir(parents=True)
-	_seed_repo(repo_dir)
-	auth = AuthConfig(
-		auth_mode="local",
-		local_users=[LocalUser(user="admin", password="secret")],
-		oauth_permissions=PermissionsMap.model_validate({"*": []}),
-	)
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(root)),
-		patch.object(settings, "PROJECT_MAXDEPTH", 2),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "LIST_ALL", True),
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.auth_config.auth_config", auth),
-		patch("pygitweb.auth.auth_config", auth),
-		patch("pygitweb.main.auth_config", auth),
-	):
-		yield {"root": str(root)}
+    root = tmp_path / "project-list"
+    repo_dir = root / "alpha"
+    repo_dir.mkdir(parents=True)
+    _seed_repo(repo_dir)
+    auth = AuthConfig(
+        auth_mode="local",
+        local_users=[LocalUser(user="admin", password="secret")],
+        oauth_permissions=PermissionsMap.model_validate({"*": []}),
+    )
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(root)),
+        patch.object(settings, "PROJECT_MAXDEPTH", 2),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "LIST_ALL", True),
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.auth_config.auth_config", auth),
+        patch("pygitweb.auth.auth_config", auth),
+        patch("pygitweb.main.auth_config", auth),
+    ):
+        yield {"root": str(root)}
 
 
 def test_home_page_shows_auth_visibility_notice_when_no_readable_projects(
-	hidden_projects_env: dict[str, str],
+    hidden_projects_env: dict[str, str],
 ) -> None:
-	del hidden_projects_env
-	client = TestClient(app)
-	resp = client.get("/")
-	assert resp.status_code == 200
-	assert "No projects are visible" in resp.text
-	assert "Sign in or ask an administrator for repository access." in resp.text
-	assert 'href="/login?next=/"' in resp.text
-	assert "<table" not in resp.text
+    del hidden_projects_env
+    client = TestClient(app)
+    resp = client.get("/")
+    assert resp.status_code == 200
+    assert "No projects are visible" in resp.text
+    assert "Sign in or ask an administrator for repository access." in resp.text
+    assert 'href="/login?next=/"' in resp.text
+    assert "<table" not in resp.text
diff --git a/pygitweb/src/pygitweb/projects.py b/pygitweb/src/pygitweb/projects.py
index c2d0061..d107820 100644
--- a/pygitweb/src/pygitweb/projects.py
+++ b/pygitweb/src/pygitweb/projects.py
@@ -18,198 +18,198 @@ from pygitweb.validation import check_export_ok
 
 
 def project_in_list(
-	project: str,
-	get_projects_list_fn: Callable[[], list[dict[str, Any]]],
+    project: str,
+    get_projects_list_fn: Callable[[], list[dict[str, Any]]],
 ) -> bool:
-	"""True if project appears in project list. Port of project_in_list."""
-	lst = get_projects_list_fn()
-	return any(p.get("path") == project for p in lst)
+    """True if project appears in project list. Port of project_in_list."""
+    lst = get_projects_list_fn()
+    return any(p.get("path") == project for p in lst)
 
 
 def _find_projects_in_dir(
-	root: str,
-	prefix_len: int,
-	prefix_depth: int,
-	maxdepth: int,
-	export_ok: str,
-	export_auth_hook: Callable[[str], bool] | None,
-	skip_export_check: bool = False,
+    root: str,
+    prefix_len: int,
+    prefix_depth: int,
+    maxdepth: int,
+    export_ok: str,
+    export_auth_hook: Callable[[str], bool] | None,
+    skip_export_check: bool = False,
 ) -> list[dict[str, Any]]:
-	result = []
-	for dirpath, dirnames, _ in os.walk(root, topdown=True):
-		rel = os.path.relpath(dirpath, root)
-		depth = 0 if rel == "." else rel.count(os.sep) + 1
-		if depth >= maxdepth:
-			dirnames.clear()
-			continue
-		for d in list(dirnames):
-			path = os.path.join(dirpath, d)
-			if not os.path.isdir(path):
-				continue
-			try:
-				if not os.access(path, os.X_OK):
-					continue
-			except OSError:
-				continue
-			repo = pygit2.discover_repository(path)
-			if not repo:
-				continue
-			project_path = os.path.relpath(path, settings.PROJECTROOT)
-			project_path = project_path.replace("\\", "/")
-			git_dir = os.path.join(settings.PROJECTROOT, project_path)
-			if not skip_export_check and not check_export_ok(git_dir, export_ok, export_auth_hook):
-				continue
-			result.append({"path": project_path})
-			dirnames.remove(d)
-	return result
+    result = []
+    for dirpath, dirnames, _ in os.walk(root, topdown=True):
+        rel = os.path.relpath(dirpath, root)
+        depth = 0 if rel == "." else rel.count(os.sep) + 1
+        if depth >= maxdepth:
+            dirnames.clear()
+            continue
+        for d in list(dirnames):
+            path = os.path.join(dirpath, d)
+            if not os.path.isdir(path):
+                continue
+            try:
+                if not os.access(path, os.X_OK):
+                    continue
+            except OSError:
+                continue
+            repo = pygit2.discover_repository(path)
+            if not repo:
+                continue
+            project_path = os.path.relpath(path, settings.PROJECTROOT)
+            project_path = project_path.replace("\\", "/")
+            git_dir = os.path.join(settings.PROJECTROOT, project_path)
+            if not skip_export_check and not check_export_ok(git_dir, export_ok, export_auth_hook):
+                continue
+            result.append({"path": project_path})
+            dirnames.remove(d)
+    return result
 
 
 def git_get_projects_list(
-	filter_path: str = "",
-	paranoid: bool = False,
-	projectroot: str | None = None,
-	projects_list: str | None = None,
-	project_maxdepth: int | None = None,
-	export_ok: str = "",
-	export_auth_hook: Callable[[str], bool] | None = None,
+    filter_path: str = "",
+    paranoid: bool = False,
+    projectroot: str | None = None,
+    projects_list: str | None = None,
+    project_maxdepth: int | None = None,
+    export_ok: str = "",
+    export_auth_hook: Callable[[str], bool] | None = None,
 ) -> list[dict[str, Any]]:
-	"""List projects from directory scan or file. Port of git_get_projects_list."""
-	projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
-	projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
-	project_maxdepth = project_maxdepth if project_maxdepth is not None else settings.PROJECT_MAXDEPTH
-	if os.path.isdir(projects_list):
-		root = projects_list.rstrip("/")
-		prefix_len = len(root) + 1
-		prefix_depth = root.count(os.sep)
-		if filter_path and not paranoid:
-			root = os.path.join(root, filter_path).rstrip("/")
-		result = _find_projects_in_dir(
-			root,
-			prefix_len,
-			prefix_depth,
-			project_maxdepth,
-			export_ok,
-			export_auth_hook,
-			skip_export_check=settings.LIST_ALL,
-		)
-		if filter_path and paranoid:
-			result = [p for p in result if p["path"].startswith(filter_path + "/")]
-		return result
-	if os.path.isfile(projects_list):
-		from urllib.parse import unquote
-
-		result = []
-		with open(projects_list) as f:
-			for line in f:
-				line = line.strip()
-				if not line:
-					continue
-				parts = line.split(None, 1)
-				path = unquote(parts[0]) if parts else ""
-				owner = unquote(parts[1]) if len(parts) > 1 else None
-				if not path:
-					continue
-				if filter_path and not path.startswith(filter_path + "/"):
-					continue
-				git_dir = os.path.join(projectroot, path)
-				if not settings.LIST_ALL and not check_export_ok(git_dir, export_ok, export_auth_hook):
-					continue
-				pr = {"path": path}
-				if owner:
-					pr["owner"] = owner
-				result.append(pr)
-		return result
-	return []
+    """List projects from directory scan or file. Port of git_get_projects_list."""
+    projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
+    projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
+    project_maxdepth = project_maxdepth if project_maxdepth is not None else settings.PROJECT_MAXDEPTH
+    if os.path.isdir(projects_list):
+        root = projects_list.rstrip("/")
+        prefix_len = len(root) + 1
+        prefix_depth = root.count(os.sep)
+        if filter_path and not paranoid:
+            root = os.path.join(root, filter_path).rstrip("/")
+        result = _find_projects_in_dir(
+            root,
+            prefix_len,
+            prefix_depth,
+            project_maxdepth,
+            export_ok,
+            export_auth_hook,
+            skip_export_check=settings.LIST_ALL,
+        )
+        if filter_path and paranoid:
+            result = [p for p in result if p["path"].startswith(filter_path + "/")]
+        return result
+    if os.path.isfile(projects_list):
+        from urllib.parse import unquote
+
+        result = []
+        with open(projects_list) as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                parts = line.split(None, 1)
+                path = unquote(parts[0]) if parts else ""
+                owner = unquote(parts[1]) if len(parts) > 1 else None
+                if not path:
+                    continue
+                if filter_path and not path.startswith(filter_path + "/"):
+                    continue
+                git_dir = os.path.join(projectroot, path)
+                if not settings.LIST_ALL and not check_export_ok(git_dir, export_ok, export_auth_hook):
+                    continue
+                pr = {"path": path}
+                if owner:
+                    pr["owner"] = owner
+                result.append(pr)
+        return result
+    return []
 
 
 _gitweb_project_owner: dict[str, str] | None = None
 
 
 def git_get_project_list_from_file(
-	projects_list: str | None = None,
-	projectroot: str | None = None,
+    projects_list: str | None = None,
+    projectroot: str | None = None,
 ) -> dict[str, str]:
-	"""Load project -> owner from file. Port of git_get_project_list_from_file."""
-	global _gitweb_project_owner
-	if _gitweb_project_owner is not None:
-		return _gitweb_project_owner
-	projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
-	_gitweb_project_owner = {}
-	if os.path.isfile(projects_list):
-		from urllib.parse import unquote
-
-		with open(projects_list) as f:
-			for line in f:
-				line = line.strip()
-				if not line:
-					continue
-				parts = line.split(None, 1)
-				path = unquote(parts[0]) if parts else ""
-				owner = unquote(parts[1]) if len(parts) > 1 else ""
-				if path:
-					_gitweb_project_owner[path] = owner
-	return _gitweb_project_owner
+    """Load project -> owner from file. Port of git_get_project_list_from_file."""
+    global _gitweb_project_owner
+    if _gitweb_project_owner is not None:
+        return _gitweb_project_owner
+    projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
+    _gitweb_project_owner = {}
+    if os.path.isfile(projects_list):
+        from urllib.parse import unquote
+
+        with open(projects_list) as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                parts = line.split(None, 1)
+                path = unquote(parts[0]) if parts else ""
+                owner = unquote(parts[1]) if len(parts) > 1 else ""
+                if path:
+                    _gitweb_project_owner[path] = owner
+    return _gitweb_project_owner
 
 
 def git_get_project_owner(
-	project: str,
-	projectroot: str | None = None,
-	get_project_config: Callable[[str, str], int | str | list[str] | bool | None] | None = None,
+    project: str,
+    projectroot: str | None = None,
+    get_project_config: Callable[[str, str], int | str | list[str] | bool | None] | None = None,
 ) -> str | None:
-	"""Owner from list file or config or file ownership. Port of git_get_project_owner."""
-	if not project:
-		return None
-	projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
-	owners = git_get_project_list_from_file(settings.PROJECTS_LIST, projectroot)
-	if project in owners and owners[project]:
-		return owners[project]
-	if get_project_config:
-		val = get_project_config(project, "owner")
-		if isinstance(val, list) and val:
-			return val[0]
-		if isinstance(val, str) and val:
-			return val
-	return None
+    """Owner from list file or config or file ownership. Port of git_get_project_owner."""
+    if not project:
+        return None
+    projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
+    owners = git_get_project_list_from_file(settings.PROJECTS_LIST, projectroot)
+    if project in owners and owners[project]:
+        return owners[project]
+    if get_project_config:
+        val = get_project_config(project, "owner")
+        if isinstance(val, list) and val:
+            return val[0]
+        if isinstance(val, str) and val:
+            return val
+    return None
 
 
 def git_get_last_activity(project: str, projectroot: str | None = None) -> int | None:
-	"""Last commit timestamp for project. Port of git_get_last_activity."""
-	try:
-		repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-		if not repo.head:
-			return None
-		commit = repo[repo.head.target]
-		if isinstance(commit, pygit2.Commit):
-			return commit.committer.time
-	except (KeyError, pygit2.GitError, OSError):
-		return None
-	return None
+    """Last commit timestamp for project. Port of git_get_last_activity."""
+    try:
+        repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+        if not repo.head:
+            return None
+        commit = repo[repo.head.target]
+        if isinstance(commit, pygit2.Commit):
+            return commit.committer.time
+    except (KeyError, pygit2.GitError, OSError):
+        return None
+    return None
 
 
 def search_projects_list(
-	projlist: list[dict[str, Any]],
-	tagfilter: str | None = None,
-	search_regexp: str | None = None,
-	fill_project_list_info: Callable[..., None] | None = None,
+    projlist: list[dict[str, Any]],
+    tagfilter: str | None = None,
+    search_regexp: str | None = None,
+    fill_project_list_info: Callable[..., None] | None = None,
 ) -> list[dict[str, Any]]:
-	"""Filter by tag or search regex. Port of search_projects_list."""
-	if not tagfilter and not search_regexp:
-		return projlist
-	if fill_project_list_info:
-		fill_project_list_info(projlist, tagfilter=tagfilter, search_re=search_regexp)
-	result = []
-	for pr in projlist:
-		if tagfilter:
-			ctags = pr.get("ctags") or {}
-			if not any(k.lower() == tagfilter.lower() for k in ctags):
-				continue
-		if search_regexp:
-			try:
-				rex = re.compile(search_regexp)
-			except re.error:
-				continue
-			descr = (pr.get("descr_long") or "") + (pr.get("path") or "")
-			if not rex.search(descr):
-				continue
-		result.append(pr)
-	return result
+    """Filter by tag or search regex. Port of search_projects_list."""
+    if not tagfilter and not search_regexp:
+        return projlist
+    if fill_project_list_info:
+        fill_project_list_info(projlist, tagfilter=tagfilter, search_re=search_regexp)
+    result = []
+    for pr in projlist:
+        if tagfilter:
+            ctags = pr.get("ctags") or {}
+            if not any(k.lower() == tagfilter.lower() for k in ctags):
+                continue
+        if search_regexp:
+            try:
+                rex = re.compile(search_regexp)
+            except re.error:
+                continue
+            descr = (pr.get("descr_long") or "") + (pr.get("path") or "")
+            if not rex.search(descr):
+                continue
+        result.append(pr)
+    return result
diff --git a/pygitweb/src/pygitweb/projects_test.py b/pygitweb/src/pygitweb/projects_test.py
index 3a4acb1..060e073 100644
--- a/pygitweb/src/pygitweb/projects_test.py
+++ b/pygitweb/src/pygitweb/projects_test.py
@@ -12,175 +12,175 @@ from pygitweb.config import settings
 
 
 def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path, message: str) -> None:
-	(repo_dir / "README.md").write_text(f"{message}\n", encoding="utf-8")
-	index = repo.index
-	index.add("README.md")
-	index.write()
-	tree = index.write_tree()
-	sig = pygit2.Signature("tester", "tester@example.com")
-	repo.create_commit("HEAD", sig, sig, message, tree, [])
+    (repo_dir / "README.md").write_text(f"{message}\n", encoding="utf-8")
+    index = repo.index
+    index.add("README.md")
+    index.write()
+    tree = index.write_tree()
+    sig = pygit2.Signature("tester", "tester@example.com")
+    repo.create_commit("HEAD", sig, sig, message, tree, [])
 
 
 class TestProjects:
-	@pytest.fixture(scope="class")
-	def project_env(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
-		root: Path = tmp_path_factory.mktemp("projects")
-
-		alpha_dir: Path = root / "alpha"
-		alpha_dir.mkdir()
-		alpha_repo = pygit2.init_repository(str(alpha_dir), bare=False)
-		_create_initial_commit(alpha_repo, alpha_dir, "alpha init")
-		(alpha_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
-
-		nested_dir: Path = root / "group"
-		nested_dir.mkdir()
-		beta_dir: Path = nested_dir / "beta"
-		beta_dir.mkdir()
-		beta_repo = pygit2.init_repository(str(beta_dir), bare=False)
-		_create_initial_commit(beta_repo, beta_dir, "beta init")
-		(beta_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
-
-		private_dir: Path = root / "private"
-		private_dir.mkdir()
-		private_repo = pygit2.init_repository(str(private_dir), bare=False)
-		_create_initial_commit(private_repo, private_dir, "private init")
-
-		(root / "not-a-repo").mkdir()
-
-		projects_list_file: Path = root / "projects.list"
-		projects_list_file.write_text(
-			"\n".join([
-				"alpha Alice",
-				"group/beta Bob%20Owner",
-				"private",
-				"name%20with%20space Owner%20Space",
-			])
-			+ "\n",
-			encoding="utf-8",
-		)
-
-		try:
-			yield {
-				"root": str(root),
-				"alpha": "alpha",
-				"beta": "group/beta",
-				"private": "private",
-				"projects_list_file": str(projects_list_file),
-			}
-		finally:
-			projects._gitweb_project_owner = None
-
-	class TestProjectInList:
-		def test_project_in_list_true(self) -> None:
-			def getter() -> list[dict[str, str]]:
-				return [{"path": "alpha"}, {"path": "beta"}]
-
-			assert projects.project_in_list("alpha", getter) is True
-
-		def test_project_in_list_false(self) -> None:
-			def getter() -> list[dict[str, str]]:
-				return [{"path": "alpha"}]
-
-			assert projects.project_in_list("missing", getter) is False
-
-	class TestProjectDiscovery:
-		def test_git_get_projects_list_directory_mode(self, project_env: dict[str, str]) -> None:
-			with (
-				patch.object(settings, "PROJECTROOT", project_env["root"]),
-				patch.object(settings, "PROJECTS_LIST", project_env["root"]),
-				patch.object(settings, "PROJECT_MAXDEPTH", 3),
-				patch.object(settings, "LIST_ALL", True),
-			):
-				result = projects.git_get_projects_list(export_ok="git-daemon-export-ok")
-			paths = {entry["path"] for entry in result}
-			assert {"alpha", "group/beta", "private"} <= paths
-
-		def test_git_get_projects_list_respects_export_ok_when_list_all_false(
-			self,
-			project_env: dict[str, str],
-		) -> None:
-			with (
-				patch.object(settings, "PROJECTROOT", project_env["root"]),
-				patch.object(settings, "PROJECTS_LIST", project_env["root"]),
-				patch.object(settings, "PROJECT_MAXDEPTH", 3),
-				patch.object(settings, "LIST_ALL", False),
-			):
-				result = projects.git_get_projects_list(export_ok="git-daemon-export-ok")
-			paths = {entry["path"] for entry in result}
-			assert "alpha" in paths
-			assert "group/beta" in paths
-			assert "private" not in paths
-
-		def test_git_get_projects_list_filter_path(self, project_env: dict[str, str]) -> None:
-			with (
-				patch.object(settings, "PROJECTROOT", project_env["root"]),
-				patch.object(settings, "PROJECTS_LIST", project_env["root"]),
-				patch.object(settings, "PROJECT_MAXDEPTH", 3),
-				patch.object(settings, "LIST_ALL", True),
-			):
-				result = projects.git_get_projects_list(filter_path="group")
-			assert result == [{"path": "group/beta"}]
-
-		def test_git_get_projects_list_file_mode(self, project_env: dict[str, str]) -> None:
-			with (
-				patch.object(settings, "PROJECTROOT", project_env["root"]),
-				patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
-				patch.object(settings, "LIST_ALL", True),
-			):
-				result = projects.git_get_projects_list()
-			assert result == [
-				{"path": "alpha", "owner": "Alice"},
-				{"path": "group/beta", "owner": "Bob Owner"},
-				{"path": "private"},
-				{"path": "name with space", "owner": "Owner Space"},
-			]
-
-	class TestProjectOwnerAndActivity:
-		def test_git_get_project_list_from_file_and_cache(self, project_env: dict[str, str]) -> None:
-			projects._gitweb_project_owner = None
-			with patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]):
-				owners_first = projects.git_get_project_list_from_file()
-				owners_second = projects.git_get_project_list_from_file()
-			assert owners_first is owners_second
-			assert owners_first["alpha"] == "Alice"
-			assert owners_first["group/beta"] == "Bob Owner"
-
-		def test_git_get_project_owner_from_file(self, project_env: dict[str, str]) -> None:
-			projects._gitweb_project_owner = None
-			with (
-				patch.object(settings, "PROJECTROOT", project_env["root"]),
-				patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
-			):
-				owner = projects.git_get_project_owner("alpha")
-			assert owner == "Alice"
-
-		def test_git_get_project_owner_from_config_fallback(self, project_env: dict[str, str]) -> None:
-			projects._gitweb_project_owner = None
-			with (
-				patch.object(settings, "PROJECTROOT", project_env["root"]),
-				patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
-			):
-				owner = projects.git_get_project_owner("private", get_project_config=lambda _p, _k: "Config Owner")
-			assert owner == "Config Owner"
-
-		def test_git_get_last_activity_for_repo_and_missing_project(self, project_env: dict[str, str]) -> None:
-			with patch.object(settings, "PROJECTROOT", project_env["root"]):
-				last_activity = projects.git_get_last_activity(project_env["alpha"])
-				missing_activity = projects.git_get_last_activity("does-not-exist")
-			assert isinstance(last_activity, int)
-			assert last_activity > 0
-			assert missing_activity is None
-
-	class TestSearchProjects:
-		def test_search_projects_list_by_tag_and_regex(self) -> None:
-			projlist: list[dict[str, object]] = [
-				{"path": "alpha", "descr_long": "backend api", "ctags": {"infra": 1}},
-				{"path": "beta", "descr_long": "frontend web", "ctags": {"ui": 1}},
-			]
-			result = projects.search_projects_list(projlist, tagfilter="infra", search_regexp="backend")
-			assert result == [projlist[0]]
-
-		def test_search_projects_list_invalid_regex_returns_empty(self) -> None:
-			projlist: list[dict[str, object]] = [{"path": "alpha", "descr_long": "desc"}]
-			result = projects.search_projects_list(projlist, search_regexp="(")
-			assert result == []
+    @pytest.fixture(scope="class")
+    def project_env(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
+        root: Path = tmp_path_factory.mktemp("projects")
+
+        alpha_dir: Path = root / "alpha"
+        alpha_dir.mkdir()
+        alpha_repo = pygit2.init_repository(str(alpha_dir), bare=False)
+        _create_initial_commit(alpha_repo, alpha_dir, "alpha init")
+        (alpha_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
+
+        nested_dir: Path = root / "group"
+        nested_dir.mkdir()
+        beta_dir: Path = nested_dir / "beta"
+        beta_dir.mkdir()
+        beta_repo = pygit2.init_repository(str(beta_dir), bare=False)
+        _create_initial_commit(beta_repo, beta_dir, "beta init")
+        (beta_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
+
+        private_dir: Path = root / "private"
+        private_dir.mkdir()
+        private_repo = pygit2.init_repository(str(private_dir), bare=False)
+        _create_initial_commit(private_repo, private_dir, "private init")
+
+        (root / "not-a-repo").mkdir()
+
+        projects_list_file: Path = root / "projects.list"
+        projects_list_file.write_text(
+            "\n".join([
+                "alpha Alice",
+                "group/beta Bob%20Owner",
+                "private",
+                "name%20with%20space Owner%20Space",
+            ])
+            + "\n",
+            encoding="utf-8",
+        )
+
+        try:
+            yield {
+                "root": str(root),
+                "alpha": "alpha",
+                "beta": "group/beta",
+                "private": "private",
+                "projects_list_file": str(projects_list_file),
+            }
+        finally:
+            projects._gitweb_project_owner = None
+
+    class TestProjectInList:
+        def test_project_in_list_true(self) -> None:
+            def getter() -> list[dict[str, str]]:
+                return [{"path": "alpha"}, {"path": "beta"}]
+
+            assert projects.project_in_list("alpha", getter) is True
+
+        def test_project_in_list_false(self) -> None:
+            def getter() -> list[dict[str, str]]:
+                return [{"path": "alpha"}]
+
+            assert projects.project_in_list("missing", getter) is False
+
+    class TestProjectDiscovery:
+        def test_git_get_projects_list_directory_mode(self, project_env: dict[str, str]) -> None:
+            with (
+                patch.object(settings, "PROJECTROOT", project_env["root"]),
+                patch.object(settings, "PROJECTS_LIST", project_env["root"]),
+                patch.object(settings, "PROJECT_MAXDEPTH", 3),
+                patch.object(settings, "LIST_ALL", True),
+            ):
+                result = projects.git_get_projects_list(export_ok="git-daemon-export-ok")
+            paths = {entry["path"] for entry in result}
+            assert {"alpha", "group/beta", "private"} <= paths
+
+        def test_git_get_projects_list_respects_export_ok_when_list_all_false(
+            self,
+            project_env: dict[str, str],
+        ) -> None:
+            with (
+                patch.object(settings, "PROJECTROOT", project_env["root"]),
+                patch.object(settings, "PROJECTS_LIST", project_env["root"]),
+                patch.object(settings, "PROJECT_MAXDEPTH", 3),
+                patch.object(settings, "LIST_ALL", False),
+            ):
+                result = projects.git_get_projects_list(export_ok="git-daemon-export-ok")
+            paths = {entry["path"] for entry in result}
+            assert "alpha" in paths
+            assert "group/beta" in paths
+            assert "private" not in paths
+
+        def test_git_get_projects_list_filter_path(self, project_env: dict[str, str]) -> None:
+            with (
+                patch.object(settings, "PROJECTROOT", project_env["root"]),
+                patch.object(settings, "PROJECTS_LIST", project_env["root"]),
+                patch.object(settings, "PROJECT_MAXDEPTH", 3),
+                patch.object(settings, "LIST_ALL", True),
+            ):
+                result = projects.git_get_projects_list(filter_path="group")
+            assert result == [{"path": "group/beta"}]
+
+        def test_git_get_projects_list_file_mode(self, project_env: dict[str, str]) -> None:
+            with (
+                patch.object(settings, "PROJECTROOT", project_env["root"]),
+                patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
+                patch.object(settings, "LIST_ALL", True),
+            ):
+                result = projects.git_get_projects_list()
+            assert result == [
+                {"path": "alpha", "owner": "Alice"},
+                {"path": "group/beta", "owner": "Bob Owner"},
+                {"path": "private"},
+                {"path": "name with space", "owner": "Owner Space"},
+            ]
+
+    class TestProjectOwnerAndActivity:
+        def test_git_get_project_list_from_file_and_cache(self, project_env: dict[str, str]) -> None:
+            projects._gitweb_project_owner = None
+            with patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]):
+                owners_first = projects.git_get_project_list_from_file()
+                owners_second = projects.git_get_project_list_from_file()
+            assert owners_first is owners_second
+            assert owners_first["alpha"] == "Alice"
+            assert owners_first["group/beta"] == "Bob Owner"
+
+        def test_git_get_project_owner_from_file(self, project_env: dict[str, str]) -> None:
+            projects._gitweb_project_owner = None
+            with (
+                patch.object(settings, "PROJECTROOT", project_env["root"]),
+                patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
+            ):
+                owner = projects.git_get_project_owner("alpha")
+            assert owner == "Alice"
+
+        def test_git_get_project_owner_from_config_fallback(self, project_env: dict[str, str]) -> None:
+            projects._gitweb_project_owner = None
+            with (
+                patch.object(settings, "PROJECTROOT", project_env["root"]),
+                patch.object(settings, "PROJECTS_LIST", project_env["projects_list_file"]),
+            ):
+                owner = projects.git_get_project_owner("private", get_project_config=lambda _p, _k: "Config Owner")
+            assert owner == "Config Owner"
+
+        def test_git_get_last_activity_for_repo_and_missing_project(self, project_env: dict[str, str]) -> None:
+            with patch.object(settings, "PROJECTROOT", project_env["root"]):
+                last_activity = projects.git_get_last_activity(project_env["alpha"])
+                missing_activity = projects.git_get_last_activity("does-not-exist")
+            assert isinstance(last_activity, int)
+            assert last_activity > 0
+            assert missing_activity is None
+
+    class TestSearchProjects:
+        def test_search_projects_list_by_tag_and_regex(self) -> None:
+            projlist: list[dict[str, object]] = [
+                {"path": "alpha", "descr_long": "backend api", "ctags": {"infra": 1}},
+                {"path": "beta", "descr_long": "frontend web", "ctags": {"ui": 1}},
+            ]
+            result = projects.search_projects_list(projlist, tagfilter="infra", search_regexp="backend")
+            assert result == [projlist[0]]
+
+        def test_search_projects_list_invalid_regex_returns_empty(self) -> None:
+            projlist: list[dict[str, object]] = [{"path": "alpha", "descr_long": "desc"}]
+            result = projects.search_projects_list(projlist, search_regexp="(")
+            assert result == []
diff --git a/pygitweb/src/pygitweb/sessions.py b/pygitweb/src/pygitweb/sessions.py
index 030ef0c..6dc5eff 100644
--- a/pygitweb/src/pygitweb/sessions.py
+++ b/pygitweb/src/pygitweb/sessions.py
@@ -19,55 +19,55 @@ AuthMethod = Literal["local", "oauth"]
 
 @dataclass(frozen=True)
 class Session:
-	username: str
-	created_at: float
-	subject: str | None = None
-	email: str | None = None
-	auth_method: AuthMethod = "local"
+    username: str
+    created_at: float
+    subject: str | None = None
+    email: str | None = None
+    auth_method: AuthMethod = "local"
 
 
 _sessions: dict[str, Session] = {}
 
 
 def create_session(
-	username: str,
-	*,
-	subject: str | None = None,
-	email: str | None = None,
-	auth_method: AuthMethod = "local",
+    username: str,
+    *,
+    subject: str | None = None,
+    email: str | None = None,
+    auth_method: AuthMethod = "local",
 ) -> str:
-	sid = secrets.token_urlsafe(32)
-	with _lock:
-		_sessions[sid] = Session(
-			username=username,
-			created_at=time.time(),
-			subject=subject,
-			email=email,
-			auth_method=auth_method,
-		)
-	return sid
+    sid = secrets.token_urlsafe(32)
+    with _lock:
+        _sessions[sid] = Session(
+            username=username,
+            created_at=time.time(),
+            subject=subject,
+            email=email,
+            auth_method=auth_method,
+        )
+    return sid
 
 
 def get_session(session_id: str | None) -> Session | None:
-	if not session_id:
-		return None
-	raw = session_id.strip()
-	if not raw:
-		return None
-	with _lock:
-		return _sessions.get(raw)
+    if not session_id:
+        return None
+    raw = session_id.strip()
+    if not raw:
+        return None
+    with _lock:
+        return _sessions.get(raw)
 
 
 def revoke_session(session_id: str | None) -> None:
-	if not session_id:
-		return
-	raw = session_id.strip()
-	if not raw:
-		return
-	with _lock:
-		_sessions.pop(raw, None)
+    if not session_id:
+        return
+    raw = session_id.strip()
+    if not raw:
+        return
+    with _lock:
+        _sessions.pop(raw, None)
 
 
 def clear_all_sessions() -> None:
-	with _lock:
-		_sessions.clear()
+    with _lock:
+        _sessions.clear()
diff --git a/pygitweb/src/pygitweb/settings.py b/pygitweb/src/pygitweb/settings.py
index 4ef37df..982122e 100644
--- a/pygitweb/src/pygitweb/settings.py
+++ b/pygitweb/src/pygitweb/settings.py
@@ -26,8 +26,8 @@ from pygitweb.templates_env import Cell, env, html_format, render_page
 
 
 def _quote_path(path: str) -> str:
-	"""Quote path segment for URL (e.g. project name with slashes)."""
-	return quote(path, safe="/")
+    """Quote path segment for URL (e.g. project name with slashes)."""
+    return quote(path, safe="/")
 
 
 # libgit2 config levels: only local (5) and worktree (6) for per-repo editing
@@ -35,424 +35,424 @@ GIT_CONFIG_LEVEL_LOCAL = 5
 GIT_CONFIG_LEVEL_WORKTREE = 6
 
 router = APIRouter(
-	prefix="/settings",
-	tags=["settings"],
-	dependencies=[Depends(require_permission(Permission.SETTINGS))],
+    prefix="/settings",
+    tags=["settings"],
+    dependencies=[Depends(require_permission(Permission.SETTINGS))],
 )
 
 
 # ---------- PyGitWeb settings schema ----------
 # Name, type, docstring for form generation. Must match Settings field names.
 PYGITWEB_SETTINGS: list[tuple[str, type, str]] = [
-	("PROJECTROOT", str, "Root filesystem path under which git repositories live."),
-	("PROJECTS_LIST", str, "Path used for listing projects (often same as PROJECTROOT)."),
-	("SITE_NAME", str, "Site name shown in the web interface."),
-	("EXPORT_OK", str, "If set, only repos with this file (or matching path) are listed."),
-	("LIST_ALL", bool, "When true, list all directories under project root without export_ok checks."),
-	("STRICT_EXPORT", bool, "When true, require export_ok (or path match) to list projects."),
-	("GIT", str, "Path to the git executable (e.g. for maintenance)."),
-	("MAXLOAD", float, "Max load average; 503 when exceeded. Empty to disable."),
-	("API_KEY_MAX_COUNT", int, "Maximum Smart HTTP API keys each local user may hold."),
-	(
-		"API_KEY_EXPIRY_DAYS",
-		int,
-		"Lifetime of a newly issued API key, in days. 0 means the key does not expire.",
-	),
+    ("PROJECTROOT", str, "Root filesystem path under which git repositories live."),
+    ("PROJECTS_LIST", str, "Path used for listing projects (often same as PROJECTROOT)."),
+    ("SITE_NAME", str, "Site name shown in the web interface."),
+    ("EXPORT_OK", str, "If set, only repos with this file (or matching path) are listed."),
+    ("LIST_ALL", bool, "When true, list all directories under project root without export_ok checks."),
+    ("STRICT_EXPORT", bool, "When true, require export_ok (or path match) to list projects."),
+    ("GIT", str, "Path to the git executable (e.g. for maintenance)."),
+    ("MAXLOAD", float, "Max load average; 503 when exceeded. Empty to disable."),
+    ("API_KEY_MAX_COUNT", int, "Maximum Smart HTTP API keys each local user may hold."),
+    (
+        "API_KEY_EXPIRY_DAYS",
+        int,
+        "Lifetime of a newly issued API key, in days. 0 means the key does not expire.",
+    ),
 ]
 
 
 def _get_pygitweb_values() -> list[tuple[str, Any, type, str]]:
-	"""Return (name, current_value, type, docstring) for each PyGitWeb setting."""
-	return [(name, getattr(settings, name), expected_type, doc) for name, expected_type, doc in PYGITWEB_SETTINGS]
+    """Return (name, current_value, type, docstring) for each PyGitWeb setting."""
+    return [(name, getattr(settings, name), expected_type, doc) for name, expected_type, doc in PYGITWEB_SETTINGS]
 
 
 def _render_form_field(name: str, value: Any, value_type: type, docstring: str, name_prefix: str = "") -> str:
-	"""Generate HTML for a single form field based on type."""
-	field_name = f"{name_prefix}{name}" if name_prefix else name
-	safe_name = field_name.replace(".", "_")
-	tpl = env.get_template("settings_form_field.html")
-	if value_type is bool:
-		return tpl.render(
-			kind="bool",
-			safe_name=safe_name,
-			field_name=field_name,
-			name=name,
-			checked=bool(value),
-			hint_text=docstring,
-		)
-	if value_type in (int, float):
-		raw = "" if value is None else str(value)
-		return tpl.render(
-			kind="number",
-			safe_name=safe_name,
-			field_name=field_name,
-			name=name,
-			str_val=raw,
-			hint_text=docstring,
-			number_step="any",
-		)
-	raw = "" if value is None else str(value)
-	return tpl.render(
-		kind="text",
-		safe_name=safe_name,
-		field_name=field_name,
-		name=name,
-		str_val=raw,
-		hint_text=docstring,
-	)
+    """Generate HTML for a single form field based on type."""
+    field_name = f"{name_prefix}{name}" if name_prefix else name
+    safe_name = field_name.replace(".", "_")
+    tpl = env.get_template("settings_form_field.html")
+    if value_type is bool:
+        return tpl.render(
+            kind="bool",
+            safe_name=safe_name,
+            field_name=field_name,
+            name=name,
+            checked=bool(value),
+            hint_text=docstring,
+        )
+    if value_type in (int, float):
+        raw = "" if value is None else str(value)
+        return tpl.render(
+            kind="number",
+            safe_name=safe_name,
+            field_name=field_name,
+            name=name,
+            str_val=raw,
+            hint_text=docstring,
+            number_step="any",
+        )
+    raw = "" if value is None else str(value)
+    return tpl.render(
+        kind="text",
+        safe_name=safe_name,
+        field_name=field_name,
+        name=name,
+        str_val=raw,
+        hint_text=docstring,
+    )
 
 
 # ---------- PyGit2 Settings schema ----------
 # List of (attr_name, docstring, value_kind: 'bool'|'int'|'str'|'readonly').
 # Based on https://www.pygit2.org/settings.html
 PYGIT2_SETTINGS = [
-	(
-		"cache_max_size",
-		"Maximum total data size (bytes) cached in memory across all repositories. Default 256MB.",
-		"int",
-	),
-	(
-		"cache_object_limit",
-		"Max size per object type for caching (use cache_object_limit_commit etc. for fine-grained).",
-		"skip",
-	),  # multi-param
-	(
-		"cached_memory",
-		"Current bytes in cache and maximum allowed (read-only).",
-		"readonly",
-	),
-	(
-		"disable_pack_keep_file_checks",
-		"Skip .keep file checks when accessing packfiles; can help on remote filesystems.",
-		"bool",
-	),
-	("enable_caching", "Enable or disable caching completely.", "bool"),
-	(
-		"enable_fsync_gitdir",
-		"Enable or disable fsync for git directory operations.",
-		"bool",
-	),
-	(
-		"enable_http_expect_continue",
-		"Enable or disable HTTP Expect/Continue for large pushes.",
-		"bool",
-	),
-	("enable_ofs_delta", "Enable or disable offset delta encoding.", "bool"),
-	(
-		"enable_strict_hash_verification",
-		"Enable or disable strict hash verification.",
-		"bool",
-	),
-	(
-		"enable_strict_object_creation",
-		"Enable or disable strict object creation validation.",
-		"bool",
-	),
-	(
-		"enable_strict_symbolic_ref_creation",
-		"Enable or disable strict symbolic reference creation validation.",
-		"bool",
-	),
-	(
-		"enable_unsaved_index_safety",
-		"Enable or disable unsaved index safety checks.",
-		"bool",
-	),
-	("extensions", "List of enabled extensions (read-only).", "readonly"),
-	("homedir", "Home directory for config lookup.", "str"),
-	("mwindow_file_limit", "Maximum number of files to be mapped at any time.", "int"),
-	(
-		"mwindow_mapped_limit",
-		"Maximum memory that will be mapped in total by the library.",
-		"int",
-	),
-	("mwindow_size", "Maximum mmap window size.", "int"),
-	(
-		"owner_validation",
-		"Validate that repository directories are owned by the current user.",
-		"bool",
-	),
-	("pack_max_objects", "Maximum number of objects in a pack.", "int"),
-	("search_path", "Configuration file search path (read-only).", "readonly"),
-	("server_connect_timeout", "Server connection timeout in milliseconds.", "int"),
-	("server_timeout", "Server timeout in milliseconds.", "int"),
-	("ssl_cert_dir", "TLS certificates lookup directory path.", "str"),
-	("ssl_cert_file", "TLS certificate file path.", "str"),
-	("template_path", "Default template path for new repositories.", "str"),
-	("user_agent", "User agent string for network operations.", "str"),
-	("user_agent_product", "User agent product name.", "str"),
-	("windows_sharemode", "Windows share mode for opening files.", "int"),
+    (
+        "cache_max_size",
+        "Maximum total data size (bytes) cached in memory across all repositories. Default 256MB.",
+        "int",
+    ),
+    (
+        "cache_object_limit",
+        "Max size per object type for caching (use cache_object_limit_commit etc. for fine-grained).",
+        "skip",
+    ),  # multi-param
+    (
+        "cached_memory",
+        "Current bytes in cache and maximum allowed (read-only).",
+        "readonly",
+    ),
+    (
+        "disable_pack_keep_file_checks",
+        "Skip .keep file checks when accessing packfiles; can help on remote filesystems.",
+        "bool",
+    ),
+    ("enable_caching", "Enable or disable caching completely.", "bool"),
+    (
+        "enable_fsync_gitdir",
+        "Enable or disable fsync for git directory operations.",
+        "bool",
+    ),
+    (
+        "enable_http_expect_continue",
+        "Enable or disable HTTP Expect/Continue for large pushes.",
+        "bool",
+    ),
+    ("enable_ofs_delta", "Enable or disable offset delta encoding.", "bool"),
+    (
+        "enable_strict_hash_verification",
+        "Enable or disable strict hash verification.",
+        "bool",
+    ),
+    (
+        "enable_strict_object_creation",
+        "Enable or disable strict object creation validation.",
+        "bool",
+    ),
+    (
+        "enable_strict_symbolic_ref_creation",
+        "Enable or disable strict symbolic reference creation validation.",
+        "bool",
+    ),
+    (
+        "enable_unsaved_index_safety",
+        "Enable or disable unsaved index safety checks.",
+        "bool",
+    ),
+    ("extensions", "List of enabled extensions (read-only).", "readonly"),
+    ("homedir", "Home directory for config lookup.", "str"),
+    ("mwindow_file_limit", "Maximum number of files to be mapped at any time.", "int"),
+    (
+        "mwindow_mapped_limit",
+        "Maximum memory that will be mapped in total by the library.",
+        "int",
+    ),
+    ("mwindow_size", "Maximum mmap window size.", "int"),
+    (
+        "owner_validation",
+        "Validate that repository directories are owned by the current user.",
+        "bool",
+    ),
+    ("pack_max_objects", "Maximum number of objects in a pack.", "int"),
+    ("search_path", "Configuration file search path (read-only).", "readonly"),
+    ("server_connect_timeout", "Server connection timeout in milliseconds.", "int"),
+    ("server_timeout", "Server timeout in milliseconds.", "int"),
+    ("ssl_cert_dir", "TLS certificates lookup directory path.", "str"),
+    ("ssl_cert_file", "TLS certificate file path.", "str"),
+    ("template_path", "Default template path for new repositories.", "str"),
+    ("user_agent", "User agent string for network operations.", "str"),
+    ("user_agent_product", "User agent product name.", "str"),
+    ("windows_sharemode", "Windows share mode for opening files.", "int"),
 ]
 
 
 def _get_pygit2_values() -> list[tuple[str, Any, str, str]]:
-	"""Return (name, value, kind, docstring) for each PyGit2 setting we can show."""
-	st = pygit2.Settings
-	instance = pygit2.Settings()  # need instance to read property values
-	out = []
-	for attr, docstring, kind in PYGIT2_SETTINGS:
-		if kind == "skip":
-			continue
-		if not hasattr(st, attr):
-			continue
-		try:
-			prop = getattr(st, attr)
-			if callable(prop) and not isinstance(prop, property):
-				continue
-			val = getattr(instance, attr)
-			if isinstance(val, (list, tuple)):
-				val = ", ".join(str(x) for x in val) if val else ""
-			elif val is None:
-				val = ""
-			out.append((attr, val, kind, docstring))
-		except (TypeError, AttributeError):
-			continue
-	return out
+    """Return (name, value, kind, docstring) for each PyGit2 setting we can show."""
+    st = pygit2.Settings
+    instance = pygit2.Settings()  # need instance to read property values
+    out = []
+    for attr, docstring, kind in PYGIT2_SETTINGS:
+        if kind == "skip":
+            continue
+        if not hasattr(st, attr):
+            continue
+        try:
+            prop = getattr(st, attr)
+            if callable(prop) and not isinstance(prop, property):
+                continue
+            val = getattr(instance, attr)
+            if isinstance(val, (list, tuple)):
+                val = ", ".join(str(x) for x in val) if val else ""
+            elif val is None:
+                val = ""
+            out.append((attr, val, kind, docstring))
+        except (TypeError, AttributeError):
+            continue
+    return out
 
 
 def _render_pygit2_field(name: str, value: Any, kind: str, docstring: str, name_prefix: str = "pygit2_") -> str:
-	"""Generate HTML for a PyGit2 form field."""
-	field_name = f"{name_prefix}{name}"
-	safe_name = field_name.replace(".", "_")
-	tpl = env.get_template("settings_form_field.html")
-	if kind == "readonly":
-		raw = str(value) if value != "" else "(not set)"
-		return tpl.render(
-			kind="readonly",
-			safe_name=safe_name,
-			field_name=field_name,
-			name=name,
-			str_val=raw,
-			hint_text=docstring,
-		)
-	if kind == "bool":
-		checked_attr = (
-			" checked"
-			if (value is True or (isinstance(value, str) and value.lower() in ("true", "1", "on", "yes")))
-			else ""
-		)
-		return tpl.render(
-			kind="bool",
-			safe_name=safe_name,
-			field_name=field_name,
-			name=name,
-			checked=(checked_attr != ""),
-			hint_text=docstring,
-		)
-	if kind == "int":
-		raw = str(value) if value != "" and value is not None else ""
-		return tpl.render(
-			kind="number",
-			safe_name=safe_name,
-			field_name=field_name,
-			name=name,
-			str_val=raw,
-			hint_text=docstring,
-		)
-	raw = str(value) if value is not None else ""
-	return tpl.render(
-		kind="text",
-		safe_name=safe_name,
-		field_name=field_name,
-		name=name,
-		str_val=raw,
-		hint_text=docstring,
-	)
+    """Generate HTML for a PyGit2 form field."""
+    field_name = f"{name_prefix}{name}"
+    safe_name = field_name.replace(".", "_")
+    tpl = env.get_template("settings_form_field.html")
+    if kind == "readonly":
+        raw = str(value) if value != "" else "(not set)"
+        return tpl.render(
+            kind="readonly",
+            safe_name=safe_name,
+            field_name=field_name,
+            name=name,
+            str_val=raw,
+            hint_text=docstring,
+        )
+    if kind == "bool":
+        checked_attr = (
+            " checked"
+            if (value is True or (isinstance(value, str) and value.lower() in ("true", "1", "on", "yes")))
+            else ""
+        )
+        return tpl.render(
+            kind="bool",
+            safe_name=safe_name,
+            field_name=field_name,
+            name=name,
+            checked=(checked_attr != ""),
+            hint_text=docstring,
+        )
+    if kind == "int":
+        raw = str(value) if value != "" and value is not None else ""
+        return tpl.render(
+            kind="number",
+            safe_name=safe_name,
+            field_name=field_name,
+            name=name,
+            str_val=raw,
+            hint_text=docstring,
+        )
+    raw = str(value) if value is not None else ""
+    return tpl.render(
+        kind="text",
+        safe_name=safe_name,
+        field_name=field_name,
+        name=name,
+        str_val=raw,
+        hint_text=docstring,
+    )
 
 
 # ---------- Project config (local/worktree only) ----------
 def _get_project_config_entries(project: str) -> list[tuple[str, str]]:
-	"""
-	Return list of (name, value) for repo config, only from LOCAL (5) or WORKTREE (6).
-	For each name we keep the last value (highest priority when iterating).
-	"""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	cfg = repo.config
-	# Iterate; for level in (5, 6) keep last value per name
-	by_name: dict[str, str] = {}
-	for entry in cfg:
-		if entry.level not in (GIT_CONFIG_LEVEL_LOCAL, GIT_CONFIG_LEVEL_WORKTREE):
-			continue
-		by_name[entry.name] = entry.value or ""
-	return [(k, v) for k, v in sorted(by_name.items())]
+    """
+    Return list of (name, value) for repo config, only from LOCAL (5) or WORKTREE (6).
+    For each name we keep the last value (highest priority when iterating).
+    """
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    cfg = repo.config
+    # Iterate; for level in (5, 6) keep last value per name
+    by_name: dict[str, str] = {}
+    for entry in cfg:
+        if entry.level not in (GIT_CONFIG_LEVEL_LOCAL, GIT_CONFIG_LEVEL_WORKTREE):
+            continue
+        by_name[entry.name] = entry.value or ""
+    return [(k, v) for k, v in sorted(by_name.items())]
 
 
 def _project_config_table_row(key: str, value: str, index: int) -> list[Cell]:
-	"""One table row of config inputs. Values are escaped by html_format."""
-	key_name = f"config_key_{index}"
-	val_name = f"config_value_{index}"
-	key_cell = html_format(
-		'<input type="text" class="form-control form-control-sm" '
-		'id="{name}" name="{name}" value="{value}" placeholder="{placeholder}">',
-		name=key_name,
-		value=key,
-		placeholder="e.g. user.name",
-	)
-	val_cell = html_format(
-		'<input type="text" class="form-control form-control-sm" '
-		'id="{name}" name="{name}" value="{value}" placeholder="{placeholder}">',
-		name=val_name,
-		value=value,
-		placeholder="value",
-	)
-	return [key_cell, val_cell]
+    """One table row of config inputs. Values are escaped by html_format."""
+    key_name = f"config_key_{index}"
+    val_name = f"config_value_{index}"
+    key_cell = html_format(
+        '<input type="text" class="form-control form-control-sm" '
+        'id="{name}" name="{name}" value="{value}" placeholder="{placeholder}">',
+        name=key_name,
+        value=key,
+        placeholder="e.g. user.name",
+    )
+    val_cell = html_format(
+        '<input type="text" class="form-control form-control-sm" '
+        'id="{name}" name="{name}" value="{value}" placeholder="{placeholder}">',
+        name=val_name,
+        value=value,
+        placeholder="value",
+    )
+    return [key_cell, val_cell]
 
 
 # ---------- Routes: PyGitWeb ----------
 @router.get("/pygitweb", response_class=HTMLResponse)
 def settings_pygitweb_page(request: Request) -> HTMLResponse:
-	"""PyGitWeb global settings form."""
-	fields_html = []
-	for name, val, value_type, doc in _get_pygitweb_values():
-		fields_html.append(_render_form_field(name, val, value_type, doc))
-	form_body = "\n".join(fields_html)
-	enabled = set(settings.ENABLED_PLUGINS)
-	plugin_rows = [{"name": name, "checked": name in enabled} for name in list_available_plugins()]
-	return render_page(
-		"pygitweb_settings.html",
-		title=f"{settings.SITE_NAME} - PyGitWeb settings",
-		form_body=form_body,
-		plugin_rows=plugin_rows,
-		nav="settings",
-	)
+    """PyGitWeb global settings form."""
+    fields_html = []
+    for name, val, value_type, doc in _get_pygitweb_values():
+        fields_html.append(_render_form_field(name, val, value_type, doc))
+    form_body = "\n".join(fields_html)
+    enabled = set(settings.ENABLED_PLUGINS)
+    plugin_rows = [{"name": name, "checked": name in enabled} for name in list_available_plugins()]
+    return render_page(
+        "pygitweb_settings.html",
+        title=f"{settings.SITE_NAME} - PyGitWeb settings",
+        form_body=form_body,
+        plugin_rows=plugin_rows,
+        nav="settings",
+    )
 
 
 @router.post("/pygitweb/submit", response_class=HTMLResponse)
 async def settings_pygitweb_submit(request: Request) -> RedirectResponse:
-	"""Apply PyGitWeb settings and persist to the settings JSON file."""
-	form = await request.form()
-
-	def _get(k: str) -> str:
-		return str(form.get(k) or "").strip()
-
-	_TRUTHY = {"on", "1", "true", "yes"}
-	with settings_batch_update(settings):
-		for name, expected_type, _doc in PYGITWEB_SETTINGS:
-			raw = _get(name)
-			if expected_type is bool:
-				setattr(settings, name, raw.lower() in _TRUTHY)
-			elif expected_type is float:
-				with suppress(ValueError):
-					setattr(settings, name, float(raw) if raw else None)
-			elif expected_type is int:
-				if raw:
-					with suppress(ValueError):
-						setattr(settings, name, int(raw))
-			elif raw:
-				setattr(settings, name, raw)
-	return RedirectResponse(url="/settings/pygitweb", status_code=303)
+    """Apply PyGitWeb settings and persist to the settings JSON file."""
+    form = await request.form()
+
+    def _get(k: str) -> str:
+        return str(form.get(k) or "").strip()
+
+    _TRUTHY = {"on", "1", "true", "yes"}
+    with settings_batch_update(settings):
+        for name, expected_type, _doc in PYGITWEB_SETTINGS:
+            raw = _get(name)
+            if expected_type is bool:
+                setattr(settings, name, raw.lower() in _TRUTHY)
+            elif expected_type is float:
+                with suppress(ValueError):
+                    setattr(settings, name, float(raw) if raw else None)
+            elif expected_type is int:
+                if raw:
+                    with suppress(ValueError):
+                        setattr(settings, name, int(raw))
+            elif raw:
+                setattr(settings, name, raw)
+    return RedirectResponse(url="/settings/pygitweb", status_code=303)
 
 
 @router.post("/pygitweb/plugins", response_class=HTMLResponse)
 async def settings_pygitweb_plugins(request: Request) -> RedirectResponse:
-	"""Enable/disable installed plugins and reload the runtime registry immediately."""
-	form = await request.form()
-	available = set(list_available_plugins())
-	selected = [str(v) for v in form.getlist("plugin") if str(v) in available]
-	with settings_batch_update(settings):
-		settings.ENABLED_PLUGINS = selected
-	apply_plugin_reload()
-	await CHANGE_QUEUE.notify_all()
-	return RedirectResponse(url="/settings/pygitweb", status_code=303)
+    """Enable/disable installed plugins and reload the runtime registry immediately."""
+    form = await request.form()
+    available = set(list_available_plugins())
+    selected = [str(v) for v in form.getlist("plugin") if str(v) in available]
+    with settings_batch_update(settings):
+        settings.ENABLED_PLUGINS = selected
+    apply_plugin_reload()
+    await CHANGE_QUEUE.notify_all()
+    return RedirectResponse(url="/settings/pygitweb", status_code=303)
 
 
 @router.post("/pygitweb/shutdown", response_class=HTMLResponse)
 async def settings_pygitweb_shutdown(request: Request, background_tasks: BackgroundTasks) -> HTMLResponse:
-	"""Gracefully stop the PyGitWeb server process."""
-	background_tasks.add_task(request_graceful_shutdown, request.app)
-	return render_page(
-		"shutdown_ack.html",
-		title=f"{settings.SITE_NAME} - Shutting down",
-	)
+    """Gracefully stop the PyGitWeb server process."""
+    background_tasks.add_task(request_graceful_shutdown, request.app)
+    return render_page(
+        "shutdown_ack.html",
+        title=f"{settings.SITE_NAME} - Shutting down",
+    )
 
 
 # ---------- Routes: PyGit2 ----------
 @router.get("/pygit2", response_class=HTMLResponse)
 def settings_pygit2_page(request: Request) -> HTMLResponse:
-	"""PyGit2 library settings form."""
-	fields_html = []
-	for name, val, kind, doc in _get_pygit2_values():
-		fields_html.append(_render_pygit2_field(name, val, kind, doc))
-	form_body = "\n".join(fields_html)
-	return render_page(
-		"pygit2_settings.html",
-		title=f"{settings.SITE_NAME} - PyGit2 settings",
-		form_body=form_body,
-		nav="settings",
-	)
+    """PyGit2 library settings form."""
+    fields_html = []
+    for name, val, kind, doc in _get_pygit2_values():
+        fields_html.append(_render_pygit2_field(name, val, kind, doc))
+    form_body = "\n".join(fields_html)
+    return render_page(
+        "pygit2_settings.html",
+        title=f"{settings.SITE_NAME} - PyGit2 settings",
+        form_body=form_body,
+        nav="settings",
+    )
 
 
 @router.post("/pygit2/submit", response_class=HTMLResponse)
 async def settings_pygit2_submit(request: Request) -> RedirectResponse:
-	"""Apply PyGit2 settings from form (all pygit2_* fields)."""
-	form = await request.form()
-	st = pygit2.Settings
-	for key, form_value in form.items():
-		if not key.startswith("pygit2_"):
-			continue
-		attr = key[7:]  # strip pygit2_
-		if attr not in [x[0] for x in PYGIT2_SETTINGS]:
-			continue
-		kind = next((k[2] for k in PYGIT2_SETTINGS if k[0] == attr), "str")
-		if kind in ("readonly", "skip"):
-			continue
-		try:
-			if kind == "bool":
-				_ = str(form_value or "").strip().lower() in ("on", "1", "true", "yes")
-				setattr(st, attr, _)
-			elif kind == "int":
-				s = str(form_value or "").strip()
-				_ = int(s) if s else 0
-				setattr(st, attr, _)
-			else:
-				setattr(st, attr, str(form_value).strip() if form_value else None)
-		except (TypeError, AttributeError, ValueError):
-			continue
-	return RedirectResponse(url="/settings/pygit2", status_code=303)
+    """Apply PyGit2 settings from form (all pygit2_* fields)."""
+    form = await request.form()
+    st = pygit2.Settings
+    for key, form_value in form.items():
+        if not key.startswith("pygit2_"):
+            continue
+        attr = key[7:]  # strip pygit2_
+        if attr not in [x[0] for x in PYGIT2_SETTINGS]:
+            continue
+        kind = next((k[2] for k in PYGIT2_SETTINGS if k[0] == attr), "str")
+        if kind in ("readonly", "skip"):
+            continue
+        try:
+            if kind == "bool":
+                _ = str(form_value or "").strip().lower() in ("on", "1", "true", "yes")
+                setattr(st, attr, _)
+            elif kind == "int":
+                s = str(form_value or "").strip()
+                _ = int(s) if s else 0
+                setattr(st, attr, _)
+            else:
+                setattr(st, attr, str(form_value).strip() if form_value else None)
+        except (TypeError, AttributeError, ValueError):
+            continue
+    return RedirectResponse(url="/settings/pygit2", status_code=303)
 
 
 # ---------- Routes: Project ----------
 @router.get("/project/{name:path}", response_class=HTMLResponse)
 def settings_project_page(request: Request, name: ValidatedReadableSettingsProject) -> HTMLResponse:
-	"""Project-specific git config form (local/worktree only)."""
-	entries = _get_project_config_entries(name)
-	rows = [_project_config_table_row(k, v, i) for i, (k, v) in enumerate(entries)]
-	# One empty row for adding new
-	rows.append(_project_config_table_row("", "", len(entries)))
-	submit_url = f"/settings/project/{_quote_path(name)}/submit"
-	cancel_url = f"/project/{_quote_path(name)}"
-	return render_page(
-		"project_settings.html",
-		title=f"{settings.SITE_NAME} - Project settings: {name}",
-		project_title=name,
-		submit_url=submit_url,
-		cancel_url=cancel_url,
-		cols=["Key", "Value"],
-		rows=rows,
-	)
+    """Project-specific git config form (local/worktree only)."""
+    entries = _get_project_config_entries(name)
+    rows = [_project_config_table_row(k, v, i) for i, (k, v) in enumerate(entries)]
+    # One empty row for adding new
+    rows.append(_project_config_table_row("", "", len(entries)))
+    submit_url = f"/settings/project/{_quote_path(name)}/submit"
+    cancel_url = f"/project/{_quote_path(name)}"
+    return render_page(
+        "project_settings.html",
+        title=f"{settings.SITE_NAME} - Project settings: {name}",
+        project_title=name,
+        submit_url=submit_url,
+        cancel_url=cancel_url,
+        cols=["Key", "Value"],
+        rows=rows,
+    )
 
 
 @router.post("/project/{name:path}/submit", response_class=HTMLResponse)
 async def settings_project_submit(request: Request, name: ValidatedWritableSettingsProject) -> RedirectResponse:
-	"""Apply project config from form."""
-	form = await request.form()
-	# Collect key/value pairs; keys are config_key_0, config_value_0, ...
-	indices = set()
-	for form_key in form:
-		if form_key.startswith("config_key_"):
-			with suppress(ValueError):
-				indices.add(int(form_key.split("_")[-1]))
-	pairs = []
-	for i in sorted(indices):
-		k = str(form.get(f"config_key_{i}") or "").strip() or ""
-		v = str(form.get(f"config_value_{i}") or "").strip() or ""
-		if k:
-			pairs.append((k, v))
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, name))
-	cfg = repo.config
-	for config_key, config_value in pairs:
-		try:
-			cfg[config_key] = config_value
-		except (pygit2.GitError, ValueError, KeyError):
-			continue
-	return RedirectResponse(url=f"/settings/project/{_quote_path(name)}", status_code=303)
+    """Apply project config from form."""
+    form = await request.form()
+    # Collect key/value pairs; keys are config_key_0, config_value_0, ...
+    indices = set()
+    for form_key in form:
+        if form_key.startswith("config_key_"):
+            with suppress(ValueError):
+                indices.add(int(form_key.split("_")[-1]))
+    pairs = []
+    for i in sorted(indices):
+        k = str(form.get(f"config_key_{i}") or "").strip() or ""
+        v = str(form.get(f"config_value_{i}") or "").strip() or ""
+        if k:
+            pairs.append((k, v))
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, name))
+    cfg = repo.config
+    for config_key, config_value in pairs:
+        try:
+            cfg[config_key] = config_value
+        except (pygit2.GitError, ValueError, KeyError):
+            continue
+    return RedirectResponse(url=f"/settings/project/{_quote_path(name)}", status_code=303)
diff --git a/pygitweb/src/pygitweb/settings_config.py b/pygitweb/src/pygitweb/settings_config.py
index 74c9725..7f94f49 100644
--- a/pygitweb/src/pygitweb/settings_config.py
+++ b/pygitweb/src/pygitweb/settings_config.py
@@ -16,7 +16,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
-	from pygitweb.config import Settings
+    from pygitweb.config import Settings
 
 DEFAULT_SETTINGS_CONFIG_PATH = Path.home() / ".pygitweb" / "settings.json"
 
@@ -27,97 +27,97 @@ _setattr_patched = False
 
 
 def settings_config_path() -> Path:
-	explicit = __import__("os").environ.get("PYGITWEB_SETTINGS_CONFIG", "").strip()
-	if explicit:
-		return Path(explicit).expanduser()
-	return DEFAULT_SETTINGS_CONFIG_PATH
+    explicit = __import__("os").environ.get("PYGITWEB_SETTINGS_CONFIG", "").strip()
+    if explicit:
+        return Path(explicit).expanduser()
+    return DEFAULT_SETTINGS_CONFIG_PATH
 
 
 def write_settings_file(settings: Settings, path: Path | None = None) -> None:
-	target = path or _active_path or settings_config_path()
-	target.parent.mkdir(parents=True, exist_ok=True)
-	target.write_text(
-		json.dumps(settings.model_dump(mode="json"), indent=2) + "\n",
-		encoding="utf-8",
-	)
+    target = path or _active_path or settings_config_path()
+    target.parent.mkdir(parents=True, exist_ok=True)
+    target.write_text(
+        json.dumps(settings.model_dump(mode="json"), indent=2) + "\n",
+        encoding="utf-8",
+    )
 
 
 def _warn_env_overrides(path: Path, fields: list[str]) -> None:
-	names = ", ".join(fields)
-	print(
-		f"WARNING: PyGitWeb: environment overrides settings file {path}\n         Overridden field(s): {names}\n",
-		file=sys.stderr,
-	)
+    names = ", ".join(fields)
+    print(
+        f"WARNING: PyGitWeb: environment overrides settings file {path}\n         Overridden field(s): {names}\n",
+        file=sys.stderr,
+    )
 
 
 def _apply_env_overrides(base: Settings) -> list[str]:
-	from pygitweb.config import Settings
+    from pygitweb.config import Settings
 
-	env_settings = Settings()
-	overrides: list[str] = []
-	for name in env_settings.model_fields_set:
-		env_val = getattr(env_settings, name)
-		if getattr(base, name) == env_val:
-			continue
-		setattr(base, name, env_val)
-		overrides.append(name)
-	return overrides
+    env_settings = Settings()
+    overrides: list[str] = []
+    for name in env_settings.model_fields_set:
+        env_val = getattr(env_settings, name)
+        if getattr(base, name) == env_val:
+            continue
+        setattr(base, name, env_val)
+        overrides.append(name)
+    return overrides
 
 
 def _patch_settings_setattr() -> None:
-	global _setattr_patched
-	if _setattr_patched:
-		return
+    global _setattr_patched
+    if _setattr_patched:
+        return
 
-	from pygitweb.config import Settings
+    from pygitweb.config import Settings
 
-	original = Settings.__setattr__
+    original = Settings.__setattr__
 
-	def _setattr(self: object, name: str, value: object) -> None:
-		original(self, name, value)
-		if not _persist_enabled or _batch_update:
-			return
-		if name not in Settings.model_fields or not isinstance(self, Settings):
-			return
-		write_settings_file(self)
+    def _setattr(self: object, name: str, value: object) -> None:
+        original(self, name, value)
+        if not _persist_enabled or _batch_update:
+            return
+        if name not in Settings.model_fields or not isinstance(self, Settings):
+            return
+        write_settings_file(self)
 
-	Settings.__setattr__ = _setattr  # type: ignore[method-assign]
-	_setattr_patched = True
+    Settings.__setattr__ = _setattr  # type: ignore[method-assign]
+    _setattr_patched = True
 
 
 def init_settings() -> Settings:
-	from pygitweb.config import Settings, _finalize_auth_settings
+    from pygitweb.config import Settings, _finalize_auth_settings
 
-	global _persist_enabled, _active_path
+    global _persist_enabled, _active_path
 
-	_persist_enabled = False
-	path = settings_config_path()
-	_active_path = path
+    _persist_enabled = False
+    path = settings_config_path()
+    _active_path = path
 
-	if path.is_file():
-		settings = Settings.model_validate_json(path.read_text(encoding="utf-8"))
-		overrides = _apply_env_overrides(settings)
-		if overrides:
-			_warn_env_overrides(path, overrides)
-	else:
-		settings = Settings()
+    if path.is_file():
+        settings = Settings.model_validate_json(path.read_text(encoding="utf-8"))
+        overrides = _apply_env_overrides(settings)
+        if overrides:
+            _warn_env_overrides(path, overrides)
+    else:
+        settings = Settings()
 
-	_finalize_auth_settings(settings)
-	write_settings_file(settings, path)
-	_patch_settings_setattr()
-	_persist_enabled = True
-	return settings
+    _finalize_auth_settings(settings)
+    write_settings_file(settings, path)
+    _patch_settings_setattr()
+    _persist_enabled = True
+    return settings
 
 
 @contextmanager
 def settings_batch_update(settings: Settings) -> Iterator[None]:
-	"""Apply multiple setting changes and persist once."""
-	global _batch_update
-
-	_batch_update = True
-	try:
-		yield
-	finally:
-		_batch_update = False
-		if _persist_enabled:
-			write_settings_file(settings)
+    """Apply multiple setting changes and persist once."""
+    global _batch_update
+
+    _batch_update = True
+    try:
+        yield
+    finally:
+        _batch_update = False
+        if _persist_enabled:
+            write_settings_file(settings)
diff --git a/pygitweb/src/pygitweb/settings_config_test.py b/pygitweb/src/pygitweb/settings_config_test.py
index 426eb0d..97a3bda 100644
--- a/pygitweb/src/pygitweb/settings_config_test.py
+++ b/pygitweb/src/pygitweb/settings_config_test.py
@@ -9,103 +9,103 @@ from pygitweb.settings_config import init_settings, settings_batch_update, write
 
 @pytest.fixture
 def settings_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
-	path = tmp_path / "settings.json"
-	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(path))
-	monkeypatch.setenv("PYGITWEB_AUTH", "0")
-	return path
+    path = tmp_path / "settings.json"
+    monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(path))
+    monkeypatch.setenv("PYGITWEB_AUTH", "0")
+    return path
 
 
 def test_creates_settings_file_from_environment(settings_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	monkeypatch.setenv("PYGITWEB_SITE_NAME", "FromEnv")
-	settings = init_settings()
-	assert settings_path.is_file()
-	data = json.loads(settings_path.read_text(encoding="utf-8"))
-	assert data["SITE_NAME"] == "FromEnv"
-	assert settings.SITE_NAME == "FromEnv"
+    monkeypatch.setenv("PYGITWEB_SITE_NAME", "FromEnv")
+    settings = init_settings()
+    assert settings_path.is_file()
+    data = json.loads(settings_path.read_text(encoding="utf-8"))
+    assert data["SITE_NAME"] == "FromEnv"
+    assert settings.SITE_NAME == "FromEnv"
 
 
 def test_loads_existing_settings_file(settings_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-	settings_path.write_text(
-		json.dumps({
-			"PROJECTROOT": "/var/git",
-			"PROJECTS_LIST": "/var/git",
-			"PROJECT_MAXDEPTH": 2,
-			"SITE_NAME": "FromFile",
-			"EXPORT_OK": "",
-			"LIST_ALL": True,
-			"STRICT_EXPORT": False,
-			"GIT": "git",
-			"MAXLOAD": None,
-			"AUTH": False,
-			"AUTH_CONFIG": "",
-			"SETTINGS_CONFIG": "",
-		})
-		+ "\n",
-		encoding="utf-8",
-	)
-	monkeypatch.delenv("PYGITWEB_SITE_NAME", raising=False)
-	settings = init_settings()
-	assert settings.SITE_NAME == "FromFile"
-	assert settings.PROJECT_MAXDEPTH == 2
+    settings_path.write_text(
+        json.dumps({
+            "PROJECTROOT": "/var/git",
+            "PROJECTS_LIST": "/var/git",
+            "PROJECT_MAXDEPTH": 2,
+            "SITE_NAME": "FromFile",
+            "EXPORT_OK": "",
+            "LIST_ALL": True,
+            "STRICT_EXPORT": False,
+            "GIT": "git",
+            "MAXLOAD": None,
+            "AUTH": False,
+            "AUTH_CONFIG": "",
+            "SETTINGS_CONFIG": "",
+        })
+        + "\n",
+        encoding="utf-8",
+    )
+    monkeypatch.delenv("PYGITWEB_SITE_NAME", raising=False)
+    settings = init_settings()
+    assert settings.SITE_NAME == "FromFile"
+    assert settings.PROJECT_MAXDEPTH == 2
 
 
 def test_env_overrides_settings_file(settings_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
-	settings_path.write_text(
-		json.dumps({
-			"PROJECTROOT": "/var/git",
-			"PROJECTS_LIST": "/var/git",
-			"PROJECT_MAXDEPTH": 1,
-			"SITE_NAME": "FromFile",
-			"EXPORT_OK": "",
-			"LIST_ALL": True,
-			"STRICT_EXPORT": False,
-			"GIT": "git",
-			"MAXLOAD": None,
-			"AUTH": False,
-			"AUTH_CONFIG": "",
-			"SETTINGS_CONFIG": "",
-		})
-		+ "\n",
-		encoding="utf-8",
-	)
-	monkeypatch.setenv("PYGITWEB_SITE_NAME", "FromEnv")
-	settings = init_settings()
-	assert settings.SITE_NAME == "FromEnv"
-	assert json.loads(settings_path.read_text(encoding="utf-8"))["SITE_NAME"] == "FromEnv"
-	err = capsys.readouterr().err
-	assert "environment overrides settings file" in err
-	assert "SITE_NAME" in err
+    settings_path.write_text(
+        json.dumps({
+            "PROJECTROOT": "/var/git",
+            "PROJECTS_LIST": "/var/git",
+            "PROJECT_MAXDEPTH": 1,
+            "SITE_NAME": "FromFile",
+            "EXPORT_OK": "",
+            "LIST_ALL": True,
+            "STRICT_EXPORT": False,
+            "GIT": "git",
+            "MAXLOAD": None,
+            "AUTH": False,
+            "AUTH_CONFIG": "",
+            "SETTINGS_CONFIG": "",
+        })
+        + "\n",
+        encoding="utf-8",
+    )
+    monkeypatch.setenv("PYGITWEB_SITE_NAME", "FromEnv")
+    settings = init_settings()
+    assert settings.SITE_NAME == "FromEnv"
+    assert json.loads(settings_path.read_text(encoding="utf-8"))["SITE_NAME"] == "FromEnv"
+    err = capsys.readouterr().err
+    assert "environment overrides settings file" in err
+    assert "SITE_NAME" in err
 
 
 def test_setattr_persists_to_file(settings_path: Path) -> None:
-	settings = init_settings()
-	settings.SITE_NAME = "Updated"
-	assert json.loads(settings_path.read_text(encoding="utf-8"))["SITE_NAME"] == "Updated"
+    settings = init_settings()
+    settings.SITE_NAME = "Updated"
+    assert json.loads(settings_path.read_text(encoding="utf-8"))["SITE_NAME"] == "Updated"
 
 
 def test_batch_update_persists_once(settings_path: Path) -> None:
-	settings = init_settings()
-	with settings_batch_update(settings):
-		settings.SITE_NAME = "BatchOne"
-		settings.GIT = "/usr/bin/git"
-	data = json.loads(settings_path.read_text(encoding="utf-8"))
-	assert data["SITE_NAME"] == "BatchOne"
-	assert data["GIT"] == "/usr/bin/git"
+    settings = init_settings()
+    with settings_batch_update(settings):
+        settings.SITE_NAME = "BatchOne"
+        settings.GIT = "/usr/bin/git"
+    data = json.loads(settings_path.read_text(encoding="utf-8"))
+    assert data["SITE_NAME"] == "BatchOne"
+    assert data["GIT"] == "/usr/bin/git"
 
 
 def test_write_settings_file_roundtrip(settings_path: Path) -> None:
-	settings = Settings(AUTH=False, SITE_NAME="Roundtrip")
-	write_settings_file(settings, settings_path)
-	loaded = Settings.model_validate_json(settings_path.read_text(encoding="utf-8"))
-	assert loaded.SITE_NAME == "Roundtrip"
-	assert loaded.AUTH is False
+    settings = Settings(AUTH=False, SITE_NAME="Roundtrip")
+    write_settings_file(settings, settings_path)
+    loaded = Settings.model_validate_json(settings_path.read_text(encoding="utf-8"))
+    assert loaded.SITE_NAME == "Roundtrip"
+    assert loaded.AUTH is False
 
 
 def test_enabled_plugins_roundtrip(settings_path: Path) -> None:
-	settings = init_settings()
-	assert settings.ENABLED_PLUGINS == []
-	settings.ENABLED_PLUGINS = ["pygitweb_demo", "pygitweb_other"]
-	data = json.loads(settings_path.read_text(encoding="utf-8"))
-	assert data["ENABLED_PLUGINS"] == ["pygitweb_demo", "pygitweb_other"]
-	reloaded = Settings.model_validate_json(settings_path.read_text(encoding="utf-8"))
-	assert reloaded.ENABLED_PLUGINS == ["pygitweb_demo", "pygitweb_other"]
+    settings = init_settings()
+    assert settings.ENABLED_PLUGINS == []
+    settings.ENABLED_PLUGINS = ["pygitweb_demo", "pygitweb_other"]
+    data = json.loads(settings_path.read_text(encoding="utf-8"))
+    assert data["ENABLED_PLUGINS"] == ["pygitweb_demo", "pygitweb_other"]
+    reloaded = Settings.model_validate_json(settings_path.read_text(encoding="utf-8"))
+    assert reloaded.ENABLED_PLUGINS == ["pygitweb_demo", "pygitweb_other"]
diff --git a/pygitweb/src/pygitweb/settings_permissions_test.py b/pygitweb/src/pygitweb/settings_permissions_test.py
index 7d2c1d8..c6194bc 100644
--- a/pygitweb/src/pygitweb/settings_permissions_test.py
+++ b/pygitweb/src/pygitweb/settings_permissions_test.py
@@ -9,89 +9,89 @@ from pygitweb.main import app
 from pygitweb.permissions import PERMISSION_SETTINGS, permissions_with_defaults
 
 _SETTINGS_AUTH = AuthConfig(
-	auth_mode="local",
-	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=permissions_with_defaults({
-		PERMISSION_SETTINGS: ["admin"],
-	}),
+    auth_mode="local",
+    local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
+    oauth_permissions=permissions_with_defaults({
+        PERMISSION_SETTINGS: ["admin"],
+    }),
 )
 
 
 @pytest.fixture
 def client() -> Generator[TestClient, None, None]:
-	with TestClient(app) as c:
-		yield c
+    with TestClient(app) as c:
+        yield c
 
 
 @pytest.fixture
 def settings_auth() -> Generator[AuthConfig, None, None]:
-	with (
-		patch("pygitweb.auth_config.auth_config", _SETTINGS_AUTH),
-		patch("pygitweb.auth.auth_config", _SETTINGS_AUTH),
-		patch("pygitweb.main.auth_config", _SETTINGS_AUTH),
-	):
-		yield _SETTINGS_AUTH
+    with (
+        patch("pygitweb.auth_config.auth_config", _SETTINGS_AUTH),
+        patch("pygitweb.auth.auth_config", _SETTINGS_AUTH),
+        patch("pygitweb.main.auth_config", _SETTINGS_AUTH),
+    ):
+        yield _SETTINGS_AUTH
 
 
 def _login(client: TestClient, username: str) -> None:
-	client.post(
-		"/login",
-		data={"username": username, "password": "secret", "next": "/"},
-		follow_redirects=False,
-	)
+    client.post(
+        "/login",
+        data={"username": username, "password": "secret", "next": "/"},
+        follow_redirects=False,
+    )
 
 
 def test_settings_open_when_auth_disabled(client: TestClient) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with patch.object(settings, "AUTH", False):
-		r = client.get("/settings/pygitweb")
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", False):
+        r = client.get("/settings/pygitweb")
+    assert r.status_code == 200
 
 
 def test_settings_forbidden_without_login(client: TestClient, settings_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with patch.object(settings, "AUTH", True):
-		r = client.get("/settings/pygitweb")
-	assert r.status_code == 401
+    with patch.object(settings, "AUTH", True):
+        r = client.get("/settings/pygitweb")
+    assert r.status_code == 401
 
 
 def test_settings_allowed_with_grant(client: TestClient, settings_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with patch.object(settings, "AUTH", True):
-		_login(client, "admin")
-		r = client.get("/settings/pygitweb")
-	assert r.status_code == 200
+    with patch.object(settings, "AUTH", True):
+        _login(client, "admin")
+        r = client.get("/settings/pygitweb")
+    assert r.status_code == 200
 
 
 def test_settings_forbidden_without_grant(client: TestClient, settings_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with patch.object(settings, "AUTH", True):
-		_login(client, "viewer")
-		r = client.get("/settings/pygit2")
-	assert r.status_code == 403
+    with patch.object(settings, "AUTH", True):
+        _login(client, "viewer")
+        r = client.get("/settings/pygit2")
+    assert r.status_code == 403
 
 
 def test_shutdown_forbidden_without_grant(client: TestClient, settings_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
+    from pygitweb.config import settings
 
-	with patch.object(settings, "AUTH", True):
-		_login(client, "viewer")
-		r = client.post("/settings/pygitweb/shutdown")
-	assert r.status_code == 403
+    with patch.object(settings, "AUTH", True):
+        _login(client, "viewer")
+        r = client.post("/settings/pygitweb/shutdown")
+    assert r.status_code == 403
 
 
 def test_shutdown_allowed_with_grant(client: TestClient, settings_auth: AuthConfig) -> None:
-	from pygitweb.config import settings
-
-	app.state.can_signal_shutdown = True
-	with patch.object(settings, "AUTH", True), patch("pygitweb.shutdown.os.kill") as kill:
-		_login(client, "admin")
-		r = client.post("/settings/pygitweb/shutdown")
-	assert r.status_code == 200
-	assert "Shutting down" in r.text
-	assert app.state.shutting_down is True
-	kill.assert_called_once()
+    from pygitweb.config import settings
+
+    app.state.can_signal_shutdown = True
+    with patch.object(settings, "AUTH", True), patch("pygitweb.shutdown.os.kill") as kill:
+        _login(client, "admin")
+        r = client.post("/settings/pygitweb/shutdown")
+    assert r.status_code == 200
+    assert "Shutting down" in r.text
+    assert app.state.shutting_down is True
+    kill.assert_called_once()
diff --git a/pygitweb/src/pygitweb/settings_plugins_test.py b/pygitweb/src/pygitweb/settings_plugins_test.py
index cb8506e..1ffc831 100644
--- a/pygitweb/src/pygitweb/settings_plugins_test.py
+++ b/pygitweb/src/pygitweb/settings_plugins_test.py
@@ -14,45 +14,45 @@ from pygitweb.plugin_loader_test import _fake_entry_points
 
 @pytest.fixture
 def client() -> Generator[TestClient, None, None]:
-	with TestClient(app) as c:
-		yield c
+    with TestClient(app) as c:
+        yield c
 
 
 def test_plugins_page_lists_available(client: TestClient) -> None:
-	with (
-		patch.object(settings, "AUTH", False),
-		patch("pygitweb.settings.list_available_plugins", return_value=["pygitweb_demo"]),
-		patch.object(settings, "ENABLED_PLUGINS", []),
-	):
-		r = client.get("/settings/pygitweb")
-	assert r.status_code == 200
-	assert b'name="plugin" value="pygitweb_demo"' in r.content
-	assert b'id="plugin_pygitweb_demo"' in r.content
-	assert b"checked" not in r.content.split(b'id="plugin_pygitweb_demo"')[1].split(b">")[0]
+    with (
+        patch.object(settings, "AUTH", False),
+        patch("pygitweb.settings.list_available_plugins", return_value=["pygitweb_demo"]),
+        patch.object(settings, "ENABLED_PLUGINS", []),
+    ):
+        r = client.get("/settings/pygitweb")
+    assert r.status_code == 200
+    assert b'name="plugin" value="pygitweb_demo"' in r.content
+    assert b'id="plugin_pygitweb_demo"' in r.content
+    assert b"checked" not in r.content.split(b'id="plugin_pygitweb_demo"')[1].split(b">")[0]
 
 
 def test_plugins_submit_enables_and_reloads(client: TestClient) -> None:
-	notify = AsyncMock(return_value=0)
-	previous = list(settings.ENABLED_PLUGINS)
-	try:
-		settings.ENABLED_PLUGINS = []
-		with (
-			patch.object(settings, "AUTH", False),
-			patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points),
-			patch("pygitweb.settings.list_available_plugins", return_value=["pygitweb_demo", "pygitweb_other"]),
-			patch("pygitweb.settings.CHANGE_QUEUE.notify_all", notify),
-		):
-			apply_plugin_reload()
-			assert PLUGIN_ACTIONS == {}
-			r = client.post(
-				"/settings/pygitweb/plugins",
-				data={"plugin": "pygitweb_demo"},
-				follow_redirects=False,
-			)
-			assert r.status_code == 303
-			assert settings.ENABLED_PLUGINS == ["pygitweb_demo"]
-			assert set(PLUGIN_ACTIONS) == {"demo_action"}
-			notify.assert_awaited_once()
-	finally:
-		settings.ENABLED_PLUGINS = previous
-		apply_plugin_reload()
+    notify = AsyncMock(return_value=0)
+    previous = list(settings.ENABLED_PLUGINS)
+    try:
+        settings.ENABLED_PLUGINS = []
+        with (
+            patch.object(settings, "AUTH", False),
+            patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points),
+            patch("pygitweb.settings.list_available_plugins", return_value=["pygitweb_demo", "pygitweb_other"]),
+            patch("pygitweb.settings.CHANGE_QUEUE.notify_all", notify),
+        ):
+            apply_plugin_reload()
+            assert PLUGIN_ACTIONS == {}
+            r = client.post(
+                "/settings/pygitweb/plugins",
+                data={"plugin": "pygitweb_demo"},
+                follow_redirects=False,
+            )
+            assert r.status_code == 303
+            assert settings.ENABLED_PLUGINS == ["pygitweb_demo"]
+            assert set(PLUGIN_ACTIONS) == {"demo_action"}
+            notify.assert_awaited_once()
+    finally:
+        settings.ENABLED_PLUGINS = previous
+        apply_plugin_reload()
diff --git a/pygitweb/src/pygitweb/shutdown.py b/pygitweb/src/pygitweb/shutdown.py
index 9cd30cb..86a92c1 100644
--- a/pygitweb/src/pygitweb/shutdown.py
+++ b/pygitweb/src/pygitweb/shutdown.py
@@ -14,50 +14,50 @@ from fastapi import FastAPI
 
 
 def begin_shutdown(app: FastAPI) -> None:
-	"""Wake long-poll waiters; safe to call multiple times."""
-	app.state.shutting_down = True
-	shutdown_event = getattr(app.state, "shutdown_event", None)
-	if shutdown_event is not None:
-		shutdown_event.set()
+    """Wake long-poll waiters; safe to call multiple times."""
+    app.state.shutting_down = True
+    shutdown_event = getattr(app.state, "shutdown_event", None)
+    if shutdown_event is not None:
+        shutdown_event.set()
 
 
 def request_graceful_shutdown(app: FastAPI) -> None:
-	"""Begin graceful shutdown and signal the server process when supported."""
-	begin_shutdown(app)
-	if getattr(app.state, "can_signal_shutdown", False):
-		os.kill(os.getpid(), signal.SIGTERM)
+    """Begin graceful shutdown and signal the server process when supported."""
+    begin_shutdown(app)
+    if getattr(app.state, "can_signal_shutdown", False):
+        os.kill(os.getpid(), signal.SIGTERM)
 
 
 def install_graceful_shutdown_wakeup(app: FastAPI) -> bool:
-	"""Chain OS signals so long-poll waiters wake before uvicorn shuts down.
-
-	Returns whether SIGTERM may be sent to this process later (e.g. from the
-	settings shutdown button). Skipped off the main thread.
-	"""
-	if threading.current_thread() is not threading.main_thread():
-		app.state.can_signal_shutdown = False
-		return False
-	signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM)
-	if sys.platform == "win32":
-		signals = signals + (signal.SIGBREAK,)
-	installed = False
-	for sig in signals:
-
-		def make_chain(
-			previous: Callable[[int, FrameType | None], Any] | int | signal.Handlers | None,
-		) -> Callable[[int, FrameType | None], Any]:
-			def handler(signum: int, frame: FrameType | None) -> None:
-				begin_shutdown(app)
-				if callable(previous):
-					previous(signum, frame)
-
-			return handler
-
-		try:
-			prev = signal.getsignal(sig)
-			signal.signal(sig, make_chain(prev))
-			installed = True
-		except ValueError:
-			continue
-	app.state.can_signal_shutdown = installed
-	return installed
+    """Chain OS signals so long-poll waiters wake before uvicorn shuts down.
+
+    Returns whether SIGTERM may be sent to this process later (e.g. from the
+    settings shutdown button). Skipped off the main thread.
+    """
+    if threading.current_thread() is not threading.main_thread():
+        app.state.can_signal_shutdown = False
+        return False
+    signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM)
+    if sys.platform == "win32":
+        signals = signals + (signal.SIGBREAK,)
+    installed = False
+    for sig in signals:
+
+        def make_chain(
+            previous: Callable[[int, FrameType | None], Any] | int | signal.Handlers | None,
+        ) -> Callable[[int, FrameType | None], Any]:
+            def handler(signum: int, frame: FrameType | None) -> None:
+                begin_shutdown(app)
+                if callable(previous):
+                    previous(signum, frame)
+
+            return handler
+
+        try:
+            prev = signal.getsignal(sig)
+            signal.signal(sig, make_chain(prev))
+            installed = True
+        except ValueError:
+            continue
+    app.state.can_signal_shutdown = installed
+    return installed
diff --git a/pygitweb/src/pygitweb/smart_http.py b/pygitweb/src/pygitweb/smart_http.py
index af918aa..f255722 100644
--- a/pygitweb/src/pygitweb/smart_http.py
+++ b/pygitweb/src/pygitweb/smart_http.py
@@ -17,15 +17,15 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, s
 from fastapi.responses import Response
 
 from pygitweb.auth import (
-	ACCESS_TOKEN_COOKIE_NAME,
-	SmartHttpCredentials,
-	can_push_ref,
-	can_read_project,
-	load_smart_http_credentials,
-	principal_can_read_project,
-	principal_can_write_project,
-	raise_for_smart_http_credentials,
-	unauthorized_smart_http,
+    ACCESS_TOKEN_COOKIE_NAME,
+    SmartHttpCredentials,
+    can_push_ref,
+    can_read_project,
+    load_smart_http_credentials,
+    principal_can_read_project,
+    principal_can_write_project,
+    raise_for_smart_http_credentials,
+    unauthorized_smart_http,
 )
 from pygitweb.config import settings
 from pygitweb.dependencies import project_visible_in_list, require_valid_project
@@ -40,293 +40,293 @@ _SMART_HTTP_LOG_MODE = 0o600
 _REF_UPDATE_LINE = re.compile(rb"^[0-9a-fA-F]{4,64} [0-9a-fA-F]{4,64} (\S+)$")
 
 _SERVICE_CONTENT_TYPES: dict[SmartHttpService, tuple[str, str]] = {
-	"git-upload-pack": (
-		"application/x-git-upload-pack-advertisement",
-		"application/x-git-upload-pack-result",
-	),
-	"git-receive-pack": (
-		"application/x-git-receive-pack-advertisement",
-		"application/x-git-receive-pack-result",
-	),
+    "git-upload-pack": (
+        "application/x-git-upload-pack-advertisement",
+        "application/x-git-upload-pack-result",
+    ),
+    "git-receive-pack": (
+        "application/x-git-receive-pack-advertisement",
+        "application/x-git-receive-pack-result",
+    ),
 }
 
 
 def _decode_request_body(body: bytes, content_encoding: str | None) -> bytes:
-	if not content_encoding or not content_encoding.strip():
-		return body
-	encoding = content_encoding.strip().lower()
-	if encoding in ("identity", "none"):
-		return body
-	if encoding == "gzip":
-		try:
-			return gzip.decompress(body)
-		except OSError as exc:
-			raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid gzip body") from exc
-	raise HTTPException(
-		status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
-		detail=f"Unsupported Content-Encoding: {content_encoding}",
-	)
+    if not content_encoding or not content_encoding.strip():
+        return body
+    encoding = content_encoding.strip().lower()
+    if encoding in ("identity", "none"):
+        return body
+    if encoding == "gzip":
+        try:
+            return gzip.decompress(body)
+        except OSError as exc:
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid gzip body") from exc
+    raise HTTPException(
+        status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
+        detail=f"Unsupported Content-Encoding: {content_encoding}",
+    )
 
 
 def _pkt_line(data: bytes) -> bytes:
-	length = len(data) + 4
-	return f"{length:04x}".encode("ascii") + data
+    length = len(data) + 4
+    return f"{length:04x}".encode("ascii") + data
 
 
 def _pkt_flush() -> bytes:
-	return b"0000"
+    return b"0000"
 
 
 def _is_valid_http_project(project: str) -> bool:
-	return is_valid_project(
-		project,
-		settings.PROJECTROOT,
-		settings.EXPORT_OK,
-		settings.STRICT_EXPORT,
-		project_visible_in_list,
-	)
+    return is_valid_project(
+        project,
+        settings.PROJECTROOT,
+        settings.EXPORT_OK,
+        settings.STRICT_EXPORT,
+        project_visible_in_list,
+    )
 
 
 def require_http_project(project: str) -> str:
-	candidate = project.removesuffix(".git")
-	if candidate != project and _is_valid_http_project(candidate):
-		return candidate
-	return require_valid_project(project)
+    candidate = project.removesuffix(".git")
+    if candidate != project and _is_valid_http_project(candidate):
+        return candidate
+    return require_valid_project(project)
 
 
 ValidatedHttpProject = Annotated[str, Depends(require_http_project)]
 
 
 def smart_http_log_path() -> Path:
-	return Path.home() / ".pygitweb" / "smart_http.log"
+    return Path.home() / ".pygitweb" / "smart_http.log"
 
 
 def _request_ip(request: Request) -> str:
-	client = request.client
-	if client is None or not client.host:
-		return "-"
-	return client.host.replace("\r", "").replace("\n", "")
+    client = request.client
+    if client is None or not client.host:
+        return "-"
+    return client.host.replace("\r", "").replace("\n", "")
 
 
 def _log_token(value: str) -> str:
-	return value.replace("\r", "").replace("\n", "") or "-"
+    return value.replace("\r", "").replace("\n", "") or "-"
 
 
 def append_smart_http_log(ip: str, actor: str, *, project: str, operation: str) -> None:
-	stamp = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
-	safe_actor = actor.replace("\r", "").replace("\n", "")
-	line = f"{stamp} {_log_token(ip)} {_log_token(project)} {_log_token(operation)}"
-	if safe_actor:
-		line = f"{line} {safe_actor}"
-	path = smart_http_log_path()
-	path.parent.mkdir(parents=True, exist_ok=True)
-	with _SMART_HTTP_LOG_LOCK:
-		created = not path.exists()
-		with path.open("a", encoding="utf-8", newline="\n") as handle:
-			handle.write(f"{line}\n")
-		if created and sys.platform != "win32":
-			os.chmod(path, _SMART_HTTP_LOG_MODE)
+    stamp = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
+    safe_actor = actor.replace("\r", "").replace("\n", "")
+    line = f"{stamp} {_log_token(ip)} {_log_token(project)} {_log_token(operation)}"
+    if safe_actor:
+        line = f"{line} {safe_actor}"
+    path = smart_http_log_path()
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with _SMART_HTTP_LOG_LOCK:
+        created = not path.exists()
+        with path.open("a", encoding="utf-8", newline="\n") as handle:
+            handle.write(f"{line}\n")
+        if created and sys.platform != "win32":
+            os.chmod(path, _SMART_HTTP_LOG_MODE)
 
 
 def _begin_smart_http(request: Request, project: str, *, write: bool, operation: str) -> SmartHttpCredentials:
-	creds = load_smart_http_credentials(
-		request.headers.get("authorization"),
-		request.cookies.get(ACCESS_TOKEN_COOKIE_NAME),
-		project=project,
-		write=write,
-	)
-	append_smart_http_log(_request_ip(request), creds.actor, project=project, operation=operation)
-	raise_for_smart_http_credentials(creds)
-	_authorize_smart_http(project, creds, write=write)
-	return creds
+    creds = load_smart_http_credentials(
+        request.headers.get("authorization"),
+        request.cookies.get(ACCESS_TOKEN_COOKIE_NAME),
+        project=project,
+        write=write,
+    )
+    append_smart_http_log(_request_ip(request), creds.actor, project=project, operation=operation)
+    raise_for_smart_http_credentials(creds)
+    _authorize_smart_http(project, creds, write=write)
+    return creds
 
 
 def receive_pack_updated_refs(body: bytes) -> list[str]:
-	"""Ref names from a stateless ``git-receive-pack`` command list.
-
-	Stops at the first flush pkt, which separates commands from the pack. Lines that
-	are not ``<old> <new> <ref>`` (capability text, push certificates) are ignored.
-	"""
-	refs: list[str] = []
-	offset = 0
-	total = len(body)
-	while offset + 4 <= total:
-		header = body[offset : offset + 4]
-		try:
-			size = int(header, 16)
-		except ValueError as exc:
-			raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid receive-pack request") from exc
-		if size == 0:
-			return refs
-		if size < 4 or offset + size > total:
-			raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid receive-pack request")
-		payload = body[offset + 4 : offset + size].split(b"\0", 1)[0].rstrip(b"\n")
-		offset += size
-		if not payload:
-			continue
-		match = _REF_UPDATE_LINE.match(payload)
-		if match is None:
-			continue
-		try:
-			refs.append(match.group(1).decode("utf-8"))
-		except UnicodeDecodeError as exc:
-			raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid receive-pack request") from exc
-	if offset != total:
-		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid receive-pack request")
-	return refs
+    """Ref names from a stateless ``git-receive-pack`` command list.
+
+    Stops at the first flush pkt, which separates commands from the pack. Lines that
+    are not ``<old> <new> <ref>`` (capability text, push certificates) are ignored.
+    """
+    refs: list[str] = []
+    offset = 0
+    total = len(body)
+    while offset + 4 <= total:
+        header = body[offset : offset + 4]
+        try:
+            size = int(header, 16)
+        except ValueError as exc:
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid receive-pack request") from exc
+        if size == 0:
+            return refs
+        if size < 4 or offset + size > total:
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid receive-pack request")
+        payload = body[offset + 4 : offset + size].split(b"\0", 1)[0].rstrip(b"\n")
+        offset += size
+        if not payload:
+            continue
+        match = _REF_UPDATE_LINE.match(payload)
+        if match is None:
+            continue
+        try:
+            refs.append(match.group(1).decode("utf-8"))
+        except UnicodeDecodeError as exc:
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid receive-pack request") from exc
+    if offset != total:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid receive-pack request")
+    return refs
 
 
 def _enforce_branch_pushes(project: str, creds: SmartHttpCredentials, body: bytes) -> None:
-	if not creds.required or creds.principal is None:
-		return
-	for ref_name in receive_pack_updated_refs(body):
-		if can_push_ref(project, creds.principal, ref_name):
-			continue
-		raise HTTPException(
-			status_code=status.HTTP_403_FORBIDDEN,
-			detail=f"Insufficient permissions for {ref_name}",
-		)
+    if not creds.required or creds.principal is None:
+        return
+    for ref_name in receive_pack_updated_refs(body):
+        if can_push_ref(project, creds.principal, ref_name):
+            continue
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail=f"Insufficient permissions for {ref_name}",
+        )
 
 
 def _authorize_smart_http(project: str, creds: SmartHttpCredentials, *, write: bool) -> None:
-	if not creds.required:
-		return
-	principal = creds.principal
-	if principal is None:
-		if not write and can_read_project(project, None):
-			return
-		raise unauthorized_smart_http()
-	if write and creds.readonly:
-		raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Read-only key")
-	if write:
-		if principal_can_write_project(project, principal):
-			return
-		raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
-	if principal_can_read_project(project, principal):
-		return
-	raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
+    if not creds.required:
+        return
+    principal = creds.principal
+    if principal is None:
+        if not write and can_read_project(project, None):
+            return
+        raise unauthorized_smart_http()
+    if write and creds.readonly:
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Read-only key")
+    if write:
+        if principal_can_write_project(project, principal):
+            return
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
+    if principal_can_read_project(project, principal):
+        return
+    raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
 
 
 def _repo_cwd(project: str) -> Path:
-	full = os.path.join(settings.PROJECTROOT, project)
-	try:
-		git_dir = pygit2.discover_repository(full)
-	except (KeyError, pygit2.GitError, OSError) as exc:
-		raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project") from exc
-	if not git_dir:
-		raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
-	repo = pygit2.Repository(git_dir)
-	if repo.is_bare:
-		return Path(git_dir)
-	worktree = repo.workdir
-	if worktree is None:
-		raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Repository has no worktree")
-	return Path(worktree)
+    full = os.path.join(settings.PROJECTROOT, project)
+    try:
+        git_dir = pygit2.discover_repository(full)
+    except (KeyError, pygit2.GitError, OSError) as exc:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project") from exc
+    if not git_dir:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
+    repo = pygit2.Repository(git_dir)
+    if repo.is_bare:
+        return Path(git_dir)
+    worktree = repo.workdir
+    if worktree is None:
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Repository has no worktree")
+    return Path(worktree)
 
 
 def _run_git_service(
-	service: SmartHttpService,
-	project: str,
-	*,
-	advertise: bool,
-	body: bytes | None = None,
-	git_protocol: str | None = None,
+    service: SmartHttpService,
+    project: str,
+    *,
+    advertise: bool,
+    body: bytes | None = None,
+    git_protocol: str | None = None,
 ) -> bytes:
-	cwd = _repo_cwd(project)
-	args = [settings.GIT, service.removeprefix("git-"), "--stateless-rpc" if not advertise else "--advertise-refs", "."]
-	env = os.environ.copy()
-	if git_protocol:
-		env["GIT_PROTOCOL"] = git_protocol
-	try:
-		completed = subprocess.run(
-			args,
-			cwd=str(cwd),
-			input=body,
-			capture_output=True,
-			check=False,
-			env=env,
-		)
-	except OSError as exc:
-		raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
-	if completed.returncode != 0:
-		detail = completed.stderr.decode("utf-8", errors="replace").strip() or f"git {service} failed"
-		raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
-	return completed.stdout
+    cwd = _repo_cwd(project)
+    args = [settings.GIT, service.removeprefix("git-"), "--stateless-rpc" if not advertise else "--advertise-refs", "."]
+    env = os.environ.copy()
+    if git_protocol:
+        env["GIT_PROTOCOL"] = git_protocol
+    try:
+        completed = subprocess.run(
+            args,
+            cwd=str(cwd),
+            input=body,
+            capture_output=True,
+            check=False,
+            env=env,
+        )
+    except OSError as exc:
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
+    if completed.returncode != 0:
+        detail = completed.stderr.decode("utf-8", errors="replace").strip() or f"git {service} failed"
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
+    return completed.stdout
 
 
 def _service_advertisement(service: SmartHttpService, project: str) -> bytes:
-	refs = _run_git_service(service, project, advertise=True)
-	announce = _pkt_line(f"# service={service}\n".encode("ascii"))
-	return announce + _pkt_flush() + refs
+    refs = _run_git_service(service, project, advertise=True)
+    announce = _pkt_line(f"# service={service}\n".encode("ascii"))
+    return announce + _pkt_flush() + refs
 
 
 def _smart_http_cache_headers() -> dict[str, str]:
-	return {
-		"Cache-Control": "no-cache",
-		"Pragma": "no-cache",
-		"Expires": "Fri, 01 Jan 1980 00:00:00 GMT",
-	}
+    return {
+        "Cache-Control": "no-cache",
+        "Pragma": "no-cache",
+        "Expires": "Fri, 01 Jan 1980 00:00:00 GMT",
+    }
 
 
 @http_router.get("/{project:path}/info/refs")
 def smart_http_info_refs(
-	project: ValidatedHttpProject,
-	service: Annotated[SmartHttpService, Query(description="Git Smart HTTP service name")],
-	request: Request,
+    project: ValidatedHttpProject,
+    service: Annotated[SmartHttpService, Query(description="Git Smart HTTP service name")],
+    request: Request,
 ) -> Response:
-	_begin_smart_http(
-		request,
-		project,
-		write=service == "git-receive-pack",
-		operation=f"info/refs?service={service}",
-	)
-	body = _service_advertisement(service, project)
-	advertise_type, _ = _SERVICE_CONTENT_TYPES[service]
-	return Response(
-		content=body,
-		media_type=advertise_type,
-		headers=_smart_http_cache_headers(),
-	)
+    _begin_smart_http(
+        request,
+        project,
+        write=service == "git-receive-pack",
+        operation=f"info/refs?service={service}",
+    )
+    body = _service_advertisement(service, project)
+    advertise_type, _ = _SERVICE_CONTENT_TYPES[service]
+    return Response(
+        content=body,
+        media_type=advertise_type,
+        headers=_smart_http_cache_headers(),
+    )
 
 
 @http_router.post("/{project:path}/git-upload-pack")
 async def smart_http_upload_pack(
-	project: ValidatedHttpProject,
-	request: Request,
-	content_encoding: Annotated[str | None, Header(alias="Content-Encoding")] = None,
-	git_protocol: Annotated[str | None, Header(alias="Git-Protocol")] = None,
+    project: ValidatedHttpProject,
+    request: Request,
+    content_encoding: Annotated[str | None, Header(alias="Content-Encoding")] = None,
+    git_protocol: Annotated[str | None, Header(alias="Git-Protocol")] = None,
 ) -> Response:
-	_begin_smart_http(request, project, write=False, operation="git-upload-pack")
-	raw_body = await request.body()
-	body = _decode_request_body(raw_body, content_encoding)
-	result = _run_git_service(
-		"git-upload-pack",
-		project,
-		advertise=False,
-		body=body,
-		git_protocol=git_protocol,
-	)
-	_, result_type = _SERVICE_CONTENT_TYPES["git-upload-pack"]
-	return Response(content=result, media_type=result_type, headers=_smart_http_cache_headers())
+    _begin_smart_http(request, project, write=False, operation="git-upload-pack")
+    raw_body = await request.body()
+    body = _decode_request_body(raw_body, content_encoding)
+    result = _run_git_service(
+        "git-upload-pack",
+        project,
+        advertise=False,
+        body=body,
+        git_protocol=git_protocol,
+    )
+    _, result_type = _SERVICE_CONTENT_TYPES["git-upload-pack"]
+    return Response(content=result, media_type=result_type, headers=_smart_http_cache_headers())
 
 
 @http_router.post("/{project:path}/git-receive-pack")
 async def smart_http_receive_pack(
-	project: ValidatedHttpProject,
-	request: Request,
-	content_encoding: Annotated[str | None, Header(alias="Content-Encoding")] = None,
-	git_protocol: Annotated[str | None, Header(alias="Git-Protocol")] = None,
+    project: ValidatedHttpProject,
+    request: Request,
+    content_encoding: Annotated[str | None, Header(alias="Content-Encoding")] = None,
+    git_protocol: Annotated[str | None, Header(alias="Git-Protocol")] = None,
 ) -> Response:
-	creds = _begin_smart_http(request, project, write=True, operation="git-receive-pack")
-	raw_body = await request.body()
-	body = _decode_request_body(raw_body, content_encoding)
-	_enforce_branch_pushes(project, creds, body)
-	result = _run_git_service(
-		"git-receive-pack",
-		project,
-		advertise=False,
-		body=body,
-		git_protocol=git_protocol,
-	)
-	_, result_type = _SERVICE_CONTENT_TYPES["git-receive-pack"]
-	return Response(content=result, media_type=result_type, headers=_smart_http_cache_headers())
+    creds = _begin_smart_http(request, project, write=True, operation="git-receive-pack")
+    raw_body = await request.body()
+    body = _decode_request_body(raw_body, content_encoding)
+    _enforce_branch_pushes(project, creds, body)
+    result = _run_git_service(
+        "git-receive-pack",
+        project,
+        advertise=False,
+        body=body,
+        git_protocol=git_protocol,
+    )
+    _, result_type = _SERVICE_CONTENT_TYPES["git-receive-pack"]
+    return Response(content=result, media_type=result_type, headers=_smart_http_cache_headers())
diff --git a/pygitweb/src/pygitweb/smart_http_test.py b/pygitweb/src/pygitweb/smart_http_test.py
index 5d41eb8..87c1e99 100644
--- a/pygitweb/src/pygitweb/smart_http_test.py
+++ b/pygitweb/src/pygitweb/smart_http_test.py
@@ -30,13 +30,13 @@ from pygitweb.password_hash import hash_password
 from pygitweb.permissions import PermissionsMap
 from pygitweb.sessions import create_session
 from pygitweb.smart_http import (
-	_decode_request_body,
-	_pkt_flush,
-	_pkt_line,
-	_service_advertisement,
-	append_smart_http_log,
-	receive_pack_updated_refs,
-	smart_http_log_path,
+    _decode_request_body,
+    _pkt_flush,
+    _pkt_line,
+    _service_advertisement,
+    append_smart_http_log,
+    receive_pack_updated_refs,
+    smart_http_log_path,
 )
 
 _KEY_ISSUED = datetime(2026, 10, 8, 12, 0, tzinfo=UTC)
@@ -46,988 +46,988 @@ _WRITE_KEY_HASH = hash_password(_WRITE_KEY)
 _READ_KEY_HASH = hash_password(_READ_KEY)
 
 _LOCAL_ADMIN = AuthConfig(
-	auth_mode="local",
-	local_users=[LocalUser(user="alice", password="secret")],
-	oauth_permissions=PermissionsMap.default_access(),
+    auth_mode="local",
+    local_users=[LocalUser(user="alice", password="secret")],
+    oauth_permissions=PermissionsMap.default_access(),
 )
 
 
 def _user_with_keys(*, secret: str | None = None, salt: str = "") -> AuthConfig:
-	if salt:
-		write_secret = secret if secret is not None else hash_password(_WRITE_KEY, config_salt=salt)
-		read_secret = hash_password(_READ_KEY, config_salt=salt)
-	else:
-		write_secret = secret if secret is not None else _WRITE_KEY_HASH
-		read_secret = _READ_KEY_HASH
-	return AuthConfig(
-		auth_mode="local",
-		salt=salt,
-		local_users=[
-			LocalUser(
-				user="alice",
-				password="secret",
-				email="alice@example.com",
-				keys=[
-					HttpKey(id="laptop", secret=write_secret, readonly=False, time_issued=_KEY_ISSUED),
-					HttpKey(
-						id="ci",
-						secret=read_secret,
-						readonly=True,
-						time_issued=_KEY_ISSUED,
-					),
-				],
-			)
-		],
-		oauth_permissions=PermissionsMap.default_access(),
-	)
+    if salt:
+        write_secret = secret if secret is not None else hash_password(_WRITE_KEY, config_salt=salt)
+        read_secret = hash_password(_READ_KEY, config_salt=salt)
+    else:
+        write_secret = secret if secret is not None else _WRITE_KEY_HASH
+        read_secret = _READ_KEY_HASH
+    return AuthConfig(
+        auth_mode="local",
+        salt=salt,
+        local_users=[
+            LocalUser(
+                user="alice",
+                password="secret",
+                email="alice@example.com",
+                keys=[
+                    HttpKey(id="laptop", secret=write_secret, readonly=False, time_issued=_KEY_ISSUED),
+                    HttpKey(
+                        id="ci",
+                        secret=read_secret,
+                        readonly=True,
+                        time_issued=_KEY_ISSUED,
+                    ),
+                ],
+            )
+        ],
+        oauth_permissions=PermissionsMap.default_access(),
+    )
 
 
 def _push_commands(*refs: str) -> bytes:
-	zero = b"0" * 40
-	new = b"a" * 40
-	body = b""
-	for index, ref in enumerate(refs):
-		line = zero + b" " + new + b" " + ref.encode("ascii")
-		if index == 0:
-			line += b"\0report-status"
-		body += _pkt_line(line + b"\n")
-	return body + _pkt_flush()
+    zero = b"0" * 40
+    new = b"a" * 40
+    body = b""
+    for index, ref in enumerate(refs):
+        line = zero + b" " + new + b" " + ref.encode("ascii")
+        if index == 0:
+            line += b"\0report-status"
+        body += _pkt_line(line + b"\n")
+    return body + _pkt_flush()
 
 
 @contextmanager
 def _auth_enabled(cfg: AuthConfig) -> Generator[None, None, None]:
-	with (
-		patch.object(settings, "AUTH", True),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		yield
+    with (
+        patch.object(settings, "AUTH", True),
+        patch("pygitweb.auth_config.auth_config", cfg),
+        patch("pygitweb.auth.auth_config", cfg),
+        patch("pygitweb.main.auth_config", cfg),
+    ):
+        yield
 
 
 @pytest.fixture
 def local_auth_config() -> Generator[AuthConfig, None, None]:
-	with (
-		patch("pygitweb.auth_config.auth_config", _LOCAL_ADMIN),
-		patch("pygitweb.auth.auth_config", _LOCAL_ADMIN),
-		patch("pygitweb.main.auth_config", _LOCAL_ADMIN),
-	):
-		yield _LOCAL_ADMIN
+    with (
+        patch("pygitweb.auth_config.auth_config", _LOCAL_ADMIN),
+        patch("pygitweb.auth.auth_config", _LOCAL_ADMIN),
+        patch("pygitweb.main.auth_config", _LOCAL_ADMIN),
+    ):
+        yield _LOCAL_ADMIN
 
 
 def _run_git(repo_dir: Path, *args: str, git_config: tuple[str, ...] = ()) -> str:
-	completed = subprocess.run(
-		[settings.GIT, *git_config, *args],
-		cwd=repo_dir,
-		capture_output=True,
-		text=True,
-		check=True,
-		env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
-	)
-	return completed.stdout.strip()
+    completed = subprocess.run(
+        [settings.GIT, *git_config, *args],
+        cwd=repo_dir,
+        capture_output=True,
+        text=True,
+        check=True,
+        env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
+    )
+    return completed.stdout.strip()
 
 
 @contextmanager
 def _http_server() -> Generator[int, None, None]:
-	with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
-		sock.bind(("127.0.0.1", 0))
-		port = sock.getsockname()[1]
-	config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error", ws="none")
-	server = uvicorn.Server(config)
-	thread = threading.Thread(target=server.run, daemon=True)
-	thread.start()
-	deadline = time.time() + 5.0
-	while not server.started and time.time() < deadline:
-		time.sleep(0.01)
-	if not server.started:
-		server.should_exit = True
-		thread.join(timeout=5.0)
-		raise RuntimeError("smart HTTP server did not start")
-	try:
-		yield port
-	finally:
-		server.should_exit = True
-		thread.join(timeout=5.0)
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+        sock.bind(("127.0.0.1", 0))
+        port = sock.getsockname()[1]
+    config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error", ws="none")
+    server = uvicorn.Server(config)
+    thread = threading.Thread(target=server.run, daemon=True)
+    thread.start()
+    deadline = time.time() + 5.0
+    while not server.started and time.time() < deadline:
+        time.sleep(0.01)
+    if not server.started:
+        server.should_exit = True
+        thread.join(timeout=5.0)
+        raise RuntimeError("smart HTTP server did not start")
+    try:
+        yield port
+    finally:
+        server.should_exit = True
+        thread.join(timeout=5.0)
 
 
 def _key_git_config() -> tuple[str, ...]:
-	helper = f"!f() {{ echo username=alice; echo password={_WRITE_KEY}; }}; f"
-	return ("-c", "credential.helper=", "-c", f"credential.helper={helper}")
+    helper = f"!f() {{ echo username=alice; echo password={_WRITE_KEY}; }}; f"
+    return ("-c", "credential.helper=", "-c", f"credential.helper={helper}")
 
 
 def _seed_bare_repo(root: Path, bare_dir: Path, message: str = "initial") -> None:
-	work_dir = root / "seed-work"
-	work_dir.mkdir()
-	_run_git(work_dir, "init", "-b", "main")
-	_run_git(work_dir, "config", "user.name", "tester")
-	_run_git(work_dir, "config", "user.email", "tester@example.com")
-	(work_dir / "README.md").write_text(f"{message}\n", encoding="utf-8")
-	_run_git(work_dir, "add", "README.md")
-	_run_git(work_dir, "commit", "-m", message)
-	_run_git(work_dir, "remote", "add", "origin", str(bare_dir))
-	_run_git(work_dir, "push", "-u", "origin", "main")
+    work_dir = root / "seed-work"
+    work_dir.mkdir()
+    _run_git(work_dir, "init", "-b", "main")
+    _run_git(work_dir, "config", "user.name", "tester")
+    _run_git(work_dir, "config", "user.email", "tester@example.com")
+    (work_dir / "README.md").write_text(f"{message}\n", encoding="utf-8")
+    _run_git(work_dir, "add", "README.md")
+    _run_git(work_dir, "commit", "-m", message)
+    _run_git(work_dir, "remote", "add", "origin", str(bare_dir))
+    _run_git(work_dir, "push", "-u", "origin", "main")
 
 
 @pytest.fixture(scope="class")
 def http_repo_env(tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
-	root = tmp_path_factory.mktemp("smart-http")
-	repo_dir = root / "demo"
-	log_path = root / "smart_http.log"
-	pygit2.init_repository(str(repo_dir), bare=True)
-	_seed_bare_repo(root, repo_dir)
-	with (
-		patch.object(settings, "PROJECTROOT", str(root)),
-		patch.object(settings, "PROJECTS_LIST", str(root)),
-		patch.object(settings, "PROJECT_MAXDEPTH", 2),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "LIST_ALL", True),
-		patch.object(settings, "AUTH", False),
-		patch("pygitweb.smart_http.smart_http_log_path", return_value=log_path),
-	):
-		yield {
-			"project": "demo",
-			"repo_dir": str(repo_dir),
-			"root": str(root),
-			"log": str(log_path),
-		}
+    root = tmp_path_factory.mktemp("smart-http")
+    repo_dir = root / "demo"
+    log_path = root / "smart_http.log"
+    pygit2.init_repository(str(repo_dir), bare=True)
+    _seed_bare_repo(root, repo_dir)
+    with (
+        patch.object(settings, "PROJECTROOT", str(root)),
+        patch.object(settings, "PROJECTS_LIST", str(root)),
+        patch.object(settings, "PROJECT_MAXDEPTH", 2),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "LIST_ALL", True),
+        patch.object(settings, "AUTH", False),
+        patch("pygitweb.smart_http.smart_http_log_path", return_value=log_path),
+    ):
+        yield {
+            "project": "demo",
+            "repo_dir": str(repo_dir),
+            "root": str(root),
+            "log": str(log_path),
+        }
 
 
 class TestSmartHttpHelpers:
-	def test_pkt_line_and_flush(self) -> None:
-		assert _pkt_line(b"hello\n") == b"000ahello\n"
-		assert _pkt_flush() == b"0000"
-
-	def test_receive_pack_updated_refs_stops_at_flush(self) -> None:
-		body = _push_commands("refs/heads/main", "refs/tags/v1") + b"PACK leftover"
-		assert receive_pack_updated_refs(body) == ["refs/heads/main", "refs/tags/v1"]
-		cert = _pkt_line(b"push-cert\0report-status\n") + _pkt_line(b"certificate version 0.1\n")
-		assert receive_pack_updated_refs(cert + body) == ["refs/heads/main", "refs/tags/v1"]
-
-	def test_decode_request_body(self) -> None:
-		raw = b"0014command=ls-refs\n0000"
-		assert _decode_request_body(raw, None) == raw
-		assert _decode_request_body(raw, "identity") == raw
-		assert _decode_request_body(gzip.compress(raw), "gzip") == raw
-
-	def test_decode_request_body_rejects_invalid_gzip(self) -> None:
-		with pytest.raises(Exception) as exc_info:
-			_decode_request_body(b"not-gzip", "gzip")
-		assert exc_info.value.status_code == 400
-
-	def test_decode_request_body_rejects_unknown_encoding(self) -> None:
-		with pytest.raises(Exception) as exc_info:
-			_decode_request_body(b"payload", "br")
-		assert exc_info.value.status_code == 415
-
-	def test_service_advertisement_matches_git(self, http_repo_env: dict[str, str]) -> None:
-		direct = subprocess.run(
-			[settings.GIT, "upload-pack", "--advertise-refs", "."],
-			cwd=http_repo_env["repo_dir"],
-			capture_output=True,
-			check=True,
-		).stdout
-		body = _service_advertisement("git-upload-pack", http_repo_env["project"])
-		assert body.startswith(_pkt_line(b"# service=git-upload-pack\n"))
-		assert body.endswith(direct)
-		assert _pkt_flush() in body
+    def test_pkt_line_and_flush(self) -> None:
+        assert _pkt_line(b"hello\n") == b"000ahello\n"
+        assert _pkt_flush() == b"0000"
+
+    def test_receive_pack_updated_refs_stops_at_flush(self) -> None:
+        body = _push_commands("refs/heads/main", "refs/tags/v1") + b"PACK leftover"
+        assert receive_pack_updated_refs(body) == ["refs/heads/main", "refs/tags/v1"]
+        cert = _pkt_line(b"push-cert\0report-status\n") + _pkt_line(b"certificate version 0.1\n")
+        assert receive_pack_updated_refs(cert + body) == ["refs/heads/main", "refs/tags/v1"]
+
+    def test_decode_request_body(self) -> None:
+        raw = b"0014command=ls-refs\n0000"
+        assert _decode_request_body(raw, None) == raw
+        assert _decode_request_body(raw, "identity") == raw
+        assert _decode_request_body(gzip.compress(raw), "gzip") == raw
+
+    def test_decode_request_body_rejects_invalid_gzip(self) -> None:
+        with pytest.raises(Exception) as exc_info:
+            _decode_request_body(b"not-gzip", "gzip")
+        assert exc_info.value.status_code == 400
+
+    def test_decode_request_body_rejects_unknown_encoding(self) -> None:
+        with pytest.raises(Exception) as exc_info:
+            _decode_request_body(b"payload", "br")
+        assert exc_info.value.status_code == 415
+
+    def test_service_advertisement_matches_git(self, http_repo_env: dict[str, str]) -> None:
+        direct = subprocess.run(
+            [settings.GIT, "upload-pack", "--advertise-refs", "."],
+            cwd=http_repo_env["repo_dir"],
+            capture_output=True,
+            check=True,
+        ).stdout
+        body = _service_advertisement("git-upload-pack", http_repo_env["project"])
+        assert body.startswith(_pkt_line(b"# service=git-upload-pack\n"))
+        assert body.endswith(direct)
+        assert _pkt_flush() in body
 
 
 class TestSmartHttpRoutes:
-	def test_upload_pack_info_refs(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		resp = client.get(
-			f"/http/{http_repo_env['project']}/info/refs",
-			params={"service": "git-upload-pack"},
-		)
-		assert resp.status_code == 200
-		assert resp.headers["content-type"].startswith("application/x-git-upload-pack-advertisement")
-		assert b"# service=git-upload-pack" in resp.content
-		assert b"refs/heads/" in resp.content
-
-	def test_accepts_git_suffix(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		resp = client.get(
-			f"/http/{http_repo_env['project']}.git/info/refs",
-			params={"service": "git-upload-pack"},
-		)
-		assert resp.status_code == 200
-
-	def test_unknown_project_404(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		resp = client.get(
-			"/http/missing/info/refs",
-			params={"service": "git-upload-pack"},
-		)
-		assert resp.status_code == 404
-
-	def test_upload_pack_accepts_gzip_body(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		request_body = b"0000"
-		headers = {
-			"Content-Type": "application/x-git-upload-pack-request",
-			"Git-Protocol": "version=2",
-		}
-		plain = client.post(
-			f"/http/{http_repo_env['project']}/git-upload-pack",
-			content=request_body,
-			headers=headers,
-		)
-		gzipped = client.post(
-			f"/http/{http_repo_env['project']}/git-upload-pack",
-			content=gzip.compress(request_body),
-			headers={**headers, "Content-Encoding": "gzip"},
-		)
-		assert plain.status_code == gzipped.status_code
-		assert plain.content == gzipped.content
-
-	def test_receive_pack_requires_auth_when_enabled(
-		self,
-		http_repo_env: dict[str, str],
-		local_auth_config: AuthConfig,
-	) -> None:
-		del local_auth_config
-		client = TestClient(app)
-		with patch.object(settings, "AUTH", True):
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-receive-pack"},
-			)
-		assert resp.status_code == 401
-
-	def test_receive_pack_allowed_when_authenticated(
-		self,
-		http_repo_env: dict[str, str],
-		local_auth_config: AuthConfig,
-	) -> None:
-		del local_auth_config
-		client = TestClient(app)
-		with patch.object(settings, "AUTH", True):
-			set_client_session(client, "alice")
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-receive-pack"},
-			)
-		assert resp.status_code == 200
-		assert resp.headers["content-type"].startswith("application/x-git-receive-pack-advertisement")
-
-	def test_fetch_requires_auth_when_enabled(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		client = TestClient(app)
-		with _auth_enabled(_user_with_keys()):
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-			)
-		assert resp.status_code == 401
-		assert "Basic" in resp.headers["www-authenticate"]
-
-	def test_http_key_allows_fetch_and_push(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		client = TestClient(app)
-		with _auth_enabled(_user_with_keys()):
-			fetch = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", _WRITE_KEY),
-			)
-			push = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-receive-pack"},
-				auth=("Alice@Example.com", _WRITE_KEY),
-			)
-		assert fetch.status_code == 200
-		assert push.status_code == 200
-
-	def test_readonly_key_can_fetch_but_not_push(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		client = TestClient(app)
-		with _auth_enabled(_user_with_keys()):
-			fetch = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice@example.com", _READ_KEY),
-			)
-			push = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-receive-pack"},
-				auth=("alice", _READ_KEY),
-			)
-			post_push = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=b"0000",
-				auth=("alice", _READ_KEY),
-			)
-		assert fetch.status_code == 200
-		assert push.status_code == 403
-		assert post_push.status_code == 403
-
-	def test_login_password_is_not_an_http_key(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		client = TestClient(app)
-		with _auth_enabled(_user_with_keys()):
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", "secret"),
-			)
-			web = client.get("/users/me", auth=("alice", _WRITE_KEY))
-		assert resp.status_code == 401
-		assert web.status_code == 401
-
-	def test_hashed_http_key_allows_fetch(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		salt = "0123456789abcdef"
-		cfg = _user_with_keys(secret=hash_password(_WRITE_KEY, config_salt=salt), salt=salt)
-		client = TestClient(app)
-		with _auth_enabled(cfg):
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", _WRITE_KEY),
-			)
-			wrong = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", "not-the-key"),
-			)
-		assert resp.status_code == 200
-		assert wrong.status_code == 401
-
-	def test_http_key_without_project_permission(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		cfg = _user_with_keys()
-		cfg = cfg.model_copy(update={"oauth_permissions": PermissionsMap.empty()})
-		client = TestClient(app)
-		with (
-			_auth_enabled(cfg),
-			patch("pygitweb.auth.git_get_project_owner", return_value=None),
-		):
-			fetch = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", _WRITE_KEY),
-			)
-			push = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-receive-pack"},
-				auth=("alice", _WRITE_KEY),
-			)
-		assert fetch.status_code == 404
-		assert push.status_code == 403
-
-	def test_write_ref_whitelist_on_receive_pack(self, http_repo_env: dict[str, str]) -> None:
-		restricted = PermissionsMap.model_validate({
-			"*": [],
-			"pgw.read.*": ["*"],
-			"pgw.write.*": ["*"],
-			"pgw.write_ref.demo/heads/main": ["other@example.com"],
-		})
-		allowed = PermissionsMap.model_validate({
-			"*": [],
-			"pgw.read.*": ["*"],
-			"pgw.write.*": ["*"],
-			"pgw.write_ref.demo/heads/main": ["alice@example.com"],
-			"pgw.write_ref.demo/tags/*": ["alice@example.com"],
-		})
-		client = TestClient(app)
-		denied_body = _push_commands("refs/heads/main")
-		with (
-			_auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": restricted})),
-			patch("pygitweb.smart_http._run_git_service", return_value=b"") as run,
-		):
-			denied = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=denied_body,
-				auth=("alice", _WRITE_KEY),
-			)
-			tag = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=_push_commands("refs/tags/v1"),
-				auth=("alice", _WRITE_KEY),
-			)
-		assert denied.status_code == 403
-		assert "refs/heads/main" in denied.json()["detail"]
-		assert tag.status_code == 403
-		assert "refs/tags/v1" in tag.json()["detail"]
-		assert run.call_count == 0
-		with (
-			_auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": allowed})),
-			patch("pygitweb.smart_http._run_git_service", return_value=b"") as run_allowed,
-		):
-			accepted = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=denied_body,
-				auth=("alice", _WRITE_KEY),
-			)
-			tag_ok = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=_push_commands("refs/tags/v1"),
-				auth=("alice", _WRITE_KEY),
-			)
-		assert accepted.status_code == 200
-		assert tag_ok.status_code == 200
-		assert run_allowed.call_count == 2
-		with (
-			_auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": allowed})),
-			patch("pygitweb.smart_http._run_git_service", return_value=b""),
-		):
-			client.cookies.set(
-				ACCESS_TOKEN_COOKIE_NAME,
-				create_session("oauthuser", email="alice@example.com", auth_method="oauth"),
-			)
-			oauth_push = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=denied_body,
-			)
-		assert oauth_push.status_code == 200
-
-	def test_no_write_ref_falls_back_to_project_write(self, http_repo_env: dict[str, str]) -> None:
-		open_write = PermissionsMap.model_validate({
-			"*": [],
-			"pgw.read.*": ["*"],
-			"pgw.write.*": ["*"],
-		})
-		client = TestClient(app)
-		with (
-			_auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": open_write})),
-			patch("pygitweb.smart_http._run_git_service", return_value=b"") as run,
-		):
-			main = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=_push_commands("refs/heads/main"),
-				auth=("alice", _WRITE_KEY),
-			)
-			tag = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=_push_commands("refs/tags/v1"),
-				auth=("alice", _WRITE_KEY),
-			)
-		assert main.status_code == 200
-		assert tag.status_code == 200
-		assert run.call_count == 2
-
-	def test_ref_push_permission_on_receive_pack(self, http_repo_env: dict[str, str]) -> None:
-		restricted = PermissionsMap.model_validate({
-			"*": [],
-			"pgw.read.*": ["*"],
-			"pgw.write.*": ["*"],
-			"pgw.write_ref.demo/tags/v*": ["release@example.com"],
-			"pgw.write_ref.demo/notes/*": ["release@example.com"],
-		})
-		client = TestClient(app)
-		with (
-			_auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": restricted})),
-			patch("pygitweb.smart_http._run_git_service", return_value=b"") as run,
-		):
-			tag = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=_push_commands("refs/tags/v1"),
-				auth=("alice", _WRITE_KEY),
-			)
-			notes = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=_push_commands("refs/notes/commits"),
-				auth=("alice", _WRITE_KEY),
-			)
-			other_tag = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=_push_commands("refs/tags/nightly"),
-				auth=("alice", _WRITE_KEY),
-			)
-			branch = client.post(
-				f"/http/{http_repo_env['project']}/git-receive-pack",
-				content=_push_commands("refs/heads/main"),
-				auth=("alice", _WRITE_KEY),
-			)
-		assert tag.status_code == 403
-		assert "refs/tags/v1" in tag.json()["detail"]
-		assert notes.status_code == 403
-		assert other_tag.status_code == 403
-		assert branch.status_code == 403
-		assert run.call_count == 0
-
-	def test_clone_and_push_over_http(self, http_repo_env: dict[str, str]) -> None:
-		with _http_server() as port:
-			clone_dir = Path(http_repo_env["root"]) / "clone"
-			remote_url = f"http://127.0.0.1:{port}/http/{http_repo_env['project']}.git"
-			_run_git(Path(http_repo_env["root"]), "clone", remote_url, str(clone_dir))
-			_run_git(clone_dir, "config", "user.name", "tester")
-			_run_git(clone_dir, "config", "user.email", "tester@example.com")
-			(clone_dir / "new.txt").write_text("pushed\n", encoding="utf-8")
-			_run_git(clone_dir, "add", "new.txt")
-			_run_git(clone_dir, "commit", "-m", "add new")
-			_run_git(clone_dir, "push", "origin", "HEAD")
-			repo = pygit2.Repository(http_repo_env["repo_dir"])
-			commit = repo.head.peel()
-			assert commit.message.strip() == "add new"
-			tree = commit.tree
-			assert tree["new.txt"].data.decode("utf-8") == "pushed\n"
-
-	def test_expired_http_key_is_rejected_and_unexpired_key_still_works(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		now = datetime.now(UTC)
-		cfg = _user_with_keys()
-		user = cfg.local_users[0]
-		cfg = cfg.model_copy(
-			update={
-				"local_users": [
-					user.model_copy(
-						update={
-							"keys": [
-								HttpKey(
-									id="old",
-									secret=_WRITE_KEY_HASH,
-									readonly=False,
-									time_issued=_KEY_ISSUED,
-									time_expiry=now - timedelta(seconds=1),
-								),
-								HttpKey(
-									id="ci",
-									secret=_READ_KEY_HASH,
-									readonly=True,
-									time_issued=_KEY_ISSUED,
-									time_expiry=now + timedelta(days=1),
-								),
-							]
-						}
-					)
-				]
-			}
-		)
-		client = TestClient(app)
-		with _auth_enabled(cfg):
-			expired = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", _WRITE_KEY),
-			)
-			current = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", _READ_KEY),
-			)
-		assert expired.status_code == 401
-		assert current.status_code == 200
-
-	def test_http_key_rejects_unknown_user_and_user_without_email(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		cfg = AuthConfig(
-			auth_mode="local",
-			local_users=[
-				LocalUser(
-					user="alice",
-					password="secret",
-					email="alice@example.com",
-					keys=[HttpKey(id="laptop", secret=_WRITE_KEY_HASH, time_issued=_KEY_ISSUED)],
-				),
-				LocalUser(
-					user="bob",
-					password="secret",
-					keys=[HttpKey(id="bob-key", secret=hash_password("bob-secret"), time_issued=_KEY_ISSUED)],
-				),
-			],
-			oauth_permissions=PermissionsMap.default_access(),
-		)
-		client = TestClient(app)
-		with _auth_enabled(cfg):
-			wrong_user = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("carol", _WRITE_KEY),
-			)
-			blank_user = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				headers={"Authorization": f"Basic {base64.b64encode(b'   :' + _WRITE_KEY.encode()).decode()}"},
-			)
-		assert wrong_user.status_code == 401
-		assert blank_user.status_code == 401
-
-	def test_malformed_and_non_basic_authorization_rejected(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		client = TestClient(app)
-		url = f"/http/{http_repo_env['project']}/info/refs"
-		params = {"service": "git-upload-pack"}
-		with _auth_enabled(_user_with_keys()):
-			not_basic = client.get(url, params=params, headers={"Authorization": "Digest abc"})
-			empty_param = client.get(url, params=params, headers={"Authorization": "Basic"})
-			bad_b64 = client.get(url, params=params, headers={"Authorization": "Basic @@@@"})
-			no_colon = client.get(
-				url,
-				params=params,
-				headers={"Authorization": f"Basic {base64.b64encode(b'alice').decode()}"},
-			)
-			bad_bearer = client.get(url, params=params, headers={"Authorization": "Bearer not-a-session"})
-			client.cookies.set(ACCESS_TOKEN_COOKIE_NAME, "not-a-session")
-			bad_cookie = client.get(url, params=params)
-			client.cookies.clear()
-			bad_utf8 = client.get(
-				url,
-				params=params,
-				headers={"Authorization": f"Basic {base64.b64encode(b'\xff\xfe:secret').decode()}"},
-			)
-			empty_secret = client.get(
-				url,
-				params=params,
-				headers={"Authorization": f"Basic {base64.b64encode(b'alice:').decode()}"},
-			)
-			padded_secret = client.get(
-				url,
-				params=params,
-				auth=("  alice  ", f"  {_WRITE_KEY}  "),
-			)
-		assert not_basic.status_code == 401
-		assert empty_param.status_code == 401
-		assert bad_b64.status_code == 401
-		assert no_colon.status_code == 401
-		assert bad_utf8.status_code == 401
-		assert empty_secret.status_code == 401
-		assert padded_secret.status_code == 200
-		assert bad_bearer.status_code == 401
-		assert bad_cookie.status_code == 401
-
-	def test_bearer_session_allows_fetch(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		with _auth_enabled(_user_with_keys()):
-			token = create_session("alice", email="alice@example.com", auth_method="local")
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				headers={"Authorization": f"Bearer {token}"},
-			)
-		assert resp.status_code == 200
-
-	def test_keys_only_user_can_fetch_when_local_password_absent(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		cfg = AuthConfig(
-			auth_mode="local",
-			local_users=[
-				LocalUser(
-					user="alice",
-					email="alice@example.com",
-					keys=[HttpKey(id="laptop", secret=_WRITE_KEY_HASH, time_issued=_KEY_ISSUED)],
-				)
-			],
-			oauth_permissions=PermissionsMap.default_access(),
-		)
-		client = TestClient(app)
-		with _auth_enabled(cfg):
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", _WRITE_KEY),
-			)
-		assert resp.status_code == 200
-
-	def test_unconfigured_auth_without_keys_is_unavailable(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		cfg = AuthConfig(auth_mode="local", local_users=[], oauth_permissions=PermissionsMap.empty())
-		client = TestClient(app)
-		with _auth_enabled(cfg):
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-			)
-		assert resp.status_code == 503
-
-	def test_anonymous_reader_can_fetch_but_not_push(
-		self,
-		http_repo_env: dict[str, str],
-	) -> None:
-		cfg = _user_with_keys()
-		user = cfg.local_users[0].model_copy(update={"assume_anonymous": True})
-		cfg = cfg.model_copy(update={"local_users": [user]})
-		client = TestClient(app)
-		with _auth_enabled(cfg):
-			fetch = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-			)
-			push = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-receive-pack"},
-			)
-		assert fetch.status_code == 200
-		assert push.status_code == 401
-
-	def test_clone_and_push_with_http_key(self, http_repo_env: dict[str, str]) -> None:
-		with _auth_enabled(_user_with_keys()), _http_server() as port:
-			clone_dir = Path(http_repo_env["root"]) / "keyed-clone"
-			remote_url = f"http://127.0.0.1:{port}/http/{http_repo_env['project']}.git"
-			git_config = _key_git_config()
-			_run_git(Path(http_repo_env["root"]), "clone", remote_url, str(clone_dir), git_config=git_config)
-			_run_git(clone_dir, "config", "user.name", "tester")
-			_run_git(clone_dir, "config", "user.email", "tester@example.com")
-			(clone_dir / "from-key.txt").write_text("keyed\n", encoding="utf-8")
-			_run_git(clone_dir, "add", "from-key.txt")
-			_run_git(clone_dir, "commit", "-m", "add keyed")
-			_run_git(clone_dir, "push", "origin", "HEAD", git_config=git_config)
-			repo = pygit2.Repository(http_repo_env["repo_dir"])
-			commit = repo.head.peel()
-			assert commit.message.strip() == "add keyed"
-			assert commit.tree["from-key.txt"].data.decode("utf-8") == "keyed\n"
+    def test_upload_pack_info_refs(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        resp = client.get(
+            f"/http/{http_repo_env['project']}/info/refs",
+            params={"service": "git-upload-pack"},
+        )
+        assert resp.status_code == 200
+        assert resp.headers["content-type"].startswith("application/x-git-upload-pack-advertisement")
+        assert b"# service=git-upload-pack" in resp.content
+        assert b"refs/heads/" in resp.content
+
+    def test_accepts_git_suffix(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        resp = client.get(
+            f"/http/{http_repo_env['project']}.git/info/refs",
+            params={"service": "git-upload-pack"},
+        )
+        assert resp.status_code == 200
+
+    def test_unknown_project_404(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        resp = client.get(
+            "/http/missing/info/refs",
+            params={"service": "git-upload-pack"},
+        )
+        assert resp.status_code == 404
+
+    def test_upload_pack_accepts_gzip_body(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        request_body = b"0000"
+        headers = {
+            "Content-Type": "application/x-git-upload-pack-request",
+            "Git-Protocol": "version=2",
+        }
+        plain = client.post(
+            f"/http/{http_repo_env['project']}/git-upload-pack",
+            content=request_body,
+            headers=headers,
+        )
+        gzipped = client.post(
+            f"/http/{http_repo_env['project']}/git-upload-pack",
+            content=gzip.compress(request_body),
+            headers={**headers, "Content-Encoding": "gzip"},
+        )
+        assert plain.status_code == gzipped.status_code
+        assert plain.content == gzipped.content
+
+    def test_receive_pack_requires_auth_when_enabled(
+        self,
+        http_repo_env: dict[str, str],
+        local_auth_config: AuthConfig,
+    ) -> None:
+        del local_auth_config
+        client = TestClient(app)
+        with patch.object(settings, "AUTH", True):
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-receive-pack"},
+            )
+        assert resp.status_code == 401
+
+    def test_receive_pack_allowed_when_authenticated(
+        self,
+        http_repo_env: dict[str, str],
+        local_auth_config: AuthConfig,
+    ) -> None:
+        del local_auth_config
+        client = TestClient(app)
+        with patch.object(settings, "AUTH", True):
+            set_client_session(client, "alice")
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-receive-pack"},
+            )
+        assert resp.status_code == 200
+        assert resp.headers["content-type"].startswith("application/x-git-receive-pack-advertisement")
+
+    def test_fetch_requires_auth_when_enabled(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        client = TestClient(app)
+        with _auth_enabled(_user_with_keys()):
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+            )
+        assert resp.status_code == 401
+        assert "Basic" in resp.headers["www-authenticate"]
+
+    def test_http_key_allows_fetch_and_push(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        client = TestClient(app)
+        with _auth_enabled(_user_with_keys()):
+            fetch = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", _WRITE_KEY),
+            )
+            push = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-receive-pack"},
+                auth=("Alice@Example.com", _WRITE_KEY),
+            )
+        assert fetch.status_code == 200
+        assert push.status_code == 200
+
+    def test_readonly_key_can_fetch_but_not_push(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        client = TestClient(app)
+        with _auth_enabled(_user_with_keys()):
+            fetch = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice@example.com", _READ_KEY),
+            )
+            push = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-receive-pack"},
+                auth=("alice", _READ_KEY),
+            )
+            post_push = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=b"0000",
+                auth=("alice", _READ_KEY),
+            )
+        assert fetch.status_code == 200
+        assert push.status_code == 403
+        assert post_push.status_code == 403
+
+    def test_login_password_is_not_an_http_key(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        client = TestClient(app)
+        with _auth_enabled(_user_with_keys()):
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", "secret"),
+            )
+            web = client.get("/users/me", auth=("alice", _WRITE_KEY))
+        assert resp.status_code == 401
+        assert web.status_code == 401
+
+    def test_hashed_http_key_allows_fetch(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        salt = "0123456789abcdef"
+        cfg = _user_with_keys(secret=hash_password(_WRITE_KEY, config_salt=salt), salt=salt)
+        client = TestClient(app)
+        with _auth_enabled(cfg):
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", _WRITE_KEY),
+            )
+            wrong = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", "not-the-key"),
+            )
+        assert resp.status_code == 200
+        assert wrong.status_code == 401
+
+    def test_http_key_without_project_permission(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        cfg = _user_with_keys()
+        cfg = cfg.model_copy(update={"oauth_permissions": PermissionsMap.empty()})
+        client = TestClient(app)
+        with (
+            _auth_enabled(cfg),
+            patch("pygitweb.auth.git_get_project_owner", return_value=None),
+        ):
+            fetch = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", _WRITE_KEY),
+            )
+            push = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-receive-pack"},
+                auth=("alice", _WRITE_KEY),
+            )
+        assert fetch.status_code == 404
+        assert push.status_code == 403
+
+    def test_write_ref_whitelist_on_receive_pack(self, http_repo_env: dict[str, str]) -> None:
+        restricted = PermissionsMap.model_validate({
+            "*": [],
+            "pgw.read.*": ["*"],
+            "pgw.write.*": ["*"],
+            "pgw.write_ref.demo/heads/main": ["other@example.com"],
+        })
+        allowed = PermissionsMap.model_validate({
+            "*": [],
+            "pgw.read.*": ["*"],
+            "pgw.write.*": ["*"],
+            "pgw.write_ref.demo/heads/main": ["alice@example.com"],
+            "pgw.write_ref.demo/tags/*": ["alice@example.com"],
+        })
+        client = TestClient(app)
+        denied_body = _push_commands("refs/heads/main")
+        with (
+            _auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": restricted})),
+            patch("pygitweb.smart_http._run_git_service", return_value=b"") as run,
+        ):
+            denied = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=denied_body,
+                auth=("alice", _WRITE_KEY),
+            )
+            tag = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=_push_commands("refs/tags/v1"),
+                auth=("alice", _WRITE_KEY),
+            )
+        assert denied.status_code == 403
+        assert "refs/heads/main" in denied.json()["detail"]
+        assert tag.status_code == 403
+        assert "refs/tags/v1" in tag.json()["detail"]
+        assert run.call_count == 0
+        with (
+            _auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": allowed})),
+            patch("pygitweb.smart_http._run_git_service", return_value=b"") as run_allowed,
+        ):
+            accepted = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=denied_body,
+                auth=("alice", _WRITE_KEY),
+            )
+            tag_ok = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=_push_commands("refs/tags/v1"),
+                auth=("alice", _WRITE_KEY),
+            )
+        assert accepted.status_code == 200
+        assert tag_ok.status_code == 200
+        assert run_allowed.call_count == 2
+        with (
+            _auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": allowed})),
+            patch("pygitweb.smart_http._run_git_service", return_value=b""),
+        ):
+            client.cookies.set(
+                ACCESS_TOKEN_COOKIE_NAME,
+                create_session("oauthuser", email="alice@example.com", auth_method="oauth"),
+            )
+            oauth_push = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=denied_body,
+            )
+        assert oauth_push.status_code == 200
+
+    def test_no_write_ref_falls_back_to_project_write(self, http_repo_env: dict[str, str]) -> None:
+        open_write = PermissionsMap.model_validate({
+            "*": [],
+            "pgw.read.*": ["*"],
+            "pgw.write.*": ["*"],
+        })
+        client = TestClient(app)
+        with (
+            _auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": open_write})),
+            patch("pygitweb.smart_http._run_git_service", return_value=b"") as run,
+        ):
+            main = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=_push_commands("refs/heads/main"),
+                auth=("alice", _WRITE_KEY),
+            )
+            tag = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=_push_commands("refs/tags/v1"),
+                auth=("alice", _WRITE_KEY),
+            )
+        assert main.status_code == 200
+        assert tag.status_code == 200
+        assert run.call_count == 2
+
+    def test_ref_push_permission_on_receive_pack(self, http_repo_env: dict[str, str]) -> None:
+        restricted = PermissionsMap.model_validate({
+            "*": [],
+            "pgw.read.*": ["*"],
+            "pgw.write.*": ["*"],
+            "pgw.write_ref.demo/tags/v*": ["release@example.com"],
+            "pgw.write_ref.demo/notes/*": ["release@example.com"],
+        })
+        client = TestClient(app)
+        with (
+            _auth_enabled(_user_with_keys().model_copy(update={"oauth_permissions": restricted})),
+            patch("pygitweb.smart_http._run_git_service", return_value=b"") as run,
+        ):
+            tag = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=_push_commands("refs/tags/v1"),
+                auth=("alice", _WRITE_KEY),
+            )
+            notes = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=_push_commands("refs/notes/commits"),
+                auth=("alice", _WRITE_KEY),
+            )
+            other_tag = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=_push_commands("refs/tags/nightly"),
+                auth=("alice", _WRITE_KEY),
+            )
+            branch = client.post(
+                f"/http/{http_repo_env['project']}/git-receive-pack",
+                content=_push_commands("refs/heads/main"),
+                auth=("alice", _WRITE_KEY),
+            )
+        assert tag.status_code == 403
+        assert "refs/tags/v1" in tag.json()["detail"]
+        assert notes.status_code == 403
+        assert other_tag.status_code == 403
+        assert branch.status_code == 403
+        assert run.call_count == 0
+
+    def test_clone_and_push_over_http(self, http_repo_env: dict[str, str]) -> None:
+        with _http_server() as port:
+            clone_dir = Path(http_repo_env["root"]) / "clone"
+            remote_url = f"http://127.0.0.1:{port}/http/{http_repo_env['project']}.git"
+            _run_git(Path(http_repo_env["root"]), "clone", remote_url, str(clone_dir))
+            _run_git(clone_dir, "config", "user.name", "tester")
+            _run_git(clone_dir, "config", "user.email", "tester@example.com")
+            (clone_dir / "new.txt").write_text("pushed\n", encoding="utf-8")
+            _run_git(clone_dir, "add", "new.txt")
+            _run_git(clone_dir, "commit", "-m", "add new")
+            _run_git(clone_dir, "push", "origin", "HEAD")
+            repo = pygit2.Repository(http_repo_env["repo_dir"])
+            commit = repo.head.peel()
+            assert commit.message.strip() == "add new"
+            tree = commit.tree
+            assert tree["new.txt"].data.decode("utf-8") == "pushed\n"
+
+    def test_expired_http_key_is_rejected_and_unexpired_key_still_works(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        now = datetime.now(UTC)
+        cfg = _user_with_keys()
+        user = cfg.local_users[0]
+        cfg = cfg.model_copy(
+            update={
+                "local_users": [
+                    user.model_copy(
+                        update={
+                            "keys": [
+                                HttpKey(
+                                    id="old",
+                                    secret=_WRITE_KEY_HASH,
+                                    readonly=False,
+                                    time_issued=_KEY_ISSUED,
+                                    time_expiry=now - timedelta(seconds=1),
+                                ),
+                                HttpKey(
+                                    id="ci",
+                                    secret=_READ_KEY_HASH,
+                                    readonly=True,
+                                    time_issued=_KEY_ISSUED,
+                                    time_expiry=now + timedelta(days=1),
+                                ),
+                            ]
+                        }
+                    )
+                ]
+            }
+        )
+        client = TestClient(app)
+        with _auth_enabled(cfg):
+            expired = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", _WRITE_KEY),
+            )
+            current = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", _READ_KEY),
+            )
+        assert expired.status_code == 401
+        assert current.status_code == 200
+
+    def test_http_key_rejects_unknown_user_and_user_without_email(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        cfg = AuthConfig(
+            auth_mode="local",
+            local_users=[
+                LocalUser(
+                    user="alice",
+                    password="secret",
+                    email="alice@example.com",
+                    keys=[HttpKey(id="laptop", secret=_WRITE_KEY_HASH, time_issued=_KEY_ISSUED)],
+                ),
+                LocalUser(
+                    user="bob",
+                    password="secret",
+                    keys=[HttpKey(id="bob-key", secret=hash_password("bob-secret"), time_issued=_KEY_ISSUED)],
+                ),
+            ],
+            oauth_permissions=PermissionsMap.default_access(),
+        )
+        client = TestClient(app)
+        with _auth_enabled(cfg):
+            wrong_user = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("carol", _WRITE_KEY),
+            )
+            blank_user = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                headers={"Authorization": f"Basic {base64.b64encode(b'   :' + _WRITE_KEY.encode()).decode()}"},
+            )
+        assert wrong_user.status_code == 401
+        assert blank_user.status_code == 401
+
+    def test_malformed_and_non_basic_authorization_rejected(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        client = TestClient(app)
+        url = f"/http/{http_repo_env['project']}/info/refs"
+        params = {"service": "git-upload-pack"}
+        with _auth_enabled(_user_with_keys()):
+            not_basic = client.get(url, params=params, headers={"Authorization": "Digest abc"})
+            empty_param = client.get(url, params=params, headers={"Authorization": "Basic"})
+            bad_b64 = client.get(url, params=params, headers={"Authorization": "Basic @@@@"})
+            no_colon = client.get(
+                url,
+                params=params,
+                headers={"Authorization": f"Basic {base64.b64encode(b'alice').decode()}"},
+            )
+            bad_bearer = client.get(url, params=params, headers={"Authorization": "Bearer not-a-session"})
+            client.cookies.set(ACCESS_TOKEN_COOKIE_NAME, "not-a-session")
+            bad_cookie = client.get(url, params=params)
+            client.cookies.clear()
+            bad_utf8 = client.get(
+                url,
+                params=params,
+                headers={"Authorization": f"Basic {base64.b64encode(b'\xff\xfe:secret').decode()}"},
+            )
+            empty_secret = client.get(
+                url,
+                params=params,
+                headers={"Authorization": f"Basic {base64.b64encode(b'alice:').decode()}"},
+            )
+            padded_secret = client.get(
+                url,
+                params=params,
+                auth=("  alice  ", f"  {_WRITE_KEY}  "),
+            )
+        assert not_basic.status_code == 401
+        assert empty_param.status_code == 401
+        assert bad_b64.status_code == 401
+        assert no_colon.status_code == 401
+        assert bad_utf8.status_code == 401
+        assert empty_secret.status_code == 401
+        assert padded_secret.status_code == 200
+        assert bad_bearer.status_code == 401
+        assert bad_cookie.status_code == 401
+
+    def test_bearer_session_allows_fetch(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        with _auth_enabled(_user_with_keys()):
+            token = create_session("alice", email="alice@example.com", auth_method="local")
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                headers={"Authorization": f"Bearer {token}"},
+            )
+        assert resp.status_code == 200
+
+    def test_keys_only_user_can_fetch_when_local_password_absent(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        cfg = AuthConfig(
+            auth_mode="local",
+            local_users=[
+                LocalUser(
+                    user="alice",
+                    email="alice@example.com",
+                    keys=[HttpKey(id="laptop", secret=_WRITE_KEY_HASH, time_issued=_KEY_ISSUED)],
+                )
+            ],
+            oauth_permissions=PermissionsMap.default_access(),
+        )
+        client = TestClient(app)
+        with _auth_enabled(cfg):
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", _WRITE_KEY),
+            )
+        assert resp.status_code == 200
+
+    def test_unconfigured_auth_without_keys_is_unavailable(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        cfg = AuthConfig(auth_mode="local", local_users=[], oauth_permissions=PermissionsMap.empty())
+        client = TestClient(app)
+        with _auth_enabled(cfg):
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+            )
+        assert resp.status_code == 503
+
+    def test_anonymous_reader_can_fetch_but_not_push(
+        self,
+        http_repo_env: dict[str, str],
+    ) -> None:
+        cfg = _user_with_keys()
+        user = cfg.local_users[0].model_copy(update={"assume_anonymous": True})
+        cfg = cfg.model_copy(update={"local_users": [user]})
+        client = TestClient(app)
+        with _auth_enabled(cfg):
+            fetch = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+            )
+            push = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-receive-pack"},
+            )
+        assert fetch.status_code == 200
+        assert push.status_code == 401
+
+    def test_clone_and_push_with_http_key(self, http_repo_env: dict[str, str]) -> None:
+        with _auth_enabled(_user_with_keys()), _http_server() as port:
+            clone_dir = Path(http_repo_env["root"]) / "keyed-clone"
+            remote_url = f"http://127.0.0.1:{port}/http/{http_repo_env['project']}.git"
+            git_config = _key_git_config()
+            _run_git(Path(http_repo_env["root"]), "clone", remote_url, str(clone_dir), git_config=git_config)
+            _run_git(clone_dir, "config", "user.name", "tester")
+            _run_git(clone_dir, "config", "user.email", "tester@example.com")
+            (clone_dir / "from-key.txt").write_text("keyed\n", encoding="utf-8")
+            _run_git(clone_dir, "add", "from-key.txt")
+            _run_git(clone_dir, "commit", "-m", "add keyed")
+            _run_git(clone_dir, "push", "origin", "HEAD", git_config=git_config)
+            repo = pygit2.Repository(http_repo_env["repo_dir"])
+            commit = repo.head.peel()
+            assert commit.message.strip() == "add keyed"
+            assert commit.tree["from-key.txt"].data.decode("utf-8") == "keyed\n"
 
 
 _LOG_LINE = re.compile(r"^(?P<stamp>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) (?P<rest>.+)$")
 
 
 def _log_lines(http_repo_env: dict[str, str]) -> list[str]:
-	path = Path(http_repo_env["log"])
-	if not path.is_file():
-		return []
-	return path.read_text(encoding="utf-8").splitlines()
+    path = Path(http_repo_env["log"])
+    if not path.is_file():
+        return []
+    return path.read_text(encoding="utf-8").splitlines()
 
 
 def _assert_log_line(line: str, actor: str | None, *, project: str, operation: str) -> None:
-	match = _LOG_LINE.fullmatch(line)
-	assert match is not None, line
-	identity = "" if actor is None else f" {actor}"
-	assert match.group("rest") == f"testclient {project} {operation}{identity}"
+    match = _LOG_LINE.fullmatch(line)
+    assert match is not None, line
+    identity = "" if actor is None else f" {actor}"
+    assert match.group("rest") == f"testclient {project} {operation}{identity}"
 
 
 class TestSmartHttpLogFile:
-	def test_default_path_is_under_pygitweb_home(self, tmp_path: Path) -> None:
-		with patch("pygitweb.smart_http.Path.home", return_value=tmp_path):
-			assert smart_http_log_path() == tmp_path / ".pygitweb" / "smart_http.log"
-
-	def test_append_keeps_earlier_lines_and_restricts_mode(self, tmp_path: Path) -> None:
-		path = tmp_path / ".pygitweb" / "smart_http.log"
-		with patch("pygitweb.smart_http.smart_http_log_path", return_value=path):
-			append_smart_http_log(
-				"203.0.113.8", "alice@example.com laptop", project="demo", operation="git-upload-pack"
-			)
-			append_smart_http_log("203.0.113.9", "INVALID_KEY", project="demo", operation="git-receive-pack")
-		lines = path.read_text(encoding="utf-8").splitlines()
-		assert lines[0].endswith(" 203.0.113.8 demo git-upload-pack alice@example.com laptop")
-		assert lines[1].endswith(" 203.0.113.9 demo git-receive-pack INVALID_KEY")
-		assert path.read_bytes().endswith(b"INVALID_KEY\n")
-		if sys.platform != "win32":
-			assert stat.S_IMODE(path.stat().st_mode) == 0o600
+    def test_default_path_is_under_pygitweb_home(self, tmp_path: Path) -> None:
+        with patch("pygitweb.smart_http.Path.home", return_value=tmp_path):
+            assert smart_http_log_path() == tmp_path / ".pygitweb" / "smart_http.log"
+
+    def test_append_keeps_earlier_lines_and_restricts_mode(self, tmp_path: Path) -> None:
+        path = tmp_path / ".pygitweb" / "smart_http.log"
+        with patch("pygitweb.smart_http.smart_http_log_path", return_value=path):
+            append_smart_http_log(
+                "203.0.113.8", "alice@example.com laptop", project="demo", operation="git-upload-pack"
+            )
+            append_smart_http_log("203.0.113.9", "INVALID_KEY", project="demo", operation="git-receive-pack")
+        lines = path.read_text(encoding="utf-8").splitlines()
+        assert lines[0].endswith(" 203.0.113.8 demo git-upload-pack alice@example.com laptop")
+        assert lines[1].endswith(" 203.0.113.9 demo git-receive-pack INVALID_KEY")
+        assert path.read_bytes().endswith(b"INVALID_KEY\n")
+        if sys.platform != "win32":
+            assert stat.S_IMODE(path.stat().st_mode) == 0o600
 
 
 class TestSmartHttpAccessLog:
-	def test_each_route_logs_owner_email_and_key_id(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		project = http_repo_env["project"]
-		with _auth_enabled(_user_with_keys()):
-			before = len(_log_lines(http_repo_env))
-			refs = client.get(
-				f"/http/{project}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", _WRITE_KEY),
-			)
-			upload = client.post(
-				f"/http/{project}/git-upload-pack",
-				content=b"0000",
-				auth=("  alice  ", f"  {_WRITE_KEY}  "),
-			)
-			push_refs = client.get(
-				f"/http/{project}/info/refs",
-				params={"service": "git-receive-pack"},
-				auth=("Alice@Example.com", _WRITE_KEY),
-			)
-			receive = client.post(
-				f"/http/{project}/git-receive-pack",
-				content=b"0000",
-				auth=("alice", _WRITE_KEY),
-			)
-		assert refs.status_code == 200
-		assert push_refs.status_code == 200
-		assert upload.status_code != 401
-		assert receive.status_code != 401
-		lines = _log_lines(http_repo_env)[before:]
-		assert len(lines) == 4
-		operations = (
-			"info/refs?service=git-upload-pack",
-			"git-upload-pack",
-			"info/refs?service=git-receive-pack",
-			"git-receive-pack",
-		)
-		for line, operation in zip(lines, operations, strict=True):
-			_assert_log_line(line, "alice@example.com laptop", project=project, operation=operation)
-			assert _WRITE_KEY not in line
-
-	def test_broken_key_logs_invalid_key_and_expired_key_marks_id(self, http_repo_env: dict[str, str]) -> None:
-		now = datetime.now(UTC)
-		cfg = _user_with_keys()
-		user = cfg.local_users[0]
-		cfg = cfg.model_copy(
-			update={
-				"local_users": [
-					user.model_copy(
-						update={
-							"keys": [
-								HttpKey(
-									id="old",
-									secret=_WRITE_KEY_HASH,
-									readonly=False,
-									time_issued=_KEY_ISSUED,
-									time_expiry=now - timedelta(seconds=1),
-								),
-								HttpKey(
-									id="ci",
-									secret=_READ_KEY_HASH,
-									readonly=True,
-									time_issued=_KEY_ISSUED,
-									time_expiry=now + timedelta(days=1),
-								),
-							]
-						}
-					)
-				]
-			}
-		)
-		client = TestClient(app)
-		project = http_repo_env["project"]
-		url = f"/http/{project}/info/refs"
-		params = {"service": "git-upload-pack"}
-		with _auth_enabled(cfg):
-			before = len(_log_lines(http_repo_env))
-			expired = client.get(url, params=params, auth=("alice", _WRITE_KEY))
-			broken = client.get(url, params=params, auth=("alice", "not-the-key"))
-			malformed = client.get(url, params=params, headers={"Authorization": "Basic @@@@"})
-			current = client.get(url, params=params, auth=("alice", _READ_KEY))
-		assert expired.status_code == 401
-		assert broken.status_code == 401
-		assert malformed.status_code == 401
-		assert current.status_code == 200
-		lines = _log_lines(http_repo_env)[before:]
-		assert len(lines) == 4
-		operation = "info/refs?service=git-upload-pack"
-		_assert_log_line(lines[0], "alice@example.com old (EXPIRED)", project=project, operation=operation)
-		_assert_log_line(lines[1], "INVALID_KEY", project=project, operation=operation)
-		_assert_log_line(lines[2], "INVALID_KEY", project=project, operation=operation)
-		_assert_log_line(lines[3], "alice@example.com ci", project=project, operation=operation)
-		logged = "\n".join(lines)
-		assert _WRITE_KEY not in logged
-		assert _READ_KEY not in logged
-
-	def test_public_fetch_logs_username_instead_of_credentials(self, http_repo_env: dict[str, str]) -> None:
-		cfg = _user_with_keys()
-		user = cfg.local_users[0].model_copy(update={"assume_anonymous": True})
-		cfg = cfg.model_copy(update={"local_users": [user]})
-		client = TestClient(app)
-		project = http_repo_env["project"]
-		with _auth_enabled(cfg):
-			before = len(_log_lines(http_repo_env))
-			fetch = client.get(
-				f"/http/{project}/info/refs",
-				params={"service": "git-upload-pack"},
-			)
-			upload = client.post(f"/http/{project}/git-upload-pack", content=b"0000")
-			push = client.get(
-				f"/http/{project}/info/refs",
-				params={"service": "git-receive-pack"},
-			)
-			keyed = client.get(
-				f"/http/{project}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("alice", _WRITE_KEY),
-			)
-		assert fetch.status_code == 200
-		assert upload.status_code != 401
-		assert push.status_code == 401
-		assert keyed.status_code == 200
-		lines = _log_lines(http_repo_env)[before:]
-		assert len(lines) == 4
-		_assert_log_line(lines[0], "alice", project=project, operation="info/refs?service=git-upload-pack")
-		_assert_log_line(lines[1], "alice", project=project, operation="git-upload-pack")
-		_assert_log_line(lines[2], None, project=project, operation="info/refs?service=git-receive-pack")
-		_assert_log_line(
-			lines[3], "alice@example.com laptop", project=project, operation="info/refs?service=git-upload-pack"
-		)
-
-	def test_open_fetch_logs_ip_only(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		before = len(_log_lines(http_repo_env))
-		resp = client.get(
-			f"/http/{http_repo_env['project']}/info/refs",
-			params={"service": "git-upload-pack"},
-		)
-		assert resp.status_code == 200
-		lines = _log_lines(http_repo_env)[before:]
-		assert len(lines) == 1
-		_assert_log_line(
-			lines[0], None, project=http_repo_env["project"], operation="info/refs?service=git-upload-pack"
-		)
-
-	def test_session_logs_email_without_a_key_id(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		with _auth_enabled(_user_with_keys()):
-			token = create_session("alice", email="alice@example.com", auth_method="local")
-			before = len(_log_lines(http_repo_env))
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				headers={"Authorization": f"Bearer {token}"},
-			)
-		assert resp.status_code == 200
-		lines = _log_lines(http_repo_env)[before:]
-		assert len(lines) == 1
-		_assert_log_line(
-			lines[0],
-			"alice@example.com",
-			project=http_repo_env["project"],
-			operation="info/refs?service=git-upload-pack",
-		)
-
-	def test_key_owner_without_email_logs_username(self, http_repo_env: dict[str, str]) -> None:
-		cfg = AuthConfig(
-			auth_mode="local",
-			local_users=[
-				LocalUser(
-					user="bob",
-					password="secret",
-					keys=[HttpKey(id="bob-key", secret=hash_password("bob-secret"), time_issued=_KEY_ISSUED)],
-				)
-			],
-			oauth_permissions=PermissionsMap.default_access(),
-		)
-		client = TestClient(app)
-		with _auth_enabled(cfg):
-			before = len(_log_lines(http_repo_env))
-			resp = client.get(
-				f"/http/{http_repo_env['project']}/info/refs",
-				params={"service": "git-upload-pack"},
-				auth=("bob", "bob-secret"),
-			)
-		assert resp.status_code == 200
-		lines = _log_lines(http_repo_env)[before:]
-		assert len(lines) == 1
-		_assert_log_line(
-			lines[0],
-			"bob bob-key",
-			project=http_repo_env["project"],
-			operation="info/refs?service=git-upload-pack",
-		)
-		assert "bob-secret" not in lines[0]
-
-	def test_unknown_project_is_not_logged(self, http_repo_env: dict[str, str]) -> None:
-		client = TestClient(app)
-		before = len(_log_lines(http_repo_env))
-		resp = client.get("/http/missing/info/refs", params={"service": "git-upload-pack"})
-		assert resp.status_code == 404
-		assert _log_lines(http_repo_env)[before:] == []
+    def test_each_route_logs_owner_email_and_key_id(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        project = http_repo_env["project"]
+        with _auth_enabled(_user_with_keys()):
+            before = len(_log_lines(http_repo_env))
+            refs = client.get(
+                f"/http/{project}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", _WRITE_KEY),
+            )
+            upload = client.post(
+                f"/http/{project}/git-upload-pack",
+                content=b"0000",
+                auth=("  alice  ", f"  {_WRITE_KEY}  "),
+            )
+            push_refs = client.get(
+                f"/http/{project}/info/refs",
+                params={"service": "git-receive-pack"},
+                auth=("Alice@Example.com", _WRITE_KEY),
+            )
+            receive = client.post(
+                f"/http/{project}/git-receive-pack",
+                content=b"0000",
+                auth=("alice", _WRITE_KEY),
+            )
+        assert refs.status_code == 200
+        assert push_refs.status_code == 200
+        assert upload.status_code != 401
+        assert receive.status_code != 401
+        lines = _log_lines(http_repo_env)[before:]
+        assert len(lines) == 4
+        operations = (
+            "info/refs?service=git-upload-pack",
+            "git-upload-pack",
+            "info/refs?service=git-receive-pack",
+            "git-receive-pack",
+        )
+        for line, operation in zip(lines, operations, strict=True):
+            _assert_log_line(line, "alice@example.com laptop", project=project, operation=operation)
+            assert _WRITE_KEY not in line
+
+    def test_broken_key_logs_invalid_key_and_expired_key_marks_id(self, http_repo_env: dict[str, str]) -> None:
+        now = datetime.now(UTC)
+        cfg = _user_with_keys()
+        user = cfg.local_users[0]
+        cfg = cfg.model_copy(
+            update={
+                "local_users": [
+                    user.model_copy(
+                        update={
+                            "keys": [
+                                HttpKey(
+                                    id="old",
+                                    secret=_WRITE_KEY_HASH,
+                                    readonly=False,
+                                    time_issued=_KEY_ISSUED,
+                                    time_expiry=now - timedelta(seconds=1),
+                                ),
+                                HttpKey(
+                                    id="ci",
+                                    secret=_READ_KEY_HASH,
+                                    readonly=True,
+                                    time_issued=_KEY_ISSUED,
+                                    time_expiry=now + timedelta(days=1),
+                                ),
+                            ]
+                        }
+                    )
+                ]
+            }
+        )
+        client = TestClient(app)
+        project = http_repo_env["project"]
+        url = f"/http/{project}/info/refs"
+        params = {"service": "git-upload-pack"}
+        with _auth_enabled(cfg):
+            before = len(_log_lines(http_repo_env))
+            expired = client.get(url, params=params, auth=("alice", _WRITE_KEY))
+            broken = client.get(url, params=params, auth=("alice", "not-the-key"))
+            malformed = client.get(url, params=params, headers={"Authorization": "Basic @@@@"})
+            current = client.get(url, params=params, auth=("alice", _READ_KEY))
+        assert expired.status_code == 401
+        assert broken.status_code == 401
+        assert malformed.status_code == 401
+        assert current.status_code == 200
+        lines = _log_lines(http_repo_env)[before:]
+        assert len(lines) == 4
+        operation = "info/refs?service=git-upload-pack"
+        _assert_log_line(lines[0], "alice@example.com old (EXPIRED)", project=project, operation=operation)
+        _assert_log_line(lines[1], "INVALID_KEY", project=project, operation=operation)
+        _assert_log_line(lines[2], "INVALID_KEY", project=project, operation=operation)
+        _assert_log_line(lines[3], "alice@example.com ci", project=project, operation=operation)
+        logged = "\n".join(lines)
+        assert _WRITE_KEY not in logged
+        assert _READ_KEY not in logged
+
+    def test_public_fetch_logs_username_instead_of_credentials(self, http_repo_env: dict[str, str]) -> None:
+        cfg = _user_with_keys()
+        user = cfg.local_users[0].model_copy(update={"assume_anonymous": True})
+        cfg = cfg.model_copy(update={"local_users": [user]})
+        client = TestClient(app)
+        project = http_repo_env["project"]
+        with _auth_enabled(cfg):
+            before = len(_log_lines(http_repo_env))
+            fetch = client.get(
+                f"/http/{project}/info/refs",
+                params={"service": "git-upload-pack"},
+            )
+            upload = client.post(f"/http/{project}/git-upload-pack", content=b"0000")
+            push = client.get(
+                f"/http/{project}/info/refs",
+                params={"service": "git-receive-pack"},
+            )
+            keyed = client.get(
+                f"/http/{project}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("alice", _WRITE_KEY),
+            )
+        assert fetch.status_code == 200
+        assert upload.status_code != 401
+        assert push.status_code == 401
+        assert keyed.status_code == 200
+        lines = _log_lines(http_repo_env)[before:]
+        assert len(lines) == 4
+        _assert_log_line(lines[0], "alice", project=project, operation="info/refs?service=git-upload-pack")
+        _assert_log_line(lines[1], "alice", project=project, operation="git-upload-pack")
+        _assert_log_line(lines[2], None, project=project, operation="info/refs?service=git-receive-pack")
+        _assert_log_line(
+            lines[3], "alice@example.com laptop", project=project, operation="info/refs?service=git-upload-pack"
+        )
+
+    def test_open_fetch_logs_ip_only(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        before = len(_log_lines(http_repo_env))
+        resp = client.get(
+            f"/http/{http_repo_env['project']}/info/refs",
+            params={"service": "git-upload-pack"},
+        )
+        assert resp.status_code == 200
+        lines = _log_lines(http_repo_env)[before:]
+        assert len(lines) == 1
+        _assert_log_line(
+            lines[0], None, project=http_repo_env["project"], operation="info/refs?service=git-upload-pack"
+        )
+
+    def test_session_logs_email_without_a_key_id(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        with _auth_enabled(_user_with_keys()):
+            token = create_session("alice", email="alice@example.com", auth_method="local")
+            before = len(_log_lines(http_repo_env))
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                headers={"Authorization": f"Bearer {token}"},
+            )
+        assert resp.status_code == 200
+        lines = _log_lines(http_repo_env)[before:]
+        assert len(lines) == 1
+        _assert_log_line(
+            lines[0],
+            "alice@example.com",
+            project=http_repo_env["project"],
+            operation="info/refs?service=git-upload-pack",
+        )
+
+    def test_key_owner_without_email_logs_username(self, http_repo_env: dict[str, str]) -> None:
+        cfg = AuthConfig(
+            auth_mode="local",
+            local_users=[
+                LocalUser(
+                    user="bob",
+                    password="secret",
+                    keys=[HttpKey(id="bob-key", secret=hash_password("bob-secret"), time_issued=_KEY_ISSUED)],
+                )
+            ],
+            oauth_permissions=PermissionsMap.default_access(),
+        )
+        client = TestClient(app)
+        with _auth_enabled(cfg):
+            before = len(_log_lines(http_repo_env))
+            resp = client.get(
+                f"/http/{http_repo_env['project']}/info/refs",
+                params={"service": "git-upload-pack"},
+                auth=("bob", "bob-secret"),
+            )
+        assert resp.status_code == 200
+        lines = _log_lines(http_repo_env)[before:]
+        assert len(lines) == 1
+        _assert_log_line(
+            lines[0],
+            "bob bob-key",
+            project=http_repo_env["project"],
+            operation="info/refs?service=git-upload-pack",
+        )
+        assert "bob-secret" not in lines[0]
+
+    def test_unknown_project_is_not_logged(self, http_repo_env: dict[str, str]) -> None:
+        client = TestClient(app)
+        before = len(_log_lines(http_repo_env))
+        resp = client.get("/http/missing/info/refs", params={"service": "git-upload-pack"})
+        assert resp.status_code == 404
+        assert _log_lines(http_repo_env)[before:] == []
diff --git a/pygitweb/src/pygitweb/static_tests/conftest.py b/pygitweb/src/pygitweb/static_tests/conftest.py
index 676c576..7f52a6c 100644
--- a/pygitweb/src/pygitweb/static_tests/conftest.py
+++ b/pygitweb/src/pygitweb/static_tests/conftest.py
@@ -14,55 +14,55 @@ from pygitweb.static_tests.js_runtime import JsPage, StaticServer, _NavGuard
 
 
 class _FileModule(pytest.Module):
-	def _getobj(self) -> ModuleType:
-		name = "js_tests_" + self.path.stem.replace("-", "_")
-		spec = importlib.util.spec_from_file_location(name, self.path)
-		if spec is None or spec.loader is None:
-			raise ImportError(f"cannot load {self.path}")
-		module = importlib.util.module_from_spec(spec)
-		sys.modules[name] = module
-		spec.loader.exec_module(module)
-		return module
+    def _getobj(self) -> ModuleType:
+        name = "js_tests_" + self.path.stem.replace("-", "_")
+        spec = importlib.util.spec_from_file_location(name, self.path)
+        if spec is None or spec.loader is None:
+            raise ImportError(f"cannot load {self.path}")
+        module = importlib.util.module_from_spec(spec)
+        sys.modules[name] = module
+        spec.loader.exec_module(module)
+        return module
 
 
 @pytest.hookimpl(hookwrapper=True)
 def pytest_collect_file(file_path: Path, parent: pytest.Collector) -> object:
-	outcome = yield
-	if file_path.suffix != ".py" or not file_path.name.startswith("test_") or "-" not in file_path.name:
-		return
-	# pytest 9 gathers every pytest_collect_file result, so the default collector
-	# would import this hyphenated module a second time.
-	cast(Any, outcome).force_result([_FileModule.from_parent(parent, path=file_path)])
+    outcome = yield
+    if file_path.suffix != ".py" or not file_path.name.startswith("test_") or "-" not in file_path.name:
+        return
+    # pytest 9 gathers every pytest_collect_file result, so the default collector
+    # would import this hyphenated module a second time.
+    cast(Any, outcome).force_result([_FileModule.from_parent(parent, path=file_path)])
 
 
 @pytest.fixture(scope="session")
 def _js_browser() -> Iterator[tuple[Any, str]]:
-	pytest.importorskip("playwright.sync_api")
-	from playwright.sync_api import sync_playwright
+    pytest.importorskip("playwright.sync_api")
+    from playwright.sync_api import sync_playwright
 
-	server = StaticServer()
-	server.start()
-	try:
-		with sync_playwright() as playwright:
-			browser = playwright.chromium.launch(headless=True)
-			try:
-				yield browser, server.base_url
-			finally:
-				browser.close()
-	finally:
-		server.close()
+    server = StaticServer()
+    server.start()
+    try:
+        with sync_playwright() as playwright:
+            browser = playwright.chromium.launch(headless=True)
+            try:
+                yield browser, server.base_url
+            finally:
+                browser.close()
+    finally:
+        server.close()
 
 
 @pytest.fixture
 def js(_js_browser: tuple[Any, str]) -> Iterator[JsPage]:
-	browser, base_url = _js_browser
-	page = browser.new_page()
-	page.set_default_timeout(8000)
-	guard = _NavGuard()
-	page.route("**/*", guard.handle)
-	page.goto(base_url + "/", wait_until="domcontentloaded")
-	guard.current = page.url
-	guard.locked = True
-	js_page = JsPage(page, guard)
-	yield js_page
-	js_page.close()
+    browser, base_url = _js_browser
+    page = browser.new_page()
+    page.set_default_timeout(8000)
+    guard = _NavGuard()
+    page.route("**/*", guard.handle)
+    page.goto(base_url + "/", wait_until="domcontentloaded")
+    guard.current = page.url
+    guard.locked = True
+    js_page = JsPage(page, guard)
+    yield js_page
+    js_page.close()
diff --git a/pygitweb/src/pygitweb/static_tests/js_runtime.py b/pygitweb/src/pygitweb/static_tests/js_runtime.py
index 4646d10..3711d4a 100644
--- a/pygitweb/src/pygitweb/static_tests/js_runtime.py
+++ b/pygitweb/src/pygitweb/static_tests/js_runtime.py
@@ -22,256 +22,256 @@ _PAGE = """<!DOCTYPE html>
 
 
 class JsError(RuntimeError):
-	pass
+    pass
 
 
 class _Handler(BaseHTTPRequestHandler):
-	def log_message(self, format: str, *args: object) -> None:
-		return
-
-	def do_GET(self) -> None:
-		path = unquote(urlparse(self.path).path)
-		if path in ("/", "/index.html"):
-			self._send(200, "text/html; charset=utf-8", _PAGE.encode())
-			return
-		if path == "/harness.js":
-			self._send(200, "text/javascript; charset=utf-8", _HARNESS_JS.read_bytes())
-			return
-		if path.startswith("/static/"):
-			rel = path.removeprefix("/static/").lstrip("/")
-			file = (_STATIC / rel).resolve()
-			if file.is_relative_to(_STATIC.resolve()) and file.is_file():
-				mime = "text/javascript; charset=utf-8" if file.suffix == ".js" else "application/octet-stream"
-				self._send(200, mime, file.read_bytes())
-				return
-		self._send(404, "text/plain; charset=utf-8", b"not found")
-
-	def _send(self, status: int, content_type: str, body: bytes) -> None:
-		self.send_response(status)
-		self.send_header("Content-Type", content_type)
-		self.send_header("Content-Length", str(len(body)))
-		self.send_header("Cache-Control", "no-store")
-		self.end_headers()
-		self.wfile.write(body)
+    def log_message(self, format: str, *args: object) -> None:
+        return
+
+    def do_GET(self) -> None:
+        path = unquote(urlparse(self.path).path)
+        if path in ("/", "/index.html"):
+            self._send(200, "text/html; charset=utf-8", _PAGE.encode())
+            return
+        if path == "/harness.js":
+            self._send(200, "text/javascript; charset=utf-8", _HARNESS_JS.read_bytes())
+            return
+        if path.startswith("/static/"):
+            rel = path.removeprefix("/static/").lstrip("/")
+            file = (_STATIC / rel).resolve()
+            if file.is_relative_to(_STATIC.resolve()) and file.is_file():
+                mime = "text/javascript; charset=utf-8" if file.suffix == ".js" else "application/octet-stream"
+                self._send(200, mime, file.read_bytes())
+                return
+        self._send(404, "text/plain; charset=utf-8", b"not found")
+
+    def _send(self, status: int, content_type: str, body: bytes) -> None:
+        self.send_response(status)
+        self.send_header("Content-Type", content_type)
+        self.send_header("Content-Length", str(len(body)))
+        self.send_header("Cache-Control", "no-store")
+        self.end_headers()
+        self.wfile.write(body)
 
 
 class StaticServer:
-	def __init__(self) -> None:
-		self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
-		self.base_url = f"http://127.0.0.1:{self._httpd.server_address[1]}"
-		self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
+    def __init__(self) -> None:
+        self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
+        self.base_url = f"http://127.0.0.1:{self._httpd.server_address[1]}"
+        self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
 
-	def start(self) -> None:
-		self._thread.start()
+    def start(self) -> None:
+        self._thread.start()
 
-	def close(self) -> None:
-		self._httpd.shutdown()
-		self._httpd.server_close()
+    def close(self) -> None:
+        self._httpd.shutdown()
+        self._httpd.server_close()
 
 
 class _NavGuard:
-	def __init__(self) -> None:
-		self.locked = False
-		self.current = ""
-		self.events: list[tuple[str, str]] = []
-
-	def handle(self, route: Any) -> None:
-		request = route.request
-		if request.is_navigation_request() and self.locked:
-			target = urlparse(request.url)
-			current = urlparse(self.current)
-			same = (target.path, target.query) == (current.path, current.query)
-			rel = target.path or "/"
-			if target.query:
-				rel += "?" + target.query
-			self.events.append(("reload" if same else "assign", rel))
-			route.fulfill(status=204, body=b"")
-			return
-		route.fallback()
+    def __init__(self) -> None:
+        self.locked = False
+        self.current = ""
+        self.events: list[tuple[str, str]] = []
+
+    def handle(self, route: Any) -> None:
+        request = route.request
+        if request.is_navigation_request() and self.locked:
+            target = urlparse(request.url)
+            current = urlparse(self.current)
+            same = (target.path, target.query) == (current.path, current.query)
+            rel = target.path or "/"
+            if target.query:
+                rel += "?" + target.query
+            self.events.append(("reload" if same else "assign", rel))
+            route.fulfill(status=204, body=b"")
+            return
+        route.fallback()
 
 
 def _module_names(name: str) -> list[str]:
-	seen: list[str] = []
+    seen: list[str] = []
 
-	def walk(module: str) -> None:
-		if module in seen:
-			return
-		seen.append(module)
-		text = (_STATIC / module).read_text(encoding="utf-8")
-		for match in _IMPORT_FROM.finditer(text):
-			walk(match.group(1).removeprefix("./"))
+    def walk(module: str) -> None:
+        if module in seen:
+            return
+        seen.append(module)
+        text = (_STATIC / module).read_text(encoding="utf-8")
+        for match in _IMPORT_FROM.finditer(text):
+            walk(match.group(1).removeprefix("./"))
 
-	walk(name)
-	return seen
+    walk(name)
+    return seen
 
 
 def _same_origin_path(href: str) -> str:
-	if href.startswith("#") or href.startswith("/") or href.startswith("?"):
-		return href
-	parsed = urlparse(href)
-	path = parsed.path or "/"
-	if parsed.query:
-		path += "?" + parsed.query
-	if parsed.fragment:
-		path += "#" + parsed.fragment
-	return path
+    if href.startswith("#") or href.startswith("/") or href.startswith("?"):
+        return href
+    parsed = urlparse(href)
+    path = parsed.path or "/"
+    if parsed.query:
+        path += "?" + parsed.query
+    if parsed.fragment:
+        path += "#" + parsed.fragment
+    return path
 
 
 class JsPage:
-	def __init__(self, page: Any, guard: _NavGuard) -> None:
-		self._page = page
-		self._guard = guard
-
-	def close(self) -> None:
-		self._page.close()
-
-	def _evaluate(self, expression: str, arg: object | None = None) -> Any:
-		self._flush_nav()
-		try:
-			result = self._page.evaluate(expression) if arg is None else self._page.evaluate(expression, arg)
-		except Exception as exc:
-			raise JsError(str(exc)) from exc
-		self._flush_nav()
-		return result
-
-	def _flush_nav(self) -> None:
-		self._page.evaluate("() => 0")
-		self._publish()
-
-	def _publish(self) -> None:
-		events = self._guard.events
-		self._guard.events = []
-		for kind, url in events:
-			if kind == "reload":
-				self._page.evaluate("() => { globalThis.__reloaded = true; }")
-			else:
-				self._page.evaluate("(url) => { globalThis.__assigned = url; }", url)
-
-	def run(self, source: str) -> None:
-		self._evaluate("source => globalThis.__run(source)", source)
-
-	def value(self, expression: str) -> Any:
-		return self._evaluate("expression => globalThis.__value(expression)", expression)
-
-	def settle(self, expression: str) -> Any:
-		return self._evaluate("expression => globalThis.__settle(expression)", expression)
-
-	def set_html(self, html: str) -> None:
-		self._evaluate("html => globalThis.__setHtml(html)", html)
-
-	def set_location(self, href: str) -> None:
-		self._evaluate("path => globalThis.__setLocation(path)", _same_origin_path(href))
-		self._guard.current = self._page.url
-
-	def load(self, name: str) -> None:
-		self._evaluate("names => globalThis.__load(names)", _module_names(name))
-
-	def call(self, module: str, fn: str, *args: object) -> Any:
-		joined = ", ".join(json.dumps(arg) for arg in args)
-		return self.value(f"__loaded[{json.dumps(module)}][{json.dumps(fn)}]({joined})")
-
-	def route(
-		self,
-		url: str,
-		*,
-		status: int = 200,
-		body: object | None = None,
-		text: str | None = None,
-		hang: bool = False,
-		json_error: bool = False,
-		status_text: str = "",
-		error: str | None = None,
-	) -> None:
-		spec: dict[str, object] = {
-			"url": url,
-			"status": status,
-			"hang": hang,
-			"jsonError": json_error,
-			"statusText": status_text,
-		}
-		if error is not None:
-			spec["reject"] = error
-		if text is not None:
-			spec["text"] = text
-		elif body is not None:
-			spec["json"] = body
-		self._evaluate("spec => { globalThis.__routes.push(spec); }", spec)
-
-	def fulfill(self, url: str, *, status: int = 200, body: object | None = None, text: str | None = None) -> None:
-		spec: dict[str, object] = {"status": status}
-		if text is not None:
-			spec["text"] = text
-		elif body is not None:
-			spec["json"] = body
-		self._evaluate(
-			"payload => { globalThis.__fulfill(payload.url, payload.spec); return globalThis.__drain(); }",
-			{"url": url, "spec": spec},
-		)
-
-	def tick(self) -> None:
-		self._evaluate("() => { globalThis.__tick(); return globalThis.__drain(); }")
-
-	def text(self, selector: str) -> str:
-		found = self.value("document.querySelector(" + json.dumps(selector) + ").textContent")
-		return "" if found is None else str(found)
-
-	def texts(self, selector: str) -> list[str]:
-		found = self.value(
-			"Array.from(document.querySelectorAll("
-			+ json.dumps(selector)
-			+ ")).map(function(el){return el.textContent;})"
-		)
-		return [str(item) for item in found or []]
-
-	def attr(self, selector: str, name: str) -> str | None:
-		value = self.value(
-			"(document.querySelector(" + json.dumps(selector) + ")||{}).getAttribute(" + json.dumps(name) + ")"
-		)
-		return None if value is None else str(value)
-
-	def attrs(self, selector: str, name: str) -> list[str | None]:
-		found = self.value(
-			"Array.from(document.querySelectorAll("
-			+ json.dumps(selector)
-			+ ")).map(function(el){return el.getAttribute("
-			+ json.dumps(name)
-			+ ");})"
-		)
-		return list(found or [])
-
-	def count(self, selector: str) -> int:
-		value = self.value("document.querySelectorAll(" + json.dumps(selector) + ").length")
-		return int(value or 0)
-
-	def classes(self, selector: str) -> list[str]:
-		value = self.value(
-			"(document.querySelector("
-			+ json.dumps(selector)
-			+ ").getAttribute('class')||'').split(/\\s+/).filter(Boolean)"
-		)
-		return list(value or [])
-
-	def click(self, selector: str) -> None:
-		self._evaluate("selector => globalThis.__click(selector)", selector)
-
-	def dispatch(self, selector: str, event: str, **fields: object) -> None:
-		self._evaluate(
-			"payload => globalThis.__dispatch(payload.selector, payload.type, payload.fields)",
-			{"selector": selector, "type": event, "fields": fields},
-		)
-
-	def dispatch_window(self, event: str) -> None:
-		self._evaluate("type => globalThis.__dispatchWindow(type)", event)
-
-	def set_prop(self, selector: str, name: str, value: object) -> None:
-		self._evaluate(
-			"payload => globalThis.__setProp(payload.selector, payload.name, payload.value)",
-			{"selector": selector, "name": name, "value": value},
-		)
-
-	def requests(self) -> list[dict[str, Any]]:
-		found = self.value("__requests")
-		return [] if not isinstance(found, list) else found
-
-	def prop(self, selector: str, name: str) -> Any:
-		return self.value("document.querySelector(" + json.dumps(selector) + ")[" + json.dumps(name) + "]")
+    def __init__(self, page: Any, guard: _NavGuard) -> None:
+        self._page = page
+        self._guard = guard
+
+    def close(self) -> None:
+        self._page.close()
+
+    def _evaluate(self, expression: str, arg: object | None = None) -> Any:
+        self._flush_nav()
+        try:
+            result = self._page.evaluate(expression) if arg is None else self._page.evaluate(expression, arg)
+        except Exception as exc:
+            raise JsError(str(exc)) from exc
+        self._flush_nav()
+        return result
+
+    def _flush_nav(self) -> None:
+        self._page.evaluate("() => 0")
+        self._publish()
+
+    def _publish(self) -> None:
+        events = self._guard.events
+        self._guard.events = []
+        for kind, url in events:
+            if kind == "reload":
+                self._page.evaluate("() => { globalThis.__reloaded = true; }")
+            else:
+                self._page.evaluate("(url) => { globalThis.__assigned = url; }", url)
+
+    def run(self, source: str) -> None:
+        self._evaluate("source => globalThis.__run(source)", source)
+
+    def value(self, expression: str) -> Any:
+        return self._evaluate("expression => globalThis.__value(expression)", expression)
+
+    def settle(self, expression: str) -> Any:
+        return self._evaluate("expression => globalThis.__settle(expression)", expression)
+
+    def set_html(self, html: str) -> None:
+        self._evaluate("html => globalThis.__setHtml(html)", html)
+
+    def set_location(self, href: str) -> None:
+        self._evaluate("path => globalThis.__setLocation(path)", _same_origin_path(href))
+        self._guard.current = self._page.url
+
+    def load(self, name: str) -> None:
+        self._evaluate("names => globalThis.__load(names)", _module_names(name))
+
+    def call(self, module: str, fn: str, *args: object) -> Any:
+        joined = ", ".join(json.dumps(arg) for arg in args)
+        return self.value(f"__loaded[{json.dumps(module)}][{json.dumps(fn)}]({joined})")
+
+    def route(
+        self,
+        url: str,
+        *,
+        status: int = 200,
+        body: object | None = None,
+        text: str | None = None,
+        hang: bool = False,
+        json_error: bool = False,
+        status_text: str = "",
+        error: str | None = None,
+    ) -> None:
+        spec: dict[str, object] = {
+            "url": url,
+            "status": status,
+            "hang": hang,
+            "jsonError": json_error,
+            "statusText": status_text,
+        }
+        if error is not None:
+            spec["reject"] = error
+        if text is not None:
+            spec["text"] = text
+        elif body is not None:
+            spec["json"] = body
+        self._evaluate("spec => { globalThis.__routes.push(spec); }", spec)
+
+    def fulfill(self, url: str, *, status: int = 200, body: object | None = None, text: str | None = None) -> None:
+        spec: dict[str, object] = {"status": status}
+        if text is not None:
+            spec["text"] = text
+        elif body is not None:
+            spec["json"] = body
+        self._evaluate(
+            "payload => { globalThis.__fulfill(payload.url, payload.spec); return globalThis.__drain(); }",
+            {"url": url, "spec": spec},
+        )
+
+    def tick(self) -> None:
+        self._evaluate("() => { globalThis.__tick(); return globalThis.__drain(); }")
+
+    def text(self, selector: str) -> str:
+        found = self.value("document.querySelector(" + json.dumps(selector) + ").textContent")
+        return "" if found is None else str(found)
+
+    def texts(self, selector: str) -> list[str]:
+        found = self.value(
+            "Array.from(document.querySelectorAll("
+            + json.dumps(selector)
+            + ")).map(function(el){return el.textContent;})"
+        )
+        return [str(item) for item in found or []]
+
+    def attr(self, selector: str, name: str) -> str | None:
+        value = self.value(
+            "(document.querySelector(" + json.dumps(selector) + ")||{}).getAttribute(" + json.dumps(name) + ")"
+        )
+        return None if value is None else str(value)
+
+    def attrs(self, selector: str, name: str) -> list[str | None]:
+        found = self.value(
+            "Array.from(document.querySelectorAll("
+            + json.dumps(selector)
+            + ")).map(function(el){return el.getAttribute("
+            + json.dumps(name)
+            + ");})"
+        )
+        return list(found or [])
+
+    def count(self, selector: str) -> int:
+        value = self.value("document.querySelectorAll(" + json.dumps(selector) + ").length")
+        return int(value or 0)
+
+    def classes(self, selector: str) -> list[str]:
+        value = self.value(
+            "(document.querySelector("
+            + json.dumps(selector)
+            + ").getAttribute('class')||'').split(/\\s+/).filter(Boolean)"
+        )
+        return list(value or [])
+
+    def click(self, selector: str) -> None:
+        self._evaluate("selector => globalThis.__click(selector)", selector)
+
+    def dispatch(self, selector: str, event: str, **fields: object) -> None:
+        self._evaluate(
+            "payload => globalThis.__dispatch(payload.selector, payload.type, payload.fields)",
+            {"selector": selector, "type": event, "fields": fields},
+        )
+
+    def dispatch_window(self, event: str) -> None:
+        self._evaluate("type => globalThis.__dispatchWindow(type)", event)
+
+    def set_prop(self, selector: str, name: str, value: object) -> None:
+        self._evaluate(
+            "payload => globalThis.__setProp(payload.selector, payload.name, payload.value)",
+            {"selector": selector, "name": name, "value": value},
+        )
+
+    def requests(self) -> list[dict[str, Any]]:
+        found = self.value("__requests")
+        return [] if not isinstance(found, list) else found
+
+    def prop(self, selector: str, name: str) -> Any:
+        return self.value("document.querySelector(" + json.dumps(selector) + ")[" + json.dumps(name) + "]")
diff --git a/pygitweb/src/pygitweb/static_tests/pages.py b/pygitweb/src/pygitweb/static_tests/pages.py
index 16459f1..06a74bf 100644
--- a/pygitweb/src/pygitweb/static_tests/pages.py
+++ b/pygitweb/src/pygitweb/static_tests/pages.py
@@ -8,107 +8,107 @@ from typing import Any
 from pygitweb.static_tests.js_runtime import JsPage
 
 MANIFEST: dict[str, list[dict[str, str]]] = {
-	"commits": [
-		{
-			"insert": "abcd",
-			"name": "subject",
-			"url": "/c/abcd",
-			"tags": "abcd subject",
-			"snippet": "subject",
-			"created": "2020-01-01",
-		},
-		{"insert": "eeee", "name": "other", "url": "/c/eeee", "tags": "eeee"},
-	],
-	"refs": [
-		{"insert": "main", "name": "main", "url": "/r/main", "tags": "main branch"},
-		{"insert": "task-1234", "name": "task", "url": "/r/task", "tags": "task"},
-	],
+    "commits": [
+        {
+            "insert": "abcd",
+            "name": "subject",
+            "url": "/c/abcd",
+            "tags": "abcd subject",
+            "snippet": "subject",
+            "created": "2020-01-01",
+        },
+        {"insert": "eeee", "name": "other", "url": "/c/eeee", "tags": "eeee"},
+    ],
+    "refs": [
+        {"insert": "main", "name": "main", "url": "/r/main", "tags": "main branch"},
+        {"insert": "task-1234", "name": "task", "url": "/r/task", "tags": "task"},
+    ],
 }
 
 
 def card_html(
-	*,
-	ref: str = "",
-	status: str = "TODO",
-	points: str = "",
-	title: str = "",
-	description: str = "",
+    *,
+    ref: str = "",
+    status: str = "TODO",
+    points: str = "",
+    title: str = "",
+    description: str = "",
 ) -> str:
-	return (
-		f'<div class="card board-card" draggable="true" data-task-ref="{ref}" '
-		f'data-task-status="{status}" data-task-points="{points}">'
-		'<span class="board-card-icon" data-icon="cloud"></span>'
-		'<div class="board-card-title-line">'
-		'<span class="board-card-task-id"></span>'
-		f'<span class="board-card-title">{title}</span></div>'
-		'<div class="board-card-description-preview"></div>'
-		'<div class="board-card-meta">'
-		'<span class="badge">LOW</span>'
-		'<span class="board-card-points-badge d-none"></span>'
-		'<span class="board-card-assignee-compact">None</span>'
-		'<span class="ms-auto board-card-comments-count-meta">0 comments</span>'
-		"</div>"
-		'<div class="board-card-details d-none">'
-		'<div class="board-card-task-ref"></div>'
-		f'<div class="board-card-description-full">{description}</div>'
-		'<div class="board-card-due-date">None</div>'
-		'<div class="board-card-updated-at">None</div>'
-		'<div class="board-card-status">TODO</div>'
-		'<div class="board-card-priority">LOW</div>'
-		'<div class="board-card-points">–</div>'
-		'<div class="board-card-assignee">None</div>'
-		'<div class="board-card-tags"></div>'
-		'<select class="board-card-move-select"><option value="">Move to board…</option></select>'
-		'<button type="button" class="board-card-delete-btn">Delete</button>'
-		'<button type="button" class="board-card-archive-btn">Archive</button>'
-		'<form class="board-card-comment-form"><textarea class="board-card-comment-input"></textarea></form>'
-		'<div class="board-card-comment-signin-hint d-none"></div>'
-		'<div class="board-card-comments-list"></div>'
-		"</div></div>"
-	)
+    return (
+        f'<div class="card board-card" draggable="true" data-task-ref="{ref}" '
+        f'data-task-status="{status}" data-task-points="{points}">'
+        '<span class="board-card-icon" data-icon="cloud"></span>'
+        '<div class="board-card-title-line">'
+        '<span class="board-card-task-id"></span>'
+        f'<span class="board-card-title">{title}</span></div>'
+        '<div class="board-card-description-preview"></div>'
+        '<div class="board-card-meta">'
+        '<span class="badge">LOW</span>'
+        '<span class="board-card-points-badge d-none"></span>'
+        '<span class="board-card-assignee-compact">None</span>'
+        '<span class="ms-auto board-card-comments-count-meta">0 comments</span>'
+        "</div>"
+        '<div class="board-card-details d-none">'
+        '<div class="board-card-task-ref"></div>'
+        f'<div class="board-card-description-full">{description}</div>'
+        '<div class="board-card-due-date">None</div>'
+        '<div class="board-card-updated-at">None</div>'
+        '<div class="board-card-status">TODO</div>'
+        '<div class="board-card-priority">LOW</div>'
+        '<div class="board-card-points">–</div>'
+        '<div class="board-card-assignee">None</div>'
+        '<div class="board-card-tags"></div>'
+        '<select class="board-card-move-select"><option value="">Move to board…</option></select>'
+        '<button type="button" class="board-card-delete-btn">Delete</button>'
+        '<button type="button" class="board-card-archive-btn">Archive</button>'
+        '<form class="board-card-comment-form"><textarea class="board-card-comment-input"></textarea></form>'
+        '<div class="board-card-comment-signin-hint d-none"></div>'
+        '<div class="board-card-comments-list"></div>'
+        "</div></div>"
+    )
 
 
 def column(status: str, cards: str = "") -> str:
-	return (
-		f'<div class="board-column dropzone" data-status="{status}">'
-		'<button type="button" class="board-column-add-card">'
-		'<span class="board-column-task-count">0</span></button>'
-		'<span class="board-column-points-total">0 pts</span>'
-		f'<div class="board-column-cards">{cards}</div></div>'
-	)
+    return (
+        f'<div class="board-column dropzone" data-status="{status}">'
+        '<button type="button" class="board-column-add-card">'
+        '<span class="board-column-task-count">0</span></button>'
+        '<span class="board-column-points-total">0 pts</span>'
+        f'<div class="board-column-cards">{cards}</div></div>'
+    )
 
 
 def board_shell(
-	*,
-	project: str = "demo",
-	board: str = "Tasks",
-	cards_todo: str = "",
-	cards_progress: str = "",
-	include_template: bool = True,
+    *,
+    project: str = "demo",
+    board: str = "Tasks",
+    cards_todo: str = "",
+    cards_progress: str = "",
+    include_template: bool = True,
 ) -> str:
-	template = f'<template id="board-card-template">{card_html()}</template>' if include_template else ""
-	return (
-		template
-		+ f'<div class="board-columns" data-project="{project}" data-board="{board}">'
-		+ column("TODO", cards_todo)
-		+ column("IN_PROGRESS", cards_progress)
-		+ "</div>"
-		+ '<select id="board-board-select"></select>'
-		+ '<button type="button" id="board-new-board-btn">+ Board</button>'
-		+ '<button type="button" id="board-new-sprint-btn">+ Sprint</button>'
-		+ '<form id="board-new-form" class="d-none"><input id="board-new-name">'
-		+ '<span id="board-new-error" class="d-none"></span>'
-		+ '<button type="submit">Create</button></form>'
-		+ '<button type="button" id="board-new-cancel">Cancel</button>'
-	)
+    template = f'<template id="board-card-template">{card_html()}</template>' if include_template else ""
+    return (
+        template
+        + f'<div class="board-columns" data-project="{project}" data-board="{board}">'
+        + column("TODO", cards_todo)
+        + column("IN_PROGRESS", cards_progress)
+        + "</div>"
+        + '<select id="board-board-select"></select>'
+        + '<button type="button" id="board-new-board-btn">+ Board</button>'
+        + '<button type="button" id="board-new-sprint-btn">+ Sprint</button>'
+        + '<form id="board-new-form" class="d-none"><input id="board-new-name">'
+        + '<span id="board-new-error" class="d-none"></span>'
+        + '<button type="submit">Create</button></form>'
+        + '<button type="button" id="board-new-cancel">Cancel</button>'
+    )
 
 
 def requested(js: JsPage, fragment: str) -> bool:
-	return any(fragment in str(item["url"]) for item in js.requests())
+    return any(fragment in str(item["url"]) for item in js.requests())
 
 
 def posted_json(js: JsPage, fragment: str) -> Any:
-	for item in reversed(js.requests()):
-		if fragment in str(item["url"]) and item.get("body"):
-			return json.loads(str(item["body"]))
-	return None
+    for item in reversed(js.requests()):
+        if fragment in str(item["url"]) and item.get("body"):
+            return json.loads(str(item["body"]))
+    return None
diff --git a/pygitweb/src/pygitweb/static_tests/test_addproject.py b/pygitweb/src/pygitweb/static_tests/test_addproject.py
index d97dbd2..a6a6b54 100644
--- a/pygitweb/src/pygitweb/static_tests/test_addproject.py
+++ b/pygitweb/src/pygitweb/static_tests/test_addproject.py
@@ -4,164 +4,164 @@ from pygitweb.static_tests.js_runtime import JsPage
 from pygitweb.static_tests.pages import requested
 
 _FORM = (
-	'<input id="project_name">'
-	'<input type="radio" name="project_source_type" value="empty" checked>'
-	'<input type="radio" name="project_source_type" value="zip">'
-	'<div id="tab-empty-repo"></div>'
-	'<div id="tab-upload-zip" class="d-none"></div>'
-	'<input id="pull_from_remote" value="git://example">'
-	'<input id="repo_zip" value="old.zip">'
-	'<div id="dropzone">'
-	"<span data-dropzone-placeholder>drop</span>"
-	'<span data-dropzone-filename class="d-none"></span>'
-	"</div>"
+    '<input id="project_name">'
+    '<input type="radio" name="project_source_type" value="empty" checked>'
+    '<input type="radio" name="project_source_type" value="zip">'
+    '<div id="tab-empty-repo"></div>'
+    '<div id="tab-upload-zip" class="d-none"></div>'
+    '<input id="pull_from_remote" value="git://example">'
+    '<input id="repo_zip" value="old.zip">'
+    '<div id="dropzone">'
+    "<span data-dropzone-placeholder>drop</span>"
+    '<span data-dropzone-filename class="d-none"></span>'
+    "</div>"
 )
 
 
 def test_empty_document_skips_every_widget(js: JsPage) -> None:
-	js.set_html("<div></div>")
-	js.load("addproject.js")
-	assert js.count("#project_name") == 0
+    js.set_html("<div></div>")
+    js.load("addproject.js")
+    assert js.count("#project_name") == 0
 
 
 def test_blank_name_clears_the_border(js: JsPage) -> None:
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.dispatch("#project_name", "blur")
-	assert "border" not in js.classes("#project_name")
-	assert requested(js, "projectnamevalid") is False
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.dispatch("#project_name", "blur")
+    assert "border" not in js.classes("#project_name")
+    assert requested(js, "projectnamevalid") is False
 
 
 def test_blur_marks_an_accepted_name(js: JsPage) -> None:
-	js.route("projectnamevalid", status=200)
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.set_prop("#project_name", "value", " demo ")
-	js.dispatch("#project_name", "blur")
-	assert "border-success" in js.classes("#project_name")
-	assert any("name=demo" in str(item["url"]) for item in js.requests())
+    js.route("projectnamevalid", status=200)
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.set_prop("#project_name", "value", " demo ")
+    js.dispatch("#project_name", "blur")
+    assert "border-success" in js.classes("#project_name")
+    assert any("name=demo" in str(item["url"]) for item in js.requests())
 
 
 def test_blur_marks_a_rejected_name(js: JsPage) -> None:
-	js.route("projectnamevalid", status=404)
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.set_prop("#project_name", "value", "taken")
-	js.dispatch("#project_name", "blur")
-	assert "border-danger" in js.classes("#project_name")
+    js.route("projectnamevalid", status=404)
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.set_prop("#project_name", "value", "taken")
+    js.dispatch("#project_name", "blur")
+    assert "border-danger" in js.classes("#project_name")
 
 
 def test_name_check_clears_the_border_when_fetch_fails(js: JsPage) -> None:
-	js.route("projectnamevalid", error="offline")
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.set_prop("#project_name", "value", "demo")
-	js.dispatch("#project_name", "blur")
-	assert "border" not in js.classes("#project_name")
+    js.route("projectnamevalid", error="offline")
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.set_prop("#project_name", "value", "demo")
+    js.dispatch("#project_name", "blur")
+    assert "border" not in js.classes("#project_name")
 
 
 def test_input_checks_the_name_after_the_debounce(js: JsPage) -> None:
-	js.route("projectnamevalid", status=200)
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.set_prop("#project_name", "value", "demo")
-	js.dispatch("#project_name", "input")
-	assert requested(js, "projectnamevalid") is False
-	js.tick()
-	assert requested(js, "projectnamevalid") is True
+    js.route("projectnamevalid", status=200)
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.set_prop("#project_name", "value", "demo")
+    js.dispatch("#project_name", "input")
+    assert requested(js, "projectnamevalid") is False
+    js.tick()
+    assert requested(js, "projectnamevalid") is True
 
 
 def test_clearing_the_name_cancels_the_debounce(js: JsPage) -> None:
-	js.route("projectnamevalid", status=200)
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.set_prop("#project_name", "value", "demo")
-	js.dispatch("#project_name", "input")
-	js.set_prop("#project_name", "value", "  ")
-	js.dispatch("#project_name", "input")
-	js.tick()
-	assert requested(js, "projectnamevalid") is False
-	assert "border" not in js.classes("#project_name")
+    js.route("projectnamevalid", status=200)
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.set_prop("#project_name", "value", "demo")
+    js.dispatch("#project_name", "input")
+    js.set_prop("#project_name", "value", "  ")
+    js.dispatch("#project_name", "input")
+    js.tick()
+    assert requested(js, "projectnamevalid") is False
+    assert "border" not in js.classes("#project_name")
 
 
 def test_zip_source_hides_the_empty_tab_and_clears_the_remote(js: JsPage) -> None:
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	assert "d-none" not in js.classes("#tab-empty-repo")
-	js.set_prop('input[value="empty"]', "checked", False)
-	js.set_prop('input[value="zip"]', "checked", True)
-	js.dispatch('input[value="zip"]', "change")
-	assert "d-none" in js.classes("#tab-empty-repo")
-	assert "d-none" not in js.classes("#tab-upload-zip")
-	assert js.prop("#pull_from_remote", "value") == ""
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    assert "d-none" not in js.classes("#tab-empty-repo")
+    js.set_prop('input[value="empty"]', "checked", False)
+    js.set_prop('input[value="zip"]', "checked", True)
+    js.dispatch('input[value="zip"]', "change")
+    assert "d-none" in js.classes("#tab-empty-repo")
+    assert "d-none" not in js.classes("#tab-upload-zip")
+    assert js.prop("#pull_from_remote", "value") == ""
 
 
 def test_empty_source_clears_the_zip_input(js: JsPage) -> None:
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.set_prop('input[value="empty"]', "checked", False)
-	js.set_prop('input[value="zip"]', "checked", True)
-	js.dispatch('input[value="zip"]', "change")
-	js.set_prop('input[value="zip"]', "checked", False)
-	js.set_prop('input[value="empty"]', "checked", True)
-	js.dispatch('input[value="empty"]', "change")
-	assert js.prop("#repo_zip", "value") == ""
-	assert "d-none" in js.classes("#tab-upload-zip")
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.set_prop('input[value="empty"]', "checked", False)
+    js.set_prop('input[value="zip"]', "checked", True)
+    js.dispatch('input[value="zip"]', "change")
+    js.set_prop('input[value="zip"]', "checked", False)
+    js.set_prop('input[value="empty"]', "checked", True)
+    js.dispatch('input[value="empty"]', "change")
+    assert js.prop("#repo_zip", "value") == ""
+    assert "d-none" in js.classes("#tab-upload-zip")
 
 
 def test_unchecked_radios_default_to_the_empty_source(js: JsPage) -> None:
-	html = _FORM.replace(" checked", "", 1)
-	js.set_html(html)
-	js.load("addproject.js")
-	assert "d-none" in js.classes("#tab-upload-zip")
+    html = _FORM.replace(" checked", "", 1)
+    js.set_html(html)
+    js.load("addproject.js")
+    assert "d-none" in js.classes("#tab-upload-zip")
 
 
 def test_dropzone_click_clicks_the_file_input(js: JsPage) -> None:
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.run(
-		"globalThis.__zipClicks = 0;"
-		" document.getElementById('repo_zip').addEventListener('click', function() { __zipClicks++; });"
-	)
-	js.click("#dropzone")
-	assert js.value("__zipClicks") == 1
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.run(
+        "globalThis.__zipClicks = 0;"
+        " document.getElementById('repo_zip').addEventListener('click', function() { __zipClicks++; });"
+    )
+    js.click("#dropzone")
+    assert js.value("__zipClicks") == 1
 
 
 def test_drag_highlights_the_dropzone_until_leave_or_drop(js: JsPage) -> None:
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.dispatch("#dropzone", "dragover")
-	assert "border-primary" in js.classes("#dropzone")
-	js.dispatch("#dropzone", "dragleave")
-	assert "border-primary" not in js.classes("#dropzone")
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.dispatch("#dropzone", "dragover")
+    assert "border-primary" in js.classes("#dropzone")
+    js.dispatch("#dropzone", "dragleave")
+    assert "border-primary" not in js.classes("#dropzone")
 
 
 def test_drop_accepts_only_a_zip_file(js: JsPage) -> None:
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.run(
-		"var ev = new Event('drop', {bubbles:true, cancelable:true});"
-		"ev.dataTransfer.files = [{name: 'notes.txt'}];"
-		"document.getElementById('dropzone').dispatchEvent(ev);"
-	)
-	assert "Selected:" not in js.text("[data-dropzone-filename]")
-	js.run(
-		"var ev = new Event('drop', {bubbles:true, cancelable:true});"
-		"ev.dataTransfer.files = [{name: 'Pkg.ZIP'}];"
-		"document.getElementById('dropzone').dispatchEvent(ev);"
-	)
-	assert js.text("[data-dropzone-filename]") == "Selected: Pkg.ZIP"
-	assert "d-none" in js.classes("[data-dropzone-placeholder]")
-	assert "border-primary" not in js.classes("#dropzone")
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.run(
+        "var ev = new Event('drop', {bubbles:true, cancelable:true});"
+        "ev.dataTransfer.files = [{name: 'notes.txt'}];"
+        "document.getElementById('dropzone').dispatchEvent(ev);"
+    )
+    assert "Selected:" not in js.text("[data-dropzone-filename]")
+    js.run(
+        "var ev = new Event('drop', {bubbles:true, cancelable:true});"
+        "ev.dataTransfer.files = [{name: 'Pkg.ZIP'}];"
+        "document.getElementById('dropzone').dispatchEvent(ev);"
+    )
+    assert js.text("[data-dropzone-filename]") == "Selected: Pkg.ZIP"
+    assert "d-none" in js.classes("[data-dropzone-placeholder]")
+    assert "border-primary" not in js.classes("#dropzone")
 
 
 def test_file_input_change_shows_and_clears_the_name(js: JsPage) -> None:
-	js.set_html(_FORM)
-	js.load("addproject.js")
-	js.run("document.getElementById('repo_zip').files = [{name: 'a.zip'}];")
-	js.dispatch("#repo_zip", "change")
-	assert js.text("[data-dropzone-filename]") == "Selected: a.zip"
-	js.run("document.getElementById('repo_zip').files = [];")
-	js.dispatch("#repo_zip", "change")
-	assert js.text("[data-dropzone-filename]") == ""
-	assert "d-none" not in js.classes("[data-dropzone-placeholder]")
+    js.set_html(_FORM)
+    js.load("addproject.js")
+    js.run("document.getElementById('repo_zip').files = [{name: 'a.zip'}];")
+    js.dispatch("#repo_zip", "change")
+    assert js.text("[data-dropzone-filename]") == "Selected: a.zip"
+    js.run("document.getElementById('repo_zip').files = [];")
+    js.dispatch("#repo_zip", "change")
+    assert js.text("[data-dropzone-filename]") == ""
+    assert "d-none" not in js.classes("[data-dropzone-placeholder]")
diff --git a/pygitweb/src/pygitweb/static_tests/test_blob-view.py b/pygitweb/src/pygitweb/static_tests/test_blob-view.py
index 939efc2..8e303c4 100644
--- a/pygitweb/src/pygitweb/static_tests/test_blob-view.py
+++ b/pygitweb/src/pygitweb/static_tests/test_blob-view.py
@@ -4,80 +4,80 @@ from pygitweb.static_tests.js_runtime import JsPage
 
 
 def _blob(js: JsPage, code: str, lines: str | None) -> None:
-	href = "http://127.0.0.1/blob"
-	if lines is not None:
-		href += "?lines=" + lines
-	js.set_location(href)
-	js.set_html(f'<pre class="blob-content"><code>{code}</code></pre>')
+    href = "http://127.0.0.1/blob"
+    if lines is not None:
+        href += "?lines=" + lines
+    js.set_location(href)
+    js.set_html(f'<pre class="blob-content"><code>{code}</code></pre>')
 
 
 def test_missing_code_element_is_ignored(js: JsPage) -> None:
-	js.set_location("http://127.0.0.1/blob?lines=L1")
-	js.set_html("<div></div>")
-	js.load("blob-view.js")
-	assert js.count("[data-line]") == 0
+    js.set_location("http://127.0.0.1/blob?lines=L1")
+    js.set_html("<div></div>")
+    js.load("blob-view.js")
+    assert js.count("[data-line]") == 0
 
 
 def test_no_lines_param_leaves_the_code_unwrapped(js: JsPage) -> None:
-	js.run("globalThis.hljs = { highlightElement: function(code) { code.setAttribute('data-hl', '1'); } };")
-	_blob(js, "print('hi')", None)
-	js.load("blob-view.js")
-	assert js.attr(".blob-content code", "data-hl") == "1"
-	assert js.count("[data-line]") == 0
+    js.run("globalThis.hljs = { highlightElement: function(code) { code.setAttribute('data-hl', '1'); } };")
+    _blob(js, "print('hi')", None)
+    js.load("blob-view.js")
+    assert js.attr(".blob-content code", "data-hl") == "1"
+    assert js.count("[data-line]") == 0
 
 
 def test_a_full_line_range_selects_each_line_and_scrolls(js: JsPage) -> None:
-	_blob(js, "alpha\nbeta\ngamma", "L1-L2")
-	js.load("blob-view.js")
-	assert "blob-line-selected" in js.classes('[data-line="1"]')
-	assert "blob-line-selected" in js.classes('[data-line="2"]')
-	assert "blob-line-selected" not in js.classes('[data-line="3"]')
-	assert js.value("__lastScrolled && __lastScrolled.tagName") == "SPAN"
+    _blob(js, "alpha\nbeta\ngamma", "L1-L2")
+    js.load("blob-view.js")
+    assert "blob-line-selected" in js.classes('[data-line="1"]')
+    assert "blob-line-selected" in js.classes('[data-line="2"]')
+    assert "blob-line-selected" not in js.classes('[data-line="3"]')
+    assert js.value("__lastScrolled && __lastScrolled.tagName") == "SPAN"
 
 
 def test_a_character_range_wraps_a_selection_span(js: JsPage) -> None:
-	_blob(js, "print('hi')", "L1C2-L1C5")
-	js.load("blob-view.js")
-	assert js.text(".blob-selection") == "rint"
-	assert js.count(".blob-line-selected") == 0
+    _blob(js, "print('hi')", "L1C2-L1C5")
+    js.load("blob-view.js")
+    assert js.text(".blob-selection") == "rint"
+    assert js.count(".blob-line-selected") == 0
 
 
 def test_a_zero_width_range_does_not_wrap(js: JsPage) -> None:
-	_blob(js, "print('hi')", "L1C5-L1C4")
-	js.load("blob-view.js")
-	assert js.count(".blob-selection") == 0
-	assert js.count(".blob-line-selected") == 0
+    _blob(js, "print('hi')", "L1C5-L1C4")
+    js.load("blob-view.js")
+    assert js.count(".blob-selection") == 0
+    assert js.count(".blob-line-selected") == 0
 
 
 def test_a_missing_line_is_skipped(js: JsPage) -> None:
-	_blob(js, "only", "L9")
-	js.load("blob-view.js")
-	assert js.count(".blob-line-selected") == 0
-	assert js.value("__lastScrolled") is None
+    _blob(js, "only", "L9")
+    js.load("blob-view.js")
+    assert js.count(".blob-line-selected") == 0
+    assert js.value("__lastScrolled") is None
 
 
 def test_highlight_elements_are_kept_inside_line_spans(js: JsPage) -> None:
-	js.run(
-		"globalThis.hljs = { highlightElement: function(code) {"
-		" var text = code.textContent; code.textContent = '';"
-		" var span = document.createElement('span'); span.className = 'hljs-built_in';"
-		" span.textContent = text; code.appendChild(span); } };"
-	)
-	_blob(js, "print('hi')", "L1")
-	js.load("blob-view.js")
-	assert js.count('[data-line="1"] .hljs-built_in') == 1
-	assert "blob-line-selected" in js.classes('[data-line="1"]')
+    js.run(
+        "globalThis.hljs = { highlightElement: function(code) {"
+        " var text = code.textContent; code.textContent = '';"
+        " var span = document.createElement('span'); span.className = 'hljs-built_in';"
+        " span.textContent = text; code.appendChild(span); } };"
+    )
+    _blob(js, "print('hi')", "L1")
+    js.load("blob-view.js")
+    assert js.count('[data-line="1"] .hljs-built_in') == 1
+    assert "blob-line-selected" in js.classes('[data-line="1"]')
 
 
 def test_a_range_across_nodes_is_ignored_when_surround_fails(js: JsPage) -> None:
-	_blob(js, "ab<span>XX</span>cd", "L1C1-L1C6")
-	js.load("blob-view.js")
-	assert js.count(".blob-selection") == 0
-	assert js.count("[data-line]") == 1
+    _blob(js, "ab<span>XX</span>cd", "L1C1-L1C6")
+    js.load("blob-view.js")
+    assert js.count(".blob-selection") == 0
+    assert js.count("[data-line]") == 1
 
 
 def test_several_ranges_in_one_param_are_applied(js: JsPage) -> None:
-	_blob(js, "one\ntwo", "L1,L2C1-L2C2")
-	js.load("blob-view.js")
-	assert "blob-line-selected" in js.classes('[data-line="1"]')
-	assert js.text(".blob-selection") == "tw"
+    _blob(js, "one\ntwo", "L1,L2C1-L2C2")
+    js.load("blob-view.js")
+    assert "blob-line-selected" in js.classes('[data-line="1"]')
+    assert js.text(".blob-selection") == "tw"
diff --git a/pygitweb/src/pygitweb/static_tests/test_board-cards.py b/pygitweb/src/pygitweb/static_tests/test_board-cards.py
index 0b285e8..33d90d9 100644
--- a/pygitweb/src/pygitweb/static_tests/test_board-cards.py
+++ b/pygitweb/src/pygitweb/static_tests/test_board-cards.py
@@ -4,192 +4,192 @@ from pygitweb.static_tests.js_runtime import JsPage
 from pygitweb.static_tests.pages import board_shell, card_html, posted_json, requested
 
 _TASKS = [
-	{
-		"ref": "refs/tags/tasks/task_abcd",
-		"id": "abcd",
-		"title": "Ship",
-		"description": "**bold**",
-		"status": "TODO",
-		"priority": "CRITICAL",
-		"points": 2,
-		"assignee": "Ada",
-		"comments_count": 1,
-		"due_date": "2020-01-02",
-		"updated_at": "2020-01-03",
-		"tags": "ui",
-	},
-	{
-		"ref": "refs/tags/tasks/task_eeee",
-		"title": "",
-		"status": "IN_PROGRESS",
-		"priority": "HIGH",
-		"points": 1,
-		"comments_count": 0,
-	},
-	{"ref": "refs/tags/tasks/task_ffff", "status": "DONE", "priority": "LOW", "points": 0},
-	{"status": "TODO", "title": "Zero", "points": 0, "priority": "LOW"},
+    {
+        "ref": "refs/tags/tasks/task_abcd",
+        "id": "abcd",
+        "title": "Ship",
+        "description": "**bold**",
+        "status": "TODO",
+        "priority": "CRITICAL",
+        "points": 2,
+        "assignee": "Ada",
+        "comments_count": 1,
+        "due_date": "2020-01-02",
+        "updated_at": "2020-01-03",
+        "tags": "ui",
+    },
+    {
+        "ref": "refs/tags/tasks/task_eeee",
+        "title": "",
+        "status": "IN_PROGRESS",
+        "priority": "HIGH",
+        "points": 1,
+        "comments_count": 0,
+    },
+    {"ref": "refs/tags/tasks/task_ffff", "status": "DONE", "priority": "LOW", "points": 0},
+    {"status": "TODO", "title": "Zero", "points": 0, "priority": "LOW"},
 ]
 
 
 def test_preload_and_assign_icons(js: JsPage) -> None:
-	js.set_html(board_shell(cards_todo=card_html(ref="t")))
-	js.load("board-cards.js")
-	js.call("board-cards.js", "preloadIcons")
-	preloaded = js.value("__preloaded")
-	assert len(preloaded) == 6
-	assert any(src.endswith("cloud.svg") for src in preloaded)
-	js.call("board-cards.js", "assignCloudIconToAllCards")
-	assert js.attr(".board-card-icon", "data-icon") == "cloud"
+    js.set_html(board_shell(cards_todo=card_html(ref="t")))
+    js.load("board-cards.js")
+    js.call("board-cards.js", "preloadIcons")
+    preloaded = js.value("__preloaded")
+    assert len(preloaded) == 6
+    assert any(src.endswith("cloud.svg") for src in preloaded)
+    js.call("board-cards.js", "assignCloudIconToAllCards")
+    assert js.attr(".board-card-icon", "data-icon") == "cloud"
 
 
 def test_load_tasks_builds_cards_for_known_columns(js: JsPage) -> None:
-	js.route("tasks/list", body=_TASKS)
-	js.route("board-ref-manifest", body={"commits": [], "refs": []})
-	js.set_html(board_shell())
-	js.load("board-cards.js")
-	js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
-	assert js.count('.board-column[data-status="TODO"] .board-card') == 2
-	assert js.count('.board-column[data-status="IN_PROGRESS"] .board-card') == 1
-	assert js.count('.board-column[data-status="DONE"] .board-card') == 0
-	assert js.text(".board-card-title") == "Ship"
-	assert "text-danger" in js.classes(".badge")
-	assert js.text(".board-card-points-badge") == "2 pts"
-	assert js.text(".board-card-assignee-compact") == "Ada"
-	assert js.text(".board-card-comments-count-meta") == "1 comment"
-	assert js.text(".board-card-due-date") == "2020-01-02"
-	assert js.text(".board-card-points") == "2"
-	assert js.text(".board-card-tags") == "ui"
-	assert js.count(".board-card-description-full strong") == 1
-	assert js.attr(".board-card-icon", "data-icon") == "cloud-filled"
-	assert js.text(".board-column-task-count") == "2"
-	assert js.text('.board-card[data-task-points="0"] .board-card-points-badge') == "0 pts"
-	assert "text-secondary" in js.classes('.board-card[data-task-points="0"] .badge')
-	assert js.text('.board-column[data-status="IN_PROGRESS"] .board-card-title') == "Untitled"
-	assert js.text('.board-column[data-status="IN_PROGRESS"] .board-card-points-badge') == "1 pt"
-	assert "text-warning" in js.classes('.board-column[data-status="IN_PROGRESS"] .badge')
+    js.route("tasks/list", body=_TASKS)
+    js.route("board-ref-manifest", body={"commits": [], "refs": []})
+    js.set_html(board_shell())
+    js.load("board-cards.js")
+    js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
+    assert js.count('.board-column[data-status="TODO"] .board-card') == 2
+    assert js.count('.board-column[data-status="IN_PROGRESS"] .board-card') == 1
+    assert js.count('.board-column[data-status="DONE"] .board-card') == 0
+    assert js.text(".board-card-title") == "Ship"
+    assert "text-danger" in js.classes(".badge")
+    assert js.text(".board-card-points-badge") == "2 pts"
+    assert js.text(".board-card-assignee-compact") == "Ada"
+    assert js.text(".board-card-comments-count-meta") == "1 comment"
+    assert js.text(".board-card-due-date") == "2020-01-02"
+    assert js.text(".board-card-points") == "2"
+    assert js.text(".board-card-tags") == "ui"
+    assert js.count(".board-card-description-full strong") == 1
+    assert js.attr(".board-card-icon", "data-icon") == "cloud-filled"
+    assert js.text(".board-column-task-count") == "2"
+    assert js.text('.board-card[data-task-points="0"] .board-card-points-badge') == "0 pts"
+    assert "text-secondary" in js.classes('.board-card[data-task-points="0"] .badge')
+    assert js.text('.board-column[data-status="IN_PROGRESS"] .board-card-title') == "Untitled"
+    assert js.text('.board-column[data-status="IN_PROGRESS"] .board-card-points-badge') == "1 pt"
+    assert "text-warning" in js.classes('.board-column[data-status="IN_PROGRESS"] .badge')
 
 
 def test_load_tasks_keeps_cards_when_the_response_is_unusable(js: JsPage) -> None:
-	js.set_html('<div class="board-columns"></div>')
-	js.set_location("http://127.0.0.1/")
-	js.load("board-cards.js")
-	js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
-	assert requested(js, "tasks/list") is False
-	js.set_location("http://127.0.0.1/project/demo/board/")
-	js.set_html(board_shell(cards_todo=card_html(ref="keep", title="Keep")))
-	js.route("tasks/list", status=500)
-	js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
-	assert js.text(".board-card-title") == "Keep"
-	js.route("tasks/list", body={"nope": True})
-	js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
-	assert js.text(".board-card-title") == "Keep"
-	js.route("tasks/list", error="offline")
-	js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
-	assert js.text(".board-card-title") == "Keep"
+    js.set_html('<div class="board-columns"></div>')
+    js.set_location("http://127.0.0.1/")
+    js.load("board-cards.js")
+    js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
+    assert requested(js, "tasks/list") is False
+    js.set_location("http://127.0.0.1/project/demo/board/")
+    js.set_html(board_shell(cards_todo=card_html(ref="keep", title="Keep")))
+    js.route("tasks/list", status=500)
+    js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
+    assert js.text(".board-card-title") == "Keep"
+    js.route("tasks/list", body={"nope": True})
+    js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
+    assert js.text(".board-card-title") == "Keep"
+    js.route("tasks/list", error="offline")
+    js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
+    assert js.text(".board-card-title") == "Keep"
 
 
 def test_load_tasks_without_a_template_clears_the_column(js: JsPage) -> None:
-	js.route("tasks/list", body=[{"ref": "t", "title": "X", "status": "TODO"}])
-	js.route("board-ref-manifest", body={"commits": [], "refs": []})
-	js.set_html(board_shell(include_template=False, cards_todo=card_html(ref="old", title="Old")))
-	js.load("board-cards.js")
-	js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
-	assert js.count(".board-card") == 0
+    js.route("tasks/list", body=[{"ref": "t", "title": "X", "status": "TODO"}])
+    js.route("board-ref-manifest", body={"commits": [], "refs": []})
+    js.set_html(board_shell(include_template=False, cards_todo=card_html(ref="old", title="Old")))
+    js.load("board-cards.js")
+    js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
+    assert js.count(".board-card") == 0
 
 
 def test_fetch_and_reorder_follow_the_task_list(js: JsPage) -> None:
-	js.set_html('<div class="board-columns"></div>')
-	js.set_location("http://127.0.0.1/")
-	js.load("board-cards.js")
-	assert js.settle("__loaded['board-cards.js'].fetchBoardTasksJson()") is None
-	js.set_html(
-		board_shell(
-			cards_todo=card_html(ref="a", title="A") + card_html(ref="b", title="B"),
-			cards_progress=card_html(ref="c", title="C"),
-		)
-	)
-	js.route("tasks/list", status=404)
-	assert js.settle("__loaded['board-cards.js'].fetchBoardTasksJson()") is None
-	js.route(
-		"tasks/list",
-		body=[{"ref": "b", "status": "TODO"}, {"ref": "a", "status": "TODO"}, {"ref": "c", "status": "IN_PROGRESS"}],
-	)
-	assert js.settle("__loaded['board-cards.js'].fetchBoardTasksJson()")[0]["ref"] == "b"
-	js.run("__loaded['board-cards.js'].reorderBoardDomToTaskList(null)")
-	js.run(
-		"__loaded['board-cards.js'].reorderBoardDomToTaskList(["
-		"{ref:'b', status:'TODO'}, {status:'TODO'}, {ref:'missing', status:'TODO'}, {ref:'a', status:'IN_PROGRESS'}"
-		"])"
-	)
-	titles = js.texts('.board-column[data-status="TODO"] .board-card-title')
-	assert titles == ["B"]
-	progress = js.texts('.board-column[data-status="IN_PROGRESS"] .board-card-title')
-	assert progress == ["C", "A"]
+    js.set_html('<div class="board-columns"></div>')
+    js.set_location("http://127.0.0.1/")
+    js.load("board-cards.js")
+    assert js.settle("__loaded['board-cards.js'].fetchBoardTasksJson()") is None
+    js.set_html(
+        board_shell(
+            cards_todo=card_html(ref="a", title="A") + card_html(ref="b", title="B"),
+            cards_progress=card_html(ref="c", title="C"),
+        )
+    )
+    js.route("tasks/list", status=404)
+    assert js.settle("__loaded['board-cards.js'].fetchBoardTasksJson()") is None
+    js.route(
+        "tasks/list",
+        body=[{"ref": "b", "status": "TODO"}, {"ref": "a", "status": "TODO"}, {"ref": "c", "status": "IN_PROGRESS"}],
+    )
+    assert js.settle("__loaded['board-cards.js'].fetchBoardTasksJson()")[0]["ref"] == "b"
+    js.run("__loaded['board-cards.js'].reorderBoardDomToTaskList(null)")
+    js.run(
+        "__loaded['board-cards.js'].reorderBoardDomToTaskList(["
+        "{ref:'b', status:'TODO'}, {status:'TODO'}, {ref:'missing', status:'TODO'}, {ref:'a', status:'IN_PROGRESS'}"
+        "])"
+    )
+    titles = js.texts('.board-column[data-status="TODO"] .board-card-title')
+    assert titles == ["B"]
+    progress = js.texts('.board-column[data-status="IN_PROGRESS"] .board-card-title')
+    assert progress == ["C", "A"]
 
 
 def test_add_card_posts_and_marks_success_or_failure(js: JsPage) -> None:
-	js.route("tasks/create", body={"ref": "refs/tags/tasks/task_abcd"})
-	js.set_html(board_shell())
-	js.load("board-cards.js")
-	js.call("board-cards.js", "bindAddCardButtons")
-	js.click(".board-column-task-count")
-	assert js.count(".board-card") == 0
-	js.click(".board-column-add-card")
-	assert js.text(".board-card-title") == "New task"
-	assert js.attr(".board-card-icon", "data-icon") == "cloud-filled"
-	assert js.text(".board-card-task-id") == "abcd"
-	assert js.text(".board-column-task-count") == "1"
-	body_url = next(str(item["url"]) for item in js.requests() if "tasks/create" in str(item["url"]))
-	assert "title=New+task" in body_url or "title=New%20task" in body_url
-	assert "status=TODO" in body_url
-	js.route("tasks/create", status=500)
-	js.click('.board-column[data-status="IN_PROGRESS"] .board-column-add-card')
-	assert js.attr('.board-column[data-status="IN_PROGRESS"] .board-card-icon', "data-icon") == "cloud-exclamation"
-	js.route("tasks/create", status=302)
-	js.click(".board-column-add-card")
-	pending = js.attrs('.board-column[data-status="TODO"] .board-card-icon', "data-icon")
-	assert pending[-1] == "cloud-up"
-	js.route("tasks/create", error="offline")
-	js.click(".board-column-add-card")
-	icons = js.attrs('.board-column[data-status="TODO"] .board-card-icon', "data-icon")
-	assert icons[-1] == "cloud-exclamation"
+    js.route("tasks/create", body={"ref": "refs/tags/tasks/task_abcd"})
+    js.set_html(board_shell())
+    js.load("board-cards.js")
+    js.call("board-cards.js", "bindAddCardButtons")
+    js.click(".board-column-task-count")
+    assert js.count(".board-card") == 0
+    js.click(".board-column-add-card")
+    assert js.text(".board-card-title") == "New task"
+    assert js.attr(".board-card-icon", "data-icon") == "cloud-filled"
+    assert js.text(".board-card-task-id") == "abcd"
+    assert js.text(".board-column-task-count") == "1"
+    body_url = next(str(item["url"]) for item in js.requests() if "tasks/create" in str(item["url"]))
+    assert "title=New+task" in body_url or "title=New%20task" in body_url
+    assert "status=TODO" in body_url
+    js.route("tasks/create", status=500)
+    js.click('.board-column[data-status="IN_PROGRESS"] .board-column-add-card')
+    assert js.attr('.board-column[data-status="IN_PROGRESS"] .board-card-icon', "data-icon") == "cloud-exclamation"
+    js.route("tasks/create", status=302)
+    js.click(".board-column-add-card")
+    pending = js.attrs('.board-column[data-status="TODO"] .board-card-icon', "data-icon")
+    assert pending[-1] == "cloud-up"
+    js.route("tasks/create", error="offline")
+    js.click(".board-column-add-card")
+    icons = js.attrs('.board-column[data-status="TODO"] .board-card-icon', "data-icon")
+    assert icons[-1] == "cloud-exclamation"
 
 
 def test_add_card_does_nothing_without_a_project_or_template(js: JsPage) -> None:
-	js.set_html(board_shell(project="", include_template=True))
-	js.set_location("http://127.0.0.1/")
-	js.load("board-cards.js")
-	js.call("board-cards.js", "bindAddCardButtons")
-	js.click(".board-column-add-card")
-	assert js.count(".board-card") == 0
-	js.set_html(board_shell(include_template=False))
-	js.call("board-cards.js", "bindAddCardButtons")
-	js.click(".board-column-add-card")
-	assert js.count(".board-card") == 0
+    js.set_html(board_shell(project="", include_template=True))
+    js.set_location("http://127.0.0.1/")
+    js.load("board-cards.js")
+    js.call("board-cards.js", "bindAddCardButtons")
+    js.click(".board-column-add-card")
+    assert js.count(".board-card") == 0
+    js.set_html(board_shell(include_template=False))
+    js.call("board-cards.js", "bindAddCardButtons")
+    js.click(".board-column-add-card")
+    assert js.count(".board-card") == 0
 
 
 def test_compact_assignee_edit_commits_or_restores(js: JsPage) -> None:
-	js.route("tasks/list", body=[{"ref": "t1", "title": "Ship", "status": "TODO", "assignee": "Ada"}])
-	js.route("board-ref-manifest", body={"commits": [], "refs": []})
-	js.route("tasks/update", status=200)
-	js.set_html(board_shell())
-	js.load("board-cards.js")
-	js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
-	js.click(".board-card-assignee-compact")
-	assert js.count(".board-card-assignee-compact-edit") == 1
-	js.dispatch(".board-card-assignee-compact-edit", "mousedown")
-	js.set_prop(".board-card-assignee-compact-edit", "value", "Bea")
-	js.dispatch(".board-card-assignee-compact-edit", "keydown", key="Enter")
-	assert js.text(".board-card-assignee-compact") == "Bea"
-	assert js.text(".board-card-assignee") == "Bea"
-	assert posted_json(js, "tasks/update")["assignee"] == "Bea"
-	assert js.prop(".board-card", "draggable") is True
-	js.click(".board-card-assignee-compact")
-	js.set_prop(".board-card-assignee-compact-edit", "value", "nope")
-	js.dispatch(".board-card-assignee-compact-edit", "keydown", key="Escape")
-	assert js.text(".board-card-assignee-compact") == "Bea"
-	js.click(".board-card-assignee-compact")
-	js.set_prop(".board-card-assignee-compact-edit", "value", "  ")
-	js.dispatch(".board-card-assignee-compact-edit", "blur")
-	assert js.text(".board-card-assignee-compact") == "None"
-	assert posted_json(js, "tasks/update")["assignee"] is None
+    js.route("tasks/list", body=[{"ref": "t1", "title": "Ship", "status": "TODO", "assignee": "Ada"}])
+    js.route("board-ref-manifest", body={"commits": [], "refs": []})
+    js.route("tasks/update", status=200)
+    js.set_html(board_shell())
+    js.load("board-cards.js")
+    js.settle("__loaded['board-cards.js'].loadTasksForBoard()")
+    js.click(".board-card-assignee-compact")
+    assert js.count(".board-card-assignee-compact-edit") == 1
+    js.dispatch(".board-card-assignee-compact-edit", "mousedown")
+    js.set_prop(".board-card-assignee-compact-edit", "value", "Bea")
+    js.dispatch(".board-card-assignee-compact-edit", "keydown", key="Enter")
+    assert js.text(".board-card-assignee-compact") == "Bea"
+    assert js.text(".board-card-assignee") == "Bea"
+    assert posted_json(js, "tasks/update")["assignee"] == "Bea"
+    assert js.prop(".board-card", "draggable") is True
+    js.click(".board-card-assignee-compact")
+    js.set_prop(".board-card-assignee-compact-edit", "value", "nope")
+    js.dispatch(".board-card-assignee-compact-edit", "keydown", key="Escape")
+    assert js.text(".board-card-assignee-compact") == "Bea"
+    js.click(".board-card-assignee-compact")
+    js.set_prop(".board-card-assignee-compact-edit", "value", "  ")
+    js.dispatch(".board-card-assignee-compact-edit", "blur")
+    assert js.text(".board-card-assignee-compact") == "None"
+    assert posted_json(js, "tasks/update")["assignee"] is None
diff --git a/pygitweb/src/pygitweb/static_tests/test_board-comments.py b/pygitweb/src/pygitweb/static_tests/test_board-comments.py
index 6f3d977..5b36a79 100644
--- a/pygitweb/src/pygitweb/static_tests/test_board-comments.py
+++ b/pygitweb/src/pygitweb/static_tests/test_board-comments.py
@@ -5,128 +5,128 @@ from pygitweb.static_tests.pages import board_shell, card_html, requested
 
 
 def _card(js: JsPage) -> None:
-	js.set_html(board_shell(cards_todo=card_html(ref="refs/tags/tasks/task_abcd", title="Ship")))
-	js.load("board-comments.js")
+    js.set_html(board_shell(cards_todo=card_html(ref="refs/tags/tasks/task_abcd", title="Ship")))
+    js.load("board-comments.js")
 
 
 def test_comment_labels_and_counts(js: JsPage) -> None:
-	_card(js)
-	assert js.call("board-comments.js", "commentCountLabel", 0) == "0 comments"
-	assert js.call("board-comments.js", "commentCountLabel", 1) == "1 comment"
-	assert js.call("board-comments.js", "commentCountLabel", 2) == "2 comments"
-	js.run("__loaded['board-comments.js'].updateCardCommentCount(document.querySelector('.board-card'), 1)")
-	assert js.text(".board-card-comments-count-meta") == "1 comment"
-	js.set_html("<div class='board-card'></div>")
-	js.run("__loaded['board-comments.js'].updateCardCommentCount(document.querySelector('.board-card'), 3)")
-	assert js.count(".board-card-comments-count-meta") == 0
+    _card(js)
+    assert js.call("board-comments.js", "commentCountLabel", 0) == "0 comments"
+    assert js.call("board-comments.js", "commentCountLabel", 1) == "1 comment"
+    assert js.call("board-comments.js", "commentCountLabel", 2) == "2 comments"
+    js.run("__loaded['board-comments.js'].updateCardCommentCount(document.querySelector('.board-card'), 1)")
+    assert js.text(".board-card-comments-count-meta") == "1 comment"
+    js.set_html("<div class='board-card'></div>")
+    js.run("__loaded['board-comments.js'].updateCardCommentCount(document.querySelector('.board-card'), 3)")
+    assert js.count(".board-card-comments-count-meta") == 0
 
 
 def test_comments_render_avatars_markdown_and_initials(js: JsPage) -> None:
-	js.route(
-		"comments/list",
-		body=[
-			{"author": "Ada Lovelace", "content": "**hi**", "gravatar_url": "http://img/a"},
-			{"author": "sam", "content": "yo"},
-			{"content": ""},
-		],
-	)
-	_card(js)
-	comments = js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))")
-	assert len(comments) == 3
-	assert js.text(".board-card-comments-count-meta") == "3 comments"
-	assert js.count(".board-card-comment-item") == 3
-	assert js.count("#md strong") == 0
-	assert js.count(".board-card-comment-content strong") == 1
-	assert js.prop(".board-card-comment-avatar img", "src") == "http://img/a"
-	js.run("document.querySelector('.board-card-comment-avatar img').onerror()")
-	assert js.text(".board-card-comment-avatar-fallback") == "AL"
-	assert js.text(".board-card-comment-author") == "Ada Lovelace"
-	fallbacks = js.texts(".board-card-comment-avatar-fallback")
-	assert fallbacks == ["AL", "S", "?"]
+    js.route(
+        "comments/list",
+        body=[
+            {"author": "Ada Lovelace", "content": "**hi**", "gravatar_url": "http://img/a"},
+            {"author": "sam", "content": "yo"},
+            {"content": ""},
+        ],
+    )
+    _card(js)
+    comments = js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))")
+    assert len(comments) == 3
+    assert js.text(".board-card-comments-count-meta") == "3 comments"
+    assert js.count(".board-card-comment-item") == 3
+    assert js.count("#md strong") == 0
+    assert js.count(".board-card-comment-content strong") == 1
+    assert js.prop(".board-card-comment-avatar img", "src") == "http://img/a"
+    js.run("document.querySelector('.board-card-comment-avatar img').onerror()")
+    assert js.text(".board-card-comment-avatar-fallback") == "AL"
+    assert js.text(".board-card-comment-author") == "Ada Lovelace"
+    fallbacks = js.texts(".board-card-comment-avatar-fallback")
+    assert fallbacks == ["AL", "S", "?"]
 
 
 def test_comment_load_tolerates_empty_and_failed_responses(js: JsPage) -> None:
-	_card(js)
-	js.route("comments/list", status=404)
-	assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") == []
-	js.route("comments/list", status=500)
-	assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") == []
-	js.route("comments/list", body={"nope": True})
-	assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") == []
-	js.route("comments/list", error="offline")
-	assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") == []
-	js.set_html('<div class="board-columns" data-project="demo"><div class="board-card"></div></div>')
-	assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") is None
-	js.set_html('<div class="board-columns"><div class="board-card" data-task-ref="t"></div></div>')
-	js.set_location("http://127.0.0.1/")
-	assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") is None
+    _card(js)
+    js.route("comments/list", status=404)
+    assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") == []
+    js.route("comments/list", status=500)
+    assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") == []
+    js.route("comments/list", body={"nope": True})
+    assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") == []
+    js.route("comments/list", error="offline")
+    assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") == []
+    js.set_html('<div class="board-columns" data-project="demo"><div class="board-card"></div></div>')
+    assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") is None
+    js.set_html('<div class="board-columns"><div class="board-card" data-task-ref="t"></div></div>')
+    js.set_location("http://127.0.0.1/")
+    assert js.settle("__loaded['board-comments.js'].loadCommentsForCard(document.querySelector('.board-card'))") is None
 
 
 def test_comment_form_hides_until_a_user_is_signed_in(js: JsPage) -> None:
-	js.route("/auth/status", body={"auth_enabled": True})
-	_card(js)
-	js.settle("__loaded['board-comments.js'].loadBoardAuthStatus()")
-	js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
-	assert "d-none" in js.classes(".board-card-comment-form")
-	assert "d-none" not in js.classes(".board-card-comment-signin-hint")
-	js.route("/auth/status", body={"auth_enabled": True, "username": "ada"})
-	js.settle("__loaded['board-comments.js'].loadBoardAuthStatus()")
-	js.set_html(board_shell(cards_todo=card_html(ref="refs/tags/tasks/task_abcd")))
-	js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
-	assert "d-none" not in js.classes(".board-card-comment-form")
+    js.route("/auth/status", body={"auth_enabled": True})
+    _card(js)
+    js.settle("__loaded['board-comments.js'].loadBoardAuthStatus()")
+    js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
+    assert "d-none" in js.classes(".board-card-comment-form")
+    assert "d-none" not in js.classes(".board-card-comment-signin-hint")
+    js.route("/auth/status", body={"auth_enabled": True, "username": "ada"})
+    js.settle("__loaded['board-comments.js'].loadBoardAuthStatus()")
+    js.set_html(board_shell(cards_todo=card_html(ref="refs/tags/tasks/task_abcd")))
+    js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
+    assert "d-none" not in js.classes(".board-card-comment-form")
 
 
 def test_auth_status_failure_leaves_comments_open(js: JsPage) -> None:
-	js.route("/auth/status", status=500)
-	_card(js)
-	js.settle("__loaded['board-comments.js'].loadBoardAuthStatus()")
-	js.route("/auth/status", error="offline")
-	js.settle("__loaded['board-comments.js'].loadBoardAuthStatus()")
-	js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
-	assert "d-none" not in js.classes(".board-card-comment-form")
+    js.route("/auth/status", status=500)
+    _card(js)
+    js.settle("__loaded['board-comments.js'].loadBoardAuthStatus()")
+    js.route("/auth/status", error="offline")
+    js.settle("__loaded['board-comments.js'].loadBoardAuthStatus()")
+    js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
+    assert "d-none" not in js.classes(".board-card-comment-form")
 
 
 def test_submitting_a_comment_posts_and_reloads(js: JsPage) -> None:
-	js.route("comments/create", status=200, text="ok")
-	js.route("comments/list", body=[{"author": "ada", "content": "hello"}])
-	_card(js)
-	js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
-	js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
-	js.dispatch(".board-card-comment-form", "submit")
-	assert requested(js, "comments/create") is False
-	js.set_prop(".board-card-comment-input", "value", "  hello  ")
-	js.dispatch(".board-card-comment-form", "submit")
-	assert any("content=hello" in str(item["url"]) for item in js.requests())
-	assert js.prop(".board-card-comment-input", "value") == ""
-	assert js.text(".board-card-comment-content") == "hello"
+    js.route("comments/create", status=200, text="ok")
+    js.route("comments/list", body=[{"author": "ada", "content": "hello"}])
+    _card(js)
+    js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
+    js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
+    js.dispatch(".board-card-comment-form", "submit")
+    assert requested(js, "comments/create") is False
+    js.set_prop(".board-card-comment-input", "value", "  hello  ")
+    js.dispatch(".board-card-comment-form", "submit")
+    assert any("content=hello" in str(item["url"]) for item in js.requests())
+    assert js.prop(".board-card-comment-input", "value") == ""
+    assert js.text(".board-card-comment-content") == "hello"
 
 
 def test_comment_post_errors_are_shown(js: JsPage) -> None:
-	_card(js)
-	js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
-	js.set_prop(".board-card-comment-input", "value", "hello")
-	for status, snippet in ((401, "Not authorized"), (500, "Could not post comment")):
-		js.route("comments/create", status=status, text="no")
-		js.dispatch(".board-card-comment-form", "submit")
-		assert snippet in js.text(".board-task-error-hint")
-		js.tick()
-	js.route("comments/create", error="offline")
-	js.set_prop(".board-card-comment-input", "value", "hello")
-	js.dispatch(".board-card-comment-form", "submit")
-	assert "Could not post comment" in js.text(".board-task-error-hint")
+    _card(js)
+    js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
+    js.set_prop(".board-card-comment-input", "value", "hello")
+    for status, snippet in ((401, "Not authorized"), (500, "Could not post comment")):
+        js.route("comments/create", status=status, text="no")
+        js.dispatch(".board-card-comment-form", "submit")
+        assert snippet in js.text(".board-task-error-hint")
+        js.tick()
+    js.route("comments/create", error="offline")
+    js.set_prop(".board-card-comment-input", "value", "hello")
+    js.dispatch(".board-card-comment-form", "submit")
+    assert "Could not post comment" in js.text(".board-task-error-hint")
 
 
 def test_comment_form_without_a_form_or_project_does_nothing(js: JsPage) -> None:
-	js.set_html('<div class="board-columns" data-project="demo"><div class="board-card" data-task-ref="t"></div></div>')
-	js.load("board-comments.js")
-	js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
-	js.set_html(
-		'<div class="board-columns"><div class="board-card" data-task-ref="t">'
-		'<form class="board-card-comment-form"><textarea class="board-card-comment-input"></textarea></form>'
-		'<div class="board-card-comment-signin-hint d-none"></div></div></div>'
-	)
-	js.set_location("http://127.0.0.1/")
-	js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
-	js.set_prop(".board-card-comment-input", "value", "hello")
-	js.dispatch(".board-card-comment-form", "submit")
-	assert requested(js, "comments/create") is False
+    js.set_html('<div class="board-columns" data-project="demo"><div class="board-card" data-task-ref="t"></div></div>')
+    js.load("board-comments.js")
+    js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
+    js.set_html(
+        '<div class="board-columns"><div class="board-card" data-task-ref="t">'
+        '<form class="board-card-comment-form"><textarea class="board-card-comment-input"></textarea></form>'
+        '<div class="board-card-comment-signin-hint d-none"></div></div></div>'
+    )
+    js.set_location("http://127.0.0.1/")
+    js.run("__loaded['board-comments.js'].setupCommentForm(document.querySelector('.board-card'))")
+    js.set_prop(".board-card-comment-input", "value", "hello")
+    js.dispatch(".board-card-comment-form", "submit")
+    assert requested(js, "comments/create") is False
diff --git a/pygitweb/src/pygitweb/static_tests/test_board-context.py b/pygitweb/src/pygitweb/static_tests/test_board-context.py
index c198f1b..8d3a453 100644
--- a/pygitweb/src/pygitweb/static_tests/test_board-context.py
+++ b/pygitweb/src/pygitweb/static_tests/test_board-context.py
@@ -5,139 +5,139 @@ from pygitweb.static_tests.pages import MANIFEST, board_shell, card_html, posted
 
 
 def _board(js: JsPage, html: str | None = None) -> None:
-	js.set_html(
-		html if html is not None else board_shell(cards_todo=card_html(ref="refs/tags/tasks/task_abcd", title="Ship"))
-	)
-	js.load("board-context.js")
+    js.set_html(
+        html if html is not None else board_shell(cards_todo=card_html(ref="refs/tags/tasks/task_abcd", title="Ship"))
+    )
+    js.load("board-context.js")
 
 
 def test_task_ids_drop_the_prefix_and_shorten(js: JsPage) -> None:
-	_board(js, "<div></div>")
-	assert js.call("board-context.js", "taskDisplayId", "") == ""
-	assert js.call("board-context.js", "taskDisplayId", "refs/tags/tasks/task_abcd") == "abcd"
-	assert js.call("board-context.js", "taskDisplayId", "refs/tags/tasks/custom") == "custom"
-	assert js.call("board-context.js", "taskDisplayIdShort", "") == ""
-	assert js.call("board-context.js", "taskDisplayIdShort", "ab") == "ab"
-	assert js.call("board-context.js", "taskDisplayIdShort", "abcdef") == "cdef"
+    _board(js, "<div></div>")
+    assert js.call("board-context.js", "taskDisplayId", "") == ""
+    assert js.call("board-context.js", "taskDisplayId", "refs/tags/tasks/task_abcd") == "abcd"
+    assert js.call("board-context.js", "taskDisplayId", "refs/tags/tasks/custom") == "custom"
+    assert js.call("board-context.js", "taskDisplayIdShort", "") == ""
+    assert js.call("board-context.js", "taskDisplayIdShort", "ab") == "ab"
+    assert js.call("board-context.js", "taskDisplayIdShort", "abcdef") == "cdef"
 
 
 def test_context_comes_from_the_dataset_or_the_url(js: JsPage) -> None:
-	_board(js)
-	assert js.call("board-context.js", "getBoardContext") == {"project": "demo", "board": "Tasks"}
-	js.set_html('<div class="board-columns"></div>')
-	js.set_location("http://127.0.0.1/project/demo/board/?b=Sprint")
-	js.load("board-context.js")
-	assert js.call("board-context.js", "getBoardContext") == {"project": "demo", "board": "Sprint"}
-	js.set_location("http://127.0.0.1/project/demo/board")
-	assert js.call("board-context.js", "getBoardContext")["board"] == "Tasks"
+    _board(js)
+    assert js.call("board-context.js", "getBoardContext") == {"project": "demo", "board": "Tasks"}
+    js.set_html('<div class="board-columns"></div>')
+    js.set_location("http://127.0.0.1/project/demo/board/?b=Sprint")
+    js.load("board-context.js")
+    assert js.call("board-context.js", "getBoardContext") == {"project": "demo", "board": "Sprint"}
+    js.set_location("http://127.0.0.1/project/demo/board")
+    assert js.call("board-context.js", "getBoardContext")["board"] == "Tasks"
 
 
 def test_find_card_matches_the_task_ref(js: JsPage) -> None:
-	_board(js)
-	assert js.call("board-context.js", "findBoardCardByTaskRef", "") is None
-	assert js.value("__loaded['board-context.js'].findBoardCardByTaskRef('missing')") is None
-	assert js.value("__loaded['board-context.js'].findBoardCardByTaskRef('refs/tags/tasks/task_abcd') !== null") is True
+    _board(js)
+    assert js.call("board-context.js", "findBoardCardByTaskRef", "") is None
+    assert js.value("__loaded['board-context.js'].findBoardCardByTaskRef('missing')") is None
+    assert js.value("__loaded['board-context.js'].findBoardCardByTaskRef('refs/tags/tasks/task_abcd') !== null") is True
 
 
 def test_title_tooltip_uses_the_id_and_falls_back_to_untitled(js: JsPage) -> None:
-	js.set_html('<div class="board-card"></div>')
-	js.load("board-context.js")
-	js.run("__loaded['board-context.js'].syncCardTitleLineTooltip(document.querySelector('.board-card'))")
-	js.set_html(board_shell(cards_todo=card_html(title="  ")))
-	js.run("__loaded['board-context.js'].syncCardTitleLineTooltip(document.querySelector('.board-card'))")
-	assert js.attr(".board-card-title-line", "title") == "Untitled"
-	js.set_html(board_shell(cards_todo=card_html(ref="refs/tags/tasks/task_abcd", title="Ship")))
-	js.run(
-		"__loaded['board-context.js'].setCardTaskRefDisplay("
-		"document.querySelector('.board-card'), 'refs/tags/tasks/task_abcd', null)"
-	)
-	assert js.text(".board-card-task-id") == "abcd"
-	assert js.text(".board-card-task-ref") == "refs/tags/tasks/task_abcd"
-	assert js.attr(".board-card-title-line", "title") == "abcd: Ship"
+    js.set_html('<div class="board-card"></div>')
+    js.load("board-context.js")
+    js.run("__loaded['board-context.js'].syncCardTitleLineTooltip(document.querySelector('.board-card'))")
+    js.set_html(board_shell(cards_todo=card_html(title="  ")))
+    js.run("__loaded['board-context.js'].syncCardTitleLineTooltip(document.querySelector('.board-card'))")
+    assert js.attr(".board-card-title-line", "title") == "Untitled"
+    js.set_html(board_shell(cards_todo=card_html(ref="refs/tags/tasks/task_abcd", title="Ship")))
+    js.run(
+        "__loaded['board-context.js'].setCardTaskRefDisplay("
+        "document.querySelector('.board-card'), 'refs/tags/tasks/task_abcd', null)"
+    )
+    assert js.text(".board-card-task-id") == "abcd"
+    assert js.text(".board-card-task-ref") == "refs/tags/tasks/task_abcd"
+    assert js.attr(".board-card-title-line", "title") == "abcd: Ship"
 
 
 def test_title_line_without_a_title_element_uses_untitled(js: JsPage) -> None:
-	js.set_html(
-		'<div class="board-card" data-task-ref="refs/tags/tasks/task_abcd">'
-		'<div class="board-card-title-line"></div>'
-		'<span class="board-card-task-id"></span>'
-		'<div class="board-card-task-ref">old</div></div>'
-	)
-	js.load("board-context.js")
-	js.run("__loaded['board-context.js'].syncCardTitleLineTooltip(document.querySelector('.board-card'))")
-	assert js.attr(".board-card-title-line", "title") == "abcd: Untitled"
-	js.run("__loaded['board-context.js'].setCardTaskRefDisplay(document.querySelector('.board-card'), '', 'abcd')")
-	assert js.text(".board-card-task-ref") == ""
+    js.set_html(
+        '<div class="board-card" data-task-ref="refs/tags/tasks/task_abcd">'
+        '<div class="board-card-title-line"></div>'
+        '<span class="board-card-task-id"></span>'
+        '<div class="board-card-task-ref">old</div></div>'
+    )
+    js.load("board-context.js")
+    js.run("__loaded['board-context.js'].syncCardTitleLineTooltip(document.querySelector('.board-card'))")
+    assert js.attr(".board-card-title-line", "title") == "abcd: Untitled"
+    js.run("__loaded['board-context.js'].setCardTaskRefDisplay(document.querySelector('.board-card'), '', 'abcd')")
+    assert js.text(".board-card-task-ref") == ""
 
 
 def test_error_hint_replaces_itself_and_expires(js: JsPage) -> None:
-	_board(js, "<div></div>")
-	js.call("board-context.js", "showBoardError", "nope")
-	assert js.count(".board-task-error-hint") == 0
-	_board(js)
-	js.call("board-context.js", "showBoardError", "first")
-	js.call("board-context.js", "showBoardError", "second")
-	assert js.count(".board-task-error-hint") == 1
-	assert js.text(".board-task-error-hint") == "second"
-	js.tick()
-	assert js.count(".board-task-error-hint") == 0
+    _board(js, "<div></div>")
+    js.call("board-context.js", "showBoardError", "nope")
+    assert js.count(".board-task-error-hint") == 0
+    _board(js)
+    js.call("board-context.js", "showBoardError", "first")
+    js.call("board-context.js", "showBoardError", "second")
+    assert js.count(".board-task-error-hint") == 1
+    assert js.text(".board-task-error-hint") == "second"
+    js.tick()
+    assert js.count(".board-task-error-hint") == 0
 
 
 def test_column_badge_sums_points(js: JsPage) -> None:
-	html = board_shell(
-		cards_todo=card_html(ref="a", points="2") + card_html(ref="b", points="3") + card_html(ref="c", points=""),
-	)
-	_board(js, html)
-	js.run("__loaded['board-context.js'].updateColumnBadge(null)")
-	js.run("__loaded['board-context.js'].updateColumnBadge(document.querySelector('.board-column'))")
-	assert js.text(".board-column-task-count") == "3"
-	assert js.text(".board-column-points-total") == "5 pts"
-	js.set_html('<div class="board-column"></div>')
-	js.load("board-context.js")
-	js.run("__loaded['board-context.js'].updateColumnBadge(document.querySelector('.board-column'))")
-	assert js.count(".board-column-task-count") == 0
+    html = board_shell(
+        cards_todo=card_html(ref="a", points="2") + card_html(ref="b", points="3") + card_html(ref="c", points=""),
+    )
+    _board(js, html)
+    js.run("__loaded['board-context.js'].updateColumnBadge(null)")
+    js.run("__loaded['board-context.js'].updateColumnBadge(document.querySelector('.board-column'))")
+    assert js.text(".board-column-task-count") == "3"
+    assert js.text(".board-column-points-total") == "5 pts"
+    js.set_html('<div class="board-column"></div>')
+    js.load("board-context.js")
+    js.run("__loaded['board-context.js'].updateColumnBadge(document.querySelector('.board-column'))")
+    assert js.count(".board-column-task-count") == 0
 
 
 def test_field_update_posts_json_and_reports_failures(js: JsPage) -> None:
-	_board(js, "<div></div>")
-	assert js.settle("__loaded['board-context.js'].pushTaskFieldUpdate(null, {})") == {"ok": False, "status": 0}
-	_board(js)
-	card = "document.querySelector('.board-card')"
-	js.route("tasks/update", error="offline")
-	assert js.settle(f"__loaded['board-context.js'].pushTaskFieldUpdate({card}, {{a:1}})") == {
-		"ok": False,
-		"status": 0,
-	}
-	js.route("tasks/update", status=200)
-	assert js.settle(f"__loaded['board-context.js'].pushTaskFieldUpdate({card}, {{title:'Ship'}})") == {
-		"ok": True,
-		"status": 200,
-	}
-	assert posted_json(js, "tasks/update")["title"] == "Ship"
-	js.route("tasks/update", status=404)
-	assert js.settle(f"__loaded['board-context.js'].pushTaskFieldUpdate({card}, {{}})")["status"] == 404
-	js.route("tasks/update", error="offline")
-	assert js.settle(f"__loaded['board-context.js'].pushTaskFieldUpdate({card}, {{}})") == {"ok": False, "status": 0}
-	js.set_html('<div class="board-columns"><div class="board-card" data-task-ref="t"></div></div>')
-	js.load("board-context.js")
-	assert js.settle("__loaded['board-context.js'].pushTaskFieldUpdate(document.querySelector('.board-card'), {})") == {
-		"ok": False,
-		"status": 0,
-	}
+    _board(js, "<div></div>")
+    assert js.settle("__loaded['board-context.js'].pushTaskFieldUpdate(null, {})") == {"ok": False, "status": 0}
+    _board(js)
+    card = "document.querySelector('.board-card')"
+    js.route("tasks/update", error="offline")
+    assert js.settle(f"__loaded['board-context.js'].pushTaskFieldUpdate({card}, {{a:1}})") == {
+        "ok": False,
+        "status": 0,
+    }
+    js.route("tasks/update", status=200)
+    assert js.settle(f"__loaded['board-context.js'].pushTaskFieldUpdate({card}, {{title:'Ship'}})") == {
+        "ok": True,
+        "status": 200,
+    }
+    assert posted_json(js, "tasks/update")["title"] == "Ship"
+    js.route("tasks/update", status=404)
+    assert js.settle(f"__loaded['board-context.js'].pushTaskFieldUpdate({card}, {{}})")["status"] == 404
+    js.route("tasks/update", error="offline")
+    assert js.settle(f"__loaded['board-context.js'].pushTaskFieldUpdate({card}, {{}})") == {"ok": False, "status": 0}
+    js.set_html('<div class="board-columns"><div class="board-card" data-task-ref="t"></div></div>')
+    js.load("board-context.js")
+    assert js.settle("__loaded['board-context.js'].pushTaskFieldUpdate(document.querySelector('.board-card'), {})") == {
+        "ok": False,
+        "status": 0,
+    }
 
 
 def test_refresh_stores_the_manifest_or_an_empty_one(js: JsPage) -> None:
-	_board(js, '<div class="board-columns"></div>')
-	js.set_location("http://127.0.0.1/")
-	assert js.settle("__loaded['board-context.js'].refreshBoardRefManifest()") is None
-	js.route("board-ref-manifest", body=MANIFEST)
-	_board(js)
-	got = js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
-	assert got["commits"][0]["insert"] == "abcd"
-	assert js.call("board-context.js", "getBoardRefManifest")["refs"][0]["insert"] == "main"
-	js.route("board-ref-manifest", status=500)
-	empty = js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
-	assert empty == {"commits": [], "refs": []}
-	js.route("board-ref-manifest", error="offline")
-	assert js.settle("__loaded['board-context.js'].refreshBoardRefManifest()") == {"commits": [], "refs": []}
-	assert requested(js, "board-ref-manifest") is True
+    _board(js, '<div class="board-columns"></div>')
+    js.set_location("http://127.0.0.1/")
+    assert js.settle("__loaded['board-context.js'].refreshBoardRefManifest()") is None
+    js.route("board-ref-manifest", body=MANIFEST)
+    _board(js)
+    got = js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
+    assert got["commits"][0]["insert"] == "abcd"
+    assert js.call("board-context.js", "getBoardRefManifest")["refs"][0]["insert"] == "main"
+    js.route("board-ref-manifest", status=500)
+    empty = js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
+    assert empty == {"commits": [], "refs": []}
+    js.route("board-ref-manifest", error="offline")
+    assert js.settle("__loaded['board-context.js'].refreshBoardRefManifest()") == {"commits": [], "refs": []}
+    assert requested(js, "board-ref-manifest") is True
diff --git a/pygitweb/src/pygitweb/static_tests/test_board-dnd.py b/pygitweb/src/pygitweb/static_tests/test_board-dnd.py
index 7753b5d..a374012 100644
--- a/pygitweb/src/pygitweb/static_tests/test_board-dnd.py
+++ b/pygitweb/src/pygitweb/static_tests/test_board-dnd.py
@@ -5,111 +5,111 @@ from pygitweb.static_tests.pages import board_shell, card_html, requested
 
 
 def _bind(js: JsPage, html: str | None = None) -> None:
-	js.set_html(html if html is not None else board_shell(cards_todo=card_html(ref="t1", title="A", points="1")))
-	js.load("board-dnd.js")
-	js.call("board-dnd.js", "bindBoardInteractions")
+    js.set_html(html if html is not None else board_shell(cards_todo=card_html(ref="t1", title="A", points="1")))
+    js.load("board-dnd.js")
+    js.call("board-dnd.js", "bindBoardInteractions")
 
 
 def test_binding_without_a_board_does_nothing(js: JsPage) -> None:
-	js.set_html("<div></div>")
-	js.load("board-dnd.js")
-	js.call("board-dnd.js", "bindBoardInteractions")
-	assert requested(js, "tasks/update") is False
+    js.set_html("<div></div>")
+    js.load("board-dnd.js")
+    js.call("board-dnd.js", "bindBoardInteractions")
+    assert requested(js, "tasks/update") is False
 
 
 def test_drop_on_another_column_saves_and_reorders(js: JsPage) -> None:
-	js.route("tasks/update", status=200)
-	js.route("tasks/list", body=[{"ref": "t1", "status": "IN_PROGRESS", "title": "A"}])
-	_bind(js)
-	js.dispatch(".board-card", "dragstart")
-	assert "opacity-50" in js.classes(".board-card")
-	js.dispatch('.board-column[data-status="IN_PROGRESS"]', "dragover")
-	assert "border-primary" in js.classes('.board-column[data-status="IN_PROGRESS"]')
-	js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
-	assert js.count('.board-column[data-status="IN_PROGRESS"] .board-card') == 1
-	assert js.attr(".board-card", "data-task-status") == "IN_PROGRESS"
-	assert requested(js, "tasks/update") is True
-	js.dispatch(".board-card", "dragend")
-	assert "opacity-50" not in js.classes(".board-card")
-	assert "border-primary" not in js.classes(".board-column")
+    js.route("tasks/update", status=200)
+    js.route("tasks/list", body=[{"ref": "t1", "status": "IN_PROGRESS", "title": "A"}])
+    _bind(js)
+    js.dispatch(".board-card", "dragstart")
+    assert "opacity-50" in js.classes(".board-card")
+    js.dispatch('.board-column[data-status="IN_PROGRESS"]', "dragover")
+    assert "border-primary" in js.classes('.board-column[data-status="IN_PROGRESS"]')
+    js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
+    assert js.count('.board-column[data-status="IN_PROGRESS"] .board-card') == 1
+    assert js.attr(".board-card", "data-task-status") == "IN_PROGRESS"
+    assert requested(js, "tasks/update") is True
+    js.dispatch(".board-card", "dragend")
+    assert "opacity-50" not in js.classes(".board-card")
+    assert "border-primary" not in js.classes(".board-column")
 
 
 def test_same_column_drop_does_not_save(js: JsPage) -> None:
-	_bind(js)
-	js.dispatch(".board-card", "dragstart")
-	js.dispatch('.board-column[data-status="TODO"]', "drop")
-	assert js.count('.board-column[data-status="TODO"] .board-card') == 1
-	assert requested(js, "tasks/update") is False
+    _bind(js)
+    js.dispatch(".board-card", "dragstart")
+    js.dispatch('.board-column[data-status="TODO"]', "drop")
+    assert js.count('.board-column[data-status="TODO"] .board-card') == 1
+    assert requested(js, "tasks/update") is False
 
 
 def test_a_forbidden_move_puts_the_card_back(js: JsPage) -> None:
-	js.route("tasks/update", status=403)
-	html = board_shell(
-		cards_todo=card_html(ref="t1", title="A") + card_html(ref="t2", title="B"),
-	)
-	_bind(js, html)
-	js.dispatch('.board-card[data-task-ref="t1"]', "dragstart")
-	js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
-	assert js.count('.board-column[data-status="TODO"] .board-card') == 2
-	assert "Not authorized" in js.text(".board-task-error-hint")
-	js.route("tasks/update", status=500)
-	js.dispatch('.board-card[data-task-ref="t1"]', "dragstart")
-	js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
-	assert "Could not save" in js.text(".board-task-error-hint")
-	assert js.attr('.board-card[data-task-ref="t1"]', "data-task-status") == "TODO"
+    js.route("tasks/update", status=403)
+    html = board_shell(
+        cards_todo=card_html(ref="t1", title="A") + card_html(ref="t2", title="B"),
+    )
+    _bind(js, html)
+    js.dispatch('.board-card[data-task-ref="t1"]', "dragstart")
+    js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
+    assert js.count('.board-column[data-status="TODO"] .board-card') == 2
+    assert "Not authorized" in js.text(".board-task-error-hint")
+    js.route("tasks/update", status=500)
+    js.dispatch('.board-card[data-task-ref="t1"]', "dragstart")
+    js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
+    assert "Could not save" in js.text(".board-task-error-hint")
+    assert js.attr('.board-card[data-task-ref="t1"]', "data-task-status") == "TODO"
 
 
 def test_a_card_without_a_ref_is_moved_back(js: JsPage) -> None:
-	_bind(js, board_shell(cards_todo=card_html(title="A")))
-	js.dispatch(".board-card", "dragstart")
-	js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
-	assert js.count('.board-column[data-status="TODO"] .board-card') == 1
-	assert "Could not save" in js.text(".board-task-error-hint")
+    _bind(js, board_shell(cards_todo=card_html(title="A")))
+    js.dispatch(".board-card", "dragstart")
+    js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
+    assert js.count('.board-column[data-status="TODO"] .board-card') == 1
+    assert "Could not save" in js.text(".board-task-error-hint")
 
 
 def test_drag_data_failures_still_move_the_dragged_card(js: JsPage) -> None:
-	js.route("tasks/update", status=200)
-	js.route("tasks/list", body=[{"ref": "t1", "status": "IN_PROGRESS"}])
-	_bind(js)
-	js.run("__failSetData = true;")
-	js.dispatch(".board-card", "dragstart")
-	assert "opacity-50" in js.classes(".board-card")
-	js.run("__failGetData = true;")
-	js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
-	assert js.attr(".board-card", "data-task-status") == "IN_PROGRESS"
+    js.route("tasks/update", status=200)
+    js.route("tasks/list", body=[{"ref": "t1", "status": "IN_PROGRESS"}])
+    _bind(js)
+    js.run("__failSetData = true;")
+    js.dispatch(".board-card", "dragstart")
+    assert "opacity-50" in js.classes(".board-card")
+    js.run("__failGetData = true;")
+    js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
+    assert js.attr(".board-card", "data-task-status") == "IN_PROGRESS"
 
 
 def test_click_opens_the_popout_unless_the_gesture_was_a_drag(js: JsPage) -> None:
-	_bind(js)
-	js.dispatch(".board-card", "dragstart")
-	js.click(".board-card")
-	assert js.count(".board-card-popout-overlay") == 0
-	js.dispatch(".board-card", "dragend")
-	js.tick()
-	js.click(".board-card")
-	assert js.count(".board-card-popout-overlay") == 1
-	js.set_html(board_shell(cards_todo=card_html(ref="t1", title="A")))
-	js.call("board-dnd.js", "bindBoardInteractions")
-	js.run("document.querySelector('.board-card').classList.add('board-card-popout')")
-	js.click(".board-card")
-	assert js.count(".board-card-popout-overlay") == 0
+    _bind(js)
+    js.dispatch(".board-card", "dragstart")
+    js.click(".board-card")
+    assert js.count(".board-card-popout-overlay") == 0
+    js.dispatch(".board-card", "dragend")
+    js.tick()
+    js.click(".board-card")
+    assert js.count(".board-card-popout-overlay") == 1
+    js.set_html(board_shell(cards_todo=card_html(ref="t1", title="A")))
+    js.call("board-dnd.js", "bindBoardInteractions")
+    js.run("document.querySelector('.board-card').classList.add('board-card-popout')")
+    js.click(".board-card")
+    assert js.count(".board-card-popout-overlay") == 0
 
 
 def test_drop_without_a_card_or_a_list_is_ignored(js: JsPage) -> None:
-	html = (
-		'<div class="board-columns" data-project="demo" data-board="Tasks">'
-		'<div class="board-column dropzone" data-status="TODO"></div>'
-		'<div class="board-column dropzone" data-status="IN_PROGRESS"><div class="board-column-cards"></div></div>'
-		"</div>"
-	)
-	_bind(js, html)
-	js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
-	js.dispatch('.board-column[data-status="TODO"]', "dragstart")
-	js.dispatch('.board-column[data-status="TODO"]', "drop")
-	assert requested(js, "tasks/update") is False
+    html = (
+        '<div class="board-columns" data-project="demo" data-board="Tasks">'
+        '<div class="board-column dropzone" data-status="TODO"></div>'
+        '<div class="board-column dropzone" data-status="IN_PROGRESS"><div class="board-column-cards"></div></div>'
+        "</div>"
+    )
+    _bind(js, html)
+    js.dispatch('.board-column[data-status="IN_PROGRESS"]', "drop")
+    js.dispatch('.board-column[data-status="TODO"]', "dragstart")
+    js.dispatch('.board-column[data-status="TODO"]', "drop")
+    assert requested(js, "tasks/update") is False
 
 
 def test_dragover_outside_a_column_does_not_highlight(js: JsPage) -> None:
-	_bind(js)
-	js.dispatch(".board-column-cards", "dragover")
-	assert "border-primary" not in js.classes(".board-column")
+    _bind(js)
+    js.dispatch(".board-column-cards", "dragover")
+    assert "border-primary" not in js.classes(".board-column")
diff --git a/pygitweb/src/pygitweb/static_tests/test_board-markdown.py b/pygitweb/src/pygitweb/static_tests/test_board-markdown.py
index 865796e..afe2a7d 100644
--- a/pygitweb/src/pygitweb/static_tests/test_board-markdown.py
+++ b/pygitweb/src/pygitweb/static_tests/test_board-markdown.py
@@ -7,70 +7,70 @@ from pygitweb.static_tests.pages import MANIFEST, board_shell
 
 
 def test_set_markdown_renders_and_linkifies_after_the_manifest_loads(js: JsPage) -> None:
-	js.route("board-ref-manifest", body=MANIFEST)
-	js.set_html(board_shell() + '<div id="md"></div><textarea id="note"></textarea>')
-	js.load("board-markdown.js")
-	assert js.call("board-markdown.js", "getMarkdownFromElement", None) == ""
-	js.run("__loaded['board-markdown.js'].setMarkdownElement(null, 'x')")
-	js.run("__loaded['board-markdown.js'].linkifyMarkdownElement(null)")
-	js.run("__loaded['board-markdown.js'].attachBoardRefAutocomplete(null)")
-	source = json.dumps("see #abcd @missing")
-	js.run(f"__loaded['board-markdown.js'].setMarkdownElement(document.getElementById('md'), {source})")
-	assert js.count("#md .board-ref-link") == 0
-	assert js.value("document.getElementById('md').dataset.boardMarkdown") == "see #abcd @missing"
-	js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
-	js.run(f"__loaded['board-markdown.js'].setMarkdownElement(document.getElementById('md'), {source})")
-	assert js.count("#md .board-ref-commit") == 1
-	assert js.count("#md .board-ref-unresolved") == 1
-	assert js.count("#md strong") == 0
-	js.run("__loaded['board-markdown.js'].setMarkdownElement(document.getElementById('md'), '**bold**')")
-	assert js.count("#md strong") == 1
-	assert "markdown-body" in js.classes("#md")
-	js.run("__loaded['board-markdown.js'].attachBoardRefAutocomplete(document.getElementById('note'))")
-	assert js.attr("#note", "data-board-ref-autocomplete") == "1"
+    js.route("board-ref-manifest", body=MANIFEST)
+    js.set_html(board_shell() + '<div id="md"></div><textarea id="note"></textarea>')
+    js.load("board-markdown.js")
+    assert js.call("board-markdown.js", "getMarkdownFromElement", None) == ""
+    js.run("__loaded['board-markdown.js'].setMarkdownElement(null, 'x')")
+    js.run("__loaded['board-markdown.js'].linkifyMarkdownElement(null)")
+    js.run("__loaded['board-markdown.js'].attachBoardRefAutocomplete(null)")
+    source = json.dumps("see #abcd @missing")
+    js.run(f"__loaded['board-markdown.js'].setMarkdownElement(document.getElementById('md'), {source})")
+    assert js.count("#md .board-ref-link") == 0
+    assert js.value("document.getElementById('md').dataset.boardMarkdown") == "see #abcd @missing"
+    js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
+    js.run(f"__loaded['board-markdown.js'].setMarkdownElement(document.getElementById('md'), {source})")
+    assert js.count("#md .board-ref-commit") == 1
+    assert js.count("#md .board-ref-unresolved") == 1
+    assert js.count("#md strong") == 0
+    js.run("__loaded['board-markdown.js'].setMarkdownElement(document.getElementById('md'), '**bold**')")
+    assert js.count("#md strong") == 1
+    assert "markdown-body" in js.classes("#md")
+    js.run("__loaded['board-markdown.js'].attachBoardRefAutocomplete(document.getElementById('note'))")
+    assert js.attr("#note", "data-board-ref-autocomplete") == "1"
 
 
 def test_compact_preview_truncates_at_120_characters(js: JsPage) -> None:
-	long = "word " * 40
-	js.set_html(
-		'<div class="board-columns" data-project="demo" data-board="Tasks"></div>'
-		'<div class="board-card"><div class="board-card-description-preview"></div>'
-		'<div class="board-card-description-full"></div></div>'
-	)
-	js.load("board-markdown.js")
-	js.run(
-		"__loaded['board-markdown.js'].syncCompactDescription(document.querySelector('.board-card'), "
-		+ json.dumps(long)
-		+ ")"
-	)
-	preview = js.text(".board-card-description-preview")
-	assert preview.endswith("…")
-	assert len(preview) == 121
-	js.run("__loaded['board-markdown.js'].syncCompactDescription(document.querySelector('.board-card'), '')")
-	assert js.text(".board-card-description-preview") == ""
+    long = "word " * 40
+    js.set_html(
+        '<div class="board-columns" data-project="demo" data-board="Tasks"></div>'
+        '<div class="board-card"><div class="board-card-description-preview"></div>'
+        '<div class="board-card-description-full"></div></div>'
+    )
+    js.load("board-markdown.js")
+    js.run(
+        "__loaded['board-markdown.js'].syncCompactDescription(document.querySelector('.board-card'), "
+        + json.dumps(long)
+        + ")"
+    )
+    preview = js.text(".board-card-description-preview")
+    assert preview.endswith("…")
+    assert len(preview) == 121
+    js.run("__loaded['board-markdown.js'].syncCompactDescription(document.querySelector('.board-card'), '')")
+    assert js.text(".board-card-description-preview") == ""
 
 
 def test_preview_reads_plain_text_when_markdown_was_not_stored(js: JsPage) -> None:
-	js.set_html(
-		'<div class="board-card"><div class="board-card-description-preview"></div>'
-		'<div class="board-card-description-full">plain</div></div>'
-	)
-	js.load("board-markdown.js")
-	text = js.value(
-		"__loaded['board-markdown.js'].getMarkdownFromElement(document.querySelector('.board-card-description-full'))"
-	)
-	assert text == "plain"
-	js.run("__loaded['board-markdown.js'].syncCompactDescription(document.querySelector('.board-card'))")
-	assert js.text(".board-card-description-preview") == "plain"
+    js.set_html(
+        '<div class="board-card"><div class="board-card-description-preview"></div>'
+        '<div class="board-card-description-full">plain</div></div>'
+    )
+    js.load("board-markdown.js")
+    text = js.value(
+        "__loaded['board-markdown.js'].getMarkdownFromElement(document.querySelector('.board-card-description-full'))"
+    )
+    assert text == "plain"
+    js.run("__loaded['board-markdown.js'].syncCompactDescription(document.querySelector('.board-card'))")
+    assert js.text(".board-card-description-preview") == "plain"
 
 
 def test_linkify_stops_when_the_board_has_no_project(js: JsPage) -> None:
-	js.route("board-ref-manifest", body=MANIFEST)
-	js.set_html(board_shell() + '<div id="md">see #abcd</div>')
-	js.load("board-markdown.js")
-	js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
-	js.set_html('<div class="board-columns"></div><div id="md">see #abcd</div>')
-	js.set_location("http://127.0.0.1/")
-	js.run("__loaded['board-markdown.js'].linkifyMarkdownElement(document.getElementById('md'))")
-	assert js.count(".board-ref-link") == 0
-	js.run("__loaded['board-markdown.js'].attachBoardRefAutocomplete(document.createElement('textarea'))")
+    js.route("board-ref-manifest", body=MANIFEST)
+    js.set_html(board_shell() + '<div id="md">see #abcd</div>')
+    js.load("board-markdown.js")
+    js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
+    js.set_html('<div class="board-columns"></div><div id="md">see #abcd</div>')
+    js.set_location("http://127.0.0.1/")
+    js.run("__loaded['board-markdown.js'].linkifyMarkdownElement(document.getElementById('md'))")
+    assert js.count(".board-ref-link") == 0
+    js.run("__loaded['board-markdown.js'].attachBoardRefAutocomplete(document.createElement('textarea'))")
diff --git a/pygitweb/src/pygitweb/static_tests/test_board-popout.py b/pygitweb/src/pygitweb/static_tests/test_board-popout.py
index b073e8b..59e829d 100644
--- a/pygitweb/src/pygitweb/static_tests/test_board-popout.py
+++ b/pygitweb/src/pygitweb/static_tests/test_board-popout.py
@@ -5,196 +5,196 @@ from pygitweb.static_tests.pages import MANIFEST, board_shell, card_html, posted
 
 
 def _open(js: JsPage, *, cards: str | None = None, project: str = "demo") -> None:
-	js.route("board/list", body=[{"name": "Tasks"}, {"name": "Other"}])
-	js.route("comments/list", body=[])
-	js.route("board-ref-manifest", body=MANIFEST)
-	js.route("tasks/list", body=[])
-	js.route("tasks/update", status=200)
-	html = board_shell(
-		project=project,
-		cards_todo=cards if cards is not None else card_html(ref="t1", title="Ship", description="see #abcd"),
-	)
-	js.set_html(html)
-	js.load("board-popout.js")
-	js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
-	js.run("__loaded['board-popout.js'].showCardPopout(document.querySelector('.board-card'))")
+    js.route("board/list", body=[{"name": "Tasks"}, {"name": "Other"}])
+    js.route("comments/list", body=[])
+    js.route("board-ref-manifest", body=MANIFEST)
+    js.route("tasks/list", body=[])
+    js.route("tasks/update", status=200)
+    html = board_shell(
+        project=project,
+        cards_todo=cards if cards is not None else card_html(ref="t1", title="Ship", description="see #abcd"),
+    )
+    js.set_html(html)
+    js.load("board-popout.js")
+    js.settle("__loaded['board-context.js'].refreshBoardRefManifest()")
+    js.run("__loaded['board-popout.js'].showCardPopout(document.querySelector('.board-card'))")
 
 
 def test_popout_edits_post_the_card_fields(js: JsPage) -> None:
-	_open(js)
-	assert "d-none" not in js.classes(".board-card-details")
-	assert js.count(".board-card-popout-overlay") == 1
-	assert js.count(".board-ref-commit") == 1
-	js.click(".board-card-title")
-	js.click(".board-card-title")
-	js.set_prop(".board-card-title-edit", "value", "  ")
-	js.dispatch(".board-card-title-edit", "keydown", key="Enter")
-	assert js.text(".board-card-title") == "Untitled"
-	js.click(".board-card-description-full")
-	js.set_prop(".board-card-description-edit", "value", "updated")
-	js.dispatch(".board-card-description-edit", "blur")
-	assert js.text(".board-card-description-preview") == "updated"
-	js.run("document.querySelector('.board-card-due-date').textContent = '2020-01-02 extra';")
-	js.click(".board-card-due-date")
-	assert js.prop(".board-card-due-date-edit", "value") == "2020-01-02"
-	js.set_prop(".board-card-due-date-edit", "value", "")
-	js.dispatch(".board-card-due-date-edit", "blur")
-	assert js.text(".board-card-due-date") == "None"
-	js.click(".board-card-status")
-	js.run("document.querySelector('.board-card-status-edit option').textContent = 'In Progress';")
-	js.dispatch(".board-card-status-edit", "change")
-	assert js.text(".board-card-status") == "In Progress"
-	assert js.attr(".board-card", "data-task-status") == "IN_PROGRESS"
-	js.click(".board-card-priority")
-	js.run("document.querySelector('.board-card-priority-edit option').textContent = 'High';")
-	js.dispatch(".board-card-priority-edit", "change")
-	assert js.text(".board-card-priority") == "High"
-	js.click(".board-card-points")
-	assert js.count(".board-card-points-edit option") == 9
-	js.set_prop(".board-card-points-edit", "value", "5")
-	js.dispatch(".board-card-points-edit", "change")
-	assert js.text(".board-card-points") == "5"
-	assert js.attr(".board-card", "data-task-points") == "5"
-	js.click(".board-card-assignee")
-	js.set_prop(".board-card-assignee-edit", "value", "Ada")
-	js.dispatch(".board-card-assignee-edit", "keydown", key="Enter")
-	assert js.text(".board-card-assignee-compact") == "Ada"
-	js.run("document.querySelector('.board-card-tags').textContent = 'ui';")
-	js.dispatch(".board-card-tags", "blur")
-	body = posted_json(js, "tasks/update")
-	assert body["title"] == "Untitled"
-	assert body["description"] == "updated"
-	assert body["status"] == "IN_PROGRESS"
-	assert body["priority"] == "HIGH"
-	assert body["points"] == 5
-	assert body["assignee"] == "Ada"
-	assert body["due_date"] is None
-	assert body["tags"] == "ui"
+    _open(js)
+    assert "d-none" not in js.classes(".board-card-details")
+    assert js.count(".board-card-popout-overlay") == 1
+    assert js.count(".board-ref-commit") == 1
+    js.click(".board-card-title")
+    js.click(".board-card-title")
+    js.set_prop(".board-card-title-edit", "value", "  ")
+    js.dispatch(".board-card-title-edit", "keydown", key="Enter")
+    assert js.text(".board-card-title") == "Untitled"
+    js.click(".board-card-description-full")
+    js.set_prop(".board-card-description-edit", "value", "updated")
+    js.dispatch(".board-card-description-edit", "blur")
+    assert js.text(".board-card-description-preview") == "updated"
+    js.run("document.querySelector('.board-card-due-date').textContent = '2020-01-02 extra';")
+    js.click(".board-card-due-date")
+    assert js.prop(".board-card-due-date-edit", "value") == "2020-01-02"
+    js.set_prop(".board-card-due-date-edit", "value", "")
+    js.dispatch(".board-card-due-date-edit", "blur")
+    assert js.text(".board-card-due-date") == "None"
+    js.click(".board-card-status")
+    js.run("document.querySelector('.board-card-status-edit option').textContent = 'In Progress';")
+    js.dispatch(".board-card-status-edit", "change")
+    assert js.text(".board-card-status") == "In Progress"
+    assert js.attr(".board-card", "data-task-status") == "IN_PROGRESS"
+    js.click(".board-card-priority")
+    js.run("document.querySelector('.board-card-priority-edit option').textContent = 'High';")
+    js.dispatch(".board-card-priority-edit", "change")
+    assert js.text(".board-card-priority") == "High"
+    js.click(".board-card-points")
+    assert js.count(".board-card-points-edit option") == 9
+    js.set_prop(".board-card-points-edit", "value", "5")
+    js.dispatch(".board-card-points-edit", "change")
+    assert js.text(".board-card-points") == "5"
+    assert js.attr(".board-card", "data-task-points") == "5"
+    js.click(".board-card-assignee")
+    js.set_prop(".board-card-assignee-edit", "value", "Ada")
+    js.dispatch(".board-card-assignee-edit", "keydown", key="Enter")
+    assert js.text(".board-card-assignee-compact") == "Ada"
+    js.run("document.querySelector('.board-card-tags').textContent = 'ui';")
+    js.dispatch(".board-card-tags", "blur")
+    body = posted_json(js, "tasks/update")
+    assert body["title"] == "Untitled"
+    assert body["description"] == "updated"
+    assert body["status"] == "IN_PROGRESS"
+    assert body["priority"] == "HIGH"
+    assert body["points"] == 5
+    assert body["assignee"] == "Ada"
+    assert body["due_date"] is None
+    assert body["tags"] == "ui"
 
 
 def test_due_date_keeps_a_typed_value_and_rejects_junk(js: JsPage) -> None:
-	_open(js)
-	js.run("document.querySelector('.board-card-due-date').textContent = 'soon';")
-	js.click(".board-card-due-date")
-	assert js.prop(".board-card-due-date-edit", "value") == ""
-	js.set_prop(".board-card-due-date-edit", "value", "2024-05-06")
-	js.dispatch(".board-card-due-date-edit", "blur")
-	assert posted_json(js, "tasks/update")["due_date"] == "2024-05-06"
+    _open(js)
+    js.run("document.querySelector('.board-card-due-date').textContent = 'soon';")
+    js.click(".board-card-due-date")
+    assert js.prop(".board-card-due-date-edit", "value") == ""
+    js.set_prop(".board-card-due-date-edit", "value", "2024-05-06")
+    js.dispatch(".board-card-due-date-edit", "blur")
+    assert posted_json(js, "tasks/update")["due_date"] == "2024-05-06"
 
 
 def test_backdrop_restores_the_card_ahead_of_its_sibling(js: JsPage) -> None:
-	_open(js, cards=card_html(ref="t1", title="A") + card_html(ref="t2", title="B"))
-	assert js.count(".board-card-popout-overlay .board-card") == 1
-	js.click(".board-card-popout-overlay")
-	assert js.count(".board-card-popout-overlay") == 0
-	titles = js.texts(".board-column-cards .board-card-title")
-	assert titles == ["A", "B"]
-	assert "d-none" in js.classes(".board-card-details")
-	assert js.attr(".board-card", "draggable") == "true"
+    _open(js, cards=card_html(ref="t1", title="A") + card_html(ref="t2", title="B"))
+    assert js.count(".board-card-popout-overlay .board-card") == 1
+    js.click(".board-card-popout-overlay")
+    assert js.count(".board-card-popout-overlay") == 0
+    titles = js.texts(".board-column-cards .board-card-title")
+    assert titles == ["A", "B"]
+    assert "d-none" in js.classes(".board-card-details")
+    assert js.attr(".board-card", "draggable") == "true"
 
 
 def test_escape_closes_the_popout(js: JsPage) -> None:
-	_open(js, cards=card_html(ref="t1", title="A") + card_html(ref="t2", title="B"))
-	js.click(".board-card-popout .board-card-title")
-	js.set_prop(".board-card-title-edit", "value", "Renamed")
-	js.dispatch(".board-card-title-edit", "keydown", key="Escape")
-	assert js.count(".board-card-popout-overlay") == 0
-	assert js.count(".board-card-title-edit") == 0
-	assert js.texts(".board-column-cards .board-card-title") == ["Renamed", "B"]
-	assert "d-none" in js.classes(".board-card-details")
-	assert js.attr(".board-card", "draggable") == "true"
-	js.dispatch("body", "keydown", key="Escape")
-	assert js.count(".board-card") == 2
+    _open(js, cards=card_html(ref="t1", title="A") + card_html(ref="t2", title="B"))
+    js.click(".board-card-popout .board-card-title")
+    js.set_prop(".board-card-title-edit", "value", "Renamed")
+    js.dispatch(".board-card-title-edit", "keydown", key="Escape")
+    assert js.count(".board-card-popout-overlay") == 0
+    assert js.count(".board-card-title-edit") == 0
+    assert js.texts(".board-column-cards .board-card-title") == ["Renamed", "B"]
+    assert "d-none" in js.classes(".board-card-details")
+    assert js.attr(".board-card", "draggable") == "true"
+    js.dispatch("body", "keydown", key="Escape")
+    assert js.count(".board-card") == 2
 
 
 def test_escape_closes_suggestions_before_the_popout(js: JsPage) -> None:
-	_open(js)
-	js.click(".board-card-description-full")
-	js.set_prop(".board-card-description-edit", "value", "@")
-	js.set_prop(".board-card-description-edit", "selectionStart", 1)
-	js.dispatch(".board-card-description-edit", "input")
-	assert js.count(".board-ref-suggest:not(.d-none)") == 1
-	js.dispatch(".board-card-description-edit", "keydown", key="Escape")
-	assert js.count(".board-ref-suggest:not(.d-none)") == 0
-	assert js.count(".board-card-popout-overlay") == 1
-	js.dispatch(".board-card-description-edit", "keydown", key="Escape")
-	assert js.count(".board-card-popout-overlay") == 0
+    _open(js)
+    js.click(".board-card-description-full")
+    js.set_prop(".board-card-description-edit", "value", "@")
+    js.set_prop(".board-card-description-edit", "selectionStart", 1)
+    js.dispatch(".board-card-description-edit", "input")
+    assert js.count(".board-ref-suggest:not(.d-none)") == 1
+    js.dispatch(".board-card-description-edit", "keydown", key="Escape")
+    assert js.count(".board-ref-suggest:not(.d-none)") == 0
+    assert js.count(".board-card-popout-overlay") == 1
+    js.dispatch(".board-card-description-edit", "keydown", key="Escape")
+    assert js.count(".board-card-popout-overlay") == 0
 
 
 def test_closing_the_last_card_appends_it(js: JsPage) -> None:
-	_open(js, cards=card_html(ref="t1", title="Only"))
-	js.click(".board-card-popout-overlay")
-	assert js.text(".board-column-cards .board-card-title") == "Only"
+    _open(js, cards=card_html(ref="t1", title="Only"))
+    js.click(".board-card-popout-overlay")
+    assert js.text(".board-column-cards .board-card-title") == "Only"
 
 
 def test_delete_and_archive_ask_before_removing_the_card(js: JsPage) -> None:
-	js.route("tasks/delete", status=200)
-	js.route("tasks/archive", status=200)
-	_open(js)
-	js.run("__confirmYes = false;")
-	js.click(".board-card-delete-btn")
-	assert requested(js, "tasks/delete") is False
-	assert "permanently" in js.value("__confirms")[0]
-	js.run("__confirmYes = true;")
-	js.click(".board-card-delete-btn")
-	assert js.count(".board-card") == 0
-	_open(js)
-	js.route("tasks/delete", status=500)
-	js.click(".board-card-delete-btn")
-	assert js.count(".board-card") == 1
-	js.click(".board-card-archive-btn")
-	assert js.count(".board-card") == 0
-	assert "Archive this task" in js.value("__confirms")[-1]
+    js.route("tasks/delete", status=200)
+    js.route("tasks/archive", status=200)
+    _open(js)
+    js.run("__confirmYes = false;")
+    js.click(".board-card-delete-btn")
+    assert requested(js, "tasks/delete") is False
+    assert "permanently" in js.value("__confirms")[0]
+    js.run("__confirmYes = true;")
+    js.click(".board-card-delete-btn")
+    assert js.count(".board-card") == 0
+    _open(js)
+    js.route("tasks/delete", status=500)
+    js.click(".board-card-delete-btn")
+    assert js.count(".board-card") == 1
+    js.click(".board-card-archive-btn")
+    assert js.count(".board-card") == 0
+    assert "Archive this task" in js.value("__confirms")[-1]
 
 
 def test_archive_failure_leaves_the_card(js: JsPage) -> None:
-	js.route("tasks/archive", error="offline")
-	_open(js)
-	js.click(".board-card-archive-btn")
-	assert js.count(".board-card") == 1
+    js.route("tasks/archive", error="offline")
+    _open(js)
+    js.click(".board-card-archive-btn")
+    assert js.count(".board-card") == 1
 
 
 def test_move_removes_the_card_or_shows_an_error(js: JsPage) -> None:
-	js.route("tasks/move", status=200)
-	_open(js)
-	labels = js.texts(".board-card-move-select option")
-	assert "Tasks" not in labels
-	assert "Other" in labels
-	js.dispatch(".board-card-move-select", "change")
-	assert requested(js, "tasks/move") is False
-	js.set_prop(".board-card-move-select", "value", "Other")
-	js.dispatch(".board-card-move-select", "change")
-	assert js.count(".board-card-popout-overlay") == 0
-	assert any("target=Other" in str(item["url"]) for item in js.requests())
-	js.route("tasks/move", status=401)
-	_open(js)
-	js.set_prop(".board-card-move-select", "value", "Other")
-	js.dispatch(".board-card-move-select", "change")
-	assert "Not authorized" in js.text(".board-task-error-hint")
-	assert js.prop(".board-card-move-select", "value") == ""
-	js.route("tasks/move", status=500)
-	js.set_prop(".board-card-move-select", "value", "Other")
-	js.dispatch(".board-card-move-select", "change")
-	assert "Could not move task" in js.text(".board-task-error-hint")
-	js.route("tasks/move", error="offline")
-	js.set_prop(".board-card-move-select", "value", "Other")
-	js.dispatch(".board-card-move-select", "change")
-	assert "Could not move task" in js.text(".board-task-error-hint")
+    js.route("tasks/move", status=200)
+    _open(js)
+    labels = js.texts(".board-card-move-select option")
+    assert "Tasks" not in labels
+    assert "Other" in labels
+    js.dispatch(".board-card-move-select", "change")
+    assert requested(js, "tasks/move") is False
+    js.set_prop(".board-card-move-select", "value", "Other")
+    js.dispatch(".board-card-move-select", "change")
+    assert js.count(".board-card-popout-overlay") == 0
+    assert any("target=Other" in str(item["url"]) for item in js.requests())
+    js.route("tasks/move", status=401)
+    _open(js)
+    js.set_prop(".board-card-move-select", "value", "Other")
+    js.dispatch(".board-card-move-select", "change")
+    assert "Not authorized" in js.text(".board-task-error-hint")
+    assert js.prop(".board-card-move-select", "value") == ""
+    js.route("tasks/move", status=500)
+    js.set_prop(".board-card-move-select", "value", "Other")
+    js.dispatch(".board-card-move-select", "change")
+    assert "Could not move task" in js.text(".board-task-error-hint")
+    js.route("tasks/move", error="offline")
+    js.set_prop(".board-card-move-select", "value", "Other")
+    js.dispatch(".board-card-move-select", "change")
+    assert "Could not move task" in js.text(".board-task-error-hint")
 
 
 def test_actions_without_a_ref_or_project_are_not_bound(js: JsPage) -> None:
-	_open(js, cards=card_html(title="Draft"), project="")
-	js.set_location("http://127.0.0.1/")
-	js.click(".board-card-delete-btn")
-	assert js.value("__confirms") == []
-	assert requested(js, "tasks/delete") is False
+    _open(js, cards=card_html(title="Draft"), project="")
+    js.set_location("http://127.0.0.1/")
+    js.click(".board-card-delete-btn")
+    assert js.value("__confirms") == []
+    assert requested(js, "tasks/delete") is False
 
 
 def test_assignee_cleared_to_none_posts_null(js: JsPage) -> None:
-	_open(js)
-	js.run("document.querySelector('.board-card-assignee').textContent = 'Ada';")
-	js.click(".board-card-assignee")
-	js.set_prop(".board-card-assignee-edit", "value", "")
-	js.dispatch(".board-card-assignee-edit", "blur")
-	assert js.text(".board-card-assignee") == "None"
-	assert posted_json(js, "tasks/update")["assignee"] is None
+    _open(js)
+    js.run("document.querySelector('.board-card-assignee').textContent = 'Ada';")
+    js.click(".board-card-assignee")
+    js.set_prop(".board-card-assignee-edit", "value", "")
+    js.dispatch(".board-card-assignee-edit", "blur")
+    assert js.text(".board-card-assignee") == "None"
+    assert posted_json(js, "tasks/update")["assignee"] is None
diff --git a/pygitweb/src/pygitweb/static_tests/test_board-refs.py b/pygitweb/src/pygitweb/static_tests/test_board-refs.py
index ce2b61d..27eba38 100644
--- a/pygitweb/src/pygitweb/static_tests/test_board-refs.py
+++ b/pygitweb/src/pygitweb/static_tests/test_board-refs.py
@@ -6,137 +6,137 @@ from pygitweb.static_tests.js_runtime import JsPage
 from pygitweb.static_tests.pages import MANIFEST
 
 _ROOT = (
-	'<div id="root"><p>see #abcd and #abc and @main and @1234 and @missing and (#eeee)</p>'
-	"<pre><code>skip #abcd</code></pre><a href='/z'>keep @main</a></div>"
-	'<textarea id="note"></textarea>'
+    '<div id="root"><p>see #abcd and #abc and @main and @1234 and @missing and (#eeee)</p>'
+    "<pre><code>skip #abcd</code></pre><a href='/z'>keep @main</a></div>"
+    '<textarea id="note"></textarea>'
 )
 
 
 def _load(js: JsPage) -> None:
-	js.set_html(_ROOT)
-	js.run("__manifest = " + json.dumps(MANIFEST))
-	js.load("board-refs.js")
+    js.set_html(_ROOT)
+    js.run("__manifest = " + json.dumps(MANIFEST))
+    js.load("board-refs.js")
 
 
 def test_linkify_resolves_commits_refs_and_skips_code(js: JsPage) -> None:
-	_load(js)
-	js.run("__loaded['board-refs.js'].linkifyElement(null, __manifest, 'demo')")
-	js.run("__loaded['board-refs.js'].linkifyElement(document.getElementById('root'), __manifest, '')")
-	assert js.count(".board-ref-link") == 0
-	js.run("__loaded['board-refs.js'].linkifyElement(document.getElementById('root'), __manifest, 'group/demo')")
-	assert js.attr(".board-ref-commit", "href") == "/c/abcd"
-	assert js.attr(".board-ref-commit", "title") == "subject"
-	assert js.count(".board-ref-commit") >= 2
-	assert js.attr(".board-ref-tag", "href") == "/r/main"
-	assert js.text(".board-ref-unresolved") == "@missing"
-	assert js.attr(".board-ref-unresolved", "title") == "Unresolved reference"
-	assert "#abcd" in js.text("pre")
-	assert js.count("a[href='/z'] .board-ref-link") == 0
+    _load(js)
+    js.run("__loaded['board-refs.js'].linkifyElement(null, __manifest, 'demo')")
+    js.run("__loaded['board-refs.js'].linkifyElement(document.getElementById('root'), __manifest, '')")
+    assert js.count(".board-ref-link") == 0
+    js.run("__loaded['board-refs.js'].linkifyElement(document.getElementById('root'), __manifest, 'group/demo')")
+    assert js.attr(".board-ref-commit", "href") == "/c/abcd"
+    assert js.attr(".board-ref-commit", "title") == "subject"
+    assert js.count(".board-ref-commit") >= 2
+    assert js.attr(".board-ref-tag", "href") == "/r/main"
+    assert js.text(".board-ref-unresolved") == "@missing"
+    assert js.attr(".board-ref-unresolved", "title") == "Unresolved reference"
+    assert "#abcd" in js.text("pre")
+    assert js.count("a[href='/z'] .board-ref-link") == 0
 
 
 def test_unresolved_commit_links_to_the_project(js: JsPage) -> None:
-	js.set_html("<div id='root'>#ffff</div>")
-	js.run("__manifest = " + json.dumps(MANIFEST))
-	js.load("board-refs.js")
-	js.run("__loaded['board-refs.js'].linkifyElement(document.getElementById('root'), __manifest, 'group/demo')")
-	href = js.attr(".board-ref-commit", "href") or ""
-	assert "/project/group/demo" in href
-	assert "h=ffff" in href
-	assert js.attr(".board-ref-commit", "title") is None
+    js.set_html("<div id='root'>#ffff</div>")
+    js.run("__manifest = " + json.dumps(MANIFEST))
+    js.load("board-refs.js")
+    js.run("__loaded['board-refs.js'].linkifyElement(document.getElementById('root'), __manifest, 'group/demo')")
+    href = js.attr(".board-ref-commit", "href") or ""
+    assert "/project/group/demo" in href
+    assert "h=ffff" in href
+    assert js.attr(".board-ref-commit", "title") is None
 
 
 def test_load_manifest_normalizes_payloads(js: JsPage) -> None:
-	js.load("board-refs.js")
-	js.route("board-ref-manifest", body=MANIFEST)
-	got = js.settle("__loaded['board-refs.js'].loadManifest('group/demo', 'Sprint')")
-	assert got["commits"][0]["insert"] == "abcd"
-	assert any("b=Sprint" in str(item["url"]) and "/project/group/demo/" in str(item["url"]) for item in js.requests())
-	js.route("board-ref-manifest", status=500, body={"commits": "nope", "refs": None})
-	assert js.settle("__loaded['board-refs.js'].loadManifest('demo', '')") == {"commits": [], "refs": []}
-	js.route("board-ref-manifest", body={"commits": "nope", "refs": {}})
-	normalized = js.settle("__loaded['board-refs.js'].loadManifest('demo', 'Tasks')")
-	assert normalized == {"commits": [], "refs": []}
-	js.route("board-ref-manifest", error="offline")
-	assert js.settle("__loaded['board-refs.js'].loadManifest('demo', 'Tasks')") == {"commits": [], "refs": []}
+    js.load("board-refs.js")
+    js.route("board-ref-manifest", body=MANIFEST)
+    got = js.settle("__loaded['board-refs.js'].loadManifest('group/demo', 'Sprint')")
+    assert got["commits"][0]["insert"] == "abcd"
+    assert any("b=Sprint" in str(item["url"]) and "/project/group/demo/" in str(item["url"]) for item in js.requests())
+    js.route("board-ref-manifest", status=500, body={"commits": "nope", "refs": None})
+    assert js.settle("__loaded['board-refs.js'].loadManifest('demo', '')") == {"commits": [], "refs": []}
+    js.route("board-ref-manifest", body={"commits": "nope", "refs": {}})
+    normalized = js.settle("__loaded['board-refs.js'].loadManifest('demo', 'Tasks')")
+    assert normalized == {"commits": [], "refs": []}
+    js.route("board-ref-manifest", error="offline")
+    assert js.settle("__loaded['board-refs.js'].loadManifest('demo', 'Tasks')") == {"commits": [], "refs": []}
 
 
 def test_autocomplete_inserts_a_suggestion(js: JsPage) -> None:
-	_load(js)
-	js.run("__loaded['board-refs.js'].attachAutocomplete(null, __manifest)")
-	js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
-	js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
-	js.set_prop("#note", "value", "see @ma")
-	js.set_prop("#note", "selectionStart", 7)
-	js.dispatch("#note", "input")
-	assert "d-none" not in js.classes(".board-ref-suggest")
-	assert js.text(".board-ref-suggest-title") == "main"
-	js.dispatch(".board-ref-suggest-item", "mousedown")
-	assert js.prop("#note", "value") == "see @main"
-	assert "d-none" in js.classes(".board-ref-suggest")
+    _load(js)
+    js.run("__loaded['board-refs.js'].attachAutocomplete(null, __manifest)")
+    js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
+    js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
+    js.set_prop("#note", "value", "see @ma")
+    js.set_prop("#note", "selectionStart", 7)
+    js.dispatch("#note", "input")
+    assert "d-none" not in js.classes(".board-ref-suggest")
+    assert js.text(".board-ref-suggest-title") == "main"
+    js.dispatch(".board-ref-suggest-item", "mousedown")
+    assert js.prop("#note", "value") == "see @main"
+    assert "d-none" in js.classes(".board-ref-suggest")
 
 
 def test_autocomplete_filters_commits_and_caps_the_list(js: JsPage) -> None:
-	commits = [
-		{"insert": f"{index:04x}", "name": f"name-{index}", "url": "/c", "tags": "t", "snippet": "s"}
-		for index in range(14)
-	]
-	js.set_html("<textarea id='note'></textarea>")
-	js.run("__manifest = " + json.dumps({"commits": commits, "refs": MANIFEST["refs"]}))
-	js.load("board-refs.js")
-	js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
-	js.set_prop("#note", "value", "#")
-	js.set_prop("#note", "selectionStart", 1)
-	js.dispatch("#note", "keyup")
-	assert js.count(".board-ref-suggest-item") == 12
-	js.set_prop("#note", "value", "#1")
-	js.set_prop("#note", "selectionStart", 2)
-	js.dispatch("#note", "input")
-	assert js.count(".board-ref-suggest-item") >= 1
-	js.dispatch("#note", "keydown", key="Escape")
-	assert "d-none" in js.classes(".board-ref-suggest")
+    commits = [
+        {"insert": f"{index:04x}", "name": f"name-{index}", "url": "/c", "tags": "t", "snippet": "s"}
+        for index in range(14)
+    ]
+    js.set_html("<textarea id='note'></textarea>")
+    js.run("__manifest = " + json.dumps({"commits": commits, "refs": MANIFEST["refs"]}))
+    js.load("board-refs.js")
+    js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
+    js.set_prop("#note", "value", "#")
+    js.set_prop("#note", "selectionStart", 1)
+    js.dispatch("#note", "keyup")
+    assert js.count(".board-ref-suggest-item") == 12
+    js.set_prop("#note", "value", "#1")
+    js.set_prop("#note", "selectionStart", 2)
+    js.dispatch("#note", "input")
+    assert js.count(".board-ref-suggest-item") >= 1
+    js.dispatch("#note", "keydown", key="Escape")
+    assert "d-none" in js.classes(".board-ref-suggest")
 
 
 def test_autocomplete_hides_on_blur_and_outside_click(js: JsPage) -> None:
-	_load(js)
-	js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
-	js.set_prop("#note", "value", "@")
-	js.set_prop("#note", "selectionStart", 1)
-	js.dispatch("#note", "input")
-	assert js.count(".board-ref-suggest-item") == 2
-	js.dispatch("#note", "blur")
-	assert "d-none" not in js.classes(".board-ref-suggest")
-	js.tick()
-	assert "d-none" in js.classes(".board-ref-suggest")
-	js.set_prop("#note", "value", "@ma")
-	js.set_prop("#note", "selectionStart", 3)
-	js.dispatch("#note", "input")
-	js.dispatch("#root", "mousedown")
-	assert "d-none" in js.classes(".board-ref-suggest")
+    _load(js)
+    js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
+    js.set_prop("#note", "value", "@")
+    js.set_prop("#note", "selectionStart", 1)
+    js.dispatch("#note", "input")
+    assert js.count(".board-ref-suggest-item") == 2
+    js.dispatch("#note", "blur")
+    assert "d-none" not in js.classes(".board-ref-suggest")
+    js.tick()
+    assert "d-none" in js.classes(".board-ref-suggest")
+    js.set_prop("#note", "value", "@ma")
+    js.set_prop("#note", "selectionStart", 3)
+    js.dispatch("#note", "input")
+    js.dispatch("#root", "mousedown")
+    assert "d-none" in js.classes(".board-ref-suggest")
 
 
 def test_autocomplete_ignores_text_without_a_token(js: JsPage) -> None:
-	_load(js)
-	js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
-	js.set_prop("#note", "value", "hello")
-	js.set_prop("#note", "selectionStart", 5)
-	js.dispatch("#note", "input")
-	assert js.count(".board-ref-suggest-item") == 0
+    _load(js)
+    js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
+    js.set_prop("#note", "value", "hello")
+    js.set_prop("#note", "selectionStart", 5)
+    js.dispatch("#note", "input")
+    assert js.count(".board-ref-suggest-item") == 0
 
 
 def test_suggestion_dates_and_snippets_are_escaped(js: JsPage) -> None:
-	manifest = {
-		"commits": [],
-		"refs": [{"insert": "main", "name": "<b>", "url": "/r", "tags": "main", "snippet": "<i>", "created": "<d>"}],
-	}
-	js.set_html("<textarea id='note'></textarea>")
-	js.run("__manifest = " + json.dumps(manifest))
-	js.load("board-refs.js")
-	js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
-	js.set_prop("#note", "value", "@")
-	js.set_prop("#note", "selectionStart", 1)
-	js.dispatch("#note", "input")
-	html = js.prop(".board-ref-suggest-item", "innerHTML")
-	assert "&lt;b&gt;" in html
-	assert "&lt;i&gt;" in html
-	assert "&lt;d&gt;" in html
-	assert "<b>" not in html
+    manifest = {
+        "commits": [],
+        "refs": [{"insert": "main", "name": "<b>", "url": "/r", "tags": "main", "snippet": "<i>", "created": "<d>"}],
+    }
+    js.set_html("<textarea id='note'></textarea>")
+    js.run("__manifest = " + json.dumps(manifest))
+    js.load("board-refs.js")
+    js.run("__loaded['board-refs.js'].attachAutocomplete(document.getElementById('note'), __manifest)")
+    js.set_prop("#note", "value", "@")
+    js.set_prop("#note", "selectionStart", 1)
+    js.dispatch("#note", "input")
+    html = js.prop(".board-ref-suggest-item", "innerHTML")
+    assert "&lt;b&gt;" in html
+    assert "&lt;i&gt;" in html
+    assert "&lt;d&gt;" in html
+    assert "<b>" not in html
diff --git a/pygitweb/src/pygitweb/static_tests/test_board.py b/pygitweb/src/pygitweb/static_tests/test_board.py
index 790ed6e..afe7dc6 100644
--- a/pygitweb/src/pygitweb/static_tests/test_board.py
+++ b/pygitweb/src/pygitweb/static_tests/test_board.py
@@ -5,152 +5,152 @@ from pygitweb.static_tests.pages import board_shell, requested
 
 
 def _routes(js: JsPage) -> None:
-	js.route("board/list", body=[{"name": "Tasks", "task_count": 1}, {"name": "Sprint-2", "task_count": 0}])
-	js.route("tasks/list", body=[])
-	js.route("board-ref-manifest", body={"commits": [], "refs": []})
-	js.route("/auth/status", body={"auth_enabled": False})
+    js.route("board/list", body=[{"name": "Tasks", "task_count": 1}, {"name": "Sprint-2", "task_count": 0}])
+    js.route("tasks/list", body=[])
+    js.route("board-ref-manifest", body={"commits": [], "refs": []})
+    js.route("/auth/status", body={"auth_enabled": False})
 
 
 def test_init_waits_for_dom_content_loaded(js: JsPage) -> None:
-	_routes(js)
-	js.set_html(board_shell())
-	js.run("document.readyState = 'loading';")
-	js.load("board.js")
-	assert js.value("__preloaded") == []
-	js.run("document.readyState = 'complete';")
-	js.run("document.dispatchEvent(new Event('DOMContentLoaded'))")
-	assert len(js.value("__preloaded")) == 6
+    _routes(js)
+    js.set_html(board_shell())
+    js.run("document.readyState = 'loading';")
+    js.load("board.js")
+    assert js.value("__preloaded") == []
+    js.run("document.readyState = 'complete';")
+    js.run("document.dispatchEvent(new Event('DOMContentLoaded'))")
+    assert len(js.value("__preloaded")) == 6
 
 
 def test_selector_lists_boards_and_navigates(js: JsPage) -> None:
-	_routes(js)
-	js.set_html(board_shell())
-	js.load("board.js")
-	labels = js.value(
-		"Array.from(document.querySelectorAll('#board-board-select option')).map(function(el){return el.textContent;})"
-	)
-	assert labels == ["Tasks (1)", "Sprint-2 (0)"]
-	assert js.prop("#board-board-select", "value") == "Tasks"
-	js.dispatch("#board-board-select", "change")
-	assert js.value("__assigned") is None
-	js.set_prop("#board-board-select", "value", "Sprint-2")
-	js.dispatch("#board-board-select", "change")
-	assert js.value("__assigned") == "/project/demo/board/?b=Sprint-2"
+    _routes(js)
+    js.set_html(board_shell())
+    js.load("board.js")
+    labels = js.value(
+        "Array.from(document.querySelectorAll('#board-board-select option')).map(function(el){return el.textContent;})"
+    )
+    assert labels == ["Tasks (1)", "Sprint-2 (0)"]
+    assert js.prop("#board-board-select", "value") == "Tasks"
+    js.dispatch("#board-board-select", "change")
+    assert js.value("__assigned") is None
+    js.set_prop("#board-board-select", "value", "Sprint-2")
+    js.dispatch("#board-board-select", "change")
+    assert js.value("__assigned") == "/project/demo/board/?b=Sprint-2"
 
 
 def test_unknown_current_board_is_inserted(js: JsPage) -> None:
-	_routes(js)
-	js.set_html(board_shell(board="Other"))
-	js.load("board.js")
-	assert js.prop("#board-board-select", "value") == "Other"
-	assert js.text("#board-board-select option") == "Other"
+    _routes(js)
+    js.set_html(board_shell(board="Other"))
+    js.load("board.js")
+    assert js.prop("#board-board-select", "value") == "Other"
+    assert js.text("#board-board-select option") == "Other"
 
 
 def test_empty_or_failed_board_list_leaves_the_select(js: JsPage) -> None:
-	js.route("board/list", body=[])
-	js.route("tasks/list", body=[])
-	js.route("board-ref-manifest", body={"commits": [], "refs": []})
-	js.route("/auth/status", body={"auth_enabled": False})
-	js.set_html(board_shell())
-	js.load("board.js")
-	assert js.count("#board-board-select option") == 0
+    js.route("board/list", body=[])
+    js.route("tasks/list", body=[])
+    js.route("board-ref-manifest", body={"commits": [], "refs": []})
+    js.route("/auth/status", body={"auth_enabled": False})
+    js.set_html(board_shell())
+    js.load("board.js")
+    assert js.count("#board-board-select option") == 0
 
 
 def test_failed_board_list_leaves_the_select(js: JsPage) -> None:
-	js.route("board/list", error="offline")
-	js.route("tasks/list", body=[])
-	js.route("board-ref-manifest", body={"commits": [], "refs": []})
-	js.route("/auth/status", body={"auth_enabled": False})
-	js.set_html(board_shell())
-	js.load("board.js")
-	assert js.count("#board-board-select option") == 0
+    js.route("board/list", error="offline")
+    js.route("tasks/list", body=[])
+    js.route("board-ref-manifest", body={"commits": [], "refs": []})
+    js.route("/auth/status", body={"auth_enabled": False})
+    js.set_html(board_shell())
+    js.load("board.js")
+    assert js.count("#board-board-select option") == 0
 
 
 def test_sprint_name_increments_and_the_form_can_be_cancelled(js: JsPage) -> None:
-	_routes(js)
-	js.route("board/list", body=[{"name": "Sprint-2"}, {"name": "notes"}, {"name": "Sprint-9"}])
-	js.set_html(board_shell())
-	js.load("board.js")
-	js.click("#board-new-sprint-btn")
-	assert "d-none" not in js.classes("#board-new-form")
-	assert js.prop("#board-new-name", "value") == "Sprint-10"
-	js.click("#board-new-cancel")
-	assert "d-none" in js.classes("#board-new-form")
-	js.click("#board-new-board-btn")
-	assert js.prop("#board-new-name", "value") == ""
+    _routes(js)
+    js.route("board/list", body=[{"name": "Sprint-2"}, {"name": "notes"}, {"name": "Sprint-9"}])
+    js.set_html(board_shell())
+    js.load("board.js")
+    js.click("#board-new-sprint-btn")
+    assert "d-none" not in js.classes("#board-new-form")
+    assert js.prop("#board-new-name", "value") == "Sprint-10"
+    js.click("#board-new-cancel")
+    assert "d-none" in js.classes("#board-new-form")
+    js.click("#board-new-board-btn")
+    assert js.prop("#board-new-name", "value") == ""
 
 
 def test_create_board_navigates_or_shows_the_error(js: JsPage) -> None:
-	_routes(js)
-	js.route("board/create", status=200)
-	js.set_html(board_shell())
-	js.load("board.js")
-	js.click("#board-new-board-btn")
-	js.set_prop("#board-new-name", "value", "   ")
-	js.dispatch("#board-new-form", "submit")
-	assert requested(js, "board/create") is False
-	js.set_prop("#board-new-name", "value", "My Board")
-	js.dispatch("#board-new-form", "submit")
-	assert js.value("__assigned") == "/project/demo/board/?b=My-Board"
-	js.route("board/create", status=400, body={"detail": "taken"})
-	js.set_prop("#board-new-name", "value", "Tasks")
-	js.dispatch("#board-new-form", "submit")
-	assert js.text("#board-new-error") == "taken"
-	assert "d-none" not in js.classes("#board-new-error")
-	js.route("board/create", status=400, json_error=True)
-	js.dispatch("#board-new-form", "submit")
-	assert js.text("#board-new-error") == "Could not create board."
-	js.route("board/create", error="offline")
-	js.dispatch("#board-new-form", "submit")
-	assert js.text("#board-new-error") == "Could not create board."
+    _routes(js)
+    js.route("board/create", status=200)
+    js.set_html(board_shell())
+    js.load("board.js")
+    js.click("#board-new-board-btn")
+    js.set_prop("#board-new-name", "value", "   ")
+    js.dispatch("#board-new-form", "submit")
+    assert requested(js, "board/create") is False
+    js.set_prop("#board-new-name", "value", "My Board")
+    js.dispatch("#board-new-form", "submit")
+    assert js.value("__assigned") == "/project/demo/board/?b=My-Board"
+    js.route("board/create", status=400, body={"detail": "taken"})
+    js.set_prop("#board-new-name", "value", "Tasks")
+    js.dispatch("#board-new-form", "submit")
+    assert js.text("#board-new-error") == "taken"
+    assert "d-none" not in js.classes("#board-new-error")
+    js.route("board/create", status=400, json_error=True)
+    js.dispatch("#board-new-form", "submit")
+    assert js.text("#board-new-error") == "Could not create board."
+    js.route("board/create", error="offline")
+    js.dispatch("#board-new-form", "submit")
+    assert js.text("#board-new-error") == "Could not create board."
 
 
 def test_open_task_from_the_query_string(js: JsPage) -> None:
-	_routes(js)
-	js.route(
-		"tasks/list",
-		body=[{"ref": "refs/tags/tasks/task_abcd", "title": "Ship", "status": "TODO"}],
-	)
-	js.set_location("http://127.0.0.1/project/demo/board/?task=refs/tags/tasks/task_abcd")
-	js.set_html(board_shell())
-	js.load("board.js")
-	assert js.count(".board-card-popout") == 0
-	js.tick()
-	assert "board-card-popout" in js.classes(".board-card")
+    _routes(js)
+    js.route(
+        "tasks/list",
+        body=[{"ref": "refs/tags/tasks/task_abcd", "title": "Ship", "status": "TODO"}],
+    )
+    js.set_location("http://127.0.0.1/project/demo/board/?task=refs/tags/tasks/task_abcd")
+    js.set_html(board_shell())
+    js.load("board.js")
+    assert js.count(".board-card-popout") == 0
+    js.tick()
+    assert "board-card-popout" in js.classes(".board-card")
 
 
 def test_open_task_skips_a_card_that_is_already_open(js: JsPage) -> None:
-	_routes(js)
-	js.route("tasks/list", body=[{"ref": "t1", "title": "Ship", "status": "TODO"}])
-	js.set_location("http://127.0.0.1/project/demo/board/?task=t1")
-	js.set_html(board_shell())
-	js.load("board.js")
-	js.run("document.querySelector('.board-card').classList.add('board-card-popout')")
-	js.tick()
-	assert js.count(".board-card-popout-overlay") == 0
+    _routes(js)
+    js.route("tasks/list", body=[{"ref": "t1", "title": "Ship", "status": "TODO"}])
+    js.set_location("http://127.0.0.1/project/demo/board/?task=t1")
+    js.set_html(board_shell())
+    js.load("board.js")
+    js.run("document.querySelector('.board-card').classList.add('board-card-popout')")
+    js.tick()
+    assert js.count(".board-card-popout-overlay") == 0
 
 
 def test_missing_query_task_does_not_open_a_card(js: JsPage) -> None:
-	_routes(js)
-	js.set_html(board_shell())
-	js.load("board.js")
-	js.tick()
-	assert js.count(".board-card-popout-overlay") == 0
+    _routes(js)
+    js.set_html(board_shell())
+    js.load("board.js")
+    js.tick()
+    assert js.count(".board-card-popout-overlay") == 0
 
 
 def test_init_without_a_project_skips_the_network(js: JsPage) -> None:
-	js.set_html('<div class="board-columns"></div><select id="board-board-select"></select>')
-	js.set_location("http://127.0.0.1/")
-	js.load("board.js")
-	assert requested(js, "board/list") is False
-	assert requested(js, "tasks/list") is False
+    js.set_html('<div class="board-columns"></div><select id="board-board-select"></select>')
+    js.set_location("http://127.0.0.1/")
+    js.load("board.js")
+    assert requested(js, "board/list") is False
+    assert requested(js, "tasks/list") is False
 
 
 def test_sprint_suggestion_starts_at_one_before_the_list_loads(js: JsPage) -> None:
-	js.route("tasks/list", body=[])
-	js.route("board-ref-manifest", body={"commits": [], "refs": []})
-	js.route("/auth/status", body={"auth_enabled": False})
-	js.set_html(board_shell())
-	js.load("board.js")
-	js.click("#board-new-sprint-btn")
-	assert js.prop("#board-new-name", "value") == "Sprint-1"
+    js.route("tasks/list", body=[])
+    js.route("board-ref-manifest", body={"commits": [], "refs": []})
+    js.route("/auth/status", body={"auth_enabled": False})
+    js.set_html(board_shell())
+    js.load("board.js")
+    js.click("#board-new-sprint-btn")
+    assert js.prop("#board-new-name", "value") == "Sprint-1"
diff --git a/pygitweb/src/pygitweb/static_tests/test_diff-view.py b/pygitweb/src/pygitweb/static_tests/test_diff-view.py
index 939a5d9..cb2e12b 100644
--- a/pygitweb/src/pygitweb/static_tests/test_diff-view.py
+++ b/pygitweb/src/pygitweb/static_tests/test_diff-view.py
@@ -6,72 +6,72 @@ from pygitweb.static_tests.js_runtime import JsPage
 
 _DIFF = base64.b64encode(b"diff --git a/a b/a\n").decode("ascii")
 _SHELL = (
-	'<div id="d2h-container" data-diff-b64="{b64}" data-has-diff="{has}" data-empty-text="{empty}"></div>'
-	'<div id="d2h-output"></div><div id="d2h-empty"></div>'
+    '<div id="d2h-container" data-diff-b64="{b64}" data-has-diff="{has}" data-empty-text="{empty}"></div>'
+    '<div id="d2h-output"></div><div id="d2h-empty"></div>'
 )
 
 
 def _stub_diff2html(js: JsPage) -> None:
-	js.run(
-		"globalThis.Diff2HtmlUI = function(el, diff, config) {"
-		" this.el = el; this.diff = diff; this.config = config;"
-		" el.setAttribute('data-diff', diff);"
-		" el.setAttribute('data-format', config.outputFormat);"
-		" this.draw = function() { el.setAttribute('data-drawn', '1'); };"
-		" this.highlightCode = function() { el.setAttribute('data-hl', '1'); };"
-		"};"
-	)
+    js.run(
+        "globalThis.Diff2HtmlUI = function(el, diff, config) {"
+        " this.el = el; this.diff = diff; this.config = config;"
+        " el.setAttribute('data-diff', diff);"
+        " el.setAttribute('data-format', config.outputFormat);"
+        " this.draw = function() { el.setAttribute('data-drawn', '1'); };"
+        " this.highlightCode = function() { el.setAttribute('data-hl', '1'); };"
+        "};"
+    )
 
 
 def test_render_does_nothing_when_nodes_are_missing(js: JsPage) -> None:
-	js.set_html("<div></div>")
-	js.load("diff-view.js")
-	assert js.count("#d2h-empty") == 0
+    js.set_html("<div></div>")
+    js.load("diff-view.js")
+    assert js.count("#d2h-empty") == 0
 
 
 def test_render_shows_empty_text_when_there_is_no_diff(js: JsPage) -> None:
-	js.set_html(_SHELL.format(b64="", has="false", empty="No changes"))
-	js.load("diff-view.js")
-	assert js.text("#d2h-empty") == "No changes"
-	assert js.value("document.querySelector('#d2h-empty').style.display") == "block"
+    js.set_html(_SHELL.format(b64="", has="false", empty="No changes"))
+    js.load("diff-view.js")
+    assert js.text("#d2h-empty") == "No changes"
+    assert js.value("document.querySelector('#d2h-empty').style.display") == "block"
 
 
 def test_render_shows_empty_text_when_base64_is_missing(js: JsPage) -> None:
-	js.set_html(_SHELL.format(b64="", has="true", empty="Empty"))
-	js.load("diff-view.js")
-	assert js.text("#d2h-empty") == "Empty"
+    js.set_html(_SHELL.format(b64="", has="true", empty="Empty"))
+    js.load("diff-view.js")
+    assert js.text("#d2h-empty") == "Empty"
 
 
 def test_render_draws_a_decoded_diff(js: JsPage) -> None:
-	_stub_diff2html(js)
-	js.set_html(_SHELL.format(b64=_DIFF, has="true", empty=""))
-	js.load("diff-view.js")
-	assert js.attr("#d2h-output", "data-drawn") == "1"
-	assert js.attr("#d2h-output", "data-hl") == "1"
-	assert "diff --git" in (js.attr("#d2h-output", "data-diff") or "")
-	assert js.attr("#d2h-output", "data-format") == "side-by-side"
+    _stub_diff2html(js)
+    js.set_html(_SHELL.format(b64=_DIFF, has="true", empty=""))
+    js.load("diff-view.js")
+    assert js.attr("#d2h-output", "data-drawn") == "1"
+    assert js.attr("#d2h-output", "data-hl") == "1"
+    assert "diff --git" in (js.attr("#d2h-output", "data-diff") or "")
+    assert js.attr("#d2h-output", "data-format") == "side-by-side"
 
 
 def test_render_treats_an_empty_decode_as_no_diff(js: JsPage) -> None:
-	js.run("globalThis.atob = function() { return ''; };")
-	js.set_html(_SHELL.format(b64=_DIFF, has="true", empty="Blank"))
-	js.load("diff-view.js")
-	assert js.text("#d2h-empty") == "Blank"
+    js.run("globalThis.atob = function() { return ''; };")
+    js.set_html(_SHELL.format(b64=_DIFF, has="true", empty="Blank"))
+    js.load("diff-view.js")
+    assert js.text("#d2h-empty") == "Blank"
 
 
 def test_render_uses_the_raw_string_without_textdecoder(js: JsPage) -> None:
-	_stub_diff2html(js)
-	js.run("globalThis.__td = globalThis.TextDecoder; delete globalThis.TextDecoder;")
-	js.set_html(_SHELL.format(b64=_DIFF, has="true", empty=""))
-	js.load("diff-view.js")
-	js.run("globalThis.TextDecoder = globalThis.__td;")
-	assert js.attr("#d2h-output", "data-drawn") == "1"
+    _stub_diff2html(js)
+    js.run("globalThis.__td = globalThis.TextDecoder; delete globalThis.TextDecoder;")
+    js.set_html(_SHELL.format(b64=_DIFF, has="true", empty=""))
+    js.load("diff-view.js")
+    js.run("globalThis.TextDecoder = globalThis.__td;")
+    assert js.attr("#d2h-output", "data-drawn") == "1"
 
 
 def test_render_escapes_a_decoder_error(js: JsPage) -> None:
-	js.set_html(_SHELL.format(b64="!!!", has="true", empty=""))
-	js.load("diff-view.js")
-	html = js.prop("#d2h-output", "innerHTML")
-	assert "Failed to render diff" in html
-	assert "<script" not in html
-	assert "Invalid" in html or "not correctly encoded" in html
+    js.set_html(_SHELL.format(b64="!!!", has="true", empty=""))
+    js.load("diff-view.js")
+    html = js.prop("#d2h-output", "innerHTML")
+    assert "Failed to render diff" in html
+    assert "<script" not in html
+    assert "Invalid" in html or "not correctly encoded" in html
diff --git a/pygitweb/src/pygitweb/static_tests/test_dom.py b/pygitweb/src/pygitweb/static_tests/test_dom.py
index a83fa07..ea1f397 100644
--- a/pygitweb/src/pygitweb/static_tests/test_dom.py
+++ b/pygitweb/src/pygitweb/static_tests/test_dom.py
@@ -4,11 +4,11 @@ from pygitweb.static_tests.js_runtime import JsPage
 
 
 def test_escape_html_replaces_special_characters(js: JsPage) -> None:
-	js.load("dom.js")
-	assert js.call("dom.js", "escapeHtml", """a&<>"'""") == "a&amp;&lt;&gt;&quot;&#39;"
+    js.load("dom.js")
+    assert js.call("dom.js", "escapeHtml", """a&<>"'""") == "a&amp;&lt;&gt;&quot;&#39;"
 
 
 def test_escape_html_stringifies_non_strings(js: JsPage) -> None:
-	js.load("dom.js")
-	assert js.call("dom.js", "escapeHtml", None) == "null"
-	assert js.call("dom.js", "escapeHtml", 12) == "12"
+    js.load("dom.js")
+    assert js.call("dom.js", "escapeHtml", None) == "null"
+    assert js.call("dom.js", "escapeHtml", 12) == "12"
diff --git a/pygitweb/src/pygitweb/static_tests/test_hook-install.py b/pygitweb/src/pygitweb/static_tests/test_hook-install.py
index 964d851..1c1f9f2 100644
--- a/pygitweb/src/pygitweb/static_tests/test_hook-install.py
+++ b/pygitweb/src/pygitweb/static_tests/test_hook-install.py
@@ -4,86 +4,86 @@ from pygitweb.static_tests.js_runtime import JsPage
 from pygitweb.static_tests.pages import requested
 
 _HOOKS = (
-	'<button class="hook-toggle" data-project="demo" data-sample="commit-msg" data-op="uninstall">x</button>'
-	'<button class="hook-install-btn" data-project="demo" data-op="install">install</button>'
-	'<select id="hook-install-select"><option value="pre-commit">pre</option></select>'
-	'<button id="plain">plain</button>'
-	'<button class="hook-toggle" id="incomplete">bad</button>'
-	'<button class="hook-install-btn" id="install-bare">bare</button>'
+    '<button class="hook-toggle" data-project="demo" data-sample="commit-msg" data-op="uninstall">x</button>'
+    '<button class="hook-install-btn" data-project="demo" data-op="install">install</button>'
+    '<select id="hook-install-select"><option value="pre-commit">pre</option></select>'
+    '<button id="plain">plain</button>'
+    '<button class="hook-toggle" id="incomplete">bad</button>'
+    '<button class="hook-install-btn" id="install-bare">bare</button>'
 )
 
 
 def test_click_outside_a_hook_control_is_ignored(js: JsPage) -> None:
-	js.set_html(_HOOKS)
-	js.load("hook-install.js")
-	js.click("#plain")
-	assert js.value("__alerts") == []
-	assert requested(js, "/hook?") is False
+    js.set_html(_HOOKS)
+    js.load("hook-install.js")
+    js.click("#plain")
+    assert js.value("__alerts") == []
+    assert requested(js, "/hook?") is False
 
 
 def test_incomplete_toggle_does_not_post(js: JsPage) -> None:
-	js.set_html(_HOOKS)
-	js.load("hook-install.js")
-	js.click("#incomplete")
-	assert requested(js, "/hook?") is False
-	assert js.prop("#incomplete", "disabled") is False
+    js.set_html(_HOOKS)
+    js.load("hook-install.js")
+    js.click("#incomplete")
+    assert requested(js, "/hook?") is False
+    assert js.prop("#incomplete", "disabled") is False
 
 
 def test_uninstall_reloads_after_a_successful_post(js: JsPage) -> None:
-	js.route("/hook?", status=200, text="ok")
-	js.set_html(_HOOKS)
-	js.load("hook-install.js")
-	js.click(".hook-toggle[data-sample]")
-	urls = [item["url"] for item in js.requests() if "/hook?" in str(item["url"])]
-	assert urls[0].endswith("name=commit-msg&op=uninstall")
-	assert js.value("__reloaded") is True
-	assert js.prop(".hook-toggle[data-sample]", "disabled") is True
+    js.route("/hook?", status=200, text="ok")
+    js.set_html(_HOOKS)
+    js.load("hook-install.js")
+    js.click(".hook-toggle[data-sample]")
+    urls = [item["url"] for item in js.requests() if "/hook?" in str(item["url"])]
+    assert urls[0].endswith("name=commit-msg&op=uninstall")
+    assert js.value("__reloaded") is True
+    assert js.prop(".hook-toggle[data-sample]", "disabled") is True
 
 
 def test_uninstall_alerts_and_reenables_when_the_post_fails(js: JsPage) -> None:
-	js.route("/hook?", status=500, text="nope")
-	js.set_html(_HOOKS)
-	js.load("hook-install.js")
-	js.click(".hook-toggle[data-sample]")
-	alerts = js.value("__alerts")
-	assert "uninstall failed (500)" in alerts[0]
-	assert "nope" in alerts[0]
-	assert js.prop(".hook-toggle[data-sample]", "disabled") is False
-	assert js.value("__reloaded") is False
+    js.route("/hook?", status=500, text="nope")
+    js.set_html(_HOOKS)
+    js.load("hook-install.js")
+    js.click(".hook-toggle[data-sample]")
+    alerts = js.value("__alerts")
+    assert "uninstall failed (500)" in alerts[0]
+    assert "nope" in alerts[0]
+    assert js.prop(".hook-toggle[data-sample]", "disabled") is False
+    assert js.value("__reloaded") is False
 
 
 def test_uninstall_alerts_when_fetch_rejects(js: JsPage) -> None:
-	js.route("/hook?", error="offline")
-	js.set_html(_HOOKS)
-	js.load("hook-install.js")
-	js.click(".hook-toggle[data-sample]")
-	assert "offline" in js.value("__alerts")[0]
-	assert js.prop(".hook-toggle[data-sample]", "disabled") is False
+    js.route("/hook?", error="offline")
+    js.set_html(_HOOKS)
+    js.load("hook-install.js")
+    js.click(".hook-toggle[data-sample]")
+    assert "offline" in js.value("__alerts")[0]
+    assert js.prop(".hook-toggle[data-sample]", "disabled") is False
 
 
 def test_install_posts_the_selected_sample(js: JsPage) -> None:
-	js.route("/hook?", status=200, text="ok")
-	js.set_html(_HOOKS)
-	js.load("hook-install.js")
-	js.click(".hook-install-btn[data-project]")
-	urls = [item["url"] for item in js.requests()]
-	assert any("name=pre-commit&op=install" in url for url in urls)
-	assert js.value("__reloaded") is True
+    js.route("/hook?", status=200, text="ok")
+    js.set_html(_HOOKS)
+    js.load("hook-install.js")
+    js.click(".hook-install-btn[data-project]")
+    urls = [item["url"] for item in js.requests()]
+    assert any("name=pre-commit&op=install" in url for url in urls)
+    assert js.value("__reloaded") is True
 
 
 def test_install_without_a_select_does_not_post(js: JsPage) -> None:
-	html = _HOOKS.replace(
-		'<select id="hook-install-select"><option value="pre-commit">pre</option></select>',
-		'<div id="hook-install-select"></div>',
-	)
-	js.set_html(html)
-	js.load("hook-install.js")
-	js.click(".hook-install-btn[data-project]")
-	assert requested(js, "/hook?") is False
+    html = _HOOKS.replace(
+        '<select id="hook-install-select"><option value="pre-commit">pre</option></select>',
+        '<div id="hook-install-select"></div>',
+    )
+    js.set_html(html)
+    js.load("hook-install.js")
+    js.click(".hook-install-btn[data-project]")
+    assert requested(js, "/hook?") is False
 
 
 def test_install_without_a_project_does_not_post(js: JsPage) -> None:
-	js.set_html(_HOOKS)
-	js.load("hook-install.js")
-	js.click("#install-bare")
-	assert requested(js, "/hook?") is False
+    js.set_html(_HOOKS)
+    js.load("hook-install.js")
+    js.click("#install-bare")
+    assert requested(js, "/hook?") is False
diff --git a/pygitweb/src/pygitweb/static_tests/test_login.py b/pygitweb/src/pygitweb/static_tests/test_login.py
index 0c4c412..520a963 100644
--- a/pygitweb/src/pygitweb/static_tests/test_login.py
+++ b/pygitweb/src/pygitweb/static_tests/test_login.py
@@ -3,34 +3,34 @@ from __future__ import annotations
 from pygitweb.static_tests.js_runtime import JsPage
 
 _FORM = (
-	'<input type="password" id="login_password">'
-	'<button type="button" data-password-toggle="login_password" aria-pressed="false"'
-	' aria-label="Show password" title="Show password">'
-	'<svg class="icon icon-toggle-show"></svg>'
-	'<svg class="icon icon-toggle-hide d-none"></svg>'
-	"</button>"
+    '<input type="password" id="login_password">'
+    '<button type="button" data-password-toggle="login_password" aria-pressed="false"'
+    ' aria-label="Show password" title="Show password">'
+    '<svg class="icon icon-toggle-show"></svg>'
+    '<svg class="icon icon-toggle-hide d-none"></svg>'
+    "</button>"
 )
 
 
 def test_password_toggle_reveals_and_hides(js: JsPage) -> None:
-	js.set_html(_FORM)
-	js.load("login.js")
-	js.click("[data-password-toggle]")
-	assert js.prop("#login_password", "type") == "text"
-	assert js.attr("[data-password-toggle]", "aria-pressed") == "true"
-	assert js.attr("[data-password-toggle]", "aria-label") == "Hide password"
-	assert "d-none" in js.classes(".icon-toggle-show")
-	assert "d-none" not in js.classes(".icon-toggle-hide")
-	js.click("[data-password-toggle]")
-	assert js.prop("#login_password", "type") == "password"
-	assert js.attr("[data-password-toggle]", "aria-pressed") == "false"
-	assert js.attr("[data-password-toggle]", "title") == "Show password"
-	assert "d-none" not in js.classes(".icon-toggle-show")
-	assert "d-none" in js.classes(".icon-toggle-hide")
+    js.set_html(_FORM)
+    js.load("login.js")
+    js.click("[data-password-toggle]")
+    assert js.prop("#login_password", "type") == "text"
+    assert js.attr("[data-password-toggle]", "aria-pressed") == "true"
+    assert js.attr("[data-password-toggle]", "aria-label") == "Hide password"
+    assert "d-none" in js.classes(".icon-toggle-show")
+    assert "d-none" not in js.classes(".icon-toggle-hide")
+    js.click("[data-password-toggle]")
+    assert js.prop("#login_password", "type") == "password"
+    assert js.attr("[data-password-toggle]", "aria-pressed") == "false"
+    assert js.attr("[data-password-toggle]", "title") == "Show password"
+    assert "d-none" not in js.classes(".icon-toggle-show")
+    assert "d-none" in js.classes(".icon-toggle-hide")
 
 
 def test_password_toggle_ignores_a_missing_field(js: JsPage) -> None:
-	js.set_html('<button type="button" data-password-toggle="missing">Show</button>')
-	js.load("login.js")
-	js.click("[data-password-toggle]")
-	assert js.attr("[data-password-toggle]", "aria-pressed") is None
+    js.set_html('<button type="button" data-password-toggle="missing">Show</button>')
+    js.load("login.js")
+    js.click("[data-password-toggle]")
+    assert js.attr("[data-password-toggle]", "aria-pressed") is None
diff --git a/pygitweb/src/pygitweb/static_tests/test_navbar-auth.py b/pygitweb/src/pygitweb/static_tests/test_navbar-auth.py
index 8090f5e..63e2116 100644
--- a/pygitweb/src/pygitweb/static_tests/test_navbar-auth.py
+++ b/pygitweb/src/pygitweb/static_tests/test_navbar-auth.py
@@ -3,129 +3,129 @@ from __future__ import annotations
 from pygitweb.static_tests.js_runtime import JsPage
 
 _NAV = (
-	'<div id="navbar-user">'
-	'<a id="navbar-user-link" href="/login"></a>'
-	'<span id="navbar-user-avatar" class="bg-secondary"></span>'
-	'<div class="navbar-user-name"></div>'
-	'<div class="navbar-user-meta"></div>'
-	"</div>"
+    '<div id="navbar-user">'
+    '<a id="navbar-user-link" href="/login"></a>'
+    '<span id="navbar-user-avatar" class="bg-secondary"></span>'
+    '<div class="navbar-user-name"></div>'
+    '<div class="navbar-user-meta"></div>'
+    "</div>"
 )
 
 
 def test_missing_navbar_returns_immediately(js: JsPage) -> None:
-	js.set_html("<div></div>")
-	js.load("navbar-auth.js")
-	assert js.count("#navbar-user") == 0
+    js.set_html("<div></div>")
+    js.load("navbar-auth.js")
+    assert js.count("#navbar-user") == 0
 
 
 def test_missing_child_nodes_return_immediately(js: JsPage) -> None:
-	js.set_html('<div id="navbar-user"></div>')
-	js.load("navbar-auth.js")
-	assert js.count(".navbar-user-name") == 0
+    js.set_html('<div id="navbar-user"></div>')
+    js.load("navbar-auth.js")
+    assert js.count(".navbar-user-name") == 0
 
 
 def test_disabled_auth_renders_a_guest_who_cannot_sign_in(js: JsPage) -> None:
-	js.route("/auth/status", body={"auth_enabled": False})
-	js.set_html(_NAV)
-	js.load("navbar-auth.js")
-	assert js.text(".navbar-user-name") == "Guest"
-	assert js.text(".navbar-user-meta") == "Sign-in not used"
-	assert js.attr("#navbar-user-link", "href") is None
-	assert js.attr("#navbar-user-link", "aria-disabled") == "true"
-	assert "pe-none" in js.classes("#navbar-user-link")
+    js.route("/auth/status", body={"auth_enabled": False})
+    js.set_html(_NAV)
+    js.load("navbar-auth.js")
+    assert js.text(".navbar-user-name") == "Guest"
+    assert js.text(".navbar-user-meta") == "Sign-in not used"
+    assert js.attr("#navbar-user-link", "href") is None
+    assert js.attr("#navbar-user-link", "aria-disabled") == "true"
+    assert "pe-none" in js.classes("#navbar-user-link")
 
 
 def test_admin_without_gravatar_uses_the_admin_avatar(js: JsPage) -> None:
-	js.route("/auth/status", body={"auth_enabled": True, "username": "Admin"})
-	js.set_html(_NAV)
-	js.load("navbar-auth.js")
-	assert js.attr("#navbar-user", "data-logged-in") == "true"
-	assert js.prop("#navbar-user-link", "href").endswith("/user")
-	assert js.text(".navbar-user-name") == "Admin"
-	assert js.text(".navbar-user-meta") == "Signed in"
-	assert "bg-primary" in js.classes("#navbar-user-avatar")
-	assert "svg" in js.prop("#navbar-user-avatar", "innerHTML")
+    js.route("/auth/status", body={"auth_enabled": True, "username": "Admin"})
+    js.set_html(_NAV)
+    js.load("navbar-auth.js")
+    assert js.attr("#navbar-user", "data-logged-in") == "true"
+    assert js.prop("#navbar-user-link", "href").endswith("/user")
+    assert js.text(".navbar-user-name") == "Admin"
+    assert js.text(".navbar-user-meta") == "Signed in"
+    assert "bg-primary" in js.classes("#navbar-user-avatar")
+    assert "svg" in js.prop("#navbar-user-avatar", "innerHTML")
 
 
 def test_user_gravatar_falls_back_when_the_image_errors(js: JsPage) -> None:
-	js.route(
-		"/auth/status",
-		body={"auth_enabled": True, "username": "ada", "gravatar_url": "http://img/a"},
-	)
-	js.set_html(_NAV)
-	js.load("navbar-auth.js")
-	assert js.prop("#navbar-user-avatar img", "src") == "http://img/a"
-	js.run("document.querySelector('#navbar-user-avatar img').onerror()")
-	assert js.count("#navbar-user-avatar img") == 0
-	assert "bg-teal" in js.classes("#navbar-user-avatar")
-	assert "bg-primary" not in js.classes("#navbar-user-avatar")
+    js.route(
+        "/auth/status",
+        body={"auth_enabled": True, "username": "ada", "gravatar_url": "http://img/a"},
+    )
+    js.set_html(_NAV)
+    js.load("navbar-auth.js")
+    assert js.prop("#navbar-user-avatar img", "src") == "http://img/a"
+    js.run("document.querySelector('#navbar-user-avatar img').onerror()")
+    assert js.count("#navbar-user-avatar img") == 0
+    assert "bg-teal" in js.classes("#navbar-user-avatar")
+    assert "bg-primary" not in js.classes("#navbar-user-avatar")
 
 
 def test_signed_out_visitor_links_to_login(js: JsPage) -> None:
-	js.route("/auth/status", body={"auth_enabled": True, "username": ""})
-	js.set_html(_NAV)
-	js.load("navbar-auth.js")
-	assert js.attr("#navbar-user", "data-logged-in") == "false"
-	assert js.prop("#navbar-user-link", "href").endswith("/login")
-	assert js.text(".navbar-user-meta") == "Not signed in"
-	assert "bg-secondary" in js.classes("#navbar-user-avatar")
+    js.route("/auth/status", body={"auth_enabled": True, "username": ""})
+    js.set_html(_NAV)
+    js.load("navbar-auth.js")
+    assert js.attr("#navbar-user", "data-logged-in") == "false"
+    assert js.prop("#navbar-user-link", "href").endswith("/login")
+    assert js.text(".navbar-user-meta") == "Not signed in"
+    assert "bg-secondary" in js.classes("#navbar-user-avatar")
 
 
 def test_signed_in_user_gets_profile_and_sign_out(js: JsPage) -> None:
-	js.route("/auth/status", body={"auth_enabled": True, "username": "ada"})
-	js.set_html(
-		'<div id="navbar-user">'
-		'<a id="navbar-user-link" href="/login"></a>'
-		'<span id="navbar-user-avatar" class="bg-secondary"></span>'
-		'<div class="navbar-user-name"></div>'
-		'<div class="navbar-user-meta"></div>'
-		'<div id="navbar-user-menu" class="d-none">'
-		'<a id="navbar-user-profile" href="/user"></a>'
-		'<a id="navbar-user-logout" href="/logout"></a>'
-		"</div></div>"
-	)
-	js.load("navbar-auth.js")
-	assert "dropdown" in js.classes("#navbar-user")
-	assert "dropup" in js.classes("#navbar-user")
-	assert js.attr("#navbar-user-link", "data-bs-toggle") == "dropdown"
-	assert js.prop("#navbar-user-profile", "href").endswith("/user")
-	assert "next=/" in js.prop("#navbar-user-logout", "href")
-	assert "d-none" not in js.classes("#navbar-user-menu")
+    js.route("/auth/status", body={"auth_enabled": True, "username": "ada"})
+    js.set_html(
+        '<div id="navbar-user">'
+        '<a id="navbar-user-link" href="/login"></a>'
+        '<span id="navbar-user-avatar" class="bg-secondary"></span>'
+        '<div class="navbar-user-name"></div>'
+        '<div class="navbar-user-meta"></div>'
+        '<div id="navbar-user-menu" class="d-none">'
+        '<a id="navbar-user-profile" href="/user"></a>'
+        '<a id="navbar-user-logout" href="/logout"></a>'
+        "</div></div>"
+    )
+    js.load("navbar-auth.js")
+    assert "dropdown" in js.classes("#navbar-user")
+    assert "dropup" in js.classes("#navbar-user")
+    assert js.attr("#navbar-user-link", "data-bs-toggle") == "dropdown"
+    assert js.prop("#navbar-user-profile", "href").endswith("/user")
+    assert "next=/" in js.prop("#navbar-user-logout", "href")
+    assert "d-none" not in js.classes("#navbar-user-menu")
 
 
 def test_top_navbar_menu_opens_downward(js: JsPage) -> None:
-	js.route("/auth/status", body={"auth_enabled": True, "username": "ada"})
-	js.set_html(
-		'<div id="navbar-user" data-dropdown="down" data-login-href="/login?next=/project/demo/blog/">'
-		'<a id="navbar-user-link" href="/login?next=/project/demo/blog/"></a>'
-		'<span id="navbar-user-avatar" class="bg-secondary"></span>'
-		'<div class="navbar-user-name"></div>'
-		'<div class="navbar-user-meta"></div>'
-		'<div id="navbar-user-menu" class="d-none"></div>'
-		"</div>"
-	)
-	js.load("navbar-auth.js")
-	assert "dropdown" in js.classes("#navbar-user")
-	assert "dropup" not in js.classes("#navbar-user")
+    js.route("/auth/status", body={"auth_enabled": True, "username": "ada"})
+    js.set_html(
+        '<div id="navbar-user" data-dropdown="down" data-login-href="/login?next=/project/demo/blog/">'
+        '<a id="navbar-user-link" href="/login?next=/project/demo/blog/"></a>'
+        '<span id="navbar-user-avatar" class="bg-secondary"></span>'
+        '<div class="navbar-user-name"></div>'
+        '<div class="navbar-user-meta"></div>'
+        '<div id="navbar-user-menu" class="d-none"></div>'
+        "</div>"
+    )
+    js.load("navbar-auth.js")
+    assert "dropdown" in js.classes("#navbar-user")
+    assert "dropup" not in js.classes("#navbar-user")
 
 
 def test_signed_out_keeps_a_login_return_path(js: JsPage) -> None:
-	js.route("/auth/status", body={"auth_enabled": True, "username": ""})
-	js.set_html(
-		'<div id="navbar-user" data-login-href="/login?next=/project/demo/blog/">'
-		'<a id="navbar-user-link" href="/login"></a>'
-		'<span id="navbar-user-avatar" class="bg-secondary"></span>'
-		'<div class="navbar-user-name"></div>'
-		'<div class="navbar-user-meta"></div>'
-		"</div>"
-	)
-	js.load("navbar-auth.js")
-	assert js.prop("#navbar-user-link", "href").endswith("/login?next=/project/demo/blog/")
+    js.route("/auth/status", body={"auth_enabled": True, "username": ""})
+    js.set_html(
+        '<div id="navbar-user" data-login-href="/login?next=/project/demo/blog/">'
+        '<a id="navbar-user-link" href="/login"></a>'
+        '<span id="navbar-user-avatar" class="bg-secondary"></span>'
+        '<div class="navbar-user-name"></div>'
+        '<div class="navbar-user-meta"></div>'
+        "</div>"
+    )
+    js.load("navbar-auth.js")
+    assert js.prop("#navbar-user-link", "href").endswith("/login?next=/project/demo/blog/")
 
 
 def test_status_fetch_failure_sends_the_link_to_login(js: JsPage) -> None:
-	js.route("/auth/status", error="offline")
-	js.set_html(_NAV)
-	js.load("navbar-auth.js")
-	assert js.prop("#navbar-user-link", "href").endswith("/login")
-	assert "bg-secondary" in js.classes("#navbar-user-avatar")
+    js.route("/auth/status", error="offline")
+    js.set_html(_NAV)
+    js.load("navbar-auth.js")
+    assert js.prop("#navbar-user-link", "href").endswith("/login")
+    assert "bg-secondary" in js.classes("#navbar-user-avatar")
diff --git a/pygitweb/src/pygitweb/static_tests/test_readme-render.py b/pygitweb/src/pygitweb/static_tests/test_readme-render.py
index 4e193dd..496124f 100644
--- a/pygitweb/src/pygitweb/static_tests/test_readme-render.py
+++ b/pygitweb/src/pygitweb/static_tests/test_readme-render.py
@@ -9,111 +9,111 @@ _B64 = base64.b64encode(_README.encode()).decode("ascii")
 
 
 def _page(b64: str, *, blob_base: str | None = "/blob?f=", blob_dir: str = "docs") -> str:
-	attrs = f'id="readme-container" data-readme-b64="{b64}"'
-	if blob_base is not None:
-		attrs += f' data-blob-base="{blob_base}" data-blob-dir="{blob_dir}"'
-	return f'<div class="page"></div><div {attrs}></div>'
+    attrs = f'id="readme-container" data-readme-b64="{b64}"'
+    if blob_base is not None:
+        attrs += f' data-blob-base="{blob_base}" data-blob-dir="{blob_dir}"'
+    return f'<div class="page"></div><div {attrs}></div>'
 
 
 def test_missing_container_is_ignored(js: JsPage) -> None:
-	js.set_html('<div class="page"></div>')
-	js.load("readme-render.js")
-	assert js.count(".readme-toc") == 0
+    js.set_html('<div class="page"></div>')
+    js.load("readme-render.js")
+    assert js.count(".readme-toc") == 0
 
 
 def test_empty_payload_is_ignored(js: JsPage) -> None:
-	js.set_html('<div class="page"></div><div id="readme-container"></div>')
-	js.load("readme-render.js")
-	assert js.count("h1") == 0
+    js.set_html('<div class="page"></div><div id="readme-container"></div>')
+    js.load("readme-render.js")
+    assert js.count("h1") == 0
 
 
 def test_render_builds_headings_links_and_a_hidden_toc(js: JsPage) -> None:
-	js.set_location("http://127.0.0.1/readme")
-	js.set_html(_page(_B64))
-	js.load("readme-render.js")
-	assert js.text("h1") == "Demo"
-	assert js.count("strong") == 1
-	file_href = js.attr('a[href*="blob"]', "href") or ""
-	assert "docs%2Fa.md" in file_href
-	assert js.attr('a[href="https://example.com"]', "href") == "https://example.com"
-	assert js.attr('a[href="/x"]', "href") == "/x"
-	assert js.count(".readme-section-anchor") == 1
-	assert js.prop(".readme-toc-mount", "hidden") is True
-	assert "readme-toc-active" in js.classes(".readme-toc-item")
+    js.set_location("http://127.0.0.1/readme")
+    js.set_html(_page(_B64))
+    js.load("readme-render.js")
+    assert js.text("h1") == "Demo"
+    assert js.count("strong") == 1
+    file_href = js.attr('a[href*="blob"]', "href") or ""
+    assert "docs%2Fa.md" in file_href
+    assert js.attr('a[href="https://example.com"]', "href") == "https://example.com"
+    assert js.attr('a[href="/x"]', "href") == "/x"
+    assert js.count(".readme-section-anchor") == 1
+    assert js.prop(".readme-toc-mount", "hidden") is True
+    assert "readme-toc-active" in js.classes(".readme-toc-item")
 
 
 def test_relative_links_stay_put_without_a_blob_base(js: JsPage) -> None:
-	js.set_html(_page(_B64, blob_base=None))
-	js.load("readme-render.js")
-	assert js.attr('a[href="./a.md"]', "href") == "./a.md"
+    js.set_html(_page(_B64, blob_base=None))
+    js.load("readme-render.js")
+    assert js.attr('a[href="./a.md"]', "href") == "./a.md"
 
 
 def test_invalid_base64_is_swallowed(js: JsPage) -> None:
-	js.set_html(_page("!!!"))
-	js.load("readme-render.js")
-	assert js.count("h1") == 0
+    js.set_html(_page("!!!"))
+    js.load("readme-render.js")
+    assert js.count("h1") == 0
 
 
 def test_section_anchor_copies_the_url(js: JsPage) -> None:
-	js.set_location("http://127.0.0.1/readme?x=1")
-	js.set_html(_page(_B64))
-	js.load("readme-render.js")
-	js.click(".readme-section-anchor")
-	assert js.value("__copied") == js.value("location.origin") + "/readme?x=1#demo"
+    js.set_location("http://127.0.0.1/readme?x=1")
+    js.set_html(_page(_B64))
+    js.load("readme-render.js")
+    js.click(".readme-section-anchor")
+    assert js.value("__copied") == js.value("location.origin") + "/readme?x=1#demo"
 
 
 def test_section_anchor_sets_the_hash_without_clipboard(js: JsPage) -> None:
-	js.set_location("http://127.0.0.1/readme")
-	js.set_html(_page(_B64))
-	js.load("readme-render.js")
-	js.run("navigator.clipboard = null;")
-	js.click(".readme-section-anchor")
-	assert js.value("location.hash") == "#demo"
+    js.set_location("http://127.0.0.1/readme")
+    js.set_html(_page(_B64))
+    js.load("readme-render.js")
+    js.run("navigator.clipboard = null;")
+    js.click(".readme-section-anchor")
+    assert js.value("location.hash") == "#demo"
 
 
 def test_toc_link_scrolls_and_updates_the_hash(js: JsPage) -> None:
-	js.set_location("http://127.0.0.1/readme")
-	js.set_html(_page(_B64))
-	js.load("readme-render.js")
-	js.click(".readme-toc-link")
-	assert js.value("location.hash") == "#demo"
-	assert js.value("__lastScrolled && __lastScrolled.id") == "demo"
+    js.set_location("http://127.0.0.1/readme")
+    js.set_html(_page(_B64))
+    js.load("readme-render.js")
+    js.click(".readme-toc-link")
+    assert js.value("location.hash") == "#demo"
+    assert js.value("__lastScrolled && __lastScrolled.id") == "demo"
 
 
 def test_scroll_spy_shows_the_toc_and_marks_the_visible_heading(js: JsPage) -> None:
-	js.set_html(_page(_B64))
-	js.load("readme-render.js")
-	js.run("document.getElementById('readme-container')._rectTop = -40;")
-	js.run("document.querySelector('h1')._rectTop = -40;")
-	js.run("window.scrollY = 0;")
-	js.dispatch_window("scroll")
-	js.dispatch_window("scroll")
-	js.dispatch_window("resize")
-	js.tick()
-	assert js.prop(".readme-toc-mount", "hidden") is False
-	assert "readme-toc-active" in js.classes(".readme-toc-item")
+    js.set_html(_page(_B64))
+    js.load("readme-render.js")
+    js.run("document.getElementById('readme-container')._rectTop = -40;")
+    js.run("document.querySelector('h1')._rectTop = -40;")
+    js.run("window.scrollY = 0;")
+    js.dispatch_window("scroll")
+    js.dispatch_window("scroll")
+    js.dispatch_window("resize")
+    js.tick()
+    assert js.prop(".readme-toc-mount", "hidden") is False
+    assert "readme-toc-active" in js.classes(".readme-toc-item")
 
 
 def test_generated_heading_ids_are_unique_and_protocol_relative_links_stay(js: JsPage) -> None:
-	js.run(
-		"var __real = showdown.Converter;"
-		"showdown.Converter = function() { return { makeHtml: function() {"
-		" return '<h1>Demo</h1><h1>Demo</h1><h2>!!!</h2><p>Hi</p>"
-		'<a href="./a.md">f</a><a href="//cdn.example/a">c</a>\'; } }; };'
-	)
-	js.set_html(_page(_B64, blob_dir=""))
-	js.load("readme-render.js")
-	js.run("showdown.Converter = __real;")
-	assert js.attr("h1", "id") == "demo"
-	assert js.count("#demo-1") == 1
-	assert js.count("#section") == 1
-	assert "a.md" in (js.attr('a[href*="a.md"]', "href") or "")
-	assert js.attr('a[href="//cdn.example/a"]', "href") == "//cdn.example/a"
-	assert js.count(".page .readme-toc") == 1
+    js.run(
+        "var __real = showdown.Converter;"
+        "showdown.Converter = function() { return { makeHtml: function() {"
+        " return '<h1>Demo</h1><h1>Demo</h1><h2>!!!</h2><p>Hi</p>"
+        '<a href="./a.md">f</a><a href="//cdn.example/a">c</a>\'; } }; };'
+    )
+    js.set_html(_page(_B64, blob_dir=""))
+    js.load("readme-render.js")
+    js.run("showdown.Converter = __real;")
+    assert js.attr("h1", "id") == "demo"
+    assert js.count("#demo-1") == 1
+    assert js.count("#section") == 1
+    assert "a.md" in (js.attr('a[href*="a.md"]', "href") or "")
+    assert js.attr('a[href="//cdn.example/a"]', "href") == "//cdn.example/a"
+    assert js.count(".page .readme-toc") == 1
 
 
 def test_toc_is_not_mounted_without_a_page_column(js: JsPage) -> None:
-	js.set_html(f'<div id="readme-container" data-readme-b64="{_B64}" data-blob-base="/b?f="></div>')
-	js.load("readme-render.js")
-	assert js.count("h1") == 1
-	assert js.count(".readme-toc-mount") == 0
+    js.set_html(f'<div id="readme-container" data-readme-b64="{_B64}" data-blob-base="/b?f="></div>')
+    js.load("readme-render.js")
+    assert js.count("h1") == 1
+    assert js.count(".readme-toc-mount") == 0
diff --git a/pygitweb/src/pygitweb/static_tests/test_search-page.py b/pygitweb/src/pygitweb/static_tests/test_search-page.py
index b4b822d..181c442 100644
--- a/pygitweb/src/pygitweb/static_tests/test_search-page.py
+++ b/pygitweb/src/pygitweb/static_tests/test_search-page.py
@@ -4,150 +4,150 @@ import pytest
 from pygitweb.static_tests.js_runtime import JsError, JsPage
 
 _FORM = (
-	'<input id="search-patterns">'
-	'<input id="search-paths">'
-	'<input id="search-globs">'
-	'<select id="search-sort"><option value=""></option><option value="path">path</option></select>'
-	'<input id="search-max-count">'
-	'<input id="search-heading" type="checkbox">'
-	'<input id="search-multiline" type="checkbox">'
-	'<div id="search-status"></div>'
-	'<div id="search-results"></div>'
-	'<details id="search-advanced"></details>'
+    '<input id="search-patterns">'
+    '<input id="search-paths">'
+    '<input id="search-globs">'
+    '<select id="search-sort"><option value=""></option><option value="path">path</option></select>'
+    '<input id="search-max-count">'
+    '<input id="search-heading" type="checkbox">'
+    '<input id="search-multiline" type="checkbox">'
+    '<div id="search-status"></div>'
+    '<div id="search-results"></div>'
+    '<details id="search-advanced"></details>'
 )
 
 
 def _ready(js: JsPage, href: str = "http://127.0.0.1/search") -> None:
-	js.run("window.SEARCH_API_URL = '/api/search';")
-	js.set_location(href)
-	js.set_html(_FORM)
+    js.run("window.SEARCH_API_URL = '/api/search';")
+    js.set_location(href)
+    js.set_html(_FORM)
 
 
 def test_load_throws_when_a_control_is_missing(js: JsPage) -> None:
-	js.run("window.SEARCH_API_URL = '/api/search';")
-	js.set_html("<div></div>")
-	with pytest.raises(JsError):
-		js.load("search-page.js")
+    js.run("window.SEARCH_API_URL = '/api/search';")
+    js.set_html("<div></div>")
+    with pytest.raises(JsError):
+        js.load("search-page.js")
 
 
 def test_read_url_fills_the_form_and_opens_advanced_filters(js: JsPage) -> None:
-	js.route("/api/search", body={"results": []})
-	_ready(
-		js,
-		"http://127.0.0.1/search?patterns=foo&paths=src&globs=*.py&sort=path&max_count=5&heading=true&multiline=true",
-	)
-	js.load("search-page.js")
-	assert js.prop("#search-patterns", "value") == "foo"
-	assert js.prop("#search-paths", "value") == "src"
-	assert js.prop("#search-globs", "value") == "*.py"
-	assert js.prop("#search-sort", "value") == "path"
-	assert js.prop("#search-max-count", "value") == "5"
-	assert js.prop("#search-heading", "checked") is True
-	assert js.prop("#search-multiline", "checked") is True
-	assert js.prop("#search-advanced", "open") is True
+    js.route("/api/search", body={"results": []})
+    _ready(
+        js,
+        "http://127.0.0.1/search?patterns=foo&paths=src&globs=*.py&sort=path&max_count=5&heading=true&multiline=true",
+    )
+    js.load("search-page.js")
+    assert js.prop("#search-patterns", "value") == "foo"
+    assert js.prop("#search-paths", "value") == "src"
+    assert js.prop("#search-globs", "value") == "*.py"
+    assert js.prop("#search-sort", "value") == "path"
+    assert js.prop("#search-max-count", "value") == "5"
+    assert js.prop("#search-heading", "checked") is True
+    assert js.prop("#search-multiline", "checked") is True
+    assert js.prop("#search-advanced", "open") is True
 
 
 def test_empty_pattern_clears_results_and_the_query_string(js: JsPage) -> None:
-	_ready(js, "http://127.0.0.1/search?patterns=old")
-	js.load("search-page.js")
-	js.set_prop("#search-patterns", "value", "  ")
-	js.dispatch("#search-patterns", "keydown", key="Enter")
-	assert js.text("#search-status") == ""
-	assert js.prop("#search-results", "innerHTML") == ""
-	assert "patterns" not in js.value("location.search")
+    _ready(js, "http://127.0.0.1/search?patterns=old")
+    js.load("search-page.js")
+    js.set_prop("#search-patterns", "value", "  ")
+    js.dispatch("#search-patterns", "keydown", key="Enter")
+    assert js.text("#search-status") == ""
+    assert js.prop("#search-results", "innerHTML") == ""
+    assert "patterns" not in js.value("location.search")
 
 
 def test_results_are_escaped_and_pluralized(js: JsPage) -> None:
-	js.route("/api/search", body={"results": ["<script>", "ok"]})
-	_ready(js, "http://127.0.0.1/search?patterns=foo")
-	js.load("search-page.js")
-	assert js.text("#search-status") == "2 matches"
-	assert "&lt;script&gt;" in js.prop("#search-results", "innerHTML")
-	assert "<script>" not in js.prop("#search-results", "innerHTML")
-	assert "is-loading" not in js.classes("#search-results")
+    js.route("/api/search", body={"results": ["<script>", "ok"]})
+    _ready(js, "http://127.0.0.1/search?patterns=foo")
+    js.load("search-page.js")
+    assert js.text("#search-status") == "2 matches"
+    assert "&lt;script&gt;" in js.prop("#search-results", "innerHTML")
+    assert "<script>" not in js.prop("#search-results", "innerHTML")
+    assert "is-loading" not in js.classes("#search-results")
 
 
 def test_a_single_match_uses_the_singular_label(js: JsPage) -> None:
-	js.route("/api/search", body={"results": ["one"]})
-	_ready(js, "http://127.0.0.1/search?patterns=foo&heading=false")
-	js.set_html(_FORM)
-	js.load("search-page.js")
-	assert js.text("#search-status") == "1 match"
-	url = next(str(item["url"]) for item in js.requests() if "/api/search" in str(item["url"]))
-	assert "heading=false" in url
-	assert "patterns=foo" in url
+    js.route("/api/search", body={"results": ["one"]})
+    _ready(js, "http://127.0.0.1/search?patterns=foo&heading=false")
+    js.set_html(_FORM)
+    js.load("search-page.js")
+    assert js.text("#search-status") == "1 match"
+    url = next(str(item["url"]) for item in js.requests() if "/api/search" in str(item["url"]))
+    assert "heading=false" in url
+    assert "patterns=foo" in url
 
 
 def test_missing_results_count_as_zero(js: JsPage) -> None:
-	js.route("/api/search", body={})
-	_ready(js, "http://127.0.0.1/search?patterns=foo")
-	js.load("search-page.js")
-	assert js.text("#search-status") == "0 matches"
-	assert js.prop("#search-results", "innerHTML") == ""
+    js.route("/api/search", body={})
+    _ready(js, "http://127.0.0.1/search?patterns=foo")
+    js.load("search-page.js")
+    assert js.text("#search-status") == "0 matches"
+    assert js.prop("#search-results", "innerHTML") == ""
 
 
 def test_error_detail_is_shown(js: JsPage) -> None:
-	js.route("/api/search", status=400, body={"detail": "bad pattern"}, status_text="Bad")
-	_ready(js, "http://127.0.0.1/search?patterns=foo")
-	js.load("search-page.js")
-	assert js.text("#search-status") == "Error 400: bad pattern"
+    js.route("/api/search", status=400, body={"detail": "bad pattern"}, status_text="Bad")
+    _ready(js, "http://127.0.0.1/search?patterns=foo")
+    js.load("search-page.js")
+    assert js.text("#search-status") == "Error 400: bad pattern"
 
 
 def test_error_without_json_uses_the_status_text(js: JsPage) -> None:
-	js.route("/api/search", status=502, json_error=True, status_text="Bad Gateway")
-	_ready(js, "http://127.0.0.1/search?patterns=foo")
-	js.load("search-page.js")
-	assert js.text("#search-status") == "Error 502: Bad Gateway"
+    js.route("/api/search", status=502, json_error=True, status_text="Bad Gateway")
+    _ready(js, "http://127.0.0.1/search?patterns=foo")
+    js.load("search-page.js")
+    assert js.text("#search-status") == "Error 502: Bad Gateway"
 
 
 def test_network_error_is_shown(js: JsPage) -> None:
-	js.route("/api/search", error="offline")
-	_ready(js, "http://127.0.0.1/search?patterns=foo")
-	js.load("search-page.js")
-	assert js.text("#search-status") == "Error: offline"
-	assert "is-loading" not in js.classes("#search-results")
+    js.route("/api/search", error="offline")
+    _ready(js, "http://127.0.0.1/search?patterns=foo")
+    js.load("search-page.js")
+    assert js.text("#search-status") == "Error: offline"
+    assert "is-loading" not in js.classes("#search-results")
 
 
 def test_a_newer_search_aborts_the_previous_one(js: JsPage) -> None:
-	js.route("/api/search", hang=True)
-	_ready(js, "http://127.0.0.1/search?patterns=foo")
-	js.load("search-page.js")
-	assert js.text("#search-status") == "Searching…"
-	js.set_prop("#search-patterns", "value", "bar")
-	js.dispatch("#search-patterns", "keydown", key="Enter")
-	assert js.text("#search-status") == "Searching…"
-	js.fulfill("/api/search", body={"results": ["bar"]})
-	assert js.text("#search-status") == "1 match"
-	assert "Error" not in js.text("#search-status")
+    js.route("/api/search", hang=True)
+    _ready(js, "http://127.0.0.1/search?patterns=foo")
+    js.load("search-page.js")
+    assert js.text("#search-status") == "Searching…"
+    js.set_prop("#search-patterns", "value", "bar")
+    js.dispatch("#search-patterns", "keydown", key="Enter")
+    assert js.text("#search-status") == "Searching…"
+    js.fulfill("/api/search", body={"results": ["bar"]})
+    assert js.text("#search-status") == "1 match"
+    assert "Error" not in js.text("#search-status")
 
 
 def test_input_debounces_and_other_controls_search_too(js: JsPage) -> None:
-	js.route("/api/search", body={"results": ["x"]})
-	_ready(js)
-	js.load("search-page.js")
-	js.set_prop("#search-patterns", "value", "foo")
-	js.set_prop("#search-paths", "value", "src")
-	js.set_prop("#search-globs", "value", "*.py")
-	js.set_prop("#search-max-count", "value", "")
-	js.set_prop("#search-sort", "value", "path")
-	js.set_prop("#search-heading", "checked", True)
-	js.set_prop("#search-multiline", "checked", True)
-	js.dispatch("#search-patterns", "input")
-	assert js.text("#search-status") == ""
-	js.tick()
-	url = next(str(item["url"]) for item in js.requests() if "patterns=foo" in str(item["url"]))
-	assert "paths=src" in url
-	assert "globs=*.py" in url
-	assert "sort=path" in url
-	assert "max_count" not in url
-	assert "heading=true" in url
-	assert "multiline=true" in url
-	assert js.value("location.search").find("patterns=foo") >= 0
+    js.route("/api/search", body={"results": ["x"]})
+    _ready(js)
+    js.load("search-page.js")
+    js.set_prop("#search-patterns", "value", "foo")
+    js.set_prop("#search-paths", "value", "src")
+    js.set_prop("#search-globs", "value", "*.py")
+    js.set_prop("#search-max-count", "value", "")
+    js.set_prop("#search-sort", "value", "path")
+    js.set_prop("#search-heading", "checked", True)
+    js.set_prop("#search-multiline", "checked", True)
+    js.dispatch("#search-patterns", "input")
+    assert js.text("#search-status") == ""
+    js.tick()
+    url = next(str(item["url"]) for item in js.requests() if "patterns=foo" in str(item["url"]))
+    assert "paths=src" in url
+    assert "globs=*.py" in url
+    assert "sort=path" in url
+    assert "max_count" not in url
+    assert "heading=true" in url
+    assert "multiline=true" in url
+    assert js.value("location.search").find("patterns=foo") >= 0
 
 
 def test_other_keys_do_not_run_a_search_immediately(js: JsPage) -> None:
-	_ready(js)
-	js.load("search-page.js")
-	js.set_prop("#search-patterns", "value", "foo")
-	js.dispatch("#search-patterns", "keydown", key="a")
-	assert js.text("#search-status") == ""
+    _ready(js)
+    js.load("search-page.js")
+    js.set_prop("#search-patterns", "value", "foo")
+    js.dispatch("#search-patterns", "keydown", key="a")
+    assert js.text("#search-status") == ""
diff --git a/pygitweb/src/pygitweb/static_tests/test_summary-ref-switcher.py b/pygitweb/src/pygitweb/static_tests/test_summary-ref-switcher.py
index cd6132c..12f683c 100644
--- a/pygitweb/src/pygitweb/static_tests/test_summary-ref-switcher.py
+++ b/pygitweb/src/pygitweb/static_tests/test_summary-ref-switcher.py
@@ -7,154 +7,154 @@ from pygitweb.static_tests.pages import requested
 
 _B64 = base64.b64encode(b"# Demo\n").decode("ascii")
 _STATE = {
-	"commit_href": "/commit",
-	"display": "abc",
-	"browse_href": "/tree",
-	"log_href": "/log",
-	"shortlog_href": "/shortlog",
-	"tree_html": "<span class='tree'>files</span>",
-	"readme_html": "<p>hi</p>",
+    "commit_href": "/commit",
+    "display": "abc",
+    "browse_href": "/tree",
+    "log_href": "/log",
+    "shortlog_href": "/shortlog",
+    "tree_html": "<span class='tree'>files</span>",
+    "readme_html": "<p>hi</p>",
 }
 
 
 def _page(*, project: str = "demo", full: bool = True) -> str:
-	links = (
-		'<a id="summary-ref-log-link"></a><a id="summary-ref-shortlog-link"></a>'
-		'<div id="summary-tree-container"></div><a id="summary-tree-browse-link"></a>'
-		if full
-		else ""
-	)
-	return (
-		'<select id="summary-ref-select"><option value="HEAD">HEAD</option>'
-		'<option value="old" selected>old</option></select>'
-		'<a id="summary-ref-commit-link"></a><a id="summary-ref-browse-link"></a>'
-		+ links
-		+ f'<div id="summary-readme-container" data-project="{project}"></div>'
-		+ f'<div class="page"></div><div id="readme-container" data-readme-b64="{_B64}"></div>'
-	)
+    links = (
+        '<a id="summary-ref-log-link"></a><a id="summary-ref-shortlog-link"></a>'
+        '<div id="summary-tree-container"></div><a id="summary-tree-browse-link"></a>'
+        if full
+        else ""
+    )
+    return (
+        '<select id="summary-ref-select"><option value="HEAD">HEAD</option>'
+        '<option value="old" selected>old</option></select>'
+        '<a id="summary-ref-commit-link"></a><a id="summary-ref-browse-link"></a>'
+        + links
+        + f'<div id="summary-readme-container" data-project="{project}"></div>'
+        + f'<div class="page"></div><div id="readme-container" data-readme-b64="{_B64}"></div>'
+    )
 
 
 def test_missing_controls_skip_startup(js: JsPage) -> None:
-	js.set_html("<div></div>")
-	js.load("summary-ref-switcher.js")
-	assert requested(js, "summary-refs") is False
+    js.set_html("<div></div>")
+    js.load("summary-ref-switcher.js")
+    assert requested(js, "summary-refs") is False
 
 
 def test_missing_project_skips_startup(js: JsPage) -> None:
-	js.set_html(_page(project=""))
-	js.load("summary-ref-switcher.js")
-	assert requested(js, "summary-refs") is False
+    js.set_html(_page(project=""))
+    js.load("summary-ref-switcher.js")
+    assert requested(js, "summary-refs") is False
 
 
 def test_options_replace_stale_entries_and_keep_a_live_value(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": [{"value": "old", "kind": "branch", "label": "old"}]})
-	js.route("summary-ref-state", body=_STATE)
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	assert js.count("#summary-ref-select option") == 2
-	assert js.prop("#summary-ref-select", "value") == "old"
-	assert js.text("#summary-ref-select option:last-child") == "branch: old"
+    js.route("summary-refs", body={"options": [{"value": "old", "kind": "branch", "label": "old"}]})
+    js.route("summary-ref-state", body=_STATE)
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    assert js.count("#summary-ref-select option") == 2
+    assert js.prop("#summary-ref-select", "value") == "old"
+    assert js.text("#summary-ref-select option:last-child") == "branch: old"
 
 
 def test_a_removed_value_falls_back_to_head(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": [{"value": "main", "kind": "branch", "label": "main"}]})
-	js.route("summary-ref-state", body=_STATE)
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	assert js.prop("#summary-ref-select", "value") == "HEAD"
-	assert js.text("#summary-ref-select option:last-child") == "branch: main"
+    js.route("summary-refs", body={"options": [{"value": "main", "kind": "branch", "label": "main"}]})
+    js.route("summary-ref-state", body=_STATE)
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    assert js.prop("#summary-ref-select", "value") == "HEAD"
+    assert js.text("#summary-ref-select option:last-child") == "branch: main"
 
 
 def test_failed_option_and_state_loads_leave_the_page_alone(js: JsPage) -> None:
-	js.route("summary-refs", status=500)
-	js.route("summary-ref-state", status=404)
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	js.set_prop("#summary-ref-select", "value", "HEAD")
-	js.dispatch("#summary-ref-select", "change")
-	assert js.text("#summary-ref-commit-link") == ""
-	assert js.count("#summary-ref-select option") == 2
+    js.route("summary-refs", status=500)
+    js.route("summary-ref-state", status=404)
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    js.set_prop("#summary-ref-select", "value", "HEAD")
+    js.dispatch("#summary-ref-select", "change")
+    assert js.text("#summary-ref-commit-link") == ""
+    assert js.count("#summary-ref-select option") == 2
 
 
 def test_change_loads_the_selected_ref(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": []})
-	js.route("summary-ref-state", body=_STATE)
-	js.set_html(_page(project="group/demo"))
-	js.load("summary-ref-switcher.js")
-	js.run(
-		"document.getElementById('summary-ref-select').innerHTML = '<option value=\"feature/a\">feature/a</option>';"
-	)
-	js.dispatch("#summary-ref-select", "change")
-	assert any("ref=feature%2Fa" in str(item["url"]) for item in js.requests())
-	assert any("/project/group/demo/" in str(item["url"]) for item in js.requests())
+    js.route("summary-refs", body={"options": []})
+    js.route("summary-ref-state", body=_STATE)
+    js.set_html(_page(project="group/demo"))
+    js.load("summary-ref-switcher.js")
+    js.run(
+        "document.getElementById('summary-ref-select').innerHTML = '<option value=\"feature/a\">feature/a</option>';"
+    )
+    js.dispatch("#summary-ref-select", "change")
+    assert any("ref=feature%2Fa" in str(item["url"]) for item in js.requests())
+    assert any("/project/group/demo/" in str(item["url"]) for item in js.requests())
 
 
 def test_optional_links_are_skipped_when_absent(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": []})
-	js.route("summary-ref-state", body=_STATE)
-	js.set_html(_page(full=False))
-	js.load("summary-ref-switcher.js")
-	js.dispatch("#summary-ref-select", "change")
-	assert js.text("#summary-ref-commit-link") == "abc"
+    js.route("summary-refs", body={"options": []})
+    js.route("summary-ref-state", body=_STATE)
+    js.set_html(_page(full=False))
+    js.load("summary-ref-switcher.js")
+    js.dispatch("#summary-ref-select", "change")
+    assert js.text("#summary-ref-commit-link") == "abc"
 
 
 def test_an_update_with_text_reloads_options_and_state(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": [{"value": "main", "kind": "branch", "label": "main"}]})
-	js.route("summary-ref-state", body=_STATE)
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	before = len(js.requests())
-	js.fulfill("updates=true", status=200, text="changed")
-	assert len(js.requests()) > before
-	assert js.text("#summary-ref-commit-link") == "abc"
+    js.route("summary-refs", body={"options": [{"value": "main", "kind": "branch", "label": "main"}]})
+    js.route("summary-ref-state", body=_STATE)
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    before = len(js.requests())
+    js.fulfill("updates=true", status=200, text="changed")
+    assert len(js.requests()) > before
+    assert js.text("#summary-ref-commit-link") == "abc"
 
 
 def test_an_empty_update_does_not_reload(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": []})
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	refs = [item for item in js.requests() if "summary-refs" in str(item["url"])]
-	js.fulfill("updates=true", status=200, text="")
-	refs_after = [item for item in js.requests() if "summary-refs" in str(item["url"])]
-	assert len(refs_after) == len(refs)
+    js.route("summary-refs", body={"options": []})
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    refs = [item for item in js.requests() if "summary-refs" in str(item["url"])]
+    js.fulfill("updates=true", status=200, text="")
+    refs_after = [item for item in js.requests() if "summary-refs" in str(item["url"])]
+    assert len(refs_after) == len(refs)
 
 
 def test_not_modified_polls_again_without_a_backoff(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": []})
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	js.fulfill("updates=true", status=304, text="")
-	updates = [item for item in js.requests() if "updates=true" in str(item["url"])]
-	assert len(updates) >= 2
-	assert js.value("__timers.length") == 0
+    js.route("summary-refs", body={"options": []})
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    js.fulfill("updates=true", status=304, text="")
+    updates = [item for item in js.requests() if "updates=true" in str(item["url"])]
+    assert len(updates) >= 2
+    assert js.value("__timers.length") == 0
 
 
 def test_a_failed_update_backs_off_then_continues(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": []})
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	js.fulfill("updates=true", status=500, text="nope")
-	assert js.value("__timers.length") == 1
-	before = len([item for item in js.requests() if "updates=true" in str(item["url"])])
-	js.tick()
-	after = len([item for item in js.requests() if "updates=true" in str(item["url"])])
-	assert after > before
+    js.route("summary-refs", body={"options": []})
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    js.fulfill("updates=true", status=500, text="nope")
+    assert js.value("__timers.length") == 1
+    before = len([item for item in js.requests() if "updates=true" in str(item["url"])])
+    js.tick()
+    after = len([item for item in js.requests() if "updates=true" in str(item["url"])])
+    assert after > before
 
 
 def test_beforeunload_stops_the_subscription(js: JsPage) -> None:
-	js.route("summary-refs", body={"options": []})
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	js.dispatch_window("beforeunload")
-	assert js.value("__alerts") == []
+    js.route("summary-refs", body={"options": []})
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    js.dispatch_window("beforeunload")
+    assert js.value("__alerts") == []
 
 
 def test_a_rejected_update_backs_off_until_abort(js: JsPage) -> None:
-	js.route("updates=true", error="offline")
-	js.route("summary-refs", body={"options": []})
-	js.set_html(_page())
-	js.load("summary-ref-switcher.js")
-	assert js.value("__timers.length") >= 1
-	js.dispatch_window("beforeunload")
-	js.tick()
-	assert js.text("#summary-ref-commit-link") == ""
+    js.route("updates=true", error="offline")
+    js.route("summary-refs", body={"options": []})
+    js.set_html(_page())
+    js.load("summary-ref-switcher.js")
+    assert js.value("__timers.length") >= 1
+    js.dispatch_window("beforeunload")
+    js.tick()
+    assert js.text("#summary-ref-commit-link") == ""
diff --git a/pygitweb/src/pygitweb/tasks.py b/pygitweb/src/pygitweb/tasks.py
index fb0e971..ceb4023 100644
--- a/pygitweb/src/pygitweb/tasks.py
+++ b/pygitweb/src/pygitweb/tasks.py
@@ -15,119 +15,119 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from fastapi.responses import HTMLResponse, JSONResponse
 from pygit2 import Oid
 from pygittools.tasks import (
-	ARCHIVE_BOARD_NAME,
-	BOARD_REF_PREFIX,
-	TASK_REF_PREFIX,
-	Board,
-	Comment,
-	Task,
-	_parse_dt,
-	archive_task_local,
-	create_task,
-	delete_task_local,
-	get_board,
-	get_board_ref,
-	get_comment,
-	get_repo_head_or_empty,
-	get_task,
-	get_task_by_oid,
-	get_task_ref,
-	parse_points,
+    ARCHIVE_BOARD_NAME,
+    BOARD_REF_PREFIX,
+    TASK_REF_PREFIX,
+    Board,
+    Comment,
+    Task,
+    _parse_dt,
+    archive_task_local,
+    create_task,
+    delete_task_local,
+    get_board,
+    get_board_ref,
+    get_comment,
+    get_repo_head_or_empty,
+    get_task,
+    get_task_by_oid,
+    get_task_ref,
+    parse_points,
 )
 
 from pygitweb.auth import (
-	access_token_from_request,
-	ensure_active_user_if_auth_enabled,
-	has_permission,
-	principal_from_session,
-	require_permission,
+    access_token_from_request,
+    ensure_active_user_if_auth_enabled,
+    has_permission,
+    principal_from_session,
+    require_permission,
 )
 from pygitweb.config import settings
 from pygitweb.dependencies import (
-	ValidatedReadableQueryProject,
-	filter_projects_by_read_access,
+    ValidatedReadableQueryProject,
+    filter_projects_by_read_access,
 )
 from pygitweb.gravatar import gravatar_url
 from pygitweb.permissions import Permission, PermissionPrincipal
 from pygitweb.projects import git_get_projects_list
 from pygitweb.templates_env import (
-	Cell,
-	html_link,
-	person_label,
-	priority_badge,
-	render_page,
-	status_badge,
+    Cell,
+    html_link,
+    person_label,
+    priority_badge,
+    render_page,
+    status_badge,
 )
 
 EMPTY_TREE_OID = Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
 
 
 def _sync_board_task_oid(
-	repo: pygit2.Repository,
-	board: str,
-	old_oid: str,
-	new_oid: pygit2.Oid | str,
-	*,
-	move_to_end: bool = False,
+    repo: pygit2.Repository,
+    board: str,
+    old_oid: str,
+    new_oid: pygit2.Oid | str,
+    *,
+    move_to_end: bool = False,
 ) -> None:
-	"""Replace a task OID on the board after the task tag object was rewritten."""
-	b = get_board(repo, get_board_ref(board))
-	if not b or not getattr(b, "tasks", None):
-		return
-	new_oid_str = str(new_oid)
-	tasks_list = [new_oid_str if o == old_oid else o for o in b.tasks]
-	if move_to_end and new_oid_str in tasks_list:
-		tasks_list = [o for o in tasks_list if o != new_oid_str] + [new_oid_str]
-	b.tasks = tasks_list
-	b.update_message()
-	b.write(repo)
+    """Replace a task OID on the board after the task tag object was rewritten."""
+    b = get_board(repo, get_board_ref(board))
+    if not b or not getattr(b, "tasks", None):
+        return
+    new_oid_str = str(new_oid)
+    tasks_list = [new_oid_str if o == old_oid else o for o in b.tasks]
+    if move_to_end and new_oid_str in tasks_list:
+        tasks_list = [o for o in tasks_list if o != new_oid_str] + [new_oid_str]
+    b.tasks = tasks_list
+    b.update_message()
+    b.write(repo)
 
 
 def _task_from_board_entry(
-	repo: pygit2.Repository,
-	oid_hex: str | pygit2.Oid,
-	*,
-	board: str | None = None,
+    repo: pygit2.Repository,
+    oid_hex: str | pygit2.Oid,
+    *,
+    board: str | None = None,
 ) -> Task | None:
-	"""
-	Load a task listed on a board by OID.
-
-	Board entries can lag behind ``refs/tags/tasks/…`` after a task rewrite (e.g. a new
-	comment). When the ref points at a newer tag object, return that version and repair
-	the board list when ``board`` is provided.
-	"""
-	oid = pygit2.Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
-	t = get_task_by_oid(repo, oid)
-	if not t or not t.name:
-		return t
-	try:
-		if t.name not in repo.references:
-			return t
-		current_oid = repo.references[t.name].resolve().target
-	except KeyError:
-		return t
-	if str(current_oid) == str(oid):
-		return t
-	t_current = get_task(repo, t.name)
-	if t_current and board:
-		_sync_board_task_oid(repo, board, str(oid), current_oid)
-	return t_current or t
+    """
+    Load a task listed on a board by OID.
+
+    Board entries can lag behind ``refs/tags/tasks/…`` after a task rewrite (e.g. a new
+    comment). When the ref points at a newer tag object, return that version and repair
+    the board list when ``board`` is provided.
+    """
+    oid = pygit2.Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+    t = get_task_by_oid(repo, oid)
+    if not t or not t.name:
+        return t
+    try:
+        if t.name not in repo.references:
+            return t
+        current_oid = repo.references[t.name].resolve().target
+    except KeyError:
+        return t
+    if str(current_oid) == str(oid):
+        return t
+    t_current = get_task(repo, t.name)
+    if t_current and board:
+        _sync_board_task_oid(repo, board, str(oid), current_oid)
+    return t_current or t
 
 
 def create_board_for_project(
-	project: str,
-	name: str = "Tasks",
-	description: str = "",
+    project: str,
+    name: str = "Tasks",
+    description: str = "",
 ) -> str:
-	"""Create a board in the project repo. Returns the board ref. Raises HTTPException on error."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = get_board_ref(name)
-	if ref in repo.references:
-		raise HTTPException(status_code=409, detail="Board already exists")
-	target = get_repo_head_or_empty(repo)
-	board = Board(target, ref, tagger="", description=description)
-	board.write(repo)
-	return ref
+    """Create a board in the project repo. Returns the board ref. Raises HTTPException on error."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = get_board_ref(name)
+    if ref in repo.references:
+        raise HTTPException(status_code=409, detail="Board already exists")
+    target = get_repo_head_or_empty(repo)
+    board = Board(target, ref, tagger="", description=description)
+    board.write(repo)
+    return ref
 
 
 # ---------- Board Routes ----------
@@ -136,76 +136,76 @@ board_router = APIRouter(tags=["boards"])
 
 
 def list_project_boards(project: str) -> list[dict[str, Any]]:
-	"""Return [{name, description, task_count}, ...] for every board in a project."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	refs = [
-		(ref_name, str(repo.references[ref_name].resolve().target))
-		for ref_name in repo.references
-		if ref_name.startswith(BOARD_REF_PREFIX)
-	]
-	boards = []
-	for ref_name, _ in refs:
-		try:
-			b = get_board(repo, ref_name)
-			if b:
-				boards.append({
-					"name": ref_name.replace(BOARD_REF_PREFIX, ""),
-					"description": getattr(b, "description", "") or "",
-					"task_count": len(getattr(b, "tasks", [])),
-				})
-		except (ValueError, KeyError):
-			continue
-	return boards
+    """Return [{name, description, task_count}, ...] for every board in a project."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    refs = [
+        (ref_name, str(repo.references[ref_name].resolve().target))
+        for ref_name in repo.references
+        if ref_name.startswith(BOARD_REF_PREFIX)
+    ]
+    boards = []
+    for ref_name, _ in refs:
+        try:
+            b = get_board(repo, ref_name)
+            if b:
+                boards.append({
+                    "name": ref_name.replace(BOARD_REF_PREFIX, ""),
+                    "description": getattr(b, "description", "") or "",
+                    "task_count": len(getattr(b, "tasks", [])),
+                })
+        except (ValueError, KeyError):
+            continue
+    return boards
 
 
 @board_router.get(
-	"/list",
-	response_class=JSONResponse,
+    "/list",
+    response_class=JSONResponse,
 )
 def boards_list(project: ValidatedReadableQueryProject) -> JSONResponse:
-	"""List all boards for the project."""
-	return JSONResponse(content=list_project_boards(project))
+    """List all boards for the project."""
+    return JSONResponse(content=list_project_boards(project))
 
 
 @board_router.post(
-	"/create",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.BOARDS, project_from_query=True))],
+    "/create",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.BOARDS, project_from_query=True))],
 )
 def boards_create(
-	project: ValidatedReadableQueryProject,
-	name: str = Query(..., description="Board name"),
-	description: str = Query("", description="Board description"),
+    project: ValidatedReadableQueryProject,
+    name: str = Query(..., description="Board name"),
+    description: str = Query("", description="Board description"),
 ) -> JSONResponse:
-	"""Create a new board."""
-	if not name or not pygit2.reference_is_valid_name(get_board_ref(name)):
-		raise HTTPException(status_code=400, detail="Invalid board name")
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = get_board_ref(name)
-	if ref in repo.references:
-		raise HTTPException(status_code=409, detail="Board already exists")
-	target = get_repo_head_or_empty(repo)
-	board = Board(target, ref, tagger="", description=description or "")
-	oid = board.write(repo)
-	return JSONResponse(content={"name": name, "ref": ref, "oid": str(oid)})
+    """Create a new board."""
+    if not name or not pygit2.reference_is_valid_name(get_board_ref(name)):
+        raise HTTPException(status_code=400, detail="Invalid board name")
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = get_board_ref(name)
+    if ref in repo.references:
+        raise HTTPException(status_code=409, detail="Board already exists")
+    target = get_repo_head_or_empty(repo)
+    board = Board(target, ref, tagger="", description=description or "")
+    oid = board.write(repo)
+    return JSONResponse(content={"name": name, "ref": ref, "oid": str(oid)})
 
 
 @board_router.post(
-	"/delete",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.BOARDS, project_from_query=True))],
+    "/delete",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.BOARDS, project_from_query=True))],
 )
 def boards_delete(
-	project: ValidatedReadableQueryProject,
-	name: str = Query(..., description="Board name"),
+    project: ValidatedReadableQueryProject,
+    name: str = Query(..., description="Board name"),
 ) -> JSONResponse:
-	"""Delete a board (removes ref; tag object remains in ODB)."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = get_board_ref(name)
-	if ref not in repo.references:
-		raise HTTPException(status_code=404, detail="Board not found")
-	repo.references.delete(ref)
-	return JSONResponse(content={"message": "Board deleted"})
+    """Delete a board (removes ref; tag object remains in ODB)."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = get_board_ref(name)
+    if ref not in repo.references:
+        raise HTTPException(status_code=404, detail="Board not found")
+    repo.references.delete(ref)
+    return JSONResponse(content={"message": "Board deleted"})
 
 
 # ---------- Task Routes ----------
@@ -214,29 +214,29 @@ task_router = APIRouter(tags=["tasks"])
 
 
 def _task_display_id(ref: str) -> str:
-	"""Numeric task id for UI (task_1730000000000 -> 1730000000000)."""
-	slug = ref.removeprefix(TASK_REF_PREFIX)
-	if slug.startswith("task_"):
-		return slug.removeprefix("task_")
-	return slug
+    """Numeric task id for UI (task_1730000000000 -> 1730000000000)."""
+    slug = ref.removeprefix(TASK_REF_PREFIX)
+    if slug.startswith("task_"):
+        return slug.removeprefix("task_")
+    return slug
 
 
 def _task_to_json(t: Task) -> dict[str, Any]:
-	return {
-		"ref": t.name,
-		"id": _task_display_id(t.name),
-		"title": t.title,
-		"description": getattr(t, "description", "") or "",
-		"status": t.status.value if t.status else None,
-		"priority": t.priority.value if t.priority else None,
-		"assignee": t.assignee,
-		"due_date": t.due_date.isoformat() if t.due_date else None,
-		"tags": getattr(t, "tags", "") or "",
-		"points": getattr(t, "points", None),
-		"created_at": t.created_at.isoformat() if t.created_at else None,
-		"updated_at": t.updated_at.isoformat() if t.updated_at else None,
-		"comments_count": len(getattr(t, "comments", [])),
-	}
+    return {
+        "ref": t.name,
+        "id": _task_display_id(t.name),
+        "title": t.title,
+        "description": getattr(t, "description", "") or "",
+        "status": t.status.value if t.status else None,
+        "priority": t.priority.value if t.priority else None,
+        "assignee": t.assignee,
+        "due_date": t.due_date.isoformat() if t.due_date else None,
+        "tags": getattr(t, "tags", "") or "",
+        "points": getattr(t, "points", None),
+        "created_at": t.created_at.isoformat() if t.created_at else None,
+        "updated_at": t.updated_at.isoformat() if t.updated_at else None,
+        "comments_count": len(getattr(t, "comments", [])),
+    }
 
 
 # Status order for board columns
@@ -244,320 +244,320 @@ BOARD_STATUS_ORDER = ["TODO", "IN_PROGRESS", "IN_REVIEW", "DONE", "CANCELLED"]
 
 
 def get_board_tasks_grouped(
-	project: str,
-	board_name: str,
+    project: str,
+    board_name: str,
 ) -> list[dict[str, Any]]:
-	"""
-	Return tasks for a board grouped by status for board view.
-	Returns a list of { "status": str, "label": str, "tasks": [ _task_to_json, ... ] }
-	in BOARD_STATUS_ORDER. Skips validation/auth (caller must validate project).
-	"""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = get_board_ref(board_name)
-	b = get_board(repo, ref)
-	task_oids = getattr(b, "tasks", []) or []
-	by_status: dict[str, list[dict[str, Any]]] = {s: [] for s in BOARD_STATUS_ORDER}
-	for oid_hex in task_oids:
-		try:
-			t = _task_from_board_entry(repo, oid_hex, board=board_name)
-			if t:
-				d = _task_to_json(t)
-				status_key = (d.get("status") or "TODO") if d else "TODO"
-				if status_key not in by_status:
-					by_status[status_key] = []
-				by_status[status_key].append(d)
-		except (ValueError, KeyError, TypeError):
-			continue
-	return [
-		{
-			"status": s,
-			"label": s.replace("_", " ").title(),
-			"tasks": by_status.get(s, []),
-			"points_total": sum(t.get("points") or 0 for t in by_status.get(s, [])),
-		}
-		for s in BOARD_STATUS_ORDER
-	]
+    """
+    Return tasks for a board grouped by status for board view.
+    Returns a list of { "status": str, "label": str, "tasks": [ _task_to_json, ... ] }
+    in BOARD_STATUS_ORDER. Skips validation/auth (caller must validate project).
+    """
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = get_board_ref(board_name)
+    b = get_board(repo, ref)
+    task_oids = getattr(b, "tasks", []) or []
+    by_status: dict[str, list[dict[str, Any]]] = {s: [] for s in BOARD_STATUS_ORDER}
+    for oid_hex in task_oids:
+        try:
+            t = _task_from_board_entry(repo, oid_hex, board=board_name)
+            if t:
+                d = _task_to_json(t)
+                status_key = (d.get("status") or "TODO") if d else "TODO"
+                if status_key not in by_status:
+                    by_status[status_key] = []
+                by_status[status_key].append(d)
+        except (ValueError, KeyError, TypeError):
+            continue
+    return [
+        {
+            "status": s,
+            "label": s.replace("_", " ").title(),
+            "tasks": by_status.get(s, []),
+            "points_total": sum(t.get("points") or 0 for t in by_status.get(s, [])),
+        }
+        for s in BOARD_STATUS_ORDER
+    ]
 
 
 def get_all_project_tasks(project: str) -> list[dict[str, Any]]:
-	"""
-	Return every task across all boards in a project, each tagged with its board name.
-
-	Used by the team overview page, which needs to see who has what regardless of
-	which board a task currently sits on. Skips validation/auth (caller must validate project).
-	"""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	out: list[dict[str, Any]] = []
-	for ref_name in repo.references:
-		if not ref_name.startswith(BOARD_REF_PREFIX):
-			continue
-		board_name = ref_name.removeprefix(BOARD_REF_PREFIX)
-		b = get_board(repo, ref_name)
-		for oid_hex in getattr(b, "tasks", []) or []:
-			try:
-				t = _task_from_board_entry(repo, oid_hex, board=board_name)
-				if t:
-					d = _task_to_json(t)
-					d["board"] = board_name
-					out.append(d)
-			except (ValueError, KeyError, TypeError):
-				continue
-	return out
+    """
+    Return every task across all boards in a project, each tagged with its board name.
+
+    Used by the team overview page, which needs to see who has what regardless of
+    which board a task currently sits on. Skips validation/auth (caller must validate project).
+    """
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    out: list[dict[str, Any]] = []
+    for ref_name in repo.references:
+        if not ref_name.startswith(BOARD_REF_PREFIX):
+            continue
+        board_name = ref_name.removeprefix(BOARD_REF_PREFIX)
+        b = get_board(repo, ref_name)
+        for oid_hex in getattr(b, "tasks", []) or []:
+            try:
+                t = _task_from_board_entry(repo, oid_hex, board=board_name)
+                if t:
+                    d = _task_to_json(t)
+                    d["board"] = board_name
+                    out.append(d)
+            except (ValueError, KeyError, TypeError):
+                continue
+    return out
 
 
 @task_router.get("", response_class=HTMLResponse)
 def all_tasks_page(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	status: Annotated[str | None, Query()] = None,
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    status: Annotated[str | None, Query()] = None,
 ) -> HTMLResponse:
-	"""Flat list of every task across every project and board you can read."""
-	status_filter = (status or "").strip().upper() or None
-	projects = filter_projects_by_read_access(
-		git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
-		token,
-	)
-	rows: list[list[Cell]] = []
-	for pr in projects:
-		project = pr.get("path", "")
-		if not project:
-			continue
-		try:
-			tasks = get_all_project_tasks(project)
-		except Exception:
-			continue
-		for t in tasks:
-			task_status = t.get("status") or "TODO"
-			if status_filter and task_status != status_filter:
-				continue
-			project_url = f"/project/{quote(project, safe='/')}"
-			board_url = f"{project_url}/board/?b={quote(t['board'], safe='')}"
-			task_url = f"{board_url}&task={quote(t['ref'], safe='')}"
-			rows.append([
-				html_link(task_url, t.get("title") or "(untitled)"),
-				html_link(project_url, project),
-				t.get("board") or "",
-				status_badge(task_status),
-				priority_badge(t.get("priority") or "LOW"),
-				person_label(t.get("assignee") or ""),
-				str(t.get("points") if t.get("points") is not None else ""),
-			])
-	statuses = [
-		{"value": status, "label": status.replace("_", " ").title(), "selected": status_filter == status}
-		for status in BOARD_STATUS_ORDER
-	]
-	return render_page(
-		"tasks.html",
-		title=f"{settings.SITE_NAME} - Tasks",
-		status_filter=status_filter or "",
-		statuses=statuses,
-		cols=["Task", "Project", "Board", "Status", "Priority", "Assignee", "Points"],
-		rows=rows,
-		nav="tasks",
-	)
+    """Flat list of every task across every project and board you can read."""
+    status_filter = (status or "").strip().upper() or None
+    projects = filter_projects_by_read_access(
+        git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
+        token,
+    )
+    rows: list[list[Cell]] = []
+    for pr in projects:
+        project = pr.get("path", "")
+        if not project:
+            continue
+        try:
+            tasks = get_all_project_tasks(project)
+        except Exception:
+            continue
+        for t in tasks:
+            task_status = t.get("status") or "TODO"
+            if status_filter and task_status != status_filter:
+                continue
+            project_url = f"/project/{quote(project, safe='/')}"
+            board_url = f"{project_url}/board/?b={quote(t['board'], safe='')}"
+            task_url = f"{board_url}&task={quote(t['ref'], safe='')}"
+            rows.append([
+                html_link(task_url, t.get("title") or "(untitled)"),
+                html_link(project_url, project),
+                t.get("board") or "",
+                status_badge(task_status),
+                priority_badge(t.get("priority") or "LOW"),
+                person_label(t.get("assignee") or ""),
+                str(t.get("points") if t.get("points") is not None else ""),
+            ])
+    statuses = [
+        {"value": status, "label": status.replace("_", " ").title(), "selected": status_filter == status}
+        for status in BOARD_STATUS_ORDER
+    ]
+    return render_page(
+        "tasks.html",
+        title=f"{settings.SITE_NAME} - Tasks",
+        status_filter=status_filter or "",
+        statuses=statuses,
+        cols=["Task", "Project", "Board", "Status", "Priority", "Assignee", "Points"],
+        rows=rows,
+        nav="tasks",
+    )
 
 
 @task_router.get(
-	"/list",
-	response_class=JSONResponse,
+    "/list",
+    response_class=JSONResponse,
 )
 def task_list(
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Board name"),
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Board name"),
 ) -> JSONResponse:
-	"""List all tasks on a board."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = get_board_ref(board)
-	b = get_board(repo, ref)
-	if not b:
-		raise HTTPException(status_code=404, detail="Board not found")
-	task_oids = getattr(b, "tasks", [])
-	tasks = []
-	for oid_hex in task_oids:
-		try:
-			t = _task_from_board_entry(repo, oid_hex, board=board)
-			if t:
-				tasks.append(_task_to_json(t))
-		except (ValueError, KeyError, TypeError):
-			continue
-	return JSONResponse(content=tasks)
+    """List all tasks on a board."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = get_board_ref(board)
+    b = get_board(repo, ref)
+    if not b:
+        raise HTTPException(status_code=404, detail="Board not found")
+    task_oids = getattr(b, "tasks", [])
+    tasks = []
+    for oid_hex in task_oids:
+        try:
+            t = _task_from_board_entry(repo, oid_hex, board=board)
+            if t:
+                tasks.append(_task_to_json(t))
+        except (ValueError, KeyError, TypeError):
+            continue
+    return JSONResponse(content=tasks)
 
 
 @task_router.post(
-	"/create",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
+    "/create",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
 )
 def task_create(
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Board name"),
-	title: str = Query(..., description="Task title"),
-	description: str = Query("", description="Task description"),
-	status: str = Query("TODO", description="Task status"),
-	priority: str = Query("LOW", description="Task priority"),
-	assignee: str = Query("", description="Assignee"),
-	due_date: str = Query("", description="Due date ISO"),
-	tags: str = Query("", description="Task tags (KEY=VALUE list)"),
-	points: str = Query("", description="Sprint story points (Fibonacci: 0,1,2,3,5,8,13,21)"),
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Board name"),
+    title: str = Query(..., description="Task title"),
+    description: str = Query("", description="Task description"),
+    status: str = Query("TODO", description="Task status"),
+    priority: str = Query("LOW", description="Task priority"),
+    assignee: str = Query("", description="Assignee"),
+    due_date: str = Query("", description="Due date ISO"),
+    tags: str = Query("", description="Task tags (KEY=VALUE list)"),
+    points: str = Query("", description="Sprint story points (Fibonacci: 0,1,2,3,5,8,13,21)"),
 ) -> JSONResponse:
-	"""Create a new task on a board."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	b = get_board(repo, get_board_ref(board))
-	if not b:
-		raise HTTPException(status_code=404, detail="Board not found")
-	result = create_task(
-		repo=repo,
-		board=b,
-		title=title,
-		description=description,
-		status=Task.Status(status),
-		priority=Task.Priority(priority),
-		assignee=assignee,
-		due_date=_parse_dt(due_date),
-		tags=tags,
-		points=parse_points(points),
-	)
-	return JSONResponse(content={"task_id": result["id"], "ref": result["ref"], "oid": str(result["oid"])})
+    """Create a new task on a board."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    b = get_board(repo, get_board_ref(board))
+    if not b:
+        raise HTTPException(status_code=404, detail="Board not found")
+    result = create_task(
+        repo=repo,
+        board=b,
+        title=title,
+        description=description,
+        status=Task.Status(status),
+        priority=Task.Priority(priority),
+        assignee=assignee,
+        due_date=_parse_dt(due_date),
+        tags=tags,
+        points=parse_points(points),
+    )
+    return JSONResponse(content={"task_id": result["id"], "ref": result["ref"], "oid": str(result["oid"])})
 
 
 @task_router.post(
-	"/update",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
+    "/update",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
 )
 async def task_update(
-	request: Request,
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Board name"),
-	task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tags/tasks/task_123)"),
+    request: Request,
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Board name"),
+    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, tags)."""
-	try:
-		body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
-	except Exception:
-		body = {}
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
-	t = get_task(repo, ref)
-	if not t:
-		raise HTTPException(status_code=404, detail="Task not found")
-	status_changed = bool("status" in body and hasattr(Task.Status, body["status"]))
-	if "title" in body:
-		t.title = body["title"]
-	if "description" in body:
-		t.description = body.get("description", "")
-	if status_changed:
-		t.status = Task.Status(body["status"])
-	if "priority" in body and hasattr(Task.Priority, body["priority"]):
-		t.priority = Task.Priority(body["priority"])
-	if "assignee" in body:
-		t.assignee = body["assignee"] or None
-	if "points" in body:
-		t.points = parse_points(body["points"])
-	if "due_date" in body:
-		try:
-			from datetime import datetime
-
-			t.due_date = (
-				datetime.fromisoformat(str(body["due_date"]).replace("Z", "+00:00")) if body["due_date"] else None
-			)
-		except ValueError:
-			pass
-	if "tags" in body:
-		t.tags = str(body.get("tags") or "")
-	old_oid = str(repo.references[ref].resolve().target)
-	t.update_message()
-	new_oid = t.write(repo)
-	_sync_board_task_oid(repo, board, old_oid, new_oid, move_to_end=status_changed)
-	return JSONResponse(content=_task_to_json(t))
+    """Update a task (body: optional title, description, status, priority, assignee, due_date, tags)."""
+    try:
+        body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
+    except Exception:
+        body = {}
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
+    t = get_task(repo, ref)
+    if not t:
+        raise HTTPException(status_code=404, detail="Task not found")
+    status_changed = bool("status" in body and hasattr(Task.Status, body["status"]))
+    if "title" in body:
+        t.title = body["title"]
+    if "description" in body:
+        t.description = body.get("description", "")
+    if status_changed:
+        t.status = Task.Status(body["status"])
+    if "priority" in body and hasattr(Task.Priority, body["priority"]):
+        t.priority = Task.Priority(body["priority"])
+    if "assignee" in body:
+        t.assignee = body["assignee"] or None
+    if "points" in body:
+        t.points = parse_points(body["points"])
+    if "due_date" in body:
+        try:
+            from datetime import datetime
+
+            t.due_date = (
+                datetime.fromisoformat(str(body["due_date"]).replace("Z", "+00:00")) if body["due_date"] else None
+            )
+        except ValueError:
+            pass
+    if "tags" in body:
+        t.tags = str(body.get("tags") or "")
+    old_oid = str(repo.references[ref].resolve().target)
+    t.update_message()
+    new_oid = t.write(repo)
+    _sync_board_task_oid(repo, board, old_oid, new_oid, move_to_end=status_changed)
+    return JSONResponse(content=_task_to_json(t))
 
 
 @task_router.post(
-	"/delete",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
+    "/delete",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
 )
 def task_delete(
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Board name"),
-	task_ref: str = Query(..., alias="task", description="Task ref"),
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Board name"),
+    task_ref: str = Query(..., alias="task", description="Task ref"),
 ) -> JSONResponse:
-	"""Delete a task (remove ref, comments, and board entries)."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
-	if ref not in repo.references:
-		raise HTTPException(status_code=404, detail="Task not found")
-	delete_task_local(repo, ref)
-	return JSONResponse(content={"message": "Task deleted"})
+    """Delete a task (remove ref, comments, and board entries)."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
+    if ref not in repo.references:
+        raise HTTPException(status_code=404, detail="Task not found")
+    delete_task_local(repo, ref)
+    return JSONResponse(content={"message": "Task deleted"})
 
 
 @task_router.post(
-	"/archive",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
+    "/archive",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
 )
 def task_archive(
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Source board name"),
-	task_ref: str = Query(..., alias="task", description="Task ref"),
-	archive_board: str = Query(ARCHIVE_BOARD_NAME, description="Archive board name"),
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Source board name"),
+    task_ref: str = Query(..., alias="task", description="Task ref"),
+    archive_board: str = Query(ARCHIVE_BOARD_NAME, description="Archive board name"),
 ) -> JSONResponse:
-	"""Move a task from a board to the archive board (creates archive board if needed)."""
-	if not archive_board or "/" in archive_board or ".." in archive_board:
-		raise HTTPException(status_code=400, detail="Invalid archive board name")
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
-	try:
-		archive_ref = archive_task_local(repo, ref, board, archive_board_name=archive_board)
-	except KeyError as exc:
-		raise HTTPException(status_code=404, detail=str(exc)) from exc
-	return JSONResponse(
-		content={
-			"message": "Task archived",
-			"archive_board": archive_board,
-			"archive_ref": archive_ref,
-		}
-	)
+    """Move a task from a board to the archive board (creates archive board if needed)."""
+    if not archive_board or "/" in archive_board or ".." in archive_board:
+        raise HTTPException(status_code=400, detail="Invalid archive board name")
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
+    try:
+        archive_ref = archive_task_local(repo, ref, board, archive_board_name=archive_board)
+    except KeyError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    return JSONResponse(
+        content={
+            "message": "Task archived",
+            "archive_board": archive_board,
+            "archive_ref": archive_ref,
+        }
+    )
 
 
 @task_router.post(
-	"/move",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
+    "/move",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
 )
 def task_move(
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Source board name"),
-	task_ref: str = Query(..., alias="task", description="Task ref"),
-	target: str = Query(..., description="Target board name (e.g. a sprint board)"),
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Source board name"),
+    task_ref: str = Query(..., alias="task", description="Task ref"),
+    target: str = Query(..., description="Target board name (e.g. a sprint board)"),
 ) -> JSONResponse:
-	"""Move a task onto another board (e.g. pulling it into a sprint board)."""
-	target_ref = get_board_ref(target)
-	if not target or not pygit2.reference_is_valid_name(target_ref):
-		raise HTTPException(status_code=400, detail="Invalid board name")
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
-	if ref not in repo.references:
-		raise HTTPException(status_code=404, detail="Task not found")
-	source_ref = get_board_ref(board)
-	if target_ref not in repo.references:
-		raise HTTPException(status_code=404, detail="Target board not found")
-	tb = get_board(repo, target_ref)
-	if not tb:
-		raise HTTPException(status_code=404, detail="Target board not found")
-	if source_ref == target_ref:
-		return JSONResponse(content={"message": "Task already on board", "board": target})
-	oid_hex = str(repo.references[ref].resolve().target)
-	tb.tasks = getattr(tb, "tasks", []) or []
-	if oid_hex not in tb.tasks:
-		tb.tasks.append(oid_hex)
-		tb.update_message()
-		tb.write(repo)
-	sb = get_board(repo, source_ref) if source_ref in repo.references else None
-	if sb and getattr(sb, "tasks", None) and oid_hex in sb.tasks:
-		sb.tasks = [x for x in sb.tasks if x != oid_hex]
-		sb.update_message()
-		sb.write(repo)
-	return JSONResponse(content={"message": "Task moved", "board": target})
+    """Move a task onto another board (e.g. pulling it into a sprint board)."""
+    target_ref = get_board_ref(target)
+    if not target or not pygit2.reference_is_valid_name(target_ref):
+        raise HTTPException(status_code=400, detail="Invalid board name")
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
+    if ref not in repo.references:
+        raise HTTPException(status_code=404, detail="Task not found")
+    source_ref = get_board_ref(board)
+    if target_ref not in repo.references:
+        raise HTTPException(status_code=404, detail="Target board not found")
+    tb = get_board(repo, target_ref)
+    if not tb:
+        raise HTTPException(status_code=404, detail="Target board not found")
+    if source_ref == target_ref:
+        return JSONResponse(content={"message": "Task already on board", "board": target})
+    oid_hex = str(repo.references[ref].resolve().target)
+    tb.tasks = getattr(tb, "tasks", []) or []
+    if oid_hex not in tb.tasks:
+        tb.tasks.append(oid_hex)
+        tb.update_message()
+        tb.write(repo)
+    sb = get_board(repo, source_ref) if source_ref in repo.references else None
+    if sb and getattr(sb, "tasks", None) and oid_hex in sb.tasks:
+        sb.tasks = [x for x in sb.tasks if x != oid_hex]
+        sb.update_message()
+        sb.write(repo)
+    return JSONResponse(content={"message": "Task moved", "board": target})
 
 
 # ---------- Comment Routes ----------
@@ -566,202 +566,202 @@ comment_router = APIRouter(tags=["comments"])
 
 
 def _parse_tagger_display(tagger: str) -> tuple[str, str | None]:
-	s = (tagger or "").strip()
-	if not s:
-		return ("Unknown", None)
-	m = re.match(r"^(.+?)\s*<([^>]+)>", s)
-	if m:
-		name = m.group(1).strip() or "Unknown"
-		return (name, m.group(2).strip())
-	return (s, None)
+    s = (tagger or "").strip()
+    if not s:
+        return ("Unknown", None)
+    m = re.match(r"^(.+?)\s*<([^>]+)>", s)
+    if m:
+        name = m.group(1).strip() or "Unknown"
+        return (name, m.group(2).strip())
+    return (s, None)
 
 
 def _tagger_from_principal(principal: PermissionPrincipal | None) -> str:
-	if principal is None:
-		return ""
-	email = (principal.email or "").strip() or f"{principal.username}@local"
-	name = principal.username.strip() or email
-	return f"{name} <{email}>"
+    if principal is None:
+        return ""
+    email = (principal.email or "").strip() or f"{principal.username}@local"
+    name = principal.username.strip() or email
+    return f"{name} <{email}>"
 
 
 def _principal_matches_comment_tagger(tagger: str, principal: PermissionPrincipal) -> bool:
-	identity = principal.identity
-	if not identity:
-		return False
-	raw = (tagger or "").strip()
-	if not raw:
-		return False
-	if raw == identity or raw.lower() == identity.lower():
-		return True
-	name, email = _parse_tagger_display(raw)
-	valid_email = email and email.lower() == identity.lower()
-	valid_name = name and (name == identity or name.lower() == identity.lower())
-	return bool(valid_email or valid_name)
+    identity = principal.identity
+    if not identity:
+        return False
+    raw = (tagger or "").strip()
+    if not raw:
+        return False
+    if raw == identity or raw.lower() == identity.lower():
+        return True
+    name, email = _parse_tagger_display(raw)
+    valid_email = email and email.lower() == identity.lower()
+    valid_name = name and (name == identity or name.lower() == identity.lower())
+    return bool(valid_email or valid_name)
 
 
 def _ensure_comment_modify_allowed(project: str, token: str | None, tagger: str) -> None:
-	ensure_active_user_if_auth_enabled(token)
-	if has_permission(Permission.COMMENTS, project, token=token):
-		return
-	principal = principal_from_session(token)
-	if principal is not None and _principal_matches_comment_tagger(tagger, principal):
-		return
-	raise HTTPException(status_code=403, detail="Insufficient permissions")
+    ensure_active_user_if_auth_enabled(token)
+    if has_permission(Permission.COMMENTS, project, token=token):
+        return
+    principal = principal_from_session(token)
+    if principal is not None and _principal_matches_comment_tagger(tagger, principal):
+        return
+    raise HTTPException(status_code=403, detail="Insufficient permissions")
 
 
 def _comment_to_json(c: Comment) -> dict[str, Any]:
-	author, email = _parse_tagger_display(c.tagger)
-	return {
-		"content": c.content,
-		"tagger": c.tagger,
-		"author": author,
-		"gravatar_url": gravatar_url(email) if email else None,
-		"created_at": c.created_at.isoformat() if c.created_at else None,
-		"edited_at": c.edited_at.isoformat() if c.edited_at else None,
-	}
+    author, email = _parse_tagger_display(c.tagger)
+    return {
+        "content": c.content,
+        "tagger": c.tagger,
+        "author": author,
+        "gravatar_url": gravatar_url(email) if email else None,
+        "created_at": c.created_at.isoformat() if c.created_at else None,
+        "edited_at": c.edited_at.isoformat() if c.edited_at else None,
+    }
 
 
 def _comment_sort_key(entry: dict[str, Any]) -> str:
-	return entry.get("edited_at") or entry.get("created_at") or ""
+    return entry.get("edited_at") or entry.get("created_at") or ""
 
 
 @comment_router.get(
-	"/list",
-	response_class=JSONResponse,
+    "/list",
+    response_class=JSONResponse,
 )
 def comment_list(
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Board name"),
-	task: str = Query(..., description="Task ref (e.g. refs/tags/tasks/task_123)"),
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Board name"),
+    task: str = Query(..., description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
-	"""List all comments for a task."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	task_ref = task if task.startswith("refs/") else get_task_ref(task)
-	t = get_task(repo, task_ref)
-	if not t:
-		raise HTTPException(status_code=404, detail="Task not found")
-	comment_oids = getattr(t, "comments", []) or []
-	comments = []
-	for oid_hex in comment_oids:
-		try:
-			oid = pygit2.Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
-			c = get_comment(repo, oid)
-			if c:
-				comments.append(_comment_to_json(c))
-		except (ValueError, KeyError, TypeError):
-			continue
-	comments.sort(key=_comment_sort_key)
-	return JSONResponse(content=comments)
+    """List all comments for a task."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    task_ref = task if task.startswith("refs/") else get_task_ref(task)
+    t = get_task(repo, task_ref)
+    if not t:
+        raise HTTPException(status_code=404, detail="Task not found")
+    comment_oids = getattr(t, "comments", []) or []
+    comments = []
+    for oid_hex in comment_oids:
+        try:
+            oid = pygit2.Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+            c = get_comment(repo, oid)
+            if c:
+                comments.append(_comment_to_json(c))
+        except (ValueError, KeyError, TypeError):
+            continue
+    comments.sort(key=_comment_sort_key)
+    return JSONResponse(content=comments)
 
 
 @comment_router.post(
-	"/create",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.COMMENTS, project_from_query=True))],
+    "/create",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.COMMENTS, project_from_query=True))],
 )
 def comment_create(
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Board name"),
-	task: str = Query(..., description="Task ref"),
-	content: str = Query(..., description="Comment content"),
-	token: Annotated[str | None, Depends(access_token_from_request)] = None,
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Board name"),
+    task: str = Query(..., description="Task ref"),
+    content: str = Query(..., description="Comment content"),
+    token: Annotated[str | None, Depends(access_token_from_request)] = None,
 ) -> JSONResponse:
-	"""Create a new comment on a task."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	task_ref = task if task.startswith("refs/") else get_task_ref(task)
-	t = get_task(repo, task_ref)
-	if not t:
-		raise HTTPException(status_code=404, detail="Task not found")
-	try:
-		target_oid = repo.references[task_ref].resolve().target
-	except KeyError as exc:
-		raise HTTPException(status_code=404, detail="Task not found") from exc
-	tagger = _tagger_from_principal(principal_from_session(token))
-	comment = Comment(target=target_oid, tagger=tagger, content=content or "")
-	comment_oid = comment.write(repo)
-	t.comments = getattr(t, "comments", []) or []
-	t.comments.append(str(comment_oid))
-	old_task_oid = str(target_oid)
-	t.update_message()
-	new_task_oid = t.write(repo)
-	_sync_board_task_oid(repo, board, old_task_oid, new_task_oid)
-	return JSONResponse(
-		content={
-			"oid": str(comment_oid),
-			"message": "Comment created",
-			"comments_count": len(t.comments),
-		}
-	)
+    """Create a new comment on a task."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    task_ref = task if task.startswith("refs/") else get_task_ref(task)
+    t = get_task(repo, task_ref)
+    if not t:
+        raise HTTPException(status_code=404, detail="Task not found")
+    try:
+        target_oid = repo.references[task_ref].resolve().target
+    except KeyError as exc:
+        raise HTTPException(status_code=404, detail="Task not found") from exc
+    tagger = _tagger_from_principal(principal_from_session(token))
+    comment = Comment(target=target_oid, tagger=tagger, content=content or "")
+    comment_oid = comment.write(repo)
+    t.comments = getattr(t, "comments", []) or []
+    t.comments.append(str(comment_oid))
+    old_task_oid = str(target_oid)
+    t.update_message()
+    new_task_oid = t.write(repo)
+    _sync_board_task_oid(repo, board, old_task_oid, new_task_oid)
+    return JSONResponse(
+        content={
+            "oid": str(comment_oid),
+            "message": "Comment created",
+            "comments_count": len(t.comments),
+        }
+    )
 
 
 @comment_router.post(
-	"/modify",
-	response_class=JSONResponse,
+    "/modify",
+    response_class=JSONResponse,
 )
 async def comment_modify(
-	request: Request,
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Board name"),
-	task: str = Query(..., description="Task ref (to update task.comments)"),
-	comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
-	token: Annotated[str | None, Depends(access_token_from_request)] = None,
+    request: Request,
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Board name"),
+    task: str = Query(..., description="Task ref (to update task.comments)"),
+    comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
+    token: Annotated[str | None, Depends(access_token_from_request)] = None,
 ) -> JSONResponse:
-	"""Modify a comment (body: content). Author or pgw.comments grant required."""
-	try:
-		body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
-	except Exception:
-		body = {}
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	oid = pygit2.Oid(hex=comment_oid)
-	c = get_comment(repo, oid)
-	if not c:
-		raise HTTPException(status_code=404, detail="Comment not found")
-	_ensure_comment_modify_allowed(project, token, c.tagger)
-	if "content" in body:
-		c.content = body["content"]
-	from datetime import datetime
-
-	c.edited_at = datetime.now()
-	c.update_message()
-	new_oid = c.write(repo)
-	task_ref = task if task.startswith("refs/") else get_task_ref(task)
-	t = get_task(repo, task_ref)
-	if t and getattr(t, "comments", None):
-		t.comments = [str(new_oid) if str(co) == comment_oid else co for co in t.comments]
-		try:
-			old_task_oid = str(repo.references[task_ref].resolve().target)
-		except KeyError:
-			old_task_oid = ""
-		t.update_message()
-		new_task_oid = t.write(repo)
-		if old_task_oid:
-			_sync_board_task_oid(repo, board, old_task_oid, new_task_oid)
-	return JSONResponse(
-		content={
-			**_comment_to_json(c),
-			"oid": str(new_oid),
-			"message": "Comment updated",
-		}
-	)
+    """Modify a comment (body: content). Author or pgw.comments grant required."""
+    try:
+        body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
+    except Exception:
+        body = {}
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    oid = pygit2.Oid(hex=comment_oid)
+    c = get_comment(repo, oid)
+    if not c:
+        raise HTTPException(status_code=404, detail="Comment not found")
+    _ensure_comment_modify_allowed(project, token, c.tagger)
+    if "content" in body:
+        c.content = body["content"]
+    from datetime import datetime
+
+    c.edited_at = datetime.now()
+    c.update_message()
+    new_oid = c.write(repo)
+    task_ref = task if task.startswith("refs/") else get_task_ref(task)
+    t = get_task(repo, task_ref)
+    if t and getattr(t, "comments", None):
+        t.comments = [str(new_oid) if str(co) == comment_oid else co for co in t.comments]
+        try:
+            old_task_oid = str(repo.references[task_ref].resolve().target)
+        except KeyError:
+            old_task_oid = ""
+        t.update_message()
+        new_task_oid = t.write(repo)
+        if old_task_oid:
+            _sync_board_task_oid(repo, board, old_task_oid, new_task_oid)
+    return JSONResponse(
+        content={
+            **_comment_to_json(c),
+            "oid": str(new_oid),
+            "message": "Comment updated",
+        }
+    )
 
 
 @comment_router.post(
-	"/delete",
-	response_class=JSONResponse,
-	dependencies=[Depends(require_permission(Permission.COMMENTS, project_from_query=True))],
+    "/delete",
+    response_class=JSONResponse,
+    dependencies=[Depends(require_permission(Permission.COMMENTS, project_from_query=True))],
 )
 def comment_delete(
-	project: ValidatedReadableQueryProject,
-	board: str = Query(..., description="Board name"),
-	comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
+    project: ValidatedReadableQueryProject,
+    board: str = Query(..., description="Board name"),
+    comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
 ) -> JSONResponse:
-	"""Delete a comment (object remains in ODB; caller may remove from task.comments)."""
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	oid = pygit2.Oid(hex=comment_oid)
-	try:
-		c = get_comment(repo, oid)
-	except (ValueError, KeyError) as e:
-		raise HTTPException(status_code=404, detail="Comment not found") from e
-	if not c:
-		raise HTTPException(status_code=404, detail="Comment not found")
-	return JSONResponse(content={"message": "Comment deleted"})
+    """Delete a comment (object remains in ODB; caller may remove from task.comments)."""
+    repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    oid = pygit2.Oid(hex=comment_oid)
+    try:
+        c = get_comment(repo, oid)
+    except (ValueError, KeyError) as e:
+        raise HTTPException(status_code=404, detail="Comment not found") from e
+    if not c:
+        raise HTTPException(status_code=404, detail="Comment not found")
+    return JSONResponse(content={"message": "Comment deleted"})
diff --git a/pygitweb/src/pygitweb/tasks_permissions_test.py b/pygitweb/src/pygitweb/tasks_permissions_test.py
index 5f0c949..59fffb4 100644
--- a/pygitweb/src/pygitweb/tasks_permissions_test.py
+++ b/pygitweb/src/pygitweb/tasks_permissions_test.py
@@ -17,120 +17,120 @@ from pygitweb.permissions import Permission, permission_key, permissions_with_de
 from pygitweb.tasks import EMPTY_TREE_OID, task_router
 
 _TASKS_AUTH = AuthConfig(
-	auth_mode="local",
-	local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
-	oauth_permissions=permissions_with_defaults({
-		permission_key(Permission.TASKS, "demo"): ["admin"],
-	}),
+    auth_mode="local",
+    local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
+    oauth_permissions=permissions_with_defaults({
+        permission_key(Permission.TASKS, "demo"): ["admin"],
+    }),
 )
 
 
 def _build_client() -> TestClient:
-	app = FastAPI()
-	app.include_router(task_router, prefix="/tasks")
-	return TestClient(app)
+    app = FastAPI()
+    app.include_router(task_router, prefix="/tasks")
+    return TestClient(app)
 
 
 def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
-	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
-	index = repo.index
-	index.add("README.md")
-	index.write()
-	tree = index.write_tree()
-	sig = pygit2.Signature("tester", "tester@example.com")
-	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+    (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+    index = repo.index
+    index.add("README.md")
+    index.write()
+    tree = index.write_tree()
+    sig = pygit2.Signature("tester", "tester@example.com")
+    repo.create_commit("HEAD", sig, sig, "initial", tree, [])
 
 
 @pytest.fixture
 def task_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
-	repo_dir = tmp_path / "demo"
-	repo = pygit2.init_repository(str(repo_dir), bare=False)
-	_create_initial_commit(repo, repo_dir)
-	board = Board(EMPTY_TREE_OID, f"{BOARD_REF_PREFIX}Tasks", tagger="", description="")
-	board.write(repo)
-	with (
-		patch.object(settings, "PROJECTROOT", str(tmp_path)),
-		patch.object(settings, "PROJECTS_LIST", str(tmp_path)),
-		patch.object(settings, "STRICT_EXPORT", False),
-		patch.object(settings, "EXPORT_OK", ""),
-		patch.object(settings, "LIST_ALL", True),
-		patch("pygitweb.auth_config.auth_config", _TASKS_AUTH),
-		patch("pygitweb.auth.auth_config", _TASKS_AUTH),
-	):
-		yield {"client": _build_client(), "project": "demo", "board": "Tasks"}
+    repo_dir = tmp_path / "demo"
+    repo = pygit2.init_repository(str(repo_dir), bare=False)
+    _create_initial_commit(repo, repo_dir)
+    board = Board(EMPTY_TREE_OID, f"{BOARD_REF_PREFIX}Tasks", tagger="", description="")
+    board.write(repo)
+    with (
+        patch.object(settings, "PROJECTROOT", str(tmp_path)),
+        patch.object(settings, "PROJECTS_LIST", str(tmp_path)),
+        patch.object(settings, "STRICT_EXPORT", False),
+        patch.object(settings, "EXPORT_OK", ""),
+        patch.object(settings, "LIST_ALL", True),
+        patch("pygitweb.auth_config.auth_config", _TASKS_AUTH),
+        patch("pygitweb.auth.auth_config", _TASKS_AUTH),
+    ):
+        yield {"client": _build_client(), "project": "demo", "board": "Tasks"}
 
 
 def test_task_post_open_when_auth_disabled(task_env: dict[str, str | TestClient]) -> None:
-	client = task_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", False):
-		r = client.post(
-			"/tasks/create",
-			params={
-				"project": task_env["project"],
-				"board": task_env["board"],
-				"title": "New task",
-			},
-		)
-	assert r.status_code == 200
+    client = task_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", False):
+        r = client.post(
+            "/tasks/create",
+            params={
+                "project": task_env["project"],
+                "board": task_env["board"],
+                "title": "New task",
+            },
+        )
+    assert r.status_code == 200
 
 
 def test_task_create_401_without_login(task_env: dict[str, str | TestClient]) -> None:
-	client = task_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True):
-		r = client.post(
-			"/tasks/create",
-			params={
-				"project": task_env["project"],
-				"board": task_env["board"],
-				"title": "New task",
-			},
-		)
-	assert r.status_code == 401
+    client = task_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True):
+        r = client.post(
+            "/tasks/create",
+            params={
+                "project": task_env["project"],
+                "board": task_env["board"],
+                "title": "New task",
+            },
+        )
+    assert r.status_code == 401
 
 
 def test_task_create_allowed_with_grant(task_env: dict[str, str | TestClient]) -> None:
-	client = task_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
-		r = client.post(
-			"/tasks/create",
-			params={
-				"project": task_env["project"],
-				"board": task_env["board"],
-				"title": "Granted task",
-			},
-		)
-	assert r.status_code == 200
+    client = task_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
+        r = client.post(
+            "/tasks/create",
+            params={
+                "project": task_env["project"],
+                "board": task_env["board"],
+                "title": "Granted task",
+            },
+        )
+    assert r.status_code == 200
 
 
 def test_task_create_forbidden_without_grant(task_env: dict[str, str | TestClient]) -> None:
-	client = task_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			"/tasks/create",
-			params={
-				"project": task_env["project"],
-				"board": task_env["board"],
-				"title": "Denied task",
-			},
-		)
-	assert r.status_code == 403
+    client = task_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            "/tasks/create",
+            params={
+                "project": task_env["project"],
+                "board": task_env["board"],
+                "title": "Denied task",
+            },
+        )
+    assert r.status_code == 403
 
 
 def test_task_update_forbidden_without_grant(task_env: dict[str, str | TestClient]) -> None:
-	client = task_env["client"]
-	assert isinstance(client, TestClient)
-	with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
-		r = client.post(
-			"/tasks/update",
-			params={
-				"project": task_env["project"],
-				"board": task_env["board"],
-				"task": "refs/tags/tasks/task_missing",
-			},
-			json={"title": "nope"},
-		)
-	assert r.status_code == 403
+    client = task_env["client"]
+    assert isinstance(client, TestClient)
+    with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
+        r = client.post(
+            "/tasks/update",
+            params={
+                "project": task_env["project"],
+                "board": task_env["board"],
+                "task": "refs/tags/tasks/task_missing",
+            },
+            json={"title": "nope"},
+        )
+    assert r.status_code == 403
diff --git a/pygitweb/src/pygitweb/tasks_routes_test.py b/pygitweb/src/pygitweb/tasks_routes_test.py
index 5378717..2b25413 100644
--- a/pygitweb/src/pygitweb/tasks_routes_test.py
+++ b/pygitweb/src/pygitweb/tasks_routes_test.py
@@ -16,583 +16,583 @@ from pygitweb.tasks import EMPTY_TREE_OID, board_router, comment_router, task_ro
 
 
 def _build_client() -> TestClient:
-	app = FastAPI()
-	app.include_router(board_router, prefix="/board")
-	app.include_router(task_router, prefix="/tasks")
-	app.include_router(comment_router, prefix="/comments")
-	return TestClient(app)
+    app = FastAPI()
+    app.include_router(board_router, prefix="/board")
+    app.include_router(task_router, prefix="/tasks")
+    app.include_router(comment_router, prefix="/comments")
+    return TestClient(app)
 
 
 def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
-	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
-	index = repo.index
-	index.add("README.md")
-	index.write()
-	tree = index.write_tree()
-	sig = pygit2.Signature("tester", "tester@example.com")
-	repo.create_commit("HEAD", sig, sig, "initial", tree, [])
+    (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
+    index = repo.index
+    index.add("README.md")
+    index.write()
+    tree = index.write_tree()
+    sig = pygit2.Signature("tester", "tester@example.com")
+    repo.create_commit("HEAD", sig, sig, "initial", tree, [])
 
 
 class TestTaskRoutes:
-	class TestReadonlyResponses:
-		@pytest.fixture(scope="class")
-		def seeded_env(
-			self, tmp_path_factory: pytest.TempPathFactory
-		) -> Generator[dict[str, str | TestClient], None, None]:
-			root: Path = tmp_path_factory.mktemp("tasks-routes-readonly")
-			repo_dir: Path = root / "demo"
-			repo = pygit2.init_repository(str(repo_dir), bare=False)
-			project: str = "demo"
-
-			board_ref: str = f"{BOARD_REF_PREFIX}Tasks"
-			board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main board")
-
-			task_a_ref: str = f"{TASK_REF_PREFIX}task_a"
-			task_a = Task(
-				EMPTY_TREE_OID,
-				task_a_ref,
-				tagger="",
-				title="Alpha",
-				description="Alpha description",
-				status=Task.Status.TODO,
-				priority=Task.Priority.HIGH,
-				assignee="alice",
-			)
-			task_a_oid = task_a.write(repo)
-			comment_a = Comment(task_a_oid, "alice <alice@example.com>", "alpha note")
-			comment_a_oid = comment_a.write(repo)
-			task_a.comments = [str(comment_a_oid)]
-			task_a.update_message()
-			task_a_oid = task_a.write(repo)
-
-			task_b_ref: str = f"{TASK_REF_PREFIX}task_b"
-			task_b = Task(
-				EMPTY_TREE_OID,
-				task_b_ref,
-				tagger="",
-				title="Bravo",
-				description="",
-				status=Task.Status.IN_PROGRESS,
-				priority=Task.Priority.MEDIUM,
-				assignee=None,
-			)
-			task_b_oid = task_b.write(repo)
-
-			board.tasks = [str(task_a_oid), str(task_b_oid)]
-			board.update_message()
-			board.write(repo)
-
-			with (
-				patch.object(settings, "PROJECTROOT", str(root)),
-				patch.object(settings, "PROJECTS_LIST", str(root)),
-				patch.object(settings, "STRICT_EXPORT", False),
-				patch.object(settings, "EXPORT_OK", ""),
-				patch.object(settings, "AUTH", False),
-			):
-				client = _build_client()
-				yield {
-					"client": client,
-					"project": project,
-					"board": "Tasks",
-					"task_a_ref": task_a_ref,
-					"task_b_ref": task_b_ref,
-				}
-
-		def test_board_list_response_format(self, seeded_env: dict[str, str | TestClient]) -> None:
-			client = seeded_env["client"]
-			assert isinstance(client, TestClient)
-			resp = client.get("/board/list", params={"project": seeded_env["project"]})
-			assert resp.status_code == 200
-			body = resp.json()
-			assert isinstance(body, list)
-			assert body == [{"name": "Tasks", "description": "Main board", "task_count": 2}]
-
-		def test_task_list_response_format(self, seeded_env: dict[str, str | TestClient]) -> None:
-			client = seeded_env["client"]
-			assert isinstance(client, TestClient)
-			resp = client.get(
-				"/tasks/list",
-				params={"project": seeded_env["project"], "board": seeded_env["board"]},
-			)
-			assert resp.status_code == 200
-			body = resp.json()
-			assert isinstance(body, list)
-			tasks_by_ref = {entry["ref"]: entry for entry in body}
-			alpha = tasks_by_ref[str(seeded_env["task_a_ref"])]
-			assert alpha["title"] == "Alpha"
-			assert alpha["id"] == "a"
-			assert alpha["description"] == "Alpha description"
-			assert alpha["status"] == "TODO"
-			assert alpha["priority"] == "HIGH"
-			assert alpha["assignee"] == "alice"
-			assert alpha["comments_count"] == 1
-			assert alpha["created_at"] is not None
-			assert alpha["updated_at"] is not None
-
-		def test_comment_list_response_format(self, seeded_env: dict[str, str | TestClient]) -> None:
-			client = seeded_env["client"]
-			assert isinstance(client, TestClient)
-			resp = client.get(
-				"/comments/list",
-				params={
-					"project": seeded_env["project"],
-					"board": seeded_env["board"],
-					"task": seeded_env["task_a_ref"],
-				},
-			)
-			assert resp.status_code == 200
-			body = resp.json()
-			assert isinstance(body, list)
-			assert len(body) == 1
-			comment = body[0]
-			assert comment["content"] == "alpha note"
-			assert comment["tagger"] == "alice <alice@example.com>"
-			assert comment["author"] == "alice"
-			assert comment["gravatar_url"] is not None
-			assert comment["created_at"] is not None
-			assert comment["edited_at"] is not None
-
-	class TestMutableResponses:
-		@pytest.fixture(scope="class")
-		def mutable_env(
-			self, tmp_path_factory: pytest.TempPathFactory
-		) -> Generator[dict[str, str | TestClient], None, None]:
-			root: Path = tmp_path_factory.mktemp("tasks-routes-mutable")
-			repo_dir: Path = root / "demo"
-			repo = pygit2.init_repository(str(repo_dir), bare=False)
-			_create_initial_commit(repo, repo_dir)
-			project: str = "demo"
-			board_name: str = "Tasks"
-			board_ref: str = f"{BOARD_REF_PREFIX}{board_name}"
-			board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Mutable board")
-			task_ref = f"{TASK_REF_PREFIX}mutable_seed"
-			task = Task(
-				EMPTY_TREE_OID,
-				task_ref,
-				tagger="",
-				title="Mutable seed",
-				description="",
-				status=Task.Status.TODO,
-				priority=Task.Priority.LOW,
-			)
-			task_oid = task.write(repo)
-			comment = Comment(task_oid, "seed <seed@example.com>", "seed comment")
-			comment_oid = comment.write(repo)
-			task.comments = [str(comment_oid)]
-			task.update_message()
-			task_oid = task.write(repo)
-			board.tasks = [str(task_oid)]
-			board.update_message()
-			board.write(repo)
-
-			with (
-				patch.object(settings, "PROJECTROOT", str(root)),
-				patch.object(settings, "PROJECTS_LIST", str(root)),
-				patch.object(settings, "STRICT_EXPORT", False),
-				patch.object(settings, "EXPORT_OK", ""),
-				patch.object(settings, "AUTH", False),
-			):
-				client = _build_client()
-				yield {
-					"client": client,
-					"project": project,
-					"board": board_name,
-					"seed_comment_oid": str(comment_oid),
-				}
-
-		def test_task_create_and_update_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			create_resp = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "Created Task",
-					"description": "Needs updates",
-					"status": "TODO",
-					"priority": "LOW",
-				},
-			)
-			assert create_resp.status_code == 200
-			created = create_resp.json()
-			assert set(created.keys()) == {"task_id", "ref", "oid"}
-			assert str(created["ref"]).startswith("refs/tags/tasks/task_")
-
-			update_resp = client.post(
-				"/tasks/update",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"task": created["ref"],
-				},
-				json={
-					"title": "Updated Task",
-					"description": "Updated description",
-					"status": "IN_REVIEW",
-					"priority": "CRITICAL",
-					"assignee": "bob",
-					"due_date": "2025-02-03T04:05:06+00:00",
-				},
-			)
-			assert update_resp.status_code == 200
-			updated = update_resp.json()
-			assert updated["ref"] == created["ref"]
-			assert updated["title"] == "Updated Task"
-			assert updated["description"] == "Updated description"
-			assert updated["status"] == "IN_REVIEW"
-			assert updated["priority"] == "CRITICAL"
-			assert updated["assignee"] == "bob"
-			assert updated["due_date"] == "2025-02-03T04:05:06+00:00"
-
-		def test_task_create_and_update_points_snap_to_fibonacci(
-			self, mutable_env: dict[str, str | TestClient]
-		) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			create_resp = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "Pointed task",
-					"points": "5",
-				},
-			)
-			assert create_resp.status_code == 200
-			task_ref = create_resp.json()["ref"]
-			list_resp = client.get(
-				"/tasks/list",
-				params={"project": mutable_env["project"], "board": mutable_env["board"]},
-			)
-			by_ref = {entry["ref"]: entry for entry in list_resp.json()}
-			assert by_ref[task_ref]["points"] == 5
-
-			update_resp = client.post(
-				"/tasks/update",
-				params={"project": mutable_env["project"], "board": mutable_env["board"], "task": task_ref},
-				json={"points": 4},
-			)
-			assert update_resp.status_code == 200
-			assert update_resp.json()["points"] is None
-
-			clear_resp = client.post(
-				"/tasks/update",
-				params={"project": mutable_env["project"], "board": mutable_env["board"], "task": task_ref},
-				json={"points": None},
-			)
-			assert clear_resp.status_code == 200
-			assert clear_resp.json()["points"] is None
-
-		def test_task_move_transfers_task_between_boards(self, mutable_env: dict[str, str | TestClient]) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			create_resp = client.post(
-				"/board/create",
-				params={"project": mutable_env["project"], "name": "Sprint-1"},
-			)
-			assert create_resp.status_code == 200
-			task_ref = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "Movable task",
-				},
-			).json()["ref"]
-
-			move_resp = client.post(
-				"/tasks/move",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"task": task_ref,
-					"target": "Sprint-1",
-				},
-			)
-			assert move_resp.status_code == 200
-			assert move_resp.json() == {"message": "Task moved", "board": "Sprint-1"}
-
-			source_list = client.get(
-				"/tasks/list",
-				params={"project": mutable_env["project"], "board": mutable_env["board"]},
-			).json()
-			assert task_ref not in [t["ref"] for t in source_list]
-
-			target_list = client.get(
-				"/tasks/list",
-				params={"project": mutable_env["project"], "board": "Sprint-1"},
-			).json()
-			assert task_ref in [t["ref"] for t in target_list]
-
-			client.post(
-				"/board/delete",
-				params={"project": mutable_env["project"], "name": "Sprint-1"},
-			)
-
-		def test_board_create_rejects_invalid_ref_name(self, mutable_env: dict[str, str | TestClient]) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			resp = client.post(
-				"/board/create",
-				params={"project": mutable_env["project"], "name": "Sprint 1"},
-			)
-			assert resp.status_code == 400
-
-		def test_task_move_to_missing_board_returns_404(self, mutable_env: dict[str, str | TestClient]) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			task_ref = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "Orphan candidate",
-				},
-			).json()["ref"]
-			move_resp = client.post(
-				"/tasks/move",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"task": task_ref,
-					"target": "does-not-exist",
-				},
-			)
-			assert move_resp.status_code == 404
-
-		def test_task_status_update_moves_oid_to_end_of_board_list(
-			self, mutable_env: dict[str, str | TestClient]
-		) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			a_ref = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "First",
-					"status": "TODO",
-				},
-			).json()["ref"]
-			b_ref = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "Second",
-					"status": "TODO",
-				},
-			).json()["ref"]
-			list_before = client.get(
-				"/tasks/list",
-				params={"project": mutable_env["project"], "board": mutable_env["board"]},
-			).json()
-			refs_before = [t["ref"] for t in list_before if t["title"] in ("First", "Second")]
-			assert refs_before.index(a_ref) < refs_before.index(b_ref)
-			client.post(
-				"/tasks/update",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"task": a_ref,
-				},
-				json={"status": "IN_PROGRESS"},
-			)
-			repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
-			loaded = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
-			assert loaded is not None
-			a_oid = str(repo.references[a_ref].resolve().target)
-			b_oid = str(repo.references[b_ref].resolve().target)
-			assert loaded.tasks.index(b_oid) < loaded.tasks.index(a_oid)
-
-		def test_task_list_resolves_stale_board_task_oid(self, mutable_env: dict[str, str | TestClient]) -> None:
-			"""Board may still list an old task OID after the ref was rewritten."""
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
-			board = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
-			assert board is not None
-			stale_oid = board.tasks[0]
-			task_ref = f"{TASK_REF_PREFIX}mutable_seed"
-			t = Task(
-				EMPTY_TREE_OID,
-				task_ref,
-				tagger="",
-				title="Stale board entry",
-				description="",
-				status=Task.Status.TODO,
-				priority=Task.Priority.LOW,
-			)
-			t.comments = ["abc123"]
-			t.update_message()
-			current_oid = t.write(repo)
-			board.tasks = [stale_oid]
-			board.update_message()
-			board.write(repo)
-
-			list_resp = client.get(
-				"/tasks/list",
-				params={"project": mutable_env["project"], "board": mutable_env["board"]},
-			)
-			assert list_resp.status_code == 200
-			by_ref = {entry["ref"]: entry for entry in list_resp.json()}
-			assert by_ref[task_ref]["comments_count"] == 1
-			reloaded = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
-			assert reloaded is not None
-			assert str(current_oid) in reloaded.tasks
-
-		def test_comment_create_updates_task_list_comments_count(
-			self, mutable_env: dict[str, str | TestClient]
-		) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			create_resp = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "Comment count task",
-				},
-			)
-			assert create_resp.status_code == 200
-			task_ref: str = create_resp.json()["ref"]
-			comment_resp = client.post(
-				"/comments/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"task": task_ref,
-					"content": "board count note",
-				},
-			)
-			assert comment_resp.status_code == 200
-			assert comment_resp.json()["comments_count"] == 1
-			list_resp = client.get(
-				"/tasks/list",
-				params={"project": mutable_env["project"], "board": mutable_env["board"]},
-			)
-			assert list_resp.status_code == 200
-			by_ref = {entry["ref"]: entry for entry in list_resp.json()}
-			assert by_ref[task_ref]["comments_count"] == 1
-			repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
-			board = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
-			assert board is not None
-			ref_oid = str(repo.references[task_ref].resolve().target)
-			assert ref_oid in board.tasks
-
-		def test_comment_delete_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			delete_comment_resp = client.post(
-				"/comments/delete",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"comment": mutable_env["seed_comment_oid"],
-				},
-			)
-			assert delete_comment_resp.status_code == 200
-			assert delete_comment_resp.json() == {"message": "Comment deleted"}
-
-		def test_task_delete_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			create_resp = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "To delete",
-				},
-			)
-			task_ref: str = create_resp.json()["ref"]
-			delete_resp = client.post(
-				"/tasks/delete",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"task": task_ref,
-				},
-			)
-			assert delete_resp.status_code == 200
-			assert delete_resp.json() == {"message": "Task deleted"}
-
-			repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
-			assert task_ref not in repo.references
-
-		def test_task_archive_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			create_resp = client.post(
-				"/tasks/create",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"title": "To archive",
-				},
-			)
-			task_ref: str = create_resp.json()["ref"]
-			archive_resp = client.post(
-				"/tasks/archive",
-				params={
-					"project": mutable_env["project"],
-					"board": mutable_env["board"],
-					"task": task_ref,
-				},
-			)
-			assert archive_resp.status_code == 200
-			archived = archive_resp.json()
-			assert archived["message"] == "Task archived"
-			assert archived["archive_board"] == "Archive"
-			assert archived["archive_ref"] == f"{BOARD_REF_PREFIX}Archive"
-
-			repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
-			assert task_ref in repo.references
-			main_board = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
-			assert main_board is not None
-			main_oids = [str(o) for o in main_board.tasks]
-			archived_oid = str(repo.references[task_ref].resolve().target)
-			assert archived_oid not in main_oids
-			archive_board = get_board(repo, f"{BOARD_REF_PREFIX}Archive")
-			assert archive_board is not None
-			assert len(archive_board.tasks) == 1
-
-			list_resp = client.get(
-				"/tasks/list",
-				params={"project": mutable_env["project"], "board": mutable_env["board"]},
-			)
-			refs_on_main = [t["ref"] for t in list_resp.json()]
-			assert task_ref not in refs_on_main
-
-			archive_list_resp = client.get(
-				"/tasks/list",
-				params={"project": mutable_env["project"], "board": "Archive"},
-			)
-			refs_on_archive = [t["ref"] for t in archive_list_resp.json()]
-			assert task_ref in refs_on_archive
-
-		def test_board_create_and_delete_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
-			client = mutable_env["client"]
-			assert isinstance(client, TestClient)
-			create_resp = client.post(
-				"/board/create",
-				params={
-					"project": mutable_env["project"],
-					"name": "temp-board",
-					"description": "Temporary",
-				},
-			)
-			assert create_resp.status_code == 200
-			created = create_resp.json()
-			assert set(created.keys()) == {"name", "ref", "oid"}
-			assert created["name"] == "temp-board"
-			assert created["ref"] == f"{BOARD_REF_PREFIX}temp-board"
-			Oid(hex=created["oid"])
-
-			delete_resp = client.post(
-				"/board/delete",
-				params={"project": mutable_env["project"], "name": "temp-board"},
-			)
-			assert delete_resp.status_code == 200
-			assert delete_resp.json() == {"message": "Board deleted"}
+    class TestReadonlyResponses:
+        @pytest.fixture(scope="class")
+        def seeded_env(
+            self, tmp_path_factory: pytest.TempPathFactory
+        ) -> Generator[dict[str, str | TestClient], None, None]:
+            root: Path = tmp_path_factory.mktemp("tasks-routes-readonly")
+            repo_dir: Path = root / "demo"
+            repo = pygit2.init_repository(str(repo_dir), bare=False)
+            project: str = "demo"
+
+            board_ref: str = f"{BOARD_REF_PREFIX}Tasks"
+            board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main board")
+
+            task_a_ref: str = f"{TASK_REF_PREFIX}task_a"
+            task_a = Task(
+                EMPTY_TREE_OID,
+                task_a_ref,
+                tagger="",
+                title="Alpha",
+                description="Alpha description",
+                status=Task.Status.TODO,
+                priority=Task.Priority.HIGH,
+                assignee="alice",
+            )
+            task_a_oid = task_a.write(repo)
+            comment_a = Comment(task_a_oid, "alice <alice@example.com>", "alpha note")
+            comment_a_oid = comment_a.write(repo)
+            task_a.comments = [str(comment_a_oid)]
+            task_a.update_message()
+            task_a_oid = task_a.write(repo)
+
+            task_b_ref: str = f"{TASK_REF_PREFIX}task_b"
+            task_b = Task(
+                EMPTY_TREE_OID,
+                task_b_ref,
+                tagger="",
+                title="Bravo",
+                description="",
+                status=Task.Status.IN_PROGRESS,
+                priority=Task.Priority.MEDIUM,
+                assignee=None,
+            )
+            task_b_oid = task_b.write(repo)
+
+            board.tasks = [str(task_a_oid), str(task_b_oid)]
+            board.update_message()
+            board.write(repo)
+
+            with (
+                patch.object(settings, "PROJECTROOT", str(root)),
+                patch.object(settings, "PROJECTS_LIST", str(root)),
+                patch.object(settings, "STRICT_EXPORT", False),
+                patch.object(settings, "EXPORT_OK", ""),
+                patch.object(settings, "AUTH", False),
+            ):
+                client = _build_client()
+                yield {
+                    "client": client,
+                    "project": project,
+                    "board": "Tasks",
+                    "task_a_ref": task_a_ref,
+                    "task_b_ref": task_b_ref,
+                }
+
+        def test_board_list_response_format(self, seeded_env: dict[str, str | TestClient]) -> None:
+            client = seeded_env["client"]
+            assert isinstance(client, TestClient)
+            resp = client.get("/board/list", params={"project": seeded_env["project"]})
+            assert resp.status_code == 200
+            body = resp.json()
+            assert isinstance(body, list)
+            assert body == [{"name": "Tasks", "description": "Main board", "task_count": 2}]
+
+        def test_task_list_response_format(self, seeded_env: dict[str, str | TestClient]) -> None:
+            client = seeded_env["client"]
+            assert isinstance(client, TestClient)
+            resp = client.get(
+                "/tasks/list",
+                params={"project": seeded_env["project"], "board": seeded_env["board"]},
+            )
+            assert resp.status_code == 200
+            body = resp.json()
+            assert isinstance(body, list)
+            tasks_by_ref = {entry["ref"]: entry for entry in body}
+            alpha = tasks_by_ref[str(seeded_env["task_a_ref"])]
+            assert alpha["title"] == "Alpha"
+            assert alpha["id"] == "a"
+            assert alpha["description"] == "Alpha description"
+            assert alpha["status"] == "TODO"
+            assert alpha["priority"] == "HIGH"
+            assert alpha["assignee"] == "alice"
+            assert alpha["comments_count"] == 1
+            assert alpha["created_at"] is not None
+            assert alpha["updated_at"] is not None
+
+        def test_comment_list_response_format(self, seeded_env: dict[str, str | TestClient]) -> None:
+            client = seeded_env["client"]
+            assert isinstance(client, TestClient)
+            resp = client.get(
+                "/comments/list",
+                params={
+                    "project": seeded_env["project"],
+                    "board": seeded_env["board"],
+                    "task": seeded_env["task_a_ref"],
+                },
+            )
+            assert resp.status_code == 200
+            body = resp.json()
+            assert isinstance(body, list)
+            assert len(body) == 1
+            comment = body[0]
+            assert comment["content"] == "alpha note"
+            assert comment["tagger"] == "alice <alice@example.com>"
+            assert comment["author"] == "alice"
+            assert comment["gravatar_url"] is not None
+            assert comment["created_at"] is not None
+            assert comment["edited_at"] is not None
+
+    class TestMutableResponses:
+        @pytest.fixture(scope="class")
+        def mutable_env(
+            self, tmp_path_factory: pytest.TempPathFactory
+        ) -> Generator[dict[str, str | TestClient], None, None]:
+            root: Path = tmp_path_factory.mktemp("tasks-routes-mutable")
+            repo_dir: Path = root / "demo"
+            repo = pygit2.init_repository(str(repo_dir), bare=False)
+            _create_initial_commit(repo, repo_dir)
+            project: str = "demo"
+            board_name: str = "Tasks"
+            board_ref: str = f"{BOARD_REF_PREFIX}{board_name}"
+            board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Mutable board")
+            task_ref = f"{TASK_REF_PREFIX}mutable_seed"
+            task = Task(
+                EMPTY_TREE_OID,
+                task_ref,
+                tagger="",
+                title="Mutable seed",
+                description="",
+                status=Task.Status.TODO,
+                priority=Task.Priority.LOW,
+            )
+            task_oid = task.write(repo)
+            comment = Comment(task_oid, "seed <seed@example.com>", "seed comment")
+            comment_oid = comment.write(repo)
+            task.comments = [str(comment_oid)]
+            task.update_message()
+            task_oid = task.write(repo)
+            board.tasks = [str(task_oid)]
+            board.update_message()
+            board.write(repo)
+
+            with (
+                patch.object(settings, "PROJECTROOT", str(root)),
+                patch.object(settings, "PROJECTS_LIST", str(root)),
+                patch.object(settings, "STRICT_EXPORT", False),
+                patch.object(settings, "EXPORT_OK", ""),
+                patch.object(settings, "AUTH", False),
+            ):
+                client = _build_client()
+                yield {
+                    "client": client,
+                    "project": project,
+                    "board": board_name,
+                    "seed_comment_oid": str(comment_oid),
+                }
+
+        def test_task_create_and_update_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            create_resp = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "Created Task",
+                    "description": "Needs updates",
+                    "status": "TODO",
+                    "priority": "LOW",
+                },
+            )
+            assert create_resp.status_code == 200
+            created = create_resp.json()
+            assert set(created.keys()) == {"task_id", "ref", "oid"}
+            assert str(created["ref"]).startswith("refs/tags/tasks/task_")
+
+            update_resp = client.post(
+                "/tasks/update",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "task": created["ref"],
+                },
+                json={
+                    "title": "Updated Task",
+                    "description": "Updated description",
+                    "status": "IN_REVIEW",
+                    "priority": "CRITICAL",
+                    "assignee": "bob",
+                    "due_date": "2025-02-03T04:05:06+00:00",
+                },
+            )
+            assert update_resp.status_code == 200
+            updated = update_resp.json()
+            assert updated["ref"] == created["ref"]
+            assert updated["title"] == "Updated Task"
+            assert updated["description"] == "Updated description"
+            assert updated["status"] == "IN_REVIEW"
+            assert updated["priority"] == "CRITICAL"
+            assert updated["assignee"] == "bob"
+            assert updated["due_date"] == "2025-02-03T04:05:06+00:00"
+
+        def test_task_create_and_update_points_snap_to_fibonacci(
+            self, mutable_env: dict[str, str | TestClient]
+        ) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            create_resp = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "Pointed task",
+                    "points": "5",
+                },
+            )
+            assert create_resp.status_code == 200
+            task_ref = create_resp.json()["ref"]
+            list_resp = client.get(
+                "/tasks/list",
+                params={"project": mutable_env["project"], "board": mutable_env["board"]},
+            )
+            by_ref = {entry["ref"]: entry for entry in list_resp.json()}
+            assert by_ref[task_ref]["points"] == 5
+
+            update_resp = client.post(
+                "/tasks/update",
+                params={"project": mutable_env["project"], "board": mutable_env["board"], "task": task_ref},
+                json={"points": 4},
+            )
+            assert update_resp.status_code == 200
+            assert update_resp.json()["points"] is None
+
+            clear_resp = client.post(
+                "/tasks/update",
+                params={"project": mutable_env["project"], "board": mutable_env["board"], "task": task_ref},
+                json={"points": None},
+            )
+            assert clear_resp.status_code == 200
+            assert clear_resp.json()["points"] is None
+
+        def test_task_move_transfers_task_between_boards(self, mutable_env: dict[str, str | TestClient]) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            create_resp = client.post(
+                "/board/create",
+                params={"project": mutable_env["project"], "name": "Sprint-1"},
+            )
+            assert create_resp.status_code == 200
+            task_ref = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "Movable task",
+                },
+            ).json()["ref"]
+
+            move_resp = client.post(
+                "/tasks/move",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "task": task_ref,
+                    "target": "Sprint-1",
+                },
+            )
+            assert move_resp.status_code == 200
+            assert move_resp.json() == {"message": "Task moved", "board": "Sprint-1"}
+
+            source_list = client.get(
+                "/tasks/list",
+                params={"project": mutable_env["project"], "board": mutable_env["board"]},
+            ).json()
+            assert task_ref not in [t["ref"] for t in source_list]
+
+            target_list = client.get(
+                "/tasks/list",
+                params={"project": mutable_env["project"], "board": "Sprint-1"},
+            ).json()
+            assert task_ref in [t["ref"] for t in target_list]
+
+            client.post(
+                "/board/delete",
+                params={"project": mutable_env["project"], "name": "Sprint-1"},
+            )
+
+        def test_board_create_rejects_invalid_ref_name(self, mutable_env: dict[str, str | TestClient]) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            resp = client.post(
+                "/board/create",
+                params={"project": mutable_env["project"], "name": "Sprint 1"},
+            )
+            assert resp.status_code == 400
+
+        def test_task_move_to_missing_board_returns_404(self, mutable_env: dict[str, str | TestClient]) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            task_ref = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "Orphan candidate",
+                },
+            ).json()["ref"]
+            move_resp = client.post(
+                "/tasks/move",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "task": task_ref,
+                    "target": "does-not-exist",
+                },
+            )
+            assert move_resp.status_code == 404
+
+        def test_task_status_update_moves_oid_to_end_of_board_list(
+            self, mutable_env: dict[str, str | TestClient]
+        ) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            a_ref = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "First",
+                    "status": "TODO",
+                },
+            ).json()["ref"]
+            b_ref = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "Second",
+                    "status": "TODO",
+                },
+            ).json()["ref"]
+            list_before = client.get(
+                "/tasks/list",
+                params={"project": mutable_env["project"], "board": mutable_env["board"]},
+            ).json()
+            refs_before = [t["ref"] for t in list_before if t["title"] in ("First", "Second")]
+            assert refs_before.index(a_ref) < refs_before.index(b_ref)
+            client.post(
+                "/tasks/update",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "task": a_ref,
+                },
+                json={"status": "IN_PROGRESS"},
+            )
+            repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
+            loaded = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
+            assert loaded is not None
+            a_oid = str(repo.references[a_ref].resolve().target)
+            b_oid = str(repo.references[b_ref].resolve().target)
+            assert loaded.tasks.index(b_oid) < loaded.tasks.index(a_oid)
+
+        def test_task_list_resolves_stale_board_task_oid(self, mutable_env: dict[str, str | TestClient]) -> None:
+            """Board may still list an old task OID after the ref was rewritten."""
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
+            board = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
+            assert board is not None
+            stale_oid = board.tasks[0]
+            task_ref = f"{TASK_REF_PREFIX}mutable_seed"
+            t = Task(
+                EMPTY_TREE_OID,
+                task_ref,
+                tagger="",
+                title="Stale board entry",
+                description="",
+                status=Task.Status.TODO,
+                priority=Task.Priority.LOW,
+            )
+            t.comments = ["abc123"]
+            t.update_message()
+            current_oid = t.write(repo)
+            board.tasks = [stale_oid]
+            board.update_message()
+            board.write(repo)
+
+            list_resp = client.get(
+                "/tasks/list",
+                params={"project": mutable_env["project"], "board": mutable_env["board"]},
+            )
+            assert list_resp.status_code == 200
+            by_ref = {entry["ref"]: entry for entry in list_resp.json()}
+            assert by_ref[task_ref]["comments_count"] == 1
+            reloaded = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
+            assert reloaded is not None
+            assert str(current_oid) in reloaded.tasks
+
+        def test_comment_create_updates_task_list_comments_count(
+            self, mutable_env: dict[str, str | TestClient]
+        ) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            create_resp = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "Comment count task",
+                },
+            )
+            assert create_resp.status_code == 200
+            task_ref: str = create_resp.json()["ref"]
+            comment_resp = client.post(
+                "/comments/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "task": task_ref,
+                    "content": "board count note",
+                },
+            )
+            assert comment_resp.status_code == 200
+            assert comment_resp.json()["comments_count"] == 1
+            list_resp = client.get(
+                "/tasks/list",
+                params={"project": mutable_env["project"], "board": mutable_env["board"]},
+            )
+            assert list_resp.status_code == 200
+            by_ref = {entry["ref"]: entry for entry in list_resp.json()}
+            assert by_ref[task_ref]["comments_count"] == 1
+            repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
+            board = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
+            assert board is not None
+            ref_oid = str(repo.references[task_ref].resolve().target)
+            assert ref_oid in board.tasks
+
+        def test_comment_delete_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            delete_comment_resp = client.post(
+                "/comments/delete",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "comment": mutable_env["seed_comment_oid"],
+                },
+            )
+            assert delete_comment_resp.status_code == 200
+            assert delete_comment_resp.json() == {"message": "Comment deleted"}
+
+        def test_task_delete_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            create_resp = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "To delete",
+                },
+            )
+            task_ref: str = create_resp.json()["ref"]
+            delete_resp = client.post(
+                "/tasks/delete",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "task": task_ref,
+                },
+            )
+            assert delete_resp.status_code == 200
+            assert delete_resp.json() == {"message": "Task deleted"}
+
+            repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
+            assert task_ref not in repo.references
+
+        def test_task_archive_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            create_resp = client.post(
+                "/tasks/create",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "title": "To archive",
+                },
+            )
+            task_ref: str = create_resp.json()["ref"]
+            archive_resp = client.post(
+                "/tasks/archive",
+                params={
+                    "project": mutable_env["project"],
+                    "board": mutable_env["board"],
+                    "task": task_ref,
+                },
+            )
+            assert archive_resp.status_code == 200
+            archived = archive_resp.json()
+            assert archived["message"] == "Task archived"
+            assert archived["archive_board"] == "Archive"
+            assert archived["archive_ref"] == f"{BOARD_REF_PREFIX}Archive"
+
+            repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / str(mutable_env["project"])))
+            assert task_ref in repo.references
+            main_board = get_board(repo, f"{BOARD_REF_PREFIX}{mutable_env['board']}")
+            assert main_board is not None
+            main_oids = [str(o) for o in main_board.tasks]
+            archived_oid = str(repo.references[task_ref].resolve().target)
+            assert archived_oid not in main_oids
+            archive_board = get_board(repo, f"{BOARD_REF_PREFIX}Archive")
+            assert archive_board is not None
+            assert len(archive_board.tasks) == 1
+
+            list_resp = client.get(
+                "/tasks/list",
+                params={"project": mutable_env["project"], "board": mutable_env["board"]},
+            )
+            refs_on_main = [t["ref"] for t in list_resp.json()]
+            assert task_ref not in refs_on_main
+
+            archive_list_resp = client.get(
+                "/tasks/list",
+                params={"project": mutable_env["project"], "board": "Archive"},
+            )
+            refs_on_archive = [t["ref"] for t in archive_list_resp.json()]
+            assert task_ref in refs_on_archive
+
+        def test_board_create_and_delete_response_format(self, mutable_env: dict[str, str | TestClient]) -> None:
+            client = mutable_env["client"]
+            assert isinstance(client, TestClient)
+            create_resp = client.post(
+                "/board/create",
+                params={
+                    "project": mutable_env["project"],
+                    "name": "temp-board",
+                    "description": "Temporary",
+                },
+            )
+            assert create_resp.status_code == 200
+            created = create_resp.json()
+            assert set(created.keys()) == {"name", "ref", "oid"}
+            assert created["name"] == "temp-board"
+            assert created["ref"] == f"{BOARD_REF_PREFIX}temp-board"
+            Oid(hex=created["oid"])
+
+            delete_resp = client.post(
+                "/board/delete",
+                params={"project": mutable_env["project"], "name": "temp-board"},
+            )
+            assert delete_resp.status_code == 200
+            assert delete_resp.json() == {"message": "Board deleted"}
diff --git a/pygitweb/src/pygitweb/team.py b/pygitweb/src/pygitweb/team.py
index 01c4bd8..8f298d0 100644
--- a/pygitweb/src/pygitweb/team.py
+++ b/pygitweb/src/pygitweb/team.py
@@ -14,10 +14,10 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Query
 from fastapi.responses import HTMLResponse, RedirectResponse
 
 from pygitweb.auth import (
-	access_token_from_request,
-	ensure_active_user_if_auth_enabled,
-	has_permission,
-	principal_from_session,
+    access_token_from_request,
+    ensure_active_user_if_auth_enabled,
+    has_permission,
+    principal_from_session,
 )
 from pygitweb.capacity_config import current_iso_week, get_user_week_capacity, set_user_week_capacity
 from pygitweb.config import settings
@@ -36,148 +36,148 @@ _ISO_WEEK_RE = re.compile(r"^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$")
 
 
 def _gather_open_tasks_by_assignee(token: str | None) -> dict[str, list[dict[str, Any]]]:
-	projects = filter_projects_by_read_access(
-		git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
-		token,
-	)
-	by_assignee: dict[str, list[dict[str, Any]]] = defaultdict(list)
-	for pr in projects:
-		project = pr.get("path", "")
-		if not project:
-			continue
-		try:
-			tasks = get_all_project_tasks(project)
-		except Exception:
-			continue
-		for t in tasks:
-			if t.get("status") not in OPEN_STATUSES:
-				continue
-			assignee = (t.get("assignee") or "").strip() or "Unassigned"
-			t["project"] = project
-			by_assignee[assignee].append(t)
-	return by_assignee
+    projects = filter_projects_by_read_access(
+        git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
+        token,
+    )
+    by_assignee: dict[str, list[dict[str, Any]]] = defaultdict(list)
+    for pr in projects:
+        project = pr.get("path", "")
+        if not project:
+            continue
+        try:
+            tasks = get_all_project_tasks(project)
+        except Exception:
+            continue
+        for t in tasks:
+            if t.get("status") not in OPEN_STATUSES:
+                continue
+            assignee = (t.get("assignee") or "").strip() or "Unassigned"
+            t["project"] = project
+            by_assignee[assignee].append(t)
+    return by_assignee
 
 
 def _ensure_can_set_capacity(token: str | None, target_user: str) -> None:
-	if not settings.AUTH:
-		return
-	ensure_active_user_if_auth_enabled(token)
-	principal = principal_from_session(token)
-	if principal is not None and principal.username == target_user:
-		return
-	if has_permission(Permission.SETTINGS, token=token):
-		return
-	raise HTTPException(status_code=403, detail="Insufficient permissions")
+    if not settings.AUTH:
+        return
+    ensure_active_user_if_auth_enabled(token)
+    principal = principal_from_session(token)
+    if principal is not None and principal.username == target_user:
+        return
+    if has_permission(Permission.SETTINGS, token=token):
+        return
+    raise HTTPException(status_code=403, detail="Insufficient permissions")
 
 
 @team_router.get("", response_class=HTMLResponse)
 def team_page(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	week: Annotated[str | None, Query()] = None,
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    week: Annotated[str | None, Query()] = None,
 ) -> HTMLResponse:
-	"""Team overview: open tasks grouped by assignee, with weekly point capacity vs. load."""
-	iso_week = (week or current_iso_week()).strip()
-	if not _ISO_WEEK_RE.match(iso_week):
-		iso_week = current_iso_week()
-	by_assignee = _gather_open_tasks_by_assignee(token)
-
-	principal = principal_from_session(token)
-	current_username = principal.username if principal else None
-	can_edit_others = (not settings.AUTH) or has_permission(Permission.SETTINGS, token=token)
-
-	groups: list[dict[str, Any]] = []
-	for assignee in sorted(by_assignee, key=lambda a: (a == "Unassigned", a.lower())):
-		tasks = sorted(by_assignee[assignee], key=lambda t: (t.get("priority") or "", t.get("title") or ""))
-		points_total = sum(t.get("points") or 0 for t in tasks)
-		is_unassigned = assignee == "Unassigned"
-		capacity = None if is_unassigned else get_user_week_capacity(assignee, iso_week)
-
-		rows: list[list[Cell]] = []
-		for t in tasks:
-			project_enc = quote(t["project"], safe="/")
-			board_enc = quote(t["board"], safe="")
-			task_enc = quote(t["ref"], safe="")
-			project_url = f"/project/{project_enc}"
-			task_url = f"{project_url}/board/?b={board_enc}&task={task_enc}"
-			rows.append([
-				html_link(task_url, t.get("title") or "(untitled)"),
-				html_link(project_url, t["project"]),
-				t.get("board") or "",
-				(t.get("status") or "").replace("_", " ").title(),
-				(t.get("priority") or "").title(),
-				str(t.get("points") if t.get("points") is not None else ""),
-			])
-		can_edit = (not is_unassigned) and (can_edit_others or assignee == current_username)
-		capacity_url = f"/team/capacity?user={quote(assignee, safe='')}&week={quote(iso_week, safe='')}"
-		capacity_pct = 0
-		if capacity:
-			capacity_pct = min(100, round(100 * points_total / capacity))
-		groups.append({
-			"assignee": assignee,
-			"points_total": points_total,
-			"capacity": capacity,
-			"capacity_pct": capacity_pct,
-			"over_capacity": capacity is not None and points_total > capacity,
-			"can_edit": can_edit,
-			"capacity_url": capacity_url,
-			"rows": rows,
-		})
-
-	return render_page(
-		"team.html",
-		title=f"{settings.SITE_NAME} - Team",
-		iso_week=iso_week,
-		groups=groups,
-		nav="team",
-	)
+    """Team overview: open tasks grouped by assignee, with weekly point capacity vs. load."""
+    iso_week = (week or current_iso_week()).strip()
+    if not _ISO_WEEK_RE.match(iso_week):
+        iso_week = current_iso_week()
+    by_assignee = _gather_open_tasks_by_assignee(token)
+
+    principal = principal_from_session(token)
+    current_username = principal.username if principal else None
+    can_edit_others = (not settings.AUTH) or has_permission(Permission.SETTINGS, token=token)
+
+    groups: list[dict[str, Any]] = []
+    for assignee in sorted(by_assignee, key=lambda a: (a == "Unassigned", a.lower())):
+        tasks = sorted(by_assignee[assignee], key=lambda t: (t.get("priority") or "", t.get("title") or ""))
+        points_total = sum(t.get("points") or 0 for t in tasks)
+        is_unassigned = assignee == "Unassigned"
+        capacity = None if is_unassigned else get_user_week_capacity(assignee, iso_week)
+
+        rows: list[list[Cell]] = []
+        for t in tasks:
+            project_enc = quote(t["project"], safe="/")
+            board_enc = quote(t["board"], safe="")
+            task_enc = quote(t["ref"], safe="")
+            project_url = f"/project/{project_enc}"
+            task_url = f"{project_url}/board/?b={board_enc}&task={task_enc}"
+            rows.append([
+                html_link(task_url, t.get("title") or "(untitled)"),
+                html_link(project_url, t["project"]),
+                t.get("board") or "",
+                (t.get("status") or "").replace("_", " ").title(),
+                (t.get("priority") or "").title(),
+                str(t.get("points") if t.get("points") is not None else ""),
+            ])
+        can_edit = (not is_unassigned) and (can_edit_others or assignee == current_username)
+        capacity_url = f"/team/capacity?user={quote(assignee, safe='')}&week={quote(iso_week, safe='')}"
+        capacity_pct = 0
+        if capacity:
+            capacity_pct = min(100, round(100 * points_total / capacity))
+        groups.append({
+            "assignee": assignee,
+            "points_total": points_total,
+            "capacity": capacity,
+            "capacity_pct": capacity_pct,
+            "over_capacity": capacity is not None and points_total > capacity,
+            "can_edit": can_edit,
+            "capacity_url": capacity_url,
+            "rows": rows,
+        })
+
+    return render_page(
+        "team.html",
+        title=f"{settings.SITE_NAME} - Team",
+        iso_week=iso_week,
+        groups=groups,
+        nav="team",
+    )
 
 
 @team_router.get("/capacity", response_class=HTMLResponse)
 def team_capacity_form(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	user: Annotated[str | None, Query()] = None,
-	week: Annotated[str | None, Query()] = None,
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    user: Annotated[str | None, Query()] = None,
+    week: Annotated[str | None, Query()] = None,
 ) -> HTMLResponse:
-	"""Form to set a user's sprint point capacity for a given ISO week."""
-	ensure_active_user_if_auth_enabled(token)
-	principal = principal_from_session(token)
-	target_user = (user or (principal.username if principal else "")).strip()
-	if not target_user:
-		raise HTTPException(status_code=400, detail="user is required")
-	iso_week = (week or current_iso_week()).strip()
-	if not _ISO_WEEK_RE.match(iso_week):
-		iso_week = current_iso_week()
-	current_points = get_user_week_capacity(target_user, iso_week)
-	return render_page(
-		"team_capacity_form.html",
-		title=f"{settings.SITE_NAME} - Set capacity",
-		target_user=target_user,
-		iso_week=iso_week,
-		current_points=current_points,
-		nav="team",
-	)
+    """Form to set a user's sprint point capacity for a given ISO week."""
+    ensure_active_user_if_auth_enabled(token)
+    principal = principal_from_session(token)
+    target_user = (user or (principal.username if principal else "")).strip()
+    if not target_user:
+        raise HTTPException(status_code=400, detail="user is required")
+    iso_week = (week or current_iso_week()).strip()
+    if not _ISO_WEEK_RE.match(iso_week):
+        iso_week = current_iso_week()
+    current_points = get_user_week_capacity(target_user, iso_week)
+    return render_page(
+        "team_capacity_form.html",
+        title=f"{settings.SITE_NAME} - Set capacity",
+        target_user=target_user,
+        iso_week=iso_week,
+        current_points=current_points,
+        nav="team",
+    )
 
 
 @team_router.post("/capacity/submit", response_class=HTMLResponse)
 def team_capacity_submit(
-	token: Annotated[str | None, Depends(access_token_from_request)],
-	user: Annotated[str, Form()],
-	week: Annotated[str, Form()],
-	points: Annotated[str, Form()],
+    token: Annotated[str | None, Depends(access_token_from_request)],
+    user: Annotated[str, Form()],
+    week: Annotated[str, Form()],
+    points: Annotated[str, Form()],
 ) -> RedirectResponse:
-	"""Save a user's sprint point capacity for a given ISO week."""
-	target_user = user.strip()
-	iso_week = week.strip()
-	if not target_user:
-		raise HTTPException(status_code=400, detail="user is required")
-	if not _ISO_WEEK_RE.match(iso_week):
-		raise HTTPException(status_code=400, detail="week must be in ISO format YYYY-Www")
-	_ensure_can_set_capacity(token, target_user)
-	try:
-		points_val = int(points)
-	except ValueError as exc:
-		raise HTTPException(status_code=400, detail="points must be an integer") from exc
-	if points_val < 0:
-		raise HTTPException(status_code=400, detail="points must be non-negative")
-	set_user_week_capacity(target_user, iso_week, points_val)
-	return RedirectResponse(url=f"/team?week={quote(iso_week, safe='')}", status_code=303)
+    """Save a user's sprint point capacity for a given ISO week."""
+    target_user = user.strip()
+    iso_week = week.strip()
+    if not target_user:
+        raise HTTPException(status_code=400, detail="user is required")
+    if not _ISO_WEEK_RE.match(iso_week):
+        raise HTTPException(status_code=400, detail="week must be in ISO format YYYY-Www")
+    _ensure_can_set_capacity(token, target_user)
+    try:
+        points_val = int(points)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail="points must be an integer") from exc
+    if points_val < 0:
+        raise HTTPException(status_code=400, detail="points must be non-negative")
+    set_user_week_capacity(target_user, iso_week, points_val)
+    return RedirectResponse(url=f"/team?week={quote(iso_week, safe='')}", status_code=303)
diff --git a/pygitweb/src/pygitweb/team_routes_test.py b/pygitweb/src/pygitweb/team_routes_test.py
index bf1129e..2be7843 100644
--- a/pygitweb/src/pygitweb/team_routes_test.py
+++ b/pygitweb/src/pygitweb/team_routes_test.py
@@ -16,128 +16,128 @@ from pygitweb.team import team_router
 
 
 def _build_client() -> TestClient:
-	app = FastAPI()
-	app.include_router(team_router, prefix="/team")
-	return TestClient(app)
+    app = FastAPI()
+    app.include_router(team_router, prefix="/team")
+    return TestClient(app)
 
 
 class TestTeamRoutes:
-	@pytest.fixture(scope="class")
-	def seeded_env(
-		self, tmp_path_factory: pytest.TempPathFactory
-	) -> Generator[dict[str, str | TestClient], None, None]:
-		root: Path = tmp_path_factory.mktemp("team-routes")
-		repo_dir: Path = root / "demo"
-		pygit2.init_repository(str(repo_dir), bare=False)
-		repo = pygit2.Repository(str(repo_dir))
-		project = "demo"
-
-		board_ref = f"{BOARD_REF_PREFIX}Tasks"
-		board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main board")
-
-		alice_task = Task(
-			EMPTY_TREE_OID,
-			f"{TASK_REF_PREFIX}task_alice",
-			tagger="",
-			title="Fix the bug",
-			status=Task.Status.IN_PROGRESS,
-			priority=Task.Priority.HIGH,
-			assignee="alice",
-			points=5,
-		)
-		alice_oid = alice_task.write(repo)
-
-		done_task = Task(
-			EMPTY_TREE_OID,
-			f"{TASK_REF_PREFIX}task_done",
-			tagger="",
-			title="Already shipped",
-			status=Task.Status.DONE,
-			priority=Task.Priority.LOW,
-			assignee="alice",
-			points=13,
-		)
-		done_oid = done_task.write(repo)
-
-		unassigned_task = Task(
-			EMPTY_TREE_OID,
-			f"{TASK_REF_PREFIX}task_open",
-			tagger="",
-			title="Needs an owner",
-			status=Task.Status.TODO,
-			priority=Task.Priority.MEDIUM,
-			points=3,
-		)
-		unassigned_oid = unassigned_task.write(repo)
-
-		board.tasks = [str(alice_oid), str(done_oid), str(unassigned_oid)]
-		board.update_message()
-		board.write(repo)
-
-		capacity_path = root / "capacity.json"
-
-		with (
-			patch.object(settings, "PROJECTROOT", str(root)),
-			patch.object(settings, "PROJECTS_LIST", str(root)),
-			patch.object(settings, "STRICT_EXPORT", False),
-			patch.object(settings, "EXPORT_OK", ""),
-			patch.object(settings, "AUTH", False),
-			patch("pygitweb.capacity_config.capacity_config_path", return_value=capacity_path),
-		):
-			client = _build_client()
-			yield {"client": client, "project": project}
-
-	def test_team_page_groups_open_tasks_by_assignee(self, seeded_env: dict[str, str | TestClient]) -> None:
-		client = seeded_env["client"]
-		assert isinstance(client, TestClient)
-		resp = client.get("/team")
-		assert resp.status_code == 200
-		body = resp.text
-		assert "alice" in body
-		assert "Fix the bug" in body
-		# DONE tasks are not part of current workload.
-		assert "Already shipped" not in body
-		# Unassigned open tasks show up under their own bucket.
-		assert "Unassigned" in body
-		assert "Needs an owner" in body
-
-	def test_capacity_form_shows_default(self, seeded_env: dict[str, str | TestClient]) -> None:
-		client = seeded_env["client"]
-		assert isinstance(client, TestClient)
-		resp = client.get("/team/capacity", params={"user": "alice", "week": "2026-W10"})
-		assert resp.status_code == 200
-		assert "alice" in resp.text
-
-	def test_capacity_submit_persists_and_reflects_on_team_page(self, seeded_env: dict[str, str | TestClient]) -> None:
-		client = seeded_env["client"]
-		assert isinstance(client, TestClient)
-		resp = client.post(
-			"/team/capacity/submit",
-			data={"user": "alice", "week": "2026-W10", "points": "8"},
-			follow_redirects=False,
-		)
-		assert resp.status_code == 303
-		assert resp.headers["location"] == "/team?week=2026-W10"
-
-		team_resp = client.get("/team", params={"week": "2026-W10"})
-		assert team_resp.status_code == 200
-		# alice has 5 open points against an 8-point capacity for this week.
-		assert "5 / 8 pts this week" in team_resp.text
-
-	def test_capacity_submit_rejects_invalid_week(self, seeded_env: dict[str, str | TestClient]) -> None:
-		client = seeded_env["client"]
-		assert isinstance(client, TestClient)
-		resp = client.post(
-			"/team/capacity/submit",
-			data={"user": "alice", "week": "not-a-week", "points": "8"},
-		)
-		assert resp.status_code == 400
-
-	def test_capacity_submit_rejects_negative_points(self, seeded_env: dict[str, str | TestClient]) -> None:
-		client = seeded_env["client"]
-		assert isinstance(client, TestClient)
-		resp = client.post(
-			"/team/capacity/submit",
-			data={"user": "alice", "week": "2026-W11", "points": "-1"},
-		)
-		assert resp.status_code == 400
+    @pytest.fixture(scope="class")
+    def seeded_env(
+        self, tmp_path_factory: pytest.TempPathFactory
+    ) -> Generator[dict[str, str | TestClient], None, None]:
+        root: Path = tmp_path_factory.mktemp("team-routes")
+        repo_dir: Path = root / "demo"
+        pygit2.init_repository(str(repo_dir), bare=False)
+        repo = pygit2.Repository(str(repo_dir))
+        project = "demo"
+
+        board_ref = f"{BOARD_REF_PREFIX}Tasks"
+        board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main board")
+
+        alice_task = Task(
+            EMPTY_TREE_OID,
+            f"{TASK_REF_PREFIX}task_alice",
+            tagger="",
+            title="Fix the bug",
+            status=Task.Status.IN_PROGRESS,
+            priority=Task.Priority.HIGH,
+            assignee="alice",
+            points=5,
+        )
+        alice_oid = alice_task.write(repo)
+
+        done_task = Task(
+            EMPTY_TREE_OID,
+            f"{TASK_REF_PREFIX}task_done",
+            tagger="",
+            title="Already shipped",
+            status=Task.Status.DONE,
+            priority=Task.Priority.LOW,
+            assignee="alice",
+            points=13,
+        )
+        done_oid = done_task.write(repo)
+
+        unassigned_task = Task(
+            EMPTY_TREE_OID,
+            f"{TASK_REF_PREFIX}task_open",
+            tagger="",
+            title="Needs an owner",
+            status=Task.Status.TODO,
+            priority=Task.Priority.MEDIUM,
+            points=3,
+        )
+        unassigned_oid = unassigned_task.write(repo)
+
+        board.tasks = [str(alice_oid), str(done_oid), str(unassigned_oid)]
+        board.update_message()
+        board.write(repo)
+
+        capacity_path = root / "capacity.json"
+
+        with (
+            patch.object(settings, "PROJECTROOT", str(root)),
+            patch.object(settings, "PROJECTS_LIST", str(root)),
+            patch.object(settings, "STRICT_EXPORT", False),
+            patch.object(settings, "EXPORT_OK", ""),
+            patch.object(settings, "AUTH", False),
+            patch("pygitweb.capacity_config.capacity_config_path", return_value=capacity_path),
+        ):
+            client = _build_client()
+            yield {"client": client, "project": project}
+
+    def test_team_page_groups_open_tasks_by_assignee(self, seeded_env: dict[str, str | TestClient]) -> None:
+        client = seeded_env["client"]
+        assert isinstance(client, TestClient)
+        resp = client.get("/team")
+        assert resp.status_code == 200
+        body = resp.text
+        assert "alice" in body
+        assert "Fix the bug" in body
+        # DONE tasks are not part of current workload.
+        assert "Already shipped" not in body
+        # Unassigned open tasks show up under their own bucket.
+        assert "Unassigned" in body
+        assert "Needs an owner" in body
+
+    def test_capacity_form_shows_default(self, seeded_env: dict[str, str | TestClient]) -> None:
+        client = seeded_env["client"]
+        assert isinstance(client, TestClient)
+        resp = client.get("/team/capacity", params={"user": "alice", "week": "2026-W10"})
+        assert resp.status_code == 200
+        assert "alice" in resp.text
+
+    def test_capacity_submit_persists_and_reflects_on_team_page(self, seeded_env: dict[str, str | TestClient]) -> None:
+        client = seeded_env["client"]
+        assert isinstance(client, TestClient)
+        resp = client.post(
+            "/team/capacity/submit",
+            data={"user": "alice", "week": "2026-W10", "points": "8"},
+            follow_redirects=False,
+        )
+        assert resp.status_code == 303
+        assert resp.headers["location"] == "/team?week=2026-W10"
+
+        team_resp = client.get("/team", params={"week": "2026-W10"})
+        assert team_resp.status_code == 200
+        # alice has 5 open points against an 8-point capacity for this week.
+        assert "5 / 8 pts this week" in team_resp.text
+
+    def test_capacity_submit_rejects_invalid_week(self, seeded_env: dict[str, str | TestClient]) -> None:
+        client = seeded_env["client"]
+        assert isinstance(client, TestClient)
+        resp = client.post(
+            "/team/capacity/submit",
+            data={"user": "alice", "week": "not-a-week", "points": "8"},
+        )
+        assert resp.status_code == 400
+
+    def test_capacity_submit_rejects_negative_points(self, seeded_env: dict[str, str | TestClient]) -> None:
+        client = seeded_env["client"]
+        assert isinstance(client, TestClient)
+        resp = client.post(
+            "/team/capacity/submit",
+            data={"user": "alice", "week": "2026-W11", "points": "-1"},
+        )
+        assert resp.status_code == 400
diff --git a/pygitweb/src/pygitweb/templates_env.py b/pygitweb/src/pygitweb/templates_env.py
index d70a507..887c05d 100644
--- a/pygitweb/src/pygitweb/templates_env.py
+++ b/pygitweb/src/pygitweb/templates_env.py
@@ -7,10 +7,10 @@ from jinja2 import Environment, PackageLoader, select_autoescape
 from markupsafe import Markup
 
 env = Environment(
-	loader=PackageLoader("pygitweb", "templates"),
-	autoescape=select_autoescape(enabled_extensions=("html", "xml"), default_for_string=True),
-	trim_blocks=True,
-	lstrip_blocks=True,
+    loader=PackageLoader("pygitweb", "templates"),
+    autoescape=select_autoescape(enabled_extensions=("html", "xml"), default_for_string=True),
+    trim_blocks=True,
+    lstrip_blocks=True,
 )
 
 # A table cell is plain text, which the template escapes, or Markup, which it does not.
@@ -20,88 +20,88 @@ _ESCAPE_TEMPLATE = env.from_string("{{ value|e }}")
 
 
 def render_page(name: str, /, **ctx: object) -> HTMLResponse:
-	"""Render a page template. Templates extend base.html; site_name defaults from settings."""
-	from pygitweb.config import settings
+    """Render a page template. Templates extend base.html; site_name defaults from settings."""
+    from pygitweb.config import settings
 
-	page_ctx = dict(ctx)
-	page_ctx.setdefault("site_name", settings.SITE_NAME)
-	return HTMLResponse(env.get_template(name).render(**page_ctx))
+    page_ctx = dict(ctx)
+    page_ctx.setdefault("site_name", settings.SITE_NAME)
+    return HTMLResponse(env.get_template(name).render(**page_ctx))
 
 
 def jinja_escape(value: str | None) -> str | None:
-	if value is None:
-		return None
-	return _ESCAPE_TEMPLATE.render(value=value)
+    if value is None:
+        return None
+    return _ESCAPE_TEMPLATE.render(value=value)
 
 
 def html_link(href: str, text: str, **attrs: str) -> Markup:
-	"""Anchor tag. href, text, and attribute values are escaped."""
-	attr_html = Markup("").join(
-		Markup(' {}="{}"').format(name.replace("_", "-"), value) for name, value in attrs.items()
-	)
-	return Markup('<a href="{}"{}>{}</a>').format(href, attr_html, text)
+    """Anchor tag. href, text, and attribute values are escaped."""
+    attr_html = Markup("").join(
+        Markup(' {}="{}"').format(name.replace("_", "-"), value) for name, value in attrs.items()
+    )
+    return Markup('<a href="{}"{}>{}</a>').format(href, attr_html, text)
 
 
 def html_format(template: str, /, *args: object, **kwargs: object) -> Markup:
-	"""Format an HTML snippet, escaping interpolated values. The template itself is trusted."""
-	return Markup(template).format(*args, **kwargs)
+    """Format an HTML snippet, escaping interpolated values. The template itself is trusted."""
+    return Markup(template).format(*args, **kwargs)
 
 
 def html_join(parts: Iterable[str | Markup], sep: str = "") -> Markup:
-	"""Join fragments. Plain strings are escaped; Markup is inserted as-is."""
-	return Markup(sep).join(parts)
+    """Join fragments. Plain strings are escaped; Markup is inserted as-is."""
+    return Markup(sep).join(parts)
 
 
 def trusted_html(value: str) -> Markup:
-	"""Mark HTML that a template or plugin already escaped. Do not pass raw text."""
-	return Markup(value)
+    """Mark HTML that a template or plugin already escaped. Do not pass raw text."""
+    return Markup(value)
 
 
 def initials(name: str) -> str:
-	parts = [part for part in name.replace("_", " ").split() if part]
-	if len(parts) >= 2:
-		return (parts[0][0] + parts[-1][0]).upper()
-	if parts:
-		return parts[0][:2].upper()
-	return "?"
+    parts = [part for part in name.replace("_", " ").split() if part]
+    if len(parts) >= 2:
+        return (parts[0][0] + parts[-1][0]).upper()
+    if parts:
+        return parts[0][:2].upper()
+    return "?"
 
 
 _STATUS_BADGE = {
-	"TODO": "secondary",
-	"IN_PROGRESS": "blue",
-	"IN_REVIEW": "yellow",
-	"DONE": "green",
-	"CANCELLED": "red",
+    "TODO": "secondary",
+    "IN_PROGRESS": "blue",
+    "IN_REVIEW": "yellow",
+    "DONE": "green",
+    "CANCELLED": "red",
 }
 _PRIORITY_BADGE = {
-	"LOW": "secondary",
-	"MEDIUM": "azure",
-	"HIGH": "orange",
-	"CRITICAL": "red",
+    "LOW": "secondary",
+    "MEDIUM": "azure",
+    "HIGH": "orange",
+    "CRITICAL": "red",
 }
 
 
 def status_badge(status: str) -> Markup:
-	key = (status or "TODO").upper()
-	color = _STATUS_BADGE.get(key, "secondary")
-	label = key.replace("_", " ").title()
-	return html_format('<span class="badge bg-{}-lt">{}</span>', color, label)
+    key = (status or "TODO").upper()
+    color = _STATUS_BADGE.get(key, "secondary")
+    label = key.replace("_", " ").title()
+    return html_format('<span class="badge bg-{}-lt">{}</span>', color, label)
 
 
 def priority_badge(priority: str) -> Markup:
-	key = (priority or "LOW").upper()
-	color = _PRIORITY_BADGE.get(key, "secondary")
-	label = key.replace("_", " ").title()
-	return html_format('<span class="badge bg-{}-lt">{}</span>', color, label)
+    key = (priority or "LOW").upper()
+    color = _PRIORITY_BADGE.get(key, "secondary")
+    label = key.replace("_", " ").title()
+    return html_format('<span class="badge bg-{}-lt">{}</span>', color, label)
 
 
 def person_label(name: str) -> Markup:
-	label = (name or "").strip() or "Unassigned"
-	return html_format(
-		'<span class="d-inline-flex align-items-center gap-1"><span class="avatar avatar-xs">{}</span>{}</span>',
-		initials(label),
-		label,
-	)
+    label = (name or "").strip() or "Unassigned"
+    return html_format(
+        '<span class="d-inline-flex align-items-center gap-1"><span class="avatar avatar-xs">{}</span>{}</span>',
+        initials(label),
+        label,
+    )
 
 
 env.filters["initials"] = initials
diff --git a/pygitweb/src/pygitweb/templates_env_test.py b/pygitweb/src/pygitweb/templates_env_test.py
index 51724fe..b400fa9 100644
--- a/pygitweb/src/pygitweb/templates_env_test.py
+++ b/pygitweb/src/pygitweb/templates_env_test.py
@@ -3,114 +3,114 @@ from pygitweb.templates_env import env, html_format, html_join, html_link, rende
 
 
 def test_table_escapes_plain_text() -> None:
-	html = env.get_template("table.html").render(
-		cols=["Name"],
-		rows=[["<script>alert(1)</script>"]],
-	)
-	assert "<script>" not in html
-	assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
+    html = env.get_template("table.html").render(
+        cols=["Name"],
+        rows=[["<script>alert(1)</script>"]],
+    )
+    assert "<script>" not in html
+    assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
 
 
 def test_table_renders_link_without_double_escaping() -> None:
-	cell = html_link("/p?a=commit&h=abc", "a<b", id="commit-link")
-	html = env.get_template("table.html").render(cols=["Commit"], rows=[[cell]])
-	assert 'href="/p?a=commit&amp;h=abc"' in html
-	assert 'id="commit-link"' in html
-	assert ">a&lt;b</a>" in html
-	assert "&amp;amp;" not in html
+    cell = html_link("/p?a=commit&h=abc", "a<b", id="commit-link")
+    html = env.get_template("table.html").render(cols=["Commit"], rows=[[cell]])
+    assert 'href="/p?a=commit&amp;h=abc"' in html
+    assert 'id="commit-link"' in html
+    assert ">a&lt;b</a>" in html
+    assert "&amp;amp;" not in html
 
 
 def test_html_join_escapes_text_and_keeps_markup() -> None:
-	joined = html_join([html_link("/a", "x"), " <", "a&b", ">"])
-	assert str(joined) == '<a href="/a">x</a> &lt;a&amp;b&gt;'
+    joined = html_join([html_link("/a", "x"), " <", "a&b", ">"])
+    assert str(joined) == '<a href="/a">x</a> &lt;a&amp;b&gt;'
 
 
 def test_trusted_html_is_not_escaped_in_table() -> None:
-	html = env.get_template("table.html").render(
-		cols=["V"],
-		rows=[[trusted_html("<em>ok</em>")]],
-	)
-	assert "<em>ok</em>" in html
+    html = env.get_template("table.html").render(
+        cols=["V"],
+        rows=[[trusted_html("<em>ok</em>")]],
+    )
+    assert "<em>ok</em>" in html
 
 
 def test_html_format_escapes_values() -> None:
-	assert str(html_format("<pre>{}</pre>", "<hi>")) == "<pre>&lt;hi&gt;</pre>"
+    assert str(html_format("<pre>{}</pre>", "<hi>")) == "<pre>&lt;hi&gt;</pre>"
 
 
 def test_render_page_uses_base_template_and_escapes_text() -> None:
-	resp = render_page("message.html", title="Hello <x>", paragraphs=["Hi <there>"])
-	text = resp.body.decode()
-	assert text.startswith("<!DOCTYPE html>")
-	assert "<title>Hello &lt;x&gt;</title>" in text
-	assert "Hi &lt;there&gt;" in text
-	assert text.count('<div class="page">') == 1
-	assert "</body></html>" in text
-	assert text.index('<div class="container-xl">') < text.index("Hi &lt;there&gt;")
-	assert text.index("Hi &lt;there&gt;") < text.index("</body></html>")
+    resp = render_page("message.html", title="Hello <x>", paragraphs=["Hi <there>"])
+    text = resp.body.decode()
+    assert text.startswith("<!DOCTYPE html>")
+    assert "<title>Hello &lt;x&gt;</title>" in text
+    assert "Hi &lt;there&gt;" in text
+    assert text.count('<div class="page">') == 1
+    assert "</body></html>" in text
+    assert text.index('<div class="container-xl">') < text.index("Hi &lt;there&gt;")
+    assert text.index("Hi &lt;there&gt;") < text.index("</body></html>")
 
 
 def test_project_list_template_emits_links() -> None:
-	resp = render_page(
-		"project_list.html",
-		title="Projects",
-		notice=None,
-		projects=[
-			{
-				"href": "/project/a<b",
-				"path": "a<b",
-				"descr": "d&d",
-				"board_href": "/board/create?project=a%3Cb",
-				"board_text": "create board",
-				"board_style": None,
-			}
-		],
-	)
-	text = resp.body.decode()
-	assert 'href="/project/a&lt;b"' in text
-	assert ">a&lt;b</a>" in text
-	assert "d&amp;d" in text
-	assert ">create board</a>" in text
-	assert 'href="/board/create?project=a%3Cb"' in text
-	assert "bg-secondary-lt" in text
+    resp = render_page(
+        "project_list.html",
+        title="Projects",
+        notice=None,
+        projects=[
+            {
+                "href": "/project/a<b",
+                "path": "a<b",
+                "descr": "d&d",
+                "board_href": "/board/create?project=a%3Cb",
+                "board_text": "create board",
+                "board_style": None,
+            }
+        ],
+    )
+    text = resp.body.decode()
+    assert 'href="/project/a&lt;b"' in text
+    assert ">a&lt;b</a>" in text
+    assert "d&amp;d" in text
+    assert ">create board</a>" in text
+    assert 'href="/board/create?project=a%3Cb"' in text
+    assert "bg-secondary-lt" in text
 
 
 def test_project_board_link_points_at_existing_board(monkeypatch) -> None:
-	monkeypatch.setattr(
-		"pygitweb.actions.git_get_references",
-		lambda _project, _prefix: [("Tasks", "refs/tags/boards/Tasks")],
-	)
-	assert str(project_board_link("demo")) == '<a href="/project/demo/board/">project board</a>'
+    monkeypatch.setattr(
+        "pygitweb.actions.git_get_references",
+        lambda _project, _prefix: [("Tasks", "refs/tags/boards/Tasks")],
+    )
+    assert str(project_board_link("demo")) == '<a href="/project/demo/board/">project board</a>'
 
 
 def test_project_board_link_offers_create_when_missing(monkeypatch) -> None:
-	monkeypatch.setattr("pygitweb.actions.git_get_references", lambda _project, _prefix: [])
-	monkeypatch.setattr("pygitweb.actions.settings.AUTH", True)
-	html = str(project_board_link("my proj"))
-	assert 'href="/board/create?project=my%20proj"' in html
-	assert 'style="color: #999; cursor: not-allowed;"' in html
-	assert ">create board</a>" in html
+    monkeypatch.setattr("pygitweb.actions.git_get_references", lambda _project, _prefix: [])
+    monkeypatch.setattr("pygitweb.actions.settings.AUTH", True)
+    html = str(project_board_link("my proj"))
+    assert 'href="/board/create?project=my%20proj"' in html
+    assert 'style="color: #999; cursor: not-allowed;"' in html
+    assert ">create board</a>" in html
 
-	monkeypatch.setattr("pygitweb.actions.settings.AUTH", False)
-	plain = str(project_board_link("my proj"))
-	assert "style=" not in plain
-	assert ">create board</a>" in plain
+    monkeypatch.setattr("pygitweb.actions.settings.AUTH", False)
+    plain = str(project_board_link("my proj"))
+    assert "style=" not in plain
+    assert ">create board</a>" in plain
 
 
 def test_blog_link_spec_points_at_existing_blog(monkeypatch) -> None:
-	monkeypatch.setattr("pygitweb.actions.project_has_blog", lambda _project: True)
-	spec = blog_link_spec("demo")
-	assert spec == {
-		"href": "/project/demo/blog/",
-		"text": "view blog",
-		"style": None,
-	}
+    monkeypatch.setattr("pygitweb.actions.project_has_blog", lambda _project: True)
+    spec = blog_link_spec("demo")
+    assert spec == {
+        "href": "/project/demo/blog/",
+        "text": "view blog",
+        "style": None,
+    }
 
 
 def test_blog_link_spec_muted_when_missing(monkeypatch) -> None:
-	monkeypatch.setattr("pygitweb.actions.project_has_blog", lambda _project: False)
-	spec = blog_link_spec("my proj")
-	assert spec == {
-		"href": "#",
-		"text": "no blog",
-		"style": "color: #999; cursor: not-allowed;",
-	}
+    monkeypatch.setattr("pygitweb.actions.project_has_blog", lambda _project: False)
+    spec = blog_link_spec("my proj")
+    assert spec == {
+        "href": "#",
+        "text": "no blog",
+        "style": "color: #999; cursor: not-allowed;",
+    }
diff --git a/pygitweb/src/pygitweb/timeline_cache.py b/pygitweb/src/pygitweb/timeline_cache.py
index 90fe61f..13f4efd 100644
--- a/pygitweb/src/pygitweb/timeline_cache.py
+++ b/pygitweb/src/pygitweb/timeline_cache.py
@@ -19,15 +19,15 @@ TIMELINE_CACHE_BATCH_SIZE: int = 5
 
 
 class TimelineEvent(TypedDict):
-	timestamp: int
-	oid: str
-	kind: Literal["commit", "ref"]
-	description: str | None
+    timestamp: int
+    oid: str
+    kind: Literal["commit", "ref"]
+    description: str | None
 
 
 class TimelineProjectEvent(TypedDict):
-	project: str
-	event: TimelineEvent
+    project: str
+    event: TimelineEvent
 
 
 PROJECT_TIMELINE_CACHE: dict[str, list[TimelineEvent]] = {}
@@ -36,208 +36,208 @@ _TIMELINE_WARMING: bool = False
 
 
 def _commit_description(commit: pygit2.Commit) -> str | None:
-	if not commit.message:
-		return None
-	first_line: str = commit.message.splitlines()[0].strip()
-	return first_line or None
+    if not commit.message:
+        return None
+    first_line: str = commit.message.splitlines()[0].strip()
+    return first_line or None
 
 
 def _collect_commit_events(repo: pygit2.Repository) -> list[TimelineEvent]:
-	start_points: list[pygit2.Oid | str] = []
-	for ref_name in repo.references:
-		if not ref_name.startswith("refs/heads/"):
-			continue
-		try:
-			resolved: pygit2.Reference = repo.references[ref_name].resolve()
-			oid: pygit2.Oid | str = resolved.target
-			start_points.append(oid)
-		except (KeyError, pygit2.GitError, ValueError):
-			continue
-	if not start_points:
-		try:
-			head_ref: pygit2.Reference = repo.head
-			start_points.append(head_ref.target)
-		except (pygit2.GitError, ValueError, KeyError):
-			pass
-	if not start_points:
-		return []
-
-	walker: pygit2.Walker = repo.walk(
-		start_points[0],
-		pygit2.enums.SortMode(pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL),
-	)
-	for oid in start_points[1:]:
-		try:
-			walker.push(oid)
-		except (pygit2.GitError, ValueError):
-			continue
-
-	events: list[TimelineEvent] = []
-	seen: set[str] = set()
-	for commit in walker:
-		commit_oid: str = str(commit.id)
-		if commit_oid in seen:
-			continue
-		seen.add(commit_oid)
-		events.append({
-			"timestamp": commit.commit_time,
-			"oid": commit_oid,
-			"kind": "commit",
-			"description": _commit_description(commit),
-		})
-	return events
+    start_points: list[pygit2.Oid | str] = []
+    for ref_name in repo.references:
+        if not ref_name.startswith("refs/heads/"):
+            continue
+        try:
+            resolved: pygit2.Reference = repo.references[ref_name].resolve()
+            oid: pygit2.Oid | str = resolved.target
+            start_points.append(oid)
+        except (KeyError, pygit2.GitError, ValueError):
+            continue
+    if not start_points:
+        try:
+            head_ref: pygit2.Reference = repo.head
+            start_points.append(head_ref.target)
+        except (pygit2.GitError, ValueError, KeyError):
+            pass
+    if not start_points:
+        return []
+
+    walker: pygit2.Walker = repo.walk(
+        start_points[0],
+        pygit2.enums.SortMode(pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL),
+    )
+    for oid in start_points[1:]:
+        try:
+            walker.push(oid)
+        except (pygit2.GitError, ValueError):
+            continue
+
+    events: list[TimelineEvent] = []
+    seen: set[str] = set()
+    for commit in walker:
+        commit_oid: str = str(commit.id)
+        if commit_oid in seen:
+            continue
+        seen.add(commit_oid)
+        events.append({
+            "timestamp": commit.commit_time,
+            "oid": commit_oid,
+            "kind": "commit",
+            "description": _commit_description(commit),
+        })
+    return events
 
 
 def _collect_ref_events(repo: pygit2.Repository) -> list[TimelineEvent]:
-	events: list[TimelineEvent] = []
-	for ref_name in repo.references:
-		if not ref_name.startswith("refs/tags/"):
-			continue
-		try:
-			ref_obj: pygit2.Reference = repo.references[ref_name].resolve()
-			oid_str: str = str(ref_obj.target)
-			obj = repo[ref_obj.target]
-		except (KeyError, pygit2.GitError, ValueError):
-			continue
-
-		if isinstance(obj, pygit2.Tag):
-			tag_tagger = obj.tagger
-			timestamp: int = tag_tagger.time if tag_tagger else 0
-			description: str | None = ref_name.replace("refs/tags/", "", 1)
-		elif isinstance(obj, pygit2.Commit):
-			timestamp = obj.commit_time
-			description = ref_name.replace("refs/tags/", "", 1)
-		else:
-			continue
-
-		events.append({
-			"timestamp": timestamp,
-			"oid": oid_str,
-			"kind": "ref",
-			"description": description or None,
-		})
-	return events
+    events: list[TimelineEvent] = []
+    for ref_name in repo.references:
+        if not ref_name.startswith("refs/tags/"):
+            continue
+        try:
+            ref_obj: pygit2.Reference = repo.references[ref_name].resolve()
+            oid_str: str = str(ref_obj.target)
+            obj = repo[ref_obj.target]
+        except (KeyError, pygit2.GitError, ValueError):
+            continue
+
+        if isinstance(obj, pygit2.Tag):
+            tag_tagger = obj.tagger
+            timestamp: int = tag_tagger.time if tag_tagger else 0
+            description: str | None = ref_name.replace("refs/tags/", "", 1)
+        elif isinstance(obj, pygit2.Commit):
+            timestamp = obj.commit_time
+            description = ref_name.replace("refs/tags/", "", 1)
+        else:
+            continue
+
+        events.append({
+            "timestamp": timestamp,
+            "oid": oid_str,
+            "kind": "ref",
+            "description": description or None,
+        })
+    return events
 
 
 def build_project_timeline_cache(project: str, max_events: int = TIMELINE_CACHE_SIZE) -> list[TimelineEvent]:
-	try:
-		repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	except (pygit2.GitError, OSError, ValueError):
-		return []
+    try:
+        repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+    except (pygit2.GitError, OSError, ValueError):
+        return []
 
-	commit_events: list[TimelineEvent] = _collect_commit_events(repo)
-	ref_events: list[TimelineEvent] = _collect_ref_events(repo)
-	all_events: list[TimelineEvent] = commit_events + ref_events
-	all_events.sort(key=lambda event: (event["timestamp"], event["oid"]), reverse=True)
-	return all_events[:max_events]
+    commit_events: list[TimelineEvent] = _collect_commit_events(repo)
+    ref_events: list[TimelineEvent] = _collect_ref_events(repo)
+    all_events: list[TimelineEvent] = commit_events + ref_events
+    all_events.sort(key=lambda event: (event["timestamp"], event["oid"]), reverse=True)
+    return all_events[:max_events]
 
 
 def warm_timeline_cache() -> None:
-	global PROJECT_TIMELINE_CACHE, _TIMELINE_WARMING
-	with _TIMELINE_CACHE_LOCK:
-		_TIMELINE_WARMING = True
-	try:
-		new_cache: dict[str, list[TimelineEvent]] = {}
-		projects = git_get_projects_list(
-			filter_path="",
-			paranoid=settings.STRICT_EXPORT,
-			export_ok=settings.EXPORT_OK,
-		)
-		project_names: list[str] = []
-		project_event_pool: dict[str, list[TimelineEvent]] = {}
-		for project_entry in projects:
-			project_name = project_entry.get("path")
-			if not isinstance(project_name, str) or not project_name:
-				continue
-			project_names.append(project_name)
-			try:
-				project_event_pool[project_name] = build_project_timeline_cache(
-					project_name,
-					max_events=TIMELINE_CACHE_TOTAL_EVENTS,
-				)
-			except (pygit2.GitError, OSError, ValueError, KeyError):
-				project_event_pool[project_name] = []
-
-		for project_name in project_names:
-			new_cache[project_name] = []
-
-		selected_events: list[TimelineProjectEvent] = []
-		next_index_by_project: dict[str, int] = {project_name: 0 for project_name in project_names}
-
-		for project_name in project_names:
-			if len(selected_events) >= TIMELINE_CACHE_TOTAL_EVENTS:
-				break
-			project_events = project_event_pool.get(project_name, [])
-			if not project_events:
-				continue
-			selected_events.append({"project": project_name, "event": project_events[0]})
-			next_index_by_project[project_name] = 1
-
-		while len(selected_events) < TIMELINE_CACHE_TOTAL_EVENTS:
-			candidate_projects: list[str] = [
-				project_name
-				for project_name in project_names
-				if next_index_by_project[project_name] < len(project_event_pool.get(project_name, []))
-			]
-			if not candidate_projects:
-				break
-			best_project: str = max(
-				candidate_projects,
-				key=lambda project_name: (
-					project_event_pool[project_name][next_index_by_project[project_name]]["timestamp"],
-					project_event_pool[project_name][next_index_by_project[project_name]]["oid"],
-					project_name,
-				),
-			)
-			start_index = next_index_by_project[best_project]
-			remaining_global = TIMELINE_CACHE_TOTAL_EVENTS - len(selected_events)
-			remaining_project = len(project_event_pool[best_project]) - start_index
-			take_count = min(TIMELINE_CACHE_BATCH_SIZE, remaining_global, remaining_project)
-			for offset in range(take_count):
-				selected_events.append({
-					"project": best_project,
-					"event": project_event_pool[best_project][start_index + offset],
-				})
-			next_index_by_project[best_project] = start_index + take_count
-
-		for item in selected_events:
-			new_cache[item["project"]].append(item["event"])
-		with _TIMELINE_CACHE_LOCK:
-			PROJECT_TIMELINE_CACHE = new_cache
-	finally:
-		with _TIMELINE_CACHE_LOCK:
-			_TIMELINE_WARMING = False
+    global PROJECT_TIMELINE_CACHE, _TIMELINE_WARMING
+    with _TIMELINE_CACHE_LOCK:
+        _TIMELINE_WARMING = True
+    try:
+        new_cache: dict[str, list[TimelineEvent]] = {}
+        projects = git_get_projects_list(
+            filter_path="",
+            paranoid=settings.STRICT_EXPORT,
+            export_ok=settings.EXPORT_OK,
+        )
+        project_names: list[str] = []
+        project_event_pool: dict[str, list[TimelineEvent]] = {}
+        for project_entry in projects:
+            project_name = project_entry.get("path")
+            if not isinstance(project_name, str) or not project_name:
+                continue
+            project_names.append(project_name)
+            try:
+                project_event_pool[project_name] = build_project_timeline_cache(
+                    project_name,
+                    max_events=TIMELINE_CACHE_TOTAL_EVENTS,
+                )
+            except (pygit2.GitError, OSError, ValueError, KeyError):
+                project_event_pool[project_name] = []
+
+        for project_name in project_names:
+            new_cache[project_name] = []
+
+        selected_events: list[TimelineProjectEvent] = []
+        next_index_by_project: dict[str, int] = {project_name: 0 for project_name in project_names}
+
+        for project_name in project_names:
+            if len(selected_events) >= TIMELINE_CACHE_TOTAL_EVENTS:
+                break
+            project_events = project_event_pool.get(project_name, [])
+            if not project_events:
+                continue
+            selected_events.append({"project": project_name, "event": project_events[0]})
+            next_index_by_project[project_name] = 1
+
+        while len(selected_events) < TIMELINE_CACHE_TOTAL_EVENTS:
+            candidate_projects: list[str] = [
+                project_name
+                for project_name in project_names
+                if next_index_by_project[project_name] < len(project_event_pool.get(project_name, []))
+            ]
+            if not candidate_projects:
+                break
+            best_project: str = max(
+                candidate_projects,
+                key=lambda project_name: (
+                    project_event_pool[project_name][next_index_by_project[project_name]]["timestamp"],
+                    project_event_pool[project_name][next_index_by_project[project_name]]["oid"],
+                    project_name,
+                ),
+            )
+            start_index = next_index_by_project[best_project]
+            remaining_global = TIMELINE_CACHE_TOTAL_EVENTS - len(selected_events)
+            remaining_project = len(project_event_pool[best_project]) - start_index
+            take_count = min(TIMELINE_CACHE_BATCH_SIZE, remaining_global, remaining_project)
+            for offset in range(take_count):
+                selected_events.append({
+                    "project": best_project,
+                    "event": project_event_pool[best_project][start_index + offset],
+                })
+            next_index_by_project[best_project] = start_index + take_count
+
+        for item in selected_events:
+            new_cache[item["project"]].append(item["event"])
+        with _TIMELINE_CACHE_LOCK:
+            PROJECT_TIMELINE_CACHE = new_cache
+    finally:
+        with _TIMELINE_CACHE_LOCK:
+            _TIMELINE_WARMING = False
 
 
 def warm_timeline_cache_async() -> bool:
-	global _TIMELINE_WARMING
-	with _TIMELINE_CACHE_LOCK:
-		if _TIMELINE_WARMING:
-			return False
-		_TIMELINE_WARMING = True
-	thread = threading.Thread(target=warm_timeline_cache, daemon=True)
-	thread.start()
-	return True
+    global _TIMELINE_WARMING
+    with _TIMELINE_CACHE_LOCK:
+        if _TIMELINE_WARMING:
+            return False
+        _TIMELINE_WARMING = True
+    thread = threading.Thread(target=warm_timeline_cache, daemon=True)
+    thread.start()
+    return True
 
 
 def clear_timeline_cache_async() -> bool:
-	global PROJECT_TIMELINE_CACHE
-	with _TIMELINE_CACHE_LOCK:
-		PROJECT_TIMELINE_CACHE = {}
-	return True
+    global PROJECT_TIMELINE_CACHE
+    with _TIMELINE_CACHE_LOCK:
+        PROJECT_TIMELINE_CACHE = {}
+    return True
 
 
 def get_all_timeline_events() -> list[TimelineProjectEvent]:
-	events: list[TimelineProjectEvent] = []
-	with _TIMELINE_CACHE_LOCK:
-		cache_snapshot = {
-			project_name: list(project_events) for project_name, project_events in PROJECT_TIMELINE_CACHE.items()
-		}
-	for project_name, project_events in cache_snapshot.items():
-		for event in project_events:
-			events.append({"project": project_name, "event": event})
-	events.sort(
-		key=lambda item: (item["event"]["timestamp"], item["event"]["oid"], item["project"]),
-		reverse=True,
-	)
-	return events
+    events: list[TimelineProjectEvent] = []
+    with _TIMELINE_CACHE_LOCK:
+        cache_snapshot = {
+            project_name: list(project_events) for project_name, project_events in PROJECT_TIMELINE_CACHE.items()
+        }
+    for project_name, project_events in cache_snapshot.items():
+        for event in project_events:
+            events.append({"project": project_name, "event": event})
+    events.sort(
+        key=lambda item: (item["event"]["timestamp"], item["event"]["oid"], item["project"]),
+        reverse=True,
+    )
+    return events
diff --git a/pygitweb/src/pygitweb/validation.py b/pygitweb/src/pygitweb/validation.py
index 612a799..328ba5e 100644
--- a/pygitweb/src/pygitweb/validation.py
+++ b/pygitweb/src/pygitweb/validation.py
@@ -14,62 +14,62 @@ import pygit2
 
 
 def is_valid_pathname(input_path: str | None) -> bool:
-	"""No '.', '..' as path elements, no null, no doubled slashes. Port of is_valid_pathname."""
-	if input_path is None:
-		return False
-	if "\0" in input_path:
-		return False
-	parts = input_path.strip("/").split("/")
-	return all(p not in ("", ".", "..") for p in parts)
+    """No '.', '..' as path elements, no null, no doubled slashes. Port of is_valid_pathname."""
+    if input_path is None:
+        return False
+    if "\0" in input_path:
+        return False
+    parts = input_path.strip("/").split("/")
+    return all(p not in ("", ".", "..") for p in parts)
 
 
 def is_valid_ref_format(input_ref: str | None) -> bool:
-	"""Git-check-ref-format rules: no /., no .., no control/space/special at start/end. Port of is_valid_ref_format."""
-	if input_ref is None:
-		return False
-	if "/." in input_ref or input_ref.startswith(".") or ".." in input_ref:
-		return False
-	if input_ref.endswith("/") or input_ref.endswith(".lock"):
-		return False
-	# No ASCII control, space, ~^:?*[
-	return not re.search(r"[\x00-\x20\x7f ~^:?*\[\]]", input_ref)
+    """Git-check-ref-format rules: no /., no .., no control/space/special at start/end. Port of is_valid_ref_format."""
+    if input_ref is None:
+        return False
+    if "/." in input_ref or input_ref.startswith(".") or ".." in input_ref:
+        return False
+    if input_ref.endswith("/") or input_ref.endswith(".lock"):
+        return False
+    # No ASCII control, space, ~^:?*[
+    return not re.search(r"[\x00-\x20\x7f ~^:?*\[\]]", input_ref)
 
 
 def check_export_ok(
-	git_dir: str,
-	export_ok: str = "",
-	export_auth_hook: Callable[[str], bool] | None = None,
-	across_fs: bool = False,
+    git_dir: str,
+    export_ok: str = "",
+    export_auth_hook: Callable[[str], bool] | None = None,
+    across_fs: bool = False,
 ) -> bool:
-	"""True if path is a git repo (via pygit2.discover_repository) and optional export_ok file / auth hook pass."""
-	if not os.path.isdir(git_dir):
-		return False
-	try:
-		discovered = pygit2.discover_repository(git_dir, across_fs)
-		if not discovered:
-			return False
-	except (KeyError, pygit2.GitError, OSError):
-		return False
-	if export_ok and not os.path.isfile(os.path.join(git_dir, export_ok)):
-		return False
-	return not export_auth_hook or export_auth_hook(git_dir)
+    """True if path is a git repo (via pygit2.discover_repository) and optional export_ok file / auth hook pass."""
+    if not os.path.isdir(git_dir):
+        return False
+    try:
+        discovered = pygit2.discover_repository(git_dir, across_fs)
+        if not discovered:
+            return False
+    except (KeyError, pygit2.GitError, OSError):
+        return False
+    if export_ok and not os.path.isfile(os.path.join(git_dir, export_ok)):
+        return False
+    return not export_auth_hook or export_auth_hook(git_dir)
 
 
 def is_valid_project(
-	project: str | None,
-	projectroot: str,
-	export_ok: str,
-	strict_export: bool,
-	project_in_list: Callable[[str], bool],
+    project: str | None,
+    projectroot: str,
+    export_ok: str,
+    strict_export: bool,
+    project_in_list: Callable[[str], bool],
 ) -> bool:
-	"""Pathname valid, dir exists, export_ok, and (if strict) in project list. Port of is_valid_project."""
-	if project is None:
-		return False
-	if not is_valid_pathname(project):
-		return False
-	full = os.path.join(projectroot, project)
-	if not os.path.isdir(full):
-		return False
-	if not check_export_ok(full, export_ok):
-		return False
-	return not strict_export or project_in_list(project)
+    """Pathname valid, dir exists, export_ok, and (if strict) in project list. Port of is_valid_project."""
+    if project is None:
+        return False
+    if not is_valid_pathname(project):
+        return False
+    full = os.path.join(projectroot, project)
+    if not os.path.isdir(full):
+        return False
+    if not check_export_ok(full, export_ok):
+        return False
+    return not strict_export or project_in_list(project)
diff --git a/pygitweb/src/pygitweb/validation_test.py b/pygitweb/src/pygitweb/validation_test.py
index 9497c79..f812f41 100644
--- a/pygitweb/src/pygitweb/validation_test.py
+++ b/pygitweb/src/pygitweb/validation_test.py
@@ -13,155 +13,155 @@ from pygitweb.validation import check_export_ok, is_valid_pathname, is_valid_pro
 
 
 class TestValidation:
-	@pytest.fixture(scope="class")
-	def project_env(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
-		projectroot: Path = tmp_path_factory.mktemp("validation-projects")
-		valid_dir: Path = projectroot / "valid"
-		invalid_dir: Path = projectroot / "invalid"
-		unreachable_dir: Path = projectroot / "unreachable"
-
-		valid_dir.mkdir()
-		invalid_dir.mkdir()
-		unreachable_dir.mkdir()
-
-		pygit2.init_repository(str(valid_dir), bare=True)
-		(valid_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
-
-		pygit2.init_repository(str(unreachable_dir), bare=True)
-		(unreachable_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
-		original_mode: int = stat.S_IMODE(unreachable_dir.stat().st_mode)
-		os.chmod(unreachable_dir, 0)
-
-		try:
-			yield {
-				"projectroot": str(projectroot),
-				"valid_name": "valid",
-				"invalid_name": "invalid",
-				"unreachable_name": "unreachable",
-				"valid_dir": str(valid_dir),
-				"invalid_dir": str(invalid_dir),
-				"unreachable_dir": str(unreachable_dir),
-				"export_ok": "git-daemon-export-ok",
-			}
-		finally:
-			os.chmod(unreachable_dir, original_mode)
-
-	class TestPathname:
-		def test_is_valid_pathname_success(self) -> None:
-			assert is_valid_pathname("group/project.git") is True
-
-		@pytest.mark.parametrize("input_path", [None, "a//b", "a/./b", "a/../b", "a\0b"])
-		def test_is_valid_pathname_failure_modes(self, input_path: str | None) -> None:
-			assert is_valid_pathname(input_path) is False
-
-	class TestRefFormat:
-		def test_is_valid_ref_format_success(self) -> None:
-			assert is_valid_ref_format("refs/heads/main") is True
-
-		@pytest.mark.parametrize(
-			"input_ref",
-			[None, ".bad", "refs/heads/../x", "refs/heads/x.lock", "refs/heads/x ", "refs/heads/x?y"],
-		)
-		def test_is_valid_ref_format_failure_modes(self, input_ref: str | None) -> None:
-			assert is_valid_ref_format(input_ref) is False
-
-	class TestCheckExportOk:
-		def test_check_export_ok_success(self, project_env: dict[str, str]) -> None:
-			assert check_export_ok(project_env["valid_dir"], project_env["export_ok"]) is True
-
-		def test_check_export_ok_fails_for_missing_directory(self, project_env: dict[str, str]) -> None:
-			missing_dir: str = str(Path(project_env["projectroot"]) / "missing")
-			assert check_export_ok(missing_dir, project_env["export_ok"]) is False
-
-		def test_check_export_ok_fails_for_non_repo_directory(self, project_env: dict[str, str]) -> None:
-			assert check_export_ok(project_env["invalid_dir"], project_env["export_ok"]) is False
-
-		def test_check_export_ok_fails_for_missing_export_file(self, project_env: dict[str, str]) -> None:
-			assert check_export_ok(project_env["valid_dir"], "missing-export-ok") is False
-
-		def test_check_export_ok_fails_when_hook_rejects(self, project_env: dict[str, str]) -> None:
-			assert (
-				check_export_ok(
-					project_env["valid_dir"],
-					project_env["export_ok"],
-					export_auth_hook=lambda _path: False,
-				)
-				is False
-			)
-
-		@pytest.mark.skipif(sys.platform == "win32", reason="chmod-based unreachable directory check is not portable")
-		def test_check_export_ok_fails_for_unreachable_repo(self, project_env: dict[str, str]) -> None:
-			assert check_export_ok(project_env["unreachable_dir"], project_env["export_ok"]) is False
-
-	class TestValidProject:
-		def test_is_valid_project_success(self, project_env: dict[str, str]) -> None:
-			assert (
-				is_valid_project(
-					project=project_env["valid_name"],
-					projectroot=project_env["projectroot"],
-					export_ok=project_env["export_ok"],
-					strict_export=False,
-					project_in_list=lambda _project: True,
-				)
-				is True
-			)
-
-		def test_is_valid_project_fails_for_none(self, project_env: dict[str, str]) -> None:
-			assert (
-				is_valid_project(
-					project=None,
-					projectroot=project_env["projectroot"],
-					export_ok=project_env["export_ok"],
-					strict_export=False,
-					project_in_list=lambda _project: True,
-				)
-				is False
-			)
-
-		def test_is_valid_project_fails_for_invalid_pathname(self, project_env: dict[str, str]) -> None:
-			assert (
-				is_valid_project(
-					project="bad/../name",
-					projectroot=project_env["projectroot"],
-					export_ok=project_env["export_ok"],
-					strict_export=False,
-					project_in_list=lambda _project: True,
-				)
-				is False
-			)
-
-		def test_is_valid_project_fails_for_missing_directory(self, project_env: dict[str, str]) -> None:
-			assert (
-				is_valid_project(
-					project="missing",
-					projectroot=project_env["projectroot"],
-					export_ok=project_env["export_ok"],
-					strict_export=False,
-					project_in_list=lambda _project: True,
-				)
-				is False
-			)
-
-		def test_is_valid_project_fails_when_export_check_fails(self, project_env: dict[str, str]) -> None:
-			assert (
-				is_valid_project(
-					project=project_env["invalid_name"],
-					projectroot=project_env["projectroot"],
-					export_ok=project_env["export_ok"],
-					strict_export=False,
-					project_in_list=lambda _project: True,
-				)
-				is False
-			)
-
-		def test_is_valid_project_fails_strict_export_when_not_listed(self, project_env: dict[str, str]) -> None:
-			assert (
-				is_valid_project(
-					project=project_env["valid_name"],
-					projectroot=project_env["projectroot"],
-					export_ok=project_env["export_ok"],
-					strict_export=True,
-					project_in_list=lambda _project: False,
-				)
-				is False
-			)
+    @pytest.fixture(scope="class")
+    def project_env(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
+        projectroot: Path = tmp_path_factory.mktemp("validation-projects")
+        valid_dir: Path = projectroot / "valid"
+        invalid_dir: Path = projectroot / "invalid"
+        unreachable_dir: Path = projectroot / "unreachable"
+
+        valid_dir.mkdir()
+        invalid_dir.mkdir()
+        unreachable_dir.mkdir()
+
+        pygit2.init_repository(str(valid_dir), bare=True)
+        (valid_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
+
+        pygit2.init_repository(str(unreachable_dir), bare=True)
+        (unreachable_dir / "git-daemon-export-ok").write_text("", encoding="utf-8")
+        original_mode: int = stat.S_IMODE(unreachable_dir.stat().st_mode)
+        os.chmod(unreachable_dir, 0)
+
+        try:
+            yield {
+                "projectroot": str(projectroot),
+                "valid_name": "valid",
+                "invalid_name": "invalid",
+                "unreachable_name": "unreachable",
+                "valid_dir": str(valid_dir),
+                "invalid_dir": str(invalid_dir),
+                "unreachable_dir": str(unreachable_dir),
+                "export_ok": "git-daemon-export-ok",
+            }
+        finally:
+            os.chmod(unreachable_dir, original_mode)
+
+    class TestPathname:
+        def test_is_valid_pathname_success(self) -> None:
+            assert is_valid_pathname("group/project.git") is True
+
+        @pytest.mark.parametrize("input_path", [None, "a//b", "a/./b", "a/../b", "a\0b"])
+        def test_is_valid_pathname_failure_modes(self, input_path: str | None) -> None:
+            assert is_valid_pathname(input_path) is False
+
+    class TestRefFormat:
+        def test_is_valid_ref_format_success(self) -> None:
+            assert is_valid_ref_format("refs/heads/main") is True
+
+        @pytest.mark.parametrize(
+            "input_ref",
+            [None, ".bad", "refs/heads/../x", "refs/heads/x.lock", "refs/heads/x ", "refs/heads/x?y"],
+        )
+        def test_is_valid_ref_format_failure_modes(self, input_ref: str | None) -> None:
+            assert is_valid_ref_format(input_ref) is False
+
+    class TestCheckExportOk:
+        def test_check_export_ok_success(self, project_env: dict[str, str]) -> None:
+            assert check_export_ok(project_env["valid_dir"], project_env["export_ok"]) is True
+
+        def test_check_export_ok_fails_for_missing_directory(self, project_env: dict[str, str]) -> None:
+            missing_dir: str = str(Path(project_env["projectroot"]) / "missing")
+            assert check_export_ok(missing_dir, project_env["export_ok"]) is False
+
+        def test_check_export_ok_fails_for_non_repo_directory(self, project_env: dict[str, str]) -> None:
+            assert check_export_ok(project_env["invalid_dir"], project_env["export_ok"]) is False
+
+        def test_check_export_ok_fails_for_missing_export_file(self, project_env: dict[str, str]) -> None:
+            assert check_export_ok(project_env["valid_dir"], "missing-export-ok") is False
+
+        def test_check_export_ok_fails_when_hook_rejects(self, project_env: dict[str, str]) -> None:
+            assert (
+                check_export_ok(
+                    project_env["valid_dir"],
+                    project_env["export_ok"],
+                    export_auth_hook=lambda _path: False,
+                )
+                is False
+            )
+
+        @pytest.mark.skipif(sys.platform == "win32", reason="chmod-based unreachable directory check is not portable")
+        def test_check_export_ok_fails_for_unreachable_repo(self, project_env: dict[str, str]) -> None:
+            assert check_export_ok(project_env["unreachable_dir"], project_env["export_ok"]) is False
+
+    class TestValidProject:
+        def test_is_valid_project_success(self, project_env: dict[str, str]) -> None:
+            assert (
+                is_valid_project(
+                    project=project_env["valid_name"],
+                    projectroot=project_env["projectroot"],
+                    export_ok=project_env["export_ok"],
+                    strict_export=False,
+                    project_in_list=lambda _project: True,
+                )
+                is True
+            )
+
+        def test_is_valid_project_fails_for_none(self, project_env: dict[str, str]) -> None:
+            assert (
+                is_valid_project(
+                    project=None,
+                    projectroot=project_env["projectroot"],
+                    export_ok=project_env["export_ok"],
+                    strict_export=False,
+                    project_in_list=lambda _project: True,
+                )
+                is False
+            )
+
+        def test_is_valid_project_fails_for_invalid_pathname(self, project_env: dict[str, str]) -> None:
+            assert (
+                is_valid_project(
+                    project="bad/../name",
+                    projectroot=project_env["projectroot"],
+                    export_ok=project_env["export_ok"],
+                    strict_export=False,
+                    project_in_list=lambda _project: True,
+                )
+                is False
+            )
+
+        def test_is_valid_project_fails_for_missing_directory(self, project_env: dict[str, str]) -> None:
+            assert (
+                is_valid_project(
+                    project="missing",
+                    projectroot=project_env["projectroot"],
+                    export_ok=project_env["export_ok"],
+                    strict_export=False,
+                    project_in_list=lambda _project: True,
+                )
+                is False
+            )
+
+        def test_is_valid_project_fails_when_export_check_fails(self, project_env: dict[str, str]) -> None:
+            assert (
+                is_valid_project(
+                    project=project_env["invalid_name"],
+                    projectroot=project_env["projectroot"],
+                    export_ok=project_env["export_ok"],
+                    strict_export=False,
+                    project_in_list=lambda _project: True,
+                )
+                is False
+            )
+
+        def test_is_valid_project_fails_strict_export_when_not_listed(self, project_env: dict[str, str]) -> None:
+            assert (
+                is_valid_project(
+                    project=project_env["valid_name"],
+                    projectroot=project_env["projectroot"],
+                    export_ok=project_env["export_ok"],
+                    strict_export=True,
+                    project_in_list=lambda _project: False,
+                )
+                is False
+            )
diff --git a/pyproject.toml b/pyproject.toml
index 196bdcf..0f3195e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,6 @@ line-length = 120
 
 [tool.ruff.format]
 quote-style = "double"
-indent-style = "tab"
 docstring-code-format = true
 
 [tool.ruff.lint]
diff --git a/scripts/mypy.py b/scripts/mypy.py
index 88a2303..4f6837f 100644
--- a/scripts/mypy.py
+++ b/scripts/mypy.py
@@ -6,8 +6,8 @@ import sys
 
 
 def main() -> int:
-	return subprocess.call([sys.executable, "-m", "mypy", "."])
+    return subprocess.call([sys.executable, "-m", "mypy", "."])
 
 
 if __name__ == "__main__":
-	raise SystemExit(main())
+    raise SystemExit(main())
diff --git a/scripts/pytest.py b/scripts/pytest.py
index b3e2115..cc62b0f 100644
--- a/scripts/pytest.py
+++ b/scripts/pytest.py
@@ -6,8 +6,8 @@ import sys
 
 
 def main() -> int:
-	return subprocess.call([sys.executable, "-m", "pytest", ".", *sys.argv[1:]])
+    return subprocess.call([sys.executable, "-m", "pytest", ".", *sys.argv[1:]])
 
 
 if __name__ == "__main__":
-	raise SystemExit(main())
+    raise SystemExit(main())
diff --git a/scripts/pytest_version.py b/scripts/pytest_version.py
index fff1f0b..e7f2cc9 100644
--- a/scripts/pytest_version.py
+++ b/scripts/pytest_version.py
@@ -11,35 +11,35 @@ from pathlib import Path
 
 
 def run(version: str, pytest_args: list[str]) -> int:
-	root = Path(__file__).resolve().parents[1]
-	with tempfile.TemporaryDirectory(prefix=f"pygitweb-{version}-", ignore_cleanup_errors=True) as tmp:
-		env = os.environ.copy()
-		env.pop("VIRTUAL_ENV", None)
-		env.pop("PYTHONHOME", None)
-		env["UV_PROJECT_ENVIRONMENT"] = str(Path(tmp) / ".venv")
-		sync = subprocess.run(
-			["uv", "sync", "--all-packages", "--python", version],
-			cwd=root,
-			env=env,
-			check=False,
-		)
-		if sync.returncode != 0:
-			return sync.returncode
-		proc = subprocess.run(
-			["uv", "run", "--python", version, "pytest", ".", *pytest_args],
-			cwd=root,
-			env=env,
-			check=False,
-		)
-		return proc.returncode
+    root = Path(__file__).resolve().parents[1]
+    with tempfile.TemporaryDirectory(prefix=f"pygitweb-{version}-", ignore_cleanup_errors=True) as tmp:
+        env = os.environ.copy()
+        env.pop("VIRTUAL_ENV", None)
+        env.pop("PYTHONHOME", None)
+        env["UV_PROJECT_ENVIRONMENT"] = str(Path(tmp) / ".venv")
+        sync = subprocess.run(
+            ["uv", "sync", "--all-packages", "--python", version],
+            cwd=root,
+            env=env,
+            check=False,
+        )
+        if sync.returncode != 0:
+            return sync.returncode
+        proc = subprocess.run(
+            ["uv", "run", "--python", version, "pytest", ".", *pytest_args],
+            cwd=root,
+            env=env,
+            check=False,
+        )
+        return proc.returncode
 
 
 def main() -> int:
-	if len(sys.argv) < 2 or sys.argv[1] == "":
-		print("usage: pytest_version.py VERSION [pytest args...]", file=sys.stderr)
-		return 2
-	return run(sys.argv[1], sys.argv[2:])
+    if len(sys.argv) < 2 or sys.argv[1] == "":
+        print("usage: pytest_version.py VERSION [pytest args...]", file=sys.stderr)
+        return 2
+    return run(sys.argv[1], sys.argv[2:])
 
 
 if __name__ == "__main__":
-	raise SystemExit(main())
+    raise SystemExit(main())
diff --git a/scripts/pytest_version_test.py b/scripts/pytest_version_test.py
index 9b06597..8eca616 100644
--- a/scripts/pytest_version_test.py
+++ b/scripts/pytest_version_test.py
@@ -11,72 +11,72 @@ import pytest
 
 
 def _load_script() -> ModuleType:
-	path = Path(__file__).resolve().parent / "pytest_version.py"
-	spec = importlib.util.spec_from_file_location("pytest_version_script", path)
-	assert spec is not None and spec.loader is not None
-	module = importlib.util.module_from_spec(spec)
-	spec.loader.exec_module(module)
-	return module
+    path = Path(__file__).resolve().parent / "pytest_version.py"
+    spec = importlib.util.spec_from_file_location("pytest_version_script", path)
+    assert spec is not None and spec.loader is not None
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
 
 
 def test_run_syncs_temp_env_then_pytest(monkeypatch: pytest.MonkeyPatch) -> None:
-	module = _load_script()
-	commands: list[list[str]] = []
-	envs: list[dict[str, str]] = []
-
-	class _Proc:
-		def __init__(self, returncode: int) -> None:
-			self.returncode = returncode
-
-	def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
-		commands.append(list(cmd))
-		env = kwargs["env"]
-		assert isinstance(env, dict)
-		envs.append({str(key): str(value) for key, value in env.items()})
-		return _Proc(0)
-
-	monkeypatch.setattr(module.subprocess, "run", fake_run)
-	assert module.run("3.12", ["-k", "foo"]) == 0
-	assert commands[0] == ["uv", "sync", "--all-packages", "--python", "3.12"]
-	assert commands[1] == ["uv", "run", "--python", "3.12", "pytest", ".", "-k", "foo"]
-	assert "VIRTUAL_ENV" not in envs[0]
-	assert envs[0]["UV_PROJECT_ENVIRONMENT"].endswith(".venv")
-	assert envs[0]["UV_PROJECT_ENVIRONMENT"] == envs[1]["UV_PROJECT_ENVIRONMENT"]
+    module = _load_script()
+    commands: list[list[str]] = []
+    envs: list[dict[str, str]] = []
+
+    class _Proc:
+        def __init__(self, returncode: int) -> None:
+            self.returncode = returncode
+
+    def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
+        commands.append(list(cmd))
+        env = kwargs["env"]
+        assert isinstance(env, dict)
+        envs.append({str(key): str(value) for key, value in env.items()})
+        return _Proc(0)
+
+    monkeypatch.setattr(module.subprocess, "run", fake_run)
+    assert module.run("3.12", ["-k", "foo"]) == 0
+    assert commands[0] == ["uv", "sync", "--all-packages", "--python", "3.12"]
+    assert commands[1] == ["uv", "run", "--python", "3.12", "pytest", ".", "-k", "foo"]
+    assert "VIRTUAL_ENV" not in envs[0]
+    assert envs[0]["UV_PROJECT_ENVIRONMENT"].endswith(".venv")
+    assert envs[0]["UV_PROJECT_ENVIRONMENT"] == envs[1]["UV_PROJECT_ENVIRONMENT"]
 
 
 def test_run_stops_when_sync_fails(monkeypatch: pytest.MonkeyPatch) -> None:
-	module = _load_script()
-	commands: list[list[str]] = []
+    module = _load_script()
+    commands: list[list[str]] = []
 
-	class _Proc:
-		returncode = 4
+    class _Proc:
+        returncode = 4
 
-	def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
-		del kwargs
-		commands.append(list(cmd))
-		return _Proc()
+    def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
+        del kwargs
+        commands.append(list(cmd))
+        return _Proc()
 
-	monkeypatch.setattr(module.subprocess, "run", fake_run)
-	assert module.run("3.11", []) == 4
-	assert len(commands) == 1
+    monkeypatch.setattr(module.subprocess, "run", fake_run)
+    assert module.run("3.11", []) == 4
+    assert len(commands) == 1
 
 
 def test_main_requires_version(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
-	module = _load_script()
-	monkeypatch.setattr(sys, "argv", ["pytest_version.py"])
-	assert module.main() == 2
-	assert "usage:" in capsys.readouterr().err
+    module = _load_script()
+    monkeypatch.setattr(sys, "argv", ["pytest_version.py"])
+    assert module.main() == 2
+    assert "usage:" in capsys.readouterr().err
 
 
 def test_main_forwards_pytest_args(monkeypatch: pytest.MonkeyPatch) -> None:
-	module = _load_script()
-	seen: list[tuple[str, list[str]]] = []
+    module = _load_script()
+    seen: list[tuple[str, list[str]]] = []
 
-	def fake_run(version: str, pytest_args: list[str]) -> int:
-		seen.append((version, pytest_args))
-		return 0
+    def fake_run(version: str, pytest_args: list[str]) -> int:
+        seen.append((version, pytest_args))
+        return 0
 
-	monkeypatch.setattr(module, "run", fake_run)
-	monkeypatch.setattr(sys, "argv", ["pytest_version.py", "3.14", "-q"])
-	assert module.main() == 0
-	assert seen == [("3.14", ["-q"])]
+    monkeypatch.setattr(module, "run", fake_run)
+    monkeypatch.setattr(sys, "argv", ["pytest_version.py", "3.14", "-q"])
+    assert module.main() == 0
+    assert seen == [("3.14", ["-q"])]
diff --git a/scripts/ruff_check.py b/scripts/ruff_check.py
index 1451cfb..e177faa 100644
--- a/scripts/ruff_check.py
+++ b/scripts/ruff_check.py
@@ -6,8 +6,8 @@ import sys
 
 
 def main() -> int:
-	return subprocess.call([sys.executable, "-m", "ruff", "check", "--fix"])
+    return subprocess.call([sys.executable, "-m", "ruff", "check", "--fix"])
 
 
 if __name__ == "__main__":
-	raise SystemExit(main())
+    raise SystemExit(main())
diff --git a/scripts/ruff_format.py b/scripts/ruff_format.py
index b9da743..a6007bd 100644
--- a/scripts/ruff_format.py
+++ b/scripts/ruff_format.py
@@ -6,8 +6,8 @@ import sys
 
 
 def main() -> int:
-	return subprocess.call([sys.executable, "-m", "ruff", "format", "--preview"])
+    return subprocess.call([sys.executable, "-m", "ruff", "format", "--preview"])
 
 
 if __name__ == "__main__":
-	raise SystemExit(main())
+    raise SystemExit(main())
