twig 1.6 → 1.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +2 -0
- data/HISTORY.md +27 -0
- data/README.md +41 -22
- data/bin/twig +12 -4
- data/bin/twig-checkout-child +56 -25
- data/bin/twig-checkout-parent +54 -26
- data/bin/twig-create-branch +40 -15
- data/bin/twig-diff +44 -25
- data/bin/twig-gh-open +43 -17
- data/bin/twig-gh-open-issue +51 -23
- data/bin/twig-gh-update +46 -20
- data/bin/twig-help +49 -5
- data/bin/twig-init +46 -13
- data/bin/twig-init-completion +46 -19
- data/bin/twig-init-completion-bash +50 -25
- data/bin/twig-init-config +77 -0
- data/bin/twig-rebase +85 -33
- data/config/twigconfig +47 -0
- data/lib/twig.rb +16 -10
- data/lib/twig/branch.rb +19 -12
- data/lib/twig/cli.rb +118 -183
- data/lib/twig/cli/help.rb +174 -0
- data/lib/twig/commit_time.rb +69 -14
- data/lib/twig/display.rb +19 -6
- data/lib/twig/github.rb +10 -8
- data/lib/twig/options.rb +22 -14
- data/lib/twig/subcommands.rb +13 -1
- data/lib/twig/system.rb +0 -2
- data/lib/twig/util.rb +0 -2
- data/lib/twig/version.rb +1 -1
- data/spec/spec_helper.rb +4 -3
- data/spec/twig/branch_spec.rb +100 -13
- data/spec/twig/cli/help_spec.rb +187 -0
- data/spec/twig/cli_spec.rb +34 -189
- data/spec/twig/commit_time_spec.rb +185 -16
- data/spec/twig/display_spec.rb +69 -48
- data/spec/twig/github_spec.rb +29 -15
- data/spec/twig/options_spec.rb +42 -13
- data/spec/twig/subcommands_spec.rb +35 -2
- data/spec/twig/system_spec.rb +5 -6
- data/spec/twig/util_spec.rb +20 -20
- data/spec/twig_spec.rb +21 -6
- data/twig.gemspec +14 -16
- metadata +23 -27
@@ -0,0 +1,174 @@
|
|
1
|
+
class Twig
|
2
|
+
module Cli
|
3
|
+
# Handles printing help output for `twig help`.
|
4
|
+
module Help
|
5
|
+
def self.console_width
|
6
|
+
80
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.intro
|
10
|
+
version_string = "Twig v#{Twig::VERSION}"
|
11
|
+
|
12
|
+
intro = Help.paragraph(%{
|
13
|
+
Twig is your personal Git branch assistant. It's a command-line tool
|
14
|
+
for listing your most recent branches, and for remembering branch
|
15
|
+
details for you, like issue tracker ids and todos. It also supports
|
16
|
+
subcommands, like automatically fetching statuses from your issue
|
17
|
+
tracking system.
|
18
|
+
})
|
19
|
+
|
20
|
+
intro = <<-BANNER.gsub(/^[ ]+/, '')
|
21
|
+
|
22
|
+
#{'=' * version_string.size}
|
23
|
+
#{version_string}
|
24
|
+
#{'=' * version_string.size}
|
25
|
+
|
26
|
+
#{intro}
|
27
|
+
|
28
|
+
#{Twig::HOMEPAGE}
|
29
|
+
BANNER
|
30
|
+
|
31
|
+
intro + ' ' # Force extra blank line
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.description(text, options = {})
|
35
|
+
defaults = {
|
36
|
+
:add_blank_line => false,
|
37
|
+
:width => 40
|
38
|
+
}
|
39
|
+
options = defaults.merge(options)
|
40
|
+
|
41
|
+
width = options[:width]
|
42
|
+
words = text.gsub(/\n?\s+/, ' ').strip.split(' ')
|
43
|
+
lines = []
|
44
|
+
|
45
|
+
# Split words into lines
|
46
|
+
while words.any?
|
47
|
+
current_word = words.shift
|
48
|
+
current_word_size = Display.unformat_string(current_word).size
|
49
|
+
last_line = lines.last
|
50
|
+
last_line_size = last_line && Display.unformat_string(last_line).size
|
51
|
+
|
52
|
+
if last_line_size && (last_line_size + current_word_size + 1 <= width)
|
53
|
+
last_line << ' ' << current_word
|
54
|
+
elsif current_word_size >= width
|
55
|
+
lines << current_word[0...width]
|
56
|
+
words.unshift(current_word[width..-1])
|
57
|
+
else
|
58
|
+
lines << current_word
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
lines << ' ' if options[:add_blank_line]
|
63
|
+
lines
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.description_for_custom_property(option_parser, desc_lines, options = {})
|
67
|
+
options[:trailing] ||= "\n"
|
68
|
+
indent = ' '
|
69
|
+
left_column_width = 29
|
70
|
+
|
71
|
+
help_desc = desc_lines.inject('') do |desc, (left_column, right_column)|
|
72
|
+
desc + indent +
|
73
|
+
sprintf("%-#{left_column_width}s", left_column) + right_column + "\n"
|
74
|
+
end
|
75
|
+
|
76
|
+
Help.print_section(option_parser, help_desc, :trailing => options[:trailing])
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.line_for_custom_property?(line)
|
80
|
+
is_custom_property_except = (
|
81
|
+
line.include?('--except-') &&
|
82
|
+
!line.include?('--except-branch') &&
|
83
|
+
!line.include?('--except-property') &&
|
84
|
+
!line.include?('--except-PROPERTY')
|
85
|
+
)
|
86
|
+
is_custom_property_only = (
|
87
|
+
line.include?('--only-') &&
|
88
|
+
!line.include?('--only-branch') &&
|
89
|
+
!line.include?('--only-property') &&
|
90
|
+
!line.include?('--only-PROPERTY')
|
91
|
+
)
|
92
|
+
is_custom_property_width = (
|
93
|
+
line =~ /--.+-width/ &&
|
94
|
+
!line.include?('--branch-width') &&
|
95
|
+
!line.include?('--PROPERTY-width')
|
96
|
+
)
|
97
|
+
|
98
|
+
is_custom_property_except ||
|
99
|
+
is_custom_property_only ||
|
100
|
+
is_custom_property_width
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.paragraph(text)
|
104
|
+
Help.description(text, :width => console_width).join("\n")
|
105
|
+
end
|
106
|
+
|
107
|
+
def self.print_line(option_parser, text)
|
108
|
+
# Prints a single line of text without line breaks.
|
109
|
+
option_parser.separator(text)
|
110
|
+
end
|
111
|
+
|
112
|
+
def self.print_paragraph(option_parser, text, separator_options = {})
|
113
|
+
# Prints a long chunk of text with automatic word wrapping and a leading
|
114
|
+
# line break.
|
115
|
+
|
116
|
+
separator_options[:trailing] ||= ''
|
117
|
+
Help.print_section(option_parser, Help.paragraph(text), separator_options)
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.print_section(option_parser, text, options = {})
|
121
|
+
# Prints text with leading and trailing line breaks.
|
122
|
+
|
123
|
+
options[:trailing] ||= ''
|
124
|
+
option_parser.separator "\n#{text}#{options[:trailing]}"
|
125
|
+
end
|
126
|
+
|
127
|
+
def self.subcommand_descriptions
|
128
|
+
descs = {
|
129
|
+
'checkout-child' => 'Checks out a branch\'s child branch, if any.',
|
130
|
+
'checkout-parent' => 'Checks out a branch\'s parent branch.',
|
131
|
+
'create-branch' => 'Creates a branch and sets its `diff-branch` property to the previous branch name.',
|
132
|
+
'diff' => 'Shows the diff between a branch and its parent branch (`diff-branch`).',
|
133
|
+
'gh-open' => 'Opens a browser window for the current GitHub repository.',
|
134
|
+
'gh-open-issue' => 'Opens a browser window for a branch\'s GitHub issue, if any.',
|
135
|
+
'gh-update' => 'Updates each branch with the latest issue status on GitHub.',
|
136
|
+
'help' => 'Provides help for Twig and its subcommands.',
|
137
|
+
'init' => 'Runs all Twig setup commands.',
|
138
|
+
'init-completion' => 'Initializes tab completion for Twig. Runs as part of `twig init`.',
|
139
|
+
'init-config' => 'Creates a default `~/.twigconfig` file. Runs as part of `twig init`.',
|
140
|
+
'rebase' => 'Rebases a branch onto its parent branch (`diff-branch`).'
|
141
|
+
}
|
142
|
+
|
143
|
+
line_prefix = '- '
|
144
|
+
gutter_width = 2 # Space between columns
|
145
|
+
names = descs.keys.sort
|
146
|
+
max_name_width = names.map { |name| name.length }.max
|
147
|
+
names_width = max_name_width + gutter_width
|
148
|
+
descs_width = Help.console_width - line_prefix.length - names_width
|
149
|
+
desc_indent = ' ' * (names_width + line_prefix.length)
|
150
|
+
|
151
|
+
names.map do |name|
|
152
|
+
line_prefix +
|
153
|
+
sprintf("%-#{names_width}s", name) +
|
154
|
+
Help.description(descs[name], :width => descs_width).join("\n" + desc_indent)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def self.header(option_parser, text, separator_options = {}, header_options = {})
|
159
|
+
separator_options[:trailing] ||= "\n\n"
|
160
|
+
header_options[:underline] ||= '='
|
161
|
+
|
162
|
+
Help.print_section(
|
163
|
+
option_parser,
|
164
|
+
text + "\n" + (header_options[:underline] * text.size),
|
165
|
+
separator_options
|
166
|
+
)
|
167
|
+
end
|
168
|
+
|
169
|
+
def self.subheader(option_parser, text, separator_options = {})
|
170
|
+
header(option_parser, text, separator_options, :underline => '-')
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
data/lib/twig/commit_time.rb
CHANGED
@@ -2,23 +2,79 @@ class Twig
|
|
2
2
|
|
3
3
|
# Stores a branch's last commit time and its relative time representation.
|
4
4
|
class CommitTime
|
5
|
+
def self.now
|
6
|
+
Time.now
|
7
|
+
end
|
5
8
|
|
6
|
-
def initialize(time
|
9
|
+
def initialize(time)
|
7
10
|
@time = time
|
11
|
+
suffix = 'ago'
|
12
|
+
|
13
|
+
# Cache calculations against current time
|
14
|
+
years_ago = count_years_ago
|
15
|
+
months_ago = count_months_ago
|
16
|
+
weeks_ago = count_weeks_ago
|
17
|
+
days_ago = count_days_ago
|
18
|
+
hours_ago = count_hours_ago
|
19
|
+
minutes_ago = count_minutes_ago
|
20
|
+
seconds_ago = count_seconds_ago
|
21
|
+
|
22
|
+
@time_ago =
|
23
|
+
if years_ago > 0
|
24
|
+
"#{years_ago}y"
|
25
|
+
elsif months_ago > 0 and weeks_ago > 4
|
26
|
+
"#{months_ago}mo"
|
27
|
+
elsif weeks_ago > 0
|
28
|
+
"#{weeks_ago}w"
|
29
|
+
elsif days_ago > 0
|
30
|
+
"#{days_ago}d"
|
31
|
+
elsif hours_ago > 0
|
32
|
+
"#{hours_ago}h"
|
33
|
+
elsif minutes_ago > 0
|
34
|
+
"#{minutes_ago}m"
|
35
|
+
else
|
36
|
+
"#{seconds_ago}s"
|
37
|
+
end
|
38
|
+
@time_ago << ' ' << suffix
|
39
|
+
end
|
40
|
+
|
41
|
+
def count_years_ago
|
42
|
+
seconds_in_a_year = 60 * 60 * 24 * 365
|
43
|
+
seconds = CommitTime.now - @time
|
44
|
+
seconds < seconds_in_a_year ? 0 : (seconds / seconds_in_a_year).round
|
45
|
+
end
|
46
|
+
|
47
|
+
def count_months_ago
|
48
|
+
now = CommitTime.now
|
49
|
+
(now.year * 12 + now.month) - (@time.year * 12 + @time.month)
|
50
|
+
end
|
8
51
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
52
|
+
def count_weeks_ago
|
53
|
+
seconds_in_a_week = 60 * 60 * 24 * 7
|
54
|
+
seconds = CommitTime.now - @time
|
55
|
+
seconds < seconds_in_a_week ? 0 : (seconds / seconds_in_a_week).round
|
56
|
+
end
|
57
|
+
|
58
|
+
def count_days_ago
|
59
|
+
seconds_in_a_day = 60 * 60 * 24
|
60
|
+
seconds = CommitTime.now - @time
|
61
|
+
seconds < seconds_in_a_day ? 0 : (seconds / seconds_in_a_day).round
|
62
|
+
end
|
63
|
+
|
64
|
+
def count_hours_ago
|
65
|
+
seconds_in_an_hour = 60 * 60
|
66
|
+
seconds = CommitTime.now - @time
|
67
|
+
seconds < seconds_in_an_hour ? 0 : (seconds / seconds_in_an_hour).round
|
68
|
+
end
|
69
|
+
|
70
|
+
def count_minutes_ago
|
71
|
+
seconds_in_a_minute = 60
|
72
|
+
seconds = CommitTime.now - @time
|
73
|
+
seconds < seconds_in_a_minute ? 0 : (seconds / seconds_in_a_minute).round
|
74
|
+
end
|
18
75
|
|
19
|
-
|
20
|
-
|
21
|
-
@time_ago = "#{time_ago_parts[0]} #{time_ago_parts[-1]}".gsub(/,/, '')
|
76
|
+
def count_seconds_ago
|
77
|
+
(CommitTime.now - @time).to_i
|
22
78
|
end
|
23
79
|
|
24
80
|
def to_i
|
@@ -37,6 +93,5 @@ class Twig
|
|
37
93
|
def <=>(other)
|
38
94
|
to_i <=> other.to_i
|
39
95
|
end
|
40
|
-
|
41
96
|
end
|
42
97
|
end
|
data/lib/twig/display.rb
CHANGED
@@ -1,4 +1,7 @@
|
|
1
1
|
class Twig
|
2
|
+
|
3
|
+
# Handles displaying matching branches as a command-line table or as
|
4
|
+
# serialized data.
|
2
5
|
module Display
|
3
6
|
COLORS = {
|
4
7
|
:black => 30,
|
@@ -19,6 +22,11 @@ class Twig
|
|
19
22
|
CURRENT_BRANCH_INDICATOR = '* '
|
20
23
|
EMPTY_BRANCH_PROPERTY_INDICATOR = '-'
|
21
24
|
|
25
|
+
def self.unformat_string(string)
|
26
|
+
# Returns a copy of the given string without color/weight markers.
|
27
|
+
string.gsub(/\e\[[0-9]+(;[0-9]+)?m/, '')
|
28
|
+
end
|
29
|
+
|
22
30
|
def column(string, options = {})
|
23
31
|
# Returns `string` with an exact fixed width. If `string` is too wide, it
|
24
32
|
# is truncated with an ellipsis and a trailing space to separate columns.
|
@@ -42,7 +50,7 @@ class Twig
|
|
42
50
|
|
43
51
|
new_string = format_string(
|
44
52
|
new_string,
|
45
|
-
options.reject { |
|
53
|
+
options.reject { |key, value| ![:color, :weight].include?(key) }
|
46
54
|
)
|
47
55
|
|
48
56
|
new_string
|
@@ -144,11 +152,20 @@ class Twig
|
|
144
152
|
data.to_json
|
145
153
|
end
|
146
154
|
|
155
|
+
def format_strings?
|
156
|
+
!Twig::System.windows?
|
157
|
+
end
|
158
|
+
|
147
159
|
def format_string(string, options)
|
148
160
|
# Options:
|
149
161
|
# - `:color`: `nil` by default. Accepts a key from `COLORS`.
|
150
162
|
# - `:weight`: `nil` by default. Accepts a key from `WEIGHTS`.
|
151
163
|
|
164
|
+
# Unlike `::unformat_string`, this is an instance method so that it can
|
165
|
+
# handle config options, e.g., globally disabling color.
|
166
|
+
|
167
|
+
return string unless format_strings?
|
168
|
+
|
152
169
|
string_options = []
|
153
170
|
string_options << COLORS[options[:color]] if options[:color]
|
154
171
|
string_options << WEIGHTS[options[:weight]] if options[:weight]
|
@@ -159,9 +176,5 @@ class Twig
|
|
159
176
|
|
160
177
|
open_format + string.to_s + close_format
|
161
178
|
end
|
162
|
-
|
163
|
-
def unformat_string(string)
|
164
|
-
string.gsub(/\e\[[0-9]+(;[0-9]+)?m/, '')
|
165
|
-
end
|
166
|
-
end # module Display
|
179
|
+
end
|
167
180
|
end
|
data/lib/twig/github.rb
CHANGED
@@ -1,14 +1,20 @@
|
|
1
1
|
require 'uri'
|
2
2
|
|
3
3
|
class Twig
|
4
|
+
|
5
|
+
# Represents a Git repository that is hosted on GitHub. Usage:
|
6
|
+
#
|
7
|
+
# Twig::GithubRepo.new do |gh_repo|
|
8
|
+
# puts gh_repo.username
|
9
|
+
# puts gh_repo.repository
|
10
|
+
# end
|
11
|
+
#
|
4
12
|
class GithubRepo
|
5
13
|
def initialize
|
6
|
-
unless Twig.repo?
|
7
|
-
abort 'Current directory is not a git repository.'
|
8
|
-
end
|
14
|
+
abort 'Current directory is not a git repository.' unless Twig.repo?
|
9
15
|
|
10
16
|
if origin_url.empty? || !github_repo? || username.empty? || repository.empty?
|
11
|
-
|
17
|
+
abort 'This does not appear to be a GitHub repository.'
|
12
18
|
end
|
13
19
|
|
14
20
|
yield(self)
|
@@ -35,9 +41,5 @@ class Twig
|
|
35
41
|
def repository
|
36
42
|
@repo ||= origin_url_parts[-1].sub(/\.git$/, '') || ''
|
37
43
|
end
|
38
|
-
|
39
|
-
def abort_for_non_github_repo
|
40
|
-
abort 'This does not appear to be a GitHub repository.'
|
41
|
-
end
|
42
44
|
end
|
43
45
|
end
|
data/lib/twig/options.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
class Twig
|
2
|
-
module Options
|
3
2
|
|
3
|
+
# Handles reading options from command-line switches and config files.
|
4
|
+
module Options
|
4
5
|
CONFIG_PATH = '~/.twigconfig'
|
5
6
|
DEPRECATED_CONFIG_PATH = '~/.twigrc'
|
6
7
|
MIN_PROPERTY_WIDTH = 3
|
@@ -8,7 +9,7 @@ class Twig
|
|
8
9
|
def readable_config_file_path
|
9
10
|
config_path = File.expand_path(CONFIG_PATH)
|
10
11
|
|
11
|
-
if File.
|
12
|
+
if File.exist?(config_path)
|
12
13
|
unless File.readable?(config_path)
|
13
14
|
$stderr.puts "Warning: #{CONFIG_PATH} is not readable."
|
14
15
|
return # Stop if file exists but is not readable
|
@@ -16,12 +17,12 @@ class Twig
|
|
16
17
|
else
|
17
18
|
config_path = File.expand_path(DEPRECATED_CONFIG_PATH)
|
18
19
|
|
19
|
-
if File.
|
20
|
+
if File.exist?(config_path)
|
20
21
|
if File.readable?(config_path)
|
21
|
-
$stderr.puts "DEPRECATED: #{DEPRECATED_CONFIG_PATH} is deprecated. "
|
22
|
+
$stderr.puts "DEPRECATED: #{DEPRECATED_CONFIG_PATH} is deprecated. " \
|
22
23
|
"Please rename it to #{CONFIG_PATH}."
|
23
24
|
else
|
24
|
-
$stderr.puts "DEPRECATED: #{DEPRECATED_CONFIG_PATH} is deprecated. "
|
25
|
+
$stderr.puts "DEPRECATED: #{DEPRECATED_CONFIG_PATH} is deprecated. " \
|
25
26
|
"Please rename it to #{CONFIG_PATH} and make it readable."
|
26
27
|
return # Stop if file exists but is not readable
|
27
28
|
end
|
@@ -50,8 +51,8 @@ class Twig
|
|
50
51
|
if !key.empty? && value
|
51
52
|
opts[key] = value.strip
|
52
53
|
elsif !line.empty?
|
53
|
-
$stderr.puts %{Warning: Invalid line "#{line}" in #{config_path}. }
|
54
|
-
|
54
|
+
$stderr.puts %{Warning: Invalid line "#{line}" in #{config_path}. } \
|
55
|
+
'Expected format: `key: value`'
|
55
56
|
end
|
56
57
|
|
57
58
|
opts
|
@@ -99,6 +100,10 @@ class Twig
|
|
99
100
|
when 'github-uri-prefix'
|
100
101
|
set_option(:github_uri_prefix, value)
|
101
102
|
|
103
|
+
# Subcommands:
|
104
|
+
when 'twig-rebase-autoconfirm'
|
105
|
+
set_option(:twig_rebase_autoconfirm, value)
|
106
|
+
|
102
107
|
end
|
103
108
|
end
|
104
109
|
end
|
@@ -148,6 +153,9 @@ class Twig
|
|
148
153
|
when :reverse
|
149
154
|
options[:reverse] = Twig::Util.truthy?(value)
|
150
155
|
|
156
|
+
when :twig_rebase_autoconfirm
|
157
|
+
options[:twig_rebase_autoconfirm] = Twig::Util.truthy?(value)
|
158
|
+
|
151
159
|
when :unset_property
|
152
160
|
options[key] = value
|
153
161
|
end
|
@@ -187,11 +195,12 @@ class Twig
|
|
187
195
|
min_property_value = [property_name_width, MIN_PROPERTY_WIDTH].max
|
188
196
|
|
189
197
|
if property_value < min_property_value
|
190
|
-
min_desc =
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
198
|
+
min_desc =
|
199
|
+
if property_value < property_name_width
|
200
|
+
%{#{property_name_width} (width of "#{property_name}")}
|
201
|
+
else
|
202
|
+
MIN_PROPERTY_WIDTH.to_s
|
203
|
+
end
|
195
204
|
|
196
205
|
error = %{The value `--#{property_name}-width=#{property_value}` } +
|
197
206
|
%{is too low. The minimum is #{min_desc}.}
|
@@ -206,6 +215,5 @@ class Twig
|
|
206
215
|
def unset_option(key)
|
207
216
|
options.delete(key)
|
208
217
|
end
|
209
|
-
|
210
|
-
end # module Options
|
218
|
+
end
|
211
219
|
end
|