git_tree 0.3.0 → 1.0.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/.rubocop.yml +21 -4
- data/CHANGELOG.md +8 -2
- data/README.md +222 -81
- data/exe/git-commitAll +3 -0
- data/exe/git-evars +3 -0
- data/exe/git-exec +3 -0
- data/exe/git-replicate +3 -0
- data/exe/git-update +3 -0
- data/git_tree.gemspec +15 -10
- data/lib/commands/abstract_command.rb +88 -0
- data/lib/commands/git_commit_all.rb +155 -0
- data/lib/commands/git_evars.rb +159 -0
- data/lib/commands/git_exec.rb +104 -0
- data/lib/commands/git_exec_spec.rb +0 -0
- data/lib/commands/git_replicate.rb +91 -0
- data/lib/commands/git_update.rb +113 -0
- data/lib/git_tree/version.rb +1 -1
- data/lib/git_tree.rb +10 -26
- data/lib/util/command_runner.rb +12 -0
- data/lib/{util.rb → util/gem_support.rb} +16 -6
- data/lib/util/git_tree_walker.rb +75 -0
- data/lib/util/git_tree_walker_private.rb +60 -0
- data/lib/util/log.rb +49 -0
- data/lib/util/thread_pool_manager.rb +116 -0
- data/lib/util/zowee_optimizer.rb +136 -0
- metadata +52 -21
- data/bindir/git-tree-evars +0 -5
- data/bindir/git-tree-exec +0 -5
- data/bindir/git-tree-replicate +0 -5
- data/lib/git_tree_evars.rb +0 -82
- data/lib/git_tree_exec.rb +0 -94
- data/lib/git_tree_replicate.rb +0 -86
@@ -0,0 +1,113 @@
|
|
1
|
+
require 'shellwords'
|
2
|
+
require 'timeout'
|
3
|
+
require_relative 'abstract_command'
|
4
|
+
require_relative '../util/git_tree_walker'
|
5
|
+
require_relative '../util/command_runner'
|
6
|
+
|
7
|
+
module GitTree
|
8
|
+
class UpdateCommand < GitTree::AbstractCommand
|
9
|
+
include Logging
|
10
|
+
|
11
|
+
attr_writer :walker, :runner
|
12
|
+
|
13
|
+
self.allow_empty_args = true
|
14
|
+
|
15
|
+
def initialize(args = ARGV, options: {})
|
16
|
+
$PROGRAM_NAME = 'git-update'
|
17
|
+
super
|
18
|
+
# Allow walker and runner to be injected for testing
|
19
|
+
@runner = @options.delete(:runner)
|
20
|
+
@walker = @options.delete(:walker)
|
21
|
+
end
|
22
|
+
|
23
|
+
def run
|
24
|
+
setup
|
25
|
+
@runner ||= CommandRunner.new
|
26
|
+
@walker ||= GitTreeWalker.new(@args, options: @options)
|
27
|
+
@walker.process do |dir, thread_id, walker|
|
28
|
+
process_repo(walker, dir, thread_id)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def help(msg = nil)
|
35
|
+
log(QUIET, "Error: #{msg}\n", :red) if msg
|
36
|
+
log QUIET, <<~END_HELP
|
37
|
+
git-update - Recursively updates trees of git repositories.
|
38
|
+
|
39
|
+
If no arguments are given, uses default environment variables (#{GitTreeWalker::DEFAULT_ROOTS.join(', ')}) as roots.
|
40
|
+
These environment variables point to roots of git repository trees to walk.
|
41
|
+
Skips directories containing a .ignore file, and all subdirectories.
|
42
|
+
|
43
|
+
Environment variables that point to the roots of git repository trees must have been exported, for example:
|
44
|
+
|
45
|
+
$ export work=$HOME/work
|
46
|
+
|
47
|
+
Usage: #{$PROGRAM_NAME} [OPTIONS] [ROOTS...]
|
48
|
+
|
49
|
+
OPTIONS:
|
50
|
+
-h, --help Show this help message and exit.
|
51
|
+
-q, --quiet Suppress normal output, only show errors.
|
52
|
+
-v, --verbose Increase verbosity. Can be used multiple times (e.g., -v, -vv).
|
53
|
+
|
54
|
+
ROOTS:
|
55
|
+
When specifying roots, directory paths can be specified, and environment variables can be used, preceded by a dollar sign.
|
56
|
+
|
57
|
+
Usage examples:
|
58
|
+
|
59
|
+
$ #{$PROGRAM_NAME} # Use default environment variables as roots
|
60
|
+
$ #{$PROGRAM_NAME} $work $sites # Use specific environment variables
|
61
|
+
$ #{$PROGRAM_NAME} $work /path/to/git/tree
|
62
|
+
END_HELP
|
63
|
+
exit 1
|
64
|
+
end
|
65
|
+
|
66
|
+
# Updates the git repository in the given directory.
|
67
|
+
# @param git_walker [GitTreeWalker] The GitTreeWalker instance.
|
68
|
+
# @param dir [String] The path to the git repository.
|
69
|
+
# @param thread_id [Integer] The ID of the current worker thread.
|
70
|
+
# @return [nil]
|
71
|
+
def process_repo(git_walker, dir, thread_id)
|
72
|
+
abbrev_dir = git_walker.abbreviate_path(dir)
|
73
|
+
log NORMAL, "Updating #{abbrev_dir}", :green
|
74
|
+
log VERBOSE, "Thread #{thread_id}: git -C #{dir} pull", :yellow
|
75
|
+
|
76
|
+
output = nil
|
77
|
+
status = nil
|
78
|
+
begin
|
79
|
+
Timeout.timeout(GitTreeWalker::GIT_TIMEOUT) do
|
80
|
+
log VERBOSE, "Executing: git pull in #{dir}", :yellow
|
81
|
+
output, status_obj = @runner.run('git pull', dir)
|
82
|
+
status = status_obj.exitstatus
|
83
|
+
end
|
84
|
+
rescue Timeout::Error
|
85
|
+
log NORMAL, "[TIMEOUT] Thread #{thread_id}: git pull timed out in #{abbrev_dir}", :red
|
86
|
+
status = -1
|
87
|
+
rescue StandardError => e
|
88
|
+
log NORMAL, "[ERROR] Thread #{thread_id}: #{e.class} in #{abbrev_dir}; #{e.message}\n#{e.backtrace.join("\n")}", :red
|
89
|
+
status = -1
|
90
|
+
end
|
91
|
+
|
92
|
+
if !status.zero?
|
93
|
+
log NORMAL, "[ERROR] git pull failed in #{abbrev_dir} (exit code #{status}):", :red
|
94
|
+
log NORMAL, output.strip, :red unless output.to_s.strip.empty?
|
95
|
+
elsif Logging.verbosity >= VERBOSE
|
96
|
+
# Output from a successful pull is considered NORMAL level
|
97
|
+
log NORMAL, output.strip, :green
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
if $PROGRAM_NAME == __FILE__ || $PROGRAM_NAME.end_with?('git-update')
|
104
|
+
begin
|
105
|
+
GitTree::UpdateCommand.new(ARGV).run
|
106
|
+
rescue Interrupt
|
107
|
+
log NORMAL, "\nInterrupted by user", :yellow
|
108
|
+
exit! 130
|
109
|
+
rescue StandardError => e
|
110
|
+
log QUIET, "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}", :red
|
111
|
+
exit! 1
|
112
|
+
end
|
113
|
+
end
|
data/lib/git_tree/version.rb
CHANGED
data/lib/git_tree.rb
CHANGED
@@ -1,31 +1,15 @@
|
|
1
|
-
require '
|
2
|
-
|
3
|
-
require 'rugged'
|
4
|
-
require_relative 'util'
|
1
|
+
require 'gem_support'
|
2
|
+
require_relative 'git_tree/version'
|
5
3
|
|
6
4
|
module GitTree
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
def self.directories_to_process(root)
|
12
|
-
root_fq = File.expand_path root
|
13
|
-
abort "Error: #{root_fq} is a file, instead of a directory. Cannot recurse.".red if File.file? root_fq
|
14
|
-
|
15
|
-
root_fq = MslinnUtil.deref_symlink(root_fq).to_s
|
16
|
-
abort "Error: #{root_fq} does not exist. Halting.".red unless Dir.exist? root_fq
|
17
|
-
|
18
|
-
result = []
|
19
|
-
Find.find(root_fq) do |path|
|
20
|
-
next if File.file? path
|
5
|
+
# Helper to require all .rb files in a subdirectory
|
6
|
+
def self.require_all(relative_path)
|
7
|
+
Dir[File.join(__dir__, relative_path, '*.rb')].sort.each { |file| require file }
|
8
|
+
end
|
21
9
|
|
22
|
-
|
10
|
+
# Require utilities first, as commands may depend on them.
|
11
|
+
require_all 'util'
|
12
|
+
require_all 'commands'
|
23
13
|
|
24
|
-
|
25
|
-
result << path.to_s
|
26
|
-
Find.prune
|
27
|
-
end
|
28
|
-
end
|
29
|
-
result.map { |x| x.delete_prefix("#{root_fq}/") }
|
30
|
-
end
|
14
|
+
include Logging
|
31
15
|
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'open3'
|
2
|
+
|
3
|
+
class CommandRunner
|
4
|
+
# Executes a shell command in a specified directory.
|
5
|
+
# This is wrapped in a class to make it easy to mock in tests.
|
6
|
+
# @param command [String] The shell command to execute.
|
7
|
+
# @param dir [String] The directory to execute the command in.
|
8
|
+
# @return [Array] A tuple containing the output and the status object.
|
9
|
+
def run(command, dir)
|
10
|
+
Open3.capture2e(command, chdir: dir)
|
11
|
+
end
|
12
|
+
end
|
@@ -1,5 +1,8 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
require 'pathname'
|
2
|
+
|
3
|
+
module GemSupport
|
4
|
+
# @param paths [Array<String>] all start with a leading '/' (they are assumed to be absolute paths).
|
5
|
+
# @param allow_root_match [Boolean]
|
3
6
|
# @return [String] the longest path prefix that is a prefix of all paths in array.
|
4
7
|
# If array is empty, return ''.
|
5
8
|
# If only the leading slash matches, and allow_root_match is true, return '/', else return ''.
|
@@ -25,8 +28,10 @@ module MslinnUtil
|
|
25
28
|
result.empty? && allow_root_match ? '/' : result
|
26
29
|
end
|
27
30
|
|
28
|
-
# @param paths [Array
|
31
|
+
# @param paths [Array<String>] absolute paths to examine
|
29
32
|
# @param level [Int] minimum # of leading directory names in result, origin 1
|
33
|
+
# @param allow_root_match [Boolean] Whether to return '/' if only the root matches.
|
34
|
+
# Defaults to false.
|
30
35
|
def self.roots(paths, level, allow_root_match: false)
|
31
36
|
abort "Error: level must be positive, but it is #{level}." unless level.positive?
|
32
37
|
return allow_root_match ? '/' : '' if paths.empty?
|
@@ -51,27 +56,32 @@ module MslinnUtil
|
|
51
56
|
allow_root_match ? '/' : ''
|
52
57
|
end
|
53
58
|
|
54
|
-
# @param paths [Array
|
59
|
+
# @param paths [Array<String>] absolute paths to examine
|
55
60
|
# @param level is origin 1
|
56
61
|
def self.trim_to_level(paths, level)
|
57
62
|
result = paths.map do |x|
|
58
63
|
elements = x.split('/').reject(&:empty?)
|
59
|
-
'/' + elements[0..level - 1].join('/')
|
64
|
+
'/' + elements[0..(level - 1)].join('/')
|
60
65
|
end
|
61
66
|
result.sort.uniq
|
62
67
|
end
|
63
68
|
|
64
69
|
# @return Path to symlink
|
65
70
|
def self.deref_symlink(symlink)
|
66
|
-
require 'pathname'
|
67
71
|
Pathname.new(symlink).realpath
|
68
72
|
end
|
69
73
|
|
74
|
+
# @param string [String] The string to ensure ends with the suffix.
|
75
|
+
# @param suffix [String] The suffix to ensure the string ends with.
|
70
76
|
def self.ensure_ends_with(string, suffix)
|
71
77
|
string = string.delete_suffix suffix
|
72
78
|
"#{string}#{suffix}"
|
73
79
|
end
|
74
80
|
|
81
|
+
# Expands environment variables in a string.
|
82
|
+
#
|
83
|
+
# @param str [String] The string containing environment variables to expand.
|
84
|
+
# @return [String] The string with environment variables expanded.
|
75
85
|
def self.expand_env(str)
|
76
86
|
str.gsub(/\$([a-zA-Z_][a-zA-Z0-9_]*)|\${\g<1>}|%\g<1>%/) do
|
77
87
|
ENV.fetch(Regexp.last_match(1), nil)
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'English'
|
2
|
+
require 'etc'
|
3
|
+
require 'shellwords'
|
4
|
+
require 'optparse'
|
5
|
+
require 'timeout'
|
6
|
+
require_relative 'thread_pool_manager'
|
7
|
+
require_relative 'log'
|
8
|
+
|
9
|
+
class GitTreeWalker
|
10
|
+
include Logging
|
11
|
+
|
12
|
+
attr_reader :display_roots, :root_map
|
13
|
+
|
14
|
+
DEFAULT_ROOTS = %w[sites sitesUbuntu work].freeze
|
15
|
+
GIT_TIMEOUT = 10 # TODO: for debuggin only; should be 300 # 5 minutes per git pull
|
16
|
+
IGNORED_DIRECTORIES = ['.', '..', '.venv'].freeze
|
17
|
+
|
18
|
+
def initialize(args = ARGV, options: {})
|
19
|
+
@options = options
|
20
|
+
@root_map = {}
|
21
|
+
@display_roots = []
|
22
|
+
determine_roots(args)
|
23
|
+
end
|
24
|
+
|
25
|
+
def abbreviate_path(dir)
|
26
|
+
@root_map.each do |display_root, expanded_paths|
|
27
|
+
expanded_paths.each do |expanded_path|
|
28
|
+
return dir.sub(expanded_path, display_root) if dir.start_with?(expanded_path)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
dir # Return original if no match
|
32
|
+
end
|
33
|
+
|
34
|
+
def process(&) # Accepts a block
|
35
|
+
log VERBOSE, "Processing #{@display_roots.join(' ')}", :green
|
36
|
+
if @options[:serial]
|
37
|
+
log VERBOSE, "Running in serial mode.", :yellow
|
38
|
+
find_and_process_repos do |dir, _root_arg|
|
39
|
+
yield(dir, 0, self) # task, thread_id, walker
|
40
|
+
end
|
41
|
+
else
|
42
|
+
pool = FixedThreadPoolManager.new(0.75)
|
43
|
+
# The block passed to pool.start now receives the walker instance (self)
|
44
|
+
pool.start do |_worker, dir, thread_id|
|
45
|
+
yield(dir, thread_id, self)
|
46
|
+
end
|
47
|
+
# Run the directory scanning in a separate thread so the main thread can handle interrupts.
|
48
|
+
producer_thread = Thread.new do
|
49
|
+
find_and_process_repos do |dir, _root_arg|
|
50
|
+
pool.add_task(dir)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Wait for the producer to finish, then wait for the pool to complete.
|
55
|
+
producer_thread.join
|
56
|
+
pool.wait_for_completion
|
57
|
+
end
|
58
|
+
rescue Interrupt
|
59
|
+
# If interrupted, ensure the pool is shut down and then let the main command handle the exit.
|
60
|
+
pool&.shutdown
|
61
|
+
raise
|
62
|
+
end
|
63
|
+
|
64
|
+
# Finds git repos and yields them to the block. Does not use thread pool.
|
65
|
+
def find_and_process_repos(&)
|
66
|
+
visited = Set.new
|
67
|
+
@root_map.each do |root_arg, paths|
|
68
|
+
paths.sort.each do |root_path|
|
69
|
+
find_git_repos_recursive(root_path, visited) { |dir| yield(dir, root_arg) }
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
require_relative 'git_tree_walker_private'
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require_relative 'log'
|
2
|
+
|
3
|
+
class GitTreeWalker
|
4
|
+
include Logging
|
5
|
+
|
6
|
+
private
|
7
|
+
|
8
|
+
def determine_roots(args)
|
9
|
+
if args.empty?
|
10
|
+
@display_roots = DEFAULT_ROOTS.map { |r| "$#{r}" }
|
11
|
+
DEFAULT_ROOTS.each do |r|
|
12
|
+
@root_map["$#{r}"] = ENV[r].split.map { |p| File.expand_path(p) } if ENV[r]
|
13
|
+
end
|
14
|
+
else
|
15
|
+
processed_args = args.flat_map { |arg| arg.strip.split(/\s+/) }
|
16
|
+
@display_roots = processed_args.dup
|
17
|
+
processed_args.each do |arg|
|
18
|
+
path = arg
|
19
|
+
if (match = arg.match(/\A'?\$([a-zA-Z_]\w*)'?\z/))
|
20
|
+
var_name = match[1]
|
21
|
+
path = ENV.fetch(var_name, nil)
|
22
|
+
end
|
23
|
+
@root_map[arg] = [File.expand_path(path)] if path
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def sort_directory_entries(directory_path)
|
29
|
+
Dir.children(directory_path).select do |entry|
|
30
|
+
File.directory?(File.join(directory_path, entry))
|
31
|
+
end.sort
|
32
|
+
end
|
33
|
+
|
34
|
+
def find_git_repos_recursive(root_path, visited, &block)
|
35
|
+
return unless File.directory?(root_path)
|
36
|
+
|
37
|
+
return if File.exist?(File.join(root_path, '.ignore'))
|
38
|
+
|
39
|
+
log DEBUG, "Scanning #{root_path}", :green
|
40
|
+
git_dir_or_file = File.join(root_path, '.git')
|
41
|
+
if File.exist?(git_dir_or_file)
|
42
|
+
log DEBUG, " Found #{git_dir_or_file}", :green
|
43
|
+
unless visited.include?(root_path)
|
44
|
+
visited.add(root_path)
|
45
|
+
yield root_path
|
46
|
+
end
|
47
|
+
return # Prune search
|
48
|
+
else
|
49
|
+
log DEBUG, " #{root_path} is not a git directory", :green
|
50
|
+
end
|
51
|
+
|
52
|
+
sort_directory_entries(root_path).each do |entry|
|
53
|
+
next if IGNORED_DIRECTORIES.include?(entry)
|
54
|
+
|
55
|
+
find_git_repos_recursive(File.join(root_path, entry), visited, &block)
|
56
|
+
end
|
57
|
+
rescue SystemCallError => e
|
58
|
+
log NORMAL, "Error scanning #{root_path}: #{e.message}", :red
|
59
|
+
end
|
60
|
+
end
|
data/lib/util/log.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'rainbow/refinement'
|
2
|
+
|
3
|
+
module Logging
|
4
|
+
using Rainbow
|
5
|
+
|
6
|
+
# Verbosity levels
|
7
|
+
QUIET = 0
|
8
|
+
NORMAL = 1
|
9
|
+
VERBOSE = 2
|
10
|
+
DEBUG = 3
|
11
|
+
|
12
|
+
# Class-level instance variables to hold the verbosity setting for the module
|
13
|
+
@verbosity = NORMAL
|
14
|
+
|
15
|
+
# @return [Integer] The current verbosity level.
|
16
|
+
def self.verbosity
|
17
|
+
@verbosity
|
18
|
+
end
|
19
|
+
|
20
|
+
# @param level [Integer] The new verbosity level.
|
21
|
+
# @return [nil]
|
22
|
+
def self.verbosity=(level)
|
23
|
+
@verbosity = level
|
24
|
+
end
|
25
|
+
|
26
|
+
# A thread-safe output method for colored text to STDERR.
|
27
|
+
# @param level [Integer] The verbosity level of the message.
|
28
|
+
# @param multiline_string [String] The message to log.
|
29
|
+
# @param color [Symbol, nil] The color method to apply from Rainbow, e.g., :red, :green. If nil, no color is applied.
|
30
|
+
# @return [nil]
|
31
|
+
def log(level, multiline_string, color = nil)
|
32
|
+
return unless Logging.verbosity >= level
|
33
|
+
|
34
|
+
multiline_string.to_s.each_line do |line|
|
35
|
+
line_to_print = line.chomp
|
36
|
+
line_to_print = line_to_print.public_send(color) if color
|
37
|
+
warn line_to_print
|
38
|
+
end
|
39
|
+
$stderr.flush
|
40
|
+
end
|
41
|
+
|
42
|
+
# A thread-safe output method for uncolored text to STDOUT.
|
43
|
+
# @param multiline_string [String] The message to log.
|
44
|
+
# @return [nil]
|
45
|
+
def log_stdout(multiline_string)
|
46
|
+
$stdout.puts multiline_string.to_s
|
47
|
+
$stdout.flush
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
require 'etc'
|
2
|
+
require_relative 'log'
|
3
|
+
|
4
|
+
class FixedThreadPoolManager
|
5
|
+
include Logging
|
6
|
+
|
7
|
+
SHUTDOWN_SIGNAL = :shutdown
|
8
|
+
|
9
|
+
# Calculate the number of worker threads as 75% of available processors
|
10
|
+
# (less one for the monitor thread), with a minimum of 1.
|
11
|
+
# @param percent_available_processors [Float] The percentage of available processors to use for worker threads.
|
12
|
+
def initialize(percent_available_processors = 0.75)
|
13
|
+
if percent_available_processors > 1 || percent_available_processors <= 0
|
14
|
+
msg = <<~END_MSG
|
15
|
+
Error: The allowable range for the ThreadPool.initialize percent_available_processors is between 0 and 1.
|
16
|
+
You provided #{percent_available_processors}.
|
17
|
+
END_MSG
|
18
|
+
log QUIET, msg, :red
|
19
|
+
exit! 1
|
20
|
+
end
|
21
|
+
@worker_count = [(Etc.nprocessors * percent_available_processors).floor, 1].max
|
22
|
+
@main_work_queue = Queue.new
|
23
|
+
@workers = []
|
24
|
+
end
|
25
|
+
|
26
|
+
# Adds a single task to the work queue.
|
27
|
+
# The pool must have been started with `start` first.
|
28
|
+
def add_task(task)
|
29
|
+
@main_work_queue.push(task)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Signals the pool to shut down after all currently queued tasks are processed.
|
33
|
+
# This is a non-blocking method.
|
34
|
+
# When you call it, it simply places a special SHUTDOWN_SIGNAL message onto the
|
35
|
+
# main work queue. The method returns immediately, allowing your main thread to
|
36
|
+
# continue with other tasks.
|
37
|
+
# It's like telling the pool, "I'm not going to give you any more tasks,
|
38
|
+
# so start wrapping things up when you're done with what you have."
|
39
|
+
def shutdown
|
40
|
+
@main_work_queue.push(SHUTDOWN_SIGNAL)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Starts the workers and the monitor, but does not wait for them to complete.
|
44
|
+
# This is for "drip-feeding" tasks.
|
45
|
+
# @param Block of code to execute for each task.
|
46
|
+
# @return nil
|
47
|
+
def start(&)
|
48
|
+
initialize_workers(&)
|
49
|
+
end
|
50
|
+
|
51
|
+
# This is the last method to call when using FixedPoolManager.
|
52
|
+
# This is a blocking method.
|
53
|
+
# It pauses the execution of your main thread and waits until the monitor and all worker threads have
|
54
|
+
# fully completed their work and terminated.
|
55
|
+
def wait_for_completion
|
56
|
+
@worker_count.times { @main_work_queue.push(SHUTDOWN_SIGNAL) }
|
57
|
+
|
58
|
+
last_active_count = -1
|
59
|
+
loop do
|
60
|
+
active_workers = @workers.count(&:alive?)
|
61
|
+
break if active_workers.zero?
|
62
|
+
|
63
|
+
if active_workers != last_active_count
|
64
|
+
warn format("Waiting for %d worker threads to complete...", active_workers) + "\r" if Logging.verbosity > NORMAL
|
65
|
+
last_active_count = active_workers
|
66
|
+
end
|
67
|
+
begin
|
68
|
+
sleep 0.1
|
69
|
+
rescue Interrupt
|
70
|
+
# This can be interrupted by Ctrl-C. We catch it here to allow the main thread's
|
71
|
+
# rescue block to handle the exit gracefully without a stack trace.
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
warn (" " * 60) + "\r" # Clear the line
|
76
|
+
log NORMAL, "All work is complete.", :green
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def initialize_workers
|
82
|
+
log NORMAL, "Initializing #{@worker_count} worker threads...", :green
|
83
|
+
@worker_count.times do |i|
|
84
|
+
worker_thread = Thread.new do
|
85
|
+
log NORMAL, " [Worker #{i}] Started.", :cyan
|
86
|
+
start_time = Time.now
|
87
|
+
start_cpu = Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID)
|
88
|
+
tasks_processed = 0
|
89
|
+
|
90
|
+
loop do
|
91
|
+
task = @main_work_queue.pop # The worker blocks here, waiting for a task.
|
92
|
+
break if task == SHUTDOWN_SIGNAL
|
93
|
+
|
94
|
+
yield(self, task, i) # Execute the provided block of work.
|
95
|
+
tasks_processed += 1
|
96
|
+
end
|
97
|
+
|
98
|
+
if Logging.verbosity >= VERBOSE
|
99
|
+
elapsed_time = Time.now - start_time
|
100
|
+
cpu_time = Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) - start_cpu
|
101
|
+
shutdown_msg = format(
|
102
|
+
" [Worker #{i}] Shutting down. Processed #{tasks_processed} tasks. Elapsed: %.2fs, CPU: %.2fs",
|
103
|
+
elapsed_time, cpu_time
|
104
|
+
)
|
105
|
+
log VERBOSE, shutdown_msg, :cyan
|
106
|
+
end
|
107
|
+
rescue Interrupt
|
108
|
+
# This thread was interrupted by Ctrl-C, likely while waiting on the queue.
|
109
|
+
# Exit gracefully without a stack trace.
|
110
|
+
end
|
111
|
+
# Suppress automatic error reporting for this thread. The error should be handled elsewhere.
|
112
|
+
worker_thread.report_on_exception = false
|
113
|
+
@workers << worker_thread
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
class ZoweeOptimizer
|
2
|
+
# The ZoweeOptimizer class is responsible for optimizing the environment variable definitions.
|
3
|
+
# It is used by the `git-evars` command to generate a script with shorter and more readable variable names.
|
4
|
+
def initialize(initial_vars = {})
|
5
|
+
@defined_vars = {}
|
6
|
+
initial_vars.each do |var_ref, paths|
|
7
|
+
var_name = var_ref.tr("'$", '')
|
8
|
+
@defined_vars[var_name] = paths.first if paths.any?
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# Optimizes a list of paths to generate a script with environment variable definitions.
|
13
|
+
# @param paths [Array<String>] are provided in breadth-first order, so no sorting is needed.
|
14
|
+
# @param initial_roots [Array<String>] a list of initial root variables.
|
15
|
+
# @return [Array<String>] a list of strings, where each string is an export statement for an environment variable.
|
16
|
+
def optimize(paths, initial_roots)
|
17
|
+
output = []
|
18
|
+
|
19
|
+
# Find common prefixes and define intermediate variables
|
20
|
+
define_intermediate_vars(paths)
|
21
|
+
|
22
|
+
paths.each do |path|
|
23
|
+
var_name = generate_var_name(path)
|
24
|
+
next if var_name.nil?
|
25
|
+
|
26
|
+
# Skip defining a var for a root that was passed in.
|
27
|
+
next if initial_roots.include?("$#{var_name}") && @defined_vars[var_name] == path
|
28
|
+
|
29
|
+
best_substitution = find_best_substitution(path)
|
30
|
+
|
31
|
+
value = if best_substitution
|
32
|
+
"$#{best_substitution[:var]}/#{path.sub("#{best_substitution[:path]}/", '')}"
|
33
|
+
else
|
34
|
+
path
|
35
|
+
end
|
36
|
+
|
37
|
+
output << "export #{var_name}=#{value}"
|
38
|
+
@defined_vars[var_name] = path
|
39
|
+
end
|
40
|
+
|
41
|
+
(@intermediate_vars.values + output).uniq
|
42
|
+
end
|
43
|
+
|
44
|
+
# Generates a valid environment variable name from a path.
|
45
|
+
# @param path [String] the path to generate the variable name from.
|
46
|
+
# @return [String] a valid environment variable name.
|
47
|
+
def generate_var_name(path)
|
48
|
+
basename = File.basename(path)
|
49
|
+
return nil if basename.empty?
|
50
|
+
|
51
|
+
parts = basename.split('.')
|
52
|
+
name = if parts.first == 'www' && parts.length > 1
|
53
|
+
parts[1]
|
54
|
+
else
|
55
|
+
parts.first
|
56
|
+
end.tr('-', '_')
|
57
|
+
|
58
|
+
if @defined_vars.key?(name) && @defined_vars[name] != path
|
59
|
+
# Collision. Try to disambiguate.
|
60
|
+
parent_name = File.basename(File.dirname(path))
|
61
|
+
name = "#{parent_name}_#{name}"
|
62
|
+
end
|
63
|
+
|
64
|
+
# Sanitize the name
|
65
|
+
name.gsub!(/[^a-zA-Z0-9_]/, '_')
|
66
|
+
|
67
|
+
# Prepend underscore if it starts with a digit
|
68
|
+
name = "_#{name}" if name.match?(/^[0-9]/)
|
69
|
+
|
70
|
+
name
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
# Defines intermediate variables based on common prefixes in the given paths.
|
76
|
+
# @param paths [Array<String>] a list of paths.
|
77
|
+
def define_intermediate_vars(paths)
|
78
|
+
@intermediate_vars = {}
|
79
|
+
prefixes = {}
|
80
|
+
paths.each do |path|
|
81
|
+
parts = path.split('/')
|
82
|
+
(1...parts.length).each do |i|
|
83
|
+
prefix = parts.take(i).join('/')
|
84
|
+
prefixes[prefix] ||= 0
|
85
|
+
prefixes[prefix] += 1
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Sort by length to define shorter prefixes first
|
90
|
+
sorted_prefixes = prefixes.keys.sort_by(&:length)
|
91
|
+
|
92
|
+
sorted_prefixes.each do |prefix|
|
93
|
+
# An intermediate variable is useful if it is a prefix to at least 2 paths
|
94
|
+
# and is not one of the paths to be defined.
|
95
|
+
# Also, it should not be created if a more specific path from the input list can be used.
|
96
|
+
is_useful = prefixes[prefix] > 1 &&
|
97
|
+
!@defined_vars.value?(prefix) &&
|
98
|
+
paths.none? { |p| File.dirname(p) == prefix } &&
|
99
|
+
@defined_vars.values.compact.none? { |v| prefix.start_with?(v) || v.start_with?(prefix) }
|
100
|
+
is_not_an_input_path = !paths.include?(prefix)
|
101
|
+
next unless is_useful && is_not_an_input_path
|
102
|
+
|
103
|
+
var_name = generate_var_name(prefix)
|
104
|
+
next if var_name.nil?
|
105
|
+
|
106
|
+
best_substitution = find_best_substitution(prefix)
|
107
|
+
value = if best_substitution
|
108
|
+
"$#{best_substitution[:var]}/#{prefix.sub("#{best_substitution[:path]}/", '')}"
|
109
|
+
else
|
110
|
+
prefix
|
111
|
+
end
|
112
|
+
|
113
|
+
unless @defined_vars.key?(var_name)
|
114
|
+
@defined_vars[var_name] = prefix
|
115
|
+
@intermediate_vars[prefix] = "export #{var_name}=#{value}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Finds the best substitution for a given path from the currently defined variables.
|
121
|
+
# @param path [String] the path to find the best substitution for.
|
122
|
+
# @return [Hash] a hash containing the best substitution variable and path, or nil if no substitution is found.
|
123
|
+
def find_best_substitution(path)
|
124
|
+
best_substitution = nil
|
125
|
+
longest_match = 0
|
126
|
+
|
127
|
+
# Find the best existing variable to substitute.
|
128
|
+
@defined_vars.each do |sub_var, sub_path|
|
129
|
+
if path.start_with?("#{sub_path}/") && sub_path.length > longest_match
|
130
|
+
best_substitution = { var: sub_var, path: sub_path }
|
131
|
+
longest_match = sub_path.length
|
132
|
+
end
|
133
|
+
end
|
134
|
+
best_substitution
|
135
|
+
end
|
136
|
+
end
|