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.
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "digest"
5
+ require "active_support"
6
+ require "active_support/core_ext/time"
7
+
8
+ module Mighost
9
+ class Snapshot
10
+ attr_accessor :id, :version, :content, :filename, :branch_name, :git_sha, :content_hash, :migrated_at, :source,
11
+ :dismissed, :dismissed_at, :author_name, :author_email
12
+
13
+ # Special version used to store scan metadata
14
+ SCAN_METADATA_VERSION = "__scan_metadata__"
15
+
16
+ def initialize(attrs = {})
17
+ attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
18
+ end
19
+
20
+ class << self
21
+ def table_name
22
+ "mighost_snapshots"
23
+ end
24
+
25
+ def ensure_table_exists!
26
+ db.execute(<<~SQL)
27
+ CREATE TABLE IF NOT EXISTS #{table_name} (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ version TEXT NOT NULL UNIQUE,
30
+ content TEXT NOT NULL,
31
+ filename TEXT NOT NULL,
32
+ branch_name TEXT,
33
+ git_sha TEXT,
34
+ content_hash TEXT,
35
+ migrated_at TEXT NOT NULL,
36
+ source TEXT DEFAULT 'file',
37
+ dismissed INTEGER DEFAULT 0,
38
+ dismissed_at TEXT,
39
+ author_name TEXT,
40
+ author_email TEXT
41
+ )
42
+ SQL
43
+ db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_#{table_name}_version ON #{table_name}(version)")
44
+ migrate_columns!
45
+ end
46
+
47
+ def find_by_version(version)
48
+ ensure_table_exists!
49
+ row = db.get_first_row(
50
+ "SELECT * FROM #{table_name} WHERE version = ? LIMIT 1",
51
+ [version.to_s]
52
+ )
53
+ return nil unless row
54
+
55
+ row_to_snapshot(row)
56
+ end
57
+
58
+ def find_or_recover(version)
59
+ existing = find_by_version(version)
60
+ return existing if existing
61
+
62
+ require_relative "git_recovery"
63
+ git_data = GitRecovery.find_migration(version)
64
+ if git_data
65
+ return store(
66
+ version: version,
67
+ content: git_data[:content],
68
+ filename: git_data[:filename],
69
+ branch_name: git_data[:branch],
70
+ git_sha: git_data[:sha],
71
+ source: "git",
72
+ author_name: git_data[:author_name],
73
+ author_email: git_data[:author_email]
74
+ )
75
+ end
76
+
77
+ require_relative "worktree_recovery"
78
+ wt_data = WorktreeRecovery.find_migration(version)
79
+ return nil unless wt_data
80
+
81
+ store(
82
+ version: version,
83
+ content: wt_data[:content],
84
+ filename: wt_data[:filename],
85
+ branch_name: wt_data[:branch],
86
+ source: "worktree",
87
+ author_name: wt_data[:author_name],
88
+ author_email: wt_data[:author_email]
89
+ )
90
+ end
91
+
92
+ def store(version:, content:, filename:, branch_name: nil, git_sha: nil, source: "file", author_name: nil, author_email: nil)
93
+ ensure_table_exists!
94
+ content_hash = Digest::SHA256.hexdigest(content)
95
+ migrated_at = Time.current.iso8601
96
+
97
+ existing = find_by_version(version)
98
+ if existing
99
+ db.execute(<<~SQL, [content, filename, branch_name, git_sha, content_hash, migrated_at, source, author_name, author_email, version.to_s])
100
+ UPDATE #{table_name}
101
+ SET content = ?, filename = ?, branch_name = ?, git_sha = ?, content_hash = ?, migrated_at = ?, source = ?,
102
+ author_name = ?, author_email = ?
103
+ WHERE version = ?
104
+ SQL
105
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ SQL
110
+ end
111
+
112
+ find_by_version(version)
113
+ end
114
+
115
+ # Store scan context metadata (branch and SHA when scan was performed)
116
+ def store_scan_metadata(branch:, sha:)
117
+ ensure_table_exists!
118
+ store(
119
+ version: SCAN_METADATA_VERSION,
120
+ content: "",
121
+ filename: "",
122
+ branch_name: branch,
123
+ git_sha: sha,
124
+ source: "metadata"
125
+ )
126
+ end
127
+
128
+ # Retrieve scan context metadata
129
+ def scan_metadata
130
+ ensure_table_exists!
131
+ record = find_by_version(SCAN_METADATA_VERSION)
132
+ return nil unless record
133
+
134
+ {branch: record.branch_name, sha: record.git_sha}
135
+ end
136
+
137
+ # Dismiss a migration (mark as hidden)
138
+ def dismiss(version)
139
+ ensure_table_exists!
140
+ existing = find_by_version(version)
141
+
142
+ unless existing
143
+ store(
144
+ version: version,
145
+ content: "",
146
+ filename: "",
147
+ source: nil
148
+ )
149
+ end
150
+
151
+ dismissed_at = Time.current.iso8601
152
+ db.execute(
153
+ "UPDATE #{table_name} SET dismissed = ?, dismissed_at = ? WHERE version = ?",
154
+ [1, dismissed_at, version.to_s]
155
+ )
156
+
157
+ find_by_version(version)
158
+ end
159
+
160
+ # Undismiss a migration (restore visibility)
161
+ def undismiss(version)
162
+ ensure_table_exists!
163
+ existing = find_by_version(version)
164
+ return nil unless existing
165
+
166
+ db.execute(
167
+ "UPDATE #{table_name} SET dismissed = ?, dismissed_at = ? WHERE version = ?",
168
+ [0, nil, version.to_s]
169
+ )
170
+
171
+ find_by_version(version)
172
+ end
173
+
174
+ # Get all dismissed versions
175
+ def dismissed_versions
176
+ ensure_table_exists!
177
+ db.execute(
178
+ "SELECT version FROM #{table_name} WHERE dismissed = 1 AND version != ?",
179
+ [SCAN_METADATA_VERSION]
180
+ ).flatten
181
+ end
182
+
183
+ def delete_by_version(version)
184
+ ensure_table_exists!
185
+ db.execute("DELETE FROM #{table_name} WHERE version = ?", [version.to_s])
186
+ end
187
+
188
+ def all_snapshots
189
+ ensure_table_exists!
190
+ db.execute(
191
+ "SELECT * FROM #{table_name} WHERE version != ? ORDER BY migrated_at DESC",
192
+ [SCAN_METADATA_VERSION]
193
+ ).map { |row| row_to_snapshot(row) }
194
+ end
195
+
196
+ # Lightweight version - only metadata, no content (for status display)
197
+ def all_metadata
198
+ ensure_table_exists!
199
+ cols = %i[version filename branch_name git_sha migrated_at source dismissed author_name author_email]
200
+ db.execute(
201
+ "SELECT version, filename, branch_name, git_sha, migrated_at, source, dismissed, author_name, author_email FROM #{table_name} WHERE version != ?",
202
+ [SCAN_METADATA_VERSION]
203
+ ).map do |row|
204
+ record = new(cols.zip(row).to_h)
205
+ record.dismissed = record.dismissed == 1
206
+ record
207
+ end
208
+ end
209
+
210
+ def all_versions
211
+ ensure_table_exists!
212
+ db.execute(
213
+ "SELECT version FROM #{table_name} WHERE version != ?",
214
+ [SCAN_METADATA_VERSION]
215
+ ).flatten
216
+ end
217
+
218
+ # Close and forget the database handle. Needed when storage_path changes
219
+ # (the handle is memoized on first access).
220
+ def reset_db!
221
+ @db&.close
222
+ @db = nil
223
+ end
224
+
225
+ private
226
+
227
+ def db
228
+ @db ||= begin
229
+ require "sqlite3"
230
+ SQLite3::Database.new(resolve_storage_path)
231
+ end
232
+ end
233
+
234
+ def migrate_columns!
235
+ columns = db.execute("PRAGMA table_info(#{table_name})").map { |c| c[1] }
236
+
237
+ unless columns.include?("source")
238
+ db.execute("ALTER TABLE #{table_name} ADD COLUMN source TEXT DEFAULT 'file'")
239
+ end
240
+ unless columns.include?("dismissed")
241
+ db.execute("ALTER TABLE #{table_name} ADD COLUMN dismissed INTEGER DEFAULT 0")
242
+ end
243
+ unless columns.include?("dismissed_at")
244
+ db.execute("ALTER TABLE #{table_name} ADD COLUMN dismissed_at TEXT")
245
+ end
246
+ unless columns.include?("author_name")
247
+ db.execute("ALTER TABLE #{table_name} ADD COLUMN author_name TEXT")
248
+ end
249
+ unless columns.include?("author_email")
250
+ db.execute("ALTER TABLE #{table_name} ADD COLUMN author_email TEXT")
251
+ end
252
+ end
253
+
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
255
+ private_constant :SNAPSHOT_COLUMNS
256
+
257
+ def row_to_snapshot(row)
258
+ record = new(SNAPSHOT_COLUMNS.zip(row).to_h)
259
+ record.dismissed = record.dismissed == 1
260
+ record
261
+ end
262
+
263
+ def resolve_storage_path
264
+ path = Mighost.configuration.storage_path
265
+
266
+ if defined?(Rails) && Rails.root
267
+ Rails.root.join(path).to_s
268
+ else
269
+ File.expand_path(path, Dir.pwd)
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mighost"
4
+
5
+ namespace :mighost do
6
+ desc "Show available commands"
7
+ task :help do
8
+ puts <<~HELP
9
+ Mighost - Rollback Rails migrations even when the file is missing
10
+
11
+ Commands:
12
+ rails mighost:status Show orphaned migrations (in DB but no file)
13
+ rails mighost:rollback Rollback orphaned migrations (interactive)
14
+ rails mighost:scan Capture migrations + search git for orphans
15
+ rails mighost:list (ls) List stored migration records
16
+ rails mighost:dismiss Dismiss orphans (interactive, or VERSION=x)
17
+ rails mighost:undismiss Restore dismissed migrations
18
+ rails mighost:dismissed List dismissed migrations
19
+ rails mighost:cleanup Remove stale records
20
+
21
+ Options:
22
+ VERSION=x Target specific version (rollback, dismiss, undismiss)
23
+ VERSION=a..b Version range: a to b inclusive
24
+ VERSION=..b All versions up to b
25
+ VERSION=a.. All versions from a onwards
26
+ RESCAN=1 Force re-capture all snapshots (scan)
27
+ FORCE=1 Skip confirmations
28
+ OLD=1 Dismiss only old unrecoverable migrations (dismiss)
29
+ SHOW_DISMISSED=1 Include dismissed in status
30
+ LIMIT=N Limit output rows (list)
31
+ DEBUG=1 Verbose output (status)
32
+
33
+ Typical workflow:
34
+ 1. rails mighost:scan # First time setup
35
+ 2. rails mighost:status # Check for orphaned migrations
36
+ 3. rails mighost:rollback # Rollback interactively
37
+ 4. rails mighost:dismiss # Dismiss unrecoverable orphans
38
+
39
+ HELP
40
+ end
41
+
42
+ desc "Show orphaned migrations (in DB but no file). DEBUG=1, SHOW_DISMISSED=1"
43
+ task status: :environment do
44
+ ui = Mighost.ui
45
+ Mighost::OrphanDetector.debug = ENV["DEBUG"] == "1"
46
+ show_dismissed = ENV["SHOW_DISMISSED"] == "1"
47
+
48
+ orphans = Mighost::API.orphaned_migrations(include_dismissed: show_dismissed)
49
+ ui.render_orphans(orphans)
50
+
51
+ unless show_dismissed
52
+ dismissed_count = Mighost::API.dismissed_count
53
+ ui.hint "#{dismissed_count} dismissed migration(s) hidden. Use SHOW_DISMISSED=1 to show." if dismissed_count.positive?
54
+ end
55
+
56
+ non_recoverable = orphans.reject(&:recoverable?)
57
+ if non_recoverable.any? && Mighost::OrphanDetector.branch_changed?
58
+ ui.hint "Branch changed since last scan. Run 'rails mighost:scan' to update recovery info."
59
+ end
60
+ end
61
+
62
+ desc "Rollback orphaned migrations. VERSION=xxx for specific, FORCE=1 to skip confirmation"
63
+ task rollback: :environment do
64
+ ui = Mighost.ui
65
+ version = ENV["VERSION"]
66
+ force = ENV["FORCE"] == "1"
67
+
68
+ if version
69
+ begin
70
+ Mighost::API.rollback_version!(version)
71
+ ui.success "Successfully rolled back migration #{version}"
72
+ rescue Mighost::SnapshotNotFound
73
+ ui.error "No record found for version #{version}"
74
+ rescue Mighost::RollbackFailed => e
75
+ ui.error "Failed to rollback: #{e.message}"
76
+ end
77
+ next
78
+ end
79
+
80
+ orphans = Mighost::API.orphaned_migrations.select(&:recoverable?)
81
+
82
+ if orphans.empty?
83
+ ui.info "No recoverable orphaned migrations found."
84
+ next
85
+ end
86
+
87
+ ui.render_recoverable_list(orphans)
88
+
89
+ results = {rolled_back: [], failed: [], skipped: []}
90
+ rollback_all = force
91
+
92
+ orphans.each do |orphan|
93
+ unless rollback_all
94
+ response = ui.confirm_each?(orphan)
95
+ case response
96
+ when :quit
97
+ ui.warning "Aborted."
98
+ break
99
+ when :no
100
+ results[:skipped] << orphan.version
101
+ next
102
+ when :all
103
+ rollback_all = true
104
+ end
105
+ end
106
+
107
+ begin
108
+ Mighost::API.rollback_version!(orphan.version)
109
+ results[:rolled_back] << orphan.version
110
+ ui.success " Rolled back #{orphan.version}"
111
+ rescue => e
112
+ results[:failed] << {version: orphan.version, error: e.message}
113
+ ui.error " Failed: #{e.message}"
114
+ end
115
+ end
116
+
117
+ ui.info "Done: #{results[:rolled_back].size} rolled back, #{results[:skipped].size} skipped, #{results[:failed].size} failed"
118
+ end
119
+
120
+ desc "Scan and capture existing migrations, search git for orphans. RESCAN=1 to force re-capture all"
121
+ task scan: :environment do
122
+ ui = Mighost.ui
123
+ result = Mighost::API.scan!(rescan: ENV["RESCAN"] == "1")
124
+
125
+ ui.render_scan_details(result[:details])
126
+ ui.render_scan_results(
127
+ captured: result[:captured],
128
+ git_recovered: result[:git_recovered],
129
+ worktree_recovered: result[:worktree_recovered],
130
+ branch: result[:branch],
131
+ sha: result[:sha]
132
+ )
133
+ end
134
+
135
+ desc "List stored migration records (LIMIT=N to limit)"
136
+ task list: :environment do
137
+ limit = ENV["LIMIT"]&.to_i
138
+ records = Mighost::API.all_snapshots
139
+ total_count = records.count
140
+ display_records = limit&.positive? ? records.first(limit) : records
141
+
142
+ Mighost.ui.render_records(display_records, limit: limit, total_count: total_count)
143
+ end
144
+
145
+ task ls: :list
146
+
147
+ desc "Clean up stale records for migrations no longer in schema_migrations"
148
+ task cleanup: :environment do
149
+ ui = Mighost.ui
150
+ Mighost::Snapshot.ensure_table_exists!
151
+ migrated_versions = Mighost::Base.connection
152
+ .select_values("SELECT version FROM schema_migrations")
153
+ .map(&:to_s)
154
+
155
+ all_record_versions = Mighost::Snapshot.all_versions
156
+ stale_versions = all_record_versions - migrated_versions
157
+
158
+ if stale_versions.empty?
159
+ ui.info "No stale records to clean up."
160
+ next
161
+ end
162
+
163
+ ui.warning "Found #{stale_versions.count} stale record(s):"
164
+ stale_versions.each { |v| ui.info " #{v}" }
165
+
166
+ unless ENV["FORCE"] == "1"
167
+ unless ui.confirm?("Delete these records?")
168
+ ui.warning "Aborted."
169
+ next
170
+ end
171
+ end
172
+
173
+ stale_versions.each { |v| Mighost::Snapshot.delete_by_version(v) }
174
+ ui.success "Cleaned up #{stale_versions.count} record(s)."
175
+ end
176
+
177
+ desc "Dismiss orphaned migrations (hide from status). VERSION=x, VERSION=a..b (range), OLD=1"
178
+ task dismiss: :environment do
179
+ ui = Mighost.ui
180
+ version_param = ENV["VERSION"]
181
+ old_only = ENV["OLD"] == "1"
182
+
183
+ orphans = Mighost::API.orphaned_migrations
184
+
185
+ if old_only
186
+ orphans = orphans.select(&:suggested_for_dismiss?)
187
+ if orphans.empty?
188
+ ui.info "No old unrecoverable migrations to dismiss."
189
+ ui.hint "Migrations must be older than 6 months and not recoverable."
190
+ next
191
+ end
192
+ end
193
+
194
+ if orphans.empty?
195
+ ui.info "No orphaned migrations to dismiss."
196
+ next
197
+ end
198
+
199
+ orphan_versions = orphans.map(&:version).sort
200
+ orphan_map = orphans.index_by(&:version)
201
+
202
+ to_dismiss = if version_param
203
+ Mighost::Formatting.parse_version_range(version_param, orphan_versions)
204
+ else
205
+ orphan_versions
206
+ end
207
+
208
+ if to_dismiss.empty?
209
+ ui.warning "No matching orphaned migrations found."
210
+ next
211
+ end
212
+
213
+ ui.render_dismiss_preview(to_dismiss, orphan_map)
214
+
215
+ results = {dismissed: [], skipped: []}
216
+ dismiss_all = ENV["FORCE"] == "1"
217
+
218
+ to_dismiss.each do |version|
219
+ orphan = orphan_map[version]
220
+ name = orphan&.filename || "(unknown)"
221
+
222
+ unless dismiss_all
223
+ response = ui.confirm_dismiss?(version, name)
224
+ case response
225
+ when :quit
226
+ ui.warning "Aborted."
227
+ break
228
+ when :no
229
+ results[:skipped] << version
230
+ next
231
+ when :all
232
+ remaining = to_dismiss[to_dismiss.index(version)..]
233
+ ui.render_dismiss_remaining(remaining, orphan_map)
234
+
235
+ if ui.confirm?("Confirm dismiss all #{remaining.count}?")
236
+ remaining.each do |v|
237
+ Mighost::API.dismiss(v)
238
+ results[:dismissed] << v
239
+ end
240
+ ui.success "Dismissed #{remaining.count} migration(s)."
241
+ else
242
+ ui.warning "Aborted."
243
+ end
244
+ break
245
+ end
246
+ end
247
+
248
+ Mighost::API.dismiss(version)
249
+ results[:dismissed] << version
250
+ ui.success " Dismissed #{version}"
251
+ end
252
+
253
+ ui.info "Done: #{results[:dismissed].size} dismissed, #{results[:skipped].size} skipped"
254
+ end
255
+
256
+ desc "Undismiss migrations (restore visibility). VERSION=x or VERSION=a..b"
257
+ task undismiss: :environment do
258
+ ui = Mighost.ui
259
+ version_param = ENV["VERSION"]
260
+
261
+ unless version_param
262
+ ui.error "VERSION required. Use same format as dismiss."
263
+ next
264
+ end
265
+
266
+ dismissed = Mighost::Snapshot.dismissed_versions.sort
267
+
268
+ if dismissed.empty?
269
+ ui.info "No dismissed migrations."
270
+ next
271
+ end
272
+
273
+ targets = Mighost::Formatting.parse_version_range(version_param, dismissed)
274
+
275
+ if targets.empty?
276
+ ui.warning "No matching dismissed migrations found."
277
+ next
278
+ end
279
+
280
+ targets.each do |version|
281
+ Mighost::API.undismiss(version)
282
+ ui.success "Undismissed #{version}"
283
+ end
284
+
285
+ ui.info "Restored #{targets.count} migration(s) to status output."
286
+ end
287
+
288
+ desc "List dismissed migrations"
289
+ task dismissed: :environment do
290
+ dismissed = Mighost::Snapshot.dismissed_versions.sort
291
+
292
+ if dismissed.empty?
293
+ Mighost.ui.info "No dismissed migrations."
294
+ next
295
+ end
296
+
297
+ dismissed_snapshots = dismissed.map { |v| [v, Mighost::API.find_snapshot(v)] }
298
+ Mighost.ui.render_dismissed_list(dismissed_snapshots)
299
+ end
300
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mighost
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "active_support"
5
+ require "active_support/core_ext/object/blank"
6
+ require_relative "git_metadata"
7
+
8
+ module Mighost
9
+ # Searches other git worktrees for migration files that exist on disk
10
+ # but haven't been committed yet (invisible to git log --all)
11
+ module WorktreeRecovery
12
+ class << self
13
+ # Find migration file in other worktrees
14
+ # Returns { content:, filename:, branch:, worktree_path: } or nil
15
+ def find_migration(version)
16
+ return nil unless GitMetadata.git_available?
17
+
18
+ worktrees = list_worktrees
19
+ return nil if worktrees.empty?
20
+
21
+ current_root = git_root
22
+ return nil unless current_root
23
+
24
+ worktrees.each do |wt|
25
+ next if wt[:path] == current_root
26
+
27
+ result = find_in_worktree(wt, version)
28
+ return result if result
29
+ end
30
+
31
+ nil
32
+ end
33
+
34
+ # List all git worktrees with their paths and branches
35
+ def list_worktrees
36
+ output = `git worktree list --porcelain 2>/dev/null`.strip
37
+ return [] if output.empty?
38
+
39
+ worktrees = []
40
+ current = {}
41
+
42
+ output.each_line do |line|
43
+ line = line.chomp
44
+ if line.start_with?("worktree ")
45
+ current = {path: line.sub("worktree ", "")}
46
+ elsif line.start_with?("branch ")
47
+ ref = line.sub("branch ", "")
48
+ # refs/heads/feature-branch -> feature-branch
49
+ current[:branch] = ref.sub(%r{^refs/heads/}, "")
50
+ elsif line == "detached"
51
+ current[:branch] = nil
52
+ elsif line.empty? && current[:path]
53
+ worktrees << current
54
+ current = {}
55
+ end
56
+ end
57
+
58
+ # Last entry (no trailing blank line)
59
+ worktrees << current if current[:path]
60
+
61
+ worktrees
62
+ end
63
+
64
+ private
65
+
66
+ def find_in_worktree(wt, version)
67
+ pattern = File.join(wt[:path], "db", "migrate", "#{version}_*.rb")
68
+ matches = Dir.glob(pattern)
69
+ return nil if matches.empty?
70
+
71
+ file = matches.first
72
+ content = File.read(file)
73
+
74
+ author_name, author_email = worktree_git_user(wt[:path])
75
+
76
+ {
77
+ content: content,
78
+ filename: File.basename(file),
79
+ branch: wt[:branch],
80
+ worktree_path: wt[:path],
81
+ author_name: author_name,
82
+ author_email: author_email
83
+ }
84
+ rescue => e
85
+ warn "[Mighost] Failed to read worktree migration #{file}: #{e.message}"
86
+ nil
87
+ end
88
+
89
+ def worktree_git_user(path)
90
+ escaped = Shellwords.escape(path)
91
+ name = `git -C #{escaped} config user.name 2>/dev/null`.strip
92
+ email = `git -C #{escaped} config user.email 2>/dev/null`.strip
93
+ [name.presence, email.presence]
94
+ end
95
+
96
+ def git_root
97
+ result = `git rev-parse --show-toplevel 2>/dev/null`.strip
98
+ result.empty? ? nil : result
99
+ end
100
+ end
101
+ end
102
+ end