Files
ducoterra d3f8c7f2c8
Build and Push Containers / build-and-push-app (push) Successful in 2m34s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 135_git_source_branch
All verification complete. No edits were needed by this pass — the phase work and docs were already consistent and green.

**Phase 135 final verification pass — report**

- Verified all 5 tasks' work present & correct: migration 0024 (reversible), `GitSource.branch` + 4 schema shapes, `validate_branch_name` (full check order per design), `clone_or_pull(…, branch)` (HEAD guard first, `--branch` clone, fetch+ff merge), both call sites pass `row.branch`, UI (add-form field, editor field, row tag), `file://` URL transport.
- Completion criteria: (1) branch-mirrored through Sync + CLI — **pass** (E2E + integration); (2) `branch=None` byte-identical — **pass** (`test_branch_none_argv_is_byte_identical_pre_phase_sequence` green); (3) fixed 422s / local-row 422 / tri-state — **pass**; (4) mismatch fails loudly with remove-and-re-add — **pass** (E2E); (5) coverage 99% >90%, E2E isolated green — **pass**; (6) no regressions — **pass**.
- Tests: `uv run pytest --cov=app --cov-report=term-missing` → 3223 passed, app/ 99%; `uv run pytest tests/e2e/test_git_source_branch.py -v --no-cov` → 4 passed (16.4 s); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings.
- Documentation: README.md reviewed in full — phase-135 branch section accurate; the phase's README diff also correctly fixed a pre-existing stale line (shallow `--depth 1` clone vs full-history, phase 107). Routes, env defaults, feature lists verified against code. AGENTS.md — all 10 rules + quick-reference verified (referenced tests, PLAN.md, phases dirs, commands all accurate). **Verified consistent — no changes needed.**
- No live-infrastructure changes (dev DB already running and healthy; no live steps in this phase).
- Next pending phase: `.agents/phases/todo/136_force_push_reset`.
2026-09-28 21:15:03 -04:00

316 lines
13 KiB
Python

"""Integration: migration 0024 (git_sources.branch) schema contract
(phase 135, task 01).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0021.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0023 → 0024 step
explicitly so later migrations cannot break the pins:
* upgrade 0023 → 0024 → ``branch`` exists with the full contract —
TEXT, NULLABLE, no server default — while the 0023 ``git_sources``
schema (``url`` NOT NULL + the unique index, ``kind``, ``path``,
``ignore_paths``, ``include_hidden``, ``token``, ``added_at``)
survives;
* rows inserted while the DB is at 0023 backfill ``branch`` to NULL —
ALL of them (the byte-identical guarantee: a pre-phase-135 row is a
default-branch source by definition; no backfill, no default);
* the ORM contract agrees: a freshly inserted ``GitSource`` with an
explicit ``branch`` round-trips it through a fresh session, and a
row without one reads ``branch is None``;
* downgrade to 0023 → the column is GONE (A13) while the row + its
``url`` survive; upgrade back to 0024 → the column is back
(round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.orm import Session
from alembic import command
from app.db import SessionLocal, db_available
from app.models import GitSource
URL_A = "https://github.com/mig0024/repo-a.git"
URL_B = "https://github.com/mig0024/repo-b.git"
BRANCH = "release/1.0"
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown
upgrades to head no matter what happened, so the dev DB is never
left below head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
# Release the test session's open transaction BEFORE the repair
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
# on ``git_sources``, which would deadlock the repair's
# ``ALTER TABLE`` (0024) forever.
db.rollback()
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one git_sources
column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'git_sources' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _insert_sql(db: Session, url: str, token: str | None = None) -> uuid.UUID:
"""Insert one git_sources row with the PRE-0024 column set (the
0023 shape — the branch column, when present, is omitted so a NULL
backfill is what the row reads)."""
row_id = uuid.uuid4()
db.execute(
text(
"INSERT INTO git_sources (id, url, kind, path, ignore_paths,"
" include_hidden, token)"
" VALUES (:id, :u, 'git', NULL, '[]', false, :t)"
),
{"id": row_id, "u": url, "t": token},
)
db.commit()
return row_id
def _delete(db: Session, row_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM git_sources WHERE id = :id"), {"id": row_id})
db.commit()
def test_upgrade_to_0024_adds_branch(db: Session, alembic: Config) -> None:
"""Upgrade 0023 → 0024: ``branch`` exists with the full contract
(TEXT, NULLABLE, no server default — NULL = the repo's default
branch), is ABSENT at 0023, and every pre-0024 row (WITH and
WITHOUT a stored token) backfills ``branch`` to NULL — the
byte-identical guarantee, no backfill and no default — while the
0023 table contract (``url`` + unique index, ``kind``,
``ignore_paths``, ``include_hidden``, ``token``, ``added_at``)
survives."""
command.downgrade(alembic, "0023") # start from the pre-0024 state
assert _version(db) == "0023"
assert _column(db, "branch") is None, "branch must be absent at 0023"
pre_a = _insert_sql(db, URL_A) # the 0023 column set
pre_b = _insert_sql(db, URL_B, token="ghp_mig0024secret")
try:
command.upgrade(alembic, "0024")
assert _version(db) == "0024", "alembic_version must be at 0024"
branch = _column(db, "branch")
assert branch is not None, "git_sources.branch is missing"
assert branch[0] == "text", "branch must be TEXT"
assert branch[1] == "YES", "branch must be NULLABLE"
assert branch[2] is None, (
"branch must carry NO server default — NULL is the"
" default-branch value"
)
# Every pre-0024 row backfilled to NULL (the repo's default
# branch — the byte-identical guarantee for ALL existing rows).
for pre_id, url in ((pre_a, URL_A), (pre_b, URL_B)):
row = db.execute(
text("SELECT url, token, branch FROM git_sources WHERE id = :id"),
{"id": pre_id},
).fetchone()
assert row is not None and row[0] == url, (
"the pre-0024 row must survive the upgrade"
)
assert row[2] is None, (
"every pre-0024 row must backfill branch to NULL — no"
" backfill, no default"
)
# The tokened row kept its credential (the upgrade is
# branch-only).
assert db.execute(
text("SELECT token FROM git_sources WHERE id = :id"),
{"id": pre_b},
).scalar() == "ghp_mig0024secret", (
"a pre-0024 stored token must survive the branch upgrade"
)
# A row written without the column stays NULL (no default to
# fill it in — the Python-side default is None, same value).
new_id = _insert_sql(db, URL_A.replace("repo-a", "repo-c"))
try:
omitted = db.execute(
text("SELECT branch FROM git_sources WHERE id = :id"),
{"id": new_id},
).scalar()
assert omitted is None, "an omitted branch must stay NULL"
finally:
_delete(db, new_id)
# The 0023 schema survives the additive upgrade.
url = _column(db, "url")
assert url is not None and url[0] == "text" and url[1] == "NO", (
"git_sources.url (0006) must keep its 0023 contract"
)
kind = _column(db, "kind")
assert kind is not None and kind[0] == "text" and kind[1] == "NO", (
"git_sources.kind (0007) must survive the upgrade"
)
ignore = _column(db, "ignore_paths")
assert ignore is not None and ignore[0] == "jsonb" and ignore[1] == "NO", (
"git_sources.ignore_paths (0013) must survive the upgrade"
)
hidden = _column(db, "include_hidden")
assert hidden is not None and hidden[0] == "boolean" and hidden[1] == "NO", (
"git_sources.include_hidden (0019) must survive the upgrade"
)
token = _column(db, "token")
assert token is not None and token[0] == "text" and token[1] == "YES", (
"git_sources.token (0021) must survive the upgrade"
)
added = _column(db, "added_at")
assert added is not None and added[0] == "timestamp with time zone", (
"git_sources.added_at (0006) must survive the upgrade"
)
assert added[1] == "NO" and "now()" in str(added[2]), (
"git_sources.added_at must keep its `now()` server default"
)
index = db.execute(
text(
"SELECT indexname FROM pg_indexes"
" WHERE tablename = 'git_sources'"
" AND indexname = 'uq_git_sources_url'"
)
).scalar()
assert index is not None, (
"the uq_git_sources_url unique index must survive the upgrade"
)
finally:
_delete(db, pre_a)
_delete(db, pre_b)
def test_orm_branch_round_trips(db: Session, alembic: Config) -> None:
"""The ORM contract agrees with the column contract: a freshly
inserted ``GitSource`` with an explicit ``branch`` round-trips the
value through a FRESH session, and a row without one reads
``branch is None`` (the NULL default-branch state)."""
command.upgrade(alembic, "head")
row_branched = GitSource(url=URL_A, kind="git", branch=BRANCH)
row_default = GitSource(url=URL_B, kind="git")
db.add(row_branched)
db.add(row_default)
db.commit()
try:
with SessionLocal() as fresh:
reloaded_branched = fresh.get(GitSource, row_branched.id)
assert reloaded_branched is not None, "the branched row must be readable"
assert reloaded_branched.branch == BRANCH, (
"the explicit branch must round-trip through the DB"
)
reloaded_default = fresh.get(GitSource, row_default.id)
assert reloaded_default is not None, "the default row must be readable"
assert reloaded_default.branch is None, (
"a row without a branch must read branch is None"
)
finally:
_delete(db, row_branched.id)
_delete(db, row_default.id)
def test_downgrade_to_0023_drops_the_column(db: Session, alembic: Config) -> None:
"""Downgrade 0024 → 0023: the branch column is gone (A13 — fully
reversible) while the row + its ``url`` (and ``token``) survive,
and the rest of the 0023 table contract (``url`` unique index,
``kind``, ``added_at``) is intact."""
command.upgrade(alembic, "head")
row = GitSource(url=URL_A, kind="git", branch=BRANCH, token="ghp_mig0024secret")
db.add(row)
db.commit()
try:
command.downgrade(alembic, "0023")
assert _version(db) == "0023"
assert _column(db, "branch") is None, "branch must be dropped"
surviving = db.execute(
text(
"SELECT url, kind, ignore_paths, include_hidden, token, added_at"
" FROM git_sources WHERE id = :id"
),
{"id": row.id},
).fetchone()
assert surviving is not None and surviving[0] == URL_A, (
"the row must survive the column drop"
)
assert surviving[1] == "git" and surviving[2] == [] and surviving[3] is False, (
"kind + ignore_paths + include_hidden must survive the drop"
)
assert surviving[4] == "ghp_mig0024secret", "token must survive the drop"
assert surviving[5] is not None, "added_at must survive the drop"
index = db.execute(
text(
"SELECT indexname FROM pg_indexes"
" WHERE tablename = 'git_sources'"
" AND indexname = 'uq_git_sources_url'"
)
).scalar()
assert index is not None, "the unique index must survive the downgrade"
finally:
_delete(db, row.id)
# Repair: the fixture teardown re-upgrades to head.
def test_upgrade_round_trip_restores_the_column(db: Session, alembic: Config) -> None:
"""Downgrade to 0023, then upgrade back to 0024: ``branch`` is back
with the full contract (TEXT, NULLABLE, no server default), and a
row seeded at 0023 reads ``branch is None`` (the byte-identical
guarantee on the round-trip)."""
command.downgrade(alembic, "0023")
pre_id = _insert_sql(db, URL_B)
try:
command.upgrade(alembic, "0024")
assert _version(db) == "0024", "round-trip upgrade must land at 0024"
branch = _column(db, "branch")
assert branch is not None, "git_sources.branch must be back"
assert branch[0] == "text", "branch must be TEXT after the round-trip"
assert branch[1] == "YES", "branch must be NULLABLE after the round-trip"
assert branch[2] is None, (
"branch must still carry NO server default after the round-trip"
)
assert db.execute(
text("SELECT branch FROM git_sources WHERE id = :id"),
{"id": pre_id},
).scalar() is None, (
"a row seeded at 0023 must read branch NULL after the round-trip"
)
finally:
_delete(db, pre_id)