diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 75fcca1..25c4b49 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,10 @@ Changelog ========= +* Extend ``rebase_migration`` to detect Git in-progress merges and select the correct migration to rebase. + + Thanks to Dmitry Sleptsov in `PR #260 `__. + 2.7.0 (2023-02-25) ------------------ diff --git a/README.rst b/README.rst index 6185d7d..13d5b73 100644 --- a/README.rst +++ b/README.rst @@ -149,14 +149,20 @@ Following a conflicted “rebase” operation in Git, run it with the name of th $ python manage.py rebase_migration -The command will use the conflict information in the ``max_migration.txt`` file to determine which migration to rebase. -It will then rename the migration, edit it to depend on the new migration in your main branch, and update ``max_migration.txt``. -If Black is installed, it will format the updated migration file with it, like Django’s built-in migration commands (from version 4.1+). +The command uses the conflict information in the ``max_migration.txt`` file to determine which migration to rebase. +It automatically detects whether a Git merge or rebase operation is in progress, assuming rebase if a Git repository cannot be found. +The command then: + +1. renames the migration +2. edits it to depend on the new migration from your main branch +3. updates ``max_migration.txt``. + +If Black is installed, the command formats the updated migration file with it, like Django’s built-in migration commands do (from version 4.1+). See below for some examples and caveats. 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 to 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. +If the migrations in your main and feature branches have both affected the same models, rebasing the migration to the end may not make sense. +However, such parallel changes would *normally* cause conflicts in your model files or other parts of the source code as well. Worked Example ^^^^^^^^^^^^^^ diff --git a/src/django_linear_migrations/management/commands/rebase_migration.py b/src/django_linear_migrations/management/commands/rebase_migration.py index 6292e9a..b2d552c 100644 --- a/src/django_linear_migrations/management/commands/rebase_migration.py +++ b/src/django_linear_migrations/management/commands/rebase_migration.py @@ -184,7 +184,29 @@ def find_migration_names(max_migration_lines: list[str]) -> tuple[str, str] | No return None if not lines[-1].startswith(">>>>>>>"): return None - return lines[1].strip(), lines[-2].strip() + migration_names = (lines[1].strip(), lines[-2].strip()) + if is_merge_in_progress(): + # During the merge 'ours' and 'theirs' are swapped in comparison with rebase + migration_names = (migration_names[1], migration_names[0]) + return migration_names + + +def is_merge_in_progress() -> bool: + try: + subprocess.run( + ["git", "rev-parse", "--verify", "MERGE_HEAD"], + capture_output=True, + check=True, + text=True, + ) + except (FileNotFoundError, subprocess.SubprocessError): + # Either: + # - `git` is not available, or broken + # - there is no git repository + # - no merge head exists, so assume rebasing + return False + # Merged head exists, we are merging + return True def migration_applied(app_label: str, migration_name: str) -> bool: diff --git a/tests/test_rebase_migration.py b/tests/test_rebase_migration.py index 8066c39..596009d 100644 --- a/tests/test_rebase_migration.py +++ b/tests/test_rebase_migration.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import subprocess import sys import time from functools import partial @@ -438,7 +440,7 @@ def test_none_when_no_second_marker(self): result = module.find_migration_names(["<<<<<<<", "0002_author_nicknames"]) assert result is None - def test_works_with_two_way_merge(self): + def test_works_with_two_way_merge_during_rebase(self): result = module.find_migration_names( [ "<<<<<<<", @@ -450,7 +452,7 @@ def test_works_with_two_way_merge(self): ) assert result == ("0002_author_nicknames", "0002_longer_titles") - def test_works_with_three_way_merge(self): + def test_works_with_three_way_merge_during_rebase(self): result = module.find_migration_names( [ "<<<<<<<", @@ -464,6 +466,73 @@ def test_works_with_three_way_merge(self): ) assert result == ("0002_author_nicknames", "0002_longer_titles") + def test_works_with_two_way_merge_during_merge(self): + with mock.patch.object(module, "is_merge_in_progress", return_value=True): + result = module.find_migration_names( + [ + "<<<<<<<", + "0002_longer_titles", + "=======", + "0002_author_nicknames", + ">>>>>>>", + ] + ) + assert result == ("0002_author_nicknames", "0002_longer_titles") + + def test_works_with_three_way_merge_during_merge(self): + with mock.patch.object(module, "is_merge_in_progress", return_value=True): + result = module.find_migration_names( + [ + "<<<<<<<", + "0002_longer_titles", + "|||||||", + "0001_initial", + "=======", + "0002_author_nicknames", + ">>>>>>>", + ] + ) + assert result == ("0002_author_nicknames", "0002_longer_titles") + + +class IsMergeInProgressTests(SimpleTestCase): + @pytest.fixture(autouse=True) + def tmp_path_fixture(self, tmp_path): + self.tmp_path = tmp_path + self.subprocess_run = partial(subprocess.run, cwd=tmp_path, check=True) + + def setUp(self): + orig = os.getcwd() + os.chdir(self.tmp_path) + self.addCleanup(os.chdir, orig) + + def test_no_git_command(self): + with mock.patch.dict(os.environ, {"PATH": ""}): + result = module.is_merge_in_progress() + assert result is False + + def test_no_git_dir(self): + result = module.is_merge_in_progress() + assert result is False + + def test_git_dir_no_merge(self): + self.subprocess_run(["git", "init"]) + result = module.is_merge_in_progress() + assert result is False + + def test_git_dir_merge(self): + self.subprocess_run(["git", "init", "-b", "main"]) + self.subprocess_run(["git", "config", "user.email", "hacker@example.com"]) + self.subprocess_run(["git", "config", "user.name", "A Hacker"]) + self.subprocess_run(["git", "commit", "--allow-empty", "-m", "A"]) + self.subprocess_run(["git", "switch", "--orphan", "other"]) + self.subprocess_run(["git", "commit", "--allow-empty", "-m", "B"]) + self.subprocess_run( + ["git", "merge", "--no-commit", "--allow-unrelated-histories", "main"] + ) + result = module.is_merge_in_progress() + assert result is True + class MigrationAppliedTests(TestCase): def test_table_does_not_exist(self):