railbow 0.6.2 → 0.7.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.
@@ -10,30 +10,12 @@ module Railbow
10
10
  def announce(message)
11
11
  return super if Railbow.plain?
12
12
 
13
- f = FORMATTER
14
- migration_name = self.class.name&.demodulize || "Migration"
15
-
16
- line = case message
17
- when /migrating/i
18
- f.cyan("#{f.emoji(:migrating)} #{migration_name}: migrating...")
19
- when /migrated\s*\((\d+\.\d+)s\)/i
20
- raw_seconds = Regexp.last_match(1).to_f
21
- formatted = f.format_timing(raw_seconds)
22
- f.green("#{f.emoji(:migrated)} #{migration_name}: migrated") + " (#{formatted} total)"
23
- when /reverting/i
24
- f.yellow("#{f.emoji(:reverting)} #{migration_name}: reverting...")
25
- when /reverted\s*\((\d+\.\d+)s\)/i
26
- raw_seconds = Regexp.last_match(1).to_f
27
- formatted = f.format_timing(raw_seconds)
28
- f.green("#{f.emoji(:reverted)} #{migration_name}: reverted") + " (#{formatted} total)"
29
- else
30
- "#{migration_name}: #{message}"
31
- end
32
-
33
- write ""
34
- write line
13
+ Railbow.guard(-> { super }) { railbow_announce(message) }
35
14
  end
36
15
 
16
+ # The yield runs the migration itself, so it stays outside the guard: a
17
+ # failing migration must raise as usual, and a formatting error must not
18
+ # re-run it through super. The fallback prints what Rails would have.
37
19
  def say_with_time(message)
38
20
  return super { yield } if Railbow.plain?
39
21
 
@@ -41,9 +23,16 @@ module Railbow
41
23
  result = nil
42
24
  time = Benchmark.measure { result = yield }
43
25
 
44
- timing = f.format_timing(time.real)
45
- write " #{f.green(f.emoji(:check))} #{message} → #{timing}"
46
- say("#{result} rows", :subitem) if result.is_a?(Integer)
26
+ plain = lambda do
27
+ say(message)
28
+ say("%.4fs" % time.real, :subitem)
29
+ say("#{result} rows", :subitem) if result.is_a?(Integer)
30
+ end
31
+ Railbow.guard(plain) do
32
+ timing = f.format_timing(time.real)
33
+ write " #{f.green(f.emoji(:check))} #{message} → #{timing}"
34
+ say("#{result} rows", :subitem) if result.is_a?(Integer)
35
+ end
47
36
 
48
37
  result
49
38
  end
@@ -60,5 +49,32 @@ module Railbow
60
49
 
61
50
  puts text
62
51
  end
52
+
53
+ private
54
+
55
+ def railbow_announce(message)
56
+ f = FORMATTER
57
+ migration_name = self.class.name&.demodulize || "Migration"
58
+
59
+ line = case message
60
+ when /migrating/i
61
+ f.cyan("#{f.emoji(:migrating)} #{migration_name}: migrating...")
62
+ when /migrated\s*\((\d+\.\d+)s\)/i
63
+ raw_seconds = Regexp.last_match(1).to_f
64
+ formatted = f.format_timing(raw_seconds)
65
+ f.green("#{f.emoji(:migrated)} #{migration_name}: migrated") + " (#{formatted} total)"
66
+ when /reverting/i
67
+ f.yellow("#{f.emoji(:reverting)} #{migration_name}: reverting...")
68
+ when /reverted\s*\((\d+\.\d+)s\)/i
69
+ raw_seconds = Regexp.last_match(1).to_f
70
+ formatted = f.format_timing(raw_seconds)
71
+ f.green("#{f.emoji(:reverted)} #{migration_name}: reverted") + " (#{formatted} total)"
72
+ else
73
+ "#{migration_name}: #{message}"
74
+ end
75
+
76
+ write ""
77
+ write line
78
+ end
63
79
  end
64
80
  end
@@ -4,7 +4,6 @@ require "active_support/inflector"
4
4
 
5
5
  module Railbow
6
6
  class MigrationParser
7
- # Methods that take a single table name as the first argument
8
7
  SINGLE_TABLE_METHODS = %w[
9
8
  create_table drop_table change_table
10
9
  add_column remove_column rename_column change_column
@@ -15,7 +14,6 @@ module Railbow
15
14
  add_timestamps remove_timestamps
16
15
  ].freeze
17
16
 
18
- # Methods that take two table names as arguments
19
17
  DUAL_TABLE_METHODS = %w[
20
18
  add_foreign_key remove_foreign_key
21
19
  ].freeze
@@ -25,7 +23,6 @@ module Railbow
25
23
  SINGLE_TABLE_PATTERN = /\b(?:#{(SINGLE_TABLE_METHODS + DUAL_TABLE_METHODS).join("|")})\s+[:"](\w+)/
26
24
  DUAL_TABLE_PATTERN = /\b(?:#{DUAL_TABLE_METHODS.join("|")})\s+[:"](\w+)["\s,]+[:"](\w+)/
27
25
 
28
- # SQL keywords followed by a table name
29
26
  SQL_TABLE_PATTERNS = [
30
27
  /\bUPDATE\s+["']?(\w+)["']?/i,
31
28
  /\bINSERT\s+INTO\s+["']?(\w+)["']?/i,
@@ -36,21 +33,19 @@ module Railbow
36
33
  /\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i
37
34
  ].freeze
38
35
 
39
- # Helper/DSL calls that name their target table via a keyword argument, e.g.
40
- # `convert_to_monthly_partitions(table: :apples)` or
41
- # `remove_foreign_key :accounts, to_table: :owners`. Catches project-level
42
- # migration helpers regardless of the method name. Bare `from:`/`to:` are
43
- # deliberately not matched — `change_column_default :users, :status,
36
+ # A table named by keyword argument, which catches project-level helpers
37
+ # such as `convert_to_monthly_partitions(table: :apples)` whatever their
38
+ # name. Not bare `from:`/`to:`: `change_column_default :users, :status,
44
39
  # from: nil, to: "active"` would yield phantom tables.
45
40
  KEYWORD_TABLE_PATTERN = /\b(?:table|to_table|from_table):\s*[:"']?(\w+)/
46
41
 
47
42
  # Ruby `#` comments up to end of line, but not `#{}` interpolation.
48
43
  COMMENT_PATTERN = /#(?!\{).*/
49
44
 
50
- # AR class-level query/mutation methods that strongly signal a constant is a model.
51
- # Kept narrow on purpose — methods like `find`, `all`, `first`, `count`, `create`,
52
- # `new`, `transaction`, `order`, `includes` collide with stdlib/non-model constants
53
- # (e.g. Time.new, File.find, Array#first) and would produce false positives.
45
+ # AR class methods that strongly signal a constant is a model. Kept narrow:
46
+ # `find`, `all`, `first`, `count`, `create`, `new`, `transaction`, `order`,
47
+ # `includes` also exist on non-model constants (Time.new, File.find) and
48
+ # would produce false positives.
54
49
  MODEL_AR_METHODS = %w[
55
50
  find_each find_in_batches
56
51
  find_by find_by!
@@ -74,9 +69,8 @@ module Railbow
74
69
  def self.extract_tables_from_content(content)
75
70
  return [] if content.nil? || content.empty?
76
71
 
77
- # Comments would otherwise produce phantom tables — an explanatory
78
- # "# ALTER TABLE foo ..." or a commented-out create_table reads exactly
79
- # like the real thing to the patterns below.
72
+ # An explanatory "# ALTER TABLE foo ..." or a commented-out create_table
73
+ # reads exactly like the real thing to the patterns below.
80
74
  content = content.gsub(COMMENT_PATTERN, "")
81
75
 
82
76
  tables = []
@@ -89,10 +83,9 @@ module Railbow
89
83
  content.scan(KEYWORD_TABLE_PATTERN) { |match| tables << match[0] }
90
84
  tables.uniq!
91
85
 
92
- # Model-based detection is heuristic (a constant that happens to respond
93
- # to one of these methods isn't necessarily an AR model). Only use it to
94
- # supplement sparse DDL/SQL findings — skip once we already know of ≥2
95
- # tables, and cap the final total at 2.
86
+ # Model references are a guess (a constant answering these methods need
87
+ # not be a model), so they only fill in when DDL and SQL found fewer
88
+ # than 2 tables, and never take the total past 2.
96
89
  if tables.size < 2
97
90
  content.scan(MODEL_REFERENCE_PATTERN) do |match|
98
91
  break if tables.size >= 2
@@ -51,10 +51,24 @@ module Railbow
51
51
  def flush
52
52
  return if empty?
53
53
 
54
- Railbow::Status::Printer.new(@sections, skipped: @skipped).print
54
+ if Railbow.plain?
55
+ print_fallback
56
+ else
57
+ Railbow.guard(-> { print_fallback }) do
58
+ Railbow::Status::Printer.new(@sections, skipped: @skipped).print
59
+ end
60
+ end
55
61
  @sections = []
56
62
  @skipped = []
57
63
  end
64
+
65
+ private
66
+
67
+ # The stock Rails tables captured per database while their pools were
68
+ # still open; see MigrateStatusFormatter#migrate_status.
69
+ def print_fallback
70
+ @sections.each { |section| $stdout.write(section.fallback_output.to_s) }
71
+ end
58
72
  end
59
73
 
60
74
  module_function
@@ -3,13 +3,9 @@
3
3
  require_relative "name_formatter"
4
4
 
5
5
  module Railbow
6
- # Detects and resolves collisions when multiple different names produce the
7
- # same formatted output. Works by progressively expanding pattern tokens
8
- # (F → FF → FFF → FFFF, then L) until every distinct raw name maps to a
9
- # unique formatted string.
10
- #
11
- # Names that share the same first+last name but differ only in middle name
12
- # are treated as the same person and merged before collision detection.
6
+ # Makes distinct names format distinctly by widening pattern tokens (F to
7
+ # FFFF, then L) until no two collide. Names differing only in the middle
8
+ # name count as one person and share an output.
13
9
  #
14
10
  # NameCollisionResolver.resolve(["John Doe", "Jane Doe"], "F L")
15
11
  # # => {"John Doe" => "Jo D", "Jane Doe" => "Ja D"}
@@ -20,19 +16,16 @@ module Railbow
20
16
 
21
17
  # @param names [Array<String, nil>] raw author names (may contain duplicates/nils)
22
18
  # @param pattern [String] a NameFormatter pattern or preset name
23
- # @return [Hash{String => String}] raw_name → disambiguated formatted name
19
+ # @return [Hash{String => String}] raw name to disambiguated formatted name
24
20
  def resolve(names, pattern)
25
21
  pattern = NameFormatter::PRESETS.fetch(pattern, pattern)
26
22
  uniq_names = names.compact.reject(&:empty?).uniq
27
23
 
28
- # Merge names that are the same person (same first+last, differ only in middle)
29
24
  canonical, aliases = merge_same_person(uniq_names)
30
25
 
31
- # Format every canonical name with the base pattern
32
26
  result = {}
33
27
  canonical.each { |n| result[n] = NameFormatter.format(n, pattern) }
34
28
 
35
- # Find collision groups (different raw names → same formatted output)
36
29
  collisions = find_collisions(result)
37
30
  unless collisions.empty?
38
31
  tokens = parse_tokens(pattern)
@@ -41,16 +34,14 @@ module Railbow
41
34
  end
42
35
  end
43
36
 
44
- # Expand aliases: all variant spellings get the same formatted output
45
37
  aliases.each { |variant, canon| result[variant] = result[canon] }
46
38
 
47
39
  result
48
40
  end
49
41
 
50
- # --- private helpers ---
51
-
52
- # Group names by (first, last) and pick the shortest as canonical.
53
- # Returns [canonical_names, aliases_hash] where aliases maps variant → canonical.
42
+ # Groups names by first and last name; the shortest (no middle name) is
43
+ # canonical. Returns [canonical_names, aliases], aliases mapping each
44
+ # variant to its canonical name.
54
45
  def merge_same_person(names)
55
46
  groups = {}
56
47
  names.each do |name|
@@ -66,7 +57,6 @@ module Railbow
66
57
  canonical = []
67
58
  aliases = {}
68
59
  groups.each_value do |group|
69
- # Pick shortest name as canonical (the one without middle name)
70
60
  canon = group.min_by(&:length)
71
61
  canonical << canon
72
62
  group.each { |name| aliases[name] = canon unless name == canon }
@@ -81,14 +71,12 @@ module Railbow
81
71
  .transform_values { |pairs| pairs.map(&:first) }
82
72
  end
83
73
 
84
- # Resolve a single collision group by progressively expanding tokens.
85
- # Only names that still collide continue to be expanded; once a name
86
- # becomes unique it keeps its current (minimal) expansion.
74
+ # Widens tokens, all F tokens before L, only for names that still collide:
75
+ # a name that becomes unique keeps its shortest distinct form.
87
76
  def resolve_group(raw_names, base_pattern, base_tokens, result)
88
77
  remaining = raw_names.dup
89
78
  tokens = base_tokens.map(&:dup)
90
79
 
91
- # Expansion order: exhaust all F tokens first, then L tokens
92
80
  expansion_order = tokens.each_index.sort_by { |i| (tokens[i][:type] == "F") ? 0 : 1 }
93
81
 
94
82
  expansion_order.each do |ti|
@@ -102,15 +90,14 @@ module Railbow
102
90
 
103
91
  remaining.each { |n| result[n] = NameFormatter.format(n, current_pattern) }
104
92
 
105
- # Check collisions only within this group (not the full result),
106
- # so expansion from one group can't chase collisions with other groups
93
+ # Within this group only, so one group's expansion never chases
94
+ # collisions with another's.
107
95
  still_colliding = find_collisions(result.slice(*raw_names))
108
96
  if still_colliding.empty?
109
97
  remaining = []
110
98
  break
111
99
  end
112
100
 
113
- # Only keep expanding names that still collide within the group
114
101
  remaining = still_colliding.values.flatten
115
102
  end
116
103
 
@@ -119,7 +106,7 @@ module Railbow
119
106
 
120
107
  return if remaining.empty?
121
108
 
122
- # Last resort: numeric suffixes
109
+ # Fully expanded and still equal: number them.
123
110
  find_collisions(result.slice(*remaining)).each_value do |group|
124
111
  group[1..].each_with_index do |name, i|
125
112
  suffix = SUPERSCRIPTS[i] || (i + 2).to_s
@@ -128,7 +115,7 @@ module Railbow
128
115
  end
129
116
  end
130
117
 
131
- # Parse pattern into token descriptors: [{type:, length:, maxed:}, ...]
118
+ # [{type:, length:, maxed:}, ...] for each token in the pattern.
132
119
  def parse_tokens(pattern)
133
120
  tokens = []
134
121
  pattern.scan(NameFormatter::TOKEN_RE) do |match|
@@ -139,7 +126,7 @@ module Railbow
139
126
  tokens
140
127
  end
141
128
 
142
- # Rebuild a pattern string from token descriptors, preserving literals.
129
+ # The original pattern with its tokens resized, literals kept.
143
130
  def rebuild_pattern(tokens, original_pattern)
144
131
  ti = 0
145
132
  original_pattern.gsub(NameFormatter::TOKEN_RE) do
@@ -1,22 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railbow
4
- # Formats person names using a pattern language inspired by date-format strings.
4
+ # Formats person names with a pattern language modeled on date formats.
5
5
  #
6
- # Tokens (repeat the letter to control length; 4+ means full word):
7
- # F / FF / FFF / FFFF+ — first name (1 char, 2 chars, … , full)
8
- # L / LL / LLL / LLLL+ — last name
9
- # M / MM / MMM / MMMM+ — middle name(s)
6
+ # Tokens (the letter count is the length; 4 or more means the full word):
7
+ # F / FF / FFF / FFFF+ first name (1 char, 2 chars, 3 chars, full)
8
+ # L / LL / LLL / LLLL+ last name
9
+ # M / MM / MMM / MMMM+ middle name(s)
10
10
  #
11
11
  # Any other characters (spaces, dots, commas) are literals and pass through.
12
12
  #
13
13
  # Named presets:
14
- # "initials" → "F L"
15
- # "short" → "FF L"
16
- # "first_name" → "FFFF"
17
- # "last_name" → "LLLL"
18
- # "full_name" → "FFFF MMMM LLLL"
19
- # "full_name_short" → "FFFF L."
14
+ # "initials" => "F L"
15
+ # "short" => "FF L"
16
+ # "first_name" => "FFFF"
17
+ # "last_name" => "LLLL"
18
+ # "full_name" => "FFFF MMMM LLLL"
19
+ # "full_name_short" => "FFFF L."
20
20
  module NameFormatter
21
21
  PRESETS = {
22
22
  "initials" => "F L",
@@ -34,8 +34,7 @@ module Railbow
34
34
 
35
35
  module_function
36
36
 
37
- # Format a name according to a pattern or preset name.
38
- # Returns "" for nil/empty input.
37
+ # Formats a name by a pattern or preset name. "" for a nil or empty name.
39
38
  def format(name, pattern)
40
39
  return "" if name.nil? || name.empty?
41
40
 
@@ -59,14 +58,14 @@ module Railbow
59
58
  expanded.empty? ? EMPTY : expanded
60
59
  end
61
60
 
62
- # Remove sentinels and any adjacent non-letter literals (dots, commas, spaces)
61
+ # A missing part takes its adjacent literals with it, so "F L." on a
62
+ # one-word name gives no stray dot.
63
63
  result.gsub(/[^a-zA-Z\x00]*\x00[^a-zA-Z\x00]*/, " ")
64
64
  .gsub(/ +/, " ")
65
65
  .strip
66
66
  end
67
67
 
68
- # Truncate a word to `len` chars (4+ means full), preserving original case
69
- # for full words, capitalizing otherwise.
68
+ # A full word keeps its case; a cut one is capitalized.
70
69
  def truncate(word, len)
71
70
  return "" if word.nil? || word.empty?
72
71
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "zlib"
4
4
  require "date"
5
+ require_relative "clock"
5
6
  require_relative "git_utils"
6
7
  require_relative "formatters/base"
7
8
  require_relative "logo"
@@ -30,9 +31,18 @@ module Railbow
30
31
 
31
32
  PALETTE = Formatters::Base::TABLE_PALETTE
32
33
 
34
+ # git blame's author-mail for lines not committed yet
35
+ UNCOMMITTED_EMAIL = "not.committed.yet"
36
+
33
37
  def display(results, options = {})
34
38
  return super if Railbow.plain?
35
39
 
40
+ Railbow.guard(-> { super }) { railbow_display(results, options) }
41
+ end
42
+
43
+ private
44
+
45
+ def railbow_display(results, options = {})
36
46
  if Railbow::Params.help?
37
47
  print_help
38
48
  return
@@ -45,7 +55,6 @@ module Railbow
45
55
  since_date = Railbow::Params.parse_since(since_val, context: "annotations")
46
56
  git_needed = %w[all me].include?(author_mode) || since_date || sort_mode == "date"
47
57
 
48
- # Build blame cache for all files if git features are needed
49
58
  blame_cache = {}
50
59
  if git_needed
51
60
  results.each_key do |source_file|
@@ -53,7 +62,7 @@ module Railbow
53
62
  end
54
63
  end
55
64
 
56
- # Flatten annotations into a list for filtering/sorting
65
+ # Flattened so filtering and sorting can cross file boundaries
57
66
  entries = []
58
67
  results.each do |source_file, annotations|
59
68
  annotations.each do |annotation|
@@ -66,7 +75,6 @@ module Railbow
66
75
  end
67
76
  end
68
77
 
69
- # Filter by SINCE
70
78
  skipped = 0
71
79
  if since_date
72
80
  before = entries.size
@@ -76,7 +84,7 @@ module Railbow
76
84
  skipped = before - entries.size
77
85
  end
78
86
 
79
- # Sort by date (newest first) when requested
87
+ # Newest first; entries with no blame date sink to the end
80
88
  if sort_mode == "date"
81
89
  entries.sort_by! do |entry|
82
90
  d = entry.dig(:blame, :date)
@@ -84,21 +92,23 @@ module Railbow
84
92
  end
85
93
  end
86
94
 
87
- # Determine "me" identity
88
95
  git_email = nil
89
96
  if author_mode == "me"
90
97
  git_email = current_git_email
91
98
  end
92
99
 
93
- # Determine max location width for alignment when showing metadata
100
+ # Locations pad to one width so the author and date line up
94
101
  show_date = git_needed
95
102
  max_loc_width = 0
96
103
  author_colors = nil
97
104
  author_display = {}
98
105
  if author_mode == "all"
99
- author_names = entries.filter_map { |e| e.dig(:blame, :author) }.uniq
106
+ author_names = entries.reject { |e| uncommitted?(e[:blame]) }
107
+ .filter_map { |e| e.dig(:blame, :author) }.uniq
100
108
  author_colors = ColorAssigner.new(author_names)
101
109
  author_display = Railbow::Params.format_authors(author_names)
110
+ # Uncommitted notes have no author; dashes fill the author column instead
111
+ no_author = "-" * (author_display.values.map(&:length).max || 4)
102
112
  end
103
113
  if show_date || author_mode == "all"
104
114
  entries.each do |entry|
@@ -107,11 +117,10 @@ module Railbow
107
117
  end
108
118
  end
109
119
 
110
- # Group entries back by file for bar coloring
120
+ # A file with several notes gets its own bar color; a lone note's bar is dim
111
121
  file_counts = Hash.new(0)
112
122
  entries.each { |e| file_counts[e[:file]] += 1 }
113
123
 
114
- # Display
115
124
  entries.each do |entry|
116
125
  source_file = entry[:file]
117
126
  annotation = entry[:annotation]
@@ -127,7 +136,6 @@ module Railbow
127
136
  DIM
128
137
  end
129
138
  colored_bar = "#{bar_color}#{BAR}#{RESET}"
130
- # When highlighting "me", use bright white bar
131
139
  colored_bar = "#{WHITE}#{BAR}#{RESET}" if is_mine
132
140
 
133
141
  tag = annotation.tag
@@ -136,7 +144,6 @@ module Railbow
136
144
  colored_tag = "#{tag_color}#{BOLD}#{tag}#{RESET}"
137
145
  note = annotation.text.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
138
146
 
139
- # Build the file:line row with optional author+date
140
147
  loc_line = " #{colored_bar} #{location}"
141
148
 
142
149
  if author_mode == "all" && blame_info
@@ -144,7 +151,11 @@ module Railbow
144
151
  author_name = author_display[raw_author] || Railbow::Params.format_author(raw_author)
145
152
  blame_date = blame_info[:date] ? blame_info[:date].strftime("%Y-%m-%d") : ""
146
153
  padding = " " * [(max_loc_width - location.length + 2), 2].max
147
- colored_author = "#{author_colors.color_for(raw_author)}#{author_name}#{RESET}"
154
+ colored_author = if uncommitted?(blame_info)
155
+ "#{DIM}#{no_author}#{RESET}"
156
+ else
157
+ "#{author_colors.color_for(raw_author)}#{author_name}#{RESET}"
158
+ end
148
159
  meta = "#{colored_author} #{DIM}#{blame_date}#{RESET}"
149
160
  loc_line = " #{colored_bar} #{location}#{padding}#{meta}"
150
161
  elsif author_mode == "me" && is_mine && blame_info
@@ -164,7 +175,6 @@ module Railbow
164
175
  indent_width = 1 + 1 + 1 + 3 + tag.length + 1 # " " + BAR + " " + " " + TAG + " "
165
176
  continuation_prefix = " #{colored_bar} #{" " * (tag.length + 1)}"
166
177
 
167
- # Apply "me" highlighting to note text
168
178
  if is_mine
169
179
  tag_str = "#{WHITE}#{tag}#{RESET} "
170
180
  end
@@ -198,13 +208,14 @@ module Railbow
198
208
  puts
199
209
  end
200
210
 
201
- private
202
-
203
211
  def blame_file(path)
204
212
  return {} unless File.exist?(path)
205
213
 
206
- output, status = Railbow::GitUtils.capture2("blame", "--porcelain", path)
207
- return {} unless status.success?
214
+ # stderr is captured so "no such path in HEAD" never reaches the terminal
215
+ output, _err, status = Railbow::GitUtils.capture3("blame", "--porcelain", path)
216
+ # Untracked or ignored files (e.g. app/assets/builds) count as uncommitted,
217
+ # the way git blame dates uncommitted lines, so RBW_SINCE keeps them
218
+ return Hash.new(uncommitted_blame) unless status.success?
208
219
 
209
220
  result = {}
210
221
  current_line = nil
@@ -227,8 +238,8 @@ module Railbow
227
238
  elsif line.start_with?("author-time ")
228
239
  current_time = line.sub("author-time ", "").to_i
229
240
  elsif line.start_with?("\t") && current_line
230
- # End of this blame entry — store it
231
- date = current_time ? Time.at(current_time).to_date : nil
241
+ # The tab-prefixed source line closes a porcelain blame entry
242
+ date = current_time ? Railbow::Clock.in_zone(Time.at(current_time)).to_date : nil
232
243
  result[current_line] = {
233
244
  author: current_author,
234
245
  email: current_email,
@@ -240,6 +251,18 @@ module Railbow
240
251
  result
241
252
  end
242
253
 
254
+ def uncommitted_blame
255
+ {
256
+ author: "Not Committed Yet",
257
+ email: UNCOMMITTED_EMAIL,
258
+ date: Railbow::Clock.in_zone(Time.now).to_date
259
+ }.freeze
260
+ end
261
+
262
+ def uncommitted?(blame_info)
263
+ blame_info&.dig(:email) == UNCOMMITTED_EMAIL
264
+ end
265
+
243
266
  def current_git_email
244
267
  output, _status = Railbow::GitUtils.capture2("config", "user.email")
245
268
  output.strip.downcase