migrate --stop-before an already-applied migration is now an error

The CLI validated --stop-before names against both pending and applied
migrations, but Migrations.apply() only looked for the stop name among
pending ones - naming an applied migration passed validation and then
silently applied every migration after it, the exact outcome the option
exists to prevent. apply() now raises ValueError before applying
anything if a stop_before name matches an applied migration in its set;
names not in the set are still ignored, since unqualified CLI values are
offered to every set. The migrate command reports it as a clean error.

Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-07-06 21:50:05 -07:00
commit b3aa3f47b7
6 changed files with 79 additions and 3 deletions

View file

@ -3462,7 +3462,10 @@ def migrate(db_path, migrations, stop_before, list_, verbose):
for migration_set in migration_sets:
matches = _stop_before_for_migration_set(stop_before, migration_set.name)
if isinstance(migration_set, sqlite_utils.Migrations):
migration_set.apply(db, stop_before=matches)
try:
migration_set.apply(db, stop_before=matches)
except ValueError as e:
raise click.ClickException(str(e))
else:
# Legacy sqlite-migrate Migrations objects take a single string
# for stop_before, not a list

View file

@ -96,14 +96,34 @@ class Migrations:
changes are rolled back, no record is written and the migration stays
pending. Migrations registered with ``transactional=False`` run
outside of a transaction.
:raises ValueError: if a ``stop_before`` name matches a migration in
this set that has already been applied - stopping before it is
impossible to honor, and no pending migrations are applied
"""
self.ensure_migrations_table(db)
if stop_before is None:
stop_before_names = set()
elif isinstance(stop_before, str):
stop_before_names = {stop_before}
else:
stop_before_names = set(stop_before)
# A stop_before naming an already-applied migration cannot be
# honored - error rather than applying everything after it. Names
# not in this set at all are ignored, because unqualified CLI
# values are offered to every migration set
already_applied = stop_before_names.intersection(
migration.name for migration in self.applied(db)
)
if already_applied:
raise ValueError(
"Cannot stop before migration{} {} in set '{}' - already "
"been applied".format(
"s" if len(already_applied) > 1 else "",
", ".join(sorted(already_applied)),
self.name,
)
)
self.ensure_migrations_table(db)
for migration in self.pending(db):
name = migration.name
if name in stop_before_names: