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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +30 -6
- data/lib/railbow/about_formatter.rb +57 -21
- data/lib/railbow/calendar.rb +2 -4
- data/lib/railbow/clock.rb +72 -0
- data/lib/railbow/color_assigner.rb +6 -12
- data/lib/railbow/config.rb +1 -1
- data/lib/railbow/demo/status_demo.rb +4 -11
- data/lib/railbow/formatters/base.rb +11 -32
- data/lib/railbow/init.rb +5 -1
- data/lib/railbow/migration_formatter.rb +41 -25
- data/lib/railbow/migration_parser.rb +12 -19
- data/lib/railbow/multi_db.rb +15 -1
- data/lib/railbow/name_collision_resolver.rb +14 -27
- data/lib/railbow/name_formatter.rb +15 -16
- data/lib/railbow/notes_formatter.rb +42 -19
- data/lib/railbow/params.rb +33 -27
- data/lib/railbow/railtie.rb +8 -6
- data/lib/railbow/routes_formatter.rb +18 -6
- data/lib/railbow/stats_formatter.rb +139 -69
- data/lib/railbow/status/ghosts.rb +5 -6
- data/lib/railbow/status/git_data.rb +11 -18
- data/lib/railbow/status/help.rb +6 -0
- data/lib/railbow/status/printer.rb +19 -33
- data/lib/railbow/status/section.rb +28 -44
- data/lib/railbow/table/renderer.rb +27 -35
- data/lib/railbow/tasks/migrate_status.rake +33 -17
- data/lib/railbow/tasks/stats.rake +11 -75
- data/lib/railbow/version.rb +1 -1
- data/lib/railbow.rb +28 -4
- metadata +2 -2
|
@@ -10,30 +10,12 @@ module Railbow
|
|
|
10
10
|
def announce(message)
|
|
11
11
|
return super if Railbow.plain?
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
40
|
-
# `convert_to_monthly_partitions(table: :apples)`
|
|
41
|
-
# `
|
|
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
|
|
51
|
-
#
|
|
52
|
-
# `
|
|
53
|
-
#
|
|
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
|
-
#
|
|
78
|
-
#
|
|
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
|
|
93
|
-
#
|
|
94
|
-
#
|
|
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
|
data/lib/railbow/multi_db.rb
CHANGED
|
@@ -51,10 +51,24 @@ module Railbow
|
|
|
51
51
|
def flush
|
|
52
52
|
return if empty?
|
|
53
53
|
|
|
54
|
-
Railbow
|
|
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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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}]
|
|
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
|
-
#
|
|
51
|
-
|
|
52
|
-
#
|
|
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
|
-
#
|
|
85
|
-
#
|
|
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
|
-
#
|
|
106
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
4
|
+
# Formats person names with a pattern language modeled on date formats.
|
|
5
5
|
#
|
|
6
|
-
# Tokens (
|
|
7
|
-
# F / FF / FFF / FFFF+
|
|
8
|
-
# L / LL / LLL / LLLL+
|
|
9
|
-
# M / MM / MMM / MMMM+
|
|
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"
|
|
15
|
-
# "short"
|
|
16
|
-
# "first_name"
|
|
17
|
-
# "last_name"
|
|
18
|
-
# "full_name"
|
|
19
|
-
# "full_name_short"
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
#
|
|
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
|