Skip to content

Commit e577526

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.
1 parent d3e759d commit e577526

File tree

6 files changed

+318
-132
lines changed

6 files changed

+318
-132
lines changed

src/django_linear_migrations/apps.py

Lines changed: 16 additions & 29 deletions
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(
Lines changed: 41 additions & 18 deletions
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

Lines changed: 79 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import subprocess
88
from pathlib import Path
99
from typing import Any
10+
from typing import List
11+
from typing import Tuple
1012

1113
from django.apps import apps
1214
from django.core.management import BaseCommand
@@ -46,27 +48,43 @@ def handle(self, *args: Any, app_label: str, **options: Any) -> None:
4648
if not max_migration_txt.exists():
4749
raise CommandError(f"{app_label} does not have a max_migration.txt.")
4850

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

7189
rebased_migration_filename = f"{rebased_migration_name}.py"
7290
rebased_migration_path = migration_details.dir / rebased_migration_filename
@@ -136,7 +154,7 @@ def handle(self, *args: Any, app_label: str, **options: Any) -> None:
136154
ast.Tuple(
137155
elts=[
138156
ast.Constant(app_label),
139-
ast.Constant(merged_migration_name),
157+
ast.Constant(self.last_migration_name),
140158
]
141159
)
142160
)
@@ -152,16 +170,23 @@ def handle(self, *args: Any, app_label: str, **options: Any) -> None:
152170

153171
new_content = before_deps + ast_unparse(new_dependencies) + after_deps
154172

155-
merged_number, _merged_rest = merged_migration_name.split("_", 1)
173+
last_merged_number, _merged_rest = self.last_migration_name.split("_", 1)
156174
_rebased_number, rebased_rest = rebased_migration_name.split("_", 1)
157-
new_number = int(merged_number) + 1
175+
new_number = int(last_merged_number) + 1
158176
new_name = str(new_number).zfill(4) + "_" + rebased_rest
159177
new_path_parts = rebased_migration_path.parts[:-1] + (f"{new_name}.py",)
160178
new_path = Path(*new_path_parts)
161179

162180
rebased_migration_path.rename(new_path)
163181
new_path.write_text(new_content)
164-
max_migration_txt.write_text(f"{new_name}\n")
182+
183+
if first_migration:
184+
max_migration_txt.write_text(f"{new_name}\n")
185+
else:
186+
current_version_migrations = max_migration_txt.read_text()
187+
max_migration_txt.write_text(current_version_migrations + f"{new_name}\n")
188+
189+
self.last_migration_name = new_name
165190

166191
black_path = shutil.which("black")
167192
if black_path: # pragma: no cover
@@ -176,19 +201,45 @@ def handle(self, *args: Any, app_label: str, **options: Any) -> None:
176201
)
177202

178203

179-
def find_migration_names(max_migration_lines: list[str]) -> tuple[str, str] | None:
180-
lines = max_migration_lines
181-
if len(lines) <= 1:
204+
def find_migration_names(
205+
current_version_migrations: str,
206+
) -> Tuple[List[str], List[str]] | None:
207+
migrations_lines = current_version_migrations.strip().splitlines()
208+
209+
if len(migrations_lines) <= 1:
182210
return None
183-
if not lines[0].startswith("<<<<<<<"):
211+
if not migrations_lines[0].startswith("<<<<<<<"):
184212
return None
185-
if not lines[-1].startswith(">>>>>>>"):
213+
if not migrations_lines[-1].startswith(">>>>>>>"):
186214
return None
187-
migration_names = (lines[1].strip(), lines[-2].strip())
215+
216+
merged_migration_names = []
217+
rebased_migration_names = []
218+
219+
index = 0
220+
while index < len(migrations_lines):
221+
if migrations_lines[index].startswith("<<<<<<<"):
222+
index += 1
223+
while not migrations_lines[index].startswith("======="):
224+
if migrations_lines[index] == "|||||||":
225+
while not migrations_lines[index].startswith("======="):
226+
index += 1
227+
else:
228+
merged_migration_names.append(migrations_lines[index])
229+
index += 1
230+
231+
index += 1
232+
233+
else:
234+
while not migrations_lines[index].startswith(">>>>>>>"):
235+
rebased_migration_names.append(migrations_lines[index])
236+
index += 1
237+
break
238+
188239
if is_merge_in_progress():
189240
# 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
241+
return (rebased_migration_names, merged_migration_names)
242+
return (merged_migration_names, rebased_migration_names)
192243

193244

194245
def is_merge_in_progress() -> bool:

tests/test_checks.py

Lines changed: 1 addition & 12 deletions
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"

0 commit comments

Comments
 (0)