twig 1.6 → 1.7
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/.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
|