diff --git a/README.md b/README.md index 0173e0e..ac53b25 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Instead of hardcoding `down_revision`, this library determines the migration cha This means: - New migrations never conflict with each other - The chain is always linear, regardless of branch merge order -- Existing migrations with hardcoded `down_revision` continue to work +- Existing migrations with hardcoded `down_revision` continue to work, as long as they are not chained behind a revision that has already been applied somewhere (see [Hardcoded `down_revision` on a deployed database](#hardcoded-down_revision-on-a-deployed-database)) ## Installation @@ -91,6 +91,49 @@ The library handles three types of migrations: Classification reads the `revision` and `down_revision` attributes from each migration module (the same values Alembic loads), so any Alembic `file_template` and any `rev_id` format work. +## Hardcoded `down_revision` on a deployed database + +A hybrid is placed immediately after the revision it points at, so any dynamic migration added between the two is re-parented onto the hybrid. That is what keeps the chain linear when two branches fork from the same head, and it is safe while none of those migrations has run yet. + +It stops being safe once one of them has been applied. Say `bbbb -> cccc -> dddd` are already deployed, and a new migration hardcodes `down_revision = "bbbb"`: + +``` +chain before: aaaa -> bbbb -> cccc -> dddd alembic_version = dddd +chain after: aaaa -> bbbb -> eeee -> cccc -> dddd alembic_version = dddd +``` + +`eeee` now sits behind the recorded head. `alembic upgrade head` walks down from `dddd`, finds every revision already applied, and does nothing. The migration never runs, and nothing reports an error. + +**The rule: only hardcode `down_revision` onto the current head.** Anything older is spliced into history the database has already walked past. Prefer `get_down_revision(revision)`, which cannot pick a stale parent. + +To check an existing tree: + +```bash +alembic-git-revisions --check /path/to/versions +``` + +This lists every hybrid chained ahead of earlier revisions and **exits 0**, because git history alone cannot prove a finding is a real problem. A hybrid added by a branch that forked from the same head produces exactly the same shape, and that case is benign. Treat the output as something to look at, not as a failure. + +Only the revisions a database has actually applied separate the two. Pass them in to narrow the report to migrations that provably cannot run, which **exits non-zero**: + +```bash +psql -Atc 'select version_num from alembic_version' \ + | alembic-git-revisions --check --applied - /path/to/versions +``` + +That is the form worth gating CI on. Note `alembic_version` records only the current head, so for a full picture supply every revision reachable from it. The same distinction is available from Python: + +```python +from alembic_git_revisions import find_displaced_revisions + +# advisory: includes the benign fork-from-the-same-head case +candidates = find_displaced_revisions(versions_dir) + +# confirmed: only what cannot run on this database +for found in find_displaced_revisions(versions_dir, applied=applied): + print(f"{found.hybrid} will never run on this database") +``` + ## API ### `get_down_revision(revision, versions_dir=None)` @@ -127,6 +170,10 @@ A frozen dataclass describing one parsed migration: `git_sequence` is a position within one particular parse, not a stable property of the file. Files absent from git history all share the same end-of-list sentinel and are separated only by `filename`, which is why `parse_versions_dir` sorts on both. +### `find_displaced_revisions(versions_dir, applied=None)` + +Returns a list of `DisplacedRevision(hybrid, target, displaced)`, one per hybrid that is chained ahead of revisions added before it. Without `applied` the result is advisory and includes the benign fork-from-the-same-head case. Pass `applied`, the revisions a database has actually run, to keep only the hybrids that can never execute on it. See [Hardcoded `down_revision` on a deployed database](#hardcoded-down_revision-on-a-deployed-database). + ### `CHAIN_FILENAME` Name of the generated chain file, `revision_chain.json`. Use it instead of hardcoding the string when locating or cleaning up the generated artifact. diff --git a/alembic_git_revisions/__init__.py b/alembic_git_revisions/__init__.py index 9eb5c24..7e248ee 100644 --- a/alembic_git_revisions/__init__.py +++ b/alembic_git_revisions/__init__.py @@ -17,18 +17,25 @@ from __future__ import annotations +import argparse import pathlib import sys from alembic_git_revisions._chain import ( CHAIN_FILENAME as CHAIN_FILENAME, ) +from alembic_git_revisions._chain import ( + DisplacedRevision as DisplacedRevision, +) from alembic_git_revisions._chain import ( MigrationFile as MigrationFile, ) from alembic_git_revisions._chain import ( build_chain as build_chain, ) +from alembic_git_revisions._chain import ( + find_displaced_revisions as find_displaced_revisions, +) from alembic_git_revisions._chain import ( generate_chain_file as generate_chain_file, ) @@ -40,13 +47,123 @@ ) -def _cli() -> None: - """CLI entry point: generate revision_chain.json.""" - if len(sys.argv) != 2: # noqa: PLR2004 +def _read_applied(source: str) -> set[str]: + """Read applied revision ids, one per line, from a file or stdin (``-``). + + Blank lines and ``#`` comments are ignored, so the output of a query + against ``alembic_version`` can be piped in directly. + """ + text = ( + sys.stdin.read() + if source == "-" + else pathlib.Path(source).read_text(encoding="utf-8") + ) + return { + stripped + for line in text.splitlines() + if (stripped := line.strip()) and not stripped.startswith("#") + } + + +def _report_candidates(displaced: list[DisplacedRevision]) -> int: + """Report hybrids that *may* be unreachable. Always exit code 0. + + Without the set of applied revisions nothing here is known to be wrong: + two branches forking from the same head produce this shape legitimately. + Reporting a non-zero status would fail that ordinary workflow. + """ + if not displaced: + print("No hybrid migrations are chained ahead of earlier revisions.") # noqa: T201 + return 0 + + for revision in displaced: + following = ", ".join(revision.displaced) print( # noqa: T201 - f"Usage: {sys.argv[0]} ", + f"{revision.hybrid}: hardcodes down_revision={revision.target!r}; " + f"{len(revision.displaced)} later revision(s) now chain after it: " + f"{following}\n" + f" If any of those is already applied on a database, " + f"{revision.hybrid} sits behind that database's head and will not " + f"run there. Git history alone cannot tell: two branches forking " + f"from the same head produce this shape legitimately. Pass " + f"--applied to decide.", + ) + return 0 + + +def _report_confirmed(displaced: list[DisplacedRevision], applied: set[str]) -> int: + """Report hybrids that cannot run on the described database. + + Exits non-zero, because every finding here is backed by a revision the + database has actually applied. + """ + if not displaced: + print("No migration is chained behind an applied revision.") # noqa: T201 + return 0 + + for revision in displaced: + blocking = ", ".join(r for r in revision.displaced if r in applied) + print( # noqa: T201 + f"{revision.hybrid}: hardcodes down_revision={revision.target!r}, " + f"but {blocking} is already applied and now chains after it.\n" + f" {revision.hybrid} sits behind this database's head, so " + f"'alembic upgrade head' will never run it.", file=sys.stderr, ) - sys.exit(1) + return 1 + - generate_chain_file(pathlib.Path(sys.argv[1])) +def _cli() -> None: + """CLI entry point: generate revision_chain.json, or check the chain.""" + parser = argparse.ArgumentParser( + prog="alembic-git-revisions", + description=( + "Generate revision_chain.json from git history, or with --check " + "report hybrid migrations chained ahead of earlier revisions." + ), + ) + parser.add_argument( + "versions_dir", + metavar="versions-directory", + type=pathlib.Path, + help="Alembic versions directory.", + ) + parser.add_argument( + "--check", + action="store_true", + help=( + "Report hybrid migrations chained ahead of revisions added " + "before them, instead of generating the chain file. Exits 0: " + "git history alone cannot prove a finding is a real problem." + ), + ) + parser.add_argument( + "--applied", + metavar="FILE", + help=( + "With --check, a file of applied revision ids (one per line, " + "'-' for stdin) as recorded by the target database. Narrows the " + "report to migrations that provably cannot run, and exits " + "non-zero if there are any." + ), + ) + args = parser.parse_args() + + if args.applied is not None and not args.check: + parser.error("--applied requires --check") + + # Both paths fail the same way when git is missing or shallow, and an + # unreadable --applied file is an ordinary user error. Report all of + # them as a message rather than a traceback. + try: + if args.check: + applied = _read_applied(args.applied) if args.applied is not None else None + displaced = find_displaced_revisions(args.versions_dir, applied=applied) + if applied is None: + sys.exit(_report_candidates(displaced)) + sys.exit(_report_confirmed(displaced, applied)) + + generate_chain_file(args.versions_dir) + except (RuntimeError, OSError) as exc: + print(f"{parser.prog}: error: {exc}", file=sys.stderr) # noqa: T201 + sys.exit(1) diff --git a/alembic_git_revisions/_chain.py b/alembic_git_revisions/_chain.py index a1d55f0..8ecc1d6 100644 --- a/alembic_git_revisions/_chain.py +++ b/alembic_git_revisions/_chain.py @@ -9,6 +9,12 @@ import re import subprocess +# Imported at runtime rather than under TYPE_CHECKING: this annotates public +# API, and ``from __future__ import annotations`` turns annotations into +# strings, so the name has to stay in module globals for +# ``typing.get_type_hints()`` to resolve it. +from collections.abc import Collection # noqa: TC003 + _REVISION_FROM_FILENAME_RE = re.compile(r"^([a-f0-9]+)_") CHAIN_FILENAME = "revision_chain.json" @@ -531,3 +537,80 @@ def generate_chain_file(versions_dir: pathlib.Path) -> None: json.dump(chain, f, indent=2, sort_keys=True) f.write("\n") print(f"Generated {chain_file} with {len(chain)} revisions") # noqa: T201 + + +@dataclasses.dataclass(frozen=True) +class DisplacedRevision: + """A hybrid migration chained ahead of revisions that predate it. + + ``hybrid`` hardcodes ``target`` as its ``down_revision``. Because a + hybrid is placed immediately after its target, every revision in + ``displaced`` -- added to git after the target but before the hybrid -- + is re-parented onto the hybrid instead of staying where it was. + + That is intended while none of them has run anywhere: it is what keeps + the chain linear when two branches fork from the same head. It is + destructive once any of them has been applied. A database whose + ``alembic_version`` records a displaced revision has already walked past + the point where the hybrid now sits, so ``alembic upgrade head`` finds + nothing to do and the hybrid never runs. + + Git history cannot separate the two cases; only the set of applied + revisions can. Pass one to :func:`find_displaced_revisions` to narrow + candidates down to the hybrids that are genuinely unreachable. + """ + + hybrid: str + target: str + displaced: tuple[str, ...] + + +def _displaced_revisions(files: list[MigrationFile]) -> list[DisplacedRevision]: + """Find every hybrid that displaces revisions added before it.""" + dynamic_revisions = {f.revision for f in files if f.is_dynamic} + sequence = {f.revision: f.git_sequence for f in files} + + found: list[DisplacedRevision] = [] + for hybrid in files: + if hybrid.is_dynamic: + continue + for target in hybrid.static_down_revisions: + if target not in dynamic_revisions: + continue + target_sequence = sequence[target] + displaced = tuple( + f.revision + for f in files + if f.is_dynamic + and target_sequence < f.git_sequence < hybrid.git_sequence + ) + if displaced: + found.append( + DisplacedRevision(hybrid.revision, target, displaced), + ) + return found + + +def find_displaced_revisions( + versions_dir: pathlib.Path, + applied: Collection[str] | None = None, +) -> list[DisplacedRevision]: + """Report hybrids chained ahead of revisions that were added before them. + + Without *applied* the result is advisory: it lists every hybrid whose + target is not the newest revision preceding it, which includes the benign + case of two branches forking from the same head. + + Pass *applied* -- the revisions a database has actually run, e.g. read + from ``alembic_version`` or an equivalent record -- to keep only the + findings that matter. A hybrid displacing an already-applied revision + sits behind that database's head and will never be executed. + """ + found = _displaced_revisions(parse_versions_dir(versions_dir)) + if applied is None: + return found + return [ + revision + for revision in found + if any(displaced in applied for displaced in revision.displaced) + ] diff --git a/tests/test_chain.py b/tests/test_chain.py index 793b159..c1f9a1d 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -4,10 +4,13 @@ import os import pathlib import subprocess +import sys +import typing from unittest import mock import pytest +import alembic_git_revisions from alembic_git_revisions import _chain @@ -1359,3 +1362,428 @@ def test_chain_filename_is_the_generated_file(tmp_path: pathlib.Path) -> None: _chain.generate_chain_file(versions_dir) assert (versions_dir.parent / _chain.CHAIN_FILENAME).is_file() + + +def _write_splice_scenario(versions_dir: pathlib.Path, hybrid_target: str) -> list[str]: + """Root, three dynamic migrations, then a hybrid pointing at *hybrid_target*. + + ``bbbb -> cccc -> dddd`` are dynamic and chain in that order; ``eeee`` is + added last with a hardcoded ``down_revision``. Returns the git order. + """ + (versions_dir / "aaaa_root.py").write_text( + 'revision = "aaaa"\ndown_revision = None\n', + ) + for revision, name in (("bbbb", "one"), ("cccc", "two"), ("dddd", "three")): + (versions_dir / f"{revision}_{name}.py").write_text( + "from alembic_git_revisions import get_down_revision\n" + f'revision = "{revision}"\n' + "down_revision = get_down_revision(revision)\n", + ) + (versions_dir / "eeee_manual.py").write_text( + f'revision = "eeee"\ndown_revision = "{hybrid_target}"\n', + ) + return [ + "aaaa_root.py", + "bbbb_one.py", + "cccc_two.py", + "dddd_three.py", + "eeee_manual.py", + ] + + +def test_find_displaced_revisions_reports_stale_target( + tmp_path: pathlib.Path, +) -> None: + """A hybrid pointing behind the head displaces the revisions in between.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + git_order = _write_splice_scenario(versions_dir, hybrid_target="bbbb") + + with mock.patch.object( + _chain, + "_get_git_commit_order", + return_value=git_order, + ): + displaced = _chain.find_displaced_revisions(versions_dir) + chain = _chain._build_chain_from_git(versions_dir) + + assert displaced == [ + _chain.DisplacedRevision( + hybrid="eeee", + target="bbbb", + displaced=("cccc", "dddd"), + ), + ] + # What the report describes is what the chain actually does: cccc no + # longer follows bbbb, it follows the hybrid inserted behind it. + assert chain["cccc"] == "eeee" + + +def test_find_displaced_revisions_ignores_hybrid_on_head( + tmp_path: pathlib.Path, +) -> None: + """Hardcoding the newest revision displaces nothing.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + git_order = _write_splice_scenario(versions_dir, hybrid_target="dddd") + + with mock.patch.object( + _chain, + "_get_git_commit_order", + return_value=git_order, + ): + assert _chain.find_displaced_revisions(versions_dir) == [] + + +@pytest.mark.parametrize( + ("applied", "expected_hybrids"), + [ + pytest.param( + {"aaaa", "bbbb", "cccc", "dddd"}, + ["eeee"], + id="displaced-applied", + ), + pytest.param({"aaaa", "bbbb"}, [], id="displaced-not-applied"), + pytest.param(set(), [], id="nothing-applied"), + ], +) +def test_find_displaced_revisions_filters_by_applied( + tmp_path: pathlib.Path, + applied: set[str], + expected_hybrids: list[str], +) -> None: + """Only a hybrid displacing an already-applied revision is unreachable.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + git_order = _write_splice_scenario(versions_dir, hybrid_target="bbbb") + + with mock.patch.object( + _chain, + "_get_git_commit_order", + return_value=git_order, + ): + found = _chain.find_displaced_revisions(versions_dir, applied=applied) + + assert [revision.hybrid for revision in found] == expected_hybrids + + +def test_find_displaced_revisions_reads_every_merge_parent( + tmp_path: pathlib.Path, +) -> None: + """A merge migration is checked through each of its hardcoded parents.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + + (versions_dir / "aaaa_root.py").write_text( + 'revision = "aaaa"\ndown_revision = None\n', + ) + for revision, name in (("bbbb", "one"), ("cccc", "two")): + (versions_dir / f"{revision}_{name}.py").write_text( + "from alembic_git_revisions import get_down_revision\n" + f'revision = "{revision}"\n' + "down_revision = get_down_revision(revision)\n", + ) + (versions_dir / "dddd_merge.py").write_text( + 'revision = "dddd"\ndown_revision = ("aaaa", "bbbb")\n', + ) + + git_order = ["aaaa_root.py", "bbbb_one.py", "cccc_two.py", "dddd_merge.py"] + + with mock.patch.object( + _chain, + "_get_git_commit_order", + return_value=git_order, + ): + displaced = _chain.find_displaced_revisions(versions_dir) + + assert displaced == [ + _chain.DisplacedRevision(hybrid="dddd", target="bbbb", displaced=("cccc",)), + ] + + +def test_find_displaced_revisions_without_git(tmp_path: pathlib.Path) -> None: + """Ordering cannot be guessed, so a missing git history is an error.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + + with ( + mock.patch.object(_chain, "_get_git_commit_order", return_value=None), + pytest.raises(RuntimeError, match="Cannot read git history"), + ): + _chain.find_displaced_revisions(versions_dir) + + +def test_find_displaced_revisions_reports_the_benign_fork_case( + tmp_path: pathlib.Path, +) -> None: + """Advisory mode cannot exclude a legitimate fork from the same head. + + This is the layout of ``test_dynamic_inserted_before_hybrid_no_multiple_heads``: + two branches fork from dynamic head ``bbbb``, one adds a dynamic migration + and the other a hybrid, and the dynamic one merges first. The resulting + chain is correct, yet in git history it is indistinguishable from a hybrid + hardcoded behind the head. Advisory mode therefore reports it, and only + ``applied`` can rule it out. + """ + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + + (versions_dir / "aaaa_root.py").write_text( + 'revision = "aaaa"\ndown_revision = None\n', + ) + for revision, name in (("bbbb", "base"), ("cccc", "branch_a")): + (versions_dir / f"{revision}_{name}.py").write_text( + "from alembic_git_revisions import get_down_revision\n" + f'revision = "{revision}"\n' + "down_revision = get_down_revision(revision)\n", + ) + (versions_dir / "dddd_branch_b.py").write_text( + 'revision = "dddd"\ndown_revision = "bbbb"\n', + ) + + git_order = [ + "aaaa_root.py", + "bbbb_base.py", + "cccc_branch_a.py", + "dddd_branch_b.py", + ] + + with mock.patch.object( + _chain, + "_get_git_commit_order", + return_value=git_order, + ): + candidates = _chain.find_displaced_revisions(versions_dir) + # cccc has not been applied anywhere, so nothing is confirmed. + confirmed = _chain.find_displaced_revisions( + versions_dir, + applied={"aaaa", "bbbb"}, + ) + + assert [c.hybrid for c in candidates] == ["dddd"] + assert confirmed == [] + + +def test_find_displaced_revisions_reports_each_problematic_merge_parent( + tmp_path: pathlib.Path, +) -> None: + """A merge migration displacing through two parents yields two findings.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + + (versions_dir / "aaaa_root.py").write_text( + 'revision = "aaaa"\ndown_revision = None\n', + ) + for revision, name in ( + ("bbbb", "one"), + ("cccc", "two"), + ("dddd", "three"), + ("eeee", "four"), + ): + (versions_dir / f"{revision}_{name}.py").write_text( + "from alembic_git_revisions import get_down_revision\n" + f'revision = "{revision}"\n' + "down_revision = get_down_revision(revision)\n", + ) + # Both hardcoded parents have later dynamic revisions after them. + (versions_dir / "ffff_merge.py").write_text( + 'revision = "ffff"\ndown_revision = ("bbbb", "cccc")\n', + ) + + git_order = [ + "aaaa_root.py", + "bbbb_one.py", + "cccc_two.py", + "dddd_three.py", + "eeee_four.py", + "ffff_merge.py", + ] + + with mock.patch.object( + _chain, + "_get_git_commit_order", + return_value=git_order, + ): + displaced = _chain.find_displaced_revisions(versions_dir) + + # One entry per problematic parent, both keyed by the same hybrid: a + # caller keying findings by `hybrid` alone would silently drop one. + assert displaced == [ + _chain.DisplacedRevision( + hybrid="ffff", + target="bbbb", + displaced=("cccc", "dddd", "eeee"), + ), + _chain.DisplacedRevision( + hybrid="ffff", + target="cccc", + displaced=("dddd", "eeee"), + ), + ] + + +_ARGPARSE_USAGE_ERROR = 2 + + +def _run_cli(argv: list[str], git_order: list[str]) -> int: + """Run the CLI with *argv*, returning its exit code.""" + with ( + mock.patch.object(_chain, "_get_git_commit_order", return_value=git_order), + mock.patch.object(sys, "argv", ["alembic-git-revisions", *argv]), + pytest.raises(SystemExit) as excinfo, + ): + alembic_git_revisions._cli() + code = excinfo.value.code + assert isinstance(code, int) + return code + + +def test_cli_check_without_applied_never_fails( + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Advisory mode reports, but must not fail a build on an unproven finding.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + git_order = _write_splice_scenario(versions_dir, hybrid_target="bbbb") + + assert _run_cli(["--check", str(versions_dir)], git_order) == 0 + + out = capsys.readouterr().out + assert "eeee" in out + # The wording must stay conditional: nothing has been proven wrong. + assert "Pass --applied to decide." in out + assert "will not run there" in out + + +def test_cli_check_with_applied_fails_and_names_the_blocker( + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Confirmed mode is backed by an applied revision, so it exits non-zero.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + git_order = _write_splice_scenario(versions_dir, hybrid_target="bbbb") + applied_file = tmp_path / "applied.txt" + applied_file.write_text("# current head and ancestors\naaaa\nbbbb\ncccc\n\n") + + exit_code = _run_cli( + ["--check", "--applied", str(applied_file), str(versions_dir)], + git_order, + ) + + assert exit_code == 1 + err = capsys.readouterr().err + assert "cccc is already applied" in err + assert "will never run it" in err + + +def test_cli_check_with_applied_is_clean_when_nothing_ran( + tmp_path: pathlib.Path, +) -> None: + """A displaced revision that no database applied is not a failure.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + git_order = _write_splice_scenario(versions_dir, hybrid_target="bbbb") + applied_file = tmp_path / "applied.txt" + applied_file.write_text("aaaa\nbbbb\n") + + exit_code = _run_cli( + ["--check", "--applied", str(applied_file), str(versions_dir)], + git_order, + ) + + assert exit_code == 0 + + +def test_cli_applied_requires_check(tmp_path: pathlib.Path) -> None: + """--applied is meaningless without --check, so it is rejected.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + applied_file = tmp_path / "applied.txt" + applied_file.write_text("aaaa\n") + + argv = ["--applied", str(applied_file), str(versions_dir)] + assert _run_cli(argv, []) == _ARGPARSE_USAGE_ERROR + + +@pytest.mark.parametrize( + "argv_extra", + [ + pytest.param([], id="generate"), + pytest.param(["--check"], id="check"), + ], +) +def test_cli_reports_missing_git_without_a_traceback( + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], + argv_extra: list[str], +) -> None: + """A shallow clone is an environment problem, not a crash.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + (versions_dir / "aaaa_root.py").write_text( + 'revision = "aaaa"\ndown_revision = None\n', + ) + + argv = ["alembic-git-revisions", *argv_extra, str(versions_dir)] + with ( + mock.patch.object(_chain, "_get_git_commit_order", return_value=None), + mock.patch.object(sys, "argv", argv), + pytest.raises(SystemExit) as excinfo, + ): + alembic_git_revisions._cli() + + assert excinfo.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("alembic-git-revisions: error: ") + assert "Traceback" not in err + + +def test_cli_reports_an_unreadable_applied_file( + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A bad --applied path is a message, not a traceback.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + git_order = _write_splice_scenario(versions_dir, hybrid_target="bbbb") + + argv = ["--check", "--applied", str(tmp_path / "nope.txt"), str(versions_dir)] + assert _run_cli(argv, git_order) == 1 + assert "error: " in capsys.readouterr().err + + +def test_public_annotations_resolve_at_runtime() -> None: + """Public signatures must survive typing.get_type_hints(). + + ``from __future__ import annotations`` makes annotations strings, so a + name imported only under TYPE_CHECKING would raise NameError here and + break any consumer that introspects the signature. + """ + hints = typing.get_type_hints(alembic_git_revisions.find_displaced_revisions) + + assert "applied" in hints + + +def test_cli_without_check_still_generates_the_chain_file( + tmp_path: pathlib.Path, +) -> None: + """The original positional form keeps working unchanged.""" + versions_dir = tmp_path / "versions" + versions_dir.mkdir() + git_order = _write_splice_scenario(versions_dir, hybrid_target="dddd") + + argv = ["alembic-git-revisions", str(versions_dir)] + with ( + mock.patch.object(_chain, "_get_git_commit_order", return_value=git_order), + mock.patch.object(sys, "argv", argv), + ): + alembic_git_revisions._cli() + + chain_file = versions_dir.parent / _chain.CHAIN_FILENAME + assert json.loads(chain_file.read_text()) == { + "bbbb": "aaaa", + "cccc": "bbbb", + "dddd": "cccc", + }