From 1209e1c2d96fcd9d2b6e0ddacabae6e41d1228ab Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:10:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(ledger):=20a=20repo=20binding=20names=20th?= =?UTF-8?q?e=20branch=20its=20ledger=20follows=20=E2=80=94=20bind=5Frepo(r?= =?UTF-8?q?ef=3D)=20(#2873,=20milestone=20294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project 2 is bound to main, so every consolidation of the 2026-08 audit was invisible to the ledger until the dev→main merge; the operator works on dev (rule 1). repo_bindings.ref (migration 0082, nullable) is the branch the coverage refresh reads; NULL keeps the forge default branch. set_binding takes ref (name sets, "" clears, None leaves standing); bindings_for_project feeds the refresh; bind_repo exposes ref ("-" clears). to_dict carries it. Operator decision on #2873 (2026-08-21): per-binding ref, chosen at bind time, default the repo default branch. Co-Authored-By: Claude Fable 5 --- alembic/versions/0082_repo_binding_ref.py | 26 ++++++++++++++++++++ src/scribe/mcp/tools/repos.py | 21 +++++++++++++--- src/scribe/models/repo_binding.py | 5 ++++ src/scribe/services/coverage.py | 9 ++++--- src/scribe/services/repo_bindings.py | 26 ++++++++++++++++++-- tests/test_pattern_coverage.py | 30 ++++++++++++++++++++++- 6 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 alembic/versions/0082_repo_binding_ref.py diff --git a/alembic/versions/0082_repo_binding_ref.py b/alembic/versions/0082_repo_binding_ref.py new file mode 100644 index 0000000..30f4916 --- /dev/null +++ b/alembic/versions/0082_repo_binding_ref.py @@ -0,0 +1,26 @@ +"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294) + +Revision ID: 0082 +Revises: 0081 +Create Date: 2026-08-21 + +A repo binding used to imply the repo's default branch; the shape ledger +therefore only saw work after a merge to main, while the operator's work +lands on dev (rule 1). `ref` names the branch the coverage refresh reads — +NULL keeps today's behaviour (the forge's default branch). +""" +import sqlalchemy as sa +from alembic import op + +revision = "0082" +down_revision = "0081" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("repo_bindings", "ref") diff --git a/src/scribe/mcp/tools/repos.py b/src/scribe/mcp/tools/repos.py index f652587..a08ec3c 100644 --- a/src/scribe/mcp/tools/repos.py +++ b/src/scribe/mcp/tools/repos.py @@ -13,28 +13,43 @@ from scribe.services import projects as projects_svc from scribe.services import repo_bindings as repo_bindings_svc -async def bind_repo(repo_url: str, project_id: int) -> dict: +async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict: """Bind a git repository to a Scribe project for session-start context. After this, any session started in that repo auto-loads the project's context (the SessionStart hook sends the repo's remote; the server resolves it here). Idempotent — re-binding the same repo updates the target project. + The binding is also what the shape ledger reads (refresh_pattern_coverage): + `ref` names the branch it follows. Default (""): the repo's default branch + — which means the ledger only sees work after a merge. A dev-first project + (rule 1: dev is home) should bind with ref="dev" so classification follows + the push, not the merge. Re-binding with ref="" keeps the standing ref; + pass ref="-" to clear it back to the default branch. + Args: repo_url: the repo's git remote (e.g. the output of `git remote get-url origin` — ssh or https form, both work). project_id: the Scribe project this repo represents. + ref: branch the ledger follows ("" = leave as is / default branch on + a new binding; "-" = clear to the default branch). """ uid = current_user_id() project = await projects_svc.get_project(uid, project_id) if project is None: raise ValueError(f"project {project_id} not found") - binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id) + ref_arg = None if not ref else ("" if ref.strip() == "-" else ref) + binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id, ref_arg) + follows = binding.ref or "the default branch" return { "repo_key": binding.repo_key, "project_id": binding.project_id, "project_title": project.title, - "message": f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}).", + "ref": binding.ref, + "message": ( + f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}); " + f"the ledger follows {follows}." + ), } diff --git a/src/scribe/models/repo_binding.py b/src/scribe/models/repo_binding.py index 8686bcf..7be94eb 100644 --- a/src/scribe/models/repo_binding.py +++ b/src/scribe/models/repo_binding.py @@ -28,6 +28,10 @@ class RepoBinding(Base, TimestampMixin): Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False ) repo_key: Mapped[str] = mapped_column(Text, nullable=False) + # The branch the coverage refresh reads for this binding (#2873); NULL = + # the forge's default branch. Chosen at bind time so a dev-first project + # can have its ledger follow dev instead of waiting for the merge. + ref: Mapped[str | None] = mapped_column(Text, nullable=True) def to_dict(self) -> dict: return { @@ -35,6 +39,7 @@ class RepoBinding(Base, TimestampMixin): "user_id": self.user_id, "project_id": self.project_id, "repo_key": self.repo_key, + "ref": self.ref, "created_at": iso(self.created_at), "updated_at": iso(self.updated_at), } diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index a1e44fd..2d1db2d 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -36,7 +36,7 @@ from typing import NamedTuple from datetime import datetime, timedelta, timezone from scribe.services.forge import ForgeSelector, get_forges -from scribe.services.repo_bindings import keys_for_project +from scribe.services.repo_bindings import bindings_for_project from scribe.services.settings import get_setting, set_setting logger = logging.getLogger(__name__) @@ -407,12 +407,15 @@ async def compute_coverage( # the project's repos (#2792). canons = None proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0} - for key in await keys_for_project(user_id, project_id): + for binding in await bindings_for_project(user_id, project_id): + key = binding.repo_key hit = selector.resolve(key) if hit is None: continue # bound to a host no connection serves forge, api_repo = hit - ref = await forge.default_branch(api_repo) + # The binding's own ref when it names one (#2873: a dev-first project + # has its ledger follow dev), else the forge's default branch. + ref = binding.ref or await forge.default_branch(api_repo) definitions = definitions_from_archive(await forge.archive(api_repo, ref)) # The head commit is provenance sugar on the ledger rows; failing to # learn it must not fail the sync — the ref names the point well diff --git a/src/scribe/services/repo_bindings.py b/src/scribe/services/repo_bindings.py index 724a339..5d0b85b 100644 --- a/src/scribe/services/repo_bindings.py +++ b/src/scribe/services/repo_bindings.py @@ -68,8 +68,16 @@ async def resolve_project(user_id: int, raw_repo: str) -> int | None: return row.scalar_one_or_none() -async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBinding: - """Create or update the binding for a repo. Idempotent on (user, repo_key).""" +async def set_binding( + user_id: int, raw_repo: str, project_id: int, ref: str | None = None, +) -> RepoBinding: + """Create or update the binding for a repo. Idempotent on (user, repo_key). + + ``ref`` (#2873) is the branch the coverage refresh reads for this + binding: a name sets it, ``""`` clears it back to the forge's default + branch, ``None`` leaves whatever stands (a re-bind that only moves the + project keeps the ref it had). + """ key = normalize_repo_key(raw_repo) if not key: raise ValueError("repo remote is empty or unparseable") @@ -85,6 +93,8 @@ async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBindi session.add(binding) else: binding.project_id = project_id + if ref is not None: + binding.ref = ref.strip() or None await session.commit() await session.refresh(binding) return binding @@ -100,6 +110,18 @@ async def list_bindings(user_id: int) -> list[RepoBinding]: return list(rows.scalars().all()) +async def bindings_for_project(user_id: int, project_id: int) -> list[RepoBinding]: + """Every binding of a project — key AND the ref its ledger follows (#2873).""" + async with async_session() as session: + rows = await session.execute( + select(RepoBinding).where( + RepoBinding.user_id == user_id, + RepoBinding.project_id == project_id, + ).order_by(RepoBinding.repo_key) + ) + return list(rows.scalars().all()) + + async def keys_for_project(user_id: int, project_id: int) -> list[str]: """Every repo key bound to a project — the snippet→forge join (#2691). diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index 5bdd4fd..0d1c0cb 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -168,6 +168,13 @@ def test_coverage_line_is_evidence_carrying_and_labeled_estimate(): assert "internal/api, web/src/components" in line +def test_bind_repo_tool_takes_a_ref(): + """#2873: the binding names the branch the ledger follows.""" + from scribe.mcp.server import build_mcp_server + tool = build_mcp_server()._tool_manager.get_tool("bind_repo") + assert "ref" in tool.parameters.get("properties", {}) + + def test_coverage_routes_are_registered(): from scribe.app import create_app @@ -188,7 +195,8 @@ def _forge(tar_bytes: bytes): path = request.url.path if path == "/api/v1/repos/alice/widget": return httpx.Response(200, json={"default_branch": "main"}) - if path == "/api/v1/repos/alice/widget/archive/main.tar.gz": + if path in ("/api/v1/repos/alice/widget/archive/main.tar.gz", + "/api/v1/repos/alice/widget/archive/dev.tar.gz"): return httpx.Response(200, content=tar_bytes) return httpx.Response(404, json={"message": "not found"}) @@ -533,3 +541,23 @@ def test_scoped_definitions_are_vue_script_setup_and_scoped_style_only(): extract_definitions(".card {\n x: 1;\n}\n")) == set() assert scoped_definitions("src/a.py", "def load():\n pass\n", extract_definitions("def load():\n pass\n")) == set() + +@pytest.mark.integration +async def test_binding_ref_is_the_branch_the_ledger_follows(seeded): + """#2873: a binding that names a ref is read at that ref (not the forge's + default branch); "" clears it; None on a re-bind leaves it standing.""" + from scribe.services.repo_bindings import bindings_for_project, set_binding + uid, pid = seeded["uid"], seeded["pid"] + b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "dev") + assert b.ref == "dev" + coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) + assert coverage["repos"][0]["ref"] == "dev" + # A re-bind without a ref keeps it; "" clears it back to the default branch. + b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid) + assert b.ref == "dev" + b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "") + assert b.ref is None + assert [x.ref for x in await bindings_for_project(uid, pid)] == [None] + coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) + assert coverage["repos"][0]["ref"] == "main" +