Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions alembic_git_revisions/_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ def _get_git_commit_order(versions_dir: pathlib.Path) -> list[str] | None:
in the order they were first added, walking the linear commit tree
from oldest to newest.

The command runs without a pathspec so that it scans the full repo
history. This is necessary to preserve chronological ordering when
migrations are moved to a new directory: the original add commits
(in the old directory) come before the move commit, so each file's
first appearance in the log reflects its true creation order. Results
are filtered against the set of ``.py`` files that currently exist in
*versions_dir*, so unrelated files are excluded. Deduplication
(keep-first) ensures that a file moved across directories keeps the
ordering of its original add, not the later move.

``--no-renames`` is critical: without it, git's rename detection can
cause a renamed migration file (e.g. when changing its revision ID)
to be treated as a rename rather than an add. ``--diff-filter=A``
Expand All @@ -131,6 +141,8 @@ def _get_git_commit_order(versions_dir: pathlib.Path) -> list[str] | None:
if _is_shallow_clone(versions_dir):
return None

existing = {f.name for f in versions_dir.glob("*.py")}

try:
result = subprocess.run(
[
Expand All @@ -141,8 +153,6 @@ def _get_git_commit_order(versions_dir: pathlib.Path) -> list[str] | None:
"--no-renames",
"--format=",
"--name-only",
"--",
versions_dir.name + "/",
],
capture_output=True,
text=True,
Expand All @@ -159,7 +169,7 @@ def _get_git_commit_order(versions_dir: pathlib.Path) -> list[str] | None:
if not line or not line.endswith(".py"):
continue
fname = pathlib.Path(line).name
if fname not in order:
if fname in existing and fname not in order:
order.append(fname)

return order
Expand Down
107 changes: 107 additions & 0 deletions tests/test_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,113 @@ def test_renamed_migration_file_detected(
assert result.index("bbbb_renamed.py") < result.index("cccc_new.py")


def test_migrations_moved_to_new_directory(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Moving all migrations to a new directory must preserve git add order.

Reproduces a real scenario: migrations A, B, C are added in separate
commits to ``versions/``, then all moved to ``new_versions/`` in a
single commit.

With ``--diff-filter=A --no-renames``, git sees the move as deletes
from the old directory + adds to the new directory. All files appear
as added in the same (move) commit, so the original chronological
order is lost — git lists them alphabetically within a single commit.

Filenames are chosen so that alphabetical order (cccc, dddd, eeee)
differs from chronological add order (eeee, dddd, cccc), making the
bug visible.
"""
repo = tmp_path / "repo"
old_versions = repo / "versions"
old_versions.mkdir(parents=True)

subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=repo,
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=repo,
check=True,
capture_output=True,
)

# Add migrations in REVERSE alphabetical order so that
# chronological order ≠ alphabetical order.
# Commit 1: add migration eeee (alphabetically last)
(old_versions / "eeee_first.py").write_text(
"from alembic_git_revisions import get_down_revision\n"
'revision = "eeee"\n'
"down_revision = get_down_revision(revision)\n",
)
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "add eeee"],
cwd=repo,
check=True,
capture_output=True,
)

# Commit 2: add migration dddd
(old_versions / "dddd_second.py").write_text(
"from alembic_git_revisions import get_down_revision\n"
'revision = "dddd"\n'
"down_revision = get_down_revision(revision)\n",
)
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "add dddd"],
cwd=repo,
check=True,
capture_output=True,
)

# Commit 3: add migration cccc (alphabetically first)
(old_versions / "cccc_third.py").write_text(
"from alembic_git_revisions import get_down_revision\n"
'revision = "cccc"\n'
"down_revision = get_down_revision(revision)\n",
)
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "add cccc"],
cwd=repo,
check=True,
capture_output=True,
)

# Commit 4: move all migrations to a new directory
new_versions = repo / "new_versions"
new_versions.mkdir()
for f in old_versions.iterdir():
f.rename(new_versions / f.name)
old_versions.rmdir()
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "move migrations to new_versions"],
cwd=repo,
check=True,
capture_output=True,
)

monkeypatch.chdir(repo)
result = _chain._get_git_commit_order(pathlib.Path("new_versions"))

assert result is not None
assert "eeee_first.py" in result
assert "dddd_second.py" in result
assert "cccc_third.py" in result
# The critical assertion: order must be chronological (eeee, dddd, cccc),
# not alphabetical (cccc, dddd, eeee).
assert result == ["eeee_first.py", "dddd_second.py", "cccc_third.py"]


def test_auto_discover_versions_dir(tmp_path: pathlib.Path) -> None:
"""get_down_revision auto-discovers versions_dir from caller's location."""
versions_dir = tmp_path / "versions"
Expand Down
Loading