@@ -539,3 +539,109 @@ Run 'regresql snapshot build --schema=%s' to rebuild the snapshot`,
539539
540540 return nil
541541}
542+
543+ func ValidateMigrationsHash (root string ) error {
544+ snapshotsDir := GetSnapshotsDir (root )
545+
546+ metadata , err := ReadSnapshotMetadata (snapshotsDir )
547+ if err != nil {
548+ return nil // No metadata - already warned by ValidateSchemaHash
549+ }
550+
551+ info := metadata .Current
552+ if info == nil || info .MigrationsDir == "" {
553+ return nil
554+ }
555+
556+ if _ , err := os .Stat (info .MigrationsDir ); os .IsNotExist (err ) {
557+ return nil // Stale metadata - directory no longer exists
558+ }
559+
560+ currentFiles , err := discoverMigrations (info .MigrationsDir )
561+ if err != nil {
562+ return fmt .Errorf ("failed to discover migrations in %s: %w" , info .MigrationsDir , err )
563+ }
564+
565+ if len (currentFiles ) == 0 && info .MigrationsHash == "" {
566+ return nil // No migrations before, none now
567+ }
568+
569+ if len (currentFiles ) == 0 {
570+ return migrationChangeError (info , "" , nil , info .MigrationsApplied )
571+ }
572+
573+ currentHash , err := computeMigrationsHash (currentFiles )
574+ if err != nil {
575+ return fmt .Errorf ("failed to hash migrations: %w" , err )
576+ }
577+
578+ if currentHash == info .MigrationsHash {
579+ return nil
580+ }
581+
582+ // Detect what changed
583+ currentNames := make ([]string , len (currentFiles ))
584+ for i , f := range currentFiles {
585+ currentNames [i ] = filepath .Base (f )
586+ }
587+
588+ return migrationChangeError (info , currentHash , currentNames , info .MigrationsApplied )
589+ }
590+
591+ func migrationChangeError (info * SnapshotInfo , currentHash string , current , stored []string ) error {
592+ currentSet := make (map [string ]bool )
593+ for _ , name := range current {
594+ currentSet [name ] = true
595+ }
596+ storedSet := make (map [string ]bool )
597+ for _ , name := range stored {
598+ storedSet [name ] = true
599+ }
600+
601+ var added , removed []string
602+ for _ , name := range current {
603+ if ! storedSet [name ] {
604+ added = append (added , name )
605+ }
606+ }
607+ for _ , name := range stored {
608+ if ! currentSet [name ] {
609+ removed = append (removed , name )
610+ }
611+ }
612+
613+ var changes strings.Builder
614+ changes .WriteString ("\n Changes detected:" )
615+ if len (added ) == 0 && len (removed ) == 0 {
616+ changes .WriteString ("\n ~ content modified" )
617+ }
618+ for _ , name := range added {
619+ changes .WriteString ("\n + " )
620+ changes .WriteString (name )
621+ }
622+ for _ , name := range removed {
623+ changes .WriteString ("\n - " )
624+ changes .WriteString (name )
625+ }
626+
627+ expectedHash := info .MigrationsHash
628+ if expectedHash != "" {
629+ expectedHash = expectedHash [:20 ] + "..."
630+ } else {
631+ expectedHash = "(none)"
632+ }
633+ if currentHash != "" {
634+ currentHash = currentHash [:20 ] + "..."
635+ } else {
636+ currentHash = "(empty)"
637+ }
638+
639+ return fmt .Errorf (`migrations have changed since last snapshot build
640+
641+ Migrations dir: %s
642+ Expected hash: %s
643+ Current hash: %s%s
644+
645+ Run 'regresql snapshot build --migrations=%s' to rebuild the snapshot` ,
646+ info .MigrationsDir , expectedHash , currentHash , changes .String (), info .MigrationsDir )
647+ }
0 commit comments