diff --git a/HISTORY.rst b/HISTORY.rst index 4aa4b1f..b31910c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -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) ------------------ diff --git a/README.rst b/README.rst index 0d0874b..83fce0c 100644 --- a/README.rst +++ b/README.rst @@ -106,20 +106,37 @@ Following a conflicted “rebase” operation in your source control tool, run i $ python manage.py rebase-migration +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 @@ -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: @@ -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 =========== diff --git a/src/django_linear_migrations/management/commands/rebase-migration.py b/src/django_linear_migrations/management/commands/rebase-migration.py index 664e207..39395ec 100644 --- a/src/django_linear_migrations/management/commands/rebase-migration.py +++ b/src/django_linear_migrations/management/commands/rebase-migration.py @@ -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 @@ -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 = )(\[.*?\])", @@ -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 diff --git a/tests/test_rebase_migration.py b/tests/test_rebase_migration.py index 099803a..c91aa77 100644 --- a/tests/test_rebase_migration.py +++ b/tests/test_rebase_migration.py @@ -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") @@ -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() @@ -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