Skip to content

Commit

Permalink
Make rebase-migration abort if the migration has been applied locally
Browse files Browse the repository at this point in the history
For #10.
  • Loading branch information
adamchainz committed Dec 17, 2020
1 parent 16a1499 commit 8e145b0
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 7 deletions.
3 changes: 3 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
History
=======

* Made ``rebase-migration`` abort if the migration to be rebased has been
applied in any local database.

1.2.1 (2020-12-15)
------------------

Expand Down
38 changes: 31 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,37 @@ Following a conflicted “rebase” operation in your source control tool, run i
$ python manage.py rebase-migration <app_label>
Note rebasing the migration might not always be the *correct* thing to do.
If the migrations in main and feature branches have both affected the same models, rebasing the migration on the end may not make sense.
However, such parallel changes would *normally* cause conflicts in your models files or other parts of the source code as well.

Let's walk through an example using Git, although it should extend to other source control tools.

Imagine you were working on your project's ``books`` app in a feature branch and created a migration called ``0002_longer_titles``.
Imagine you were working on your project's ``books`` app in a feature branch called ``titles`` and created a migration called ``0002_longer_titles``.
Meanwhile a commit has been merged to your ``main`` branch with a *different* 2nd migration for ``books`` called ``0002_author_nicknames``.
Thanks to django-linear-migrations, the ``max_migration.txt`` file will show as conflicted between your feature and main branches.

You can start to fix the conflict by pulling your latest ``main`` branch, then rebasing your ``titles`` branch on top of it.
When you do this, Git will report the conflict:
You start the fix by reversing your new migration from your local database.
This is necessary since it will be renamed after rebasing and seen as unapplied.
You do this by switching to the feature branch ``titles`` migrating back to the last common migration:

.. code-block:: console
$ git switch titles
$ python manage.py migrate books 0001
You then fetch the latest code:

.. code-block:: console
$ git switch main
$ git pull
...
You then rebase your ``titles`` branch on top of it, for which Git will detect the conflict on ``max_migration.txt``:

.. code-block:: console
$ git switch titles
$ git rebase main
Auto-merging books/models.py
Expand Down Expand Up @@ -149,7 +166,7 @@ It's at this point you can use ``rebase-migration`` to automatically fix the ``b
$ python manage.py rebease-migration books
Renamed 0002_longer_titles.py to 0003_longer_titles.py, updated its dependencies, and updated max_migration.txt.
This places the conflcited migration on the end of the migration history.
This places the conflicted migration on the end of the migration history.
It renames the file appropriately, modifies its ``dependencies = [...]`` declaration, and updates the migration named in ``max_migration.txt`` appropriately.

After this, you should be able to continue the rebase:
Expand All @@ -159,9 +176,16 @@ After this, you should be able to continue the rebase:
$ git add books/migrations
$ git rebase --continue
Note this might not always be the *correct* thing to do.
If the migrations in main and feature branches have both affected the same models, rebasing the migration on the end may not make sense.
However, such parallel changes would *normally* cause conflicts in other parts of the source code as well, such as in the models.
And the migrate your local database to allow you to continue development:

.. code-block:: console
$ python manage.py migrate books
Operations to perform:
Target specific migration: 0003_longer_titles, from books
Running migrations:
Applying books.0002_author_nicknames... OK
Applying books.0003_longer_titles... OK
Inspiration
===========
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from django.apps import apps
from django.core.management import BaseCommand, CommandError
from django.db import DatabaseError, connections
from django.db.migrations.recorder import MigrationRecorder

from django_linear_migrations.apps import MigrationDetails, is_first_party_app_config

Expand Down Expand Up @@ -64,6 +66,13 @@ def handle(self, app_label, **options):
+ " migration filename, but it does not exist."
)

if migration_applied(app_label, rebased_migration_name):
raise CommandError(
f"Detected {rebased_migration_name} as the rebased migration,"
+ " but it is applied to the local database. Undo the rebase,"
+ " reverse the migration, and try again."
)

content = rebased_migration_path.read_text()
split_result = re.split(
r"(?<=dependencies = )(\[.*?\])",
Expand Down Expand Up @@ -127,3 +136,19 @@ def find_migration_names(max_migration_lines):
if not lines[-1].startswith(">>>>>>>"):
return None
return lines[1].strip(), lines[-2].strip()


def migration_applied(app_label, migration_name):
Migration = MigrationRecorder.Migration
for alias in connections:
try:
if (
Migration.objects.using(alias)
.filter(app=app_label, name=migration_name)
.exists()
):
return True
except DatabaseError:
# django_migrations table does not exist -> no migrations applied
pass
return False
41 changes: 41 additions & 0 deletions tests/test_rebase_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import pytest
from django.core.management import CommandError, call_command
from django.db import connection
from django.db.migrations.recorder import MigrationRecorder
from django.test import SimpleTestCase, TestCase, override_settings

module = import_module("django_linear_migrations.management.commands.rebase-migration")
Expand Down Expand Up @@ -140,6 +142,35 @@ def test_error_for_non_existent_rebased_migration_file(self):
+ " filename, but it does not exist."
)

def test_error_for_applied_migration(self):
(self.migrations_dir / "__init__.py").touch()
(self.migrations_dir / "0001_initial.py").touch()
(self.migrations_dir / "0002_author_nicknames.py").touch()
(self.migrations_dir / "0002_longer_titles.py").touch()
(self.migrations_dir / "max_migration.txt").write_text(
dedent(
"""\
<<<<<<< HEAD
0002_author_nicknames
=======
0002_longer_titles
>>>>>>> 123456789 (Increase Book title length)
"""
)
)
MigrationRecorder.Migration.objects.create(
app="testapp", name="0002_longer_titles"
)

with pytest.raises(CommandError) as excinfo:
self.call_command("testapp")

assert excinfo.value.args[0] == (
"Detected 0002_longer_titles as the rebased migration, but it is"
+ " applied to the local database. Undo the rebase, reverse the"
+ " migration, and try again."
)

def test_error_for_missing_dependencies(self):
(self.migrations_dir / "__init__.py").touch()
(self.migrations_dir / "0001_initial.py").touch()
Expand Down Expand Up @@ -339,3 +370,13 @@ def test_works_with_three_way_merge(self):
]
)
assert result == ("0002_author_nicknames", "0002_longer_titles")


class MigrationAppliedTests(TestCase):
def test_table_does_not_exist(self):
with connection.cursor() as cursor:
cursor.execute("DROP TABLE django_migrations")

result = module.migration_applied("testapp", "0001_initial")

assert result is False

0 comments on commit 8e145b0

Please sign in to comment.