Skip to content

Commit 3ad0437

Browse files
committed
rebase-migration: Make it possible to handle chains of migrations.
Previously, the rebase_migration command was not able to rebase chains of migrations in a single app. This commit introduces a new flag -- "new" which basically is used for the first migration you create, and it wipes out your migration history in max_migration.txt, and writes up that first migration in the first line. Any further migrations added without the flag are simply added under each other in the max_migration.txt. This would allow the rebase_migration command to access the chain of migrations that need to be rebased in a commit, and will rebase them accordingly. Fixes #27
1 parent ec1f97a commit 3ad0437

File tree

6 files changed

+316
-132
lines changed

6 files changed

+316
-132
lines changed

src/django_linear_migrations/apps.py

+16-29
Original file line numberDiff line numberDiff line change
@@ -188,41 +188,28 @@ def check_max_migration_files(
188188
)
189189
continue
190190

191-
max_migration_txt_lines = max_migration_txt.read_text().strip().splitlines()
192-
if len(max_migration_txt_lines) > 1:
193-
errors.append(
194-
Error(
195-
id="dlm.E002",
196-
msg=f"{app_label}'s max_migration.txt contains multiple lines.",
197-
hint=(
198-
"This may be the result of a git merge. Fix the file"
199-
+ " to contain only the name of the latest migration,"
200-
+ " or maybe use the 'rebase-migration' command."
201-
),
191+
migration_txt_lines = max_migration_txt.read_text().strip().splitlines()
192+
for migration_name in migration_txt_lines:
193+
if migration_name not in migration_details.names:
194+
errors.append(
195+
Error(
196+
id="dlm.E003",
197+
msg=(
198+
f"{app_label}'s max_migration.txt points to"
199+
+ f" non-existent migration {migration_name!r}."
200+
),
201+
hint=(
202+
"Edit the max_migration.txt to contain the latest"
203+
+ " migration's name."
204+
),
205+
)
202206
)
203-
)
204-
continue
205-
206-
max_migration_name = max_migration_txt_lines[0]
207-
if max_migration_name not in migration_details.names:
208-
errors.append(
209-
Error(
210-
id="dlm.E003",
211-
msg=(
212-
f"{app_label}'s max_migration.txt points to"
213-
+ f" non-existent migration {max_migration_name!r}."
214-
),
215-
hint=(
216-
"Edit the max_migration.txt to contain the latest"
217-
+ " migration's name."
218-
),
219-
)
220-
)
221207
continue
222208

223209
real_max_migration_name = [
224210
name for gp_app_label, name in graph_plan if gp_app_label == app_label
225211
][-1]
212+
max_migration_name = migration_txt_lines[-1]
226213
if max_migration_name != real_max_migration_name:
227214
errors.append(
228215
Error(
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

3+
from typing import Any
4+
35
import django
6+
from django.core.management.base import CommandParser
47
from django.core.management.commands.makemigrations import Command as BaseCommand
58
from django.db.migrations import Migration
69

@@ -9,6 +12,18 @@
912

1013

1114
class Command(BaseCommand):
15+
def add_arguments(self, parser: CommandParser) -> None:
16+
super().add_arguments(parser)
17+
parser.add_argument(
18+
"--new",
19+
action="store_true",
20+
help="Create and register the migration as the first migration of the commit.",
21+
)
22+
23+
def handle(self, *app_labels: str, **options: Any) -> None:
24+
self.first_migration = options["new"]
25+
super().handle(*app_labels, **options)
26+
1227
if django.VERSION >= (4, 2):
1328

1429
def write_migration_files(
@@ -22,7 +37,7 @@ def write_migration_files(
2237
changes,
2338
update_previous_migration_paths,
2439
)
25-
_post_write_migration_files(self.dry_run, changes)
40+
self._post_write_migration_files(self.dry_run, changes)
2641

2742
else:
2843

@@ -31,25 +46,33 @@ def write_migration_files( # type: ignore[misc,override]
3146
changes: dict[str, list[Migration]],
3247
) -> None:
3348
super().write_migration_files(changes)
34-
_post_write_migration_files(self.dry_run, changes)
49+
self._post_write_migration_files(self.dry_run, changes)
3550

51+
def _post_write_migration_files(
52+
self, dry_run: bool, changes: dict[str, list[Migration]]
53+
) -> None:
54+
if dry_run:
55+
return
3656

37-
def _post_write_migration_files(
38-
dry_run: bool, changes: dict[str, list[Migration]]
39-
) -> None:
40-
if dry_run:
41-
return
57+
first_party_app_labels = {
58+
app_config.label for app_config in first_party_app_configs()
59+
}
4260

43-
first_party_app_labels = {
44-
app_config.label for app_config in first_party_app_configs()
45-
}
61+
for app_label, app_migrations in changes.items():
62+
if app_label not in first_party_app_labels:
63+
continue
4664

47-
for app_label, app_migrations in changes.items():
48-
if app_label not in first_party_app_labels:
49-
continue
65+
# Reload required as we've generated changes
66+
migration_details = MigrationDetails(app_label, do_reload=True)
67+
max_migration_name = app_migrations[-1].name
68+
max_migration_txt = migration_details.dir / "max_migration.txt"
5069

51-
# Reload required as we've generated changes
52-
migration_details = MigrationDetails(app_label, do_reload=True)
53-
max_migration_name = app_migrations[-1].name
54-
max_migration_txt = migration_details.dir / "max_migration.txt"
55-
max_migration_txt.write_text(max_migration_name + "\n")
70+
if self.first_migration:
71+
max_migration_txt.write_text(max_migration_name + "\n")
72+
self.first_migration = False
73+
continue
74+
75+
current_version_migrations = max_migration_txt.read_text()
76+
max_migration_txt.write_text(
77+
current_version_migrations + max_migration_name + "\n"
78+
)

src/django_linear_migrations/management/commands/rebase_migration.py

+77-28
Original file line numberDiff line numberDiff line change
@@ -46,27 +46,43 @@ def handle(self, *args: Any, app_label: str, **options: Any) -> None:
4646
if not max_migration_txt.exists():
4747
raise CommandError(f"{app_label} does not have a max_migration.txt.")
4848

49-
migration_names = find_migration_names(
50-
max_migration_txt.read_text().splitlines()
51-
)
49+
migration_names = find_migration_names(max_migration_txt.read_text())
5250
if migration_names is None:
5351
raise CommandError(
5452
f"{app_label}'s max_migration.txt does not seem to contain a"
5553
+ " merge conflict."
5654
)
57-
merged_migration_name, rebased_migration_name = migration_names
58-
if merged_migration_name not in migration_details.names:
59-
raise CommandError(
60-
f"Parsed {merged_migration_name!r} as the already-merged"
61-
+ f" migration name from {app_label}'s max_migration.txt, but"
62-
+ " this migration does not exist."
63-
)
64-
if rebased_migration_name not in migration_details.names:
65-
raise CommandError(
66-
f"Parsed {rebased_migration_name!r} as the rebased migration"
67-
+ f" name from {app_label}'s max_migration.txt, but this"
68-
+ " migration does not exist."
69-
)
55+
56+
merged_migration_names, rebased_migration_names = migration_names
57+
58+
for merged_migration_name in merged_migration_names:
59+
if merged_migration_name not in migration_details.names:
60+
raise CommandError(
61+
f"Parsed {merged_migration_name!r} as the already-merged"
62+
+ f" migration name from {app_label}'s max_migration.txt, but"
63+
+ " this migration does not exist."
64+
)
65+
66+
for rebased_migration_name in rebased_migration_names:
67+
if rebased_migration_name not in migration_details.names:
68+
raise CommandError(
69+
f"Parsed {rebased_migration_name!r} as the rebased migration"
70+
+ f" name from {app_label}'s max_migration.txt, but this"
71+
+ " migration does not exist."
72+
)
73+
74+
self.last_migration_name = merged_migration_names[-1]
75+
76+
first_migration = True
77+
for rebased_migration_name in rebased_migration_names:
78+
self.rebase_migration(app_label, rebased_migration_name, first_migration)
79+
first_migration = False
80+
81+
def rebase_migration(
82+
self, app_label: str, rebased_migration_name: str, first_migration: bool
83+
) -> None:
84+
migration_details = MigrationDetails(app_label)
85+
max_migration_txt = migration_details.dir / "max_migration.txt"
7086

7187
rebased_migration_filename = f"{rebased_migration_name}.py"
7288
rebased_migration_path = migration_details.dir / rebased_migration_filename
@@ -136,7 +152,7 @@ def handle(self, *args: Any, app_label: str, **options: Any) -> None:
136152
ast.Tuple(
137153
elts=[
138154
ast.Constant(app_label),
139-
ast.Constant(merged_migration_name),
155+
ast.Constant(self.last_migration_name),
140156
]
141157
)
142158
)
@@ -152,16 +168,23 @@ def handle(self, *args: Any, app_label: str, **options: Any) -> None:
152168

153169
new_content = before_deps + ast_unparse(new_dependencies) + after_deps
154170

155-
merged_number, _merged_rest = merged_migration_name.split("_", 1)
171+
last_merged_number, _merged_rest = self.last_migration_name.split("_", 1)
156172
_rebased_number, rebased_rest = rebased_migration_name.split("_", 1)
157-
new_number = int(merged_number) + 1
173+
new_number = int(last_merged_number) + 1
158174
new_name = str(new_number).zfill(4) + "_" + rebased_rest
159175
new_path_parts = rebased_migration_path.parts[:-1] + (f"{new_name}.py",)
160176
new_path = Path(*new_path_parts)
161177

162178
rebased_migration_path.rename(new_path)
163179
new_path.write_text(new_content)
164-
max_migration_txt.write_text(f"{new_name}\n")
180+
181+
if first_migration:
182+
max_migration_txt.write_text(f"{new_name}\n")
183+
else:
184+
current_version_migrations = max_migration_txt.read_text()
185+
max_migration_txt.write_text(current_version_migrations + f"{new_name}\n")
186+
187+
self.last_migration_name = new_name
165188

166189
black_path = shutil.which("black")
167190
if black_path: # pragma: no cover
@@ -176,19 +199,45 @@ def handle(self, *args: Any, app_label: str, **options: Any) -> None:
176199
)
177200

178201

179-
def find_migration_names(max_migration_lines: list[str]) -> tuple[str, str] | None:
180-
lines = max_migration_lines
181-
if len(lines) <= 1:
202+
def find_migration_names(
203+
current_version_migrations: str,
204+
) -> tuple[list[str], list[str]] | None:
205+
migrations_lines = current_version_migrations.strip().splitlines()
206+
207+
if len(migrations_lines) <= 1:
182208
return None
183-
if not lines[0].startswith("<<<<<<<"):
209+
if not migrations_lines[0].startswith("<<<<<<<"):
184210
return None
185-
if not lines[-1].startswith(">>>>>>>"):
211+
if not migrations_lines[-1].startswith(">>>>>>>"):
186212
return None
187-
migration_names = (lines[1].strip(), lines[-2].strip())
213+
214+
merged_migration_names = []
215+
rebased_migration_names = []
216+
217+
index = 0
218+
while index < len(migrations_lines):
219+
if migrations_lines[index].startswith("<<<<<<<"):
220+
index += 1
221+
while not migrations_lines[index].startswith("======="):
222+
if migrations_lines[index] == "|||||||":
223+
while not migrations_lines[index].startswith("======="):
224+
index += 1
225+
else:
226+
merged_migration_names.append(migrations_lines[index])
227+
index += 1
228+
229+
index += 1
230+
231+
else:
232+
while not migrations_lines[index].startswith(">>>>>>>"):
233+
rebased_migration_names.append(migrations_lines[index])
234+
index += 1
235+
break
236+
188237
if is_merge_in_progress():
189238
# During the merge 'ours' and 'theirs' are swapped in comparison with rebase
190-
migration_names = (migration_names[1], migration_names[0])
191-
return migration_names
239+
return (rebased_migration_names, merged_migration_names)
240+
return (merged_migration_names, rebased_migration_names)
192241

193242

194243
def is_merge_in_progress() -> bool:

tests/test_checks.py

+1-12
Original file line numberDiff line numberDiff line change
@@ -64,25 +64,14 @@ def test_dlm_E001(self):
6464
assert result[0].id == "dlm.E001"
6565
assert result[0].msg == "testapp's max_migration.txt does not exist."
6666

67-
def test_dlm_E002(self):
68-
(self.migrations_dir / "__init__.py").touch()
69-
(self.migrations_dir / "0001_initial.py").write_text(empty_migration)
70-
(self.migrations_dir / "max_migration.txt").write_text("line1\nline2\n")
71-
72-
result = check_max_migration_files()
73-
74-
assert len(result) == 1
75-
assert result[0].id == "dlm.E002"
76-
assert result[0].msg == "testapp's max_migration.txt contains multiple lines."
77-
7867
def test_dlm_E003(self):
7968
(self.migrations_dir / "__init__.py").touch()
8069
(self.migrations_dir / "0001_initial.py").write_text(empty_migration)
8170
(self.migrations_dir / "max_migration.txt").write_text("0001_start\n")
8271

8372
result = check_max_migration_files()
8473

85-
assert len(result) == 1
74+
assert len(result) == 2
8675
assert result[0].id == "dlm.E003"
8776
assert result[0].msg == (
8877
"testapp's max_migration.txt points to non-existent migration"

tests/test_makemigrations.py

+36-5
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,15 @@ def test_dry_run(self):
4040
assert not max_migration_txt.exists()
4141

4242
def test_creates_max_migration_txt(self):
43-
out, err, returncode = self.call_command("testapp")
43+
out, err, returncode = self.call_command("testapp", "--new")
4444

4545
assert returncode == 0
4646
max_migration_txt = self.migrations_dir / "max_migration.txt"
4747
assert max_migration_txt.read_text() == "0001_initial\n"
4848

4949
@unittest.skipUnless(django.VERSION >= (4, 2), "--update added in Django 4.2")
5050
def test_update(self):
51-
self.call_command("testapp")
51+
self.call_command("testapp", "--new")
5252
max_migration_txt = self.migrations_dir / "max_migration.txt"
5353
assert max_migration_txt.read_text() == "0001_initial\n"
5454

@@ -59,10 +59,12 @@ class Meta:
5959
out, err, returncode = self.call_command("--update", "testapp")
6060
assert returncode == 0
6161
max_migration_txt = self.migrations_dir / "max_migration.txt"
62-
assert max_migration_txt.read_text() == "0001_initial_updated\n"
62+
assert max_migration_txt.read_text() == "0001_initial\n0001_initial_updated\n"
6363

6464
def test_creates_max_migration_txt_given_name(self):
65-
out, err, returncode = self.call_command("testapp", "--name", "brand_new")
65+
out, err, returncode = self.call_command(
66+
"testapp", "--name", "brand_new", "--new"
67+
)
6668

6769
assert returncode == 0
6870
max_migration_txt = self.migrations_dir / "max_migration.txt"
@@ -89,7 +91,36 @@ class Migration(migrations.Migration):
8991

9092
assert returncode == 0
9193
max_migration_txt = self.migrations_dir / "max_migration.txt"
92-
assert max_migration_txt.read_text() == "0002_create_book\n"
94+
assert max_migration_txt.read_text() == "0001_initial\n0002_create_book\n"
95+
96+
def test_create_max_migration_txt_with_multiple_migrations(self):
97+
max_migration_txt = self.migrations_dir / "max_migration.txt"
98+
(self.migrations_dir / "__init__.py").touch()
99+
100+
out, err, returncode = self.call_command("testapp", "--name", "first", "--new")
101+
102+
assert returncode == 0
103+
assert max_migration_txt.read_text() == "0001_first\n"
104+
105+
# Creating a second migration on without the `new` flag keeps
106+
# the first migration, while updates the last migration in the
107+
# "max_migration.txt"
108+
out, err, returncode = self.call_command(
109+
"testapp", "--empty", "--name", "second"
110+
)
111+
112+
assert returncode == 0
113+
assert max_migration_txt.read_text() == "0001_first\n0002_second\n"
114+
115+
# Creating a third migration on without the `new` flag keeps
116+
# the first migration, while updates the last migration in the
117+
# "max_migration.txt"
118+
out, err, returncode = self.call_command(
119+
"testapp", "--empty", "--name", "third"
120+
)
121+
122+
assert returncode == 0
123+
assert max_migration_txt.read_text() == "0001_first\n0002_second\n0003_third\n"
93124

94125
@override_settings(FIRST_PARTY_APPS=[])
95126
def test_skips_creating_max_migration_txt_for_non_first_party_app(self):

0 commit comments

Comments
 (0)