chore: branches polish — global-leak docs + unknown-name warning (T103)
This commit is contained in:
@@ -9,11 +9,15 @@ existing event readers remain branch-agnostic.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from sqlite3 import Connection
|
from sqlite3 import Connection
|
||||||
|
|
||||||
from chat.eventlog.projector import on
|
from chat.eventlog.projector import on
|
||||||
from chat.eventlog.log import Event
|
from chat.eventlog.log import Event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@on("branch_created")
|
@on("branch_created")
|
||||||
def _apply_branch_created(conn: Connection, e: Event) -> None:
|
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.
|
"""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.
|
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
|
p = e.payload
|
||||||
name = p["name"]
|
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).
|
# 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("UPDATE branches SET is_active = 0 WHERE is_active = 1")
|
||||||
conn.execute(
|
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]:
|
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:
|
if chat_id is None:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
|
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from chat.db.connection import open_db
|
from chat.db.connection import open_db
|
||||||
from chat.db.migrate import apply_migrations
|
from chat.db.migrate import apply_migrations
|
||||||
from chat.eventlog.log import append_event
|
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)]
|
names = [b["name"] for b in list_branches(conn)]
|
||||||
assert "main" in names
|
assert "main" in names
|
||||||
assert "experiment" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user