mighost 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8aff4752d4638c5822c2e81ff30f7b2fd2b5ddf7f38009cbd24faea539fa1c24
4
- data.tar.gz: 3a8580ffebdc73e87ce3eac7f29c5a7a9b98a83f935925974729b01581436336
3
+ metadata.gz: 4e7ba2817e992323bc0bd578432f76c0e28d8e86dcb7207c7fbb0ab7aa6cb99f
4
+ data.tar.gz: 38a4ca5b14240b3430278c2b60ced6b6ec7c43165f0d2e03105401115c5760c3
5
5
  SHA512:
6
- metadata.gz: 1e9bf5182859b684f2799edfcdd9be3025af0af5416f4748207ce3bb439b6c5252db037c75443388353284f964a6192d8d9ccafe5d241201015dbb757a333e6c
7
- data.tar.gz: 2eb3deee0b4c7083b887187fcce41e7e53252eb12e08b8756a9ec1bbbccac5b142ef64cef9a39ec5996d3f3edb737e31be7bbceab3bb5d701d3b322f1beff401
6
+ metadata.gz: cc1f54500be9da4130310bee9d37f6d6df95e2539ad05308d389dac24732e4de8af4e1c40281c2111f389d06ac9ad4d034836e1be8742174374a47fd1515ad1f
7
+ data.tar.gz: 717e1ca3fc35d784e63ea7b2e00fd571d60dc4d6f84c7487846356e988b463e9d251a6a21b8594f8a9a8ccb3815a4cd831e8aabeb9621a5ce8e3eb32e9c22237
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-07-23
9
+
10
+ ### Added
11
+
12
+ - Superseded-migration classification: a ghost whose migration name suffix is
13
+ owned by another applied version with a file on disk is labeled superseded
14
+ (new `Mighost::Supersession` module, `OrphanedMigration#superseded_by` /
15
+ `#superseded?`, `Mighost::API.superseded_by`, and a dedicated
16
+ "Superseded (safe to forget)" section in `mighost:status`).
17
+ - `config.hide_superseded` (default `false`) to hide superseded orphans from
18
+ detection.
19
+ - Deletion metadata for ghosts gone from every branch tip: `deleted_in_sha`,
20
+ `deleted_at`, `deleted_by` on snapshots and orphans, rendered as
21
+ `deleted in:<sha>` in status output.
22
+
23
+ ### Fixed
24
+
25
+ - Branch attribution no longer guesses: the reported branch is one whose tip
26
+ still has the migration file (preferring current branch → default branch →
27
+ local → remote by recency), instead of the first alphabetical branch that
28
+ merely contains the commit. Remote-only matches keep their `origin/` prefix
29
+ instead of masquerading as local branches.
30
+
8
31
  ## [0.4.0] - 2026-07-08
9
32
 
10
33
  First public release.
data/README.md CHANGED
@@ -14,6 +14,13 @@
14
14
 
15
15
  ---
16
16
 
17
+ > [!TIP]
18
+ > Mighost is the recovery engine behind the ghost-migration features of
19
+ > [**railbow**](https://github.com/amberpixels/railbow), which wraps it in rich, colorful
20
+ > `db:migrate:status` output. If you want beautiful Rails CLI output *and* ghost recovery
21
+ > in one step, you probably want to install railbow directly — it detects mighost and
22
+ > uses it automatically.
23
+
17
24
  ## The Problem
18
25
 
19
26
  When working with Rails migrations across git branches:
@@ -120,9 +127,35 @@ Mighost.configure do |config|
120
127
 
121
128
  # Enable/disable (disabled in production by default)
122
129
  config.enabled = !Rails.env.production?
130
+
131
+ # Hide superseded orphans from status/detection (off by default —
132
+ # supersession is a name-suffix heuristic, and silently dropping rows
133
+ # could mask genuine schema drift)
134
+ config.hide_superseded = false
123
135
  end
124
136
  ```
125
137
 
138
+ ## Superseded migrations
139
+
140
+ Some ghosts aren't lost — they were *re-timestamped*. When a migration ships with a bad
141
+ timestamp and gets renamed to a new version, restoring from a production dump leaves the old
142
+ version row in `schema_migrations` forever, with no file anywhere.
143
+
144
+ Mighost detects this: a ghost whose migration name suffix is owned by another **applied**
145
+ version with a file on disk is classified as **superseded** and listed in its own calmer
146
+ `Superseded (safe to forget)` section of `mighost:status`, showing the superseding version
147
+ instead of the alarming ghost treatment. `OrphanedMigration#superseded_by` /
148
+ `#superseded?` expose the classification programmatically.
149
+
150
+ ## Honest branch attribution
151
+
152
+ When Mighost recovers a ghost from git history, the reported branch is one whose **tip still
153
+ has the file** — preferring the current branch, then the default branch, then local over
154
+ remote branches (remote-only matches keep their `origin/` prefix). If the file is gone from
155
+ every tip, Mighost reports no branch at all and instead records where it went:
156
+ `deleted_in_sha`, `deleted_at`, `deleted_by` — rendered as `deleted in:<sha>` in status
157
+ output. No more pointing you at an unrelated branch that merely contains the commit.
158
+
126
159
  ## Rich Output with Railbow
127
160
 
128
161
  By default, Mighost outputs plain, uncolored text. For rich, colorized output with tables, emojis, and git enrichment, use the [railbow](https://github.com/amberpixels/railbow) gem:
@@ -150,6 +183,9 @@ Mighost::API.scan!(rescan: false) # => {captured:, git_recovere
150
183
 
151
184
  # Rollback
152
185
  Mighost::API.rollback_version!("20240115123456") # raises on failure
186
+
187
+ # Supersession check (read-only)
188
+ Mighost::API.superseded_by("20240115123456") # => superseding version or nil
153
189
  ```
154
190
 
155
191
  ### UI Adapter
data/lib/mighost/api.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "snapshot"
7
7
  require_relative "rollbacker"
8
8
  require_relative "capturer"
9
9
  require_relative "git_recovery"
10
+ require_relative "supersession"
10
11
  require_relative "worktree_recovery"
11
12
  require_relative "git_metadata"
12
13
  require_relative "formatting"
@@ -96,9 +97,12 @@ module Mighost
96
97
  git_sha: git_data[:sha],
97
98
  source: "git",
98
99
  author_name: git_data[:author_name],
99
- author_email: git_data[:author_email]
100
+ author_email: git_data[:author_email],
101
+ deleted_in_sha: git_data[:deleted_in_sha],
102
+ deleted_at: git_data[:deleted_at],
103
+ deleted_by: git_data[:deleted_by]
100
104
  )
101
- details << {type: :git_recovered, filename: git_data[:filename], branch: git_data[:branch]}
105
+ details << {type: :git_recovered, filename: git_data[:filename], branch: git_data[:branch], deleted_in_sha: git_data[:deleted_in_sha]}
102
106
  git_recovered += 1
103
107
  end
104
108
 
@@ -140,6 +144,15 @@ module Mighost
140
144
  }
141
145
  end
142
146
 
147
+ # Returns the superseding version (string) for a ghost, or nil.
148
+ # Read-only: resolves the ghost's filename from its snapshot, no recovery side effects.
149
+ def superseded_by(version)
150
+ snapshot = Snapshot.find_by_version(version)
151
+ return nil unless snapshot&.filename.present?
152
+
153
+ Supersession.find_superseder(version, filename: snapshot.filename)
154
+ end
155
+
143
156
  def dismiss(version)
144
157
  Snapshot.dismiss(version)
145
158
  end
@@ -21,12 +21,17 @@ module Mighost
21
21
  # Threshold for suggesting dismissal of old unrecoverable migrations
22
22
  attr_accessor :dismiss_old_threshold
23
23
 
24
+ # Hide superseded orphans from detection (off by default: supersession is a
25
+ # name-suffix heuristic, and silently dropping rows could mask schema drift)
26
+ attr_accessor :hide_superseded
27
+
24
28
  def initialize
25
29
  @storage_path = ".mighost.sqlite3"
26
30
  @auto_capture = true
27
31
  @store_git_metadata = true
28
32
  @enabled = true
29
33
  @dismiss_old_threshold = 6.months
34
+ @hide_superseded = false
30
35
  end
31
36
 
32
37
  def enabled?
@@ -9,7 +9,9 @@ module Mighost
9
9
  module GitRecovery
10
10
  class << self
11
11
  # Find migration file content from git history
12
- # Returns { content:, filename:, branch:, sha:, commit_date: } or nil
12
+ # Returns { content:, filename:, branch:, sha:, commit_date:, deleted_in_sha:, ... } or nil
13
+ # branch: is a ref whose tip still has the file (remote refs keep their origin/ prefix),
14
+ # or nil when the file is gone from every tip — then the deleted_* fields say where it went.
13
15
  def find_migration(version)
14
16
  return nil unless GitMetadata.git_available?
15
17
 
@@ -22,6 +24,8 @@ module Mighost
22
24
  content = get_file_content(result[:sha], result[:path])
23
25
  return nil unless content
24
26
 
27
+ deletion = result[:branch] ? {} : deletion_metadata(pattern)
28
+
25
29
  {
26
30
  content: content,
27
31
  filename: File.basename(result[:path]),
@@ -29,7 +33,10 @@ module Mighost
29
33
  sha: result[:sha][0, 12],
30
34
  commit_date: result[:date],
31
35
  author_name: result[:author_name],
32
- author_email: result[:author_email]
36
+ author_email: result[:author_email],
37
+ deleted_in_sha: deletion[:deleted_in_sha],
38
+ deleted_at: deletion[:deleted_at],
39
+ deleted_by: deletion[:deleted_by]
33
40
  }
34
41
  end
35
42
 
@@ -37,12 +44,11 @@ module Mighost
37
44
 
38
45
  def find_latest_commit_with_file(pattern)
39
46
  # Use git log --all to search across ALL refs in a single command
40
- # %D shows decorated refs (branches/tags pointing to commit)
41
47
  # --name-only includes the filename in output
42
48
  # --diff-filter=d excludes deletion commits: without it, a migration whose
43
49
  # last touch was a delete resolves to the deleting commit, where the file
44
50
  # no longer exists and `git show` fails
45
- fmt = "%H\t%aI\t%D\t%aN\t%aE"
51
+ fmt = "%H\t%aI\t%aN\t%aE"
46
52
  cmd = "git log --all -1 --diff-filter=d --format='#{fmt}' --name-only -- #{Shellwords.escape(pattern)} 2>/dev/null"
47
53
  output = `#{cmd}`.strip
48
54
  return nil if output.empty?
@@ -50,9 +56,9 @@ module Mighost
50
56
  lines = output.split("\n").map(&:strip).reject(&:empty?)
51
57
  return nil if lines.empty?
52
58
 
53
- # First non-empty line: commit info (sha\tdate\tdecorated_refs\tauthor_name\tauthor_email)
59
+ # First non-empty line: commit info (sha\tdate\tauthor_name\tauthor_email)
54
60
  commit_line = lines[0]
55
- sha, date_str, decorated_refs, author_name, author_email = commit_line.split("\t", 5)
61
+ sha, date_str, author_name, author_email = commit_line.split("\t", 4)
56
62
  return nil unless sha && date_str
57
63
 
58
64
  # Second non-empty line: the file path
@@ -66,56 +72,77 @@ module Mighost
66
72
  end
67
73
  return nil unless date
68
74
 
69
- # Extract branch name from decorated refs (may be empty if commit is not a branch tip)
70
- branch = extract_branch_from_decorated_refs(decorated_refs)
71
-
72
- # If no branch found from decoration, try to find which branch contains this commit
73
- branch ||= find_branch_containing_commit(sha)
74
-
75
75
  {
76
76
  sha: sha,
77
77
  path: file_path,
78
- branch: branch,
78
+ branch: resolve_branch(sha, file_path),
79
79
  date: date,
80
80
  author_name: author_name,
81
81
  author_email: author_email
82
82
  }
83
83
  end
84
84
 
85
- def extract_branch_from_decorated_refs(refs)
86
- return nil if refs.nil? || refs.strip.empty?
85
+ # A branch is only a valid answer if its tip still has the file — a branch
86
+ # that merely contains the commit sends the user somewhere the file isn't.
87
+ def resolve_branch(sha, path)
88
+ locals, remotes = branch_candidates(sha)
89
+ locals = locals.select { |ref| file_at_tip?(ref, path) }
90
+ remotes = remotes.select { |ref| file_at_tip?(ref, path) }
91
+ return nil if locals.empty? && remotes.empty?
87
92
 
88
- # Decorated refs look like: "HEAD -> main, origin/main, feature-branch"
89
- # Extract the first branch-like ref
90
- refs.split(",").each do |ref|
91
- ref = ref.strip
92
- ref = ref.sub(/^HEAD -> /, "")
93
- ref = ref.sub(%r{^origin/}, "")
94
- next if ref.empty? || ref.start_with?("tag:")
93
+ current = GitMetadata.current_branch
94
+ return current if current && locals.include?(current)
95
95
 
96
- return ref
97
- end
98
- nil
96
+ default = default_branch
97
+ return default if default && locals.include?(default)
98
+
99
+ return locals.first unless locals.empty?
100
+
101
+ return "origin/#{default}" if default && remotes.include?("origin/#{default}")
102
+
103
+ remotes.first
99
104
  end
100
105
 
101
- def find_branch_containing_commit(sha)
102
- # Find branches that contain this commit
103
- output = `git branch -a --contains #{sha} 2>/dev/null`.strip
104
- return nil if output.empty?
106
+ # Refs containing the commit, most recently committed first.
107
+ # Returns [local_names, remote_names] remote names keep their origin/ prefix.
108
+ def branch_candidates(sha)
109
+ [contains_refs(sha, "refs/heads"), contains_refs(sha, "refs/remotes")]
110
+ end
105
111
 
106
- # Parse output - each line is a branch name, * = current branch, + = worktree-checked-out
107
- lines = output.split("\n").map { |l| l.sub(/^[*+]?\s*/, "").strip }
108
- return nil if lines.empty?
112
+ def contains_refs(sha, namespace)
113
+ cmd = "git for-each-ref #{namespace} --contains #{Shellwords.escape(sha)} " \
114
+ "--sort=-committerdate --format='%(refname:short)' 2>/dev/null"
115
+ `#{cmd}`.split("\n").map(&:strip).reject { |r| r.empty? || r.end_with?("/HEAD") }
116
+ end
109
117
 
110
- # Prefer local branches over remote, clean up names
111
- lines.each do |branch|
112
- next if branch.start_with?("remotes/")
113
- return branch
118
+ def file_at_tip?(ref, path)
119
+ system("git cat-file -e #{Shellwords.escape("#{ref}:#{path}")} 2>/dev/null")
120
+ end
121
+
122
+ def default_branch
123
+ head = `git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`.strip
124
+ return head.sub(%r{^origin/}, "") unless head.empty?
125
+
126
+ %w[main master].find do |name|
127
+ system("git show-ref --verify --quiet #{Shellwords.escape("refs/heads/#{name}")}")
128
+ end
129
+ end
130
+
131
+ # Where the file was deleted, for ghosts gone from every tip
132
+ def deletion_metadata(pattern)
133
+ fmt = "%H\t%aI\t%aN"
134
+ cmd = "git log --all --diff-filter=D -1 --format='#{fmt}' -- #{Shellwords.escape(pattern)} 2>/dev/null"
135
+ output = `#{cmd}`.strip
136
+ return {} if output.empty?
137
+
138
+ sha, date_str, author = output.split("\t", 3)
139
+ date = begin
140
+ Time.parse(date_str)
141
+ rescue
142
+ nil
114
143
  end
115
144
 
116
- # Fall back to first remote branch, cleaned up
117
- first = lines.first
118
- first&.sub(%r{^remotes/origin/}, "")
145
+ {deleted_in_sha: sha && sha[0, 12], deleted_at: date, deleted_by: author}
119
146
  end
120
147
 
121
148
  def get_file_content(sha, path)
@@ -5,13 +5,16 @@ require "active_support/core_ext/object/blank"
5
5
  require "active_support/core_ext/enumerable"
6
6
  require_relative "snapshot"
7
7
  require_relative "git_metadata"
8
+ require_relative "supersession"
8
9
 
9
10
  module Mighost
10
11
  class OrphanDetector
11
12
  class OrphanedMigration
12
- attr_reader :version, :filename, :branch_name, :git_sha, :migrated_at, :source, :dismissed, :author_name, :author_email
13
+ attr_reader :version, :filename, :branch_name, :git_sha, :migrated_at, :source, :dismissed, :author_name, :author_email,
14
+ :superseded_by, :deleted_in_sha, :deleted_at, :deleted_by
13
15
 
14
- def initialize(version:, filename: nil, branch_name: nil, git_sha: nil, migrated_at: nil, source: nil, dismissed: false, author_name: nil, author_email: nil)
16
+ def initialize(version:, filename: nil, branch_name: nil, git_sha: nil, migrated_at: nil, source: nil, dismissed: false,
17
+ author_name: nil, author_email: nil, superseded_by: nil, deleted_in_sha: nil, deleted_at: nil, deleted_by: nil)
15
18
  @version = version
16
19
  @filename = filename
17
20
  @branch_name = branch_name
@@ -21,6 +24,15 @@ module Mighost
21
24
  @dismissed = dismissed
22
25
  @author_name = author_name
23
26
  @author_email = author_email
27
+ @superseded_by = superseded_by
28
+ @deleted_in_sha = deleted_in_sha
29
+ @deleted_at = deleted_at
30
+ @deleted_by = deleted_by
31
+ end
32
+
33
+ # Schema change lives on under a re-timestamped version — a stale row, not a lost migration
34
+ def superseded?
35
+ !superseded_by.nil?
24
36
  end
25
37
 
26
38
  def recoverable?
@@ -74,9 +86,11 @@ module Mighost
74
86
  def detect(include_dismissed: false)
75
87
  # Load everything upfront for performance
76
88
  versions = migrated_versions
77
- file_versions = migration_file_versions
89
+ disk_files = migration_file_basenames
90
+ file_versions = disk_files.map { |basename| basename.split("_").first }.to_set
78
91
  dismissed_set = Snapshot.dismissed_versions.to_set
79
92
  snapshots_by_version = Snapshot.all_metadata.index_by(&:version)
93
+ hide_superseded = Mighost.configuration.hide_superseded
80
94
 
81
95
  log "Found #{versions.count} migrated versions, #{file_versions.count} files on disk"
82
96
 
@@ -92,6 +106,16 @@ module Mighost
92
106
  snapshot = snapshots_by_version[version]
93
107
  log " #{version} - orphaned#{", has snapshot (#{snapshot.source})" if snapshot}"
94
108
 
109
+ superseded_by = if snapshot&.filename.present?
110
+ Supersession.find_superseder(
111
+ version,
112
+ filename: snapshot.filename,
113
+ applied_versions: versions,
114
+ disk_files: disk_files
115
+ )
116
+ end
117
+ next if superseded_by && hide_superseded
118
+
95
119
  orphaned << OrphanedMigration.new(
96
120
  version: version,
97
121
  filename: snapshot&.filename,
@@ -101,7 +125,11 @@ module Mighost
101
125
  source: snapshot&.source,
102
126
  dismissed: is_dismissed,
103
127
  author_name: snapshot&.author_name,
104
- author_email: snapshot&.author_email
128
+ author_email: snapshot&.author_email,
129
+ superseded_by: superseded_by,
130
+ deleted_in_sha: snapshot&.deleted_in_sha,
131
+ deleted_at: snapshot&.deleted_at,
132
+ deleted_by: snapshot&.deleted_by
105
133
  )
106
134
  end
107
135
 
@@ -149,16 +177,11 @@ module Mighost
149
177
  Mighost::Base.connection.quote_table_name("schema_migrations")
150
178
  end
151
179
 
152
- # Load all migration file versions at once (single glob per path)
153
- def migration_file_versions
154
- versions = Set.new
155
- migration_paths.each do |path|
156
- Dir.glob(File.join(path, "*.rb")).each do |file|
157
- version = File.basename(file).split("_").first
158
- versions << version
159
- end
180
+ # Load all migration file basenames at once (single glob per path)
181
+ def migration_file_basenames
182
+ migration_paths.flat_map do |path|
183
+ Dir.glob(File.join(path, "*.rb")).map { |file| File.basename(file) }
160
184
  end
161
- versions
162
185
  end
163
186
 
164
187
  def migration_paths
@@ -33,12 +33,14 @@ module Mighost
33
33
  return
34
34
  end
35
35
 
36
- actionable, suggested = orphans.partition(&:actionable?)
36
+ superseded, rest = orphans.partition(&:superseded?)
37
+ actionable, suggested = rest.partition(&:actionable?)
37
38
 
38
39
  recoverable_count = orphans.count(&:recoverable?)
39
40
  suggested_count = suggested.count
40
41
 
41
42
  header_parts = ["#{orphans.count} total", "#{recoverable_count} recoverable"]
43
+ header_parts << "#{superseded.count} superseded" if superseded.any?
42
44
  header_parts << "#{suggested_count} suggested to dismiss" if suggested_count.positive?
43
45
  puts "\nOrphaned migrations (#{header_parts.join(", ")}):\n\n"
44
46
 
@@ -50,6 +52,11 @@ module Mighost
50
52
 
51
53
  suggested.each { |o| puts " #{orphan_line(o)}" }
52
54
 
55
+ if superseded.any?
56
+ puts "\n Superseded (safe to forget — schema is owned by a newer version):\n"
57
+ superseded.each { |o| puts " #{orphan_line(o)} superseded by #{o.superseded_by}" }
58
+ end
59
+
53
60
  if suggested_count.positive?
54
61
  puts "\nTip: Run 'rails mighost:dismiss OLD=1' to bulk-dismiss the #{suggested_count} old migrations."
55
62
  end
@@ -108,7 +115,14 @@ module Mighost
108
115
  when :captured
109
116
  info "Captured: #{detail[:filename]}"
110
117
  when :git_recovered
111
- info "Git recovered: #{detail[:filename]} (from #{detail[:branch]})"
118
+ location = if detail[:branch]
119
+ "from #{detail[:branch]}"
120
+ elsif detail[:deleted_in_sha]
121
+ "deleted in #{detail[:deleted_in_sha]}"
122
+ else
123
+ "from history"
124
+ end
125
+ info "Git recovered: #{detail[:filename]} (#{location})"
112
126
  when :worktree_recovered
113
127
  info "Worktree recovered: #{detail[:filename]} (from #{detail[:branch]})"
114
128
  end
@@ -213,7 +227,14 @@ module Mighost
213
227
  version = orphan.version
214
228
  name = format_name(orphan.filename)
215
229
  recovery = orphan.recoverable? ? "recoverable (#{format_recovery(orphan.recovery_source)})" : "not recoverable"
216
- branch = orphan.branch_name ? "branch:#{truncate(orphan.branch_name, 30)}" : ""
230
+ branch = if orphan.branch_name
231
+ "branch:#{truncate(orphan.branch_name, 30)}"
232
+ elsif orphan.deleted_in_sha
233
+ # File is gone from every branch tip — say where it was deleted instead of guessing a branch
234
+ "deleted in:#{orphan.deleted_in_sha}"
235
+ else
236
+ ""
237
+ end
217
238
  author = orphan.author_name ? "author:#{truncate(orphan.author_name, 20)}" : ""
218
239
 
219
240
  parts = [version, name.ljust(42), recovery.ljust(24)]
@@ -8,7 +8,7 @@ require "active_support/core_ext/time"
8
8
  module Mighost
9
9
  class Snapshot
10
10
  attr_accessor :id, :version, :content, :filename, :branch_name, :git_sha, :content_hash, :migrated_at, :source,
11
- :dismissed, :dismissed_at, :author_name, :author_email
11
+ :dismissed, :dismissed_at, :author_name, :author_email, :deleted_in_sha, :deleted_at, :deleted_by
12
12
 
13
13
  # Special version used to store scan metadata
14
14
  SCAN_METADATA_VERSION = "__scan_metadata__"
@@ -37,7 +37,10 @@ module Mighost
37
37
  dismissed INTEGER DEFAULT 0,
38
38
  dismissed_at TEXT,
39
39
  author_name TEXT,
40
- author_email TEXT
40
+ author_email TEXT,
41
+ deleted_in_sha TEXT,
42
+ deleted_at TEXT,
43
+ deleted_by TEXT
41
44
  )
42
45
  SQL
43
46
  db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_#{table_name}_version ON #{table_name}(version)")
@@ -70,7 +73,10 @@ module Mighost
70
73
  git_sha: git_data[:sha],
71
74
  source: "git",
72
75
  author_name: git_data[:author_name],
73
- author_email: git_data[:author_email]
76
+ author_email: git_data[:author_email],
77
+ deleted_in_sha: git_data[:deleted_in_sha],
78
+ deleted_at: git_data[:deleted_at],
79
+ deleted_by: git_data[:deleted_by]
74
80
  )
75
81
  end
76
82
 
@@ -89,23 +95,25 @@ module Mighost
89
95
  )
90
96
  end
91
97
 
92
- def store(version:, content:, filename:, branch_name: nil, git_sha: nil, source: "file", author_name: nil, author_email: nil)
98
+ def store(version:, content:, filename:, branch_name: nil, git_sha: nil, source: "file", author_name: nil, author_email: nil,
99
+ deleted_in_sha: nil, deleted_at: nil, deleted_by: nil)
93
100
  ensure_table_exists!
94
101
  content_hash = Digest::SHA256.hexdigest(content)
95
102
  migrated_at = Time.current.iso8601
103
+ deleted_at = deleted_at.iso8601 if deleted_at.respond_to?(:iso8601)
96
104
 
97
105
  existing = find_by_version(version)
98
106
  if existing
99
- db.execute(<<~SQL, [content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email, version.to_s])
107
+ db.execute(<<~SQL, [content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email, deleted_in_sha, deleted_at, deleted_by, version.to_s])
100
108
  UPDATE #{table_name}
101
109
  SET content = ?, filename = ?, branch_name = ?, git_sha = ?, content_hash = ?, migrated_at = ?, source = ?,
102
- author_name = ?, author_email = ?
110
+ author_name = ?, author_email = ?, deleted_in_sha = ?, deleted_at = ?, deleted_by = ?
103
111
  WHERE version = ?
104
112
  SQL
105
113
  else
106
- db.execute(<<~SQL, [version.to_s, content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email])
107
- INSERT INTO #{table_name} (version, content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email)
108
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
114
+ db.execute(<<~SQL, [version.to_s, content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email, deleted_in_sha, deleted_at, deleted_by])
115
+ INSERT INTO #{table_name} (version, content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email, deleted_in_sha, deleted_at, deleted_by)
116
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
117
  SQL
110
118
  end
111
119
 
@@ -196,9 +204,9 @@ module Mighost
196
204
  # Lightweight version - only metadata, no content (for status display)
197
205
  def all_metadata
198
206
  ensure_table_exists!
199
- cols = %i[version filename branch_name git_sha migrated_at source dismissed author_name author_email]
207
+ cols = %i[version filename branch_name git_sha migrated_at source dismissed author_name author_email deleted_in_sha deleted_at deleted_by]
200
208
  db.execute(
201
- "SELECT version, filename, branch_name, git_sha, migrated_at, source, dismissed, author_name, author_email FROM #{table_name} WHERE version != ?",
209
+ "SELECT version, filename, branch_name, git_sha, migrated_at, source, dismissed, author_name, author_email, deleted_in_sha, deleted_at, deleted_by FROM #{table_name} WHERE version != ?",
202
210
  [SCAN_METADATA_VERSION]
203
211
  ).map do |row|
204
212
  record = new(cols.zip(row).to_h)
@@ -249,9 +257,18 @@ module Mighost
249
257
  unless columns.include?("author_email")
250
258
  db.execute("ALTER TABLE #{table_name} ADD COLUMN author_email TEXT")
251
259
  end
260
+ unless columns.include?("deleted_in_sha")
261
+ db.execute("ALTER TABLE #{table_name} ADD COLUMN deleted_in_sha TEXT")
262
+ end
263
+ unless columns.include?("deleted_at")
264
+ db.execute("ALTER TABLE #{table_name} ADD COLUMN deleted_at TEXT")
265
+ end
266
+ unless columns.include?("deleted_by")
267
+ db.execute("ALTER TABLE #{table_name} ADD COLUMN deleted_by TEXT")
268
+ end
252
269
  end
253
270
 
254
- SNAPSHOT_COLUMNS = %i[id version content filename branch_name git_sha content_hash migrated_at source dismissed dismissed_at author_name author_email].freeze
271
+ SNAPSHOT_COLUMNS = %i[id version content filename branch_name git_sha content_hash migrated_at source dismissed dismissed_at author_name author_email deleted_in_sha deleted_at deleted_by].freeze
255
272
  private_constant :SNAPSHOT_COLUMNS
256
273
 
257
274
  def row_to_snapshot(row)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mighost
4
+ # Detects re-timestamped migrations: a ghost version whose name suffix is owned
5
+ # by another applied version with a file on disk is superseded, not lost.
6
+ module Supersession
7
+ module_function
8
+
9
+ # Returns the superseding version (string) for `version`, or nil.
10
+ # A superseder must be a different version, present in schema_migrations,
11
+ # and own an on-disk migration file with the same name suffix — an applied
12
+ # version whose own file is missing is itself a ghost, not a superseder.
13
+ #
14
+ # applied_versions and disk_files (migration file basenames) are injectable
15
+ # so batch callers like OrphanDetector can reuse data they already loaded.
16
+ def find_superseder(version, filename:, applied_versions: nil, disk_files: nil)
17
+ suffix = name_suffix(filename)
18
+ return nil unless suffix
19
+
20
+ applied = applied_versions || load_applied_versions
21
+ files = disk_files || load_disk_files
22
+
23
+ # Newest candidate wins if several versions share the suffix
24
+ files.sort.reverse_each do |basename|
25
+ candidate = basename.split("_").first
26
+ next if candidate == version.to_s
27
+ next unless applied.include?(candidate)
28
+
29
+ return candidate if name_suffix(basename) == suffix
30
+ end
31
+ nil
32
+ end
33
+
34
+ # "20260702110000_add_utility_belt_to_batcaves.rb" -> "add_utility_belt_to_batcaves"
35
+ def name_suffix(basename)
36
+ return nil if basename.nil? || basename.empty?
37
+
38
+ basename.sub(/\.rb\z/, "").split("_", 2)[1]
39
+ end
40
+
41
+ def load_applied_versions
42
+ Mighost::Base.connection
43
+ .select_values("SELECT version FROM schema_migrations")
44
+ .map(&:to_s)
45
+ end
46
+
47
+ def load_disk_files
48
+ paths = if defined?(Rails) && Rails.application
49
+ Rails.application.config.paths["db/migrate"].to_ary
50
+ else
51
+ ["db/migrate"]
52
+ end
53
+
54
+ paths.flat_map { |path| Dir.glob(File.join(path, "*.rb")).map { |f| File.basename(f) } }
55
+ end
56
+ end
57
+ end
@@ -53,6 +53,10 @@ namespace :mighost do
53
53
  ui.hint "#{dismissed_count} dismissed migration(s) hidden. Use SHOW_DISMISSED=1 to show." if dismissed_count.positive?
54
54
  end
55
55
 
56
+ if Mighost.configuration.hide_superseded
57
+ ui.hint "Superseded migrations are hidden (config.hide_superseded)."
58
+ end
59
+
56
60
  non_recoverable = orphans.reject(&:recoverable?)
57
61
  if non_recoverable.any? && Mighost::OrphanDetector.branch_changed?
58
62
  ui.hint "Branch changed since last scan. Run 'rails mighost:scan' to update recovery info."
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mighost
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mighost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene M
@@ -89,6 +89,7 @@ files:
89
89
  - lib/mighost/railtie.rb
90
90
  - lib/mighost/rollbacker.rb
91
91
  - lib/mighost/snapshot.rb
92
+ - lib/mighost/supersession.rb
92
93
  - lib/mighost/tasks.rake
93
94
  - lib/mighost/version.rb
94
95
  - lib/mighost/worktree_recovery.rb