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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55d44fb326ce90c9bc4e843104639b49c2d96c866401bb53c962f226166bb22f
4
- data.tar.gz: 808baadb49bf2e7d96586a1a809501bdbe87cfa454db019d184ca36804cc813a
3
+ metadata.gz: d599ddd7c1bd3a12893312ab22f40ff75def87f5d85d42dd2ee5a11da7183225
4
+ data.tar.gz: 4413568aafb22c8cf2b1025bbd6f90696f4da6f3bcead307c48ffeac3e96f04c
5
5
  SHA512:
6
- metadata.gz: c256343dee985473f88c27aa26e0bb827587d49001eaf82f7a8d85dbf3a56197d65ba12e3641d64c87cfbcac290a2b787020d1850a53ce0e8fbf746dc2d3ef9e
7
- data.tar.gz: 4d5740b3a9a98aa692b7b031fa26a811709c4724c996ee9dfd00f0dfc3f339bf431499c27c3af46b354a7aeb7ae9bd95503f99402b1d5e44f3b1fc6314cd64a1
6
+ metadata.gz: a02d2dda50ba6fa9ae4b04d166ebc51148532dcfccd5ab1a7777179d14767d2bb87b2fc4a9a5d3aadbb0eeae114a68f5365cd3bfd0d27286958b192f5fa43967
7
+ data.tar.gz: 5ad80b381822e25a9078093f82c8b52deccae5940334ce45645883e2d4c06a81070e2710876f58fc8af949cc3b96009369e141eb9945c3a1941233561c31f486
data/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ 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.5.0] - 2026-07-28
9
+
10
+ ### Added
11
+
12
+ - Multi-database support for `db:migrate:status`. Rails runs the task once per
13
+ database; railbow now wraps that loop and renders the databases together, as
14
+ one section each in `database.yml` order with column widths shared so the
15
+ tables line up. The section header names the database by its `database.yml`
16
+ name, which is what you configure and filter by, rather than the database
17
+ name Rails reports.
18
+ - A database with nothing in the time window and nothing pending collapses to a
19
+ one-line summary instead of an empty table, so the `cache`, `queue` and
20
+ `cable` databases of a stock Rails 8 app stop drowning out your own. Anything
21
+ it hides (pending migrations, ghosts) is counted in the line, along with the
22
+ flag that reveals them.
23
+ - Databases sharing a `migrations_paths` (horizontal sharding) merge into a
24
+ single table carrying a status glyph per database, making drift between
25
+ shards visible at a glance. A dim `·` marks a version one shard has never
26
+ seen.
27
+ - `RBW_DB` (config key `db`) selects and shapes a multi-database run:
28
+ `only:<name>` and `skip:<name>` (both repeatable) pick databases, `full`
29
+ draws the quiet ones in full, and `inline` merges everything into one table
30
+ ordered by version with a `Db` column, so the calendar spans the whole
31
+ application instead of restarting per database. Filtered-out databases are
32
+ named in a footer rather than silently dropped.
33
+
34
+ - `RBW_SINCE_MIN` (config key `since_min`, default 10) makes the time window a
35
+ soft limit: whatever `since` leaves, the floor tops it back up to this many
36
+ migrations. A database with five migrations now shows all five rather than
37
+ hiding the two that happen to be old and reporting an empty period. The
38
+ hidden-count line says `showing the last 10` when the floor overrode the
39
+ window, and a multi-database section header reads `last 10 of 50` rather than
40
+ `10 of 50`, so a table holding rows older than the window it names still
41
+ reads honestly in both views. `RBW_SINCE_MIN=0` restores the old hard cutoff.
42
+ - `RBW_DB=focus`, **the default**, expands only the first database and
43
+ summarizes the rest in a line each, so a multi-database app is one table to
44
+ read rather than several. A database holding pending migrations is expanded
45
+ anyway: the summary exists to hide what needs no action, and pending work is
46
+ the opposite of that. An empty `RBW_DB` (or `db: ""`) expands every database
47
+ that has recent activity, `full` expands everything including quiet ones, and
48
+ `focus` never applies to an `inline` run, which exists to hold every database
49
+ at once.
50
+
51
+ ### Changed
52
+
53
+ - Git lookups are now shared across a run: authors, landed dates and branch
54
+ origins are resolved once per migrations directory instead of once per
55
+ database. `RBW_HELP=1` prints the help once for the whole run rather than
56
+ once per database.
57
+ - `db:migrate:status` internals moved out of the rake file into
58
+ `Railbow::Status` (`Section`, `Printer`, `GitData`, `Ghosts`, `Help`). No
59
+ output changed: a single-database app renders byte for byte what it did
60
+ before, which a golden fixture now enforces.
61
+
8
62
  ## [0.4.0] - 2026-07-28
9
63
 
10
64
  ### Added
data/README.md CHANGED
@@ -22,6 +22,7 @@
22
22
  - **About** - polished `rails about` output
23
23
  - **Git Integration** - authors, diffs, branch origin, landing dates, uncommitted file indicators
24
24
  - **Calendar View** - month separators plus week ticks or full week rows, with optional per-section counts
25
+ - **Multiple Databases** - read one table by default, the rest summarized in a line each (and expanded when they have migrations pending); sharded databases merged with a status per shard
25
26
  - **Ghost Recovery** - uses the [mighost](https://github.com/amberpixels/mighost) gem (optional) to recover names, authors, and branch badges for `NO FILE` migrations
26
27
  - **Smart Defaults** - auto-disables in CI, piped output, `NO_COLOR`, and LLM agents
27
28
 
@@ -103,7 +104,7 @@ Out of the box you get:
103
104
  - **Landing dates** - `⤻ Mar 04` badge when a migration was merged to main after its creation
104
105
  - **Branch badges** - `⎇ PS-142` showing the source branch/ticket
105
106
  - **Affected tables** - color-coded table names extracted from migration files
106
- - **Time filtering** - only the last 70 days shown by default (`since: 70d`)
107
+ - **Time filtering** - only the last 70 days shown by default (`since: 70d`), as a soft limit: never fewer than 10 migrations, however old (`since_min: 10`)
107
108
  - **Your migrations highlighted** - rows authored by you are visually distinct
108
109
  - **Pending migrations greyed out** - a `down` row keeps its status glyph but loses its colors
109
110
 
@@ -146,6 +147,79 @@ calendar: "" # month separators only
146
147
 
147
148
  Dropping `calendar` from `view` instead removes the month separators too.
148
149
 
150
+ #### Multiple databases
151
+
152
+ Rails runs `db:migrate:status` once per database, so a multi-database app used
153
+ to get one blind table per database: widths that did not line up, the help
154
+ printed once per database, and every git lookup repeated. Railbow now renders
155
+ the whole run together.
156
+
157
+ By default you read one table: the first database in `database.yml` is rendered
158
+ in full and every other one is summarized in a line each (`db: focus`).
159
+
160
+ ```
161
+ 📊 2 databases · primary, raw
162
+
163
+ ──── primary · myapp_development ──── 122 of 1500 ──────────────────────
164
+ ↑↑ │ 20260721130000 │ 2026-07-21 13:00:00 │ Partition sms notifications ● sms_notifications
165
+ ↓↓ │ 20260727170000 │ 2026-07-27 17:00:00 │ Add cr push columns to lines ● lines
166
+
167
+ ⋯ raw · 5 migrations, all applied · latest Jul 22 2026
168
+ ```
169
+
170
+ **A database with migrations pending is always expanded**, however focused the
171
+ run is. The summary exists to hide what needs no action, and pending work is
172
+ the opposite of that.
173
+
174
+ Setting `db` to an empty value expands every database that has recent activity,
175
+ each as its own section with column widths shared so the tables line up:
176
+
177
+ ```yaml
178
+ db: "" # or RBW_DB= on the command line
179
+ ```
180
+
181
+ ```
182
+ ──── primary · myapp_development ──── 122 of 1500 ──────────────────────
183
+ ↑↑ │ 20260721130000 │ 2026-07-21 13:00:00 │ Partition sms notifications ● sms_notifications
184
+
185
+ ──── raw · raw_development ─────────────────────────────────────────────
186
+ ↑↑ │ 20260722120000 │ 2026-07-22 12:00:00 │ Drop archived activity logs ● archived_activity_logs
187
+ ```
188
+
189
+ A section header counts what it is showing: `122 of 1500` means 122 fell inside
190
+ the time window. When the window found too few and the `since_min` floor topped
191
+ the section back up, it reads `last 10 of 50` instead, because the rows are
192
+ then the newest ten rather than a window's worth.
193
+
194
+ Even then, a database with nothing in the time window collapses to one line
195
+ rather than an empty table, which is what keeps the `cache`, `queue` and
196
+ `cable` databases of a stock Rails 8 app from drowning out your own:
197
+
198
+ ```
199
+ ⋯ cache · 5 migrations, all applied · latest Feb 12 2024
200
+ ⋯ queue · 12 migrations, 3 pending outside the 70d window - RBW_SINCE=all
201
+ ```
202
+
203
+ Databases that share a `migrations_paths` (horizontal sharding) run the same
204
+ files, so they merge into one table carrying a status per database. Drift
205
+ between shards is then visible at a glance, and `·` marks a version a shard has
206
+ never seen:
207
+
208
+ ```
209
+ ──── primary + primary_shard_one · db/migrate ──────────────────────────
210
+ ↑↑ ↑↑ │ 20260601151200 │ 2026-06-01 15:12:00 │ Add owner id to pets
211
+ ↑↑ ↓↓ │ 20260726143000 │ 2026-07-26 14:30:00 │ Add policy number to bills
212
+ ```
213
+
214
+ `RBW_DB` controls the rest: `only:<name>` and `skip:<name>` (both repeatable)
215
+ pick databases, `full` draws every section in full and overrides `focus`, and
216
+ `inline` merges everything into a single table ordered by version with a `Db`
217
+ column, so the calendar spans the whole application rather than restarting per
218
+ database. `focus` never applies to an inline run - a merged table exists to
219
+ hold every database at once.
220
+
221
+ A single-database app sees none of this: its output is unchanged.
222
+
149
223
  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`.
150
224
 
151
225
  ### `rails db:migrate:down`
@@ -193,10 +267,12 @@ rake railbow:init
193
267
 
194
268
  ```yaml
195
269
  since: 70d
270
+ since_min: 10
196
271
  date: rel
197
272
  git: "author:me,diff,mask:auto"
198
273
  view: "calendar,tables"
199
274
  calendar: wticks
275
+ db: focus
200
276
 
201
277
  compact: "maxw:120"
202
278
 
@@ -218,10 +294,12 @@ Every option can also be set via `RBW_*` environment variables, which override c
218
294
  | `RBW_PLAIN` | `1` | Disable all formatting |
219
295
  | `RBW_FORCE` | `1` | Force formatting even when piped, in CI, or run by an LLM agent (`RBW_PLAIN=1` still wins) |
220
296
  | `RBW_SINCE` | `2mo`, `70d`, `1y`, `all` | Filter migrations by time period |
297
+ | `RBW_SINCE_MIN` | `10`, `0` | Never show fewer than this many migrations, however old (default 10; `0` disables) |
221
298
  | `RBW_DATE` | `full`, `rel`, `short`, `custom(%b %d)` | Date display format |
222
299
  | `RBW_GIT` | `author:me,diff,mask:auto` | Git integration options |
223
300
  | `RBW_VIEW` | `calendar,tables` | Enable calendar view and table detection |
224
301
  | `RBW_CALENDAR` | `wticks`, `wdividers,counts`, `` (empty) | Week markers: tick marks, full week separator rows, per-section counts. Empty means month separators only |
302
+ | `RBW_DB` | `focus` (default), `` (empty), `only:primary`, `skip:cache`, `full`, `inline` | Multi-database runs: focus one database, expand them all, pick some, or merge them into one table |
225
303
  | `RBW_COMPACT` | `oneline,dense,noheader,maxw:80` | Compact display options |
226
304
  | `RBW_VERB` | `GET,POST` | Filter routes by HTTP method |
227
305
  | `RBW_SORT` | `file`, `date` | Sort order for notes |
@@ -14,10 +14,12 @@ module Railbow
14
14
  }
15
15
  },
16
16
  "since" => "70d",
17
+ "since_min" => 10,
17
18
  "git" => "author:all,diff,mask:auto",
18
19
  "author_format" => "short",
19
20
  "view" => "calendar,tables",
20
- "calendar" => "wticks"
21
+ "calendar" => "wticks",
22
+ "db" => "focus"
21
23
  }.freeze
22
24
 
23
25
  @root = nil
data/lib/railbow/init.rb CHANGED
@@ -22,6 +22,11 @@ module Railbow
22
22
  # Time window for migrations to display (e.g. 30d, 2mo, 1y, all)
23
23
  since: "70d"
24
24
 
25
+ # Never show fewer than this many migrations, however old they are.
26
+ # The window above is a soft limit: a database with five migrations shows
27
+ # all five rather than reporting an empty period. 0 disables the floor.
28
+ since_min: 10
29
+
25
30
  # Sort order: file (by filename/version) or date (by timestamp)
26
31
  # sort: "file"
27
32
 
@@ -55,6 +60,17 @@ module Railbow
55
60
  # Date format: full, rel, short, or custom(%b %d, %Y)
56
61
  # date: "full"
57
62
 
63
+ # Multi-database runs (comma-separated). Leave empty ("") to expand every
64
+ # database into its own section, in database.yml order; a database with
65
+ # nothing in the time window collapses to a one-line summary either way.
66
+ # focus - expand only the first database, summarizing the rest.
67
+ # A database with migrations pending is always expanded
68
+ # only:<name> - render only this database (repeatable)
69
+ # skip:<name> - drop this database (repeatable)
70
+ # full - draw every database in full, overriding focus
71
+ # inline - one merged table ordered by version, with a Db column
72
+ db: "focus"
73
+
58
74
  # Compact mode (comma-separated):
59
75
  # oneline - one line per migration
60
76
  # dense - reduce padding
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railbow
4
+ # Rails runs db:migrate:status once per database, inside
5
+ # with_temporary_pool_for_each. Each call on its own cannot know whether more
6
+ # databases are coming, so railbow wraps that loop: sections collect into the
7
+ # open batch and the batch renders them together when the loop ends.
8
+ #
9
+ # A batch is inert unless something adds to it, so wrapping the other tasks
10
+ # that share the same Rails helper (db:migrate, db:prepare) costs one object.
11
+ module MultiDb
12
+ KEY = :railbow_multi_db_batch
13
+
14
+ class Batch
15
+ attr_reader :git_cache
16
+
17
+ def initialize
18
+ @sections = []
19
+ @skipped = []
20
+ @git_cache = {}
21
+ @help_shown = false
22
+ end
23
+
24
+ # A database excluded by RBW_DB. Recorded rather than dropped so the run
25
+ # can say what it left out.
26
+ def skip(name)
27
+ @skipped << name.to_s
28
+ end
29
+
30
+ # Databases sharing a migrations path see the same files, so they merge
31
+ # into one section that carries a status per database.
32
+ def add(section)
33
+ existing = @sections.find { |s| s.same_migrations?(section) }
34
+ if existing
35
+ existing.merge!(section)
36
+ else
37
+ @sections << section
38
+ end
39
+ end
40
+
41
+ def claim_help
42
+ return false if @help_shown
43
+
44
+ @help_shown = true
45
+ end
46
+
47
+ def empty?
48
+ @sections.empty? && @skipped.empty?
49
+ end
50
+
51
+ def flush
52
+ return if empty?
53
+
54
+ Railbow::Status::Printer.new(@sections, skipped: @skipped).print
55
+ @sections = []
56
+ @skipped = []
57
+ end
58
+ end
59
+
60
+ module_function
61
+
62
+ # Opens a batch for the duration of the block. The flush is in an ensure so
63
+ # that a database aborting mid-run (a missing schema_migrations table, say)
64
+ # still prints the sections collected before it.
65
+ def batch
66
+ previous = Thread.current[KEY]
67
+ batch = Batch.new
68
+ Thread.current[KEY] = batch
69
+ begin
70
+ yield
71
+ ensure
72
+ Thread.current[KEY] = previous
73
+ batch.flush
74
+ end
75
+ end
76
+
77
+ def current
78
+ Thread.current[KEY]
79
+ end
80
+
81
+ # True the first time it is asked within a run. Without a batch every call
82
+ # is its own run, so the answer is always true.
83
+ def claim_help
84
+ current ? current.claim_help : true
85
+ end
86
+ end
87
+ end
@@ -80,6 +80,17 @@ module Railbow
80
80
  (ENV["RBW_SINCE"] || Config.load["since"] || "all").strip.downcase
81
81
  end
82
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
+
83
94
  def sort
84
95
  (ENV["RBW_SORT"] || Config.load["sort"] || "file").strip.downcase
85
96
  end
@@ -215,6 +226,49 @@ module Railbow
215
226
  !val.nil?
216
227
  end
217
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
+
218
272
  # --- Compound: RBW_CALENDAR ---
219
273
 
220
274
  def calendar
@@ -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