Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
337 lines
12 KiB
Python
Executable File
337 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import fileinput
|
|
import glob
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
# When we need to backport migrations to a previous release, we
|
|
# sometimes end up with multiple having the same ID number (which
|
|
# isn't a problem; the migrations graph structure, not the IDs, is
|
|
# what matters).
|
|
MIGRATIONS_TO_SKIP = {"0209", "0261", "0501", "0001"}
|
|
|
|
|
|
def validate_order(order: list[int], length: int) -> None:
|
|
if sorted(order) != list(range(1, length + 1)):
|
|
print(f"Expected a permutation of 1..{length}; got: {order}")
|
|
sys.exit(1)
|
|
|
|
|
|
def renumber_one(
|
|
filename: str,
|
|
new_number: int,
|
|
new_predecessor: str,
|
|
migrations_dir: str,
|
|
app_label: str,
|
|
) -> str:
|
|
"""Rewrite the file's in-app dep tuples to point at new_predecessor, then rename it."""
|
|
path = os.path.join(migrations_dir, filename)
|
|
# Match only ("<app_label>", "NNNN_xxx") tuples, not things like
|
|
# `1_000` or cross-app deps. ruff-format always puts `, ` between
|
|
# the two tuple elements, so the replacement hardcodes that.
|
|
dep_pattern = re.compile(rf'"{re.escape(app_label)}"\s*,\s*"\d+(?:_[a-z0-9]+)+"')
|
|
# Don't touch migration-merge files: with two in-app deps in the
|
|
# same file, the substitution would collapse both to the same
|
|
# predecessor and produce a self-referential migration.
|
|
with open(path) as f:
|
|
if len(dep_pattern.findall(f.read())) > 1:
|
|
print(
|
|
f"Refusing to renumber {filename}: it has multiple "
|
|
f"in-app dependencies (a migration merge). Renumber it by hand."
|
|
)
|
|
sys.exit(1)
|
|
replacement = f'"{app_label}", "{new_predecessor}"'
|
|
with fileinput.FileInput(path, inplace=True) as f:
|
|
for line in f:
|
|
print(dep_pattern.sub(replacement, line), end="")
|
|
new_name = f"{new_number:04}{filename[4:]}"
|
|
os.rename(path, os.path.join(migrations_dir, new_name))
|
|
return new_name
|
|
|
|
|
|
def renumber_migration(
|
|
conflicts: list[str],
|
|
order: list[int],
|
|
last_correct_migration: str,
|
|
migrations_dir: str,
|
|
app_label: str,
|
|
) -> list[tuple[str, str]]:
|
|
renames: list[tuple[str, str]] = []
|
|
seen_prefixes: set[str] = set()
|
|
for i in order:
|
|
prefix = conflicts[i - 1][0:4]
|
|
if prefix not in seen_prefixes:
|
|
seen_prefixes.add(prefix)
|
|
continue
|
|
new_number = int(last_correct_migration[0:4]) + 1
|
|
old_name = conflicts[i - 1]
|
|
new_name = renumber_one(
|
|
old_name, new_number, last_correct_migration, migrations_dir, app_label
|
|
)
|
|
renames.append((old_name, new_name))
|
|
last_correct_migration = new_name.removesuffix(".py")
|
|
return renames
|
|
|
|
|
|
def resolve_conflicts_interactive(
|
|
conflicts: list[str], migrations_dir: str, app_label: str
|
|
) -> list[tuple[str, str]]:
|
|
print("Conflicting migrations:")
|
|
for i, name in enumerate(conflicts, 1):
|
|
print(f"{i}. {name}")
|
|
|
|
order_input = input("Enter the order in which these migrations should be arranged: ")
|
|
order = list(map(int, order_input.split()))
|
|
validate_order(order, len(conflicts))
|
|
|
|
last_correct_migration = conflicts[order[0] - 1].removesuffix(".py")
|
|
return renumber_migration(conflicts, order, last_correct_migration, migrations_dir, app_label)
|
|
|
|
|
|
def get_upstream_files(app_label: str) -> set[str] | None:
|
|
"""`NNNN_*.py` migration filenames on @{u}, or None if @{u} is unavailable.
|
|
|
|
Skips non-migration entries such as `__init__.py`.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"git",
|
|
"ls-tree",
|
|
"-r",
|
|
"--name-only",
|
|
"@{u}",
|
|
"--",
|
|
f"{app_label}/migrations/",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
except FileNotFoundError:
|
|
return None
|
|
except subprocess.CalledProcessError:
|
|
print(
|
|
"This branch has no upstream tracking branch (@{u} is not set).\n"
|
|
"Falling back to interactive prompts for each conflict group.\n"
|
|
"\n"
|
|
"For the faster auto-resolve flow, you can exit (Ctrl-C), then run:\n"
|
|
" git branch --set-upstream-to=upstream/main\n"
|
|
"and re-run this script."
|
|
)
|
|
return None
|
|
return {
|
|
basename
|
|
for line in result.stdout.splitlines()
|
|
if re.fullmatch(r"\d{4}_.*\.py", basename := os.path.basename(line))
|
|
}
|
|
|
|
|
|
def find_conflict_groups(files_list: list[str]) -> list[list[str]]:
|
|
"""Return migration filenames grouped by NNNN prefix, only for groups in conflict."""
|
|
groups: dict[str, list[str]] = {}
|
|
for f in files_list:
|
|
groups.setdefault(f[0:4], []).append(f)
|
|
return [
|
|
sorted(group)
|
|
for prefix, group in sorted(groups.items())
|
|
if len(group) > 1 and prefix not in MIGRATIONS_TO_SKIP
|
|
]
|
|
|
|
|
|
def renumber_locals_past_tip(
|
|
files_list: list[str],
|
|
groups: list[list[str]],
|
|
upstream_files: set[str],
|
|
migrations_dir: str,
|
|
app_label: str,
|
|
) -> list[tuple[str, str]]:
|
|
"""Renumber every branch-local migration at-or-past the first conflict
|
|
contiguously past the upstream tip, in NNNN order.
|
|
|
|
The new NNNNs fill in from `upstream_tip + 1` and increment by 1, so
|
|
non-conflicting locals that sit between conflicts and the new tip (and
|
|
would otherwise be left with stale dependencies) get pulled into the
|
|
chain too.
|
|
"""
|
|
min_conflict_nnnn = int(groups[0][0][0:4])
|
|
local_files = sorted(
|
|
(f for f in files_list if f not in upstream_files and int(f[0:4]) >= min_conflict_nnnn),
|
|
key=lambda f: int(f[0:4]),
|
|
)
|
|
|
|
# Use `upstream_files` directly here, not the intersection with the
|
|
# working tree: if the local checkout is missing one of the upstream
|
|
# migrations, we still need the new NNNN to land past where it would
|
|
# come back on the next pull.
|
|
upstream_tip = max(upstream_files, key=lambda f: int(f[0:4]))
|
|
new_predecessor = upstream_tip.removesuffix(".py")
|
|
new_number = int(upstream_tip[0:4]) + 1
|
|
|
|
print(f"Renumbering {len(local_files)} branch-local migration(s) past the tip of the graph:")
|
|
renames: list[tuple[str, str]] = []
|
|
for local_file in local_files:
|
|
new_name = renumber_one(local_file, new_number, new_predecessor, migrations_dir, app_label)
|
|
print(f" {local_file} -> {new_name} (now follows {new_predecessor})")
|
|
renames.append((local_file, new_name))
|
|
new_predecessor = new_name.removesuffix(".py")
|
|
new_number += 1
|
|
return renames
|
|
|
|
|
|
def find_introducing_commit(rel_path: str) -> str | None:
|
|
"""Most recent commit between @{u} and HEAD that added rel_path."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "log", "--diff-filter=A", "--format=%H", "@{u}..HEAD", "--", rel_path],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
return None
|
|
lines = result.stdout.strip().splitlines()
|
|
return lines[0] if lines else None
|
|
|
|
|
|
def has_merge_commits_since_upstream() -> bool:
|
|
"""True if @{u}..HEAD contains any merge commit."""
|
|
result = subprocess.run(
|
|
["git", "rev-list", "--merges", "@{u}..HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return bool(result.stdout.strip())
|
|
|
|
|
|
def squash_renumbers_into_origin(renames: list[tuple[str, str]], migrations_dir: str) -> None:
|
|
"""Build `fixup!` commits for each rename, then rebase to squash them
|
|
into the commits that originally added the migrations."""
|
|
if has_merge_commits_since_upstream():
|
|
# `git rebase -i --autosquash` would silently linearize merge
|
|
# commits, and `--autostash` makes recovery awkward. Bail out
|
|
# and let the user handle the squash by hand.
|
|
print(
|
|
"Refusing to rebase: @{u}..HEAD contains merge commit(s), which "
|
|
"would be linearized. Re-run with --no-rebase, or fold the renumbers "
|
|
"into the appropriate commits by hand."
|
|
)
|
|
sys.exit(1)
|
|
|
|
current_head = subprocess.run(
|
|
["git", "rev-parse", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
print(
|
|
"Starting rebase of renames into their original commits; if you need "
|
|
f"to reset to the old state, HEAD is currently at {current_head}"
|
|
)
|
|
fixups = 0
|
|
for old, new in renames:
|
|
old_path = os.path.join(migrations_dir, old)
|
|
new_path = os.path.join(migrations_dir, new)
|
|
target = find_introducing_commit(old_path)
|
|
if target is None:
|
|
print(f" Leaving {old} -> {new} in your working tree (no in-branch commit added it).")
|
|
continue
|
|
subprocess.run(["git", "add", "--", old_path, new_path], check=True)
|
|
# Pass the paths to `git commit --only` so any unrelated changes
|
|
# the user had already staged don't get folded into the fixup.
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"commit",
|
|
"--quiet",
|
|
f"--fixup={target}",
|
|
"--only",
|
|
"--",
|
|
old_path,
|
|
new_path,
|
|
],
|
|
check=True,
|
|
)
|
|
fixups += 1
|
|
if fixups == 0:
|
|
return
|
|
print(f"Squashing {fixups} renumber(s) into the commits that introduced them...")
|
|
env = {**os.environ, "GIT_SEQUENCE_EDITOR": "true"}
|
|
subprocess.run(
|
|
["git", "rebase", "-i", "--autosquash", "--autostash", "@{u}"],
|
|
check=True,
|
|
env=env,
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Renumber migration files.")
|
|
parser.add_argument(
|
|
"app_label",
|
|
nargs="?",
|
|
default="zerver",
|
|
help="The Django app label to process (e.g. zerver, analytics)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-rebase",
|
|
action="store_true",
|
|
help="Leave the renumbers as uncommitted changes instead of squashing "
|
|
"them into the commits that introduced each local migration.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
migrations_dir = os.path.join(args.app_label, "migrations")
|
|
if not os.path.exists(migrations_dir):
|
|
print(f"Error: Directory '{migrations_dir}' does not exist.")
|
|
sys.exit(1)
|
|
|
|
upstream_files = get_upstream_files(args.app_label)
|
|
renames: list[tuple[str, str]] = []
|
|
|
|
if upstream_files is None:
|
|
# @{u} can't be resolved: fall back to one prompt per conflict group.
|
|
while True:
|
|
files_list = sorted(
|
|
os.path.basename(path)
|
|
for path in glob.glob(os.path.join(migrations_dir, "????_*.py"))
|
|
)
|
|
groups = find_conflict_groups(files_list)
|
|
if not groups:
|
|
break
|
|
renames.extend(resolve_conflicts_interactive(groups[0], migrations_dir, args.app_label))
|
|
else:
|
|
files_list = sorted(
|
|
os.path.basename(path) for path in glob.glob(os.path.join(migrations_dir, "????_*.py"))
|
|
)
|
|
groups = find_conflict_groups(files_list)
|
|
if groups:
|
|
# Each conflict must have exactly one branch-local file; 0 means
|
|
# an all-upstream backport that belongs in MIGRATIONS_TO_SKIP, and
|
|
# 2+ means the user added the same NNNN twice locally and we
|
|
# can't pick a relative order for them.
|
|
for group in groups:
|
|
locals_in_group = [f for f in group if f not in upstream_files]
|
|
if len(locals_in_group) != 1:
|
|
label = (
|
|
"no local files (backport?)"
|
|
if not locals_in_group
|
|
else "multiple local files (order ambiguous)"
|
|
)
|
|
print(f"Conflict at {group[0][0:4]}: {label}; resolve by hand.")
|
|
sys.exit(1)
|
|
renames = renumber_locals_past_tip(
|
|
files_list, groups, upstream_files, migrations_dir, args.app_label
|
|
)
|
|
|
|
print("All conflicts resolved")
|
|
if renames and not args.no_rebase:
|
|
squash_renumbers_into_origin(renames, migrations_dir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|