railbow 0.2.0 → 0.3.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: c42f0f6d8162662344a6ef8fb12388e8dd6a2a579384cab3829a85d806cb1571
4
+ data.tar.gz: 064fb908f8cd7567e9d8be53800e6972aa48faf65348aff7338c6088aa7427dd
5
5
  SHA512:
6
- metadata.gz: 0f1eaa898dfc630a06d9a7515603eb46537ee06ede2d92ac2ba45a0de4ad6413b54d9d0b2c4afa9eb8538ad656e1b94829f828fa4de370cd7cbb4060f8bd2d14
7
- data.tar.gz: 0ae1860fe55db53d8b3d9eb47459838101ebb056d8b942dcfe9d7cfc1e2d0201e8e7afccb98896c4f0bf2ec0a450128e0d6fa48bfb710d82d6b0f9437d302260
6
+ metadata.gz: f7af392359a26db27919f6bac5d31ce22d6736615bbbe8d514c794e5114a2a71ff61081160340a3177fd4b172207b8c07ae2948fcfdaeaccbc0498b28370fb1c
7
+ data.tar.gz: e5a9364c62c93fa530cbde3222c2249b3f251043514fda4d4cf51a33cfaf27ad54bffed5d5abae43befe065634faee0d96ec60581adc22004cd45790e7f87534
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ 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.3.0] - 2026-07-23
9
+
10
+ ### Added
11
+
12
+ - Ghost rows in `db:migrate:status` now surface mighost 0.5's classification:
13
+ a superseded ghost (re-timestamped migration) shows a calmer 🪦 status with
14
+ a `≡ <version>` badge pointing at its successor, and a ghost deleted with no
15
+ surviving branch shows `✂ deleted in:<sha>`. Branch badges are unchanged and
16
+ take precedence right after supersession.
17
+ - `RBW_FORCE=1` forces Railbow formatting past every auto-detection
18
+ (`NO_COLOR`, CI, piped output, LLM agent detection). An explicit
19
+ `RBW_PLAIN=1` still wins.
20
+
21
+ ### Changed
22
+
23
+ - Ghost data now comes from `Mighost::API.orphaned_migrations`, so
24
+ `db:migrate:status` honors mighost dismissals and `hide_superseded` for the
25
+ first time: a dismissed or hidden ghost renders as plain `NO FILE` instead
26
+ of 👻. Live git/worktree recovery still kicks in for versions without a
27
+ stored snapshot, so fresh clones keep working with zero setup.
28
+
29
+ ### Fixed
30
+
31
+ - A ghost row without a branch no longer inherits the previous ghost row's
32
+ branch badge (a stale local leaked across loop iterations; unreachable
33
+ before mighost 0.5 made branchless ghosts the normal case).
34
+
8
35
  ## [0.2.0] - 2026-07-23
9
36
 
10
37
  ### Added
data/README.md CHANGED
@@ -106,7 +106,7 @@ Out of the box you get:
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
108
 
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`.
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 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
110
 
111
111
  ### `rails db:migrate:down`
112
112
 
@@ -176,6 +176,7 @@ Every option can also be set via `RBW_*` environment variables, which override c
176
176
  | Variable | Example | Description |
177
177
  |---|---|---|
178
178
  | `RBW_PLAIN` | `1` | Disable all formatting |
179
+ | `RBW_FORCE` | `1` | Force formatting even when piped, in CI, or run by an LLM agent (`RBW_PLAIN=1` still wins) |
179
180
  | `RBW_SINCE` | `2mo`, `70d`, `1y`, `all` | Filter migrations by time period |
180
181
  | `RBW_DATE` | `full`, `rel`, `short`, `custom(%b %d)` | Date display format |
181
182
  | `RBW_GIT` | `author:me,diff,mask:auto` | Git integration options |
@@ -221,6 +222,10 @@ Formatting auto-disables when:
221
222
  - Output is piped or redirected (non-TTY)
222
223
  - Running inside an LLM agent (`CLAUDECODE` env var)
223
224
 
225
+ `RBW_FORCE=1` overrides all of the auto-detection above (useful for capturing
226
+ formatted output to a file, or letting an agent inspect the real rendering).
227
+ An explicit `RBW_PLAIN=1` always wins over `RBW_FORCE`.
228
+
224
229
  ## Requirements
225
230
 
226
231
  - Ruby >= 3.1.0
@@ -71,6 +71,10 @@ module Railbow
71
71
  truthy?(ENV["RBW_PLAIN"])
72
72
  end
73
73
 
74
+ def force?
75
+ truthy?(ENV["RBW_FORCE"])
76
+ end
77
+
74
78
  def since
75
79
  (ENV["RBW_SINCE"] || Config.load["since"] || "all").strip.downcase
76
80
  end
@@ -12,8 +12,113 @@ require_relative "../logo"
12
12
  # db:migrate:status and db:migrate:status:<database_name> tasks.
13
13
  module Railbow
14
14
  module MigrateStatusFormatter
15
+ # Ghost migration data normalized for rendering, whether it came from
16
+ # mighost's orphan classification or a live snapshot recovery.
17
+ class GhostRow
18
+ attr_reader :filename, :branch_name, :source, :superseded_by, :deleted_in_sha,
19
+ :author_name, :author_email, :content
20
+
21
+ def initialize(filename: nil, branch_name: nil, source: nil, superseded_by: nil,
22
+ deleted_in_sha: nil, author_name: nil, author_email: nil, content: nil)
23
+ @filename = filename
24
+ @branch_name = branch_name
25
+ @source = source
26
+ @superseded_by = superseded_by
27
+ @deleted_in_sha = deleted_in_sha
28
+ @author_name = author_name
29
+ @author_email = author_email
30
+ @content = content
31
+ end
32
+ end
33
+
15
34
  private
16
35
 
36
+ def mighost_attr(obj, name)
37
+ obj.respond_to?(name) ? obj.public_send(name) : nil
38
+ end
39
+
40
+ def mighost_snapshot_content(version)
41
+ Mighost::API.find_snapshot(version)&.content
42
+ rescue
43
+ nil
44
+ end
45
+
46
+ def load_ghost_rows(versions, with_content: false)
47
+ # Detect once: OrphanedMigration carries the classification (supersession,
48
+ # deletion commit) that a bare snapshot doesn't, and already honors
49
+ # dismissals and hide_superseded.
50
+ orphans = begin
51
+ Mighost::API.orphaned_migrations.to_h { |o| [o.version.to_s, o] }
52
+ rescue
53
+ return {}
54
+ end
55
+
56
+ rows = {}
57
+ versions.each do |v|
58
+ # Absent from detect = deliberately suppressed (dismissed, or superseded
59
+ # with hide_superseded on) - render as plain NO FILE, don't re-recover.
60
+ next unless (orphan = orphans[v])
61
+
62
+ if orphan.filename && !orphan.filename.empty?
63
+ rows[v] = GhostRow.new(
64
+ filename: orphan.filename,
65
+ branch_name: orphan.branch_name,
66
+ source: mighost_attr(orphan, :source),
67
+ superseded_by: mighost_attr(orphan, :superseded_by),
68
+ deleted_in_sha: mighost_attr(orphan, :deleted_in_sha),
69
+ author_name: mighost_attr(orphan, :author_name),
70
+ author_email: mighost_attr(orphan, :author_email),
71
+ content: with_content ? mighost_snapshot_content(v) : nil
72
+ )
73
+ else
74
+ # Detect reads stored snapshots only. A version it lists without a
75
+ # filename has no snapshot yet, so fall back to live git/worktree
76
+ # recovery - keeps fresh clones working with zero setup.
77
+ snapshot = begin
78
+ Mighost::API.find_or_recover_snapshot(v)
79
+ rescue
80
+ nil
81
+ end
82
+ next unless snapshot&.filename && !snapshot.filename.empty?
83
+
84
+ rows[v] = GhostRow.new(
85
+ filename: snapshot.filename,
86
+ branch_name: snapshot.branch_name,
87
+ source: mighost_attr(snapshot, :source),
88
+ superseded_by: api_superseded_by(v),
89
+ deleted_in_sha: mighost_attr(snapshot, :deleted_in_sha),
90
+ author_name: mighost_attr(snapshot, :author_name),
91
+ author_email: mighost_attr(snapshot, :author_email),
92
+ content: with_content ? snapshot.content : nil
93
+ )
94
+ end
95
+ end
96
+ rows
97
+ end
98
+
99
+ def api_superseded_by(version)
100
+ return nil unless Mighost::API.respond_to?(:superseded_by)
101
+
102
+ Mighost::API.superseded_by(version)
103
+ rescue
104
+ nil
105
+ end
106
+
107
+ # One tag slot per ghost row; most informative wins.
108
+ def ghost_tag(ghost)
109
+ if ghost.superseded_by
110
+ "\e[38;5;245m≡ #{ghost.superseded_by}\e[38;5;217m"
111
+ elsif ghost.branch_name
112
+ if ghost.source == "worktree"
113
+ "\e[38;5;222m⌥ₜ#{ghost.branch_name}\e[38;5;217m"
114
+ else
115
+ "\e[38;5;222m⌥ #{ghost.branch_name}\e[38;5;217m"
116
+ end
117
+ elsif ghost.deleted_in_sha
118
+ "\e[38;5;245m✂ deleted in:#{ghost.deleted_in_sha[0, 8]}\e[38;5;217m"
119
+ end
120
+ end
121
+
17
122
  def git_migration_authors(migrate_dir)
18
123
  output, _status = Railbow::GitUtils.capture2(
19
124
  "log", "--format=COMMIT:%aN\t%aE", "--diff-filter=AR", "--name-status", "--", migrate_dir
@@ -304,6 +409,9 @@ module Railbow
304
409
 
305
410
  RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
306
411
 
412
+ RBW_FORCE=1 Force Railbow formatting even when piped, in CI,
413
+ or called by an LLM agent (RBW_PLAIN=1 still wins)
414
+
307
415
  RBW_HELP=1 Show this help message
308
416
 
309
417
  \e[2mAuto-disabled when piped, in CI, or when called by an LLM agent.\e[0m
@@ -428,19 +536,12 @@ module Railbow
428
536
  uncommitted_files.each { |f| branch_origins[f] ||= current_branch }
429
537
  end
430
538
 
431
- # Load mighost snapshots for "NO FILE" migrations (if mighost gem is available)
539
+ # Load mighost ghost data for "NO FILE" migrations (if mighost gem is available)
432
540
  mighost_snapshots = {}
433
541
  mighost_available = defined?(Mighost::API) && Mighost.enabled?
434
542
  if mighost_available
435
543
  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
544
+ mighost_snapshots = load_ghost_rows(no_file_versions, with_content: tables_enabled) if no_file_versions.any?
444
545
  end
445
546
 
446
547
  # Build columns
@@ -501,29 +602,25 @@ module Railbow
501
602
  ghost_snapshot = name.include?("NO FILE") ? mighost_snapshots[version.to_s] : nil
502
603
  if name.include?("NO FILE") && ghost_snapshot
503
604
  ghost_rows << idx
504
- # Mighost recovered this ghost migration — show 👻 status + name + branch badge
505
- colored_status = "👻"
605
+ # Mighost recovered this ghost migration — show ghost status + name + badge.
606
+ # A superseded ghost lives on under another version: stale bookkeeping,
607
+ # not a lost migration, so it gets a calmer glyph.
608
+ colored_status = ghost_snapshot.superseded_by ? "🪦" : "👻"
506
609
  ghost_name = ghost_snapshot.filename
507
610
  .sub(/\A\d+_/, "") # strip version prefix
508
611
  .sub(/\.rb\z/, "") # strip extension
509
612
  .tr("_", " ")
510
613
  .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))
614
+ ghost_badge = ghost_tag(ghost_snapshot)
615
+ if ghost_badge && name_col_width
616
+ tag_width = formatter.display_width(formatter.strip_ansi(ghost_badge))
520
617
  available = name_col_width - tag_width - 2
521
618
  ghost_name = formatter.truncate_str(ghost_name, available)
522
619
  name_width = formatter.display_width(ghost_name)
523
620
  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}"
621
+ display_name = "#{ghost_name}#{" " * [padding, 2].max}#{ghost_badge}"
622
+ elsif ghost_badge
623
+ display_name = "#{ghost_name} #{ghost_badge}"
527
624
  else
528
625
  display_name = ghost_name
529
626
  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.3.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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene M