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 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
+ [![Gem Version](https://img.shields.io/gem/v/mighost)](https://rubygems.org/gems/mighost)
10
+ [![CI](https://github.com/amberpixels/mighost/actions/workflows/ci.yml/badge.svg)](https://github.com/amberpixels/mighost/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](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
@@ -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