Skip to content

Commit f8091f6

Browse files
jdclaude
andauthored
fix: sort hybrid migrations after their target to prevent multiple heads (#14)
When two branches fork from the same dynamic head and one adds a dynamic migration while the other adds a hybrid (static with hardcoded down_revision), the first-merged dynamic and the later-merged hybrid both pointed to the same predecessor, creating a fork and MultipleHeads. Fix by sorting hybrids by their target's git_sequence instead of their own, placing them immediately after their target in the chain. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 40671d3 commit f8091f6

File tree

2 files changed

+85
-7
lines changed

2 files changed

+85
-7
lines changed

alembic_git_revisions/_chain.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -215,20 +215,40 @@ def _build_dynamic_chain(
215215
"""Build ``{revision: down_revision}`` for dynamic migrations.
216216
217217
Dynamic files are sorted by ``(git_sequence, filename)`` and chained
218-
linearly after *static_head*.
219-
220-
Hybrid files (static files whose ``down_revision`` points to a
221-
dynamic revision) participate in the ordering so that subsequent
222-
dynamic files chain after them, but they don't get entries in the
223-
returned dict since they already have a hardcoded ``down_revision``.
218+
linearly after *static_head*. Hybrid files (static files whose
219+
``down_revision`` points to a dynamic revision) are placed
220+
immediately after their target so that dynamic migrations added by
221+
concurrent branches chain after the hybrid, not to the same target
222+
(which would create a fork / multiple heads).
223+
224+
Hybrids don't get entries in the returned dict since they already
225+
have a hardcoded ``down_revision``.
224226
"""
225227
dynamic_revisions = {f.revision for f in files if f.is_dynamic}
226228

227229
dynamic_participants: list[MigrationFile] = [
228230
f for f in files if f.is_dynamic or f.static_down_revision in dynamic_revisions
229231
]
230232

231-
dynamic_participants.sort(key=lambda f: (f.git_sequence, f.filename))
233+
# O(1) lookup for each participant's git_sequence by revision.
234+
seq_by_rev = {f.revision: f.git_sequence for f in dynamic_participants}
235+
236+
# Map each hybrid's target revision to that target's git_sequence so
237+
# the hybrid sorts right after its target (not at its own commit time).
238+
target_seq = {
239+
f.static_down_revision: seq_by_rev.get(f.static_down_revision, f.git_sequence)
240+
for f in dynamic_participants
241+
if not f.is_dynamic and f.static_down_revision
242+
}
243+
244+
def _sort_key(f: MigrationFile) -> tuple[int, int, str]:
245+
if not f.is_dynamic and f.static_down_revision in target_seq:
246+
# Place hybrid right after its target (the 1 ensures it
247+
# sorts after the target itself at the same git_sequence).
248+
return (target_seq[f.static_down_revision], 1, f.filename)
249+
return (f.git_sequence, 0, f.filename)
250+
251+
dynamic_participants.sort(key=_sort_key)
232252

233253
chain: dict[str, str] = {}
234254
prev_revision = static_head

tests/test_chain.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,64 @@ def test_git_commit_order_with_relative_nested_path(
407407
assert result.index("aaaa_first.py") < result.index("bbbb_second.py")
408408

409409

410+
def test_dynamic_inserted_before_hybrid_no_multiple_heads(
411+
tmp_path: pathlib.Path,
412+
) -> None:
413+
"""A dynamic migration added before a hybrid must not create multiple heads.
414+
415+
Reproduces a production failure: two branches fork from the same dynamic
416+
head. Branch A adds a dynamic migration; branch B adds a static
417+
migration (hybrid) pointing to the same head. Branch A merges first,
418+
so the new dynamic migration appears *before* the hybrid in git order.
419+
420+
Without a fix, both the new dynamic migration and the hybrid point to
421+
the same predecessor, creating a fork and ``MultipleHeads``.
422+
"""
423+
versions_dir = tmp_path / "versions"
424+
versions_dir.mkdir()
425+
426+
# Static root
427+
(versions_dir / "aaaa_root.py").write_text(
428+
'revision = "aaaa"\ndown_revision = None\n',
429+
)
430+
# Dynamic migration (the previous head before the two branches)
431+
(versions_dir / "bbbb_dynamic.py").write_text(
432+
"from alembic_git_revisions import get_down_revision\n"
433+
'revision = "bbbb"\n'
434+
"down_revision = get_down_revision(revision)\n",
435+
)
436+
# Dynamic migration added on branch A (merged first)
437+
(versions_dir / "cccc_dynamic.py").write_text(
438+
"from alembic_git_revisions import get_down_revision\n"
439+
'revision = "cccc"\n'
440+
"down_revision = get_down_revision(revision)\n",
441+
)
442+
# Hybrid: static migration added on branch B, hardcoded to the old head.
443+
# Merged *after* branch A, so it appears last in git order.
444+
(versions_dir / "dddd_hybrid.py").write_text(
445+
'revision = "dddd"\ndown_revision = "bbbb"\n',
446+
)
447+
448+
git_order = [
449+
"aaaa_root.py",
450+
"bbbb_dynamic.py",
451+
"cccc_dynamic.py", # branch A merged first
452+
"dddd_hybrid.py", # branch B merged second
453+
]
454+
455+
with mock.patch.object(
456+
_chain,
457+
"_get_git_commit_order",
458+
return_value=git_order,
459+
):
460+
chain = _chain._build_chain_from_git(versions_dir)
461+
462+
# dddd hardcodes down_revision="bbbb", so it doesn't need a chain entry.
463+
# cccc must chain AFTER dddd (not to bbbb) to avoid multiple heads.
464+
# Correct chain: aaaa -> bbbb -> dddd(hybrid) -> cccc
465+
assert chain == {"bbbb": "aaaa", "cccc": "dddd"}
466+
467+
410468
def test_auto_discover_versions_dir(tmp_path: pathlib.Path) -> None:
411469
"""get_down_revision auto-discovers versions_dir from caller's location."""
412470
versions_dir = tmp_path / "versions"

0 commit comments

Comments
 (0)