git_tree 0.3.0 → 1.0.1

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.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module GitUrlsVersion
2
- VERSION = '0.3.0'.freeze
2
+ VERSION = '1.0.1'.freeze
3
3
  end
data/lib/git_tree.rb CHANGED
@@ -1,31 +1,14 @@
1
- require 'find'
2
- require 'rainbow/refinement'
3
- require 'rugged'
4
- require_relative 'util'
1
+ require_relative 'git_tree/version'
5
2
 
6
3
  module GitTree
7
- using Rainbow
8
-
9
- # @return array containing directory names to process
10
- # Each directory name ends with a slash, to ensure symlinks are dereferences
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
4
+ # Helper to require all .rb files in a subdirectory
5
+ def self.require_all(relative_path)
6
+ Dir[File.join(__dir__, relative_path, '*.rb')].sort.each { |file| require file }
7
+ end
21
8
 
22
- Find.prune if File.exist?("#{path}/.ignore")
9
+ # Require utilities first, as commands may depend on them.
10
+ require_all 'util'
11
+ require_all 'commands'
23
12
 
24
- if Dir.exist?("#{path}/.git")
25
- result << path.to_s
26
- Find.prune
27
- end
28
- end
29
- result.map { |x| x.delete_prefix("#{root_fq}/") }
30
- end
13
+ include Logging
31
14
  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
- module MslinnUtil
2
- # @param paths [Array[String]] all start with a leading '/' (they are assumed to be absolute paths).
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[String]] absolute paths to examine
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[String]] absolute paths to examine
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