railbow 0.3.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 +95 -0
- data/README.md +121 -3
- data/lib/railbow/calendar.rb +89 -0
- data/lib/railbow/config.rb +3 -1
- data/lib/railbow/demo/status_demo.rb +22 -38
- data/lib/railbow/init.rb +23 -1
- data/lib/railbow/multi_db.rb +87 -0
- data/lib/railbow/params.rb +74 -1
- data/lib/railbow/status/ghosts.rb +144 -0
- data/lib/railbow/status/git_data.rb +327 -0
- data/lib/railbow/status/help.rb +121 -0
- data/lib/railbow/status/printer.rb +324 -0
- data/lib/railbow/status/section.rb +551 -0
- data/lib/railbow/table/column.rb +10 -2
- data/lib/railbow/table/renderer.rb +106 -48
- data/lib/railbow/table/theme.rb +3 -2
- data/lib/railbow/tasks/migrate_status.rake +30 -765
- data/lib/railbow/version.rb +1 -1
- metadata +8 -1
data/lib/railbow/params.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "date"
|
|
4
|
+
require_relative "calendar"
|
|
4
5
|
require_relative "config"
|
|
5
6
|
require_relative "name_formatter"
|
|
6
7
|
require_relative "name_collision_resolver"
|
|
@@ -79,6 +80,17 @@ module Railbow
|
|
|
79
80
|
(ENV["RBW_SINCE"] || Config.load["since"] || "all").strip.downcase
|
|
80
81
|
end
|
|
81
82
|
|
|
83
|
+
# Floor on how many migrations survive the SINCE window. The window is a
|
|
84
|
+
# soft limit: when it leaves fewer rows than this, the oldest ones are
|
|
85
|
+
# pulled back in until the count is met. 0 disables the floor.
|
|
86
|
+
#
|
|
87
|
+
# Deliberately a knob of its own rather than a token inside RBW_SINCE, so
|
|
88
|
+
# an ad-hoc `RBW_SINCE=2mo` keeps the floor instead of silently dropping it.
|
|
89
|
+
def since_min
|
|
90
|
+
value = ENV["RBW_SINCE_MIN"] || Config.load["since_min"] || 0
|
|
91
|
+
[value.to_i, 0].max
|
|
92
|
+
end
|
|
93
|
+
|
|
82
94
|
def sort
|
|
83
95
|
(ENV["RBW_SORT"] || Config.load["sort"] || "file").strip.downcase
|
|
84
96
|
end
|
|
@@ -214,6 +226,49 @@ module Railbow
|
|
|
214
226
|
!val.nil?
|
|
215
227
|
end
|
|
216
228
|
|
|
229
|
+
# --- Compound: RBW_DB ---
|
|
230
|
+
|
|
231
|
+
def db
|
|
232
|
+
parse_compound(ENV["RBW_DB"] || Config.load["db"])
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Render every database in one time-ordered table instead of a section
|
|
236
|
+
# each. Only meaningful when the databases keep separate migration sets.
|
|
237
|
+
def db_inline?
|
|
238
|
+
db["inline"] == true
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Draw the full table for every database, including the quiet ones that
|
|
242
|
+
# would otherwise collapse to a summary line.
|
|
243
|
+
def db_full?
|
|
244
|
+
db["full"] == true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Expand only the first database, summarizing the rest in a line each - but
|
|
248
|
+
# never at the cost of hiding work: a database holding pending migrations
|
|
249
|
+
# is always drawn in full, since that is the one thing you may need to act
|
|
250
|
+
# on. RBW_DB=full overrides it, and it does not apply to inline runs.
|
|
251
|
+
def db_focus?
|
|
252
|
+
db["focus"] == true
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def db_only
|
|
256
|
+
Array(db["only"]).select { |v| v.is_a?(String) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def db_skip
|
|
260
|
+
Array(db["skip"]).select { |v| v.is_a?(String) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Whether a database, by its database.yml name, is part of this run.
|
|
264
|
+
def db_included?(name)
|
|
265
|
+
name = name.to_s
|
|
266
|
+
only = db_only
|
|
267
|
+
return only.include?(name) if only.any?
|
|
268
|
+
|
|
269
|
+
!db_skip.include?(name)
|
|
270
|
+
end
|
|
271
|
+
|
|
217
272
|
# --- Compound: RBW_CALENDAR ---
|
|
218
273
|
|
|
219
274
|
def calendar
|
|
@@ -226,8 +281,26 @@ module Railbow
|
|
|
226
281
|
calendar["wticks"] == true
|
|
227
282
|
end
|
|
228
283
|
|
|
284
|
+
# A separator row per ISO week, the fuller counterpart to wticks.
|
|
285
|
+
def calendar_wdividers?
|
|
286
|
+
return false unless view_calendar?
|
|
287
|
+
|
|
288
|
+
calendar["wdividers"] == true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Append "· N migrations" to every separator row: how many it introduces.
|
|
292
|
+
def calendar_counts?
|
|
293
|
+
return false unless view_calendar?
|
|
294
|
+
|
|
295
|
+
calendar["counts"] == true
|
|
296
|
+
end
|
|
297
|
+
|
|
229
298
|
def calendar_label
|
|
230
|
-
calendar["label"] ||
|
|
299
|
+
calendar["label"] || Calendar::DEFAULT_MONTH_LABEL
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def calendar_week_label
|
|
303
|
+
calendar["wlabel"] || Calendar::DEFAULT_WEEK_LABEL
|
|
231
304
|
end
|
|
232
305
|
end
|
|
233
306
|
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railbow
|
|
4
|
+
module Status
|
|
5
|
+
# Recovery of "NO FILE" migrations through the optional mighost gem: rows
|
|
6
|
+
# applied to the database whose migration file is no longer on disk.
|
|
7
|
+
module Ghosts
|
|
8
|
+
GHOST_FG = "\e[38;5;217m" # matches Table::Renderer::GHOST_FG, the row it sits in
|
|
9
|
+
MUTED = "\e[38;5;245m"
|
|
10
|
+
BRANCH = "\e[38;5;222m"
|
|
11
|
+
|
|
12
|
+
# A ghost normalized for rendering, whether it came from mighost's orphan
|
|
13
|
+
# classification or from a live snapshot recovery.
|
|
14
|
+
class Row
|
|
15
|
+
attr_reader :filename, :branch_name, :source, :superseded_by, :deleted_in_sha,
|
|
16
|
+
:author_name, :author_email, :content
|
|
17
|
+
|
|
18
|
+
def initialize(filename: nil, branch_name: nil, source: nil, superseded_by: nil,
|
|
19
|
+
deleted_in_sha: nil, author_name: nil, author_email: nil, content: nil)
|
|
20
|
+
@filename = filename
|
|
21
|
+
@branch_name = branch_name
|
|
22
|
+
@source = source
|
|
23
|
+
@superseded_by = superseded_by
|
|
24
|
+
@deleted_in_sha = deleted_in_sha
|
|
25
|
+
@author_name = author_name
|
|
26
|
+
@author_email = author_email
|
|
27
|
+
@content = content
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def available?
|
|
34
|
+
defined?(Mighost::API) && Mighost.enabled?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load(versions, with_content: false)
|
|
38
|
+
# Detect once: OrphanedMigration carries the classification (supersession,
|
|
39
|
+
# deletion commit) that a bare snapshot does not, and already honors
|
|
40
|
+
# dismissals and hide_superseded.
|
|
41
|
+
orphans = begin
|
|
42
|
+
Mighost::API.orphaned_migrations.to_h { |o| [o.version.to_s, o] }
|
|
43
|
+
rescue
|
|
44
|
+
return {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
rows = {}
|
|
48
|
+
versions.each do |v|
|
|
49
|
+
# Absent from detect = deliberately suppressed (dismissed, or superseded
|
|
50
|
+
# with hide_superseded on) - render as plain NO FILE, do not re-recover.
|
|
51
|
+
next unless (orphan = orphans[v])
|
|
52
|
+
|
|
53
|
+
row = if orphan.filename && !orphan.filename.empty?
|
|
54
|
+
from_orphan(orphan, v, with_content)
|
|
55
|
+
else
|
|
56
|
+
from_live_recovery(v, with_content)
|
|
57
|
+
end
|
|
58
|
+
rows[v] = row if row
|
|
59
|
+
end
|
|
60
|
+
rows
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def from_orphan(orphan, version, with_content)
|
|
64
|
+
Row.new(
|
|
65
|
+
filename: orphan.filename,
|
|
66
|
+
branch_name: orphan.branch_name,
|
|
67
|
+
source: read_attr(orphan, :source),
|
|
68
|
+
superseded_by: read_attr(orphan, :superseded_by),
|
|
69
|
+
deleted_in_sha: read_attr(orphan, :deleted_in_sha),
|
|
70
|
+
author_name: read_attr(orphan, :author_name),
|
|
71
|
+
author_email: read_attr(orphan, :author_email),
|
|
72
|
+
content: with_content ? snapshot_content(version) : nil
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Detect reads stored snapshots only. A version it lists without a filename
|
|
77
|
+
# has no snapshot yet, so fall back to live git/worktree recovery - that
|
|
78
|
+
# keeps fresh clones working with zero setup.
|
|
79
|
+
def from_live_recovery(version, with_content)
|
|
80
|
+
snapshot = begin
|
|
81
|
+
Mighost::API.find_or_recover_snapshot(version)
|
|
82
|
+
rescue
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
return nil unless snapshot&.filename && !snapshot.filename.empty?
|
|
86
|
+
|
|
87
|
+
Row.new(
|
|
88
|
+
filename: snapshot.filename,
|
|
89
|
+
branch_name: snapshot.branch_name,
|
|
90
|
+
source: read_attr(snapshot, :source),
|
|
91
|
+
superseded_by: api_superseded_by(version),
|
|
92
|
+
deleted_in_sha: read_attr(snapshot, :deleted_in_sha),
|
|
93
|
+
author_name: read_attr(snapshot, :author_name),
|
|
94
|
+
author_email: read_attr(snapshot, :author_email),
|
|
95
|
+
content: with_content ? snapshot.content : nil
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def read_attr(obj, name)
|
|
100
|
+
obj.respond_to?(name) ? obj.public_send(name) : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def snapshot_content(version)
|
|
104
|
+
Mighost::API.find_snapshot(version)&.content
|
|
105
|
+
rescue
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def api_superseded_by(version)
|
|
110
|
+
return nil unless Mighost::API.respond_to?(:superseded_by)
|
|
111
|
+
|
|
112
|
+
Mighost::API.superseded_by(version)
|
|
113
|
+
rescue
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# One tag slot per ghost row; the most informative wins. Each ends in the
|
|
118
|
+
# ghost foreground rather than a reset, so the rest of the row keeps its
|
|
119
|
+
# ghost styling.
|
|
120
|
+
def tag(ghost)
|
|
121
|
+
if ghost.superseded_by
|
|
122
|
+
"#{MUTED}≡ #{ghost.superseded_by}#{GHOST_FG}"
|
|
123
|
+
elsif ghost.branch_name
|
|
124
|
+
if ghost.source == "worktree"
|
|
125
|
+
"#{BRANCH}⌥ₜ#{ghost.branch_name}#{GHOST_FG}"
|
|
126
|
+
else
|
|
127
|
+
"#{BRANCH}⌥ #{ghost.branch_name}#{GHOST_FG}"
|
|
128
|
+
end
|
|
129
|
+
elsif ghost.deleted_in_sha
|
|
130
|
+
"#{MUTED}✂ deleted in:#{ghost.deleted_in_sha[0, 8]}#{GHOST_FG}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The name a ghost row displays, derived from its recovered filename.
|
|
135
|
+
def display_name(ghost)
|
|
136
|
+
ghost.filename
|
|
137
|
+
.sub(/\A\d+_/, "") # strip version prefix
|
|
138
|
+
.sub(/\.rb\z/, "") # strip extension
|
|
139
|
+
.tr("_", " ")
|
|
140
|
+
.gsub(/\b\w/, &:upcase) # titleize
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "../git_utils"
|
|
5
|
+
require_relative "../multi_db"
|
|
6
|
+
require_relative "../params"
|
|
7
|
+
|
|
8
|
+
module Railbow
|
|
9
|
+
module Status
|
|
10
|
+
# Every git lookup db:migrate:status makes about a migrations directory:
|
|
11
|
+
# who wrote each migration, when it landed on the mainline, which branch
|
|
12
|
+
# introduced it, and what is still uncommitted.
|
|
13
|
+
#
|
|
14
|
+
# One instance per migrations directory. Multi-database runs reuse the
|
|
15
|
+
# instance across databases that share a directory, which is what keeps
|
|
16
|
+
# `git log` from running once per database.
|
|
17
|
+
class GitData
|
|
18
|
+
EMPTY_AUTHORS = {names: {}, emails: {}}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :migrate_dir, :author_names, :author_emails, :landed_dates,
|
|
21
|
+
:git_email, :git_name, :branch_origins, :uncommitted_files,
|
|
22
|
+
:incoming_merge_files, :merge_source_label
|
|
23
|
+
|
|
24
|
+
# migrate_dir is nil when no migration file could be located, in which
|
|
25
|
+
# case every file-scoped lookup is skipped and the accessors stay empty.
|
|
26
|
+
def initialize(migrate_dir:, author_enabled:, diff_enabled:, base_override: "", branch_mask: "")
|
|
27
|
+
@migrate_dir = migrate_dir
|
|
28
|
+
@author_names = {}
|
|
29
|
+
@author_emails = {}
|
|
30
|
+
@landed_dates = {}
|
|
31
|
+
@branch_origins = {}
|
|
32
|
+
@uncommitted_files = Set.new
|
|
33
|
+
@incoming_merge_files = Set.new
|
|
34
|
+
@merge_source_label = nil
|
|
35
|
+
|
|
36
|
+
load_authors(author_enabled)
|
|
37
|
+
load_diff(branch_mask, base_override) if diff_enabled && migrate_dir
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Shares one instance per migrations directory across a multi-database
|
|
41
|
+
# run. Databases that share a directory (shards) or a run that renders
|
|
42
|
+
# several databases would otherwise repeat the same full-history
|
|
43
|
+
# `git log` once per database.
|
|
44
|
+
def self.for(migrate_dir:, **opts)
|
|
45
|
+
cache = Railbow::MultiDb.current&.git_cache
|
|
46
|
+
return new(migrate_dir: migrate_dir, **opts) unless cache
|
|
47
|
+
|
|
48
|
+
cache[[migrate_dir, opts]] ||= new(migrate_dir: migrate_dir, **opts)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Extracts a display label from a branch name. "auto" uses the built-in
|
|
52
|
+
# ticket pattern; anything else is a regex whose first capture wins.
|
|
53
|
+
def self.apply_branch_mask(branch, branch_mask)
|
|
54
|
+
return branch if branch_mask.empty?
|
|
55
|
+
return Railbow::Params.extract_branch_ticket(branch) if branch_mask == "auto"
|
|
56
|
+
|
|
57
|
+
re = begin
|
|
58
|
+
Regexp.new(branch_mask, Regexp::IGNORECASE)
|
|
59
|
+
rescue RegexpError
|
|
60
|
+
return branch
|
|
61
|
+
end
|
|
62
|
+
m = branch.match(re)
|
|
63
|
+
(m && m[1]) ? m[1] : branch
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def apply_branch_mask(branch, branch_mask)
|
|
69
|
+
self.class.apply_branch_mask(branch, branch_mask)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def load_authors(author_enabled)
|
|
73
|
+
if migrate_dir
|
|
74
|
+
@landed_dates = migration_landed_dates
|
|
75
|
+
if author_enabled
|
|
76
|
+
result = migration_authors
|
|
77
|
+
@author_names = result[:names]
|
|
78
|
+
@author_emails = result[:emails]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
return unless author_enabled
|
|
82
|
+
|
|
83
|
+
@git_email = current_git_email
|
|
84
|
+
@git_name = current_git_name
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load_diff(branch_mask, base_override)
|
|
88
|
+
base_branch = detect_default_branch(base_override)
|
|
89
|
+
@branch_origins = branch_migration_origins(base_branch, branch_mask)
|
|
90
|
+
@incoming_merge_files = incoming_merge_files_from_git
|
|
91
|
+
@merge_source_label = detect_merge_source_label(branch_mask) if @incoming_merge_files.any?
|
|
92
|
+
@uncommitted_files = uncommitted_migration_files
|
|
93
|
+
|
|
94
|
+
# Uncommitted files have no commit to attribute, so they belong to
|
|
95
|
+
# whatever branch is checked out right now.
|
|
96
|
+
current_branch = current_branch_name(branch_mask)
|
|
97
|
+
@uncommitted_files.each { |f| @branch_origins[f] ||= current_branch }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def migration_authors
|
|
101
|
+
output, _status = Railbow::GitUtils.capture2(
|
|
102
|
+
"log", "--format=COMMIT:%aN\t%aE", "--diff-filter=AR", "--name-status", "--", migrate_dir
|
|
103
|
+
)
|
|
104
|
+
return EMPTY_AUTHORS.dup if output.empty?
|
|
105
|
+
|
|
106
|
+
names = {}
|
|
107
|
+
emails = {}
|
|
108
|
+
current_name = nil
|
|
109
|
+
current_email = nil
|
|
110
|
+
output.each_line do |line|
|
|
111
|
+
line = line.strip
|
|
112
|
+
if line.start_with?("COMMIT:")
|
|
113
|
+
parts = line.sub("COMMIT:", "").split("\t", 2)
|
|
114
|
+
current_name = parts[0]
|
|
115
|
+
current_email = parts[1]&.downcase
|
|
116
|
+
elsif !line.empty? && current_name
|
|
117
|
+
basename = name_status_basename(line)
|
|
118
|
+
next unless basename
|
|
119
|
+
names[basename] ||= current_name
|
|
120
|
+
emails[basename] ||= current_email
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
{names: names, emails: emails}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# basename => Date the migration first appeared on the mainline (the
|
|
127
|
+
# merge commit date, via --first-parent).
|
|
128
|
+
def migration_landed_dates
|
|
129
|
+
output, _status = Railbow::GitUtils.capture2(
|
|
130
|
+
"log", "--first-parent", "--format=COMMIT:%cI", "--diff-filter=AR", "--name-status", "--", migrate_dir
|
|
131
|
+
)
|
|
132
|
+
return {} if output.empty?
|
|
133
|
+
|
|
134
|
+
dates = {}
|
|
135
|
+
current_date = nil
|
|
136
|
+
output.each_line do |line|
|
|
137
|
+
line = line.strip
|
|
138
|
+
if line.start_with?("COMMIT:")
|
|
139
|
+
current_date = begin
|
|
140
|
+
Date.parse(line.sub("COMMIT:", ""))
|
|
141
|
+
rescue Date::Error
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
elsif !line.empty? && current_date
|
|
145
|
+
basename = name_status_basename(line)
|
|
146
|
+
next unless basename
|
|
147
|
+
dates[basename] ||= current_date
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
dates
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# --name-status lines are "A\tfilepath" or "Rnnn\told\tnew"; a rename
|
|
154
|
+
# attributes to its destination.
|
|
155
|
+
def name_status_basename(line)
|
|
156
|
+
cols = line.split("\t")
|
|
157
|
+
filepath = cols[0]&.start_with?("R") ? cols[2] : cols[1]
|
|
158
|
+
filepath ? File.basename(filepath) : nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def current_git_email
|
|
162
|
+
output, _status = Railbow::GitUtils.capture2("config", "user.email")
|
|
163
|
+
output.strip.downcase
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def current_git_name
|
|
167
|
+
output, _status = Railbow::GitUtils.capture2("config", "user.name")
|
|
168
|
+
output.strip
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def current_branch_name(branch_mask)
|
|
172
|
+
output, status = Railbow::GitUtils.capture2("rev-parse", "--abbrev-ref", "HEAD")
|
|
173
|
+
return "HEAD" unless status.success?
|
|
174
|
+
|
|
175
|
+
apply_branch_mask(output.strip, branch_mask)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def detect_default_branch(override)
|
|
179
|
+
return override if override && !override.empty?
|
|
180
|
+
|
|
181
|
+
output, status = Railbow::GitUtils.capture2("symbolic-ref", "refs/remotes/origin/HEAD")
|
|
182
|
+
if status.success?
|
|
183
|
+
branch = output.strip.sub(%r{^refs/remotes/origin/}, "")
|
|
184
|
+
return branch unless branch.empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
%w[main master].each do |candidate|
|
|
188
|
+
_, st = Railbow::GitUtils.capture2("rev-parse", "--verify", "refs/heads/#{candidate}")
|
|
189
|
+
return candidate if st.success?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
"main"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def branch_migration_origins(base_branch, branch_mask)
|
|
196
|
+
merge_base_out, mb_status = Railbow::GitUtils.capture2("merge-base", "HEAD", base_branch)
|
|
197
|
+
return {} unless mb_status.success?
|
|
198
|
+
|
|
199
|
+
merge_base = merge_base_out.strip
|
|
200
|
+
diff_out, diff_status = Railbow::GitUtils.capture2(
|
|
201
|
+
"diff", "--name-status", "--diff-filter=AR", merge_base, "HEAD", "--", migrate_dir
|
|
202
|
+
)
|
|
203
|
+
return {} unless diff_status.success?
|
|
204
|
+
|
|
205
|
+
files = diff_out.each_line.map { |l|
|
|
206
|
+
cols = l.strip.split("\t")
|
|
207
|
+
cols[0]&.start_with?("R") ? cols[2] : cols[1]
|
|
208
|
+
}.compact.reject(&:empty?)
|
|
209
|
+
origins = {}
|
|
210
|
+
|
|
211
|
+
files.each do |filepath|
|
|
212
|
+
basename = File.basename(filepath)
|
|
213
|
+
commit = adding_commit(filepath)
|
|
214
|
+
next unless commit
|
|
215
|
+
|
|
216
|
+
branches = branches_containing(commit)
|
|
217
|
+
next if branches.empty?
|
|
218
|
+
|
|
219
|
+
best = pick_origin_branch(branches, commit)
|
|
220
|
+
origins[basename] = best ? apply_branch_mask(best, branch_mask) : best
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
origins
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def adding_commit(filepath)
|
|
227
|
+
commit_out, cs = Railbow::GitUtils.capture2(
|
|
228
|
+
"log", "--diff-filter=AR", "--format=%H", "-1", "--", filepath
|
|
229
|
+
)
|
|
230
|
+
return nil unless cs.success?
|
|
231
|
+
|
|
232
|
+
commit = commit_out.strip
|
|
233
|
+
commit.empty? ? nil : commit
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def branches_containing(commit)
|
|
237
|
+
branches_out, bs = Railbow::GitUtils.capture2(
|
|
238
|
+
"branch", "--contains", commit, "--format=%(refname:short)"
|
|
239
|
+
)
|
|
240
|
+
return [] unless bs.success?
|
|
241
|
+
|
|
242
|
+
branches_out.each_line.map(&:strip).reject(&:empty?)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Picks the branch that originally introduced the commit.
|
|
246
|
+
# 1. Drop child branches: if A is an ancestor of B, the commit came from A.
|
|
247
|
+
# 2. Among the rest, prefer the branch with the most commits after the
|
|
248
|
+
# adding commit - it has been active longest since, so it is the
|
|
249
|
+
# original rather than a newer fork.
|
|
250
|
+
def pick_origin_branch(branches, commit)
|
|
251
|
+
return branches.first if branches.size == 1
|
|
252
|
+
|
|
253
|
+
filtered = branches.reject do |b|
|
|
254
|
+
branches.any? do |other|
|
|
255
|
+
next false if other == b
|
|
256
|
+
_, st = Railbow::GitUtils.capture2("merge-base", "--is-ancestor", other, b)
|
|
257
|
+
st.success?
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
filtered = branches if filtered.empty?
|
|
261
|
+
return filtered.first if filtered.size == 1
|
|
262
|
+
|
|
263
|
+
filtered.max_by do |b|
|
|
264
|
+
count_out, _ = Railbow::GitUtils.capture2("rev-list", "--count", "#{commit}..#{b}")
|
|
265
|
+
count_out.strip.to_i
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def uncommitted_migration_files
|
|
270
|
+
output, status = Railbow::GitUtils.capture2("status", "--porcelain", "--", migrate_dir)
|
|
271
|
+
return Set.new unless status.success?
|
|
272
|
+
|
|
273
|
+
result = Set.new
|
|
274
|
+
output.each_line do |line|
|
|
275
|
+
code = line[0..1]
|
|
276
|
+
|
|
277
|
+
if code[0] == "R"
|
|
278
|
+
# Rename: "R old -> new" or "R100 old -> new". Take the destination.
|
|
279
|
+
parts = line[3..].split(" -> ", 2)
|
|
280
|
+
filepath = (parts[1] || parts[0]).strip
|
|
281
|
+
elsif ["??", "A ", "AM", "M "].include?(code)
|
|
282
|
+
filepath = line[3..].strip
|
|
283
|
+
else
|
|
284
|
+
next
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
result << File.basename(filepath) unless filepath.empty?
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# During an in-progress merge, files from MERGE_HEAD (e.g. main) show up
|
|
291
|
+
# as staged additions. Exclude them so they are not tagged as ours.
|
|
292
|
+
result - incoming_merge_files_from_git
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def detect_merge_source_label(branch_mask)
|
|
296
|
+
merge_head, _, status = Railbow::GitUtils.capture3("rev-parse", "MERGE_HEAD")
|
|
297
|
+
return nil unless status.success?
|
|
298
|
+
|
|
299
|
+
branches_out, _, bs = Railbow::GitUtils.capture3(
|
|
300
|
+
"branch", "--contains", merge_head.strip, "--format=%(refname:short)"
|
|
301
|
+
)
|
|
302
|
+
return nil unless bs.success?
|
|
303
|
+
|
|
304
|
+
branches = branches_out.each_line.map(&:strip).reject(&:empty?)
|
|
305
|
+
return nil if branches.empty?
|
|
306
|
+
|
|
307
|
+
branch = branches.first if branches.size == 1
|
|
308
|
+
branch ||= branches.find { |b| %w[main master develop].include?(b) }
|
|
309
|
+
branch ||= branches.first
|
|
310
|
+
|
|
311
|
+
apply_branch_mask(branch, branch_mask)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def incoming_merge_files_from_git
|
|
315
|
+
_, _, mh_status = Railbow::GitUtils.capture3("rev-parse", "MERGE_HEAD")
|
|
316
|
+
return Set.new unless mh_status.success?
|
|
317
|
+
|
|
318
|
+
output, _, status = Railbow::GitUtils.capture3(
|
|
319
|
+
"diff", "--name-only", "--diff-filter=AR", "HEAD", "MERGE_HEAD", "--", migrate_dir
|
|
320
|
+
)
|
|
321
|
+
return Set.new unless status.success?
|
|
322
|
+
|
|
323
|
+
Set.new(output.each_line.map { |l| File.basename(l.strip) }.reject(&:empty?))
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../logo"
|
|
4
|
+
|
|
5
|
+
module Railbow
|
|
6
|
+
module Status
|
|
7
|
+
module Help
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def print
|
|
11
|
+
Railbow.print_logo
|
|
12
|
+
puts <<~HELP
|
|
13
|
+
|
|
14
|
+
Enhanced db:migrate:status
|
|
15
|
+
|
|
16
|
+
\e[1mUsage:\e[0m
|
|
17
|
+
[RBW_*=value ...] rake db:migrate:status
|
|
18
|
+
|
|
19
|
+
\e[1mOptions:\e[0m
|
|
20
|
+
RBW_SINCE=<period> Filter migrations by age (default: all)
|
|
21
|
+
Values: all, 2mo, 1w, 30d, 1y, etc.
|
|
22
|
+
Units: d (days), w (weeks), mo/m (months), y (years)
|
|
23
|
+
|
|
24
|
+
RBW_SINCE_MIN=<n> Never show fewer than n migrations, however
|
|
25
|
+
old (default: 10). The window above is a
|
|
26
|
+
soft limit: a database with five migrations
|
|
27
|
+
shows all five rather than reporting an
|
|
28
|
+
empty period. 0 disables the floor.
|
|
29
|
+
|
|
30
|
+
RBW_DATE=<mode> Date column format (default: full):
|
|
31
|
+
full - 2026-01-30 12:08:54 (column: Created At)
|
|
32
|
+
rel - ~3d ago
|
|
33
|
+
short - Jan 30 (column: Date)
|
|
34
|
+
custom(…) - user strftime, e.g. custom(%b %d, %Y)
|
|
35
|
+
|
|
36
|
+
RBW_VIEW=<options> Display options (comma-separated):
|
|
37
|
+
calendar - show month/year separator lines + week ticks
|
|
38
|
+
tables - parse migration files, show Tables column
|
|
39
|
+
|
|
40
|
+
RBW_COMPACT=<options> Compact display (comma-separated):
|
|
41
|
+
oneline - truncate instead of wrapping
|
|
42
|
+
dense - remove cell padding
|
|
43
|
+
noheader - hide table header row
|
|
44
|
+
maxw:<n> - cap column widths at n chars
|
|
45
|
+
hide:<col> - hide a column by name (repeatable)
|
|
46
|
+
|
|
47
|
+
RBW_CALENDAR=<options> Calendar sub-options (requires RBW_VIEW=calendar):
|
|
48
|
+
(empty) - month separators only, no week
|
|
49
|
+
markers at all
|
|
50
|
+
wticks - week tick marks on the date column
|
|
51
|
+
wdividers - a separator row per ISO week
|
|
52
|
+
counts - append "· N migrations" to every
|
|
53
|
+
separator row (that section)
|
|
54
|
+
label:<fmt> - strftime for month separators
|
|
55
|
+
(default: %b %Y W%V)
|
|
56
|
+
wlabel:<fmt> - strftime for week separators
|
|
57
|
+
(default: same as label, so the
|
|
58
|
+
week number never shifts)
|
|
59
|
+
|
|
60
|
+
RBW_DB=<options> Multi-database runs (default: focus).
|
|
61
|
+
An empty value expands every database into
|
|
62
|
+
its own section, in database.yml order,
|
|
63
|
+
with column widths shared so the tables
|
|
64
|
+
line up. Databases sharing a migrations
|
|
65
|
+
path (shards) merge into one table with a
|
|
66
|
+
status per database.
|
|
67
|
+
focus - expand only the first
|
|
68
|
+
database, summarizing the
|
|
69
|
+
rest in a line each. A
|
|
70
|
+
database with migrations
|
|
71
|
+
pending is always expanded
|
|
72
|
+
only:<name> - render only this database
|
|
73
|
+
(repeatable)
|
|
74
|
+
skip:<name> - drop this database
|
|
75
|
+
(repeatable)
|
|
76
|
+
full - draw every database in full,
|
|
77
|
+
overriding focus and never
|
|
78
|
+
collapsing a quiet one
|
|
79
|
+
inline - one merged table ordered by
|
|
80
|
+
version, with a Db column
|
|
81
|
+
|
|
82
|
+
RBW_GIT=<options> Git integration (comma-separated):
|
|
83
|
+
author - add an Author column (same as author:all)
|
|
84
|
+
author:all - add an Author column
|
|
85
|
+
author:me - highlight your own migrations
|
|
86
|
+
diff - tag migrations by git origin
|
|
87
|
+
base:<branch> - base branch for diff (default: auto-detected)
|
|
88
|
+
mask:<re> - regex to extract branch label
|
|
89
|
+
e.g. mask:(WS-[^/]+)/
|
|
90
|
+
mask:auto - auto-extract ticket id from branch name
|
|
91
|
+
|
|
92
|
+
RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
|
|
93
|
+
|
|
94
|
+
RBW_FORCE=1 Force Railbow formatting even when piped, in CI,
|
|
95
|
+
or called by an LLM agent (RBW_PLAIN=1 still wins)
|
|
96
|
+
|
|
97
|
+
RBW_HELP=1 Show this help message
|
|
98
|
+
|
|
99
|
+
\e[2mAuto-disabled when piped, in CI, or when called by an LLM agent.\e[0m
|
|
100
|
+
|
|
101
|
+
\e[1mExamples:\e[0m
|
|
102
|
+
rake db:migrate:status
|
|
103
|
+
RBW_SINCE=2mo RBW_VIEW=calendar rake db:migrate:status
|
|
104
|
+
RBW_CALENDAR=wdividers,counts rake db:migrate:status
|
|
105
|
+
RBW_VIEW=tables RBW_GIT=author rake db:migrate:status
|
|
106
|
+
RBW_GIT=author:me RBW_SINCE=3mo rake db:migrate:status
|
|
107
|
+
RBW_DATE=rel rake db:migrate:status
|
|
108
|
+
RBW_DATE=short rake db:migrate:status
|
|
109
|
+
RBW_DATE='custom(%b %d, %Y)' rake db:migrate:status
|
|
110
|
+
RBW_GIT=diff rake db:migrate:status
|
|
111
|
+
RBW_GIT=diff,base:develop rake db:migrate:status
|
|
112
|
+
RBW_GIT=diff,mask:(WS-[^/]+)/ rake db:migrate:status
|
|
113
|
+
RBW_DB= rake db:migrate:status
|
|
114
|
+
RBW_DB=only:primary rake db:migrate:status
|
|
115
|
+
RBW_DB=inline rake db:migrate:status
|
|
116
|
+
|
|
117
|
+
HELP
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|