Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-07-23

### Added

- Ghost rows in `db:migrate:status` now surface mighost 0.5's classification:
a superseded ghost (re-timestamped migration) shows a calmer 🪦 status with
a `≡ <version>` badge pointing at its successor, and a ghost deleted with no
surviving branch shows `✂ deleted in:<sha>`. Branch badges are unchanged and
take precedence right after supersession.
- `RBW_FORCE=1` forces Railbow formatting past every auto-detection
(`NO_COLOR`, CI, piped output, LLM agent detection). An explicit
`RBW_PLAIN=1` still wins.

### Changed

- Ghost data now comes from `Mighost::API.orphaned_migrations`, so
`db:migrate:status` honors mighost dismissals and `hide_superseded` for the
first time: a dismissed or hidden ghost renders as plain `NO FILE` instead
of 👻. Live git/worktree recovery still kicks in for versions without a
stored snapshot, so fresh clones keep working with zero setup.

### Fixed

- A ghost row without a branch no longer inherits the previous ghost row's
branch badge (a stale local leaked across loop iterations; unreachable
before mighost 0.5 made branchless ghosts the normal case).

## [0.2.0] - 2026-07-23

### Added
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Out of the box you get:
- **Time filtering** - only the last 70 days shown by default (`since: 70d`)
- **Your migrations highlighted** - rows authored by you are visually distinct

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`.
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`.

### `rails db:migrate:down`

Expand Down Expand Up @@ -176,6 +176,7 @@ Every option can also be set via `RBW_*` environment variables, which override c
| Variable | Example | Description |
|---|---|---|
| `RBW_PLAIN` | `1` | Disable all formatting |
| `RBW_FORCE` | `1` | Force formatting even when piped, in CI, or run by an LLM agent (`RBW_PLAIN=1` still wins) |
| `RBW_SINCE` | `2mo`, `70d`, `1y`, `all` | Filter migrations by time period |
| `RBW_DATE` | `full`, `rel`, `short`, `custom(%b %d)` | Date display format |
| `RBW_GIT` | `author:me,diff,mask:auto` | Git integration options |
Expand Down Expand Up @@ -221,6 +222,10 @@ Formatting auto-disables when:
- Output is piped or redirected (non-TTY)
- Running inside an LLM agent (`CLAUDECODE` env var)

`RBW_FORCE=1` overrides all of the auto-detection above (useful for capturing
formatted output to a file, or letting an agent inspect the real rendering).
An explicit `RBW_PLAIN=1` always wins over `RBW_FORCE`.

## Requirements

- Ruby >= 3.1.0
Expand Down
2 changes: 2 additions & 0 deletions lib/railbow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ class Error < StandardError; end

# Returns true when Railbow formatting should be disabled.
# Checks for explicit opt-out, standard conventions, CI, and LLM agents.
# RBW_PLAIN always wins; RBW_FORCE beats every auto-detection below it.
def self.plain?
return true if Params.plain?
return false if Params.force?
return true if ENV.key?("NO_COLOR")
return true if ENV.key?("CLAUDECODE")
return true if ENV.key?("CI")
Expand Down
4 changes: 4 additions & 0 deletions lib/railbow/params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ def plain?
truthy?(ENV["RBW_PLAIN"])
end

def force?
truthy?(ENV["RBW_FORCE"])
end

def since
(ENV["RBW_SINCE"] || Config.load["since"] || "all").strip.downcase
end
Expand Down
143 changes: 120 additions & 23 deletions lib/railbow/tasks/migrate_status.rake
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,113 @@ require_relative "../logo"
# db:migrate:status and db:migrate:status:<database_name> tasks.
module Railbow
module MigrateStatusFormatter
# Ghost migration data normalized for rendering, whether it came from
# mighost's orphan classification or a live snapshot recovery.
class GhostRow
attr_reader :filename, :branch_name, :source, :superseded_by, :deleted_in_sha,
:author_name, :author_email, :content

def initialize(filename: nil, branch_name: nil, source: nil, superseded_by: nil,
deleted_in_sha: nil, author_name: nil, author_email: nil, content: nil)
@filename = filename
@branch_name = branch_name
@source = source
@superseded_by = superseded_by
@deleted_in_sha = deleted_in_sha
@author_name = author_name
@author_email = author_email
@content = content
end
end

private

def mighost_attr(obj, name)
obj.respond_to?(name) ? obj.public_send(name) : nil
end

def mighost_snapshot_content(version)
Mighost::API.find_snapshot(version)&.content
rescue
nil
end

def load_ghost_rows(versions, with_content: false)
# Detect once: OrphanedMigration carries the classification (supersession,
# deletion commit) that a bare snapshot doesn't, and already honors
# dismissals and hide_superseded.
orphans = begin
Mighost::API.orphaned_migrations.to_h { |o| [o.version.to_s, o] }
rescue
return {}
end

rows = {}
versions.each do |v|
# Absent from detect = deliberately suppressed (dismissed, or superseded
# with hide_superseded on) - render as plain NO FILE, don't re-recover.
next unless (orphan = orphans[v])

if orphan.filename && !orphan.filename.empty?
rows[v] = GhostRow.new(
filename: orphan.filename,
branch_name: orphan.branch_name,
source: mighost_attr(orphan, :source),
superseded_by: mighost_attr(orphan, :superseded_by),
deleted_in_sha: mighost_attr(orphan, :deleted_in_sha),
author_name: mighost_attr(orphan, :author_name),
author_email: mighost_attr(orphan, :author_email),
content: with_content ? mighost_snapshot_content(v) : nil
)
else
# Detect reads stored snapshots only. A version it lists without a
# filename has no snapshot yet, so fall back to live git/worktree
# recovery - keeps fresh clones working with zero setup.
snapshot = begin
Mighost::API.find_or_recover_snapshot(v)
rescue
nil
end
next unless snapshot&.filename && !snapshot.filename.empty?

rows[v] = GhostRow.new(
filename: snapshot.filename,
branch_name: snapshot.branch_name,
source: mighost_attr(snapshot, :source),
superseded_by: api_superseded_by(v),
deleted_in_sha: mighost_attr(snapshot, :deleted_in_sha),
author_name: mighost_attr(snapshot, :author_name),
author_email: mighost_attr(snapshot, :author_email),
content: with_content ? snapshot.content : nil
)
end
end
rows
end

def api_superseded_by(version)
return nil unless Mighost::API.respond_to?(:superseded_by)

Mighost::API.superseded_by(version)
rescue
nil
end

# One tag slot per ghost row; most informative wins.
def ghost_tag(ghost)
if ghost.superseded_by
"\e[38;5;245m≡ #{ghost.superseded_by}\e[38;5;217m"
elsif ghost.branch_name
if ghost.source == "worktree"
"\e[38;5;222m⌥ₜ#{ghost.branch_name}\e[38;5;217m"
else
"\e[38;5;222m⌥ #{ghost.branch_name}\e[38;5;217m"
end
elsif ghost.deleted_in_sha
"\e[38;5;245m✂ deleted in:#{ghost.deleted_in_sha[0, 8]}\e[38;5;217m"
end
end

def git_migration_authors(migrate_dir)
output, _status = Railbow::GitUtils.capture2(
"log", "--format=COMMIT:%aN\t%aE", "--diff-filter=AR", "--name-status", "--", migrate_dir
Expand Down Expand Up @@ -304,6 +409,9 @@ module Railbow

RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)

RBW_FORCE=1 Force Railbow formatting even when piped, in CI,
or called by an LLM agent (RBW_PLAIN=1 still wins)

RBW_HELP=1 Show this help message

\e[2mAuto-disabled when piped, in CI, or when called by an LLM agent.\e[0m
Expand Down Expand Up @@ -428,19 +536,12 @@ module Railbow
uncommitted_files.each { |f| branch_origins[f] ||= current_branch }
end

# Load mighost snapshots for "NO FILE" migrations (if mighost gem is available)
# Load mighost ghost data for "NO FILE" migrations (if mighost gem is available)
mighost_snapshots = {}
mighost_available = defined?(Mighost::API) && Mighost.enabled?
if mighost_available
no_file_versions = db_list.select { |_, _, n| n.include?("NO FILE") }.map { |_, v, _| v.to_s }
no_file_versions.each do |v|
snapshot = begin
Mighost::API.find_or_recover_snapshot(v)
rescue
nil
end
mighost_snapshots[v] = snapshot if snapshot&.filename && !snapshot.filename.empty?
end
mighost_snapshots = load_ghost_rows(no_file_versions, with_content: tables_enabled) if no_file_versions.any?
end

# Build columns
Expand Down Expand Up @@ -501,29 +602,25 @@ module Railbow
ghost_snapshot = name.include?("NO FILE") ? mighost_snapshots[version.to_s] : nil
if name.include?("NO FILE") && ghost_snapshot
ghost_rows << idx
# Mighost recovered this ghost migration — show 👻 status + name + branch badge
colored_status = "👻"
# Mighost recovered this ghost migration — show ghost status + name + badge.
# A superseded ghost lives on under another version: stale bookkeeping,
# not a lost migration, so it gets a calmer glyph.
colored_status = ghost_snapshot.superseded_by ? "🪦" : "👻"
ghost_name = ghost_snapshot.filename
.sub(/\A\d+_/, "") # strip version prefix
.sub(/\.rb\z/, "") # strip extension
.tr("_", " ")
.gsub(/\b\w/, &:upcase) # titleize
if ghost_snapshot.branch_name
branch_tag = if ghost_snapshot.respond_to?(:source) && ghost_snapshot.source == "worktree"
"\e[38;5;222m⌥ₜ#{ghost_snapshot.branch_name}\e[38;5;217m"
else
"\e[38;5;222m⌥ #{ghost_snapshot.branch_name}\e[38;5;217m"
end
end
if branch_tag && name_col_width
tag_width = formatter.display_width(formatter.strip_ansi(branch_tag))
ghost_badge = ghost_tag(ghost_snapshot)
if ghost_badge && name_col_width
tag_width = formatter.display_width(formatter.strip_ansi(ghost_badge))
available = name_col_width - tag_width - 2
ghost_name = formatter.truncate_str(ghost_name, available)
name_width = formatter.display_width(ghost_name)
padding = name_col_width - name_width - tag_width
display_name = "#{ghost_name}#{" " * [padding, 2].max}#{branch_tag}"
elsif branch_tag
display_name = "#{ghost_name} #{branch_tag}"
display_name = "#{ghost_name}#{" " * [padding, 2].max}#{ghost_badge}"
elsif ghost_badge
display_name = "#{ghost_name} #{ghost_badge}"
else
display_name = ghost_name
end
Expand Down
2 changes: 1 addition & 1 deletion lib/railbow/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Railbow
VERSION = "0.2.0"
VERSION = "0.3.0"
end
Loading
Loading