railbow 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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