From 374a76c867930955dbc9b8484dc7efbb7007786f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 04:34:32 -0400 Subject: [PATCH] =?UTF-8?q?chore:=20branches=20polish=20=E2=80=94=20global?= =?UTF-8?q?-leak=20docs=20+=20unknown-name=20warning=20(T103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chat/state/branches.py | 31 +++++++++++++++++++++++++++++++ tests/test_branches_state.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/chat/state/branches.py b/chat/state/branches.py index 101627e..c51808e 100644 --- a/chat/state/branches.py +++ b/chat/state/branches.py @@ -9,11 +9,15 @@ existing event readers remain branch-agnostic. """ from __future__ import annotations + +import logging from sqlite3 import Connection from chat.eventlog.projector import on from chat.eventlog.log import Event +logger = logging.getLogger(__name__) + @on("branch_created") def _apply_branch_created(conn: Connection, e: Event) -> None: @@ -37,9 +41,26 @@ def _apply_branch_switched(conn: Connection, e: Event) -> None: """Set is_active=1 on the named branch and is_active=0 on all others. Atomic via two UPDATEs ordered to avoid the unique-active-index race. + + If the named branch does not exist, a warning is emitted and the + is_active flags are still cleared (preserving prior behavior — the + second UPDATE simply matches no rows). Callers should validate the + name upstream; this guard surfaces accidental mismatches in the log. """ p = e.payload name = p["name"] + # Warn (don't raise) if the target branch is missing. The existing + # outcome — zero active branches — is preserved; this just makes the + # condition observable instead of silent. + exists = conn.execute( + "SELECT 1 FROM branches WHERE name = ? LIMIT 1", + (name,), + ).fetchone() + if exists is None: + logger.warning( + "branch_switched to unknown branch name %r; no branch will be active", + name, + ) # Clear ALL is_active flags first (avoids the unique-index trip). conn.execute("UPDATE branches SET is_active = 0 WHERE is_active = 1") conn.execute( @@ -79,6 +100,16 @@ def get_branch(conn: Connection, name: str) -> dict | None: def list_branches(conn: Connection, chat_id: str | None = None) -> list[dict]: + """Return branch rows, optionally scoped to a chat. + + When ``chat_id`` is provided the filter is ``chat_id = ? OR chat_id IS NULL``, + so global (null-chat) branches are returned in *every* per-chat scope. This + is intentional: the bootstrapped ``"main"`` branch (and any future + null-chat branches) are global by design — they belong to no single chat + and should appear alongside per-chat branches in any chat-scoped listing. + Callers that want only per-chat branches should filter the result on + ``chat_id is not None``. + """ if chat_id is None: rows = conn.execute( "SELECT id, name, origin_event_id, head_event_id, chat_id, " diff --git a/tests/test_branches_state.py b/tests/test_branches_state.py index ace2e8e..ea397e2 100644 --- a/tests/test_branches_state.py +++ b/tests/test_branches_state.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from chat.db.connection import open_db from chat.db.migrate import apply_migrations from chat.eventlog.log import append_event @@ -139,3 +141,36 @@ def test_list_branches_returns_all(tmp_path): names = [b["name"] for b in list_branches(conn)] assert "main" in names assert "experiment" in names + + +def test_branch_switched_unknown_name_warns(tmp_path, caplog): + """Switching to a nonexistent branch logs a warning and leaves no branch active. + + The previous behavior silently cleared is_active flags and applied no UPDATE + when the named branch did not exist. T103 makes that condition observable + by emitting a warning while preserving the existing (zero-active) outcome. + """ + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + with caplog.at_level(logging.WARNING, logger="chat.state.branches"): + append_event( + conn, + kind="branch_switched", + payload={"name": "does_not_exist"}, + ) + project(conn) + + # A warning was emitted naming the missing branch. + warnings = [ + r for r in caplog.records + if r.levelno == logging.WARNING and r.name == "chat.state.branches" + ] + assert warnings, "expected a warning for unknown branch name" + assert any("does_not_exist" in r.getMessage() for r in warnings) + + # Existing behavior preserved: no branch is active after the switch. + assert active_branch(conn) is None + + # The unknown name was not inserted as a side effect. + assert get_branch(conn, "does_not_exist") is None