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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +36 -0
- data/lib/mighost/api.rb +15 -2
- data/lib/mighost/configuration.rb +5 -0
- data/lib/mighost/git_recovery.rb +66 -39
- data/lib/mighost/orphan_detector.rb +36 -13
- data/lib/mighost/plain_ui.rb +24 -3
- data/lib/mighost/snapshot.rb +29 -12
- data/lib/mighost/supersession.rb +57 -0
- data/lib/mighost/tasks.rake +4 -0
- data/lib/mighost/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e7ba2817e992323bc0bd578432f76c0e28d8e86dcb7207c7fbb0ab7aa6cb99f
|
|
4
|
+
data.tar.gz: 38a4ca5b14240b3430278c2b60ced6b6ec7c43165f0d2e03105401115c5760c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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?
|
data/lib/mighost/git_recovery.rb
CHANGED
|
@@ -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
|
|
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%
|
|
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\
|
|
59
|
+
# First non-empty line: commit info (sha\tdate\tauthor_name\tauthor_email)
|
|
54
60
|
commit_line = lines[0]
|
|
55
|
-
sha, date_str,
|
|
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:
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
153
|
-
def
|
|
154
|
-
|
|
155
|
-
|
|
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
|
data/lib/mighost/plain_ui.rb
CHANGED
|
@@ -33,12 +33,14 @@ module Mighost
|
|
|
33
33
|
return
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
|
|
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
|
-
|
|
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 =
|
|
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)]
|
data/lib/mighost/snapshot.rb
CHANGED
|
@@ -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
|
data/lib/mighost/tasks.rake
CHANGED
|
@@ -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."
|
data/lib/mighost/version.rb
CHANGED
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
|
+
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
|