railbow 0.2.0 โ†’ 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e976b8252fe6a3ad465f8df1ae11fa0bcdca3baaa03883d7aa2c3ff4dc1f073f
4
- data.tar.gz: e6e498789b4c15c1932b3a96db8b429a18194336119ad56d5bfeaf9ddb85ca25
3
+ metadata.gz: 55d44fb326ce90c9bc4e843104639b49c2d96c866401bb53c962f226166bb22f
4
+ data.tar.gz: 808baadb49bf2e7d96586a1a809501bdbe87cfa454db019d184ca36804cc813a
5
5
  SHA512:
6
- metadata.gz: 0f1eaa898dfc630a06d9a7515603eb46537ee06ede2d92ac2ba45a0de4ad6413b54d9d0b2c4afa9eb8538ad656e1b94829f828fa4de370cd7cbb4060f8bd2d14
7
- data.tar.gz: 0ae1860fe55db53d8b3d9eb47459838101ebb056d8b942dcfe9d7cfc1e2d0201e8e7afccb98896c4f0bf2ec0a450128e0d6fa48bfb710d82d6b0f9437d302260
6
+ metadata.gz: c256343dee985473f88c27aa26e0bb827587d49001eaf82f7a8d85dbf3a56197d65ba12e3641d64c87cfbcac290a2b787020d1850a53ce0e8fbf746dc2d3ef9e
7
+ data.tar.gz: 4d5740b3a9a98aa692b7b031fa26a811709c4724c996ee9dfd00f0dfc3f339bf431499c27c3af46b354a7aeb7ae9bd95503f99402b1d5e44f3b1fc6314cd64a1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,74 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-07-28
9
+
10
+ ### Added
11
+
12
+ - `RBW_CALENDAR=wdividers` gives every ISO week its own separator row, the
13
+ fuller counterpart to `wticks`. A week that opens a new month is left to the
14
+ month row rather than being announced twice. The default stays `wticks`, so
15
+ existing output is unchanged until you opt in.
16
+ - `RBW_CALENDAR=counts` appends `ยท N migrations` to every separator row: how
17
+ many migrations that section holds, counted up to the next separator.
18
+ - `RBW_CALENDAR=wlabel:<fmt>` sets the strftime pattern for week rows,
19
+ alongside the existing `label:` for month rows. It defaults to the month
20
+ pattern, so the week number sits at the same offset on every separator row
21
+ rather than shifting left when no new month is announced.
22
+ - `railbow demo status` now honors `RBW_CALENDAR`, so the calendar modes can be
23
+ tried without a Rails project.
24
+ - An empty `RBW_CALENDAR` (month separators, no week markers) is now documented
25
+ in the help output, the `railbow init` template, and the README. It always
26
+ worked; nobody could have guessed it.
27
+
28
+ ### Changed
29
+
30
+ - Calendar separator rows are drawn in a muted purple (256-color 97) instead of
31
+ the brighter 141, so they frame the migrations without competing with them.
32
+ - Migrations that are not applied (`down`) now render as a greyed-out row in
33
+ `db:migrate:status`, instead of signalling their state through the status
34
+ column alone. The row keeps its layout and its status glyph keeps its color;
35
+ everything else drops its own colors, so pending migrations read as inactive
36
+ next to the applied ones.
37
+
38
+ ### Fixed
39
+
40
+ - Rows whose last column wrapped were indented with too wide a blank prefix:
41
+ building the row mutated the very cell that the wrapped-line indent is
42
+ measured from. Reachable from `routes`, `about`, and `stats`.
43
+ - A separator label wider than its column no longer pushes the rest of the row
44
+ sideways. Separator rows are drawn as empty walls with the label laid over
45
+ them, so a long label spills into the blank space to its right.
46
+ - `RBW_HELP` output for `db:migrate:status` uses plain hyphens instead of
47
+ em-dashes, matching the rest of the user-facing text.
48
+
49
+ ## [0.3.0] - 2026-07-23
50
+
51
+ ### Added
52
+
53
+ - Ghost rows in `db:migrate:status` now surface mighost 0.5's classification:
54
+ a superseded ghost (re-timestamped migration) shows a calmer ๐Ÿชฆ status with
55
+ a `โ‰ก <version>` badge pointing at its successor, and a ghost deleted with no
56
+ surviving branch shows `โœ‚ deleted in:<sha>`. Branch badges are unchanged and
57
+ take precedence right after supersession.
58
+ - `RBW_FORCE=1` forces Railbow formatting past every auto-detection
59
+ (`NO_COLOR`, CI, piped output, LLM agent detection). An explicit
60
+ `RBW_PLAIN=1` still wins.
61
+
62
+ ### Changed
63
+
64
+ - Ghost data now comes from `Mighost::API.orphaned_migrations`, so
65
+ `db:migrate:status` honors mighost dismissals and `hide_superseded` for the
66
+ first time: a dismissed or hidden ghost renders as plain `NO FILE` instead
67
+ of ๐Ÿ‘ป. Live git/worktree recovery still kicks in for versions without a
68
+ stored snapshot, so fresh clones keep working with zero setup.
69
+
70
+ ### Fixed
71
+
72
+ - A ghost row without a branch no longer inherits the previous ghost row's
73
+ branch badge (a stale local leaked across loop iterations; unreachable
74
+ before mighost 0.5 made branchless ghosts the normal case).
75
+
8
76
  ## [0.2.0] - 2026-07-23
9
77
 
10
78
  ### Added
data/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
  - **Notes** - `rails notes` with git blame, author colors, date filtering, and sorting
22
22
  - **About** - polished `rails about` output
23
23
  - **Git Integration** - authors, diffs, branch origin, landing dates, uncommitted file indicators
24
- - **Calendar View** - month separators and week tick markers for migration timelines
24
+ - **Calendar View** - month separators plus week ticks or full week rows, with optional per-section counts
25
25
  - **Ghost Recovery** - uses the [mighost](https://github.com/amberpixels/mighost) gem (optional) to recover names, authors, and branch badges for `NO FILE` migrations
26
26
  - **Smart Defaults** - auto-disables in CI, piped output, `NO_COLOR`, and LLM agents
27
27
 
@@ -105,8 +105,48 @@ Out of the box you get:
105
105
  - **Affected tables** - color-coded table names extracted from migration files
106
106
  - **Time filtering** - only the last 70 days shown by default (`since: 70d`)
107
107
  - **Your migrations highlighted** - rows authored by you are visually distinct
108
+ - **Pending migrations greyed out** - a `down` row keeps its status glyph but loses its colors
108
109
 
109
- If the [mighost](https://github.com/amberpixels/mighost) gem is installed (`gem "mighost", group: [:development, :test]`), migrations whose files were deleted (e.g. after switching branches) show up with a ๐Ÿ‘ป status and their recovered name and origin branch instead of a bare `********** NO FILE **********` row - Railbow talks to it through the stable `Mighost::API`.
110
+ #### Weekly view
111
+
112
+ By default each new ISO week is marked with a tick on the date column
113
+ (`calendar: wticks`). For a fuller weekly breakdown, `wdividers` gives every
114
+ week its own separator row, and `counts` appends how many migrations sit under
115
+ each separator, week or month:
116
+
117
+ ```bash
118
+ RBW_CALENDAR=wdividers,counts rake db:migrate:status
119
+ ```
120
+
121
+ ```
122
+ Feb 2026 W06 ยท 2 migrations
123
+ โ†‘โ†‘ โ”‚ 20260202145343 โ”‚ 2026-02-02 14:53:43 โ”‚ Add weight field to animals โ— animals
124
+ โ†‘โ†‘ โ”‚ 20260203134528 โ”‚ 2026-02-03 13:45:28 โ”‚ Create vaccination records โ— vaccination_records
125
+ Feb 2026 W07 ยท 1 migration
126
+ โ†‘โ†‘ โ”‚ 20260210183902 โ”‚ 2026-02-10 18:39:02 โ”‚ Create pet tags โ— pet_tags
127
+ Mar 2026 W10 ยท 1 migration
128
+ โ†‘โ†‘ โ”‚ 20260303120000 โ”‚ 2026-03-03 12:00:00 โ”‚ Add breed restrictions toโ€ฆ โ— adoption_policies
129
+ Mar 2026 W11 ยท 1 migration
130
+ โ†‘โ†‘ โ”‚ 20260313132325 โ”‚ 2026-03-13 13:23:25 โ”‚ Create veterinary appointmโ€ฆ โއ PS-142 โ— veterinary_appointments
131
+ ```
132
+
133
+ Every separator carries the month, so the week number always sits at the same
134
+ offset instead of shifting left on rows that open no new month, and every
135
+ separator is drawn in the same muted purple. Both labels are `strftime`
136
+ patterns: `label:` for months, `wlabel:` for weeks (defaulting to the same
137
+ pattern).
138
+
139
+ `RBW_CALENDAR` is the list of *week* markers, so an empty value leaves you with
140
+ month separators and nothing else:
141
+
142
+ ```yaml
143
+ view: "calendar,tables"
144
+ calendar: "" # month separators only
145
+ ```
146
+
147
+ Dropping `calendar` from `view` instead removes the month separators too.
148
+
149
+ If the [mighost](https://github.com/amberpixels/mighost) gem is installed (`gem "mighost", group: [:development, :test]`), migrations whose files were deleted (e.g. after switching branches) show up with a ๐Ÿ‘ป status and their recovered name instead of a bare `********** NO FILE **********` row - Railbow talks to it through the stable `Mighost::API`. Each ghost row carries the most informative badge mighost can provide: `โ‰ก <version>` when the migration was re-timestamped and lives on under another version (shown with a calmer ๐Ÿชฆ status), `โŒฅ <branch>` when a branch still holds the file, or `โœ‚ deleted in:<sha>` pointing at the commit that removed it. Ghosts dismissed via `mighost:dismiss` (or hidden by `hide_superseded`) render as plain `NO FILE`.
110
150
 
111
151
  ### `rails db:migrate:down`
112
152
 
@@ -176,11 +216,12 @@ Every option can also be set via `RBW_*` environment variables, which override c
176
216
  | Variable | Example | Description |
177
217
  |---|---|---|
178
218
  | `RBW_PLAIN` | `1` | Disable all formatting |
219
+ | `RBW_FORCE` | `1` | Force formatting even when piped, in CI, or run by an LLM agent (`RBW_PLAIN=1` still wins) |
179
220
  | `RBW_SINCE` | `2mo`, `70d`, `1y`, `all` | Filter migrations by time period |
180
221
  | `RBW_DATE` | `full`, `rel`, `short`, `custom(%b %d)` | Date display format |
181
222
  | `RBW_GIT` | `author:me,diff,mask:auto` | Git integration options |
182
223
  | `RBW_VIEW` | `calendar,tables` | Enable calendar view and table detection |
183
- | `RBW_CALENDAR` | `wticks` | Show week tick markers |
224
+ | `RBW_CALENDAR` | `wticks`, `wdividers,counts`, `` (empty) | Week markers: tick marks, full week separator rows, per-section counts. Empty means month separators only |
184
225
  | `RBW_COMPACT` | `oneline,dense,noheader,maxw:80` | Compact display options |
185
226
  | `RBW_VERB` | `GET,POST` | Filter routes by HTTP method |
186
227
  | `RBW_SORT` | `file`, `date` | Sort order for notes |
@@ -221,6 +262,10 @@ Formatting auto-disables when:
221
262
  - Output is piped or redirected (non-TTY)
222
263
  - Running inside an LLM agent (`CLAUDECODE` env var)
223
264
 
265
+ `RBW_FORCE=1` overrides all of the auto-detection above (useful for capturing
266
+ formatted output to a file, or letting an agent inspect the real rendering).
267
+ An explicit `RBW_PLAIN=1` always wins over `RBW_FORCE`.
268
+
224
269
  ## Requirements
225
270
 
226
271
  - Ruby >= 3.1.0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Railbow
6
+ # Turns an ordered list of migration versions into the calendar furniture that
7
+ # gives the status table its shape: a separator row when the month changes,
8
+ # optionally one when the ISO week changes, and optionally a tick mark on the
9
+ # date column at each week boundary.
10
+ #
11
+ # A separator introduces the rows beneath it and runs until the next separator,
12
+ # so the optional count always answers "how many migrations in this section".
13
+ module Calendar
14
+ DEFAULT_MONTH_LABEL = "%b %Y W%V"
15
+ # Week rows carry the month too, so the week number sits at the same offset
16
+ # on every separator row rather than jumping left when the month is absent.
17
+ DEFAULT_WEEK_LABEL = DEFAULT_MONTH_LABEL
18
+
19
+ # What the renderer needs to draw the calendar: a label per separator row,
20
+ # and which rows open a week (for tick marks). Both keyed by row index.
21
+ class Furniture
22
+ attr_reader :separators, :tick_rows
23
+
24
+ def initialize
25
+ @separators = {}
26
+ @tick_rows = Set.new
27
+ end
28
+ end
29
+
30
+ module_function
31
+
32
+ def none
33
+ Furniture.new
34
+ end
35
+
36
+ def build(versions, weeks: false, ticks: false, counts: false,
37
+ month_label: DEFAULT_MONTH_LABEL, week_label: DEFAULT_WEEK_LABEL)
38
+ dates = versions.map { |v| parse_date(v) }
39
+ furniture = none
40
+
41
+ dates.each_with_index do |date, i|
42
+ prev = (i > 0) ? dates[i - 1] : nil
43
+ next unless date && prev
44
+
45
+ opens_week = week_key(prev) != week_key(date)
46
+
47
+ if month_key(prev) != month_key(date)
48
+ furniture.separators[i] = date.strftime(month_label)
49
+ elsif weeks && opens_week
50
+ furniture.separators[i] = date.strftime(week_label)
51
+ end
52
+
53
+ furniture.tick_rows << i if ticks && opens_week
54
+ end
55
+
56
+ append_counts(furniture.separators, dates.size) if counts
57
+ furniture
58
+ end
59
+
60
+ def parse_date(version)
61
+ v = version.to_s
62
+ Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
63
+ rescue Date::Error
64
+ nil
65
+ end
66
+
67
+ def month_key(date)
68
+ [date.year, date.month]
69
+ end
70
+
71
+ # cwyear, not year: the ISO week of Jan 1 can belong to the year before.
72
+ def week_key(date)
73
+ [date.cwyear, date.cweek]
74
+ end
75
+
76
+ def append_counts(separators, total)
77
+ indices = separators.keys.sort
78
+ indices.each_with_index do |index, nth|
79
+ section_end = indices[nth + 1] || total
80
+ separators[index] = "#{separators[index]} ยท #{migration_count(section_end - index)}"
81
+ end
82
+ end
83
+
84
+ # Spelled out: a bare number next to a week label reads as part of the date.
85
+ def migration_count(count)
86
+ "#{count} #{(count == 1) ? "migration" : "migrations"}"
87
+ end
88
+ end
89
+ end
@@ -3,7 +3,9 @@
3
3
  require "date"
4
4
  require_relative "../formatters/base"
5
5
  require_relative "../table"
6
+ require_relative "../calendar"
6
7
  require_relative "../config"
8
+ require_relative "../params"
7
9
  require_relative "fixtures"
8
10
 
9
11
  module Railbow
@@ -20,7 +22,8 @@ module Railbow
20
22
  puts "\n#{formatter.emoji(:status)} Database: #{formatter.cyan("db/development.sqlite3")}"
21
23
  puts
22
24
 
23
- # Use default config: author:me, diff, calendar, tables, wticks, full date
25
+ # Fixed for the showcase: author:me, diff, tables, full date. The calendar
26
+ # options below come from the real params, so RBW_CALENDAR still applies.
24
27
  date_format = "full"
25
28
  tables_enabled = true
26
29
  diff_enabled = true
@@ -28,7 +31,7 @@ module Railbow
28
31
  git_email = Fixtures::DEMO_USER_EMAIL
29
32
  git_name = Fixtures::DEMO_USER_NAME
30
33
 
31
- # Latest migration date โ€” for "fresh" landed badge detection
34
+ # Latest migration date - for "fresh" landed badge detection
32
35
  latest_version = migrations.last[:version]
33
36
  latest_mig_date = parse_version_date(latest_version)
34
37
 
@@ -43,7 +46,7 @@ module Railbow
43
46
  name_col_width = needs_name_truncation ? 60 : nil
44
47
 
45
48
  table_columns = [
46
- Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true),
49
+ Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true, accent: true),
47
50
  Railbow::Table::Column.new(label: "Migration ID", sticky: true),
48
51
  Railbow::Table::Column.new(label: "Created At"),
49
52
  Railbow::Table::Column.new(label: "Migration Name",
@@ -57,7 +60,9 @@ module Railbow
57
60
 
58
61
  # Build rows
59
62
  highlight_rows = Set.new
63
+ down_rows = Set.new
60
64
  rows = migrations.each_with_index.map do |m, idx|
65
+ down_rows << idx if m[:status] == "down"
61
66
  colored_status = case m[:status]
62
67
  when "up" then formatter.green_bold("up")
63
68
  when "down" then formatter.yellow_bold("down")
@@ -106,40 +111,16 @@ module Railbow
106
111
  [colored_status, m[:version], created_at, display_name, table_tags]
107
112
  end
108
113
 
109
- # Calendar separators
110
- separators = {}
111
- versions = migrations.map { |m| m[:version] }
112
- month_keys = versions.map { |v| v[0..5] }
113
- calendar_label_fmt = "%b %Y W%V"
114
-
115
- if month_keys.uniq.size > 1
116
- month_keys.each_with_index do |mk, i|
117
- next if i == 0
118
- if mk != month_keys[i - 1]
119
- v = versions[i]
120
- date = Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
121
- separators[i] = date.strftime(calendar_label_fmt)
122
- end
123
- end
124
- end
125
-
126
- # Week ticks
127
- tick_rows = Set.new
128
- prev_week = nil
129
- versions.each_with_index do |v, i|
130
- y = v[0..3].to_i
131
- m = v[4..5].to_i
132
- d = v[6..7].to_i
133
- next if y == 0 || m == 0 || d == 0
134
-
135
- week = Date.new(y, m, d).cweek
136
-
137
- if i > 0 && prev_week && week != prev_week
138
- tick_rows << i
139
- end
140
-
141
- prev_week = week
142
- end
114
+ # Calendar furniture. Driven by the real params, so RBW_CALENDAR can be
115
+ # tried out here without a Rails project.
116
+ calendar = Railbow::Calendar.build(
117
+ migrations.map { |m| m[:version] },
118
+ weeks: Railbow::Params.calendar_wdividers?,
119
+ ticks: Railbow::Params.calendar_wticks?,
120
+ counts: Railbow::Params.calendar_counts?,
121
+ month_label: Railbow::Params.calendar_label,
122
+ week_label: Railbow::Params.calendar_week_label
123
+ )
143
124
 
144
125
  aliases = Railbow::Config.table_aliases
145
126
  renderer = Railbow::Table::Renderer.new(
@@ -148,7 +129,10 @@ module Railbow
148
129
  compact: {oneline: false, dense: false, noheader: false, maxw: nil, hidden_columns: []},
149
130
  aliases: aliases
150
131
  )
151
- puts renderer.render(rows, separators: separators, highlight_rows: highlight_rows, tick_rows: tick_rows, tick_col: 2)
132
+ puts renderer.render(rows,
133
+ separators: calendar.separators,
134
+ highlight_rows: highlight_rows, dim_rows: down_rows,
135
+ tick_rows: calendar.tick_rows, tick_col: 2)
152
136
  end
153
137
 
154
138
  private
data/lib/railbow/init.rb CHANGED
@@ -43,7 +43,13 @@ module Railbow
43
43
  # View mode (comma-separated): calendar, tables
44
44
  view: "calendar,tables"
45
45
 
46
- # Calendar options: wticks (show week tick separators)
46
+ # Calendar options (comma-separated), all require view: calendar.
47
+ # Leave empty ("") for month separators only, with no week markers.
48
+ # wticks - week tick marks on the date column
49
+ # wdividers - a separator row per ISO week
50
+ # counts - append "ยท N migrations" to separator rows (per section)
51
+ # label:<fmt> - strftime for month separators (default: %b %Y W%V)
52
+ # wlabel:<fmt> - strftime for week separators (default: same as label)
47
53
  calendar: "wticks"
48
54
 
49
55
  # Date format: full, rel, short, or custom(%b %d, %Y)
@@ -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"
@@ -71,6 +72,10 @@ module Railbow
71
72
  truthy?(ENV["RBW_PLAIN"])
72
73
  end
73
74
 
75
+ def force?
76
+ truthy?(ENV["RBW_FORCE"])
77
+ end
78
+
74
79
  def since
75
80
  (ENV["RBW_SINCE"] || Config.load["since"] || "all").strip.downcase
76
81
  end
@@ -222,8 +227,26 @@ module Railbow
222
227
  calendar["wticks"] == true
223
228
  end
224
229
 
230
+ # A separator row per ISO week, the fuller counterpart to wticks.
231
+ def calendar_wdividers?
232
+ return false unless view_calendar?
233
+
234
+ calendar["wdividers"] == true
235
+ end
236
+
237
+ # Append "ยท N migrations" to every separator row: how many it introduces.
238
+ def calendar_counts?
239
+ return false unless view_calendar?
240
+
241
+ calendar["counts"] == true
242
+ end
243
+
225
244
  def calendar_label
226
- calendar["label"] || "%b %Y W%V"
245
+ calendar["label"] || Calendar::DEFAULT_MONTH_LABEL
246
+ end
247
+
248
+ def calendar_week_label
249
+ calendar["wlabel"] || Calendar::DEFAULT_WEEK_LABEL
227
250
  end
228
251
  end
229
252
  end
@@ -3,9 +3,11 @@
3
3
  module Railbow
4
4
  module Table
5
5
  class Column
6
- attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn, :sticky
6
+ attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn, :sticky, :accent
7
7
 
8
- def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false)
8
+ # accent: the column carries its own meaning through color (a status
9
+ # glyph, say), so it keeps that color when the row is dimmed.
10
+ def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false)
9
11
  @label = label
10
12
  @width = width
11
13
  @min_width = min_width
@@ -14,6 +16,7 @@ module Railbow
14
16
  @truncate = truncate
15
17
  @truncate_fn = truncate_fn
16
18
  @sticky = sticky
19
+ @accent = accent
17
20
  end
18
21
 
19
22
  def fixed?
@@ -10,8 +10,9 @@ module Railbow
10
10
 
11
11
  RESET = "\e[0m"
12
12
  WHITE = "\e[97m"
13
- GHOST_BG = "\e[48;5;52m" # deep red/maroon background โ€” stands out as abnormal
13
+ GHOST_BG = "\e[48;5;52m" # deep red/maroon background - stands out as abnormal
14
14
  GHOST_FG = "\e[38;5;217m" # warm pink foreground for contrast
15
+ DIMMED_FG = "\e[38;5;242m" # muted grey - a row that is not currently in effect
15
16
 
16
17
  attr_reader :columns, :theme
17
18
 
@@ -23,7 +24,7 @@ module Railbow
23
24
  @theme = theme
24
25
  end
25
26
 
26
- def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, tick_rows: Set.new, tick_col: nil)
27
+ def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, dim_rows: Set.new, tick_rows: Set.new, tick_col: nil)
27
28
  return "" if columns.empty?
28
29
 
29
30
  # Remap rows if columns were hidden
@@ -64,12 +65,11 @@ module Railbow
64
65
  rows.each_with_index do |row, i|
65
66
  tc = (tick_rows.include?(i) && tick_col) ? tick_col : nil
66
67
  if separators.key?(i) && theme.format_separator
67
- sep_row = Array.new(columns.size, "")
68
- sep_row[1] = theme.format_separator.call(separators[i]) if columns.size > 1
69
- lines << render_row(sep_row, resolved, tick_col: tc, tick_cross: true)
68
+ lines << render_separator_row(separators[i], resolved, tick_col: tc)
70
69
  tc = nil # tick already shown on separator row
71
70
  end
72
- formatted = render_row(row, resolved, tick_col: tc, highlight: highlight_rows.include?(i), ghost: ghost_rows.include?(i))
71
+ formatted = render_row(row, resolved, tick_col: tc, highlight: highlight_rows.include?(i),
72
+ ghost: ghost_rows.include?(i), dim: dim_rows.include?(i))
73
73
  lines << formatted
74
74
  end
75
75
  lines.join("\n")
@@ -112,44 +112,65 @@ module Railbow
112
112
  }.join(theme.header_col_separator)
113
113
  end
114
114
 
115
- def render_row(row, widths, tick_col: nil, tick_cross: false, highlight: false, ghost: false)
115
+ # A separator row is furniture, not data: the walls are drawn empty and the
116
+ # label is laid over them starting at the second column. Writing over the
117
+ # blanks rather than filling a cell means a label wider than its column
118
+ # spills into the empty space to its right instead of shifting the row.
119
+ def render_separator_row(label, widths, tick_col: nil)
120
+ pad = effective_padding
121
+ cells = widths.map { |w| "#{pad}#{" " * w}#{pad}" }
122
+
123
+ skeleton = cells.first.to_s.dup
124
+ (1...cells.size).each do |i|
125
+ skeleton << "#{separator_at(i - 1, tick_col, cross: true)}#{cells[i]}"
126
+ end
127
+ return skeleton if cells.size < 2 || label.nil? || label.empty?
128
+
129
+ offset = display_width(cells[0]) + display_width(separator_at(0, tick_col, cross: true)) + display_width(pad)
130
+ tail = skeleton[(offset + display_width(label))..] || ""
131
+ "#{skeleton[0, offset]}#{theme.format_separator.call(label)}#{tail}"
132
+ end
133
+
134
+ # The separator character between column index and index + 1. Columns
135
+ # flanking the tick column get the tick variant so the week line reads
136
+ # as one continuous rule.
137
+ def separator_at(index, tick_col, cross: false)
138
+ flanks_tick = tick_col && (index == tick_col - 1 || index == tick_col)
139
+ return theme.col_separator unless flanks_tick
140
+
141
+ cross ? theme.tick_cross_separator : theme.tick_separator
142
+ end
143
+
144
+ def render_row(row, widths, tick_col: nil, highlight: false, ghost: false, dim: false)
116
145
  last = columns.size - 1
117
146
  pad = effective_padding
118
- default_sep = theme.col_separator
119
- tick_sep = tick_cross ? theme.tick_cross_separator : theme.tick_separator
120
147
 
121
148
  prefix_parts = row[0...last].each_with_index.map { |cell, i|
122
149
  s = cell.to_s
123
150
  cell_w = display_width(strip_ansi(s))
124
151
  padding = " " * [widths[i] - cell_w, 0].max
125
152
  content = (columns[i].align == :right) ? "#{padding}#{s}" : "#{s}#{padding}"
126
- if ghost
127
- content = "#{GHOST_BG}#{GHOST_FG}#{content}#{RESET}"
128
- elsif highlight
129
- content = "#{WHITE}#{content}#{RESET}"
130
- end
153
+ content = style_cell(content, highlight: highlight, ghost: ghost, dim: dim, accent: columns[i].accent)
131
154
  "#{pad}#{content}#{RESET}#{pad}"
132
155
  }
133
156
 
134
- # Join prefix parts with per-position separators
135
- # Loop index i joins column i-1 and column i (separator_index = i-1)
136
- # For tick_col, flanking separator indices are tick_col-1 and tick_col
137
- prefix = prefix_parts.first.to_s
157
+ # Join prefix parts with per-position separators.
158
+ # Loop index i joins column i-1 and column i (separator index = i-1).
159
+ # dup: appending to prefix_parts.first itself would corrupt the parts
160
+ # that render_last_cell measures for the wrapped-line indent.
161
+ prefix = prefix_parts.first.to_s.dup
138
162
  (1...prefix_parts.size).each do |i|
139
- sep_idx = i - 1
140
- sep = (tick_col && (sep_idx == tick_col - 1 || sep_idx == tick_col)) ? tick_sep : default_sep
141
- prefix << "#{sep}#{prefix_parts[i]}"
163
+ prefix << "#{separator_at(i - 1, tick_col)}#{prefix_parts[i]}"
142
164
  end
143
165
 
144
166
  last_cell_raw = row[last].to_s
145
167
 
146
168
  # Separator before the last column has index (last - 1)
147
- last_sep_idx = last - 1
148
- last_sep = (tick_col && (last_sep_idx == tick_col - 1 || last_sep_idx == tick_col)) ? tick_sep : default_sep
149
- render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: last_sep, highlight: highlight, ghost: ghost)
169
+ last_sep = separator_at(last - 1, tick_col)
170
+ render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: last_sep, highlight: highlight, ghost: ghost, dim: dim)
150
171
  end
151
172
 
152
- def render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: nil, highlight: false, ghost: false)
173
+ def render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: nil, highlight: false, ghost: false, dim: false)
153
174
  pad = effective_padding
154
175
  sep = col_sep || theme.col_separator
155
176
 
@@ -167,7 +188,7 @@ module Railbow
167
188
  if columns[last].truncate_fn && last_col_max &&
168
189
  display_width(last_cell_plain) > last_col_max
169
190
  last_cell_raw = columns[last].truncate_fn.call(last_cell_raw, last_col_max)
170
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
191
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
171
192
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
172
193
  end
173
194
 
@@ -175,18 +196,18 @@ module Railbow
175
196
  if columns[last].truncate && !columns[last].max_width && last_col_max &&
176
197
  display_width(last_cell_plain) > last_col_max
177
198
  last_cell_raw = truncate_by_words(last_cell_raw, last_col_max)
178
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
199
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
179
200
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
180
201
  end
181
202
 
182
203
  # In oneline mode, truncate instead of wrapping
183
204
  if @compact[:oneline] && last_col_max && display_width(last_cell_plain) > last_col_max
184
205
  last_cell_raw = truncate_by_words(last_cell_raw, last_col_max)
185
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
206
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
186
207
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
187
208
  end
188
209
 
189
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
210
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
190
211
 
191
212
  if last_col_max && display_width(strip_ansi(last_cell_raw)) > last_col_max
192
213
  blank_prefix = prefix_parts.map { |part|
@@ -201,9 +222,17 @@ module Railbow
201
222
  end
202
223
  end
203
224
 
204
- def style_cell(content, highlight: false, ghost: false)
225
+ def style_cell(content, highlight: false, ghost: false, dim: false, accent: false)
205
226
  if ghost
206
227
  "#{GHOST_BG}#{GHOST_FG}#{content}#{RESET}"
228
+ elsif dim
229
+ # An accent column keeps its color - on a dimmed row it is the one
230
+ # cell still carrying meaning. Everything else drops its own colors:
231
+ # one flat grey is what reads as "not in effect", and the resets that
232
+ # end each inner color would break the grey run anyway.
233
+ return content if accent
234
+
235
+ "#{DIMMED_FG}#{strip_ansi(content)}#{RESET}"
207
236
  elsif highlight
208
237
  "#{WHITE}#{content}#{RESET}"
209
238
  else
@@ -315,7 +344,7 @@ module Railbow
315
344
  current << token
316
345
  current_width += token_width
317
346
  elsif current_width.zero?
318
- # Single token wider than max โ€” try to break on underscores
347
+ # Single token wider than max - try to break on underscores
319
348
  broken = break_long_token(token, max_width)
320
349
  lines.concat(broken[0...-1])
321
350
  current = +broken.last
@@ -8,7 +8,8 @@ module Railbow
8
8
  :format_header_cell, :format_separator
9
9
 
10
10
  def initialize(col_separator:, header_col_separator:, cell_padding:,
11
- format_header_cell:, format_separator: nil, tick_separator: nil, tick_cross_separator: nil)
11
+ format_header_cell:, format_separator: nil,
12
+ tick_separator: nil, tick_cross_separator: nil)
12
13
  @col_separator = col_separator
13
14
  @tick_separator = tick_separator || col_separator
14
15
  @tick_cross_separator = tick_cross_separator || @tick_separator
@@ -23,7 +24,7 @@ module Railbow
23
24
  RESET = "\e[0m"
24
25
  BOLD = "\e[1m"
25
26
  WHITE = "\e[97m"
26
- PURPLE = "\e[38;5;141m"
27
+ PURPLE = "\e[38;5;97m" # separator rows: present, but never louder than the data
27
28
  BG_PURPLE = "\e[48;5;99m"
28
29
  DIM = "\e[2m"
29
30
 
@@ -6,14 +6,120 @@ require_relative "../formatters/base"
6
6
  require_relative "../migration_parser"
7
7
  require_relative "../config"
8
8
  require_relative "../table"
9
+ require_relative "../calendar"
9
10
  require_relative "../logo"
10
11
 
11
12
  # Override DatabaseTasks.migrate_status which is called by both
12
13
  # db:migrate:status and db:migrate:status:<database_name> tasks.
13
14
  module Railbow
14
15
  module MigrateStatusFormatter
16
+ # Ghost migration data normalized for rendering, whether it came from
17
+ # mighost's orphan classification or a live snapshot recovery.
18
+ class GhostRow
19
+ attr_reader :filename, :branch_name, :source, :superseded_by, :deleted_in_sha,
20
+ :author_name, :author_email, :content
21
+
22
+ def initialize(filename: nil, branch_name: nil, source: nil, superseded_by: nil,
23
+ deleted_in_sha: nil, author_name: nil, author_email: nil, content: nil)
24
+ @filename = filename
25
+ @branch_name = branch_name
26
+ @source = source
27
+ @superseded_by = superseded_by
28
+ @deleted_in_sha = deleted_in_sha
29
+ @author_name = author_name
30
+ @author_email = author_email
31
+ @content = content
32
+ end
33
+ end
34
+
15
35
  private
16
36
 
37
+ def mighost_attr(obj, name)
38
+ obj.respond_to?(name) ? obj.public_send(name) : nil
39
+ end
40
+
41
+ def mighost_snapshot_content(version)
42
+ Mighost::API.find_snapshot(version)&.content
43
+ rescue
44
+ nil
45
+ end
46
+
47
+ def load_ghost_rows(versions, with_content: false)
48
+ # Detect once: OrphanedMigration carries the classification (supersession,
49
+ # deletion commit) that a bare snapshot doesn't, and already honors
50
+ # dismissals and hide_superseded.
51
+ orphans = begin
52
+ Mighost::API.orphaned_migrations.to_h { |o| [o.version.to_s, o] }
53
+ rescue
54
+ return {}
55
+ end
56
+
57
+ rows = {}
58
+ versions.each do |v|
59
+ # Absent from detect = deliberately suppressed (dismissed, or superseded
60
+ # with hide_superseded on) - render as plain NO FILE, don't re-recover.
61
+ next unless (orphan = orphans[v])
62
+
63
+ if orphan.filename && !orphan.filename.empty?
64
+ rows[v] = GhostRow.new(
65
+ filename: orphan.filename,
66
+ branch_name: orphan.branch_name,
67
+ source: mighost_attr(orphan, :source),
68
+ superseded_by: mighost_attr(orphan, :superseded_by),
69
+ deleted_in_sha: mighost_attr(orphan, :deleted_in_sha),
70
+ author_name: mighost_attr(orphan, :author_name),
71
+ author_email: mighost_attr(orphan, :author_email),
72
+ content: with_content ? mighost_snapshot_content(v) : nil
73
+ )
74
+ else
75
+ # Detect reads stored snapshots only. A version it lists without a
76
+ # filename has no snapshot yet, so fall back to live git/worktree
77
+ # recovery - keeps fresh clones working with zero setup.
78
+ snapshot = begin
79
+ Mighost::API.find_or_recover_snapshot(v)
80
+ rescue
81
+ nil
82
+ end
83
+ next unless snapshot&.filename && !snapshot.filename.empty?
84
+
85
+ rows[v] = GhostRow.new(
86
+ filename: snapshot.filename,
87
+ branch_name: snapshot.branch_name,
88
+ source: mighost_attr(snapshot, :source),
89
+ superseded_by: api_superseded_by(v),
90
+ deleted_in_sha: mighost_attr(snapshot, :deleted_in_sha),
91
+ author_name: mighost_attr(snapshot, :author_name),
92
+ author_email: mighost_attr(snapshot, :author_email),
93
+ content: with_content ? snapshot.content : nil
94
+ )
95
+ end
96
+ end
97
+ rows
98
+ end
99
+
100
+ def api_superseded_by(version)
101
+ return nil unless Mighost::API.respond_to?(:superseded_by)
102
+
103
+ Mighost::API.superseded_by(version)
104
+ rescue
105
+ nil
106
+ end
107
+
108
+ # One tag slot per ghost row; most informative wins.
109
+ def ghost_tag(ghost)
110
+ if ghost.superseded_by
111
+ "\e[38;5;245mโ‰ก #{ghost.superseded_by}\e[38;5;217m"
112
+ elsif ghost.branch_name
113
+ if ghost.source == "worktree"
114
+ "\e[38;5;222mโŒฅโ‚œ#{ghost.branch_name}\e[38;5;217m"
115
+ else
116
+ "\e[38;5;222mโŒฅ #{ghost.branch_name}\e[38;5;217m"
117
+ end
118
+ elsif ghost.deleted_in_sha
119
+ "\e[38;5;245mโœ‚ deleted in:#{ghost.deleted_in_sha[0, 8]}\e[38;5;217m"
120
+ end
121
+ end
122
+
17
123
  def git_migration_authors(migrate_dir)
18
124
  output, _status = Railbow::GitUtils.capture2(
19
125
  "log", "--format=COMMIT:%aN\t%aE", "--diff-filter=AR", "--name-status", "--", migrate_dir
@@ -165,7 +271,7 @@ module Railbow
165
271
  # 1. Filter out child branches: if branch A is an ancestor of branch B,
166
272
  # the commit was introduced in A, not B.
167
273
  # 2. Among remaining, prefer the branch with the MOST commits after the
168
- # adding commit โ€” it has been active longer since the commit was made,
274
+ # adding commit - it has been active longer since the commit was made,
169
275
  # indicating it is the original branch (not a newer fork).
170
276
  best = if branches.size == 1
171
277
  branches.first
@@ -271,39 +377,50 @@ module Railbow
271
377
  Units: d (days), w (weeks), mo/m (months), y (years)
272
378
 
273
379
  RBW_DATE=<mode> Date column format (default: full):
274
- full โ€” 2026-01-30 12:08:54 (column: Created At)
275
- rel โ€” ~3d ago
276
- short โ€” Jan 30 (column: Date)
277
- custom(โ€ฆ) โ€” user strftime, e.g. custom(%b %d, %Y)
380
+ full - 2026-01-30 12:08:54 (column: Created At)
381
+ rel - ~3d ago
382
+ short - Jan 30 (column: Date)
383
+ custom(โ€ฆ) - user strftime, e.g. custom(%b %d, %Y)
278
384
 
279
385
  RBW_VIEW=<options> Display options (comma-separated):
280
- calendar โ€” show month/year separator lines + week ticks
281
- tables โ€” parse migration files, show Tables column
386
+ calendar - show month/year separator lines + week ticks
387
+ tables - parse migration files, show Tables column
282
388
 
283
389
  RBW_COMPACT=<options> Compact display (comma-separated):
284
- oneline โ€” truncate instead of wrapping
285
- dense โ€” remove cell padding
286
- noheader โ€” hide table header row
287
- maxw:<n> โ€” cap column widths at n chars
288
- hide:<col> โ€” hide a column by name (repeatable)
289
-
290
- RBW_CALENDAR=<options> Calendar sub-options (requires RBW_VIEW=calendar):
291
- wticks โ€” show week tick marks on date column
292
- label:<fmt> โ€” strftime format for month separator
293
- (default: %b %Y W%V)
390
+ oneline - truncate instead of wrapping
391
+ dense - remove cell padding
392
+ noheader - hide table header row
393
+ maxw:<n> - cap column widths at n chars
394
+ hide:<col> - hide a column by name (repeatable)
395
+
396
+ RBW_CALENDAR=<options> Calendar sub-options (requires RBW_VIEW=calendar):
397
+ (empty) - month separators only, no week
398
+ markers at all
399
+ wticks - week tick marks on the date column
400
+ wdividers - a separator row per ISO week
401
+ counts - append "ยท N migrations" to every
402
+ separator row (that section)
403
+ label:<fmt> - strftime for month separators
404
+ (default: %b %Y W%V)
405
+ wlabel:<fmt> - strftime for week separators
406
+ (default: same as label, so the
407
+ week number never shifts)
294
408
 
295
409
  RBW_GIT=<options> Git integration (comma-separated):
296
- author โ€” add an Author column (same as author:all)
297
- author:all โ€” add an Author column
298
- author:me โ€” highlight your own migrations
299
- diff โ€” tag migrations by git origin
300
- base:<branch> โ€” base branch for diff (default: auto-detected)
301
- mask:<re> โ€” regex to extract branch label
410
+ author - add an Author column (same as author:all)
411
+ author:all - add an Author column
412
+ author:me - highlight your own migrations
413
+ diff - tag migrations by git origin
414
+ base:<branch> - base branch for diff (default: auto-detected)
415
+ mask:<re> - regex to extract branch label
302
416
  e.g. mask:(WS-[^/]+)/
303
- mask:auto โ€” auto-extract ticket id from branch name
417
+ mask:auto - auto-extract ticket id from branch name
304
418
 
305
419
  RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
306
420
 
421
+ RBW_FORCE=1 Force Railbow formatting even when piped, in CI,
422
+ or called by an LLM agent (RBW_PLAIN=1 still wins)
423
+
307
424
  RBW_HELP=1 Show this help message
308
425
 
309
426
  \e[2mAuto-disabled when piped, in CI, or when called by an LLM agent.\e[0m
@@ -311,6 +428,7 @@ module Railbow
311
428
  \e[1mExamples:\e[0m
312
429
  rake db:migrate:status
313
430
  RBW_SINCE=2mo RBW_VIEW=calendar rake db:migrate:status
431
+ RBW_CALENDAR=wdividers,counts rake db:migrate:status
314
432
  RBW_VIEW=tables RBW_GIT=author rake db:migrate:status
315
433
  RBW_GIT=author:me RBW_SINCE=3mo rake db:migrate:status
316
434
  RBW_DATE=rel rake db:migrate:status
@@ -373,7 +491,7 @@ module Railbow
373
491
 
374
492
  skipped = total_count - db_list.size
375
493
  if skipped > 0
376
- puts formatter.dim(" (#{skipped} older migrations hidden โ€” SINCE=#{since_value})")
494
+ puts formatter.dim(" (#{skipped} older migrations hidden - SINCE=#{since_value})")
377
495
  puts
378
496
  end
379
497
  end
@@ -428,23 +546,16 @@ module Railbow
428
546
  uncommitted_files.each { |f| branch_origins[f] ||= current_branch }
429
547
  end
430
548
 
431
- # Load mighost snapshots for "NO FILE" migrations (if mighost gem is available)
549
+ # Load mighost ghost data for "NO FILE" migrations (if mighost gem is available)
432
550
  mighost_snapshots = {}
433
551
  mighost_available = defined?(Mighost::API) && Mighost.enabled?
434
552
  if mighost_available
435
553
  no_file_versions = db_list.select { |_, _, n| n.include?("NO FILE") }.map { |_, v, _| v.to_s }
436
- no_file_versions.each do |v|
437
- snapshot = begin
438
- Mighost::API.find_or_recover_snapshot(v)
439
- rescue
440
- nil
441
- end
442
- mighost_snapshots[v] = snapshot if snapshot&.filename && !snapshot.filename.empty?
443
- end
554
+ mighost_snapshots = load_ghost_rows(no_file_versions, with_content: tables_enabled) if no_file_versions.any?
444
555
  end
445
556
 
446
557
  # Build columns
447
- # Latest migration ID date โ€” used to determine "fresh" landed badges
558
+ # Latest migration ID date - used to determine "fresh" landed badges
448
559
  latest_version = db_list.last&.dig(1).to_s
449
560
  latest_mig_date = begin
450
561
  Date.new(latest_version[0..3].to_i, latest_version[4..5].to_i, latest_version[6..7].to_i)
@@ -464,7 +575,7 @@ module Railbow
464
575
  needs_name_truncation = tables_enabled || author_mode == "all" || diff_enabled || has_landed_tags
465
576
  name_col_width = needs_name_truncation ? 60 : nil
466
577
  table_columns = [
467
- Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true),
578
+ Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true, accent: true),
468
579
  Railbow::Table::Column.new(label: "Migration ID", sticky: true),
469
580
  Railbow::Table::Column.new(label: (date_format == "full") ? "Created At" : "Date"),
470
581
  Railbow::Table::Column.new(label: "Migration Name",
@@ -489,10 +600,14 @@ module Railbow
489
600
  {}
490
601
  end
491
602
 
492
- # Build rows and track highlight/ghost indices
603
+ # Build rows and track highlight/ghost/down indices
493
604
  highlight_rows = Set.new
494
605
  ghost_rows = Set.new
606
+ down_rows = Set.new
495
607
  rows = db_list.each_with_index.map do |(status, version, name), idx|
608
+ # A pending migration is not in effect yet: grey the whole row out so it
609
+ # reads as inactive next to the applied ones.
610
+ down_rows << idx if status == "down"
496
611
  colored_status = case status
497
612
  when "up" then formatter.green_bold("up")
498
613
  when "down" then formatter.yellow_bold("down")
@@ -501,29 +616,25 @@ module Railbow
501
616
  ghost_snapshot = name.include?("NO FILE") ? mighost_snapshots[version.to_s] : nil
502
617
  if name.include?("NO FILE") && ghost_snapshot
503
618
  ghost_rows << idx
504
- # Mighost recovered this ghost migration โ€” show ๐Ÿ‘ป status + name + branch badge
505
- colored_status = "๐Ÿ‘ป"
619
+ # Mighost recovered this ghost migration - show ghost status + name + badge.
620
+ # A superseded ghost lives on under another version: stale bookkeeping,
621
+ # not a lost migration, so it gets a calmer glyph.
622
+ colored_status = ghost_snapshot.superseded_by ? "๐Ÿชฆ" : "๐Ÿ‘ป"
506
623
  ghost_name = ghost_snapshot.filename
507
624
  .sub(/\A\d+_/, "") # strip version prefix
508
625
  .sub(/\.rb\z/, "") # strip extension
509
626
  .tr("_", " ")
510
627
  .gsub(/\b\w/, &:upcase) # titleize
511
- if ghost_snapshot.branch_name
512
- branch_tag = if ghost_snapshot.respond_to?(:source) && ghost_snapshot.source == "worktree"
513
- "\e[38;5;222mโŒฅโ‚œ#{ghost_snapshot.branch_name}\e[38;5;217m"
514
- else
515
- "\e[38;5;222mโŒฅ #{ghost_snapshot.branch_name}\e[38;5;217m"
516
- end
517
- end
518
- if branch_tag && name_col_width
519
- tag_width = formatter.display_width(formatter.strip_ansi(branch_tag))
628
+ ghost_badge = ghost_tag(ghost_snapshot)
629
+ if ghost_badge && name_col_width
630
+ tag_width = formatter.display_width(formatter.strip_ansi(ghost_badge))
520
631
  available = name_col_width - tag_width - 2
521
632
  ghost_name = formatter.truncate_str(ghost_name, available)
522
633
  name_width = formatter.display_width(ghost_name)
523
634
  padding = name_col_width - name_width - tag_width
524
- display_name = "#{ghost_name}#{" " * [padding, 2].max}#{branch_tag}"
525
- elsif branch_tag
526
- display_name = "#{ghost_name} #{branch_tag}"
635
+ display_name = "#{ghost_name}#{" " * [padding, 2].max}#{ghost_badge}"
636
+ elsif ghost_badge
637
+ display_name = "#{ghost_name} #{ghost_badge}"
527
638
  else
528
639
  display_name = ghost_name
529
640
  end
@@ -604,7 +715,7 @@ module Railbow
604
715
  filepath = version_to_file[version.to_s]
605
716
  basename = filepath ? File.basename(filepath) : nil
606
717
 
607
- # Uncommitted migrations have no git author โ€” treat them as mine.
718
+ # Uncommitted migrations have no git author - treat them as mine.
608
719
  # Match by email first; fall back to author name to handle cases where
609
720
  # the commit email differs from git config (e.g. GitHub noreply emails
610
721
  # after squash-merge, or mailmap rewrites).
@@ -639,44 +750,18 @@ module Railbow
639
750
  row
640
751
  end
641
752
 
642
- # Calendar separators
643
- separators = {}
644
- if calendar_enabled
645
- versions = db_list.map { |_, v, _| v.to_s }
646
- month_keys = versions.map { |v| v[0..5] }
647
- calendar_label_fmt = Railbow::Params.calendar_label
648
-
649
- if month_keys.uniq.size > 1
650
- month_keys.each_with_index do |mk, i|
651
- next if i == 0
652
- if mk != month_keys[i - 1]
653
- v = versions[i]
654
- date = Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
655
- separators[i] = date.strftime(calendar_label_fmt)
656
- end
657
- end
658
- end
659
- end
660
-
661
- # Week tick separators: mark first row of each new ISO week
662
- tick_rows = Set.new
663
- if ticks_enabled
664
- versions = db_list.map { |_, v, _| v.to_s }
665
- prev_week = nil
666
- versions.each_with_index do |v, i|
667
- y = v[0..3].to_i
668
- m = v[4..5].to_i
669
- d = v[6..7].to_i
670
- next if y == 0 || m == 0 || d == 0
671
-
672
- week = Date.new(y, m, d).cweek
673
-
674
- if i > 0 && prev_week && week != prev_week
675
- tick_rows << i
676
- end
677
-
678
- prev_week = week
679
- end
753
+ # Calendar furniture: month separators, week separators, week ticks
754
+ calendar = if calendar_enabled
755
+ Railbow::Calendar.build(
756
+ db_list.map { |_, v, _| v.to_s },
757
+ weeks: Railbow::Params.calendar_wdividers?,
758
+ ticks: ticks_enabled,
759
+ counts: Railbow::Params.calendar_counts?,
760
+ month_label: Railbow::Params.calendar_label,
761
+ week_label: Railbow::Params.calendar_week_label
762
+ )
763
+ else
764
+ Railbow::Calendar.none
680
765
  end
681
766
 
682
767
  renderer = Railbow::Table::Renderer.new(
@@ -686,7 +771,10 @@ module Railbow
686
771
  aliases: Railbow::Config.table_aliases
687
772
  )
688
773
  tick_col = 2 # Date column index
689
- puts renderer.render(rows, separators: separators, highlight_rows: highlight_rows, ghost_rows: ghost_rows, tick_rows: tick_rows, tick_col: tick_col)
774
+ puts renderer.render(rows,
775
+ separators: calendar.separators,
776
+ highlight_rows: highlight_rows, ghost_rows: ghost_rows, dim_rows: down_rows,
777
+ tick_rows: calendar.tick_rows, tick_col: tick_col)
690
778
  end
691
779
  end
692
780
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railbow
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/railbow.rb CHANGED
@@ -9,8 +9,10 @@ module Railbow
9
9
 
10
10
  # Returns true when Railbow formatting should be disabled.
11
11
  # Checks for explicit opt-out, standard conventions, CI, and LLM agents.
12
+ # RBW_PLAIN always wins; RBW_FORCE beats every auto-detection below it.
12
13
  def self.plain?
13
14
  return true if Params.plain?
15
+ return false if Params.force?
14
16
  return true if ENV.key?("NO_COLOR")
15
17
  return true if ENV.key?("CLAUDECODE")
16
18
  return true if ENV.key?("CI")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railbow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene M
@@ -78,6 +78,7 @@ files:
78
78
  - exe/railbow
79
79
  - lib/railbow.rb
80
80
  - lib/railbow/about_formatter.rb
81
+ - lib/railbow/calendar.rb
81
82
  - lib/railbow/color_assigner.rb
82
83
  - lib/railbow/config.rb
83
84
  - lib/railbow/demo/fixtures.rb