• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sds / overcommit / 30066285380

24 Jul 2026 04:14AM UTC coverage: 91.935% (+0.3%) from 91.62%
30066285380

Pull #887

github

web-flow
Merge 91717e3e2 into ac17d9352
Pull Request #887: Fix operation state restoration in linked worktrees

20 of 22 new or added lines in 2 files covered. (90.91%)

3306 of 3596 relevant lines covered (91.94%)

3265.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.74
/lib/overcommit/utils.rb
1
# frozen_string_literal: true
2

3
require 'etc'
16✔
4
require 'pathname'
16✔
5
require 'overcommit/os'
16✔
6
require 'overcommit/subprocess'
16✔
7
require 'overcommit/command_splitter'
16✔
8
require 'tempfile'
16✔
9

10
module Overcommit
16✔
11
  # Utility functions for general use.
12
  module Utils
16✔
13
    # Helper class for doing quick constraint validations on version numbers.
14
    #
15
    # This allows us to execute code based on the git version.
16
    class Version < Gem::Version
16✔
17
      # Overload comparison operators so we can conveniently compare this
18
      # version directly to a string in code.
19
      %w[< <= > >= == !=].each do |operator|
16✔
20
        define_method operator do |version|
96✔
21
          case version
4,000✔
22
          when String
23
            super(Gem::Version.new(version))
4,000✔
24
          else
25
            super(version)
×
26
          end
27
        end
28
      end
29
    end
30

31
    class << self
16✔
32
      # @return [Overcommit::Logger] logger with which to send debug output
33
      attr_accessor :log
16✔
34

35
      def script_path(script)
16✔
36
        File.join(Overcommit::HOME, 'libexec', script)
96✔
37
      end
38

39
      # Returns an absolute path to the root of the repository.
40
      #
41
      # We do this ourselves rather than call `git rev-parse --show-toplevel` to
42
      # solve an issue where the .git directory might not actually be valid in
43
      # tests.
44
      #
45
      # @return [String]
46
      def repo_root
16✔
47
        @repo_root ||=
1,984✔
48
          begin
49
            result = execute(%w[git rev-parse --show-toplevel])
768✔
50
            unless result.success?
768✔
51
              raise Overcommit::Exceptions::InvalidGitRepo,
16✔
52
                    'Unable to determine location of GIT_DIR. ' \
53
                    'Not a recognizable Git repository!'
54
            end
55
            result.stdout.chomp("\n")
752✔
56
          end
57
      end
58

59
      # Returns an absolute path to the .git directory for a repo.
60
      #
61
      # @return [String]
62
      def git_dir
16✔
63
        @git_dir ||=
320✔
64
          begin
65
            cmd = %w[git rev-parse]
320✔
66
            cmd << (GIT_VERSION < '2.5' ? '--git-dir' : '--git-common-dir')
320✔
67
            result = execute(cmd)
320✔
68
            unless result.success?
320✔
69
              raise Overcommit::Exceptions::InvalidGitRepo,
×
70
                    'Unable to determine location of GIT_DIR. ' \
71
                    'Not a recognizable Git repository!'
72
            end
73
            File.expand_path(result.stdout.chomp("\n"), Dir.pwd)
320✔
74
          end
75
      end
76

77
      # Returns an absolute path to a file in the current worktree's Git
78
      # directory.
79
      #
80
      # @param path [String]
81
      # @return [String]
82
      def git_path(path)
16✔
83
        cmd = %w[git rev-parse]
1,760✔
84
        if GIT_VERSION < '2.5'
1,760✔
NEW
85
          cmd << '--git-dir'
×
86
        else
87
          cmd += ['--git-path', path]
1,760✔
88
        end
89

90
        result = execute(cmd)
1,760✔
91
        unless result.success?
1,760✔
NEW
92
          raise Overcommit::Exceptions::InvalidGitRepo,
×
93
                'Unable to determine Git path. ' \
94
                'Not a recognizable Git repository!'
95
        end
96

97
        resolved_path = result.stdout.chomp("\n")
1,760✔
98
        resolved_path = File.join(resolved_path, path) if GIT_VERSION < '2.5'
1,760✔
99
        File.expand_path(resolved_path, Dir.pwd)
1,760✔
100
      end
101

102
      # Remove ANSI escape sequences from a string.
103
      #
104
      # This is useful for stripping colorized output from external tools.
105
      #
106
      # @param text [String]
107
      # @return [String]
108
      def strip_color_codes(text)
16✔
109
        text.gsub(/\e\[(\d+)(;\d+)*m/, '')
80✔
110
      end
111

112
      # Shamelessly stolen from:
113
      # stackoverflow.com/questions/1509915/converting-camel-case-to-underscore-case-in-ruby
114
      def snake_case(str)
16✔
115
        str.gsub(/::/, '/').
9,200✔
116
            gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').
117
            gsub(/([a-z\d])([A-Z])/, '\1_\2').
118
            tr('-', '_').
119
            downcase
120
      end
121

122
      # Converts a string containing underscores/hyphens/spaces into CamelCase.
123
      def camel_case(str)
16✔
124
        str.split(/_|-| /).map { |part| part.sub(/^\w/, &:upcase) }.join
9,584✔
125
      end
126

127
      # Returns a list of supported hook types (pre-commit, commit-msg, etc.)
128
      def supported_hook_types
16✔
129
        Dir[File.join(HOOK_DIRECTORY, '*')].
19,552✔
130
          select { |file| File.directory?(file) }.
215,072✔
131
          reject { |file| File.basename(file) == 'shared' }.
195,520✔
132
          map { |file| File.basename(file).tr('_', '-') }
175,968✔
133
      end
134

135
      # Returns a list of supported hook classes (PreCommit, CommitMsg, etc.)
136
      def supported_hook_type_classes
16✔
137
        supported_hook_types.map do |file|
18,624✔
138
          file.split('-').map(&:capitalize).join
167,616✔
139
        end
140
      end
141

142
      # @param cmd [String]
143
      # @return [true,false] whether a command can be found given the current
144
      #   environment path.
145
      def in_path?(cmd)
16✔
146
        # ENV['PATH'] doesn't include the repo root, but that is a valid
147
        # location for executables, so we want to add it to the list of places
148
        # we are checking for the executable.
149
        paths = [repo_root] + ENV['PATH'].split(File::PATH_SEPARATOR)
16✔
150
        exts  = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
16✔
151
        paths.each do |path|
16✔
152
          exts.each do |ext|
224✔
153
            cmd_with_ext = cmd.upcase.end_with?(ext.upcase) ? cmd : "#{cmd}#{ext}"
224✔
154
            full_path = File.join(path, cmd_with_ext)
224✔
155
            return true if File.executable?(full_path)
224✔
156
          end
157
        end
158
        false
×
159
      end
160

161
      # Return the parent command that triggered this hook run
162
      #
163
      # @return [String,nil] the command as a string, if a parent exists.
164
      def parent_command
16✔
165
        # When run in Docker containers, there may be no parent process.
166
        return if Process.ppid.zero?
832✔
167

168
        if OS.windows?
816✔
169
          `wmic process where ProcessId=#{Process.ppid} get CommandLine /FORMAT:VALUE`.
×
170
            strip.
171
            slice(/(?<=CommandLine=).+/)
172
        elsif OS.cygwin?
816✔
173
          # Cygwin's `ps` command behaves differently than the traditional
174
          # Linux version, but a comparable `procps` is provided to compensate.
175
          `procps -ocommand= -p #{Process.ppid}`.chomp
×
176
        else
177
          `ps -ocommand= -p #{Process.ppid}`.chomp
816✔
178
        end
179
      rescue Errno::EPERM, Errno::ENOENT
180
        # Process information may not be available, such as inside sandboxed environments
181
        nil
×
182
      end
183

184
      # Execute a command in a subprocess, capturing exit status and output from
185
      # both standard and error streams.
186
      #
187
      # This is intended to provide a centralized place to perform any checks or
188
      # filtering of the command before executing it.
189
      #
190
      # The `args` option provides a convenient way of splitting up long
191
      # argument lists which would otherwise exceed the maximum command line
192
      # length of the OS. It will break up the list into chunks and run the
193
      # command with the same prefix `initial_args`, finally combining the
194
      # output together at the end.
195
      #
196
      # This requires that the external command you are running can have its
197
      # work split up in this way and still produce the same resultant output
198
      # when outputs of the individual commands are concatenated back together.
199
      #
200
      # @param initial_args [Array<String>]
201
      # @param options [Hash]
202
      # @option options [Array<String>] :args long list of arguments to split up
203
      # @return [Overcommit::Subprocess::Result] status, stdout, and stderr
204
      def execute(initial_args, options = {})
16✔
205
        if initial_args.include?('|')
15,072✔
206
          raise Overcommit::Exceptions::InvalidCommandArgs,
16✔
207
                'Cannot pipe commands with the `execute` helper'
208
        end
209

210
        result =
211
          if (splittable_args = options.fetch(:args) { [] }).any?
29,488✔
212
            debug(initial_args.join(' ') + " ... (#{splittable_args.length} splittable args)")
608✔
213
            Overcommit::CommandSplitter.execute(initial_args, options)
608✔
214
          else
215
            debug(initial_args.join(' '))
14,448✔
216
            Overcommit::Subprocess.spawn(initial_args, options)
14,448✔
217
          end
218

219
        debug("EXIT STATUS: #{result.status}")
15,056✔
220
        debug("STDOUT: #{result.stdout.inspect}")
15,056✔
221
        debug("STDERR: #{result.stderr.inspect}")
15,056✔
222

223
        result
15,056✔
224
      end
225

226
      # Execute a command in a subprocess, returning immediately.
227
      #
228
      # This provides a convenient way to execute long-running processes for
229
      # which we do not need to know the result.
230
      #
231
      # @param args [Array<String>]
232
      # @return [ChildProcess] detached process spawned in the background
233
      def execute_in_background(args)
16✔
234
        if args.include?('|')
16✔
235
          raise Overcommit::Exceptions::InvalidCommandArgs,
×
236
                'Cannot pipe commands with the `execute_in_background` helper'
237
        end
238

239
        debug("Spawning background task: #{args.join(' ')}")
16✔
240
        Subprocess.spawn_detached(args)
16✔
241
      end
242

243
      # Return the number of processors used by the OS for process scheduling.
244
      def processor_count
16✔
245
        @processor_count ||= Etc.nprocessors
4,480✔
246
      end
247

248
      # Calls a block of code with a modified set of environment variables,
249
      # restoring them once the code has executed.
250
      def with_environment(env)
16✔
251
        old_env = {}
27,648✔
252
        env.each do |var, value|
27,648✔
253
          old_env[var] = ENV[var.to_s]
27,344✔
254
          ENV[var.to_s] = value
27,344✔
255
        end
256

257
        yield
27,648✔
258
      ensure
259
        old_env.each { |var, value| ENV[var.to_s] = value }
54,992✔
260
      end
261

262
      # Returns whether a file is a broken symlink.
263
      #
264
      # @return [true,false]
265
      def broken_symlink?(file)
16✔
266
        # JRuby's implementation of File.exist? returns true for broken
267
        # symlinks, so we need use File.size?
268
        Overcommit::Utils::FileUtils.symlink?(file) && File.size?(file).nil?
9,952✔
269
      end
270

271
      # Convert a glob pattern to an absolute path glob pattern rooted from the
272
      # repository root directory.
273
      #
274
      # @param glob [String]
275
      # @return [String]
276
      def convert_glob_to_absolute(glob)
16✔
277
        File.join(repo_root, glob)
80✔
278
      end
279

280
      # Return whether a pattern matches the given path.
281
      #
282
      # @param pattern [String]
283
      # @param path [String]
284
      def matches_path?(pattern, path)
16✔
285
        File.fnmatch?(
32✔
286
          pattern, path,
287
          File::FNM_PATHNAME | # Wildcard doesn't match separator
288
          File::FNM_DOTMATCH   # Wildcards match dotfiles
289
        )
290
      end
291

292
      private
16✔
293

294
      # Log debug output.
295
      #
296
      # This is necessary since some specs indirectly call utility functions but
297
      # don't explicitly set the logger for the Utils class, so we do a quick
298
      # check here to see if it's set before we attempt to log.
299
      #
300
      # @param args [Array<String>]
301
      def debug(*args)
16✔
302
        log&.debug(*args)
60,240✔
303
      end
304
    end
305
  end
306
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc