git 1.19.1 → 3.1.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.commitlintrc.yml +38 -0
  3. data/.github/pull_request_template.md +8 -0
  4. data/.github/workflows/continuous_integration.yml +19 -21
  5. data/.github/workflows/enforce_conventional_commits.yml +28 -0
  6. data/.github/workflows/experimental_continuous_integration.yml +50 -0
  7. data/.github/workflows/release.yml +52 -0
  8. data/.gitignore +3 -0
  9. data/.husky/commit-msg +1 -0
  10. data/.release-please-manifest.json +3 -0
  11. data/.yardopts +0 -1
  12. data/CHANGELOG.md +211 -0
  13. data/CONTRIBUTING.md +290 -102
  14. data/README.md +180 -62
  15. data/Rakefile +7 -0
  16. data/git.gemspec +11 -11
  17. data/lib/git/author.rb +3 -2
  18. data/lib/git/base.rb +222 -59
  19. data/lib/git/branch.rb +2 -0
  20. data/lib/git/branches.rb +15 -14
  21. data/lib/git/command_line.rb +287 -0
  22. data/lib/git/config.rb +7 -1
  23. data/lib/git/diff.rb +2 -0
  24. data/lib/git/errors.rb +206 -0
  25. data/lib/git/escaped_path.rb +1 -1
  26. data/lib/git/index.rb +2 -1
  27. data/lib/git/lib.rb +604 -234
  28. data/lib/git/log.rb +74 -6
  29. data/lib/git/object.rb +80 -76
  30. data/lib/git/path.rb +9 -8
  31. data/lib/git/remote.rb +2 -0
  32. data/lib/git/repository.rb +2 -0
  33. data/lib/git/stash.rb +7 -6
  34. data/lib/git/stashes.rb +11 -10
  35. data/lib/git/status.rb +135 -25
  36. data/lib/git/version.rb +3 -1
  37. data/lib/git/working_directory.rb +2 -0
  38. data/lib/git/worktree.rb +2 -0
  39. data/lib/git/worktrees.rb +2 -0
  40. data/lib/git.rb +21 -7
  41. data/package.json +10 -0
  42. data/release-please-config.json +36 -0
  43. metadata +52 -38
  44. data/.github/stale.yml +0 -25
  45. data/Dockerfile.changelog-rs +0 -12
  46. data/PULL_REQUEST_TEMPLATE.md +0 -9
  47. data/RELEASING.md +0 -70
  48. data/lib/git/base/factory.rb +0 -99
  49. data/lib/git/failed_error.rb +0 -53
  50. data/lib/git/git_execute_error.rb +0 -7
  51. data/lib/git/signaled_error.rb +0 -50
  52. /data/{ISSUE_TEMPLATE.md → .github/issue_template.md} +0 -0
data/lib/git/lib.rb CHANGED
@@ -1,14 +1,17 @@
1
- require 'git/failed_error'
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/command_line'
4
+ require 'git/errors'
2
5
  require 'logger'
6
+ require 'pp'
7
+ require 'process_executer'
8
+ require 'stringio'
3
9
  require 'tempfile'
4
10
  require 'zlib'
5
11
  require 'open3'
6
12
 
7
13
  module Git
8
14
  class Lib
9
-
10
- @@semaphore = Mutex.new
11
-
12
15
  # The path to the Git working copy. The default is '"./.git"'.
13
16
  #
14
17
  # @return [Pathname] the path to the Git working copy.
@@ -37,14 +40,23 @@ module Git
37
40
 
38
41
  # Create a new Git::Lib object
39
42
  #
40
- # @param [Git::Base, Hash] base An object that passes in values for
41
- # @git_work_dir, @git_dir, and @git_index_file
43
+ # @overload initialize(base, logger)
44
+ #
45
+ # @param base [Hash] the hash containing paths to the Git working copy,
46
+ # the Git repository directory, and the Git index file.
42
47
  #
43
- # @param [Logger] logger
48
+ # @option base [Pathname] :working_directory
49
+ # @option base [Pathname] :repository
50
+ # @option base [Pathname] :index
44
51
  #
45
- # @option base [Pathname] :working_directory
46
- # @option base [Pathname] :repository
47
- # @option base [Pathname] :index
52
+ # @param [Logger] logger
53
+ #
54
+ # @overload initialize(base, logger)
55
+ #
56
+ # @param base [#dir, #repo, #index] an object with methods to get the Git worktree (#dir),
57
+ # the Git repository directory (#repo), and the Git index file (#index).
58
+ #
59
+ # @param [Logger] logger
48
60
  #
49
61
  def initialize(base = nil, logger = nil)
50
62
  @git_dir = nil
@@ -79,22 +91,34 @@ module Git
79
91
  command('init', *arr_opts)
80
92
  end
81
93
 
82
- # tries to clone the given repo
94
+ # Clones a repository into a newly created directory
83
95
  #
84
- # accepts options:
85
- # :bare:: no working directory
86
- # :branch:: name of branch to track (rather than 'master')
87
- # :depth:: the number of commits back to pull
88
- # :filter:: specify partial clone
89
- # :origin:: name of remote (same as remote)
90
- # :path:: directory where the repo will be cloned
91
- # :remote:: name of remote (rather than 'origin')
92
- # :recursive:: after the clone is created, initialize all submodules within, using their default settings.
96
+ # @param [String] repository_url the URL of the repository to clone
97
+ # @param [String, nil] directory the directory to clone into
93
98
  #
94
- # TODO - make this work with SSH password or auth_key
99
+ # If nil, the repository is cloned into a directory with the same name as
100
+ # the repository.
101
+ #
102
+ # @param [Hash] opts the options for this command
103
+ #
104
+ # @option opts [Boolean] :bare (false) if true, clone as a bare repository
105
+ # @option opts [String] :branch the branch to checkout
106
+ # @option opts [String, Array] :config one or more configuration options to set
107
+ # @option opts [Integer] :depth the number of commits back to pull
108
+ # @option opts [String] :filter specify partial clone
109
+ # @option opts [String] :mirror set up a mirror of the source repository
110
+ # @option opts [String] :origin the name of the remote
111
+ # @option opts [String] :path an optional prefix for the directory parameter
112
+ # @option opts [String] :remote the name of the remote
113
+ # @option opts [Boolean] :recursive after the clone is created, initialize all submodules within, using their default settings
114
+ # @option opts [Numeric, nil] :timeout the number of seconds to wait for the command to complete
115
+ #
116
+ # See {Git::Lib#command} for more information about :timeout
95
117
  #
96
118
  # @return [Hash] the options to pass to {Git::Base.new}
97
119
  #
120
+ # @todo make this work with SSH password or auth_key
121
+ #
98
122
  def clone(repository_url, directory, opts = {})
99
123
  @path = opts[:path] || '.'
100
124
  clone_dir = opts[:path] ? File.join(@path, directory) : directory
@@ -102,7 +126,7 @@ module Git
102
126
  arr_opts = []
103
127
  arr_opts << '--bare' if opts[:bare]
104
128
  arr_opts << '--branch' << opts[:branch] if opts[:branch]
105
- arr_opts << '--depth' << opts[:depth].to_i if opts[:depth] && opts[:depth].to_i > 0
129
+ arr_opts << '--depth' << opts[:depth].to_i if opts[:depth]
106
130
  arr_opts << '--filter' << opts[:filter] if opts[:filter]
107
131
  Array(opts[:config]).each { |c| arr_opts << '--config' << c }
108
132
  arr_opts << '--origin' << opts[:remote] || opts[:origin] if opts[:remote] || opts[:origin]
@@ -114,7 +138,7 @@ module Git
114
138
  arr_opts << repository_url
115
139
  arr_opts << clone_dir
116
140
 
117
- command('clone', *arr_opts)
141
+ command('clone', *arr_opts, timeout: opts[:timeout])
118
142
 
119
143
  return_base_opts_from_clone(clone_dir, opts)
120
144
  end
@@ -142,32 +166,38 @@ module Git
142
166
  match_data = output.match(%r{^ref: refs/heads/(?<default_branch>[^\t]+)\tHEAD$})
143
167
  return match_data[:default_branch] if match_data
144
168
 
145
- raise 'Unable to determine the default branch'
169
+ raise Git::UnexpectedResultError, 'Unable to determine the default branch'
146
170
  end
147
171
 
148
172
  ## READ COMMANDS ##
149
173
 
174
+ # Finds most recent tag that is reachable from a commit
150
175
  #
151
- # Returns most recent tag that is reachable from a commit
176
+ # @see https://git-scm.com/docs/git-describe git-describe
152
177
  #
153
- # accepts options:
154
- # :all
155
- # :tags
156
- # :contains
157
- # :debug
158
- # :exact_match
159
- # :dirty
160
- # :abbrev
161
- # :candidates
162
- # :long
163
- # :always
164
- # :math
165
- #
166
- # @param [String|NilClass] committish target commit sha or object name
167
- # @param [{Symbol=>Object}] opts the given options
168
- # @return [String] the tag name
169
- #
170
- def describe(committish=nil, opts={})
178
+ # @param commit_ish [String, nil] target commit sha or object name
179
+ #
180
+ # @param opts [Hash] the given options
181
+ #
182
+ # @option opts :all [Boolean]
183
+ # @option opts :tags [Boolean]
184
+ # @option opts :contains [Boolean]
185
+ # @option opts :debug [Boolean]
186
+ # @option opts :long [Boolean]
187
+ # @option opts :always [Boolean]
188
+ # @option opts :exact_match [Boolean]
189
+ # @option opts :dirty [true, String]
190
+ # @option opts :abbrev [String]
191
+ # @option opts :candidates [String]
192
+ # @option opts :match [String]
193
+ #
194
+ # @return [String] the tag name
195
+ #
196
+ # @raise [ArgumentError] if the commit_ish is a string starting with a hyphen
197
+ #
198
+ def describe(commit_ish = nil, opts = {})
199
+ assert_args_are_not_options('commit-ish object', commit_ish)
200
+
171
201
  arr_opts = []
172
202
 
173
203
  arr_opts << '--all' if opts[:all]
@@ -185,12 +215,42 @@ module Git
185
215
  arr_opts << "--candidates=#{opts[:candidates]}" if opts[:candidates]
186
216
  arr_opts << "--match=#{opts[:match]}" if opts[:match]
187
217
 
188
- arr_opts << committish if committish
218
+ arr_opts << commit_ish if commit_ish
189
219
 
190
220
  return command('describe', *arr_opts)
191
221
  end
192
222
 
193
- def log_commits(opts={})
223
+ # Return the commits that are within the given revision range
224
+ #
225
+ # @see https://git-scm.com/docs/git-log git-log
226
+ #
227
+ # @param opts [Hash] the given options
228
+ #
229
+ # @option opts :count [Integer] the maximum number of commits to return (maps to max-count)
230
+ # @option opts :all [Boolean]
231
+ # @option opts :cherry [Boolean]
232
+ # @option opts :since [String]
233
+ # @option opts :until [String]
234
+ # @option opts :grep [String]
235
+ # @option opts :author [String]
236
+ # @option opts :between [Array<String>] an array of two commit-ish strings to specify a revision range
237
+ #
238
+ # Only :between or :object options can be used, not both.
239
+ #
240
+ # @option opts :object [String] the revision range for the git log command
241
+ #
242
+ # Only :between or :object options can be used, not both.
243
+ #
244
+ # @option opts :path_limiter [Array<String>, String] only include commits that impact files from the specified paths
245
+ #
246
+ # @return [Array<String>] the log output
247
+ #
248
+ # @raise [ArgumentError] if the resulting revision range is a string starting with a hyphen
249
+ #
250
+ def log_commits(opts = {})
251
+ assert_args_are_not_options('between', opts[:between]&.first)
252
+ assert_args_are_not_options('object', opts[:object])
253
+
194
254
  arr_opts = log_common_options(opts)
195
255
 
196
256
  arr_opts << '--pretty=oneline'
@@ -200,11 +260,53 @@ module Git
200
260
  command_lines('log', *arr_opts).map { |l| l.split.first }
201
261
  end
202
262
 
203
- def full_log_commits(opts={})
263
+ # Return the commits that are within the given revision range
264
+ #
265
+ # @see https://git-scm.com/docs/git-log git-log
266
+ #
267
+ # @param opts [Hash] the given options
268
+ #
269
+ # @option opts :count [Integer] the maximum number of commits to return (maps to max-count)
270
+ # @option opts :all [Boolean]
271
+ # @option opts :cherry [Boolean]
272
+ # @option opts :since [String]
273
+ # @option opts :until [String]
274
+ # @option opts :grep [String]
275
+ # @option opts :author [String]
276
+ # @option opts :between [Array<String>] an array of two commit-ish strings to specify a revision range
277
+ #
278
+ # Only :between or :object options can be used, not both.
279
+ #
280
+ # @option opts :object [String] the revision range for the git log command
281
+ #
282
+ # Only :between or :object options can be used, not both.
283
+ #
284
+ # @option opts :path_limiter [Array<String>, String] only include commits that impact files from the specified paths
285
+ # @option opts :skip [Integer]
286
+ #
287
+ # @return [Array<Hash>] the log output parsed into an array of hashs for each commit
288
+ #
289
+ # Each hash contains the following keys:
290
+ # * 'sha' [String] the commit sha
291
+ # * 'author' [String] the author of the commit
292
+ # * 'message' [String] the commit message
293
+ # * 'parent' [Array<String>] the commit shas of the parent commits
294
+ # * 'tree' [String] the tree sha
295
+ # * 'author' [String] the author of the commit and timestamp of when the changes were created
296
+ # * 'committer' [String] the committer of the commit and timestamp of when the commit was applied
297
+ # * 'merges' [Boolean] if truthy, only include merge commits (aka commits with 2 or more parents)
298
+ #
299
+ # @raise [ArgumentError] if the revision range (specified with :between or :object) is a string starting with a hyphen
300
+ #
301
+ def full_log_commits(opts = {})
302
+ assert_args_are_not_options('between', opts[:between]&.first)
303
+ assert_args_are_not_options('object', opts[:object])
304
+
204
305
  arr_opts = log_common_options(opts)
205
306
 
206
307
  arr_opts << '--pretty=raw'
207
308
  arr_opts << "--skip=#{opts[:skip]}" if opts[:skip]
309
+ arr_opts << '--merges' if opts[:merges]
208
310
 
209
311
  arr_opts += log_path_options(opts)
210
312
 
@@ -213,36 +315,147 @@ module Git
213
315
  process_commit_log_data(full_log)
214
316
  end
215
317
 
216
- def revparse(string)
217
- return string if string =~ /^[A-Fa-f0-9]{40}$/ # passing in a sha - just no-op it
218
- rev = ['head', 'remotes', 'tags'].map do |d|
219
- File.join(@git_dir, 'refs', d, string)
220
- end.find do |path|
221
- File.file?(path)
222
- end
223
- return File.read(rev).chomp if rev
224
- command('rev-parse', string)
318
+ # Verify and resolve a Git revision to its full SHA
319
+ #
320
+ # @see https://git-scm.com/docs/git-rev-parse git-rev-parse
321
+ # @see https://git-scm.com/docs/git-rev-parse#_specifying_revisions Valid ways to specify revisions
322
+ # @see https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt-emltrefnamegtemegemmasterememheadsmasterememrefsheadsmasterem Ref disambiguation rules
323
+ #
324
+ # @example
325
+ # lib.rev_parse('HEAD') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d'
326
+ # lib.rev_parse('9b9b31e') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d'
327
+ #
328
+ # @param revision [String] the revision to resolve
329
+ #
330
+ # @return [String] the full commit hash
331
+ #
332
+ # @raise [Git::FailedError] if the revision cannot be resolved
333
+ # @raise [ArgumentError] if the revision is a string starting with a hyphen
334
+ #
335
+ def rev_parse(revision)
336
+ assert_args_are_not_options('rev', revision)
337
+
338
+ command('rev-parse', '--revs-only', '--end-of-options', revision, '--')
339
+ end
340
+
341
+ # For backwards compatibility with the old method name
342
+ alias :revparse :rev_parse
343
+
344
+ # Find the first symbolic name for given commit_ish
345
+ #
346
+ # @param commit_ish [String] the commit_ish to find the symbolic name of
347
+ #
348
+ # @return [String, nil] the first symbolic name or nil if the commit_ish isn't found
349
+ #
350
+ # @raise [ArgumentError] if the commit_ish is a string starting with a hyphen
351
+ #
352
+ def name_rev(commit_ish)
353
+ assert_args_are_not_options('commit_ish', commit_ish)
354
+
355
+ command('name-rev', commit_ish).split[1]
225
356
  end
226
357
 
227
- def namerev(string)
228
- command('name-rev', string).split[1]
358
+ alias :namerev :name_rev
359
+
360
+ # Output the contents or other properties of one or more objects.
361
+ #
362
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
363
+ #
364
+ # @example Get the contents of a file without a block
365
+ # lib.cat_file_contents('README.md') # => "This is a README file\n"
366
+ #
367
+ # @example Get the contents of a file with a block
368
+ # lib.cat_file_contents('README.md') { |f| f.read } # => "This is a README file\n"
369
+ #
370
+ # @param object [String] the object whose contents to return
371
+ #
372
+ # @return [String] the object contents
373
+ #
374
+ # @raise [ArgumentError] if object is a string starting with a hyphen
375
+ #
376
+ def cat_file_contents(object, &block)
377
+ assert_args_are_not_options('object', object)
378
+
379
+ if block_given?
380
+ Tempfile.create do |file|
381
+ # If a block is given, write the output from the process to a temporary
382
+ # file and then yield the file to the block
383
+ #
384
+ command('cat-file', "-p", object, out: file, err: file)
385
+ file.rewind
386
+ yield file
387
+ end
388
+ else
389
+ # If a block is not given, return the file contents as a string
390
+ command('cat-file', '-p', object)
391
+ end
229
392
  end
230
393
 
231
- def object_type(sha)
232
- command('cat-file', '-t', sha)
394
+ alias :object_contents :cat_file_contents
395
+
396
+ # Get the type for the given object
397
+ #
398
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
399
+ #
400
+ # @param object [String] the object to get the type
401
+ #
402
+ # @return [String] the object type
403
+ #
404
+ # @raise [ArgumentError] if object is a string starting with a hyphen
405
+ #
406
+ def cat_file_type(object)
407
+ assert_args_are_not_options('object', object)
408
+
409
+ command('cat-file', '-t', object)
233
410
  end
234
411
 
235
- def object_size(sha)
236
- command('cat-file', '-s', sha).to_i
412
+ alias :object_type :cat_file_type
413
+
414
+ # Get the size for the given object
415
+ #
416
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
417
+ #
418
+ # @param object [String] the object to get the type
419
+ #
420
+ # @return [String] the object type
421
+ #
422
+ # @raise [ArgumentError] if object is a string starting with a hyphen
423
+ #
424
+ def cat_file_size(object)
425
+ assert_args_are_not_options('object', object)
426
+
427
+ command('cat-file', '-s', object).to_i
237
428
  end
238
429
 
239
- # returns useful array of raw commit object data
240
- def commit_data(sha)
241
- sha = sha.to_s
242
- cdata = command_lines('cat-file', 'commit', sha)
243
- process_commit_data(cdata, sha)
430
+ alias :object_size :cat_file_size
431
+
432
+ # Return a hash of commit data
433
+ #
434
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
435
+ #
436
+ # @param object [String] the object to get the type
437
+ #
438
+ # @return [Hash] commit data
439
+ #
440
+ # The returned commit data has the following keys:
441
+ # * tree [String]
442
+ # * parent [Array<String>]
443
+ # * author [String] the author name, email, and commit timestamp
444
+ # * committer [String] the committer name, email, and merge timestamp
445
+ # * message [String] the commit message
446
+ # * gpgsig [String] the public signing key of the commit (if signed)
447
+ #
448
+ # @raise [ArgumentError] if object is a string starting with a hyphen
449
+ #
450
+ def cat_file_commit(object)
451
+ assert_args_are_not_options('object', object)
452
+
453
+ cdata = command_lines('cat-file', 'commit', object)
454
+ process_commit_data(cdata, object)
244
455
  end
245
456
 
457
+ alias :commit_data :cat_file_commit
458
+
246
459
  def process_commit_data(data, sha)
247
460
  hsh = {
248
461
  'sha' => sha,
@@ -277,12 +490,50 @@ module Git
277
490
  end
278
491
  end
279
492
 
280
- def tag_data(name)
281
- sha = sha.to_s
282
- tdata = command_lines('cat-file', 'tag', name)
283
- process_tag_data(tdata, name)
493
+ # Return a hash of annotated tag data
494
+ #
495
+ # Does not work with lightweight tags. List all annotated tags in your repository with the following command:
496
+ #
497
+ # ```sh
498
+ # git for-each-ref --format='%(refname:strip=2)' refs/tags | while read tag; do git cat-file tag $tag >/dev/null 2>&1 && echo $tag; done
499
+ # ```
500
+ #
501
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
502
+ #
503
+ # @param object [String] the tag to retrieve
504
+ #
505
+ # @return [Hash] tag data
506
+ #
507
+ # Example tag data returned:
508
+ # ```ruby
509
+ # {
510
+ # "name" => "annotated_tag",
511
+ # "object" => "46abbf07e3c564c723c7c039a43ab3a39e5d02dd",
512
+ # "type" => "commit",
513
+ # "tag" => "annotated_tag",
514
+ # "tagger" => "Scott Chacon <schacon@gmail.com> 1724799270 -0700",
515
+ # "message" => "Creating an annotated tag\n"
516
+ # }
517
+ # ```
518
+ #
519
+ # The returned commit data has the following keys:
520
+ # * object [String] the sha of the tag object
521
+ # * type [String]
522
+ # * tag [String] tag name
523
+ # * tagger [String] the name and email of the user who created the tag and the timestamp of when the tag was created
524
+ # * message [String] the tag message
525
+ #
526
+ # @raise [ArgumentError] if object is a string starting with a hyphen
527
+ #
528
+ def cat_file_tag(object)
529
+ assert_args_are_not_options('object', object)
530
+
531
+ tdata = command_lines('cat-file', 'tag', object)
532
+ process_tag_data(tdata, object)
284
533
  end
285
534
 
535
+ alias :tag_data :cat_file_tag
536
+
286
537
  def process_tag_data(data, name)
287
538
  hsh = { 'name' => name }
288
539
 
@@ -323,7 +574,7 @@ module Git
323
574
  case key
324
575
  when 'commit'
325
576
  hsh_array << hsh if hsh
326
- hsh = {'sha' => value, 'message' => '', 'parent' => []}
577
+ hsh = {'sha' => value, 'message' => +'', 'parent' => []}
327
578
  when 'parent'
328
579
  hsh['parent'] << value
329
580
  else
@@ -336,14 +587,15 @@ module Git
336
587
  return hsh_array
337
588
  end
338
589
 
339
- def object_contents(sha, &block)
340
- command('cat-file', '-p', sha, &block)
341
- end
342
-
343
- def ls_tree(sha)
590
+ def ls_tree(sha, opts = {})
344
591
  data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
345
592
 
346
- command_lines('ls-tree', sha).each do |line|
593
+ ls_tree_opts = []
594
+ ls_tree_opts << '-r' if opts[:recursive]
595
+ # path must be last arg
596
+ ls_tree_opts << opts[:path] if opts[:path]
597
+
598
+ command_lines('ls-tree', sha, *ls_tree_opts).each do |line|
347
599
  (info, filenm) = line.split("\t")
348
600
  (mode, type, sha) = info.split
349
601
  data[type][filenm] = {:mode => mode, :sha => sha}
@@ -393,10 +645,13 @@ module Git
393
645
  /x
394
646
 
395
647
  def branches_all
396
- command_lines('branch', '-a').map do |line|
648
+ lines = command_lines('branch', '-a')
649
+ lines.each_with_index.map do |line, line_index|
397
650
  match_data = line.match(BRANCH_LINE_REGEXP)
398
- raise GitExecuteError, 'Unexpected branch line format' unless match_data
651
+
652
+ raise Git::UnexpectedResultError, unexpected_branch_line_error(lines, line, line_index) unless match_data
399
653
  next nil if match_data[:not_a_branch] || match_data[:detached_ref]
654
+
400
655
  [
401
656
  match_data[:refname],
402
657
  !match_data[:current].nil?,
@@ -406,6 +661,18 @@ module Git
406
661
  end.compact
407
662
  end
408
663
 
664
+ def unexpected_branch_line_error(lines, line, index)
665
+ <<~ERROR
666
+ Unexpected line in output from `git branch -a`, line #{index + 1}
667
+
668
+ Full output:
669
+ #{lines.join("\n ")}
670
+
671
+ Line #{index + 1}:
672
+ "#{line}"
673
+ ERROR
674
+ end
675
+
409
676
  def worktrees_all
410
677
  arr = []
411
678
  directory = ''
@@ -449,8 +716,54 @@ module Git
449
716
  files
450
717
  end
451
718
 
719
+ # The state and name of branch pointed to by `HEAD`
720
+ #
721
+ # HEAD can be in the following states:
722
+ #
723
+ # **:active**: `HEAD` points to a branch reference which in turn points to a
724
+ # commit representing the tip of that branch. This is the typical state when
725
+ # working on a branch.
726
+ #
727
+ # **:unborn**: `HEAD` points to a branch reference that does not yet exist
728
+ # because no commits have been made on that branch. This state occurs in two
729
+ # scenarios:
730
+ #
731
+ # * When a repository is newly initialized, and no commits have been made on the
732
+ # initial branch.
733
+ # * When a new branch is created using `git checkout --orphan <branch>`, starting
734
+ # a new branch with no history.
735
+ #
736
+ # **:detached**: `HEAD` points directly to a specific commit (identified by its
737
+ # SHA) rather than a branch reference. This state occurs when you check out a
738
+ # commit, a tag, or any state that is not directly associated with a branch. The
739
+ # branch name in this case is `HEAD`.
740
+ #
741
+ HeadState = Struct.new(:state, :name)
742
+
743
+ # The current branch state which is the state of `HEAD`
744
+ #
745
+ # @return [HeadState] the state and name of the current branch
746
+ #
747
+ def current_branch_state
748
+ branch_name = command('branch', '--show-current')
749
+ return HeadState.new(:detached, 'HEAD') if branch_name.empty?
750
+
751
+ state =
752
+ begin
753
+ command('rev-parse', '--verify', '--quiet', branch_name)
754
+ :active
755
+ rescue Git::FailedError => e
756
+ raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
757
+
758
+ :unborn
759
+ end
760
+
761
+ return HeadState.new(state, branch_name)
762
+ end
763
+
452
764
  def branch_current
453
- branches_all.select { |b| b[1] }.first[0] rescue nil
765
+ branch_name = command('branch', '--show-current')
766
+ branch_name.empty? ? 'HEAD' : branch_name
454
767
  end
455
768
 
456
769
  def branch_contains(commit, branch_name="")
@@ -474,16 +787,37 @@ module Git
474
787
  grep_opts.push('--', *opts[:path_limiter]) if opts[:path_limiter].is_a?(Array)
475
788
 
476
789
  hsh = {}
477
- command_lines('grep', *grep_opts).each do |line|
478
- if m = /(.*?)\:(\d+)\:(.*)/.match(line)
479
- hsh[m[1]] ||= []
480
- hsh[m[1]] << [m[2].to_i, m[3]]
790
+ begin
791
+ command_lines('grep', *grep_opts).each do |line|
792
+ if m = /(.*?)\:(\d+)\:(.*)/.match(line)
793
+ hsh[m[1]] ||= []
794
+ hsh[m[1]] << [m[2].to_i, m[3]]
795
+ end
481
796
  end
797
+ rescue Git::FailedError => e
798
+ raise unless e.result.status.exitstatus == 1 && e.result.stderr == ''
482
799
  end
483
800
  hsh
484
801
  end
485
802
 
803
+ # Validate that the given arguments cannot be mistaken for a command-line option
804
+ #
805
+ # @param arg_name [String] the name of the arguments to mention in the error message
806
+ # @param args [Array<String, nil>] the arguments to validate
807
+ #
808
+ # @raise [ArgumentError] if any of the parameters are a string starting with a hyphen
809
+ # @return [void]
810
+ #
811
+ def assert_args_are_not_options(arg_name, *args)
812
+ invalid_args = args.select { |arg| arg&.start_with?('-') }
813
+ if invalid_args.any?
814
+ raise ArgumentError, "Invalid #{arg_name}: '#{invalid_args.join("', '")}'"
815
+ end
816
+ end
817
+
486
818
  def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {})
819
+ assert_args_are_not_options('commit or commit range', obj1, obj2)
820
+
487
821
  diff_opts = ['-p']
488
822
  diff_opts << obj1
489
823
  diff_opts << obj2 if obj2.is_a?(String)
@@ -493,6 +827,8 @@ module Git
493
827
  end
494
828
 
495
829
  def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {})
830
+ assert_args_are_not_options('commit or commit range', obj1, obj2)
831
+
496
832
  diff_opts = ['--numstat']
497
833
  diff_opts << obj1
498
834
  diff_opts << obj2 if obj2.is_a?(String)
@@ -513,6 +849,8 @@ module Git
513
849
  end
514
850
 
515
851
  def diff_name_status(reference1 = nil, reference2 = nil, opts = {})
852
+ assert_args_are_not_options('commit or commit range', reference1, reference2)
853
+
516
854
  opts_arr = ['--name-status']
517
855
  opts_arr << reference1 if reference1
518
856
  opts_arr << reference2 if reference2
@@ -536,18 +874,52 @@ module Git
536
874
  diff_as_hash('diff-index', treeish)
537
875
  end
538
876
 
877
+ # List all files that are in the index
878
+ #
879
+ # @param location [String] the location to list the files from
880
+ #
881
+ # @return [Hash<String, Hash>] a hash of files in the index
882
+ # * key: file [String] the file path
883
+ # * value: file_info [Hash] the file information containing the following keys:
884
+ # * :path [String] the file path
885
+ # * :mode_index [String] the file mode
886
+ # * :sha_index [String] the file sha
887
+ # * :stage [String] the file stage
888
+ #
539
889
  def ls_files(location=nil)
540
890
  location ||= '.'
541
- hsh = {}
542
- command_lines('ls-files', '--stage', location).each do |line|
543
- (info, file) = line.split("\t")
544
- (mode, sha, stage) = info.split
545
- if file.start_with?('"') && file.end_with?('"')
546
- file = Git::EscapedPath.new(file[1..-2]).unescape
891
+ {}.tap do |files|
892
+ command_lines('ls-files', '--stage', location).each do |line|
893
+ (info, file) = line.split("\t")
894
+ (mode, sha, stage) = info.split
895
+ files[unescape_quoted_path(file)] = {
896
+ :path => file, :mode_index => mode, :sha_index => sha, :stage => stage
897
+ }
547
898
  end
548
- hsh[file] = {:path => file, :mode_index => mode, :sha_index => sha, :stage => stage}
549
899
  end
550
- hsh
900
+ end
901
+
902
+ # Unescape a path if it is quoted
903
+ #
904
+ # Git commands that output paths (e.g. ls-files, diff), will escape unusual
905
+ # characters.
906
+ #
907
+ # @example
908
+ # lib.unescape_if_quoted('"quoted_file_\\342\\230\\240"') # => 'quoted_file_☠'
909
+ # lib.unescape_if_quoted('unquoted_file') # => 'unquoted_file'
910
+ #
911
+ # @param path [String] the path to unescape if quoted
912
+ #
913
+ # @return [String] the unescaped path if quoted otherwise the original path
914
+ #
915
+ # @api private
916
+ #
917
+ def unescape_quoted_path(path)
918
+ if path.start_with?('"') && path.end_with?('"')
919
+ Git::EscapedPath.new(path[1..-2]).unescape
920
+ else
921
+ path
922
+ end
551
923
  end
552
924
 
553
925
  def ls_remote(location=nil, opts={})
@@ -568,9 +940,12 @@ module Git
568
940
  end
569
941
 
570
942
  def ignored_files
571
- command_lines('ls-files', '--others', '-i', '--exclude-standard')
943
+ command_lines('ls-files', '--others', '-i', '--exclude-standard').map { |f| unescape_quoted_path(f) }
572
944
  end
573
945
 
946
+ def untracked_files
947
+ command_lines('ls-files', '--others', '--exclude-standard', chdir: @git_work_dir)
948
+ end
574
949
 
575
950
  def config_remote(name)
576
951
  hsh = {}
@@ -638,18 +1013,20 @@ module Git
638
1013
  command('config', '--global', name, value)
639
1014
  end
640
1015
 
641
- # updates the repository index using the working directory content
642
- #
643
- # lib.add('path/to/file')
644
- # lib.add(['path/to/file1','path/to/file2'])
645
- # lib.add(:all => true)
1016
+
1017
+ # Update the index from the current worktree to prepare the for the next commit
646
1018
  #
647
- # options:
648
- # :all => true
649
- # :force => true
1019
+ # @example
1020
+ # lib.add('path/to/file')
1021
+ # lib.add(['path/to/file1','path/to/file2'])
1022
+ # lib.add(:all => true)
650
1023
  #
651
- # @param [String,Array] paths files paths to be added to the repository
1024
+ # @param [String, Array<String>] paths files to be added to the repository (relative to the worktree root)
652
1025
  # @param [Hash] options
1026
+ #
1027
+ # @option options [Boolean] :all Add, modify, and remove index entries to match the worktree
1028
+ # @option options [Boolean] :force Allow adding otherwise ignored files
1029
+ #
653
1030
  def add(paths='.',options={})
654
1031
  arr_opts = []
655
1032
 
@@ -675,6 +1052,19 @@ module Git
675
1052
  command('rm', *arr_opts)
676
1053
  end
677
1054
 
1055
+ # Returns true if the repository is empty (meaning it has no commits)
1056
+ #
1057
+ # @return [Boolean]
1058
+ #
1059
+ def empty?
1060
+ command('rev-parse', '--verify', 'HEAD')
1061
+ false
1062
+ rescue Git::FailedError => e
1063
+ raise unless e.result.status.exitstatus == 128 &&
1064
+ e.result.stderr == 'fatal: Needed a single revision'
1065
+ true
1066
+ end
1067
+
678
1068
  # Takes the commit message with the options and executes the commit command
679
1069
  #
680
1070
  # accepts options:
@@ -763,8 +1153,10 @@ module Git
763
1153
  if File.exist?(filename)
764
1154
  File.open(filename) do |f|
765
1155
  f.each_with_index do |line, i|
766
- m = line.match(/:(.*)$/)
767
- arr << [i, m[1].strip]
1156
+ _, msg = line.split("\t")
1157
+ # NOTE this logic may be removed/changed in 3.x
1158
+ m = msg.match(/^[^:]+:(.*)$/)
1159
+ arr << [i, (m ? m[1] : msg).strip]
768
1160
  end
769
1161
  end
770
1162
  end
@@ -865,16 +1257,17 @@ module Git
865
1257
 
866
1258
  def conflicts # :yields: file, your, their
867
1259
  self.unmerged.each do |f|
868
- your_tempfile = Tempfile.new("YOUR-#{File.basename(f)}")
869
- your = your_tempfile.path
870
- your_tempfile.close # free up file for git command process
871
- command('show', ":2:#{f}", redirect: "> #{escape your}")
872
-
873
- their_tempfile = Tempfile.new("THEIR-#{File.basename(f)}")
874
- their = their_tempfile.path
875
- their_tempfile.close # free up file for git command process
876
- command('show', ":3:#{f}", redirect: "> #{escape their}")
877
- yield(f, your, their)
1260
+ Tempfile.create("YOUR-#{File.basename(f)}") do |your|
1261
+ command('show', ":2:#{f}", out: your)
1262
+ your.close
1263
+
1264
+ Tempfile.create("THEIR-#{File.basename(f)}") do |their|
1265
+ command('show', ":3:#{f}", out: their)
1266
+ their.close
1267
+
1268
+ yield(f, your.path, their.path)
1269
+ end
1270
+ end
878
1271
  end
879
1272
  end
880
1273
 
@@ -915,7 +1308,7 @@ module Git
915
1308
  opts = opts.last.instance_of?(Hash) ? opts.last : {}
916
1309
 
917
1310
  if (opts[:a] || opts[:annotate]) && !(opts[:m] || opts[:message])
918
- raise "Can not create an [:a|:annotate] tag without the precense of [:m|:message]."
1311
+ raise ArgumentError, 'Cannot create an annotated tag without a message.'
919
1312
  end
920
1313
 
921
1314
  arr_opts = []
@@ -948,7 +1341,7 @@ module Git
948
1341
  arr_opts << remote if remote
949
1342
  arr_opts << opts[:ref] if opts[:ref]
950
1343
 
951
- command('fetch', *arr_opts)
1344
+ command('fetch', *arr_opts, merge: true)
952
1345
  end
953
1346
 
954
1347
  def push(remote = nil, branch = nil, opts = nil)
@@ -988,10 +1381,11 @@ module Git
988
1381
  end
989
1382
  end
990
1383
 
991
- def pull(remote = nil, branch = nil)
1384
+ def pull(remote = nil, branch = nil, opts = {})
992
1385
  raise ArgumentError, "You must specify a remote if a branch is specified" if remote.nil? && !branch.nil?
993
1386
 
994
1387
  arr_opts = []
1388
+ arr_opts << '--allow-unrelated-histories' if opts[:allow_unrelated_histories]
995
1389
  arr_opts << remote if remote
996
1390
  arr_opts << branch if branch
997
1391
  command('pull', *arr_opts)
@@ -1001,7 +1395,13 @@ module Git
1001
1395
  head = File.join(@git_dir, 'refs', 'tags', tag_name)
1002
1396
  return File.read(head).chomp if File.exist?(head)
1003
1397
 
1004
- command('show-ref', '--tags', '-s', tag_name)
1398
+ begin
1399
+ command('show-ref', '--tags', '-s', tag_name)
1400
+ rescue Git::FailedError => e
1401
+ raise unless e.result.status.exitstatus == 1 && e.result.stderr == ''
1402
+
1403
+ ''
1404
+ end
1005
1405
  end
1006
1406
 
1007
1407
  def repack
@@ -1026,15 +1426,12 @@ module Git
1026
1426
 
1027
1427
  def commit_tree(tree, opts = {})
1028
1428
  opts[:message] ||= "commit tree #{tree}"
1029
- t = Tempfile.new('commit-message')
1030
- t.write(opts[:message])
1031
- t.close
1032
-
1033
1429
  arr_opts = []
1034
1430
  arr_opts << tree
1035
1431
  arr_opts << '-p' << opts[:parent] if opts[:parent]
1036
- arr_opts += Array(opts[:parents]).map { |p| ['-p', p] }.flatten if opts[:parents]
1037
- command('commit-tree', *arr_opts, redirect: "< #{escape t.path}")
1432
+ Array(opts[:parents]).each { |p| arr_opts << '-p' << p } if opts[:parents]
1433
+ arr_opts << '-m' << opts[:message]
1434
+ command('commit-tree', *arr_opts)
1038
1435
  end
1039
1436
 
1040
1437
  def update_ref(ref, commit)
@@ -1080,7 +1477,11 @@ module Git
1080
1477
  arr_opts << "--remote=#{opts[:remote]}" if opts[:remote]
1081
1478
  arr_opts << sha
1082
1479
  arr_opts << '--' << opts[:path] if opts[:path]
1083
- command('archive', *arr_opts, redirect: " > #{escape file}")
1480
+
1481
+ f = File.open(file, 'wb')
1482
+ command('archive', *arr_opts, out: f)
1483
+ f.close
1484
+
1084
1485
  if opts[:add_gzip]
1085
1486
  file_content = File.read(file)
1086
1487
  Zlib::GzipWriter.open(file) do |gz|
@@ -1115,7 +1516,7 @@ module Git
1115
1516
  end
1116
1517
 
1117
1518
  def required_command_version
1118
- [1, 6]
1519
+ [2, 28]
1119
1520
  end
1120
1521
 
1121
1522
  def meets_required_version?
@@ -1133,11 +1534,6 @@ module Git
1133
1534
 
1134
1535
  private
1135
1536
 
1136
- # Systen ENV variables involved in the git commands.
1137
- #
1138
- # @return [<String>] the names of the EVN variables involved in the git commands
1139
- ENV_VARIABLE_NAMES = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_SSH']
1140
-
1141
1537
  def command_lines(cmd, *opts, chdir: nil)
1142
1538
  cmd_op = command(cmd, *opts, chdir: chdir)
1143
1539
  if cmd_op.encoding.name != "UTF-8"
@@ -1148,84 +1544,91 @@ module Git
1148
1544
  op.split("\n")
1149
1545
  end
1150
1546
 
1151
- # Takes the current git's system ENV variables and store them.
1152
- def store_git_system_env_variables
1153
- @git_system_env_variables = {}
1154
- ENV_VARIABLE_NAMES.each do |env_variable_name|
1155
- @git_system_env_variables[env_variable_name] = ENV[env_variable_name]
1156
- end
1547
+ def env_overrides
1548
+ {
1549
+ 'GIT_DIR' => @git_dir,
1550
+ 'GIT_WORK_TREE' => @git_work_dir,
1551
+ 'GIT_INDEX_FILE' => @git_index_file,
1552
+ 'GIT_SSH' => Git::Base.config.git_ssh,
1553
+ 'LC_ALL' => 'en_US.UTF-8'
1554
+ }
1157
1555
  end
1158
1556
 
1159
- # Takes the previously stored git's ENV variables and set them again on ENV.
1160
- def restore_git_system_env_variables
1161
- ENV_VARIABLE_NAMES.each do |env_variable_name|
1162
- ENV[env_variable_name] = @git_system_env_variables[env_variable_name]
1557
+ def global_opts
1558
+ Array.new.tap do |global_opts|
1559
+ global_opts << "--git-dir=#{@git_dir}" if !@git_dir.nil?
1560
+ global_opts << "--work-tree=#{@git_work_dir}" if !@git_work_dir.nil?
1561
+ global_opts << '-c' << 'core.quotePath=true'
1562
+ global_opts << '-c' << 'color.ui=false'
1563
+ global_opts << '-c' << 'color.advice=false'
1564
+ global_opts << '-c' << 'color.diff=false'
1565
+ global_opts << '-c' << 'color.grep=false'
1566
+ global_opts << '-c' << 'color.push=false'
1567
+ global_opts << '-c' << 'color.remote=false'
1568
+ global_opts << '-c' << 'color.showBranch=false'
1569
+ global_opts << '-c' << 'color.status=false'
1570
+ global_opts << '-c' << 'color.transport=false'
1163
1571
  end
1164
1572
  end
1165
1573
 
1166
- # Sets git's ENV variables to the custom values for the current instance.
1167
- def set_custom_git_env_variables
1168
- ENV['GIT_DIR'] = @git_dir
1169
- ENV['GIT_WORK_TREE'] = @git_work_dir
1170
- ENV['GIT_INDEX_FILE'] = @git_index_file
1171
- ENV['GIT_SSH'] = Git::Base.config.git_ssh
1574
+ def command_line
1575
+ @command_line ||=
1576
+ Git::CommandLine.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
1172
1577
  end
1173
1578
 
1174
- # Runs a block inside an environment with customized ENV variables.
1175
- # It restores the ENV after execution.
1579
+ # Runs a git command and returns the output
1176
1580
  #
1177
- # @param [Proc] block block to be executed within the customized environment
1178
- def with_custom_env_variables(&block)
1179
- @@semaphore.synchronize do
1180
- store_git_system_env_variables()
1181
- set_custom_git_env_variables()
1182
- return block.call()
1183
- end
1184
- ensure
1185
- restore_git_system_env_variables()
1186
- end
1187
-
1188
- def command(*cmd, redirect: '', chomp: true, chdir: nil, &block)
1189
- Git::Lib.warn_if_old_command(self)
1190
-
1191
- raise 'cmd can not include a nested array' if cmd.any? { |o| o.is_a? Array }
1192
-
1193
- global_opts = []
1194
- global_opts << "--git-dir=#{@git_dir}" if !@git_dir.nil?
1195
- global_opts << "--work-tree=#{@git_work_dir}" if !@git_work_dir.nil?
1196
- global_opts << '-c' << 'core.quotePath=true'
1197
- global_opts << '-c' << 'color.ui=false'
1198
-
1199
- escaped_cmd = cmd.map { |part| escape(part) }.join(' ')
1200
-
1201
- global_opts = global_opts.map { |s| escape(s) }.join(' ')
1202
-
1203
- git_cmd = "#{Git::Base.config.binary_path} #{global_opts} #{escaped_cmd} #{redirect} 2>&1"
1204
-
1205
- output = nil
1206
-
1207
- command_thread = nil;
1208
-
1209
- status = nil
1210
-
1211
- with_custom_env_variables do
1212
- command_thread = Thread.new do
1213
- output, status = run_command(git_cmd, chdir, &block)
1214
- end
1215
- command_thread.join
1216
- end
1217
-
1218
- @logger.info(git_cmd)
1219
- @logger.debug(output)
1220
-
1221
- if status.exitstatus > 1 || (status.exitstatus == 1 && output != '')
1222
- result = Git::CommandLineResult.new(git_cmd, status, output, '')
1223
- raise Git::FailedError.new(result)
1224
- end
1225
-
1226
- output.chomp! if output && chomp && !block_given?
1227
-
1228
- output
1581
+ # @param args [Array] the git command to run and its arguments
1582
+ #
1583
+ # This should exclude the 'git' command itself and global options.
1584
+ #
1585
+ # For example, to run `git log --pretty=oneline`, you would pass `['log',
1586
+ # '--pretty=oneline']`
1587
+ #
1588
+ # @param out [String, nil] the path to a file or an IO to write the command's
1589
+ # stdout to
1590
+ #
1591
+ # @param err [String, nil] the path to a file or an IO to write the command's
1592
+ # stdout to
1593
+ #
1594
+ # @param normalize [Boolean] true to normalize the output encoding
1595
+ #
1596
+ # @param chomp [Boolean] true to remove trailing newlines from the output
1597
+ #
1598
+ # @param merge [Boolean] true to merge stdout and stderr
1599
+ #
1600
+ # @param chdir [String, nil] the directory to run the command in
1601
+ #
1602
+ # @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete
1603
+ #
1604
+ # If timeout is nil, the global timeout from {Git::Config} is used.
1605
+ #
1606
+ # If timeout is zero, the timeout will not be enforced.
1607
+ #
1608
+ # If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised.
1609
+ #
1610
+ # If the command does not respond to SIGKILL, it will hang this method.
1611
+ #
1612
+ # @see Git::CommandLine#run
1613
+ #
1614
+ # @return [String] the command's stdout (or merged stdout and stderr if `merge`
1615
+ # is true)
1616
+ #
1617
+ # @raise [Git::FailedError] if the command failed
1618
+ # @raise [Git::SignaledError] if the command was signaled
1619
+ # @raise [Git::TimeoutError] if the command times out
1620
+ # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
1621
+ #
1622
+ # The exception's `result` attribute is a {Git::CommandLineResult} which will
1623
+ # contain the result of the command including the exit status, stdout, and
1624
+ # stderr.
1625
+ #
1626
+ # @api private
1627
+ #
1628
+ def command(*args, out: nil, err: nil, normalize: true, chomp: true, merge: false, chdir: nil, timeout: nil)
1629
+ timeout = timeout || Git.config.timeout
1630
+ result = command_line.run(*args, out: out, err: err, normalize: normalize, chomp: chomp, merge: merge, chdir: chdir, timeout: timeout)
1631
+ result.stdout
1229
1632
  end
1230
1633
 
1231
1634
  # Takes the diff command line output (as Array) and parse it into a Hash
@@ -1291,38 +1694,5 @@ module Git
1291
1694
  end
1292
1695
  arr_opts
1293
1696
  end
1294
-
1295
- def run_command(git_cmd, chdir=nil, &block)
1296
- block ||= Proc.new do |io|
1297
- io.readlines.map { |l| Git::EncodingUtils.normalize_encoding(l) }.join
1298
- end
1299
-
1300
- opts = {}
1301
- opts[:chdir] = File.expand_path(chdir) if chdir
1302
-
1303
- Open3.popen2(git_cmd, opts) do |stdin, stdout, wait_thr|
1304
- [block.call(stdout), wait_thr.value]
1305
- end
1306
- end
1307
-
1308
- def escape(s)
1309
- windows_platform? ? escape_for_windows(s) : escape_for_sh(s)
1310
- end
1311
-
1312
- def escape_for_sh(s)
1313
- "'#{s && s.to_s.gsub('\'','\'"\'"\'')}'"
1314
- end
1315
-
1316
- def escape_for_windows(s)
1317
- # Escape existing double quotes in s and then wrap the result with double quotes
1318
- escaped_string = s.to_s.gsub('"','\\"')
1319
- %Q{"#{escaped_string}"}
1320
- end
1321
-
1322
- def windows_platform?
1323
- # Check if on Windows via RUBY_PLATFORM (CRuby) and RUBY_DESCRIPTION (JRuby)
1324
- win_platform_regex = /mingw|mswin/
1325
- RUBY_PLATFORM =~ win_platform_regex || RUBY_DESCRIPTION =~ win_platform_regex
1326
- end
1327
1697
  end
1328
1698
  end