airbrake-ruby 3.2.2-java
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/airbrake-ruby.rb +554 -0
- data/lib/airbrake-ruby/async_sender.rb +119 -0
- data/lib/airbrake-ruby/backtrace.rb +194 -0
- data/lib/airbrake-ruby/code_hunk.rb +53 -0
- data/lib/airbrake-ruby/config.rb +238 -0
- data/lib/airbrake-ruby/config/validator.rb +63 -0
- data/lib/airbrake-ruby/deploy_notifier.rb +47 -0
- data/lib/airbrake-ruby/file_cache.rb +48 -0
- data/lib/airbrake-ruby/filter_chain.rb +95 -0
- data/lib/airbrake-ruby/filters/context_filter.rb +29 -0
- data/lib/airbrake-ruby/filters/dependency_filter.rb +31 -0
- data/lib/airbrake-ruby/filters/exception_attributes_filter.rb +45 -0
- data/lib/airbrake-ruby/filters/gem_root_filter.rb +33 -0
- data/lib/airbrake-ruby/filters/git_last_checkout_filter.rb +90 -0
- data/lib/airbrake-ruby/filters/git_repository_filter.rb +42 -0
- data/lib/airbrake-ruby/filters/git_revision_filter.rb +66 -0
- data/lib/airbrake-ruby/filters/keys_blacklist.rb +50 -0
- data/lib/airbrake-ruby/filters/keys_filter.rb +140 -0
- data/lib/airbrake-ruby/filters/keys_whitelist.rb +49 -0
- data/lib/airbrake-ruby/filters/root_directory_filter.rb +28 -0
- data/lib/airbrake-ruby/filters/sql_filter.rb +104 -0
- data/lib/airbrake-ruby/filters/system_exit_filter.rb +23 -0
- data/lib/airbrake-ruby/filters/thread_filter.rb +92 -0
- data/lib/airbrake-ruby/hash_keyable.rb +37 -0
- data/lib/airbrake-ruby/ignorable.rb +44 -0
- data/lib/airbrake-ruby/nested_exception.rb +39 -0
- data/lib/airbrake-ruby/notice.rb +165 -0
- data/lib/airbrake-ruby/notice_notifier.rb +228 -0
- data/lib/airbrake-ruby/performance_notifier.rb +161 -0
- data/lib/airbrake-ruby/promise.rb +99 -0
- data/lib/airbrake-ruby/response.rb +71 -0
- data/lib/airbrake-ruby/stat.rb +56 -0
- data/lib/airbrake-ruby/sync_sender.rb +111 -0
- data/lib/airbrake-ruby/tdigest.rb +393 -0
- data/lib/airbrake-ruby/time_truncate.rb +17 -0
- data/lib/airbrake-ruby/truncator.rb +115 -0
- data/lib/airbrake-ruby/version.rb +6 -0
- data/spec/airbrake_spec.rb +171 -0
- data/spec/async_sender_spec.rb +154 -0
- data/spec/backtrace_spec.rb +438 -0
- data/spec/code_hunk_spec.rb +118 -0
- data/spec/config/validator_spec.rb +189 -0
- data/spec/config_spec.rb +281 -0
- data/spec/deploy_notifier_spec.rb +41 -0
- data/spec/file_cache.rb +36 -0
- data/spec/filter_chain_spec.rb +83 -0
- data/spec/filters/context_filter_spec.rb +25 -0
- data/spec/filters/dependency_filter_spec.rb +14 -0
- data/spec/filters/exception_attributes_filter_spec.rb +63 -0
- data/spec/filters/gem_root_filter_spec.rb +44 -0
- data/spec/filters/git_last_checkout_filter_spec.rb +48 -0
- data/spec/filters/git_repository_filter.rb +53 -0
- data/spec/filters/git_revision_filter_spec.rb +126 -0
- data/spec/filters/keys_blacklist_spec.rb +236 -0
- data/spec/filters/keys_whitelist_spec.rb +205 -0
- data/spec/filters/root_directory_filter_spec.rb +42 -0
- data/spec/filters/sql_filter_spec.rb +219 -0
- data/spec/filters/system_exit_filter_spec.rb +14 -0
- data/spec/filters/thread_filter_spec.rb +279 -0
- data/spec/fixtures/notroot.txt +7 -0
- data/spec/fixtures/project_root/code.rb +221 -0
- data/spec/fixtures/project_root/empty_file.rb +0 -0
- data/spec/fixtures/project_root/long_line.txt +1 -0
- data/spec/fixtures/project_root/short_file.rb +3 -0
- data/spec/fixtures/project_root/vendor/bundle/ignored_file.rb +5 -0
- data/spec/helpers.rb +9 -0
- data/spec/ignorable_spec.rb +14 -0
- data/spec/nested_exception_spec.rb +75 -0
- data/spec/notice_notifier_spec.rb +436 -0
- data/spec/notice_notifier_spec/options_spec.rb +266 -0
- data/spec/notice_spec.rb +297 -0
- data/spec/performance_notifier_spec.rb +287 -0
- data/spec/promise_spec.rb +165 -0
- data/spec/response_spec.rb +82 -0
- data/spec/spec_helper.rb +102 -0
- data/spec/stat_spec.rb +35 -0
- data/spec/sync_sender_spec.rb +140 -0
- data/spec/tdigest_spec.rb +230 -0
- data/spec/time_truncate_spec.rb +13 -0
- data/spec/truncator_spec.rb +238 -0
- metadata +278 -0
@@ -0,0 +1,63 @@
|
|
1
|
+
module Airbrake
|
2
|
+
class Config
|
3
|
+
# Validates values of {Airbrake::Config} options.
|
4
|
+
#
|
5
|
+
# @api private
|
6
|
+
# @since v1.5.0
|
7
|
+
class Validator
|
8
|
+
# @return [String]
|
9
|
+
REQUIRED_KEY_MSG = ':project_key is required'.freeze
|
10
|
+
|
11
|
+
# @return [String]
|
12
|
+
REQUIRED_ID_MSG = ':project_id is required'.freeze
|
13
|
+
|
14
|
+
# @return [String]
|
15
|
+
WRONG_ENV_TYPE_MSG = "the 'environment' option must be configured " \
|
16
|
+
"with a Symbol (or String), but '%s' was provided: " \
|
17
|
+
'%s'.freeze
|
18
|
+
|
19
|
+
# @return [Array<Class>] the list of allowed types to configure the
|
20
|
+
# environment option
|
21
|
+
VALID_ENV_TYPES = [NilClass, String, Symbol].freeze
|
22
|
+
|
23
|
+
# @return [String] error message, if validator was able to find any errors
|
24
|
+
# in the config
|
25
|
+
attr_reader :error_message
|
26
|
+
|
27
|
+
# Validates given config and stores error message, if any errors were
|
28
|
+
# found.
|
29
|
+
#
|
30
|
+
# @param config [Airbrake::Config] the config to validate
|
31
|
+
def initialize(config)
|
32
|
+
@config = config
|
33
|
+
@error_message = nil
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return [Boolean]
|
37
|
+
def valid_project_id?
|
38
|
+
valid = @config.project_id.to_i > 0
|
39
|
+
@error_message = REQUIRED_ID_MSG unless valid
|
40
|
+
valid
|
41
|
+
end
|
42
|
+
|
43
|
+
# @return [Boolean]
|
44
|
+
def valid_project_key?
|
45
|
+
valid = @config.project_key.is_a?(String) && !@config.project_key.empty?
|
46
|
+
@error_message = REQUIRED_KEY_MSG unless valid
|
47
|
+
valid
|
48
|
+
end
|
49
|
+
|
50
|
+
# @return [Boolean]
|
51
|
+
def valid_environment?
|
52
|
+
environment = @config.environment
|
53
|
+
valid = VALID_ENV_TYPES.any? { |type| environment.is_a?(type) }
|
54
|
+
|
55
|
+
unless valid
|
56
|
+
@error_message = format(WRONG_ENV_TYPE_MSG, environment.class, environment)
|
57
|
+
end
|
58
|
+
|
59
|
+
valid
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Airbrake
|
2
|
+
# DeployNotifier sends deploy information to Airbrake. The information
|
3
|
+
# consists of:
|
4
|
+
# - environment
|
5
|
+
# - username
|
6
|
+
# - repository
|
7
|
+
# - revision
|
8
|
+
# - version
|
9
|
+
#
|
10
|
+
# @api public
|
11
|
+
# @since v3.2.0
|
12
|
+
class DeployNotifier
|
13
|
+
# @param [Airbrake::Config] config
|
14
|
+
def initialize(config)
|
15
|
+
@config =
|
16
|
+
if config.is_a?(Config)
|
17
|
+
config
|
18
|
+
else
|
19
|
+
loc = caller_locations(1..1).first
|
20
|
+
signature = "#{self.class.name}##{__method__}"
|
21
|
+
warn(
|
22
|
+
"#{loc.path}:#{loc.lineno}: warning: passing a Hash to #{signature} " \
|
23
|
+
'is deprecated. Pass `Airbrake::Config` instead'
|
24
|
+
)
|
25
|
+
Config.new(config)
|
26
|
+
end
|
27
|
+
|
28
|
+
@sender = SyncSender.new(@config)
|
29
|
+
end
|
30
|
+
|
31
|
+
# @see Airbrake.create_deploy
|
32
|
+
def notify(deploy_info, promise = Airbrake::Promise.new)
|
33
|
+
if @config.ignored_environment?
|
34
|
+
return promise.reject("The '#{@config.environment}' environment is ignored")
|
35
|
+
end
|
36
|
+
|
37
|
+
deploy_info[:environment] ||= @config.environment
|
38
|
+
@sender.send(
|
39
|
+
deploy_info,
|
40
|
+
promise,
|
41
|
+
URI.join(@config.host, "api/v4/projects/#{@config.project_id}/deploys")
|
42
|
+
)
|
43
|
+
|
44
|
+
promise
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Airbrake
|
2
|
+
# Extremely simple global cache.
|
3
|
+
#
|
4
|
+
# @api private
|
5
|
+
# @since v2.4.1
|
6
|
+
module FileCache
|
7
|
+
# @return [Integer]
|
8
|
+
MAX_SIZE = 50
|
9
|
+
|
10
|
+
# @return [Mutex]
|
11
|
+
MUTEX = Mutex.new
|
12
|
+
|
13
|
+
# Associates the value given by +value+ with the key given by +key+. Deletes
|
14
|
+
# entries that exceed +MAX_SIZE+.
|
15
|
+
#
|
16
|
+
# @param [Object] key
|
17
|
+
# @param [Object] value
|
18
|
+
# @return [Object] the corresponding value
|
19
|
+
def self.[]=(key, value)
|
20
|
+
MUTEX.synchronize do
|
21
|
+
data[key] = value
|
22
|
+
data.delete(data.keys.first) if data.size > MAX_SIZE
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Retrieve an object from the cache.
|
27
|
+
#
|
28
|
+
# @param [Object] key
|
29
|
+
# @return [Object] the corresponding value
|
30
|
+
def self.[](key)
|
31
|
+
MUTEX.synchronize do
|
32
|
+
data[key]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Checks whether the cache is empty. Needed only for the test suite.
|
37
|
+
#
|
38
|
+
# @return [Boolean]
|
39
|
+
def self.empty?
|
40
|
+
data.empty?
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.data
|
44
|
+
@data ||= {}
|
45
|
+
end
|
46
|
+
private_class_method :data
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module Airbrake
|
2
|
+
# FilterChain represents an ordered array of filters.
|
3
|
+
#
|
4
|
+
# A filter is an object that responds to <b>#call</b> (typically a Proc or a
|
5
|
+
# class that implements the call method). The <b>#call</b> method must accept
|
6
|
+
# exactly one argument: an object to be filtered.
|
7
|
+
#
|
8
|
+
# When you add a new filter to the chain, it gets inserted according to its
|
9
|
+
# <b>weight</b>. Smaller weight means the filter will be somewhere in the
|
10
|
+
# beginning of the array. Larger - in the end. If a filter doesn't implement
|
11
|
+
# weight, the chain assumes it's equal to 0.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# class MyFilter
|
15
|
+
# attr_reader :weight
|
16
|
+
#
|
17
|
+
# def initialize
|
18
|
+
# @weight = 1
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# def call(obj)
|
22
|
+
# puts 'Filtering...'
|
23
|
+
# obj[:data] = '[Filtered]'
|
24
|
+
# end
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# filter_chain = FilterChain.new
|
28
|
+
# filter_chain.add_filter(MyFilter)
|
29
|
+
#
|
30
|
+
# filter_chain.refine(obj)
|
31
|
+
# #=> Filtering...
|
32
|
+
#
|
33
|
+
# @see Airbrake.add_filter
|
34
|
+
# @api private
|
35
|
+
# @since v1.0.0
|
36
|
+
class FilterChain
|
37
|
+
# @return [Integer]
|
38
|
+
DEFAULT_WEIGHT = 0
|
39
|
+
|
40
|
+
def initialize
|
41
|
+
@filters = []
|
42
|
+
end
|
43
|
+
|
44
|
+
# Adds a filter to the filter chain. Sorts filters by weight.
|
45
|
+
#
|
46
|
+
# @param [#call] filter The filter object (proc, class, module, etc)
|
47
|
+
# @return [void]
|
48
|
+
def add_filter(filter)
|
49
|
+
@filters = (@filters << filter).sort_by do |f|
|
50
|
+
f.respond_to?(:weight) ? f.weight : DEFAULT_WEIGHT
|
51
|
+
end.reverse!
|
52
|
+
end
|
53
|
+
|
54
|
+
# Deletes a filter from the the filter chain.
|
55
|
+
#
|
56
|
+
# @param [Class] filter_class The class of the filter you want to delete
|
57
|
+
# @return [void]
|
58
|
+
# @since v3.1.0
|
59
|
+
def delete_filter(filter_class)
|
60
|
+
index = @filters.index { |f| f.class.name == filter_class.name }
|
61
|
+
@filters.delete_at(index) if index
|
62
|
+
end
|
63
|
+
|
64
|
+
# Applies all the filters in the filter chain to the given notice. Does not
|
65
|
+
# filter ignored notices.
|
66
|
+
#
|
67
|
+
# @param [Airbrake::Notice] notice The notice to be filtered
|
68
|
+
# @return [void]
|
69
|
+
# @todo Make it work with anything, not only notices
|
70
|
+
def refine(notice)
|
71
|
+
@filters.each do |filter|
|
72
|
+
break if notice.ignored?
|
73
|
+
filter.call(notice)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# @return [String] customized inspect to lessen the amount of clutter
|
78
|
+
def inspect
|
79
|
+
@filters.map(&:class)
|
80
|
+
end
|
81
|
+
|
82
|
+
# @return [String] {#inspect} for PrettyPrint
|
83
|
+
def pretty_print(q)
|
84
|
+
q.text('[')
|
85
|
+
|
86
|
+
# Make nesting of the first element consistent on JRuby and MRI.
|
87
|
+
q.nest(2) { q.breakable }
|
88
|
+
|
89
|
+
q.nest(2) do
|
90
|
+
q.seplist(@filters) { |f| q.pp(f.class) }
|
91
|
+
end
|
92
|
+
q.text(']')
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Airbrake
|
2
|
+
module Filters
|
3
|
+
# Adds user context to the notice object. Clears the context after it's
|
4
|
+
# attached.
|
5
|
+
#
|
6
|
+
# @api private
|
7
|
+
# @since v2.9.0
|
8
|
+
class ContextFilter
|
9
|
+
# @return [Integer]
|
10
|
+
attr_reader :weight
|
11
|
+
|
12
|
+
def initialize(context)
|
13
|
+
@context = context
|
14
|
+
@weight = 119
|
15
|
+
@mutex = Mutex.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# @macro call_filter
|
19
|
+
def call(notice)
|
20
|
+
@mutex.synchronize do
|
21
|
+
return if @context.empty?
|
22
|
+
|
23
|
+
notice[:params][:airbrake_context] = @context.dup
|
24
|
+
@context.clear
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Airbrake
|
2
|
+
module Filters
|
3
|
+
# Attaches loaded dependencies to the notice object.
|
4
|
+
#
|
5
|
+
# @api private
|
6
|
+
# @since v2.10.0
|
7
|
+
class DependencyFilter
|
8
|
+
def initialize
|
9
|
+
@weight = 117
|
10
|
+
end
|
11
|
+
|
12
|
+
# @macro call_filter
|
13
|
+
def call(notice)
|
14
|
+
deps = {}
|
15
|
+
Gem.loaded_specs.map.with_object(deps) do |(name, spec), h|
|
16
|
+
h[name] = "#{spec.version}#{git_version(spec)}"
|
17
|
+
end
|
18
|
+
|
19
|
+
notice[:context][:versions] = {} unless notice[:context].key?(:versions)
|
20
|
+
notice[:context][:versions][:dependencies] = deps
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def git_version(spec)
|
26
|
+
return unless spec.respond_to?(:git_version) || spec.git_version
|
27
|
+
spec.git_version.to_s
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Airbrake
|
2
|
+
module Filters
|
3
|
+
# ExceptionAttributesFilter attempts to call `#to_airbrake` on the stashed
|
4
|
+
# exception and attaches returned data to the notice object.
|
5
|
+
#
|
6
|
+
# @api private
|
7
|
+
# @since v2.10.0
|
8
|
+
class ExceptionAttributesFilter
|
9
|
+
def initialize(logger)
|
10
|
+
@logger = logger
|
11
|
+
@weight = 118
|
12
|
+
end
|
13
|
+
|
14
|
+
# @macro call_filter
|
15
|
+
def call(notice)
|
16
|
+
exception = notice.stash[:exception]
|
17
|
+
return unless exception.respond_to?(:to_airbrake)
|
18
|
+
|
19
|
+
attributes = nil
|
20
|
+
begin
|
21
|
+
attributes = exception.to_airbrake
|
22
|
+
rescue StandardError => ex
|
23
|
+
@logger.error(
|
24
|
+
"#{LOG_LABEL} #{exception.class}#to_airbrake failed. #{ex.class}: #{ex}"
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
unless attributes.is_a?(Hash)
|
29
|
+
@logger.error(
|
30
|
+
"#{LOG_LABEL} #{self.class}: wanted Hash, got #{attributes.class}"
|
31
|
+
)
|
32
|
+
return
|
33
|
+
end
|
34
|
+
|
35
|
+
attributes.each do |key, attrs|
|
36
|
+
if notice[key]
|
37
|
+
notice[key].merge!(attrs)
|
38
|
+
else
|
39
|
+
notice[key] = attrs
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Airbrake
|
2
|
+
module Filters
|
3
|
+
# Replaces paths to gems with a placeholder.
|
4
|
+
# @api private
|
5
|
+
class GemRootFilter
|
6
|
+
# @return [String]
|
7
|
+
GEM_ROOT_LABEL = '/GEM_ROOT'.freeze
|
8
|
+
|
9
|
+
# @return [Integer]
|
10
|
+
attr_reader :weight
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@weight = 120
|
14
|
+
end
|
15
|
+
|
16
|
+
# @macro call_filter
|
17
|
+
def call(notice)
|
18
|
+
return unless defined?(Gem)
|
19
|
+
|
20
|
+
notice[:errors].each do |error|
|
21
|
+
Gem.path.each do |gem_path|
|
22
|
+
error[:backtrace].each do |frame|
|
23
|
+
# If the frame is unparseable, then 'file' is nil, thus nothing to
|
24
|
+
# filter (all frame's data is in 'function' instead).
|
25
|
+
next unless (file = frame[:file])
|
26
|
+
frame[:file] = file.sub(/\A#{gem_path}/, GEM_ROOT_LABEL)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module Airbrake
|
4
|
+
module Filters
|
5
|
+
# Attaches git checkout info to `context`. The info includes:
|
6
|
+
# * username
|
7
|
+
# * email
|
8
|
+
# * revision
|
9
|
+
# * time
|
10
|
+
#
|
11
|
+
# This information is used to track deploys automatically.
|
12
|
+
#
|
13
|
+
# @api private
|
14
|
+
# @since v2.12.0
|
15
|
+
class GitLastCheckoutFilter
|
16
|
+
# @return [Integer]
|
17
|
+
attr_reader :weight
|
18
|
+
|
19
|
+
# @return [Integer] least possible amount of columns in git's `logs/HEAD`
|
20
|
+
# file (checkout information is omitted)
|
21
|
+
MIN_HEAD_COLS = 6
|
22
|
+
|
23
|
+
# @param [Logger] logger
|
24
|
+
# @param [String] root_directory
|
25
|
+
def initialize(logger, root_directory)
|
26
|
+
@git_path = File.join(root_directory, '.git')
|
27
|
+
@logger = logger
|
28
|
+
@weight = 116
|
29
|
+
@last_checkout = nil
|
30
|
+
end
|
31
|
+
|
32
|
+
# @macro call_filter
|
33
|
+
def call(notice)
|
34
|
+
return if notice[:context].key?(:lastCheckout)
|
35
|
+
|
36
|
+
if @last_checkout
|
37
|
+
notice[:context][:lastCheckout] = @last_checkout
|
38
|
+
return
|
39
|
+
end
|
40
|
+
|
41
|
+
return unless File.exist?(@git_path)
|
42
|
+
return unless (checkout = last_checkout)
|
43
|
+
notice[:context][:lastCheckout] = checkout
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def last_checkout
|
49
|
+
return unless (line = last_checkout_line)
|
50
|
+
|
51
|
+
parts = line.chomp.split("\t").first.split(' ')
|
52
|
+
if parts.size < MIN_HEAD_COLS
|
53
|
+
@logger.error(
|
54
|
+
"#{LOG_LABEL} Airbrake::#{self.class.name}: can't parse line: #{line}"
|
55
|
+
)
|
56
|
+
return
|
57
|
+
end
|
58
|
+
|
59
|
+
author = parts[2..-4]
|
60
|
+
@last_checkout = {
|
61
|
+
username: author[0..1].join(' '),
|
62
|
+
email: parts[-3][1..-2],
|
63
|
+
revision: parts[1],
|
64
|
+
time: timestamp(parts[-2].to_i)
|
65
|
+
}
|
66
|
+
end
|
67
|
+
|
68
|
+
def last_checkout_line
|
69
|
+
head_path = File.join(@git_path, 'logs', 'HEAD')
|
70
|
+
return unless File.exist?(head_path)
|
71
|
+
|
72
|
+
last_line = nil
|
73
|
+
IO.foreach(head_path) do |line|
|
74
|
+
last_line = line if checkout_line?(line)
|
75
|
+
end
|
76
|
+
last_line
|
77
|
+
end
|
78
|
+
|
79
|
+
def checkout_line?(line)
|
80
|
+
line.include?("\tclone:") ||
|
81
|
+
line.include?("\tpull:") ||
|
82
|
+
line.include?("\tcheckout:")
|
83
|
+
end
|
84
|
+
|
85
|
+
def timestamp(utime)
|
86
|
+
Time.at(utime).to_datetime.rfc3339
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|