From a7111f0b1dd769ac23595c521947a8399964c120 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Thu, 13 Aug 2026 14:38:27 +0200 Subject: [PATCH] test: pin hybrid chaining when the hardcoded parent is not the head A hybrid whose ``down_revision`` points at a revision that is no longer the head is spliced in behind it, re-parenting the revisions in between. That keeps the chain linear and single-headed, which is the property this library exists to provide, but nothing pinned it -- so a future change could turn it into a fork without a test noticing. Two cases, both intended: * the parent was already stale when the file was written (an authoring error: the rule is to hardcode only onto the current head) * the parent was the head when the file was written, and another branch merged first (prevented upstream by the merge queue, which tests the branch against the rebased base) The second case is driven through real branches and real merge commits rather than a hand-written git order, so it also covers ``_get_git_commit_order`` producing the order that ``test_dynamic_inserted_before_hybrid_no_multiple_heads`` assumes. Both walk the reconstructed chain, so they fail loudly on a fork rather than only on a changed dict. Refs #47 Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I20e5d314043a3d8da985e394c84d1008dfc82862 Claude-Session-Id: 934c0f4e-8a8c-425a-826e-ed4ae67d6d2d --- tests/test_chain.py | 143 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tests/test_chain.py b/tests/test_chain.py index 793b159..7822c30 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -1359,3 +1359,146 @@ 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 test_hybrid_behind_the_head_is_spliced_in_and_keeps_one_head( + tmp_path: pathlib.Path, +) -> None: + """A hybrid may hardcode a revision that is no longer the head. + + ``eeee`` points at ``bbbb`` when the chain has already reached + ``dddd``. It is placed immediately after ``bbbb`` and the revisions + in between are re-parented onto it, so the chain stays linear and + single-headed -- the property this library exists to guarantee. + + This is intended, not incidental. Pointing a migration at a revision + a database has already passed is an authoring error; the rule is to + hardcode only onto the current head, and enforcing it belongs in the + merge queue rather than here (see #47). The test pins the chain + shape so a later change cannot quietly turn it into a fork. + """ + 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")): + (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", + ) + # Added last, but hardcoded three revisions behind the head. + (versions_dir / "eeee_manual.py").write_text( + 'revision = "eeee"\ndown_revision = "bbbb"\n', + ) + + git_order = [ + "aaaa_root.py", + "bbbb_one.py", + "cccc_two.py", + "dddd_three.py", + "eeee_manual.py", + ] + + with mock.patch.object( + _chain, + "_get_git_commit_order", + return_value=git_order, + ): + chain = _chain._build_chain_from_git(versions_dir) + files = _chain._parse_migration_files(versions_dir, git_order) + + # cccc follows the spliced-in hybrid, not bbbb as it did before eeee + # existed. dddd is untouched: only the revision directly after the + # hybrid's target changes parent. + assert chain == {"bbbb": "aaaa", "cccc": "eeee", "dddd": "cccc"} + + full = { + f.revision: (f.static_down_revisions[0] if f.static_down_revisions else None) + for f in files + if not f.is_dynamic + } + full.update(chain) + # No fork: _walk_chain fails loudly if two revisions share a parent. + assert _walk_chain(full) == ["aaaa", "bbbb", "eeee", "cccc", "dddd"] + + +def test_concurrent_branches_keep_one_head_when_the_hybrid_merges_second( + tmp_path: pathlib.Path, +) -> None: + """Two branches fork from the same head and the hybrid lands last. + + Branch A adds a generated migration, branch B a hand-written one + hardcoded onto the head both forked from. A merges first, so the + generated migration precedes the hybrid in git order and both would + otherwise point at ``bbbb`` -- a fork, and ``MultipleHeads``. The + hybrid is spliced in behind the generated migration instead, keeping + a single head. + + ``test_dynamic_inserted_before_hybrid_no_multiple_heads`` pins the + same chain shape from a hardcoded git order. This one drives real + branches and real merge commits, so it also covers + ``_get_git_commit_order`` actually producing the order that test + assumes. + """ + repo = tmp_path / "repo" + versions = repo / "versions" + versions.mkdir(parents=True) + + _git(repo, "init", "-b", "main") + _git(repo, "config", "user.email", "test@test.com") + _git(repo, "config", "user.name", "Test") + + def add(filename: str, body: str) -> None: + (versions / filename).write_text(body) + _git(repo, "add", ".") + _git(repo, "commit", "-m", f"add {filename}") + + def dynamic(revision: str) -> str: + return ( + "from alembic_git_revisions import get_down_revision\n" + f'revision = "{revision}"\n' + "down_revision = get_down_revision(revision)\n" + ) + + add("aaaa_root.py", 'revision = "aaaa"\ndown_revision = None\n') + add("bbbb_base.py", dynamic("bbbb")) # the head both branches fork from + + # Branch B: hand-written, hardcoded onto the head it can see. + _git(repo, "checkout", "-b", "branch-b") + add("dddd_manual.py", 'revision = "dddd"\ndown_revision = "bbbb"\n') + + # Branch A: an ordinary generated migration, merged first. + _git(repo, "checkout", "main") + _git(repo, "checkout", "-b", "branch-a") + add("cccc_generated.py", dynamic("cccc")) + _git(repo, "checkout", "main") + _git(repo, "merge", "--no-ff", "-m", "Merge branch-a", "branch-a") + + assert _chain._build_chain_from_git(versions) == {"bbbb": "aaaa", "cccc": "bbbb"} + + _git(repo, "merge", "--no-ff", "-m", "Merge branch-b", "branch-b") + + git_order = _chain._get_git_commit_order(versions) + assert git_order == [ + "aaaa_root.py", + "bbbb_base.py", + "cccc_generated.py", + "dddd_manual.py", + ] + + chain = _chain._build_chain_from_git(versions) + # cccc moves off bbbb onto the hybrid, so bbbb has exactly one child. + assert chain == {"bbbb": "aaaa", "cccc": "dddd"} + + assert git_order is not None + files = _chain._parse_migration_files(versions, git_order) + full = { + f.revision: (f.static_down_revisions[0] if f.static_down_revisions else None) + for f in files + if not f.is_dynamic + } + full.update(chain) + assert _walk_chain(full) == ["aaaa", "bbbb", "dddd", "cccc"]