Skip to content

Commit ff5a831

Browse files
authored
Make squashmigrations update max_migrations.txt (#360)
Fixes #329.
1 parent b57d62b commit ff5a831

File tree

8 files changed

+284
-42
lines changed

8 files changed

+284
-42
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
Changelog
33
=========
44

5+
* Make ``squashmigrations`` update ``max_migration.txt`` files as well.
6+
7+
Thanks to Gordon Wrigley for the report in `Issue #329 <https://github.com/adamchainz/django-linear-migrations/issues/329>`__.
8+
59
* Drop Python 3.8 support.
610

711
* Support Python 3.13.

README.rst

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,6 @@ Installation
5454
...,
5555
]
5656
57-
The app relies on overriding the built-in ``makemigrations`` command.
58-
*If your project has a custom* ``makemigrations`` *command,* ensure the app containing your custom command is **above** ``django_linear_migrations``, and that your command subclasses its ``Command`` class:
59-
60-
.. code-block:: python
61-
62-
# myapp/management/commands/makemigrations.py
63-
from django_linear_migrations.management.commands.makemigrations import (
64-
Command as BaseCommand,
65-
)
66-
67-
68-
class Command(BaseCommand):
69-
...
70-
7157
**Third,** check the automatic detection of first-party apps.
7258
Run this command:
7359

@@ -86,31 +72,42 @@ If you see any apps listed that *aren’t* part of your project, define the list
8672
8773
INSTALLED_APPS = FIRST_PARTY_APPS + ["django_linear_migrations", ...]
8874
89-
(Note: Django recommends you always list first-party apps first in your project so they can override things in third-party and contrib apps.)
75+
Note: Django recommends you always list first-party apps first in your project so they can override things in third-party and contrib apps.
9076

9177
**Fourth,** create the ``max_migration.txt`` files for your first-party apps by re-running the command without the dry run flag:
9278

9379
.. code-block:: sh
9480
9581
python manage.py create_max_migration_files
9682
97-
In the future, when you add a new app to your project, you’ll need to create its ``max_migration.txt`` file.
98-
Add the new app to ``INSTALLED_APPS`` or ``FIRST_PARTY_APPS`` as appropriate, then rerun the creation command for the new app by specifying its label:
99-
100-
.. code-block:: sh
101-
102-
python manage.py create_max_migration_files my_new_app
103-
10483
Usage
10584
=====
10685

10786
django-linear-migrations helps you work on Django projects where several branches adding migrations may be in progress at any time.
10887
It enforces that your apps have a *linear* migration history, avoiding merge migrations and the problems they can cause from migrations running in different orders.
109-
It does this by making ``makemigrations`` record the name of the latest migration in per-app ``max_migration.txt`` files.
88+
It does this by making ``makemigrations`` and ``squashmigrations`` record the name of the latest migration in per-app ``max_migration.txt`` files.
11089
These files will then cause a merge conflicts in your source control tool (Git, Mercurial, etc.) in the case of migrations being developed in parallel.
11190
The first merged migration for an app will prevent the second from being merged, without addressing the conflict.
11291
The included ``rebase_migration`` command can help automatically such conflicts.
11392

93+
Custom commands
94+
---------------
95+
96+
django-linear-migrations relies on overriding the built-in ``makemigrations`` and ``squashmigrations`` commands.
97+
If your project has custom versions of these commands, ensure the app containing your custom commands is **above** ``django_linear_migrations``, and that your commands subclass its ``Command`` class.
98+
For example, for ``makemigrations``:
99+
100+
.. code-block:: python
101+
102+
# myapp/management/commands/makemigrations.py
103+
from django_linear_migrations.management.commands.makemigrations import (
104+
Command as BaseCommand,
105+
)
106+
107+
108+
class Command(BaseCommand):
109+
...
110+
114111
System Checks
115112
-------------
116113

@@ -138,6 +135,16 @@ Pass the ``--dry-run`` flag to only list the ``max_migration.txt`` files that wo
138135
Pass the ``--recreate`` flag to re-create files that already exist.
139136
This may be useful after altering migrations with merges or manually.
140137
138+
Adding new apps
139+
^^^^^^^^^^^^^^^
140+
141+
When you add a new app to your project, you may need to create its ``max_migration.txt`` file to match any pre-created migrations.
142+
Add the new app to ``INSTALLED_APPS`` or ``FIRST_PARTY_APPS`` as appropriate, then rerun the creation command for the new app by specifying its label:
143+
144+
.. code-block:: sh
145+
146+
python manage.py create_max_migration_files my_new_app
147+
141148
``rebase_migration`` Command
142149
----------------------------
143150

src/django_linear_migrations/management/commands/makemigrations.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,5 @@ def _post_write_migration_files(
5050

5151
# Reload required as we've generated changes
5252
migration_details = MigrationDetails(app_label, do_reload=True)
53-
max_migration_name = app_migrations[-1].name
5453
max_migration_txt = migration_details.dir / "max_migration.txt"
55-
max_migration_txt.write_text(max_migration_name + "\n")
54+
max_migration_txt.write_text(f"{app_migrations[-1].name}\n")
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from django.core.management.commands import squashmigrations
6+
from django.core.management.commands.squashmigrations import Command as BaseCommand
7+
from django.db.migrations import Migration
8+
from django.db.migrations.writer import MigrationWriter
9+
10+
from django_linear_migrations.apps import MigrationDetails
11+
from django_linear_migrations.apps import first_party_app_configs
12+
13+
14+
class Command(BaseCommand):
15+
def handle(self, **options: Any) -> None:
16+
# Temporarily wrap the call to MigrationWriter.__init__ to capture its first
17+
# argument, the generated migration instance.
18+
captured_migration = None
19+
20+
def wrapper(migration: Migration, *args: Any, **kwargs: Any) -> MigrationWriter:
21+
nonlocal captured_migration
22+
captured_migration = migration
23+
return MigrationWriter(migration, *args, **kwargs)
24+
25+
squashmigrations.MigrationWriter = wrapper # type: ignore[attr-defined]
26+
27+
try:
28+
super().handle(**options)
29+
finally:
30+
squashmigrations.MigrationWriter = MigrationWriter # type: ignore[attr-defined]
31+
32+
if captured_migration is not None and any(
33+
captured_migration.app_label == app_config.label
34+
for app_config in first_party_app_configs()
35+
):
36+
# A squash migration was generated, update max_migration.txt.
37+
migration_details = MigrationDetails(captured_migration.app_label)
38+
max_migration_txt = migration_details.dir / "max_migration.txt"
39+
max_migration_txt.write_text(f"{captured_migration.name}\n")

tests/compat.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
import unittest
5+
from collections.abc import Callable
6+
from contextlib import AbstractContextManager
7+
from typing import Any
8+
from typing import TypeVar
9+
10+
# TestCase.enterContext() backport, source:
11+
# https://adamj.eu/tech/2022/11/14/unittest-context-methods-python-3-11-backports/
12+
13+
_T = TypeVar("_T")
14+
15+
if sys.version_info < (3, 11):
16+
17+
def _enter_context(cm: Any, addcleanup: Callable[..., None]) -> Any:
18+
# We look up the special methods on the type to match the with
19+
# statement.
20+
cls = type(cm)
21+
try:
22+
enter = cls.__enter__
23+
exit = cls.__exit__
24+
except AttributeError: # pragma: no cover
25+
raise TypeError(
26+
f"'{cls.__module__}.{cls.__qualname__}' object does "
27+
f"not support the context manager protocol"
28+
) from None
29+
result = enter(cm)
30+
addcleanup(exit, cm, None, None, None)
31+
return result
32+
33+
34+
class EnterContextMixin(unittest.TestCase):
35+
if sys.version_info < (3, 11):
36+
37+
def enterContext(self, cm: AbstractContextManager[_T]) -> _T:
38+
result: _T = _enter_context(cm, self.addCleanup)
39+
return result

tests/test_makemigrations.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,22 @@
11
from __future__ import annotations
22

3-
import sys
4-
import time
53
import unittest
64
from functools import partial
75
from textwrap import dedent
86

97
import django
10-
import pytest
118
from django.db import models
129
from django.test import TestCase
1310
from django.test import override_settings
1411

12+
from tests.compat import EnterContextMixin
1513
from tests.utils import run_command
14+
from tests.utils import temp_migrations_module
1615

1716

18-
class MakeMigrationsTests(TestCase):
19-
@pytest.fixture(autouse=True)
20-
def tmp_path_fixture(self, tmp_path):
21-
migrations_module_name = "migrations" + str(time.time()).replace(".", "")
22-
self.migrations_dir = tmp_path / migrations_module_name
23-
self.migrations_dir.mkdir()
24-
sys.path.insert(0, str(tmp_path))
25-
try:
26-
with override_settings(
27-
MIGRATION_MODULES={"testapp": migrations_module_name}
28-
):
29-
yield
30-
finally:
31-
sys.path.pop(0)
17+
class MakeMigrationsTests(EnterContextMixin, TestCase):
18+
def setUp(self):
19+
self.migrations_dir = self.enterContext(temp_migrations_module())
3220

3321
call_command = partial(run_command, "makemigrations")
3422

tests/test_squashmigrations.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from __future__ import annotations
2+
3+
from functools import partial
4+
from textwrap import dedent
5+
6+
import pytest
7+
from django.core.management import CommandError
8+
from django.test import TestCase
9+
from django.test import override_settings
10+
11+
from tests.compat import EnterContextMixin
12+
from tests.utils import run_command
13+
from tests.utils import temp_migrations_module
14+
15+
16+
class SquashMigrationsTests(EnterContextMixin, TestCase):
17+
def setUp(self):
18+
self.migrations_dir = self.enterContext(temp_migrations_module())
19+
20+
call_command = partial(run_command, "squashmigrations")
21+
22+
def test_fail_already_squashed_migration(self):
23+
(self.migrations_dir / "__init__.py").touch()
24+
(self.migrations_dir / "0001_already_squashed.py").write_text(
25+
dedent(
26+
"""\
27+
from django.db import migrations, models
28+
29+
30+
class Migration(migrations.Migration):
31+
replaces = [
32+
('testapp', '0001_initial'),
33+
('testapp', '0002_second'),
34+
]
35+
dependencies = []
36+
operations = []
37+
"""
38+
)
39+
)
40+
(self.migrations_dir / "__init__.py").touch()
41+
(self.migrations_dir / "0002_new_branch.py").write_text(
42+
dedent(
43+
"""\
44+
from django.db import migrations, models
45+
46+
47+
class Migration(migrations.Migration):
48+
dependencies = [
49+
('testapp', '0001_already_squashed'),
50+
]
51+
operations = []
52+
"""
53+
)
54+
)
55+
max_migration_txt = self.migrations_dir / "max_migration.txt"
56+
max_migration_txt.write_text("0002_new_branch\n")
57+
58+
with pytest.raises(CommandError) as excinfo:
59+
self.call_command("testapp", "0002", "--no-input")
60+
61+
assert excinfo.value.args[0].startswith(
62+
"You cannot squash squashed migrations!"
63+
)
64+
assert max_migration_txt.read_text() == "0002_new_branch\n"
65+
66+
def test_success(self):
67+
(self.migrations_dir / "__init__.py").touch()
68+
(self.migrations_dir / "0001_initial.py").write_text(
69+
dedent(
70+
"""\
71+
from django.db import migrations, models
72+
73+
74+
class Migration(migrations.Migration):
75+
intial = True
76+
dependencies = []
77+
operations = []
78+
"""
79+
)
80+
)
81+
(self.migrations_dir / "__init__.py").touch()
82+
(self.migrations_dir / "0002_second.py").write_text(
83+
dedent(
84+
"""\
85+
from django.db import migrations, models
86+
87+
88+
class Migration(migrations.Migration):
89+
dependencies = [
90+
('testapp', '0001_initial'),
91+
]
92+
operations = []
93+
"""
94+
)
95+
)
96+
max_migration_txt = self.migrations_dir / "max_migration.txt"
97+
max_migration_txt.write_text("0002_second\n")
98+
99+
out, err, returncode = self.call_command("testapp", "0002", "--no-input")
100+
101+
assert returncode == 0
102+
assert max_migration_txt.read_text() == "0001_squashed_0002_second\n"
103+
104+
@override_settings(FIRST_PARTY_APPS=[])
105+
def test_skip_non_first_party_app(self):
106+
(self.migrations_dir / "__init__.py").touch()
107+
(self.migrations_dir / "0001_initial.py").write_text(
108+
dedent(
109+
"""\
110+
from django.db import migrations, models
111+
112+
113+
class Migration(migrations.Migration):
114+
intial = True
115+
dependencies = []
116+
operations = []
117+
"""
118+
)
119+
)
120+
(self.migrations_dir / "__init__.py").touch()
121+
(self.migrations_dir / "0002_second.py").write_text(
122+
dedent(
123+
"""\
124+
from django.db import migrations, models
125+
126+
127+
class Migration(migrations.Migration):
128+
dependencies = [
129+
('testapp', '0001_initial'),
130+
]
131+
operations = []
132+
"""
133+
)
134+
)
135+
max_migration_txt = self.migrations_dir / "max_migration.txt"
136+
max_migration_txt.write_text("0002_second\n")
137+
138+
out, err, returncode = self.call_command("testapp", "0002", "--no-input")
139+
140+
assert returncode == 0
141+
assert max_migration_txt.read_text() == "0002_second\n"

0 commit comments

Comments
 (0)