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`.
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user