Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect Git merges in rebase_migration #261

Merged
merged 10 commits into from
May 29, 2023
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/adamchainz/django-linear-migrations/pull/260>`__.

2.7.0 (2023-02-25)
------------------

Expand Down
16 changes: 11 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,20 @@ Following a conflicted “rebase” operation in Git, run it with the name of th

$ python manage.py rebase_migration <app_label>

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
^^^^^^^^^^^^^^
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 71 additions & 2 deletions tests/test_rebase_migration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import os
import subprocess
import sys
import time
from functools import partial
Expand Down Expand Up @@ -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(
[
"<<<<<<<",
Expand All @@ -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(
[
"<<<<<<<",
Expand All @@ -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", "[email protected]"])
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):
Expand Down