git 1.19.1 → 4.4.5

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.commitlintrc.yml +38 -0
  3. data/.github/copilot-instructions.md +2733 -0
  4. data/.github/pull_request_template.md +17 -0
  5. data/.github/workflows/continuous_integration.yml +92 -21
  6. data/.github/workflows/enforce_conventional_commits.yml +29 -0
  7. data/.github/workflows/experimental_continuous_integration.yml +59 -0
  8. data/.github/workflows/release.yml +53 -0
  9. data/.gitignore +5 -0
  10. data/.husky/commit-msg +1 -0
  11. data/.release-please-manifest.json +3 -0
  12. data/.rubocop.yml +55 -0
  13. data/.rubocop_todo.yml +12 -0
  14. data/.yardopts +4 -1
  15. data/AI_POLICY.md +24 -0
  16. data/CHANGELOG.md +501 -0
  17. data/CODE_OF_CONDUCT.md +25 -0
  18. data/CONTRIBUTING.md +323 -102
  19. data/GOVERNANCE.md +106 -0
  20. data/LICENSE +1 -1
  21. data/MAINTAINERS.md +17 -4
  22. data/README.md +575 -246
  23. data/Rakefile +13 -55
  24. data/git.gemspec +36 -30
  25. data/lib/git/args_builder.rb +111 -0
  26. data/lib/git/author.rb +9 -7
  27. data/lib/git/base.rb +602 -173
  28. data/lib/git/branch.rb +318 -38
  29. data/lib/git/branches.rb +21 -24
  30. data/lib/git/command_line.rb +330 -0
  31. data/lib/git/command_line_result.rb +9 -3
  32. data/lib/git/config.rb +10 -6
  33. data/lib/git/diff.rb +149 -81
  34. data/lib/git/diff_path_status.rb +46 -0
  35. data/lib/git/diff_stats.rb +59 -0
  36. data/lib/git/errors.rb +212 -0
  37. data/lib/git/escaped_path.rb +2 -2
  38. data/lib/git/fsck_object.rb +48 -0
  39. data/lib/git/fsck_result.rb +121 -0
  40. data/lib/git/index.rb +2 -1
  41. data/lib/git/lib.rb +1648 -643
  42. data/lib/git/log.rb +143 -106
  43. data/lib/git/object.rb +151 -125
  44. data/lib/git/path.rb +23 -16
  45. data/lib/git/remote.rb +5 -4
  46. data/lib/git/repository.rb +2 -2
  47. data/lib/git/stash.rb +11 -12
  48. data/lib/git/stashes.rb +16 -15
  49. data/lib/git/status.rb +104 -143
  50. data/lib/git/url.rb +3 -3
  51. data/lib/git/version.rb +3 -1
  52. data/lib/git/working_directory.rb +2 -0
  53. data/lib/git/worktree.rb +6 -5
  54. data/lib/git/worktrees.rb +6 -6
  55. data/lib/git.rb +131 -28
  56. data/package.json +10 -0
  57. data/redesign/1_architecture_existing.md +66 -0
  58. data/redesign/2_architecture_redesign.md +130 -0
  59. data/redesign/3_architecture_implementation.md +138 -0
  60. data/redesign/index.md +34 -0
  61. data/release-please-config.json +36 -0
  62. data/tasks/gem_tasks.rake +10 -0
  63. data/tasks/rubocop.rake +12 -0
  64. data/tasks/test.rake +13 -0
  65. data/tasks/test_gem.rake +12 -0
  66. data/tasks/yard.rake +23 -0
  67. metadata +114 -37
  68. data/.github/stale.yml +0 -25
  69. data/Dockerfile.changelog-rs +0 -12
  70. data/PULL_REQUEST_TEMPLATE.md +0 -9
  71. data/RELEASING.md +0 -70
  72. data/lib/git/base/factory.rb +0 -99
  73. data/lib/git/failed_error.rb +0 -53
  74. data/lib/git/git_execute_error.rb +0 -7
  75. data/lib/git/signaled_error.rb +0 -50
  76. /data/{ISSUE_TEMPLATE.md → .github/issue_template.md} +0 -0
data/lib/git/lib.rb CHANGED
@@ -1,14 +1,21 @@
1
- require 'git/failed_error'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'args_builder'
4
+
5
+ require 'git/command_line'
6
+ require 'git/errors'
2
7
  require 'logger'
8
+ require 'pathname'
9
+ require 'pp'
10
+ require 'process_executer'
11
+ require 'stringio'
3
12
  require 'tempfile'
4
13
  require 'zlib'
5
- require 'open3'
6
14
 
7
15
  module Git
16
+ # Internal git operations
17
+ # @api private
8
18
  class Lib
9
-
10
- @@semaphore = Mutex.new
11
-
12
19
  # The path to the Git working copy. The default is '"./.git"'.
13
20
  #
14
21
  # @return [Pathname] the path to the Git working copy.
@@ -37,33 +44,41 @@ module Git
37
44
 
38
45
  # Create a new Git::Lib object
39
46
  #
40
- # @param [Git::Base, Hash] base An object that passes in values for
41
- # @git_work_dir, @git_dir, and @git_index_file
47
+ # @overload initialize(base, logger)
48
+ #
49
+ # @param base [Hash] the hash containing paths to the Git working copy,
50
+ # the Git repository directory, and the Git index file.
51
+ #
52
+ # @option base [Pathname] :working_directory
53
+ # @option base [Pathname] :repository
54
+ # @option base [Pathname] :index
55
+ #
56
+ # @param [Logger] logger
57
+ #
58
+ # @overload initialize(base, logger)
42
59
  #
43
- # @param [Logger] logger
60
+ # @param base [#dir, #repo, #index] an object with methods to get the Git worktree (#dir),
61
+ # the Git repository directory (#repo), and the Git index file (#index).
44
62
  #
45
- # @option base [Pathname] :working_directory
46
- # @option base [Pathname] :repository
47
- # @option base [Pathname] :index
63
+ # @param [Logger] logger
48
64
  #
49
65
  def initialize(base = nil, logger = nil)
50
- @git_dir = nil
51
- @git_index_file = nil
52
- @git_work_dir = nil
53
- @path = nil
54
66
  @logger = logger || Logger.new(nil)
67
+ @git_ssh = :use_global_config
55
68
 
56
- if base.is_a?(Git::Base)
57
- @git_dir = base.repo.path
58
- @git_index_file = base.index.path if base.index
59
- @git_work_dir = base.dir.path if base.dir
60
- elsif base.is_a?(Hash)
61
- @git_dir = base[:repository]
62
- @git_index_file = base[:index]
63
- @git_work_dir = base[:working_directory]
69
+ case base
70
+ when Git::Base
71
+ initialize_from_base(base)
72
+ when Hash
73
+ initialize_from_hash(base)
64
74
  end
65
75
  end
66
76
 
77
+ INIT_OPTION_MAP = [
78
+ { keys: [:bare], flag: '--bare', type: :boolean },
79
+ { keys: [:initial_branch], flag: '--initial-branch', type: :valued_equals }
80
+ ].freeze
81
+
67
82
  # creates or reinitializes the repository
68
83
  #
69
84
  # options:
@@ -71,62 +86,94 @@ module Git
71
86
  # :working_directory
72
87
  # :initial_branch
73
88
  #
74
- def init(opts={})
75
- arr_opts = []
76
- arr_opts << '--bare' if opts[:bare]
77
- arr_opts << "--initial-branch=#{opts[:initial_branch]}" if opts[:initial_branch]
78
-
79
- command('init', *arr_opts)
80
- end
89
+ def init(opts = {})
90
+ args = build_args(opts, INIT_OPTION_MAP)
91
+ command('init', *args)
92
+ end
93
+
94
+ CLONE_OPTION_MAP = [
95
+ { keys: [:bare], flag: '--bare', type: :boolean },
96
+ { keys: [:recursive], flag: '--recursive', type: :boolean },
97
+ { keys: [:mirror], flag: '--mirror', type: :boolean },
98
+ { keys: [:branch], flag: '--branch', type: :valued_space },
99
+ { keys: [:filter], flag: '--filter', type: :valued_space },
100
+ { keys: %i[remote origin], flag: '--origin', type: :valued_space },
101
+ { keys: [:config], flag: '--config', type: :repeatable_valued_space },
102
+ {
103
+ keys: [:single_branch],
104
+ type: :custom,
105
+ validator: ->(value) { [nil, true, false].include?(value) },
106
+ builder: lambda do |value|
107
+ case value
108
+ when true
109
+ ['--single-branch']
110
+ when false
111
+ ['--no-single-branch']
112
+ else
113
+ []
114
+ end
115
+ end
116
+ },
117
+ {
118
+ keys: [:depth],
119
+ type: :custom,
120
+ builder: ->(value) { ['--depth', value.to_i] if value }
121
+ }
122
+ ].freeze
81
123
 
82
- # tries to clone the given repo
124
+ # Clones a repository into a newly created directory
83
125
  #
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.
126
+ # @param [String] repository_url the URL of the repository to clone
127
+ #
128
+ # @param [String, nil] directory the directory to clone into
129
+ #
130
+ # If nil, the repository is cloned into a directory with the same name as
131
+ # the repository.
132
+ #
133
+ # @param [Hash] opts the options for this command
134
+ #
135
+ # @option opts [Boolean] :bare (false) if true, clone as a bare repository
136
+ #
137
+ # @option opts [String] :branch the branch to checkout
138
+ #
139
+ # @option opts [String, Array] :config one or more configuration options to set
140
+ #
141
+ # @option opts [Integer] :depth the number of commits back to pull
93
142
  #
94
- # TODO - make this work with SSH password or auth_key
143
+ # @option opts [String] :filter specify partial clone
144
+ #
145
+ # @option opts [String] :mirror set up a mirror of the source repository
146
+ #
147
+ # @option opts [String] :origin the name of the remote
148
+ #
149
+ # @option opts [String] :path an optional prefix for the directory parameter
150
+ #
151
+ # @option opts [String] :remote the name of the remote
152
+ #
153
+ # @option opts [Boolean] :recursive after the clone is created, initialize all
154
+ # within, using their default settings
155
+ #
156
+ # @option opts [Numeric, nil] :timeout the number of seconds to wait for the
157
+ # command to complete
158
+ #
159
+ # See {Git::Lib#command} for more information about :timeout
95
160
  #
96
161
  # @return [Hash] the options to pass to {Git::Base.new}
97
162
  #
163
+ # @todo make this work with SSH password or auth_key
164
+ #
98
165
  def clone(repository_url, directory, opts = {})
99
166
  @path = opts[:path] || '.'
100
167
  clone_dir = opts[:path] ? File.join(@path, directory) : directory
101
168
 
102
- arr_opts = []
103
- arr_opts << '--bare' if opts[:bare]
104
- arr_opts << '--branch' << opts[:branch] if opts[:branch]
105
- arr_opts << '--depth' << opts[:depth].to_i if opts[:depth] && opts[:depth].to_i > 0
106
- arr_opts << '--filter' << opts[:filter] if opts[:filter]
107
- Array(opts[:config]).each { |c| arr_opts << '--config' << c }
108
- arr_opts << '--origin' << opts[:remote] || opts[:origin] if opts[:remote] || opts[:origin]
109
- arr_opts << '--recursive' if opts[:recursive]
110
- arr_opts << '--mirror' if opts[:mirror]
111
-
112
- arr_opts << '--'
169
+ args = build_args(opts, CLONE_OPTION_MAP)
170
+ args.push('--', repository_url, clone_dir)
113
171
 
114
- arr_opts << repository_url
115
- arr_opts << clone_dir
116
-
117
- command('clone', *arr_opts)
172
+ command('clone', *args, timeout: opts[:timeout])
118
173
 
119
174
  return_base_opts_from_clone(clone_dir, opts)
120
175
  end
121
176
 
122
- def return_base_opts_from_clone(clone_dir, opts)
123
- base_opts = {}
124
- base_opts[:repository] = clone_dir if (opts[:bare] || opts[:mirror])
125
- base_opts[:working_directory] = clone_dir unless (opts[:bare] || opts[:mirror])
126
- base_opts[:log] = opts[:log] if opts[:log]
127
- base_opts
128
- end
129
-
130
177
  # Returns the name of the default branch of the given repository
131
178
  #
132
179
  # @param repository [URI, Pathname, String] The (possibly remote) repository to clone from
@@ -142,124 +189,331 @@ module Git
142
189
  match_data = output.match(%r{^ref: refs/heads/(?<default_branch>[^\t]+)\tHEAD$})
143
190
  return match_data[:default_branch] if match_data
144
191
 
145
- raise 'Unable to determine the default branch'
192
+ raise Git::UnexpectedResultError, 'Unable to determine the default branch'
146
193
  end
147
194
 
148
195
  ## READ COMMANDS ##
149
196
 
197
+ # The map defining how to translate user options to git command arguments.
198
+ DESCRIBE_OPTION_MAP = [
199
+ { keys: [:all], flag: '--all', type: :boolean },
200
+ { keys: [:tags], flag: '--tags', type: :boolean },
201
+ { keys: [:contains], flag: '--contains', type: :boolean },
202
+ { keys: [:debug], flag: '--debug', type: :boolean },
203
+ { keys: [:long], flag: '--long', type: :boolean },
204
+ { keys: [:always], flag: '--always', type: :boolean },
205
+ { keys: %i[exact_match exact-match], flag: '--exact-match', type: :boolean },
206
+ { keys: [:abbrev], flag: '--abbrev', type: :valued_equals },
207
+ { keys: [:candidates], flag: '--candidates', type: :valued_equals },
208
+ { keys: [:match], flag: '--match', type: :valued_equals },
209
+ {
210
+ keys: [:dirty],
211
+ type: :custom,
212
+ builder: lambda do |value|
213
+ return '--dirty' if value == true
214
+
215
+ "--dirty=#{value}" if value.is_a?(String)
216
+ end
217
+ }
218
+ ].freeze
219
+
220
+ # Finds most recent tag that is reachable from a commit
150
221
  #
151
- # Returns most recent tag that is reachable from a commit
222
+ # @see https://git-scm.com/docs/git-describe git-describe
152
223
  #
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={})
171
- arr_opts = []
172
-
173
- arr_opts << '--all' if opts[:all]
174
- arr_opts << '--tags' if opts[:tags]
175
- arr_opts << '--contains' if opts[:contains]
176
- arr_opts << '--debug' if opts[:debug]
177
- arr_opts << '--long' if opts[:long]
178
- arr_opts << '--always' if opts[:always]
179
- arr_opts << '--exact-match' if opts[:exact_match] || opts[:"exact-match"]
180
-
181
- arr_opts << '--dirty' if opts[:dirty] == true
182
- arr_opts << "--dirty=#{opts[:dirty]}" if opts[:dirty].is_a?(String)
183
-
184
- arr_opts << "--abbrev=#{opts[:abbrev]}" if opts[:abbrev]
185
- arr_opts << "--candidates=#{opts[:candidates]}" if opts[:candidates]
186
- arr_opts << "--match=#{opts[:match]}" if opts[:match]
224
+ # @param commit_ish [String, nil] target commit sha or object name
225
+ #
226
+ # @param opts [Hash] the given options
227
+ #
228
+ # @option opts :all [Boolean]
229
+ # @option opts :tags [Boolean]
230
+ # @option opts :contains [Boolean]
231
+ # @option opts :debug [Boolean]
232
+ # @option opts :long [Boolean]
233
+ # @option opts :always [Boolean]
234
+ # @option opts :exact_match [Boolean]
235
+ # @option opts :dirty [true, String]
236
+ # @option opts :abbrev [String]
237
+ # @option opts :candidates [String]
238
+ # @option opts :match [String]
239
+ #
240
+ # @return [String] the tag name
241
+ #
242
+ # @raise [ArgumentError] if the commit_ish is a string starting with a hyphen
243
+ #
244
+ def describe(commit_ish = nil, opts = {})
245
+ assert_args_are_not_options('commit-ish object', commit_ish)
187
246
 
188
- arr_opts << committish if committish
247
+ args = build_args(opts, DESCRIBE_OPTION_MAP)
248
+ args << commit_ish if commit_ish
189
249
 
190
- return command('describe', *arr_opts)
250
+ command('describe', *args)
191
251
  end
192
252
 
193
- def log_commits(opts={})
253
+ # Return the commits that are within the given revision range
254
+ #
255
+ # @see https://git-scm.com/docs/git-log git-log
256
+ #
257
+ # @param opts [Hash] the given options
258
+ #
259
+ # @option opts :count [Integer] the maximum number of commits to return (maps to max-count)
260
+ # @option opts :all [Boolean]
261
+ # @option opts :cherry [Boolean]
262
+ # @option opts :since [String]
263
+ # @option opts :until [String]
264
+ # @option opts :grep [String]
265
+ # @option opts :author [String]
266
+ # @option opts :between [Array<String>] an array of two commit-ish strings to specify a revision range
267
+ #
268
+ # Only :between or :object options can be used, not both.
269
+ #
270
+ # @option opts :object [String] the revision range for the git log command
271
+ #
272
+ # Only :between or :object options can be used, not both.
273
+ #
274
+ # @option opts :path_limiter [String, Pathname, Array<String, Pathname>] only
275
+ # include commits that impact files from the specified paths
276
+ #
277
+ # @return [Array<String>] the log output
278
+ #
279
+ # @raise [ArgumentError] if the resulting revision range is a string starting with a hyphen
280
+ #
281
+ def log_commits(opts = {})
282
+ assert_args_are_not_options('between', opts[:between]&.first)
283
+ assert_args_are_not_options('object', opts[:object])
284
+
194
285
  arr_opts = log_common_options(opts)
195
286
 
196
287
  arr_opts << '--pretty=oneline'
197
288
 
198
289
  arr_opts += log_path_options(opts)
199
290
 
200
- command_lines('log', *arr_opts).map { |l| l.split.first }
291
+ log_or_empty_on_unborn { command_lines('log', *arr_opts).map { |l| l.split.first } }
201
292
  end
202
293
 
203
- def full_log_commits(opts={})
204
- arr_opts = log_common_options(opts)
205
-
206
- arr_opts << '--pretty=raw'
207
- arr_opts << "--skip=#{opts[:skip]}" if opts[:skip]
294
+ FULL_LOG_EXTRA_OPTIONS_MAP = [
295
+ { type: :static, flag: '--pretty=raw' },
296
+ { keys: [:skip], flag: '--skip', type: :valued_equals },
297
+ { keys: [:merges], flag: '--merges', type: :boolean }
298
+ ].freeze
208
299
 
209
- arr_opts += log_path_options(opts)
300
+ # Return the commits that are within the given revision range
301
+ #
302
+ # @see https://git-scm.com/docs/git-log git-log
303
+ #
304
+ # @param opts [Hash] the given options
305
+ #
306
+ # @option opts :count [Integer] the maximum number of commits to return (maps to
307
+ # max-count)
308
+ #
309
+ # @option opts :all [Boolean]
310
+ #
311
+ # @option opts :cherry [Boolean]
312
+ #
313
+ # @option opts :since [String]
314
+ #
315
+ # @option opts :until [String]
316
+ #
317
+ # @option opts :grep [String]
318
+ #
319
+ # @option opts :author [String]
320
+ #
321
+ # @option opts :between [Array<String>] an array of two commit-ish strings to
322
+ # specify a revision range
323
+ #
324
+ # Only :between or :object options can be used, not both.
325
+ #
326
+ # @option opts :object [String] the revision range for the git log command
327
+ #
328
+ # Only :between or :object options can be used, not both.
329
+ #
330
+ # @option opts :path_limiter [String, Pathname, Array<String, Pathname>] only include commits that
331
+ # impact files from the specified paths
332
+ #
333
+ # @option opts :skip [Integer]
334
+ #
335
+ # @return [Array<Hash>] the log output parsed into an array of hashs for each commit
336
+ #
337
+ # Each hash contains the following keys:
338
+ #
339
+ # * 'sha' [String] the commit sha
340
+ # * 'author' [String] the author of the commit
341
+ # * 'message' [String] the commit message
342
+ # * 'parent' [Array<String>] the commit shas of the parent commits
343
+ # * 'tree' [String] the tree sha
344
+ # * 'author' [String] the author of the commit and timestamp of when the
345
+ # changes were created
346
+ # * 'committer' [String] the committer of the commit and timestamp of when the
347
+ # commit was applied
348
+ # * 'merges' [Boolean] if truthy, only include merge commits (aka commits with
349
+ # 2 or more parents)
350
+ #
351
+ # @raise [ArgumentError] if the revision range (specified with :between or
352
+ # :object) is a string starting with a hyphen
353
+ #
354
+ def full_log_commits(opts = {})
355
+ assert_args_are_not_options('between', opts[:between]&.first)
356
+ assert_args_are_not_options('object', opts[:object])
210
357
 
211
- full_log = command_lines('log', *arr_opts)
358
+ args = log_common_options(opts)
359
+ args += build_args(opts, FULL_LOG_EXTRA_OPTIONS_MAP)
360
+ args += log_path_options(opts)
212
361
 
213
- process_commit_log_data(full_log)
362
+ log_or_empty_on_unborn do
363
+ full_log = command_lines('log', *args)
364
+ process_commit_log_data(full_log)
365
+ end
214
366
  end
215
367
 
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)
368
+ # Verify and resolve a Git revision to its full SHA
369
+ #
370
+ # @see https://git-scm.com/docs/git-rev-parse git-rev-parse
371
+ # @see https://git-scm.com/docs/git-rev-parse#_specifying_revisions Valid ways to specify revisions
372
+ # @see https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt-emltrefnamegtemegemmasterememheadsmasterememrefsheadsmasterem
373
+ # Ref disambiguation rules
374
+ #
375
+ # @example
376
+ # lib.rev_parse('HEAD') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d'
377
+ # lib.rev_parse('9b9b31e') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d'
378
+ #
379
+ # @param revision [String] the revision to resolve
380
+ #
381
+ # @return [String] the full commit hash
382
+ #
383
+ # @raise [Git::FailedError] if the revision cannot be resolved
384
+ # @raise [ArgumentError] if the revision is a string starting with a hyphen
385
+ #
386
+ def rev_parse(revision)
387
+ assert_args_are_not_options('rev', revision)
388
+
389
+ command('rev-parse', '--revs-only', '--end-of-options', revision, '--')
225
390
  end
226
391
 
227
- def namerev(string)
228
- command('name-rev', string).split[1]
392
+ # For backwards compatibility with the old method name
393
+ alias revparse rev_parse
394
+
395
+ # Find the first symbolic name for given commit_ish
396
+ #
397
+ # @param commit_ish [String] the commit_ish to find the symbolic name of
398
+ #
399
+ # @return [String, nil] the first symbolic name or nil if the commit_ish isn't found
400
+ #
401
+ # @raise [ArgumentError] if the commit_ish is a string starting with a hyphen
402
+ #
403
+ def name_rev(commit_ish)
404
+ assert_args_are_not_options('commit_ish', commit_ish)
405
+
406
+ command('name-rev', commit_ish).split[1]
229
407
  end
230
408
 
231
- def object_type(sha)
232
- command('cat-file', '-t', sha)
409
+ alias namerev name_rev
410
+
411
+ # Output the contents or other properties of one or more objects.
412
+ #
413
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
414
+ #
415
+ # @example Get the contents of a file without a block
416
+ # lib.cat_file_contents('README.md') # => "This is a README file\n"
417
+ #
418
+ # @example Get the contents of a file with a block
419
+ # lib.cat_file_contents('README.md') { |f| f.read } # => "This is a README file\n"
420
+ #
421
+ # @param object [String] the object whose contents to return
422
+ #
423
+ # @return [String] the object contents
424
+ #
425
+ # @raise [ArgumentError] if object is a string starting with a hyphen
426
+ #
427
+ def cat_file_contents(object)
428
+ assert_args_are_not_options('object', object)
429
+
430
+ if block_given?
431
+ Tempfile.create do |file|
432
+ # If a block is given, write the output from the process to a temporary
433
+ # file and then yield the file to the block
434
+ #
435
+ command('cat-file', '-p', object, out: file, err: file)
436
+ file.rewind
437
+ yield file
438
+ end
439
+ else
440
+ # If a block is not given, return the file contents as a string
441
+ command('cat-file', '-p', object)
442
+ end
233
443
  end
234
444
 
235
- def object_size(sha)
236
- command('cat-file', '-s', sha).to_i
445
+ alias object_contents cat_file_contents
446
+
447
+ # Get the type for the given object
448
+ #
449
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
450
+ #
451
+ # @param object [String] the object to get the type
452
+ #
453
+ # @return [String] the object type
454
+ #
455
+ # @raise [ArgumentError] if object is a string starting with a hyphen
456
+ #
457
+ def cat_file_type(object)
458
+ assert_args_are_not_options('object', object)
459
+
460
+ command('cat-file', '-t', object)
237
461
  end
238
462
 
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)
463
+ alias object_type cat_file_type
464
+
465
+ # Get the size for the given object
466
+ #
467
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
468
+ #
469
+ # @param object [String] the object to get the type
470
+ #
471
+ # @return [String] the object type
472
+ #
473
+ # @raise [ArgumentError] if object is a string starting with a hyphen
474
+ #
475
+ def cat_file_size(object)
476
+ assert_args_are_not_options('object', object)
477
+
478
+ command('cat-file', '-s', object).to_i
244
479
  end
245
480
 
246
- def process_commit_data(data, sha)
247
- hsh = {
248
- 'sha' => sha,
249
- 'parent' => []
250
- }
481
+ alias object_size cat_file_size
251
482
 
252
- each_cat_file_header(data) do |key, value|
253
- if key == 'parent'
254
- hsh['parent'] << value
255
- else
256
- hsh[key] = value
257
- end
258
- end
483
+ # Return a hash of commit data
484
+ #
485
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
486
+ #
487
+ # @param object [String] the object to get the type
488
+ #
489
+ # @return [Hash] commit data
490
+ #
491
+ # The returned commit data has the following keys:
492
+ # * tree [String]
493
+ # * parent [Array<String>]
494
+ # * author [String] the author name, email, and commit timestamp
495
+ # * committer [String] the committer name, email, and merge timestamp
496
+ # * message [String] the commit message
497
+ # * gpgsig [String] the public signing key of the commit (if signed)
498
+ #
499
+ # @raise [ArgumentError] if object is a string starting with a hyphen
500
+ #
501
+ def cat_file_commit(object)
502
+ assert_args_are_not_options('object', object)
503
+
504
+ cdata = command_lines('cat-file', 'commit', object)
505
+ process_commit_data(cdata, object)
506
+ end
507
+
508
+ alias commit_data cat_file_commit
259
509
 
260
- hsh['message'] = data.join("\n") + "\n"
510
+ def process_commit_data(data, sha)
511
+ # process_commit_headers consumes the header lines from the `data` array,
512
+ # leaving only the message lines behind.
513
+ headers = process_commit_headers(data)
514
+ message = "#{data.join("\n")}\n"
261
515
 
262
- return hsh
516
+ { 'sha' => sha, 'message' => message }.merge(headers)
263
517
  end
264
518
 
265
519
  CAT_FILE_HEADER_LINE = /\A(?<key>\w+) (?<value>.*)\z/
@@ -269,20 +523,59 @@ module Git
269
523
  key = match[:key]
270
524
  value_lines = [match[:value]]
271
525
 
272
- while data.first.start_with?(' ')
273
- value_lines << data.shift.lstrip
274
- end
526
+ value_lines << data.shift.lstrip while data.first.start_with?(' ')
275
527
 
276
528
  yield key, value_lines.join("\n")
277
529
  end
278
530
  end
279
531
 
280
- def tag_data(name)
281
- sha = sha.to_s
282
- tdata = command_lines('cat-file', 'tag', name)
283
- process_tag_data(tdata, name)
532
+ # Return a hash of annotated tag data
533
+ #
534
+ # Does not work with lightweight tags. List all annotated tags in your repository
535
+ # with the following command:
536
+ #
537
+ # ```sh
538
+ # git for-each-ref --format='%(refname:strip=2)' refs/tags | \
539
+ # while read tag; do git cat-file tag $tag >/dev/null 2>&1 && echo $tag; done
540
+ # ```
541
+ #
542
+ # @see https://git-scm.com/docs/git-cat-file git-cat-file
543
+ #
544
+ # @param object [String] the tag to retrieve
545
+ #
546
+ # @return [Hash] tag data
547
+ #
548
+ # Example tag data returned:
549
+ # ```ruby
550
+ # {
551
+ # "name" => "annotated_tag",
552
+ # "object" => "46abbf07e3c564c723c7c039a43ab3a39e5d02dd",
553
+ # "type" => "commit",
554
+ # "tag" => "annotated_tag",
555
+ # "tagger" => "Scott Chacon <schacon@gmail.com> 1724799270 -0700",
556
+ # "message" => "Creating an annotated tag\n"
557
+ # }
558
+ # ```
559
+ #
560
+ # The returned commit data has the following keys:
561
+ # * object [String] the sha of the tag object
562
+ # * type [String]
563
+ # * tag [String] tag name
564
+ # * tagger [String] the name and email of the user who created the tag
565
+ # and the timestamp of when the tag was created
566
+ # * message [String] the tag message
567
+ #
568
+ # @raise [ArgumentError] if object is a string starting with a hyphen
569
+ #
570
+ def cat_file_tag(object)
571
+ assert_args_are_not_options('object', object)
572
+
573
+ tdata = command_lines('cat-file', 'tag', object)
574
+ process_tag_data(tdata, object)
284
575
  end
285
576
 
577
+ alias tag_data cat_file_tag
578
+
286
579
  def process_tag_data(data, name)
287
580
  hsh = { 'name' => name }
288
581
 
@@ -290,63 +583,88 @@ module Git
290
583
  hsh[key] = value
291
584
  end
292
585
 
293
- hsh['message'] = data.join("\n") + "\n"
586
+ hsh['message'] = "#{data.join("\n")}\n"
294
587
 
295
- return hsh
588
+ hsh
296
589
  end
297
590
 
298
591
  def process_commit_log_data(data)
299
- in_message = false
592
+ RawLogParser.new(data).parse
593
+ end
300
594
 
301
- hsh_array = []
595
+ # A private parser class to process the output of `git log --pretty=raw`
596
+ # @api private
597
+ class RawLogParser
598
+ def initialize(lines)
599
+ @lines = lines
600
+ @commits = []
601
+ @current_commit = nil
602
+ @in_message = false
603
+ end
302
604
 
303
- hsh = nil
605
+ def parse
606
+ @lines.each { |line| process_line(line.chomp) }
607
+ finalize_commit
608
+ @commits
609
+ end
304
610
 
305
- data.each do |line|
306
- line = line.chomp
611
+ private
307
612
 
308
- if line[0].nil?
309
- in_message = !in_message
310
- next
613
+ def process_line(line)
614
+ if line.empty?
615
+ @in_message = !@in_message
616
+ return
311
617
  end
312
618
 
313
- in_message = false if in_message && line[0..3] != " "
619
+ @in_message = false if @in_message && !line.start_with?(' ')
314
620
 
315
- if in_message
316
- hsh['message'] << "#{line[4..-1]}\n"
317
- next
318
- end
621
+ @in_message ? process_message_line(line) : process_metadata_line(line)
622
+ end
623
+
624
+ def process_message_line(line)
625
+ @current_commit['message'] << "#{line[4..]}\n"
626
+ end
319
627
 
628
+ def process_metadata_line(line)
320
629
  key, *value = line.split
321
630
  value = value.join(' ')
322
631
 
323
632
  case key
324
- when 'commit'
325
- hsh_array << hsh if hsh
326
- hsh = {'sha' => value, 'message' => '', 'parent' => []}
327
- when 'parent'
328
- hsh['parent'] << value
329
- else
330
- hsh[key] = value
633
+ when 'commit'
634
+ start_new_commit(value)
635
+ when 'parent'
636
+ @current_commit['parent'] << value
637
+ else
638
+ @current_commit[key] = value
331
639
  end
332
640
  end
333
641
 
334
- hsh_array << hsh if hsh
642
+ def start_new_commit(sha)
643
+ finalize_commit
644
+ @current_commit = { 'sha' => sha, 'message' => +'', 'parent' => [] }
645
+ end
335
646
 
336
- return hsh_array
647
+ def finalize_commit
648
+ @commits << @current_commit if @current_commit
649
+ end
337
650
  end
651
+ private_constant :RawLogParser
338
652
 
339
- def object_contents(sha, &block)
340
- command('cat-file', '-p', sha, &block)
341
- end
653
+ LS_TREE_OPTION_MAP = [
654
+ { keys: [:recursive], flag: '-r', type: :boolean }
655
+ ].freeze
342
656
 
343
- def ls_tree(sha)
657
+ def ls_tree(sha, opts = {})
344
658
  data = { 'blob' => {}, 'tree' => {}, 'commit' => {} }
659
+ args = build_args(opts, LS_TREE_OPTION_MAP)
345
660
 
346
- command_lines('ls-tree', sha).each do |line|
347
- (info, filenm) = line.split("\t")
661
+ args.unshift(sha)
662
+ args << opts[:path] if opts[:path]
663
+
664
+ command_lines('ls-tree', *args).each do |line|
665
+ (info, filenm) = split_status_line(line)
348
666
  (mode, type, sha) = info.split
349
- data[type][filenm] = {:mode => mode, :sha => sha}
667
+ data[type][filenm] = { mode: mode, sha: sha }
350
668
  end
351
669
 
352
670
  data
@@ -380,7 +698,7 @@ module Git
380
698
  # The branch's full refname
381
699
  (?:
382
700
  (?<not_a_branch>\(not[[:blank:]]a[[:blank:]]branch\)) |
383
- (?:\(HEAD[[:blank:]]detached[[:blank:]]at[[:blank:]](?<detached_ref>[^\)]+)\)) |
701
+ (?:\(HEAD[[:blank:]]detached[[:blank:]]at[[:blank:]](?<detached_ref>[^)]+)\)) |
384
702
  (?<refname>[^[[:blank:]]]+)
385
703
  )
386
704
 
@@ -393,17 +711,10 @@ module Git
393
711
  /x
394
712
 
395
713
  def branches_all
396
- command_lines('branch', '-a').map do |line|
397
- match_data = line.match(BRANCH_LINE_REGEXP)
398
- raise GitExecuteError, 'Unexpected branch line format' unless match_data
399
- next nil if match_data[:not_a_branch] || match_data[:detached_ref]
400
- [
401
- match_data[:refname],
402
- !match_data[:current].nil?,
403
- !match_data[:worktree].nil?,
404
- match_data[:symref]
405
- ]
406
- end.compact
714
+ lines = command_lines('branch', '-a')
715
+ lines.each_with_index.filter_map do |line, index|
716
+ parse_branch_line(line, index, lines)
717
+ end
407
718
  end
408
719
 
409
720
  def worktrees_all
@@ -419,7 +730,7 @@ module Git
419
730
  # detached
420
731
  #
421
732
  command_lines('worktree', 'list', '--porcelain').each do |w|
422
- s = w.split("\s")
733
+ s = w.split
423
734
  directory = s[1] if s[0] == 'worktree'
424
735
  arr << [directory, s[1]] if s[0] == 'HEAD'
425
736
  end
@@ -427,103 +738,216 @@ module Git
427
738
  end
428
739
 
429
740
  def worktree_add(dir, commitish = nil)
430
- return command('worktree', 'add', dir, commitish) if !commitish.nil?
431
- command('worktree', 'add', dir)
741
+ return worktree_command('worktree', 'add', dir, commitish) unless commitish.nil?
742
+
743
+ worktree_command('worktree', 'add', dir)
432
744
  end
433
745
 
434
746
  def worktree_remove(dir)
435
- command('worktree', 'remove', dir)
747
+ worktree_command('worktree', 'remove', dir)
436
748
  end
437
749
 
438
750
  def worktree_prune
439
- command('worktree', 'prune')
751
+ worktree_command('worktree', 'prune')
440
752
  end
441
753
 
442
754
  def list_files(ref_dir)
443
755
  dir = File.join(@git_dir, 'refs', ref_dir)
444
- files = []
445
- begin
446
- files = Dir.glob('**/*', base: dir).select { |f| File.file?(File.join(dir, f)) }
447
- rescue
448
- end
449
- files
756
+ Dir.glob('**/*', base: dir).select { |f| File.file?(File.join(dir, f)) }
757
+ end
758
+
759
+ # The state and name of branch pointed to by `HEAD`
760
+ #
761
+ # HEAD can be in the following states:
762
+ #
763
+ # **:active**: `HEAD` points to a branch reference which in turn points to a
764
+ # commit representing the tip of that branch. This is the typical state when
765
+ # working on a branch.
766
+ #
767
+ # **:unborn**: `HEAD` points to a branch reference that does not yet exist
768
+ # because no commits have been made on that branch. This state occurs in two
769
+ # scenarios:
770
+ #
771
+ # * When a repository is newly initialized, and no commits have been made on the
772
+ # initial branch.
773
+ # * When a new branch is created using `git checkout --orphan <branch>`, starting
774
+ # a new branch with no history.
775
+ #
776
+ # **:detached**: `HEAD` points directly to a specific commit (identified by its
777
+ # SHA) rather than a branch reference. This state occurs when you check out a
778
+ # commit, a tag, or any state that is not directly associated with a branch. The
779
+ # branch name in this case is `HEAD`.
780
+ #
781
+ HeadState = Struct.new(:state, :name)
782
+
783
+ # The current branch state which is the state of `HEAD`
784
+ #
785
+ # @return [HeadState] the state and name of the current branch
786
+ #
787
+ def current_branch_state
788
+ branch_name = command('branch', '--show-current')
789
+ return HeadState.new(:detached, 'HEAD') if branch_name.empty?
790
+
791
+ state = get_branch_state(branch_name)
792
+ HeadState.new(state, branch_name)
450
793
  end
451
794
 
452
795
  def branch_current
453
- branches_all.select { |b| b[1] }.first[0] rescue nil
796
+ branch_name = command('branch', '--show-current')
797
+ branch_name.empty? ? 'HEAD' : branch_name
454
798
  end
455
799
 
456
- def branch_contains(commit, branch_name="")
457
- command("branch", branch_name, "--contains", commit)
800
+ def branch_contains(commit, branch_name = '')
801
+ command('branch', branch_name, '--contains', commit)
458
802
  end
459
803
 
804
+ GREP_OPTION_MAP = [
805
+ { keys: [:ignore_case], flag: '-i', type: :boolean },
806
+ { keys: [:invert_match], flag: '-v', type: :boolean },
807
+ { keys: [:extended_regexp], flag: '-E', type: :boolean },
808
+ # For validation only, as these are handled manually
809
+ { keys: [:object], type: :validate_only },
810
+ { keys: [:path_limiter], type: :validate_only }
811
+ ].freeze
812
+
460
813
  # returns hash
461
814
  # [tree-ish] = [[line_no, match], [line_no, match2]]
462
815
  # [tree-ish] = [[line_no, match], [line_no, match2]]
463
816
  def grep(string, opts = {})
464
817
  opts[:object] ||= 'HEAD'
818
+ ArgsBuilder.validate!(opts, GREP_OPTION_MAP)
465
819
 
466
- grep_opts = ['-n']
467
- grep_opts << '-i' if opts[:ignore_case]
468
- grep_opts << '-v' if opts[:invert_match]
469
- grep_opts << '-E' if opts[:extended_regexp]
470
- grep_opts << '-e'
471
- grep_opts << string
472
- grep_opts << opts[:object] if opts[:object].is_a?(String)
473
- grep_opts.push('--', opts[:path_limiter]) if opts[:path_limiter].is_a?(String)
474
- grep_opts.push('--', *opts[:path_limiter]) if opts[:path_limiter].is_a?(Array)
820
+ boolean_flags = build_args(opts, GREP_OPTION_MAP)
821
+ args = ['-n', *boolean_flags, '-e', string, opts[:object]]
475
822
 
476
- 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]]
481
- end
823
+ if (limiter = opts[:path_limiter])
824
+ args.push('--', *Array(limiter))
482
825
  end
483
- hsh
826
+
827
+ lines = execute_grep_command(args)
828
+ parse_grep_output(lines)
829
+ end
830
+
831
+ # Validate that the given arguments cannot be mistaken for a command-line option
832
+ #
833
+ # @param arg_name [String] the name of the arguments to mention in the error message
834
+ # @param args [Array<String, nil>] the arguments to validate
835
+ #
836
+ # @raise [ArgumentError] if any of the parameters are a string starting with a hyphen
837
+ # @return [void]
838
+ #
839
+ def assert_args_are_not_options(arg_name, *args)
840
+ invalid_args = args.select { |arg| arg&.start_with?('-') }
841
+ return unless invalid_args.any?
842
+
843
+ raise ArgumentError, "Invalid #{arg_name}: '#{invalid_args.join("', '")}'"
844
+ end
845
+
846
+ # Normalizes path specifications for Git commands
847
+ #
848
+ # Converts a single path or array of paths into a consistent array format
849
+ # suitable for appending to Git command arguments after '--'. Empty strings
850
+ # are filtered out after conversion.
851
+ #
852
+ # @param pathspecs [String, Pathname, Array<String, Pathname>, nil] path(s) to normalize
853
+ # @param arg_name [String] name of the argument for error messages
854
+ # @return [Array<String>, nil] normalized array of path strings, or nil if empty/nil input
855
+ # @raise [ArgumentError] if any path is not a String or Pathname
856
+ #
857
+ def normalize_pathspecs(pathspecs, arg_name)
858
+ return nil unless pathspecs
859
+
860
+ normalized = Array(pathspecs)
861
+ validate_pathspec_types(normalized, arg_name)
862
+
863
+ normalized = normalized.map(&:to_s).reject(&:empty?)
864
+ return nil if normalized.empty?
865
+
866
+ normalized
484
867
  end
485
868
 
869
+ # Validates that all pathspecs are String or Pathname objects
870
+ #
871
+ # @param pathspecs [Array] the pathspecs to validate
872
+ # @param arg_name [String] name of the argument for error messages
873
+ # @raise [ArgumentError] if any path is not a String or Pathname
874
+ #
875
+ def validate_pathspec_types(pathspecs, arg_name)
876
+ return if pathspecs.all? { |path| path.is_a?(String) || path.is_a?(Pathname) }
877
+
878
+ raise ArgumentError, "Invalid #{arg_name}: must be a String, Pathname, or Array of Strings/Pathnames"
879
+ end
880
+
881
+ # Handle deprecated :path option in favor of :path_limiter
882
+ def handle_deprecated_path_option(opts)
883
+ if opts.key?(:path_limiter)
884
+ opts[:path_limiter]
885
+ elsif opts.key?(:path)
886
+ Git::Deprecation.warn(
887
+ 'Git::Lib#diff_path_status :path option is deprecated. Use :path_limiter instead.'
888
+ )
889
+ opts[:path]
890
+ end
891
+ end
892
+
893
+ DIFF_FULL_OPTION_MAP = [
894
+ { type: :static, flag: '-p' },
895
+ { keys: [:path_limiter], type: :validate_only }
896
+ ].freeze
897
+
486
898
  def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {})
487
- diff_opts = ['-p']
488
- diff_opts << obj1
489
- diff_opts << obj2 if obj2.is_a?(String)
490
- diff_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String
899
+ assert_args_are_not_options('commit or commit range', obj1, obj2)
900
+ ArgsBuilder.validate!(opts, DIFF_FULL_OPTION_MAP)
901
+
902
+ args = build_args(opts, DIFF_FULL_OPTION_MAP)
903
+ args.push(obj1, obj2).compact!
904
+
905
+ if (pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter'))
906
+ args.push('--', *pathspecs)
907
+ end
491
908
 
492
- command('diff', *diff_opts)
909
+ command('diff', *args)
493
910
  end
494
911
 
912
+ DIFF_STATS_OPTION_MAP = [
913
+ { type: :static, flag: '--numstat' },
914
+ { keys: [:path_limiter], type: :validate_only }
915
+ ].freeze
916
+
495
917
  def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {})
496
- diff_opts = ['--numstat']
497
- diff_opts << obj1
498
- diff_opts << obj2 if obj2.is_a?(String)
499
- diff_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String
500
-
501
- hsh = {:total => {:insertions => 0, :deletions => 0, :lines => 0, :files => 0}, :files => {}}
502
-
503
- command_lines('diff', *diff_opts).each do |file|
504
- (insertions, deletions, filename) = file.split("\t")
505
- hsh[:total][:insertions] += insertions.to_i
506
- hsh[:total][:deletions] += deletions.to_i
507
- hsh[:total][:lines] = (hsh[:total][:deletions] + hsh[:total][:insertions])
508
- hsh[:total][:files] += 1
509
- hsh[:files][filename] = {:insertions => insertions.to_i, :deletions => deletions.to_i}
918
+ assert_args_are_not_options('commit or commit range', obj1, obj2)
919
+ ArgsBuilder.validate!(opts, DIFF_STATS_OPTION_MAP)
920
+
921
+ args = build_args(opts, DIFF_STATS_OPTION_MAP)
922
+ args.push(obj1, obj2).compact!
923
+
924
+ if (pathspecs = normalize_pathspecs(opts[:path_limiter], 'path limiter'))
925
+ args.push('--', *pathspecs)
510
926
  end
511
927
 
512
- hsh
928
+ output_lines = command_lines('diff', *args)
929
+ parse_diff_stats_output(output_lines)
513
930
  end
514
931
 
515
- def diff_name_status(reference1 = nil, reference2 = nil, opts = {})
516
- opts_arr = ['--name-status']
517
- opts_arr << reference1 if reference1
518
- opts_arr << reference2 if reference2
932
+ DIFF_PATH_STATUS_OPTION_MAP = [
933
+ { type: :static, flag: '--name-status' },
934
+ { keys: [:path_limiter], type: :validate_only },
935
+ { keys: [:path], type: :validate_only }
936
+ ].freeze
519
937
 
520
- opts_arr << '--' << opts[:path] if opts[:path]
938
+ def diff_path_status(reference1 = nil, reference2 = nil, opts = {})
939
+ assert_args_are_not_options('commit or commit range', reference1, reference2)
940
+ ArgsBuilder.validate!(opts, DIFF_PATH_STATUS_OPTION_MAP)
521
941
 
522
- command_lines('diff', *opts_arr).inject({}) do |memo, line|
523
- status, path = line.split("\t")
524
- memo[path] = status
525
- memo
942
+ args = build_args(opts, DIFF_PATH_STATUS_OPTION_MAP)
943
+ args.push(reference1, reference2).compact!
944
+
945
+ path_limiter = handle_deprecated_path_option(opts)
946
+ if (pathspecs = normalize_pathspecs(path_limiter, 'path limiter'))
947
+ args.push('--', *pathspecs)
526
948
  end
949
+
950
+ parse_diff_path_status(args)
527
951
  end
528
952
 
529
953
  # compares the index and the working directory
@@ -536,48 +960,82 @@ module Git
536
960
  diff_as_hash('diff-index', treeish)
537
961
  end
538
962
 
539
- def ls_files(location=nil)
963
+ # List all files that are in the index
964
+ #
965
+ # @param location [String] the location to list the files from
966
+ #
967
+ # @return [Hash<String, Hash>] a hash of files in the index
968
+ # * key: file [String] the file path
969
+ # * value: file_info [Hash] the file information containing the following keys:
970
+ # * :path [String] the file path
971
+ # * :mode_index [String] the file mode
972
+ # * :sha_index [String] the file sha
973
+ # * :stage [String] the file stage
974
+ #
975
+ def ls_files(location = nil)
540
976
  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
977
+ {}.tap do |files|
978
+ command_lines('ls-files', '--stage', location).each do |line|
979
+ (info, file) = split_status_line(line)
980
+ (mode, sha, stage) = info.split
981
+ files[file] = {
982
+ path: file, mode_index: mode, sha_index: sha, stage: stage
983
+ }
547
984
  end
548
- hsh[file] = {:path => file, :mode_index => mode, :sha_index => sha, :stage => stage}
549
985
  end
550
- hsh
551
986
  end
552
987
 
553
- def ls_remote(location=nil, opts={})
554
- arr_opts = []
555
- arr_opts << '--refs' if opts[:refs]
556
- arr_opts << (location || '.')
557
-
558
- Hash.new{ |h,k| h[k] = {} }.tap do |hsh|
559
- command_lines('ls-remote', *arr_opts).each do |line|
560
- (sha, info) = line.split("\t")
561
- (ref, type, name) = info.split('/', 3)
562
- type ||= 'head'
563
- type = 'branches' if type == 'heads'
564
- value = {:ref => ref, :sha => sha}
565
- hsh[type].update( name.nil? ? value : { name => value })
566
- end
988
+ # Unescape a path if it is quoted
989
+ #
990
+ # Git commands that output paths (e.g. ls-files, diff), will escape unusual
991
+ # characters.
992
+ #
993
+ # @example
994
+ # lib.unescape_if_quoted('"quoted_file_\\342\\230\\240"') # => 'quoted_file_☠'
995
+ # lib.unescape_if_quoted('unquoted_file') # => 'unquoted_file'
996
+ #
997
+ # @param path [String] the path to unescape if quoted
998
+ #
999
+ # @return [String] the unescaped path if quoted otherwise the original path
1000
+ #
1001
+ # @api private
1002
+ #
1003
+ def unescape_quoted_path(path)
1004
+ if path.start_with?('"') && path.end_with?('"')
1005
+ Git::EscapedPath.new(path[1..-2]).unescape
1006
+ else
1007
+ path
567
1008
  end
568
1009
  end
569
1010
 
1011
+ LS_REMOTE_OPTION_MAP = [
1012
+ { keys: [:refs], flag: '--refs', type: :boolean }
1013
+ ].freeze
1014
+
1015
+ def ls_remote(location = nil, opts = {})
1016
+ ArgsBuilder.validate!(opts, LS_REMOTE_OPTION_MAP)
1017
+
1018
+ flags = build_args(opts, LS_REMOTE_OPTION_MAP)
1019
+ positional_arg = location || '.'
1020
+
1021
+ output_lines = command_lines('ls-remote', *flags, positional_arg)
1022
+ parse_ls_remote_output(output_lines)
1023
+ end
1024
+
570
1025
  def ignored_files
571
- command_lines('ls-files', '--others', '-i', '--exclude-standard')
1026
+ command_lines('ls-files', '--others', '-i', '--exclude-standard').map { |f| unescape_quoted_path(f) }
572
1027
  end
573
1028
 
1029
+ def untracked_files
1030
+ command_lines('ls-files', '--others', '--exclude-standard', chdir: @git_work_dir).map do |f|
1031
+ unescape_quoted_path(f)
1032
+ end
1033
+ end
574
1034
 
575
1035
  def config_remote(name)
576
1036
  hsh = {}
577
1037
  config_list.each do |key, value|
578
- if /remote.#{name}/.match(key)
579
- hsh[key.gsub("remote.#{name}.", '')] = value
580
- end
1038
+ hsh[key.gsub("remote.#{name}.", '')] = value if /remote.#{name}/.match(key)
581
1039
  end
582
1040
  hsh
583
1041
  end
@@ -616,7 +1074,7 @@ module Git
616
1074
  # @param [String|NilClass] objectish the target object reference (nil == HEAD)
617
1075
  # @param [String|NilClass] path the path of the file to be shown
618
1076
  # @return [String] the object information
619
- def show(objectish=nil, path=nil)
1077
+ def show(objectish = nil, path = nil)
620
1078
  arr_opts = []
621
1079
 
622
1080
  arr_opts << (path ? "#{objectish}:#{path}" : objectish)
@@ -626,55 +1084,97 @@ module Git
626
1084
 
627
1085
  ## WRITE COMMANDS ##
628
1086
 
1087
+ CONFIG_SET_OPTION_MAP = [
1088
+ { keys: [:file], flag: '--file', type: :valued_space }
1089
+ ].freeze
1090
+
629
1091
  def config_set(name, value, options = {})
630
- if options[:file].to_s.empty?
631
- command('config', name, value)
632
- else
633
- command('config', '--file', options[:file], name, value)
634
- end
1092
+ ArgsBuilder.validate!(options, CONFIG_SET_OPTION_MAP)
1093
+ flags = build_args(options, CONFIG_SET_OPTION_MAP)
1094
+ command('config', *flags, name, value)
635
1095
  end
636
1096
 
637
1097
  def global_config_set(name, value)
638
1098
  command('config', '--global', name, value)
639
1099
  end
640
1100
 
641
- # updates the repository index using the working directory content
1101
+ ADD_OPTION_MAP = [
1102
+ { keys: [:all], flag: '--all', type: :boolean },
1103
+ { keys: [:force], flag: '--force', type: :boolean }
1104
+ ].freeze
1105
+
1106
+ # Update the index from the current worktree to prepare the for the next commit
642
1107
  #
643
- # lib.add('path/to/file')
644
- # lib.add(['path/to/file1','path/to/file2'])
645
- # lib.add(:all => true)
1108
+ # @example
1109
+ # lib.add('path/to/file')
1110
+ # lib.add(['path/to/file1','path/to/file2'])
1111
+ # lib.add(:all => true)
646
1112
  #
647
- # options:
648
- # :all => true
649
- # :force => true
650
- #
651
- # @param [String,Array] paths files paths to be added to the repository
1113
+ # @param [String, Array<String>] paths files to be added to the repository (relative to the worktree root)
652
1114
  # @param [Hash] options
653
- def add(paths='.',options={})
654
- arr_opts = []
1115
+ #
1116
+ # @option options [Boolean] :all Add, modify, and remove index entries to match the worktree
1117
+ # @option options [Boolean] :force Allow adding otherwise ignored files
1118
+ #
1119
+ def add(paths = '.', options = {})
1120
+ args = build_args(options, ADD_OPTION_MAP)
655
1121
 
656
- arr_opts << '--all' if options[:all]
657
- arr_opts << '--force' if options[:force]
1122
+ args << '--'
1123
+ args.concat(Array(paths))
658
1124
 
659
- arr_opts << '--'
1125
+ command('add', *args)
1126
+ end
1127
+
1128
+ RM_OPTION_MAP = [
1129
+ { type: :static, flag: '-f' },
1130
+ { keys: [:recursive], flag: '-r', type: :boolean },
1131
+ { keys: [:cached], flag: '--cached', type: :boolean }
1132
+ ].freeze
660
1133
 
661
- arr_opts << paths
1134
+ def rm(path = '.', opts = {})
1135
+ args = build_args(opts, RM_OPTION_MAP)
662
1136
 
663
- arr_opts.flatten!
1137
+ args << '--'
1138
+ args.concat(Array(path))
664
1139
 
665
- command('add', *arr_opts)
1140
+ command('rm', *args)
666
1141
  end
667
1142
 
668
- def rm(path = '.', opts = {})
669
- arr_opts = ['-f'] # overrides the up-to-date check by default
670
- arr_opts << '-r' if opts[:recursive]
671
- arr_opts << '--cached' if opts[:cached]
672
- arr_opts << '--'
673
- arr_opts += Array(path)
1143
+ # Returns true if the repository is empty (meaning it has no commits)
1144
+ #
1145
+ # @return [Boolean]
1146
+ #
1147
+ def empty?
1148
+ command('rev-parse', '--verify', 'HEAD')
1149
+ false
1150
+ rescue Git::FailedError => e
1151
+ raise unless e.result.status.exitstatus == 128 &&
1152
+ e.result.stderr == 'fatal: Needed a single revision'
674
1153
 
675
- command('rm', *arr_opts)
1154
+ true
676
1155
  end
677
1156
 
1157
+ COMMIT_OPTION_MAP = [
1158
+ { keys: %i[add_all all], flag: '--all', type: :boolean },
1159
+ { keys: [:allow_empty], flag: '--allow-empty', type: :boolean },
1160
+ { keys: [:no_verify], flag: '--no-verify', type: :boolean },
1161
+ { keys: [:allow_empty_message], flag: '--allow-empty-message', type: :boolean },
1162
+ { keys: [:author], flag: '--author', type: :valued_equals },
1163
+ { keys: [:message], flag: '--message', type: :valued_equals },
1164
+ { keys: [:no_gpg_sign], flag: '--no-gpg-sign', type: :boolean },
1165
+ { keys: [:date], flag: '--date', type: :valued_equals, validator: ->(v) { v.is_a?(String) } },
1166
+ { keys: [:amend], type: :custom, builder: ->(value) { ['--amend', '--no-edit'] if value } },
1167
+ {
1168
+ keys: [:gpg_sign],
1169
+ type: :custom,
1170
+ builder: lambda { |value|
1171
+ if value
1172
+ value == true ? '--gpg-sign' : "--gpg-sign=#{value}"
1173
+ end
1174
+ }
1175
+ }
1176
+ ].freeze
1177
+
678
1178
  # Takes the commit message with the options and executes the commit command
679
1179
  #
680
1180
  # accepts options:
@@ -690,59 +1190,52 @@ module Git
690
1190
  #
691
1191
  # @param [String] message the commit message to be used
692
1192
  # @param [Hash] opts the commit options to be used
1193
+
693
1194
  def commit(message, opts = {})
694
- arr_opts = []
695
- arr_opts << "--message=#{message}" if message
696
- arr_opts << '--amend' << '--no-edit' if opts[:amend]
697
- arr_opts << '--all' if opts[:add_all] || opts[:all]
698
- arr_opts << '--allow-empty' if opts[:allow_empty]
699
- arr_opts << "--author=#{opts[:author]}" if opts[:author]
700
- arr_opts << "--date=#{opts[:date]}" if opts[:date].is_a? String
701
- arr_opts << '--no-verify' if opts[:no_verify]
702
- arr_opts << '--allow-empty-message' if opts[:allow_empty_message]
703
-
704
- if opts[:gpg_sign] && opts[:no_gpg_sign]
705
- raise ArgumentError, 'cannot specify :gpg_sign and :no_gpg_sign'
706
- elsif opts[:gpg_sign]
707
- arr_opts <<
708
- if opts[:gpg_sign] == true
709
- '--gpg-sign'
710
- else
711
- "--gpg-sign=#{opts[:gpg_sign]}"
712
- end
713
- elsif opts[:no_gpg_sign]
714
- arr_opts << '--no-gpg-sign'
715
- end
1195
+ opts[:message] = message if message # Handle message arg for backward compatibility
1196
+
1197
+ # Perform cross-option validation before building args
1198
+ raise ArgumentError, 'cannot specify :gpg_sign and :no_gpg_sign' if opts[:gpg_sign] && opts[:no_gpg_sign]
716
1199
 
717
- command('commit', *arr_opts)
1200
+ ArgsBuilder.validate!(opts, COMMIT_OPTION_MAP)
1201
+
1202
+ args = build_args(opts, COMMIT_OPTION_MAP)
1203
+ command('commit', *args)
718
1204
  end
1205
+ RESET_OPTION_MAP = [
1206
+ { keys: [:hard], flag: '--hard', type: :boolean }
1207
+ ].freeze
719
1208
 
720
1209
  def reset(commit, opts = {})
721
- arr_opts = []
722
- arr_opts << '--hard' if opts[:hard]
723
- arr_opts << commit if commit
724
- command('reset', *arr_opts)
1210
+ args = build_args(opts, RESET_OPTION_MAP)
1211
+ args << commit if commit
1212
+ command('reset', *args)
725
1213
  end
726
1214
 
727
- def clean(opts = {})
728
- arr_opts = []
729
- arr_opts << '--force' if opts[:force]
730
- arr_opts << '-ff' if opts[:ff]
731
- arr_opts << '-d' if opts[:d]
732
- arr_opts << '-x' if opts[:x]
1215
+ CLEAN_OPTION_MAP = [
1216
+ { keys: [:force], flag: '--force', type: :boolean },
1217
+ { keys: [:ff], flag: '-ff', type: :boolean },
1218
+ { keys: [:d], flag: '-d', type: :boolean },
1219
+ { keys: [:x], flag: '-x', type: :boolean }
1220
+ ].freeze
733
1221
 
734
- command('clean', *arr_opts)
1222
+ def clean(opts = {})
1223
+ args = build_args(opts, CLEAN_OPTION_MAP)
1224
+ command('clean', *args)
735
1225
  end
736
1226
 
1227
+ REVERT_OPTION_MAP = [
1228
+ { keys: [:no_edit], flag: '--no-edit', type: :boolean }
1229
+ ].freeze
1230
+
737
1231
  def revert(commitish, opts = {})
738
1232
  # Forcing --no-edit as default since it's not an interactive session.
739
- opts = {:no_edit => true}.merge(opts)
1233
+ opts = { no_edit: true }.merge(opts)
740
1234
 
741
- arr_opts = []
742
- arr_opts << '--no-edit' if opts[:no_edit]
743
- arr_opts << commitish
1235
+ args = build_args(opts, REVERT_OPTION_MAP)
1236
+ args << commitish
744
1237
 
745
- command('revert', *arr_opts)
1238
+ command('revert', *args)
746
1239
  end
747
1240
 
748
1241
  def apply(patch_file)
@@ -758,17 +1251,9 @@ module Git
758
1251
  end
759
1252
 
760
1253
  def stashes_all
761
- arr = []
762
- filename = File.join(@git_dir, 'logs/refs/stash')
763
- if File.exist?(filename)
764
- File.open(filename) do |f|
765
- f.each_with_index do |line, i|
766
- m = line.match(/:(.*)$/)
767
- arr << [i, m[1].strip]
768
- end
769
- end
1254
+ stash_log_lines.each_with_index.map do |line, index|
1255
+ parse_stash_log_line(line, index)
770
1256
  end
771
- arr
772
1257
  end
773
1258
 
774
1259
  def stash_save(message)
@@ -800,6 +1285,12 @@ module Git
800
1285
  command('branch', '-D', branch)
801
1286
  end
802
1287
 
1288
+ CHECKOUT_OPTION_MAP = [
1289
+ { keys: %i[force f], flag: '--force', type: :boolean },
1290
+ { keys: %i[new_branch b], type: :validate_only },
1291
+ { keys: [:start_point], type: :validate_only }
1292
+ ].freeze
1293
+
803
1294
  # Runs checkout command to checkout or create branch
804
1295
  #
805
1296
  # accepts options:
@@ -810,18 +1301,16 @@ module Git
810
1301
  # @param [String] branch
811
1302
  # @param [Hash] opts
812
1303
  def checkout(branch = nil, opts = {})
813
- if branch.is_a?(Hash) && opts == {}
1304
+ if branch.is_a?(Hash) && opts.empty?
814
1305
  opts = branch
815
1306
  branch = nil
816
1307
  end
1308
+ ArgsBuilder.validate!(opts, CHECKOUT_OPTION_MAP)
817
1309
 
818
- arr_opts = []
819
- arr_opts << '-b' if opts[:new_branch] || opts[:b]
820
- arr_opts << '--force' if opts[:force] || opts[:f]
821
- arr_opts << branch if branch
822
- arr_opts << opts[:start_point] if opts[:start_point] && arr_opts.include?('-b')
1310
+ flags = build_args(opts, CHECKOUT_OPTION_MAP)
1311
+ positional_args = build_checkout_positional_args(branch, opts)
823
1312
 
824
- command('checkout', *arr_opts)
1313
+ command('checkout', *flags, *positional_args)
825
1314
  end
826
1315
 
827
1316
  def checkout_file(version, file)
@@ -831,62 +1320,88 @@ module Git
831
1320
  command('checkout', *arr_opts)
832
1321
  end
833
1322
 
1323
+ MERGE_OPTION_MAP = [
1324
+ { keys: [:no_commit], flag: '--no-commit', type: :boolean },
1325
+ { keys: [:no_ff], flag: '--no-ff', type: :boolean },
1326
+ { keys: [:m], flag: '-m', type: :valued_space }
1327
+ ].freeze
1328
+
834
1329
  def merge(branch, message = nil, opts = {})
835
- arr_opts = []
836
- arr_opts << '--no-commit' if opts[:no_commit]
837
- arr_opts << '--no-ff' if opts[:no_ff]
838
- arr_opts << '-m' << message if message
839
- arr_opts += Array(branch)
840
- command('merge', *arr_opts)
1330
+ # For backward compatibility, treat the message arg as the :m option.
1331
+ opts[:m] = message if message
1332
+ ArgsBuilder.validate!(opts, MERGE_OPTION_MAP)
1333
+
1334
+ args = build_args(opts, MERGE_OPTION_MAP)
1335
+ args.concat(Array(branch))
1336
+
1337
+ command('merge', *args)
841
1338
  end
842
1339
 
1340
+ MERGE_BASE_OPTION_MAP = [
1341
+ { keys: [:octopus], flag: '--octopus', type: :boolean },
1342
+ { keys: [:independent], flag: '--independent', type: :boolean },
1343
+ { keys: [:fork_point], flag: '--fork-point', type: :boolean },
1344
+ { keys: [:all], flag: '--all', type: :boolean }
1345
+ ].freeze
1346
+
843
1347
  def merge_base(*args)
844
1348
  opts = args.last.is_a?(Hash) ? args.pop : {}
1349
+ ArgsBuilder.validate!(opts, MERGE_BASE_OPTION_MAP)
845
1350
 
846
- arg_opts = []
847
-
848
- arg_opts << '--octopus' if opts[:octopus]
849
- arg_opts << '--independent' if opts[:independent]
850
- arg_opts << '--fork-point' if opts[:fork_point]
851
- arg_opts << '--all' if opts[:all]
1351
+ flags = build_args(opts, MERGE_BASE_OPTION_MAP)
1352
+ command_args = flags + args
852
1353
 
853
- arg_opts += args
854
-
855
- command('merge-base', *arg_opts).lines.map(&:strip)
1354
+ command('merge-base', *command_args).lines.map(&:strip)
856
1355
  end
857
1356
 
858
1357
  def unmerged
859
1358
  unmerged = []
860
- command_lines('diff', "--cached").each do |line|
861
- unmerged << $1 if line =~ /^\* Unmerged path (.*)/
1359
+ command_lines('diff', '--cached').each do |line|
1360
+ unmerged << ::Regexp.last_match(1) if line =~ /^\* Unmerged path (.*)/
862
1361
  end
863
1362
  unmerged
864
1363
  end
865
1364
 
866
1365
  def conflicts # :yields: file, your, their
867
- 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)
1366
+ unmerged.each do |file_path|
1367
+ Tempfile.create(['YOUR-', File.basename(file_path)]) do |your_file|
1368
+ write_staged_content(file_path, 2, your_file).flush
1369
+
1370
+ Tempfile.create(['THEIR-', File.basename(file_path)]) do |their_file|
1371
+ write_staged_content(file_path, 3, their_file).flush
1372
+ yield(file_path, your_file.path, their_file.path)
1373
+ end
1374
+ end
878
1375
  end
879
1376
  end
880
1377
 
1378
+ REMOTE_ADD_OPTION_MAP = [
1379
+ { keys: %i[with_fetch fetch], flag: '-f', type: :boolean },
1380
+ { keys: [:track], flag: '-t', type: :valued_space }
1381
+ ].freeze
1382
+
881
1383
  def remote_add(name, url, opts = {})
882
- arr_opts = ['add']
883
- arr_opts << '-f' if opts[:with_fetch] || opts[:fetch]
884
- arr_opts << '-t' << opts[:track] if opts[:track]
885
- arr_opts << '--'
886
- arr_opts << name
887
- arr_opts << url
1384
+ ArgsBuilder.validate!(opts, REMOTE_ADD_OPTION_MAP)
888
1385
 
889
- command('remote', *arr_opts)
1386
+ flags = build_args(opts, REMOTE_ADD_OPTION_MAP)
1387
+ positional_args = ['--', name, url]
1388
+ command_args = ['add'] + flags + positional_args
1389
+
1390
+ command('remote', *command_args)
1391
+ end
1392
+
1393
+ REMOTE_SET_BRANCHES_OPTION_MAP = [
1394
+ { keys: [:add], flag: '--add', type: :boolean }
1395
+ ].freeze
1396
+
1397
+ def remote_set_branches(name, branches, opts = {})
1398
+ ArgsBuilder.validate!(opts, REMOTE_SET_BRANCHES_OPTION_MAP)
1399
+
1400
+ flags = build_args(opts, REMOTE_SET_BRANCHES_OPTION_MAP)
1401
+ branch_args = Array(branches).flatten
1402
+ command_args = ['set-branches'] + flags + [name] + branch_args
1403
+
1404
+ command('remote', *command_args)
890
1405
  end
891
1406
 
892
1407
  def remote_set_url(name, url)
@@ -909,99 +1424,103 @@ module Git
909
1424
  command_lines('tag')
910
1425
  end
911
1426
 
912
- def tag(name, *opts)
913
- target = opts[0].instance_of?(String) ? opts[0] : nil
1427
+ TAG_OPTION_MAP = [
1428
+ { keys: %i[force f], flag: '-f', type: :boolean },
1429
+ { keys: %i[annotate a], flag: '-a', type: :boolean },
1430
+ { keys: %i[sign s], flag: '-s', type: :boolean },
1431
+ { keys: %i[delete d], flag: '-d', type: :boolean },
1432
+ { keys: %i[message m], flag: '-m', type: :valued_space }
1433
+ ].freeze
914
1434
 
915
- opts = opts.last.instance_of?(Hash) ? opts.last : {}
1435
+ def tag(name, *args)
1436
+ opts = args.last.is_a?(Hash) ? args.pop : {}
1437
+ target = args.first
916
1438
 
917
- if (opts[:a] || opts[:annotate]) && !(opts[:m] || opts[:message])
918
- raise "Can not create an [:a|:annotate] tag without the precense of [:m|:message]."
919
- end
1439
+ validate_tag_options!(opts)
1440
+ ArgsBuilder.validate!(opts, TAG_OPTION_MAP)
920
1441
 
921
- arr_opts = []
922
-
923
- arr_opts << '-f' if opts[:force] || opts[:f]
924
- arr_opts << '-a' if opts[:a] || opts[:annotate]
925
- arr_opts << '-s' if opts[:s] || opts[:sign]
926
- arr_opts << '-d' if opts[:d] || opts[:delete]
927
- arr_opts << name
928
- arr_opts << target if target
1442
+ flags = build_args(opts, TAG_OPTION_MAP)
1443
+ positional_args = [name, target].compact
929
1444
 
930
- if opts[:m] || opts[:message]
931
- arr_opts << '-m' << (opts[:m] || opts[:message])
932
- end
933
-
934
- command('tag', *arr_opts)
1445
+ command('tag', *flags, *positional_args)
935
1446
  end
936
1447
 
937
- def fetch(remote, opts)
938
- arr_opts = []
939
- arr_opts << '--all' if opts[:all]
940
- arr_opts << '--tags' if opts[:t] || opts[:tags]
941
- arr_opts << '--prune' if opts[:p] || opts[:prune]
942
- arr_opts << '--prune-tags' if opts[:P] || opts[:'prune-tags']
943
- arr_opts << '--force' if opts[:f] || opts[:force]
944
- arr_opts << '--update-head-ok' if opts[:u] || opts[:'update-head-ok']
945
- arr_opts << '--unshallow' if opts[:unshallow]
946
- arr_opts << '--depth' << opts[:depth] if opts[:depth]
947
- arr_opts << '--' if remote || opts[:ref]
948
- arr_opts << remote if remote
949
- arr_opts << opts[:ref] if opts[:ref]
950
-
951
- command('fetch', *arr_opts)
952
- end
1448
+ FETCH_OPTION_MAP = [
1449
+ { keys: [:all], flag: '--all', type: :boolean },
1450
+ { keys: %i[tags t], flag: '--tags', type: :boolean },
1451
+ { keys: %i[prune p], flag: '--prune', type: :boolean },
1452
+ { keys: %i[prune-tags P], flag: '--prune-tags', type: :boolean },
1453
+ { keys: %i[force f], flag: '--force', type: :boolean },
1454
+ { keys: %i[update-head-ok u], flag: '--update-head-ok', type: :boolean },
1455
+ { keys: [:unshallow], flag: '--unshallow', type: :boolean },
1456
+ { keys: [:depth], flag: '--depth', type: :valued_space },
1457
+ { keys: [:ref], type: :validate_only }
1458
+ ].freeze
953
1459
 
954
- def push(remote = nil, branch = nil, opts = nil)
955
- if opts.nil? && branch.instance_of?(Hash)
956
- opts = branch
957
- branch = nil
958
- end
1460
+ def fetch(remote, opts)
1461
+ ArgsBuilder.validate!(opts, FETCH_OPTION_MAP)
1462
+ args = build_args(opts, FETCH_OPTION_MAP)
959
1463
 
960
- if opts.nil? && remote.instance_of?(Hash)
961
- opts = remote
962
- remote = nil
1464
+ if remote || opts[:ref]
1465
+ args << '--'
1466
+ args << remote if remote
1467
+ args << opts[:ref] if opts[:ref]
963
1468
  end
964
1469
 
965
- opts ||= {}
1470
+ command('fetch', *args, merge: true)
1471
+ end
966
1472
 
967
- # Small hack to keep backwards compatibility with the 'push(remote, branch, tags)' method signature.
968
- opts = {:tags => opts} if [true, false].include?(opts)
1473
+ PUSH_OPTION_MAP = [
1474
+ { keys: [:mirror], flag: '--mirror', type: :boolean },
1475
+ { keys: [:delete], flag: '--delete', type: :boolean },
1476
+ { keys: %i[force f], flag: '--force', type: :boolean },
1477
+ { keys: [:push_option], flag: '--push-option', type: :repeatable_valued_space },
1478
+ { keys: [:all], type: :validate_only }, # For validation purposes
1479
+ { keys: [:tags], type: :validate_only } # From the `push` method's logic
1480
+ ].freeze
969
1481
 
970
- raise ArgumentError, "You must specify a remote if a branch is specified" if remote.nil? && !branch.nil?
1482
+ def push(remote = nil, branch = nil, opts = nil)
1483
+ remote, branch, opts = normalize_push_args(remote, branch, opts)
1484
+ ArgsBuilder.validate!(opts, PUSH_OPTION_MAP)
971
1485
 
972
- arr_opts = []
973
- arr_opts << '--mirror' if opts[:mirror]
974
- arr_opts << '--delete' if opts[:delete]
975
- arr_opts << '--force' if opts[:force] || opts[:f]
976
- arr_opts << '--all' if opts[:all] && remote
1486
+ raise ArgumentError, 'remote is required if branch is specified' if !remote && branch
977
1487
 
978
- Array(opts[:push_option]).each { |o| arr_opts << '--push-option' << o } if opts[:push_option]
979
- arr_opts << remote if remote
980
- arr_opts_with_branch = arr_opts.dup
981
- arr_opts_with_branch << branch if branch
1488
+ args = build_push_args(remote, branch, opts)
982
1489
 
983
1490
  if opts[:mirror]
984
- command('push', *arr_opts_with_branch)
1491
+ command('push', *args)
985
1492
  else
986
- command('push', *arr_opts_with_branch)
987
- command('push', '--tags', *arr_opts) if opts[:tags]
1493
+ command('push', *args)
1494
+ command('push', '--tags', *(args - [branch].compact)) if opts[:tags]
988
1495
  end
989
1496
  end
990
1497
 
991
- def pull(remote = nil, branch = nil)
992
- raise ArgumentError, "You must specify a remote if a branch is specified" if remote.nil? && !branch.nil?
1498
+ PULL_OPTION_MAP = [
1499
+ { keys: [:allow_unrelated_histories], flag: '--allow-unrelated-histories', type: :boolean }
1500
+ ].freeze
993
1501
 
994
- arr_opts = []
995
- arr_opts << remote if remote
996
- arr_opts << branch if branch
997
- command('pull', *arr_opts)
1502
+ def pull(remote = nil, branch = nil, opts = {})
1503
+ raise ArgumentError, 'You must specify a remote if a branch is specified' if remote.nil? && !branch.nil?
1504
+
1505
+ ArgsBuilder.validate!(opts, PULL_OPTION_MAP)
1506
+
1507
+ flags = build_args(opts, PULL_OPTION_MAP)
1508
+ positional_args = [remote, branch].compact
1509
+
1510
+ command('pull', *flags, *positional_args)
998
1511
  end
999
1512
 
1000
1513
  def tag_sha(tag_name)
1001
1514
  head = File.join(@git_dir, 'refs', 'tags', tag_name)
1002
1515
  return File.read(head).chomp if File.exist?(head)
1003
1516
 
1004
- command('show-ref', '--tags', '-s', tag_name)
1517
+ begin
1518
+ command('show-ref', '--tags', '-s', tag_name)
1519
+ rescue Git::FailedError => e
1520
+ raise unless e.result.status.exitstatus == 1 && e.result.stderr == ''
1521
+
1522
+ ''
1523
+ end
1005
1524
  end
1006
1525
 
1007
1526
  def repack
@@ -1012,89 +1531,117 @@ module Git
1012
1531
  command('gc', '--prune', '--aggressive', '--auto')
1013
1532
  end
1014
1533
 
1015
- # reads a tree into the current index file
1534
+ FSCK_OPTION_MAP = [
1535
+ { flag: '--no-progress', type: :static },
1536
+ { keys: [:unreachable], flag: '--unreachable', type: :boolean },
1537
+ { keys: [:strict], flag: '--strict', type: :boolean },
1538
+ { keys: [:connectivity_only], flag: '--connectivity-only', type: :boolean },
1539
+ { keys: [:root], flag: '--root', type: :boolean },
1540
+ { keys: [:tags], flag: '--tags', type: :boolean },
1541
+ { keys: [:cache], flag: '--cache', type: :boolean },
1542
+ { keys: [:no_reflogs], flag: '--no-reflogs', type: :boolean },
1543
+ { keys: [:lost_found], flag: '--lost-found', type: :boolean },
1544
+ { keys: [:dangling], flag: '--dangling', type: :boolean_negatable },
1545
+ { keys: [:full], flag: '--full', type: :boolean_negatable },
1546
+ { keys: [:name_objects], flag: '--name-objects', type: :boolean_negatable },
1547
+ { keys: [:references], flag: '--references', type: :boolean_negatable }
1548
+ ].freeze
1549
+
1550
+ def fsck(*objects, **opts)
1551
+ args = ArgsBuilder.build(opts, FSCK_OPTION_MAP)
1552
+ args.concat(objects) unless objects.empty?
1553
+ # fsck returns non-zero exit status when issues are found:
1554
+ # 1 = errors found, 2 = missing objects, 4 = warnings
1555
+ # We still want to parse the output in these cases
1556
+ output = begin
1557
+ command('fsck', *args)
1558
+ rescue Git::FailedError => e
1559
+ raise unless [1, 2, 4].include?(e.result.status.exitstatus)
1560
+
1561
+ e.result.stdout
1562
+ end
1563
+ parse_fsck_output(output)
1564
+ end
1565
+
1566
+ READ_TREE_OPTION_MAP = [
1567
+ { keys: [:prefix], flag: '--prefix', type: :valued_equals }
1568
+ ].freeze
1569
+
1016
1570
  def read_tree(treeish, opts = {})
1017
- arr_opts = []
1018
- arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix]
1019
- arr_opts += [treeish]
1020
- command('read-tree', *arr_opts)
1571
+ ArgsBuilder.validate!(opts, READ_TREE_OPTION_MAP)
1572
+ flags = build_args(opts, READ_TREE_OPTION_MAP)
1573
+ command('read-tree', *flags, treeish)
1021
1574
  end
1022
1575
 
1023
1576
  def write_tree
1024
1577
  command('write-tree')
1025
1578
  end
1026
1579
 
1580
+ COMMIT_TREE_OPTION_MAP = [
1581
+ { keys: %i[parent parents], flag: '-p', type: :repeatable_valued_space },
1582
+ { keys: [:message], flag: '-m', type: :valued_space }
1583
+ ].freeze
1584
+
1027
1585
  def commit_tree(tree, opts = {})
1028
1586
  opts[:message] ||= "commit tree #{tree}"
1029
- t = Tempfile.new('commit-message')
1030
- t.write(opts[:message])
1031
- t.close
1587
+ ArgsBuilder.validate!(opts, COMMIT_TREE_OPTION_MAP)
1032
1588
 
1033
- arr_opts = []
1034
- arr_opts << tree
1035
- 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}")
1589
+ flags = build_args(opts, COMMIT_TREE_OPTION_MAP)
1590
+ command('commit-tree', tree, *flags)
1038
1591
  end
1039
1592
 
1040
1593
  def update_ref(ref, commit)
1041
1594
  command('update-ref', ref, commit)
1042
1595
  end
1043
1596
 
1597
+ CHECKOUT_INDEX_OPTION_MAP = [
1598
+ { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1599
+ { keys: [:force], flag: '--force', type: :boolean },
1600
+ { keys: [:all], flag: '--all', type: :boolean },
1601
+ { keys: [:path_limiter], type: :validate_only }
1602
+ ].freeze
1603
+
1044
1604
  def checkout_index(opts = {})
1045
- arr_opts = []
1046
- arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix]
1047
- arr_opts << "--force" if opts[:force]
1048
- arr_opts << "--all" if opts[:all]
1049
- arr_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String
1605
+ ArgsBuilder.validate!(opts, CHECKOUT_INDEX_OPTION_MAP)
1606
+ args = build_args(opts, CHECKOUT_INDEX_OPTION_MAP)
1607
+
1608
+ if (path = opts[:path_limiter]) && path.is_a?(String)
1609
+ args.push('--', path)
1610
+ end
1050
1611
 
1051
- command('checkout-index', *arr_opts)
1612
+ command('checkout-index', *args)
1052
1613
  end
1053
1614
 
1054
- # creates an archive file
1055
- #
1056
- # options
1057
- # :format (zip, tar)
1058
- # :prefix
1059
- # :remote
1060
- # :path
1615
+ ARCHIVE_OPTION_MAP = [
1616
+ { keys: [:prefix], flag: '--prefix', type: :valued_equals },
1617
+ { keys: [:remote], flag: '--remote', type: :valued_equals },
1618
+ # These options are used by helpers or handled manually
1619
+ { keys: [:path], type: :validate_only },
1620
+ { keys: [:format], type: :validate_only },
1621
+ { keys: [:add_gzip], type: :validate_only }
1622
+ ].freeze
1623
+
1061
1624
  def archive(sha, file = nil, opts = {})
1062
- opts[:format] ||= 'zip'
1625
+ ArgsBuilder.validate!(opts, ARCHIVE_OPTION_MAP)
1626
+ file ||= temp_file_name
1627
+ format, gzip = parse_archive_format_options(opts)
1063
1628
 
1064
- if opts[:format] == 'tgz'
1065
- opts[:format] = 'tar'
1066
- opts[:add_gzip] = true
1067
- end
1629
+ args = build_args(opts, ARCHIVE_OPTION_MAP)
1630
+ args.unshift("--format=#{format}")
1631
+ args << sha
1632
+ args.push('--', opts[:path]) if opts[:path]
1068
1633
 
1069
- if !file
1070
- tempfile = Tempfile.new('archive')
1071
- file = tempfile.path
1072
- # delete it now, before we write to it, so that Ruby doesn't delete it
1073
- # when it finalizes the Tempfile.
1074
- tempfile.close!
1075
- end
1634
+ File.open(file, 'wb') { |f| command('archive', *args, out: f) }
1635
+ apply_gzip(file) if gzip
1076
1636
 
1077
- arr_opts = []
1078
- arr_opts << "--format=#{opts[:format]}" if opts[:format]
1079
- arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix]
1080
- arr_opts << "--remote=#{opts[:remote]}" if opts[:remote]
1081
- arr_opts << sha
1082
- arr_opts << '--' << opts[:path] if opts[:path]
1083
- command('archive', *arr_opts, redirect: " > #{escape file}")
1084
- if opts[:add_gzip]
1085
- file_content = File.read(file)
1086
- Zlib::GzipWriter.open(file) do |gz|
1087
- gz.write(file_content)
1088
- end
1089
- end
1090
- return file
1637
+ file
1091
1638
  end
1092
1639
 
1093
1640
  # returns the current version of git, as an Array of Fixnums.
1094
1641
  def current_command_version
1095
1642
  output = command('version')
1096
1643
  version = output[/\d+(\.\d+)+/]
1097
- version_parts = version.split('.').collect { |i| i.to_i }
1644
+ version_parts = version.split('.').collect(&:to_i)
1098
1645
  version_parts.fill(0, version_parts.length...3)
1099
1646
  end
1100
1647
 
@@ -1115,117 +1662,616 @@ module Git
1115
1662
  end
1116
1663
 
1117
1664
  def required_command_version
1118
- [1, 6]
1665
+ [2, 28]
1119
1666
  end
1120
1667
 
1121
1668
  def meets_required_version?
1122
- (self.current_command_version <=> self.required_command_version) >= 0
1669
+ (current_command_version <=> required_command_version) >= 0
1123
1670
  end
1124
1671
 
1125
- def self.warn_if_old_command(lib)
1672
+ def self.warn_if_old_command(lib) # rubocop:disable Naming/PredicateMethod
1673
+ Git::Deprecation.warn('Git::Lib#warn_if_old_command is deprecated. Use meets_required_version?.')
1674
+
1126
1675
  return true if @version_checked
1676
+
1127
1677
  @version_checked = true
1128
1678
  unless lib.meets_required_version?
1129
- $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."
1679
+ warn "[WARNING] The git gem requires git #{lib.required_command_version.join('.')} or later, " \
1680
+ "but only found #{lib.current_command_version.join('.')}. You should probably upgrade."
1130
1681
  end
1131
1682
  true
1132
1683
  end
1133
1684
 
1685
+ COMMAND_ARG_DEFAULTS = {
1686
+ out: nil,
1687
+ err: nil,
1688
+ normalize: true,
1689
+ chomp: true,
1690
+ merge: false,
1691
+ chdir: nil,
1692
+ timeout: nil # Don't set to Git.config.timeout here since it is mutable
1693
+ }.freeze
1694
+
1695
+ STATIC_GLOBAL_OPTS = %w[
1696
+ -c core.quotePath=true
1697
+ -c color.ui=false
1698
+ -c color.advice=false
1699
+ -c color.diff=false
1700
+ -c color.grep=false
1701
+ -c color.push=false
1702
+ -c color.remote=false
1703
+ -c color.showBranch=false
1704
+ -c color.status=false
1705
+ -c color.transport=false
1706
+ ].freeze
1707
+
1708
+ LOG_OPTION_MAP = [
1709
+ { type: :static, flag: '--no-color' },
1710
+ { keys: [:all], flag: '--all', type: :boolean },
1711
+ { keys: [:cherry], flag: '--cherry', type: :boolean },
1712
+ { keys: [:since], flag: '--since', type: :valued_equals },
1713
+ { keys: [:until], flag: '--until', type: :valued_equals },
1714
+ { keys: [:grep], flag: '--grep', type: :valued_equals },
1715
+ { keys: [:author], flag: '--author', type: :valued_equals },
1716
+ { keys: [:count], flag: '--max-count', type: :valued_equals },
1717
+ { keys: [:between], type: :custom, builder: ->(value) { "#{value[0]}..#{value[1]}" if value } }
1718
+ ].freeze
1719
+
1720
+ FSCK_OBJECT_PATTERN = /\A(dangling|missing|unreachable) (\w+) ([0-9a-f]{40})(?: \((.+)\))?\z/
1721
+ FSCK_WARNING_PATTERN = /\Awarning in (\w+) ([0-9a-f]{40}): (.+)\z/
1722
+ FSCK_ROOT_PATTERN = /\Aroot ([0-9a-f]{40})\z/
1723
+ FSCK_TAGGED_PATTERN = /\Atagged (\w+) ([0-9a-f]{40}) \((.+)\) in ([0-9a-f]{40})\z/
1724
+
1725
+ private_constant :FSCK_OBJECT_PATTERN, :FSCK_WARNING_PATTERN, :FSCK_ROOT_PATTERN, :FSCK_TAGGED_PATTERN
1726
+
1134
1727
  private
1135
1728
 
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']
1729
+ def parse_fsck_output(output)
1730
+ result = { dangling: [], missing: [], unreachable: [], warnings: [], root: [], tagged: [] }
1731
+ output.each_line { |line| parse_fsck_line(line.strip, result) }
1732
+ Git::FsckResult.new(**result)
1733
+ end
1140
1734
 
1141
- def command_lines(cmd, *opts, chdir: nil)
1142
- cmd_op = command(cmd, *opts, chdir: chdir)
1143
- if cmd_op.encoding.name != "UTF-8"
1144
- op = cmd_op.encode("UTF-8", "binary", :invalid => :replace, :undef => :replace)
1145
- else
1146
- op = cmd_op
1147
- end
1148
- op.split("\n")
1735
+ def parse_fsck_line(line, result)
1736
+ parse_fsck_object_line(line, result) ||
1737
+ parse_fsck_warning_line(line, result) ||
1738
+ parse_fsck_root_line(line, result) ||
1739
+ parse_fsck_tagged_line(line, result)
1740
+ end
1741
+
1742
+ def parse_fsck_object_line(line, result)
1743
+ return unless (match = FSCK_OBJECT_PATTERN.match(line))
1744
+
1745
+ result[match[1].to_sym] << Git::FsckObject.new(type: match[2].to_sym, sha: match[3], name: match[4])
1746
+ end
1747
+
1748
+ def parse_fsck_warning_line(line, result)
1749
+ return unless (match = FSCK_WARNING_PATTERN.match(line))
1750
+
1751
+ result[:warnings] << Git::FsckObject.new(type: match[1].to_sym, sha: match[2], message: match[3])
1752
+ end
1753
+
1754
+ def parse_fsck_root_line(line, result)
1755
+ return unless (match = FSCK_ROOT_PATTERN.match(line))
1756
+
1757
+ result[:root] << Git::FsckObject.new(type: :commit, sha: match[1])
1758
+ end
1759
+
1760
+ def parse_fsck_tagged_line(line, result)
1761
+ return unless (match = FSCK_TAGGED_PATTERN.match(line))
1762
+
1763
+ result[:tagged] << Git::FsckObject.new(type: match[1].to_sym, sha: match[2], name: match[3])
1149
1764
  end
1150
1765
 
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]
1766
+ def parse_diff_path_status(args)
1767
+ command_lines('diff', *args).each_with_object({}) do |line, memo|
1768
+ status, path = split_status_line(line)
1769
+ memo[path] = status
1156
1770
  end
1157
1771
  end
1158
1772
 
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]
1773
+ def build_checkout_positional_args(branch, opts)
1774
+ args = []
1775
+ if opts[:new_branch] || opts[:b]
1776
+ args.push('-b', branch)
1777
+ args << opts[:start_point] if opts[:start_point]
1778
+ elsif branch
1779
+ args << branch
1163
1780
  end
1781
+ args
1164
1782
  end
1165
1783
 
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
1784
+ def build_args(opts, option_map)
1785
+ Git::ArgsBuilder.new(opts, option_map).build
1172
1786
  end
1173
1787
 
1174
- # Runs a block inside an environment with customized ENV variables.
1175
- # It restores the ENV after execution.
1176
- #
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()
1788
+ def initialize_from_base(base_object)
1789
+ @git_dir = base_object.repo.to_s
1790
+ @git_index_file = base_object.index&.to_s
1791
+ @git_work_dir = base_object.dir&.to_s
1792
+ @git_ssh = base_object.git_ssh
1793
+ end
1794
+
1795
+ def initialize_from_hash(base_hash)
1796
+ @git_dir = base_hash[:repository]
1797
+ @git_index_file = base_hash[:index]
1798
+ @git_work_dir = base_hash[:working_directory]
1799
+ @git_ssh = base_hash.key?(:git_ssh) ? base_hash[:git_ssh] : :use_global_config
1800
+ end
1801
+
1802
+ def return_base_opts_from_clone(clone_dir, opts)
1803
+ base_opts = {}
1804
+ base_opts[:repository] = clone_dir if opts[:bare] || opts[:mirror]
1805
+ base_opts[:working_directory] = clone_dir unless opts[:bare] || opts[:mirror]
1806
+ base_opts[:log] = opts[:log] if opts[:log]
1807
+ base_opts[:git_ssh] = opts[:git_ssh] if opts.key?(:git_ssh)
1808
+ base_opts
1809
+ end
1810
+
1811
+ def process_commit_headers(data)
1812
+ headers = { 'parent' => [] } # Pre-initialize for multiple parents
1813
+ each_cat_file_header(data) do |key, value|
1814
+ if key == 'parent'
1815
+ headers['parent'] << value
1816
+ else
1817
+ headers[key] = value
1818
+ end
1183
1819
  end
1184
- ensure
1185
- restore_git_system_env_variables()
1820
+ headers
1186
1821
  end
1187
1822
 
1188
- def command(*cmd, redirect: '', chomp: true, chdir: nil, &block)
1189
- Git::Lib.warn_if_old_command(self)
1823
+ def parse_branch_line(line, index, all_lines)
1824
+ match_data = match_branch_line(line, index, all_lines)
1190
1825
 
1191
- raise 'cmd can not include a nested array' if cmd.any? { |o| o.is_a? Array }
1826
+ return nil if match_data[:not_a_branch] || match_data[:detached_ref]
1192
1827
 
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'
1828
+ format_branch_data(match_data)
1829
+ end
1198
1830
 
1199
- escaped_cmd = cmd.map { |part| escape(part) }.join(' ')
1831
+ def match_branch_line(line, index, all_lines)
1832
+ match_data = line.match(BRANCH_LINE_REGEXP)
1833
+ raise Git::UnexpectedResultError, unexpected_branch_line_error(all_lines, line, index) unless match_data
1200
1834
 
1201
- global_opts = global_opts.map { |s| escape(s) }.join(' ')
1835
+ match_data
1836
+ end
1202
1837
 
1203
- git_cmd = "#{Git::Base.config.binary_path} #{global_opts} #{escaped_cmd} #{redirect} 2>&1"
1838
+ def format_branch_data(match_data)
1839
+ [
1840
+ match_data[:refname],
1841
+ !match_data[:current].nil?,
1842
+ !match_data[:worktree].nil?,
1843
+ match_data[:symref]
1844
+ ]
1845
+ end
1846
+
1847
+ def unexpected_branch_line_error(lines, line, index)
1848
+ <<~ERROR
1849
+ Unexpected line in output from `git branch -a`, line #{index + 1}
1204
1850
 
1205
- output = nil
1851
+ Full output:
1852
+ #{lines.join("\n ")}
1206
1853
 
1207
- command_thread = nil;
1854
+ Line #{index + 1}:
1855
+ "#{line}"
1856
+ ERROR
1857
+ end
1208
1858
 
1209
- status = nil
1859
+ def get_branch_state(branch_name)
1860
+ command('rev-parse', '--verify', '--quiet', branch_name)
1861
+ :active
1862
+ rescue Git::FailedError => e
1863
+ # An exit status of 1 with empty stderr from `rev-parse --verify`
1864
+ # indicates a ref that exists but does not yet point to a commit.
1865
+ raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
1210
1866
 
1211
- with_custom_env_variables do
1212
- command_thread = Thread.new do
1213
- output, status = run_command(git_cmd, chdir, &block)
1867
+ :unborn
1868
+ end
1869
+
1870
+ def execute_grep_command(args)
1871
+ command_lines('grep', *args)
1872
+ rescue Git::FailedError => e
1873
+ # `git grep` returns 1 when no lines are selected.
1874
+ raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty?
1875
+
1876
+ [] # Return an empty array for "no matches found"
1877
+ end
1878
+
1879
+ def parse_grep_output(lines)
1880
+ lines.each_with_object(Hash.new { |h, k| h[k] = [] }) do |line, hsh|
1881
+ match = line.match(/\A(.*?):(\d+):(.*)/)
1882
+ next unless match
1883
+
1884
+ _full, filename, line_num, text = match.to_a
1885
+ hsh[filename] << [line_num.to_i, text]
1886
+ end
1887
+ end
1888
+
1889
+ def parse_diff_stats_output(lines)
1890
+ file_stats = parse_stat_lines(lines)
1891
+ build_final_stats_hash(file_stats)
1892
+ end
1893
+
1894
+ def parse_stat_lines(lines)
1895
+ lines.map do |line|
1896
+ insertions_s, deletions_s, filename = split_status_line(line)
1897
+ {
1898
+ filename: filename,
1899
+ insertions: insertions_s.to_i,
1900
+ deletions: deletions_s.to_i
1901
+ }
1902
+ end
1903
+ end
1904
+
1905
+ def split_status_line(line)
1906
+ parts = line.split("\t")
1907
+ parts[-1] = unescape_quoted_path(parts[-1]) if parts.any?
1908
+ parts
1909
+ end
1910
+
1911
+ def build_final_stats_hash(file_stats)
1912
+ {
1913
+ total: build_total_stats(file_stats),
1914
+ files: build_files_hash(file_stats)
1915
+ }
1916
+ end
1917
+
1918
+ def build_total_stats(file_stats)
1919
+ insertions = file_stats.sum { |s| s[:insertions] }
1920
+ deletions = file_stats.sum { |s| s[:deletions] }
1921
+ {
1922
+ insertions: insertions,
1923
+ deletions: deletions,
1924
+ lines: insertions + deletions,
1925
+ files: file_stats.size
1926
+ }
1927
+ end
1928
+
1929
+ def build_files_hash(file_stats)
1930
+ file_stats.to_h { |s| [s[:filename], s.slice(:insertions, :deletions)] }
1931
+ end
1932
+
1933
+ def parse_ls_remote_output(lines)
1934
+ lines.each_with_object(Hash.new { |h, k| h[k] = {} }) do |line, hsh|
1935
+ type, name, value = parse_ls_remote_line(line)
1936
+ if name
1937
+ hsh[type][name] = value
1938
+ else # Handles the HEAD entry, which has no name
1939
+ hsh[type].update(value)
1214
1940
  end
1215
- command_thread.join
1216
1941
  end
1942
+ end
1943
+
1944
+ def parse_ls_remote_line(line)
1945
+ sha, info = line.split("\t", 2)
1946
+ ref, type, name = info.split('/', 3)
1947
+
1948
+ type ||= 'head'
1949
+ type = 'branches' if type == 'heads'
1950
+
1951
+ value = { ref: ref, sha: sha }
1952
+
1953
+ [type, name, value]
1954
+ end
1955
+
1956
+ def stash_log_lines
1957
+ path = File.join(@git_dir, 'logs/refs/stash')
1958
+ return [] unless File.exist?(path)
1959
+
1960
+ File.readlines(path, chomp: true)
1961
+ end
1962
+
1963
+ def parse_stash_log_line(line, index)
1964
+ full_message = line.split("\t", 2).last
1965
+ match_data = full_message.match(/^[^:]+:(.*)$/)
1966
+ message = match_data ? match_data[1] : full_message
1967
+
1968
+ [index, message.strip]
1969
+ end
1970
+
1971
+ # Writes the staged content of a conflicted file to an IO stream
1972
+ #
1973
+ # @param path [String] the path to the file in the index
1974
+ #
1975
+ # @param stage [Integer] the stage of the file to show (e.g., 2 for 'ours', 3 for 'theirs')
1976
+ #
1977
+ # @param out_io [IO] the IO object to write the staged content to
1978
+ #
1979
+ # @return [IO] the IO object that was written to
1980
+ #
1981
+ def write_staged_content(path, stage, out_io)
1982
+ command('show', ":#{stage}:#{path}", out: out_io)
1983
+ out_io
1984
+ end
1985
+
1986
+ def validate_tag_options!(opts)
1987
+ is_annotated = opts[:a] || opts[:annotate]
1988
+ has_message = opts[:m] || opts[:message]
1989
+
1990
+ return unless is_annotated && !has_message
1991
+
1992
+ raise ArgumentError, 'Cannot create an annotated tag without a message.'
1993
+ end
1994
+
1995
+ def normalize_push_args(remote, branch, opts)
1996
+ if branch.is_a?(Hash)
1997
+ opts = branch
1998
+ branch = nil
1999
+ elsif remote.is_a?(Hash)
2000
+ opts = remote
2001
+ remote = nil
2002
+ end
2003
+
2004
+ opts ||= {}
2005
+ # Backwards compatibility for `push(remote, branch, true)`
2006
+ opts = { tags: opts } if [true, false].include?(opts)
2007
+ [remote, branch, opts]
2008
+ end
2009
+
2010
+ def build_push_args(remote, branch, opts)
2011
+ # Build the simple flags using the ArgsBuilder
2012
+ args = build_args(opts, PUSH_OPTION_MAP)
2013
+
2014
+ # Manually handle the flag with external dependencies and positional args
2015
+ args << '--all' if opts[:all] && remote
2016
+ args << remote if remote
2017
+ args << branch if branch
2018
+ args
2019
+ end
2020
+
2021
+ def temp_file_name
2022
+ tempfile = Tempfile.new('archive')
2023
+ file = tempfile.path
2024
+ tempfile.close! # Prevents Ruby from deleting the file on garbage collection
2025
+ file
2026
+ end
2027
+
2028
+ def parse_archive_format_options(opts)
2029
+ format = opts[:format] || 'zip'
2030
+ gzip = opts[:add_gzip] == true || format == 'tgz'
2031
+ format = 'tar' if format == 'tgz'
2032
+ [format, gzip]
2033
+ end
2034
+
2035
+ def apply_gzip(file)
2036
+ file_content = File.read(file)
2037
+ Zlib::GzipWriter.open(file) { |gz| gz.write(file_content) }
2038
+ end
2039
+
2040
+ def command_lines(cmd, *opts, chdir: nil)
2041
+ cmd_op = command(cmd, *opts, chdir: chdir)
2042
+ op = if cmd_op.encoding.name == 'UTF-8'
2043
+ cmd_op
2044
+ else
2045
+ cmd_op.encode('UTF-8', 'binary', invalid: :replace, undef: :replace)
2046
+ end
2047
+ op.split("\n")
2048
+ end
2049
+
2050
+ # Returns a hash of environment variable overrides for git commands
2051
+ #
2052
+ # This method builds a hash of environment variables that control git's behavior,
2053
+ # such as the git directory, working tree, and index file locations.
2054
+ #
2055
+ # @param additional_overrides [Hash] additional environment variables to set or unset
2056
+ #
2057
+ # Keys should be environment variable names (String) and values should be either:
2058
+ # * A String value to set the environment variable
2059
+ # * `nil` to unset the environment variable
2060
+ #
2061
+ # Per Process.spawn semantics, setting a key to `nil` will unset that environment
2062
+ # variable, removing it from the environment passed to the git command.
2063
+ #
2064
+ # @return [Hash<String, String|nil>] environment variable overrides
2065
+ #
2066
+ # @example Basic usage with default environment variables
2067
+ # env_overrides
2068
+ # # => { 'GIT_DIR' => '/path/to/.git', 'GIT_WORK_TREE' => '/path/to/worktree', ... }
2069
+ #
2070
+ # @example Adding a custom environment variable
2071
+ # env_overrides('GIT_TRACE' => '1')
2072
+ # # => { 'GIT_DIR' => '/path/to/.git', ..., 'GIT_TRACE' => '1' }
2073
+ #
2074
+ # @example Unsetting an environment variable (used by worktree_command_line)
2075
+ # env_overrides('GIT_INDEX_FILE' => nil)
2076
+ # # => { 'GIT_DIR' => '/path/to/.git', 'GIT_WORK_TREE' => '/path/to/worktree',
2077
+ # # 'GIT_INDEX_FILE' => nil, 'GIT_SSH' => <git_ssh_value>, 'LC_ALL' => <pinned_locale> }
2078
+ # # When passed to Process.spawn, GIT_INDEX_FILE will be unset in the environment
2079
+ #
2080
+ # @see https://ruby-doc.org/core/Process.html#method-c-spawn Process.spawn
2081
+ #
2082
+ # @api private
2083
+ #
2084
+ def env_overrides(**additional_overrides)
2085
+ {
2086
+ 'GIT_DIR' => @git_dir,
2087
+ 'GIT_WORK_TREE' => @git_work_dir,
2088
+ 'GIT_INDEX_FILE' => @git_index_file,
2089
+ 'GIT_SSH' => resolved_git_ssh,
2090
+ # Pin the locale so git's behavior does not depend on the user's environment.
2091
+ # Added for issue #753, where a German user's `git branch` output
2092
+ # ("* (HEAD losgelöst bei origin/25.1)") broke branch parsing.
2093
+ #
2094
+ # The pin has two halves, and both are load-bearing here:
2095
+ #
2096
+ # - Messages: keeps git's human-readable text in English. `BRANCH_LINE_REGEXP`
2097
+ # matches the literal English strings "(HEAD detached at ...)" and
2098
+ # "(not a branch)" in `git branch -a` output, which is the exact issue #753
2099
+ # failure mode.
2100
+ # - Ctype: makes git's regex engine match *characters* rather than *bytes*.
2101
+ # Under a C ctype, `grep` and `log --grep` silently return wrong answers —
2102
+ # with exit status zero — for any pattern whose metacharacters span
2103
+ # non-ASCII text.
2104
+ #
2105
+ # So do not "simplify" this to `C`: that yields English messages and a broken
2106
+ # ctype, which is the worst of both. A pinned locale the host does not have
2107
+ # degrades to that same C ctype, which is why each branch below has to name a
2108
+ # locale that actually exists on the platform it applies to:
2109
+ #
2110
+ # - Non-Darwin: `C.UTF-8`. Stock Debian, Ubuntu, and RHEL images generate no
2111
+ # `en_US.UTF-8`, so pinning it there produced exactly the silent breakage
2112
+ # above. (musl ignores the locale name for ctype, so Alpine is UTF-8 either
2113
+ # way.)
2114
+ # - Darwin: `en_US.UTF-8`, which every macOS release ships. `C.UTF-8` did not
2115
+ # arrive until macOS 15, so macOS 11–14 — including Intel Macs that are
2116
+ # hardware-capped below 15 — would break under it. Delete this branch once
2117
+ # macOS 14 and earlier are out of support.
2118
+ #
2119
+ # Windows takes the non-Darwin branch, where the value makes no difference:
2120
+ # Git for Windows folds case and runs PCRE in UTF mode under every value, and
2121
+ # matches bytes for `.` and POSIX classes under every value — measured on git
2122
+ # 2.55.0 against `en_US.UTF-8`, `C.UTF-8`, `C`, and no pin at all.
2123
+ #
2124
+ # RHEL 7 (glibc 2.17) has neither locale and gets a C ctype whatever is pinned.
2125
+ # It is EOL, and there is deliberately no public override for this value.
2126
+ 'LC_ALL' => darwin_platform? ? 'en_US.UTF-8' : 'C.UTF-8'
2127
+ }.merge(additional_overrides)
2128
+ end
2129
+
2130
+ # Whether this process is running on macOS
2131
+ #
2132
+ # Checks `RUBY_DESCRIPTION` as well as `RUBY_PLATFORM` because JRuby reports
2133
+ # `RUBY_PLATFORM` as `"java"` on every host and records the real platform only in
2134
+ # `RUBY_DESCRIPTION` (as, for example, `"... [arm64-darwin]"`). Testing
2135
+ # `RUBY_PLATFORM` alone would put JRuby on macOS onto the non-Darwin branch, which is
2136
+ # the one platform pairing that branch must not be given. This is the same detection
2137
+ # the test suite uses for Windows, and for the same reason.
2138
+ #
2139
+ # Test the platform plainly rather than sniffing the Darwin version: `RUBY_PLATFORM`
2140
+ # records the version Ruby was *built* against, so a Ruby built on macOS 14 still
2141
+ # reports `darwin23` when run on macOS 15.
2142
+ #
2143
+ # @return [Boolean] true if this process is running on macOS
2144
+ #
2145
+ # @api private
2146
+ #
2147
+ def darwin_platform?
2148
+ RUBY_PLATFORM.include?('darwin') || RUBY_DESCRIPTION.include?('darwin')
2149
+ end
1217
2150
 
1218
- @logger.info(git_cmd)
1219
- @logger.debug(output)
2151
+ # Resolve the git_ssh value to use for this instance
2152
+ #
2153
+ # @return [String, nil] the resolved git_ssh value
2154
+ #
2155
+ # Returns the global config value if @git_ssh is the sentinel :use_global_config,
2156
+ # otherwise returns @git_ssh (which may be nil or a string)
2157
+ #
2158
+ # @api private
2159
+ #
2160
+ def resolved_git_ssh
2161
+ return Git::Base.config.git_ssh if @git_ssh == :use_global_config
1220
2162
 
1221
- if status.exitstatus > 1 || (status.exitstatus == 1 && output != '')
1222
- result = Git::CommandLineResult.new(git_cmd, status, output, '')
1223
- raise Git::FailedError.new(result)
2163
+ @git_ssh
2164
+ end
2165
+
2166
+ def global_opts
2167
+ [].tap do |global_opts|
2168
+ global_opts << "--git-dir=#{@git_dir}" unless @git_dir.nil?
2169
+ global_opts << "--work-tree=#{@git_work_dir}" unless @git_work_dir.nil?
2170
+ global_opts.concat(STATIC_GLOBAL_OPTS)
1224
2171
  end
2172
+ end
2173
+
2174
+ def command_line
2175
+ @command_line ||=
2176
+ Git::CommandLine.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger)
2177
+ end
2178
+
2179
+ # Returns a command line instance without GIT_INDEX_FILE for worktree commands
2180
+ #
2181
+ # Git worktrees manage their own index files and setting GIT_INDEX_FILE
2182
+ # causes corruption of both the main worktree and new worktree indexes.
2183
+ #
2184
+ # @return [Git::CommandLine]
2185
+ # @api private
2186
+ #
2187
+ def worktree_command_line
2188
+ @worktree_command_line ||=
2189
+ Git::CommandLine.new(env_overrides('GIT_INDEX_FILE' => nil), Git::Base.config.binary_path, global_opts,
2190
+ @logger)
2191
+ end
1225
2192
 
1226
- output.chomp! if output && chomp && !block_given?
2193
+ # @overload worktree_command(*args, **options_hash)
2194
+ # Runs a git worktree command and returns the output
2195
+ #
2196
+ # This method is similar to #command but uses a command line instance
2197
+ # that excludes GIT_INDEX_FILE from the environment to prevent index corruption.
2198
+ #
2199
+ # @param args [Array<String>] the command arguments
2200
+ # @param options_hash [Hash] the options to pass to the command
2201
+ #
2202
+ # @return [String] the command's stdout
2203
+ #
2204
+ # @see #command
2205
+ #
2206
+ # @api private
2207
+ #
2208
+ def worktree_command(*, **options_hash)
2209
+ options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
2210
+ options_hash[:timeout] ||= Git.config.timeout
1227
2211
 
1228
- output
2212
+ extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
2213
+ raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2214
+
2215
+ result = worktree_command_line.run(*, **options_hash)
2216
+ result.stdout
2217
+ end
2218
+
2219
+ # Runs a git command and returns the output
2220
+ #
2221
+ # Additional args are passed to the command line. They should exclude the 'git'
2222
+ # command itself and global options. Remember to splat the the arguments if given
2223
+ # as an array.
2224
+ #
2225
+ # For example, to run `git log --pretty=oneline`, you would create the array
2226
+ # `args = ['log', '--pretty=oneline']` and call `command(*args)`.
2227
+ #
2228
+ # @param options_hash [Hash] the options to pass to the command
2229
+ # @option options_hash [IO, String, #write, nil] :out the destination for captured stdout
2230
+ # @option options_hash [IO, String, #write, nil] :err the destination for captured stderr
2231
+ # @option options_hash [Boolean] :normalize true to normalize the output encoding to UTF-8
2232
+ # @option options_hash [Boolean] :chomp true to remove trailing newlines from the output
2233
+ # @option options_hash [Boolean] :merge true to merge stdout and stderr into a single output
2234
+ # @option options_hash [String, nil] :chdir the directory to run the command in
2235
+ # @option options_hash [Numeric, nil] :timeout the maximum seconds to wait for the command to complete
2236
+ #
2237
+ # If timeout is nil, the global timeout from {Git::Config} is used.
2238
+ #
2239
+ # If timeout is zero, the timeout will not be enforced.
2240
+ #
2241
+ # If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised.
2242
+ #
2243
+ # If the command does not respond to SIGKILL, it will hang this method.
2244
+ #
2245
+ # @see Git::CommandLine#run
2246
+ #
2247
+ # @return [String] the command's stdout (or merged stdout and stderr if `merge`
2248
+ # is true)
2249
+ #
2250
+ # @raise [ArgumentError] if an unknown option is passed
2251
+ #
2252
+ # @raise [Git::FailedError] if the command failed
2253
+ #
2254
+ # @raise [Git::SignaledError] if the command was signaled
2255
+ #
2256
+ # @raise [Git::TimeoutError] if the command times out
2257
+ #
2258
+ # @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output
2259
+ #
2260
+ # The exception's `result` attribute is a {Git::CommandLineResult} which will
2261
+ # contain the result of the command including the exit status, stdout, and
2262
+ # stderr.
2263
+ #
2264
+ # @api private
2265
+ #
2266
+ def command(*, **options_hash)
2267
+ options_hash = COMMAND_ARG_DEFAULTS.merge(options_hash)
2268
+ options_hash[:timeout] ||= Git.config.timeout
2269
+
2270
+ extra_options = options_hash.keys - COMMAND_ARG_DEFAULTS.keys
2271
+ raise ArgumentError, "Unknown options: #{extra_options.join(', ')}" if extra_options.any?
2272
+
2273
+ result = command_line.run(*, **options_hash)
2274
+ result.stdout
1229
2275
  end
1230
2276
 
1231
2277
  # Takes the diff command line output (as Array) and parse it into a Hash
@@ -1233,23 +2279,18 @@ module Git
1233
2279
  # @param [String] diff_command the diff commadn to be used
1234
2280
  # @param [Array] opts the diff options to be used
1235
2281
  # @return [Hash] the diff as Hash
1236
- def diff_as_hash(diff_command, opts=[])
2282
+ def diff_as_hash(diff_command, opts = [])
1237
2283
  # update index before diffing to avoid spurious diffs
1238
2284
  command('status')
1239
- command_lines(diff_command, *opts).inject({}) do |memo, line|
1240
- info, file = line.split("\t")
2285
+ command_lines(diff_command, *opts).each_with_object({}) do |line, memo|
2286
+ info, file = split_status_line(line)
1241
2287
  mode_src, mode_dest, sha_src, sha_dest, type = info.split
1242
2288
 
1243
2289
  memo[file] = {
1244
- :mode_index => mode_dest,
1245
- :mode_repo => mode_src.to_s[1, 7],
1246
- :path => file,
1247
- :sha_repo => sha_src,
1248
- :sha_index => sha_dest,
1249
- :type => type
2290
+ mode_index: mode_dest, mode_repo: mode_src.to_s[1, 7],
2291
+ path: file, sha_repo: sha_src, sha_index: sha_dest,
2292
+ type: type
1250
2293
  }
1251
-
1252
- memo
1253
2294
  end
1254
2295
  end
1255
2296
 
@@ -1258,23 +2299,11 @@ module Git
1258
2299
  # @param [Hash] opts the given options
1259
2300
  # @return [Array] the set of common options that the log command will use
1260
2301
  def log_common_options(opts)
1261
- arr_opts = []
1262
-
1263
2302
  if opts[:count] && !opts[:count].is_a?(Integer)
1264
2303
  raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}"
1265
2304
  end
1266
2305
 
1267
- arr_opts << "--max-count=#{opts[:count]}" if opts[:count]
1268
- arr_opts << "--all" if opts[:all]
1269
- arr_opts << "--no-color"
1270
- arr_opts << "--cherry" if opts[:cherry]
1271
- arr_opts << "--since=#{opts[:since]}" if opts[:since].is_a? String
1272
- arr_opts << "--until=#{opts[:until]}" if opts[:until].is_a? String
1273
- arr_opts << "--grep=#{opts[:grep]}" if opts[:grep].is_a? String
1274
- arr_opts << "--author=#{opts[:author]}" if opts[:author].is_a? String
1275
- arr_opts << "#{opts[:between][0].to_s}..#{opts[:between][1].to_s}" if (opts[:between] && opts[:between].size == 2)
1276
-
1277
- arr_opts
2306
+ build_args(opts, LOG_OPTION_MAP)
1278
2307
  end
1279
2308
 
1280
2309
  # Retrurns an array holding path options for the log commands
@@ -1292,37 +2321,13 @@ module Git
1292
2321
  arr_opts
1293
2322
  end
1294
2323
 
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
2324
+ def log_or_empty_on_unborn
2325
+ yield
2326
+ rescue Git::FailedError => e
2327
+ raise unless e.result.status.exitstatus == 128 &&
2328
+ e.result.stderr =~ /does not have any commits yet/
1321
2329
 
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
2330
+ []
1326
2331
  end
1327
2332
  end
1328
2333
  end