git 4.0.0 → 4.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.
data/lib/git/lib.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'args_builder'
4
+
3
5
  require 'git/command_line'
4
6
  require 'git/errors'
5
7
  require 'logger'
@@ -11,6 +13,8 @@ require 'zlib'
11
13
  require 'open3'
12
14
 
13
15
  module Git
16
+ # Internal git operations
17
+ # @api private
14
18
  class Lib
15
19
  # The path to the Git working copy. The default is '"./.git"'.
16
20
  #
@@ -59,23 +63,21 @@ module Git
59
63
  # @param [Logger] logger
60
64
  #
61
65
  def initialize(base = nil, logger = nil)
62
- @git_dir = nil
63
- @git_index_file = nil
64
- @git_work_dir = nil
65
- @path = nil
66
66
  @logger = logger || Logger.new(nil)
67
67
 
68
- if base.is_a?(Git::Base)
69
- @git_dir = base.repo.path
70
- @git_index_file = base.index.path if base.index
71
- @git_work_dir = base.dir.path if base.dir
72
- elsif base.is_a?(Hash)
73
- @git_dir = base[:repository]
74
- @git_index_file = base[:index]
75
- @git_work_dir = base[:working_directory]
68
+ case base
69
+ when Git::Base
70
+ initialize_from_base(base)
71
+ when Hash
72
+ initialize_from_hash(base)
76
73
  end
77
74
  end
78
75
 
76
+ INIT_OPTION_MAP = [
77
+ { keys: [:bare], flag: '--bare', type: :boolean },
78
+ { keys: [:initial_branch], flag: '--initial-branch', type: :valued_equals }
79
+ ].freeze
80
+
79
81
  # creates or reinitializes the repository
80
82
  #
81
83
  # options:
@@ -83,17 +85,30 @@ module Git
83
85
  # :working_directory
84
86
  # :initial_branch
85
87
  #
86
- def init(opts={})
87
- arr_opts = []
88
- arr_opts << '--bare' if opts[:bare]
89
- arr_opts << "--initial-branch=#{opts[:initial_branch]}" if opts[:initial_branch]
90
-
91
- command('init', *arr_opts)
88
+ def init(opts = {})
89
+ args = build_args(opts, INIT_OPTION_MAP)
90
+ command('init', *args)
92
91
  end
93
92
 
93
+ CLONE_OPTION_MAP = [
94
+ { keys: [:bare], flag: '--bare', type: :boolean },
95
+ { keys: [:recursive], flag: '--recursive', type: :boolean },
96
+ { keys: [:mirror], flag: '--mirror', type: :boolean },
97
+ { keys: [:branch], flag: '--branch', type: :valued_space },
98
+ { keys: [:filter], flag: '--filter', type: :valued_space },
99
+ { keys: %i[remote origin], flag: '--origin', type: :valued_space },
100
+ { keys: [:config], flag: '--config', type: :repeatable_valued_space },
101
+ {
102
+ keys: [:depth],
103
+ type: :custom,
104
+ builder: ->(value) { ['--depth', value.to_i] if value }
105
+ }
106
+ ].freeze
107
+
94
108
  # Clones a repository into a newly created directory
95
109
  #
96
110
  # @param [String] repository_url the URL of the repository to clone
111
+ #
97
112
  # @param [String, nil] directory the directory to clone into
98
113
  #
99
114
  # If nil, the repository is cloned into a directory with the same name as
@@ -102,16 +117,28 @@ module Git
102
117
  # @param [Hash] opts the options for this command
103
118
  #
104
119
  # @option opts [Boolean] :bare (false) if true, clone as a bare repository
120
+ #
105
121
  # @option opts [String] :branch the branch to checkout
122
+ #
106
123
  # @option opts [String, Array] :config one or more configuration options to set
124
+ #
107
125
  # @option opts [Integer] :depth the number of commits back to pull
126
+ #
108
127
  # @option opts [String] :filter specify partial clone
128
+ #
109
129
  # @option opts [String] :mirror set up a mirror of the source repository
130
+ #
110
131
  # @option opts [String] :origin the name of the remote
132
+ #
111
133
  # @option opts [String] :path an optional prefix for the directory parameter
134
+ #
112
135
  # @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
136
+ #
137
+ # @option opts [Boolean] :recursive after the clone is created, initialize all
138
+ # within, using their default settings
139
+ #
140
+ # @option opts [Numeric, nil] :timeout the number of seconds to wait for the
141
+ # command to complete
115
142
  #
116
143
  # See {Git::Lib#command} for more information about :timeout
117
144
  #
@@ -123,34 +150,14 @@ module Git
123
150
  @path = opts[:path] || '.'
124
151
  clone_dir = opts[:path] ? File.join(@path, directory) : directory
125
152
 
126
- arr_opts = []
127
- arr_opts << '--bare' if opts[:bare]
128
- arr_opts << '--branch' << opts[:branch] if opts[:branch]
129
- arr_opts << '--depth' << opts[:depth].to_i if opts[:depth]
130
- arr_opts << '--filter' << opts[:filter] if opts[:filter]
131
- Array(opts[:config]).each { |c| arr_opts << '--config' << c }
132
- arr_opts << '--origin' << opts[:remote] || opts[:origin] if opts[:remote] || opts[:origin]
133
- arr_opts << '--recursive' if opts[:recursive]
134
- arr_opts << '--mirror' if opts[:mirror]
135
-
136
- arr_opts << '--'
137
-
138
- arr_opts << repository_url
139
- arr_opts << clone_dir
153
+ args = build_args(opts, CLONE_OPTION_MAP)
154
+ args.push('--', repository_url, clone_dir)
140
155
 
141
- command('clone', *arr_opts, timeout: opts[:timeout])
156
+ command('clone', *args, timeout: opts[:timeout])
142
157
 
143
158
  return_base_opts_from_clone(clone_dir, opts)
144
159
  end
145
160
 
146
- def return_base_opts_from_clone(clone_dir, opts)
147
- base_opts = {}
148
- base_opts[:repository] = clone_dir if (opts[:bare] || opts[:mirror])
149
- base_opts[:working_directory] = clone_dir unless (opts[:bare] || opts[:mirror])
150
- base_opts[:log] = opts[:log] if opts[:log]
151
- base_opts
152
- end
153
-
154
161
  # Returns the name of the default branch of the given repository
155
162
  #
156
163
  # @param repository [URI, Pathname, String] The (possibly remote) repository to clone from
@@ -171,6 +178,29 @@ module Git
171
178
 
172
179
  ## READ COMMANDS ##
173
180
 
181
+ # The map defining how to translate user options to git command arguments.
182
+ DESCRIBE_OPTION_MAP = [
183
+ { keys: [:all], flag: '--all', type: :boolean },
184
+ { keys: [:tags], flag: '--tags', type: :boolean },
185
+ { keys: [:contains], flag: '--contains', type: :boolean },
186
+ { keys: [:debug], flag: '--debug', type: :boolean },
187
+ { keys: [:long], flag: '--long', type: :boolean },
188
+ { keys: [:always], flag: '--always', type: :boolean },
189
+ { keys: %i[exact_match exact-match], flag: '--exact-match', type: :boolean },
190
+ { keys: [:abbrev], flag: '--abbrev', type: :valued_equals },
191
+ { keys: [:candidates], flag: '--candidates', type: :valued_equals },
192
+ { keys: [:match], flag: '--match', type: :valued_equals },
193
+ {
194
+ keys: [:dirty],
195
+ type: :custom,
196
+ builder: lambda do |value|
197
+ return '--dirty' if value == true
198
+
199
+ "--dirty=#{value}" if value.is_a?(String)
200
+ end
201
+ }
202
+ ].freeze
203
+
174
204
  # Finds most recent tag that is reachable from a commit
175
205
  #
176
206
  # @see https://git-scm.com/docs/git-describe git-describe
@@ -198,26 +228,10 @@ module Git
198
228
  def describe(commit_ish = nil, opts = {})
199
229
  assert_args_are_not_options('commit-ish object', commit_ish)
200
230
 
201
- arr_opts = []
202
-
203
- arr_opts << '--all' if opts[:all]
204
- arr_opts << '--tags' if opts[:tags]
205
- arr_opts << '--contains' if opts[:contains]
206
- arr_opts << '--debug' if opts[:debug]
207
- arr_opts << '--long' if opts[:long]
208
- arr_opts << '--always' if opts[:always]
209
- arr_opts << '--exact-match' if opts[:exact_match] || opts[:"exact-match"]
231
+ args = build_args(opts, DESCRIBE_OPTION_MAP)
232
+ args << commit_ish if commit_ish
210
233
 
211
- arr_opts << '--dirty' if opts[:dirty] == true
212
- arr_opts << "--dirty=#{opts[:dirty]}" if opts[:dirty].is_a?(String)
213
-
214
- arr_opts << "--abbrev=#{opts[:abbrev]}" if opts[:abbrev]
215
- arr_opts << "--candidates=#{opts[:candidates]}" if opts[:candidates]
216
- arr_opts << "--match=#{opts[:match]}" if opts[:match]
217
-
218
- arr_opts << commit_ish if commit_ish
219
-
220
- command('describe', *arr_opts)
234
+ command('describe', *args)
221
235
  end
222
236
 
223
237
  # Return the commits that are within the given revision range
@@ -260,20 +274,35 @@ module Git
260
274
  command_lines('log', *arr_opts).map { |l| l.split.first }
261
275
  end
262
276
 
277
+ FULL_LOG_EXTRA_OPTIONS_MAP = [
278
+ { type: :static, flag: '--pretty=raw' },
279
+ { keys: [:skip], flag: '--skip', type: :valued_equals },
280
+ { keys: [:merges], flag: '--merges', type: :boolean }
281
+ ].freeze
282
+
263
283
  # Return the commits that are within the given revision range
264
284
  #
265
285
  # @see https://git-scm.com/docs/git-log git-log
266
286
  #
267
287
  # @param opts [Hash] the given options
268
288
  #
269
- # @option opts :count [Integer] the maximum number of commits to return (maps to max-count)
289
+ # @option opts :count [Integer] the maximum number of commits to return (maps to
290
+ # max-count)
291
+ #
270
292
  # @option opts :all [Boolean]
293
+ #
271
294
  # @option opts :cherry [Boolean]
295
+ #
272
296
  # @option opts :since [String]
297
+ #
273
298
  # @option opts :until [String]
299
+ #
274
300
  # @option opts :grep [String]
301
+ #
275
302
  # @option opts :author [String]
276
- # @option opts :between [Array<String>] an array of two commit-ish strings to specify a revision range
303
+ #
304
+ # @option opts :between [Array<String>] an array of two commit-ish strings to
305
+ # specify a revision range
277
306
  #
278
307
  # Only :between or :object options can be used, not both.
279
308
  #
@@ -281,37 +310,39 @@ module Git
281
310
  #
282
311
  # Only :between or :object options can be used, not both.
283
312
  #
284
- # @option opts :path_limiter [Array<String>, String] only include commits that impact files from the specified paths
313
+ # @option opts :path_limiter [Array<String>, String] only include commits that
314
+ # impact files from the specified paths
315
+ #
285
316
  # @option opts :skip [Integer]
286
317
  #
287
318
  # @return [Array<Hash>] the log output parsed into an array of hashs for each commit
288
319
  #
289
320
  # Each hash contains the following keys:
321
+ #
290
322
  # * 'sha' [String] the commit sha
291
323
  # * 'author' [String] the author of the commit
292
324
  # * 'message' [String] the commit message
293
325
  # * 'parent' [Array<String>] the commit shas of the parent commits
294
326
  # * '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)
327
+ # * 'author' [String] the author of the commit and timestamp of when the
328
+ # changes were created
329
+ # * 'committer' [String] the committer of the commit and timestamp of when the
330
+ # commit was applied
331
+ # * 'merges' [Boolean] if truthy, only include merge commits (aka commits with
332
+ # 2 or more parents)
298
333
  #
299
- # @raise [ArgumentError] if the revision range (specified with :between or :object) is a string starting with a hyphen
334
+ # @raise [ArgumentError] if the revision range (specified with :between or
335
+ # :object) is a string starting with a hyphen
300
336
  #
301
337
  def full_log_commits(opts = {})
302
338
  assert_args_are_not_options('between', opts[:between]&.first)
303
339
  assert_args_are_not_options('object', opts[:object])
304
340
 
305
- arr_opts = log_common_options(opts)
306
-
307
- arr_opts << '--pretty=raw'
308
- arr_opts << "--skip=#{opts[:skip]}" if opts[:skip]
309
- arr_opts << '--merges' if opts[:merges]
310
-
311
- arr_opts += log_path_options(opts)
312
-
313
- full_log = command_lines('log', *arr_opts)
341
+ args = log_common_options(opts)
342
+ args += build_args(opts, FULL_LOG_EXTRA_OPTIONS_MAP)
343
+ args += log_path_options(opts)
314
344
 
345
+ full_log = command_lines('log', *args)
315
346
  process_commit_log_data(full_log)
316
347
  end
317
348
 
@@ -319,7 +350,8 @@ module Git
319
350
  #
320
351
  # @see https://git-scm.com/docs/git-rev-parse git-rev-parse
321
352
  # @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
353
+ # @see https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt-emltrefnamegtemegemmasterememheadsmasterememrefsheadsmasterem
354
+ # Ref disambiguation rules
323
355
  #
324
356
  # @example
325
357
  # lib.rev_parse('HEAD') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d'
@@ -339,7 +371,7 @@ module Git
339
371
  end
340
372
 
341
373
  # For backwards compatibility with the old method name
342
- alias :revparse :rev_parse
374
+ alias revparse rev_parse
343
375
 
344
376
  # Find the first symbolic name for given commit_ish
345
377
  #
@@ -355,7 +387,7 @@ module Git
355
387
  command('name-rev', commit_ish).split[1]
356
388
  end
357
389
 
358
- alias :namerev :name_rev
390
+ alias namerev name_rev
359
391
 
360
392
  # Output the contents or other properties of one or more objects.
361
393
  #
@@ -373,7 +405,7 @@ module Git
373
405
  #
374
406
  # @raise [ArgumentError] if object is a string starting with a hyphen
375
407
  #
376
- def cat_file_contents(object, &block)
408
+ def cat_file_contents(object)
377
409
  assert_args_are_not_options('object', object)
378
410
 
379
411
  if block_given?
@@ -381,7 +413,7 @@ module Git
381
413
  # If a block is given, write the output from the process to a temporary
382
414
  # file and then yield the file to the block
383
415
  #
384
- command('cat-file', "-p", object, out: file, err: file)
416
+ command('cat-file', '-p', object, out: file, err: file)
385
417
  file.rewind
386
418
  yield file
387
419
  end
@@ -391,7 +423,7 @@ module Git
391
423
  end
392
424
  end
393
425
 
394
- alias :object_contents :cat_file_contents
426
+ alias object_contents cat_file_contents
395
427
 
396
428
  # Get the type for the given object
397
429
  #
@@ -409,7 +441,7 @@ module Git
409
441
  command('cat-file', '-t', object)
410
442
  end
411
443
 
412
- alias :object_type :cat_file_type
444
+ alias object_type cat_file_type
413
445
 
414
446
  # Get the size for the given object
415
447
  #
@@ -427,7 +459,7 @@ module Git
427
459
  command('cat-file', '-s', object).to_i
428
460
  end
429
461
 
430
- alias :object_size :cat_file_size
462
+ alias object_size cat_file_size
431
463
 
432
464
  # Return a hash of commit data
433
465
  #
@@ -454,25 +486,15 @@ module Git
454
486
  process_commit_data(cdata, object)
455
487
  end
456
488
 
457
- alias :commit_data :cat_file_commit
489
+ alias commit_data cat_file_commit
458
490
 
459
491
  def process_commit_data(data, sha)
460
- hsh = {
461
- 'sha' => sha,
462
- 'parent' => []
463
- }
492
+ # process_commit_headers consumes the header lines from the `data` array,
493
+ # leaving only the message lines behind.
494
+ headers = process_commit_headers(data)
495
+ message = "#{data.join("\n")}\n"
464
496
 
465
- each_cat_file_header(data) do |key, value|
466
- if key == 'parent'
467
- hsh['parent'] << value
468
- else
469
- hsh[key] = value
470
- end
471
- end
472
-
473
- hsh['message'] = data.join("\n") + "\n"
474
-
475
- hsh
497
+ { 'sha' => sha, 'message' => message }.merge(headers)
476
498
  end
477
499
 
478
500
  CAT_FILE_HEADER_LINE = /\A(?<key>\w+) (?<value>.*)\z/
@@ -482,9 +504,7 @@ module Git
482
504
  key = match[:key]
483
505
  value_lines = [match[:value]]
484
506
 
485
- while data.first.start_with?(' ')
486
- value_lines << data.shift.lstrip
487
- end
507
+ value_lines << data.shift.lstrip while data.first.start_with?(' ')
488
508
 
489
509
  yield key, value_lines.join("\n")
490
510
  end
@@ -492,10 +512,12 @@ module Git
492
512
 
493
513
  # Return a hash of annotated tag data
494
514
  #
495
- # Does not work with lightweight tags. List all annotated tags in your repository with the following command:
515
+ # Does not work with lightweight tags. List all annotated tags in your repository
516
+ # with the following command:
496
517
  #
497
518
  # ```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
519
+ # git for-each-ref --format='%(refname:strip=2)' refs/tags | \
520
+ # while read tag; do git cat-file tag $tag >/dev/null 2>&1 && echo $tag; done
499
521
  # ```
500
522
  #
501
523
  # @see https://git-scm.com/docs/git-cat-file git-cat-file
@@ -520,7 +542,8 @@ module Git
520
542
  # * object [String] the sha of the tag object
521
543
  # * type [String]
522
544
  # * 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
545
+ # * tagger [String] the name and email of the user who created the tag
546
+ # and the timestamp of when the tag was created
524
547
  # * message [String] the tag message
525
548
  #
526
549
  # @raise [ArgumentError] if object is a string starting with a hyphen
@@ -532,7 +555,7 @@ module Git
532
555
  process_tag_data(tdata, object)
533
556
  end
534
557
 
535
- alias :tag_data :cat_file_tag
558
+ alias tag_data cat_file_tag
536
559
 
537
560
  def process_tag_data(data, name)
538
561
  hsh = { 'name' => name }
@@ -541,64 +564,88 @@ module Git
541
564
  hsh[key] = value
542
565
  end
543
566
 
544
- hsh['message'] = data.join("\n") + "\n"
567
+ hsh['message'] = "#{data.join("\n")}\n"
545
568
 
546
569
  hsh
547
570
  end
548
571
 
549
572
  def process_commit_log_data(data)
550
- in_message = false
573
+ RawLogParser.new(data).parse
574
+ end
551
575
 
552
- hsh_array = []
576
+ # A private parser class to process the output of `git log --pretty=raw`
577
+ # @api private
578
+ class RawLogParser
579
+ def initialize(lines)
580
+ @lines = lines
581
+ @commits = []
582
+ @current_commit = nil
583
+ @in_message = false
584
+ end
553
585
 
554
- hsh = nil
586
+ def parse
587
+ @lines.each { |line| process_line(line.chomp) }
588
+ finalize_commit
589
+ @commits
590
+ end
555
591
 
556
- data.each do |line|
557
- line = line.chomp
592
+ private
558
593
 
559
- if line[0].nil?
560
- in_message = !in_message
561
- next
594
+ def process_line(line)
595
+ if line.empty?
596
+ @in_message = !@in_message
597
+ return
562
598
  end
563
599
 
564
- in_message = false if in_message && line[0..3] != " "
600
+ @in_message = false if @in_message && !line.start_with?(' ')
565
601
 
566
- if in_message
567
- hsh['message'] << "#{line[4..-1]}\n"
568
- next
569
- end
602
+ @in_message ? process_message_line(line) : process_metadata_line(line)
603
+ end
570
604
 
605
+ def process_message_line(line)
606
+ @current_commit['message'] << "#{line[4..]}\n"
607
+ end
608
+
609
+ def process_metadata_line(line)
571
610
  key, *value = line.split
572
611
  value = value.join(' ')
573
612
 
574
613
  case key
575
- when 'commit'
576
- hsh_array << hsh if hsh
577
- hsh = {'sha' => value, 'message' => +'', 'parent' => []}
578
- when 'parent'
579
- hsh['parent'] << value
580
- else
581
- hsh[key] = value
614
+ when 'commit'
615
+ start_new_commit(value)
616
+ when 'parent'
617
+ @current_commit['parent'] << value
618
+ else
619
+ @current_commit[key] = value
582
620
  end
583
621
  end
584
622
 
585
- hsh_array << hsh if hsh
623
+ def start_new_commit(sha)
624
+ finalize_commit
625
+ @current_commit = { 'sha' => sha, 'message' => +'', 'parent' => [] }
626
+ end
586
627
 
587
- hsh_array
628
+ def finalize_commit
629
+ @commits << @current_commit if @current_commit
630
+ end
588
631
  end
632
+ private_constant :RawLogParser
633
+
634
+ LS_TREE_OPTION_MAP = [
635
+ { keys: [:recursive], flag: '-r', type: :boolean }
636
+ ].freeze
589
637
 
590
638
  def ls_tree(sha, opts = {})
591
639
  data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
640
+ args = build_args(opts, LS_TREE_OPTION_MAP)
592
641
 
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]
642
+ args.unshift(sha)
643
+ args << opts[:path] if opts[:path]
597
644
 
598
- command_lines('ls-tree', sha, *ls_tree_opts).each do |line|
645
+ command_lines('ls-tree', *args).each do |line|
599
646
  (info, filenm) = line.split("\t")
600
647
  (mode, type, sha) = info.split
601
- data[type][filenm] = {:mode => mode, :sha => sha}
648
+ data[type][filenm] = { mode: mode, sha: sha }
602
649
  end
603
650
 
604
651
  data
@@ -646,31 +693,9 @@ module Git
646
693
 
647
694
  def branches_all
648
695
  lines = command_lines('branch', '-a')
649
- lines.each_with_index.map do |line, line_index|
650
- match_data = line.match(BRANCH_LINE_REGEXP)
651
-
652
- raise Git::UnexpectedResultError, unexpected_branch_line_error(lines, line, line_index) unless match_data
653
- next nil if match_data[:not_a_branch] || match_data[:detached_ref]
654
-
655
- [
656
- match_data[:refname],
657
- !match_data[:current].nil?,
658
- !match_data[:worktree].nil?,
659
- match_data[:symref]
660
- ]
661
- end.compact
662
- end
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
696
+ lines.each_with_index.filter_map do |line, index|
697
+ parse_branch_line(line, index, lines)
698
+ end
674
699
  end
675
700
 
676
701
  def worktrees_all
@@ -686,7 +711,7 @@ module Git
686
711
  # detached
687
712
  #
688
713
  command_lines('worktree', 'list', '--porcelain').each do |w|
689
- s = w.split("\s")
714
+ s = w.split
690
715
  directory = s[1] if s[0] == 'worktree'
691
716
  arr << [directory, s[1]] if s[0] == 'HEAD'
692
717
  end
@@ -694,7 +719,8 @@ module Git
694
719
  end
695
720
 
696
721
  def worktree_add(dir, commitish = nil)
697
- return command('worktree', 'add', dir, commitish) if !commitish.nil?
722
+ return command('worktree', 'add', dir, commitish) unless commitish.nil?
723
+
698
724
  command('worktree', 'add', dir)
699
725
  end
700
726
 
@@ -708,12 +734,7 @@ module Git
708
734
 
709
735
  def list_files(ref_dir)
710
736
  dir = File.join(@git_dir, 'refs', ref_dir)
711
- files = []
712
- begin
713
- files = Dir.glob('**/*', base: dir).select { |f| File.file?(File.join(dir, f)) }
714
- rescue
715
- end
716
- files
737
+ Dir.glob('**/*', base: dir).select { |f| File.file?(File.join(dir, f)) }
717
738
  end
718
739
 
719
740
  # The state and name of branch pointed to by `HEAD`
@@ -748,16 +769,7 @@ module Git
748
769
  branch_name = command('branch', '--show-current')
749
770
  return HeadState.new(:detached, 'HEAD') if branch_name.empty?
750
771
 
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
-
772
+ state = get_branch_state(branch_name)
761
773
  HeadState.new(state, branch_name)
762
774
  end
763
775
 
@@ -766,38 +778,35 @@ module Git
766
778
  branch_name.empty? ? 'HEAD' : branch_name
767
779
  end
768
780
 
769
- def branch_contains(commit, branch_name="")
770
- command("branch", branch_name, "--contains", commit)
781
+ def branch_contains(commit, branch_name = '')
782
+ command('branch', branch_name, '--contains', commit)
771
783
  end
772
784
 
785
+ GREP_OPTION_MAP = [
786
+ { keys: [:ignore_case], flag: '-i', type: :boolean },
787
+ { keys: [:invert_match], flag: '-v', type: :boolean },
788
+ { keys: [:extended_regexp], flag: '-E', type: :boolean },
789
+ # For validation only, as these are handled manually
790
+ { keys: [:object], type: :validate_only },
791
+ { keys: [:path_limiter], type: :validate_only }
792
+ ].freeze
793
+
773
794
  # returns hash
774
795
  # [tree-ish] = [[line_no, match], [line_no, match2]]
775
796
  # [tree-ish] = [[line_no, match], [line_no, match2]]
776
797
  def grep(string, opts = {})
777
798
  opts[:object] ||= 'HEAD'
799
+ ArgsBuilder.validate!(opts, GREP_OPTION_MAP)
778
800
 
779
- grep_opts = ['-n']
780
- grep_opts << '-i' if opts[:ignore_case]
781
- grep_opts << '-v' if opts[:invert_match]
782
- grep_opts << '-E' if opts[:extended_regexp]
783
- grep_opts << '-e'
784
- grep_opts << string
785
- grep_opts << opts[:object] if opts[:object].is_a?(String)
786
- grep_opts.push('--', opts[:path_limiter]) if opts[:path_limiter].is_a?(String)
787
- grep_opts.push('--', *opts[:path_limiter]) if opts[:path_limiter].is_a?(Array)
801
+ boolean_flags = build_args(opts, GREP_OPTION_MAP)
802
+ args = ['-n', *boolean_flags, '-e', string, opts[:object]]
788
803
 
789
- hsh = {}
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
796
- end
797
- rescue Git::FailedError => e
798
- raise unless e.result.status.exitstatus == 1 && e.result.stderr == ''
804
+ if (limiter = opts[:path_limiter])
805
+ args.push('--', *Array(limiter))
799
806
  end
800
- hsh
807
+
808
+ lines = execute_grep_command(args)
809
+ parse_grep_output(lines)
801
810
  end
802
811
 
803
812
  # Validate that the given arguments cannot be mistaken for a command-line option
@@ -810,58 +819,64 @@ module Git
810
819
  #
811
820
  def assert_args_are_not_options(arg_name, *args)
812
821
  invalid_args = args.select { |arg| arg&.start_with?('-') }
813
- if invalid_args.any?
814
- raise ArgumentError, "Invalid #{arg_name}: '#{invalid_args.join("', '")}'"
815
- end
822
+ return unless invalid_args.any?
823
+
824
+ raise ArgumentError, "Invalid #{arg_name}: '#{invalid_args.join("', '")}'"
816
825
  end
817
826
 
827
+ DIFF_FULL_OPTION_MAP = [
828
+ { type: :static, flag: '-p' },
829
+ { keys: [:path_limiter], type: :validate_only }
830
+ ].freeze
831
+
818
832
  def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {})
819
833
  assert_args_are_not_options('commit or commit range', obj1, obj2)
834
+ ArgsBuilder.validate!(opts, DIFF_FULL_OPTION_MAP)
820
835
 
821
- diff_opts = ['-p']
822
- diff_opts << obj1
823
- diff_opts << obj2 if obj2.is_a?(String)
824
- diff_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String
836
+ args = build_args(opts, DIFF_FULL_OPTION_MAP)
837
+ args.push(obj1, obj2).compact!
825
838
 
826
- command('diff', *diff_opts)
839
+ if (path = opts[:path_limiter]) && path.is_a?(String)
840
+ args.push('--', path)
841
+ end
842
+
843
+ command('diff', *args)
827
844
  end
828
845
 
846
+ DIFF_STATS_OPTION_MAP = [
847
+ { type: :static, flag: '--numstat' },
848
+ { keys: [:path_limiter], type: :validate_only }
849
+ ].freeze
850
+
829
851
  def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {})
830
852
  assert_args_are_not_options('commit or commit range', obj1, obj2)
853
+ ArgsBuilder.validate!(opts, DIFF_STATS_OPTION_MAP)
831
854
 
832
- diff_opts = ['--numstat']
833
- diff_opts << obj1
834
- diff_opts << obj2 if obj2.is_a?(String)
835
- diff_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String
855
+ args = build_args(opts, DIFF_STATS_OPTION_MAP)
856
+ args.push(obj1, obj2).compact!
836
857
 
837
- hsh = {:total => {:insertions => 0, :deletions => 0, :lines => 0, :files => 0}, :files => {}}
838
-
839
- command_lines('diff', *diff_opts).each do |file|
840
- (insertions, deletions, filename) = file.split("\t")
841
- hsh[:total][:insertions] += insertions.to_i
842
- hsh[:total][:deletions] += deletions.to_i
843
- hsh[:total][:lines] = (hsh[:total][:deletions] + hsh[:total][:insertions])
844
- hsh[:total][:files] += 1
845
- hsh[:files][filename] = {:insertions => insertions.to_i, :deletions => deletions.to_i}
858
+ if (path = opts[:path_limiter]) && path.is_a?(String)
859
+ args.push('--', path)
846
860
  end
847
861
 
848
- hsh
862
+ output_lines = command_lines('diff', *args)
863
+ parse_diff_stats_output(output_lines)
849
864
  end
850
865
 
866
+ DIFF_PATH_STATUS_OPTION_MAP = [
867
+ { type: :static, flag: '--name-status' },
868
+ { keys: [:path], type: :validate_only }
869
+ ].freeze
870
+
851
871
  def diff_path_status(reference1 = nil, reference2 = nil, opts = {})
852
872
  assert_args_are_not_options('commit or commit range', reference1, reference2)
873
+ ArgsBuilder.validate!(opts, DIFF_PATH_STATUS_OPTION_MAP)
853
874
 
854
- opts_arr = ['--name-status']
855
- opts_arr << reference1 if reference1
856
- opts_arr << reference2 if reference2
875
+ args = build_args(opts, DIFF_PATH_STATUS_OPTION_MAP)
876
+ args.push(reference1, reference2).compact!
877
+ args.push('--', opts[:path]) if opts[:path]
857
878
 
858
- opts_arr << '--' << opts[:path] if opts[:path]
859
-
860
- command_lines('diff', *opts_arr).inject({}) do |memo, line|
861
- status, path = line.split("\t")
862
- memo[path] = status
863
- memo
864
- end
879
+ parse_diff_path_status(args)
865
880
  end
866
881
 
867
882
  # compares the index and the working directory
@@ -886,14 +901,14 @@ module Git
886
901
  # * :sha_index [String] the file sha
887
902
  # * :stage [String] the file stage
888
903
  #
889
- def ls_files(location=nil)
904
+ def ls_files(location = nil)
890
905
  location ||= '.'
891
906
  {}.tap do |files|
892
907
  command_lines('ls-files', '--stage', location).each do |line|
893
908
  (info, file) = line.split("\t")
894
909
  (mode, sha, stage) = info.split
895
910
  files[unescape_quoted_path(file)] = {
896
- :path => file, :mode_index => mode, :sha_index => sha, :stage => stage
911
+ path: file, mode_index: mode, sha_index: sha, stage: stage
897
912
  }
898
913
  end
899
914
  end
@@ -922,21 +937,18 @@ module Git
922
937
  end
923
938
  end
924
939
 
925
- def ls_remote(location=nil, opts={})
926
- arr_opts = []
927
- arr_opts << '--refs' if opts[:refs]
928
- arr_opts << (location || '.')
929
-
930
- Hash.new{ |h,k| h[k] = {} }.tap do |hsh|
931
- command_lines('ls-remote', *arr_opts).each do |line|
932
- (sha, info) = line.split("\t")
933
- (ref, type, name) = info.split('/', 3)
934
- type ||= 'head'
935
- type = 'branches' if type == 'heads'
936
- value = {:ref => ref, :sha => sha}
937
- hsh[type].update( name.nil? ? value : { name => value })
938
- end
939
- end
940
+ LS_REMOTE_OPTION_MAP = [
941
+ { keys: [:refs], flag: '--refs', type: :boolean }
942
+ ].freeze
943
+
944
+ def ls_remote(location = nil, opts = {})
945
+ ArgsBuilder.validate!(opts, LS_REMOTE_OPTION_MAP)
946
+
947
+ flags = build_args(opts, LS_REMOTE_OPTION_MAP)
948
+ positional_arg = location || '.'
949
+
950
+ output_lines = command_lines('ls-remote', *flags, positional_arg)
951
+ parse_ls_remote_output(output_lines)
940
952
  end
941
953
 
942
954
  def ignored_files
@@ -950,9 +962,7 @@ module Git
950
962
  def config_remote(name)
951
963
  hsh = {}
952
964
  config_list.each do |key, value|
953
- if /remote.#{name}/.match(key)
954
- hsh[key.gsub("remote.#{name}.", '')] = value
955
- end
965
+ hsh[key.gsub("remote.#{name}.", '')] = value if /remote.#{name}/.match(key)
956
966
  end
957
967
  hsh
958
968
  end
@@ -991,7 +1001,7 @@ module Git
991
1001
  # @param [String|NilClass] objectish the target object reference (nil == HEAD)
992
1002
  # @param [String|NilClass] path the path of the file to be shown
993
1003
  # @return [String] the object information
994
- def show(objectish=nil, path=nil)
1004
+ def show(objectish = nil, path = nil)
995
1005
  arr_opts = []
996
1006
 
997
1007
  arr_opts << (path ? "#{objectish}:#{path}" : objectish)
@@ -1001,18 +1011,24 @@ module Git
1001
1011
 
1002
1012
  ## WRITE COMMANDS ##
1003
1013
 
1014
+ CONFIG_SET_OPTION_MAP = [
1015
+ { keys: [:file], flag: '--file', type: :valued_space }
1016
+ ].freeze
1017
+
1004
1018
  def config_set(name, value, options = {})
1005
- if options[:file].to_s.empty?
1006
- command('config', name, value)
1007
- else
1008
- command('config', '--file', options[:file], name, value)
1009
- end
1019
+ ArgsBuilder.validate!(options, CONFIG_SET_OPTION_MAP)
1020
+ flags = build_args(options, CONFIG_SET_OPTION_MAP)
1021
+ command('config', *flags, name, value)
1010
1022
  end
1011
1023
 
1012
1024
  def global_config_set(name, value)
1013
1025
  command('config', '--global', name, value)
1014
1026
  end
1015
1027
 
1028
+ ADD_OPTION_MAP = [
1029
+ { keys: [:all], flag: '--all', type: :boolean },
1030
+ { keys: [:force], flag: '--force', type: :boolean }
1031
+ ].freeze
1016
1032
 
1017
1033
  # Update the index from the current worktree to prepare the for the next commit
1018
1034
  #
@@ -1027,29 +1043,28 @@ module Git
1027
1043
  # @option options [Boolean] :all Add, modify, and remove index entries to match the worktree
1028
1044
  # @option options [Boolean] :force Allow adding otherwise ignored files
1029
1045
  #
1030
- def add(paths='.',options={})
1031
- arr_opts = []
1032
-
1033
- arr_opts << '--all' if options[:all]
1034
- arr_opts << '--force' if options[:force]
1035
-
1036
- arr_opts << '--'
1046
+ def add(paths = '.', options = {})
1047
+ args = build_args(options, ADD_OPTION_MAP)
1037
1048
 
1038
- arr_opts << paths
1049
+ args << '--'
1050
+ args.concat(Array(paths))
1039
1051
 
1040
- arr_opts.flatten!
1041
-
1042
- command('add', *arr_opts)
1052
+ command('add', *args)
1043
1053
  end
1044
1054
 
1055
+ RM_OPTION_MAP = [
1056
+ { type: :static, flag: '-f' },
1057
+ { keys: [:recursive], flag: '-r', type: :boolean },
1058
+ { keys: [:cached], flag: '--cached', type: :boolean }
1059
+ ].freeze
1060
+
1045
1061
  def rm(path = '.', opts = {})
1046
- arr_opts = ['-f'] # overrides the up-to-date check by default
1047
- arr_opts << '-r' if opts[:recursive]
1048
- arr_opts << '--cached' if opts[:cached]
1049
- arr_opts << '--'
1050
- arr_opts += Array(path)
1062
+ args = build_args(opts, RM_OPTION_MAP)
1051
1063
 
1052
- command('rm', *arr_opts)
1064
+ args << '--'
1065
+ args.concat(Array(path))
1066
+
1067
+ command('rm', *args)
1053
1068
  end
1054
1069
 
1055
1070
  # Returns true if the repository is empty (meaning it has no commits)
@@ -1061,10 +1076,32 @@ module Git
1061
1076
  false
1062
1077
  rescue Git::FailedError => e
1063
1078
  raise unless e.result.status.exitstatus == 128 &&
1064
- e.result.stderr == 'fatal: Needed a single revision'
1079
+ e.result.stderr == 'fatal: Needed a single revision'
1080
+
1065
1081
  true
1066
1082
  end
1067
1083
 
1084
+ COMMIT_OPTION_MAP = [
1085
+ { keys: %i[add_all all], flag: '--all', type: :boolean },
1086
+ { keys: [:allow_empty], flag: '--allow-empty', type: :boolean },
1087
+ { keys: [:no_verify], flag: '--no-verify', type: :boolean },
1088
+ { keys: [:allow_empty_message], flag: '--allow-empty-message', type: :boolean },
1089
+ { keys: [:author], flag: '--author', type: :valued_equals },
1090
+ { keys: [:message], flag: '--message', type: :valued_equals },
1091
+ { keys: [:no_gpg_sign], flag: '--no-gpg-sign', type: :boolean },
1092
+ { keys: [:date], flag: '--date', type: :valued_equals, validator: ->(v) { v.is_a?(String) } },
1093
+ { keys: [:amend], type: :custom, builder: ->(value) { ['--amend', '--no-edit'] if value } },
1094
+ {
1095
+ keys: [:gpg_sign],
1096
+ type: :custom,
1097
+ builder: lambda { |value|
1098
+ if value
1099
+ value == true ? '--gpg-sign' : "--gpg-sign=#{value}"
1100
+ end
1101
+ }
1102
+ }
1103
+ ].freeze
1104
+
1068
1105
  # Takes the commit message with the options and executes the commit command
1069
1106
  #
1070
1107
  # accepts options:
@@ -1080,59 +1117,52 @@ module Git
1080
1117
  #
1081
1118
  # @param [String] message the commit message to be used
1082
1119
  # @param [Hash] opts the commit options to be used
1120
+
1083
1121
  def commit(message, opts = {})
1084
- arr_opts = []
1085
- arr_opts << "--message=#{message}" if message
1086
- arr_opts << '--amend' << '--no-edit' if opts[:amend]
1087
- arr_opts << '--all' if opts[:add_all] || opts[:all]
1088
- arr_opts << '--allow-empty' if opts[:allow_empty]
1089
- arr_opts << "--author=#{opts[:author]}" if opts[:author]
1090
- arr_opts << "--date=#{opts[:date]}" if opts[:date].is_a? String
1091
- arr_opts << '--no-verify' if opts[:no_verify]
1092
- arr_opts << '--allow-empty-message' if opts[:allow_empty_message]
1093
-
1094
- if opts[:gpg_sign] && opts[:no_gpg_sign]
1095
- raise ArgumentError, 'cannot specify :gpg_sign and :no_gpg_sign'
1096
- elsif opts[:gpg_sign]
1097
- arr_opts <<
1098
- if opts[:gpg_sign] == true
1099
- '--gpg-sign'
1100
- else
1101
- "--gpg-sign=#{opts[:gpg_sign]}"
1102
- end
1103
- elsif opts[:no_gpg_sign]
1104
- arr_opts << '--no-gpg-sign'
1105
- end
1122
+ opts[:message] = message if message # Handle message arg for backward compatibility
1123
+
1124
+ # Perform cross-option validation before building args
1125
+ raise ArgumentError, 'cannot specify :gpg_sign and :no_gpg_sign' if opts[:gpg_sign] && opts[:no_gpg_sign]
1106
1126
 
1107
- command('commit', *arr_opts)
1127
+ ArgsBuilder.validate!(opts, COMMIT_OPTION_MAP)
1128
+
1129
+ args = build_args(opts, COMMIT_OPTION_MAP)
1130
+ command('commit', *args)
1108
1131
  end
1132
+ RESET_OPTION_MAP = [
1133
+ { keys: [:hard], flag: '--hard', type: :boolean }
1134
+ ].freeze
1109
1135
 
1110
1136
  def reset(commit, opts = {})
1111
- arr_opts = []
1112
- arr_opts << '--hard' if opts[:hard]
1113
- arr_opts << commit if commit
1114
- command('reset', *arr_opts)
1137
+ args = build_args(opts, RESET_OPTION_MAP)
1138
+ args << commit if commit
1139
+ command('reset', *args)
1115
1140
  end
1116
1141
 
1117
- def clean(opts = {})
1118
- arr_opts = []
1119
- arr_opts << '--force' if opts[:force]
1120
- arr_opts << '-ff' if opts[:ff]
1121
- arr_opts << '-d' if opts[:d]
1122
- arr_opts << '-x' if opts[:x]
1142
+ CLEAN_OPTION_MAP = [
1143
+ { keys: [:force], flag: '--force', type: :boolean },
1144
+ { keys: [:ff], flag: '-ff', type: :boolean },
1145
+ { keys: [:d], flag: '-d', type: :boolean },
1146
+ { keys: [:x], flag: '-x', type: :boolean }
1147
+ ].freeze
1123
1148
 
1124
- command('clean', *arr_opts)
1149
+ def clean(opts = {})
1150
+ args = build_args(opts, CLEAN_OPTION_MAP)
1151
+ command('clean', *args)
1125
1152
  end
1126
1153
 
1154
+ REVERT_OPTION_MAP = [
1155
+ { keys: [:no_edit], flag: '--no-edit', type: :boolean }
1156
+ ].freeze
1157
+
1127
1158
  def revert(commitish, opts = {})
1128
1159
  # Forcing --no-edit as default since it's not an interactive session.
1129
- opts = {:no_edit => true}.merge(opts)
1160
+ opts = { no_edit: true }.merge(opts)
1130
1161
 
1131
- arr_opts = []
1132
- arr_opts << '--no-edit' if opts[:no_edit]
1133
- arr_opts << commitish
1162
+ args = build_args(opts, REVERT_OPTION_MAP)
1163
+ args << commitish
1134
1164
 
1135
- command('revert', *arr_opts)
1165
+ command('revert', *args)
1136
1166
  end
1137
1167
 
1138
1168
  def apply(patch_file)
@@ -1148,19 +1178,9 @@ module Git
1148
1178
  end
1149
1179
 
1150
1180
  def stashes_all
1151
- arr = []
1152
- filename = File.join(@git_dir, 'logs/refs/stash')
1153
- if File.exist?(filename)
1154
- File.open(filename) do |f|
1155
- f.each_with_index do |line, i|
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]
1160
- end
1161
- end
1181
+ stash_log_lines.each_with_index.map do |line, index|
1182
+ parse_stash_log_line(line, index)
1162
1183
  end
1163
- arr
1164
1184
  end
1165
1185
 
1166
1186
  def stash_save(message)
@@ -1192,6 +1212,12 @@ module Git
1192
1212
  command('branch', '-D', branch)
1193
1213
  end
1194
1214
 
1215
+ CHECKOUT_OPTION_MAP = [
1216
+ { keys: %i[force f], flag: '--force', type: :boolean },
1217
+ { keys: %i[new_branch b], type: :validate_only },
1218
+ { keys: [:start_point], type: :validate_only }
1219
+ ].freeze
1220
+
1195
1221
  # Runs checkout command to checkout or create branch
1196
1222
  #
1197
1223
  # accepts options:
@@ -1202,18 +1228,16 @@ module Git
1202
1228
  # @param [String] branch
1203
1229
  # @param [Hash] opts
1204
1230
  def checkout(branch = nil, opts = {})
1205
- if branch.is_a?(Hash) && opts == {}
1231
+ if branch.is_a?(Hash) && opts.empty?
1206
1232
  opts = branch
1207
1233
  branch = nil
1208
1234
  end
1235
+ ArgsBuilder.validate!(opts, CHECKOUT_OPTION_MAP)
1209
1236
 
1210
- arr_opts = []
1211
- arr_opts << '-b' if opts[:new_branch] || opts[:b]
1212
- arr_opts << '--force' if opts[:force] || opts[:f]
1213
- arr_opts << branch if branch
1214
- arr_opts << opts[:start_point] if opts[:start_point] && arr_opts.include?('-b')
1237
+ flags = build_args(opts, CHECKOUT_OPTION_MAP)
1238
+ positional_args = build_checkout_positional_args(branch, opts)
1215
1239
 
1216
- command('checkout', *arr_opts)
1240
+ command('checkout', *flags, *positional_args)
1217
1241
  end
1218
1242
 
1219
1243
  def checkout_file(version, file)
@@ -1223,63 +1247,74 @@ module Git
1223
1247
  command('checkout', *arr_opts)
1224
1248
  end
1225
1249
 
1250
+ MERGE_OPTION_MAP = [
1251
+ { keys: [:no_commit], flag: '--no-commit', type: :boolean },
1252
+ { keys: [:no_ff], flag: '--no-ff', type: :boolean },
1253
+ { keys: [:m], flag: '-m', type: :valued_space }
1254
+ ].freeze
1255
+
1226
1256
  def merge(branch, message = nil, opts = {})
1227
- arr_opts = []
1228
- arr_opts << '--no-commit' if opts[:no_commit]
1229
- arr_opts << '--no-ff' if opts[:no_ff]
1230
- arr_opts << '-m' << message if message
1231
- arr_opts += Array(branch)
1232
- command('merge', *arr_opts)
1257
+ # For backward compatibility, treat the message arg as the :m option.
1258
+ opts[:m] = message if message
1259
+ ArgsBuilder.validate!(opts, MERGE_OPTION_MAP)
1260
+
1261
+ args = build_args(opts, MERGE_OPTION_MAP)
1262
+ args.concat(Array(branch))
1263
+
1264
+ command('merge', *args)
1233
1265
  end
1234
1266
 
1267
+ MERGE_BASE_OPTION_MAP = [
1268
+ { keys: [:octopus], flag: '--octopus', type: :boolean },
1269
+ { keys: [:independent], flag: '--independent', type: :boolean },
1270
+ { keys: [:fork_point], flag: '--fork-point', type: :boolean },
1271
+ { keys: [:all], flag: '--all', type: :boolean }
1272
+ ].freeze
1273
+
1235
1274
  def merge_base(*args)
1236
1275
  opts = args.last.is_a?(Hash) ? args.pop : {}
1276
+ ArgsBuilder.validate!(opts, MERGE_BASE_OPTION_MAP)
1237
1277
 
1238
- arg_opts = []
1239
-
1240
- arg_opts << '--octopus' if opts[:octopus]
1241
- arg_opts << '--independent' if opts[:independent]
1242
- arg_opts << '--fork-point' if opts[:fork_point]
1243
- arg_opts << '--all' if opts[:all]
1278
+ flags = build_args(opts, MERGE_BASE_OPTION_MAP)
1279
+ command_args = flags + args
1244
1280
 
1245
- arg_opts += args
1246
-
1247
- command('merge-base', *arg_opts).lines.map(&:strip)
1281
+ command('merge-base', *command_args).lines.map(&:strip)
1248
1282
  end
1249
1283
 
1250
1284
  def unmerged
1251
1285
  unmerged = []
1252
- command_lines('diff', "--cached").each do |line|
1253
- unmerged << $1 if line =~ /^\* Unmerged path (.*)/
1286
+ command_lines('diff', '--cached').each do |line|
1287
+ unmerged << ::Regexp.last_match(1) if line =~ /^\* Unmerged path (.*)/
1254
1288
  end
1255
1289
  unmerged
1256
1290
  end
1257
1291
 
1258
1292
  def conflicts # :yields: file, your, their
1259
- self.unmerged.each do |f|
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
1293
+ unmerged.each do |file_path|
1294
+ Tempfile.create(['YOUR-', File.basename(file_path)]) do |your_file|
1295
+ write_staged_content(file_path, 2, your_file).flush
1267
1296
 
1268
- yield(f, your.path, their.path)
1297
+ Tempfile.create(['THEIR-', File.basename(file_path)]) do |their_file|
1298
+ write_staged_content(file_path, 3, their_file).flush
1299
+ yield(file_path, your_file.path, their_file.path)
1269
1300
  end
1270
1301
  end
1271
1302
  end
1272
1303
  end
1273
1304
 
1305
+ REMOTE_ADD_OPTION_MAP = [
1306
+ { keys: %i[with_fetch fetch], flag: '-f', type: :boolean },
1307
+ { keys: [:track], flag: '-t', type: :valued_space }
1308
+ ].freeze
1309
+
1274
1310
  def remote_add(name, url, opts = {})
1275
- arr_opts = ['add']
1276
- arr_opts << '-f' if opts[:with_fetch] || opts[:fetch]
1277
- arr_opts << '-t' << opts[:track] if opts[:track]
1278
- arr_opts << '--'
1279
- arr_opts << name
1280
- arr_opts << url
1311
+ ArgsBuilder.validate!(opts, REMOTE_ADD_OPTION_MAP)
1281
1312
 
1282
- command('remote', *arr_opts)
1313
+ flags = build_args(opts, REMOTE_ADD_OPTION_MAP)
1314
+ positional_args = ['--', name, url]
1315
+ command_args = ['add'] + flags + positional_args
1316
+
1317
+ command('remote', *command_args)
1283
1318
  end
1284
1319
 
1285
1320
  def remote_set_url(name, url)
@@ -1302,93 +1337,90 @@ module Git
1302
1337
  command_lines('tag')
1303
1338
  end
1304
1339
 
1305
- def tag(name, *opts)
1306
- target = opts[0].instance_of?(String) ? opts[0] : nil
1307
-
1308
- opts = opts.last.instance_of?(Hash) ? opts.last : {}
1340
+ TAG_OPTION_MAP = [
1341
+ { keys: %i[force f], flag: '-f', type: :boolean },
1342
+ { keys: %i[annotate a], flag: '-a', type: :boolean },
1343
+ { keys: %i[sign s], flag: '-s', type: :boolean },
1344
+ { keys: %i[delete d], flag: '-d', type: :boolean },
1345
+ { keys: %i[message m], flag: '-m', type: :valued_space }
1346
+ ].freeze
1309
1347
 
1310
- if (opts[:a] || opts[:annotate]) && !(opts[:m] || opts[:message])
1311
- raise ArgumentError, 'Cannot create an annotated tag without a message.'
1312
- end
1313
-
1314
- arr_opts = []
1348
+ def tag(name, *args)
1349
+ opts = args.last.is_a?(Hash) ? args.pop : {}
1350
+ target = args.first
1315
1351
 
1316
- arr_opts << '-f' if opts[:force] || opts[:f]
1317
- arr_opts << '-a' if opts[:a] || opts[:annotate]
1318
- arr_opts << '-s' if opts[:s] || opts[:sign]
1319
- arr_opts << '-d' if opts[:d] || opts[:delete]
1320
- arr_opts << name
1321
- arr_opts << target if target
1352
+ validate_tag_options!(opts)
1353
+ ArgsBuilder.validate!(opts, TAG_OPTION_MAP)
1322
1354
 
1323
- if opts[:m] || opts[:message]
1324
- arr_opts << '-m' << (opts[:m] || opts[:message])
1325
- end
1355
+ flags = build_args(opts, TAG_OPTION_MAP)
1356
+ positional_args = [name, target].compact
1326
1357
 
1327
- command('tag', *arr_opts)
1358
+ command('tag', *flags, *positional_args)
1328
1359
  end
1329
1360
 
1330
- def fetch(remote, opts)
1331
- arr_opts = []
1332
- arr_opts << '--all' if opts[:all]
1333
- arr_opts << '--tags' if opts[:t] || opts[:tags]
1334
- arr_opts << '--prune' if opts[:p] || opts[:prune]
1335
- arr_opts << '--prune-tags' if opts[:P] || opts[:'prune-tags']
1336
- arr_opts << '--force' if opts[:f] || opts[:force]
1337
- arr_opts << '--update-head-ok' if opts[:u] || opts[:'update-head-ok']
1338
- arr_opts << '--unshallow' if opts[:unshallow]
1339
- arr_opts << '--depth' << opts[:depth] if opts[:depth]
1340
- arr_opts << '--' if remote || opts[:ref]
1341
- arr_opts << remote if remote
1342
- arr_opts << opts[:ref] if opts[:ref]
1343
-
1344
- command('fetch', *arr_opts, merge: true)
1345
- end
1361
+ FETCH_OPTION_MAP = [
1362
+ { keys: [:all], flag: '--all', type: :boolean },
1363
+ { keys: %i[tags t], flag: '--tags', type: :boolean },
1364
+ { keys: %i[prune p], flag: '--prune', type: :boolean },
1365
+ { keys: %i[prune-tags P], flag: '--prune-tags', type: :boolean },
1366
+ { keys: %i[force f], flag: '--force', type: :boolean },
1367
+ { keys: %i[update-head-ok u], flag: '--update-head-ok', type: :boolean },
1368
+ { keys: [:unshallow], flag: '--unshallow', type: :boolean },
1369
+ { keys: [:depth], flag: '--depth', type: :valued_space },
1370
+ { keys: [:ref], type: :validate_only }
1371
+ ].freeze
1346
1372
 
1347
- def push(remote = nil, branch = nil, opts = nil)
1348
- if opts.nil? && branch.instance_of?(Hash)
1349
- opts = branch
1350
- branch = nil
1351
- end
1373
+ def fetch(remote, opts)
1374
+ ArgsBuilder.validate!(opts, FETCH_OPTION_MAP)
1375
+ args = build_args(opts, FETCH_OPTION_MAP)
1352
1376
 
1353
- if opts.nil? && remote.instance_of?(Hash)
1354
- opts = remote
1355
- remote = nil
1377
+ if remote || opts[:ref]
1378
+ args << '--'
1379
+ args << remote if remote
1380
+ args << opts[:ref] if opts[:ref]
1356
1381
  end
1357
1382
 
1358
- opts ||= {}
1383
+ command('fetch', *args, merge: true)
1384
+ end
1359
1385
 
1360
- # Small hack to keep backwards compatibility with the 'push(remote, branch, tags)' method signature.
1361
- opts = {:tags => opts} if [true, false].include?(opts)
1386
+ PUSH_OPTION_MAP = [
1387
+ { keys: [:mirror], flag: '--mirror', type: :boolean },
1388
+ { keys: [:delete], flag: '--delete', type: :boolean },
1389
+ { keys: %i[force f], flag: '--force', type: :boolean },
1390
+ { keys: [:push_option], flag: '--push-option', type: :repeatable_valued_space },
1391
+ { keys: [:all], type: :validate_only }, # For validation purposes
1392
+ { keys: [:tags], type: :validate_only } # From the `push` method's logic
1393
+ ].freeze
1362
1394
 
1363
- raise ArgumentError, "You must specify a remote if a branch is specified" if remote.nil? && !branch.nil?
1395
+ def push(remote = nil, branch = nil, opts = nil)
1396
+ remote, branch, opts = normalize_push_args(remote, branch, opts)
1397
+ ArgsBuilder.validate!(opts, PUSH_OPTION_MAP)
1364
1398
 
1365
- arr_opts = []
1366
- arr_opts << '--mirror' if opts[:mirror]
1367
- arr_opts << '--delete' if opts[:delete]
1368
- arr_opts << '--force' if opts[:force] || opts[:f]
1369
- arr_opts << '--all' if opts[:all] && remote
1399
+ raise ArgumentError, 'remote is required if branch is specified' if !remote && branch
1370
1400
 
1371
- Array(opts[:push_option]).each { |o| arr_opts << '--push-option' << o } if opts[:push_option]
1372
- arr_opts << remote if remote
1373
- arr_opts_with_branch = arr_opts.dup
1374
- arr_opts_with_branch << branch if branch
1401
+ args = build_push_args(remote, branch, opts)
1375
1402
 
1376
1403
  if opts[:mirror]
1377
- command('push', *arr_opts_with_branch)
1404
+ command('push', *args)
1378
1405
  else
1379
- command('push', *arr_opts_with_branch)
1380
- command('push', '--tags', *arr_opts) if opts[:tags]
1406
+ command('push', *args)
1407
+ command('push', '--tags', *(args - [branch].compact)) if opts[:tags]
1381
1408
  end
1382
1409
  end
1383
1410
 
1411
+ PULL_OPTION_MAP = [
1412
+ { keys: [:allow_unrelated_histories], flag: '--allow-unrelated-histories', type: :boolean }
1413
+ ].freeze
1414
+
1384
1415
  def pull(remote = nil, branch = nil, opts = {})
1385
- raise ArgumentError, "You must specify a remote if a branch is specified" if remote.nil? && !branch.nil?
1416
+ raise ArgumentError, 'You must specify a remote if a branch is specified' if remote.nil? && !branch.nil?
1386
1417
 
1387
- arr_opts = []
1388
- arr_opts << '--allow-unrelated-histories' if opts[:allow_unrelated_histories]
1389
- arr_opts << remote if remote
1390
- arr_opts << branch if branch
1391
- command('pull', *arr_opts)
1418
+ ArgsBuilder.validate!(opts, PULL_OPTION_MAP)
1419
+
1420
+ flags = build_args(opts, PULL_OPTION_MAP)
1421
+ positional_args = [remote, branch].compact
1422
+
1423
+ command('pull', *flags, *positional_args)
1392
1424
  end
1393
1425
 
1394
1426
  def tag_sha(tag_name)
@@ -1396,7 +1428,7 @@ module Git
1396
1428
  return File.read(head).chomp if File.exist?(head)
1397
1429
 
1398
1430
  begin
1399
- command('show-ref', '--tags', '-s', tag_name)
1431
+ command('show-ref', '--tags', '-s', tag_name)
1400
1432
  rescue Git::FailedError => e
1401
1433
  raise unless e.result.status.exitstatus == 1 && e.result.stderr == ''
1402
1434
 
@@ -1412,82 +1444,77 @@ module Git
1412
1444
  command('gc', '--prune', '--aggressive', '--auto')
1413
1445
  end
1414
1446
 
1415
- # reads a tree into the current index file
1447
+ READ_TREE_OPTION_MAP = [
1448
+ { keys: [:prefix], flag: '--prefix', type: :valued_equals }
1449
+ ].freeze
1450
+
1416
1451
  def read_tree(treeish, opts = {})
1417
- arr_opts = []
1418
- arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix]
1419
- arr_opts += [treeish]
1420
- command('read-tree', *arr_opts)
1452
+ ArgsBuilder.validate!(opts, READ_TREE_OPTION_MAP)
1453
+ flags = build_args(opts, READ_TREE_OPTION_MAP)
1454
+ command('read-tree', *flags, treeish)
1421
1455
  end
1422
1456
 
1423
1457
  def write_tree
1424
1458
  command('write-tree')
1425
1459
  end
1426
1460
 
1461
+ COMMIT_TREE_OPTION_MAP = [
1462
+ { keys: %i[parent parents], flag: '-p', type: :repeatable_valued_space },
1463
+ { keys: [:message], flag: '-m', type: :valued_space }
1464
+ ].freeze
1465
+
1427
1466
  def commit_tree(tree, opts = {})
1428
1467
  opts[:message] ||= "commit tree #{tree}"
1429
- arr_opts = []
1430
- arr_opts << tree
1431
- arr_opts << '-p' << opts[:parent] if opts[:parent]
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)
1468
+ ArgsBuilder.validate!(opts, COMMIT_TREE_OPTION_MAP)
1469
+
1470
+ flags = build_args(opts, COMMIT_TREE_OPTION_MAP)
1471
+ command('commit-tree', tree, *flags)
1435
1472
  end
1436
1473
 
1437
1474
  def update_ref(ref, commit)
1438
1475
  command('update-ref', ref, commit)
1439
1476
  end
1440
1477
 
1478
+ CHECKOUT_INDEX_OPTION_MAP = [
1479
+ { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1480
+ { keys: [:force], flag: '--force', type: :boolean },
1481
+ { keys: [:all], flag: '--all', type: :boolean },
1482
+ { keys: [:path_limiter], type: :validate_only }
1483
+ ].freeze
1484
+
1441
1485
  def checkout_index(opts = {})
1442
- arr_opts = []
1443
- arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix]
1444
- arr_opts << "--force" if opts[:force]
1445
- arr_opts << "--all" if opts[:all]
1446
- arr_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String
1486
+ ArgsBuilder.validate!(opts, CHECKOUT_INDEX_OPTION_MAP)
1487
+ args = build_args(opts, CHECKOUT_INDEX_OPTION_MAP)
1488
+
1489
+ if (path = opts[:path_limiter]) && path.is_a?(String)
1490
+ args.push('--', path)
1491
+ end
1447
1492
 
1448
- command('checkout-index', *arr_opts)
1493
+ command('checkout-index', *args)
1449
1494
  end
1450
1495
 
1451
- # creates an archive file
1452
- #
1453
- # options
1454
- # :format (zip, tar)
1455
- # :prefix
1456
- # :remote
1457
- # :path
1496
+ ARCHIVE_OPTION_MAP = [
1497
+ { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1498
+ { keys: [:remote], flag: '--remote', type: :valued_equals },
1499
+ # These options are used by helpers or handled manually
1500
+ { keys: [:path], type: :validate_only },
1501
+ { keys: [:format], type: :validate_only },
1502
+ { keys: [:add_gzip], type: :validate_only }
1503
+ ].freeze
1504
+
1458
1505
  def archive(sha, file = nil, opts = {})
1459
- opts[:format] ||= 'zip'
1506
+ ArgsBuilder.validate!(opts, ARCHIVE_OPTION_MAP)
1507
+ file ||= temp_file_name
1508
+ format, gzip = parse_archive_format_options(opts)
1460
1509
 
1461
- if opts[:format] == 'tgz'
1462
- opts[:format] = 'tar'
1463
- opts[:add_gzip] = true
1464
- end
1510
+ args = build_args(opts, ARCHIVE_OPTION_MAP)
1511
+ args.unshift("--format=#{format}")
1512
+ args << sha
1513
+ args.push('--', opts[:path]) if opts[:path]
1465
1514
 
1466
- if !file
1467
- tempfile = Tempfile.new('archive')
1468
- file = tempfile.path
1469
- # delete it now, before we write to it, so that Ruby doesn't delete it
1470
- # when it finalizes the Tempfile.
1471
- tempfile.close!
1472
- end
1515
+ File.open(file, 'wb') { |f| command('archive', *args, out: f) }
1516
+ apply_gzip(file) if gzip
1473
1517
 
1474
- arr_opts = []
1475
- arr_opts << "--format=#{opts[:format]}" if opts[:format]
1476
- arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix]
1477
- arr_opts << "--remote=#{opts[:remote]}" if opts[:remote]
1478
- arr_opts << sha
1479
- arr_opts << '--' << opts[:path] if opts[:path]
1480
-
1481
- f = File.open(file, 'wb')
1482
- command('archive', *arr_opts, out: f)
1483
- f.close
1484
-
1485
- if opts[:add_gzip]
1486
- file_content = File.read(file)
1487
- Zlib::GzipWriter.open(file) do |gz|
1488
- gz.write(file_content)
1489
- end
1490
- end
1491
1518
  file
1492
1519
  end
1493
1520
 
@@ -1495,7 +1522,7 @@ module Git
1495
1522
  def current_command_version
1496
1523
  output = command('version')
1497
1524
  version = output[/\d+(\.\d+)+/]
1498
- version_parts = version.split('.').collect { |i| i.to_i }
1525
+ version_parts = version.split('.').collect(&:to_i)
1499
1526
  version_parts.fill(0, version_parts.length...3)
1500
1527
  end
1501
1528
 
@@ -1520,27 +1547,331 @@ module Git
1520
1547
  end
1521
1548
 
1522
1549
  def meets_required_version?
1523
- (self.current_command_version <=> self.required_command_version) >= 0
1550
+ (current_command_version <=> required_command_version) >= 0
1524
1551
  end
1525
1552
 
1526
- def self.warn_if_old_command(lib)
1553
+ def self.warn_if_old_command(lib) # rubocop:disable Naming/PredicateMethod
1554
+ Git::Deprecation.warn('Git::Lib#warn_if_old_command is deprecated. Use meets_required_version?.')
1555
+
1527
1556
  return true if @version_checked
1557
+
1528
1558
  @version_checked = true
1529
1559
  unless lib.meets_required_version?
1530
- $stderr.puts "[WARNING] The git gem requires git #{lib.required_command_version.join('.')} or later, but only found #{lib.current_command_version.join('.')}. You should probably upgrade."
1560
+ warn "[WARNING] The git gem requires git #{lib.required_command_version.join('.')} or later, " \
1561
+ "but only found #{lib.current_command_version.join('.')}. You should probably upgrade."
1531
1562
  end
1532
1563
  true
1533
1564
  end
1534
1565
 
1566
+ COMMAND_ARG_DEFAULTS = {
1567
+ out: nil,
1568
+ err: nil,
1569
+ normalize: true,
1570
+ chomp: true,
1571
+ merge: false,
1572
+ chdir: nil,
1573
+ timeout: nil # Don't set to Git.config.timeout here since it is mutable
1574
+ }.freeze
1575
+
1576
+ STATIC_GLOBAL_OPTS = %w[
1577
+ -c core.quotePath=true
1578
+ -c color.ui=false
1579
+ -c color.advice=false
1580
+ -c color.diff=false
1581
+ -c color.grep=false
1582
+ -c color.push=false
1583
+ -c color.remote=false
1584
+ -c color.showBranch=false
1585
+ -c color.status=false
1586
+ -c color.transport=false
1587
+ ].freeze
1588
+
1589
+ LOG_OPTION_MAP = [
1590
+ { type: :static, flag: '--no-color' },
1591
+ { keys: [:all], flag: '--all', type: :boolean },
1592
+ { keys: [:cherry], flag: '--cherry', type: :boolean },
1593
+ { keys: [:since], flag: '--since', type: :valued_equals },
1594
+ { keys: [:until], flag: '--until', type: :valued_equals },
1595
+ { keys: [:grep], flag: '--grep', type: :valued_equals },
1596
+ { keys: [:author], flag: '--author', type: :valued_equals },
1597
+ { keys: [:count], flag: '--max-count', type: :valued_equals },
1598
+ { keys: [:between], type: :custom, builder: ->(value) { "#{value[0]}..#{value[1]}" if value } }
1599
+ ].freeze
1600
+
1535
1601
  private
1536
1602
 
1603
+ def parse_diff_path_status(args)
1604
+ command_lines('diff', *args).each_with_object({}) do |line, memo|
1605
+ status, path = line.split("\t")
1606
+ memo[path] = status
1607
+ end
1608
+ end
1609
+
1610
+ def build_checkout_positional_args(branch, opts)
1611
+ args = []
1612
+ if opts[:new_branch] || opts[:b]
1613
+ args.push('-b', branch)
1614
+ args << opts[:start_point] if opts[:start_point]
1615
+ elsif branch
1616
+ args << branch
1617
+ end
1618
+ args
1619
+ end
1620
+
1621
+ def build_args(opts, option_map)
1622
+ Git::ArgsBuilder.new(opts, option_map).build
1623
+ end
1624
+
1625
+ def initialize_from_base(base_object)
1626
+ @git_dir = base_object.repo.path
1627
+ @git_index_file = base_object.index&.path
1628
+ @git_work_dir = base_object.dir&.path
1629
+ end
1630
+
1631
+ def initialize_from_hash(base_hash)
1632
+ @git_dir = base_hash[:repository]
1633
+ @git_index_file = base_hash[:index]
1634
+ @git_work_dir = base_hash[:working_directory]
1635
+ end
1636
+
1637
+ def return_base_opts_from_clone(clone_dir, opts)
1638
+ base_opts = {}
1639
+ base_opts[:repository] = clone_dir if opts[:bare] || opts[:mirror]
1640
+ base_opts[:working_directory] = clone_dir unless opts[:bare] || opts[:mirror]
1641
+ base_opts[:log] = opts[:log] if opts[:log]
1642
+ base_opts
1643
+ end
1644
+
1645
+ def process_commit_headers(data)
1646
+ headers = { 'parent' => [] } # Pre-initialize for multiple parents
1647
+ each_cat_file_header(data) do |key, value|
1648
+ if key == 'parent'
1649
+ headers['parent'] << value
1650
+ else
1651
+ headers[key] = value
1652
+ end
1653
+ end
1654
+ headers
1655
+ end
1656
+
1657
+ def parse_branch_line(line, index, all_lines)
1658
+ match_data = match_branch_line(line, index, all_lines)
1659
+
1660
+ return nil if match_data[:not_a_branch] || match_data[:detached_ref]
1661
+
1662
+ format_branch_data(match_data)
1663
+ end
1664
+
1665
+ def match_branch_line(line, index, all_lines)
1666
+ match_data = line.match(BRANCH_LINE_REGEXP)
1667
+ raise Git::UnexpectedResultError, unexpected_branch_line_error(all_lines, line, index) unless match_data
1668
+
1669
+ match_data
1670
+ end
1671
+
1672
+ def format_branch_data(match_data)
1673
+ [
1674
+ match_data[:refname],
1675
+ !match_data[:current].nil?,
1676
+ !match_data[:worktree].nil?,
1677
+ match_data[:symref]
1678
+ ]
1679
+ end
1680
+
1681
+ def unexpected_branch_line_error(lines, line, index)
1682
+ <<~ERROR
1683
+ Unexpected line in output from `git branch -a`, line #{index + 1}
1684
+
1685
+ Full output:
1686
+ #{lines.join("\n ")}
1687
+
1688
+ Line #{index + 1}:
1689
+ "#{line}"
1690
+ ERROR
1691
+ end
1692
+
1693
+ def get_branch_state(branch_name)
1694
+ command('rev-parse', '--verify', '--quiet', branch_name)
1695
+ :active
1696
+ rescue Git::FailedError => e
1697
+ # An exit status of 1 with empty stderr from `rev-parse --verify`
1698
+ # indicates a ref that exists but does not yet point to a commit.
1699
+ raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
1700
+
1701
+ :unborn
1702
+ end
1703
+
1704
+ def execute_grep_command(args)
1705
+ command_lines('grep', *args)
1706
+ rescue Git::FailedError => e
1707
+ # `git grep` returns 1 when no lines are selected.
1708
+ raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
1709
+
1710
+ [] # Return an empty array for "no matches found"
1711
+ end
1712
+
1713
+ def parse_grep_output(lines)
1714
+ lines.each_with_object(Hash.new { |h, k| h[k] = [] }) do |line, hsh|
1715
+ match = line.match(/\A(.*?):(\d+):(.*)/)
1716
+ next unless match
1717
+
1718
+ _full, filename, line_num, text = match.to_a
1719
+ hsh[filename] << [line_num.to_i, text]
1720
+ end
1721
+ end
1722
+
1723
+ def parse_diff_stats_output(lines)
1724
+ file_stats = parse_stat_lines(lines)
1725
+ build_final_stats_hash(file_stats)
1726
+ end
1727
+
1728
+ def parse_stat_lines(lines)
1729
+ lines.map do |line|
1730
+ insertions_s, deletions_s, filename = line.split("\t")
1731
+ {
1732
+ filename: filename,
1733
+ insertions: insertions_s.to_i,
1734
+ deletions: deletions_s.to_i
1735
+ }
1736
+ end
1737
+ end
1738
+
1739
+ def build_final_stats_hash(file_stats)
1740
+ {
1741
+ total: build_total_stats(file_stats),
1742
+ files: build_files_hash(file_stats)
1743
+ }
1744
+ end
1745
+
1746
+ def build_total_stats(file_stats)
1747
+ insertions = file_stats.sum { |s| s[:insertions] }
1748
+ deletions = file_stats.sum { |s| s[:deletions] }
1749
+ {
1750
+ insertions: insertions,
1751
+ deletions: deletions,
1752
+ lines: insertions + deletions,
1753
+ files: file_stats.size
1754
+ }
1755
+ end
1756
+
1757
+ def build_files_hash(file_stats)
1758
+ file_stats.to_h { |s| [s[:filename], s.slice(:insertions, :deletions)] }
1759
+ end
1760
+
1761
+ def parse_ls_remote_output(lines)
1762
+ lines.each_with_object(Hash.new { |h, k| h[k] = {} }) do |line, hsh|
1763
+ type, name, value = parse_ls_remote_line(line)
1764
+ if name
1765
+ hsh[type][name] = value
1766
+ else # Handles the HEAD entry, which has no name
1767
+ hsh[type].update(value)
1768
+ end
1769
+ end
1770
+ end
1771
+
1772
+ def parse_ls_remote_line(line)
1773
+ sha, info = line.split("\t", 2)
1774
+ ref, type, name = info.split('/', 3)
1775
+
1776
+ type ||= 'head'
1777
+ type = 'branches' if type == 'heads'
1778
+
1779
+ value = { ref: ref, sha: sha }
1780
+
1781
+ [type, name, value]
1782
+ end
1783
+
1784
+ def stash_log_lines
1785
+ path = File.join(@git_dir, 'logs/refs/stash')
1786
+ return [] unless File.exist?(path)
1787
+
1788
+ File.readlines(path, chomp: true)
1789
+ end
1790
+
1791
+ def parse_stash_log_line(line, index)
1792
+ full_message = line.split("\t", 2).last
1793
+ match_data = full_message.match(/^[^:]+:(.*)$/)
1794
+ message = match_data ? match_data[1] : full_message
1795
+
1796
+ [index, message.strip]
1797
+ end
1798
+
1799
+ # Writes the staged content of a conflicted file to an IO stream
1800
+ #
1801
+ # @param path [String] the path to the file in the index
1802
+ #
1803
+ # @param stage [Integer] the stage of the file to show (e.g., 2 for 'ours', 3 for 'theirs')
1804
+ #
1805
+ # @param out_io [IO] the IO object to write the staged content to
1806
+ #
1807
+ # @return [IO] the IO object that was written to
1808
+ #
1809
+ def write_staged_content(path, stage, out_io)
1810
+ command('show', ":#{stage}:#{path}", out: out_io)
1811
+ out_io
1812
+ end
1813
+
1814
+ def validate_tag_options!(opts)
1815
+ is_annotated = opts[:a] || opts[:annotate]
1816
+ has_message = opts[:m] || opts[:message]
1817
+
1818
+ return unless is_annotated && !has_message
1819
+
1820
+ raise ArgumentError, 'Cannot create an annotated tag without a message.'
1821
+ end
1822
+
1823
+ def normalize_push_args(remote, branch, opts)
1824
+ if branch.is_a?(Hash)
1825
+ opts = branch
1826
+ branch = nil
1827
+ elsif remote.is_a?(Hash)
1828
+ opts = remote
1829
+ remote = nil
1830
+ end
1831
+
1832
+ opts ||= {}
1833
+ # Backwards compatibility for `push(remote, branch, true)`
1834
+ opts = { tags: opts } if [true, false].include?(opts)
1835
+ [remote, branch, opts]
1836
+ end
1837
+
1838
+ def build_push_args(remote, branch, opts)
1839
+ # Build the simple flags using the ArgsBuilder
1840
+ args = build_args(opts, PUSH_OPTION_MAP)
1841
+
1842
+ # Manually handle the flag with external dependencies and positional args
1843
+ args << '--all' if opts[:all] && remote
1844
+ args << remote if remote
1845
+ args << branch if branch
1846
+ args
1847
+ end
1848
+
1849
+ def temp_file_name
1850
+ tempfile = Tempfile.new('archive')
1851
+ file = tempfile.path
1852
+ tempfile.close! # Prevents Ruby from deleting the file on garbage collection
1853
+ file
1854
+ end
1855
+
1856
+ def parse_archive_format_options(opts)
1857
+ format = opts[:format] || 'zip'
1858
+ gzip = opts[:add_gzip] == true || format == 'tgz'
1859
+ format = 'tar' if format == 'tgz'
1860
+ [format, gzip]
1861
+ end
1862
+
1863
+ def apply_gzip(file)
1864
+ file_content = File.read(file)
1865
+ Zlib::GzipWriter.open(file) { |gz| gz.write(file_content) }
1866
+ end
1867
+
1537
1868
  def command_lines(cmd, *opts, chdir: nil)
1538
1869
  cmd_op = command(cmd, *opts, chdir: chdir)
1539
- if cmd_op.encoding.name != "UTF-8"
1540
- op = cmd_op.encode("UTF-8", "binary", :invalid => :replace, :undef => :replace)
1541
- else
1542
- op = cmd_op
1543
- end
1870
+ op = if cmd_op.encoding.name == 'UTF-8'
1871
+ cmd_op
1872
+ else
1873
+ cmd_op.encode('UTF-8', 'binary', invalid: :replace, undef: :replace)
1874
+ end
1544
1875
  op.split("\n")
1545
1876
  end
1546
1877
 
@@ -1555,19 +1886,10 @@ module Git
1555
1886
  end
1556
1887
 
1557
1888
  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'
1889
+ [].tap do |global_opts|
1890
+ global_opts << "--git-dir=#{@git_dir}" unless @git_dir.nil?
1891
+ global_opts << "--work-tree=#{@git_work_dir}" unless @git_work_dir.nil?
1892
+ global_opts.concat(STATIC_GLOBAL_OPTS)
1571
1893
  end
1572
1894
  end
1573
1895
 
@@ -1578,28 +1900,21 @@ module Git
1578
1900
 
1579
1901
  # Runs a git command and returns the output
1580
1902
  #
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
1903
+ # Additional args are passed to the command line. They should exclude the 'git'
1904
+ # command itself and global options. Remember to splat the the arguments if given
1905
+ # as an array.
1599
1906
  #
1600
- # @param chdir [String, nil] the directory to run the command in
1907
+ # For example, to run `git log --pretty=oneline`, you would create the array
1908
+ # `args = ['log', '--pretty=oneline']` and call `command(*args)`.
1601
1909
  #
1602
- # @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete
1910
+ # @param options_hash [Hash] the options to pass to the command
1911
+ # @option options_hash [IO, String, #write, nil] :out the destination for captured stdout
1912
+ # @option options_hash [IO, String, #write, nil] :err the destination for captured stderr
1913
+ # @option options_hash [Boolean] :normalize true to normalize the output encoding to UTF-8
1914
+ # @option options_hash [Boolean] :chomp true to remove trailing newlines from the output
1915
+ # @option options_hash [Boolean] :merge true to merge stdout and stderr into a single output
1916
+ # @option options_hash [String, nil] :chdir the directory to run the command in
1917
+ # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command to complete
1603
1918
  #
1604
1919
  # If timeout is nil, the global timeout from {Git::Config} is used.
1605
1920
  #
@@ -1614,9 +1929,14 @@ module Git
1614
1929
  # @return [String] the command's stdout (or merged stdout and stderr if `merge`
1615
1930
  # is true)
1616
1931
  #
1932
+ # @raise [ArgumentError] if an unknown option is passed
1933
+ #
1617
1934
  # @raise [Git::FailedError] if the command failed
1935
+ #
1618
1936
  # @raise [Git::SignaledError] if the command was signaled
1937
+ #
1619
1938
  # @raise [Git::TimeoutError] if the command times out
1939
+ #
1620
1940
  # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
1621
1941
  #
1622
1942
  # The exception's `result` attribute is a {Git::CommandLineResult} which will
@@ -1625,9 +1945,14 @@ module Git
1625
1945
  #
1626
1946
  # @api private
1627
1947
  #
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)
1948
+ def command(*, **options_hash)
1949
+ options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
1950
+ options_hash[:timeout] ||= Git.config.timeout
1951
+
1952
+ extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
1953
+ raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
1954
+
1955
+ result = command_line.run(*, **options_hash)
1631
1956
  result.stdout
1632
1957
  end
1633
1958
 
@@ -1636,23 +1961,18 @@ module Git
1636
1961
  # @param [String] diff_command the diff commadn to be used
1637
1962
  # @param [Array] opts the diff options to be used
1638
1963
  # @return [Hash] the diff as Hash
1639
- def diff_as_hash(diff_command, opts=[])
1964
+ def diff_as_hash(diff_command, opts = [])
1640
1965
  # update index before diffing to avoid spurious diffs
1641
1966
  command('status')
1642
- command_lines(diff_command, *opts).inject({}) do |memo, line|
1967
+ command_lines(diff_command, *opts).each_with_object({}) do |line, memo|
1643
1968
  info, file = line.split("\t")
1644
1969
  mode_src, mode_dest, sha_src, sha_dest, type = info.split
1645
1970
 
1646
1971
  memo[file] = {
1647
- :mode_index => mode_dest,
1648
- :mode_repo => mode_src.to_s[1, 7],
1649
- :path => file,
1650
- :sha_repo => sha_src,
1651
- :sha_index => sha_dest,
1652
- :type => type
1972
+ mode_index: mode_dest, mode_repo: mode_src.to_s[1, 7],
1973
+ path: file, sha_repo: sha_src, sha_index: sha_dest,
1974
+ type: type
1653
1975
  }
1654
-
1655
- memo
1656
1976
  end
1657
1977
  end
1658
1978
 
@@ -1661,23 +1981,11 @@ module Git
1661
1981
  # @param [Hash] opts the given options
1662
1982
  # @return [Array] the set of common options that the log command will use
1663
1983
  def log_common_options(opts)
1664
- arr_opts = []
1665
-
1666
1984
  if opts[:count] && !opts[:count].is_a?(Integer)
1667
1985
  raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}"
1668
1986
  end
1669
1987
 
1670
- arr_opts << "--max-count=#{opts[:count]}" if opts[:count]
1671
- arr_opts << "--all" if opts[:all]
1672
- arr_opts << "--no-color"
1673
- arr_opts << "--cherry" if opts[:cherry]
1674
- arr_opts << "--since=#{opts[:since]}" if opts[:since].is_a? String
1675
- arr_opts << "--until=#{opts[:until]}" if opts[:until].is_a? String
1676
- arr_opts << "--grep=#{opts[:grep]}" if opts[:grep].is_a? String
1677
- arr_opts << "--author=#{opts[:author]}" if opts[:author].is_a? String
1678
- arr_opts << "#{opts[:between][0].to_s}..#{opts[:between][1].to_s}" if (opts[:between] && opts[:between].size == 2)
1679
-
1680
- arr_opts
1988
+ build_args(opts, LOG_OPTION_MAP)
1681
1989
  end
1682
1990
 
1683
1991
  # Retrurns an array holding path options for the log commands