feat(ledger): a repo binding names the branch its ledger follows — bind_repo(ref=) (#2873, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Canceled after 30s
CI & Build / Python tests (push) Canceled after 30s
CI & Build / Build & push image (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:10:54 -04:00
co-authored by Claude Fable 5
parent 57d68c9355
commit 1209e1c2d9
6 changed files with 108 additions and 9 deletions
+18 -3
View File
@@ -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}."
),
}
+5
View File
@@ -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),
}
+6 -3
View File
@@ -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
+24 -2
View File
@@ -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).