mighost 0.4.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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +189 -0
- data/lib/generators/mighost/install_generator.rb +22 -0
- data/lib/generators/mighost/templates/initializer.rb.tt +15 -0
- data/lib/mighost/api.rb +155 -0
- data/lib/mighost/capturer.rb +59 -0
- data/lib/mighost/configuration.rb +36 -0
- data/lib/mighost/formatting.rb +89 -0
- data/lib/mighost/git_metadata.rb +31 -0
- data/lib/mighost/git_recovery.rb +129 -0
- data/lib/mighost/migrator_extension.rb +30 -0
- data/lib/mighost/orphan_detector.rb +173 -0
- data/lib/mighost/plain_ui.rb +225 -0
- data/lib/mighost/railtie.rb +27 -0
- data/lib/mighost/rollbacker.rb +113 -0
- data/lib/mighost/snapshot.rb +274 -0
- data/lib/mighost/tasks.rake +300 -0
- data/lib/mighost/version.rb +5 -0
- data/lib/mighost/worktree_recovery.rb +102 -0
- data/lib/mighost.rb +53 -0
- metadata +121 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8aff4752d4638c5822c2e81ff30f7b2fd2b5ddf7f38009cbd24faea539fa1c24
|
|
4
|
+
data.tar.gz: 3a8580ffebdc73e87ce3eac7f29c5a7a9b98a83f935925974729b01581436336
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1e9bf5182859b684f2799edfcdd9be3025af0af5416f4748207ce3bb439b6c5252db037c75443388353284f964a6192d8d9ccafe5d241201015dbb757a333e6c
|
|
7
|
+
data.tar.gz: 2eb3deee0b4c7083b887187fcce41e7e53252eb12e08b8756a9ec1bbbccac5b142ef64cef9a39ec5996d3f3edb737e31be7bbceab3bb5d701d3b322f1beff401
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.4.0] - 2026-07-08
|
|
9
|
+
|
|
10
|
+
First public release.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Ghost migrations whose last touch in git history was a deletion are now
|
|
15
|
+
recoverable: recovery resolves to the last commit where the file existed
|
|
16
|
+
instead of the deleting commit.
|
|
17
|
+
- `Mighost::GitMetadata.reset!` no longer permanently disables git detection.
|
|
18
|
+
- Worktree paths containing spaces or shell metacharacters are handled correctly.
|
|
19
|
+
- Rollback now fails loudly when the migration class cannot be determined from
|
|
20
|
+
the recovered file, instead of guessing among loaded migration classes.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- CI matrix (Ruby 3.1–3.4 × Rails 7.1–8.1), packaging cleanup, and expanded
|
|
25
|
+
test coverage in preparation for the public release.
|
|
26
|
+
|
|
27
|
+
## [0.3.0] - 2026-04-06 (internal)
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Automatic capture of migration file contents on `db:migrate` into a local
|
|
32
|
+
SQLite store (`.mighost.sqlite3`).
|
|
33
|
+
- 3-tier recovery for orphaned ("ghost") migrations: stored snapshots,
|
|
34
|
+
git history across all branches, and other git worktrees.
|
|
35
|
+
- Rake tasks: `mighost:status`, `mighost:rollback`, `mighost:scan`,
|
|
36
|
+
`mighost:list`, `mighost:dismiss`, `mighost:undismiss`, `mighost:dismissed`,
|
|
37
|
+
`mighost:cleanup`, `mighost:help`.
|
|
38
|
+
- Version range syntax for dismiss/undismiss (`VERSION=a..b`, `a..`, `..b`).
|
|
39
|
+
- Stable programmatic interface via `Mighost::API` (used by
|
|
40
|
+
[railbow](https://github.com/amberpixels/railbow)).
|
|
41
|
+
- Pluggable UI adapter (`Mighost.ui`) with a zero-dependency `PlainUI` default.
|
|
42
|
+
- Git metadata capture: branch, SHA, and author per migration.
|
|
43
|
+
- Rails 6.1–8.1 support.
|
|
44
|
+
|
|
45
|
+
## [0.2.0] - 2026-01-23 (internal)
|
|
46
|
+
|
|
47
|
+
- Git history recovery, dismiss/undismiss workflow, performance improvements.
|
|
48
|
+
|
|
49
|
+
## [0.1.0] - 2026-01-21 (internal)
|
|
50
|
+
|
|
51
|
+
- Initial prototype: snapshot capture and rollback of missing migrations.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 amberpixels (Eugene M)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="logo.svg" alt="Mighost" width="620">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<div align="center">
|
|
6
|
+
|
|
7
|
+
### Rollback Rails migrations even when the migration file is missing.
|
|
8
|
+
|
|
9
|
+
[](https://rubygems.org/gems/mighost)
|
|
10
|
+
[](https://github.com/amberpixels/mighost/actions/workflows/ci.yml)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## The Problem
|
|
18
|
+
|
|
19
|
+
When working with Rails migrations across git branches:
|
|
20
|
+
|
|
21
|
+
1. You run migrations on a feature branch
|
|
22
|
+
2. Switch back to `main`
|
|
23
|
+
3. Run `db:migrate` - Rails dumps schema including changes from the other branch
|
|
24
|
+
4. You can't rollback because the migration files don't exist in current branch
|
|
25
|
+
|
|
26
|
+
**Usual workaround**: Switch to the other branch, rollback, switch back. Tedious.
|
|
27
|
+
|
|
28
|
+
## The Solution
|
|
29
|
+
|
|
30
|
+
Mighost automatically captures migration file contents when they run, enabling rollback even when the original file is not present. It uses a 3-tier recovery strategy:
|
|
31
|
+
|
|
32
|
+
1. **Stored snapshots** — captured at migration time into a local SQLite file
|
|
33
|
+
2. **Git history** — searches across all branches
|
|
34
|
+
3. **Worktree recovery** — finds files in other git worktrees
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Add to your Gemfile:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem 'mighost', group: [:development, :test]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then run:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bundle install
|
|
48
|
+
rails generate mighost:install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Add `.mighost.sqlite3` to your `.gitignore`.
|
|
52
|
+
|
|
53
|
+
Requires Ruby >= 3.1 and Rails 6.1–8.1.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### Check for orphaned migrations
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
rails mighost:status
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Rollback orphaned migrations
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Interactive (with confirmation)
|
|
67
|
+
rails mighost:rollback
|
|
68
|
+
|
|
69
|
+
# Force (no confirmation)
|
|
70
|
+
FORCE=1 rails mighost:rollback
|
|
71
|
+
|
|
72
|
+
# Specific version
|
|
73
|
+
rails mighost:rollback VERSION=20240115123456
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Scan existing migrations & git history
|
|
77
|
+
|
|
78
|
+
If you install Mighost on an existing project:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
rails mighost:scan
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### All commands
|
|
85
|
+
|
|
86
|
+
| Command | Description | Options |
|
|
87
|
+
|---------|-------------|---------|
|
|
88
|
+
| `mighost:status` | Show orphaned migrations | `DEBUG=1`, `SHOW_DISMISSED=1` |
|
|
89
|
+
| `mighost:rollback` | Rollback orphaned migrations | `VERSION=x`, `FORCE=1` |
|
|
90
|
+
| `mighost:scan` | Capture migrations + search git | `RESCAN=1` |
|
|
91
|
+
| `mighost:list` (`ls`) | List stored migration records | `LIMIT=N` |
|
|
92
|
+
| `mighost:dismiss` | Hide migrations from status | `VERSION=x`, `VERSION=a..b`, `OLD=1` |
|
|
93
|
+
| `mighost:undismiss` | Restore dismissed migrations | `VERSION=x` |
|
|
94
|
+
| `mighost:dismissed` | List dismissed migrations | |
|
|
95
|
+
| `mighost:cleanup` | Remove stale records | `FORCE=1` |
|
|
96
|
+
| `mighost:help` | Show command reference | |
|
|
97
|
+
|
|
98
|
+
### Version ranges
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
VERSION=20240115123456 # Single version
|
|
102
|
+
VERSION=20240110..20240120 # Inclusive range
|
|
103
|
+
VERSION=20240110.. # From version onwards
|
|
104
|
+
VERSION=..20240120 # Up to version
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
# config/initializers/mighost.rb
|
|
111
|
+
Mighost.configure do |config|
|
|
112
|
+
# Path to SQLite file (relative to Rails.root)
|
|
113
|
+
config.storage_path = ".mighost.sqlite3"
|
|
114
|
+
|
|
115
|
+
# Auto-capture on migrate
|
|
116
|
+
config.auto_capture = true
|
|
117
|
+
|
|
118
|
+
# Store git metadata
|
|
119
|
+
config.store_git_metadata = true
|
|
120
|
+
|
|
121
|
+
# Enable/disable (disabled in production by default)
|
|
122
|
+
config.enabled = !Rails.env.production?
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Rich Output with Railbow
|
|
127
|
+
|
|
128
|
+
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:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
gem 'railbow'
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Railbow automatically detects Mighost and enhances its output.
|
|
135
|
+
|
|
136
|
+
## Public API
|
|
137
|
+
|
|
138
|
+
Mighost exposes a stable API for programmatic access:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
# Detect orphaned migrations
|
|
142
|
+
Mighost::API.orphaned_migrations # => [OrphanedMigration, ...]
|
|
143
|
+
|
|
144
|
+
# Find snapshots
|
|
145
|
+
Mighost::API.find_snapshot("20240115123456") # => Snapshot or nil
|
|
146
|
+
Mighost::API.find_or_recover_snapshot("20240115") # => Snapshot (with git/worktree fallback)
|
|
147
|
+
|
|
148
|
+
# Scan and recover
|
|
149
|
+
Mighost::API.scan!(rescan: false) # => {captured:, git_recovered:, ...}
|
|
150
|
+
|
|
151
|
+
# Rollback
|
|
152
|
+
Mighost::API.rollback_version!("20240115123456") # raises on failure
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### UI Adapter
|
|
156
|
+
|
|
157
|
+
Mighost uses a pluggable UI adapter for output. Replace it to customize formatting:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# In your railtie or initializer:
|
|
161
|
+
Mighost.ui = YourCustomUI.new
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The adapter must implement: `render_orphans`, `render_records`, `render_recoverable_list`, `render_scan_details`, `render_scan_results`, `render_dismiss_preview`, `render_dismiss_remaining`, `render_dismissed_list`, `confirm?`, `confirm_each?`, `confirm_dismiss?`, `success`, `error`, `warning`, `info`, `hint`. See [`Mighost::PlainUI`](lib/mighost/plain_ui.rb) for the reference implementation.
|
|
165
|
+
|
|
166
|
+
## How It Works
|
|
167
|
+
|
|
168
|
+
1. **Capture**: When you run `db:migrate`, Mighost hooks into `ActiveRecord::Migrator` and stores the migration file content along with git metadata (branch, SHA) in a local SQLite file.
|
|
169
|
+
|
|
170
|
+
2. **Detect**: `mighost:status` compares `schema_migrations` table with actual migration files to find orphans.
|
|
171
|
+
|
|
172
|
+
3. **Recover**: For missing files, Mighost searches stored snapshots, git history (all branches), and other git worktrees.
|
|
173
|
+
|
|
174
|
+
4. **Rollback**: Writes recovered content to a temp file, loads it, calls the `down` method, then removes the version from `schema_migrations`.
|
|
175
|
+
|
|
176
|
+
## Limitations
|
|
177
|
+
|
|
178
|
+
- **Irreversible migrations**: If the migration raises `ActiveRecord::IrreversibleMigration`, Mighost can't help.
|
|
179
|
+
- **Model dependencies**: If your migration references models/classes that don't exist in the current branch, the rollback may fail.
|
|
180
|
+
- **Manual SQL changes**: If someone manually modified the database, rollback might not work correctly.
|
|
181
|
+
- **Trust**: Recovered migration code (from snapshots, git history, or worktrees) is executed with the same trust as your own migrations — it is code from your repository, run by you.
|
|
182
|
+
|
|
183
|
+
## Contributing
|
|
184
|
+
|
|
185
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/amberpixels/mighost/issues). Run `bundle exec rake` (specs + standardrb) before submitting.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Mighost
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates a Mighost initializer"
|
|
11
|
+
|
|
12
|
+
def create_initializer
|
|
13
|
+
template "initializer.rb.tt", "config/initializers/mighost.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show_instructions
|
|
17
|
+
say "\nMighost configured (local SQLite storage).", :green
|
|
18
|
+
say "Add '.mighost.sqlite3' to your .gitignore"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Mighost.configure do |config|
|
|
4
|
+
# Path to SQLite file (relative to Rails.root)
|
|
5
|
+
# config.storage_path = ".mighost.sqlite3"
|
|
6
|
+
|
|
7
|
+
# Auto-capture migrations when they run
|
|
8
|
+
config.auto_capture = true
|
|
9
|
+
|
|
10
|
+
# Store git metadata (branch name, commit SHA)
|
|
11
|
+
config.store_git_metadata = true
|
|
12
|
+
|
|
13
|
+
# Disable in production
|
|
14
|
+
config.enabled = !Rails.env.production?
|
|
15
|
+
end
|
data/lib/mighost/api.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
require_relative "orphan_detector"
|
|
6
|
+
require_relative "snapshot"
|
|
7
|
+
require_relative "rollbacker"
|
|
8
|
+
require_relative "capturer"
|
|
9
|
+
require_relative "git_recovery"
|
|
10
|
+
require_relative "worktree_recovery"
|
|
11
|
+
require_relative "git_metadata"
|
|
12
|
+
require_relative "formatting"
|
|
13
|
+
|
|
14
|
+
module Mighost
|
|
15
|
+
# Public API for programmatic access.
|
|
16
|
+
# This is the stable interface for external consumers (e.g. railbow).
|
|
17
|
+
module API
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Returns [OrphanedMigration]
|
|
21
|
+
def orphaned_migrations(include_dismissed: false)
|
|
22
|
+
OrphanDetector.detect(include_dismissed: include_dismissed)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns Snapshot or nil
|
|
26
|
+
def find_snapshot(version)
|
|
27
|
+
Snapshot.find_by_version(version)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns Snapshot (with git/worktree fallback) or nil
|
|
31
|
+
def find_or_recover_snapshot(version)
|
|
32
|
+
Snapshot.find_or_recover(version)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns [Snapshot]
|
|
36
|
+
def all_snapshots(limit: nil)
|
|
37
|
+
records = Snapshot.all_snapshots
|
|
38
|
+
limit&.positive? ? records.first(limit) : records
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Executes rollback. Raises SnapshotNotFound or RollbackFailed on failure.
|
|
42
|
+
def rollback_version!(version)
|
|
43
|
+
Rollbacker.rollback_version!(version)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Scan: capture files, search git, search worktrees.
|
|
47
|
+
# Returns {captured:, git_recovered:, worktree_recovered:, branch:, sha:, details:[]}
|
|
48
|
+
def scan!(rescan: false)
|
|
49
|
+
migration_paths = if defined?(Rails) && Rails.application
|
|
50
|
+
Rails.application.config.paths["db/migrate"].to_ary
|
|
51
|
+
else
|
|
52
|
+
["db/migrate"]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
migrated_versions = Mighost::Base.connection
|
|
56
|
+
.select_values("SELECT version FROM schema_migrations")
|
|
57
|
+
.map(&:to_s)
|
|
58
|
+
|
|
59
|
+
captured = 0
|
|
60
|
+
git_recovered = 0
|
|
61
|
+
worktree_recovered = 0
|
|
62
|
+
details = []
|
|
63
|
+
|
|
64
|
+
# Phase 1: Capture existing migration files
|
|
65
|
+
migration_paths.each do |path|
|
|
66
|
+
Dir.glob(File.join(path, "*.rb")).each do |file|
|
|
67
|
+
basename = File.basename(file)
|
|
68
|
+
version = basename.split("_").first
|
|
69
|
+
|
|
70
|
+
next unless migrated_versions.include?(version)
|
|
71
|
+
next if !rescan && Snapshot.find_by_version(version)
|
|
72
|
+
|
|
73
|
+
Capturer.new(version, file).capture
|
|
74
|
+
details << {type: :captured, filename: basename}
|
|
75
|
+
captured += 1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Phase 2: Search git for orphaned migrations
|
|
80
|
+
orphan_versions = migrated_versions.reject do |version|
|
|
81
|
+
migration_paths.any? { |p| Dir.glob(File.join(p, "#{version}_*.rb")).any? }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
orphan_versions.each do |version|
|
|
85
|
+
existing = Snapshot.find_by_version(version)
|
|
86
|
+
next if !rescan && existing&.source.present?
|
|
87
|
+
|
|
88
|
+
git_data = GitRecovery.find_migration(version)
|
|
89
|
+
next unless git_data
|
|
90
|
+
|
|
91
|
+
Snapshot.store(
|
|
92
|
+
version: version,
|
|
93
|
+
content: git_data[:content],
|
|
94
|
+
filename: git_data[:filename],
|
|
95
|
+
branch_name: git_data[:branch],
|
|
96
|
+
git_sha: git_data[:sha],
|
|
97
|
+
source: "git",
|
|
98
|
+
author_name: git_data[:author_name],
|
|
99
|
+
author_email: git_data[:author_email]
|
|
100
|
+
)
|
|
101
|
+
details << {type: :git_recovered, filename: git_data[:filename], branch: git_data[:branch]}
|
|
102
|
+
git_recovered += 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Phase 3: Search worktrees for uncommitted migrations
|
|
106
|
+
remaining_orphans = orphan_versions.reject do |version|
|
|
107
|
+
existing = Snapshot.find_by_version(version)
|
|
108
|
+
!rescan && existing&.source.present?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
remaining_orphans.each do |version|
|
|
112
|
+
wt_data = WorktreeRecovery.find_migration(version)
|
|
113
|
+
next unless wt_data
|
|
114
|
+
|
|
115
|
+
Snapshot.store(
|
|
116
|
+
version: version,
|
|
117
|
+
content: wt_data[:content],
|
|
118
|
+
filename: wt_data[:filename],
|
|
119
|
+
branch_name: wt_data[:branch],
|
|
120
|
+
source: "worktree",
|
|
121
|
+
author_name: wt_data[:author_name],
|
|
122
|
+
author_email: wt_data[:author_email]
|
|
123
|
+
)
|
|
124
|
+
details << {type: :worktree_recovered, filename: wt_data[:filename], branch: wt_data[:branch]}
|
|
125
|
+
worktree_recovered += 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Store scan metadata
|
|
129
|
+
branch = GitMetadata.current_branch
|
|
130
|
+
sha = GitMetadata.current_sha
|
|
131
|
+
Snapshot.store_scan_metadata(branch: branch, sha: sha)
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
captured: captured,
|
|
135
|
+
git_recovered: git_recovered,
|
|
136
|
+
worktree_recovered: worktree_recovered,
|
|
137
|
+
branch: branch,
|
|
138
|
+
sha: sha,
|
|
139
|
+
details: details
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def dismiss(version)
|
|
144
|
+
Snapshot.dismiss(version)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def undismiss(version)
|
|
148
|
+
Snapshot.undismiss(version)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def dismissed_count
|
|
152
|
+
Snapshot.dismissed_versions.count
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require_relative "snapshot"
|
|
5
|
+
require_relative "git_metadata"
|
|
6
|
+
|
|
7
|
+
module Mighost
|
|
8
|
+
class Capturer
|
|
9
|
+
attr_reader :version, :migration_path
|
|
10
|
+
|
|
11
|
+
def initialize(version, migration_path)
|
|
12
|
+
@version = version.to_s
|
|
13
|
+
@migration_path = migration_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def capture
|
|
17
|
+
return unless Mighost.enabled?
|
|
18
|
+
return unless Mighost.configuration.auto_capture
|
|
19
|
+
return unless File.exist?(migration_path)
|
|
20
|
+
|
|
21
|
+
content = File.read(migration_path)
|
|
22
|
+
filename = File.basename(migration_path)
|
|
23
|
+
|
|
24
|
+
attributes = {
|
|
25
|
+
version: version,
|
|
26
|
+
content: content,
|
|
27
|
+
filename: filename
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if Mighost.configuration.store_git_metadata
|
|
31
|
+
attributes[:branch_name] = GitMetadata.current_branch
|
|
32
|
+
attributes[:git_sha] = GitMetadata.current_sha
|
|
33
|
+
|
|
34
|
+
author_name, author_email = git_author_of(migration_path)
|
|
35
|
+
attributes[:author_name] = author_name
|
|
36
|
+
attributes[:author_email] = author_email
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Snapshot.store(**attributes)
|
|
40
|
+
rescue => e
|
|
41
|
+
# Don't break migrations if snapshot capture fails
|
|
42
|
+
warn "[Mighost] Failed to capture snapshot for #{version}: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def git_author_of(filepath)
|
|
48
|
+
return [nil, nil] unless GitMetadata.git_available?
|
|
49
|
+
|
|
50
|
+
escaped = Shellwords.shellescape(filepath)
|
|
51
|
+
output = `git log --format='%aN\t%aE' --follow -1 -- #{escaped} 2>/dev/null`.strip
|
|
52
|
+
return [nil, nil] if output.empty?
|
|
53
|
+
|
|
54
|
+
output.split("\t", 2)
|
|
55
|
+
rescue
|
|
56
|
+
[nil, nil]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# active_support must load before any core_ext cherry-pick (AS 7.1 requirement)
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/core_ext/integer/time"
|
|
6
|
+
|
|
7
|
+
module Mighost
|
|
8
|
+
class Configuration
|
|
9
|
+
# Path to SQLite file (relative to Rails.root)
|
|
10
|
+
attr_accessor :storage_path
|
|
11
|
+
|
|
12
|
+
# Auto-capture migrations on db:migrate
|
|
13
|
+
attr_accessor :auto_capture
|
|
14
|
+
|
|
15
|
+
# Store git metadata (branch, SHA)
|
|
16
|
+
attr_accessor :store_git_metadata
|
|
17
|
+
|
|
18
|
+
# Enabled environments (disabled in production by default)
|
|
19
|
+
attr_accessor :enabled
|
|
20
|
+
|
|
21
|
+
# Threshold for suggesting dismissal of old unrecoverable migrations
|
|
22
|
+
attr_accessor :dismiss_old_threshold
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@storage_path = ".mighost.sqlite3"
|
|
26
|
+
@auto_capture = true
|
|
27
|
+
@store_git_metadata = true
|
|
28
|
+
@enabled = true
|
|
29
|
+
@dismiss_old_threshold = 6.months
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled?
|
|
33
|
+
@enabled
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mighost
|
|
4
|
+
module Formatting
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def format_version(version)
|
|
8
|
+
if version =~ /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/
|
|
9
|
+
"#{$1}-#{$2}-#{$3} #{$4}:#{$5}"
|
|
10
|
+
else
|
|
11
|
+
version.to_s
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format_name(filename)
|
|
16
|
+
return "(unknown)" unless filename
|
|
17
|
+
|
|
18
|
+
name = filename.sub(/^\d+_/, "").sub(/\.rb$/, "")
|
|
19
|
+
truncate(name, 40)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_recovery(source)
|
|
23
|
+
case source
|
|
24
|
+
when :snapshot then "snapshot"
|
|
25
|
+
when :branch then "git"
|
|
26
|
+
when :worktree then "worktree"
|
|
27
|
+
else "-"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def format_captured_at(value)
|
|
32
|
+
case value
|
|
33
|
+
when String then value[0, 10]
|
|
34
|
+
when Time then value.strftime("%Y-%m-%d")
|
|
35
|
+
else "-"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_age(created_at)
|
|
40
|
+
return "-" unless created_at
|
|
41
|
+
|
|
42
|
+
seconds = Time.now - created_at
|
|
43
|
+
return "-" if seconds < 0
|
|
44
|
+
|
|
45
|
+
days = (seconds / 86400).to_i
|
|
46
|
+
months = (days / 30.0).to_i
|
|
47
|
+
years = (days / 365.0).to_i
|
|
48
|
+
|
|
49
|
+
if years >= 1
|
|
50
|
+
remaining_months = ((days - years * 365) / 30.0).to_i
|
|
51
|
+
if remaining_months.positive?
|
|
52
|
+
"#{years}y #{remaining_months}mo"
|
|
53
|
+
else
|
|
54
|
+
"#{years}y"
|
|
55
|
+
end
|
|
56
|
+
elsif months >= 1
|
|
57
|
+
"#{months}mo"
|
|
58
|
+
elsif days >= 1
|
|
59
|
+
"#{days}d"
|
|
60
|
+
else
|
|
61
|
+
"<1d"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def truncate(str, max_length)
|
|
66
|
+
return str if str.length <= max_length
|
|
67
|
+
|
|
68
|
+
"#{str[0, max_length - 3]}..."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_version_range(param, available_versions)
|
|
72
|
+
return [] if available_versions.empty?
|
|
73
|
+
|
|
74
|
+
case param
|
|
75
|
+
when /^\.\.(.+)$/ # ..bbb (up to)
|
|
76
|
+
upper = $1
|
|
77
|
+
available_versions.select { |v| v <= upper }
|
|
78
|
+
when /^(.+)\.\.$/ # aaa.. (from onwards)
|
|
79
|
+
lower = $1
|
|
80
|
+
available_versions.select { |v| v >= lower }
|
|
81
|
+
when /^(.+)\.\.(.+)$/ # aaa..bbb (range)
|
|
82
|
+
lower, upper = $1, $2
|
|
83
|
+
available_versions.select { |v| v.between?(lower, upper) }
|
|
84
|
+
else # single version
|
|
85
|
+
available_versions.include?(param) ? [param] : []
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mighost
|
|
4
|
+
class GitMetadata
|
|
5
|
+
class << self
|
|
6
|
+
def current_branch
|
|
7
|
+
return nil unless git_available?
|
|
8
|
+
|
|
9
|
+
result = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
|
|
10
|
+
result.empty? ? nil : result
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def current_sha
|
|
14
|
+
return nil unless git_available?
|
|
15
|
+
|
|
16
|
+
result = `git rev-parse HEAD 2>/dev/null`.strip
|
|
17
|
+
result.empty? ? nil : result[0, 12] # Short SHA
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def git_available?
|
|
21
|
+
return @git_available if defined?(@git_available)
|
|
22
|
+
|
|
23
|
+
@git_available = system("git rev-parse --git-dir > /dev/null 2>&1")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset!
|
|
27
|
+
remove_instance_variable(:@git_available) if defined?(@git_available)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|